From bb657eec9359b8a782cd2a17f4abc7ed413ca979 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 23 Jul 2026 17:45:02 -0700 Subject: [PATCH] refactor(config): retire redundant settings (#113174) * refactor(config): retire redundant settings * style: apply current formatter * chore: update plugin sdk baseline * fix: keep Codex tool caps context-aware * chore: remove stale imports * test: align WhatsApp QA debounce config * fix(config): clean up retired config checks * fix(ci): align config cleanup checks --- .../control-ui-startup-budget-baseline.json | 4 +- docs/.generated/config-baseline.counts.json | 4 +- docs/.generated/config-baseline.sha256 | 6 +- docs/channels/whatsapp.md | 3 +- docs/concepts/typing-indicators.md | 3 +- docs/gateway/config-agents.md | 17 +- docs/gateway/configuration-reference.md | 7 +- docs/gateway/doctor.md | 2 +- docs/reference/token-use.md | 10 +- docs/tools/skills-config.md | 5 - docs/tools/skills.md | 4 +- docs/web/control-ui.md | 18 +- .../src/app-server/agent-context-limits.ts | 37 --- .../src/app-server/dynamic-tools.test.ts | 175 ++++---------- .../codex/src/app-server/dynamic-tools.ts | 38 ++- .../src/app-server/run-attempt-tool-setup.ts | 1 + .../codex/src/app-server/side-question.ts | 1 + .../src/monitor/message-handler.process.ts | 3 +- .../memory-core/src/memory/qmd-manager.ts | 4 +- .../src/memory/search-manager.test.ts | 1 - .../whatsapp/whatsapp-live.config.ts | 71 +++--- .../whatsapp/whatsapp-live.runtime.test.ts | 4 +- extensions/whatsapp/src/accounts.test.ts | 6 +- extensions/whatsapp/src/accounts.ts | 2 - .../whatsapp/src/auto-reply.test-harness.ts | 6 +- ...o-reply.connection-and-logging.e2e.test.ts | 71 +----- .../whatsapp/src/auto-reply/config.runtime.ts | 5 +- extensions/whatsapp/src/auto-reply/monitor.ts | 38 +-- extensions/whatsapp/src/config-schema.test.ts | 3 - extensions/whatsapp/src/config-ui-hints.ts | 6 +- extensions/whatsapp/src/test-helpers.ts | 20 -- .../memory-host-sdk/src/host/config-utils.ts | 1 - .../memory-host-sdk/src/host/read-file.ts | 1 - src/agents/agent-scope-config.ts | 2 - src/agents/agent-scope.test.ts | 4 - src/agents/command/cli-compaction.ts | 2 - .../run/attempt-context-guards.ts | 2 - .../run/attempt-prompt-context.ts | 2 - .../run/attempt-prompt-preflight.ts | 2 - ...mpt.spawn-workspace.context-engine.test.ts | 18 +- .../run/overflow-context-recovery.ts | 4 - .../tool-result-truncation.test.ts | 26 +-- .../tool-result-truncation.ts | 67 +----- .../session-tool-result-guard-wrapper.ts | 2 - src/agents/tool-result-limits.ts | 39 ++++ src/auto-reply/reply/get-reply.ts | 3 +- .../shared/legacy-config-migrate.e2e.test.ts | 2 +- ...onfig-migrations.runtime.config-tranche.ts | 217 ++++++++++++++++++ ...-config-migrations.runtime.retired.test.ts | 101 ++++++++ ...egacy-config-migrations.runtime.retired.ts | 24 +- .../schema.help.quality.test.ts.snap | 1 - ...ndled-channel-config-metadata.generated.ts | 4 +- src/config/config-misc.test.ts | 46 ---- src/config/config.schema-regressions.test.ts | 16 -- src/config/control-ui-css.ts | 64 ------ src/config/dead-config-keys.test.ts | 11 + src/config/schema.base.generated.test.ts | 2 - src/config/schema.help.agents.ts | 4 +- src/config/schema.help.core.ts | 8 - .../schema.help.quality.test-fixtures.ts | 2 - src/config/schema.help.runtime.ts | 4 - src/config/schema.labels.ts | 7 - src/config/schema.tags.ts | 1 - src/config/schema.tiers.ts | 5 +- src/config/types.agent-defaults.ts | 4 - src/config/types.agents.ts | 2 - src/config/types.openclaw.ts | 6 - src/config/types.skills.ts | 2 - src/config/types.whatsapp.ts | 2 - src/config/zod-schema.agent-defaults.test.ts | 4 - src/config/zod-schema.agent-runtime.ts | 3 - src/config/zod-schema.providers-whatsapp.ts | 3 - src/config/zod-schema.root-shape.ts | 17 -- src/flows/doctor-health-contributions.test.ts | 106 --------- src/flows/doctor-health-contributions.ts | 174 -------------- .../doctor-tool-result-cap-advice.test.ts | 80 ------- src/flows/doctor-tool-result-cap-advice.ts | 162 ------------- src/gateway/control-ui-contract.ts | 1 - src/gateway/control-ui.http.test.ts | 3 - src/gateway/control-ui.ts | 1 - src/infra/heartbeat-runner.ts | 8 +- src/skills/runtime/refresh.test.ts | 97 +++----- src/skills/runtime/refresh.ts | 53 ++--- ui/src/app/config.ts | 6 - ui/src/app/server-prefs.test.ts | 42 ++-- ui/src/app/server-prefs.ts | 12 - ui/src/app/settings.node.test.ts | 21 ++ ui/src/app/settings.ts | 66 ++++++ ui/src/components/config-form.browser.test.ts | 4 +- ui/src/i18n/.i18n/raw-copy-baseline.json | 7 + ui/src/i18n/locales/en.ts | 5 + ui/src/pages/chat/chat-pane.test.ts | 1 - ui/src/pages/chat/chat-pane.ts | 6 +- ui/src/pages/chat/chat-state.test.ts | 1 - ui/src/pages/chat/chat-state.ts | 2 - ui/src/pages/config/config-page.ts | 4 + ui/src/pages/config/view.browser.test.ts | 22 ++ ui/src/pages/config/view.ts | 44 +++- ui/src/pages/plugin/plugin-page.test.ts | 1 - 99 files changed, 796 insertions(+), 1445 deletions(-) delete mode 100644 extensions/codex/src/app-server/agent-context-limits.ts create mode 100644 src/agents/tool-result-limits.ts create mode 100644 src/commands/doctor/shared/legacy-config-migrations.runtime.config-tranche.ts delete mode 100644 src/config/control-ui-css.ts delete mode 100644 src/flows/doctor-tool-result-cap-advice.test.ts delete mode 100644 src/flows/doctor-tool-result-cap-advice.ts diff --git a/config/control-ui-startup-budget-baseline.json b/config/control-ui-startup-budget-baseline.json index 1a64aa7a8948..5c791f64cd99 100644 --- a/config/control-ui-startup-budget-baseline.json +++ b/config/control-ui-startup-budget-baseline.json @@ -1,5 +1,5 @@ { - "startupJsGzipBytes": 323435, - "reason": "catalog session activity indicator", + "startupJsGzipBytes": 318587, + "reason": "move presentation preferences to browser-local state", "updatedAt": "2026-07-23" } diff --git a/docs/.generated/config-baseline.counts.json b/docs/.generated/config-baseline.counts.json index 00230b357f11..7a4f3b65c4b3 100644 --- a/docs/.generated/config-baseline.counts.json +++ b/docs/.generated/config-baseline.counts.json @@ -1,5 +1,5 @@ { - "core": 2308, - "channel": 3632, + "core": 2299, + "channel": 3630, "plugin": 3556 } diff --git a/docs/.generated/config-baseline.sha256 b/docs/.generated/config-baseline.sha256 index a545f0a72e4c..aca9db8c6dd5 100644 --- a/docs/.generated/config-baseline.sha256 +++ b/docs/.generated/config-baseline.sha256 @@ -1,4 +1,4 @@ -f3efbf17f96d7bc331bf9655625c9e41387707d6e12ac1e000a5c6518581f826 config-baseline.json -686273ffd3d3d8034bf68d0cc9acee9e1ec83191e68b40b0c8f58f72521d4f97 config-baseline.core.json -8f64ad7f21d2d1aa18a956243f1a67c86f6cfa3fe2cddbf3ebfafb263ea02092 config-baseline.channel.json +54416b69e0b764d3505677b143f1698cc2454fce8e9f69dd1d59867e3747598e config-baseline.json +e57bb6bda49f3e895633269dae7ea7e1ae31a051c20eeb8db38f6b5694996ffe config-baseline.core.json +af6ca0e70007113462270d46fa14ef0551e577e2fa157d2b9f6d0a93d36f96f1 config-baseline.channel.json 28460228b14a94a2b93040ab3f43b214bdc6d4fda4139a75b21e70fc5983dd56 config-baseline.plugin.json diff --git a/docs/channels/whatsapp.md b/docs/channels/whatsapp.md index ba6f7f4059a1..e2288d21a72c 100644 --- a/docs/channels/whatsapp.md +++ b/docs/channels/whatsapp.md @@ -667,7 +667,8 @@ 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..enabled`, `accounts..authDir`, and other per-account overrides | -| Operations | `configWrites`, `debounceMs`, `enabled` | +| Operations | `configWrites`, `enabled` | +| Inbound batching | `messages.inbound.debounceMs`, `messages.inbound.byChannel.whatsapp` | | Session behavior | `session.dmScope`, `historyLimit`, `dmHistoryLimit`, `dms..historyLimit` | | Prompts | `groups..systemPrompt`, `groups["*"].systemPrompt`, `direct..systemPrompt`, `direct["*"].systemPrompt` | diff --git a/docs/concepts/typing-indicators.md b/docs/concepts/typing-indicators.md index 96f9386cdfde..62d49b0d4c15 100644 --- a/docs/concepts/typing-indicators.md +++ b/docs/concepts/typing-indicators.md @@ -50,7 +50,6 @@ Override the policy for one agent: entries: { support: { typingMode: "message", - typingIntervalSeconds: 8, }, }, }, @@ -63,7 +62,7 @@ Override the policy for one agent: - `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. -- `agents.defaults.typingIntervalSeconds` controls the **refresh cadence**, not the start time. Default: 6 seconds. `agents.entries.*.typingIntervalSeconds` can override it per agent. +- `agents.defaults.typingIntervalSeconds` controls the **refresh cadence** for every agent, not the start time. Default: 6 seconds. ## Related diff --git a/docs/gateway/config-agents.md b/docs/gateway/config-agents.md index 4933072e6602..3a5e4b7b6467 100644 --- a/docs/gateway/config-agents.md +++ b/docs/gateway/config-agents.md @@ -227,7 +227,6 @@ Shared defaults for bounded runtime context surfaces. defaults: { contextLimits: { memoryGetMaxChars: 12000, - memoryGetDefaultLines: 120, postCompactionMaxChars: 1800, }, }, @@ -237,15 +236,10 @@ Shared defaults for bounded runtime context surfaces. - `memoryGetMaxChars`: default `memory_get` excerpt cap before truncation metadata and continuation notice are added. -- `memoryGetDefaultLines`: default `memory_get` line window when `lines` is - omitted. -- `toolResultMaxChars`: advanced live tool-result ceiling used for persisted - results and overflow recovery. Leave unset for the model-context auto cap: - `16000` chars below 100K tokens, `32000` chars at 100K+ tokens, and `64000` - chars at 200K+ tokens. Explicit values up to `1000000` are accepted for - long-context models, but the effective cap is still limited to about 30% of - the model context window. `openclaw doctor --deep` prints the effective cap, - and doctor warns only when an explicit override is stale or has no effect. +- When `memory_get` omits `lines`, OpenClaw uses a built-in 120-line window and + then applies `memoryGetMaxChars`. +- Live tool results use a model-context auto cap: `16000` chars below 100K + tokens, `32000` chars at 100K+ tokens, and `64000` chars at 200K+ tokens. - `postCompactionMaxChars`: AGENTS.md excerpt cap used during post-compaction refresh injection. @@ -265,7 +259,6 @@ from `agents.defaults.contextLimits`. id: "tiny-local", contextLimits: { memoryGetMaxChars: 6000, - toolResultMaxChars: 8000, // advanced ceiling for this agent }, }, ], @@ -712,7 +705,7 @@ See [Streaming](/concepts/streaming) for behavior + chunking details. - Defaults: `instant` for direct chats/mentions, `message` for unmentioned group chats. - `typingIntervalSeconds` default: `6`. -- Per-agent overrides: `agents.entries.*.typingMode` and `agents.entries.*.typingIntervalSeconds`. +- Per-agent override: `agents.entries.*.typingMode`. See [Typing Indicators](/concepts/typing-indicators). diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index 0533fd20c3d4..328290b21d4c 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -510,7 +510,6 @@ See [Plugins](/tools/plugin). prefs: { theme: "claw", // claw | knot | dash | custom themeMode: "system", // light | dark | system - textScale: 100, // 90 | 100 | 110 | 125 | 140 locale: "en", chatShowThinking: true, chatShowToolCalls: true, @@ -525,7 +524,7 @@ See [Plugins](/tools/plugin). - `seamColor`: accent color for native app UI chrome (Talk Mode bubble tint, etc.). - `assistant`: Control UI identity override. Falls back to active agent identity. -- `prefs`: operator display preferences. This is the canonical home so agents can +- `prefs`: cross-device operator preferences. This is the canonical home so agents can change them through the approval gate and every Control UI client stays in sync; browsers mirror the values into local storage for instant boot and keep a device-local copy when they cannot write config (viewer scope, offline). @@ -535,6 +534,8 @@ See [Plugins](/tools/plugin). delivery remains separate and unchanged. `showAdvancedSettings` defaults to `false`; Settings search may temporarily open one matching advanced group without changing this preference. + Presentation-only preferences such as text scale, chat width, and live + sidebar activity stay browser-local and are configured in Settings. Connected clients apply server-side changes live: the gateway broadcasts a hash-only `config.changed` event after every persisted config write and clients refresh their snapshot (skipped while a local settings draft has @@ -574,7 +575,6 @@ See [Plugins](/tools/plugin). // toolTitles: false, // opt-in AI purpose titles for tool calls (spends utility-model tokens) // embedSandbox: "scripts", // strict | scripts | trusted // allowExternalEmbedUrls: false, // dangerous: allow absolute external http(s) embed URLs - // chatMessageMaxWidth: "min(1280px, 82%)", // optional centered chat transcript max-width // allowedOrigins: ["https://control.example.com"], // required for non-loopback Control UI // dangerouslyAllowHostHeaderOriginFallback: false, // dangerous Host-header origin fallback mode }, @@ -656,7 +656,6 @@ See [Plugins](/tools/plugin). Default `false`. - `controlUi.allowedOrigins`: explicit browser-origin allowlist for Gateway WebSocket connects. Required for public non-loopback browser origins. Private same-origin LAN/Tailnet UI loads from loopback, RFC1918/link-local, `.local`, `.ts.net`, or Tailscale CGNAT hosts are accepted without enabling Host-header fallback. - `controlUi.toolTitles`: opt in to AI-generated purpose titles for tool calls in Control UI chat. Default: `false` (tool rendering stays fully deterministic with no background model calls). When enabled, the `chat.toolTitles` method labels complex calls through standard utility-model routing — the agent's `utilityModel` (an operator decision that may send bounded tool arguments to the chosen provider, like every utility task), or the session provider's declared small-model default (OpenAI → `gpt-5.6-luna`, Anthropic → `claude-haiku-4-5`) — and caches results in the per-agent state database so repeat views never re-bill. `utilityModel: \"\"` disables titles like every other utility task; titles never fall back to the primary model. -- `controlUi.chatMessageMaxWidth`: optional max-width for the centered Control UI chat transcript. Accepts constrained CSS width values such as `960px`, `82%`, `min(1280px, 82%)`, and `calc(100% - 2rem)`. - `controlUi.dangerouslyAllowHostHeaderOriginFallback`: dangerous mode that enables Host-header origin fallback for deployments that intentionally rely on Host-header origin policy. - `terminal.enabled`: opt in to the admin-scoped operator terminal. Default: `false`. The terminal starts a host PTY in the selected agent workspace, inherits the Gateway process environment, and is refused for agents with `sandbox.mode: "all"`. Enable it only for trusted operator deployments; changing it restarts the Gateway and updates the Control UI content security policy. - `terminal.shell`: optional shell executable. When unset, OpenClaw uses `$SHELL` on Unix and `%ComSpec%` on Windows. diff --git a/docs/gateway/doctor.md b/docs/gateway/doctor.md index 4df8bbf1e117..fb1bdf074049 100644 --- a/docs/gateway/doctor.md +++ b/docs/gateway/doctor.md @@ -312,7 +312,7 @@ That stages grounded durable candidates into the short-term dreaming store while | `channels.whatsapp.messagePrefix` and legacy `messages.messagePrefix` | `channels.whatsapp.responsePrefix` | | `channels.whatsapp.ackReaction` | global `messages.ackReaction` and `ackReactionScope` where translatable | | `cron.failureDestination` | destination fields on `cron.failureAlert` | - | `gateway.controlUi.chatMessageMaxWidth` | `ui.prefs.chatMessageMaxWidth` | + | `gateway.controlUi.chatMessageMaxWidth`, presentation-only `ui.prefs` keys | removed (text scale, chat width, and live sidebar activity are browser-local) | | `agents.list` | keyed `agents.entries` | | top-level `defaultModel` | `agents.defaults.model` | | `messages.messagePrefix` | `channels.whatsapp.responsePrefix` | diff --git a/docs/reference/token-use.md b/docs/reference/token-use.md index 300d382b4b0e..d8892f3065fc 100644 --- a/docs/reference/token-use.md +++ b/docs/reference/token-use.md @@ -71,19 +71,17 @@ Runtime-heavy surfaces have their own explicit caps under | Key | Purpose | | ------------------------ | ------------------------------------------------------------------------ | | `memoryGetMaxChars` | Max characters `memory_get` returns before truncation. | -| `memoryGetDefaultLines` | Default `memory_get` line window when a request omits `lines`. | -| `toolResultMaxChars` | Advanced ceiling for a single live tool result (up to `1000000` chars). | | `postCompactionMaxChars` | Max characters retained from `AGENTS.md` during post-compaction refresh. | These are bounded runtime excerpts and injected runtime-owned blocks, separate from bootstrap limits, startup-context limits, and skills prompt limits. -`toolResultMaxChars` is unset by default, so OpenClaw derives the live -tool-result cap from the effective model context window: `16000` chars below +OpenClaw derives the live tool-result cap from the effective model context +window: `16000` chars below 100K tokens, `32000` chars at 100K+ tokens, `64000` chars at 200K+ tokens. -The runtime context-share guard still caps a single tool result at 30% of the -context window even when a larger explicit ceiling is configured. +The runtime context-share guard also caps a single tool result at 30% of the +context window. Large provider windows are not enabled automatically when they materially change cost or latency. For example, direct OpenAI GPT-5.5 and GPT-5.6 models diff --git a/docs/tools/skills-config.md b/docs/tools/skills-config.md index 8c96cfe564c8..1b1582e64cb1 100644 --- a/docs/tools/skills-config.md +++ b/docs/tools/skills-config.md @@ -20,7 +20,6 @@ Most skills configuration lives under `skills` in extraDirs: ["~/Projects/agent-scripts/skills"], allowSymlinkTargets: ["~/Projects/manager/skills"], watch: true, - watchDebounceMs: 250, }, install: { preferBrew: true, @@ -73,10 +72,6 @@ Most skills configuration lives under `skills` in change. Covers nested files under grouped skill roots. - - Debounce window for skill watcher events in milliseconds. - - ## Install (`skills.install`) diff --git a/docs/tools/skills.md b/docs/tools/skills.md index c1a9e205ab2e..10af3358773d 100644 --- a/docs/tools/skills.md +++ b/docs/tools/skills.md @@ -555,13 +555,13 @@ aligned. extraDirs: ["~/Projects/agent-scripts/skills"], allowSymlinkTargets: ["~/Projects/manager/skills"], watch: true, // default - watchDebounceMs: 250, // default }, }, } ``` - Use `allowSymlinkTargets` for intentional symlinked layouts where a skill + Watcher events use a built-in 250 ms debounce. Use `allowSymlinkTargets` + for intentional symlinked layouts where a skill root symlink points outside the configured root, for example `/skills/manager -> ~/Projects/manager/skills`. Enable `skills.workshop.allowSymlinkTargetWrites` only when Skill Workshop diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index b7e5fb3c9f3d..43b9d2214530 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -566,19 +566,11 @@ The chat transcript uses a centered readable frame aligned with the composer. As ## Chat message width -Wide-monitor deployments can override the transcript width without patching bundled CSS by setting `ui.prefs.chatMessageMaxWidth`: - -```json5 -{ - gateway: { - controlUi: { - chatMessageMaxWidth: "min(1280px, 82%)", - }, - }, -} -``` - -The value is validated before it reaches the browser. Supported forms include plain lengths and percentages such as `960px` or `82%`, plus constrained `min(...)`, `max(...)`, `clamp(...)`, `calc(...)`, and `fit-content(...)` width expressions. +Wide-monitor users can override the transcript width under **Settings → Chat → +Message width**. The preference stays in that browser's local storage. Supported +forms include plain lengths and percentages such as `960px` or `82%`, plus +constrained `min(...)`, `max(...)`, `clamp(...)`, `calc(...)`, and +`fit-content(...)` width expressions. ## Tailnet access (recommended) diff --git a/extensions/codex/src/app-server/agent-context-limits.ts b/extensions/codex/src/app-server/agent-context-limits.ts deleted file mode 100644 index e76858a3d6ec..000000000000 --- a/extensions/codex/src/app-server/agent-context-limits.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { EmbeddedRunAttemptParams } from "openclaw/plugin-sdk/agent-harness-runtime"; -import { normalizeAgentId } from "openclaw/plugin-sdk/routing"; -import { asOptionalRecord as readRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; - -/** Resolves an agent override before falling back to the configured default. */ -export function resolveAgentContextLimitValue(params: { - config: EmbeddedRunAttemptParams["config"] | undefined; - agentId?: string; - key: string; -}): number | undefined { - const agents = readRecord(params.config?.agents); - const defaults = readRecord(readRecord(agents?.defaults)?.contextLimits); - const defaultValue = readPositiveInteger(defaults?.[params.key]); - if (!params.agentId) { - return defaultValue; - } - const list = agents?.list; - if (!Array.isArray(list)) { - return defaultValue; - } - const normalizedAgentId = normalizeAgentId(params.agentId); - const agent = list.find((entry) => { - const entryId = readRecord(entry)?.id; - return typeof entryId === "string" && normalizeAgentId(entryId) === normalizedAgentId; - }); - const agentValue = readPositiveInteger( - readRecord(readRecord(agent)?.contextLimits)?.[params.key], - ); - return agentValue ?? defaultValue; -} - -function readPositiveInteger(value: unknown): number | undefined { - if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) { - return undefined; - } - return Math.floor(value); -} diff --git a/extensions/codex/src/app-server/dynamic-tools.test.ts b/extensions/codex/src/app-server/dynamic-tools.test.ts index e0002a39baef..beae0280a10c 100644 --- a/extensions/codex/src/app-server/dynamic-tools.test.ts +++ b/extensions/codex/src/app-server/dynamic-tools.test.ts @@ -802,8 +802,8 @@ describe("createCodexDynamicToolBridge", () => { expectNoNamespace(bridge.specs[1]); }); - it("truncates configured text tool results before returning them to Codex", async () => { - const longText = "x".repeat(400); + it("scales oversized text tool results to the effective context window", async () => { + const longText = "x".repeat(40_000); const bridge = createCodexDynamicToolBridge({ tools: [ createTool({ @@ -812,18 +812,7 @@ describe("createCodexDynamicToolBridge", () => { }), ], signal: new AbortController().signal, - hookContext: { - agentId: "main", - config: { - agents: { - defaults: { - contextLimits: { - toolResultMaxChars: 180, - }, - }, - }, - } as never, - }, + hookContext: { contextWindowTokens: 128_000 }, }); const result = await bridge.handleToolCall({ @@ -841,15 +830,43 @@ describe("createCodexDynamicToolBridge", () => { throw new Error("expected inputText tool result"); } const text = firstItem.text; - expect(text.length).toBeLessThanOrEqual(180); + expect(text.length).toBeLessThanOrEqual(32_000); expect(text).toContain("OpenClaw truncated dynamic tool result"); - expect(text).toContain("original 400 chars"); + expect(text).toContain("original 40000 chars"); expect(text).toContain("rerun with narrower args"); }); - it("keeps a whole code point when dynamic tool text crosses the configured boundary", async () => { - const maxChars = 180; - const totalChars = 400; + it("applies the context-share ceiling for small effective windows", async () => { + const bridge = createCodexDynamicToolBridge({ + tools: [ + createTool({ + name: "small_context_lookup", + execute: vi.fn(async () => textToolResult("x".repeat(20_000))), + }), + ], + signal: new AbortController().signal, + hookContext: { contextWindowTokens: 8_000 }, + }); + + const result = await bridge.handleToolCall({ + threadId: "thread-1", + turnId: "turn-1", + callId: "call-small-context", + namespace: null, + tool: "small_context_lookup", + arguments: {}, + }); + const firstItem = result.contentItems[0]; + if (firstItem?.type !== "inputText" || typeof firstItem.text !== "string") { + throw new Error("expected inputText tool result"); + } + expect(firstItem.text.length).toBeLessThanOrEqual(9_600); + expect(firstItem.text).toContain("OpenClaw truncated dynamic tool result"); + }); + + it("keeps a whole code point when dynamic tool text crosses the automatic boundary", async () => { + const maxChars = 16_000; + const totalChars = 20_000; const noticeText = `...(OpenClaw truncated dynamic tool result: original ${totalChars} chars, showing ${maxChars}; rerun with narrower args.)`; const textBudget = maxChars - noticeText.length - 1; const prefix = "a".repeat(textBudget - 1); @@ -862,12 +879,6 @@ describe("createCodexDynamicToolBridge", () => { }), ], signal: new AbortController().signal, - hookContext: { - agentId: "main", - config: { - agents: { defaults: { contextLimits: { toolResultMaxChars: maxChars } } }, - } as never, - }, }); const result = await bridge.handleToolCall({ @@ -882,123 +893,21 @@ describe("createCodexDynamicToolBridge", () => { expect(result.contentItems).toEqual([{ type: "inputText", text: `${prefix}\n${noticeText}` }]); }); - it("honors normalized per-agent dynamic tool result caps", async () => { - const bridge = createCodexDynamicToolBridge({ - tools: [ - createTool({ - name: "large_lookup", - execute: vi.fn(async () => textToolResult("x".repeat(400))), - }), - ], - signal: new AbortController().signal, - hookContext: { - agentId: "research-bot", - config: { - agents: { - defaults: { - contextLimits: { - toolResultMaxChars: 1_000, - }, - }, - list: [ - { - id: "Research Bot", - contextLimits: { - toolResultMaxChars: 180, - }, - }, - ], - }, - } as never, - }, - }); - - const result = await bridge.handleToolCall({ - threadId: "thread-1", - turnId: "turn-1", - callId: "call-1", - namespace: null, - tool: "large_lookup", - arguments: {}, - }); - - expect(result.success).toBe(true); - const firstItem = result.contentItems[0]; - if (firstItem?.type !== "inputText" || typeof firstItem.text !== "string") { - throw new Error("expected inputText tool result"); - } - expect(firstItem.text.length).toBeLessThanOrEqual(180); - expect(firstItem.text).toContain("OpenClaw truncated dynamic tool result"); - }); - - it("keeps truncation notices within tiny configured caps", async () => { - const bridge = createCodexDynamicToolBridge({ - tools: [ - createTool({ - name: "large_lookup", - execute: vi.fn(async () => textToolResult("x".repeat(400))), - }), - ], - signal: new AbortController().signal, - hookContext: { - agentId: "main", - config: { - agents: { - defaults: { - contextLimits: { - toolResultMaxChars: 32, - }, - }, - }, - } as never, - }, - }); - - const result = await bridge.handleToolCall({ - threadId: "thread-1", - turnId: "turn-1", - callId: "call-1", - namespace: null, - tool: "large_lookup", - arguments: {}, - }); - - expect(result.success).toBe(true); - const firstItem = result.contentItems[0]; - if (firstItem?.type !== "inputText" || typeof firstItem.text !== "string") { - throw new Error("expected inputText tool result"); - } - expect(firstItem.text.length).toBeLessThanOrEqual(32); - expect(firstItem.text).toBe("...(OpenClaw truncated dynamic tool".slice(0, 32)); - }); - - it("budgets configured truncation across all text result blocks", async () => { + it("budgets automatic truncation across all text result blocks", async () => { const bridge = createCodexDynamicToolBridge({ tools: [ createTool({ name: "large_lookup", execute: vi.fn(async () => ({ content: [ - { type: "text" as const, text: "a".repeat(200) }, - { type: "text" as const, text: "b".repeat(200) }, + { type: "text" as const, text: "a".repeat(10_000) }, + { type: "text" as const, text: "b".repeat(10_000) }, ], details: {}, })), }), ], signal: new AbortController().signal, - hookContext: { - agentId: "main", - config: { - agents: { - defaults: { - contextLimits: { - toolResultMaxChars: 180, - }, - }, - }, - } as never, - }, }); const result = await bridge.handleToolCall({ @@ -1014,10 +923,10 @@ describe("createCodexDynamicToolBridge", () => { const text = result.contentItems .map((item) => (item.type === "inputText" && typeof item.text === "string" ? item.text : "")) .join(""); - expect(text.length).toBeLessThanOrEqual(180); + expect(text.length).toBeLessThanOrEqual(16_000); expect(text).toContain("OpenClaw truncated dynamic tool result"); - expect(text).toContain("original 400 chars"); - expect(text).not.toContain("b".repeat(100)); + expect(text).toContain("original 20000 chars"); + expect(text).not.toContain("b".repeat(10_000)); }); it.each([ diff --git a/extensions/codex/src/app-server/dynamic-tools.ts b/extensions/codex/src/app-server/dynamic-tools.ts index cfe8f1f57ef8..a2be1c0f7b48 100644 --- a/extensions/codex/src/app-server/dynamic-tools.ts +++ b/extensions/codex/src/app-server/dynamic-tools.ts @@ -47,7 +47,6 @@ import type { ImageContent, TextContent } from "openclaw/plugin-sdk/llm"; import { normalizeOpenAIToolSchemas } from "openclaw/plugin-sdk/provider-tools"; import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; -import { resolveAgentContextLimitValue } from "./agent-context-limits.js"; import type { CodexDynamicToolsLoading } from "./config.js"; import { createFailedDynamicToolResponse, @@ -78,6 +77,7 @@ type CodexDynamicToolHookContext = { runId?: string; channelId?: string; currentChannelProvider?: string; + contextWindowTokens?: number; currentChannelId?: string; currentMessagingTarget?: string; currentMessageId?: string | number; @@ -386,6 +386,28 @@ const EXPLICIT_MESSAGE_TARGET_KEYS = ["target", "to", "channelId"]; const EXPLICIT_MESSAGE_THREAD_KEYS = ["threadId", "thread_id", "messageThreadId", "topicId"]; const EXPLICIT_MESSAGE_REPLY_KEYS = ["replyTo", "replyToId", "replyToIdFull"]; const DEFAULT_CODEX_DYNAMIC_TOOL_RESULT_MAX_CHARS = 16_000; +const LARGE_CONTEXT_CODEX_DYNAMIC_TOOL_RESULT_MAX_CHARS = 32_000; +const XL_CONTEXT_CODEX_DYNAMIC_TOOL_RESULT_MAX_CHARS = 64_000; + +function resolveCodexDynamicToolResultMaxChars(contextWindowTokens?: number): number { + if ( + typeof contextWindowTokens !== "number" || + !Number.isFinite(contextWindowTokens) || + contextWindowTokens <= 0 + ) { + return DEFAULT_CODEX_DYNAMIC_TOOL_RESULT_MAX_CHARS; + } + const tokens = Math.floor(contextWindowTokens); + const autoCap = + tokens >= 200_000 + ? XL_CONTEXT_CODEX_DYNAMIC_TOOL_RESULT_MAX_CHARS + : tokens >= 100_000 + ? LARGE_CONTEXT_CODEX_DYNAMIC_TOOL_RESULT_MAX_CHARS + : DEFAULT_CODEX_DYNAMIC_TOOL_RESULT_MAX_CHARS; + // Match the core live-result context-share ceiling without importing core + // internals across the bundled-plugin boundary. + return Math.min(autoCap, Math.max(1, Math.floor(tokens * 0.3) * 4)); +} function computerFrameImageIdentity( content: AgentToolResult["content"] | undefined, @@ -431,7 +453,9 @@ export function createCodexDynamicToolBridge(params: { directToolNames?: Iterable; }): CodexDynamicToolBridge { const toolResultHookContext = toToolResultHookContext(params.hookContext); - const toolResultMaxChars = resolveCodexDynamicToolResultMaxChars(params.hookContext); + const toolResultMaxChars = resolveCodexDynamicToolResultMaxChars( + params.hookContext?.contextWindowTokens, + ); const availableProjection = projectCodexDynamicTools(params.tools); const registeredProjection = params.registeredTools ? projectCodexDynamicTools(params.registeredTools) @@ -1165,16 +1189,6 @@ function toToolResultHookContext( }; } -function resolveCodexDynamicToolResultMaxChars( - ctx: CodexDynamicToolHookContext | undefined, -): number { - const configured = resolveAgentContextLimitValue({ - config: ctx?.config, - agentId: ctx?.agentId, - key: "toolResultMaxChars", - }); - return configured ?? DEFAULT_CODEX_DYNAMIC_TOOL_RESULT_MAX_CHARS; -} function composeAbortSignals(...signals: Array): AbortSignal { const activeSignals = signals.filter((signal): signal is AbortSignal => Boolean(signal)); if (activeSignals.length === 0) { diff --git a/extensions/codex/src/app-server/run-attempt-tool-setup.ts b/extensions/codex/src/app-server/run-attempt-tool-setup.ts index a50205e921ec..16c068c6e287 100644 --- a/extensions/codex/src/app-server/run-attempt-tool-setup.ts +++ b/extensions/codex/src/app-server/run-attempt-tool-setup.ts @@ -229,6 +229,7 @@ export async function prepareCodexAttemptTools(runtime: CodexAttemptRuntime) { hookContext: { agentId: sessionAgentId, config: params.config, + contextWindowTokens: params.contextTokenBudget ?? params.model.contextWindow, workspaceDir: effectiveWorkspace, sessionId: params.sessionId, sessionKey: sandboxSessionKey, diff --git a/extensions/codex/src/app-server/side-question.ts b/extensions/codex/src/app-server/side-question.ts index baf2b0ccbd6c..3bd9768d675d 100644 --- a/extensions/codex/src/app-server/side-question.ts +++ b/extensions/codex/src/app-server/side-question.ts @@ -1042,6 +1042,7 @@ async function createCodexSideToolBridge(input: { hookContext: { agentId: input.sessionAgentId, config: input.params.cfg, + contextWindowTokens: runtimeModel.contextWindow, sessionId: input.params.sessionId, sessionKey: input.params.sessionKey, runId: input.runId, diff --git a/extensions/discord/src/monitor/message-handler.process.ts b/extensions/discord/src/monitor/message-handler.process.ts index 3571ea1465fc..5c97143b0e15 100644 --- a/extensions/discord/src/monitor/message-handler.process.ts +++ b/extensions/discord/src/monitor/message-handler.process.ts @@ -129,8 +129,7 @@ async function processDiscordMessageInner( const sourceRepliesAreToolOnly = sourceReplyDeliveryMode === "message_tool_only"; const routedAgentConfig = resolveAgentConfig(cfg, route.agentId); const configuredTypingMode = routedAgentConfig?.typingMode ?? cfg.agents?.defaults?.typingMode; - const configuredTypingInterval = - routedAgentConfig?.typingIntervalSeconds ?? cfg.agents?.defaults?.typingIntervalSeconds; + const configuredTypingInterval = cfg.agents?.defaults?.typingIntervalSeconds; const shouldDisableCoreTypingKeepalive = sourceRepliesAreToolOnly && configuredTypingMode === undefined && diff --git a/extensions/memory-core/src/memory/qmd-manager.ts b/extensions/memory-core/src/memory/qmd-manager.ts index 97e1b6feee89..49f2121a0f95 100644 --- a/extensions/memory-core/src/memory/qmd-manager.ts +++ b/extensions/memory-core/src/memory/qmd-manager.ts @@ -1004,7 +1004,7 @@ export class QmdMemoryManager implements MemorySearchManager { if (params.from !== undefined || params.lines !== undefined) { const startLine = normalizePositiveInteger(params.from, 1); const requestedCount = normalizePositiveInteger( - params.lines ?? contextLimits?.memoryGetDefaultLines ?? DEFAULT_MEMORY_READ_LINES, + params.lines ?? DEFAULT_MEMORY_READ_LINES, DEFAULT_MEMORY_READ_LINES, ); const partial = await this.readPartialText(absPath, startLine, requestedCount); @@ -1029,7 +1029,7 @@ export class QmdMemoryManager implements MemorySearchManager { relPath, from: params.from, lines: params.lines, - defaultLines: contextLimits?.memoryGetDefaultLines ?? DEFAULT_MEMORY_READ_LINES, + defaultLines: DEFAULT_MEMORY_READ_LINES, maxChars: contextLimits?.memoryGetMaxChars, suggestReadFallback: isDefaultMemoryPath(relPath), }); diff --git a/extensions/memory-core/src/memory/search-manager.test.ts b/extensions/memory-core/src/memory/search-manager.test.ts index 32600e46eb25..11bda228f70b 100644 --- a/extensions/memory-core/src/memory/search-manager.test.ts +++ b/extensions/memory-core/src/memory/search-manager.test.ts @@ -740,7 +740,6 @@ describe("getMemorySearchManager caching", () => { workspace: "/tmp/workspace", contextLimits: { memoryGetMaxChars: 24_000, - memoryGetDefaultLines: 180, }, }, ], diff --git a/extensions/qa-lab/src/live-transports/whatsapp/whatsapp-live.config.ts b/extensions/qa-lab/src/live-transports/whatsapp/whatsapp-live.config.ts index 3e3318a40080..c1715b27032c 100644 --- a/extensions/qa-lab/src/live-transports/whatsapp/whatsapp-live.config.ts +++ b/extensions/qa-lab/src/live-transports/whatsapp/whatsapp-live.config.ts @@ -242,6 +242,42 @@ export function buildWhatsAppQaConfig( }, } : {}; + const messagesConfig = { + ...baseCfg.messages, + ...(params.overrides?.inboundDebounceMs !== undefined + ? { + inbound: { + ...baseCfg.messages?.inbound, + byChannel: { + ...baseCfg.messages?.inbound?.byChannel, + whatsapp: params.overrides.inboundDebounceMs, + }, + }, + } + : {}), + ...(params.groupJid + ? { + groupChat: { + ...baseCfg.messages?.groupChat, + visibleReplies: "automatic" as const, + mentionPatterns: [ + ...new Set([ + ...(baseCfg.messages?.groupChat?.mentionPatterns ?? []), + "\\bopenclawqa\\b", + ]), + ], + }, + } + : {}), + ...(statusReactionsEnabled + ? { + statusReactions: { + ...baseCfg.messages?.statusReactions, + enabled: true, + }, + } + : {}), + }; return { ...baseCfg, ...approvalForwardingConfig, @@ -263,6 +299,7 @@ export function buildWhatsAppQaConfig( whatsapp: { enabled: true }, }, }, + messages: messagesConfig, channels: { ...baseCfg.channels, whatsapp: { @@ -301,11 +338,6 @@ export function buildWhatsAppQaConfig( replyToMode: params.overrides.replyToMode, } : {}), - ...(params.overrides?.inboundDebounceMs !== undefined - ? { - debounceMs: params.overrides.inboundDebounceMs, - } - : {}), ...(params.groupJid ? { groupPolicy, @@ -331,34 +363,5 @@ export function buildWhatsAppQaConfig( }, }, }, - ...(params.groupJid || statusReactionsEnabled - ? { - messages: { - ...baseCfg.messages, - ...(params.groupJid - ? { - groupChat: { - ...baseCfg.messages?.groupChat, - visibleReplies: "automatic", - mentionPatterns: [ - ...new Set([ - ...(baseCfg.messages?.groupChat?.mentionPatterns ?? []), - "\\bopenclawqa\\b", - ]), - ], - }, - } - : {}), - ...(statusReactionsEnabled - ? { - statusReactions: { - ...baseCfg.messages?.statusReactions, - enabled: true, - }, - } - : {}), - }, - } - : {}), }; } diff --git a/extensions/qa-lab/src/live-transports/whatsapp/whatsapp-live.runtime.test.ts b/extensions/qa-lab/src/live-transports/whatsapp/whatsapp-live.runtime.test.ts index c564c45ce0c7..f40136f205f1 100644 --- a/extensions/qa-lab/src/live-transports/whatsapp/whatsapp-live.runtime.test.ts +++ b/extensions/qa-lab/src/live-transports/whatsapp/whatsapp-live.runtime.test.ts @@ -2182,7 +2182,7 @@ describe("WhatsApp QA live runtime", () => { }); expect(cfg.channels?.whatsapp?.accounts?.sut?.replyToMode).toBe("all"); - expect(cfg.channels?.whatsapp?.accounts?.sut?.debounceMs).toBe(250); + expect(cfg.messages?.inbound?.byChannel?.whatsapp).toBe(250); expect(cfg.channels?.whatsapp?.ackReaction).toMatchObject({ direct: true, emoji: "👀", @@ -2252,7 +2252,7 @@ describe("WhatsApp QA live runtime", () => { expect(supportedHistoryLimit).toEqual(expect.any(Number)); expect(supportedHistoryLimit).toBeGreaterThan(0); expect(cfg.channels?.whatsapp?.accounts?.sut?.replyToMode).toBe("all"); - expect(cfg.channels?.whatsapp?.accounts?.sut?.debounceMs).toBe(0); + expect(cfg.messages?.inbound?.byChannel?.whatsapp).toBe(0); expect(cfg.channels?.whatsapp?.accounts?.sut?.groups?.[groupJid]?.requireMention).toBe(true); }); diff --git a/extensions/whatsapp/src/accounts.test.ts b/extensions/whatsapp/src/accounts.test.ts index ffaa2b5e2970..8ced81fd3791 100644 --- a/extensions/whatsapp/src/accounts.test.ts +++ b/extensions/whatsapp/src/accounts.test.ts @@ -75,11 +75,8 @@ describe("resolveWhatsAppAuthDir", () => { whatsapp: { sendReadReceipts: false, responsePrefix: "[root]", - debounceMs: 100, accounts: { - work: { - debounceMs: 250, - }, + work: {}, }, }, }, @@ -89,7 +86,6 @@ describe("resolveWhatsAppAuthDir", () => { expect(resolved.sendReadReceipts).toBe(false); expect(resolved.messagePrefix).toBe("[root]"); - expect(resolved.debounceMs).toBe(250); }); it("inherits shared defaults from accounts.default for named accounts", () => { diff --git a/extensions/whatsapp/src/accounts.ts b/extensions/whatsapp/src/accounts.ts index fd2d677a5093..d121de250cb8 100644 --- a/extensions/whatsapp/src/accounts.ts +++ b/extensions/whatsapp/src/accounts.ts @@ -44,7 +44,6 @@ export type ResolvedWhatsAppAccount = { reactionLevel?: WhatsAppAccountConfig["reactionLevel"]; groups?: WhatsAppAccountConfig["groups"]; direct?: WhatsAppAccountConfig["direct"]; - debounceMs?: number; replyToMode?: ReplyToMode; }; @@ -151,7 +150,6 @@ export function resolveWhatsAppAccount(params: { reactionLevel: merged.reactionLevel, groups: merged.groups, direct: merged.direct, - debounceMs: merged.debounceMs, replyToMode: merged.replyToMode, }; } diff --git a/extensions/whatsapp/src/auto-reply.test-harness.ts b/extensions/whatsapp/src/auto-reply.test-harness.ts index a1af1fd824c5..c246960c0d10 100644 --- a/extensions/whatsapp/src/auto-reply.test-harness.ts +++ b/extensions/whatsapp/src/auto-reply.test-harness.ts @@ -20,11 +20,7 @@ import { resetLoadConfigMock as _resetLoadConfigMock, } from "./test-helpers.js"; -export { - resetLoadConfigMock, - setLoadConfigMock, - setRuntimeConfigSourceSnapshotMock, -} from "./test-helpers.js"; +export { resetLoadConfigMock, setLoadConfigMock } from "./test-helpers.js"; // Avoid exporting inferred vitest mock types (TS2742 under pnpm + d.ts emit). type AnyExport = any; diff --git a/extensions/whatsapp/src/auto-reply.web-auto-reply.connection-and-logging.e2e.test.ts b/extensions/whatsapp/src/auto-reply.web-auto-reply.connection-and-logging.e2e.test.ts index 6376276bee52..6b464086a138 100644 --- a/extensions/whatsapp/src/auto-reply.web-auto-reply.connection-and-logging.e2e.test.ts +++ b/extensions/whatsapp/src/auto-reply.web-auto-reply.connection-and-logging.e2e.test.ts @@ -23,7 +23,6 @@ import { resetLoadConfigMock, sendWebDirectInboundMessage, setLoadConfigMock, - setRuntimeConfigSourceSnapshotMock, startWebAutoReplyMonitor, } from "./auto-reply.test-harness.js"; import { @@ -925,73 +924,7 @@ describe("web auto-reply connection", () => { } }); - it("passes accounts.default debounceMs into the live listener for named accounts", async () => { - const capture = createWebListenerFactoryCapture(); - - setLoadConfigMock({ - channels: { - whatsapp: { - accounts: { - default: { - debounceMs: 250, - }, - work: { - authDir: "/tmp/work", - }, - }, - }, - }, - } as OpenClawConfig); - - await monitorWebChannel( - false, - capture.listenerFactory as never, - false, - async () => ({ text: "ok" }), - undefined, - undefined, - { - accountId: "work", - }, - ); - - resetLoadConfigMock(); - expect(capture.getLastOptions()?.debounceMs).toBe(250); - }); - - it("matches per-account debounce overrides case-insensitively", async () => { - const capture = createWebListenerFactoryCapture(); - - setLoadConfigMock({ - channels: { - whatsapp: { - accounts: { - work: { - authDir: "/tmp/work", - debounceMs: 250, - }, - }, - }, - }, - } as OpenClawConfig); - - await monitorWebChannel( - false, - capture.listenerFactory as never, - false, - async () => ({ text: "ok" }), - undefined, - undefined, - { - accountId: "Work", - }, - ); - - resetLoadConfigMock(); - expect(capture.getLastOptions()?.debounceMs).toBe(250); - }); - - it("keeps the global inbound debounce fallback when WhatsApp debounceMs is only the schema default", async () => { + it("passes the global inbound debounce into the live listener", async () => { const capture = createWebListenerFactoryCapture(); setLoadConfigMock({ @@ -1010,8 +943,6 @@ describe("web auto-reply connection", () => { }, }, } as OpenClawConfig); - setRuntimeConfigSourceSnapshotMock(null); - await monitorWebChannel( false, capture.listenerFactory as never, diff --git a/extensions/whatsapp/src/auto-reply/config.runtime.ts b/extensions/whatsapp/src/auto-reply/config.runtime.ts index e9f496491230..011c158dd85a 100644 --- a/extensions/whatsapp/src/auto-reply/config.runtime.ts +++ b/extensions/whatsapp/src/auto-reply/config.runtime.ts @@ -1,7 +1,4 @@ // Whatsapp helper module supports config behavior. export { resolveStorePath, updateLastRoute } from "openclaw/plugin-sdk/session-store-runtime"; -export { - getRuntimeConfig, - getRuntimeConfigSourceSnapshot, -} from "openclaw/plugin-sdk/runtime-config-snapshot"; +export { getRuntimeConfig } from "openclaw/plugin-sdk/runtime-config-snapshot"; export { resolveChannelContextVisibilityMode } from "openclaw/plugin-sdk/context-visibility-runtime"; diff --git a/extensions/whatsapp/src/auto-reply/monitor.ts b/extensions/whatsapp/src/auto-reply/monitor.ts index f664e5ff6f9c..99cf9055e6f8 100644 --- a/extensions/whatsapp/src/auto-reply/monitor.ts +++ b/extensions/whatsapp/src/auto-reply/monitor.ts @@ -1,6 +1,5 @@ // Whatsapp plugin module implements monitor behavior. import type { WAMessageKey } from "baileys"; -import { resolveAccountEntry } from "openclaw/plugin-sdk/account-core"; import { CHANNEL_APPROVAL_NATIVE_RUNTIME_CONTEXT_CAPABILITY } from "openclaw/plugin-sdk/approval-handler-runtime"; import { shouldDebounceTextInbound } from "openclaw/plugin-sdk/channel-inbound"; import { resolveInboundDebounceMs } from "openclaw/plugin-sdk/channel-inbound-debounce"; @@ -45,7 +44,7 @@ import { } from "../reconnect.js"; import { formatError, getWebAuthAgeMs, readWebSelfId } from "../session.js"; import { resolveWhatsAppSocketTiming } from "../socket-timing.js"; -import { getRuntimeConfig, getRuntimeConfigSourceSnapshot } from "./config.runtime.js"; +import { getRuntimeConfig } from "./config.runtime.js"; import { whatsappHeartbeatLog, whatsappLog } from "./loggers.js"; import { buildMentionConfig } from "./mentions.js"; import { createWebChannelStatusController } from "./monitor-state.js"; @@ -104,37 +103,12 @@ function resolveWebMonitorConfigSnapshot(params: { return { cfg, account }; } -function normalizeReconnectAccountId(accountId?: string | null): string { - return (accountId ?? "").trim() || "default"; -} - function isNoListenerReconnectError(lastError?: string): boolean { return typeof lastError === "string" && /No active WhatsApp Web listener/i.test(lastError); } -function resolveExplicitWhatsAppDebounceOverride(params: { - cfg: ReturnType; - sourceCfg?: ReturnType | null; - accountId: string; -}): number | undefined { - const channel = params.sourceCfg?.channels?.whatsapp; - if (!channel) { - return undefined; - } - - const accountId = normalizeReconnectAccountId(params.accountId); - const accountDebounce = resolveAccountEntry(channel.accounts, accountId)?.debounceMs; - if (accountDebounce !== undefined) { - return accountDebounce; - } - if (accountId !== "default") { - const defaultAccountDebounce = resolveAccountEntry(channel.accounts, "default")?.debounceMs; - if (defaultAccountDebounce !== undefined) { - return defaultAccountDebounce; - } - } - - return channel.debounceMs; +function normalizeReconnectAccountId(accountId?: string | null): string { + return (accountId ?? "").trim() || "default"; } function isRetryableAuthUnstableError(error: unknown): error is WhatsAppAuthUnstableError { @@ -166,7 +140,6 @@ export async function monitorWebChannel( const heartbeatLogger = getChildLogger({ module: "web-heartbeat", runId }); const reconnectLogger = getChildLogger({ module: "web-reconnect", runId }); const baseCfg = getRuntimeConfig(); - const sourceCfg = getRuntimeConfigSourceSnapshot(); const { cfg, account } = resolveWebMonitorConfigSnapshot({ cfg: baseCfg, accountId: tuning.accountId, @@ -256,11 +229,6 @@ export async function monitorWebChannel( const inboundDebounceMs = resolveInboundDebounceMs({ cfg, channel: "whatsapp", - overrideMs: resolveExplicitWhatsAppDebounceOverride({ - cfg, - sourceCfg, - accountId: account.accountId, - }), }); const shouldDebounce = (msg: WebInboundMessageInput) => { const normalized = normalizeWebInboundMessage(msg); diff --git a/extensions/whatsapp/src/config-schema.test.ts b/extensions/whatsapp/src/config-schema.test.ts index 4e20d1330236..7f91506a13d5 100644 --- a/extensions/whatsapp/src/config-schema.test.ts +++ b/extensions/whatsapp/src/config-schema.test.ts @@ -82,7 +82,6 @@ describe("whatsapp config schema", () => { const res = expectWhatsAppConfigValid({ dmPolicy: "allowlist", groupPolicy: "open", - debounceMs: 250, allowFrom: ["+15550001111"], accounts: { work: { @@ -96,10 +95,8 @@ describe("whatsapp config schema", () => { } expect(res.data.dmPolicy).toBe("allowlist"); expect(res.data.groupPolicy).toBe("open"); - expect(res.data.debounceMs).toBe(250); expect(res.data.accounts?.work?.dmPolicy).toBeUndefined(); expect(res.data.accounts?.work?.groupPolicy).toBeUndefined(); - expect(res.data.accounts?.work?.debounceMs).toBeUndefined(); }); it("accepts allowlist accounts inheriting allowFrom from accounts.default", () => { diff --git a/extensions/whatsapp/src/config-ui-hints.ts b/extensions/whatsapp/src/config-ui-hints.ts index b3857a38ff8d..7444c3884e1e 100644 --- a/extensions/whatsapp/src/config-ui-hints.ts +++ b/extensions/whatsapp/src/config-ui-hints.ts @@ -5,7 +5,7 @@ import type { ChannelConfigUiHint } from "openclaw/plugin-sdk/core"; export const whatsAppChannelConfigUiHints = { "": { label: "WhatsApp", - help: "WhatsApp channel provider configuration for access policy and message batching behavior. Use this section to tune responsiveness and direct-message routing safety for WhatsApp chats.", + help: "WhatsApp channel provider configuration for access policy and direct-message routing safety.", }, ...createChannelConfigUiHints({ channelLabel: "WhatsApp", @@ -21,10 +21,6 @@ export const whatsAppChannelConfigUiHints = { label: "WhatsApp Self-Phone Mode", help: "Same-phone setup (bot uses your personal WhatsApp number).", }, - debounceMs: { - label: "WhatsApp Message Debounce (ms)", - help: "Debounce window (ms) for batching rapid consecutive messages from the same sender (0 to disable).", - }, ...createChannelConfigUiHints({ channelLabel: "WhatsApp", configWrites: true }), "actions.calls": { label: "WhatsApp Voice Calls", diff --git a/extensions/whatsapp/src/test-helpers.ts b/extensions/whatsapp/src/test-helpers.ts index 5653dd77a1f8..f7173f3e45c9 100644 --- a/extensions/whatsapp/src/test-helpers.ts +++ b/extensions/whatsapp/src/test-helpers.ts @@ -10,7 +10,6 @@ import { createMockBaileys } from "../../../test/mocks/baileys.js"; // Use globalThis to store the mock config so it survives vi.mock hoisting const CONFIG_KEY = Symbol.for("openclaw:testConfigMock"); -const SOURCE_CONFIG_KEY = Symbol.for("openclaw:testSourceConfigMock"); const DEFAULT_CONFIG = { channels: { whatsapp: { @@ -28,22 +27,12 @@ const DEFAULT_CONFIG = { if (!(globalThis as Record)[CONFIG_KEY]) { (globalThis as Record)[CONFIG_KEY] = () => DEFAULT_CONFIG; } -if (!(globalThis as Record)[SOURCE_CONFIG_KEY]) { - (globalThis as Record)[SOURCE_CONFIG_KEY] = () => loadConfigMock(); -} - export function setLoadConfigMock(fn: unknown) { (globalThis as Record)[CONFIG_KEY] = typeof fn === "function" ? fn : () => fn; } -export function setRuntimeConfigSourceSnapshotMock(fn: unknown) { - (globalThis as Record)[SOURCE_CONFIG_KEY] = - typeof fn === "function" ? fn : () => fn; -} - export function resetLoadConfigMock() { (globalThis as Record)[CONFIG_KEY] = () => DEFAULT_CONFIG; - (globalThis as Record)[SOURCE_CONFIG_KEY] = () => loadConfigMock(); } function resolveStorePathFallback(store?: string, opts?: { agentId?: string }) { @@ -69,14 +58,6 @@ function loadConfigMock() { return DEFAULT_CONFIG; } -function loadRuntimeConfigSourceSnapshotMock() { - const getter = (globalThis as Record)[SOURCE_CONFIG_KEY]; - if (typeof getter === "function") { - return getter(); - } - return loadConfigMock(); -} - async function updateLastRouteMock(params: { storePath: string; sessionKey: string; @@ -445,7 +426,6 @@ function resolveChannelGroupRequireMentionMock(params: { vi.mock("./auto-reply/config.runtime.js", () => ({ getRuntimeConfig: loadConfigMock, - getRuntimeConfigSourceSnapshot: loadRuntimeConfigSourceSnapshotMock, loadConfig: loadConfigMock, updateLastRoute: updateLastRouteMock, loadSessionStore: loadSessionStoreMock, diff --git a/packages/memory-host-sdk/src/host/config-utils.ts b/packages/memory-host-sdk/src/host/config-utils.ts index 93d518f275f3..9c75e346812a 100644 --- a/packages/memory-host-sdk/src/host/config-utils.ts +++ b/packages/memory-host-sdk/src/host/config-utils.ts @@ -104,7 +104,6 @@ type MemorySearchConfig = { /** Agent context limits that bound memory file reads. */ type AgentContextLimitsConfig = { memoryGetMaxChars?: number; - memoryGetDefaultLines?: number; }; /** Secret reference accepted by provider header config. */ diff --git a/packages/memory-host-sdk/src/host/read-file.ts b/packages/memory-host-sdk/src/host/read-file.ts index 41218811b1b9..733c6edd89ac 100644 --- a/packages/memory-host-sdk/src/host/read-file.ts +++ b/packages/memory-host-sdk/src/host/read-file.ts @@ -176,7 +176,6 @@ export async function readAgentMemoryFile(params: { relPath: params.relPath, from: params.from, lines: params.lines, - defaultLines: contextLimits?.memoryGetDefaultLines, maxChars: contextLimits?.memoryGetMaxChars, }); } diff --git a/src/agents/agent-scope-config.ts b/src/agents/agent-scope-config.ts index 50946a598577..2cfa901b7173 100644 --- a/src/agents/agent-scope-config.ts +++ b/src/agents/agent-scope-config.ts @@ -37,7 +37,6 @@ export type ResolvedAgentConfig = { memory?: AgentEntry["memory"]; humanDelay?: AgentEntry["humanDelay"]; typingMode?: AgentEntry["typingMode"]; - typingIntervalSeconds?: AgentEntry["typingIntervalSeconds"]; tts?: AgentEntry["tts"]; contextLimits?: AgentContextLimitsConfig; heartbeat?: AgentEntry["heartbeat"]; @@ -156,7 +155,6 @@ export function resolveAgentConfig( memory: entry.memory, humanDelay: entry.humanDelay, typingMode: entry.typingMode ?? agentDefaults?.typingMode, - typingIntervalSeconds: entry.typingIntervalSeconds ?? agentDefaults?.typingIntervalSeconds, tts: entry.tts, contextLimits: typeof entry.contextLimits === "object" && entry.contextLimits diff --git a/src/agents/agent-scope.test.ts b/src/agents/agent-scope.test.ts index 4919a8d4df5f..3be92c5af94b 100644 --- a/src/agents/agent-scope.test.ts +++ b/src/agents/agent-scope.test.ts @@ -101,8 +101,6 @@ describe("resolveAgentConfig", () => { defaults: { contextLimits: { memoryGetMaxChars: 20_000, - memoryGetDefaultLines: 180, - toolResultMaxChars: 18_000, }, }, list: [ @@ -121,8 +119,6 @@ describe("resolveAgentConfig", () => { expect(resolveAgentConfig(cfg, "main")?.contextLimits).toEqual({ memoryGetMaxChars: 24_000, - memoryGetDefaultLines: 180, - toolResultMaxChars: 18_000, }); }); diff --git a/src/agents/command/cli-compaction.ts b/src/agents/command/cli-compaction.ts index 01605d3f2dbb..0e15290de32a 100644 --- a/src/agents/command/cli-compaction.ts +++ b/src/agents/command/cli-compaction.ts @@ -629,8 +629,6 @@ export async function runCliTurnCompactionLifecycle(params: { reserveTokens: settingsManager.getCompactionReserveTokens(), toolResultMaxChars: cliCompactionDeps.resolveLiveToolResultMaxChars({ contextWindowTokens: contextTokenBudget, - cfg: params.cfg, - agentId: params.sessionAgentId, }), }); const tokenSnapshot = resolveSessionTokenSnapshot(params.sessionEntry); diff --git a/src/agents/embedded-agent-runner/run/attempt-context-guards.ts b/src/agents/embedded-agent-runner/run/attempt-context-guards.ts index 663c36a9a560..91ab92c9761d 100644 --- a/src/agents/embedded-agent-runner/run/attempt-context-guards.ts +++ b/src/agents/embedded-agent-runner/run/attempt-context-guards.ts @@ -61,8 +61,6 @@ export function installEmbeddedAttemptContextGuards(input: { ); const toolResultMaxChars = resolveLiveToolResultMaxChars({ contextWindowTokens: contextTokenBudget, - cfg: attempt.config, - agentId: input.sessionAgentId, }); let pendingMidTurnPrecheckRequest: MidTurnPrecheckRequest | null = null; let afterTurnCheckpoint: number | null = null; diff --git a/src/agents/embedded-agent-runner/run/attempt-prompt-context.ts b/src/agents/embedded-agent-runner/run/attempt-prompt-context.ts index 993dd9d04b86..318d30ca21d1 100644 --- a/src/agents/embedded-agent-runner/run/attempt-prompt-context.ts +++ b/src/agents/embedded-agent-runner/run/attempt-prompt-context.ts @@ -110,8 +110,6 @@ export function prepareEmbeddedAttemptPromptContext(input: { const contextTokenBudget = attempt.contextTokenBudget ?? DEFAULT_CONTEXT_TOKENS; const promptToolResultMaxChars = resolveLiveToolResultMaxChars({ contextWindowTokens: contextTokenBudget, - cfg: attempt.config, - agentId: input.sessionAgentId, }); const promptToolResultAggregateMaxChars = resolveLiveToolResultAggregateMaxChars({ contextWindowTokens: contextTokenBudget, diff --git a/src/agents/embedded-agent-runner/run/attempt-prompt-preflight.ts b/src/agents/embedded-agent-runner/run/attempt-prompt-preflight.ts index a59284ca4d89..8c6fc00152b7 100644 --- a/src/agents/embedded-agent-runner/run/attempt-prompt-preflight.ts +++ b/src/agents/embedded-agent-runner/run/attempt-prompt-preflight.ts @@ -82,8 +82,6 @@ export function handleEmbeddedAttemptMidTurnPrecheck(input: { const contextTokenBudget = attempt.contextTokenBudget ?? DEFAULT_CONTEXT_TOKENS; const toolResultMaxChars = resolveLiveToolResultMaxChars({ contextWindowTokens: contextTokenBudget, - cfg: attempt.config, - agentId: input.sessionAgentId, }); const truncationResult = truncateOversizedToolResultsInSessionManager({ sessionManager: input.sessionManager, diff --git a/src/agents/embedded-agent-runner/run/attempt.spawn-workspace.context-engine.test.ts b/src/agents/embedded-agent-runner/run/attempt.spawn-workspace.context-engine.test.ts index 7c41f501ac5e..4a90c4d2b463 100644 --- a/src/agents/embedded-agent-runner/run/attempt.spawn-workspace.context-engine.test.ts +++ b/src/agents/embedded-agent-runner/run/attempt.spawn-workspace.context-engine.test.ts @@ -3641,19 +3641,7 @@ describe("runEmbeddedAttempt tool-result guard budget wiring", () => { sessionKey, tempPaths, sessionMessages, - attemptOverrides: { - contextTokenBudget: 128_000, - config: { - agents: { - defaults: { - contextLimits: { - toolResultMaxChars: 2_000, - }, - }, - list: [{ id: "main" }], - }, - } as OpenClawConfig, - }, + attemptOverrides: { contextTokenBudget: 128_000 }, createSession: () => { const session = createDefaultEmbeddedSession({ initialMessages: sessionMessages }); session.agent.streamFn = async (_model, context) => { @@ -3675,7 +3663,7 @@ describe("runEmbeddedAttempt tool-result guard budget wiring", () => { toolCallId: `current_call_${index}`, toolName: "process", content: [ - { type: "text", text: `current ${index}: ${"current output ".repeat(300)}` }, + { type: "text", text: `current ${index}: ${"current output ".repeat(3_000)}` }, ], isError: false, timestamp: 100 + index, @@ -3696,7 +3684,7 @@ describe("runEmbeddedAttempt tool-result guard budget wiring", () => { expect( submittedMessages .filter((message) => message.role === "toolResult") - .every((message) => sumToolResultTextChars([message]) <= 2_000), + .every((message) => sumToolResultTextChars([message]) <= 32_000), ).toBe(true); expect(JSON.stringify(submittedCurrentPromptMessages)).toContain("truncated"); expect(afterTurn).toHaveBeenCalledTimes(1); diff --git a/src/agents/embedded-agent-runner/run/overflow-context-recovery.ts b/src/agents/embedded-agent-runner/run/overflow-context-recovery.ts index 075ba8284e54..2534a92fa7eb 100644 --- a/src/agents/embedded-agent-runner/run/overflow-context-recovery.ts +++ b/src/agents/embedded-agent-runner/run/overflow-context-recovery.ts @@ -356,8 +356,6 @@ export async function recoverEmbeddedRunOverflow(input: { contextWindowTokens: input.contextTokenBudget, maxCharsOverride: resolveLiveToolResultMaxChars({ contextWindowTokens: input.contextTokenBudget, - cfg: runParams.config, - agentId: input.sessionAgentId, }), config: runParams.config, protectTrailingToolResults: true, @@ -390,8 +388,6 @@ export async function recoverEmbeddedRunOverflow(input: { if (!input.state.toolResultTruncationAttempted) { const toolResultMaxChars = resolveLiveToolResultMaxChars({ contextWindowTokens: input.contextTokenBudget, - cfg: runParams.config, - agentId: input.sessionAgentId, }); const hasOversized = input.attempt.messagesSnapshot ? sessionLikelyHasOversizedToolResults({ diff --git a/src/agents/embedded-agent-runner/tool-result-truncation.test.ts b/src/agents/embedded-agent-runner/tool-result-truncation.test.ts index c629a0cbaffe..2fe337f2e86d 100644 --- a/src/agents/embedded-agent-runner/tool-result-truncation.test.ts +++ b/src/agents/embedded-agent-runner/tool-result-truncation.test.ts @@ -20,6 +20,10 @@ import type { SessionEntry as SessionStoreEntry } from "../../config/sessions/ty import { onInternalSessionTranscriptUpdate } from "../../sessions/transcript-events.js"; import { formatFullOutputFooter } from "../sessions/tools/tool-contracts.js"; import { makeAgentAssistantMessage } from "../test-helpers/agent-message-fixtures.js"; +import { + calculateMaxToolResultCharsWithCap, + resolveAutoLiveToolResultMaxChars, +} from "../tool-result-limits.js"; import { buildRuntimeContextCustomMessage } from "./run/runtime-context-prompt.js"; import { clearEmbeddedSessionPromptStates, @@ -28,8 +32,6 @@ import { } from "./session-prompt-state.js"; let truncateToolResultMessage: typeof import("./tool-result-truncation.js").truncateToolResultMessage; -let calculateMaxToolResultCharsWithCap: typeof import("./tool-result-truncation.js").calculateMaxToolResultCharsWithCap; -let resolveAutoLiveToolResultMaxChars: typeof import("./tool-result-truncation.js").resolveAutoLiveToolResultMaxChars; let truncateOversizedToolResultsInMessages: typeof import("./tool-result-truncation.js").truncateOversizedToolResultsInMessages; let truncateOversizedToolResultsInActiveTarget: typeof import("./tool-result-truncation.js").truncateOversizedToolResultsInActiveTarget; let sessionLikelyHasOversizedToolResults: typeof import("./tool-result-truncation.js").sessionLikelyHasOversizedToolResults; @@ -45,8 +47,6 @@ async function loadFreshToolResultTruncationModuleForTest() { // across persisted-session and live-truncation tests. ({ truncateToolResultMessage, - calculateMaxToolResultCharsWithCap, - resolveAutoLiveToolResultMaxChars, truncateOversizedToolResultsInMessages, truncateOversizedToolResultsInActiveTarget, sessionLikelyHasOversizedToolResults, @@ -416,24 +416,6 @@ describe("calculateMaxToolResultChars", () => { expect(result).toBe(32_000); }); - it("resolves per-agent tool-result cap overrides", () => { - const result = resolveLiveToolResultMaxChars({ - contextWindowTokens: 128_000, - cfg: { - agents: { - defaults: { - contextLimits: { - toolResultMaxChars: 24_000, - }, - }, - list: [{ id: "writer" }], - }, - }, - agentId: "writer", - }); - expect(result).toBe(24_000); - }); - it.each([ { contextWindowTokens: 20_000, perResultMaxChars: 16_000, aggregateMaxChars: 64_000 }, { contextWindowTokens: 128_000, perResultMaxChars: 32_000, aggregateMaxChars: 256_000 }, diff --git a/src/agents/embedded-agent-runner/tool-result-truncation.ts b/src/agents/embedded-agent-runner/tool-result-truncation.ts index bf0152670ee4..ee71ecaf8080 100644 --- a/src/agents/embedded-agent-runner/tool-result-truncation.ts +++ b/src/agents/embedded-agent-runner/tool-result-truncation.ts @@ -12,7 +12,6 @@ import { createDedupeCache } from "../../infra/dedupe.js"; import { formatErrorMessage } from "../../infra/errors.js"; import type { TextContent } from "../../llm/types.js"; import { emitSessionTranscriptUpdate } from "../../sessions/transcript-events.js"; -import { resolveAgentContextLimits } from "../agent-scope.js"; import type { AgentMessage } from "../runtime/index.js"; import { acquireSessionWriteLock, @@ -21,6 +20,11 @@ import { } from "../session-write-lock.js"; import { SessionManager } from "../sessions/index.js"; import { formatFullOutputFooter } from "../sessions/tools/tool-contracts.js"; +import { + calculateMaxToolResultCharsWithCap, + resolveAutoLiveToolResultMaxChars, + resolveLiveToolResultMaxChars, +} from "../tool-result-limits.js"; import { formatContextLimitTruncationNotice } from "./context-truncation-notice.js"; import { log } from "./logger.js"; import type { ToolResultPromptProjectionState } from "./session-prompt-state.js"; @@ -40,25 +44,10 @@ import { type RuntimeTranscriptScope, } from "./transcript-runtime-state.js"; -/** - * Maximum share of the context window a single tool result should occupy. - * This is intentionally conservative – a single tool result should not - * consume more than 30% of the context window even without other messages. - */ -const MAX_TOOL_RESULT_CONTEXT_SHARE = 0.3; - -/** - * Low-context default cap for a single live tool result text block. - * - * The session runtime already truncates tool results aggressively when serializing old history - * for compaction summaries. For the live request path we still keep a bounded - * request-local ceiling so oversized tool output cannot dominate the next turn. - */ -export const DEFAULT_MAX_LIVE_TOOL_RESULT_CHARS = 16_000; -const LARGE_CONTEXT_MAX_LIVE_TOOL_RESULT_CHARS = 32_000; -const XL_CONTEXT_MAX_LIVE_TOOL_RESULT_CHARS = 64_000; -const LARGE_CONTEXT_TOOL_RESULT_TOKENS = 100_000; -const XL_CONTEXT_TOOL_RESULT_TOKENS = 200_000; +export { + DEFAULT_MAX_LIVE_TOOL_RESULT_CHARS, + resolveLiveToolResultMaxChars, +} from "../tool-result-limits.js"; const PROMPT_TOOL_RESULT_AGGREGATE_CAP_MULTIPLIER = 4; const AGGREGATE_TOOL_RESULT_CONTEXT_SHARE = 0.5; @@ -288,45 +277,9 @@ function calculateMaxToolResultChars(contextWindowTokens: number): number { ); } -export function resolveAutoLiveToolResultMaxChars(contextWindowTokens: number): number { - if (!Number.isFinite(contextWindowTokens)) { - return DEFAULT_MAX_LIVE_TOOL_RESULT_CHARS; - } - const tokens = Math.floor(contextWindowTokens); - if (tokens >= XL_CONTEXT_TOOL_RESULT_TOKENS) { - return XL_CONTEXT_MAX_LIVE_TOOL_RESULT_CHARS; - } - if (tokens >= LARGE_CONTEXT_TOOL_RESULT_TOKENS) { - return LARGE_CONTEXT_MAX_LIVE_TOOL_RESULT_CHARS; - } - return DEFAULT_MAX_LIVE_TOOL_RESULT_CHARS; -} - -export function calculateMaxToolResultCharsWithCap( - contextWindowTokens: number, - hardCapChars: number, -): number { - const maxTokens = Math.floor(contextWindowTokens * MAX_TOOL_RESULT_CONTEXT_SHARE); - // Rough conversion: ~4 chars per token on average - const maxChars = maxTokens * 4; - return Math.min(maxChars, Math.max(1, hardCapChars)); -} - -export function resolveLiveToolResultMaxChars(params: { - contextWindowTokens: number; - cfg?: OpenClawConfig; - agentId?: string | null; -}): number { - const configuredCap = resolveAgentContextLimits(params.cfg, params.agentId)?.toolResultMaxChars; - const cap = configuredCap ?? resolveAutoLiveToolResultMaxChars(params.contextWindowTokens); - return calculateMaxToolResultCharsWithCap(params.contextWindowTokens, cap); -} - export function resolveLiveToolResultAggregateMaxChars(params: { contextWindowTokens: number; perResultMaxChars?: number; - cfg?: OpenClawConfig; - agentId?: string | null; }): number { const perResultMaxChars = Math.max( 1, @@ -334,8 +287,6 @@ export function resolveLiveToolResultAggregateMaxChars(params: { params.perResultMaxChars ?? resolveLiveToolResultMaxChars({ contextWindowTokens: params.contextWindowTokens, - cfg: params.cfg, - agentId: params.agentId, }), ), ); diff --git a/src/agents/session-tool-result-guard-wrapper.ts b/src/agents/session-tool-result-guard-wrapper.ts index f2c01251a49f..380e0a2193ef 100644 --- a/src/agents/session-tool-result-guard-wrapper.ts +++ b/src/agents/session-tool-result-guard-wrapper.ts @@ -206,8 +206,6 @@ export function guardSessionManager( typeof opts?.contextWindowTokens === "number" ? resolveLiveToolResultMaxChars({ contextWindowTokens: opts.contextWindowTokens, - cfg: opts.config, - agentId: opts.agentId, }) : undefined, suppressNextUserMessagePersistence: opts?.suppressNextUserMessagePersistence, diff --git a/src/agents/tool-result-limits.ts b/src/agents/tool-result-limits.ts new file mode 100644 index 000000000000..bb0d4a636840 --- /dev/null +++ b/src/agents/tool-result-limits.ts @@ -0,0 +1,39 @@ +/** Automatic live tool-result caps derived from the effective model context. */ + +const MAX_TOOL_RESULT_CONTEXT_SHARE = 0.3; + +export const DEFAULT_MAX_LIVE_TOOL_RESULT_CHARS = 16_000; +const LARGE_CONTEXT_MAX_LIVE_TOOL_RESULT_CHARS = 32_000; +const XL_CONTEXT_MAX_LIVE_TOOL_RESULT_CHARS = 64_000; +const LARGE_CONTEXT_TOOL_RESULT_TOKENS = 100_000; +const XL_CONTEXT_TOOL_RESULT_TOKENS = 200_000; + +export function resolveAutoLiveToolResultMaxChars(contextWindowTokens: number): number { + if (!Number.isFinite(contextWindowTokens)) { + return DEFAULT_MAX_LIVE_TOOL_RESULT_CHARS; + } + const tokens = Math.floor(contextWindowTokens); + if (tokens >= XL_CONTEXT_TOOL_RESULT_TOKENS) { + return XL_CONTEXT_MAX_LIVE_TOOL_RESULT_CHARS; + } + if (tokens >= LARGE_CONTEXT_TOOL_RESULT_TOKENS) { + return LARGE_CONTEXT_MAX_LIVE_TOOL_RESULT_CHARS; + } + return DEFAULT_MAX_LIVE_TOOL_RESULT_CHARS; +} + +export function calculateMaxToolResultCharsWithCap( + contextWindowTokens: number, + hardCapChars: number, +): number { + const maxTokens = Math.floor(contextWindowTokens * MAX_TOOL_RESULT_CONTEXT_SHARE); + const maxChars = maxTokens * 4; + return Math.min(maxChars, Math.max(1, hardCapChars)); +} + +export function resolveLiveToolResultMaxChars(params: { contextWindowTokens: number }): number { + return calculateMaxToolResultCharsWithCap( + params.contextWindowTokens, + resolveAutoLiveToolResultMaxChars(params.contextWindowTokens), + ); +} diff --git a/src/auto-reply/reply/get-reply.ts b/src/auto-reply/reply/get-reply.ts index 2f1bd27a90b5..aca1026401ab 100644 --- a/src/auto-reply/reply/get-reply.ts +++ b/src/auto-reply/reply/get-reply.ts @@ -348,8 +348,7 @@ export async function getReplyFromConfig( }; }); const typing = resolverTiming.measureSync("reply.create_typing_controller", () => { - const configuredTypingSeconds = - agentEntry?.typingIntervalSeconds ?? agentCfg?.typingIntervalSeconds; + const configuredTypingSeconds = agentCfg?.typingIntervalSeconds; const typingIntervalSeconds = typeof configuredTypingSeconds === "number" ? configuredTypingSeconds : 6; const controller = createTypingController({ diff --git a/src/commands/doctor/shared/legacy-config-migrate.e2e.test.ts b/src/commands/doctor/shared/legacy-config-migrate.e2e.test.ts index 6b959e99ebb9..daf0150a630c 100644 --- a/src/commands/doctor/shared/legacy-config-migrate.e2e.test.ts +++ b/src/commands/doctor/shared/legacy-config-migrate.e2e.test.ts @@ -135,8 +135,8 @@ describe("legacy config migration end to end", () => { reload: { mode: "hybrid" }, nodes: { allowSkills: false, commands: { allow: ["camera.snap"], deny: ["system.run"] } }, }, - ui: { prefs: { chatMessageMaxWidth: "82%" } }, cron: { failureAlert: { channel: "telegram", to: "123" } }, + messages: { inbound: { byChannel: { whatsapp: 0 } } }, channels: { defaults: { heartbeatVisibility: { showOk: true } }, slack: { postAs: "user" }, diff --git a/src/commands/doctor/shared/legacy-config-migrations.runtime.config-tranche.ts b/src/commands/doctor/shared/legacy-config-migrations.runtime.config-tranche.ts new file mode 100644 index 000000000000..4ea0d9fcb27b --- /dev/null +++ b/src/commands/doctor/shared/legacy-config-migrations.runtime.config-tranche.ts @@ -0,0 +1,217 @@ +// Config-tranche migrations move legacy aliases before canonical validation. +import { ensureRecord, getRecord } from "../../../config/legacy.shared.js"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../routing/account-id.js"; + +function deletePath(owner: unknown, path: readonly string[], index = 0): boolean { + const record = getRecord(owner); + const key = path[index]; + if (!record || !key) { + return false; + } + if (index === path.length - 1) { + if (!Object.hasOwn(record, key)) { + return false; + } + delete record[key]; + return true; + } + const child = getRecord(record[key]); + if (!child || !deletePath(child, path, index + 1)) { + return false; + } + if (Object.keys(child).length === 0) { + delete record[key]; + } + return true; +} + +function visitAgentEntries( + raw: Record, + visitor: (entry: Record, path: string) => void, +): void { + const agents = getRecord(raw.agents); + const entries = getRecord(agents?.entries); + if (entries) { + for (const [agentId, value] of Object.entries(entries)) { + const entry = getRecord(value); + if (entry) { + visitor(entry, `agents.entries.${agentId}`); + } + } + } + if (Array.isArray(agents?.list)) { + agents.list.forEach((value, index) => { + const entry = getRecord(value); + if (entry) { + visitor(entry, `agents.list[${index}]`); + } + }); + } +} + +function stripRetiredPresentationPrefs(raw: Record, changes: string[]): void { + const prefs = getRecord(getRecord(raw.ui)?.prefs); + if (!prefs) { + return; + } + const removed = ["chatMessageMaxWidth", "textScale", "sidebarLiveActivity"].filter((key) => { + if (!Object.hasOwn(prefs, key)) { + return false; + } + delete prefs[key]; + return true; + }); + if (removed.length > 0) { + changes.push( + `Removed browser-local ui.prefs keys: ${removed.map((key) => `ui.prefs.${key}`).join(", ")}.`, + ); + } + const ui = getRecord(raw.ui); + if (Object.keys(prefs).length === 0) { + delete ui?.prefs; + } + if (ui && Object.keys(ui).length === 0) { + delete raw.ui; + } +} + +function stripRetiredAgentConfig(raw: Record, changes: string[]): void { + const agents = getRecord(raw.agents); + const defaults = getRecord(agents?.defaults); + let removedContextLimits = false; + const stripContextLimits = (owner: Record) => { + for (const key of ["memoryGetDefaultLines", "toolResultMaxChars"]) { + removedContextLimits = deletePath(owner, ["contextLimits", key]) || removedContextLimits; + } + }; + if (defaults) { + stripContextLimits(defaults); + } + let removedTypingOverride = false; + visitAgentEntries(raw, (entry) => { + if (Object.hasOwn(entry, "typingIntervalSeconds")) { + delete entry.typingIntervalSeconds; + removedTypingOverride = true; + } + stripContextLimits(entry); + }); + if (removedTypingOverride) { + changes.push( + "Removed per-agent typingIntervalSeconds overrides; agents.defaults.typingIntervalSeconds now applies to every agent.", + ); + } + if (removedContextLimits) { + changes.push( + "Removed contextLimits.memoryGetDefaultLines/toolResultMaxChars overrides; canonical memory and context-window caps now apply.", + ); + } +} + +type LegacyWhatsAppDebounce = { path: string; value?: number; accountId?: string }; +type LegacyWhatsAppDebounceWithValue = LegacyWhatsAppDebounce & { value: number }; + +function readLegacyDebounce( + path: string, + owner: Record, + accountId?: string, +): LegacyWhatsAppDebounce | null { + if (!Object.hasOwn(owner, "debounceMs")) { + return null; + } + const raw = owner.debounceMs; + delete owner.debounceMs; + return { + path, + ...(accountId ? { accountId } : {}), + ...(typeof raw === "number" && Number.isInteger(raw) && raw >= 0 ? { value: raw } : {}), + }; +} + +function migrateWhatsAppDebounce(raw: Record, changes: string[]): void { + const whatsapp = getRecord(getRecord(raw.channels)?.whatsapp); + if (!whatsapp) { + return; + } + const sources: LegacyWhatsAppDebounce[] = []; + const rootSource = readLegacyDebounce("channels.whatsapp.debounceMs", whatsapp); + if (rootSource) { + sources.push(rootSource); + } + const accounts = getRecord(whatsapp.accounts); + if (accounts) { + for (const accountId of Object.keys(accounts).toSorted()) { + const account = getRecord(accounts[accountId]); + if (!account) { + continue; + } + const source = readLegacyDebounce( + `channels.whatsapp.accounts.${accountId}.debounceMs`, + account, + normalizeAccountId(accountId), + ); + if (source) { + sources.push(source); + } + } + } + if (sources.length === 0) { + return; + } + + const validSources = sources.filter( + (source): source is LegacyWhatsAppDebounceWithValue => source.value !== undefined, + ); + if (validSources.length === 0) { + changes.push( + `Removed invalid WhatsApp debounce values: ${sources.map((source) => source.path).join(", ")}.`, + ); + return; + } + + const inbound = ensureRecord(ensureRecord(raw, "messages"), "inbound"); + const byChannel = ensureRecord(inbound, "byChannel"); + if (byChannel.whatsapp !== undefined) { + changes.push( + `Removed ${sources.map((source) => source.path).join(", ")} (messages.inbound.byChannel.whatsapp already set).`, + ); + return; + } + + const configuredDefaultAccount = normalizeAccountId( + typeof whatsapp.defaultAccount === "string" ? whatsapp.defaultAccount : undefined, + ); + const selected = + validSources.find((source) => source.accountId === configuredDefaultAccount) ?? + validSources.find((source) => source.accountId === DEFAULT_ACCOUNT_ID) ?? + validSources.find((source) => source.path === "channels.whatsapp.debounceMs") ?? + validSources[0]; + if (!selected) { + return; + } + byChannel.whatsapp = selected.value; + const distinctValues = new Set(validSources.map((source) => source.value)); + if (distinctValues.size === 1 && validSources.length === sources.length) { + changes.push( + `Moved ${sources.map((source) => source.path).join(", ")} → messages.inbound.byChannel.whatsapp.`, + ); + } else { + changes.push( + `Collapsed conflicting WhatsApp debounce values into messages.inbound.byChannel.whatsapp using ${selected.path} (${selected.value} ms); account-specific debounce is no longer supported.`, + ); + } +} + +export function migrateConfigTranche(raw: Record, changes: string[]): void { + stripRetiredPresentationPrefs(raw, changes); + if (deletePath(raw, ["skills", "load", "watchDebounceMs"])) { + changes.push("Removed skills.load.watchDebounceMs; the watcher now uses the 250 ms default."); + } + stripRetiredAgentConfig(raw, changes); + migrateWhatsAppDebounce(raw, changes); +} + +export function hasConfigTrancheLegacyKeys(root: Record): boolean { + const changes: string[] = []; + migrateConfigTranche(structuredClone(root), changes); + return changes.length > 0; +} diff --git a/src/commands/doctor/shared/legacy-config-migrations.runtime.retired.test.ts b/src/commands/doctor/shared/legacy-config-migrations.runtime.retired.test.ts index b5f3c35054bb..162dcb3636ff 100644 --- a/src/commands/doctor/shared/legacy-config-migrations.runtime.retired.test.ts +++ b/src/commands/doctor/shared/legacy-config-migrations.runtime.retired.test.ts @@ -276,6 +276,107 @@ describe("retired runtime config migrations", () => { ); }); + it("migrates the config tranche while preserving canonical settings", () => { + const result = applyAll({ + ui: { + prefs: { + chatMessageMaxWidth: "82%", + textScale: 125, + sidebarLiveActivity: false, + showAdvancedSettings: true, + }, + }, + skills: { load: { watch: true, watchDebounceMs: 500 } }, + agents: { + defaults: { + typingIntervalSeconds: 6, + contextLimits: { + memoryGetMaxChars: 12_000, + memoryGetDefaultLines: 180, + toolResultMaxChars: 24_000, + }, + }, + entries: { + writer: { + typingMode: "message", + typingIntervalSeconds: 8, + contextLimits: { toolResultMaxChars: 8_000 }, + }, + }, + list: [ + { + id: "legacy", + typingIntervalSeconds: 10, + contextLimits: { memoryGetDefaultLines: 80 }, + }, + ], + }, + channels: { + whatsapp: { + defaultAccount: "Work", + debounceMs: 2_000, + accounts: { + default: { debounceMs: 3_000 }, + work: { debounceMs: 4_000 }, + }, + }, + }, + }); + + expect(result.raw).toMatchObject({ + ui: { prefs: { showAdvancedSettings: true } }, + skills: { load: { watch: true } }, + agents: { + defaults: { + typingIntervalSeconds: 6, + contextLimits: { memoryGetMaxChars: 12_000 }, + }, + entries: { writer: { typingMode: "message" } }, + list: [{ id: "legacy" }], + }, + messages: { inbound: { byChannel: { whatsapp: 4_000 } } }, + }); + expect(result.raw).not.toHaveProperty("channels.whatsapp.debounceMs"); + expect(result.raw).not.toHaveProperty("channels.whatsapp.accounts.default.debounceMs"); + expect(result.raw).not.toHaveProperty("channels.whatsapp.accounts.work.debounceMs"); + expect(result.changes).toContain( + "Collapsed conflicting WhatsApp debounce values into messages.inbound.byChannel.whatsapp using channels.whatsapp.accounts.work.debounceMs (4000 ms); account-specific debounce is no longer supported.", + ); + }); + + it("keeps an existing canonical WhatsApp debounce value", () => { + const result = applyAll({ + messages: { inbound: { byChannel: { whatsapp: 900 } } }, + channels: { + whatsapp: { + debounceMs: 2_000, + accounts: { work: { debounceMs: 4_000 } }, + }, + }, + }); + + expect(result.raw).toHaveProperty("messages.inbound.byChannel.whatsapp", 900); + expect(result.raw).not.toHaveProperty("channels.whatsapp.debounceMs"); + expect(result.raw).not.toHaveProperty("channels.whatsapp.accounts.work.debounceMs"); + }); + + it("preserves accounts.default debounce inheritance for a named default account", () => { + const result = applyAll({ + channels: { + whatsapp: { + defaultAccount: "work", + debounceMs: 2_000, + accounts: { + default: { debounceMs: 3_000 }, + work: { name: "Work" }, + }, + }, + }, + }); + + expect(result.raw).toHaveProperty("messages.inbound.byChannel.whatsapp", 3_000); + }); + it("moves aliases and strips dead keys", () => { const result = applyAll({ tui: { footer: { showRemoteHost: true } }, diff --git a/src/commands/doctor/shared/legacy-config-migrations.runtime.retired.ts b/src/commands/doctor/shared/legacy-config-migrations.runtime.retired.ts index d34bcc3ea94d..4db488feb696 100644 --- a/src/commands/doctor/shared/legacy-config-migrations.runtime.retired.ts +++ b/src/commands/doctor/shared/legacy-config-migrations.runtime.retired.ts @@ -7,6 +7,10 @@ import { type LegacyConfigMigrationSpec, type LegacyConfigRule, } from "../../../config/legacy.shared.js"; +import { + hasConfigTrancheLegacyKeys, + migrateConfigTranche, +} from "./legacy-config-migrations.runtime.config-tranche.js"; import { consolidateMediaCapabilityConfig, hasDiscordRealtimeVoice, @@ -425,14 +429,8 @@ function migrateFinalLayoutKills(raw: Record, changes: string[] } const controlUi = getRecord(gateway?.controlUi); if (controlUi && Object.hasOwn(controlUi, "chatMessageMaxWidth")) { - const prefs = ensureRecord(ensureRecord(raw, "ui"), "prefs"); - if (prefs.chatMessageMaxWidth === undefined) { - prefs.chatMessageMaxWidth = controlUi.chatMessageMaxWidth; - changes.push("Moved gateway.controlUi.chatMessageMaxWidth → ui.prefs.chatMessageMaxWidth."); - } else { - changes.push("Removed gateway.controlUi.chatMessageMaxWidth (ui.prefs value already set)."); - } delete controlUi.chatMessageMaxWidth; + changes.push("Removed gateway.controlUi.chatMessageMaxWidth; chat width is now browser-local."); } } @@ -488,6 +486,18 @@ export const LEGACY_CONFIG_MIGRATIONS_RUNTIME_RETIRED: LegacyConfigMigrationSpec consolidateMediaCapabilityConfig(raw, changes); }, }), + defineLegacyConfigMigration({ + id: "runtime.config-tranche", + describe: "Migrate retired config-tranche options", + legacyRules: [ + rule( + [], + "Presentation-only preferences and duplicate tuning options moved to canonical defaults.", + (_value, root) => hasConfigTrancheLegacyKeys(root), + ), + ], + apply: migrateConfigTranche, + }), defineLegacyConfigMigration({ id: "runtime.tuning-knobs-purge", describe: "Remove retired runtime tuning knobs", diff --git a/src/config/__snapshots__/schema.help.quality.test.ts.snap b/src/config/__snapshots__/schema.help.quality.test.ts.snap index 36da3e69e916..60c5c3bb40d5 100644 --- a/src/config/__snapshots__/schema.help.quality.test.ts.snap +++ b/src/config/__snapshots__/schema.help.quality.test.ts.snap @@ -618,7 +618,6 @@ exports[`config tier coverage > keeps the curated common leaf set reviewable 1`] "ui.prefs.chatShowToolCalls", "ui.prefs.locale", "ui.prefs.showAdvancedSettings", - "ui.prefs.textScale", "ui.prefs.theme", "ui.prefs.themeMode", "update.auto.enabled", diff --git a/src/config/bundled-channel-config-metadata.generated.ts b/src/config/bundled-channel-config-metadata.generated.ts index 85b071772d40..2fdff0668a61 100644 --- a/src/config/bundled-channel-config-metadata.generated.ts +++ b/src/config/bundled-channel-config-metadata.generated.ts @@ -31,8 +31,8 @@ const RAW_BUNDLED_CHANNEL_CONFIG_METADATA = [ 't. Use Slack user IDs or user targets such as `U123`, `user:U123`, or `<@U123>`. If you leave this unset, OpenClaw falls back to commands.ownerAllowFrom when possible."},"execApprovals.agentFilter":{"label":"Slack Exec Approval Agent Filter","help":"Optional allowlist of agent IDs eligible for Slack exec approvals, for example `[\\"main\\", \\"ops-agent\\"]`. Use this to keep approval prompts scoped to the agents you actually operate from Slack."},"execApprovals.sessionFilter":{"label":"Slack Exec Approval Session Filter","help":"Optional session-key filters matched as substring or regex-style patterns before Slack approval routing is used. Use narrow patterns so Slack approvals only appear for intended sessions."},"execApprovals.target":{"label":"Slack Exec Approval Target","help":"Controls where Slack approval prompts are sent: \\"dm\\" sends to approver DMs (default), \\"channel\\" sends to the originating Slack chat/thread, and \\"both\\" sends to both. Channel delivery exposes the command text to the chat, so only use it in trusted channels."},"streaming":{"label":"Slack Streaming Mode","help":"Unified Slack stream preview mode: \\"off\\" | \\"partial\\" | \\"block\\" | \\"progress\\". Legacy boolean/streamMode keys are auto-mapped."},"streaming.mode":{"label":"Slack Streaming Mode","help":"Canonical Slack preview mode: \\"off\\" | \\"partial\\" | \\"block\\" | \\"progress\\"."},"streaming.chunkMode":{"label":"Slack Chunk Mode","help":"Chunking mode for outbound Slack text delivery: \\"length\\" (default) or \\"newline\\"."},"streaming.block.enabled":{"label":"Slack Block Streaming Enabled","help":"Enable chunked block-style Slack preview delivery when channels.slack.streaming.mode=\\"block\\"."},"streaming.block.coalesce":{"label":"Slack Block Streaming Coalesce","help":"Merge streamed Slack block replies before final delivery."},"streaming.nativeTransport":{"label":"Slack Native Streaming","help":"Enable native Slack text streaming (chat.startStream/chat.appendStream/chat.stopStream) when channels.slack.streaming.mode is partial (default: true). Native streaming and Slack assistant thread status require a reply thread target; top-level DMs can still use draft post-and-edit preview streaming."},"streaming.preview.toolProgress":{"label":"Slack Draft Tool Progress","help":"Show tool/progress activity in the live draft preview message (default: true). Set false to hide interim tool updates while the draft preview stays active."},"streaming.preview.commandText":{"label":"Slack Draft Command Text","help":"Command/exec detail in preview tool-progress lines: \\"raw\\" preserves released behavior; \\"status\\" shows only the tool label."},"streaming.progress.label":{"label":"Slack Progress Label","help":"Initial progress draft title. Use \\"auto\\" for built-in single-word labels, a custom string, or false to hide the title."},"streaming.progress.labels":{"label":"Slack Progress Label Pool","help":"Candidate labels for streaming.progress.label=\\"auto\\". Leave unset to use OpenClaw built-in progress labels."},"streaming.progress.maxLines":{"label":"Slack Progress Max Lines","help":"Maximum number of compact progress lines to keep below the draft label (default: 8)."},"streaming.progress.maxLineChars":{"label":"Slack Progress Max Line Chars","help":"Maximum characters per compact progress line before truncation (default: 120). Prose cuts at word boundaries; commands and paths keep useful suffixes."},"streaming.progress.render":{"label":"Slack Progress Renderer","help":"Progress draft renderer: \\"text\\" uses one portable text body; \\"rich\\" renders structured Slack Block Kit fields with the same text fallback."},"streaming.progress.nativeTaskCards":{"label":"Slack Native Progress Task Cards","help":"Opt in to Slack native task-card progress updates when channels.slack.streaming.mode=\\"progress\\" and streaming.nativeTransport is enabled. Default: false."},"streaming.progress.toolProgress":{"label":"Slack Progress Tool Lines","help":"Show compact tool/progress lines in progress draft mode (default: true). Set false to keep only the label until final delivery."},"streaming.progress.commandText":{"label":"Slack Progress Command Text","help":"Command/exec detail in progress draft lines: \\"raw\\" preserves released behavior; \\"status\\" shows only the tool label."},"thread.historyScope":{"label":"Slack Thread History Scope","help":"Scope for Slack thread history context (\\"thread\\" isolates per thread; \\"channel\\" reuses channel history)."},"thread.inheritParent":{"label":"Slack Thread Parent Inheritance","help":"If true, Slack thread sessions inherit the parent channel transcript (default: false)."},"thread.initialHistoryLimit":{"label":"Slack Thread Initial History Limit","help":"Maximum number of existing Slack thread messages to fetch when starting a new thread session (default: 20, set to 0 to disable)."}}},{"pluginId":"sms","channelId":"sms","order":88,"channelEnvVars":["SMS_ALLOWED_USERS","SMS_PUBLIC_WEBHOOK_URL","SMS_WEBHOOK_PATH","TWILIO_ACCOUNT_SID","TWILIO_AUTH_TOKEN","TWILIO_MESSAGING_SERVICE_SID","TWILIO_PHONE_NUMBER","TWILIO_SMS_FROM"],"label":"SMS","description":"Twilio-backed SMS with inbound webhooks and outbound replies.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"accountSid":{"type":"string"},"authToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"fromNumber":{"type":"string"},"messagingServiceSid":{"type":"string"},"defaultTo":{"type":"string"},"webhookPath":{"type":"string"},"publicWebhookUrl":{"type":"string"},"dangerouslyDisableSignatureValidation":{"type":"boolean"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"accountSid":{"type":"string"},"authToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"fromNumber":{"type":"string"},"messagingServiceSid":{"type":"string"},"defaultTo":{"type":"string"},"webhookPath":{"type":"string"},"publicWebhookUrl":{"type":"string"},"dangerouslyDisableSignatureValidation":{"type":"boolean"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},"required":["dmPolicy"],"additionalProperties":false}},"defaultAccount":{"type":"string"}},"required":["dmPolicy"],"additionalProperties":false},"uiHints":{"":{"label":"SMS","help":"Twilio SMS channel configuration for inbound webhooks and outbound text replies."},"accountSid":{"label":"Twilio Account SID","help":"Twilio Account SID used for SMS outbound API calls."},"authToken":{"label":"Twilio Auth Token","help":"Twilio Auth Token used to sign webhook validation and SMS outbound API calls."},"fromNumber":{"label":"SMS From Number","help":"Twilio SMS-capable phone number in E.164 format, for example +15551234567.","presentation":"phone-number"},"messagingServiceSid":{"label":"Twilio Messaging Service SID","help":"Twilio Messaging Service SID to use instead of a dedicated fromNumber."},"defaultTo":{"label":"SMS Default To Number","help":"Optional default outbound phone number used when a send flow omits an explicit SMS target.","presentation":"phone-number"},"publicWebhookUrl":{"label":"SMS Public Webhook URL","help":"Public URL configured in Twilio for incoming messages. Must match Twilio\'s signed URL exactly."},"webhookPath":{"label":"SMS Webhook Path","help":"Gateway HTTP path that receives Twilio incoming-message webhooks. Use a distinct path per account."},"dmPolicy":{"label":"SMS DM Policy","help":"Direct SMS access control (\\"pairing\\" recommended). \\"open\\" requires channels.sms.allowFrom=[\\"*\\"]."},"allowFrom":{"label":"SMS Allow From","help":"Allowed sender phone numbers in E.164 format, or * when dmPolicy is open.","presentation":"phone-number"},"accounts.*.fromNumber":{"presentation":"phone-number"},"accounts.*.defaultTo":{"presentation":"phone-number"},"accounts.*.allowFrom.*":{"presentation":"phone-number"},"textChunkLimit":{"label":"SMS Text Chunk Limit","help":"Maximum characters per outbound SMS chunk before OpenClaw splits long replies."}}},{"pluginId":"synology-chat","channelId":"synology-chat","order":90,"channelEnvVars":["OPENCLAW_BOT_NAME","SYNOLOGY_ALLOWED_USER_IDS","SYNOLOGY_CHAT_INCOMING_URL","SYNOLOGY_CHAT_TOKEN","SYNOLOGY_NAS_HOST","SYNOLOGY_RATE_LIMIT"],"label":"Synology Chat","description":"Connect your Synology NAS Chat to OpenClaw with full agent capabilities.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"dangerouslyAllowNameMatching":{"type":"boolean"},"dangerouslyAllowInheritedWebhookPath":{"type":"boolean"}},"additionalProperties":{}}},{"pluginId":"telegram","channelId":"telegram","channelEnvVars":["TELEGRAM_BOT_TOKEN"],"label":"Telegram","description":"simplest way to get started — register a bot with @BotFather and get going.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"capabilities":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"object","properties":{"inlineButtons":{"type":"string","enum":["off","dm","group","all","allowlist"]}},"additionalProperties":false}]},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"enabled":{"type":"boolean"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"defaultTo":{"anyOf":[{"type":"string"},{"type":"number"}]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"mentionPatterns":{"type":"object","properties":{"mode":{"anyOf":[{"type":"string","const":"allow"},{"type":"string","const":"deny"}]},"allowIn":{"type":"array","items":{"type":"string"}},"denyIn":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"streaming":{"type":"object","properties":{"mode":{"type":"string","enum":["off","partial","block","progress"]},"chunkMode":{"type":"string","enum":["length","newline"]},"preview":{"type":"object","properties":{"chunk":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"breakPreference":{"anyOf":[{"type":"string","const":"paragraph"},{"type":"string","const":"newline"},{"type":"string","const":"sentence"}]}},"additionalProperties":false},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"progress":{"type":"object","properties":{"label":{"anyOf":[{"type":"string"},{"type":"boolean","const":false}]},"labels":{"type":"array","items":{"type":"string"}},"maxLines":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxLineChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"render":{"type":"string","enum":["text","rich"]},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]},"commentary":{"type":"boolean"},"narration":{"type":"boolean"}},"additionalProperties":false},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false}},"additionalProperties":false},"heartbeatVisibility":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"responsePrefix":{"type":"string"},"mediaMaxMb":{"type":"number","exclusiveMinimum":0},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"execApprovals":{"type":"object","properties":{"enabled":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"approvers":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"agentFilter":{"type":"array","items":{"type":"string"}},"sessionFilter":{"type":"array","items":{"type":"string"}},"target":{"type":"string","enum":["dm","channel","both"]}},"additionalProperties":false},"commands":{"type":"object","properties":{"native":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"nativeSkills":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]}},"additionalProperties":false},"customCommands":{"type":"array","items":{"type":"object","properties":{"command":{"type":"string"},"description":{"type":"string"}},"required":["command","description"],"additionalProperties":false}},"botToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"tokenFile":{"type":', '"string"},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"ingest":{"type":"boolean"},"disableAudioPreflight":{"type":"boolean"},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"topics":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"ingest":{"type":"boolean"},"disableAudioPreflight":{"type":"boolean"},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"agentId":{"type":"string"},"errorPolicy":{"type":"string","enum":["always","once","silent"]}},"additionalProperties":false}},"errorPolicy":{"type":"string","enum":["always","once","silent"]}},"additionalProperties":false}},"direct":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"topics":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"ingest":{"type":"boolean"},"disableAudioPreflight":{"type":"boolean"},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"agentId":{"type":"string"},"errorPolicy":{"type":"string","enum":["always","once","silent"]}},"additionalProperties":false}},"errorPolicy":{"type":"string","enum":["always","once","silent"]},"requireTopic":{"type":"boolean"},"autoTopicLabel":{"anyOf":[{"type":"boolean"},{"type":"object","properties":{"enabled":{"type":"boolean"},"prompt":{"type":"string"}},"additionalProperties":false}]}},"additionalProperties":false}},"richMessages":{"type":"boolean"},"network":{"type":"object","properties":{"autoSelectFamily":{"type":"boolean"},"dnsResultOrder":{"type":"string","enum":["ipv4first","verbatim"]},"dangerouslyAllowPrivateNetwork":{"description":"Dangerous opt-in for trusted Telegram fake-IP or transparent-proxy environments where api.telegram.org resolves to private/internal/special-use addresses during media downloads.","type":"boolean"}},"additionalProperties":false},"proxy":{"type":"string"},"webhookUrl":{"description":"Public HTTPS webhook URL registered with Telegram for inbound updates. This must be internet-reachable and requires channels.telegram.webhookSecret.","type":"string"},"webhookSecret":{"description":"Secret token sent to Telegram during webhook registration and verified on inbound webhook requests. Telegram returns this value for verification; this is not the gateway auth token and not the bot token.","anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"webhookPath":{"description":"Local webhook route path served by the gateway listener. Defaults to /telegram-webhook.","type":"string"},"webhookHost":{"description":"Local bind host for the webhook listener. Defaults to 127.0.0.1; keep loopback unless you intentionally expose direct ingress.","type":"string"},"webhookPort":{"description":"Local bind port for the webhook listener. Defaults to 8787; set to 0 to let the OS assign an ephemeral port.","type":"integer","minimum":0,"maximum":9007199254740991},"webhookCertPath":{"description":"Path to the self-signed certificate (PEM) to upload to Telegram during webhook registration. Required for self-signed certs (direct IP or no domain).","type":"string"},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"},"sendMessage":{"type":"boolean"},"poll":{"type":"boolean"},"deleteMessage":{"type":"boolean"},"editMessage":{"type":"boolean"},"sticker":{"type":"boolean"},"createForumTopic":{"type":"boolean"},"editForumTopic":{"type":"boolean"}},"additionalProperties":false},"threadBindings":{"type":"object","properties":{"enabled":{"type":"boolean"},"idleHours":{"type":"number","minimum":0},"maxAgeHours":{"type":"number","minimum":0},"spawnSessions":{"type":"boolean"},"defaultSpawnContext":{"type":"string","enum":["isolated","fork"]}},"additionalProperties":false},"reactionNotifications":{"type":"string","enum":["off","own","all"]},"reactionLevel":{"type":"string","enum":["off","ack","minimal","extensive"]},"ackReaction":{"type":"string"},"linkPreview":{"type":"boolean"},"silentErrorReplies":{"type":"boolean"},"errorPolicy":{"type":"string","enum":["always","once","silent"]},"apiRoot":{"type":"string","format":"uri"},"trustedLocalFileRoots":{"description":"Trusted local filesystem roots for self-hosted Telegram Bot API absolute file_path values. Only absolute paths under these roots are read directly; all other absolute paths are rejected.","type":"array","items":{"type":"string"}},"autoTopicLabel":{"anyOf":[{"type":"boolean"},{"type":"object","properties":{"enabled":{"type":"boolean"},"prompt":{"type":"string"}},"additionalProperties":false}]},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"capabilities":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"object","properties":{"inlineButtons":{"type":"string","enum":["off","dm","group","all","allowlist"]}},"additionalProperties":false}]},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"enabled":{"type":"boolean"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"defaultTo":{"anyOf":[{"type":"string"},{"type":"number"}]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"mentionPatterns":{"type":"object","properties":{"mode":{"anyOf":[{"type":"string","const":"allow"},{"type":"string","const":"deny"}]},"allowIn":{"type":"array","items":{"type":"string"}},"denyIn":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"streaming":{"type":"object","properties":{"mode":{"type":"string","enum":["off","partial","block","progress"]},"chunkMode":{"type":"string","enum":["length","newline"]},"preview":{"type":"object","properties":{"chunk":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"breakPreference":{"anyOf":[{"type":"string","const":"paragraph"},{"type":"string","const":"newline"},{"type":"string","const":"sentence"}]}},"additionalProperties":false},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"progress":{"type":"object","properties":{"label":{"anyOf":[{"type":"string"},{"type":"boolean","const":false}]},"labels":{"type":"array","items":{"type":"string"}},"maxLines":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxLineChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"render":{"type":"string","enum":["text","rich"]},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]},"commentary":{"type":"boolean"},"narration":{"type":"boolean"}},"additionalProperties":false},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false}},"additionalProperties":false},"heartbeatVisibility":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"responsePrefix":{"type":"string"},"mediaMaxMb":{"type":"number","exclusiveMinimum":0},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"execApprovals":{"type":"object","properties":{"enabled":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"approvers":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"agentFilter":{"type":"array","items":{"type":"string"}},"sessionFilter":{"type":"array","items":{"type":"string"}},"target":{"type":"string","enum":["dm","channel","both"]}},"additionalProperties":false},"commands":{"type":"object","properties":{"native":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"nativeSkills":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]}},"additionalProperties":false},"customCommands":{"type":"array","items":{"type":"object","properties":{"command":{"type":"string"},"description":{"type":"string"}},"required":["command","description"],"additionalProperties":false}},"botToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"tokenFile":{"type":"string"},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"ingest":{"type":"boolean"},"disableAudioPreflight":{"type":"boolean"},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"topics":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"ingest":{"type":"boolean"},"disableAudioPreflight":{"type":"boolean"},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"agentId":{"type":"string"},"errorPolicy":{"type":"string","enum":["always","once","silent"]}},"additionalProperties":false}},"errorPolicy":{"type":"string","enum":["always","once","silent"]}},"additionalProperties":false}},"direct":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"topics":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"ingest":{"type":"boolean"},"disableAudioPreflight":{"type":"boolean"},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"agentId":{"type":"string"},"errorPolicy":{"type":"string","enum":["always","once","silent"]}},"additionalProperties":false}},"errorPolicy":{"type":"string","enum":["always', '","once","silent"]},"requireTopic":{"type":"boolean"},"autoTopicLabel":{"anyOf":[{"type":"boolean"},{"type":"object","properties":{"enabled":{"type":"boolean"},"prompt":{"type":"string"}},"additionalProperties":false}]}},"additionalProperties":false}},"richMessages":{"type":"boolean"},"network":{"type":"object","properties":{"autoSelectFamily":{"type":"boolean"},"dnsResultOrder":{"type":"string","enum":["ipv4first","verbatim"]},"dangerouslyAllowPrivateNetwork":{"description":"Dangerous opt-in for trusted Telegram fake-IP or transparent-proxy environments where api.telegram.org resolves to private/internal/special-use addresses during media downloads.","type":"boolean"}},"additionalProperties":false},"proxy":{"type":"string"},"webhookUrl":{"description":"Public HTTPS webhook URL registered with Telegram for inbound updates. This must be internet-reachable and requires channels.telegram.webhookSecret.","type":"string"},"webhookSecret":{"description":"Secret token sent to Telegram during webhook registration and verified on inbound webhook requests. Telegram returns this value for verification; this is not the gateway auth token and not the bot token.","anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"webhookPath":{"description":"Local webhook route path served by the gateway listener. Defaults to /telegram-webhook.","type":"string"},"webhookHost":{"description":"Local bind host for the webhook listener. Defaults to 127.0.0.1; keep loopback unless you intentionally expose direct ingress.","type":"string"},"webhookPort":{"description":"Local bind port for the webhook listener. Defaults to 8787; set to 0 to let the OS assign an ephemeral port.","type":"integer","minimum":0,"maximum":9007199254740991},"webhookCertPath":{"description":"Path to the self-signed certificate (PEM) to upload to Telegram during webhook registration. Required for self-signed certs (direct IP or no domain).","type":"string"},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"},"sendMessage":{"type":"boolean"},"poll":{"type":"boolean"},"deleteMessage":{"type":"boolean"},"editMessage":{"type":"boolean"},"sticker":{"type":"boolean"},"createForumTopic":{"type":"boolean"},"editForumTopic":{"type":"boolean"}},"additionalProperties":false},"threadBindings":{"type":"object","properties":{"enabled":{"type":"boolean"},"idleHours":{"type":"number","minimum":0},"maxAgeHours":{"type":"number","minimum":0},"spawnSessions":{"type":"boolean"},"defaultSpawnContext":{"type":"string","enum":["isolated","fork"]}},"additionalProperties":false},"reactionNotifications":{"type":"string","enum":["off","own","all"]},"reactionLevel":{"type":"string","enum":["off","ack","minimal","extensive"]},"ackReaction":{"type":"string"},"linkPreview":{"type":"boolean"},"silentErrorReplies":{"type":"boolean"},"errorPolicy":{"type":"string","enum":["always","once","silent"]},"apiRoot":{"type":"string","format":"uri"},"trustedLocalFileRoots":{"description":"Trusted local filesystem roots for self-hosted Telegram Bot API absolute file_path values. Only absolute paths under these roots are read directly; all other absolute paths are rejected.","type":"array","items":{"type":"string"}},"autoTopicLabel":{"anyOf":[{"type":"boolean"},{"type":"object","properties":{"enabled":{"type":"boolean"},"prompt":{"type":"string"}},"additionalProperties":false}]}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false}},"defaultAccount":{"type":"string"}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false},"uiHints":{"":{"label":"Telegram","help":"Telegram channel provider configuration including auth tokens, retry behavior, and message rendering controls. Use this section to tune bot behavior for Telegram-specific API semantics."},"customCommands":{"label":"Telegram Custom Commands","help":"Additional Telegram bot menu commands (merged with native; conflicts ignored)."},"botToken":{"label":"Telegram Bot Token","help":"Telegram bot token used to authenticate Bot API requests for this account/provider config. Use secret/env substitution and rotate tokens if exposure is suspected."},"dmPolicy":{"label":"Telegram DM Policy","help":"Direct message access control (\\"pairing\\" recommended). \\"open\\" requires channels.telegram.allowFrom=[\\"*\\"]."},"configWrites":{"label":"Telegram Config Writes","help":"Allow Telegram to write config in response to channel events/commands (default: true)."},"mentionPatterns":{"label":"Telegram Mention Pattern Policy","help":"Scopes configured groupChat mentionPatterns to selected Telegram group chat IDs or chatId:topic:threadId topic IDs. Native Telegram bot mentions still trigger even when regex patterns are denied."},"mentionPatterns.mode":{"label":"Telegram Mention Pattern Mode","help":"\\"allow\\" enables configured regex mention patterns unless denyIn matches; \\"deny\\" disables them unless allowIn matches."},"mentionPatterns.allowIn":{"label":"Telegram Mention Pattern Allowlist","help":"Telegram group chat IDs or chatId:topic:threadId topic IDs where configured regex mention patterns are enabled when mode is deny."},"mentionPatterns.denyIn":{"label":"Telegram Mention Pattern Denylist","help":"Telegram group chat IDs or chatId:topic:threadId topic IDs where configured regex mention patterns are disabled. Native bot mentions still trigger."},"commands.native":{"label":"Telegram Native Commands","help":"Override native commands for Telegram (bool or \\"auto\\")."},"commands.nativeSkills":{"label":"Telegram Native Skill Commands","help":"Override native skill commands for Telegram (bool or \\"auto\\")."},"streaming":{"label":"Telegram Streaming Mode","help":"Unified Telegram stream preview mode: \\"off\\" | \\"partial\\" | \\"block\\" | \\"progress\\" (default: \\"partial\\"). \\"progress\\" keeps a single editable progress draft until final delivery. Legacy boolean/streamMode keys are detected; run doctor --fix to migrate."},"streaming.mode":{"label":"Telegram Streaming Mode","help":"Canonical Telegram preview mode: \\"off\\" | \\"partial\\" | \\"block\\" | \\"progress\\" (default: \\"partial\\")."},"streaming.chunkMode":{"label":"Telegram Chunk Mode","help":"Chunking mode for outbound Telegram text delivery: \\"length\\" (default) or \\"newline\\"."},"richMessages":{"label":"Telegram Rich Messages","help":"Opt into Bot API 10.1 rich text sends and edits, including native tables and rich media. Default: false because some current Telegram clients render these messages as unsupported."},"streaming.block.enabled":{"label":"Telegram Block Streaming Enabled","help":"Enable chunked block-style Telegram preview delivery when channels.telegram.streaming.mode=\\"block\\"."},"streaming.block.coalesce":{"label":"Telegram Block Streaming Coalesce","help":"Merge streamed Telegram block replies before sending final delivery."},"streaming.preview.chunk.minChars":{"label":"Telegram Draft Chunk Min Chars","help":"Minimum chars before emitting a Telegram block preview chunk when channels.telegram.streaming.mode=\\"block\\"."},"streaming.preview.chunk.maxChars":{"label":"Telegram Draft Chunk Max Chars","help":"Target max size for a Telegram block preview chunk when channels.telegram.streaming.mode=\\"block\\"."},"streaming.preview.chunk.breakPreference":{"label":"Telegram Draft Chunk Break Preference","help":"Preferred breakpoints for Telegram draft chunks (paragraph | newline | sentence)."},"streaming.preview.toolProgress":{"label":"Telegram Draft Tool Progress","help":"Show tool/progress activity in the live draft preview message (default: true when preview streaming is active). Set false to keep tool updates out of the edited Telegram preview."},"streaming.preview.commandText":{"label":"Telegram Draft Command Text","help":"Command/exec detail in preview tool-progress lines: \\"raw\\" preserves released behavior; \\"status\\" shows only the tool label."},"streaming.progress.label":{"label":"Telegram Progress Label","help":"Initial progress draft title. Use \\"auto\\" for built-in single-word labels, a custom string, or false to hide the title."},"streaming.progress.labels":{"label":"Telegram Progress Label Pool","help":"Candidate labels for streaming.progress.label=\\"auto\\". Leave unset to use the built-in \\"Working\\" label."},"streaming.progress.maxLines":{"label":"Telegram Progress Max Lines","help":"Maximum number of compact progress lines to keep below the draft label (default: 8)."},"streaming.progress.maxLineChars":{"label":"Telegram Progress Max Line Chars","help":"Maximum characters per compact progress line before truncation (default: 120). Prose cuts at word boundaries; commands and paths keep useful suffixes."},"streaming.progress.toolProgress":{"label":"Telegram Progress Tool Lines","help":"Show compact tool/progress lines in progress draft mode (default: true). Set false to keep only the label until final delivery."},"streaming.progress.commandText":{"label":"Telegram Progress Command Text","help":"Command/exec detail in progress draft lines: \\"raw\\" preserves released behavior; \\"status\\" shows only the tool label."},"streaming.progress.commentary":{"label":"Telegram Progress Commentary","help":"Show assistant commentary/preamble text in the temporary progress draft. Final answer delivery is unchanged."},"network.autoSelectFamily":{"label":"Telegram autoSelectFamily","help":"Override Node autoSelectFamily for Telegram (true=enable, false=disable)."},"network.dangerouslyAllowPrivateNetwork":{"label":"Telegram Dangerously Allow Private Network","help":"Dangerous opt-in for trusted fake-IP or transparent-proxy environments where Telegram media downloads resolve api.telegram.org to private/internal/special-use addresses."},"silentErrorReplies":{"label":"Telegram Silent Error Replies","help":"When true, Telegram bot replies marked as errors are sent silently (no notification sound). Default: false."},"apiRoot":{"label":"Telegram API Root URL","help":"Custom Telegram Bot API root URL. Use the API root only (for example https://api.telegram.org), not a full /bot endpoint. Use for self-hosted Bot API servers (https://github.com/tdlib/telegram-bot-api) or reverse proxies in regions where api.telegram.org is blocked."},"trustedLocalFileRoots":{"label":"Telegram Trusted Local File Roots","help":"Trusted local filesystem roots for self-hosted Telegram Bot API file_path values. Exact in-root paths are read directly; container paths under /var/lib/telegram-bot-api can map into a host volume mount. Other absolute paths are rejected."},"autoTopicLabel":{"label":"Telegram Auto Topic Label","help":"Auto-rename DM forum topics on first message using LLM. Default: true. Set to false to disable, or use object form { enabled: true, prompt: \'...\' } for custom prompt."},"autoTopicLabel.enabled":{"label":"Telegram Auto Topic Label Enabled","help":"Whether auto topic labeling is enabled. Default: true."},"autoTopicLabel.prompt":{"label":"Telegram Auto Topic Label Prompt","help":"Custom prompt for LLM-based topic naming. The user message is appended after the prompt."},"capabilities.inlineButtons":{"label":"Telegram Inline Buttons","help":"Enable Telegram inline button components for supported command and interaction surfaces. Disable if your deployment needs plain-text-only compatibility behavior."},"execApprovals":{"label":"Telegram Exec Approvals","help":"Telegram-native exec approval routing and approver authorization. When unset, OpenClaw auto-enables DM-first native approvals if approvers can be resolved for the selected bot account."},"execApprovals.enabled":{"label":"Telegram Exec Approvals Enabled","help":"Controls Telegram native exec approvals for this account: unset or \\"auto\\" enables DM-first native approvals when approvers can be resolved, true forces native approvals on, and false disables them."},"execApprovals.approvers":{"label":"Telegram Exec Approval Approvers","help":"Telegram user IDs allowed to approve exec requests for this bot account. Use numeric Telegram user IDs. If you leave this unset, OpenClaw falls back to numeric owner IDs inferred from commands.ownerAllowFrom when possible."},"execApprovals.agentFilter":{"label":"Telegram Exec Approval Agent Filter","help":"Optional allowlist of agent IDs eligible for Telegram exec approvals, for example `[\\"main\\", \\"ops-agent\\"]`. Use this to keep approval prompts scoped to the agents you actually operate from Telegram."},"execApprovals.sessionFilter":{"label":"Telegram Exec Approval Session Filter","help":"Optional session-key filters matched as substring or regex-style patterns before Telegram approval routing is used. Use narrow patterns so Telegram approvals only appear for intended sessions."},"execApprovals.target":{"label":"Telegram Exec Approval Target","help":"Controls where Telegram approval prompts are sent: \\"dm\\" sends to approver DMs (default), \\"channel\\" sends to the originating Telegram chat/topic, and \\"both\\" sends to both. Channel delivery exposes the command text to the chat, so only use it in trusted groups/topics."},"threadBindings.enabled":{"label":"Telegram Thread Binding Enabled","help":"Enable Telegram conversation binding features (/focus, /unfocus, /agents, and /session idle|max-age). Overrides session.threadBindings.enabled when set."},"threadBindings.idleHours":{"label":"Telegram Thread Binding Idle Timeout (hours)","help":"Inactivity window in hours for Telegram bound sessions. Set 0 to disable idle auto-unfocus (default: 24). Overrides session.threadBindings.idleHours when set."},"threadBindings.maxAgeHours":{"label":"Telegram Thread Binding Max Age (hours)","help":"Optional hard max age in hours for Telegram bound sessions. Set 0 to disable hard cap (default: 0). Overrides session.threadBindings.maxAgeHours when set."},"threadBindings.spawnSessions":{"label":"Telegram Thread-Bound Session Spawn","help":"Allow sessions_spawn(thread=true) and ACP thread spawns to auto-bind Telegram current conversations when supported."},"threadBindings.defaultSpawnContext":{"label":"Telegram Thread Spawn Context","help":"Default native subagent context for thread-bound spawns. \\"fork\\" starts from the requester transcript; \\"isolated\\" starts clean. Default: \\"fork\\"."}}},{"pluginId":"tlon","channelId":"tlon","order":90,"label":"Tlon","description":"decentralized messaging on Urbit; install the plugin to enable.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"ship":{"type":"string","minLength":1},"url":{"type":"string"},"code":{"type":"string"},"network":{"type":"object","properties":{"dangerouslyAllowPrivateNetwork":{"type":"boolean"}},"additionalProperties":false},"groupChannels":{"type":"array","items":{"type":"string","minLength":1}},"dmAllowlist":{"type":"array","items":{"type":"string","minLength":1}},"groupInviteAllowlist":{"type":"array","items":{"type":"string","minLength":1}},"autoDiscoverChannels":{"type":"boolean"},"showModelSignature":{"type":"boolean"},"responsePrefix":{"type":"string"},"implicitMentions":{"type":"object","properties":{"replyToBot":{"type":"boolean"},"quotedBot":{"type":"boolean"},"threadParticipation":{"type":"boolean"}},"additionalProperties":false},"autoAcceptDmInvites":{"type":"boolean"},"autoAcceptGroupInvites":{"type":"boolean"},"ownerShip":{"type":"string","minLength":1},"authorization":{"type":"object","properties":{"channelRules":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"mode":{"type":"string","enum":["restricted","open"]},"allowedShips":{"type":"array","items":{"type":"string","minLength":1}}},"additionalProperties":false}}},"additionalProperties":false},"defa', - 'ultAuthorizedShips":{"type":"array","items":{"type":"string","minLength":1}},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"ship":{"type":"string","minLength":1},"url":{"type":"string"},"code":{"type":"string"},"network":{"type":"object","properties":{"dangerouslyAllowPrivateNetwork":{"type":"boolean"}},"additionalProperties":false},"groupChannels":{"type":"array","items":{"type":"string","minLength":1}},"dmAllowlist":{"type":"array","items":{"type":"string","minLength":1}},"groupInviteAllowlist":{"type":"array","items":{"type":"string","minLength":1}},"autoDiscoverChannels":{"type":"boolean"},"showModelSignature":{"type":"boolean"},"responsePrefix":{"type":"string"},"implicitMentions":{"type":"object","properties":{"replyToBot":{"type":"boolean"},"quotedBot":{"type":"boolean"},"threadParticipation":{"type":"boolean"}},"additionalProperties":false},"autoAcceptDmInvites":{"type":"boolean"},"autoAcceptGroupInvites":{"type":"boolean"},"ownerShip":{"type":"string","minLength":1}},"additionalProperties":false}}},"additionalProperties":false},"uiHints":{"implicitMentions":{"label":"Tlon Implicit Mentions","help":"Control which Tlon reply, quote, and thread-participation signals count as mentions. Unset flags preserve the channel defaults."},"implicitMentions.replyToBot":{"label":"Tlon Replies to Bot","help":"Treat replies to the bot\'s own messages as implicit mentions when the channel reports that signal."},"implicitMentions.quotedBot":{"label":"Tlon Quoted Bot Messages","help":"Treat messages quoting the bot as implicit mentions when the channel reports that signal."},"implicitMentions.threadParticipation":{"label":"Tlon Thread Participation","help":"Treat follow-ups in threads where the bot participated as implicit mentions when the channel reports that signal."}}},{"pluginId":"twitch","channelId":"twitch","aliases":["twitch-chat"],"channelEnvVars":["OPENCLAW_TWITCH_ACCESS_TOKEN"],"label":"Twitch","description":"Twitch chat integration","schema":{"$schema":"http://json-schema.org/draft-07/schema#","anyOf":[{"type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"defaultAccount":{"type":"string"},"username":{"type":"string"},"accessToken":{"type":"string"},"clientId":{"type":"string"},"channel":{"type":"string","minLength":1},"allowFrom":{"type":"array","items":{"type":"string"}},"allowedRoles":{"type":"array","items":{"type":"string","enum":["moderator","owner","vip","subscriber","all"]}},"requireMention":{"type":"boolean"},"responsePrefix":{"type":"string"},"clientSecret":{"type":"string"},"refreshToken":{"type":"string"},"expiresIn":{"anyOf":[{"type":"number"},{"type":"null"}]},"obtainmentTimestamp":{"type":"number"}},"required":["username","accessToken","channel"],"additionalProperties":false},{"type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"defaultAccount":{"type":"string"},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"username":{"type":"string"},"accessToken":{"type":"string"},"clientId":{"type":"string"},"channel":{"type":"string","minLength":1},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"type":"string"}},"allowedRoles":{"type":"array","items":{"type":"string","enum":["moderator","owner","vip","subscriber","all"]}},"requireMention":{"type":"boolean"},"responsePrefix":{"type":"string"},"clientSecret":{"type":"string"},"refreshToken":{"type":"string"},"expiresIn":{"anyOf":[{"type":"number"},{"type":"null"}]},"obtainmentTimestamp":{"type":"number"}},"required":["username","accessToken","channel"],"additionalProperties":false}}},"required":["accounts"],"additionalProperties":false}]}},{"pluginId":"whatsapp","channelId":"whatsapp","label":"WhatsApp","description":"works with your own number; recommend a separate phone + eSIM.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"capabilities":{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"enabled":{"type":"boolean"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"type":"string"}},"defaultTo":{"type":"string"},"groupAllowFrom":{"type":"array","items":{"type":"string"}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"mentionPatterns":{"type":"object","properties":{"mode":{"anyOf":[{"type":"string","const":"allow"},{"type":"string","const":"deny"}]},"allowIn":{"type":"array","items":{"type":"string"}},"denyIn":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"streaming":{"type":"object","properties":{"chunkMode":{"type":"string","enum":["length","newline"]},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false}},"additionalProperties":false},"heartbeatVisibility":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"responsePrefix":{"type":"string"},"mediaMaxMb":{"default":50,"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"sendReadReceipts":{"type":"boolean"},"selfChatMode":{"type":"boolean"},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"direct":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"systemPrompt":{"type":"string"}},"additionalProperties":false}},"reactionLevel":{"type":"string","enum":["off","ack","minimal","extensive"]},"debounceMs":{"default":0,"type":"integer","minimum":0,"maximum":9007199254740991},"pluginHooks":{"type":"object","properties":{"messageReceived":{"type":"boolean"}},"additionalProperties":false},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"capabilities":{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"enabled":{"type":"boolean"},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"type":"string"}},"defaultTo":{"type":"string"},"groupAllowFrom":{"type":"array","items":{"type":"string"}},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"mentionPatterns":{"type":"object","properties":{"mode":{"anyOf":[{"type":"string","const":"allow"},{"type":"string","const":"deny"}]},"allowIn":{"type":"array","items":{"type":"string"}},"denyIn":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"streaming":{"type":"object","properties":{"chunkMode":{"type":"string","enum":["length","newline"]},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false}},"additionalProperties":false},"heartbeatVisibility":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"responsePrefix":{"type":"string"},"mediaMaxMb":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"sendReadReceipts":{"type":"boolean"},"selfChatMode":{"type":"boolean"},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"direct":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"systemPrompt":{"type":"string"}},"additionalProperties":false}},"reactionLevel":{"type":"string","enum":["off","ack","minimal","extensive"]},"debounceMs":{"type":"integer","minimum":0,"maximum":9007199254740991},"pluginHooks":{"type":"object","properties":{"messageReceived":{"type":"boolean"}},"additionalProperties":false},"name":{"type":"string"},"authDir":{"type":"string"}},"additionalProperties":false}},"defaultAccount":{"type":"string"},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"},"sendMessage":{"type":"boolean"},"polls":{"type":"boolean"},"calls":{"type":"boolean"}},"additionalProperties":false}},"required":["dmPolicy","groupPolicy","mediaMaxMb","debounceMs"],"additionalProperties":false},"uiHints":{"":{"label":"WhatsApp","help":"WhatsApp channel provider configuration for access policy and message batching behavior. Use this section to tune responsiveness and direct-message routing safety for WhatsApp chats."},"dmPolicy":{"label":"WhatsApp DM Policy","help":"Direct message access control (\\"pairing\\" recommended). \\"open\\" requires channels.whatsapp.allowFrom=[\\"*\\"]."},"allowFrom":{"presentation":"phone-number"},"defaultTo":{"presentation":"phone-number"},"groupAllowFrom":{"presentation":"phone-number"},"accounts.*.allowFrom.*":{"presentation":"phone-number"},"accounts.*.defaultTo":{"presentation":"phone-number"},"accounts.*.groupAllowFrom.*":{"presentation":"phone-number"},"selfChatMode":{"label":"WhatsApp Self-Phone Mode","help":"Same-phone setup (bot uses your personal WhatsApp number)."},"debounceMs":{"label":"WhatsApp Message Debounce (ms)","help":"Debounce window (ms) for batching rapid consecutive messages from the same sender (0 to disable)."},"configWrites":{"label":"WhatsApp Config Writes","help":"Allow WhatsApp to write config in response to channel events/commands (default: true)."},"actions.calls":{"label":"WhatsApp Voice Calls","help":"Expose the experimental requester-bound WhatsApp voice-call tool. Default: false. Requires a separately paired MeowCaller CLI."},"mentionPatterns":{"label":"WhatsApp Mention Pattern Policy","help":"Scopes configured groupChat mentionPatterns to selected WhatsApp conversation IDs such as 123@g.us."},"mentionPatterns.mode":{"label":"WhatsApp Mention Pattern Mode","help":"\\"allow\\" enables configured regex mention patterns unless denyIn matches; \\"deny\\" disables them unless allowIn matches."},"mentionPatterns.allowIn":{"label":"WhatsApp Mention Pattern Allowlist","help":"WhatsApp conversation IDs where configured regex mention patterns are enabled when mode is deny."},"mentionPatterns.denyIn":{"label":"WhatsApp Mention Pattern Denylist","help":"WhatsApp conversation IDs where configured regex mention patterns are disabled."}},"unsupportedSecretRefSurfacePatterns":["channels.whatsapp.accounts.*.creds.json","channels.whatsapp.creds.json"]},{"pluginId":"zalo","channelId":"zalo","aliases":["zl"],"order":80,"channelEnvVars":["ZALO_BOT_TOKEN","ZALO_WEBHOOK_SECRET"],"label":"Zalo","description":"Vietnam-focused messaging platform with Bot API.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"botToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"tokenFile":{"type":"string"},"webhookUrl":{"type":"string"},"webhookSecret":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","prop', - 'erties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"webhookPath":{"type":"string"},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"mediaMaxMb":{"type":"number"},"proxy":{"type":"string"},"responsePrefix":{"type":"string"},"accounts":{"type":"object","properties":{},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"botToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"tokenFile":{"type":"string"},"webhookUrl":{"type":"string"},"webhookSecret":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"webhookPath":{"type":"string"},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"mediaMaxMb":{"type":"number"},"proxy":{"type":"string"},"responsePrefix":{"type":"string"}},"additionalProperties":false}},"defaultAccount":{"type":"string"}},"additionalProperties":false}},{"pluginId":"zalouser","channelId":"zalouser","aliases":["zlu"],"order":85,"channelEnvVars":["ZALOUSER_PROFILE","ZCA_PROFILE"],"label":"Zalo Personal","description":"Zalo personal account via QR code login.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"profile":{"type":"string"},"dangerouslyAllowNameMatching":{"type":"boolean"},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"groups":{"type":"object","properties":{},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"enabled":{"type":"boolean"}},"additionalProperties":false}},"messagePrefix":{"type":"string"},"responsePrefix":{"type":"string"},"accounts":{"type":"object","properties":{},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"profile":{"type":"string"},"dangerouslyAllowNameMatching":{"type":"boolean"},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"groups":{"type":"object","properties":{},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"enabled":{"type":"boolean"}},"additionalProperties":false}},"messagePrefix":{"type":"string"},"responsePrefix":{"type":"string"}},"required":["groupPolicy"],"additionalProperties":false}},"defaultAccount":{"type":"string"}},"required":["groupPolicy"],"additionalProperties":false}}]', + 'ultAuthorizedShips":{"type":"array","items":{"type":"string","minLength":1}},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"ship":{"type":"string","minLength":1},"url":{"type":"string"},"code":{"type":"string"},"network":{"type":"object","properties":{"dangerouslyAllowPrivateNetwork":{"type":"boolean"}},"additionalProperties":false},"groupChannels":{"type":"array","items":{"type":"string","minLength":1}},"dmAllowlist":{"type":"array","items":{"type":"string","minLength":1}},"groupInviteAllowlist":{"type":"array","items":{"type":"string","minLength":1}},"autoDiscoverChannels":{"type":"boolean"},"showModelSignature":{"type":"boolean"},"responsePrefix":{"type":"string"},"implicitMentions":{"type":"object","properties":{"replyToBot":{"type":"boolean"},"quotedBot":{"type":"boolean"},"threadParticipation":{"type":"boolean"}},"additionalProperties":false},"autoAcceptDmInvites":{"type":"boolean"},"autoAcceptGroupInvites":{"type":"boolean"},"ownerShip":{"type":"string","minLength":1}},"additionalProperties":false}}},"additionalProperties":false},"uiHints":{"implicitMentions":{"label":"Tlon Implicit Mentions","help":"Control which Tlon reply, quote, and thread-participation signals count as mentions. Unset flags preserve the channel defaults."},"implicitMentions.replyToBot":{"label":"Tlon Replies to Bot","help":"Treat replies to the bot\'s own messages as implicit mentions when the channel reports that signal."},"implicitMentions.quotedBot":{"label":"Tlon Quoted Bot Messages","help":"Treat messages quoting the bot as implicit mentions when the channel reports that signal."},"implicitMentions.threadParticipation":{"label":"Tlon Thread Participation","help":"Treat follow-ups in threads where the bot participated as implicit mentions when the channel reports that signal."}}},{"pluginId":"twitch","channelId":"twitch","aliases":["twitch-chat"],"channelEnvVars":["OPENCLAW_TWITCH_ACCESS_TOKEN"],"label":"Twitch","description":"Twitch chat integration","schema":{"$schema":"http://json-schema.org/draft-07/schema#","anyOf":[{"type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"defaultAccount":{"type":"string"},"username":{"type":"string"},"accessToken":{"type":"string"},"clientId":{"type":"string"},"channel":{"type":"string","minLength":1},"allowFrom":{"type":"array","items":{"type":"string"}},"allowedRoles":{"type":"array","items":{"type":"string","enum":["moderator","owner","vip","subscriber","all"]}},"requireMention":{"type":"boolean"},"responsePrefix":{"type":"string"},"clientSecret":{"type":"string"},"refreshToken":{"type":"string"},"expiresIn":{"anyOf":[{"type":"number"},{"type":"null"}]},"obtainmentTimestamp":{"type":"number"}},"required":["username","accessToken","channel"],"additionalProperties":false},{"type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"defaultAccount":{"type":"string"},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"username":{"type":"string"},"accessToken":{"type":"string"},"clientId":{"type":"string"},"channel":{"type":"string","minLength":1},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"type":"string"}},"allowedRoles":{"type":"array","items":{"type":"string","enum":["moderator","owner","vip","subscriber","all"]}},"requireMention":{"type":"boolean"},"responsePrefix":{"type":"string"},"clientSecret":{"type":"string"},"refreshToken":{"type":"string"},"expiresIn":{"anyOf":[{"type":"number"},{"type":"null"}]},"obtainmentTimestamp":{"type":"number"}},"required":["username","accessToken","channel"],"additionalProperties":false}}},"required":["accounts"],"additionalProperties":false}]}},{"pluginId":"whatsapp","channelId":"whatsapp","label":"WhatsApp","description":"works with your own number; recommend a separate phone + eSIM.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"capabilities":{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"enabled":{"type":"boolean"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"type":"string"}},"defaultTo":{"type":"string"},"groupAllowFrom":{"type":"array","items":{"type":"string"}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"mentionPatterns":{"type":"object","properties":{"mode":{"anyOf":[{"type":"string","const":"allow"},{"type":"string","const":"deny"}]},"allowIn":{"type":"array","items":{"type":"string"}},"denyIn":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"streaming":{"type":"object","properties":{"chunkMode":{"type":"string","enum":["length","newline"]},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false}},"additionalProperties":false},"heartbeatVisibility":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"responsePrefix":{"type":"string"},"mediaMaxMb":{"default":50,"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"sendReadReceipts":{"type":"boolean"},"selfChatMode":{"type":"boolean"},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"direct":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"systemPrompt":{"type":"string"}},"additionalProperties":false}},"reactionLevel":{"type":"string","enum":["off","ack","minimal","extensive"]},"pluginHooks":{"type":"object","properties":{"messageReceived":{"type":"boolean"}},"additionalProperties":false},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"capabilities":{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"enabled":{"type":"boolean"},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"type":"string"}},"defaultTo":{"type":"string"},"groupAllowFrom":{"type":"array","items":{"type":"string"}},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"mentionPatterns":{"type":"object","properties":{"mode":{"anyOf":[{"type":"string","const":"allow"},{"type":"string","const":"deny"}]},"allowIn":{"type":"array","items":{"type":"string"}},"denyIn":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"streaming":{"type":"object","properties":{"chunkMode":{"type":"string","enum":["length","newline"]},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false}},"additionalProperties":false},"heartbeatVisibility":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"responsePrefix":{"type":"string"},"mediaMaxMb":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"sendReadReceipts":{"type":"boolean"},"selfChatMode":{"type":"boolean"},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"direct":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"systemPrompt":{"type":"string"}},"additionalProperties":false}},"reactionLevel":{"type":"string","enum":["off","ack","minimal","extensive"]},"pluginHooks":{"type":"object","properties":{"messageReceived":{"type":"boolean"}},"additionalProperties":false},"name":{"type":"string"},"authDir":{"type":"string"}},"additionalProperties":false}},"defaultAccount":{"type":"string"},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"},"sendMessage":{"type":"boolean"},"polls":{"type":"boolean"},"calls":{"type":"boolean"}},"additionalProperties":false}},"required":["dmPolicy","groupPolicy","mediaMaxMb"],"additionalProperties":false},"uiHints":{"":{"label":"WhatsApp","help":"WhatsApp channel provider configuration for access policy and direct-message routing safety."},"dmPolicy":{"label":"WhatsApp DM Policy","help":"Direct message access control (\\"pairing\\" recommended). \\"open\\" requires channels.whatsapp.allowFrom=[\\"*\\"]."},"allowFrom":{"presentation":"phone-number"},"defaultTo":{"presentation":"phone-number"},"groupAllowFrom":{"presentation":"phone-number"},"accounts.*.allowFrom.*":{"presentation":"phone-number"},"accounts.*.defaultTo":{"presentation":"phone-number"},"accounts.*.groupAllowFrom.*":{"presentation":"phone-number"},"selfChatMode":{"label":"WhatsApp Self-Phone Mode","help":"Same-phone setup (bot uses your personal WhatsApp number)."},"configWrites":{"label":"WhatsApp Config Writes","help":"Allow WhatsApp to write config in response to channel events/commands (default: true)."},"actions.calls":{"label":"WhatsApp Voice Calls","help":"Expose the experimental requester-bound WhatsApp voice-call tool. Default: false. Requires a separately paired MeowCaller CLI."},"mentionPatterns":{"label":"WhatsApp Mention Pattern Policy","help":"Scopes configured groupChat mentionPatterns to selected WhatsApp conversation IDs such as 123@g.us."},"mentionPatterns.mode":{"label":"WhatsApp Mention Pattern Mode","help":"\\"allow\\" enables configured regex mention patterns unless denyIn matches; \\"deny\\" disables them unless allowIn matches."},"mentionPatterns.allowIn":{"label":"WhatsApp Mention Pattern Allowlist","help":"WhatsApp conversation IDs where configured regex mention patterns are enabled when mode is deny."},"mentionPatterns.denyIn":{"label":"WhatsApp Mention Pattern Denylist","help":"WhatsApp conversation IDs where configured regex mention patterns are disabled."}},"unsupportedSecretRefSurfacePatterns":["channels.whatsapp.accounts.*.creds.json","channels.whatsapp.creds.json"]},{"pluginId":"zalo","channelId":"zalo","aliases":["zl"],"order":80,"channelEnvVars":["ZALO_BOT_TOKEN","ZALO_WEBHOOK_SECRET"],"label":"Zalo","description":"Vietnam-focused messaging platform with Bot API.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"botToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"tokenFile":{"type":"string"},"webhookUrl":{"type":"string"},"webhookSecret":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"webhookPath":{"type":"string"},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy"', + ':{"type":"string","enum":["open","disabled","allowlist"]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"mediaMaxMb":{"type":"number"},"proxy":{"type":"string"},"responsePrefix":{"type":"string"},"accounts":{"type":"object","properties":{},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"botToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"tokenFile":{"type":"string"},"webhookUrl":{"type":"string"},"webhookSecret":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"webhookPath":{"type":"string"},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"mediaMaxMb":{"type":"number"},"proxy":{"type":"string"},"responsePrefix":{"type":"string"}},"additionalProperties":false}},"defaultAccount":{"type":"string"}},"additionalProperties":false}},{"pluginId":"zalouser","channelId":"zalouser","aliases":["zlu"],"order":85,"channelEnvVars":["ZALOUSER_PROFILE","ZCA_PROFILE"],"label":"Zalo Personal","description":"Zalo personal account via QR code login.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"profile":{"type":"string"},"dangerouslyAllowNameMatching":{"type":"boolean"},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"groups":{"type":"object","properties":{},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"enabled":{"type":"boolean"}},"additionalProperties":false}},"messagePrefix":{"type":"string"},"responsePrefix":{"type":"string"},"accounts":{"type":"object","properties":{},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"profile":{"type":"string"},"dangerouslyAllowNameMatching":{"type":"boolean"},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"groups":{"type":"object","properties":{},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"enabled":{"type":"boolean"}},"additionalProperties":false}},"messagePrefix":{"type":"string"},"responsePrefix":{"type":"string"}},"required":["groupPolicy"],"additionalProperties":false}},"defaultAccount":{"type":"string"}},"required":["groupPolicy"],"additionalProperties":false}}]', ].join(""); export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = JSON.parse( diff --git a/src/config/config-misc.test.ts b/src/config/config-misc.test.ts index 890150e4d541..e778a056ac2b 100644 --- a/src/config/config-misc.test.ts +++ b/src/config/config-misc.test.ts @@ -567,52 +567,6 @@ describe("gateway.controlUi.sessionObserver", () => { }); }); -describe("ui.prefs.chatMessageMaxWidth", () => { - it("accepts constrained CSS width values", () => { - for (const value of ["960px", "82%", "min(1280px, 82%)", "calc(100% - 2rem)"]) { - const result = OpenClawSchema.safeParse({ - ui: { - prefs: { - chatMessageMaxWidth: value, - }, - }, - }); - expect(result.success).toBe(true); - if (result.success) { - expect(result.data.ui?.prefs?.chatMessageMaxWidth).toBe(value); - } - } - }); - - it("normalizes whitespace around the width value", () => { - const result = OpenClawSchema.safeParse({ - ui: { - prefs: { - chatMessageMaxWidth: " min(1280px, 82%) ", - }, - }, - }); - - expect(result.success).toBe(true); - if (result.success) { - expect(result.data.ui?.prefs?.chatMessageMaxWidth).toBe("min(1280px, 82%)"); - } - }); - - it("rejects arbitrary CSS injection", () => { - for (const value of ["url(https://example.com/x)", "960px; color: red", "var(--x)"]) { - const result = OpenClawSchema.safeParse({ - ui: { - prefs: { - chatMessageMaxWidth: value, - }, - }, - }); - expect(result.success).toBe(false); - } - }); -}); - describe("plugins.entries.*.hooks", () => { it.each([true, false])("accepts allowConversationAccess=%s", (allowConversationAccess) => { const result = OpenClawSchema.safeParse({ diff --git a/src/config/config.schema-regressions.test.ts b/src/config/config.schema-regressions.test.ts index 880b450e4ade..647d60a2f192 100644 --- a/src/config/config.schema-regressions.test.ts +++ b/src/config/config.schema-regressions.test.ts @@ -146,28 +146,12 @@ describe("config schema regressions", () => { expect(res.ok).toBe(false); }); - it("accepts 1M-character tool result caps for long-context agents", () => { - const res = validateConfigObject({ - agents: { - defaults: { - contextLimits: { - toolResultMaxChars: 1_000_000, - }, - }, - }, - }); - - expect(res.ok).toBe(true); - }); - it("accepts agents.defaults and agents.entries contextLimits overrides", () => { const res = validateConfigObject({ agents: { defaults: { contextLimits: { memoryGetMaxChars: 20_000, - memoryGetDefaultLines: 180, - toolResultMaxChars: 24_000, postCompactionMaxChars: 4_000, }, }, diff --git a/src/config/control-ui-css.ts b/src/config/control-ui-css.ts deleted file mode 100644 index 94212b2f7b7c..000000000000 --- a/src/config/control-ui-css.ts +++ /dev/null @@ -1,64 +0,0 @@ -// Normalizes Control UI CSS config values before runtime use. -const CSS_WIDTH_KEYWORDS = new Set(["none", "min-content", "max-content"]); -const CSS_WIDTH_FUNCTIONS = new Set(["calc", "clamp", "fit-content", "max", "min"]); -const CSS_WIDTH_UNITS = new Set(["ch", "em", "rem", "vh", "vmax", "vmin", "vw", "px"]); -const CSS_WIDTH_ALLOWED_CHARS = /^[0-9A-Za-z.%+\-*/(),\s]+$/; -const CSS_WIDTH_IDENTIFIER_RE = /[A-Za-z][A-Za-z0-9-]*/g; -const CSS_WIDTH_SIMPLE_RE = /^(?:\d+(?:\.\d+)?|\.\d+)(?:px|rem|em|ch|vw|vh|vmin|vmax|%)$/i; -const CSS_WIDTH_MAX_LENGTH = 96; - -function hasBalancedParentheses(value: string): boolean { - let depth = 0; - for (const char of value) { - if (char === "(") { - depth++; - } else if (char === ")") { - depth--; - if (depth < 0) { - return false; - } - } - } - return depth === 0; -} - -function hasAllowedIdentifiers(value: string): boolean { - for (const match of value.matchAll(CSS_WIDTH_IDENTIFIER_RE)) { - const identifier = match[0].toLowerCase(); - // Keep the config parser to width-only CSS tokens; reject arbitrary identifiers/properties. - if ( - !CSS_WIDTH_FUNCTIONS.has(identifier) && - !CSS_WIDTH_KEYWORDS.has(identifier) && - !CSS_WIDTH_UNITS.has(identifier) - ) { - return false; - } - } - return true; -} - -/** Normalizes operator-provided Control UI chat max-width CSS values before validation. */ -export function normalizeControlUiChatMessageMaxWidth(value: string): string { - return value.trim().replace(/\s+/g, " "); -} - -/** Validates the constrained CSS width grammar accepted by `ui.prefs.chatMessageMaxWidth`. */ -export function isValidControlUiChatMessageMaxWidth(value: string): boolean { - const normalized = normalizeControlUiChatMessageMaxWidth(value); - if (normalized.length === 0 || normalized.length > CSS_WIDTH_MAX_LENGTH) { - return false; - } - if (CSS_WIDTH_KEYWORDS.has(normalized.toLowerCase())) { - return true; - } - if (CSS_WIDTH_SIMPLE_RE.test(normalized)) { - return true; - } - if (!CSS_WIDTH_ALLOWED_CHARS.test(normalized)) { - return false; - } - if (!hasBalancedParentheses(normalized) || !hasAllowedIdentifiers(normalized)) { - return false; - } - return /^(?:calc|clamp|fit-content|max|min)\(.+\)$/i.test(normalized); -} diff --git a/src/config/dead-config-keys.test.ts b/src/config/dead-config-keys.test.ts index 02ba549f7602..8e591ace4a71 100644 --- a/src/config/dead-config-keys.test.ts +++ b/src/config/dead-config-keys.test.ts @@ -82,6 +82,9 @@ describe("dead config keys", () => { "gateway.nodes.allowCommands", "gateway.nodes.denyCommands", "gateway.controlUi.chatMessageMaxWidth", + "ui.prefs.chatMessageMaxWidth", + "ui.prefs.textScale", + "ui.prefs.sidebarLiveActivity", "cron.failureDestination", "channels.defaults.heartbeat", "tools.loopDetection.historySize", @@ -117,6 +120,12 @@ describe("dead config keys", () => { "agents.defaults.runRetries", "agents.entries.test.memory.search.chunking", "agents.entries.test.runRetries", + "agents.entries.test.typingIntervalSeconds", + "agents.defaults.contextLimits.memoryGetDefaultLines", + "agents.defaults.contextLimits.toolResultMaxChars", + "agents.entries.test.contextLimits.memoryGetDefaultLines", + "agents.entries.test.contextLimits.toolResultMaxChars", + "skills.load.watchDebounceMs", "gateway.handshakeTimeoutMs", "gateway.channelHealthCheckMinutes", "gateway.channelStaleEventThresholdMinutes", @@ -162,6 +171,8 @@ describe("dead config keys", () => { "channels.telegram.errorCooldownMs", "channels.telegram.accounts.work.timeoutSeconds", "channels.telegram.accounts.work.retry", + "channels.whatsapp.debounceMs", + "channels.whatsapp.accounts.work.debounceMs", "channels.telegram.groups.-100.errorCooldownMs", "channels.telegram.groups.-100.topics.1.errorCooldownMs", "channels.discord.gatewayInfoTimeoutMs", diff --git a/src/config/schema.base.generated.test.ts b/src/config/schema.base.generated.test.ts index 217fd1ed4a5c..a6e04eaaf69a 100644 --- a/src/config/schema.base.generated.test.ts +++ b/src/config/schema.base.generated.test.ts @@ -187,8 +187,6 @@ describe("base config schema", () => { expect(codexUserLocation?.properties?.region?.type).toBe("string"); expect(codexUserLocation?.properties?.city?.type).toBe("string"); expect(codexUserLocation?.properties?.timezone?.type).toBe("string"); - - expect(schemaAt(BASE_SCHEMA, ["ui", "prefs", "chatMessageMaxWidth"])?.type).toBe("string"); }); it("does not publish metadata-only composition branches", () => { diff --git a/src/config/schema.help.agents.ts b/src/config/schema.help.agents.ts index 30fb1a43c598..6a938abff50a 100644 --- a/src/config/schema.help.agents.ts +++ b/src/config/schema.help.agents.ts @@ -180,11 +180,9 @@ export const AGENT_FIELD_HELP: Record = { "agents.defaults.typingMode": 'Controls when typing starts for agents: "never", "instant", "thinking", or "message". Per-agent typingMode overrides this default.', "agents.defaults.typingIntervalSeconds": - "Controls typing-indicator keepalive cadence in seconds. Per-agent typingIntervalSeconds overrides this default.", + "Controls typing-indicator keepalive cadence in seconds for every agent. Increase it to reduce update frequency across all typing-capable channels.", "agents.entries.*.typingMode": "Overrides the default typing start policy for one agent without changing other agents.", - "agents.entries.*.typingIntervalSeconds": - "Overrides the default typing-indicator keepalive cadence for one agent. Use this when one agent needs a different refresh interval from the shared default.", commands: "Controls chat command surfaces, owner gating, and elevated command access behavior across providers. Keep defaults unless you need stricter operator controls or broader command availability.", "commands.native": diff --git a/src/config/schema.help.core.ts b/src/config/schema.help.core.ts index 8124c4bd514f..92497aef7ed9 100644 --- a/src/config/schema.help.core.ts +++ b/src/config/schema.help.core.ts @@ -273,10 +273,6 @@ export const CORE_FIELD_HELP: Record = { "Focused per-agent-context budget defaults for selected high-volume excerpts and injected prompt blocks. Use this to tune bounded read/injection sizes without reopening any unbounded call paths.", "agents.defaults.contextLimits.memoryGetMaxChars": "Default max characters returned by memory_get before truncation metadata and continuation notice are added. Increase to approximate older larger excerpts, but keep it bounded.", - "agents.defaults.contextLimits.memoryGetDefaultLines": - "Default memory_get line window used when requests omit lines. This controls how many source lines are selected before the max-char cap is applied.", - "agents.defaults.contextLimits.toolResultMaxChars": - "Advanced ceiling for a single live tool result before truncation. Leave unset to use the model-context auto cap; explicit values affect both persisted live tool-result writes and overflow-recovery truncation heuristics.", "agents.defaults.contextLimits.postCompactionMaxChars": "Default max characters retained from AGENTS.md during post-compaction context refresh injection. Lower this to make compaction recovery cheaper, or raise it for agents that depend on longer startup guidance.", "agents.entries": @@ -289,10 +285,6 @@ export const CORE_FIELD_HELP: Record = { "Optional per-agent overrides for the focused context budget knobs. Omitted fields inherit agents.defaults.contextLimits.", "agents.entries.*.contextLimits.memoryGetMaxChars": "Per-agent override for the default memory_get max character budget.", - "agents.entries.*.contextLimits.memoryGetDefaultLines": - "Per-agent override for the default memory_get line window when lines is omitted.", - "agents.entries.*.contextLimits.toolResultMaxChars": - "Per-agent advanced ceiling for the live tool-result max character budget. Omit to inherit defaults or the model-context auto cap.", "agents.entries.*.contextLimits.postCompactionMaxChars": "Per-agent override for the post-compaction AGENTS.md excerpt budget.", "agents.entries.*.thinkingDefault": diff --git a/src/config/schema.help.quality.test-fixtures.ts b/src/config/schema.help.quality.test-fixtures.ts index 68b6cc29ce68..73047f32cc2c 100644 --- a/src/config/schema.help.quality.test-fixtures.ts +++ b/src/config/schema.help.quality.test-fixtures.ts @@ -110,7 +110,6 @@ export const TARGET_KEYS = [ "agents.defaults.typingMode", "agents.defaults.typingIntervalSeconds", "agents.entries.*.typingMode", - "agents.entries.*.typingIntervalSeconds", "session.mainKey", "session.sendPolicy", "session.sendPolicy.default", @@ -498,7 +497,6 @@ export const FINAL_BACKLOG_TARGET_KEYS = [ "skills.load.allowSymlinkTargets", "skills.load.extraDirs", "skills.load.watch", - "skills.load.watchDebounceMs", "skills.workshop.allowSymlinkTargetWrites", "ui.assistant.avatar", "ui.assistant.name", diff --git a/src/config/schema.help.runtime.ts b/src/config/schema.help.runtime.ts index 34f13cee0c68..ffda503ceffa 100644 --- a/src/config/schema.help.runtime.ts +++ b/src/config/schema.help.runtime.ts @@ -188,8 +188,6 @@ export const RUNTIME_FIELD_HELP: Record = { 'Iframe sandbox policy for hosted Control UI embeds. "strict" disables scripts, "scripts" allows interactive embeds while keeping origin isolation (default), and "trusted" adds `allow-same-origin` for same-site documents that intentionally need stronger privileges.', "gateway.controlUi.allowExternalEmbedUrls": "DANGEROUS toggle that allows hosted embeds to load absolute external http(s) URLs. Keep this off unless your Control UI intentionally embeds trusted third-party pages; hosted /__openclaw__/canvas and /__openclaw__/a2ui documents do not need it.", - "ui.prefs.chatMessageMaxWidth": - 'Optional CSS max-width for the centered Control UI chat transcript, for example "960px", "82%", or "min(1280px, 82%)". Values are validated against a constrained width grammar before reaching the browser.', "gateway.controlUi.allowedOrigins": 'Allowed browser origins for Control UI/WebChat websocket connections (full origins only, e.g. https://control.example.com). Required for non-loopback Control UI deployments unless dangerous Host-header fallback is explicitly enabled. Setting ["*"] means allow any browser origin and should be avoided outside tightly controlled local testing.', "gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback": @@ -448,8 +446,6 @@ export const RUNTIME_FIELD_HELP: Record = { "Trusted real target roots that skill symlinks may resolve into when they sit outside their configured source root. Keep this narrow, such as a sibling repo skills directory.", "skills.load.watch": "Enable filesystem watching for skill-definition changes so updates can be applied without full process restart. Keep enabled in development workflows and disable in immutable production images.", - "skills.load.watchDebounceMs": - "Debounce window in milliseconds for coalescing rapid skill file changes before reload logic runs. Increase to reduce reload churn on frequent writes, or lower for faster edit feedback.", "skills.workshop.allowSymlinkTargetWrites": "Allows Skill Workshop apply to write through symlinked workspace skill paths whose real target is already trusted by skills.load.allowSymlinkTargets. Keep disabled unless operators intentionally want generated proposal applies to mutate those shared skill roots.", approvals: diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index 499e7cee3d4b..6df920cbf446 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -83,16 +83,12 @@ export const FIELD_LABELS: Record = { "agents.defaults": "Agent Defaults", "agents.defaults.contextLimits": "Default Context Limits", "agents.defaults.contextLimits.memoryGetMaxChars": "Default memory_get Max Chars", - "agents.defaults.contextLimits.memoryGetDefaultLines": "Default memory_get Line Window", - "agents.defaults.contextLimits.toolResultMaxChars": "Default Tool Result Max Chars", "agents.defaults.contextLimits.postCompactionMaxChars": "Default Post-compaction Max Chars", "agents.entries": "Agent List", "agents.entries.*.skillsLimits": "Agent Skills Limits", "agents.entries.*.skillsLimits.maxSkillsPromptChars": "Agent Skills Prompt Max Chars", "agents.entries.*.contextLimits": "Agent Context Limits", "agents.entries.*.contextLimits.memoryGetMaxChars": "Agent memory_get Max Chars", - "agents.entries.*.contextLimits.memoryGetDefaultLines": "Agent memory_get Line Window", - "agents.entries.*.contextLimits.toolResultMaxChars": "Agent Tool Result Max Chars", "agents.entries.*.contextLimits.postCompactionMaxChars": "Agent Post-compaction Max Chars", "agents.entries.*.models": "Agent Model Overrides", "agents.entries.*.modelPolicy": "Agent Model Policy", @@ -343,7 +339,6 @@ export const FIELD_LABELS: Record = { "gateway.controlUi.root": "Control UI Assets Root", "gateway.controlUi.embedSandbox": "Control UI Embed Sandbox Mode", "gateway.controlUi.allowExternalEmbedUrls": "Allow External Control UI Embed URLs", - "ui.prefs.chatMessageMaxWidth": "Control UI Chat Message Max Width", "gateway.controlUi.allowedOrigins": "Control UI Allowed Origins", "gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback": "Dangerously Allow Host-Header Origin Fallback", @@ -416,7 +411,6 @@ export const FIELD_LABELS: Record = { "skills.load.extraDirs": "Extra Skill Directories", "skills.load.allowSymlinkTargets": "Allowed Skill Symlink Targets", "skills.load.watch": "Watch Skills", - "skills.load.watchDebounceMs": "Skills Watch Debounce (ms)", "skills.workshop.allowSymlinkTargetWrites": "Allow Skill Workshop Symlink Writes", "agents.defaults.skills": "Skills", "agents.defaults.subagents.delegationMode": "Sub-agent Delegation Mode", @@ -608,7 +602,6 @@ export const FIELD_LABELS: Record = { "agents.defaults.typingMode": "Typing Mode", "agents.defaults.typingIntervalSeconds": "Typing Interval (Seconds)", "agents.entries.*.typingMode": "Agent Typing Mode", - "agents.entries.*.typingIntervalSeconds": "Agent Typing Interval (Seconds)", "agents.entries.*.sandbox.browser.network": "Agent Sandbox Browser Network", "agents.entries.*.sandbox.browser.cdpSourceRange": "Agent Sandbox Browser CDP Source Range", "agents.entries.*.sandbox.docker.dangerouslyAllowContainerNamespaceJoin": diff --git a/src/config/schema.tags.ts b/src/config/schema.tags.ts index 15cbc693ee90..e25d4d006b42 100644 --- a/src/config/schema.tags.ts +++ b/src/config/schema.tags.ts @@ -48,7 +48,6 @@ const TAG_OVERRIDES: Record = { "gateway.push.apns.relay.baseUrl": ["network", "advanced"], "gateway.controlUi.embedSandbox": ["security", "access", "advanced"], "gateway.controlUi.allowExternalEmbedUrls": ["security", "access", "network", "advanced"], - "ui.prefs.chatMessageMaxWidth": ["advanced"], "gateway.controlUi.toolTitles": ["advanced"], "gateway.controlUi.sessionObserver": ["advanced"], "gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback": [ diff --git a/src/config/schema.tiers.ts b/src/config/schema.tiers.ts index e499d7df08eb..f61e3b5e770e 100644 --- a/src/config/schema.tiers.ts +++ b/src/config/schema.tiers.ts @@ -169,13 +169,12 @@ tts.providers.* tts.providers.*.apiKey ui.assistant.avatar ui.assistant.name ui.prefs.chatFollowUpMode ui.prefs.chatPersistCommentary ui.prefs.chatSendShortcut ui.prefs.chatShowThinking ui.prefs.chatShowToolCalls ui.prefs.locale ui.prefs.showAdvancedSettings -ui.prefs.textScale ui.prefs.theme ui.prefs.themeMode update.auto.enabled update.channel +ui.prefs.theme ui.prefs.themeMode update.auto.enabled update.channel wizard.accessMode wizard.appRecommendations ` .trim() .split(/\s+/); -const NUMERIC_COMMON_EXCEPTIONS = new Set(["ui.prefs.textScale"]); const ADVANCED_TUNING_PATHS = new Set(["agents.defaults.heartbeat.every"]); const CHANNEL_KERNEL_TIER_PREFIXES = ["channels.defaults", "channels.modelByChannel"] as const; @@ -240,7 +239,7 @@ function isNumericSchema(schema: JsonSchemaObject): boolean { } function isNumericCommonException(path: string): boolean { - return NUMERIC_COMMON_EXCEPTIONS.has(path) || splitPath(path).at(-1) === "port"; + return splitPath(path).at(-1) === "port"; } function resolveTier(params: { inheritedTier: boolean; ownTier: boolean | undefined }): boolean { diff --git a/src/config/types.agent-defaults.ts b/src/config/types.agent-defaults.ts index 6d9755cfb676..e0f11e86f127 100644 --- a/src/config/types.agent-defaults.ts +++ b/src/config/types.agent-defaults.ts @@ -100,10 +100,6 @@ export type AgentStartupContextConfig = { export type AgentContextLimitsConfig = { /** Default max chars returned by memory_get before truncation metadata/notice (default: 12000). */ memoryGetMaxChars?: number; - /** Default line window for memory_get when lines is omitted (default: 120). */ - memoryGetDefaultLines?: number; - /** Advanced max chars for a single live tool result; unset uses model-context auto cap. */ - toolResultMaxChars?: number; /** Max chars retained from post-compaction AGENTS.md context injection (default: 1800). */ postCompactionMaxChars?: number; }; diff --git a/src/config/types.agents.ts b/src/config/types.agents.ts index 831e1bcaa4b8..940f67751fdb 100644 --- a/src/config/types.agents.ts +++ b/src/config/types.agents.ts @@ -130,8 +130,6 @@ export type AgentConfig = { humanDelay?: HumanDelayConfig; /** Optional per-agent typing start policy. */ typingMode?: AgentDefaultsConfig["typingMode"]; - /** Optional per-agent typing keepalive cadence. */ - typingIntervalSeconds?: AgentDefaultsConfig["typingIntervalSeconds"]; /** Optional per-agent TTS overrides, deep-merged over top-level tts. */ /** Per-agent TTS overrides. prefsPath remains scoped because agents may use distinct preference stores. */ tts?: TtsConfig & { prefsPath?: string }; diff --git a/src/config/types.openclaw.ts b/src/config/types.openclaw.ts index 79b61b1c838a..cfa43442275f 100644 --- a/src/config/types.openclaw.ts +++ b/src/config/types.openclaw.ts @@ -160,8 +160,6 @@ export type OpenClawConfig = { theme?: "claw" | "knot" | "dash" | "custom"; /** Light/dark preference. */ themeMode?: "light" | "dark" | "system"; - /** Text scale percentage stop. */ - textScale?: 90 | 100 | 110 | 125 | 140; /** BCP 47 UI locale, e.g. "en" or "pt-BR". */ locale?: string; /** Show model thinking output in chat. */ @@ -176,10 +174,6 @@ export type OpenClawConfig = { chatFollowUpMode?: "steer" | "queue"; /** Ordered page and pinned-session entries shown in the Control UI sidebar. */ sidebarEntries?: string[]; - /** Maximum chat message width CSS value. */ - chatMessageMaxWidth?: string; - /** Show live agent activity beneath running Control UI sidebar sessions. */ - sidebarLiveActivity?: boolean; /** Expand advanced settings in schema-driven Control UI forms. */ showAdvancedSettings?: boolean; }; diff --git a/src/config/types.skills.ts b/src/config/types.skills.ts index 162846d0bb92..848bc97c1a52 100644 --- a/src/config/types.skills.ts +++ b/src/config/types.skills.ts @@ -30,8 +30,6 @@ export type SkillsLoadConfig = { allowSymlinkTargets?: string[]; /** Watch skill folders for changes and refresh the skills snapshot. */ watch?: boolean; - /** Debounce for the skills watcher (ms). */ - watchDebounceMs?: number; }; /** Skill installation preferences and upload policy. */ diff --git a/src/config/types.whatsapp.ts b/src/config/types.whatsapp.ts index 77ea0d5f9705..57cb440daccd 100644 --- a/src/config/types.whatsapp.ts +++ b/src/config/types.whatsapp.ts @@ -53,8 +53,6 @@ type WhatsAppSharedConfig = CommonChannelMessagingConfig & groups?: Record; /** Per-direct-chat prompt overrides keyed by user ID or `*` wildcard. */ direct?: Record; - /** Debounce window (ms) for batching rapid consecutive messages from the same sender (0 to disable). */ - debounceMs?: number; }; type WhatsAppSpecificConfig = { diff --git a/src/config/zod-schema.agent-defaults.test.ts b/src/config/zod-schema.agent-defaults.test.ts index dc2e843376e8..def206341387 100644 --- a/src/config/zod-schema.agent-defaults.test.ts +++ b/src/config/zod-schema.agent-defaults.test.ts @@ -392,8 +392,6 @@ describe("agent defaults schema", () => { const defaults = AgentDefaultsSchema.parse({ contextLimits: { memoryGetMaxChars: 20_000, - memoryGetDefaultLines: 200, - toolResultMaxChars: 24_000, postCompactionMaxChars: 4_000, }, })!; @@ -408,8 +406,6 @@ describe("agent defaults schema", () => { }); expect(defaults.contextLimits?.memoryGetMaxChars).toBe(20_000); - expect(defaults.contextLimits?.memoryGetDefaultLines).toBe(200); - expect(defaults.contextLimits?.toolResultMaxChars).toBe(24_000); expect(agent.skillsLimits?.maxSkillsPromptChars).toBe(30_000); expect(agent.contextLimits?.memoryGetMaxChars).toBe(18_000); }); diff --git a/src/config/zod-schema.agent-runtime.ts b/src/config/zod-schema.agent-runtime.ts index 26f60ec31ef9..bf4741fe38f5 100644 --- a/src/config/zod-schema.agent-runtime.ts +++ b/src/config/zod-schema.agent-runtime.ts @@ -279,8 +279,6 @@ const SandboxPruneSchema = z export const AgentContextLimitsSchema = z .object({ memoryGetMaxChars: z.number().int().min(1).max(250_000).optional(), - memoryGetDefaultLines: z.number().int().min(1).max(5_000).optional(), - toolResultMaxChars: z.number().int().min(1).max(1_000_000).optional(), postCompactionMaxChars: z.number().int().min(1).max(50_000).optional(), }) .strict() @@ -940,7 +938,6 @@ export const AgentEntrySchema = z .optional(), humanDelay: HumanDelaySchema.optional(), typingMode: TypingModeSchema.optional(), - typingIntervalSeconds: z.number().int().positive().optional(), tts: AgentTtsConfigSchema, skillsLimits: AgentSkillsLimitsSchema, contextLimits: AgentContextLimitsSchema, diff --git a/src/config/zod-schema.providers-whatsapp.ts b/src/config/zod-schema.providers-whatsapp.ts index d4081849f06f..ae6f9785ef26 100644 --- a/src/config/zod-schema.providers-whatsapp.ts +++ b/src/config/zod-schema.providers-whatsapp.ts @@ -49,9 +49,6 @@ function buildWhatsAppCommonShape(params: { useDefaults: boolean }) { ...buildChannelReactionShape({ reactionLevels: ["off", "ack", "minimal", "extensive"], }), - debounceMs: params.useDefaults - ? z.number().int().nonnegative().optional().default(0) - : z.number().int().nonnegative().optional(), pluginHooks: WhatsAppPluginHooksSchema, }; } diff --git a/src/config/zod-schema.root-shape.ts b/src/config/zod-schema.root-shape.ts index 3c243a10b73f..0f596105e394 100644 --- a/src/config/zod-schema.root-shape.ts +++ b/src/config/zod-schema.root-shape.ts @@ -1,10 +1,6 @@ import { normalizeStringifiedOptionalString } from "@openclaw/normalization-core/string-coerce"; import { z } from "zod"; import { parseDurationMs } from "../cli/parse-duration.js"; -import { - isValidControlUiChatMessageMaxWidth, - normalizeControlUiChatMessageMaxWidth, -} from "./control-ui-css.js"; import { SilentReplyPolicyConfigSchema } from "./zod-schema.agent-defaults.js"; import { ToolsSchema } from "./zod-schema.agent-runtime.js"; import { AgentsSchema, BindingsSchema, BroadcastSchema } from "./zod-schema.agents.js"; @@ -222,9 +218,6 @@ export const OpenClawSchemaShape = { themeMode: z .union([z.literal("light"), z.literal("dark"), z.literal("system")]) .optional(), - textScale: z - .union([z.literal(90), z.literal(100), z.literal(110), z.literal(125), z.literal(140)]) - .optional(), locale: z.string().max(20).optional(), chatShowThinking: z.boolean().optional(), chatShowToolCalls: z.boolean().optional(), @@ -232,15 +225,6 @@ export const OpenClawSchemaShape = { chatSendShortcut: z.union([z.literal("enter"), z.literal("modifier-enter")]).optional(), chatFollowUpMode: z.union([z.literal("steer"), z.literal("queue")]).optional(), sidebarEntries: z.array(z.string()).optional(), - chatMessageMaxWidth: z - .string() - .transform((value) => normalizeControlUiChatMessageMaxWidth(value)) - .refine((value) => isValidControlUiChatMessageMaxWidth(value), { - message: - "Expected a CSS width value such as 960px, 82%, min(1280px, 82%), or calc(100% - 2rem)", - }) - .optional(), - sidebarLiveActivity: z.boolean().optional(), showAdvancedSettings: z.boolean().optional(), }) .optional(), @@ -418,7 +402,6 @@ export const OpenClawSchemaShape = { extraDirs: z.array(z.string()).optional(), allowSymlinkTargets: z.array(z.string()).optional(), watch: z.boolean().optional(), - watchDebounceMs: z.number().int().min(0).optional(), }) .optional(), install: z diff --git a/src/flows/doctor-health-contributions.test.ts b/src/flows/doctor-health-contributions.test.ts index b45b6e51827b..818d369351f2 100644 --- a/src/flows/doctor-health-contributions.test.ts +++ b/src/flows/doctor-health-contributions.test.ts @@ -9,7 +9,6 @@ import { LEGACY_SECRETREF_ENV_MARKER_PREFIX } from "../config/types.secrets.js"; import { migrateLegacySecretRefEnvMarkers } from "../secrets/legacy-secretref-env-marker.js"; import { readConfigMachineState } from "../state/config-machine-state.js"; import { CORE_HEALTH_CHECKS } from "./doctor-core-checks.js"; -import "./doctor-tool-result-cap-advice.js"; import { resolveDoctorContributionHealthChecks } from "./doctor-health-contributions.js"; import { createDoctorHealthContribution, @@ -1865,7 +1864,6 @@ describe("doctor health contributions", () => { expect(contributionIds).toContain("core/doctor/device-pairing"); expect(contributionIds).toContain("core/doctor/channel-plugin-blockers"); expect(contributionIds).toContain("core/doctor/channel-preview-warnings"); - expect(contributionIds).toContain("core/doctor/tool-result-cap"); expect(contributionIds).toContain("core/doctor/systemd-linger"); expect(contributionChecks.map((check) => check.id)).toEqual(contributionIds); }); @@ -1935,60 +1933,6 @@ describe("doctor health contributions", () => { expect(mocks.readSystemdUserLingerStatus).not.toHaveBeenCalled(); }); - it("keeps tool result cap opt-in for default lint selection", async () => { - const contributionChecks = await resolveDoctorContributionHealthChecks(); - const toolResultCapCheck = contributionChecks.find( - (check) => check.id === "core/doctor/tool-result-cap", - ); - expect(toolResultCapCheck).toMatchObject({ defaultEnabled: false }); - expect(toolResultCapCheck).toBeDefined(); - - const ctx = { - cfg: { - agents: { - defaults: { contextLimits: { toolResultMaxChars: 16_000 } }, - }, - }, - mode: "lint", - runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() }, - } as const; - const detect = vi.fn(async () => [ - { - checkId: "core/doctor/tool-result-cap", - severity: "warning" as const, - message: "Configured tool result cap overrides the model-window default.", - path: "agents.defaults.contextLimits.toolResultMaxChars", - }, - ]); - // This case owns lint selection; the real cap detector is exercised below. - const checks = [{ ...toolResultCapCheck!, detect }]; - - await expect(runDoctorLintChecks(ctx, { checks })).resolves.toMatchObject({ - checksRun: 0, - checksSkipped: 1, - }); - expect(detect).not.toHaveBeenCalled(); - await expect( - runDoctorLintChecks(ctx, { checks, includeAllChecks: true }), - ).resolves.toMatchObject({ - checksRun: 1, - checksSkipped: 0, - findings: [ - expect.objectContaining({ - checkId: "core/doctor/tool-result-cap", - path: "agents.defaults.contextLimits.toolResultMaxChars", - }), - ], - }); - await expect( - runDoctorLintChecks(ctx, { checks, onlyIds: ["core/doctor/tool-result-cap"] }), - ).resolves.toMatchObject({ - checksRun: 1, - checksSkipped: 0, - }); - expect(detect).toHaveBeenCalledTimes(2); - }); - it("keeps stale plugin-runtime symlinks opt-in for structured lint selection", async () => { const contributionChecks = await resolveDoctorContributionHealthChecks(); const check = contributionChecks.find( @@ -2036,56 +1980,6 @@ describe("doctor health contributions", () => { expect(mocks.collectStalePluginRuntimeSymlinkHealthFindings).toHaveBeenCalledTimes(1); }); - it("reports agent findings for inherited default tool result caps", async () => { - const contributionChecks = await resolveDoctorContributionHealthChecks(); - const toolResultCapCheck = contributionChecks.find( - (check) => check.id === "core/doctor/tool-result-cap", - ); - expect(toolResultCapCheck).toBeDefined(); - - mocks.resolveAgentContextLimits.mockImplementation( - (cfg: { agents?: { defaults?: { contextLimits?: unknown } } }) => - cfg.agents?.defaults?.contextLimits ?? {}, - ); - mocks.resolveDefaultModelForAgent.mockImplementation((...args: unknown[]) => { - const params = args[0] as { agentId?: string }; - return params.agentId === "writer" - ? { provider: "openai", model: "gpt-5.5" } - : { provider: "local", model: "tiny" }; - }); - mocks.findModelCatalogEntry.mockImplementation((...args: unknown[]) => { - const params = args[1] as { modelId?: string }; - return params.modelId === "gpt-5.5" ? { contextTokens: 200_000 } : { contextTokens: 8_000 }; - }); - - const ctx = { - cfg: { - agents: { - defaults: { contextLimits: { toolResultMaxChars: 16_000 } }, - list: [{ id: "writer" }], - }, - }, - mode: "lint" as const, - runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() }, - }; - - await expect( - runDoctorLintChecks(ctx, { - checks: [toolResultCapCheck!], - onlyIds: ["core/doctor/tool-result-cap"], - }), - ).resolves.toMatchObject({ - checksRun: 1, - findings: expect.arrayContaining([ - expect.objectContaining({ - checkId: "core/doctor/tool-result-cap", - path: "agents.defaults.contextLimits.toolResultMaxChars", - target: "agents.list.writer", - }), - ]), - }); - }); - it("keeps legacy plugin dependency lint opt-in and read-only", async () => { const previousStateDir = process.env.OPENCLAW_STATE_DIR; const tempDir = fs.mkdtempSync(nodePath.join(os.tmpdir(), "openclaw-legacy-plugin-deps-lint-")); diff --git a/src/flows/doctor-health-contributions.ts b/src/flows/doctor-health-contributions.ts index fbf49f1a0d53..5e46370d8566 100644 --- a/src/flows/doctor-health-contributions.ts +++ b/src/flows/doctor-health-contributions.ts @@ -86,7 +86,6 @@ const loadDoctorCoreChecksModule = async () => await import("./doctor-core-check const loadDoctorStateIntegrityModule = async () => await import("../commands/doctor-state-integrity.js"); const loadHealthCheckRegistryModule = async () => await import("./health-check-registry.js"); -const loadCatalogLookupModule = async () => await import("../agents/model-catalog.js"); const loadPreparedModelCatalogModule = async () => await import("../agents/prepared-model-catalog.js"); const loadModelSelectionModule = async () => await import("../agents/model-selection.js"); @@ -842,167 +841,6 @@ async function runHooksModelHealth(ctx: DoctorHealthFlowContext): Promise } } -type ToolResultCapTarget = { - agentId?: string; - configuredCap?: number; - path?: string; - scopeLabel: string; - target?: string; -}; - -async function collectToolResultCapFindings( - cfg: OpenClawConfig, -): Promise { - const { resolveAgentContextLimits } = await loadAgentScopeModule(); - const { normalizeAgentId } = await import("../routing/session-key.js"); - const targets: ToolResultCapTarget[] = []; - const defaultsConfiguredCap = cfg.agents?.defaults?.contextLimits?.toolResultMaxChars; - if (defaultsConfiguredCap !== undefined) { - targets.push({ - configuredCap: defaultsConfiguredCap, - path: "agents.defaults.contextLimits.toolResultMaxChars", - scopeLabel: "defaults", - target: "agents.defaults", - }); - } - for (const entry of cfg.agents?.list ?? []) { - const normalizedAgentId = normalizeAgentId(entry.id); - if ( - !normalizedAgentId || - (defaultsConfiguredCap === undefined && entry.contextLimits?.toolResultMaxChars === undefined) - ) { - continue; - } - targets.push({ - agentId: normalizedAgentId, - configuredCap: resolveAgentContextLimits(cfg, normalizedAgentId)?.toolResultMaxChars, - path: - entry.contextLimits?.toolResultMaxChars === undefined - ? "agents.defaults.contextLimits.toolResultMaxChars" - : `agents.list.${normalizedAgentId}.contextLimits.toolResultMaxChars`, - scopeLabel: `agent "${normalizedAgentId}"`, - target: `agents.list.${normalizedAgentId}`, - }); - } - if (targets.length === 0) { - return []; - } - - const { collectToolResultCapDoctorIssues, toolResultCapDoctorIssueToHealthFinding } = - await import("./doctor-tool-result-cap-advice.js"); - - return collectToolResultCapTargetAdvice({ - cfg, - readOnlyCatalog: true, - targets, - }).then((entries) => - entries.flatMap((entry) => - collectToolResultCapDoctorIssues(entry).map(toolResultCapDoctorIssueToHealthFinding), - ), - ); -} - -async function collectToolResultCapTargetAdvice(params: { - cfg: OpenClawConfig; - readOnlyCatalog?: boolean; - targets: readonly ToolResultCapTarget[]; -}): Promise< - Array<{ - contextWindowTokens: number; - modelKey: string; - configuredCap?: number; - deep?: boolean; - path?: string; - scopeLabel?: string; - target?: string; - }> -> { - const { DEFAULT_CONTEXT_TOKENS } = await loadAgentDefaultsModule(); - const { findModelCatalogEntry } = await loadCatalogLookupModule(); - const { loadPreparedModelCatalog } = await loadPreparedModelCatalogModule(); - const { resolveContextWindowInfo } = await import("../agents/context-window-guard.js"); - const { resolveDefaultModelForAgent, modelKey } = await loadModelSelectionModule(); - const catalog = await loadPreparedModelCatalog({ - config: params.cfg, - ...(params.readOnlyCatalog ? { readOnly: true } : {}), - }); - - return params.targets.map((target) => { - const modelRef = resolveDefaultModelForAgent({ - cfg: params.cfg, - agentId: target.agentId, - }); - const entry = findModelCatalogEntry(catalog, { - provider: modelRef.provider, - modelId: modelRef.model, - }); - const contextWindow = resolveContextWindowInfo({ - cfg: params.cfg, - provider: modelRef.provider, - modelId: modelRef.model, - modelContextTokens: entry?.contextTokens, - modelContextWindow: entry?.contextWindow, - defaultTokens: DEFAULT_CONTEXT_TOKENS, - }); - return { - contextWindowTokens: contextWindow.tokens, - modelKey: modelKey(modelRef.provider, modelRef.model), - configuredCap: target.configuredCap, - path: target.path, - scopeLabel: target.scopeLabel, - target: target.target, - }; - }); -} - -async function runToolResultCapHealth(ctx: DoctorHealthFlowContext): Promise { - const { resolveAgentContextLimits } = await loadAgentScopeModule(); - const { normalizeAgentId } = await import("../routing/session-key.js"); - const targets: ToolResultCapTarget[] = []; - const defaultsConfiguredCap = ctx.cfg.agents?.defaults?.contextLimits?.toolResultMaxChars; - if (ctx.options.deep === true || defaultsConfiguredCap !== undefined) { - targets.push({ - configuredCap: defaultsConfiguredCap, - scopeLabel: "defaults", - }); - } - for (const entry of ctx.cfg.agents?.list ?? []) { - const normalizedAgentId = normalizeAgentId(entry.id); - if ( - !normalizedAgentId || - (ctx.options.deep !== true && - defaultsConfiguredCap === undefined && - entry.contextLimits?.toolResultMaxChars === undefined) - ) { - continue; - } - targets.push({ - agentId: normalizedAgentId, - configuredCap: resolveAgentContextLimits(ctx.cfg, normalizedAgentId)?.toolResultMaxChars, - scopeLabel: `agent "${normalizedAgentId}"`, - }); - } - if (targets.length === 0) { - return; - } - - const { buildToolResultCapDoctorAdvice } = await import("./doctor-tool-result-cap-advice.js"); - const { note } = await loadNoteModule(); - const entries = await collectToolResultCapTargetAdvice({ - cfg: ctx.cfg, - targets, - }); - const lines = entries.flatMap((entry) => - buildToolResultCapDoctorAdvice({ - ...entry, - deep: ctx.options.deep === true, - }), - ); - if (lines.length > 0) { - note(lines.join("\n"), "Tool result cap"); - } -} - async function runSystemdLingerHealth(ctx: DoctorHealthFlowContext): Promise { if ( ctx.options.nonInteractive === true || @@ -2095,18 +1933,6 @@ function resolveDoctorHealthContributions(): DoctorHealthContribution[] { healthCheckIds: ["core/doctor/hooks-model"], run: runHooksModelHealth, }), - createDoctorHealthContribution({ - id: "doctor:tool-result-cap", - label: "Tool result cap", - healthChecks: { - id: "core/doctor/tool-result-cap", - description: - "Detect explicit toolResultMaxChars settings that fight model-window defaults.", - defaultEnabled: false, - detect: async (ctx) => collectToolResultCapFindings(ctx.cfg), - }, - run: runToolResultCapHealth, - }), createDoctorHealthContribution({ id: "doctor:provider-catalog-projection", label: "Provider catalog projection", diff --git a/src/flows/doctor-tool-result-cap-advice.test.ts b/src/flows/doctor-tool-result-cap-advice.test.ts deleted file mode 100644 index 222a9d6db3bb..000000000000 --- a/src/flows/doctor-tool-result-cap-advice.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -// Tool result cap advice tests cover doctor guidance for capped tool output. -import { expectDefined } from "@openclaw/normalization-core"; -import { describe, expect, it } from "vitest"; -import { - buildToolResultCapDoctorAdvice, - collectToolResultCapDoctorIssues, - toolResultCapDoctorIssueToHealthFinding, -} from "./doctor-tool-result-cap-advice.js"; - -describe("buildToolResultCapDoctorAdvice", () => { - it("stays quiet for unset config outside deep doctor output", () => { - expect( - buildToolResultCapDoctorAdvice({ - contextWindowTokens: 200_000, - modelKey: "openai/gpt-5.5", - }), - ).toEqual([]); - }); - - it("shows the effective auto cap in deep doctor output", () => { - expect( - buildToolResultCapDoctorAdvice({ - contextWindowTokens: 200_000, - modelKey: "openai/gpt-5.5", - deep: true, - }), - ).toEqual([ - '- primary model "openai/gpt-5.5" context window 200,000 tokens; live tool-result cap 64,000 chars (auto)', - ]); - }); - - it("warns when an explicit cap keeps a large model on the old 16K ceiling", () => { - expect( - buildToolResultCapDoctorAdvice({ - contextWindowTokens: 200_000, - modelKey: "openai/gpt-5.5", - configuredCap: 16_000, - scopeLabel: 'agent "writer"', - }), - ).toEqual([ - '- agent "writer": configured toolResultMaxChars is 16,000 chars; unset it to use the 64,000 char auto cap for "openai/gpt-5.5".', - ]); - }); - - it("warns when an explicit cap is above the runtime context-share ceiling", () => { - expect( - buildToolResultCapDoctorAdvice({ - contextWindowTokens: 8_000, - modelKey: "local/tiny", - configuredCap: 20_000, - }), - ).toEqual([ - "- configured toolResultMaxChars is 20,000 chars, but this model can use at most 9,600 chars per live tool result; lower it or unset it.", - ]); - }); - - it("maps cap advice issues to structured health findings", () => { - const [issue] = collectToolResultCapDoctorIssues({ - contextWindowTokens: 200_000, - modelKey: "openai/gpt-5.5", - configuredCap: 16_000, - path: "agents.writer.contextLimits.toolResultMaxChars", - scopeLabel: 'agent "writer"', - target: "agents.writer", - }); - - expect( - toolResultCapDoctorIssueToHealthFinding(expectDefined(issue, "issue test invariant")), - ).toEqual({ - checkId: "core/doctor/tool-result-cap", - severity: "warning", - message: - 'agent "writer": configured toolResultMaxChars is 16,000 chars; unset it to use the 64,000 char auto cap for "openai/gpt-5.5".', - path: "agents.writer.contextLimits.toolResultMaxChars", - target: "agents.writer", - requirement: "configured-below-auto-cap", - fixHint: "Lower or unset agents.writer.contextLimits.toolResultMaxChars.", - }); - }); -}); diff --git a/src/flows/doctor-tool-result-cap-advice.ts b/src/flows/doctor-tool-result-cap-advice.ts deleted file mode 100644 index c770c56e742c..000000000000 --- a/src/flows/doctor-tool-result-cap-advice.ts +++ /dev/null @@ -1,162 +0,0 @@ -// Tool result cap advice helpers format doctor guidance for capped outputs. -import { - calculateMaxToolResultCharsWithCap, - resolveAutoLiveToolResultMaxChars, -} from "../agents/embedded-agent-runner/tool-result-truncation.js"; -import type { HealthFinding } from "./health-checks.js"; - -const TOOL_RESULT_CAP_CHECK_ID = "core/doctor/tool-result-cap"; - -type ToolResultCapDoctorIssue = { - kind: "configured-above-runtime-ceiling" | "configured-below-auto-cap"; - contextWindowTokens: number; - modelKey: string; - configuredCap: number; - runtimeCeiling?: number; - autoEffectiveCap?: number; - path?: string; - scopeLabel?: string; - target?: string; -}; - -// Doctor advice for explicit live tool-result caps that fight model-window defaults. -type ToolResultCapDoctorAdviceParams = { - contextWindowTokens: number; - modelKey: string; - configuredCap?: number; - deep?: boolean; - path?: string; - scopeLabel?: string; - target?: string; -}; - -function formatNumber(value: number): string { - return String(Math.max(0, Math.floor(value))).replace(/\B(?=(\d{3})+(?!\d))/g, ","); -} - -function formatIssueMessage(issue: ToolResultCapDoctorIssue): string { - const prefix = issue.scopeLabel ? `${issue.scopeLabel}: ` : ""; - if (issue.kind === "configured-above-runtime-ceiling") { - return `${prefix}configured toolResultMaxChars is ${formatNumber( - issue.configuredCap, - )} chars, but this model can use at most ${formatNumber( - issue.runtimeCeiling ?? 0, - )} chars per live tool result; lower it or unset it.`; - } - return `${prefix}configured toolResultMaxChars is ${formatNumber( - issue.configuredCap, - )} chars; unset it to use the ${formatNumber(issue.autoEffectiveCap ?? 0)} char auto cap for "${ - issue.modelKey - }".`; -} - -export function collectToolResultCapDoctorIssues( - params: ToolResultCapDoctorAdviceParams, -): ToolResultCapDoctorIssue[] { - if (!Number.isFinite(params.contextWindowTokens) || params.contextWindowTokens <= 0) { - return []; - } - - const configuredCap = - typeof params.configuredCap === "number" && Number.isFinite(params.configuredCap) - ? Math.floor(params.configuredCap) - : undefined; - if (configuredCap === undefined) { - return []; - } - - const autoCap = resolveAutoLiveToolResultMaxChars(params.contextWindowTokens); - const runtimeCeiling = calculateMaxToolResultCharsWithCap( - params.contextWindowTokens, - Number.MAX_SAFE_INTEGER, - ); - const effectiveCap = calculateMaxToolResultCharsWithCap( - params.contextWindowTokens, - configuredCap, - ); - const autoEffectiveCap = calculateMaxToolResultCharsWithCap(params.contextWindowTokens, autoCap); - - if (configuredCap > runtimeCeiling) { - return [ - { - kind: "configured-above-runtime-ceiling", - contextWindowTokens: params.contextWindowTokens, - modelKey: params.modelKey, - configuredCap, - runtimeCeiling, - path: params.path, - scopeLabel: params.scopeLabel, - target: params.target, - }, - ]; - } - - if (effectiveCap < autoEffectiveCap) { - return [ - { - kind: "configured-below-auto-cap", - contextWindowTokens: params.contextWindowTokens, - modelKey: params.modelKey, - configuredCap, - autoEffectiveCap, - path: params.path, - scopeLabel: params.scopeLabel, - target: params.target, - }, - ]; - } - - return []; -} - -export function toolResultCapDoctorIssueToHealthFinding( - issue: ToolResultCapDoctorIssue, -): HealthFinding { - return { - checkId: TOOL_RESULT_CAP_CHECK_ID, - severity: "warning", - message: formatIssueMessage(issue), - ...(issue.path ? { path: issue.path } : {}), - ...(issue.target ? { target: issue.target } : {}), - requirement: issue.kind, - fixHint: issue.path ? `Lower or unset ${issue.path}.` : "Lower or unset toolResultMaxChars.", - }; -} - -/** Builds human-readable doctor lines for stale or ineffective toolResultMaxChars settings. */ -export function buildToolResultCapDoctorAdvice(params: ToolResultCapDoctorAdviceParams): string[] { - if (!Number.isFinite(params.contextWindowTokens) || params.contextWindowTokens <= 0) { - return []; - } - - const autoCap = resolveAutoLiveToolResultMaxChars(params.contextWindowTokens); - const configuredCap = - typeof params.configuredCap === "number" && Number.isFinite(params.configuredCap) - ? Math.floor(params.configuredCap) - : undefined; - const configuredSource = configuredCap !== undefined; - const requestedCap = configuredCap ?? autoCap; - const effectiveCap = calculateMaxToolResultCharsWithCap(params.contextWindowTokens, requestedCap); - const lines: string[] = []; - const prefix = params.scopeLabel ? `${params.scopeLabel}: ` : ""; - // Deep mode always shows the effective cap, even when no warning is needed. - if (params.deep) { - lines.push( - `- ${prefix}primary model "${params.modelKey}" context window ${formatNumber( - params.contextWindowTokens, - )} tokens; live tool-result cap ${formatNumber(effectiveCap)} chars (${ - configuredSource ? "explicit" : "auto" - })`, - ); - } - - if (configuredCap === undefined) { - return lines; - } - - lines.push( - ...collectToolResultCapDoctorIssues(params).map((issue) => `- ${formatIssueMessage(issue)}`), - ); - - return lines; -} diff --git a/src/gateway/control-ui-contract.ts b/src/gateway/control-ui-contract.ts index a7534af62a95..47b62ca4539d 100644 --- a/src/gateway/control-ui-contract.ts +++ b/src/gateway/control-ui-contract.ts @@ -148,7 +148,6 @@ export type ControlUiBootstrapConfig = { localMediaPreviewRoots?: string[]; embedSandbox?: ControlUiEmbedSandboxMode; allowExternalEmbedUrls?: boolean; - chatMessageMaxWidth?: string; seamColor?: string; /** Resolved `agents.defaults.timeFormat`; "auto" keeps the browser locale default. */ timeFormat?: "auto" | "12" | "24"; diff --git a/src/gateway/control-ui.http.test.ts b/src/gateway/control-ui.http.test.ts index aeb6381a0746..de8cccb73e08 100644 --- a/src/gateway/control-ui.http.test.ts +++ b/src/gateway/control-ui.http.test.ts @@ -99,7 +99,6 @@ describe("handleControlUiHttpRequest", () => { assistantAgentId: string; devGitBranch?: string; localMediaPreviewRoots?: string[]; - chatMessageMaxWidth?: string; seamColor?: string; timeFormat?: "auto" | "12" | "24"; terminalEnabled: boolean; @@ -1103,7 +1102,6 @@ describe("handleControlUiHttpRequest", () => { config: { agents: { defaults: { workspace: tmp } }, ui: { - prefs: { chatMessageMaxWidth: "min(1280px, 82%)" }, seamColor: "#1A2b3C", assistant: { name: ".png" }, }, @@ -1118,7 +1116,6 @@ describe("handleControlUiHttpRequest", () => { expect(parsed.assistantAvatarStatus).toBe("none"); expect(parsed.assistantAvatarReason).toBe("missing"); expect(parsed.assistantAgentId).toBe("main"); - expect(parsed.chatMessageMaxWidth).toBe("min(1280px, 82%)"); expect(parsed.seamColor).toBe("#1A2b3C"); expect(parsed.timeFormat).toBe("auto"); expect(parsed.terminalEnabled).toBe(false); diff --git a/src/gateway/control-ui.ts b/src/gateway/control-ui.ts index 6f265dc02259..479721bace79 100644 --- a/src/gateway/control-ui.ts +++ b/src/gateway/control-ui.ts @@ -989,7 +989,6 @@ export async function handleControlUiHttpRequest( ? "strict" : "scripts", allowExternalEmbedUrls: config?.gateway?.controlUi?.allowExternalEmbedUrls === true, - chatMessageMaxWidth: config?.ui?.prefs?.chatMessageMaxWidth, seamColor: config?.ui?.seamColor, timeFormat: "auto", terminalEnabled, diff --git a/src/infra/heartbeat-runner.ts b/src/infra/heartbeat-runner.ts index 5e3fc2187f6c..468feb735cb8 100644 --- a/src/infra/heartbeat-runner.ts +++ b/src/infra/heartbeat-runner.ts @@ -560,10 +560,8 @@ function isHeartbeatTypingEnabled(params: { return typingMode !== "never"; } -function resolveHeartbeatTypingIntervalSeconds(cfg: OpenClawConfig, agentId: string) { - const configured = - resolveAgentConfig(cfg, agentId)?.typingIntervalSeconds ?? - cfg.agents?.defaults?.typingIntervalSeconds; +function resolveHeartbeatTypingIntervalSeconds(cfg: OpenClawConfig) { + const configured = cfg.agents?.defaults?.typingIntervalSeconds; return typeof configured === "number" && configured > 0 ? configured : undefined; } @@ -1692,7 +1690,7 @@ export async function runHeartbeatOnce(opts: { const hasChatDelivery = Boolean( delivery.channel !== "none" && delivery.to && (visibility.showAlerts || visibility.showOk), ); - const heartbeatTypingIntervalSeconds = resolveHeartbeatTypingIntervalSeconds(cfg, agentId); + const heartbeatTypingIntervalSeconds = resolveHeartbeatTypingIntervalSeconds(cfg); const heartbeatChannelPlugin = delivery.channel !== "none" ? resolveHeartbeatChannelPlugin(delivery.channel) : undefined; const heartbeatTyping = diff --git a/src/skills/runtime/refresh.test.ts b/src/skills/runtime/refresh.test.ts index 1d257717c9f2..c967f3972e17 100644 --- a/src/skills/runtime/refresh.test.ts +++ b/src/skills/runtime/refresh.test.ts @@ -176,7 +176,7 @@ describe("ensureSkillsWatcher", () => { }); refreshModule.ensureSkillsWatcher({ workspaceDir, - config: { skills: { load: { watchDebounceMs: 10 } } }, + config: { skills: { load: {} } }, }); createdWatchers[0]?.emit( @@ -187,7 +187,7 @@ describe("ensureSkillsWatcher", () => { watchedPath: path.join(workspaceDir, "skills", "demo"), }, ); - await vi.advanceTimersByTimeAsync(20); + await vi.advanceTimersByTimeAsync(500); expect(seen).toEqual([]); } finally { @@ -210,7 +210,7 @@ describe("ensureSkillsWatcher", () => { }); refreshModule.ensureSkillsWatcher({ workspaceDir, - config: { skills: { load: { watchDebounceMs: 10 } } }, + config: { skills: { load: {} } }, }); const calls = watchMock.mock.calls as unknown as Array< @@ -222,7 +222,7 @@ describe("ensureSkillsWatcher", () => { const changedPath = path.join(workspaceDir, "skills", "group", "demo", "SKILL.md"); createdWatchers[firstIndex]?.emit("all", "change", changedPath); - await vi.advanceTimersByTimeAsync(10); + await vi.advanceTimersByTimeAsync(250); expect(seen).toEqual([ { @@ -246,20 +246,20 @@ describe("ensureSkillsWatcher", () => { refreshModule.ensureSkillsWatcher({ workspaceDir, - config: { skills: { load: { watchDebounceMs: 10 } } }, + config: { skills: { load: {} } }, }); createdWatchers[0]?.emit("raw", "rename", "README.md", { watchedPath: "/tmp/workspace/skills/demo", }); - await vi.advanceTimersByTimeAsync(10); + await vi.advanceTimersByTimeAsync(250); expect(seen).toEqual([]); createdWatchers[0]?.emit("raw", "rename", "SKILL.md", { watchedPath: "/tmp/workspace/skills/demo", }); await Promise.resolve(); - await vi.advanceTimersByTimeAsync(10); + await vi.advanceTimersByTimeAsync(250); expect(seen).toEqual([ { @@ -280,13 +280,13 @@ describe("ensureSkillsWatcher", () => { refreshModule.ensureSkillsWatcher({ workspaceDir, - config: { skills: { load: { watchDebounceMs: 10 } } }, + config: { skills: { load: {} } }, }); createdWatchers[0]?.emit("raw", "rename", undefined, { watchedPath: "/tmp/workspace/skills", }); - await vi.advanceTimersByTimeAsync(10); + await vi.advanceTimersByTimeAsync(250); expect(seen).toEqual([ { @@ -312,15 +312,15 @@ describe("ensureSkillsWatcher", () => { refreshModule.ensureSkillsWatcher({ workspaceDir, - config: { skills: { load: { watchDebounceMs: 10 } } }, + config: { skills: { load: {} } }, }); createdWatchers[0]?.emit("raw", "change", "SKILL.md", { watchedPath: skillDir }); await Promise.resolve(); - await vi.advanceTimersByTimeAsync(10); + await vi.advanceTimersByTimeAsync(250); expect(seen).toEqual([]); - await vi.advanceTimersByTimeAsync(10); + await vi.advanceTimersByTimeAsync(250); expect(seen).toEqual([ { workspaceDir, @@ -725,11 +725,11 @@ describe("ensureSkillsWatcher", () => { }); refreshModule.ensureSkillsWatcher({ workspaceDir: "/tmp/workspace", - config: { skills: { load: { watchDebounceMs: 10 } } }, + config: { skills: { load: {} } }, }); createdWatchers[0]?.emit("all", event, "/tmp/workspace/skills/demo/SKILL.md"); - await vi.advanceTimersByTimeAsync(10); + await vi.advanceTimersByTimeAsync(250); expect(seen).toEqual([ { @@ -797,11 +797,11 @@ describe("ensureSkillsWatcher", () => { }); refreshModule.ensureSkillsWatcher({ workspaceDir: "/tmp/ws-a", - config: { skills: { load: { extraDirs: ["/tmp/shared"], watchDebounceMs: 10 } } }, + config: { skills: { load: { extraDirs: ["/tmp/shared"] } } }, }); refreshModule.ensureSkillsWatcher({ workspaceDir: "/tmp/ws-b", - config: { skills: { load: { extraDirs: ["/tmp/shared"], watchDebounceMs: 10 } } }, + config: { skills: { load: { extraDirs: ["/tmp/shared"] } } }, }); const callPaths = (watchMock.mock.calls as unknown as Array<[string]>).map((call) => call[0]); @@ -809,7 +809,7 @@ describe("ensureSkillsWatcher", () => { expect(sharedIndex).toBeGreaterThanOrEqual(0); createdWatchers[sharedIndex]?.emit("all", "change", "/tmp/shared/demo/SKILL.md"); - await vi.advanceTimersByTimeAsync(10); + await vi.advanceTimersByTimeAsync(250); expect(seen).toContainEqual({ workspaceDir: "/tmp/ws-a", @@ -831,11 +831,11 @@ describe("ensureSkillsWatcher", () => { }); refreshModule.ensureSkillsWatcher({ workspaceDir: "/tmp/ws-a", - config: { skills: { load: { extraDirs: ["/tmp/shared"], watchDebounceMs: 10 } } }, + config: { skills: { load: { extraDirs: ["/tmp/shared"] } } }, }); refreshModule.ensureSkillsWatcher({ workspaceDir: "/tmp/ws-b", - config: { skills: { load: { extraDirs: ["/tmp/shared"], watchDebounceMs: 10 } } }, + config: { skills: { load: { extraDirs: ["/tmp/shared"] } } }, }); // ws-a turns watching off: it unsubscribes, but the shared watcher stays @@ -851,7 +851,7 @@ describe("ensureSkillsWatcher", () => { expect(sharedIndex).toBeGreaterThanOrEqual(0); createdWatchers[sharedIndex]?.emit("all", "change", "/tmp/shared/demo/SKILL.md"); - await vi.advanceTimersByTimeAsync(10); + await vi.advanceTimersByTimeAsync(250); expect(seen).toContainEqual({ workspaceDir: "/tmp/ws-b", @@ -867,7 +867,7 @@ describe("ensureSkillsWatcher", () => { const workspaceDir = "/tmp/workspace-version-cleanup"; refreshModule.ensureSkillsWatcher({ workspaceDir, - config: { skills: { load: { watchDebounceMs: 10 } } }, + config: { skills: { load: {} } }, }); const firstVersion = bumpSkillsSnapshotVersion({ @@ -897,7 +897,7 @@ describe("ensureSkillsWatcher", () => { const idleWorkspaceDir = "/tmp/workspace-idle"; refreshModule.ensureSkillsWatcher({ workspaceDir: idleWorkspaceDir, - config: { skills: { load: { watchDebounceMs: 10 } } }, + config: { skills: { load: {} } }, }); const callPaths = (watchMock.mock.calls as unknown as Array<[string]>).map((call) => call[0]); const idleSkillsIndex = callPaths.findIndex( @@ -912,7 +912,7 @@ describe("ensureSkillsWatcher", () => { vi.advanceTimersByTime(60 * 60_000 + 1_000); refreshModule.ensureSkillsWatcher({ workspaceDir: "/tmp/workspace-active", - config: { skills: { load: { watchDebounceMs: 10 } } }, + config: { skills: { load: {} } }, }); expect(createdWatchers[idleSkillsIndex]?.close).toHaveBeenCalledTimes(1); @@ -932,7 +932,7 @@ describe("ensureSkillsWatcher", () => { const activeWorkspaceDir = "/tmp/workspace-active-refresh"; refreshModule.ensureSkillsWatcher({ workspaceDir: activeWorkspaceDir, - config: { skills: { load: { watchDebounceMs: 10 } } }, + config: { skills: { load: {} } }, }); const callPaths = (watchMock.mock.calls as unknown as Array<[string]>).map((call) => call[0]); const activeSkillsIndex = callPaths.findIndex( @@ -943,59 +943,14 @@ describe("ensureSkillsWatcher", () => { vi.advanceTimersByTime(30 * 60_000); refreshModule.ensureSkillsWatcher({ workspaceDir: activeWorkspaceDir, - config: { skills: { load: { watchDebounceMs: 10 } } }, + config: { skills: { load: {} } }, }); vi.advanceTimersByTime(31 * 60_000); refreshModule.ensureSkillsWatcher({ workspaceDir: "/tmp/workspace-other", - config: { skills: { load: { watchDebounceMs: 10 } } }, + config: { skills: { load: {} } }, }); expect(createdWatchers[activeSkillsIndex]?.close).not.toHaveBeenCalled(); }); - - it("rebuilds a shared watcher with last-writer debounce while preserving subscribers", async () => { - vi.useFakeTimers(); - const seen: SkillsChangeEvent[] = []; - refreshModule.registerSkillsChangeListener((change) => { - seen.push(change); - }); - refreshModule.ensureSkillsWatcher({ - workspaceDir: "/tmp/ws-a", - config: { skills: { load: { extraDirs: ["/tmp/shared"], watchDebounceMs: 10 } } }, - }); - const callPaths1 = (watchMock.mock.calls as unknown as Array<[string]>).map((call) => call[0]); - const firstSharedIndex = callPaths1.findIndex((target) => target === "/tmp/shared"); - - // ws-b subscribes to the same path with a different debounce: the shared - // watcher is rebuilt once, the previous instance closed, and both - // workspaces remain subscribed. - refreshModule.ensureSkillsWatcher({ - workspaceDir: "/tmp/ws-b", - config: { skills: { load: { extraDirs: ["/tmp/shared"], watchDebounceMs: 50 } } }, - }); - - expect(createdWatchers[firstSharedIndex]?.close).toHaveBeenCalledTimes(1); - const callPaths2 = (watchMock.mock.calls as unknown as Array<[string]>).map((call) => call[0]); - const sharedIndices = callPaths2 - .map((target, index) => (target === "/tmp/shared" ? index : -1)) - .filter((index) => index >= 0); - expect(sharedIndices).toHaveLength(2); - expect(callPaths2.filter((target) => target === "/tmp/shared/skills")).toHaveLength(2); - const liveSharedIndex = sharedIndices[sharedIndices.length - 1] ?? -1; - - createdWatchers[liveSharedIndex]?.emit("all", "change", "/tmp/shared/demo/SKILL.md"); - await vi.advanceTimersByTimeAsync(50); - - expect(seen).toContainEqual({ - workspaceDir: "/tmp/ws-a", - reason: "watch", - changedPath: "/tmp/shared/demo/SKILL.md", - }); - expect(seen).toContainEqual({ - workspaceDir: "/tmp/ws-b", - reason: "watch", - changedPath: "/tmp/shared/demo/SKILL.md", - }); - }); }); diff --git a/src/skills/runtime/refresh.ts b/src/skills/runtime/refresh.ts index 071efbad1f41..1882fc4c39a8 100644 --- a/src/skills/runtime/refresh.ts +++ b/src/skills/runtime/refresh.ts @@ -23,7 +23,6 @@ export { registerSkillsChangeListener } from "./refresh-state.js"; type SkillsPathWatchState = { watcher: FSWatcher; depth: number; - debounceMs: number; timer?: ReturnType; pendingPath?: string; readonly subscribers: Set; @@ -52,6 +51,7 @@ const MAX_SYMLINK_WATCH_TARGETS_PER_ROOT = 100; const MAX_SYMLINK_WATCH_DIRECTORY_SCANS_PER_ROOT = 200; const MAX_SYMLINK_WATCH_RAW_ENTRIES_PER_ROOT = 2_000; const RAW_SKILL_FILE_POLL_INTERVAL_MS = 100; +const SKILLS_WATCH_DEBOUNCE_MS = 250; // One watcher per unique watched directory. Agent workspaces that include the // same shared skill root (the global skills dir, the home skills dir, or a // configured extra/plugin dir) subscribe to the same watcher instead of each @@ -467,11 +467,6 @@ async function waitForStableSkillFile(filePath: string, stabilityMs: number): Pr } } -function resolveWatchDebounceMs(config?: OpenClawConfig): number { - const raw = config?.skills?.load?.watchDebounceMs; - return typeof raw === "number" && Number.isFinite(raw) ? Math.max(0, raw) : 250; -} - function resolveSkillsWatcherUsePolling(): boolean { const envPolling = process.env.CHOKIDAR_USEPOLLING; if (envPolling === undefined) { @@ -502,7 +497,7 @@ function sameWatchTargets(a: WatchTarget[], b: WatchTarget[]): boolean { return true; } -function createSkillsPathWatcher(target: WatchTarget, debounceMs: number): SkillsPathWatchState { +function createSkillsPathWatcher(target: WatchTarget): SkillsPathWatchState { const usePolling = resolveSkillsWatcherUsePolling(); const watcher = chokidar.watch(target.path, { ignoreInitial: true, @@ -512,7 +507,7 @@ function createSkillsPathWatcher(target: WatchTarget, debounceMs: number): Skill // so watcher invalidation must observe that whole decision surface. depth: target.depth, awaitWriteFinish: { - stabilityThreshold: debounceMs, + stabilityThreshold: SKILLS_WATCH_DEBOUNCE_MS, pollInterval: 100, }, ignored: (watchPath, stats) => shouldIgnoreSkillsWatchPath(watchPath, stats, { usePolling }), @@ -521,7 +516,6 @@ function createSkillsPathWatcher(target: WatchTarget, debounceMs: number): Skill const state: SkillsPathWatchState = { watcher, depth: target.depth, - debounceMs, subscribers: new Set(), }; @@ -544,10 +538,10 @@ function createSkillsPathWatcher(target: WatchTarget, debounceMs: number): Skill changedPath: pendingPath, }); } - }, debounceMs); + }, SKILLS_WATCH_DEBOUNCE_MS); }; const scheduleRawSkillFile = (changedPath: string) => { - void waitForStableSkillFile(changedPath, debounceMs) + void waitForStableSkillFile(changedPath, SKILLS_WATCH_DEBOUNCE_MS) .catch((err: unknown) => { log.warn(`skills watcher stability check failed (${changedPath}): ${String(err)}`); }) @@ -586,26 +580,18 @@ function teardownSkillsPathWatcher(state: SkillsPathWatchState): void { void state.watcher.close().catch(() => {}); } -function subscribeWorkspaceToPath( - workspaceDir: string, - watchTarget: WatchTarget, - debounceMs: number, -): void { +function subscribeWorkspaceToPath(workspaceDir: string, watchTarget: WatchTarget): void { const existing = pathWatchers.get(watchTarget.key); - if (existing && existing.debounceMs === debounceMs && existing.depth >= watchTarget.depth) { + if (existing && existing.depth >= watchTarget.depth) { existing.subscribers.add(workspaceDir); return; } if (existing) { - // Debounce changed (config reload): rebuild the shared watcher while - // preserving existing subscribers. Debounce is a gateway-global config - // value, so all workspaces normally request the same value and this branch - // does not fire; if it does, the most recent requested debounce wins for - // every subscriber of the shared path (last-writer-wins). - const next = createSkillsPathWatcher( - { ...watchTarget, depth: Math.max(existing.depth, watchTarget.depth) }, - debounceMs, - ); + // A deeper target needs a rebuilt watcher while preserving subscribers. + const next = createSkillsPathWatcher({ + ...watchTarget, + depth: Math.max(existing.depth, watchTarget.depth), + }); for (const subscriber of existing.subscribers) { next.subscribers.add(subscriber); } @@ -614,7 +600,7 @@ function subscribeWorkspaceToPath( pathWatchers.set(watchTarget.key, next); return; } - const state = createSkillsPathWatcher(watchTarget, debounceMs); + const state = createSkillsPathWatcher(watchTarget); state.subscribers.add(workspaceDir); pathWatchers.set(watchTarget.key, state); } @@ -666,7 +652,6 @@ export function ensureSkillsWatcher(params: { workspaceDir: string; config?: Ope } const now = Date.now(); const watchEnabled = params.config?.skills?.load?.watch !== false; - const debounceMs = resolveWatchDebounceMs(params.config); const previousTargets = workspaceWatchTargets.get(workspaceDir) ?? []; if (!watchEnabled) { @@ -678,14 +663,10 @@ export function ensureSkillsWatcher(params: { workspaceDir: string; config?: Ope workspaceWatchLastEnsuredAt.set(workspaceDir, now); const watchTargets = resolveWatchTargets(workspaceDir, params.config); const targetsUnchanged = sameWatchTargets(previousTargets, watchTargets); - const debounceUnchanged = watchTargets.every( - // undefined for paths not yet watched -> false -> fall through to subscribe. - (watchTarget) => { - const pathWatcher = pathWatchers.get(watchTarget.key); - return pathWatcher?.debounceMs === debounceMs && pathWatcher.depth >= watchTarget.depth; - }, + const watcherDepthsCoverTargets = watchTargets.every( + (watchTarget) => (pathWatchers.get(watchTarget.key)?.depth ?? -1) >= watchTarget.depth, ); - if (targetsUnchanged && debounceUnchanged) { + if (targetsUnchanged && watcherDepthsCoverTargets) { evictIdleWorkspaceWatchStates(now); return; } @@ -698,7 +679,7 @@ export function ensureSkillsWatcher(params: { workspaceDir: string; config?: Ope } } for (const watchTarget of watchTargets) { - subscribeWorkspaceToPath(workspaceDir, watchTarget, debounceMs); + subscribeWorkspaceToPath(workspaceDir, watchTarget); } workspaceWatchTargets.set(workspaceDir, watchTargets); diff --git a/ui/src/app/config.ts b/ui/src/app/config.ts index cf96420aa44e..b19eade93ccc 100644 --- a/ui/src/app/config.ts +++ b/ui/src/app/config.ts @@ -43,7 +43,6 @@ type ApplicationConfig = { localMediaPreviewRoots: string[]; embedSandboxMode: ControlUiEmbedSandboxMode; allowExternalEmbedUrls: boolean; - chatMessageMaxWidth: string | null; terminalEnabled: boolean; pluginFrameGrants: ControlUiPluginFrameGrantAck[]; }; @@ -80,7 +79,6 @@ const DEFAULT_APPLICATION_CONFIG: ApplicationConfig = { localMediaPreviewRoots: [], embedSandboxMode: "strict", allowExternalEmbedUrls: false, - chatMessageMaxWidth: null, terminalEnabled: readDocumentTerminalEnabled() ?? false, pluginFrameGrants: [], }; @@ -157,10 +155,6 @@ function normalizeApplicationConfig(parsed: ControlUiBootstrapConfig): Applicati ? "strict" : "scripts", allowExternalEmbedUrls: parsed.allowExternalEmbedUrls === true, - chatMessageMaxWidth: - typeof parsed.chatMessageMaxWidth === "string" && parsed.chatMessageMaxWidth.trim() - ? parsed.chatMessageMaxWidth - : null, terminalEnabled: parsed.terminalEnabled === true, pluginFrameGrants: Array.isArray(parsed.pluginFrameGrants) ? parsed.pluginFrameGrants.filter( diff --git a/ui/src/app/server-prefs.test.ts b/ui/src/app/server-prefs.test.ts index fd0fd78bb15b..563e7167806f 100644 --- a/ui/src/app/server-prefs.test.ts +++ b/ui/src/app/server-prefs.test.ts @@ -31,11 +31,12 @@ describe("server pref extraction", () => { configWithPrefs({ theme: "knot", themeMode: "dark", - textScale: 125, locale: "de", chatShowThinking: false, chatSendShortcut: "modifier-enter", + textScale: 125, sidebarLiveActivity: false, + chatMessageMaxWidth: "82%", showAdvancedSettings: true, sidebarEntries: ["route:usage", "session:agent:main:test", "route:usage", 7], bogus: true, @@ -46,11 +47,9 @@ describe("server pref extraction", () => { expect(onApplied).toHaveBeenCalledWith({ theme: "knot", themeMode: "dark", - textScale: 125, locale: "de", chatShowThinking: false, chatSendShortcut: "modifier-enter", - sidebarLiveActivity: false, showAdvancedSettings: true, sidebarEntries: ["route:usage", "session:agent:main:test"], }); @@ -59,7 +58,7 @@ describe("server pref extraction", () => { it("ignores invalid values and configs without prefs", () => { const onApplied = vi.fn(); expect( - applyServerUiPrefs(configWithPrefs({ theme: "neon", textScale: 97, locale: "xx-YY" }), { + applyServerUiPrefs(configWithPrefs({ theme: "neon", locale: "xx-YY" }), { onApplied, }), ).toBe(false); @@ -74,12 +73,11 @@ describe("server pref extraction", () => { describe("applyServerUiPrefs", () => { it("applies a server delta to the local mirror once", () => { const onApplied = vi.fn(); - const config = configWithPrefs({ themeMode: "dark", textScale: 110 }); + const config = configWithPrefs({ themeMode: "dark" }); expect(applyServerUiPrefs(config, { onApplied })).toBe(true); expect(loadSettings().themeMode).toBe("dark"); - expect(loadSettings().textScale).toBe(110); - expect(onApplied).toHaveBeenCalledWith({ themeMode: "dark", textScale: 110 }); + expect(onApplied).toHaveBeenCalledWith({ themeMode: "dark" }); // The same server value never re-applies, so a later local edit sticks. patchSettings({ themeMode: "light" }); @@ -111,15 +109,15 @@ describe("applyServerUiPrefs", () => { it("applies only the fields the server actually changed", () => { const onApplied = vi.fn(); - applyServerUiPrefs(configWithPrefs({ themeMode: "dark", textScale: 100 }), { onApplied }); + applyServerUiPrefs(configWithPrefs({ themeMode: "dark", locale: "de" }), { onApplied }); // Unpushable local edit on one field... patchSettings({ themeMode: "light" }); // ...survives a server change to a *different* field. expect( - applyServerUiPrefs(configWithPrefs({ themeMode: "dark", textScale: 125 }), { onApplied }), + applyServerUiPrefs(configWithPrefs({ themeMode: "dark", locale: "fr" }), { onApplied }), ).toBe(true); - expect(loadSettings().textScale).toBe(125); + expect(loadSettings().locale).toBe("fr"); expect(loadSettings().themeMode).toBe("light"); }); @@ -169,12 +167,16 @@ describe("changedServerUiPrefs", () => { ).toBeNull(); }); - it("syncs the live sidebar activity preference", () => { + it("does not sync browser-local presentation preferences", () => { const previous = loadSettings(); - expect(previous.sidebarLiveActivity).toBe(true); - expect(changedServerUiPrefs(previous, { ...previous, sidebarLiveActivity: false })).toEqual({ - sidebarLiveActivity: false, - }); + expect( + changedServerUiPrefs(previous, { + ...previous, + textScale: 125, + sidebarLiveActivity: false, + chatMessageMaxWidth: "82%", + }), + ).toBeNull(); }); it("syncs the advanced settings visibility preference", () => { @@ -314,7 +316,7 @@ describe("pushServerUiPrefs", () => { const client = { request } as unknown as Parameters[0]; pushServerUiPrefs(client, { themeMode: "dark" }); - pushServerUiPrefs(client, { textScale: 125 }); + pushServerUiPrefs(client, { locale: "de" }); pushServerUiPrefs(client, { themeMode: "light" }); await vi.waitFor(() => { @@ -323,7 +325,7 @@ describe("pushServerUiPrefs", () => { // The first patch carries the first delta; the rest coalesce into one. expect(patched[0]).toBe(JSON.stringify({ ui: { prefs: { themeMode: "dark" } } })); expect(patched[1]).toBe( - JSON.stringify({ ui: { prefs: { textScale: 125, themeMode: "light" } } }), + JSON.stringify({ ui: { prefs: { locale: "de", themeMode: "light" } } }), ); }); @@ -349,7 +351,7 @@ describe("pushServerUiPrefs", () => { const clientB = { request: requestB } as unknown as Parameters[0]; pushServerUiPrefs(clientA, { themeMode: "dark" }); - pushServerUiPrefs(clientB, { textScale: 125 }); + pushServerUiPrefs(clientB, { locale: "de" }); resolveFirstGet?.({ hash: "a-1" }); await vi.waitFor(() => { @@ -359,7 +361,7 @@ describe("pushServerUiPrefs", () => { expect(requestA.mock.calls.filter(([method]) => method === "config.patch").length).toBe(0); expect(requestB).toHaveBeenCalledWith("config.patch", { baseHash: "b-1", - raw: JSON.stringify({ ui: { prefs: { textScale: 125 } } }), + raw: JSON.stringify({ ui: { prefs: { locale: "de" } } }), note: "control-ui prefs sync", }); }); @@ -378,7 +380,7 @@ describe("pushServerUiPrefs", () => { }); const client = { request } as unknown as Parameters[0]; - pushServerUiPrefs(client, { textScale: 125 }); + pushServerUiPrefs(client, { locale: "de" }); await vi.waitFor(() => { expect(patchCalls).toBe(2); }); diff --git a/ui/src/app/server-prefs.ts b/ui/src/app/server-prefs.ts index f74627ee5eb9..4fd8fc96a7c6 100644 --- a/ui/src/app/server-prefs.ts +++ b/ui/src/app/server-prefs.ts @@ -13,12 +13,9 @@ import { loadSettings, normalizeChatFollowUpModeOverride, normalizeChatSendShortcut, - normalizeTextScale, patchSettings, - TEXT_SCALE_STOPS, type ChatFollowUpMode, type ChatSendShortcut, - type TextScaleStop, type UiSettings, } from "./settings.ts"; import type { ThemeMode, ThemeName } from "./theme.ts"; @@ -61,11 +58,6 @@ const SYNCED_PREFS = { extract: (value) => (THEME_MODES.has(value as ThemeMode) ? (value as ThemeMode) : undefined), local: (settings) => settings.themeMode, }), - textScale: prefSpec({ - extract: (value) => - TEXT_SCALE_STOPS.includes(value as TextScaleStop) ? normalizeTextScale(value) : undefined, - local: (settings) => normalizeTextScale(settings.textScale), - }), locale: prefSpec({ extract: (value) => (typeof value === "string" && isSupportedLocale(value) ? value : undefined), local: (settings) => settings.locale, @@ -100,10 +92,6 @@ const SYNCED_PREFS = { extract: (value) => normalizeSidebarEntries(value) ?? undefined, local: (settings) => settings.sidebarEntries, }), - sidebarLiveActivity: prefSpec({ - extract: (value) => (typeof value === "boolean" ? value : undefined), - local: (settings) => settings.sidebarLiveActivity !== false, - }), showAdvancedSettings: prefSpec({ extract: (value) => (typeof value === "boolean" ? value : undefined), local: (settings) => settings.showAdvancedSettings === true, diff --git a/ui/src/app/settings.node.test.ts b/ui/src/app/settings.node.test.ts index 2a8a2fa135f4..84acce8d23f9 100644 --- a/ui/src/app/settings.node.test.ts +++ b/ui/src/app/settings.node.test.ts @@ -5,6 +5,7 @@ import { createStorageMock } from "../test-helpers/storage.ts"; import { loadLocalUserIdentity, loadSettings, + normalizeChatMessageMaxWidth, persistSessionToken, resolvePageGatewaySettings, saveSettings, @@ -628,6 +629,26 @@ describe("loadSettings default gateway URL derivation", () => { ); }); + it("normalizes and persists browser-local chat message width", () => { + setTestLocation({ protocol: "https:", host: "gateway.example:8443", pathname: "/" }); + const gwUrl = expectedGatewayUrl(""); + const scopedKey = `openclaw.control.settings.v1:${gwUrl}`; + + expect(normalizeChatMessageMaxWidth(" min(1280px, 82%) ")).toBe("min(1280px, 82%)"); + expect(normalizeChatMessageMaxWidth("960px; color: red")).toBeUndefined(); + + saveSettings({ ...loadSettings(), chatMessageMaxWidth: " min(1280px, 82%) " }); + expect(JSON.parse(localStorage.getItem(scopedKey) ?? "{}").chatMessageMaxWidth).toBe( + "min(1280px, 82%)", + ); + expect(loadSettings().chatMessageMaxWidth).toBe("min(1280px, 82%)"); + + saveSettings({ ...loadSettings(), chatMessageMaxWidth: undefined }); + expect(JSON.parse(localStorage.getItem(scopedKey) ?? "{}")).not.toHaveProperty( + "chatMessageMaxWidth", + ); + }); + it("persists only a normalized realtime Talk microphone id", () => { setTestLocation({ protocol: "https:", diff --git a/ui/src/app/settings.ts b/ui/src/app/settings.ts index 4b4bae7cb91a..c388461165a7 100644 --- a/ui/src/app/settings.ts +++ b/ui/src/app/settings.ts @@ -51,6 +51,67 @@ import { normalizeLocalUserIdentity, type LocalUserIdentity } from "./user-ident export const TEXT_SCALE_STOPS = [90, 100, 110, 125, 140] as const; export type TextScaleStop = (typeof TEXT_SCALE_STOPS)[number]; +const CSS_WIDTH_KEYWORDS = new Set(["none", "min-content", "max-content"]); +const CSS_WIDTH_FUNCTIONS = new Set(["calc", "clamp", "fit-content", "max", "min"]); +const CSS_WIDTH_UNITS = new Set(["ch", "em", "rem", "vh", "vmax", "vmin", "vw", "px"]); +const CSS_WIDTH_ALLOWED_CHARS = /^[0-9A-Za-z.%+\-*/(),\s]+$/; +const CSS_WIDTH_IDENTIFIER_RE = /[A-Za-z][A-Za-z0-9-]*/g; +const CSS_WIDTH_SIMPLE_RE = /^(?:\d+(?:\.\d+)?|\.\d+)(?:px|rem|em|ch|vw|vh|vmin|vmax|%)$/i; +const CSS_WIDTH_MAX_LENGTH = 96; + +function hasBalancedParentheses(value: string): boolean { + let depth = 0; + for (const char of value) { + if (char === "(") { + depth++; + } else if (char === ")") { + depth--; + if (depth < 0) { + return false; + } + } + } + return depth === 0; +} + +function hasAllowedWidthIdentifiers(value: string): boolean { + for (const match of value.matchAll(CSS_WIDTH_IDENTIFIER_RE)) { + const identifier = match[0].toLowerCase(); + if ( + !CSS_WIDTH_FUNCTIONS.has(identifier) && + !CSS_WIDTH_KEYWORDS.has(identifier) && + !CSS_WIDTH_UNITS.has(identifier) + ) { + return false; + } + } + return true; +} + +export function normalizeChatMessageMaxWidth(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const normalized = value.trim().replace(/\s+/g, " "); + if (normalized.length === 0) { + return undefined; + } + if (normalized.length > CSS_WIDTH_MAX_LENGTH) { + return undefined; + } + if (CSS_WIDTH_KEYWORDS.has(normalized.toLowerCase()) || CSS_WIDTH_SIMPLE_RE.test(normalized)) { + return normalized; + } + if ( + !CSS_WIDTH_ALLOWED_CHARS.test(normalized) || + !hasBalancedParentheses(normalized) || + !hasAllowedWidthIdentifiers(normalized) + ) { + return undefined; + } + return /^(?:calc|clamp|fit-content|max|min)\(.+\)$/i.test(normalized) ? normalized : undefined; +} + const CHAT_SEND_SHORTCUTS = ["enter", "modifier-enter"] as const; export type ChatSendShortcut = (typeof CHAT_SEND_SHORTCUTS)[number]; @@ -126,6 +187,7 @@ export type UiSettings = { navWidth: number; // Sidebar width when expanded (240–400px) sidebarEntries: string[]; // Ordered routes, Workboard boards, and pinned sessions below Home sidebarLiveActivity?: boolean; // Latest activity under running sidebar sessions (default true) + chatMessageMaxWidth?: string; // Browser-local centered chat transcript max width showAdvancedSettings?: boolean; // Expand advanced schema settings (default false) pinnedAgentIds?: string[]; // Agents surfaced first in the agent-chip quick switcher textScale?: TextScaleStop; // Browser-local text scale percentage @@ -462,6 +524,7 @@ export function loadSettings(): UiSettings { typeof parsed.sidebarLiveActivity === "boolean" ? parsed.sidebarLiveActivity : defaults.sidebarLiveActivity, + chatMessageMaxWidth: normalizeChatMessageMaxWidth(parsed.chatMessageMaxWidth), showAdvancedSettings: typeof parsed.showAdvancedSettings === "boolean" ? parsed.showAdvancedSettings @@ -589,6 +652,9 @@ function persistSettings(next: UiSettings, options: { selectGateway?: boolean } navWidth: next.navWidth, sidebarEntries: next.sidebarEntries, ...(next.sidebarLiveActivity === false ? { sidebarLiveActivity: false } : {}), + ...(normalizeChatMessageMaxWidth(next.chatMessageMaxWidth) + ? { chatMessageMaxWidth: normalizeChatMessageMaxWidth(next.chatMessageMaxWidth) } + : {}), ...(next.showAdvancedSettings === true ? { showAdvancedSettings: true } : {}), // Empty pin list is the default; only real pins persist. ...(next.pinnedAgentIds && next.pinnedAgentIds.length > 0 diff --git a/ui/src/components/config-form.browser.test.ts b/ui/src/components/config-form.browser.test.ts index b945b9fdb69b..12c0447c4fe2 100644 --- a/ui/src/components/config-form.browser.test.ts +++ b/ui/src/components/config-form.browser.test.ts @@ -649,7 +649,7 @@ describe("config form renderer", () => { type: "array", items: { type: "string" }, }, - chatMessageMaxWidth: { + displayWidth: { type: "string", }, }, @@ -667,7 +667,7 @@ describe("config form renderer", () => { lastTouchedAt: "2026-05-05T00:00:00.000Z", setupCommand: "apt-get update", allowedDomains: ["example.com"], - chatMessageMaxWidth: "960px", + displayWidth: "960px", }, onPatch: vi.fn(), }), diff --git a/ui/src/i18n/.i18n/raw-copy-baseline.json b/ui/src/i18n/.i18n/raw-copy-baseline.json index 9dc8ca65087c..26b0ddaf6d34 100644 --- a/ui/src/i18n/.i18n/raw-copy-baseline.json +++ b/ui/src/i18n/.i18n/raw-copy-baseline.json @@ -596,6 +596,13 @@ "path": "ui/src/pages/config/mcp.ts", "text": "openclaw mcp status --verbose" }, + { + "count": 1, + "kind": "html-attribute", + "name": "placeholder", + "path": "ui/src/pages/config/view.ts", + "text": "48rem" + }, { "count": 1, "kind": "html-attribute", diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index 8e03dce7a6ed..afbe0d799d3e 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -1366,6 +1366,11 @@ export const en: TranslationMap = { chatPrefs: { title: "Chat", hint: "Browser-local chat preferences.", + messageWidth: "Message width", + messageWidthHint: + "Optional CSS width for the centered transcript, such as 960px, 82%, or min(1280px, 82%).", + messageWidthInvalid: + "Enter a CSS width such as 960px, 82%, min(1280px, 82%), or calc(100% - 2rem).", }, sidebarPrefs: { title: "Sidebar", diff --git a/ui/src/pages/chat/chat-pane.test.ts b/ui/src/pages/chat/chat-pane.test.ts index 0f19e6faef6a..d67e0b30e4b5 100644 --- a/ui/src/pages/chat/chat-pane.test.ts +++ b/ui/src/pages/chat/chat-pane.test.ts @@ -80,7 +80,6 @@ function createInitializationContext(): ApplicationContext { localMediaPreviewRoots: [], embedSandboxMode: "strict", allowExternalEmbedUrls: false, - chatMessageMaxWidth: null, terminalEnabled: false, }, }, diff --git a/ui/src/pages/chat/chat-pane.ts b/ui/src/pages/chat/chat-pane.ts index d4e618f8e7de..e881598e750f 100644 --- a/ui/src/pages/chat/chat-pane.ts +++ b/ui/src/pages/chat/chat-pane.ts @@ -2764,15 +2764,13 @@ class ChatPane extends OpenClawLightDomElement { !rootsChanged && state.terminalAvailable === previousTerminalAvailable && state.embedSandboxMode === config.embedSandboxMode && - state.allowExternalEmbedUrls === config.allowExternalEmbedUrls && - state.chatMessageMaxWidth === config.chatMessageMaxWidth + state.allowExternalEmbedUrls === config.allowExternalEmbedUrls ) { return; } state.localMediaPreviewRoots = config.localMediaPreviewRoots; state.embedSandboxMode = config.embedSandboxMode; state.allowExternalEmbedUrls = config.allowExternalEmbedUrls; - state.chatMessageMaxWidth = config.chatMessageMaxWidth; state.requestUpdate?.(); } @@ -3947,7 +3945,7 @@ class ChatPane extends OpenClawLightDomElement { localMediaPreviewRoots: state.localMediaPreviewRoots, embedSandboxMode: state.embedSandboxMode, allowExternalEmbedUrls: state.allowExternalEmbedUrls, - chatMessageMaxWidth: state.chatMessageMaxWidth, + chatMessageMaxWidth: state.settings.chatMessageMaxWidth, assistantAttachmentAuthToken: resolveAssistantAttachmentAuthToken(state as never), basePath: state.basePath, gatewayUrl: state.settings.gatewayUrl, diff --git a/ui/src/pages/chat/chat-state.test.ts b/ui/src/pages/chat/chat-state.test.ts index a07817bc7acf..3aa9294c8d7d 100644 --- a/ui/src/pages/chat/chat-state.test.ts +++ b/ui/src/pages/chat/chat-state.test.ts @@ -611,7 +611,6 @@ describe("image lightbox lifecycle", () => { current: { allowExternalEmbedUrls: false, assistantIdentity: { name: "Assistant" }, - chatMessageMaxWidth: null, embedSandboxMode: "scripts", localMediaPreviewRoots: [], }, diff --git a/ui/src/pages/chat/chat-state.ts b/ui/src/pages/chat/chat-state.ts index c5d928135ba9..9271e28dd6ee 100644 --- a/ui/src/pages/chat/chat-state.ts +++ b/ui/src/pages/chat/chat-state.ts @@ -215,7 +215,6 @@ export type ChatPageHost = ChatHost & localMediaPreviewRoots: string[]; embedSandboxMode: EmbedSandboxMode; allowExternalEmbedUrls: boolean; - chatMessageMaxWidth: string | null; chatToolMessages: Record[]; chatAttachments: ChatAttachment[]; chatQueue: ChatQueueItem[]; @@ -1271,7 +1270,6 @@ export function createPageState( localMediaPreviewRoots: appConfig.localMediaPreviewRoots, embedSandboxMode: appConfig.embedSandboxMode, allowExternalEmbedUrls: appConfig.allowExternalEmbedUrls, - chatMessageMaxWidth: appConfig.chatMessageMaxWidth, client: null, connected: false, connectionEpoch: 0, diff --git a/ui/src/pages/config/config-page.ts b/ui/src/pages/config/config-page.ts index c3ba1dd0a9e9..fec2d2a756d2 100644 --- a/ui/src/pages/config/config-page.ts +++ b/ui/src/pages/config/config-page.ts @@ -69,6 +69,7 @@ type ConfigSelection = { activeSection: string | null; activeSubsection: string type ConfigPageSetting = | "textScale" | "sidebarLiveActivity" + | "chatMessageMaxWidth" | "showAdvancedSettings" | "chatSendShortcut" | "chatFollowUpMode" @@ -713,6 +714,7 @@ export class ConfigPage extends OpenClawLightDomElement { customTheme: next.customTheme, textScale: next.textScale, sidebarLiveActivity: next.sidebarLiveActivity, + chatMessageMaxWidth: next.chatMessageMaxWidth, showAdvancedSettings: next.showAdvancedSettings, chatSendShortcut: next.chatSendShortcut, chatFollowUpMode: next.chatFollowUpMode, @@ -940,6 +942,8 @@ export class ConfigPage extends OpenClawLightDomElement { setTextScale: (value) => this.setSetting("textScale", normalizeTextScale(value)), sidebarLiveActivity: this.settings.sidebarLiveActivity !== false, setSidebarLiveActivity: (enabled) => this.setSetting("sidebarLiveActivity", enabled), + chatMessageMaxWidth: this.settings.chatMessageMaxWidth, + setChatMessageMaxWidth: (value) => this.setSetting("chatMessageMaxWidth", value), showAdvancedSettings: this.settings.showAdvancedSettings === true, setShowAdvancedSettings: (enabled) => this.setSetting("showAdvancedSettings", enabled), forceAdvancedSection: this.routeData?.advanced ? this.routeData.section : null, diff --git a/ui/src/pages/config/view.browser.test.ts b/ui/src/pages/config/view.browser.test.ts index dfa6c8b43d98..e82d4902be3c 100644 --- a/ui/src/pages/config/view.browser.test.ts +++ b/ui/src/pages/config/view.browser.test.ts @@ -69,6 +69,8 @@ describe("config view", () => { setTextScale: vi.fn(), sidebarLiveActivity: true, setSidebarLiveActivity: vi.fn(), + chatMessageMaxWidth: undefined, + setChatMessageMaxWidth: vi.fn(), showAdvancedSettings: false, setShowAdvancedSettings: vi.fn(), chatSendShortcut: "enter" as const, @@ -1633,6 +1635,26 @@ describe("config view", () => { expect(setSidebarLiveActivity).toHaveBeenCalledWith(false); }); + it("validates and changes the browser-local chat width", () => { + const setChatMessageMaxWidth = vi.fn(); + const { container } = renderConfigView({ + activeSection: "__appearance__", + includeSections: ["__appearance__"], + setChatMessageMaxWidth, + }); + const input = container.querySelector("[data-settings-chat-message-width]"); + expect(input).not.toBeNull(); + + input!.value = " min(1280px, 82%) "; + input!.dispatchEvent(new Event("change", { bubbles: true })); + expect(setChatMessageMaxWidth).toHaveBeenCalledWith("min(1280px, 82%)"); + + input!.value = "960px; color: red"; + input!.dispatchEvent(new Event("change", { bubbles: true })); + expect(input!.validationMessage).not.toBe(""); + expect(setChatMessageMaxWidth).toHaveBeenCalledTimes(1); + }); + it("marks browser follow-up overrides and resets them to the server", () => { const setChatFollowUpMode = vi.fn(); const { container } = renderConfigView({ diff --git a/ui/src/pages/config/view.ts b/ui/src/pages/config/view.ts index fd9b0b474bde..9a69ad577562 100644 --- a/ui/src/pages/config/view.ts +++ b/ui/src/pages/config/view.ts @@ -11,6 +11,7 @@ import { normalizeChatFollowUpMode, normalizeChatSendShortcut, normalizeCatalogOpenTarget, + normalizeChatMessageMaxWidth, TEXT_SCALE_STOPS, type ChatFollowUpMode, type ChatSendShortcut, @@ -186,6 +187,8 @@ export type ConfigProps = { setTextScale: (value: number) => void; sidebarLiveActivity: boolean; setSidebarLiveActivity: (enabled: boolean) => void; + chatMessageMaxWidth?: string; + setChatMessageMaxWidth: (value: string | undefined) => void; showAdvancedSettings: boolean; setShowAdvancedSettings: (enabled: boolean) => void; forceAdvancedSection?: string | null; @@ -1067,6 +1070,43 @@ function renderChatPreferencesSection(props: ConfigProps) { ${t("configView.chatPrefs.hint")} ${t("configView.syncedHint")}

+ ${renderSettingsRow({ + title: t("configView.chatPrefs.messageWidth"), + description: t("configView.chatPrefs.messageWidthHint"), + control: html` + { + const input = event.currentTarget as HTMLInputElement; + const normalized = normalizeChatMessageMaxWidth(input.value); + if (input.value.trim() && !normalized) { + input.setCustomValidity(t("configView.chatPrefs.messageWidthInvalid")); + input.reportValidity(); + return; + } + input.setCustomValidity(""); + input.value = normalized ?? ""; + props.setChatMessageMaxWidth(normalized); + }} + /> + ${props.chatMessageMaxWidth + ? html` + + ` + : nothing} + `, + })} ${renderSettingsSelectRow({ title: t("chat.sendShortcut"), value: props.chatSendShortcut, @@ -1226,9 +1266,7 @@ function renderSidebarPreferencesSection(props: ConfigProps) {

${t("configView.sidebarPrefs.title")}

-

- ${t("configView.sidebarPrefs.hint")} ${t("configView.syncedHint")} -

+

${t("configView.sidebarPrefs.hint")}

${renderSettingsToggleRow({ title: t("configView.sidebarPrefs.liveActivity"), diff --git a/ui/src/pages/plugin/plugin-page.test.ts b/ui/src/pages/plugin/plugin-page.test.ts index 7e309cc9eab2..9867f9304071 100644 --- a/ui/src/pages/plugin/plugin-page.test.ts +++ b/ui/src/pages/plugin/plugin-page.test.ts @@ -98,7 +98,6 @@ function externalPluginConfig( localMediaPreviewRoots: [], embedSandboxMode: "scripts", allowExternalEmbedUrls: false, - chatMessageMaxWidth: null, terminalEnabled: false, pluginFrameGrants, };