refactor(config): purge numeric tuning knobs behind built-in defaults (#111382)

This commit is contained in:
Peter Steinberger
2026-07-19 07:35:45 -07:00
committed by GitHub
parent ec01949d86
commit 783a5d21cf
365 changed files with 1672 additions and 9001 deletions
-2
View File
@@ -19,7 +19,6 @@ extensions/browser/src/browser/chrome-mcp.ts
extensions/browser/src/browser/chrome.executables.ts
extensions/browser/src/browser/chrome.internal.test.ts
extensions/browser/src/browser/chrome.ts
extensions/browser/src/browser/config.test.ts
extensions/browser/src/browser/extension-relay/relay-bridge.ts
extensions/browser/src/browser/pw-session.create-page.navigation-guard.test.ts
extensions/browser/src/browser/pw-session.ts
@@ -545,7 +544,6 @@ src/agents/system-prompt.test.ts
src/agents/system-prompt.ts
src/agents/tool-display-common.ts
src/agents/tool-loop-detection.test.ts
src/agents/tool-loop-detection.ts
src/agents/tool-search.test.ts
src/agents/tool-search.ts
src/agents/tools/computer-tool.ts
+2 -2
View File
@@ -1,5 +1,5 @@
{
"core": 3007,
"channel": 3732,
"core": 2851,
"channel": 3680,
"plugin": 3537
}
+3 -3
View File
@@ -1,4 +1,4 @@
2f4ba51e354995c0d28052d7c61210ace881145af0db7f5409f8eb7dff167a4f config-baseline.json
0e9d04a669b853f8b58397d2dfc8913b5a3dd70af20ebd43e25b08be4cd3d292 config-baseline.core.json
c624c55cb46a67db4c836dcc04d295db966579885a00778d1a32241632de3475 config-baseline.channel.json
a8e315e49d62b95c54c752041cc27628260686f0a99de646c24505edc977a3c3 config-baseline.json
d3da432d372bc652d432be978495a9f28b3478da1034cbe888550c8bf6cb9dd4 config-baseline.core.json
dbf05e8852c873d48288336dbbb05d7a7b3a1bcbee8cb8ece5e40f204d2ab9db config-baseline.channel.json
0e0592b95e4958477e539abd5ccb336c63735c7d69133199953c48ff3c0845a8 config-baseline.plugin.json
@@ -13,7 +13,7 @@ e5e67ddf3cab38fcbf9220bc3160715897e2709d9a9ff6ff36f1ecc9453c2367 module/agent-c
30452bae2a689fb75dcb6dba9ef11e5b95f9cbe0c62eb190a0fcb82d0a19ae52 module/agent-core
74daa746deb548379d3f0d6eac3c4d082df1034c4360cc03bf51fee0f10a2e4d module/agent-harness
e09226afd443cee02ed648f032f53bfeb60585617bb523a649995764d3c38da9 module/agent-harness-exec-review-runtime
ca9810b66aff3c8b60278b80d634c9c280322034964c05cb727d84252080aabf module/agent-harness-runtime
ffacec9f51060b81c597a9d169e7ee0fa463fcbd18daffdce129d144897b2c5b module/agent-harness-runtime
ec22d7a039fb58d0b8343ad149322960d3d8ca58b3f4c70f2fa8a099f8186d0c module/agent-harness-task-runtime
5f63bf587bf3547d59d0dc5d0dc2fee54745aa6edaab4aa3ae700dba03443edb module/agent-harness-tool-runtime
5168648cd946abad8a92822889f13ceacc87ed502314a66190d0b1eb8ebe76ea module/agent-media-payload
@@ -35,13 +35,13 @@ a5e9215460bc99fb6e6cdd4bc67eb6466a5e99f28279e13d1769599c18dfe0b3 module/approva
6c81b9122ab0a5c3190700c0a234047274a8d48edddcc8f26d3859b886696068 module/async-lock-runtime
ad60ccc4fe9084d47f0477e02d9296bacad32f26d7456e2a84be8d25a53a25c2 module/boolean-param
333a906d4ad9d3e89102ab7eb9059a7f9c7277328041223ee3b0713442f56a29 module/browser-config
0de3c53c3ba6ff739d4b047b440588ac25fdea3b795e92b37cab90bfd1520a8c module/bundled-channel-config-schema
c117222faacb1c68bf6df6594fbf47c3ea0240fd1403f913ecb72e08730d32cf module/bundled-channel-config-schema
b6e53fb9c69840d71785325cd9a244809ffd6d3f0dd08ed0cccf816dd993f210 module/channel-actions
fcfd76dbfe818e8e8574a425f340fee935acf53d4484e961f54c3ec760fb15fe module/channel-activity-runtime
24c53c9cefacd8c1bec2aa91ba2b01e606f8b8477bc5cb2f99567225a4dcf67c module/channel-config-helpers
16dc9d32e8ca3ef78fc63e0fc4b20e6b943633e9572de1a49d24a880b6ffc66c module/channel-config-primitives
5da2caccf30780a4cf86ac710e2ca42cc9576cb1c49291c0929889fec0cc2257 module/channel-config-schema
080d51451ad15a2787eada3dcc04985154cc394058dfbf2194c6560801f3a1c1 module/channel-config-schema-legacy
e6c20ca52397a90297c49928e9b8d98a48059ed107d21f8cec6a7ec9c9e2d1ab module/channel-config-schema-legacy
8740f7cc786a380043e41aaf710ac47931b3587c122bb152d5f0231f98fd5351 module/channel-config-writes
2dd98659d9600e755f09ef00dd91c36692b8562ed3700461937e94f6cd1e640a module/channel-contract
3db1a968e5b8aa97623a48a9ee76393d578d4bb90634fa59f499a2fd87b9da72 module/channel-core
@@ -94,8 +94,8 @@ e26e0f75b43c5bbadd34401b21d8c76406ca0b87cc997be5abd100462d318f1a module/compat
235a9e4d983042c3db156efcab0e333984cbbf13ebdfcc44a3a5ab40cf3edb4f module/config-contracts
20f3f8042de53e4eee61b64de9102c8c202b9299e6a29235647a4729f70145f2 module/config-mutation
316949815affe623ac63951a5db580527f02663576dffa084f02768f612c0c1c module/config-runtime
adb5fa3476c1f6b4e6c6d2d170254ed781949e7b609be1679661003911635de3 module/config-schema
1531347939d51c528b7230b951da194faafdfc3d8284765e7e9a0373d1ce5ef8 module/config-types
a8ce8d7a8df9d427956a3d6f2daf00b0234be473e8cec2d01b84f34c6d7c78bb module/config-schema
b317e9810e57d07ddd720d68c1ce93172e5bc331d9c095a332edc52f565d5ce4 module/config-types
42d15153981cfe3adc1d5f91621434c56f07a9bd48c15ce34742f72dd040c142 module/context-visibility-runtime
03636897fb99cb73e4d8620c8a0e0d72b4d52fc32bf94f525af2aa88c489c6c2 module/conversation-binding-runtime
c1ea9510dfda047609a99d5d2cd1f1560f5d469a36e6b695766213d695c25b0f module/conversation-runtime
@@ -111,7 +111,7 @@ f70c93d28053ca2e8353e45e6515ce7acef188097c6117d1545965d0699c8004 module/device-
371ee1fd78810526745c93c24c4b85562c981676adcc33aaa0c627f5d2d45810 module/direct-dm-guard-policy
91278c800e0f87d7111f226e1cfcb6f29552936fec7215b3b69be4da7e2ee8fb module/directory-config-runtime
ea81ef06956c1bc0853fa00afbbc2b5a4019116aaf8a436e1b27d06f7a2c9e88 module/directory-runtime
c443b68d232f28b7241e1be10d1604def0d52d99ce3ae1ca8e2be3c6fd8b2d2f module/discord
7e953d227d71e4e07c412b21f6db0604279cb6360eb6e025aceb79576212afd0 module/discord
f41f9b34ab771c894293453bcdf072860c7cd509dd4e0172156634a816b8d727 module/document-extractor
3ac20ebba52de5a2f18807c0c8ade6e61f59e260a35fc1d077c9dedb9960dabd module/embedding-providers
46c05a90b66032d1d7ad08445840a4bf81aa2bd325348f87710ad4538daf38f6 module/error-runtime
@@ -299,12 +299,12 @@ eb9a25321eaa2bbea1721f7d4b8b218bed398379494e09a21a621f264435b39a module/string-
32f031eb75c887b24b8eaa693cd0aa1a4648dca85eab7bfdfb3d08136861c274 module/system-event-runtime
65361dc9a23578787c1ccf98f7df99e443574614e7ce95889f9a295725e12fae module/talk-config-runtime
9efd666b8c2cc8a9abf816751fd9420f3c048d158e48570cae8b7a4cbc52a83f module/target-resolver-runtime
da05dc1506e226fc25285a2963bd8d96dd0e882d6baa24b8073a0c53fef9fc81 module/telegram-account
ec6df323a2dbd1f837df25c0290289031bf60e0b0250f47172f3eaa316667c90 module/telegram-account
7729f9f201c08f114925da75ee86a5a8deb6677e5d1b5ee86bc60aacaa01f63b module/telegram-command-config
110944726884fca94f38c9c329b5950629438b9a719f4782c4beeade8bd67746 module/temp-path
a65f17db3d04c2ca1b34f9a4ebe8748952bbcb00318b741adbe3f227d2e2de20 module/text-autolink-runtime
e24c49c1c7b35e8b4403d45eb76bb0f33ec2f23f714bda322e1363251737d6cd module/text-chunking
948694f6da0f85fb2e9af0d66cd49b8ff0648b6dc96ed4165b03736a3e8f2439 module/text-runtime
1df5a33be5dbf611e301c01c6b8bcf502c3101aae10cd14a79f1bbb58b116497 module/text-runtime
4bdc79e3b42814a30c3c84401f1f0e4620706092787a1f5d2b6c4148b202fe02 module/text-utility-runtime
dac9afb3833e237e3edcc9d687240caceb8288abd0c6b757dcc3c16a819eb029 module/thread-bindings-runtime
65c9b0b77c0c19140765ec282f9a6424f6ce8a55d0e318b30485d52fd41968e9 module/thread-bindings-session-runtime
+3 -12
View File
@@ -82,7 +82,7 @@ Recurring jobs can set `pacing.min` and/or `pacing.max` to duration strings such
During an isolated run, a paced job can call the `cron` tool with `action: "next_check"` and `in: "30m"`. The proposal applies only to that currently running job and is measured from successful run completion. OpenClaw silently clamps it to the configured bounds.
Pacing without a proposal leaves the normal schedule unchanged. Failed, timed-out, and skipped runs discard the proposal, so existing retry and error-backoff behavior takes precedence. Manually forcing a recurring job is out-of-band and preserves its pending natural or paced slot. For condition-triggered jobs, `cron.triggers.minIntervalMs` remains a lower bound even when a proposal requests an earlier check.
Pacing without a proposal leaves the normal schedule unchanged. Failed, timed-out, and skipped runs discard the proposal, so existing retry and error-backoff behavior takes precedence. Manually forcing a recurring job is out-of-band and preserves its pending natural or paced slot. For condition-triggered jobs, the built-in minimum interval remains a lower bound even when a proposal requests an earlier check.
### Day-of-month and day-of-week use OR logic
@@ -583,15 +583,8 @@ Use the latest-generation, best-tier model available from your provider for untr
cron: {
enabled: true,
store: "~/.openclaw/cron/jobs.json",
maxConcurrentRuns: 8,
triggers: {
enabled: false,
minIntervalMs: 30000,
},
retry: {
maxAttempts: 3,
backoffMs: [30000, 60000, 300000],
retryOn: ["rate_limit", "overloaded", "network", "timeout", "server_error"],
},
webhookToken: "replace-with-dedicated-webhook-token",
sessionRetention: "24h",
@@ -599,9 +592,7 @@ Use the latest-generation, best-tier model available from your provider for untr
}
```
The `retry` values above are the defaults: up to 3 retries with `30s/60s/5m` backoff, retrying all five transient categories. `webhookToken` is sent as `Authorization: Bearer <token>` on cron webhook POSTs.
`maxConcurrentRuns` limits both scheduled cron dispatch and isolated agent-turn execution, and defaults to 8. Isolated cron agent turns use the queue's dedicated `cron-nested` execution lane internally, so raising this value lets independent cron LLM runs progress in parallel instead of only starting their outer cron wrappers. The shared non-cron `nested` lane is not widened by this setting.
`webhookToken` is sent as `Authorization: Bearer <token>` on cron webhook POSTs.
`cron.store` is a logical store key and doctor migration path, not a live JSON file to hand-edit. Job data lives in SQLite; use the CLI or Gateway API for changes.
@@ -609,7 +600,7 @@ Disable cron: `cron.enabled: false` or `OPENCLAW_SKIP_CRON=1`.
<AccordionGroup>
<Accordion title="Retry behavior">
**One-shot retry**: transient errors (rate limit, overload, network, timeout, server error) retry up to `retry.maxAttempts` times (default 3) using `retry.backoffMs` (default 30s, 60s, 5m). Permanent errors disable the job immediately.
**One-shot retry**: transient errors (rate limit, overload, network, timeout, server error) use a built-in retry schedule. Permanent errors disable the job immediately.
**Recurring retry**: consecutive execution errors back off on an extended schedule (30s, 60s, 5m, 15m, 60m). Backoff resets after the next successful run.
+4 -41
View File
@@ -1580,57 +1580,21 @@ openclaw logs --follow
- `Slow listener detected ...`
- `stuck session: sessionKey=agent:...:discord:... state=processing ...`
Discord gateway queue knobs:
- single-account: `channels.discord.eventQueue.listenerTimeout`
- multi-account: `channels.discord.accounts.<accountId>.eventQueue.listenerTimeout`
- this only controls Discord gateway listener work, not agent turn lifetime
Discord does not apply a channel-owned timeout to queued agent turns. Message listeners hand off immediately, and queued Discord runs preserve per-session ordering until the session/tool/runtime lifecycle completes or aborts the work.
```json5
{
channels: {
discord: {
accounts: {
default: {
eventQueue: {
listenerTimeout: 120000,
},
},
},
},
},
}
```
</Accordion>
<Accordion title="Gateway metadata lookup timeout warnings">
OpenClaw fetches Discord `/gateway/bot` metadata before connecting. Transient failures fall back to Discord's default gateway URL and are rate-limited in logs.
Metadata timeout knobs:
- single-account: `channels.discord.gatewayInfoTimeoutMs`
- multi-account: `channels.discord.accounts.<accountId>.gatewayInfoTimeoutMs`
- env fallback when config is unset: `OPENCLAW_DISCORD_GATEWAY_INFO_TIMEOUT_MS`
- default: `30000` (30 seconds), max: `120000`
The metadata timeout defaults to 30 seconds. `OPENCLAW_DISCORD_GATEWAY_INFO_TIMEOUT_MS` can override it for unusual host environments.
</Accordion>
<Accordion title="Gateway READY timeout restarts">
OpenClaw waits for Discord's gateway `READY` event during startup and after runtime reconnects. Multi-account setups with startup staggering can need a longer startup READY window than the default.
READY timeout knobs:
- startup single-account: `channels.discord.gatewayReadyTimeoutMs`
- startup multi-account: `channels.discord.accounts.<accountId>.gatewayReadyTimeoutMs`
- startup env fallback when config is unset: `OPENCLAW_DISCORD_READY_TIMEOUT_MS`
- startup default: `15000` (15 seconds), max: `120000`
- runtime single-account: `channels.discord.gatewayRuntimeReadyTimeoutMs`
- runtime multi-account: `channels.discord.accounts.<accountId>.gatewayRuntimeReadyTimeoutMs`
- runtime env fallback when config is unset: `OPENCLAW_DISCORD_RUNTIME_READY_TIMEOUT_MS`
- runtime default: `30000` (30 seconds), max: `120000`
Startup waits 15 seconds and runtime reconnects wait 30 seconds. `OPENCLAW_DISCORD_READY_TIMEOUT_MS` and `OPENCLAW_DISCORD_RUNTIME_READY_TIMEOUT_MS` remain available for unusual host environments.
</Accordion>
@@ -1737,12 +1701,11 @@ Primary reference: [Configuration reference - Discord](/gateway/config-channels#
- startup/auth: `enabled`, `token`, `applicationId`, `accounts.*`, `allowBots`
- policy: `groupPolicy`, `dmPolicy`, `allowFrom`, `dm.*`, `guilds.*`, `guilds.*.channels.*`
- command: `commands.native`, `commands.useAccessGroups` (global), `configWrites`, `slashCommand.ephemeral`
- event queue: `eventQueue.listenerTimeout` (listener budget, default `120000`), `eventQueue.maxQueueSize` (default `10000`), `eventQueue.maxConcurrency` (default `50`)
- gateway: `proxy`, `gatewayInfoTimeoutMs`, `gatewayReadyTimeoutMs`, `gatewayRuntimeReadyTimeoutMs`
- gateway: `proxy`
- reply/history: `replyToMode`, `historyLimit`, `dmHistoryLimit`, `dms.*.historyLimit`
- delivery: `textChunkLimit` (default `2000`), `maxLinesPerMessage` (default `17`)
- streaming: `streaming.mode`, `streaming.chunkMode`, `streaming.preview.*`, `streaming.progress.*`, `streaming.block.*` (legacy flat `streamMode`, `draftChunk`, `blockStreaming`, `blockStreamingCoalesce`, `chunkMode` keys are migrated into `streaming.*` by `openclaw doctor --fix`)
- media/retry: `mediaMaxMb` (caps outbound Discord uploads, default `100`), `retry`
- media: `mediaMaxMb` (caps outbound Discord uploads, default `100`)
- actions: `actions.*`
- presence: `activity`, `status`, `activityType`, `activityUrl`, `autoPresence.*`
- UI: `ui.components.accentColor`
+7 -15
View File
@@ -283,7 +283,7 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
- Long polling uses the grammY runner with per-chat/per-thread sequencing. Runner sink concurrency uses `agents.defaults.maxConcurrent`.
- Multi-account startup bounds concurrent `getMe` probes so large bot fleets do not fan out every account probe at once.
- Each gateway process guards long polling so only one active poller can use a bot token at a time. Persistent `getUpdates` 409 conflicts point to another OpenClaw gateway, script, or external poller using the same token.
- The polling watchdog restarts after 120 seconds without completed `getUpdates` liveness by default. Raise `channels.telegram.pollingStallThresholdMs` (30000-600000, per-account overrides supported) only if your deployment sees false polling-stall restarts during long-running work.
- The polling watchdog restarts after 120 seconds without completed `getUpdates` liveness.
- Telegram Bot API has no read-receipt support (`sendReadReceipts` does not apply).
<Note>
@@ -743,17 +743,13 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
</Accordion>
<Accordion title="Limits, retry, and CLI targets">
<Accordion title="Limits and CLI targets">
- `channels.telegram.textChunkLimit` default 4000; `streaming.chunkMode="newline"` prefers paragraph boundaries (blank lines) before length splitting.
- `channels.telegram.mediaMaxMb` (default 100) caps inbound and outbound media size.
- `channels.telegram.mediaGroupFlushMs` (default 500, range 10-60000) controls how long albums/media groups are buffered before OpenClaw dispatches them as one inbound message. Increase it if album parts arrive late; decrease it to reduce album reply latency.
- `channels.telegram.timeoutSeconds` overrides the API client timeout (grammY default applies if unset). Bot clients clamp configured values below the 60-second outbound text/typing request guard so grammY does not abort visible reply delivery before OpenClaw's transport guard and fallback can run. Long polling still uses a 45-second `getUpdates` request guard so idle polls are not abandoned indefinitely.
- `channels.telegram.pollingStallThresholdMs` defaults to 120000; tune between 30000 and 600000 only for false-positive polling-stall restarts.
- group context history uses `channels.telegram.historyLimit` or `messages.groupChat.historyLimit` (default 50); `0` disables.
- reply/quote/forward supplemental context normalizes into one selected conversation context window when the gateway has observed the parent messages; the observed-message cache lives in OpenClaw SQLite plugin state, and `openclaw doctor --fix` imports legacy sidecars. Telegram only includes one shallow `reply_to_message` per update, so chains older than the cache are limited to that payload.
- Telegram allowlists primarily gate who can trigger the agent, not a full supplemental-context redaction boundary.
- DM history: `channels.telegram.dmHistoryLimit`, `channels.telegram.dms["<user_id>"].historyLimit`.
- `channels.telegram.retry` applies to Telegram send helpers (CLI/tools/actions) for recoverable outbound API errors. Inbound final-reply delivery uses a bounded safe-send retry for pre-connect failures, but does not retry ambiguous post-send network envelopes that could duplicate visible messages.
CLI and message-tool send targets accept a numeric chat ID, username, or forum topic target:
@@ -804,10 +800,9 @@ openclaw message poll --channel telegram --target -1001234567890:topic:42 \
When the agent hits a delivery or provider error, the error policy controls whether error messages reach the Telegram chat:
| Key | Values | Default | Description |
| ----------------------------------- | -------------------------- | --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `channels.telegram.errorPolicy` | `always`, `once`, `silent` | `always` | `always` sends every error message to the chat. `once` sends each unique error message once per cooldown window (suppresses repeated identical errors). `silent` never sends error messages to the chat. |
| `channels.telegram.errorCooldownMs` | number (ms) | `14400000` (4h) | Cooldown window for the `once` policy. After an error is sent, the same message is suppressed until this interval elapses. Prevents error spam during outages. |
| Key | Values | Default | Description |
| ------------------------------- | -------------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `channels.telegram.errorPolicy` | `always`, `once`, `silent` | `always` | `always` sends every error message to the chat. `once` sends each unique error message once per built-in cooldown window. `silent` never sends error messages to the chat. |
Per-account, per-group, and per-topic overrides are supported (same inheritance as other Telegram config keys).
@@ -816,7 +811,6 @@ Per-account, per-group, and per-topic overrides are supported (same inheritance
channels: {
telegram: {
errorPolicy: "always",
errorCooldownMs: 120000,
groups: {
"-1001234567890": {
errorPolicy: "silent", // suppress errors in this group
@@ -869,10 +863,8 @@ Per-account, per-group, and per-topic overrides are supported (same inheritance
- Logs with `TypeError: fetch failed` or `Network request for 'getUpdates' failed!` are retried as recoverable network errors.
- During polling startup, OpenClaw reuses the successful startup `getMe` probe for grammY so the runner does not need a second `getMe` before the first `getUpdates`.
- If `deleteWebhook` fails with a transient network error during polling startup, OpenClaw continues into long polling instead of making another pre-poll control-plane call. A still-active webhook then surfaces as a `getUpdates` conflict; OpenClaw rebuilds the transport and retries webhook cleanup.
- If Telegram sockets recycle on a short fixed cadence, check for a low `channels.telegram.timeoutSeconds` — bot clients clamp configured values below the outbound and `getUpdates` request guards, but older releases could abort every poll or reply when this was set below those guards.
- `Polling stall detected` in logs means OpenClaw restarts polling and rebuilds the transport after 120 seconds without completed long-poll liveness by default.
- `openclaw channels status --probe` and `openclaw doctor` warn when a running polling account has not completed `getUpdates` after startup grace, a running webhook account has not completed `setWebhook` after startup grace, or the last successful polling transport activity is stale.
- Raise `channels.telegram.pollingStallThresholdMs` only when long-running `getUpdates` calls are healthy but your host still reports false polling-stall restarts. Persistent stalls usually point to proxy, DNS, IPv6, or TLS egress issues to `api.telegram.org`.
- Telegram honors process proxy env for Bot API transport: `HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`, and lowercase variants. `NO_PROXY` / `no_proxy` can still bypass `api.telegram.org`.
- If `OPENCLAW_PROXY_URL` is set for a service environment and no standard proxy env is present, Telegram uses that URL for Bot API transport too.
- On VPS hosts with unstable direct egress/TLS, route Telegram API calls through a proxy:
@@ -936,12 +928,12 @@ Primary reference: [Configuration reference - Telegram](/gateway/config-channels
- threading/replies: `replyToMode`, `threadBindings`
- streaming: `streaming` (modes `off | partial | block | progress`), `streaming.preview.toolProgress`
- formatting/delivery: `textChunkLimit`, `streaming.chunkMode`, `richMessages`, `markdown.tables` (`off | bullets | code | block`), `linkPreview`, `responsePrefix`
- media/network: `mediaMaxMb`, `mediaGroupFlushMs`, `timeoutSeconds`, `pollingStallThresholdMs`, `retry`, `network.autoSelectFamily`, `network.dangerouslyAllowPrivateNetwork`, `proxy`
- media/network: `mediaMaxMb`, `network.autoSelectFamily`, `network.dangerouslyAllowPrivateNetwork`, `proxy`
- custom API root: `apiRoot` (Bot API root only; do not include `/bot<TOKEN>`), `trustedLocalFileRoots` (self-hosted Bot API absolute `file_path` roots)
- webhook: `webhookUrl`, `webhookSecret`, `webhookPath`, `webhookHost`, `webhookPort`, `webhookCertPath`
- actions/capabilities: `capabilities.inlineButtons`, `actions.sendMessage|editMessage|deleteMessage|reactions|sticker|createForumTopic|editForumTopic`
- reactions: `reactionNotifications`, `reactionLevel`
- errors: `errorPolicy`, `errorCooldownMs`, `silentErrorReplies`
- errors: `errorPolicy`, `silentErrorReplies`
- writes/history: `configWrites`, `historyLimit`, `dmHistoryLimit`, `dms.*.historyLimit`
</Accordion>
+9 -9
View File
@@ -64,15 +64,15 @@ Full troubleshooting: [WhatsApp troubleshooting](/channels/whatsapp#troubleshoot
### Telegram failure signatures
| Symptom | Fastest check | Fix |
| ------------------------------------ | ------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------- |
| `/start` but no usable reply flow | `openclaw pairing list telegram` | Approve pairing or change DM policy. |
| Bot online but group stays silent | Verify mention requirement and bot privacy mode | Disable privacy mode for group visibility or mention bot. |
| Send failures with network errors | Inspect logs for Telegram API call failures | Fix DNS/IPv6/proxy routing to `api.telegram.org`. |
| Startup reports `getMe returned 401` | Check configured token source | Re-copy or regenerate the BotFather token and update `botToken`, `tokenFile`, or default-account `TELEGRAM_BOT_TOKEN`. |
| Polling stalls or reconnects slowly | `openclaw logs --follow` for polling diagnostics | Upgrade; if restarts are false positives, tune `pollingStallThresholdMs`. Persistent stalls still point to proxy/DNS/IPv6. |
| `setMyCommands` rejected at startup | Inspect logs for `BOT_COMMANDS_TOO_MUCH` | Reduce plugin/skill/custom Telegram commands or disable native menus. |
| Upgraded and allowlist blocks you | `openclaw security audit` and config allowlists | Run `openclaw doctor --fix` or replace `@username` with numeric sender IDs. |
| Symptom | Fastest check | Fix |
| ------------------------------------ | ------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------- |
| `/start` but no usable reply flow | `openclaw pairing list telegram` | Approve pairing or change DM policy. |
| Bot online but group stays silent | Verify mention requirement and bot privacy mode | Disable privacy mode for group visibility or mention bot. |
| Send failures with network errors | Inspect logs for Telegram API call failures | Fix DNS/IPv6/proxy routing to `api.telegram.org`. |
| Startup reports `getMe returned 401` | Check configured token source | Re-copy or regenerate the BotFather token and update `botToken`, `tokenFile`, or default-account `TELEGRAM_BOT_TOKEN`. |
| Polling stalls or reconnects slowly | `openclaw logs --follow` for polling diagnostics | Upgrade; persistent stalls usually point to proxy/DNS/IPv6. |
| `setMyCommands` rejected at startup | Inspect logs for `BOT_COMMANDS_TOO_MUCH` | Reduce plugin/skill/custom Telegram commands or disable native menus. |
| Upgraded and allowlist blocks you | `openclaw security audit` and config allowlists | Run `openclaw doctor --fix` or replace `@username` with numeric sender IDs. |
Full troubleshooting: [Telegram troubleshooting](/channels/telegram#troubleshooting)
+1 -16
View File
@@ -126,7 +126,6 @@ A separate WhatsApp number is recommended (setup and metadata are optimized for
- The gateway owns the WhatsApp socket and reconnect loop.
- A watchdog tracks two signals independently: raw WhatsApp Web transport activity and application-message activity. A quiet-but-connected session is not restarted just because no message arrived recently; it forces reconnect only when transport frames stop arriving for a fixed internal window (not user-configurable) or application messages stay silent past 4x the normal message timeout. Right after a reconnect for a recently active session, that first window uses the shorter normal message timeout instead of the 4x window. OpenClaw can auto-reply to offline messages that Baileys delivers early in that reconnect, bounded by the inbound message-ID dedupe lifetime; initial startup keeps the short stale-history guard.
- Baileys socket timings are explicit under `web.whatsapp.*`: `keepAliveIntervalMs` (application ping interval), `connectTimeoutMs` (opening handshake timeout), `defaultQueryTimeoutMs` (Baileys query waits, plus OpenClaw's outbound send/presence and inbound read-receipt timeouts).
- Outbound sends require an active WhatsApp listener for the target account; sends fail fast otherwise.
- Group sends attach native mention metadata for `@+<digits>` and `@<digits>` tokens (in text and media captions) when the token matches current participant metadata, including LID-backed groups.
- Status and broadcast chats (`@status`, `@broadcast`) are ignored.
@@ -540,20 +539,6 @@ openclaw channels status
Quiet accounts can stay connected past the normal message timeout; the watchdog restarts only when WhatsApp Web transport activity stops, the socket closes, or application-level activity stays silent beyond the longer safety window (see Runtime model above).
If logs show repeated `status=408 Request Time-out Connection was lost`, tune Baileys socket timings under `web.whatsapp`. Start by shortening `keepAliveIntervalMs` below your network's idle timeout and increasing `connectTimeoutMs` on slow or lossy links:
```json5
{
web: {
whatsapp: {
keepAliveIntervalMs: 15000,
connectTimeoutMs: 60000,
defaultQueryTimeoutMs: 60000,
},
},
}
```
Fix:
```bash
@@ -683,7 +668,7 @@ Primary reference: [Configuration reference - WhatsApp](/gateway/config-channels
| Access | `dmPolicy`, `allowFrom`, `groupPolicy`, `groupAllowFrom`, `groups` |
| Delivery | `textChunkLimit`, `streaming.chunkMode`, `mediaMaxMb`, `sendReadReceipts`, `ackReaction`, `reactionLevel` |
| Multi-account | `accounts.<id>.enabled`, `accounts.<id>.authDir`, and other per-account overrides |
| Operations | `configWrites`, `debounceMs`, `web.enabled`, `web.heartbeatSeconds`, `web.reconnect.*`, `web.whatsapp.*` |
| Operations | `configWrites`, `debounceMs`, `web.enabled` |
| Session behavior | `session.dmScope`, `historyLimit`, `dmHistoryLimit`, `dms.<id>.historyLimit` |
| Prompts | `groups.<id>.systemPrompt`, `groups["*"].systemPrompt`, `direct.<id>.systemPrompt`, `direct["*"].systemPrompt` |
+1 -1
View File
@@ -255,7 +255,7 @@ The default existing-session path is host-only Chrome MCP auto-connect. If the b
Current existing-session limits:
- Snapshot-driven actions use refs, not CSS selectors.
- `browser.actionTimeoutMs` defaults supported `act` requests to 60000 ms when callers omit `timeoutMs`; per-call `timeoutMs` still wins.
- Supported `act` requests use a built-in 60000 ms default when callers omit `timeoutMs`; per-call `timeoutMs` still wins.
- `click` is left-click only.
- `type` does not support `slowly=true`.
- `press` does not support `delayMs`.
+1 -1
View File
@@ -34,7 +34,7 @@ openclaw daemon uninstall
- `status`: shows service install state (launchd/systemd/schtasks) and probes Gateway health.
- `install`: installs the service; `--force` reinstalls/overwrites an existing install.
- `restart --safe`: asks the running Gateway to preflight active work and schedule one coalesced restart after work drains, bounded by `gateway.reload.deferralTimeoutMs` (default 300000ms/5 minutes; set to `0` to wait indefinitely). When that budget expires, the restart is forced anyway. Plain `restart` uses the service manager directly; `--force` is the immediate override.
- `restart --safe`: asks the running Gateway to preflight active work and schedule one coalesced restart after work drains, bounded to 5 minutes. When that budget expires, the restart is forced anyway. Plain `restart` uses the service manager directly; `--force` is the immediate override.
- `restart --safe --skip-deferral`: bypasses the active-work deferral gate so the Gateway restarts immediately even when blockers are reported. Requires `--safe`.
## Notes
+1 -1
View File
@@ -114,7 +114,7 @@ openclaw gateway restart --force
openclaw gateway restart --wait 30s
```
`--safe` asks the running Gateway to preflight active work and schedule one coalesced restart after that work drains. The wait is bounded by `gateway.reload.deferralTimeoutMs` (default: 5 minutes / `300000`); when the budget expires the restart is forced. Set `deferralTimeoutMs: 0` to wait indefinitely (with periodic still-pending warnings) instead of forcing. `--safe` cannot combine with `--force` or `--wait`.
`--safe` asks the running Gateway to preflight active work and schedule one coalesced restart after that work drains. The wait is bounded to 5 minutes; when the budget expires the restart is forced. `--safe` cannot combine with `--force` or `--wait`.
`--skip-deferral` bypasses the active-work deferral gate on a safe restart, so the Gateway restarts immediately even with reported blockers. It requires `--safe` — use it when a deferral is stuck on a runaway task.
+1 -1
View File
@@ -376,7 +376,7 @@ Those saved definitions are for runtimes that OpenClaw launches or configures la
- servers that advertise resources or prompts also expose utility tools for listing/reading resources and listing/fetching prompts; those generated utility names (`resources_list`, `resources_read`, `prompts_list`, `prompts_get`) use the same include/exclude filter
- dynamic MCP tool-list changes invalidate the cached catalog for that session; the next discovery/use refreshes from the server
- repeated MCP tool request/protocol failures pause that server briefly so one broken server does not consume the whole turn
- session-scoped bundled MCP runtimes are reaped after `mcp.sessionIdleTtlMs` milliseconds of idle time (default 10 minutes; set `0` to disable) and one-shot embedded runs clean them up at run end
- session-scoped bundled MCP runtimes are reaped after 10 minutes of idle time and one-shot embedded runs clean them up at run end
</Accordion>
</AccordionGroup>
+5 -9
View File
@@ -111,20 +111,16 @@ with:
```json
{
"transcripts": {
"enabled": true,
"maxUtterances": 2000
"enabled": true
}
}
```
- `enabled` (default `false`): turn the tool on.
- `maxUtterances` (default `2000`, clamped 1-10000): utterance buffer size per
session.
Configure auto-start sources with `transcripts.autoStart`. Each entry is
enabled by being present; omit an entry to disable that source. `discord-voice`
is the bundled auto-start-capable source and requires `guildId` and
`channelId`:
Configure auto-start sources with `transcripts.autoStart`. Each entry is
enabled by being present; omit an entry to disable that source. `discord-voice`
is the bundled auto-start-capable source and requires `guildId` and
`channelId`:
```json
{
+4 -4
View File
@@ -27,7 +27,7 @@ execution, streaming, persistence.
Runs are serialized per session key (session lane) and optionally through a global lane, preventing tool/session races. Messaging channels choose a queue mode (steer/followup/collect/interrupt) that feeds this lane system; see [Command Queue](/concepts/queue).
Transcript writes are additionally protected by a session write lock on the session file. The lock is process-aware and file-based, so it catches writers that bypass the in-process queue or come from another process. Writers wait up to `session.writeLock.acquireTimeoutMs` (default `60000` ms; env override `OPENCLAW_SESSION_WRITE_LOCK_ACQUIRE_TIMEOUT_MS`) before reporting the session as busy.
Transcript writes are additionally protected by a session write lock on the session file. The lock is process-aware and file-based, so it catches writers that bypass the in-process queue or come from another process. Writers wait up to 60 seconds by default (env override `OPENCLAW_SESSION_WRITE_LOCK_ACQUIRE_TIMEOUT_MS`) before reporting the session as busy.
Session write locks are non-reentrant by default. A helper that intentionally nests acquisition of the same lock while preserving one logical writer must opt in with `allowReentrant: true`.
@@ -138,13 +138,13 @@ Assistant deltas buffer into chat `delta` messages. A chat `final` is emitted on
### Stuck session diagnostics
With diagnostics enabled, `diagnostics.stuckSessionWarnMs` (default `120000` ms) classifies long `processing` sessions with no observed reply, tool, status, block, or ACP progress:
With diagnostics enabled, a built-in two-minute threshold classifies long `processing` sessions with no observed reply, tool, status, block, or ACP progress:
- Active embedded runs, model calls, and tool calls report as `session.long_running`. Owned silent model calls stay `session.long_running` until `diagnostics.stuckSessionAbortMs` so slow or non-streaming providers are not flagged as stalled too early.
- Active embedded runs, model calls, and tool calls report as `session.long_running`. Owned silent model calls stay `session.long_running` until the abort threshold so slow or non-streaming providers are not flagged as stalled too early.
- Active work with no recent progress reports as `session.stalled`. Owned model calls switch to `session.stalled` at or after the abort threshold; ownerless stale model/tool activity is not hidden as long-running.
- `session.stuck` is reserved for recoverable stale session bookkeeping, including idle queued sessions with stale ownerless model/tool activity.
`diagnostics.stuckSessionAbortMs` defaults to at least 5 minutes and 3x the warn threshold. Stale session bookkeeping releases the affected session lane immediately after recovery gates pass; stalled embedded runs are abort-drained only after the abort threshold, so queued work resumes without cutting off merely slow runs. Recovery emits structured requested/completed outcomes; diagnostic state is marked idle only if the same processing generation is still current, and repeated `session.stuck` diagnostics back off while the session stays unchanged.
The abort threshold is at least 5 minutes and 3x the warning threshold. Stale session bookkeeping releases the affected session lane immediately after recovery gates pass; stalled embedded runs are abort-drained only after the abort threshold, so queued work resumes without cutting off merely slow runs. Recovery emits structured requested/completed outcomes; diagnostic state is marked idle only if the same processing generation is still current, and repeated `session.stuck` diagnostics back off while the session stays unchanged.
## Where things can end early
+1 -2
View File
@@ -65,7 +65,6 @@ OpenClaw applies these cleanup rules:
- At run end, it removes a worktree only when `git status --porcelain` is empty and `git log HEAD --not --remotes --oneline` finds no unpushed commits. Otherwise it only releases the activity lock.
- Hourly cleanup snapshots and removes unlocked Workboard- and session-owned worktrees idle for more than 7 days, even when dirty. Manual worktrees are never automatically removed.
- When `worktrees.cleanup.maxCount` or `worktrees.cleanup.maxTotalSizeGb` is configured, cleanup also snapshots and removes the least recently active Workboard- and session-owned worktrees until the total count and disk size fit the limits. All managed worktrees count toward the totals, but manual and otherwise protected worktrees are never limit-evicted, so a limit can remain exceeded until eligible worktrees exist. 0 or unset disables a limit.
- Snapshot records remain restorable for 30 days. Cleanup then deletes the snapshot ref and registry row.
- A live OpenClaw process lock and any foreign or unrecognized git worktree lock protect a worktree from garbage collection.
@@ -81,7 +80,7 @@ openclaw worktrees restore <id> [--json]
openclaw worktrees gc [--json]
```
The Control UI **Worktrees** page under Settings provides the same actions plus creation with a base-branch picker, shows each worktree's owner (manual, Workboard, or the owning session with a link into its chat), and offers a force retry when a removal reports a failed snapshot. Its **Cleanup** section edits the `worktrees.cleanup` retention limits described in the [configuration reference](/gateway/configuration-reference#worktrees).
The Control UI **Worktrees** page under Settings provides the same actions plus creation with a base-branch picker, shows each worktree's owner (manual, Workboard, or the owning session with a link into its chat), and offers a force retry when a removal reports a failed snapshot.
## Gateway methods
+1 -1
View File
@@ -108,7 +108,7 @@ When a run is already active, inbound messages steer into it by default. `messag
| `collect` | Batch compatible messages into one later turn. |
| `interrupt` | Abort the active run, then start the newest prompt. |
Defaults: `messages.queue.debounceMs` is 500ms (applies to steer, followup, and collect batching alike), `messages.queue.cap` is 20 queued messages, and `messages.queue.drop` is `summarize` (`old` and `new` are also available). Configure per-channel overrides via `messages.queue.byChannel` and `messages.queue.debounceMsByChannel`.
The queue uses a built-in 500ms debounce for steer, followup, and collect batching. `messages.queue.cap` defaults to 20 queued messages, and `messages.queue.drop` defaults to `summarize` (`old` and `new` are also available). Configure per-channel overrides via `messages.queue.byChannel` and `messages.queue.debounceMsByChannel`.
Details: [Command queue](/concepts/queue) and [Steering queue](/concepts/queue-steering).
+1 -19
View File
@@ -203,7 +203,7 @@ Regular (non-billing, non-auth-permanent) cooldowns scale with the profile's rec
- 2nd failure: 1 minute
- 3rd+ failure: 5 minutes (cap)
Counters reset once the profile's failure window has passed (`auth.cooldowns.failureWindowHours`, default 24).
Counters reset once the profile's built-in failure window has passed.
State is stored in the per-agent SQLite auth state under `usageStats`:
@@ -244,19 +244,6 @@ State is stored in the per-agent SQLite auth state:
}
```
Defaults (`auth.cooldowns.*`):
| Key | Default | Purpose |
| ----------------------------- | ------- | --------------------------------------------------------------------------- |
| `billingBackoffHours` | 5 | Base billing backoff, doubles per billing failure |
| `billingMaxHours` | 24 | Billing backoff cap |
| `authPermanentBackoffMinutes` | 10 | Base backoff for high-confidence permanent-auth failures |
| `authPermanentMaxMinutes` | 60 | Cap for that backoff |
| `failureWindowHours` | 24 | Failure counters reset if no failures occur in this window |
| `overloadedProfileRotations` | 1 | Same-provider profile rotations allowed before model fallback on overload |
| `overloadedBackoffMs` | 0 | Fixed delay before an overloaded rotation retry |
| `rateLimitedProfileRotations` | 1 | Same-provider profile rotations allowed before model fallback on rate limit |
Overloaded and rate-limit errors are handled more aggressively than billing cooldowns: by default, OpenClaw allows one same-provider auth-profile retry, then switches to the next configured model fallback without waiting.
## Model fallback
@@ -363,11 +350,6 @@ That cooldown summary is model-aware:
See [Gateway configuration](/gateway/configuration) for:
- `auth.profiles` / `auth.order`
- `auth.cooldowns.billingBackoffHours` / `auth.cooldowns.billingBackoffHoursByProvider`
- `auth.cooldowns.billingMaxHours` / `auth.cooldowns.failureWindowHours`
- `auth.cooldowns.authPermanentBackoffMinutes` / `auth.cooldowns.authPermanentMaxMinutes`
- `auth.cooldowns.overloadedProfileRotations` / `auth.cooldowns.overloadedBackoffMs`
- `auth.cooldowns.rateLimitedProfileRotations`
- `agents.defaults.model.primary` / `agents.defaults.model.fallbacks`
- `agents.defaults.imageModel` routing
+1 -1
View File
@@ -52,7 +52,7 @@ Use `followup` or `collect` when you want messages to queue by default instead o
## Debounce
`messages.queue.debounceMs` applies to queued `followup` and `collect` delivery. In `steer` mode with the native Codex harness, it also sets the quiet window before sending batched `turn/steer`. For OpenClaw, active steering itself does not use the debounce timer because OpenClaw naturally batches messages until the next model boundary.
The built-in queue debounce applies to queued `followup` and `collect` delivery. In `steer` mode with the native Codex harness, it also sets the quiet window before sending batched `turn/steer`. For OpenClaw, active steering itself does not use the debounce timer because OpenClaw naturally batches messages until the next model boundary.
## Related
+4 -4
View File
@@ -131,7 +131,7 @@ not a local-mode command.
- Applies to auto-reply agent runs across all inbound channels that use the gateway reply pipeline (WhatsApp web, Telegram, Slack, Discord, Signal, iMessage, webchat, etc.).
- Default lane (`main`) is process-wide for inbound + main heartbeats; set `agents.defaults.maxConcurrent` to allow multiple sessions in parallel.
- Additional lanes may exist (e.g. `cron`, `cron-nested`, `nested`, `subagent`) so background jobs can run in parallel without blocking inbound replies. Isolated cron agent turns hold a `cron` slot while their inner agent execution uses `cron-nested`; both use `cron.maxConcurrentRuns`. Shared non-cron `nested` flows keep their own lane behavior. These detached runs are tracked as [background tasks](/automation/tasks).
- Additional lanes may exist (e.g. `cron`, `cron-nested`, `nested`, `subagent`) so background jobs can run in parallel without blocking inbound replies. Isolated cron agent turns hold a `cron` slot while their inner agent execution uses `cron-nested`. Shared non-cron `nested` flows keep their own lane behavior. These detached runs are tracked as [background tasks](/automation/tasks).
- Per-session lanes guarantee that only one agent run touches a given session at a time.
- No external dependencies or background worker threads; pure TypeScript + promises.
@@ -139,11 +139,11 @@ not a local-mode command.
- If commands seem stuck, enable verbose logs and look for "queued for ...ms" lines to confirm the queue is draining.
- Codex app-server runs that accept a turn and then stop emitting progress are interrupted by the Codex adapter so the active session lane can release instead of waiting for the outer run timeout.
- When diagnostics are enabled, sessions that remain in `processing` past `diagnostics.stuckSessionWarnMs` with no observed reply, tool, status, block, or ACP progress are classified by current activity:
- Active work with recent progress logs as `session.long_running`. Owned silent model calls also stay `session.long_running` until `diagnostics.stuckSessionAbortMs` so slow or non-streaming providers are not reported as stalled too early.
- When diagnostics are enabled, sessions that remain in `processing` past the built-in warning threshold with no observed reply, tool, status, block, or ACP progress are classified by current activity:
- Active work with recent progress logs as `session.long_running`. Owned silent model calls also stay `session.long_running` until the built-in abort threshold so slow or non-streaming providers are not reported as stalled too early.
- Active work with no recent progress logs as `session.stalled`; owned model calls, blocked tool calls, and stalled embedded runs switch to `session.stalled` at or after the abort threshold. Ownerless stale model/tool activity is not hidden as long-running.
- `session.stuck` is reserved for recoverable stale session bookkeeping, including idle queued sessions with stale ownerless model/tool activity.
- `session.stuck` always triggers recovery that can release the affected session lane. A `session.stalled` classification past `diagnostics.stuckSessionAbortMs` (blocked tool call, stalled model call, or stalled embedded run) can also trigger active-abort recovery, so both classifications can unstick a queue, not only `session.stuck`.
- `session.stuck` always triggers recovery that can release the affected session lane. A `session.stalled` classification past the abort threshold (blocked tool call, stalled model call, or stalled embedded run) can also trigger active-abort recovery, so both classifications can unstick a queue, not only `session.stuck`.
- Repeated `session.stuck` and `session.long_running` warning log lines back off exponentially while the session remains unchanged; recovery attempts still run on every heartbeat tick regardless of that backoff.
## Related
+1 -1
View File
@@ -86,7 +86,7 @@ Thread-scoped chat sessions, such as keys ending in `:thread:<id>`, are not vali
Messages and A2A follow-up replies are marked as inter-session data in the receiving prompt (`[Inter-session message ... isUser=false]`) and in transcript provenance. The receiving agent should treat them as tool-routed data, not as a direct end-user-authored instruction.
After the target responds, OpenClaw can run a **reply-back loop** where the agents alternate messages (up to `session.agentToAgent.maxPingPongTurns`, range 0-20, default 5). The target agent can reply `REPLY_SKIP` to stop early.
After the target responds, OpenClaw can run a **reply-back loop** where the agents alternate messages up to the built-in limit. The target agent can reply `REPLY_SKIP` to stop early.
Pass `watch: true` to also register the sender as a state-change watcher of the target: when another actor later sends the target a direct human message or changes its goal, the sender receives a system notice pointing at `session_status` `changesSince`. Registration happens after successful dispatch, targets the session that actually received the message, and starts at its current state version, so only later changes produce notices. The result reports `watched: true` when registration succeeded. See [Session state awareness](/concepts/session-state).
+2 -3
View File
@@ -42,13 +42,12 @@ Set the agent-level default:
}
```
Override mode or cadence per session:
Override the mode per session:
```json5
{
session: {
typingMode: "message",
typingIntervalSeconds: 4,
},
}
```
@@ -59,7 +58,7 @@ Override mode or cadence per session:
- `thinking` still reacts to streamed reasoning (`reasoningLevel: "stream"`), and can also start from active execution before reasoning deltas arrive.
- Heartbeat typing is a liveness signal for the resolved delivery target. It starts at heartbeat run start instead of following `message` or `thinking` stream timing. Set `typingMode: "never"` to disable it.
- Heartbeats do not show typing when the heartbeat target is `"none"`, when the target cannot be resolved, when chat delivery is disabled for the heartbeat, or when the channel does not support typing.
- `typingIntervalSeconds` controls the **refresh cadence**, not the start time. Default: 6 seconds.
- `agents.defaults.typingIntervalSeconds` controls the **refresh cadence**, not the start time. Default: 6 seconds.
## Related
+2 -6
View File
@@ -3290,7 +3290,6 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
- H3: agents.defaults.promptOverlays
- H3: agents.defaults.heartbeat
- H3: agents.defaults.compaction
- H3: agents.defaults.runRetries
- H3: agents.defaults.contextPruning
- H3: Block streaming
- H3: Typing indicators
@@ -3421,7 +3420,6 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
- H3: Supported credential surface
- H3: Secret providers config
- H2: Auth storage
- H3: auth.cooldowns
- H2: Audit
- H2: Logging
- H2: Diagnostics
@@ -3432,10 +3430,8 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
- H2: Identity
- H2: Bridge (legacy, removed)
- H2: Cron
- H3: cron.retry
- H3: cron.failureAlert
- H3: cron.failureDestination
- H2: Worktrees
- H2: Media model template variables
- H2: Config includes ($include)
- H2: Related
@@ -3666,7 +3662,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
- H2: When to use this endpoint
- H2: Agent-first model contract
- H2: Session behavior
- H2: Request limits (config)
- H2: Request limits
- H2: Chat tool contract
- H3: Supported request fields
- H3: Unsupported variants
@@ -3692,7 +3688,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
- H2: Tools (client-side function tools)
- H2: Images (inputimage)
- H2: Files (inputfile)
- H2: File + image limits (config)
- H2: File + image limits
- H2: Streaming (SSE)
- H2: Usage
- H2: Errors
+3 -24
View File
@@ -138,27 +138,6 @@ openclaw config unset agents.defaults.timeoutSeconds
openclaw config set agents.defaults.timeoutSeconds 43200
```
For a CLI that legitimately emits no output for long periods, tune the relevant watchdog profile instead of the overall turn timeout:
```json5
{
agents: {
defaults: {
cliBackends: {
"claude-cli": {
reliability: {
watchdog: {
fresh: { noOutputTimeoutMs: 1800000 },
resume: { noOutputTimeoutMs: 1800000 },
},
},
},
},
},
},
}
```
Background work started inside a CLI is still part of that CLI subprocess. If the parent turn reaches its overall limit, OpenClaw stops the subprocess and its CLI-internal background tasks together. For durable long work, use a detached OpenClaw [sub-agent](/tools/subagents) or [ACP agent](/tools/acp-agents); detached sub-agents have no run timeout by default.
The `openclaw agent` command also has its own request deadline. Its 600-second fallback default applies to that command invocation, not to ordinary Gateway turns; see [`openclaw agent`](/cli/agent).
@@ -192,7 +171,7 @@ Set `agents.defaults.cliBackends.claude-cli.command` only when the `claude` bina
- `existing`: only send a session id if one was stored before.
- `none`: never send a session id.
- `claude-cli` defaults to `liveSession: "claude-stdio"`, `output: "jsonl"`, and `input: "stdin"`, so follow-up turns reuse the live Claude process while it is active, including for custom configs that omit transport fields. If the gateway restarts or the idle process exits, OpenClaw resumes from the stored Claude session id. Stored session ids are verified against a readable project transcript before resume; a missing transcript clears the binding (logged as `reason=transcript-missing`) instead of silently starting a fresh session under `--resume`.
- Claude live sessions keep bounded JSONL output guards: 8 MiB and 20,000 raw JSONL lines per turn by default. Raise them per backend with `agents.defaults.cliBackends.claude-cli.reliability.outputLimits.maxTurnRawChars` and `maxTurnLines`; OpenClaw clamps those settings to 64 MiB and 100,000 lines.
- Claude live sessions keep bounded JSONL output guards: 8 MiB and 20,000 raw JSONL lines per turn.
- Stored CLI sessions are provider-owned continuity. Automatic reset is disabled by default; `/reset` and explicit daily or idle `session.reset` policies still cut them.
- Fresh CLI sessions normally reseed only from OpenClaw's compaction summary plus the post-compaction tail. To recover short sessions invalidated before compaction, a backend can opt in with `reseedFromRawTranscriptWhenUncompacted: true`. Raw transcript reseed stays bounded and limited to safe invalidations, such as a missing CLI transcript, an orphaned tool-use tail, message-policy/system-prompt/cwd/MCP changes, or a session-expired retry; auth profile or credential-epoch changes never reseed raw transcript history.
@@ -326,13 +305,13 @@ When bundle MCP is enabled, OpenClaw:
If no MCP servers are enabled, OpenClaw still injects a strict config when a backend opts into bundle MCP, so background runs stay isolated.
Session-scoped bundled MCP runtimes are cached for reuse within a session, then reaped after `mcp.sessionIdleTtlMs` milliseconds of idle time (default 10 minutes; set `0` to disable). One-shot embedded runs such as auth probes, slug generation, and active-memory recall request cleanup at run end so stdio children and Streamable HTTP/SSE streams do not outlive the run.
Session-scoped bundled MCP runtimes are cached for reuse within a session, then reaped after 10 minutes of idle time. One-shot embedded runs such as auth probes, slug generation, and active-memory recall request cleanup at run end so stdio children and Streamable HTTP/SSE streams do not outlive the run.
## Reseed history cap
When a fresh CLI session is seeded from a prior OpenClaw transcript (for example after a `session_expired` retry), the rendered `<conversation_history>` block is capped to keep reseed prompts from exploding. The default is 12,288 characters (about 3,000 tokens).
Claude CLI backends scale this cap with the resolved Claude context window instead: larger context windows get a larger prior-history slice, up to a fixed ceiling; other CLI backends keep the conservative default. This cap only governs the reseed prompt's prior-history block — live-session output limits are tuned separately under `reliability.outputLimits` (see [Sessions](#sessions)).
Claude CLI backends scale this cap with the resolved Claude context window instead: larger context windows get a larger prior-history slice, up to a fixed ceiling; other CLI backends keep the conservative default. This cap only governs the reseed prompt's prior-history block.
## Limitations
+2 -58
View File
@@ -620,10 +620,8 @@ Periodic heartbeat runs.
provider: "my-provider", // id of a registered compaction provider plugin (optional)
thinkingLevel: "low", // optional compaction-only thinking override
timeoutSeconds: 180,
reserveTokensFloor: 24000,
keepRecentTokens: 50000,
recentTurnsPreserve: 3,
maxHistoryShare: 0.7,
identifierPolicy: "strict", // strict | off | custom
identifierInstructions: "Preserve deployment IDs, ticket IDs, and host:port pairs exactly.", // used when identifierPolicy=custom
qualityGuard: { enabled: true, maxRetries: 1 },
@@ -652,11 +650,8 @@ Periodic heartbeat runs.
- `provider`: id of a registered compaction provider plugin. When set, the provider's `summarize()` is called instead of built-in LLM summarization. Falls back to built-in on failure. Setting a provider forces `mode: "safeguard"`. See [Compaction](/concepts/compaction).
- `thinkingLevel`: optional thinking level used only for embedded OpenClaw compaction summaries (`off`, `minimal`, `low`, `medium`, `high`, `xhigh`, `adaptive`, `max`, or `ultra`). It overrides the session's current thinking level and is clamped to the selected compaction model/runtime. Leave unset to inherit the session level. Native Codex app-server compaction ignores this setting because the native compact request has no per-operation thinking override; OpenClaw logs a warning when configured.
- `timeoutSeconds`: maximum seconds allowed for a single compaction operation before OpenClaw aborts it. Default: `180`.
- `reserveTokens`: token headroom kept available for model output and future tool results after compaction. When the model context window is known, OpenClaw caps the effective reserve so it cannot consume the prompt budget.
- `reserveTokensFloor`: minimum reserve enforced by the embedded runtime. Set `0` to disable the floor. The floor remains subject to the active context-window cap.
- `keepRecentTokens`: agent cut-point budget for keeping the most recent transcript tail verbatim. Manual `/compact` honors this when explicitly set; otherwise manual compaction is a hard checkpoint.
- `recentTurnsPreserve`: number of most recent user/assistant turns kept verbatim outside safeguard summarization. Default: `3`.
- `maxHistoryShare`: maximum fraction of the total context budget allowed for retained history after compaction (range `0.1`-`0.9`).
- `identifierPolicy`: `strict` (default), `off`, or `custom`. `strict` prepends built-in opaque identifier retention guidance during compaction summarization.
- `identifierInstructions`: optional custom identifier-preservation text used when `identifierPolicy=custom`.
- `qualityGuard`: retry-on-malformed-output checks for safeguard summaries. Enabled by default in safeguard mode; set `enabled: false` to skip the audit.
@@ -669,36 +664,6 @@ Periodic heartbeat runs.
- `notifyUser`: when `true`, sends brief context-maintenance notices to the user: when compaction starts and completes (for example, "Compacting context..." and "Compaction complete"), and when a pre-compaction memory flush is exhausted so the reply continues in a degraded state (for example, "Memory maintenance temporarily failed; continuing your reply."). Disabled by default to keep these notices silent.
- `memoryFlush`: silent agentic turn before auto-compaction to store durable memories. Set `model` to an exact provider/model such as `ollama/qwen3:8b` when this housekeeping turn should stay on a local model; the override does not inherit the active session fallback chain. `forceFlushTranscriptBytes` forces the flush when transcript size reaches the threshold even if token counters are stale. Skipped when workspace is read-only.
### `agents.defaults.runRetries`
Outer run loop retry iteration boundaries for the embedded agent runtime to prevent infinite execution loops during failure recovery. This setting only applies to the embedded agent runtime, not ACP or CLI runtimes.
```json5
{
agents: {
defaults: {
runRetries: {
base: 24,
perProfile: 8,
min: 32,
max: 160,
},
},
list: [
{
id: "main",
runRetries: { max: 50 }, // optional per-agent overrides
},
],
},
}
```
- `base`: base number of run retry iterations for the outer run loop. Default: `24`.
- `perProfile`: additional run retry iterations granted per fallback profile candidate. Default: `8`.
- `min`: minimum absolute limit for run retry iterations. Default: `32`.
- `max`: maximum absolute limit for run retry iterations to prevent runaway execution. Default: `160`.
### `agents.defaults.contextPruning`
Prunes **old tool results** from in-memory context before sending to the LLM. Does **not** modify session history on disk. Disabled by default; set `mode: "cache-ttl"` to enable.
@@ -709,14 +674,6 @@ Prunes **old tool results** from in-memory context before sending to the LLM. Do
defaults: {
contextPruning: {
mode: "cache-ttl", // off (default) | cache-ttl
ttl: "1h", // duration (ms/s/m/h), default unit: minutes; default: 5m
keepLastAssistants: 3,
softTrimRatio: 0.3,
hardClearRatio: 0.5,
minPrunableToolChars: 50000,
softTrim: { maxChars: 4000, headChars: 1500, tailChars: 1500 },
hardClear: { enabled: true, placeholder: "[Old tool result content cleared]" },
tools: { deny: ["browser", "canvas"] },
},
},
},
@@ -726,9 +683,7 @@ Prunes **old tool results** from in-memory context before sending to the LLM. Do
<Accordion title="cache-ttl mode behavior">
- `mode: "cache-ttl"` enables pruning passes.
- `ttl` controls how often pruning can run again (after the last cache touch). Default: `5m`.
- Pruning soft-trims oversized tool results first, then hard-clears older tool results if needed.
- `softTrimRatio` and `hardClearRatio` accept values from `0.0` through `1.0`; config validation rejects values outside that range.
**Soft-trim** keeps beginning + end and inserts `...` in the middle.
@@ -738,7 +693,7 @@ Notes:
- Image blocks are never trimmed/cleared.
- Ratios are character-based (approximate), not exact token counts.
- If fewer than `keepLastAssistants` assistant messages exist, pruning is skipped.
- The most recent assistant messages are preserved.
</Accordion>
@@ -782,7 +737,7 @@ See [Streaming](/concepts/streaming) for behavior + chunking details.
- Defaults: `instant` for direct chats/mentions, `message` for unmentioned group chats.
- `typingIntervalSeconds` default: `6`.
- Per-session overrides: `session.typingMode`, `session.typingIntervalSeconds`.
- Per-session overrides: `session.typingMode`.
See [Typing Indicators](/concepts/typing-indicators).
@@ -1288,18 +1243,12 @@ See [Multi-Agent Sandbox & Tools](/tools/multi-agent-sandbox-tools) for preceden
maxDiskBytes: "500mb", // optional hard budget
highWaterBytes: "400mb", // optional cleanup target
},
writeLock: {
acquireTimeoutMs: 60000,
staleMs: 1800000,
maxHoldMs: 300000,
},
threadBindings: {
enabled: true,
idleHours: 24, // default inactivity auto-unfocus in hours (`0` disables)
maxAgeHours: 0, // default hard max age in hours (`0` disables)
},
mainKey: "main", // legacy (runtime always uses "main")
agentToAgent: { maxPingPongTurns: 5 },
sendPolicy: {
rules: [{ action: "deny", match: { channel: "discord", chatType: "group" } }],
default: "allow",
@@ -1323,7 +1272,6 @@ See [Multi-Agent Sandbox & Tools](/tools/multi-agent-sandbox-tools) for preceden
- **`resetByType`**: per-type overrides (`direct`, `group`, `thread`). Legacy `dm` accepted as alias for `direct`.
- **`resetByChannel`**: per-channel reset overrides keyed by provider/channel id. When the session's channel has a matching entry, it wins outright over `resetByType`/`reset` for that session. Use only when one channel needs reset behavior different from the type-level policy.
- **`mainKey`**: legacy field. Runtime always uses `"main"` for the main direct-chat bucket.
- **`agentToAgent.maxPingPongTurns`**: maximum reply-back turns between agents during agent-to-agent exchanges (integer, range: `0`-`20`, default: `5`). `0` disables ping-pong chaining.
- **`sendPolicy`**: match by `channel`, `chatType` (`direct|group|channel`, with legacy `dm` alias), `keyPrefix`, or `rawKeyPrefix`. First deny wins.
- **`maintenance`**: session-store cleanup + retention controls.
- `mode`: `enforce` applies cleanup and is the default; `warn` emits warnings only.
@@ -1334,10 +1282,6 @@ See [Multi-Agent Sandbox & Tools](/tools/multi-agent-sandbox-tools) for preceden
- `resetArchiveRetention`: age-based retention for reset/deleted transcript archives. By default, archives remain until disk-budget eviction; set a duration to opt into wall-clock deletion, or `false` to disable it explicitly.
- `maxDiskBytes`: optional sessions-directory disk budget. In `warn` mode it logs warnings; in `enforce` mode it removes oldest artifacts/sessions first.
- `highWaterBytes`: optional target after budget cleanup. Defaults to `80%` of `maxDiskBytes`.
- **`writeLock`**: session transcript write-lock controls. Tune only when legitimate transcript prep, cleanup, compaction, or mirror work contends longer than the default policies.
- `acquireTimeoutMs`: milliseconds to wait while acquiring a lock before reporting the session as busy. Default: `60000`; env override `OPENCLAW_SESSION_WRITE_LOCK_ACQUIRE_TIMEOUT_MS`.
- `staleMs`: milliseconds before an existing lock is treated as stale and reclaimed. Default: `1800000`; env override `OPENCLAW_SESSION_WRITE_LOCK_STALE_MS`.
- `maxHoldMs`: milliseconds a held in-process lock may remain held before the watchdog releases it. Default: `300000`; env override `OPENCLAW_SESSION_WRITE_LOCK_MAX_HOLD_MS`.
- **`threadBindings`**: global defaults for thread-bound session features.
- `enabled`: master default switch (providers can override; Discord uses `channels.discord.threadBindings.enabled`)
- `idleHours`: default inactivity auto-unfocus in hours (`0` disables; providers can override)
-15
View File
@@ -116,19 +116,6 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat
{
web: {
enabled: true,
heartbeatSeconds: 60,
whatsapp: {
keepAliveIntervalMs: 25000,
connectTimeoutMs: 60000,
defaultQueryTimeoutMs: 60000,
},
reconnect: {
initialMs: 2000,
maxMs: 30000,
factor: 1.8,
jitter: 0.25,
maxAttempts: 12, // 0 = retry forever
},
},
channels: {
whatsapp: {
@@ -148,8 +135,6 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat
}
```
- `web.whatsapp.keepAliveIntervalMs` (default `25000`), `connectTimeoutMs` (default `60000`), and `defaultQueryTimeoutMs` (default `60000`) tune the Baileys socket.
- `web.reconnect` defaults: `initialMs: 2000`, `maxMs: 30000`, `factor: 1.8`, `jitter: 0.25`, `maxAttempts: 12`. `maxAttempts: 0` retries forever instead of giving up.
- Top-level `bindings[]` entries with `type: "acp"` configure persistent ACP bindings for WhatsApp DMs and groups. Use an E.164 direct number or WhatsApp group JID in `match.peer.id`. Field semantics are shared in [ACP Agents](/tools/acp-agents#persistent-channel-bindings).
<Accordion title="Multi-account WhatsApp">
-45
View File
@@ -227,56 +227,11 @@ Tool-loop safety checks are **disabled by default**. Set `enabled: true` to acti
tools: {
loopDetection: {
enabled: true,
historySize: 30,
warningThreshold: 10,
unknownToolThreshold: 10,
criticalThreshold: 20,
globalCircuitBreakerThreshold: 30,
detectors: {
genericRepeat: true,
knownPollNoProgress: true,
pingPong: true,
},
postCompactionGuard: {
windowSize: 3,
},
},
},
}
```
<ParamField path="historySize" type="number">
Max tool-call history retained for loop analysis.
</ParamField>
<ParamField path="warningThreshold" type="number">
Repeating no-progress pattern threshold for warnings.
</ParamField>
<ParamField path="unknownToolThreshold" type="number">
Blocks repeated calls to the same unavailable/unknown tool name after this many misses.
</ParamField>
<ParamField path="criticalThreshold" type="number">
Higher repeating threshold for blocking critical loops.
</ParamField>
<ParamField path="globalCircuitBreakerThreshold" type="number">
Hard stop threshold for any no-progress run.
</ParamField>
<ParamField path="detectors.genericRepeat" type="boolean">
Warn on repeated same-tool/same-args calls.
</ParamField>
<ParamField path="detectors.knownPollNoProgress" type="boolean">
Warn/block on known poll tools (`process.poll`, `command_status`, etc.).
</ParamField>
<ParamField path="detectors.pingPong" type="boolean">
Warn/block on alternating no-progress pair patterns.
</ParamField>
<ParamField path="postCompactionGuard.windowSize" type="number">
Number of attempts after auto-compaction the guard stays armed for; aborts if the agent repeats the same (tool, args, result) inside that window.
</ParamField>
<Warning>
If `warningThreshold >= criticalThreshold` or `criticalThreshold >= globalCircuitBreakerThreshold`, validation fails.
</Warning>
### `tools.web`
```json5
-2
View File
@@ -173,7 +173,6 @@ Save to `~/.openclaw/openclaw.json` and you can DM the bot from that number.
maxDiskBytes: "500mb", // optional
highWaterBytes: "400mb", // optional (defaults to 80% of maxDiskBytes)
},
typingIntervalSeconds: 5,
sendPolicy: {
default: "allow",
rules: [{ action: "deny", match: { channel: "discord", chatType: "group" } }],
@@ -384,7 +383,6 @@ Save to `~/.openclaw/openclaw.json` and you can DM the bot from that number.
cron: {
enabled: true,
store: "~/.openclaw/cron/jobs.json",
maxConcurrentRuns: 8, // default; cron dispatch + isolated cron agent-turn execution
sessionRetention: "24h",
},
+4 -133
View File
@@ -85,8 +85,6 @@ target server during config edits.
```json5
{
mcp: {
// Optional. Default: 600000 ms (10 minutes). Set 0 to disable idle eviction.
sessionIdleTtlMs: 600000,
servers: {
docs: {
command: "npx",
@@ -157,9 +155,8 @@ target server during config edits.
block before passing native `mcp_servers` config to Codex. Omit the block to
keep the server projected for every Codex app-server agent with Codex's
default MCP approval behavior.
- `mcp.sessionIdleTtlMs`: idle TTL for session-scoped bundled MCP runtimes.
One-shot embedded runs request run-end cleanup; this TTL is the backstop for
long-lived sessions and future callers.
- Session-scoped bundled MCP runtimes use a built-in 10-minute idle TTL.
One-shot embedded runs request run-end cleanup; the TTL is the backstop for long-lived sessions and future callers.
- Changes under `mcp.*` hot-apply by disposing cached session MCP runtimes.
The next tool discovery/use recreates them from the new config, so removed
`mcp.servers` entries are reaped immediately instead of waiting for idle TTL.
@@ -451,9 +448,7 @@ See [Inferred commitments](/concepts/commitments).
- `tabCleanup` controls best-effort periodic cleanup for tracked primary-agent
tabs after idle time or when a session exceeds its cap. Tracking applies only
to tabs created by browser tool `action: "open"`; tabs opened by the user or
with unknown ownership are never adopted. Set `idleMinutes: 0` or
`maxTabsPerSession: 0` to disable those individual cleanup modes. Disabling
`tabCleanup` does not disable explicit session lifecycle cleanup.
with unknown ownership are never adopted. Disabling `tabCleanup` does not disable explicit session lifecycle cleanup.
- Host-local opens with a stable native CDP target and browser identity are
stored in shared SQLite state and remain eligible across Gateway restarts for
`/new` and session lifecycle cleanup. Native tool-facing CDP targets also
@@ -475,10 +470,6 @@ See [Inferred commitments](/concepts/commitments).
- `profiles.*.cdpUrl` accepts `http://`, `https://`, `ws://`, and `wss://`.
Use HTTP(S) when you want OpenClaw to discover `/json/version`; use WS(S)
when your provider gives you a direct DevTools WebSocket URL.
- `remoteCdpTimeoutMs` and `remoteCdpHandshakeTimeoutMs` apply to remote and
`attachOnly` CDP reachability plus tab-opening requests. Managed loopback
profiles keep local CDP defaults. Persistent remote Playwright tab
enumeration uses the larger value as its operation deadline.
- If an externally managed CDP service is reachable through loopback, set that
profile's `attachOnly: true`; otherwise OpenClaw treats the loopback port as a
local managed browser profile and may report local port ownership errors.
@@ -500,11 +491,6 @@ See [Inferred commitments](/concepts/commitments).
- Local managed profiles can set `executablePath` to override the global
`browser.executablePath` for that profile. Use this to run one profile in
Chrome and another in Brave.
- Local managed profiles use `browser.localLaunchTimeoutMs` for Chrome CDP HTTP
discovery after process start and `browser.localCdpReadyTimeoutMs` for
post-launch CDP websocket readiness. Raise them on slower hosts where Chrome
starts successfully but readiness checks race startup. Both values must be
positive integers up to `120000` ms; invalid config values are rejected.
- Auto-detect order: default browser if Chromium-based → Chrome → Brave → Edge → Chromium → Chrome Canary.
- `browser.executablePath` and `browser.profiles.<name>.executablePath` both
accept `~` and `~/...` for your OS home directory before Chromium launch.
@@ -681,10 +667,7 @@ See [Inferred commitments](/concepts/commitments).
- Relay-backed registrations are delegated to a specific gateway identity. The paired iOS app fetches `gateway.identity.get`, includes that identity in the relay registration, and forwards a registration-scoped send grant to the gateway. Another gateway cannot reuse that stored registration.
- `OPENCLAW_APNS_RELAY_BASE_URL` / `OPENCLAW_APNS_RELAY_TIMEOUT_MS`: temporary env overrides for the relay config above.
- `OPENCLAW_APNS_RELAY_ALLOW_HTTP=true`: development-only escape hatch for loopback HTTP relay URLs. Production relay URLs should stay on HTTPS.
- `gateway.handshakeTimeoutMs`: pre-auth Gateway WebSocket handshake timeout in milliseconds. Default: `15000`. `OPENCLAW_HANDSHAKE_TIMEOUT_MS` takes precedence when set. Increase this on loaded or low-powered hosts where local clients can connect while startup warmup is still settling.
- `gateway.channelHealthCheckMinutes`: channel health-monitor interval in minutes. Set `0` to disable health-monitor restarts globally. Default: `5`.
- `gateway.channelStaleEventThresholdMinutes`: stale-socket threshold in minutes. Keep this greater than or equal to `gateway.channelHealthCheckMinutes`. Default: `30`.
- `gateway.channelMaxRestartsPerHour`: maximum health-monitor restarts per channel/account in a rolling hour. Default: `10`.
- `OPENCLAW_HANDSHAKE_TIMEOUT_MS`: optional environment override for the built-in pre-auth Gateway WebSocket handshake timeout.
- `channels.<provider>.healthMonitor.enabled`: per-channel opt-out for health-monitor restarts while keeping the global monitor enabled.
- `channels.<provider>.accounts.<accountId>.healthMonitor.enabled`: per-account override for multi-account channels. When set, it takes precedence over the channel-level override.
- Local gateway call paths can use `gateway.remote.*` as fallback only when `gateway.auth.*` is unset.
@@ -887,7 +870,6 @@ Lifetime values are data only in the first cloud-worker release; automatic enfor
enabled: true,
token: "shared-secret",
path: "/hooks",
maxBodyBytes: 262144,
defaultSessionKey: "hook:ingress",
allowRequestSessionKey: true,
allowedSessionKeyPrefixes: ["hook:", "hook:gmail:"],
@@ -1193,42 +1175,6 @@ Notes:
- See [OAuth](/concepts/oauth).
- Secrets runtime behavior and `audit/configure/apply` tooling: [Secrets Management](/gateway/secrets).
### `auth.cooldowns`
```json5
{
auth: {
cooldowns: {
billingBackoffHours: 5,
billingBackoffHoursByProvider: { anthropic: 3, openai: 8 },
billingMaxHours: 24,
authPermanentBackoffMinutes: 10,
authPermanentMaxMinutes: 60,
failureWindowHours: 24,
overloadedProfileRotations: 1,
overloadedBackoffMs: 0,
rateLimitedProfileRotations: 1,
},
},
}
```
- `billingBackoffHours`: base backoff in hours when a profile fails due to true
billing/insufficient-credit errors (default: `5`). Explicit billing text can
still land here even on `401`/`403` responses, but provider-specific text
matchers stay scoped to the provider that owns them (for example OpenRouter
`Key limit exceeded`). Retryable HTTP `402` usage-window or
organization/workspace spend-limit messages stay in the `rate_limit` path
instead.
- `billingBackoffHoursByProvider`: optional per-provider overrides for billing backoff hours.
- `billingMaxHours`: cap in hours for billing backoff exponential growth (default: `24`).
- `authPermanentBackoffMinutes`: base backoff in minutes for high-confidence `auth_permanent` failures (default: `10`).
- `authPermanentMaxMinutes`: cap in minutes for `auth_permanent` backoff growth (default: `60`).
- `failureWindowHours`: rolling window in hours used for backoff counters (default: `24`).
- `overloadedProfileRotations`: maximum same-provider auth-profile rotations for overloaded errors before switching to model fallback (default: `1`). Provider-busy shapes such as `ModelNotReadyException` land here.
- `overloadedBackoffMs`: fixed delay before retrying an overloaded provider/profile rotation (default: `0`).
- `rateLimitedProfileRotations`: maximum same-provider auth-profile rotations for rate-limit errors before switching to model fallback (default: `1`). That rate-limit bucket includes provider-shaped text such as `Too many concurrent requests`, `ThrottlingException`, `concurrency limit reached`, `workers_ai ... quota limit exceeded`, and `resource exhausted`.
---
## Audit
@@ -1307,9 +1253,6 @@ writer is best-effort, not a lossless compliance archive.
diagnostics: {
enabled: true,
flags: ["telegram.*"],
stuckSessionWarnMs: 30000,
stuckSessionAbortMs: 300000,
memoryPressureSnapshot: false,
otel: {
enabled: false,
@@ -1350,9 +1293,6 @@ writer is best-effort, not a lossless compliance archive.
- `enabled`: master toggle for instrumentation output (default: `true`).
- `flags`: array of flag strings enabling targeted log output (supports wildcards like `"telegram.*"` or `"*"`).
- `stuckSessionWarnMs`: no-progress age threshold in ms for classifying long-running processing sessions as `session.long_running`, `session.stalled`, or `session.stuck` (default: `120000`). Reply, tool, status, block, and ACP progress reset the timer; repeated `session.stuck` diagnostics back off while unchanged.
- `stuckSessionAbortMs`: no-progress age threshold in ms before eligible stalled active work may be abort-drained for recovery. When unset, OpenClaw uses the safer extended embedded-run window of at least 5 minutes and 3x `stuckSessionWarnMs`.
- `memoryPressureSnapshot`: captures a redacted pre-OOM stability snapshot when memory pressure reaches `critical` (default: `false`). Set to `true` to add the stability bundle file scan/write while keeping normal memory pressure events.
- `otel.enabled`: enables the OpenTelemetry export pipeline (default: `false`). For the full configuration, signal catalog, and privacy model, see [OpenTelemetry export](/gateway/opentelemetry).
- `otel.endpoint`: collector URL for OTel export.
- `otel.tracesEndpoint` / `otel.metricsEndpoint` / `otel.logsEndpoint`: optional signal-specific OTLP endpoints. When set, they override `otel.endpoint` for that signal only.
@@ -1383,9 +1323,6 @@ writer is best-effort, not a lossless compliance archive.
auto: {
enabled: false,
stableDelayHours: 6,
stableJitterHours: 12,
betaCheckIntervalHours: 1,
},
},
}
@@ -1394,9 +1331,6 @@ writer is best-effort, not a lossless compliance archive.
- `channel`: release channel - `"stable"`, `"extended-stable"`, `"beta"`, or `"dev"`. Extended-stable is package-only: foreground commands own installation, while the Gateway may emit read-only update hints.
- `checkOnStart`: check for npm updates when the gateway starts (default: `true`). Stored extended-stable selections use the same read-only hint and 24-hour hint schedule.
- `auto.enabled`: enable background auto-update for stable and beta package installs (default: `false`). Extended-stable never applies automatically.
- `auto.stableDelayHours`: minimum delay in hours before stable-channel auto-apply (default: `6`; max: `168`).
- `auto.stableJitterHours`: extra stable-channel rollout spread window in hours (default: `12`; max: `168`).
- `auto.betaCheckIntervalHours`: how often beta-channel checks run in hours (default: `1`; max: `24`). Stable delay/jitter and beta polling settings do not apply to extended-stable.
---
@@ -1411,20 +1345,9 @@ writer is best-effort, not a lossless compliance archive.
fallbacks: ["acpx-secondary"],
defaultAgent: "main",
allowedAgents: ["main", "ops"],
maxConcurrentSessions: 10,
stream: {
coalesceIdleMs: 50,
maxChunkChars: 1000,
repeatSuppression: true,
deliveryMode: "live", // live | final_only
hiddenBoundarySeparator: "paragraph", // none | space | newline | paragraph
maxOutputChars: 50000,
maxSessionUpdateChars: 500,
},
runtime: {
ttlMinutes: 30,
},
},
}
@@ -1437,16 +1360,9 @@ writer is best-effort, not a lossless compliance archive.
- `fallbacks`: ordered list of fallback ACP backend ids tried when the primary backend fails early with a transient-looking error (unavailable, rate-limited, quota exhausted, or overloaded) before it produced any output. Each entry must match a registered ACP runtime plugin backend.
- `defaultAgent`: fallback ACP target agent id when spawns do not specify an explicit target.
- `allowedAgents`: allowlist of agent ids permitted for ACP runtime sessions; empty means no additional restriction.
- `maxConcurrentSessions`: maximum concurrently active ACP sessions.
- `stream.coalesceIdleMs`: idle flush window in ms for streamed text.
- `stream.maxChunkChars`: maximum chunk size before splitting streamed block projection.
- `stream.repeatSuppression`: suppress repeated status/tool lines per turn (default: `true`).
- `stream.deliveryMode`: `"live"` streams incrementally; `"final_only"` buffers until turn terminal events.
- `stream.hiddenBoundarySeparator`: separator before visible text after hidden tool events (default: `"paragraph"`).
- `stream.maxOutputChars`: maximum assistant output characters projected per ACP turn.
- `stream.maxSessionUpdateChars`: maximum characters for projected ACP status/update lines.
- `stream.tagVisibility`: record of tag names to boolean visibility overrides for streamed events.
- `runtime.ttlMinutes`: idle TTL in minutes for ACP session workers before eligible cleanup.
- `runtime.installCommand`: optional install command to run when bootstrapping an ACP runtime environment.
---
@@ -1532,7 +1448,6 @@ Current builds no longer include the TCP bridge. Nodes connect over the Gateway
{
cron: {
enabled: true,
maxConcurrentRuns: 8, // default; cron dispatch + isolated cron agent-turn execution
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
@@ -1545,26 +1460,6 @@ Current builds no longer include the TCP bridge. Nodes connect over the Gateway
- `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 by `openclaw doctor --fix` to migrate stored jobs that still have `notify: true`; runtime delivery uses per-job `delivery.mode="webhook"` plus `delivery.to`, or `delivery.completionDestination` when preserving announce delivery.
### `cron.retry`
```json5
{
cron: {
retry: {
maxAttempts: 3,
backoffMs: [30000, 60000, 300000],
retryOn: ["rate_limit", "overloaded", "network", "timeout", "server_error"],
},
},
}
```
- `maxAttempts`: maximum retries for cron jobs on transient errors (default: `3`; range: `0`-`10`).
- `backoffMs`: array of backoff delays in ms for each retry attempt (default: `[30000, 60000, 300000]`; 1-10 entries).
- `retryOn`: error types that trigger retries - `"rate_limit"`, `"overloaded"`, `"network"`, `"timeout"`, `"server_error"`. Omit to retry all transient types.
One-shot jobs stay enabled until retry attempts are exhausted, then disable while keeping the final error state. Recurring jobs use the same transient retry policy to run again after backoff before their next scheduled slot; permanent errors or exhausted transient retries fall back to the normal recurring schedule with error backoff.
### `cron.failureAlert`
```json5
@@ -1615,30 +1510,6 @@ One-shot jobs stay enabled until retry attempts are exhausted, then disable whil
See [Cron Jobs](/automation/cron-jobs). Isolated cron executions are tracked as [background tasks](/automation/tasks).
---
## Worktrees
```json5
{
worktrees: {
cleanup: {
maxCount: 25, // max managed worktrees across all repositories; 0 or unset disables
maxTotalSizeGb: 50, // max total size in GB across all managed worktrees; 0 or unset disables
},
},
}
```
Retention limits for OpenClaw-managed worktrees, enforced by hourly cleanup, `openclaw worktrees gc`, and the Control UI **Clean up now** action. When a limit is exceeded, cleanup snapshots and removes the least recently active session- and Workboard-owned worktrees until the count and total size fit. Manual worktrees, worktrees with live locks or run leases, and worktrees owned by recently active sessions are never limit-evicted, so a limit can remain exceeded when only protected worktrees are left. Removed worktrees stay restorable from their snapshots for 30 days.
- `cleanup.maxCount`: maximum number of managed worktrees to retain across all repositories. Default: unset (no limit).
- `cleanup.maxTotalSizeGb`: maximum total disk size in GB across all managed worktrees, measured during cleanup. Fractional values such as `0.5` are accepted. Default: unset (no limit).
The Control UI **Worktrees** page under Settings exposes both limits as stepper controls. See [Managed worktrees](/concepts/managed-worktrees).
---
## Media model template variables
Template placeholders expanded in `tools.media.models[].args`:
+3 -29
View File
@@ -238,16 +238,11 @@ candidate contains a redacted secret placeholder such as `***` or `[redacted]`.
</Accordion>
<Accordion title="Tune gateway channel health monitoring">
Control how aggressively the gateway restarts channels that look stale:
<Accordion title="Configure per-channel health monitoring">
Disable or enable automatic health restarts for a channel or account:
```json5
{
gateway: {
channelHealthCheckMinutes: 5,
channelStaleEventThresholdMinutes: 30,
channelMaxRestartsPerHour: 10,
},
channels: {
telegram: {
healthMonitor: { enabled: false },
@@ -261,31 +256,11 @@ candidate contains a redacted secret placeholder such as `***` or `[redacted]`.
}
```
- Values shown are the defaults. Set `gateway.channelHealthCheckMinutes: 0` to disable health-monitor restarts globally.
- `channelStaleEventThresholdMinutes` should be greater than or equal to the check interval.
- Use `channels.<provider>.healthMonitor.enabled` or `channels.<provider>.accounts.<id>.healthMonitor.enabled` to disable auto-restarts for one channel or account without disabling the global monitor.
- Use `channels.<provider>.healthMonitor.enabled` or `channels.<provider>.accounts.<id>.healthMonitor.enabled` to control auto-restarts for one channel or account.
- See [Health Checks](/gateway/health) for operational debugging and the [full reference](/gateway/configuration-reference#gateway) for all fields.
</Accordion>
<Accordion title="Tune gateway WebSocket handshake timeout">
Give local clients more time to complete the pre-auth WebSocket handshake on
loaded or low-powered hosts:
```json5
{
gateway: {
handshakeTimeoutMs: 30000,
},
}
```
- Default is `15000` milliseconds.
- `OPENCLAW_HANDSHAKE_TIMEOUT_MS` still takes precedence for one-off service or shell overrides.
- Prefer fixing startup/event-loop stalls first; this knob is for hosts that are healthy but slow during warmup.
</Accordion>
<Accordion title="Configure sessions and resets">
Sessions control conversation continuity and isolation:
@@ -420,7 +395,6 @@ candidate contains a redacted secret placeholder such as `***` or `[redacted]`.
{
cron: {
enabled: true,
maxConcurrentRuns: 8, // default; cron dispatch + isolated cron agent-turn execution
sessionRetention: "24h",
},
}
+3 -15
View File
@@ -175,21 +175,9 @@ diagnostic event collection:
Disabling diagnostics reduces bug-report detail; it does not affect normal
Gateway logging.
Critical memory pressure snapshots are off by default. To capture the
pre-OOM stability snapshot in addition to normal diagnostics events:
```json5
{
diagnostics: {
memoryPressureSnapshot: true,
},
}
```
Use this only on hosts that can tolerate the extra file-system scan and
snapshot write during critical memory pressure. Normal memory pressure events
still record RSS, heap, threshold, and growth facts (`rss_threshold`,
`heap_threshold`, `rss_growth`) when the snapshot is off.
Memory pressure events record RSS, heap, threshold, and growth facts
(`rss_threshold`, `heap_threshold`, `rss_growth`) without performing a
file-system scan or writing a pre-OOM snapshot.
## Related
+1 -1
View File
@@ -302,7 +302,7 @@ That stages grounded durable candidates into the short-term dreaming store while
| `plugins.openai-codex` policy ids | `plugins.openai` |
| `tools.web.x_search.apiKey` | `plugins.entries.xai.config.webSearch.apiKey` |
| `session.maintenance.rotateBytes`, `session.parentForkMaxTokens` | removed (deprecated) |
| `diagnostics.memoryPressureBundle` | `diagnostics.memoryPressureSnapshot` |
| Runtime and channel tuning knobs retired in 2026.7 | removed (built-in production defaults apply) |
<Note>
The `plugins.entries.voice-call.config.*` rows above are normalized by
+2 -5
View File
@@ -31,15 +31,12 @@ health commands above for live connectivity checks.
- Creds on disk: `ls -l ~/.openclaw/credentials/whatsapp/<accountId>/creds.json` (mtime should be recent).
- Session store: `ls -l ~/.openclaw/agents/<agentId>/agent/openclaw-agent.sqlite`. Count and recent recipients are surfaced via `status`.
- Relink flow: `openclaw channels logout && openclaw channels login --verbose` when status codes 409-515 or `loggedOut` appear in logs. The QR login flow auto-restarts once for status 515 after pairing.
- Diagnostics are enabled by default (`diagnostics.enabled: false` disables them). Memory events record RSS/heap byte counts and threshold/growth pressure; critical memory pressure logs through the gateway logger and, when `diagnostics.memoryPressureSnapshot: true` is set, also writes a pre-OOM stability bundle (V8 heap stats, Linux cgroup counters when available, active resource counts, largest session/transcript files by redacted relative path). Liveness warnings record event-loop delay/utilization, CPU-core ratio, and active/waiting/queued session counts when the process is running but saturated. Oversized-payload events record what was rejected/truncated/chunked plus sizes and limits, never message text, attachment contents, webhook bodies, raw request/response bodies, tokens, cookies, or secret values.
- The same heartbeat drives the bounded stability recorder: `openclaw gateway stability` (or the `diagnostics.stability` Gateway RPC). Fatal Gateway exits, shutdown timeouts, restart startup failures, and (when `diagnostics.memoryPressureSnapshot: true`) critical memory pressure persist the latest snapshot under `~/.openclaw/logs/stability/`. Inspect the newest bundle with `openclaw gateway stability --bundle latest`.
- Diagnostics are enabled by default (`diagnostics.enabled: false` disables them). Memory events record RSS/heap byte counts and threshold/growth pressure. Liveness warnings record event-loop delay/utilization, CPU-core ratio, and active/waiting/queued session counts when the process is running but saturated. Oversized-payload events record what was rejected/truncated/chunked plus sizes and limits, never message text, attachment contents, webhook bodies, raw request/response bodies, tokens, cookies, or secret values.
- The same heartbeat drives the bounded stability recorder: `openclaw gateway stability` (or the `diagnostics.stability` Gateway RPC). Fatal Gateway exits, shutdown timeouts, and restart startup failures persist the latest snapshot under `~/.openclaw/logs/stability/`. Inspect the newest bundle with `openclaw gateway stability --bundle latest`.
- For bug reports, run `openclaw gateway diagnostics export` and attach the generated zip: a Markdown summary, the newest stability bundle, sanitized log metadata, sanitized Gateway status/health snapshots, and config shape. Chat text, webhook bodies, tool outputs, credentials, cookies, account/message identifiers, and secret values are omitted or redacted. See [Diagnostics Export](/gateway/diagnostics).
## Health monitor config
- `gateway.channelHealthCheckMinutes`: how often the gateway checks channel health. Default: `5`. Set `0` to disable health-monitor restarts globally.
- `gateway.channelStaleEventThresholdMinutes`: how long a connected channel can stay idle before the health monitor treats it as stale and restarts it. Default: `30`. Keep this greater than or equal to `gateway.channelHealthCheckMinutes`.
- `gateway.channelMaxRestartsPerHour`: rolling one-hour cap for health-monitor restarts per channel/account. Default: `10`.
- `channels.<provider>.healthMonitor.enabled`: disable health-monitor restarts for a specific channel while leaving global monitoring enabled.
- `channels.<provider>.accounts.<accountId>.healthMonitor.enabled`: multi-account override that wins over the channel-level setting.
- These per-channel overrides apply to the built-in channels that expose them today: Discord, Google Chat, iMessage, IRC, Microsoft Teams, Signal, Slack, Telegram, and WhatsApp.
+12 -15
View File
@@ -104,9 +104,12 @@ By default the endpoint is **stateless per request** (a new session key is gener
If the request includes an OpenAI `user` string, the Gateway derives a stable session key from it so repeated calls can share an agent session. For custom apps, reuse the same `user` value per conversation thread; avoid account-level identifiers unless you want multiple conversations/devices to share one OpenClaw session. Use `x-openclaw-session-key` only when you need explicit routing control across multiple clients/threads, with application-owned keys that avoid the reserved namespaces above.
## Request limits (config)
## Request limits
Defaults can be tuned under `gateway.http.endpoints.chatCompletions`:
The endpoint uses built-in limits of 20 MB per request body, 8 `image_url`
parts from the latest user message, and 20 MB of cumulative decoded image
data. Image source policy remains configurable under
`gateway.http.endpoints.chatCompletions.images`:
```json5
{
@@ -115,9 +118,6 @@ Defaults can be tuned under `gateway.http.endpoints.chatCompletions`:
endpoints: {
chatCompletions: {
enabled: true,
maxBodyBytes: 20000000,
maxImageParts: 8,
maxTotalImageBytes: 20000000,
images: {
allowUrl: false,
urlAllowlist: ["cdn.example.com", "*.assets.example.com"],
@@ -140,17 +140,14 @@ Defaults can be tuned under `gateway.http.endpoints.chatCompletions`:
}
```
Defaults when omitted:
Image settings default to:
| Key | Default |
| --------------------- | --------------------------------------------------------------------------- |
| `maxBodyBytes` | 20MB |
| `maxImageParts` | 8 (max `image_url` parts read from the latest user message) |
| `maxTotalImageBytes` | 20MB (cumulative decoded bytes across all `image_url` parts in one request) |
| `images.allowUrl` | `false` (URL-sourced `image_url` parts are rejected unless enabled) |
| `images.maxBytes` | 10MB per image |
| `images.maxRedirects` | 3 |
| `images.timeoutMs` | 10s |
| Key | Default |
| --------------------- | ------------------------------------------------------------------- |
| `images.allowUrl` | `false` (URL-sourced `image_url` parts are rejected unless enabled) |
| `images.maxBytes` | 10MB per image |
| `images.maxRedirects` | 3 |
| `images.timeoutMs` | 10s |
HEIC/HEIF `image_url` sources are accepted and normalized to JPEG before provider delivery through the shared OpenClaw image processor (Rastermill), which falls back to a system converter (`sips`, ImageMagick, GraphicsMagick, or ffmpeg) for formats needing external codec support.
+3 -4
View File
@@ -135,9 +135,10 @@ URL fetch defaults:
- Optional hostname allowlists are supported per input type (`files.urlAllowlist`, `images.urlAllowlist`): exact host (`"cdn.example.com"`) or wildcard subdomains (`"*.assets.example.com"`, does not match the apex). Empty or omitted allowlists mean no hostname allowlist restriction.
- To disable URL-based fetches entirely, set `files.allowUrl: false` and/or `images.allowUrl: false`.
## File + image limits (config)
## File + image limits
Defaults can be tuned under `gateway.http.endpoints.responses`:
The endpoint uses a built-in 20 MB request-body limit. File and image source
policy remains configurable under `gateway.http.endpoints.responses`:
```json5
{
@@ -146,7 +147,6 @@ Defaults can be tuned under `gateway.http.endpoints.responses`:
endpoints: {
responses: {
enabled: true,
maxBodyBytes: 20000000,
maxUrlParts: 8,
files: {
allowUrl: true,
@@ -195,7 +195,6 @@ Defaults when omitted:
| Key | Default |
| ------------------------ | --------- |
| `maxBodyBytes` | 20MB |
| `maxUrlParts` | 8 |
| `files.maxBytes` | 5MB |
| `files.maxChars` | 60k |
+4 -14
View File
@@ -350,28 +350,18 @@ bounds; content remains off by default.
### Session liveness telemetry
`diagnostics.stuckSessionWarnMs` is the no-progress age threshold for session
liveness diagnostics. A `processing` session does not age toward this
threshold while OpenClaw observes reply, tool, status, block, or ACP runtime
progress. Typing keepalives do not count as progress, so a silent model or
harness can still be detected.
A `processing` session does not age toward the built-in liveness threshold while OpenClaw observes reply, tool, status, block, or ACP runtime progress. Typing keepalives do not count as progress, so a silent model or harness can still be detected.
OpenClaw classifies sessions by the work it can still observe:
- `session.long_running`: active embedded work, model calls, or tool calls
are still making progress. Owned model calls that stay silent past
`diagnostics.stuckSessionWarnMs` also report as long-running before
`diagnostics.stuckSessionAbortMs`, so slow or non-streaming model providers
do not look like stalled gateway sessions while abort-observable.
are still making progress. Owned silent model calls also report as long-running before the built-in abort threshold, so slow or non-streaming model providers do not look like stalled gateway sessions while abort-observable.
- `session.stalled`: active work exists, but the active run has not reported
recent progress. Owned model calls switch from `session.long_running` to
`session.stalled` at or after `diagnostics.stuckSessionAbortMs`; ownerless
`session.stalled` at or after the built-in abort threshold; ownerless
stale model/tool activity is not treated as harmless long-running work.
Stalled embedded runs stay observe-only at first, then abort-drain after
`diagnostics.stuckSessionAbortMs` with no progress so queued turns behind
the lane can resume. When unset, the abort threshold defaults to the safer
extended window of at least 5 minutes and 3x
`diagnostics.stuckSessionWarnMs`.
the abort threshold with no progress so queued turns behind the lane can resume.
- `session.stuck`: stale session bookkeeping with no active work, or an idle
queued session with stale ownerless model/tool activity. This releases the
affected session lane immediately after recovery gates pass.
-5
View File
@@ -190,11 +190,6 @@ Define providers under `secrets.providers`:
file: "filemain",
exec: "vault",
},
resolution: {
maxProviderConcurrency: 4,
maxRefsPerProvider: 512,
maxBatchBytes: 262144,
},
},
}
```
+2 -2
View File
@@ -601,11 +601,11 @@ Look for:
Common signatures:
- `critical memory pressure bundle written` appears shortly before restart → OpenClaw captured a pre-OOM stability bundle. Inspect it with `openclaw gateway stability --bundle latest`.
- `memory pressure: level=critical ... memoryPressureSnapshot=disabled` appears in gateway logs → OpenClaw detected critical memory pressure, but the pre-OOM stability snapshot is off.
- `memory pressure: level=critical` appears in gateway logs → OpenClaw detected critical memory pressure and recorded the available in-process memory facts.
- `Largest session files:` points at a very large redacted transcript path → reduce retained session history, inspect session growth, or move old transcripts out of the active store before restarting.
- `V8 heap:` used bytes are close to the heap limit → lower prompt/session pressure or reduce concurrent work first. For a managed service, inspect `Gateway heap:` in `openclaw gateway status`; if it says `not set`, regenerate old service metadata with `openclaw gateway install --force`. Ambient shell `NODE_OPTIONS` is intentionally ignored. Use an explicit supervisor-level heap override only after confirming the sustained workload and leaving enough native-memory headroom.
- `Memory pressure: critical/rss_growth` → memory grew quickly inside one sampling window. Check the latest logs for a large import, runaway tool output, repeated retries, or a batch of queued agent work.
- Critical memory pressure appears in logs but no bundle exists → this is the default. Set `diagnostics.memoryPressureSnapshot: true` to capture the pre-OOM stability bundle on future critical memory pressure events.
- Critical memory pressure appears in logs but no bundle exists → capture `openclaw gateway diagnostics export` after the event for the available operational evidence.
The stability bundle is payload-free. It includes operational memory evidence and redacted relative file paths, not message text, webhook bodies, credentials, tokens, cookies, or raw session ids. Attach the diagnostics export to bug reports instead of copying raw logs.
+1 -2
View File
@@ -462,8 +462,7 @@ Related: [/concepts/oauth](/concepts/oauth) (OAuth flows, token storage, multi-a
OpenClaw may skip a profile in a short **cooldown** (rate limits,
timeouts, auth failures) or a longer **disabled** state
(billing/insufficient credits). Inspect with `openclaw models status
--json` and check `auth.unusableProfiles`. Tune with
`auth.cooldowns.billingBackoffHours*`. Rate-limit cooldowns can be
--json` and check `auth.unusableProfiles`. Rate-limit cooldowns can be
model-scoped — a profile cooling down for one model can still serve a
sibling model on the same provider; billing/disabled windows block the
whole profile.
+6 -9
View File
@@ -229,20 +229,17 @@ Off by default. Enable it in `~/.openclaw/openclaw.json`:
channel: "stable",
auto: {
enabled: true,
stableDelayHours: 6,
stableJitterHours: 12,
betaCheckIntervalHours: 1,
},
},
}
```
| Channel | Behavior |
| ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
| `stable` | Waits `stableDelayHours` (default: 6), then applies with deterministic jitter across `stableJitterHours` (default: 12) for a spread rollout. |
| `extended-stable` | Checks for a read-only update hint on startup and every 24 hours when `checkOnStart` is enabled. Never applies automatically. |
| `beta` | Checks every `betaCheckIntervalHours` (default: 1) and applies immediately. |
| `dev` | No automatic apply. Use `openclaw update` manually. |
| Channel | Behavior |
| ----------------- | ----------------------------------------------------------------------------------------------------------------------------- |
| `stable` | Applies after a built-in delay with deterministic jitter for a spread rollout. |
| `extended-stable` | Checks for a read-only update hint on startup and every 24 hours when `checkOnStart` is enabled. Never applies automatically. |
| `beta` | Checks on a built-in interval and applies immediately. |
| `dev` | No automatic apply. Use `openclaw update` manually. |
The gateway also logs an update hint on startup (disable with
`update.checkOnStart: false`). Stored extended-stable selections use this
-1
View File
@@ -196,7 +196,6 @@ runtime behavior. Runtime behavior starts when the plugin entry calls
| `imagePathScope` | Where staged image files live before handoff: `temp` or `workspace` |
| `serialize` | Keep same-backend runs ordered |
| `reseedFromRawTranscriptWhenUncompacted` | Opt in to bounded raw-transcript reseed before compaction for safe session resets |
| `reliability.outputLimits` | Max raw JSONL chars/lines retained for one live CLI turn (live-session backends) |
| `reliability.watchdog` | No-output timeout tuning, separate for fresh vs resumed runs |
Prefer the smallest static config that matches the CLI. Add plugin callbacks
-2
View File
@@ -2034,8 +2034,6 @@ payload.
6. Delete file-lock-shaped session mutation.
- Done for runtime lock creation and runtime lock APIs.
- The standalone legacy `.jsonl.lock` doctor cleanup lane is removed.
- `session.writeLock` is doctor-migrated legacy config, not a typed runtime
setting.
- State integrity no longer has a separate orphan transcript-file pruning
path; doctor migration imports/removes legacy JSONL sources in one place.
- Gateway singleton coordination uses typed SQLite `state_leases` rows under
+17 -38
View File
@@ -1,11 +1,11 @@
---
summary: "All configuration knobs for memory search, embedding providers, QMD, hybrid search, and multimodal indexing"
summary: "Memory search providers, retrieval modes, QMD, and multimodal indexing"
title: "Memory configuration reference"
sidebarTitle: "Memory config"
read_when:
- You want to configure memory search providers or embedding models
- You want to set up the QMD backend
- You want to tune hybrid search, MMR, or temporal decay
- You want to enable hybrid search, MMR, or temporal decay
- You want to enable multimodal memory indexing
---
@@ -372,21 +372,8 @@ All under `memorySearch.sync` unless noted:
| `onSessionStart` | `boolean` | `true` | Sync the memory index when a session starts |
| `onSearch` | `boolean` | `true` | Sync lazily on search after detecting content changes |
| `watch` | `boolean` | `true` | Watch memory files (chokidar) and schedule reindex on changes |
| `watchDebounceMs` | `number` | `1500` | Debounce window for coalescing rapid file-watch events |
| `intervalMinutes` | `number` | `0` | Periodic reindex interval in minutes (`0` disables) |
| `sessions.postCompactionForce` | `boolean` | `true` | Force a session reindex after compaction-triggered transcript updates |
<ParamField path="chunking.tokens" type="number">
Chunk size in tokens used when splitting memory sources before embedding (default: 400).
</ParamField>
<ParamField path="chunking.overlap" type="number">
Token overlap between adjacent chunks to preserve context near split boundaries (default: 80).
</ParamField>
<Note>
Changing `chunking.tokens` or `chunking.overlap` changes chunk boundaries and invalidates the existing index identity (see the Warning under Provider selection).
</Note>
---
## Hybrid search config
@@ -400,25 +387,20 @@ All under `memorySearch.query`:
And under `memorySearch.query.hybrid`:
| Key | Type | Default | Description |
| --------------------- | --------- | ------- | ---------------------------------- |
| `enabled` | `boolean` | `true` | Enable hybrid BM25 + vector search |
| `vectorWeight` | `number` | `0.7` | Weight for vector scores (0-1) |
| `textWeight` | `number` | `0.3` | Weight for BM25 scores (0-1) |
| `candidateMultiplier` | `number` | `4` | Candidate pool size multiplier |
| Key | Type | Default | Description |
| --------- | --------- | ------- | ---------------------------------- |
| `enabled` | `boolean` | `true` | Enable hybrid BM25 + vector search |
<Tabs>
<Tab title="MMR (diversity)">
| Key | Type | Default | Description |
| ------------- | --------- | ------- | ------------------------------------- |
| `mmr.enabled` | `boolean` | `false` | Enable MMR re-ranking |
| `mmr.lambda` | `number` | `0.7` | 0 = max diversity, 1 = max relevance |
| Key | Type | Default | Description |
| ------------- | --------- | ------- | --------------------- |
| `mmr.enabled` | `boolean` | `false` | Enable MMR re-ranking |
</Tab>
<Tab title="Temporal decay (recency)">
| Key | Type | Default | Description |
| ---------------------------- | --------- | ------- | -------------------------- |
| `temporalDecay.enabled` | `boolean` | `false` | Enable recency boost |
| `temporalDecay.halfLifeDays` | `number` | `30` | Score halves every N days |
| Key | Type | Default | Description |
| ----------------------- | --------- | ------- | -------------------- |
| `temporalDecay.enabled` | `boolean` | `false` | Enable recency boost |
Evergreen files (`MEMORY.md`, non-dated files in `memory/`) are never decayed.
@@ -436,10 +418,8 @@ And under `memorySearch.query.hybrid`:
maxResults: 6,
minScore: 0.35,
hybrid: {
vectorWeight: 0.7,
textWeight: 0.3,
mmr: { enabled: true, lambda: 0.7 },
temporalDecay: { enabled: true, halfLifeDays: 30 },
mmr: { enabled: true },
temporalDecay: { enabled: true },
},
},
},
@@ -494,12 +474,11 @@ Supported formats: `.jpg`, `.jpeg`, `.png`, `.webp`, `.gif`, `.heic`, `.heif` (i
## Embedding cache
| Key | Type | Default | Description |
| ------------------ | --------- | ------- | -------------------------------------------- |
| `cache.enabled` | `boolean` | `true` | Cache chunk embeddings in SQLite |
| `cache.maxEntries` | `number` | unset | Best-effort upper bound on cached embeddings |
| Key | Type | Default | Description |
| --------------- | --------- | ------- | -------------------------------- |
| `cache.enabled` | `boolean` | `true` | Cache chunk embeddings in SQLite |
Prevents re-embedding unchanged text during reindex or transcript updates. Leave `maxEntries` unset for an unbounded cache; set it when disk growth matters more than peak reindex speed. When set, the oldest entries (by last-updated time) are pruned first once the cache exceeds the limit.
Prevents re-embedding unchanged text during reindex or transcript updates.
---
@@ -78,13 +78,9 @@ OpenClaw no longer creates automatic `sessions.json.bak.*` rotation backups duri
Transcript mutations use the session write queue for the SQLite transcript target:
| Setting | Default | Env override |
| ------------------------------------ | --------- | ------------------------------------------------ |
| `session.writeLock.acquireTimeoutMs` | `60000` | `OPENCLAW_SESSION_WRITE_LOCK_ACQUIRE_TIMEOUT_MS` |
| `session.writeLock.staleMs` | `1800000` | `OPENCLAW_SESSION_WRITE_LOCK_STALE_MS` |
| `session.writeLock.maxHoldMs` | `300000` | `OPENCLAW_SESSION_WRITE_LOCK_MAX_HOLD_MS` |
`acquireTimeoutMs` is how long a lock wait surfaces a busy-session error before giving up; raise it only when legitimate prep, cleanup, compaction, or transcript mirror work contends longer on slow machines. `staleMs` is when an existing lock can be reclaimed as stale. `maxHoldMs` is the in-process watchdog release threshold.
Session write locks use fixed production defaults. The corresponding
`OPENCLAW_SESSION_WRITE_LOCK_*` environment variables remain available for
process-level diagnostics and emergency overrides.
### Downgrading After The SQLite Flip
@@ -217,7 +213,7 @@ When splitting a long transcript into compaction chunks, OpenClaw keeps assistan
Two triggers in the embedded OpenClaw agent:
1. **Overflow recovery**: the model returns a context-overflow error (`request_too_large`, `context length exceeded`, `input exceeds the maximum number of tokens`, `input token count exceeds the maximum number of input tokens`, `input is too long for the model`, `ollama error: context length exceeded`, and other provider-shaped variants) - compact, then retry. When the provider reports the attempted token count, OpenClaw forwards that observed count into overflow-recovery compaction; if the provider confirms overflow but exposes no parseable count, OpenClaw passes a minimally over-budget synthetic count to compaction engines and diagnostics. If overflow recovery still fails, OpenClaw surfaces explicit guidance and preserves the current session mapping instead of silently rotating to a fresh session id - retry the message, run `/compact`, or run `/new`.
2. **Threshold maintenance**: after a successful turn, when `contextTokens > contextWindow - reserveTokens`, where `contextWindow` is the model's context window and `reserveTokens` is headroom reserved for prompts plus the next model output.
2. **Threshold maintenance**: after a successful turn, when the current context exceeds the model window minus OpenClaw's built-in headroom for prompts and the next model output.
Two additional guards run outside these two triggers:
@@ -232,7 +228,6 @@ Two additional guards run outside these two triggers:
defaults: {
compaction: {
enabled: true,
reserveTokens: 16384,
keepRecentTokens: 20000,
},
},
@@ -240,7 +235,7 @@ Two additional guards run outside these two triggers:
}
```
OpenClaw also enforces a safety floor for embedded runs: if `compaction.reserveTokens` is below `reserveTokensFloor` (default `20000`), OpenClaw bumps it up. Set `agents.defaults.compaction.reserveTokensFloor: 0` to disable the floor. When the active model context window is known, both the floor and the final effective reserve are capped so the reserve cannot consume the whole prompt budget. This keeps small-context models (for example a 16K-token local model) from entering compaction from the first token; without a known context window, configured and current reserve budgets remain uncapped. Why a floor at all: leave enough headroom for multi-turn "housekeeping" (like the memory flush, below) before compaction becomes unavoidable. Implementation: `applyAgentCompactionSettingsFromConfig()` in `src/agents/agent-settings.ts`, called from embedded-runner turn and compaction setup paths.
OpenClaw enforces a built-in reserve for embedded runs and caps it against the active model context window so it cannot consume the whole prompt budget. This keeps small-context local models from entering compaction from the first token while leaving enough headroom for multi-turn housekeeping such as the memory flush.
Manual `/compact` honors an explicit `agents.defaults.compaction.keepRecentTokens` and keeps the runtime's recent-tail cut point. Without an explicit keep budget, manual compaction is a hard checkpoint and rebuilt context starts from the new summary.
@@ -305,7 +300,7 @@ OpenClaw exposes a `session_before_compact` hook in the extension API, but the f
- **Session key wrong?** Start with [/concepts/session](/concepts/session) and confirm the `sessionKey` in `/status`.
- **Store vs transcript mismatch?** Confirm the Gateway host and the store path from `openclaw status`.
- **Compaction spam?** Check the model's context window (too small forces frequent compaction), `reserveTokens` (too high for the model window causes earlier compaction), and tool-result bloat (tune session pruning).
- **Compaction spam?** Check the model's context window (too small forces frequent compaction) and tool-result bloat (tune session pruning).
- **Every prompt seems to overflow on a small local model?** Confirm the provider reports the correct model context window. OpenClaw can cap the effective reserve only when that window is known.
- **Silent turns leaking?** Confirm the reply starts with the exact silent token `NO_REPLY` (case-insensitive) and you are on a build that includes the streaming-suppression fix (`2026.1.10`+).
+1 -7
View File
@@ -89,14 +89,8 @@ Core ACP baseline:
"opencode",
"qwen",
],
maxConcurrentSessions: 8,
stream: {
// Defaults are coalesceIdleMs: 350, maxChunkChars: 1800; shown explicitly here.
coalesceIdleMs: 350,
maxChunkChars: 1800,
},
runtime: {
ttlMinutes: 120,
deliveryMode: "live",
},
},
}
+1 -1
View File
@@ -158,7 +158,7 @@ Quick `/acp` flow from chat:
- `cancel` aborts the active turn when the backend supports cancellation; it does not delete the binding or session metadata.
- `close` ends the ACP session from OpenClaw's point of view and removes the binding. A harness may still keep its own upstream history if it supports resume.
- The acpx plugin cleans up OpenClaw-owned wrapper and adapter process trees after `close`, and reaps stale OpenClaw-owned ACPX orphans during Gateway startup.
- Idle runtime workers are eligible for cleanup after `acp.runtime.ttlMinutes`; stored session metadata remains available for `/acp sessions`.
- Idle runtime workers are eligible for cleanup after the built-in idle period; stored session metadata remains available for `/acp sessions`.
</Accordion>
<Accordion title="Native Codex routing rules">
+11 -17
View File
@@ -115,24 +115,18 @@ curl -s http://127.0.0.1:18791/tabs
### Config reference
| Option | Description | Default |
| -------------------------------- | -------------------------------------------------------------------- | ------------------------------------------------------------------ |
| `browser.enabled` | Enable browser control | `true` |
| `browser.executablePath` | Path to a Chromium-based browser binary (Chrome/Brave/Edge/Chromium) | auto-detected (prefers the OS default browser when Chromium-based) |
| `browser.headless` | Run without GUI | `false` |
| `OPENCLAW_BROWSER_HEADLESS` | Per-process override for local managed browser headless mode | unset |
| `browser.noSandbox` | Add `--no-sandbox` flag (needed for some Linux setups) | `false` |
| `browser.attachOnly` | Do not launch a browser; only attach to an existing one | `false` |
| `browser.cdpPortRangeStart` | Starting local CDP port for auto-assigned profiles | `18800` (derived from the gateway port) |
| `browser.localLaunchTimeoutMs` | Local managed Chrome discovery timeout, up to `120000` | `15000` |
| `browser.localCdpReadyTimeoutMs` | Local managed post-launch CDP readiness timeout, up to `120000` | `8000` |
| Option | Description | Default |
| --------------------------- | -------------------------------------------------------------------- | ------------------------------------------------------------------ |
| `browser.enabled` | Enable browser control | `true` |
| `browser.executablePath` | Path to a Chromium-based browser binary (Chrome/Brave/Edge/Chromium) | auto-detected (prefers the OS default browser when Chromium-based) |
| `browser.headless` | Run without GUI | `false` |
| `OPENCLAW_BROWSER_HEADLESS` | Per-process override for local managed browser headless mode | unset |
| `browser.noSandbox` | Add `--no-sandbox` flag (needed for some Linux setups) | `false` |
| `browser.attachOnly` | Do not launch a browser; only attach to an existing one | `false` |
Both timeout values must be positive integers up to `120000` ms; other values
are rejected at config load. On Raspberry Pi, older VPS hosts, or slow
storage, raise `browser.localLaunchTimeoutMs` when Chrome needs more time to
expose its CDP HTTP endpoint. Raise `browser.localCdpReadyTimeoutMs` when
launch succeeds but `openclaw browser start` still reports `not reachable
after start`.
On Raspberry Pi, older VPS hosts, or slow storage, use a manually launched
browser with `attachOnly` when Chrome needs more time to expose its CDP HTTP
endpoint or become ready than the managed-browser deadline permits.
### Problem: No Chrome tabs found for profile="user"
@@ -202,8 +202,8 @@ Good result:
| empty CDP reply / `other side closed` through a portproxy | Windows listener mismatch or a self-loop; inspect both loopback families and `netsh interface portproxy show all` |
| `Browser attachOnly is enabled and CDP websocket for profile "remote" is not reachable` | the HTTP endpoint answered, but the DevTools WebSocket could not be opened |
| stale viewport / dark-mode / locale / offline overrides after a remote session | run `openclaw browser --browser-profile remote stop` to close the session and release the cached Playwright/CDP connection without restarting the Gateway or the external browser |
| timeout around `remoteCdpTimeoutMs` (default 1500ms) | usually still CDP reachability, or a slow/unreachable remote endpoint |
| `Playwright page enumeration timed out after 3000ms` | the remote CDP connected, but its persistent tab read stalled; the deadline is the larger of `remoteCdpTimeoutMs` and `remoteCdpHandshakeTimeoutMs` |
| timeout during CDP reachability | usually still CDP reachability, or a slow/unreachable remote endpoint |
| `Playwright page enumeration timed out after 3000ms` | the remote CDP connected, but its persistent tab read stalled |
| `No Chrome tabs found for profile="user"` | local Chrome MCP profile selected where no host-local tabs are available |
## Fast triage checklist
+2 -25
View File
@@ -157,16 +157,8 @@ Browser settings live in `~/.openclaw/openclaw.json`.
// allowedHostnames: ["localhost"],
},
// cdpUrl: "http://127.0.0.1:18792", // legacy single-profile override
remoteCdpTimeoutMs: 1500, // remote CDP HTTP timeout (ms)
remoteCdpHandshakeTimeoutMs: 3000, // remote CDP WebSocket handshake timeout (ms)
localLaunchTimeoutMs: 15000, // local managed Chrome discovery timeout (ms)
localCdpReadyTimeoutMs: 8000, // local managed post-launch CDP readiness timeout (ms)
actionTimeoutMs: 60000, // default browser act timeout (ms)
tabCleanup: {
enabled: true, // default: true
idleMinutes: 120, // set 0 to disable idle cleanup
maxTabsPerSession: 8, // set 0 to disable the per-session cap
sweepMinutes: 5,
},
// snapshotDefaults: { mode: "efficient" }, // default snapshot mode when the caller omits one
defaultProfile: "openclaw",
@@ -301,22 +293,13 @@ main model can read the screenshot directly.
- Local `openclaw` profiles auto-assign `cdpPort`/`cdpUrl` from a range starting 9 ports above the control port (default `18800`-`18899`); set those only for
remote CDP profiles or existing-session endpoint attach. `cdpUrl` defaults to
the managed local CDP port when unset.
- `remoteCdpTimeoutMs` applies to remote and `attachOnly` CDP HTTP reachability
checks and tab-opening HTTP requests; `remoteCdpHandshakeTimeoutMs` applies to
their CDP WebSocket handshakes. Persistent remote Playwright tab enumeration
uses the larger of the two as its operation deadline.
- `localLaunchTimeoutMs` is the budget for a locally launched managed Chrome
process to expose its CDP HTTP endpoint. `localCdpReadyTimeoutMs` is the
follow-up budget for CDP websocket readiness after the process is discovered.
Raise these on Raspberry Pi, low-end VPS, or older hardware where Chromium
starts slowly. Values must be positive integers up to `120000` ms; invalid
config values are rejected.
- Remote and `attachOnly` CDP reachability, WebSocket handshakes, and local
managed-Chrome startup use built-in deadlines.
- Repeated managed Chrome launch/readiness failures are circuit-broken per
profile. After several consecutive failures, OpenClaw pauses new launch
attempts briefly instead of spawning Chromium on every browser tool call. Fix
the startup problem, disable the browser if it is not needed, or restart the
Gateway after repair.
- `actionTimeoutMs` is the default budget for browser `act` requests when the caller does not pass `timeoutMs`. The client transport adds a small slack window so long waits can finish instead of timing out at the HTTP boundary.
</Accordion>
@@ -491,8 +474,6 @@ Example:
browser: {
enabled: true,
defaultProfile: "browserless",
remoteCdpTimeoutMs: 2000,
remoteCdpHandshakeTimeoutMs: 4000,
profiles: {
browserless: {
cdpUrl: "wss://production-sfo.browserless.io?token=<BROWSERLESS_API_KEY>",
@@ -585,8 +566,6 @@ proxies.
browser: {
enabled: true,
defaultProfile: "browserbase",
remoteCdpTimeoutMs: 3000,
remoteCdpHandshakeTimeoutMs: 5000,
profiles: {
browserbase: {
cdpUrl: "wss://connect.browserbase.com?apiKey=<BROWSERBASE_API_KEY>",
@@ -619,8 +598,6 @@ WebSocket gateway.
browser: {
enabled: true,
defaultProfile: "notte",
remoteCdpTimeoutMs: 3000,
remoteCdpHandshakeTimeoutMs: 5000,
profiles: {
notte: {
cdpUrl: "wss://us-prod.notte.cc/sessions/connect?token=<NOTTE_API_KEY>",
+10 -52
View File
@@ -1,9 +1,9 @@
---
summary: "How to enable and tune guardrails that detect repetitive tool-call loops"
summary: "How to enable guardrails that detect repetitive tool-call loops"
title: "Tool-loop detection"
read_when:
- A user reports agents getting stuck repeating tool calls
- You need to tune repetitive-call protection
- You need to control repetitive-call protection
- You are editing agent tool/runtime policies
- You hit `compaction_loop_persisted` aborts after a context-overflow retry
---
@@ -13,7 +13,7 @@ both configured under `tools.loopDetection`:
1. **Loop detection** (`enabled`) - disabled by default. Watches the rolling
tool-call history for repeated patterns and unknown-tool retries.
2. **Post-compaction guard** (`postCompactionGuard`) - enabled whenever
2. **Post-compaction guard** - enabled whenever
`enabled` is not explicitly `false`. Arms after every compaction-retry and
aborts the run if the agent repeats the same `(tool, args, result)` triple
within the window.
@@ -31,26 +31,13 @@ Set `tools.loopDetection.enabled: false` to silence both guardrails.
## Configuration block
Global defaults, with every documented field shown:
Global setting:
```json5
{
tools: {
loopDetection: {
enabled: false, // master switch for the rolling-history detectors
historySize: 30,
warningThreshold: 10,
criticalThreshold: 20,
unknownToolThreshold: 10,
globalCircuitBreakerThreshold: 30,
detectors: {
genericRepeat: true,
knownPollNoProgress: true,
pingPong: true,
},
postCompactionGuard: {
windowSize: 3, // armed after compaction-retry; runs unless enabled is explicitly false
},
},
},
}
@@ -67,8 +54,6 @@ Per-agent override (optional, at `agents.list[].tools.loopDetection`):
tools: {
loopDetection: {
enabled: true,
warningThreshold: 8,
criticalThreshold: 16,
},
},
},
@@ -77,24 +62,13 @@ Per-agent override (optional, at `agents.list[].tools.loopDetection`):
}
```
Per-agent settings overlay the global block field by field (including nested
`detectors` and `postCompactionGuard`), so an agent only needs to set the
fields it wants to change.
The per-agent setting overrides the global setting.
### Field behavior
| Field | Default | Effect |
| -------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| `enabled` | `false` | Master switch for the rolling-history detectors. `false` also disables the post-compaction guard. |
| `historySize` | `30` | Number of recent tool calls kept for analysis. |
| `warningThreshold` | `10` | Repeat count before a pattern is classified as warning-only. |
| `criticalThreshold` | `20` | Repeat count for blocking a no-progress loop pattern. Runtime clamps this above `warningThreshold` if misconfigured. |
| `unknownToolThreshold` | `10` | Blocks repeated calls to the same unavailable tool after this many misses. Not gated by `detectors`. |
| `globalCircuitBreakerThreshold` | `30` | Global no-progress breaker across all detectors. Runtime clamps this above `criticalThreshold` if misconfigured. Not gated by `detectors`. |
| `detectors.genericRepeat` | `true` | Warns on repeated same-tool + same-args calls; blocks once those calls also return identical outcomes. |
| `detectors.knownPollNoProgress` | `true` | Detects known no-progress polling patterns (`process` with `action: "poll"`/`"log"`, `command_status`). |
| `detectors.pingPong` | `true` | Detects alternating no-progress ping-pong patterns between two calls. |
| `postCompactionGuard.windowSize` | `3` | Attempts the guard stays armed after compaction, and the count of identical triples that aborts the run. |
| Field | Default | Effect |
| --------- | ------- | ------------------------------------------------------------------------------------------------- |
| `enabled` | `false` | Master switch for the rolling-history detectors. `false` also disables the post-compaction guard. |
For `exec`, no-progress hashing compares stable command outcomes (status,
exit code, timed-out flag, output) and ignores volatile runtime metadata such
@@ -107,19 +81,9 @@ from earlier runs.
## Recommended setup
- For smaller models, set `enabled: true` and leave thresholds at their
defaults. Flagship models rarely need rolling-history detection and can
- For smaller models, set `enabled: true`. Flagship models rarely need rolling-history detection and can
leave the master switch `false` while still benefiting from the
post-compaction guard.
- Keep thresholds ordered `warningThreshold < criticalThreshold <
globalCircuitBreakerThreshold`; the runtime nudges `criticalThreshold` and
`globalCircuitBreakerThreshold` upward if you set them at or below the
threshold they must exceed.
- If false positives occur:
- Raise `warningThreshold` and/or `criticalThreshold`.
- Optionally raise `globalCircuitBreakerThreshold`.
- Disable only the specific detector causing issues (`detectors.<name>: false`).
- Reduce `historySize` for a shorter historical window.
- To disable everything, including the post-compaction guard, set
`tools.loopDetection.enabled: false` explicitly.
@@ -127,8 +91,7 @@ globalCircuitBreakerThreshold`; the runtime nudges `criticalThreshold` and
After a compaction-retry following a context-overflow, the runner arms a
short-window guard on the next few tool calls. If the agent emits the same
`(toolName, argsHash, resultHash)` triple `postCompactionGuard.windowSize`
times within that window, the guard concludes compaction did not break the
`(toolName, argsHash, resultHash)` triple enough times within that window, the guard concludes compaction did not break the
loop and aborts the run with a `compaction_loop_persisted` error.
The guard is gated by the master `tools.loopDetection.enabled` flag with one
@@ -143,16 +106,11 @@ so a no-config user still gets the protection.
loopDetection: {
// master switch; set false to disable the guard along with the rolling detectors
enabled: true,
postCompactionGuard: {
windowSize: 3, // default
},
},
},
}
```
- Lower `windowSize` is stricter (fewer attempts before abort).
- Higher `windowSize` gives the agent more recovery attempts.
- The guard never aborts while results are changing; only byte-identical
results across the window trigger it.
- It only arms in the immediate aftermath of a compaction-retry, not at other
@@ -120,10 +120,7 @@ function withConfiguredActTimeout(
return request;
}
const cfg = browserToolActionDeps.getRuntimeConfig();
const configuredTimeout =
normalizePositiveTimeoutMs(cfg.browser?.actionTimeoutMs) ?? DEFAULT_BROWSER_ACTION_TIMEOUT_MS;
return { ...typedRequest, timeoutMs: configuredTimeout } as BrowserActRequest;
return { ...typedRequest, timeoutMs: DEFAULT_BROWSER_ACTION_TIMEOUT_MS } as BrowserActRequest;
}
function resolveActProxyTimeoutMs(request: BrowserActRequest): number | undefined {
-103
View File
@@ -2361,109 +2361,6 @@ describe("browser tool act compatibility", () => {
});
});
it("applies configured browser action timeout when act timeout is omitted", async () => {
configMocks.loadConfig.mockReturnValue({ browser: { actionTimeoutMs: 45_000 } });
const tool = createBrowserTool();
await tool.execute?.("call-1", {
action: "act",
request: {
kind: "wait",
timeMs: 20_000,
},
});
const request = lastMockCallArg<{ kind?: string; timeMs?: number; timeoutMs?: number }>(
browserActionsMocks.browserAct,
1,
);
const opts = lastMockCallArg<{ profile?: string }>(browserActionsMocks.browserAct, 2);
expect(request).toEqual({ kind: "wait", timeMs: 20_000, timeoutMs: 45_000 });
expect(opts.profile).toBeUndefined();
});
it("does not inject unsupported action timeout for existing-session type actions", async () => {
setResolvedBrowserProfiles({
user: { driver: "existing-session", attachOnly: true, color: "#00AA00" },
});
configMocks.loadConfig.mockReturnValue({ browser: { actionTimeoutMs: 45_000 } });
const tool = createBrowserTool();
await tool.execute?.("call-1", {
action: "act",
profile: "user",
target: "host",
request: {
kind: "type",
ref: "f1e3",
text: "Test Title",
},
});
const request = lastMockCallArg<{ kind?: string; ref?: string; text?: string }>(
browserActionsMocks.browserAct,
1,
);
const opts = lastMockCallArg<{ profile?: string }>(browserActionsMocks.browserAct, 2);
expect(request).toEqual({ kind: "type", ref: "f1e3", text: "Test Title" });
expect(opts.profile).toBe("user");
});
it("injects configured action timeout for existing-session evaluate actions", async () => {
setResolvedBrowserProfiles({
user: { driver: "existing-session", attachOnly: true, color: "#00AA00" },
});
configMocks.loadConfig.mockReturnValue({ browser: { actionTimeoutMs: 45_000 } });
const tool = createBrowserTool();
await tool.execute?.("call-1", {
action: "act",
profile: "user",
target: "host",
request: {
kind: "evaluate",
fn: "() => 1 + 1",
},
});
const request = lastMockCallArg<{ kind?: string; fn?: string; timeoutMs?: number }>(
browserActionsMocks.browserAct,
1,
);
const opts = lastMockCallArg<{ profile?: string }>(browserActionsMocks.browserAct, 2);
expect(request).toEqual({ kind: "evaluate", fn: "() => 1 + 1", timeoutMs: 45_000 });
expect(opts.profile).toBe("user");
});
it("passes configured act timeout through node proxy with transport slack", async () => {
mockSingleBrowserProxyNode();
configMocks.loadConfig.mockReturnValue({
browser: {
actionTimeoutMs: 45_000,
},
gateway: { nodes: { browser: { node: "node-1" } } },
});
const tool = createBrowserTool();
await tool.execute?.("call-1", {
action: "act",
target: "node",
request: { kind: "wait", timeMs: 20_000, text: "ready" },
});
const { options, request } = lastNodeInvokeCall();
expect(options.timeoutMs).toBe(75_000);
expect(request.timeoutMs).toBe(75_000);
expect(request.params?.path).toBe("/act");
expect(request.params?.body).toEqual({
kind: "wait",
timeMs: 20_000,
text: "ready",
timeoutMs: 45_000,
});
expect(request.params?.timeoutMs).toBe(70_000);
});
it("honors string act request timeouts when sizing node proxy calls", async () => {
mockSingleBrowserProxyNode();
const tool = createBrowserTool();
@@ -94,9 +94,7 @@ export async function createBrowserProfileConfig(params: {
? rawDraftBrowser.cdpPortRangeEnd
: undefined;
const useRebasedPortRange =
draft.gateway?.port !== undefined ||
draft.browser?.cdpPortRangeStart !== undefined ||
draftCdpPortRangeEnd !== undefined;
draft.gateway?.port !== undefined || draftCdpPortRangeEnd !== undefined;
const latestResolved = resolveBrowserConfig(
{
...params.resolved,
@@ -2,7 +2,6 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import type { BrowserConfig } from "../config/config.js";
import { resolveUserPath } from "../utils.js";
@@ -179,22 +178,6 @@ describe("browser config", () => {
});
});
it("supports overriding the local CDP auto-allocation range start", () => {
const resolved = resolveBrowserConfig({
cdpPortRangeStart: 19000,
});
const openclaw = resolveProfile(resolved, "openclaw");
expect(resolved.cdpPortRangeStart).toBe(19000);
expect(openclaw?.cdpPort).toBe(19000);
expect(openclaw?.cdpUrl).toBe("http://127.0.0.1:19000");
});
it("rejects cdpPortRangeStart values that overflow the CDP range window", () => {
expect(() => resolveBrowserConfig({ cdpPortRangeStart: 65535 })).toThrow(
/cdpPortRangeStart .* too high/i,
);
});
it("normalizes hex colors", () => {
const resolved = resolveBrowserConfig({
color: "ff4500",
@@ -202,47 +185,6 @@ describe("browser config", () => {
expect(resolved.color).toBe("#FF4500");
});
it("supports custom remote CDP timeouts", () => {
const resolved = resolveBrowserConfig({
remoteCdpTimeoutMs: 2200,
remoteCdpHandshakeTimeoutMs: 5000,
actionTimeoutMs: 45_000,
});
expect(resolved.remoteCdpTimeoutMs).toBe(2200);
expect(resolved.remoteCdpHandshakeTimeoutMs).toBe(5000);
expect(resolved.actionTimeoutMs).toBe(45_000);
});
it("supports custom browser tab cleanup policy", () => {
const resolved = resolveBrowserConfig({
tabCleanup: {
enabled: false,
idleMinutes: 0,
maxTabsPerSession: 0,
sweepMinutes: 15,
},
});
expect(resolved.tabCleanup).toEqual({
enabled: false,
idleMinutes: 0,
maxTabsPerSession: 0,
sweepMinutes: 15,
});
});
it("caps browser tab cleanup timer minutes before converting to milliseconds", () => {
const maxTimerMinutes = Math.floor(MAX_TIMER_TIMEOUT_MS / 60_000);
const resolved = resolveBrowserConfig({
tabCleanup: {
idleMinutes: Number.MAX_SAFE_INTEGER,
sweepMinutes: Number.MAX_SAFE_INTEGER,
},
});
expect(resolved.tabCleanup.idleMinutes).toBe(maxTimerMinutes);
expect(resolved.tabCleanup.sweepMinutes).toBe(maxTimerMinutes);
});
it("expands tilde-prefixed executablePath with the OS home directory", () => {
const resolved = resolveBrowserConfig({
executablePath: " ~/.local/bin/chromium ",
@@ -300,22 +242,6 @@ describe("browser config", () => {
expect(resolved.executablePath).toBe("/opt/~chromium/chrome");
});
it("normalizes invalid browser tab cleanup numbers to defaults", () => {
const resolved = resolveBrowserConfig({
tabCleanup: {
idleMinutes: -1,
maxTabsPerSession: -2,
sweepMinutes: 0,
},
});
expect(resolved.tabCleanup).toEqual({
enabled: true,
idleMinutes: 120,
maxTabsPerSession: 8,
sweepMinutes: 5,
});
});
it("falls back to default color for invalid hex", () => {
const resolved = resolveBrowserConfig({
color: "#GGGGGG",
@@ -558,26 +484,6 @@ describe("browser config", () => {
expect(resolved.localLaunchTimeoutMs).toBe(15_000);
expect(resolved.localCdpReadyTimeoutMs).toBe(8_000);
});
it("accepts custom local startup timeout values", () => {
const resolved = resolveBrowserConfig({
localLaunchTimeoutMs: 45_000,
localCdpReadyTimeoutMs: 30_000,
});
expect(resolved.localLaunchTimeoutMs).toBe(45_000);
expect(resolved.localCdpReadyTimeoutMs).toBe(30_000);
});
it("clamps oversized local startup timeout values", () => {
const resolved = resolveBrowserConfig({
localLaunchTimeoutMs: 999_999,
localCdpReadyTimeoutMs: 999_999,
});
expect(resolved.localLaunchTimeoutMs).toBe(120_000);
expect(resolved.localCdpReadyTimeoutMs).toBe(120_000);
});
});
it("inherits executablePath from global browser config when profile override is not set", () => {
@@ -1193,4 +1099,3 @@ describe("browser config", () => {
});
});
});
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */
+12 -92
View File
@@ -6,7 +6,6 @@
*/
import os from "node:os";
import path from "node:path";
import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime";
import {
normalizeOptionalString,
normalizeOptionalTrimmedStringList,
@@ -130,6 +129,8 @@ export function getOwnBrowserProfile<T>(
}
const DEFAULT_BROWSER_CDP_PORT_RANGE_START = 18800;
const DEFAULT_BROWSER_REMOTE_CDP_TIMEOUT_MS = 1_500;
const DEFAULT_BROWSER_REMOTE_CDP_HANDSHAKE_TIMEOUT_MS = 3_000;
/**
* Default extension relay port offset from the browser control port. Sits just
* below the CDP allocation range (controlPort+9..) so profile port allocation
@@ -138,7 +139,6 @@ const DEFAULT_BROWSER_CDP_PORT_RANGE_START = 18800;
const EXTENSION_RELAY_PORT_OFFSET = 8;
/** Username half of the relay's Basic credential; the password is the derived token. */
const EXTENSION_RELAY_CDP_USER = "openclaw";
const MAX_BROWSER_STARTUP_TIMEOUT_MS = 120_000;
/** Environment variable that overrides managed Chrome headless mode. */
const OPENCLAW_BROWSER_HEADLESS_ENV = "OPENCLAW_BROWSER_HEADLESS";
@@ -180,39 +180,6 @@ function normalizeHexColor(raw: string | undefined): string {
return normalized.toUpperCase();
}
function normalizeTimeoutMs(raw: number | undefined, fallback: number): number {
const value = typeof raw === "number" && Number.isFinite(raw) ? Math.floor(raw) : fallback;
return value < 0 ? fallback : value;
}
function normalizeStartupTimeoutMs(raw: number | undefined, fallback: number): number {
const value = typeof raw === "number" && Number.isFinite(raw) ? Math.floor(raw) : fallback;
if (value <= 0) {
return fallback;
}
return Math.min(value, MAX_BROWSER_STARTUP_TIMEOUT_MS);
}
function normalizeNonNegativeInteger(raw: number | undefined, fallback: number): number {
const value = typeof raw === "number" && Number.isFinite(raw) ? Math.floor(raw) : fallback;
return value < 0 ? fallback : value;
}
function normalizePositiveInteger(raw: number | undefined, fallback: number): number {
const value = typeof raw === "number" && Number.isFinite(raw) ? Math.floor(raw) : fallback;
return value <= 0 ? fallback : value;
}
const MAX_BROWSER_TIMER_MINUTES = Math.floor(MAX_TIMER_TIMEOUT_MS / 60_000);
function normalizeNonNegativeTimerMinutes(raw: number | undefined, fallback: number): number {
return Math.min(normalizeNonNegativeInteger(raw, fallback), MAX_BROWSER_TIMER_MINUTES);
}
function normalizePositiveTimerMinutes(raw: number | undefined, fallback: number): number {
return Math.min(normalizePositiveInteger(raw, fallback), MAX_BROWSER_TIMER_MINUTES);
}
function normalizeExecutablePath(raw: string | undefined): string | undefined {
const value = normalizeOptionalString(raw);
if (!value) {
@@ -269,42 +236,12 @@ function resolveBrowserTabCleanupConfig(
const raw = cfg?.tabCleanup;
return {
enabled: raw?.enabled ?? true,
idleMinutes: normalizeNonNegativeTimerMinutes(
raw?.idleMinutes,
DEFAULT_BROWSER_TAB_CLEANUP_IDLE_MINUTES,
),
maxTabsPerSession: normalizeNonNegativeInteger(
raw?.maxTabsPerSession,
DEFAULT_BROWSER_TAB_CLEANUP_MAX_TABS_PER_SESSION,
),
sweepMinutes: normalizePositiveTimerMinutes(
raw?.sweepMinutes,
DEFAULT_BROWSER_TAB_CLEANUP_SWEEP_MINUTES,
),
idleMinutes: DEFAULT_BROWSER_TAB_CLEANUP_IDLE_MINUTES,
maxTabsPerSession: DEFAULT_BROWSER_TAB_CLEANUP_MAX_TABS_PER_SESSION,
sweepMinutes: DEFAULT_BROWSER_TAB_CLEANUP_SWEEP_MINUTES,
};
}
function resolveCdpPortRangeStart(
rawStart: number | undefined,
fallbackStart: number,
rangeSpan: number,
): number {
const start =
typeof rawStart === "number" && Number.isFinite(rawStart)
? Math.floor(rawStart)
: fallbackStart;
if (start < 1 || start > 65535) {
throw new Error(`browser.cdpPortRangeStart must be between 1 and 65535, got: ${start}`);
}
const maxStart = 65535 - rangeSpan;
if (start > maxStart) {
throw new Error(
`browser.cdpPortRangeStart (${start}) is too high for a ${rangeSpan + 1}-port range; max is ${maxStart}.`,
);
}
return start;
}
const normalizeStringList = normalizeOptionalTrimmedStringList;
function resolveBrowserSsrFPolicy(cfg: BrowserConfig | undefined): SsrFPolicy | undefined {
@@ -445,32 +382,15 @@ export function resolveBrowserConfig(
const gatewayPort = resolveGatewayPort(rootConfig);
const controlPort = deriveDefaultBrowserControlPort(gatewayPort ?? DEFAULT_BROWSER_CONTROL_PORT);
const defaultColor = normalizeHexColor(cfg?.color);
const remoteCdpTimeoutMs = normalizeTimeoutMs(cfg?.remoteCdpTimeoutMs, 1500);
const remoteCdpHandshakeTimeoutMs = normalizeTimeoutMs(
cfg?.remoteCdpHandshakeTimeoutMs,
Math.max(2000, remoteCdpTimeoutMs * 2),
);
const localLaunchTimeoutMs = normalizeStartupTimeoutMs(
cfg?.localLaunchTimeoutMs,
DEFAULT_BROWSER_LOCAL_LAUNCH_TIMEOUT_MS,
);
const localCdpReadyTimeoutMs = normalizeStartupTimeoutMs(
cfg?.localCdpReadyTimeoutMs,
DEFAULT_BROWSER_LOCAL_CDP_READY_TIMEOUT_MS,
);
const actionTimeoutMs = normalizeTimeoutMs(
cfg?.actionTimeoutMs,
DEFAULT_BROWSER_ACTION_TIMEOUT_MS,
);
const remoteCdpTimeoutMs = DEFAULT_BROWSER_REMOTE_CDP_TIMEOUT_MS;
const remoteCdpHandshakeTimeoutMs = DEFAULT_BROWSER_REMOTE_CDP_HANDSHAKE_TIMEOUT_MS;
const localLaunchTimeoutMs = DEFAULT_BROWSER_LOCAL_LAUNCH_TIMEOUT_MS;
const localCdpReadyTimeoutMs = DEFAULT_BROWSER_LOCAL_CDP_READY_TIMEOUT_MS;
const actionTimeoutMs = DEFAULT_BROWSER_ACTION_TIMEOUT_MS;
const derivedCdpRange = deriveDefaultBrowserCdpPortRange(controlPort);
const cdpRangeSpan = derivedCdpRange.end - derivedCdpRange.start;
const cdpPortRangeStart = resolveCdpPortRangeStart(
cfg?.cdpPortRangeStart,
derivedCdpRange.start,
cdpRangeSpan,
);
const cdpPortRangeEnd = cdpPortRangeStart + cdpRangeSpan;
const cdpPortRangeStart = derivedCdpRange.start;
const cdpPortRangeEnd = derivedCdpRange.end;
const rawCdpUrl = (cfg?.cdpUrl ?? "").trim();
let cdpInfo:
@@ -274,18 +274,6 @@ describe("BrowserProfilesService", () => {
expect(writeConfigFile).toHaveBeenCalled();
});
it("allocates from configured cdpPortRangeStart for new local profiles", async () => {
const { result, state } = await createWorkProfileWithConfig({
resolved: resolveBrowserConfig({ cdpPortRangeStart: 19000 }),
browserConfig: { cdpPortRangeStart: 19000, profiles: {} },
});
expect(result.cdpPort).toBe(19001);
expect(result.isRemote).toBe(false);
expect(state.resolved.profiles.work?.cdpPort).toBe(19001);
expect(writeConfigFile).toHaveBeenCalled();
});
it("allocates local ports from the rebased config snapshot", async () => {
const resolved = resolveBrowserConfig({});
const { ctx, state } = createCtx(resolved);
@@ -310,12 +298,11 @@ describe("BrowserProfilesService", () => {
});
it("allocates local ports from the rebased CDP range end", async () => {
const resolved = resolveBrowserConfig({ cdpPortRangeStart: 19000 });
const resolved = resolveBrowserConfig({});
const { ctx, state } = createCtx(resolved);
vi.mocked(getRuntimeConfig)
.mockReturnValueOnce({
browser: {
cdpPortRangeStart: 19000,
profiles: {},
},
} as OpenClawConfig)
@@ -0,0 +1 @@
export { normalizeCompatibilityConfig } from "./src/doctor-contract.js";
@@ -121,7 +121,6 @@ describe("ClickClack account resolution", () => {
replyMode: "agent",
systemPrompt: undefined,
token: "test-token-placeholder",
timeoutSeconds: undefined,
toolsAllow: undefined,
workspace: "wsp_1",
});
@@ -222,7 +221,6 @@ describe("ClickClack account resolution", () => {
replyMode: "model",
systemPrompt: undefined,
token: "token-oversized",
timeoutSeconds: undefined,
toolsAllow: ["web_search"],
workspace: "wsp_1",
});
-1
View File
@@ -174,7 +174,6 @@ export function resolveClickClackAccount(params: {
replyMode: merged.replyMode === "model" ? "model" : "agent",
model: normalizeOptionalString(merged.model),
systemPrompt: normalizeOptionalString(merged.systemPrompt),
timeoutSeconds: merged.timeoutSeconds,
toolsAllow: merged.toolsAllow,
defaultTo: merged.defaultTo?.trim() || "channel:general",
allowFrom: merged.allowFrom ?? ["*"],
@@ -21,7 +21,6 @@ const ClickClackAccountConfigSchema = z
replyMode: z.enum(["agent", "model"]).optional(),
model: z.string().optional(),
systemPrompt: z.string().optional(),
timeoutSeconds: z.number().int().min(1).max(3_600).optional(),
toolsAllow: z.array(z.string()).optional(),
defaultTo: z.string().optional(),
allowFrom: z.array(z.string()).optional(),
@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import { normalizeCompatibilityConfig } from "./doctor-contract.js";
describe("ClickClack doctor contract", () => {
it("strips root and account timeout tuning", () => {
const result = normalizeCompatibilityConfig({
cfg: {
channels: {
clickclack: {
timeoutSeconds: 1,
reconnectMs: 2,
accounts: { work: { timeoutSeconds: 3, reconnectMs: 4 } },
},
},
} as never,
});
expect((result.config.channels as Record<string, unknown>).clickclack).toEqual({
reconnectMs: 2,
accounts: { work: { reconnectMs: 4 } },
});
expect(result.changes).toEqual(["Removed retired ClickClack timeout tuning knobs."]);
});
});
@@ -0,0 +1,49 @@
import type { ChannelDoctorConfigMutation } from "openclaw/plugin-sdk/channel-contract";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { asObjectRecord } from "openclaw/plugin-sdk/runtime-doctor";
function stripTimeoutSeconds(value: unknown): { value: unknown; changed: boolean } {
const record = asObjectRecord(value);
if (!record) {
return { value, changed: false };
}
let changed = false;
const next: Record<string, unknown> = {};
for (const [key, child] of Object.entries(record)) {
if (key === "timeoutSeconds") {
changed = true;
continue;
}
const stripped = stripTimeoutSeconds(child);
changed = changed || stripped.changed;
next[key] = stripped.value;
}
return { value: changed ? next : value, changed };
}
export function normalizeCompatibilityConfig({
cfg,
}: {
cfg: OpenClawConfig;
}): ChannelDoctorConfigMutation {
const rawEntry = asObjectRecord(
(cfg.channels as Record<string, unknown> | undefined)?.clickclack,
);
if (!rawEntry) {
return { config: cfg, changes: [] };
}
const stripped = stripTimeoutSeconds(rawEntry);
if (!stripped.changed) {
return { config: cfg, changes: [] };
}
return {
config: {
...cfg,
channels: {
...cfg.channels,
clickclack: stripped.value,
} as OpenClawConfig["channels"],
},
changes: ["Removed retired ClickClack timeout tuning knobs."],
};
}
-2
View File
@@ -16,7 +16,6 @@ export type ClickClackAccountConfig = {
replyMode?: "agent" | "model";
model?: string;
systemPrompt?: string;
timeoutSeconds?: number;
toolsAllow?: string[];
defaultTo?: string;
allowFrom?: string[];
@@ -54,7 +53,6 @@ export type ResolvedClickClackAccount = {
replyMode: "agent" | "model";
model?: string;
systemPrompt?: string;
timeoutSeconds?: number;
toolsAllow?: string[];
defaultTo: string;
allowFrom: string[];
@@ -5,7 +5,6 @@ import {
fitCodexProjectedContextForTurnStart,
projectContextEngineAssemblyForCodex,
resolveCodexContextEngineProjectionMaxChars,
resolveCodexContextEngineProjectionReserveTokens,
} from "./context-engine-projection.js";
const CODEX_TURN_START_TEXT_INPUT_MAX_CHARS = 1 << 20;
@@ -393,31 +392,6 @@ describe("projectContextEngineAssemblyForCodex", () => {
);
});
it("maps OpenClaw compaction reserve config onto Codex projection reserves", () => {
expect(
resolveCodexContextEngineProjectionReserveTokens({
config: { agents: { defaults: { compaction: { reserveTokens: 12_000 } } } },
}),
).toBe(20_000);
expect(
resolveCodexContextEngineProjectionReserveTokens({
config: {
agents: { defaults: { compaction: { reserveTokens: 12_000, reserveTokensFloor: 0 } } },
},
}),
).toBe(12_000);
expect(
resolveCodexContextEngineProjectionReserveTokens({
config: { agents: { defaults: { compaction: { reserveTokens: 48_000 } } } },
}),
).toBe(48_000);
expect(
resolveCodexContextEngineProjectionReserveTokens({
config: { agents: { defaults: { compaction: { reserveTokensFloor: 0 } } } },
}),
).toBe(0);
});
it("applies configured reserve tokens to the scaled projection cap", () => {
expect(
resolveCodexContextEngineProjectionMaxChars({
@@ -98,24 +98,9 @@ export function resolveCodexContextEngineProjectionMaxChars(params: {
return normalizeRenderedContextMaxChars(scaledChars);
}
/** Reads Codex projection reserve tokens from compaction config. */
export function resolveCodexContextEngineProjectionReserveTokens(params: {
config?: unknown;
}): number | undefined {
const compaction = asRecord(asRecord(asRecord(params.config)?.agents)?.defaults)?.compaction;
const configuredReserveTokens = toNonNegativeInt(asRecord(compaction)?.reserveTokens);
const configuredReserveTokensFloor = toNonNegativeInt(asRecord(compaction)?.reserveTokensFloor);
if (configuredReserveTokens !== undefined) {
return Math.max(
configuredReserveTokens,
configuredReserveTokensFloor ?? DEFAULT_CODEX_PROJECTION_RESERVE_TOKENS,
);
}
if (configuredReserveTokensFloor !== undefined) {
return configuredReserveTokensFloor;
}
return undefined;
/** Returns the fixed reserve used for Codex context-engine projections. */
export function resolveCodexContextEngineProjectionReserveTokens(): number {
return DEFAULT_CODEX_PROJECTION_RESERVE_TOKENS;
}
/** Fits projected context prompts under Codex app-server turn/start text limits. */
@@ -233,17 +218,6 @@ function resolveProjectionPromptBudgetTokens(params: {
return Math.max(1, params.contextTokenBudget - effectiveReserveTokens);
}
function asRecord(value: unknown): Record<string, unknown> | undefined {
return value && typeof value === "object" ? (value as Record<string, unknown>) : undefined;
}
function toNonNegativeInt(value: unknown): number | undefined {
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
return undefined;
}
return Math.floor(value);
}
function dropDuplicateTrailingPrompt(messages: AgentMessage[], prompt: string): AgentMessage[] {
if (!prompt) {
return messages;
@@ -167,7 +167,7 @@ export async function prepareCodexAttemptContext(
};
const codexContextProjectionMaxChars = resolveCodexContextEngineProjectionMaxChars({
contextTokenBudget: effectiveContextTokenBudget,
reserveTokens: resolveCodexContextEngineProjectionReserveTokens({ config: params.config }),
reserveTokens: resolveCodexContextEngineProjectionReserveTokens(),
});
return {
runtime,
@@ -679,37 +679,6 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
await run;
});
it("uses configured compaction reserve when sizing Codex context-engine projections", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const longContext = `configured reserve context start ${"x".repeat(30_000)} CONFIG_END`;
const contextEngine = createContextEngine({
assemble: vi.fn(async () => ({
messages: [assistantMessage(longContext, 10)],
estimatedTokens: 10_000,
systemPromptAddition: "context-engine system",
})),
});
const harness = createStartedThreadHarness();
const params = createParams(sessionFile, workspaceDir);
params.contextEngine = contextEngine;
params.contextTokenBudget = 80_000;
params.config = {
agents: { defaults: { compaction: { reserveTokens: 60_000, reserveTokensFloor: 0 } } },
} as EmbeddedRunAttemptParams["config"];
const run = runCodexAppServerAttempt(params);
await harness.waitForMethod("turn/start");
const inputText = getRequestInputText(harness);
expect(inputText).toContain("configured reserve context start");
expect(inputText).toContain("[truncated ");
expect(inputText).not.toContain("CONFIG_END");
await harness.completeTurn();
await run;
});
it("projects thread-bootstrap context only once for a matching context-engine epoch", async () => {
const info = vi.spyOn(embeddedAgentLog, "info").mockImplementation(() => undefined);
const sessionFile = path.join(tempDir, "session.jsonl");
@@ -260,13 +260,6 @@ function readCodexAppServerRolloutTokenSnapshotLine(
}
}
function toNonNegativeInt(value: unknown): number | undefined {
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
return undefined;
}
return Math.floor(value);
}
function readCompactionConfig(config: EmbeddedRunAttemptParams["config"] | undefined) {
return isJsonObject(config?.agents?.defaults?.compaction)
? config.agents.defaults.compaction
@@ -274,18 +267,9 @@ function readCompactionConfig(config: EmbeddedRunAttemptParams["config"] | undef
}
function resolveCodexAppServerNativeThreadReserveTokens(
config: EmbeddedRunAttemptParams["config"] | undefined,
_config: EmbeddedRunAttemptParams["config"] | undefined,
): number {
const compaction = readCompactionConfig(config);
const reserveTokens = toNonNegativeInt(compaction?.reserveTokens);
const reserveTokensFloor = toNonNegativeInt(compaction?.reserveTokensFloor);
if (reserveTokens !== undefined) {
return Math.max(
reserveTokens,
reserveTokensFloor ?? CODEX_APP_SERVER_NATIVE_THREAD_DEFAULT_RESERVE_TOKENS,
);
}
return reserveTokensFloor ?? CODEX_APP_SERVER_NATIVE_THREAD_DEFAULT_RESERVE_TOKENS;
return CODEX_APP_SERVER_NATIVE_THREAD_DEFAULT_RESERVE_TOKENS;
}
function resolveCodexAppServerNativeThreadTokenFuse(params: {
@@ -42,9 +42,7 @@ export function buildContextEngineBinding(
contextTokenBudget: params.contextTokenBudget,
projectionMaxChars: resolveCodexContextEngineProjectionMaxChars({
contextTokenBudget: params.contextTokenBudget,
reserveTokens: resolveCodexContextEngineProjectionReserveTokens({
config: params.config,
}),
reserveTokens: resolveCodexContextEngineProjectionReserveTokens(),
}),
}),
projection: projection ? buildContextEngineProjectionBinding(projection) : undefined,
-23
View File
@@ -343,29 +343,6 @@ describe("discordPlugin outbound", () => {
expect(resolveReplyToMode({ cfg, accountId: "default" })).toBe("all");
});
it("inherits Discord gateway READY timeout settings per account", () => {
const cfg = {
channels: {
discord: {
token: "discord-token",
gatewayReadyTimeoutMs: 90_000,
gatewayRuntimeReadyTimeoutMs: 120_000,
accounts: {
work: {
token: "discord-token-work",
gatewayReadyTimeoutMs: 60_000,
},
},
},
},
} as OpenClawConfig;
expect(resolveAccount(cfg).config.gatewayReadyTimeoutMs).toBe(90_000);
expect(resolveAccount(cfg).config.gatewayRuntimeReadyTimeoutMs).toBe(120_000);
expect(resolveAccount(cfg, "work").config.gatewayReadyTimeoutMs).toBe(60_000);
expect(resolveAccount(cfg, "work").config.gatewayRuntimeReadyTimeoutMs).toBe(120_000);
});
it("forwards full media send context to sendMessageDiscord", async () => {
const sendMessageDiscord = vi.fn(async () => ({ messageId: "m1" }));
const mediaReadFile = vi.fn(async () => Buffer.from("media"));
-33
View File
@@ -19,7 +19,6 @@ describe("createDiscordClient", () => {
channels: {
discord: {
token: "discord-token",
retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 0, jitter: 0 },
},
},
},
@@ -59,38 +58,6 @@ describe("createDiscordRestClient", () => {
expect(result.account.accountId).toBe("default");
});
it("keeps account retry config when explicit token is provided", () => {
const cfg = {
channels: {
discord: {
accounts: {
ops: {
token: {
source: "exec",
provider: "vault",
id: "discord/ops-token",
},
retry: {
attempts: 7,
},
},
},
},
},
} as OpenClawConfig;
const result = createDiscordRestClient({
cfg,
accountId: "ops",
token: "Bot explicit-account-token",
rest: fakeRest,
});
expect(result.token).toBe("explicit-account-token");
expect(result.account.accountId).toBe("ops");
expect(result.account.config.retry).toEqual({ attempts: 7 });
});
it("applies a caller timeout to a dedicated REST client", () => {
const cfg = { channels: { discord: { token: "discord-token" } } } as OpenClawConfig;
-1
View File
@@ -147,7 +147,6 @@ export function createDiscordClient(opts: DiscordClientOpts): {
const { token, rest, account } = createDiscordRestClient(opts);
const request = createDiscordRetryRunner({
retry: opts.retry,
configRetry: account.config.retry,
verbose: opts.verbose,
isGatewayDisconnected: () => {
const gateway = getGateway(account.accountId);
-25
View File
@@ -65,7 +65,6 @@ export const discordChannelConfigUiHints = {
...createChannelConfigUiHints({
channelLabel: "Discord",
progress: { includeCommentary: true },
retry: true,
}),
maxLinesPerMessage: {
label: "Discord Max Lines Per Message",
@@ -79,18 +78,6 @@ export const discordChannelConfigUiHints = {
label: "Discord Thread Parent Inheritance",
help: "If true, Discord thread sessions inherit the parent channel transcript (default: false).",
},
"eventQueue.listenerTimeout": {
label: "Discord EventQueue Listener Timeout (ms)",
help: "Canonical Discord listener timeout control in ms for gateway normalization/enqueue handlers. Default is 120000 in OpenClaw; set per account via channels.discord.accounts.<id>.eventQueue.listenerTimeout.",
},
"eventQueue.maxQueueSize": {
label: "Discord EventQueue Max Queue Size",
help: "Optional Discord EventQueue capacity override (max queued events before backpressure). Set per account via channels.discord.accounts.<id>.eventQueue.maxQueueSize.",
},
"eventQueue.maxConcurrency": {
label: "Discord EventQueue Max Concurrency",
help: "Optional Discord EventQueue concurrency override (max concurrent handler executions). Set per account via channels.discord.accounts.<id>.eventQueue.maxConcurrency.",
},
"threadBindings.enabled": {
label: "Discord Thread Binding Enabled",
help: "Enable Discord thread binding features (/focus, bound-thread routing/delivery, and thread-bound subagent sessions). Overrides session.threadBindings.enabled when set.",
@@ -135,18 +122,6 @@ export const discordChannelConfigUiHints = {
label: "Discord Voice States Intent",
help: "Enable the Guild Voice States intent. Defaults to the effective Discord voice setting; set true only for Discord voice channel conversations.",
},
gatewayInfoTimeoutMs: {
label: "Discord Gateway Metadata Timeout (ms)",
help: "Timeout for Discord /gateway/bot metadata lookup before falling back to the default gateway URL. Default is 30000; OPENCLAW_DISCORD_GATEWAY_INFO_TIMEOUT_MS can override when config is unset.",
},
gatewayReadyTimeoutMs: {
label: "Discord Gateway READY Timeout (ms)",
help: "Startup wait for the Discord gateway READY event before restarting the socket. Default is 15000; OPENCLAW_DISCORD_READY_TIMEOUT_MS can override when config is unset.",
},
gatewayRuntimeReadyTimeoutMs: {
label: "Discord Gateway Runtime READY Timeout (ms)",
help: "Runtime reconnect wait for the Discord gateway READY event before force-stopping the lifecycle. Default is 30000; OPENCLAW_DISCORD_RUNTIME_READY_TIMEOUT_MS can override when config is unset.",
},
"voice.enabled": {
label: "Discord Voice Enabled",
help: "Enable Discord voice channel conversations. Text-only Discord configs leave voice off by default; set true to enable /vc commands and the Guild Voice States intent.",
+55
View File
@@ -11,8 +11,57 @@ import {
import { asObjectRecord, defineChannelAliasMigration } from "openclaw/plugin-sdk/runtime-doctor";
const LEGACY_TTS_PROVIDER_KEYS = ["openai", "elevenlabs", "microsoft", "edge"] as const;
const RETIRED_TUNING_KEYS = new Set([
"gatewayInfoTimeoutMs",
"gatewayReadyTimeoutMs",
"gatewayRuntimeReadyTimeoutMs",
"eventQueue",
"retry",
]);
type AgentBindingConfig = NonNullable<OpenClawConfig["bindings"]>[number];
function stripRetiredTuningKnobs(value: unknown): { value: unknown; changed: boolean } {
const record = asObjectRecord(value);
if (!record) {
return { value, changed: false };
}
const next = { ...record };
let changed = false;
for (const key of RETIRED_TUNING_KEYS) {
if (Object.hasOwn(next, key)) {
delete next[key];
changed = true;
}
}
return { value: changed ? next : record, changed };
}
function stripRetiredDiscordTuningKnobs(value: unknown): {
value: Record<string, unknown>;
changed: boolean;
} {
const root = asObjectRecord(value) ?? {};
const rootResult = stripRetiredTuningKnobs(root);
const nextRoot = rootResult.value as Record<string, unknown>;
const accounts = asObjectRecord(nextRoot.accounts);
if (!accounts) {
return { value: nextRoot, changed: rootResult.changed };
}
let accountsChanged = false;
const nextAccounts = { ...accounts };
for (const [accountId, account] of Object.entries(accounts)) {
const accountResult = stripRetiredTuningKnobs(account);
if (accountResult.changed) {
nextAccounts[accountId] = accountResult.value;
accountsChanged = true;
}
}
return {
value: accountsChanged ? { ...nextRoot, accounts: nextAccounts } : nextRoot,
changed: rootResult.changed || accountsChanged,
};
}
const streamingAliasMigration = defineChannelAliasMigration({
channelId: "discord",
streaming: {
@@ -521,6 +570,12 @@ export function normalizeCompatibilityConfig({
}
let updated = rawEntry;
let changed = aliases.config !== cfg;
const tuningKnobs = stripRetiredDiscordTuningKnobs(updated);
if (tuningKnobs.changed) {
updated = tuningKnobs.value as Record<string, unknown>;
changes.push("Removed retired Discord tuning knobs.");
changed = true;
}
const guildAliases = normalizeDiscordGuildChannelAllowAliases({
entry: updated,
+37
View File
@@ -21,6 +21,43 @@ function getDiscordCompatibilityNormalizer(): NonNullable<
}
describe("discord doctor", () => {
it("strips retired gateway, queue, and retry tuning at root and account scope", () => {
const normalize = getDiscordCompatibilityNormalizer();
const result = normalize({
cfg: {
channels: {
discord: {
gatewayInfoTimeoutMs: 1,
gatewayReadyTimeoutMs: 2,
gatewayRuntimeReadyTimeoutMs: 3,
eventQueue: { listenerTimeout: 4 },
retry: { attempts: 5 },
voice: {
realtime: {
providers: {
custom: { retry: { attempts: 9 }, eventQueue: { maxConcurrency: 2 } },
},
},
},
accounts: {
work: { eventQueue: { maxConcurrency: 6 }, retry: { attempts: 7 } },
},
},
},
} as never,
});
expect(result.config.channels?.discord).toEqual({
voice: {
realtime: {
providers: { custom: { retry: { attempts: 9 }, eventQueue: { maxConcurrency: 2 } } },
},
},
accounts: { work: {} },
});
expect(result.changes).toContain("Removed retired Discord tuning knobs.");
});
it("normalizes legacy discord streaming aliases for runtime config", () => {
const normalize = getDiscordCompatibilityNormalizer();
@@ -54,7 +54,6 @@ describe("durable Discord delivery", () => {
channels: {
discord: {
token: "test-token",
retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 0, jitter: 0 },
},
},
},
@@ -67,17 +67,14 @@ function createStalledLookup() {
}
describe("Discord gateway metadata", () => {
it("resolves gateway info timeouts from strict integer config and env values", () => {
expect(resolveDiscordGatewayInfoTimeoutMs({ configuredTimeoutMs: 45_000 })).toBe(45_000);
it("resolves gateway info timeouts from strict integer env values", () => {
expect(
resolveDiscordGatewayInfoTimeoutMs({
env: { OPENCLAW_DISCORD_GATEWAY_INFO_TIMEOUT_MS: "90000" },
}),
).toBe(90_000);
expect(resolveDiscordGatewayInfoTimeoutMs({ configuredTimeoutMs: 150_000 })).toBe(120_000);
expect(
resolveDiscordGatewayInfoTimeoutMs({
configuredTimeoutMs: 1.5,
env: { OPENCLAW_DISCORD_GATEWAY_INFO_TIMEOUT_MS: "0x1000" },
}),
).toBe(30_000);
@@ -82,12 +82,8 @@ function normalizeGatewayInfoTimeoutMs(value: unknown): number | undefined {
return Math.min(numeric, MAX_DISCORD_GATEWAY_INFO_TIMEOUT_MS);
}
export function resolveDiscordGatewayInfoTimeoutMs(params?: {
configuredTimeoutMs?: number;
env?: NodeJS.ProcessEnv;
}): number {
export function resolveDiscordGatewayInfoTimeoutMs(params?: { env?: NodeJS.ProcessEnv }): number {
return (
normalizeGatewayInfoTimeoutMs(params?.configuredTimeoutMs) ??
normalizeGatewayInfoTimeoutMs(params?.env?.[DISCORD_GATEWAY_INFO_TIMEOUT_ENV]) ??
DEFAULT_DISCORD_GATEWAY_INFO_TIMEOUT_MS
);
@@ -139,8 +139,7 @@ describe("createDiscordGatewayPlugin", () => {
expect(intents & GatewayIntents.GuildMembers).toBe(GatewayIntents.GuildMembers);
});
it("resolves gateway metadata timeout from config, env, then default", () => {
expect(resolveDiscordGatewayInfoTimeoutMs({ configuredTimeoutMs: 45_000 })).toBe(45_000);
it("resolves gateway metadata timeout from env, then default", () => {
expect(
resolveDiscordGatewayInfoTimeoutMs({
env: { OPENCLAW_DISCORD_GATEWAY_INFO_TIMEOUT_MS: "25000" },
@@ -249,22 +248,6 @@ describe("createDiscordGatewayPlugin", () => {
});
});
it("keeps OpenClaw metadata timeout out of gateway options", () => {
const plugin = createDiscordGatewayPlugin({
discordConfig: { gatewayInfoTimeoutMs: 5_000 },
runtime: {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
},
});
expect(
(plugin as unknown as { options?: { gatewayInfoTimeoutMs?: number } }).options
?.gatewayInfoTimeoutMs,
).toBeUndefined();
});
it("emits transport activity for current gateway socket messages", () => {
const socket = new EventEmitter() as EventEmitter & { binaryType?: string };
const dateNowSpy = vi.spyOn(Date, "now").mockReturnValue(1_700_000_000_000);
@@ -389,7 +389,6 @@ export function createDiscordGatewayPlugin(params: {
const proxy = resolveEffectiveDebugProxyUrl(params.discordConfig?.proxy);
const debugProxySettings = resolveDebugProxySettings();
const gatewayInfoTimeoutMs = resolveDiscordGatewayInfoTimeoutMs({
configuredTimeoutMs: params.discordConfig?.gatewayInfoTimeoutMs,
env: process.env,
});
let fetchImpl = createDiscordGatewayMetadataFetch(debugProxySettings.enabled);
@@ -94,10 +94,7 @@ export function createDiscordMessageReactionRuntime(params: {
messageId: message.id,
reactionContext: ackReactionContext,
});
const statusReactionTiming = {
...DEFAULT_TIMING,
...cfg.messages?.statusReactions?.timing,
};
const statusReactionTiming = DEFAULT_TIMING;
let statusReactionTarget = `${messageChannelId}/${message.id}`;
let statusReactionsActive = statusReactionsEnabled;
let statusReactions: StatusReactionController = createStatusReactionController({
@@ -128,8 +128,7 @@ async function processDiscordMessageInner(
});
const sourceRepliesAreToolOnly = sourceReplyDeliveryMode === "message_tool_only";
const configuredTypingMode = cfg.session?.typingMode ?? cfg.agents?.defaults?.typingMode;
const configuredTypingInterval =
cfg.agents?.defaults?.typingIntervalSeconds ?? cfg.session?.typingIntervalSeconds;
const configuredTypingInterval = cfg.agents?.defaults?.typingIntervalSeconds;
const shouldDisableCoreTypingKeepalive =
sourceRepliesAreToolOnly &&
configuredTypingMode === undefined &&
@@ -272,35 +272,6 @@ describe("runDiscordGatewayLifecycle", () => {
);
});
it("does not treat a missing gateway handle as ready", async () => {
vi.useFakeTimers();
try {
const { lifecycleParams, threadStop, statusSink, gatewaySupervisor } = createLifecycleHarness(
{
gateway: null,
},
);
lifecycleParams.gatewayReadyTimeoutMs = 5_000;
const lifecyclePromise = runDiscordGatewayLifecycle(lifecycleParams);
lifecyclePromise.catch(() => {});
await vi.advanceTimersByTimeAsync(0);
await vi.advanceTimersByTimeAsync(5_500);
await expect(lifecyclePromise).rejects.toThrow(
"discord gateway did not reach READY within 5000ms",
);
expect(statusPatches(statusSink).every((patch) => patch.connected !== true)).toBe(true);
expectLifecycleCleanup({
threadStop,
waitCalls: 0,
gatewaySupervisor,
});
} finally {
vi.useRealTimers();
}
});
it("records throttled gateway socket activity as transport liveness", async () => {
const { emitter, gateway } = createGatewayHarness();
gateway.isConnected = true;
@@ -700,45 +671,6 @@ describe("runDiscordGatewayLifecycle", () => {
vi.useRealTimers();
}
});
it("force-stops when a runtime reconnect opens but never becomes ready", async () => {
vi.useFakeTimers();
try {
const { emitter, gateway } = createGatewayHarness();
gateway.isConnected = true;
getDiscordGatewayEmitterMock.mockReturnValueOnce(emitter);
waitForDiscordGatewayStopMock.mockImplementationOnce(
(params: WaitForDiscordGatewayStopParams) =>
new Promise<void>((_resolve, reject) => {
params.registerForceStop?.((err) =>
reject(toLintErrorObject(err, "Non-Error rejection")),
);
gateway.isConnected = false;
emitter.emit("debug", "Gateway websocket opened");
}),
);
const { lifecycleParams, runtimeError, statusSink } = createLifecycleHarness({ gateway });
lifecycleParams.gatewayRuntimeReadyTimeoutMs = 5_000;
const lifecyclePromise = runDiscordGatewayLifecycle(lifecycleParams);
lifecyclePromise.catch(() => {});
await vi.advanceTimersByTimeAsync(5_500);
await expect(lifecyclePromise).rejects.toThrow(
"discord gateway opened but did not reach READY within 5000ms",
);
expectMockMessageContains(runtimeError, "did not reach READY within 5000ms");
expectStatusPatch(
statusSink,
(patch) =>
patch.connected === false &&
patch.lastDisconnect !== null &&
patch.lastDisconnect?.error === "runtime-not-ready",
);
} finally {
vi.useRealTimers();
}
});
});
describe("waitForGatewayReady", () => {
@@ -786,17 +718,3 @@ describe("waitForGatewayReady", () => {
}
});
});
function toLintErrorObject(value: unknown, fallbackMessage: string): Error {
if (value instanceof Error) {
return value;
}
if (typeof value === "string") {
return new Error(value);
}
const error = new Error(fallbackMessage, { cause: value });
if ((typeof value === "object" && value !== null) || typeof value === "function") {
Object.assign(error, value);
}
return error;
}
@@ -43,23 +43,15 @@ function normalizeGatewayReadyTimeoutMs(value: unknown): number | undefined {
return Math.min(numeric, MAX_DISCORD_GATEWAY_READY_TIMEOUT_MS);
}
function resolveDiscordGatewayReadyTimeoutMs(params?: {
configuredTimeoutMs?: number;
env?: NodeJS.ProcessEnv;
}): number {
function resolveDiscordGatewayReadyTimeoutMs(params?: { env?: NodeJS.ProcessEnv }): number {
return (
normalizeGatewayReadyTimeoutMs(params?.configuredTimeoutMs) ??
normalizeGatewayReadyTimeoutMs(params?.env?.[DISCORD_GATEWAY_READY_TIMEOUT_ENV]) ??
DEFAULT_DISCORD_GATEWAY_READY_TIMEOUT_MS
);
}
function resolveDiscordGatewayRuntimeReadyTimeoutMs(params?: {
configuredTimeoutMs?: number;
env?: NodeJS.ProcessEnv;
}): number {
function resolveDiscordGatewayRuntimeReadyTimeoutMs(params?: { env?: NodeJS.ProcessEnv }): number {
return (
normalizeGatewayReadyTimeoutMs(params?.configuredTimeoutMs) ??
normalizeGatewayReadyTimeoutMs(params?.env?.[DISCORD_GATEWAY_RUNTIME_READY_TIMEOUT_ENV]) ??
DEFAULT_DISCORD_GATEWAY_RUNTIME_READY_TIMEOUT_MS
);
@@ -421,8 +413,6 @@ export async function runDiscordGatewayLifecycle(params: {
threadBindings: { stop: () => void };
gatewaySupervisor: DiscordGatewaySupervisor;
statusSink?: DiscordMonitorStatusSink;
gatewayReadyTimeoutMs?: number;
gatewayRuntimeReadyTimeoutMs?: number;
}) {
const gateway = params.gateway;
const gatewayReadyAtLifecycleStart = gateway?.isConnected === true;
@@ -440,11 +430,9 @@ export async function runDiscordGatewayLifecycle(params: {
params.statusSink?.(patch);
};
const gatewayReadyTimeoutMs = resolveDiscordGatewayReadyTimeoutMs({
configuredTimeoutMs: params.gatewayReadyTimeoutMs,
env: process.env,
});
const gatewayRuntimeReadyTimeoutMs = resolveDiscordGatewayRuntimeReadyTimeoutMs({
configuredTimeoutMs: params.gatewayRuntimeReadyTimeoutMs,
env: process.env,
});
const statusObserver = createGatewayStatusObserver({
@@ -737,26 +737,6 @@ describe("createDiscordGatewayPlugin", () => {
);
});
it("uses configured gateway metadata timeout before falling back", async () => {
vi.useFakeTimers();
const runtime = createRuntime();
globalFetchMock.mockImplementation(() => new Promise(() => {}));
const plugin = createDiscordGatewayPlugin({
discordConfig: { gatewayInfoTimeoutMs: 5_000 },
runtime,
});
const registerPromise = registerGatewayClient(plugin);
await vi.advanceTimersByTimeAsync(4_999);
expect(baseRegisterClientSpy).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1);
await registerPromise;
expect((plugin as unknown as { gatewayInfo?: { url?: string } }).gatewayInfo?.url).toBe(
"wss://gateway.discord.gg/",
);
});
it("uses env gateway metadata timeout when config is unset", async () => {
vi.useFakeTimers();
vi.stubEnv("OPENCLAW_DISCORD_GATEWAY_INFO_TIMEOUT_MS", "6000");
@@ -96,12 +96,7 @@ export async function createDiscordMonitorClient(params: {
components: BaseMessageInteractiveComponent[];
modals: Modal[];
voiceEnabled: boolean;
discordConfig: Parameters<typeof resolveDiscordPresenceUpdate>[0] & {
eventQueue?: Pick<
DiscordEventQueueOptions,
"listenerTimeout" | "maxQueueSize" | "maxConcurrency"
>;
};
discordConfig: Parameters<typeof resolveDiscordPresenceUpdate>[0];
runtime: RuntimeEnv;
commandDeployHashStore?: DiscordCommandDeployHashStore;
createClient: CreateClientFn;
@@ -128,7 +123,6 @@ export async function createDiscordMonitorClient(params: {
const eventQueueOpts = {
listenerTimeout: 120_000,
slowListenerThreshold: 30_000,
...params.discordConfig.eventQueue,
} satisfies DiscordEventQueueOptions;
const readyListener = createDiscordStatusReadyListener({
discordConfig: params.discordConfig,
@@ -210,20 +210,6 @@ describe("monitorDiscordProvider", () => {
return reconcileParams.healthProbe;
};
const getMonitorLifecycleParams = (): {
gatewayReadyTimeoutMs?: number;
gatewayRuntimeReadyTimeoutMs?: number;
} => {
expect(monitorLifecycleMock).toHaveBeenCalledTimes(1);
const params = firstMockArg(monitorLifecycleMock, "Discord lifecycle monitor") as
| { gatewayReadyTimeoutMs?: number; gatewayRuntimeReadyTimeoutMs?: number }
| undefined;
if (!params) {
throw new Error("expected lifecycle monitor params");
}
return params;
};
beforeAll(async () => {
vi.doMock("openclaw/plugin-sdk/plugin-runtime", () => ({
getPluginCommandSpecs: getPluginCommandSpecsMock,
@@ -442,30 +428,6 @@ describe("monitorDiscordProvider", () => {
expect(reconcileAcpThreadBindingsOnStartupMock).toHaveBeenCalledTimes(1);
});
it("passes configured gateway READY timeouts to the lifecycle monitor", async () => {
resolveDiscordAccountMock.mockReturnValueOnce({
accountId: "default",
token: "cfg-token",
config: {
commands: { native: true, nativeSkills: false },
voice: { enabled: false },
agentComponents: { enabled: false },
execApprovals: { enabled: false },
gatewayReadyTimeoutMs: 90_000,
gatewayRuntimeReadyTimeoutMs: 120_000,
},
});
await monitorDiscordProvider({
config: baseConfig(),
runtime: baseRuntime(),
});
const lifecycleParams = getMonitorLifecycleParams();
expect(lifecycleParams.gatewayReadyTimeoutMs).toBe(90_000);
expect(lifecycleParams.gatewayRuntimeReadyTimeoutMs).toBe(120_000);
});
it("does not load the Discord voice runtime when voice is disabled", async () => {
await monitorDiscordProvider({
config: baseConfig(),
@@ -504,8 +504,6 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
voiceManagerRef,
threadBindings,
gatewaySupervisor,
gatewayReadyTimeoutMs: account.config.gatewayReadyTimeoutMs,
gatewayRuntimeReadyTimeoutMs: account.config.gatewayRuntimeReadyTimeoutMs,
});
} finally {
await cleanupDiscordProviderStartup({
@@ -181,7 +181,6 @@ describe("discordOutbound", () => {
channels: {
discord: {
token: "test-token",
retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 0, jitter: 0 },
},
},
},
+1 -5
View File
@@ -130,14 +130,10 @@ function isRetryableDiscordGatewayTransportError(err: unknown): boolean {
export function createDiscordRetryRunner(params: {
retry?: RetryConfig;
configRetry?: RetryConfig;
verbose?: boolean;
isGatewayDisconnected?: () => boolean;
}): DiscordRetryRunner {
const retryConfig = resolveRetryConfig(DISCORD_RETRY_DEFAULTS, {
...params.configRetry,
...params.retry,
});
const retryConfig = resolveRetryConfig(DISCORD_RETRY_DEFAULTS, params.retry);
// Extend only the per-request runner. A delivery may contain several REST
// writes, so replaying its outer adapter can duplicate already-sent chunks.
const attempts =
-2
View File
@@ -264,7 +264,6 @@ describe("buildMemoryFlushPlan", () => {
agents: {
defaults: {
compaction: {
reserveTokensFloor: Number.NaN,
memoryFlush: {
softThresholdTokens: -100,
},
@@ -276,7 +275,6 @@ describe("buildMemoryFlushPlan", () => {
expect(plan?.softThresholdTokens).toBe(4000);
expect(plan?.forceFlushTranscriptBytes).toBe(2 * 1024 * 1024);
expect(plan?.reserveTokensFloor).toBe(20_000);
});
it("parses forceFlushTranscriptBytes from byte-size strings", () => {
+1 -3
View File
@@ -113,9 +113,7 @@ export function buildMemoryFlushPlan(
const forceFlushTranscriptBytes =
parseNonNegativeByteSize(defaults?.forceFlushTranscriptBytes) ??
DEFAULT_MEMORY_FLUSH_FORCE_TRANSCRIPT_BYTES;
const reserveTokensFloor =
normalizeNonNegativeInt(cfg?.agents?.defaults?.compaction?.reserveTokensFloor) ??
DEFAULT_AGENT_COMPACTION_RESERVE_TOKENS_FLOOR;
const reserveTokensFloor = DEFAULT_AGENT_COMPACTION_RESERVE_TOKENS_FLOOR;
const { timeLine, userTimezone } = resolveCronStyleNow(cfg ?? {}, nowMs);
const dateStamp = formatDateStampInTimezone(nowMs, userTimezone);
@@ -395,8 +395,6 @@ describe("memory index", () => {
fallback: params.fallback,
outputDimensionality: params.outputDimensionality,
store: { vector: { enabled: params.vectorEnabled ?? false } },
// Perf: keep test indexes to a single chunk to reduce sqlite work.
chunking: { tokens: 4000, overlap: 0 },
sync: { watch: false, onSessionStart: false, onSearch: params.onSearch ?? true },
remote: params.batchEnabled
? {
@@ -237,7 +237,7 @@ export abstract class MemoryManagerWatchOps extends MemoryManagerSyncBase {
count,
unit,
"Large memory folders or extraPaths can make OpenClaw run out of file watchers or open files.",
"Remove large extraPaths, or set memorySearch.sync.watch to false and refresh memory manually or with sync.intervalMinutes.",
"Remove large extraPaths, or set memorySearch.sync.watch to false and refresh memory manually.",
(message) => log.warn(message),
);
}
@@ -70,7 +70,6 @@ describe("memory manager reindex recovery", () => {
provider: params.provider ?? "openai",
model: "mock-embed",
store: { vector: { enabled: false } },
chunking: { tokens: 4000, overlap: 0 },
sync: { watch: false, onSessionStart: false, onSearch: false },
remote: { nonBatchConcurrency: 1 },
cache: { enabled: false },
@@ -231,7 +231,7 @@ describe("memory watcher config", () => {
provider: "openai",
model: "mock-embed",
store: { vector: { enabled: false } },
sync: { watch: true, watchDebounceMs: 25, onSessionStart: false, onSearch: false },
sync: { watch: true, onSessionStart: false, onSearch: false },
query: { minScore: 0, hybrid: { enabled: false } },
extraPaths: [extraDir],
...overrides,
@@ -1145,7 +1145,7 @@ describe("QmdMemoryManager", () => {
provider: "openai",
model: "mock-embed",
store: { vector: { enabled: false } },
sync: { watch: true, watchDebounceMs: 25, onSessionStart: false, onSearch: false },
sync: { watch: true, onSessionStart: false, onSearch: false },
},
},
list: [{ id: agentId, default: true, workspace: workspaceDir }],
@@ -1213,7 +1213,7 @@ describe("QmdMemoryManager", () => {
provider: "openai",
model: "mock-embed",
store: { vector: { enabled: false } },
sync: { watch: true, watchDebounceMs: 25, onSessionStart: false, onSearch: false },
sync: { watch: true, onSessionStart: false, onSearch: false },
},
},
list: [{ id: agentId, default: true, workspace: workspaceDir }],
@@ -1260,7 +1260,7 @@ describe("QmdMemoryManager", () => {
provider: "openai",
model: "mock-embed",
store: { vector: { enabled: false } },
sync: { watch: true, watchDebounceMs: 25, onSessionStart: false, onSearch: false },
sync: { watch: true, onSessionStart: false, onSearch: false },
},
},
list: [{ id: agentId, default: true, workspace: workspaceDir }],
@@ -1298,7 +1298,7 @@ describe("QmdMemoryManager", () => {
provider: "openai",
model: "mock-embed",
store: { vector: { enabled: false } },
sync: { watch: true, watchDebounceMs: 25, onSessionStart: false, onSearch: false },
sync: { watch: true, onSessionStart: false, onSearch: false },
},
},
list: [{ id: agentId, default: true, workspace: workspaceDir }],
@@ -1308,7 +1308,7 @@ export class QmdMemoryManager implements MemorySearchManager {
count,
"paths",
"Large QMD collections can make OpenClaw run out of file watchers or open files.",
"Remove large collections, or set memorySearch.sync.watch to false and refresh memory manually or with sync.intervalMinutes.",
"Remove large collections, or set memorySearch.sync.watch to false and refresh memory manually.",
(message) => log.warn(message),
);
}
@@ -209,7 +209,6 @@ describe("discord live qa runtime", () => {
expect(next.messages?.ackReactionScope).toBe("all");
expect(next.messages?.groupChat?.visibleReplies).toBe("message_tool");
expect(next.messages?.statusReactions?.enabled).toBe(true);
expect(next.messages?.statusReactions?.timing?.debounceMs).toBe(0);
const discordAccount = next.channels?.discord?.accounts?.sut;
expect(discordAccount?.allowBots).toBe(true);
expect(discordAccount?.guilds?.["123456789012345678"]?.requireMention).toBe(false);
@@ -412,10 +412,6 @@ function buildDiscordQaConfig(
statusReactions: {
...baseCfg.messages?.statusReactions,
enabled: true,
timing: {
...baseCfg.messages?.statusReactions?.timing,
debounceMs: 0,
},
},
}
: {

Some files were not shown because too many files have changed in this diff Show More