Compare commits

...

16 Commits

Author SHA1 Message Date
Peter Steinberger
07cc816969 fix: explain /bash enablement when disabled (#722) (thanks @vrknetha) 2026-01-13 07:18:15 +00:00
Peter Steinberger
b0d0914ca0 docs: group FAQ sections 2026-01-13 07:17:28 +00:00
Peter Steinberger
b33334620a Tests: run e2e gateway with node 2026-01-13 07:17:28 +00:00
Peter Steinberger
ebc4fe57d7 Docs: reorder 2025.1.12 changelog 2026-01-13 07:17:28 +00:00
Peter Steinberger
5909cf36b9 Deps: update Pi + Vitest and add Bedrock docs 2026-01-13 07:17:28 +00:00
Peter Steinberger
e3a97b96d9 docs: add FAQ table of contents 2026-01-13 07:17:28 +00:00
Peter Steinberger
4942f3af0f fix: per-agent model fallbacks (#583) (thanks @mitschabaude-bot) 2026-01-13 07:17:28 +00:00
Gregor's Bot
ad44e32910 Docs: document agents.list model fallbacks 2026-01-13 07:17:28 +00:00
Gregor's Bot
b0b77aae88 Agents: test per-agent model fallbacks override 2026-01-13 07:17:28 +00:00
Gregor's Bot
007f8c9222 Config: support per-agent model fallbacks 2026-01-13 07:17:28 +00:00
Peter Steinberger
dbd5a76cb0 fix: gate xhigh by model (#444) (thanks @grp06) 2026-01-13 07:17:28 +00:00
George Pickett
630b256aee Thinking: gate xhigh by model 2026-01-13 07:17:28 +00:00
Peter Steinberger
e170bc397d test(tools): cover tool policy helpers 2026-01-13 07:17:28 +00:00
Peter Steinberger
d2f033aee5 docs: update changelog for tool schema and profiles 2026-01-13 07:17:28 +00:00
Peter Steinberger
8f12b47b6d feat(tools): add tool profiles and group shorthands 2026-01-13 07:17:28 +00:00
Peter Steinberger
e8b382e8ed fix(tools): harden tool schemas for strict providers 2026-01-13 07:17:28 +00:00
66 changed files with 3118 additions and 1102 deletions

View File

@ -100,6 +100,7 @@
- **Multi-agent safety:** focus reports on your edits; avoid guard-rail disclaimers unless truly blocked; when multiple agents touch the same file, continue if safe; end with a brief “other files present” note only if relevant. - **Multi-agent safety:** focus reports on your edits; avoid guard-rail disclaimers unless truly blocked; when multiple agents touch the same file, continue if safe; end with a brief “other files present” note only if relevant.
- Bug investigations: read source code of relevant npm dependencies and all related local code before concluding; aim for high-confidence root cause. - Bug investigations: read source code of relevant npm dependencies and all related local code before concluding; aim for high-confidence root cause.
- Code style: add brief comments for tricky logic; keep files under ~500 LOC when feasible (split/refactor as needed). - Code style: add brief comments for tricky logic; keep files under ~500 LOC when feasible (split/refactor as needed).
- Tool schema guardrails (google-antigravity): avoid `Type.Union` in tool input schemas; no `anyOf`/`oneOf`/`allOf`. Use `stringEnum`/`optionalStringEnum` (Type.Unsafe enum) for string lists, and `Type.Optional(...)` instead of `... | null`. Keep top-level tool schema as `type: "object"` with `properties`.
- When asked to open a “session” file, open the Pi session logs under `~/.clawdbot/agents/main/sessions/*.jsonl` (newest unless a specific ID is given), not the default `sessions.json`. If logs are needed from another machine, SSH via Tailscale and read the same path there. - When asked to open a “session” file, open the Pi session logs under `~/.clawdbot/agents/main/sessions/*.jsonl` (newest unless a specific ID is given), not the default `sessions.json`. If logs are needed from another machine, SSH via Tailscale and read the same path there.
- Menubar dimming + restart flow mirrors Trimmy: use `scripts/restart-mac.sh` (kills all Clawdbot variants, runs `swift build`, packages, relaunches). Icon dimming depends on MenuBarExtraAccess wiring in AppMain; keep `appearsDisabled` updates intact when touching the status item. - Menubar dimming + restart flow mirrors Trimmy: use `scripts/restart-mac.sh` (kills all Clawdbot variants, runs `swift build`, packages, relaunches). Icon dimming depends on MenuBarExtraAccess wiring in AppMain; keep `appearsDisabled` updates intact when touching the status item.
- Do not rebuild the macOS app over SSH; rebuilds must be run directly on the Mac. - Do not rebuild the macOS app over SSH; rebuilds must be run directly on the Mac.

View File

@ -1,116 +1,48 @@
# Changelog # Changelog
## 2026.1.13-1 ## 2025.1.12 (Unreleased)
### Changes
- Cron: accept ISO timestamps for one-shot schedules (UTC) and allow optional delete-after-run; wired into CLI + macOS editor.
- Gateway: add Tailscale binary discovery, custom bind mode, and probe auth retry for password changes. (#740 — thanks @jeffersonwarrior)
- Agents: add compaction mode config with optional safeguard summarization for long histories. (#700 — thanks @thewilloftheshadow)
### Fixes
- Gateway: honor `CLAWDBOT_LAUNCHD_LABEL` / `CLAWDBOT_SYSTEMD_UNIT` overrides when checking or restarting the daemon.
## 2026.1.12-4
### Changes
- Models/Moonshot: add Kimi K2 0905 + turbo/thinking variants to the preset + docs. (#818 — thanks @mickahouan)
- Memory: allow custom OpenAI-compatible embedding endpoints for memory search (remote baseUrl/apiKey/headers). (#819 — thanks @mukhtharcm)
- Auth: add Chutes OAuth (PKCE + refresh + onboarding choice). (#726 — thanks @FrieSei)
- Agents: make workspace bootstrap truncation configurable (default 20k) and warn when files are truncated.
### Fixes
- Typing: keep typing indicators alive during tool execution. (#450, #447 — thanks @thewilloftheshadow)
- Cron: coerce enabled patches so disabling jobs persists correctly. (#205 — thanks @thewilloftheshadow)
- Control UI: keep chat scroll position unless user is near the bottom. (#217 — thanks @thewilloftheshadow)
- Fallback: treat credential validation failures ("no credentials found", "no API key found") as auth errors that trigger model fallback. (#822 — thanks @sebslight)
- Telegram: preserve forum topic thread ids, including General topic replies. (#727 — thanks @thewilloftheshadow)
- Telegram: persist polling update offsets across restarts to avoid duplicate updates. (#739 — thanks @thewilloftheshadow)
- Discord: avoid duplicate message/reaction listeners on monitor reloads. (#744 — thanks @thewilloftheshadow)
- System events: include local timestamps when events are injected into prompts. (#245 — thanks @thewilloftheshadow)
- Cron: accept `jobId` aliases for cron update/run/remove params in gateway validation. (#252 — thanks @thewilloftheshadow)
- Models/Google: normalize Gemini 3 model ids to preview variants before runtime selection. (#795 — thanks @thewilloftheshadow)
- Models/Google: strip Gemini CLI tool call/response ids in patched provider handling. (#783 — thanks @ananth-vardhan-cn)
- TUI: keep the last streamed response instead of replacing it with "(no output)". (#747 — thanks @thewilloftheshadow)
- Slack: accept slash commands with or without leading `/` for custom command configs. (#798 — thanks @thewilloftheshadow)
- Onboarding/Configure: refuse to proceed with invalid configs; run `clawdbot doctor` first to avoid wiping custom fields. (#764 — thanks @mukhtharcm)
- Onboarding: quote Windows browser URLs when launching via `cmd start` to preserve OAuth query params. (#794 — thanks @roshanasingh4)
- Gateway/Auth: allow Tailscale Serve identity headers to satisfy token auth when `allowTailscale` is enabled. (#823 — thanks @roshanasingh4)
- Anthropic: merge consecutive user turns (preserve newest metadata) before validation to avoid “Incorrect role information” errors. (#804 — thanks @ThomsenDrake)
- Discord/Slack: centralize reply-thread planning so auto-thread replies stay in the created thread without parent reply refs.
- Telegram: respect account-scoped bindings when webhook mode is enabled. (#821 — thanks @gumadeiras)
- Update: run `clawdbot doctor --non-interactive` during updates to avoid TTY hangs. (#781 — thanks @ronyrus)
- Browser tools: treat explicit `maxChars: 0` as unlimited while keeping the default limit only when omitted. (#796 — thanks @gabriel-trigo)
- Tools: allow Claude/Gemini tool param aliases (`file_path`, `old_string`, `new_string`) while enforcing required params at runtime. (#793 — thanks @hsrvc)
- Gemini: downgrade tool-call history missing `thought_signature` to avoid INVALID_ARGUMENT errors. (#793 — thanks @hsrvc)
- Messaging: enforce context isolation for message tool sends across providers (normalized targets + tests). (#793 — thanks @hsrvc)
- Auto-reply: re-evaluate reasoning tag enforcement on fallback providers to prevent leaked reasoning. (#810 — thanks @mcinteerj)
- Tools/Gemini: drop null-only union variants while cleaning tool schemas to avoid Cloud Code Assist schema errors. (#782 — thanks @AbhisekBasu1)
- Connections UI: polish multi-account account cards in the Connections view. (#816 — thanks @steipete)
- Gemini: strip Claude `msg_*` thought_signature fields from session history to avoid base64 decode errors. (#805 — thanks @marcmarg)
## 2026.1.12-3
### Changes
- Sandbox: drop legacy `memory` tool-policy shorthand; require explicit `group:memory`.
### Fixes
- Telegram: tolerate mocked bots missing native-command APIs (`setMyCommands`, `command`) during tests.
- Auto-reply: fix streaming block reply media handling (no redeclared/use-before-declare vars).
## 2026.1.12-2
### Changes
- Subagents: add config to set default sub-agent model (`agents.defaults.subagents.model` + per-agent override); still overridden by `sessions_spawn.model`.
- Plugins: restore full voice-call plugin parity (Telnyx/Twilio, streaming, inbound policies, tools/CLI).
- Sandbox: support tool-policy groups in `tools.sandbox.tools` (e.g. `group:memory`, `group:fs`) to reduce config churn.
### Fixes
- Models/MiniMax: strip malformed tool invocation XML (`<invoke>...</invoke>` and `</minimax:tool_call>`) from assistant text to prevent tool call leaks into user messages. (#809 — thanks @latitudeki5223)
- Tools/Models: MiniMax vision now uses the Coding Plan VLM endpoint (`/v1/coding_plan/vlm`) so the `image` tool works with MiniMax keys (also accepts `@/path/to/file.png`-style inputs).
- Gateway/macOS: reduce noisy loopback WS "closed before connect" logs during tests.
- Auto-reply: resolve ambiguous `/model` fuzzy matches by picking the best candidate instead of erroring.
## 2026.1.12-1
### Changes
- Heartbeat: raise default `ackMaxChars` to 300 so any `HEARTBEAT_OK` replies with short padding stay internal (fewer noisy heartbeat posts on providers).
- Onboarding: normalize API key inputs (strip `export KEY=...` wrappers) so shell-style entries paste cleanly.
## 2026.1.12 (Unreleased)
### Highlights ### Highlights
- Memory: add vector search for agent memories (Markdown-only scope) with SQLite index, chunking, lazy sync + file watch, and per-agent enablement/fallback. - Memory: add vector search for agent memories (Markdown-only) with SQLite index, chunking, lazy sync + file watch, and per-agent enablement/fallback.
- Plugins: restore full voice-call plugin parity (Telnyx/Twilio, streaming, inbound policies, tools/CLI).
- Models: add Synthetic provider plus Moonshot Kimi K2 0905 + turbo/thinking variants (with docs).
- Cron: one-shot schedules accept ISO timestamps (UTC) with optional delete-after-run; cron jobs can target a specific agent (CLI + macOS/Control UI).
- Agents: add compaction mode config with optional safeguard summarization and per-agent model fallbacks.
### Changes ### New & Improved
- Agents: strengthen memory recall guidance (memory_search mandatory for past work/preferences; system prompt injects conditional recall section; memory_get now described as safe snippet fetch). - Memory: add custom OpenAI-compatible embedding endpoints; support OpenAI/local `node-llama-cpp` embeddings with per-agent overrides and provider metadata in tools/CLI.
- Browser: add `scrollintoview` action to scroll refs into view before click/type. - Memory: new `clawdbot memory` CLI plus `memory_search`/`memory_get` tools with snippets + line ranges; index stored under `~/.clawdbot/memory/{agentId}.sqlite` with watch-on-by-default.
- Memory: embedding providers support OpenAI or local `node-llama-cpp`; config adds defaults + per-agent overrides, provider/fallback metadata surfaced in tools/CLI. - Agents: strengthen memory recall guidance; make workspace bootstrap truncation configurable (default 20k) with warnings; add default sub-agent model config.
- CLI/Tools: new `clawdbot memory` commands plus `memory_search`/`memory_get` tools returning snippets + line ranges and provider info. - Tools/Sandbox: add tool profiles + group shorthands; support tool-policy groups in `tools.sandbox.tools`; drop legacy `memory` shorthand; allow Docker bind mounts via `docker.binds`.
- Runtime: memory index stored under `~/.clawdbot/memory/{agentId}.sqlite` with watch-on-by-default; inline status replies now stay auth-gated while inline prompts continue to the agent. - Tools: add browser `scrollintoview` action; allow Claude/Gemini tool param aliases; allow thinking `xhigh` for GPT-5.2/Codex with safe downgrades.
- Doctor: rebuild Control UI assets when protocol schema is newer to avoid stale UI connect errors. (#786) — thanks @meaningfool. - Gateway/CLI: add Tailscale binary discovery, custom bind mode, and probe auth retry; add `clawdbot dashboard` auto-open flow; default native slash commands to `"auto"` with per-provider overrides.
- Cron: allow jobs to target a specific agent and expose agent selection in the macOS app + Control UI. - Auth/Onboarding: add Chutes OAuth (PKCE + refresh + onboarding choice); normalize API key inputs; default TUI onboarding to `deliver: false`.
- Discord: add `discord.allowBots` to permit bot-authored messages (still ignores its own messages) with docs warning about bot loops. (#802) — thanks @zknicker. - Providers: add `discord.allowBots`; trim legacy MiniMax M2 from default catalogs; route MiniMax vision to the Coding Plan VLM endpoint (also accepts `@/path/to/file.png` inputs).
- CLI/Onboarding: `clawdbot dashboard` prints/copies the tokenized Control UI link and opens it; onboarding now auto-opens the dashboard with your token and keeps the link in the summary. - Gateway: allow Tailscale Serve identity headers to satisfy token auth; rebuild Control UI assets when protocol schema is newer.
- Commands: native slash commands now default to `"auto"` (on for Discord/Telegram, off for Slack) with per-provider overrides (`discord/telegram/slack.commands.native`) and docs updated. - Heartbeat: default `ackMaxChars` to 300 so short `HEARTBEAT_OK` replies stay internal.
- Sandbox: allow Docker bind mounts via `docker.binds`; merges global + per-agent binds (per-agent ignored under shared scope) for custom host paths. (#790 — thanks @akonyer)
- Models: add Synthetic provider (Anthropic-compatible) and trim legacy MiniMax M2 from default catalogs. (#811 — thanks @siraht)
### Fixes ### Fixes
- Auto-reply: inline `/status` now honors allowlists (authorized stripped + replied inline; unauthorized leaves text for the agent) to match command gating tests. - Models/Providers: treat credential validation failures as auth errors to trigger fallback; normalize `${ENV_VAR}` apiKey values and auto-fill missing provider keys; preserve explicit GitHub Copilot provider config + agent-dir auth profiles.
- Models: normalize `${ENV_VAR}` apiKey config values and auto-fill missing provider `apiKey` from env/auth when custom provider models are configured (fixes MiniMax “Unknown model” on fresh installs). - Gemini: normalize Gemini 3 ids to preview variants; strip Gemini CLI tool call/response ids; downgrade missing `thought_signature`; strip Claude `msg_*` thought_signature fields to avoid base64 decode errors.
- Models/Tools: include `MiniMax-VL-01` in implicit MiniMax provider so image pairing uses a real vision model. - MiniMax: strip malformed tool invocation XML; include `MiniMax-VL-01` in implicit provider for image pairing.
- Telegram: show typing indicator in General forum topics. (#779) — thanks @azade-c. - Anthropic: merge consecutive user turns (preserve newest metadata) before validation to avoid incorrect role errors.
- Discord: keep reasoning italics intact when messages are chunked, so reasoning stays italic across multi-part sends. - Messaging: enforce context isolation for message tool sends; keep typing indicators alive during tool execution.
- Models: keep explicit GitHub Copilot provider config and honor agent-dir auth profiles for auto-injection. (#705) — thanks @TAGOOZ. - Auto-reply: `/status` allowlist behavior, reasoning-tag enforcement on fallback, and system-event enqueueing for elevated/reasoning toggles.
- Auto-reply: restore 300-char heartbeat ack limit and keep >300 char replies instead of dropping them; adjust long heartbeat test content accordingly. - Auto-reply: explain how to enable `/bash` when its disabled; add security notes + FAQ. (#722) — thanks @vrknetha.
- Gateway: `agents.list` now honors explicit `agents.list` config without pulling stray agents from disk; GitHub Copilot CLI auth path uses the updated provider build. - Auto-reply: resolve ambiguous `/model` matches; fix streaming block reply media handling; keep >300 char heartbeat replies instead of dropping.
- Google: apply patched pi-ai `google-gemini-cli` function call handling (strips ids) after upgrading to pi-ai 0.43.0. - Discord/Slack: centralize reply-thread planning; fix autoThread routing + add per-channel autoThread; avoid duplicate listeners; keep reasoning italics intact; allow clearing channel parents via message tool.
- Tools: allow Gemini/Claude-style aliases (`file_path`, `old_string`, `new_string`) in read/write/edit schemas while still enforcing required params at runtime to avoid validation loops. - Telegram: preserve forum topic thread ids, persist polling offsets, respect account bindings in webhook mode, and show typing indicator in General topics.
- Auto-reply: elevated/reasoning toggles now enqueue system events so the model sees the mode change immediately. - Slack: accept slash commands with or without leading `/` for custom command configs.
- Tools: keep `image` available in sandbox and fail over when image models return empty output (fixes “(no text returned)”). - Cron: persist disabled jobs correctly; accept `jobId` aliases for update/run/remove params.
- Discord: add per-channel `autoThread` to auto-create threads for top-level messages. (#800) — thanks @davidguttman. - Gateway/CLI: honor `CLAWDBOT_LAUNCHD_LABEL` / `CLAWDBOT_SYSTEMD_UNIT` overrides; `agents.list` respects explicit config; reduce noisy loopback WS logs during tests; run `clawdbot doctor --non-interactive` during updates.
- Discord: fix autoThread routing so replies stay in the created thread and avoid reply references. (#807) — thanks @davidguttman. - Onboarding/Control UI: refuse invalid configs (run doctor first); quote Windows browser URLs for OAuth; keep chat scroll position unless the user is near the bottom.
- Onboarding: TUI defaults to `deliver: false` to avoid cross-provider auto-delivery leaks; onboarding spawns the TUI with explicit `deliver: false`. (#791 — thanks @roshanasingh4) - Tools/UI: harden tool input schemas for strict providers; drop null-only union variants for Gemini schema cleanup; treat `maxChars: 0` as unlimited; keep TUI last streamed response instead of "(no output)".
- Connections UI: polish multi-account account cards.
### Maintenance
- Dependencies: bump Pi packages to 0.45.3 and refresh patched pi-ai.
- Testing: update Vitest + browser-playwright to 4.0.17.
- Docs: add Amazon Bedrock provider notes and link from models/FAQ.
## 2026.1.11 ## 2026.1.11

View File

@ -247,7 +247,7 @@ Send these in WhatsApp/Telegram/Slack/Microsoft Teams/WebChat (group commands ar
- `/status` — compact session status (model + tokens, cost when available) - `/status` — compact session status (model + tokens, cost when available)
- `/new` or `/reset` — reset the session - `/new` or `/reset` — reset the session
- `/compact` — compact session context (summary) - `/compact` — compact session context (summary)
- `/think <level>` — off|minimal|low|medium|high - `/think <level>` — off|minimal|low|medium|high|xhigh (GPT-5.2 + Codex models only)
- `/verbose on|off` - `/verbose on|off`
- `/cost on|off` — append per-response token/cost usage lines - `/cost on|off` — append per-response token/cost usage lines
- `/restart` — restart the gateway (owner-only in groups) - `/restart` — restart the gateway (owner-only in groups)

View File

@ -105,7 +105,7 @@ Isolation options (only for `session=isolated`):
### Model and thinking overrides ### Model and thinking overrides
Isolated jobs (`agentTurn`) can override the model and thinking level: Isolated jobs (`agentTurn`) can override the model and thinking level:
- `model`: Provider/model string (e.g., `anthropic/claude-sonnet-4-20250514`) or alias (e.g., `opus`) - `model`: Provider/model string (e.g., `anthropic/claude-sonnet-4-20250514`) or alias (e.g., `opus`)
- `thinking`: Thinking level (`off`, `minimal`, `low`, `medium`, `high`) - `thinking`: Thinking level (`off`, `minimal`, `low`, `medium`, `high`, `xhigh`; GPT-5.2 + Codex models only)
Note: You can set `model` on main-session jobs too, but it changes the shared main Note: You can set `model` on main-session jobs too, but it changes the shared main
session model. We recommend model overrides only for isolated jobs to avoid session model. We recommend model overrides only for isolated jobs to avoid

71
docs/bedrock.md Normal file
View File

@ -0,0 +1,71 @@
---
summary: "Use Amazon Bedrock (Converse API) models with Clawdbot"
read_when:
- You want to use Amazon Bedrock models with Clawdbot
- You need AWS credential/region setup for model calls
---
# Amazon Bedrock
Clawdbot can use **Amazon Bedrock** models via piais **Bedrock Converse**
streaming provider. Bedrock auth uses the **AWS SDK default credential chain**,
not an API key.
## What piai supports
- Provider: `amazon-bedrock`
- API: `bedrock-converse-stream`
- Auth: AWS credentials (env vars, shared config, or instance role)
- Region: `AWS_REGION` or `AWS_DEFAULT_REGION` (default: `us-east-1`)
## Setup (manual)
1) Ensure AWS credentials are available on the **gateway host**:
```bash
export AWS_ACCESS_KEY_ID="AKIA..."
export AWS_SECRET_ACCESS_KEY="..."
export AWS_REGION="us-east-1"
# Optional:
export AWS_SESSION_TOKEN="..."
export AWS_PROFILE="your-profile"
```
2) Add a Bedrock provider and model to your config:
```json5
{
models: {
providers: {
"amazon-bedrock": {
baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com",
api: "bedrock-converse-stream",
models: [
{
id: "anthropic.claude-3-7-sonnet-20250219-v1:0",
name: "Claude 3.7 Sonnet (Bedrock)",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 200000,
maxTokens: 8192
}
]
}
}
},
agents: {
defaults: {
model: { primary: "amazon-bedrock/anthropic.claude-3-7-sonnet-20250219-v1:0" }
}
}
}
```
## Notes
- Bedrock requires **model access** enabled in your AWS account/region.
- If you use profiles, set `AWS_PROFILE` on the gateway host.
- Reasoning support depends on the model; check the Bedrock model card for
current capabilities.
- If you prefer a managed key flow, you can also place an OpenAIcompatible
proxy in front of Bedrock and configure it as an OpenAI provider instead.

View File

@ -398,7 +398,7 @@ Required:
Options: Options:
- `--to <dest>` (for session key and optional delivery) - `--to <dest>` (for session key and optional delivery)
- `--session-id <id>` - `--session-id <id>`
- `--thinking <off|minimal|low|medium|high>` - `--thinking <off|minimal|low|medium|high|xhigh>` (GPT-5.2 + Codex models only)
- `--verbose <on|off>` - `--verbose <on|off>`
- `--provider <whatsapp|telegram|discord|slack|signal|imessage>` - `--provider <whatsapp|telegram|discord|slack|signal|imessage>`
- `--local` - `--local`

View File

@ -39,7 +39,7 @@ Add `hooks.gmail.model` config option to specify an optional cheaper model for G
| Field | Type | Default | Description | | Field | Type | Default | Description |
|-------|------|---------|-------------| |-------|------|---------|-------------|
| `hooks.gmail.model` | `string` | (none) | Model to use for Gmail hook processing. Accepts `provider/model` refs or aliases from `agents.defaults.models`. | | `hooks.gmail.model` | `string` | (none) | Model to use for Gmail hook processing. Accepts `provider/model` refs or aliases from `agents.defaults.models`. |
| `hooks.gmail.thinking` | `string` | (inherited) | Thinking level override (`off`, `minimal`, `low`, `medium`, `high`). If unset, inherits from `agents.defaults.thinkingDefault` or model's default. | | `hooks.gmail.thinking` | `string` | (inherited) | Thinking level override (`off`, `minimal`, `low`, `medium`, `high`, `xhigh`; GPT-5.2 + Codex models only). If unset, inherits from `agents.defaults.thinkingDefault` or model's default. |
### Alias Support ### Alias Support

View File

@ -569,7 +569,9 @@ Inbound messages are routed to an agent via bindings.
- `name`: display name for the agent. - `name`: display name for the agent.
- `workspace`: default `~/clawd-<agentId>` (for `main`, falls back to `agents.defaults.workspace`). - `workspace`: default `~/clawd-<agentId>` (for `main`, falls back to `agents.defaults.workspace`).
- `agentDir`: default `~/.clawdbot/agents/<agentId>/agent`. - `agentDir`: default `~/.clawdbot/agents/<agentId>/agent`.
- `model`: per-agent default model (provider/model), overrides `agents.defaults.model` for that agent. - `model`: per-agent default model, overrides `agents.defaults.model` for that agent.
- string form: `"provider/model"`, overrides only `agents.defaults.model.primary`
- object form: `{ primary, fallbacks }` (fallbacks override `agents.defaults.model.fallbacks`; `[]` disables global fallbacks for that agent)
- `identity`: per-agent name/theme/emoji (used for mention patterns + ack reactions). - `identity`: per-agent name/theme/emoji (used for mention patterns + ack reactions).
- `groupChat`: per-agent mention-gating (`mentionPatterns`). - `groupChat`: per-agent mention-gating (`mentionPatterns`).
- `sandbox`: per-agent sandbox config (overrides `agents.defaults.sandbox`). - `sandbox`: per-agent sandbox config (overrides `agents.defaults.sandbox`).
@ -583,6 +585,7 @@ Inbound messages are routed to an agent via bindings.
- `subagents`: per-agent sub-agent defaults. - `subagents`: per-agent sub-agent defaults.
- `allowAgents`: allowlist of agent ids for `sessions_spawn` from this agent (`["*"]` = allow any; default: only same agent) - `allowAgents`: allowlist of agent ids for `sessions_spawn` from this agent (`["*"]` = allow any; default: only same agent)
- `tools`: per-agent tool restrictions (applied before sandbox tool policy). - `tools`: per-agent tool restrictions (applied before sandbox tool policy).
- `profile`: base tool profile (applied before allow/deny)
- `allow`: array of allowed tool names - `allow`: array of allowed tool names
- `deny`: array of denied tool names (deny wins) - `deny`: array of denied tool names (deny wins)
- `agents.defaults`: shared agent defaults (model, workspace, sandbox, etc.). - `agents.defaults`: shared agent defaults (model, workspace, sandbox, etc.).
@ -1507,6 +1510,34 @@ Legacy: `tools.bash` is still accepted as an alias.
- `archiveAfterMinutes`: auto-archive sub-agent sessions after N minutes (default 60; set `0` to disable) - `archiveAfterMinutes`: auto-archive sub-agent sessions after N minutes (default 60; set `0` to disable)
- Per-subagent tool policy: `tools.subagents.tools.allow` / `tools.subagents.tools.deny` (deny wins) - Per-subagent tool policy: `tools.subagents.tools.allow` / `tools.subagents.tools.deny` (deny wins)
`tools.profile` sets a **base tool allowlist** before `tools.allow`/`tools.deny`:
- `minimal`: `session_status` only
- `coding`: `group:fs`, `group:runtime`, `group:sessions`, `group:memory`, `image`
- `messaging`: `group:messaging`, `sessions_list`, `sessions_history`, `sessions_send`, `session_status`
- `full`: no restriction (same as unset)
Per-agent override: `agents.list[].tools.profile`.
Example (messaging-only by default, allow Slack + Discord tools too):
```json5
{
tools: {
profile: "messaging",
allow: ["slack", "discord"]
}
}
```
Example (coding profile, but deny exec/process everywhere):
```json5
{
tools: {
profile: "coding",
deny: ["group:runtime"]
}
}
```
`tools.allow` / `tools.deny` configure 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**. This is applied even when the Docker sandbox is **off**.
@ -1517,6 +1548,17 @@ Example (disable browser/canvas everywhere):
} }
``` ```
Tool groups (shorthands) work in **global** and **per-agent** tool policies:
- `group:runtime`: `exec`, `bash`, `process`
- `group:fs`: `read`, `write`, `edit`, `apply_patch`
- `group:sessions`: `sessions_list`, `sessions_history`, `sessions_send`, `sessions_spawn`, `session_status`
- `group:memory`: `memory_search`, `memory_get`
- `group:ui`: `browser`, `canvas`
- `group:automation`: `cron`, `gateway`
- `group:messaging`: `message`
- `group:nodes`: `nodes`
- `group:clawdbot`: all built-in Clawdbot tools (excludes provider plugins)
`tools.elevated` controls elevated (host) exec access: `tools.elevated` controls elevated (host) exec access:
- `enabled`: allow elevated mode (default true) - `enabled`: allow elevated mode (default true)
- `allowFrom`: per-provider allowlists (empty = disabled) - `allowFrom`: per-provider allowlists (empty = disabled)

View File

@ -50,6 +50,7 @@ See [Sandboxing](/gateway/sandboxing) for the full matrix (scope, workspace moun
## Tool policy: which tools exist/are callable ## Tool policy: which tools exist/are callable
Two layers matter: Two layers matter:
- **Tool profile**: `tools.profile` and `agents.list[].tools.profile` (base allowlist)
- **Global/per-agent tool policy**: `tools.allow`/`tools.deny` and `agents.list[].tools.allow`/`agents.list[].tools.deny` - **Global/per-agent tool policy**: `tools.allow`/`tools.deny` and `agents.list[].tools.allow`/`agents.list[].tools.deny`
- **Sandbox tool policy** (only applies when sandboxed): `tools.sandbox.tools.allow`/`tools.sandbox.tools.deny` and `agents.list[].tools.sandbox.tools.*` - **Sandbox tool policy** (only applies when sandboxed): `tools.sandbox.tools.allow`/`tools.sandbox.tools.deny` and `agents.list[].tools.sandbox.tools.*`
@ -59,7 +60,7 @@ Rules of thumb:
### Tool groups (shorthands) ### Tool groups (shorthands)
For sandbox tool policy, you can use `group:*` entries that expand to multiple tools: Tool policies (global, agent, sandbox) support `group:*` entries that expand to multiple tools:
```json5 ```json5
{ {
@ -78,6 +79,11 @@ Available groups:
- `group:fs`: `read`, `write`, `edit`, `apply_patch` - `group:fs`: `read`, `write`, `edit`, `apply_patch`
- `group:sessions`: `sessions_list`, `sessions_history`, `sessions_send`, `sessions_spawn`, `session_status` - `group:sessions`: `sessions_list`, `sessions_history`, `sessions_send`, `sessions_spawn`, `session_status`
- `group:memory`: `memory_search`, `memory_get` - `group:memory`: `memory_search`, `memory_get`
- `group:ui`: `browser`, `canvas`
- `group:automation`: `cron`, `gateway`
- `group:messaging`: `message`
- `group:nodes`: `nodes`
- `group:clawdbot`: all built-in Clawdbot tools (excludes provider plugins)
## Elevated: exec-only “run on host” ## Elevated: exec-only “run on host”

View File

@ -106,6 +106,28 @@ For debugging “why is this blocked?”, see [Sandbox vs Tool Policy vs Elevate
--- ---
### Example 2b: Global coding profile + messaging-only agent
```json
{
"tools": { "profile": "coding" },
"agents": {
"list": [
{
"id": "support",
"tools": { "profile": "messaging", "allow": ["slack"] }
}
]
}
}
```
**Result:**
- default agents get coding tools
- `support` agent is messaging-only (+ Slack tool)
---
### Example 3: Different Sandbox Modes per Agent ### Example 3: Different Sandbox Modes per Agent
```json ```json
@ -165,22 +187,29 @@ agents.list[].sandbox.prune.* > agents.defaults.sandbox.prune.*
### Tool Restrictions ### Tool Restrictions
The filtering order is: The filtering order is:
1. **Global tool policy** (`tools.allow` / `tools.deny`) 1. **Tool profile** (`tools.profile` or `agents.list[].tools.profile`)
2. **Agent-specific tool policy** (`agents.list[].tools`) 2. **Global tool policy** (`tools.allow` / `tools.deny`)
3. **Sandbox tool policy** (`tools.sandbox.tools` or `agents.list[].tools.sandbox.tools`) 3. **Agent-specific tool policy** (`agents.list[].tools`)
4. **Subagent tool policy** (`tools.subagents.tools`, if applicable) 4. **Sandbox tool policy** (`tools.sandbox.tools` or `agents.list[].tools.sandbox.tools`)
5. **Subagent tool policy** (`tools.subagents.tools`, if applicable)
Each level can further restrict tools, but cannot grant back denied tools from earlier levels. Each level can further restrict tools, but cannot grant back denied tools from earlier levels.
If `agents.list[].tools.sandbox.tools` is set, it replaces `tools.sandbox.tools` for that agent. If `agents.list[].tools.sandbox.tools` is set, it replaces `tools.sandbox.tools` for that agent.
If `agents.list[].tools.profile` is set, it overrides `tools.profile` for that agent.
### Tool groups (shorthands) ### Tool groups (shorthands)
Sandbox tool policy supports `group:*` entries that expand to multiple concrete tools: Tool policies (global, agent, sandbox) support `group:*` entries that expand to multiple concrete tools:
- `group:runtime`: `exec`, `bash`, `process` - `group:runtime`: `exec`, `bash`, `process`
- `group:fs`: `read`, `write`, `edit`, `apply_patch` - `group:fs`: `read`, `write`, `edit`, `apply_patch`
- `group:sessions`: `sessions_list`, `sessions_history`, `sessions_send`, `sessions_spawn`, `session_status` - `group:sessions`: `sessions_list`, `sessions_history`, `sessions_send`, `sessions_spawn`, `session_status`
- `group:memory`: `memory_search`, `memory_get` - `group:memory`: `memory_search`, `memory_get`
- `group:ui`: `browser`, `canvas`
- `group:automation`: `cron`, `gateway`
- `group:messaging`: `message`
- `group:nodes`: `nodes`
- `group:clawdbot`: all built-in Clawdbot tools (excludes provider plugins)
### Elevated Mode ### Elevated Mode
`tools.elevated` is the global baseline (sender-based allowlist). `agents.list[].tools.elevated` can further restrict elevated for specific agents (both must allow). `tools.elevated` is the global baseline (sender-based allowlist). `agents.list[].tools.elevated` can further restrict elevated for specific agents (both must allow).

View File

@ -31,6 +31,7 @@ model as `provider/model`.
- [Z.AI](/providers/zai) - [Z.AI](/providers/zai)
- [GLM models](/providers/glm) - [GLM models](/providers/glm)
- [MiniMax](/providers/minimax) - [MiniMax](/providers/minimax)
- [Amazon Bedrock](/bedrock)
For the full provider catalog (xAI, Groq, Mistral, etc.) and advanced configuration, For the full provider catalog (xAI, Groq, Mistral, etc.) and advanced configuration,
see [Model providers](/concepts/model-providers). see [Model providers](/concepts/model-providers).

View File

@ -5,6 +5,109 @@ summary: "Frequently asked questions about Clawdbot setup, configuration, and us
Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS, multi-agent, OAuth/API keys, model failover). For runtime diagnostics, see [Troubleshooting](/gateway/troubleshooting). For the full config reference, see [Configuration](/gateway/configuration). Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS, multi-agent, OAuth/API keys, model failover). For runtime diagnostics, see [Troubleshooting](/gateway/troubleshooting). For the full config reference, see [Configuration](/gateway/configuration).
## Table of contents
- [What is Clawdbot?](#what-is-clawdbot)
- [What is Clawdbot, in one paragraph?](#what-is-clawdbot-in-one-paragraph)
- [Quick start and first-run setup](#quick-start-and-first-run-setup)
- [Whats the recommended way to install and set up Clawdbot?](#whats-the-recommended-way-to-install-and-set-up-clawdbot)
- [How do I open the dashboard after onboarding?](#how-do-i-open-the-dashboard-after-onboarding)
- [How do I authenticate the dashboard (token) on localhost vs remote?](#how-do-i-authenticate-the-dashboard-token-on-localhost-vs-remote)
- [What runtime do I need?](#what-runtime-do-i-need)
- [What does the onboarding wizard actually do?](#what-does-the-onboarding-wizard-actually-do)
- [How does Anthropic "setup-token" auth work?](#how-does-anthropic-setup-token-auth-work)
- [Do you support Claude subscription auth (Claude Code OAuth)?](#do-you-support-claude-subscription-auth-claude-code-oauth)
- [Is AWS Bedrock supported?](#is-aws-bedrock-supported)
- [How does Codex auth work?](#how-does-codex-auth-work)
- [Is a local model OK for casual chats?](#is-a-local-model-ok-for-casual-chats)
- [How do I keep hosted model traffic in a specific region?](#how-do-i-keep-hosted-model-traffic-in-a-specific-region)
- [Can I use Bun?](#can-i-use-bun)
- [Telegram: what goes in `allowFrom`?](#telegram-what-goes-in-allowfrom)
- [Can multiple people use one WhatsApp number with different Clawdbots?](#can-multiple-people-use-one-whatsapp-number-with-different-clawdbots)
- [Can I run a "fast chat" agent and an "Opus for coding" agent?](#can-i-run-a-fast-chat-agent-and-an-opus-for-coding-agent)
- [Does Homebrew work on Linux?](#does-homebrew-work-on-linux)
- [Can I switch between npm and git installs later?](#can-i-switch-between-npm-and-git-installs-later)
- [Skills and automation](#skills-and-automation)
- [How do I customize skills without keeping the repo dirty?](#how-do-i-customize-skills-without-keeping-the-repo-dirty)
- [Can I load skills from a custom folder?](#can-i-load-skills-from-a-custom-folder)
- [How can I use different models for different tasks?](#how-can-i-use-different-models-for-different-tasks)
- [How do I install skills on Linux?](#how-do-i-install-skills-on-linux)
- [Sandboxing and memory](#sandboxing-and-memory)
- [Is there a dedicated sandboxing doc?](#is-there-a-dedicated-sandboxing-doc)
- [How do I bind a host folder into the sandbox?](#how-do-i-bind-a-host-folder-into-the-sandbox)
- [How does memory work?](#how-does-memory-work)
- [Does semantic memory search require an OpenAI API key?](#does-semantic-memory-search-require-an-openai-api-key)
- [Where things live on disk](#where-things-live-on-disk)
- [Where does Clawdbot store its data?](#where-does-clawdbot-store-its-data)
- [How do I completely uninstall Clawdbot?](#how-do-i-completely-uninstall-clawdbot)
- [Can agents work outside the workspace?](#can-agents-work-outside-the-workspace)
- [Im in remote mode — where is the session store?](#im-in-remote-mode-where-is-the-session-store)
- [Config basics](#config-basics)
- [What format is the config? Where is it?](#what-format-is-the-config-where-is-it)
- [I set `gateway.bind: "lan"` (or `"tailnet"`) and now nothing listens / the UI says unauthorized](#i-set-gatewaybind-lan-or-tailnet-and-now-nothing-listens-the-ui-says-unauthorized)
- [Why do I need a token on localhost now?](#why-do-i-need-a-token-on-localhost-now)
- [Do I have to restart after changing config?](#do-i-have-to-restart-after-changing-config)
- [How do I run a central Gateway with specialized workers across devices?](#how-do-i-run-a-central-gateway-with-specialized-workers-across-devices)
- [Can the Clawdbot browser run headless?](#can-the-clawdbot-browser-run-headless)
- [Remote gateways + nodes](#remote-gateways-nodes)
- [How do commands propagate between Telegram, the gateway, and nodes?](#how-do-commands-propagate-between-telegram-the-gateway-and-nodes)
- [Do nodes run a gateway daemon?](#do-nodes-run-a-gateway-daemon)
- [Is there an API / RPC way to apply config?](#is-there-an-api-rpc-way-to-apply-config)
- [Whats a minimal “sane” config for a first install?](#whats-a-minimal-sane-config-for-a-first-install)
- [Env vars and .env loading](#env-vars-and-env-loading)
- [How does Clawdbot load environment variables?](#how-does-clawdbot-load-environment-variables)
- [“I started the Gateway via a daemon and my env vars disappeared.” What now?](#i-started-the-gateway-via-a-daemon-and-my-env-vars-disappeared-what-now)
- [Sessions & multiple chats](#sessions-multiple-chats)
- [How do I start a fresh conversation?](#how-do-i-start-a-fresh-conversation)
- [How do I completely reset Clawdbot (but keep it installed)?](#how-do-i-completely-reset-clawdbot-but-keep-it-installed)
- [Do I need to add a “bot account” to a WhatsApp group?](#do-i-need-to-add-a-bot-account-to-a-whatsapp-group)
- [Why doesnt Clawdbot reply in a group?](#why-doesnt-clawdbot-reply-in-a-group)
- [Do groups/threads share context with DMs?](#do-groupsthreads-share-context-with-dms)
- [Models: defaults, selection, aliases, switching](#models-defaults-selection-aliases-switching)
- [What is the “default model”?](#what-is-the-default-model)
- [How do I switch models on the fly (without restarting)?](#how-do-i-switch-models-on-the-fly-without-restarting)
- [Why do I see “Model … is not allowed” and then no reply?](#why-do-i-see-model-is-not-allowed-and-then-no-reply)
- [Are opus / sonnet / gpt builtin shortcuts?](#are-opus-sonnet-gpt-builtin-shortcuts)
- [How do I define/override model shortcuts (aliases)?](#how-do-i-defineoverride-model-shortcuts-aliases)
- [How do I add models from other providers like OpenRouter or Z.AI?](#how-do-i-add-models-from-other-providers-like-openrouter-or-zai)
- [Model failover and “All models failed”](#model-failover-and-all-models-failed)
- [How does failover work?](#how-does-failover-work)
- [What does this error mean?](#what-does-this-error-mean)
- [Fix checklist for `No credentials found for profile "anthropic:default"`](#fix-checklist-for-no-credentials-found-for-profile-anthropicdefault)
- [Why did it also try Google Gemini and fail?](#why-did-it-also-try-google-gemini-and-fail)
- [Auth profiles: what they are and how to manage them](#auth-profiles-what-they-are-and-how-to-manage-them)
- [What is an auth profile?](#what-is-an-auth-profile)
- [What are typical profile IDs?](#what-are-typical-profile-ids)
- [Can I control which auth profile is tried first?](#can-i-control-which-auth-profile-is-tried-first)
- [OAuth vs API key: whats the difference?](#oauth-vs-api-key-whats-the-difference)
- [Gateway: ports, “already running”, and remote mode](#gateway-ports-already-running-and-remote-mode)
- [What port does the Gateway use?](#what-port-does-the-gateway-use)
- [Why does `clawdbot daemon status` say `Runtime: running` but `RPC probe: failed`?](#why-does-clawdbot-daemon-status-say-runtime-running-but-rpc-probe-failed)
- [Why does `clawdbot daemon status` show `Config (cli)` and `Config (daemon)` different?](#why-does-clawdbot-daemon-status-show-config-cli-and-config-daemon-different)
- [What does “another gateway instance is already listening” mean?](#what-does-another-gateway-instance-is-already-listening-mean)
- [How do I run Clawdbot in remote mode (client connects to a Gateway elsewhere)?](#how-do-i-run-clawdbot-in-remote-mode-client-connects-to-a-gateway-elsewhere)
- [The Control UI says “unauthorized” (or keeps reconnecting). What now?](#the-control-ui-says-unauthorized-or-keeps-reconnecting-what-now)
- [I set `gateway.bind: "tailnet"` but it cant bind / nothing listens](#i-set-gatewaybind-tailnet-but-it-cant-bind-nothing-listens)
- [Can I run multiple Gateways on the same host?](#can-i-run-multiple-gateways-on-the-same-host)
- [Logging and debugging](#logging-and-debugging)
- [Where are logs?](#where-are-logs)
- [How do I start/stop/restart the Gateway daemon?](#how-do-i-startstoprestart-the-gateway-daemon)
- [Whats the fastest way to get more details when something fails?](#whats-the-fastest-way-to-get-more-details-when-something-fails)
- [Media & attachments](#media-attachments)
- [My skill generated an image/PDF, but nothing was sent](#my-skill-generated-an-imagepdf-but-nothing-was-sent)
- [Security and access control](#security-and-access-control)
- [Is it safe to expose Clawdbot to inbound DMs?](#is-it-safe-to-expose-clawdbot-to-inbound-dms)
- [WhatsApp: will it message my contacts? How does pairing work?](#whatsapp-will-it-message-my-contacts-how-does-pairing-work)
- [Chat commands, aborting tasks, and “it wont stop”](#chat-commands-aborting-tasks-and-it-wont-stop)
- [How do I stop/cancel a running task?](#how-do-i-stopcancel-a-running-task)
- [Why does `/bash` say its disabled?](#why-does-bash-say-its-disabled)
- [Why does it feel like the bot “ignores” rapidfire messages?](#why-does-it-feel-like-the-bot-ignores-rapidfire-messages)
- [Common troubleshooting](#common-troubleshooting)
- [“All models failed” — what should I check first?](#all-models-failed-what-should-i-check-first)
- [Im running on my personal WhatsApp number — why is self-chat weird?](#im-running-on-my-personal-whatsapp-number-why-is-self-chat-weird)
- [WhatsApp logged me out. How do I reauth?](#whatsapp-logged-me-out-how-do-i-reauth)
- [Build errors on `main` — whats the standard fix path?](#build-errors-on-main-whats-the-standard-fix-path)
## First 60 seconds if something's broken ## First 60 seconds if something's broken
1) **Quick status (first check)** 1) **Quick status (first check)**
@ -60,7 +163,7 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
Clawdbot is a personal AI assistant you run on your own devices. It replies on the messaging surfaces you already use (WhatsApp, Telegram, Slack, Discord, Signal, iMessage, WebChat) and can also do voice + a live Canvas on supported platforms. The **Gateway** is the alwayson control plane; the assistant is the product. Clawdbot is a personal AI assistant you run on your own devices. It replies on the messaging surfaces you already use (WhatsApp, Telegram, Slack, Discord, Signal, iMessage, WebChat) and can also do voice + a live Canvas on supported platforms. The **Gateway** is the alwayson control plane; the assistant is the product.
## Quick start and firstrun setup ## Quick start and first-run setup
### Whats the recommended way to install and set up Clawdbot? ### Whats the recommended way to install and set up Clawdbot?
@ -128,7 +231,7 @@ Yes. Clawdbot can **reuse Claude Code CLI credentials** (OAuth) and also support
### Is AWS Bedrock supported? ### Is AWS Bedrock supported?
Not currently. Clawdbot doesnt ship a Bedrock provider today. If you must use Bedrock, the common workaround is an OpenAIcompatible proxy in front of Bedrock, then point Clawdbot at that endpoint. See [Model providers](/providers/models) and [Model providers (full list)](/concepts/model-providers). Yes — via piais **Amazon Bedrock (Converse)** provider with **manual config**. You must supply AWS credentials/region on the gateway host and add a Bedrock provider entry in your models config. See [Amazon Bedrock](/bedrock) and [Model providers](/providers/models). If you prefer a managed key flow, an OpenAIcompatible proxy in front of Bedrock is still a valid option.
### How does Codex auth work? ### How does Codex auth work?
@ -196,6 +299,8 @@ clawdbot daemon restart
Doctor detects a gateway service entrypoint mismatch and offers to rewrite the service config to match the current install (use `--repair` in automation). Doctor detects a gateway service entrypoint mismatch and offers to rewrite the service config to match the current install (use `--repair` in automation).
## Skills and automation
### How do I customize skills without keeping the repo dirty? ### How do I customize skills without keeping the repo dirty?
Use managed overrides instead of editing the repo copy. Put your changes in `~/.clawdbot/skills/<name>/SKILL.md` (or add a folder via `skills.load.extraDirs` in `~/.clawdbot/clawdbot.json`). Precedence is `<workspace>/skills` > `~/.clawdbot/skills` > bundled, so managed overrides win without touching git. Only upstream-worthy edits should live in the repo and go out as PRs. Use managed overrides instead of editing the repo copy. Put your changes in `~/.clawdbot/skills/<name>/SKILL.md` (or add a folder via `skills.load.extraDirs` in `~/.clawdbot/clawdbot.json`). Precedence is `<workspace>/skills` > `~/.clawdbot/skills` > bundled, so managed overrides win without touching git. Only upstream-worthy edits should live in the repo and go out as PRs.
@ -240,6 +345,8 @@ clawdhub update --all
ClawdHub installs into `./skills` under your current directory (or falls back to your configured Clawdbot workspace); Clawdbot treats that as `<workspace>/skills` on the next session. For shared skills across agents, place them in `~/.clawdbot/skills/<name>/SKILL.md`. Some skills expect binaries installed via Homebrew; on Linux that means Linuxbrew (see the Homebrew Linux FAQ entry above). See [Skills](/tools/skills) and [ClawdHub](/tools/clawdhub). ClawdHub installs into `./skills` under your current directory (or falls back to your configured Clawdbot workspace); Clawdbot treats that as `<workspace>/skills` on the next session. For shared skills across agents, place them in `~/.clawdbot/skills/<name>/SKILL.md`. Some skills expect binaries installed via Homebrew; on Linux that means Linuxbrew (see the Homebrew Linux FAQ entry above). See [Skills](/tools/skills) and [ClawdHub](/tools/clawdhub).
## Sandboxing and memory
### Is there a dedicated sandboxing doc? ### Is there a dedicated sandboxing doc?
Yes. See [Sandboxing](/gateway/sandboxing). For Docker-specific setup (full gateway in Docker or sandbox images), see [Docker](/install/docker). Yes. See [Sandboxing](/gateway/sandboxing). For Docker-specific setup (full gateway in Docker or sandbox images), see [Docker](/install/docker).
@ -957,6 +1064,26 @@ Slash commands overview: see [Slash commands](/tools/slash-commands).
Most commands must be sent as a **standalone** message that starts with `/`, but a few shortcuts (like `/status`) also work inline for allowlisted senders. Most commands must be sent as a **standalone** message that starts with `/`, but a few shortcuts (like `/status`) also work inline for allowlisted senders.
### Why does `/bash` say its disabled?
The host shell command (`! <cmd>` or `/bash <cmd>`) is **disabled by default** because it runs directly on the gateway host. To enable it, update your config and restart the Gateway:
```json5
{
commands: { bash: true },
tools: {
elevated: {
enabled: true,
allowFrom: {
"<provider>": ["<sender-id>"]
}
}
}
}
```
Keep the allowlist tight and avoid enabling it in public rooms. See [Slash commands](/tools/slash-commands) and [Security](/gateway/security).
### Why does it feel like the bot “ignores” rapidfire messages? ### Why does it feel like the bot “ignores” rapidfire messages?
Queue mode controls how new messages interact with an inflight run. Use `/queue` to change modes: Queue mode controls how new messages interact with an inflight run. Use `/queue` to change modes:

View File

@ -38,7 +38,7 @@ clawdbot agent --to +15555550123 --message "Summon reply" --deliver
- `--local`: run locally (requires provider keys in your shell) - `--local`: run locally (requires provider keys in your shell)
- `--deliver`: send the reply to the chosen provider (requires `--to`) - `--deliver`: send the reply to the chosen provider (requires `--to`)
- `--provider`: `whatsapp|telegram|discord|slack|signal|imessage` (default: `whatsapp`) - `--provider`: `whatsapp|telegram|discord|slack|signal|imessage` (default: `whatsapp`)
- `--thinking <off|minimal|low|medium|high>`: persist thinking level - `--thinking <off|minimal|low|medium|high|xhigh>`: persist thinking level (GPT-5.2 + Codex models only)
- `--verbose <on|off>`: persist verbose level - `--verbose <on|off>`: persist verbose level
- `--timeout <seconds>`: override agent timeout - `--timeout <seconds>`: override agent timeout
- `--json`: output structured JSON - `--json`: output structured JSON

View File

@ -12,6 +12,8 @@ read_when:
- Directive forms: `/elevated on`, `/elevated off`, `/elev on`, `/elev off`. - Directive forms: `/elevated on`, `/elevated off`, `/elev on`, `/elev off`.
- Only `on|off` are accepted; anything else returns a hint and does not change state. - Only `on|off` are accepted; anything else returns a hint and does not change state.
Security note: enabling elevated access or `/bash` effectively grants host shell access. Keep allowlists tight and avoid enabling it in public rooms.
## What it controls (and what it doesnt) ## What it controls (and what it doesnt)
- **Availability gates**: `tools.elevated` is the global baseline. `agents.list[].tools.elevated` can further restrict elevated per agent (both must allow). - **Availability gates**: `tools.elevated` is the global baseline. `agents.list[].tools.elevated` can further restrict elevated per agent (both must allow).
- **Per-session state**: `/elevated on|off` sets the elevated level for the current session key. - **Per-session state**: `/elevated on|off` sets the elevated level for the current session key.

View File

@ -22,6 +22,77 @@ You can globally allow/deny tools via `tools.allow` / `tools.deny` in `clawdbot.
} }
``` ```
## Tool profiles (base allowlist)
`tools.profile` sets a **base tool allowlist** before `tools.allow`/`tools.deny`.
Per-agent override: `agents.list[].tools.profile`.
Profiles:
- `minimal`: `session_status` only
- `coding`: `group:fs`, `group:runtime`, `group:sessions`, `group:memory`, `image`
- `messaging`: `group:messaging`, `sessions_list`, `sessions_history`, `sessions_send`, `session_status`
- `full`: no restriction (same as unset)
Example (messaging-only by default, allow Slack + Discord tools too):
```json5
{
tools: {
profile: "messaging",
allow: ["slack", "discord"]
}
}
```
Example (coding profile, but deny exec/process everywhere):
```json5
{
tools: {
profile: "coding",
deny: ["group:runtime"]
}
}
```
Example (global coding profile, messaging-only support agent):
```json5
{
tools: { profile: "coding" },
agents: {
list: [
{
id: "support",
tools: { profile: "messaging", allow: ["slack"] }
}
]
}
}
```
## Tool groups (shorthands)
Tool policies (global, agent, sandbox) support `group:*` entries that expand to multiple tools.
Use these in `tools.allow` / `tools.deny`.
Available groups:
- `group:runtime`: `exec`, `bash`, `process`
- `group:fs`: `read`, `write`, `edit`, `apply_patch`
- `group:sessions`: `sessions_list`, `sessions_history`, `sessions_send`, `sessions_spawn`, `session_status`
- `group:memory`: `memory_search`, `memory_get`
- `group:ui`: `browser`, `canvas`
- `group:automation`: `cron`, `gateway`
- `group:messaging`: `message`
- `group:nodes`: `nodes`
- `group:clawdbot`: all built-in Clawdbot tools (excludes provider plugins)
Example (allow only file tools + browser):
```json5
{
tools: {
allow: ["group:fs", "browser"]
}
}
```
## Plugins + tools ## Plugins + tools
Plugins can register **additional tools** (and CLI commands) beyond the core set. Plugins can register **additional tools** (and CLI commands) beyond the core set.

View File

@ -44,6 +44,7 @@ They run immediately, are stripped before the model sees the message, and the re
- Set `discord.commands.native`, `telegram.commands.native`, or `slack.commands.native` to override per provider (bool or `"auto"`). - Set `discord.commands.native`, `telegram.commands.native`, or `slack.commands.native` to override per provider (bool or `"auto"`).
- `false` clears previously registered commands on Discord/Telegram at startup. Slack commands are managed in the Slack app and are not removed automatically. - `false` clears previously registered commands on Discord/Telegram at startup. Slack commands are managed in the Slack app and are not removed automatically.
- `commands.bash` (default `false`) enables `! <cmd>` to run host shell commands (`/bash <cmd>` is an alias; requires `tools.elevated` allowlists). - `commands.bash` (default `false`) enables `! <cmd>` to run host shell commands (`/bash <cmd>` is an alias; requires `tools.elevated` allowlists).
- When `commands.bash` is `false`, `/bash` replies with a short enablement hint (config keys + allowlist).
- `commands.bashForegroundMs` (default `2000`) controls how long bash waits before switching to background mode (`0` backgrounds immediately). - `commands.bashForegroundMs` (default `2000`) controls how long bash waits before switching to background mode (`0` backgrounds immediately).
- `commands.config` (default `false`) enables `/config` (reads/writes `clawdbot.json`). - `commands.config` (default `false`) enables `/config` (reads/writes `clawdbot.json`).
- `commands.debug` (default `false`) enables `/debug` (runtime-only overrides). - `commands.debug` (default `false`) enables `/debug` (runtime-only overrides).
@ -65,7 +66,7 @@ Text + native (when enabled):
- `/activation mention|always` (groups only) - `/activation mention|always` (groups only)
- `/send on|off|inherit` (owner-only) - `/send on|off|inherit` (owner-only)
- `/reset` or `/new` - `/reset` or `/new`
- `/think <level>` (aliases: `/thinking`, `/t`) - `/think <off|minimal|low|medium|high|xhigh>` (GPT-5.2 + Codex models only; aliases: `/thinking`, `/t`)
- `/verbose on|off` (alias: `/v`) - `/verbose on|off` (alias: `/v`)
- `/reasoning on|off|stream` (alias: `/reason`; when on, sends a separate message prefixed `Reasoning:`; `stream` = Telegram draft only) - `/reasoning on|off|stream` (alias: `/reason`; when on, sends a separate message prefixed `Reasoning:`; `stream` = Telegram draft only)
- `/elevated on|off` (alias: `/elev`) - `/elevated on|off` (alias: `/elev`)
@ -90,6 +91,10 @@ Notes:
- **Inline shortcuts (allowlisted senders only):** `/help`, `/commands`, `/status` (`/usage`), `/whoami` (`/id`) also work when embedded in text. - **Inline shortcuts (allowlisted senders only):** `/help`, `/commands`, `/status` (`/usage`), `/whoami` (`/id`) also work when embedded in text.
- Unauthorized command-only messages are silently ignored, and inline `/...` tokens are treated as plain text. - Unauthorized command-only messages are silently ignored, and inline `/...` tokens are treated as plain text.
## Security note: `!` / `/bash`
`! <cmd>` and `/bash <cmd>` run **directly on the gateway host** as the gateway user. Keep this disabled unless you fully trust the sender and have locked down allowlists. Treat it like giving someone SSH access to the host.
## Usage vs cost (what shows where) ## Usage vs cost (what shows where)
- **Provider usage/quota** (example: “Claude 80% left”) shows up in `/status` when provider usage tracking is enabled. - **Provider usage/quota** (example: “Claude 80% left”) shows up in `/status` when provider usage tracking is enabled.

View File

@ -7,11 +7,12 @@ read_when:
## What it does ## What it does
- Inline directive in any inbound body: `/t <level>`, `/think:<level>`, or `/thinking <level>`. - Inline directive in any inbound body: `/t <level>`, `/think:<level>`, or `/thinking <level>`.
- Levels (aliases): `off | minimal | low | medium | high` - Levels (aliases): `off | minimal | low | medium | high | xhigh` (GPT-5.2 + Codex models only)
- minimal → “think” - minimal → “think”
- low → “think hard” - low → “think hard”
- medium → “think harder” - medium → “think harder”
- high → “ultrathink” (max budget) - high → “ultrathink” (max budget)
- xhigh → “ultrathink+” (GPT-5.2 + Codex models only)
- `highest`, `max` map to `high`. - `highest`, `max` map to `high`.
## Resolution order ## Resolution order

View File

@ -50,7 +50,7 @@ Use SSH tunneling or Tailscale to reach the Gateway WS.
- `/agent <id>` (or `/agents`) - `/agent <id>` (or `/agents`)
- `/session <key>` (or `/sessions`) - `/session <key>` (or `/sessions`)
- `/model <provider/model>` (or `/model list`, `/models`) - `/model <provider/model>` (or `/model list`, `/models`)
- `/think <off|minimal|low|medium|high>` - `/think <off|minimal|low|medium|high|xhigh>` (GPT-5.2 + Codex models only)
- `/verbose <on|off>` - `/verbose <on|off>`
- `/reasoning <on|off|stream>` (stream = Telegram draft only) - `/reasoning <on|off|stream>` (stream = Telegram draft only)
- `/cost <on|off>` - `/cost <on|off>`

View File

@ -132,10 +132,10 @@
"@grammyjs/runner": "^2.0.3", "@grammyjs/runner": "^2.0.3",
"@grammyjs/transformer-throttler": "^1.2.1", "@grammyjs/transformer-throttler": "^1.2.1",
"@homebridge/ciao": "^1.3.4", "@homebridge/ciao": "^1.3.4",
"@mariozechner/pi-agent-core": "^0.43.0", "@mariozechner/pi-agent-core": "^0.45.3",
"@mariozechner/pi-ai": "^0.43.0", "@mariozechner/pi-ai": "^0.45.3",
"@mariozechner/pi-coding-agent": "^0.43.0", "@mariozechner/pi-coding-agent": "^0.45.3",
"@mariozechner/pi-tui": "^0.43.0", "@mariozechner/pi-tui": "^0.45.3",
"@microsoft/agents-hosting": "^1.1.1", "@microsoft/agents-hosting": "^1.1.1",
"@microsoft/agents-hosting-express": "^1.1.1", "@microsoft/agents-hosting-express": "^1.1.1",
"@microsoft/agents-hosting-extensions-teams": "^1.1.1", "@microsoft/agents-hosting-extensions-teams": "^1.1.1",
@ -207,7 +207,7 @@
"@sinclair/typebox": "0.34.47" "@sinclair/typebox": "0.34.47"
}, },
"patchedDependencies": { "patchedDependencies": {
"@mariozechner/pi-ai@0.43.0": "patches/@mariozechner__pi-ai@0.43.0.patch" "@mariozechner/pi-ai@0.45.3": "patches/@mariozechner__pi-ai@0.45.3.patch"
} }
}, },
"vitest": { "vitest": {

View File

@ -1,8 +1,8 @@
diff --git a/dist/providers/google-gemini-cli.js b/dist/providers/google-gemini-cli.js diff --git a/dist/providers/google-gemini-cli.js b/dist/providers/google-gemini-cli.js
index 12540bb1069087a0d0a2967f792008627b9f79d9..f30b525620e6d8e45146b439ec3733e4053c9d2a 100644 index cc9e0cb..2b18ec4 100644
--- a/dist/providers/google-gemini-cli.js --- a/dist/providers/google-gemini-cli.js
+++ b/dist/providers/google-gemini-cli.js +++ b/dist/providers/google-gemini-cli.js
@@ -248,6 +248,11 @@ export const streamGoogleGeminiCli = (model, context, options) => { @@ -329,6 +329,11 @@ export const streamGoogleGeminiCli = (model, context, options) => {
break; // Success, exit retry loop break; // Success, exit retry loop
} }
const errorText = await response.text(); const errorText = await response.text();
@ -14,41 +14,8 @@ index 12540bb1069087a0d0a2967f792008627b9f79d9..f30b525620e6d8e45146b439ec3733e4
// Check if retryable // Check if retryable
if (attempt < MAX_RETRIES && isRetryableError(response.status, errorText)) { if (attempt < MAX_RETRIES && isRetryableError(response.status, errorText)) {
// Use server-provided delay or exponential backoff // Use server-provided delay or exponential backoff
diff --git a/dist/providers/google-shared.js b/dist/providers/google-shared.js
index ae4710b0f134ac4a48f5b7053f454d1068bee71f..b1b5bd94586f68461ccc44e4a9cdf3acb4e0d084 100644
--- a/dist/providers/google-shared.js
+++ b/dist/providers/google-shared.js
@@ -42,6 +42,8 @@ export function retainThoughtSignature(existing, incoming) {
export function convertMessages(model, context) {
const contents = [];
const transformedMessages = transformMessages(context.messages, model);
+ const shouldStripFunctionId = typeof model.provider === "string" &&
+ model.provider.startsWith("google");
for (const msg of transformedMessages) {
if (msg.role === "user") {
if (typeof msg.content === "string") {
@@ -113,6 +115,9 @@ export function convertMessages(model, context) {
args: block.arguments,
},
};
+ if (shouldStripFunctionId && part?.functionCall?.id) {
+ delete part.functionCall.id; // Google Gemini/Vertex do not support 'id' in functionCall
+ }
if (block.thoughtSignature) {
part.thoughtSignature = block.thoughtSignature;
}
@@ -155,6 +160,9 @@ export function convertMessages(model, context) {
...(hasImages && supportsMultimodalFunctionResponse && { parts: imageParts }),
},
};
+ if (shouldStripFunctionId && functionResponsePart.functionResponse?.id) {
+ delete functionResponsePart.functionResponse.id; // Google Gemini/Vertex do not support 'id' in functionResponse
+ }
// Cloud Code Assist API requires all function responses to be in a single user turn.
// Check if the last content is already a user turn with function responses and merge.
const lastContent = contents[contents.length - 1];
diff --git a/dist/providers/openai-codex-responses.js b/dist/providers/openai-codex-responses.js diff --git a/dist/providers/openai-codex-responses.js b/dist/providers/openai-codex-responses.js
index ad0a2aabbe10382cee4e463b68a02864dd235e57..8c001acfd0b4e0743181c246f1bedcf8cd2ffb02 100644 index 7488c79..4c34587 100644
--- a/dist/providers/openai-codex-responses.js --- a/dist/providers/openai-codex-responses.js
+++ b/dist/providers/openai-codex-responses.js +++ b/dist/providers/openai-codex-responses.js
@@ -517,7 +517,7 @@ function convertTools(tools) { @@ -517,7 +517,7 @@ function convertTools(tools) {
@ -61,10 +28,10 @@ index ad0a2aabbe10382cee4e463b68a02864dd235e57..8c001acfd0b4e0743181c246f1bedcf8
} }
function mapStopReason(status) { function mapStopReason(status) {
diff --git a/dist/providers/openai-responses.js b/dist/providers/openai-responses.js diff --git a/dist/providers/openai-responses.js b/dist/providers/openai-responses.js
index f07085c64390b211340d6a826b28ea9c2e77302f..7f758532246cc7b062df48e9cec4e6c904b76a99 100644 index c4714f4..4d1e6b0 100644
--- a/dist/providers/openai-responses.js --- a/dist/providers/openai-responses.js
+++ b/dist/providers/openai-responses.js +++ b/dist/providers/openai-responses.js
@@ -396,10 +396,16 @@ function convertMessages(model, context) { @@ -400,10 +400,16 @@ function convertMessages(model, context) {
} }
else if (msg.role === "assistant") { else if (msg.role === "assistant") {
const output = []; const output = [];
@ -81,7 +48,7 @@ index f07085c64390b211340d6a826b28ea9c2e77302f..7f758532246cc7b062df48e9cec4e6c9
const reasoningItem = JSON.parse(block.thinkingSignature); const reasoningItem = JSON.parse(block.thinkingSignature);
output.push(reasoningItem); output.push(reasoningItem);
} }
@@ -434,6 +440,16 @@ function convertMessages(model, context) { @@ -438,6 +444,16 @@ function convertMessages(model, context) {
}); });
} }
} }

1573
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -10,4 +10,4 @@ onlyBuiltDependencies:
- sharp - sharp
patchedDependencies: patchedDependencies:
'@mariozechner/pi-ai@0.43.0': patches/@mariozechner__pi-ai@0.43.0.patch '@mariozechner/pi-ai@0.45.3': patches/@mariozechner__pi-ai@0.45.3.patch

View File

@ -1,6 +1,10 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import type { ClawdbotConfig } from "../config/config.js"; import type { ClawdbotConfig } from "../config/config.js";
import { resolveAgentConfig } from "./agent-scope.js"; import {
resolveAgentConfig,
resolveAgentModelFallbacksOverride,
resolveAgentModelPrimary,
} from "./agent-scope.js";
describe("resolveAgentConfig", () => { describe("resolveAgentConfig", () => {
it("should return undefined when no agents config exists", () => { it("should return undefined when no agents config exists", () => {
@ -47,6 +51,68 @@ describe("resolveAgentConfig", () => {
}); });
}); });
it("supports per-agent model primary+fallbacks", () => {
const cfg: ClawdbotConfig = {
agents: {
defaults: {
model: {
primary: "anthropic/claude-sonnet-4",
fallbacks: ["openai/gpt-4.1"],
},
},
list: [
{
id: "linus",
model: {
primary: "anthropic/claude-opus-4",
fallbacks: ["openai/gpt-5.2"],
},
},
],
},
};
expect(resolveAgentModelPrimary(cfg, "linus")).toBe(
"anthropic/claude-opus-4",
);
expect(resolveAgentModelFallbacksOverride(cfg, "linus")).toEqual([
"openai/gpt-5.2",
]);
// If fallbacks isn't present, we don't override the global fallbacks.
const cfgNoOverride: ClawdbotConfig = {
agents: {
list: [
{
id: "linus",
model: {
primary: "anthropic/claude-opus-4",
},
},
],
},
};
expect(resolveAgentModelFallbacksOverride(cfgNoOverride, "linus")).toBe(
undefined,
);
// Explicit empty list disables global fallbacks for that agent.
const cfgDisable: ClawdbotConfig = {
agents: {
list: [
{
id: "linus",
model: {
primary: "anthropic/claude-opus-4",
fallbacks: [],
},
},
],
},
};
expect(resolveAgentModelFallbacksOverride(cfgDisable, "linus")).toEqual([]);
});
it("should return agent-specific sandbox config", () => { it("should return agent-specific sandbox config", () => {
const cfg: ClawdbotConfig = { const cfg: ClawdbotConfig = {
agents: { agents: {

View File

@ -21,7 +21,7 @@ type ResolvedAgentConfig = {
name?: string; name?: string;
workspace?: string; workspace?: string;
agentDir?: string; agentDir?: string;
model?: string; model?: AgentEntry["model"];
memorySearch?: AgentEntry["memorySearch"]; memorySearch?: AgentEntry["memorySearch"];
humanDelay?: AgentEntry["humanDelay"]; humanDelay?: AgentEntry["humanDelay"];
identity?: AgentEntry["identity"]; identity?: AgentEntry["identity"];
@ -95,7 +95,11 @@ export function resolveAgentConfig(
workspace: workspace:
typeof entry.workspace === "string" ? entry.workspace : undefined, typeof entry.workspace === "string" ? entry.workspace : undefined,
agentDir: typeof entry.agentDir === "string" ? entry.agentDir : undefined, agentDir: typeof entry.agentDir === "string" ? entry.agentDir : undefined,
model: typeof entry.model === "string" ? entry.model : undefined, model:
typeof entry.model === "string" ||
(entry.model && typeof entry.model === "object")
? entry.model
: undefined,
memorySearch: entry.memorySearch, memorySearch: entry.memorySearch,
humanDelay: entry.humanDelay, humanDelay: entry.humanDelay,
identity: entry.identity, identity: entry.identity,
@ -109,6 +113,28 @@ export function resolveAgentConfig(
}; };
} }
export function resolveAgentModelPrimary(
cfg: ClawdbotConfig,
agentId: string,
): string | undefined {
const raw = resolveAgentConfig(cfg, agentId)?.model;
if (!raw) return undefined;
if (typeof raw === "string") return raw.trim() || undefined;
const primary = raw.primary?.trim();
return primary || undefined;
}
export function resolveAgentModelFallbacksOverride(
cfg: ClawdbotConfig,
agentId: string,
): string[] | undefined {
const raw = resolveAgentConfig(cfg, agentId)?.model;
if (!raw || typeof raw === "string") return undefined;
// Important: treat an explicitly provided empty array as an override to disable global fallbacks.
if (!Object.hasOwn(raw, "fallbacks")) return undefined;
return Array.isArray(raw.fallbacks) ? raw.fallbacks : undefined;
}
export function resolveAgentWorkspaceDir(cfg: ClawdbotConfig, agentId: string) { export function resolveAgentWorkspaceDir(cfg: ClawdbotConfig, agentId: string) {
const id = normalizeAgentId(agentId); const id = normalizeAgentId(agentId);
const configured = resolveAgentConfig(cfg, id)?.workspace?.trim(); const configured = resolveAgentConfig(cfg, id)?.workspace?.trim();

View File

@ -40,20 +40,6 @@ const DEFAULT_PATH =
process.env.PATH ?? process.env.PATH ??
"/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"; "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
// NOTE: Using Type.Unsafe with enum instead of Type.Union([Type.Literal(...)])
// because Claude API on Vertex AI rejects nested anyOf schemas as invalid JSON Schema.
// Type.Union of literals compiles to { anyOf: [{enum:["a"]}, {enum:["b"]}, ...] }
// which is valid but not accepted. A flat enum { type: "string", enum: [...] } works.
const _stringEnum = <T extends readonly string[]>(
values: T,
options?: { description?: string },
) =>
Type.Unsafe<T[number]>({
type: "string",
enum: values as unknown as string[],
...options,
});
export type ExecToolDefaults = { export type ExecToolDefaults = {
backgroundMs?: number; backgroundMs?: number;
timeoutSec?: number; timeoutSec?: number;

View File

@ -124,6 +124,106 @@ describe("runWithModelFallback", () => {
expect(run.mock.calls[1]?.[1]).toBe("claude-haiku-3-5"); expect(run.mock.calls[1]?.[1]).toBe("claude-haiku-3-5");
}); });
it("does not append configured primary when fallbacksOverride is set", async () => {
const cfg = makeCfg({
agents: {
defaults: {
model: {
primary: "openai/gpt-4.1-mini",
},
},
},
});
const run = vi
.fn()
.mockImplementation(() =>
Promise.reject(Object.assign(new Error("nope"), { status: 401 })),
);
await expect(
runWithModelFallback({
cfg,
provider: "anthropic",
model: "claude-opus-4-5",
fallbacksOverride: ["anthropic/claude-haiku-3-5"],
run,
}),
).rejects.toThrow("All models failed");
expect(run.mock.calls).toEqual([
["anthropic", "claude-opus-4-5"],
["anthropic", "claude-haiku-3-5"],
]);
});
it("uses fallbacksOverride instead of agents.defaults.model.fallbacks", async () => {
const cfg = {
agents: {
defaults: {
model: {
fallbacks: ["openai/gpt-5.2"],
},
},
},
} as ClawdbotConfig;
const calls: Array<{ provider: string; model: string }> = [];
const res = await runWithModelFallback({
cfg,
provider: "anthropic",
model: "claude-opus-4-5",
fallbacksOverride: ["openai/gpt-4.1"],
run: async (provider, model) => {
calls.push({ provider, model });
if (provider === "anthropic") {
throw Object.assign(new Error("nope"), { status: 401 });
}
if (provider === "openai" && model === "gpt-4.1") {
return "ok";
}
throw new Error(`unexpected candidate: ${provider}/${model}`);
},
});
expect(res.result).toBe("ok");
expect(calls).toEqual([
{ provider: "anthropic", model: "claude-opus-4-5" },
{ provider: "openai", model: "gpt-4.1" },
]);
});
it("treats an empty fallbacksOverride as disabling global fallbacks", async () => {
const cfg = {
agents: {
defaults: {
model: {
fallbacks: ["openai/gpt-5.2"],
},
},
},
} as ClawdbotConfig;
const calls: Array<{ provider: string; model: string }> = [];
await expect(
runWithModelFallback({
cfg,
provider: "anthropic",
model: "claude-opus-4-5",
fallbacksOverride: [],
run: async (provider, model) => {
calls.push({ provider, model });
throw new Error("primary failed");
},
}),
).rejects.toThrow("primary failed");
expect(calls).toEqual([
{ provider: "anthropic", model: "claude-opus-4-5" },
]);
});
it("falls back on missing API key errors", async () => { it("falls back on missing API key errors", async () => {
const cfg = makeCfg(); const cfg = makeCfg();
const run = vi const run = vi

View File

@ -126,6 +126,8 @@ function resolveFallbackCandidates(params: {
cfg: ClawdbotConfig | undefined; cfg: ClawdbotConfig | undefined;
provider: string; provider: string;
model: string; model: string;
/** Optional explicit fallbacks list; when provided (even empty), replaces agents.defaults.model.fallbacks. */
fallbacksOverride?: string[];
}): ModelCandidate[] { }): ModelCandidate[] {
const provider = params.provider.trim() || DEFAULT_PROVIDER; const provider = params.provider.trim() || DEFAULT_PROVIDER;
const model = params.model.trim() || DEFAULT_MODEL; const model = params.model.trim() || DEFAULT_MODEL;
@ -159,6 +161,7 @@ function resolveFallbackCandidates(params: {
addCandidate({ provider, model }, false); addCandidate({ provider, model }, false);
const modelFallbacks = (() => { const modelFallbacks = (() => {
if (params.fallbacksOverride !== undefined) return params.fallbacksOverride;
const model = params.cfg?.agents?.defaults?.model as const model = params.cfg?.agents?.defaults?.model as
| { fallbacks?: string[] } | { fallbacks?: string[] }
| string | string
@ -177,7 +180,11 @@ function resolveFallbackCandidates(params: {
addCandidate(resolved.ref, true); addCandidate(resolved.ref, true);
} }
if (primary?.provider && primary.model) { if (
params.fallbacksOverride === undefined &&
primary?.provider &&
primary.model
) {
addCandidate({ provider: primary.provider, model: primary.model }, false); addCandidate({ provider: primary.provider, model: primary.model }, false);
} }
@ -188,6 +195,8 @@ export async function runWithModelFallback<T>(params: {
cfg: ClawdbotConfig | undefined; cfg: ClawdbotConfig | undefined;
provider: string; provider: string;
model: string; model: string;
/** Optional explicit fallbacks list; when provided (even empty), replaces agents.defaults.model.fallbacks. */
fallbacksOverride?: string[];
run: (provider: string, model: string) => Promise<T>; run: (provider: string, model: string) => Promise<T>;
onError?: (attempt: { onError?: (attempt: {
provider: string; provider: string;
@ -202,7 +211,12 @@ export async function runWithModelFallback<T>(params: {
model: string; model: string;
attempts: FallbackAttempt[]; attempts: FallbackAttempt[];
}> { }> {
const candidates = resolveFallbackCandidates(params); const candidates = resolveFallbackCandidates({
cfg: params.cfg,
provider: params.provider,
model: params.model,
fallbacksOverride: params.fallbacksOverride,
});
const attempts: FallbackAttempt[] = []; const attempts: FallbackAttempt[] = [];
let lastError: unknown; let lastError: unknown;

View File

@ -7,7 +7,13 @@ export type ModelRef = {
model: string; model: string;
}; };
export type ThinkLevel = "off" | "minimal" | "low" | "medium" | "high"; export type ThinkLevel =
| "off"
| "minimal"
| "low"
| "medium"
| "high"
| "xhigh";
export type ModelAliasIndex = { export type ModelAliasIndex = {
byAlias: Map<string, { alias: string; ref: ModelRef }>; byAlias: Map<string, { alias: string; ref: ModelRef }>;

View File

@ -1046,7 +1046,7 @@ export function resolveEmbeddedSessionLane(key: string) {
} }
function mapThinkingLevel(level?: ThinkLevel): ThinkingLevel { function mapThinkingLevel(level?: ThinkLevel): ThinkingLevel {
// pi-agent-core supports "xhigh" too; Clawdbot doesn't surface it for now. // pi-agent-core supports "xhigh"; Clawdbot enables it for specific models.
if (!level) return "off"; if (!level) return "off";
return level; return level;
} }

View File

@ -5,6 +5,7 @@ import type { AgentTool } from "@mariozechner/pi-agent-core";
import sharp from "sharp"; import sharp from "sharp";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import type { ClawdbotConfig } from "../config/config.js"; import type { ClawdbotConfig } from "../config/config.js";
import { createClawdbotTools } from "./clawdbot-tools.js";
import { __testing, createClawdbotCodingTools } from "./pi-tools.js"; import { __testing, createClawdbotCodingTools } from "./pi-tools.js";
import { createBrowserTool } from "./tools/browser-tool.js"; import { createBrowserTool } from "./tools/browser-tool.js";
@ -16,7 +17,7 @@ describe("createClawdbotCodingTools", () => {
expect(schema.anyOf).toBeUndefined(); expect(schema.anyOf).toBeUndefined();
}); });
it("merges properties for union tool schemas", () => { it("keeps browser tool schema properties after normalization", () => {
const tools = createClawdbotCodingTools(); const tools = createClawdbotCodingTools();
const browser = tools.find((tool) => tool.name === "browser"); const browser = tools.find((tool) => tool.name === "browser");
expect(browser).toBeDefined(); expect(browser).toBeDefined();
@ -266,6 +267,95 @@ describe("createClawdbotCodingTools", () => {
expect(offenders).toEqual([]); expect(offenders).toEqual([]);
}); });
it("avoids anyOf/oneOf/allOf in tool schemas", () => {
const tools = createClawdbotCodingTools();
const offenders: Array<{
name: string;
keyword: string;
path: string;
}> = [];
const keywords = new Set(["anyOf", "oneOf", "allOf"]);
const walk = (value: unknown, path: string, name: string): void => {
if (!value) return;
if (Array.isArray(value)) {
for (const [index, entry] of value.entries()) {
walk(entry, `${path}[${index}]`, name);
}
return;
}
if (typeof value !== "object") return;
const record = value as Record<string, unknown>;
for (const [key, entry] of Object.entries(record)) {
const nextPath = path ? `${path}.${key}` : key;
if (keywords.has(key)) {
offenders.push({ name, keyword: key, path: nextPath });
}
walk(entry, nextPath, name);
}
};
for (const tool of tools) {
walk(tool.parameters, "", tool.name);
}
expect(offenders).toEqual([]);
});
it("keeps raw core tool schemas union-free", () => {
const tools = createClawdbotTools();
const coreTools = new Set([
"browser",
"canvas",
"nodes",
"cron",
"message",
"gateway",
"agents_list",
"sessions_list",
"sessions_history",
"sessions_send",
"sessions_spawn",
"session_status",
"memory_search",
"memory_get",
"image",
]);
const offenders: Array<{
name: string;
keyword: string;
path: string;
}> = [];
const keywords = new Set(["anyOf", "oneOf", "allOf"]);
const walk = (value: unknown, path: string, name: string): void => {
if (!value) return;
if (Array.isArray(value)) {
for (const [index, entry] of value.entries()) {
walk(entry, `${path}[${index}]`, name);
}
return;
}
if (typeof value !== "object") return;
const record = value as Record<string, unknown>;
for (const [key, entry] of Object.entries(record)) {
const nextPath = path ? `${path}.${key}` : key;
if (keywords.has(key)) {
offenders.push({ name, keyword: key, path: nextPath });
}
walk(entry, nextPath, name);
}
};
for (const tool of tools) {
if (!coreTools.has(tool.name)) continue;
walk(tool.parameters, "", tool.name);
}
expect(offenders).toEqual([]);
});
it("does not expose provider-specific message tools", () => { it("does not expose provider-specific message tools", () => {
const tools = createClawdbotCodingTools({ messageProvider: "discord" }); const tools = createClawdbotCodingTools({ messageProvider: "discord" });
const names = new Set(tools.map((tool) => tool.name)); const names = new Set(tools.map((tool) => tool.name));
@ -517,6 +607,57 @@ describe("createClawdbotCodingTools", () => {
expect(tools.some((tool) => tool.name === "browser")).toBe(false); expect(tools.some((tool) => tool.name === "browser")).toBe(false);
}); });
it("applies tool profiles before allow/deny policies", () => {
const tools = createClawdbotCodingTools({
config: { tools: { profile: "messaging" } },
});
const names = new Set(tools.map((tool) => tool.name));
expect(names.has("message")).toBe(true);
expect(names.has("sessions_send")).toBe(true);
expect(names.has("sessions_spawn")).toBe(false);
expect(names.has("exec")).toBe(false);
expect(names.has("browser")).toBe(false);
});
it("expands group shorthands in global tool policy", () => {
const tools = createClawdbotCodingTools({
config: { tools: { allow: ["group:fs"] } },
});
const names = new Set(tools.map((tool) => tool.name));
expect(names.has("read")).toBe(true);
expect(names.has("write")).toBe(true);
expect(names.has("edit")).toBe(true);
expect(names.has("exec")).toBe(false);
expect(names.has("browser")).toBe(false);
});
it("expands group shorthands in global tool deny policy", () => {
const tools = createClawdbotCodingTools({
config: { tools: { deny: ["group:fs"] } },
});
const names = new Set(tools.map((tool) => tool.name));
expect(names.has("read")).toBe(false);
expect(names.has("write")).toBe(false);
expect(names.has("edit")).toBe(false);
expect(names.has("exec")).toBe(true);
});
it("lets agent profiles override global profiles", () => {
const tools = createClawdbotCodingTools({
sessionKey: "agent:work:main",
config: {
tools: { profile: "coding" },
agents: {
list: [{ id: "work", tools: { profile: "messaging" } }],
},
},
});
const names = new Set(tools.map((tool) => tool.name));
expect(names.has("message")).toBe(true);
expect(names.has("exec")).toBe(false);
expect(names.has("read")).toBe(false);
});
it("removes unsupported JSON Schema keywords for Cloud Code Assist API compatibility", () => { it("removes unsupported JSON Schema keywords for Cloud Code Assist API compatibility", () => {
const tools = createClawdbotCodingTools(); const tools = createClawdbotCodingTools();

View File

@ -28,6 +28,11 @@ import type { SandboxContext, SandboxToolPolicy } from "./sandbox.js";
import { assertSandboxPath } from "./sandbox-paths.js"; import { assertSandboxPath } from "./sandbox-paths.js";
import { cleanSchemaForGemini } from "./schema/clean-for-gemini.js"; import { cleanSchemaForGemini } from "./schema/clean-for-gemini.js";
import { sanitizeToolResultImages } from "./tool-images.js"; import { sanitizeToolResultImages } from "./tool-images.js";
import {
expandToolGroups,
normalizeToolName,
resolveToolProfilePolicy,
} from "./tool-policy.js";
// NOTE(steipete): Upstream read now does file-magic MIME detection; we keep the wrapper // NOTE(steipete): Upstream read now does file-magic MIME detection; we keep the wrapper
// to normalize payloads and sanitize oversized images before they hit providers. // to normalize payloads and sanitize oversized images before they hit providers.
@ -291,21 +296,6 @@ function cleanToolSchemaForGemini(schema: Record<string, unknown>): unknown {
return cleanSchemaForGemini(schema); return cleanSchemaForGemini(schema);
} }
const TOOL_NAME_ALIASES: Record<string, string> = {
bash: "exec",
"apply-patch": "apply_patch",
};
function normalizeToolName(name: string) {
const normalized = name.trim().toLowerCase();
return TOOL_NAME_ALIASES[normalized] ?? normalized;
}
function normalizeToolNames(list?: string[]) {
if (!list) return [];
return list.map(normalizeToolName).filter(Boolean);
}
function isOpenAIProvider(provider?: string) { function isOpenAIProvider(provider?: string) {
const normalized = provider?.trim().toLowerCase(); const normalized = provider?.trim().toLowerCase();
return normalized === "openai" || normalized === "openai-codex"; return normalized === "openai" || normalized === "openai-codex";
@ -357,8 +347,8 @@ function isToolAllowedByPolicyName(
policy?: SandboxToolPolicy, policy?: SandboxToolPolicy,
): boolean { ): boolean {
if (!policy) return true; if (!policy) return true;
const deny = new Set(normalizeToolNames(policy.deny)); const deny = new Set(expandToolGroups(policy.deny));
const allowRaw = normalizeToolNames(policy.allow); const allowRaw = expandToolGroups(policy.allow);
const allow = allowRaw.length > 0 ? new Set(allowRaw) : null; const allow = allowRaw.length > 0 ? new Set(allowRaw) : null;
const normalized = normalizeToolName(name); const normalized = normalizeToolName(name);
if (deny.has(normalized)) return false; if (deny.has(normalized)) return false;
@ -391,11 +381,15 @@ function resolveEffectiveToolPolicy(params: {
: undefined; : undefined;
const agentTools = agentConfig?.tools; const agentTools = agentConfig?.tools;
const hasAgentToolPolicy = const hasAgentToolPolicy =
Array.isArray(agentTools?.allow) || Array.isArray(agentTools?.deny); Array.isArray(agentTools?.allow) ||
Array.isArray(agentTools?.deny) ||
typeof agentTools?.profile === "string";
const globalTools = params.config?.tools; const globalTools = params.config?.tools;
const profile = agentTools?.profile ?? globalTools?.profile;
return { return {
agentId, agentId,
policy: hasAgentToolPolicy ? agentTools : globalTools, policy: hasAgentToolPolicy ? agentTools : globalTools,
profile,
}; };
} }
@ -703,10 +697,15 @@ export function createClawdbotCodingTools(options?: {
}): AnyAgentTool[] { }): AnyAgentTool[] {
const execToolName = "exec"; const execToolName = "exec";
const sandbox = options?.sandbox?.enabled ? options.sandbox : undefined; const sandbox = options?.sandbox?.enabled ? options.sandbox : undefined;
const { agentId, policy: effectiveToolsPolicy } = resolveEffectiveToolPolicy({ const {
agentId,
policy: effectiveToolsPolicy,
profile,
} = resolveEffectiveToolPolicy({
config: options?.config, config: options?.config,
sessionKey: options?.sessionKey, sessionKey: options?.sessionKey,
}); });
const profilePolicy = resolveToolProfilePolicy(profile);
const scopeKey = const scopeKey =
options?.exec?.scopeKey ?? (agentId ? `agent:${agentId}` : undefined); options?.exec?.scopeKey ?? (agentId ? `agent:${agentId}` : undefined);
const subagentPolicy = const subagentPolicy =
@ -714,6 +713,7 @@ export function createClawdbotCodingTools(options?: {
? resolveSubagentToolPolicy(options.config) ? resolveSubagentToolPolicy(options.config)
: undefined; : undefined;
const allowBackground = isToolAllowedByPolicies("process", [ const allowBackground = isToolAllowedByPolicies("process", [
profilePolicy,
effectiveToolsPolicy, effectiveToolsPolicy,
sandbox?.tools, sandbox?.tools,
subagentPolicy, subagentPolicy,
@ -829,12 +829,15 @@ export function createClawdbotCodingTools(options?: {
hasRepliedRef: options?.hasRepliedRef, hasRepliedRef: options?.hasRepliedRef,
}), }),
]; ];
const toolsFiltered = effectiveToolsPolicy const toolsFiltered = profilePolicy
? filterToolsByPolicy(tools, effectiveToolsPolicy) ? filterToolsByPolicy(tools, profilePolicy)
: tools; : tools;
const sandboxed = sandbox const policyFiltered = effectiveToolsPolicy
? filterToolsByPolicy(toolsFiltered, sandbox.tools) ? filterToolsByPolicy(toolsFiltered, effectiveToolsPolicy)
: toolsFiltered; : toolsFiltered;
const sandboxed = sandbox
? filterToolsByPolicy(policyFiltered, sandbox.tools)
: policyFiltered;
const subagentFiltered = subagentPolicy const subagentFiltered = subagentPolicy
? filterToolsByPolicy(sandboxed, subagentPolicy) ? filterToolsByPolicy(sandboxed, subagentPolicy)
: sandboxed; : sandboxed;

View File

@ -33,6 +33,7 @@ import {
resolveSessionAgentId, resolveSessionAgentId,
} from "./agent-scope.js"; } from "./agent-scope.js";
import { syncSkillsToWorkspace } from "./skills.js"; import { syncSkillsToWorkspace } from "./skills.js";
import { expandToolGroups } from "./tool-policy.js";
import { import {
DEFAULT_AGENT_WORKSPACE_DIR, DEFAULT_AGENT_WORKSPACE_DIR,
DEFAULT_AGENTS_FILENAME, DEFAULT_AGENTS_FILENAME,
@ -239,58 +240,10 @@ const BROWSER_BRIDGES = new Map<
{ bridge: BrowserBridge; containerName: string } { bridge: BrowserBridge; containerName: string }
>(); >();
function normalizeToolList(values?: string[]) {
if (!values) return [];
return values
.map((value) => value.trim())
.filter(Boolean)
.map((value) => value.toLowerCase());
}
const TOOL_GROUPS: Record<string, string[]> = {
// NOTE: Keep canonical (lowercase) tool names here.
"group:memory": ["memory_search", "memory_get"],
// Basic workspace/file tools
"group:fs": ["read", "write", "edit", "apply_patch"],
// Session management tools
"group:sessions": [
"sessions_list",
"sessions_history",
"sessions_send",
"sessions_spawn",
"session_status",
],
// Host/runtime execution tools
"group:runtime": ["exec", "bash", "process"],
};
function expandToolGroupEntry(entry: string): string[] {
const raw = entry.trim();
if (!raw) return [];
const lower = raw.toLowerCase();
const group = TOOL_GROUPS[lower];
if (group) return group;
return [raw];
}
function expandToolGroups(values?: string[]): string[] {
if (!values) return [];
const out: string[] = [];
for (const value of values) {
for (const expanded of expandToolGroupEntry(value)) {
const trimmed = expanded.trim();
if (!trimmed) continue;
out.push(trimmed);
}
}
return out;
}
function isToolAllowed(policy: SandboxToolPolicy, name: string) { function isToolAllowed(policy: SandboxToolPolicy, name: string) {
const deny = new Set(normalizeToolList(expandToolGroups(policy.deny))); const deny = new Set(expandToolGroups(policy.deny));
if (deny.has(name.toLowerCase())) return false; if (deny.has(name.toLowerCase())) return false;
const allow = normalizeToolList(expandToolGroups(policy.allow)); const allow = expandToolGroups(policy.allow);
if (allow.length === 0) return true; if (allow.length === 0) return true;
return allow.includes(name.toLowerCase()); return allow.includes(name.toLowerCase());
} }
@ -687,8 +640,8 @@ export function formatSandboxToolPolicyBlockedMessage(params: {
}); });
if (!runtime.sandboxed) return undefined; if (!runtime.sandboxed) return undefined;
const deny = new Set(normalizeToolList(runtime.toolPolicy.deny)); const deny = new Set(expandToolGroups(runtime.toolPolicy.deny));
const allow = normalizeToolList(runtime.toolPolicy.allow); const allow = expandToolGroups(runtime.toolPolicy.allow);
const allowSet = allow.length > 0 ? new Set(allow) : null; const allowSet = allow.length > 0 ? new Set(allow) : null;
const blockedByDeny = deny.has(tool); const blockedByDeny = deny.has(tool);
const blockedByAllow = allowSet ? !allowSet.has(tool) : false; const blockedByAllow = allowSet ? !allowSet.has(tool) : false;

View File

@ -0,0 +1,27 @@
import { Type } from "@sinclair/typebox";
type StringEnumOptions<T extends readonly string[]> = {
description?: string;
title?: string;
default?: T[number];
};
// NOTE: Avoid Type.Union([Type.Literal(...)]) which compiles to anyOf.
// Some providers reject anyOf in tool schemas; a flat string enum is safer.
export function stringEnum<T extends readonly string[]>(
values: T,
options: StringEnumOptions<T> = {},
) {
return Type.Unsafe<T[number]>({
type: "string",
enum: [...values],
...options,
});
}
export function optionalStringEnum<T extends readonly string[]>(
values: T,
options: StringEnumOptions<T> = {},
) {
return Type.Optional(stringEnum(values, options));
}

View File

@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest";
import {
expandToolGroups,
resolveToolProfilePolicy,
TOOL_GROUPS,
} from "./tool-policy.js";
describe("tool-policy", () => {
it("expands groups and normalizes aliases", () => {
const expanded = expandToolGroups([
"group:runtime",
"BASH",
"apply-patch",
"group:fs",
]);
const set = new Set(expanded);
expect(set.has("exec")).toBe(true);
expect(set.has("bash")).toBe(true);
expect(set.has("process")).toBe(true);
expect(set.has("apply_patch")).toBe(true);
expect(set.has("read")).toBe(true);
expect(set.has("write")).toBe(true);
expect(set.has("edit")).toBe(true);
});
it("resolves known profiles and ignores unknown ones", () => {
const coding = resolveToolProfilePolicy("coding");
expect(coding?.allow).toContain("group:fs");
expect(resolveToolProfilePolicy("nope")).toBeUndefined();
});
it("includes core tool groups in group:clawdbot", () => {
const group = TOOL_GROUPS["group:clawdbot"];
expect(group).toContain("browser");
expect(group).toContain("message");
expect(group).toContain("session_status");
});
});

116
src/agents/tool-policy.ts Normal file
View File

@ -0,0 +1,116 @@
export type ToolProfileId = "minimal" | "coding" | "messaging" | "full";
type ToolProfilePolicy = {
allow?: string[];
deny?: string[];
};
const TOOL_NAME_ALIASES: Record<string, string> = {
bash: "exec",
"apply-patch": "apply_patch",
};
export const TOOL_GROUPS: Record<string, string[]> = {
// NOTE: Keep canonical (lowercase) tool names here.
"group:memory": ["memory_search", "memory_get"],
// Basic workspace/file tools
"group:fs": ["read", "write", "edit", "apply_patch"],
// Host/runtime execution tools
"group:runtime": ["exec", "bash", "process"],
// Session management tools
"group:sessions": [
"sessions_list",
"sessions_history",
"sessions_send",
"sessions_spawn",
"session_status",
],
// UI helpers
"group:ui": ["browser", "canvas"],
// Automation + infra
"group:automation": ["cron", "gateway"],
// Messaging surface
"group:messaging": ["message"],
// Nodes + device tools
"group:nodes": ["nodes"],
// All Clawdbot native tools (excludes provider plugins).
"group:clawdbot": [
"browser",
"canvas",
"nodes",
"cron",
"message",
"gateway",
"agents_list",
"sessions_list",
"sessions_history",
"sessions_send",
"sessions_spawn",
"session_status",
"memory_search",
"memory_get",
"image",
],
};
const TOOL_PROFILES: Record<ToolProfileId, ToolProfilePolicy> = {
minimal: {
allow: ["session_status"],
},
coding: {
allow: [
"group:fs",
"group:runtime",
"group:sessions",
"group:memory",
"image",
],
},
messaging: {
allow: [
"group:messaging",
"sessions_list",
"sessions_history",
"sessions_send",
"session_status",
],
},
full: {},
};
export function normalizeToolName(name: string) {
const normalized = name.trim().toLowerCase();
return TOOL_NAME_ALIASES[normalized] ?? normalized;
}
export function normalizeToolList(list?: string[]) {
if (!list) return [];
return list.map(normalizeToolName).filter(Boolean);
}
export function expandToolGroups(list?: string[]) {
const normalized = normalizeToolList(list);
const expanded: string[] = [];
for (const value of normalized) {
const group = TOOL_GROUPS[value];
if (group) {
expanded.push(...group);
continue;
}
expanded.push(value);
}
return Array.from(new Set(expanded));
}
export function resolveToolProfilePolicy(
profile?: string,
): ToolProfilePolicy | undefined {
if (!profile) return undefined;
const resolved = TOOL_PROFILES[profile as ToolProfileId];
if (!resolved) return undefined;
if (!resolved.allow && !resolved.deny) return undefined;
return {
allow: resolved.allow ? [...resolved.allow] : undefined,
deny: resolved.deny ? [...resolved.deny] : undefined,
};
}

View File

@ -22,6 +22,7 @@ import {
import { resolveBrowserConfig } from "../../browser/config.js"; import { resolveBrowserConfig } from "../../browser/config.js";
import { DEFAULT_AI_SNAPSHOT_MAX_CHARS } from "../../browser/constants.js"; import { DEFAULT_AI_SNAPSHOT_MAX_CHARS } from "../../browser/constants.js";
import { loadConfig } from "../../config/config.js"; import { loadConfig } from "../../config/config.js";
import { optionalStringEnum, stringEnum } from "../schema/typebox.js";
import { import {
type AnyAgentTool, type AnyAgentTool,
imageResultFromFile, imageResultFromFile,
@ -43,16 +44,35 @@ const BROWSER_ACT_KINDS = [
"close", "close",
] as const; ] as const;
type BrowserActKind = (typeof BROWSER_ACT_KINDS)[number]; const BROWSER_TOOL_ACTIONS = [
"status",
"start",
"stop",
"tabs",
"open",
"focus",
"close",
"snapshot",
"screenshot",
"navigate",
"console",
"pdf",
"upload",
"dialog",
"act",
] as const;
const BROWSER_TARGETS = ["sandbox", "host", "custom"] as const;
const BROWSER_SNAPSHOT_FORMATS = ["aria", "ai"] as const;
const BROWSER_IMAGE_TYPES = ["png", "jpeg"] as const;
// NOTE: Using a flattened object schema instead of Type.Union([Type.Object(...), ...]) // NOTE: Using a flattened object schema instead of Type.Union([Type.Object(...), ...])
// because Claude API on Vertex AI rejects nested anyOf schemas as invalid JSON Schema. // because Claude API on Vertex AI rejects nested anyOf schemas as invalid JSON Schema.
// The discriminator (kind) determines which properties are relevant; runtime validates. // The discriminator (kind) determines which properties are relevant; runtime validates.
const BrowserActSchema = Type.Object({ const BrowserActSchema = Type.Object({
kind: Type.Unsafe<BrowserActKind>({ kind: stringEnum(BROWSER_ACT_KINDS),
type: "string",
enum: [...BROWSER_ACT_KINDS],
}),
// Common fields // Common fields
targetId: Type.Optional(Type.String()), targetId: Type.Optional(Type.String()),
ref: Type.Optional(Type.String()), ref: Type.Optional(Type.String()),
@ -89,37 +109,15 @@ const BrowserActSchema = Type.Object({
// A root-level `Type.Union([...])` compiles to `{ anyOf: [...] }` (no `type`), // A root-level `Type.Union([...])` compiles to `{ anyOf: [...] }` (no `type`),
// which OpenAI rejects ("Invalid schema ... type: None"). Keep this schema an object. // which OpenAI rejects ("Invalid schema ... type: None"). Keep this schema an object.
const BrowserToolSchema = Type.Object({ const BrowserToolSchema = Type.Object({
action: Type.Union([ action: stringEnum(BROWSER_TOOL_ACTIONS),
Type.Literal("status"), target: optionalStringEnum(BROWSER_TARGETS),
Type.Literal("start"),
Type.Literal("stop"),
Type.Literal("tabs"),
Type.Literal("open"),
Type.Literal("focus"),
Type.Literal("close"),
Type.Literal("snapshot"),
Type.Literal("screenshot"),
Type.Literal("navigate"),
Type.Literal("console"),
Type.Literal("pdf"),
Type.Literal("upload"),
Type.Literal("dialog"),
Type.Literal("act"),
]),
target: Type.Optional(
Type.Union([
Type.Literal("sandbox"),
Type.Literal("host"),
Type.Literal("custom"),
]),
),
profile: Type.Optional(Type.String()), profile: Type.Optional(Type.String()),
controlUrl: Type.Optional(Type.String()), controlUrl: Type.Optional(Type.String()),
targetUrl: Type.Optional(Type.String()), targetUrl: Type.Optional(Type.String()),
targetId: Type.Optional(Type.String()), targetId: Type.Optional(Type.String()),
limit: Type.Optional(Type.Number()), limit: Type.Optional(Type.Number()),
maxChars: Type.Optional(Type.Number()), maxChars: Type.Optional(Type.Number()),
format: Type.Optional(Type.Union([Type.Literal("aria"), Type.Literal("ai")])), format: optionalStringEnum(BROWSER_SNAPSHOT_FORMATS),
interactive: Type.Optional(Type.Boolean()), interactive: Type.Optional(Type.Boolean()),
compact: Type.Optional(Type.Boolean()), compact: Type.Optional(Type.Boolean()),
depth: Type.Optional(Type.Number()), depth: Type.Optional(Type.Number()),
@ -128,7 +126,7 @@ const BrowserToolSchema = Type.Object({
fullPage: Type.Optional(Type.Boolean()), fullPage: Type.Optional(Type.Boolean()),
ref: Type.Optional(Type.String()), ref: Type.Optional(Type.String()),
element: Type.Optional(Type.String()), element: Type.Optional(Type.String()),
type: Type.Optional(Type.Union([Type.Literal("png"), Type.Literal("jpeg")])), type: optionalStringEnum(BROWSER_IMAGE_TYPES),
level: Type.Optional(Type.String()), level: Type.Optional(Type.String()),
paths: Type.Optional(Type.Array(Type.String())), paths: Type.Optional(Type.Array(Type.String())),
inputRef: Type.Optional(Type.String()), inputRef: Type.Optional(Type.String()),

View File

@ -8,6 +8,7 @@ import {
parseCanvasSnapshotPayload, parseCanvasSnapshotPayload,
} from "../../cli/nodes-canvas.js"; } from "../../cli/nodes-canvas.js";
import { imageMimeFromFormat } from "../../media/mime.js"; import { imageMimeFromFormat } from "../../media/mime.js";
import { optionalStringEnum, stringEnum } from "../schema/typebox.js";
import { import {
type AnyAgentTool, type AnyAgentTool,
imageResult, imageResult,
@ -17,76 +18,44 @@ import {
import { callGatewayTool, type GatewayCallOptions } from "./gateway.js"; import { callGatewayTool, type GatewayCallOptions } from "./gateway.js";
import { resolveNodeId } from "./nodes-utils.js"; import { resolveNodeId } from "./nodes-utils.js";
const CanvasToolSchema = Type.Union([ const CANVAS_ACTIONS = [
Type.Object({ "present",
action: Type.Literal("present"), "hide",
gatewayUrl: Type.Optional(Type.String()), "navigate",
gatewayToken: Type.Optional(Type.String()), "eval",
timeoutMs: Type.Optional(Type.Number()), "snapshot",
node: Type.Optional(Type.String()), "a2ui_push",
target: Type.Optional(Type.String()), "a2ui_reset",
x: Type.Optional(Type.Number()), ] as const;
y: Type.Optional(Type.Number()),
width: Type.Optional(Type.Number()), const CANVAS_SNAPSHOT_FORMATS = ["png", "jpg", "jpeg"] as const;
height: Type.Optional(Type.Number()),
}), // Flattened schema: runtime validates per-action requirements.
Type.Object({ const CanvasToolSchema = Type.Object({
action: Type.Literal("hide"), action: stringEnum(CANVAS_ACTIONS),
gatewayUrl: Type.Optional(Type.String()), gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()), gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()), timeoutMs: Type.Optional(Type.Number()),
node: Type.Optional(Type.String()), node: Type.Optional(Type.String()),
}), // present
Type.Object({ target: Type.Optional(Type.String()),
action: Type.Literal("navigate"), x: Type.Optional(Type.Number()),
gatewayUrl: Type.Optional(Type.String()), y: Type.Optional(Type.Number()),
gatewayToken: Type.Optional(Type.String()), width: Type.Optional(Type.Number()),
timeoutMs: Type.Optional(Type.Number()), height: Type.Optional(Type.Number()),
node: Type.Optional(Type.String()), // navigate
url: Type.String(), url: Type.Optional(Type.String()),
}), // eval
Type.Object({ javaScript: Type.Optional(Type.String()),
action: Type.Literal("eval"), // snapshot
gatewayUrl: Type.Optional(Type.String()), format: optionalStringEnum(CANVAS_SNAPSHOT_FORMATS),
gatewayToken: Type.Optional(Type.String()), maxWidth: Type.Optional(Type.Number()),
timeoutMs: Type.Optional(Type.Number()), quality: Type.Optional(Type.Number()),
node: Type.Optional(Type.String()), delayMs: Type.Optional(Type.Number()),
javaScript: Type.String(), // a2ui_push
}), jsonl: Type.Optional(Type.String()),
Type.Object({ jsonlPath: Type.Optional(Type.String()),
action: Type.Literal("snapshot"), });
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
node: Type.Optional(Type.String()),
format: Type.Optional(
Type.Union([
Type.Literal("png"),
Type.Literal("jpg"),
Type.Literal("jpeg"),
]),
),
maxWidth: Type.Optional(Type.Number()),
quality: Type.Optional(Type.Number()),
delayMs: Type.Optional(Type.Number()),
}),
Type.Object({
action: Type.Literal("a2ui_push"),
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
node: Type.Optional(Type.String()),
jsonl: Type.Optional(Type.String()),
jsonlPath: Type.Optional(Type.String()),
}),
Type.Object({
action: Type.Literal("a2ui_reset"),
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
node: Type.Optional(Type.String()),
}),
]);
export function createCanvasTool(): AnyAgentTool { export function createCanvasTool(): AnyAgentTool {
return { return {

View File

@ -3,89 +3,42 @@ import {
normalizeCronJobCreate, normalizeCronJobCreate,
normalizeCronJobPatch, normalizeCronJobPatch,
} from "../../cron/normalize.js"; } from "../../cron/normalize.js";
import { optionalStringEnum, stringEnum } from "../schema/typebox.js";
import { type AnyAgentTool, jsonResult, readStringParam } from "./common.js"; import { type AnyAgentTool, jsonResult, readStringParam } from "./common.js";
import { callGatewayTool, type GatewayCallOptions } from "./gateway.js"; import { callGatewayTool, type GatewayCallOptions } from "./gateway.js";
// NOTE: We use Type.Object({}, { additionalProperties: true }) for job/patch // NOTE: We use Type.Object({}, { additionalProperties: true }) for job/patch
// instead of CronAddParamsSchema/CronJobPatchSchema because: // instead of CronAddParamsSchema/CronJobPatchSchema because the gateway schemas
// // contain nested unions. Tool schemas need to stay provider-friendly, so we
// 1. CronAddParamsSchema contains nested Type.Union (for schedule, payload, etc.) // accept "any object" here and validate at runtime.
// 2. TypeBox compiles Type.Union to JSON Schema `anyOf`
// 3. pi-ai's sanitizeSchemaForGoogle() strips `anyOf` from nested properties
// 4. This leaves empty schemas `{}` which Claude rejects as invalid
//
// The actual validation happens at runtime via normalizeCronJobCreate/Patch
// and the gateway's validateCronAddParams. This schema just needs to accept
// any object so the AI can pass through the job definition.
//
// See: https://github.com/anthropics/anthropic-cookbook/blob/main/misc/tool_use_best_practices.md
// Claude requires valid JSON Schema 2020-12 with explicit types.
const CronToolSchema = Type.Union([ const CRON_ACTIONS = [
Type.Object({ "status",
action: Type.Literal("status"), "list",
gatewayUrl: Type.Optional(Type.String()), "add",
gatewayToken: Type.Optional(Type.String()), "update",
timeoutMs: Type.Optional(Type.Number()), "remove",
}), "run",
Type.Object({ "runs",
action: Type.Literal("list"), "wake",
gatewayUrl: Type.Optional(Type.String()), ] as const;
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()), const CRON_WAKE_MODES = ["now", "next-heartbeat"] as const;
includeDisabled: Type.Optional(Type.Boolean()),
}), // Flattened schema: runtime validates per-action requirements.
Type.Object({ const CronToolSchema = Type.Object({
action: Type.Literal("add"), action: stringEnum(CRON_ACTIONS),
gatewayUrl: Type.Optional(Type.String()), gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()), gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()), timeoutMs: Type.Optional(Type.Number()),
job: Type.Object({}, { additionalProperties: true }), includeDisabled: Type.Optional(Type.Boolean()),
}), job: Type.Optional(Type.Object({}, { additionalProperties: true })),
Type.Object({ jobId: Type.Optional(Type.String()),
action: Type.Literal("update"), id: Type.Optional(Type.String()),
gatewayUrl: Type.Optional(Type.String()), patch: Type.Optional(Type.Object({}, { additionalProperties: true })),
gatewayToken: Type.Optional(Type.String()), text: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()), mode: optionalStringEnum(CRON_WAKE_MODES),
jobId: Type.Optional(Type.String()), });
id: Type.Optional(Type.String()),
patch: Type.Object({}, { additionalProperties: true }),
}),
Type.Object({
action: Type.Literal("remove"),
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
jobId: Type.Optional(Type.String()),
id: Type.Optional(Type.String()),
}),
Type.Object({
action: Type.Literal("run"),
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
jobId: Type.Optional(Type.String()),
id: Type.Optional(Type.String()),
}),
Type.Object({
action: Type.Literal("runs"),
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
jobId: Type.Optional(Type.String()),
id: Type.Optional(Type.String()),
}),
Type.Object({
action: Type.Literal("wake"),
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
text: Type.String(),
mode: Type.Optional(
Type.Union([Type.Literal("now"), Type.Literal("next-heartbeat")]),
),
}),
]);
export function createCronTool(): AnyAgentTool { export function createCronTool(): AnyAgentTool {
return { return {

View File

@ -31,6 +31,7 @@ import {
function readParentIdParam( function readParentIdParam(
params: Record<string, unknown>, params: Record<string, unknown>,
): string | null | undefined { ): string | null | undefined {
if (params.clearParent === true) return null;
if (params.parentId === null) return null; if (params.parentId === null) return null;
return readStringParam(params, "parentId"); return readStringParam(params, "parentId");
} }

View File

@ -218,6 +218,26 @@ describe("handleDiscordGuildAction - channel management", () => {
}); });
}); });
it("clears the channel parent when clearParent is true", async () => {
await handleDiscordGuildAction(
"channelEdit",
{
channelId: "C1",
clearParent: true,
},
channelsEnabled,
);
expect(editChannelDiscord).toHaveBeenCalledWith({
channelId: "C1",
name: undefined,
topic: undefined,
position: undefined,
parentId: null,
nsfw: undefined,
rateLimitPerUser: undefined,
});
});
it("deletes a channel", async () => { it("deletes a channel", async () => {
await handleDiscordGuildAction( await handleDiscordGuildAction(
"channelDelete", "channelDelete",
@ -264,6 +284,24 @@ describe("handleDiscordGuildAction - channel management", () => {
}); });
}); });
it("clears the channel parent on move when clearParent is true", async () => {
await handleDiscordGuildAction(
"channelMove",
{
guildId: "G1",
channelId: "C1",
clearParent: true,
},
channelsEnabled,
);
expect(moveChannelDiscord).toHaveBeenCalledWith({
guildId: "G1",
channelId: "C1",
parentId: null,
position: undefined,
});
});
it("creates a category with type=4", async () => { it("creates a category with type=4", async () => {
await handleDiscordGuildAction( await handleDiscordGuildAction(
"categoryCreate", "categoryCreate",

View File

@ -7,6 +7,7 @@ import {
type RestartSentinelPayload, type RestartSentinelPayload,
writeRestartSentinel, writeRestartSentinel,
} from "../../infra/restart-sentinel.js"; } from "../../infra/restart-sentinel.js";
import { stringEnum } from "../schema/typebox.js";
import { type AnyAgentTool, jsonResult, readStringParam } from "./common.js"; import { type AnyAgentTool, jsonResult, readStringParam } from "./common.js";
import { callGatewayTool } from "./gateway.js"; import { callGatewayTool } from "./gateway.js";
@ -18,16 +19,11 @@ const GATEWAY_ACTIONS = [
"update.run", "update.run",
] as const; ] as const;
type GatewayAction = (typeof GATEWAY_ACTIONS)[number];
// NOTE: Using a flattened object schema instead of Type.Union([Type.Object(...), ...]) // NOTE: Using a flattened object schema instead of Type.Union([Type.Object(...), ...])
// because Claude API on Vertex AI rejects nested anyOf schemas as invalid JSON Schema. // because Claude API on Vertex AI rejects nested anyOf schemas as invalid JSON Schema.
// The discriminator (action) determines which properties are relevant; runtime validates. // The discriminator (action) determines which properties are relevant; runtime validates.
const GatewayToolSchema = Type.Object({ const GatewayToolSchema = Type.Object({
action: Type.Unsafe<GatewayAction>({ action: stringEnum(GATEWAY_ACTIONS),
type: "string",
enum: [...GATEWAY_ACTIONS],
}),
// restart // restart
delayMs: Type.Optional(Type.Number()), delayMs: Type.Optional(Type.Number()),
reason: Type.Optional(Type.String()), reason: Type.Optional(Type.String()),

View File

@ -16,6 +16,7 @@ import {
type ProviderMessageActionName, type ProviderMessageActionName,
} from "../../providers/plugins/types.js"; } from "../../providers/plugins/types.js";
import { normalizeAccountId } from "../../routing/session-key.js"; import { normalizeAccountId } from "../../routing/session-key.js";
import { stringEnum } from "../schema/typebox.js";
import type { AnyAgentTool } from "./common.js"; import type { AnyAgentTool } from "./common.js";
import { jsonResult, readNumberParam, readStringParam } from "./common.js"; import { jsonResult, readNumberParam, readStringParam } from "./common.js";
@ -90,12 +91,17 @@ const MessageToolCommonSchema = {
timeoutMs: Type.Optional(Type.Number()), timeoutMs: Type.Optional(Type.Number()),
name: Type.Optional(Type.String()), name: Type.Optional(Type.String()),
type: Type.Optional(Type.Number()), type: Type.Optional(Type.Number()),
parentId: Type.Optional(Type.Union([Type.String(), Type.Null()])), parentId: Type.Optional(Type.String()),
topic: Type.Optional(Type.String()), topic: Type.Optional(Type.String()),
position: Type.Optional(Type.Number()), position: Type.Optional(Type.Number()),
nsfw: Type.Optional(Type.Boolean()), nsfw: Type.Optional(Type.Boolean()),
rateLimitPerUser: Type.Optional(Type.Number()), rateLimitPerUser: Type.Optional(Type.Number()),
categoryId: Type.Optional(Type.String()), categoryId: Type.Optional(Type.String()),
clearParent: Type.Optional(
Type.Boolean({
description: "Clear the parent/category when supported by the provider.",
}),
),
}; };
function buildMessageToolSchemaFromActions( function buildMessageToolSchemaFromActions(
@ -105,31 +111,10 @@ function buildMessageToolSchemaFromActions(
const props: Record<string, unknown> = { ...MessageToolCommonSchema }; const props: Record<string, unknown> = { ...MessageToolCommonSchema };
if (!options.includeButtons) delete props.buttons; if (!options.includeButtons) delete props.buttons;
const schemas: Array<ReturnType<typeof Type.Object>> = []; return Type.Object({
if (actions.includes("send")) { action: stringEnum(actions),
schemas.push( ...props,
Type.Object({ });
action: Type.Literal("send"),
to: Type.String(),
message: Type.String(),
...props,
}),
);
}
const nonSendActions = actions.filter((action) => action !== "send");
if (nonSendActions.length > 0) {
schemas.push(
Type.Object({
action: Type.Union(
nonSendActions.map((action) => Type.Literal(action)),
),
...props,
}),
);
}
return schemas.length === 1 ? schemas[0] : Type.Union(schemas);
} }
const MessageToolSchema = buildMessageToolSchemaFromActions(AllMessageActions, { const MessageToolSchema = buildMessageToolSchemaFromActions(AllMessageActions, {

View File

@ -18,151 +18,73 @@ import {
} from "../../cli/nodes-screen.js"; } from "../../cli/nodes-screen.js";
import { parseDurationMs } from "../../cli/parse-duration.js"; import { parseDurationMs } from "../../cli/parse-duration.js";
import { imageMimeFromFormat } from "../../media/mime.js"; import { imageMimeFromFormat } from "../../media/mime.js";
import { optionalStringEnum, stringEnum } from "../schema/typebox.js";
import { sanitizeToolResultImages } from "../tool-images.js"; import { sanitizeToolResultImages } from "../tool-images.js";
import { type AnyAgentTool, jsonResult, readStringParam } from "./common.js"; import { type AnyAgentTool, jsonResult, readStringParam } from "./common.js";
import { callGatewayTool, type GatewayCallOptions } from "./gateway.js"; import { callGatewayTool, type GatewayCallOptions } from "./gateway.js";
import { resolveNodeId } from "./nodes-utils.js"; import { resolveNodeId } from "./nodes-utils.js";
const NodesToolSchema = Type.Union([ const NODES_TOOL_ACTIONS = [
Type.Object({ "status",
action: Type.Literal("status"), "describe",
gatewayUrl: Type.Optional(Type.String()), "pending",
gatewayToken: Type.Optional(Type.String()), "approve",
timeoutMs: Type.Optional(Type.Number()), "reject",
"notify",
"camera_snap",
"camera_list",
"camera_clip",
"screen_record",
"location_get",
"run",
] as const;
const NOTIFY_PRIORITIES = ["passive", "active", "timeSensitive"] as const;
const NOTIFY_DELIVERIES = ["system", "overlay", "auto"] as const;
const CAMERA_FACING = ["front", "back", "both"] as const;
const LOCATION_ACCURACY = ["coarse", "balanced", "precise"] as const;
// Flattened schema: runtime validates per-action requirements.
const NodesToolSchema = Type.Object({
action: stringEnum(NODES_TOOL_ACTIONS),
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
node: Type.Optional(Type.String()),
requestId: Type.Optional(Type.String()),
// notify
title: Type.Optional(Type.String()),
body: Type.Optional(Type.String()),
sound: Type.Optional(Type.String()),
priority: optionalStringEnum(NOTIFY_PRIORITIES),
delivery: optionalStringEnum(NOTIFY_DELIVERIES),
// camera_snap / camera_clip
facing: optionalStringEnum(CAMERA_FACING, {
description: "camera_snap: front/back/both; camera_clip: front/back only.",
}), }),
Type.Object({ maxWidth: Type.Optional(Type.Number()),
action: Type.Literal("describe"), quality: Type.Optional(Type.Number()),
gatewayUrl: Type.Optional(Type.String()), delayMs: Type.Optional(Type.Number()),
gatewayToken: Type.Optional(Type.String()), deviceId: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()), duration: Type.Optional(Type.String()),
node: Type.String(), durationMs: Type.Optional(Type.Number()),
}), includeAudio: Type.Optional(Type.Boolean()),
Type.Object({ // screen_record
action: Type.Literal("pending"), fps: Type.Optional(Type.Number()),
gatewayUrl: Type.Optional(Type.String()), screenIndex: Type.Optional(Type.Number()),
gatewayToken: Type.Optional(Type.String()), outPath: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()), // location_get
}), maxAgeMs: Type.Optional(Type.Number()),
Type.Object({ locationTimeoutMs: Type.Optional(Type.Number()),
action: Type.Literal("approve"), desiredAccuracy: optionalStringEnum(LOCATION_ACCURACY),
gatewayUrl: Type.Optional(Type.String()), // run
gatewayToken: Type.Optional(Type.String()), command: Type.Optional(Type.Array(Type.String())),
timeoutMs: Type.Optional(Type.Number()), cwd: Type.Optional(Type.String()),
requestId: Type.String(), env: Type.Optional(Type.Array(Type.String())),
}), commandTimeoutMs: Type.Optional(Type.Number()),
Type.Object({ invokeTimeoutMs: Type.Optional(Type.Number()),
action: Type.Literal("reject"), needsScreenRecording: Type.Optional(Type.Boolean()),
gatewayUrl: Type.Optional(Type.String()), });
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
requestId: Type.String(),
}),
Type.Object({
action: Type.Literal("notify"),
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
node: Type.String(),
title: Type.Optional(Type.String()),
body: Type.Optional(Type.String()),
sound: Type.Optional(Type.String()),
priority: Type.Optional(
Type.Union([
Type.Literal("passive"),
Type.Literal("active"),
Type.Literal("timeSensitive"),
]),
),
delivery: Type.Optional(
Type.Union([
Type.Literal("system"),
Type.Literal("overlay"),
Type.Literal("auto"),
]),
),
}),
Type.Object({
action: Type.Literal("camera_snap"),
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
node: Type.String(),
facing: Type.Optional(
Type.Union([
Type.Literal("front"),
Type.Literal("back"),
Type.Literal("both"),
]),
),
maxWidth: Type.Optional(Type.Number()),
quality: Type.Optional(Type.Number()),
delayMs: Type.Optional(Type.Number()),
deviceId: Type.Optional(Type.String()),
}),
Type.Object({
action: Type.Literal("camera_list"),
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
node: Type.String(),
}),
Type.Object({
action: Type.Literal("camera_clip"),
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
node: Type.String(),
facing: Type.Optional(
Type.Union([Type.Literal("front"), Type.Literal("back")]),
),
duration: Type.Optional(Type.String()),
durationMs: Type.Optional(Type.Number()),
includeAudio: Type.Optional(Type.Boolean()),
deviceId: Type.Optional(Type.String()),
}),
Type.Object({
action: Type.Literal("screen_record"),
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
node: Type.String(),
duration: Type.Optional(Type.String()),
durationMs: Type.Optional(Type.Number()),
fps: Type.Optional(Type.Number()),
screenIndex: Type.Optional(Type.Number()),
includeAudio: Type.Optional(Type.Boolean()),
outPath: Type.Optional(Type.String()),
}),
Type.Object({
action: Type.Literal("location_get"),
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
node: Type.String(),
maxAgeMs: Type.Optional(Type.Number()),
locationTimeoutMs: Type.Optional(Type.Number()),
desiredAccuracy: Type.Optional(
Type.Union([
Type.Literal("coarse"),
Type.Literal("balanced"),
Type.Literal("precise"),
]),
),
}),
Type.Object({
action: Type.Literal("run"),
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
node: Type.String(),
command: Type.Array(Type.String()),
cwd: Type.Optional(Type.String()),
env: Type.Optional(Type.Array(Type.String())),
commandTimeoutMs: Type.Optional(Type.Number()),
invokeTimeoutMs: Type.Optional(Type.Number()),
needsScreenRecording: Type.Optional(Type.Boolean()),
}),
]);
export function createNodesTool(): AnyAgentTool { export function createNodesTool(): AnyAgentTool {
return { return {

View File

@ -12,6 +12,7 @@ import {
import type { GatewayMessageProvider } from "../../utils/message-provider.js"; import type { GatewayMessageProvider } from "../../utils/message-provider.js";
import { resolveAgentConfig } from "../agent-scope.js"; import { resolveAgentConfig } from "../agent-scope.js";
import { AGENT_LANE_SUBAGENT } from "../lanes.js"; import { AGENT_LANE_SUBAGENT } from "../lanes.js";
import { optionalStringEnum } from "../schema/typebox.js";
import { buildSubagentSystemPrompt } from "../subagent-announce.js"; import { buildSubagentSystemPrompt } from "../subagent-announce.js";
import { registerSubagentRun } from "../subagent-registry.js"; import { registerSubagentRun } from "../subagent-registry.js";
import type { AnyAgentTool } from "./common.js"; import type { AnyAgentTool } from "./common.js";
@ -30,9 +31,7 @@ const SessionsSpawnToolSchema = Type.Object({
runTimeoutSeconds: Type.Optional(Type.Number({ minimum: 0 })), runTimeoutSeconds: Type.Optional(Type.Number({ minimum: 0 })),
// Back-compat alias. Prefer runTimeoutSeconds. // Back-compat alias. Prefer runTimeoutSeconds.
timeoutSeconds: Type.Optional(Type.Number({ minimum: 0 })), timeoutSeconds: Type.Optional(Type.Number({ minimum: 0 })),
cleanup: Type.Optional( cleanup: optionalStringEnum(["delete", "keep"] as const),
Type.Union([Type.Literal("delete"), Type.Literal("keep")]),
),
}); });
function normalizeModelSelection(value: unknown): string | undefined { function normalizeModelSelection(value: unknown): string | undefined {

View File

@ -69,6 +69,97 @@ describe("directive behavior", () => {
vi.restoreAllMocks(); vi.restoreAllMocks();
}); });
it("accepts /thinking xhigh for codex models", async () => {
await withTempHome(async (home) => {
const storePath = path.join(home, "sessions.json");
const res = await getReplyFromConfig(
{
Body: "/thinking xhigh",
From: "+1004",
To: "+2000",
},
{},
{
agents: {
defaults: {
model: "openai-codex/gpt-5.2-codex",
workspace: path.join(home, "clawd"),
},
},
whatsapp: { allowFrom: ["*"] },
session: { store: storePath },
},
);
const texts = (Array.isArray(res) ? res : [res])
.map((entry) => entry?.text)
.filter(Boolean);
expect(texts).toContain("Thinking level set to xhigh.");
});
});
it("accepts /thinking xhigh for openai gpt-5.2", async () => {
await withTempHome(async (home) => {
const storePath = path.join(home, "sessions.json");
const res = await getReplyFromConfig(
{
Body: "/thinking xhigh",
From: "+1004",
To: "+2000",
},
{},
{
agents: {
defaults: {
model: "openai/gpt-5.2",
workspace: path.join(home, "clawd"),
},
},
whatsapp: { allowFrom: ["*"] },
session: { store: storePath },
},
);
const texts = (Array.isArray(res) ? res : [res])
.map((entry) => entry?.text)
.filter(Boolean);
expect(texts).toContain("Thinking level set to xhigh.");
});
});
it("rejects /thinking xhigh for non-codex models", async () => {
await withTempHome(async (home) => {
const storePath = path.join(home, "sessions.json");
const res = await getReplyFromConfig(
{
Body: "/thinking xhigh",
From: "+1004",
To: "+2000",
},
{},
{
agents: {
defaults: {
model: "openai/gpt-4.1-mini",
workspace: path.join(home, "clawd"),
},
},
whatsapp: { allowFrom: ["*"] },
session: { store: storePath },
},
);
const texts = (Array.isArray(res) ? res : [res])
.map((entry) => entry?.text)
.filter(Boolean);
expect(texts).toContain(
'Thinking level "xhigh" is only supported for openai/gpt-5.2, openai-codex/gpt-5.2-codex or openai-codex/gpt-5.1-codex.',
);
});
});
it("keeps reserved command aliases from matching after trimming", async () => { it("keeps reserved command aliases from matching after trimming", async () => {
await withTempHome(async (home) => { await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockReset(); vi.mocked(runEmbeddedPiAgent).mockReset();

View File

@ -29,7 +29,10 @@ import {
type ClawdbotConfig, type ClawdbotConfig,
loadConfig, loadConfig,
} from "../config/config.js"; } from "../config/config.js";
import { resolveSessionFilePath } from "../config/sessions.js"; import {
resolveSessionFilePath,
saveSessionStore,
} from "../config/sessions.js";
import { logVerbose } from "../globals.js"; import { logVerbose } from "../globals.js";
import { clearCommandLane, getQueueSize } from "../process/command-queue.js"; import { clearCommandLane, getQueueSize } from "../process/command-queue.js";
import { getProviderDock } from "../providers/dock.js"; import { getProviderDock } from "../providers/dock.js";
@ -94,8 +97,10 @@ import {
import type { MsgContext, TemplateContext } from "./templating.js"; import type { MsgContext, TemplateContext } from "./templating.js";
import { import {
type ElevatedLevel, type ElevatedLevel,
formatXHighModelHint,
normalizeThinkLevel, normalizeThinkLevel,
type ReasoningLevel, type ReasoningLevel,
supportsXHighThinking,
type ThinkLevel, type ThinkLevel,
type VerboseLevel, type VerboseLevel,
} from "./thinking.js"; } from "./thinking.js";
@ -1197,7 +1202,10 @@ export async function getReplyFromConfig(
if (!resolvedThinkLevel && prefixedCommandBody) { if (!resolvedThinkLevel && prefixedCommandBody) {
const parts = prefixedCommandBody.split(/\s+/); const parts = prefixedCommandBody.split(/\s+/);
const maybeLevel = normalizeThinkLevel(parts[0]); const maybeLevel = normalizeThinkLevel(parts[0]);
if (maybeLevel) { if (
maybeLevel &&
(maybeLevel !== "xhigh" || supportsXHighThinking(provider, model))
) {
resolvedThinkLevel = maybeLevel; resolvedThinkLevel = maybeLevel;
prefixedCommandBody = parts.slice(1).join(" ").trim(); prefixedCommandBody = parts.slice(1).join(" ").trim();
} }
@ -1205,6 +1213,33 @@ export async function getReplyFromConfig(
if (!resolvedThinkLevel) { if (!resolvedThinkLevel) {
resolvedThinkLevel = await modelState.resolveDefaultThinkingLevel(); resolvedThinkLevel = await modelState.resolveDefaultThinkingLevel();
} }
if (
resolvedThinkLevel === "xhigh" &&
!supportsXHighThinking(provider, model)
) {
const explicitThink =
directives.hasThinkDirective && directives.thinkLevel !== undefined;
if (explicitThink) {
typing.cleanup();
return {
text: `Thinking level "xhigh" is only supported for ${formatXHighModelHint()}. Use /think high or switch to one of those models.`,
};
}
resolvedThinkLevel = "high";
if (
sessionEntry &&
sessionStore &&
sessionKey &&
sessionEntry.thinkingLevel === "xhigh"
) {
sessionEntry.thinkingLevel = "high";
sessionEntry.updatedAt = Date.now();
sessionStore[sessionKey] = sessionEntry;
if (storePath) {
await saveSessionStore(storePath, sessionStore);
}
}
}
const sessionIdFinal = sessionId ?? crypto.randomUUID(); const sessionIdFinal = sessionId ?? crypto.randomUUID();
const sessionFile = resolveSessionFilePath(sessionIdFinal, sessionEntry); const sessionFile = resolveSessionFilePath(sessionIdFinal, sessionEntry);
const queueBodyBase = transcribedText const queueBodyBase = transcribedText

View File

@ -1,5 +1,6 @@
import crypto from "node:crypto"; import crypto from "node:crypto";
import fs from "node:fs"; import fs from "node:fs";
import { resolveAgentModelFallbacksOverride } from "../../agents/agent-scope.js";
import { runCliAgent } from "../../agents/cli-runner.js"; import { runCliAgent } from "../../agents/cli-runner.js";
import { getCliSessionId, setCliSessionId } from "../../agents/cli-session.js"; import { getCliSessionId, setCliSessionId } from "../../agents/cli-session.js";
import { lookupContextTokens } from "../../agents/context.js"; import { lookupContextTokens } from "../../agents/context.js";
@ -394,6 +395,10 @@ export async function runReplyAgent(params: {
cfg: followupRun.run.config, cfg: followupRun.run.config,
provider: followupRun.run.provider, provider: followupRun.run.provider,
model: followupRun.run.model, model: followupRun.run.model,
fallbacksOverride: resolveAgentModelFallbacksOverride(
followupRun.run.config,
resolveAgentIdFromSessionKey(followupRun.run.sessionKey),
),
run: (provider, model) => run: (provider, model) =>
runEmbeddedPiAgent({ runEmbeddedPiAgent({
sessionId: followupRun.run.sessionId, sessionId: followupRun.run.sessionId,
@ -586,6 +591,10 @@ export async function runReplyAgent(params: {
cfg: followupRun.run.config, cfg: followupRun.run.config,
provider: followupRun.run.provider, provider: followupRun.run.provider,
model: followupRun.run.model, model: followupRun.run.model,
fallbacksOverride: resolveAgentModelFallbacksOverride(
followupRun.run.config,
resolveAgentIdFromSessionKey(followupRun.run.sessionKey),
),
run: (provider, model) => { run: (provider, model) => {
if (isCliProvider(provider, followupRun.run.config)) { if (isCliProvider(provider, followupRun.run.config)) {
const startedAt = Date.now(); const startedAt = Date.now();

View File

@ -155,6 +155,30 @@ function buildUsageReply(): ReplyPayload {
}; };
} }
function buildBashDisabledReply(provider?: string): ReplyPayload {
const providerKey = provider?.trim().toLowerCase() || "<provider>";
return {
text: [
"⚠️ /bash is disabled (default).",
"Enable it in your config (host-only; trusted senders only):",
"```json5",
"{",
" commands: {",
" bash: true",
" },",
" tools: {",
" elevated: {",
" enabled: true,",
` allowFrom: { "${providerKey}": ["<sender-id>"] }`,
" }",
" }",
"}",
"```",
"Restart the Gateway after updating config.",
].join("\n"),
};
}
function formatElevatedUnavailableMessage(params: { function formatElevatedUnavailableMessage(params: {
runtimeSandboxed: boolean; runtimeSandboxed: boolean;
failures: Array<{ gate: string; key: string }>; failures: Array<{ gate: string; key: string }>;
@ -199,9 +223,13 @@ export async function handleBashChatCommand(params: {
}; };
}): Promise<ReplyPayload> { }): Promise<ReplyPayload> {
if (params.cfg.commands?.bash !== true) { if (params.cfg.commands?.bash !== true) {
return { logVerbose("Blocked /bash: commands.bash is disabled.");
text: "⚠️ bash is disabled. Set commands.bash=true to enable.", const provider =
}; params.ctx.Provider ??
params.ctx.Surface ??
params.ctx.OriginatingChannel ??
undefined;
return buildBashDisabledReply(provider ?? undefined);
} }
const agentId = const agentId =

View File

@ -1,6 +1,6 @@
import { import {
resolveAgentConfig,
resolveAgentDir, resolveAgentDir,
resolveAgentModelPrimary,
resolveDefaultAgentId, resolveDefaultAgentId,
resolveSessionAgentId, resolveSessionAgentId,
} from "../../agents/agent-scope.js"; } from "../../agents/agent-scope.js";
@ -37,6 +37,11 @@ import { applyVerboseOverride } from "../../sessions/level-overrides.js";
import { shortenHomePath } from "../../utils.js"; import { shortenHomePath } from "../../utils.js";
import { extractModelDirective } from "../model.js"; import { extractModelDirective } from "../model.js";
import type { MsgContext } from "../templating.js"; import type { MsgContext } from "../templating.js";
import {
formatThinkingLevels,
formatXHighModelHint,
supportsXHighThinking,
} from "../thinking.js";
import type { ReplyPayload } from "../types.js"; import type { ReplyPayload } from "../types.js";
import { import {
type ElevatedLevel, type ElevatedLevel,
@ -778,6 +783,7 @@ export async function handleDirectiveOnly(params: {
allowedModelCatalog, allowedModelCatalog,
resetModelOverride, resetModelOverride,
provider, provider,
model,
initialModelLabel, initialModelLabel,
formatModelSwitchEvent, formatModelSwitchEvent,
currentThinkLevel, currentThinkLevel,
@ -943,6 +949,117 @@ export async function handleDirectiveOnly(params: {
} }
} }
let modelSelection: ModelDirectiveSelection | undefined;
let profileOverride: string | undefined;
if (directives.hasModelDirective && directives.rawModelDirective) {
const raw = directives.rawModelDirective.trim();
if (/^[0-9]+$/.test(raw)) {
const resolvedDefault = resolveConfiguredModelRef({
cfg: params.cfg,
defaultProvider,
defaultModel,
});
const pickerCatalog: ModelPickerCatalogEntry[] = (() => {
const keys = new Set<string>();
const out: ModelPickerCatalogEntry[] = [];
const push = (entry: ModelPickerCatalogEntry) => {
const provider = normalizeProviderId(entry.provider);
const id = String(entry.id ?? "").trim();
if (!provider || !id) return;
const key = modelKey(provider, id);
if (keys.has(key)) return;
keys.add(key);
out.push({ provider, id, name: entry.name });
};
for (const entry of allowedModelCatalog) push(entry);
for (const rawKey of Object.keys(
params.cfg.agents?.defaults?.models ?? {},
)) {
const resolved = resolveModelRefFromString({
raw: String(rawKey),
defaultProvider,
aliasIndex,
});
if (!resolved) continue;
push({
provider: resolved.ref.provider,
id: resolved.ref.model,
name: resolved.ref.model,
});
}
if (resolvedDefault.model) {
push({
provider: resolvedDefault.provider,
id: resolvedDefault.model,
name: resolvedDefault.model,
});
}
return out;
})();
const items = buildModelPickerItems(pickerCatalog);
const index = Number.parseInt(raw, 10) - 1;
const item = Number.isFinite(index) ? items[index] : undefined;
if (!item) {
return {
text: `Invalid model selection "${raw}". Use /model to list.`,
};
}
const picked = pickProviderForModel({
item,
preferredProvider: params.provider,
});
if (!picked) {
return {
text: `Invalid model selection "${raw}". Use /model to list.`,
};
}
const key = `${picked.provider}/${picked.model}`;
const aliases = aliasIndex.byKey.get(key);
const alias = aliases && aliases.length > 0 ? aliases[0] : undefined;
modelSelection = {
provider: picked.provider,
model: picked.model,
isDefault:
picked.provider === defaultProvider && picked.model === defaultModel,
...(alias ? { alias } : {}),
};
} else {
const resolved = resolveModelDirectiveSelection({
raw,
defaultProvider,
defaultModel,
aliasIndex,
allowedModelKeys,
});
if (resolved.error) {
return { text: resolved.error };
}
modelSelection = resolved.selection;
}
if (modelSelection && directives.rawModelProfile) {
const profileResolved = resolveProfileOverride({
rawProfile: directives.rawModelProfile,
provider: modelSelection.provider,
cfg: params.cfg,
agentDir,
});
if (profileResolved.error) {
return { text: profileResolved.error };
}
profileOverride = profileResolved.profileId;
}
}
if (directives.rawModelProfile && !modelSelection) {
return { text: "Auth profile override requires a model selection." };
}
const resolvedProvider = modelSelection?.provider ?? provider;
const resolvedModel = modelSelection?.model ?? model;
if (directives.hasThinkDirective && !directives.thinkLevel) { if (directives.hasThinkDirective && !directives.thinkLevel) {
// If no argument was provided, show the current level // If no argument was provided, show the current level
if (!directives.rawThinkLevel) { if (!directives.rawThinkLevel) {
@ -950,12 +1067,12 @@ export async function handleDirectiveOnly(params: {
return { return {
text: withOptions( text: withOptions(
`Current thinking level: ${level}.`, `Current thinking level: ${level}.`,
"off, minimal, low, medium, high", formatThinkingLevels(resolvedProvider, resolvedModel),
), ),
}; };
} }
return { return {
text: `Unrecognized thinking level "${directives.rawThinkLevel}". Valid levels: off, minimal, low, medium, high.`, text: `Unrecognized thinking level "${directives.rawThinkLevel}". Valid levels: ${formatThinkingLevels(resolvedProvider, resolvedModel)}.`,
}; };
} }
if (directives.hasVerboseDirective && !directives.verboseLevel) { if (directives.hasVerboseDirective && !directives.verboseLevel) {
@ -1098,126 +1215,25 @@ export async function handleDirectiveOnly(params: {
return { text: errors.join(" ") }; return { text: errors.join(" ") };
} }
let modelSelection: ModelDirectiveSelection | undefined; if (
let profileOverride: string | undefined; directives.hasThinkDirective &&
if (directives.hasModelDirective && directives.rawModelDirective) { directives.thinkLevel === "xhigh" &&
const raw = directives.rawModelDirective.trim(); !supportsXHighThinking(resolvedProvider, resolvedModel)
if (/^[0-9]+$/.test(raw)) { ) {
const resolvedDefault = resolveConfiguredModelRef({ return {
cfg: params.cfg, text: `Thinking level "xhigh" is only supported for ${formatXHighModelHint()}.`,
defaultProvider, };
defaultModel,
});
const pickerCatalog: ModelPickerCatalogEntry[] = (() => {
const keys = new Set<string>();
const out: ModelPickerCatalogEntry[] = [];
const push = (entry: ModelPickerCatalogEntry) => {
const provider = normalizeProviderId(entry.provider);
const id = String(entry.id ?? "").trim();
if (!provider || !id) return;
const key = modelKey(provider, id);
if (keys.has(key)) return;
keys.add(key);
out.push({ provider, id, name: entry.name });
};
for (const entry of allowedModelCatalog) push(entry);
for (const rawKey of Object.keys(
params.cfg.agents?.defaults?.models ?? {},
)) {
const resolved = resolveModelRefFromString({
raw: String(rawKey),
defaultProvider,
aliasIndex,
});
if (!resolved) continue;
push({
provider: resolved.ref.provider,
id: resolved.ref.model,
name: resolved.ref.model,
});
}
if (resolvedDefault.model) {
push({
provider: resolvedDefault.provider,
id: resolvedDefault.model,
name: resolvedDefault.model,
});
}
return out;
})();
const items = buildModelPickerItems(pickerCatalog);
const index = Number.parseInt(raw, 10) - 1;
const item = Number.isFinite(index) ? items[index] : undefined;
if (!item) {
return {
text: `Invalid model selection "${raw}". Use /model to list.`,
};
}
const picked = pickProviderForModel({
item,
preferredProvider: params.provider,
});
if (!picked) {
return {
text: `Invalid model selection "${raw}". Use /model to list.`,
};
}
const key = `${picked.provider}/${picked.model}`;
const aliases = aliasIndex.byKey.get(key);
const alias = aliases && aliases.length > 0 ? aliases[0] : undefined;
modelSelection = {
provider: picked.provider,
model: picked.model,
isDefault:
picked.provider === defaultProvider && picked.model === defaultModel,
...(alias ? { alias } : {}),
};
} else {
const resolved = resolveModelDirectiveSelection({
raw,
defaultProvider,
defaultModel,
aliasIndex,
allowedModelKeys,
});
if (resolved.error) {
return { text: resolved.error };
}
modelSelection = resolved.selection;
}
if (modelSelection) {
if (directives.rawModelProfile) {
const profileResolved = resolveProfileOverride({
rawProfile: directives.rawModelProfile,
provider: modelSelection.provider,
cfg: params.cfg,
agentDir,
});
if (profileResolved.error) {
return { text: profileResolved.error };
}
profileOverride = profileResolved.profileId;
}
const nextLabel = `${modelSelection.provider}/${modelSelection.model}`;
if (nextLabel !== initialModelLabel) {
enqueueSystemEvent(
formatModelSwitchEvent(nextLabel, modelSelection.alias),
{
sessionKey,
contextKey: `model:${nextLabel}`,
},
);
}
}
}
if (directives.rawModelProfile && !modelSelection) {
return { text: "Auth profile override requires a model selection." };
} }
const nextThinkLevel = directives.hasThinkDirective
? directives.thinkLevel
: ((sessionEntry?.thinkingLevel as ThinkLevel | undefined) ??
currentThinkLevel);
const shouldDowngradeXHigh =
!directives.hasThinkDirective &&
nextThinkLevel === "xhigh" &&
!supportsXHighThinking(resolvedProvider, resolvedModel);
if (sessionEntry && sessionStore && sessionKey) { if (sessionEntry && sessionStore && sessionKey) {
const prevElevatedLevel = const prevElevatedLevel =
currentElevatedLevel ?? currentElevatedLevel ??
@ -1239,6 +1255,9 @@ export async function handleDirectiveOnly(params: {
if (directives.thinkLevel === "off") delete sessionEntry.thinkingLevel; if (directives.thinkLevel === "off") delete sessionEntry.thinkingLevel;
else sessionEntry.thinkingLevel = directives.thinkLevel; else sessionEntry.thinkingLevel = directives.thinkLevel;
} }
if (shouldDowngradeXHigh) {
sessionEntry.thinkingLevel = "high";
}
if (directives.hasVerboseDirective && directives.verboseLevel) { if (directives.hasVerboseDirective && directives.verboseLevel) {
applyVerboseOverride(sessionEntry, directives.verboseLevel); applyVerboseOverride(sessionEntry, directives.verboseLevel);
} }
@ -1295,6 +1314,18 @@ export async function handleDirectiveOnly(params: {
if (storePath) { if (storePath) {
await saveSessionStore(storePath, sessionStore); await saveSessionStore(storePath, sessionStore);
} }
if (modelSelection) {
const nextLabel = `${modelSelection.provider}/${modelSelection.model}`;
if (nextLabel !== initialModelLabel) {
enqueueSystemEvent(
formatModelSwitchEvent(nextLabel, modelSelection.alias),
{
sessionKey,
contextKey: `model:${nextLabel}`,
},
);
}
}
if (elevatedChanged) { if (elevatedChanged) {
const nextElevated = (sessionEntry.elevatedLevel ?? const nextElevated = (sessionEntry.elevatedLevel ??
"off") as ElevatedLevel; "off") as ElevatedLevel;
@ -1345,6 +1376,11 @@ export async function handleDirectiveOnly(params: {
); );
if (shouldHintDirectRuntime) parts.push(formatElevatedRuntimeHint()); if (shouldHintDirectRuntime) parts.push(formatElevatedRuntimeHint());
} }
if (shouldDowngradeXHigh) {
parts.push(
`Thinking level set to high (xhigh not supported for ${resolvedProvider}/${resolvedModel}).`,
);
}
if (modelSelection) { if (modelSelection) {
const label = `${modelSelection.provider}/${modelSelection.model}`; const label = `${modelSelection.provider}/${modelSelection.model}`;
const labelWithAlias = modelSelection.alias const labelWithAlias = modelSelection.alias
@ -1593,7 +1629,7 @@ export function resolveDefaultModel(params: {
aliasIndex: ModelAliasIndex; aliasIndex: ModelAliasIndex;
} { } {
const agentModelOverride = params.agentId const agentModelOverride = params.agentId
? resolveAgentConfig(params.cfg, params.agentId)?.model?.trim() ? resolveAgentModelPrimary(params.cfg, params.agentId)
: undefined; : undefined;
const cfg = const cfg =
agentModelOverride && agentModelOverride.length > 0 agentModelOverride && agentModelOverride.length > 0

View File

@ -1,10 +1,12 @@
import crypto from "node:crypto"; import crypto from "node:crypto";
import { resolveAgentModelFallbacksOverride } from "../../agents/agent-scope.js";
import { lookupContextTokens } from "../../agents/context.js"; import { lookupContextTokens } from "../../agents/context.js";
import { DEFAULT_CONTEXT_TOKENS } from "../../agents/defaults.js"; import { DEFAULT_CONTEXT_TOKENS } from "../../agents/defaults.js";
import { runWithModelFallback } from "../../agents/model-fallback.js"; import { runWithModelFallback } from "../../agents/model-fallback.js";
import { runEmbeddedPiAgent } from "../../agents/pi-embedded.js"; import { runEmbeddedPiAgent } from "../../agents/pi-embedded.js";
import { hasNonzeroUsage } from "../../agents/usage.js"; import { hasNonzeroUsage } from "../../agents/usage.js";
import { import {
resolveAgentIdFromSessionKey,
type SessionEntry, type SessionEntry,
updateSessionStoreEntry, updateSessionStoreEntry,
} from "../../config/sessions.js"; } from "../../config/sessions.js";
@ -136,6 +138,10 @@ export function createFollowupRunner(params: {
cfg: queued.run.config, cfg: queued.run.config,
provider: queued.run.provider, provider: queued.run.provider,
model: queued.run.model, model: queued.run.model,
fallbacksOverride: resolveAgentModelFallbacksOverride(
queued.run.config,
resolveAgentIdFromSessionKey(queued.run.sessionKey),
),
run: (provider, model) => run: (provider, model) =>
runEmbeddedPiAgent({ runEmbeddedPiAgent({
sessionId: queued.run.sessionId, sessionId: queued.run.sessionId,

View File

@ -1,10 +1,34 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { normalizeReasoningLevel, normalizeThinkLevel } from "./thinking.js"; import {
listThinkingLevels,
normalizeReasoningLevel,
normalizeThinkLevel,
} from "./thinking.js";
describe("normalizeThinkLevel", () => { describe("normalizeThinkLevel", () => {
it("accepts mid as medium", () => { it("accepts mid as medium", () => {
expect(normalizeThinkLevel("mid")).toBe("medium"); expect(normalizeThinkLevel("mid")).toBe("medium");
}); });
it("accepts xhigh", () => {
expect(normalizeThinkLevel("xhigh")).toBe("xhigh");
});
});
describe("listThinkingLevels", () => {
it("includes xhigh for codex models", () => {
expect(listThinkingLevels(undefined, "gpt-5.2-codex")).toContain("xhigh");
});
it("includes xhigh for openai gpt-5.2", () => {
expect(listThinkingLevels("openai", "gpt-5.2")).toContain("xhigh");
});
it("excludes xhigh for non-codex models", () => {
expect(listThinkingLevels(undefined, "gpt-4.1-mini")).not.toContain(
"xhigh",
);
});
}); });
describe("normalizeReasoningLevel", () => { describe("normalizeReasoningLevel", () => {

View File

@ -1,9 +1,30 @@
export type ThinkLevel = "off" | "minimal" | "low" | "medium" | "high"; export type ThinkLevel =
| "off"
| "minimal"
| "low"
| "medium"
| "high"
| "xhigh";
export type VerboseLevel = "off" | "on"; export type VerboseLevel = "off" | "on";
export type ElevatedLevel = "off" | "on"; export type ElevatedLevel = "off" | "on";
export type ReasoningLevel = "off" | "on" | "stream"; export type ReasoningLevel = "off" | "on" | "stream";
export type UsageDisplayLevel = "off" | "on"; export type UsageDisplayLevel = "off" | "on";
export const XHIGH_MODEL_REFS = [
"openai/gpt-5.2",
"openai-codex/gpt-5.2-codex",
"openai-codex/gpt-5.1-codex",
] as const;
const XHIGH_MODEL_SET = new Set(
XHIGH_MODEL_REFS.map((entry) => entry.toLowerCase()),
);
const XHIGH_MODEL_IDS = new Set(
XHIGH_MODEL_REFS.map((entry) => entry.split("/")[1]?.toLowerCase()).filter(
(entry): entry is string => Boolean(entry),
),
);
// Normalize user-provided thinking level strings to the canonical enum. // Normalize user-provided thinking level strings to the canonical enum.
export function normalizeThinkLevel( export function normalizeThinkLevel(
raw?: string | null, raw?: string | null,
@ -32,10 +53,49 @@ export function normalizeThinkLevel(
].includes(key) ].includes(key)
) )
return "high"; return "high";
if (["xhigh", "x-high", "x_high"].includes(key)) return "xhigh";
if (["think"].includes(key)) return "minimal"; if (["think"].includes(key)) return "minimal";
return undefined; return undefined;
} }
export function supportsXHighThinking(
provider?: string | null,
model?: string | null,
): boolean {
const modelKey = model?.trim().toLowerCase();
if (!modelKey) return false;
const providerKey = provider?.trim().toLowerCase();
if (providerKey) {
return XHIGH_MODEL_SET.has(`${providerKey}/${modelKey}`);
}
return XHIGH_MODEL_IDS.has(modelKey);
}
export function listThinkingLevels(
provider?: string | null,
model?: string | null,
): ThinkLevel[] {
const levels: ThinkLevel[] = ["off", "minimal", "low", "medium", "high"];
if (supportsXHighThinking(provider, model)) levels.push("xhigh");
return levels;
}
export function formatThinkingLevels(
provider?: string | null,
model?: string | null,
separator = ", ",
): string {
return listThinkingLevels(provider, model).join(separator);
}
export function formatXHighModelHint(): string {
const refs = [...XHIGH_MODEL_REFS] as string[];
if (refs.length === 0) return "unknown model";
if (refs.length === 1) return refs[0];
if (refs.length === 2) return `${refs[0]} or ${refs[1]}`;
return `${refs.slice(0, -1).join(", ")} or ${refs[refs.length - 1]}`;
}
// Normalize verbose flags used to toggle agent verbosity. // Normalize verbose flags used to toggle agent verbosity.
export function normalizeVerboseLevel( export function normalizeVerboseLevel(
raw?: string | null, raw?: string | null,

View File

@ -1,6 +1,8 @@
import crypto from "node:crypto"; import crypto from "node:crypto";
import { import {
resolveAgentDir, resolveAgentDir,
resolveAgentModelFallbacksOverride,
resolveAgentModelPrimary,
resolveAgentWorkspaceDir, resolveAgentWorkspaceDir,
} from "../agents/agent-scope.js"; } from "../agents/agent-scope.js";
import { ensureAuthProfileStore } from "../agents/auth-profiles.js"; import { ensureAuthProfileStore } from "../agents/auth-profiles.js";
@ -28,8 +30,11 @@ import { hasNonzeroUsage } from "../agents/usage.js";
import { ensureAgentWorkspace } from "../agents/workspace.js"; import { ensureAgentWorkspace } from "../agents/workspace.js";
import type { MsgContext } from "../auto-reply/templating.js"; import type { MsgContext } from "../auto-reply/templating.js";
import { import {
formatThinkingLevels,
formatXHighModelHint,
normalizeThinkLevel, normalizeThinkLevel,
normalizeVerboseLevel, normalizeVerboseLevel,
supportsXHighThinking,
type ThinkLevel, type ThinkLevel,
type VerboseLevel, type VerboseLevel,
} from "../auto-reply/thinking.js"; } from "../auto-reply/thinking.js";
@ -214,17 +219,26 @@ export async function agentCommand(
ensureBootstrapFiles: !agentCfg?.skipBootstrap, ensureBootstrapFiles: !agentCfg?.skipBootstrap,
}); });
const workspaceDir = workspace.dir; const workspaceDir = workspace.dir;
const configuredModel = resolveConfiguredModelRef({
cfg,
defaultProvider: DEFAULT_PROVIDER,
defaultModel: DEFAULT_MODEL,
});
const thinkingLevelsHint = formatThinkingLevels(
configuredModel.provider,
configuredModel.model,
);
const thinkOverride = normalizeThinkLevel(opts.thinking); const thinkOverride = normalizeThinkLevel(opts.thinking);
const thinkOnce = normalizeThinkLevel(opts.thinkingOnce); const thinkOnce = normalizeThinkLevel(opts.thinkingOnce);
if (opts.thinking && !thinkOverride) { if (opts.thinking && !thinkOverride) {
throw new Error( throw new Error(
"Invalid thinking level. Use one of: off, minimal, low, medium, high.", `Invalid thinking level. Use one of: ${thinkingLevelsHint}.`,
); );
} }
if (opts.thinkingOnce && !thinkOnce) { if (opts.thinkingOnce && !thinkOnce) {
throw new Error( throw new Error(
"Invalid one-shot thinking level. Use one of: off, minimal, low, medium, high.", `Invalid one-shot thinking level. Use one of: ${thinkingLevelsHint}.`,
); );
} }
@ -333,9 +347,28 @@ export async function agentCommand(
await saveSessionStore(storePath, sessionStore); await saveSessionStore(storePath, sessionStore);
} }
const agentModelPrimary = resolveAgentModelPrimary(cfg, sessionAgentId);
const cfgForModelSelection = agentModelPrimary
? {
...cfg,
agents: {
...cfg.agents,
defaults: {
...cfg.agents?.defaults,
model: {
...(typeof cfg.agents?.defaults?.model === "object"
? cfg.agents.defaults.model
: undefined),
primary: agentModelPrimary,
},
},
},
}
: cfg;
const { provider: defaultProvider, model: defaultModel } = const { provider: defaultProvider, model: defaultModel } =
resolveConfiguredModelRef({ resolveConfiguredModelRef({
cfg, cfg: cfgForModelSelection,
defaultProvider: DEFAULT_PROVIDER, defaultProvider: DEFAULT_PROVIDER,
defaultModel: DEFAULT_MODEL, defaultModel: DEFAULT_MODEL,
}); });
@ -423,6 +456,29 @@ export async function agentCommand(
catalog: catalogForThinking, catalog: catalogForThinking,
}); });
} }
if (
resolvedThinkLevel === "xhigh" &&
!supportsXHighThinking(provider, model)
) {
const explicitThink = Boolean(thinkOnce || thinkOverride);
if (explicitThink) {
throw new Error(
`Thinking level "xhigh" is only supported for ${formatXHighModelHint()}.`,
);
}
resolvedThinkLevel = "high";
if (
sessionEntry &&
sessionStore &&
sessionKey &&
sessionEntry.thinkingLevel === "xhigh"
) {
sessionEntry.thinkingLevel = "high";
sessionEntry.updatedAt = Date.now();
sessionStore[sessionKey] = sessionEntry;
await saveSessionStore(storePath, sessionStore);
}
}
const sessionFile = resolveSessionFilePath(sessionId, sessionEntry, { const sessionFile = resolveSessionFilePath(sessionId, sessionEntry, {
agentId: sessionAgentId, agentId: sessionAgentId,
}); });
@ -442,6 +498,10 @@ export async function agentCommand(
cfg, cfg,
provider, provider,
model, model,
fallbacksOverride: resolveAgentModelFallbacksOverride(
cfg,
sessionAgentId,
),
run: (providerOverride, modelOverride) => { run: (providerOverride, modelOverride) => {
if (isCliProvider(providerOverride, cfg)) { if (isCliProvider(providerOverride, cfg)) {
const cliSessionId = getCliSessionId(sessionEntry, providerOverride); const cliSessionId = getCliSessionId(sessionEntry, providerOverride);

View File

@ -142,7 +142,15 @@ function resolveAgentModel(cfg: ClawdbotConfig, agentId: string) {
const entry = listAgentEntries(cfg).find( const entry = listAgentEntries(cfg).find(
(agent) => normalizeAgentId(agent.id) === normalizeAgentId(agentId), (agent) => normalizeAgentId(agent.id) === normalizeAgentId(agentId),
); );
if (entry?.model?.trim()) return entry.model.trim(); if (entry?.model) {
if (typeof entry.model === "string" && entry.model.trim()) {
return entry.model.trim();
}
if (typeof entry.model === "object") {
const primary = entry.model.primary?.trim();
if (primary) return primary;
}
}
const raw = cfg.agents?.defaults?.model; const raw = cfg.agents?.defaults?.model;
if (typeof raw === "string") return raw; if (typeof raw === "string") return raw;
return raw?.primary?.trim() || undefined; return raw?.primary?.trim() || undefined;

View File

@ -1,5 +1,5 @@
import { loginOpenAICodex, type OAuthCredentials } from "@mariozechner/pi-ai"; import { loginOpenAICodex, type OAuthCredentials } from "@mariozechner/pi-ai";
import { resolveAgentConfig } from "../agents/agent-scope.js"; import { resolveAgentModelPrimary } from "../agents/agent-scope.js";
import { import {
CLAUDE_CLI_PROFILE_ID, CLAUDE_CLI_PROFILE_ID,
CODEX_CLI_PROFILE_ID, CODEX_CLI_PROFILE_ID,
@ -152,7 +152,7 @@ export async function warnIfModelConfigLooksOff(
options?: { agentId?: string; agentDir?: string }, options?: { agentId?: string; agentDir?: string },
) { ) {
const agentModelOverride = options?.agentId const agentModelOverride = options?.agentId
? resolveAgentConfig(config, options.agentId)?.model?.trim() ? resolveAgentModelPrimary(config, options.agentId)
: undefined; : undefined;
const configWithModel = const configWithModel =
agentModelOverride && agentModelOverride.length > 0 agentModelOverride && agentModelOverride.length > 0

View File

@ -107,6 +107,8 @@ const FIELD_LABELS: Record<string, string> = {
"tools.audio.transcription.args": "Audio Transcription Args", "tools.audio.transcription.args": "Audio Transcription Args",
"tools.audio.transcription.timeoutSeconds": "tools.audio.transcription.timeoutSeconds":
"Audio Transcription Timeout (sec)", "Audio Transcription Timeout (sec)",
"tools.profile": "Tool Profile",
"agents.list[].tools.profile": "Agent Tool Profile",
"tools.exec.applyPatch.enabled": "Enable apply_patch", "tools.exec.applyPatch.enabled": "Enable apply_patch",
"tools.exec.applyPatch.allowModels": "apply_patch Model Allowlist", "tools.exec.applyPatch.allowModels": "apply_patch Model Allowlist",
"gateway.controlUi.basePath": "Control UI Base Path", "gateway.controlUi.basePath": "Control UI Base Path",

View File

@ -988,7 +988,11 @@ export type QueueConfig = {
drop?: QueueDropPolicy; drop?: QueueDropPolicy;
}; };
export type ToolProfileId = "minimal" | "coding" | "messaging" | "full";
export type AgentToolsConfig = { export type AgentToolsConfig = {
/** Base tool profile applied before allow/deny lists. */
profile?: ToolProfileId;
allow?: string[]; allow?: string[];
deny?: string[]; deny?: string[];
/** Per-agent elevated exec gate (can only further restrict global tools.elevated). */ /** Per-agent elevated exec gate (can only further restrict global tools.elevated). */
@ -1053,6 +1057,8 @@ export type MemorySearchConfig = {
}; };
export type ToolsConfig = { export type ToolsConfig = {
/** Base tool profile applied before allow/deny lists. */
profile?: ToolProfileId;
allow?: string[]; allow?: string[];
deny?: string[]; deny?: string[];
audio?: { audio?: {
@ -1121,13 +1127,22 @@ export type ToolsConfig = {
}; };
}; };
export type AgentModelConfig =
| string
| {
/** Primary model (provider/model). */
primary?: string;
/** Per-agent model fallbacks (provider/model). */
fallbacks?: string[];
};
export type AgentConfig = { export type AgentConfig = {
id: string; id: string;
default?: boolean; default?: boolean;
name?: string; name?: string;
workspace?: string; workspace?: string;
agentDir?: string; agentDir?: string;
model?: string; model?: AgentModelConfig;
memorySearch?: MemorySearchConfig; memorySearch?: MemorySearchConfig;
/** Human-like delay between block replies for this agent. */ /** Human-like delay between block replies for this agent. */
humanDelay?: HumanDelayConfig; humanDelay?: HumanDelayConfig;
@ -1618,7 +1633,7 @@ export type AgentDefaultsConfig = {
/** Vector memory search configuration (per-agent overrides supported). */ /** Vector memory search configuration (per-agent overrides supported). */
memorySearch?: MemorySearchConfig; memorySearch?: MemorySearchConfig;
/** Default thinking level when no /think directive is present. */ /** Default thinking level when no /think directive is present. */
thinkingDefault?: "off" | "minimal" | "low" | "medium" | "high"; thinkingDefault?: "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
/** Default verbose level when no /verbose directive is present. */ /** Default verbose level when no /verbose directive is present. */
verboseDefault?: "off" | "on"; verboseDefault?: "off" | "on";
/** Default elevated level when no /elevated directive is present. */ /** Default elevated level when no /elevated directive is present. */

View File

@ -839,6 +839,15 @@ const ToolPolicySchema = z
}) })
.optional(); .optional();
const ToolProfileSchema = z
.union([
z.literal("minimal"),
z.literal("coding"),
z.literal("messaging"),
z.literal("full"),
])
.optional();
// Provider docking: allowlists keyed by provider id (no schema updates when adding providers). // Provider docking: allowlists keyed by provider id (no schema updates when adding providers).
const ElevatedAllowFromSchema = z const ElevatedAllowFromSchema = z
.record(z.string(), z.array(z.union([z.string(), z.number()]))) .record(z.string(), z.array(z.union([z.string(), z.number()])))
@ -868,6 +877,7 @@ const AgentSandboxSchema = z
const AgentToolsSchema = z const AgentToolsSchema = z
.object({ .object({
profile: ToolProfileSchema,
allow: z.array(z.string()).optional(), allow: z.array(z.string()).optional(),
deny: z.array(z.string()).optional(), deny: z.array(z.string()).optional(),
elevated: z elevated: z
@ -932,14 +942,20 @@ const MemorySearchSchema = z
.optional(), .optional(),
}) })
.optional(); .optional();
const AgentModelSchema = z.union([
z.string(),
z.object({
primary: z.string().optional(),
fallbacks: z.array(z.string()).optional(),
}),
]);
const AgentEntrySchema = z.object({ const AgentEntrySchema = z.object({
id: z.string(), id: z.string(),
default: z.boolean().optional(), default: z.boolean().optional(),
name: z.string().optional(), name: z.string().optional(),
workspace: z.string().optional(), workspace: z.string().optional(),
agentDir: z.string().optional(), agentDir: z.string().optional(),
model: z.string().optional(), model: AgentModelSchema.optional(),
memorySearch: MemorySearchSchema, memorySearch: MemorySearchSchema,
humanDelay: HumanDelaySchema.optional(), humanDelay: HumanDelaySchema.optional(),
identity: IdentitySchema, identity: IdentitySchema,
@ -964,6 +980,7 @@ const AgentEntrySchema = z.object({
const ToolsSchema = z const ToolsSchema = z
.object({ .object({
profile: ToolProfileSchema,
allow: z.array(z.string()).optional(), allow: z.array(z.string()).optional(),
deny: z.array(z.string()).optional(), deny: z.array(z.string()).optional(),
audio: z audio: z
@ -1233,6 +1250,7 @@ const AgentDefaultsSchema = z
z.literal("low"), z.literal("low"),
z.literal("medium"), z.literal("medium"),
z.literal("high"), z.literal("high"),
z.literal("xhigh"),
]) ])
.optional(), .optional(),
verboseDefault: z.union([z.literal("off"), z.literal("on")]).optional(), verboseDefault: z.union([z.literal("off"), z.literal("on")]).optional(),

View File

@ -1,6 +1,7 @@
import crypto from "node:crypto"; import crypto from "node:crypto";
import { import {
resolveAgentConfig, resolveAgentConfig,
resolveAgentModelFallbacksOverride,
resolveAgentWorkspaceDir, resolveAgentWorkspaceDir,
resolveDefaultAgentId, resolveDefaultAgentId,
} from "../agents/agent-scope.js"; } from "../agents/agent-scope.js";
@ -31,7 +32,11 @@ import {
DEFAULT_HEARTBEAT_ACK_MAX_CHARS, DEFAULT_HEARTBEAT_ACK_MAX_CHARS,
stripHeartbeatToken, stripHeartbeatToken,
} from "../auto-reply/heartbeat.js"; } from "../auto-reply/heartbeat.js";
import { normalizeThinkLevel } from "../auto-reply/thinking.js"; import {
formatXHighModelHint,
normalizeThinkLevel,
supportsXHighThinking,
} from "../auto-reply/thinking.js";
import type { CliDeps } from "../cli/deps.js"; import type { CliDeps } from "../cli/deps.js";
import type { ClawdbotConfig } from "../config/config.js"; import type { ClawdbotConfig } from "../config/config.js";
import { import {
@ -366,6 +371,11 @@ export async function runCronIsolatedAgentTurn(params: {
catalog: await loadCatalog(), catalog: await loadCatalog(),
}); });
} }
if (thinkLevel === "xhigh" && !supportsXHighThinking(provider, model)) {
throw new Error(
`Thinking level "xhigh" is only supported for ${formatXHighModelHint()}.`,
);
}
const timeoutMs = resolveAgentTimeoutMs({ const timeoutMs = resolveAgentTimeoutMs({
cfg: cfgWithAgentDefaults, cfg: cfgWithAgentDefaults,
@ -449,6 +459,10 @@ export async function runCronIsolatedAgentTurn(params: {
cfg: cfgWithAgentDefaults, cfg: cfgWithAgentDefaults,
provider, provider,
model, model,
fallbacksOverride: resolveAgentModelFallbacksOverride(
params.cfg,
agentId,
),
run: (providerOverride, modelOverride) => { run: (providerOverride, modelOverride) => {
if (isCliProvider(providerOverride, cfgWithAgentDefaults)) { if (isCliProvider(providerOverride, cfgWithAgentDefaults)) {
const cliSessionId = getCliSessionId( const cliSessionId = getCliSessionId(

View File

@ -8,10 +8,13 @@ import {
} from "../agents/model-selection.js"; } from "../agents/model-selection.js";
import { normalizeGroupActivation } from "../auto-reply/group-activation.js"; import { normalizeGroupActivation } from "../auto-reply/group-activation.js";
import { import {
formatThinkingLevels,
formatXHighModelHint,
normalizeElevatedLevel, normalizeElevatedLevel,
normalizeReasoningLevel, normalizeReasoningLevel,
normalizeThinkLevel, normalizeThinkLevel,
normalizeUsageDisplay, normalizeUsageDisplay,
supportsXHighThinking,
} from "../auto-reply/thinking.js"; } from "../auto-reply/thinking.js";
import type { ClawdbotConfig } from "../config/config.js"; import type { ClawdbotConfig } from "../config/config.js";
import type { SessionEntry } from "../config/sessions.js"; import type { SessionEntry } from "../config/sessions.js";
@ -95,8 +98,17 @@ export async function applySessionsPatchToStore(params: {
} else if (raw !== undefined) { } else if (raw !== undefined) {
const normalized = normalizeThinkLevel(String(raw)); const normalized = normalizeThinkLevel(String(raw));
if (!normalized) { if (!normalized) {
const resolvedDefault = resolveConfiguredModelRef({
cfg,
defaultProvider: DEFAULT_PROVIDER,
defaultModel: DEFAULT_MODEL,
});
const hintProvider =
existing?.providerOverride?.trim() || resolvedDefault.provider;
const hintModel =
existing?.modelOverride?.trim() || resolvedDefault.model;
return invalid( return invalid(
"invalid thinkingLevel (use off|minimal|low|medium|high)", `invalid thinkingLevel (use ${formatThinkingLevels(hintProvider, hintModel, "|")})`,
); );
} }
if (normalized === "off") delete next.thinkingLevel; if (normalized === "off") delete next.thinkingLevel;
@ -196,6 +208,24 @@ export async function applySessionsPatchToStore(params: {
} }
} }
if (next.thinkingLevel === "xhigh") {
const resolvedDefault = resolveConfiguredModelRef({
cfg,
defaultProvider: DEFAULT_PROVIDER,
defaultModel: DEFAULT_MODEL,
});
const effectiveProvider = next.providerOverride ?? resolvedDefault.provider;
const effectiveModel = next.modelOverride ?? resolvedDefault.model;
if (!supportsXHighThinking(effectiveProvider, effectiveModel)) {
if ("thinkingLevel" in patch) {
return invalid(
`thinkingLevel "xhigh" is only supported for ${formatXHighModelHint()}`,
);
}
next.thinkingLevel = "high";
}
}
if ("sendPolicy" in patch) { if ("sendPolicy" in patch) {
const raw = patch.sendPolicy; const raw = patch.sendPolicy;
if (raw === null) { if (raw === null) {

View File

@ -171,7 +171,7 @@ describe("google-shared convertMessages", () => {
{ {
type: "thinking", type: "thinking",
thinking: "hidden", thinking: "hidden",
thinkingSignature: "sig", thinkingSignature: "c2ln",
}, },
], ],
api: "google-generative-ai", api: "google-generative-ai",
@ -202,7 +202,7 @@ describe("google-shared convertMessages", () => {
expect(contents[0].role).toBe("model"); expect(contents[0].role).toBe("model");
expect(contents[0].parts?.[0]).toMatchObject({ expect(contents[0].parts?.[0]).toMatchObject({
thought: true, thought: true,
thoughtSignature: "sig", thoughtSignature: "c2ln",
}); });
}); });
@ -216,7 +216,7 @@ describe("google-shared convertMessages", () => {
{ {
type: "thinking", type: "thinking",
thinking: "structured", thinking: "structured",
thinkingSignature: "sig", thinkingSignature: "c2ln",
}, },
], ],
api: "google-generative-ai", api: "google-generative-ai",
@ -247,7 +247,7 @@ describe("google-shared convertMessages", () => {
expect(parts).toHaveLength(1); expect(parts).toHaveLength(1);
expect(parts[0]).toMatchObject({ expect(parts[0]).toMatchObject({
thought: true, thought: true,
thoughtSignature: "sig", thoughtSignature: "c2ln",
}); });
}); });

View File

@ -13,6 +13,14 @@ import type {
const providerId = "discord"; const providerId = "discord";
function readParentIdParam(
params: Record<string, unknown>,
): string | null | undefined {
if (params.clearParent === true) return null;
if (params.parentId === null) return null;
return readStringParam(params, "parentId");
}
export const discordMessageActions: ProviderMessageActionAdapter = { export const discordMessageActions: ProviderMessageActionAdapter = {
listActions: ({ cfg }) => { listActions: ({ cfg }) => {
const accounts = listEnabledDiscordAccounts(cfg).filter( const accounts = listEnabledDiscordAccounts(cfg).filter(
@ -462,8 +470,7 @@ export const discordMessageActions: ProviderMessageActionAdapter = {
const guildId = readStringParam(params, "guildId", { required: true }); const guildId = readStringParam(params, "guildId", { required: true });
const name = readStringParam(params, "name", { required: true }); const name = readStringParam(params, "name", { required: true });
const type = readNumberParam(params, "type", { integer: true }); const type = readNumberParam(params, "type", { integer: true });
const parentId = const parentId = readParentIdParam(params);
params.parentId === null ? null : readStringParam(params, "parentId");
const topic = readStringParam(params, "topic"); const topic = readStringParam(params, "topic");
const position = readNumberParam(params, "position", { integer: true }); const position = readNumberParam(params, "position", { integer: true });
const nsfw = typeof params.nsfw === "boolean" ? params.nsfw : undefined; const nsfw = typeof params.nsfw === "boolean" ? params.nsfw : undefined;
@ -489,8 +496,7 @@ export const discordMessageActions: ProviderMessageActionAdapter = {
const name = readStringParam(params, "name"); const name = readStringParam(params, "name");
const topic = readStringParam(params, "topic"); const topic = readStringParam(params, "topic");
const position = readNumberParam(params, "position", { integer: true }); const position = readNumberParam(params, "position", { integer: true });
const parentId = const parentId = readParentIdParam(params);
params.parentId === null ? null : readStringParam(params, "parentId");
const nsfw = typeof params.nsfw === "boolean" ? params.nsfw : undefined; const nsfw = typeof params.nsfw === "boolean" ? params.nsfw : undefined;
const rateLimitPerUser = readNumberParam(params, "rateLimitPerUser", { const rateLimitPerUser = readNumberParam(params, "rateLimitPerUser", {
integer: true, integer: true,
@ -525,8 +531,7 @@ export const discordMessageActions: ProviderMessageActionAdapter = {
const channelId = readStringParam(params, "channelId", { const channelId = readStringParam(params, "channelId", {
required: true, required: true,
}); });
const parentId = const parentId = readParentIdParam(params);
params.parentId === null ? null : readStringParam(params, "parentId");
const position = readNumberParam(params, "position", { integer: true }); const position = readNumberParam(params, "position", { integer: true });
return await handleDiscordAction( return await handleDiscordAction(
{ {

View File

@ -1,6 +1,9 @@
import type { SlashCommand } from "@mariozechner/pi-tui"; import type { SlashCommand } from "@mariozechner/pi-tui";
import {
formatThinkingLevels,
listThinkingLevels,
} from "../auto-reply/thinking.js";
const THINK_LEVELS = ["off", "minimal", "low", "medium", "high"];
const VERBOSE_LEVELS = ["on", "off"]; const VERBOSE_LEVELS = ["on", "off"];
const REASONING_LEVELS = ["on", "off"]; const REASONING_LEVELS = ["on", "off"];
const ELEVATED_LEVELS = ["on", "off"]; const ELEVATED_LEVELS = ["on", "off"];
@ -12,6 +15,11 @@ export type ParsedCommand = {
args: string; args: string;
}; };
export type SlashCommandOptions = {
provider?: string;
model?: string;
};
const COMMAND_ALIASES: Record<string, string> = { const COMMAND_ALIASES: Record<string, string> = {
elev: "elevated", elev: "elevated",
}; };
@ -27,7 +35,10 @@ export function parseCommand(input: string): ParsedCommand {
}; };
} }
export function getSlashCommands(): SlashCommand[] { export function getSlashCommands(
options: SlashCommandOptions = {},
): SlashCommand[] {
const thinkLevels = listThinkingLevels(options.provider, options.model);
return [ return [
{ name: "help", description: "Show slash command help" }, { name: "help", description: "Show slash command help" },
{ name: "status", description: "Show gateway status summary" }, { name: "status", description: "Show gateway status summary" },
@ -44,9 +55,9 @@ export function getSlashCommands(): SlashCommand[] {
name: "think", name: "think",
description: "Set thinking level", description: "Set thinking level",
getArgumentCompletions: (prefix) => getArgumentCompletions: (prefix) =>
THINK_LEVELS.filter((v) => v.startsWith(prefix.toLowerCase())).map( thinkLevels
(value) => ({ value, label: value }), .filter((v) => v.startsWith(prefix.toLowerCase()))
), .map((value) => ({ value, label: value })),
}, },
{ {
name: "verbose", name: "verbose",
@ -105,7 +116,12 @@ export function getSlashCommands(): SlashCommand[] {
]; ];
} }
export function helpText(): string { export function helpText(options: SlashCommandOptions = {}): string {
const thinkLevels = formatThinkingLevels(
options.provider,
options.model,
"|",
);
return [ return [
"Slash commands:", "Slash commands:",
"/help", "/help",
@ -113,7 +129,7 @@ export function helpText(): string {
"/agent <id> (or /agents)", "/agent <id> (or /agents)",
"/session <key> (or /sessions)", "/session <key> (or /sessions)",
"/model <provider/model> (or /models)", "/model <provider/model> (or /models)",
"/think <off|minimal|low|medium|high>", `/think <${thinkLevels}>`,
"/verbose <on|off>", "/verbose <on|off>",
"/reasoning <on|off>", "/reasoning <on|off>",
"/cost <on|off>", "/cost <on|off>",

View File

@ -7,7 +7,10 @@ import {
TUI, TUI,
} from "@mariozechner/pi-tui"; } from "@mariozechner/pi-tui";
import { resolveDefaultAgentId } from "../agents/agent-scope.js"; import { resolveDefaultAgentId } from "../agents/agent-scope.js";
import { normalizeUsageDisplay } from "../auto-reply/thinking.js"; import {
formatThinkingLevels,
normalizeUsageDisplay,
} from "../auto-reply/thinking.js";
import { loadConfig } from "../config/config.js"; import { loadConfig } from "../config/config.js";
import { formatAge } from "../infra/provider-summary.js"; import { formatAge } from "../infra/provider-summary.js";
import { import {
@ -239,6 +242,18 @@ export async function runTui(opts: TuiOptions) {
root.addChild(footer); root.addChild(footer);
root.addChild(editor); root.addChild(editor);
const updateAutocompleteProvider = () => {
editor.setAutocompleteProvider(
new CombinedAutocompleteProvider(
getSlashCommands({
provider: sessionInfo.modelProvider,
model: sessionInfo.model,
}),
process.cwd(),
),
);
};
const tui = new TUI(new ProcessTerminal()); const tui = new TUI(new ProcessTerminal());
tui.addChild(root); tui.addChild(root);
tui.setFocus(editor); tui.setFocus(editor);
@ -524,6 +539,7 @@ export async function runTui(opts: TuiOptions) {
} catch (err) { } catch (err) {
chatLog.addSystem(`sessions list failed: ${String(err)}`); chatLog.addSystem(`sessions list failed: ${String(err)}`);
} }
updateAutocompleteProvider();
updateFooter(); updateFooter();
tui.requestRender(); tui.requestRender();
}; };
@ -861,7 +877,12 @@ export async function runTui(opts: TuiOptions) {
if (!name) return; if (!name) return;
switch (name) { switch (name) {
case "help": case "help":
chatLog.addSystem(helpText()); chatLog.addSystem(
helpText({
provider: sessionInfo.modelProvider,
model: sessionInfo.model,
}),
);
break; break;
case "status": case "status":
try { try {
@ -921,7 +942,12 @@ export async function runTui(opts: TuiOptions) {
break; break;
case "think": case "think":
if (!args) { if (!args) {
chatLog.addSystem("usage: /think <off|minimal|low|medium|high>"); const levels = formatThinkingLevels(
sessionInfo.modelProvider,
sessionInfo.model,
"|",
);
chatLog.addSystem(`usage: /think <${levels}>`);
break; break;
} }
try { try {
@ -1071,9 +1097,7 @@ export async function runTui(opts: TuiOptions) {
tui.requestRender(); tui.requestRender();
}; };
editor.setAutocompleteProvider( updateAutocompleteProvider();
new CombinedAutocompleteProvider(getSlashCommands(), process.cwd()),
);
editor.onSubmit = (text) => { editor.onSubmit = (text) => {
const value = text.trim(); const value = text.trim();
editor.setText(""); editor.setText("");

View File

@ -122,9 +122,9 @@ const spawnGatewayInstance = async (name: string): Promise<GatewayInstance> => {
try { try {
child = spawn( child = spawn(
"bun", "node",
[ [
"src/index.ts", "dist/index.js",
"gateway", "gateway",
"--port", "--port",
String(port), String(port),
@ -222,7 +222,7 @@ const runCliJson = async (
): Promise<unknown> => { ): Promise<unknown> => {
const stdout: string[] = []; const stdout: string[] = [];
const stderr: string[] = []; const stderr: string[] = [];
const child = spawn("bun", ["src/index.ts", ...args], { const child = spawn("node", ["dist/index.js", ...args], {
cwd: process.cwd(), cwd: process.cwd(),
env: { ...process.env, ...env }, env: { ...process.env, ...env },
stdio: ["ignore", "pipe", "pipe"], stdio: ["ignore", "pipe", "pipe"],

View File

@ -15,10 +15,10 @@
"vite": "7.3.1" "vite": "7.3.1"
}, },
"devDependencies": { "devDependencies": {
"@vitest/browser-playwright": "4.0.16", "@vitest/browser-playwright": "4.0.17",
"playwright": "^1.57.0", "playwright": "^1.57.0",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"vitest": "4.0.16" "vitest": "4.0.17"
}, },
"pnpm": { "pnpm": {
"minimumReleaseAge": 2880 "minimumReleaseAge": 2880