diff --git a/CHANGELOG.md b/CHANGELOG.md index e0d6aa845..c775398c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ - Deps: bump Pi to 0.40.0 and drop pi-ai patch (upstream 429 fix). (#543) — thanks @mcinteerj - Security: per-agent mention patterns and group elevated directives now require explicit mention to avoid cross-agent toggles. - Config: support inline env vars in config (`env.*` / `env.vars`) and document env precedence. +- Config: migrate routing/agent config into agents.list/agents.defaults and messages/tools/audio with default agent selection and per-agent identity config. - Agent: enable adaptive context pruning by default for tool-result trimming. - Doctor: check config/state permissions and offer to tighten them. — thanks @steipete - Doctor/Daemon: audit supervisor configs, add --repair/--force flows, surface service config audits in daemon status, and document user vs system services. — thanks @steipete @@ -109,8 +110,8 @@ - New default: DM pairing (`dmPolicy="pairing"` / `discord.dm.policy="pairing"` / `slack.dm.policy="pairing"`). - To keep old “open to everyone” behavior: set `dmPolicy="open"` and include `"*"` in the relevant `allowFrom` (Discord/Slack: `discord.dm.allowFrom` / `slack.dm.allowFrom`). - Approve requests via `clawdbot pairing list --provider ` + `clawdbot pairing approve --provider `. -- Sandbox: default `agent.sandbox.scope` to `"agent"` (one container/workspace per agent). Use `"session"` for per-session isolation; `"shared"` disables cross-session isolation. -- Timestamps in agent envelopes are now UTC (compact `YYYY-MM-DDTHH:mmZ`); removed `messages.timestampPrefix`. Add `agent.userTimezone` to tell the model the user’s local time (system prompt only). +- Sandbox: default `agents.defaults.sandbox.scope` to `"agent"` (one container/workspace per agent). Use `"session"` for per-session isolation; `"shared"` disables cross-session isolation. +- Timestamps in agent envelopes are now UTC (compact `YYYY-MM-DDTHH:mmZ`); removed `messages.timestampPrefix`. Add `agents.defaults.userTimezone` to tell the model the user’s local time (system prompt only). - Model config schema changes (auth profiles + model lists); doctor auto-migrates and the gateway rewrites legacy configs on startup. - Commands: gate all slash commands to authorized senders; add `/compact` to manually compact session context. - Groups: `whatsapp.groups`, `telegram.groups`, and `imessage.groups` now act as allowlists when set. Add `"*"` to keep allow-all behavior. @@ -136,7 +137,7 @@ ## 2026.1.5 ### Highlights -- Models: add image-specific model config (`agent.imageModel` + fallbacks) and scan support. +- Models: add image-specific model config (`agents.defaults.imageModel` + fallbacks) and scan support. - Agent tools: new `image` tool routed to the image model (when configured). - Config: default model shorthands (`opus`, `sonnet`, `gpt`, `gpt-mini`, `gemini`, `gemini-flash`). - Docs: document built-in model shorthands + precedence (user config wins). @@ -161,7 +162,7 @@ - Agent tools: OpenAI-compatible tool JSON Schemas (fix `browser`, normalize union schemas). - Onboarding: when running from source, auto-build missing Control UI assets (`bun run ui:build`). - Discord/Slack: route reaction + system notifications to the correct session (no main-session bleed). -- Agent tools: honor `agent.tools` allow/deny policy even when sandbox is off. +- Agent tools: honor `tools.allow` / `tools.deny` policy even when sandbox is off. - Discord: avoid duplicate replies when OpenAI emits repeated `message_end` events. - Commands: unify /status (inline) and command auth across providers; group bypass for authorized control commands; remove Discord /clawd slash handler. - CLI: run `clawdbot agent` via the Gateway by default; use `--local` to force embedded mode. diff --git a/README.md b/README.md index 18ea6ca16..fb56738af 100644 --- a/README.md +++ b/README.md @@ -284,7 +284,7 @@ Runbook: [iOS connect](https://docs.clawd.bot/ios). ## Agent workspace + skills -- Workspace root: `~/clawd` (configurable via `agent.workspace`). +- Workspace root: `~/clawd` (configurable via `agents.defaults.workspace`). - Injected prompt files: `AGENTS.md`, `SOUL.md`, `TOOLS.md`. - Skills: `~/clawd/skills//SKILL.md`. @@ -305,7 +305,7 @@ Minimal `~/.clawdbot/clawdbot.json` (model + defaults): ## Security model (important) - **Default:** tools run on the host for the **main** session, so the agent has full access when it’s just you. -- **Group/channel safety:** set `agent.sandbox.mode: "non-main"` to run **non‑main sessions** (groups/channels) inside per‑session Docker sandboxes; bash then runs in Docker for those sessions. +- **Group/channel safety:** set `agents.defaults.sandbox.mode: "non-main"` to run **non‑main sessions** (groups/channels) inside per‑session Docker sandboxes; bash then runs in Docker for those sessions. - **Sandbox defaults:** allowlist `bash`, `process`, `read`, `write`, `edit`, `sessions_list`, `sessions_history`, `sessions_send`, `sessions_spawn`; denylist `browser`, `canvas`, `nodes`, `cron`, `discord`, `gateway`. Details: [Security guide](https://docs.clawd.bot/security) · [Docker + sandboxing](https://docs.clawd.bot/docker) · [Sandbox config](https://docs.clawd.bot/configuration) diff --git a/apps/macos/Sources/Clawdbot/ClawdbotConfigFile.swift b/apps/macos/Sources/Clawdbot/ClawdbotConfigFile.swift index 83d38b79a..6b2169010 100644 --- a/apps/macos/Sources/Clawdbot/ClawdbotConfigFile.swift +++ b/apps/macos/Sources/Clawdbot/ClawdbotConfigFile.swift @@ -81,22 +81,33 @@ enum ClawdbotConfigFile { static func agentWorkspace() -> String? { let root = self.loadDict() - let agent = root["agent"] as? [String: Any] - return agent?["workspace"] as? String + let agents = root["agents"] as? [String: Any] + let defaults = agents?["defaults"] as? [String: Any] + return defaults?["workspace"] as? String } static func setAgentWorkspace(_ workspace: String?) { var root = self.loadDict() - var agent = root["agent"] as? [String: Any] ?? [:] + var agents = root["agents"] as? [String: Any] ?? [:] + var defaults = agents["defaults"] as? [String: Any] ?? [:] let trimmed = workspace?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" if trimmed.isEmpty { - agent.removeValue(forKey: "workspace") + defaults.removeValue(forKey: "workspace") } else { - agent["workspace"] = trimmed + defaults["workspace"] = trimmed + } + if defaults.isEmpty { + agents.removeValue(forKey: "defaults") + } else { + agents["defaults"] = defaults + } + if agents.isEmpty { + root.removeValue(forKey: "agents") + } else { + root["agents"] = agents } - root["agent"] = agent self.saveDict(root) - self.logger.debug("agent workspace updated set=\(!trimmed.isEmpty)") + self.logger.debug("agents.defaults.workspace updated set=\(!trimmed.isEmpty)") } static func gatewayPassword() -> String? { diff --git a/apps/macos/Sources/Clawdbot/ConfigSettings.swift b/apps/macos/Sources/Clawdbot/ConfigSettings.swift index d1a48b2b3..7e5501793 100644 --- a/apps/macos/Sources/Clawdbot/ConfigSettings.swift +++ b/apps/macos/Sources/Clawdbot/ConfigSettings.swift @@ -387,13 +387,20 @@ struct ConfigSettings: View { private func loadConfig() async { let parsed = await ConfigStore.load() - let agent = parsed["agent"] as? [String: Any] - let heartbeatMinutes = agent?["heartbeatMinutes"] as? Int - let heartbeatBody = agent?["heartbeatBody"] as? String + let agents = parsed["agents"] as? [String: Any] + let defaults = agents?["defaults"] as? [String: Any] + let heartbeat = defaults?["heartbeat"] as? [String: Any] + let heartbeatEvery = heartbeat?["every"] as? String + let heartbeatBody = heartbeat?["prompt"] as? String let browser = parsed["browser"] as? [String: Any] let talk = parsed["talk"] as? [String: Any] - let loadedModel = (agent?["model"] as? String) ?? "" + let loadedModel: String = { + if let raw = defaults?["model"] as? String { return raw } + if let modelDict = defaults?["model"] as? [String: Any], + let primary = modelDict["primary"] as? String { return primary } + return "" + }() if !loadedModel.isEmpty { self.configModel = loadedModel self.customModel = loadedModel @@ -402,7 +409,13 @@ struct ConfigSettings: View { self.customModel = SessionLoader.fallbackModel } - if let heartbeatMinutes { self.heartbeatMinutes = heartbeatMinutes } + if let heartbeatEvery { + let digits = heartbeatEvery.trimmingCharacters(in: .whitespacesAndNewlines) + .prefix { $0.isNumber } + if let minutes = Int(digits) { + self.heartbeatMinutes = minutes + } + } if let heartbeatBody, !heartbeatBody.isEmpty { self.heartbeatBody = heartbeatBody } if let browser { @@ -480,25 +493,49 @@ struct ConfigSettings: View { @MainActor private static func buildAndSaveConfig(_ draft: ConfigDraft) async -> String? { var root = await ConfigStore.load() - var agent = root["agent"] as? [String: Any] ?? [:] + var agents = root["agents"] as? [String: Any] ?? [:] + var defaults = agents["defaults"] as? [String: Any] ?? [:] var browser = root["browser"] as? [String: Any] ?? [:] var talk = root["talk"] as? [String: Any] ?? [:] let chosenModel = (draft.configModel == "__custom__" ? draft.customModel : draft.configModel) .trimmingCharacters(in: .whitespacesAndNewlines) let trimmedModel = chosenModel - if !trimmedModel.isEmpty { agent["model"] = trimmedModel } + if !trimmedModel.isEmpty { + var model = defaults["model"] as? [String: Any] ?? [:] + model["primary"] = trimmedModel + defaults["model"] = model + + var models = defaults["models"] as? [String: Any] ?? [:] + if models[trimmedModel] == nil { + models[trimmedModel] = [:] + } + defaults["models"] = models + } if let heartbeatMinutes = draft.heartbeatMinutes { - agent["heartbeatMinutes"] = heartbeatMinutes + var heartbeat = defaults["heartbeat"] as? [String: Any] ?? [:] + heartbeat["every"] = "\(heartbeatMinutes)m" + defaults["heartbeat"] = heartbeat } let trimmedBody = draft.heartbeatBody.trimmingCharacters(in: .whitespacesAndNewlines) if !trimmedBody.isEmpty { - agent["heartbeatBody"] = trimmedBody + var heartbeat = defaults["heartbeat"] as? [String: Any] ?? [:] + heartbeat["prompt"] = trimmedBody + defaults["heartbeat"] = heartbeat } - root["agent"] = agent + if defaults.isEmpty { + agents.removeValue(forKey: "defaults") + } else { + agents["defaults"] = defaults + } + if agents.isEmpty { + root.removeValue(forKey: "agents") + } else { + root["agents"] = agents + } browser["enabled"] = draft.browserEnabled let trimmedUrl = draft.browserControlUrl.trimmingCharacters(in: .whitespacesAndNewlines) diff --git a/apps/macos/Sources/Clawdbot/OnboardingView+Pages.swift b/apps/macos/Sources/Clawdbot/OnboardingView+Pages.swift index 9ee1d266a..326504ec6 100644 --- a/apps/macos/Sources/Clawdbot/OnboardingView+Pages.swift +++ b/apps/macos/Sources/Clawdbot/OnboardingView+Pages.swift @@ -607,7 +607,7 @@ extension OnboardingView { let saved = await self.saveAgentWorkspace(AgentWorkspace.displayPath(for: url)) if saved { self.workspaceStatus = - "Saved to ~/.clawdbot/clawdbot.json (agent.workspace)" + "Saved to ~/.clawdbot/clawdbot.json (agents.defaults.workspace)" } } } diff --git a/apps/macos/Sources/Clawdbot/OnboardingView+Workspace.swift b/apps/macos/Sources/Clawdbot/OnboardingView+Workspace.swift index fa35a0af2..c1d2e54f6 100644 --- a/apps/macos/Sources/Clawdbot/OnboardingView+Workspace.swift +++ b/apps/macos/Sources/Clawdbot/OnboardingView+Workspace.swift @@ -69,8 +69,9 @@ extension OnboardingView { private func loadAgentWorkspace() async -> String? { let root = await ConfigStore.load() - let agent = root["agent"] as? [String: Any] - return agent?["workspace"] as? String + let agents = root["agents"] as? [String: Any] + let defaults = agents?["defaults"] as? [String: Any] + return defaults?["workspace"] as? String } @discardableResult @@ -86,17 +87,23 @@ extension OnboardingView { @MainActor private static func buildAndSaveWorkspace(_ workspace: String?) async -> (Bool, String?) { var root = await ConfigStore.load() - var agent = root["agent"] as? [String: Any] ?? [:] + var agents = root["agents"] as? [String: Any] ?? [:] + var defaults = agents["defaults"] as? [String: Any] ?? [:] let trimmed = workspace?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" if trimmed.isEmpty { - agent.removeValue(forKey: "workspace") + defaults.removeValue(forKey: "workspace") } else { - agent["workspace"] = trimmed + defaults["workspace"] = trimmed } - if agent.isEmpty { - root.removeValue(forKey: "agent") + if defaults.isEmpty { + agents.removeValue(forKey: "defaults") } else { - root["agent"] = agent + agents["defaults"] = defaults + } + if agents.isEmpty { + root.removeValue(forKey: "agents") + } else { + root["agents"] = agents } do { try await ConfigStore.save(root) diff --git a/docs/automation/gmail-pubsub.md b/docs/automation/gmail-pubsub.md index 2c94c2c2c..9995c9bc9 100644 --- a/docs/automation/gmail-pubsub.md +++ b/docs/automation/gmail-pubsub.md @@ -63,7 +63,7 @@ If you want a fixed channel, set `provider` + `to`. Otherwise `provider: "last"` uses the last delivery route (falls back to WhatsApp). To force a cheaper model for Gmail runs, set `model` in the mapping -(`provider/model` or alias). If you enforce `agent.models`, include it there. +(`provider/model` or alias). If you enforce `agents.defaults.models`, include it there. To customize payload handling further, add `hooks.mappings` or a JS/TS transform module under `hooks.transformsDir` (see [`docs/webhook.md`](https://docs.clawd.bot/automation/webhook)). diff --git a/docs/automation/webhook.md b/docs/automation/webhook.md index 45a6ca4a2..1535bae4c 100644 --- a/docs/automation/webhook.md +++ b/docs/automation/webhook.md @@ -134,7 +134,7 @@ curl -X POST http://127.0.0.1:18789/hooks/agent \ -d '{"message":"Summarize inbox","name":"Email","model":"openai/gpt-5.2-mini"}' ``` -If you enforce `agent.models`, make sure the override model is included there. +If you enforce `agents.defaults.models`, make sure the override model is included there. ```bash curl -X POST http://127.0.0.1:18789/hooks/gmail \ diff --git a/docs/cli/index.md b/docs/cli/index.md index e61fc9e94..9734a0951 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -488,10 +488,10 @@ Options: Always includes the auth overview and OAuth expiry status for profiles in the auth store. ### `models set ` -Set `agent.model.primary`. +Set `agents.defaults.model.primary`. ### `models set-image ` -Set `agent.imageModel.primary`. +Set `agents.defaults.imageModel.primary`. ### `models aliases list|add|remove` Options: diff --git a/docs/concepts/agent-loop.md b/docs/concepts/agent-loop.md index 5a1190687..2b737b591 100644 --- a/docs/concepts/agent-loop.md +++ b/docs/concepts/agent-loop.md @@ -42,7 +42,7 @@ Short, exact flow of one agent run. ## Timeouts - `agent.wait` default: 30s (just the wait). `timeoutMs` param overrides. -- Agent runtime: `agent.timeoutSeconds` default 600s; enforced in `runEmbeddedPiAgent` abort timer. +- Agent runtime: `agents.defaults.timeoutSeconds` default 600s; enforced in `runEmbeddedPiAgent` abort timer. ## Where things can end early - Agent timeout (abort) diff --git a/docs/concepts/agent-workspace.md b/docs/concepts/agent-workspace.md index edf43e889..cc159a847 100644 --- a/docs/concepts/agent-workspace.md +++ b/docs/concepts/agent-workspace.md @@ -15,7 +15,7 @@ sessions. **Important:** the workspace is the **default cwd**, not a hard sandbox. Tools resolve relative paths against the workspace, but absolute paths can still reach elsewhere on the host unless sandboxing is enabled. If you need isolation, use -[`agent.sandbox`](/gateway/sandboxing) (and/or per‑agent sandbox config). +[`agents.defaults.sandbox`](/gateway/sandboxing) (and/or per‑agent sandbox config). When sandboxing is enabled and `workspaceAccess` is not `"rw"`, tools operate inside a sandbox workspace under `~/.clawdbot/sandboxes`, not your host workspace. @@ -53,7 +53,7 @@ only one workspace is active at a time. **Recommendation:** keep a single active workspace. If you no longer use the legacy folders, archive or move them to Trash (for example `trash ~/clawdis`). If you intentionally keep multiple workspaces, make sure -`agent.workspace` points to the active one. +`agents.defaults.workspace` points to the active one. `clawdbot doctor` warns when it detects legacy workspace directories. @@ -207,7 +207,7 @@ Suggested `.gitignore` starter: ## Moving the workspace to a new machine 1. Clone the repo to the desired path (default `~/clawd`). -2. Set `agent.workspace` to that path in `~/.clawdbot/clawdbot.json`. +2. Set `agents.defaults.workspace` to that path in `~/.clawdbot/clawdbot.json`. 3. Run `clawdbot setup --workspace ` to seed any missing files. 4. If you need sessions, copy `~/.clawdbot/agents//sessions/` from the old machine separately. @@ -216,5 +216,5 @@ Suggested `.gitignore` starter: - Multi-agent routing can use different workspaces per agent. See `docs/provider-routing.md` for routing configuration. -- If `agent.sandbox` is enabled, non-main sessions can use per-session sandbox - workspaces under `agent.sandbox.workspaceRoot`. +- If `agents.defaults.sandbox` is enabled, non-main sessions can use per-session sandbox + workspaces under `agents.defaults.sandbox.workspaceRoot`. diff --git a/docs/concepts/agent.md b/docs/concepts/agent.md index f970da7f2..13dcd75bc 100644 --- a/docs/concepts/agent.md +++ b/docs/concepts/agent.md @@ -9,19 +9,19 @@ CLAWDBOT runs a single embedded agent runtime derived from **p-mono**. ## Workspace (required) -CLAWDBOT uses a single agent workspace directory (`agent.workspace`) as the agent’s **only** working directory (`cwd`) for tools and context. +CLAWDBOT uses a single agent workspace directory (`agents.defaults.workspace`) as the agent’s **only** working directory (`cwd`) for tools and context. Recommended: use `clawdbot setup` to create `~/.clawdbot/clawdbot.json` if missing and initialize the workspace files. Full workspace layout + backup guide: [`docs/agent-workspace.md`](/concepts/agent-workspace) -If `agent.sandbox` is enabled, non-main sessions can override this with -per-session workspaces under `agent.sandbox.workspaceRoot` (see +If `agents.defaults.sandbox` is enabled, non-main sessions can override this with +per-session workspaces under `agents.defaults.sandbox.workspaceRoot` (see [`docs/configuration.md`](/gateway/configuration)). ## Bootstrap files (injected) -Inside `agent.workspace`, CLAWDBOT expects these user-editable files: +Inside `agents.defaults.workspace`, CLAWDBOT expects these user-editable files: - `AGENTS.md` — operating instructions + “memory” - `SOUL.md` — persona, boundaries, tone - `TOOLS.md` — user-maintained tool notes (e.g. `imsg`, `sag`, conventions) @@ -84,9 +84,9 @@ current turn ends, then a new agent turn starts with the queued payloads. See [`docs/queue.md`](/concepts/queue) for mode + debounce/cap behavior. Block streaming sends completed assistant blocks as soon as they finish; disable -via `agent.blockStreamingDefault: "off"` if you only want the final response. -Tune the boundary via `agent.blockStreamingBreak` (`text_end` vs `message_end`; defaults to text_end). -Control soft block chunking with `agent.blockStreamingChunk` (defaults to +via `agents.defaults.blockStreamingDefault: "off"` if you only want the final response. +Tune the boundary via `agents.defaults.blockStreamingBreak` (`text_end` vs `message_end`; defaults to text_end). +Control soft block chunking with `agents.defaults.blockStreamingChunk` (defaults to 800–1200 chars; prefers paragraph breaks, then newlines; sentences last). Verbose tool summaries are emitted at tool start (no debounce); Control UI streams tool output via agent events when available. @@ -95,7 +95,7 @@ More details: [Streaming + chunking](/concepts/streaming). ## Configuration (minimal) At minimum, set: -- `agent.workspace` +- `agents.defaults.workspace` - `whatsapp.allowFrom` (strongly recommended) --- diff --git a/docs/concepts/group-messages.md b/docs/concepts/group-messages.md index 7d9092e53..9c103605b 100644 --- a/docs/concepts/group-messages.md +++ b/docs/concepts/group-messages.md @@ -7,7 +7,7 @@ read_when: Goal: let Clawd sit in WhatsApp groups, wake up only when pinged, and keep that thread separate from the personal DM session. -Note: `routing.groupChat.mentionPatterns` is now used by Telegram/Discord/Slack/iMessage as well; this doc focuses on WhatsApp-specific behavior. For multi-agent setups, you can override per agent with `routing.agents..mentionPatterns`. +Note: `agents.list[].groupChat.mentionPatterns` is now used by Telegram/Discord/Slack/iMessage as well; this doc focuses on WhatsApp-specific behavior. For multi-agent setups, set `agents.list[].groupChat.mentionPatterns` per agent (or use `messages.groupChat.mentionPatterns` as a global fallback). ## What’s implemented (2025-12-03) - Activation modes: `mention` (default) or `always`. `mention` requires a ping (real WhatsApp @-mentions via `mentionedJids`, regex patterns, or the bot’s E.164 anywhere in the text). `always` wakes the agent on every message but it should reply only when it can add meaningful value; otherwise it returns the silent token `NO_REPLY`. Defaults can be set in config (`whatsapp.groups`) and overridden per group via `/activation`. When `whatsapp.groups` is set, it also acts as a group allowlist (include `"*"` to allow all). @@ -28,16 +28,21 @@ Add a `groupChat` block to `~/.clawdbot/clawdbot.json` so display-name pings wor "*": { "requireMention": true } } }, - "routing": { - "groupChat": { - "historyLimit": 50, - "mentionPatterns": [ - "@?clawd", - "@?clawd\\s*uk", - "@?clawdbot", - "\\+?447700900123" - ] - } + "agents": { + "list": [ + { + "id": "main", + "groupChat": { + "historyLimit": 50, + "mentionPatterns": [ + "@?clawd", + "@?clawd\\s*uk", + "@?clawdbot", + "\\+?447700900123" + ] + } + } + ] } } ``` @@ -70,4 +75,4 @@ Only the owner number (from `whatsapp.allowFrom`, or the bot’s own E.164 when - Heartbeats are intentionally skipped for groups to avoid noisy broadcasts. - Echo suppression uses the combined batch string; if you send identical text twice without mentions, only the first will get a response. - Session store entries will appear as `agent::whatsapp:group:` in the session store (`~/.clawdbot/agents//sessions/sessions.json` by default); a missing entry just means the group hasn’t triggered a run yet. -- Typing indicators in groups follow `agent.typingMode` (default: `message` when unmentioned). +- Typing indicators in groups follow `agents.defaults.typingMode` (default: `message` when unmentioned). diff --git a/docs/concepts/groups.md b/docs/concepts/groups.md index 27020c6d6..7358ab187 100644 --- a/docs/concepts/groups.md +++ b/docs/concepts/groups.md @@ -88,11 +88,16 @@ Group messages require a mention unless overridden per group. Defaults live per "123": { requireMention: false } } }, - routing: { - groupChat: { - mentionPatterns: ["@clawd", "clawdbot", "\\+15555550123"], - historyLimit: 50 - } + agents: { + list: [ + { + id: "main", + groupChat: { + mentionPatterns: ["@clawd", "clawdbot", "\\+15555550123"], + historyLimit: 50 + } + } + ] } } ``` @@ -100,7 +105,7 @@ Group messages require a mention unless overridden per group. Defaults live per Notes: - `mentionPatterns` are case-insensitive regexes. - Surfaces that provide explicit mentions still pass; patterns are a fallback. -- Per-agent override: `routing.agents..mentionPatterns` (useful when multiple agents share a group). +- Per-agent override: `agents.list[].groupChat.mentionPatterns` (useful when multiple agents share a group). - Mention gating is only enforced when mention detection is possible (native mentions or `mentionPatterns` are configured). - Discord defaults live in `discord.guilds."*"` (overridable per guild/channel). diff --git a/docs/concepts/model-failover.md b/docs/concepts/model-failover.md index b6e660d01..fcaef512f 100644 --- a/docs/concepts/model-failover.md +++ b/docs/concepts/model-failover.md @@ -9,7 +9,7 @@ read_when: Clawdbot handles failures in two stages: 1) **Auth profile rotation** within the current provider. -2) **Model fallback** to the next model in `agent.model.fallbacks`. +2) **Model fallback** to the next model in `agents.defaults.model.fallbacks`. This doc explains the runtime rules and the data that backs them. @@ -82,14 +82,14 @@ State is stored in `auth-profiles.json` under `usageStats`: ## Model fallback If all profiles for a provider fail, Clawdbot moves to the next model in -`agent.model.fallbacks`. This applies to auth failures, rate limits, and +`agents.defaults.model.fallbacks`. This applies to auth failures, rate limits, and timeouts that exhausted profile rotation. ## Related config See [`docs/configuration.md`](/gateway/configuration) for: - `auth.profiles` / `auth.order` -- `agent.model.primary` / `agent.model.fallbacks` -- `agent.imageModel` routing +- `agents.defaults.model.primary` / `agents.defaults.model.fallbacks` +- `agents.defaults.imageModel` routing See [`docs/models.md`](/concepts/models) for the broader model selection and fallback overview. diff --git a/docs/concepts/models.md b/docs/concepts/models.md index 67e786211..aa8317b00 100644 --- a/docs/concepts/models.md +++ b/docs/concepts/models.md @@ -14,20 +14,20 @@ rotation, cooldowns, and how that interacts with fallbacks. Clawdbot selects models in this order: -1) **Primary** model (`agent.model.primary` or `agent.model`). -2) **Fallbacks** in `agent.model.fallbacks` (in order). +1) **Primary** model (`agents.defaults.model.primary` or `agents.defaults.model`). +2) **Fallbacks** in `agents.defaults.model.fallbacks` (in order). 3) **Provider auth failover** happens inside a provider before moving to the next model. Related: -- `agent.models` is the allowlist/catalog of models Clawdbot can use (plus aliases). -- `agent.imageModel` is used **only when** the primary model can’t accept images. +- `agents.defaults.models` is the allowlist/catalog of models Clawdbot can use (plus aliases). +- `agents.defaults.imageModel` is used **only when** the primary model can’t accept images. ## Config keys (overview) -- `agent.model.primary` and `agent.model.fallbacks` -- `agent.imageModel.primary` and `agent.imageModel.fallbacks` -- `agent.models` (allowlist + aliases + provider params) +- `agents.defaults.model.primary` and `agents.defaults.model.fallbacks` +- `agents.defaults.imageModel.primary` and `agents.defaults.imageModel.fallbacks` +- `agents.defaults.models` (allowlist + aliases + provider params) - `models.providers` (custom providers written into `models.json`) Model refs are normalized to lowercase. Provider aliases like `z.ai/*` normalize @@ -35,7 +35,7 @@ to `zai/*`. ## “Model is not allowed” (and why replies stop) -If `agent.models` is set, it becomes the **allowlist** for `/model` and for +If `agents.defaults.models` is set, it becomes the **allowlist** for `/model` and for session overrides. When a user selects a model that isn’t in that allowlist, Clawdbot returns: @@ -46,8 +46,8 @@ Model "provider/model" is not allowed. Use /model to list available models. This happens **before** a normal reply is generated, so the message can feel like it “didn’t respond.” The fix is to either: -- Add the model to `agent.models`, or -- Clear the allowlist (remove `agent.models`), or +- Add the model to `agents.defaults.models`, or +- Clear the allowlist (remove `agents.defaults.models`), or - Pick a model from `/model list`. Example allowlist config: @@ -123,8 +123,8 @@ Key flags: - `--max-age-days `: skip older models - `--provider `: provider prefix filter - `--max-candidates `: fallback list size -- `--set-default`: set `agent.model.primary` to the first selection -- `--set-image`: set `agent.imageModel.primary` to the first image selection +- `--set-default`: set `agents.defaults.model.primary` to the first selection +- `--set-image`: set `agents.defaults.imageModel.primary` to the first image selection Probing requires an OpenRouter API key (from auth profiles or `OPENROUTER_API_KEY`). Without a key, use `--no-probe` to list candidates only. diff --git a/docs/concepts/multi-agent.md b/docs/concepts/multi-agent.md index 67429b9e7..01f9362a4 100644 --- a/docs/concepts/multi-agent.md +++ b/docs/concepts/multi-agent.md @@ -32,7 +32,7 @@ reach other host locations unless sandboxing is enabled. See - Config: `~/.clawdbot/clawdbot.json` (or `CLAWDBOT_CONFIG_PATH`) - State dir: `~/.clawdbot` (or `CLAWDBOT_STATE_DIR`) - Workspace: `~/clawd` (or `~/clawd-`) -- Agent dir: `~/.clawdbot/agents//agent` (or `routing.agents..agentDir`) +- Agent dir: `~/.clawdbot/agents//agent` (or `agents.list[].agentDir`) - Sessions: `~/.clawdbot/agents//sessions` ### Single-agent mode (default) @@ -52,7 +52,7 @@ Use the agent wizard to add a new isolated agent: clawdbot agents add work ``` -Then add `routing.bindings` (or let the wizard do it) to route inbound messages. +Then add `bindings` (or let the wizard do it) to route inbound messages. Verify with: @@ -79,7 +79,7 @@ Bindings are **deterministic** and **most-specific wins**: 3. `teamId` (Slack) 4. `accountId` match for a provider 5. provider-level match (`accountId: "*"`) -6. fallback to `routing.defaultAgentId` (default: `main`) +6. fallback to default agent (`agents.list[].default`, else first list entry, default: `main`) ## Multiple accounts / phone numbers @@ -100,39 +100,42 @@ multiple phone numbers without mixing sessions. ```js { - routing: { - defaultAgentId: "home", - - agents: { - home: { + agents: { + list: [ + { + id: "home", + default: true, name: "Home", workspace: "~/clawd-home", agentDir: "~/.clawdbot/agents/home/agent", }, - work: { + { + id: "work", name: "Work", workspace: "~/clawd-work", agentDir: "~/.clawdbot/agents/work/agent", }, - }, - - // Deterministic routing: first match wins (most-specific first). - bindings: [ - { agentId: "home", match: { provider: "whatsapp", accountId: "personal" } }, - { agentId: "work", match: { provider: "whatsapp", accountId: "biz" } }, - - // Optional per-peer override (example: send a specific group to work agent). - { - agentId: "work", - match: { - provider: "whatsapp", - accountId: "personal", - peer: { kind: "group", id: "1203630...@g.us" }, - }, - }, ], + }, - // Off by default: agent-to-agent messaging must be explicitly enabled + allowlisted. + // Deterministic routing: first match wins (most-specific first). + bindings: [ + { agentId: "home", match: { provider: "whatsapp", accountId: "personal" } }, + { agentId: "work", match: { provider: "whatsapp", accountId: "biz" } }, + + // Optional per-peer override (example: send a specific group to work agent). + { + agentId: "work", + match: { + provider: "whatsapp", + accountId: "personal", + peer: { kind: "group", id: "1203630...@g.us" }, + }, + }, + ], + + // Off by default: agent-to-agent messaging must be explicitly enabled + allowlisted. + tools: { agentToAgent: { enabled: false, allow: ["home", "work"], @@ -160,16 +163,18 @@ Starting with v2026.1.6, each agent can have its own sandbox and tool restrictio ```js { - routing: { - agents: { - personal: { + agents: { + list: [ + { + id: "personal", workspace: "~/clawd-personal", sandbox: { mode: "off", // No sandbox for personal agent }, // No tool restrictions - all tools available }, - family: { + { + id: "family", workspace: "~/clawd-family", sandbox: { mode: "all", // Always sandboxed @@ -184,7 +189,7 @@ Starting with v2026.1.6, each agent can have its own sandbox and tool restrictio deny: ["bash", "write", "edit"], // Deny others }, }, - }, + ], }, } ``` @@ -194,8 +199,8 @@ Starting with v2026.1.6, each agent can have its own sandbox and tool restrictio - **Resource control**: Sandbox specific agents while keeping others on host - **Flexible policies**: Different permissions per agent -Note: `agent.elevated` is **global** and sender-based; it is not configurable per agent. -If you need per-agent boundaries, use `routing.agents[id].tools` to deny `bash`. -For group targeting, you can set `routing.agents[id].mentionPatterns` so @mentions map cleanly to the intended agent. +Note: `tools.elevated` is **global** and sender-based; it is not configurable per agent. +If you need per-agent boundaries, use `agents.list[].tools` to deny `bash`. +For group targeting, use `agents.list[].groupChat.mentionPatterns` so @mentions map cleanly to the intended agent. See [Multi-Agent Sandbox & Tools](/multi-agent-sandbox-tools) for detailed examples. diff --git a/docs/concepts/provider-routing.md b/docs/concepts/provider-routing.md index 2125c888c..958d3d83e 100644 --- a/docs/concepts/provider-routing.md +++ b/docs/concepts/provider-routing.md @@ -42,35 +42,33 @@ Examples: Routing picks **one agent** for each inbound message: -1. **Exact peer match** (`routing.bindings` with `peer.kind` + `peer.id`). +1. **Exact peer match** (`bindings` with `peer.kind` + `peer.id`). 2. **Guild match** (Discord) via `guildId`. 3. **Team match** (Slack) via `teamId`. 4. **Account match** (`accountId` on the provider). 5. **Provider match** (any account on that provider). -6. **Default agent** (`routing.defaultAgentId`, fallback to `main`). +6. **Default agent** (`agents.list[].default`, else first list entry, fallback to `main`). The matched agent determines which workspace and session store are used. ## Config overview -- `routing.defaultAgentId`: default agent when no binding matches. -- `routing.agents`: named agent definitions (workspace, model, etc.). -- `routing.bindings`: map inbound providers/accounts/peers to agents. +- `agents.list`: named agent definitions (workspace, model, etc.). +- `bindings`: map inbound providers/accounts/peers to agents. Example: ```json5 { - routing: { - defaultAgentId: "main", - agents: { - support: { name: "Support", workspace: "~/clawd-support" } - }, - bindings: [ - { match: { provider: "slack", teamId: "T123" }, agentId: "support" }, - { match: { provider: "telegram", peer: { kind: "group", id: "-100123" } }, agentId: "support" } + agents: { + list: [ + { id: "support", name: "Support", workspace: "~/clawd-support" } ] - } + }, + bindings: [ + { match: { provider: "slack", teamId: "T123" }, agentId: "support" }, + { match: { provider: "telegram", peer: { kind: "group", id: "-100123" } }, agentId: "support" } + ] } ``` diff --git a/docs/concepts/queue.md b/docs/concepts/queue.md index b175134e0..d5485a0ae 100644 --- a/docs/concepts/queue.md +++ b/docs/concepts/queue.md @@ -14,7 +14,7 @@ We now serialize command-based auto-replies (WhatsApp Web listener) through a ti ## How it works - A lane-aware FIFO queue drains each lane synchronously. - `runEmbeddedPiAgent` enqueues by **session key** (lane `session:`) to guarantee only one active run per session. -- Each session run is then queued into a **global lane** (`main` by default) so overall parallelism is capped by `agent.maxConcurrent`. +- Each session run is then queued into a **global lane** (`main` by default) so overall parallelism is capped by `agents.defaults.maxConcurrent`. - When verbose logging is enabled, queued commands emit a short notice if they waited more than ~2s before starting. - Typing indicators (`onReplyStart`) still fire immediately on enqueue so user experience is unchanged while we wait our turn. @@ -30,16 +30,16 @@ Inbound messages can steer the current run, wait for a followup turn, or do both Steer-backlog means you can get a followup response after the steered run, so streaming surfaces can look like duplicates. Prefer `collect`/`steer` if you want one response per inbound message. -Send `/queue collect` as a standalone command (per-session) or set `routing.queue.byProvider.discord: "collect"`. +Send `/queue collect` as a standalone command (per-session) or set `messages.queue.byProvider.discord: "collect"`. Defaults (when unset in config): - All surfaces → `collect` -Configure globally or per provider via `routing.queue`: +Configure globally or per provider via `messages.queue`: ```json5 { - routing: { + messages: { queue: { mode: "collect", debounceMs: 1000, @@ -67,7 +67,7 @@ Defaults: `debounceMs: 1000`, `cap: 20`, `drop: summarize`. ## Scope and guarantees - Applies only to config-driven command replies; plain text replies are unaffected. -- Default lane (`main`) is process-wide for inbound + main heartbeats; set `agent.maxConcurrent` to allow multiple sessions in parallel. +- Default lane (`main`) is process-wide for inbound + main heartbeats; set `agents.defaults.maxConcurrent` to allow multiple sessions in parallel. - Additional lanes may exist (e.g. `cron`) so background jobs can run in parallel without blocking inbound replies. - Per-session lanes guarantee that only one agent run touches a given session at a time. - No external dependencies or background worker threads; pure TypeScript + promises. diff --git a/docs/concepts/session-pruning.md b/docs/concepts/session-pruning.md index fa3e48fb4..6b54a655f 100644 --- a/docs/concepts/session-pruning.md +++ b/docs/concepts/session-pruning.md @@ -2,7 +2,7 @@ summary: "Session pruning: tool-result trimming to reduce context bloat" read_when: - You want to reduce LLM context growth from tool outputs - - You are tuning agent.contextPruning + - You are tuning agents.defaults.contextPruning --- # Session Pruning @@ -23,7 +23,7 @@ Session pruning trims **old tool results** from the in-memory context right befo Pruning uses an estimated context window (chars ≈ tokens × 4). The window size is resolved in this order: 1) Model definition `contextWindow` (from the model registry). 2) `models.providers.*.models[].contextWindow` override. -3) `agent.contextTokens`. +3) `agents.defaults.contextTokens`. 4) Default `200000` tokens. ## Modes diff --git a/docs/concepts/session-tool.md b/docs/concepts/session-tool.md index 55427fef3..9a611c495 100644 --- a/docs/concepts/session-tool.md +++ b/docs/concepts/session-tool.md @@ -132,19 +132,19 @@ Parameters: - `cleanup?` (`delete|keep`, default `keep`) Allowlist: -- `routing.agents..subagents.allowAgents`: list of agent ids allowed via `agentId` (`["*"]` to allow any). Default: only the requester agent. +- `agents.list[].subagents.allowAgents`: list of agent ids allowed via `agentId` (`["*"]` to allow any). Default: only the requester agent. Discovery: - Use `agents_list` to discover which agent ids are allowed for `sessions_spawn`. Behavior: - Starts a new `agent::subagent:` session with `deliver: false`. -- Sub-agents default to the full tool set **minus session tools** (configurable via `agent.subagents.tools`). +- Sub-agents default to the full tool set **minus session tools** (configurable via `tools.subagents.tools`). - Sub-agents are not allowed to call `sessions_spawn` (no sub-agent → sub-agent spawning). - Always non-blocking: returns `{ status: "accepted", runId, childSessionKey }` immediately. - After completion, Clawdbot runs a sub-agent **announce step** and posts the result to the requester chat provider. - Reply exactly `ANNOUNCE_SKIP` during the announce step to stay silent. -- Sub-agent sessions are auto-archived after `agent.subagents.archiveAfterMinutes` (default: 60). +- Sub-agent sessions are auto-archived after `agents.defaults.subagents.archiveAfterMinutes` (default: 60). - Announce replies include a stats line (runtime, tokens, sessionKey/sessionId, transcript path, and optional cost). ## Sandbox Session Visibility @@ -155,10 +155,12 @@ Config: ```json5 { - agent: { - sandbox: { - // default: "spawned" - sessionToolsVisibility: "spawned" // or "all" + agents: { + defaults: { + sandbox: { + // default: "spawned" + sessionToolsVisibility: "spawned" // or "all" + } } } } diff --git a/docs/concepts/streaming.md b/docs/concepts/streaming.md index de36a6b52..9d8f9e6c6 100644 --- a/docs/concepts/streaming.md +++ b/docs/concepts/streaming.md @@ -32,9 +32,9 @@ Legend: - `provider send`: actual outbound messages (block replies). **Controls:** -- `agent.blockStreamingDefault`: `"on"`/`"off"` (default on). -- `agent.blockStreamingBreak`: `"text_end"` or `"message_end"`. -- `agent.blockStreamingChunk`: `{ minChars, maxChars, breakPreference? }`. +- `agents.defaults.blockStreamingDefault`: `"on"`/`"off"` (default on). +- `agents.defaults.blockStreamingBreak`: `"text_end"` or `"message_end"`. +- `agents.defaults.blockStreamingChunk`: `{ minChars, maxChars, breakPreference? }`. - Provider hard cap: `*.textChunkLimit` (e.g., `whatsapp.textChunkLimit`). - Discord soft cap: `discord.maxLinesPerMessage` (default 17) splits tall replies to avoid UI clipping. diff --git a/docs/concepts/system-prompt.md b/docs/concepts/system-prompt.md index 14c862e3a..271204d3c 100644 --- a/docs/concepts/system-prompt.md +++ b/docs/concepts/system-prompt.md @@ -17,7 +17,7 @@ The prompt is intentionally compact and uses fixed sections: - **Tooling**: current tool list + short descriptions. - **Skills**: tells the model how to load skill instructions on demand. - **Clawdbot Self-Update**: how to run `config.apply` and `update.run`. -- **Workspace**: working directory (`agent.workspace`). +- **Workspace**: working directory (`agents.defaults.workspace`). - **Workspace Files (injected)**: indicates bootstrap files are included below. - **Time**: UTC default + the user’s local time (already converted). - **Reply Tags**: optional reply tag syntax for supported providers. @@ -43,9 +43,9 @@ Large files are truncated with a marker. Missing files inject a short missing-fi The Time line is compact and explicit: - Assume timestamps are **UTC** unless stated. -- The listed **user time** is already converted to `agent.userTimezone` (if set). +- The listed **user time** is already converted to `agents.defaults.userTimezone` (if set). -Use `agent.userTimezone` in `~/.clawdbot/clawdbot.json` to change the user time zone. +Use `agents.defaults.userTimezone` in `~/.clawdbot/clawdbot.json` to change the user time zone. ## Skills diff --git a/docs/concepts/timezone.md b/docs/concepts/timezone.md index 3269c610e..6d37c1293 100644 --- a/docs/concepts/timezone.md +++ b/docs/concepts/timezone.md @@ -26,7 +26,7 @@ These are typically UTC ISO strings (Discord) or UTC epoch strings (Slack). We d ## User timezone for the system prompt -Set `agent.userTimezone` to tell the model the user's local time zone. If it is +Set `agents.defaults.userTimezone` to tell the model the user's local time zone. If it is unset, Clawdbot resolves the **host timezone at runtime** (no config write). ```json5 diff --git a/docs/concepts/typing-indicators.md b/docs/concepts/typing-indicators.md index e3d92a46f..9143eb8ef 100644 --- a/docs/concepts/typing-indicators.md +++ b/docs/concepts/typing-indicators.md @@ -6,18 +6,18 @@ read_when: # Typing indicators Typing indicators are sent to the chat provider while a run is active. Use -`agent.typingMode` to control **when** typing starts and `typingIntervalSeconds` +`agents.defaults.typingMode` to control **when** typing starts and `typingIntervalSeconds` to control **how often** it refreshes. ## Defaults -When `agent.typingMode` is **unset**, Clawdbot keeps the legacy behavior: +When `agents.defaults.typingMode` is **unset**, Clawdbot keeps the legacy behavior: - **Direct chats**: typing starts immediately once the model loop begins. - **Group chats with a mention**: typing starts immediately. - **Group chats without a mention**: typing starts only when message text begins streaming. - **Heartbeat runs**: typing is disabled. ## Modes -Set `agent.typingMode` to one of: +Set `agents.defaults.typingMode` to one of: - `never` — no typing indicator, ever. - `instant` — start typing **as soon as the model loop begins**, even if the run later returns only the silent reply token. diff --git a/docs/experiments/research/memory.md b/docs/experiments/research/memory.md index 56523f186..59d35d22b 100644 --- a/docs/experiments/research/memory.md +++ b/docs/experiments/research/memory.md @@ -8,7 +8,7 @@ read_when: # Workspace Memory v2 (offline): research notes -Target: Clawd-style workspace (`agent.workspace`, default `~/clawd`) where “memory” is stored as one Markdown file per day (`memory/YYYY-MM-DD.md`) plus a small set of stable files (e.g. `memory.md`, `SOUL.md`). +Target: Clawd-style workspace (`agents.defaults.workspace`, default `~/clawd`) where “memory” is stored as one Markdown file per day (`memory/YYYY-MM-DD.md`) plus a small set of stable files (e.g. `memory.md`, `SOUL.md`). This doc proposes an **offline-first** memory architecture that keeps Markdown as the canonical, reviewable source of truth, but adds **structured recall** (search, entity summaries, confidence updates) via a derived index. @@ -159,7 +159,7 @@ Recommendation: **deep integration in Clawdbot**, but keep a separable core libr ### Why integrate into Clawdbot? - Clawdbot already knows: - - the workspace path (`agent.workspace`) + - the workspace path (`agents.defaults.workspace`) - the session model + heartbeats - logging + troubleshooting patterns - You want the agent itself to call the tools: diff --git a/docs/gateway/background-process.md b/docs/gateway/background-process.md index 3f97c844b..f3e819f5a 100644 --- a/docs/gateway/background-process.md +++ b/docs/gateway/background-process.md @@ -32,9 +32,9 @@ Environment overrides: - `PI_BASH_JOB_TTL_MS`: TTL for finished sessions (ms, bounded to 1m–3h) Config (preferred): -- `agent.bash.backgroundMs` (default 10000) -- `agent.bash.timeoutSec` (default 1800) -- `agent.bash.cleanupMs` (default 1800000) +- `tools.bash.backgroundMs` (default 10000) +- `tools.bash.timeoutSec` (default 1800) +- `tools.bash.cleanupMs` (default 1800000) ## process tool diff --git a/docs/gateway/configuration-examples.md b/docs/gateway/configuration-examples.md index ef97465b7..3bb47b681 100644 --- a/docs/gateway/configuration-examples.md +++ b/docs/gateway/configuration-examples.md @@ -189,52 +189,71 @@ Save to `~/.clawdbot/clawdbot.json` and you can DM the bot from that number. }, // Agent runtime - agent: { - workspace: "~/clawd", - userTimezone: "America/Chicago", - model: { - primary: "anthropic/claude-sonnet-4-5", - fallbacks: ["anthropic/claude-opus-4-5", "openai/gpt-5.2"] - }, - imageModel: { - primary: "openrouter/anthropic/claude-sonnet-4-5" - }, - models: { - "anthropic/claude-opus-4-5": { alias: "opus" }, - "anthropic/claude-sonnet-4-5": { alias: "sonnet" }, - "openai/gpt-5.2": { alias: "gpt" } - }, - thinkingDefault: "low", - verboseDefault: "off", - elevatedDefault: "on", - blockStreamingDefault: "on", - blockStreamingBreak: "text_end", - blockStreamingChunk: { - minChars: 800, - maxChars: 1200, - breakPreference: "paragraph" - }, - timeoutSeconds: 600, - mediaMaxMb: 5, - typingIntervalSeconds: 5, - maxConcurrent: 3, - tools: { - allow: ["bash", "process", "read", "write", "edit"], - deny: ["browser", "canvas"] - }, + agents: { + defaults: { + workspace: "~/clawd", + userTimezone: "America/Chicago", + model: { + primary: "anthropic/claude-sonnet-4-5", + fallbacks: ["anthropic/claude-opus-4-5", "openai/gpt-5.2"] + }, + imageModel: { + primary: "openrouter/anthropic/claude-sonnet-4-5" + }, + models: { + "anthropic/claude-opus-4-5": { alias: "opus" }, + "anthropic/claude-sonnet-4-5": { alias: "sonnet" }, + "openai/gpt-5.2": { alias: "gpt" } + }, + thinkingDefault: "low", + verboseDefault: "off", + elevatedDefault: "on", + blockStreamingDefault: "on", + blockStreamingBreak: "text_end", + blockStreamingChunk: { + minChars: 800, + maxChars: 1200, + breakPreference: "paragraph" + }, + timeoutSeconds: 600, + mediaMaxMb: 5, + typingIntervalSeconds: 5, + maxConcurrent: 3, + heartbeat: { + every: "30m", + model: "anthropic/claude-sonnet-4-5", + target: "last", + to: "+15555550123", + prompt: "HEARTBEAT", + ackMaxChars: 30 + }, + sandbox: { + mode: "non-main", + perSession: true, + workspaceRoot: "~/.clawdbot/sandboxes", + docker: { + image: "clawdbot-sandbox:bookworm-slim", + workdir: "/workspace", + readOnlyRoot: true, + tmpfs: ["/tmp", "/var/tmp", "/run"], + network: "none", + user: "1000:1000" + }, + browser: { + enabled: false + } + } + } + }, + + tools: { + allow: ["bash", "process", "read", "write", "edit"], + deny: ["browser", "canvas"], bash: { backgroundMs: 10000, timeoutSec: 1800, cleanupMs: 1800000 }, - heartbeat: { - every: "30m", - model: "anthropic/claude-sonnet-4-5", - target: "last", - to: "+15555550123", - prompt: "HEARTBEAT", - ackMaxChars: 30 - }, elevated: { enabled: true, allowFrom: { @@ -246,22 +265,6 @@ Save to `~/.clawdbot/clawdbot.json` and you can DM the bot from that number. imessage: ["user@example.com"], webchat: ["session:demo"] } - }, - sandbox: { - mode: "non-main", - perSession: true, - workspaceRoot: "~/.clawdbot/sandboxes", - docker: { - image: "clawdbot-sandbox:bookworm-slim", - workdir: "/workspace", - readOnlyRoot: true, - tmpfs: ["/tmp", "/var/tmp", "/run"], - network: "none", - user: "1000:1000" - }, - browser: { - enabled: false - } } }, diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index 2f931e3ec..2e7c34036 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -9,11 +9,11 @@ CLAWDBOT reads an optional **JSON5** config from `~/.clawdbot/clawdbot.json` (co If the file is missing, CLAWDBOT uses safe-ish defaults (embedded Pi agent + per-sender sessions + workspace `~/clawd`). You usually only need a config to: - restrict who can trigger the bot (`whatsapp.allowFrom`, `telegram.allowFrom`, etc.) -- control group allowlists + mention behavior (`whatsapp.groups`, `telegram.groups`, `discord.guilds`, `routing.groupChat`) +- control group allowlists + mention behavior (`whatsapp.groups`, `telegram.groups`, `discord.guilds`, `agents.list[].groupChat`) - customize message prefixes (`messages`) -- set the agent's workspace (`agent.workspace`) -- tune the embedded agent (`agent`) and session behavior (`session`) -- set the agent's identity (`identity`) +- set the agent's workspace (`agents.defaults.workspace` or `agents.list[].workspace`) +- tune the embedded agent defaults (`agents.defaults`) and session behavior (`session`) +- set per-agent identity (`agents.list[].identity`) > **New to configuration?** Check out the [Configuration Examples](/gateway/configuration-examples) guide for complete examples with detailed explanations! @@ -39,7 +39,7 @@ Example (via `gateway call`): ```bash clawdbot gateway call config.apply --params '{ - "raw": "{\\n agent: { workspace: \\"~/clawd\\" }\\n}\\n", + "raw": "{\\n agents: { defaults: { workspace: \\"~/clawd\\" } }\\n}\\n", "sessionKey": "agent:main:whatsapp:dm:+15555550123", "restartDelayMs": 1000 }' @@ -49,7 +49,7 @@ clawdbot gateway call config.apply --params '{ ```json5 { - agent: { workspace: "~/clawd" }, + agents: { defaults: { workspace: "~/clawd" } }, whatsapp: { allowFrom: ["+15555550123"] } } ``` @@ -65,16 +65,19 @@ To prevent the bot from responding to WhatsApp @-mentions in groups (only respon ```json5 { - agent: { workspace: "~/clawd" }, + agents: { + defaults: { workspace: "~/clawd" }, + list: [ + { + id: "main", + groupChat: { mentionPatterns: ["@clawd", "reisponde"] } + } + ] + }, whatsapp: { // Allowlist is DMs only; including your own number enables self-chat mode. allowFrom: ["+15555550123"], groups: { "*": { requireMention: true } } - }, - routing: { - groupChat: { - mentionPatterns: ["@clawd", "reisponde"] - } } } ``` @@ -175,17 +178,21 @@ rotation order used for failover. } ``` -### `identity` +### `agents.list[].identity` -Optional agent identity used for defaults and UX. This is written by the macOS onboarding assistant. +Optional per-agent identity used for defaults and UX. This is written by the macOS onboarding assistant. If set, CLAWDBOT derives defaults (only when you haven’t set them explicitly): -- `messages.ackReaction` from `identity.emoji` (falls back to 👀) -- `routing.groupChat.mentionPatterns` from `identity.name` (so “@Samantha” works in groups across Telegram/Slack/Discord/iMessage/WhatsApp) +- `messages.ackReaction` from the **active agent**’s `identity.emoji` (falls back to 👀) +- `agents.list[].groupChat.mentionPatterns` from the agent’s `identity.name`/`identity.emoji` (so “@Samantha” works in groups across Telegram/Slack/Discord/iMessage/WhatsApp) ```json5 { - identity: { name: "Samantha", theme: "helpful sloth", emoji: "🦥" } + agents: { + list: [ + { id: "main", identity: { name: "Samantha", theme: "helpful sloth", emoji: "🦥" } } + ] + } } ``` @@ -311,25 +318,26 @@ Notes: - `default` is used when `accountId` is omitted (CLI + routing). - Env tokens only apply to the **default** account. - Base provider settings (group policy, mention gating, etc.) apply to all accounts unless overridden per account. -- Use `routing.bindings[].match.accountId` to route each account to a different agent. +- Use `bindings[].match.accountId` to route each account to a different agents.defaults. -### `routing.groupChat` +### Group chat mention gating (`agents.list[].groupChat` + `messages.groupChat`) Group messages default to **require mention** (either metadata mention or regex patterns). Applies to WhatsApp, Telegram, Discord, and iMessage group chats. **Mention types:** - **Metadata mentions**: Native platform @-mentions (e.g., WhatsApp tap-to-mention). Ignored in WhatsApp self-chat mode (see `whatsapp.allowFrom`). -- **Text patterns**: Regex patterns defined in `mentionPatterns`. Always checked regardless of self-chat mode. +- **Text patterns**: Regex patterns defined in `agents.list[].groupChat.mentionPatterns`. Always checked regardless of self-chat mode. - Mention gating is enforced only when mention detection is possible (native mentions or at least one `mentionPattern`). - - Per-agent override: `routing.agents..mentionPatterns` (useful when multiple agents share a group). ```json5 { - routing: { - groupChat: { - mentionPatterns: ["@clawd", "clawdbot", "clawd"], - historyLimit: 50 - } + messages: { + groupChat: { historyLimit: 50 } + }, + agents: { + list: [ + { id: "main", groupChat: { mentionPatterns: ["@clawd", "clawdbot", "clawd"] } } + ] } } ``` @@ -337,11 +345,11 @@ Group messages default to **require mention** (either metadata mention or regex Per-agent override (takes precedence when set, even `[]`): ```json5 { - routing: { - agents: { - work: { mentionPatterns: ["@workbot", "\\+15555550123"] }, - personal: { mentionPatterns: ["@homebot", "\\+15555550999"] } - } + agents: { + list: [ + { id: "work", groupChat: { mentionPatterns: ["@workbot", "\\+15555550123"] } }, + { id: "personal", groupChat: { mentionPatterns: ["@homebot", "\\+15555550999"] } } + ] } } ``` @@ -356,11 +364,16 @@ To respond **only** to specific text triggers (ignoring native @-mentions): allowFrom: ["+15555550123"], groups: { "*": { requireMention: true } } }, - routing: { - groupChat: { - // Only these text patterns will trigger responses - mentionPatterns: ["reisponde", "@clawd"] - } + agents: { + list: [ + { + id: "main", + groupChat: { + // Only these text patterns will trigger responses + mentionPatterns: ["reisponde", "@clawd"] + } + } + ] } } ``` @@ -410,17 +423,22 @@ Notes: - Discord/Slack use channel allowlists (`discord.guilds.*.channels`, `slack.channels`). - Group DMs (Discord/Slack) are still controlled by `dm.groupEnabled` + `dm.groupChannels`. -### Multi-agent routing (`routing.agents` + `routing.bindings`) +### Multi-agent routing (`agents.list` + `bindings`) -Run multiple isolated agents (separate workspace, `agentDir`, sessions) inside one Gateway. Inbound messages are routed to an agent via bindings. +Run multiple isolated agents (separate workspace, `agentDir`, sessions) inside one Gateway. +Inbound messages are routed to an agent via bindings. -- `routing.defaultAgentId`: fallback when no binding matches (default: `main`). -- `routing.agents.`: per-agent overrides. +- `agents.list[]`: per-agent overrides. + - `id`: stable agent id (required). + - `default`: optional; when multiple are set, the first wins and a warning is logged. + If none are set, the **first entry** in the list is the default agent. - `name`: display name for the agent. - - `workspace`: default `~/clawd-` (for `main`, falls back to legacy `agent.workspace`). + - `workspace`: default `~/clawd-` (for `main`, falls back to `agents.defaults.workspace`). - `agentDir`: default `~/.clawdbot/agents//agent`. - - `model`: per-agent default model (provider/model), overrides `agent.model` for that agent. - - `sandbox`: per-agent sandbox config (overrides `agent.sandbox`). + - `model`: per-agent default model (provider/model), overrides `agents.defaults.model` for that agent. + - `identity`: per-agent name/theme/emoji (used for mention patterns + ack reactions). + - `groupChat`: per-agent mention-gating (`mentionPatterns`). + - `sandbox`: per-agent sandbox config (overrides `agents.defaults.sandbox`). - `mode`: `"off"` | `"non-main"` | `"all"` - `workspaceAccess`: `"none"` | `"ro"` | `"rw"` - `scope`: `"session"` | `"agent"` | `"shared"` @@ -428,13 +446,13 @@ Run multiple isolated agents (separate workspace, `agentDir`, sessions) inside o - `docker`: per-agent docker overrides (e.g. `image`, `network`, `env`, `setupCommand`, limits; ignored when `scope: "shared"`) - `browser`: per-agent sandboxed browser overrides (ignored when `scope: "shared"`) - `prune`: per-agent sandbox pruning overrides (ignored when `scope: "shared"`) - - `tools`: per-agent sandbox tool policy (deny wins; overrides `agent.sandbox.tools`) - `subagents`: per-agent sub-agent defaults. - `allowAgents`: allowlist of agent ids for `sessions_spawn` from this agent (`["*"]` = allow any; default: only same agent) - - `tools`: per-agent tool restrictions (overrides `agent.tools`; applied before sandbox tool policy). + - `tools`: per-agent tool restrictions (applied before sandbox tool policy). - `allow`: array of allowed tool names - `deny`: array of denied tool names (deny wins) -- `routing.bindings[]`: routes inbound messages to an `agentId`. +- `agents.defaults`: shared agent defaults (model, workspace, sandbox, etc.). +- `bindings[]`: routes inbound messages to an `agentId`. - `match.provider` (required) - `match.accountId` (optional; `*` = any account; omitted = default account) - `match.peer` (optional; `{ kind: dm|group|channel, id }`) @@ -446,9 +464,9 @@ Deterministic match order: 3) `match.teamId` 4) `match.accountId` (exact, no peer/guild/team) 5) `match.accountId: "*"` (provider-wide, no peer/guild/team) -6) `routing.defaultAgentId` +6) default agent (`agents.list[].default`, else first list entry, else `"main"`) -Within each match tier, the first matching entry in `routing.bindings` wins. +Within each match tier, the first matching entry in `bindings` wins. #### Per-agent access profiles (multi-agent) @@ -464,13 +482,14 @@ additional examples. Full access (no sandbox): ```json5 { - routing: { - agents: { - personal: { + agents: { + list: [ + { + id: "personal", workspace: "~/clawd-personal", sandbox: { mode: "off" } } - } + ] } } ``` @@ -478,9 +497,10 @@ Full access (no sandbox): Read-only tools + read-only workspace: ```json5 { - routing: { - agents: { - family: { + agents: { + list: [ + { + id: "family", workspace: "~/clawd-family", sandbox: { mode: "all", @@ -492,7 +512,7 @@ Read-only tools + read-only workspace: deny: ["write", "edit", "bash", "process", "browser"] } } - } + ] } } ``` @@ -500,9 +520,10 @@ Read-only tools + read-only workspace: No filesystem access (messaging/session tools enabled): ```json5 { - routing: { - agents: { - public: { + agents: { + list: [ + { + id: "public", workspace: "~/clawd-public", sandbox: { mode: "all", @@ -514,7 +535,7 @@ No filesystem access (messaging/session tools enabled): deny: ["read", "write", "edit", "bash", "process", "browser", "canvas", "nodes", "cron", "gateway", "image"] } } - } + ] } } ``` @@ -523,17 +544,16 @@ Example: two WhatsApp accounts → two agents: ```json5 { - routing: { - defaultAgentId: "home", - agents: { - home: { workspace: "~/clawd-home" }, - work: { workspace: "~/clawd-work" }, - }, - bindings: [ - { agentId: "home", match: { provider: "whatsapp", accountId: "personal" } }, - { agentId: "work", match: { provider: "whatsapp", accountId: "biz" } }, - ], + agents: { + list: [ + { id: "home", default: true, workspace: "~/clawd-home" }, + { id: "work", workspace: "~/clawd-work" } + ] }, + bindings: [ + { agentId: "home", match: { provider: "whatsapp", accountId: "personal" } }, + { agentId: "work", match: { provider: "whatsapp", accountId: "biz" } } + ], whatsapp: { accounts: { personal: {}, @@ -543,13 +563,13 @@ Example: two WhatsApp accounts → two agents: } ``` -### `routing.agentToAgent` (optional) +### `tools.agentToAgent` (optional) Agent-to-agent messaging is opt-in: ```json5 { - routing: { + tools: { agentToAgent: { enabled: false, allow: ["home", "work"] @@ -558,13 +578,13 @@ Agent-to-agent messaging is opt-in: } ``` -### `routing.queue` +### `messages.queue` Controls how inbound messages behave when an agent run is already active. ```json5 { - routing: { + messages: { queue: { mode: "collect", // steer | followup | collect | steer-backlog (steer+backlog ok) | interrupt (queue=steer legacy) debounceMs: 1000, @@ -859,7 +879,7 @@ Example wrapper: exec ssh -T mac-mini "imsg rpc" ``` -### `agent.workspace` +### `agents.defaults.workspace` Sets the **single global workspace directory** used by the agent for file operations. @@ -867,14 +887,14 @@ Default: `~/clawd`. ```json5 { - agent: { workspace: "~/clawd" } + agents: { defaults: { workspace: "~/clawd" } } } ``` -If `agent.sandbox` is enabled, non-main sessions can override this with their -own per-scope workspaces under `agent.sandbox.workspaceRoot`. +If `agents.defaults.sandbox` is enabled, non-main sessions can override this with their +own per-scope workspaces under `agents.defaults.sandbox.workspaceRoot`. -### `agent.skipBootstrap` +### `agents.defaults.skipBootstrap` Disables automatic creation of the workspace bootstrap files (`AGENTS.md`, `SOUL.md`, `TOOLS.md`, `IDENTITY.md`, `USER.md`, and `BOOTSTRAP.md`). @@ -882,18 +902,18 @@ Use this for pre-seeded deployments where your workspace files come from a repo. ```json5 { - agent: { skipBootstrap: true } + agents: { defaults: { skipBootstrap: true } } } ``` -### `agent.userTimezone` +### `agents.defaults.userTimezone` Sets the user’s timezone for **system prompt context** (not for timestamps in message envelopes). If unset, Clawdbot uses the host timezone at runtime. ```json5 { - agent: { userTimezone: "America/Chicago" } + agents: { defaults: { userTimezone: "America/Chicago" } } } ``` @@ -917,7 +937,7 @@ streaming, final replies) across providers unless already present. `ackReaction` sends a best-effort emoji reaction to acknowledge inbound messages on providers that support reactions (Slack/Discord/Telegram). Defaults to the -configured `identity.emoji` when set, otherwise `"👀"`. Set it to `""` to disable. +active agent’s `identity.emoji` when set, otherwise `"👀"`. Set it to `""` to disable. `ackReactionScope` controls when reactions fire: - `group-mentions` (default): only when a group/room requires mentions **and** the bot was mentioned @@ -947,22 +967,22 @@ Defaults for Talk mode (macOS/iOS/Android). Voice IDs fall back to `ELEVENLABS_V } ``` -### `agent` +### `agents.defaults` Controls the embedded agent runtime (model/thinking/verbose/timeouts). -`agent.models` defines the configured model catalog (and acts as the allowlist for `/model`). -`agent.model.primary` sets the default model; `agent.model.fallbacks` are global failovers. -`agent.imageModel` is optional and is **only used if the primary model lacks image input**. -Each `agent.models` entry can include: +`agents.defaults.models` defines the configured model catalog (and acts as the allowlist for `/model`). +`agents.defaults.model.primary` sets the default model; `agents.defaults.model.fallbacks` are global failovers. +`agents.defaults.imageModel` is optional and is **only used if the primary model lacks image input**. +Each `agents.defaults.models` entry can include: - `alias` (optional model shortcut, e.g. `/opus`). - `params` (optional provider-specific API params passed through to the model request). Z.AI GLM-4.x models automatically enable thinking mode unless you: - set `--thinking off`, or -- define `agent.models["zai/"].params.thinking` yourself. +- define `agents.defaults.models["zai/"].params.thinking` yourself. Clawdbot also ships a few built-in alias shorthands. Defaults only apply when the model -is already present in `agent.models`: +is already present in `agents.defaults.models`: - `opus` -> `anthropic/claude-opus-4-5` - `sonnet` -> `anthropic/claude-sonnet-4-5` @@ -975,61 +995,63 @@ If you configure the same alias name (case-insensitive) yourself, your value win ```json5 { - agent: { - models: { - "anthropic/claude-opus-4-5": { alias: "Opus" }, - "anthropic/claude-sonnet-4-1": { alias: "Sonnet" }, - "openrouter/deepseek/deepseek-r1:free": {}, - "zai/glm-4.7": { - alias: "GLM", - params: { - thinking: { - type: "enabled", - clear_thinking: false + agents: { + defaults: { + models: { + "anthropic/claude-opus-4-5": { alias: "Opus" }, + "anthropic/claude-sonnet-4-1": { alias: "Sonnet" }, + "openrouter/deepseek/deepseek-r1:free": {}, + "zai/glm-4.7": { + alias: "GLM", + params: { + thinking: { + type: "enabled", + clear_thinking: false + } } } - } - }, - model: { - primary: "anthropic/claude-opus-4-5", - fallbacks: [ - "openrouter/deepseek/deepseek-r1:free", - "openrouter/meta-llama/llama-3.3-70b-instruct:free" - ] - }, - imageModel: { - primary: "openrouter/qwen/qwen-2.5-vl-72b-instruct:free", - fallbacks: [ - "openrouter/google/gemini-2.0-flash-vision:free" - ] - }, - thinkingDefault: "low", - verboseDefault: "off", - elevatedDefault: "on", - timeoutSeconds: 600, - mediaMaxMb: 5, - heartbeat: { - every: "30m", - target: "last" - }, - maxConcurrent: 3, - subagents: { - maxConcurrent: 1, - archiveAfterMinutes: 60 - }, - bash: { - backgroundMs: 10000, - timeoutSec: 1800, - cleanupMs: 1800000 - }, - contextTokens: 200000 + }, + model: { + primary: "anthropic/claude-opus-4-5", + fallbacks: [ + "openrouter/deepseek/deepseek-r1:free", + "openrouter/meta-llama/llama-3.3-70b-instruct:free" + ] + }, + imageModel: { + primary: "openrouter/qwen/qwen-2.5-vl-72b-instruct:free", + fallbacks: [ + "openrouter/google/gemini-2.0-flash-vision:free" + ] + }, + thinkingDefault: "low", + verboseDefault: "off", + elevatedDefault: "on", + timeoutSeconds: 600, + mediaMaxMb: 5, + heartbeat: { + every: "30m", + target: "last" + }, + maxConcurrent: 3, + subagents: { + maxConcurrent: 1, + archiveAfterMinutes: 60 + }, + bash: { + backgroundMs: 10000, + timeoutSec: 1800, + cleanupMs: 1800000 + }, + contextTokens: 200000 + } } } ``` -#### `agent.contextPruning` (tool-result pruning) +#### `agents.defaults.contextPruning` (tool-result pruning) -`agent.contextPruning` prunes **old tool results** from the in-memory context right before a request is sent to the LLM. +`agents.defaults.contextPruning` prunes **old tool results** from the in-memory context right before a request is sent to the LLM. It does **not** modify the session history on disk (`*.jsonl` remains complete). This is intended to reduce token usage for chatty agents that accumulate large tool outputs over time. @@ -1061,22 +1083,14 @@ Notes / current limitations: Default (adaptive): ```json5 { - agent: { - contextPruning: { - mode: "adaptive" - } - } + agents: { defaults: { contextPruning: { mode: "adaptive" } } } } ``` To disable: ```json5 { - agent: { - contextPruning: { - mode: "off" - } - } + agents: { defaults: { contextPruning: { mode: "off" } } } } ``` @@ -1091,28 +1105,26 @@ Defaults (when `mode` is `"adaptive"` or `"aggressive"`): Example (aggressive, minimal): ```json5 { - agent: { - contextPruning: { - mode: "aggressive" - } - } + agents: { defaults: { contextPruning: { mode: "aggressive" } } } } ``` Example (adaptive tuned): ```json5 { - agent: { - contextPruning: { - mode: "adaptive", - keepLastAssistants: 3, - softTrimRatio: 0.3, - hardClearRatio: 0.5, - minPrunableToolChars: 50000, - softTrim: { maxChars: 4000, headChars: 1500, tailChars: 1500 }, - hardClear: { enabled: true, placeholder: "[Old tool result content cleared]" }, - // Optional: restrict pruning to specific tools (deny wins; supports "*" wildcards) - tools: { deny: ["browser", "canvas"] }, + agents: { + defaults: { + contextPruning: { + mode: "adaptive", + keepLastAssistants: 3, + softTrimRatio: 0.3, + hardClearRatio: 0.5, + minPrunableToolChars: 50000, + softTrim: { maxChars: 4000, headChars: 1500, tailChars: 1500 }, + hardClear: { enabled: true, placeholder: "[Old tool result content cleared]" }, + // Optional: restrict pruning to specific tools (deny wins; supports "*" wildcards) + tools: { deny: ["browser", "canvas"] }, + } } } } @@ -1121,36 +1133,34 @@ Example (adaptive tuned): See [/concepts/session-pruning](/concepts/session-pruning) for behavior details. Block streaming: -- `agent.blockStreamingDefault`: `"on"`/`"off"` (default on). -- `agent.blockStreamingBreak`: `"text_end"` or `"message_end"` (default: text_end). -- `agent.blockStreamingChunk`: soft chunking for streamed blocks. Defaults to +- `agents.defaults.blockStreamingDefault`: `"on"`/`"off"` (default on). +- `agents.defaults.blockStreamingBreak`: `"text_end"` or `"message_end"` (default: text_end). +- `agents.defaults.blockStreamingChunk`: soft chunking for streamed blocks. Defaults to 800–1200 chars, prefers paragraph breaks (`\n\n`), then newlines, then sentences. Example: ```json5 { - agent: { - blockStreamingChunk: { minChars: 800, maxChars: 1200 } - } + agents: { defaults: { blockStreamingChunk: { minChars: 800, maxChars: 1200 } } } } ``` See [/concepts/streaming](/concepts/streaming) for behavior + chunking details. Typing indicators: -- `agent.typingMode`: `"never" | "instant" | "thinking" | "message"`. Defaults to +- `agents.defaults.typingMode`: `"never" | "instant" | "thinking" | "message"`. Defaults to `instant` for direct chats / mentions and `message` for unmentioned group chats. - `session.typingMode`: per-session override for the mode. -- `agent.typingIntervalSeconds`: how often the typing signal is refreshed (default: 6s). +- `agents.defaults.typingIntervalSeconds`: how often the typing signal is refreshed (default: 6s). - `session.typingIntervalSeconds`: per-session override for the refresh interval. See [/concepts/typing-indicators](/concepts/typing-indicators) for behavior details. -`agent.model.primary` should be set as `provider/model` (e.g. `anthropic/claude-opus-4-5`). -Aliases come from `agent.models.*.alias` (e.g. `Opus`). +`agents.defaults.model.primary` should be set as `provider/model` (e.g. `anthropic/claude-opus-4-5`). +Aliases come from `agents.defaults.models.*.alias` (e.g. `Opus`). If you omit the provider, CLAWDBOT currently assumes `anthropic` as a temporary deprecation fallback. Z.AI models are available as `zai/` (e.g. `zai/glm-4.7`) and require `ZAI_API_KEY` (or legacy `Z_AI_API_KEY`) in the environment. -`agent.heartbeat` configures periodic heartbeat runs: +`agents.defaults.heartbeat` configures periodic heartbeat runs: - `every`: duration string (`ms`, `s`, `m`, `h`); default unit minutes. Default: `30m`. Set `0m` to disable. - `model`: optional override model for heartbeat runs (`provider/model`). @@ -1162,31 +1172,27 @@ Z.AI models are available as `zai/` (e.g. `zai/glm-4.7`) and require Heartbeats run full agent turns. Shorter intervals burn more tokens; be mindful of `every`, keep `HEARTBEAT.md` tiny, and/or choose a cheaper `model`. -`agent.bash` configures background bash defaults: +`tools.bash` configures background bash defaults: - `backgroundMs`: time before auto-background (ms, default 10000) - `timeoutSec`: auto-kill after this runtime (seconds, default 1800) - `cleanupMs`: how long to keep finished sessions in memory (ms, default 1800000) -`agent.subagents` configures sub-agent defaults: +`agents.defaults.subagents` configures sub-agent defaults: - `maxConcurrent`: max concurrent sub-agent runs (default 1) - `archiveAfterMinutes`: auto-archive sub-agent sessions after N minutes (default 60; set `0` to disable) -- `tools.allow` / `tools.deny`: per-subagent tool allow/deny policy (deny wins) +- Per-subagent tool policy: `tools.subagents.tools.allow` / `tools.subagents.tools.deny` (deny wins) -`agent.tools` configures a global tool allow/deny policy (deny wins). +`tools.allow` / `tools.deny` configure a global tool allow/deny policy (deny wins). This is applied even when the Docker sandbox is **off**. Example (disable browser/canvas everywhere): ```json5 { - agent: { - tools: { - deny: ["browser", "canvas"] - } - } + tools: { deny: ["browser", "canvas"] } } ``` -`agent.elevated` controls elevated (host) bash access: +`tools.elevated` controls elevated (host) bash access: - `enabled`: allow elevated mode (default true) - `allowFrom`: per-provider allowlists (empty = disabled) - `whatsapp`: E.164 numbers @@ -1199,7 +1205,7 @@ Example (disable browser/canvas everywhere): Example: ```json5 { - agent: { + tools: { elevated: { enabled: true, allowFrom: { @@ -1212,16 +1218,16 @@ Example: ``` Notes: -- `agent.elevated` is **global** (not per-agent). Availability is based on sender allowlists. +- `tools.elevated` is **global** (not per-agent). Availability is based on sender allowlists. - `/elevated on|off` stores state per session key; inline directives apply to a single message. - Elevated `bash` runs on the host and bypasses sandboxing. - Tool policy still applies; if `bash` is denied, elevated cannot be used. -`agent.maxConcurrent` sets the maximum number of embedded agent runs that can +`agents.defaults.maxConcurrent` sets the maximum number of embedded agent runs that can execute in parallel across sessions. Each session is still serialized (one run per session key at a time). Default: 1. -### `agent.sandbox` +### `agents.defaults.sandbox` Optional **Docker sandboxing** for the embedded agent. Intended for non-main sessions so they cannot access your host system. @@ -1236,7 +1242,8 @@ Defaults (if enabled): - `"ro"`: keep the sandbox workspace at `/workspace`, and mount the agent workspace read-only at `/agent` (disables `write`/`edit`) - `"rw"`: mount the agent workspace read/write at `/workspace` - auto-prune: idle > 24h OR age > 7d -- tools: allow only `bash`, `process`, `read`, `write`, `edit`, `sessions_list`, `sessions_history`, `sessions_send`, `sessions_spawn` (deny wins) +- tool policy: allow only `bash`, `process`, `read`, `write`, `edit`, `sessions_list`, `sessions_history`, `sessions_send`, `sessions_spawn` (deny wins) + - configure via `tools.sandbox.tools`, override per-agent via `agents.list[].tools.sandbox.tools` - optional sandboxed browser (Chromium + CDP, noVNC observer) - hardening knobs: `network`, `user`, `pidsLimit`, `memory`, `cpus`, `ulimits`, `seccompProfile`, `apparmorProfile` @@ -1248,54 +1255,60 @@ Legacy: `perSession` is still supported (`true` → `scope: "session"`, ```json5 { - agent: { - sandbox: { - mode: "non-main", // off | non-main | all - scope: "agent", // session | agent | shared (agent is default) - workspaceAccess: "none", // none | ro | rw - workspaceRoot: "~/.clawdbot/sandboxes", - docker: { - image: "clawdbot-sandbox:bookworm-slim", - containerPrefix: "clawdbot-sbx-", - workdir: "/workspace", - readOnlyRoot: true, - tmpfs: ["/tmp", "/var/tmp", "/run"], - network: "none", - user: "1000:1000", - capDrop: ["ALL"], - env: { LANG: "C.UTF-8" }, - setupCommand: "apt-get update && apt-get install -y git curl jq", - // Per-agent override (multi-agent): routing.agents..sandbox.docker.* - pidsLimit: 256, - memory: "1g", - memorySwap: "2g", - cpus: 1, - ulimits: { - nofile: { soft: 1024, hard: 2048 }, - nproc: 256 + agents: { + defaults: { + sandbox: { + mode: "non-main", // off | non-main | all + scope: "agent", // session | agent | shared (agent is default) + workspaceAccess: "none", // none | ro | rw + workspaceRoot: "~/.clawdbot/sandboxes", + docker: { + image: "clawdbot-sandbox:bookworm-slim", + containerPrefix: "clawdbot-sbx-", + workdir: "/workspace", + readOnlyRoot: true, + tmpfs: ["/tmp", "/var/tmp", "/run"], + network: "none", + user: "1000:1000", + capDrop: ["ALL"], + env: { LANG: "C.UTF-8" }, + setupCommand: "apt-get update && apt-get install -y git curl jq", + // Per-agent override (multi-agent): agents.list[].sandbox.docker.* + pidsLimit: 256, + memory: "1g", + memorySwap: "2g", + cpus: 1, + ulimits: { + nofile: { soft: 1024, hard: 2048 }, + nproc: 256 + }, + seccompProfile: "/path/to/seccomp.json", + apparmorProfile: "clawdbot-sandbox", + dns: ["1.1.1.1", "8.8.8.8"], + extraHosts: ["internal.service:10.0.0.5"] }, - seccompProfile: "/path/to/seccomp.json", - apparmorProfile: "clawdbot-sandbox", - dns: ["1.1.1.1", "8.8.8.8"], - extraHosts: ["internal.service:10.0.0.5"] - }, - browser: { - enabled: false, - image: "clawdbot-sandbox-browser:bookworm-slim", - containerPrefix: "clawdbot-sbx-browser-", - cdpPort: 9222, - vncPort: 5900, - noVncPort: 6080, - headless: false, - enableNoVnc: true - }, + browser: { + enabled: false, + image: "clawdbot-sandbox-browser:bookworm-slim", + containerPrefix: "clawdbot-sbx-browser-", + cdpPort: 9222, + vncPort: 5900, + noVncPort: 6080, + headless: false, + enableNoVnc: true + }, + prune: { + idleHours: 24, // 0 disables idle pruning + maxAgeDays: 7 // 0 disables max-age pruning + } + } + } + }, + tools: { + sandbox: { tools: { allow: ["bash", "process", "read", "write", "edit", "sessions_list", "sessions_history", "sessions_send", "sessions_spawn"], deny: ["browser", "canvas", "nodes", "cron", "discord", "gateway"] - }, - prune: { - idleHours: 24, // 0 disables idle pruning - maxAgeDays: 7 // 0 disables max-age pruning } } } @@ -1307,7 +1320,7 @@ Build the default sandbox image once with: scripts/sandbox-setup.sh ``` -Note: sandbox containers default to `network: "none"`; set `agent.sandbox.docker.network` +Note: sandbox containers default to `network: "none"`; set `agents.defaults.sandbox.docker.network` to `"bridge"` (or your custom network) if the agent needs outbound access. Note: inbound attachments are staged into the active workspace at `media/inbound/*`. With `workspaceAccess: "rw"`, that means files are written into the agent workspace. @@ -1317,7 +1330,7 @@ Build the optional browser image with: scripts/sandbox-browser-setup.sh ``` -When `agent.sandbox.browser.enabled=true`, the browser tool uses a sandboxed +When `agents.defaults.sandbox.browser.enabled=true`, the browser tool uses a sandboxed Chromium instance (CDP). If noVNC is enabled (default when headless=false), the noVNC URL is injected into the system prompt so the agent can reference it. This does not require `browser.enabled` in the main config; the sandbox control @@ -1335,14 +1348,16 @@ When `models.providers` is present, Clawdbot writes/merges a `models.json` into - default behavior: **merge** (keeps existing providers, overrides on name) - set `models.mode: "replace"` to overwrite the file contents -Select the model via `agent.model.primary` (provider/model). +Select the model via `agents.defaults.model.primary` (provider/model). ```json5 { - agent: { - model: { primary: "custom-proxy/llama-3.1-8b" }, - models: { - "custom-proxy/llama-3.1-8b": {} + agents: { + defaults: { + model: { primary: "custom-proxy/llama-3.1-8b" }, + models: { + "custom-proxy/llama-3.1-8b": {} + } } }, models: { @@ -1376,9 +1391,11 @@ in your environment and reference the model by provider/model. ```json5 { - agent: { - model: "zai/glm-4.7", - allowedModels: ["zai/glm-4.7"] + agents: { + defaults: { + model: { primary: "zai/glm-4.7" }, + models: { "zai/glm-4.7": {} } + } } } ``` @@ -1401,11 +1418,13 @@ via **LM Studio** using the **Responses API**. ```json5 { - agent: { - model: { primary: "lmstudio/minimax-m2.1-gs32" }, - models: { - "anthropic/claude-opus-4-5": { alias: "Opus" }, - "lmstudio/minimax-m2.1-gs32": { alias: "Minimax" } + agents: { + defaults: { + model: { primary: "lmstudio/minimax-m2.1-gs32" }, + models: { + "anthropic/claude-opus-4-5": { alias: "Opus" }, + "lmstudio/minimax-m2.1-gs32": { alias: "Minimax" } + } } }, models: { @@ -1475,7 +1494,7 @@ Controls session scoping, idle expiry, reset triggers, and where the session sto Fields: - `mainKey`: direct-chat bucket key (default: `"main"`). Useful when you want to “rename” the primary DM thread without changing `agentId`. - - Sandbox note: `agent.sandbox.mode: "non-main"` uses this key to detect the main session. Any session key that does not match `mainKey` (groups/channels) is sandboxed. + - Sandbox note: `agents.defaults.sandbox.mode: "non-main"` uses this key to detect the main session. Any session key that does not match `mainKey` (groups/channels) is sandboxed. - `agentToAgent.maxPingPongTurns`: max reply-back turns between requester/target (0–5, default 5). - `sendPolicy.default`: `allow` or `deny` fallback when no rule matches. - `sendPolicy.rules[]`: match by `provider`, `chatType` (`direct|group|room`), or `keyPrefix` (e.g. `cron:`). First deny wins; otherwise allow. @@ -1684,7 +1703,7 @@ Hot-applied (no full gateway restart): - `hooks` (webhook auth/path/mappings) + `hooks.gmail` (Gmail watcher restarted) - `browser` (browser control server restart) - `cron` (cron service restart + concurrency update) -- `agent.heartbeat` (heartbeat runner restart) +- `agents.defaults.heartbeat` (heartbeat runner restart) - `web` (WhatsApp web provider restart) - `telegram`, `discord`, `signal`, `imessage` (provider restarts) - `agent`, `models`, `routing`, `messages`, `session`, `whatsapp`, `logging`, `skills`, `ui`, `talk`, `identity`, `wizard` (dynamic reads) @@ -1701,7 +1720,7 @@ Requires full Gateway restart: To run multiple gateways on one host, isolate per-instance state + config and use unique ports: - `CLAWDBOT_CONFIG_PATH` (per-instance config) - `CLAWDBOT_STATE_DIR` (sessions/creds) -- `agent.workspace` (memories) +- `agents.defaults.workspace` (memories) - `gateway.port` (unique per instance) Convenience flags (CLI): @@ -1771,7 +1790,7 @@ Mapping notes: - `transform` can point to a JS/TS module that returns a hook action. - `deliver: true` sends the final reply to a provider; `provider` defaults to `last` (falls back to WhatsApp). - If there is no prior delivery route, set `provider` + `to` explicitly (required for Telegram/Discord/Slack/Signal/iMessage). -- `model` overrides the LLM for this hook run (`provider/model` or alias; must be allowed if `agent.models` is set). +- `model` overrides the LLM for this hook run (`provider/model` or alias; must be allowed if `agents.defaults.models` is set). Gmail helper config (used by `clawdbot hooks gmail setup` / `run`): @@ -1886,7 +1905,7 @@ clawdbot dns setup --apply ## Template variables -Template placeholders are expanded in `routing.transcribeAudio.command` (and any future templated command fields). +Template placeholders are expanded in `audio.transcription.command` (and any future templated command fields). | Variable | Description | |----------|-------------| diff --git a/docs/gateway/doctor.md b/docs/gateway/doctor.md index 60e31841d..7120af4ba 100644 --- a/docs/gateway/doctor.md +++ b/docs/gateway/doctor.md @@ -94,8 +94,18 @@ legacy config format, so stale configs are repaired without manual intervention. Current migrations: - `routing.allowFrom` → `whatsapp.allowFrom` +- `routing.groupChat.requireMention` → `whatsapp/telegram/imessage.groups."*".requireMention` +- `routing.groupChat.historyLimit` → `messages.groupChat.historyLimit` +- `routing.groupChat.mentionPatterns` → `messages.groupChat.mentionPatterns` +- `routing.queue` → `messages.queue` +- `routing.bindings` → top-level `bindings` +- `routing.agents`/`routing.defaultAgentId` → `agents.list` + `agents.list[].default` +- `routing.agentToAgent` → `tools.agentToAgent` +- `routing.transcribeAudio` → `audio.transcription` +- `identity` → `agents.list[].identity` +- `agent.*` → `agents.defaults` + `tools.*` (tools/elevated/bash/sandbox/subagents) - `agent.model`/`allowedModels`/`modelAliases`/`modelFallbacks`/`imageModelFallbacks` - → `agent.models` + `agent.model.primary/fallbacks` + `agent.imageModel.primary/fallbacks` + → `agents.defaults.models` + `agents.defaults.model.primary/fallbacks` + `agents.defaults.imageModel.primary/fallbacks` ### 3) Legacy state migrations (disk layout) Doctor can migrate older on-disk layouts into the current structure: diff --git a/docs/gateway/health.md b/docs/gateway/health.md index 230a1794b..a83b1ccd5 100644 --- a/docs/gateway/health.md +++ b/docs/gateway/health.md @@ -22,7 +22,7 @@ Short guide to verify the WhatsApp Web / Baileys stack without guessing. ## When something fails - `logged out` or status 409–515 → relink with `clawdbot providers logout` then `clawdbot providers login`. - Gateway unreachable → start it: `clawdbot gateway --port 18789` (use `--force` if the port is busy). -- No inbound messages → confirm linked phone is online and the sender is allowed (`whatsapp.allowFrom`); for group chats, ensure allowlist + mention rules match (`whatsapp.groups`, `routing.groupChat.mentionPatterns`). +- No inbound messages → confirm linked phone is online and the sender is allowed (`whatsapp.allowFrom`); for group chats, ensure allowlist + mention rules match (`whatsapp.groups`, `agents.list[].groupChat.mentionPatterns`). ## Dedicated "health" command `clawdbot health --json` asks the running Gateway for its health snapshot (no direct Baileys socket from the CLI). It reports linked creds, auth age, Baileys connect result/status code, session-store summary, and a probe duration. It exits non-zero if the Gateway is unreachable or the probe fails/timeouts. Use `--timeout ` to override the 10s default. diff --git a/docs/gateway/heartbeat.md b/docs/gateway/heartbeat.md index 431c4848b..42002d169 100644 --- a/docs/gateway/heartbeat.md +++ b/docs/gateway/heartbeat.md @@ -10,8 +10,8 @@ surface anything that needs attention without spamming you. ## Defaults -- Interval: `30m` (set `agent.heartbeat.every`; use `0m` to disable). -- Prompt body (configurable via `agent.heartbeat.prompt`): +- Interval: `30m` (set `agents.defaults.heartbeat.every`; use `0m` to disable). +- Prompt body (configurable via `agents.defaults.heartbeat.prompt`): `Read HEARTBEAT.md if exists. Consider outstanding tasks. Checkup sometimes on your human during (user local) day time.` - The heartbeat prompt is sent **verbatim** as the user message. The system prompt includes a “Heartbeat” section and the run is flagged internally. @@ -33,14 +33,16 @@ and logged; a message that is only `HEARTBEAT_OK` is dropped. ```json5 { - agent: { - heartbeat: { - every: "30m", // default: 30m (0m disables) - model: "anthropic/claude-opus-4-5", - target: "last", // last | whatsapp | telegram | discord | slack | signal | imessage | none - to: "+15551234567", // optional provider-specific override - prompt: "Read HEARTBEAT.md if exists. Consider outstanding tasks. Checkup sometimes on your human during (user local) day time.", - ackMaxChars: 30 // max chars allowed after HEARTBEAT_OK + agents: { + defaults: { + heartbeat: { + every: "30m", // default: 30m (0m disables) + model: "anthropic/claude-opus-4-5", + target: "last", // last | whatsapp | telegram | discord | slack | signal | imessage | none + to: "+15551234567", // optional provider-specific override + prompt: "Read HEARTBEAT.md if exists. Consider outstanding tasks. Checkup sometimes on your human during (user local) day time.", + ackMaxChars: 30 // max chars allowed after HEARTBEAT_OK + } } } } diff --git a/docs/gateway/index.md b/docs/gateway/index.md index 9b2e3dcf2..5c4e7dc24 100644 --- a/docs/gateway/index.md +++ b/docs/gateway/index.md @@ -68,7 +68,7 @@ Defaults (can be overridden via env/flags/config): - `bridge.port=19002` (derived: `gateway.port+1`) - `browser.controlUrl=http://127.0.0.1:19003` (derived: `gateway.port+2`) - `canvasHost.port=19005` (derived: `gateway.port+4`) -- `agent.workspace` default becomes `~/clawd-dev` when you run `setup`/`onboard` under `--dev`. +- `agents.defaults.workspace` default becomes `~/clawd-dev` when you run `setup`/`onboard` under `--dev`. Derived ports (rules of thumb): - Base port = `gateway.port` (or `CLAWDBOT_GATEWAY_PORT` / `--port`) @@ -81,7 +81,7 @@ Checklist per instance: - unique `gateway.port` - unique `CLAWDBOT_CONFIG_PATH` - unique `CLAWDBOT_STATE_DIR` -- unique `agent.workspace` +- unique `agents.defaults.workspace` - separate WhatsApp numbers (if using WA) Example: diff --git a/docs/gateway/sandboxing.md b/docs/gateway/sandboxing.md index 93a629bbe..6a2d46d2e 100644 --- a/docs/gateway/sandboxing.md +++ b/docs/gateway/sandboxing.md @@ -1,15 +1,15 @@ --- summary: "How Clawdbot sandboxing works: modes, scopes, workspace access, and images" title: Sandboxing -read_when: "You want a dedicated explanation of sandboxing or need to tune agent.sandbox." +read_when: "You want a dedicated explanation of sandboxing or need to tune agents.defaults.sandbox." status: active --- # Sandboxing Clawdbot can run **tools inside Docker containers** to reduce blast radius. -This is **optional** and controlled by configuration (`agent.sandbox` or -`routing.agents[id].sandbox`). If sandboxing is off, tools run on the host. +This is **optional** and controlled by configuration (`agents.defaults.sandbox` or +`agents.list[].sandbox`). If sandboxing is off, tools run on the host. The Gateway stays on the host; tool execution runs in an isolated sandbox when enabled. @@ -18,16 +18,16 @@ and process access when the model does something dumb. ## What gets sandboxed - Tool execution (`bash`, `read`, `write`, `edit`, `process`, etc.). -- Optional sandboxed browser (`agent.sandbox.browser`). +- Optional sandboxed browser (`agents.defaults.sandbox.browser`). Not sandboxed: - The Gateway process itself. -- Any tool explicitly allowed to run on the host (e.g. `agent.elevated`). +- Any tool explicitly allowed to run on the host (e.g. `tools.elevated`). - **Elevated bash runs on the host and bypasses sandboxing.** - - If sandboxing is off, `agent.elevated` does not change execution (already on host). See [Elevated Mode](/tools/elevated). + - If sandboxing is off, `tools.elevated` does not change execution (already on host). See [Elevated Mode](/tools/elevated). ## Modes -`agent.sandbox.mode` controls **when** sandboxing is used: +`agents.defaults.sandbox.mode` controls **when** sandboxing is used: - `"off"`: no sandboxing. - `"non-main"`: sandbox only **non-main** sessions (default if you want normal chats on host). - `"all"`: every session runs in a sandbox. @@ -35,13 +35,13 @@ Note: `"non-main"` is based on `session.mainKey` (default `"main"`), not agent i Group/channel sessions use their own keys, so they count as non-main and will be sandboxed. ## Scope -`agent.sandbox.scope` controls **how many containers** are created: +`agents.defaults.sandbox.scope` controls **how many containers** are created: - `"session"` (default): one container per session. - `"agent"`: one container per agent. - `"shared"`: one container shared by all sandboxed sessions. ## Workspace access -`agent.sandbox.workspaceAccess` controls **what the sandbox can see**: +`agents.defaults.sandbox.workspaceAccess` controls **what the sandbox can see**: - `"none"` (default): tools see a sandbox workspace under `~/.clawdbot/sandboxes`. - `"ro"`: mounts the agent workspace read-only at `/agent` (disables `write`/`edit`). - `"rw"`: mounts the agent workspace read/write at `/workspace`. @@ -66,7 +66,7 @@ scripts/sandbox-browser-setup.sh ``` By default, sandbox containers run with **no network**. -Override with `agent.sandbox.docker.network`. +Override with `agents.defaults.sandbox.docker.network`. Docker installs and the containerized gateway live here: [Docker](/install/docker) @@ -75,28 +75,30 @@ Docker installs and the containerized gateway live here: Tool allow/deny policies still apply before sandbox rules. If a tool is denied globally or per-agent, sandboxing doesn’t bring it back. -`agent.elevated` is an explicit escape hatch that runs `bash` on the host. +`tools.elevated` is an explicit escape hatch that runs `bash` on the host. Keep it locked down. ## Multi-agent overrides Each agent can override sandbox + tools: -`routing.agents[id].sandbox` and `routing.agents[id].tools`. +`agents.list[].sandbox` and `agents.list[].tools` (plus `agents.list[].tools.sandbox.tools` for sandbox tool policy). See [Multi-Agent Sandbox & Tools](/multi-agent-sandbox-tools) for precedence. ## Minimal enable example ```json5 { - agent: { - sandbox: { - mode: "non-main", - scope: "session", - workspaceAccess: "none" + agents: { + defaults: { + sandbox: { + mode: "non-main", + scope: "session", + workspaceAccess: "none" + } } } } ``` ## Related docs -- [Sandbox Configuration](/gateway/configuration#agent-sandbox) +- [Sandbox Configuration](/gateway/configuration#agentsdefaults-sandbox) - [Multi-Agent Sandbox & Tools](/multi-agent-sandbox-tools) - [Security](/gateway/security) diff --git a/docs/gateway/security.md b/docs/gateway/security.md index 0d8b62b48..5dc3066ea 100644 --- a/docs/gateway/security.md +++ b/docs/gateway/security.md @@ -127,10 +127,13 @@ Keep config + state private on the gateway host: "*": { "requireMention": true } } }, - "routing": { - "groupChat": { - "mentionPatterns": ["@clawd", "@mybot"] - } + "agents": { + "list": [ + { + "id": "main", + "groupChat": { "mentionPatterns": ["@clawd", "@mybot"] } + } + ] } } ``` @@ -146,7 +149,7 @@ Consider running your AI on a separate phone number from your personal one: ### 4. Read-Only Mode (Today, via sandbox + tools) You can already build a read-only profile by combining: -- `sandbox.workspaceAccess: "ro"` (or `"none"` for no workspace access) +- `agents.defaults.sandbox.workspaceAccess: "ro"` (or `"none"` for no workspace access) - tool allow/deny lists that block `write`, `edit`, `bash`, `process`, etc. We may add a single `readOnlyMode` flag later to simplify this configuration. @@ -158,18 +161,18 @@ Dedicated doc: [Sandboxing](/gateway/sandboxing) Two complementary approaches: - **Run the full Gateway in Docker** (container boundary): [Docker](/install/docker) -- **Tool sandbox** (`agent.sandbox`, host gateway + Docker-isolated tools): [Sandboxing](/gateway/sandboxing) +- **Tool sandbox** (`agents.defaults.sandbox`, host gateway + Docker-isolated tools): [Sandboxing](/gateway/sandboxing) -Note: to prevent cross-agent access, keep `sandbox.scope` at `"agent"` (default) +Note: to prevent cross-agent access, keep `agents.defaults.sandbox.scope` at `"agent"` (default) or `"session"` for stricter per-session isolation. `scope: "shared"` uses a single container/workspace. Also consider agent workspace access inside the sandbox: -- `agent.sandbox.workspaceAccess: "none"` (default) keeps the agent workspace off-limits; tools run against a sandbox workspace under `~/.clawdbot/sandboxes` -- `workspaceAccess: "ro"` mounts the agent workspace read-only at `/agent` (disables `write`/`edit`) -- `workspaceAccess: "rw"` mounts the agent workspace read/write at `/workspace` +- `agents.defaults.sandbox.workspaceAccess: "none"` (default) keeps the agent workspace off-limits; tools run against a sandbox workspace under `~/.clawdbot/sandboxes` +- `agents.defaults.sandbox.workspaceAccess: "ro"` mounts the agent workspace read-only at `/agent` (disables `write`/`edit`) +- `agents.defaults.sandbox.workspaceAccess: "rw"` mounts the agent workspace read/write at `/workspace` -Important: `agent.elevated` is a **global**, sender-based escape hatch that runs bash on the host. Keep `agent.elevated.allowFrom` tight and don’t enable it for strangers. See [Elevated Mode](/tools/elevated). +Important: `tools.elevated` is a **global**, sender-based escape hatch that runs bash on the host. Keep `tools.elevated.allowFrom` tight and don’t enable it for strangers. See [Elevated Mode](/tools/elevated). ## Per-agent access profiles (multi-agent) @@ -187,13 +190,14 @@ Common use cases: ```json5 { - routing: { - agents: { - personal: { + agents: { + list: [ + { + id: "personal", workspace: "~/clawd-personal", sandbox: { mode: "off" } } - } + ] } } ``` @@ -202,9 +206,10 @@ Common use cases: ```json5 { - routing: { - agents: { - family: { + agents: { + list: [ + { + id: "family", workspace: "~/clawd-family", sandbox: { mode: "all", @@ -216,7 +221,7 @@ Common use cases: deny: ["write", "edit", "bash", "process", "browser"] } } - } + ] } } ``` @@ -225,9 +230,10 @@ Common use cases: ```json5 { - routing: { - agents: { - public: { + agents: { + list: [ + { + id: "public", workspace: "~/clawd-public", sandbox: { mode: "all", @@ -239,7 +245,7 @@ Common use cases: deny: ["read", "write", "edit", "bash", "process", "browser", "canvas", "nodes", "cron", "gateway", "image"] } } - } + ] } } ``` diff --git a/docs/gateway/troubleshooting.md b/docs/gateway/troubleshooting.md index 43b636892..3b3b35bf3 100644 --- a/docs/gateway/troubleshooting.md +++ b/docs/gateway/troubleshooting.md @@ -127,12 +127,12 @@ or state drift because only one workspace is active. Symptoms: `pwd` or file tools show `~/.clawdbot/sandboxes/...` even though you expected the host workspace. -**Why:** `agent.sandbox.mode: "non-main"` keys off `session.mainKey` (default `"main"`). +**Why:** `agents.defaults.sandbox.mode: "non-main"` keys off `session.mainKey` (default `"main"`). Group/channel sessions use their own keys, so they are treated as non-main and get sandbox workspaces. **Fix options:** -- If you want host workspaces for an agent: set `routing.agents..sandbox.mode: "off"`. +- If you want host workspaces for an agent: set `agents.list[].sandbox.mode: "off"`. - If you want host workspace access inside sandbox: set `workspaceAccess: "rw"` for that agent. ### "Agent was aborted" @@ -157,8 +157,8 @@ Look for `AllowFrom: ...` in the output. **Check 2:** For group chats, is mention required? ```bash # The message must match mentionPatterns or explicit mentions; defaults live in provider groups/guilds. -# Multi-agent: `routing.agents..mentionPatterns` overrides global patterns. -grep -n "routing\\|groupChat\\|mentionPatterns\\|whatsapp\\.groups\\|telegram\\.groups\\|imessage\\.groups\\|discord\\.guilds" \ +# Multi-agent: `agents.list[].groupChat.mentionPatterns` overrides global patterns. +grep -n "agents\\|groupChat\\|mentionPatterns\\|whatsapp\\.groups\\|telegram\\.groups\\|imessage\\.groups\\|discord\\.guilds" \ "${CLAWDBOT_CONFIG_PATH:-$HOME/.clawdbot/clawdbot.json}" ``` diff --git a/docs/install/docker.md b/docs/install/docker.md index 1c47cb57b..4db81590d 100644 --- a/docs/install/docker.md +++ b/docs/install/docker.md @@ -109,12 +109,12 @@ Deep dive: [Sandboxing](/gateway/sandboxing) ### What it does -When `agent.sandbox` is enabled, **non-main sessions** run tools inside a Docker +When `agents.defaults.sandbox` is enabled, **non-main sessions** run tools inside a Docker container. The gateway stays on your host, but the tool execution is isolated: - scope: `"agent"` by default (one container + workspace per agent) - scope: `"session"` for per-session isolation - per-scope workspace folder mounted at `/workspace` -- optional agent workspace access (`agent.sandbox.workspaceAccess`) +- optional agent workspace access (`agents.defaults.sandbox.workspaceAccess`) - allow/deny tool policy (deny wins) - inbound media is copied into the active sandbox workspace (`media/inbound/*`) so tools can read it (with `workspaceAccess: "rw"`, this lands in the agent workspace) @@ -124,7 +124,7 @@ one container and one workspace. ### Per-agent sandbox profiles (multi-agent) If you use multi-agent routing, each agent can override sandbox + tool settings: -`routing.agents[id].sandbox` and `routing.agents[id].tools`. This lets you run +`agents.list[].sandbox` and `agents.list[].tools` (plus `agents.list[].tools.sandbox.tools`). This lets you run mixed access levels in one gateway: - Full access (personal agent) - Read-only tools + read-only workspace (family/work agent) @@ -149,54 +149,60 @@ precedence, and troubleshooting. ```json5 { - agent: { - sandbox: { - mode: "non-main", // off | non-main | all - scope: "agent", // session | agent | shared (agent is default) - workspaceAccess: "none", // none | ro | rw - workspaceRoot: "~/.clawdbot/sandboxes", - docker: { - image: "clawdbot-sandbox:bookworm-slim", - workdir: "/workspace", - readOnlyRoot: true, - tmpfs: ["/tmp", "/var/tmp", "/run"], - network: "none", - user: "1000:1000", - capDrop: ["ALL"], - env: { LANG: "C.UTF-8" }, - setupCommand: "apt-get update && apt-get install -y git curl jq", - pidsLimit: 256, - memory: "1g", - memorySwap: "2g", - cpus: 1, - ulimits: { - nofile: { soft: 1024, hard: 2048 }, - nproc: 256 + agents: { + defaults: { + sandbox: { + mode: "non-main", // off | non-main | all + scope: "agent", // session | agent | shared (agent is default) + workspaceAccess: "none", // none | ro | rw + workspaceRoot: "~/.clawdbot/sandboxes", + docker: { + image: "clawdbot-sandbox:bookworm-slim", + workdir: "/workspace", + readOnlyRoot: true, + tmpfs: ["/tmp", "/var/tmp", "/run"], + network: "none", + user: "1000:1000", + capDrop: ["ALL"], + env: { LANG: "C.UTF-8" }, + setupCommand: "apt-get update && apt-get install -y git curl jq", + pidsLimit: 256, + memory: "1g", + memorySwap: "2g", + cpus: 1, + ulimits: { + nofile: { soft: 1024, hard: 2048 }, + nproc: 256 + }, + seccompProfile: "/path/to/seccomp.json", + apparmorProfile: "clawdbot-sandbox", + dns: ["1.1.1.1", "8.8.8.8"], + extraHosts: ["internal.service:10.0.0.5"] }, - seccompProfile: "/path/to/seccomp.json", - apparmorProfile: "clawdbot-sandbox", - dns: ["1.1.1.1", "8.8.8.8"], - extraHosts: ["internal.service:10.0.0.5"] - }, + prune: { + idleHours: 24, // 0 disables idle pruning + maxAgeDays: 7 // 0 disables max-age pruning + } + } + } + }, + tools: { + sandbox: { tools: { allow: ["bash", "process", "read", "write", "edit", "sessions_list", "sessions_history", "sessions_send", "sessions_spawn"], deny: ["browser", "canvas", "nodes", "cron", "discord", "gateway"] - }, - prune: { - idleHours: 24, // 0 disables idle pruning - maxAgeDays: 7 // 0 disables max-age pruning } } } } ``` -Hardening knobs live under `agent.sandbox.docker`: +Hardening knobs live under `agents.defaults.sandbox.docker`: `network`, `user`, `pidsLimit`, `memory`, `memorySwap`, `cpus`, `ulimits`, `seccompProfile`, `apparmorProfile`, `dns`, `extraHosts`. -Multi-agent: override `agent.sandbox.{docker,browser,prune}.*` per agent via `routing.agents..sandbox.{docker,browser,prune}.*` -(ignored when `agent.sandbox.scope` / `routing.agents..sandbox.scope` is `"shared"`). +Multi-agent: override `agents.defaults.sandbox.{docker,browser,prune}.*` per agent via `agents.list[].sandbox.{docker,browser,prune}.*` +(ignored when `agents.defaults.sandbox.scope` / `agents.list[].sandbox.scope` is `"shared"`). ### Build the default sandbox image @@ -217,7 +223,7 @@ This builds `clawdbot-sandbox-common:bookworm-slim`. To use it: ```json5 { - agent: { sandbox: { docker: { image: "clawdbot-sandbox-common:bookworm-slim" } } } + agents: { defaults: { sandbox: { docker: { image: "clawdbot-sandbox-common:bookworm-slim" } } } } } ``` @@ -235,16 +241,18 @@ an optional noVNC observer (headful via Xvfb). Notes: - Headful (Xvfb) reduces bot blocking vs headless. -- Headless can still be used by setting `agent.sandbox.browser.headless=true`. +- Headless can still be used by setting `agents.defaults.sandbox.browser.headless=true`. - No full desktop environment (GNOME) is needed; Xvfb provides the display. Use config: ```json5 { - agent: { - sandbox: { - browser: { enabled: true } + agents: { + defaults: { + sandbox: { + browser: { enabled: true } + } } } } @@ -254,8 +262,10 @@ Custom browser image: ```json5 { - agent: { - sandbox: { browser: { image: "my-clawdbot-browser" } } + agents: { + defaults: { + sandbox: { browser: { image: "my-clawdbot-browser" } } + } } } ``` @@ -266,7 +276,7 @@ When enabled, the agent receives: Remember: if you use an allowlist for tools, add `browser` (and remove it from deny) or the tool remains blocked. -Prune rules (`agent.sandbox.prune`) apply to browser containers too. +Prune rules (`agents.defaults.sandbox.prune`) apply to browser containers too. ### Custom sandbox image @@ -278,8 +288,10 @@ docker build -t my-clawdbot-sbx -f Dockerfile.sandbox . ```json5 { - agent: { - sandbox: { docker: { image: "my-clawdbot-sbx" } } + agents: { + defaults: { + sandbox: { docker: { image: "my-clawdbot-sbx" } } + } } } ``` @@ -310,7 +322,7 @@ Example: ## Troubleshooting -- Image missing: build with [`scripts/sandbox-setup.sh`](https://github.com/clawdbot/clawdbot/blob/main/scripts/sandbox-setup.sh) or set `agent.sandbox.docker.image`. +- Image missing: build with [`scripts/sandbox-setup.sh`](https://github.com/clawdbot/clawdbot/blob/main/scripts/sandbox-setup.sh) or set `agents.defaults.sandbox.docker.image`. - Container not running: it will auto-create per session on demand. - Permission errors in sandbox: set `docker.user` to a UID:GID that matches your mounted workspace ownership (or chown the workspace folder). diff --git a/docs/multi-agent-sandbox-tools.md b/docs/multi-agent-sandbox-tools.md index d17ee98f2..8405f4148 100644 --- a/docs/multi-agent-sandbox-tools.md +++ b/docs/multi-agent-sandbox-tools.md @@ -10,8 +10,8 @@ status: active ## Overview Each agent in a multi-agent setup can now have its own: -- **Sandbox configuration** (`mode`, `scope`, `workspaceRoot`, `workspaceAccess`, `tools`) -- **Tool restrictions** (`allow`, `deny`) +- **Sandbox configuration** (`agents.list[].sandbox` overrides `agents.defaults.sandbox`) +- **Tool restrictions** (`tools.allow` / `tools.deny`, plus `agents.list[].tools`) This allows you to run multiple agents with different security profiles: - Personal assistant with full access @@ -28,18 +28,17 @@ For how sandboxing behaves at runtime, see [Sandboxing](/gateway/sandboxing). ```json { - "routing": { - "defaultAgentId": "main", - "agents": { - "main": { + "agents": { + "list": [ + { + "id": "main", + "default": true, "name": "Personal Assistant", "workspace": "~/clawd", - "sandbox": { - "mode": "off" - } - // No tool restrictions - all tools available + "sandbox": { "mode": "off" } }, - "family": { + { + "id": "family", "name": "Family Bot", "workspace": "~/clawd-family", "sandbox": { @@ -51,21 +50,21 @@ For how sandboxing behaves at runtime, see [Sandboxing](/gateway/sandboxing). "deny": ["bash", "write", "edit", "process", "browser"] } } - }, - "bindings": [ - { - "agentId": "family", - "match": { - "provider": "whatsapp", - "accountId": "*", - "peer": { - "kind": "group", - "id": "120363424282127706@g.us" - } + ] + }, + "bindings": [ + { + "agentId": "family", + "match": { + "provider": "whatsapp", + "accountId": "*", + "peer": { + "kind": "group", + "id": "120363424282127706@g.us" } } - ] - } + } + ] } ``` @@ -79,13 +78,15 @@ For how sandboxing behaves at runtime, see [Sandboxing](/gateway/sandboxing). ```json { - "routing": { - "agents": { - "personal": { + "agents": { + "list": [ + { + "id": "personal", "workspace": "~/clawd-personal", "sandbox": { "mode": "off" } }, - "work": { + { + "id": "work", "workspace": "~/clawd-work", "sandbox": { "mode": "all", @@ -97,7 +98,7 @@ For how sandboxing behaves at runtime, see [Sandboxing](/gateway/sandboxing). "deny": ["browser", "gateway", "discord"] } } - } + ] } } ``` @@ -108,21 +109,23 @@ For how sandboxing behaves at runtime, see [Sandboxing](/gateway/sandboxing). ```json { - "agent": { - "sandbox": { - "mode": "non-main", // Global default - "scope": "session" - } - }, - "routing": { - "agents": { - "main": { + "agents": { + "defaults": { + "sandbox": { + "mode": "non-main", // Global default + "scope": "session" + } + }, + "list": [ + { + "id": "main", "workspace": "~/clawd", "sandbox": { "mode": "off" // Override: main never sandboxed } }, - "public": { + { + "id": "public", "workspace": "~/clawd-public", "sandbox": { "mode": "all", // Override: public always sandboxed @@ -133,7 +136,7 @@ For how sandboxing behaves at runtime, see [Sandboxing](/gateway/sandboxing). "deny": ["bash", "write", "edit"] } } - } + ] } } ``` @@ -142,40 +145,40 @@ For how sandboxing behaves at runtime, see [Sandboxing](/gateway/sandboxing). ## Configuration Precedence -When both global (`agent.*`) and agent-specific (`routing.agents[id].*`) configs exist: +When both global (`agents.defaults.*`) and agent-specific (`agents.list[].*`) configs exist: ### Sandbox Config Agent-specific settings override global: ``` -routing.agents[id].sandbox.mode > agent.sandbox.mode -routing.agents[id].sandbox.scope > agent.sandbox.scope -routing.agents[id].sandbox.workspaceRoot > agent.sandbox.workspaceRoot -routing.agents[id].sandbox.workspaceAccess > agent.sandbox.workspaceAccess -routing.agents[id].sandbox.docker.* > agent.sandbox.docker.* -routing.agents[id].sandbox.browser.* > agent.sandbox.browser.* -routing.agents[id].sandbox.prune.* > agent.sandbox.prune.* +agents.list[].sandbox.mode > agents.defaults.sandbox.mode +agents.list[].sandbox.scope > agents.defaults.sandbox.scope +agents.list[].sandbox.workspaceRoot > agents.defaults.sandbox.workspaceRoot +agents.list[].sandbox.workspaceAccess > agents.defaults.sandbox.workspaceAccess +agents.list[].sandbox.docker.* > agents.defaults.sandbox.docker.* +agents.list[].sandbox.browser.* > agents.defaults.sandbox.browser.* +agents.list[].sandbox.prune.* > agents.defaults.sandbox.prune.* ``` **Notes:** -- `routing.agents[id].sandbox.{docker,browser,prune}.*` overrides `agent.sandbox.{docker,browser,prune}.*` for that agent (ignored when sandbox scope resolves to `"shared"`). +- `agents.list[].sandbox.{docker,browser,prune}.*` overrides `agents.defaults.sandbox.{docker,browser,prune}.*` for that agent (ignored when sandbox scope resolves to `"shared"`). ### Tool Restrictions The filtering order is: -1. **Global tool policy** (`agent.tools`) -2. **Agent-specific tool policy** (`routing.agents[id].tools`) -3. **Sandbox tool policy** (`agent.sandbox.tools` or `routing.agents[id].sandbox.tools`) -4. **Subagent tool policy** (if applicable) +1. **Global tool policy** (`tools.allow` / `tools.deny`) +2. **Agent-specific tool policy** (`agents.list[].tools`) +3. **Sandbox tool policy** (`tools.sandbox.tools` or `agents.list[].tools.sandbox.tools`) +4. **Subagent tool policy** (`tools.subagents.tools`, if applicable) Each level can further restrict tools, but cannot grant back denied tools from earlier levels. -If `routing.agents[id].sandbox.tools` is set, it replaces `agent.sandbox.tools` for that agent. +If `agents.list[].tools.sandbox.tools` is set, it replaces `tools.sandbox.tools` for that agent. ### Elevated Mode (global) -`agent.elevated` is **global** and **sender-based** (per-provider allowlist). It is **not** configurable per agent. +`tools.elevated` is **global** and **sender-based** (per-provider allowlist). It is **not** configurable per agent. Mitigation patterns: -- Deny `bash` for untrusted agents (`routing.agents[id].tools.deny: ["bash"]`) +- Deny `bash` for untrusted agents (`agents.list[].tools.deny: ["bash"]`) - Avoid allowlisting senders that route to restricted agents -- Disable elevated globally (`agent.elevated.enabled: false`) if you only want sandboxed execution +- Disable elevated globally (`tools.elevated.enabled: false`) if you only want sandboxed execution --- @@ -184,10 +187,16 @@ Mitigation patterns: **Before (single agent):** ```json { - "agent": { - "workspace": "~/clawd", + "agents": { + "defaults": { + "workspace": "~/clawd", + "sandbox": { + "mode": "non-main" + } + } + }, + "tools": { "sandbox": { - "mode": "non-main", "tools": { "allow": ["read", "write", "bash"], "deny": [] @@ -200,21 +209,20 @@ Mitigation patterns: **After (multi-agent with different profiles):** ```json { - "routing": { - "defaultAgentId": "main", - "agents": { - "main": { + "agents": { + "list": [ + { + "id": "main", + "default": true, "workspace": "~/clawd", - "sandbox": { - "mode": "off" - } + "sandbox": { "mode": "off" } } - } + ] } } ``` -The global `agent.workspace` and `agent.sandbox` are still supported for backward compatibility, but we recommend using `routing.agents` for clarity in multi-agent setups. +Legacy `agent.*` configs are migrated by `clawdbot doctor`; prefer `agents.defaults` + `agents.list` going forward. --- @@ -254,10 +262,10 @@ The global `agent.workspace` and `agent.sandbox` are still supported for backwar ## Common Pitfall: "non-main" -`sandbox.mode: "non-main"` is based on `session.mainKey` (default `"main"`), +`agents.defaults.sandbox.mode: "non-main"` is based on `session.mainKey` (default `"main"`), not the agent id. Group/channel sessions always get their own keys, so they are treated as non-main and will be sandboxed. If you want an agent to never -sandbox, set `routing.agents..sandbox.mode: "off"`. +sandbox, set `agents.list[].sandbox.mode: "off"`. --- @@ -289,8 +297,8 @@ After configuring multi-agent sandbox and tools: ## Troubleshooting ### Agent not sandboxed despite `mode: "all"` -- Check if there's a global `agent.sandbox.mode` that overrides it -- Agent-specific config takes precedence, so set `routing.agents[id].sandbox.mode: "all"` +- Check if there's a global `agents.defaults.sandbox.mode` that overrides it +- Agent-specific config takes precedence, so set `agents.list[].sandbox.mode: "all"` ### Tools still available despite deny list - Check tool filtering order: global → agent → sandbox → subagent @@ -306,5 +314,5 @@ After configuring multi-agent sandbox and tools: ## See Also - [Multi-Agent Routing](/concepts/multi-agent) -- [Sandbox Configuration](/gateway/configuration#agent-sandbox) +- [Sandbox Configuration](/gateway/configuration#agentsdefaults-sandbox) - [Session Management](/concepts/session) diff --git a/docs/nodes/audio.md b/docs/nodes/audio.md index db0507b71..402dd700d 100644 --- a/docs/nodes/audio.md +++ b/docs/nodes/audio.md @@ -6,7 +6,7 @@ read_when: # Audio / Voice Notes — 2025-12-05 ## What works -- **Optional transcription**: If `routing.transcribeAudio.command` is set in `~/.clawdbot/clawdbot.json`, CLAWDBOT will: +- **Optional transcription**: If `audio.transcription.command` is set in `~/.clawdbot/clawdbot.json`, CLAWDBOT will: 1) Download inbound audio to a temp path when WhatsApp only provides a URL. 2) Run the configured CLI (templated with `{{MediaPath}}`), expecting transcript on stdout. 3) Replace `Body` with the transcript, set `{{Transcript}}`, and prepend the original media path plus a `Transcript:` section in the command prompt so models see both. @@ -17,8 +17,8 @@ read_when: Requires `OPENAI_API_KEY` in env and `openai` CLI installed: ```json5 { - routing: { - transcribeAudio: { + audio: { + transcription: { command: [ "openai", "api", diff --git a/docs/nodes/images.md b/docs/nodes/images.md index 84c1a3008..15b455ff7 100644 --- a/docs/nodes/images.md +++ b/docs/nodes/images.md @@ -20,7 +20,7 @@ CLAWDBOT is now **web-only** (Baileys). This document captures the current media ## Web Provider Behavior - Input: local file path **or** HTTP(S) URL. - Flow: load into a Buffer, detect media kind, and build the correct payload: - - **Images:** resize & recompress to JPEG (max side 2048px) targeting `agent.mediaMaxMb` (default 5 MB), capped at 6 MB. + - **Images:** resize & recompress to JPEG (max side 2048px) targeting `agents.defaults.mediaMaxMb` (default 5 MB), capped at 6 MB. - **Audio/Voice/Video:** pass-through up to 16 MB; audio is sent as a voice note (`ptt: true`). - **Documents:** anything else, up to 100 MB, with filename preserved when available. - WhatsApp GIF-style playback: send an MP4 with `gifPlayback: true` (CLI: `--gif-playback`) so mobile clients loop inline. diff --git a/docs/providers/discord.md b/docs/providers/discord.md index d1b077eaf..3f844fa7e 100644 --- a/docs/providers/discord.md +++ b/docs/providers/discord.md @@ -136,8 +136,8 @@ Example “single server, only allow me, only allow #help”: Notes: - `requireMention: true` means the bot only replies when mentioned (recommended for shared channels). -- `routing.groupChat.mentionPatterns` also count as mentions for guild messages. -- Multi-agent override: `routing.agents..mentionPatterns` takes precedence. +- `agents.list[].groupChat.mentionPatterns` (or `messages.groupChat.mentionPatterns`) also count as mentions for guild messages. +- Multi-agent override: set per-agent patterns on `agents.list[].groupChat.mentionPatterns`. - If `channels` is present, any channel not listed is denied by default. ### 6) Verify it works diff --git a/docs/providers/imessage.md b/docs/providers/imessage.md index 991bd89b8..c676fbc95 100644 --- a/docs/providers/imessage.md +++ b/docs/providers/imessage.md @@ -66,8 +66,8 @@ DMs: Groups: - `imessage.groupPolicy = open | allowlist | disabled`. - `imessage.groupAllowFrom` controls who can trigger in groups when `allowlist` is set. -- Mention gating uses `routing.groupChat.mentionPatterns` (iMessage has no native mention metadata). -- Multi-agent override: `routing.agents..mentionPatterns` takes precedence. +- Mention gating uses `agents.list[].groupChat.mentionPatterns` (or `messages.groupChat.mentionPatterns`) because iMessage has no native mention metadata. +- Multi-agent override: set per-agent patterns on `agents.list[].groupChat.mentionPatterns`. ## How it works (behavior) - `imsg` streams message events; the gateway normalizes them into the shared provider envelope. @@ -112,5 +112,5 @@ Provider options: - `imessage.textChunkLimit`: outbound chunk size (chars). Related global options: -- `routing.groupChat.mentionPatterns`. +- `agents.list[].groupChat.mentionPatterns` (or `messages.groupChat.mentionPatterns`). - `messages.responsePrefix`. diff --git a/docs/providers/signal.md b/docs/providers/signal.md index f906856e0..a3de20eac 100644 --- a/docs/providers/signal.md +++ b/docs/providers/signal.md @@ -92,6 +92,6 @@ Provider options: - `signal.mediaMaxMb`: inbound/outbound media cap (MB). Related global options: -- `routing.groupChat.mentionPatterns` (Signal does not support native mentions). -- Multi-agent override: `routing.agents..mentionPatterns` takes precedence. +- `agents.list[].groupChat.mentionPatterns` (Signal does not support native mentions). +- `messages.groupChat.mentionPatterns` (global fallback). - `messages.responsePrefix`. diff --git a/docs/providers/slack.md b/docs/providers/slack.md index 594d377b4..cabeaa53e 100644 --- a/docs/providers/slack.md +++ b/docs/providers/slack.md @@ -248,8 +248,8 @@ Slack tool actions can be gated with `slack.actions.*`: | emojiList | enabled | Custom emoji list | ## Notes -- Mention gating is controlled via `slack.channels` (set `requireMention` to `true`); `routing.groupChat.mentionPatterns` also count as mentions. -- Multi-agent override: `routing.agents..mentionPatterns` takes precedence. +- Mention gating is controlled via `slack.channels` (set `requireMention` to `true`); `agents.list[].groupChat.mentionPatterns` (or `messages.groupChat.mentionPatterns`) also count as mentions. +- Multi-agent override: set per-agent patterns on `agents.list[].groupChat.mentionPatterns`. - Reaction notifications follow `slack.reactionNotifications` (use `reactionAllowlist` with mode `allowlist`). - Bot-authored messages are ignored by default; enable via `slack.allowBots` or `slack.channels..allowBots`. - For the Slack tool, reaction removal semantics are in [/tools/reactions](/tools/reactions). diff --git a/docs/providers/telegram.md b/docs/providers/telegram.md index 42cf31cf2..6485b09dd 100644 --- a/docs/providers/telegram.md +++ b/docs/providers/telegram.md @@ -64,10 +64,10 @@ group messages, so use admin if you need full visibility. ## How it works (behavior) - Inbound messages are normalized into the shared provider envelope with reply context and media placeholders. -- Group replies require a mention by default (native @mention or `routing.groupChat.mentionPatterns`). -- Multi-agent override: `routing.agents..mentionPatterns` takes precedence. +- Group replies require a mention by default (native @mention or `agents.list[].groupChat.mentionPatterns` / `messages.groupChat.mentionPatterns`). +- Multi-agent override: set per-agent patterns on `agents.list[].groupChat.mentionPatterns`. - Replies always route back to the same Telegram chat. -- Long-polling uses grammY runner with per-chat sequencing; overall concurrency is capped by `agent.maxConcurrent`. +- Long-polling uses grammY runner with per-chat sequencing; overall concurrency is capped by `agents.defaults.maxConcurrent`. ## Formatting (Telegram HTML) - Outbound Telegram text uses `parse_mode: "HTML"` (Telegram’s supported tag subset). @@ -81,7 +81,7 @@ group messages, so use admin if you need full visibility. ## Group activation modes -By default, the bot only responds to mentions in groups (`@botname` or patterns in `routing.groupChat.mentionPatterns`). To change this behavior: +By default, the bot only responds to mentions in groups (`@botname` or patterns in `agents.list[].groupChat.mentionPatterns`). To change this behavior: ### Via config (recommended) @@ -280,7 +280,7 @@ Provider options: - `telegram.actions.sendMessage`: gate Telegram tool message sends. Related global options: -- `routing.groupChat.mentionPatterns` (mention gating patterns). -- `routing.agents..mentionPatterns` overrides for multi-agent setups. +- `agents.list[].groupChat.mentionPatterns` (mention gating patterns). +- `messages.groupChat.mentionPatterns` (global fallback). - `commands.native`, `commands.text`, `commands.useAccessGroups` (command behavior). - `messages.responsePrefix`, `messages.ackReaction`, `messages.ackReactionScope`. diff --git a/docs/providers/whatsapp.md b/docs/providers/whatsapp.md index b67e4a3cd..ec314936e 100644 --- a/docs/providers/whatsapp.md +++ b/docs/providers/whatsapp.md @@ -148,7 +148,7 @@ Behavior: ## Limits - Outbound text is chunked to `whatsapp.textChunkLimit` (default 4000). -- Media items are capped by `agent.mediaMaxMb` (default 5 MB). +- Media items are capped by `agents.defaults.mediaMaxMb` (default 5 MB). ## Outbound send (text + media) - Uses active web listener; error if gateway not running. @@ -164,13 +164,13 @@ Behavior: ## Media limits + optimization - Default cap: 5 MB (per media item). -- Override: `agent.mediaMaxMb`. +- Override: `agents.defaults.mediaMaxMb`. - Images are auto-optimized to JPEG under cap (resize + quality sweep). - Oversize media => error; media reply falls back to text warning. ## Heartbeats - **Gateway heartbeat** logs connection health (`web.heartbeatSeconds`, default 60s). -- **Agent heartbeat** is global (`agent.heartbeat.*`) and runs in the main session. +- **Agent heartbeat** is global (`agents.defaults.heartbeat.*`) and runs in the main session. - Uses the configured heartbeat prompt (default: `Read HEARTBEAT.md if exists. Consider outstanding tasks. Checkup sometimes on your human during (user local) day time.`) + `HEARTBEAT_OK` skip behavior. - Delivery defaults to the last used provider (or configured target). @@ -189,16 +189,15 @@ Behavior: - `whatsapp.groupPolicy` (group policy). - `whatsapp.groups` (group allowlist + mention gating defaults; use `"*"` to allow all) - `whatsapp.actions.reactions` (gate WhatsApp tool reactions). -- `routing.groupChat.mentionPatterns` -- Multi-agent override: `routing.agents..mentionPatterns` takes precedence. -- `routing.groupChat.historyLimit` +- `agents.list[].groupChat.mentionPatterns` (or `messages.groupChat.mentionPatterns`) +- `messages.groupChat.historyLimit` - `messages.messagePrefix` (inbound prefix) - `messages.responsePrefix` (outbound prefix) -- `agent.mediaMaxMb` -- `agent.heartbeat.every` -- `agent.heartbeat.model` (optional override) -- `agent.heartbeat.target` -- `agent.heartbeat.to` +- `agents.defaults.mediaMaxMb` +- `agents.defaults.heartbeat.every` +- `agents.defaults.heartbeat.model` (optional override) +- `agents.defaults.heartbeat.target` +- `agents.defaults.heartbeat.to` - `session.*` (scope, idle, store, mainKey) - `web.enabled` (disable provider startup when false) - `web.heartbeatSeconds` diff --git a/docs/reference/AGENTS.default.md b/docs/reference/AGENTS.default.md index a7d33f0bd..cce2c4cda 100644 --- a/docs/reference/AGENTS.default.md +++ b/docs/reference/AGENTS.default.md @@ -8,7 +8,7 @@ read_when: ## First run (recommended) -Clawdbot uses a dedicated workspace directory for the agent. Default: `~/clawd` (configurable via `agent.workspace`). +Clawdbot uses a dedicated workspace directory for the agent. Default: `~/clawd` (configurable via `agents.defaults.workspace`). 1) Create the workspace (if it doesn’t already exist): @@ -30,13 +30,11 @@ cp docs/reference/templates/TOOLS.md ~/clawd/TOOLS.md cp docs/reference/AGENTS.default.md ~/clawd/AGENTS.md ``` -4) Optional: choose a different workspace by setting `agent.workspace` (supports `~`): +4) Optional: choose a different workspace by setting `agents.defaults.workspace` (supports `~`): ```json5 { - agent: { - workspace: "~/clawd" - } + agents: { defaults: { workspace: "~/clawd" } } } ``` diff --git a/docs/start/clawd.md b/docs/start/clawd.md index 9dde7d4f1..a859116d3 100644 --- a/docs/start/clawd.md +++ b/docs/start/clawd.md @@ -18,7 +18,7 @@ You’re putting an agent in a position to: Start conservative: - Always set `whatsapp.allowFrom` (never run open-to-the-world on your personal Mac). - Use a dedicated WhatsApp number for the assistant. -- Heartbeats now default to every 30 minutes. Disable until you trust the setup by setting `agent.heartbeat.every: "0m"`. +- Heartbeats now default to every 30 minutes. Disable until you trust the setup by setting `agents.defaults.heartbeat.every: "0m"`. ## Prerequisites @@ -103,7 +103,7 @@ clawdbot setup Full workspace layout + backup guide: [`docs/agent-workspace.md`](/concepts/agent-workspace) -Optional: choose a different workspace with `agent.workspace` (supports `~`). +Optional: choose a different workspace with `agents.defaults.workspace` (supports `~`). ```json5 { @@ -173,9 +173,9 @@ Example: By default, CLAWDBOT runs a heartbeat every 30 minutes with the prompt: `Read HEARTBEAT.md if exists. Consider outstanding tasks. Checkup sometimes on your human during (user local) day time.` -Set `agent.heartbeat.every: "0m"` to disable. +Set `agents.defaults.heartbeat.every: "0m"` to disable. -- If the agent replies with `HEARTBEAT_OK` (optionally with short padding; see `agent.heartbeat.ackMaxChars`), CLAWDBOT suppresses outbound delivery for that heartbeat. +- If the agent replies with `HEARTBEAT_OK` (optionally with short padding; see `agents.defaults.heartbeat.ackMaxChars`), CLAWDBOT suppresses outbound delivery for that heartbeat. - Heartbeats run full agent turns — shorter intervals burn more tokens. ```json5 diff --git a/docs/start/faq.md b/docs/start/faq.md index 06be764b1..14b7db395 100644 --- a/docs/start/faq.md +++ b/docs/start/faq.md @@ -115,14 +115,14 @@ Everything lives under `$CLAWDBOT_STATE_DIR` (default: `~/.clawdbot`): Legacy single‑agent path: `~/.clawdbot/agent/*` (migrated by `clawdbot doctor`). -Your **workspace** (AGENTS.md, memory files, skills, etc.) is separate and configured via `agent.workspace` (default: `~/clawd`). +Your **workspace** (AGENTS.md, memory files, skills, etc.) is separate and configured via `agents.defaults.workspace` (default: `~/clawd`). ### Can agents work outside the workspace? Yes. The workspace is the **default cwd** and memory anchor, not a hard sandbox. Relative paths resolve inside the workspace, but absolute paths can access other host locations unless sandboxing is enabled. If you need isolation, use -[`agent.sandbox`](/gateway/sandboxing) or per‑agent sandbox settings. If you +[`agents.defaults.sandbox`](/gateway/sandboxing) or per‑agent sandbox settings. If you want a repo to be the default working directory, point that agent’s `workspace` to the repo root. The Clawdbot repo is just source code; keep the workspace separate unless you intentionally want the agent to work inside it. @@ -259,7 +259,7 @@ Direct chats collapse to the main session by default. Groups/channels have their Clawdbot’s default model is whatever you set as: ``` -agent.model.primary +agents.defaults.model.primary ``` Models are referenced as `provider/model` (example: `anthropic/claude-opus-4-5`). If you omit the provider, Clawdbot currently assumes `anthropic` as a temporary deprecation fallback — but you should still **explicitly** set `provider/model`. @@ -282,7 +282,7 @@ You can list available models with `/model`, `/model list`, or `/model status`. ### Why do I see “Model … is not allowed” and then no reply? -If `agent.models` is set, it becomes the **allowlist** for `/model` and any +If `agents.defaults.models` is set, it becomes the **allowlist** for `/model` and any session overrides. Choosing a model that isn’t in that list returns: ``` @@ -290,11 +290,11 @@ Model "provider/model" is not allowed. Use /model to list available models. ``` That error is returned **instead of** a normal reply. Fix: add the model to -`agent.models`, remove the allowlist, or pick a model from `/model list`. +`agents.defaults.models`, remove the allowlist, or pick a model from `/model list`. ### Are opus / sonnet / gpt built‑in shortcuts? -Yes. Clawdbot ships a few default shorthands (only applied when the model exists in `agent.models`): +Yes. Clawdbot ships a few default shorthands (only applied when the model exists in `agents.defaults.models`): - `opus` → `anthropic/claude-opus-4-5` - `sonnet` → `anthropic/claude-sonnet-4-5` @@ -307,7 +307,7 @@ If you set your own alias with the same name, your value wins. ### How do I define/override model shortcuts (aliases)? -Aliases come from `agent.models..alias`. Example: +Aliases come from `agents.defaults.models..alias`. Example: ```json5 { @@ -359,7 +359,7 @@ If you reference a provider/model but the required provider key is missing, you Failover happens in two stages: 1) **Auth profile rotation** within the same provider. -2) **Model fallback** to the next model in `agent.model.fallbacks`. +2) **Model fallback** to the next model in `agents.defaults.model.fallbacks`. Cooldowns apply to failing profiles (exponential backoff), so Clawdbot can keep responding even when a provider is rate‑limited or temporarily failing. @@ -387,7 +387,7 @@ It means the system attempted to use the auth profile ID `anthropic:default`, bu If your model config includes Google Gemini as a fallback (or you switched to a Gemini shorthand), Clawdbot will try it during model fallback. If you haven’t configured Google credentials, you’ll see `No API key found for provider "google"`. -Fix: either provide Google auth, or remove/avoid Google models in `agent.model.fallbacks` / aliases so fallback doesn’t route there. +Fix: either provide Google auth, or remove/avoid Google models in `agents.defaults.model.fallbacks` / aliases so fallback doesn’t route there. ## Auth profiles: what they are and how to manage them @@ -506,7 +506,7 @@ Yes, but you must isolate: - `CLAWDBOT_CONFIG_PATH` (per‑instance config) - `CLAWDBOT_STATE_DIR` (per‑instance state) -- `agent.workspace` (workspace isolation) +- `agents.defaults.workspace` (workspace isolation) - `gateway.port` (unique ports) There are convenience CLI flags like `--dev` and `--profile ` that shift state dirs and ports. @@ -619,7 +619,7 @@ You can add options like `debounce:2s cap:25 drop:summarize` for followup modes. ### “All models failed” — what should I check first? - **Credentials** present for the provider(s) being tried (auth profiles + env vars). -- **Model routing**: confirm `agent.model.primary` and fallbacks are models you can access. +- **Model routing**: confirm `agents.defaults.model.primary` and fallbacks are models you can access. - **Gateway logs** in `/tmp/clawdbot/…` for the exact provider error. - **`/model status`** to see current configured models + shorthands. @@ -658,7 +658,7 @@ clawdbot providers login **Q: “What’s the default model for Anthropic with an API key?”** -**A:** In Clawdbot, credentials and model selection are separate. Setting `ANTHROPIC_API_KEY` (or storing an Anthropic API key in auth profiles) enables authentication, but the actual default model is whatever you configure in `agent.model.primary` (for example, `anthropic/claude-sonnet-4-5` or `anthropic/claude-opus-4-5`). If you see `No credentials found for profile "anthropic:default"`, it means the Gateway couldn’t find Anthropic credentials in the expected `auth-profiles.json` for the agent that’s running. +**A:** In Clawdbot, credentials and model selection are separate. Setting `ANTHROPIC_API_KEY` (or storing an Anthropic API key in auth profiles) enables authentication, but the actual default model is whatever you configure in `agents.defaults.model.primary` (for example, `anthropic/claude-sonnet-4-5` or `anthropic/claude-opus-4-5`). If you see `No credentials found for profile "anthropic:default"`, it means the Gateway couldn’t find Anthropic credentials in the expected `auth-profiles.json` for the agent that’s running. --- diff --git a/docs/start/getting-started.md b/docs/start/getting-started.md index f81d70a20..330a6af04 100644 --- a/docs/start/getting-started.md +++ b/docs/start/getting-started.md @@ -19,7 +19,7 @@ Recommended path: use the **CLI onboarding wizard** (`clawdbot onboard`). It set If you want the deeper reference pages, jump to: [Wizard](/start/wizard), [Setup](/start/setup), [Pairing](/start/pairing), [Security](/gateway/security). -Sandboxing note: `agent.sandbox.mode: "non-main"` uses `session.mainKey` (default `"main"`), +Sandboxing note: `agents.defaults.sandbox.mode: "non-main"` uses `session.mainKey` (default `"main"`), so group/channel sessions are sandboxed. If you want the main agent to always run on host, set an explicit per-agent override: diff --git a/docs/start/wizard.md b/docs/start/wizard.md index bc52f2c47..652653564 100644 --- a/docs/start/wizard.md +++ b/docs/start/wizard.md @@ -71,12 +71,12 @@ Tip: `--json` does **not** imply non-interactive mode. Use `--non-interactive` ( 2) **Model/Auth** - **Anthropic OAuth (Claude CLI)**: on macOS the wizard checks Keychain item "Claude Code-credentials" (choose "Always Allow" so launchd starts don't block); on Linux/Windows it reuses `~/.claude/.credentials.json` if present. -- **Anthropic token (paste setup-token)**: run `claude setup-token` in your terminal, then paste the token (you can name it; blank = default). -- **OpenAI Codex OAuth (Codex CLI)**: if `~/.codex/auth.json` exists, the wizard can reuse it. -- **OpenAI Codex OAuth**: browser flow; paste the `code#state`. - - Sets `agent.model` to `openai-codex/gpt-5.2` when model is unset or `openai/*`. -- **OpenAI API key**: uses `OPENAI_API_KEY` if present or prompts for a key, then saves it to `~/.clawdbot/.env` so launchd can read it. -- **API key**: stores the key for you. + - **Anthropic token (paste setup-token)**: run `claude setup-token` in your terminal, then paste the token (you can name it; blank = default). + - **OpenAI Codex OAuth (Codex CLI)**: if `~/.codex/auth.json` exists, the wizard can reuse it. + - **OpenAI Codex OAuth**: browser flow; paste the `code#state`. + - Sets `agents.defaults.model` to `openai-codex/gpt-5.2` when model is unset or `openai/*`. + - **OpenAI API key**: uses `OPENAI_API_KEY` if present or prompts for a key, then saves it to `~/.clawdbot/.env` so launchd can read it. + - **API key**: stores the key for you. - **Minimax M2.1 (LM Studio)**: config is auto‑written for the LM Studio endpoint. - **Skip**: no auth configured yet. - Wizard runs a model check and warns if the configured model is unknown or missing auth. @@ -144,14 +144,14 @@ Use `clawdbot agents add ` to create a separate agent with its own workspa sessions, and auth profiles. Running without `--workspace` launches the wizard. What it sets: -- `routing.agents..name` -- `routing.agents..workspace` -- `routing.agents..agentDir` +- `agents.list[].name` +- `agents.list[].workspace` +- `agents.list[].agentDir` Notes: - Default workspaces follow `~/clawd-`. -- Add `routing.bindings` to route inbound messages (the wizard can do this). - - Non-interactive flags: `--model`, `--agent-dir`, `--bind`, `--non-interactive`. +- Add `bindings` to route inbound messages (the wizard can do this). +- Non-interactive flags: `--model`, `--agent-dir`, `--bind`, `--non-interactive`. ## Non‑interactive mode @@ -213,8 +213,8 @@ Notes: ## What the wizard writes Typical fields in `~/.clawdbot/clawdbot.json`: -- `agent.workspace` -- `agent.model` / `models.providers` (if Minimax chosen) +- `agents.defaults.workspace` +- `agents.defaults.model` / `models.providers` (if Minimax chosen) - `gateway.*` (mode, bind, auth, tailscale) - `telegram.botToken`, `discord.token`, `signal.*`, `imessage.*` - `skills.install.nodeManager` @@ -224,7 +224,7 @@ Typical fields in `~/.clawdbot/clawdbot.json`: - `wizard.lastRunCommand` - `wizard.lastRunMode` -`clawdbot agents add` writes `routing.agents.` and optional `routing.bindings`. +`clawdbot agents add` writes `agents.list[]` and optional `bindings`. WhatsApp credentials go under `~/.clawdbot/credentials/whatsapp//`. Sessions are stored under `~/.clawdbot/agents//sessions/`. diff --git a/docs/tools/elevated.md b/docs/tools/elevated.md index 746edc9cf..abdf34740 100644 --- a/docs/tools/elevated.md +++ b/docs/tools/elevated.md @@ -12,7 +12,7 @@ read_when: - Only `on|off` are accepted; anything else returns a hint and does not change state. ## What it controls (and what it doesn’t) -- **Global availability gate**: `agent.elevated` is global (not per-agent). If disabled or sender not allowlisted, elevated is unavailable everywhere. +- **Global availability gate**: `tools.elevated` is global (not per-agent). If disabled or sender not allowlisted, elevated is unavailable everywhere. - **Per-session state**: `/elevated on|off` sets the elevated level for the current session key. - **Inline directive**: `/elevated on` inside a message applies to that message only. - **Groups**: In group chats, elevated directives are only honored when the agent is mentioned. Command-only messages that bypass mention requirements are treated as mentioned. @@ -31,7 +31,7 @@ Note: ## Resolution order 1. Inline directive on the message (applies only to that message). 2. Session override (set by sending a directive-only message). -3. Global default (`agent.elevatedDefault` in config). +3. Global default (`agents.defaults.elevatedDefault` in config). ## Setting a session default - Send a message that is **only** the directive (whitespace allowed), e.g. `/elevated on`. @@ -40,10 +40,10 @@ Note: - Send `/elevated` (or `/elevated:`) with no argument to see the current elevated level. ## Availability + allowlists -- Feature gate: `agent.elevated.enabled` (default can be off via config even if the code supports it). -- Sender allowlist: `agent.elevated.allowFrom` with per-provider allowlists (e.g. `discord`, `whatsapp`). +- Feature gate: `tools.elevated.enabled` (default can be off via config even if the code supports it). +- Sender allowlist: `tools.elevated.allowFrom` with per-provider allowlists (e.g. `discord`, `whatsapp`). - Both must pass; otherwise elevated is treated as unavailable. -- Discord fallback: if `agent.elevated.allowFrom.discord` is omitted, the `discord.dm.allowFrom` list is used as a fallback. Set `agent.elevated.allowFrom.discord` (even `[]`) to override. +- Discord fallback: if `tools.elevated.allowFrom.discord` is omitted, the `discord.dm.allowFrom` list is used as a fallback. Set `tools.elevated.allowFrom.discord` (even `[]`) to override. ## Logging + status - Elevated bash calls are logged at info level. diff --git a/docs/tools/index.md b/docs/tools/index.md index aa663a0ea..f4e7c0aa4 100644 --- a/docs/tools/index.md +++ b/docs/tools/index.md @@ -13,16 +13,12 @@ and the agent should rely on them directly. ## Disabling tools -You can globally allow/deny tools via `agent.tools` in `clawdbot.json` +You can globally allow/deny tools via `tools.allow` / `tools.deny` in `clawdbot.json` (deny wins). This prevents disallowed tools from being sent to providers. ```json5 { - agent: { - tools: { - deny: ["browser"] - } - } + tools: { deny: ["browser"] } } ``` @@ -43,7 +39,7 @@ Notes: - Returns `status: "running"` with a `sessionId` when backgrounded. - Use `process` to poll/log/write/kill/clear background sessions. - If `process` is disallowed, `bash` runs synchronously and ignores `yieldMs`/`background`. -- `elevated` is gated by `agent.elevated` (global sender allowlist) and runs on the host. +- `elevated` is gated by `tools.elevated` (global sender allowlist) and runs on the host. - `elevated` only changes behavior when the agent is sandboxed (otherwise it’s a no-op). ### `process` @@ -145,7 +141,7 @@ Core parameters: - `maxBytesMb` (optional size cap) Notes: -- Only available when `agent.imageModel` is configured (primary or fallbacks). +- Only available when `agents.defaults.imageModel` is configured (primary or fallbacks). - Uses the image model directly (independent of the main chat model). ### `message` @@ -219,7 +215,7 @@ Notes: List agent ids that the current session may target with `sessions_spawn`. Notes: -- Result is restricted to per-agent allowlists (`routing.agents..subagents.allowAgents`). +- Result is restricted to per-agent allowlists (`agents.list[].subagents.allowAgents`). - When `["*"]` is configured, the tool includes all configured agents and marks `allowAny: true`. ## Parameters (common) diff --git a/docs/tools/slash-commands.md b/docs/tools/slash-commands.md index b426fffc3..c17e7dc6b 100644 --- a/docs/tools/slash-commands.md +++ b/docs/tools/slash-commands.md @@ -46,7 +46,7 @@ Text + native (when enabled): - `/verbose on|off` (alias: `/v`) - `/reasoning on|off|stream` (alias: `/reason`; `stream` = Telegram draft only) - `/elevated on|off` (alias: `/elev`) -- `/model ` (or `/` from `agent.models.*.alias`) +- `/model ` (or `/` from `agents.defaults.models.*.alias`) - `/queue ` (plus options like `debounce:2s cap:25 drop:summarize`; send `/queue` to see current settings) Text-only: diff --git a/docs/tools/subagents.md b/docs/tools/subagents.md index 9151d332a..f9288bf84 100644 --- a/docs/tools/subagents.md +++ b/docs/tools/subagents.md @@ -30,13 +30,13 @@ Tool params: - `cleanup?` (`delete|keep`, default `keep`) Allowlist: -- `routing.agents..subagents.allowAgents`: list of agent ids that can be targeted via `agentId` (`["*"]` to allow any). Default: only the requester agent. +- `agents.list[].subagents.allowAgents`: list of agent ids that can be targeted via `agentId` (`["*"]` to allow any). Default: only the requester agent. Discovery: - Use `agents_list` to see which agent ids are currently allowed for `sessions_spawn`. Auto-archive: -- Sub-agent sessions are automatically archived after `agent.subagents.archiveAfterMinutes` (default: 60). +- Sub-agent sessions are automatically archived after `agents.defaults.subagents.archiveAfterMinutes` (default: 60). - Archive uses `sessions.delete` and renames the transcript to `*.deleted.` (same folder). - `cleanup: "delete"` archives immediately after announce (still keeps the transcript via rename). - Auto-archive is best-effort; pending timers are lost if the gateway restarts. @@ -67,9 +67,15 @@ Override via config: ```json5 { - agent: { + agents: { + defaults: { + subagents: { + maxConcurrent: 1 + } + } + }, + tools: { subagents: { - maxConcurrent: 1, tools: { // deny wins deny: ["gateway", "cron"], @@ -85,7 +91,7 @@ Override via config: Sub-agents use a dedicated in-process queue lane: - Lane name: `subagent` -- Concurrency: `agent.subagents.maxConcurrent` (default `1`) +- Concurrency: `agents.defaults.subagents.maxConcurrent` (default `1`) ## Limitations diff --git a/docs/tools/thinking.md b/docs/tools/thinking.md index e43701566..b5a396085 100644 --- a/docs/tools/thinking.md +++ b/docs/tools/thinking.md @@ -17,7 +17,7 @@ read_when: ## Resolution order 1. Inline directive on the message (applies only to that message). 2. Session override (set by sending a directive-only message). -3. Global default (`agent.thinkingDefault` in config). +3. Global default (`agents.defaults.thinkingDefault` in config). 4. Fallback: low for reasoning-capable models; off otherwise. ## Setting a session default diff --git a/scripts/e2e/onboard-docker.sh b/scripts/e2e/onboard-docker.sh index 468434042..8b3259d92 100755 --- a/scripts/e2e/onboard-docker.sh +++ b/scripts/e2e/onboard-docker.sh @@ -231,8 +231,10 @@ const cfg = JSON5.parse(fs.readFileSync(process.env.CONFIG_PATH, "utf-8")); const expectedWorkspace = process.env.WORKSPACE_DIR; const errors = []; -if (cfg?.agent?.workspace !== expectedWorkspace) { - errors.push(`agent.workspace mismatch (got ${cfg?.agent?.workspace ?? "unset"})`); +if (cfg?.agents?.defaults?.workspace !== expectedWorkspace) { + errors.push( + `agents.defaults.workspace mismatch (got ${cfg?.agents?.defaults?.workspace ?? "unset"})`, + ); } if (cfg?.gateway?.mode !== "local") { errors.push(`gateway.mode mismatch (got ${cfg?.gateway?.mode ?? "unset"})`); diff --git a/scripts/sandbox-common-setup.sh b/scripts/sandbox-common-setup.sh index 10a047ff5..680e22421 100755 --- a/scripts/sandbox-common-setup.sh +++ b/scripts/sandbox-common-setup.sh @@ -59,7 +59,7 @@ EOF cat < { it("should return undefined when agent id does not exist", () => { const cfg: ClawdbotConfig = { - routing: { - agents: { - main: { workspace: "~/clawd" }, - }, + agents: { + list: [{ id: "main", workspace: "~/clawd" }], }, }; const result = resolveAgentConfig(cfg, "nonexistent"); @@ -23,15 +21,16 @@ describe("resolveAgentConfig", () => { it("should return basic agent config", () => { const cfg: ClawdbotConfig = { - routing: { - agents: { - main: { + agents: { + list: [ + { + id: "main", name: "Main Agent", workspace: "~/clawd", agentDir: "~/.clawdbot/agents/main", model: "anthropic/claude-opus-4", }, - }, + ], }, }; const result = resolveAgentConfig(cfg, "main"); @@ -40,6 +39,9 @@ describe("resolveAgentConfig", () => { workspace: "~/clawd", agentDir: "~/.clawdbot/agents/main", model: "anthropic/claude-opus-4", + identity: undefined, + groupChat: undefined, + subagents: undefined, sandbox: undefined, tools: undefined, }); @@ -47,9 +49,10 @@ describe("resolveAgentConfig", () => { it("should return agent-specific sandbox config", () => { const cfg: ClawdbotConfig = { - routing: { - agents: { - work: { + agents: { + list: [ + { + id: "work", workspace: "~/clawd-work", sandbox: { mode: "all", @@ -57,13 +60,9 @@ describe("resolveAgentConfig", () => { perSession: false, workspaceAccess: "ro", workspaceRoot: "~/sandboxes", - tools: { - allow: ["read"], - deny: ["bash"], - }, }, }, - }, + ], }, }; const result = resolveAgentConfig(cfg, "work"); @@ -73,25 +72,22 @@ describe("resolveAgentConfig", () => { perSession: false, workspaceAccess: "ro", workspaceRoot: "~/sandboxes", - tools: { - allow: ["read"], - deny: ["bash"], - }, }); }); it("should return agent-specific tools config", () => { const cfg: ClawdbotConfig = { - routing: { - agents: { - restricted: { + agents: { + list: [ + { + id: "restricted", workspace: "~/clawd-restricted", tools: { allow: ["read"], deny: ["bash", "write", "edit"], }, }, - }, + ], }, }; const result = resolveAgentConfig(cfg, "restricted"); @@ -103,9 +99,10 @@ describe("resolveAgentConfig", () => { it("should return both sandbox and tools config", () => { const cfg: ClawdbotConfig = { - routing: { - agents: { - family: { + agents: { + list: [ + { + id: "family", workspace: "~/clawd-family", sandbox: { mode: "all", @@ -116,7 +113,7 @@ describe("resolveAgentConfig", () => { deny: ["bash"], }, }, - }, + ], }, }; const result = resolveAgentConfig(cfg, "family"); @@ -126,10 +123,8 @@ describe("resolveAgentConfig", () => { it("should normalize agent id", () => { const cfg: ClawdbotConfig = { - routing: { - agents: { - main: { workspace: "~/clawd" }, - }, + agents: { + list: [{ id: "main", workspace: "~/clawd" }], }, }; // Should normalize to "main" (default) diff --git a/src/agents/agent-scope.ts b/src/agents/agent-scope.ts index 01edbf808..4aa5faa7f 100644 --- a/src/agents/agent-scope.ts +++ b/src/agents/agent-scope.ts @@ -11,6 +11,24 @@ import { import { resolveUserPath } from "../utils.js"; import { DEFAULT_AGENT_WORKSPACE_DIR } from "./workspace.js"; +type AgentEntry = NonNullable< + NonNullable["list"] +>[number]; + +type ResolvedAgentConfig = { + name?: string; + workspace?: string; + agentDir?: string; + model?: string; + identity?: AgentEntry["identity"]; + groupChat?: AgentEntry["groupChat"]; + subagents?: AgentEntry["subagents"]; + sandbox?: AgentEntry["sandbox"]; + tools?: AgentEntry["tools"]; +}; + +let defaultAgentWarned = false; + export function resolveAgentIdFromSessionKey( sessionKey?: string | null, ): string { @@ -18,46 +36,51 @@ export function resolveAgentIdFromSessionKey( return normalizeAgentId(parsed?.agentId ?? DEFAULT_AGENT_ID); } +function listAgents(cfg: ClawdbotConfig): AgentEntry[] { + const list = cfg.agents?.list; + if (!Array.isArray(list)) return []; + return list.filter((entry): entry is AgentEntry => + Boolean(entry && typeof entry === "object"), + ); +} + +export function resolveDefaultAgentId(cfg: ClawdbotConfig): string { + const agents = listAgents(cfg); + if (agents.length === 0) return DEFAULT_AGENT_ID; + const defaults = agents.filter((agent) => agent?.default); + if (defaults.length > 1 && !defaultAgentWarned) { + defaultAgentWarned = true; + console.warn( + "Multiple agents marked default=true; using the first entry as default.", + ); + } + const chosen = (defaults[0] ?? agents[0])?.id?.trim(); + return normalizeAgentId(chosen || DEFAULT_AGENT_ID); +} + +function resolveAgentEntry( + cfg: ClawdbotConfig, + agentId: string, +): AgentEntry | undefined { + const id = normalizeAgentId(agentId); + return listAgents(cfg).find((entry) => normalizeAgentId(entry.id) === id); +} + export function resolveAgentConfig( cfg: ClawdbotConfig, agentId: string, -): - | { - name?: string; - workspace?: string; - agentDir?: string; - model?: string; - subagents?: { - allowAgents?: string[]; - }; - sandbox?: { - mode?: "off" | "non-main" | "all"; - workspaceAccess?: "none" | "ro" | "rw"; - scope?: "session" | "agent" | "shared"; - perSession?: boolean; - workspaceRoot?: string; - tools?: { - allow?: string[]; - deny?: string[]; - }; - }; - tools?: { - allow?: string[]; - deny?: string[]; - }; - } - | undefined { +): ResolvedAgentConfig | undefined { const id = normalizeAgentId(agentId); - const agents = cfg.routing?.agents; - if (!agents || typeof agents !== "object") return undefined; - const entry = agents[id]; - if (!entry || typeof entry !== "object") return undefined; + const entry = resolveAgentEntry(cfg, id); + if (!entry) return undefined; return { name: typeof entry.name === "string" ? entry.name : undefined, workspace: typeof entry.workspace === "string" ? entry.workspace : undefined, agentDir: typeof entry.agentDir === "string" ? entry.agentDir : undefined, model: typeof entry.model === "string" ? entry.model : undefined, + identity: entry.identity, + groupChat: entry.groupChat, subagents: typeof entry.subagents === "object" && entry.subagents ? entry.subagents @@ -71,9 +94,10 @@ export function resolveAgentWorkspaceDir(cfg: ClawdbotConfig, agentId: string) { const id = normalizeAgentId(agentId); const configured = resolveAgentConfig(cfg, id)?.workspace?.trim(); if (configured) return resolveUserPath(configured); - if (id === DEFAULT_AGENT_ID) { - const legacy = cfg.agent?.workspace?.trim(); - if (legacy) return resolveUserPath(legacy); + const defaultAgentId = resolveDefaultAgentId(cfg); + if (id === defaultAgentId) { + const fallback = cfg.agents?.defaults?.workspace?.trim(); + if (fallback) return resolveUserPath(fallback); return DEFAULT_AGENT_WORKSPACE_DIR; } return path.join(os.homedir(), `clawd-${id}`); diff --git a/src/agents/auth-profiles.ts b/src/agents/auth-profiles.ts index fff259824..b60888a26 100644 --- a/src/agents/auth-profiles.ts +++ b/src/agents/auth-profiles.ts @@ -925,7 +925,10 @@ export function resolveAuthProfileOrder(params: { // Still put preferredProfile first if specified if (preferredProfile && ordered.includes(preferredProfile)) { - return [preferredProfile, ...ordered.filter((e) => e !== preferredProfile)]; + return [ + preferredProfile, + ...ordered.filter((e) => e !== preferredProfile), + ]; } return ordered; } diff --git a/src/agents/claude-cli-runner.ts b/src/agents/claude-cli-runner.ts index ed79afeec..3b67e131c 100644 --- a/src/agents/claude-cli-runner.ts +++ b/src/agents/claude-cli-runner.ts @@ -108,7 +108,7 @@ function formatUserTime(date: Date, timeZone: string): string | undefined { } function buildModelAliasLines(cfg?: ClawdbotConfig) { - const models = cfg?.agent?.models ?? {}; + const models = cfg?.agents?.defaults?.models ?? {}; const entries: Array<{ alias: string; model: string }> = []; for (const [keyRaw, entryRaw] of Object.entries(models)) { const model = String(keyRaw ?? "").trim(); @@ -134,7 +134,9 @@ function buildSystemPrompt(params: { contextFiles?: EmbeddedContextFile[]; modelDisplay: string; }) { - const userTimezone = resolveUserTimezone(params.config?.agent?.userTimezone); + const userTimezone = resolveUserTimezone( + params.config?.agents?.defaults?.userTimezone, + ); const userTime = formatUserTime(new Date(), userTimezone); return buildAgentSystemPrompt({ workspaceDir: params.workspaceDir, @@ -143,7 +145,7 @@ function buildSystemPrompt(params: { ownerNumbers: params.ownerNumbers, reasoningTagHint: false, heartbeatPrompt: resolveHeartbeatPrompt( - params.config?.agent?.heartbeat?.prompt, + params.config?.agents?.defaults?.heartbeat?.prompt, ), runtimeInfo: { host: "clawdbot", diff --git a/src/agents/clawdbot-gateway-tool.test.ts b/src/agents/clawdbot-gateway-tool.test.ts index 7bdc9f5b4..04cbf8599 100644 --- a/src/agents/clawdbot-gateway-tool.test.ts +++ b/src/agents/clawdbot-gateway-tool.test.ts @@ -46,7 +46,7 @@ describe("gateway tool", () => { expect(tool).toBeDefined(); if (!tool) throw new Error("missing gateway tool"); - const raw = '{\n agent: { workspace: "~/clawd" }\n}\n'; + const raw = '{\n agents: { defaults: { workspace: "~/clawd" } }\n}\n'; await tool.execute("call2", { action: "config.apply", raw, diff --git a/src/agents/clawdbot-tools.agents.test.ts b/src/agents/clawdbot-tools.agents.test.ts index 225779595..b3d4ab76e 100644 --- a/src/agents/clawdbot-tools.agents.test.ts +++ b/src/agents/clawdbot-tools.agents.test.ts @@ -52,18 +52,20 @@ describe("agents_list", () => { mainKey: "main", scope: "per-sender", }, - routing: { - agents: { - main: { + agents: { + list: [ + { + id: "main", name: "Main", subagents: { allowAgents: ["research"], }, }, - research: { + { + id: "research", name: "Research", }, - }, + ], }, }; @@ -87,20 +89,23 @@ describe("agents_list", () => { mainKey: "main", scope: "per-sender", }, - routing: { - agents: { - main: { + agents: { + list: [ + { + id: "main", subagents: { allowAgents: ["*"], }, }, - research: { + { + id: "research", name: "Research", }, - coder: { + { + id: "coder", name: "Coder", }, - }, + ], }, }; @@ -131,14 +136,15 @@ describe("agents_list", () => { mainKey: "main", scope: "per-sender", }, - routing: { - agents: { - main: { + agents: { + list: [ + { + id: "main", subagents: { allowAgents: ["research"], }, }, - }, + ], }, }; diff --git a/src/agents/clawdbot-tools.subagents.test.ts b/src/agents/clawdbot-tools.subagents.test.ts index fa4020227..855f219f1 100644 --- a/src/agents/clawdbot-tools.subagents.test.ts +++ b/src/agents/clawdbot-tools.subagents.test.ts @@ -314,14 +314,15 @@ describe("subagents", () => { mainKey: "main", scope: "per-sender", }, - routing: { - agents: { - main: { + agents: { + list: [ + { + id: "main", subagents: { allowAgents: ["beta"], }, }, - }, + ], }, }; @@ -365,14 +366,15 @@ describe("subagents", () => { mainKey: "main", scope: "per-sender", }, - routing: { - agents: { - main: { + agents: { + list: [ + { + id: "main", subagents: { allowAgents: ["*"], }, }, - }, + ], }, }; @@ -416,14 +418,15 @@ describe("subagents", () => { mainKey: "main", scope: "per-sender", }, - routing: { - agents: { - main: { + agents: { + list: [ + { + id: "main", subagents: { allowAgents: ["Research"], }, }, - }, + ], }, }; @@ -467,14 +470,15 @@ describe("subagents", () => { mainKey: "main", scope: "per-sender", }, - routing: { - agents: { - main: { + agents: { + list: [ + { + id: "main", subagents: { allowAgents: ["alpha"], }, }, - }, + ], }, }; diff --git a/src/agents/model-fallback.ts b/src/agents/model-fallback.ts index 96d9abeb5..78989a16d 100644 --- a/src/agents/model-fallback.ts +++ b/src/agents/model-fallback.ts @@ -34,7 +34,7 @@ function buildAllowedModelKeys( defaultProvider: string, ): Set | null { const rawAllowlist = (() => { - const modelMap = cfg?.agent?.models ?? {}; + const modelMap = cfg?.agents?.defaults?.models ?? {}; return Object.keys(modelMap); })(); if (rawAllowlist.length === 0) return null; @@ -85,7 +85,7 @@ function resolveImageFallbackCandidates(params: { if (params.modelOverride?.trim()) { addRaw(params.modelOverride, false); } else { - const imageModel = params.cfg?.agent?.imageModel as + const imageModel = params.cfg?.agents?.defaults?.imageModel as | { primary?: string } | string | undefined; @@ -95,7 +95,7 @@ function resolveImageFallbackCandidates(params: { } const imageFallbacks = (() => { - const imageModel = params.cfg?.agent?.imageModel as + const imageModel = params.cfg?.agents?.defaults?.imageModel as | { fallbacks?: string[] } | string | undefined; @@ -142,7 +142,7 @@ function resolveFallbackCandidates(params: { addCandidate({ provider, model }, false); const modelFallbacks = (() => { - const model = params.cfg?.agent?.model as + const model = params.cfg?.agents?.defaults?.model as | { fallbacks?: string[] } | string | undefined; @@ -253,7 +253,7 @@ export async function runWithImageModelFallback(params: { }); if (candidates.length === 0) { throw new Error( - "No image model configured. Set agent.imageModel.primary or agent.imageModel.fallbacks.", + "No image model configured. Set agents.defaults.imageModel.primary or agents.defaults.imageModel.fallbacks.", ); } diff --git a/src/agents/model-selection.test.ts b/src/agents/model-selection.test.ts index 8281941e7..25a1f06be 100644 --- a/src/agents/model-selection.test.ts +++ b/src/agents/model-selection.test.ts @@ -18,9 +18,11 @@ const catalog = [ describe("buildAllowedModelSet", () => { it("always allows the configured default model", () => { const cfg = { - agent: { - models: { - "openai/gpt-4": { alias: "gpt4" }, + agents: { + defaults: { + models: { + "openai/gpt-4": { alias: "gpt4" }, + }, }, }, } as ClawdbotConfig; @@ -41,7 +43,7 @@ describe("buildAllowedModelSet", () => { it("includes the default model when no allowlist is set", () => { const cfg = { - agent: {}, + agents: { defaults: {} }, } as ClawdbotConfig; const allowed = buildAllowedModelSet({ diff --git a/src/agents/model-selection.ts b/src/agents/model-selection.ts index 7e0f0b411..8d199a6c0 100644 --- a/src/agents/model-selection.ts +++ b/src/agents/model-selection.ts @@ -65,7 +65,7 @@ export function buildModelAliasIndex(params: { const byAlias = new Map(); const byKey = new Map(); - const rawModels = params.cfg.agent?.models ?? {}; + const rawModels = params.cfg.agents?.defaults?.models ?? {}; for (const [keyRaw, entryRaw] of Object.entries(rawModels)) { const parsed = parseModelRef(String(keyRaw ?? ""), params.defaultProvider); if (!parsed) continue; @@ -109,7 +109,7 @@ export function resolveConfiguredModelRef(params: { defaultModel: string; }): ModelRef { const rawModel = (() => { - const raw = params.cfg.agent?.model as + const raw = params.cfg.agents?.defaults?.model as | { primary?: string } | string | undefined; @@ -128,7 +128,7 @@ export function resolveConfiguredModelRef(params: { aliasIndex, }); if (resolved) return resolved.ref; - // TODO(steipete): drop this fallback once provider-less agent.model is fully deprecated. + // TODO(steipete): drop this fallback once provider-less agents.defaults.model is fully deprecated. return { provider: "anthropic", model: trimmed }; } return { provider: params.defaultProvider, model: params.defaultModel }; @@ -145,7 +145,7 @@ export function buildAllowedModelSet(params: { allowedKeys: Set; } { const rawAllowlist = (() => { - const modelMap = params.cfg.agent?.models ?? {}; + const modelMap = params.cfg.agents?.defaults?.models ?? {}; return Object.keys(modelMap); })(); const allowAny = rawAllowlist.length === 0; @@ -203,7 +203,7 @@ export function resolveThinkingDefault(params: { model: string; catalog?: ModelCatalogEntry[]; }): ThinkLevel { - const configured = params.cfg.agent?.thinkingDefault; + const configured = params.cfg.agents?.defaults?.thinkingDefault; if (configured) return configured; const candidate = params.catalog?.find( (entry) => entry.provider === params.provider && entry.id === params.model, diff --git a/src/agents/pi-embedded-runner-extraparams.test.ts b/src/agents/pi-embedded-runner-extraparams.test.ts index 8ffe0352f..d1243da61 100644 --- a/src/agents/pi-embedded-runner-extraparams.test.ts +++ b/src/agents/pi-embedded-runner-extraparams.test.ts @@ -110,12 +110,14 @@ describe("resolveExtraParams", () => { it("respects explicit thinking config from user (disable thinking)", () => { const result = resolveExtraParams({ cfg: { - agent: { - models: { - "zai/glm-4.7": { - params: { - thinking: { - type: "disabled", + agents: { + defaults: { + models: { + "zai/glm-4.7": { + params: { + thinking: { + type: "disabled", + }, }, }, }, @@ -136,12 +138,14 @@ describe("resolveExtraParams", () => { it("preserves other params while adding thinking config", () => { const result = resolveExtraParams({ cfg: { - agent: { - models: { - "zai/glm-4.7": { - params: { - temperature: 0.7, - max_tokens: 4096, + agents: { + defaults: { + models: { + "zai/glm-4.7": { + params: { + temperature: 0.7, + max_tokens: 4096, + }, }, }, }, @@ -164,13 +168,15 @@ describe("resolveExtraParams", () => { it("does not override explicit thinking config even if partial", () => { const result = resolveExtraParams({ cfg: { - agent: { - models: { - "zai/glm-4.7": { - params: { - thinking: { - type: "enabled", - // User explicitly omitted clear_thinking + agents: { + defaults: { + models: { + "zai/glm-4.7": { + params: { + thinking: { + type: "enabled", + // User explicitly omitted clear_thinking + }, }, }, }, @@ -214,12 +220,14 @@ describe("resolveExtraParams", () => { it("passes through params for non-GLM models without modification", () => { const result = resolveExtraParams({ cfg: { - agent: { - models: { - "openai/gpt-4": { - params: { - logprobs: true, - top_logprobs: 5, + agents: { + defaults: { + models: { + "openai/gpt-4": { + params: { + logprobs: true, + top_logprobs: 5, + }, }, }, }, @@ -264,7 +272,7 @@ describe("resolveExtraParams", () => { it("handles config with empty models gracefully", () => { const result = resolveExtraParams({ - cfg: { agent: { models: {} } }, + cfg: { agents: { defaults: { models: {} } } }, provider: "zai", modelId: "glm-4.7", }); @@ -280,12 +288,14 @@ describe("resolveExtraParams", () => { it("model alias lookup uses exact provider/model key", () => { const result = resolveExtraParams({ cfg: { - agent: { - models: { - "zai/glm-4.7": { - alias: "smart", - params: { - custom_param: "value", + agents: { + defaults: { + models: { + "zai/glm-4.7": { + alias: "smart", + params: { + custom_param: "value", + }, }, }, }, @@ -307,11 +317,13 @@ describe("resolveExtraParams", () => { it("treats thinking: null as explicit config (no auto-enable)", () => { const result = resolveExtraParams({ cfg: { - agent: { - models: { - "zai/glm-4.7": { - params: { - thinking: null, + agents: { + defaults: { + models: { + "zai/glm-4.7": { + params: { + thinking: null, + }, }, }, }, @@ -374,11 +386,13 @@ describe("resolveExtraParams", () => { it("thinkLevel: 'off' still passes through explicit config", () => { const result = resolveExtraParams({ cfg: { - agent: { - models: { - "zai/glm-4.7": { - params: { - custom_param: "value", + agents: { + defaults: { + models: { + "zai/glm-4.7": { + params: { + custom_param: "value", + }, }, }, }, diff --git a/src/agents/pi-embedded-runner.ts b/src/agents/pi-embedded-runner.ts index d3e1aab4f..9fab11a0a 100644 --- a/src/agents/pi-embedded-runner.ts +++ b/src/agents/pi-embedded-runner.ts @@ -105,7 +105,7 @@ import { loadWorkspaceBootstrapFiles } from "./workspace.js"; * - GLM-4.5/4.6: Interleaved thinking (clear_thinking: true) - reasoning cleared each turn * * Users can override via config: - * agent.models["zai/glm-4.7"].params.thinking = { type: "disabled" } + * agents.defaults.models["zai/glm-4.7"].params.thinking = { type: "disabled" } * * Or disable via runtime flag: --thinking off * @@ -119,7 +119,7 @@ export function resolveExtraParams(params: { thinkLevel?: string; }): Record | undefined { const modelKey = `${params.provider}/${params.modelId}`; - const modelConfig = params.cfg?.agent?.models?.[modelKey]; + const modelConfig = params.cfg?.agents?.defaults?.models?.[modelKey]; let extraParams = modelConfig?.params ? { ...modelConfig.params } : undefined; // Auto-enable thinking for ZAI GLM-4.x models when not explicitly configured @@ -200,10 +200,10 @@ function resolveContextWindowTokens(params: { if (fromModelsConfig) return fromModelsConfig; const fromAgentConfig = - typeof params.cfg?.agent?.contextTokens === "number" && - Number.isFinite(params.cfg.agent.contextTokens) && - params.cfg.agent.contextTokens > 0 - ? Math.floor(params.cfg.agent.contextTokens) + typeof params.cfg?.agents?.defaults?.contextTokens === "number" && + Number.isFinite(params.cfg.agents.defaults.contextTokens) && + params.cfg.agents.defaults.contextTokens > 0 + ? Math.floor(params.cfg.agents.defaults.contextTokens) : undefined; if (fromAgentConfig) return fromAgentConfig; @@ -217,7 +217,7 @@ function buildContextPruningExtension(params: { modelId: string; model: Model | undefined; }): { additionalExtensionPaths?: string[] } { - const raw = params.cfg?.agent?.contextPruning; + const raw = params.cfg?.agents?.defaults?.contextPruning; if (raw?.mode !== "adaptive" && raw?.mode !== "aggressive") return {}; const settings = computeEffectiveSettings(raw); @@ -254,7 +254,7 @@ export type EmbeddedPiRunMeta = { }; function buildModelAliasLines(cfg?: ClawdbotConfig) { - const models = cfg?.agent?.models ?? {}; + const models = cfg?.agents?.defaults?.models ?? {}; const entries: Array<{ alias: string; model: string }> = []; for (const [keyRaw, entryRaw] of Object.entries(models)) { const model = String(keyRaw ?? "").trim(); @@ -844,7 +844,7 @@ export async function compactEmbeddedPiSession(params: { const contextFiles = buildBootstrapContextFiles(bootstrapFiles); const tools = createClawdbotCodingTools({ bash: { - ...params.config?.agent?.bash, + ...params.config?.tools?.bash, elevated: params.bashElevated, }, sandbox, @@ -865,7 +865,7 @@ export async function compactEmbeddedPiSession(params: { const sandboxInfo = buildEmbeddedSandboxInfo(sandbox); const reasoningTagHint = provider === "ollama"; const userTimezone = resolveUserTimezone( - params.config?.agent?.userTimezone, + params.config?.agents?.defaults?.userTimezone, ); const userTime = formatUserTime(new Date(), userTimezone); const appendPrompt = buildEmbeddedSystemPrompt({ @@ -875,7 +875,7 @@ export async function compactEmbeddedPiSession(params: { ownerNumbers: params.ownerNumbers, reasoningTagHint, heartbeatPrompt: resolveHeartbeatPrompt( - params.config?.agent?.heartbeat?.prompt, + params.config?.agents?.defaults?.heartbeat?.prompt, ), runtimeInfo, sandboxInfo, @@ -1157,7 +1157,7 @@ export async function runEmbeddedPiAgent(params: { // `createClawdbotCodingTools()` normalizes schemas so the session can pass them through unchanged. const tools = createClawdbotCodingTools({ bash: { - ...params.config?.agent?.bash, + ...params.config?.tools?.bash, elevated: params.bashElevated, }, sandbox, @@ -1178,7 +1178,7 @@ export async function runEmbeddedPiAgent(params: { const sandboxInfo = buildEmbeddedSandboxInfo(sandbox); const reasoningTagHint = provider === "ollama"; const userTimezone = resolveUserTimezone( - params.config?.agent?.userTimezone, + params.config?.agents?.defaults?.userTimezone, ); const userTime = formatUserTime(new Date(), userTimezone); const appendPrompt = buildEmbeddedSystemPrompt({ @@ -1188,7 +1188,7 @@ export async function runEmbeddedPiAgent(params: { ownerNumbers: params.ownerNumbers, reasoningTagHint, heartbeatPrompt: resolveHeartbeatPrompt( - params.config?.agent?.heartbeat?.prompt, + params.config?.agents?.defaults?.heartbeat?.prompt, ), runtimeInfo, sandboxInfo, @@ -1444,7 +1444,8 @@ export async function runEmbeddedPiAgent(params: { } const fallbackConfigured = - (params.config?.agent?.model?.fallbacks?.length ?? 0) > 0; + (params.config?.agents?.defaults?.model?.fallbacks?.length ?? 0) > + 0; const authFailure = isAuthAssistantError(lastAssistant); const rateLimitFailure = isRateLimitAssistantError(lastAssistant); diff --git a/src/agents/pi-tools-agent-config.test.ts b/src/agents/pi-tools-agent-config.test.ts index 4756e72d2..6bb9b2b2a 100644 --- a/src/agents/pi-tools-agent-config.test.ts +++ b/src/agents/pi-tools-agent-config.test.ts @@ -6,18 +6,17 @@ import type { SandboxDockerConfig } from "./sandbox.js"; describe("Agent-specific tool filtering", () => { it("should apply global tool policy when no agent-specific policy exists", () => { const cfg: ClawdbotConfig = { - agent: { - tools: { - allow: ["read", "write"], - deny: ["bash"], - }, + tools: { + allow: ["read", "write"], + deny: ["bash"], }, - routing: { - agents: { - main: { + agents: { + list: [ + { + id: "main", workspace: "~/clawd", }, - }, + ], }, }; @@ -36,22 +35,21 @@ describe("Agent-specific tool filtering", () => { it("should apply agent-specific tool policy", () => { const cfg: ClawdbotConfig = { - agent: { - tools: { - allow: ["read", "write", "bash"], - deny: [], - }, + tools: { + allow: ["read", "write", "bash"], + deny: [], }, - routing: { - agents: { - restricted: { + agents: { + list: [ + { + id: "restricted", workspace: "~/clawd-restricted", tools: { allow: ["read"], // Agent override: only read deny: ["bash", "write", "edit"], }, }, - }, + ], }, }; @@ -71,20 +69,22 @@ describe("Agent-specific tool filtering", () => { it("should allow different tool policies for different agents", () => { const cfg: ClawdbotConfig = { - routing: { - agents: { - main: { + agents: { + list: [ + { + id: "main", workspace: "~/clawd", // No tools restriction - all tools available }, - family: { + { + id: "family", workspace: "~/clawd-family", tools: { allow: ["read"], deny: ["bash", "write", "edit", "process"], }, }, - }, + ], }, }; @@ -116,20 +116,19 @@ describe("Agent-specific tool filtering", () => { it("should prefer agent-specific tool policy over global", () => { const cfg: ClawdbotConfig = { - agent: { - tools: { - deny: ["browser"], // Global deny - }, + tools: { + deny: ["browser"], // Global deny }, - routing: { - agents: { - work: { + agents: { + list: [ + { + id: "work", workspace: "~/clawd-work", tools: { deny: ["bash", "process"], // Agent deny (override) }, }, - }, + ], }, }; @@ -149,19 +148,16 @@ describe("Agent-specific tool filtering", () => { it("should work with sandbox tools filtering", () => { const cfg: ClawdbotConfig = { - agent: { - sandbox: { - mode: "all", - scope: "agent", - tools: { - allow: ["read", "write", "bash"], // Sandbox allows these - deny: [], + agents: { + defaults: { + sandbox: { + mode: "all", + scope: "agent", }, }, - }, - routing: { - agents: { - restricted: { + list: [ + { + id: "restricted", workspace: "~/clawd-restricted", sandbox: { mode: "all", @@ -172,6 +168,14 @@ describe("Agent-specific tool filtering", () => { deny: ["bash", "write"], }, }, + ], + }, + tools: { + sandbox: { + tools: { + allow: ["read", "write", "bash"], // Sandbox allows these + deny: [], + }, }, }, }; @@ -216,10 +220,8 @@ describe("Agent-specific tool filtering", () => { it("should run bash synchronously when process is denied", async () => { const cfg: ClawdbotConfig = { - agent: { - tools: { - deny: ["process"], - }, + tools: { + deny: ["process"], }, }; diff --git a/src/agents/pi-tools.test.ts b/src/agents/pi-tools.test.ts index f6250d1b3..3242f1e7e 100644 --- a/src/agents/pi-tools.test.ts +++ b/src/agents/pi-tools.test.ts @@ -171,7 +171,7 @@ describe("createClawdbotCodingTools", () => { sessionKey: "agent:main:subagent:test", // Intentionally partial config; only fields used by pi-tools are provided. config: { - agent: { + tools: { subagents: { tools: { // Policy matching is case-insensitive @@ -325,7 +325,7 @@ describe("createClawdbotCodingTools", () => { it("filters tools by agent tool policy even without sandbox", () => { const tools = createClawdbotCodingTools({ - config: { agent: { tools: { deny: ["browser"] } } }, + config: { tools: { deny: ["browser"] } }, }); // NOTE: bash is capitalized to bypass Anthropic OAuth blocking expect(tools.some((tool) => tool.name === "Bash")).toBe(true); diff --git a/src/agents/pi-tools.ts b/src/agents/pi-tools.ts index 11e8c491b..440a2a95f 100644 --- a/src/agents/pi-tools.ts +++ b/src/agents/pi-tools.ts @@ -429,7 +429,7 @@ const DEFAULT_SUBAGENT_TOOL_DENY = [ ]; function resolveSubagentToolPolicy(cfg?: ClawdbotConfig): SandboxToolPolicy { - const configured = cfg?.agent?.subagents?.tools; + const configured = cfg?.tools?.subagents?.tools; const deny = [ ...DEFAULT_SUBAGENT_TOOL_DENY, ...(Array.isArray(configured?.deny) ? configured.deny : []), @@ -466,7 +466,7 @@ function resolveEffectiveToolPolicy(params: { ? resolveAgentConfig(params.config, agentId) : undefined; const hasAgentTools = agentConfig?.tools !== undefined; - const globalTools = params.config?.agent?.tools; + const globalTools = params.config?.tools; return { agentId, policy: hasAgentTools ? agentConfig?.tools : globalTools, diff --git a/src/agents/sandbox-agent-config.test.ts b/src/agents/sandbox-agent-config.test.ts index 26d71595e..d233b451c 100644 --- a/src/agents/sandbox-agent-config.test.ts +++ b/src/agents/sandbox-agent-config.test.ts @@ -56,18 +56,19 @@ describe("Agent-specific sandbox config", () => { const { resolveSandboxContext } = await import("./sandbox.js"); const cfg: ClawdbotConfig = { - agent: { - sandbox: { - mode: "all", - scope: "agent", - }, - }, - routing: { - agents: { - main: { - workspace: "~/clawd", + agents: { + defaults: { + sandbox: { + mode: "all", + scope: "agent", }, }, + list: [ + { + id: "main", + workspace: "~/clawd", + }, + ], }, }; @@ -85,18 +86,19 @@ describe("Agent-specific sandbox config", () => { const { resolveSandboxContext } = await import("./sandbox.js"); const cfg: ClawdbotConfig = { - agent: { - sandbox: { - mode: "all", - scope: "agent", - docker: { - setupCommand: "echo global", + agents: { + defaults: { + sandbox: { + mode: "all", + scope: "agent", + docker: { + setupCommand: "echo global", + }, }, }, - }, - routing: { - agents: { - work: { + list: [ + { + id: "work", workspace: "~/clawd-work", sandbox: { mode: "all", @@ -106,7 +108,7 @@ describe("Agent-specific sandbox config", () => { }, }, }, - }, + ], }, }; @@ -133,18 +135,19 @@ describe("Agent-specific sandbox config", () => { const { resolveSandboxContext } = await import("./sandbox.js"); const cfg: ClawdbotConfig = { - agent: { - sandbox: { - mode: "all", - scope: "shared", - docker: { - setupCommand: "echo global", + agents: { + defaults: { + sandbox: { + mode: "all", + scope: "shared", + docker: { + setupCommand: "echo global", + }, }, }, - }, - routing: { - agents: { - work: { + list: [ + { + id: "work", workspace: "~/clawd-work", sandbox: { mode: "all", @@ -154,7 +157,7 @@ describe("Agent-specific sandbox config", () => { }, }, }, - }, + ], }, }; @@ -182,19 +185,20 @@ describe("Agent-specific sandbox config", () => { const { resolveSandboxContext } = await import("./sandbox.js"); const cfg: ClawdbotConfig = { - agent: { - sandbox: { - mode: "all", - scope: "agent", - docker: { - image: "global-image", - network: "none", + agents: { + defaults: { + sandbox: { + mode: "all", + scope: "agent", + docker: { + image: "global-image", + network: "none", + }, }, }, - }, - routing: { - agents: { - work: { + list: [ + { + id: "work", workspace: "~/clawd-work", sandbox: { mode: "all", @@ -205,7 +209,7 @@ describe("Agent-specific sandbox config", () => { }, }, }, - }, + ], }, }; @@ -224,21 +228,22 @@ describe("Agent-specific sandbox config", () => { const { resolveSandboxContext } = await import("./sandbox.js"); const cfg: ClawdbotConfig = { - agent: { - sandbox: { - mode: "all", // Global default - scope: "agent", + agents: { + defaults: { + sandbox: { + mode: "all", // Global default + scope: "agent", + }, }, - }, - routing: { - agents: { - main: { + list: [ + { + id: "main", workspace: "~/clawd", sandbox: { mode: "off", // Agent override }, }, - }, + ], }, }; @@ -256,21 +261,22 @@ describe("Agent-specific sandbox config", () => { const { resolveSandboxContext } = await import("./sandbox.js"); const cfg: ClawdbotConfig = { - agent: { - sandbox: { - mode: "off", // Global default + agents: { + defaults: { + sandbox: { + mode: "off", // Global default + }, }, - }, - routing: { - agents: { - family: { + list: [ + { + id: "family", workspace: "~/clawd-family", sandbox: { mode: "all", // Agent override scope: "agent", }, }, - }, + ], }, }; @@ -288,22 +294,23 @@ describe("Agent-specific sandbox config", () => { const { resolveSandboxContext } = await import("./sandbox.js"); const cfg: ClawdbotConfig = { - agent: { - sandbox: { - mode: "all", - scope: "session", // Global default + agents: { + defaults: { + sandbox: { + mode: "all", + scope: "session", // Global default + }, }, - }, - routing: { - agents: { - work: { + list: [ + { + id: "work", workspace: "~/clawd-work", sandbox: { mode: "all", scope: "agent", // Agent override }, }, - }, + ], }, }; @@ -322,16 +329,17 @@ describe("Agent-specific sandbox config", () => { const { resolveSandboxContext } = await import("./sandbox.js"); const cfg: ClawdbotConfig = { - agent: { - sandbox: { - mode: "all", - scope: "agent", - workspaceRoot: "~/.clawdbot/sandboxes", // Global default + agents: { + defaults: { + sandbox: { + mode: "all", + scope: "agent", + workspaceRoot: "~/.clawdbot/sandboxes", // Global default + }, }, - }, - routing: { - agents: { - isolated: { + list: [ + { + id: "isolated", workspace: "~/clawd-isolated", sandbox: { mode: "all", @@ -339,7 +347,7 @@ describe("Agent-specific sandbox config", () => { workspaceRoot: "/tmp/isolated-sandboxes", // Agent override }, }, - }, + ], }, }; @@ -359,28 +367,30 @@ describe("Agent-specific sandbox config", () => { const { resolveSandboxContext } = await import("./sandbox.js"); const cfg: ClawdbotConfig = { - agent: { - sandbox: { - mode: "non-main", - scope: "session", + agents: { + defaults: { + sandbox: { + mode: "non-main", + scope: "session", + }, }, - }, - routing: { - agents: { - main: { + list: [ + { + id: "main", workspace: "~/clawd", sandbox: { mode: "off", // main: no sandbox }, }, - family: { + { + id: "family", workspace: "~/clawd-family", sandbox: { mode: "all", // family: always sandbox scope: "agent", }, }, - }, + ], }, }; @@ -406,29 +416,38 @@ describe("Agent-specific sandbox config", () => { const { resolveSandboxContext } = await import("./sandbox.js"); const cfg: ClawdbotConfig = { - agent: { - sandbox: { - mode: "all", - scope: "agent", - tools: { - allow: ["read"], - deny: ["bash"], + agents: { + defaults: { + sandbox: { + mode: "all", + scope: "agent", }, }, - }, - routing: { - agents: { - restricted: { + list: [ + { + id: "restricted", workspace: "~/clawd-restricted", sandbox: { mode: "all", scope: "agent", - tools: { - allow: ["read", "write"], - deny: ["edit"], + }, + tools: { + sandbox: { + tools: { + allow: ["read", "write"], + deny: ["edit"], + }, }, }, }, + ], + }, + tools: { + sandbox: { + tools: { + allow: ["read"], + deny: ["bash"], + }, }, }, }; diff --git a/src/agents/sandbox.ts b/src/agents/sandbox.ts index d9121c93a..e367df51f 100644 --- a/src/agents/sandbox.ts +++ b/src/agents/sandbox.ts @@ -22,7 +22,10 @@ import { import { normalizeAgentId } from "../routing/session-key.js"; import { defaultRuntime } from "../runtime.js"; import { resolveUserPath } from "../utils.js"; -import { resolveAgentIdFromSessionKey } from "./agent-scope.js"; +import { + resolveAgentConfig, + resolveAgentIdFromSessionKey, +} from "./agent-scope.js"; import { syncSkillsToWorkspace } from "./skills.js"; import { DEFAULT_AGENT_WORKSPACE_DIR, @@ -345,15 +348,14 @@ export function resolveSandboxConfigForAgent( cfg?: ClawdbotConfig, agentId?: string, ): SandboxConfig { - const agent = cfg?.agent?.sandbox; + const agent = cfg?.agents?.defaults?.sandbox; // Agent-specific sandbox config overrides global let agentSandbox: typeof agent | undefined; - if (agentId && cfg?.routing?.agents) { - const agentConfig = cfg.routing.agents[agentId]; - if (agentConfig && typeof agentConfig === "object") { - agentSandbox = agentConfig.sandbox; - } + const agentConfig = + cfg && agentId ? resolveAgentConfig(cfg, agentId) : undefined; + if (agentConfig?.sandbox) { + agentSandbox = agentConfig.sandbox; } const scope = resolveSandboxScope({ @@ -382,9 +384,13 @@ export function resolveSandboxConfigForAgent( }), tools: { allow: - agentSandbox?.tools?.allow ?? agent?.tools?.allow ?? DEFAULT_TOOL_ALLOW, + agentConfig?.tools?.sandbox?.tools?.allow ?? + cfg?.tools?.sandbox?.tools?.allow ?? + DEFAULT_TOOL_ALLOW, deny: - agentSandbox?.tools?.deny ?? agent?.tools?.deny ?? DEFAULT_TOOL_DENY, + agentConfig?.tools?.sandbox?.tools?.deny ?? + cfg?.tools?.sandbox?.tools?.deny ?? + DEFAULT_TOOL_DENY, }, prune: resolveSandboxPruneConfig({ scope, @@ -1059,7 +1065,7 @@ export async function resolveSandboxContext(params: { await ensureSandboxWorkspace( sandboxWorkspaceDir, agentWorkspaceDir, - params.config?.agent?.skipBootstrap, + params.config?.agents?.defaults?.skipBootstrap, ); if (cfg.workspaceAccess === "none") { try { @@ -1133,7 +1139,7 @@ export async function ensureSandboxWorkspaceForSession(params: { await ensureSandboxWorkspace( sandboxWorkspaceDir, agentWorkspaceDir, - params.config?.agent?.skipBootstrap, + params.config?.agents?.defaults?.skipBootstrap, ); if (cfg.workspaceAccess === "none") { try { diff --git a/src/agents/subagent-registry.ts b/src/agents/subagent-registry.ts index 210efbb14..cfd022145 100644 --- a/src/agents/subagent-registry.ts +++ b/src/agents/subagent-registry.ts @@ -24,7 +24,7 @@ let listenerStarted = false; function resolveArchiveAfterMs() { const cfg = loadConfig(); - const minutes = cfg.agent?.subagents?.archiveAfterMinutes ?? 60; + const minutes = cfg.agents?.defaults?.subagents?.archiveAfterMinutes ?? 60; if (!Number.isFinite(minutes) || minutes <= 0) return undefined; return Math.max(1, Math.floor(minutes)) * 60_000; } diff --git a/src/agents/timeout.ts b/src/agents/timeout.ts index 65d0eeb9c..5cb5dbca3 100644 --- a/src/agents/timeout.ts +++ b/src/agents/timeout.ts @@ -8,7 +8,7 @@ const normalizeNumber = (value: unknown): number | undefined => : undefined; export function resolveAgentTimeoutSeconds(cfg?: ClawdbotConfig): number { - const raw = normalizeNumber(cfg?.agent?.timeoutSeconds); + const raw = normalizeNumber(cfg?.agents?.defaults?.timeoutSeconds); const seconds = raw ?? DEFAULT_AGENT_TIMEOUT_SECONDS; return Math.max(seconds, 1); } diff --git a/src/agents/tools/agents-list-tool.ts b/src/agents/tools/agents-list-tool.ts index 561973994..b94208af9 100644 --- a/src/agents/tools/agents-list-tool.ts +++ b/src/agents/tools/agents-list-tool.ts @@ -55,19 +55,17 @@ export function createAgentsListTool(opts?: { .map((value) => normalizeAgentId(value)), ); - const configuredAgents = cfg.routing?.agents ?? {}; - const configuredIds = Object.keys(configuredAgents).map((key) => - normalizeAgentId(key), + const configuredAgents = Array.isArray(cfg.agents?.list) + ? cfg.agents?.list + : []; + const configuredIds = configuredAgents.map((entry) => + normalizeAgentId(entry.id), ); const configuredNameMap = new Map(); - for (const [key, value] of Object.entries(configuredAgents)) { - if (!value || typeof value !== "object") continue; - const name = - typeof (value as { name?: unknown }).name === "string" - ? ((value as { name?: string }).name?.trim() ?? "") - : ""; + for (const entry of configuredAgents) { + const name = entry?.name?.trim() ?? ""; if (!name) continue; - configuredNameMap.set(normalizeAgentId(key), name); + configuredNameMap.set(normalizeAgentId(entry.id), name); } const allowed = new Set(); diff --git a/src/agents/tools/image-tool.ts b/src/agents/tools/image-tool.ts index b1a7574e8..5b8c56f56 100644 --- a/src/agents/tools/image-tool.ts +++ b/src/agents/tools/image-tool.ts @@ -23,7 +23,7 @@ import type { AnyAgentTool } from "./common.js"; const DEFAULT_PROMPT = "Describe the image."; function ensureImageToolConfigured(cfg?: ClawdbotConfig): boolean { - const imageModel = cfg?.agent?.imageModel as + const imageModel = cfg?.agents?.defaults?.imageModel as | { primary?: string; fallbacks?: string[] } | string | undefined; @@ -45,7 +45,7 @@ function pickMaxBytes( ) { return Math.floor(maxBytesMb * 1024 * 1024); } - const configured = cfg?.agent?.mediaMaxMb; + const configured = cfg?.agents?.defaults?.mediaMaxMb; if ( typeof configured === "number" && Number.isFinite(configured) && @@ -141,7 +141,7 @@ export function createImageTool(options?: { label: "Image", name: "image", description: - "Analyze an image with the configured image model (agent.imageModel). Provide a prompt and image path or URL.", + "Analyze an image with the configured image model (agents.defaults.imageModel). Provide a prompt and image path or URL.", parameters: Type.Object({ prompt: Type.Optional(Type.String()), image: Type.String(), diff --git a/src/agents/tools/sessions-history-tool.ts b/src/agents/tools/sessions-history-tool.ts index f35806fe6..0cc378f7b 100644 --- a/src/agents/tools/sessions-history-tool.ts +++ b/src/agents/tools/sessions-history-tool.ts @@ -25,7 +25,7 @@ const SessionsHistoryToolSchema = Type.Object({ function resolveSandboxSessionToolsVisibility( cfg: ReturnType, ) { - return cfg.agent?.sandbox?.sessionToolsVisibility ?? "spawned"; + return cfg.agents?.defaults?.sandbox?.sessionToolsVisibility ?? "spawned"; } async function isSpawnedSessionAllowed(params: { @@ -97,7 +97,7 @@ export function createSessionsHistoryTool(opts?: { } } - const routingA2A = cfg.routing?.agentToAgent; + const routingA2A = cfg.tools?.agentToAgent; const a2aEnabled = routingA2A?.enabled === true; const allowPatterns = Array.isArray(routingA2A?.allow) ? routingA2A.allow @@ -126,14 +126,13 @@ export function createSessionsHistoryTool(opts?: { return jsonResult({ status: "forbidden", error: - "Agent-to-agent history is disabled. Set routing.agentToAgent.enabled=true to allow cross-agent access.", + "Agent-to-agent history is disabled. Set tools.agentToAgent.enabled=true to allow cross-agent access.", }); } if (!matchesAllow(requesterAgentId) || !matchesAllow(targetAgentId)) { return jsonResult({ status: "forbidden", - error: - "Agent-to-agent history denied by routing.agentToAgent.allow.", + error: "Agent-to-agent history denied by tools.agentToAgent.allow.", }); } } diff --git a/src/agents/tools/sessions-list-tool.gating.test.ts b/src/agents/tools/sessions-list-tool.gating.test.ts index e375a766f..c5e94da93 100644 --- a/src/agents/tools/sessions-list-tool.gating.test.ts +++ b/src/agents/tools/sessions-list-tool.gating.test.ts @@ -13,7 +13,7 @@ vi.mock("../../config/config.js", async (importOriginal) => { loadConfig: () => ({ session: { scope: "per-sender", mainKey: "main" }, - routing: { agentToAgent: { enabled: false } }, + tools: { agentToAgent: { enabled: false } }, }) as never, }; }); @@ -32,7 +32,7 @@ describe("sessions_list gating", () => { }); }); - it("filters out other agents when routing.agentToAgent.enabled is false", async () => { + it("filters out other agents when tools.agentToAgent.enabled is false", async () => { const tool = createSessionsListTool({ agentSessionKey: "agent:main:main" }); const result = await tool.execute("call1", {}); expect(result.details).toMatchObject({ diff --git a/src/agents/tools/sessions-list-tool.ts b/src/agents/tools/sessions-list-tool.ts index 0163f3b04..4afc708a5 100644 --- a/src/agents/tools/sessions-list-tool.ts +++ b/src/agents/tools/sessions-list-tool.ts @@ -53,7 +53,7 @@ const SessionsListToolSchema = Type.Object({ function resolveSandboxSessionToolsVisibility( cfg: ReturnType, ) { - return cfg.agent?.sandbox?.sessionToolsVisibility ?? "spawned"; + return cfg.agents?.defaults?.sandbox?.sessionToolsVisibility ?? "spawned"; } export function createSessionsListTool(opts?: { @@ -126,7 +126,7 @@ export function createSessionsListTool(opts?: { const sessions = Array.isArray(list?.sessions) ? list.sessions : []; const storePath = typeof list?.path === "string" ? list.path : undefined; - const routingA2A = cfg.routing?.agentToAgent; + const routingA2A = cfg.tools?.agentToAgent; const a2aEnabled = routingA2A?.enabled === true; const allowPatterns = Array.isArray(routingA2A?.allow) ? routingA2A.allow diff --git a/src/agents/tools/sessions-send-tool.gating.test.ts b/src/agents/tools/sessions-send-tool.gating.test.ts index 5137eea71..5d56b4a4d 100644 --- a/src/agents/tools/sessions-send-tool.gating.test.ts +++ b/src/agents/tools/sessions-send-tool.gating.test.ts @@ -13,7 +13,7 @@ vi.mock("../../config/config.js", async (importOriginal) => { loadConfig: () => ({ session: { scope: "per-sender", mainKey: "main" }, - routing: { agentToAgent: { enabled: false } }, + tools: { agentToAgent: { enabled: false } }, }) as never, }; }); @@ -25,7 +25,7 @@ describe("sessions_send gating", () => { callGatewayMock.mockReset(); }); - it("blocks cross-agent sends when routing.agentToAgent.enabled is false", async () => { + it("blocks cross-agent sends when tools.agentToAgent.enabled is false", async () => { const tool = createSessionsSendTool({ agentSessionKey: "agent:main:main", agentProvider: "whatsapp", diff --git a/src/agents/tools/sessions-send-tool.ts b/src/agents/tools/sessions-send-tool.ts index 8c8a4cdec..b3711ffef 100644 --- a/src/agents/tools/sessions-send-tool.ts +++ b/src/agents/tools/sessions-send-tool.ts @@ -54,7 +54,7 @@ export function createSessionsSendTool(opts?: { const cfg = loadConfig(); const { mainKey, alias } = resolveMainSessionAlias(cfg); const visibility = - cfg.agent?.sandbox?.sessionToolsVisibility ?? "spawned"; + cfg.agents?.defaults?.sandbox?.sessionToolsVisibility ?? "spawned"; const requesterInternalKey = typeof opts?.agentSessionKey === "string" && opts.agentSessionKey.trim() ? resolveInternalSessionKey({ @@ -126,7 +126,7 @@ export function createSessionsSendTool(opts?: { mainKey, }); - const routingA2A = cfg.routing?.agentToAgent; + const routingA2A = cfg.tools?.agentToAgent; const a2aEnabled = routingA2A?.enabled === true; const allowPatterns = Array.isArray(routingA2A?.allow) ? routingA2A.allow @@ -156,7 +156,7 @@ export function createSessionsSendTool(opts?: { runId: crypto.randomUUID(), status: "forbidden", error: - "Agent-to-agent messaging is disabled. Set routing.agentToAgent.enabled=true to allow cross-agent sends.", + "Agent-to-agent messaging is disabled. Set tools.agentToAgent.enabled=true to allow cross-agent sends.", sessionKey: displayKey, }); } @@ -165,7 +165,7 @@ export function createSessionsSendTool(opts?: { runId: crypto.randomUUID(), status: "forbidden", error: - "Agent-to-agent messaging denied by routing.agentToAgent.allow.", + "Agent-to-agent messaging denied by tools.agentToAgent.allow.", sessionKey: displayKey, }); } diff --git a/src/auto-reply/reply.block-streaming.test.ts b/src/auto-reply/reply.block-streaming.test.ts index 15dca24e1..010c385d0 100644 --- a/src/auto-reply/reply.block-streaming.test.ts +++ b/src/auto-reply/reply.block-streaming.test.ts @@ -85,9 +85,11 @@ describe("block streaming", () => { onBlockReply, }, { - agent: { - model: "anthropic/claude-opus-4-5", - workspace: path.join(home, "clawd"), + agents: { + defaults: { + model: "anthropic/claude-opus-4-5", + workspace: path.join(home, "clawd"), + }, }, whatsapp: { allowFrom: ["*"] }, session: { store: path.join(home, "sessions.json") }, @@ -140,9 +142,11 @@ describe("block streaming", () => { onBlockReply, }, { - agent: { - model: "anthropic/claude-opus-4-5", - workspace: path.join(home, "clawd"), + agents: { + defaults: { + model: "anthropic/claude-opus-4-5", + workspace: path.join(home, "clawd"), + }, }, telegram: { allowFrom: ["*"] }, session: { store: path.join(home, "sessions.json") }, @@ -185,9 +189,11 @@ describe("block streaming", () => { onBlockReply, }, { - agent: { - model: "anthropic/claude-opus-4-5", - workspace: path.join(home, "clawd"), + agents: { + defaults: { + model: "anthropic/claude-opus-4-5", + workspace: path.join(home, "clawd"), + }, }, whatsapp: { allowFrom: ["*"] }, session: { store: path.join(home, "sessions.json") }, @@ -239,9 +245,11 @@ describe("block streaming", () => { blockReplyTimeoutMs: 10, }, { - agent: { - model: "anthropic/claude-opus-4-5", - workspace: path.join(home, "clawd"), + agents: { + defaults: { + model: "anthropic/claude-opus-4-5", + workspace: path.join(home, "clawd"), + }, }, telegram: { allowFrom: ["*"] }, session: { store: path.join(home, "sessions.json") }, diff --git a/src/auto-reply/reply.directive.test.ts b/src/auto-reply/reply.directive.test.ts index fa8c5051c..b21314030 100644 --- a/src/auto-reply/reply.directive.test.ts +++ b/src/auto-reply/reply.directive.test.ts @@ -78,11 +78,13 @@ describe("directive behavior", () => { }, {}, { - agent: { - model: "anthropic/claude-opus-4-5", - workspace: path.join(home, "clawd"), - models: { - "anthropic/claude-opus-4-5": { alias: " help " }, + agents: { + defaults: { + model: "anthropic/claude-opus-4-5", + workspace: path.join(home, "clawd"), + models: { + "anthropic/claude-opus-4-5": { alias: " help " }, + }, }, }, whatsapp: { allowFrom: ["*"] }, @@ -108,9 +110,11 @@ describe("directive behavior", () => { }, {}, { - agent: { - model: "anthropic/claude-opus-4-5", - workspace: path.join(home, "clawd"), + agents: { + defaults: { + model: "anthropic/claude-opus-4-5", + workspace: path.join(home, "clawd"), + }, }, whatsapp: { allowFrom: ["*"] }, session: { store: path.join(home, "sessions.json") }, @@ -138,11 +142,13 @@ describe("directive behavior", () => { }, {}, { - agent: { - model: "anthropic/claude-opus-4-5", - workspace: path.join(home, "clawd"), + agents: { + defaults: { + model: "anthropic/claude-opus-4-5", + workspace: path.join(home, "clawd"), + }, }, - routing: { + messages: { queue: { mode: "collect", debounceMs: 1500, @@ -174,10 +180,12 @@ describe("directive behavior", () => { { Body: "/think", From: "+1222", To: "+1222" }, {}, { - agent: { - model: "anthropic/claude-opus-4-5", - workspace: path.join(home, "clawd"), - thinkingDefault: "high", + agents: { + defaults: { + model: "anthropic/claude-opus-4-5", + workspace: path.join(home, "clawd"), + thinkingDefault: "high", + }, }, session: { store: path.join(home, "sessions.json") }, }, @@ -198,9 +206,11 @@ describe("directive behavior", () => { { Body: "/think", From: "+1222", To: "+1222" }, {}, { - agent: { - model: "anthropic/claude-opus-4-5", - workspace: path.join(home, "clawd"), + agents: { + defaults: { + model: "anthropic/claude-opus-4-5", + workspace: path.join(home, "clawd"), + }, }, session: { store: path.join(home, "sessions.json") }, }, @@ -232,9 +242,11 @@ describe("directive behavior", () => { }, {}, { - agent: { - model: "anthropic/claude-opus-4-5", - workspace: path.join(home, "clawd"), + agents: { + defaults: { + model: "anthropic/claude-opus-4-5", + workspace: path.join(home, "clawd"), + }, }, whatsapp: { allowFrom: ["*"] }, session: { store: path.join(home, "sessions.json") }, @@ -270,9 +282,11 @@ describe("directive behavior", () => { }, {}, { - agent: { - model: "anthropic/claude-opus-4-5", - workspace: path.join(home, "clawd"), + agents: { + defaults: { + model: "anthropic/claude-opus-4-5", + workspace: path.join(home, "clawd"), + }, }, whatsapp: { allowFrom: ["*"] }, session: { store: path.join(home, "sessions.json") }, @@ -303,9 +317,11 @@ describe("directive behavior", () => { }, {}, { - agent: { - model: "anthropic/claude-opus-4-5", - workspace: path.join(home, "clawd"), + agents: { + defaults: { + model: "anthropic/claude-opus-4-5", + workspace: path.join(home, "clawd"), + }, }, whatsapp: { allowFrom: ["*"], @@ -330,9 +346,11 @@ describe("directive behavior", () => { { Body: "/verbose on", From: "+1222", To: "+1222" }, {}, { - agent: { - model: "anthropic/claude-opus-4-5", - workspace: path.join(home, "clawd"), + agents: { + defaults: { + model: "anthropic/claude-opus-4-5", + workspace: path.join(home, "clawd"), + }, }, session: { store: path.join(home, "sessions.json") }, }, @@ -352,10 +370,12 @@ describe("directive behavior", () => { { Body: "/think", From: "+1222", To: "+1222" }, {}, { - agent: { - model: "anthropic/claude-opus-4-5", - workspace: path.join(home, "clawd"), - thinkingDefault: "high", + agents: { + defaults: { + model: "anthropic/claude-opus-4-5", + workspace: path.join(home, "clawd"), + thinkingDefault: "high", + }, }, session: { store: path.join(home, "sessions.json") }, }, @@ -376,9 +396,11 @@ describe("directive behavior", () => { { Body: "/think", From: "+1222", To: "+1222" }, {}, { - agent: { - model: "anthropic/claude-opus-4-5", - workspace: path.join(home, "clawd"), + agents: { + defaults: { + model: "anthropic/claude-opus-4-5", + workspace: path.join(home, "clawd"), + }, }, session: { store: path.join(home, "sessions.json") }, }, @@ -399,10 +421,12 @@ describe("directive behavior", () => { { Body: "/verbose", From: "+1222", To: "+1222" }, {}, { - agent: { - model: "anthropic/claude-opus-4-5", - workspace: path.join(home, "clawd"), - verboseDefault: "on", + agents: { + defaults: { + model: "anthropic/claude-opus-4-5", + workspace: path.join(home, "clawd"), + verboseDefault: "on", + }, }, session: { store: path.join(home, "sessions.json") }, }, @@ -423,9 +447,11 @@ describe("directive behavior", () => { { Body: "/reasoning", From: "+1222", To: "+1222" }, {}, { - agent: { - model: "anthropic/claude-opus-4-5", - workspace: path.join(home, "clawd"), + agents: { + defaults: { + model: "anthropic/claude-opus-4-5", + workspace: path.join(home, "clawd"), + }, }, session: { store: path.join(home, "sessions.json") }, }, @@ -452,10 +478,14 @@ describe("directive behavior", () => { }, {}, { - agent: { - model: "anthropic/claude-opus-4-5", - workspace: path.join(home, "clawd"), - elevatedDefault: "on", + agents: { + defaults: { + model: "anthropic/claude-opus-4-5", + workspace: path.join(home, "clawd"), + elevatedDefault: "on", + }, + }, + tools: { elevated: { allowFrom: { whatsapp: ["+1222"] }, }, @@ -486,13 +516,17 @@ describe("directive behavior", () => { }, {}, { - agent: { - model: "anthropic/claude-opus-4-5", - workspace: path.join(home, "clawd"), + agents: { + defaults: { + model: "anthropic/claude-opus-4-5", + workspace: path.join(home, "clawd"), + sandbox: { mode: "off" }, + }, + }, + tools: { elevated: { allowFrom: { whatsapp: ["+1222"] }, }, - sandbox: { mode: "off" }, }, whatsapp: { allowFrom: ["+1222"] }, session: { store: path.join(home, "sessions.json") }, @@ -520,9 +554,13 @@ describe("directive behavior", () => { }, {}, { - agent: { - model: "anthropic/claude-opus-4-5", - workspace: path.join(home, "clawd"), + agents: { + defaults: { + model: "anthropic/claude-opus-4-5", + workspace: path.join(home, "clawd"), + }, + }, + tools: { elevated: { allowFrom: { whatsapp: ["+1222"] }, }, @@ -552,9 +590,13 @@ describe("directive behavior", () => { }, {}, { - agent: { - model: "anthropic/claude-opus-4-5", - workspace: path.join(home, "clawd"), + agents: { + defaults: { + model: "anthropic/claude-opus-4-5", + workspace: path.join(home, "clawd"), + }, + }, + tools: { elevated: { allowFrom: { whatsapp: ["+1222"] }, }, @@ -585,9 +627,13 @@ describe("directive behavior", () => { }, {}, { - agent: { - model: "anthropic/claude-opus-4-5", - workspace: path.join(home, "clawd"), + agents: { + defaults: { + model: "anthropic/claude-opus-4-5", + workspace: path.join(home, "clawd"), + }, + }, + tools: { elevated: { allowFrom: { whatsapp: ["+1222"] }, }, @@ -613,9 +659,11 @@ describe("directive behavior", () => { { Body: "/queue interrupt", From: "+1222", To: "+1222" }, {}, { - agent: { - model: "anthropic/claude-opus-4-5", - workspace: path.join(home, "clawd"), + agents: { + defaults: { + model: "anthropic/claude-opus-4-5", + workspace: path.join(home, "clawd"), + }, }, whatsapp: { allowFrom: ["*"] }, session: { store: storePath }, @@ -644,9 +692,11 @@ describe("directive behavior", () => { }, {}, { - agent: { - model: "anthropic/claude-opus-4-5", - workspace: path.join(home, "clawd"), + agents: { + defaults: { + model: "anthropic/claude-opus-4-5", + workspace: path.join(home, "clawd"), + }, }, whatsapp: { allowFrom: ["*"] }, session: { store: storePath }, @@ -677,9 +727,11 @@ describe("directive behavior", () => { { Body: "/queue interrupt", From: "+1222", To: "+1222" }, {}, { - agent: { - model: "anthropic/claude-opus-4-5", - workspace: path.join(home, "clawd"), + agents: { + defaults: { + model: "anthropic/claude-opus-4-5", + workspace: path.join(home, "clawd"), + }, }, whatsapp: { allowFrom: ["*"] }, session: { store: storePath }, @@ -690,9 +742,11 @@ describe("directive behavior", () => { { Body: "/queue reset", From: "+1222", To: "+1222" }, {}, { - agent: { - model: "anthropic/claude-opus-4-5", - workspace: path.join(home, "clawd"), + agents: { + defaults: { + model: "anthropic/claude-opus-4-5", + workspace: path.join(home, "clawd"), + }, }, whatsapp: { allowFrom: ["*"] }, session: { store: storePath }, @@ -749,9 +803,11 @@ describe("directive behavior", () => { ctx, {}, { - agent: { - model: "anthropic/claude-opus-4-5", - workspace: path.join(home, "clawd"), + agents: { + defaults: { + model: "anthropic/claude-opus-4-5", + workspace: path.join(home, "clawd"), + }, }, whatsapp: { allowFrom: ["*"], @@ -810,9 +866,11 @@ describe("directive behavior", () => { { Body: "/verbose on", From: ctx.From, To: ctx.To }, {}, { - agent: { - model: "anthropic/claude-opus-4-5", - workspace: path.join(home, "clawd"), + agents: { + defaults: { + model: "anthropic/claude-opus-4-5", + workspace: path.join(home, "clawd"), + }, }, whatsapp: { allowFrom: ["*"], @@ -825,9 +883,11 @@ describe("directive behavior", () => { ctx, {}, { - agent: { - model: "anthropic/claude-opus-4-5", - workspace: path.join(home, "clawd"), + agents: { + defaults: { + model: "anthropic/claude-opus-4-5", + workspace: path.join(home, "clawd"), + }, }, whatsapp: { allowFrom: ["*"], @@ -853,12 +913,14 @@ describe("directive behavior", () => { { Body: "/model", From: "+1222", To: "+1222" }, {}, { - agent: { - model: { primary: "anthropic/claude-opus-4-5" }, - workspace: path.join(home, "clawd"), - models: { - "anthropic/claude-opus-4-5": {}, - "openai/gpt-4.1-mini": {}, + agents: { + defaults: { + model: { primary: "anthropic/claude-opus-4-5" }, + workspace: path.join(home, "clawd"), + models: { + "anthropic/claude-opus-4-5": {}, + "openai/gpt-4.1-mini": {}, + }, }, }, session: { store: storePath }, @@ -883,12 +945,14 @@ describe("directive behavior", () => { { Body: "/model status", From: "+1222", To: "+1222" }, {}, { - agent: { - model: { primary: "anthropic/claude-opus-4-5" }, - workspace: path.join(home, "clawd"), - models: { - "anthropic/claude-opus-4-5": {}, - "openai/gpt-4.1-mini": {}, + agents: { + defaults: { + model: { primary: "anthropic/claude-opus-4-5" }, + workspace: path.join(home, "clawd"), + models: { + "anthropic/claude-opus-4-5": {}, + "openai/gpt-4.1-mini": {}, + }, }, }, session: { store: storePath }, @@ -913,12 +977,14 @@ describe("directive behavior", () => { { Body: "/model list", From: "+1222", To: "+1222" }, {}, { - agent: { - model: { primary: "anthropic/claude-opus-4-5" }, - workspace: path.join(home, "clawd"), - models: { - "anthropic/claude-opus-4-5": {}, - "openai/gpt-4.1-mini": {}, + agents: { + defaults: { + model: { primary: "anthropic/claude-opus-4-5" }, + workspace: path.join(home, "clawd"), + models: { + "anthropic/claude-opus-4-5": {}, + "openai/gpt-4.1-mini": {}, + }, }, }, session: { store: storePath }, @@ -943,12 +1009,14 @@ describe("directive behavior", () => { { Body: "/model", From: "+1222", To: "+1222" }, {}, { - agent: { - model: { primary: "anthropic/claude-opus-4-5" }, - workspace: path.join(home, "clawd"), - models: { - "anthropic/claude-opus-4-5": {}, - "openai/gpt-4.1-mini": {}, + agents: { + defaults: { + model: { primary: "anthropic/claude-opus-4-5" }, + workspace: path.join(home, "clawd"), + models: { + "anthropic/claude-opus-4-5": {}, + "openai/gpt-4.1-mini": {}, + }, }, }, session: { store: storePath }, @@ -972,11 +1040,13 @@ describe("directive behavior", () => { { Body: "/model list", From: "+1222", To: "+1222" }, {}, { - agent: { - model: { primary: "anthropic/claude-opus-4-5" }, - workspace: path.join(home, "clawd"), - models: { - "anthropic/claude-opus-4-5": {}, + agents: { + defaults: { + model: { primary: "anthropic/claude-opus-4-5" }, + workspace: path.join(home, "clawd"), + models: { + "anthropic/claude-opus-4-5": {}, + }, }, }, session: { store: storePath }, @@ -999,12 +1069,14 @@ describe("directive behavior", () => { { Body: "/model openai/gpt-4.1-mini", From: "+1222", To: "+1222" }, {}, { - agent: { - model: { primary: "anthropic/claude-opus-4-5" }, - workspace: path.join(home, "clawd"), - models: { - "anthropic/claude-opus-4-5": {}, - "openai/gpt-4.1-mini": {}, + agents: { + defaults: { + model: { primary: "anthropic/claude-opus-4-5" }, + workspace: path.join(home, "clawd"), + models: { + "anthropic/claude-opus-4-5": {}, + "openai/gpt-4.1-mini": {}, + }, }, }, session: { store: storePath }, @@ -1030,12 +1102,14 @@ describe("directive behavior", () => { { Body: "/model Opus", From: "+1222", To: "+1222" }, {}, { - agent: { - model: { primary: "openai/gpt-4.1-mini" }, - workspace: path.join(home, "clawd"), - models: { - "openai/gpt-4.1-mini": {}, - "anthropic/claude-opus-4-5": { alias: "Opus" }, + agents: { + defaults: { + model: { primary: "openai/gpt-4.1-mini" }, + workspace: path.join(home, "clawd"), + models: { + "openai/gpt-4.1-mini": {}, + "anthropic/claude-opus-4-5": { alias: "Opus" }, + }, }, }, session: { store: storePath }, @@ -1081,12 +1155,14 @@ describe("directive behavior", () => { { Body: "/model Opus@anthropic:work", From: "+1222", To: "+1222" }, {}, { - agent: { - model: { primary: "openai/gpt-4.1-mini" }, - workspace: path.join(home, "clawd"), - models: { - "openai/gpt-4.1-mini": {}, - "anthropic/claude-opus-4-5": { alias: "Opus" }, + agents: { + defaults: { + model: { primary: "openai/gpt-4.1-mini" }, + workspace: path.join(home, "clawd"), + models: { + "openai/gpt-4.1-mini": {}, + "anthropic/claude-opus-4-5": { alias: "Opus" }, + }, }, }, session: { store: storePath }, @@ -1112,12 +1188,14 @@ describe("directive behavior", () => { { Body: "/model Opus", From: "+1222", To: "+1222" }, {}, { - agent: { - model: { primary: "openai/gpt-4.1-mini" }, - workspace: path.join(home, "clawd"), - models: { - "openai/gpt-4.1-mini": {}, - "anthropic/claude-opus-4-5": { alias: "Opus" }, + agents: { + defaults: { + model: { primary: "openai/gpt-4.1-mini" }, + workspace: path.join(home, "clawd"), + models: { + "openai/gpt-4.1-mini": {}, + "anthropic/claude-opus-4-5": { alias: "Opus" }, + }, }, }, session: { store: storePath }, @@ -1151,12 +1229,14 @@ describe("directive behavior", () => { }, {}, { - agent: { - model: { primary: "anthropic/claude-opus-4-5" }, - workspace: path.join(home, "clawd"), - models: { - "anthropic/claude-opus-4-5": {}, - "openai/gpt-4.1-mini": {}, + agents: { + defaults: { + model: { primary: "anthropic/claude-opus-4-5" }, + workspace: path.join(home, "clawd"), + models: { + "anthropic/claude-opus-4-5": {}, + "openai/gpt-4.1-mini": {}, + }, }, }, whatsapp: { @@ -1204,9 +1284,11 @@ describe("directive behavior", () => { }, {}, { - agent: { - model: "anthropic/claude-opus-4-5", - workspace: path.join(home, "clawd"), + agents: { + defaults: { + model: "anthropic/claude-opus-4-5", + workspace: path.join(home, "clawd"), + }, }, whatsapp: { allowFrom: ["*"], @@ -1242,9 +1324,13 @@ describe("directive behavior", () => { }, {}, { - agent: { - model: "anthropic/claude-opus-4-5", - workspace: path.join(home, "clawd"), + agents: { + defaults: { + model: "anthropic/claude-opus-4-5", + workspace: path.join(home, "clawd"), + }, + }, + tools: { elevated: { allowFrom: { whatsapp: ["+1004"] }, }, diff --git a/src/auto-reply/reply.heartbeat-typing.test.ts b/src/auto-reply/reply.heartbeat-typing.test.ts index 27cd335f1..57f1fdcdd 100644 --- a/src/auto-reply/reply.heartbeat-typing.test.ts +++ b/src/auto-reply/reply.heartbeat-typing.test.ts @@ -57,9 +57,11 @@ async function withTempHome(fn: (home: string) => Promise): Promise { function makeCfg(home: string) { return { - agent: { - model: "anthropic/claude-opus-4-5", - workspace: join(home, "clawd"), + agents: { + defaults: { + model: "anthropic/claude-opus-4-5", + workspace: join(home, "clawd"), + }, }, whatsapp: { allowFrom: ["*"], diff --git a/src/auto-reply/reply.media-note.test.ts b/src/auto-reply/reply.media-note.test.ts index df0fec8fa..56b6544dd 100644 --- a/src/auto-reply/reply.media-note.test.ts +++ b/src/auto-reply/reply.media-note.test.ts @@ -53,9 +53,11 @@ async function withTempHome(fn: (home: string) => Promise): Promise { function makeCfg(home: string) { return { - agent: { - model: "anthropic/claude-opus-4-5", - workspace: path.join(home, "clawd"), + agents: { + defaults: { + model: "anthropic/claude-opus-4-5", + workspace: path.join(home, "clawd"), + }, }, whatsapp: { allowFrom: ["*"] }, session: { store: path.join(home, "sessions.json") }, diff --git a/src/auto-reply/reply.queue.test.ts b/src/auto-reply/reply.queue.test.ts index 3f0095312..7572c6d80 100644 --- a/src/auto-reply/reply.queue.test.ts +++ b/src/auto-reply/reply.queue.test.ts @@ -50,13 +50,15 @@ async function withTempHome(fn: (home: string) => Promise): Promise { function makeCfg(home: string, queue?: Record) { return { - agent: { - model: "anthropic/claude-opus-4-5", - workspace: path.join(home, "clawd"), + agents: { + defaults: { + model: "anthropic/claude-opus-4-5", + workspace: path.join(home, "clawd"), + }, }, whatsapp: { allowFrom: ["*"] }, session: { store: path.join(home, "sessions.json") }, - routing: queue ? { queue } : undefined, + messages: queue ? { queue } : undefined, }; } diff --git a/src/auto-reply/reply.triggers.test.ts b/src/auto-reply/reply.triggers.test.ts index e801ace34..d5017a3fa 100644 --- a/src/auto-reply/reply.triggers.test.ts +++ b/src/auto-reply/reply.triggers.test.ts @@ -25,13 +25,18 @@ const usageMocks = vi.hoisted(() => ({ vi.mock("../infra/provider-usage.js", () => usageMocks); +import { resolveAgentWorkspaceDir } from "../agents/agent-scope.js"; import { abortEmbeddedPiRun, compactEmbeddedPiSession, runEmbeddedPiAgent, } from "../agents/pi-embedded.js"; import { ensureSandboxWorkspaceForSession } from "../agents/sandbox.js"; -import { loadSessionStore, resolveSessionKey } from "../config/sessions.js"; +import { + loadSessionStore, + resolveAgentIdFromSessionKey, + resolveSessionKey, +} from "../config/sessions.js"; import { getReplyFromConfig } from "./reply.js"; import { HEARTBEAT_TOKEN } from "./tokens.js"; @@ -61,9 +66,11 @@ async function withTempHome(fn: (home: string) => Promise): Promise { function makeCfg(home: string) { return { - agent: { - model: "anthropic/claude-opus-4-5", - workspace: join(home, "clawd"), + agents: { + defaults: { + model: "anthropic/claude-opus-4-5", + workspace: join(home, "clawd"), + }, }, whatsapp: { allowFrom: ["*"], @@ -345,9 +352,11 @@ describe("trigger handling", () => { it("allows owner to set send policy", async () => { await withTempHome(async (home) => { const cfg = { - agent: { - model: "anthropic/claude-opus-4-5", - workspace: join(home, "clawd"), + agents: { + defaults: { + model: "anthropic/claude-opus-4-5", + workspace: join(home, "clawd"), + }, }, whatsapp: { allowFrom: ["+1000"], @@ -381,9 +390,13 @@ describe("trigger handling", () => { it("allows approved sender to toggle elevated mode", async () => { await withTempHome(async (home) => { const cfg = { - agent: { - model: "anthropic/claude-opus-4-5", - workspace: join(home, "clawd"), + agents: { + defaults: { + model: "anthropic/claude-opus-4-5", + workspace: join(home, "clawd"), + }, + }, + tools: { elevated: { allowFrom: { whatsapp: ["+1000"] }, }, @@ -420,9 +433,13 @@ describe("trigger handling", () => { it("rejects elevated toggles when disabled", async () => { await withTempHome(async (home) => { const cfg = { - agent: { - model: "anthropic/claude-opus-4-5", - workspace: join(home, "clawd"), + agents: { + defaults: { + model: "anthropic/claude-opus-4-5", + workspace: join(home, "clawd"), + }, + }, + tools: { elevated: { enabled: false, allowFrom: { whatsapp: ["+1000"] }, @@ -467,9 +484,13 @@ describe("trigger handling", () => { }, }); const cfg = { - agent: { - model: "anthropic/claude-opus-4-5", - workspace: join(home, "clawd"), + agents: { + defaults: { + model: "anthropic/claude-opus-4-5", + workspace: join(home, "clawd"), + }, + }, + tools: { elevated: { allowFrom: { whatsapp: ["+1000"] }, }, @@ -510,9 +531,13 @@ describe("trigger handling", () => { }, }); const cfg = { - agent: { - model: "anthropic/claude-opus-4-5", - workspace: join(home, "clawd"), + agents: { + defaults: { + model: "anthropic/claude-opus-4-5", + workspace: join(home, "clawd"), + }, + }, + tools: { elevated: { allowFrom: { whatsapp: ["+1000"] }, }, @@ -545,9 +570,13 @@ describe("trigger handling", () => { it("allows elevated directive in groups when mentioned", async () => { await withTempHome(async (home) => { const cfg = { - agent: { - model: "anthropic/claude-opus-4-5", - workspace: join(home, "clawd"), + agents: { + defaults: { + model: "anthropic/claude-opus-4-5", + workspace: join(home, "clawd"), + }, + }, + tools: { elevated: { allowFrom: { whatsapp: ["+1000"] }, }, @@ -589,9 +618,13 @@ describe("trigger handling", () => { it("allows elevated directive in direct chats without mentions", async () => { await withTempHome(async (home) => { const cfg = { - agent: { - model: "anthropic/claude-opus-4-5", - workspace: join(home, "clawd"), + agents: { + defaults: { + model: "anthropic/claude-opus-4-5", + workspace: join(home, "clawd"), + }, + }, + tools: { elevated: { allowFrom: { whatsapp: ["+1000"] }, }, @@ -635,9 +668,13 @@ describe("trigger handling", () => { }, }); const cfg = { - agent: { - model: "anthropic/claude-opus-4-5", - workspace: join(home, "clawd"), + agents: { + defaults: { + model: "anthropic/claude-opus-4-5", + workspace: join(home, "clawd"), + }, + }, + tools: { elevated: { allowFrom: { whatsapp: ["+1000"] }, }, @@ -668,9 +705,11 @@ describe("trigger handling", () => { it("falls back to discord dm allowFrom for elevated approval", async () => { await withTempHome(async (home) => { const cfg = { - agent: { - model: "anthropic/claude-opus-4-5", - workspace: join(home, "clawd"), + agents: { + defaults: { + model: "anthropic/claude-opus-4-5", + workspace: join(home, "clawd"), + }, }, discord: { dm: { @@ -708,9 +747,13 @@ describe("trigger handling", () => { it("treats explicit discord elevated allowlist as override", async () => { await withTempHome(async (home) => { const cfg = { - agent: { - model: "anthropic/claude-opus-4-5", - workspace: join(home, "clawd"), + agents: { + defaults: { + model: "anthropic/claude-opus-4-5", + workspace: join(home, "clawd"), + }, + }, + tools: { elevated: { allowFrom: { discord: [] }, }, @@ -799,9 +842,12 @@ describe("trigger handling", () => { }); const cfg = makeCfg(home); - cfg.agent = { - ...cfg.agent, - heartbeat: { model: "anthropic/claude-haiku-4-5-20251001" }, + cfg.agents = { + ...cfg.agents, + defaults: { + ...cfg.agents?.defaults, + heartbeat: { model: "anthropic/claude-haiku-4-5-20251001" }, + }, }; await getReplyFromConfig( @@ -941,15 +987,17 @@ describe("trigger handling", () => { }, {}, { - agent: { - model: "anthropic/claude-opus-4-5", - workspace: join(home, "clawd"), + agents: { + defaults: { + model: "anthropic/claude-opus-4-5", + workspace: join(home, "clawd"), + }, }, whatsapp: { allowFrom: ["*"], groups: { "*": { requireMention: false } }, }, - routing: { + messages: { groupChat: {}, }, session: { store: join(home, "sessions.json") }, @@ -985,9 +1033,11 @@ describe("trigger handling", () => { }, {}, { - agent: { - model: "anthropic/claude-opus-4-5", - workspace: join(home, "clawd"), + agents: { + defaults: { + model: "anthropic/claude-opus-4-5", + workspace: join(home, "clawd"), + }, }, whatsapp: { allowFrom: ["*"], @@ -1024,9 +1074,11 @@ describe("trigger handling", () => { }, {}, { - agent: { - model: "anthropic/claude-opus-4-5", - workspace: join(home, "clawd"), + agents: { + defaults: { + model: "anthropic/claude-opus-4-5", + workspace: join(home, "clawd"), + }, }, whatsapp: { allowFrom: ["*"], @@ -1056,9 +1108,11 @@ describe("trigger handling", () => { }, {}, { - agent: { - model: "anthropic/claude-opus-4-5", - workspace: join(home, "clawd"), + agents: { + defaults: { + model: "anthropic/claude-opus-4-5", + workspace: join(home, "clawd"), + }, }, whatsapp: { allowFrom: ["+1999"], @@ -1083,9 +1137,11 @@ describe("trigger handling", () => { }, {}, { - agent: { - model: "anthropic/claude-opus-4-5", - workspace: join(home, "clawd"), + agents: { + defaults: { + model: "anthropic/claude-opus-4-5", + workspace: join(home, "clawd"), + }, }, whatsapp: { allowFrom: ["+1999"], @@ -1124,9 +1180,11 @@ describe("trigger handling", () => { }, {}, { - agent: { - model: "anthropic/claude-opus-4-5", - workspace: join(home, "clawd"), + agents: { + defaults: { + model: "anthropic/claude-opus-4-5", + workspace: join(home, "clawd"), + }, }, whatsapp: { allowFrom: ["*"], @@ -1229,12 +1287,14 @@ describe("trigger handling", () => { }); const cfg = { - agent: { - model: "anthropic/claude-opus-4-5", - workspace: join(home, "clawd"), - sandbox: { - mode: "non-main" as const, - workspaceRoot: join(home, "sandboxes"), + agents: { + defaults: { + model: "anthropic/claude-opus-4-5", + workspace: join(home, "clawd"), + sandbox: { + mode: "non-main" as const, + workspaceRoot: join(home, "sandboxes"), + }, }, }, whatsapp: { @@ -1272,10 +1332,11 @@ describe("trigger handling", () => { ctx, cfg.session?.mainKey, ); + const agentId = resolveAgentIdFromSessionKey(sessionKey); const sandbox = await ensureSandboxWorkspaceForSession({ config: cfg, sessionKey, - workspaceDir: cfg.agent.workspace, + workspaceDir: resolveAgentWorkspaceDir(cfg, agentId), }); expect(sandbox).not.toBeNull(); if (!sandbox) { diff --git a/src/auto-reply/reply.ts b/src/auto-reply/reply.ts index eebbe2be0..b787c0faa 100644 --- a/src/auto-reply/reply.ts +++ b/src/auto-reply/reply.ts @@ -212,7 +212,7 @@ export async function getReplyFromConfig( ): Promise { const cfg = configOverride ?? loadConfig(); const agentId = resolveAgentIdFromSessionKey(ctx.SessionKey); - const agentCfg = cfg.agent; + const agentCfg = cfg.agents?.defaults; const sessionCfg = cfg.session; const { defaultProvider, defaultModel, aliasIndex } = resolveDefaultModel({ cfg, @@ -239,7 +239,7 @@ export async function getReplyFromConfig( resolveAgentWorkspaceDir(cfg, agentId) ?? DEFAULT_AGENT_WORKSPACE_DIR; const workspace = await ensureAgentWorkspace({ dir: workspaceDirRaw, - ensureBootstrapFiles: !cfg.agent?.skipBootstrap, + ensureBootstrapFiles: !agentCfg?.skipBootstrap, }); const workspaceDir = workspace.dir; const agentDir = resolveAgentDir(cfg, agentId); @@ -257,7 +257,7 @@ export async function getReplyFromConfig( opts?.onTypingController?.(typing); let transcribedText: string | undefined; - if (cfg.routing?.transcribeAudio && isAudio(ctx.MediaType)) { + if (cfg.audio?.transcription && isAudio(ctx.MediaType)) { const transcribed = await transcribeInboundAudio(cfg, ctx, defaultRuntime); if (transcribed?.text) { transcribedText = transcribed.text; @@ -329,7 +329,7 @@ export async function getReplyFromConfig( cmd.textAliases.map((a) => a.replace(/^\//, "").toLowerCase()), ), ); - const configuredAliases = Object.values(cfg.agent?.models ?? {}) + const configuredAliases = Object.values(cfg.agents?.defaults?.models ?? {}) .map((entry) => entry.alias?.trim()) .filter((alias): alias is string => Boolean(alias)) .filter((alias) => !reservedCommands.has(alias.toLowerCase())); @@ -391,7 +391,7 @@ export async function getReplyFromConfig( sessionCtx.Provider?.trim().toLowerCase() ?? ctx.Provider?.trim().toLowerCase() ?? ""; - const elevatedConfig = agentCfg?.elevated; + const elevatedConfig = cfg.tools?.elevated; const discordElevatedFallback = messageProviderKey === "discord" ? cfg.discord?.dm?.allowFrom : undefined; const elevatedEnabled = elevatedConfig?.enabled !== false; diff --git a/src/auto-reply/reply/block-streaming.ts b/src/auto-reply/reply/block-streaming.ts index ea231c04c..2a51369a3 100644 --- a/src/auto-reply/reply/block-streaming.ts +++ b/src/auto-reply/reply/block-streaming.ts @@ -34,7 +34,7 @@ export function resolveBlockStreamingChunking( } { const providerKey = normalizeChunkProvider(provider); const textLimit = resolveTextChunkLimit(cfg, providerKey); - const chunkCfg = cfg?.agent?.blockStreamingChunk; + const chunkCfg = cfg?.agents?.defaults?.blockStreamingChunk; const maxRequested = Math.max( 1, Math.floor(chunkCfg?.maxChars ?? DEFAULT_BLOCK_STREAM_MAX), diff --git a/src/auto-reply/reply/commands.ts b/src/auto-reply/reply/commands.ts index 5102d1b78..1453a4d43 100644 --- a/src/auto-reply/reply/commands.ts +++ b/src/auto-reply/reply/commands.ts @@ -163,18 +163,19 @@ export async function buildStatusReply(params: { ? (normalizeGroupActivation(sessionEntry?.groupActivation) ?? defaultGroupActivation()) : undefined; + const agentDefaults = cfg.agents?.defaults ?? {}; const statusText = buildStatusMessage({ config: cfg, agent: { - ...cfg.agent, + ...agentDefaults, model: { - ...cfg.agent?.model, + ...agentDefaults.model, primary: `${provider}/${model}`, }, contextTokens, - thinkingDefault: cfg.agent?.thinkingDefault, - verboseDefault: cfg.agent?.verboseDefault, - elevatedDefault: cfg.agent?.elevatedDefault, + thinkingDefault: agentDefaults.thinkingDefault, + verboseDefault: agentDefaults.verboseDefault, + elevatedDefault: agentDefaults.elevatedDefault, }, sessionEntry, sessionKey, diff --git a/src/auto-reply/reply/directive-handling.ts b/src/auto-reply/reply/directive-handling.ts index 15a89b79e..6a50d1281 100644 --- a/src/auto-reply/reply/directive-handling.ts +++ b/src/auto-reply/reply/directive-handling.ts @@ -23,6 +23,7 @@ import { resolveConfiguredModelRef, resolveModelRefFromString, } from "../../agents/model-selection.js"; +import { resolveSandboxConfigForAgent } from "../../agents/sandbox.js"; import type { ClawdbotConfig } from "../../config/config.js"; import { resolveAgentIdFromSessionKey, @@ -363,16 +364,16 @@ export async function handleDirectiveOnly(params: { currentElevatedLevel, } = params; const runtimeIsSandboxed = (() => { - const sandboxMode = params.cfg.agent?.sandbox?.mode ?? "off"; - if (sandboxMode === "off") return false; const sessionKey = params.sessionKey?.trim(); if (!sessionKey) return false; const agentId = resolveAgentIdFromSessionKey(sessionKey); + const sandboxCfg = resolveSandboxConfigForAgent(params.cfg, agentId); + if (sandboxCfg.mode === "off") return false; const mainKey = resolveAgentMainSessionKey({ cfg: params.cfg, agentId, }); - if (sandboxMode === "all") return true; + if (sandboxCfg.mode === "all") return true; return sessionKey !== mainKey; })(); const shouldHintDirectRuntime = @@ -394,7 +395,9 @@ export async function handleDirectiveOnly(params: { provider: string; id: string; }> = []; - for (const raw of Object.keys(params.cfg.agent?.models ?? {})) { + for (const raw of Object.keys( + params.cfg.agents?.defaults?.models ?? {}, + )) { const resolved = resolveModelRefFromString({ raw: String(raw), defaultProvider, @@ -851,7 +854,7 @@ export async function persistInlineDirectives(params: { model: string; initialModelLabel: string; formatModelSwitchEvent: (label: string, alias?: string) => string; - agentCfg: ClawdbotConfig["agent"] | undefined; + agentCfg: NonNullable["defaults"] | undefined; }): Promise<{ provider: string; model: string; contextTokens: number }> { const { directives, @@ -1007,13 +1010,16 @@ export function resolveDefaultModel(params: { agentModelOverride && agentModelOverride.length > 0 ? { ...params.cfg, - agent: { - ...params.cfg.agent, - model: { - ...(typeof params.cfg.agent?.model === "object" - ? params.cfg.agent.model - : undefined), - primary: agentModelOverride, + agents: { + ...params.cfg.agents, + defaults: { + ...params.cfg.agents?.defaults, + model: { + ...(typeof params.cfg.agents?.defaults?.model === "object" + ? params.cfg.agents.defaults.model + : undefined), + primary: agentModelOverride, + }, }, }, } diff --git a/src/auto-reply/reply/mentions.test.ts b/src/auto-reply/reply/mentions.test.ts index 7d218f305..88f3172c4 100644 --- a/src/auto-reply/reply/mentions.test.ts +++ b/src/auto-reply/reply/mentions.test.ts @@ -9,7 +9,7 @@ import { describe("mention helpers", () => { it("builds regexes and skips invalid patterns", () => { const regexes = buildMentionRegexes({ - routing: { + messages: { groupChat: { mentionPatterns: ["\\bclawd\\b", "(invalid"] }, }, }); @@ -23,7 +23,7 @@ describe("mention helpers", () => { it("matches patterns case-insensitively", () => { const regexes = buildMentionRegexes({ - routing: { groupChat: { mentionPatterns: ["\\bclawd\\b"] } }, + messages: { groupChat: { mentionPatterns: ["\\bclawd\\b"] } }, }); expect(matchesMentionPatterns("CLAWD: hi", regexes)).toBe(true); }); @@ -31,11 +31,16 @@ describe("mention helpers", () => { it("uses per-agent mention patterns when configured", () => { const regexes = buildMentionRegexes( { - routing: { + messages: { groupChat: { mentionPatterns: ["\\bglobal\\b"] }, - agents: { - work: { mentionPatterns: ["\\bworkbot\\b"] }, - }, + }, + agents: { + list: [ + { + id: "work", + groupChat: { mentionPatterns: ["\\bworkbot\\b"] }, + }, + ], }, }, "work", diff --git a/src/auto-reply/reply/mentions.ts b/src/auto-reply/reply/mentions.ts index 6403776e0..1ef890f3b 100644 --- a/src/auto-reply/reply/mentions.ts +++ b/src/auto-reply/reply/mentions.ts @@ -1,23 +1,62 @@ +import { resolveAgentConfig } from "../../agents/agent-scope.js"; import type { ClawdbotConfig } from "../../config/config.js"; import type { MsgContext } from "../templating.js"; +function escapeRegExp(text: string): string { + return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function deriveMentionPatterns(identity?: { name?: string; emoji?: string }) { + const patterns: string[] = []; + const name = identity?.name?.trim(); + if (name) { + const parts = name.split(/\s+/).filter(Boolean).map(escapeRegExp); + const re = parts.length ? parts.join(String.raw`\s+`) : escapeRegExp(name); + patterns.push(String.raw`\b@?${re}\b`); + } + const emoji = identity?.emoji?.trim(); + if (emoji) { + patterns.push(escapeRegExp(emoji)); + } + return patterns; +} + +const BACKSPACE_CHAR = "\u0008"; + +function normalizeMentionPattern(pattern: string): string { + if (!pattern.includes(BACKSPACE_CHAR)) return pattern; + return pattern.split(BACKSPACE_CHAR).join("\\b"); +} + +function normalizeMentionPatterns(patterns: string[]): string[] { + return patterns.map(normalizeMentionPattern); +} + function resolveMentionPatterns( cfg: ClawdbotConfig | undefined, agentId?: string, ): string[] { if (!cfg) return []; - const agentConfig = agentId ? cfg.routing?.agents?.[agentId] : undefined; - if (agentConfig && Object.hasOwn(agentConfig, "mentionPatterns")) { - return agentConfig.mentionPatterns ?? []; + const agentConfig = agentId ? resolveAgentConfig(cfg, agentId) : undefined; + const agentGroupChat = agentConfig?.groupChat; + if (agentGroupChat && Object.hasOwn(agentGroupChat, "mentionPatterns")) { + return agentGroupChat.mentionPatterns ?? []; } - return cfg.routing?.groupChat?.mentionPatterns ?? []; + const globalGroupChat = cfg.messages?.groupChat; + if (globalGroupChat && Object.hasOwn(globalGroupChat, "mentionPatterns")) { + return globalGroupChat.mentionPatterns ?? []; + } + const derived = deriveMentionPatterns(agentConfig?.identity); + return derived.length > 0 ? derived : []; } export function buildMentionRegexes( cfg: ClawdbotConfig | undefined, agentId?: string, ): RegExp[] { - const patterns = resolveMentionPatterns(cfg, agentId); + const patterns = normalizeMentionPatterns( + resolveMentionPatterns(cfg, agentId), + ); return patterns .map((pattern) => { try { @@ -66,7 +105,9 @@ export function stripMentions( agentId?: string, ): string { let result = text; - const patterns = resolveMentionPatterns(cfg, agentId); + const patterns = normalizeMentionPatterns( + resolveMentionPatterns(cfg, agentId), + ); for (const p of patterns) { try { const re = new RegExp(p, "gi"); diff --git a/src/auto-reply/reply/model-selection.ts b/src/auto-reply/reply/model-selection.ts index 63f58b721..37b290309 100644 --- a/src/auto-reply/reply/model-selection.ts +++ b/src/auto-reply/reply/model-selection.ts @@ -33,7 +33,9 @@ type ModelSelectionState = { export async function createModelSelectionState(params: { cfg: ClawdbotConfig; - agentCfg: ClawdbotConfig["agent"] | undefined; + agentCfg: + | NonNullable["defaults"]> + | undefined; sessionEntry?: SessionEntry; sessionStore?: Record; sessionKey?: string; @@ -201,7 +203,9 @@ export function resolveModelDirectiveSelection(params: { } export function resolveContextTokens(params: { - agentCfg: ClawdbotConfig["agent"] | undefined; + agentCfg: + | NonNullable["defaults"]> + | undefined; model: string; }): number { return ( diff --git a/src/auto-reply/reply/queue.ts b/src/auto-reply/reply/queue.ts index 0b486fe57..4b14afa43 100644 --- a/src/auto-reply/reply/queue.ts +++ b/src/auto-reply/reply/queue.ts @@ -553,7 +553,7 @@ export function resolveQueueSettings(params: { inlineOptions?: Partial; }): QueueSettings { const providerKey = params.provider?.trim().toLowerCase(); - const queueCfg = params.cfg.routing?.queue; + const queueCfg = params.cfg.messages?.queue; const providerModeRaw = providerKey && queueCfg?.byProvider ? (queueCfg.byProvider as Record)[providerKey] diff --git a/src/auto-reply/status.ts b/src/auto-reply/status.ts index c7930c7ae..a9323df6a 100644 --- a/src/auto-reply/status.ts +++ b/src/auto-reply/status.ts @@ -35,7 +35,9 @@ import type { VerboseLevel, } from "./thinking.js"; -type AgentConfig = NonNullable; +type AgentConfig = Partial< + NonNullable["defaults"]> +>; export const formatTokenCount = formatTokenCountShared; @@ -188,7 +190,11 @@ export function buildStatusMessage(args: StatusArgs): string { const now = args.now ?? Date.now(); const entry = args.sessionEntry; const resolved = resolveConfiguredModelRef({ - cfg: { agent: args.agent ?? {} }, + cfg: { + agents: { + defaults: args.agent ?? {}, + }, + } as ClawdbotConfig, defaultProvider: DEFAULT_PROVIDER, defaultModel: DEFAULT_MODEL, }); diff --git a/src/auto-reply/transcription.test.ts b/src/auto-reply/transcription.test.ts index 811196f21..7347a9f86 100644 --- a/src/auto-reply/transcription.test.ts +++ b/src/auto-reply/transcription.test.ts @@ -37,8 +37,8 @@ describe("transcribeInboundAudio", () => { vi.stubGlobal("fetch", fetchMock); const cfg = { - routing: { - transcribeAudio: { + audio: { + transcription: { command: ["echo", "{{MediaPath}}"], timeoutSeconds: 5, }, @@ -64,7 +64,7 @@ describe("transcribeInboundAudio", () => { it("returns undefined when no transcription command", async () => { const { transcribeInboundAudio } = await import("./transcription.js"); const res = await transcribeInboundAudio( - { routing: {} } as never, + { audio: {} } as never, {} as never, runtime as never, ); diff --git a/src/auto-reply/transcription.ts b/src/auto-reply/transcription.ts index f82992e20..462a07171 100644 --- a/src/auto-reply/transcription.ts +++ b/src/auto-reply/transcription.ts @@ -18,7 +18,7 @@ export async function transcribeInboundAudio( ctx: MsgContext, runtime: RuntimeEnv, ): Promise<{ text: string } | undefined> { - const transcriber = cfg.routing?.transcribeAudio; + const transcriber = cfg.audio?.transcription; if (!transcriber?.command?.length) return undefined; const timeoutMs = Math.max((transcriber.timeoutSeconds ?? 45) * 1000, 1_000); diff --git a/src/cli/models-cli.ts b/src/cli/models-cli.ts index ac3f5342d..75b749ed8 100644 --- a/src/cli/models-cli.ts +++ b/src/cli/models-cli.ts @@ -267,10 +267,14 @@ export function registerModelsCli(program: Command) { .option("--no-probe", "Skip live probes; list free candidates only") .option("--yes", "Accept defaults without prompting", false) .option("--no-input", "Disable prompts (use defaults)") - .option("--set-default", "Set agent.model to the first selection", false) + .option( + "--set-default", + "Set agents.defaults.model to the first selection", + false, + ) .option( "--set-image", - "Set agent.imageModel to the first image selection", + "Set agents.defaults.imageModel to the first image selection", false, ) .option("--json", "Output JSON", false) diff --git a/src/cli/program.ts b/src/cli/program.ts index 18372f58c..1124d7847 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -191,7 +191,7 @@ export function buildProgram() { .description("Initialize ~/.clawdbot/clawdbot.json and the agent workspace") .option( "--workspace ", - "Agent workspace directory (default: ~/clawd; stored as agent.workspace)", + "Agent workspace directory (default: ~/clawd; stored as agents.defaults.workspace)", ) .option("--wizard", "Run the interactive onboarding wizard", false) .option("--non-interactive", "Run the wizard without prompts", false) @@ -1163,7 +1163,7 @@ Examples: clawdbot sessions --json # machine-readable output clawdbot sessions --store ./tmp/sessions.json -Shows token usage per session when the agent reports it; set agent.contextTokens to see % of your model window.`, +Shows token usage per session when the agent reports it; set agents.defaults.contextTokens to see % of your model window.`, ) .action(async (opts) => { setVerbose(Boolean(opts.verbose)); diff --git a/src/cli/skills-cli.ts b/src/cli/skills-cli.ts index 7fdb02785..7527c4faa 100644 --- a/src/cli/skills-cli.ts +++ b/src/cli/skills-cli.ts @@ -1,5 +1,9 @@ import chalk from "chalk"; import type { Command } from "commander"; +import { + resolveAgentWorkspaceDir, + resolveDefaultAgentId, +} from "../agents/agent-scope.js"; import { buildWorkspaceSkillStatus, type SkillStatusEntry, @@ -363,7 +367,10 @@ export function registerSkillsCli(program: Command) { .action(async (opts) => { try { const config = loadConfig(); - const workspaceDir = config.agent?.workspace ?? process.cwd(); + const workspaceDir = resolveAgentWorkspaceDir( + config, + resolveDefaultAgentId(config), + ); const report = buildWorkspaceSkillStatus(workspaceDir, { config }); console.log(formatSkillsList(report, opts)); } catch (err) { @@ -380,7 +387,10 @@ export function registerSkillsCli(program: Command) { .action(async (name, opts) => { try { const config = loadConfig(); - const workspaceDir = config.agent?.workspace ?? process.cwd(); + const workspaceDir = resolveAgentWorkspaceDir( + config, + resolveDefaultAgentId(config), + ); const report = buildWorkspaceSkillStatus(workspaceDir, { config }); console.log(formatSkillInfo(report, name, opts)); } catch (err) { @@ -396,7 +406,10 @@ export function registerSkillsCli(program: Command) { .action(async (opts) => { try { const config = loadConfig(); - const workspaceDir = config.agent?.workspace ?? process.cwd(); + const workspaceDir = resolveAgentWorkspaceDir( + config, + resolveDefaultAgentId(config), + ); const report = buildWorkspaceSkillStatus(workspaceDir, { config }); console.log(formatSkillsCheck(report, opts)); } catch (err) { @@ -409,7 +422,10 @@ export function registerSkillsCli(program: Command) { skills.action(async () => { try { const config = loadConfig(); - const workspaceDir = config.agent?.workspace ?? process.cwd(); + const workspaceDir = resolveAgentWorkspaceDir( + config, + resolveDefaultAgentId(config), + ); const report = buildWorkspaceSkillStatus(workspaceDir, { config }); console.log(formatSkillsList(report, {})); } catch (err) { diff --git a/src/commands/agent-via-gateway.test.ts b/src/commands/agent-via-gateway.test.ts index cd0867582..1f11f6ea9 100644 --- a/src/commands/agent-via-gateway.test.ts +++ b/src/commands/agent-via-gateway.test.ts @@ -29,9 +29,11 @@ const configSpy = vi.spyOn(configModule, "loadConfig"); function mockConfig(storePath: string, overrides?: Partial) { configSpy.mockReturnValue({ - agent: { - timeoutSeconds: 600, - ...overrides?.agent, + agents: { + defaults: { + timeoutSeconds: 600, + ...overrides?.agents?.defaults, + }, }, session: { store: storePath, diff --git a/src/commands/agent-via-gateway.ts b/src/commands/agent-via-gateway.ts index 18afb4d0b..2db5b43c7 100644 --- a/src/commands/agent-via-gateway.ts +++ b/src/commands/agent-via-gateway.ts @@ -80,7 +80,7 @@ function parseTimeoutSeconds(opts: { const raw = opts.timeout !== undefined ? Number.parseInt(String(opts.timeout), 10) - : (opts.cfg.agent?.timeoutSeconds ?? 600); + : (opts.cfg.agents?.defaults?.timeoutSeconds ?? 600); if (Number.isNaN(raw) || raw <= 0) { throw new Error("--timeout must be a positive integer (seconds)"); } diff --git a/src/commands/agent.test.ts b/src/commands/agent.test.ts index 7aac629d1..8ff14c8de 100644 --- a/src/commands/agent.test.ts +++ b/src/commands/agent.test.ts @@ -53,19 +53,21 @@ async function withTempHome(fn: (home: string) => Promise): Promise { function mockConfig( home: string, storePath: string, - routingOverrides?: Partial>, - agentOverrides?: Partial>, + agentOverrides?: Partial< + NonNullable["defaults"]> + >, telegramOverrides?: Partial>, ) { configSpy.mockReturnValue({ - agent: { - model: { primary: "anthropic/claude-opus-4-5" }, - models: { "anthropic/claude-opus-4-5": {} }, - workspace: path.join(home, "clawd"), - ...agentOverrides, + agents: { + defaults: { + model: { primary: "anthropic/claude-opus-4-5" }, + models: { "anthropic/claude-opus-4-5": {} }, + workspace: path.join(home, "clawd"), + ...agentOverrides, + }, }, session: { store: storePath, mainKey: "main" }, - routing: routingOverrides ? { ...routingOverrides } : undefined, telegram: telegramOverrides ? { ...telegramOverrides } : undefined, }); } @@ -153,11 +155,15 @@ describe("agentCommand", () => { }); }); - it("uses provider/model from agent.model", async () => { + it("uses provider/model from agents.defaults.model.primary", async () => { await withTempHome(async (home) => { const store = path.join(home, "sessions.json"); - mockConfig(home, store, undefined, { - model: "openai/gpt-4.1-mini", + mockConfig(home, store, { + model: { primary: "openai/gpt-4.1-mini" }, + models: { + "anthropic/claude-opus-4-5": {}, + "openai/gpt-4.1-mini": {}, + }, }); await agentCommand({ message: "hi", to: "+1555" }, runtime); @@ -269,7 +275,7 @@ describe("agentCommand", () => { it("passes through telegram accountId when delivering", async () => { await withTempHome(async (home) => { const store = path.join(home, "sessions.json"); - mockConfig(home, store, undefined, undefined, { botToken: "t-1" }); + mockConfig(home, store, undefined, { botToken: "t-1" }); const deps = { sendMessageWhatsApp: vi.fn(), sendMessageTelegram: vi diff --git a/src/commands/agent.ts b/src/commands/agent.ts index ca83d59a7..2941fa1cb 100644 --- a/src/commands/agent.ts +++ b/src/commands/agent.ts @@ -181,13 +181,13 @@ export async function agentCommand( } const cfg = loadConfig(); - const agentCfg = cfg.agent; + const agentCfg = cfg.agents?.defaults; const sessionAgentId = resolveAgentIdFromSessionKey(opts.sessionKey?.trim()); const workspaceDirRaw = resolveAgentWorkspaceDir(cfg, sessionAgentId); const agentDir = resolveAgentDir(cfg, sessionAgentId); const workspace = await ensureAgentWorkspace({ dir: workspaceDirRaw, - ensureBootstrapFiles: !cfg.agent?.skipBootstrap, + ensureBootstrapFiles: !agentCfg?.skipBootstrap, }); const workspaceDir = workspace.dir; diff --git a/src/commands/agents.test.ts b/src/commands/agents.test.ts index 375794655..64ea85501 100644 --- a/src/commands/agents.test.ts +++ b/src/commands/agents.test.ts @@ -1,9 +1,9 @@ +import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; import type { ClawdbotConfig } from "../config/config.js"; -import { DEFAULT_AGENT_ID } from "../routing/session-key.js"; import { applyAgentBindings, applyAgentConfig, @@ -12,27 +12,32 @@ import { } from "./agents.js"; describe("agents helpers", () => { - it("buildAgentSummaries includes default + routing agents", () => { + it("buildAgentSummaries includes default + configured agents", () => { const cfg: ClawdbotConfig = { - agent: { workspace: "/main-ws", model: { primary: "anthropic/claude" } }, - routing: { - defaultAgentId: "work", - agents: { - work: { + agents: { + defaults: { + workspace: "/main-ws", + model: { primary: "anthropic/claude" }, + }, + list: [ + { id: "main" }, + { + id: "work", + default: true, name: "Work", workspace: "/work-ws", agentDir: "/state/agents/work/agent", model: "openai/gpt-4.1", }, - }, - bindings: [ - { - agentId: "work", - match: { provider: "whatsapp", accountId: "biz" }, - }, - { agentId: "main", match: { provider: "telegram" } }, ], }, + bindings: [ + { + agentId: "work", + match: { provider: "whatsapp", accountId: "biz" }, + }, + { agentId: "main", match: { provider: "telegram" } }, + ], }; const summaries = buildAgentSummaries(cfg); @@ -40,7 +45,7 @@ describe("agents helpers", () => { const work = summaries.find((summary) => summary.id === "work"); expect(main).toBeTruthy(); - expect(main?.workspace).toBe(path.resolve("/main-ws")); + expect(main?.workspace).toBe(path.join(os.homedir(), "clawd-main")); expect(main?.bindings).toBe(1); expect(main?.model).toBe("anthropic/claude"); expect(main?.agentDir.endsWith(path.join("agents", "main", "agent"))).toBe( @@ -57,10 +62,8 @@ describe("agents helpers", () => { it("applyAgentConfig merges updates", () => { const cfg: ClawdbotConfig = { - routing: { - agents: { - work: { workspace: "/old-ws", model: "anthropic/claude" }, - }, + agents: { + list: [{ id: "work", workspace: "/old-ws", model: "anthropic/claude" }], }, }; @@ -71,7 +74,7 @@ describe("agents helpers", () => { agentDir: "/state/work/agent", }); - const work = next.routing?.agents?.work; + const work = next.agents?.list?.find((agent) => agent.id === "work"); expect(work?.name).toBe("Work"); expect(work?.workspace).toBe("/new-ws"); expect(work?.agentDir).toBe("/state/work/agent"); @@ -80,14 +83,12 @@ describe("agents helpers", () => { it("applyAgentBindings skips duplicates and reports conflicts", () => { const cfg: ClawdbotConfig = { - routing: { - bindings: [ - { - agentId: "main", - match: { provider: "whatsapp", accountId: "default" }, - }, - ], - }, + bindings: [ + { + agentId: "main", + match: { provider: "whatsapp", accountId: "default" }, + }, + ], }; const result = applyAgentBindings(cfg, [ @@ -108,32 +109,36 @@ describe("agents helpers", () => { expect(result.added).toHaveLength(1); expect(result.skipped).toHaveLength(1); expect(result.conflicts).toHaveLength(1); - expect(result.config.routing?.bindings).toHaveLength(2); + expect(result.config.bindings).toHaveLength(2); }); it("pruneAgentConfig removes agent, bindings, and allowlist entries", () => { const cfg: ClawdbotConfig = { - routing: { - defaultAgentId: "work", - agents: { - work: { workspace: "/work-ws" }, - home: { workspace: "/home-ws" }, - }, - bindings: [ - { agentId: "work", match: { provider: "whatsapp" } }, - { agentId: "home", match: { provider: "telegram" } }, + agents: { + list: [ + { id: "work", default: true, workspace: "/work-ws" }, + { id: "home", workspace: "/home-ws" }, ], + }, + bindings: [ + { agentId: "work", match: { provider: "whatsapp" } }, + { agentId: "home", match: { provider: "telegram" } }, + ], + tools: { agentToAgent: { enabled: true, allow: ["work", "home"] }, }, }; const result = pruneAgentConfig(cfg, "work"); - expect(result.config.routing?.agents?.work).toBeUndefined(); - expect(result.config.routing?.agents?.home).toBeTruthy(); - expect(result.config.routing?.bindings).toHaveLength(1); - expect(result.config.routing?.bindings?.[0]?.agentId).toBe("home"); - expect(result.config.routing?.agentToAgent?.allow).toEqual(["home"]); - expect(result.config.routing?.defaultAgentId).toBe(DEFAULT_AGENT_ID); + expect( + result.config.agents?.list?.some((agent) => agent.id === "work"), + ).toBe(false); + expect( + result.config.agents?.list?.some((agent) => agent.id === "home"), + ).toBe(true); + expect(result.config.bindings).toHaveLength(1); + expect(result.config.bindings?.[0]?.agentId).toBe("home"); + expect(result.config.tools?.agentToAgent?.allow).toEqual(["home"]); expect(result.removedBindings).toBe(1); expect(result.removedAllow).toBe(1); }); diff --git a/src/commands/agents.ts b/src/commands/agents.ts index 81de133ab..6dd8e8ba1 100644 --- a/src/commands/agents.ts +++ b/src/commands/agents.ts @@ -1,9 +1,9 @@ import fs from "node:fs"; import path from "node:path"; - import { resolveAgentDir, resolveAgentWorkspaceDir, + resolveDefaultAgentId, } from "../agents/agent-scope.js"; import { ensureAuthProfileStore } from "../agents/auth-profiles.js"; import { DEFAULT_IDENTITY_FILENAME } from "../agents/workspace.js"; @@ -114,6 +114,10 @@ type AgentBinding = { }; }; +type AgentEntry = NonNullable< + NonNullable["list"] +>[number]; + type AgentIdentity = { name?: string; emoji?: string; @@ -140,15 +144,32 @@ function createQuietRuntime(runtime: RuntimeEnv): RuntimeEnv { return { ...runtime, log: () => {} }; } +function listAgentEntries(cfg: ClawdbotConfig): AgentEntry[] { + const list = cfg.agents?.list; + if (!Array.isArray(list)) return []; + return list.filter((entry): entry is AgentEntry => + Boolean(entry && typeof entry === "object"), + ); +} + +function findAgentEntryIndex(list: AgentEntry[], agentId: string): number { + const id = normalizeAgentId(agentId); + return list.findIndex((entry) => normalizeAgentId(entry.id) === id); +} + function resolveAgentName(cfg: ClawdbotConfig, agentId: string) { - return cfg.routing?.agents?.[agentId]?.name?.trim() || undefined; + const entry = listAgentEntries(cfg).find( + (agent) => normalizeAgentId(agent.id) === normalizeAgentId(agentId), + ); + return entry?.name?.trim() || undefined; } function resolveAgentModel(cfg: ClawdbotConfig, agentId: string) { - if (agentId !== DEFAULT_AGENT_ID) { - return cfg.routing?.agents?.[agentId]?.model?.trim() || undefined; - } - const raw = cfg.agent?.model; + const entry = listAgentEntries(cfg).find( + (agent) => normalizeAgentId(agent.id) === normalizeAgentId(agentId), + ); + if (entry?.model?.trim()) return entry.model.trim(); + const raw = cfg.agents?.defaults?.model; if (typeof raw === "string") return raw; return raw?.primary?.trim() || undefined; } @@ -183,37 +204,33 @@ function loadAgentIdentity(workspace: string): AgentIdentity | null { } export function buildAgentSummaries(cfg: ClawdbotConfig): AgentSummary[] { - const defaultAgentId = normalizeAgentId( - cfg.routing?.defaultAgentId ?? DEFAULT_AGENT_ID, - ); - const agentIds = new Set([ - DEFAULT_AGENT_ID, - defaultAgentId, - ...Object.keys(cfg.routing?.agents ?? {}), - ]); - + const defaultAgentId = normalizeAgentId(resolveDefaultAgentId(cfg)); + const configuredAgents = listAgentEntries(cfg); + const orderedIds = + configuredAgents.length > 0 + ? configuredAgents.map((agent) => normalizeAgentId(agent.id)) + : [defaultAgentId]; const bindingCounts = new Map(); - for (const binding of cfg.routing?.bindings ?? []) { + for (const binding of cfg.bindings ?? []) { const agentId = normalizeAgentId(binding.agentId); bindingCounts.set(agentId, (bindingCounts.get(agentId) ?? 0) + 1); } - const ordered = [ - DEFAULT_AGENT_ID, - ...[...agentIds] - .filter((id) => id !== DEFAULT_AGENT_ID) - .sort((a, b) => a.localeCompare(b)), - ]; + const ordered = orderedIds.filter( + (id, index) => orderedIds.indexOf(id) === index, + ); return ordered.map((id) => { const workspace = resolveAgentWorkspaceDir(cfg, id); const identity = loadAgentIdentity(workspace); - const fallbackIdentity = id === defaultAgentId ? cfg.identity : undefined; - const identityName = identity?.name ?? fallbackIdentity?.name?.trim(); - const identityEmoji = identity?.emoji ?? fallbackIdentity?.emoji?.trim(); + const configIdentity = configuredAgents.find( + (agent) => normalizeAgentId(agent.id) === id, + )?.identity; + const identityName = identity?.name ?? configIdentity?.name?.trim(); + const identityEmoji = identity?.emoji ?? configIdentity?.emoji?.trim(); const identitySource = identity ? "identity" - : fallbackIdentity && (identityName || identityEmoji) + : configIdentity && (identityName || identityEmoji) ? "config" : undefined; return { @@ -242,22 +259,34 @@ export function applyAgentConfig( }, ): ClawdbotConfig { const agentId = normalizeAgentId(params.agentId); - const existing = cfg.routing?.agents?.[agentId] ?? {}; const name = params.name?.trim(); + const list = listAgentEntries(cfg); + const index = findAgentEntryIndex(list, agentId); + const base = index >= 0 ? list[index] : { id: agentId }; + const nextEntry: AgentEntry = { + ...base, + ...(name ? { name } : {}), + ...(params.workspace ? { workspace: params.workspace } : {}), + ...(params.agentDir ? { agentDir: params.agentDir } : {}), + ...(params.model ? { model: params.model } : {}), + }; + const nextList = [...list]; + if (index >= 0) { + nextList[index] = nextEntry; + } else { + if ( + nextList.length === 0 && + agentId !== normalizeAgentId(resolveDefaultAgentId(cfg)) + ) { + nextList.push({ id: resolveDefaultAgentId(cfg) }); + } + nextList.push(nextEntry); + } return { ...cfg, - routing: { - ...cfg.routing, - agents: { - ...cfg.routing?.agents, - [agentId]: { - ...existing, - ...(name ? { name } : {}), - ...(params.workspace ? { workspace: params.workspace } : {}), - ...(params.agentDir ? { agentDir: params.agentDir } : {}), - ...(params.model ? { model: params.model } : {}), - }, - }, + agents: { + ...cfg.agents, + list: nextList, }, }; } @@ -283,7 +312,7 @@ export function applyAgentBindings( skipped: AgentBinding[]; conflicts: Array<{ binding: AgentBinding; existingAgentId: string }>; } { - const existing = cfg.routing?.bindings ?? []; + const existing = cfg.bindings ?? []; const existingMatchMap = new Map(); for (const binding of existing) { const key = bindingMatchKey(binding.match); @@ -320,10 +349,7 @@ export function applyAgentBindings( return { config: { ...cfg, - routing: { - ...cfg.routing, - bindings: [...existing, ...added], - }, + bindings: [...existing, ...added], }, added, skipped, @@ -340,39 +366,41 @@ export function pruneAgentConfig( removedAllow: number; } { const id = normalizeAgentId(agentId); - const agents = { ...cfg.routing?.agents }; - delete agents[id]; - const nextAgents = Object.keys(agents).length > 0 ? agents : undefined; + const agents = listAgentEntries(cfg); + const nextAgentsList = agents.filter( + (entry) => normalizeAgentId(entry.id) !== id, + ); + const nextAgents = nextAgentsList.length > 0 ? nextAgentsList : undefined; - const bindings = cfg.routing?.bindings ?? []; + const bindings = cfg.bindings ?? []; const filteredBindings = bindings.filter( (binding) => normalizeAgentId(binding.agentId) !== id, ); - const allow = cfg.routing?.agentToAgent?.allow ?? []; + const allow = cfg.tools?.agentToAgent?.allow ?? []; const filteredAllow = allow.filter((entry) => entry !== id); - const nextRouting = { - ...cfg.routing, - ...(nextAgents ? { agents: nextAgents } : {}), - ...(nextAgents ? {} : { agents: undefined }), - bindings: filteredBindings.length > 0 ? filteredBindings : undefined, - agentToAgent: cfg.routing?.agentToAgent - ? { - ...cfg.routing.agentToAgent, + const nextAgentsConfig = cfg.agents + ? { ...cfg.agents, list: nextAgents } + : nextAgents + ? { list: nextAgents } + : undefined; + const nextTools = cfg.tools?.agentToAgent + ? { + ...cfg.tools, + agentToAgent: { + ...cfg.tools.agentToAgent, allow: filteredAllow.length > 0 ? filteredAllow : undefined, - } - : undefined, - defaultAgentId: - normalizeAgentId(cfg.routing?.defaultAgentId ?? DEFAULT_AGENT_ID) === id - ? DEFAULT_AGENT_ID - : cfg.routing?.defaultAgentId, - }; + }, + } + : cfg.tools; return { config: { ...cfg, - routing: nextRouting, + agents: nextAgentsConfig, + bindings: filteredBindings.length > 0 ? filteredBindings : undefined, + tools: nextTools, }, removedBindings: bindings.length - filteredBindings.length, removedAllow: allow.length - filteredAllow.length, @@ -632,7 +660,7 @@ export async function agentsListCommand( const summaries = buildAgentSummaries(cfg); const bindingMap = new Map(); - for (const binding of cfg.routing?.bindings ?? []) { + for (const binding of cfg.bindings ?? []) { const agentId = normalizeAgentId(binding.agentId); const list = bindingMap.get(agentId) ?? []; list.push(binding as AgentBinding); @@ -818,7 +846,7 @@ export async function agentsAddCommand( if (agentId !== nameInput) { runtime.log(`Normalized agent id to "${agentId}".`); } - if (cfg.routing?.agents?.[agentId]) { + if (findAgentEntryIndex(listAgentEntries(cfg), agentId) >= 0) { runtime.error(`Agent "${agentId}" already exists.`); runtime.exit(1); return; @@ -856,7 +884,9 @@ export async function agentsAddCommand( if (!opts.json) runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`); const quietRuntime = opts.json ? createQuietRuntime(runtime) : runtime; await ensureWorkspaceAndSessions(workspaceDir, quietRuntime, { - skipBootstrap: Boolean(bindingResult.config.agent?.skipBootstrap), + skipBootstrap: Boolean( + bindingResult.config.agents?.defaults?.skipBootstrap, + ), agentId, }); @@ -920,7 +950,9 @@ export async function agentsAddCommand( await prompter.note(`Normalized id to "${agentId}".`, "Agent id"); } - const existingAgent = cfg.routing?.agents?.[agentId]; + const existingAgent = listAgentEntries(cfg).find( + (agent) => normalizeAgentId(agent.id) === agentId, + ); if (existingAgent) { const shouldUpdate = await prompter.confirm({ message: `Agent "${agentId}" already exists. Update it?`, @@ -1005,8 +1037,7 @@ export async function agentsAddCommand( if (selection.length > 0) { const wantsBindings = await prompter.confirm({ - message: - "Route selected providers to this agent now? (routing.bindings)", + message: "Route selected providers to this agent now? (bindings)", initialValue: false, }); if (wantsBindings) { @@ -1033,7 +1064,7 @@ export async function agentsAddCommand( } else { await prompter.note( [ - "Routing unchanged. Add routing.bindings when you're ready.", + "Routing unchanged. Add bindings when you're ready.", "Docs: https://docs.clawd.bot/concepts/multi-agent", ].join("\n"), "Routing", @@ -1044,7 +1075,7 @@ export async function agentsAddCommand( await writeConfigFile(nextConfig); runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`); await ensureWorkspaceAndSessions(workspaceDir, runtime, { - skipBootstrap: Boolean(nextConfig.agent?.skipBootstrap), + skipBootstrap: Boolean(nextConfig.agents?.defaults?.skipBootstrap), agentId, }); @@ -1091,7 +1122,7 @@ export async function agentsDeleteCommand( return; } - if (!cfg.routing?.agents?.[agentId]) { + if (findAgentEntryIndex(listAgentEntries(cfg), agentId) < 0) { runtime.error(`Agent "${agentId}" not found.`); runtime.exit(1); return; diff --git a/src/commands/auth-choice.ts b/src/commands/auth-choice.ts index 288aa5f73..897ae3003 100644 --- a/src/commands/auth-choice.ts +++ b/src/commands/auth-choice.ts @@ -65,13 +65,16 @@ export async function warnIfModelConfigLooksOff( agentModelOverride && agentModelOverride.length > 0 ? { ...config, - agent: { - ...config.agent, - model: { - ...(typeof config.agent?.model === "object" - ? config.agent.model - : undefined), - primary: agentModelOverride, + agents: { + ...config.agents, + defaults: { + ...config.agents?.defaults, + model: { + ...(typeof config.agents?.defaults?.model === "object" + ? config.agents.defaults.model + : undefined), + primary: agentModelOverride, + }, }, }, } @@ -92,7 +95,7 @@ export async function warnIfModelConfigLooksOff( ); if (!known) { warnings.push( - `Model not found: ${ref.provider}/${ref.model}. Update agent.model or run /models list.`, + `Model not found: ${ref.provider}/${ref.model}. Update agents.defaults.model or run /models list.`, ); } } @@ -111,7 +114,7 @@ export async function warnIfModelConfigLooksOff( const hasCodex = listProfilesForProvider(store, "openai-codex").length > 0; if (hasCodex) { warnings.push( - `Detected OpenAI Codex OAuth. Consider setting agent.model to ${OPENAI_CODEX_DEFAULT_MODEL}.`, + `Detected OpenAI Codex OAuth. Consider setting agents.defaults.model to ${OPENAI_CODEX_DEFAULT_MODEL}.`, ); } } @@ -454,30 +457,36 @@ export async function applyAuthChoice(params: { const modelKey = "google-antigravity/claude-opus-4-5-thinking"; nextConfig = { ...nextConfig, - agent: { - ...nextConfig.agent, - models: { - ...nextConfig.agent?.models, - [modelKey]: nextConfig.agent?.models?.[modelKey] ?? {}, + agents: { + ...nextConfig.agents, + defaults: { + ...nextConfig.agents?.defaults, + models: { + ...nextConfig.agents?.defaults?.models, + [modelKey]: + nextConfig.agents?.defaults?.models?.[modelKey] ?? {}, + }, }, }, }; if (params.setDefaultModel) { + const existingModel = nextConfig.agents?.defaults?.model; nextConfig = { ...nextConfig, - agent: { - ...nextConfig.agent, - model: { - ...(nextConfig.agent?.model && - "fallbacks" in - (nextConfig.agent.model as Record) - ? { - fallbacks: ( - nextConfig.agent.model as { fallbacks?: string[] } - ).fallbacks, - } - : undefined), - primary: modelKey, + agents: { + ...nextConfig.agents, + defaults: { + ...nextConfig.agents?.defaults, + model: { + ...(existingModel && + "fallbacks" in (existingModel as Record) + ? { + fallbacks: (existingModel as { fallbacks?: string[] }) + .fallbacks, + } + : undefined), + primary: modelKey, + }, }, }, }; diff --git a/src/commands/configure.ts b/src/commands/configure.ts index 29ab1dc87..1416a255c 100644 --- a/src/commands/configure.ts +++ b/src/commands/configure.ts @@ -625,26 +625,32 @@ async function promptAuthConfig( mode: "oauth", }); // Set default model to Claude Opus 4.5 via Antigravity + const existingDefaults = next.agents?.defaults; + const existingModel = existingDefaults?.model; + const existingModels = existingDefaults?.models; next = { ...next, - agent: { - ...next.agent, - model: { - ...(next.agent?.model && - "fallbacks" in (next.agent.model as Record) - ? { - fallbacks: (next.agent.model as { fallbacks?: string[] }) - .fallbacks, - } - : undefined), - primary: "google-antigravity/claude-opus-4-5-thinking", - }, - models: { - ...next.agent?.models, - "google-antigravity/claude-opus-4-5-thinking": - next.agent?.models?.[ - "google-antigravity/claude-opus-4-5-thinking" - ] ?? {}, + agents: { + ...next.agents, + defaults: { + ...existingDefaults, + model: { + ...(existingModel && + "fallbacks" in (existingModel as Record) + ? { + fallbacks: (existingModel as { fallbacks?: string[] }) + .fallbacks, + } + : undefined), + primary: "google-antigravity/claude-opus-4-5-thinking", + }, + models: { + ...existingModels, + "google-antigravity/claude-opus-4-5-thinking": + existingModels?.[ + "google-antigravity/claude-opus-4-5-thinking" + ] ?? {}, + }, }, }, }; @@ -714,9 +720,9 @@ async function promptAuthConfig( } const currentModel = - typeof next.agent?.model === "string" - ? next.agent?.model - : (next.agent?.model?.primary ?? ""); + typeof next.agents?.defaults?.model === "string" + ? next.agents?.defaults?.model + : (next.agents?.defaults?.model?.primary ?? ""); const preferAnthropic = authChoice === "claude-cli" || authChoice === "token" || @@ -736,23 +742,29 @@ async function promptAuthConfig( ); const model = String(modelInput ?? "").trim(); if (model) { + const existingDefaults = next.agents?.defaults; + const existingModel = existingDefaults?.model; + const existingModels = existingDefaults?.models; next = { ...next, - agent: { - ...next.agent, - model: { - ...(next.agent?.model && - "fallbacks" in (next.agent.model as Record) - ? { - fallbacks: (next.agent.model as { fallbacks?: string[] }) - .fallbacks, - } - : undefined), - primary: model, - }, - models: { - ...next.agent?.models, - [model]: next.agent?.models?.[model] ?? {}, + agents: { + ...next.agents, + defaults: { + ...existingDefaults, + model: { + ...(existingModel && + "fallbacks" in (existingModel as Record) + ? { + fallbacks: (existingModel as { fallbacks?: string[] }) + .fallbacks, + } + : undefined), + primary: model, + }, + models: { + ...existingModels, + [model]: existingModels?.[model] ?? {}, + }, }, }, }; @@ -955,7 +967,7 @@ export async function runConfigureWizard( { value: "workspace", label: "Workspace", - hint: "Set agent workspace + ensure sessions", + hint: "Set default workspace + ensure sessions", }, { value: "model", @@ -999,8 +1011,8 @@ export async function runConfigureWizard( let nextConfig = { ...baseConfig }; let workspaceDir = - nextConfig.agent?.workspace ?? - baseConfig.agent?.workspace ?? + nextConfig.agents?.defaults?.workspace ?? + baseConfig.agents?.defaults?.workspace ?? DEFAULT_WORKSPACE; let gatewayPort = resolveGatewayPort(baseConfig); let gatewayToken: string | undefined; @@ -1018,9 +1030,12 @@ export async function runConfigureWizard( ); nextConfig = { ...nextConfig, - agent: { - ...nextConfig.agent, - workspace: workspaceDir, + agents: { + ...nextConfig.agents, + defaults: { + ...nextConfig.agents?.defaults, + workspace: workspaceDir, + }, }, }; await ensureWorkspaceAndSessions(workspaceDir, runtime); diff --git a/src/commands/doctor-legacy-config.ts b/src/commands/doctor-legacy-config.ts index 3c0d20fbc..4e359b675 100644 --- a/src/commands/doctor-legacy-config.ts +++ b/src/commands/doctor-legacy-config.ts @@ -71,75 +71,184 @@ export function normalizeLegacyConfigValues(cfg: ClawdbotConfig): { const changes: string[] = []; let next: ClawdbotConfig = cfg; - const workspace = cfg.agent?.workspace; - const updatedWorkspace = normalizeDefaultWorkspacePath(workspace); - if (updatedWorkspace && updatedWorkspace !== workspace) { - next = { - ...next, - agent: { - ...next.agent, - workspace: updatedWorkspace, - }, - }; - changes.push(`Updated agent.workspace → ${updatedWorkspace}`); - } + const defaults = cfg.agents?.defaults; + if (defaults) { + let updatedDefaults = defaults; + let defaultsChanged = false; - const workspaceRoot = cfg.agent?.sandbox?.workspaceRoot; - const updatedWorkspaceRoot = normalizeDefaultWorkspacePath(workspaceRoot); - if (updatedWorkspaceRoot && updatedWorkspaceRoot !== workspaceRoot) { - next = { - ...next, - agent: { - ...next.agent, - sandbox: { - ...next.agent?.sandbox, + const updatedWorkspace = normalizeDefaultWorkspacePath(defaults.workspace); + if (updatedWorkspace && updatedWorkspace !== defaults.workspace) { + updatedDefaults = { ...updatedDefaults, workspace: updatedWorkspace }; + defaultsChanged = true; + changes.push(`Updated agents.defaults.workspace → ${updatedWorkspace}`); + } + + const sandbox = defaults.sandbox; + if (sandbox) { + let updatedSandbox = sandbox; + let sandboxChanged = false; + + const updatedWorkspaceRoot = normalizeDefaultWorkspacePath( + sandbox.workspaceRoot, + ); + if ( + updatedWorkspaceRoot && + updatedWorkspaceRoot !== sandbox.workspaceRoot + ) { + updatedSandbox = { + ...updatedSandbox, workspaceRoot: updatedWorkspaceRoot, - }, - }, - }; - changes.push( - `Updated agent.sandbox.workspaceRoot → ${updatedWorkspaceRoot}`, - ); - } + }; + sandboxChanged = true; + changes.push( + `Updated agents.defaults.sandbox.workspaceRoot → ${updatedWorkspaceRoot}`, + ); + } - const dockerImage = cfg.agent?.sandbox?.docker?.image; - const updatedDockerImage = replaceLegacyName(dockerImage); - if (updatedDockerImage && updatedDockerImage !== dockerImage) { - next = { - ...next, - agent: { - ...next.agent, - sandbox: { - ...next.agent?.sandbox, + const dockerImage = sandbox.docker?.image; + const updatedDockerImage = replaceLegacyName(dockerImage); + if (updatedDockerImage && updatedDockerImage !== dockerImage) { + updatedSandbox = { + ...updatedSandbox, docker: { - ...next.agent?.sandbox?.docker, + ...updatedSandbox.docker, image: updatedDockerImage, }, - }, - }, - }; - changes.push(`Updated agent.sandbox.docker.image → ${updatedDockerImage}`); - } + }; + sandboxChanged = true; + changes.push( + `Updated agents.defaults.sandbox.docker.image → ${updatedDockerImage}`, + ); + } - const containerPrefix = cfg.agent?.sandbox?.docker?.containerPrefix; - const updatedContainerPrefix = replaceLegacyName(containerPrefix); - if (updatedContainerPrefix && updatedContainerPrefix !== containerPrefix) { - next = { - ...next, - agent: { - ...next.agent, - sandbox: { - ...next.agent?.sandbox, + const containerPrefix = sandbox.docker?.containerPrefix; + const updatedContainerPrefix = replaceLegacyName(containerPrefix); + if ( + updatedContainerPrefix && + updatedContainerPrefix !== containerPrefix + ) { + updatedSandbox = { + ...updatedSandbox, docker: { - ...next.agent?.sandbox?.docker, + ...updatedSandbox.docker, containerPrefix: updatedContainerPrefix, }, + }; + sandboxChanged = true; + changes.push( + `Updated agents.defaults.sandbox.docker.containerPrefix → ${updatedContainerPrefix}`, + ); + } + + if (sandboxChanged) { + updatedDefaults = { ...updatedDefaults, sandbox: updatedSandbox }; + defaultsChanged = true; + } + } + + if (defaultsChanged) { + next = { + ...next, + agents: { + ...next.agents, + defaults: updatedDefaults, }, - }, - }; - changes.push( - `Updated agent.sandbox.docker.containerPrefix → ${updatedContainerPrefix}`, - ); + }; + } + } + + const list = Array.isArray(cfg.agents?.list) ? cfg.agents.list : []; + if (list.length > 0) { + let listChanged = false; + const nextList = list.map((agent) => { + let updatedAgent = agent; + let agentChanged = false; + + const updatedWorkspace = normalizeDefaultWorkspacePath(agent.workspace); + if (updatedWorkspace && updatedWorkspace !== agent.workspace) { + updatedAgent = { ...updatedAgent, workspace: updatedWorkspace }; + agentChanged = true; + changes.push( + `Updated agents.list (id "${agent.id}") workspace → ${updatedWorkspace}`, + ); + } + + const sandbox = agent.sandbox; + if (sandbox) { + let updatedSandbox = sandbox; + let sandboxChanged = false; + + const updatedWorkspaceRoot = normalizeDefaultWorkspacePath( + sandbox.workspaceRoot, + ); + if ( + updatedWorkspaceRoot && + updatedWorkspaceRoot !== sandbox.workspaceRoot + ) { + updatedSandbox = { + ...updatedSandbox, + workspaceRoot: updatedWorkspaceRoot, + }; + sandboxChanged = true; + changes.push( + `Updated agents.list (id "${agent.id}") sandbox.workspaceRoot → ${updatedWorkspaceRoot}`, + ); + } + + const dockerImage = sandbox.docker?.image; + const updatedDockerImage = replaceLegacyName(dockerImage); + if (updatedDockerImage && updatedDockerImage !== dockerImage) { + updatedSandbox = { + ...updatedSandbox, + docker: { + ...updatedSandbox.docker, + image: updatedDockerImage, + }, + }; + sandboxChanged = true; + changes.push( + `Updated agents.list (id "${agent.id}") sandbox.docker.image → ${updatedDockerImage}`, + ); + } + + const containerPrefix = sandbox.docker?.containerPrefix; + const updatedContainerPrefix = replaceLegacyName(containerPrefix); + if ( + updatedContainerPrefix && + updatedContainerPrefix !== containerPrefix + ) { + updatedSandbox = { + ...updatedSandbox, + docker: { + ...updatedSandbox.docker, + containerPrefix: updatedContainerPrefix, + }, + }; + sandboxChanged = true; + changes.push( + `Updated agents.list (id "${agent.id}") sandbox.docker.containerPrefix → ${updatedContainerPrefix}`, + ); + } + + if (sandboxChanged) { + updatedAgent = { ...updatedAgent, sandbox: updatedSandbox }; + agentChanged = true; + } + } + + if (agentChanged) listChanged = true; + return agentChanged ? updatedAgent : agent; + }); + + if (listChanged) { + next = { + ...next, + agents: { + ...next.agents, + list: nextList, + }, + }; + } } return { config: next, changes }; @@ -170,18 +279,40 @@ export async function maybeMigrateLegacyConfigFile(runtime: RuntimeEnv) { typeof (legacySnapshot.parsed as ClawdbotConfig)?.gateway?.bind === "string" ? (legacySnapshot.parsed as ClawdbotConfig).gateway?.bind : undefined; - const agentWorkspace = - typeof (legacySnapshot.parsed as ClawdbotConfig)?.agent?.workspace === - "string" - ? (legacySnapshot.parsed as ClawdbotConfig).agent?.workspace + const parsed = legacySnapshot.parsed as Record; + const parsedAgents = + parsed.agents && typeof parsed.agents === "object" + ? (parsed.agents as Record) : undefined; + const parsedDefaults = + parsedAgents?.defaults && typeof parsedAgents.defaults === "object" + ? (parsedAgents.defaults as Record) + : undefined; + const parsedLegacyAgent = + parsed.agent && typeof parsed.agent === "object" + ? (parsed.agent as Record) + : undefined; + const defaultWorkspace = + typeof parsedDefaults?.workspace === "string" + ? parsedDefaults.workspace + : undefined; + const legacyWorkspace = + typeof parsedLegacyAgent?.workspace === "string" + ? parsedLegacyAgent.workspace + : undefined; + const agentWorkspace = defaultWorkspace ?? legacyWorkspace; + const workspaceLabel = defaultWorkspace + ? "agents.defaults.workspace" + : legacyWorkspace + ? "agent.workspace" + : "agents.defaults.workspace"; note( [ `- File exists at ${legacyConfigPath}`, gatewayMode ? `- gateway.mode: ${gatewayMode}` : undefined, gatewayBind ? `- gateway.bind: ${gatewayBind}` : undefined, - agentWorkspace ? `- agent.workspace: ${agentWorkspace}` : undefined, + agentWorkspace ? `- ${workspaceLabel}: ${agentWorkspace}` : undefined, ] .filter(Boolean) .join("\n"), diff --git a/src/commands/doctor-sandbox.ts b/src/commands/doctor-sandbox.ts index 6d1b6e6ce..2d4b7d697 100644 --- a/src/commands/doctor-sandbox.ts +++ b/src/commands/doctor-sandbox.ts @@ -96,12 +96,12 @@ async function dockerImageExists(image: string): Promise { } function resolveSandboxDockerImage(cfg: ClawdbotConfig): string { - const image = cfg.agent?.sandbox?.docker?.image?.trim(); + const image = cfg.agents?.defaults?.sandbox?.docker?.image?.trim(); return image ? image : DEFAULT_SANDBOX_IMAGE; } function resolveSandboxBrowserImage(cfg: ClawdbotConfig): string { - const image = cfg.agent?.sandbox?.browser?.image?.trim(); + const image = cfg.agents?.defaults?.sandbox?.browser?.image?.trim(); return image ? image : DEFAULT_SANDBOX_BROWSER_IMAGE; } @@ -111,13 +111,16 @@ function updateSandboxDockerImage( ): ClawdbotConfig { return { ...cfg, - agent: { - ...cfg.agent, - sandbox: { - ...cfg.agent?.sandbox, - docker: { - ...cfg.agent?.sandbox?.docker, - image, + agents: { + ...cfg.agents, + defaults: { + ...cfg.agents?.defaults, + sandbox: { + ...cfg.agents?.defaults?.sandbox, + docker: { + ...cfg.agents?.defaults?.sandbox?.docker, + image, + }, }, }, }, @@ -130,13 +133,16 @@ function updateSandboxBrowserImage( ): ClawdbotConfig { return { ...cfg, - agent: { - ...cfg.agent, - sandbox: { - ...cfg.agent?.sandbox, - browser: { - ...cfg.agent?.sandbox?.browser, - image, + agents: { + ...cfg.agents, + defaults: { + ...cfg.agents?.defaults, + sandbox: { + ...cfg.agents?.defaults?.sandbox, + browser: { + ...cfg.agents?.defaults?.sandbox?.browser, + image, + }, }, }, }, @@ -198,7 +204,7 @@ export async function maybeRepairSandboxImages( runtime: RuntimeEnv, prompter: DoctorPrompter, ): Promise { - const sandbox = cfg.agent?.sandbox; + const sandbox = cfg.agents?.defaults?.sandbox; const mode = sandbox?.mode ?? "off"; if (!sandbox || mode === "off") return cfg; @@ -224,7 +230,7 @@ export async function maybeRepairSandboxImages( : undefined, updateConfig: (image) => { next = updateSandboxDockerImage(next, image); - changes.push(`Updated agent.sandbox.docker.image → ${image}`); + changes.push(`Updated agents.defaults.sandbox.docker.image → ${image}`); }, }, runtime, @@ -239,7 +245,9 @@ export async function maybeRepairSandboxImages( buildScript: "scripts/sandbox-browser-setup.sh", updateConfig: (image) => { next = updateSandboxBrowserImage(next, image); - changes.push(`Updated agent.sandbox.browser.image → ${image}`); + changes.push( + `Updated agents.defaults.sandbox.browser.image → ${image}`, + ); }, }, runtime, @@ -255,11 +263,12 @@ export async function maybeRepairSandboxImages( } export function noteSandboxScopeWarnings(cfg: ClawdbotConfig) { - const globalSandbox = cfg.agent?.sandbox; - const agents = cfg.routing?.agents ?? {}; + const globalSandbox = cfg.agents?.defaults?.sandbox; + const agents = Array.isArray(cfg.agents?.list) ? cfg.agents.list : []; const warnings: string[] = []; - for (const [agentId, agent] of Object.entries(agents)) { + for (const agent of agents) { + const agentId = agent.id; const agentSandbox = agent.sandbox; if (!agentSandbox) continue; @@ -284,7 +293,7 @@ export function noteSandboxScopeWarnings(cfg: ClawdbotConfig) { if (overrides.length === 0) continue; warnings.push( - `- routing.agents.${agentId}.sandbox: ${overrides.join( + `- agents.list (id "${agentId}") sandbox ${overrides.join( "/", )} overrides ignored (scope resolves to "shared").`, ); diff --git a/src/commands/doctor-state-integrity.ts b/src/commands/doctor-state-integrity.ts index 49d849ffa..e667ec4a6 100644 --- a/src/commands/doctor-state-integrity.ts +++ b/src/commands/doctor-state-integrity.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { note as clackNote } from "@clack/prompts"; +import { resolveDefaultAgentId } from "../agents/agent-scope.js"; import type { ClawdbotConfig } from "../config/config.js"; import { resolveOAuthDir, resolveStateDir } from "../config/paths.js"; import { @@ -13,7 +14,6 @@ import { resolveSessionTranscriptsDirForAgent, resolveStorePath, } from "../config/sessions.js"; -import { DEFAULT_AGENT_ID, normalizeAgentId } from "../routing/session-key.js"; import { stylePromptTitle } from "../terminal/prompt-style.js"; const note = (message: string, title?: string) => @@ -136,9 +136,7 @@ export async function noteStateIntegrity( const stateDir = resolveStateDir(env, homedir); const defaultStateDir = path.join(homedir(), ".clawdbot"); const oauthDir = resolveOAuthDir(env, stateDir); - const agentId = normalizeAgentId( - cfg.routing?.defaultAgentId ?? DEFAULT_AGENT_ID, - ); + const agentId = resolveDefaultAgentId(cfg); const sessionsDir = resolveSessionTranscriptsDirForAgent( agentId, env, diff --git a/src/commands/doctor-state-migrations.test.ts b/src/commands/doctor-state-migrations.test.ts index 760db978b..352ade5ff 100644 --- a/src/commands/doctor-state-migrations.test.ts +++ b/src/commands/doctor-state-migrations.test.ts @@ -186,9 +186,11 @@ describe("doctor legacy state migrations", () => { expect(result.changes).toEqual([]); }); - it("routes legacy state to routing.defaultAgentId", async () => { + it("routes legacy state to the default agent entry", async () => { const root = await makeTempRoot(); - const cfg: ClawdbotConfig = { routing: { defaultAgentId: "alpha" } }; + const cfg: ClawdbotConfig = { + agents: { list: [{ id: "alpha", default: true }] }, + }; const legacySessionsDir = path.join(root, "sessions"); fs.mkdirSync(legacySessionsDir, { recursive: true }); writeJson5(path.join(legacySessionsDir, "sessions.json"), { diff --git a/src/commands/doctor.test.ts b/src/commands/doctor.test.ts index 1ba4f2c5e..468548e55 100644 --- a/src/commands/doctor.test.ts +++ b/src/commands/doctor.test.ts @@ -344,13 +344,15 @@ describe("doctor", () => { raw: "{}", parsed: { gateway: { mode: "local", bind: "loopback" }, - agent: { - workspace: "/Users/steipete/clawd", - sandbox: { - workspaceRoot: "/Users/steipete/clawd/sandboxes", - docker: { - image: "clawdbot-sandbox", - containerPrefix: "clawdbot-sbx", + agents: { + defaults: { + workspace: "/Users/steipete/clawd", + sandbox: { + workspaceRoot: "/Users/steipete/clawd/sandboxes", + docker: { + image: "clawdbot-sandbox", + containerPrefix: "clawdbot-sbx", + }, }, }, }, @@ -358,13 +360,15 @@ describe("doctor", () => { valid: true, config: { gateway: { mode: "local", bind: "loopback" }, - agent: { - workspace: "/Users/steipete/clawd", - sandbox: { - workspaceRoot: "/Users/steipete/clawd/sandboxes", - docker: { - image: "clawdbot-sandbox", - containerPrefix: "clawdbot-sbx", + agents: { + defaults: { + workspace: "/Users/steipete/clawd", + sandbox: { + workspaceRoot: "/Users/steipete/clawd/sandboxes", + docker: { + image: "clawdbot-sandbox", + containerPrefix: "clawdbot-sbx", + }, }, }, }, @@ -411,13 +415,15 @@ describe("doctor", () => { migrateLegacyConfig.mockReturnValueOnce({ config: { gateway: { mode: "local", bind: "loopback" }, - agent: { - workspace: "/Users/steipete/clawd", - sandbox: { - workspaceRoot: "/Users/steipete/clawd/sandboxes", - docker: { - image: "clawdis-sandbox", - containerPrefix: "clawdis-sbx", + agents: { + defaults: { + workspace: "/Users/steipete/clawd", + sandbox: { + workspaceRoot: "/Users/steipete/clawd/sandboxes", + docker: { + image: "clawdis-sandbox", + containerPrefix: "clawdis-sbx", + }, }, }, }, @@ -438,11 +444,12 @@ describe("doctor", () => { string, unknown >; - const agent = written.agent as Record; - const sandbox = agent.sandbox as Record; + const agents = written.agents as Record; + const defaults = agents.defaults as Record; + const sandbox = defaults.sandbox as Record; const docker = sandbox.docker as Record; - expect(agent.workspace).toBe("/Users/steipete/clawd"); + expect(defaults.workspace).toBe("/Users/steipete/clawd"); expect(sandbox.workspaceRoot).toBe("/Users/steipete/clawd/sandboxes"); expect(docker.image).toBe("clawdbot-sandbox"); expect(docker.containerPrefix).toBe("clawdbot-sbx"); @@ -456,15 +463,16 @@ describe("doctor", () => { parsed: {}, valid: true, config: { - agent: { - sandbox: { - mode: "all", - scope: "shared", + agents: { + defaults: { + sandbox: { + mode: "all", + scope: "shared", + }, }, - }, - routing: { - agents: { - work: { + list: [ + { + id: "work", workspace: "~/clawd-work", sandbox: { mode: "all", @@ -474,7 +482,7 @@ describe("doctor", () => { }, }, }, - }, + ], }, }, issues: [], @@ -497,7 +505,7 @@ describe("doctor", () => { ([message, title]) => title === "Sandbox" && typeof message === "string" && - message.includes("routing.agents.work.sandbox") && + message.includes('agents.list (id "work") sandbox docker') && message.includes('scope resolves to "shared"'), ), ).toBe(true); @@ -511,7 +519,7 @@ describe("doctor", () => { parsed: {}, valid: true, config: { - agent: { workspace: "/Users/steipete/clawd" }, + agents: { defaults: { workspace: "/Users/steipete/clawd" } }, }, issues: [], legacyIssues: [], @@ -556,22 +564,26 @@ describe("doctor", () => { exists: true, raw: "{}", parsed: { - agent: { - sandbox: { - mode: "non-main", - docker: { - image: "clawdbot-sandbox-common:bookworm-slim", + agents: { + defaults: { + sandbox: { + mode: "non-main", + docker: { + image: "clawdbot-sandbox-common:bookworm-slim", + }, }, }, }, }, valid: true, config: { - agent: { - sandbox: { - mode: "non-main", - docker: { - image: "clawdbot-sandbox-common:bookworm-slim", + agents: { + defaults: { + sandbox: { + mode: "non-main", + docker: { + image: "clawdbot-sandbox-common:bookworm-slim", + }, }, }, }, @@ -614,8 +626,9 @@ describe("doctor", () => { string, unknown >; - const agent = written.agent as Record; - const sandbox = agent.sandbox as Record; + const agents = written.agents as Record; + const defaults = agents.defaults as Record; + const sandbox = defaults.sandbox as Record; const docker = sandbox.docker as Record; expect(docker.image).toBe("clawdis-sandbox-common:bookworm-slim"); diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 7e670cc57..9c27ff030 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -4,6 +4,10 @@ import { note as clackNote, outro as clackOutro, } from "@clack/prompts"; +import { + resolveAgentWorkspaceDir, + resolveDefaultAgentId, +} from "../agents/agent-scope.js"; import { buildWorkspaceSkillStatus } from "../agents/skills-status.js"; import type { ClawdbotConfig } from "../config/config.js"; import { @@ -25,7 +29,7 @@ import { collectProvidersStatusIssues } from "../infra/providers-status-issues.j import type { RuntimeEnv } from "../runtime.js"; import { defaultRuntime } from "../runtime.js"; import { stylePromptTitle } from "../terminal/prompt-style.js"; -import { resolveUserPath, sleep } from "../utils.js"; +import { sleep } from "../utils.js"; import { DEFAULT_GATEWAY_DAEMON_RUNTIME, GATEWAY_DAEMON_RUNTIME_OPTIONS, @@ -69,11 +73,7 @@ import { shouldSuggestMemorySystem, } from "./doctor-workspace.js"; import { healthCommand } from "./health.js"; -import { - applyWizardMetadata, - DEFAULT_WORKSPACE, - printWizardHeader, -} from "./onboard-helpers.js"; +import { applyWizardMetadata, printWizardHeader } from "./onboard-helpers.js"; import { ensureSystemdUserLingerInteractive } from "./systemd-linger.js"; const intro = (message: string) => @@ -224,8 +224,9 @@ export async function doctorCommand( } } - const workspaceDir = resolveUserPath( - cfg.agent?.workspace ?? DEFAULT_WORKSPACE, + const workspaceDir = resolveAgentWorkspaceDir( + cfg, + resolveDefaultAgentId(cfg), ); const legacyWorkspace = detectLegacyWorkspaceDirs({ workspaceDir }); if (legacyWorkspace.legacyDirs.length > 0) { @@ -415,8 +416,9 @@ export async function doctorCommand( runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`); if (options.workspaceSuggestions !== false) { - const workspaceDir = resolveUserPath( - cfg.agent?.workspace ?? DEFAULT_WORKSPACE, + const workspaceDir = resolveAgentWorkspaceDir( + cfg, + resolveDefaultAgentId(cfg), ); noteWorkspaceBackupTip(workspaceDir); if (await shouldSuggestMemorySystem(workspaceDir)) { diff --git a/src/commands/google-gemini-model-default.test.ts b/src/commands/google-gemini-model-default.test.ts index 9dff42e8c..e8946bc9f 100644 --- a/src/commands/google-gemini-model-default.test.ts +++ b/src/commands/google-gemini-model-default.test.ts @@ -8,28 +8,28 @@ import { describe("applyGoogleGeminiModelDefault", () => { it("sets gemini default when model is unset", () => { - const cfg: ClawdbotConfig = { agent: {} }; + const cfg: ClawdbotConfig = { agents: { defaults: {} } }; const applied = applyGoogleGeminiModelDefault(cfg); expect(applied.changed).toBe(true); - expect(applied.next.agent?.model).toEqual({ + expect(applied.next.agents?.defaults?.model).toEqual({ primary: GOOGLE_GEMINI_DEFAULT_MODEL, }); }); it("overrides existing model", () => { const cfg: ClawdbotConfig = { - agent: { model: "anthropic/claude-opus-4-5" }, + agents: { defaults: { model: "anthropic/claude-opus-4-5" } }, }; const applied = applyGoogleGeminiModelDefault(cfg); expect(applied.changed).toBe(true); - expect(applied.next.agent?.model).toEqual({ + expect(applied.next.agents?.defaults?.model).toEqual({ primary: GOOGLE_GEMINI_DEFAULT_MODEL, }); }); it("no-ops when already gemini default", () => { const cfg: ClawdbotConfig = { - agent: { model: GOOGLE_GEMINI_DEFAULT_MODEL }, + agents: { defaults: { model: GOOGLE_GEMINI_DEFAULT_MODEL } }, }; const applied = applyGoogleGeminiModelDefault(cfg); expect(applied.changed).toBe(false); diff --git a/src/commands/google-gemini-model-default.ts b/src/commands/google-gemini-model-default.ts index 6ae4917db..d45e28592 100644 --- a/src/commands/google-gemini-model-default.ts +++ b/src/commands/google-gemini-model-default.ts @@ -17,7 +17,7 @@ export function applyGoogleGeminiModelDefault(cfg: ClawdbotConfig): { next: ClawdbotConfig; changed: boolean; } { - const current = resolvePrimaryModel(cfg.agent?.model)?.trim(); + const current = resolvePrimaryModel(cfg.agents?.defaults?.model)?.trim(); if (current === GOOGLE_GEMINI_DEFAULT_MODEL) { return { next: cfg, changed: false }; } @@ -25,12 +25,19 @@ export function applyGoogleGeminiModelDefault(cfg: ClawdbotConfig): { return { next: { ...cfg, - agent: { - ...cfg.agent, - model: - cfg.agent?.model && typeof cfg.agent.model === "object" - ? { ...cfg.agent.model, primary: GOOGLE_GEMINI_DEFAULT_MODEL } - : { primary: GOOGLE_GEMINI_DEFAULT_MODEL }, + agents: { + ...cfg.agents, + defaults: { + ...cfg.agents?.defaults, + model: + cfg.agents?.defaults?.model && + typeof cfg.agents.defaults.model === "object" + ? { + ...cfg.agents.defaults.model, + primary: GOOGLE_GEMINI_DEFAULT_MODEL, + } + : { primary: GOOGLE_GEMINI_DEFAULT_MODEL }, + }, }, }, changed: true, diff --git a/src/commands/models.list.test.ts b/src/commands/models.list.test.ts index 84dc203c4..2d7e339d8 100644 --- a/src/commands/models.list.test.ts +++ b/src/commands/models.list.test.ts @@ -57,7 +57,9 @@ function makeRuntime() { describe("models list/status", () => { it("models status resolves z.ai alias to canonical zai", async () => { - loadConfig.mockReturnValue({ agent: { model: "z.ai/glm-4.7" } }); + loadConfig.mockReturnValue({ + agents: { defaults: { model: "z.ai/glm-4.7" } }, + }); const runtime = makeRuntime(); const { modelsStatusCommand } = await import("./models/list.js"); @@ -69,7 +71,9 @@ describe("models list/status", () => { }); it("models status plain outputs canonical zai model", async () => { - loadConfig.mockReturnValue({ agent: { model: "z.ai/glm-4.7" } }); + loadConfig.mockReturnValue({ + agents: { defaults: { model: "z.ai/glm-4.7" } }, + }); const runtime = makeRuntime(); const { modelsStatusCommand } = await import("./models/list.js"); @@ -80,7 +84,9 @@ describe("models list/status", () => { }); it("models list outputs canonical zai key for configured z.ai model", async () => { - loadConfig.mockReturnValue({ agent: { model: "z.ai/glm-4.7" } }); + loadConfig.mockReturnValue({ + agents: { defaults: { model: "z.ai/glm-4.7" } }, + }); const runtime = makeRuntime(); const model = { @@ -106,7 +112,9 @@ describe("models list/status", () => { }); it("models list plain outputs canonical zai key", async () => { - loadConfig.mockReturnValue({ agent: { model: "z.ai/glm-4.7" } }); + loadConfig.mockReturnValue({ + agents: { defaults: { model: "z.ai/glm-4.7" } }, + }); const runtime = makeRuntime(); const model = { @@ -131,7 +139,9 @@ describe("models list/status", () => { }); it("models list provider filter normalizes z.ai alias", async () => { - loadConfig.mockReturnValue({ agent: { model: "z.ai/glm-4.7" } }); + loadConfig.mockReturnValue({ + agents: { defaults: { model: "z.ai/glm-4.7" } }, + }); const runtime = makeRuntime(); const models = [ @@ -171,7 +181,9 @@ describe("models list/status", () => { }); it("models list provider filter normalizes Z.AI alias casing", async () => { - loadConfig.mockReturnValue({ agent: { model: "z.ai/glm-4.7" } }); + loadConfig.mockReturnValue({ + agents: { defaults: { model: "z.ai/glm-4.7" } }, + }); const runtime = makeRuntime(); const models = [ @@ -211,7 +223,9 @@ describe("models list/status", () => { }); it("models list provider filter normalizes z-ai alias", async () => { - loadConfig.mockReturnValue({ agent: { model: "z.ai/glm-4.7" } }); + loadConfig.mockReturnValue({ + agents: { defaults: { model: "z.ai/glm-4.7" } }, + }); const runtime = makeRuntime(); const models = [ @@ -251,7 +265,9 @@ describe("models list/status", () => { }); it("models list marks auth as unavailable when ZAI key is missing", async () => { - loadConfig.mockReturnValue({ agent: { model: "z.ai/glm-4.7" } }); + loadConfig.mockReturnValue({ + agents: { defaults: { model: "z.ai/glm-4.7" } }, + }); const runtime = makeRuntime(); const model = { diff --git a/src/commands/models.set.test.ts b/src/commands/models.set.test.ts index 03b85d9e6..9e1908f10 100644 --- a/src/commands/models.set.test.ts +++ b/src/commands/models.set.test.ts @@ -39,9 +39,11 @@ describe("models set + fallbacks", () => { string, unknown >; - expect(written.agent).toEqual({ - model: { primary: "zai/glm-4.7" }, - models: { "zai/glm-4.7": {} }, + expect(written.agents).toEqual({ + defaults: { + model: { primary: "zai/glm-4.7" }, + models: { "zai/glm-4.7": {} }, + }, }); }); @@ -52,7 +54,7 @@ describe("models set + fallbacks", () => { raw: "{}", parsed: {}, valid: true, - config: { agent: { model: { fallbacks: [] } } }, + config: { agents: { defaults: { model: { fallbacks: [] } } } }, issues: [], legacyIssues: [], }); @@ -67,9 +69,11 @@ describe("models set + fallbacks", () => { string, unknown >; - expect(written.agent).toEqual({ - model: { fallbacks: ["zai/glm-4.7"] }, - models: { "zai/glm-4.7": {} }, + expect(written.agents).toEqual({ + defaults: { + model: { fallbacks: ["zai/glm-4.7"] }, + models: { "zai/glm-4.7": {} }, + }, }); }); @@ -95,9 +99,11 @@ describe("models set + fallbacks", () => { string, unknown >; - expect(written.agent).toEqual({ - model: { primary: "zai/glm-4.7" }, - models: { "zai/glm-4.7": {} }, + expect(written.agents).toEqual({ + defaults: { + model: { primary: "zai/glm-4.7" }, + models: { "zai/glm-4.7": {} }, + }, }); }); }); diff --git a/src/commands/models/aliases.ts b/src/commands/models/aliases.ts index 9600b7494..2991ab111 100644 --- a/src/commands/models/aliases.ts +++ b/src/commands/models/aliases.ts @@ -13,7 +13,7 @@ export async function modelsAliasesListCommand( ) { ensureFlagCompatibility(opts); const cfg = loadConfig(); - const models = cfg.agent?.models ?? {}; + const models = cfg.agents?.defaults?.models ?? {}; const aliases = Object.entries(models).reduce>( (acc, [modelKey, entry]) => { const alias = entry?.alias?.trim(); @@ -53,7 +53,7 @@ export async function modelsAliasesAddCommand( const resolved = resolveModelTarget({ raw: modelRaw, cfg: loadConfig() }); const _updated = await updateConfig((cfg) => { const modelKey = `${resolved.provider}/${resolved.model}`; - const nextModels = { ...cfg.agent?.models }; + const nextModels = { ...cfg.agents?.defaults?.models }; for (const [key, entry] of Object.entries(nextModels)) { const existing = entry?.alias?.trim(); if (existing && existing === alias && key !== modelKey) { @@ -64,9 +64,12 @@ export async function modelsAliasesAddCommand( nextModels[modelKey] = { ...existing, alias }; return { ...cfg, - agent: { - ...cfg.agent, - models: nextModels, + agents: { + ...cfg.agents, + defaults: { + ...cfg.agents?.defaults, + models: nextModels, + }, }, }; }); @@ -81,7 +84,7 @@ export async function modelsAliasesRemoveCommand( ) { const alias = normalizeAlias(aliasRaw); const updated = await updateConfig((cfg) => { - const nextModels = { ...cfg.agent?.models }; + const nextModels = { ...cfg.agents?.defaults?.models }; let found = false; for (const [key, entry] of Object.entries(nextModels)) { if (entry?.alias?.trim() === alias) { @@ -95,17 +98,22 @@ export async function modelsAliasesRemoveCommand( } return { ...cfg, - agent: { - ...cfg.agent, - models: nextModels, + agents: { + ...cfg.agents, + defaults: { + ...cfg.agents?.defaults, + models: nextModels, + }, }, }; }); runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`); if ( - !updated.agent?.models || - Object.values(updated.agent.models).every((entry) => !entry?.alias?.trim()) + !updated.agents?.defaults?.models || + Object.values(updated.agents.defaults.models).every( + (entry) => !entry?.alias?.trim(), + ) ) { runtime.log("No aliases configured."); } diff --git a/src/commands/models/fallbacks.ts b/src/commands/models/fallbacks.ts index c5ac94f4d..4b49d4ed1 100644 --- a/src/commands/models/fallbacks.ts +++ b/src/commands/models/fallbacks.ts @@ -18,7 +18,7 @@ export async function modelsFallbacksListCommand( ) { ensureFlagCompatibility(opts); const cfg = loadConfig(); - const fallbacks = cfg.agent?.model?.fallbacks ?? []; + const fallbacks = cfg.agents?.defaults?.model?.fallbacks ?? []; if (opts.json) { runtime.log(JSON.stringify({ fallbacks }, null, 2)); @@ -44,13 +44,13 @@ export async function modelsFallbacksAddCommand( const updated = await updateConfig((cfg) => { const resolved = resolveModelTarget({ raw: modelRaw, cfg }); const targetKey = modelKey(resolved.provider, resolved.model); - const nextModels = { ...cfg.agent?.models }; + const nextModels = { ...cfg.agents?.defaults?.models }; if (!nextModels[targetKey]) nextModels[targetKey] = {}; const aliasIndex = buildModelAliasIndex({ cfg, defaultProvider: DEFAULT_PROVIDER, }); - const existing = cfg.agent?.model?.fallbacks ?? []; + const existing = cfg.agents?.defaults?.model?.fallbacks ?? []; const existingKeys = existing .map((entry) => resolveModelRefFromString({ @@ -64,28 +64,31 @@ export async function modelsFallbacksAddCommand( if (existingKeys.includes(targetKey)) return cfg; - const existingModel = cfg.agent?.model as + const existingModel = cfg.agents?.defaults?.model as | { primary?: string; fallbacks?: string[] } | undefined; return { ...cfg, - agent: { - ...cfg.agent, - model: { - ...(existingModel?.primary - ? { primary: existingModel.primary } - : undefined), - fallbacks: [...existing, targetKey], + agents: { + ...cfg.agents, + defaults: { + ...cfg.agents?.defaults, + model: { + ...(existingModel?.primary + ? { primary: existingModel.primary } + : undefined), + fallbacks: [...existing, targetKey], + }, + models: nextModels, }, - models: nextModels, }, }; }); runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`); runtime.log( - `Fallbacks: ${(updated.agent?.model?.fallbacks ?? []).join(", ")}`, + `Fallbacks: ${(updated.agents?.defaults?.model?.fallbacks ?? []).join(", ")}`, ); } @@ -100,7 +103,7 @@ export async function modelsFallbacksRemoveCommand( cfg, defaultProvider: DEFAULT_PROVIDER, }); - const existing = cfg.agent?.model?.fallbacks ?? []; + const existing = cfg.agents?.defaults?.model?.fallbacks ?? []; const filtered = existing.filter((entry) => { const resolvedEntry = resolveModelRefFromString({ raw: String(entry ?? ""), @@ -118,19 +121,22 @@ export async function modelsFallbacksRemoveCommand( throw new Error(`Fallback not found: ${targetKey}`); } - const existingModel = cfg.agent?.model as + const existingModel = cfg.agents?.defaults?.model as | { primary?: string; fallbacks?: string[] } | undefined; return { ...cfg, - agent: { - ...cfg.agent, - model: { - ...(existingModel?.primary - ? { primary: existingModel.primary } - : undefined), - fallbacks: filtered, + agents: { + ...cfg.agents, + defaults: { + ...cfg.agents?.defaults, + model: { + ...(existingModel?.primary + ? { primary: existingModel.primary } + : undefined), + fallbacks: filtered, + }, }, }, }; @@ -138,24 +144,27 @@ export async function modelsFallbacksRemoveCommand( runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`); runtime.log( - `Fallbacks: ${(updated.agent?.model?.fallbacks ?? []).join(", ")}`, + `Fallbacks: ${(updated.agents?.defaults?.model?.fallbacks ?? []).join(", ")}`, ); } export async function modelsFallbacksClearCommand(runtime: RuntimeEnv) { await updateConfig((cfg) => { - const existingModel = cfg.agent?.model as + const existingModel = cfg.agents?.defaults?.model as | { primary?: string; fallbacks?: string[] } | undefined; return { ...cfg, - agent: { - ...cfg.agent, - model: { - ...(existingModel?.primary - ? { primary: existingModel.primary } - : undefined), - fallbacks: [], + agents: { + ...cfg.agents, + defaults: { + ...cfg.agents?.defaults, + model: { + ...(existingModel?.primary + ? { primary: existingModel.primary } + : undefined), + fallbacks: [], + }, }, }, }; diff --git a/src/commands/models/image-fallbacks.ts b/src/commands/models/image-fallbacks.ts index 25ea316ec..f106b331d 100644 --- a/src/commands/models/image-fallbacks.ts +++ b/src/commands/models/image-fallbacks.ts @@ -18,7 +18,7 @@ export async function modelsImageFallbacksListCommand( ) { ensureFlagCompatibility(opts); const cfg = loadConfig(); - const fallbacks = cfg.agent?.imageModel?.fallbacks ?? []; + const fallbacks = cfg.agents?.defaults?.imageModel?.fallbacks ?? []; if (opts.json) { runtime.log(JSON.stringify({ fallbacks }, null, 2)); @@ -44,13 +44,13 @@ export async function modelsImageFallbacksAddCommand( const updated = await updateConfig((cfg) => { const resolved = resolveModelTarget({ raw: modelRaw, cfg }); const targetKey = modelKey(resolved.provider, resolved.model); - const nextModels = { ...cfg.agent?.models }; + const nextModels = { ...cfg.agents?.defaults?.models }; if (!nextModels[targetKey]) nextModels[targetKey] = {}; const aliasIndex = buildModelAliasIndex({ cfg, defaultProvider: DEFAULT_PROVIDER, }); - const existing = cfg.agent?.imageModel?.fallbacks ?? []; + const existing = cfg.agents?.defaults?.imageModel?.fallbacks ?? []; const existingKeys = existing .map((entry) => resolveModelRefFromString({ @@ -64,28 +64,31 @@ export async function modelsImageFallbacksAddCommand( if (existingKeys.includes(targetKey)) return cfg; - const existingModel = cfg.agent?.imageModel as + const existingModel = cfg.agents?.defaults?.imageModel as | { primary?: string; fallbacks?: string[] } | undefined; return { ...cfg, - agent: { - ...cfg.agent, - imageModel: { - ...(existingModel?.primary - ? { primary: existingModel.primary } - : undefined), - fallbacks: [...existing, targetKey], + agents: { + ...cfg.agents, + defaults: { + ...cfg.agents?.defaults, + imageModel: { + ...(existingModel?.primary + ? { primary: existingModel.primary } + : undefined), + fallbacks: [...existing, targetKey], + }, + models: nextModels, }, - models: nextModels, }, }; }); runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`); runtime.log( - `Image fallbacks: ${(updated.agent?.imageModel?.fallbacks ?? []).join(", ")}`, + `Image fallbacks: ${(updated.agents?.defaults?.imageModel?.fallbacks ?? []).join(", ")}`, ); } @@ -100,7 +103,7 @@ export async function modelsImageFallbacksRemoveCommand( cfg, defaultProvider: DEFAULT_PROVIDER, }); - const existing = cfg.agent?.imageModel?.fallbacks ?? []; + const existing = cfg.agents?.defaults?.imageModel?.fallbacks ?? []; const filtered = existing.filter((entry) => { const resolvedEntry = resolveModelRefFromString({ raw: String(entry ?? ""), @@ -118,19 +121,22 @@ export async function modelsImageFallbacksRemoveCommand( throw new Error(`Image fallback not found: ${targetKey}`); } - const existingModel = cfg.agent?.imageModel as + const existingModel = cfg.agents?.defaults?.imageModel as | { primary?: string; fallbacks?: string[] } | undefined; return { ...cfg, - agent: { - ...cfg.agent, - imageModel: { - ...(existingModel?.primary - ? { primary: existingModel.primary } - : undefined), - fallbacks: filtered, + agents: { + ...cfg.agents, + defaults: { + ...cfg.agents?.defaults, + imageModel: { + ...(existingModel?.primary + ? { primary: existingModel.primary } + : undefined), + fallbacks: filtered, + }, }, }, }; @@ -138,24 +144,27 @@ export async function modelsImageFallbacksRemoveCommand( runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`); runtime.log( - `Image fallbacks: ${(updated.agent?.imageModel?.fallbacks ?? []).join(", ")}`, + `Image fallbacks: ${(updated.agents?.defaults?.imageModel?.fallbacks ?? []).join(", ")}`, ); } export async function modelsImageFallbacksClearCommand(runtime: RuntimeEnv) { await updateConfig((cfg) => { - const existingModel = cfg.agent?.imageModel as + const existingModel = cfg.agents?.defaults?.imageModel as | { primary?: string; fallbacks?: string[] } | undefined; return { ...cfg, - agent: { - ...cfg.agent, - imageModel: { - ...(existingModel?.primary - ? { primary: existingModel.primary } - : undefined), - fallbacks: [], + agents: { + ...cfg.agents, + defaults: { + ...cfg.agents?.defaults, + imageModel: { + ...(existingModel?.primary + ? { primary: existingModel.primary } + : undefined), + fallbacks: [], + }, }, }, }; diff --git a/src/commands/models/list.status.test.ts b/src/commands/models/list.status.test.ts index eab6df45a..ba85255cb 100644 --- a/src/commands/models/list.status.test.ts +++ b/src/commands/models/list.status.test.ts @@ -63,9 +63,11 @@ const mocks = vi.hoisted(() => { .mockReturnValue(["OPENAI_API_KEY", "ANTHROPIC_OAUTH_TOKEN"]), shouldEnableShellEnvFallback: vi.fn().mockReturnValue(true), loadConfig: vi.fn().mockReturnValue({ - agent: { - model: { primary: "anthropic/claude-opus-4-5", fallbacks: [] }, - models: { "anthropic/claude-opus-4-5": { alias: "Opus" } }, + agents: { + defaults: { + model: { primary: "anthropic/claude-opus-4-5", fallbacks: [] }, + models: { "anthropic/claude-opus-4-5": { alias: "Opus" } }, + }, }, models: { providers: {} }, env: { shellEnv: { enabled: true } }, diff --git a/src/commands/models/list.ts b/src/commands/models/list.ts index 0ec63a3ab..d08b753e0 100644 --- a/src/commands/models/list.ts +++ b/src/commands/models/list.ts @@ -290,10 +290,10 @@ const resolveConfiguredEntries = (cfg: ClawdbotConfig) => { addEntry(resolvedDefault, "default"); - const modelConfig = cfg.agent?.model as + const modelConfig = cfg.agents?.defaults?.model as | { primary?: string; fallbacks?: string[] } | undefined; - const imageModelConfig = cfg.agent?.imageModel as + const imageModelConfig = cfg.agents?.defaults?.imageModel as | { primary?: string; fallbacks?: string[] } | undefined; const modelFallbacks = @@ -333,7 +333,7 @@ const resolveConfiguredEntries = (cfg: ClawdbotConfig) => { addEntry(resolved.ref, `img-fallback#${idx + 1}`); }); - for (const key of Object.keys(cfg.agent?.models ?? {})) { + for (const key of Object.keys(cfg.agents?.defaults?.models ?? {})) { const parsed = parseModelRef(String(key ?? ""), DEFAULT_PROVIDER); if (!parsed) continue; addEntry(parsed, "configured"); @@ -623,11 +623,11 @@ export async function modelsStatusCommand( defaultModel: DEFAULT_MODEL, }); - const modelConfig = cfg.agent?.model as + const modelConfig = cfg.agents?.defaults?.model as | { primary?: string; fallbacks?: string[] } | string | undefined; - const imageConfig = cfg.agent?.imageModel as + const imageConfig = cfg.agents?.defaults?.imageModel as | { primary?: string; fallbacks?: string[] } | string | undefined; @@ -645,14 +645,14 @@ export async function modelsStatusCommand( : (imageConfig?.primary?.trim() ?? ""); const imageFallbacks = typeof imageConfig === "object" ? (imageConfig?.fallbacks ?? []) : []; - const aliases = Object.entries(cfg.agent?.models ?? {}).reduce< + const aliases = Object.entries(cfg.agents?.defaults?.models ?? {}).reduce< Record >((acc, [key, entry]) => { const alias = entry?.alias?.trim(); if (alias) acc[alias] = key; return acc; }, {}); - const allowed = Object.keys(cfg.agent?.models ?? {}); + const allowed = Object.keys(cfg.agents?.defaults?.models ?? {}); const agentDir = resolveClawdbotAgentDir(); const store = ensureAuthProfileStore(); diff --git a/src/commands/models/scan.ts b/src/commands/models/scan.ts index 586f7f009..2fca56bf3 100644 --- a/src/commands/models/scan.ts +++ b/src/commands/models/scan.ts @@ -327,14 +327,14 @@ export async function modelsScanCommand( } const _updated = await updateConfig((cfg) => { - const nextModels = { ...cfg.agent?.models }; + const nextModels = { ...cfg.agents?.defaults?.models }; for (const entry of selected) { if (!nextModels[entry]) nextModels[entry] = {}; } for (const entry of selectedImages) { if (!nextModels[entry]) nextModels[entry] = {}; } - const existingImageModel = cfg.agent?.imageModel as + const existingImageModel = cfg.agents?.defaults?.imageModel as | { primary?: string; fallbacks?: string[] } | undefined; const nextImageModel = @@ -346,12 +346,12 @@ export async function modelsScanCommand( fallbacks: selectedImages, ...(opts.setImage ? { primary: selectedImages[0] } : {}), } - : cfg.agent?.imageModel; - const existingModel = cfg.agent?.model as + : cfg.agents?.defaults?.imageModel; + const existingModel = cfg.agents?.defaults?.model as | { primary?: string; fallbacks?: string[] } | undefined; - const agent = { - ...cfg.agent, + const defaults = { + ...cfg.agents?.defaults, model: { ...(existingModel?.primary ? { primary: existingModel.primary } @@ -361,10 +361,13 @@ export async function modelsScanCommand( }, ...(nextImageModel ? { imageModel: nextImageModel } : {}), models: nextModels, - } satisfies NonNullable; + } satisfies NonNullable["defaults"]>; return { ...cfg, - agent, + agents: { + ...cfg.agents, + defaults, + }, }; }); diff --git a/src/commands/models/set-image.ts b/src/commands/models/set-image.ts index ed7a3e0db..5bf851e9f 100644 --- a/src/commands/models/set-image.ts +++ b/src/commands/models/set-image.ts @@ -9,26 +9,31 @@ export async function modelsSetImageCommand( const updated = await updateConfig((cfg) => { const resolved = resolveModelTarget({ raw: modelRaw, cfg }); const key = `${resolved.provider}/${resolved.model}`; - const nextModels = { ...cfg.agent?.models }; + const nextModels = { ...cfg.agents?.defaults?.models }; if (!nextModels[key]) nextModels[key] = {}; - const existingModel = cfg.agent?.imageModel as + const existingModel = cfg.agents?.defaults?.imageModel as | { primary?: string; fallbacks?: string[] } | undefined; return { ...cfg, - agent: { - ...cfg.agent, - imageModel: { - ...(existingModel?.fallbacks - ? { fallbacks: existingModel.fallbacks } - : undefined), - primary: key, + agents: { + ...cfg.agents, + defaults: { + ...cfg.agents?.defaults, + imageModel: { + ...(existingModel?.fallbacks + ? { fallbacks: existingModel.fallbacks } + : undefined), + primary: key, + }, + models: nextModels, }, - models: nextModels, }, }; }); runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`); - runtime.log(`Image model: ${updated.agent?.imageModel?.primary ?? modelRaw}`); + runtime.log( + `Image model: ${updated.agents?.defaults?.imageModel?.primary ?? modelRaw}`, + ); } diff --git a/src/commands/models/set.ts b/src/commands/models/set.ts index 0cfc9cdc3..494abbd15 100644 --- a/src/commands/models/set.ts +++ b/src/commands/models/set.ts @@ -6,26 +6,31 @@ export async function modelsSetCommand(modelRaw: string, runtime: RuntimeEnv) { const updated = await updateConfig((cfg) => { const resolved = resolveModelTarget({ raw: modelRaw, cfg }); const key = `${resolved.provider}/${resolved.model}`; - const nextModels = { ...cfg.agent?.models }; + const nextModels = { ...cfg.agents?.defaults?.models }; if (!nextModels[key]) nextModels[key] = {}; - const existingModel = cfg.agent?.model as + const existingModel = cfg.agents?.defaults?.model as | { primary?: string; fallbacks?: string[] } | undefined; return { ...cfg, - agent: { - ...cfg.agent, - model: { - ...(existingModel?.fallbacks - ? { fallbacks: existingModel.fallbacks } - : undefined), - primary: key, + agents: { + ...cfg.agents, + defaults: { + ...cfg.agents?.defaults, + model: { + ...(existingModel?.fallbacks + ? { fallbacks: existingModel.fallbacks } + : undefined), + primary: key, + }, + models: nextModels, }, - models: nextModels, }, }; }); runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`); - runtime.log(`Default model: ${updated.agent?.model?.primary ?? modelRaw}`); + runtime.log( + `Default model: ${updated.agents?.defaults?.model?.primary ?? modelRaw}`, + ); } diff --git a/src/commands/models/shared.ts b/src/commands/models/shared.ts index a8d305998..c6c47498a 100644 --- a/src/commands/models/shared.ts +++ b/src/commands/models/shared.ts @@ -69,7 +69,7 @@ export function resolveModelTarget(params: { export function buildAllowlistSet(cfg: ClawdbotConfig): Set { const allowed = new Set(); - const models = cfg.agent?.models ?? {}; + const models = cfg.agents?.defaults?.models ?? {}; for (const raw of Object.keys(models)) { const parsed = parseModelRef(String(raw ?? ""), DEFAULT_PROVIDER); if (!parsed) continue; diff --git a/src/commands/onboard-auth.ts b/src/commands/onboard-auth.ts index fb17f4ca0..14325ff20 100644 --- a/src/commands/onboard-auth.ts +++ b/src/commands/onboard-auth.ts @@ -126,7 +126,7 @@ export function applyAuthProfileConfig( export function applyMinimaxProviderConfig( cfg: ClawdbotConfig, ): ClawdbotConfig { - const models = { ...cfg.agent?.models }; + const models = { ...cfg.agents?.defaults?.models }; models["anthropic/claude-opus-4-5"] = { ...models["anthropic/claude-opus-4-5"], alias: models["anthropic/claude-opus-4-5"]?.alias ?? "Opus", @@ -158,9 +158,12 @@ export function applyMinimaxProviderConfig( return { ...cfg, - agent: { - ...cfg.agent, - models, + agents: { + ...cfg.agents, + defaults: { + ...cfg.agents?.defaults, + models, + }, }, models: { mode: cfg.models?.mode ?? "merge", @@ -224,17 +227,21 @@ export function applyMinimaxConfig(cfg: ClawdbotConfig): ClawdbotConfig { const next = applyMinimaxProviderConfig(cfg); return { ...next, - agent: { - ...next.agent, - model: { - ...(next.agent?.model && - "fallbacks" in (next.agent.model as Record) - ? { - fallbacks: (next.agent.model as { fallbacks?: string[] }) - .fallbacks, - } - : undefined), - primary: "lmstudio/minimax-m2.1-gs32", + agents: { + ...next.agents, + defaults: { + ...next.agents?.defaults, + model: { + ...(next.agents?.defaults?.model && + "fallbacks" in (next.agents.defaults.model as Record) + ? { + fallbacks: ( + next.agents.defaults.model as { fallbacks?: string[] } + ).fallbacks, + } + : undefined), + primary: "lmstudio/minimax-m2.1-gs32", + }, }, }, }; diff --git a/src/commands/onboard-helpers.ts b/src/commands/onboard-helpers.ts index c8e777772..fea853718 100644 --- a/src/commands/onboard-helpers.ts +++ b/src/commands/onboard-helpers.ts @@ -36,13 +36,13 @@ export function guardCancel(value: T, runtime: RuntimeEnv): T { export function summarizeExistingConfig(config: ClawdbotConfig): string { const rows: string[] = []; - if (config.agent?.workspace) - rows.push(`workspace: ${config.agent.workspace}`); - if (config.agent?.model) { + const defaults = config.agents?.defaults; + if (defaults?.workspace) rows.push(`workspace: ${defaults.workspace}`); + if (defaults?.model) { const model = - typeof config.agent.model === "string" - ? config.agent.model - : config.agent.model.primary; + typeof defaults.model === "string" + ? defaults.model + : defaults.model.primary; if (model) rows.push(`model: ${model}`); } if (config.gateway?.mode) rows.push(`gateway.mode: ${config.gateway.mode}`); diff --git a/src/commands/onboard-non-interactive.ts b/src/commands/onboard-non-interactive.ts index f4dceef79..c27f00eb3 100644 --- a/src/commands/onboard-non-interactive.ts +++ b/src/commands/onboard-non-interactive.ts @@ -96,14 +96,21 @@ export async function runNonInteractiveOnboarding( } const workspaceDir = resolveUserPath( - (opts.workspace ?? baseConfig.agent?.workspace ?? DEFAULT_WORKSPACE).trim(), + ( + opts.workspace ?? + baseConfig.agents?.defaults?.workspace ?? + DEFAULT_WORKSPACE + ).trim(), ); let nextConfig: ClawdbotConfig = { ...baseConfig, - agent: { - ...baseConfig.agent, - workspace: workspaceDir, + agents: { + ...baseConfig.agents, + defaults: { + ...baseConfig.agents?.defaults, + workspace: workspaceDir, + }, }, gateway: { ...baseConfig.gateway, @@ -311,7 +318,7 @@ export async function runNonInteractiveOnboarding( await writeConfigFile(nextConfig); runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`); await ensureWorkspaceAndSessions(workspaceDir, runtime, { - skipBootstrap: Boolean(nextConfig.agent?.skipBootstrap), + skipBootstrap: Boolean(nextConfig.agents?.defaults?.skipBootstrap), }); const daemonRuntimeRaw = opts.daemonRuntime ?? DEFAULT_GATEWAY_DAEMON_RUNTIME; diff --git a/src/commands/openai-codex-model-default.test.ts b/src/commands/openai-codex-model-default.test.ts index 86497bb90..2a7734798 100644 --- a/src/commands/openai-codex-model-default.test.ts +++ b/src/commands/openai-codex-model-default.test.ts @@ -8,25 +8,29 @@ import { describe("applyOpenAICodexModelDefault", () => { it("sets openai-codex default when model is unset", () => { - const cfg: ClawdbotConfig = { agent: {} }; + const cfg: ClawdbotConfig = { agents: { defaults: {} } }; const applied = applyOpenAICodexModelDefault(cfg); expect(applied.changed).toBe(true); - expect(applied.next.agent?.model).toEqual({ + expect(applied.next.agents?.defaults?.model).toEqual({ primary: OPENAI_CODEX_DEFAULT_MODEL, }); }); it("sets openai-codex default when model is openai/*", () => { - const cfg: ClawdbotConfig = { agent: { model: "openai/gpt-5.2" } }; + const cfg: ClawdbotConfig = { + agents: { defaults: { model: "openai/gpt-5.2" } }, + }; const applied = applyOpenAICodexModelDefault(cfg); expect(applied.changed).toBe(true); - expect(applied.next.agent?.model).toEqual({ + expect(applied.next.agents?.defaults?.model).toEqual({ primary: OPENAI_CODEX_DEFAULT_MODEL, }); }); it("does not override openai-codex/*", () => { - const cfg: ClawdbotConfig = { agent: { model: "openai-codex/gpt-5.2" } }; + const cfg: ClawdbotConfig = { + agents: { defaults: { model: "openai-codex/gpt-5.2" } }, + }; const applied = applyOpenAICodexModelDefault(cfg); expect(applied.changed).toBe(false); expect(applied.next).toEqual(cfg); @@ -34,7 +38,7 @@ describe("applyOpenAICodexModelDefault", () => { it("does not override non-openai models", () => { const cfg: ClawdbotConfig = { - agent: { model: "anthropic/claude-opus-4-5" }, + agents: { defaults: { model: "anthropic/claude-opus-4-5" } }, }; const applied = applyOpenAICodexModelDefault(cfg); expect(applied.changed).toBe(false); diff --git a/src/commands/openai-codex-model-default.ts b/src/commands/openai-codex-model-default.ts index d1d5b0914..58706877c 100644 --- a/src/commands/openai-codex-model-default.ts +++ b/src/commands/openai-codex-model-default.ts @@ -26,19 +26,26 @@ export function applyOpenAICodexModelDefault(cfg: ClawdbotConfig): { next: ClawdbotConfig; changed: boolean; } { - const current = resolvePrimaryModel(cfg.agent?.model); + const current = resolvePrimaryModel(cfg.agents?.defaults?.model); if (!shouldSetOpenAICodexModel(current)) { return { next: cfg, changed: false }; } return { next: { ...cfg, - agent: { - ...cfg.agent, - model: - cfg.agent?.model && typeof cfg.agent.model === "object" - ? { ...cfg.agent.model, primary: OPENAI_CODEX_DEFAULT_MODEL } - : { primary: OPENAI_CODEX_DEFAULT_MODEL }, + agents: { + ...cfg.agents, + defaults: { + ...cfg.agents?.defaults, + model: + cfg.agents?.defaults?.model && + typeof cfg.agents.defaults.model === "object" + ? { + ...cfg.agents.defaults.model, + primary: OPENAI_CODEX_DEFAULT_MODEL, + } + : { primary: OPENAI_CODEX_DEFAULT_MODEL }, + }, }, }, changed: true, diff --git a/src/commands/sessions.test.ts b/src/commands/sessions.test.ts index de68266e1..ce6737159 100644 --- a/src/commands/sessions.test.ts +++ b/src/commands/sessions.test.ts @@ -12,10 +12,12 @@ vi.mock("../config/config.js", async (importOriginal) => { return { ...actual, loadConfig: () => ({ - agent: { - model: { primary: "pi:opus" }, - models: { "pi:opus": {} }, - contextTokens: 32000, + agents: { + defaults: { + model: { primary: "pi:opus" }, + models: { "pi:opus": {} }, + contextTokens: 32000, + }, }, }), }; diff --git a/src/commands/sessions.ts b/src/commands/sessions.ts index 2e792e2f7..53fc6257f 100644 --- a/src/commands/sessions.ts +++ b/src/commands/sessions.ts @@ -169,7 +169,7 @@ export async function sessionsCommand( defaultModel: DEFAULT_MODEL, }); const configContextTokens = - cfg.agent?.contextTokens ?? + cfg.agents?.defaults?.contextTokens ?? lookupContextTokens(resolved.model) ?? DEFAULT_CONTEXT_TOKENS; const configModel = resolved.model ?? DEFAULT_MODEL; diff --git a/src/commands/setup.ts b/src/commands/setup.ts index 0dc1d9048..991002df0 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -48,25 +48,28 @@ export async function setupCommand( const existingRaw = await readConfigFileRaw(); const cfg = existingRaw.parsed; - const agent = cfg.agent ?? {}; + const defaults = cfg.agents?.defaults ?? {}; const workspace = - desiredWorkspace ?? agent.workspace ?? DEFAULT_AGENT_WORKSPACE_DIR; + desiredWorkspace ?? defaults.workspace ?? DEFAULT_AGENT_WORKSPACE_DIR; const next: ClawdbotConfig = { ...cfg, - agent: { - ...agent, - workspace, + agents: { + ...cfg.agents, + defaults: { + ...defaults, + workspace, + }, }, }; - if (!existingRaw.exists || agent.workspace !== workspace) { + if (!existingRaw.exists || defaults.workspace !== workspace) { await writeConfigFile(next); runtime.log( !existingRaw.exists ? `Wrote ${CONFIG_PATH_CLAWDBOT}` - : `Updated ${CONFIG_PATH_CLAWDBOT} (set agent.workspace)`, + : `Updated ${CONFIG_PATH_CLAWDBOT} (set agents.defaults.workspace)`, ); } else { runtime.log(`Config OK: ${CONFIG_PATH_CLAWDBOT}`); @@ -74,7 +77,7 @@ export async function setupCommand( const ws = await ensureAgentWorkspace({ dir: workspace, - ensureBootstrapFiles: !next.agent?.skipBootstrap, + ensureBootstrapFiles: !next.agents?.defaults?.skipBootstrap, }); runtime.log(`Workspace OK: ${ws.dir}`); diff --git a/src/commands/status.ts b/src/commands/status.ts index 0b547fb65..1343ae206 100644 --- a/src/commands/status.ts +++ b/src/commands/status.ts @@ -86,7 +86,7 @@ export async function getStatusSummary(): Promise { }); const configModel = resolved.model ?? DEFAULT_MODEL; const configContextTokens = - cfg.agent?.contextTokens ?? + cfg.agents?.defaults?.contextTokens ?? lookupContextTokens(configModel) ?? DEFAULT_CONTEXT_TOKENS; diff --git a/src/config/agent-dirs.ts b/src/config/agent-dirs.ts index 871cbc89f..4c0d5b367 100644 --- a/src/config/agent-dirs.ts +++ b/src/config/agent-dirs.ts @@ -31,18 +31,18 @@ function canonicalizeAgentDir(agentDir: string): string { function collectReferencedAgentIds(cfg: ClawdbotConfig): string[] { const ids = new Set(); + const agents = Array.isArray(cfg.agents?.list) ? cfg.agents?.list : []; const defaultAgentId = - cfg.routing?.defaultAgentId?.trim() || DEFAULT_AGENT_ID; + agents.find((agent) => agent?.default)?.id ?? + agents[0]?.id ?? + DEFAULT_AGENT_ID; ids.add(normalizeAgentId(defaultAgentId)); - const agents = cfg.routing?.agents; - if (agents && typeof agents === "object") { - for (const id of Object.keys(agents)) { - ids.add(normalizeAgentId(id)); - } + for (const entry of agents) { + if (entry?.id) ids.add(normalizeAgentId(entry.id)); } - const bindings = cfg.routing?.bindings; + const bindings = cfg.bindings; if (Array.isArray(bindings)) { for (const binding of bindings) { const id = binding?.agentId; @@ -61,8 +61,12 @@ function resolveEffectiveAgentDir( deps?: { env?: NodeJS.ProcessEnv; homedir?: () => string }, ): string { const id = normalizeAgentId(agentId); - const configured = cfg.routing?.agents?.[id]?.agentDir?.trim(); - if (configured) return resolveUserPath(configured); + const configured = Array.isArray(cfg.agents?.list) + ? cfg.agents?.list.find((agent) => normalizeAgentId(agent.id) === id) + ?.agentDir + : undefined; + const trimmed = configured?.trim(); + if (trimmed) return resolveUserPath(trimmed); const root = resolveStateDir( deps?.env ?? process.env, deps?.homedir ?? os.homedir, @@ -102,7 +106,7 @@ export function formatDuplicateAgentDirError( (d) => `- ${d.agentDir}: ${d.agentIds.map((id) => `"${id}"`).join(", ")}`, ), "", - "Fix: remove the shared routing.agents.*.agentDir override (or give each agent its own directory).", + "Fix: remove the shared agents.list[].agentDir override (or give each agent its own directory).", "If you want to share credentials, copy auth-profiles.json instead of sharing the entire agentDir.", ]; return lines.join("\n"); diff --git a/src/config/config.test.ts b/src/config/config.test.ts index 787857a7d..cf0024483 100644 --- a/src/config/config.test.ts +++ b/src/config/config.test.ts @@ -80,7 +80,7 @@ describe("config identity defaults", () => { process.env.HOME = previousHome; }); - it("derives mentionPatterns when identity is set", async () => { + it("does not derive mentionPatterns when identity is set", async () => { await withTempHome(async (home) => { const configDir = path.join(home, ".clawdbot"); await fs.mkdir(configDir, { recursive: true }); @@ -88,9 +88,19 @@ describe("config identity defaults", () => { path.join(configDir, "clawdbot.json"), JSON.stringify( { - identity: { name: "Samantha", theme: "helpful sloth", emoji: "🦥" }, + agents: { + list: [ + { + id: "main", + identity: { + name: "Samantha", + theme: "helpful sloth", + emoji: "🦥", + }, + }, + ], + }, messages: {}, - routing: {}, }, null, 2, @@ -103,13 +113,11 @@ describe("config identity defaults", () => { const cfg = loadConfig(); expect(cfg.messages?.responsePrefix).toBeUndefined(); - expect(cfg.routing?.groupChat?.mentionPatterns).toEqual([ - "\\b@?Samantha\\b", - ]); + expect(cfg.messages?.groupChat?.mentionPatterns).toBeUndefined(); }); }); - it("defaults ackReaction to identity emoji", async () => { + it("defaults ackReactionScope without setting ackReaction", async () => { await withTempHome(async (home) => { const configDir = path.join(home, ".clawdbot"); await fs.mkdir(configDir, { recursive: true }); @@ -117,7 +125,18 @@ describe("config identity defaults", () => { path.join(configDir, "clawdbot.json"), JSON.stringify( { - identity: { name: "Samantha", theme: "helpful sloth", emoji: "🦥" }, + agents: { + list: [ + { + id: "main", + identity: { + name: "Samantha", + theme: "helpful sloth", + emoji: "🦥", + }, + }, + ], + }, messages: {}, }, null, @@ -130,12 +149,12 @@ describe("config identity defaults", () => { const { loadConfig } = await import("./config.js"); const cfg = loadConfig(); - expect(cfg.messages?.ackReaction).toBe("🦥"); + expect(cfg.messages?.ackReaction).toBeUndefined(); expect(cfg.messages?.ackReactionScope).toBe("group-mentions"); }); }); - it("defaults ackReaction to 👀 when identity is missing", async () => { + it("keeps ackReaction unset when identity is missing", async () => { await withTempHome(async (home) => { const configDir = path.join(home, ".clawdbot"); await fs.mkdir(configDir, { recursive: true }); @@ -155,7 +174,7 @@ describe("config identity defaults", () => { const { loadConfig } = await import("./config.js"); const cfg = loadConfig(); - expect(cfg.messages?.ackReaction).toBe("👀"); + expect(cfg.messages?.ackReaction).toBeUndefined(); expect(cfg.messages?.ackReactionScope).toBe("group-mentions"); }); }); @@ -168,17 +187,22 @@ describe("config identity defaults", () => { path.join(configDir, "clawdbot.json"), JSON.stringify( { - identity: { - name: "Samantha Sloth", - theme: "space lobster", - emoji: "🦞", + agents: { + list: [ + { + id: "main", + identity: { + name: "Samantha Sloth", + theme: "space lobster", + emoji: "🦞", + }, + groupChat: { mentionPatterns: ["@clawd"] }, + }, + ], }, messages: { responsePrefix: "✅", }, - routing: { - groupChat: { mentionPatterns: ["@clawd"] }, - }, }, null, 2, @@ -191,7 +215,9 @@ describe("config identity defaults", () => { const cfg = loadConfig(); expect(cfg.messages?.responsePrefix).toBe("✅"); - expect(cfg.routing?.groupChat?.mentionPatterns).toEqual(["@clawd"]); + expect(cfg.agents?.list?.[0]?.groupChat?.mentionPatterns).toEqual([ + "@clawd", + ]); }); }); @@ -209,7 +235,6 @@ describe("config identity defaults", () => { // legacy field should be ignored (moved to providers) textChunkLimit: 9999, }, - routing: {}, whatsapp: { allowFrom: ["+15555550123"], textChunkLimit: 4444 }, telegram: { enabled: true, textChunkLimit: 3333 }, discord: { @@ -251,9 +276,19 @@ describe("config identity defaults", () => { path.join(configDir, "clawdbot.json"), JSON.stringify( { - identity: { name: "Samantha", theme: "helpful sloth", emoji: "🦥" }, + agents: { + list: [ + { + id: "main", + identity: { + name: "Samantha", + theme: "helpful sloth", + emoji: "🦥", + }, + }, + ], + }, messages: { responsePrefix: "" }, - routing: {}, }, null, 2, @@ -277,9 +312,7 @@ describe("config identity defaults", () => { path.join(configDir, "clawdbot.json"), JSON.stringify( { - identity: { name: "Samantha", theme: "helpful sloth", emoji: "🦥" }, messages: {}, - routing: {}, }, null, 2, @@ -292,10 +325,8 @@ describe("config identity defaults", () => { const cfg = loadConfig(); expect(cfg.messages?.responsePrefix).toBeUndefined(); - expect(cfg.routing?.groupChat?.mentionPatterns).toEqual([ - "\\b@?Samantha\\b", - ]); - expect(cfg.agent).toBeUndefined(); + expect(cfg.messages?.groupChat?.mentionPatterns).toBeUndefined(); + expect(cfg.agents).toBeUndefined(); expect(cfg.session).toBeUndefined(); }); }); @@ -308,9 +339,19 @@ describe("config identity defaults", () => { path.join(configDir, "clawdbot.json"), JSON.stringify( { - identity: { name: "Clawd", theme: "space lobster", emoji: "🦞" }, + agents: { + list: [ + { + id: "main", + identity: { + name: "Clawd", + theme: "space lobster", + emoji: "🦞", + }, + }, + ], + }, messages: {}, - routing: {}, }, null, 2, @@ -411,7 +452,7 @@ describe("config pruning defaults", () => { await fs.mkdir(configDir, { recursive: true }); await fs.writeFile( path.join(configDir, "clawdbot.json"), - JSON.stringify({ agent: {} }, null, 2), + JSON.stringify({ agents: { defaults: {} } }, null, 2), "utf-8", ); @@ -419,7 +460,7 @@ describe("config pruning defaults", () => { const { loadConfig } = await import("./config.js"); const cfg = loadConfig(); - expect(cfg.agent?.contextPruning?.mode).toBe("adaptive"); + expect(cfg.agents?.defaults?.contextPruning?.mode).toBe("adaptive"); }); }); @@ -429,7 +470,11 @@ describe("config pruning defaults", () => { await fs.mkdir(configDir, { recursive: true }); await fs.writeFile( path.join(configDir, "clawdbot.json"), - JSON.stringify({ agent: { contextPruning: { mode: "off" } } }, null, 2), + JSON.stringify( + { agents: { defaults: { contextPruning: { mode: "off" } } } }, + null, + 2, + ), "utf-8", ); @@ -437,7 +482,7 @@ describe("config pruning defaults", () => { const { loadConfig } = await import("./config.js"); const cfg = loadConfig(); - expect(cfg.agent?.contextPruning?.mode).toBe("off"); + expect(cfg.agents?.defaults?.contextPruning?.mode).toBe("off"); }); }); }); @@ -850,6 +895,97 @@ describe("legacy config detection", () => { expect(res.config?.routing?.groupChat?.requireMention).toBeUndefined(); }); + it("migrates routing.groupChat.mentionPatterns to messages.groupChat.mentionPatterns", async () => { + vi.resetModules(); + const { migrateLegacyConfig } = await import("./config.js"); + const res = migrateLegacyConfig({ + routing: { groupChat: { mentionPatterns: ["@clawd"] } }, + }); + expect(res.changes).toContain( + "Moved routing.groupChat.mentionPatterns → messages.groupChat.mentionPatterns.", + ); + expect(res.config?.messages?.groupChat?.mentionPatterns).toEqual([ + "@clawd", + ]); + expect(res.config?.routing?.groupChat?.mentionPatterns).toBeUndefined(); + }); + + it("migrates routing agentToAgent/queue/transcribeAudio to tools/messages/audio", async () => { + vi.resetModules(); + const { migrateLegacyConfig } = await import("./config.js"); + const res = migrateLegacyConfig({ + routing: { + agentToAgent: { enabled: true, allow: ["main"] }, + queue: { mode: "queue", cap: 3 }, + transcribeAudio: { command: ["echo", "hi"], timeoutSeconds: 2 }, + }, + }); + expect(res.changes).toContain( + "Moved routing.agentToAgent → tools.agentToAgent.", + ); + expect(res.changes).toContain("Moved routing.queue → messages.queue."); + expect(res.changes).toContain( + "Moved routing.transcribeAudio → audio.transcription.", + ); + expect(res.config?.tools?.agentToAgent).toEqual({ + enabled: true, + allow: ["main"], + }); + expect(res.config?.messages?.queue).toEqual({ + mode: "queue", + cap: 3, + }); + expect(res.config?.audio?.transcription).toEqual({ + command: ["echo", "hi"], + timeoutSeconds: 2, + }); + expect(res.config?.routing).toBeUndefined(); + }); + + it("migrates agent config into agents.defaults and tools", async () => { + vi.resetModules(); + const { migrateLegacyConfig } = await import("./config.js"); + const res = migrateLegacyConfig({ + agent: { + model: "openai/gpt-5.2", + tools: { allow: ["sessions.list"], deny: ["danger"] }, + elevated: { enabled: true, allowFrom: { discord: ["user:1"] } }, + bash: { timeoutSec: 12 }, + sandbox: { tools: { allow: ["browser.open"] } }, + subagents: { tools: { deny: ["sandbox"] } }, + }, + }); + expect(res.changes).toContain("Moved agent.tools.allow → tools.allow."); + expect(res.changes).toContain("Moved agent.tools.deny → tools.deny."); + expect(res.changes).toContain("Moved agent.elevated → tools.elevated."); + expect(res.changes).toContain("Moved agent.bash → tools.bash."); + expect(res.changes).toContain( + "Moved agent.sandbox.tools → tools.sandbox.tools.", + ); + expect(res.changes).toContain( + "Moved agent.subagents.tools → tools.subagents.tools.", + ); + expect(res.changes).toContain("Moved agent → agents.defaults."); + expect(res.config?.agents?.defaults?.model).toEqual({ + primary: "openai/gpt-5.2", + fallbacks: [], + }); + expect(res.config?.tools?.allow).toEqual(["sessions.list"]); + expect(res.config?.tools?.deny).toEqual(["danger"]); + expect(res.config?.tools?.elevated).toEqual({ + enabled: true, + allowFrom: { discord: ["user:1"] }, + }); + expect(res.config?.tools?.bash).toEqual({ timeoutSec: 12 }); + expect(res.config?.tools?.sandbox?.tools).toEqual({ + allow: ["browser.open"], + }); + expect(res.config?.tools?.subagents?.tools).toEqual({ + deny: ["sandbox"], + }); + expect((res.config as { agent?: unknown }).agent).toBeUndefined(); + }); + it("rejects telegram.requireMention", async () => { vi.resetModules(); const { validateConfigObject } = await import("./config.js"); @@ -1064,7 +1200,7 @@ describe("legacy config detection", () => { }); expect(res.ok).toBe(false); if (!res.ok) { - expect(res.issues[0]?.path).toBe("agent.model"); + expect(res.issues.some((i) => i.path === "agent.model")).toBe(true); } }); @@ -1095,22 +1231,25 @@ describe("legacy config detection", () => { }, }); - expect(res.config?.agent?.model?.primary).toBe("anthropic/claude-opus-4-5"); - expect(res.config?.agent?.model?.fallbacks).toEqual([ + expect(res.config?.agents?.defaults?.model?.primary).toBe( + "anthropic/claude-opus-4-5", + ); + expect(res.config?.agents?.defaults?.model?.fallbacks).toEqual([ "openai/gpt-4.1-mini", ]); - expect(res.config?.agent?.imageModel?.primary).toBe("openai/gpt-4.1-mini"); - expect(res.config?.agent?.imageModel?.fallbacks).toEqual([ + expect(res.config?.agents?.defaults?.imageModel?.primary).toBe( + "openai/gpt-4.1-mini", + ); + expect(res.config?.agents?.defaults?.imageModel?.fallbacks).toEqual([ "anthropic/claude-opus-4-5", ]); expect( - res.config?.agent?.models?.["anthropic/claude-opus-4-5"], + res.config?.agents?.defaults?.models?.["anthropic/claude-opus-4-5"], ).toMatchObject({ alias: "Opus" }); - expect(res.config?.agent?.models?.["openai/gpt-4.1-mini"]).toBeTruthy(); - expect(res.config?.agent?.allowedModels).toBeUndefined(); - expect(res.config?.agent?.modelAliases).toBeUndefined(); - expect(res.config?.agent?.modelFallbacks).toBeUndefined(); - expect(res.config?.agent?.imageModelFallbacks).toBeUndefined(); + expect( + res.config?.agents?.defaults?.models?.["openai/gpt-4.1-mini"], + ).toBeTruthy(); + expect(res.config?.agent).toBeUndefined(); }); it("surfaces legacy issues in snapshot", async () => { @@ -1135,21 +1274,21 @@ describe("legacy config detection", () => { }); describe("multi-agent agentDir validation", () => { - it("rejects shared routing.agents.*.agentDir", async () => { + it("rejects shared agents.list agentDir", async () => { vi.resetModules(); const { validateConfigObject } = await import("./config.js"); const shared = path.join(os.tmpdir(), "clawdbot-shared-agentdir"); const res = validateConfigObject({ - routing: { - agents: { - a: { agentDir: shared }, - b: { agentDir: shared }, - }, + agents: { + list: [ + { id: "a", agentDir: shared }, + { id: "b", agentDir: shared }, + ], }, }); expect(res.ok).toBe(false); if (!res.ok) { - expect(res.issues.some((i) => i.path === "routing.agents")).toBe(true); + expect(res.issues.some((i) => i.path === "agents.list")).toBe(true); expect(res.issues[0]?.message).toContain("Duplicate agentDir"); } }); @@ -1162,13 +1301,13 @@ describe("multi-agent agentDir validation", () => { path.join(configDir, "clawdbot.json"), JSON.stringify( { - routing: { - agents: { - a: { agentDir: "~/.clawdbot/agents/shared/agent" }, - b: { agentDir: "~/.clawdbot/agents/shared/agent" }, - }, - bindings: [{ agentId: "a", match: { provider: "telegram" } }], + agents: { + list: [ + { id: "a", agentDir: "~/.clawdbot/agents/shared/agent" }, + { id: "b", agentDir: "~/.clawdbot/agents/shared/agent" }, + ], }, + bindings: [{ agentId: "a", match: { provider: "telegram" } }], }, null, 2, diff --git a/src/config/defaults.ts b/src/config/defaults.ts index b88c7751a..620c17e8f 100644 --- a/src/config/defaults.ts +++ b/src/config/defaults.ts @@ -24,56 +24,13 @@ export type SessionDefaultsOptions = { warnState?: WarnState; }; -function escapeRegExp(text: string): string { - return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -} - -export function applyIdentityDefaults(cfg: ClawdbotConfig): ClawdbotConfig { - const identity = cfg.identity; - if (!identity) return cfg; - - const name = identity.name?.trim(); - - const routing = cfg.routing ?? {}; - const groupChat = routing.groupChat ?? {}; - - let mutated = false; - const next: ClawdbotConfig = { ...cfg }; - - if (name && !groupChat.mentionPatterns) { - const parts = name.split(/\s+/).filter(Boolean).map(escapeRegExp); - const re = parts.length ? parts.join("\\s+") : escapeRegExp(name); - const pattern = `\\b@?${re}\\b`; - next.routing = { - ...(next.routing ?? routing), - groupChat: { ...groupChat, mentionPatterns: [pattern] }, - }; - mutated = true; - } - - return mutated ? next : cfg; -} - export function applyMessageDefaults(cfg: ClawdbotConfig): ClawdbotConfig { const messages = cfg.messages; - const hasAckReaction = messages?.ackReaction !== undefined; const hasAckScope = messages?.ackReactionScope !== undefined; - if (hasAckReaction && hasAckScope) return cfg; + if (hasAckScope) return cfg; - const fallbackEmoji = cfg.identity?.emoji?.trim() || "👀"; const nextMessages = messages ? { ...messages } : {}; - let mutated = false; - - if (!hasAckReaction) { - nextMessages.ackReaction = fallbackEmoji; - mutated = true; - } - if (!hasAckScope) { - nextMessages.ackReactionScope = "group-mentions"; - mutated = true; - } - - if (!mutated) return cfg; + nextMessages.ackReactionScope = "group-mentions"; return { ...cfg, messages: nextMessages, @@ -119,7 +76,7 @@ export function applyTalkApiKey(config: ClawdbotConfig): ClawdbotConfig { } export function applyModelDefaults(cfg: ClawdbotConfig): ClawdbotConfig { - const existingAgent = cfg.agent; + const existingAgent = cfg.agents?.defaults; if (!existingAgent) return cfg; const existingModels = existingAgent.models ?? {}; if (Object.keys(existingModels).length === 0) return cfg; @@ -141,9 +98,9 @@ export function applyModelDefaults(cfg: ClawdbotConfig): ClawdbotConfig { return { ...cfg, - agent: { - ...existingAgent, - models: nextModels, + agents: { + ...cfg.agents, + defaults: { ...existingAgent, models: nextModels }, }, }; } @@ -164,18 +121,21 @@ export function applyLoggingDefaults(cfg: ClawdbotConfig): ClawdbotConfig { export function applyContextPruningDefaults( cfg: ClawdbotConfig, ): ClawdbotConfig { - const agent = cfg.agent; - if (!agent) return cfg; - const contextPruning = agent?.contextPruning; + const defaults = cfg.agents?.defaults; + if (!defaults) return cfg; + const contextPruning = defaults?.contextPruning; if (contextPruning?.mode) return cfg; return { ...cfg, - agent: { - ...agent, - contextPruning: { - ...contextPruning, - mode: "adaptive", + agents: { + ...cfg.agents, + defaults: { + ...defaults, + contextPruning: { + ...contextPruning, + mode: "adaptive", + }, }, }, }; diff --git a/src/config/io.ts b/src/config/io.ts index c2de03d66..fd9a920db 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -14,7 +14,6 @@ import { } from "./agent-dirs.js"; import { applyContextPruningDefaults, - applyIdentityDefaults, applyLoggingDefaults, applyMessageDefaults, applyModelDefaults, @@ -165,9 +164,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { applyContextPruningDefaults( applySessionDefaults( applyLoggingDefaults( - applyMessageDefaults( - applyIdentityDefaults(validated.data as ClawdbotConfig), - ), + applyMessageDefaults(validated.data as ClawdbotConfig), ), ), ), diff --git a/src/config/legacy.ts b/src/config/legacy.ts index 873134c92..d90de4a09 100644 --- a/src/config/legacy.ts +++ b/src/config/legacy.ts @@ -12,53 +12,179 @@ type LegacyConfigMigration = { apply: (raw: Record, changes: string[]) => void; }; +const isRecord = (value: unknown): value is Record => + Boolean(value && typeof value === "object" && !Array.isArray(value)); + +const getRecord = (value: unknown): Record | null => + isRecord(value) ? value : null; + +const ensureRecord = ( + root: Record, + key: string, +): Record => { + const existing = root[key]; + if (isRecord(existing)) return existing; + const next: Record = {}; + root[key] = next; + return next; +}; + +const mergeMissing = ( + target: Record, + source: Record, +) => { + for (const [key, value] of Object.entries(source)) { + if (value === undefined) continue; + const existing = target[key]; + if (existing === undefined) { + target[key] = value; + continue; + } + if (isRecord(existing) && isRecord(value)) { + mergeMissing(existing, value); + } + } +}; + +const getAgentsList = (agents: Record | null) => { + const list = agents?.list; + return Array.isArray(list) ? list : []; +}; + +const resolveDefaultAgentIdFromRaw = (raw: Record) => { + const agents = getRecord(raw.agents); + const list = getAgentsList(agents); + const defaultEntry = list.find( + (entry): entry is { id: string } => + isRecord(entry) && + entry.default === true && + typeof entry.id === "string" && + entry.id.trim() !== "", + ); + if (defaultEntry) return defaultEntry.id.trim(); + const routing = getRecord(raw.routing); + const routingDefault = + typeof routing?.defaultAgentId === "string" + ? routing.defaultAgentId.trim() + : ""; + if (routingDefault) return routingDefault; + const firstEntry = list.find( + (entry): entry is { id: string } => + isRecord(entry) && typeof entry.id === "string" && entry.id.trim() !== "", + ); + if (firstEntry) return firstEntry.id.trim(); + return "main"; +}; + +const ensureAgentEntry = ( + list: unknown[], + id: string, +): Record => { + const normalized = id.trim(); + const existing = list.find( + (entry): entry is Record => + isRecord(entry) && + typeof entry.id === "string" && + entry.id.trim() === normalized, + ); + if (existing) return existing; + const created: Record = { id: normalized }; + list.push(created); + return created; +}; + const LEGACY_CONFIG_RULES: LegacyConfigRule[] = [ { path: ["routing", "allowFrom"], message: "routing.allowFrom was removed; use whatsapp.allowFrom instead (run `clawdbot doctor` to migrate).", }, + { + path: ["routing", "bindings"], + message: + "routing.bindings was moved; use top-level bindings instead (run `clawdbot doctor` to migrate).", + }, + { + path: ["routing", "agents"], + message: + "routing.agents was moved; use agents.list instead (run `clawdbot doctor` to migrate).", + }, + { + path: ["routing", "defaultAgentId"], + message: + "routing.defaultAgentId was moved; use agents.list[].default instead (run `clawdbot doctor` to migrate).", + }, + { + path: ["routing", "agentToAgent"], + message: + "routing.agentToAgent was moved; use tools.agentToAgent instead (run `clawdbot doctor` to migrate).", + }, { path: ["routing", "groupChat", "requireMention"], message: 'routing.groupChat.requireMention was removed; use whatsapp/telegram/imessage groups defaults (e.g. whatsapp.groups."*".requireMention) instead (run `clawdbot doctor` to migrate).', }, + { + path: ["routing", "groupChat", "mentionPatterns"], + message: + "routing.groupChat.mentionPatterns was moved; use agents.list[].groupChat.mentionPatterns or messages.groupChat.mentionPatterns instead (run `clawdbot doctor` to migrate).", + }, + { + path: ["routing", "queue"], + message: + "routing.queue was moved; use messages.queue instead (run `clawdbot doctor` to migrate).", + }, + { + path: ["routing", "transcribeAudio"], + message: + "routing.transcribeAudio was moved; use audio.transcription instead (run `clawdbot doctor` to migrate).", + }, { path: ["telegram", "requireMention"], message: 'telegram.requireMention was removed; use telegram.groups."*".requireMention instead (run `clawdbot doctor` to migrate).', }, + { + path: ["identity"], + message: + "identity was moved; use agents.list[].identity instead (run `clawdbot doctor` to migrate).", + }, + { + path: ["agent"], + message: + "agent.* was moved; use agents.defaults (and tools.* for tool/elevated/bash settings) instead (run `clawdbot doctor` to migrate).", + }, { path: ["agent", "model"], message: - "agent.model string was replaced by agent.model.primary/fallbacks and agent.models (run `clawdbot doctor` to migrate).", + "agent.model string was replaced by agents.defaults.model.primary/fallbacks and agents.defaults.models (run `clawdbot doctor` to migrate).", match: (value) => typeof value === "string", }, { path: ["agent", "imageModel"], message: - "agent.imageModel string was replaced by agent.imageModel.primary/fallbacks (run `clawdbot doctor` to migrate).", + "agent.imageModel string was replaced by agents.defaults.imageModel.primary/fallbacks (run `clawdbot doctor` to migrate).", match: (value) => typeof value === "string", }, { path: ["agent", "allowedModels"], message: - "agent.allowedModels was replaced by agent.models (run `clawdbot doctor` to migrate).", + "agent.allowedModels was replaced by agents.defaults.models (run `clawdbot doctor` to migrate).", }, { path: ["agent", "modelAliases"], message: - "agent.modelAliases was replaced by agent.models.*.alias (run `clawdbot doctor` to migrate).", + "agent.modelAliases was replaced by agents.defaults.models.*.alias (run `clawdbot doctor` to migrate).", }, { path: ["agent", "modelFallbacks"], message: - "agent.modelFallbacks was replaced by agent.model.fallbacks (run `clawdbot doctor` to migrate).", + "agent.modelFallbacks was replaced by agents.defaults.model.fallbacks (run `clawdbot doctor` to migrate).", }, { path: ["agent", "imageModelFallbacks"], message: - "agent.imageModelFallbacks was replaced by agent.imageModel.fallbacks (run `clawdbot doctor` to migrate).", + "agent.imageModelFallbacks was replaced by agents.defaults.imageModel.fallbacks (run `clawdbot doctor` to migrate).", }, { path: ["gateway", "token"], @@ -236,11 +362,11 @@ const LEGACY_CONFIG_MIGRATIONS: LegacyConfigMigration[] = [ describe: "Migrate legacy agent.model/allowedModels/modelAliases/modelFallbacks/imageModelFallbacks to agent.models + model lists", apply: (raw, changes) => { - const agent = - raw.agent && typeof raw.agent === "object" - ? (raw.agent as Record) - : null; + const agentRoot = getRecord(raw.agent); + const defaults = getRecord(getRecord(raw.agents)?.defaults); + const agent = agentRoot ?? defaults; if (!agent) return; + const label = agentRoot ? "agent" : "agents.defaults"; const legacyModel = typeof agent.model === "string" ? String(agent.model) : undefined; @@ -358,26 +484,32 @@ const LEGACY_CONFIG_MIGRATIONS: LegacyConfigMigration[] = [ agent.models = models; if (legacyModel !== undefined) { - changes.push("Migrated agent.model string → agent.model.primary."); + changes.push( + `Migrated ${label}.model string → ${label}.model.primary.`, + ); } if (legacyModelFallbacks.length > 0) { - changes.push("Migrated agent.modelFallbacks → agent.model.fallbacks."); + changes.push( + `Migrated ${label}.modelFallbacks → ${label}.model.fallbacks.`, + ); } if (legacyImageModel !== undefined) { changes.push( - "Migrated agent.imageModel string → agent.imageModel.primary.", + `Migrated ${label}.imageModel string → ${label}.imageModel.primary.`, ); } if (legacyImageModelFallbacks.length > 0) { changes.push( - "Migrated agent.imageModelFallbacks → agent.imageModel.fallbacks.", + `Migrated ${label}.imageModelFallbacks → ${label}.imageModel.fallbacks.`, ); } if (legacyAllowed.length > 0) { - changes.push("Migrated agent.allowedModels → agent.models."); + changes.push(`Migrated ${label}.allowedModels → ${label}.models.`); } if (Object.keys(legacyAliases).length > 0) { - changes.push("Migrated agent.modelAliases → agent.models.*.alias."); + changes.push( + `Migrated ${label}.modelAliases → ${label}.models.*.alias.`, + ); } delete agent.allowedModels; @@ -386,6 +518,311 @@ const LEGACY_CONFIG_MIGRATIONS: LegacyConfigMigration[] = [ delete agent.imageModelFallbacks; }, }, + { + id: "routing.agents-v2", + describe: "Move routing.agents/defaultAgentId to agents.list", + apply: (raw, changes) => { + const routing = getRecord(raw.routing); + if (!routing) return; + + const routingAgents = getRecord(routing.agents); + const agents = ensureRecord(raw, "agents"); + const list = getAgentsList(agents); + + if (routingAgents) { + for (const [rawId, entryRaw] of Object.entries(routingAgents)) { + const agentId = String(rawId ?? "").trim(); + const entry = getRecord(entryRaw); + if (!agentId || !entry) continue; + + const target = ensureAgentEntry(list, agentId); + const entryCopy: Record = { ...entry }; + + if ("mentionPatterns" in entryCopy) { + const mentionPatterns = entryCopy.mentionPatterns; + const groupChat = ensureRecord(target, "groupChat"); + if (groupChat.mentionPatterns === undefined) { + groupChat.mentionPatterns = mentionPatterns; + changes.push( + `Moved routing.agents.${agentId}.mentionPatterns → agents.list (id "${agentId}").groupChat.mentionPatterns.`, + ); + } else { + changes.push( + `Removed routing.agents.${agentId}.mentionPatterns (agents.list groupChat mentionPatterns already set).`, + ); + } + delete entryCopy.mentionPatterns; + } + + const legacyGroupChat = getRecord(entryCopy.groupChat); + if (legacyGroupChat) { + const groupChat = ensureRecord(target, "groupChat"); + mergeMissing(groupChat, legacyGroupChat); + delete entryCopy.groupChat; + } + + const legacySandbox = getRecord(entryCopy.sandbox); + if (legacySandbox) { + const sandboxTools = getRecord(legacySandbox.tools); + if (sandboxTools) { + const tools = ensureRecord(target, "tools"); + const sandbox = ensureRecord(tools, "sandbox"); + const toolPolicy = ensureRecord(sandbox, "tools"); + mergeMissing(toolPolicy, sandboxTools); + delete legacySandbox.tools; + changes.push( + `Moved routing.agents.${agentId}.sandbox.tools → agents.list (id "${agentId}").tools.sandbox.tools.`, + ); + } + entryCopy.sandbox = legacySandbox; + } + + mergeMissing(target, entryCopy); + } + delete routing.agents; + changes.push("Moved routing.agents → agents.list."); + } + + const defaultAgentId = + typeof routing.defaultAgentId === "string" + ? routing.defaultAgentId.trim() + : ""; + if (defaultAgentId) { + const hasDefault = list.some( + (entry): entry is Record => + isRecord(entry) && entry.default === true, + ); + if (!hasDefault) { + const entry = ensureAgentEntry(list, defaultAgentId); + entry.default = true; + changes.push( + `Moved routing.defaultAgentId → agents.list (id "${defaultAgentId}").default.`, + ); + } else { + changes.push( + "Removed routing.defaultAgentId (agents.list default already set).", + ); + } + delete routing.defaultAgentId; + } + + if (list.length > 0) { + agents.list = list; + } + + if (Object.keys(routing).length === 0) { + delete raw.routing; + } + }, + }, + { + id: "routing.config-v2", + describe: + "Move routing bindings/groupChat/queue/agentToAgent/transcribeAudio", + apply: (raw, changes) => { + const routing = getRecord(raw.routing); + if (!routing) return; + + if (routing.bindings !== undefined) { + if (raw.bindings === undefined) { + raw.bindings = routing.bindings; + changes.push("Moved routing.bindings → bindings."); + } else { + changes.push("Removed routing.bindings (bindings already set)."); + } + delete routing.bindings; + } + + if (routing.agentToAgent !== undefined) { + const tools = ensureRecord(raw, "tools"); + if (tools.agentToAgent === undefined) { + tools.agentToAgent = routing.agentToAgent; + changes.push("Moved routing.agentToAgent → tools.agentToAgent."); + } else { + changes.push( + "Removed routing.agentToAgent (tools.agentToAgent already set).", + ); + } + delete routing.agentToAgent; + } + + if (routing.queue !== undefined) { + const messages = ensureRecord(raw, "messages"); + if (messages.queue === undefined) { + messages.queue = routing.queue; + changes.push("Moved routing.queue → messages.queue."); + } else { + changes.push("Removed routing.queue (messages.queue already set)."); + } + delete routing.queue; + } + + const groupChat = getRecord(routing.groupChat); + if (groupChat) { + const historyLimit = groupChat.historyLimit; + if (historyLimit !== undefined) { + const messages = ensureRecord(raw, "messages"); + const messagesGroup = ensureRecord(messages, "groupChat"); + if (messagesGroup.historyLimit === undefined) { + messagesGroup.historyLimit = historyLimit; + changes.push( + "Moved routing.groupChat.historyLimit → messages.groupChat.historyLimit.", + ); + } else { + changes.push( + "Removed routing.groupChat.historyLimit (messages.groupChat.historyLimit already set).", + ); + } + delete groupChat.historyLimit; + } + + const mentionPatterns = groupChat.mentionPatterns; + if (mentionPatterns !== undefined) { + const messages = ensureRecord(raw, "messages"); + const messagesGroup = ensureRecord(messages, "groupChat"); + if (messagesGroup.mentionPatterns === undefined) { + messagesGroup.mentionPatterns = mentionPatterns; + changes.push( + "Moved routing.groupChat.mentionPatterns → messages.groupChat.mentionPatterns.", + ); + } else { + changes.push( + "Removed routing.groupChat.mentionPatterns (messages.groupChat.mentionPatterns already set).", + ); + } + delete groupChat.mentionPatterns; + } + + if (Object.keys(groupChat).length === 0) { + delete routing.groupChat; + } else { + routing.groupChat = groupChat; + } + } + + if (routing.transcribeAudio !== undefined) { + const audio = ensureRecord(raw, "audio"); + if (audio.transcription === undefined) { + audio.transcription = routing.transcribeAudio; + changes.push("Moved routing.transcribeAudio → audio.transcription."); + } else { + changes.push( + "Removed routing.transcribeAudio (audio.transcription already set).", + ); + } + delete routing.transcribeAudio; + } + + if (Object.keys(routing).length === 0) { + delete raw.routing; + } + }, + }, + { + id: "agent.defaults-v2", + describe: "Move agent config to agents.defaults and tools", + apply: (raw, changes) => { + const agent = getRecord(raw.agent); + if (!agent) return; + + const agents = ensureRecord(raw, "agents"); + const defaults = getRecord(agents.defaults) ?? {}; + const tools = ensureRecord(raw, "tools"); + + const agentTools = getRecord(agent.tools); + if (agentTools) { + if (tools.allow === undefined && agentTools.allow !== undefined) { + tools.allow = agentTools.allow; + changes.push("Moved agent.tools.allow → tools.allow."); + } + if (tools.deny === undefined && agentTools.deny !== undefined) { + tools.deny = agentTools.deny; + changes.push("Moved agent.tools.deny → tools.deny."); + } + } + + const elevated = getRecord(agent.elevated); + if (elevated) { + if (tools.elevated === undefined) { + tools.elevated = elevated; + changes.push("Moved agent.elevated → tools.elevated."); + } else { + changes.push("Removed agent.elevated (tools.elevated already set)."); + } + } + + const bash = getRecord(agent.bash); + if (bash) { + if (tools.bash === undefined) { + tools.bash = bash; + changes.push("Moved agent.bash → tools.bash."); + } else { + changes.push("Removed agent.bash (tools.bash already set)."); + } + } + + const sandbox = getRecord(agent.sandbox); + if (sandbox) { + const sandboxTools = getRecord(sandbox.tools); + if (sandboxTools) { + const toolsSandbox = ensureRecord(tools, "sandbox"); + const toolPolicy = ensureRecord(toolsSandbox, "tools"); + mergeMissing(toolPolicy, sandboxTools); + delete sandbox.tools; + changes.push("Moved agent.sandbox.tools → tools.sandbox.tools."); + } + } + + const subagents = getRecord(agent.subagents); + if (subagents) { + const subagentTools = getRecord(subagents.tools); + if (subagentTools) { + const toolsSubagents = ensureRecord(tools, "subagents"); + const toolPolicy = ensureRecord(toolsSubagents, "tools"); + mergeMissing(toolPolicy, subagentTools); + delete subagents.tools; + changes.push("Moved agent.subagents.tools → tools.subagents.tools."); + } + } + + const agentCopy: Record = structuredClone(agent); + delete agentCopy.tools; + delete agentCopy.elevated; + delete agentCopy.bash; + if (isRecord(agentCopy.sandbox)) delete agentCopy.sandbox.tools; + if (isRecord(agentCopy.subagents)) delete agentCopy.subagents.tools; + + mergeMissing(defaults, agentCopy); + agents.defaults = defaults; + raw.agents = agents; + delete raw.agent; + changes.push("Moved agent → agents.defaults."); + }, + }, + { + id: "identity->agents.list", + describe: "Move identity to agents.list[].identity", + apply: (raw, changes) => { + const identity = getRecord(raw.identity); + if (!identity) return; + + const agents = ensureRecord(raw, "agents"); + const list = getAgentsList(agents); + const defaultId = resolveDefaultAgentIdFromRaw(raw); + const entry = ensureAgentEntry(list, defaultId); + if (entry.identity === undefined) { + entry.identity = identity; + changes.push( + `Moved identity → agents.list (id "${defaultId}").identity.`, + ); + } else { + changes.push("Removed identity (agents.list identity already set)."); + } + agents.list = list; + raw.agents = agents; + delete raw.identity; + }, + }, ]; export function findLegacyConfigIssues(raw: unknown): LegacyConfigIssue[] { diff --git a/src/config/model-alias-defaults.test.ts b/src/config/model-alias-defaults.test.ts index cf11f6c0e..f3843dbed 100644 --- a/src/config/model-alias-defaults.test.ts +++ b/src/config/model-alias-defaults.test.ts @@ -5,52 +5,62 @@ import type { ClawdbotConfig } from "./types.js"; describe("applyModelDefaults", () => { it("adds default aliases when models are present", () => { const cfg = { - agent: { - models: { - "anthropic/claude-opus-4-5": {}, - "openai/gpt-5.2": {}, + agents: { + defaults: { + models: { + "anthropic/claude-opus-4-5": {}, + "openai/gpt-5.2": {}, + }, }, }, } satisfies ClawdbotConfig; const next = applyModelDefaults(cfg); - expect(next.agent?.models?.["anthropic/claude-opus-4-5"]?.alias).toBe( - "opus", + expect( + next.agents?.defaults?.models?.["anthropic/claude-opus-4-5"]?.alias, + ).toBe("opus"); + expect(next.agents?.defaults?.models?.["openai/gpt-5.2"]?.alias).toBe( + "gpt", ); - expect(next.agent?.models?.["openai/gpt-5.2"]?.alias).toBe("gpt"); }); it("does not override existing aliases", () => { const cfg = { - agent: { - models: { - "anthropic/claude-opus-4-5": { alias: "Opus" }, + agents: { + defaults: { + models: { + "anthropic/claude-opus-4-5": { alias: "Opus" }, + }, }, }, } satisfies ClawdbotConfig; const next = applyModelDefaults(cfg); - expect(next.agent?.models?.["anthropic/claude-opus-4-5"]?.alias).toBe( - "Opus", - ); + expect( + next.agents?.defaults?.models?.["anthropic/claude-opus-4-5"]?.alias, + ).toBe("Opus"); }); it("respects explicit empty alias disables", () => { const cfg = { - agent: { - models: { - "google/gemini-3-pro-preview": { alias: "" }, - "google/gemini-3-flash-preview": {}, + agents: { + defaults: { + models: { + "google/gemini-3-pro-preview": { alias: "" }, + "google/gemini-3-flash-preview": {}, + }, }, }, } satisfies ClawdbotConfig; const next = applyModelDefaults(cfg); - expect(next.agent?.models?.["google/gemini-3-pro-preview"]?.alias).toBe(""); - expect(next.agent?.models?.["google/gemini-3-flash-preview"]?.alias).toBe( - "gemini-flash", - ); + expect( + next.agents?.defaults?.models?.["google/gemini-3-pro-preview"]?.alias, + ).toBe(""); + expect( + next.agents?.defaults?.models?.["google/gemini-3-flash-preview"]?.alias, + ).toBe("gemini-flash"); }); }); diff --git a/src/config/schema.test.ts b/src/config/schema.test.ts index c5de85aed..782ea2b0e 100644 --- a/src/config/schema.test.ts +++ b/src/config/schema.test.ts @@ -7,7 +7,7 @@ describe("config schema", () => { const res = buildConfigSchema(); const schema = res.schema as { properties?: Record }; expect(schema.properties?.gateway).toBeTruthy(); - expect(schema.properties?.agent).toBeTruthy(); + expect(schema.properties?.agents).toBeTruthy(); expect(res.uiHints.gateway?.label).toBe("Gateway"); expect(res.uiHints["gateway.auth.token"]?.sensitive).toBe(true); expect(res.version).toBeTruthy(); diff --git a/src/config/schema.ts b/src/config/schema.ts index a299c47e3..06df7c2ce 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -24,13 +24,14 @@ export type ConfigSchemaResponse = { }; const GROUP_LABELS: Record = { - identity: "Identity", wizard: "Wizard", logging: "Logging", gateway: "Gateway", - agent: "Agent", + agents: "Agents", + tools: "Tools", + bindings: "Bindings", + audio: "Audio", models: "Models", - routing: "Routing", messages: "Messages", commands: "Commands", session: "Session", @@ -52,30 +53,31 @@ const GROUP_LABELS: Record = { }; const GROUP_ORDER: Record = { - identity: 10, wizard: 20, gateway: 30, - agent: 40, - models: 50, - routing: 60, - messages: 70, - commands: 75, - session: 80, - cron: 90, - hooks: 100, - ui: 110, - browser: 120, - talk: 130, - telegram: 140, - discord: 150, - slack: 155, - signal: 160, - imessage: 170, - whatsapp: 180, - skills: 190, - discovery: 200, - presence: 210, - voicewake: 220, + agents: 40, + tools: 50, + bindings: 55, + audio: 60, + models: 70, + messages: 80, + commands: 85, + session: 90, + cron: 100, + hooks: 110, + ui: 120, + browser: 130, + talk: 140, + telegram: 150, + discord: 160, + slack: 165, + signal: 170, + imessage: 180, + whatsapp: 190, + skills: 200, + discovery: 210, + presence: 220, + voicewake: 230, logging: 900, }; @@ -90,14 +92,14 @@ const FIELD_LABELS: Record = { "gateway.controlUi.basePath": "Control UI Base Path", "gateway.reload.mode": "Config Reload Mode", "gateway.reload.debounceMs": "Config Reload Debounce (ms)", - "agent.workspace": "Workspace", + "agents.defaults.workspace": "Workspace", "auth.profiles": "Auth Profiles", "auth.order": "Auth Profile Order", - "agent.models": "Models", - "agent.model.primary": "Primary Model", - "agent.model.fallbacks": "Model Fallbacks", - "agent.imageModel.primary": "Image Model", - "agent.imageModel.fallbacks": "Image Model Fallbacks", + "agents.defaults.models": "Models", + "agents.defaults.model.primary": "Primary Model", + "agents.defaults.model.fallbacks": "Model Fallbacks", + "agents.defaults.imageModel.primary": "Image Model", + "agents.defaults.imageModel.fallbacks": "Image Model Fallbacks", "commands.native": "Native Commands", "commands.text": "Text Commands", "commands.restart": "Allow Restart", @@ -154,14 +156,14 @@ const FIELD_HELP: Record = { "auth.profiles": "Named auth profiles (provider + mode + optional email).", "auth.order": "Ordered auth profile IDs per provider (used for automatic failover).", - "agent.models": + "agents.defaults.models": "Configured model catalog (keys are full provider/model IDs).", - "agent.model.primary": "Primary model (provider/model).", - "agent.model.fallbacks": + "agents.defaults.model.primary": "Primary model (provider/model).", + "agents.defaults.model.fallbacks": "Ordered fallback models (provider/model). Used when the primary model fails.", - "agent.imageModel.primary": + "agents.defaults.imageModel.primary": "Optional image model (provider/model) used when the primary model lacks image input.", - "agent.imageModel.fallbacks": + "agents.defaults.imageModel.fallbacks": "Ordered fallback image models (provider/model).", "commands.native": "Register native commands with connectors that support it (Discord/Slack/Telegram).", diff --git a/src/config/sessions.ts b/src/config/sessions.ts index dfeda9e98..72b1eae5d 100644 --- a/src/config/sessions.ts +++ b/src/config/sessions.ts @@ -217,12 +217,15 @@ export function resolveStorePath(store?: string, opts?: { agentId?: string }) { export function resolveMainSessionKey(cfg?: { session?: { scope?: SessionScope; mainKey?: string }; - routing?: { defaultAgentId?: string }; + agents?: { list?: Array<{ id?: string; default?: boolean }> }; }): string { if (cfg?.session?.scope === "global") return "global"; - const agentId = normalizeAgentId( - cfg?.routing?.defaultAgentId ?? DEFAULT_AGENT_ID, - ); + const agents = cfg?.agents?.list ?? []; + const defaultAgentId = + agents.find((agent) => agent?.default)?.id ?? + agents[0]?.id ?? + DEFAULT_AGENT_ID; + const agentId = normalizeAgentId(defaultAgentId); const mainKey = (cfg?.session?.mainKey ?? DEFAULT_MAIN_KEY).trim() || DEFAULT_MAIN_KEY; return buildAgentMainSessionKey({ agentId, mainKey }); diff --git a/src/config/types.ts b/src/config/types.ts index 5eadd307b..e09d1af69 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -91,6 +91,12 @@ export type AgentElevatedAllowFromConfig = { webchat?: Array; }; +export type IdentityConfig = { + name?: string; + theme?: string; + emoji?: string; +}; + export type WhatsAppActionConfig = { reactions?: boolean; sendMessage?: boolean; @@ -762,83 +768,133 @@ export type GroupChatConfig = { historyLimit?: number; }; -export type RoutingConfig = { - transcribeAudio?: { - // Optional CLI to turn inbound audio into text; templated args, must output transcript to stdout. - command: string[]; - timeoutSeconds?: number; +export type QueueConfig = { + mode?: QueueMode; + byProvider?: QueueModeByProvider; + debounceMs?: number; + cap?: number; + drop?: QueueDropPolicy; +}; + +export type AgentToolsConfig = { + allow?: string[]; + deny?: string[]; + sandbox?: { + tools?: { + allow?: string[]; + deny?: string[]; + }; }; - groupChat?: GroupChatConfig; - /** Default agent id when no binding matches. Default: "main". */ - defaultAgentId?: string; +}; + +export type ToolsConfig = { + allow?: string[]; + deny?: string[]; agentToAgent?: { /** Enable agent-to-agent messaging tools. Default: false. */ enabled?: boolean; /** Allowlist of agent ids or patterns (implementation-defined). */ allow?: string[]; }; - agents?: Record< - string, - { - name?: string; - workspace?: string; - agentDir?: string; - model?: string; - /** Per-agent override for group mention patterns. */ - mentionPatterns?: string[]; - subagents?: { - /** Allow spawning sub-agents under other agent ids. Use "*" to allow any. */ - allowAgents?: string[]; - }; - sandbox?: { - mode?: "off" | "non-main" | "all"; - /** Agent workspace access inside the sandbox. */ - workspaceAccess?: "none" | "ro" | "rw"; - /** Container/workspace scope for sandbox isolation. */ - scope?: "session" | "agent" | "shared"; - /** Legacy alias for scope ("session" when true, "shared" when false). */ - perSession?: boolean; - workspaceRoot?: string; - /** Docker-specific sandbox overrides for this agent. */ - docker?: SandboxDockerSettings; - /** Optional sandboxed browser overrides for this agent. */ - browser?: SandboxBrowserSettings; - /** Tool allow/deny policy for sandboxed sessions (deny wins). */ - tools?: { - allow?: string[]; - deny?: string[]; - }; - /** Auto-prune overrides for this agent. */ - prune?: SandboxPruneSettings; - }; - tools?: { - allow?: string[]; - deny?: string[]; - }; - } - >; - bindings?: Array<{ - agentId: string; - match: { - provider: string; - accountId?: string; - peer?: { kind: "dm" | "group" | "channel"; id: string }; - guildId?: string; - teamId?: string; + /** Elevated bash permissions for the host machine. */ + elevated?: { + /** Enable or disable elevated mode (default: true). */ + enabled?: boolean; + /** Approved senders for /elevated (per-provider allowlists). */ + allowFrom?: AgentElevatedAllowFromConfig; + }; + /** Bash tool defaults. */ + bash?: { + /** Default time (ms) before a bash command auto-backgrounds. */ + backgroundMs?: number; + /** Default timeout (seconds) before auto-killing bash commands. */ + timeoutSec?: number; + /** How long to keep finished sessions in memory (ms). */ + cleanupMs?: number; + }; + /** Sub-agent tool policy defaults (deny wins). */ + subagents?: { + tools?: { + allow?: string[]; + deny?: string[]; }; - }>; - queue?: { - mode?: QueueMode; - byProvider?: QueueModeByProvider; - debounceMs?: number; - cap?: number; - drop?: QueueDropPolicy; + }; + /** Sandbox tool policy defaults (deny wins). */ + sandbox?: { + tools?: { + allow?: string[]; + deny?: string[]; + }; + }; +}; + +export type AgentConfig = { + id: string; + default?: boolean; + name?: string; + workspace?: string; + agentDir?: string; + model?: string; + identity?: IdentityConfig; + groupChat?: GroupChatConfig; + subagents?: { + /** Allow spawning sub-agents under other agent ids. Use "*" to allow any. */ + allowAgents?: string[]; + }; + sandbox?: { + mode?: "off" | "non-main" | "all"; + /** Agent workspace access inside the sandbox. */ + workspaceAccess?: "none" | "ro" | "rw"; + /** + * Session tools visibility for sandboxed sessions. + * - "spawned": only allow session tools to target sessions spawned from this session (default) + * - "all": allow session tools to target any session + */ + sessionToolsVisibility?: "spawned" | "all"; + /** Container/workspace scope for sandbox isolation. */ + scope?: "session" | "agent" | "shared"; + /** Legacy alias for scope ("session" when true, "shared" when false). */ + perSession?: boolean; + workspaceRoot?: string; + /** Docker-specific sandbox overrides for this agent. */ + docker?: SandboxDockerSettings; + /** Optional sandboxed browser overrides for this agent. */ + browser?: SandboxBrowserSettings; + /** Auto-prune overrides for this agent. */ + prune?: SandboxPruneSettings; + }; + tools?: AgentToolsConfig; +}; + +export type AgentsConfig = { + defaults?: AgentDefaultsConfig; + list?: AgentConfig[]; +}; + +export type AgentBinding = { + agentId: string; + match: { + provider: string; + accountId?: string; + peer?: { kind: "dm" | "group" | "channel"; id: string }; + guildId?: string; + teamId?: string; + }; +}; + +export type AudioConfig = { + transcription?: { + // Optional CLI to turn inbound audio into text; templated args, must output transcript to stdout. + command: string[]; + timeoutSeconds?: number; }; }; export type MessagesConfig = { messagePrefix?: string; // Prefix added to all inbound messages (default: "[clawdbot]" if no allowFrom, else "") responsePrefix?: string; // Prefix auto-added to all outbound replies (e.g., "🦞") + groupChat?: GroupChatConfig; + queue?: QueueConfig; /** Emoji reaction used to acknowledge inbound messages (empty disables). */ ackReaction?: string; /** When to send ack reactions. Default: "group-mentions". */ @@ -1097,6 +1153,113 @@ export type AgentContextPruningConfig = { }; }; +export type AgentDefaultsConfig = { + /** Primary model and fallbacks (provider/model). */ + model?: AgentModelListConfig; + /** Optional image-capable model and fallbacks (provider/model). */ + imageModel?: AgentModelListConfig; + /** Model catalog with optional aliases (full provider/model keys). */ + models?: Record; + /** Agent working directory (preferred). Used as the default cwd for agent runs. */ + workspace?: string; + /** Skip bootstrap (BOOTSTRAP.md creation, etc.) for pre-configured deployments. */ + skipBootstrap?: boolean; + /** Optional IANA timezone for the user (used in system prompt; defaults to host timezone). */ + userTimezone?: string; + /** Optional display-only context window override (used for % in status UIs). */ + contextTokens?: number; + /** Opt-in: prune old tool results from the LLM context to reduce token usage. */ + contextPruning?: AgentContextPruningConfig; + /** Default thinking level when no /think directive is present. */ + thinkingDefault?: "off" | "minimal" | "low" | "medium" | "high"; + /** Default verbose level when no /verbose directive is present. */ + verboseDefault?: "off" | "on"; + /** Default elevated level when no /elevated directive is present. */ + elevatedDefault?: "off" | "on"; + /** Default block streaming level when no override is present. */ + blockStreamingDefault?: "off" | "on"; + /** + * Block streaming boundary: + * - "text_end": end of each assistant text content block (before tool calls) + * - "message_end": end of the whole assistant message (may include tool blocks) + */ + blockStreamingBreak?: "text_end" | "message_end"; + /** Soft block chunking for streamed replies (min/max chars, prefer paragraph/newline). */ + blockStreamingChunk?: { + minChars?: number; + maxChars?: number; + breakPreference?: "paragraph" | "newline" | "sentence"; + }; + timeoutSeconds?: number; + /** Max inbound media size in MB for agent-visible attachments (text note or future image attach). */ + mediaMaxMb?: number; + typingIntervalSeconds?: number; + /** Typing indicator start mode (never|instant|thinking|message). */ + typingMode?: TypingMode; + /** Periodic background heartbeat runs. */ + heartbeat?: { + /** Heartbeat interval (duration string, default unit: minutes; default: 30m). */ + every?: string; + /** Heartbeat model override (provider/model). */ + model?: string; + /** Delivery target (last|whatsapp|telegram|discord|signal|imessage|none). */ + target?: + | "last" + | "whatsapp" + | "telegram" + | "discord" + | "slack" + | "signal" + | "imessage" + | "none"; + /** Optional delivery override (E.164 for WhatsApp, chat id for Telegram). */ + to?: string; + /** Override the heartbeat prompt body (default: "Read HEARTBEAT.md if exists. Consider outstanding tasks. Checkup sometimes on your human during (user local) day time."). */ + prompt?: string; + /** Max chars allowed after HEARTBEAT_OK before delivery (default: 30). */ + ackMaxChars?: number; + }; + /** Max concurrent agent runs across all conversations. Default: 1 (sequential). */ + maxConcurrent?: number; + /** Sub-agent defaults (spawned via sessions_spawn). */ + subagents?: { + /** Max concurrent sub-agent runs (global lane: "subagent"). Default: 1. */ + maxConcurrent?: number; + /** Auto-archive sub-agent sessions after N minutes (default: 60). */ + archiveAfterMinutes?: number; + }; + /** Optional sandbox settings for non-main sessions. */ + sandbox?: { + /** Enable sandboxing for sessions. */ + mode?: "off" | "non-main" | "all"; + /** + * Agent workspace access inside the sandbox. + * - "none": do not mount the agent workspace into the container; use a sandbox workspace under workspaceRoot + * - "ro": mount the agent workspace read-only; disables write/edit tools + * - "rw": mount the agent workspace read/write; enables write/edit tools + */ + workspaceAccess?: "none" | "ro" | "rw"; + /** + * Session tools visibility for sandboxed sessions. + * - "spawned": only allow session tools to target sessions spawned from this session (default) + * - "all": allow session tools to target any session + */ + sessionToolsVisibility?: "spawned" | "all"; + /** Container/workspace scope for sandbox isolation. */ + scope?: "session" | "agent" | "shared"; + /** Legacy alias for scope ("session" when true, "shared" when false). */ + perSession?: boolean; + /** Root directory for sandbox workspaces. */ + workspaceRoot?: string; + /** Docker-specific sandbox settings. */ + docker?: SandboxDockerSettings; + /** Optional sandboxed browser settings. */ + browser?: SandboxBrowserSettings; + /** Auto-prune sandbox containers. */ + prune?: SandboxPruneSettings; + }; +}; + export type ClawdbotConfig = { auth?: AuthConfig; env?: { @@ -1115,11 +1278,6 @@ export type ClawdbotConfig = { | { enabled?: boolean; timeoutMs?: number } | undefined; }; - identity?: { - name?: string; - theme?: string; - emoji?: string; - }; wizard?: { lastRunAt?: string; lastRunVersion?: string; @@ -1135,145 +1293,10 @@ export type ClawdbotConfig = { }; skills?: SkillsConfig; models?: ModelsConfig; - agent?: { - /** Primary model and fallbacks (provider/model). */ - model?: AgentModelListConfig; - /** Optional image-capable model and fallbacks (provider/model). */ - imageModel?: AgentModelListConfig; - /** Model catalog with optional aliases (full provider/model keys). */ - models?: Record; - /** Agent working directory (preferred). Used as the default cwd for agent runs. */ - workspace?: string; - /** Skip bootstrap (BOOTSTRAP.md creation, etc.) for pre-configured deployments. */ - skipBootstrap?: boolean; - /** Optional IANA timezone for the user (used in system prompt; defaults to host timezone). */ - userTimezone?: string; - /** Optional display-only context window override (used for % in status UIs). */ - contextTokens?: number; - /** Opt-in: prune old tool results from the LLM context to reduce token usage. */ - contextPruning?: AgentContextPruningConfig; - /** Default thinking level when no /think directive is present. */ - thinkingDefault?: "off" | "minimal" | "low" | "medium" | "high"; - /** Default verbose level when no /verbose directive is present. */ - verboseDefault?: "off" | "on"; - /** Default elevated level when no /elevated directive is present. */ - elevatedDefault?: "off" | "on"; - /** Default block streaming level when no override is present. */ - blockStreamingDefault?: "off" | "on"; - /** - * Block streaming boundary: - * - "text_end": end of each assistant text content block (before tool calls) - * - "message_end": end of the whole assistant message (may include tool blocks) - */ - blockStreamingBreak?: "text_end" | "message_end"; - /** Soft block chunking for streamed replies (min/max chars, prefer paragraph/newline). */ - blockStreamingChunk?: { - minChars?: number; - maxChars?: number; - breakPreference?: "paragraph" | "newline" | "sentence"; - }; - timeoutSeconds?: number; - /** Max inbound media size in MB for agent-visible attachments (text note or future image attach). */ - mediaMaxMb?: number; - typingIntervalSeconds?: number; - /** Typing indicator start mode (never|instant|thinking|message). */ - typingMode?: TypingMode; - /** Periodic background heartbeat runs. */ - heartbeat?: { - /** Heartbeat interval (duration string, default unit: minutes; default: 30m). */ - every?: string; - /** Heartbeat model override (provider/model). */ - model?: string; - /** Delivery target (last|whatsapp|telegram|discord|signal|imessage|msteams|none). */ - target?: - | "last" - | "whatsapp" - | "telegram" - | "discord" - | "slack" - | "signal" - | "imessage" - | "msteams" - | "none"; - /** Optional delivery override (E.164 for WhatsApp, chat id for Telegram). */ - to?: string; - /** Override the heartbeat prompt body (default: "Read HEARTBEAT.md if exists. Consider outstanding tasks. Checkup sometimes on your human during (user local) day time."). */ - prompt?: string; - /** Max chars allowed after HEARTBEAT_OK before delivery (default: 30). */ - ackMaxChars?: number; - }; - /** Max concurrent agent runs across all conversations. Default: 1 (sequential). */ - maxConcurrent?: number; - /** Sub-agent defaults (spawned via sessions_spawn). */ - subagents?: { - /** Max concurrent sub-agent runs (global lane: "subagent"). Default: 1. */ - maxConcurrent?: number; - /** Auto-archive sub-agent sessions after N minutes (default: 60). */ - archiveAfterMinutes?: number; - /** Tool allow/deny policy for sub-agent sessions (deny wins). */ - tools?: { - allow?: string[]; - deny?: string[]; - }; - }; - /** Bash tool defaults. */ - bash?: { - /** Default time (ms) before a bash command auto-backgrounds. */ - backgroundMs?: number; - /** Default timeout (seconds) before auto-killing bash commands. */ - timeoutSec?: number; - /** How long to keep finished sessions in memory (ms). */ - cleanupMs?: number; - }; - /** Elevated bash permissions for the host machine. */ - elevated?: { - /** Enable or disable elevated mode (default: true). */ - enabled?: boolean; - /** Approved senders for /elevated (per-provider allowlists). */ - allowFrom?: AgentElevatedAllowFromConfig; - }; - /** Optional sandbox settings for non-main sessions. */ - sandbox?: { - /** Enable sandboxing for sessions. */ - mode?: "off" | "non-main" | "all"; - /** - * Agent workspace access inside the sandbox. - * - "none": do not mount the agent workspace into the container; use a sandbox workspace under workspaceRoot - * - "ro": mount the agent workspace read-only; disables write/edit tools - * - "rw": mount the agent workspace read/write; enables write/edit tools - */ - workspaceAccess?: "none" | "ro" | "rw"; - /** - * Session tools visibility for sandboxed sessions. - * - "spawned": only allow session tools to target sessions spawned from this session (default) - * - "all": allow session tools to target any session - */ - sessionToolsVisibility?: "spawned" | "all"; - /** Container/workspace scope for sandbox isolation. */ - scope?: "session" | "agent" | "shared"; - /** Legacy alias for scope ("session" when true, "shared" when false). */ - perSession?: boolean; - /** Root directory for sandbox workspaces. */ - workspaceRoot?: string; - /** Docker-specific sandbox settings. */ - docker?: SandboxDockerSettings; - /** Optional sandboxed browser settings. */ - browser?: SandboxBrowserSettings; - /** Tool allow/deny policy (deny wins). */ - tools?: { - allow?: string[]; - deny?: string[]; - }; - /** Auto-prune sandbox containers. */ - prune?: SandboxPruneSettings; - }; - /** Global tool allow/deny policy for all providers (deny wins). */ - tools?: { - allow?: string[]; - deny?: string[]; - }; - }; - routing?: RoutingConfig; + agents?: AgentsConfig; + tools?: ToolsConfig; + bindings?: AgentBinding[]; + audio?: AudioConfig; messages?: MessagesConfig; commands?: CommandsConfig; session?: SessionConfig; diff --git a/src/config/validation.ts b/src/config/validation.ts index c8b49dca9..509cd8726 100644 --- a/src/config/validation.ts +++ b/src/config/validation.ts @@ -2,11 +2,7 @@ import { findDuplicateAgentDirs, formatDuplicateAgentDirError, } from "./agent-dirs.js"; -import { - applyIdentityDefaults, - applyModelDefaults, - applySessionDefaults, -} from "./defaults.js"; +import { applyModelDefaults, applySessionDefaults } from "./defaults.js"; import { findLegacyConfigIssues } from "./legacy.js"; import type { ClawdbotConfig, ConfigValidationIssue } from "./types.js"; import { ClawdbotSchema } from "./zod-schema.js"; @@ -42,7 +38,7 @@ export function validateConfigObject( ok: false, issues: [ { - path: "routing.agents", + path: "agents.list", message: formatDuplicateAgentDirError(duplicates), }, ], @@ -51,9 +47,7 @@ export function validateConfigObject( return { ok: true, config: applyModelDefaults( - applySessionDefaults( - applyIdentityDefaults(validated.data as ClawdbotConfig), - ), + applySessionDefaults(validated.data as ClawdbotConfig), ), }; } diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 2655bb573..b29c19e3b 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -61,6 +61,14 @@ const GroupChatSchema = z }) .optional(); +const IdentitySchema = z + .object({ + name: z.string().optional(), + theme: z.string().optional(), + emoji: z.string().optional(), + }) + .optional(); + const QueueModeSchema = z.union([ z.literal("steer"), z.literal("followup"), @@ -133,6 +141,16 @@ const QueueModeBySurfaceSchema = z }) .optional(); +const QueueSchema = z + .object({ + mode: QueueModeSchema.optional(), + byProvider: QueueModeBySurfaceSchema, + debounceMs: z.number().int().nonnegative().optional(), + cap: z.number().int().positive().optional(), + drop: QueueDropSchema.optional(), + }) + .optional(); + const TranscribeAudioSchema = z .object({ command: z.array(z.string()), @@ -554,6 +572,8 @@ const MessagesSchema = z .object({ messagePrefix: z.string().optional(), responsePrefix: z.string().optional(), + groupChat: GroupChatSchema, + queue: QueueSchema, ackReaction: z.string().optional(), ackReactionScope: z .enum(["group-mentions", "group-all", "direct", "all"]) @@ -667,96 +687,140 @@ const ToolPolicySchema = z }) .optional(); -const RoutingSchema = z +const ElevatedAllowFromSchema = z .object({ - groupChat: GroupChatSchema, - transcribeAudio: TranscribeAudioSchema, - defaultAgentId: z.string().optional(), + whatsapp: z.array(z.string()).optional(), + telegram: z.array(z.union([z.string(), z.number()])).optional(), + discord: z.array(z.union([z.string(), z.number()])).optional(), + slack: z.array(z.union([z.string(), z.number()])).optional(), + signal: z.array(z.union([z.string(), z.number()])).optional(), + imessage: z.array(z.union([z.string(), z.number()])).optional(), + webchat: z.array(z.union([z.string(), z.number()])).optional(), + }) + .optional(); + +const AgentSandboxSchema = z + .object({ + mode: z + .union([z.literal("off"), z.literal("non-main"), z.literal("all")]) + .optional(), + workspaceAccess: z + .union([z.literal("none"), z.literal("ro"), z.literal("rw")]) + .optional(), + sessionToolsVisibility: z + .union([z.literal("spawned"), z.literal("all")]) + .optional(), + scope: z + .union([z.literal("session"), z.literal("agent"), z.literal("shared")]) + .optional(), + perSession: z.boolean().optional(), + workspaceRoot: z.string().optional(), + docker: SandboxDockerSchema, + browser: SandboxBrowserSchema, + prune: SandboxPruneSchema, + }) + .optional(); + +const AgentToolsSchema = z + .object({ + allow: z.array(z.string()).optional(), + deny: z.array(z.string()).optional(), + sandbox: z + .object({ + tools: ToolPolicySchema, + }) + .optional(), + }) + .optional(); + +const AgentEntrySchema = z.object({ + id: z.string(), + default: z.boolean().optional(), + name: z.string().optional(), + workspace: z.string().optional(), + agentDir: z.string().optional(), + model: z.string().optional(), + identity: IdentitySchema, + groupChat: GroupChatSchema, + subagents: z + .object({ + allowAgents: z.array(z.string()).optional(), + }) + .optional(), + sandbox: AgentSandboxSchema, + tools: AgentToolsSchema, +}); + +const ToolsSchema = z + .object({ + allow: z.array(z.string()).optional(), + deny: z.array(z.string()).optional(), agentToAgent: z .object({ enabled: z.boolean().optional(), allow: z.array(z.string()).optional(), }) .optional(), - agents: z - .record( - z.string(), - z - .object({ - name: z.string().optional(), - workspace: z.string().optional(), - agentDir: z.string().optional(), - model: z.string().optional(), - mentionPatterns: z.array(z.string()).optional(), - subagents: z - .object({ - allowAgents: z.array(z.string()).optional(), - }) - .optional(), - sandbox: z - .object({ - mode: z - .union([ - z.literal("off"), - z.literal("non-main"), - z.literal("all"), - ]) - .optional(), - workspaceAccess: z - .union([z.literal("none"), z.literal("ro"), z.literal("rw")]) - .optional(), - scope: z - .union([ - z.literal("session"), - z.literal("agent"), - z.literal("shared"), - ]) - .optional(), - perSession: z.boolean().optional(), - workspaceRoot: z.string().optional(), - docker: SandboxDockerSchema, - browser: SandboxBrowserSchema, - tools: ToolPolicySchema, - prune: SandboxPruneSchema, - }) - .optional(), - tools: ToolPolicySchema, - }) - .optional(), - ) - .optional(), - bindings: z - .array( - z.object({ - agentId: z.string(), - match: z.object({ - provider: z.string(), - accountId: z.string().optional(), - peer: z - .object({ - kind: z.union([ - z.literal("dm"), - z.literal("group"), - z.literal("channel"), - ]), - id: z.string(), - }) - .optional(), - guildId: z.string().optional(), - teamId: z.string().optional(), - }), - }), - ) - .optional(), - queue: z + elevated: z .object({ - mode: QueueModeSchema.optional(), - byProvider: QueueModeBySurfaceSchema, - debounceMs: z.number().int().nonnegative().optional(), - cap: z.number().int().positive().optional(), - drop: QueueDropSchema.optional(), + enabled: z.boolean().optional(), + allowFrom: ElevatedAllowFromSchema, }) .optional(), + bash: z + .object({ + backgroundMs: z.number().int().positive().optional(), + timeoutSec: z.number().int().positive().optional(), + cleanupMs: z.number().int().positive().optional(), + }) + .optional(), + subagents: z + .object({ + tools: ToolPolicySchema, + }) + .optional(), + sandbox: z + .object({ + tools: ToolPolicySchema, + }) + .optional(), + }) + .optional(); + +const AgentsSchema = z + .object({ + defaults: z.lazy(() => AgentDefaultsSchema).optional(), + list: z.array(AgentEntrySchema).optional(), + }) + .optional(); + +const BindingsSchema = z + .array( + z.object({ + agentId: z.string(), + match: z.object({ + provider: z.string(), + accountId: z.string().optional(), + peer: z + .object({ + kind: z.union([ + z.literal("dm"), + z.literal("group"), + z.literal("channel"), + ]), + id: z.string(), + }) + .optional(), + guildId: z.string().optional(), + teamId: z.string().optional(), + }), + }), + ) + .optional(); + +const AudioSchema = z + .object({ + transcription: TranscribeAudioSchema, }) .optional(); @@ -832,6 +896,145 @@ const HooksGmailSchema = z }) .optional(); +const AgentDefaultsSchema = z + .object({ + model: z + .object({ + primary: z.string().optional(), + fallbacks: z.array(z.string()).optional(), + }) + .optional(), + imageModel: z + .object({ + primary: z.string().optional(), + fallbacks: z.array(z.string()).optional(), + }) + .optional(), + models: z + .record( + z.string(), + z.object({ + alias: z.string().optional(), + /** Provider-specific API parameters (e.g., GLM-4.7 thinking mode). */ + params: z.record(z.string(), z.unknown()).optional(), + }), + ) + .optional(), + workspace: z.string().optional(), + skipBootstrap: z.boolean().optional(), + userTimezone: z.string().optional(), + contextTokens: z.number().int().positive().optional(), + contextPruning: z + .object({ + mode: z + .union([ + z.literal("off"), + z.literal("adaptive"), + z.literal("aggressive"), + ]) + .optional(), + keepLastAssistants: z.number().int().nonnegative().optional(), + softTrimRatio: z.number().min(0).max(1).optional(), + hardClearRatio: z.number().min(0).max(1).optional(), + minPrunableToolChars: z.number().int().nonnegative().optional(), + tools: z + .object({ + allow: z.array(z.string()).optional(), + deny: z.array(z.string()).optional(), + }) + .optional(), + softTrim: z + .object({ + maxChars: z.number().int().nonnegative().optional(), + headChars: z.number().int().nonnegative().optional(), + tailChars: z.number().int().nonnegative().optional(), + }) + .optional(), + hardClear: z + .object({ + enabled: z.boolean().optional(), + placeholder: z.string().optional(), + }) + .optional(), + }) + .optional(), + thinkingDefault: z + .union([ + z.literal("off"), + z.literal("minimal"), + z.literal("low"), + z.literal("medium"), + z.literal("high"), + ]) + .optional(), + verboseDefault: z.union([z.literal("off"), z.literal("on")]).optional(), + elevatedDefault: z.union([z.literal("off"), z.literal("on")]).optional(), + blockStreamingDefault: z + .union([z.literal("off"), z.literal("on")]) + .optional(), + blockStreamingBreak: z + .union([z.literal("text_end"), z.literal("message_end")]) + .optional(), + blockStreamingChunk: z + .object({ + minChars: z.number().int().positive().optional(), + maxChars: z.number().int().positive().optional(), + breakPreference: z + .union([ + z.literal("paragraph"), + z.literal("newline"), + z.literal("sentence"), + ]) + .optional(), + }) + .optional(), + timeoutSeconds: z.number().int().positive().optional(), + mediaMaxMb: z.number().positive().optional(), + typingIntervalSeconds: z.number().int().positive().optional(), + typingMode: z + .union([ + z.literal("never"), + z.literal("instant"), + z.literal("thinking"), + z.literal("message"), + ]) + .optional(), + heartbeat: HeartbeatSchema, + maxConcurrent: z.number().int().positive().optional(), + subagents: z + .object({ + maxConcurrent: z.number().int().positive().optional(), + archiveAfterMinutes: z.number().int().positive().optional(), + }) + .optional(), + sandbox: z + .object({ + mode: z + .union([z.literal("off"), z.literal("non-main"), z.literal("all")]) + .optional(), + workspaceAccess: z + .union([z.literal("none"), z.literal("ro"), z.literal("rw")]) + .optional(), + sessionToolsVisibility: z + .union([z.literal("spawned"), z.literal("all")]) + .optional(), + scope: z + .union([ + z.literal("session"), + z.literal("agent"), + z.literal("shared"), + ]) + .optional(), + perSession: z.boolean().optional(), + workspaceRoot: z.string().optional(), + docker: SandboxDockerSchema, + browser: SandboxBrowserSchema, + prune: SandboxPruneSchema, + }) + .optional(), + }) + .optional(); + export const ClawdbotSchema = z.object({ env: z .object({ @@ -845,13 +1048,6 @@ export const ClawdbotSchema = z.object({ }) .catchall(z.string()) .optional(), - identity: z - .object({ - name: z.string().optional(), - theme: z.string().optional(), - emoji: z.string().optional(), - }) - .optional(), wizard: z .object({ lastRunAt: z.string().optional(), @@ -954,182 +1150,10 @@ export const ClawdbotSchema = z.object({ }) .optional(), models: ModelsConfigSchema, - agent: z - .object({ - model: z - .object({ - primary: z.string().optional(), - fallbacks: z.array(z.string()).optional(), - }) - .optional(), - imageModel: z - .object({ - primary: z.string().optional(), - fallbacks: z.array(z.string()).optional(), - }) - .optional(), - models: z - .record( - z.string(), - z.object({ - alias: z.string().optional(), - /** Provider-specific API parameters (e.g., GLM-4.7 thinking mode). */ - params: z.record(z.string(), z.unknown()).optional(), - }), - ) - .optional(), - workspace: z.string().optional(), - skipBootstrap: z.boolean().optional(), - userTimezone: z.string().optional(), - contextTokens: z.number().int().positive().optional(), - contextPruning: z - .object({ - mode: z - .union([ - z.literal("off"), - z.literal("adaptive"), - z.literal("aggressive"), - ]) - .optional(), - keepLastAssistants: z.number().int().nonnegative().optional(), - softTrimRatio: z.number().min(0).max(1).optional(), - hardClearRatio: z.number().min(0).max(1).optional(), - minPrunableToolChars: z.number().int().nonnegative().optional(), - tools: z - .object({ - allow: z.array(z.string()).optional(), - deny: z.array(z.string()).optional(), - }) - .optional(), - softTrim: z - .object({ - maxChars: z.number().int().nonnegative().optional(), - headChars: z.number().int().nonnegative().optional(), - tailChars: z.number().int().nonnegative().optional(), - }) - .optional(), - hardClear: z - .object({ - enabled: z.boolean().optional(), - placeholder: z.string().optional(), - }) - .optional(), - }) - .optional(), - tools: z - .object({ - allow: z.array(z.string()).optional(), - deny: z.array(z.string()).optional(), - }) - .optional(), - thinkingDefault: z - .union([ - z.literal("off"), - z.literal("minimal"), - z.literal("low"), - z.literal("medium"), - z.literal("high"), - ]) - .optional(), - verboseDefault: z.union([z.literal("off"), z.literal("on")]).optional(), - elevatedDefault: z.union([z.literal("off"), z.literal("on")]).optional(), - blockStreamingDefault: z - .union([z.literal("off"), z.literal("on")]) - .optional(), - blockStreamingBreak: z - .union([z.literal("text_end"), z.literal("message_end")]) - .optional(), - blockStreamingChunk: z - .object({ - minChars: z.number().int().positive().optional(), - maxChars: z.number().int().positive().optional(), - breakPreference: z - .union([ - z.literal("paragraph"), - z.literal("newline"), - z.literal("sentence"), - ]) - .optional(), - }) - .optional(), - timeoutSeconds: z.number().int().positive().optional(), - mediaMaxMb: z.number().positive().optional(), - typingIntervalSeconds: z.number().int().positive().optional(), - typingMode: z - .union([ - z.literal("never"), - z.literal("instant"), - z.literal("thinking"), - z.literal("message"), - ]) - .optional(), - heartbeat: HeartbeatSchema, - maxConcurrent: z.number().int().positive().optional(), - subagents: z - .object({ - maxConcurrent: z.number().int().positive().optional(), - archiveAfterMinutes: z.number().int().positive().optional(), - tools: z - .object({ - allow: z.array(z.string()).optional(), - deny: z.array(z.string()).optional(), - }) - .optional(), - }) - .optional(), - bash: z - .object({ - backgroundMs: z.number().int().positive().optional(), - timeoutSec: z.number().int().positive().optional(), - cleanupMs: z.number().int().positive().optional(), - }) - .optional(), - elevated: z - .object({ - enabled: z.boolean().optional(), - allowFrom: z - .object({ - whatsapp: z.array(z.string()).optional(), - telegram: z.array(z.union([z.string(), z.number()])).optional(), - discord: z.array(z.union([z.string(), z.number()])).optional(), - slack: z.array(z.union([z.string(), z.number()])).optional(), - signal: z.array(z.union([z.string(), z.number()])).optional(), - imessage: z.array(z.union([z.string(), z.number()])).optional(), - msteams: z.array(z.union([z.string(), z.number()])).optional(), - webchat: z.array(z.union([z.string(), z.number()])).optional(), - }) - .optional(), - }) - .optional(), - sandbox: z - .object({ - mode: z - .union([z.literal("off"), z.literal("non-main"), z.literal("all")]) - .optional(), - workspaceAccess: z - .union([z.literal("none"), z.literal("ro"), z.literal("rw")]) - .optional(), - sessionToolsVisibility: z - .union([z.literal("spawned"), z.literal("all")]) - .optional(), - scope: z - .union([ - z.literal("session"), - z.literal("agent"), - z.literal("shared"), - ]) - .optional(), - perSession: z.boolean().optional(), - workspaceRoot: z.string().optional(), - docker: SandboxDockerSchema, - browser: SandboxBrowserSchema, - tools: ToolPolicySchema, - prune: SandboxPruneSchema, - }) - .optional(), - }) - .optional(), - routing: RoutingSchema, + agents: AgentsSchema, + tools: ToolsSchema, + bindings: BindingsSchema, + audio: AudioSchema, messages: MessagesSchema, commands: CommandsSchema, session: SessionSchema, diff --git a/src/cron/isolated-agent.test.ts b/src/cron/isolated-agent.test.ts index 8591e7bdd..2dc60afd0 100644 --- a/src/cron/isolated-agent.test.ts +++ b/src/cron/isolated-agent.test.ts @@ -63,9 +63,11 @@ function makeCfg( overrides: Partial = {}, ): ClawdbotConfig { const base: ClawdbotConfig = { - agent: { - model: "anthropic/claude-opus-4-5", - workspace: path.join(home, "clawd"), + agents: { + defaults: { + model: "anthropic/claude-opus-4-5", + workspace: path.join(home, "clawd"), + }, }, session: { store: storePath, mainKey: "main" }, } as ClawdbotConfig; @@ -738,7 +740,13 @@ describe("runCronIsolatedAgentTurn", () => { }); const cfg = makeCfg(home, storePath); - cfg.agent = { ...cfg.agent, heartbeat: { ackMaxChars: 0 } }; + cfg.agents = { + ...cfg.agents, + defaults: { + ...cfg.agents?.defaults, + heartbeat: { ackMaxChars: 0 }, + }, + }; const res = await runCronIsolatedAgentTurn({ cfg, diff --git a/src/cron/isolated-agent.ts b/src/cron/isolated-agent.ts index 6711850b7..38d23d351 100644 --- a/src/cron/isolated-agent.ts +++ b/src/cron/isolated-agent.ts @@ -269,12 +269,11 @@ export async function runCronIsolatedAgentTurn(params: { sessionKey: string; lane?: string; }): Promise { - const agentCfg = params.cfg.agent; - const workspaceDirRaw = - params.cfg.agent?.workspace ?? DEFAULT_AGENT_WORKSPACE_DIR; + const agentCfg = params.cfg.agents?.defaults; + const workspaceDirRaw = agentCfg?.workspace ?? DEFAULT_AGENT_WORKSPACE_DIR; const workspace = await ensureAgentWorkspace({ dir: workspaceDirRaw, - ensureBootstrapFiles: !params.cfg.agent?.skipBootstrap, + ensureBootstrapFiles: !agentCfg?.skipBootstrap, }); const workspaceDir = workspace.dir; @@ -521,7 +520,8 @@ export async function runCronIsolatedAgentTurn(params: { // This allows cron jobs to silently ack when nothing to report but still deliver // actual content when there is something to say. const ackMaxChars = - params.cfg.agent?.heartbeat?.ackMaxChars ?? DEFAULT_HEARTBEAT_ACK_MAX_CHARS; + params.cfg.agents?.defaults?.heartbeat?.ackMaxChars ?? + DEFAULT_HEARTBEAT_ACK_MAX_CHARS; const skipHeartbeatDelivery = delivery && isHeartbeatOnlyResponse(payloads, Math.max(0, ackMaxChars)); diff --git a/src/discord/monitor.tool-result.test.ts b/src/discord/monitor.tool-result.test.ts index a9d09bdd1..55317fb28 100644 --- a/src/discord/monitor.tool-result.test.ts +++ b/src/discord/monitor.tool-result.test.ts @@ -3,6 +3,7 @@ import { ChannelType, MessageType } from "@buape/carbon"; import { beforeEach, describe, expect, it, vi } from "vitest"; const sendMock = vi.fn(); +const reactMock = vi.fn(); const updateLastRouteMock = vi.fn(); const dispatchMock = vi.fn(); const readAllowFromStoreMock = vi.fn(); @@ -10,6 +11,9 @@ const upsertPairingRequestMock = vi.fn(); vi.mock("./send.js", () => ({ sendMessageDiscord: (...args: unknown[]) => sendMock(...args), + reactMessageDiscord: async (...args: unknown[]) => { + reactMock(...args); + }, })); vi.mock("../auto-reply/reply/dispatch-from-config.js", () => ({ dispatchReplyFromConfig: (...args: unknown[]) => dispatchMock(...args), @@ -48,11 +52,15 @@ describe("discord tool result dispatch", () => { it("sends status replies with responsePrefix", async () => { const { createDiscordMessageHandler } = await import("./monitor.js"); const cfg = { - agent: { model: "anthropic/claude-opus-4-5", workspace: "/tmp/clawd" }, + agents: { + defaults: { + model: "anthropic/claude-opus-4-5", + workspace: "/tmp/clawd", + }, + }, session: { store: "/tmp/clawdbot-sessions.json" }, messages: { responsePrefix: "PFX" }, discord: { dm: { enabled: true, policy: "open" } }, - routing: { allowFrom: [] }, } as ReturnType; const runtimeError = vi.fn(); @@ -114,10 +122,14 @@ describe("discord tool result dispatch", () => { it("replies with pairing code and sender id when dmPolicy is pairing", async () => { const { createDiscordMessageHandler } = await import("./monitor.js"); const cfg = { - agent: { model: "anthropic/claude-opus-4-5", workspace: "/tmp/clawd" }, + agents: { + defaults: { + model: "anthropic/claude-opus-4-5", + workspace: "/tmp/clawd", + }, + }, session: { store: "/tmp/clawdbot-sessions.json" }, discord: { dm: { enabled: true, policy: "pairing", allowFrom: [] } }, - routing: { allowFrom: [] }, } as ReturnType; const handler = createDiscordMessageHandler({ @@ -184,15 +196,19 @@ describe("discord tool result dispatch", () => { it("accepts guild messages when mentionPatterns match", async () => { const { createDiscordMessageHandler } = await import("./monitor.js"); const cfg = { - agent: { model: "anthropic/claude-opus-4-5", workspace: "/tmp/clawd" }, + agents: { + defaults: { + model: "anthropic/claude-opus-4-5", + workspace: "/tmp/clawd", + }, + }, session: { store: "/tmp/clawdbot-sessions.json" }, - messages: { responsePrefix: "PFX" }, discord: { dm: { enabled: true, policy: "open" }, guilds: { "*": { requireMention: true } }, }, - routing: { - allowFrom: [], + messages: { + responsePrefix: "PFX", groupChat: { mentionPatterns: ["\\bclawd\\b"] }, }, } as ReturnType; @@ -271,14 +287,18 @@ describe("discord tool result dispatch", () => { }); const cfg = { - agent: { model: "anthropic/claude-opus-4-5", workspace: "/tmp/clawd" }, + agents: { + defaults: { + model: "anthropic/claude-opus-4-5", + workspace: "/tmp/clawd", + }, + }, session: { store: "/tmp/clawdbot-sessions.json" }, messages: { responsePrefix: "PFX" }, discord: { dm: { enabled: true, policy: "open" }, guilds: { "*": { requireMention: false } }, }, - routing: { allowFrom: [] }, } as ReturnType; const handler = createDiscordMessageHandler({ @@ -377,19 +397,21 @@ describe("discord tool result dispatch", () => { }); const cfg = { - agent: { model: "anthropic/claude-opus-4-5", workspace: "/tmp/clawd" }, + agents: { + defaults: { + model: "anthropic/claude-opus-4-5", + workspace: "/tmp/clawd", + }, + }, session: { store: "/tmp/clawdbot-sessions.json" }, messages: { responsePrefix: "PFX" }, discord: { dm: { enabled: true, policy: "open" }, guilds: { "*": { requireMention: false } }, }, - routing: { - allowFrom: [], - bindings: [ - { agentId: "support", match: { provider: "discord", guildId: "g1" } }, - ], - }, + bindings: [ + { agentId: "support", match: { provider: "discord", guildId: "g1" } }, + ], } as ReturnType; const handler = createDiscordMessageHandler({ diff --git a/src/discord/monitor.ts b/src/discord/monitor.ts index 829cb1014..5fa9426c2 100644 --- a/src/discord/monitor.ts +++ b/src/discord/monitor.ts @@ -17,6 +17,7 @@ import { GatewayIntents, GatewayPlugin } from "@buape/carbon/gateway"; import type { APIAttachment } from "discord-api-types/v10"; import { ApplicationCommandOptionType, Routes } from "discord-api-types/v10"; +import { resolveAckReaction } from "../agents/identity.js"; import { resolveTextChunkLimit } from "../auto-reply/chunk.js"; import { hasControlCommand } from "../auto-reply/command-detection.js"; import { @@ -501,7 +502,6 @@ export function createDiscordMessageHandler(params: { guildEntries, } = params; const logger = getChildLogger({ module: "discord-auto-reply" }); - const ackReaction = (cfg.messages?.ackReaction ?? "").trim(); const ackReactionScope = cfg.messages?.ackReactionScope ?? "group-mentions"; const groupPolicy = discordConfig?.groupPolicy ?? "open"; @@ -842,6 +842,7 @@ export function createDiscordMessageHandler(params: { logVerbose(`discord: drop message ${message.id} (empty content)`); return; } + const ackReaction = resolveAckReaction(cfg, route.agentId); const shouldAckReaction = () => { if (!ackReaction) return false; if (ackReactionScope === "all") return true; diff --git a/src/gateway/config-reload.test.ts b/src/gateway/config-reload.test.ts index 62df6d3c4..cd3c0aba0 100644 --- a/src/gateway/config-reload.test.ts +++ b/src/gateway/config-reload.test.ts @@ -14,10 +14,10 @@ describe("diffConfigPaths", () => { }); it("captures array changes", () => { - const prev = { routing: { groupChat: { mentionPatterns: ["a"] } } }; - const next = { routing: { groupChat: { mentionPatterns: ["b"] } } }; + const prev = { messages: { groupChat: { mentionPatterns: ["a"] } } }; + const next = { messages: { groupChat: { mentionPatterns: ["b"] } } }; const paths = diffConfigPaths(prev, next); - expect(paths).toContain("routing.groupChat.mentionPatterns"); + expect(paths).toContain("messages.groupChat.mentionPatterns"); }); }); diff --git a/src/gateway/config-reload.ts b/src/gateway/config-reload.ts index f053dcd2e..f33f958f4 100644 --- a/src/gateway/config-reload.ts +++ b/src/gateway/config-reload.ts @@ -64,7 +64,11 @@ const RELOAD_RULES: ReloadRule[] = [ { prefix: "gateway.reload", kind: "none" }, { prefix: "hooks.gmail", kind: "hot", actions: ["restart-gmail-watcher"] }, { prefix: "hooks", kind: "hot", actions: ["reload-hooks"] }, - { prefix: "agent.heartbeat", kind: "hot", actions: ["restart-heartbeat"] }, + { + prefix: "agents.defaults.heartbeat", + kind: "hot", + actions: ["restart-heartbeat"], + }, { prefix: "cron", kind: "hot", actions: ["restart-cron"] }, { prefix: "browser", @@ -78,12 +82,13 @@ const RELOAD_RULES: ReloadRule[] = [ { prefix: "signal", kind: "hot", actions: ["restart-provider:signal"] }, { prefix: "imessage", kind: "hot", actions: ["restart-provider:imessage"] }, { prefix: "msteams", kind: "hot", actions: ["restart-provider:msteams"] }, - { prefix: "identity", kind: "none" }, + { prefix: "agents", kind: "none" }, + { prefix: "tools", kind: "none" }, + { prefix: "bindings", kind: "none" }, + { prefix: "audio", kind: "none" }, { prefix: "wizard", kind: "none" }, { prefix: "logging", kind: "none" }, { prefix: "models", kind: "none" }, - { prefix: "agent", kind: "none" }, - { prefix: "routing", kind: "none" }, { prefix: "messages", kind: "none" }, { prefix: "session", kind: "none" }, { prefix: "whatsapp", kind: "none" }, diff --git a/src/gateway/server-bridge.ts b/src/gateway/server-bridge.ts index eea960170..44cbde1e8 100644 --- a/src/gateway/server-bridge.ts +++ b/src/gateway/server-bridge.ts @@ -857,7 +857,7 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) { ).items; let thinkingLevel = entry?.thinkingLevel; if (!thinkingLevel) { - const configured = cfg.agent?.thinkingDefault; + const configured = cfg.agents?.defaults?.thinkingDefault; if (configured) { thinkingLevel = configured; } else { diff --git a/src/gateway/server-methods/chat.ts b/src/gateway/server-methods/chat.ts index 6b2799200..1dca7c8f1 100644 --- a/src/gateway/server-methods/chat.ts +++ b/src/gateway/server-methods/chat.ts @@ -61,7 +61,7 @@ export const chatHandlers: GatewayRequestHandlers = { ).items; let thinkingLevel = entry?.thinkingLevel; if (!thinkingLevel) { - const configured = cfg.agent?.thinkingDefault; + const configured = cfg.agents?.defaults?.thinkingDefault; if (configured) { thinkingLevel = configured; } else { diff --git a/src/gateway/server-methods/skills.ts b/src/gateway/server-methods/skills.ts index a3dea2320..72aec64be 100644 --- a/src/gateway/server-methods/skills.ts +++ b/src/gateway/server-methods/skills.ts @@ -1,9 +1,11 @@ +import { + resolveAgentWorkspaceDir, + resolveDefaultAgentId, +} from "../../agents/agent-scope.js"; import { installSkill } from "../../agents/skills-install.js"; import { buildWorkspaceSkillStatus } from "../../agents/skills-status.js"; -import { DEFAULT_AGENT_WORKSPACE_DIR } from "../../agents/workspace.js"; import type { ClawdbotConfig } from "../../config/config.js"; import { loadConfig, writeConfigFile } from "../../config/config.js"; -import { resolveUserPath } from "../../utils.js"; import { ErrorCodes, errorShape, @@ -28,8 +30,10 @@ export const skillsHandlers: GatewayRequestHandlers = { return; } const cfg = loadConfig(); - const workspaceDirRaw = cfg.agent?.workspace ?? DEFAULT_AGENT_WORKSPACE_DIR; - const workspaceDir = resolveUserPath(workspaceDirRaw); + const workspaceDir = resolveAgentWorkspaceDir( + cfg, + resolveDefaultAgentId(cfg), + ); const report = buildWorkspaceSkillStatus(workspaceDir, { config: cfg, }); @@ -53,7 +57,10 @@ export const skillsHandlers: GatewayRequestHandlers = { timeoutMs?: number; }; const cfg = loadConfig(); - const workspaceDirRaw = cfg.agent?.workspace ?? DEFAULT_AGENT_WORKSPACE_DIR; + const workspaceDirRaw = resolveAgentWorkspaceDir( + cfg, + resolveDefaultAgentId(cfg), + ); const result = await installSkill({ workspaceDir: workspaceDirRaw, skillName: p.name, diff --git a/src/gateway/server.agents.test.ts b/src/gateway/server.agents.test.ts index 36ab82f4a..60a8543a8 100644 --- a/src/gateway/server.agents.test.ts +++ b/src/gateway/server.agents.test.ts @@ -11,12 +11,11 @@ installGatewayTestHooks(); describe("gateway server agents", () => { test("lists configured agents via agents.list RPC", async () => { - testState.routingConfig = { - defaultAgentId: "work", - agents: { - work: { name: "Work" }, - home: { name: "Home" }, - }, + testState.agentsConfig = { + list: [ + { id: "work", name: "Work", default: true }, + { id: "home", name: "Home" }, + ], }; const { ws } = await startServerWithClient(); diff --git a/src/gateway/server.reload.test.ts b/src/gateway/server.reload.test.ts index af7cd80f5..3172208d3 100644 --- a/src/gateway/server.reload.test.ts +++ b/src/gateway/server.reload.test.ts @@ -210,7 +210,7 @@ describe("gateway hot reload", () => { gmail: { account: "me@example.com" }, }, cron: { enabled: true, store: "/tmp/cron.json" }, - agent: { heartbeat: { every: "1m" }, maxConcurrent: 2 }, + agents: { defaults: { heartbeat: { every: "1m" }, maxConcurrent: 2 } }, browser: { enabled: true, controlUrl: "http://127.0.0.1:18791" }, web: { enabled: true }, telegram: { botToken: "token" }, @@ -224,7 +224,7 @@ describe("gateway hot reload", () => { changedPaths: [ "hooks.gmail.account", "cron.enabled", - "agent.heartbeat.every", + "agents.defaults.heartbeat.every", "browser.enabled", "web.enabled", "telegram.botToken", diff --git a/src/gateway/server.sessions.test.ts b/src/gateway/server.sessions.test.ts index 2f346018f..675f5213a 100644 --- a/src/gateway/server.sessions.test.ts +++ b/src/gateway/server.sessions.test.ts @@ -328,12 +328,8 @@ describe("gateway server sessions", () => { testState.sessionConfig = { store: path.join(dir, "{agentId}", "sessions.json"), }; - testState.routingConfig = { - defaultAgentId: "home", - agents: { - home: {}, - work: {}, - }, + testState.agentsConfig = { + list: [{ id: "home", default: true }, { id: "work" }], }; const homeDir = path.join(dir, "home"); const workDir = path.join(dir, "work"); diff --git a/src/gateway/server.ts b/src/gateway/server.ts index 8d7997841..473cd84f7 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -687,10 +687,13 @@ export async function startGatewayServer( { controller: AbortController; sessionId: string; sessionKey: string } >(); setCommandLaneConcurrency("cron", cfgAtStart.cron?.maxConcurrentRuns ?? 1); - setCommandLaneConcurrency("main", cfgAtStart.agent?.maxConcurrent ?? 1); + setCommandLaneConcurrency( + "main", + cfgAtStart.agents?.defaults?.maxConcurrent ?? 1, + ); setCommandLaneConcurrency( "subagent", - cfgAtStart.agent?.subagents?.maxConcurrent ?? 1, + cfgAtStart.agents?.defaults?.subagents?.maxConcurrent ?? 1, ); const cronLogger = getChildLogger({ @@ -1975,10 +1978,13 @@ export async function startGatewayServer( } setCommandLaneConcurrency("cron", nextConfig.cron?.maxConcurrentRuns ?? 1); - setCommandLaneConcurrency("main", nextConfig.agent?.maxConcurrent ?? 1); + setCommandLaneConcurrency( + "main", + nextConfig.agents?.defaults?.maxConcurrent ?? 1, + ); setCommandLaneConcurrency( "subagent", - nextConfig.agent?.subagents?.maxConcurrent ?? 1, + nextConfig.agents?.defaults?.subagents?.maxConcurrent ?? 1, ); if (plan.hotReasons.length > 0) { diff --git a/src/gateway/session-utils.ts b/src/gateway/session-utils.ts index 6df2cf9e5..9042f763d 100644 --- a/src/gateway/session-utils.ts +++ b/src/gateway/session-utils.ts @@ -1,6 +1,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { resolveDefaultAgentId } from "../agents/agent-scope.js"; import { lookupContextTokens } from "../agents/context.js"; import { DEFAULT_CONTEXT_TOKENS, @@ -231,11 +232,11 @@ function listExistingAgentIdsFromDisk(): string[] { function listConfiguredAgentIds(cfg: ClawdbotConfig): string[] { const ids = new Set(); - const defaultId = normalizeAgentId(cfg.routing?.defaultAgentId); + const defaultId = normalizeAgentId(resolveDefaultAgentId(cfg)); ids.add(defaultId); - const agents = cfg.routing?.agents; - if (agents && typeof agents === "object") { - for (const id of Object.keys(agents)) ids.add(normalizeAgentId(id)); + const agents = cfg.agents?.list ?? []; + for (const entry of agents) { + if (entry?.id) ids.add(normalizeAgentId(entry.id)); } for (const id of listExistingAgentIdsFromDisk()) ids.add(id); const sorted = Array.from(ids).filter(Boolean); @@ -252,22 +253,19 @@ export function listAgentsForGateway(cfg: ClawdbotConfig): { scope: SessionScope; agents: GatewayAgentRow[]; } { - const defaultId = normalizeAgentId(cfg.routing?.defaultAgentId); + const defaultId = normalizeAgentId(resolveDefaultAgentId(cfg)); const mainKey = (cfg.session?.mainKey ?? DEFAULT_MAIN_KEY).trim() || DEFAULT_MAIN_KEY; const scope = cfg.session?.scope ?? "per-sender"; - const configured = cfg.routing?.agents; const configuredById = new Map(); - if (configured && typeof configured === "object") { - for (const [key, value] of Object.entries(configured)) { - if (!value || typeof value !== "object") continue; - configuredById.set(normalizeAgentId(key), { - name: - typeof value.name === "string" && value.name.trim() - ? value.name.trim() - : undefined, - }); - } + for (const entry of cfg.agents?.list ?? []) { + if (!entry?.id) continue; + configuredById.set(normalizeAgentId(entry.id), { + name: + typeof entry.name === "string" && entry.name.trim() + ? entry.name.trim() + : undefined, + }); } const agents = listConfiguredAgentIds(cfg).map((id) => { const meta = configuredById.get(id); @@ -350,7 +348,7 @@ export function loadCombinedSessionStoreForGateway(cfg: ClawdbotConfig): { const storeConfig = cfg.session?.store; if (storeConfig && !isStorePathTemplate(storeConfig)) { const storePath = resolveStorePath(storeConfig); - const defaultAgentId = normalizeAgentId(cfg.routing?.defaultAgentId); + const defaultAgentId = normalizeAgentId(resolveDefaultAgentId(cfg)); const store = loadSessionStore(storePath); const combined: Record = {}; for (const [key, entry] of Object.entries(store)) { @@ -396,7 +394,7 @@ export function getSessionDefaults( defaultModel: DEFAULT_MODEL, }); const contextTokens = - cfg.agent?.contextTokens ?? + cfg.agents?.defaults?.contextTokens ?? lookupContextTokens(resolved.model) ?? DEFAULT_CONTEXT_TOKENS; return { diff --git a/src/gateway/test-helpers.ts b/src/gateway/test-helpers.ts index c7b454603..2c3f19462 100644 --- a/src/gateway/test-helpers.ts +++ b/src/gateway/test-helpers.ts @@ -85,7 +85,8 @@ export const agentCommand = hoisted.agentCommand; export const testState = { agentConfig: undefined as Record | undefined, - routingConfig: undefined as Record | undefined, + agentsConfig: undefined as Record | undefined, + bindingsConfig: undefined as Array> | undefined, sessionStorePath: undefined as string | undefined, sessionConfig: undefined as Record | undefined, allowFrom: undefined as string[] | undefined, @@ -242,12 +243,18 @@ vi.mock("../config/config.js", async () => { changes: testState.migrationChanges, }), loadConfig: () => ({ - agent: { - model: "anthropic/claude-opus-4-5", - workspace: path.join(os.tmpdir(), "clawd-gateway-test"), - ...testState.agentConfig, - }, - routing: testState.routingConfig, + agents: (() => { + const defaults = { + model: "anthropic/claude-opus-4-5", + workspace: path.join(os.tmpdir(), "clawd-gateway-test"), + ...testState.agentConfig, + }; + if (testState.agentsConfig) { + return { ...testState.agentsConfig, defaults }; + } + return { defaults }; + })(), + bindings: testState.bindingsConfig, whatsapp: { allowFrom: testState.allowFrom, }, @@ -356,7 +363,8 @@ export function installGatewayTestHooks() { testState.sessionConfig = undefined; testState.sessionStorePath = undefined; testState.agentConfig = undefined; - testState.routingConfig = undefined; + testState.agentsConfig = undefined; + testState.bindingsConfig = undefined; testState.allowFrom = undefined; testIsNixMode.value = false; cronIsolatedRun.mockClear(); diff --git a/src/imessage/monitor.test.ts b/src/imessage/monitor.test.ts index c7e67171f..d63206529 100644 --- a/src/imessage/monitor.test.ts +++ b/src/imessage/monitor.test.ts @@ -78,9 +78,8 @@ beforeEach(() => { groups: { "*": { requireMention: true } }, }, session: { mainKey: "main" }, - routing: { + messages: { groupChat: { mentionPatterns: ["@clawd"] }, - allowFrom: [], }, }; requestMock.mockReset().mockImplementation((method: string) => { @@ -159,7 +158,7 @@ describe("monitorIMessageProvider", () => { it("allows group messages when requireMention is true but no mentionPatterns exist", async () => { config = { ...config, - routing: { groupChat: { mentionPatterns: [] }, allowFrom: [] }, + messages: { groupChat: { mentionPatterns: [] } }, imessage: { groups: { "*": { requireMention: true } } }, }; const run = monitorIMessageProvider(); diff --git a/src/infra/heartbeat-runner.test.ts b/src/infra/heartbeat-runner.test.ts index 2cdc595a9..beee3a992 100644 --- a/src/infra/heartbeat-runner.test.ts +++ b/src/infra/heartbeat-runner.test.ts @@ -24,22 +24,32 @@ describe("resolveHeartbeatIntervalMs", () => { it("returns null when invalid or zero", () => { expect( - resolveHeartbeatIntervalMs({ agent: { heartbeat: { every: "0m" } } }), + resolveHeartbeatIntervalMs({ + agents: { defaults: { heartbeat: { every: "0m" } } }, + }), ).toBeNull(); expect( - resolveHeartbeatIntervalMs({ agent: { heartbeat: { every: "oops" } } }), + resolveHeartbeatIntervalMs({ + agents: { defaults: { heartbeat: { every: "oops" } } }, + }), ).toBeNull(); }); it("parses duration strings with minute defaults", () => { expect( - resolveHeartbeatIntervalMs({ agent: { heartbeat: { every: "5m" } } }), + resolveHeartbeatIntervalMs({ + agents: { defaults: { heartbeat: { every: "5m" } } }, + }), ).toBe(5 * 60_000); expect( - resolveHeartbeatIntervalMs({ agent: { heartbeat: { every: "5" } } }), + resolveHeartbeatIntervalMs({ + agents: { defaults: { heartbeat: { every: "5" } } }, + }), ).toBe(5 * 60_000); expect( - resolveHeartbeatIntervalMs({ agent: { heartbeat: { every: "2h" } } }), + resolveHeartbeatIntervalMs({ + agents: { defaults: { heartbeat: { every: "2h" } } }, + }), ).toBe(2 * 60 * 60_000); }); }); @@ -51,7 +61,7 @@ describe("resolveHeartbeatPrompt", () => { it("uses a trimmed override when configured", () => { const cfg: ClawdbotConfig = { - agent: { heartbeat: { prompt: " ping " } }, + agents: { defaults: { heartbeat: { prompt: " ping " } } }, }; expect(resolveHeartbeatPrompt(cfg)).toBe("ping"); }); @@ -65,7 +75,7 @@ describe("resolveHeartbeatDeliveryTarget", () => { it("respects target none", () => { const cfg: ClawdbotConfig = { - agent: { heartbeat: { target: "none" } }, + agents: { defaults: { heartbeat: { target: "none" } } }, }; expect(resolveHeartbeatDeliveryTarget({ cfg, entry: baseEntry })).toEqual({ provider: "none", @@ -101,7 +111,7 @@ describe("resolveHeartbeatDeliveryTarget", () => { it("applies allowFrom fallback for WhatsApp targets", () => { const cfg: ClawdbotConfig = { - agent: { heartbeat: { target: "whatsapp", to: "+1999" } }, + agents: { defaults: { heartbeat: { target: "whatsapp", to: "+1999" } } }, whatsapp: { allowFrom: ["+1555", "+1666"] }, }; const entry = { @@ -118,7 +128,7 @@ describe("resolveHeartbeatDeliveryTarget", () => { it("keeps explicit telegram targets", () => { const cfg: ClawdbotConfig = { - agent: { heartbeat: { target: "telegram", to: "123" } }, + agents: { defaults: { heartbeat: { target: "telegram", to: "123" } } }, }; expect(resolveHeartbeatDeliveryTarget({ cfg, entry: baseEntry })).toEqual({ provider: "telegram", @@ -150,8 +160,10 @@ describe("runHeartbeatOnce", () => { ); const cfg: ClawdbotConfig = { - agent: { - heartbeat: { every: "5m", target: "whatsapp", to: "+1555" }, + agents: { + defaults: { + heartbeat: { every: "5m", target: "whatsapp", to: "+1555" }, + }, }, whatsapp: { allowFrom: ["*"] }, session: { store: storePath }, @@ -200,8 +212,10 @@ describe("runHeartbeatOnce", () => { const replySpy = vi.spyOn(replyModule, "getReplyFromConfig"); try { const cfg: ClawdbotConfig = { - routing: { defaultAgentId: "work" }, - agent: { heartbeat: { every: "5m" } }, + agents: { + defaults: { heartbeat: { every: "5m" } }, + list: [{ id: "work", default: true }], + }, whatsapp: { allowFrom: ["*"] }, session: { store: storeTemplate }, }; @@ -277,12 +291,14 @@ describe("runHeartbeatOnce", () => { ); const cfg: ClawdbotConfig = { - agent: { - heartbeat: { - every: "5m", - target: "whatsapp", - to: "+1555", - ackMaxChars: 0, + agents: { + defaults: { + heartbeat: { + every: "5m", + target: "whatsapp", + to: "+1555", + ackMaxChars: 0, + }, }, }, whatsapp: { allowFrom: ["*"] }, @@ -335,8 +351,10 @@ describe("runHeartbeatOnce", () => { ); const cfg: ClawdbotConfig = { - agent: { - heartbeat: { every: "5m", target: "whatsapp", to: "+1555" }, + agents: { + defaults: { + heartbeat: { every: "5m", target: "whatsapp", to: "+1555" }, + }, }, whatsapp: { allowFrom: ["*"] }, session: { store: storePath }, @@ -392,8 +410,10 @@ describe("runHeartbeatOnce", () => { ); const cfg: ClawdbotConfig = { - agent: { - heartbeat: { every: "5m", target: "telegram", to: "123456" }, + agents: { + defaults: { + heartbeat: { every: "5m", target: "telegram", to: "123456" }, + }, }, telegram: { botToken: "test-bot-token-123" }, session: { store: storePath }, @@ -455,8 +475,10 @@ describe("runHeartbeatOnce", () => { ); const cfg: ClawdbotConfig = { - agent: { - heartbeat: { every: "5m", target: "telegram", to: "123456" }, + agents: { + defaults: { + heartbeat: { every: "5m", target: "telegram", to: "123456" }, + }, }, telegram: { accounts: { diff --git a/src/infra/heartbeat-runner.ts b/src/infra/heartbeat-runner.ts index b015a0896..58207ae93 100644 --- a/src/infra/heartbeat-runner.ts +++ b/src/infra/heartbeat-runner.ts @@ -53,7 +53,9 @@ export function resolveHeartbeatIntervalMs( overrideEvery?: string, ) { const raw = - overrideEvery ?? cfg.agent?.heartbeat?.every ?? DEFAULT_HEARTBEAT_EVERY; + overrideEvery ?? + cfg.agents?.defaults?.heartbeat?.every ?? + DEFAULT_HEARTBEAT_EVERY; if (!raw) return null; const trimmed = String(raw).trim(); if (!trimmed) return null; @@ -68,13 +70,14 @@ export function resolveHeartbeatIntervalMs( } export function resolveHeartbeatPrompt(cfg: ClawdbotConfig) { - return resolveHeartbeatPromptText(cfg.agent?.heartbeat?.prompt); + return resolveHeartbeatPromptText(cfg.agents?.defaults?.heartbeat?.prompt); } function resolveHeartbeatAckMaxChars(cfg: ClawdbotConfig) { return Math.max( 0, - cfg.agent?.heartbeat?.ackMaxChars ?? DEFAULT_HEARTBEAT_ACK_MAX_CHARS, + cfg.agents?.defaults?.heartbeat?.ackMaxChars ?? + DEFAULT_HEARTBEAT_ACK_MAX_CHARS, ); } diff --git a/src/infra/outbound/deliver.test.ts b/src/infra/outbound/deliver.test.ts index 3dc29b2f1..adcda8758 100644 --- a/src/infra/outbound/deliver.test.ts +++ b/src/infra/outbound/deliver.test.ts @@ -114,7 +114,9 @@ describe("deliverOutboundPayloads", () => { it("uses iMessage media maxBytes from agent fallback", async () => { const sendIMessage = vi.fn().mockResolvedValue({ messageId: "i1" }); - const cfg: ClawdbotConfig = { agent: { mediaMaxMb: 3 } }; + const cfg: ClawdbotConfig = { + agents: { defaults: { mediaMaxMb: 3 } }, + }; await deliverOutboundPayloads({ cfg, diff --git a/src/infra/outbound/deliver.ts b/src/infra/outbound/deliver.ts index f38576f5f..12e31f73c 100644 --- a/src/infra/outbound/deliver.ts +++ b/src/infra/outbound/deliver.ts @@ -82,7 +82,9 @@ function resolveMediaMaxBytes( : (cfg.imessage?.accounts?.[normalizedAccountId]?.mediaMaxMb ?? cfg.imessage?.mediaMaxMb); if (providerLimit) return providerLimit * MB; - if (cfg.agent?.mediaMaxMb) return cfg.agent.mediaMaxMb * MB; + if (cfg.agents?.defaults?.mediaMaxMb) { + return cfg.agents.defaults.mediaMaxMb * MB; + } return undefined; } diff --git a/src/infra/outbound/targets.ts b/src/infra/outbound/targets.ts index 6d526f851..1d784e592 100644 --- a/src/infra/outbound/targets.ts +++ b/src/infra/outbound/targets.ts @@ -130,7 +130,7 @@ export function resolveHeartbeatDeliveryTarget(params: { entry?: SessionEntry; }): OutboundTarget { const { cfg, entry } = params; - const rawTarget = cfg.agent?.heartbeat?.target; + const rawTarget = cfg.agents?.defaults?.heartbeat?.target; const target: HeartbeatTarget = rawTarget === "whatsapp" || rawTarget === "telegram" || @@ -148,9 +148,9 @@ export function resolveHeartbeatDeliveryTarget(params: { } const explicitTo = - typeof cfg.agent?.heartbeat?.to === "string" && - cfg.agent.heartbeat.to.trim() - ? cfg.agent.heartbeat.to.trim() + typeof cfg.agents?.defaults?.heartbeat?.to === "string" && + cfg.agents.defaults.heartbeat.to.trim() + ? cfg.agents.defaults.heartbeat.to.trim() : undefined; const lastProvider = diff --git a/src/infra/state-migrations.ts b/src/infra/state-migrations.ts index c3c0aecd4..7a844705d 100644 --- a/src/infra/state-migrations.ts +++ b/src/infra/state-migrations.ts @@ -4,6 +4,7 @@ import path from "node:path"; import JSON5 from "json5"; +import { resolveDefaultAgentId } from "../agents/agent-scope.js"; import type { ClawdbotConfig } from "../config/config.js"; import { resolveOAuthDir, resolveStateDir } from "../config/paths.js"; import type { SessionEntry } from "../config/sessions.js"; @@ -12,7 +13,6 @@ import { createSubsystemLogger } from "../logging.js"; import { buildAgentMainSessionKey, DEFAULT_ACCOUNT_ID, - DEFAULT_AGENT_ID, DEFAULT_MAIN_KEY, normalizeAgentId, } from "../routing/session-key.js"; @@ -192,9 +192,7 @@ export async function detectLegacyStateMigrations(params: { const stateDir = resolveStateDir(env, homedir); const oauthDir = resolveOAuthDir(env, stateDir); - const targetAgentId = normalizeAgentId( - params.cfg.routing?.defaultAgentId ?? DEFAULT_AGENT_ID, - ); + const targetAgentId = normalizeAgentId(resolveDefaultAgentId(params.cfg)); const rawMainKey = params.cfg.session?.mainKey; const targetMainKey = typeof rawMainKey === "string" && rawMainKey.trim().length > 0 diff --git a/src/routing/resolve-route.test.ts b/src/routing/resolve-route.test.ts index 9e18a00cd..1b79cc31b 100644 --- a/src/routing/resolve-route.test.ts +++ b/src/routing/resolve-route.test.ts @@ -20,22 +20,20 @@ describe("resolveAgentRoute", () => { test("peer binding wins over account binding", () => { const cfg: ClawdbotConfig = { - routing: { - bindings: [ - { - agentId: "a", - match: { - provider: "whatsapp", - accountId: "biz", - peer: { kind: "dm", id: "+1000" }, - }, + bindings: [ + { + agentId: "a", + match: { + provider: "whatsapp", + accountId: "biz", + peer: { kind: "dm", id: "+1000" }, }, - { - agentId: "b", - match: { provider: "whatsapp", accountId: "biz" }, - }, - ], - }, + }, + { + agentId: "b", + match: { provider: "whatsapp", accountId: "biz" }, + }, + ], }; const route = resolveAgentRoute({ cfg, @@ -50,26 +48,24 @@ describe("resolveAgentRoute", () => { test("discord channel peer binding wins over guild binding", () => { const cfg: ClawdbotConfig = { - routing: { - bindings: [ - { - agentId: "chan", - match: { - provider: "discord", - accountId: "default", - peer: { kind: "channel", id: "c1" }, - }, + bindings: [ + { + agentId: "chan", + match: { + provider: "discord", + accountId: "default", + peer: { kind: "channel", id: "c1" }, }, - { - agentId: "guild", - match: { - provider: "discord", - accountId: "default", - guildId: "g1", - }, + }, + { + agentId: "guild", + match: { + provider: "discord", + accountId: "default", + guildId: "g1", }, - ], - }, + }, + ], }; const route = resolveAgentRoute({ cfg, @@ -85,22 +81,20 @@ describe("resolveAgentRoute", () => { test("guild binding wins over account binding when peer not bound", () => { const cfg: ClawdbotConfig = { - routing: { - bindings: [ - { - agentId: "guild", - match: { - provider: "discord", - accountId: "default", - guildId: "g1", - }, + bindings: [ + { + agentId: "guild", + match: { + provider: "discord", + accountId: "default", + guildId: "g1", }, - { - agentId: "acct", - match: { provider: "discord", accountId: "default" }, - }, - ], - }, + }, + { + agentId: "acct", + match: { provider: "discord", accountId: "default" }, + }, + ], }; const route = resolveAgentRoute({ cfg, @@ -115,9 +109,7 @@ describe("resolveAgentRoute", () => { test("missing accountId in binding matches default account only", () => { const cfg: ClawdbotConfig = { - routing: { - bindings: [{ agentId: "defaultAcct", match: { provider: "whatsapp" } }], - }, + bindings: [{ agentId: "defaultAcct", match: { provider: "whatsapp" } }], }; const defaultRoute = resolveAgentRoute({ @@ -140,14 +132,12 @@ describe("resolveAgentRoute", () => { test("accountId=* matches any account as a provider fallback", () => { const cfg: ClawdbotConfig = { - routing: { - bindings: [ - { - agentId: "any", - match: { provider: "whatsapp", accountId: "*" }, - }, - ], - }, + bindings: [ + { + agentId: "any", + match: { provider: "whatsapp", accountId: "*" }, + }, + ], }; const route = resolveAgentRoute({ cfg, @@ -161,9 +151,8 @@ describe("resolveAgentRoute", () => { test("defaultAgentId is used when no binding matches", () => { const cfg: ClawdbotConfig = { - routing: { - defaultAgentId: "home", - agents: { home: { workspace: "~/clawd-home" } }, + agents: { + list: [{ id: "home", default: true, workspace: "~/clawd-home" }], }, }; const route = resolveAgentRoute({ diff --git a/src/routing/resolve-route.ts b/src/routing/resolve-route.ts index 568ace79e..d9c5858d5 100644 --- a/src/routing/resolve-route.ts +++ b/src/routing/resolve-route.ts @@ -1,9 +1,9 @@ +import { resolveDefaultAgentId } from "../agents/agent-scope.js"; import type { ClawdbotConfig } from "../config/config.js"; import { buildAgentMainSessionKey, buildAgentPeerSessionKey, DEFAULT_ACCOUNT_ID, - DEFAULT_AGENT_ID, DEFAULT_MAIN_KEY, normalizeAgentId, } from "./session-key.js"; @@ -81,19 +81,13 @@ export function buildAgentSessionKey(params: { } function listBindings(cfg: ClawdbotConfig) { - const bindings = cfg.routing?.bindings; + const bindings = cfg.bindings; return Array.isArray(bindings) ? bindings : []; } function listAgents(cfg: ClawdbotConfig) { - const agents = cfg.routing?.agents; - return agents && typeof agents === "object" ? agents : undefined; -} - -function resolveDefaultAgentId(cfg: ClawdbotConfig): string { - const explicit = cfg.routing?.defaultAgentId?.trim(); - if (explicit) return explicit; - return DEFAULT_AGENT_ID; + const agents = cfg.agents?.list; + return Array.isArray(agents) ? agents : []; } function pickFirstExistingAgentId( @@ -102,8 +96,10 @@ function pickFirstExistingAgentId( ): string { const normalized = normalizeAgentId(agentId); const agents = listAgents(cfg); - if (!agents) return normalized; - if (Object.hasOwn(agents, normalized)) return normalized; + if (agents.length === 0) return normalized; + if (agents.some((agent) => normalizeAgentId(agent.id) === normalized)) { + return normalized; + } return normalizeAgentId(resolveDefaultAgentId(cfg)); } diff --git a/src/signal/monitor.tool-result.test.ts b/src/signal/monitor.tool-result.test.ts index ff1777501..5a051210d 100644 --- a/src/signal/monitor.tool-result.test.ts +++ b/src/signal/monitor.tool-result.test.ts @@ -57,7 +57,6 @@ beforeEach(() => { config = { messages: { responsePrefix: "PFX" }, signal: { autoStart: false, dmPolicy: "open", allowFrom: ["*"] }, - routing: { allowFrom: [] }, }; sendMock.mockReset().mockResolvedValue(undefined); replyMock.mockReset(); diff --git a/src/signal/send.ts b/src/signal/send.ts index 50e392783..7e004ca47 100644 --- a/src/signal/send.ts +++ b/src/signal/send.ts @@ -80,8 +80,8 @@ export async function sendMessageSignal( if (typeof accountInfo.config.mediaMaxMb === "number") { return accountInfo.config.mediaMaxMb * 1024 * 1024; } - if (typeof cfg.agent?.mediaMaxMb === "number") { - return cfg.agent.mediaMaxMb * 1024 * 1024; + if (typeof cfg.agents?.defaults?.mediaMaxMb === "number") { + return cfg.agents.defaults.mediaMaxMb * 1024 * 1024; } return 8 * 1024 * 1024; })(); diff --git a/src/slack/monitor.tool-result.test.ts b/src/slack/monitor.tool-result.test.ts index 551cb8ba8..77cf26e9a 100644 --- a/src/slack/monitor.tool-result.test.ts +++ b/src/slack/monitor.tool-result.test.ts @@ -105,7 +105,6 @@ beforeEach(() => { ackReactionScope: "group-mentions", }, slack: { dm: { enabled: true, policy: "open", allowFrom: ["*"] } }, - routing: { allowFrom: [] }, }; sendMock.mockReset().mockResolvedValue(undefined); replyMock.mockReset(); @@ -208,15 +207,14 @@ describe("monitorSlackProvider tool results", () => { it("accepts channel messages when mentionPatterns match", async () => { config = { - messages: { responsePrefix: "PFX" }, + messages: { + responsePrefix: "PFX", + groupChat: { mentionPatterns: ["\\bclawd\\b"] }, + }, slack: { dm: { enabled: true, policy: "open", allowFrom: ["*"] }, channels: { C1: { allow: true, requireMention: true } }, }, - routing: { - allowFrom: [], - groupChat: { mentionPatterns: ["\\bclawd\\b"] }, - }, }; replyMock.mockResolvedValue({ text: "hi" }); @@ -378,7 +376,6 @@ describe("monitorSlackProvider tool results", () => { dm: { enabled: true, policy: "open", allowFrom: ["*"] }, channels: { C1: { allow: true, requireMention: false } }, }, - routing: { allowFrom: [] }, }; const controller = new AbortController(); @@ -429,12 +426,9 @@ describe("monitorSlackProvider tool results", () => { dm: { enabled: true, policy: "open", allowFrom: ["*"] }, channels: { C1: { allow: true, requireMention: false } }, }, - routing: { - allowFrom: [], - bindings: [ - { agentId: "support", match: { provider: "slack", teamId: "T1" } }, - ], - }, + bindings: [ + { agentId: "support", match: { provider: "slack", teamId: "T1" } }, + ], }; const client = getSlackClient(); diff --git a/src/slack/monitor.ts b/src/slack/monitor.ts index f97cd42df..f7d35cd33 100644 --- a/src/slack/monitor.ts +++ b/src/slack/monitor.ts @@ -4,6 +4,7 @@ import { type SlackEventMiddlewareArgs, } from "@slack/bolt"; import type { WebClient as SlackWebClient } from "@slack/web-api"; +import { resolveAckReaction } from "../agents/identity.js"; import { chunkMarkdownText, resolveTextChunkLimit, @@ -509,7 +510,6 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { opts.slashCommand ?? slackCfg.slashCommand, ); const textLimit = resolveTextChunkLimit(cfg, "slack", account.accountId); - const ackReaction = (cfg.messages?.ackReaction ?? "").trim(); const ackReactionScope = cfg.messages?.ackReactionScope ?? "group-mentions"; const mediaMaxBytes = (opts.mediaMaxMb ?? slackCfg.mediaMaxMb ?? 20) * 1024 * 1024; @@ -936,6 +936,7 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { }); const rawBody = (message.text ?? "").trim() || media?.placeholder || ""; if (!rawBody) return; + const ackReaction = resolveAckReaction(cfg, route.agentId); const shouldAckReaction = () => { if (!ackReaction) return false; if (ackReactionScope === "all") return true; diff --git a/src/telegram/bot.test.ts b/src/telegram/bot.test.ts index e71cdb5f9..615ca095f 100644 --- a/src/telegram/bot.test.ts +++ b/src/telegram/bot.test.ts @@ -360,7 +360,7 @@ describe("createTelegramBot", () => { loadConfig.mockReturnValue({ identity: { name: "Bert" }, - routing: { groupChat: { mentionPatterns: ["\\bbert\\b"] } }, + messages: { groupChat: { mentionPatterns: ["\\bbert\\b"] } }, telegram: { groups: { "*": { requireMention: true } } }, }); @@ -438,8 +438,11 @@ describe("createTelegramBot", () => { replySpy.mockReset(); loadConfig.mockReturnValue({ - messages: { ackReaction: "👀", ackReactionScope: "group-mentions" }, - routing: { groupChat: { mentionPatterns: ["\\bbert\\b"] } }, + messages: { + ackReaction: "👀", + ackReactionScope: "group-mentions", + groupChat: { mentionPatterns: ["\\bbert\\b"] }, + }, telegram: { groups: { "*": { requireMention: true } } }, }); @@ -483,7 +486,7 @@ describe("createTelegramBot", () => { replySpy.mockReset(); loadConfig.mockReturnValue({ - routing: { groupChat: { mentionPatterns: ["\\bbert\\b"] } }, + messages: { groupChat: { mentionPatterns: ["\\bbert\\b"] } }, telegram: { groups: { "*": { requireMention: true } } }, }); @@ -515,7 +518,7 @@ describe("createTelegramBot", () => { replySpy.mockReset(); loadConfig.mockReturnValue({ - routing: { groupChat: { mentionPatterns: [] } }, + messages: { groupChat: { mentionPatterns: [] } }, telegram: { groups: { "*": { requireMention: true } } }, }); @@ -790,17 +793,15 @@ describe("createTelegramBot", () => { ); loadConfig.mockReturnValue({ telegram: { groups: { "*": { requireMention: true } } }, - routing: { - bindings: [ - { - agentId: "ops", - match: { - provider: "telegram", - peer: { kind: "group", id: "123" }, - }, + bindings: [ + { + agentId: "ops", + match: { + provider: "telegram", + peer: { kind: "group", id: "123" }, }, - ], - }, + }, + ], session: { store: storePath }, }); diff --git a/src/telegram/bot.ts b/src/telegram/bot.ts index ac0550aeb..17c226fb0 100644 --- a/src/telegram/bot.ts +++ b/src/telegram/bot.ts @@ -5,6 +5,8 @@ import { sequentialize } from "@grammyjs/runner"; import { apiThrottler } from "@grammyjs/transformer-throttler"; import type { ApiClientOptions, Message } from "grammy"; import { Bot, InputFile, webhookCallback } from "grammy"; +import { resolveDefaultAgentId } from "../agents/agent-scope.js"; +import { resolveAckReaction } from "../agents/identity.js"; import { EmbeddedBlockChunker } from "../agents/pi-embedded-block-chunker.js"; import { chunkMarkdownText, @@ -225,7 +227,6 @@ export function createTelegramBot(opts: TelegramBotOptions) { const nativeEnabled = cfg.commands?.native === true; const nativeDisabledExplicit = cfg.commands?.native === false; const useAccessGroups = cfg.commands?.useAccessGroups !== false; - const ackReaction = (cfg.messages?.ackReaction ?? "").trim(); const ackReactionScope = cfg.messages?.ackReactionScope ?? "group-mentions"; const mediaMaxBytes = (opts.mediaMaxMb ?? telegramCfg.mediaMaxMb ?? 5) * 1024 * 1024; @@ -260,7 +261,7 @@ export function createTelegramBot(opts: TelegramBotOptions) { messageThreadId?: number; sessionKey?: string; }) => { - const agentId = params.agentId ?? cfg.agent?.id ?? "main"; + const agentId = params.agentId ?? resolveDefaultAgentId(cfg); const sessionKey = params.sessionKey ?? `agent:${agentId}:telegram:group:${buildTelegramGroupPeerId(params.chatId, params.messageThreadId)}`; @@ -500,6 +501,7 @@ export function createTelegramBot(opts: TelegramBotOptions) { } // ACK reactions + const ackReaction = resolveAckReaction(cfg, route.agentId); const shouldAckReaction = () => { if (!ackReaction) return false; if (ackReactionScope === "all") return true; diff --git a/src/telegram/monitor.test.ts b/src/telegram/monitor.test.ts index 75f21b672..81ceb0f25 100644 --- a/src/telegram/monitor.test.ts +++ b/src/telegram/monitor.test.ts @@ -30,7 +30,7 @@ const { initSpy, runSpy, loadConfig } = vi.hoisted(() => ({ stop: vi.fn(), })), loadConfig: vi.fn(() => ({ - agent: { maxConcurrent: 2 }, + agents: { defaults: { maxConcurrent: 2 } }, telegram: {}, })), })); @@ -79,7 +79,7 @@ vi.mock("../auto-reply/reply.js", () => ({ describe("monitorTelegramProvider (grammY)", () => { beforeEach(() => { loadConfig.mockReturnValue({ - agent: { maxConcurrent: 2 }, + agents: { defaults: { maxConcurrent: 2 } }, telegram: {}, }); initSpy.mockClear(); @@ -109,7 +109,7 @@ describe("monitorTelegramProvider (grammY)", () => { it("uses agent maxConcurrent for runner concurrency", async () => { runSpy.mockClear(); loadConfig.mockReturnValue({ - agent: { maxConcurrent: 3 }, + agents: { defaults: { maxConcurrent: 3 } }, telegram: {}, }); diff --git a/src/telegram/monitor.ts b/src/telegram/monitor.ts index ed90fed2e..627d2796c 100644 --- a/src/telegram/monitor.ts +++ b/src/telegram/monitor.ts @@ -28,7 +28,7 @@ export function createTelegramRunnerOptions( ): RunOptions { return { sink: { - concurrency: cfg.agent?.maxConcurrent ?? 1, + concurrency: cfg.agents?.defaults?.maxConcurrent ?? 1, }, runner: { fetch: { diff --git a/src/tui/tui.ts b/src/tui/tui.ts index 5a189ba39..204156beb 100644 --- a/src/tui/tui.ts +++ b/src/tui/tui.ts @@ -6,6 +6,7 @@ import { Text, TUI, } from "@mariozechner/pi-tui"; +import { resolveDefaultAgentId } from "../agents/agent-scope.js"; import { normalizeUsageDisplay } from "../auto-reply/thinking.js"; import { loadConfig } from "../config/config.js"; import { @@ -131,9 +132,7 @@ export async function runTui(opts: TuiOptions) { let sessionScope: SessionScope = (config.session?.scope ?? "per-sender") as SessionScope; let sessionMainKey = (config.session?.mainKey ?? "main").trim() || "main"; - let agentDefaultId = normalizeAgentId( - config.routing?.defaultAgentId ?? "main", - ); + let agentDefaultId = resolveDefaultAgentId(config); let currentAgentId = agentDefaultId; let agents: AgentSummary[] = []; const agentNames = new Map(); diff --git a/src/web/auto-reply.test.ts b/src/web/auto-reply.test.ts index e2fe2a7f0..cff460ad9 100644 --- a/src/web/auto-reply.test.ts +++ b/src/web/auto-reply.test.ts @@ -875,7 +875,7 @@ describe("web auto-reply", () => { for (const fmt of formats) { // Force a small cap to ensure compression is exercised for every format. - setLoadConfigMock(() => ({ agent: { mediaMaxMb: 1 } })); + setLoadConfigMock(() => ({ agents: { defaults: { mediaMaxMb: 1 } } })); const sendMedia = vi.fn(); const reply = vi.fn().mockResolvedValue(undefined); const sendComposing = vi.fn(); @@ -940,7 +940,7 @@ describe("web auto-reply", () => { ); it("honors mediaMaxMb from config", async () => { - setLoadConfigMock(() => ({ agent: { mediaMaxMb: 1 } })); + setLoadConfigMock(() => ({ agents: { defaults: { mediaMaxMb: 1 } } })); const sendMedia = vi.fn(); const reply = vi.fn().mockResolvedValue(undefined); const sendComposing = vi.fn(); @@ -1182,21 +1182,26 @@ describe("web auto-reply", () => { allowFrom: ["*"], groups: { "*": { requireMention: true } }, }, - routing: { + messages: { groupChat: { mentionPatterns: ["@global"] }, - agents: { - work: { mentionPatterns: ["@workbot"] }, - }, - bindings: [ + }, + agents: { + list: [ { - agentId: "work", - match: { - provider: "whatsapp", - peer: { kind: "group", id: "123@g.us" }, - }, + id: "work", + groupChat: { mentionPatterns: ["@workbot"] }, }, ], }, + bindings: [ + { + agentId: "work", + match: { + provider: "whatsapp", + peer: { kind: "group", id: "123@g.us" }, + }, + }, + ], })); let capturedOnMessage: @@ -1260,7 +1265,7 @@ describe("web auto-reply", () => { allowFrom: ["*"], groups: { "*": { requireMention: false } }, }, - routing: { groupChat: { mentionPatterns: ["@clawd"] } }, + messages: { groupChat: { mentionPatterns: ["@clawd"] } }, })); let capturedOnMessage: @@ -1309,7 +1314,7 @@ describe("web auto-reply", () => { allowFrom: ["*"], groups: { "999@g.us": { requireMention: false } }, }, - routing: { groupChat: { mentionPatterns: ["@clawd"] } }, + messages: { groupChat: { mentionPatterns: ["@clawd"] } }, })); let capturedOnMessage: @@ -1363,7 +1368,7 @@ describe("web auto-reply", () => { "123@g.us": { requireMention: false }, }, }, - routing: { groupChat: { mentionPatterns: ["@clawd"] } }, + messages: { groupChat: { mentionPatterns: ["@clawd"] } }, })); let capturedOnMessage: @@ -1419,7 +1424,7 @@ describe("web auto-reply", () => { }); setLoadConfigMock(() => ({ - routing: { + messages: { groupChat: { mentionPatterns: ["@clawd"] }, }, session: { store: storePath }, @@ -1498,7 +1503,7 @@ describe("web auto-reply", () => { allowFrom: ["+999"], groups: { "*": { requireMention: true } }, }, - routing: { + messages: { groupChat: { mentionPatterns: ["\\bclawd\\b"], }, diff --git a/src/web/auto-reply.ts b/src/web/auto-reply.ts index b7fca639a..dc1050443 100644 --- a/src/web/auto-reply.ts +++ b/src/web/auto-reply.ts @@ -341,7 +341,7 @@ export async function runWebHeartbeatOnce(opts: { const replyResult = await replyResolver( { - Body: resolveHeartbeatPrompt(cfg.agent?.heartbeat?.prompt), + Body: resolveHeartbeatPrompt(cfg.agents?.defaults?.heartbeat?.prompt), From: to, To: to, MessageSid: sessionId ?? sessionSnapshot.entry?.sessionId, @@ -377,7 +377,8 @@ export async function runWebHeartbeatOnce(opts: { ); const ackMaxChars = Math.max( 0, - cfg.agent?.heartbeat?.ackMaxChars ?? DEFAULT_HEARTBEAT_ACK_MAX_CHARS, + cfg.agents?.defaults?.heartbeat?.ackMaxChars ?? + DEFAULT_HEARTBEAT_ACK_MAX_CHARS, ); const stripped = stripHeartbeatToken(replyPayload.text, { mode: "heartbeat", @@ -786,7 +787,7 @@ export async function monitorWebProvider( groups: account.groups, }, } satisfies ReturnType; - const configuredMaxMb = cfg.agent?.mediaMaxMb; + const configuredMaxMb = cfg.agents?.defaults?.mediaMaxMb; const maxMediaBytes = typeof configuredMaxMb === "number" && configuredMaxMb > 0 ? configuredMaxMb * 1024 * 1024 @@ -800,7 +801,7 @@ export async function monitorWebProvider( buildMentionConfig(cfg, agentId); const baseMentionConfig = resolveMentionConfig(); const groupHistoryLimit = - cfg.routing?.groupChat?.historyLimit ?? DEFAULT_GROUP_HISTORY_LIMIT; + cfg.messages?.groupChat?.historyLimit ?? DEFAULT_GROUP_HISTORY_LIMIT; const groupHistories = new Map< string, Array<{ sender: string; body: string; timestamp?: number }> diff --git a/src/web/monitor-inbox.test.ts b/src/web/monitor-inbox.test.ts index 1e1fbda04..f0ba00419 100644 --- a/src/web/monitor-inbox.test.ts +++ b/src/web/monitor-inbox.test.ts @@ -1081,7 +1081,7 @@ describe("web monitor inbox", () => { // Reset mock for other tests mockLoadConfig.mockReturnValue({ - routing: { + whatsapp: { allowFrom: ["*"], }, messages: { diff --git a/src/wizard/onboarding.ts b/src/wizard/onboarding.ts index 830655f05..cb8459437 100644 --- a/src/wizard/onboarding.ts +++ b/src/wizard/onboarding.ts @@ -92,7 +92,8 @@ export async function runOnboardingWizard( })) as "keep" | "modify" | "reset"; if (action === "reset") { - const workspaceDefault = baseConfig.agent?.workspace ?? DEFAULT_WORKSPACE; + const workspaceDefault = + baseConfig.agents?.defaults?.workspace ?? DEFAULT_WORKSPACE; const resetScope = (await prompter.select({ message: "Reset scope", options: [ @@ -276,10 +277,11 @@ export async function runOnboardingWizard( const workspaceInput = opts.workspace ?? (flow === "quickstart" - ? (baseConfig.agent?.workspace ?? DEFAULT_WORKSPACE) + ? (baseConfig.agents?.defaults?.workspace ?? DEFAULT_WORKSPACE) : await prompter.text({ message: "Workspace directory", - initialValue: baseConfig.agent?.workspace ?? DEFAULT_WORKSPACE, + initialValue: + baseConfig.agents?.defaults?.workspace ?? DEFAULT_WORKSPACE, })); const workspaceDir = resolveUserPath( @@ -288,9 +290,12 @@ export async function runOnboardingWizard( let nextConfig: ClawdbotConfig = { ...baseConfig, - agent: { - ...baseConfig.agent, - workspace: workspaceDir, + agents: { + ...baseConfig.agents, + defaults: { + ...baseConfig.agents?.defaults, + workspace: workspaceDir, + }, }, gateway: { ...baseConfig.gateway, @@ -505,7 +510,7 @@ export async function runOnboardingWizard( await writeConfigFile(nextConfig); runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`); await ensureWorkspaceAndSessions(workspaceDir, runtime, { - skipBootstrap: Boolean(nextConfig.agent?.skipBootstrap), + skipBootstrap: Boolean(nextConfig.agents?.defaults?.skipBootstrap), }); nextConfig = await setupSkills(nextConfig, workspaceDir, runtime, prompter);