Compare commits
227 Commits
pr-18304
...
split/gate
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d4b640499 | ||
|
|
f83476364d | ||
|
|
551efb9fc9 | ||
|
|
5ef811771a | ||
|
|
fa90b3c92b | ||
|
|
fec4be8dec | ||
|
|
095d522099 | ||
|
|
6931f0fb50 | ||
|
|
b4fa10ae67 | ||
|
|
7b8cce0910 | ||
|
|
5b8bfd261b | ||
|
|
f4b2fd00bc | ||
|
|
dddb1bc942 | ||
|
|
553d17f8af | ||
|
|
e3e8046a93 | ||
|
|
cb391f4bdc | ||
|
|
3a277e394e | ||
|
|
d224776ffb | ||
|
|
c2a0cf0c28 | ||
|
|
6e8ed7af3a | ||
|
|
39bb1b3322 | ||
|
|
5ccabe9e63 | ||
|
|
aab3c4d2f0 | ||
|
|
244ed9db39 | ||
|
|
b2aa6e094d | ||
|
|
bc67af6ad8 | ||
|
|
d841c9b26b | ||
|
|
597f956a4f | ||
|
|
f043f2d8c9 | ||
|
|
a4e7f256db | ||
|
|
893f56b87d | ||
|
|
4da68afc73 | ||
|
|
7cfd0aed5f | ||
|
|
d611db8049 | ||
|
|
3eb9c2105c | ||
|
|
9f6462bd56 | ||
|
|
2d03473072 | ||
|
|
dbcdcc5d19 | ||
|
|
c4297a8d60 | ||
|
|
deef9f91bf | ||
|
|
523193a91f | ||
|
|
cd04385f9f | ||
|
|
82fa526bb0 | ||
|
|
3fb4a7eb53 | ||
|
|
7a6928712b | ||
|
|
9b351fcbd8 | ||
|
|
d3ddf893c2 | ||
|
|
a597bd26d4 | ||
|
|
6fa150a890 | ||
|
|
93ad783c1b | ||
|
|
acc6b62289 | ||
|
|
fec1566f04 | ||
|
|
ced5148afd | ||
|
|
c0973f24c6 | ||
|
|
6a392b8493 | ||
|
|
f8ae538985 | ||
|
|
b63f9b7066 | ||
|
|
4e3429ae6e | ||
|
|
78976d3f6f | ||
|
|
e30900f93e | ||
|
|
cef02df9d5 | ||
|
|
0f7ad51020 | ||
|
|
4e16893c61 | ||
|
|
192dbc3ba9 | ||
|
|
d0b0ca9fcf | ||
|
|
22c53af604 | ||
|
|
54948a1d44 | ||
|
|
22a1a56e7e | ||
|
|
15f8c57797 | ||
|
|
404a8bc35f | ||
|
|
7a4c131d6b | ||
|
|
b156aafab9 | ||
|
|
838d875fcb | ||
|
|
7932387df2 | ||
|
|
4d2ba58da5 | ||
|
|
7d26eae3ee | ||
|
|
5dc02aa55e | ||
|
|
c8704297b2 | ||
|
|
eb7b5c02c3 | ||
|
|
314f193030 | ||
|
|
d5bc5ab7ba | ||
|
|
fecd623431 | ||
|
|
1e4cf489e0 | ||
|
|
5d8f43ae8e | ||
|
|
896f9efcb7 | ||
|
|
f448e4bf77 | ||
|
|
ada7a6289f | ||
|
|
731d72e119 | ||
|
|
bf801f5159 | ||
|
|
929a96c2f8 | ||
|
|
2983ef0243 | ||
|
|
b5183c93d6 | ||
|
|
bd0e7d3d22 | ||
|
|
19dfdfe5a8 | ||
|
|
2d6b605cc3 | ||
|
|
025d4152d1 | ||
|
|
f9419e26bb | ||
|
|
a4f86dc433 | ||
|
|
0c035c85ab | ||
|
|
aabc09bb9b | ||
|
|
0d2e13fb73 | ||
|
|
4f05d045b9 | ||
|
|
3daaa19426 | ||
|
|
ec00efb38d | ||
|
|
83a5f7ba8c | ||
|
|
6a759c9191 | ||
|
|
f6b7736744 | ||
|
|
b6a9741ba4 | ||
|
|
1f607bec49 | ||
|
|
3dbb69da05 | ||
|
|
49d383ba7c | ||
|
|
e72e8ebe62 | ||
|
|
374ad8c813 | ||
|
|
6f4da72cb5 | ||
|
|
facf53cc3f | ||
|
|
eaec65656f | ||
|
|
dfaca933c6 | ||
|
|
aaf308d7ec | ||
|
|
d115d48a72 | ||
|
|
d174c38737 | ||
|
|
005dbdd13e | ||
|
|
c31e33cd18 | ||
|
|
f4fbfae97e | ||
|
|
f349d40e62 | ||
|
|
d71779b46f | ||
|
|
df062fdb63 | ||
|
|
52ddaed795 | ||
|
|
b7a20d8e8d | ||
|
|
5cb228fdd0 | ||
|
|
8fd6d4d6dd | ||
|
|
242e8f5c43 | ||
|
|
4aab640fd1 | ||
|
|
35b6ccd62c | ||
|
|
7e1f542233 | ||
|
|
5927c53630 | ||
|
|
8505577218 | ||
|
|
b18b85dc77 | ||
|
|
f3eb003db9 | ||
|
|
0448693f8f | ||
|
|
e86647889c | ||
|
|
993a5e63a1 | ||
|
|
c01e97f124 | ||
|
|
bf2d78505e | ||
|
|
91337b4b6f | ||
|
|
f74d56bd3b | ||
|
|
56d0ad6942 | ||
|
|
5997a4b0ef | ||
|
|
720aa3c1e6 | ||
|
|
223e2a7127 | ||
|
|
31ab8ad46d | ||
|
|
82a8fc0bc7 | ||
|
|
227e31d791 | ||
|
|
357b1e8fee | ||
|
|
4c46c23ca8 | ||
|
|
189b2e0588 | ||
|
|
a39c2263e5 | ||
|
|
0490d0e173 | ||
|
|
64a0339d58 | ||
|
|
077130bdb8 | ||
|
|
12d6b3b0c9 | ||
|
|
3028a1bd3e | ||
|
|
57e055ddb5 | ||
|
|
4fd008e918 | ||
|
|
d39b8541f8 | ||
|
|
ac4183edd7 | ||
|
|
838963d66c | ||
|
|
4852dd4503 | ||
|
|
4d1cb661fc | ||
|
|
3bd961f00a | ||
|
|
583345fdfe | ||
|
|
3d550ed4c3 | ||
|
|
c37cc5ffad | ||
|
|
b83ccfba13 | ||
|
|
8ea890e8fb | ||
|
|
ae6060d777 | ||
|
|
ec708b6ab5 | ||
|
|
944a32cf02 | ||
|
|
c4880675e1 | ||
|
|
8b6537d857 | ||
|
|
12c3821acb | ||
|
|
a69c06e3cc | ||
|
|
67aa7eefe5 | ||
|
|
425c715a05 | ||
|
|
dcba3e5699 | ||
|
|
27083e6f1a | ||
|
|
18bb242316 | ||
|
|
68ea063958 | ||
|
|
eefda1314f | ||
|
|
a8a22920f1 | ||
|
|
a8084b24d6 | ||
|
|
97d5ff3500 | ||
|
|
abb7618b0f | ||
|
|
1ec0f3b81d | ||
|
|
6c3e7896c5 | ||
|
|
2a5fa426f2 | ||
|
|
29203884c2 | ||
|
|
91e120870f | ||
|
|
6a9ead3813 | ||
|
|
cb998aa7f9 | ||
|
|
ac02e45a88 | ||
|
|
8f603ec03d | ||
|
|
84e0ee3c31 | ||
|
|
da2bdbef7e | ||
|
|
1be7c4ba8e | ||
|
|
a82df1015b | ||
|
|
a0b459b8f9 | ||
|
|
28118ca051 | ||
|
|
d374a64658 | ||
|
|
0895bb6de6 | ||
|
|
189cba0100 | ||
|
|
108ebc380f | ||
|
|
93e62d8e3e | ||
|
|
ed28ad2822 | ||
|
|
00bbddeef5 | ||
|
|
5ac59e6e02 | ||
|
|
bfb5a44089 | ||
|
|
599195fb31 | ||
|
|
705d83aec7 | ||
|
|
c80017e704 | ||
|
|
9e67f9d889 | ||
|
|
9383f85046 | ||
|
|
5212d1c79e | ||
|
|
7aa7b04fb0 | ||
|
|
b3d3f36360 | ||
|
|
2b6f8548c9 | ||
|
|
9684ae4c6d | ||
|
|
39fa81dc96 |
10
.env.example
10
.env.example
@@ -37,16 +37,6 @@ OPENCLAW_GATEWAY_TOKEN=change-me-to-a-long-random-token
|
||||
# ANTHROPIC_API_KEY=sk-ant-...
|
||||
# GEMINI_API_KEY=...
|
||||
# OPENROUTER_API_KEY=sk-or-...
|
||||
# OPENCLAW_LIVE_OPENAI_KEY=sk-...
|
||||
# OPENCLAW_LIVE_ANTHROPIC_KEY=sk-ant-...
|
||||
# OPENCLAW_LIVE_GEMINI_KEY=...
|
||||
# OPENAI_API_KEY_1=...
|
||||
# ANTHROPIC_API_KEY_1=...
|
||||
# GEMINI_API_KEY_1=...
|
||||
# GOOGLE_API_KEY=...
|
||||
# OPENAI_API_KEYS=sk-1,sk-2
|
||||
# ANTHROPIC_API_KEYS=sk-ant-1,sk-ant-2
|
||||
# GEMINI_API_KEYS=key-1,key-2
|
||||
|
||||
# Optional additional providers
|
||||
# ZAI_API_KEY=...
|
||||
|
||||
11
.github/workflows/ci.yml
vendored
11
.github/workflows/ci.yml
vendored
@@ -6,14 +6,14 @@ on:
|
||||
pull_request:
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.event.pull_request.number || github.sha }}
|
||||
cancel-in-progress: true
|
||||
group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
# Detect docs-only changes to skip heavy jobs (test, build, Windows, macOS, Android).
|
||||
# Lint and format always run. Fail-safe: if detection fails, run everything.
|
||||
docs-scope:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
outputs:
|
||||
docs_only: ${{ steps.check.outputs.docs_only }}
|
||||
docs_changed: ${{ steps.check.outputs.docs_changed }}
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
changed-scope:
|
||||
needs: [docs-scope]
|
||||
if: needs.docs-scope.outputs.docs_only != 'true'
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
outputs:
|
||||
run_node: ${{ steps.scope.outputs.run_node }}
|
||||
run_macos: ${{ steps.scope.outputs.run_macos }}
|
||||
@@ -672,7 +672,8 @@ jobs:
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 21
|
||||
# setup-android's sdkmanager currently crashes on JDK 21 in CI.
|
||||
java-version: 17
|
||||
|
||||
- name: Setup Android SDK
|
||||
uses: android-actions/setup-android@v3
|
||||
|
||||
4
.github/workflows/docker-release.yml
vendored
4
.github/workflows/docker-release.yml
vendored
@@ -13,6 +13,10 @@ on:
|
||||
- ".agents/**"
|
||||
- "skills/**"
|
||||
|
||||
concurrency:
|
||||
group: docker-release-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
4
.github/workflows/install-smoke.yml
vendored
4
.github/workflows/install-smoke.yml
vendored
@@ -7,8 +7,8 @@ on:
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: install-smoke-${{ github.event.pull_request.number || github.sha }}
|
||||
cancel-in-progress: true
|
||||
group: install-smoke-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
docs-scope:
|
||||
|
||||
4
.github/workflows/sandbox-common-smoke.yml
vendored
4
.github/workflows/sandbox-common-smoke.yml
vendored
@@ -14,8 +14,8 @@ on:
|
||||
- scripts/sandbox-common-setup.sh
|
||||
|
||||
concurrency:
|
||||
group: sandbox-common-smoke-${{ github.event.pull_request.number || github.sha }}
|
||||
cancel-in-progress: true
|
||||
group: sandbox-common-smoke-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
sandbox-common-smoke:
|
||||
|
||||
4
.github/workflows/workflow-sanity.yml
vendored
4
.github/workflows/workflow-sanity.yml
vendored
@@ -6,8 +6,8 @@ on:
|
||||
branches: [main]
|
||||
|
||||
concurrency:
|
||||
group: workflow-sanity-${{ github.event.pull_request.number || github.sha }}
|
||||
cancel-in-progress: true
|
||||
group: workflow-sanity-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
no-tabs:
|
||||
|
||||
@@ -213,7 +213,7 @@
|
||||
- skip if package is missing on npm or version already matches.
|
||||
- Keep `openclaw` untouched: never run publish from repo root unless explicitly requested.
|
||||
- Post-check for each release:
|
||||
- per-plugin: `npm view @openclaw/<name> version --userconfig "$(mktemp)"` should be `2026.2.15`
|
||||
- per-plugin: `npm view @openclaw/<name> version --userconfig "$(mktemp)"` should be `2026.2.16`
|
||||
- core guard: `npm view openclaw version --userconfig "$(mktemp)"` should stay at previous version unless explicitly requested.
|
||||
|
||||
## Changelog Release Notes
|
||||
|
||||
13
CHANGELOG.md
13
CHANGELOG.md
@@ -2,7 +2,7 @@
|
||||
|
||||
Docs: https://docs.openclaw.ai
|
||||
|
||||
## 2026.2.15 (Unreleased)
|
||||
## 2026.2.16 (Unreleased)
|
||||
|
||||
### Changes
|
||||
|
||||
@@ -12,11 +12,13 @@ Docs: https://docs.openclaw.ai
|
||||
- Subagents: nested sub-agents (sub-sub-agents) with configurable depth. Set `agents.defaults.subagents.maxSpawnDepth: 2` to allow sub-agents to spawn their own children. Includes `maxChildrenPerAgent` limit (default 5), depth-aware tool policy, and proper announce chain routing. (#14447) Thanks @tyler6204.
|
||||
- Slack/Discord/Telegram: add per-channel ack reaction overrides (account/channel-level) to support platform-specific emoji formats. (#17092) Thanks @zerone0x.
|
||||
- Cron/Gateway: add finished-run webhook delivery toggle (`notify`) and dedicated webhook auth token support (`cron.webhookToken`) for outbound cron webhook posts. (#14535) Thanks @advaitpaliwal.
|
||||
- Cron/Gateway: separate per-job webhook delivery (`delivery.mode = "webhook"`) from announce delivery, enforce valid HTTP(S) webhook URLs, and keep a temporary legacy `notify + cron.webhook` fallback for stored jobs. (#17901) Thanks @advaitpaliwal.
|
||||
- Channels: deduplicate probe/token resolution base types across core + extensions while preserving per-channel error typing. (#16986) Thanks @iyoda and @thewilloftheshadow.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Security: replace deprecated SHA-1 sandbox configuration hashing with SHA-256 for deterministic sandbox cache identity and recreation checks. Thanks @kexinoh.
|
||||
- Security/Sessions: create new session transcript JSONL files with user-only (`0o600`) permissions and extend `openclaw security audit --fix` to remediate existing transcript file permissions.
|
||||
- Security/Logging: redact Telegram bot tokens from error messages and uncaught stack traces to prevent accidental secret leakage into logs. Thanks @aether-ai-agent.
|
||||
- Sandbox/Security: block dangerous sandbox Docker config (bind mounts, host networking, unconfined seccomp/apparmor) to prevent container escape via config injection. Thanks @aether-ai-agent.
|
||||
- Sandbox: preserve array order in config hashing so order-sensitive Docker/browser settings trigger container recreation correctly. Thanks @kexinoh.
|
||||
@@ -26,7 +28,9 @@ Docs: https://docs.openclaw.ai
|
||||
- Skills/Security: restrict `download` installer `targetDir` to the per-skill tools directory to prevent arbitrary file writes. Thanks @Adam55A-code.
|
||||
- Skills/Linux: harden go installer fallback on apt-based systems by handling root/no-sudo environments safely, doing best-effort apt index refresh, and returning actionable errors instead of failing with spawn errors. (#17687) Thanks @mcrolly.
|
||||
- Web Fetch/Security: cap downloaded response body size before HTML parsing to prevent memory exhaustion from oversized or deeply nested pages. Thanks @xuemian168.
|
||||
- Infra/Fetch: ensure foreign abort-signal listener cleanup never masks original fetch successes/failures, while still preventing detached-finally unhandled rejection noise in `wrapFetchWithAbortSignal`. Thanks @Jackten.
|
||||
- Config/Gateway: make sensitive-key whitelist suffix matching case-insensitive while preserving `passwordFile` path exemptions, preventing accidental redaction of non-secret config values like `maxTokens` and IRC password-file paths. (#16042) Thanks @akramcodez.
|
||||
- Gateway/Config: prevent `config.patch` object-array merges from falling back to full-array replacement when some patch entries lack `id`, so partial `agents.list` updates no longer drop unrelated agents. (#17989) Thanks @stakeswky.
|
||||
- Dev tooling: harden git `pre-commit` hook against option injection from malicious filenames (for example `--force`), preventing accidental staging of ignored files. Thanks @mrthankyou.
|
||||
- Gateway/Agent: reject malformed `agent:`-prefixed session keys (for example, `agent:main`) in `agent` and `agent.identity.get` instead of silently resolving them to the default agent, preventing accidental cross-session routing. (#15707) Thanks @rodrigouroz.
|
||||
- Gateway/Chat: harden `chat.send` inbound message handling by rejecting null bytes, stripping unsafe control characters, and normalizing Unicode to NFC before dispatch. (#8593) Thanks @fr33d3m0n.
|
||||
@@ -44,19 +48,24 @@ Docs: https://docs.openclaw.ai
|
||||
- Browser/Agents: when browser control service is unavailable, return explicit non-retry guidance (instead of "try again") so models do not loop on repeated browser tool calls until timeout. (#17673) Thanks @austenstone.
|
||||
- Subagents: use child-run-based deterministic announce idempotency keys across direct and queued delivery paths (with legacy queued-item fallback) to prevent duplicate announce retries without collapsing distinct same-millisecond announces. (#17150) Thanks @widingmarcus-cyber.
|
||||
- Subagents/Models: preserve `agents.defaults.model.fallbacks` when subagent sessions carry a model override, so subagent runs fail over to configured fallback models instead of retrying only the overridden primary model.
|
||||
- Agents/Models: probe the primary model when its auth-profile cooldown is near expiry (with per-provider throttling), so runs recover from temporary rate limits without staying on fallback models until restart. (#17478) Thanks @PlayerGhost.
|
||||
- Telegram: omit `message_thread_id` for DM sends/draft previews and keep forum-topic handling (`id=1` general omitted, non-general kept), preventing DM failures with `400 Bad Request: message thread not found`. (#10942) Thanks @garnetlyx.
|
||||
- Telegram: replace inbound `<media:audio>` placeholder with successful preflight voice transcript in message body context, preventing placeholder-only prompt bodies for mention-gated voice messages. (#16789) Thanks @Limitless2023.
|
||||
- Telegram: retry inbound media `getFile` calls (3 attempts with backoff) and gracefully fall back to placeholder-only processing when retries fail, preventing dropped voice/media messages on transient Telegram network errors. (#16154) Thanks @yinghaosang.
|
||||
- Telegram: finalize streaming preview replies in place instead of sending a second final message, preventing duplicate Telegram assistant outputs at stream completion. (#17218) Thanks @obviyus.
|
||||
- Telegram: keep draft-stream preview replies attached to the user message for `replyToMode: "all"` in groups and DMs, preserving threaded reply context from preview through finalization. (#17880) Thanks @yinghaosang.
|
||||
- Telegram: disable block streaming when `channels.telegram.streamMode` is `off`, preventing newline/content-block replies from splitting into multiple messages. (#17679) Thanks @saivarunk.
|
||||
- Telegram: route non-abort slash commands on the normal chat/topic sequential lane while keeping true abort requests (`/stop`, `stop`) on the control lane, preventing command/reply race conditions from control-lane bypass. (#17899) Thanks @obviyus.
|
||||
- Telegram: prevent streaming final replies from being overwritten by later final/error payloads, and suppress fallback tool-error warnings when a recovered assistant answer already exists after tool calls. (#17883) Thanks @Marvae and @obviyus.
|
||||
- Discord: preserve channel session continuity when runtime payloads omit `message.channelId` by falling back to event/raw `channel_id` values for routing/session keys, so same-channel messages keep history across turns/restarts. Also align diagnostics so active Discord runs no longer appear as `sessionKey=unknown`. (#17622) Thanks @shakkernerd.
|
||||
- Discord: dedupe native skill commands by skill name in multi-agent setups to prevent duplicated slash commands with `_2` suffixes. (#17365) Thanks @seewhyme.
|
||||
- Discord: ensure role allowlist matching uses raw role IDs for message routing authorization. Thanks @xinhuagu.
|
||||
- Web UI/Agents: hide `BOOTSTRAP.md` in the Agents Files list after onboarding is completed, avoiding confusing missing-file warnings for completed workspaces. (#17491) Thanks @gumadeiras.
|
||||
- Memory/QMD: scope managed collection names per agent and precreate glob-backed collection directories before registration, preventing cross-agent collection clobbering and startup ENOENT failures in fresh workspaces. (#17194) Thanks @jonathanadams96.
|
||||
- Auto-reply/WhatsApp/TUI/Web: when a final assistant message is `NO_REPLY` and a messaging tool send succeeded, mirror the delivered messaging-tool text into session-visible assistant output so TUI/Web no longer show `NO_REPLY` placeholders. (#7010) Thanks @Morrowind-Xie.
|
||||
- Auto-reply/TTS: keep tool-result media delivery enabled in group chats and native command sessions (while still suppressing tool summary text) so `NO_REPLY` follow-ups do not drop successful TTS audio. (#17991) Thanks @zerone0x.
|
||||
- Cron: infer `payload.kind="agentTurn"` for model-only `cron.update` payload patches, so partial agent-turn updates do not fail validation when `kind` is omitted. (#15664) Thanks @rodrigouroz.
|
||||
- Memory/FTS: in embedding-provider fallback mode, ensure file/session indexing still writes and refreshes FTS rows so first-run memory search works and stale removed entries are removed from FTS too. Thanks @irchelper.
|
||||
- Cron: preserve per-job schedule-error isolation in post-run maintenance recompute so malformed sibling jobs no longer abort persistence of successful runs. (#17852) Thanks @pierreeurope.
|
||||
- TUI: make searchable-select filtering and highlight rendering ANSI-aware so queries ignore hidden escape codes and no longer corrupt ANSI styling sequences during match highlighting. (#4519) Thanks @bee4come.
|
||||
- TUI/Windows: coalesce rapid single-line submit bursts in Git Bash into one multiline message as a fallback when bracketed paste is unavailable, preventing pasted multiline text from being split into multiple sends. (#4986) Thanks @adamkane.
|
||||
- TUI: suppress false `(no output)` placeholders for non-local empty final events during concurrent runs, preventing external-channel replies from showing empty assistant bubbles while a local run is still streaming. (#5782) Thanks @LagWizard and @vignesh07.
|
||||
|
||||
@@ -21,8 +21,8 @@ android {
|
||||
applicationId = "ai.openclaw.android"
|
||||
minSdk = 31
|
||||
targetSdk = 36
|
||||
versionCode = 202602150
|
||||
versionName = "2026.2.15"
|
||||
versionCode = 202602160
|
||||
versionName = "2026.2.16"
|
||||
ndk {
|
||||
// Support all major ABIs — native libs are tiny (~47 KB per ABI)
|
||||
abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")
|
||||
|
||||
@@ -19,9 +19,9 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.2.15</string>
|
||||
<string>2026.2.16</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>20260215</string>
|
||||
<string>20260216</string>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoadsInWebContent</key>
|
||||
|
||||
@@ -17,8 +17,8 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>BNDL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.2.15</string>
|
||||
<string>2026.2.16</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>20260215</string>
|
||||
<string>20260216</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -81,8 +81,8 @@ targets:
|
||||
properties:
|
||||
CFBundleDisplayName: OpenClaw
|
||||
CFBundleIconName: AppIcon
|
||||
CFBundleShortVersionString: "2026.2.15"
|
||||
CFBundleVersion: "20260215"
|
||||
CFBundleShortVersionString: "2026.2.16"
|
||||
CFBundleVersion: "20260216"
|
||||
UILaunchScreen: {}
|
||||
UIApplicationSceneManifest:
|
||||
UIApplicationSupportsMultipleScenes: false
|
||||
@@ -130,5 +130,5 @@ targets:
|
||||
path: Tests/Info.plist
|
||||
properties:
|
||||
CFBundleDisplayName: OpenClawTests
|
||||
CFBundleShortVersionString: "2026.2.15"
|
||||
CFBundleVersion: "20260215"
|
||||
CFBundleShortVersionString: "2026.2.16"
|
||||
CFBundleVersion: "20260216"
|
||||
|
||||
@@ -21,6 +21,7 @@ enum CronWakeMode: String, CaseIterable, Identifiable, Codable {
|
||||
enum CronDeliveryMode: String, CaseIterable, Identifiable, Codable {
|
||||
case none
|
||||
case announce
|
||||
case webhook
|
||||
|
||||
var id: String {
|
||||
self.rawValue
|
||||
|
||||
@@ -15,9 +15,9 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.2.15</string>
|
||||
<string>2026.2.16</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>202602150</string>
|
||||
<string>202602160</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>OpenClaw</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
|
||||
@@ -2087,7 +2087,6 @@ public struct CronJob: Codable, Sendable {
|
||||
public let name: String
|
||||
public let description: String?
|
||||
public let enabled: Bool
|
||||
public let notify: Bool?
|
||||
public let deleteafterrun: Bool?
|
||||
public let createdatms: Int
|
||||
public let updatedatms: Int
|
||||
@@ -2095,7 +2094,7 @@ public struct CronJob: Codable, Sendable {
|
||||
public let sessiontarget: AnyCodable
|
||||
public let wakemode: AnyCodable
|
||||
public let payload: AnyCodable
|
||||
public let delivery: [String: AnyCodable]?
|
||||
public let delivery: AnyCodable?
|
||||
public let state: [String: AnyCodable]
|
||||
|
||||
public init(
|
||||
@@ -2104,7 +2103,6 @@ public struct CronJob: Codable, Sendable {
|
||||
name: String,
|
||||
description: String?,
|
||||
enabled: Bool,
|
||||
notify: Bool?,
|
||||
deleteafterrun: Bool?,
|
||||
createdatms: Int,
|
||||
updatedatms: Int,
|
||||
@@ -2112,7 +2110,7 @@ public struct CronJob: Codable, Sendable {
|
||||
sessiontarget: AnyCodable,
|
||||
wakemode: AnyCodable,
|
||||
payload: AnyCodable,
|
||||
delivery: [String: AnyCodable]?,
|
||||
delivery: AnyCodable?,
|
||||
state: [String: AnyCodable]
|
||||
) {
|
||||
self.id = id
|
||||
@@ -2120,7 +2118,6 @@ public struct CronJob: Codable, Sendable {
|
||||
self.name = name
|
||||
self.description = description
|
||||
self.enabled = enabled
|
||||
self.notify = notify
|
||||
self.deleteafterrun = deleteafterrun
|
||||
self.createdatms = createdatms
|
||||
self.updatedatms = updatedatms
|
||||
@@ -2137,7 +2134,6 @@ public struct CronJob: Codable, Sendable {
|
||||
case name
|
||||
case description
|
||||
case enabled
|
||||
case notify
|
||||
case deleteafterrun = "deleteAfterRun"
|
||||
case createdatms = "createdAtMs"
|
||||
case updatedatms = "updatedAtMs"
|
||||
@@ -2171,32 +2167,29 @@ public struct CronAddParams: Codable, Sendable {
|
||||
public let agentid: AnyCodable?
|
||||
public let description: String?
|
||||
public let enabled: Bool?
|
||||
public let notify: Bool?
|
||||
public let deleteafterrun: Bool?
|
||||
public let schedule: AnyCodable
|
||||
public let sessiontarget: AnyCodable
|
||||
public let wakemode: AnyCodable
|
||||
public let payload: AnyCodable
|
||||
public let delivery: [String: AnyCodable]?
|
||||
public let delivery: AnyCodable?
|
||||
|
||||
public init(
|
||||
name: String,
|
||||
agentid: AnyCodable?,
|
||||
description: String?,
|
||||
enabled: Bool?,
|
||||
notify: Bool?,
|
||||
deleteafterrun: Bool?,
|
||||
schedule: AnyCodable,
|
||||
sessiontarget: AnyCodable,
|
||||
wakemode: AnyCodable,
|
||||
payload: AnyCodable,
|
||||
delivery: [String: AnyCodable]?
|
||||
delivery: AnyCodable?
|
||||
) {
|
||||
self.name = name
|
||||
self.agentid = agentid
|
||||
self.description = description
|
||||
self.enabled = enabled
|
||||
self.notify = notify
|
||||
self.deleteafterrun = deleteafterrun
|
||||
self.schedule = schedule
|
||||
self.sessiontarget = sessiontarget
|
||||
@@ -2209,7 +2202,6 @@ public struct CronAddParams: Codable, Sendable {
|
||||
case agentid = "agentId"
|
||||
case description
|
||||
case enabled
|
||||
case notify
|
||||
case deleteafterrun = "deleteAfterRun"
|
||||
case schedule
|
||||
case sessiontarget = "sessionTarget"
|
||||
|
||||
@@ -2087,7 +2087,6 @@ public struct CronJob: Codable, Sendable {
|
||||
public let name: String
|
||||
public let description: String?
|
||||
public let enabled: Bool
|
||||
public let notify: Bool?
|
||||
public let deleteafterrun: Bool?
|
||||
public let createdatms: Int
|
||||
public let updatedatms: Int
|
||||
@@ -2095,7 +2094,7 @@ public struct CronJob: Codable, Sendable {
|
||||
public let sessiontarget: AnyCodable
|
||||
public let wakemode: AnyCodable
|
||||
public let payload: AnyCodable
|
||||
public let delivery: [String: AnyCodable]?
|
||||
public let delivery: AnyCodable?
|
||||
public let state: [String: AnyCodable]
|
||||
|
||||
public init(
|
||||
@@ -2104,7 +2103,6 @@ public struct CronJob: Codable, Sendable {
|
||||
name: String,
|
||||
description: String?,
|
||||
enabled: Bool,
|
||||
notify: Bool?,
|
||||
deleteafterrun: Bool?,
|
||||
createdatms: Int,
|
||||
updatedatms: Int,
|
||||
@@ -2112,7 +2110,7 @@ public struct CronJob: Codable, Sendable {
|
||||
sessiontarget: AnyCodable,
|
||||
wakemode: AnyCodable,
|
||||
payload: AnyCodable,
|
||||
delivery: [String: AnyCodable]?,
|
||||
delivery: AnyCodable?,
|
||||
state: [String: AnyCodable]
|
||||
) {
|
||||
self.id = id
|
||||
@@ -2120,7 +2118,6 @@ public struct CronJob: Codable, Sendable {
|
||||
self.name = name
|
||||
self.description = description
|
||||
self.enabled = enabled
|
||||
self.notify = notify
|
||||
self.deleteafterrun = deleteafterrun
|
||||
self.createdatms = createdatms
|
||||
self.updatedatms = updatedatms
|
||||
@@ -2137,7 +2134,6 @@ public struct CronJob: Codable, Sendable {
|
||||
case name
|
||||
case description
|
||||
case enabled
|
||||
case notify
|
||||
case deleteafterrun = "deleteAfterRun"
|
||||
case createdatms = "createdAtMs"
|
||||
case updatedatms = "updatedAtMs"
|
||||
@@ -2171,32 +2167,29 @@ public struct CronAddParams: Codable, Sendable {
|
||||
public let agentid: AnyCodable?
|
||||
public let description: String?
|
||||
public let enabled: Bool?
|
||||
public let notify: Bool?
|
||||
public let deleteafterrun: Bool?
|
||||
public let schedule: AnyCodable
|
||||
public let sessiontarget: AnyCodable
|
||||
public let wakemode: AnyCodable
|
||||
public let payload: AnyCodable
|
||||
public let delivery: [String: AnyCodable]?
|
||||
public let delivery: AnyCodable?
|
||||
|
||||
public init(
|
||||
name: String,
|
||||
agentid: AnyCodable?,
|
||||
description: String?,
|
||||
enabled: Bool?,
|
||||
notify: Bool?,
|
||||
deleteafterrun: Bool?,
|
||||
schedule: AnyCodable,
|
||||
sessiontarget: AnyCodable,
|
||||
wakemode: AnyCodable,
|
||||
payload: AnyCodable,
|
||||
delivery: [String: AnyCodable]?
|
||||
delivery: AnyCodable?
|
||||
) {
|
||||
self.name = name
|
||||
self.agentid = agentid
|
||||
self.description = description
|
||||
self.enabled = enabled
|
||||
self.notify = notify
|
||||
self.deleteafterrun = deleteafterrun
|
||||
self.schedule = schedule
|
||||
self.sessiontarget = sessiontarget
|
||||
@@ -2209,7 +2202,6 @@ public struct CronAddParams: Codable, Sendable {
|
||||
case agentid = "agentId"
|
||||
case description
|
||||
case enabled
|
||||
case notify
|
||||
case deleteafterrun = "deleteAfterRun"
|
||||
case schedule
|
||||
case sessiontarget = "sessionTarget"
|
||||
|
||||
@@ -27,7 +27,8 @@ Troubleshooting: [/automation/troubleshooting](/automation/troubleshooting)
|
||||
- **Main session**: enqueue a system event, then run on the next heartbeat.
|
||||
- **Isolated**: run a dedicated agent turn in `cron:<jobId>`, with delivery (announce by default or none).
|
||||
- Wakeups are first-class: a job can request “wake now” vs “next heartbeat”.
|
||||
- Webhook posting is opt-in per job: set `notify: true` and configure `cron.webhook`.
|
||||
- Webhook posting is per job via `delivery.mode = "webhook"` + `delivery.to = "<url>"`.
|
||||
- Legacy fallback remains for stored jobs with `notify: true` when `cron.webhook` is set, migrate those jobs to webhook delivery mode.
|
||||
|
||||
## Quick start (actionable)
|
||||
|
||||
@@ -100,7 +101,7 @@ A cron job is a stored record with:
|
||||
|
||||
- a **schedule** (when it should run),
|
||||
- a **payload** (what it should do),
|
||||
- optional **delivery mode** (announce or none).
|
||||
- optional **delivery mode** (`announce`, `webhook`, or `none`).
|
||||
- optional **agent binding** (`agentId`): run the job under a specific agent; if
|
||||
missing or unknown, the gateway falls back to the default agent.
|
||||
|
||||
@@ -141,8 +142,9 @@ Key behaviors:
|
||||
- Prompt is prefixed with `[cron:<jobId> <job name>]` for traceability.
|
||||
- Each run starts a **fresh session id** (no prior conversation carry-over).
|
||||
- Default behavior: if `delivery` is omitted, isolated jobs announce a summary (`delivery.mode = "announce"`).
|
||||
- `delivery.mode` (isolated-only) chooses what happens:
|
||||
- `delivery.mode` chooses what happens:
|
||||
- `announce`: deliver a summary to the target channel and post a brief summary to the main session.
|
||||
- `webhook`: POST the finished event payload to `delivery.to`.
|
||||
- `none`: internal only (no delivery, no main-session summary).
|
||||
- `wakeMode` controls when the main-session summary posts:
|
||||
- `now`: immediate heartbeat.
|
||||
@@ -164,11 +166,11 @@ Common `agentTurn` fields:
|
||||
- `model` / `thinking`: optional overrides (see below).
|
||||
- `timeoutSeconds`: optional timeout override.
|
||||
|
||||
Delivery config (isolated jobs only):
|
||||
Delivery config:
|
||||
|
||||
- `delivery.mode`: `none` | `announce`.
|
||||
- `delivery.mode`: `none` | `announce` | `webhook`.
|
||||
- `delivery.channel`: `last` or a specific channel.
|
||||
- `delivery.to`: channel-specific target (phone/chat/channel id).
|
||||
- `delivery.to`: channel-specific target (announce) or webhook URL (webhook mode).
|
||||
- `delivery.bestEffort`: avoid failing the job if announce delivery fails.
|
||||
|
||||
Announce delivery suppresses messaging tool sends for the run; use `delivery.channel`/`delivery.to`
|
||||
@@ -193,6 +195,18 @@ Behavior details:
|
||||
- The main-session summary respects `wakeMode`: `now` triggers an immediate heartbeat and
|
||||
`next-heartbeat` waits for the next scheduled heartbeat.
|
||||
|
||||
#### Webhook delivery flow
|
||||
|
||||
When `delivery.mode = "webhook"`, cron posts the finished event payload to `delivery.to`.
|
||||
|
||||
Behavior details:
|
||||
|
||||
- The endpoint must be a valid HTTP(S) URL.
|
||||
- No channel delivery is attempted in webhook mode.
|
||||
- No main-session summary is posted in webhook mode.
|
||||
- If `cron.webhookToken` is set, auth header is `Authorization: Bearer <cron.webhookToken>`.
|
||||
- Deprecated fallback: stored legacy jobs with `notify: true` still post to `cron.webhook` (if configured), with a warning so you can migrate to `delivery.mode = "webhook"`.
|
||||
|
||||
### Model and thinking overrides
|
||||
|
||||
Isolated jobs (`agentTurn`) can override the model and thinking level:
|
||||
@@ -214,11 +228,12 @@ Resolution priority:
|
||||
|
||||
Isolated jobs can deliver output to a channel via the top-level `delivery` config:
|
||||
|
||||
- `delivery.mode`: `announce` (deliver a summary) or `none`.
|
||||
- `delivery.mode`: `announce` (channel delivery), `webhook` (HTTP POST), or `none`.
|
||||
- `delivery.channel`: `whatsapp` / `telegram` / `discord` / `slack` / `mattermost` (plugin) / `signal` / `imessage` / `last`.
|
||||
- `delivery.to`: channel-specific recipient target.
|
||||
|
||||
Delivery config is only valid for isolated jobs (`sessionTarget: "isolated"`).
|
||||
`announce` delivery is only valid for isolated jobs (`sessionTarget: "isolated"`).
|
||||
`webhook` delivery is valid for both main and isolated jobs.
|
||||
|
||||
If `delivery.channel` or `delivery.to` is omitted, cron can fall back to the main session’s
|
||||
“last route” (the last place the agent replied).
|
||||
@@ -289,7 +304,7 @@ Notes:
|
||||
- `schedule.at` accepts ISO 8601 (timezone optional; treated as UTC when omitted).
|
||||
- `everyMs` is milliseconds.
|
||||
- `sessionTarget` must be `"main"` or `"isolated"` and must match `payload.kind`.
|
||||
- Optional fields: `agentId`, `description`, `enabled`, `notify`, `deleteAfterRun` (defaults to true for `at`),
|
||||
- Optional fields: `agentId`, `description`, `enabled`, `deleteAfterRun` (defaults to true for `at`),
|
||||
`delivery`.
|
||||
- `wakeMode` defaults to `"now"` when omitted.
|
||||
|
||||
@@ -334,18 +349,20 @@ Notes:
|
||||
enabled: true, // default true
|
||||
store: "~/.openclaw/cron/jobs.json",
|
||||
maxConcurrentRuns: 1, // default 1
|
||||
webhook: "https://example.invalid/cron-finished", // optional finished-run webhook endpoint
|
||||
webhookToken: "replace-with-dedicated-webhook-token", // optional, do not reuse gateway auth token
|
||||
webhook: "https://example.invalid/legacy", // deprecated fallback for stored notify:true jobs
|
||||
webhookToken: "replace-with-dedicated-webhook-token", // optional bearer token for webhook mode
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Webhook behavior:
|
||||
|
||||
- The Gateway posts finished run events to `cron.webhook` only when the job has `notify: true`.
|
||||
- Preferred: set `delivery.mode: "webhook"` with `delivery.to: "https://..."` per job.
|
||||
- Webhook URLs must be valid `http://` or `https://` URLs.
|
||||
- Payload is the cron finished event JSON.
|
||||
- If `cron.webhookToken` is set, auth header is `Authorization: Bearer <cron.webhookToken>`.
|
||||
- If `cron.webhookToken` is not set, no `Authorization` header is sent.
|
||||
- Deprecated fallback: stored legacy jobs with `notify: true` still use `cron.webhook` when present.
|
||||
|
||||
Disable cron entirely:
|
||||
|
||||
|
||||
@@ -17,20 +17,6 @@ For model selection rules, see [/concepts/models](/concepts/models).
|
||||
- If you set `agents.defaults.models`, it becomes the allowlist.
|
||||
- CLI helpers: `openclaw onboard`, `openclaw models list`, `openclaw models set <provider/model>`.
|
||||
|
||||
## API key rotation
|
||||
|
||||
- Supports generic provider rotation for selected providers.
|
||||
- Configure multiple keys via:
|
||||
- `OPENCLAW_LIVE_<PROVIDER>_KEY` (single live override, highest priority)
|
||||
- `<PROVIDER>_API_KEYS` (comma or semicolon list)
|
||||
- `<PROVIDER>_API_KEY` (primary key)
|
||||
- `<PROVIDER>_API_KEY_*` (numbered list, e.g. `<PROVIDER>_API_KEY_1`)
|
||||
- For Google providers, `GOOGLE_API_KEY` is also included as fallback.
|
||||
- Key selection order preserves priority and deduplicates values.
|
||||
- Requests are retried with the next key only on rate-limit responses (for example `429`, `rate_limit`, `quota`, `resource exhausted`).
|
||||
- Non-rate-limit failures fail immediately; no key rotation is attempted.
|
||||
- When all candidate keys fail, the final error is returned from the last attempt.
|
||||
|
||||
## Built-in providers (pi-ai catalog)
|
||||
|
||||
OpenClaw ships with the pi‑ai catalog. These providers require **no**
|
||||
@@ -40,7 +26,6 @@ OpenClaw ships with the pi‑ai catalog. These providers require **no**
|
||||
|
||||
- Provider: `openai`
|
||||
- Auth: `OPENAI_API_KEY`
|
||||
- Optional rotation: `OPENAI_API_KEYS`, `OPENAI_API_KEY_1`, `OPENAI_API_KEY_2`, plus `OPENCLAW_LIVE_OPENAI_KEY` (single override)
|
||||
- Example model: `openai/gpt-5.1-codex`
|
||||
- CLI: `openclaw onboard --auth-choice openai-api-key`
|
||||
|
||||
@@ -54,7 +39,6 @@ OpenClaw ships with the pi‑ai catalog. These providers require **no**
|
||||
|
||||
- Provider: `anthropic`
|
||||
- Auth: `ANTHROPIC_API_KEY` or `claude setup-token`
|
||||
- Optional rotation: `ANTHROPIC_API_KEYS`, `ANTHROPIC_API_KEY_1`, `ANTHROPIC_API_KEY_2`, plus `OPENCLAW_LIVE_ANTHROPIC_KEY` (single override)
|
||||
- Example model: `anthropic/claude-opus-4-6`
|
||||
- CLI: `openclaw onboard --auth-choice token` (paste setup-token) or `openclaw models auth paste-token --provider anthropic`
|
||||
|
||||
@@ -94,7 +78,6 @@ OpenClaw ships with the pi‑ai catalog. These providers require **no**
|
||||
|
||||
- Provider: `google`
|
||||
- Auth: `GEMINI_API_KEY`
|
||||
- Optional rotation: `GEMINI_API_KEYS`, `GEMINI_API_KEY_1`, `GEMINI_API_KEY_2`, `GOOGLE_API_KEY` fallback, and `OPENCLAW_LIVE_GEMINI_KEY` (single override)
|
||||
- Example model: `google/gemini-3-pro-preview`
|
||||
- CLI: `openclaw onboard --auth-choice gemini-api-key`
|
||||
|
||||
|
||||
@@ -103,23 +103,6 @@ openclaw models status
|
||||
openclaw doctor
|
||||
```
|
||||
|
||||
## API key rotation behavior (gateway)
|
||||
|
||||
Some providers support retrying a request with alternative keys when an API call
|
||||
hits a provider rate limit.
|
||||
|
||||
- Priority order:
|
||||
- `OPENCLAW_LIVE_<PROVIDER>_KEY` (single override)
|
||||
- `<PROVIDER>_API_KEYS`
|
||||
- `<PROVIDER>_API_KEY`
|
||||
- `<PROVIDER>_API_KEY_*`
|
||||
- Google providers also include `GOOGLE_API_KEY` as an additional fallback.
|
||||
- The same key list is deduplicated before use.
|
||||
- OpenClaw retries with the next key only for rate-limit errors (for example
|
||||
`429`, `rate_limit`, `quota`, `resource exhausted`).
|
||||
- Non-rate-limit errors are not retried with alternate keys.
|
||||
- If all keys fail, the final error from the last attempt is returned.
|
||||
|
||||
## Controlling which credential is used
|
||||
|
||||
### Per-session (chat command)
|
||||
|
||||
@@ -2320,7 +2320,7 @@ Current builds no longer include the TCP bridge. Nodes connect over the Gateway
|
||||
cron: {
|
||||
enabled: true,
|
||||
maxConcurrentRuns: 2,
|
||||
webhook: "https://example.invalid/cron-finished", // optional, must be http:// or https://
|
||||
webhook: "https://example.invalid/legacy", // deprecated fallback for stored notify:true jobs
|
||||
webhookToken: "replace-with-dedicated-token", // optional bearer token for outbound webhook auth
|
||||
sessionRetention: "24h", // duration string or false
|
||||
},
|
||||
@@ -2328,8 +2328,8 @@ Current builds no longer include the TCP bridge. Nodes connect over the Gateway
|
||||
```
|
||||
|
||||
- `sessionRetention`: how long to keep completed cron sessions before pruning. Default: `24h`.
|
||||
- `webhook`: finished-run webhook endpoint, only used when the job has `notify: true`.
|
||||
- `webhookToken`: dedicated bearer token for webhook auth, if omitted no auth header is sent.
|
||||
- `webhookToken`: bearer token used for cron webhook POST delivery (`delivery.mode = "webhook"`), if omitted no auth header is sent.
|
||||
- `webhook`: deprecated legacy fallback webhook URL (http/https) used only for stored jobs that still have `notify: true`.
|
||||
|
||||
See [Cron Jobs](/automation/cron-jobs).
|
||||
|
||||
|
||||
@@ -91,7 +91,7 @@ Think of the suites as “increasing realism” (and increasing flakiness/cost):
|
||||
- Costs money / uses rate limits
|
||||
- Prefer running narrowed subsets instead of “everything”
|
||||
- Live runs will source `~/.profile` to pick up missing API keys
|
||||
- API key rotation (provider-specific): set `*_API_KEYS` with comma/semicolon format or `*_API_KEY_1`, `*_API_KEY_2` (for example `OPENAI_API_KEYS`, `ANTHROPIC_API_KEYS`, `GEMINI_API_KEYS`) or per-live override via `OPENCLAW_LIVE_*_KEY`; tests retry on rate limit responses.
|
||||
- Anthropic key rotation: set `OPENCLAW_LIVE_ANTHROPIC_KEYS="sk-...,sk-..."` (or `OPENCLAW_LIVE_ANTHROPIC_KEY=sk-...`) or multiple `ANTHROPIC_API_KEY*` vars; tests will retry on rate limits
|
||||
|
||||
## Which suite should I run?
|
||||
|
||||
|
||||
@@ -34,17 +34,17 @@ Notes:
|
||||
# From repo root; set release IDs so Sparkle feed is enabled.
|
||||
# APP_BUILD must be numeric + monotonic for Sparkle compare.
|
||||
BUNDLE_ID=bot.molt.mac \
|
||||
APP_VERSION=2026.2.15 \
|
||||
APP_VERSION=2026.2.16 \
|
||||
APP_BUILD="$(git rev-list --count HEAD)" \
|
||||
BUILD_CONFIG=release \
|
||||
SIGN_IDENTITY="Developer ID Application: <Developer Name> (<TEAMID>)" \
|
||||
scripts/package-mac-app.sh
|
||||
|
||||
# Zip for distribution (includes resource forks for Sparkle delta support)
|
||||
ditto -c -k --sequesterRsrc --keepParent dist/OpenClaw.app dist/OpenClaw-2026.2.15.zip
|
||||
ditto -c -k --sequesterRsrc --keepParent dist/OpenClaw.app dist/OpenClaw-2026.2.16.zip
|
||||
|
||||
# Optional: also build a styled DMG for humans (drag to /Applications)
|
||||
scripts/create-dmg.sh dist/OpenClaw.app dist/OpenClaw-2026.2.15.dmg
|
||||
scripts/create-dmg.sh dist/OpenClaw.app dist/OpenClaw-2026.2.16.dmg
|
||||
|
||||
# Recommended: build + notarize/staple zip + DMG
|
||||
# First, create a keychain profile once:
|
||||
@@ -52,14 +52,14 @@ scripts/create-dmg.sh dist/OpenClaw.app dist/OpenClaw-2026.2.15.dmg
|
||||
# --apple-id "<apple-id>" --team-id "<team-id>" --password "<app-specific-password>"
|
||||
NOTARIZE=1 NOTARYTOOL_PROFILE=openclaw-notary \
|
||||
BUNDLE_ID=bot.molt.mac \
|
||||
APP_VERSION=2026.2.15 \
|
||||
APP_VERSION=2026.2.16 \
|
||||
APP_BUILD="$(git rev-list --count HEAD)" \
|
||||
BUILD_CONFIG=release \
|
||||
SIGN_IDENTITY="Developer ID Application: <Developer Name> (<TEAMID>)" \
|
||||
scripts/package-mac-dist.sh
|
||||
|
||||
# Optional: ship dSYM alongside the release
|
||||
ditto -c -k --keepParent apps/macos/.build/release/OpenClaw.app.dSYM dist/OpenClaw-2026.2.15.dSYM.zip
|
||||
ditto -c -k --keepParent apps/macos/.build/release/OpenClaw.app.dSYM dist/OpenClaw-2026.2.16.dSYM.zip
|
||||
```
|
||||
|
||||
## Appcast entry
|
||||
@@ -67,7 +67,7 @@ ditto -c -k --keepParent apps/macos/.build/release/OpenClaw.app.dSYM dist/OpenCl
|
||||
Use the release note generator so Sparkle renders formatted HTML notes:
|
||||
|
||||
```bash
|
||||
SPARKLE_PRIVATE_KEY_FILE=/path/to/ed25519-private-key scripts/make_appcast.sh dist/OpenClaw-2026.2.15.zip https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml
|
||||
SPARKLE_PRIVATE_KEY_FILE=/path/to/ed25519-private-key scripts/make_appcast.sh dist/OpenClaw-2026.2.16.zip https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml
|
||||
```
|
||||
|
||||
Generates HTML release notes from `CHANGELOG.md` (via [`scripts/changelog-to-html.sh`](https://github.com/openclaw/openclaw/blob/main/scripts/changelog-to-html.sh)) and embeds them in the appcast entry.
|
||||
@@ -75,7 +75,7 @@ Commit the updated `appcast.xml` alongside the release assets (zip + dSYM) when
|
||||
|
||||
## Publish & verify
|
||||
|
||||
- Upload `OpenClaw-2026.2.15.zip` (and `OpenClaw-2026.2.15.dSYM.zip`) to the GitHub release for tag `v2026.2.15`.
|
||||
- Upload `OpenClaw-2026.2.16.zip` (and `OpenClaw-2026.2.16.dSYM.zip`) to the GitHub release for tag `v2026.2.16`.
|
||||
- Ensure the raw appcast URL matches the baked feed: `https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml`.
|
||||
- Sanity checks:
|
||||
- `curl -I https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml` returns 200.
|
||||
|
||||
@@ -83,9 +83,10 @@ Cron jobs panel notes:
|
||||
|
||||
- For isolated jobs, delivery defaults to announce summary. You can switch to none if you want internal-only runs.
|
||||
- Channel/target fields appear when announce is selected.
|
||||
- New job form includes a **Notify webhook** toggle (`notify` on the job).
|
||||
- Gateway webhook posting requires both `notify: true` on the job and `cron.webhook` in config.
|
||||
- Webhook mode uses `delivery.mode = "webhook"` with `delivery.to` set to a valid HTTP(S) webhook URL.
|
||||
- For main-session jobs, webhook and none delivery modes are available.
|
||||
- Set `cron.webhookToken` to send a dedicated bearer token, if omitted the webhook is sent without an auth header.
|
||||
- Deprecated fallback: stored legacy jobs with `notify: true` can still use `cron.webhook` until migrated.
|
||||
|
||||
## Chat behavior
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/bluebubbles",
|
||||
"version": "2026.2.15",
|
||||
"version": "2026.2.16",
|
||||
"description": "OpenClaw BlueBubbles channel plugin",
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/copilot-proxy",
|
||||
"version": "2026.2.15",
|
||||
"version": "2026.2.16",
|
||||
"private": true,
|
||||
"description": "OpenClaw Copilot Proxy provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/diagnostics-otel",
|
||||
"version": "2026.2.15",
|
||||
"version": "2026.2.16",
|
||||
"description": "OpenClaw diagnostics OpenTelemetry exporter",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/discord",
|
||||
"version": "2026.2.15",
|
||||
"version": "2026.2.16",
|
||||
"description": "OpenClaw Discord channel plugin",
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/feishu",
|
||||
"version": "2026.2.15",
|
||||
"version": "2026.2.16",
|
||||
"description": "OpenClaw Feishu/Lark channel plugin (community maintained by @m1heng)",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/google-antigravity-auth",
|
||||
"version": "2026.2.15",
|
||||
"version": "2026.2.16",
|
||||
"private": true,
|
||||
"description": "OpenClaw Google Antigravity OAuth provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/google-gemini-cli-auth",
|
||||
"version": "2026.2.15",
|
||||
"version": "2026.2.16",
|
||||
"private": true,
|
||||
"description": "OpenClaw Gemini CLI OAuth provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/googlechat",
|
||||
"version": "2026.2.15",
|
||||
"version": "2026.2.16",
|
||||
"private": true,
|
||||
"description": "OpenClaw Google Chat channel plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/imessage",
|
||||
"version": "2026.2.15",
|
||||
"version": "2026.2.16",
|
||||
"private": true,
|
||||
"description": "OpenClaw iMessage channel plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/irc",
|
||||
"version": "2026.2.15",
|
||||
"version": "2026.2.16",
|
||||
"description": "OpenClaw IRC channel plugin",
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/line",
|
||||
"version": "2026.2.15",
|
||||
"version": "2026.2.16",
|
||||
"private": true,
|
||||
"description": "OpenClaw LINE channel plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/llm-task",
|
||||
"version": "2026.2.15",
|
||||
"version": "2026.2.16",
|
||||
"private": true,
|
||||
"description": "OpenClaw JSON-only LLM task plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/lobster",
|
||||
"version": "2026.2.15",
|
||||
"version": "2026.2.16",
|
||||
"description": "Lobster workflow tool plugin (typed pipelines + resumable approvals)",
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# Changelog
|
||||
|
||||
## 2026.2.16
|
||||
|
||||
### Changes
|
||||
|
||||
- Version alignment with core OpenClaw release numbers.
|
||||
|
||||
## 2026.2.15
|
||||
|
||||
### Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/matrix",
|
||||
"version": "2026.2.15",
|
||||
"version": "2026.2.16",
|
||||
"description": "OpenClaw Matrix channel plugin",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/mattermost",
|
||||
"version": "2026.2.15",
|
||||
"version": "2026.2.16",
|
||||
"private": true,
|
||||
"description": "OpenClaw Mattermost channel plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/memory-core",
|
||||
"version": "2026.2.15",
|
||||
"version": "2026.2.16",
|
||||
"private": true,
|
||||
"description": "OpenClaw core memory search plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/memory-lancedb",
|
||||
"version": "2026.2.15",
|
||||
"version": "2026.2.16",
|
||||
"private": true,
|
||||
"description": "OpenClaw LanceDB-backed long-term memory plugin with auto-recall/capture",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/minimax-portal-auth",
|
||||
"version": "2026.2.15",
|
||||
"version": "2026.2.16",
|
||||
"private": true,
|
||||
"description": "OpenClaw MiniMax Portal OAuth provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# Changelog
|
||||
|
||||
## 2026.2.16
|
||||
|
||||
### Changes
|
||||
|
||||
- Version alignment with core OpenClaw release numbers.
|
||||
|
||||
## 2026.2.15
|
||||
|
||||
### Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/msteams",
|
||||
"version": "2026.2.15",
|
||||
"version": "2026.2.16",
|
||||
"description": "OpenClaw Microsoft Teams channel plugin",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/nextcloud-talk",
|
||||
"version": "2026.2.15",
|
||||
"version": "2026.2.16",
|
||||
"description": "OpenClaw Nextcloud Talk channel plugin",
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# Changelog
|
||||
|
||||
## 2026.2.16
|
||||
|
||||
### Changes
|
||||
|
||||
- Version alignment with core OpenClaw release numbers.
|
||||
|
||||
## 2026.2.15
|
||||
|
||||
### Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/nostr",
|
||||
"version": "2026.2.15",
|
||||
"version": "2026.2.16",
|
||||
"description": "OpenClaw Nostr channel plugin for NIP-04 encrypted DMs",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/open-prose",
|
||||
"version": "2026.2.15",
|
||||
"version": "2026.2.16",
|
||||
"private": true,
|
||||
"description": "OpenProse VM skill pack plugin (slash command + telemetry).",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/signal",
|
||||
"version": "2026.2.15",
|
||||
"version": "2026.2.16",
|
||||
"private": true,
|
||||
"description": "OpenClaw Signal channel plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/slack",
|
||||
"version": "2026.2.15",
|
||||
"version": "2026.2.16",
|
||||
"private": true,
|
||||
"description": "OpenClaw Slack channel plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/telegram",
|
||||
"version": "2026.2.15",
|
||||
"version": "2026.2.16",
|
||||
"private": true,
|
||||
"description": "OpenClaw Telegram channel plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/tlon",
|
||||
"version": "2026.2.15",
|
||||
"version": "2026.2.16",
|
||||
"private": true,
|
||||
"description": "OpenClaw Tlon/Urbit channel plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# Changelog
|
||||
|
||||
## 2026.2.16
|
||||
|
||||
### Changes
|
||||
|
||||
- Version alignment with core OpenClaw release numbers.
|
||||
|
||||
## 2026.2.15
|
||||
|
||||
### Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/twitch",
|
||||
"version": "2026.2.15",
|
||||
"version": "2026.2.16",
|
||||
"private": true,
|
||||
"description": "OpenClaw Twitch channel plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# Changelog
|
||||
|
||||
## 2026.2.16
|
||||
|
||||
### Changes
|
||||
|
||||
- Version alignment with core OpenClaw release numbers.
|
||||
|
||||
## 2026.2.15
|
||||
|
||||
### Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/voice-call",
|
||||
"version": "2026.2.15",
|
||||
"version": "2026.2.16",
|
||||
"description": "OpenClaw voice-call plugin",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/whatsapp",
|
||||
"version": "2026.2.15",
|
||||
"version": "2026.2.16",
|
||||
"private": true,
|
||||
"description": "OpenClaw WhatsApp channel plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# Changelog
|
||||
|
||||
## 2026.2.16
|
||||
|
||||
### Changes
|
||||
|
||||
- Version alignment with core OpenClaw release numbers.
|
||||
|
||||
## 2026.2.15
|
||||
|
||||
### Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/zalo",
|
||||
"version": "2026.2.15",
|
||||
"version": "2026.2.16",
|
||||
"description": "OpenClaw Zalo channel plugin",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# Changelog
|
||||
|
||||
## 2026.2.16
|
||||
|
||||
### Changes
|
||||
|
||||
- Version alignment with core OpenClaw release numbers.
|
||||
|
||||
## 2026.2.15
|
||||
|
||||
### Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/zalouser",
|
||||
"version": "2026.2.15",
|
||||
"version": "2026.2.16",
|
||||
"description": "OpenClaw Zalo Personal Account plugin via zca-cli",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openclaw",
|
||||
"version": "2026.2.15",
|
||||
"version": "2026.2.16",
|
||||
"description": "Multi-channel AI gateway with extensible messaging integrations",
|
||||
"keywords": [],
|
||||
"homepage": "https://github.com/openclaw/openclaw#readme",
|
||||
|
||||
@@ -27,12 +27,8 @@ const unitIsolatedFilesRaw = [
|
||||
"src/browser/server.agent-contract-form-layout-act-commands.test.ts",
|
||||
"src/browser/server.skips-default-maxchars-explicitly-set-zero.test.ts",
|
||||
"src/browser/server.auth-token-gates-http.test.ts",
|
||||
"src/browser/server-context.remote-tab-ops.test.ts",
|
||||
"src/browser/server-context.ensure-tab-available.prefers-last-target.test.ts",
|
||||
// Keep this high-variance heavy file off the unit-fast critical path.
|
||||
"src/auto-reply/reply.block-streaming.test.ts",
|
||||
// Integration test is process-heavy and can bottleneck unit-fast.
|
||||
"test/git-hooks-pre-commit.integration.test.ts",
|
||||
// Archive extraction/fixture-heavy suite; keep off unit-fast critical path.
|
||||
"src/hooks/install.test.ts",
|
||||
// Setup-heavy bot bootstrap suite.
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
import { formatErrorMessage } from "../infra/errors.js";
|
||||
import { collectProviderApiKeys, isApiKeyRateLimitError } from "./live-auth-keys.js";
|
||||
|
||||
type ApiKeyRetryParams = {
|
||||
apiKey: string;
|
||||
error: unknown;
|
||||
attempt: number;
|
||||
};
|
||||
|
||||
type ExecuteWithApiKeyRotationOptions<T> = {
|
||||
provider: string;
|
||||
apiKeys: string[];
|
||||
execute: (apiKey: string) => Promise<T>;
|
||||
shouldRetry?: (params: ApiKeyRetryParams & { message: string }) => boolean;
|
||||
onRetry?: (params: ApiKeyRetryParams & { message: string }) => void;
|
||||
};
|
||||
|
||||
function dedupeApiKeys(raw: string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const keys: string[] = [];
|
||||
for (const value of raw) {
|
||||
const apiKey = value.trim();
|
||||
if (!apiKey || seen.has(apiKey)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(apiKey);
|
||||
keys.push(apiKey);
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
export function collectProviderApiKeysForExecution(params: {
|
||||
provider: string;
|
||||
primaryApiKey?: string;
|
||||
}): string[] {
|
||||
const { primaryApiKey, provider } = params;
|
||||
return dedupeApiKeys([primaryApiKey?.trim() ?? "", ...collectProviderApiKeys(provider)]);
|
||||
}
|
||||
|
||||
export async function executeWithApiKeyRotation<T>(
|
||||
params: ExecuteWithApiKeyRotationOptions<T>,
|
||||
): Promise<T> {
|
||||
const keys = dedupeApiKeys(params.apiKeys);
|
||||
if (keys.length === 0) {
|
||||
throw new Error(`No API keys configured for provider "${params.provider}".`);
|
||||
}
|
||||
|
||||
let lastError: unknown;
|
||||
for (let attempt = 0; attempt < keys.length; attempt += 1) {
|
||||
const apiKey = keys[attempt];
|
||||
try {
|
||||
return await params.execute(apiKey);
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
const message = formatErrorMessage(error);
|
||||
const retryable = params.shouldRetry
|
||||
? params.shouldRetry({ apiKey, error, attempt, message })
|
||||
: isApiKeyRateLimitError(message);
|
||||
|
||||
if (!retryable || attempt + 1 >= keys.length) {
|
||||
break;
|
||||
}
|
||||
|
||||
params.onRetry?.({ apiKey, error, attempt, message });
|
||||
}
|
||||
}
|
||||
|
||||
if (lastError === undefined) {
|
||||
throw new Error(`Failed to run API request for ${params.provider}.`);
|
||||
}
|
||||
throw lastError;
|
||||
}
|
||||
77
src/agents/auth-profiles.getsoonestcooldownexpiry.test.ts
Normal file
77
src/agents/auth-profiles.getsoonestcooldownexpiry.test.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { AuthProfileStore } from "./auth-profiles.js";
|
||||
import { getSoonestCooldownExpiry } from "./auth-profiles.js";
|
||||
|
||||
function makeStore(usageStats?: AuthProfileStore["usageStats"]): AuthProfileStore {
|
||||
return {
|
||||
version: 1,
|
||||
profiles: {},
|
||||
usageStats,
|
||||
};
|
||||
}
|
||||
|
||||
describe("getSoonestCooldownExpiry", () => {
|
||||
it("returns null when no cooldown timestamps exist", () => {
|
||||
const store = makeStore();
|
||||
expect(getSoonestCooldownExpiry(store, ["openai:p1"])).toBeNull();
|
||||
});
|
||||
|
||||
it("returns earliest unusable time across profiles", () => {
|
||||
const store = makeStore({
|
||||
"openai:p1": {
|
||||
cooldownUntil: 1_700_000_002_000,
|
||||
disabledUntil: 1_700_000_004_000,
|
||||
},
|
||||
"openai:p2": {
|
||||
cooldownUntil: 1_700_000_003_000,
|
||||
},
|
||||
"openai:p3": {
|
||||
disabledUntil: 1_700_000_001_000,
|
||||
},
|
||||
});
|
||||
|
||||
expect(getSoonestCooldownExpiry(store, ["openai:p1", "openai:p2", "openai:p3"])).toBe(
|
||||
1_700_000_001_000,
|
||||
);
|
||||
});
|
||||
|
||||
it("ignores unknown profiles and invalid cooldown values", () => {
|
||||
const store = makeStore({
|
||||
"openai:p1": {
|
||||
cooldownUntil: -1,
|
||||
},
|
||||
"openai:p2": {
|
||||
cooldownUntil: Infinity,
|
||||
},
|
||||
"openai:p3": {
|
||||
disabledUntil: NaN,
|
||||
},
|
||||
"openai:p4": {
|
||||
cooldownUntil: 1_700_000_005_000,
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
getSoonestCooldownExpiry(store, [
|
||||
"missing",
|
||||
"openai:p1",
|
||||
"openai:p2",
|
||||
"openai:p3",
|
||||
"openai:p4",
|
||||
]),
|
||||
).toBe(1_700_000_005_000);
|
||||
});
|
||||
|
||||
it("returns past timestamps when cooldown already expired", () => {
|
||||
const store = makeStore({
|
||||
"openai:p1": {
|
||||
cooldownUntil: 1_700_000_000_000,
|
||||
},
|
||||
"openai:p2": {
|
||||
disabledUntil: 1_700_000_010_000,
|
||||
},
|
||||
});
|
||||
|
||||
expect(getSoonestCooldownExpiry(store, ["openai:p1", "openai:p2"])).toBe(1_700_000_000_000);
|
||||
});
|
||||
});
|
||||
@@ -33,6 +33,7 @@ export type {
|
||||
export {
|
||||
calculateAuthProfileCooldownMs,
|
||||
clearAuthProfileCooldown,
|
||||
getSoonestCooldownExpiry,
|
||||
isProfileInCooldown,
|
||||
markAuthProfileCooldown,
|
||||
markAuthProfileFailure,
|
||||
|
||||
@@ -25,6 +25,32 @@ export function isProfileInCooldown(store: AuthProfileStore, profileId: string):
|
||||
return unusableUntil ? Date.now() < unusableUntil : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the soonest `unusableUntil` timestamp (ms epoch) among the given
|
||||
* profiles, or `null` when no profile has a recorded cooldown. Note: the
|
||||
* returned timestamp may be in the past if the cooldown has already expired.
|
||||
*/
|
||||
export function getSoonestCooldownExpiry(
|
||||
store: AuthProfileStore,
|
||||
profileIds: string[],
|
||||
): number | null {
|
||||
let soonest: number | null = null;
|
||||
for (const id of profileIds) {
|
||||
const stats = store.usageStats?.[id];
|
||||
if (!stats) {
|
||||
continue;
|
||||
}
|
||||
const until = resolveProfileUnusableUntil(stats);
|
||||
if (typeof until !== "number" || !Number.isFinite(until) || until <= 0) {
|
||||
continue;
|
||||
}
|
||||
if (soonest === null || until < soonest) {
|
||||
soonest = until;
|
||||
}
|
||||
}
|
||||
return soonest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a profile as successfully used. Resets error count and updates lastUsed.
|
||||
* Uses store lock to avoid overwriting concurrent usage updates.
|
||||
|
||||
@@ -1,47 +1,4 @@
|
||||
import { normalizeProviderId } from "./model-selection.js";
|
||||
|
||||
const KEY_SPLIT_RE = /[\s,;]+/g;
|
||||
const GOOGLE_LIVE_SINGLE_KEY = "OPENCLAW_LIVE_GEMINI_KEY";
|
||||
|
||||
const PROVIDER_PREFIX_OVERRIDES: Record<string, string> = {
|
||||
google: "GEMINI",
|
||||
"google-vertex": "GEMINI",
|
||||
};
|
||||
|
||||
type ProviderApiKeyConfig = {
|
||||
liveSingle?: string;
|
||||
listVar?: string;
|
||||
primaryVar?: string;
|
||||
prefixedVar?: string;
|
||||
fallbackVars: string[];
|
||||
};
|
||||
|
||||
const PROVIDER_API_KEY_CONFIG: Record<string, Omit<ProviderApiKeyConfig, "fallbackVars">> = {
|
||||
anthropic: {
|
||||
liveSingle: "OPENCLAW_LIVE_ANTHROPIC_KEY",
|
||||
listVar: "OPENCLAW_LIVE_ANTHROPIC_KEYS",
|
||||
primaryVar: "ANTHROPIC_API_KEY",
|
||||
prefixedVar: "ANTHROPIC_API_KEY_",
|
||||
},
|
||||
google: {
|
||||
liveSingle: GOOGLE_LIVE_SINGLE_KEY,
|
||||
listVar: "GEMINI_API_KEYS",
|
||||
primaryVar: "GEMINI_API_KEY",
|
||||
prefixedVar: "GEMINI_API_KEY_",
|
||||
},
|
||||
"google-vertex": {
|
||||
liveSingle: GOOGLE_LIVE_SINGLE_KEY,
|
||||
listVar: "GEMINI_API_KEYS",
|
||||
primaryVar: "GEMINI_API_KEY",
|
||||
prefixedVar: "GEMINI_API_KEY_",
|
||||
},
|
||||
openai: {
|
||||
liveSingle: "OPENCLAW_LIVE_OPENAI_KEY",
|
||||
listVar: "OPENAI_API_KEYS",
|
||||
primaryVar: "OPENAI_API_KEY",
|
||||
prefixedVar: "OPENAI_API_KEY_",
|
||||
},
|
||||
};
|
||||
|
||||
function parseKeyList(raw?: string | null): string[] {
|
||||
if (!raw) {
|
||||
@@ -68,53 +25,17 @@ function collectEnvPrefixedKeys(prefix: string): string[] {
|
||||
return keys;
|
||||
}
|
||||
|
||||
function resolveProviderApiKeyConfig(provider: string): ProviderApiKeyConfig {
|
||||
const normalized = normalizeProviderId(provider);
|
||||
const custom = PROVIDER_API_KEY_CONFIG[normalized];
|
||||
const base = PROVIDER_PREFIX_OVERRIDES[normalized] ?? normalized.toUpperCase().replace(/-/g, "_");
|
||||
|
||||
const liveSingle = custom?.liveSingle ?? `OPENCLAW_LIVE_${base}_KEY`;
|
||||
const listVar = custom?.listVar ?? `${base}_API_KEYS`;
|
||||
const primaryVar = custom?.primaryVar ?? `${base}_API_KEY`;
|
||||
const prefixedVar = custom?.prefixedVar ?? `${base}_API_KEY_`;
|
||||
|
||||
if (normalized === "google" || normalized === "google-vertex") {
|
||||
return {
|
||||
liveSingle,
|
||||
listVar,
|
||||
primaryVar,
|
||||
prefixedVar,
|
||||
fallbackVars: ["GOOGLE_API_KEY"],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
liveSingle,
|
||||
listVar,
|
||||
primaryVar,
|
||||
prefixedVar,
|
||||
fallbackVars: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function collectProviderApiKeys(provider: string): string[] {
|
||||
const config = resolveProviderApiKeyConfig(provider);
|
||||
|
||||
const forcedSingle = config.liveSingle ? process.env[config.liveSingle]?.trim() : undefined;
|
||||
export function collectAnthropicApiKeys(): string[] {
|
||||
const forcedSingle = process.env.OPENCLAW_LIVE_ANTHROPIC_KEY?.trim();
|
||||
if (forcedSingle) {
|
||||
return [forcedSingle];
|
||||
}
|
||||
|
||||
const fromList = parseKeyList(config.listVar ? process.env[config.listVar] : undefined);
|
||||
const primary = config.primaryVar ? process.env[config.primaryVar]?.trim() : undefined;
|
||||
const fromPrefixed = config.prefixedVar ? collectEnvPrefixedKeys(config.prefixedVar) : [];
|
||||
|
||||
const fallback = config.fallbackVars
|
||||
.map((envVar) => process.env[envVar]?.trim())
|
||||
.filter(Boolean) as string[];
|
||||
const fromList = parseKeyList(process.env.OPENCLAW_LIVE_ANTHROPIC_KEYS);
|
||||
const fromEnv = collectEnvPrefixedKeys("ANTHROPIC_API_KEY");
|
||||
const primary = process.env.ANTHROPIC_API_KEY?.trim();
|
||||
|
||||
const seen = new Set<string>();
|
||||
|
||||
const add = (value?: string) => {
|
||||
if (!value) {
|
||||
return;
|
||||
@@ -128,26 +49,17 @@ export function collectProviderApiKeys(provider: string): string[] {
|
||||
for (const value of fromList) {
|
||||
add(value);
|
||||
}
|
||||
add(primary);
|
||||
for (const value of fromPrefixed) {
|
||||
add(value);
|
||||
if (primary) {
|
||||
add(primary);
|
||||
}
|
||||
for (const value of fallback) {
|
||||
for (const value of fromEnv) {
|
||||
add(value);
|
||||
}
|
||||
|
||||
return Array.from(seen);
|
||||
}
|
||||
|
||||
export function collectAnthropicApiKeys(): string[] {
|
||||
return collectProviderApiKeys("anthropic");
|
||||
}
|
||||
|
||||
export function collectGeminiApiKeys(): string[] {
|
||||
return collectProviderApiKeys("google");
|
||||
}
|
||||
|
||||
export function isApiKeyRateLimitError(message: string): boolean {
|
||||
export function isAnthropicRateLimitError(message: string): boolean {
|
||||
const lower = message.toLowerCase();
|
||||
if (lower.includes("rate_limit")) {
|
||||
return true;
|
||||
@@ -158,22 +70,9 @@ export function isApiKeyRateLimitError(message: string): boolean {
|
||||
if (lower.includes("429")) {
|
||||
return true;
|
||||
}
|
||||
if (lower.includes("quota exceeded") || lower.includes("quota_exceeded")) {
|
||||
return true;
|
||||
}
|
||||
if (lower.includes("resource exhausted") || lower.includes("resource_exhausted")) {
|
||||
return true;
|
||||
}
|
||||
if (lower.includes("too many requests")) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function isAnthropicRateLimitError(message: string): boolean {
|
||||
return isApiKeyRateLimitError(message);
|
||||
}
|
||||
|
||||
export function isAnthropicBillingError(message: string): boolean {
|
||||
const lower = message.toLowerCase();
|
||||
if (lower.includes("credit balance")) {
|
||||
@@ -192,7 +91,7 @@ export function isAnthropicBillingError(message: string): boolean {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
/["']?(?:status|code)["']?\s*[:=]\s*402\b|\bhttp\s*402\b|\berror(?:\s+code)?\s*[:=]?\s*402\b|\b(?:got|returned|received)\s+(?:a\s+)?402\b|^\s*402\spayment/i.test(
|
||||
/["']?(?:status|code)["']?\s*[:=]\s*402\b|\bhttp\s*402\b|\berror(?:\s+code)?\s*[:=]?\s*402\b|\b(?:got|returned|received)\s+(?:a\s+)?402\b|^\s*402\s+payment/i.test(
|
||||
lower,
|
||||
)
|
||||
) {
|
||||
|
||||
343
src/agents/model-fallback.probe.test.ts
Normal file
343
src/agents/model-fallback.probe.test.ts
Normal file
@@ -0,0 +1,343 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import type { AuthProfileStore } from "./auth-profiles.js";
|
||||
|
||||
// Mock auth-profiles module — must be before importing model-fallback
|
||||
vi.mock("./auth-profiles.js", () => ({
|
||||
ensureAuthProfileStore: vi.fn(),
|
||||
getSoonestCooldownExpiry: vi.fn(),
|
||||
isProfileInCooldown: vi.fn(),
|
||||
resolveAuthProfileOrder: vi.fn(),
|
||||
}));
|
||||
|
||||
import {
|
||||
ensureAuthProfileStore,
|
||||
getSoonestCooldownExpiry,
|
||||
isProfileInCooldown,
|
||||
resolveAuthProfileOrder,
|
||||
} from "./auth-profiles.js";
|
||||
import { _probeThrottleInternals, runWithModelFallback } from "./model-fallback.js";
|
||||
|
||||
const mockedEnsureAuthProfileStore = vi.mocked(ensureAuthProfileStore);
|
||||
const mockedGetSoonestCooldownExpiry = vi.mocked(getSoonestCooldownExpiry);
|
||||
const mockedIsProfileInCooldown = vi.mocked(isProfileInCooldown);
|
||||
const mockedResolveAuthProfileOrder = vi.mocked(resolveAuthProfileOrder);
|
||||
|
||||
function makeCfg(overrides: Partial<OpenClawConfig> = {}): OpenClawConfig {
|
||||
return {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: {
|
||||
primary: "openai/gpt-4.1-mini",
|
||||
fallbacks: ["anthropic/claude-haiku-3-5"],
|
||||
},
|
||||
},
|
||||
},
|
||||
...overrides,
|
||||
} as OpenClawConfig;
|
||||
}
|
||||
|
||||
describe("runWithModelFallback – probe logic", () => {
|
||||
let realDateNow: () => number;
|
||||
const NOW = 1_700_000_000_000;
|
||||
|
||||
beforeEach(() => {
|
||||
realDateNow = Date.now;
|
||||
Date.now = vi.fn(() => NOW);
|
||||
|
||||
// Clear throttle state between tests
|
||||
_probeThrottleInternals.lastProbeAttempt.clear();
|
||||
|
||||
// Default: ensureAuthProfileStore returns a fake store
|
||||
const fakeStore: AuthProfileStore = {
|
||||
version: 1,
|
||||
profiles: {},
|
||||
};
|
||||
mockedEnsureAuthProfileStore.mockReturnValue(fakeStore);
|
||||
|
||||
// Default: resolveAuthProfileOrder returns profiles only for "openai" provider
|
||||
mockedResolveAuthProfileOrder.mockImplementation(({ provider }: { provider: string }) => {
|
||||
if (provider === "openai") {
|
||||
return ["openai-profile-1"];
|
||||
}
|
||||
if (provider === "anthropic") {
|
||||
return ["anthropic-profile-1"];
|
||||
}
|
||||
if (provider === "google") {
|
||||
return ["google-profile-1"];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
// Default: only openai profiles are in cooldown; fallback providers are available
|
||||
mockedIsProfileInCooldown.mockImplementation((_store, profileId: string) => {
|
||||
return profileId.startsWith("openai");
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Date.now = realDateNow;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("skips primary model when far from cooldown expiry (30 min remaining)", async () => {
|
||||
const cfg = makeCfg();
|
||||
// Cooldown expires in 30 min — well beyond the 2-min margin
|
||||
const expiresIn30Min = NOW + 30 * 60 * 1000;
|
||||
mockedGetSoonestCooldownExpiry.mockReturnValue(expiresIn30Min);
|
||||
|
||||
const run = vi.fn().mockResolvedValue("ok");
|
||||
|
||||
const result = await runWithModelFallback({
|
||||
cfg,
|
||||
provider: "openai",
|
||||
model: "gpt-4.1-mini",
|
||||
run,
|
||||
});
|
||||
|
||||
// Should skip primary and use fallback
|
||||
expect(result.result).toBe("ok");
|
||||
expect(run).toHaveBeenCalledTimes(1);
|
||||
expect(run).toHaveBeenCalledWith("anthropic", "claude-haiku-3-5");
|
||||
expect(result.attempts[0]?.reason).toBe("rate_limit");
|
||||
});
|
||||
|
||||
it("probes primary model when within 2-min margin of cooldown expiry", async () => {
|
||||
const cfg = makeCfg();
|
||||
// Cooldown expires in 1 minute — within 2-min probe margin
|
||||
const expiresIn1Min = NOW + 60 * 1000;
|
||||
mockedGetSoonestCooldownExpiry.mockReturnValue(expiresIn1Min);
|
||||
|
||||
const run = vi.fn().mockResolvedValue("probed-ok");
|
||||
|
||||
const result = await runWithModelFallback({
|
||||
cfg,
|
||||
provider: "openai",
|
||||
model: "gpt-4.1-mini",
|
||||
run,
|
||||
});
|
||||
|
||||
// Should probe primary and succeed
|
||||
expect(result.result).toBe("probed-ok");
|
||||
expect(run).toHaveBeenCalledTimes(1);
|
||||
expect(run).toHaveBeenCalledWith("openai", "gpt-4.1-mini");
|
||||
});
|
||||
|
||||
it("probes primary model when cooldown already expired", async () => {
|
||||
const cfg = makeCfg();
|
||||
// Cooldown expired 5 min ago
|
||||
const expiredAlready = NOW - 5 * 60 * 1000;
|
||||
mockedGetSoonestCooldownExpiry.mockReturnValue(expiredAlready);
|
||||
|
||||
const run = vi.fn().mockResolvedValue("recovered");
|
||||
|
||||
const result = await runWithModelFallback({
|
||||
cfg,
|
||||
provider: "openai",
|
||||
model: "gpt-4.1-mini",
|
||||
run,
|
||||
});
|
||||
|
||||
expect(result.result).toBe("recovered");
|
||||
expect(run).toHaveBeenCalledTimes(1);
|
||||
expect(run).toHaveBeenCalledWith("openai", "gpt-4.1-mini");
|
||||
});
|
||||
|
||||
it("does NOT probe non-primary candidates during cooldown", async () => {
|
||||
const cfg = makeCfg({
|
||||
agents: {
|
||||
defaults: {
|
||||
model: {
|
||||
primary: "openai/gpt-4.1-mini",
|
||||
fallbacks: ["anthropic/claude-haiku-3-5", "google/gemini-2-flash"],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as Partial<OpenClawConfig>);
|
||||
|
||||
// Override: ALL providers in cooldown for this test
|
||||
mockedIsProfileInCooldown.mockReturnValue(true);
|
||||
|
||||
// All profiles in cooldown, cooldown just about to expire
|
||||
const almostExpired = NOW + 30 * 1000; // 30s remaining
|
||||
mockedGetSoonestCooldownExpiry.mockReturnValue(almostExpired);
|
||||
|
||||
// Primary probe fails with 429
|
||||
const run = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(Object.assign(new Error("rate limited"), { status: 429 }))
|
||||
.mockResolvedValue("should-not-reach");
|
||||
|
||||
try {
|
||||
await runWithModelFallback({
|
||||
cfg,
|
||||
provider: "openai",
|
||||
model: "gpt-4.1-mini",
|
||||
run,
|
||||
});
|
||||
expect.unreachable("should have thrown since all candidates exhausted");
|
||||
} catch {
|
||||
// Primary was probed (i === 0 + within margin), non-primary were skipped
|
||||
expect(run).toHaveBeenCalledTimes(1); // only primary was actually called
|
||||
expect(run).toHaveBeenCalledWith("openai", "gpt-4.1-mini");
|
||||
}
|
||||
});
|
||||
|
||||
it("throttles probe when called within 30s interval", async () => {
|
||||
const cfg = makeCfg();
|
||||
// Cooldown just about to expire (within probe margin)
|
||||
const almostExpired = NOW + 30 * 1000;
|
||||
mockedGetSoonestCooldownExpiry.mockReturnValue(almostExpired);
|
||||
|
||||
// Simulate a recent probe 10s ago
|
||||
_probeThrottleInternals.lastProbeAttempt.set("openai", NOW - 10_000);
|
||||
|
||||
const run = vi.fn().mockResolvedValue("ok");
|
||||
|
||||
const result = await runWithModelFallback({
|
||||
cfg,
|
||||
provider: "openai",
|
||||
model: "gpt-4.1-mini",
|
||||
run,
|
||||
});
|
||||
|
||||
// Should be throttled → skip primary, use fallback
|
||||
expect(result.result).toBe("ok");
|
||||
expect(run).toHaveBeenCalledTimes(1);
|
||||
expect(run).toHaveBeenCalledWith("anthropic", "claude-haiku-3-5");
|
||||
expect(result.attempts[0]?.reason).toBe("rate_limit");
|
||||
});
|
||||
|
||||
it("allows probe when 30s have passed since last probe", async () => {
|
||||
const cfg = makeCfg();
|
||||
const almostExpired = NOW + 30 * 1000;
|
||||
mockedGetSoonestCooldownExpiry.mockReturnValue(almostExpired);
|
||||
|
||||
// Last probe was 31s ago — should NOT be throttled
|
||||
_probeThrottleInternals.lastProbeAttempt.set("openai", NOW - 31_000);
|
||||
|
||||
const run = vi.fn().mockResolvedValue("probed-ok");
|
||||
|
||||
const result = await runWithModelFallback({
|
||||
cfg,
|
||||
provider: "openai",
|
||||
model: "gpt-4.1-mini",
|
||||
run,
|
||||
});
|
||||
|
||||
expect(result.result).toBe("probed-ok");
|
||||
expect(run).toHaveBeenCalledTimes(1);
|
||||
expect(run).toHaveBeenCalledWith("openai", "gpt-4.1-mini");
|
||||
});
|
||||
|
||||
it("handles non-finite soonest safely (treats as probe-worthy)", async () => {
|
||||
const cfg = makeCfg();
|
||||
|
||||
// Return Infinity — should be treated as "probe" per the guard
|
||||
mockedGetSoonestCooldownExpiry.mockReturnValue(Infinity);
|
||||
|
||||
const run = vi.fn().mockResolvedValue("ok-infinity");
|
||||
|
||||
const result = await runWithModelFallback({
|
||||
cfg,
|
||||
provider: "openai",
|
||||
model: "gpt-4.1-mini",
|
||||
run,
|
||||
});
|
||||
|
||||
expect(result.result).toBe("ok-infinity");
|
||||
expect(run).toHaveBeenCalledWith("openai", "gpt-4.1-mini");
|
||||
});
|
||||
|
||||
it("handles NaN soonest safely (treats as probe-worthy)", async () => {
|
||||
const cfg = makeCfg();
|
||||
|
||||
mockedGetSoonestCooldownExpiry.mockReturnValue(NaN);
|
||||
|
||||
const run = vi.fn().mockResolvedValue("ok-nan");
|
||||
|
||||
const result = await runWithModelFallback({
|
||||
cfg,
|
||||
provider: "openai",
|
||||
model: "gpt-4.1-mini",
|
||||
run,
|
||||
});
|
||||
|
||||
expect(result.result).toBe("ok-nan");
|
||||
expect(run).toHaveBeenCalledWith("openai", "gpt-4.1-mini");
|
||||
});
|
||||
|
||||
it("handles null soonest safely (treats as probe-worthy)", async () => {
|
||||
const cfg = makeCfg();
|
||||
|
||||
mockedGetSoonestCooldownExpiry.mockReturnValue(null);
|
||||
|
||||
const run = vi.fn().mockResolvedValue("ok-null");
|
||||
|
||||
const result = await runWithModelFallback({
|
||||
cfg,
|
||||
provider: "openai",
|
||||
model: "gpt-4.1-mini",
|
||||
run,
|
||||
});
|
||||
|
||||
expect(result.result).toBe("ok-null");
|
||||
expect(run).toHaveBeenCalledWith("openai", "gpt-4.1-mini");
|
||||
});
|
||||
|
||||
it("single candidate skips with rate_limit and exhausts candidates", async () => {
|
||||
const cfg = makeCfg({
|
||||
agents: {
|
||||
defaults: {
|
||||
model: {
|
||||
primary: "openai/gpt-4.1-mini",
|
||||
fallbacks: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as Partial<OpenClawConfig>);
|
||||
|
||||
const almostExpired = NOW + 30 * 1000;
|
||||
mockedGetSoonestCooldownExpiry.mockReturnValue(almostExpired);
|
||||
|
||||
const run = vi.fn().mockResolvedValue("unreachable");
|
||||
|
||||
await expect(
|
||||
runWithModelFallback({
|
||||
cfg,
|
||||
provider: "openai",
|
||||
model: "gpt-4.1-mini",
|
||||
fallbacksOverride: [],
|
||||
run,
|
||||
}),
|
||||
).rejects.toThrow("All models failed");
|
||||
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("scopes probe throttling by agentDir to avoid cross-agent suppression", async () => {
|
||||
const cfg = makeCfg();
|
||||
const almostExpired = NOW + 30 * 1000;
|
||||
mockedGetSoonestCooldownExpiry.mockReturnValue(almostExpired);
|
||||
|
||||
const run = vi.fn().mockResolvedValue("probed-ok");
|
||||
|
||||
await runWithModelFallback({
|
||||
cfg,
|
||||
provider: "openai",
|
||||
model: "gpt-4.1-mini",
|
||||
agentDir: "/tmp/agent-a",
|
||||
run,
|
||||
});
|
||||
|
||||
await runWithModelFallback({
|
||||
cfg,
|
||||
provider: "openai",
|
||||
model: "gpt-4.1-mini",
|
||||
agentDir: "/tmp/agent-b",
|
||||
run,
|
||||
});
|
||||
|
||||
expect(run).toHaveBeenNthCalledWith(1, "openai", "gpt-4.1-mini");
|
||||
expect(run).toHaveBeenNthCalledWith(2, "openai", "gpt-4.1-mini");
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import type { OpenClawConfig } from "../config/config.js";
|
||||
import type { FailoverReason } from "./pi-embedded-helpers.js";
|
||||
import {
|
||||
ensureAuthProfileStore,
|
||||
getSoonestCooldownExpiry,
|
||||
isProfileInCooldown,
|
||||
resolveAuthProfileOrder,
|
||||
} from "./auth-profiles.js";
|
||||
@@ -217,6 +218,50 @@ function resolveFallbackCandidates(params: {
|
||||
return candidates;
|
||||
}
|
||||
|
||||
const lastProbeAttempt = new Map<string, number>();
|
||||
const MIN_PROBE_INTERVAL_MS = 30_000; // 30 seconds between probes per key
|
||||
const PROBE_MARGIN_MS = 2 * 60 * 1000;
|
||||
const PROBE_SCOPE_DELIMITER = "::";
|
||||
|
||||
function resolveProbeThrottleKey(provider: string, agentDir?: string): string {
|
||||
const scope = String(agentDir ?? "").trim();
|
||||
return scope ? `${scope}${PROBE_SCOPE_DELIMITER}${provider}` : provider;
|
||||
}
|
||||
|
||||
function shouldProbePrimaryDuringCooldown(params: {
|
||||
isPrimary: boolean;
|
||||
hasFallbackCandidates: boolean;
|
||||
now: number;
|
||||
throttleKey: string;
|
||||
authStore: ReturnType<typeof ensureAuthProfileStore>;
|
||||
profileIds: string[];
|
||||
}): boolean {
|
||||
if (!params.isPrimary || !params.hasFallbackCandidates) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const lastProbe = lastProbeAttempt.get(params.throttleKey) ?? 0;
|
||||
if (params.now - lastProbe < MIN_PROBE_INTERVAL_MS) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const soonest = getSoonestCooldownExpiry(params.authStore, params.profileIds);
|
||||
if (soonest === null || !Number.isFinite(soonest)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Probe when cooldown already expired or within the configured margin.
|
||||
return params.now >= soonest - PROBE_MARGIN_MS;
|
||||
}
|
||||
|
||||
/** @internal – exposed for unit tests only */
|
||||
export const _probeThrottleInternals = {
|
||||
lastProbeAttempt,
|
||||
MIN_PROBE_INTERVAL_MS,
|
||||
PROBE_MARGIN_MS,
|
||||
resolveProbeThrottleKey,
|
||||
} as const;
|
||||
|
||||
export async function runWithModelFallback<T>(params: {
|
||||
cfg: OpenClawConfig | undefined;
|
||||
provider: string;
|
||||
@@ -239,6 +284,8 @@ export async function runWithModelFallback<T>(params: {
|
||||
const attempts: FallbackAttempt[] = [];
|
||||
let lastError: unknown;
|
||||
|
||||
const hasFallbackCandidates = candidates.length > 1;
|
||||
|
||||
for (let i = 0; i < candidates.length; i += 1) {
|
||||
const candidate = candidates[i];
|
||||
if (authStore) {
|
||||
@@ -250,14 +297,34 @@ export async function runWithModelFallback<T>(params: {
|
||||
const isAnyProfileAvailable = profileIds.some((id) => !isProfileInCooldown(authStore, id));
|
||||
|
||||
if (profileIds.length > 0 && !isAnyProfileAvailable) {
|
||||
// All profiles for this provider are in cooldown; skip without attempting
|
||||
attempts.push({
|
||||
provider: candidate.provider,
|
||||
model: candidate.model,
|
||||
error: `Provider ${candidate.provider} is in cooldown (all profiles unavailable)`,
|
||||
reason: "rate_limit",
|
||||
// All profiles for this provider are in cooldown.
|
||||
// For the primary model (i === 0), probe it if the soonest cooldown
|
||||
// expiry is close or already past. This avoids staying on a fallback
|
||||
// model long after the real rate-limit window clears.
|
||||
const now = Date.now();
|
||||
const probeThrottleKey = resolveProbeThrottleKey(candidate.provider, params.agentDir);
|
||||
const shouldProbe = shouldProbePrimaryDuringCooldown({
|
||||
isPrimary: i === 0,
|
||||
hasFallbackCandidates,
|
||||
now,
|
||||
throttleKey: probeThrottleKey,
|
||||
authStore,
|
||||
profileIds,
|
||||
});
|
||||
continue;
|
||||
if (!shouldProbe) {
|
||||
// Skip without attempting
|
||||
attempts.push({
|
||||
provider: candidate.provider,
|
||||
model: candidate.model,
|
||||
error: `Provider ${candidate.provider} is in cooldown (all profiles unavailable)`,
|
||||
reason: "rate_limit",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
// Primary model probe: attempt it despite cooldown to detect recovery.
|
||||
// If it fails, the error is caught below and we fall through to the
|
||||
// next candidate as usual.
|
||||
lastProbeAttempt.set(probeThrottleKey, now);
|
||||
}
|
||||
}
|
||||
try {
|
||||
|
||||
@@ -115,6 +115,15 @@ describe("openai-responses reasoning replay", () => {
|
||||
expect(types).toContain("reasoning");
|
||||
expect(types).toContain("function_call");
|
||||
expect(types.indexOf("reasoning")).toBeLessThan(types.indexOf("function_call"));
|
||||
|
||||
const functionCall = input.find(
|
||||
(item) =>
|
||||
item &&
|
||||
typeof item === "object" &&
|
||||
(item as Record<string, unknown>).type === "function_call",
|
||||
) as Record<string, unknown> | undefined;
|
||||
expect(functionCall?.call_id).toBe("call_123");
|
||||
expect(functionCall?.id).toBe("fc_123");
|
||||
});
|
||||
|
||||
it("still replays reasoning when paired with an assistant message", async () => {
|
||||
|
||||
@@ -160,6 +160,35 @@ describe("sanitizeSessionMessagesImages", () => {
|
||||
const toolResult = out[1] as { toolUseId?: string };
|
||||
expect(toolResult.toolUseId).toBe("callabcitem123");
|
||||
});
|
||||
|
||||
it("does not sanitize tool IDs in images-only mode", async () => {
|
||||
const input = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "toolCall", id: "call_123|fc_456", name: "read", arguments: {} }],
|
||||
},
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: "call_123|fc_456",
|
||||
toolName: "read",
|
||||
content: [{ type: "text", text: "ok" }],
|
||||
isError: false,
|
||||
},
|
||||
] satisfies AgentMessage[];
|
||||
|
||||
const out = await sanitizeSessionMessagesImages(input, "test", {
|
||||
sanitizeMode: "images-only",
|
||||
sanitizeToolCallIds: true,
|
||||
toolCallIdMode: "strict",
|
||||
});
|
||||
|
||||
const assistant = out[0] as unknown as { content?: Array<{ type?: string; id?: string }> };
|
||||
const toolCall = assistant.content?.find((b) => b.type === "toolCall");
|
||||
expect(toolCall?.id).toBe("call_123|fc_456");
|
||||
|
||||
const toolResult = out[1] as unknown as { toolCallId?: string };
|
||||
expect(toolResult.toolCallId).toBe("call_123|fc_456");
|
||||
});
|
||||
it("filters whitespace-only assistant text blocks", async () => {
|
||||
const input = [
|
||||
{
|
||||
|
||||
@@ -294,6 +294,27 @@ describe("downgradeOpenAIReasoningBlocks", () => {
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
expect(downgradeOpenAIReasoningBlocks(input as any)).toEqual(input);
|
||||
});
|
||||
|
||||
it("is idempotent for orphaned reasoning cleanup", () => {
|
||||
const input = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "thinking",
|
||||
thinkingSignature: JSON.stringify({ id: "rs_orphan", type: "reasoning" }),
|
||||
},
|
||||
],
|
||||
},
|
||||
{ role: "user", content: "next" },
|
||||
];
|
||||
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
const once = downgradeOpenAIReasoningBlocks(input as any);
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
const twice = downgradeOpenAIReasoningBlocks(once as any);
|
||||
expect(twice).toEqual(once);
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeTextForComparison", () => {
|
||||
|
||||
@@ -51,9 +51,10 @@ export async function sanitizeSessionMessagesImages(
|
||||
const allowNonImageSanitization = sanitizeMode === "full";
|
||||
// We sanitize historical session messages because Anthropic can reject a request
|
||||
// if the transcript contains oversized base64 images (see MAX_IMAGE_DIMENSION_PX).
|
||||
const sanitizedIds = options?.sanitizeToolCallIds
|
||||
? sanitizeToolCallIdsForCloudCodeAssist(messages, options.toolCallIdMode)
|
||||
: messages;
|
||||
const sanitizedIds =
|
||||
allowNonImageSanitization && options?.sanitizeToolCallIds
|
||||
? sanitizeToolCallIdsForCloudCodeAssist(messages, options.toolCallIdMode)
|
||||
: messages;
|
||||
const out: AgentMessage[] = [];
|
||||
for (const msg of sanitizedIds) {
|
||||
if (!msg || typeof msg !== "object") {
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
makeInMemorySessionManager,
|
||||
makeModelSnapshotEntry,
|
||||
} from "./pi-embedded-runner.sanitize-session-history.test-harness.js";
|
||||
import { sanitizeSessionHistory } from "./pi-embedded-runner/google.js";
|
||||
|
||||
describe("sanitizeSessionHistory openai tool id preservation", () => {
|
||||
it("keeps canonical call_id|fc_id pairings for same-model openai replay", async () => {
|
||||
const sessionEntries = [
|
||||
makeModelSnapshotEntry({
|
||||
provider: "openai",
|
||||
modelApi: "openai-responses",
|
||||
modelId: "gpt-5.2-codex",
|
||||
}),
|
||||
];
|
||||
const sessionManager = makeInMemorySessionManager(sessionEntries);
|
||||
|
||||
const messages: AgentMessage[] = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "toolCall", id: "call_123|fc_123", name: "noop", arguments: {} }],
|
||||
},
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: "call_123|fc_123",
|
||||
toolName: "noop",
|
||||
content: [{ type: "text", text: "ok" }],
|
||||
isError: false,
|
||||
} as unknown as AgentMessage,
|
||||
];
|
||||
|
||||
const result = await sanitizeSessionHistory({
|
||||
messages,
|
||||
modelApi: "openai-responses",
|
||||
provider: "openai",
|
||||
modelId: "gpt-5.2-codex",
|
||||
sessionManager,
|
||||
sessionId: "test-session",
|
||||
});
|
||||
|
||||
const assistant = result[0] as { content?: Array<{ type?: string; id?: string }> };
|
||||
const toolCall = assistant.content?.find((block) => block.type === "toolCall");
|
||||
expect(toolCall?.id).toBe("call_123|fc_123");
|
||||
|
||||
const toolResult = result[1] as { toolCallId?: string };
|
||||
expect(toolResult.toolCallId).toBe("call_123|fc_123");
|
||||
});
|
||||
});
|
||||
@@ -52,7 +52,7 @@ describe("sanitizeSessionHistory e2e smoke", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("applies strict tool-call sanitization for openai-responses", async () => {
|
||||
it("keeps images-only sanitize policy without tool-call id rewriting for openai-responses", async () => {
|
||||
vi.mocked(helpers.isGoogleModelApi).mockReturnValue(false);
|
||||
|
||||
await sanitizeSessionHistory({
|
||||
@@ -68,8 +68,7 @@ describe("sanitizeSessionHistory e2e smoke", () => {
|
||||
"session:history",
|
||||
expect.objectContaining({
|
||||
sanitizeMode: "images-only",
|
||||
sanitizeToolCallIds: true,
|
||||
toolCallIdMode: "strict",
|
||||
sanitizeToolCallIds: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -98,7 +98,7 @@ describe("sanitizeSessionHistory", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("sanitizes tool call ids for openai-responses while keeping images-only mode", async () => {
|
||||
it("does not sanitize tool call ids for openai-responses", async () => {
|
||||
vi.mocked(helpers.isGoogleModelApi).mockReturnValue(false);
|
||||
|
||||
await sanitizeSessionHistory({
|
||||
@@ -112,11 +112,7 @@ describe("sanitizeSessionHistory", () => {
|
||||
expect(helpers.sanitizeSessionMessagesImages).toHaveBeenCalledWith(
|
||||
mockMessages,
|
||||
"session:history",
|
||||
expect.objectContaining({
|
||||
sanitizeMode: "images-only",
|
||||
sanitizeToolCallIds: true,
|
||||
toolCallIdMode: "strict",
|
||||
}),
|
||||
expect.objectContaining({ sanitizeMode: "images-only", sanitizeToolCallIds: false }),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -243,7 +239,7 @@ describe("sanitizeSessionHistory", () => {
|
||||
expect(result).toEqual(messages);
|
||||
});
|
||||
|
||||
it("downgrades openai reasoning only when the model changes", async () => {
|
||||
it("downgrades openai reasoning when the model changes", async () => {
|
||||
const sessionEntries = [
|
||||
makeModelSnapshotEntry({
|
||||
provider: "anthropic",
|
||||
|
||||
@@ -471,6 +471,7 @@ export async function runEmbeddedPiAgent(
|
||||
blockReplyBreak: params.blockReplyBreak,
|
||||
blockReplyChunking: params.blockReplyChunking,
|
||||
onReasoningStream: params.onReasoningStream,
|
||||
onReasoningEnd: params.onReasoningEnd,
|
||||
onToolResult: params.onToolResult,
|
||||
onAgentEvent: params.onAgentEvent,
|
||||
extraSystemPrompt: params.extraSystemPrompt,
|
||||
|
||||
@@ -737,6 +737,7 @@ export async function runEmbeddedAttempt(
|
||||
shouldEmitToolOutput: params.shouldEmitToolOutput,
|
||||
onToolResult: params.onToolResult,
|
||||
onReasoningStream: params.onReasoningStream,
|
||||
onReasoningEnd: params.onReasoningEnd,
|
||||
onBlockReply: params.onBlockReply,
|
||||
onBlockReplyFlush: params.onBlockReplyFlush,
|
||||
blockReplyBreak: params.blockReplyBreak,
|
||||
|
||||
@@ -95,6 +95,7 @@ export type RunEmbeddedPiAgentParams = {
|
||||
blockReplyBreak?: "text_end" | "message_end";
|
||||
blockReplyChunking?: BlockReplyChunking;
|
||||
onReasoningStream?: (payload: { text?: string; mediaUrls?: string[] }) => void | Promise<void>;
|
||||
onReasoningEnd?: () => void | Promise<void>;
|
||||
onToolResult?: (payload: { text?: string; mediaUrls?: string[] }) => void | Promise<void>;
|
||||
onAgentEvent?: (evt: { stream: string; data: Record<string, unknown> }) => void;
|
||||
lane?: string;
|
||||
|
||||
@@ -184,6 +184,29 @@ describe("buildEmbeddedRunPayloads", () => {
|
||||
expect(payloads[0]?.text).toContain("code 1");
|
||||
});
|
||||
|
||||
it("does not add tool error fallback when assistant text exists after tool calls", () => {
|
||||
const payloads = buildPayloads({
|
||||
assistantTexts: ["Checked the page and recovered with final answer."],
|
||||
lastAssistant: makeAssistant({
|
||||
stopReason: "toolUse",
|
||||
errorMessage: undefined,
|
||||
content: [
|
||||
{
|
||||
type: "toolCall",
|
||||
id: "toolu_01",
|
||||
name: "browser",
|
||||
arguments: { action: "search", query: "openclaw docs" },
|
||||
},
|
||||
],
|
||||
}),
|
||||
lastToolError: { toolName: "browser", error: "connection timeout" },
|
||||
});
|
||||
|
||||
expect(payloads).toHaveLength(1);
|
||||
expect(payloads[0]?.isError).toBeUndefined();
|
||||
expect(payloads[0]?.text).toContain("recovered");
|
||||
});
|
||||
|
||||
it("suppresses recoverable tool errors containing 'required' for non-mutating tools", () => {
|
||||
const payloads = buildPayloads({
|
||||
lastToolError: { toolName: "browser", error: "url required" },
|
||||
|
||||
@@ -218,6 +218,7 @@ export function buildEmbeddedRunPayloads(params: {
|
||||
: []
|
||||
).filter((text) => !shouldSuppressRawErrorText(text));
|
||||
|
||||
let hasUserFacingAssistantReply = false;
|
||||
for (const text of answerTexts) {
|
||||
const {
|
||||
text: cleanedText,
|
||||
@@ -238,22 +239,13 @@ export function buildEmbeddedRunPayloads(params: {
|
||||
replyToTag,
|
||||
replyToCurrent,
|
||||
});
|
||||
hasUserFacingAssistantReply = true;
|
||||
}
|
||||
|
||||
if (params.lastToolError) {
|
||||
const lastAssistantHasToolCalls =
|
||||
Array.isArray(params.lastAssistant?.content) &&
|
||||
params.lastAssistant?.content.some((block) =>
|
||||
block && typeof block === "object"
|
||||
? (block as { type?: unknown }).type === "toolCall"
|
||||
: false,
|
||||
);
|
||||
const lastAssistantWasToolUse = params.lastAssistant?.stopReason === "toolUse";
|
||||
const hasUserFacingReply =
|
||||
replyItems.length > 0 && !lastAssistantHasToolCalls && !lastAssistantWasToolUse;
|
||||
const shouldShowToolError = shouldShowToolErrorWarning({
|
||||
lastToolError: params.lastToolError,
|
||||
hasUserFacingReply,
|
||||
hasUserFacingReply: hasUserFacingAssistantReply,
|
||||
suppressToolErrors: Boolean(params.config?.messages?.suppressToolErrors),
|
||||
});
|
||||
|
||||
|
||||
@@ -140,7 +140,12 @@ export function handleMessageUpdate(
|
||||
})
|
||||
.trim();
|
||||
if (next) {
|
||||
const wasThinking = ctx.state.partialBlockState.thinking;
|
||||
const visibleDelta = chunk ? ctx.stripBlockTags(chunk, ctx.state.partialBlockState) : "";
|
||||
// Detect when thinking block ends (</think> tag processed)
|
||||
if (wasThinking && !ctx.state.partialBlockState.thinking) {
|
||||
void ctx.params.onReasoningEnd?.();
|
||||
}
|
||||
const parsedDelta = visibleDelta ? ctx.consumePartialReplyDirectives(visibleDelta) : null;
|
||||
const parsedFull = parseReplyDirectives(stripTrailingDirective(next));
|
||||
const cleanedText = parsedFull.text;
|
||||
|
||||
@@ -17,6 +17,8 @@ export type SubscribeEmbeddedPiSessionParams = {
|
||||
shouldEmitToolOutput?: () => boolean;
|
||||
onToolResult?: (payload: { text?: string; mediaUrls?: string[] }) => void | Promise<void>;
|
||||
onReasoningStream?: (payload: { text?: string; mediaUrls?: string[] }) => void | Promise<void>;
|
||||
/** Called when a thinking/reasoning block ends (</think> tag processed). */
|
||||
onReasoningEnd?: () => void | Promise<void>;
|
||||
onBlockReply?: (payload: {
|
||||
text?: string;
|
||||
mediaUrls?: string[];
|
||||
|
||||
@@ -78,7 +78,15 @@ describe("validateBindMounts", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "openclaw-sbx-"));
|
||||
const link = join(dir, "etc-link");
|
||||
symlinkSync("/etc", link);
|
||||
expect(() => validateBindMounts([`${link}/passwd:/mnt/passwd:ro`])).toThrow(/blocked path/);
|
||||
const run = () => validateBindMounts([`${link}/passwd:/mnt/passwd:ro`]);
|
||||
|
||||
if (process.platform === "win32") {
|
||||
// Windows source paths (e.g. C:\...) are intentionally rejected as non-POSIX.
|
||||
expect(run).toThrow(/non-absolute source path/);
|
||||
return;
|
||||
}
|
||||
|
||||
expect(run).toThrow(/blocked path/);
|
||||
});
|
||||
|
||||
it("rejects non-absolute source paths (relative or named volumes)", () => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js";
|
||||
|
||||
const agentSpy = vi.fn(async () => ({ runId: "run-main", status: "ok" }));
|
||||
const sessionsDeleteSpy = vi.fn();
|
||||
@@ -22,6 +23,17 @@ let configOverride: ReturnType<(typeof import("../config/config.js"))["loadConfi
|
||||
},
|
||||
};
|
||||
|
||||
function loadSessionStoreFixture(): Record<string, Record<string, unknown>> {
|
||||
return new Proxy(sessionStore, {
|
||||
get(target, key: string | symbol) {
|
||||
if (typeof key === "string" && !(key in target) && key.includes(":subagent:")) {
|
||||
return { inputTokens: 1, outputTokens: 1, totalTokens: 2 };
|
||||
}
|
||||
return target[key as keyof typeof target];
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
vi.mock("../gateway/call.js", () => ({
|
||||
callGateway: vi.fn(async (req: unknown) => {
|
||||
const typed = req as { method?: string; params?: { message?: string; sessionKey?: string } };
|
||||
@@ -47,7 +59,7 @@ vi.mock("./tools/agent-step.js", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("../config/sessions.js", () => ({
|
||||
loadSessionStore: vi.fn(() => sessionStore),
|
||||
loadSessionStore: vi.fn(() => loadSessionStoreFixture()),
|
||||
resolveAgentIdFromSessionKey: () => "main",
|
||||
resolveStorePath: () => "/tmp/sessions.json",
|
||||
resolveMainSessionKey: () => "agent:main:main",
|
||||
@@ -93,6 +105,9 @@ describe("subagent announce formatting", () => {
|
||||
sessionStore = {
|
||||
"agent:main:subagent:test": {
|
||||
sessionId: "child-session-123",
|
||||
inputTokens: 1,
|
||||
outputTokens: 1,
|
||||
totalTokens: 2,
|
||||
},
|
||||
};
|
||||
await runSubagentAnnounceFlow({
|
||||
@@ -208,7 +223,7 @@ describe("subagent announce formatting", () => {
|
||||
expect(msg).toContain("[sessionId: child-session-usage]");
|
||||
expect(msg).toContain("A completed subagent task is ready for user delivery.");
|
||||
expect(msg).toContain(
|
||||
"Reply ONLY: NO_REPLY if this exact result was already delivered to the user in this same turn.",
|
||||
`Reply ONLY: ${SILENT_REPLY_TOKEN} if this exact result was already delivered to the user in this same turn.`,
|
||||
);
|
||||
expect(msg).toContain("step-0");
|
||||
expect(msg).toContain("step-139");
|
||||
@@ -580,6 +595,9 @@ describe("subagent announce formatting", () => {
|
||||
sessionStore = {
|
||||
"agent:main:subagent:test": {
|
||||
sessionId: "child-session-1",
|
||||
inputTokens: 1,
|
||||
outputTokens: 1,
|
||||
totalTokens: 2,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -742,34 +760,6 @@ describe("subagent announce formatting", () => {
|
||||
expect(agentSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not delete child session when announce is deferred for an active run", async () => {
|
||||
const { runSubagentAnnounceFlow } = await import("./subagent-announce.js");
|
||||
embeddedRunMock.isEmbeddedPiRunActive.mockReturnValue(true);
|
||||
embeddedRunMock.waitForEmbeddedPiRunEnd.mockResolvedValue(false);
|
||||
sessionStore = {
|
||||
"agent:main:subagent:test": {
|
||||
sessionId: "child-session-active",
|
||||
},
|
||||
};
|
||||
|
||||
const didAnnounce = await runSubagentAnnounceFlow({
|
||||
childSessionKey: "agent:main:subagent:test",
|
||||
childRunId: "run-child-active-delete",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
requesterDisplayKey: "main",
|
||||
task: "context-stress-test",
|
||||
timeoutMs: 1000,
|
||||
cleanup: "delete",
|
||||
waitForCompletion: false,
|
||||
startedAt: 10,
|
||||
endedAt: 20,
|
||||
outcome: { status: "ok" },
|
||||
});
|
||||
|
||||
expect(didAnnounce).toBe(false);
|
||||
expect(sessionsDeleteSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("normalizes requesterOrigin for direct announce delivery", async () => {
|
||||
const { runSubagentAnnounceFlow } = await import("./subagent-announce.js");
|
||||
embeddedRunMock.isEmbeddedPiRunActive.mockReturnValue(false);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { resolveQueueSettings } from "../auto-reply/reply/queue.js";
|
||||
import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import {
|
||||
loadSessionStore,
|
||||
@@ -364,9 +365,9 @@ function buildAnnounceReplyInstruction(params: {
|
||||
return `There are still ${params.remainingActiveSubagentRuns} active subagent ${activeRunsLabel} for this session. If they are part of the same workflow, wait for the remaining results before sending a user update. If they are unrelated, respond normally using only the result above.`;
|
||||
}
|
||||
if (params.requesterIsSubagent) {
|
||||
return "Convert this completion into a concise internal orchestration update for your parent agent in your own words. Keep this internal context private (don't mention system/log/stats/session details or announce type). If this result is duplicate or no update is needed, reply ONLY: NO_REPLY.";
|
||||
return `Convert this completion into a concise internal orchestration update for your parent agent in your own words. Keep this internal context private (don't mention system/log/stats/session details or announce type). If this result is duplicate or no update is needed, reply ONLY: ${SILENT_REPLY_TOKEN}.`;
|
||||
}
|
||||
return `A completed ${params.announceType} is ready for user delivery. Convert the result above into your normal assistant voice and send that user-facing update now. Keep this internal context private (don't mention system/log/stats/session details or announce type), and do not copy the system message verbatim. Reply ONLY: NO_REPLY if this exact result was already delivered to the user in this same turn.`;
|
||||
return `A completed ${params.announceType} is ready for user delivery. Convert the result above into your normal assistant voice and send that user-facing update now. Keep this internal context private (don't mention system/log/stats/session details or announce type), and do not copy the system message verbatim. Reply ONLY: ${SILENT_REPLY_TOKEN} if this exact result was already delivered to the user in this same turn.`;
|
||||
}
|
||||
|
||||
export async function runSubagentAnnounceFlow(params: {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js";
|
||||
import { buildSubagentSystemPrompt } from "./subagent-announce.js";
|
||||
import { buildAgentSystemPrompt, buildRuntimeLine } from "./system-prompt.js";
|
||||
|
||||
@@ -367,7 +368,7 @@ describe("buildAgentSystemPrompt", () => {
|
||||
|
||||
expect(prompt).toContain("message: Send messages and channel actions");
|
||||
expect(prompt).toContain("### message tool");
|
||||
expect(prompt).toContain("respond with ONLY: NO_REPLY");
|
||||
expect(prompt).toContain(`respond with ONLY: ${SILENT_REPLY_TOKEN}`);
|
||||
});
|
||||
|
||||
it("includes runtime provider capabilities when present", () => {
|
||||
|
||||
@@ -112,7 +112,7 @@ function buildMessagingSection(params: {
|
||||
"- Cross-session messaging → use sessions_send(sessionKey, message)",
|
||||
"- Sub-agent orchestration → use subagents(action=list|steer|kill)",
|
||||
"- `[System Message] ...` blocks are internal context and are not user-visible by default.",
|
||||
"- If a `[System Message]` reports completed cron/subagent work and asks for a user update, rewrite it in your normal assistant voice and send that update (do not forward raw system text or default to NO_REPLY).",
|
||||
`- If a \`[System Message]\` reports completed cron/subagent work and asks for a user update, rewrite it in your normal assistant voice and send that update (do not forward raw system text or default to ${SILENT_REPLY_TOKEN}).`,
|
||||
"- Never use exec/curl for provider messaging; OpenClaw handles all routing internally.",
|
||||
params.availableTools.has("message")
|
||||
? [
|
||||
|
||||
@@ -443,4 +443,61 @@ describe("cron tool", () => {
|
||||
};
|
||||
expect(call?.params?.delivery).toEqual({ mode: "none" });
|
||||
});
|
||||
|
||||
it("does not infer announce delivery when mode is webhook", async () => {
|
||||
callGatewayMock.mockResolvedValueOnce({ ok: true });
|
||||
|
||||
const tool = createCronTool({ agentSessionKey: "agent:main:discord:dm:buddy" });
|
||||
await tool.execute("call-webhook-explicit", {
|
||||
action: "add",
|
||||
job: {
|
||||
name: "reminder",
|
||||
schedule: { at: new Date(123).toISOString() },
|
||||
payload: { kind: "agentTurn", message: "hello" },
|
||||
delivery: { mode: "webhook", to: "https://example.invalid/cron-finished" },
|
||||
},
|
||||
});
|
||||
|
||||
const call = callGatewayMock.mock.calls[0]?.[0] as {
|
||||
params?: { delivery?: { mode?: string; channel?: string; to?: string } };
|
||||
};
|
||||
expect(call?.params?.delivery).toEqual({
|
||||
mode: "webhook",
|
||||
to: "https://example.invalid/cron-finished",
|
||||
});
|
||||
});
|
||||
|
||||
it("fails fast when webhook mode is missing delivery.to", async () => {
|
||||
const tool = createCronTool({ agentSessionKey: "agent:main:discord:dm:buddy" });
|
||||
|
||||
await expect(
|
||||
tool.execute("call-webhook-missing", {
|
||||
action: "add",
|
||||
job: {
|
||||
name: "reminder",
|
||||
schedule: { at: new Date(123).toISOString() },
|
||||
payload: { kind: "agentTurn", message: "hello" },
|
||||
delivery: { mode: "webhook" },
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow('delivery.mode="webhook" requires delivery.to to be a valid http(s) URL');
|
||||
expect(callGatewayMock).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it("fails fast when webhook mode uses a non-http URL", async () => {
|
||||
const tool = createCronTool({ agentSessionKey: "agent:main:discord:dm:buddy" });
|
||||
|
||||
await expect(
|
||||
tool.execute("call-webhook-invalid", {
|
||||
action: "add",
|
||||
job: {
|
||||
name: "reminder",
|
||||
schedule: { at: new Date(123).toISOString() },
|
||||
payload: { kind: "agentTurn", message: "hello" },
|
||||
delivery: { mode: "webhook", to: "ftp://example.invalid/cron-finished" },
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow('delivery.mode="webhook" requires delivery.to to be a valid http(s) URL');
|
||||
expect(callGatewayMock).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Type } from "@sinclair/typebox";
|
||||
import type { CronDelivery, CronMessageChannel } from "../../cron/types.js";
|
||||
import { loadConfig } from "../../config/config.js";
|
||||
import { normalizeCronJobCreate, normalizeCronJobPatch } from "../../cron/normalize.js";
|
||||
import { normalizeHttpWebhookUrl } from "../../cron/webhook-url.js";
|
||||
import { parseAgentSessionKey } from "../../sessions/session-key-utils.js";
|
||||
import { extractTextFromChatContent } from "../../shared/chat-content.js";
|
||||
import { isRecord, truncateUtf16Safe } from "../../utils.js";
|
||||
@@ -217,10 +218,9 @@ JOB SCHEMA (for add action):
|
||||
"name": "string (optional)",
|
||||
"schedule": { ... }, // Required: when to run
|
||||
"payload": { ... }, // Required: what to execute
|
||||
"delivery": { ... }, // Optional: announce summary (isolated only)
|
||||
"delivery": { ... }, // Optional: announce summary or webhook POST
|
||||
"sessionTarget": "main" | "isolated", // Required
|
||||
"enabled": true | false, // Optional, default true
|
||||
"notify": true | false // Optional webhook opt-in; set true for user-facing reminders
|
||||
"enabled": true | false // Optional, default true
|
||||
}
|
||||
|
||||
SCHEDULE TYPES (schedule.kind):
|
||||
@@ -239,15 +239,17 @@ PAYLOAD TYPES (payload.kind):
|
||||
- "agentTurn": Runs agent with message (isolated sessions only)
|
||||
{ "kind": "agentTurn", "message": "<prompt>", "model": "<optional>", "thinking": "<optional>", "timeoutSeconds": <optional> }
|
||||
|
||||
DELIVERY (isolated-only, top-level):
|
||||
{ "mode": "none|announce", "channel": "<optional>", "to": "<optional>", "bestEffort": <optional-bool> }
|
||||
DELIVERY (top-level):
|
||||
{ "mode": "none|announce|webhook", "channel": "<optional>", "to": "<optional>", "bestEffort": <optional-bool> }
|
||||
- Default for isolated agentTurn jobs (when delivery omitted): "announce"
|
||||
- If the task needs to send to a specific chat/recipient, set delivery.channel/to here; do not call messaging tools inside the run.
|
||||
- announce: send to chat channel (optional channel/to target)
|
||||
- webhook: send finished-run event as HTTP POST to delivery.to (URL required)
|
||||
- If the task needs to send to a specific chat/recipient, set announce delivery.channel/to; do not call messaging tools inside the run.
|
||||
|
||||
CRITICAL CONSTRAINTS:
|
||||
- sessionTarget="main" REQUIRES payload.kind="systemEvent"
|
||||
- sessionTarget="isolated" REQUIRES payload.kind="agentTurn"
|
||||
- For reminders users should be notified about, set notify=true.
|
||||
- For webhook callbacks, use delivery.mode="webhook" with delivery.to set to a URL.
|
||||
Default: prefer isolated agentTurn jobs unless the user explicitly wants a main-session system event.
|
||||
|
||||
WAKE MODES (for wake action):
|
||||
@@ -294,7 +296,6 @@ Use jobId as the canonical identifier; id is accepted for compatibility. Use con
|
||||
"payload",
|
||||
"delivery",
|
||||
"enabled",
|
||||
"notify",
|
||||
"description",
|
||||
"deleteAfterRun",
|
||||
"agentId",
|
||||
@@ -352,11 +353,25 @@ Use jobId as the canonical identifier; id is accepted for compatibility. Use con
|
||||
const delivery = isRecord(deliveryValue) ? deliveryValue : undefined;
|
||||
const modeRaw = typeof delivery?.mode === "string" ? delivery.mode : "";
|
||||
const mode = modeRaw.trim().toLowerCase();
|
||||
if (mode === "webhook") {
|
||||
const webhookUrl = normalizeHttpWebhookUrl(delivery?.to);
|
||||
if (!webhookUrl) {
|
||||
throw new Error(
|
||||
'delivery.mode="webhook" requires delivery.to to be a valid http(s) URL',
|
||||
);
|
||||
}
|
||||
if (delivery) {
|
||||
delivery.to = webhookUrl;
|
||||
}
|
||||
}
|
||||
|
||||
const hasTarget =
|
||||
(typeof delivery?.channel === "string" && delivery.channel.trim()) ||
|
||||
(typeof delivery?.to === "string" && delivery.to.trim());
|
||||
const shouldInfer =
|
||||
(deliveryValue == null || delivery) && mode !== "none" && !hasTarget;
|
||||
(deliveryValue == null || delivery) &&
|
||||
(mode === "" || mode === "announce") &&
|
||||
!hasTarget;
|
||||
if (shouldInfer) {
|
||||
const inferred = inferDeliveryFromSessionKey(opts.agentSessionKey);
|
||||
if (inferred) {
|
||||
|
||||
@@ -81,14 +81,12 @@ export function createMemorySearchTool(options: {
|
||||
status.backend === "qmd"
|
||||
? clampResultsByInjectedChars(decorated, resolved.qmd?.limits.maxInjectedChars)
|
||||
: decorated;
|
||||
const searchMode = (status.custom as { searchMode?: string } | undefined)?.searchMode;
|
||||
return jsonResult({
|
||||
results,
|
||||
provider: status.provider,
|
||||
model: status.model,
|
||||
fallback: status.fallback,
|
||||
citations: citationsMode,
|
||||
mode: searchMode,
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
|
||||
16
src/agents/tools/tts-tool.test.ts
Normal file
16
src/agents/tools/tts-tool.test.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../../auto-reply/tokens.js", () => ({
|
||||
SILENT_REPLY_TOKEN: "QUIET_TOKEN",
|
||||
}));
|
||||
|
||||
const { createTtsTool } = await import("./tts-tool.js");
|
||||
|
||||
describe("createTtsTool", () => {
|
||||
it("uses SILENT_REPLY_TOKEN in guidance text", () => {
|
||||
const tool = createTtsTool();
|
||||
|
||||
expect(tool.description).toContain("QUIET_TOKEN");
|
||||
expect(tool.description).not.toContain("NO_REPLY");
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { Type } from "@sinclair/typebox";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import type { GatewayMessageChannel } from "../../utils/message-channel.js";
|
||||
import type { AnyAgentTool } from "./common.js";
|
||||
import { SILENT_REPLY_TOKEN } from "../../auto-reply/tokens.js";
|
||||
import { loadConfig } from "../../config/config.js";
|
||||
import { textToSpeech } from "../../tts/tts.js";
|
||||
import { readStringParam } from "./common.js";
|
||||
@@ -20,8 +21,7 @@ export function createTtsTool(opts?: {
|
||||
return {
|
||||
label: "TTS",
|
||||
name: "tts",
|
||||
description:
|
||||
"Convert text to speech and return a MEDIA: path. Use when the user requests audio or TTS is enabled. Copy the MEDIA line exactly.",
|
||||
description: `Convert text to speech. Audio is delivered automatically from the tool result — reply with ${SILENT_REPLY_TOKEN} after a successful call to avoid duplicate messages.`,
|
||||
parameters: TtsToolSchema,
|
||||
execute: async (_toolCallId, args) => {
|
||||
const params = args as Record<string, unknown>;
|
||||
|
||||
@@ -2,15 +2,15 @@ import { describe, expect, it } from "vitest";
|
||||
import { resolveTranscriptPolicy } from "./transcript-policy.js";
|
||||
|
||||
describe("resolveTranscriptPolicy e2e smoke", () => {
|
||||
it("uses strict tool-call sanitization for OpenAI models", () => {
|
||||
it("uses images-only sanitization without tool-call id rewriting for OpenAI models", () => {
|
||||
const policy = resolveTranscriptPolicy({
|
||||
provider: "openai",
|
||||
modelId: "gpt-4o",
|
||||
modelApi: "openai",
|
||||
});
|
||||
expect(policy.sanitizeMode).toBe("images-only");
|
||||
expect(policy.sanitizeToolCallIds).toBe(true);
|
||||
expect(policy.toolCallIdMode).toBe("strict");
|
||||
expect(policy.sanitizeToolCallIds).toBe(false);
|
||||
expect(policy.toolCallIdMode).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses strict9 tool-call sanitization for Mistral-family models", () => {
|
||||
|
||||
@@ -30,13 +30,13 @@ describe("resolveTranscriptPolicy", () => {
|
||||
expect(policy.toolCallIdMode).toBe("strict9");
|
||||
});
|
||||
|
||||
it("enables sanitizeToolCallIds for OpenAI provider", () => {
|
||||
it("disables sanitizeToolCallIds for OpenAI provider", () => {
|
||||
const policy = resolveTranscriptPolicy({
|
||||
provider: "openai",
|
||||
modelId: "gpt-4o",
|
||||
modelApi: "openai",
|
||||
});
|
||||
expect(policy.sanitizeToolCallIds).toBe(true);
|
||||
expect(policy.toolCallIdMode).toBe("strict");
|
||||
expect(policy.sanitizeToolCallIds).toBe(false);
|
||||
expect(policy.toolCallIdMode).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -95,7 +95,7 @@ export function resolveTranscriptPolicy(params: {
|
||||
|
||||
const needsNonImageSanitize = isGoogle || isAnthropic || isMistral || isOpenRouterGemini;
|
||||
|
||||
const sanitizeToolCallIds = isGoogle || isMistral || isAnthropic || isOpenAi;
|
||||
const sanitizeToolCallIds = isGoogle || isMistral || isAnthropic;
|
||||
const toolCallIdMode: ToolCallIdMode | undefined = isMistral
|
||||
? "strict9"
|
||||
: sanitizeToolCallIds
|
||||
@@ -109,7 +109,7 @@ export function resolveTranscriptPolicy(params: {
|
||||
|
||||
return {
|
||||
sanitizeMode: isOpenAi ? "images-only" : needsNonImageSanitize ? "full" : "images-only",
|
||||
sanitizeToolCallIds,
|
||||
sanitizeToolCallIds: !isOpenAi && sanitizeToolCallIds,
|
||||
toolCallIdMode,
|
||||
repairToolUseResultPairing: !isOpenAi && repairToolUseResultPairing,
|
||||
preserveSignatures: isAntigravityClaudeModel,
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { OpenClawConfig } from "../../config/config.js";
|
||||
import {
|
||||
getAbortMemory,
|
||||
getAbortMemorySizeForTest,
|
||||
isAbortRequestText,
|
||||
isAbortTrigger,
|
||||
resetAbortMemoryForTest,
|
||||
setAbortMemory,
|
||||
@@ -75,6 +76,17 @@ describe("abort detection", () => {
|
||||
expect(isAbortTrigger("/stop")).toBe(false);
|
||||
});
|
||||
|
||||
it("isAbortRequestText aligns abort command semantics", () => {
|
||||
expect(isAbortRequestText("/stop")).toBe(true);
|
||||
expect(isAbortRequestText("stop")).toBe(true);
|
||||
expect(isAbortRequestText("/stop@openclaw_bot", { botUsername: "openclaw_bot" })).toBe(true);
|
||||
|
||||
expect(isAbortRequestText("/status")).toBe(false);
|
||||
expect(isAbortRequestText("stop please")).toBe(false);
|
||||
expect(isAbortRequestText("/abort")).toBe(false);
|
||||
expect(isAbortRequestText("/abort now")).toBe(false);
|
||||
});
|
||||
|
||||
it("removes abort memory entry when flag is reset", () => {
|
||||
setAbortMemory("session-1", true);
|
||||
expect(getAbortMemory("session-1")).toBe(true);
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
import { logVerbose } from "../../globals.js";
|
||||
import { parseAgentSessionKey } from "../../routing/session-key.js";
|
||||
import { resolveCommandAuthorization } from "../command-auth.js";
|
||||
import { normalizeCommandBody } from "../commands-registry.js";
|
||||
import { normalizeCommandBody, type CommandNormalizeOptions } from "../commands-registry.js";
|
||||
import { stripMentions, stripStructuralPrefixes } from "./mentions.js";
|
||||
import { clearSessionQueues } from "./queue.js";
|
||||
|
||||
@@ -35,6 +35,17 @@ export function isAbortTrigger(text?: string): boolean {
|
||||
return ABORT_TRIGGERS.has(normalized);
|
||||
}
|
||||
|
||||
export function isAbortRequestText(text?: string, options?: CommandNormalizeOptions): boolean {
|
||||
if (!text) {
|
||||
return false;
|
||||
}
|
||||
const normalized = normalizeCommandBody(text, options).trim();
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
return normalized.toLowerCase() === "/stop" || isAbortTrigger(normalized);
|
||||
}
|
||||
|
||||
export function getAbortMemory(key: string): boolean | undefined {
|
||||
const normalized = key.trim();
|
||||
if (!normalized) {
|
||||
@@ -202,8 +213,7 @@ export async function tryFastAbortFromMessage(params: {
|
||||
const raw = stripStructuralPrefixes(ctx.CommandBody ?? ctx.RawBody ?? ctx.Body ?? "");
|
||||
const isGroup = ctx.ChatType?.trim().toLowerCase() === "group";
|
||||
const stripped = isGroup ? stripMentions(raw, ctx, cfg, agentId) : raw;
|
||||
const normalized = normalizeCommandBody(stripped);
|
||||
const abortRequested = normalized === "/stop" || isAbortTrigger(stripped);
|
||||
const abortRequested = isAbortRequestText(stripped);
|
||||
if (!abortRequested) {
|
||||
return { handled: false, aborted: false };
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user