Compare commits
16 Commits
main
...
feat/slash
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
07cc816969 | ||
|
|
b0d0914ca0 | ||
|
|
b33334620a | ||
|
|
ebc4fe57d7 | ||
|
|
5909cf36b9 | ||
|
|
e3a97b96d9 | ||
|
|
4942f3af0f | ||
|
|
ad44e32910 | ||
|
|
b0b77aae88 | ||
|
|
007f8c9222 | ||
|
|
dbd5a76cb0 | ||
|
|
630b256aee | ||
|
|
e170bc397d | ||
|
|
d2f033aee5 | ||
|
|
8f12b47b6d | ||
|
|
e8b382e8ed |
@ -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.
|
||||||
|
|||||||
144
CHANGELOG.md
144
CHANGELOG.md
@ -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 it’s 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
|
||||||
|
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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
71
docs/bedrock.md
Normal 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 pi‑ai’s **Bedrock Converse**
|
||||||
|
streaming provider. Bedrock auth uses the **AWS SDK default credential chain**,
|
||||||
|
not an API key.
|
||||||
|
|
||||||
|
## What pi‑ai 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 OpenAI‑compatible
|
||||||
|
proxy in front of Bedrock and configure it as an OpenAI provider instead.
|
||||||
@ -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`
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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”
|
||||||
|
|
||||||
|
|||||||
@ -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).
|
||||||
|
|||||||
@ -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).
|
||||||
|
|||||||
@ -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)
|
||||||
|
- [What’s 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)
|
||||||
|
- [I’m 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)
|
||||||
|
- [What’s 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 doesn’t 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 built‑in 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: what’s 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 can’t 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)
|
||||||
|
- [What’s 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 won’t 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 it’s disabled?](#why-does-bash-say-its-disabled)
|
||||||
|
- [Why does it feel like the bot “ignores” rapid‑fire 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)
|
||||||
|
- [I’m 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 re‑auth?](#whatsapp-logged-me-out-how-do-i-reauth)
|
||||||
|
- [Build errors on `main` — what’s 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 always‑on 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 always‑on control plane; the assistant is the product.
|
||||||
|
|
||||||
## Quick start and first‑run setup
|
## Quick start and first-run setup
|
||||||
|
|
||||||
### What’s the recommended way to install and set up Clawdbot?
|
### What’s 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 doesn’t ship a Bedrock provider today. If you must use Bedrock, the common workaround is an OpenAI‑compatible 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 pi‑ai’s **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 OpenAI‑compatible 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 it’s 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” rapid‑fire messages?
|
### Why does it feel like the bot “ignores” rapid‑fire messages?
|
||||||
|
|
||||||
Queue mode controls how new messages interact with an in‑flight run. Use `/queue` to change modes:
|
Queue mode controls how new messages interact with an in‑flight run. Use `/queue` to change modes:
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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 doesn’t)
|
## What it controls (and what it doesn’t)
|
||||||
- **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.
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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>`
|
||||||
|
|||||||
10
package.json
10
package.json
@ -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": {
|
||||||
|
|||||||
@ -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
1573
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -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
|
||||||
|
|||||||
@ -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: {
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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;
|
||||||
|
|
||||||
|
|||||||
@ -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 }>;
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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();
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
27
src/agents/schema/typebox.ts
Normal file
27
src/agents/schema/typebox.ts
Normal 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));
|
||||||
|
}
|
||||||
38
src/agents/tool-policy.test.ts
Normal file
38
src/agents/tool-policy.test.ts
Normal 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
116
src/agents/tool-policy.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -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()),
|
||||||
|
|||||||
@ -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",
|
||||||
|
"navigate",
|
||||||
|
"eval",
|
||||||
|
"snapshot",
|
||||||
|
"a2ui_push",
|
||||||
|
"a2ui_reset",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const CANVAS_SNAPSHOT_FORMATS = ["png", "jpg", "jpeg"] as const;
|
||||||
|
|
||||||
|
// Flattened schema: runtime validates per-action requirements.
|
||||||
|
const CanvasToolSchema = Type.Object({
|
||||||
|
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
|
||||||
target: Type.Optional(Type.String()),
|
target: Type.Optional(Type.String()),
|
||||||
x: Type.Optional(Type.Number()),
|
x: Type.Optional(Type.Number()),
|
||||||
y: Type.Optional(Type.Number()),
|
y: Type.Optional(Type.Number()),
|
||||||
width: Type.Optional(Type.Number()),
|
width: Type.Optional(Type.Number()),
|
||||||
height: Type.Optional(Type.Number()),
|
height: Type.Optional(Type.Number()),
|
||||||
}),
|
// navigate
|
||||||
Type.Object({
|
url: Type.Optional(Type.String()),
|
||||||
action: Type.Literal("hide"),
|
// eval
|
||||||
gatewayUrl: Type.Optional(Type.String()),
|
javaScript: Type.Optional(Type.String()),
|
||||||
gatewayToken: Type.Optional(Type.String()),
|
// snapshot
|
||||||
timeoutMs: Type.Optional(Type.Number()),
|
format: optionalStringEnum(CANVAS_SNAPSHOT_FORMATS),
|
||||||
node: Type.Optional(Type.String()),
|
|
||||||
}),
|
|
||||||
Type.Object({
|
|
||||||
action: Type.Literal("navigate"),
|
|
||||||
gatewayUrl: Type.Optional(Type.String()),
|
|
||||||
gatewayToken: Type.Optional(Type.String()),
|
|
||||||
timeoutMs: Type.Optional(Type.Number()),
|
|
||||||
node: Type.Optional(Type.String()),
|
|
||||||
url: Type.String(),
|
|
||||||
}),
|
|
||||||
Type.Object({
|
|
||||||
action: Type.Literal("eval"),
|
|
||||||
gatewayUrl: Type.Optional(Type.String()),
|
|
||||||
gatewayToken: Type.Optional(Type.String()),
|
|
||||||
timeoutMs: Type.Optional(Type.Number()),
|
|
||||||
node: Type.Optional(Type.String()),
|
|
||||||
javaScript: Type.String(),
|
|
||||||
}),
|
|
||||||
Type.Object({
|
|
||||||
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()),
|
maxWidth: Type.Optional(Type.Number()),
|
||||||
quality: Type.Optional(Type.Number()),
|
quality: Type.Optional(Type.Number()),
|
||||||
delayMs: Type.Optional(Type.Number()),
|
delayMs: Type.Optional(Type.Number()),
|
||||||
}),
|
// a2ui_push
|
||||||
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()),
|
jsonl: Type.Optional(Type.String()),
|
||||||
jsonlPath: 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 {
|
||||||
|
|||||||
@ -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",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const CRON_WAKE_MODES = ["now", "next-heartbeat"] as const;
|
||||||
|
|
||||||
|
// Flattened schema: runtime validates per-action requirements.
|
||||||
|
const CronToolSchema = Type.Object({
|
||||||
|
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()),
|
||||||
includeDisabled: Type.Optional(Type.Boolean()),
|
includeDisabled: Type.Optional(Type.Boolean()),
|
||||||
}),
|
job: Type.Optional(Type.Object({}, { additionalProperties: true })),
|
||||||
Type.Object({
|
|
||||||
action: Type.Literal("add"),
|
|
||||||
gatewayUrl: Type.Optional(Type.String()),
|
|
||||||
gatewayToken: Type.Optional(Type.String()),
|
|
||||||
timeoutMs: Type.Optional(Type.Number()),
|
|
||||||
job: Type.Object({}, { additionalProperties: true }),
|
|
||||||
}),
|
|
||||||
Type.Object({
|
|
||||||
action: Type.Literal("update"),
|
|
||||||
gatewayUrl: Type.Optional(Type.String()),
|
|
||||||
gatewayToken: Type.Optional(Type.String()),
|
|
||||||
timeoutMs: Type.Optional(Type.Number()),
|
|
||||||
jobId: Type.Optional(Type.String()),
|
jobId: Type.Optional(Type.String()),
|
||||||
id: Type.Optional(Type.String()),
|
id: Type.Optional(Type.String()),
|
||||||
patch: Type.Object({}, { additionalProperties: true }),
|
patch: Type.Optional(Type.Object({}, { additionalProperties: true })),
|
||||||
}),
|
text: Type.Optional(Type.String()),
|
||||||
Type.Object({
|
mode: optionalStringEnum(CRON_WAKE_MODES),
|
||||||
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 {
|
||||||
|
|||||||
@ -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");
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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()),
|
||||||
|
|||||||
@ -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(
|
|
||||||
Type.Object({
|
|
||||||
action: Type.Literal("send"),
|
|
||||||
to: Type.String(),
|
|
||||||
message: Type.String(),
|
|
||||||
...props,
|
...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, {
|
||||||
|
|||||||
@ -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",
|
||||||
|
"pending",
|
||||||
|
"approve",
|
||||||
|
"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()),
|
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()),
|
||||||
Type.Object({
|
requestId: Type.Optional(Type.String()),
|
||||||
action: Type.Literal("describe"),
|
// notify
|
||||||
gatewayUrl: Type.Optional(Type.String()),
|
|
||||||
gatewayToken: Type.Optional(Type.String()),
|
|
||||||
timeoutMs: Type.Optional(Type.Number()),
|
|
||||||
node: Type.String(),
|
|
||||||
}),
|
|
||||||
Type.Object({
|
|
||||||
action: Type.Literal("pending"),
|
|
||||||
gatewayUrl: Type.Optional(Type.String()),
|
|
||||||
gatewayToken: Type.Optional(Type.String()),
|
|
||||||
timeoutMs: Type.Optional(Type.Number()),
|
|
||||||
}),
|
|
||||||
Type.Object({
|
|
||||||
action: Type.Literal("approve"),
|
|
||||||
gatewayUrl: Type.Optional(Type.String()),
|
|
||||||
gatewayToken: Type.Optional(Type.String()),
|
|
||||||
timeoutMs: Type.Optional(Type.Number()),
|
|
||||||
requestId: Type.String(),
|
|
||||||
}),
|
|
||||||
Type.Object({
|
|
||||||
action: Type.Literal("reject"),
|
|
||||||
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()),
|
title: Type.Optional(Type.String()),
|
||||||
body: Type.Optional(Type.String()),
|
body: Type.Optional(Type.String()),
|
||||||
sound: Type.Optional(Type.String()),
|
sound: Type.Optional(Type.String()),
|
||||||
priority: Type.Optional(
|
priority: optionalStringEnum(NOTIFY_PRIORITIES),
|
||||||
Type.Union([
|
delivery: optionalStringEnum(NOTIFY_DELIVERIES),
|
||||||
Type.Literal("passive"),
|
// camera_snap / camera_clip
|
||||||
Type.Literal("active"),
|
facing: optionalStringEnum(CAMERA_FACING, {
|
||||||
Type.Literal("timeSensitive"),
|
description: "camera_snap: front/back/both; camera_clip: front/back only.",
|
||||||
]),
|
|
||||||
),
|
|
||||||
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()),
|
maxWidth: Type.Optional(Type.Number()),
|
||||||
quality: Type.Optional(Type.Number()),
|
quality: Type.Optional(Type.Number()),
|
||||||
delayMs: Type.Optional(Type.Number()),
|
delayMs: Type.Optional(Type.Number()),
|
||||||
deviceId: Type.Optional(Type.String()),
|
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()),
|
duration: Type.Optional(Type.String()),
|
||||||
durationMs: Type.Optional(Type.Number()),
|
durationMs: Type.Optional(Type.Number()),
|
||||||
includeAudio: Type.Optional(Type.Boolean()),
|
includeAudio: Type.Optional(Type.Boolean()),
|
||||||
deviceId: Type.Optional(Type.String()),
|
// screen_record
|
||||||
}),
|
|
||||||
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()),
|
fps: Type.Optional(Type.Number()),
|
||||||
screenIndex: Type.Optional(Type.Number()),
|
screenIndex: Type.Optional(Type.Number()),
|
||||||
includeAudio: Type.Optional(Type.Boolean()),
|
|
||||||
outPath: Type.Optional(Type.String()),
|
outPath: Type.Optional(Type.String()),
|
||||||
}),
|
// location_get
|
||||||
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()),
|
maxAgeMs: Type.Optional(Type.Number()),
|
||||||
locationTimeoutMs: Type.Optional(Type.Number()),
|
locationTimeoutMs: Type.Optional(Type.Number()),
|
||||||
desiredAccuracy: Type.Optional(
|
desiredAccuracy: optionalStringEnum(LOCATION_ACCURACY),
|
||||||
Type.Union([
|
// run
|
||||||
Type.Literal("coarse"),
|
command: Type.Optional(Type.Array(Type.String())),
|
||||||
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()),
|
cwd: Type.Optional(Type.String()),
|
||||||
env: Type.Optional(Type.Array(Type.String())),
|
env: Type.Optional(Type.Array(Type.String())),
|
||||||
commandTimeoutMs: Type.Optional(Type.Number()),
|
commandTimeoutMs: Type.Optional(Type.Number()),
|
||||||
invokeTimeoutMs: Type.Optional(Type.Number()),
|
invokeTimeoutMs: Type.Optional(Type.Number()),
|
||||||
needsScreenRecording: Type.Optional(Type.Boolean()),
|
needsScreenRecording: Type.Optional(Type.Boolean()),
|
||||||
}),
|
});
|
||||||
]);
|
|
||||||
|
|
||||||
export function createNodesTool(): AnyAgentTool {
|
export function createNodesTool(): AnyAgentTool {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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 =
|
||||||
|
|||||||
@ -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,125 +1215,24 @@ 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({
|
|
||||||
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 {
|
return {
|
||||||
text: `Invalid model selection "${raw}". Use /model to list.`,
|
text: `Thinking level "xhigh" is only supported for ${formatXHighModelHint()}.`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const picked = pickProviderForModel({
|
|
||||||
item,
|
const nextThinkLevel = directives.hasThinkDirective
|
||||||
preferredProvider: params.provider,
|
? directives.thinkLevel
|
||||||
});
|
: ((sessionEntry?.thinkingLevel as ThinkLevel | undefined) ??
|
||||||
if (!picked) {
|
currentThinkLevel);
|
||||||
return {
|
const shouldDowngradeXHigh =
|
||||||
text: `Invalid model selection "${raw}". Use /model to list.`,
|
!directives.hasThinkDirective &&
|
||||||
};
|
nextThinkLevel === "xhigh" &&
|
||||||
}
|
!supportsXHighThinking(resolvedProvider, resolvedModel);
|
||||||
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." };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sessionEntry && sessionStore && sessionKey) {
|
if (sessionEntry && sessionStore && sessionKey) {
|
||||||
const prevElevatedLevel =
|
const prevElevatedLevel =
|
||||||
@ -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
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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", () => {
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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. */
|
||||||
|
|||||||
@ -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(),
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -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(
|
||||||
{
|
{
|
||||||
|
|||||||
@ -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>",
|
||||||
|
|||||||
@ -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("");
|
||||||
|
|||||||
@ -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"],
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user