Compare commits

..

1 Commits

Author SHA1 Message Date
Tak Hoffman
91625aa9f3 feat capability CLI on latest main 2026-04-06 17:30:59 -05:00
2965 changed files with 38083 additions and 84766 deletions

View File

@@ -33,7 +33,6 @@ Use this skill for Parallels guest workflows and smoke interpretation. Do not lo
- The aggregate npm-update wrapper must resolve the Linux VM with the same Ubuntu fallback policy as `parallels-linux-smoke.sh` before both fresh and update lanes. Treat any Ubuntu guest with major version `>= 24` as acceptable when the exact default VM is missing, preferring the closest version match. On Peter's current host today, missing `Ubuntu 24.04.3 ARM64` should fall back to `Ubuntu 25.10`.
- On macOS same-guest update checks, restart the gateway after the npm upgrade before `gateway status` / `agent`; launchd can otherwise report a loaded service while the old process has exited and the fresh process is not RPC-ready yet.
- On Windows same-guest update checks, restart the gateway after the npm upgrade before `gateway status` / `agent`; in-place global npm updates can otherwise leave stale hashed `dist/*` module imports alive in the running service.
- After that Windows restart, do not trust one `gateway status --deep --require-rpc` call after a fixed sleep. Retry the RPC-ready probe for roughly 30 seconds and log each attempt; current guests can keep port `18789` bound while the fresh RPC endpoint is still coming up.
- For Windows same-guest update checks, prefer the done-file/log-drain PowerShell runner pattern over one long-lived `prlctl exec ... powershell -EncodedCommand ...` transport. The guest can finish successfully while the outer `prlctl exec` still hangs.
- The Windows same-guest update helper should write stage markers to its log before long steps like tgz download and `npm install -g` so the outer progress monitor does not sit on `waiting for first log line` during healthy but quiet installs.
- Linux same-guest update verification should also export `HOME=/root`, pass `OPENAI_API_KEY` via `prlctl exec ... /usr/bin/env`, and use `openclaw agent --local`; the fresh Linux baseline does not rely on persisted gateway credentials.
@@ -57,7 +56,6 @@ Use this skill for Parallels guest workflows and smoke interpretation. Do not lo
- On Peter's Tahoe VM, `fresh-latest-march-2026` can hang in `prlctl snapshot-switch`; if restore times out there, rerun with `--snapshot-hint 'macOS 26.3.1 latest'` before blaming auth or the harness.
- `parallels-macos-smoke.sh` now retries `snapshot-switch` once after force-stopping a stuck running/suspended guest. If Tahoe still times out after that recovery path, then treat it as a real Parallels/host issue and rerun manually.
- The macOS smoke should include a dashboard load phase after gateway health: resolve the tokenized URL with `openclaw dashboard --no-open`, verify the served HTML contains the Control UI title/root shell, then open Safari and require an established localhost TCP connection from Safari to the gateway port.
- If a Tahoe lane times out in `fresh.first-agent-turn` and the phase log stops right after `__OPENCLAW_RC__:0` from `models set`, suspect the `prlctl enter` / `expect` wrapper before blaming auth or the model lane. That pattern means the first guest command finished but the transport never released for the next `guest_current_user_cli` call.
- If a packaged install regresses with `500` on `/`, `/healthz`, or `__openclaw/control-ui-config.json` after `fresh.install-main` or `upgrade.install-main`, suspect bundled plugin runtime deps resolving from the package root `node_modules` rather than `dist/extensions/*/node_modules`. Repro quickly with a real `npm pack`/global install lane before blaming dashboard auth or Safari.
- `prlctl exec` is fine for deterministic repo commands, but use the guest Terminal or `prlctl enter` when installer parity or shell-sensitive behavior matters.
- Multi-word `openclaw agent --message ...` checks should go through a guest shell wrapper (`guest_current_user_sh` / `guest_current_user_cli` or `/bin/sh -lc ...`), not raw `prlctl exec ... node openclaw.mjs ...`, or the message can be split into extra argv tokens and Commander reports `too many arguments for 'agent'`.

View File

@@ -39,6 +39,7 @@ pnpm openclaw qa suite \
--provider-mode live-openai \
--model openai/gpt-5.4 \
--alt-model openai/gpt-5.4 \
--fast \
--output-dir .artifacts/qa-e2e/run-all-live-openai-<tag>
```

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

View File

@@ -753,11 +753,6 @@ jobs:
continue-on-error: true
run: pnpm run lint:extensions:bundled
- name: Run extension package boundary TypeScript check
id: extension_package_boundary_tsc
continue-on-error: true
run: pnpm run test:extensions:package-boundary
- name: Enforce safe external URL opening policy
id: no_raw_window_open
continue-on-error: true
@@ -802,7 +797,6 @@ jobs:
EXTENSION_RELATIVE_OUTSIDE_PACKAGE_BOUNDARY_OUTCOME: ${{ steps.extension_relative_outside_package_boundary.outcome }}
EXTENSION_CHANNEL_LINT_OUTCOME: ${{ steps.extension_channel_lint.outcome }}
EXTENSION_BUNDLED_LINT_OUTCOME: ${{ steps.extension_bundled_lint.outcome }}
EXTENSION_PACKAGE_BOUNDARY_TSC_OUTCOME: ${{ steps.extension_package_boundary_tsc.outcome }}
NO_RAW_WINDOW_OPEN_OUTCOME: ${{ steps.no_raw_window_open.outcome }}
CONTROL_UI_I18N_OUTCOME: ${{ steps.control_ui_i18n.outcome == 'skipped' && 'success' || steps.control_ui_i18n.outcome }}
GATEWAY_WATCH_REGRESSION_OUTCOME: ${{ steps.gateway_watch_regression.outcome }}
@@ -826,7 +820,6 @@ jobs:
"extension-relative-outside-package-boundary|$EXTENSION_RELATIVE_OUTSIDE_PACKAGE_BOUNDARY_OUTCOME" \
"lint:extensions:channels|$EXTENSION_CHANNEL_LINT_OUTCOME" \
"lint:extensions:bundled|$EXTENSION_BUNDLED_LINT_OUTCOME" \
"test:extensions:package-boundary|$EXTENSION_PACKAGE_BOUNDARY_TSC_OUTCOME" \
"lint:ui:no-raw-window-open|$NO_RAW_WINDOW_OPEN_OUTCOME" \
"ui:i18n:check|$CONTROL_UI_I18N_OUTCOME" \
"gateway-watch-regression|$GATEWAY_WATCH_REGRESSION_OUTCOME"; do

View File

@@ -6,97 +6,68 @@ Docs: https://docs.openclaw.ai
### Changes
- Memory/Active Memory: add a new optional Active Memory plugin that gives OpenClaw a dedicated memory subagent right before the main reply, so ongoing chats can automatically pull in relevant preferences, context, and past details without making users remember to manually say "remember this" or "search memory" first. Includes configurable message/recent/full context modes, live `/verbose` inspection, and opt-in transcript persistence for debugging.
- CLI/infer: add a first-class `openclaw infer ...` hub for provider-backed inference workflows across model, media, web, and embedding tasks. Thanks @Takhoffman.
- CLI/capabilities: add a first-class `openclaw capability ...` hub for provider-backed inference workflows across model, media, web, and embedding tasks, with capability inspection, provider discovery, and consistent JSON output. Thanks @Takhoffman.
- Providers/Anthropic: restore Claude CLI as the preferred local Anthropic path in onboarding, model-auth guidance, and doctor flows again, and keep the Docker Claude CLI live lane aligned with the restored guidance.
- Plugins/webhooks: add a bundled webhook ingress plugin so external automation can create and drive bound TaskFlows through per-route shared-secret endpoints. (#61892) Thanks @mbelinky.
- Tools/media generation: preserve intent across auth-backed image, music, and video provider fallback, remap size, aspect ratio, resolution, and duration hints to the closest supported option, and surface explicit provider capabilities plus mode-aware video-to-video support.
- Memory/wiki: restore the bundled `memory-wiki` stack with plugin, CLI, sync/query/apply tooling, and memory-host integration for wiki-backed memory workflows.
- Providers/Arcee AI: add a bundled Arcee AI provider plugin with Trinity catalog entries, OpenRouter support, and updated onboarding/auth guidance. (#62068) Thanks @arthurbr11.
- Providers/Google: add Gemma 4 model support and keep Google fallback resolution on the requested provider path so native Google Gemma routes work again. (#61507) Thanks @eyjohn.
- Providers/Google: preserve explicit thinking-off semantics for Gemma 4 while still enabling Gemma reasoning support in the Google compatibility wrappers. (#62127) Thanks @romgenie, co-authored with BunsDev.
- Providers/Anthropic: restore Claude CLI as the preferred local Anthropic path in onboarding, model-auth guidance, doctor flows, and Docker Claude CLI live lanes again.
- ACP/ACPX plugin: bump the bundled `acpx` pin to `0.5.1` so plugin-local installs and strict version checks pick up the latest published runtime release. (#62148) Thanks @onutc.
- Tools/media generation: auto-fallback across auth-backed image, music, and video providers by default, and remap fallback size, aspect ratio, resolution, and duration hints to the closest supported option instead of dropping intent on provider switches.
- Tools/media generation: report applied fallback geometry and duration settings consistently in tool results, add a shared normalization contract for image/music/video runtimes, and simplify the bundled image-generation-core runtime test to only verify the plugin-sdk re-export seam.
- Gateway/sessions: add persisted compaction checkpoints plus Sessions UI branch/restore actions so operators can inspect and recover pre-compaction session state. (#62146) Thanks @scoootscooob.
- Providers/Ollama: detect vision capability from the `/api/show` response and set image input on models that support it so Ollama vision models accept image attachments. (#62193) Thanks @BruceMacD.
- Memory/dreaming: ingest redacted session transcripts into the dreaming corpus with per-day session-corpus notes, cursor checkpointing, and promotion/doctor support. (#62227) Thanks @vignesh07.
- Plugins/memory: add a public memory-artifact export seam to the unified memory capability so companion plugins like `memory-wiki` can bridge the active memory plugin without reaching into `memory-core` internals. Thanks @vincentkoc.
- Memory/wiki: add structured claim/evidence fields plus compiled agent digest artifacts so `memory-wiki` behaves more like a persistent knowledge layer and less like markdown-only page storage. Thanks @vincentkoc.
- Memory/wiki: add claim-health linting, contradiction clustering, staleness-aware dashboards, and freshness-weighted wiki search so `memory-wiki` can act more like a maintained belief layer than a passive markdown dump. Thanks @vincentkoc.
- Memory/wiki: use compiled digest artifacts as the first-pass wiki index for search/get flows, and resolve claim ids back to owning pages so agents can retrieve knowledge by belief identity instead of only by file path. Thanks @vincentkoc.
- Memory/wiki: add an opt-in `context.includeCompiledDigestPrompt` flag so memory prompt supplements can append a compact compiled wiki snapshot for legacy prompt assembly and context engines that explicitly consume memory prompt sections. Thanks @vincentkoc.
- Plugin SDK/context engines: pass `availableTools` and `citationsMode` into `assemble()`, and expose `buildMemorySystemPromptAddition(...)` so non-legacy context engines can adopt the active memory prompt path without reimplementing it. Thanks @vincentkoc.
- Providers/inferrs: add string-content compatibility for stricter OpenAI-compatible chat backends, document `inferrs` setup with a full config example, and add troubleshooting guidance for local backends that pass direct probes but fail on full agent-runtime prompts.
- Agents/context engine: expose prompt-cache runtime context to context engines and keep current-turn prompt-cache usage aligned with the active attempt instead of stale prior-turn assistant state. (#62179) Thanks @jalehman.
- Tools/media: document per-provider music and video generation capabilities, and add shared live video-to-video sweep coverage for providers that support local reference clips.
- Compaction: add pluggable compaction provider registry so plugins can replace the built-in summarization pipeline. Configure via `agents.defaults.compaction.provider`; falls back to LLM summarization on provider failure. (#56224) Thanks @DhruvBhatia0.
- Discord/events: allow `event-create` to accept a cover image URL or local file path, load and validate PNG/JPG/GIF event cover media, and pass the encoded image payload through Discord admin action/runtime paths. (#60883) Thanks @bittoby.
### Fixes
- Plugins/media: when `plugins.allow` is set, capability fallback now merges bundled capability plugin ids into the allowlist (not only `plugins.entries`), so media understanding providers such as OpenAI-compatible STT load for voice transcription without requiring `openai` in `plugins.allow`. (#62205) Thanks @neeravmakwana.
- CLI/infer: keep provider-backed infer behavior aligned with actual runtime execution by fixing explicit TTS override handling, profile-aware gateway TTS prefs resolution, per-request transcription `prompt`/`language` overrides, image output MIME/extension mismatches, configured web-search fallback behavior, and agent-vs-CLI web-search execution drift.
- CLI/capabilities: keep provider-backed capability behavior aligned with actual runtime execution by fixing explicit TTS override handling, profile-aware gateway TTS prefs resolution, per-request transcription `prompt`/`language` overrides, image output MIME/extension mismatches, configured web-search fallback behavior, and agent-vs-CLI web-search execution drift.
- Channels/secrets: keep bundled channel artifact and secret-contract loading stable under lazy loading so bundled channel secrets continue to appear in `openclaw secret`, status, and security-audit surfaces.
- Providers/xAI: recognize `api.grok.x.ai` as an xAI-native endpoint again so native xAI web-search attribution keeps working on Grok-hosted base URLs. (#61377) Thanks @jjjojoj.
- Providers/Anthropic/cache: preserve thinking blocks for Claude Opus 4.5+, Sonnet 4.5+, and newer Claude 4-family models so Anthropic prompt-cache prefixes keep matching after thinking turns. (#61793)
- Auth/OpenAI Codex OAuth: reload fresh on-disk credentials inside the locked refresh path and retry once after `refresh_token_reused` rotates only the stored refresh token, so relogin/restart recovery stops getting stuck on stale cached auth state. Thanks @owen-ever.
- Agents/history and replies: buffer phaseless OpenAI WS text until a real assistant phase arrives, keep replay and SSE history sequence tracking aligned, hide commentary and leaked tool XML from user-visible history, and keep history-based follow-up replies on `final_answer` text only. (#61729, #61747, #61829, #61855, #61954) Thanks @100yenadmin, @afurm, and @openperf.
- Plugins/channels: keep bundled channel artifact and secret-contract loading stable under lazy loading, preserve plugin-schema defaults during install, and fix Windows `file://` plus native-Jiti plugin loader paths so onboarding, doctor, `openclaw secret`, and bundled plugin installs work again. (#61832, #61836, #61853, #61856) Thanks @Zeesejo and @SuperMarioYL.
- Plugins/ClawHub: verify downloaded plugin archives against version metadata SHA-256, fail closed when archive integrity metadata is missing or malformed, and tighten fallback ZIP verification so plugin installs cannot proceed on mismatched or incomplete ClawHub package metadata. (#60517) Thanks @mappel-nv.
- Auto-reply/media: allow managed generated-media `MEDIA:` paths from normal reply text again while still blocking arbitrary host-local media and document paths, so generated media keep delivering without reopening host-path injection holes.
- Runtime event trust: mark background `notifyOnExit` summaries, ACP parent-stream relays, and wake-hook payloads as untrusted system events so lower-trust runtime output no longer re-enters later turns as trusted `System:` text. (#62003)
- Providers/Anthropic: preserve thinking blocks for Claude Opus 4.5+, Sonnet 4.5+, and newer Claude 4-family models so prompt-cache prefixes keep matching, and skip `service_tier` injection on OAuth-authenticated stream wrapper requests so Claude OAuth streaming stops failing with HTTP 401. (#60356, #61793)
- Control UI: show `/tts` audio replies in webchat, detect mistaken `?token=` auth links with the correct `#token=` hint, and keep Copy, Canvas, and mobile exec-approval UI from covering chat content on narrow screens. (#54842, #61514, #61598) Thanks @neeravmakwana.
- TUI: route `/status` through the shared session-status command, keep commentary hidden in history, strip raw envelope metadata from async command notices, preserve fallback streaming before per-attempt failures finalize, and restore Kitty keyboard state on exit or fatal crashes. (#49130, #59985, #60043, #61463) Thanks @biefan, @MoerAI, @jwchmodx, and @100yenadmin.
- Sessions/model selection: resolve the explicitly selected session model separately from runtime fallback resolution so session status and live model switching stay aligned with the chosen model.
- Discord/ACP bindings: canonicalize DM conversation identity across inbound messages, component interactions, native commands, and current-conversation binding resolution so `--bind here` in Discord DMs keeps routing follow-up replies to the bound agent instead of falling back to the default agent.
- iOS/Watch exec approvals: keep Apple Watch review and approval recovery working while the iPhone is locked or backgrounded, including reconnect recovery, pending approval persistence, notification cleanup, and APNs-backed watch refresh recovery. (#61757) Thanks @ngutman.
- Nodes/exec approvals: keep `host=node` POSIX transport shell wrappers (`/bin/sh -lc ...`) aligned with inner-command allowlist analysis so allowlisted scripts stop prompting unnecessarily, while Windows `cmd.exe` wrapper runs stay approval-gated. (#62401) Thanks @ngutman.
- Nodes/exec approvals: keep Windows `cmd.exe /c` wrapper runs approval-gated even when `env` carriers, including env-assignment carriers, wrap the shell invocation. (#62439) Thanks @ngutman.
- Agents/context overflow: combine oversized and aggregate tool-result recovery in one pass and restore a total-context overflow backstop so recoverable sessions retry instead of failing early. (#61651) Thanks @Takhoffman.
- Agents/exec: preserve explicit `host=node` routing under elevated defaults when `tools.exec.host=auto`, fail loud on invalid elevated cross-host overrides, and keep `strictInlineEval` commands blocked after approval timeouts instead of falling through to automatic execution. (#61739) Thanks @obviyus.
- Host exec/env sanitization: block dangerous `JAVA_OPTS`, `RUSTFLAGS`, and `CARGO_HOME` inputs at the host-exec boundary so attacker-controlled env overrides can no longer inject JVM agents, compiler flags, or Cargo state pivots into host-run processes. (#62291) Thanks @pgondhi987.
- Commands/allowlist: require owner authorization for `/allowlist add` and `/allowlist remove` before channel resolution, so non-owner but command-authorized senders can no longer persistently rewrite allowlist policy state. (#62383) Thanks @pgondhi987.
- Feishu/docx uploads: honor `tools.fs.workspaceOnly` for local `upload_file` and `upload_image` paths by forwarding workspace-constrained `localRoots` into the media loader, so docx uploads can no longer read host-local files outside the workspace when workspace-only mode is active. (#62369) Thanks @pgondhi987.
- Network/fetch guard: drop request bodies and body-describing headers on cross-origin `307` and `308` redirects by default, so attacker-controlled redirect hops cannot receive secret-bearing POST payloads from SSRF-guarded fetch flows unless a caller explicitly opts in. (#62357) Thanks @pgondhi987.
- Providers/Ollama: honor the selected provider's `baseUrl` during streaming so multi-Ollama setups stop routing every stream to the first configured Ollama endpoint. (#61678)
- Browser/remote CDP: retry the DevTools websocket once after remote browser restarts so healthy remote browser profiles do not fail availability checks during CDP warm-up. (#57397) Thanks @ThanhNguyxn07.
- Browser/SSRF: treat main-frame `document` redirect hops as navigations even when Playwright does not flag them as `isNavigationRequest()`, so strict private-network blocking still stops forbidden redirect pivots before the browser reaches the internal target. (#62355) Thanks @pgondhi987.
- Gateway/node pairing: require a fresh pairing request when a previously paired node reconnects with additional declared commands, and keep the live session pinned to the earlier approved command set until the upgrade is approved. (#62658) Thanks @eleqtrizit.
- Gateway/auth: invalidate existing shared-token and password WebSocket sessions when the configured secret rotates, so stale authenticated sockets cannot stay attached after token or password changes. (#62350) Thanks @pgondhi987.
- Gateway/status and containers: auto-bind to `0.0.0.0` inside Docker and Podman environments, and probe local TLS gateways over `wss://` with self-signed fingerprint forwarding so container startup and loopback TLS status checks work again. (#61818, #61935) Thanks @openperf and @ThanhNguyxn07.
- macOS/gateway version: strip trailing commit metadata from CLI version output before semver parsing so the Mac app recognizes installed gateway versions like `OpenClaw 2026.4.2 (d74a122)` again. (#61111) Thanks @oliviareid-svg.
- Discord: recover forwarded referenced message text and attachments when snapshots are missing, use `ws://` again for gateway monitor sockets, stop forcing a hardcoded temperature for Codex-backed auto-thread titles, and harden voice receive recovery so rapid speaker restarts keep their next utterance. (#41536, #61670) Thanks @artwalker and @wit-oc.
- Slack/threading: keep legacy thread stickiness for real replies when older callers omit `isThreadReply`, while still honoring `replyToMode` for Slack's auto-created top-level `thread_ts`. (#61835) Thanks @kaonash.
- Plugins/loaders: centralize bundled `dist/**` Jiti native-load policy and keep channel, public-surface, facade, and config-metadata loader seams off native Jiti on Windows so onboarding and configure flows stop tripping `ERR_UNSUPPORTED_ESM_URL_SCHEME`. (#62286) Thanks @chen-zhang-cs-code.
- Providers/xAI: recognize `api.grok.x.ai` as an xAI-native endpoint again and keep legacy `x_search` auth resolution working so older xAI web-search configs continue to load. (#61377) Thanks @jjjojoj.
- Memory/vector recall: surface explicit warnings when `sqlite-vec` is unavailable or vector writes are degraded, and strip managed Light Sleep and REM blocks before daily-note ingestion so memory indexing and dreaming stop reporting false-success or re-ingesting staged output. (#61720) Thanks @MonkeyLeeT.
- Matrix/formatting: preserve multi-paragraph and loose-list rendering in Element so numbered and bulleted Markdown keeps their content attached to the correct list item. (#60997) Thanks @gucasbrg.
- Memory/vector recall: surface explicit warnings when `sqlite-vec` is unavailable or vector writes are degraded so memory indexing no longer reports false-success while semantic recall is impaired.
- MS Teams/security: validate file-consent upload URLs against HTTPS, Microsoft/SharePoint host allowlists, and private-IP DNS checks before uploading attachments, blocking SSRF-style consent-upload abuse. (#23596)
- QQ Bot/media: route gateway-side attachment and fallback downloads through guarded QQ/Tencent HTTPS fetches so QQ media handling no longer follows arbitrary remote hosts.
- Providers/Ollama: stop warning that Ollama could not be reached when discovery only sees empty default local stubs, while still keeping real explicit Ollama overrides loud when the endpoint is unreachable.
- Discord/gateway monitor: use `ws://` again for gateway monitor sockets so Discord monitor connections recover reliably after recent gateway socket changes.
- Control UI/auth URLs: detect mistaken `?token=` links, show the correct `#token=` fragment hint only on real auth failures, and stop masking the real problem behind a generic device-identity error. (#54842)
- Control UI/chat layout: keep Copy and Canvas actions plus mobile exec-approval overlays from covering chat text or command previews on narrow screens. (#61514)
- Matrix/formatting: preserve multi-paragraph and loose-list rendering in Element so numbered and bulleted Markdown keeps its content attached to the correct list item. (#60997) Thanks @gucasbrg.
- Sessions/model selection: resolve the explicitly selected session model separately from runtime fallback resolution so session status and live model switching stay aligned with the chosen model.
- Secrets/x_search: keep legacy `x_search` auth resolution working so older xAI web-search configs continue to load after the plugin-owned auth move.
- iOS/Watch exec approvals: keep Apple Watch review and approval recovery working while the iPhone is locked or backgrounded, including background-safe reconnects, persisted pending approvals, notification cleanup, and APNs-backed watch refresh recovery. (#61757) Thanks @ngutman.
- Discord/forwarding: recover forwarded referenced message text and attachments when Discord omits snapshot payloads, so forwarded-message relays keep the original content. (#61670) Thanks @artwalker.
- TUI/status: route `/status` through the shared session-status command and move the old gateway-wide diagnostic summary to `/gateway-status` (`/gwstatus`). Thanks @vincentkoc.
- TUI/history and heartbeat: keep assistant commentary hidden on both streamed and reloaded TUI history views, preserve the phase-sanitized REST history contract, and stop forced heartbeat runs from targeting subagent sessions. (#61463) Thanks @100yenadmin.
- TUI/command messages: strip inbound envelope metadata before rendering command/system messages so async completion notices stop leaking raw wrappers into the operator terminal. (#59985) Thanks @MoerAI.
- TUI/terminal: restore Kitty keyboard protocol and `modifyOtherKeys` state on TUI exit and fatal CLI crashes so parent shells stop inheriting broken keyboard input after `openclaw tui` exits. (#49130) Thanks @biefan.
- Plugins/Windows: load plugin entrypoints through `file://` import specifiers on Windows without breaking plugin SDK alias resolution, fixing `ERR_UNSUPPORTED_ESM_URL_SCHEME` for absolute plugin paths. (#61832) Thanks @Zeesejo.
- Plugins/Windows: disable native Jiti loading for setup and doctor contract registries on Windows so onboarding and config-doctor plugin probes stop crashing with `ERR_UNSUPPORTED_ESM_URL_SCHEME`. (#61836, #61853)
- Plugins/install: preserve plugin-schema defaults during fresh-install raw config validation so bundled plugin installs stop failing when required fields rely on schema defaults. (#61856) Thanks @SuperMarioYL.
- macOS/gateway version: strip trailing commit metadata from CLI version output before semver parsing so the Mac app recognizes installed gateway versions like `OpenClaw 2026.4.2 (d74a122)` again. (#61111) Thanks @oliviareid-svg.
- Gateway/containers: auto-bind to `0.0.0.0` during container startup for Docker and Podman compatibility, while keeping host-side status and doctor checks on the hardened loopback default when `gateway.bind` is unset. (#61818) Thanks @openperf.
- Gateway/status: probe local TLS gateways over `wss://`, forward the local cert fingerprint for self-signed loopback probes, and warn when the local TLS runtime cannot load the configured cert. (#61935) Thanks @ThanhNguyxn07.
- Slack/threading: keep legacy thread stickiness for real replies when older callers omit `isThreadReply`, while still honoring `replyToMode` for Slack's auto-created top-level `thread_ts`. (#61835) Thanks @kaonash.
- Discord/voice: re-arm DAVE receive passthrough without suppressing decrypt-failure rejoin recovery, and clear capture state before finalize teardown so rapid speaker restarts keep their next utterance. (#41536) Thanks @wit-oc.
- Providers/Google: recognize Gemma model ids in native Google forward-compat resolution, keep the requested provider when cloning fallback templates, and force Gemma reasoning off so Gemma 4 routes stop failing through the Google catalog fallback. (#61507) Thanks @eyjohn.
- Providers/Anthropic: skip `service_tier` injection for OAuth-authenticated stream wrapper requests so Claude OAuth requests stop failing with HTTP 401. (#60356) thanks @openperf.
- Providers/OpenAI: keep WebSocket text buffered until a real assistant phase arrives, even when text deltas land before a phaseless `output_item.added` announcement. (#61954) Thanks @100yenadmin.
- Providers/OpenAI: accept case-insensitive `plugins.entries.openai.config.personality` values, keep unknown overrides on the friendly overlay path, and add `on` as an alias for `friendly`. Thanks @vincentkoc.
- Discord/thread titles: stop forcing a hardcoded temperature for generated auto-thread names so Codex-backed thread title generation works on `openai-codex/*` models again. (#59525)
- Agents/message tool: add a `read` plus `threadId` discoverability hint when the configured channel actions support threaded message reads.
- Agents/context overflow: combine oversized and aggregate tool-result recovery in one repair pass, and restore a total-context overflow backstop during tool loops so recoverable sessions retry instead of failing early. (#61651) Thanks @Takhoffman.
- Agents/exec: preserve explicit `host=node` routing under elevated defaults when `tools.exec.host=auto`, and fail loud on invalid elevated cross-host overrides. (#61739) Thanks @obviyus.
- Agents/heartbeat: stop truncating live session transcripts after no-op heartbeat acks, move heartbeat cleanup to prompt assembly and compaction, and keep post-filter context-engine ingestion aligned with the real session baseline. (#60998) Thanks @nxmxbbd.
- Gateway/TUI: defer terminal chat finalization for per-attempt lifecycle errors so fallback retries keep streaming before the run is marked failed. (#60043) Thanks @jwchmodx.
- Gateway/history: seed SSE startup history and raw transcript sequence tracking from one initial transcript snapshot so first history events cannot diverge from subsequent message sequence numbering. (#61855) Thanks @100yenadmin.
- Agents/history: keep history-based reply reads and subagent completion summaries on `final_answer` text only so internal commentary stops leaking into user-visible follow-up replies. (#61747) Thanks @afurm.
- Agents/history: suppress commentary-only visible-text leaks in streaming and chat history views, and keep sanitized SSE history sequence numbers monotonic after transcript-only refreshes. (#61829) Thanks @100yenadmin.
- Agents/history: use one shared assistant-visible sanitizer across embedded delivery and chat-history extraction so leaked `<tool_call>` and `<tool_result>` XML blocks stay hidden from user-facing replies. (#61729) Thanks @openperf.
- Agents/history: keep truly legacy unsigned replay text unphased when mixed with phased OpenAI WS assistant blocks, while still inheriting message phase for id-only replay signatures. (#61529) Thanks @100yenadmin.
- Memory/dreaming: strip managed Light Sleep and REM blocks before daily-note ingestion so dreaming summaries stop re-ingesting their own staged output into new candidates. (#61720) Thanks @MonkeyLeeT.
- Docs/i18n: relocalize final localized-page links after translation so generated locale pages stop keeping stale English-root links when targets appear later in the same run. (#61796) thanks @hxy91819.
- Docs/i18n: remove the zh-CN homepage redirect override so Mintlify can resolve the localized Chinese homepage without self-redirecting `/zh-CN/index`.
- Tools/web_fetch and web_search: fix `TypeError: fetch failed` caused by undici 8.0 enabling HTTP/2 by default; pinned SSRF-guard dispatchers now explicitly set `allowH2: false` to restore HTTP/1.1 behavior and keep the custom DNS-pinning lookup compatible. (#61738, #61777) Thanks @zozo123.
- Tools/web search/Exa: show Exa Search in onboarding and configure provider pickers again by marking the bundled Exa provider as setup-visible. Thanks @vincentkoc.
- Docs/i18n: relocalize final localized-page links after translation and remove the zh-CN homepage redirect override so localized Mintlify pages resolve to the correct language roots again. (#61796) Thanks @hxy91819.
- Agents/session keys: backfill `sessionKey` from `sessionId` in the embedded PI runner when callers omit it, so hooks, LCM, and compaction receive a valid key; also normalize whitespace-only session keys to `undefined` before downstream consumers see them. (#60555) Thanks @100yenadmin.
- Plugins/provider hooks: stop recursive provider snapshot loads from overflowing the stack during plugin initialization, while still preserving cached nested provider-hook results. (#61922, #61938, #61946, #61951)
- Discord/voice: re-arm DAVE receive passthrough without suppressing decrypt-failure rejoin recovery, and clear capture state before finalize teardown so rapid speaker restarts keep their next utterance. (#41536) Thanks @wit-oc.
- Agents/exec: keep `strictInlineEval` commands blocked after approval timeouts on both gateway and node exec hosts, so timeout fallback no longer turns timed-out inline interpreter prompts into automatic execution.
- QQ Bot/media: route gateway-side attachment and fallback downloads through guarded QQ/Tencent HTTPS fetches so QQ media handling no longer follows arbitrary remote hosts.
- Exec/runtime events: mark background `notifyOnExit` summaries and ACP parent-stream relays as untrusted system events so lower-trust runtime output no longer re-enters later turns as trusted `System:` text.
- Hooks/wake: queue direct and mapped wake-hook payloads as untrusted system events so external wake content no longer enters the main session as trusted input. (#62003)
- Browser/node invoke: block persistent browser profile create, reset, and delete mutations through `browser.proxy` on both gateway-forwarded `node.invoke` and the node-host proxy path, even when no profile allowlist is configured. (#60489)
- Slack/thread mentions: add `channels.slack.thread.requireExplicitMention` so Slack channels that already require mentions can also require explicit `@bot` mentions inside bot-participated threads. (#58276) Thanks @praktika-engineer.
- UI/light mode: target both root and nested WebKit scrollbar thumbs in the light theme so page-level and container scrollbars stay visible on light backgrounds. (#61753) Thanks @chziyue.
- Matrix/onboarding: add an invite auto-join setup step with explicit off warnings and strict stable-target validation so new Matrix accounts stop silently ignoring invited rooms and fresh DM-style invites unless operators opt in. (#62168) Thanks @gumadeiras.
- Telegram/doctor: keep top-level access-control fallback in place during multi-account normalization while still promoting legacy default auth into `accounts.default`, so existing named bots keep inherited allowlists without dropping the legacy default bot. (#62263) Thanks @obviyus.
- Agents/subagents: honor `sessions_spawn(lightContext: true)` for spawned subagent runs by preserving lightweight bootstrap context through the gateway and embedded runner instead of silently falling back to full workspace bootstrap injection. (#62264) Thanks @theSamPadilla.
- Slack/media: keep attachment downloads on the SSRF-guarded dispatcher path so Slack media fetching works on Node 22 without dropping pinned transport enforcement. (#62239) Thanks @openperf.
- Docker/plugins: stop forcing bundled plugin discovery to `/app/extensions` in runtime images so packaged installs use compiled `dist/extensions` artifacts again and Node 24 containers do not boot through source-only plugin entry paths. Fixes #62044. (#62316) Thanks @gumadeiras.
- Cron: load `jobId` into `id` when the on-disk store omits `id`, matching doctor migration and fixing `unknown cron job id` for hand-edited `jobs.json`. (#62246) Thanks @neeravmakwana.
- Agents/model fallback: classify minimal HTTP 404 API errors (for example `404 status code (no body)`) as `model_not_found` so assistant failures throw into the fallback chain instead of stopping at the first fallback candidate. (#62119) Thanks @neeravmakwana.
- Providers/Mistral: send `reasoning_effort` for `mistral/mistral-small-latest` (Mistral Small 4) with thinking-level mapping, and mark the catalog entry as reasoning-capable so adjustable reasoning matches Mistrals Chat Completions API. (#62162) Thanks @neeravmakwana.
- OpenAI TTS/Groq: send `wav` to Groq-compatible speech endpoints, honor explicit `responseFormat` overrides on OpenAI-compatible paths, and only mark voice-note output as voice-compatible when the actual format is `opus`. (#62233) Thanks @neeravmakwana.
- BlueBubbles/network: respect explicit private-network opt-out for loopback and private `serverUrl` values across account resolution, status probes, monitor startup, and attachment downloads, while keeping public-host attachment hostname pinning intact. (#59373) Thanks @jpreagan.
- Agents/heartbeat: keep heartbeat runs pinned to the main session so active subagent transcripts are not overwritten by heartbeat status messages. (#61803) Thanks @100yenadmin.
- Agents/compaction: stop compaction-wait aborts from re-entering prompt failover and replaying completed tool turns. (#62600) Thanks @i-dentifier.
- Approvals/runtime: move native approval lifecycle assembly into shared core bootstrap/runtime seams driven by channel capabilities and runtime contexts, and remove the legacy bundled approval fallback wiring. (#62135) Thanks @gumadeiras.
- Security/fetch-guard: stop rejecting operator-configured proxy hostnames against the target-scoped hostname allowlist in SSRF-guarded fetches, restoring proxy-based media downloads for Telegram and other channels. (#62312) Thanks @ademczuk.
- iOS/gateway: replace string-matched connection error UI with structured gateway connection problems, preserve actionable pairing/auth failures over later generic disconnect noise, and surface reusable problem banners and details across onboarding, settings, and root status surfaces. (#62650) Thanks @ngutman.
## 2026.4.5
@@ -235,7 +206,6 @@ Docs: https://docs.openclaw.ai
- Feishu/reasoning: only expose streamed reasoning previews when the session is explicitly `reasoning:stream`, so hidden reasoning traces do not surface on normal streaming sessions. Thanks @vincentkoc.
- Discord: keep REST, webhook, and monitor traffic on the configured proxy, preserve component-only media sends, honor `@everyone` and `@here` mention gates, keep ACK reactions on the active account, and split voice connect/playback timeouts so auto-join is more reliable. (#57465, #60361, #60345) Thanks @geekhuashan.
- WhatsApp: restore `channels.whatsapp.blockStreaming` and reset watchdog timeouts after reconnect so quiet chats stop falling into reconnect loops. (#60007, #60069) Thanks @MonkeyLeeT and @mcaxtr.
- Browser/security: re-run SSRF safety checks after interaction-driven navigations and before snapshot reads so click, submit, keyboard, and current-page snapshot flows fail closed on disallowed destinations. (#62023) Thanks @eleqtrizit.
- Memory: keep `memory-core` builtin embedding registration on the already-registered path so selecting `memory-core` no longer recurses through plugin discovery and crashes during startup. (#61402) Thanks @ngutman.
- Agents/tool results: keep large `read` outputs visible longer, preserve the latest `read` output when older tool output can absorb the overflow budget, and fall back to Pi's normal overflow compaction/retry path before replacing a fresh `read` with a compacted stub. Thanks @vincentkoc.
- Memory/QMD: prefer modern `qmd collection add --glob`, accept newer single-line JSON hit metadata while keeping legacy line fields, refresh QMD docs/doctor install guidance and model-override guidance, and keep older QMD releases working. Thanks @vincentkoc.
@@ -278,7 +248,6 @@ Docs: https://docs.openclaw.ai
- Providers/OpenRouter failover: classify `403 “Key limit exceeded”` spending-limit responses as billing so model fallback continues instead of stopping on generic auth. (#59892) Thanks @rockcent.
- Providers/Anthropic: keep `claude-cli/*` auth on live Claude CLI credentials at runtime, avoid persisting stale bearer-token profiles, and suppress macOS Keychain prompts during non-interactive Claude CLI setup. (#61234) Thanks @darkamenosa.
- Providers/Anthropic: when Claude CLI auth becomes the default, write a real `claude-cli` auth profile so local and gateway agent runs can use Claude CLI immediately without missing-API-key failures. Thanks @vincentkoc.
- Memory/dreaming: make Dreams config reads and writes respect the selected memory slot plugin (including `doctor.memory.status` and Control UI fallback state) instead of always targeting `memory-core`. (#62275) Thanks @SnowSky1.
- Providers/Anthropic Vertex: honor `cacheRetention: “long”` with the real 1-hour prompt-cache TTL on Vertex AI endpoints, and default `anthropic-vertex` cache retention like direct Anthropic. (#60888) Thanks @affsantos.
- Agents/Anthropic: preserve native `toolu_*` replay ids on direct Anthropic and Anthropic Vertex paths so cache-sensitive history stops rewriting known-valid Anthropic tool-use ids. (#52612)
- Providers/Google: add model-level `cacheRetention` support for direct Gemini system prompts by creating, reusing, and refreshing `cachedContents` automatically on Google AI Studio runs. (#51372) Thanks @rafaelmariano-glitch.
@@ -833,7 +802,6 @@ Docs: https://docs.openclaw.ai
- Agents/compaction: surface safeguard-specific cancel reasons and relabel benign manual `/compact` no-op cases as skipped instead of failed. (#51072) Thanks @afurm.
- Docs: add `pnpm docs:check-links:anchors` for Mintlify anchor validation while keeping `scripts/docs-link-audit.mjs` as the stable link-audit entrypoint. (#55912) Thanks @velvet-shark.
- Tavily: mark outbound API requests with `X-Client-Source: openclaw` so Tavily can attribute OpenClaw-originated traffic. (#55335) Thanks @lakshyaag-tavily.
- Plugins/hooks: add async `requireApproval` to `before_tool_call` hooks, letting plugins pause tool execution and prompt the user for approval via the exec approval overlay, Telegram buttons, Discord interactions, or the `/approve` command on any channel. The `/approve` command now handles both exec and plugin approvals with automatic fallback. (#55339) Thanks @vaclavbelak and @joshavant.
### Fixes

View File

@@ -62,7 +62,6 @@ RUN corepack enable
WORKDIR /app
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./
COPY openclaw.mjs ./
COPY ui/package.json ./ui/package.json
COPY patches ./patches
COPY scripts/postinstall-bundled-plugins.mjs scripts/npm-runner.mjs scripts/windows-cmd-helpers.mjs ./scripts/
@@ -103,19 +102,7 @@ RUN pnpm qa:lab:build
# Prune dev dependencies and strip build-only metadata before copying
# runtime assets into the final image.
FROM build AS runtime-assets
ARG OPENCLAW_EXTENSIONS
ARG OPENCLAW_BUNDLED_PLUGIN_DIR
# Keep the install layer frozen, but allow prune to run against the full copied
# workspace tree subset used during `pnpm install`. The build stage only copied
# the root, `ui`, and opted-in plugin manifests into the install layer, so
# prune must not rediscover unrelated workspaces from the later full source
# copy.
RUN printf 'packages:\n - .\n - ui\n' > /tmp/pnpm-workspace.runtime.yaml && \
for ext in $OPENCLAW_EXTENSIONS; do \
printf ' - %s/%s\n' "$OPENCLAW_BUNDLED_PLUGIN_DIR" "$ext" >> /tmp/pnpm-workspace.runtime.yaml; \
done && \
cp /tmp/pnpm-workspace.runtime.yaml pnpm-workspace.yaml && \
CI=true NPM_CONFIG_FROZEN_LOCKFILE=false pnpm prune --prod && \
RUN CI=true pnpm prune --prod && \
find dist -type f \( -name '*.d.ts' -o -name '*.d.mts' -o -name '*.d.cts' -o -name '*.map' \) -delete
# ── Runtime base images ─────────────────────────────────────────
@@ -172,6 +159,10 @@ COPY --from=runtime-assets --chown=node:node /app/skills ./skills
COPY --from=runtime-assets --chown=node:node /app/docs ./docs
COPY --from=runtime-assets --chown=node:node /app/qa ./qa
# In npm-installed Docker images, prefer the copied source extension tree for
# bundled discovery so package metadata that points at source entries stays valid.
ENV OPENCLAW_BUNDLED_PLUGINS_DIR=/app/${OPENCLAW_BUNDLED_PLUGIN_DIR}
# Keep pnpm available in the runtime image for container-local workflows.
# Use a shared Corepack home so the non-root `node` user does not need a
# first-run network fetch when invoking pnpm.

View File

@@ -89,7 +89,7 @@ New install? Start here: [Getting started](https://docs.openclaw.ai/start/gettin
- **[OpenAI](https://openai.com/)** (ChatGPT/Codex)
Model note: while many providers and models are supported, prefer a current flagship model from the provider you trust and already use. See [Onboarding](https://docs.openclaw.ai/start/onboarding).
Model note: while many providers/models are supported, for the best experience and lower prompt-injection risk use the strongest latest-generation model available to you. See [Onboarding](https://docs.openclaw.ai/start/onboarding).
## Models (selection + auth)
@@ -371,7 +371,7 @@ Minimal `~/.openclaw/openclaw.json` (model + defaults):
```json5
{
agent: {
model: "<provider>/<model-id>",
model: "anthropic/claude-opus-4-6",
},
}
```

View File

@@ -1,5 +1,4 @@
import Foundation
import OpenClawKit
enum GatewayConnectionIssue: Equatable {
case none
@@ -30,37 +29,6 @@ enum GatewayConnectionIssue: Equatable {
return false
}
static func detect(problem: GatewayConnectionProblem?) -> Self {
guard let problem else { return .none }
if problem.needsPairingApproval {
return .pairingRequired(requestId: problem.requestId)
}
if problem.needsCredentialUpdate {
return problem.kind == .gatewayAuthTokenMissing ? .tokenMissing : .unauthorized
}
switch problem.kind {
case .deviceIdentityRequired,
.deviceSignatureExpired,
.deviceNonceRequired,
.deviceNonceMismatch,
.deviceSignatureInvalid,
.devicePublicKeyInvalid,
.deviceIdMismatch,
.tailscaleIdentityMissing,
.tailscaleProxyMissing,
.tailscaleWhoisFailed,
.tailscaleIdentityMismatch,
.authRateLimited:
return .unauthorized
case .timeout, .connectionRefused, .reachabilityFailed, .websocketCancelled:
return .network
case .unknown:
return .unknown(problem.message)
default:
return .none
}
}
static func detect(from statusText: String) -> Self {
let trimmed = statusText.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return .none }

View File

@@ -1,232 +0,0 @@
import OpenClawKit
import SwiftUI
import UIKit
struct GatewayProblemBanner: View {
let problem: GatewayConnectionProblem
var primaryActionTitle: String?
var onPrimaryAction: (() -> Void)?
var onShowDetails: (() -> Void)?
var body: some View {
VStack(alignment: .leading, spacing: 12) {
HStack(alignment: .top, spacing: 10) {
Image(systemName: self.iconName)
.font(.headline.weight(.semibold))
.foregroundStyle(self.tint)
.frame(width: 20)
.padding(.top, 2)
VStack(alignment: .leading, spacing: 6) {
HStack(alignment: .firstTextBaseline, spacing: 8) {
Text(self.problem.title)
.font(.subheadline.weight(.semibold))
.multilineTextAlignment(.leading)
Spacer(minLength: 0)
Text(self.ownerLabel)
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
}
Text(self.problem.message)
.font(.footnote)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
if let requestId = self.problem.requestId {
Text("Request ID: \(requestId)")
.font(.system(.caption, design: .monospaced).weight(.medium))
.foregroundStyle(.secondary)
.textSelection(.enabled)
}
}
}
HStack(spacing: 10) {
if let primaryActionTitle, let onPrimaryAction {
Button(primaryActionTitle, action: onPrimaryAction)
.buttonStyle(.borderedProminent)
.controlSize(.small)
}
if let onShowDetails {
Button("Details", action: onShowDetails)
.buttonStyle(.bordered)
.controlSize(.small)
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(14)
.background(
.thinMaterial,
in: RoundedRectangle(cornerRadius: 16, style: .continuous)
)
}
private var iconName: String {
switch self.problem.kind {
case .pairingRequired,
.pairingRoleUpgradeRequired,
.pairingScopeUpgradeRequired,
.pairingMetadataUpgradeRequired:
return "person.crop.circle.badge.clock"
case .timeout, .connectionRefused, .reachabilityFailed, .websocketCancelled:
return "wifi.exclamationmark"
case .deviceIdentityRequired,
.deviceSignatureExpired,
.deviceNonceRequired,
.deviceNonceMismatch,
.deviceSignatureInvalid,
.devicePublicKeyInvalid,
.deviceIdMismatch:
return "lock.shield"
default:
return "exclamationmark.triangle.fill"
}
}
private var tint: Color {
switch self.problem.kind {
case .pairingRequired,
.pairingRoleUpgradeRequired,
.pairingScopeUpgradeRequired,
.pairingMetadataUpgradeRequired:
return .orange
case .timeout, .connectionRefused, .reachabilityFailed, .websocketCancelled:
return .yellow
default:
return .red
}
}
private var ownerLabel: String {
switch self.problem.owner {
case .gateway:
return "Fix on gateway"
case .iphone:
return "Fix on iPhone"
case .both:
return "Check both"
case .network:
return "Check network"
case .unknown:
return "Needs attention"
}
}
}
struct GatewayProblemDetailsSheet: View {
@Environment(\.dismiss) private var dismiss
let problem: GatewayConnectionProblem
var primaryActionTitle: String?
var onPrimaryAction: (() -> Void)?
@State private var copyFeedback: String?
var body: some View {
NavigationStack {
List {
Section {
VStack(alignment: .leading, spacing: 10) {
Text(self.problem.title)
.font(.title3.weight(.semibold))
Text(self.problem.message)
.font(.body)
.foregroundStyle(.secondary)
Text(self.ownerSummary)
.font(.footnote.weight(.semibold))
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, 4)
}
if let requestId = self.problem.requestId {
Section("Request") {
Text(verbatim: requestId)
.font(.system(.body, design: .monospaced))
.textSelection(.enabled)
Button("Copy request ID") {
UIPasteboard.general.string = requestId
self.copyFeedback = "Copied request ID"
}
}
}
if let actionCommand = self.problem.actionCommand {
Section("Gateway command") {
Text(verbatim: actionCommand)
.font(.system(.body, design: .monospaced))
.textSelection(.enabled)
Button("Copy command") {
UIPasteboard.general.string = actionCommand
self.copyFeedback = "Copied command"
}
}
}
if let docsURL = self.problem.docsURL {
Section("Help") {
Link(destination: docsURL) {
Label("Open docs", systemImage: "book")
}
Text(verbatim: docsURL.absoluteString)
.font(.footnote)
.foregroundStyle(.secondary)
.textSelection(.enabled)
}
}
if let technicalDetails = self.problem.technicalDetails {
Section("Technical details") {
Text(verbatim: technicalDetails)
.font(.system(.footnote, design: .monospaced))
.foregroundStyle(.secondary)
.textSelection(.enabled)
}
}
if let copyFeedback {
Section {
Text(copyFeedback)
.font(.footnote)
.foregroundStyle(.secondary)
}
}
}
.navigationTitle("Connection problem")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
if let primaryActionTitle, let onPrimaryAction {
Button(primaryActionTitle) {
self.dismiss()
onPrimaryAction()
}
}
}
ToolbarItem(placement: .topBarTrailing) {
Button("Done") {
self.dismiss()
}
}
}
}
}
private var ownerSummary: String {
switch self.problem.owner {
case .gateway:
return "Primary fix: gateway"
case .iphone:
return "Primary fix: this iPhone"
case .both:
return "Primary fix: check both this iPhone and the gateway"
case .network:
return "Primary fix: network or remote access"
case .unknown:
return "Primary fix: review details and retry"
}
}
}

View File

@@ -8,7 +8,6 @@ struct GatewayQuickSetupSheet: View {
@AppStorage("onboarding.quickSetupDismissed") private var quickSetupDismissed: Bool = false
@State private var connecting: Bool = false
@State private var connectError: String?
@State private var showGatewayProblemDetails: Bool = false
var body: some View {
NavigationStack {
@@ -16,14 +15,6 @@ struct GatewayQuickSetupSheet: View {
Text("Connect to a Gateway?")
.font(.title2.bold())
if let gatewayProblem = self.appModel.lastGatewayProblem {
GatewayProblemBanner(
problem: gatewayProblem,
onShowDetails: {
self.showGatewayProblemDetails = true
})
}
if let candidate = self.bestCandidate {
VStack(alignment: .leading, spacing: 6) {
Text(verbatim: candidate.name)
@@ -36,7 +27,7 @@ struct GatewayQuickSetupSheet: View {
// Use verbatim strings so Bonjour-provided values can't be interpreted as
// localized format strings (which can crash with Objective-C exceptions).
Text(verbatim: "Discovery: \(self.gatewayController.discoveryStatusText)")
Text(verbatim: "Status: \(self.appModel.gatewayDisplayStatusText)")
Text(verbatim: "Status: \(self.appModel.gatewayStatusText)")
Text(verbatim: "Node: \(self.appModel.nodeStatusText)")
Text(verbatim: "Operator: \(self.appModel.operatorStatusText)")
}
@@ -113,11 +104,6 @@ struct GatewayQuickSetupSheet: View {
}
}
}
.sheet(isPresented: self.$showGatewayProblemDetails) {
if let gatewayProblem = self.appModel.lastGatewayProblem {
GatewayProblemDetailsSheet(problem: gatewayProblem)
}
}
}
private var bestCandidate: GatewayDiscoveryModel.DiscoveredGateway? {

View File

@@ -120,10 +120,6 @@ final class NodeAppModel {
// multiple pending requests and cause the onboarding UI to "flip-flop".
var gatewayPairingPaused: Bool = false
var gatewayPairingRequestId: String?
private(set) var lastGatewayProblem: GatewayConnectionProblem?
var gatewayDisplayStatusText: String {
self.lastGatewayProblem?.statusText ?? self.gatewayStatusText
}
var seamColorHex: String?
private var mainSessionBaseKey: String = "main"
var selectedAgentId: String?
@@ -1819,7 +1815,6 @@ extension NodeAppModel {
self.gatewayAutoReconnectEnabled = false
self.gatewayPairingPaused = false
self.gatewayPairingRequestId = nil
self.lastGatewayProblem = nil
self.nodeGatewayTask?.cancel()
self.nodeGatewayTask = nil
self.operatorGatewayTask?.cancel()
@@ -1853,7 +1848,6 @@ private extension NodeAppModel {
self.gatewayAutoReconnectEnabled = true
self.gatewayPairingPaused = false
self.gatewayPairingRequestId = nil
self.lastGatewayProblem = nil
self.nodeGatewayTask?.cancel()
self.operatorGatewayTask?.cancel()
self.gatewayHealthMonitor.stop()
@@ -1872,38 +1866,6 @@ private extension NodeAppModel {
self.apnsLastRegisteredTokenHex = nil
}
func clearGatewayConnectionProblem() {
self.lastGatewayProblem = nil
self.gatewayPairingPaused = false
self.gatewayPairingRequestId = nil
}
func applyGatewayConnectionProblem(_ problem: GatewayConnectionProblem) {
self.lastGatewayProblem = problem
self.gatewayStatusText = problem.statusText
self.gatewayServerName = nil
self.gatewayRemoteAddress = nil
self.gatewayConnected = false
self.showLocalCanvasOnDisconnect()
if problem.pauseReconnect {
self.gatewayAutoReconnectEnabled = false
}
if problem.needsPairingApproval {
self.gatewayPairingPaused = true
self.gatewayPairingRequestId = problem.requestId
} else {
self.gatewayPairingPaused = false
self.gatewayPairingRequestId = nil
}
}
func shouldKeepGatewayProblemStatus(forDisconnectReason reason: String) -> Bool {
guard let lastGatewayProblem else { return false }
return GatewayConnectionProblemMapper.shouldPreserve(
previousProblem: lastGatewayProblem,
overDisconnectReason: reason)
}
func shouldStartOperatorGatewayLoop(
token: String?,
bootstrapToken: String?,
@@ -2200,7 +2162,6 @@ private extension NodeAppModel {
onConnected: { [weak self] in
guard let self else { return }
await MainActor.run {
self.clearGatewayConnectionProblem()
self.gatewayStatusText = "Connected"
self.gatewayServerName = url.host ?? "gateway"
self.gatewayConnected = true
@@ -2257,13 +2218,7 @@ private extension NodeAppModel {
onDisconnected: { [weak self] reason in
guard let self else { return }
await MainActor.run {
if self.shouldKeepGatewayProblemStatus(forDisconnectReason: reason),
let lastGatewayProblem = self.lastGatewayProblem
{
self.gatewayStatusText = lastGatewayProblem.statusText
} else {
self.gatewayStatusText = "Disconnected: \(reason)"
}
self.gatewayStatusText = "Disconnected: \(reason)"
self.gatewayServerName = nil
self.gatewayRemoteAddress = nil
self.gatewayConnected = false
@@ -2302,25 +2257,50 @@ private extension NodeAppModel {
}
attempt += 1
let problem = await MainActor.run {
let nextProblem = GatewayConnectionProblemMapper.map(
error: error,
preserving: self.lastGatewayProblem)
if let nextProblem {
self.applyGatewayConnectionProblem(nextProblem)
} else {
self.lastGatewayProblem = nil
self.gatewayStatusText = "Gateway error: \(error.localizedDescription)"
self.gatewayServerName = nil
self.gatewayRemoteAddress = nil
self.gatewayConnected = false
self.showLocalCanvasOnDisconnect()
}
return nextProblem
await MainActor.run {
self.gatewayStatusText = "Gateway error: \(error.localizedDescription)"
self.gatewayServerName = nil
self.gatewayRemoteAddress = nil
self.gatewayConnected = false
self.showLocalCanvasOnDisconnect()
}
GatewayDiagnostics.log("gateway connect error: \(error.localizedDescription)")
if problem?.needsPairingApproval == true {
// If auth is missing/rejected, pause reconnect churn until the user intervenes.
// Reconnect loops only spam the same failing handshake and make onboarding noisy.
let lower = error.localizedDescription.lowercased()
if lower.contains("unauthorized") || lower.contains("gateway token missing") {
await MainActor.run {
self.gatewayAutoReconnectEnabled = false
}
}
// If pairing is required, stop reconnect churn. The user must approve the request
// on the gateway before another connect attempt will succeed, and retry loops can
// generate multiple pending requests.
if lower.contains("not_paired") || lower.contains("pairing required") {
let requestId: String? = {
// GatewayResponseError for connect decorates the message with `(requestId: ...)`.
// Keep this resilient since other layers may wrap the text.
let text = error.localizedDescription
guard let start = text.range(of: "(requestId: ")?.upperBound else { return nil }
guard let end = text[start...].firstIndex(of: ")") else { return nil }
let raw = String(text[start..<end]).trimmingCharacters(in: .whitespacesAndNewlines)
return raw.isEmpty ? nil : raw
}()
await MainActor.run {
self.gatewayAutoReconnectEnabled = false
self.gatewayPairingPaused = true
self.gatewayPairingRequestId = requestId
if let requestId, !requestId.isEmpty {
self.gatewayStatusText =
"Pairing required (requestId: \(requestId)). "
+ "Approve on gateway and return to OpenClaw."
} else {
self.gatewayStatusText =
"Pairing required. Approve on gateway and return to OpenClaw."
}
}
// Hard stop the underlying WebSocket watchdog reconnects so the UI stays stable and
// we don't generate multiple pending requests while waiting for approval.
pausedForPairingApproval = true
@@ -2331,10 +2311,6 @@ private extension NodeAppModel {
break
}
if problem?.pauseReconnect == true {
continue
}
let sleepSeconds = min(8.0, 0.5 * pow(1.7, Double(attempt)))
try? await Task.sleep(nanoseconds: UInt64(sleepSeconds * 1_000_000_000))
}
@@ -2346,7 +2322,6 @@ private extension NodeAppModel {
}
await MainActor.run {
self.lastGatewayProblem = nil
self.gatewayStatusText = "Offline"
self.gatewayServerName = nil
self.gatewayRemoteAddress = nil

View File

@@ -376,7 +376,7 @@ private struct ConnectionStatusBox: View {
gatewayController: GatewayConnectionController
) -> [String] {
var lines: [String] = [
"gateway: \(appModel.gatewayDisplayStatusText)",
"gateway: \(appModel.gatewayStatusText)",
"discovery: \(gatewayController.discoveryStatusText)",
]
lines.append("server: \(appModel.gatewayServerName ?? "")")

View File

@@ -69,7 +69,6 @@ struct OnboardingWizardView: View {
@State private var showQRScanner: Bool = false
@State private var scannerError: String?
@State private var selectedPhoto: PhotosPickerItem?
@State private var showGatewayProblemDetails: Bool = false
@State private var lastPairingAutoResumeAttemptAt: Date?
private static let pairingAutoResumeTicker = Timer.publish(every: 2.0, on: .main, in: .common).autoconnect()
@@ -87,10 +86,6 @@ struct OnboardingWizardView: View {
self.step == .intro || self.step == .welcome || self.step == .success
}
private var currentProblem: GatewayConnectionProblem? {
self.appModel.lastGatewayProblem
}
var body: some View {
NavigationStack {
Group {
@@ -221,16 +216,6 @@ struct OnboardingWizardView: View {
}
}
}
.sheet(isPresented: self.$showGatewayProblemDetails) {
if let currentProblem = self.currentProblem {
GatewayProblemDetailsSheet(
problem: currentProblem,
primaryActionTitle: "Retry",
onPrimaryAction: {
Task { await self.retryLastAttempt() }
})
}
}
.onAppear {
self.initializeState()
}
@@ -265,11 +250,39 @@ struct OnboardingWizardView: View {
.onChange(of: self.gatewayPassword) { _, newValue in
self.saveGatewayCredentials(token: self.gatewayToken, password: newValue)
}
.onChange(of: self.appModel.lastGatewayProblem) { _, newValue in
self.updateConnectionIssue(problem: newValue, statusText: self.appModel.gatewayStatusText)
}
.onChange(of: self.appModel.gatewayStatusText) { _, newValue in
self.updateConnectionIssue(problem: self.appModel.lastGatewayProblem, statusText: newValue)
let next = GatewayConnectionIssue.detect(from: newValue)
// Avoid "flip-flopping" the UI by clearing actionable issues when the underlying connection
// transitions through intermediate statuses (e.g. Offline/Connecting while reconnect churns).
if self.issue.needsPairing, next.needsPairing {
// Keep the requestId sticky even if the status line omits it after we pause.
let mergedRequestId = next.requestId ?? self.issue.requestId ?? self.pairingRequestId
self.issue = .pairingRequired(requestId: mergedRequestId)
} else if self.issue.needsPairing, !next.needsPairing {
// Ignore non-pairing statuses until the user explicitly retries/scans again, or we connect.
} else if self.issue.needsAuthToken, !next.needsAuthToken, !next.needsPairing {
// Same idea for auth: once we learn credentials are missing/rejected, keep that sticky until
// the user retries/scans again or we successfully connect.
} else {
self.issue = next
}
if let requestId = next.requestId, !requestId.isEmpty {
self.pairingRequestId = requestId
}
// If the gateway tells us auth is missing/rejected, stop reconnect churn until the user intervenes.
if next.needsAuthToken {
self.appModel.gatewayAutoReconnectEnabled = false
}
if self.issue.needsAuthToken || self.issue.needsPairing {
self.step = .auth
}
if !newValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
self.connectMessage = newValue
self.statusLine = newValue
}
}
.onChange(of: self.appModel.gatewayServerName) { _, newValue in
guard newValue != nil else { return }
@@ -496,7 +509,7 @@ struct OnboardingWizardView: View {
Section {
LabeledContent("Mode", value: selectedMode.title)
LabeledContent("Discovery", value: self.gatewayController.discoveryStatusText)
LabeledContent("Status", value: self.appModel.gatewayDisplayStatusText)
LabeledContent("Status", value: self.appModel.gatewayStatusText)
LabeledContent("Progress", value: self.statusLine)
} header: {
Text("Status")
@@ -599,17 +612,7 @@ struct OnboardingWizardView: View {
.autocorrectionDisabled()
SecureField("Gateway Password", text: self.$gatewayPassword)
if let problem = self.currentProblem {
GatewayProblemBanner(
problem: problem,
primaryActionTitle: "Retry connection",
onPrimaryAction: {
Task { await self.retryLastAttempt() }
},
onShowDetails: {
self.showGatewayProblemDetails = true
})
} else if self.issue.needsAuthToken {
if self.issue.needsAuthToken {
Text("Gateway rejected credentials. Scan a fresh QR code or update token/password.")
.font(.footnote)
.foregroundStyle(.secondary)
@@ -632,15 +635,14 @@ struct OnboardingWizardView: View {
Text("Pairing Approval")
} footer: {
let requestLine: String = {
if let id = self.currentProblem?.requestId ?? self.issue.requestId, !id.isEmpty {
if let id = self.issue.requestId, !id.isEmpty {
return "Request ID: \(id)"
}
return "Request ID: check `openclaw devices list`."
}()
let commandLine = self.currentProblem?.actionCommand ?? "openclaw devices approve <requestId>"
Text(
"Approve this device on the gateway.\n"
+ "1) `\(commandLine)`\n"
+ "1) `openclaw devices approve` (or `openclaw devices approve <requestId>`)\n"
+ "2) `/pair approve` in your OpenClaw chat\n"
+ "\(requestLine)\n"
+ "OpenClaw will also retry automatically when you return to this app.")
@@ -822,45 +824,6 @@ struct OnboardingWizardView: View {
self.resumeAfterPairingApprovalInBackground()
}
private func updateConnectionIssue(problem: GatewayConnectionProblem?, statusText: String) {
let next = GatewayConnectionIssue.detect(problem: problem)
let fallback = next == .none ? GatewayConnectionIssue.detect(from: statusText) : next
// Avoid "flip-flopping" the UI by clearing actionable issues when the underlying connection
// transitions through intermediate statuses (e.g. Offline/Connecting while reconnect churns).
if self.issue.needsPairing, fallback.needsPairing {
let mergedRequestId = fallback.requestId ?? self.issue.requestId ?? self.pairingRequestId
self.issue = .pairingRequired(requestId: mergedRequestId)
} else if self.issue.needsPairing, !fallback.needsPairing {
// Ignore non-pairing statuses until the user explicitly retries/scans again, or we connect.
} else if self.issue.needsAuthToken, !fallback.needsAuthToken, !fallback.needsPairing {
// Same idea for auth: once we learn credentials are missing/rejected, keep that sticky until
// the user retries/scans again or we successfully connect.
} else {
self.issue = fallback
}
if let requestId = problem?.requestId ?? fallback.requestId, !requestId.isEmpty {
self.pairingRequestId = requestId
}
if self.issue.needsAuthToken || self.issue.needsPairing || problem?.pauseReconnect == true {
self.step = .auth
}
if let problem {
self.connectMessage = problem.message
self.statusLine = problem.message
return
}
let trimmedStatus = statusText.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmedStatus.isEmpty {
self.connectMessage = trimmedStatus
self.statusLine = trimmedStatus
}
}
private func detectQRCode(from data: Data) -> String? {
guard let ciImage = CIImage(data: data) else { return nil }
let detector = CIDetector(

View File

@@ -98,9 +98,6 @@ struct RootCanvas: View {
},
openSettings: {
self.presentedSheet = .settings
},
retryGatewayConnection: {
Task { await self.gatewayController.connectLastKnown() }
})
.preferredColorScheme(.dark)
@@ -232,7 +229,7 @@ struct RootCanvas: View {
private func updateCanvasDebugStatus() {
self.appModel.screen.setDebugStatusEnabled(self.canvasDebugStatusEnabled)
guard self.canvasDebugStatusEnabled else { return }
let title = self.appModel.gatewayDisplayStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
let title = self.appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
let subtitle = self.appModel.gatewayServerName ?? self.appModel.gatewayRemoteAddress
self.appModel.screen.updateDebugStatus(title: title, subtitle: subtitle)
}
@@ -457,7 +454,6 @@ private struct CanvasContent: View {
@AppStorage("talk.enabled") private var talkEnabled: Bool = false
@AppStorage("talk.button.enabled") private var talkButtonEnabled: Bool = true
@State private var showGatewayActions: Bool = false
@State private var showGatewayProblemDetails: Bool = false
var systemColorScheme: ColorScheme
var gatewayStatus: StatusPill.GatewayState
var voiceWakeEnabled: Bool
@@ -466,7 +462,6 @@ private struct CanvasContent: View {
var cameraHUDKind: NodeAppModel.CameraHUDKind?
var openChat: () -> Void
var openSettings: () -> Void
var retryGatewayConnection: () -> Void
private var brightenButtons: Bool { self.systemColorScheme == .light }
private var talkActive: Bool { self.appModel.talkMode.isEnabled || self.talkEnabled }
@@ -493,8 +488,6 @@ private struct CanvasContent: View {
onStatusTap: {
if self.gatewayStatus == .connected {
self.showGatewayActions = true
} else if self.appModel.lastGatewayProblem != nil {
self.showGatewayProblemDetails = true
} else {
self.openSettings()
}
@@ -511,35 +504,13 @@ private struct CanvasContent: View {
self.openSettings()
})
}
.overlay(alignment: .top) {
if let gatewayProblem = self.appModel.lastGatewayProblem,
self.gatewayStatus != .connected
{
GatewayProblemBanner(
problem: gatewayProblem,
primaryActionTitle: gatewayProblem.retryable ? "Retry" : "Open Settings",
onPrimaryAction: {
if gatewayProblem.retryable {
self.retryGatewayConnection()
} else {
self.openSettings()
}
},
onShowDetails: {
self.showGatewayProblemDetails = true
})
.padding(.horizontal, 12)
.safeAreaPadding(.top, 10)
.transition(.move(edge: .top).combined(with: .opacity))
}
}
.overlay(alignment: .topLeading) {
if let voiceWakeToastText, !voiceWakeToastText.isEmpty {
VoiceWakeToast(
command: voiceWakeToastText,
brighten: self.brightenButtons)
.padding(.leading, 10)
.safeAreaPadding(.top, self.appModel.lastGatewayProblem == nil ? 58 : 132)
.safeAreaPadding(.top, 58)
.transition(.move(edge: .top).combined(with: .opacity))
}
}
@@ -547,16 +518,6 @@ private struct CanvasContent: View {
isPresented: self.$showGatewayActions,
onDisconnect: { self.appModel.disconnectGateway() },
onOpenSettings: { self.openSettings() })
.sheet(isPresented: self.$showGatewayProblemDetails) {
if let gatewayProblem = self.appModel.lastGatewayProblem {
GatewayProblemDetailsSheet(
problem: gatewayProblem,
primaryActionTitle: "Open Settings",
onPrimaryAction: {
self.openSettings()
})
}
}
.onAppear {
// Keep the runtime talk state aligned with persisted toggle state on cold launch.
if self.talkEnabled != self.appModel.talkMode.isEnabled {

View File

@@ -9,7 +9,6 @@ struct RootTabs: View {
@State private var voiceWakeToastText: String?
@State private var toastDismissTask: Task<Void, Never>?
@State private var showGatewayActions: Bool = false
@State private var showGatewayProblemDetails: Bool = false
var body: some View {
TabView(selection: self.$selectedTab) {
@@ -33,8 +32,6 @@ struct RootTabs: View {
onTap: {
if self.gatewayStatus == .connected {
self.showGatewayActions = true
} else if self.appModel.lastGatewayProblem != nil {
self.showGatewayProblemDetails = true
} else {
self.selectedTab = 2
}
@@ -42,29 +39,11 @@ struct RootTabs: View {
.padding(.leading, 10)
.safeAreaPadding(.top, 10)
}
.overlay(alignment: .top) {
if let gatewayProblem = self.appModel.lastGatewayProblem,
self.gatewayStatus != .connected
{
GatewayProblemBanner(
problem: gatewayProblem,
primaryActionTitle: "Open Settings",
onPrimaryAction: {
self.selectedTab = 2
},
onShowDetails: {
self.showGatewayProblemDetails = true
})
.padding(.horizontal, 12)
.safeAreaPadding(.top, 10)
.transition(.move(edge: .top).combined(with: .opacity))
}
}
.overlay(alignment: .topLeading) {
if let voiceWakeToastText, !voiceWakeToastText.isEmpty {
VoiceWakeToast(command: voiceWakeToastText)
.padding(.leading, 10)
.safeAreaPadding(.top, self.appModel.lastGatewayProblem == nil ? 58 : 132)
.safeAreaPadding(.top, 58)
.transition(.move(edge: .top).combined(with: .opacity))
}
}
@@ -95,16 +74,6 @@ struct RootTabs: View {
isPresented: self.$showGatewayActions,
onDisconnect: { self.appModel.disconnectGateway() },
onOpenSettings: { self.selectedTab = 2 })
.sheet(isPresented: self.$showGatewayProblemDetails) {
if let gatewayProblem = self.appModel.lastGatewayProblem {
GatewayProblemDetailsSheet(
problem: gatewayProblem,
primaryActionTitle: "Open Settings",
onPrimaryAction: {
self.selectedTab = 2
})
}
}
}
private var gatewayStatus: StatusPill.GatewayState {

View File

@@ -53,7 +53,6 @@ struct SettingsTab: View {
@State private var selectedAgentPickerId: String = ""
@State private var showResetOnboardingAlert: Bool = false
@State private var showGatewayProblemDetails: Bool = false
@State private var activeFeatureHelp: FeatureHelp?
@State private var suppressCredentialPersist: Bool = false
@@ -64,20 +63,6 @@ struct SettingsTab: View {
Form {
Section {
DisclosureGroup(isExpanded: self.$gatewayExpanded) {
if let gatewayProblem = self.appModel.lastGatewayProblem,
!self.isGatewayConnected
{
GatewayProblemBanner(
problem: gatewayProblem,
primaryActionTitle: "Retry connection",
onPrimaryAction: {
Task { await self.retryGatewayConnectionFromProblem() }
},
onShowDetails: {
self.showGatewayProblemDetails = true
})
}
if !self.isGatewayConnected {
Text(
"1. Open a chat with your OpenClaw agent and send /pair\n"
@@ -138,7 +123,7 @@ struct SettingsTab: View {
if self.appModel.gatewayServerName == nil {
LabeledContent("Discovery", value: self.gatewayController.discoveryStatusText)
}
LabeledContent("Status", value: self.appModel.gatewayDisplayStatusText)
LabeledContent("Status", value: self.appModel.gatewayStatusText)
Toggle("Auto-connect on launch", isOn: self.$gatewayAutoConnect)
if let serverName = self.appModel.gatewayServerName {
@@ -417,16 +402,6 @@ struct SettingsTab: View {
.accessibilityLabel("Close")
}
}
.sheet(isPresented: self.$showGatewayProblemDetails) {
if let gatewayProblem = self.appModel.lastGatewayProblem {
GatewayProblemDetailsSheet(
problem: gatewayProblem,
primaryActionTitle: "Retry",
onPrimaryAction: {
Task { await self.retryGatewayConnectionFromProblem() }
})
}
}
.alert("Reset Onboarding?", isPresented: self.$showResetOnboardingAlert) {
Button("Reset", role: .destructive) {
self.resetOnboarding()
@@ -618,9 +593,6 @@ struct SettingsTab: View {
if let server = self.appModel.gatewayServerName, self.isGatewayConnected {
return server
}
if let problem = self.appModel.lastGatewayProblem {
return problem.statusText
}
let trimmed = self.appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? "Not connected" : trimmed
}
@@ -670,7 +642,7 @@ struct SettingsTab: View {
private func gatewayDebugText() -> String {
var lines: [String] = [
"gateway: \(self.appModel.gatewayDisplayStatusText)",
"gateway: \(self.appModel.gatewayStatusText)",
"discovery: \(self.gatewayController.discoveryStatusText)",
]
lines.append("server: \(self.appModel.gatewayServerName ?? "")")
@@ -917,9 +889,6 @@ struct SettingsTab: View {
}
private var setupStatusLine: String? {
if let problem = self.appModel.lastGatewayProblem {
return problem.message
}
let trimmedSetup = self.setupStatusText?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let gatewayStatus = self.appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
if let friendly = self.friendlyGatewayMessage(from: gatewayStatus) { return friendly }
@@ -1018,14 +987,6 @@ struct SettingsTab: View {
SettingsNetworkingHelpers.httpURLString(host: host, port: port, fallback: fallback)
}
private func retryGatewayConnectionFromProblem() async {
if self.manualGatewayEnabled || self.connectingGatewayID == "manual" {
await self.connectManual()
return
}
await self.connectLastKnown()
}
private func resetOnboarding() {
// Disconnect first so RootCanvas doesn't instantly mark onboarding complete again.
self.appModel.disconnectGateway()

View File

@@ -1,24 +1,11 @@
import Foundation
import OpenClawKit
enum GatewayStatusBuilder {
@MainActor
static func build(appModel: NodeAppModel) -> StatusPill.GatewayState {
self.build(
gatewayServerName: appModel.gatewayServerName,
lastGatewayProblem: appModel.lastGatewayProblem,
gatewayStatusText: appModel.gatewayStatusText)
}
if appModel.gatewayServerName != nil { return .connected }
static func build(
gatewayServerName: String?,
lastGatewayProblem: GatewayConnectionProblem?,
gatewayStatusText: String) -> StatusPill.GatewayState
{
if gatewayServerName != nil { return .connected }
if let lastGatewayProblem, lastGatewayProblem.pauseReconnect { return .error }
let text = gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
let text = appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
if text.localizedCaseInsensitiveContains("connecting") ||
text.localizedCaseInsensitiveContains("reconnecting")
{

View File

@@ -16,31 +16,6 @@ enum StatusActivityBuilder {
tint: .orange)
}
if let gatewayProblem = appModel.lastGatewayProblem {
switch gatewayProblem.kind {
case .pairingRequired,
.pairingRoleUpgradeRequired,
.pairingScopeUpgradeRequired,
.pairingMetadataUpgradeRequired:
return StatusPill.Activity(
title: "Approval pending",
systemImage: "person.crop.circle.badge.clock",
tint: .orange)
case .timeout, .connectionRefused, .reachabilityFailed, .websocketCancelled:
return StatusPill.Activity(
title: "Check network",
systemImage: "wifi.exclamationmark",
tint: .orange)
default:
if gatewayProblem.pauseReconnect {
return StatusPill.Activity(
title: "Action required",
systemImage: "exclamationmark.triangle.fill",
tint: .orange)
}
}
}
let gatewayStatus = appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
let gatewayLower = gatewayStatus.lowercased()
if gatewayLower.contains("repair") {

View File

@@ -1,36 +0,0 @@
import OpenClawKit
import Testing
@testable import OpenClaw
@Suite struct GatewayStatusBuilderTests {
@Test func pausedProblemKeepsErrorStatus() {
let state = GatewayStatusBuilder.build(
gatewayServerName: nil,
lastGatewayProblem: GatewayConnectionProblem(
kind: .pairingRequired,
owner: .gateway,
title: "Pairing required",
message: "Approve this device before reconnecting.",
requestId: "req-123",
retryable: false,
pauseReconnect: true),
gatewayStatusText: "Reconnecting…")
#expect(state == .error)
}
@Test func transientProblemAllowsConnectingStatus() {
let state = GatewayStatusBuilder.build(
gatewayServerName: nil,
lastGatewayProblem: GatewayConnectionProblem(
kind: .timeout,
owner: .network,
title: "Connection timed out",
message: "The gateway did not respond before the connection timed out.",
retryable: true,
pauseReconnect: false),
gatewayStatusText: "Reconnecting…")
#expect(state == .connecting)
}
}

View File

@@ -38,7 +38,6 @@ enum HostEnvSecurityPolicy {
"GCONV_PATH",
"IFS",
"SSLKEYLOGFILE",
"JAVA_OPTS",
"JAVA_TOOL_OPTIONS",
"_JAVA_OPTIONS",
"JDK_JAVA_OPTIONS",
@@ -145,8 +144,6 @@ enum HostEnvSecurityPolicy {
"HGRCPATH",
"PYTHONUSERBASE",
"RUSTC_WRAPPER",
"RUSTFLAGS",
"CARGO_HOME",
"VIRTUAL_ENV",
"LUA_PATH",
"LUA_CPATH",

View File

@@ -537,8 +537,6 @@ public struct AgentParams: Codable, Sendable {
public let besteffortdeliver: Bool?
public let lane: String?
public let extrasystemprompt: String?
public let bootstrapcontextmode: AnyCodable?
public let bootstrapcontextrunkind: AnyCodable?
public let internalevents: [[String: AnyCodable]]?
public let inputprovenance: [String: AnyCodable]?
public let idempotencykey: String
@@ -568,8 +566,6 @@ public struct AgentParams: Codable, Sendable {
besteffortdeliver: Bool?,
lane: String?,
extrasystemprompt: String?,
bootstrapcontextmode: AnyCodable?,
bootstrapcontextrunkind: AnyCodable?,
internalevents: [[String: AnyCodable]]?,
inputprovenance: [String: AnyCodable]?,
idempotencykey: String,
@@ -598,8 +594,6 @@ public struct AgentParams: Codable, Sendable {
self.besteffortdeliver = besteffortdeliver
self.lane = lane
self.extrasystemprompt = extrasystemprompt
self.bootstrapcontextmode = bootstrapcontextmode
self.bootstrapcontextrunkind = bootstrapcontextrunkind
self.internalevents = internalevents
self.inputprovenance = inputprovenance
self.idempotencykey = idempotencykey
@@ -630,8 +624,6 @@ public struct AgentParams: Codable, Sendable {
case besteffortdeliver = "bestEffortDeliver"
case lane
case extrasystemprompt = "extraSystemPrompt"
case bootstrapcontextmode = "bootstrapContextMode"
case bootstrapcontextrunkind = "bootstrapContextRunKind"
case internalevents = "internalEvents"
case inputprovenance = "inputProvenance"
case idempotencykey = "idempotencyKey"
@@ -1335,236 +1327,6 @@ public struct SessionsResolveParams: Codable, Sendable {
}
}
public struct SessionCompactionCheckpoint: Codable, Sendable {
public let checkpointid: String
public let sessionkey: String
public let sessionid: String
public let createdat: Int
public let reason: AnyCodable
public let tokensbefore: Int?
public let tokensafter: Int?
public let summary: String?
public let firstkeptentryid: String?
public let precompaction: [String: AnyCodable]
public let postcompaction: [String: AnyCodable]
public init(
checkpointid: String,
sessionkey: String,
sessionid: String,
createdat: Int,
reason: AnyCodable,
tokensbefore: Int?,
tokensafter: Int?,
summary: String?,
firstkeptentryid: String?,
precompaction: [String: AnyCodable],
postcompaction: [String: AnyCodable])
{
self.checkpointid = checkpointid
self.sessionkey = sessionkey
self.sessionid = sessionid
self.createdat = createdat
self.reason = reason
self.tokensbefore = tokensbefore
self.tokensafter = tokensafter
self.summary = summary
self.firstkeptentryid = firstkeptentryid
self.precompaction = precompaction
self.postcompaction = postcompaction
}
private enum CodingKeys: String, CodingKey {
case checkpointid = "checkpointId"
case sessionkey = "sessionKey"
case sessionid = "sessionId"
case createdat = "createdAt"
case reason
case tokensbefore = "tokensBefore"
case tokensafter = "tokensAfter"
case summary
case firstkeptentryid = "firstKeptEntryId"
case precompaction = "preCompaction"
case postcompaction = "postCompaction"
}
}
public struct SessionsCompactionListParams: Codable, Sendable {
public let key: String
public init(
key: String)
{
self.key = key
}
private enum CodingKeys: String, CodingKey {
case key
}
}
public struct SessionsCompactionGetParams: Codable, Sendable {
public let key: String
public let checkpointid: String
public init(
key: String,
checkpointid: String)
{
self.key = key
self.checkpointid = checkpointid
}
private enum CodingKeys: String, CodingKey {
case key
case checkpointid = "checkpointId"
}
}
public struct SessionsCompactionBranchParams: Codable, Sendable {
public let key: String
public let checkpointid: String
public init(
key: String,
checkpointid: String)
{
self.key = key
self.checkpointid = checkpointid
}
private enum CodingKeys: String, CodingKey {
case key
case checkpointid = "checkpointId"
}
}
public struct SessionsCompactionRestoreParams: Codable, Sendable {
public let key: String
public let checkpointid: String
public init(
key: String,
checkpointid: String)
{
self.key = key
self.checkpointid = checkpointid
}
private enum CodingKeys: String, CodingKey {
case key
case checkpointid = "checkpointId"
}
}
public struct SessionsCompactionListResult: Codable, Sendable {
public let ok: Bool
public let key: String
public let checkpoints: [SessionCompactionCheckpoint]
public init(
ok: Bool,
key: String,
checkpoints: [SessionCompactionCheckpoint])
{
self.ok = ok
self.key = key
self.checkpoints = checkpoints
}
private enum CodingKeys: String, CodingKey {
case ok
case key
case checkpoints
}
}
public struct SessionsCompactionGetResult: Codable, Sendable {
public let ok: Bool
public let key: String
public let checkpoint: SessionCompactionCheckpoint
public init(
ok: Bool,
key: String,
checkpoint: SessionCompactionCheckpoint)
{
self.ok = ok
self.key = key
self.checkpoint = checkpoint
}
private enum CodingKeys: String, CodingKey {
case ok
case key
case checkpoint
}
}
public struct SessionsCompactionBranchResult: Codable, Sendable {
public let ok: Bool
public let sourcekey: String
public let key: String
public let sessionid: String
public let checkpoint: SessionCompactionCheckpoint
public let entry: [String: AnyCodable]
public init(
ok: Bool,
sourcekey: String,
key: String,
sessionid: String,
checkpoint: SessionCompactionCheckpoint,
entry: [String: AnyCodable])
{
self.ok = ok
self.sourcekey = sourcekey
self.key = key
self.sessionid = sessionid
self.checkpoint = checkpoint
self.entry = entry
}
private enum CodingKeys: String, CodingKey {
case ok
case sourcekey = "sourceKey"
case key
case sessionid = "sessionId"
case checkpoint
case entry
}
}
public struct SessionsCompactionRestoreResult: Codable, Sendable {
public let ok: Bool
public let key: String
public let sessionid: String
public let checkpoint: SessionCompactionCheckpoint
public let entry: [String: AnyCodable]
public init(
ok: Bool,
key: String,
sessionid: String,
checkpoint: SessionCompactionCheckpoint,
entry: [String: AnyCodable])
{
self.ok = ok
self.key = key
self.sessionid = sessionid
self.checkpoint = checkpoint
self.entry = entry
}
private enum CodingKeys: String, CodingKey {
case ok
case key
case sessionid = "sessionId"
case checkpoint
case entry
}
}
public struct SessionsCreateParams: Codable, Sendable {
public let key: String?
public let agentid: String?

View File

@@ -624,31 +624,11 @@ public actor GatewayChannelActor {
let detailCode = details?["code"]?.value as? String
let canRetryWithDeviceToken = details?["canRetryWithDeviceToken"]?.value as? Bool ?? false
let recommendedNextStep = details?["recommendedNextStep"]?.value as? String
let requestId = details?["requestId"]?.value as? String
let reason = details?["reason"]?.value as? String
let owner = details?["owner"]?.value as? String
let title = details?["title"]?.value as? String
let userMessage = details?["userMessage"]?.value as? String
let actionLabel = details?["actionLabel"]?.value as? String
let actionCommand = details?["actionCommand"]?.value as? String
let docsURLString = details?["docsUrl"]?.value as? String
let retryableOverride = details?["retryable"]?.value as? Bool
let pauseReconnectOverride = details?["pauseReconnect"]?.value as? Bool
throw GatewayConnectAuthError(
message: msg,
detailCodeRaw: detailCode,
canRetryWithDeviceToken: canRetryWithDeviceToken,
recommendedNextStepRaw: recommendedNextStep,
requestId: requestId,
detailsReason: reason,
ownerRaw: owner,
titleOverride: title,
userMessageOverride: userMessage,
actionLabel: actionLabel,
actionCommand: actionCommand,
docsURLString: docsURLString,
retryableOverride: retryableOverride,
pauseReconnectOverride: pauseReconnectOverride)
recommendedNextStepRaw: recommendedNextStep)
}
guard let payload = res.payload else {
throw NSError(

View File

@@ -1,761 +0,0 @@
import Foundation
public struct GatewayConnectionProblem: Equatable, Sendable {
public enum Kind: String, Equatable, Sendable {
case gatewayAuthTokenMissing
case gatewayAuthTokenMismatch
case gatewayAuthTokenNotConfigured
case gatewayAuthPasswordMissing
case gatewayAuthPasswordMismatch
case gatewayAuthPasswordNotConfigured
case bootstrapTokenInvalid
case deviceTokenMismatch
case pairingRequired
case pairingRoleUpgradeRequired
case pairingScopeUpgradeRequired
case pairingMetadataUpgradeRequired
case deviceIdentityRequired
case deviceSignatureExpired
case deviceNonceRequired
case deviceNonceMismatch
case deviceSignatureInvalid
case devicePublicKeyInvalid
case deviceIdMismatch
case tailscaleIdentityMissing
case tailscaleProxyMissing
case tailscaleWhoisFailed
case tailscaleIdentityMismatch
case authRateLimited
case timeout
case connectionRefused
case reachabilityFailed
case websocketCancelled
case unknown
}
public enum Owner: String, Equatable, Sendable {
case gateway
case iphone
case both
case network
case unknown
}
public let kind: Kind
public let owner: Owner
public let title: String
public let message: String
public let actionLabel: String?
public let actionCommand: String?
public let docsURL: URL?
public let requestId: String?
public let retryable: Bool
public let pauseReconnect: Bool
public let technicalDetails: String?
public init(
kind: Kind,
owner: Owner,
title: String,
message: String,
actionLabel: String? = nil,
actionCommand: String? = nil,
docsURL: URL? = nil,
requestId: String? = nil,
retryable: Bool,
pauseReconnect: Bool,
technicalDetails: String? = nil)
{
self.kind = kind
self.owner = owner
self.title = title
self.message = message
self.actionLabel = Self.trimmedOrNil(actionLabel)
self.actionCommand = Self.trimmedOrNil(actionCommand)
self.docsURL = docsURL
self.requestId = Self.trimmedOrNil(requestId)
self.retryable = retryable
self.pauseReconnect = pauseReconnect
self.technicalDetails = Self.trimmedOrNil(technicalDetails)
}
public var needsPairingApproval: Bool {
switch self.kind {
case .pairingRequired, .pairingRoleUpgradeRequired, .pairingScopeUpgradeRequired, .pairingMetadataUpgradeRequired:
return true
default:
return false
}
}
public var needsCredentialUpdate: Bool {
switch self.kind {
case .gatewayAuthTokenMissing,
.gatewayAuthTokenMismatch,
.gatewayAuthTokenNotConfigured,
.gatewayAuthPasswordMissing,
.gatewayAuthPasswordMismatch,
.gatewayAuthPasswordNotConfigured,
.bootstrapTokenInvalid,
.deviceTokenMismatch:
return true
default:
return false
}
}
public var statusText: String {
switch self.kind {
case .pairingRequired, .pairingRoleUpgradeRequired, .pairingScopeUpgradeRequired, .pairingMetadataUpgradeRequired:
if let requestId {
return "\(self.title) (request ID: \(requestId))"
}
return self.title
default:
return self.title
}
}
private static func trimmedOrNil(_ value: String?) -> String? {
let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return trimmed.isEmpty ? nil : trimmed
}
}
public enum GatewayConnectionProblemMapper {
public static func map(error: Error, preserving previousProblem: GatewayConnectionProblem? = nil) -> GatewayConnectionProblem? {
guard let nextProblem = self.rawMap(error) else {
return nil
}
guard let previousProblem else {
return nextProblem
}
if self.shouldPreserve(previousProblem: previousProblem, over: nextProblem) {
return previousProblem
}
return nextProblem
}
public static func shouldPreserve(previousProblem: GatewayConnectionProblem, over nextProblem: GatewayConnectionProblem) -> Bool {
if nextProblem.kind == .websocketCancelled {
return previousProblem.pauseReconnect || previousProblem.requestId != nil
}
return false
}
public static func shouldPreserve(previousProblem: GatewayConnectionProblem, overDisconnectReason reason: String) -> Bool {
let normalized = reason.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
guard !normalized.isEmpty else { return false }
if normalized.contains("cancelled") || normalized.contains("canceled") {
return previousProblem.pauseReconnect || previousProblem.requestId != nil
}
return false
}
private static func rawMap(_ error: Error) -> GatewayConnectionProblem? {
if let authError = error as? GatewayConnectAuthError {
return self.map(authError)
}
if let responseError = error as? GatewayResponseError {
return self.map(responseError)
}
return self.mapTransportError(error)
}
private static func map(_ authError: GatewayConnectAuthError) -> GatewayConnectionProblem {
let pairingCommand = self.approvalCommand(requestId: authError.requestId)
switch authError.detail {
case .authTokenMissing:
return self.problem(
kind: .gatewayAuthTokenMissing,
owner: .both,
title: authError.titleOverride ?? "Gateway token required",
message: authError.userMessageOverride
?? "This gateway requires an auth token, but this iPhone did not send one.",
actionLabel: authError.actionLabel ?? "Open Settings",
actionCommand: authError.actionCommand,
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/authentication"),
requestId: authError.requestId,
retryable: false,
pauseReconnect: true,
authError: authError)
case .authTokenMismatch:
return self.problem(
kind: .gatewayAuthTokenMismatch,
owner: .both,
title: authError.titleOverride ?? "Gateway token is out of date",
message: authError.userMessageOverride
?? "The token on this iPhone does not match the gateway token.",
actionLabel: authError.actionLabel ?? (authError.canRetryWithDeviceToken ? "Retry once" : "Update gateway token"),
actionCommand: authError.actionCommand,
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/authentication"),
requestId: authError.requestId,
retryable: authError.retryableOverride ?? authError.canRetryWithDeviceToken,
pauseReconnect: authError.pauseReconnectOverride ?? !authError.canRetryWithDeviceToken,
authError: authError)
case .authTokenNotConfigured:
return self.problem(
kind: .gatewayAuthTokenNotConfigured,
owner: .gateway,
title: authError.titleOverride ?? "Gateway token is not configured",
message: authError.userMessageOverride
?? "This gateway is set to token auth, but no gateway token is configured on the gateway.",
actionLabel: authError.actionLabel ?? "Fix on gateway",
actionCommand: authError.actionCommand ?? "openclaw config set gateway.auth.token <new-token>",
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/authentication"),
requestId: authError.requestId,
retryable: false,
pauseReconnect: true,
authError: authError)
case .authPasswordMissing:
return self.problem(
kind: .gatewayAuthPasswordMissing,
owner: .both,
title: authError.titleOverride ?? "Gateway password required",
message: authError.userMessageOverride
?? "This gateway requires a password, but this iPhone did not send one.",
actionLabel: authError.actionLabel ?? "Open Settings",
actionCommand: authError.actionCommand,
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/authentication"),
requestId: authError.requestId,
retryable: false,
pauseReconnect: true,
authError: authError)
case .authPasswordMismatch:
return self.problem(
kind: .gatewayAuthPasswordMismatch,
owner: .both,
title: authError.titleOverride ?? "Gateway password is out of date",
message: authError.userMessageOverride
?? "The saved password on this iPhone does not match the gateway password.",
actionLabel: authError.actionLabel ?? "Update password",
actionCommand: authError.actionCommand,
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/authentication"),
requestId: authError.requestId,
retryable: false,
pauseReconnect: true,
authError: authError)
case .authPasswordNotConfigured:
return self.problem(
kind: .gatewayAuthPasswordNotConfigured,
owner: .gateway,
title: authError.titleOverride ?? "Gateway password is not configured",
message: authError.userMessageOverride
?? "This gateway is set to password auth, but no gateway password is configured on the gateway.",
actionLabel: authError.actionLabel ?? "Fix on gateway",
actionCommand: authError.actionCommand ?? "openclaw config set gateway.auth.password <new-password>",
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/authentication"),
requestId: authError.requestId,
retryable: false,
pauseReconnect: true,
authError: authError)
case .authBootstrapTokenInvalid:
return self.problem(
kind: .bootstrapTokenInvalid,
owner: .iphone,
title: authError.titleOverride ?? "Setup code expired",
message: authError.userMessageOverride
?? "The setup QR or bootstrap token is no longer valid.",
actionLabel: authError.actionLabel ?? "Scan QR again",
actionCommand: authError.actionCommand,
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/platforms/ios"),
requestId: authError.requestId,
retryable: false,
pauseReconnect: true,
authError: authError)
case .authDeviceTokenMismatch:
return self.problem(
kind: .deviceTokenMismatch,
owner: .both,
title: authError.titleOverride ?? "This iPhone's saved device token is no longer valid",
message: authError.userMessageOverride
?? "The gateway rejected the stored device token for this role.",
actionLabel: authError.actionLabel ?? "Repair pairing",
actionCommand: authError.actionCommand ?? pairingCommand,
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/pairing"),
requestId: authError.requestId,
retryable: false,
pauseReconnect: true,
authError: authError)
case .pairingRequired:
return self.pairingProblem(for: authError)
case .controlUiDeviceIdentityRequired, .deviceIdentityRequired:
return self.problem(
kind: .deviceIdentityRequired,
owner: .iphone,
title: authError.titleOverride ?? "Secure device identity is required",
message: authError.userMessageOverride
?? "This connection must include a signed device identity before the gateway can bind permissions to this iPhone.",
actionLabel: authError.actionLabel ?? "Retry from the app",
actionCommand: authError.actionCommand,
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/platforms/ios"),
requestId: authError.requestId,
retryable: false,
pauseReconnect: true,
authError: authError)
case .deviceAuthSignatureExpired:
return self.problem(
kind: .deviceSignatureExpired,
owner: .iphone,
title: authError.titleOverride ?? "Secure handshake expired",
message: authError.userMessageOverride ?? "The device signature is too old to use.",
actionLabel: authError.actionLabel ?? "Check iPhone time",
actionCommand: authError.actionCommand,
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/troubleshooting"),
requestId: authError.requestId,
retryable: true,
pauseReconnect: true,
authError: authError)
case .deviceAuthNonceRequired:
return self.problem(
kind: .deviceNonceRequired,
owner: .iphone,
title: authError.titleOverride ?? "Secure handshake is incomplete",
message: authError.userMessageOverride
?? "The gateway expected a one-time challenge response, but the nonce was missing.",
actionLabel: authError.actionLabel ?? "Retry",
actionCommand: authError.actionCommand,
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/troubleshooting"),
requestId: authError.requestId,
retryable: true,
pauseReconnect: true,
authError: authError)
case .deviceAuthNonceMismatch:
return self.problem(
kind: .deviceNonceMismatch,
owner: .iphone,
title: authError.titleOverride ?? "Secure handshake did not match",
message: authError.userMessageOverride ?? "The challenge response was stale or mismatched.",
actionLabel: authError.actionLabel ?? "Retry",
actionCommand: authError.actionCommand,
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/troubleshooting"),
requestId: authError.requestId,
retryable: true,
pauseReconnect: true,
authError: authError)
case .deviceAuthSignatureInvalid, .deviceAuthInvalid:
return self.problem(
kind: .deviceSignatureInvalid,
owner: .iphone,
title: authError.titleOverride ?? "This device identity could not be verified",
message: authError.userMessageOverride
?? "The gateway could not verify the identity this iPhone presented.",
actionLabel: authError.actionLabel ?? "Re-pair this iPhone",
actionCommand: authError.actionCommand,
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/pairing"),
requestId: authError.requestId,
retryable: false,
pauseReconnect: true,
authError: authError)
case .deviceAuthPublicKeyInvalid:
return self.problem(
kind: .devicePublicKeyInvalid,
owner: .iphone,
title: authError.titleOverride ?? "This device identity could not be verified",
message: authError.userMessageOverride
?? "The gateway could not verify the public key this iPhone presented.",
actionLabel: authError.actionLabel ?? "Re-pair this iPhone",
actionCommand: authError.actionCommand,
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/pairing"),
requestId: authError.requestId,
retryable: false,
pauseReconnect: true,
authError: authError)
case .deviceAuthDeviceIdMismatch:
return self.problem(
kind: .deviceIdMismatch,
owner: .iphone,
title: authError.titleOverride ?? "This device identity could not be verified",
message: authError.userMessageOverride
?? "The gateway rejected the device identity because the device ID did not match.",
actionLabel: authError.actionLabel ?? "Re-pair this iPhone",
actionCommand: authError.actionCommand,
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/pairing"),
requestId: authError.requestId,
retryable: false,
pauseReconnect: true,
authError: authError)
case .authTailscaleIdentityMissing:
return self.problem(
kind: .tailscaleIdentityMissing,
owner: .network,
title: authError.titleOverride ?? "Tailscale identity check failed",
message: authError.userMessageOverride
?? "This connection expected Tailscale identity headers, but they were not available.",
actionLabel: authError.actionLabel ?? "Turn on Tailscale",
actionCommand: authError.actionCommand,
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/tailscale"),
requestId: authError.requestId,
retryable: false,
pauseReconnect: true,
authError: authError)
case .authTailscaleProxyMissing:
return self.problem(
kind: .tailscaleProxyMissing,
owner: .network,
title: authError.titleOverride ?? "Tailscale identity check failed",
message: authError.userMessageOverride
?? "The gateway expected a Tailscale auth proxy, but it was not configured.",
actionLabel: authError.actionLabel ?? "Review Tailscale setup",
actionCommand: authError.actionCommand,
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/tailscale"),
requestId: authError.requestId,
retryable: false,
pauseReconnect: true,
authError: authError)
case .authTailscaleWhoisFailed:
return self.problem(
kind: .tailscaleWhoisFailed,
owner: .network,
title: authError.titleOverride ?? "Tailscale identity check failed",
message: authError.userMessageOverride
?? "The gateway could not verify this Tailscale client identity.",
actionLabel: authError.actionLabel ?? "Review Tailscale setup",
actionCommand: authError.actionCommand,
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/tailscale"),
requestId: authError.requestId,
retryable: false,
pauseReconnect: true,
authError: authError)
case .authTailscaleIdentityMismatch:
return self.problem(
kind: .tailscaleIdentityMismatch,
owner: .network,
title: authError.titleOverride ?? "Tailscale identity check failed",
message: authError.userMessageOverride
?? "The forwarded Tailscale identity did not match the verified identity.",
actionLabel: authError.actionLabel ?? "Review Tailscale setup",
actionCommand: authError.actionCommand,
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/tailscale"),
requestId: authError.requestId,
retryable: false,
pauseReconnect: true,
authError: authError)
case .authRateLimited:
return self.problem(
kind: .authRateLimited,
owner: .gateway,
title: authError.titleOverride ?? "Too many failed attempts",
message: authError.userMessageOverride
?? "The gateway is temporarily refusing new auth attempts after repeated failures.",
actionLabel: authError.actionLabel ?? "Wait and retry",
actionCommand: authError.actionCommand,
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/troubleshooting"),
requestId: authError.requestId,
retryable: false,
pauseReconnect: true,
authError: authError)
case .authRequired, .authUnauthorized, .none:
return self.problem(
kind: .unknown,
owner: authError.ownerRaw.flatMap { self.owner(from: $0) } ?? .unknown,
title: authError.titleOverride ?? "Gateway rejected the connection",
message: authError.userMessageOverride ?? authError.message,
actionLabel: authError.actionLabel,
actionCommand: authError.actionCommand,
docsURL: self.docsURL(authError.docsURLString, fallback: nil),
requestId: authError.requestId,
retryable: authError.retryableOverride ?? false,
pauseReconnect: authError.pauseReconnectOverride ?? authError.isNonRecoverable,
authError: authError)
}
}
private static func map(_ responseError: GatewayResponseError) -> GatewayConnectionProblem? {
let code = responseError.code.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
if code == "NOT_PAIRED" || responseError.detailsReason == "not-paired" {
let authError = GatewayConnectAuthError(
message: responseError.message,
detailCodeRaw: GatewayConnectAuthDetailCode.pairingRequired.rawValue,
canRetryWithDeviceToken: false,
recommendedNextStepRaw: nil,
requestId: self.stringValue(responseError.details["requestId"]?.value),
detailsReason: responseError.detailsReason,
ownerRaw: nil,
titleOverride: nil,
userMessageOverride: nil,
actionLabel: nil,
actionCommand: nil,
docsURLString: nil,
retryableOverride: nil,
pauseReconnectOverride: nil)
return self.map(authError)
}
return nil
}
private static func mapTransportError(_ error: Error) -> GatewayConnectionProblem? {
let nsError = error as NSError
let rawMessage = nsError.userInfo[NSLocalizedDescriptionKey] as? String ?? nsError.localizedDescription
let lower = rawMessage.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
if lower.isEmpty {
return nil
}
let urlErrorCode = URLError.Code(rawValue: nsError.code)
if nsError.domain == URLError.errorDomain {
switch urlErrorCode {
case .timedOut:
return GatewayConnectionProblem(
kind: .timeout,
owner: .network,
title: "Connection timed out",
message: "The gateway did not respond before the connection timed out.",
actionLabel: "Retry",
actionCommand: nil,
docsURL: URL(string: "https://docs.openclaw.ai/gateway/troubleshooting"),
retryable: true,
pauseReconnect: false,
technicalDetails: rawMessage)
case .cannotConnectToHost:
return GatewayConnectionProblem(
kind: .connectionRefused,
owner: .network,
title: "Gateway refused the connection",
message: "The gateway host was reachable, but it refused the connection.",
actionLabel: "Retry",
actionCommand: nil,
docsURL: URL(string: "https://docs.openclaw.ai/gateway/troubleshooting"),
retryable: true,
pauseReconnect: false,
technicalDetails: rawMessage)
case .cannotFindHost, .dnsLookupFailed, .notConnectedToInternet, .networkConnectionLost, .internationalRoamingOff, .callIsActive, .dataNotAllowed:
return GatewayConnectionProblem(
kind: .reachabilityFailed,
owner: .network,
title: "Gateway is not reachable",
message: "OpenClaw could not reach the gateway over the current network.",
actionLabel: "Check network",
actionCommand: nil,
docsURL: URL(string: "https://docs.openclaw.ai/gateway/troubleshooting"),
retryable: true,
pauseReconnect: false,
technicalDetails: rawMessage)
case .cancelled:
return GatewayConnectionProblem(
kind: .websocketCancelled,
owner: .network,
title: "Connection interrupted",
message: "The connection to the gateway was interrupted before setup completed.",
actionLabel: "Retry",
actionCommand: nil,
docsURL: URL(string: "https://docs.openclaw.ai/gateway/troubleshooting"),
retryable: true,
pauseReconnect: false,
technicalDetails: rawMessage)
default:
break
}
}
if lower.contains("timed out") {
return GatewayConnectionProblem(
kind: .timeout,
owner: .network,
title: "Connection timed out",
message: "The gateway did not respond before the connection timed out.",
actionLabel: "Retry",
actionCommand: nil,
docsURL: URL(string: "https://docs.openclaw.ai/gateway/troubleshooting"),
retryable: true,
pauseReconnect: false,
technicalDetails: rawMessage)
}
if lower.contains("connection refused") || lower.contains("refused") {
return GatewayConnectionProblem(
kind: .connectionRefused,
owner: .network,
title: "Gateway refused the connection",
message: "The gateway host was reachable, but it refused the connection.",
actionLabel: "Retry",
actionCommand: nil,
docsURL: URL(string: "https://docs.openclaw.ai/gateway/troubleshooting"),
retryable: true,
pauseReconnect: false,
technicalDetails: rawMessage)
}
if lower.contains("cannot find host") || lower.contains("could not connect") || lower.contains("network is unreachable") {
return GatewayConnectionProblem(
kind: .reachabilityFailed,
owner: .network,
title: "Gateway is not reachable",
message: "OpenClaw could not reach the gateway over the current network.",
actionLabel: "Check network",
actionCommand: nil,
docsURL: URL(string: "https://docs.openclaw.ai/gateway/troubleshooting"),
retryable: true,
pauseReconnect: false,
technicalDetails: rawMessage)
}
if lower.contains("cancelled") || lower.contains("canceled") {
return GatewayConnectionProblem(
kind: .websocketCancelled,
owner: .network,
title: "Connection interrupted",
message: "The connection to the gateway was interrupted before setup completed.",
actionLabel: "Retry",
actionCommand: nil,
docsURL: URL(string: "https://docs.openclaw.ai/gateway/troubleshooting"),
retryable: true,
pauseReconnect: false,
technicalDetails: rawMessage)
}
return nil
}
private static func pairingProblem(for authError: GatewayConnectAuthError) -> GatewayConnectionProblem {
let requestId = authError.requestId
let pairingCommand = self.approvalCommand(requestId: requestId)
switch authError.detailsReason {
case "role-upgrade":
return self.problem(
kind: .pairingRoleUpgradeRequired,
owner: .gateway,
title: authError.titleOverride ?? "Additional approval required",
message: authError.userMessageOverride
?? "This iPhone is already paired, but it is requesting a new role that was not previously approved.",
actionLabel: authError.actionLabel ?? "Approve on gateway",
actionCommand: authError.actionCommand ?? pairingCommand,
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/pairing"),
requestId: requestId,
retryable: false,
pauseReconnect: true,
authError: authError)
case "scope-upgrade":
return self.problem(
kind: .pairingScopeUpgradeRequired,
owner: .gateway,
title: authError.titleOverride ?? "Additional permissions required",
message: authError.userMessageOverride
?? "This iPhone is already paired, but it is requesting new permissions that require approval.",
actionLabel: authError.actionLabel ?? "Approve on gateway",
actionCommand: authError.actionCommand ?? pairingCommand,
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/pairing"),
requestId: requestId,
retryable: false,
pauseReconnect: true,
authError: authError)
case "metadata-upgrade":
return self.problem(
kind: .pairingMetadataUpgradeRequired,
owner: .gateway,
title: authError.titleOverride ?? "Device approval needs refresh",
message: authError.userMessageOverride
?? "The gateway detected a change in this device's approved identity metadata and requires re-approval.",
actionLabel: authError.actionLabel ?? "Approve on gateway",
actionCommand: authError.actionCommand ?? pairingCommand,
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/pairing"),
requestId: requestId,
retryable: false,
pauseReconnect: true,
authError: authError)
default:
return self.problem(
kind: .pairingRequired,
owner: .gateway,
title: authError.titleOverride ?? "This iPhone is not approved yet",
message: authError.userMessageOverride
?? "The gateway received the connection request, but this device must be approved first.",
actionLabel: authError.actionLabel ?? "Approve on gateway",
actionCommand: authError.actionCommand ?? pairingCommand,
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/pairing"),
requestId: requestId,
retryable: false,
pauseReconnect: true,
authError: authError)
}
}
private static func problem(
kind: GatewayConnectionProblem.Kind,
owner: GatewayConnectionProblem.Owner,
title: String,
message: String,
actionLabel: String?,
actionCommand: String?,
docsURL: URL?,
requestId: String?,
retryable: Bool,
pauseReconnect: Bool,
authError: GatewayConnectAuthError)
-> GatewayConnectionProblem
{
GatewayConnectionProblem(
kind: kind,
owner: authError.ownerRaw.flatMap(self.owner(from:)) ?? owner,
title: title,
message: message,
actionLabel: actionLabel,
actionCommand: actionCommand,
docsURL: docsURL,
requestId: requestId,
retryable: authError.retryableOverride ?? retryable,
pauseReconnect: authError.pauseReconnectOverride ?? pauseReconnect,
technicalDetails: self.technicalDetails(for: authError))
}
private static func approvalCommand(requestId: String?) -> String {
if let requestId = self.nonEmpty(requestId) {
return "openclaw devices approve \(requestId)"
}
return "openclaw devices list"
}
private static func technicalDetails(for authError: GatewayConnectAuthError) -> String? {
var parts: [String] = []
if let detail = self.nonEmpty(authError.detailCodeRaw) {
parts.append(detail)
}
if let reason = self.nonEmpty(authError.detailsReason) {
parts.append("reason=\(reason)")
}
if let requestId = self.nonEmpty(authError.requestId) {
parts.append("requestId=\(requestId)")
}
if let nextStep = self.nonEmpty(authError.recommendedNextStepRaw) {
parts.append("next=\(nextStep)")
}
if authError.canRetryWithDeviceToken {
parts.append("deviceTokenRetry=true")
}
return parts.isEmpty ? nil : parts.joined(separator: " · ")
}
private static func docsURL(_ preferred: String?, fallback: String?) -> URL? {
if let preferred = self.nonEmpty(preferred), let url = URL(string: preferred) {
return url
}
if let fallback = self.nonEmpty(fallback), let url = URL(string: fallback) {
return url
}
return nil
}
private static func owner(from raw: String) -> GatewayConnectionProblem.Owner? {
switch raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {
case "gateway":
return .gateway
case "iphone", "ios", "device":
return .iphone
case "both":
return .both
case "network":
return .network
case "unknown", "":
return .unknown
default:
return nil
}
}
private static func stringValue(_ value: Any?) -> String? {
self.nonEmpty(value as? String)
}
private static func nonEmpty(_ value: String?) -> String? {
let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return trimmed.isEmpty ? nil : trimmed
}
}

View File

@@ -43,32 +43,12 @@ public struct GatewayConnectAuthError: LocalizedError, Sendable {
public let detailCodeRaw: String?
public let recommendedNextStepRaw: String?
public let canRetryWithDeviceToken: Bool
public let requestId: String?
public let detailsReason: String?
public let ownerRaw: String?
public let titleOverride: String?
public let userMessageOverride: String?
public let actionLabel: String?
public let actionCommand: String?
public let docsURLString: String?
public let retryableOverride: Bool?
public let pauseReconnectOverride: Bool?
public init(
message: String,
detailCodeRaw: String?,
canRetryWithDeviceToken: Bool,
recommendedNextStepRaw: String? = nil,
requestId: String? = nil,
detailsReason: String? = nil,
ownerRaw: String? = nil,
titleOverride: String? = nil,
userMessageOverride: String? = nil,
actionLabel: String? = nil,
actionCommand: String? = nil,
docsURLString: String? = nil,
retryableOverride: Bool? = nil,
pauseReconnectOverride: Bool? = nil)
recommendedNextStepRaw: String? = nil)
{
let trimmedMessage = message.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmedDetailCode = detailCodeRaw?.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -79,54 +59,19 @@ public struct GatewayConnectAuthError: LocalizedError, Sendable {
self.canRetryWithDeviceToken = canRetryWithDeviceToken
self.recommendedNextStepRaw =
trimmedRecommendedNextStep?.isEmpty == false ? trimmedRecommendedNextStep : nil
self.requestId = Self.trimmedOrNil(requestId)
self.detailsReason = Self.trimmedOrNil(detailsReason)
self.ownerRaw = Self.trimmedOrNil(ownerRaw)
self.titleOverride = Self.trimmedOrNil(titleOverride)
self.userMessageOverride = Self.trimmedOrNil(userMessageOverride)
self.actionLabel = Self.trimmedOrNil(actionLabel)
self.actionCommand = Self.trimmedOrNil(actionCommand)
self.docsURLString = Self.trimmedOrNil(docsURLString)
self.retryableOverride = retryableOverride
self.pauseReconnectOverride = pauseReconnectOverride
}
public init(
message: String,
detailCode: String?,
canRetryWithDeviceToken: Bool,
recommendedNextStep: String? = nil,
requestId: String? = nil,
detailsReason: String? = nil,
ownerRaw: String? = nil,
titleOverride: String? = nil,
userMessageOverride: String? = nil,
actionLabel: String? = nil,
actionCommand: String? = nil,
docsURLString: String? = nil,
retryableOverride: Bool? = nil,
pauseReconnectOverride: Bool? = nil)
recommendedNextStep: String? = nil)
{
self.init(
message: message,
detailCodeRaw: detailCode,
canRetryWithDeviceToken: canRetryWithDeviceToken,
recommendedNextStepRaw: recommendedNextStep,
requestId: requestId,
detailsReason: detailsReason,
ownerRaw: ownerRaw,
titleOverride: titleOverride,
userMessageOverride: userMessageOverride,
actionLabel: actionLabel,
actionCommand: actionCommand,
docsURLString: docsURLString,
retryableOverride: retryableOverride,
pauseReconnectOverride: pauseReconnectOverride)
}
private static func trimmedOrNil(_ value: String?) -> String? {
let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return trimmed.isEmpty ? nil : trimmed
recommendedNextStepRaw: recommendedNextStep)
}
public var detailCode: String? { self.detailCodeRaw }

View File

@@ -361,14 +361,6 @@
}
}
},
"update_plan": {
"emoji": "🗺️",
"title": "Update Plan",
"detailKeys": [
"explanation",
"plan.0.step"
]
},
"gateway": {
"emoji": "🔌",
"title": "Gateway",

View File

@@ -537,8 +537,6 @@ public struct AgentParams: Codable, Sendable {
public let besteffortdeliver: Bool?
public let lane: String?
public let extrasystemprompt: String?
public let bootstrapcontextmode: AnyCodable?
public let bootstrapcontextrunkind: AnyCodable?
public let internalevents: [[String: AnyCodable]]?
public let inputprovenance: [String: AnyCodable]?
public let idempotencykey: String
@@ -568,8 +566,6 @@ public struct AgentParams: Codable, Sendable {
besteffortdeliver: Bool?,
lane: String?,
extrasystemprompt: String?,
bootstrapcontextmode: AnyCodable?,
bootstrapcontextrunkind: AnyCodable?,
internalevents: [[String: AnyCodable]]?,
inputprovenance: [String: AnyCodable]?,
idempotencykey: String,
@@ -598,8 +594,6 @@ public struct AgentParams: Codable, Sendable {
self.besteffortdeliver = besteffortdeliver
self.lane = lane
self.extrasystemprompt = extrasystemprompt
self.bootstrapcontextmode = bootstrapcontextmode
self.bootstrapcontextrunkind = bootstrapcontextrunkind
self.internalevents = internalevents
self.inputprovenance = inputprovenance
self.idempotencykey = idempotencykey
@@ -630,8 +624,6 @@ public struct AgentParams: Codable, Sendable {
case besteffortdeliver = "bestEffortDeliver"
case lane
case extrasystemprompt = "extraSystemPrompt"
case bootstrapcontextmode = "bootstrapContextMode"
case bootstrapcontextrunkind = "bootstrapContextRunKind"
case internalevents = "internalEvents"
case inputprovenance = "inputProvenance"
case idempotencykey = "idempotencyKey"
@@ -1335,236 +1327,6 @@ public struct SessionsResolveParams: Codable, Sendable {
}
}
public struct SessionCompactionCheckpoint: Codable, Sendable {
public let checkpointid: String
public let sessionkey: String
public let sessionid: String
public let createdat: Int
public let reason: AnyCodable
public let tokensbefore: Int?
public let tokensafter: Int?
public let summary: String?
public let firstkeptentryid: String?
public let precompaction: [String: AnyCodable]
public let postcompaction: [String: AnyCodable]
public init(
checkpointid: String,
sessionkey: String,
sessionid: String,
createdat: Int,
reason: AnyCodable,
tokensbefore: Int?,
tokensafter: Int?,
summary: String?,
firstkeptentryid: String?,
precompaction: [String: AnyCodable],
postcompaction: [String: AnyCodable])
{
self.checkpointid = checkpointid
self.sessionkey = sessionkey
self.sessionid = sessionid
self.createdat = createdat
self.reason = reason
self.tokensbefore = tokensbefore
self.tokensafter = tokensafter
self.summary = summary
self.firstkeptentryid = firstkeptentryid
self.precompaction = precompaction
self.postcompaction = postcompaction
}
private enum CodingKeys: String, CodingKey {
case checkpointid = "checkpointId"
case sessionkey = "sessionKey"
case sessionid = "sessionId"
case createdat = "createdAt"
case reason
case tokensbefore = "tokensBefore"
case tokensafter = "tokensAfter"
case summary
case firstkeptentryid = "firstKeptEntryId"
case precompaction = "preCompaction"
case postcompaction = "postCompaction"
}
}
public struct SessionsCompactionListParams: Codable, Sendable {
public let key: String
public init(
key: String)
{
self.key = key
}
private enum CodingKeys: String, CodingKey {
case key
}
}
public struct SessionsCompactionGetParams: Codable, Sendable {
public let key: String
public let checkpointid: String
public init(
key: String,
checkpointid: String)
{
self.key = key
self.checkpointid = checkpointid
}
private enum CodingKeys: String, CodingKey {
case key
case checkpointid = "checkpointId"
}
}
public struct SessionsCompactionBranchParams: Codable, Sendable {
public let key: String
public let checkpointid: String
public init(
key: String,
checkpointid: String)
{
self.key = key
self.checkpointid = checkpointid
}
private enum CodingKeys: String, CodingKey {
case key
case checkpointid = "checkpointId"
}
}
public struct SessionsCompactionRestoreParams: Codable, Sendable {
public let key: String
public let checkpointid: String
public init(
key: String,
checkpointid: String)
{
self.key = key
self.checkpointid = checkpointid
}
private enum CodingKeys: String, CodingKey {
case key
case checkpointid = "checkpointId"
}
}
public struct SessionsCompactionListResult: Codable, Sendable {
public let ok: Bool
public let key: String
public let checkpoints: [SessionCompactionCheckpoint]
public init(
ok: Bool,
key: String,
checkpoints: [SessionCompactionCheckpoint])
{
self.ok = ok
self.key = key
self.checkpoints = checkpoints
}
private enum CodingKeys: String, CodingKey {
case ok
case key
case checkpoints
}
}
public struct SessionsCompactionGetResult: Codable, Sendable {
public let ok: Bool
public let key: String
public let checkpoint: SessionCompactionCheckpoint
public init(
ok: Bool,
key: String,
checkpoint: SessionCompactionCheckpoint)
{
self.ok = ok
self.key = key
self.checkpoint = checkpoint
}
private enum CodingKeys: String, CodingKey {
case ok
case key
case checkpoint
}
}
public struct SessionsCompactionBranchResult: Codable, Sendable {
public let ok: Bool
public let sourcekey: String
public let key: String
public let sessionid: String
public let checkpoint: SessionCompactionCheckpoint
public let entry: [String: AnyCodable]
public init(
ok: Bool,
sourcekey: String,
key: String,
sessionid: String,
checkpoint: SessionCompactionCheckpoint,
entry: [String: AnyCodable])
{
self.ok = ok
self.sourcekey = sourcekey
self.key = key
self.sessionid = sessionid
self.checkpoint = checkpoint
self.entry = entry
}
private enum CodingKeys: String, CodingKey {
case ok
case sourcekey = "sourceKey"
case key
case sessionid = "sessionId"
case checkpoint
case entry
}
}
public struct SessionsCompactionRestoreResult: Codable, Sendable {
public let ok: Bool
public let key: String
public let sessionid: String
public let checkpoint: SessionCompactionCheckpoint
public let entry: [String: AnyCodable]
public init(
ok: Bool,
key: String,
sessionid: String,
checkpoint: SessionCompactionCheckpoint,
entry: [String: AnyCodable])
{
self.ok = ok
self.key = key
self.sessionid = sessionid
self.checkpoint = checkpoint
self.entry = entry
}
private enum CodingKeys: String, CodingKey {
case ok
case key
case sessionid = "sessionId"
case checkpoint
case entry
}
}
public struct SessionsCreateParams: Codable, Sendable {
public let key: String?
public let agentid: String?

View File

@@ -1,4 +1,3 @@
import Foundation
import OpenClawKit
import Testing
@@ -12,81 +11,4 @@ import Testing
#expect(error.isNonRecoverable)
#expect(error.detail == .authBootstrapTokenInvalid)
}
@Test func connectAuthErrorPreservesStructuredMetadata() {
let error = GatewayConnectAuthError(
message: "pairing required",
detailCode: GatewayConnectAuthDetailCode.pairingRequired.rawValue,
canRetryWithDeviceToken: false,
recommendedNextStep: "review_auth_configuration",
requestId: "req-123",
detailsReason: "scope-upgrade",
ownerRaw: "gateway",
titleOverride: "Additional permissions required",
userMessageOverride: "Approve the requested permissions on the gateway, then reconnect.",
actionLabel: "Approve on gateway",
actionCommand: "openclaw devices approve req-123",
docsURLString: "https://docs.openclaw.ai/gateway/pairing",
retryableOverride: false,
pauseReconnectOverride: true)
#expect(error.requestId == "req-123")
#expect(error.detailsReason == "scope-upgrade")
#expect(error.ownerRaw == "gateway")
#expect(error.titleOverride == "Additional permissions required")
#expect(error.actionCommand == "openclaw devices approve req-123")
#expect(error.docsURLString == "https://docs.openclaw.ai/gateway/pairing")
#expect(error.pauseReconnectOverride == true)
}
@Test func pairingProblemUsesStructuredRequestMetadata() {
let error = GatewayConnectAuthError(
message: "pairing required",
detailCode: GatewayConnectAuthDetailCode.pairingRequired.rawValue,
canRetryWithDeviceToken: false,
requestId: "req-123",
detailsReason: "scope-upgrade")
let problem = GatewayConnectionProblemMapper.map(error: error)
#expect(problem?.kind == .pairingScopeUpgradeRequired)
#expect(problem?.requestId == "req-123")
#expect(problem?.pauseReconnect == true)
#expect(problem?.actionCommand == "openclaw devices approve req-123")
}
@Test func cancelledTransportDoesNotReplaceStructuredPairingProblem() {
let pairing = GatewayConnectAuthError(
message: "pairing required",
detailCode: GatewayConnectAuthDetailCode.pairingRequired.rawValue,
canRetryWithDeviceToken: false,
requestId: "req-123")
let previousProblem = GatewayConnectionProblemMapper.map(error: pairing)
let cancelled = NSError(
domain: URLError.errorDomain,
code: URLError.cancelled.rawValue,
userInfo: [NSLocalizedDescriptionKey: "gateway receive: cancelled"])
let preserved = GatewayConnectionProblemMapper.map(error: cancelled, preserving: previousProblem)
#expect(preserved?.kind == .pairingRequired)
#expect(preserved?.requestId == "req-123")
}
@Test func unmappedTransportErrorClearsStaleStructuredProblem() {
let pairing = GatewayConnectAuthError(
message: "pairing required",
detailCode: GatewayConnectAuthDetailCode.pairingRequired.rawValue,
canRetryWithDeviceToken: false,
requestId: "req-123")
let previousProblem = GatewayConnectionProblemMapper.map(error: pairing)
let unknownTransport = NSError(
domain: NSURLErrorDomain,
code: -1202,
userInfo: [NSLocalizedDescriptionKey: "certificate chain validation failed"])
let mapped = GatewayConnectionProblemMapper.map(error: unknownTransport, preserving: previousProblem)
#expect(mapped == nil)
}
}

View File

@@ -1,4 +1,4 @@
f7b342080a730da84d1ac84a888e9506d24ee7ce7ec6ec6c0cc4f1897fabcde3 config-baseline.json
c3dd9fb8a0059dba411c4d88a6b84ca28af1e0b1925c669058ef9f38c6d2718b config-baseline.core.json
d22f4414b79ee03d896e58d875c80523bcc12303cbacb1700261e6ec73945187 config-baseline.channel.json
d42cee3dea4668bdb7daf6ff5e6f87f326fdef56a8c3716d73079b92cab6e7b2 config-baseline.plugin.json
1c74540dd152c55dbda3e5dee1e37008ee3e6aabb0608e571292832c7a1c012c config-baseline.json
7e30316f2326b7d07b71d7b8a96049a74b81428921299b5c4b5aa3d080e03305 config-baseline.core.json
66edc86a9d16db1b9e9e7dd99b7032e2d9bcfb9ff210256a21f4b4f088cb3dc1 config-baseline.channel.json
d6ebc4948499b997c4a3727cf31849d4a598de9f1a4c197417dcc0b0ec1b734f config-baseline.plugin.json

View File

@@ -1,2 +1,2 @@
2efa99907731355b31a1b95a6baa9cf5bf8d25c67931837857c9bb9dd39fad95 plugin-sdk-api-baseline.json
6c99467113b5d6a015cbd424f2eb5c7e21a6c665b3e8d0372e0e09a2218ef13e plugin-sdk-api-baseline.jsonl
08615a28ed3deb20a96c9cd8fd7237a4cbb209ceec93dca03b543979304459e4 plugin-sdk-api-baseline.json
683c1249dc15529d8e79bc75e9c00484551cb74126befee507fffcf786e01833 plugin-sdk-api-baseline.jsonl

View File

@@ -227,10 +227,7 @@ Quick mental model (evaluation order for group messages):
Group messages require a mention unless overridden per group. Defaults live per subsystem under `*.groups."*"`.
Replying to a bot message counts as an implicit mention when the channel
supports reply metadata. Quoting a bot message can also count as an implicit
mention on channels that expose quote metadata. Current built-in cases include
Telegram, WhatsApp, Slack, Discord, Microsoft Teams, and ZaloUser.
Replying to a bot message counts as an implicit mention (when the channel supports reply metadata). This applies to Telegram, WhatsApp, Slack, Discord, and Microsoft Teams.
```json5
{

View File

@@ -61,17 +61,13 @@ What the Matrix wizard actually asks for:
- optional device name
- whether to enable E2EE
- whether to configure Matrix room access now
- whether to configure Matrix invite auto-join now
- when invite auto-join is enabled, whether it should be `allowlist`, `always`, or `off`
Wizard behavior that matters:
- If Matrix auth env vars already exist for the selected account, and that account does not already have auth saved in config, the wizard offers an env shortcut so setup can keep auth in env vars instead of copying secrets into config.
- If Matrix auth env vars already exist for the selected account, and that account does not already have auth saved in config, the wizard offers an env shortcut and only writes `enabled: true` for that account.
- When you add another Matrix account interactively, the entered account name is normalized into the account ID used in config and env vars. For example, `Ops Bot` becomes `ops-bot`.
- DM allowlist prompts accept full `@user:server` values immediately. Display names only work when live directory lookup finds one exact match; otherwise the wizard asks you to retry with a full Matrix ID.
- Room allowlist prompts accept room IDs and aliases directly. They can also resolve joined-room names live, but unresolved names are only kept as typed during setup and are ignored later by runtime allowlist resolution. Prefer `!room:server` or `#alias:server`.
- The wizard now shows an explicit warning before the invite auto-join step because `channels.matrix.autoJoin` defaults to `off`; agents will not join invited rooms or fresh DM-style invites unless you set it.
- In invite auto-join allowlist mode, use only stable invite targets: `!roomId:server`, `#alias:server`, or `*`. Plain room names are rejected.
- Runtime room/session identity uses the stable Matrix room ID. Room-declared aliases are only used as lookup inputs, not as the long-term session key or stable group identity.
- To resolve room names before saving them, use `openclaw channels resolve --channel matrix "Project Room"`.
@@ -81,8 +77,6 @@ Wizard behavior that matters:
If you leave it unset, the bot will not join invited rooms or fresh DM-style invites, so it will not appear in new groups or invited DMs unless you join manually first.
Set `autoJoin: "allowlist"` together with `autoJoinAllowlist` to restrict which invites it accepts, or set `autoJoin: "always"` if you want it to join every invite.
In `allowlist` mode, `autoJoinAllowlist` only accepts `!roomId:server`, `#alias:server`, or `*`.
</Warning>
Allowlist example:
@@ -880,8 +874,7 @@ See [Pairing](/channels/pairing) for the shared DM pairing flow and storage layo
## Exec approvals
Matrix can act as a native approval client for a Matrix account. The native
DM/channel routing knobs still live under exec approval config:
Matrix can act as an exec approval client for a Matrix account.
- `channels.matrix.execApprovals.enabled`
- `channels.matrix.execApprovals.approvers` (optional; falls back to `channels.matrix.dm.allowFrom`)
@@ -889,14 +882,13 @@ DM/channel routing knobs still live under exec approval config:
- `channels.matrix.execApprovals.agentFilter`
- `channels.matrix.execApprovals.sessionFilter`
Approvers must be Matrix user IDs such as `@owner:example.org`. Matrix auto-enables native approvals when `enabled` is unset or `"auto"` and at least one approver can be resolved. Exec approvals use `execApprovals.approvers` first and can fall back to `channels.matrix.dm.allowFrom`. Plugin approvals authorize through `channels.matrix.dm.allowFrom`. Set `enabled: false` to disable Matrix as a native approval client explicitly. Approval requests otherwise fall back to other configured approval routes or the approval fallback policy.
Approvers must be Matrix user IDs such as `@owner:example.org`. Matrix auto-enables native exec approvals when `enabled` is unset or `"auto"` and at least one approver can be resolved, either from `execApprovals.approvers` or from `channels.matrix.dm.allowFrom`. Set `enabled: false` to disable Matrix as a native approval client explicitly. Approval requests otherwise fall back to other configured approval routes or the exec approval fallback policy.
Matrix native routing now supports both approval kinds:
Native Matrix routing is exec-only today:
- `channels.matrix.execApprovals.*` controls the native DM/channel fanout mode for Matrix approval prompts.
- Exec approvals use the exec approver set from `execApprovals.approvers` or `channels.matrix.dm.allowFrom`.
- Plugin approvals use the Matrix DM allowlist from `channels.matrix.dm.allowFrom`.
- Matrix reaction shortcuts and message updates apply to both exec and plugin approvals.
- `channels.matrix.execApprovals.*` controls native DM/channel routing for exec approvals only.
- Plugin approvals still use shared same-chat `/approve` plus any configured `approvals.plugin` forwarding.
- Matrix can still reuse `channels.matrix.dm.allowFrom` for plugin-approval authorization when it can infer approvers safely, but it does not expose a separate native plugin-approval DM/channel fanout path.
Delivery rules:
@@ -912,9 +904,9 @@ Matrix approval prompts seed reaction shortcuts on the primary approval message:
Approvers can react on that message or use the fallback slash commands: `/approve <id> allow-once`, `/approve <id> allow-always`, or `/approve <id> deny`.
Only resolved approvers can approve or deny. For exec approvals, channel delivery includes the command text, so only enable `channel` or `both` in trusted rooms.
Only resolved approvers can approve or deny. Channel delivery includes the command text, so only enable `channel` or `both` in trusted rooms.
Matrix approval prompts reuse the shared core approval planner. The Matrix-specific native surface handles room/DM routing, reactions, and message send/update/delete behavior for both exec and plugin approvals.
Matrix approval prompts reuse the shared core approval planner. The Matrix-specific native surface is transport only for exec approvals: room/DM routing and message send/update/delete behavior.
Per-account override:

View File

@@ -399,7 +399,7 @@ Current Slack message actions include `send`, `upload-file`, `download-file`, `r
- explicit app mention (`<@botId>`)
- mention regex patterns (`agents.list[].groupChat.mentionPatterns`, fallback `messages.groupChat.mentionPatterns`)
- implicit reply-to-bot thread behavior (disabled when `thread.requireExplicitMention` is `true`)
- implicit reply-to-bot thread behavior
Per-channel controls (`channels.slack.channels.<id>`; names only via startup resolution or `dangerouslyAllowNameMatching`):
@@ -423,7 +423,6 @@ Current Slack message actions include `send`, `upload-file`, `download-file`, `r
- Thread replies can create thread session suffixes (`:thread:<threadTs>`) when applicable.
- `channels.slack.thread.historyScope` default is `thread`; `thread.inheritParent` default is `false`.
- `channels.slack.thread.initialHistoryLimit` controls how many existing thread messages are fetched when a new thread session starts (default `20`; set `0` to disable).
- `channels.slack.thread.requireExplicitMention` (default `false`): when `true`, suppress implicit thread mentions so the bot only responds to explicit `@bot` mentions inside threads, even when the bot already participated in the thread. Without this, replies in a bot-participated thread bypass `requireMention` gating.
Reply threading controls:

View File

@@ -124,7 +124,6 @@ Example:
- `channels.zalouser.groups.<group>.requireMention` controls whether group replies require a mention.
- Resolution order: exact group id/name -> normalized group slug -> `*` -> default (`true`).
- This applies both to allowlisted groups and open group mode.
- Quoting a bot message counts as an implicit mention for group activation.
- Authorized control commands (for example `/new`) can bypass mention gating.
- When a group message is skipped because mention is required, OpenClaw stores it as pending group history and includes it on the next processed group message.
- Group history limit defaults to `messages.groupChat.historyLimit` (fallback `50`). You can override per account with `channels.zalouser.historyLimit`.

116
docs/cli/capability.md Normal file
View File

@@ -0,0 +1,116 @@
---
summary: "Capability-first CLI for provider-backed model, media, web, and embedding workflows"
read_when:
- Adding or modifying `openclaw capability` commands
- Designing stable headless capability automation
title: "Capability CLI"
---
# Capability CLI
`openclaw capability` is the canonical headless surface for provider-backed capabilities.
It intentionally exposes capability families, not raw gateway RPC names and not raw agent tool ids.
## Command tree
```text
openclaw capability
list
inspect
model
run
list
inspect
providers
auth login
auth logout
auth status
media
image
generate
edit
describe
describe-many
providers
audio
transcribe
providers
tts
convert
voices
providers
status
enable
disable
set-provider
video
generate
describe
providers
web
search
fetch
providers
memory
embedding
create
providers
```
## Transport
Supported transport flags:
- `--local`
- `--gateway`
Default transport is implicit auto at the command-family level:
- Stateless execution commands default to local.
- Gateway-managed state commands default to gateway.
Examples:
```bash
openclaw capability model run --prompt "hello" --json
openclaw capability media image generate --prompt "friendly lobster" --json
openclaw capability media tts status --json
openclaw capability embedding create --text "hello world" --json
```
## JSON output
Capability commands normalize JSON output under a shared envelope:
```json
{
"ok": true,
"capability": "media.image.generate",
"transport": "local",
"provider": "openai",
"model": "gpt-image-1",
"attempts": [],
"outputs": []
}
```
Top-level fields are stable:
- `ok`
- `capability`
- `transport`
- `provider`
- `model`
- `attempts`
- `outputs`
- `error`
## Notes
- `model run` reuses the agent runtime so provider/model overrides behave like normal agent execution.
- `media tts status` defaults to gateway because it reflects gateway-managed TTS state.

View File

@@ -35,7 +35,7 @@ This page describes the current CLI behavior. If commands change, update this do
- [`logs`](/cli/logs)
- [`system`](/cli/system)
- [`models`](/cli/models)
- [`infer`](/cli/infer)
- [`capability`](/cli/capability)
- [`memory`](/cli/memory)
- [`directory`](/cli/directory)
- [`nodes`](/cli/nodes)
@@ -249,14 +249,14 @@ openclaw [--dev] [--profile <name>] <command>
fallbacks list|add|remove|clear
image-fallbacks list|add|remove|clear
scan
infer (alias: capability)
capability
list
inspect
model run|list|inspect|providers|auth login|logout|status
image generate|edit|describe|describe-many|providers
audio transcribe|providers
tts convert|voices|providers|status|enable|disable|set-provider
video generate|describe|providers
media image generate|edit|describe|describe-many|providers
media audio transcribe|providers
media tts convert|voices|providers|status|enable|disable|set-provider
media video generate|describe|providers
web search|fetch|providers
embedding create|providers
auth add|login|login-github-copilot|setup-token|paste-token

View File

@@ -1,280 +0,0 @@
---
summary: "Infer-first CLI for provider-backed model, image, audio, TTS, video, web, and embedding workflows"
read_when:
- Adding or modifying `openclaw infer` commands
- Designing stable headless capability automation
title: "Inference CLI"
---
# Inference CLI
`openclaw infer` is the canonical headless surface for provider-backed inference workflows.
It intentionally exposes capability families, not raw gateway RPC names and not raw agent tool ids.
## Turn infer into a skill
Copy and paste this to an agent:
```text
Read https://docs.openclaw.ai/cli/infer, then create a skill that routes my common workflows to `openclaw infer`.
Focus on model runs, image generation, video generation, audio transcription, TTS, web search, and embeddings.
```
A good infer-based skill should:
- map common user intents to the correct infer subcommand
- include a few canonical infer examples for the workflows it covers
- prefer `openclaw infer ...` in examples and suggestions
- avoid re-documenting the entire infer surface inside the skill body
Typical infer-focused skill coverage:
- `openclaw infer model run`
- `openclaw infer image generate`
- `openclaw infer audio transcribe`
- `openclaw infer tts convert`
- `openclaw infer web search`
- `openclaw infer embedding create`
## Why use infer
`openclaw infer` provides one consistent CLI for provider-backed inference tasks inside OpenClaw.
Benefits:
- Use the providers and models already configured in OpenClaw instead of wiring up one-off wrappers for each backend.
- Keep model, image, audio transcription, TTS, video, web, and embedding workflows under one command tree.
- Use a stable `--json` output shape for scripts, automation, and agent-driven workflows.
- Prefer a first-party OpenClaw surface when the task is fundamentally "run inference."
- Use the normal local path without requiring the gateway for most infer commands.
## Command tree
```text
openclaw infer
list
inspect
model
run
list
inspect
providers
auth login
auth logout
auth status
image
generate
edit
describe
describe-many
providers
audio
transcribe
providers
tts
convert
voices
providers
status
enable
disable
set-provider
video
generate
describe
providers
web
search
fetch
providers
embedding
create
providers
```
## Common tasks
This table maps common inference tasks to the corresponding infer command.
| Task | Command | Notes |
| ----------------------- | ---------------------------------------------------------------------- | ---------------------------------------------------- |
| Run a text/model prompt | `openclaw infer model run --prompt "..." --json` | Uses the normal local path by default |
| Generate an image | `openclaw infer image generate --prompt "..." --json` | Use `image edit` when starting from an existing file |
| Describe an image file | `openclaw infer image describe --file ./image.png --json` | `--model` must be `<provider/model>` |
| Transcribe audio | `openclaw infer audio transcribe --file ./memo.m4a --json` | `--model` must be `<provider/model>` |
| Synthesize speech | `openclaw infer tts convert --text "..." --output ./speech.mp3 --json` | `tts status` is gateway-oriented |
| Generate a video | `openclaw infer video generate --prompt "..." --json` | |
| Describe a video file | `openclaw infer video describe --file ./clip.mp4 --json` | `--model` must be `<provider/model>` |
| Search the web | `openclaw infer web search --query "..." --json` | |
| Fetch a web page | `openclaw infer web fetch --url https://example.com --json` | |
| Create embeddings | `openclaw infer embedding create --text "..." --json` | |
## Behavior
- `openclaw infer ...` is the primary CLI surface for these workflows.
- Use `--json` when the output will be consumed by another command or script.
- Use `--provider` or `--model provider/model` when a specific backend is required.
- For `image describe`, `audio transcribe`, and `video describe`, `--model` must use the form `<provider/model>`.
- Stateless execution commands default to local.
- Gateway-managed state commands default to gateway.
- The normal local path does not require the gateway to be running.
## Model
Use `model` for provider-backed text inference and model/provider inspection.
```bash
openclaw infer model run --prompt "Reply with exactly: smoke-ok" --json
openclaw infer model run --prompt "Summarize this changelog entry" --provider openai --json
openclaw infer model providers --json
openclaw infer model inspect --name gpt-5.4 --json
```
Notes:
- `model run` reuses the agent runtime so provider/model overrides behave like normal agent execution.
- `model auth login`, `model auth logout`, and `model auth status` manage saved provider auth state.
## Image
Use `image` for generation, edit, and description.
```bash
openclaw infer image generate --prompt "friendly lobster illustration" --json
openclaw infer image generate --prompt "cinematic product photo of headphones" --json
openclaw infer image describe --file ./photo.jpg --json
openclaw infer image describe --file ./ui-screenshot.png --model openai/gpt-4.1-mini --json
```
Notes:
- Use `image edit` when starting from existing input files.
- For `image describe`, `--model` must be `<provider/model>`.
## Audio
Use `audio` for file transcription.
```bash
openclaw infer audio transcribe --file ./memo.m4a --json
openclaw infer audio transcribe --file ./team-sync.m4a --language en --prompt "Focus on names and action items" --json
openclaw infer audio transcribe --file ./memo.m4a --model openai/whisper-1 --json
```
Notes:
- `audio transcribe` is for file transcription, not realtime session management.
- `--model` must be `<provider/model>`.
## TTS
Use `tts` for speech synthesis and TTS provider state.
```bash
openclaw infer tts convert --text "hello from openclaw" --output ./hello.mp3 --json
openclaw infer tts convert --text "Your build is complete" --output ./build-complete.mp3 --json
openclaw infer tts providers --json
openclaw infer tts status --json
```
Notes:
- `tts status` defaults to gateway because it reflects gateway-managed TTS state.
- Use `tts providers`, `tts voices`, and `tts set-provider` to inspect and configure TTS behavior.
## Video
Use `video` for generation and description.
```bash
openclaw infer video generate --prompt "cinematic sunset over the ocean" --json
openclaw infer video generate --prompt "slow drone shot over a forest lake" --json
openclaw infer video describe --file ./clip.mp4 --json
openclaw infer video describe --file ./clip.mp4 --model openai/gpt-4.1-mini --json
```
Notes:
- `--model` must be `<provider/model>` for `video describe`.
## Web
Use `web` for search and fetch workflows.
```bash
openclaw infer web search --query "OpenClaw docs" --json
openclaw infer web search --query "OpenClaw infer web providers" --json
openclaw infer web fetch --url https://docs.openclaw.ai/cli/infer --json
openclaw infer web providers --json
```
Notes:
- Use `web providers` to inspect available, configured, and selected providers.
## Embedding
Use `embedding` for vector creation and embedding provider inspection.
```bash
openclaw infer embedding create --text "friendly lobster" --json
openclaw infer embedding create --text "customer support ticket: delayed shipment" --model openai/text-embedding-3-large --json
openclaw infer embedding providers --json
```
## JSON output
Infer commands normalize JSON output under a shared envelope:
```json
{
"ok": true,
"capability": "image.generate",
"transport": "local",
"provider": "openai",
"model": "gpt-image-1",
"attempts": [],
"outputs": []
}
```
Top-level fields are stable:
- `ok`
- `capability`
- `transport`
- `provider`
- `model`
- `attempts`
- `outputs`
- `error`
## Common pitfalls
```bash
# Bad
openclaw infer media image generate --prompt "friendly lobster"
# Good
openclaw infer image generate --prompt "friendly lobster"
```
```bash
# Bad
openclaw infer audio transcribe --file ./memo.m4a --model whisper-1 --json
# Good
openclaw infer audio transcribe --file ./memo.m4a --model openai/whisper-1 --json
```
## Notes
- `openclaw capability ...` is an alias for `openclaw infer ...`.

View File

@@ -1,504 +0,0 @@
---
title: "Active Memory"
summary: "A plugin-owned blocking memory subagent that injects relevant memory into interactive chat sessions"
read_when:
- You want to understand what active memory is for
- You want to turn active memory on for a conversational agent
- You want to tune active memory behavior without enabling it everywhere
---
# Active Memory
Active memory is an optional plugin-owned blocking memory subagent that runs
before the main reply for eligible conversational sessions.
It exists because most memory systems are capable but reactive. They rely on
the main agent to decide when to search memory, or on the user to say things
like "remember this" or "search memory." By then, the moment where memory would
have made the reply feel natural has already passed.
Active memory gives the system one bounded chance to surface relevant memory
before the main reply is generated.
## Paste This Into Your Agent
Paste this into your agent if you want it to enable Active Memory with a
self-contained, safe-default setup:
```json5
{
plugins: {
entries: {
"active-memory": {
enabled: true,
config: {
agents: ["main"],
allowedChatTypes: ["direct"],
modelFallbackPolicy: "default-remote",
queryMode: "recent",
timeoutMs: 15000,
maxSummaryChars: 220,
persistTranscripts: false,
logging: true,
},
},
},
},
}
```
This turns the plugin on for the `main` agent, keeps it limited to direct-message
style sessions by default, lets it inherit the current session model first, and
still allows the built-in remote fallback if no explicit or inherited model is
available.
After that, restart the gateway:
```bash
node scripts/run-node.mjs gateway --profile dev
```
To inspect it live in a conversation:
```text
/verbose on
```
## Turn active memory on
The safest setup is:
1. enable the plugin
2. target one conversational agent
3. keep logging on only while tuning
Start with this in `openclaw.json`:
```json5
{
plugins: {
entries: {
"active-memory": {
enabled: true,
config: {
agents: ["main"],
allowedChatTypes: ["direct"],
modelFallbackPolicy: "default-remote",
queryMode: "recent",
timeoutMs: 15000,
maxSummaryChars: 220,
persistTranscripts: false,
logging: true,
},
},
},
},
}
```
Then restart the gateway:
```bash
node scripts/run-node.mjs gateway --profile dev
```
What this means:
- `plugins.entries.active-memory.enabled: true` turns the plugin on
- `config.agents: ["main"]` opts only the `main` agent into active memory
- `config.allowedChatTypes: ["direct"]` keeps active memory on for direct-message style sessions only by default
- if `config.model` is unset, active memory inherits the current session model first
- `config.modelFallbackPolicy: "default-remote"` keeps the built-in remote fallback as the default when no explicit or inherited model is available
- active memory still runs only on eligible interactive persistent chat sessions
## How to see it
Active memory injects hidden system context for the model. It does not expose
raw `<active_memory_plugin>...</active_memory_plugin>` tags to the client.
If you want to see what active memory is doing in a live session, turn verbose
mode on for that session:
```text
/verbose on
```
With verbose enabled, OpenClaw can show:
- an active memory status line such as `Active Memory: ok 842ms recent 34 chars`
- a readable debug summary such as `Active Memory Debug: Lemon pepper wings with blue cheese.`
Those lines are derived from the same active memory pass that feeds the hidden
system context, but they are formatted for humans instead of exposing raw prompt
markup.
By default, the blocking memory subagent transcript is temporary and deleted
after the run completes.
Example flow:
```text
/verbose on
what wings should i order?
```
Expected visible reply shape:
```text
...normal assistant reply...
🧩 Active Memory: ok 842ms recent 34 chars
🔎 Active Memory Debug: Lemon pepper wings with blue cheese.
```
## When it runs
Active memory uses two gates:
1. **Config opt-in**
The plugin must be enabled, and the current agent id must appear in
`plugins.entries.active-memory.config.agents`.
2. **Strict runtime eligibility**
Even when enabled and targeted, active memory only runs for eligible
interactive persistent chat sessions.
The actual rule is:
```text
plugin enabled
+
agent id targeted
+
allowed chat type
+
eligible interactive persistent chat session
=
active memory runs
```
If any of those fail, active memory does not run.
## Session types
`config.allowedChatTypes` controls which kinds of conversations may run Active
Memory at all.
The default is:
```json5
allowedChatTypes: ["direct"]
```
That means Active Memory runs by default in direct-message style sessions, but
not in group or channel sessions unless you opt them in explicitly.
Examples:
```json5
allowedChatTypes: ["direct"]
```
```json5
allowedChatTypes: ["direct", "group"]
```
```json5
allowedChatTypes: ["direct", "group", "channel"]
```
## Where it runs
Active memory is a conversational enrichment feature, not a platform-wide
inference feature.
| Surface | Runs active memory? |
| ------------------------------------------------------------------- | ------------------------------------------------------- |
| Control UI / web chat persistent sessions | Yes, if the plugin is enabled and the agent is targeted |
| Other interactive channel sessions on the same persistent chat path | Yes, if the plugin is enabled and the agent is targeted |
| Headless one-shot runs | No |
| Heartbeat/background runs | No |
| Generic internal `agent-command` paths | No |
| Subagent/internal helper execution | No |
## Why use it
Use active memory when:
- the session is persistent and user-facing
- the agent has meaningful long-term memory to search
- continuity and personalization matter more than raw prompt determinism
It works especially well for:
- stable preferences
- recurring habits
- long-term user context that should surface naturally
It is a poor fit for:
- automation
- internal workers
- one-shot API tasks
- places where hidden personalization would be surprising
## How it works
The runtime shape is:
```mermaid
flowchart LR
U["User Message"] --> Q["Build Memory Query"]
Q --> R["Active Memory Blocking Memory Subagent"]
R -->|NONE or empty| M["Main Reply"]
R -->|relevant summary| I["Append Hidden active_memory_plugin System Context"]
I --> M["Main Reply"]
```
The blocking memory subagent can use only:
- `memory_search`
- `memory_get`
If the connection is weak, it should return `NONE`.
## Query modes
`config.queryMode` controls how much conversation the blocking memory subagent sees.
## Model fallback policy
If `config.model` is unset, Active Memory tries to resolve a model in this order:
```text
explicit plugin model
-> current session model
-> agent primary model
-> optional built-in remote fallback
```
`config.modelFallbackPolicy` controls the last step.
Default:
```json5
modelFallbackPolicy: "default-remote"
```
Other option:
```json5
modelFallbackPolicy: "resolved-only"
```
Use `resolved-only` if you want Active Memory to skip recall instead of falling
back to the built-in remote default when no explicit or inherited model is
available.
### `message`
Only the latest user message is sent.
```text
Latest user message only
```
Use this when:
- you want the fastest behavior
- you want the strongest bias toward stable preference recall
- follow-up turns do not need conversational context
Recommended timeout:
- start around `3000` to `5000` ms
### `recent`
The latest user message plus a small recent conversational tail is sent.
```text
Recent conversation tail:
user: ...
assistant: ...
user: ...
Latest user message:
...
```
Use this when:
- you want a better balance of speed and conversational grounding
- follow-up questions often depend on the last few turns
Recommended timeout:
- start around `15000` ms
### `full`
The full conversation is sent to the blocking memory subagent.
```text
Full conversation context:
user: ...
assistant: ...
user: ...
...
```
Use this when:
- the strongest recall quality matters more than latency
- the conversation contains important setup far back in the thread
Recommended timeout:
- increase it substantially compared with `message` or `recent`
- start around `15000` ms or higher depending on thread size
In general, timeout should increase with context size:
```text
message < recent < full
```
## Transcript persistence
Active memory blocking memory subagent runs create a real `session.jsonl`
transcript during the blocking memory subagent call.
By default, that transcript is temporary:
- it is written to a temp directory
- it is used only for the blocking memory subagent run
- it is deleted immediately after the run finishes
If you want to keep those blocking memory subagent transcripts on disk for debugging or
inspection, turn persistence on explicitly:
```json5
{
plugins: {
entries: {
"active-memory": {
enabled: true,
config: {
agents: ["main"],
persistTranscripts: true,
transcriptDir: "active-memory",
},
},
},
},
}
```
When enabled, active memory stores transcripts in a separate directory under the
target agent's sessions folder, not in the main user conversation transcript
path.
The default layout is conceptually:
```text
agents/<agent>/sessions/active-memory/<blocking-memory-subagent-session-id>.jsonl
```
You can change the relative subdirectory with `config.transcriptDir`.
Use this carefully:
- blocking memory subagent transcripts can accumulate quickly on busy sessions
- `full` query mode can duplicate a lot of conversation context
- these transcripts contain hidden prompt context and recalled memories
## Configuration
All active memory configuration lives under:
```text
plugins.entries.active-memory
```
The most important fields are:
| Key | Type | Meaning |
| --------------------------- | --------------------------------- | ----------------------------------------------------------------------------------------------------- |
| `enabled` | `boolean` | Enables the plugin itself |
| `config.agents` | `string[]` | Agent ids that may use active memory |
| `config.model` | `string` | Optional blocking memory subagent model ref; when unset, active memory uses the current session model |
| `config.queryMode` | `"message" \| "recent" \| "full"` | Controls how much conversation the blocking memory subagent sees |
| `config.timeoutMs` | `number` | Hard timeout for the blocking memory subagent |
| `config.maxSummaryChars` | `number` | Maximum total characters allowed in the active-memory summary |
| `config.logging` | `boolean` | Emits active memory logs while tuning |
| `config.persistTranscripts` | `boolean` | Keeps blocking memory subagent transcripts on disk instead of deleting temp files |
| `config.transcriptDir` | `string` | Relative blocking memory subagent transcript directory under the agent sessions folder |
Useful tuning fields:
| Key | Type | Meaning |
| ----------------------------- | -------- | ------------------------------------------------------------- |
| `config.maxSummaryChars` | `number` | Maximum total characters allowed in the active-memory summary |
| `config.recentUserTurns` | `number` | Prior user turns to include when `queryMode` is `recent` |
| `config.recentAssistantTurns` | `number` | Prior assistant turns to include when `queryMode` is `recent` |
| `config.recentUserChars` | `number` | Max chars per recent user turn |
| `config.recentAssistantChars` | `number` | Max chars per recent assistant turn |
| `config.cacheTtlMs` | `number` | Cache reuse for repeated identical queries |
## Recommended setup
Start with `recent`.
```json5
{
plugins: {
entries: {
"active-memory": {
enabled: true,
config: {
agents: ["main"],
queryMode: "recent",
timeoutMs: 15000,
maxSummaryChars: 220,
logging: true,
},
},
},
},
}
```
If you want to inspect live behavior while tuning, use `/verbose on` in the
session instead of looking for a separate active-memory debug command.
Then move to:
- `message` if you want lower latency
- `full` if you decide extra context is worth the slower blocking memory subagent
## Debugging
If active memory is not showing up where you expect:
1. Confirm the plugin is enabled under `plugins.entries.active-memory.enabled`.
2. Confirm the current agent id is listed in `config.agents`.
3. Confirm you are testing through an interactive persistent chat session.
4. Turn on `config.logging: true` and watch the gateway logs.
5. Verify memory search itself works with `openclaw memory status --deep`.
If memory hits are noisy, tighten:
- `maxSummaryChars`
If active memory is too slow:
- lower `queryMode`
- lower `timeoutMs`
- reduce recent turn counts
- reduce per-turn char caps
## Related pages
- [Memory Search](/concepts/memory-search)
- [Memory configuration reference](/reference/memory-config)
- [Plugin SDK setup](/plugins/sdk-setup)

View File

@@ -41,71 +41,6 @@ Before compacting, OpenClaw automatically reminds the agent to save important
notes to [memory](/concepts/memory) files. This prevents context loss.
</Info>
Use the `agents.defaults.compaction` setting in your `openclaw.json` to configure compaction behavior (mode, target tokens, etc.).
Compaction summarization preserves opaque identifiers by default (`identifierPolicy: "strict"`). You can override this with `identifierPolicy: "off"` or provide custom text with `identifierPolicy: "custom"` and `identifierInstructions`.
You can optionally specify a different model for compaction summarization via `agents.defaults.compaction.model`. This is useful when your primary model is a local or small model and you want compaction summaries produced by a more capable model. The override accepts any `provider/model-id` string:
```json
{
"agents": {
"defaults": {
"compaction": {
"model": "openrouter/anthropic/claude-sonnet-4-6"
}
}
}
}
```
This also works with local models, for example a second Ollama model dedicated to summarization or a fine-tuned compaction specialist:
```json
{
"agents": {
"defaults": {
"compaction": {
"model": "ollama/llama3.1:8b"
}
}
}
}
```
When unset, compaction uses the agents primary model.
## Pluggable compaction providers
Plugins can register a custom compaction provider via `registerCompactionProvider()` on the plugin API. When a provider is registered and configured, OpenClaw delegates summarization to it instead of the built-in LLM pipeline.
To use a registered provider, set the provider id in your config:
```json
{
"agents": {
"defaults": {
"compaction": {
"provider": "my-provider"
}
}
}
}
```
Setting a `provider` automatically forces `mode: "safeguard"`. Providers receive the same compaction instructions and identifier-preservation policy as the built-in path, and OpenClaw still preserves recent-turn and split-turn suffix context after provider output. If the provider fails or returns an empty result, OpenClaw falls back to built-in LLM summarization.
## Auto-compaction (default on)
When a session nears or exceeds the models context window, OpenClaw triggers auto-compaction and may retry the original request using the compacted context.
Youll see:
- `🧹 Auto-compaction complete` in verbose mode
- `/status` showing `🧹 Compactions: <count>`
Before compaction, OpenClaw can run a **silent memory flush** turn to store
durable notes to disk. See [Memory](/concepts/memory) for details and config.
## Manual compaction
Type `/compact` in any chat to force a compaction. Add instructions to guide

View File

@@ -115,8 +115,6 @@ engine is used automatically.
A plugin can register a context engine using the plugin API:
```ts
import { buildMemorySystemPromptAddition } from "openclaw/plugin-sdk/core";
export default function register(api) {
api.registerContextEngine("my-engine", () => ({
info: {
@@ -130,15 +128,12 @@ export default function register(api) {
return { ingested: true };
},
async assemble({ sessionId, messages, tokenBudget, availableTools, citationsMode }) {
async assemble({ sessionId, messages, tokenBudget }) {
// Return messages that fit the budget
return {
messages: buildContext(messages, tokenBudget),
estimatedTokens: countTokens(messages),
systemPromptAddition: buildMemorySystemPromptAddition({
availableTools: availableTools ?? new Set(),
citationsMode,
}),
systemPromptAddition: "Use lcm_grep to search history...",
};
},
@@ -253,13 +248,7 @@ OpenClaw resolves when it needs a context engine.
- **Memory plugins** (`plugins.slots.memory`) are separate from context engines.
Memory plugins provide search/retrieval; context engines control what the
model sees. They can work together — a context engine might use memory
plugin data during assembly. Plugin engines that want the active memory
prompt path should prefer `buildMemorySystemPromptAddition(...)` from
`openclaw/plugin-sdk/core`, which converts the active memory prompt sections
into a ready-to-prepend `systemPromptAddition`. If an engine needs lower-level
control, it can still pull raw lines from
`openclaw/plugin-sdk/memory-host-core` via
`buildActiveMemoryPromptSection(...)`.
plugin data during assembly.
- **Session pruning** (trimming old tool results in-memory) still runs
regardless of which context engine is active.

View File

@@ -138,6 +138,5 @@ earlier conversations. This is opt-in via
## Further reading
- [Active Memory](/concepts/active-memory) -- sidecar memory for interactive chat sessions
- [Memory](/concepts/memory) -- file layout, backends, tools
- [Memory configuration reference](/reference/memory-config) -- all config knobs

View File

@@ -360,7 +360,7 @@ OpenClaw ships with the piai catalog. These providers require **no**
- or `npm install -g @google/gemini-cli`
- Enable: `openclaw plugins enable google`
- Login: `openclaw models auth login --provider google-gemini-cli --set-default`
- Default model: `google-gemini-cli/gemini-3-flash-preview`
- Default model: `google-gemini-cli/gemini-3.1-pro-preview`
- Note: you do **not** paste a client id or secret into `openclaw.json`. The CLI login flow stores
tokens in auth profiles on the gateway host.
- If requests fail after login, set `GOOGLE_CLOUD_PROJECT` or `GOOGLE_CLOUD_PROJECT_ID` on the gateway host.

View File

@@ -37,21 +37,6 @@ QA Lab page where an operator or automation loop can give the agent a QA
mission, observe real channel behavior, and record what worked, failed, or
stayed blocked.
For faster QA Lab UI iteration without rebuilding the Docker image each time,
start the stack with a bind-mounted QA Lab bundle:
```bash
pnpm openclaw qa docker-build-image
pnpm qa:lab:build
pnpm qa:lab:up:fast
pnpm qa:lab:watch
```
`qa:lab:up:fast` keeps the Docker services on a prebuilt image and bind-mounts
`extensions/qa-lab/web/dist` into the `qa-lab` container. `qa:lab:watch`
rebuilds that bundle on change, and the browser auto-reloads when the QA Lab
asset hash changes.
## Repo-backed seeds
Seed assets live in `qa/`:

View File

@@ -76,10 +76,6 @@
"source": "/plugins/agent-tools",
"destination": "/plugins/building-plugins#registering-agent-tools"
},
{
"source": "/cli/capability",
"destination": "/cli/infer"
},
{
"source": "/tools/capability-cookbook",
"destination": "/plugins/architecture"

View File

@@ -214,10 +214,8 @@ The bundled OpenAI plugin also registers a default for `codex-cli`:
The bundled Google plugin also registers a default for `google-gemini-cli`:
- `command: "gemini"`
- `args: ["--output-format", "json", "--prompt", "{prompt}"]`
- `resumeArgs: ["--resume", "{sessionId}", "--output-format", "json", "--prompt", "{prompt}"]`
- `imageArg: "@"`
- `imagePathScope: "workspace"`
- `args: ["--prompt", "--output-format", "json"]`
- `resumeArgs: ["--resume", "{sessionId}", "--prompt", "--output-format", "json"]`
- `modelArg: "--model"`
- `sessionMode: "existing"`
- `sessionIdFields: ["session_id", "sessionId"]`
@@ -253,9 +251,8 @@ opt into a generated MCP config overlay with `bundleMcp: true`.
Current bundled behavior:
- `claude-cli`: generated strict MCP config file
- `codex-cli`: inline config overrides for `mcp_servers`
- `google-gemini-cli`: generated Gemini system settings file
- `codex-cli`: no bundle MCP overlay
- `google-gemini-cli`: no bundle MCP overlay
When bundle MCP is enabled, OpenClaw:
@@ -263,8 +260,8 @@ When bundle MCP is enabled, OpenClaw:
- authenticates the bridge with a per-session token (`OPENCLAW_MCP_TOKEN`)
- scopes tool access to the current session, account, and channel context
- loads enabled bundle-MCP servers for the current workspace
- merges them with any existing backend MCP config/settings shape
- rewrites the launch config using the backend-owned integration mode from the owning extension
- merges them with any existing backend `--mcp-config`
- rewrites the CLI args to pass `--strict-mcp-config --mcp-config <generated-file>`
If no MCP servers are enabled, OpenClaw still injects a strict config when a
backend opts into bundle MCP so background runs stay isolated.

View File

@@ -1160,7 +1160,6 @@ Periodic heartbeat runs.
defaults: {
compaction: {
mode: "safeguard", // default | safeguard
provider: "my-provider", // id of a registered compaction provider plugin (optional)
timeoutSeconds: 900,
reserveTokensFloor: 24000,
identifierPolicy: "strict", // strict | off | custom
@@ -1181,7 +1180,6 @@ Periodic heartbeat runs.
```
- `mode`: `default` or `safeguard` (chunked summarization for long histories). See [Compaction](/concepts/compaction).
- `provider`: id of a registered compaction provider plugin. When set, the provider's `summarize()` is called instead of built-in LLM summarization. Falls back to built-in on failure. Setting a provider forces `mode: "safeguard"`. See [Compaction](/concepts/compaction).
- `timeoutSeconds`: maximum seconds allowed for a single compaction operation before OpenClaw aborts it. Default: `900`.
- `identifierPolicy`: `strict` (default), `off`, or `custom`. `strict` prepends built-in opaque identifier retention guidance during compaction summarization.
- `identifierInstructions`: optional custom identifier-preservation text used when `identifierPolicy=custom`.
@@ -2351,7 +2349,6 @@ OpenClaw uses the built-in model catalog. Add custom providers via `models.provi
- `models.providers.*.models.*.contextWindow`: native model context window metadata.
- `models.providers.*.models.*.contextTokens`: optional runtime context cap. Use this when you want a smaller effective context budget than the model's native `contextWindow`.
- `models.providers.*.models.*.compat.supportsDeveloperRole`: optional compatibility hint. For `api: "openai-completions"` with a non-empty non-native `baseUrl` (host not `api.openai.com`), OpenClaw forces this to `false` at runtime. Empty/omitted `baseUrl` keeps default OpenAI behavior.
- `models.providers.*.models.*.compat.requiresStringContent`: optional compatibility hint for string-only OpenAI-compatible chat endpoints. When `true`, OpenClaw flattens pure text `messages[].content` arrays into plain strings before sending the request.
- `plugins.entries.amazon-bedrock.config.discovery`: Bedrock auto-discovery settings root.
- `plugins.entries.amazon-bedrock.config.discovery.enabled`: turn implicit discovery on/off.
- `plugins.entries.amazon-bedrock.config.discovery.region`: AWS region for discovery.

View File

@@ -155,30 +155,9 @@ Behavior note for local/proxied `/v1` backends:
- hidden OpenClaw attribution headers (`originator`, `version`, `User-Agent`)
are not injected on these custom proxy URLs
Compatibility notes for stricter OpenAI-compatible backends:
- Some servers accept only string `messages[].content` on Chat Completions, not
structured content-part arrays. Set
`models.providers.<provider>.models[].compat.requiresStringContent: true` for
those endpoints.
- Some smaller or stricter local backends are unstable with OpenClaw's full
agent-runtime prompt shape, especially when tool schemas are included. If the
backend works for tiny direct `/v1/chat/completions` calls but fails on normal
OpenClaw agent turns, try
`models.providers.<provider>.models[].compat.supportsTools: false` first.
- If the backend still fails only on larger OpenClaw runs, the remaining issue
is usually upstream model/server capacity or a backend bug, not OpenClaw's
transport layer.
## Troubleshooting
- Gateway can reach the proxy? `curl http://127.0.0.1:1234/v1/models`.
- LM Studio model unloaded? Reload; cold start is a common “hanging” cause.
- Context errors? Lower `contextWindow` or raise your server limit.
- OpenAI-compatible server returns `messages[].content ... expected a string`?
Add `compat.requiresStringContent: true` on that model entry.
- Direct tiny `/v1/chat/completions` calls work, but `openclaw infer model run`
fails on Gemma or another local model? Disable tool schemas first with
`compat.supportsTools: false`, then retest. If the server still crashes only
on larger OpenClaw prompts, treat it as an upstream server/model limitation.
- Safety: local models skip provider-side filters; keep agents narrow and compaction on to limit prompt injection blast radius.

View File

@@ -381,18 +381,16 @@ implemented in `src/gateway/server-methods/*.ts`.
#### Approval families
- `exec.approval.request`, `exec.approval.get`, `exec.approval.list`, and
`exec.approval.resolve` cover one-shot exec approval requests plus pending
approval lookup/replay.
- `exec.approval.request` and `exec.approval.resolve` cover one-shot exec
approval requests.
- `exec.approval.waitDecision` waits on one pending exec approval and returns
the final decision (or `null` on timeout).
- `exec.approvals.get` and `exec.approvals.set` manage gateway exec approval
policy snapshots.
- `exec.approvals.node.get` and `exec.approvals.node.set` manage node-local exec
approval policy via node relay commands.
- `plugin.approval.request`, `plugin.approval.list`,
`plugin.approval.waitDecision`, and `plugin.approval.resolve` cover
plugin-defined approval flows.
- `plugin.approval.request`, `plugin.approval.waitDecision`, and
`plugin.approval.resolve` cover plugin-defined approval flows.
#### Other major families

View File

@@ -59,61 +59,6 @@ Related:
- [/reference/token-use](/reference/token-use)
- [/help/faq#why-am-i-seeing-http-429-ratelimiterror-from-anthropic](/help/faq#why-am-i-seeing-http-429-ratelimiterror-from-anthropic)
## Local OpenAI-compatible backend passes direct probes but agent runs fail
Use this when:
- `curl ... /v1/models` works
- tiny direct `/v1/chat/completions` calls work
- OpenClaw model runs fail only on normal agent turns
```bash
curl http://127.0.0.1:1234/v1/models
curl http://127.0.0.1:1234/v1/chat/completions \
-H 'content-type: application/json' \
-d '{"model":"<id>","messages":[{"role":"user","content":"hi"}],"stream":false}'
openclaw infer model run --model <provider/model> --prompt "hi" --json
openclaw logs --follow
```
Look for:
- direct tiny calls succeed, but OpenClaw runs fail only on larger prompts
- backend errors about `messages[].content` expecting a string
- backend crashes that appear only with larger prompt-token counts or full agent
runtime prompts
Common signatures:
- `messages[...].content: invalid type: sequence, expected a string` → backend
rejects structured Chat Completions content parts. Fix: set
`models.providers.<provider>.models[].compat.requiresStringContent: true`.
- direct tiny requests succeed, but OpenClaw agent runs fail with backend/model
crashes (for example Gemma on some `inferrs` builds) → OpenClaw transport is
likely already correct; the backend is failing on the larger agent-runtime
prompt shape.
- failures shrink after disabling tools but do not disappear → tool schemas were
part of the pressure, but the remaining issue is still upstream model/server
capacity or a backend bug.
Fix options:
1. Set `compat.requiresStringContent: true` for string-only Chat Completions backends.
2. Set `compat.supportsTools: false` for models/backends that cannot handle
OpenClaw's tool schema surface reliably.
3. Lower prompt pressure where possible: smaller workspace bootstrap, shorter
session history, lighter local model, or a backend with stronger long-context
support.
4. If tiny direct requests keep passing while OpenClaw agent turns still crash
inside the backend, treat it as an upstream server/model limitation and file
a repro there with the accepted payload shape.
Related:
- [/gateway/local-models](/gateway/local-models)
- [/gateway/configuration#models](/gateway/configuration#models)
- [/gateway/configuration-reference#openai-compatible-endpoints](/gateway/configuration-reference#openai-compatible-endpoints)
## No replies
If channels are up but nothing answers, check routing and policy before reconnecting anything.

View File

@@ -701,7 +701,7 @@ for usage/billing and raise limits as needed.
- npm: `npm install -g @google/gemini-cli`
2. Enable the plugin: `openclaw plugins enable google`
3. Login: `openclaw models auth login --provider google-gemini-cli --set-default`
4. Default model after login: `google-gemini-cli/gemini-3-flash-preview`
4. Default model after login: `google-gemini-cli/gemini-3.1-pro-preview`
5. If requests fail, set `GOOGLE_CLOUD_PROJECT` or `GOOGLE_CLOUD_PROJECT_ID` on the gateway host
This stores OAuth tokens in auth profiles on the gateway host. Details: [Model providers](/concepts/model-providers).
@@ -1816,8 +1816,8 @@ for usage/billing and raise limits as needed.
- `config.schema.lookup`: inspect one config subtree with its shallow schema node, matched UI hint, and immediate child summaries before writing
- `config.get`: fetch the current snapshot + hash
- `config.patch`: safe partial update (preferred for most RPC edits); hot-reloads when possible and restarts when required
- `config.apply`: validate + replace the full config; hot-reloads when possible and restarts when required
- `config.patch`: safe partial update (preferred for most RPC edits)
- `config.apply`: validate + replace the full config, then restart
- The owner-only `gateway` runtime tool still refuses to rewrite `tools.exec.ask` / `tools.exec.security`; legacy `tools.bash.*` aliases normalize to the same protected exec paths
</Accordion>
@@ -2254,7 +2254,7 @@ for usage/billing and raise limits as needed.
Quickest setup:
1. Install Ollama from `https://ollama.com/download`
2. Pull a local model such as `ollama pull gemma4`
2. Pull a local model such as `ollama pull glm-4.7-flash`
3. If you want cloud models too, run `ollama signin`
4. Run `openclaw onboard` and choose `Ollama`
5. Pick `Local` or `Cloud + Local`

View File

@@ -47,7 +47,7 @@ Think of the suites as “increasing realism” (and increasing flakiness/cost):
### Unit / integration (default)
- Command: `pnpm test`
- Config: ten sequential shard runs (`vitest.full-*.config.ts`) over the existing scoped Vitest projects
- Config: five sequential shard runs (`vitest.full-*.config.ts`) over the existing scoped Vitest projects
- Files: core/unit inventories under `src/**/*.test.ts`, `packages/**/*.test.ts`, `test/**/*.test.ts`, and the whitelisted `ui` node tests covered by `vitest.unit.config.ts`
- Scope:
- Pure unit tests
@@ -58,7 +58,7 @@ Think of the suites as “increasing realism” (and increasing flakiness/cost):
- No real keys required
- Should be fast and stable
- Projects note:
- Untargeted `pnpm test` now runs eleven smaller shard configs (`core-unit-src`, `core-unit-security`, `core-unit-ui`, `core-unit-support`, `core-support-boundary`, `core-contracts`, `core-bundled`, `core-runtime`, `agentic`, `auto-reply`, `extensions`) instead of one giant native root-project process. This cuts peak RSS on loaded machines and avoids auto-reply/extension work starving unrelated suites.
- Untargeted `pnpm test` now runs eight smaller shard configs (`core-unit-src`, `core-unit-security`, `core-unit-support`, `core-contracts`, `core-runtime`, `agentic`, `auto-reply`, `extensions`) instead of one giant native root-project process. This cuts peak RSS on loaded machines and avoids auto-reply/extension work starving unrelated suites.
- `pnpm test --watch` still uses the native root `vitest.config.ts` project graph, because a multi-shard watch loop is not practical.
- `pnpm test`, `pnpm test:watch`, and `pnpm test:perf:imports` route explicit file/directory targets through scoped lanes first, so `pnpm test extensions/discord/src/monitor/message-handler.preflight.test.ts` avoids paying the full root project startup tax.
- `pnpm test:changed` expands changed git paths into the same scoped lanes when the diff only touches routable source/test files; config/setup edits still fall back to the broad root-project rerun.
@@ -253,17 +253,17 @@ openclaw models list
openclaw models list --json
```
## Live: CLI backend smoke (Claude, Codex, Gemini, or other local CLIs)
## Live: CLI backend smoke (Codex CLI or other local CLIs)
- Test: `src/gateway/gateway-cli-backend.live.test.ts`
- Goal: validate the Gateway + agent pipeline using a local CLI backend, without touching your default config.
- Backend-specific smoke defaults live with the owning extension's `cli-backend.ts` definition.
- Enable:
- `pnpm test:live` (or `OPENCLAW_LIVE_TEST=1` if invoking Vitest directly)
- `OPENCLAW_LIVE_CLI_BACKEND=1`
- Defaults:
- Default provider/model: `claude-cli/claude-sonnet-4-6`
- Command/args/image behavior come from the owning CLI backend plugin metadata.
- Model: `codex-cli/gpt-5.4`
- Command: `codex`
- Args: `["exec","--json","--color","never","--sandbox","read-only","--skip-git-repo-check"]`
- Overrides (optional):
- `OPENCLAW_LIVE_CLI_BACKEND_MODEL="codex-cli/gpt-5.4"`
- `OPENCLAW_LIVE_CLI_BACKEND_COMMAND="/full/path/to/codex"`
@@ -272,7 +272,6 @@ openclaw models list --json
- `OPENCLAW_LIVE_CLI_BACKEND_IMAGE_ARG="--image"` to pass image file paths as CLI args instead of prompt injection.
- `OPENCLAW_LIVE_CLI_BACKEND_IMAGE_MODE="repeat"` (or `"list"`) to control how image args are passed when `IMAGE_ARG` is set.
- `OPENCLAW_LIVE_CLI_BACKEND_RESUME_PROBE=1` to send a second turn and validate resume flow.
- `OPENCLAW_LIVE_CLI_BACKEND_MODEL_SWITCH_PROBE=0` to disable the default Claude Sonnet -> Opus same-session continuity probe (set to `1` to force it on when the selected model supports a switch target).
Example:
@@ -288,21 +287,11 @@ Docker recipe:
pnpm test:docker:live-cli-backend
```
Single-provider Docker recipes:
```bash
pnpm test:docker:live-cli-backend:claude
pnpm test:docker:live-cli-backend:codex
pnpm test:docker:live-cli-backend:gemini
```
Notes:
- The Docker runner lives at `scripts/test-live-cli-backend-docker.sh`.
- It runs the live CLI-backend smoke inside the repo Docker image as the non-root `node` user.
- It resolves CLI smoke metadata from the owning extension, then installs the matching Linux CLI package (`@anthropic-ai/claude-code`, `@openai/codex`, or `@google/gemini-cli`) into a cached writable prefix at `OPENCLAW_DOCKER_CLI_TOOLS_DIR` (default: `~/.cache/openclaw/docker-cli-tools`).
- The live CLI-backend smoke now exercises the same end-to-end flow for Claude, Codex, and Gemini: text turn, image classification turn, then MCP `cron` tool call verified through the gateway CLI.
- Claude's default smoke also patches the session from Sonnet to Opus and verifies the resumed session still remembers an earlier note.
- For `codex-cli`, it installs the Linux `@openai/codex` package into a cached writable prefix at `OPENCLAW_DOCKER_CLI_TOOLS_DIR` (default: `~/.cache/openclaw/docker-cli-tools`).
## Live: ACP bind smoke (`/acp spawn ... --bind here`)
@@ -316,15 +305,12 @@ Notes:
- `pnpm test:live src/gateway/gateway-acp-bind.live.test.ts`
- `OPENCLAW_LIVE_ACP_BIND=1`
- Defaults:
- ACP agents in Docker: `claude,codex,gemini`
- ACP agent for direct `pnpm test:live ...`: `claude`
- ACP agent: `claude`
- Synthetic channel: Slack DM-style conversation context
- ACP backend: `acpx`
- Overrides:
- `OPENCLAW_LIVE_ACP_BIND_AGENT=claude`
- `OPENCLAW_LIVE_ACP_BIND_AGENT=codex`
- `OPENCLAW_LIVE_ACP_BIND_AGENT=gemini`
- `OPENCLAW_LIVE_ACP_BIND_AGENTS=claude,codex,gemini`
- `OPENCLAW_LIVE_ACP_BIND_AGENT_COMMAND='npx -y @agentclientprotocol/claude-agent-acp@<version>'`
- Notes:
- This lane uses the gateway `chat.send` surface with admin-only synthetic originating-route fields so tests can attach message-channel context without pretending to deliver externally.
@@ -344,20 +330,10 @@ Docker recipe:
pnpm test:docker:live-acp-bind
```
Single-agent Docker recipes:
```bash
pnpm test:docker:live-acp-bind:claude
pnpm test:docker:live-acp-bind:codex
pnpm test:docker:live-acp-bind:gemini
```
Docker notes:
- The Docker runner lives at `scripts/test-live-acp-bind-docker.sh`.
- By default, it runs the ACP bind smoke against all supported live CLI agents in sequence: `claude`, `codex`, then `gemini`.
- Use `OPENCLAW_LIVE_ACP_BIND_AGENTS=claude`, `OPENCLAW_LIVE_ACP_BIND_AGENTS=codex`, or `OPENCLAW_LIVE_ACP_BIND_AGENTS=gemini` to narrow the matrix.
- It sources `~/.profile`, stages the matching CLI auth material into the container, installs `acpx` into a writable npm prefix, then installs the requested live CLI (`@anthropic-ai/claude-code`, `@openai/codex`, or `@google/gemini-cli`) if missing.
- It sources `~/.profile`, stages the matching CLI auth material into the container, installs `acpx` into a writable npm prefix, then installs the requested live CLI (`@anthropic-ai/claude-code` or `@openai/codex`) if missing.
- Inside Docker, the runner sets `OPENCLAW_LIVE_ACP_BIND_ACPX_COMMAND=$HOME/.npm-global/bin/acpx` so acpx keeps provider env vars from the sourced profile available to the child harness CLI.
### Recommended live recipes
@@ -450,7 +426,7 @@ Live tests discover credentials the same way the CLI does. Practical implication
- Per-agent auth profiles: `~/.openclaw/agents/<agentId>/agent/auth-profiles.json` (this is what “profile keys” means in the live tests)
- Config: `~/.openclaw/openclaw.json` (or `OPENCLAW_CONFIG_PATH`)
- Legacy state dir: `~/.openclaw/credentials/` (copied into the staged live home when present, but not the main profile-key store)
- Live local runs copy the active config, per-agent `auth-profiles.json` files, legacy `credentials/`, and supported external CLI auth dirs into a temp test home by default; staged live homes skip `workspace/` and `sandboxes/`, and `agents.*.workspace` / `agentDir` path overrides are stripped so probes stay off your real host workspace.
- Live local runs copy the active config, per-agent `auth-profiles.json` files, legacy `credentials/`, and supported external CLI auth dirs into a temp test home by default; `agents.*.workspace` / `agentDir` path overrides are stripped in that staged config so probes stay off your real host workspace.
If you want to rely on env keys (e.g. exported in your `~/.profile`), run local tests after `source ~/.profile`, or use the Docker runners below (they can mount `~/.profile` into the container).

View File

@@ -42,21 +42,6 @@ If you see:
`HTTP 429: rate_limit_error: Extra usage is required for long context requests`,
go to [/gateway/troubleshooting#anthropic-429-extra-usage-required-for-long-context](/gateway/troubleshooting#anthropic-429-extra-usage-required-for-long-context).
## Local OpenAI-compatible backend works directly but fails in OpenClaw
If your local or self-hosted `/v1` backend answers small direct
`/v1/chat/completions` probes but fails on `openclaw infer model run` or normal
agent turns:
1. If the error mentions `messages[].content` expecting a string, set
`models.providers.<provider>.models[].compat.requiresStringContent: true`.
2. If the backend still fails only on OpenClaw agent turns, set
`models.providers.<provider>.models[].compat.supportsTools: false` and retry.
3. If tiny direct calls still work but larger OpenClaw prompts crash the
backend, treat the remaining issue as an upstream model/server limitation and
continue in the deep runbook:
[/gateway/troubleshooting#local-openai-compatible-backend-passes-direct-probes-but-agent-runs-fail](/gateway/troubleshooting#local-openai-compatible-backend-passes-direct-probes-but-agent-runs-fail)
## Plugin install fails with missing openclaw extensions
If install fails with `package.json missing openclaw.extensions`, the plugin package

View File

@@ -1120,8 +1120,7 @@ authoring plugins:
`openclaw/plugin-sdk/secret-input`, and
`openclaw/plugin-sdk/webhook-ingress` for shared setup/auth/reply/webhook
wiring. `channel-inbound` is the shared home for debounce, mention matching,
inbound mention-policy helpers, envelope formatting, and inbound envelope
context helpers.
envelope formatting, and inbound envelope context helpers.
`channel-setup` is the narrow optional-install setup seam.
`setup-runtime` is the runtime-safe setup surface used by `setupEntry` /
deferred startup, including the import-safe setup patch adapters.
@@ -1134,9 +1133,6 @@ authoring plugins:
`openclaw/plugin-sdk/channel-config-schema`,
`openclaw/plugin-sdk/telegram-command-config`,
`openclaw/plugin-sdk/channel-policy`,
`openclaw/plugin-sdk/approval-gateway-runtime`,
`openclaw/plugin-sdk/approval-handler-adapter-runtime`,
`openclaw/plugin-sdk/approval-handler-runtime`,
`openclaw/plugin-sdk/approval-runtime`,
`openclaw/plugin-sdk/config-runtime`,
`openclaw/plugin-sdk/infra-runtime`,
@@ -1155,9 +1151,9 @@ authoring plugins:
assistant-visible-text stripping, markdown render/chunking helpers, redaction
helpers, directive-tag helpers, and safe-text utilities.
- Approval-specific channel seams should prefer one `approvalCapability`
contract on the plugin. Core then reads approval auth, delivery, render,
native-routing, and lazy native-handler behavior through that one capability
instead of mixing approval behavior into unrelated plugin fields.
contract on the plugin. Core then reads approval auth, delivery, render, and
native-routing behavior through that one capability instead of mixing
approval behavior into unrelated plugin fields.
- `openclaw/plugin-sdk/channel-runtime` is deprecated and remains only as a
compatibility shim for older plugins. New code should import the narrower
generic primitives instead, and repo code should not add new imports of the
@@ -1497,23 +1493,14 @@ Use this when your plugin needs to replace or extend the default context
pipeline rather than just add memory search or hooks.
```ts
import { buildMemorySystemPromptAddition } from "openclaw/plugin-sdk/core";
export default function (api) {
api.registerContextEngine("lossless-claw", () => ({
info: { id: "lossless-claw", name: "Lossless Claw", ownsCompaction: true },
async ingest() {
return { ingested: true };
},
async assemble({ messages, availableTools, citationsMode }) {
return {
messages,
estimatedTokens: 0,
systemPromptAddition: buildMemorySystemPromptAddition({
availableTools: availableTools ?? new Set(),
citationsMode,
}),
};
async assemble({ messages }) {
return { messages, estimatedTokens: 0 };
},
async compact() {
return { ok: true, compacted: false };
@@ -1526,10 +1513,7 @@ If your engine does **not** own the compaction algorithm, keep `compact()`
implemented and delegate it explicitly:
```ts
import {
buildMemorySystemPromptAddition,
delegateCompactionToRuntime,
} from "openclaw/plugin-sdk/core";
import { delegateCompactionToRuntime } from "openclaw/plugin-sdk/core";
export default function (api) {
api.registerContextEngine("my-memory-engine", () => ({
@@ -1541,15 +1525,8 @@ export default function (api) {
async ingest() {
return { ingested: true };
},
async assemble({ messages, availableTools, citationsMode }) {
return {
messages,
estimatedTokens: 0,
systemPromptAddition: buildMemorySystemPromptAddition({
availableTools: availableTools ?? new Set(),
citationsMode,
}),
};
async assemble({ messages }) {
return { messages, estimatedTokens: 0 };
},
async compact(params) {
return await delegateCompactionToRuntime(params);

View File

@@ -60,34 +60,22 @@ Most channel plugins do not need approval-specific code.
- Core owns same-chat `/approve`, shared approval button payloads, and generic fallback delivery.
- Prefer one `approvalCapability` object on the channel plugin when the channel needs approval-specific behavior.
- `ChannelPlugin.approvals` is removed. Put approval delivery/native/render/auth facts on `approvalCapability`.
- `plugin.auth` is login/logout only; core no longer reads approval auth hooks from that object.
- `approvalCapability.authorizeActorAction` and `approvalCapability.getActionAvailabilityState` are the canonical approval-auth seam.
- Use `approvalCapability.getActionAvailabilityState` for same-chat approval auth availability.
- If your channel exposes native exec approvals, use `approvalCapability.getExecInitiatingSurfaceState` for the initiating-surface/native-client state when it differs from same-chat approval auth. Core uses that exec-specific hook to distinguish `enabled` vs `disabled`, decide whether the initiating channel supports native exec approvals, and include the channel in native-client fallback guidance. `createApproverRestrictedNativeApprovalCapability(...)` fills this in for the common case.
- If your channel exposes native exec approvals, implement `approvalCapability.getActionAvailabilityState` even when the native transport lives entirely under `approvalCapability.native`. Core uses that availability hook to distinguish `enabled` vs `disabled`, decide whether the initiating channel supports native approvals, and include the channel in native-client fallback guidance.
- Use `outbound.shouldSuppressLocalPayloadPrompt` or `outbound.beforeDeliverPayload` for channel-specific payload lifecycle behavior such as hiding duplicate local approval prompts or sending typing indicators before delivery.
- Use `approvalCapability.delivery` only for native approval routing or fallback suppression.
- Use `approvalCapability.nativeRuntime` for channel-owned native approval facts. Keep it lazy on hot channel entrypoints with `createLazyChannelApprovalNativeRuntimeAdapter(...)`, which can import your runtime module on demand while still letting core assemble the approval lifecycle.
- Use `approvalCapability.render` only when a channel truly needs custom approval payloads instead of the shared renderer.
- Use `approvalCapability.describeExecApprovalSetup` when the channel wants the disabled-path reply to explain the exact config knobs needed to enable native exec approvals. The hook receives `{ channel, channelLabel, accountId }`; named-account channels should render account-scoped paths such as `channels.<channel>.accounts.<id>.execApprovals.*` instead of top-level defaults.
- If a channel can infer stable owner-like DM identities from existing config, use `createResolvedApproverActionAuthAdapter` from `openclaw/plugin-sdk/approval-runtime` to restrict same-chat `/approve` without adding approval-specific core logic.
- If a channel needs native approval delivery, keep channel code focused on target normalization plus transport/presentation facts. Use `createChannelExecApprovalProfile`, `createChannelNativeOriginTargetResolver`, `createChannelApproverDmTargetResolver`, and `createApproverRestrictedNativeApprovalCapability` from `openclaw/plugin-sdk/approval-runtime`. Put the channel-specific facts behind `approvalCapability.nativeRuntime`, ideally via `createChannelApprovalNativeRuntimeAdapter(...)` or `createLazyChannelApprovalNativeRuntimeAdapter(...)`, so core can assemble the handler and own request filtering, routing, dedupe, expiry, gateway subscription, and routed-elsewhere notices. `nativeRuntime` is split into a few smaller seams:
- `availability` — whether the account is configured and whether a request should be handled
- `presentation` — map the shared approval view model into pending/resolved/expired native payloads or final actions
- `transport` — prepare targets plus send/update/delete native approval messages
- `interactions` — optional bind/unbind/clear-action hooks for native buttons or reactions
- `observe` — optional delivery diagnostics hooks
- If the channel needs runtime-owned objects such as a client, token, Bolt app, or webhook receiver, register them through `openclaw/plugin-sdk/channel-runtime-context`. The generic runtime-context registry lets core bootstrap capability-driven handlers from channel startup state without adding approval-specific wrapper glue.
- Reach for the lower-level `createChannelApprovalHandler` or `createChannelNativeApprovalRuntime` only when the capability-driven seam is not expressive enough yet.
- If a channel needs native approval delivery, keep channel code focused on target normalization and transport hooks. Use `createChannelExecApprovalProfile`, `createChannelNativeOriginTargetResolver`, `createChannelApproverDmTargetResolver`, `createApproverRestrictedNativeApprovalCapability`, and `createChannelNativeApprovalRuntime` from `openclaw/plugin-sdk/approval-runtime` so core owns request filtering, routing, dedupe, expiry, and gateway subscription.
- Native approval channels must route both `accountId` and `approvalKind` through those helpers. `accountId` keeps multi-account approval policy scoped to the right bot account, and `approvalKind` keeps exec vs plugin approval behavior available to the channel without hardcoded branches in core.
- Core now owns approval reroute notices too. Channel plugins should not send their own "approval went to DMs / another channel" follow-up messages from `createChannelNativeApprovalRuntime`; instead, expose accurate origin + approver-DM routing through the shared approval capability helpers and let core aggregate actual deliveries before posting any notice back to the initiating chat.
- Preserve the delivered approval id kind end-to-end. Native clients should not
guess or rewrite exec vs plugin approval routing from channel-local state.
- Different approval kinds can intentionally expose different native surfaces.
Current bundled examples:
- Slack keeps native approval routing available for both exec and plugin ids.
- Matrix keeps the same native DM/channel routing and reaction UX for exec
and plugin approvals, while still letting auth differ by approval kind.
- Matrix keeps native DM/channel routing for exec approvals only and leaves
plugin approvals on the shared same-chat `/approve` path.
- `createApproverRestrictedNativeApprovalAdapter` still exists as a compatibility wrapper, but new code should prefer the capability builder and expose `approvalCapability` on the plugin.
For hot channel entrypoints, prefer the narrower runtime subpaths when you only
@@ -96,12 +84,8 @@ need one part of that family:
- `openclaw/plugin-sdk/approval-auth-runtime`
- `openclaw/plugin-sdk/approval-client-runtime`
- `openclaw/plugin-sdk/approval-delivery-runtime`
- `openclaw/plugin-sdk/approval-gateway-runtime`
- `openclaw/plugin-sdk/approval-handler-adapter-runtime`
- `openclaw/plugin-sdk/approval-handler-runtime`
- `openclaw/plugin-sdk/approval-native-runtime`
- `openclaw/plugin-sdk/approval-reply-runtime`
- `openclaw/plugin-sdk/channel-runtime-context`
Likewise, prefer `openclaw/plugin-sdk/setup-runtime`,
`openclaw/plugin-sdk/setup-adapter-runtime`,
@@ -168,87 +152,6 @@ surfaces:
Auth-only channels can usually stop at the default path: core handles approvals and the plugin just exposes outbound/auth capabilities. Native approval channels such as Matrix, Slack, Telegram, and custom chat transports should use the shared native helpers instead of rolling their own approval lifecycle.
## Inbound mention policy
Keep inbound mention handling split in two layers:
- plugin-owned evidence gathering
- shared policy evaluation
Use `openclaw/plugin-sdk/channel-inbound` for the shared layer.
Good fit for plugin-local logic:
- reply-to-bot detection
- quoted-bot detection
- thread-participation checks
- service/system-message exclusions
- platform-native caches needed to prove bot participation
Good fit for the shared helper:
- `requireMention`
- explicit mention result
- implicit mention allowlist
- command bypass
- final skip decision
Preferred flow:
1. Compute local mention facts.
2. Pass those facts into `resolveInboundMentionDecision({ facts, policy })`.
3. Use `decision.effectiveWasMentioned`, `decision.shouldBypassMention`, and `decision.shouldSkip` in your inbound gate.
```typescript
import {
implicitMentionKindWhen,
matchesMentionWithExplicit,
resolveInboundMentionDecision,
} from "openclaw/plugin-sdk/channel-inbound";
const mentionMatch = matchesMentionWithExplicit(text, {
mentionRegexes,
mentionPatterns,
});
const facts = {
canDetectMention: true,
wasMentioned: mentionMatch.matched,
hasAnyMention: mentionMatch.hasExplicitMention,
implicitMentionKinds: [
...implicitMentionKindWhen("reply_to_bot", isReplyToBot),
...implicitMentionKindWhen("quoted_bot", isQuoteOfBot),
],
};
const decision = resolveInboundMentionDecision({
facts,
policy: {
isGroup,
requireMention,
allowedImplicitMentionKinds: requireExplicitMention ? [] : ["reply_to_bot", "quoted_bot"],
allowTextCommands,
hasControlCommand,
commandAuthorized,
},
});
if (decision.shouldSkip) return;
```
`api.runtime.channel.mentions` exposes the same shared mention helpers for
bundled channel plugins that already depend on runtime injection:
- `buildMentionRegexes`
- `matchesMentionPatterns`
- `matchesMentionWithExplicit`
- `implicitMentionKindWhen`
- `resolveInboundMentionDecision`
The older `resolveMentionGating*` helpers remain on
`openclaw/plugin-sdk/channel-inbound` as compatibility exports only. New code
should use `resolveInboundMentionDecision({ facts, policy })`.
## Walkthrough
<Steps>

View File

@@ -67,32 +67,6 @@ Current bundled provider examples:
## How to migrate
<Steps>
<Step title="Migrate approval-native handlers to capability facts">
Approval-capable channel plugins now expose native approval behavior through
`approvalCapability.nativeRuntime` plus the shared runtime-context registry.
Key changes:
- Replace `approvalCapability.handler.loadRuntime(...)` with
`approvalCapability.nativeRuntime`
- Move approval-specific auth/delivery off legacy `plugin.auth` /
`plugin.approvals` wiring and onto `approvalCapability`
- `ChannelPlugin.approvals` has been removed from the public channel-plugin
contract; move delivery/native/render fields onto `approvalCapability`
- `plugin.auth` remains for channel login/logout flows only; approval auth
hooks there are no longer read by core
- Register channel-owned runtime objects such as clients, tokens, or Bolt
apps through `openclaw/plugin-sdk/channel-runtime-context`
- Do not send plugin-owned reroute notices from native approval handlers;
core now owns routed-elsewhere notices from actual delivery results
- When passing `channelRuntime` into `createChannelManager(...)`, provide a
real `createPluginRuntime().channel` surface. Partial stubs are rejected.
See `/plugins/sdk-channel-plugins` for the current approval capability
layout.
</Step>
<Step title="Audit Windows wrapper fallback behavior">
If your plugin uses `openclaw/plugin-sdk/windows-spawn`, unresolved Windows
`.cmd`/`.bat` wrappers now fail closed unless you explicitly pass
@@ -227,12 +201,8 @@ Current bundled provider examples:
| `plugin-sdk/approval-auth-runtime` | Approval auth helpers | Approver resolution, same-chat action auth |
| `plugin-sdk/approval-client-runtime` | Approval client helpers | Native exec approval profile/filter helpers |
| `plugin-sdk/approval-delivery-runtime` | Approval delivery helpers | Native approval capability/delivery adapters |
| `plugin-sdk/approval-gateway-runtime` | Approval gateway helpers | Shared approval gateway-resolution helper |
| `plugin-sdk/approval-handler-adapter-runtime` | Approval adapter helpers | Lightweight native approval adapter loading helpers for hot channel entrypoints |
| `plugin-sdk/approval-handler-runtime` | Approval handler helpers | Broader approval handler runtime helpers; prefer the narrower adapter/gateway seams when they are enough |
| `plugin-sdk/approval-native-runtime` | Approval target helpers | Native approval target/account binding helpers |
| `plugin-sdk/approval-reply-runtime` | Approval reply helpers | Exec/plugin approval reply payload helpers |
| `plugin-sdk/channel-runtime-context` | Channel runtime-context helpers | Generic channel runtime-context register/get/watch helpers |
| `plugin-sdk/security-runtime` | Security helpers | Shared trust, DM gating, external-content, and secret-collection helpers |
| `plugin-sdk/ssrf-policy` | SSRF policy helpers | Host allowlist and private-network policy helpers |
| `plugin-sdk/ssrf-runtime` | SSRF runtime helpers | Pinned-dispatcher, guarded fetch, SSRF policy helpers |
@@ -279,8 +249,7 @@ Current bundled provider examples:
| `plugin-sdk/provider-onboard` | Provider onboarding patches | Onboarding config helpers |
| `plugin-sdk/provider-http` | Provider HTTP helpers | Generic provider HTTP/endpoint capability helpers |
| `plugin-sdk/provider-web-fetch` | Provider web-fetch helpers | Web-fetch provider registration/cache helpers |
| `plugin-sdk/provider-web-search-contract` | Provider web-search contract helpers | Narrow web-search config/credential contract helpers such as `enablePluginInConfig`, `resolveProviderWebSearchPluginConfig`, and scoped credential setters/getters |
| `plugin-sdk/provider-web-search` | Provider web-search helpers | Web-search provider registration/cache/runtime helpers |
| `plugin-sdk/provider-web-search` | Provider web-search helpers | Web-search provider registration/cache/config helpers |
| `plugin-sdk/provider-tools` | Provider tool/schema compat helpers | `ProviderToolCompatFamily`, `buildProviderToolCompatFamilyHooks`, Gemini schema cleanup + diagnostics, and xAI compat helpers such as `resolveXaiModelCompatPatch` / `applyXaiModelCompat` |
| `plugin-sdk/provider-usage` | Provider usage helpers | `fetchClaudeUsage`, `fetchGeminiUsage`, `fetchGithubCopilotUsage`, and other provider usage helpers |
| `plugin-sdk/provider-stream` | Provider stream wrapper helpers | `ProviderStreamFamily`, `buildProviderStreamFamilyHooks`, `composeProviderStreamWrappers`, stream wrapper types, and shared Anthropic/Bedrock/Google/Kilocode/Moonshot/OpenAI/OpenRouter/Z.A.I/MiniMax/Copilot wrapper helpers |

View File

@@ -108,7 +108,7 @@ explicitly promotes one as public.
| `plugin-sdk/group-access` | Shared group-access decision helpers |
| `plugin-sdk/direct-dm` | Shared direct-DM auth/guard helpers |
| `plugin-sdk/interactive-runtime` | Interactive reply payload normalization/reduction helpers |
| `plugin-sdk/channel-inbound` | Inbound debounce, mention matching, mention-policy helpers, and envelope helpers |
| `plugin-sdk/channel-inbound` | Debounce, mention matching, envelope helpers |
| `plugin-sdk/channel-send-result` | Reply result types |
| `plugin-sdk/channel-actions` | `createMessageToolButtonsSchema`, `createMessageToolCardSchema` |
| `plugin-sdk/channel-targets` | Target parsing/matching helpers |
@@ -133,10 +133,8 @@ explicitly promotes one as public.
| `plugin-sdk/provider-model-shared` | `ProviderReplayFamily`, `buildProviderReplayFamilyHooks`, `normalizeModelCompat`, shared replay-policy builders, provider-endpoint helpers, and model-id normalization helpers such as `normalizeNativeXaiModelId` |
| `plugin-sdk/provider-catalog-shared` | `findCatalogTemplate`, `buildSingleProviderApiKeyCatalog`, `supportsNativeStreamingUsageCompat`, `applyProviderNativeStreamingUsageCompat` |
| `plugin-sdk/provider-http` | Generic provider HTTP/endpoint capability helpers |
| `plugin-sdk/provider-web-fetch-contract` | Narrow web-fetch config/selection contract helpers such as `enablePluginInConfig` and `WebFetchProviderPlugin` |
| `plugin-sdk/provider-web-fetch` | Web-fetch provider registration/cache helpers |
| `plugin-sdk/provider-web-search-contract` | Narrow web-search config/credential contract helpers such as `enablePluginInConfig`, `resolveProviderWebSearchPluginConfig`, and scoped credential setters/getters |
| `plugin-sdk/provider-web-search` | Web-search provider registration/cache/runtime helpers |
| `plugin-sdk/provider-web-search` | Web-search provider registration/cache/config helpers |
| `plugin-sdk/provider-tools` | `ProviderToolCompatFamily`, `buildProviderToolCompatFamilyHooks`, Gemini schema cleanup + diagnostics, and xAI compat helpers such as `resolveXaiModelCompatPatch` / `applyXaiModelCompat` |
| `plugin-sdk/provider-usage` | `fetchClaudeUsage` and similar |
| `plugin-sdk/provider-stream` | `ProviderStreamFamily`, `buildProviderStreamFamilyHooks`, `composeProviderStreamWrappers`, stream wrapper types, and shared Anthropic/Bedrock/Google/Kilocode/Moonshot/OpenAI/OpenRouter/Z.A.I/MiniMax/Copilot wrapper helpers |
@@ -151,9 +149,6 @@ explicitly promotes one as public.
| `plugin-sdk/approval-auth-runtime` | Approver resolution and same-chat action-auth helpers |
| `plugin-sdk/approval-client-runtime` | Native exec approval profile/filter helpers |
| `plugin-sdk/approval-delivery-runtime` | Native approval capability/delivery adapters |
| `plugin-sdk/approval-gateway-runtime` | Shared approval gateway-resolution helper |
| `plugin-sdk/approval-handler-adapter-runtime` | Lightweight native approval adapter loading helpers for hot channel entrypoints |
| `plugin-sdk/approval-handler-runtime` | Broader approval handler runtime helpers; prefer the narrower adapter/gateway seams when they are enough |
| `plugin-sdk/approval-native-runtime` | Native approval target + account-binding helpers |
| `plugin-sdk/approval-reply-runtime` | Exec/plugin approval reply payload helpers |
| `plugin-sdk/command-auth-native` | Native command auth + native session-target helpers |
@@ -161,7 +156,6 @@ explicitly promotes one as public.
| `plugin-sdk/command-surface` | Command-body normalization and command-surface helpers |
| `plugin-sdk/allow-from` | `formatAllowFromLowercase` |
| `plugin-sdk/channel-secret-runtime` | Narrow secret-contract collection helpers for channel/plugin secret surfaces |
| `plugin-sdk/secret-ref-runtime` | Narrow `coerceSecretRef` and SecretRef typing helpers for secret-contract/config parsing |
| `plugin-sdk/security-runtime` | Shared trust, DM gating, external-content, and secret-collection helpers |
| `plugin-sdk/ssrf-policy` | Host allowlist and private-network SSRF policy helpers |
| `plugin-sdk/ssrf-runtime` | Pinned-dispatcher, SSRF-guarded fetch, and SSRF policy helpers |
@@ -175,7 +169,6 @@ explicitly promotes one as public.
| --- | --- |
| `plugin-sdk/runtime` | Broad runtime/logging/backup/plugin-install helpers |
| `plugin-sdk/runtime-env` | Narrow runtime env, logger, timeout, retry, and backoff helpers |
| `plugin-sdk/channel-runtime-context` | Generic channel runtime-context registration and lookup helpers |
| `plugin-sdk/runtime-store` | `createPluginRuntimeStore` |
| `plugin-sdk/plugin-runtime` | Shared plugin command/hook/http/interactive helpers |
| `plugin-sdk/hook-runtime` | Shared webhook/internal hook pipeline helpers |
@@ -390,7 +383,6 @@ AI CLI backend such as `codex-cli`.
| Method | What it registers |
| ------------------------------------------ | ------------------------------------- |
| `api.registerContextEngine(id, factory)` | Context engine (one active at a time) |
| `api.registerMemoryCapability(capability)` | Unified memory capability |
| `api.registerMemoryPromptSection(builder)` | Memory prompt section builder |
| `api.registerMemoryFlushPlan(resolver)` | Memory flush plan resolver |
| `api.registerMemoryRuntime(runtime)` | Memory runtime adapter |
@@ -401,13 +393,8 @@ AI CLI backend such as `codex-cli`.
| ---------------------------------------------- | ---------------------------------------------- |
| `api.registerMemoryEmbeddingProvider(adapter)` | Memory embedding adapter for the active plugin |
- `registerMemoryCapability` is the preferred exclusive memory-plugin API.
- `registerMemoryCapability` may also expose `publicArtifacts.listArtifacts(...)`
so companion plugins can consume exported memory artifacts through
`openclaw/plugin-sdk/memory-host-core` instead of reaching into a specific
memory plugin's private layout.
- `registerMemoryPromptSection`, `registerMemoryFlushPlan`, and
`registerMemoryRuntime` are legacy-compatible exclusive memory-plugin APIs.
`registerMemoryRuntime` are exclusive to memory plugins.
- `registerMemoryEmbeddingProvider` lets the active memory plugin register one
or more embedding adapter ids (for example `openai`, `gemini`, or a custom
plugin-defined id).

View File

@@ -330,46 +330,6 @@ api.runtime.tools.registerMemoryCli(/* ... */);
Channel-specific runtime helpers (available when a channel plugin is loaded).
`api.runtime.channel.mentions` is the shared inbound mention-policy surface for
bundled channel plugins that use runtime injection:
```typescript
const mentionMatch = api.runtime.channel.mentions.matchesMentionWithExplicit(text, {
mentionRegexes,
mentionPatterns,
});
const decision = api.runtime.channel.mentions.resolveInboundMentionDecision({
facts: {
canDetectMention: true,
wasMentioned: mentionMatch.matched,
implicitMentionKinds: api.runtime.channel.mentions.implicitMentionKindWhen(
"reply_to_bot",
isReplyToBot,
),
},
policy: {
isGroup,
requireMention,
allowTextCommands,
hasControlCommand,
commandAuthorized,
},
});
```
Available mention helpers:
- `buildMentionRegexes`
- `matchesMentionPatterns`
- `matchesMentionWithExplicit`
- `implicitMentionKindWhen`
- `resolveInboundMentionDecision`
`api.runtime.channel.mentions` intentionally does not expose the older
`resolveMentionGating*` compatibility helpers. Prefer the normalized
`{ facts, policy }` path.
## Storing runtime references
Use `createPluginRuntimeStore` to store the runtime reference for use outside

View File

@@ -52,7 +52,7 @@ An alternative provider `google-gemini-cli` uses PKCE OAuth instead of an API
key. This is an unofficial integration; some users report account
restrictions. Use at your own risk.
- Default model: `google-gemini-cli/gemini-3-flash-preview`
- Default model: `google-gemini-cli/gemini-3.1-pro-preview`
- Alias: `gemini-cli`
- Install prerequisite: local Gemini CLI available as `gemini`
- Homebrew: `brew install gemini-cli`

View File

@@ -42,7 +42,6 @@ Looking for chat channel docs (WhatsApp/Telegram/Discord/Slack/Mattermost (plugi
- [Google (Gemini)](/providers/google)
- [Groq (LPU inference)](/providers/groq)
- [Hugging Face (Inference)](/providers/huggingface)
- [inferrs (local models)](/providers/inferrs)
- [Kilocode](/providers/kilocode)
- [LiteLLM (unified gateway)](/providers/litellm)
- [MiniMax](/providers/minimax)

View File

@@ -1,173 +0,0 @@
---
summary: "Run OpenClaw through inferrs (OpenAI-compatible local server)"
read_when:
- You want to run OpenClaw against a local inferrs server
- You are serving Gemma or another model through inferrs
- You need the exact OpenClaw compat flags for inferrs
title: "inferrs"
---
# inferrs
[inferrs](https://github.com/ericcurtin/inferrs) can serve local models behind an
OpenAI-compatible `/v1` API. OpenClaw works with `inferrs` through the generic
`openai-completions` path.
`inferrs` is currently best treated as a custom self-hosted OpenAI-compatible
backend, not a dedicated OpenClaw provider plugin.
## Quick start
1. Start `inferrs` with a model.
Example:
```bash
inferrs serve gg-hf-gg/gemma-4-E2B-it \
--host 127.0.0.1 \
--port 8080 \
--device metal
```
2. Verify the server is reachable.
```bash
curl http://127.0.0.1:8080/health
curl http://127.0.0.1:8080/v1/models
```
3. Add an explicit OpenClaw provider entry and point your default model at it.
## Full config example
This example uses Gemma 4 on a local `inferrs` server.
```json5
{
agents: {
defaults: {
model: { primary: "inferrs/gg-hf-gg/gemma-4-E2B-it" },
models: {
"inferrs/gg-hf-gg/gemma-4-E2B-it": {
alias: "Gemma 4 (inferrs)",
},
},
},
},
models: {
mode: "merge",
providers: {
inferrs: {
baseUrl: "http://127.0.0.1:8080/v1",
apiKey: "inferrs-local",
api: "openai-completions",
models: [
{
id: "gg-hf-gg/gemma-4-E2B-it",
name: "Gemma 4 E2B (inferrs)",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 131072,
maxTokens: 4096,
compat: {
requiresStringContent: true,
},
},
],
},
},
},
}
```
## Why `requiresStringContent` matters
Some `inferrs` Chat Completions routes accept only string
`messages[].content`, not structured content-part arrays.
If OpenClaw runs fail with an error like:
```text
messages[1].content: invalid type: sequence, expected a string
```
set:
```json5
compat: {
requiresStringContent: true
}
```
OpenClaw will flatten pure text content parts into plain strings before sending
the request.
## Gemma and tool-schema caveat
Some current `inferrs` + Gemma combinations accept small direct
`/v1/chat/completions` requests but still fail on full OpenClaw agent-runtime
turns.
If that happens, try this first:
```json5
compat: {
requiresStringContent: true,
supportsTools: false
}
```
That disables OpenClaw's tool schema surface for the model and can reduce prompt
pressure on stricter local backends.
If tiny direct requests still work but normal OpenClaw agent turns continue to
crash inside `inferrs`, the remaining issue is usually upstream model/server
behavior rather than OpenClaw's transport layer.
## Manual smoke test
Once configured, test both layers:
```bash
curl http://127.0.0.1:8080/v1/chat/completions \
-H 'content-type: application/json' \
-d '{"model":"gg-hf-gg/gemma-4-E2B-it","messages":[{"role":"user","content":"What is 2 + 2?"}],"stream":false}'
openclaw infer model run \
--model inferrs/gg-hf-gg/gemma-4-E2B-it \
--prompt "What is 2 + 2? Reply with one short sentence." \
--json
```
If the first command works but the second fails, use the troubleshooting notes
below.
## Troubleshooting
- `curl /v1/models` fails: `inferrs` is not running, not reachable, or not
bound to the expected host/port.
- `messages[].content ... expected a string`: set
`compat.requiresStringContent: true`.
- Direct tiny `/v1/chat/completions` calls pass, but `openclaw infer model run`
fails: try `compat.supportsTools: false`.
- OpenClaw no longer gets schema errors, but `inferrs` still crashes on larger
agent turns: treat it as an upstream `inferrs` or model limitation and reduce
prompt pressure or switch local backend/model.
## Proxy-style behavior
`inferrs` is treated as a proxy-style OpenAI-compatible `/v1` backend, not a
native OpenAI endpoint.
- native OpenAI-only request shaping does not apply here
- no `service_tier`, no Responses `store`, no prompt-cache hints, and no
OpenAI reasoning-compat payload shaping
- hidden OpenClaw attribution headers (`originator`, `version`, `User-Agent`)
are not injected on custom `inferrs` base URLs
## See also
- [Local models](/gateway/local-models)
- [Gateway troubleshooting](/gateway/troubleshooting#local-openai-compatible-backend-passes-direct-probes-but-agent-runs-fail)
- [Model providers](/concepts/model-providers)

View File

@@ -33,15 +33,15 @@ openclaw onboard --mistral-api-key "$MISTRAL_API_KEY"
OpenClaw currently ships this bundled Mistral catalog:
| Model ref | Input | Context | Max output | Notes |
| -------------------------------- | ----------- | ------- | ---------- | ---------------------------------------------------------------- |
| `mistral/mistral-large-latest` | text, image | 262,144 | 16,384 | Default model |
| `mistral/mistral-medium-2508` | text, image | 262,144 | 8,192 | Mistral Medium 3.1 |
| `mistral/mistral-small-latest` | text, image | 128,000 | 16,384 | Mistral Small 4; adjustable reasoning via API `reasoning_effort` |
| `mistral/pixtral-large-latest` | text, image | 128,000 | 32,768 | Pixtral |
| `mistral/codestral-latest` | text | 256,000 | 4,096 | Coding |
| `mistral/devstral-medium-latest` | text | 262,144 | 32,768 | Devstral 2 |
| `mistral/magistral-small` | text | 128,000 | 40,000 | Reasoning-enabled |
| Model ref | Input | Context | Max output | Notes |
| -------------------------------- | ----------- | ------- | ---------- | ------------------------ |
| `mistral/mistral-large-latest` | text, image | 262,144 | 16,384 | Default model |
| `mistral/mistral-medium-2508` | text, image | 262,144 | 8,192 | Mistral Medium 3.1 |
| `mistral/mistral-small-latest` | text, image | 128,000 | 16,384 | Smaller multimodal model |
| `mistral/pixtral-large-latest` | text, image | 128,000 | 32,768 | Pixtral |
| `mistral/codestral-latest` | text | 256,000 | 4,096 | Coding |
| `mistral/devstral-medium-latest` | text | 262,144 | 32,768 | Devstral 2 |
| `mistral/magistral-small` | text | 128,000 | 40,000 | Reasoning-enabled |
## Config snippet (audio transcription with Voxtral)
@@ -58,17 +58,6 @@ OpenClaw currently ships this bundled Mistral catalog:
}
```
## Adjustable reasoning (`mistral-small-latest`)
`mistral/mistral-small-latest` maps to Mistral Small 4 and supports [adjustable reasoning](https://docs.mistral.ai/capabilities/reasoning/adjustable) on the Chat Completions API via `reasoning_effort` (`none` minimizes extra thinking in the output; `high` surfaces full thinking traces before the final answer).
OpenClaw maps the session **thinking** level to Mistrals API:
- **off** / **minimal**`none`
- **low** / **medium** / **high** / **xhigh** / **adaptive**`high`
Other bundled Mistral catalog models do not use this parameter; keep using `magistral-*` models when you want Mistrals native reasoning-first behavior.
## Notes
- Mistral auth uses `MISTRAL_API_KEY`.

View File

@@ -54,7 +54,7 @@ model as `provider/model`.
- `anthropic-vertex` - implicit Anthropic on Google Vertex support when Vertex credentials are available; no separate onboarding auth choice
- `copilot-proxy` - local VS Code Copilot Proxy bridge; use `openclaw onboard --auth-choice copilot-proxy`
- `google-gemini-cli` - unofficial Gemini CLI OAuth flow; requires a local `gemini` install (`brew install gemini-cli` or `npm install -g @google/gemini-cli`); default model `google-gemini-cli/gemini-3-flash-preview`; use `openclaw onboard --auth-choice google-gemini-cli` or `openclaw models auth login --provider google-gemini-cli --set-default`
- `google-gemini-cli` - unofficial Gemini CLI OAuth flow; requires a local `gemini` install (`brew install gemini-cli` or `npm install -g @google/gemini-cli`); default model `google-gemini-cli/gemini-3.1-pro-preview`; use `openclaw onboard --auth-choice google-gemini-cli` or `openclaw models auth login --provider google-gemini-cli --set-default`
For the full provider catalog (xAI, Groq, Mistral, etc.) and advanced configuration,
see [Model providers](/concepts/model-providers).

View File

@@ -1,14 +1,14 @@
---
summary: "Use NVIDIA's OpenAI-compatible API in OpenClaw"
read_when:
- You want to use open models in OpenClaw for free
- You want to use NVIDIA models in OpenClaw
- You need NVIDIA_API_KEY setup
title: "NVIDIA"
---
# NVIDIA
NVIDIA provides an OpenAI-compatible API at `https://integrate.api.nvidia.com/v1` for open models for free. Authenticate with an API key from [build.nvidia.com](https://build.nvidia.com/settings/api-keys).
NVIDIA provides an OpenAI-compatible API at `https://integrate.api.nvidia.com/v1` for Nemotron and NeMo models. Authenticate with an API key from [NVIDIA NGC](https://catalog.ngc.nvidia.com/).
## CLI setup
@@ -17,7 +17,7 @@ Export the key once, then run onboarding and set an NVIDIA model:
```bash
export NVIDIA_API_KEY="nvapi-..."
openclaw onboard --auth-choice skip
openclaw models set nvidia/nvidia/nemotron-3-super-120b-a12b
openclaw models set nvidia/nvidia/llama-3.1-nemotron-70b-instruct
```
If you still pass `--token`, remember it lands in shell history and `ps` output; prefer the env var when possible.
@@ -37,7 +37,7 @@ If you still pass `--token`, remember it lands in shell history and `ps` output;
},
agents: {
defaults: {
model: { primary: "nvidia/nvidia/nemotron-3-super-120b-a12b" },
model: { primary: "nvidia/nvidia/llama-3.1-nemotron-70b-instruct" },
},
},
}
@@ -45,15 +45,14 @@ If you still pass `--token`, remember it lands in shell history and `ps` output;
## Model IDs
| Model ref | Name | Context | Max output |
| ------------------------------------------ | ---------------------------- | ------- | ---------- |
| `nvidia/nvidia/nemotron-3-super-120b-a12b` | NVIDIA Nemotron 3 Super 120B | 262,144 | 8,192 |
| `nvidia/moonshotai/kimi-k2.5` | Kimi K2.5 | 262,144 | 8,192 |
| `nvidia/minimaxai/minimax-m2.5` | Minimax M2.5 | 196,608 | 8,192 |
| `nvidia/z-ai/glm5` | GLM 5 | 202,752 | 8,192 |
| Model ref | Name | Context | Max output |
| ---------------------------------------------------- | ---------------------------------------- | ------- | ---------- |
| `nvidia/nvidia/llama-3.1-nemotron-70b-instruct` | NVIDIA Llama 3.1 Nemotron 70B Instruct | 131,072 | 4,096 |
| `nvidia/meta/llama-3.3-70b-instruct` | Meta Llama 3.3 70B Instruct | 131,072 | 4,096 |
| `nvidia/nvidia/mistral-nemo-minitron-8b-8k-instruct` | NVIDIA Mistral NeMo Minitron 8B Instruct | 8,192 | 2,048 |
## Notes
- OpenAI-compatible `/v1` endpoint; use an API key from [build.nvidia.com](https://build.nvidia.com/).
- OpenAI-compatible `/v1` endpoint; use an API key from NVIDIA NGC.
- Provider auto-enables when `NVIDIA_API_KEY` is set.
- The bundled catalog is static; costs default to `0` in source.

View File

@@ -57,7 +57,7 @@ openclaw onboard --non-interactive \
2. Pull a local model if you want local inference:
```bash
ollama pull gemma4
ollama pull glm-4.7-flash
# or
ollama pull gpt-oss:20b
# or
@@ -78,12 +78,12 @@ openclaw onboard
- `Local`: local models only
- `Cloud + Local`: local models plus cloud models
- Cloud models such as `kimi-k2.5:cloud`, `minimax-m2.7:cloud`, and `glm-5.1:cloud` do **not** require a local `ollama pull`
- Cloud models such as `kimi-k2.5:cloud`, `minimax-m2.5:cloud`, and `glm-5:cloud` do **not** require a local `ollama pull`
OpenClaw currently suggests:
- local default: `gemma4`
- cloud defaults: `kimi-k2.5:cloud`, `minimax-m2.7:cloud`, `glm-5.1:cloud`
- local default: `glm-4.7-flash`
- cloud defaults: `kimi-k2.5:cloud`, `minimax-m2.5:cloud`, `glm-5:cloud`
5. If you prefer manual setup, enable Ollama for OpenClaw directly (any value works; Ollama doesn't require a real key):
@@ -99,7 +99,7 @@ openclaw config set models.providers.ollama.apiKey "ollama-local"
```bash
openclaw models list
openclaw models set ollama/gemma4
openclaw models set ollama/glm-4.7-flash
```
7. Or set the default in config:
@@ -108,7 +108,7 @@ openclaw models set ollama/gemma4
{
agents: {
defaults: {
model: { primary: "ollama/gemma4" },
model: { primary: "ollama/glm-4.7-flash" },
},
},
}
@@ -229,7 +229,7 @@ Once configured, all your Ollama models are available:
## Cloud models
Cloud models let you run cloud-hosted models (for example `kimi-k2.5:cloud`, `minimax-m2.7:cloud`, `glm-5.1:cloud`) alongside your local models.
Cloud models let you run cloud-hosted models (for example `kimi-k2.5:cloud`, `minimax-m2.5:cloud`, `glm-5:cloud`) alongside your local models.
To use cloud models, select **Cloud + Local** mode during setup. The wizard checks whether you are signed in and opens a browser sign-in flow when needed. If authentication cannot be verified, the wizard falls back to local model defaults.
@@ -355,7 +355,7 @@ To add models:
```bash
ollama list # See what's installed
ollama pull gemma4
ollama pull glm-4.7-flash
ollama pull gpt-oss:20b
ollama pull llama3.3 # Or another model
```

View File

@@ -17,22 +17,10 @@ conceptual overviews, see:
- [Builtin Engine](/concepts/memory-builtin) -- default SQLite backend
- [QMD Engine](/concepts/memory-qmd) -- local-first sidecar
- [Memory Search](/concepts/memory-search) -- search pipeline and tuning
- [Active Memory](/concepts/active-memory) -- enabling the memory sidecar for interactive sessions
All memory search settings live under `agents.defaults.memorySearch` in
`openclaw.json` unless noted otherwise.
If you are looking for the **active memory** feature toggle and sidecar config,
that lives under `plugins.entries.active-memory` instead of `memorySearch`.
Active memory uses a two-gate model:
1. the plugin must be enabled and target the current agent id
2. the request must be an eligible interactive persistent chat session
See [Active Memory](/concepts/active-memory) for the activation model,
plugin-owned config, transcript persistence, and safe rollout pattern.
---
## Provider selection

View File

@@ -275,21 +275,6 @@ Implementation: `ensurePiCompactionReserveTokens()` in `src/agents/pi-settings.t
---
## Pluggable compaction providers
Plugins can register a compaction provider via `registerCompactionProvider()` on the plugin API. When `agents.defaults.compaction.provider` is set to a registered provider id, the safeguard extension delegates summarization to that provider instead of the built-in `summarizeInStages` pipeline.
- `provider`: id of a registered compaction provider plugin. Leave unset for default LLM summarization.
- Setting a `provider` forces `mode: "safeguard"`.
- Providers receive the same compaction instructions and identifier-preservation policy as the built-in path.
- The safeguard still preserves recent-turn and split-turn suffix context after provider output.
- If the provider fails or returns an empty result, OpenClaw falls back to built-in LLM summarization automatically.
- Abort/timeout signals are re-thrown (not swallowed) to respect caller cancellation.
Source: `src/plugins/compaction-provider.ts`, `src/agents/pi-hooks/compaction-safeguard.ts`.
---
## User-visible surfaces
You can observe compaction and session state via:

View File

@@ -13,7 +13,7 @@ title: "Tests"
- `pnpm test:coverage`: Runs the unit suite with V8 coverage (via `vitest.unit.config.ts`). Global thresholds are 70% lines/branches/functions/statements. Coverage excludes integration-heavy entrypoints (CLI wiring, gateway/telegram bridges, webchat static server) to keep the target focused on unit-testable logic.
- `pnpm test:coverage:changed`: Runs unit coverage only for files changed since `origin/main`.
- `pnpm test:changed`: expands changed git paths into scoped Vitest lanes when the diff only touches routable source/test files. Config/setup changes still fall back to the native root projects run so wiring edits rerun broadly when needed.
- `pnpm test`: routes explicit file/directory targets through scoped Vitest lanes. Untargeted runs now execute eleven sequential shard configs (`vitest.full-core-unit-src.config.ts`, `vitest.full-core-unit-security.config.ts`, `vitest.full-core-unit-ui.config.ts`, `vitest.full-core-unit-support.config.ts`, `vitest.full-core-support-boundary.config.ts`, `vitest.full-core-contracts.config.ts`, `vitest.full-core-bundled.config.ts`, `vitest.full-core-runtime.config.ts`, `vitest.full-agentic.config.ts`, `vitest.full-auto-reply.config.ts`, `vitest.full-extensions.config.ts`) instead of one giant root-project process.
- `pnpm test`: routes explicit file/directory targets through scoped Vitest lanes. Untargeted runs now execute eight sequential shard configs (`vitest.full-core-unit-src.config.ts`, `vitest.full-core-unit-security.config.ts`, `vitest.full-core-unit-support.config.ts`, `vitest.full-core-contracts.config.ts`, `vitest.full-core-runtime.config.ts`, `vitest.full-agentic.config.ts`, `vitest.full-auto-reply.config.ts`, `vitest.full-extensions.config.ts`) instead of one giant root-project process.
- Selected `plugin-sdk` and `commands` test files now route through dedicated light lanes that keep only `test/setup.ts`, leaving runtime-heavy cases on their existing lanes.
- Selected `plugin-sdk` and `commands` helper source files also map `pnpm test:changed` to explicit sibling tests in those light lanes, so small helper edits avoid rerunning the heavy runtime-backed suites.
- `auto-reply` now also splits into three dedicated configs (`core`, `top-level`, `reply`) so the reply harness does not dominate the lighter top-level status/token/helper tests.

View File

@@ -557,8 +557,8 @@ Shared behavior:
- Slack approvers can be explicit (`execApprovals.approvers`) or inferred from `commands.ownerAllowFrom`
- Slack native buttons preserve approval id kind, so `plugin:` ids can resolve plugin approvals
without a second Slack-local fallback layer
- Matrix native DM/channel routing and reaction shortcuts handle both exec and plugin approvals;
plugin authorization still comes from `channels.matrix.dm.allowFrom`
- Matrix native DM/channel routing is exec-only; Matrix plugin approvals stay on the shared
same-chat `/approve` and optional `approvals.plugin` forwarding paths
- the requester does not need to be an approver
- the originating chat can approve directly with `/approve` when that chat already supports commands and replies
- native Discord approval buttons route by approval id kind: `plugin:` ids go

View File

@@ -68,9 +68,7 @@ Use `action: "list"` to inspect available providers and models at runtime:
| `count` | number | Number of images to generate (14) |
| `filename` | string | Output filename hint |
Not all providers support all parameters. When a fallback provider supports a nearby geometry option instead of the exact requested one, OpenClaw remaps to the closest supported size, aspect ratio, or resolution before submission. Truly unsupported overrides are still reported in the tool result.
Tool results report the applied settings. When OpenClaw remaps geometry during provider fallback, the returned `size`, `aspectRatio`, and `resolution` values reflect what was actually sent, and `details.normalization` captures the requested-to-applied translation.
Not all providers support all parameters. The tool passes what each provider supports, ignores the rest, and reports dropped overrides in the tool result.
## Configuration
@@ -106,10 +104,6 @@ Notes:
- Auto-detection is auth-aware. A provider default only enters the candidate list
when OpenClaw can actually authenticate that provider.
- Auto-detection is enabled by default. Set
`agents.defaults.mediaGenerationAutoProviderFallback: false` if you want image
generation to use only the explicit `model`, `primary`, and `fallbacks`
entries.
- Use `action: "list"` to inspect the currently registered providers, their
default models, and auth env-var hints.

View File

@@ -131,12 +131,8 @@ Direct generation example:
| `filename` | string | Output filename hint |
Not all providers support all parameters. OpenClaw still validates hard limits
such as input counts before submission. When a provider supports duration but
uses a shorter maximum than the requested value, OpenClaw automatically clamps
to the closest supported duration. Truly unsupported optional hints are ignored
with a warning when the selected provider or model cannot honor them.
Tool results report the applied settings. When OpenClaw clamps duration during provider fallback, the returned `durationSeconds` reflects the submitted value and `details.normalization.durationSeconds` shows the requested-to-applied mapping.
such as input counts before submission, but unsupported optional hints are
ignored with a warning when the selected provider or model cannot honor them.
## Async behavior for the shared provider-backed path
@@ -198,10 +194,6 @@ When generating music, OpenClaw tries providers in this order:
If a provider fails, the next candidate is tried automatically. If all fail, the
error includes details from each attempt.
Set `agents.defaults.mediaGenerationAutoProviderFallback: false` if you want
music generation to use only the explicit `model`, `primary`, and `fallbacks`
entries.
## Provider notes
- Google uses Lyria 3 batch generation. The current bundled flow supports

View File

@@ -154,9 +154,7 @@ and the shared live sweep.
| `model` | string | Provider/model override (e.g. `runway/gen4.5`) |
| `filename` | string | Output filename hint |
Not all providers support all parameters. OpenClaw already normalizes duration to the closest provider-supported value, and it also remaps translated geometry hints such as size-to-aspect-ratio when a fallback provider exposes a different control surface. Truly unsupported overrides are ignored on a best-effort basis and reported as warnings in the tool result. Hard capability limits (such as too many reference inputs) fail before submission.
Tool results report the applied settings. When OpenClaw remaps duration or geometry during provider fallback, the returned `durationSeconds`, `size`, `aspectRatio`, and `resolution` values reflect what was submitted, and `details.normalization` captures the requested-to-applied translation.
Not all providers support all parameters. Unsupported overrides are ignored on a best-effort basis and reported as warnings in the tool result. Hard capability limits (such as too many reference inputs) fail before submission.
Reference inputs also select the runtime mode:
@@ -184,10 +182,6 @@ When generating a video, OpenClaw resolves the model in this order:
If a provider fails, the next candidate is tried automatically. If all candidates fail, the error includes details from each attempt.
Set `agents.defaults.mediaGenerationAutoProviderFallback: false` if you want
video generation to use only the explicit `model`, `primary`, and `fallbacks`
entries.
```json5
{
agents: {

View File

@@ -4,10 +4,7 @@
"description": "OpenClaw ACP runtime backend",
"type": "module",
"dependencies": {
"acpx": "0.5.2"
},
"devDependencies": {
"@openclaw/plugin-sdk": "workspace:*"
"acpx": "0.5.0"
},
"openclaw": {
"extensions": [

View File

@@ -1,5 +1,4 @@
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
export default definePluginEntry({
id: "acpx",
@@ -7,7 +6,8 @@ export default definePluginEntry({
description: "Lightweight ACPX setup hooks",
register(api) {
api.registerAutoEnableProbe(({ config }) => {
const backendRaw = normalizeLowercaseStringOrEmpty(config.acp?.backend);
const backendRaw =
typeof config.acp?.backend === "string" ? config.acp.backend.trim().toLowerCase() : "";
const configured =
config.acp?.enabled === true ||
config.acp?.dispatch?.enabled === true ||

View File

@@ -1,60 +0,0 @@
declare module "acpx/runtime" {
export const ACPX_BACKEND_ID: string;
export type AcpRuntimeDoctorReport = import("../runtime-api.js").AcpRuntimeDoctorReport;
export type AcpRuntimeEnsureInput = import("../runtime-api.js").AcpRuntimeEnsureInput;
export type AcpRuntimeEvent = import("../runtime-api.js").AcpRuntimeEvent;
export type AcpRuntimeHandle = import("../runtime-api.js").AcpRuntimeHandle;
export type AcpRuntimeCapabilities = import("../runtime-api.js").AcpRuntimeCapabilities;
export type AcpRuntimeStatus = import("../runtime-api.js").AcpRuntimeStatus;
export type AcpRuntimeTurnInput = import("../runtime-api.js").AcpRuntimeTurnInput;
export type AcpAgentRegistry = {
resolve(agent: string): string | undefined;
list(): string[];
};
export type AcpSessionRecord = Record<string, unknown>;
export type AcpSessionStore = {
load(sessionId: string): Promise<AcpSessionRecord | undefined>;
save(record: AcpSessionRecord): Promise<void>;
};
export type AcpRuntimeOptions = {
cwd: string;
sessionStore: AcpSessionStore;
agentRegistry: AcpAgentRegistry;
mcpServers?: unknown;
permissionMode?: unknown;
nonInteractivePermissions?: unknown;
timeoutMs?: number;
};
export class AcpxRuntime {
constructor(options: AcpRuntimeOptions, testOptions?: unknown);
isHealthy(): boolean;
probeAvailability(): Promise<void>;
doctor(): Promise<AcpRuntimeDoctorReport>;
ensureSession(input: AcpRuntimeEnsureInput): Promise<AcpRuntimeHandle>;
runTurn(input: AcpRuntimeTurnInput): AsyncIterable<AcpRuntimeEvent>;
getCapabilities(input?: {
handle?: AcpRuntimeHandle;
}): AcpRuntimeCapabilities | Promise<AcpRuntimeCapabilities>;
getStatus(input: { handle: AcpRuntimeHandle; signal?: AbortSignal }): Promise<AcpRuntimeStatus>;
setMode(input: { handle: AcpRuntimeHandle; mode: string }): Promise<void>;
setConfigOption(input: { handle: AcpRuntimeHandle; key: string; value: string }): Promise<void>;
cancel(input: { handle: AcpRuntimeHandle; reason?: string }): Promise<void>;
close(input: {
handle: AcpRuntimeHandle;
reason?: string;
discardPersistentState?: boolean;
}): Promise<void>;
}
export function createAcpRuntime(...args: unknown[]): AcpxRuntime;
export function createAgentRegistry(params: { overrides?: unknown }): AcpAgentRegistry;
export function createFileSessionStore(params: { stateDir: string }): AcpSessionStore;
export function decodeAcpxRuntimeHandleState(...args: unknown[]): unknown;
export function encodeAcpxRuntimeHandleState(...args: unknown[]): unknown;
}

55
extensions/acpx/src/acpx-runtime.d.ts vendored Normal file
View File

@@ -0,0 +1,55 @@
declare module "acpx/runtime" {
export const ACPX_BACKEND_ID: string;
export type AcpRuntimeDoctorReport =
import("../../../src/acp/runtime/types.js").AcpRuntimeDoctorReport;
export type AcpRuntimeEnsureInput =
import("../../../src/acp/runtime/types.js").AcpRuntimeEnsureInput;
export type AcpRuntimeEvent = import("../../../src/acp/runtime/types.js").AcpRuntimeEvent;
export type AcpRuntimeHandle = import("../../../src/acp/runtime/types.js").AcpRuntimeHandle;
export type AcpRuntimeTurnInput = import("../../../src/acp/runtime/types.js").AcpRuntimeTurnInput;
export type AcpRuntimeStatus = import("../../../src/acp/runtime/types.js").AcpRuntimeStatus;
export type AcpRuntimeCapabilities =
import("../../../src/acp/runtime/types.js").AcpRuntimeCapabilities;
export type AcpSessionStore = {
load(sessionId: string): Promise<unknown>;
save(record: unknown): Promise<void>;
};
export type AcpAgentRegistry = {
resolve(agentId: string): string;
list(): string[];
};
export type AcpRuntimeOptions = {
cwd: string;
sessionStore: AcpSessionStore;
agentRegistry: AcpAgentRegistry;
permissionMode: string;
mcpServers?: unknown[];
nonInteractivePermissions?: unknown;
timeoutMs?: number;
};
export class AcpxRuntime {
constructor(options: AcpRuntimeOptions, testOptions?: unknown);
isHealthy(): boolean;
probeAvailability(): Promise<void>;
doctor(): Promise<AcpRuntimeDoctorReport>;
ensureSession(input: AcpRuntimeEnsureInput): Promise<AcpRuntimeHandle>;
runTurn(input: AcpRuntimeTurnInput): AsyncIterable<AcpRuntimeEvent>;
getCapabilities(input?: { handle?: AcpRuntimeHandle }): AcpRuntimeCapabilities;
getStatus(input: { handle: AcpRuntimeHandle; signal?: AbortSignal }): Promise<AcpRuntimeStatus>;
setMode(input: { handle: AcpRuntimeHandle; mode: string }): Promise<void>;
setConfigOption(input: { handle: AcpRuntimeHandle; key: string; value: string }): Promise<void>;
cancel(input: { handle: AcpRuntimeHandle; reason?: string }): Promise<void>;
close(input: { handle: AcpRuntimeHandle; reason: string }): Promise<void>;
}
export function createAcpRuntime(...args: unknown[]): unknown;
export function createAgentRegistry(...args: unknown[]): AcpAgentRegistry;
export function createFileSessionStore(...args: unknown[]): AcpSessionStore;
export function decodeAcpxRuntimeHandleState(...args: unknown[]): unknown;
export function encodeAcpxRuntimeHandleState(...args: unknown[]): unknown;
}

View File

@@ -1,7 +1,6 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
import type { z } from "openclaw/plugin-sdk/zod";
import { AcpxPluginConfigSchema } from "./config-schema.js";
import type {
@@ -224,7 +223,7 @@ export function resolveAcpxPluginConfig(params: {
});
const agents = Object.fromEntries(
Object.entries(normalized.agents ?? {}).map(([name, entry]) => [
normalizeLowercaseStringOrEmpty(name),
name.trim().toLowerCase(),
entry.command.trim(),
]),
);

View File

@@ -1,6 +0,0 @@
export function formatErrorMessage(error) {
if (error instanceof Error) {
return error.message || error.name || "Error";
}
return String(error);
}

View File

@@ -4,7 +4,6 @@ import { spawn } from "node:child_process";
import path from "node:path";
import { createInterface } from "node:readline";
import { pathToFileURL } from "node:url";
import { formatErrorMessage } from "./error-format.mjs";
import { splitCommandLine } from "./mcp-command-line.mjs";
function decodePayload(argv) {
@@ -95,7 +94,7 @@ function main() {
child.stdout.pipe(process.stdout);
child.on("error", (error) => {
process.stderr.write(`${formatErrorMessage(error)}\n`);
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
process.exit(1);
});

View File

@@ -1,37 +1,74 @@
import type { AcpSessionStore } from "acpx/runtime";
import type { AcpRuntimeHandle, AcpRuntimeOptions, AcpSessionStore } from "acpx/runtime";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { AcpRuntime } from "../runtime-api.js";
import { AcpxRuntime } from "./runtime.js";
function makeRuntime(baseStore: AcpSessionStore): {
runtime: AcpxRuntime;
wrappedStore: AcpSessionStore & { markFresh: (sessionKey: string) => void };
delegate: { close: AcpRuntime["close"] };
} {
const runtime = new AcpxRuntime({
cwd: "/tmp",
sessionStore: baseStore,
agentRegistry: {
resolve: () => "codex",
list: () => ["codex"],
},
permissionMode: "approve-reads",
});
const mocks = vi.hoisted(() => {
const state = {
capturedStore: undefined as AcpSessionStore | undefined,
};
class MockAcpxRuntime {
constructor(options: AcpRuntimeOptions) {
state.capturedStore = options.sessionStore;
}
isHealthy() {
return true;
}
async probeAvailability() {}
async doctor() {
return { ok: true, message: "ok" };
}
async ensureSession() {
return {
sessionKey: "agent:codex:acp:binding:test",
backend: "acpx",
runtimeSessionName: "agent:codex:acp:binding:test",
} satisfies AcpRuntimeHandle;
}
async *runTurn() {}
getCapabilities() {
return { controls: [] };
}
async getStatus() {
return {};
}
async setMode() {}
async setConfigOption() {}
async cancel() {}
async close() {}
}
return {
runtime,
wrappedStore: (
runtime as unknown as {
sessionStore: AcpSessionStore & { markFresh: (sessionKey: string) => void };
}
).sessionStore,
delegate: (runtime as unknown as { delegate: { close: AcpRuntime["close"] } }).delegate,
state,
MockAcpxRuntime,
};
}
});
vi.mock("acpx/runtime", () => ({
ACPX_BACKEND_ID: "acpx",
AcpxRuntime: mocks.MockAcpxRuntime,
createAcpRuntime: vi.fn(),
createAgentRegistry: vi.fn(),
createFileSessionStore: vi.fn(),
decodeAcpxRuntimeHandleState: vi.fn(),
encodeAcpxRuntimeHandleState: vi.fn(),
}));
import { AcpxRuntime } from "./runtime.js";
describe("AcpxRuntime fresh reset wrapper", () => {
beforeEach(() => {
vi.restoreAllMocks();
mocks.state.capturedStore = undefined;
});
it("keeps stale persistent loads hidden until a fresh record is saved", async () => {
@@ -40,9 +77,20 @@ describe("AcpxRuntime fresh reset wrapper", () => {
save: vi.fn(async () => {}),
};
const { runtime, wrappedStore } = makeRuntime(baseStore);
const runtime = new AcpxRuntime({
cwd: "/tmp",
sessionStore: baseStore,
agentRegistry: {
resolve: () => "codex",
list: () => ["codex"],
},
permissionMode: "approve-reads",
});
expect(await wrappedStore.load("agent:codex:acp:binding:test")).toEqual({
const wrappedStore = mocks.state.capturedStore;
expect(wrappedStore).toBeDefined();
expect(await wrappedStore?.load("agent:codex:acp:binding:test")).toEqual({
acpxRecordId: "stale",
});
expect(baseStore.load).toHaveBeenCalledTimes(1);
@@ -51,17 +99,17 @@ describe("AcpxRuntime fresh reset wrapper", () => {
sessionKey: "agent:codex:acp:binding:test",
});
expect(await wrappedStore.load("agent:codex:acp:binding:test")).toBeUndefined();
expect(await wrappedStore?.load("agent:codex:acp:binding:test")).toBeUndefined();
expect(baseStore.load).toHaveBeenCalledTimes(1);
expect(await wrappedStore.load("agent:codex:acp:binding:test")).toBeUndefined();
expect(await wrappedStore?.load("agent:codex:acp:binding:test")).toBeUndefined();
expect(baseStore.load).toHaveBeenCalledTimes(1);
await wrappedStore.save({
await wrappedStore?.save({
acpxRecordId: "fresh-record",
name: "agent:codex:acp:binding:test",
} as never);
expect(await wrappedStore.load("agent:codex:acp:binding:test")).toEqual({
expect(await wrappedStore?.load("agent:codex:acp:binding:test")).toEqual({
acpxRecordId: "stale",
});
expect(baseStore.load).toHaveBeenCalledTimes(2);
@@ -73,8 +121,18 @@ describe("AcpxRuntime fresh reset wrapper", () => {
save: vi.fn(async () => {}),
};
const { runtime, wrappedStore, delegate } = makeRuntime(baseStore);
const close = vi.spyOn(delegate, "close").mockResolvedValue(undefined);
const runtime = new AcpxRuntime({
cwd: "/tmp",
sessionStore: baseStore,
agentRegistry: {
resolve: () => "codex",
list: () => ["codex"],
},
permissionMode: "approve-reads",
});
const wrappedStore = mocks.state.capturedStore;
expect(wrappedStore).toBeDefined();
await runtime.close({
handle: {
@@ -86,16 +144,7 @@ describe("AcpxRuntime fresh reset wrapper", () => {
discardPersistentState: true,
});
expect(close).toHaveBeenCalledWith({
handle: {
sessionKey: "agent:codex:acp:binding:test",
backend: "acpx",
runtimeSessionName: "agent:codex:acp:binding:test",
},
reason: "new-in-place-reset",
discardPersistentState: true,
});
expect(await wrappedStore.load("agent:codex:acp:binding:test")).toBeUndefined();
expect(await wrappedStore?.load("agent:codex:acp:binding:test")).toBeUndefined();
expect(baseStore.load).not.toHaveBeenCalled();
});
});

View File

@@ -131,7 +131,6 @@ export class AcpxRuntime implements AcpxRuntimeLike {
.close({
handle: input.handle,
reason: input.reason,
discardPersistentState: input.discardPersistentState,
})
.then(() => {
if (input.discardPersistentState) {

View File

@@ -37,8 +37,6 @@ async function makeTempDir(): Promise<string> {
afterEach(async () => {
runtimeRegistry.clear();
delete process.env.OPENCLAW_SKIP_ACPX_RUNTIME;
delete process.env.OPENCLAW_SKIP_ACPX_RUNTIME_PROBE;
for (const dir of tempDirs.splice(0)) {
await fs.rm(dir, { recursive: true, force: true });
}
@@ -142,50 +140,4 @@ describe("createAcpxRuntimeService", () => {
await service.stop?.(ctx);
});
it("can skip the embedded runtime probe via env", async () => {
process.env.OPENCLAW_SKIP_ACPX_RUNTIME_PROBE = "1";
const workspaceDir = await makeTempDir();
const ctx = createServiceContext(workspaceDir);
const probeAvailability = vi.fn(async () => {});
const service = createAcpxRuntimeService({
runtimeFactory: () =>
({
ensureSession: vi.fn(),
runTurn: vi.fn(),
cancel: vi.fn(),
close: vi.fn(),
probeAvailability,
isHealthy: () => false,
doctor: async () => ({ ok: false, message: "nope" }),
}) as never,
});
await service.start(ctx);
expect(probeAvailability).not.toHaveBeenCalled();
expect(getAcpRuntimeBackend("acpx")).toBeTruthy();
await service.stop?.(ctx);
});
it("can skip the embedded runtime backend via env", async () => {
process.env.OPENCLAW_SKIP_ACPX_RUNTIME = "1";
const workspaceDir = await makeTempDir();
const ctx = createServiceContext(workspaceDir);
const runtimeFactory = vi.fn(() => {
throw new Error("runtime factory should not run when ACPX is skipped");
});
const service = createAcpxRuntimeService({
runtimeFactory: runtimeFactory as never,
});
await service.start(ctx);
expect(runtimeFactory).not.toHaveBeenCalled();
expect(getAcpRuntimeBackend("acpx")).toBeUndefined();
expect(ctx.logger.info).toHaveBeenCalledWith(
"skipping embedded acpx runtime backend (OPENCLAW_SKIP_ACPX_RUNTIME=1)",
);
});
});

View File

@@ -1,5 +1,4 @@
import fs from "node:fs/promises";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import type {
AcpRuntime,
OpenClawPluginService,
@@ -91,11 +90,6 @@ export function createAcpxRuntimeService(
return {
id: "acpx-runtime",
async start(ctx: OpenClawPluginServiceContext): Promise<void> {
if (process.env.OPENCLAW_SKIP_ACPX_RUNTIME === "1") {
ctx.logger.info("skipping embedded acpx runtime backend (OPENCLAW_SKIP_ACPX_RUNTIME=1)");
return;
}
const pluginConfig = resolveAcpxPluginConfig({
rawConfig: params.pluginConfig,
workspaceDir: ctx.workspaceDir,
@@ -119,10 +113,6 @@ export function createAcpxRuntimeService(
});
ctx.logger.info(`embedded acpx runtime backend registered (cwd: ${pluginConfig.cwd})`);
if (process.env.OPENCLAW_SKIP_ACPX_RUNTIME_PROBE === "1") {
return;
}
lifecycleRevision += 1;
const currentRevision = lifecycleRevision;
void (async () => {
@@ -146,7 +136,9 @@ export function createAcpxRuntimeService(
if (currentRevision !== lifecycleRevision) {
return;
}
ctx.logger.warn(`embedded acpx runtime setup failed: ${formatErrorMessage(err)}`);
ctx.logger.warn(
`embedded acpx runtime setup failed: ${err instanceof Error ? err.message : String(err)}`,
);
}
})();
},

View File

@@ -1,16 +0,0 @@
{
"extends": "../tsconfig.package-boundary.base.json",
"compilerOptions": {
"rootDir": "."
},
"include": ["./*.ts", "./src/**/*.ts"],
"exclude": [
"./**/*.test.ts",
"./dist/**",
"./node_modules/**",
"./src/test-support/**",
"./src/**/*test-helpers.ts",
"./src/**/*test-harness.ts",
"./src/**/*test-support.ts"
]
}

View File

@@ -1,862 +0,0 @@
import fs from "node:fs/promises";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import plugin from "./index.js";
const hoisted = vi.hoisted(() => {
const sessionStore: Record<string, Record<string, unknown>> = {
"agent:main:main": {
sessionId: "s-main",
updatedAt: 0,
},
};
return {
sessionStore,
updateSessionStore: vi.fn(
async (_storePath: string, updater: (store: Record<string, unknown>) => void) => {
updater(sessionStore);
},
),
};
});
vi.mock("openclaw/plugin-sdk/config-runtime", async () => {
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/config-runtime")>(
"openclaw/plugin-sdk/config-runtime",
);
return {
...actual,
updateSessionStore: hoisted.updateSessionStore,
};
});
describe("active-memory plugin", () => {
const hooks: Record<string, Function> = {};
const runEmbeddedPiAgent = vi.fn();
const api: any = {
pluginConfig: {
agents: ["main"],
logging: true,
},
config: {},
id: "active-memory",
name: "Active Memory",
logger: { info: vi.fn(), warn: vi.fn(), debug: vi.fn(), error: vi.fn() },
runtime: {
agent: {
runEmbeddedPiAgent,
session: {
resolveStorePath: vi.fn(() => "/tmp/openclaw-session-store.json"),
loadSessionStore: vi.fn(() => hoisted.sessionStore),
saveSessionStore: vi.fn(async () => {}),
},
},
},
on: vi.fn((hookName: string, handler: Function) => {
hooks[hookName] = handler;
}),
};
beforeEach(() => {
vi.clearAllMocks();
api.pluginConfig = {
agents: ["main"],
logging: true,
};
hoisted.sessionStore["agent:main:main"] = {
sessionId: "s-main",
updatedAt: 0,
};
for (const key of Object.keys(hooks)) {
delete hooks[key];
}
runEmbeddedPiAgent.mockResolvedValue({
payloads: [{ text: "- lemon pepper wings\n- blue cheese" }],
});
plugin.register(api as unknown as OpenClawPluginApi);
});
afterEach(() => {
vi.restoreAllMocks();
});
it("registers a before_prompt_build hook", () => {
expect(api.on).toHaveBeenCalledWith("before_prompt_build", expect.any(Function));
});
it("does not run for agents that are not explicitly targeted", async () => {
const result = await hooks.before_prompt_build(
{ prompt: "what wings should i order?", messages: [] },
{
agentId: "support",
trigger: "user",
sessionKey: "agent:support:main",
messageProvider: "webchat",
},
);
expect(result).toBeUndefined();
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
it("does not rewrite session state for skipped turns with no active-memory entry to clear", async () => {
const result = await hooks.before_prompt_build(
{ prompt: "what wings should i order?", messages: [] },
{
agentId: "support",
trigger: "user",
sessionKey: "agent:support:main",
messageProvider: "webchat",
},
);
expect(result).toBeUndefined();
expect(hoisted.updateSessionStore).not.toHaveBeenCalled();
});
it("does not run for non-interactive contexts", async () => {
const result = await hooks.before_prompt_build(
{ prompt: "what wings should i order?", messages: [] },
{
agentId: "main",
trigger: "heartbeat",
sessionKey: "agent:main:main",
messageProvider: "webchat",
},
);
expect(result).toBeUndefined();
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
it("defaults to direct-style sessions only", async () => {
const result = await hooks.before_prompt_build(
{ prompt: "what wings should we order?", messages: [] },
{
agentId: "main",
trigger: "user",
sessionKey: "agent:main:telegram:group:-100123",
messageProvider: "telegram",
channelId: "telegram",
},
);
expect(result).toBeUndefined();
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
it("runs for group sessions when group chat types are explicitly allowed", async () => {
api.pluginConfig = {
agents: ["main"],
allowedChatTypes: ["direct", "group"],
};
plugin.register(api as unknown as OpenClawPluginApi);
const result = await hooks.before_prompt_build(
{ prompt: "what wings should we order?", messages: [] },
{
agentId: "main",
trigger: "user",
sessionKey: "agent:main:telegram:group:-100123",
messageProvider: "telegram",
channelId: "telegram",
},
);
expect(runEmbeddedPiAgent).toHaveBeenCalledTimes(1);
expect(result).toEqual({
prependSystemContext: expect.stringContaining("plugin-provided supplemental context"),
appendSystemContext: expect.stringContaining("<active_memory_plugin>"),
});
});
it("injects system context on a successful recall hit", async () => {
const result = await hooks.before_prompt_build(
{
prompt: "what wings should i order?",
messages: [
{ role: "user", content: "i want something greasy tonight" },
{ role: "assistant", content: "let's narrow it down" },
],
},
{
agentId: "main",
trigger: "user",
sessionKey: "agent:main:main",
messageProvider: "webchat",
},
);
expect(runEmbeddedPiAgent).toHaveBeenCalledTimes(1);
expect(result).toEqual({
prependSystemContext: expect.stringContaining("plugin-provided supplemental context"),
appendSystemContext: expect.stringContaining("<active_memory_plugin>"),
});
expect((result as { appendSystemContext: string }).appendSystemContext).toContain(
"lemon pepper wings",
);
expect(runEmbeddedPiAgent.mock.calls.at(-1)?.[0]).toMatchObject({
provider: "github-copilot",
model: "gpt-5.4-mini",
sessionKey: expect.stringMatching(/^agent:main:main:active-memory:[a-f0-9]{12}$/),
});
});
it("preserves leading digits in recalled memory bullets", async () => {
runEmbeddedPiAgent.mockResolvedValueOnce({
payloads: [{ text: "- 2024 trip to tokyo\n- 2% milk" }],
});
const result = await hooks.before_prompt_build(
{
prompt: "what should i remember from my 2024 trip and should i buy 2% milk?",
messages: [],
},
{
agentId: "main",
trigger: "user",
sessionKey: "agent:main:main",
messageProvider: "webchat",
},
);
expect(result).toEqual({
prependSystemContext: expect.stringContaining("plugin-provided supplemental context"),
appendSystemContext: expect.stringContaining("<active_memory_plugin>"),
});
expect((result as { appendSystemContext: string }).appendSystemContext).toContain(
"2024 trip to tokyo",
);
expect((result as { appendSystemContext: string }).appendSystemContext).toContain("2% milk");
});
it("preserves canonical parent session scope in the blocking memory subagent session key", async () => {
await hooks.before_prompt_build(
{ prompt: "what should i grab on the way?", messages: [] },
{
agentId: "main",
trigger: "user",
sessionKey: "agent:main:telegram:direct:12345:thread:99",
messageProvider: "telegram",
channelId: "telegram",
},
);
expect(runEmbeddedPiAgent.mock.calls.at(-1)?.[0]?.sessionKey).toMatch(
/^agent:main:telegram:direct:12345:thread:99:active-memory:[a-f0-9]{12}$/,
);
});
it("falls back to the current session model when no plugin model is configured", async () => {
api.pluginConfig = {
agents: ["main"],
};
plugin.register(api as unknown as OpenClawPluginApi);
await hooks.before_prompt_build(
{ prompt: "what wings should i order? temp transcript", messages: [] },
{
agentId: "main",
trigger: "user",
sessionKey: "agent:main:main",
messageProvider: "webchat",
modelProviderId: "qwen",
modelId: "glm-5",
},
);
expect(runEmbeddedPiAgent.mock.calls.at(-1)?.[0]).toMatchObject({
provider: "qwen",
model: "glm-5",
});
});
it("can disable default remote model fallback", async () => {
api.pluginConfig = {
agents: ["main"],
modelFallbackPolicy: "resolved-only",
};
plugin.register(api as unknown as OpenClawPluginApi);
const result = await hooks.before_prompt_build(
{ prompt: "what wings should i order? no fallback", messages: [] },
{
agentId: "main",
trigger: "user",
sessionKey: "agent:main:resolved-only",
messageProvider: "webchat",
},
);
expect(result).toBeUndefined();
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
it("persists a readable debug summary alongside the status line", async () => {
const sessionKey = "agent:main:debug";
hoisted.sessionStore[sessionKey] = {
sessionId: "s-main",
updatedAt: 0,
};
await hooks.before_prompt_build(
{
prompt: "what wings should i order?",
messages: [],
},
{ agentId: "main", trigger: "user", sessionKey, messageProvider: "webchat" },
);
expect(hoisted.updateSessionStore).toHaveBeenCalled();
const updater = hoisted.updateSessionStore.mock.calls.at(-1)?.[1] as
| ((store: Record<string, Record<string, unknown>>) => void)
| undefined;
const store = {
[sessionKey]: {
sessionId: "s-main",
updatedAt: 0,
},
} as Record<string, Record<string, unknown>>;
updater?.(store);
expect(store[sessionKey]?.pluginDebugEntries).toEqual([
{
pluginId: "active-memory",
lines: expect.arrayContaining([
expect.stringContaining("🧩 Active Memory: ok"),
expect.stringContaining("🔎 Active Memory Debug: lemon pepper wings"),
]),
},
]);
});
it("replaces stale legacy active-memory lines on a later empty run", async () => {
const sessionKey = "agent:main:legacy-active-memory-lines";
hoisted.sessionStore[sessionKey] = {
sessionId: "s-main",
updatedAt: 0,
pluginStatusLines: [
"Active Memory: ok 13.4s recent 1 mem",
"Active Memory Debug: Favorite desk snack: roasted almonds or cashews.",
"Other Plugin: keep me",
],
};
runEmbeddedPiAgent.mockResolvedValueOnce({
payloads: [{ text: "NONE" }],
});
await hooks.before_prompt_build(
{ prompt: "what's up with you?", messages: [] },
{ agentId: "main", trigger: "user", sessionKey, messageProvider: "webchat" },
);
const updater = hoisted.updateSessionStore.mock.calls.at(-1)?.[1] as
| ((store: Record<string, Record<string, unknown>>) => void)
| undefined;
const store = {
[sessionKey]: {
sessionId: "s-main",
updatedAt: 0,
pluginStatusLines: [
"Active Memory: ok 13.4s recent 1 mem",
"Active Memory Debug: Favorite desk snack: roasted almonds or cashews.",
"Other Plugin: keep me",
],
},
} as Record<string, Record<string, unknown>>;
updater?.(store);
expect(store[sessionKey]?.pluginDebugEntries).toEqual([
{
pluginId: "active-memory",
lines: [expect.stringContaining("🧩 Active Memory: empty")],
},
]);
expect(store[sessionKey]?.pluginStatusLines).toEqual(["Other Plugin: keep me"]);
});
it("returns nothing when the sidecar says none", async () => {
runEmbeddedPiAgent.mockResolvedValueOnce({
payloads: [{ text: "NONE" }],
});
const result = await hooks.before_prompt_build(
{ prompt: "fair, okay gonna do them by throwing them in the garbage", messages: [] },
{
agentId: "main",
trigger: "user",
sessionKey: "agent:main:main",
messageProvider: "webchat",
},
);
expect(result).toBeUndefined();
});
it("does not cache timeout results", async () => {
api.pluginConfig = {
agents: ["main"],
timeoutMs: 250,
logging: true,
};
plugin.register(api as unknown as OpenClawPluginApi);
let lastAbortSignal: AbortSignal | undefined;
runEmbeddedPiAgent.mockImplementation(async (params: { abortSignal?: AbortSignal }) => {
lastAbortSignal = params.abortSignal;
return await new Promise((resolve, reject) => {
const abortHandler = () => reject(new Error("aborted"));
params.abortSignal?.addEventListener("abort", abortHandler, { once: true });
setTimeout(() => {
params.abortSignal?.removeEventListener("abort", abortHandler);
resolve({ payloads: [] });
}, 2_000);
});
});
await hooks.before_prompt_build(
{ prompt: "what wings should i order? timeout test", messages: [] },
{
agentId: "main",
trigger: "user",
sessionKey: "agent:main:timeout-test",
messageProvider: "webchat",
},
);
await hooks.before_prompt_build(
{ prompt: "what wings should i order? timeout test", messages: [] },
{
agentId: "main",
trigger: "user",
sessionKey: "agent:main:timeout-test",
messageProvider: "webchat",
},
);
expect(hoisted.updateSessionStore).toHaveBeenCalledTimes(2);
expect(lastAbortSignal?.aborted).toBe(true);
const infoLines = vi
.mocked(api.logger.info)
.mock.calls.map((call: unknown[]) => String(call[0]));
expect(infoLines.some((line: string) => line.includes(" cached "))).toBe(false);
});
it("does not share cached recall results across session-id-only contexts", async () => {
api.pluginConfig = {
agents: ["main"],
logging: true,
};
plugin.register(api as unknown as OpenClawPluginApi);
await hooks.before_prompt_build(
{ prompt: "what wings should i order? session id cache", messages: [] },
{
agentId: "main",
trigger: "user",
sessionId: "session-a",
messageProvider: "webchat",
},
);
await hooks.before_prompt_build(
{ prompt: "what wings should i order? session id cache", messages: [] },
{
agentId: "main",
trigger: "user",
sessionId: "session-b",
messageProvider: "webchat",
},
);
expect(runEmbeddedPiAgent).toHaveBeenCalledTimes(2);
const infoLines = vi
.mocked(api.logger.info)
.mock.calls.map((call: unknown[]) => String(call[0]));
expect(infoLines.some((line: string) => line.includes(" cached "))).toBe(false);
});
it("uses a canonical agent session key when only sessionId is available", async () => {
hoisted.sessionStore["agent:main:telegram:direct:12345"] = {
sessionId: "session-a",
updatedAt: 25,
};
await hooks.before_prompt_build(
{ prompt: "what wings should i order? session id only", messages: [] },
{
agentId: "main",
trigger: "user",
sessionId: "session-a",
messageProvider: "webchat",
},
);
expect(runEmbeddedPiAgent.mock.calls.at(-1)?.[0]?.sessionKey).toMatch(
/^agent:main:telegram:direct:12345:active-memory:[a-f0-9]{12}$/,
);
});
it("clears stale status on skipped non-interactive turns even when agentId is missing", async () => {
const sessionKey = "agent:main:missing-agent";
hoisted.sessionStore[sessionKey] = {
sessionId: "s-main",
updatedAt: 0,
pluginDebugEntries: [
{ pluginId: "active-memory", lines: ["🧩 Active Memory: timeout 15s recent"] },
],
};
const result = await hooks.before_prompt_build(
{ prompt: "what wings should i order?", messages: [] },
{ trigger: "heartbeat", sessionKey, messageProvider: "webchat" },
);
expect(result).toBeUndefined();
const updater = hoisted.updateSessionStore.mock.calls.at(-1)?.[1] as
| ((store: Record<string, Record<string, unknown>>) => void)
| undefined;
const store = {
[sessionKey]: {
sessionId: "s-main",
updatedAt: 0,
pluginDebugEntries: [
{ pluginId: "active-memory", lines: ["🧩 Active Memory: timeout 15s recent"] },
],
},
} as Record<string, Record<string, unknown>>;
updater?.(store);
expect(store[sessionKey]?.pluginDebugEntries).toBeUndefined();
});
it("supports message mode by sending only the latest user message", async () => {
api.pluginConfig = {
agents: ["main"],
queryMode: "message",
};
plugin.register(api as unknown as OpenClawPluginApi);
await hooks.before_prompt_build(
{
prompt: "what should i grab on the way?",
messages: [
{ role: "user", content: "i have a flight tomorrow" },
{ role: "assistant", content: "got it" },
],
},
{
agentId: "main",
trigger: "user",
sessionKey: "agent:main:main",
messageProvider: "webchat",
},
);
const prompt = runEmbeddedPiAgent.mock.calls.at(-1)?.[0]?.prompt;
expect(prompt).toContain("Conversation context:\nwhat should i grab on the way?");
expect(prompt).not.toContain("Recent conversation tail:");
});
it("supports full mode by sending the whole conversation", async () => {
api.pluginConfig = {
agents: ["main"],
queryMode: "full",
};
plugin.register(api as unknown as OpenClawPluginApi);
await hooks.before_prompt_build(
{
prompt: "what should i grab on the way?",
messages: [
{ role: "user", content: "i have a flight tomorrow" },
{ role: "assistant", content: "got it" },
{ role: "user", content: "packing is annoying" },
],
},
{
agentId: "main",
trigger: "user",
sessionKey: "agent:main:main",
messageProvider: "webchat",
},
);
const prompt = runEmbeddedPiAgent.mock.calls.at(-1)?.[0]?.prompt;
expect(prompt).toContain("Full conversation context:");
expect(prompt).toContain("user: i have a flight tomorrow");
expect(prompt).toContain("assistant: got it");
expect(prompt).toContain("user: packing is annoying");
});
it("strips prior memory/debug traces from assistant context before retrieval", async () => {
api.pluginConfig = {
agents: ["main"],
queryMode: "recent",
};
plugin.register(api as unknown as OpenClawPluginApi);
await hooks.before_prompt_build(
{
prompt: "what should i grab on the way?",
messages: [
{ role: "user", content: "i have a flight tomorrow" },
{
role: "assistant",
content:
"🧠 Memory Search: favorite food comfort food tacos sushi ramen\n🧩 Active Memory: ok 842ms recent 2 mem\n🔎 Active Memory Debug: spicy ramen; tacos\nSounds like you want something easy before the airport.",
},
],
},
{
agentId: "main",
trigger: "user",
sessionKey: "agent:main:main",
messageProvider: "webchat",
},
);
const prompt = runEmbeddedPiAgent.mock.calls.at(-1)?.[0]?.prompt;
expect(prompt).toContain(
"ignore that text and do not search for those same surfaced memories again",
);
expect(prompt).toContain("assistant: Sounds like you want something easy before the airport.");
expect(prompt).not.toContain("Memory Search:");
expect(prompt).not.toContain("Active Memory:");
expect(prompt).not.toContain("Active Memory Debug:");
expect(prompt).not.toContain("spicy ramen; tacos");
});
it("trusts the subagent's relevance decision for explicit preference recall prompts", async () => {
runEmbeddedPiAgent.mockResolvedValueOnce({
payloads: [{ text: "- aisle seat\n- extra buffer on connections" }],
});
const result = await hooks.before_prompt_build(
{ prompt: "u remember my flight preferences", messages: [] },
{
agentId: "main",
trigger: "user",
sessionKey: "agent:main:main",
messageProvider: "webchat",
},
);
expect(result).toEqual({
prependSystemContext: expect.stringContaining("plugin-provided supplemental context"),
appendSystemContext: expect.stringContaining("aisle seat"),
});
expect((result as { appendSystemContext: string }).appendSystemContext).toContain(
"extra buffer on connections",
);
});
it("applies total summary truncation after normalizing the sidecar reply", async () => {
api.pluginConfig = {
agents: ["main"],
maxSummaryChars: 40,
};
plugin.register(api as unknown as OpenClawPluginApi);
runEmbeddedPiAgent.mockResolvedValueOnce({
payloads: [
{
text: "- lemon pepper wings with extra crisp skin\n- blue cheese dressing on the side",
},
],
});
const result = await hooks.before_prompt_build(
{ prompt: "what wings should i order?", messages: [] },
{
agentId: "main",
trigger: "user",
sessionKey: "agent:main:main",
messageProvider: "webchat",
},
);
expect(result).toEqual({
prependSystemContext: expect.stringContaining("plugin-provided supplemental context"),
appendSystemContext: expect.stringContaining("lemon pepper wings"),
});
expect((result as { appendSystemContext: string }).appendSystemContext).not.toContain(
"dressing on the side",
);
});
it("uses the configured maxSummaryChars value in the sidecar prompt", async () => {
api.pluginConfig = {
agents: ["main"],
maxSummaryChars: 90,
};
plugin.register(api as unknown as OpenClawPluginApi);
await hooks.before_prompt_build(
{ prompt: "what wings should i order? prompt-count-check", messages: [] },
{
agentId: "main",
trigger: "user",
sessionKey: "agent:main:prompt-count-check",
messageProvider: "webchat",
},
);
expect(runEmbeddedPiAgent.mock.calls.at(-1)?.[0]?.prompt).toContain(
"If something is useful, reply with one compact active-memory summary under 90 characters total.",
);
});
it("keeps sidecar transcripts off disk by default by using a temp session file", async () => {
const mkdtempSpy = vi
.spyOn(fs, "mkdtemp")
.mockResolvedValue("/tmp/openclaw-active-memory-temp");
const rmSpy = vi.spyOn(fs, "rm").mockResolvedValue(undefined);
await hooks.before_prompt_build(
{ prompt: "what wings should i order? temp transcript path", messages: [] },
{
agentId: "main",
trigger: "user",
sessionKey: "agent:main:main",
messageProvider: "webchat",
},
);
expect(mkdtempSpy).toHaveBeenCalled();
expect(runEmbeddedPiAgent.mock.calls.at(-1)?.[0]?.sessionFile).toBe(
"/tmp/openclaw-active-memory-temp/session.jsonl",
);
expect(rmSpy).toHaveBeenCalledWith("/tmp/openclaw-active-memory-temp", {
recursive: true,
force: true,
});
});
it("persists sidecar transcripts in a separate directory when enabled", async () => {
api.pluginConfig = {
agents: ["main"],
persistTranscripts: true,
transcriptDir: "active-memory-sidecars",
logging: true,
};
plugin.register(api as unknown as OpenClawPluginApi);
const mkdirSpy = vi.spyOn(fs, "mkdir").mockResolvedValue(undefined);
const mkdtempSpy = vi.spyOn(fs, "mkdtemp");
const rmSpy = vi.spyOn(fs, "rm").mockResolvedValue(undefined);
const sessionKey = "agent:main:persist-transcript";
await hooks.before_prompt_build(
{ prompt: "what wings should i order? persist transcript", messages: [] },
{ agentId: "main", trigger: "user", sessionKey, messageProvider: "webchat" },
);
expect(mkdirSpy).toHaveBeenCalledWith("/tmp/active-memory-sidecars", { recursive: true });
expect(mkdtempSpy).not.toHaveBeenCalled();
expect(runEmbeddedPiAgent.mock.calls.at(-1)?.[0]?.sessionFile).toMatch(
/^\/tmp\/active-memory-sidecars\/active-memory-[a-z0-9]+-[a-f0-9]{8}\.jsonl$/,
);
expect(rmSpy).not.toHaveBeenCalled();
expect(
vi
.mocked(api.logger.info)
.mock.calls.some((call: unknown[]) =>
String(call[0]).includes("transcript=/tmp/active-memory-sidecars/"),
),
).toBe(true);
});
it("falls back to the default transcript directory when transcriptDir is unsafe", async () => {
api.pluginConfig = {
agents: ["main"],
persistTranscripts: true,
transcriptDir: "C:/temp/escape",
logging: true,
};
plugin.register(api as unknown as OpenClawPluginApi);
const mkdirSpy = vi.spyOn(fs, "mkdir").mockResolvedValue(undefined);
await hooks.before_prompt_build(
{ prompt: "what wings should i order? unsafe transcript dir", messages: [] },
{
agentId: "main",
trigger: "user",
sessionKey: "agent:main:unsafe-transcript",
messageProvider: "webchat",
},
);
expect(mkdirSpy).toHaveBeenCalledWith("/tmp/active-memory", { recursive: true });
expect(runEmbeddedPiAgent.mock.calls.at(-1)?.[0]?.sessionFile).toMatch(
/^\/tmp\/active-memory\/active-memory-[a-z0-9]+-[a-f0-9]{8}\.jsonl$/,
);
});
it("sanitizes control characters out of debug lines", async () => {
const sessionKey = "agent:main:debug-sanitize";
hoisted.sessionStore[sessionKey] = {
sessionId: "s-main",
updatedAt: 0,
};
runEmbeddedPiAgent.mockResolvedValueOnce({
payloads: [{ text: "- spicy ramen\u001b[31m\n- fries\r\n- blue cheese\t" }],
});
await hooks.before_prompt_build(
{ prompt: "what should i order?", messages: [] },
{ agentId: "main", trigger: "user", sessionKey, messageProvider: "webchat" },
);
const updater = hoisted.updateSessionStore.mock.calls.at(-1)?.[1] as
| ((store: Record<string, Record<string, unknown>>) => void)
| undefined;
const store = {
[sessionKey]: {
sessionId: "s-main",
updatedAt: 0,
},
} as Record<string, Record<string, unknown>>;
updater?.(store);
const lines =
(store[sessionKey]?.pluginDebugEntries as Array<{ lines?: string[] }> | undefined)?.[0]
?.lines ?? [];
expect(lines.some((line) => line.includes("\u001b"))).toBe(false);
expect(lines.some((line) => line.includes("\r"))).toBe(false);
});
it("caps the active-memory cache size and evicts the oldest entries", async () => {
api.pluginConfig = {
agents: ["main"],
logging: true,
};
plugin.register(api as unknown as OpenClawPluginApi);
for (let index = 0; index <= 1000; index += 1) {
await hooks.before_prompt_build(
{ prompt: `cache pressure prompt ${index}`, messages: [] },
{
agentId: "main",
trigger: "user",
sessionKey: "agent:main:cache-cap",
messageProvider: "webchat",
},
);
}
const callsBeforeReplay = runEmbeddedPiAgent.mock.calls.length;
await hooks.before_prompt_build(
{ prompt: "cache pressure prompt 0", messages: [] },
{
agentId: "main",
trigger: "user",
sessionKey: "agent:main:cache-cap",
messageProvider: "webchat",
},
);
expect(runEmbeddedPiAgent.mock.calls.length).toBe(callsBeforeReplay + 1);
const infoLines = vi
.mocked(api.logger.info)
.mock.calls.map((call: unknown[]) => String(call[0]));
expect(
infoLines.some(
(line: string) => line.includes("cached status=ok") && line.includes("prompt 0"),
),
).toBe(false);
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -1,82 +0,0 @@
{
"id": "active-memory",
"name": "Active Memory",
"description": "Runs a bounded blocking memory subagent before eligible conversational replies and injects relevant memory into prompt context.",
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {
"agents": {
"type": "array",
"items": { "type": "string" }
},
"model": { "type": "string" },
"modelFallbackPolicy": {
"type": "string",
"enum": ["default-remote", "resolved-only"]
},
"allowedChatTypes": {
"type": "array",
"items": {
"type": "string",
"enum": ["direct", "group", "channel"]
}
},
"timeoutMs": { "type": "integer", "minimum": 250 },
"queryMode": {
"type": "string",
"enum": ["message", "recent", "full"]
},
"maxSummaryChars": { "type": "integer", "minimum": 40, "maximum": 1000 },
"recentUserTurns": { "type": "integer", "minimum": 0, "maximum": 4 },
"recentAssistantTurns": { "type": "integer", "minimum": 0, "maximum": 3 },
"recentUserChars": { "type": "integer", "minimum": 40, "maximum": 1000 },
"recentAssistantChars": { "type": "integer", "minimum": 40, "maximum": 1000 },
"logging": { "type": "boolean" },
"persistTranscripts": { "type": "boolean" },
"transcriptDir": { "type": "string" },
"cacheTtlMs": { "type": "integer", "minimum": 1000, "maximum": 120000 }
}
},
"uiHints": {
"agents": {
"label": "Target Agents",
"help": "Explicit agent ids that may use active memory."
},
"model": {
"label": "Memory Model",
"help": "Provider/model used for the blocking memory subagent."
},
"modelFallbackPolicy": {
"label": "Model Fallback Policy",
"help": "Choose whether Active Memory falls back to the built-in remote default model when no explicit or inherited model is available."
},
"allowedChatTypes": {
"label": "Allowed Chat Types",
"help": "Choose which session types may run Active Memory. Defaults to direct-message style sessions only."
},
"timeoutMs": {
"label": "Timeout (ms)"
},
"queryMode": {
"label": "Query Mode",
"help": "Choose whether the blocking memory subagent sees only the latest user message, a small recent tail, or the full conversation."
},
"maxSummaryChars": {
"label": "Max Summary Characters",
"help": "Maximum total characters allowed in the active-memory summary."
},
"logging": {
"label": "Enable Logging",
"help": "Emit active memory timing and result logs."
},
"persistTranscripts": {
"label": "Persist Transcripts",
"help": "Keep blocking memory subagent session transcripts on disk in a separate plugin-owned directory."
},
"transcriptDir": {
"label": "Transcript Directory",
"help": "Relative directory under the agent sessions folder used when transcript persistence is enabled."
}
}
}

View File

@@ -4,9 +4,6 @@
"private": true,
"description": "OpenClaw Alibaba Model Studio video provider plugin",
"type": "module",
"devDependencies": {
"@openclaw/plugin-sdk": "workspace:*"
},
"openclaw": {
"extensions": [
"./index.ts"

View File

@@ -1,16 +0,0 @@
{
"extends": "../tsconfig.package-boundary.base.json",
"compilerOptions": {
"rootDir": "."
},
"include": ["./*.ts", "./src/**/*.ts"],
"exclude": [
"./**/*.test.ts",
"./dist/**",
"./node_modules/**",
"./src/test-support/**",
"./src/**/*test-helpers.ts",
"./src/**/*test-harness.ts",
"./src/**/*test-support.ts"
]
}

View File

@@ -1,5 +1,4 @@
import { createSubsystemLogger } from "openclaw/plugin-sdk/core";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import type {
ModelDefinitionConfig,
ModelProviderConfig,
@@ -103,7 +102,7 @@ export async function generateBearerTokenFromIam(params: {
} catch (error) {
log.debug?.("Mantle IAM token generation unavailable", {
region: params.region,
error: formatErrorMessage(error),
error: error instanceof Error ? error.message : String(error),
});
return undefined;
}
@@ -234,7 +233,7 @@ export async function discoverMantleModels(params: {
return models;
} catch (error) {
log.debug?.("Mantle model discovery error", {
error: formatErrorMessage(error),
error: error instanceof Error ? error.message : String(error),
});
return cached?.models ?? [];
}

View File

@@ -7,9 +7,6 @@
"dependencies": {
"@aws/bedrock-token-generator": "^1.1.0"
},
"devDependencies": {
"@openclaw/plugin-sdk": "workspace:*"
},
"openclaw": {
"bundle": {
"stageRuntimeDependencies": true

View File

@@ -1,16 +0,0 @@
{
"extends": "../tsconfig.package-boundary.base.json",
"compilerOptions": {
"rootDir": "."
},
"include": ["./*.ts", "./src/**/*.ts"],
"exclude": [
"./**/*.test.ts",
"./dist/**",
"./node_modules/**",
"./src/test-support/**",
"./src/**/*test-helpers.ts",
"./src/**/*test-harness.ts",
"./src/**/*test-support.ts"
]
}

View File

@@ -1,11 +1,13 @@
import { isRecord } from "openclaw/plugin-sdk/text-runtime";
type JsonRecord = Record<string, unknown>;
const LEGACY_PATH = "models.bedrockDiscovery";
const TARGET_PATH = "plugins.entries.amazon-bedrock.config.discovery";
const BLOCKED_OBJECT_KEYS = new Set(["__proto__", "prototype", "constructor"]);
function isRecord(value: unknown): value is JsonRecord {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isBlockedObjectKey(key: string): boolean {
return BLOCKED_OBJECT_KEYS.has(key);
}

View File

@@ -6,17 +6,12 @@ import {
type ListInferenceProfilesCommandOutput,
} from "@aws-sdk/client-bedrock";
import { createSubsystemLogger } from "openclaw/plugin-sdk/core";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { resolveAwsSdkEnvVarName } from "openclaw/plugin-sdk/provider-auth-runtime";
import type {
BedrockDiscoveryConfig,
ModelDefinitionConfig,
ModelProviderConfig,
} from "openclaw/plugin-sdk/provider-model-shared";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalLowercaseString,
} from "openclaw/plugin-sdk/text-runtime";
const log = createSubsystemLogger("bedrock-discovery");
@@ -54,9 +49,7 @@ function normalizeProviderFilter(filter?: string[]): string[] {
return [];
}
const normalized = new Set(
filter
.map((entry) => normalizeOptionalLowercaseString(entry))
.filter((entry): entry is string => Boolean(entry)),
filter.map((entry) => entry.trim().toLowerCase()).filter((entry) => entry.length > 0),
);
return Array.from(normalized).toSorted();
}
@@ -72,7 +65,7 @@ function buildCacheKey(params: {
}
function includesTextModalities(modalities?: Array<string>): boolean {
return (modalities ?? []).some((entry) => normalizeOptionalLowercaseString(entry) === "text");
return (modalities ?? []).some((entry) => entry.toLowerCase() === "text");
}
function isActive(summary: BedrockModelSummary): boolean {
@@ -84,7 +77,7 @@ function mapInputModalities(summary: BedrockModelSummary): Array<"text" | "image
const inputs = summary.inputModalities ?? [];
const mapped = new Set<"text" | "image">();
for (const modality of inputs) {
const lower = normalizeOptionalLowercaseString(modality);
const lower = modality.toLowerCase();
if (lower === "text") {
mapped.add("text");
}
@@ -99,9 +92,7 @@ function mapInputModalities(summary: BedrockModelSummary): Array<"text" | "image
}
function inferReasoningSupport(summary: BedrockModelSummary): boolean {
const haystack = normalizeLowercaseStringOrEmpty(
`${summary.modelId ?? ""} ${summary.modelName ?? ""}`,
);
const haystack = `${summary.modelId ?? ""} ${summary.modelName ?? ""}`.toLowerCase();
return haystack.includes("reasoning") || haystack.includes("thinking");
}
@@ -126,7 +117,7 @@ function matchesProviderFilter(summary: BedrockModelSummary, filter: string[]):
const providerName =
summary.providerName ??
(typeof summary.modelId === "string" ? summary.modelId.split(".")[0] : undefined);
const normalized = normalizeOptionalLowercaseString(providerName);
const normalized = providerName?.trim().toLowerCase();
if (!normalized) {
return false;
}
@@ -223,7 +214,7 @@ async function fetchInferenceProfileSummaries(
return profiles;
} catch (error) {
log.debug?.("Skipping inference profile discovery", {
error: formatErrorMessage(error),
error: error instanceof Error ? error.message : String(error),
});
return [];
}
@@ -261,9 +252,7 @@ function resolveInferenceProfiles(
const models = profile.models ?? [];
const matchesFilter = models.some((m) => {
const provider = m.modelArn?.split("/")?.[1]?.split(".")?.[0];
return provider
? providerFilter.includes(normalizeOptionalLowercaseString(provider) ?? "")
: false;
return provider ? providerFilter.includes(provider.toLowerCase()) : false;
});
if (!matchesFilter) {
continue;
@@ -272,9 +261,7 @@ function resolveInferenceProfiles(
// Look up the underlying foundation model to inherit its capabilities.
const baseModelId = resolveBaseModelId(profile);
const baseModel = baseModelId
? foundationModels.get(normalizeLowercaseStringOrEmpty(baseModelId))
: undefined;
const baseModel = baseModelId ? foundationModels.get(baseModelId.toLowerCase()) : undefined;
discovered.push({
id: profile.inferenceProfileId,
@@ -365,9 +352,8 @@ export async function discoverBedrockModels(params: {
maxTokens: defaultMaxTokens,
});
discovered.push(def);
const normalizedId = normalizeLowercaseStringOrEmpty(def.id);
seenIds.add(normalizedId);
foundationModels.set(normalizedId, def);
seenIds.add(def.id.toLowerCase());
foundationModels.set(def.id.toLowerCase(), def);
}
// Merge inference profiles — inherit capabilities from foundation models.
@@ -378,10 +364,9 @@ export async function discoverBedrockModels(params: {
foundationModels,
);
for (const profile of inferenceProfiles) {
const normalizedId = normalizeLowercaseStringOrEmpty(profile.id);
if (!seenIds.has(normalizedId)) {
if (!seenIds.has(profile.id.toLowerCase())) {
discovered.push(profile);
seenIds.add(normalizedId);
seenIds.add(profile.id.toLowerCase());
}
}
@@ -421,7 +406,7 @@ export async function discoverBedrockModels(params: {
if (!hasLoggedBedrockError) {
hasLoggedBedrockError = true;
log.warn("Failed to discover Bedrock models", {
error: formatErrorMessage(error),
error: error instanceof Error ? error.message : String(error),
});
}
return [];

View File

@@ -34,9 +34,6 @@
}
}
},
"configContracts": {
"compatibilityMigrationPaths": ["models.bedrockDiscovery"]
},
"uiHints": {
"discovery": {
"label": "Model Discovery",

View File

@@ -7,9 +7,6 @@
"dependencies": {
"@aws-sdk/client-bedrock": "3.1024.0"
},
"devDependencies": {
"@openclaw/plugin-sdk": "workspace:*"
},
"openclaw": {
"bundle": {
"stageRuntimeDependencies": true

View File

@@ -1,16 +0,0 @@
{
"extends": "../tsconfig.package-boundary.base.json",
"compilerOptions": {
"rootDir": "."
},
"include": ["./*.ts", "./src/**/*.ts"],
"exclude": [
"./**/*.test.ts",
"./dist/**",
"./node_modules/**",
"./src/test-support/**",
"./src/**/*test-helpers.ts",
"./src/**/*test-harness.ts",
"./src/**/*test-support.ts"
]
}

View File

@@ -1,3 +1,4 @@
import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-model-shared";
export {
ANTHROPIC_VERTEX_DEFAULT_MODEL_ID,
buildAnthropicVertexProvider,
@@ -15,9 +16,9 @@ import { buildAnthropicVertexProvider } from "./provider-catalog.js";
import { hasAnthropicVertexAvailableAuth } from "./region.js";
export function mergeImplicitAnthropicVertexProvider(params: {
existing?: ReturnType<typeof buildAnthropicVertexProvider>;
implicit: ReturnType<typeof buildAnthropicVertexProvider>;
}) {
existing: ModelProviderConfig | undefined;
implicit: ModelProviderConfig;
}): ModelProviderConfig {
const { existing, implicit } = params;
if (!existing) {
return implicit;
@@ -32,7 +33,9 @@ export function mergeImplicitAnthropicVertexProvider(params: {
};
}
export function resolveImplicitAnthropicVertexProvider(params?: { env?: NodeJS.ProcessEnv }) {
export function resolveImplicitAnthropicVertexProvider(params?: {
env?: NodeJS.ProcessEnv;
}): ModelProviderConfig | null {
const env = params?.env ?? process.env;
if (!hasAnthropicVertexAvailableAuth(env)) {
return null;

View File

@@ -4,9 +4,6 @@
"private": true,
"description": "OpenClaw Anthropic Vertex provider plugin",
"type": "module",
"devDependencies": {
"@openclaw/plugin-sdk": "workspace:*"
},
"openclaw": {
"extensions": [
"./index.ts"

View File

@@ -2,7 +2,6 @@ import { readFileSync } from "node:fs";
import { homedir, platform } from "node:os";
import { join } from "node:path";
import { resolveProviderEndpoint } from "openclaw/plugin-sdk/provider-http";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
const ANTHROPIC_VERTEX_DEFAULT_REGION = "global";
const ANTHROPIC_VERTEX_REGION_RE = /^[a-z0-9-]+$/;
@@ -65,10 +64,7 @@ export function resolveAnthropicVertexClientRegion(params?: {
function hasAnthropicVertexMetadataServerAdc(env: NodeJS.ProcessEnv = process.env): boolean {
const explicitMetadataOptIn = normalizeOptionalSecretInput(env.ANTHROPIC_VERTEX_USE_GCP_METADATA);
return (
explicitMetadataOptIn === "1" ||
normalizeLowercaseStringOrEmpty(explicitMetadataOptIn) === "true"
);
return explicitMetadataOptIn === "1" || explicitMetadataOptIn?.toLowerCase() === "true";
}
function resolveAnthropicVertexDefaultAdcPath(env: NodeJS.ProcessEnv = process.env): string {

View File

@@ -1,16 +0,0 @@
{
"extends": "../tsconfig.package-boundary.base.json",
"compilerOptions": {
"rootDir": "."
},
"include": ["./*.ts", "./src/**/*.ts"],
"exclude": [
"./**/*.test.ts",
"./dist/**",
"./node_modules/**",
"./src/test-support/**",
"./src/**/*test-helpers.ts",
"./src/**/*test-harness.ts",
"./src/**/*test-support.ts"
]
}

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