Compare commits

..
Author SHA1 Message Date
Peter Steinberger 382681178b fix: enforce reasoning tags across providers (#801) (thanks @mcinteerj) 2026-01-12 22:58:44 +00:00
Jake cd169aceb5 fix(agent): restore reasoning tag enforcement for non-ollama providers
This restores the fix from PR #754 which was accidentally reverted in bf11a42c3.
2026-01-12 22:49:18 +00:00
254 changed files with 3188 additions and 11359 deletions

No files matched your search

-3
View File
@@ -1,6 +1,4 @@
# Repository Guidelines
- Repo: https://github.com/clawdbot/clawdbot
- GitHub issues: use literal multiline strings or $'...' for newlines; avoid "\\n" escapes in `gh issue create/edit`.
## Project Structure & Module Organization
- Source code: `src/` (CLI wiring in `src/cli`, commands in `src/commands`, web provider in `src/provider-web.ts`, infra in `src/infra`, media pipeline in `src/media`).
@@ -100,7 +98,6 @@
- **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.
- 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.
- 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.
+2 -70
View File
@@ -1,66 +1,5 @@
# Changelog
## 2026.1.13-1
### 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)
- Agents: support per-agent model fallbacks via `agents.list[].model`. (#583 — thanks @mitschabaude-bot)
- Tools: add tool profiles plus group shorthands for tool policy allow/deny (global, per-agent, sandbox).
### Fixes
- Gateway: honor `CLAWDBOT_LAUNCHD_LABEL` / `CLAWDBOT_SYSTEMD_UNIT` overrides when checking or restarting the daemon.
- Tools: harden tool input schemas for strict providers (no unions; flattened actions; enum helpers + tests).
- Discord: allow clearing channel parents via message tool without nullable schema inputs.
## 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
@@ -69,10 +8,8 @@
- 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.
- Gateway/macOS: reduce noisy loopback WS “closed before connect” logs during tests.
## 2026.1.12-1
@@ -86,21 +23,18 @@
- Memory: add vector search for agent memories (Markdown-only scope) with SQLite index, chunking, lazy sync + file watch, and per-agent enablement/fallback.
### Changes
- 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).
- Browser: add `scrollintoview` action to scroll refs into view before click/type.
- Memory: embedding providers support OpenAI or local `node-llama-cpp`; config adds defaults + per-agent overrides, provider/fallback metadata surfaced in tools/CLI.
- CLI/Tools: new `clawdbot memory` commands plus `memory_search`/`memory_get` tools returning snippets + line ranges and provider info.
- 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.
- Doctor: rebuild Control UI assets when protocol schema is newer to avoid stale UI connect errors. (#786) — thanks @meaningfool.
- Cron: allow jobs to target a specific agent and expose agent selection in the macOS app + Control UI.
- Discord: add `discord.allowBots` to permit bot-authored messages (still ignores its own messages) with docs warning about bot loops. (#802) — thanks @zknicker.
- 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.
- 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.
- 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
- Auto-reply: inline `/status` now honors allowlists (authorized stripped + replied inline; unauthorized leaves text for the agent) to match command gating tests.
- Auto-reply: enforce `<final>` tag for all reasoning-tag providers (Gemini Antigravity, MiniMax, etc.), not just Ollama. (#801 — thanks @mcinteerj)
- 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).
- Models/Tools: include `MiniMax-VL-01` in implicit MiniMax provider so image pairing uses a real vision model.
- Telegram: show typing indicator in General forum topics. (#779) — thanks @azade-c.
@@ -113,7 +47,6 @@
- Auto-reply: elevated/reasoning toggles now enqueue system events so the model sees the mode change immediately.
- Tools: keep `image` available in sandbox and fail over when image models return empty output (fixes “(no text returned)”).
- Discord: add per-channel `autoThread` to auto-create threads for top-level messages. (#800) — thanks @davidguttman.
- Discord: fix autoThread routing so replies stay in the created thread and avoid reply references. (#807) — thanks @davidguttman.
- Onboarding: TUI defaults to `deliver: false` to avoid cross-provider auto-delivery leaks; onboarding spawns the TUI with explicit `deliver: false`. (#791 — thanks @roshanasingh4)
## 2026.1.11
@@ -186,7 +119,6 @@
- Auto-reply: align `/think` default display with model reasoning defaults. (#751) — thanks @gabriel-trigo.
- Auto-reply: flush block reply buffers on tool boundaries. (#750) — thanks @sebslight.
- Auto-reply: allow sender fallback for command authorization when `SenderId` is empty (WhatsApp self-chat). (#755) — thanks @juanpablodlc.
- Auto-reply: treat whitespace-only sender ids as missing for command authorization (WhatsApp self-chat). (#766) — thanks @steipete.
- Heartbeat: refresh prompt text for updated defaults.
- Agents/Tools: use PowerShell on Windows to capture system utility output. (#748) — thanks @myfunc.
- Docker: tolerate unset optional env vars in docker-setup.sh under strict mode. (#725) — thanks @petradonka.
+23 -33
View File
@@ -16,7 +16,7 @@
</p>
**Clawdbot** is a *personal AI assistant* you run on your own devices.
It answers you on the providers you already use (WhatsApp, Telegram, Slack, Discord, Signal, iMessage, Microsoft Teams, WebChat), can speak and listen on macOS/iOS/Android, and can render a live Canvas you control. The Gateway is just the control plane — the product is the assistant.
It answers you on the providers you already use (WhatsApp, Telegram, Slack, Discord, Signal, iMessage, WebChat), can speak and listen on macOS/iOS/Android, and can render a live Canvas you control. The Gateway is just the control plane — the product is the assistant.
If you want a personal, single-user assistant that feels local, fast, and always-on, this is it.
@@ -64,7 +64,7 @@ clawdbot gateway --port 18789 --verbose
# Send a message
clawdbot message send --to +1234567890 --message "Hello from Clawdbot"
# Talk to the assistant (optionally deliver back to WhatsApp/Telegram/Slack/Discord/Microsoft Teams)
# Talk to the assistant (optionally deliver back to WhatsApp/Telegram/Slack/Discord)
clawdbot agent --message "Ship checklist" --thinking high
```
@@ -96,7 +96,7 @@ Clawdbot connects to real messaging surfaces. Treat inbound DMs as **untrusted i
Full security guide: [Security](https://docs.clawd.bot/gateway/security)
Default behavior on Telegram/WhatsApp/Signal/iMessage/Microsoft Teams/Discord/Slack:
Default behavior on Telegram/WhatsApp/Signal/iMessage/Discord/Slack:
- **DM pairing** (`dmPolicy="pairing"` / `discord.dm.policy="pairing"` / `slack.dm.policy="pairing"`): unknown senders receive a short pairing code and the bot does not process their message.
- Approve with: `clawdbot pairing approve <provider> <code>` (then the sender is added to a local allowlist store).
- Public inbound DMs require an explicit opt-in: set `dmPolicy="open"` and include `"*"` in the provider allowlist (`allowFrom` / `discord.dm.allowFrom` / `slack.dm.allowFrom`).
@@ -106,7 +106,7 @@ Run `clawdbot doctor` to surface risky/misconfigured DM policies.
## Highlights
- **[Local-first Gateway](https://docs.clawd.bot/gateway)** — single control plane for sessions, providers, tools, and events.
- **[Multi-provider inbox](https://docs.clawd.bot/providers)** — WhatsApp, Telegram, Slack, Discord, Signal, iMessage, Microsoft Teams, WebChat, macOS, iOS/Android.
- **[Multi-provider inbox](https://docs.clawd.bot/providers)** — WhatsApp, Telegram, Slack, Discord, Signal, iMessage, WebChat, macOS, iOS/Android.
- **[Multi-agent routing](https://docs.clawd.bot/gateway/configuration)** — route inbound providers/accounts/peers to isolated agents (workspaces + per-agent sessions).
- **[Voice Wake](https://docs.clawd.bot/nodes/voicewake) + [Talk Mode](https://docs.clawd.bot/nodes/talk)** — always-on speech for macOS/iOS/Android with ElevenLabs.
- **[Live Canvas](https://docs.clawd.bot/platforms/mac/canvas)** — agent-driven visual workspace with [A2UI](https://docs.clawd.bot/platforms/mac/canvas#canvas-a2ui).
@@ -114,10 +114,6 @@ Run `clawdbot doctor` to surface risky/misconfigured DM policies.
- **[Companion apps](https://docs.clawd.bot/platforms/macos)** — macOS menu bar app + iOS/Android [nodes](https://docs.clawd.bot/nodes).
- **[Onboarding](https://docs.clawd.bot/start/wizard) + [skills](https://docs.clawd.bot/tools/skills)** — wizard-driven setup with bundled/managed/workspace skills.
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=clawdbot/clawdbot&type=date&legend=top-left)](https://www.star-history.com/#clawdbot/clawdbot&type=date&legend=top-left)
## Everything we built so far
### Core platform
@@ -128,7 +124,7 @@ Run `clawdbot doctor` to surface risky/misconfigured DM policies.
- [Media pipeline](https://docs.clawd.bot/nodes/images): images/audio/video, transcription hooks, size caps, temp file lifecycle. Audio details: [Audio](https://docs.clawd.bot/nodes/audio).
### Providers
- [Providers](https://docs.clawd.bot/providers): [WhatsApp](https://docs.clawd.bot/providers/whatsapp) (Baileys), [Telegram](https://docs.clawd.bot/providers/telegram) (grammY), [Slack](https://docs.clawd.bot/providers/slack) (Bolt), [Discord](https://docs.clawd.bot/providers/discord) (discord.js), [Signal](https://docs.clawd.bot/providers/signal) (signal-cli), [iMessage](https://docs.clawd.bot/providers/imessage) (imsg), [Microsoft Teams](https://docs.clawd.bot/providers/msteams) (Bot Framework), [WebChat](https://docs.clawd.bot/web/webchat).
- [Providers](https://docs.clawd.bot/providers): [WhatsApp](https://docs.clawd.bot/providers/whatsapp) (Baileys), [Telegram](https://docs.clawd.bot/providers/telegram) (grammY), [Slack](https://docs.clawd.bot/providers/slack) (Bolt), [Discord](https://docs.clawd.bot/providers/discord) (discord.js), [Signal](https://docs.clawd.bot/providers/signal) (signal-cli), [iMessage](https://docs.clawd.bot/providers/imessage) (imsg), [WebChat](https://docs.clawd.bot/web/webchat).
- [Group routing](https://docs.clawd.bot/concepts/group-messages): mention gating, reply tags, per-provider chunking and routing. Provider rules: [Providers](https://docs.clawd.bot/providers).
### Apps + nodes
@@ -159,7 +155,7 @@ Run `clawdbot doctor` to surface risky/misconfigured DM policies.
## How it works (short)
```
WhatsApp / Telegram / Slack / Discord / Signal / iMessage / Microsoft Teams / WebChat
WhatsApp / Telegram / Slack / Discord / Signal / iMessage / WebChat
│
▼
┌───────────────────────────────┐
@@ -242,7 +238,7 @@ ClawdHub is a minimal skill registry. With ClawdHub enabled, the agent can searc
## Chat commands
Send these in WhatsApp/Telegram/Slack/Microsoft Teams/WebChat (group commands are owner-only):
Send these in WhatsApp/Telegram/Slack/WebChat (group commands are owner-only):
- `/status` — compact session status (model + tokens, cost when available)
- `/new` or `/reset` — reset the session
@@ -360,11 +356,6 @@ Details: [Security guide](https://docs.clawd.bot/gateway/security) · [Docker +
- macOS only; Messages must be signed in.
- If `imessage.groups` is set, it becomes a group allowlist; include `"*"` to allow all.
### [Microsoft Teams](https://docs.clawd.bot/providers/msteams)
- Configure a Teams app + Bot Framework, then add a `msteams` config section.
- Allowlist who can talk via `msteams.allowFrom`; group access via `msteams.groupAllowFrom` or `msteams.groupPolicy: "open"`.
### [WebChat](https://docs.clawd.bot/web/webchat)
- Uses the Gateway WebSocket; no separate WebChat port/config.
@@ -467,21 +458,20 @@ Special thanks to @andrewting19 for the Anthropic OAuth tool-name fix.
Thanks to all clawtributors:
<p align="left">
<a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/joaohlisboa"><img src="https://avatars.githubusercontent.com/u/8200873?v=4&s=48" width="48" height="48" alt="joaohlisboa" title="joaohlisboa"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/rahthakor"><img src="https://avatars.githubusercontent.com/u/8470553?v=4&s=48" width="48" height="48" alt="rahthakor" title="rahthakor"/></a> <a href="https://github.com/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a> <a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a> <a href="https://github.com/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a> <a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a> <a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></a> <a href="https://github.com/hsrvc"><img src="https://avatars.githubusercontent.com/u/129702169?v=4&s=48" width="48" height="48" alt="hsrvc" title="hsrvc"/></a>
<a href="https://github.com/magimetal"><img src="https://avatars.githubusercontent.com/u/36491250?v=4&s=48" width="48" height="48" alt="magimetal" title="magimetal"/></a> <a href="https://github.com/NicholasSpisak"><img src="https://avatars.githubusercontent.com/u/129075147?v=4&s=48" width="48" height="48" alt="NicholasSpisak" title="NicholasSpisak"/></a> <a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a> <a href="https://github.com/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a> <a href="https://github.com/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a> <a href="https://github.com/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a> <a href="https://github.com/daveonkels"><img src="https://avatars.githubusercontent.com/u/533642?v=4&s=48" width="48" height="48" alt="daveonkels" title="daveonkels"/></a> <a href="https://github.com/radek-paclt"><img src="https://avatars.githubusercontent.com/u/50451445?v=4&s=48" width="48" height="48" alt="radek-paclt" title="radek-paclt"/></a> <a href="https://github.com/jeffersonwarrior"><img src="https://avatars.githubusercontent.com/u/89030989?v=4&s=48" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/mteam88"><img src="https://avatars.githubusercontent.com/u/84196639?v=4&s=48" width="48" height="48" alt="mteam88" title="mteam88"/></a>
<a href="https://github.com/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a> <a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a> <a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a> <a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a> <a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a> <a href="https://github.com/cristip73"><img src="https://avatars.githubusercontent.com/u/24499421?v=4&s=48" width="48" height="48" alt="cristip73" title="cristip73"/></a> <a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a> <a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="Vasanth Rao Naik Sabavat" title="Vasanth Rao Naik Sabavat"/></a>
<a href="https://github.com/lc0rp"><img src="https://avatars.githubusercontent.com/u/2609441?v=4&s=48" width="48" height="48" alt="lc0rp" title="lc0rp"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a> <a href="https://github.com/davidguttman"><img src="https://avatars.githubusercontent.com/u/431696?v=4&s=48" width="48" height="48" alt="davidguttman" title="davidguttman"/></a> <a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/meaningfool"><img src="https://avatars.githubusercontent.com/u/2862331?v=4&s=48" width="48" height="48" alt="meaningfool" title="meaningfool"/></a> <a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a>
<a href="https://github.com/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a> <a href="https://github.com/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a> <a href="https://github.com/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/KristijanJovanovski"><img src="https://avatars.githubusercontent.com/u/8942284?v=4&s=48" width="48" height="48" alt="KristijanJovanovski" title="KristijanJovanovski"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a> <a href="https://github.com/sebslight"><img src="https://avatars.githubusercontent.com/u/19554889?v=4&s=48" width="48" height="48" alt="Sebastian Barrios" title="Sebastian Barrios"/></a> <a href="https://github.com/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a>
<a href="https://github.com/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="manuelhettich" title="manuelhettich"/></a> <a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/myfunc"><img src="https://avatars.githubusercontent.com/u/19294627?v=4&s=48" width="48" height="48" alt="myfunc" title="myfunc"/></a> <a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a> <a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a> <a href="https://github.com/timkrase"><img src="https://avatars.githubusercontent.com/u/38947626?v=4&s=48" width="48" height="48" alt="timkrase" title="timkrase"/></a> <a href="https://github.com/obviyus"><img src="https://avatars.githubusercontent.com/u/22031114?v=4&s=48" width="48" height="48" alt="obviyus" title="obviyus"/></a> <a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a> <a href="https://github.com/bjesuiter"><img src="https://avatars.githubusercontent.com/u/2365676?v=4&s=48" width="48" height="48" alt="bjesuiter" title="bjesuiter"/></a> <a href="https://github.com/superman32432432"><img src="https://avatars.githubusercontent.com/u/7228420?v=4&s=48" width="48" height="48" alt="superman32432432" title="superman32432432"/></a>
<a href="https://github.com/search?q=Yurii%20Chukhlib"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yurii Chukhlib" title="Yurii Chukhlib"/></a> <a href="https://github.com/antons"><img src="https://avatars.githubusercontent.com/u/129705?v=4&s=48" width="48" height="48" alt="antons" title="antons"/></a> <a href="https://github.com/austinm911"><img src="https://avatars.githubusercontent.com/u/31991302?v=4&s=48" width="48" height="48" alt="austinm911" title="austinm911"/></a> <a href="https://github.com/apps/blacksmith-sh"><img src="https://avatars.githubusercontent.com/in/807020?v=4&s=48" width="48" height="48" alt="blacksmith-sh[bot]" title="blacksmith-sh[bot]"/></a> <a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a> <a href="https://github.com/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a> <a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a> <a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a>
<a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a> <a href="https://github.com/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a> <a href="https://github.com/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a> <a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a> <a href="https://github.com/gabriel-trigo"><img src="https://avatars.githubusercontent.com/u/38991125?v=4&s=48" width="48" height="48" alt="gabriel-trigo" title="gabriel-trigo"/></a> <a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="iamadig" title="iamadig"/></a> <a href="https://github.com/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a>
<a href="https://github.com/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a> <a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/ogulcancelik"><img src="https://avatars.githubusercontent.com/u/7064011?v=4&s=48" width="48" height="48" alt="ogulcancelik" title="ogulcancelik"/></a> <a href="https://github.com/pasogott"><img src="https://avatars.githubusercontent.com/u/23458152?v=4&s=48" width="48" height="48" alt="pasogott" title="pasogott"/></a> <a href="https://github.com/petradonka"><img src="https://avatars.githubusercontent.com/u/7353770?v=4&s=48" width="48" height="48" alt="petradonka" title="petradonka"/></a> <a href="https://github.com/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a> <a href="https://github.com/zats"><img src="https://avatars.githubusercontent.com/u/2688806?v=4&s=48" width="48" height="48" alt="zats" title="zats"/></a> <a href="https://github.com/search?q=Chris%20Taylor"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Chris Taylor" title="Chris Taylor"/></a> <a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="Django Navarro" title="Django Navarro"/></a> <a href="https://github.com/ngutman"><img src="https://avatars.githubusercontent.com/u/1540134?v=4&s=48" width="48" height="48" alt="ngutman" title="ngutman"/></a>
<a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/rubyrunsstuff"><img src="https://avatars.githubusercontent.com/u/246602379?v=4&s=48" width="48" height="48" alt="rubyrunsstuff" title="rubyrunsstuff"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/search?q=Aaron%20Konyer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Aaron Konyer" title="Aaron Konyer"/></a> <a href="https://github.com/danielz1z"><img src="https://avatars.githubusercontent.com/u/235270390?v=4&s=48" width="48" height="48" alt="danielz1z" title="danielz1z"/></a> <a href="https://github.com/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a> <a href="https://github.com/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></a> <a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a> <a href="https://github.com/henrino3"><img src="https://avatars.githubusercontent.com/u/4260288?v=4&s=48" width="48" height="48" alt="henrino3" title="henrino3"/></a> <a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a>
<a href="https://github.com/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/juanpablodlc"><img src="https://avatars.githubusercontent.com/u/92012363?v=4&s=48" width="48" height="48" alt="juanpablodlc" title="juanpablodlc"/></a> <a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/mickahouan"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="mickahouan" title="mickahouan"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a> <a href="https://github.com/mjrussell"><img src="https://avatars.githubusercontent.com/u/1641895?v=4&s=48" width="48" height="48" alt="mjrussell" title="mjrussell"/></a> <a href="https://github.com/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a> <a href="https://github.com/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a> <a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a> <a href="https://github.com/roshanasingh4"><img src="https://avatars.githubusercontent.com/u/88576930?v=4&s=48" width="48" height="48" alt="roshanasingh4" title="roshanasingh4"/></a>
<a href="https://github.com/search?q=Sash%20Catanzarite"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sash Catanzarite" title="Sash Catanzarite"/></a> <a href="https://github.com/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a> <a href="https://github.com/zknicker"><img src="https://avatars.githubusercontent.com/u/1164085?v=4&s=48" width="48" height="48" alt="zknicker" title="zknicker"/></a> <a href="https://github.com/adam91holt"><img src="https://avatars.githubusercontent.com/u/9592417?v=4&s=48" width="48" height="48" alt="adam91holt" title="adam91holt"/></a> <a href="https://github.com/search?q=alejandro%20maza"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="alejandro maza" title="alejandro maza"/></a> <a href="https://github.com/andrewting19"><img src="https://avatars.githubusercontent.com/u/10536704?v=4&s=48" width="48" height="48" alt="andrewting19" title="andrewting19"/></a> <a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a> <a href="https://github.com/bolismauro"><img src="https://avatars.githubusercontent.com/u/771999?v=4&s=48" width="48" height="48" alt="bolismauro" title="bolismauro"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a>
<a href="https://github.com/conhecendocontato"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendocontato" title="conhecendocontato"/></a> <a href="https://github.com/search?q=Drake%20Thomsen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></a> <a href="https://github.com/evalexpr"><img src="https://avatars.githubusercontent.com/u/23485511?v=4&s=48" width="48" height="48" alt="evalexpr" title="evalexpr"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/HazAT"><img src="https://avatars.githubusercontent.com/u/363802?v=4&s=48" width="48" height="48" alt="HazAT" title="HazAT"/></a> <a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a> <a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a> <a href="https://github.com/search?q=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></a> <a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a>
<a href="https://github.com/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a> <a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a> <a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/search?q=Mustafa%20Tag%20Eldeen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mustafa Tag Eldeen" title="Mustafa Tag Eldeen"/></a> <a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a>
<a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a> <a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a> <a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a> <a href="https://github.com/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a> <a href="https://github.com/wes-davis"><img src="https://avatars.githubusercontent.com/u/16506720?v=4&s=48" width="48" height="48" alt="wes-davis" title="wes-davis"/></a> <a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a>
<a href="https://github.com/YuriNachos"><img src="https://avatars.githubusercontent.com/u/19365375?v=4&s=48" width="48" height="48" alt="YuriNachos" title="YuriNachos"/></a> <a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a> <a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a> <a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a> <a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a>
<a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a> <a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a> <a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
<a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/joaohlisboa"><img src="https://avatars.githubusercontent.com/u/8200873?v=4&s=48" width="48" height="48" alt="joaohlisboa" title="joaohlisboa"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/rahthakor"><img src="https://avatars.githubusercontent.com/u/8470553?v=4&s=48" width="48" height="48" alt="rahthakor" title="rahthakor"/></a> <a href="https://github.com/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a> <a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a> <a href="https://github.com/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a> <a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a> <a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></a> <a href="https://github.com/magimetal"><img src="https://avatars.githubusercontent.com/u/36491250?v=4&s=48" width="48" height="48" alt="magimetal" title="magimetal"/></a>
<a href="https://github.com/NicholasSpisak"><img src="https://avatars.githubusercontent.com/u/129075147?v=4&s=48" width="48" height="48" alt="NicholasSpisak" title="NicholasSpisak"/></a> <a href="https://github.com/hsrvc"><img src="https://avatars.githubusercontent.com/u/129702169?v=4&s=48" width="48" height="48" alt="hsrvc" title="hsrvc"/></a> <a href="https://github.com/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a> <a href="https://github.com/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a> <a href="https://github.com/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a> <a href="https://github.com/daveonkels"><img src="https://avatars.githubusercontent.com/u/533642?v=4&s=48" width="48" height="48" alt="daveonkels" title="daveonkels"/></a> <a href="https://github.com/radek-paclt"><img src="https://avatars.githubusercontent.com/u/50451445?v=4&s=48" width="48" height="48" alt="radek-paclt" title="radek-paclt"/></a> <a href="https://github.com/jeffersonwarrior"><img src="https://avatars.githubusercontent.com/u/89030989?v=4&s=48" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/mteam88"><img src="https://avatars.githubusercontent.com/u/84196639?v=4&s=48" width="48" height="48" alt="mteam88" title="mteam88"/></a> <a href="https://github.com/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a>
<a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a> <a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a> <a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a> <a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a> <a href="https://github.com/cristip73"><img src="https://avatars.githubusercontent.com/u/24499421?v=4&s=48" width="48" height="48" alt="cristip73" title="cristip73"/></a> <a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a> <a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="Vasanth Rao Naik Sabavat" title="Vasanth Rao Naik Sabavat"/></a> <a href="https://github.com/lc0rp"><img src="https://avatars.githubusercontent.com/u/2609441?v=4&s=48" width="48" height="48" alt="lc0rp" title="lc0rp"/></a>
<a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a> <a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/meaningfool"><img src="https://avatars.githubusercontent.com/u/2862331?v=4&s=48" width="48" height="48" alt="meaningfool" title="meaningfool"/></a> <a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a> <a href="https://github.com/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a> <a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a>
<a href="https://github.com/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a> <a href="https://github.com/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/KristijanJovanovski"><img src="https://avatars.githubusercontent.com/u/8942284?v=4&s=48" width="48" height="48" alt="KristijanJovanovski" title="KristijanJovanovski"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a> <a href="https://github.com/sebslight"><img src="https://avatars.githubusercontent.com/u/19554889?v=4&s=48" width="48" height="48" alt="Sebastian Barrios" title="Sebastian Barrios"/></a> <a href="https://github.com/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a> <a href="https://github.com/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="manuelhettich" title="manuelhettich"/></a>
<a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/myfunc"><img src="https://avatars.githubusercontent.com/u/19294627?v=4&s=48" width="48" height="48" alt="myfunc" title="myfunc"/></a> <a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a> <a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a> <a href="https://github.com/timkrase"><img src="https://avatars.githubusercontent.com/u/38947626?v=4&s=48" width="48" height="48" alt="timkrase" title="timkrase"/></a> <a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a> <a href="https://github.com/obviyus"><img src="https://avatars.githubusercontent.com/u/22031114?v=4&s=48" width="48" height="48" alt="obviyus" title="obviyus"/></a> <a href="https://github.com/bjesuiter"><img src="https://avatars.githubusercontent.com/u/2365676?v=4&s=48" width="48" height="48" alt="bjesuiter" title="bjesuiter"/></a> <a href="https://github.com/superman32432432"><img src="https://avatars.githubusercontent.com/u/7228420?v=4&s=48" width="48" height="48" alt="superman32432432" title="superman32432432"/></a> <a href="https://github.com/search?q=Yurii%20Chukhlib"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yurii Chukhlib" title="Yurii Chukhlib"/></a>
<a href="https://github.com/antons"><img src="https://avatars.githubusercontent.com/u/129705?v=4&s=48" width="48" height="48" alt="antons" title="antons"/></a> <a href="https://github.com/austinm911"><img src="https://avatars.githubusercontent.com/u/31991302?v=4&s=48" width="48" height="48" alt="austinm911" title="austinm911"/></a> <a href="https://github.com/apps/blacksmith-sh"><img src="https://avatars.githubusercontent.com/in/807020?v=4&s=48" width="48" height="48" alt="blacksmith-sh[bot]" title="blacksmith-sh[bot]"/></a> <a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a> <a href="https://github.com/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a> <a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a> <a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a> <a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a>
<a href="https://github.com/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a> <a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a> <a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="iamadig" title="iamadig"/></a> <a href="https://github.com/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a> <a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/ogulcancelik"><img src="https://avatars.githubusercontent.com/u/7064011?v=4&s=48" width="48" height="48" alt="ogulcancelik" title="ogulcancelik"/></a> <a href="https://github.com/pasogott"><img src="https://avatars.githubusercontent.com/u/23458152?v=4&s=48" width="48" height="48" alt="pasogott" title="pasogott"/></a> <a href="https://github.com/petradonka"><img src="https://avatars.githubusercontent.com/u/7353770?v=4&s=48" width="48" height="48" alt="petradonka" title="petradonka"/></a>
<a href="https://github.com/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a> <a href="https://github.com/zats"><img src="https://avatars.githubusercontent.com/u/2688806?v=4&s=48" width="48" height="48" alt="zats" title="zats"/></a> <a href="https://github.com/search?q=Chris%20Taylor"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Chris Taylor" title="Chris Taylor"/></a> <a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="Django Navarro" title="Django Navarro"/></a> <a href="https://github.com/gabriel-trigo"><img src="https://avatars.githubusercontent.com/u/38991125?v=4&s=48" width="48" height="48" alt="gabriel-trigo" title="gabriel-trigo"/></a> <a href="https://github.com/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a> <a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a> <a href="https://github.com/ngutman"><img src="https://avatars.githubusercontent.com/u/1540134?v=4&s=48" width="48" height="48" alt="ngutman" title="ngutman"/></a> <a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/rubyrunsstuff"><img src="https://avatars.githubusercontent.com/u/246602379?v=4&s=48" width="48" height="48" alt="rubyrunsstuff" title="rubyrunsstuff"/></a>
<a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/danielz1z"><img src="https://avatars.githubusercontent.com/u/235270390?v=4&s=48" width="48" height="48" alt="danielz1z" title="danielz1z"/></a> <a href="https://github.com/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a> <a href="https://github.com/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></a> <a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a> <a href="https://github.com/henrino3"><img src="https://avatars.githubusercontent.com/u/4260288?v=4&s=48" width="48" height="48" alt="henrino3" title="henrino3"/></a> <a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a> <a href="https://github.com/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/juanpablodlc"><img src="https://avatars.githubusercontent.com/u/92012363?v=4&s=48" width="48" height="48" alt="juanpablodlc" title="juanpablodlc"/></a> <a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a>
<a href="https://github.com/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a> <a href="https://github.com/mjrussell"><img src="https://avatars.githubusercontent.com/u/1641895?v=4&s=48" width="48" height="48" alt="mjrussell" title="mjrussell"/></a> <a href="https://github.com/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a> <a href="https://github.com/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a> <a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a> <a href="https://github.com/search?q=Sash%20Catanzarite"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sash Catanzarite" title="Sash Catanzarite"/></a> <a href="https://github.com/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a> <a href="https://github.com/adam91holt"><img src="https://avatars.githubusercontent.com/u/9592417?v=4&s=48" width="48" height="48" alt="adam91holt" title="adam91holt"/></a> <a href="https://github.com/search?q=alejandro%20maza"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="alejandro maza" title="alejandro maza"/></a>
<a href="https://github.com/andrewting19"><img src="https://avatars.githubusercontent.com/u/10536704?v=4&s=48" width="48" height="48" alt="andrewting19" title="andrewting19"/></a> <a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a> <a href="https://github.com/bolismauro"><img src="https://avatars.githubusercontent.com/u/771999?v=4&s=48" width="48" height="48" alt="bolismauro" title="bolismauro"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a> <a href="https://github.com/conhecendocontato"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendocontato" title="conhecendocontato"/></a> <a href="https://github.com/evalexpr"><img src="https://avatars.githubusercontent.com/u/23485511?v=4&s=48" width="48" height="48" alt="evalexpr" title="evalexpr"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/HazAT"><img src="https://avatars.githubusercontent.com/u/363802?v=4&s=48" width="48" height="48" alt="HazAT" title="HazAT"/></a> <a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a>
<a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a> <a href="https://github.com/search?q=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></a> <a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a> <a href="https://github.com/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a> <a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a> <a href="https://github.com/mickahouan"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="mickahouan" title="mickahouan"/></a> <a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a>
<a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a> <a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a> <a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/roshanasingh4"><img src="https://avatars.githubusercontent.com/u/88576930?v=4&s=48" width="48" height="48" alt="roshanasingh4" title="roshanasingh4"/></a> <a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a>
<a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a> <a href="https://github.com/wes-davis"><img src="https://avatars.githubusercontent.com/u/16506720?v=4&s=48" width="48" height="48" alt="wes-davis" title="wes-davis"/></a> <a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/YuriNachos"><img src="https://avatars.githubusercontent.com/u/19365375?v=4&s=48" width="48" height="48" alt="YuriNachos" title="YuriNachos"/></a> <a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/zknicker"><img src="https://avatars.githubusercontent.com/u/1164085?v=4&s=48" width="48" height="48" alt="zknicker" title="zknicker"/></a> <a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a> <a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a> <a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a>
<a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a> <a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a> <a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a> <a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
</p>
@@ -13,9 +13,7 @@ extension CronJobEditor {
guard let job else { return }
self.name = job.name
self.description = job.description ?? ""
self.agentId = job.agentId ?? ""
self.enabled = job.enabled
self.deleteAfterRun = job.deleteAfterRun ?? false
self.sessionTarget = job.sessionTarget
self.wakeMode = job.wakeMode
@@ -61,60 +59,18 @@ extension CronJobEditor {
}
func buildPayload() throws -> [String: AnyCodable] {
let name = try self.requireName()
let description = self.trimmed(self.description)
let agentId = self.trimmed(self.agentId)
let schedule = try self.buildSchedule()
let payload = try self.buildSelectedPayload()
try self.validateSessionTarget(payload)
try self.validatePayloadRequiredFields(payload)
var root: [String: Any] = [
"name": name,
"enabled": self.enabled,
"schedule": schedule,
"sessionTarget": self.sessionTarget.rawValue,
"wakeMode": self.wakeMode.rawValue,
"payload": payload,
]
self.applyDeleteAfterRun(to: &root)
if !description.isEmpty { root["description"] = description }
if !agentId.isEmpty {
root["agentId"] = agentId
} else if self.job?.agentId != nil {
root["agentId"] = NSNull()
}
if self.sessionTarget == .isolated {
let trimmed = self.postPrefix.trimmingCharacters(in: .whitespacesAndNewlines)
root["isolation"] = [
"postToMainPrefix": trimmed.isEmpty ? "Cron" : trimmed,
]
}
return root.mapValues { AnyCodable($0) }
}
func trimmed(_ value: String) -> String {
value.trimmingCharacters(in: .whitespacesAndNewlines)
}
func requireName() throws -> String {
let name = self.trimmed(self.name)
let name = self.name.trimmingCharacters(in: .whitespacesAndNewlines)
if name.isEmpty {
throw NSError(
domain: "Cron",
code: 0,
userInfo: [NSLocalizedDescriptionKey: "Name is required."])
}
return name
}
func buildSchedule() throws -> [String: Any] {
let description = self.description.trimmingCharacters(in: .whitespacesAndNewlines)
let schedule: [String: Any]
switch self.scheduleKind {
case .at:
return ["kind": "at", "atMs": Int(self.atDate.timeIntervalSince1970 * 1000)]
schedule = ["kind": "at", "atMs": Int(self.atDate.timeIntervalSince1970 * 1000)]
case .every:
guard let ms = Self.parseDurationMs(self.everyText) else {
throw NSError(
@@ -122,35 +78,34 @@ extension CronJobEditor {
code: 0,
userInfo: [NSLocalizedDescriptionKey: "Invalid every duration (use 10m, 1h, 1d)."])
}
return ["kind": "every", "everyMs": ms]
schedule = ["kind": "every", "everyMs": ms]
case .cron:
let expr = self.trimmed(self.cronExpr)
let expr = self.cronExpr.trimmingCharacters(in: .whitespacesAndNewlines)
if expr.isEmpty {
throw NSError(
domain: "Cron",
code: 0,
userInfo: [NSLocalizedDescriptionKey: "Cron expression is required."])
}
let tz = self.trimmed(self.cronTz)
let tz = self.cronTz.trimmingCharacters(in: .whitespacesAndNewlines)
if tz.isEmpty {
return ["kind": "cron", "expr": expr]
schedule = ["kind": "cron", "expr": expr]
} else {
schedule = ["kind": "cron", "expr": expr, "tz": tz]
}
return ["kind": "cron", "expr": expr, "tz": tz]
}
}
func buildSelectedPayload() throws -> [String: Any] {
if self.sessionTarget == .isolated { return self.buildAgentTurnPayload() }
switch self.payloadKind {
case .systemEvent:
let text = self.trimmed(self.systemEventText)
return ["kind": "systemEvent", "text": text]
case .agentTurn:
return self.buildAgentTurnPayload()
}
}
let payload: [String: Any] = {
if self.sessionTarget == .isolated { return self.buildAgentTurnPayload() }
switch self.payloadKind {
case .systemEvent:
let text = self.systemEventText.trimmingCharacters(in: .whitespacesAndNewlines)
return ["kind": "systemEvent", "text": text]
case .agentTurn:
return self.buildAgentTurnPayload()
}
}()
func validateSessionTarget(_ payload: [String: Any]) throws {
if self.sessionTarget == .main, payload["kind"] as? String == "agentTurn" {
throw NSError(
domain: "Cron",
@@ -167,9 +122,7 @@ extension CronJobEditor {
code: 0,
userInfo: [NSLocalizedDescriptionKey: "Isolated jobs require agentTurn payloads."])
}
}
func validatePayloadRequiredFields(_ payload: [String: Any]) throws {
if payload["kind"] as? String == "systemEvent" {
if (payload["text"] as? String ?? "").isEmpty {
throw NSError(
@@ -177,8 +130,7 @@ extension CronJobEditor {
code: 0,
userInfo: [NSLocalizedDescriptionKey: "System event text is required."])
}
}
if payload["kind"] as? String == "agentTurn" {
} else if payload["kind"] as? String == "agentTurn" {
if (payload["message"] as? String ?? "").isEmpty {
throw NSError(
domain: "Cron",
@@ -186,14 +138,25 @@ extension CronJobEditor {
userInfo: [NSLocalizedDescriptionKey: "Agent message is required."])
}
}
}
func applyDeleteAfterRun(to root: inout [String: Any]) {
if self.scheduleKind == .at {
root["deleteAfterRun"] = self.deleteAfterRun
} else if self.job?.deleteAfterRun != nil {
root["deleteAfterRun"] = false
var root: [String: Any] = [
"name": name,
"enabled": self.enabled,
"schedule": schedule,
"sessionTarget": self.sessionTarget.rawValue,
"wakeMode": self.wakeMode.rawValue,
"payload": payload,
]
if !description.isEmpty { root["description"] = description }
if self.sessionTarget == .isolated {
let trimmed = self.postPrefix.trimmingCharacters(in: .whitespacesAndNewlines)
root["isolation"] = [
"postToMainPrefix": trimmed.isEmpty ? "Cron" : trimmed,
]
}
return root.mapValues { AnyCodable($0) }
}
func buildAgentTurnPayload() -> [String: Any] {
@@ -3,7 +3,6 @@ extension CronJobEditor {
mutating func exerciseForTesting() {
self.name = "Test job"
self.description = "Test description"
self.agentId = "ops"
self.enabled = true
self.sessionTarget = .isolated
self.wakeMode = .now
@@ -27,11 +27,9 @@ struct CronJobEditor: View {
@State var name: String = ""
@State var description: String = ""
@State var agentId: String = ""
@State var enabled: Bool = true
@State var sessionTarget: CronSessionTarget = .main
@State var wakeMode: CronWakeMode = .nextHeartbeat
@State var deleteAfterRun: Bool = false
enum ScheduleKind: String, CaseIterable, Identifiable { case at, every, cron; var id: String { rawValue } }
@State var scheduleKind: ScheduleKind = .every
@@ -79,12 +77,6 @@ struct CronJobEditor: View {
.textFieldStyle(.roundedBorder)
.frame(maxWidth: .infinity)
}
GridRow {
self.gridLabel("Agent ID")
TextField("Optional (default agent)", text: self.$agentId)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: .infinity)
}
GridRow {
self.gridLabel("Enabled")
Toggle("", isOn: self.$enabled)
@@ -157,11 +149,6 @@ struct CronJobEditor: View {
.labelsHidden()
.frame(maxWidth: .infinity, alignment: .leading)
}
GridRow {
self.gridLabel("Auto-delete")
Toggle("Delete after successful run", isOn: self.$deleteAfterRun)
.toggleStyle(.switch)
}
case .every:
GridRow {
self.gridLabel("Every")
@@ -145,11 +145,9 @@ struct CronJobState: Codable, Equatable {
struct CronJob: Identifiable, Codable, Equatable {
let id: String
let agentId: String?
var name: String
var description: String?
var enabled: Bool
var deleteAfterRun: Bool?
let createdAtMs: Int
let updatedAtMs: Int
let schedule: CronSchedule
@@ -20,9 +20,6 @@ extension CronSettings {
HStack(spacing: 6) {
StatusPill(text: job.sessionTarget.rawValue, tint: .secondary)
StatusPill(text: job.wakeMode.rawValue, tint: .secondary)
if let agentId = job.agentId, !agentId.isEmpty {
StatusPill(text: "agent \(agentId)", tint: .secondary)
}
if let status = job.state.lastStatus {
StatusPill(text: status, tint: status == "ok" ? .green : .orange)
}
@@ -94,15 +91,9 @@ extension CronSettings {
func detailCard(_ job: CronJob) -> some View {
VStack(alignment: .leading, spacing: 10) {
LabeledContent("Schedule") { Text(self.scheduleSummary(job.schedule)).font(.callout) }
if case .at = job.schedule, job.deleteAfterRun == true {
LabeledContent("Auto-delete") { Text("after success") }
}
if let desc = job.description, !desc.isEmpty {
LabeledContent("Description") { Text(desc).font(.callout) }
}
if let agentId = job.agentId, !agentId.isEmpty {
LabeledContent("Agent") { Text(agentId) }
}
LabeledContent("Session") { Text(job.sessionTarget.rawValue) }
LabeledContent("Wake") { Text(job.wakeMode.rawValue) }
LabeledContent("Next run") {
@@ -7,11 +7,9 @@ struct CronSettings_Previews: PreviewProvider {
store.jobs = [
CronJob(
id: "job-1",
agentId: "ops",
name: "Daily summary",
description: nil,
enabled: true,
deleteAfterRun: nil,
createdAtMs: 0,
updatedAtMs: 0,
schedule: .every(everyMs: 86_400_000, anchorMs: nil),
@@ -61,11 +59,9 @@ extension CronSettings {
let job = CronJob(
id: "job-1",
agentId: "ops",
name: "Daily summary",
description: "Summary job",
enabled: true,
deleteAfterRun: nil,
createdAtMs: 1_700_000_000_000,
updatedAtMs: 1_700_000_100_000,
schedule: .cron(expr: "0 8 * * *", tz: "UTC"),
@@ -149,7 +149,8 @@ final class OnboardingWizardModel {
onboardingWizardLogger.error("wizard step decode failed")
}
if res.done { self.currentStep = nil }
if res.done || status == "done" || status == "cancelled" || status == "error" {
if res.done || status == "done" || status == "cancelled" || status == "error"
{
self.sessionId = nil
}
}
@@ -1356,7 +1356,6 @@ public struct CronJob: Codable, Sendable {
public let name: String
public let description: String?
public let enabled: Bool
public let deleteafterrun: Bool?
public let createdatms: Int
public let updatedatms: Int
public let schedule: AnyCodable
@@ -1372,7 +1371,6 @@ public struct CronJob: Codable, Sendable {
name: String,
description: String?,
enabled: Bool,
deleteafterrun: Bool?,
createdatms: Int,
updatedatms: Int,
schedule: AnyCodable,
@@ -1387,7 +1385,6 @@ public struct CronJob: Codable, Sendable {
self.name = name
self.description = description
self.enabled = enabled
self.deleteafterrun = deleteafterrun
self.createdatms = createdatms
self.updatedatms = updatedatms
self.schedule = schedule
@@ -1403,7 +1400,6 @@ public struct CronJob: Codable, Sendable {
case name
case description
case enabled
case deleteafterrun = "deleteAfterRun"
case createdatms = "createdAtMs"
case updatedatms = "updatedAtMs"
case schedule
@@ -1436,7 +1432,6 @@ public struct CronAddParams: Codable, Sendable {
public let agentid: AnyCodable?
public let description: String?
public let enabled: Bool?
public let deleteafterrun: Bool?
public let schedule: AnyCodable
public let sessiontarget: AnyCodable
public let wakemode: AnyCodable
@@ -1448,7 +1443,6 @@ public struct CronAddParams: Codable, Sendable {
agentid: AnyCodable?,
description: String?,
enabled: Bool?,
deleteafterrun: Bool?,
schedule: AnyCodable,
sessiontarget: AnyCodable,
wakemode: AnyCodable,
@@ -1459,7 +1453,6 @@ public struct CronAddParams: Codable, Sendable {
self.agentid = agentid
self.description = description
self.enabled = enabled
self.deleteafterrun = deleteafterrun
self.schedule = schedule
self.sessiontarget = sessiontarget
self.wakemode = wakemode
@@ -1471,7 +1464,6 @@ public struct CronAddParams: Codable, Sendable {
case agentid = "agentId"
case description
case enabled
case deleteafterrun = "deleteAfterRun"
case schedule
case sessiontarget = "sessionTarget"
case wakemode = "wakeMode"
@@ -1480,6 +1472,70 @@ public struct CronAddParams: Codable, Sendable {
}
}
public struct CronUpdateParams: Codable, Sendable {
public let id: String
public let patch: [String: AnyCodable]
public init(
id: String,
patch: [String: AnyCodable]
) {
self.id = id
self.patch = patch
}
private enum CodingKeys: String, CodingKey {
case id
case patch
}
}
public struct CronRemoveParams: Codable, Sendable {
public let id: String
public init(
id: String
) {
self.id = id
}
private enum CodingKeys: String, CodingKey {
case id
}
}
public struct CronRunParams: Codable, Sendable {
public let id: String
public let mode: AnyCodable?
public init(
id: String,
mode: AnyCodable?
) {
self.id = id
self.mode = mode
}
private enum CodingKeys: String, CodingKey {
case id
case mode
}
}
public struct CronRunsParams: Codable, Sendable {
public let id: String
public let limit: Int?
public init(
id: String,
limit: Int?
) {
self.id = id
self.limit = limit
}
private enum CodingKeys: String, CodingKey {
case id
case limit
}
}
public struct CronRunLogEntry: Codable, Sendable {
public let ts: Int
public let jobid: String
@@ -22,17 +22,17 @@ struct WizardCliOptions {
case "--json":
opts.json = true
case "--url":
opts.url = self.nextValue(args, index: &i)
opts.url = nextValue(args, index: &i)
case "--token":
opts.token = self.nextValue(args, index: &i)
opts.token = nextValue(args, index: &i)
case "--password":
opts.password = self.nextValue(args, index: &i)
opts.password = nextValue(args, index: &i)
case "--mode":
if let value = nextValue(args, index: &i) {
opts.mode = value
}
case "--workspace":
opts.workspace = self.nextValue(args, index: &i)
opts.workspace = nextValue(args, index: &i)
default:
break
}
@@ -118,8 +118,7 @@ private func printUsage() {
clawdbot-mac-wizard
Usage:
clawdbot-mac-wizard [--url <ws://host:port>] [--token <token>] [--password <password>]
[--mode <local|remote>] [--workspace <path>] [--json]
clawdbot-mac-wizard [--url <ws://host:port>] [--token <token>] [--password <password>] [--mode <local|remote>] [--workspace <path>] [--json]
Options:
--url <url> Gateway WebSocket URL (overrides config)
@@ -23,11 +23,8 @@ struct CronJobEditorSmokeTests {
@Test func cronJobEditorBuildsBodyForExistingJob() {
let job = CronJob(
id: "job-1",
agentId: "ops",
name: "Daily summary",
description: nil,
enabled: true,
deleteAfterRun: nil,
createdAtMs: 1_700_000_000_000,
updatedAtMs: 1_700_000_000_000,
schedule: .every(everyMs: 3_600_000, anchorMs: 1_700_000_000_000),
@@ -68,24 +65,4 @@ struct CronJobEditorSmokeTests {
onSave: { _ in })
view.exerciseForTesting()
}
@Test func cronJobEditorIncludesDeleteAfterRunForAtSchedule() throws {
var view = CronJobEditor(
job: nil,
isSaving: .constant(false),
error: .constant(nil),
onCancel: {},
onSave: { _ in })
view.name = "One-shot"
view.sessionTarget = .main
view.payloadKind = .systemEvent
view.systemEventText = "hello"
view.scheduleKind = .at
view.atDate = Date(timeIntervalSince1970: 1_700_000_000)
view.deleteAfterRun = true
let payload = try view.buildPayload()
let raw = payload["deleteAfterRun"]?.value as? Bool
#expect(raw == true)
}
}
@@ -39,27 +39,6 @@ struct CronModelsTests {
#expect(decoded == payload)
}
@Test func jobEncodesAndDecodesDeleteAfterRun() throws {
let job = CronJob(
id: "job-1",
agentId: nil,
name: "One-shot",
description: nil,
enabled: true,
deleteAfterRun: true,
createdAtMs: 0,
updatedAtMs: 0,
schedule: .at(atMs: 1_700_000_000_000),
sessionTarget: .main,
wakeMode: .now,
payload: .systemEvent(text: "ping"),
isolation: nil,
state: CronJobState())
let data = try JSONEncoder().encode(job)
let decoded = try JSONDecoder().decode(CronJob.self, from: data)
#expect(decoded.deleteAfterRun == true)
}
@Test func scheduleDecodeRejectsUnknownKind() {
let json = """
{"kind":"wat","atMs":1}
@@ -81,11 +60,9 @@ struct CronModelsTests {
@Test func displayNameTrimsWhitespaceAndFallsBack() {
let base = CronJob(
id: "x",
agentId: nil,
name: " hello ",
description: nil,
enabled: true,
deleteAfterRun: nil,
createdAtMs: 0,
updatedAtMs: 0,
schedule: .at(atMs: 0),
@@ -104,11 +81,9 @@ struct CronModelsTests {
@Test func nextRunDateAndLastRunDateDeriveFromState() {
let job = CronJob(
id: "x",
agentId: nil,
name: "t",
description: nil,
enabled: true,
deleteAfterRun: nil,
createdAtMs: 0,
updatedAtMs: 0,
schedule: .at(atMs: 0),
@@ -19,19 +19,22 @@ import Testing
error: "gateway connect failed",
elapsedMs: 12,
bot: nil,
webhook: nil),
lastProbeAt: 0),
webhook: nil
),
lastProbeAt: 0
),
],
providerOrder: ["whatsapp"],
providerLabels: ["whatsapp": "WhatsApp"],
heartbeatSeconds: 60,
sessions: .init(path: "/tmp/sessions.json", count: 0, recent: []))
sessions: .init(path: "/tmp/sessions.json", count: 0, recent: [])
)
let store = HealthStore.shared
store.__setSnapshotForTest(snap, lastError: nil)
switch store.state {
case let .degraded(message):
case .degraded(let message):
#expect(!message.isEmpty)
default:
Issue.record("Expected degraded state when probe fails for linked provider")
@@ -40,3 +43,4 @@ import Testing
#expect(store.summaryLine.contains("probe degraded"))
}
}
@@ -12,11 +12,9 @@ struct SettingsViewSmokeTests {
let job1 = CronJob(
id: "job-1",
agentId: "ops",
name: " Morning Check-in ",
description: nil,
enabled: true,
deleteAfterRun: nil,
createdAtMs: 1_700_000_000_000,
updatedAtMs: 1_700_000_100_000,
schedule: .cron(expr: "0 8 * * *", tz: "UTC"),
@@ -34,11 +32,9 @@ struct SettingsViewSmokeTests {
let job2 = CronJob(
id: "job-2",
agentId: nil,
name: "",
description: nil,
enabled: false,
deleteAfterRun: nil,
createdAtMs: 1_700_000_000_000,
updatedAtMs: 1_700_000_100_000,
schedule: .every(everyMs: 30000, anchorMs: nil),
Binary file not shown.

Before

Width:  |  Height:  |  Size: 254 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

+1 -31
View File
@@ -20,24 +20,6 @@ cron is the mechanism.
- **Isolated**: run a dedicated agent turn in `cron:<jobId>`, optionally deliver output.
- Wakeups are first-class: a job can request “wake now” vs “next heartbeat”.
## Beginner-friendly overview
Think of a cron job as: **when** to run + **what** to do.
1) **Choose a schedule**
- One-shot reminder → `schedule.kind = "at"` (CLI: `--at`)
- Repeating job → `schedule.kind = "every"` or `schedule.kind = "cron"`
- If your ISO timestamp omits a timezone, it is treated as **UTC**.
2) **Choose where it runs**
- `sessionTarget: "main"` → run during the next heartbeat with main context.
- `sessionTarget: "isolated"` → run a dedicated agent turn in `cron:<jobId>`.
3) **Choose the payload**
- Main session → `payload.kind = "systemEvent"`
- Isolated session → `payload.kind = "agentTurn"`
Optional: `deleteAfterRun: true` removes successful one-shot jobs from the store.
## Concepts
### Jobs
@@ -50,11 +32,10 @@ A cron job is a stored record with:
Jobs are identified by a stable `jobId` (used by CLI/Gateway APIs).
In agent tool calls, `jobId` is canonical; legacy `id` is accepted for compatibility.
Jobs can optionally auto-delete after a successful one-shot run via `deleteAfterRun: true`.
### Schedules
Cron supports three schedule kinds:
- `at`: one-shot timestamp (ms since epoch). Gateway accepts ISO 8601 and coerces to UTC.
- `at`: one-shot timestamp (ms since epoch).
- `every`: fixed interval (ms).
- `cron`: 5-field cron expression with optional IANA timezone.
@@ -162,17 +143,6 @@ Disable cron entirely:
## CLI quickstart
One-shot reminder (UTC ISO, auto-delete after success):
```bash
clawdbot cron add \
--name "Send reminder" \
--at "2026-01-12T18:00:00Z" \
--session main \
--system-event "Reminder: submit expense report." \
--wake now \
--delete-after-run
```
One-shot reminder (main session, wake immediately):
```bash
clawdbot cron add \
+2 -2
View File
@@ -46,7 +46,7 @@ Target formats (`--to`):
- Providers: WhatsApp/Telegram/Discord/Slack/Signal/iMessage/MS Teams
- Required: `--to`, `--message`
- Optional: `--media`, `--reply-to`, `--thread-id`, `--gif-playback`
- Telegram only: `--buttons` (requires `"inlineButtons"` in `telegram.capabilities` or `telegram.accounts.<id>.capabilities`)
- Telegram only: `--buttons-json` (requires `"inlineButtons"` in `telegram.capabilities` or `telegram.accounts.<id>.capabilities`)
- Telegram only: `--thread-id` (forum topic id)
- Slack only: `--thread-id` (thread timestamp; `--reply-to` uses the same field)
- WhatsApp only: `--gif-playback`
@@ -206,5 +206,5 @@ clawdbot message react --provider slack \
Send Telegram inline buttons:
```
clawdbot message send --provider telegram --to @mychat --message "Choose:" \
--buttons '[ [{"text":"Yes","callback_data":"cmd:yes"}], [{"text":"No","callback_data":"cmd:no"}] ]'
--buttons-json '[ [{"text":"Yes","callback_data":"cmd:yes"}], [{"text":"No","callback_data":"cmd:no"}] ]'
```
-40
View File
@@ -1,40 +0,0 @@
---
summary: "Compaction modes and configuration"
read_when:
- You want to configure compaction summarization behavior
- You are tuning compaction settings in clawdbot.json
---
# Compaction
Compaction summarizes older session history so the conversation stays within the model context window. The summary is stored in the session JSONL history and combined with the most recent messages.
## Modes
`agents.defaults.compaction.mode` controls how summaries are generated.
- `default` (default): use the built-in compaction summarizer.
- `safeguard`: uses a chunked summarization pass to avoid context overflow for very long histories. If chunked summarization fails, Clawdbot falls back to a minimal summary plus file-operation metadata.
## Configuration
```json5
{
agents: {
defaults: {
compaction: {
mode: "safeguard",
reserveTokensFloor: 20000,
memoryFlush: {
enabled: true,
softThresholdTokens: 4000
}
}
}
}
}
```
## Related docs
- [Context window + compaction behavior](/concepts/compaction)
- [Gateway configuration reference](/gateway/configuration)
+2 -4
View File
@@ -109,10 +109,8 @@ See [Memory](/concepts/memory) for the workflow and automatic memory flush.
- Canvas UI files for node displays (for example `canvas/index.html`).
If any bootstrap file is missing, Clawdbot injects a "missing file" marker into
the session and continues. Large bootstrap files are truncated when injected;
adjust the limit with `agents.defaults.bootstrapMaxChars` (default: 20000).
`clawdbot setup` can recreate missing defaults without overwriting existing
files.
the session and continues. `clawdbot setup` can recreate missing defaults
without overwriting existing files.
## What is NOT in the workspace
-3
View File
@@ -15,9 +15,6 @@ Compaction **summarizes older conversation** into a compact summary entry and ke
Compaction **persists** in the session’s JSONL history.
## Configuration
See [Compaction config & modes](/compaction) for the `agents.defaults.compaction` settings.
## Auto-compaction (default on)
When a session nears or exceeds the model’s context window, Clawdbot triggers auto-compaction and may retry the original request using the compacted context.
+1 -1
View File
@@ -212,7 +212,7 @@ Group inbound payloads set:
- `WasMentioned` (mention gating result)
- Telegram forum topics also include `MessageThreadId` and `IsForum`.
The agent system prompt includes a group intro on the first turn of a new group session. It reminds the model to respond like a human, avoid Markdown tables, and avoid typing literal `\n` sequences.
The agent system prompt includes a group intro on the first turn of a new group session.
## iMessage specifics
- Prefer `chat_id:<id>` when routing or allowlisting.
-53
View File
@@ -79,34 +79,6 @@ Defaults:
- Uses remote embeddings (OpenAI) unless configured for local.
- Local mode uses node-llama-cpp and may require `pnpm approve-builds`.
Remote embeddings **require** an API key for the embedding provider. By default
this is OpenAI (`OPENAI_API_KEY` or `models.providers.openai.apiKey`). Codex
OAuth only covers chat/completions and does **not** satisfy embeddings for
memory search. When using a custom OpenAI-compatible endpoint, set
`memorySearch.remote.apiKey` (and optional `memorySearch.remote.headers`).
If you want to use a **custom OpenAI-compatible endpoint** (like Gemini, OpenRouter, or a proxy),
you can use the `remote` configuration:
```json5
agents: {
defaults: {
memorySearch: {
provider: "openai",
model: "text-embedding-3-small",
remote: {
baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai/",
apiKey: "YOUR_GEMINI_API_KEY",
headers: { "X-Custom-Header": "value" }
}
}
}
}
```
If you don't want to set an API key, use `memorySearch.provider = "local"` or set
`memorySearch.fallback = "none"`.
Config example:
```json5
@@ -149,28 +121,3 @@ Local mode:
- When `memorySearch.provider = "local"`, `node-llama-cpp` resolves `modelPath`; if the GGUF is missing it **auto-downloads** to the cache (or `local.modelCacheDir` if set), then loads it. Downloads resume on retry.
- Native build requirement: run `pnpm approve-builds`, pick `node-llama-cpp`, then `pnpm rebuild node-llama-cpp`.
- Fallback: if local setup fails and `memorySearch.fallback = "openai"`, we automatically switch to remote embeddings (`openai/text-embedding-3-small` unless overridden) and record the reason.
### Custom OpenAI-compatible endpoint example
```json5
agents: {
defaults: {
memorySearch: {
provider: "openai",
model: "text-embedding-3-small",
remote: {
baseUrl: "https://api.example.com/v1/",
apiKey: "YOUR_REMOTE_API_KEY",
headers: {
"X-Organization": "org-id",
"X-Project": "project-id"
}
}
}
}
}
```
Notes:
- `remote.*` takes precedence over `models.providers.openai.*`.
- `remote.headers` merge with OpenAI headers; remote wins on key conflicts. Omit `remote.headers` to use the OpenAI defaults.
+2 -35
View File
@@ -117,13 +117,8 @@ Moonshot uses OpenAI-compatible endpoints, so configure it as a custom provider:
- Provider: `moonshot`
- Auth: `MOONSHOT_API_KEY`
- Example model: `moonshot/kimi-k2-0905-preview`
- Kimi K2 model IDs:
{/* moonshot-kimi-k2-model-refs:start */}
- `moonshot/kimi-k2-0905-preview`
- `moonshot/kimi-k2-turbo-preview`
- `moonshot/kimi-k2-thinking`
- `moonshot/kimi-k2-thinking-turbo`
{/* moonshot-kimi-k2-model-refs:end */}
- CLI: `clawdbot onboard --auth-choice moonshot-api-key`
```json5
{
agents: {
@@ -143,34 +138,6 @@ Moonshot uses OpenAI-compatible endpoints, so configure it as a custom provider:
}
```
### Synthetic
Synthetic provides Anthropic-compatible models behind the `synthetic` provider:
- Provider: `synthetic`
- Auth: `SYNTHETIC_API_KEY`
- Example model: `synthetic/hf:MiniMaxAI/MiniMax-M2.1`
- CLI: `clawdbot onboard --auth-choice synthetic-api-key`
```json5
{
agents: {
defaults: { model: { primary: "synthetic/hf:MiniMaxAI/MiniMax-M2.1" } }
},
models: {
mode: "merge",
providers: {
synthetic: {
baseUrl: "https://api.synthetic.new/anthropic",
apiKey: "${SYNTHETIC_API_KEY}",
api: "anthropic-messages",
models: [{ id: "hf:MiniMaxAI/MiniMax-M2.1", name: "MiniMax M2.1" }]
}
}
}
}
```
### MiniMax
MiniMax is configured via `models.providers` because it uses custom endpoints:
+1 -3
View File
@@ -38,9 +38,7 @@ Bootstrap files are trimmed and appended under **Project Context** so the model
- `HEARTBEAT.md`
- `BOOTSTRAP.md` (only on brand-new workspaces)
Large files are truncated with a marker. The max per-file size is controlled by
`agents.defaults.bootstrapMaxChars` (default: 20000). Missing files inject a
short missing-file marker.
Large files are truncated with a marker. Missing files inject a short missing-file marker.
## Time handling
+2 -74
View File
@@ -45,70 +45,6 @@
"source": "/messages/",
"destination": "/concepts/messages"
},
{
"source": "/minimax",
"destination": "/providers/minimax"
},
{
"source": "/minimax/",
"destination": "/providers/minimax"
},
{
"source": "/openai",
"destination": "/providers/openai"
},
{
"source": "/openai/",
"destination": "/providers/openai"
},
{
"source": "/anthropic",
"destination": "/providers/anthropic"
},
{
"source": "/anthropic/",
"destination": "/providers/anthropic"
},
{
"source": "/moonshot",
"destination": "/providers/moonshot"
},
{
"source": "/moonshot/",
"destination": "/providers/moonshot"
},
{
"source": "/openrouter",
"destination": "/providers/openrouter"
},
{
"source": "/openrouter/",
"destination": "/providers/openrouter"
},
{
"source": "/opencode",
"destination": "/providers/opencode"
},
{
"source": "/opencode/",
"destination": "/providers/opencode"
},
{
"source": "/glm",
"destination": "/providers/glm"
},
{
"source": "/glm/",
"destination": "/providers/glm"
},
{
"source": "/zai",
"destination": "/providers/zai"
},
{
"source": "/zai/",
"destination": "/providers/zai"
},
{
"source": "/message",
"destination": "/cli/message"
@@ -739,22 +675,14 @@
{
"group": "Providers",
"pages": [
"providers/openai",
"providers/anthropic",
"providers/moonshot",
"providers/minimax",
"providers/openrouter",
"providers/opencode",
"providers/glm",
"providers/zai",
"providers/whatsapp",
"broadcast-groups",
"providers/telegram",
"providers/grammy",
"providers/discord",
"providers/slack",
"providers/signal",
"providers/imessage",
"providers/whatsapp",
"broadcast-groups",
"providers/msteams",
"providers/troubleshooting",
"providers/location"
-8
View File
@@ -241,14 +241,6 @@ Save to `~/.clawdbot/clawdbot.json` and you can DM the bot from that number.
prompt: "HEARTBEAT",
ackMaxChars: 300
},
memorySearch: {
provider: "openai",
model: "text-embedding-004",
remote: {
baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai/",
apiKey: "${GEMINI_API_KEY}"
}
},
sandbox: {
mode: "non-main",
perSession: true,
+2 -108
View File
@@ -569,9 +569,7 @@ Inbound messages are routed to an agent via bindings.
- `name`: display name for the agent.
- `workspace`: default `~/clawd-<agentId>` (for `main`, falls back to `agents.defaults.workspace`).
- `agentDir`: default `~/.clawdbot/agents/<agentId>/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)
- `model`: per-agent default model (provider/model), overrides `agents.defaults.model` for that agent.
- `identity`: per-agent name/theme/emoji (used for mention patterns + ack reactions).
- `groupChat`: per-agent mention-gating (`mentionPatterns`).
- `sandbox`: per-agent sandbox config (overrides `agents.defaults.sandbox`).
@@ -585,7 +583,6 @@ Inbound messages are routed to an agent via bindings.
- `subagents`: per-agent sub-agent defaults.
- `allowAgents`: allowlist of agent ids for `sessions_spawn` from this agent (`["*"]` = allow any; default: only same agent)
- `tools`: per-agent tool restrictions (applied before sandbox tool policy).
- `profile`: base tool profile (applied before allow/deny)
- `allow`: array of allowed tool names
- `deny`: array of denied tool names (deny wins)
- `agents.defaults`: shared agent defaults (model, workspace, sandbox, etc.).
@@ -1078,20 +1075,6 @@ Use this for pre-seeded deployments where your workspace files come from a repo.
}
```
### `agents.defaults.bootstrapMaxChars`
Max characters of each workspace bootstrap file injected into the system prompt
before truncation. Default: `20000`.
When a file exceeds this limit, Clawdbot logs a warning and injects a truncated
head/tail with a marker.
```json5
{
agents: { defaults: { bootstrapMaxChars: 20000 } }
}
```
### `agents.defaults.userTimezone`
Sets the user’s timezone for **system prompt context** (not for timestamps in
@@ -1395,8 +1378,6 @@ See [/concepts/session-pruning](/concepts/session-pruning) for behavior details.
#### `agents.defaults.compaction` (reserve headroom + memory flush)
`agents.defaults.compaction.mode` selects the compaction summarization strategy. Defaults to `default`; set `safeguard` to enable chunked summarization for very long histories. See [/compaction](/compaction).
`agents.defaults.compaction.reserveTokensFloor` enforces a minimum `reserveTokens`
value for Pi compaction (default: `20000`). Set it to `0` to disable the floor.
@@ -1418,7 +1399,6 @@ Example (tuned):
agents: {
defaults: {
compaction: {
mode: "safeguard",
reserveTokensFloor: 24000,
memoryFlush: {
enabled: true,
@@ -1506,34 +1486,6 @@ Legacy: `tools.bash` is still accepted as an alias.
- `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)
`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).
This is applied even when the Docker sandbox is **off**.
@@ -1544,17 +1496,6 @@ 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:
- `enabled`: allow elevated mode (default true)
- `allowFrom`: per-provider allowlists (empty = disabled)
@@ -1878,48 +1819,6 @@ Notes:
- Model ref: `moonshot/kimi-k2-0905-preview`.
- Use `https://api.moonshot.cn/v1` if you need the China endpoint.
### Synthetic (Anthropic-compatible)
Use Synthetic's Anthropic-compatible endpoint:
```json5
{
env: { SYNTHETIC_API_KEY: "sk-..." },
agents: {
defaults: {
model: { primary: "synthetic/hf:MiniMaxAI/MiniMax-M2.1" },
models: { "synthetic/hf:MiniMaxAI/MiniMax-M2.1": { alias: "MiniMax M2.1" } }
}
},
models: {
mode: "merge",
providers: {
synthetic: {
baseUrl: "https://api.synthetic.new/anthropic",
apiKey: "${SYNTHETIC_API_KEY}",
api: "anthropic-messages",
models: [
{
id: "hf:MiniMaxAI/MiniMax-M2.1",
name: "MiniMax M2.1",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 192000,
maxTokens: 65536
}
]
}
}
}
}
```
Notes:
- Set `SYNTHETIC_API_KEY` or use `clawdbot onboard --auth-choice synthetic-api-key`.
- Model ref: `synthetic/hf:MiniMaxAI/MiniMax-M2.1`.
- Base URL should omit `/v1` because the Anthropic client appends it.
### Local models (LM Studio) — recommended setup
See [/gateway/local-models](/gateway/local-models) for the current local guidance. TL;DR: run MiniMax M2.1 via LM Studio Responses API on serious hardware; keep hosted models merged for fallback.
@@ -2236,12 +2135,7 @@ Auth and Tailscale:
- `gateway.auth.token` stores the shared token for token auth (used by the CLI on the same machine).
- When `gateway.auth.mode` is set, only that method is accepted (plus optional Tailscale headers).
- `gateway.auth.password` can be set here, or via `CLAWDBOT_GATEWAY_PASSWORD` (recommended).
- `gateway.auth.allowTailscale` allows Tailscale Serve identity headers
(`tailscale-user-login`) to satisfy auth when the request arrives on loopback
with `x-forwarded-for`, `x-forwarded-proto`, and `x-forwarded-host`. When
`true`, Serve requests do not need a token/password; set `false` to require
explicit credentials. Defaults to `true` when `tailscale.mode = "serve"` and
auth mode is not `password`.
- `gateway.auth.allowTailscale` controls whether Tailscale identity headers can satisfy auth.
- `gateway.tailscale.mode: "serve"` uses Tailscale Serve (tailnet only, loopback bind).
- `gateway.tailscale.mode: "funnel"` exposes the dashboard publicly; requires auth.
- `gateway.tailscale.resetOnExit` resets Serve/Funnel config on shutdown.
-1
View File
@@ -56,7 +56,6 @@ cat ~/.clawdbot/clawdbot.json
## What it does (summary)
- Optional pre-flight update for git installs (interactive only).
- UI protocol freshness check (rebuilds Control UI when the protocol schema is newer).
- Health check + restart prompt.
- Skills status summary (eligible/missing/blocked).
- Legacy config migration and normalization.
-8
View File
@@ -49,8 +49,6 @@ pnpm gateway:watch
## Multiple gateways (same host)
Usually unnecessary: one Gateway can serve multiple messaging channels and agents. Use multiple Gateways only for redundancy or strict isolation (ex: rescue bot).
Supported if you isolate state + config and use unique ports.
Service names are profile-aware:
@@ -98,12 +96,6 @@ Checklist per instance:
- unique `agents.defaults.workspace`
- separate WhatsApp numbers (if using WA)
Daemon install per profile:
```bash
clawdbot --profile main daemon install
clawdbot --profile rescue daemon install
```
Example:
```bash
CLAWDBOT_CONFIG_PATH=~/.clawdbot/a.json CLAWDBOT_STATE_DIR=~/.clawdbot-a clawdbot gateway --port 19001
+14 -3
View File
@@ -55,6 +55,17 @@ Best current local stack. Load MiniMax M2.1 in LM Studio, enable the local serve
- Adjust `contextWindow`/`maxTokens` if your LM Studio build differs.
- For WhatsApp, stick to Responses API so only final text is sent.
## Model catalog (remote + local)
| Tier | Model ID | Context | Who downloads | Notes |
| --- | --- | --- | --- | --- |
| Hosted | `anthropic/claude-opus-4-5` | 200k | Provider | Latest Claude; keep as high-quality fallback. |
| Hosted | `anthropic/claude-sonnet-4-5` | 200k | Provider | Cheaper Claude; good default. |
| Hosted | `openai/gpt-4.1` | 128k | Provider | Latest GPT-4; strong tools + reasoning. |
| Hosted | `openai/gpt-4.1-mini` | 128k | Provider | Fast/cheap GPT-4 family; good fallback. |
| Local | `lmstudio/minimax-m2.1-gs32` | ~196k (build-dependent) | You (LM Studio UI) | Recommended local heavy model; keep loaded. |
| Local | Custom `vllm` / `litellm` model | server-defined | You (server) | Any OpenAI-compatible endpoint; align context settings. |
Keep hosted models configured even when running local; use `models.mode: "merge"` so fallbacks stay available.
### Hybrid config: hosted primary, local fallback
@@ -65,12 +76,12 @@ Keep hosted models configured even when running local; use `models.mode: "merge"
defaults: {
model: {
primary: "anthropic/claude-sonnet-4-5",
fallbacks: ["lmstudio/minimax-m2.1-gs32", "anthropic/claude-opus-4-5"]
fallbacks: ["lmstudio/minimax-m2.1-gs32", "openai/gpt-4.1-mini"]
},
models: {
"anthropic/claude-sonnet-4-5": { alias: "Sonnet" },
"lmstudio/minimax-m2.1-gs32": { alias: "MiniMax Local" },
"anthropic/claude-opus-4-5": { alias: "Opus" }
"openai/gpt-4.1-mini": { alias: "GPT-4.1 mini" }
}
}
},
@@ -100,7 +111,7 @@ Keep hosted models configured even when running local; use `models.mode: "merge"
### Local-first with hosted safety net
Swap the primary and fallback order; keep the same providers block and `models.mode: "merge"` so you can fall back to Sonnet or Opus when the local box is down.
Swap the primary and fallback order; keep the same providers block and `models.mode: "merge"` so you can fall back to Sonnet or GPT-4.1 when the local box is down.
### Regional hosting / data routing
@@ -50,7 +50,6 @@ See [Sandboxing](/gateway/sandboxing) for the full matrix (scope, workspace moun
## Tool policy: which tools exist/are callable
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`
- **Sandbox tool policy** (only applies when sandboxed): `tools.sandbox.tools.allow`/`tools.sandbox.tools.deny` and `agents.list[].tools.sandbox.tools.*`
@@ -60,7 +59,7 @@ Rules of thumb:
### Tool groups (shorthands)
Tool policies (global, agent, sandbox) support `group:*` entries that expand to multiple tools:
For sandbox tool policy, you can use `group:*` entries that expand to multiple tools:
```json5
{
@@ -79,11 +78,8 @@ Available groups:
- `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)
Legacy shorthand: `memory` expands to `group:memory`.
## Elevated: exec-only “run on host”
-14
View File
@@ -145,20 +145,6 @@ Doctor can generate one for you: `clawdbot doctor --generate-gateway-token`.
Note: `gateway.remote.token` is **only** for remote CLI calls; it does not
protect local WS access.
### 0.6) Tailscale Serve identity headers
When `gateway.auth.allowTailscale` is `true` (default for Serve), Clawdbot
accepts Tailscale Serve identity headers (`tailscale-user-login`) as
authentication. This only triggers for requests that hit loopback and include
`x-forwarded-for`, `x-forwarded-proto`, and `x-forwarded-host` as injected by
Tailscale.
**Security rule:** do not forward these headers from your own reverse proxy. If
you terminate TLS or proxy in front of the gateway, disable
`gateway.auth.allowTailscale` and use token/password auth instead.
See [Tailscale](/gateway/tailscale) and [Web overview](/web).
### 1) DMs: pairing by default
```json5
+3 -7
View File
@@ -23,13 +23,9 @@ Set `gateway.auth.mode` to control the handshake:
- `token` (default when `CLAWDBOT_GATEWAY_TOKEN` is set)
- `password` (shared secret via `CLAWDBOT_GATEWAY_PASSWORD` or config)
When `tailscale.mode = "serve"` and `gateway.auth.allowTailscale` is `true`,
valid Serve proxy requests can authenticate via Tailscale identity headers
(`tailscale-user-login`) without supplying a token/password. Clawdbot only
treats a request as Serve when it arrives from loopback with Tailscale’s
`x-forwarded-for`, `x-forwarded-proto`, and `x-forwarded-host` headers.
To require explicit credentials, set `gateway.auth.allowTailscale: false` or
force `gateway.auth.mode: "password"`.
When `tailscale.mode = "serve"`, the gateway trusts Tailscale identity headers by
default unless you force `gateway.auth.mode` to `password` or set
`gateway.auth.allowTailscale: false`.
## Config examples
+7 -34
View File
@@ -106,28 +106,6 @@ 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
```json
@@ -187,29 +165,24 @@ agents.list[].sandbox.prune.* > agents.defaults.sandbox.prune.*
### Tool Restrictions
The filtering order is:
1. **Tool profile** (`tools.profile` or `agents.list[].tools.profile`)
2. **Global tool policy** (`tools.allow` / `tools.deny`)
3. **Agent-specific tool policy** (`agents.list[].tools`)
4. **Sandbox tool policy** (`tools.sandbox.tools` or `agents.list[].tools.sandbox.tools`)
5. **Subagent tool policy** (`tools.subagents.tools`, if applicable)
1. **Global tool policy** (`tools.allow` / `tools.deny`)
2. **Agent-specific tool policy** (`agents.list[].tools`)
3. **Sandbox tool policy** (`tools.sandbox.tools` or `agents.list[].tools.sandbox.tools`)
4. **Subagent tool policy** (`tools.subagents.tools`, if applicable)
Each level can further restrict tools, but cannot grant back denied tools from earlier levels.
If `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 policies (global, agent, sandbox) support `group:*` entries that expand to multiple concrete tools:
Sandbox tool policy supports `group:*` entries that expand to multiple concrete tools:
- `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)
Legacy shorthand: `memory` expands to `group:memory`.
### 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).
-3
View File
@@ -29,9 +29,6 @@ MiniMax highlights these improvements in M2.1:
- **Speed:** Lightning is the “fast” variant in MiniMax’s pricing docs.
- **Cost:** Pricing shows the same input cost, but Lightning has higher output cost.
- **Coding plan routing:** The Lightning back-end isn’t directly available on the MiniMax
coding plan. MiniMax auto-routes most requests to Lightning, but falls back to the
regular M2.1 back-end during traffic spikes.
## Choose a setup
-1
View File
@@ -26,7 +26,6 @@ model as `provider/model`.
- [Anthropic (API + Claude CLI)](/providers/anthropic)
- [OpenRouter](/providers/openrouter)
- [Moonshot AI (Kimi)](/providers/moonshot)
- [Synthetic](/providers/synthetic)
- [OpenCode Zen](/providers/opencode)
- [Z.AI](/providers/zai)
- [GLM models](/providers/glm)
+2 -44
View File
@@ -9,13 +9,7 @@ read_when:
Moonshot provides the Kimi API with OpenAI-compatible endpoints. Configure the
provider and set the default model to `moonshot/kimi-k2-0905-preview`.
Current Kimi K2 model IDs:
{/* moonshot-kimi-k2-ids:start */}
- `kimi-k2-0905-preview`
- `kimi-k2-turbo-preview`
- `kimi-k2-thinking`
- `kimi-k2-thinking-turbo`
{/* moonshot-kimi-k2-ids:end */}
## CLI setup
```bash
clawdbot onboard --auth-choice moonshot-api-key
@@ -30,12 +24,7 @@ clawdbot onboard --auth-choice moonshot-api-key
defaults: {
model: { primary: "moonshot/kimi-k2-0905-preview" },
models: {
// moonshot-kimi-k2-aliases:start
"moonshot/kimi-k2-0905-preview": { alias: "Kimi K2" },
"moonshot/kimi-k2-turbo-preview": { alias: "Kimi K2 Turbo" },
"moonshot/kimi-k2-thinking": { alias: "Kimi K2 Thinking" },
"moonshot/kimi-k2-thinking-turbo": { alias: "Kimi K2 Thinking Turbo" }
// moonshot-kimi-k2-aliases:end
"moonshot/kimi-k2-0905-preview": { alias: "Kimi K2" }
}
}
},
@@ -47,7 +36,6 @@ clawdbot onboard --auth-choice moonshot-api-key
apiKey: "${MOONSHOT_API_KEY}",
api: "openai-completions",
models: [
// moonshot-kimi-k2-models:start
{
id: "kimi-k2-0905-preview",
name: "Kimi K2 0905 Preview",
@@ -56,35 +44,7 @@ clawdbot onboard --auth-choice moonshot-api-key
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 256000,
maxTokens: 8192
},
{
id: "kimi-k2-turbo-preview",
name: "Kimi K2 Turbo",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 256000,
maxTokens: 8192
},
{
id: "kimi-k2-thinking",
name: "Kimi K2 Thinking",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 256000,
maxTokens: 8192
},
{
id: "kimi-k2-thinking-turbo",
name: "Kimi K2 Thinking Turbo",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 256000,
maxTokens: 8192
}
// moonshot-kimi-k2-models:end
]
}
}
@@ -96,6 +56,4 @@ clawdbot onboard --auth-choice moonshot-api-key
- Model refs use `moonshot/<modelId>`.
- Override pricing and context metadata in `models.providers` if needed.
- If Moonshot publishes different context limits for a model, adjust
`contextWindow` accordingly.
- Use `https://api.moonshot.cn/v1` if you need the China endpoint.
-97
View File
@@ -1,97 +0,0 @@
---
summary: "Use Synthetic's Anthropic-compatible API in Clawdbot"
read_when:
- You want to use Synthetic as a model provider
- You need a Synthetic API key or base URL setup
---
# Synthetic
Synthetic exposes Anthropic-compatible endpoints. Clawdbot registers it as the
`synthetic` provider and uses the Anthropic Messages API.
## Quick setup
1) Set `SYNTHETIC_API_KEY` (or run the wizard below).
2) Run onboarding:
```bash
clawdbot onboard --auth-choice synthetic-api-key
```
The default model is set to:
```
synthetic/hf:MiniMaxAI/MiniMax-M2.1
```
## Config example
```json5
{
env: { SYNTHETIC_API_KEY: "sk-..." },
agents: {
defaults: {
model: { primary: "synthetic/hf:MiniMaxAI/MiniMax-M2.1" },
models: { "synthetic/hf:MiniMaxAI/MiniMax-M2.1": { alias: "MiniMax M2.1" } }
}
},
models: {
mode: "merge",
providers: {
synthetic: {
baseUrl: "https://api.synthetic.new/anthropic",
apiKey: "${SYNTHETIC_API_KEY}",
api: "anthropic-messages",
models: [
{
id: "hf:MiniMaxAI/MiniMax-M2.1",
name: "MiniMax M2.1",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 192000,
maxTokens: 65536
}
]
}
}
}
}
```
Note: Clawdbot's Anthropic client appends `/v1` to the base URL, so use
`https://api.synthetic.new/anthropic` (not `/anthropic/v1`). If Synthetic changes
its base URL, override `models.providers.synthetic.baseUrl`.
## Model catalog
All models below use cost `0` (input/output/cache).
| Model ID | Context window | Max tokens | Reasoning | Input |
| --- | --- | --- | --- | --- |
| `hf:MiniMaxAI/MiniMax-M2.1` | 192000 | 65536 | false | text |
| `hf:moonshotai/Kimi-K2-Thinking` | 256000 | 8192 | true | text |
| `hf:zai-org/GLM-4.7` | 198000 | 128000 | false | text |
| `hf:deepseek-ai/DeepSeek-R1-0528` | 128000 | 8192 | false | text |
| `hf:deepseek-ai/DeepSeek-V3-0324` | 128000 | 8192 | false | text |
| `hf:deepseek-ai/DeepSeek-V3.1` | 128000 | 8192 | false | text |
| `hf:deepseek-ai/DeepSeek-V3.1-Terminus` | 128000 | 8192 | false | text |
| `hf:deepseek-ai/DeepSeek-V3.2` | 159000 | 8192 | false | text |
| `hf:meta-llama/Llama-3.3-70B-Instruct` | 128000 | 8192 | false | text |
| `hf:meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8` | 524000 | 8192 | false | text |
| `hf:moonshotai/Kimi-K2-Instruct-0905` | 256000 | 8192 | false | text |
| `hf:openai/gpt-oss-120b` | 128000 | 8192 | false | text |
| `hf:Qwen/Qwen3-235B-A22B-Instruct-2507` | 256000 | 8192 | false | text |
| `hf:Qwen/Qwen3-Coder-480B-A35B-Instruct` | 256000 | 8192 | false | text |
| `hf:Qwen/Qwen3-VL-235B-A22B-Instruct` | 250000 | 8192 | false | text + image |
| `hf:zai-org/GLM-4.5` | 128000 | 128000 | false | text |
| `hf:zai-org/GLM-4.6` | 198000 | 128000 | false | text |
| `hf:deepseek-ai/DeepSeek-V3` | 128000 | 8192 | false | text |
| `hf:Qwen/Qwen3-235B-A22B-Thinking-2507` | 256000 | 8192 | true | text |
## Notes
- Model refs use `synthetic/<modelId>`.
- If you enable a model allowlist (`agents.defaults.models`), add every model you
plan to use.
- See [Model providers](/concepts/model-providers) for provider rules.
+4 -47
View File
@@ -87,20 +87,6 @@ The wizard can also build UI assets automatically. After onboarding, you typical
The wizard now opens your browser with a tokenized dashboard URL right after onboarding and also prints the full link (with token) in the summary. Keep that tab open; if it didn’t launch, copy/paste the printed URL on the same machine. Tokens stay local to your host—nothing is fetched from the browser.
### How do I authenticate the dashboard (token) on localhost vs remote?
**Localhost (same machine):**
- Open `http://127.0.0.1:18789/`.
- If it asks for auth, run `clawdbot dashboard` and use the tokenized link (`?token=...`).
- The token is the same value as `gateway.auth.token` (or `CLAWDBOT_GATEWAY_TOKEN`) and is stored by the UI after first load.
**Not on localhost:**
- **Tailscale Serve** (recommended): keep bind loopback, run `clawdbot gateway --tailscale serve`, open `https://<magicdns>/`. If `gateway.auth.allowTailscale` is `true`, identity headers satisfy auth (no token).
- **Tailnet bind**: run `clawdbot gateway --bind tailnet --token "<token>"`, open `http://<tailscale-ip>:18789/`, paste token in dashboard settings.
- **SSH tunnel**: `ssh -N -L 18789:127.0.0.1:18789 user@host` then open `http://127.0.0.1:18789/?token=...` from `clawdbot dashboard`.
See [Dashboard](/web/dashboard) and [Web surfaces](/web) for bind modes and auth details.
### What runtime do I need?
Node **>= 22** is required. `pnpm` is recommended; `bun` is optional.
@@ -200,19 +186,6 @@ Doctor detects a gateway service entrypoint mismatch and offers to rewrite the s
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.
### Can I load skills from a custom folder?
Yes. Add extra directories via `skills.load.extraDirs` in `~/.clawdbot/clawdbot.json` (lowest precedence). Default precedence remains: `<workspace>/skills` → `~/.clawdbot/skills` → bundled → `skills.load.extraDirs`. `clawdhub` installs into `./skills` by default, which Clawdbot treats as `<workspace>/skills`.
### How can I use different models for different tasks?
Today the supported patterns are:
- **Cron jobs**: isolated jobs can set a `model` override per job.
- **Sub-agents**: route tasks to separate agents with different default models.
- **On-demand switch**: use `/model` to switch the current session model at any time.
See [Cron jobs](/automation/cron-jobs), [Multi-Agent Routing](/concepts/multi-agent), and [Slash commands](/tools/slash-commands).
### How do I install skills on Linux?
Use **ClawdHub** (CLI) or drop skills into your workspace. The macOS Skills UI isn’t available on Linux.
@@ -238,7 +211,7 @@ clawdhub install <skill-slug>
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; 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).
### Is there a dedicated sandboxing doc?
@@ -258,17 +231,6 @@ Clawdbot also runs a **silent pre-compaction memory flush** to remind the model
to write durable notes before auto-compaction. This only runs when the workspace
is writable (read-only sandboxes skip it). See [Memory](/concepts/memory).
### Does semantic memory search require an OpenAI API key?
Only if you use **remote embeddings** (OpenAI). Codex OAuth covers
chat/completions and does **not** grant embeddings access, so **signing in with
Codex (OAuth or the Codex CLI login)** does not help for semantic memory search.
Remote memory search still needs a real OpenAI API key (`OPENAI_API_KEY` or
`models.providers.openai.apiKey`). If you’d rather stay local, set
`memorySearch.provider = "local"` (and optionally `memorySearch.fallback =
"none"`). We support **remote or local embedding models** — see [Memory](/concepts/memory)
for the setup details.
## Where things live on disk
### Where does Clawdbot store its data?
@@ -829,8 +791,6 @@ Fix:
### Can I run multiple Gateways on the same host?
Usually no — one Gateway can run multiple messaging channels and agents. Use multiple Gateways only when you need redundancy (ex: rescue bot) or hard isolation.
Yes, but you must isolate:
- `CLAWDBOT_CONFIG_PATH` (per‑instance config)
@@ -838,12 +798,9 @@ Yes, but you must isolate:
- `agents.defaults.workspace` (workspace isolation)
- `gateway.port` (unique ports)
Quick setup (recommended):
- Use `clawdbot --profile <name> …` per instance (auto-creates `~/.clawdbot-<name>`).
- Set a unique `gateway.port` in each profile config (or pass `--port` for manual runs).
- Install a per-profile daemon: `clawdbot --profile <name> daemon install`.
Profiles also suffix service names (`com.clawdbot.<profile>`, `clawdbot-gateway-<profile>.service`, `Clawdbot Gateway (<profile>)`).
There are convenience CLI flags like `--dev` and `--profile <name>` that shift state dirs and ports.
When using profiles, service names are suffixed (`com.clawdbot.<profile>`, `clawdbot-gateway-<profile>.service`,
`Clawdbot Gateway (<profile>)`).
## Logging and debugging
-28
View File
@@ -42,22 +42,6 @@ Full setup walkthrough (28m) by VelvetShark.
<CardGroup cols={2}>
<Card title="PR Review → Telegram Feedback" icon="code-pull-request" href="https://x.com/i/status/2010878524543131691">
**@bangnokia** • `review` `github` `telegram`
OpenCode finishes the change → opens a PR → Clawdbot reviews the diff and replies in Telegram with “minor suggestions” plus a clear merge verdict (including critical fixes to apply first).
<img src="/assets/showcase/pr-review-telegram.jpg" alt="Clawdbot PR review feedback delivered in Telegram" />
</Card>
<Card title="Wine Cellar Skill in Minutes" icon="wine-glass" href="https://x.com/i/status/2010916352454791216">
**@prades_maxime** • `skills` `local` `csv`
Asked “Robby” (@clawdbot) for a local wine cellar skill. It requests a sample CSV export + where to store it, then builds/tests the skill fast (962 bottles in the example).
<img src="/assets/showcase/wine-cellar-skill.jpg" alt="Clawdbot building a local wine cellar skill from CSV" />
</Card>
<Card title="Tesco Shop Autopilot" icon="cart-shopping" href="https://x.com/i/status/2009724862470689131">
**@marchattonhere** • `automation` `browser` `shopping`
@@ -212,18 +196,6 @@ Full setup walkthrough (28m) by VelvetShark.
Searches job listings, matches against CV keywords, and returns relevant opportunities with links. Built in 30 minutes using JSearch API.
</Card>
<Card title="Jira Skill Builder" icon="diagram-project" href="https://x.com/jdrhyne/status/2008336434827002232">
**@jdrhyne** • `automation` `jira` `skill` `devtools`
Clawdbot connected to Jira, then generated a new skill on the fly (before it existed on ClawdHub).
</Card>
<Card title="Todoist Skill via Telegram" icon="list-check" href="https://x.com/iamsubhrajyoti/status/2009949389884920153">
**@iamsubhrajyoti** • `automation` `todoist` `skill` `telegram`
Automated Todoist tasks and had Clawdbot generate the skill directly in Telegram chat.
</Card>
<Card title="TradingView Analysis" icon="chart-line">
**@bheem1798** • `finance` `browser` `automation`
-15
View File
@@ -64,8 +64,6 @@ Tip: `--json` does **not** imply non-interactive mode. Use `--non-interactive` (
1) **Existing config detection**
- If `~/.clawdbot/clawdbot.json` exists, choose **Keep / Modify / Reset**.
- If the config is invalid or contains legacy keys, the wizard stops and asks
you to run `clawdbot doctor` before continuing.
- Reset uses `trash` (never `rm`) and offers scopes:
- Config only
- Config + credentials + sessions
@@ -84,8 +82,6 @@ Tip: `--json` does **not** imply non-interactive mode. Use `--non-interactive` (
- **API key**: stores the key for you.
- **MiniMax M2.1**: config is auto-written.
- More detail: [MiniMax](/providers/minimax)
- **Synthetic (Anthropic-compatible)**: prompts for `SYNTHETIC_API_KEY`.
- More detail: [Synthetic](/providers/synthetic)
- **Moonshot (Kimi K2)**: config is auto-written.
- More detail: [Moonshot AI](/providers/moonshot)
- **Skip**: no auth configured yet.
@@ -216,17 +212,6 @@ clawdbot onboard --non-interactive \
--gateway-bind loopback
```
Synthetic example:
```bash
clawdbot onboard --non-interactive \
--mode local \
--auth-choice synthetic-api-key \
--synthetic-api-key "$SYNTHETIC_API_KEY" \
--gateway-port 18789 \
--gateway-bind loopback
```
OpenCode Zen example:
```bash
+1 -1
View File
@@ -16,7 +16,7 @@ Clawdbot assembles its own system prompt on every run. It includes:
- Tool list + short descriptions
- Skills list (only metadata; instructions are loaded on demand with `read`)
- Self-update instructions
- Workspace + bootstrap files (`AGENTS.md`, `SOUL.md`, `TOOLS.md`, `IDENTITY.md`, `USER.md`, `HEARTBEAT.md`, `BOOTSTRAP.md` when new). Large files are truncated by `agents.defaults.bootstrapMaxChars` (default: 20000).
- Workspace + bootstrap files (`AGENTS.md`, `SOUL.md`, `TOOLS.md`, `IDENTITY.md`, `USER.md`, `HEARTBEAT.md`, `BOOTSTRAP.md` when new)
- Time (UTC + user timezone)
- Reply tags + heartbeat behavior
- Runtime metadata (host/OS/model/thinking)
+2 -3
View File
@@ -48,7 +48,7 @@ bun add -g clawdhub
## How it fits into Clawdbot
By default, the CLI installs skills into `./skills` under your current working directory. If a Clawdbot workspace is configured, `clawdhub` falls back to that workspace unless you override `--workdir` (or `CLAWDHUB_WORKDIR`). Clawdbot loads workspace skills from `<workspace>/skills` and will pick them up in the **next** session. If you already use `~/.clawdbot/skills` or bundled skills, workspace skills take precedence.
By default, the CLI installs skills into `./skills` under your current working directory. Clawdbot loads workspace skills from `<workspace>/skills` and will pick them up in the **next** session. If you already use `~/.clawdbot/skills` or bundled skills, workspace skills take precedence.
For more detail on how skills are loaded, shared, and gated, see
[Skills](/tools/skills).
@@ -67,7 +67,7 @@ For more detail on how skills are loaded, shared, and gated, see
Global options (apply to all commands):
- `--workdir <dir>`: Working directory (default: current dir; falls back to Clawdbot workspace).
- `--workdir <dir>`: Working directory (default: current dir).
- `--dir <dir>`: Skills directory, relative to workdir (default: `skills`).
- `--site <url>`: Site base URL (browser login).
- `--registry <url>`: Registry API base URL.
@@ -201,5 +201,4 @@ export CLAWDHUB_DISABLE_TELEMETRY=1
- `CLAWDHUB_SITE`: Override the site URL.
- `CLAWDHUB_REGISTRY`: Override the registry API URL.
- `CLAWDHUB_CONFIG_PATH`: Override where the CLI stores the token/config.
- `CLAWDHUB_WORKDIR`: Override the default workdir.
- `CLAWDHUB_DISABLE_TELEMETRY=1`: Disable telemetry on `sync`.
-72
View File
@@ -22,77 +22,6 @@ 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 can register **additional tools** (and CLI commands) beyond the core set.
@@ -250,7 +179,6 @@ Core actions:
Notes:
- `send` routes WhatsApp via the Gateway; other providers go direct.
- `poll` uses the Gateway for WhatsApp and MS Teams; Discord polls go direct.
- When a message tool call is bound to an active chat session, sends are constrained to that session’s target to avoid cross-context leaks.
### `cron`
Manage Gateway cron jobs and wakeups.
+1 -2
View File
@@ -58,8 +58,7 @@ Common flows:
- `clawdhub sync --all`
By default, `clawdhub` installs into `./skills` under your current working
directory (or falls back to the configured Clawdbot workspace). Clawdbot picks
that up as `<workspace>/skills` on the next session.
directory; Clawdbot picks that up as `<workspace>/skills` on the next session.
## Format (AgentSkills + Pi-compatible)
+2 -6
View File
@@ -77,12 +77,8 @@ clawdbot gateway --tailscale serve
Open:
- `https://<magicdns>/` (or your configured `gateway.controlUi.basePath`)
By default, Serve requests can authenticate via Tailscale identity headers
(`tailscale-user-login`) when `gateway.auth.allowTailscale` is `true`. Clawdbot
only accepts these when the request hits loopback with Tailscale’s
`x-forwarded-*` headers. Set `gateway.auth.allowTailscale: false` (or force
`gateway.auth.mode: "password"`) if you want to require a token/password even
for Serve traffic.
By default, the gateway trusts Tailscale identity headers in serve mode. You can still set
`gateway.auth` (or `CLAWDBOT_GATEWAY_TOKEN`) if you want a shared secret instead.
### Bind to tailnet + token
-6
View File
@@ -25,12 +25,6 @@ Authentication is enforced at the WebSocket handshake via `connect.params.auth`
- Re-open anytime: `clawdbot dashboard` (copies link, opens browser if possible, shows SSH hint if headless).
- The token stays local (query param only); the UI strips it after first load and saves it in localStorage.
## Token basics (local vs remote)
- **Localhost**: open `http://127.0.0.1:18789/`. If you see “unauthorized,” run `clawdbot dashboard` and use the tokenized link (`?token=...`).
- **Token source**: `gateway.auth.token` (or `CLAWDBOT_GATEWAY_TOKEN`); the UI stores it after first load.
- **Not localhost**: use Tailscale Serve (tokenless if `gateway.auth.allowTailscale: true`), tailnet bind with a token, or an SSH tunnel. See [Web surfaces](/web).
## If you see “unauthorized” / 1008
- Run `clawdbot dashboard` to get a fresh tokenized link.
+1 -4
View File
@@ -94,10 +94,7 @@ Open:
- Binding the Gateway to a non-loopback address **requires** auth (`gateway.auth` or `CLAWDBOT_GATEWAY_TOKEN`).
- The wizard generates a gateway token by default (even on loopback).
- The UI sends `connect.params.auth.token` or `connect.params.auth.password`.
- With Serve, Tailscale identity headers can satisfy auth when
`gateway.auth.allowTailscale` is `true` (no token/password required). Set
`gateway.auth.allowTailscale: false` to require explicit credentials. See
[Tailscale](/gateway/tailscale) and [Security](/gateway/security).
- Use `gateway.auth.allowTailscale: false` to require explicit credentials even in Serve mode.
- `gateway.tailscale.mode: "funnel"` requires `gateway.auth.mode: "password"` (shared password).
## Building the UI
-128
View File
@@ -1,128 +0,0 @@
import { readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
MOONSHOT_KIMI_K2_CONTEXT_WINDOW,
MOONSHOT_KIMI_K2_COST,
MOONSHOT_KIMI_K2_INPUT,
MOONSHOT_KIMI_K2_MAX_TOKENS,
MOONSHOT_KIMI_K2_MODELS,
} from "../ui/src/ui/data/moonshot-kimi-k2";
const here = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(here, "..");
function replaceBlockLines(
text: string,
startMarker: string,
endMarker: string,
lines: string[],
): string {
const startIndex = text.indexOf(startMarker);
if (startIndex === -1) {
throw new Error(`Missing start marker: ${startMarker}`);
}
const endIndex = text.indexOf(endMarker, startIndex);
if (endIndex === -1) {
throw new Error(`Missing end marker: ${endMarker}`);
}
const startLineStart = text.lastIndexOf("\n", startIndex);
const startLineStartIndex = startLineStart === -1 ? 0 : startLineStart + 1;
const indent = text.slice(startLineStartIndex, startIndex);
const endLineEnd = text.indexOf("\n", endIndex);
const endLineEndIndex = endLineEnd === -1 ? text.length : endLineEnd + 1;
const before = text.slice(0, startLineStartIndex);
const after = text.slice(endLineEndIndex);
const replacementLines = [
`${indent}${startMarker}`,
...lines.map((line) => `${indent}${line}`),
`${indent}${endMarker}`,
];
const replacement = replacementLines.join("\n");
if (!after) return `${before}${replacement}`;
return `${before}${replacement}\n${after}`;
}
function renderKimiK2Ids(prefix: string) {
return MOONSHOT_KIMI_K2_MODELS.map(
(model) => `- \`${prefix}${model.id}\``,
);
}
function renderMoonshotAliases() {
return MOONSHOT_KIMI_K2_MODELS.map((model, index) => {
const isLast = index === MOONSHOT_KIMI_K2_MODELS.length - 1;
const suffix = isLast ? "" : ",";
return `"moonshot/${model.id}": { alias: "${model.alias}" }${suffix}`;
});
}
function renderMoonshotModels() {
const input = JSON.stringify([...MOONSHOT_KIMI_K2_INPUT]);
const cost = `input: ${MOONSHOT_KIMI_K2_COST.input}, output: ${MOONSHOT_KIMI_K2_COST.output}, cacheRead: ${MOONSHOT_KIMI_K2_COST.cacheRead}, cacheWrite: ${MOONSHOT_KIMI_K2_COST.cacheWrite}`;
return MOONSHOT_KIMI_K2_MODELS.flatMap((model, index) => {
const isLast = index === MOONSHOT_KIMI_K2_MODELS.length - 1;
const closing = isLast ? "}" : "},";
return [
"{",
` id: "${model.id}",`,
` name: "${model.name}",`,
` reasoning: ${model.reasoning},`,
` input: ${input},`,
` cost: { ${cost} },`,
` contextWindow: ${MOONSHOT_KIMI_K2_CONTEXT_WINDOW},`,
` maxTokens: ${MOONSHOT_KIMI_K2_MAX_TOKENS}`,
closing,
];
});
}
async function syncMoonshotDocs() {
const moonshotDoc = path.join(repoRoot, "docs/providers/moonshot.md");
const conceptsDoc = path.join(
repoRoot,
"docs/concepts/model-providers.md",
);
let moonshotText = await readFile(moonshotDoc, "utf8");
moonshotText = replaceBlockLines(
moonshotText,
"<!-- moonshot-kimi-k2-ids:start -->",
"<!-- moonshot-kimi-k2-ids:end -->",
renderKimiK2Ids(""),
);
moonshotText = replaceBlockLines(
moonshotText,
"// moonshot-kimi-k2-aliases:start",
"// moonshot-kimi-k2-aliases:end",
renderMoonshotAliases(),
);
moonshotText = replaceBlockLines(
moonshotText,
"// moonshot-kimi-k2-models:start",
"// moonshot-kimi-k2-models:end",
renderMoonshotModels(),
);
let conceptsText = await readFile(conceptsDoc, "utf8");
conceptsText = replaceBlockLines(
conceptsText,
"<!-- moonshot-kimi-k2-model-refs:start -->",
"<!-- moonshot-kimi-k2-model-refs:end -->",
renderKimiK2Ids("moonshot/"),
);
await writeFile(moonshotDoc, moonshotText);
await writeFile(conceptsDoc, conceptsText);
}
syncMoonshotDocs().catch((error) => {
console.error(error);
process.exitCode = 1;
});
@@ -20,10 +20,6 @@ docker run --rm -t \
--entrypoint bash \
-e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
-e HOME=/home/node \
-e CLAWDBOT_LIVE_TEST=1 \
-e CLAWDBOT_LIVE_GATEWAY_MODELS="${CLAWDBOT_LIVE_GATEWAY_MODELS:-all}" \
-e CLAWDBOT_LIVE_GATEWAY_PROVIDERS="${CLAWDBOT_LIVE_GATEWAY_PROVIDERS:-}" \
-e CLAWDBOT_LIVE_GATEWAY_MODEL_TIMEOUT_MS="${CLAWDBOT_LIVE_GATEWAY_MODEL_TIMEOUT_MS:-}" \
-v "$CONFIG_DIR":/home/node/.clawdbot \
-v "$WORKSPACE_DIR":/home/node/clawd \
"${PROFILE_MOUNT[@]}" \
+1 -3
View File
@@ -21,10 +21,8 @@ docker run --rm -t \
-e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
-e HOME=/home/node \
-e CLAWDBOT_LIVE_TEST=1 \
-e CLAWDBOT_LIVE_ALL_MODELS=1 \
-e CLAWDBOT_LIVE_MODELS="${CLAWDBOT_LIVE_MODELS:-all}" \
-e CLAWDBOT_LIVE_PROVIDERS="${CLAWDBOT_LIVE_PROVIDERS:-}" \
-e CLAWDBOT_LIVE_MODEL_TIMEOUT_MS="${CLAWDBOT_LIVE_MODEL_TIMEOUT_MS:-}" \
-e CLAWDBOT_LIVE_REQUIRE_PROFILE_KEYS="${CLAWDBOT_LIVE_REQUIRE_PROFILE_KEYS:-}" \
-v "$CONFIG_DIR":/home/node/.clawdbot \
-v "$WORKSPACE_DIR":/home/node/clawd \
"${PROFILE_MOUNT[@]}" \
+1 -1
View File
@@ -49,5 +49,5 @@ clawdhub publish ./my-skill --slug my-skill --name "My Skill" --version 1.2.0 --
Notes
- Default registry: https://clawdhub.com (override with CLAWDHUB_REGISTRY or --registry)
- Default workdir: cwd (falls back to Clawdbot workspace); install dir: ./skills (override with --workdir / --dir / CLAWDHUB_WORKDIR)
- Default workdir: cwd; install dir: ./skills (override with --workdir / --dir)
- Update command hashes local files, resolves matching version, and upgrades to latest unless --version is set
+1 -67
View File
@@ -1,10 +1,6 @@
import { describe, expect, it } from "vitest";
import type { ClawdbotConfig } from "../config/config.js";
import {
resolveAgentConfig,
resolveAgentModelFallbacksOverride,
resolveAgentModelPrimary,
} from "./agent-scope.js";
import { resolveAgentConfig } from "./agent-scope.js";
describe("resolveAgentConfig", () => {
it("should return undefined when no agents config exists", () => {
@@ -51,68 +47,6 @@ 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", () => {
const cfg: ClawdbotConfig = {
agents: {
+2 -28
View File
@@ -21,7 +21,7 @@ type ResolvedAgentConfig = {
name?: string;
workspace?: string;
agentDir?: string;
model?: AgentEntry["model"];
model?: string;
memorySearch?: AgentEntry["memorySearch"];
humanDelay?: AgentEntry["humanDelay"];
identity?: AgentEntry["identity"];
@@ -95,11 +95,7 @@ export function resolveAgentConfig(
workspace:
typeof entry.workspace === "string" ? entry.workspace : undefined,
agentDir: typeof entry.agentDir === "string" ? entry.agentDir : undefined,
model:
typeof entry.model === "string" ||
(entry.model && typeof entry.model === "object")
? entry.model
: undefined,
model: typeof entry.model === "string" ? entry.model : undefined,
memorySearch: entry.memorySearch,
humanDelay: entry.humanDelay,
identity: entry.identity,
@@ -113,28 +109,6 @@ 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) {
const id = normalizeAgentId(agentId);
const configured = resolveAgentConfig(cfg, id)?.workspace?.trim();
-106
View File
@@ -1,106 +0,0 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
type AuthProfileStore,
ensureAuthProfileStore,
resolveApiKeyForProfile,
} from "./auth-profiles.js";
import {
CHUTES_TOKEN_ENDPOINT,
type ChutesStoredOAuth,
} from "./chutes-oauth.js";
describe("auth-profiles (chutes)", () => {
const previousStateDir = process.env.CLAWDBOT_STATE_DIR;
const previousAgentDir = process.env.CLAWDBOT_AGENT_DIR;
const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR;
const previousChutesClientId = process.env.CHUTES_CLIENT_ID;
let tempDir: string | null = null;
afterEach(async () => {
vi.unstubAllGlobals();
if (tempDir) {
await fs.rm(tempDir, { recursive: true, force: true });
tempDir = null;
}
if (previousStateDir === undefined) delete process.env.CLAWDBOT_STATE_DIR;
else process.env.CLAWDBOT_STATE_DIR = previousStateDir;
if (previousAgentDir === undefined) delete process.env.CLAWDBOT_AGENT_DIR;
else process.env.CLAWDBOT_AGENT_DIR = previousAgentDir;
if (previousPiAgentDir === undefined)
delete process.env.PI_CODING_AGENT_DIR;
else process.env.PI_CODING_AGENT_DIR = previousPiAgentDir;
if (previousChutesClientId === undefined)
delete process.env.CHUTES_CLIENT_ID;
else process.env.CHUTES_CLIENT_ID = previousChutesClientId;
});
it("refreshes expired Chutes OAuth credentials", async () => {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-chutes-"));
process.env.CLAWDBOT_STATE_DIR = tempDir;
process.env.CLAWDBOT_AGENT_DIR = path.join(
tempDir,
"agents",
"main",
"agent",
);
process.env.PI_CODING_AGENT_DIR = process.env.CLAWDBOT_AGENT_DIR;
const authProfilePath = path.join(
tempDir,
"agents",
"main",
"agent",
"auth-profiles.json",
);
await fs.mkdir(path.dirname(authProfilePath), { recursive: true });
const store: AuthProfileStore = {
version: 1,
profiles: {
"chutes:default": {
type: "oauth",
provider: "chutes",
access: "at_old",
refresh: "rt_old",
expires: Date.now() - 60_000,
clientId: "cid_test",
} as unknown as ChutesStoredOAuth,
},
};
await fs.writeFile(authProfilePath, `${JSON.stringify(store)}\n`);
const fetchSpy = vi.fn(async (input: string | URL) => {
const url = typeof input === "string" ? input : input.toString();
if (url !== CHUTES_TOKEN_ENDPOINT)
return new Response("not found", { status: 404 });
return new Response(
JSON.stringify({
access_token: "at_new",
expires_in: 3600,
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
});
vi.stubGlobal("fetch", fetchSpy);
const loaded = ensureAuthProfileStore();
const resolved = await resolveApiKeyForProfile({
store: loaded,
profileId: "chutes:default",
});
expect(resolved?.apiKey).toBe("at_new");
expect(fetchSpy).toHaveBeenCalled();
const persisted = JSON.parse(
await fs.readFile(authProfilePath, "utf8"),
) as {
profiles?: Record<string, { access?: string }>;
};
expect(persisted.profiles?.["chutes:default"]?.access).toBe("at_new");
});
});
+16 -24
View File
@@ -15,7 +15,6 @@ import { loadJsonFile, saveJsonFile } from "../infra/json-file.js";
import { createSubsystemLogger } from "../logging.js";
import { resolveUserPath } from "../utils.js";
import { resolveClawdbotAgentDir } from "./agent-paths.js";
import { refreshChutesTokens } from "./chutes-oauth.js";
import {
readClaudeCliCredentialsCached,
readCodexCliCredentialsCached,
@@ -68,8 +67,7 @@ export type TokenCredential = {
export type OAuthCredential = OAuthCredentials & {
type: "oauth";
provider: string;
clientId?: string;
provider: OAuthProvider;
email?: string;
};
@@ -173,7 +171,7 @@ async function updateAuthProfileStoreWithLock(params: {
}
function buildOAuthApiKey(
provider: string,
provider: OAuthProvider,
credentials: OAuthCredentials,
): string {
const needsProjectId =
@@ -188,6 +186,7 @@ function buildOAuthApiKey(
async function refreshOAuthTokenWithLock(params: {
profileId: string;
provider: OAuthProvider;
agentDir?: string;
}): Promise<{ apiKey: string; newCredentials: OAuthCredentials } | null> {
const authPath = resolveAuthStorePath(params.agentDir);
@@ -213,16 +212,7 @@ async function refreshOAuthTokenWithLock(params: {
const oauthCreds: Record<string, OAuthCredentials> = {
[cred.provider]: cred,
};
const result =
String(cred.provider) === "chutes"
? await (async () => {
const newCredentials = await refreshChutesTokens({
credential: cred,
});
return { apiKey: newCredentials.access, newCredentials };
})()
: await getOAuthApiKey(cred.provider as OAuthProvider, oauthCreds);
const result = await getOAuthApiKey(cred.provider, oauthCreds);
if (!result) return null;
store.profiles[params.profileId] = {
...cred,
@@ -269,7 +259,7 @@ function coerceLegacyStore(raw: unknown): LegacyAuthStore | null {
}
entries[key] = {
...typed,
provider: String(typed.provider ?? key),
provider: typed.provider ?? (key as OAuthProvider),
} as AuthProfileCredential;
}
return Object.keys(entries).length > 0 ? entries : null;
@@ -336,7 +326,7 @@ function mergeOAuthFileIntoStore(store: AuthProfileStore): boolean {
if (store.profiles[profileId]) continue;
store.profiles[profileId] = {
type: "oauth",
provider,
provider: provider as OAuthProvider,
...creds,
};
mutated = true;
@@ -478,7 +468,7 @@ function syncExternalCliCredentials(
const existingCodex = store.profiles[CODEX_CLI_PROFILE_ID];
const shouldSyncCodex =
!existingCodex ||
existingCodex.provider !== "openai-codex" ||
existingCodex.provider !== ("openai-codex" as OAuthProvider) ||
!isExternalProfileFresh(existingCodex, now);
const codexCreds = shouldSyncCodex
? readCodexCliCredentialsCached({ ttlMs: EXTERNAL_CLI_SYNC_TTL_MS })
@@ -490,7 +480,7 @@ function syncExternalCliCredentials(
// Codex creds don't carry expiry; use file mtime heuristic for freshness.
const shouldUpdate =
!existingOAuth ||
existingOAuth.provider !== "openai-codex" ||
existingOAuth.provider !== ("openai-codex" as unknown as OAuthProvider) ||
existingOAuth.expires <= now ||
codexCreds.expires > existingOAuth.expires;
@@ -535,14 +525,14 @@ export function loadAuthProfileStore(): AuthProfileStore {
if (cred.type === "api_key") {
store.profiles[profileId] = {
type: "api_key",
provider: String(cred.provider ?? provider),
provider: cred.provider ?? (provider as OAuthProvider),
key: cred.key,
...(cred.email ? { email: cred.email } : {}),
};
} else if (cred.type === "token") {
store.profiles[profileId] = {
type: "token",
provider: String(cred.provider ?? provider),
provider: cred.provider ?? (provider as OAuthProvider),
token: cred.token,
...(typeof cred.expires === "number"
? { expires: cred.expires }
@@ -552,7 +542,7 @@ export function loadAuthProfileStore(): AuthProfileStore {
} else {
store.profiles[profileId] = {
type: "oauth",
provider: String(cred.provider ?? provider),
provider: cred.provider ?? (provider as OAuthProvider),
access: cred.access,
refresh: cred.refresh,
expires: cred.expires,
@@ -600,14 +590,14 @@ export function ensureAuthProfileStore(
if (cred.type === "api_key") {
store.profiles[profileId] = {
type: "api_key",
provider: String(cred.provider ?? provider),
provider: cred.provider ?? (provider as OAuthProvider),
key: cred.key,
...(cred.email ? { email: cred.email } : {}),
};
} else if (cred.type === "token") {
store.profiles[profileId] = {
type: "token",
provider: String(cred.provider ?? provider),
provider: cred.provider ?? (provider as OAuthProvider),
token: cred.token,
...(typeof cred.expires === "number"
? { expires: cred.expires }
@@ -617,7 +607,7 @@ export function ensureAuthProfileStore(
} else {
store.profiles[profileId] = {
type: "oauth",
provider: String(cred.provider ?? provider),
provider: cred.provider ?? (provider as OAuthProvider),
access: cred.access,
refresh: cred.refresh,
expires: cred.expires,
@@ -1231,6 +1221,7 @@ export async function resolveApiKeyForProfile(params: {
try {
const result = await refreshOAuthTokenWithLock({
profileId,
provider: cred.provider,
agentDir: params.agentDir,
});
if (!result) return null;
@@ -1350,6 +1341,7 @@ async function tryResolveOAuthProfile(params: {
const refreshed = await refreshOAuthTokenWithLock({
profileId,
provider: cred.provider,
agentDir: params.agentDir,
});
if (!refreshed) return null;
+14
View File
@@ -40,6 +40,20 @@ const DEFAULT_PATH =
process.env.PATH ??
"/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 = {
backgroundMs?: number;
timeoutSec?: number;
-110
View File
@@ -1,110 +0,0 @@
import { describe, expect, it } from "vitest";
import {
CHUTES_TOKEN_ENDPOINT,
CHUTES_USERINFO_ENDPOINT,
exchangeChutesCodeForTokens,
refreshChutesTokens,
} from "./chutes-oauth.js";
describe("chutes-oauth", () => {
it("exchanges code for tokens and stores username as email", async () => {
const fetchFn: typeof fetch = async (input, init) => {
const url = String(input);
if (url === CHUTES_TOKEN_ENDPOINT) {
expect(init?.method).toBe("POST");
expect(
String(
init?.headers &&
(init.headers as Record<string, string>)["Content-Type"],
),
).toContain("application/x-www-form-urlencoded");
return new Response(
JSON.stringify({
access_token: "at_123",
refresh_token: "rt_123",
expires_in: 3600,
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
}
if (url === CHUTES_USERINFO_ENDPOINT) {
expect(
String(
init?.headers &&
(init.headers as Record<string, string>).Authorization,
),
).toBe("Bearer at_123");
return new Response(
JSON.stringify({ username: "fred", sub: "sub_1" }),
{
status: 200,
headers: { "Content-Type": "application/json" },
},
);
}
return new Response("not found", { status: 404 });
};
const now = 1_000_000;
const creds = await exchangeChutesCodeForTokens({
app: {
clientId: "cid_test",
redirectUri: "http://127.0.0.1:1456/oauth-callback",
scopes: ["openid"],
},
code: "code_123",
codeVerifier: "verifier_123",
fetchFn,
now,
});
expect(creds.access).toBe("at_123");
expect(creds.refresh).toBe("rt_123");
expect(creds.email).toBe("fred");
expect((creds as unknown as { accountId?: string }).accountId).toBe(
"sub_1",
);
expect((creds as unknown as { clientId?: string }).clientId).toBe(
"cid_test",
);
expect(creds.expires).toBe(now + 3600 * 1000 - 5 * 60 * 1000);
});
it("refreshes tokens using stored client id and falls back to old refresh token", async () => {
const fetchFn: typeof fetch = async (input, init) => {
const url = String(input);
if (url !== CHUTES_TOKEN_ENDPOINT)
return new Response("not found", { status: 404 });
expect(init?.method).toBe("POST");
const body = init?.body as URLSearchParams;
expect(String(body.get("grant_type"))).toBe("refresh_token");
expect(String(body.get("client_id"))).toBe("cid_test");
expect(String(body.get("refresh_token"))).toBe("rt_old");
return new Response(
JSON.stringify({
access_token: "at_new",
expires_in: 1800,
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
};
const now = 2_000_000;
const refreshed = await refreshChutesTokens({
credential: {
access: "at_old",
refresh: "rt_old",
expires: now - 10_000,
email: "fred",
clientId: "cid_test",
} as unknown as Parameters<typeof refreshChutesTokens>[0]["credential"],
fetchFn,
now,
});
expect(refreshed.access).toBe("at_new");
expect(refreshed.refresh).toBe("rt_old");
expect(refreshed.expires).toBe(now + 1800 * 1000 - 5 * 60 * 1000);
});
});
-200
View File
@@ -1,200 +0,0 @@
import { createHash, randomBytes } from "node:crypto";
import type { OAuthCredentials } from "@mariozechner/pi-ai";
export const CHUTES_OAUTH_ISSUER = "https://api.chutes.ai";
export const CHUTES_AUTHORIZE_ENDPOINT = `${CHUTES_OAUTH_ISSUER}/idp/authorize`;
export const CHUTES_TOKEN_ENDPOINT = `${CHUTES_OAUTH_ISSUER}/idp/token`;
export const CHUTES_USERINFO_ENDPOINT = `${CHUTES_OAUTH_ISSUER}/idp/userinfo`;
const DEFAULT_EXPIRES_BUFFER_MS = 5 * 60 * 1000;
export type ChutesPkce = { verifier: string; challenge: string };
export type ChutesUserInfo = {
sub?: string;
username?: string;
created_at?: string;
};
export type ChutesOAuthAppConfig = {
clientId: string;
clientSecret?: string;
redirectUri: string;
scopes: string[];
};
export type ChutesStoredOAuth = OAuthCredentials & {
clientId?: string;
};
export function generateChutesPkce(): ChutesPkce {
const verifier = randomBytes(32).toString("hex");
const challenge = createHash("sha256").update(verifier).digest("base64url");
return { verifier, challenge };
}
export function parseOAuthCallbackInput(
input: string,
expectedState: string,
): { code: string; state: string } | { error: string } {
const trimmed = input.trim();
if (!trimmed) return { error: "No input provided" };
try {
const url = new URL(trimmed);
const code = url.searchParams.get("code");
const state = url.searchParams.get("state");
if (!code) return { error: "Missing 'code' parameter in URL" };
if (!state) {
return { error: "Missing 'state' parameter. Paste the full URL." };
}
return { code, state };
} catch {
if (!expectedState) {
return { error: "Paste the full redirect URL, not just the code." };
}
return { code: trimmed, state: expectedState };
}
}
function coerceExpiresAt(expiresInSeconds: number, now: number): number {
const value =
now +
Math.max(0, Math.floor(expiresInSeconds)) * 1000 -
DEFAULT_EXPIRES_BUFFER_MS;
return Math.max(value, now + 30_000);
}
export async function fetchChutesUserInfo(params: {
accessToken: string;
fetchFn?: typeof fetch;
}): Promise<ChutesUserInfo | null> {
const fetchFn = params.fetchFn ?? fetch;
const response = await fetchFn(CHUTES_USERINFO_ENDPOINT, {
headers: { Authorization: `Bearer ${params.accessToken}` },
});
if (!response.ok) return null;
const data = (await response.json()) as unknown;
if (!data || typeof data !== "object") return null;
const typed = data as ChutesUserInfo;
return typed;
}
export async function exchangeChutesCodeForTokens(params: {
app: ChutesOAuthAppConfig;
code: string;
codeVerifier: string;
fetchFn?: typeof fetch;
now?: number;
}): Promise<ChutesStoredOAuth> {
const fetchFn = params.fetchFn ?? fetch;
const now = params.now ?? Date.now();
const body = new URLSearchParams({
grant_type: "authorization_code",
client_id: params.app.clientId,
code: params.code,
redirect_uri: params.app.redirectUri,
code_verifier: params.codeVerifier,
});
if (params.app.clientSecret) {
body.set("client_secret", params.app.clientSecret);
}
const response = await fetchFn(CHUTES_TOKEN_ENDPOINT, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body,
});
if (!response.ok) {
const text = await response.text();
throw new Error(`Chutes token exchange failed: ${text}`);
}
const data = (await response.json()) as {
access_token?: string;
refresh_token?: string;
expires_in?: number;
};
const access = data.access_token?.trim();
const refresh = data.refresh_token?.trim();
const expiresIn = data.expires_in ?? 0;
if (!access)
throw new Error("Chutes token exchange returned no access_token");
if (!refresh) {
throw new Error("Chutes token exchange returned no refresh_token");
}
const info = await fetchChutesUserInfo({ accessToken: access, fetchFn });
return {
access,
refresh,
expires: coerceExpiresAt(expiresIn, now),
email: info?.username,
accountId: info?.sub,
clientId: params.app.clientId,
} as unknown as ChutesStoredOAuth;
}
export async function refreshChutesTokens(params: {
credential: ChutesStoredOAuth;
fetchFn?: typeof fetch;
now?: number;
}): Promise<ChutesStoredOAuth> {
const fetchFn = params.fetchFn ?? fetch;
const now = params.now ?? Date.now();
const refreshToken = params.credential.refresh?.trim();
if (!refreshToken) {
throw new Error("Chutes OAuth credential is missing refresh token");
}
const clientId =
params.credential.clientId?.trim() ?? process.env.CHUTES_CLIENT_ID?.trim();
if (!clientId) {
throw new Error(
"Missing CHUTES_CLIENT_ID for Chutes OAuth refresh (set env var or re-auth).",
);
}
const clientSecret = process.env.CHUTES_CLIENT_SECRET?.trim() || undefined;
const body = new URLSearchParams({
grant_type: "refresh_token",
client_id: clientId,
refresh_token: refreshToken,
});
if (clientSecret) body.set("client_secret", clientSecret);
const response = await fetchFn(CHUTES_TOKEN_ENDPOINT, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body,
});
if (!response.ok) {
const text = await response.text();
throw new Error(`Chutes token refresh failed: ${text}`);
}
const data = (await response.json()) as {
access_token?: string;
refresh_token?: string;
expires_in?: number;
};
const access = data.access_token?.trim();
const newRefresh = data.refresh_token?.trim();
const expiresIn = data.expires_in ?? 0;
if (!access) throw new Error("Chutes token refresh returned no access_token");
return {
...params.credential,
access,
refresh: newRefresh || refreshToken,
expires: coerceExpiresAt(expiresIn, now),
clientId,
} as unknown as ChutesStoredOAuth;
}
-56
View File
@@ -1,56 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { runCliAgent } from "./cli-runner.js";
const runCommandWithTimeoutMock = vi.fn();
const runExecMock = vi.fn();
vi.mock("../process/exec.js", () => ({
runCommandWithTimeout: (...args: unknown[]) =>
runCommandWithTimeoutMock(...args),
runExec: (...args: unknown[]) => runExecMock(...args),
}));
describe("runCliAgent resume cleanup", () => {
beforeEach(() => {
runCommandWithTimeoutMock.mockReset();
runExecMock.mockReset();
});
it("kills stale resume processes for codex sessions", async () => {
runExecMock.mockResolvedValue({ stdout: "", stderr: "" });
runCommandWithTimeoutMock.mockResolvedValueOnce({
stdout: "ok",
stderr: "",
code: 0,
signal: null,
killed: false,
});
await runCliAgent({
sessionId: "s1",
sessionFile: "/tmp/session.jsonl",
workspaceDir: "/tmp",
prompt: "hi",
provider: "codex-cli",
model: "gpt-5.2-codex",
timeoutMs: 1_000,
runId: "run-1",
cliSessionId: "thread-123",
});
if (process.platform === "win32") {
expect(runExecMock).not.toHaveBeenCalled();
return;
}
expect(runExecMock).toHaveBeenCalledTimes(1);
const args = runExecMock.mock.calls[0] ?? [];
expect(args[0]).toBe("pkill");
const pkillArgs = args[1] as string[];
expect(pkillArgs[0]).toBe("-f");
expect(pkillArgs[1]).toContain("codex");
expect(pkillArgs[1]).toContain("resume");
expect(pkillArgs[1]).toContain("thread-123");
});
});
+2 -42
View File
@@ -11,7 +11,7 @@ import type { ClawdbotConfig } from "../config/config.js";
import type { CliBackendConfig } from "../config/types.js";
import { shouldLogVerbose } from "../globals.js";
import { createSubsystemLogger } from "../logging.js";
import { runCommandWithTimeout, runExec } from "../process/exec.js";
import { runCommandWithTimeout } from "../process/exec.js";
import { resolveUserPath } from "../utils.js";
import { resolveSessionAgentIds } from "./agent-scope.js";
import { resolveCliBackendConfig } from "./cli-backends.js";
@@ -21,7 +21,6 @@ import {
classifyFailoverReason,
type EmbeddedContextFile,
isFailoverErrorMessage,
resolveBootstrapMaxChars,
} from "./pi-embedded-helpers.js";
import type { EmbeddedPiRunResult } from "./pi-embedded-runner.js";
import { buildAgentSystemPrompt } from "./system-prompt.js";
@@ -33,37 +32,6 @@ import {
const log = createSubsystemLogger("agent/claude-cli");
const CLI_RUN_QUEUE = new Map<string, Promise<unknown>>();
function escapeRegex(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
async function cleanupResumeProcesses(
backend: CliBackendConfig,
sessionId: string,
): Promise<void> {
if (process.platform === "win32") return;
const resumeArgs = backend.resumeArgs ?? [];
if (resumeArgs.length === 0) return;
if (!resumeArgs.some((arg) => arg.includes("{sessionId}"))) return;
const commandToken = path.basename(backend.command ?? "").trim();
if (!commandToken) return;
const resumeTokens = resumeArgs.map((arg) =>
arg.replaceAll("{sessionId}", sessionId),
);
const pattern = [commandToken, ...resumeTokens]
.filter(Boolean)
.map((token) => escapeRegex(token))
.join(".*");
if (!pattern) return;
try {
await runExec("pkill", ["-f", pattern]);
} catch {
// ignore missing pkill or no matches
}
}
function enqueueCliRun<T>(key: string, task: () => Promise<T>): Promise<T> {
const prior = CLI_RUN_QUEUE.get(key) ?? Promise.resolve();
const chained = prior.catch(() => undefined).then(task);
@@ -494,11 +462,7 @@ export async function runCliAgent(params: {
await loadWorkspaceBootstrapFiles(workspaceDir),
params.sessionKey ?? params.sessionId,
);
const sessionLabel = params.sessionKey ?? params.sessionId;
const contextFiles = buildBootstrapContextFiles(bootstrapFiles, {
maxChars: resolveBootstrapMaxChars(params.config),
warn: (message) => log.warn(`${message} (sessionKey=${sessionLabel})`),
});
const contextFiles = buildBootstrapContextFiles(bootstrapFiles);
const { defaultAgentId, sessionAgentId } = resolveSessionAgentIds({
sessionKey: params.sessionKey,
config: params.config,
@@ -638,10 +602,6 @@ export async function runCliAgent(params: {
return next;
})();
if (useResume && cliSessionIdToSend) {
await cleanupResumeProcesses(backend, cliSessionIdToSend);
}
const result = await runCommandWithTimeout([backend.command, ...args], {
timeoutMs: params.timeoutMs,
cwd: workspaceDir,
-11
View File
@@ -48,14 +48,3 @@ export function isAnthropicRateLimitError(message: string): boolean {
if (lower.includes("429")) return true;
return false;
}
export function isAnthropicBillingError(message: string): boolean {
const lower = message.toLowerCase();
if (lower.includes("credit balance")) return true;
if (lower.includes("insufficient credit")) return true;
if (lower.includes("insufficient credits")) return true;
if (lower.includes("payment required")) return true;
if (lower.includes("billing") && lower.includes("disabled")) return true;
if (lower.includes("402")) return true;
return false;
}
-5
View File
@@ -20,7 +20,6 @@ const GOOGLE_PREFIXES = ["gemini-3"];
const ZAI_PREFIXES = ["glm-4.7"];
const MINIMAX_PREFIXES = ["minimax-m2.1"];
const XAI_PREFIXES = ["grok-4"];
const SYNTHETIC_PREFIXES = ["hf:minimaxai/minimax-m2.1"];
function matchesPrefix(id: string, prefixes: string[]): boolean {
return prefixes.some((prefix) => id.startsWith(prefix));
@@ -74,10 +73,6 @@ export function isModernModelRef(ref: ModelRef): boolean {
return matchesPrefix(id, XAI_PREFIXES);
}
if (provider === "synthetic") {
return matchesPrefix(id, SYNTHETIC_PREFIXES);
}
if (provider === "openrouter" || provider === "opencode") {
return matchesAny(id, [
...ANTHROPIC_PREFIXES,
-33
View File
@@ -53,37 +53,4 @@ describe("memory search config", () => {
expect(resolved?.query.maxResults).toBe(8);
expect(resolved?.query.minScore).toBe(0.2);
});
it("merges remote defaults with agent overrides", () => {
const cfg = {
agents: {
defaults: {
memorySearch: {
remote: {
baseUrl: "https://default.example/v1",
apiKey: "default-key",
headers: { "X-Default": "on" },
},
},
},
list: [
{
id: "main",
default: true,
memorySearch: {
remote: {
baseUrl: "https://agent.example/v1",
},
},
},
],
},
};
const resolved = resolveMemorySearchConfig(cfg, "main");
expect(resolved?.remote).toEqual({
baseUrl: "https://agent.example/v1",
apiKey: "default-key",
headers: { "X-Default": "on" },
});
});
});
-14
View File
@@ -9,11 +9,6 @@ import { resolveAgentConfig } from "./agent-scope.js";
export type ResolvedMemorySearchConfig = {
enabled: boolean;
provider: "openai" | "local";
remote?: {
baseUrl?: string;
apiKey?: string;
headers?: Record<string, string>;
};
fallback: "openai" | "none";
model: string;
local: {
@@ -65,14 +60,6 @@ function mergeConfig(
): ResolvedMemorySearchConfig {
const enabled = overrides?.enabled ?? defaults?.enabled ?? true;
const provider = overrides?.provider ?? defaults?.provider ?? "openai";
const hasRemote = Boolean(defaults?.remote || overrides?.remote);
const remote = hasRemote
? {
baseUrl: overrides?.remote?.baseUrl ?? defaults?.remote?.baseUrl,
apiKey: overrides?.remote?.apiKey ?? defaults?.remote?.apiKey,
headers: overrides?.remote?.headers ?? defaults?.remote?.headers,
}
: undefined;
const fallback = overrides?.fallback ?? defaults?.fallback ?? "openai";
const model = overrides?.model ?? defaults?.model ?? DEFAULT_MODEL;
const local = {
@@ -125,7 +112,6 @@ function mergeConfig(
return {
enabled,
provider,
remote,
fallback,
model,
local,
+1 -1
View File
@@ -17,7 +17,7 @@ describeLive("minimax live", () => {
api: "anthropic-messages",
provider: "minimax",
baseUrl: MINIMAX_BASE_URL,
reasoning: false,
reasoning: MINIMAX_MODEL === "MiniMax-M2",
input: ["text"],
// Pricing: placeholder values (per 1M tokens, multiplied by 1000 for display)
cost: { input: 15, output: 60, cacheRead: 2, cacheWrite: 10 },
-24
View File
@@ -236,28 +236,4 @@ describe("getApiKeyForModel", () => {
}
}
});
it("resolves Synthetic API key from env", async () => {
const previousSynthetic = process.env.SYNTHETIC_API_KEY;
try {
process.env.SYNTHETIC_API_KEY = "synthetic-test-key";
vi.resetModules();
const { resolveApiKeyForProvider } = await import("./model-auth.js");
const resolved = await resolveApiKeyForProvider({
provider: "synthetic",
store: { version: 1, profiles: {} },
});
expect(resolved.apiKey).toBe("synthetic-test-key");
expect(resolved.source).toContain("SYNTHETIC_API_KEY");
} finally {
if (previousSynthetic === undefined) {
delete process.env.SYNTHETIC_API_KEY;
} else {
process.env.SYNTHETIC_API_KEY = previousSynthetic;
}
}
});
});
-5
View File
@@ -125,10 +125,6 @@ export function resolveEnvApiKey(provider: string): EnvApiKeyResult | null {
return pick("ANTHROPIC_OAUTH_TOKEN") ?? pick("ANTHROPIC_API_KEY");
}
if (normalized === "chutes") {
return pick("CHUTES_OAUTH_TOKEN") ?? pick("CHUTES_API_KEY");
}
if (normalized === "zai") {
return pick("ZAI_API_KEY") ?? pick("Z_AI_API_KEY");
}
@@ -152,7 +148,6 @@ export function resolveEnvApiKey(provider: string): EnvApiKeyResult | null {
openrouter: "OPENROUTER_API_KEY",
moonshot: "MOONSHOT_API_KEY",
minimax: "MINIMAX_API_KEY",
synthetic: "SYNTHETIC_API_KEY",
mistral: "MISTRAL_API_KEY",
opencode: "OPENCODE_API_KEY",
};
-162
View File
@@ -102,168 +102,6 @@ describe("runWithModelFallback", () => {
expect(run.mock.calls[1]?.[1]).toBe("claude-haiku-3-5");
});
it("falls back on credential validation errors", async () => {
const cfg = makeCfg();
const run = vi
.fn()
.mockRejectedValueOnce(
new Error('No credentials found for profile "anthropic:claude-cli".'),
)
.mockResolvedValueOnce("ok");
const result = await runWithModelFallback({
cfg,
provider: "anthropic",
model: "claude-opus-4",
run,
});
expect(result.result).toBe("ok");
expect(run).toHaveBeenCalledTimes(2);
expect(run.mock.calls[1]?.[0]).toBe("anthropic");
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 () => {
const cfg = makeCfg();
const run = vi
.fn()
.mockRejectedValueOnce(new Error("No API key found for profile openai."))
.mockResolvedValueOnce("ok");
const result = await runWithModelFallback({
cfg,
provider: "openai",
model: "gpt-4.1-mini",
run,
});
expect(result.result).toBe("ok");
expect(run).toHaveBeenCalledTimes(2);
expect(run.mock.calls[1]?.[0]).toBe("anthropic");
expect(run.mock.calls[1]?.[1]).toBe("claude-haiku-3-5");
});
it("falls back on lowercase credential errors", async () => {
const cfg = makeCfg();
const run = vi
.fn()
.mockRejectedValueOnce(new Error("no api key found for profile openai"))
.mockResolvedValueOnce("ok");
const result = await runWithModelFallback({
cfg,
provider: "openai",
model: "gpt-4.1-mini",
run,
});
expect(result.result).toBe("ok");
expect(run).toHaveBeenCalledTimes(2);
expect(run.mock.calls[1]?.[0]).toBe("anthropic");
expect(run.mock.calls[1]?.[1]).toBe("claude-haiku-3-5");
});
it("appends the configured primary as a last fallback", async () => {
const cfg = makeCfg({
agents: {
+2 -16
View File
@@ -126,8 +126,6 @@ function resolveFallbackCandidates(params: {
cfg: ClawdbotConfig | undefined;
provider: string;
model: string;
/** Optional explicit fallbacks list; when provided (even empty), replaces agents.defaults.model.fallbacks. */
fallbacksOverride?: string[];
}): ModelCandidate[] {
const provider = params.provider.trim() || DEFAULT_PROVIDER;
const model = params.model.trim() || DEFAULT_MODEL;
@@ -161,7 +159,6 @@ function resolveFallbackCandidates(params: {
addCandidate({ provider, model }, false);
const modelFallbacks = (() => {
if (params.fallbacksOverride !== undefined) return params.fallbacksOverride;
const model = params.cfg?.agents?.defaults?.model as
| { fallbacks?: string[] }
| string
@@ -180,11 +177,7 @@ function resolveFallbackCandidates(params: {
addCandidate(resolved.ref, true);
}
if (
params.fallbacksOverride === undefined &&
primary?.provider &&
primary.model
) {
if (primary?.provider && primary.model) {
addCandidate({ provider: primary.provider, model: primary.model }, false);
}
@@ -195,8 +188,6 @@ export async function runWithModelFallback<T>(params: {
cfg: ClawdbotConfig | undefined;
provider: 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>;
onError?: (attempt: {
provider: string;
@@ -211,12 +202,7 @@ export async function runWithModelFallback<T>(params: {
model: string;
attempts: FallbackAttempt[];
}> {
const candidates = resolveFallbackCandidates({
cfg: params.cfg,
provider: params.provider,
model: params.model,
fallbacksOverride: params.fallbacksOverride,
});
const candidates = resolveFallbackCandidates(params);
const attempts: FallbackAttempt[] = [];
let lastError: unknown;
-18
View File
@@ -107,24 +107,6 @@ describe("parseModelRef", () => {
model: "claude-opus-4-5",
});
});
it("normalizes google gemini 3 models to preview ids", () => {
expect(parseModelRef("google/gemini-3-pro", "anthropic")).toEqual({
provider: "google",
model: "gemini-3-pro-preview",
});
expect(parseModelRef("google/gemini-3-flash", "anthropic")).toEqual({
provider: "google",
model: "gemini-3-flash-preview",
});
});
it("normalizes default-provider google models", () => {
expect(parseModelRef("gemini-3-pro", "google")).toEqual({
provider: "google",
model: "gemini-3-pro-preview",
});
});
});
describe("resolveHooksGmailModel", () => {
+4 -9
View File
@@ -1,6 +1,5 @@
import type { ClawdbotConfig } from "../config/config.js";
import type { ModelCatalogEntry } from "./model-catalog.js";
import { normalizeGoogleModelId } from "./models-config.providers.js";
export type ModelRef = {
provider: string;
@@ -48,12 +47,6 @@ function normalizeAnthropicModelId(model: string): string {
return trimmed;
}
function normalizeProviderModelId(provider: string, model: string): string {
if (provider === "anthropic") return normalizeAnthropicModelId(model);
if (provider === "google") return normalizeGoogleModelId(model);
return model;
}
export function parseModelRef(
raw: string,
defaultProvider: string,
@@ -63,14 +56,16 @@ export function parseModelRef(
const slash = trimmed.indexOf("/");
if (slash === -1) {
const provider = normalizeProviderId(defaultProvider);
const model = normalizeProviderModelId(provider, trimmed);
const model =
provider === "anthropic" ? normalizeAnthropicModelId(trimmed) : trimmed;
return { provider, model };
}
const providerRaw = trimmed.slice(0, slash).trim();
const provider = normalizeProviderId(providerRaw);
const model = trimmed.slice(slash + 1).trim();
if (!provider || !model) return null;
const normalizedModel = normalizeProviderModelId(provider, model);
const normalizedModel =
provider === "anthropic" ? normalizeAnthropicModelId(model) : model;
return { provider, model: normalizedModel };
}
-82
View File
@@ -1,18 +1,9 @@
import type { ClawdbotConfig } from "../config/config.js";
import {
DEFAULT_COPILOT_API_BASE_URL,
resolveCopilotApiToken,
} from "../providers/github-copilot-token.js";
import {
ensureAuthProfileStore,
listProfilesForProvider,
} from "./auth-profiles.js";
import { resolveEnvApiKey } from "./model-auth.js";
import {
buildSyntheticModelDefinition,
SYNTHETIC_BASE_URL,
SYNTHETIC_MODEL_CATALOG,
} from "./synthetic-models.js";
type ModelsConfig = NonNullable<ClawdbotConfig["models"]>;
export type ProviderConfig = NonNullable<ModelsConfig["providers"]>[string];
@@ -189,14 +180,6 @@ function buildMoonshotProvider(): ProviderConfig {
};
}
function buildSyntheticProvider(): ProviderConfig {
return {
baseUrl: SYNTHETIC_BASE_URL,
api: "anthropic-messages",
models: SYNTHETIC_MODEL_CATALOG.map(buildSyntheticModelDefinition),
};
}
export function resolveImplicitProviders(params: {
agentDir: string;
}): ModelsConfig["providers"] {
@@ -219,70 +202,5 @@ export function resolveImplicitProviders(params: {
providers.moonshot = { ...buildMoonshotProvider(), apiKey: moonshotKey };
}
const syntheticKey =
resolveEnvApiKeyVarName("synthetic") ??
resolveApiKeyFromProfiles({ provider: "synthetic", store: authStore });
if (syntheticKey) {
providers.synthetic = { ...buildSyntheticProvider(), apiKey: syntheticKey };
}
return providers;
}
export async function resolveImplicitCopilotProvider(params: {
agentDir: string;
env?: NodeJS.ProcessEnv;
}): Promise<ProviderConfig | null> {
const env = params.env ?? process.env;
const authStore = ensureAuthProfileStore(params.agentDir);
const hasProfile =
listProfilesForProvider(authStore, "github-copilot").length > 0;
const envToken = env.COPILOT_GITHUB_TOKEN ?? env.GH_TOKEN ?? env.GITHUB_TOKEN;
const githubToken = (envToken ?? "").trim();
if (!hasProfile && !githubToken) return null;
let selectedGithubToken = githubToken;
if (!selectedGithubToken && hasProfile) {
// Use the first available profile as a default for discovery (it will be
// re-resolved per-run by the embedded runner).
const profileId = listProfilesForProvider(authStore, "github-copilot")[0];
const profile = profileId ? authStore.profiles[profileId] : undefined;
if (profile && profile.type === "token") {
selectedGithubToken = profile.token;
}
}
let baseUrl = DEFAULT_COPILOT_API_BASE_URL;
if (selectedGithubToken) {
try {
const token = await resolveCopilotApiToken({
githubToken: selectedGithubToken,
env,
});
baseUrl = token.baseUrl;
} catch {
baseUrl = DEFAULT_COPILOT_API_BASE_URL;
}
}
// pi-coding-agent's ModelRegistry marks a model "available" only if its
// `AuthStorage` has auth configured for that provider (via auth.json/env/etc).
// Our Copilot auth lives in Clawdbot's auth-profiles store instead, so we also
// write a runtime-only auth.json entry for pi-coding-agent to pick up.
//
// This is safe because it's (1) within Clawdbot's agent dir, (2) contains the
// GitHub token (not the exchanged Copilot token), and (3) matches existing
// patterns for OAuth-like providers in pi-coding-agent.
// Note: we deliberately do not write pi-coding-agent's `auth.json` here.
// Clawdbot uses its own auth store and exchanges tokens at runtime.
// `models list` uses Clawdbot's auth heuristics for availability.
// We intentionally do NOT define custom models for Copilot in models.json.
// pi-coding-agent treats providers with models as replacements requiring apiKey.
// We only override baseUrl; the model list comes from pi-ai built-ins.
return {
baseUrl,
models: [],
} satisfies ProviderConfig;
}
-43
View File
@@ -348,13 +348,11 @@ describe("models config", () => {
const previousGithub = process.env.GITHUB_TOKEN;
const previousMinimax = process.env.MINIMAX_API_KEY;
const previousMoonshot = process.env.MOONSHOT_API_KEY;
const previousSynthetic = process.env.SYNTHETIC_API_KEY;
delete process.env.COPILOT_GITHUB_TOKEN;
delete process.env.GH_TOKEN;
delete process.env.GITHUB_TOKEN;
delete process.env.MINIMAX_API_KEY;
delete process.env.MOONSHOT_API_KEY;
delete process.env.SYNTHETIC_API_KEY;
try {
vi.resetModules();
@@ -383,9 +381,6 @@ describe("models config", () => {
else process.env.MINIMAX_API_KEY = previousMinimax;
if (previousMoonshot === undefined) delete process.env.MOONSHOT_API_KEY;
else process.env.MOONSHOT_API_KEY = previousMoonshot;
if (previousSynthetic === undefined)
delete process.env.SYNTHETIC_API_KEY;
else process.env.SYNTHETIC_API_KEY = previousSynthetic;
}
});
});
@@ -456,44 +451,6 @@ describe("models config", () => {
});
});
it("adds synthetic provider when SYNTHETIC_API_KEY is set", async () => {
await withTempHome(async () => {
vi.resetModules();
const prevKey = process.env.SYNTHETIC_API_KEY;
process.env.SYNTHETIC_API_KEY = "sk-synthetic-test";
try {
const { ensureClawdbotModelsJson } = await import("./models-config.js");
const { resolveClawdbotAgentDir } = await import("./agent-paths.js");
await ensureClawdbotModelsJson({});
const modelPath = path.join(resolveClawdbotAgentDir(), "models.json");
const raw = await fs.readFile(modelPath, "utf8");
const parsed = JSON.parse(raw) as {
providers: Record<
string,
{
baseUrl?: string;
apiKey?: string;
models?: Array<{ id: string }>;
}
>;
};
expect(parsed.providers.synthetic?.baseUrl).toBe(
"https://api.synthetic.new/anthropic",
);
expect(parsed.providers.synthetic?.apiKey).toBe("SYNTHETIC_API_KEY");
const ids = parsed.providers.synthetic?.models?.map(
(model) => model.id,
);
expect(ids).toContain("hf:MiniMaxAI/MiniMax-M2.1");
} finally {
if (prevKey === undefined) delete process.env.SYNTHETIC_API_KEY;
else process.env.SYNTHETIC_API_KEY = prevKey;
}
});
});
it("fills missing provider.apiKey from env var name when models exist", async () => {
await withTempHome(async () => {
vi.resetModules();
+67 -2
View File
@@ -2,11 +2,18 @@ import fs from "node:fs/promises";
import path from "node:path";
import { type ClawdbotConfig, loadConfig } from "../config/config.js";
import {
DEFAULT_COPILOT_API_BASE_URL,
resolveCopilotApiToken,
} from "../providers/github-copilot-token.js";
import { resolveClawdbotAgentDir } from "./agent-paths.js";
import {
ensureAuthProfileStore,
listProfilesForProvider,
} from "./auth-profiles.js";
import {
normalizeProviders,
type ProviderConfig,
resolveImplicitCopilotProvider,
resolveImplicitProviders,
} from "./models-config.providers.js";
@@ -78,6 +85,64 @@ async function readJson(pathname: string): Promise<unknown> {
}
}
async function maybeBuildCopilotProvider(params: {
agentDir: string;
env?: NodeJS.ProcessEnv;
}): Promise<ProviderConfig | null> {
const env = params.env ?? process.env;
const authStore = ensureAuthProfileStore(params.agentDir);
const hasProfile =
listProfilesForProvider(authStore, "github-copilot").length > 0;
const envToken = env.COPILOT_GITHUB_TOKEN ?? env.GH_TOKEN ?? env.GITHUB_TOKEN;
const githubToken = (envToken ?? "").trim();
if (!hasProfile && !githubToken) return null;
let selectedGithubToken = githubToken;
if (!selectedGithubToken && hasProfile) {
// Use the first available profile as a default for discovery (it will be
// re-resolved per-run by the embedded runner).
const profileId = listProfilesForProvider(authStore, "github-copilot")[0];
const profile = profileId ? authStore.profiles[profileId] : undefined;
if (profile && profile.type === "token") {
selectedGithubToken = profile.token;
}
}
let baseUrl = DEFAULT_COPILOT_API_BASE_URL;
if (selectedGithubToken) {
try {
const token = await resolveCopilotApiToken({
githubToken: selectedGithubToken,
env,
});
baseUrl = token.baseUrl;
} catch {
baseUrl = DEFAULT_COPILOT_API_BASE_URL;
}
}
// pi-coding-agent's ModelRegistry marks a model "available" only if its
// `AuthStorage` has auth configured for that provider (via auth.json/env/etc).
// Our Copilot auth lives in Clawdbot's auth-profiles store instead, so we also
// write a runtime-only auth.json entry for pi-coding-agent to pick up.
//
// This is safe because it's (1) within Clawdbot's agent dir, (2) contains the
// GitHub token (not the exchanged Copilot token), and (3) matches existing
// patterns for OAuth-like providers in pi-coding-agent.
// Note: we deliberately do not write pi-coding-agent's `auth.json` here.
// Clawdbot uses its own auth store and exchanges tokens at runtime.
// `models list` uses Clawdbot's auth heuristics for availability.
// We intentionally do NOT define custom models for Copilot in models.json.
// pi-coding-agent treats providers with models as replacements requiring apiKey.
// We only override baseUrl; the model list comes from pi-ai built-ins.
return {
baseUrl,
models: [],
} satisfies ProviderConfig;
}
export async function ensureClawdbotModelsJson(
config?: ClawdbotConfig,
agentDirOverride?: string,
@@ -96,7 +161,7 @@ export async function ensureClawdbotModelsJson(
implicit: implicitProviders,
explicit: explicitProviders,
});
const implicitCopilot = await resolveImplicitCopilotProvider({ agentDir });
const implicitCopilot = await maybeBuildCopilotProvider({ agentDir });
if (implicitCopilot && !providers["github-copilot"]) {
providers["github-copilot"] = implicitCopilot;
}
+3 -50
View File
@@ -9,7 +9,6 @@ import { loadConfig } from "../config/config.js";
import { resolveClawdbotAgentDir } from "./agent-paths.js";
import {
collectAnthropicApiKeys,
isAnthropicBillingError,
isAnthropicRateLimitError,
} from "./live-auth-keys.js";
import { isModernModelRef } from "./live-model-filter.js";
@@ -73,18 +72,6 @@ function toInt(value: string | undefined, fallback: number): number {
return Number.isFinite(parsed) ? parsed : fallback;
}
function resolveTestReasoning(
model: Model<Api>,
): "minimal" | "low" | "medium" | "high" | "xhigh" | undefined {
if (!model.reasoning) return undefined;
const id = model.id.toLowerCase();
if (model.provider === "openai" || model.provider === "openai-codex") {
if (id.includes("pro")) return "high";
return "medium";
}
return "low";
}
async function completeSimpleWithTimeout<TApi extends Api>(
model: Model<TApi>,
context: Parameters<typeof completeSimple<TApi>>[1],
@@ -123,7 +110,7 @@ async function completeOkWithRetry(params: {
},
{
apiKey: params.apiKey,
reasoning: resolveTestReasoning(params.model),
reasoning: params.model.reasoning ? "low" : undefined,
maxTokens: 64,
},
params.timeoutMs,
@@ -268,7 +255,7 @@ describeLive("live models (profile keys)", () => {
},
{
apiKey,
reasoning: resolveTestReasoning(model),
reasoning: model.reasoning ? "low" : undefined,
maxTokens: 128,
},
perModelTimeoutMs,
@@ -308,7 +295,7 @@ describeLive("live models (profile keys)", () => {
},
{
apiKey,
reasoning: resolveTestReasoning(model),
reasoning: model.reasoning ? "low" : undefined,
maxTokens: 64,
},
perModelTimeoutMs,
@@ -348,17 +335,6 @@ describeLive("live models (profile keys)", () => {
logProgress(`${progressLabel}: skip (google model not found)`);
break;
}
if (
ok.text.length === 0 &&
(model.provider === "openrouter" || model.provider === "opencode")
) {
skipped.push({
model: id,
reason: "no text returned (provider returned empty content)",
});
logProgress(`${progressLabel}: skip (empty response)`);
break;
}
expect(ok.text.length).toBeGreaterThan(0);
logProgress(`${progressLabel}: done`);
break;
@@ -374,20 +350,6 @@ describeLive("live models (profile keys)", () => {
);
continue;
}
if (
model.provider === "anthropic" &&
isAnthropicBillingError(message)
) {
if (attempt + 1 < attemptMax) {
logProgress(
`${progressLabel}: billing issue, retrying with next key`,
);
continue;
}
skipped.push({ model: id, reason: message });
logProgress(`${progressLabel}: skip (anthropic billing)`);
break;
}
if (
model.provider === "google" &&
isGoogleModelNotFoundError(err)
@@ -396,15 +358,6 @@ describeLive("live models (profile keys)", () => {
logProgress(`${progressLabel}: skip (google model not found)`);
break;
}
if (
allowNotFoundSkip &&
model.provider === "minimax" &&
message.includes("request ended without sending any chunks")
) {
skipped.push({ model: id, reason: message });
logProgress(`${progressLabel}: skip (minimax empty response)`);
break;
}
logProgress(`${progressLabel}: failed`);
failures.push({ model: id, error: message });
break;
@@ -1,88 +0,0 @@
import { describe, expect, it } from "vitest";
import {
isMessagingToolDuplicate,
normalizeTextForComparison,
} from "./pi-embedded-helpers.js";
describe("normalizeTextForComparison", () => {
it("lowercases text", () => {
expect(normalizeTextForComparison("Hello World")).toBe("hello world");
});
it("trims whitespace", () => {
expect(normalizeTextForComparison(" hello ")).toBe("hello");
});
it("collapses multiple spaces", () => {
expect(normalizeTextForComparison("hello world")).toBe("hello world");
});
it("strips emoji", () => {
expect(normalizeTextForComparison("Hello 👋 World 🌍")).toBe("hello world");
});
it("handles mixed normalization", () => {
expect(normalizeTextForComparison(" Hello 👋 WORLD 🌍 ")).toBe(
"hello world",
);
});
});
describe("isMessagingToolDuplicate", () => {
it("returns false for empty sentTexts", () => {
expect(isMessagingToolDuplicate("hello world", [])).toBe(false);
});
it("returns false for short texts", () => {
expect(isMessagingToolDuplicate("short", ["short"])).toBe(false);
});
it("detects exact duplicates", () => {
expect(
isMessagingToolDuplicate("Hello, this is a test message!", [
"Hello, this is a test message!",
]),
).toBe(true);
});
it("detects duplicates with different casing", () => {
expect(
isMessagingToolDuplicate("HELLO, THIS IS A TEST MESSAGE!", [
"hello, this is a test message!",
]),
).toBe(true);
});
it("detects duplicates with emoji variations", () => {
expect(
isMessagingToolDuplicate("Hello! 👋 This is a test message!", [
"Hello! This is a test message!",
]),
).toBe(true);
});
it("detects substring duplicates (LLM elaboration)", () => {
expect(
isMessagingToolDuplicate(
'I sent the message: "Hello, this is a test message!"',
["Hello, this is a test message!"],
),
).toBe(true);
});
it("detects when sent text contains block reply (reverse substring)", () => {
expect(
isMessagingToolDuplicate("Hello, this is a test message!", [
'I sent the message: "Hello, this is a test message!"',
]),
).toBe(true);
});
it("returns false for non-matching texts", () => {
expect(
isMessagingToolDuplicate("This is completely different content.", [
"Hello, this is a test message!",
]),
).toBe(false);
});
});
+147 -168
View File
@@ -1,13 +1,10 @@
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import type { AssistantMessage } from "@mariozechner/pi-ai";
import { describe, expect, it } from "vitest";
import type { ClawdbotConfig } from "../config/config.js";
import {
buildBootstrapContextFiles,
classifyFailoverReason,
DEFAULT_BOOTSTRAP_MAX_CHARS,
formatAssistantErrorText,
isAuthErrorMessage,
isBillingErrorMessage,
isCloudCodeAssistFormatError,
isCompactionFailureError,
@@ -15,11 +12,10 @@ import {
isFailoverErrorMessage,
isMessagingToolDuplicate,
normalizeTextForComparison,
resolveBootstrapMaxChars,
sanitizeGoogleTurnOrdering,
sanitizeSessionMessagesImages,
sanitizeToolCallId,
stripThoughtSignatures,
validateGeminiTurns,
} from "./pi-embedded-helpers.js";
import {
DEFAULT_AGENTS_FILENAME,
@@ -35,6 +31,146 @@ const makeFile = (
missing: false,
...overrides,
});
describe("validateGeminiTurns", () => {
it("should return empty array unchanged", () => {
const result = validateGeminiTurns([]);
expect(result).toEqual([]);
});
it("should return single message unchanged", () => {
const msgs: AgentMessage[] = [
{
role: "user",
content: "Hello",
},
];
const result = validateGeminiTurns(msgs);
expect(result).toEqual(msgs);
});
it("should leave alternating user/assistant unchanged", () => {
const msgs: AgentMessage[] = [
{ role: "user", content: "Hello" },
{ role: "assistant", content: [{ type: "text", text: "Hi" }] },
{ role: "user", content: "How are you?" },
{ role: "assistant", content: [{ type: "text", text: "Good!" }] },
];
const result = validateGeminiTurns(msgs);
expect(result).toHaveLength(4);
expect(result).toEqual(msgs);
});
it("should merge consecutive assistant messages", () => {
const msgs: AgentMessage[] = [
{ role: "user", content: "Hello" },
{
role: "assistant",
content: [{ type: "text", text: "Part 1" }],
stopReason: "end_turn",
},
{
role: "assistant",
content: [{ type: "text", text: "Part 2" }],
stopReason: "end_turn",
},
{ role: "user", content: "How are you?" },
];
const result = validateGeminiTurns(msgs);
expect(result).toHaveLength(3);
expect(result[0]).toEqual({ role: "user", content: "Hello" });
expect(result[1].role).toBe("assistant");
expect(result[1].content).toHaveLength(2);
expect(result[2]).toEqual({ role: "user", content: "How are you?" });
});
it("should preserve metadata from later message when merging", () => {
const msgs: AgentMessage[] = [
{
role: "assistant",
content: [{ type: "text", text: "Part 1" }],
usage: { input: 10, output: 5 },
},
{
role: "assistant",
content: [{ type: "text", text: "Part 2" }],
usage: { input: 10, output: 10 },
stopReason: "end_turn",
},
];
const result = validateGeminiTurns(msgs);
expect(result).toHaveLength(1);
const merged = result[0] as Extract<AgentMessage, { role: "assistant" }>;
expect(merged.usage).toEqual({ input: 10, output: 10 });
expect(merged.stopReason).toBe("end_turn");
expect(merged.content).toHaveLength(2);
});
it("should handle toolResult messages without merging", () => {
const msgs: AgentMessage[] = [
{ role: "user", content: "Use tool" },
{
role: "assistant",
content: [{ type: "toolUse", id: "tool-1", name: "test", input: {} }],
},
{
role: "toolResult",
toolUseId: "tool-1",
content: [{ type: "text", text: "Result" }],
},
{ role: "user", content: "Next request" },
];
const result = validateGeminiTurns(msgs);
expect(result).toHaveLength(4);
expect(result).toEqual(msgs);
});
it("should handle real-world corrupted sequence", () => {
// This is the pattern that causes Gemini errors:
// user → assistant → assistant (consecutive, wrong!)
const msgs: AgentMessage[] = [
{ role: "user", content: "Request 1" },
{
role: "assistant",
content: [{ type: "text", text: "Response A" }],
},
{
role: "assistant",
content: [{ type: "toolUse", id: "t1", name: "search", input: {} }],
},
{
role: "toolResult",
toolUseId: "t1",
content: [{ type: "text", text: "Found data" }],
},
{
role: "assistant",
content: [{ type: "text", text: "Here's the answer" }],
},
{
role: "assistant",
content: [{ type: "text", text: "Extra thoughts" }],
},
{ role: "user", content: "Request 2" },
];
const result = validateGeminiTurns(msgs);
// Should merge the consecutive assistants
expect(result[0].role).toBe("user");
expect(result[1].role).toBe("assistant");
expect(result[2].role).toBe("toolResult");
expect(result[3].role).toBe("assistant");
expect(result[4].role).toBe("user");
});
});
describe("buildBootstrapContextFiles", () => {
it("keeps missing markers", () => {
const files = [makeFile({ missing: true, content: undefined })];
@@ -52,56 +188,17 @@ describe("buildBootstrapContextFiles", () => {
});
it("truncates large bootstrap content", () => {
const head = `HEAD-${"a".repeat(600)}`;
const tail = `${"b".repeat(300)}-TAIL`;
const head = `HEAD-${"a".repeat(6000)}`;
const tail = `${"b".repeat(3000)}-TAIL`;
const long = `${head}${tail}`;
const files = [makeFile({ name: "TOOLS.md", content: long })];
const warnings: string[] = [];
const maxChars = 200;
const expectedTailChars = Math.floor(maxChars * 0.2);
const [result] = buildBootstrapContextFiles(files, {
maxChars,
warn: (message) => warnings.push(message),
});
const files = [makeFile({ content: long })];
const [result] = buildBootstrapContextFiles(files);
expect(result?.content).toContain(
"[...truncated, read TOOLS.md for full content...]",
"[...truncated, read AGENTS.md for full content...]",
);
expect(result?.content.length).toBeLessThan(long.length);
expect(result?.content.startsWith(long.slice(0, 120))).toBe(true);
expect(result?.content.endsWith(long.slice(-expectedTailChars))).toBe(true);
expect(warnings).toHaveLength(1);
expect(warnings[0]).toContain("TOOLS.md");
expect(warnings[0]).toContain("limit 200");
});
it("keeps content under the default limit", () => {
const long = "a".repeat(DEFAULT_BOOTSTRAP_MAX_CHARS - 10);
const files = [makeFile({ content: long })];
const [result] = buildBootstrapContextFiles(files);
expect(result?.content).toBe(long);
expect(result?.content).not.toContain(
"[...truncated, read AGENTS.md for full content...]",
);
});
});
describe("resolveBootstrapMaxChars", () => {
it("returns default when unset", () => {
expect(resolveBootstrapMaxChars()).toBe(DEFAULT_BOOTSTRAP_MAX_CHARS);
});
it("uses configured value when valid", () => {
const cfg = {
agents: { defaults: { bootstrapMaxChars: 12345 } },
} as ClawdbotConfig;
expect(resolveBootstrapMaxChars(cfg)).toBe(12345);
});
it("falls back when invalid", () => {
const cfg = {
agents: { defaults: { bootstrapMaxChars: -1 } },
} as ClawdbotConfig;
expect(resolveBootstrapMaxChars(cfg)).toBe(DEFAULT_BOOTSTRAP_MAX_CHARS);
expect(result?.content.endsWith(long.slice(-120))).toBe(true);
});
});
@@ -168,23 +265,6 @@ describe("isBillingErrorMessage", () => {
});
});
describe("isAuthErrorMessage", () => {
it("matches credential validation errors", () => {
const samples = [
'No credentials found for profile "anthropic:claude-cli".',
"No API key found for profile openai.",
];
for (const sample of samples) {
expect(isAuthErrorMessage(sample)).toBe(true);
}
});
it("ignores unrelated errors", () => {
expect(isAuthErrorMessage("rate limit exceeded")).toBe(false);
expect(isAuthErrorMessage("billing issue detected")).toBe(false);
});
});
describe("isFailoverErrorMessage", () => {
it("matches auth/rate/billing/timeout", () => {
const samples = [
@@ -203,8 +283,6 @@ describe("isFailoverErrorMessage", () => {
describe("classifyFailoverReason", () => {
it("returns a stable reason", () => {
expect(classifyFailoverReason("invalid api key")).toBe("auth");
expect(classifyFailoverReason("no credentials found")).toBe("auth");
expect(classifyFailoverReason("no api key found")).toBe("auth");
expect(classifyFailoverReason("429 too many requests")).toBe("rate_limit");
expect(classifyFailoverReason("resource has been exhausted")).toBe(
"rate_limit",
@@ -256,15 +334,6 @@ describe("formatAssistantErrorText", () => {
const msg = makeAssistantError("request_too_large");
expect(formatAssistantErrorText(msg)).toContain("Context overflow");
});
it("returns a friendly message for Anthropic role ordering", () => {
const msg = makeAssistantError(
'messages: roles must alternate between "user" and "assistant"',
);
expect(formatAssistantErrorText(msg)).toContain(
"Message ordering conflict",
);
});
});
describe("sanitizeToolCallId", () => {
@@ -570,96 +639,6 @@ describe("normalizeTextForComparison", () => {
});
});
describe("stripThoughtSignatures", () => {
it("returns non-array content unchanged", () => {
expect(stripThoughtSignatures("hello")).toBe("hello");
expect(stripThoughtSignatures(null)).toBe(null);
expect(stripThoughtSignatures(undefined)).toBe(undefined);
expect(stripThoughtSignatures(123)).toBe(123);
});
it("removes msg_-prefixed thought_signature from content blocks", () => {
const input = [
{ type: "text", text: "hello", thought_signature: "msg_abc123" },
{ type: "thinking", thinking: "test", thought_signature: "AQID" },
];
const result = stripThoughtSignatures(input);
expect(result).toHaveLength(2);
expect(result[0]).toEqual({ type: "text", text: "hello" });
expect(result[1]).toEqual({
type: "thinking",
thinking: "test",
thought_signature: "AQID",
});
expect("thought_signature" in result[0]).toBe(false);
expect("thought_signature" in result[1]).toBe(true);
});
it("preserves blocks without thought_signature", () => {
const input = [
{ type: "text", text: "hello" },
{ type: "toolCall", id: "call_1", name: "read", arguments: {} },
];
const result = stripThoughtSignatures(input);
expect(result).toEqual(input);
});
it("handles mixed blocks with and without thought_signature", () => {
const input = [
{ type: "text", text: "hello", thought_signature: "msg_abc" },
{ type: "toolCall", id: "call_1", name: "read", arguments: {} },
{ type: "thinking", thinking: "hmm", thought_signature: "msg_xyz" },
];
const result = stripThoughtSignatures(input);
expect(result).toEqual([
{ type: "text", text: "hello" },
{ type: "toolCall", id: "call_1", name: "read", arguments: {} },
{ type: "thinking", thinking: "hmm" },
]);
});
it("handles empty array", () => {
expect(stripThoughtSignatures([])).toEqual([]);
});
it("handles null/undefined blocks in array", () => {
const input = [null, undefined, { type: "text", text: "hello" }];
const result = stripThoughtSignatures(input);
expect(result).toEqual([null, undefined, { type: "text", text: "hello" }]);
});
});
describe("sanitizeSessionMessagesImages - thought_signature stripping", () => {
it("strips msg_-prefixed thought_signature from assistant message content blocks", async () => {
const input = [
{
role: "assistant",
content: [
{ type: "text", text: "hello", thought_signature: "msg_abc123" },
{
type: "thinking",
thinking: "reasoning",
thought_signature: "AQID",
},
],
},
] satisfies AgentMessage[];
const out = await sanitizeSessionMessagesImages(input, "test");
expect(out).toHaveLength(1);
const content = (out[0] as { content?: unknown[] }).content;
expect(content).toHaveLength(2);
expect("thought_signature" in ((content?.[0] ?? {}) as object)).toBe(false);
expect(
(content?.[1] as { thought_signature?: unknown })?.thought_signature,
).toBe("AQID");
});
});
describe("isMessagingToolDuplicate", () => {
it("returns false for empty sentTexts", () => {
expect(isMessagingToolDuplicate("hello world", [])).toBe(false);
+12 -325
View File
@@ -22,88 +22,23 @@ import type { WorkspaceBootstrapFile } from "./workspace.js";
export type EmbeddedContextFile = { path: string; content: string };
// ── Cross-provider thought_signature sanitization ──────────────────────────────
// Claude's extended thinking feature generates thought_signature fields (message IDs
// like "msg_abc123...") in content blocks. When these are sent to Google's Gemini API,
// it expects Base64-encoded bytes and rejects Claude's format with a 400 error.
// This function strips thought_signature fields to enable cross-provider session sharing.
const MAX_BOOTSTRAP_CHARS = 4000;
const BOOTSTRAP_HEAD_CHARS = 2800;
const BOOTSTRAP_TAIL_CHARS = 800;
type ContentBlockWithSignature = {
thought_signature?: unknown;
[key: string]: unknown;
};
/**
* Strips Claude-style thought_signature fields from content blocks.
*
* Gemini expects thought signatures as base64-encoded bytes, but Claude stores message ids
* like "msg_abc123...". We only strip "msg_*" to preserve any provider-valid signatures.
*/
export function stripThoughtSignatures<T>(content: T): T {
if (!Array.isArray(content)) return content;
return content.map((block) => {
if (!block || typeof block !== "object") return block;
const rec = block as ContentBlockWithSignature;
const signature = rec.thought_signature;
if (typeof signature !== "string" || !signature.startsWith("msg_")) {
return block;
}
const { thought_signature: _signature, ...rest } = rec;
return rest;
}) as T;
}
export const DEFAULT_BOOTSTRAP_MAX_CHARS = 20_000;
const BOOTSTRAP_HEAD_RATIO = 0.7;
const BOOTSTRAP_TAIL_RATIO = 0.2;
type TrimBootstrapResult = {
content: string;
truncated: boolean;
maxChars: number;
originalLength: number;
};
export function resolveBootstrapMaxChars(cfg?: ClawdbotConfig): number {
const raw = cfg?.agents?.defaults?.bootstrapMaxChars;
if (typeof raw === "number" && Number.isFinite(raw) && raw > 0) {
return Math.floor(raw);
}
return DEFAULT_BOOTSTRAP_MAX_CHARS;
}
function trimBootstrapContent(
content: string,
fileName: string,
maxChars: number,
): TrimBootstrapResult {
function trimBootstrapContent(content: string, fileName: string): string {
const trimmed = content.trimEnd();
if (trimmed.length <= maxChars) {
return {
content: trimmed,
truncated: false,
maxChars,
originalLength: trimmed.length,
};
}
if (trimmed.length <= MAX_BOOTSTRAP_CHARS) return trimmed;
const headChars = Math.max(1, Math.floor(maxChars * BOOTSTRAP_HEAD_RATIO));
const tailChars = Math.max(1, Math.floor(maxChars * BOOTSTRAP_TAIL_RATIO));
const head = trimmed.slice(0, headChars);
const tail = trimmed.slice(-tailChars);
const contentWithMarker = [
const head = trimmed.slice(0, BOOTSTRAP_HEAD_CHARS);
const tail = trimmed.slice(-BOOTSTRAP_TAIL_CHARS);
return [
head,
"",
`[...truncated, read ${fileName} for full content...]`,
"",
tail,
].join("\n");
return {
content: contentWithMarker,
truncated: true,
maxChars,
originalLength: trimmed.length,
};
}
export async function ensureSessionHeader(params: {
@@ -203,9 +138,7 @@ export async function sanitizeSessionMessagesImages(
}
const content = assistantMsg.content;
if (Array.isArray(content)) {
// Strip thought_signature fields to enable cross-provider session sharing
const strippedContent = stripThoughtSignatures(content);
const filteredContent = strippedContent.filter((block) => {
const filteredContent = content.filter((block) => {
if (!block || typeof block !== "object") return true;
const rec = block as { type?: unknown; text?: unknown };
if (rec.type !== "text" || typeof rec.text !== "string") return true;
@@ -288,9 +221,7 @@ export function sanitizeGoogleTurnOrdering(
export function buildBootstrapContextFiles(
files: WorkspaceBootstrapFile[],
opts?: { warn?: (message: string) => void; maxChars?: number },
): EmbeddedContextFile[] {
const maxChars = opts?.maxChars ?? DEFAULT_BOOTSTRAP_MAX_CHARS;
const result: EmbeddedContextFile[] = [];
for (const file of files) {
if (file.missing) {
@@ -300,20 +231,11 @@ export function buildBootstrapContextFiles(
});
continue;
}
const trimmed = trimBootstrapContent(
file.content ?? "",
file.name,
maxChars,
);
if (!trimmed.content) continue;
if (trimmed.truncated) {
opts?.warn?.(
`workspace bootstrap file ${file.name} is ${trimmed.originalLength} chars (limit ${trimmed.maxChars}); truncating in injected context`,
);
}
const trimmed = trimBootstrapContent(file.content ?? "", file.name);
if (!trimmed) continue;
result.push({
path: file.name,
content: trimmed.content,
content: trimmed,
});
}
return result;
@@ -375,16 +297,6 @@ export function formatAssistantErrorText(
);
}
// Check for role ordering errors (Anthropic 400 "Incorrect role information")
// This typically happens when consecutive user messages are sent without
// an assistant response between them, often due to steering/queueing timing.
if (/incorrect role information|roles must alternate/i.test(raw)) {
return (
"Message ordering conflict - please try again. " +
"If this persists, use /new to start a fresh session."
);
}
const invalidRequest = raw.match(
/"type":"invalid_request_error".*?"message":"([^"]+)"/,
);
@@ -439,9 +351,6 @@ const ERROR_PATTERNS = {
"token has expired",
/\b401\b/,
/\b403\b/,
// Credential validation failures should trigger fallback (#761)
"no credentials found",
"no api key found",
],
format: [
"invalid_request_error",
@@ -644,83 +553,6 @@ export function validateGeminiTurns(messages: AgentMessage[]): AgentMessage[] {
return result;
}
export function mergeConsecutiveUserTurns(
previous: Extract<AgentMessage, { role: "user" }>,
current: Extract<AgentMessage, { role: "user" }>,
): Extract<AgentMessage, { role: "user" }> {
const mergedContent = [
...(Array.isArray(previous.content) ? previous.content : []),
...(Array.isArray(current.content) ? current.content : []),
];
// Preserve newest metadata while backfilling timestamp if the latest is missing.
return {
...current, // newest wins for metadata
content: mergedContent,
timestamp: current.timestamp ?? previous.timestamp,
};
}
/**
* Validates and fixes conversation turn sequences for Anthropic API.
* Anthropic requires strict alternating user→assistant pattern.
* This function:
* 1. Detects consecutive user messages
* 2. Merges consecutive user messages together
* 3. Preserves timestamps from the later message
*
* This prevents the "400 Incorrect role information" error that occurs
* when steering messages are injected during streaming and create
* consecutive user messages.
*/
export function validateAnthropicTurns(
messages: AgentMessage[],
): AgentMessage[] {
if (!Array.isArray(messages) || messages.length === 0) {
return messages;
}
const result: AgentMessage[] = [];
let lastRole: string | undefined;
for (const msg of messages) {
if (!msg || typeof msg !== "object") {
result.push(msg);
continue;
}
const msgRole = (msg as { role?: unknown }).role as string | undefined;
if (!msgRole) {
result.push(msg);
continue;
}
// Check if this message has the same role as the last one
if (msgRole === lastRole && lastRole === "user") {
// Merge consecutive user messages. Base on the newest message so we keep
// fresh metadata (attachments, timestamps, future fields) while
// appending prior content.
const lastMsg = result[result.length - 1];
const currentMsg = msg as Extract<AgentMessage, { role: "user" }>;
if (lastMsg && typeof lastMsg === "object") {
const lastUser = lastMsg as Extract<AgentMessage, { role: "user" }>;
const merged = mergeConsecutiveUserTurns(lastUser, currentMsg);
// Replace the last message with merged version
result[result.length - 1] = merged;
continue;
}
}
// Not a consecutive duplicate, add normally
result.push(msg);
lastRole = msgRole;
}
return result;
}
// ── Messaging tool duplicate detection ──────────────────────────────────────
// When the agent uses a messaging tool (telegram, discord, slack, message, sessions_send)
// to send a message, we track the text so we can suppress duplicate block replies.
@@ -785,148 +617,3 @@ export function isMessagingToolDuplicate(
sentTexts.map(normalizeTextForComparison),
);
}
/**
* Downgrades tool calls that are missing `thought_signature` (required by Gemini)
* into text representations, to prevent 400 INVALID_ARGUMENT errors.
* Also converts corresponding tool results into user messages.
*/
type GeminiToolCallBlock = {
type?: unknown;
thought_signature?: unknown;
id?: unknown;
toolCallId?: unknown;
name?: unknown;
toolName?: unknown;
arguments?: unknown;
input?: unknown;
};
export function downgradeGeminiHistory(
messages: AgentMessage[],
): AgentMessage[] {
const downgradedIds = new Set<string>();
const out: AgentMessage[] = [];
const resolveToolResultId = (
msg: Extract<AgentMessage, { role: "toolResult" }>,
): string | undefined => {
const toolCallId = (msg as { toolCallId?: unknown }).toolCallId;
if (typeof toolCallId === "string" && toolCallId) return toolCallId;
const toolUseId = (msg as { toolUseId?: unknown }).toolUseId;
if (typeof toolUseId === "string" && toolUseId) return toolUseId;
return undefined;
};
for (const msg of messages) {
if (!msg || typeof msg !== "object") {
out.push(msg);
continue;
}
const role = (msg as { role?: unknown }).role;
if (role === "assistant") {
const assistantMsg = msg as Extract<AgentMessage, { role: "assistant" }>;
if (!Array.isArray(assistantMsg.content)) {
out.push(msg);
continue;
}
let hasDowngraded = false;
const newContent = assistantMsg.content.map((block) => {
if (!block || typeof block !== "object") return block;
const blockRecord = block as GeminiToolCallBlock;
const type = blockRecord.type;
// Check for tool calls / function calls
if (
type === "toolCall" ||
type === "functionCall" ||
type === "toolUse"
) {
// Check if thought_signature is missing
// Note: TypeScript doesn't know about thought_signature on standard types
const hasSignature = Boolean(blockRecord.thought_signature);
if (!hasSignature) {
const id =
typeof blockRecord.id === "string"
? blockRecord.id
: typeof blockRecord.toolCallId === "string"
? blockRecord.toolCallId
: undefined;
const name =
typeof blockRecord.name === "string"
? blockRecord.name
: typeof blockRecord.toolName === "string"
? blockRecord.toolName
: undefined;
const args =
blockRecord.arguments !== undefined
? blockRecord.arguments
: blockRecord.input;
if (id) downgradedIds.add(id);
hasDowngraded = true;
const argsText =
typeof args === "string" ? args : JSON.stringify(args, null, 2);
return {
type: "text",
text: `[Tool Call: ${name ?? "unknown"}${
id ? ` (ID: ${id})` : ""
}]\nArguments: ${argsText}`,
};
}
}
return block;
});
if (hasDowngraded) {
out.push({ ...assistantMsg, content: newContent } as AgentMessage);
} else {
out.push(msg);
}
continue;
}
if (role === "toolResult") {
const toolMsg = msg as Extract<AgentMessage, { role: "toolResult" }>;
const toolResultId = resolveToolResultId(toolMsg);
if (toolResultId && downgradedIds.has(toolResultId)) {
// Convert to User message
let textContent = "";
if (Array.isArray(toolMsg.content)) {
textContent = toolMsg.content
.map((entry) => {
if (entry && typeof entry === "object") {
const text = (entry as { text?: unknown }).text;
if (typeof text === "string") return text;
}
return JSON.stringify(entry);
})
.join("\n");
} else {
textContent = JSON.stringify(toolMsg.content);
}
out.push({
role: "user",
content: [
{
type: "text",
text: `[Tool Result for ID ${toolResultId}]\n${textContent}`,
},
],
} as AgentMessage);
continue;
}
}
out.push(msg);
}
return out;
}
@@ -1,352 +0,0 @@
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import { describe, expect, it } from "vitest";
import {
mergeConsecutiveUserTurns,
validateAnthropicTurns,
validateGeminiTurns,
} from "./pi-embedded-helpers.js";
describe("validateGeminiTurns", () => {
it("should return empty array unchanged", () => {
const result = validateGeminiTurns([]);
expect(result).toEqual([]);
});
it("should return single message unchanged", () => {
const msgs: AgentMessage[] = [
{
role: "user",
content: "Hello",
},
];
const result = validateGeminiTurns(msgs);
expect(result).toEqual(msgs);
});
it("should leave alternating user/assistant unchanged", () => {
const msgs: AgentMessage[] = [
{ role: "user", content: "Hello" },
{ role: "assistant", content: [{ type: "text", text: "Hi" }] },
{ role: "user", content: "How are you?" },
{ role: "assistant", content: [{ type: "text", text: "Good!" }] },
];
const result = validateGeminiTurns(msgs);
expect(result).toHaveLength(4);
expect(result).toEqual(msgs);
});
it("should merge consecutive assistant messages", () => {
const msgs: AgentMessage[] = [
{ role: "user", content: "Hello" },
{
role: "assistant",
content: [{ type: "text", text: "Part 1" }],
stopReason: "end_turn",
},
{
role: "assistant",
content: [{ type: "text", text: "Part 2" }],
stopReason: "end_turn",
},
{ role: "user", content: "How are you?" },
];
const result = validateGeminiTurns(msgs);
expect(result).toHaveLength(3);
expect(result[0]).toEqual({ role: "user", content: "Hello" });
expect(result[1].role).toBe("assistant");
expect(result[1].content).toHaveLength(2);
expect(result[2]).toEqual({ role: "user", content: "How are you?" });
});
it("should preserve metadata from later message when merging", () => {
const msgs: AgentMessage[] = [
{
role: "assistant",
content: [{ type: "text", text: "Part 1" }],
usage: { input: 10, output: 5 },
},
{
role: "assistant",
content: [{ type: "text", text: "Part 2" }],
usage: { input: 10, output: 10 },
stopReason: "end_turn",
},
];
const result = validateGeminiTurns(msgs);
expect(result).toHaveLength(1);
const merged = result[0] as Extract<AgentMessage, { role: "assistant" }>;
expect(merged.usage).toEqual({ input: 10, output: 10 });
expect(merged.stopReason).toBe("end_turn");
expect(merged.content).toHaveLength(2);
});
it("should handle toolResult messages without merging", () => {
const msgs: AgentMessage[] = [
{ role: "user", content: "Use tool" },
{
role: "assistant",
content: [{ type: "toolUse", id: "tool-1", name: "test", input: {} }],
},
{
role: "toolResult",
toolUseId: "tool-1",
content: [{ type: "text", text: "Found data" }],
},
{
role: "assistant",
content: [{ type: "text", text: "Here's the answer" }],
},
{
role: "assistant",
content: [{ type: "text", text: "Extra thoughts" }],
},
{ role: "user", content: "Request 2" },
];
const result = validateGeminiTurns(msgs);
// Should merge the consecutive assistants
expect(result[0].role).toBe("user");
expect(result[1].role).toBe("assistant");
expect(result[2].role).toBe("toolResult");
expect(result[3].role).toBe("assistant");
expect(result[4].role).toBe("user");
});
});
describe("validateAnthropicTurns", () => {
it("should return empty array unchanged", () => {
const result = validateAnthropicTurns([]);
expect(result).toEqual([]);
});
it("should return single message unchanged", () => {
const msgs: AgentMessage[] = [
{
role: "user",
content: [{ type: "text", text: "Hello" }],
},
];
const result = validateAnthropicTurns(msgs);
expect(result).toEqual(msgs);
});
it("should return alternating user/assistant unchanged", () => {
const msgs: AgentMessage[] = [
{ role: "user", content: [{ type: "text", text: "Question" }] },
{
role: "assistant",
content: [{ type: "text", text: "Answer" }],
},
{ role: "user", content: [{ type: "text", text: "Follow-up" }] },
];
const result = validateAnthropicTurns(msgs);
expect(result).toEqual(msgs);
});
it("should merge consecutive user messages", () => {
const msgs: AgentMessage[] = [
{
role: "user",
content: [{ type: "text", text: "First message" }],
timestamp: 1000,
},
{
role: "user",
content: [{ type: "text", text: "Second message" }],
timestamp: 2000,
},
];
const result = validateAnthropicTurns(msgs);
expect(result).toHaveLength(1);
expect(result[0].role).toBe("user");
const content = (result[0] as { content: unknown[] }).content;
expect(content).toHaveLength(2);
expect(content[0]).toEqual({ type: "text", text: "First message" });
expect(content[1]).toEqual({ type: "text", text: "Second message" });
// Should take timestamp from the newer message
expect((result[0] as { timestamp?: number }).timestamp).toBe(2000);
});
it("should merge three consecutive user messages", () => {
const msgs: AgentMessage[] = [
{ role: "user", content: [{ type: "text", text: "One" }] },
{ role: "user", content: [{ type: "text", text: "Two" }] },
{ role: "user", content: [{ type: "text", text: "Three" }] },
];
const result = validateAnthropicTurns(msgs);
expect(result).toHaveLength(1);
const content = (result[0] as { content: unknown[] }).content;
expect(content).toHaveLength(3);
});
it("keeps newest metadata when merging consecutive users", () => {
const msgs: AgentMessage[] = [
{
role: "user",
content: [{ type: "text", text: "Old" }],
timestamp: 1000,
attachments: [{ type: "image", url: "old.png" }],
},
{
role: "user",
content: [{ type: "text", text: "New" }],
timestamp: 2000,
attachments: [{ type: "image", url: "new.png" }],
someCustomField: "keep-me",
} as AgentMessage,
];
const result = validateAnthropicTurns(msgs) as Extract<
AgentMessage,
{ role: "user" }
>[];
expect(result).toHaveLength(1);
const merged = result[0];
expect(merged.timestamp).toBe(2000);
expect((merged as { attachments?: unknown[] }).attachments).toEqual([
{ type: "image", url: "new.png" },
]);
expect((merged as { someCustomField?: string }).someCustomField).toBe(
"keep-me",
);
expect(merged.content).toEqual([
{ type: "text", text: "Old" },
{ type: "text", text: "New" },
]);
});
it("merges consecutive users with images and preserves order", () => {
const msgs: AgentMessage[] = [
{
role: "user",
content: [
{ type: "text", text: "first" },
{ type: "image", url: "img1" },
],
},
{
role: "user",
content: [
{ type: "image", url: "img2" },
{ type: "text", text: "second" },
],
},
];
const [merged] = validateAnthropicTurns(msgs) as Extract<
AgentMessage,
{ role: "user" }
>[];
expect(merged.content).toEqual([
{ type: "text", text: "first" },
{ type: "image", url: "img1" },
{ type: "image", url: "img2" },
{ type: "text", text: "second" },
]);
});
it("should not merge consecutive assistant messages", () => {
const msgs: AgentMessage[] = [
{ role: "user", content: [{ type: "text", text: "Question" }] },
{
role: "assistant",
content: [{ type: "text", text: "Answer 1" }],
},
{
role: "assistant",
content: [{ type: "text", text: "Answer 2" }],
},
];
const result = validateAnthropicTurns(msgs);
// validateAnthropicTurns only merges user messages, not assistant
expect(result).toHaveLength(3);
});
it("should handle mixed scenario with steering messages", () => {
// Simulates: user asks -> assistant errors -> steering user message injected
const msgs: AgentMessage[] = [
{ role: "user", content: [{ type: "text", text: "Original question" }] },
{
role: "assistant",
content: [],
stopReason: "error",
errorMessage: "Overloaded",
},
{
role: "user",
content: [{ type: "text", text: "Steering: try again" }],
},
{ role: "user", content: [{ type: "text", text: "Another follow-up" }] },
];
const result = validateAnthropicTurns(msgs);
// The two consecutive user messages at the end should be merged
expect(result).toHaveLength(3);
expect(result[0].role).toBe("user");
expect(result[1].role).toBe("assistant");
expect(result[2].role).toBe("user");
const lastContent = (result[2] as { content: unknown[] }).content;
expect(lastContent).toHaveLength(2);
});
});
describe("mergeConsecutiveUserTurns", () => {
it("keeps newest metadata while merging content", () => {
const previous: Extract<AgentMessage, { role: "user" }> = {
role: "user",
content: [{ type: "text", text: "before" }],
timestamp: 1000,
attachments: [{ type: "image", url: "old.png" }],
};
const current: Extract<AgentMessage, { role: "user" }> = {
role: "user",
content: [{ type: "text", text: "after" }],
timestamp: 2000,
attachments: [{ type: "image", url: "new.png" }],
someCustomField: "keep-me",
} as AgentMessage;
const merged = mergeConsecutiveUserTurns(previous, current);
expect(merged.content).toEqual([
{ type: "text", text: "before" },
{ type: "text", text: "after" },
]);
expect((merged as { attachments?: unknown[] }).attachments).toEqual([
{ type: "image", url: "new.png" },
]);
expect((merged as { someCustomField?: string }).someCustomField).toBe(
"keep-me",
);
expect(merged.timestamp).toBe(2000);
});
it("backfills timestamp from earlier message when missing", () => {
const previous: Extract<AgentMessage, { role: "user" }> = {
role: "user",
content: [{ type: "text", text: "before" }],
timestamp: 1000,
};
const current: Extract<AgentMessage, { role: "user" }> = {
role: "user",
content: [{ type: "text", text: "after" }],
};
const merged = mergeConsecutiveUserTurns(previous, current);
expect(merged.timestamp).toBe(1000);
});
});
+5 -120
View File
@@ -85,7 +85,6 @@ export type { MessagingToolSend } from "./pi-embedded-messaging.js";
import {
buildBootstrapContextFiles,
classifyFailoverReason,
downgradeGeminiHistory,
type EmbeddedContextFile,
ensureSessionHeader,
formatAssistantErrorText,
@@ -99,10 +98,8 @@ import {
isRateLimitAssistantError,
isTimeoutErrorMessage,
pickFallbackThinkingLevel,
resolveBootstrapMaxChars,
sanitizeGoogleTurnOrdering,
sanitizeSessionMessagesImages,
validateAnthropicTurns,
validateGeminiTurns,
} from "./pi-embedded-helpers.js";
import {
@@ -321,12 +318,6 @@ function buildContextPruningExtension(params: {
};
}
function resolveCompactionMode(cfg?: ClawdbotConfig): "default" | "safeguard" {
return cfg?.agents?.defaults?.compaction?.mode === "safeguard"
? "safeguard"
: "default";
}
function buildEmbeddedExtensionPaths(params: {
cfg: ClawdbotConfig | undefined;
sessionManager: SessionManager;
@@ -335,9 +326,6 @@ function buildEmbeddedExtensionPaths(params: {
model: Model<Api> | undefined;
}): string[] {
const paths = [resolvePiExtensionPath("transcript-sanitize")];
if (resolveCompactionMode(params.cfg) === "safeguard") {
paths.push(resolvePiExtensionPath("compaction-safeguard"));
}
const pruning = buildContextPruningExtension(params);
if (pruning.additionalExtensionPaths) {
paths.push(...pruning.additionalExtensionPaths);
@@ -426,85 +414,6 @@ type EmbeddedPiQueueHandle = {
const log = createSubsystemLogger("agent/embedded");
const GOOGLE_TURN_ORDERING_CUSTOM_TYPE = "google-turn-ordering-bootstrap";
const GOOGLE_SCHEMA_UNSUPPORTED_KEYWORDS = new Set([
"patternProperties",
"additionalProperties",
"$schema",
"$id",
"$ref",
"$defs",
"definitions",
"examples",
"minLength",
"maxLength",
"minimum",
"maximum",
"multipleOf",
"pattern",
"format",
"minItems",
"maxItems",
"uniqueItems",
"minProperties",
"maxProperties",
]);
function findUnsupportedSchemaKeywords(
schema: unknown,
path: string,
): string[] {
if (!schema || typeof schema !== "object") return [];
if (Array.isArray(schema)) {
return schema.flatMap((item, index) =>
findUnsupportedSchemaKeywords(item, `${path}[${index}]`),
);
}
const record = schema as Record<string, unknown>;
const violations: string[] = [];
for (const [key, value] of Object.entries(record)) {
if (GOOGLE_SCHEMA_UNSUPPORTED_KEYWORDS.has(key)) {
violations.push(`${path}.${key}`);
}
if (value && typeof value === "object") {
violations.push(
...findUnsupportedSchemaKeywords(value, `${path}.${key}`),
);
}
}
return violations;
}
function logToolSchemasForGoogle(params: {
tools: AgentTool[];
provider: string;
}) {
if (
params.provider !== "google-antigravity" &&
params.provider !== "google-gemini-cli"
) {
return;
}
const toolNames = params.tools.map((tool, index) => `${index}:${tool.name}`);
log.info("google tool schema snapshot", {
provider: params.provider,
toolCount: params.tools.length,
tools: toolNames,
});
for (const [index, tool] of params.tools.entries()) {
const violations = findUnsupportedSchemaKeywords(
tool.parameters,
`${tool.name}.parameters`,
);
if (violations.length > 0) {
log.warn("google tool schema has unsupported keywords", {
index,
tool: tool.name,
violations: violations.slice(0, 12),
violationCount: violations.length,
});
}
}
}
registerUnhandledRejectionHandler((reason) => {
const message = describeUnknownError(reason);
@@ -583,14 +492,8 @@ async function sanitizeSessionHistory(params: {
},
);
const repairedTools = sanitizeToolUseResultPairing(sanitizedImages);
// Downgrade tool calls missing thought_signature if using Gemini
const downgraded = isGoogleModelApi(params.modelApi)
? downgradeGeminiHistory(repairedTools)
: repairedTools;
return applyGoogleTurnOrderingFix({
messages: downgraded,
messages: repairedTools,
modelApi: params.modelApi,
sessionManager: params.sessionManager,
sessionId: params.sessionId,
@@ -1241,12 +1144,7 @@ export async function compactEmbeddedPiSession(params: {
await loadWorkspaceBootstrapFiles(effectiveWorkspace),
params.sessionKey ?? params.sessionId,
);
const sessionLabel = params.sessionKey ?? params.sessionId;
const contextFiles = buildBootstrapContextFiles(bootstrapFiles, {
maxChars: resolveBootstrapMaxChars(params.config),
warn: (message) =>
log.warn(`${message} (sessionKey=${sessionLabel})`),
});
const contextFiles = buildBootstrapContextFiles(bootstrapFiles);
const runAbortController = new AbortController();
const tools = createClawdbotCodingTools({
exec: {
@@ -1266,7 +1164,6 @@ export async function compactEmbeddedPiSession(params: {
modelAuthMode: resolveModelAuthMode(model.provider, params.config),
// No currentChannelId/currentThreadTs for compaction - not in message context
});
logToolSchemasForGoogle({ tools, provider });
const machineName = await getMachineDisplayName();
const runtimeProvider = normalizeMessageProvider(
params.messageProvider,
@@ -1394,9 +1291,7 @@ export async function compactEmbeddedPiSession(params: {
sessionManager,
sessionId: params.sessionId,
});
// Validate turn ordering for both Gemini (consecutive assistant) and Anthropic (consecutive user)
const validatedGemini = validateGeminiTurns(prior);
const validated = validateAnthropicTurns(validatedGemini);
const validated = validateGeminiTurns(prior);
const limited = limitHistoryTurns(
validated,
getDmHistoryLimitFromSessionKey(params.sessionKey, params.config),
@@ -1472,7 +1367,6 @@ export async function runEmbeddedPiAgent(params: {
text?: string;
mediaUrls?: string[];
}) => void | Promise<void>;
onAssistantMessageStart?: () => void | Promise<void>;
onBlockReply?: (payload: {
text?: string;
mediaUrls?: string[];
@@ -1680,12 +1574,7 @@ export async function runEmbeddedPiAgent(params: {
await loadWorkspaceBootstrapFiles(effectiveWorkspace),
params.sessionKey ?? params.sessionId,
);
const sessionLabel = params.sessionKey ?? params.sessionId;
const contextFiles = buildBootstrapContextFiles(bootstrapFiles, {
maxChars: resolveBootstrapMaxChars(params.config),
warn: (message) =>
log.warn(`${message} (sessionKey=${sessionLabel})`),
});
const contextFiles = buildBootstrapContextFiles(bootstrapFiles);
// Tool schemas must be provider-compatible (OpenAI requires top-level `type: "object"`).
// `createClawdbotCodingTools()` normalizes schemas so the session can pass them through unchanged.
const tools = createClawdbotCodingTools({
@@ -1709,7 +1598,6 @@ export async function runEmbeddedPiAgent(params: {
replyToMode: params.replyToMode,
hasRepliedRef: params.hasRepliedRef,
});
logToolSchemasForGoogle({ tools, provider });
const machineName = await getMachineDisplayName();
const runtimeInfo = {
host: machineName,
@@ -1826,9 +1714,7 @@ export async function runEmbeddedPiAgent(params: {
sessionManager,
sessionId: params.sessionId,
});
// Validate turn ordering for both Gemini (consecutive assistant) and Anthropic (consecutive user)
const validatedGemini = validateGeminiTurns(prior);
const validated = validateAnthropicTurns(validatedGemini);
const validated = validateGeminiTurns(prior);
const limited = limitHistoryTurns(
validated,
getDmHistoryLimitFromSessionKey(params.sessionKey, params.config),
@@ -1865,7 +1751,6 @@ export async function runEmbeddedPiAgent(params: {
blockReplyBreak: params.blockReplyBreak,
blockReplyChunking: params.blockReplyChunking,
onPartialReply: params.onPartialReply,
onAssistantMessageStart: params.onAssistantMessageStart,
onAgentEvent: params.onAgentEvent,
enforceFinalTag: params.enforceFinalTag,
});
+2 -9
View File
@@ -146,7 +146,7 @@ function extractMessagingToolSend(
: undefined;
}
export type SubscribeEmbeddedPiSessionParams = {
export function subscribeEmbeddedPiSession(params: {
session: AgentSession;
runId: string;
verboseLevel?: "off" | "on";
@@ -173,17 +173,12 @@ export type SubscribeEmbeddedPiSessionParams = {
text?: string;
mediaUrls?: string[];
}) => void | Promise<void>;
onAssistantMessageStart?: () => void | Promise<void>;
onAgentEvent?: (evt: {
stream: string;
data: Record<string, unknown>;
}) => void;
enforceFinalTag?: boolean;
};
export function subscribeEmbeddedPiSession(
params: SubscribeEmbeddedPiSessionParams,
) {
}) {
const assistantTexts: string[] = [];
const toolMetas: Array<{ toolName?: string; meta?: string }> = [];
const toolMetaById = new Map<string, string | undefined>();
@@ -497,8 +492,6 @@ export function subscribeEmbeddedPiSession(
// may deliver late text_end updates after message_end, which would
// otherwise re-trigger block replies.
resetAssistantMessageState(assistantTexts.length);
// Use assistant message_start as the earliest "writing" signal for typing.
void params.onAssistantMessageStart?.();
}
}
-260
View File
@@ -1,260 +0,0 @@
import type { AssistantMessage } from "@mariozechner/pi-ai";
import { describe, expect, it } from "vitest";
import { extractAssistantText } from "./pi-embedded-utils.js";
describe("extractAssistantText", () => {
it("strips Minimax tool invocation XML from text", () => {
const msg: AssistantMessage = {
role: "assistant",
content: [
{
type: "text",
text: `<invoke name="Bash">
<parameter name="command">netstat -tlnp | grep 18789</parameter>
</invoke>
</minimax:tool_call>`,
},
],
timestamp: Date.now(),
};
const result = extractAssistantText(msg);
expect(result).toBe("");
});
it("strips multiple tool invocations", () => {
const msg: AssistantMessage = {
role: "assistant",
content: [
{
type: "text",
text: `Let me check that.<invoke name="Read">
<parameter name="path">/home/admin/test.txt</parameter>
</invoke>
</minimax:tool_call>`,
},
],
timestamp: Date.now(),
};
const result = extractAssistantText(msg);
expect(result).toBe("Let me check that.");
});
it("keeps invoke snippets without Minimax markers", () => {
const msg: AssistantMessage = {
role: "assistant",
content: [
{
type: "text",
text: `Example:\n<invoke name="Bash">\n<parameter name="command">ls</parameter>\n</invoke>`,
},
],
timestamp: Date.now(),
};
const result = extractAssistantText(msg);
expect(result).toBe(
`Example:\n<invoke name="Bash">\n<parameter name="command">ls</parameter>\n</invoke>`,
);
});
it("preserves normal text without tool invocations", () => {
const msg: AssistantMessage = {
role: "assistant",
content: [
{
type: "text",
text: "This is a normal response without any tool calls.",
},
],
timestamp: Date.now(),
};
const result = extractAssistantText(msg);
expect(result).toBe("This is a normal response without any tool calls.");
});
it("strips Minimax tool invocations with extra attributes", () => {
const msg: AssistantMessage = {
role: "assistant",
content: [
{
type: "text",
text: `Before<invoke name='Bash' data-foo="bar">\n<parameter name="command">ls</parameter>\n</invoke>\n</minimax:tool_call>After`,
},
],
timestamp: Date.now(),
};
const result = extractAssistantText(msg);
expect(result).toBe("Before\nAfter");
});
it("strips minimax tool_call open and close tags", () => {
const msg: AssistantMessage = {
role: "assistant",
content: [
{
type: "text",
text: "Start<minimax:tool_call>Inner</minimax:tool_call>End",
},
],
timestamp: Date.now(),
};
const result = extractAssistantText(msg);
expect(result).toBe("StartInnerEnd");
});
it("ignores invoke blocks without minimax markers", () => {
const msg: AssistantMessage = {
role: "assistant",
content: [
{
type: "text",
text: "Before<invoke>Keep</invoke>After",
},
],
timestamp: Date.now(),
};
const result = extractAssistantText(msg);
expect(result).toBe("Before<invoke>Keep</invoke>After");
});
it("strips invoke blocks when minimax markers are present elsewhere", () => {
const msg: AssistantMessage = {
role: "assistant",
content: [
{
type: "text",
text: "Before<invoke>Drop</invoke><minimax:tool_call>After",
},
],
timestamp: Date.now(),
};
const result = extractAssistantText(msg);
expect(result).toBe("BeforeAfter");
});
it("strips invoke blocks with nested tags", () => {
const msg: AssistantMessage = {
role: "assistant",
content: [
{
type: "text",
text: `A<invoke name="Bash"><param><deep>1</deep></param></invoke></minimax:tool_call>B`,
},
],
timestamp: Date.now(),
};
const result = extractAssistantText(msg);
expect(result).toBe("AB");
});
it("strips tool XML mixed with regular content", () => {
const msg: AssistantMessage = {
role: "assistant",
content: [
{
type: "text",
text: `I'll help you with that.<invoke name="Bash">
<parameter name="command">ls -la</parameter>
</invoke>
</minimax:tool_call>Here are the results.`,
},
],
timestamp: Date.now(),
};
const result = extractAssistantText(msg);
expect(result).toBe("I'll help you with that.\nHere are the results.");
});
it("handles multiple invoke blocks in one message", () => {
const msg: AssistantMessage = {
role: "assistant",
content: [
{
type: "text",
text: `First check.<invoke name="Read">
<parameter name="path">file1.txt</parameter>
</invoke>
</minimax:tool_call>Second check.<invoke name="Bash">
<parameter name="command">pwd</parameter>
</invoke>
</minimax:tool_call>Done.`,
},
],
timestamp: Date.now(),
};
const result = extractAssistantText(msg);
expect(result).toBe("First check.\nSecond check.\nDone.");
});
it("handles stray closing tags without opening tags", () => {
const msg: AssistantMessage = {
role: "assistant",
content: [
{
type: "text",
text: "Some text here.</minimax:tool_call>More text.",
},
],
timestamp: Date.now(),
};
const result = extractAssistantText(msg);
expect(result).toBe("Some text here.More text.");
});
it("returns empty string when message is only tool invocations", () => {
const msg: AssistantMessage = {
role: "assistant",
content: [
{
type: "text",
text: `<invoke name="Bash">
<parameter name="command">test</parameter>
</invoke>
</minimax:tool_call>`,
},
],
timestamp: Date.now(),
};
const result = extractAssistantText(msg);
expect(result).toBe("");
});
it("handles multiple text blocks", () => {
const msg: AssistantMessage = {
role: "assistant",
content: [
{
type: "text",
text: "First block.",
},
{
type: "text",
text: `<invoke name="Bash">
<parameter name="command">ls</parameter>
</invoke>
</minimax:tool_call>`,
},
{
type: "text",
text: "Third block.",
},
],
timestamp: Date.now(),
};
const result = extractAssistantText(msg);
expect(result).toBe("First block.\nThird block.");
});
});
+1 -21
View File
@@ -1,26 +1,6 @@
import type { AssistantMessage } from "@mariozechner/pi-ai";
import { formatToolDetail, resolveToolDisplay } from "./tool-display.js";
/**
* Strip malformed Minimax tool invocations that leak into text content.
* Minimax sometimes embeds tool calls as XML in text blocks instead of
* proper structured tool calls. This removes:
* - <invoke name="...">...</invoke> blocks
* - </minimax:tool_call> closing tags
*/
function stripMinimaxToolCallXml(text: string): string {
if (!text) return text;
if (!/minimax:tool_call/i.test(text)) return text;
// Remove <invoke ...>...</invoke> blocks (non-greedy to handle multiple).
let cleaned = text.replace(/<invoke\b[^>]*>[\s\S]*?<\/invoke>/gi, "");
// Remove stray minimax tool tags.
cleaned = cleaned.replace(/<\/?minimax:tool_call>/gi, "");
return cleaned;
}
export function extractAssistantText(msg: AssistantMessage): string {
const isTextBlock = (
block: unknown,
@@ -33,7 +13,7 @@ export function extractAssistantText(msg: AssistantMessage): string {
const blocks = Array.isArray(msg.content)
? msg.content
.filter(isTextBlock)
.map((c) => stripMinimaxToolCallXml(c.text).trim())
.map((c) => c.text.trim())
.filter(Boolean)
: [];
return blocks.join("\n").trim();
@@ -1,212 +0,0 @@
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import type {
ExtensionAPI,
ExtensionContext,
FileOperations,
} from "@mariozechner/pi-coding-agent";
import { estimateTokens, generateSummary } from "@mariozechner/pi-coding-agent";
import { DEFAULT_CONTEXT_TOKENS } from "../defaults.js";
const MAX_CHUNK_RATIO = 0.4;
const FALLBACK_SUMMARY =
"Summary unavailable due to context limits. Older messages were truncated.";
const TURN_PREFIX_INSTRUCTIONS =
"This summary covers the prefix of a split turn. Focus on the original request," +
" early progress, and any details needed to understand the retained suffix.";
function computeFileLists(fileOps: FileOperations): {
readFiles: string[];
modifiedFiles: string[];
} {
const modified = new Set([...fileOps.edited, ...fileOps.written]);
const readFiles = [...fileOps.read].filter((f) => !modified.has(f)).sort();
const modifiedFiles = [...modified].sort();
return { readFiles, modifiedFiles };
}
function formatFileOperations(
readFiles: string[],
modifiedFiles: string[],
): string {
const sections: string[] = [];
if (readFiles.length > 0) {
sections.push(`<read-files>\n${readFiles.join("\n")}\n</read-files>`);
}
if (modifiedFiles.length > 0) {
sections.push(
`<modified-files>\n${modifiedFiles.join("\n")}\n</modified-files>`,
);
}
if (sections.length === 0) return "";
return `\n\n${sections.join("\n\n")}`;
}
function chunkMessages(
messages: AgentMessage[],
maxTokens: number,
): AgentMessage[][] {
if (messages.length === 0) return [];
const chunks: AgentMessage[][] = [];
let currentChunk: AgentMessage[] = [];
let currentTokens = 0;
for (const message of messages) {
const messageTokens = estimateTokens(message);
if (currentChunk.length > 0 && currentTokens + messageTokens > maxTokens) {
chunks.push(currentChunk);
currentChunk = [];
currentTokens = 0;
}
currentChunk.push(message);
currentTokens += messageTokens;
if (messageTokens > maxTokens) {
// Split oversized messages to avoid unbounded chunk growth.
chunks.push(currentChunk);
currentChunk = [];
currentTokens = 0;
}
}
if (currentChunk.length > 0) {
chunks.push(currentChunk);
}
return chunks;
}
async function summarizeChunks(params: {
messages: AgentMessage[];
model: NonNullable<ExtensionContext["model"]>;
apiKey: string;
signal: AbortSignal;
reserveTokens: number;
maxChunkTokens: number;
customInstructions?: string;
previousSummary?: string;
}): Promise<string> {
if (params.messages.length === 0) {
return params.previousSummary ?? "No prior history.";
}
const chunks = chunkMessages(params.messages, params.maxChunkTokens);
let summary = params.previousSummary;
for (const chunk of chunks) {
summary = await generateSummary(
chunk,
params.model,
params.reserveTokens,
params.apiKey,
params.signal,
params.customInstructions,
summary,
);
}
return summary ?? "No prior history.";
}
export default function compactionSafeguardExtension(api: ExtensionAPI): void {
api.on("session_before_compact", async (event, ctx) => {
const { preparation, customInstructions, signal } = event;
const { readFiles, modifiedFiles } = computeFileLists(preparation.fileOps);
const fileOpsSummary = formatFileOperations(readFiles, modifiedFiles);
const fallbackSummary = `${FALLBACK_SUMMARY}${fileOpsSummary}`;
const model = ctx.model;
if (!model) {
return {
compaction: {
summary: fallbackSummary,
firstKeptEntryId: preparation.firstKeptEntryId,
tokensBefore: preparation.tokensBefore,
details: { readFiles, modifiedFiles },
},
};
}
const apiKey = await ctx.modelRegistry.getApiKey(model);
if (!apiKey) {
return {
compaction: {
summary: fallbackSummary,
firstKeptEntryId: preparation.firstKeptEntryId,
tokensBefore: preparation.tokensBefore,
details: { readFiles, modifiedFiles },
},
};
}
try {
const contextWindowTokens = Math.max(
1,
Math.floor(model.contextWindow ?? DEFAULT_CONTEXT_TOKENS),
);
const maxChunkTokens = Math.max(
1,
Math.floor(contextWindowTokens * MAX_CHUNK_RATIO),
);
const reserveTokens = Math.max(
1,
Math.floor(preparation.settings.reserveTokens),
);
const historySummary = await summarizeChunks({
messages: preparation.messagesToSummarize,
model,
apiKey,
signal,
reserveTokens,
maxChunkTokens,
customInstructions,
previousSummary: preparation.previousSummary,
});
let summary = historySummary;
if (
preparation.isSplitTurn &&
preparation.turnPrefixMessages.length > 0
) {
const prefixSummary = await summarizeChunks({
messages: preparation.turnPrefixMessages,
model,
apiKey,
signal,
reserveTokens,
maxChunkTokens,
customInstructions: TURN_PREFIX_INSTRUCTIONS,
});
summary = `${historySummary}\n\n---\n\n**Turn Context (split turn):**\n\n${prefixSummary}`;
}
summary += fileOpsSummary;
return {
compaction: {
summary,
firstKeptEntryId: preparation.firstKeptEntryId,
tokensBefore: preparation.tokensBefore,
details: { readFiles, modifiedFiles },
},
};
} catch (error) {
console.warn(
`Compaction summarization failed; truncating history: ${
error instanceof Error ? error.message : String(error)
}`,
);
return {
compaction: {
summary: fallbackSummary,
firstKeptEntryId: preparation.firstKeptEntryId,
tokensBefore: preparation.tokensBefore,
details: { readFiles, modifiedFiles },
},
};
}
});
}
+1 -167
View File
@@ -5,7 +5,6 @@ import type { AgentTool } from "@mariozechner/pi-agent-core";
import sharp from "sharp";
import { describe, expect, it, vi } from "vitest";
import type { ClawdbotConfig } from "../config/config.js";
import { createClawdbotTools } from "./clawdbot-tools.js";
import { __testing, createClawdbotCodingTools } from "./pi-tools.js";
import { createBrowserTool } from "./tools/browser-tool.js";
@@ -17,7 +16,7 @@ describe("createClawdbotCodingTools", () => {
expect(schema.anyOf).toBeUndefined();
});
it("keeps browser tool schema properties after normalization", () => {
it("merges properties for union tool schemas", () => {
const tools = createClawdbotCodingTools();
const browser = tools.find((tool) => tool.name === "browser");
expect(browser).toBeDefined();
@@ -104,31 +103,6 @@ describe("createClawdbotCodingTools", () => {
});
});
it("drops null-only union variants without flattening other unions", () => {
const cleaned = __testing.cleanToolSchemaForGemini({
type: "object",
properties: {
parentId: { anyOf: [{ type: "string" }, { type: "null" }] },
count: { oneOf: [{ type: "string" }, { type: "number" }] },
},
}) as {
properties?: Record<string, unknown>;
};
const parentId = cleaned.properties?.parentId as
| { type?: unknown; anyOf?: unknown; oneOf?: unknown }
| undefined;
expect(parentId?.anyOf).toBeUndefined();
expect(parentId?.oneOf).toBeUndefined();
expect(parentId?.type).toBe("string");
const count = cleaned.properties?.count as
| { type?: unknown; anyOf?: unknown; oneOf?: unknown }
| undefined;
expect(count?.anyOf).toBeUndefined();
expect(Array.isArray(count?.oneOf)).toBe(true);
});
it("preserves action enums in normalized schemas", () => {
const tools = createClawdbotCodingTools();
const toolNames = [
@@ -267,95 +241,6 @@ describe("createClawdbotCodingTools", () => {
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", () => {
const tools = createClawdbotCodingTools({ messageProvider: "discord" });
const names = new Set(tools.map((tool) => tool.name));
@@ -607,57 +492,6 @@ describe("createClawdbotCodingTools", () => {
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", () => {
const tools = createClawdbotCodingTools();
+23 -27
View File
@@ -28,11 +28,6 @@ import type { SandboxContext, SandboxToolPolicy } from "./sandbox.js";
import { assertSandboxPath } from "./sandbox-paths.js";
import { cleanSchemaForGemini } from "./schema/clean-for-gemini.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
// to normalize payloads and sanitize oversized images before they hit providers.
@@ -296,6 +291,21 @@ function cleanToolSchemaForGemini(schema: Record<string, unknown>): unknown {
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) {
const normalized = provider?.trim().toLowerCase();
return normalized === "openai" || normalized === "openai-codex";
@@ -347,8 +357,8 @@ function isToolAllowedByPolicyName(
policy?: SandboxToolPolicy,
): boolean {
if (!policy) return true;
const deny = new Set(expandToolGroups(policy.deny));
const allowRaw = expandToolGroups(policy.allow);
const deny = new Set(normalizeToolNames(policy.deny));
const allowRaw = normalizeToolNames(policy.allow);
const allow = allowRaw.length > 0 ? new Set(allowRaw) : null;
const normalized = normalizeToolName(name);
if (deny.has(normalized)) return false;
@@ -381,15 +391,11 @@ function resolveEffectiveToolPolicy(params: {
: undefined;
const agentTools = agentConfig?.tools;
const hasAgentToolPolicy =
Array.isArray(agentTools?.allow) ||
Array.isArray(agentTools?.deny) ||
typeof agentTools?.profile === "string";
Array.isArray(agentTools?.allow) || Array.isArray(agentTools?.deny);
const globalTools = params.config?.tools;
const profile = agentTools?.profile ?? globalTools?.profile;
return {
agentId,
policy: hasAgentToolPolicy ? agentTools : globalTools,
profile,
};
}
@@ -539,7 +545,6 @@ function createSandboxedEditTool(root: string) {
root,
);
}
// Normalize tool parameters from Claude Code conventions to pi-coding-agent conventions.
// Claude Code uses file_path/old_string/new_string while pi-coding-agent uses path/oldText/newText.
// This prevents models trained on Claude Code from getting stuck in tool-call loops.
@@ -697,15 +702,10 @@ export function createClawdbotCodingTools(options?: {
}): AnyAgentTool[] {
const execToolName = "exec";
const sandbox = options?.sandbox?.enabled ? options.sandbox : undefined;
const {
agentId,
policy: effectiveToolsPolicy,
profile,
} = resolveEffectiveToolPolicy({
const { agentId, policy: effectiveToolsPolicy } = resolveEffectiveToolPolicy({
config: options?.config,
sessionKey: options?.sessionKey,
});
const profilePolicy = resolveToolProfilePolicy(profile);
const scopeKey =
options?.exec?.scopeKey ?? (agentId ? `agent:${agentId}` : undefined);
const subagentPolicy =
@@ -713,7 +713,6 @@ export function createClawdbotCodingTools(options?: {
? resolveSubagentToolPolicy(options.config)
: undefined;
const allowBackground = isToolAllowedByPolicies("process", [
profilePolicy,
effectiveToolsPolicy,
sandbox?.tools,
subagentPolicy,
@@ -829,15 +828,12 @@ export function createClawdbotCodingTools(options?: {
hasRepliedRef: options?.hasRepliedRef,
}),
];
const toolsFiltered = profilePolicy
? filterToolsByPolicy(tools, profilePolicy)
const toolsFiltered = effectiveToolsPolicy
? filterToolsByPolicy(tools, effectiveToolsPolicy)
: tools;
const policyFiltered = effectiveToolsPolicy
? filterToolsByPolicy(toolsFiltered, effectiveToolsPolicy)
: toolsFiltered;
const sandboxed = sandbox
? filterToolsByPolicy(policyFiltered, sandbox.tools)
: policyFiltered;
? filterToolsByPolicy(toolsFiltered, sandbox.tools)
: toolsFiltered;
const subagentFiltered = subagentPolicy
? filterToolsByPolicy(sandboxed, subagentPolicy)
: sandboxed;
+2 -2
View File
@@ -65,7 +65,7 @@ describe("sandbox explain helpers", () => {
]);
});
it("denies still win after group expansion", () => {
it("supports legacy 'memory' shorthand and deny wins after expansion", () => {
const cfg: ClawdbotConfig = {
agents: {
defaults: {
@@ -75,7 +75,7 @@ describe("sandbox explain helpers", () => {
tools: {
sandbox: {
tools: {
allow: ["group:memory"],
allow: ["memory"],
deny: ["memory_get"],
},
},
+55 -5
View File
@@ -33,7 +33,6 @@ import {
resolveSessionAgentId,
} from "./agent-scope.js";
import { syncSkillsToWorkspace } from "./skills.js";
import { expandToolGroups } from "./tool-policy.js";
import {
DEFAULT_AGENT_WORKSPACE_DIR,
DEFAULT_AGENTS_FILENAME,
@@ -240,10 +239,61 @@ const BROWSER_BRIDGES = new Map<
{ 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();
// Back-compat shorthand: "memory" => "group:memory"
if (lower === "memory") return TOOL_GROUPS["group:memory"];
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) {
const deny = new Set(expandToolGroups(policy.deny));
const deny = new Set(normalizeToolList(expandToolGroups(policy.deny)));
if (deny.has(name.toLowerCase())) return false;
const allow = expandToolGroups(policy.allow);
const allow = normalizeToolList(expandToolGroups(policy.allow));
if (allow.length === 0) return true;
return allow.includes(name.toLowerCase());
}
@@ -640,8 +690,8 @@ export function formatSandboxToolPolicyBlockedMessage(params: {
});
if (!runtime.sandboxed) return undefined;
const deny = new Set(expandToolGroups(runtime.toolPolicy.deny));
const allow = expandToolGroups(runtime.toolPolicy.allow);
const deny = new Set(normalizeToolList(runtime.toolPolicy.deny));
const allow = normalizeToolList(runtime.toolPolicy.allow);
const allowSet = allow.length > 0 ? new Set(allow) : null;
const blockedByDeny = deny.has(tool);
const blockedByAllow = allowSet ? !allowSet.has(tool) : false;
+8 -100
View File
@@ -67,39 +67,6 @@ function tryFlattenLiteralAnyOf(
return null;
}
function isNullSchema(variant: unknown): boolean {
if (!variant || typeof variant !== "object" || Array.isArray(variant)) {
return false;
}
const record = variant as Record<string, unknown>;
if ("const" in record && record.const === null) return true;
if (Array.isArray(record.enum) && record.enum.length === 1) {
return record.enum[0] === null;
}
const typeValue = record.type;
if (typeValue === "null") return true;
if (
Array.isArray(typeValue) &&
typeValue.length === 1 &&
typeValue[0] === "null"
) {
return true;
}
return false;
}
function stripNullVariants(variants: unknown[]): {
variants: unknown[];
stripped: boolean;
} {
if (variants.length === 0) return { variants, stripped: false };
const nonNull = variants.filter((variant) => !isNullSchema(variant));
return {
variants: nonNull,
stripped: nonNull.length !== variants.length,
};
}
type SchemaDefs = Map<string, unknown>;
function extendSchemaDefs(
@@ -199,24 +166,9 @@ function cleanSchemaForGeminiWithDefs(
const hasAnyOf = "anyOf" in obj && Array.isArray(obj.anyOf);
const hasOneOf = "oneOf" in obj && Array.isArray(obj.oneOf);
let cleanedAnyOf = hasAnyOf
? (obj.anyOf as unknown[]).map((variant) =>
cleanSchemaForGeminiWithDefs(variant, nextDefs, refStack),
)
: undefined;
let cleanedOneOf = hasOneOf
? (obj.oneOf as unknown[]).map((variant) =>
cleanSchemaForGeminiWithDefs(variant, nextDefs, refStack),
)
: undefined;
if (hasAnyOf) {
const { variants: nonNullVariants, stripped } = stripNullVariants(
cleanedAnyOf ?? [],
);
if (stripped) cleanedAnyOf = nonNullVariants;
const flattened = tryFlattenLiteralAnyOf(nonNullVariants);
const flattened = tryFlattenLiteralAnyOf(obj.anyOf as unknown[]);
if (flattened) {
const result: Record<string, unknown> = {
type: flattened.type,
@@ -227,28 +179,10 @@ function cleanSchemaForGeminiWithDefs(
}
return result;
}
if (stripped && nonNullVariants.length === 1) {
const lone = nonNullVariants[0];
if (lone && typeof lone === "object" && !Array.isArray(lone)) {
const result: Record<string, unknown> = {
...(lone as Record<string, unknown>),
};
for (const key of ["description", "title", "default"]) {
if (key in obj && obj[key] !== undefined) result[key] = obj[key];
}
return result;
}
return lone;
}
}
if (hasOneOf) {
const { variants: nonNullVariants, stripped } = stripNullVariants(
cleanedOneOf ?? [],
);
if (stripped) cleanedOneOf = nonNullVariants;
const flattened = tryFlattenLiteralAnyOf(nonNullVariants);
const flattened = tryFlattenLiteralAnyOf(obj.oneOf as unknown[]);
if (flattened) {
const result: Record<string, unknown> = {
type: flattened.type,
@@ -259,19 +193,6 @@ function cleanSchemaForGeminiWithDefs(
}
return result;
}
if (stripped && nonNullVariants.length === 1) {
const lone = nonNullVariants[0];
if (lone && typeof lone === "object" && !Array.isArray(lone)) {
const result: Record<string, unknown> = {
...(lone as Record<string, unknown>),
};
for (const key of ["description", "title", "default"]) {
if (key in obj && obj[key] !== undefined) result[key] = obj[key];
}
return result;
}
return lone;
}
}
const cleaned: Record<string, unknown> = {};
@@ -285,15 +206,6 @@ function cleanSchemaForGeminiWithDefs(
}
if (key === "type" && (hasAnyOf || hasOneOf)) continue;
if (
key === "type" &&
Array.isArray(value) &&
value.every((entry) => typeof entry === "string")
) {
const types = value.filter((entry) => entry !== "null");
cleaned.type = types.length === 1 ? types[0] : types;
continue;
}
if (key === "properties" && value && typeof value === "object") {
const props = value as Record<string, unknown>;
@@ -306,17 +218,13 @@ function cleanSchemaForGeminiWithDefs(
} else if (key === "items" && value && typeof value === "object") {
cleaned[key] = cleanSchemaForGeminiWithDefs(value, nextDefs, refStack);
} else if (key === "anyOf" && Array.isArray(value)) {
cleaned[key] =
cleanedAnyOf ??
value.map((variant) =>
cleanSchemaForGeminiWithDefs(variant, nextDefs, refStack),
);
cleaned[key] = value.map((variant) =>
cleanSchemaForGeminiWithDefs(variant, nextDefs, refStack),
);
} else if (key === "oneOf" && Array.isArray(value)) {
cleaned[key] =
cleanedOneOf ??
value.map((variant) =>
cleanSchemaForGeminiWithDefs(variant, nextDefs, refStack),
);
cleaned[key] = value.map((variant) =>
cleanSchemaForGeminiWithDefs(variant, nextDefs, refStack),
);
} else if (key === "allOf" && Array.isArray(value)) {
cleaned[key] = value.map((variant) =>
cleanSchemaForGeminiWithDefs(variant, nextDefs, refStack),
-27
View File
@@ -1,27 +0,0 @@
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));
}
-182
View File
@@ -1,182 +0,0 @@
import type { ModelDefinitionConfig } from "../config/types.js";
export const SYNTHETIC_BASE_URL = "https://api.synthetic.new/anthropic";
export const SYNTHETIC_DEFAULT_MODEL_ID = "hf:MiniMaxAI/MiniMax-M2.1";
export const SYNTHETIC_DEFAULT_MODEL_REF = `synthetic/${SYNTHETIC_DEFAULT_MODEL_ID}`;
export const SYNTHETIC_DEFAULT_COST = {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
};
export const SYNTHETIC_MODEL_CATALOG = [
{
id: SYNTHETIC_DEFAULT_MODEL_ID,
name: "MiniMax M2.1",
reasoning: false,
input: ["text"],
contextWindow: 192000,
maxTokens: 65536,
},
{
id: "hf:moonshotai/Kimi-K2-Thinking",
name: "Kimi K2 Thinking",
reasoning: true,
input: ["text"],
contextWindow: 256000,
maxTokens: 8192,
},
{
id: "hf:zai-org/GLM-4.7",
name: "GLM-4.7",
reasoning: false,
input: ["text"],
contextWindow: 198000,
maxTokens: 128000,
},
{
id: "hf:deepseek-ai/DeepSeek-R1-0528",
name: "DeepSeek R1 0528",
reasoning: false,
input: ["text"],
contextWindow: 128000,
maxTokens: 8192,
},
{
id: "hf:deepseek-ai/DeepSeek-V3-0324",
name: "DeepSeek V3 0324",
reasoning: false,
input: ["text"],
contextWindow: 128000,
maxTokens: 8192,
},
{
id: "hf:deepseek-ai/DeepSeek-V3.1",
name: "DeepSeek V3.1",
reasoning: false,
input: ["text"],
contextWindow: 128000,
maxTokens: 8192,
},
{
id: "hf:deepseek-ai/DeepSeek-V3.1-Terminus",
name: "DeepSeek V3.1 Terminus",
reasoning: false,
input: ["text"],
contextWindow: 128000,
maxTokens: 8192,
},
{
id: "hf:deepseek-ai/DeepSeek-V3.2",
name: "DeepSeek V3.2",
reasoning: false,
input: ["text"],
contextWindow: 159000,
maxTokens: 8192,
},
{
id: "hf:meta-llama/Llama-3.3-70B-Instruct",
name: "Llama 3.3 70B Instruct",
reasoning: false,
input: ["text"],
contextWindow: 128000,
maxTokens: 8192,
},
{
id: "hf:meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8",
name: "Llama 4 Maverick 17B 128E Instruct FP8",
reasoning: false,
input: ["text"],
contextWindow: 524000,
maxTokens: 8192,
},
{
id: "hf:moonshotai/Kimi-K2-Instruct-0905",
name: "Kimi K2 Instruct 0905",
reasoning: false,
input: ["text"],
contextWindow: 256000,
maxTokens: 8192,
},
{
id: "hf:openai/gpt-oss-120b",
name: "GPT OSS 120B",
reasoning: false,
input: ["text"],
contextWindow: 128000,
maxTokens: 8192,
},
{
id: "hf:Qwen/Qwen3-235B-A22B-Instruct-2507",
name: "Qwen3 235B A22B Instruct 2507",
reasoning: false,
input: ["text"],
contextWindow: 256000,
maxTokens: 8192,
},
{
id: "hf:Qwen/Qwen3-Coder-480B-A35B-Instruct",
name: "Qwen3 Coder 480B A35B Instruct",
reasoning: false,
input: ["text"],
contextWindow: 256000,
maxTokens: 8192,
},
{
id: "hf:Qwen/Qwen3-VL-235B-A22B-Instruct",
name: "Qwen3 VL 235B A22B Instruct",
reasoning: false,
input: ["text", "image"],
contextWindow: 250000,
maxTokens: 8192,
},
{
id: "hf:zai-org/GLM-4.5",
name: "GLM-4.5",
reasoning: false,
input: ["text"],
contextWindow: 128000,
maxTokens: 128000,
},
{
id: "hf:zai-org/GLM-4.6",
name: "GLM-4.6",
reasoning: false,
input: ["text"],
contextWindow: 198000,
maxTokens: 128000,
},
{
id: "hf:deepseek-ai/DeepSeek-V3",
name: "DeepSeek V3",
reasoning: false,
input: ["text"],
contextWindow: 128000,
maxTokens: 8192,
},
{
id: "hf:Qwen/Qwen3-235B-A22B-Thinking-2507",
name: "Qwen3 235B A22B Thinking 2507",
reasoning: true,
input: ["text"],
contextWindow: 256000,
maxTokens: 8192,
},
] as const;
export type SyntheticCatalogEntry = (typeof SYNTHETIC_MODEL_CATALOG)[number];
export function buildSyntheticModelDefinition(
entry: SyntheticCatalogEntry,
): ModelDefinitionConfig {
return {
id: entry.id,
name: entry.name,
reasoning: entry.reasoning,
input: [...entry.input],
cost: SYNTHETIC_DEFAULT_COST,
contextWindow: entry.contextWindow,
maxTokens: entry.maxTokens,
};
}
+2 -11
View File
@@ -60,7 +60,7 @@ export function buildAgentSystemPrompt(params: {
browser: "Control web browser",
canvas: "Present/eval/snapshot the Canvas",
nodes: "List/describe/notify/camera/screen on paired nodes",
cron: "Manage cron jobs and wake events (use for reminders)",
cron: "Manage cron jobs and wake events",
message: "Send messages and provider actions",
gateway:
"Restart, apply config, or run updates on the running Clawdbot process",
@@ -183,14 +183,6 @@ export function buildAgentSystemPrompt(params: {
"",
]
: [];
const memorySection =
availableTools.has("memory_search") || availableTools.has("memory_get")
? [
"## Memory Recall",
"Before answering anything about prior work, decisions, dates, people, preferences, or todos: run memory_search on MEMORY.md + memory/*.md; then use memory_get to pull only the needed lines. If low confidence after search, say you checked.",
"",
]
: [];
const lines = [
"You are a personal assistant running inside Clawdbot.",
@@ -211,7 +203,7 @@ export function buildAgentSystemPrompt(params: {
"- browser: control clawd's dedicated browser",
"- canvas: present/eval/snapshot the Canvas",
"- nodes: list/describe/notify/camera/screen on paired nodes",
"- cron: manage cron jobs and wake events (use for reminders)",
"- cron: manage cron jobs and wake events",
"- sessions_list: list sessions",
"- sessions_history: fetch session history",
"- sessions_send: send to another session",
@@ -220,7 +212,6 @@ export function buildAgentSystemPrompt(params: {
"If a task is more complex or takes longer, spawn a sub-agent. It will do the work for you and ping you when it's done. You can always check up on it.",
"",
...skillsSection,
...memorySection,
hasGateway ? "## Clawdbot Self-Update" : "",
hasGateway
? [
-38
View File
@@ -1,38 +0,0 @@
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
View File
@@ -1,116 +0,0 @@
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,
};
}
-105
View File
@@ -1,105 +0,0 @@
import { afterEach, describe, expect, it, vi } from "vitest";
const browserClientMocks = vi.hoisted(() => ({
browserCloseTab: vi.fn(async () => ({})),
browserFocusTab: vi.fn(async () => ({})),
browserOpenTab: vi.fn(async () => ({})),
browserSnapshot: vi.fn(async () => ({
ok: true,
format: "ai",
targetId: "t1",
url: "https://example.com",
snapshot: "ok",
})),
browserStart: vi.fn(async () => ({})),
browserStatus: vi.fn(async () => ({
ok: true,
running: true,
pid: 1,
cdpPort: 18792,
cdpUrl: "http://127.0.0.1:18792",
})),
browserStop: vi.fn(async () => ({})),
browserTabs: vi.fn(async () => []),
}));
vi.mock("../../browser/client.js", () => browserClientMocks);
const browserConfigMocks = vi.hoisted(() => ({
resolveBrowserConfig: vi.fn(() => ({
enabled: true,
controlUrl: "http://127.0.0.1:18791",
controlHost: "127.0.0.1",
controlPort: 18791,
cdpProtocol: "http",
cdpHost: "127.0.0.1",
cdpIsLoopback: true,
color: "#FF0000",
headless: true,
noSandbox: false,
attachOnly: false,
defaultProfile: "clawd",
profiles: {
clawd: {
cdpPort: 18792,
color: "#FF0000",
},
},
})),
}));
vi.mock("../../browser/config.js", () => browserConfigMocks);
vi.mock("../../config/config.js", () => ({
loadConfig: vi.fn(() => ({ browser: {} })),
}));
import { DEFAULT_AI_SNAPSHOT_MAX_CHARS } from "../../browser/constants.js";
import { createBrowserTool } from "./browser-tool.js";
describe("browser tool snapshot maxChars", () => {
afterEach(() => {
vi.clearAllMocks();
});
it("applies the default ai snapshot limit", async () => {
const tool = createBrowserTool();
await tool.execute?.(null, { action: "snapshot", format: "ai" });
expect(browserClientMocks.browserSnapshot).toHaveBeenCalledWith(
"http://127.0.0.1:18791",
expect.objectContaining({
format: "ai",
maxChars: DEFAULT_AI_SNAPSHOT_MAX_CHARS,
}),
);
});
it("respects an explicit maxChars override", async () => {
const tool = createBrowserTool();
const override = 2_000;
await tool.execute?.(null, {
action: "snapshot",
format: "ai",
maxChars: override,
});
expect(browserClientMocks.browserSnapshot).toHaveBeenCalledWith(
"http://127.0.0.1:18791",
expect.objectContaining({
maxChars: override,
}),
);
});
it("skips the default when maxChars is explicitly zero", async () => {
const tool = createBrowserTool();
await tool.execute?.(null, {
action: "snapshot",
format: "ai",
maxChars: 0,
});
expect(browserClientMocks.browserSnapshot).toHaveBeenCalled();
const [, opts] = browserClientMocks.browserSnapshot.mock.calls.at(-1) ?? [];
expect(Object.hasOwn(opts ?? {}, "maxChars")).toBe(false);
});
});
+34 -36
View File
@@ -20,9 +20,7 @@ import {
browserScreenshotAction,
} from "../../browser/client-actions.js";
import { resolveBrowserConfig } from "../../browser/config.js";
import { DEFAULT_AI_SNAPSHOT_MAX_CHARS } from "../../browser/constants.js";
import { loadConfig } from "../../config/config.js";
import { optionalStringEnum, stringEnum } from "../schema/typebox.js";
import {
type AnyAgentTool,
imageResultFromFile,
@@ -44,35 +42,18 @@ const BROWSER_ACT_KINDS = [
"close",
] as const;
const BROWSER_TOOL_ACTIONS = [
"status",
"start",
"stop",
"tabs",
"open",
"focus",
"close",
"snapshot",
"screenshot",
"navigate",
"console",
"pdf",
"upload",
"dialog",
"act",
] as const;
type BrowserActKind = (typeof BROWSER_ACT_KINDS)[number];
const BROWSER_TARGETS = ["sandbox", "host", "custom"] as const;
const BROWSER_SNAPSHOT_FORMATS = ["aria", "ai"] as const;
const BROWSER_IMAGE_TYPES = ["png", "jpeg"] as const;
const DEFAULT_AI_SNAPSHOT_MAX_CHARS = 80_000;
// 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.
// The discriminator (kind) determines which properties are relevant; runtime validates.
const BrowserActSchema = Type.Object({
kind: stringEnum(BROWSER_ACT_KINDS),
kind: Type.Unsafe<BrowserActKind>({
type: "string",
enum: [...BROWSER_ACT_KINDS],
}),
// Common fields
targetId: Type.Optional(Type.String()),
ref: Type.Optional(Type.String()),
@@ -109,15 +90,37 @@ const BrowserActSchema = Type.Object({
// A root-level `Type.Union([...])` compiles to `{ anyOf: [...] }` (no `type`),
// which OpenAI rejects ("Invalid schema ... type: None"). Keep this schema an object.
const BrowserToolSchema = Type.Object({
action: stringEnum(BROWSER_TOOL_ACTIONS),
target: optionalStringEnum(BROWSER_TARGETS),
action: Type.Union([
Type.Literal("status"),
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()),
controlUrl: Type.Optional(Type.String()),
targetUrl: Type.Optional(Type.String()),
targetId: Type.Optional(Type.String()),
limit: Type.Optional(Type.Number()),
maxChars: Type.Optional(Type.Number()),
format: optionalStringEnum(BROWSER_SNAPSHOT_FORMATS),
format: Type.Optional(Type.Union([Type.Literal("aria"), Type.Literal("ai")])),
interactive: Type.Optional(Type.Boolean()),
compact: Type.Optional(Type.Boolean()),
depth: Type.Optional(Type.Number()),
@@ -126,7 +129,7 @@ const BrowserToolSchema = Type.Object({
fullPage: Type.Optional(Type.Boolean()),
ref: Type.Optional(Type.String()),
element: Type.Optional(Type.String()),
type: optionalStringEnum(BROWSER_IMAGE_TYPES),
type: Type.Optional(Type.Union([Type.Literal("png"), Type.Literal("jpeg")])),
level: Type.Optional(Type.String()),
paths: Type.Optional(Type.Array(Type.String())),
inputRef: Type.Optional(Type.String()),
@@ -320,7 +323,6 @@ export function createBrowserTool(opts?: {
params.format === "ai" || params.format === "aria"
? (params.format as "ai" | "aria")
: "ai";
const hasMaxChars = Object.hasOwn(params, "maxChars");
const targetId =
typeof params.targetId === "string"
? params.targetId.trim()
@@ -337,9 +339,7 @@ export function createBrowserTool(opts?: {
: undefined;
const resolvedMaxChars =
format === "ai"
? hasMaxChars
? maxChars
: DEFAULT_AI_SNAPSHOT_MAX_CHARS
? (maxChars ?? DEFAULT_AI_SNAPSHOT_MAX_CHARS)
: undefined;
const interactive =
typeof params.interactive === "boolean"
@@ -361,9 +361,7 @@ export function createBrowserTool(opts?: {
format,
targetId,
limit,
...(typeof resolvedMaxChars === "number"
? { maxChars: resolvedMaxChars }
: {}),
...(resolvedMaxChars ? { maxChars: resolvedMaxChars } : {}),
interactive,
compact,
depth,
Loaded 100 of 254 files, more files were not shown because too many files have changed in this diff. Show more