mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-28 10:21:45 +08:00
Compare commits
39 Commits
codex-mode
...
fix/subage
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3d845e2519 | ||
|
|
8d50a3a9c4 | ||
|
|
4c7a94aac4 | ||
|
|
434c8a1c91 | ||
|
|
04575333d3 | ||
|
|
50558e0d56 | ||
|
|
8fe449c883 | ||
|
|
8b32c31252 | ||
|
|
2e101e8413 | ||
|
|
a77996dc56 | ||
|
|
5e8fda4c64 | ||
|
|
76cf013df5 | ||
|
|
450dc3a206 | ||
|
|
7b438965bd | ||
|
|
c5bbf83904 | ||
|
|
f4f74a2391 | ||
|
|
0c8f0aacf5 | ||
|
|
1de4aff06d | ||
|
|
5b9be2cdb1 | ||
|
|
9d6e79019f | ||
|
|
b5e4e2f257 | ||
|
|
59d1fa65df | ||
|
|
6428440086 | ||
|
|
d419fb561d | ||
|
|
6c60cd2b72 | ||
|
|
1ee5654220 | ||
|
|
54f8e4145e | ||
|
|
d1e5f4bd3c | ||
|
|
3ad29972d0 | ||
|
|
43557b16a6 | ||
|
|
fd97f530e3 | ||
|
|
bbed91bf71 | ||
|
|
49b106d357 | ||
|
|
7a7728db13 | ||
|
|
aee4c92344 | ||
|
|
78fb0ade09 | ||
|
|
f48dc96d43 | ||
|
|
ff7f0df871 | ||
|
|
4ee537a04a |
30
.gitignore
vendored
30
.gitignore
vendored
@@ -97,6 +97,36 @@ USER.md
|
||||
# local tooling
|
||||
.serena/
|
||||
|
||||
# Local project-agent skill installs. Only repo-owned skills are visible by
|
||||
# default; promoting a new repo skill should require an intentional `git add -f`.
|
||||
.agents/skills/*
|
||||
!.agents/skills/blacksmith-testbox/
|
||||
!.agents/skills/blacksmith-testbox/**
|
||||
!.agents/skills/openclaw-ghsa-maintainer/
|
||||
!.agents/skills/openclaw-ghsa-maintainer/**
|
||||
!.agents/skills/openclaw-parallels-smoke/
|
||||
!.agents/skills/openclaw-parallels-smoke/**
|
||||
!.agents/skills/openclaw-pr-maintainer/
|
||||
!.agents/skills/openclaw-pr-maintainer/**
|
||||
!.agents/skills/openclaw-qa-testing/
|
||||
!.agents/skills/openclaw-qa-testing/**
|
||||
!.agents/skills/openclaw-release-maintainer/
|
||||
!.agents/skills/openclaw-release-maintainer/**
|
||||
!.agents/skills/openclaw-secret-scanning-maintainer/
|
||||
!.agents/skills/openclaw-secret-scanning-maintainer/**
|
||||
!.agents/skills/openclaw-test-heap-leaks/
|
||||
!.agents/skills/openclaw-test-heap-leaks/**
|
||||
!.agents/skills/openclaw-test-performance/
|
||||
!.agents/skills/openclaw-test-performance/**
|
||||
!.agents/skills/optimizetests/
|
||||
!.agents/skills/optimizetests/**
|
||||
!.agents/skills/parallels-discord-roundtrip/
|
||||
!.agents/skills/parallels-discord-roundtrip/**
|
||||
!.agents/skills/security-triage/
|
||||
!.agents/skills/security-triage/**
|
||||
!.agents/skills/tag-duplicate-prs-issues/
|
||||
!.agents/skills/tag-duplicate-prs-issues/**
|
||||
|
||||
# Agent credentials and memory (NEVER COMMIT)
|
||||
/memory/
|
||||
.agent/*.json
|
||||
|
||||
30
CHANGELOG.md
30
CHANGELOG.md
@@ -10,23 +10,26 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
- Codex/agent: translate `--thinking minimal` to `low` for modern Codex models (gpt-5.5, gpt-5.4, gpt-5.4-mini, gpt-5.2) at request build time so the first turn is accepted instead of paying a wasted call + retry-with-low fallback. Older Codex models still receive `minimal` directly. Fixes #71946. Thanks @hclsys.
|
||||
- TTS/WhatsApp: add `/tts latest` read-aloud support with duplicate suppression and `/tts chat on|off|default` session-scoped auto-TTS overrides, completing the on-demand voice-note UX for current-chat replies. Fixes #66032.
|
||||
- Plugins/tokenjuice: bump the bundled tokenjuice runtime to 0.6.3. Thanks @vincentkoc.
|
||||
- TTS/agents: allow `agents.list[].tts` to override global `messages.tts` for per-agent voices while keeping shared provider credentials and preferences in the existing TTS config surface.
|
||||
- TTS/agents: make `/tts audio`, `/tts status`, and the `tts` agent tool honor the active `agents.list[].tts` voice/provider override.
|
||||
- TTS/channels: resolve channel and account TTS overrides generically, enabling Feishu and QQBot accounts to deep-merge `channels.<channel>.accounts.<id>.tts` over global and per-agent TTS config. Thanks @sahilsatralkar.
|
||||
- TTS/agents: allow `agents.list[].tts` to override global `messages.tts` for per-agent voices, and make `/tts audio`, `/tts status`, and the `tts` agent tool honor the active voice/provider override while keeping shared provider credentials and preferences in the existing TTS config surface.
|
||||
- Providers/Azure Speech: add Azure Speech as a bundled TTS provider with Speech-resource auth, voice listing, SSML escaping, native Ogg/Opus voice-note output, and telephony output. (#51776) Thanks @leonchui.
|
||||
- Browser automation: add a CDP-native role snapshot fallback with iframe-aware refs, cursor-clickable detection, target attach preparation, and `openclaw browser doctor --deep` live snapshot probing.
|
||||
- Google Meet: add calendar-backed attendance export workflows, export manifests, dry-run previews, and tool parity for meeting records.
|
||||
- Control UI: add PWA install support and Web Push notifications for Gateway chat. (#44590) Thanks @eduardocruz.
|
||||
- Browser automation: add safe tab URLs in agent responses plus a CDP-native role snapshot fallback with iframe-aware refs, cursor-clickable detection, target attach preparation, and `openclaw browser doctor --deep` live snapshot probing.
|
||||
- CLI/image generation: expose generic `--background` on `openclaw infer image generate` and `openclaw infer image edit`, keep `--openai-background` as an OpenAI alias, and let fal image generation honor `--output-format png|jpeg`. Thanks @steipete.
|
||||
- Browser/config: allow local managed Chrome launch discovery and post-launch CDP readiness timeouts to be raised for slower hosts such as Raspberry Pi. Fixes #66803. Thanks @beat843796.
|
||||
- Discord: allow `channels.discord.voice.model` to override the LLM used for voice channel responses while keeping STT and TTS on their existing media settings. (#64368) Thanks @mrdavey.
|
||||
- Browser/CLI: add `openclaw browser start --headless` as a one-shot local managed browser launch override without rewriting persisted browser config. Thanks @BenediktSchackenberg.
|
||||
- CLI/Crestodian: open interactive Crestodian in the full OpenClaw TUI shell instead of a basic readline prompt.
|
||||
- CLI/Crestodian: shorten the startup greeting to the active planner/model, config state, Gateway probe result, and next debug action instead of dumping every discovered backend.
|
||||
- CLI/Crestodian/TUI: add the first-run setup helper, local planner fallback, full-TUI interactive Crestodian, startup progress indicators, context mode selector, and a shorter startup greeting. (#71720, #71760) Thanks @SebTardif and @kevinlin-openai.
|
||||
- Plugins: migrate the local plugin registry automatically during package install/update, keeping install metadata in the plugin index while indexing existing plugin manifests for the new cold registry path. Thanks @vincentkoc and @shakkernerd.
|
||||
- Plugins/doctor: make `openclaw doctor --fix` refresh the plugin index and cold registry index when needed without treating plugin install records as authored config. Thanks @vincentkoc and @shakkernerd.
|
||||
- Plugins/hooks: add before-agent-finalize hooks, cron `jobId` hook context, bounded native permission fingerprints, and Codex MCP hook relay support. (#71765, #71758, #71707) Thanks @vincentkoc and @pashpashpash.
|
||||
- Plugins/tokenjuice: bump the bundled tokenjuice runtime to 0.6.3. Thanks @vincentkoc.
|
||||
- Diagnostics/OTEL: align model-call GenAI span attributes with OpenTelemetry stability opt-in semantics, keeping legacy `gen_ai.system` by default while emitting `gen_ai.provider.name` under `OTEL_SEMCONV_STABILITY_OPT_IN=gen_ai_latest_experimental`. Thanks @vincentkoc.
|
||||
- Diagnostics/OTEL: support signal-specific OTLP endpoint overrides for traces, metrics, and logs via config or standard OTEL environment variables. Thanks @vincentkoc.
|
||||
- Diagnostics/OTEL: emit bounded telemetry exporter health diagnostics for startup and log-export failures without exporting raw error text. Thanks @vincentkoc.
|
||||
- Diagnostics/OTEL: export agent harness lifecycle telemetry as bounded `openclaw.harness.run` spans and `openclaw.harness.duration_ms` metrics so QA-lab, Codex, and future harnesses share one trace shape. Thanks @vincentkoc.
|
||||
- Diagnostics/trace: propagate W3C `traceparent` headers from trusted model-call trace context to provider transports while replacing caller-supplied traceparent values. Thanks @vincentkoc.
|
||||
- Plugins/CLI: add `openclaw plugins registry` for explicit persisted-registry inspection and `--refresh` repair without making normal startup rescan plugin locations. Thanks @vincentkoc.
|
||||
- Plugins/CLI: make `openclaw plugins list` read the cold persisted registry snapshot by default, leaving module-aware diagnostics to `plugins doctor` and `plugins inspect`. Thanks @vincentkoc.
|
||||
- Plugins/startup: move gateway startup plugin planning onto the versioned cold registry index, with postinstall repair for older registry files that predate startup metadata. Thanks @vincentkoc.
|
||||
@@ -56,6 +59,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Diagnostics/OTEL: keep model-usage span GenAI provider attributes aligned with the existing semantic-convention opt-in policy, using legacy `gen_ai.system` unless latest experimental GenAI conventions are enabled. Thanks @vincentkoc.
|
||||
- Diagnostics/OTEL: keep `gen_ai.request.model` present on GenAI token usage metrics with a bounded `unknown` fallback when model usage events do not include a model. Thanks @vincentkoc.
|
||||
- Docs/OTEL: document the GenAI token and model-call duration metrics, model-usage span attributes, and `OTEL_SEMCONV_STABILITY_OPT_IN=gen_ai_latest_experimental` provider-attribute behavior. Thanks @vincentkoc.
|
||||
- Docs: refresh the MCP, model provider, doctor, troubleshooting, BlueBubbles, media generation, TTS, subagents, skills, cron/tasks, exec approvals, and voice-call guides with structured Steps, Tabs, and Accordion content.
|
||||
- Diagnostics/trace: add an internal traceparent propagation helper that only formats trusted dispatcher metadata, keeping plugin-emitted diagnostic traces out of outbound propagation by default. Thanks @vincentkoc.
|
||||
- Diagnostics/OTEL: add bounded outbound message delivery lifecycle diagnostics and export them as low-cardinality delivery spans/metrics without message body, recipient, room, or media-path data. (#71471) Thanks @vincentkoc and @jlapenna.
|
||||
- Diagnostics/OTEL: emit bounded exec-process diagnostics and export them as `openclaw.exec` spans without exposing command text, working directories, or container identifiers. (#71451) Thanks @vincentkoc and @jlapenna.
|
||||
@@ -76,10 +80,24 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Fixes
|
||||
|
||||
- UI/Windows: quote resolved pnpm `.cmd` launcher paths before spawning UI install/build/test commands so Node installs under `C:\Program Files` no longer fail as `C:\Program`. Fixes #45275. Thanks @Kobevictor, @stoppieboy, and @iubns.
|
||||
- Plugins/uninstall: remove tracked plugin files from their recorded managed extensions root even when the current state directory points somewhere else, so `openclaw plugins uninstall --force` does not leave the plugin discoverable. Thanks @shakkernerd.
|
||||
- Agents/runtime: add `agentRuntime.id` as the canonical config key, migrate
|
||||
legacy runtime-policy configs with `openclaw doctor --fix`, and route
|
||||
canonical Anthropic models through `claude-cli` without passing CLI backend
|
||||
aliases to embedded harness selection. Fixes #71957. Thanks @WolvenRA.
|
||||
- CLI/update: guard Windows scheduled-task stops by state and timeout so auto-update restart cannot hang indefinitely on `schtasks /End` before stale-listener cleanup. Fixes #69970. Thanks @yangswld and @sherlock-huang.
|
||||
- Windows install/Lobster: execute `pnpm.exe` directly when `npm_execpath` points at the native pnpm binary, add an installed-package fallback for the Lobster embedded runtime, and include the Lobster runner regression test in Windows CI. Fixes #69456. Thanks @igormf.
|
||||
- Gateway/install: refresh loaded gateway service installs when the current service embeds stale gateway auth instead of returning already-installed, avoiding LaunchAgent token-mismatch loops after token rotation. Fixes #70752. Thanks @hyspacex.
|
||||
- Update: ignore bundled plugin `.openclaw-install-stage` directories during global install verification and packaged dist pruning so leftover runtime-dep staging files do not turn successful updates into `unexpected packaged dist file` failures. Fixes #71752. Thanks @waynegault.
|
||||
- Node runtime: keep node-host retry timers alive across Gateway restarts and exit on terminal credential pauses so supervised nodes do not become silent zombies. Fixes #69800. Thanks @meroli28.
|
||||
- Gateway/plugins: stop persisted WhatsApp auth state from activating bundled channel runtime-dependency repair during startup when `channels.whatsapp` is absent, avoiding npm/git stalls on packaged Linux installs. Fixes #71994. Thanks @xiao398008.
|
||||
- Gateway/device tokens: enforce caller-scope containment inside token rotation and revocation so pairing-only sessions cannot mutate higher-scope operator tokens. Fixes #71990. Thanks @coygeek.
|
||||
- Plugins/channels: keep security checks, thread-binding placement, provider summaries, health formatting, and message action labels on read-only or already-loaded channel metadata instead of importing full channel runtime. Thanks @shakkernerd.
|
||||
- Sessions/channels: stop group-session metadata from loading bundled channel runtime just to classify `#channel` subjects, using only already-loaded channel capabilities on that path. Thanks @shakkernerd.
|
||||
- Plugins/channels: keep native command and native skill `auto` defaults on static channel metadata so config, audit, and command-list checks do not load channel runtime just to read those defaults. Thanks @shakkernerd.
|
||||
- CLI/channels: keep channel remove selection and all-channel capabilities summaries on read-only plugin metadata, loading channel runtime only for the selected mutation path. Thanks @shakkernerd.
|
||||
- CLI/models: keep Provider Index preview rows out of `models list --all --provider <id>` when the owning provider plugin is disabled, preserving config authority for cold catalog fallbacks. Thanks @shakkernerd.
|
||||
- CLI/model runs: keep `openclaw infer model run` on explicit OpenRouter models from loading the full provider catalog or inheriting chat-agent silent-reply policy, restoring non-empty one-shot probe output. Fixes #68791. Thanks @limpredator.
|
||||
- Installer/macOS: rerun Homebrew install steps without the gum spinner when raw-mode ioctl failures occur, and avoid claiming `node@24` was installed when the Homebrew keg binary is missing. Fixes #70411. Thanks @1fanwang and @dad-io.
|
||||
- Installer: load nvm before Node.js detection so `curl | bash` installs respect nvm-managed Node instead of stale system Node. Fixes #49556. Thanks @heavenlxj.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
f1eefb91a486188915373b09199959f0f1a7cd01dc75ef923832741f72a12543 config-baseline.json
|
||||
9f0e386d5118cbca785a2e8e9c8b170d844faf1b7ef5e82e6b15d9e1c39f3796 config-baseline.core.json
|
||||
d8e7866e0c3f633222f75a35defed3c3a03d849f4aa4f70871e3436e80074e76 config-baseline.json
|
||||
5f5fb87fd46f9cbb84d8af17e00ae3c4b74062e8ad517bc2260ba83da2e9014f config-baseline.core.json
|
||||
7cd9c908f066c143eab2a201efbc9640f483ab28bba92ddeca1d18cc2b528bc3 config-baseline.channel.json
|
||||
a5479c182ec987bb21e814b8a4e7b3bda7190ae5c2b35fd5ca403dfa48afa115 config-baseline.plugin.json
|
||||
|
||||
@@ -213,6 +213,11 @@ openclaw pairing list feishu
|
||||
appId: "cli_xxx",
|
||||
appSecret: "xxx",
|
||||
name: "Primary bot",
|
||||
tts: {
|
||||
providers: {
|
||||
openai: { voice: "shimmer" },
|
||||
},
|
||||
},
|
||||
},
|
||||
backup: {
|
||||
appId: "cli_yyy",
|
||||
@@ -227,6 +232,10 @@ openclaw pairing list feishu
|
||||
```
|
||||
|
||||
`defaultAccount` controls which account is used when outbound APIs do not specify an `accountId`.
|
||||
`accounts.<id>.tts` uses the same shape as `messages.tts` and deep-merges over
|
||||
global TTS config, so multi-bot Feishu setups can keep shared provider
|
||||
credentials globally while overriding only voice, model, persona, or auto mode
|
||||
per account.
|
||||
|
||||
### Message limits
|
||||
|
||||
@@ -386,6 +395,7 @@ Full configuration: [Gateway configuration](/gateway/configuration)
|
||||
| `channels.feishu.accounts.<id>.appId` | App ID | — |
|
||||
| `channels.feishu.accounts.<id>.appSecret` | App Secret | — |
|
||||
| `channels.feishu.accounts.<id>.domain` | Per-account domain override | `feishu` |
|
||||
| `channels.feishu.accounts.<id>.tts` | Per-account TTS override | `messages.tts` |
|
||||
| `channels.feishu.dmPolicy` | DM policy | `allowlist` |
|
||||
| `channels.feishu.allowFrom` | DM allowlist (open_id list) | [BotOwnerId] |
|
||||
| `channels.feishu.groupPolicy` | Group policy | `allowlist` |
|
||||
|
||||
@@ -122,10 +122,10 @@ openclaw channels add --channel qqbot --account bot2 --token "222222222:secret-o
|
||||
|
||||
STT and TTS support two-level configuration with priority fallback:
|
||||
|
||||
| Setting | Plugin-specific | Framework fallback |
|
||||
| ------- | -------------------- | ----------------------------- |
|
||||
| STT | `channels.qqbot.stt` | `tools.media.audio.models[0]` |
|
||||
| TTS | `channels.qqbot.tts` | `messages.tts` |
|
||||
| Setting | Plugin-specific | Framework fallback |
|
||||
| ------- | -------------------------------------------------------- | ----------------------------- |
|
||||
| STT | `channels.qqbot.stt` | `tools.media.audio.models[0]` |
|
||||
| TTS | `channels.qqbot.tts`, `channels.qqbot.accounts.<id>.tts` | `messages.tts` |
|
||||
|
||||
```json5
|
||||
{
|
||||
@@ -140,12 +140,23 @@ STT and TTS support two-level configuration with priority fallback:
|
||||
model: "your-tts-model",
|
||||
voice: "your-voice",
|
||||
},
|
||||
accounts: {
|
||||
qq-main: {
|
||||
tts: {
|
||||
providers: {
|
||||
openai: { voice: "shimmer" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Set `enabled: false` on either to disable.
|
||||
Account-level TTS overrides use the same shape as `messages.tts` and deep-merge
|
||||
over the channel/global TTS config.
|
||||
|
||||
Inbound QQ voice attachments are exposed to agents as audio media metadata while
|
||||
keeping raw voice files out of generic `MediaPaths`. `[[audio_as_voice]]` plain
|
||||
|
||||
@@ -162,7 +162,7 @@ configured OpenClaw model. If no configured model is usable yet, it can fall
|
||||
back to local runtimes already present on the machine:
|
||||
|
||||
- Claude Code CLI: `claude-cli/claude-opus-4-7`
|
||||
- Codex app-server harness: `openai/gpt-5.5` with `embeddedHarness.runtime: "codex"`
|
||||
- Codex app-server harness: `openai/gpt-5.5` with `agentRuntime.id: "codex"`
|
||||
- Codex CLI: `codex-cli/gpt-5.5`
|
||||
|
||||
The model-assisted planner cannot mutate config directly. It must translate the
|
||||
|
||||
462
docs/cli/mcp.md
462
docs/cli/mcp.md
@@ -5,91 +5,89 @@ read_when:
|
||||
- Running `openclaw mcp serve`
|
||||
- Managing OpenClaw-saved MCP server definitions
|
||||
title: "MCP"
|
||||
sidebarTitle: "MCP"
|
||||
---
|
||||
|
||||
`openclaw mcp` has two jobs:
|
||||
|
||||
- run OpenClaw as an MCP server with `openclaw mcp serve`
|
||||
- manage OpenClaw-owned outbound MCP server definitions with `list`, `show`,
|
||||
`set`, and `unset`
|
||||
- manage OpenClaw-owned outbound MCP server definitions with `list`, `show`, `set`, and `unset`
|
||||
|
||||
In other words:
|
||||
|
||||
- `serve` is OpenClaw acting as an MCP server
|
||||
- `list` / `show` / `set` / `unset` is OpenClaw acting as an MCP client-side
|
||||
registry for other MCP servers its runtimes may consume later
|
||||
- `list` / `show` / `set` / `unset` is OpenClaw acting as an MCP client-side registry for other MCP servers its runtimes may consume later
|
||||
|
||||
Use [`openclaw acp`](/cli/acp) when OpenClaw should host a coding harness
|
||||
session itself and route that runtime through ACP.
|
||||
Use [`openclaw acp`](/cli/acp) when OpenClaw should host a coding harness session itself and route that runtime through ACP.
|
||||
|
||||
## OpenClaw as an MCP server
|
||||
|
||||
This is the `openclaw mcp serve` path.
|
||||
|
||||
## When to use `serve`
|
||||
### When to use `serve`
|
||||
|
||||
Use `openclaw mcp serve` when:
|
||||
|
||||
- Codex, Claude Code, or another MCP client should talk directly to
|
||||
OpenClaw-backed channel conversations
|
||||
- Codex, Claude Code, or another MCP client should talk directly to OpenClaw-backed channel conversations
|
||||
- you already have a local or remote OpenClaw Gateway with routed sessions
|
||||
- you want one MCP server that works across OpenClaw's channel backends instead
|
||||
of running separate per-channel bridges
|
||||
- you want one MCP server that works across OpenClaw's channel backends instead of running separate per-channel bridges
|
||||
|
||||
Use [`openclaw acp`](/cli/acp) instead when OpenClaw should host the coding
|
||||
runtime itself and keep the agent session inside OpenClaw.
|
||||
Use [`openclaw acp`](/cli/acp) instead when OpenClaw should host the coding runtime itself and keep the agent session inside OpenClaw.
|
||||
|
||||
## How it works
|
||||
### How it works
|
||||
|
||||
`openclaw mcp serve` starts a stdio MCP server. The MCP client owns that
|
||||
process. While the client keeps the stdio session open, the bridge connects to a
|
||||
local or remote OpenClaw Gateway over WebSocket and exposes routed channel
|
||||
conversations over MCP.
|
||||
`openclaw mcp serve` starts a stdio MCP server. The MCP client owns that process. While the client keeps the stdio session open, the bridge connects to a local or remote OpenClaw Gateway over WebSocket and exposes routed channel conversations over MCP.
|
||||
|
||||
Lifecycle:
|
||||
<Steps>
|
||||
<Step title="Client spawns the bridge">
|
||||
The MCP client spawns `openclaw mcp serve`.
|
||||
</Step>
|
||||
<Step title="Bridge connects to Gateway">
|
||||
The bridge connects to the OpenClaw Gateway over WebSocket.
|
||||
</Step>
|
||||
<Step title="Sessions become MCP conversations">
|
||||
Routed sessions become MCP conversations and transcript/history tools.
|
||||
</Step>
|
||||
<Step title="Live events queue">
|
||||
Live events are queued in memory while the bridge is connected.
|
||||
</Step>
|
||||
<Step title="Optional Claude push">
|
||||
If Claude channel mode is enabled, the same session can also receive Claude-specific push notifications.
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
1. the MCP client spawns `openclaw mcp serve`
|
||||
2. the bridge connects to Gateway
|
||||
3. routed sessions become MCP conversations and transcript/history tools
|
||||
4. live events are queued in memory while the bridge is connected
|
||||
5. if Claude channel mode is enabled, the same session can also receive
|
||||
Claude-specific push notifications
|
||||
<AccordionGroup>
|
||||
<Accordion title="Important behavior">
|
||||
- live queue state starts when the bridge connects
|
||||
- older transcript history is read with `messages_read`
|
||||
- Claude push notifications only exist while the MCP session is alive
|
||||
- when the client disconnects, the bridge exits and the live queue is gone
|
||||
- one-shot agent entry points such as `openclaw agent` and `openclaw infer model run` retire any bundled MCP runtimes they open when the reply completes, so repeated scripted runs do not accumulate stdio MCP child processes
|
||||
- stdio MCP servers launched by OpenClaw (bundled or user-configured) are torn down as a process tree on shutdown, so child subprocesses started by the server do not survive after the parent stdio client exits
|
||||
- deleting or resetting a session disposes that session's MCP clients through the shared runtime cleanup path, so there are no lingering stdio connections tied to a removed session
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
Important behavior:
|
||||
|
||||
- live queue state starts when the bridge connects
|
||||
- older transcript history is read with `messages_read`
|
||||
- Claude push notifications only exist while the MCP session is alive
|
||||
- when the client disconnects, the bridge exits and the live queue is gone
|
||||
- one-shot agent entry points such as `openclaw agent` and
|
||||
`openclaw infer model run` retire any bundled MCP runtimes they open when the
|
||||
reply completes, so repeated scripted runs do not accumulate stdio MCP child
|
||||
processes
|
||||
- stdio MCP servers launched by OpenClaw (bundled or user-configured) are torn
|
||||
down as a process tree on shutdown, so child subprocesses started by the
|
||||
server do not survive after the parent stdio client exits
|
||||
- deleting or resetting a session disposes that session's MCP clients through
|
||||
the shared runtime cleanup path, so there are no lingering stdio connections
|
||||
tied to a removed session
|
||||
|
||||
## Choose a client mode
|
||||
### Choose a client mode
|
||||
|
||||
Use the same bridge in two different ways:
|
||||
|
||||
- Generic MCP clients: standard MCP tools only. Use `conversations_list`,
|
||||
`messages_read`, `events_poll`, `events_wait`, `messages_send`, and the
|
||||
approval tools.
|
||||
- Claude Code: standard MCP tools plus the Claude-specific channel adapter.
|
||||
Enable `--claude-channel-mode on` or leave the default `auto`.
|
||||
<Tabs>
|
||||
<Tab title="Generic MCP clients">
|
||||
Standard MCP tools only. Use `conversations_list`, `messages_read`, `events_poll`, `events_wait`, `messages_send`, and the approval tools.
|
||||
</Tab>
|
||||
<Tab title="Claude Code">
|
||||
Standard MCP tools plus the Claude-specific channel adapter. Enable `--claude-channel-mode on` or leave the default `auto`.
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
Today, `auto` behaves the same as `on`. There is no client capability detection
|
||||
yet.
|
||||
<Note>
|
||||
Today, `auto` behaves the same as `on`. There is no client capability detection yet.
|
||||
</Note>
|
||||
|
||||
## What `serve` exposes
|
||||
### What `serve` exposes
|
||||
|
||||
The bridge uses existing Gateway session route metadata to expose channel-backed
|
||||
conversations. A conversation appears when OpenClaw already has session state
|
||||
with a known route such as:
|
||||
The bridge uses existing Gateway session route metadata to expose channel-backed conversations. A conversation appears when OpenClaw already has session state with a known route such as:
|
||||
|
||||
- `channel`
|
||||
- recipient or destination metadata
|
||||
@@ -104,101 +102,91 @@ This gives MCP clients one place to:
|
||||
- send a reply back through the same route
|
||||
- see approval requests that arrive while the bridge is connected
|
||||
|
||||
## Usage
|
||||
### Usage
|
||||
|
||||
```bash
|
||||
# Local Gateway
|
||||
openclaw mcp serve
|
||||
<Tabs>
|
||||
<Tab title="Local Gateway">
|
||||
```bash
|
||||
openclaw mcp serve
|
||||
```
|
||||
</Tab>
|
||||
<Tab title="Remote Gateway (token)">
|
||||
```bash
|
||||
openclaw mcp serve --url wss://gateway-host:18789 --token-file ~/.openclaw/gateway.token
|
||||
```
|
||||
</Tab>
|
||||
<Tab title="Remote Gateway (password)">
|
||||
```bash
|
||||
openclaw mcp serve --url wss://gateway-host:18789 --password-file ~/.openclaw/gateway.password
|
||||
```
|
||||
</Tab>
|
||||
<Tab title="Verbose / Claude off">
|
||||
```bash
|
||||
openclaw mcp serve --verbose
|
||||
openclaw mcp serve --claude-channel-mode off
|
||||
```
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
# Remote Gateway
|
||||
openclaw mcp serve --url wss://gateway-host:18789 --token-file ~/.openclaw/gateway.token
|
||||
|
||||
# Remote Gateway with password auth
|
||||
openclaw mcp serve --url wss://gateway-host:18789 --password-file ~/.openclaw/gateway.password
|
||||
|
||||
# Enable verbose bridge logs
|
||||
openclaw mcp serve --verbose
|
||||
|
||||
# Disable Claude-specific push notifications
|
||||
openclaw mcp serve --claude-channel-mode off
|
||||
```
|
||||
|
||||
## Bridge tools
|
||||
### Bridge tools
|
||||
|
||||
The current bridge exposes these MCP tools:
|
||||
|
||||
- `conversations_list`
|
||||
- `conversation_get`
|
||||
- `messages_read`
|
||||
- `attachments_fetch`
|
||||
- `events_poll`
|
||||
- `events_wait`
|
||||
- `messages_send`
|
||||
- `permissions_list_open`
|
||||
- `permissions_respond`
|
||||
<AccordionGroup>
|
||||
<Accordion title="conversations_list">
|
||||
Lists recent session-backed conversations that already have route metadata in Gateway session state.
|
||||
|
||||
### `conversations_list`
|
||||
Useful filters:
|
||||
|
||||
Lists recent session-backed conversations that already have route metadata in
|
||||
Gateway session state.
|
||||
- `limit`
|
||||
- `search`
|
||||
- `channel`
|
||||
- `includeDerivedTitles`
|
||||
- `includeLastMessage`
|
||||
|
||||
Useful filters:
|
||||
</Accordion>
|
||||
<Accordion title="conversation_get">
|
||||
Returns one conversation by `session_key`.
|
||||
</Accordion>
|
||||
<Accordion title="messages_read">
|
||||
Reads recent transcript messages for one session-backed conversation.
|
||||
</Accordion>
|
||||
<Accordion title="attachments_fetch">
|
||||
Extracts non-text message content blocks from one transcript message. This is a metadata view over transcript content, not a standalone durable attachment blob store.
|
||||
</Accordion>
|
||||
<Accordion title="events_poll">
|
||||
Reads queued live events since a numeric cursor.
|
||||
</Accordion>
|
||||
<Accordion title="events_wait">
|
||||
Long-polls until the next matching queued event arrives or a timeout expires.
|
||||
|
||||
- `limit`
|
||||
- `search`
|
||||
- `channel`
|
||||
- `includeDerivedTitles`
|
||||
- `includeLastMessage`
|
||||
Use this when a generic MCP client needs near-real-time delivery without a Claude-specific push protocol.
|
||||
|
||||
### `conversation_get`
|
||||
</Accordion>
|
||||
<Accordion title="messages_send">
|
||||
Sends text back through the same route already recorded on the session.
|
||||
|
||||
Returns one conversation by `session_key`.
|
||||
Current behavior:
|
||||
|
||||
### `messages_read`
|
||||
- requires an existing conversation route
|
||||
- uses the session's channel, recipient, account id, and thread id
|
||||
- sends text only
|
||||
|
||||
Reads recent transcript messages for one session-backed conversation.
|
||||
</Accordion>
|
||||
<Accordion title="permissions_list_open">
|
||||
Lists pending exec/plugin approval requests the bridge has observed since it connected to the Gateway.
|
||||
</Accordion>
|
||||
<Accordion title="permissions_respond">
|
||||
Resolves one pending exec/plugin approval request with:
|
||||
|
||||
### `attachments_fetch`
|
||||
- `allow-once`
|
||||
- `allow-always`
|
||||
- `deny`
|
||||
|
||||
Extracts non-text message content blocks from one transcript message. This is a
|
||||
metadata view over transcript content, not a standalone durable attachment blob
|
||||
store.
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
### `events_poll`
|
||||
|
||||
Reads queued live events since a numeric cursor.
|
||||
|
||||
### `events_wait`
|
||||
|
||||
Long-polls until the next matching queued event arrives or a timeout expires.
|
||||
|
||||
Use this when a generic MCP client needs near-real-time delivery without a
|
||||
Claude-specific push protocol.
|
||||
|
||||
### `messages_send`
|
||||
|
||||
Sends text back through the same route already recorded on the session.
|
||||
|
||||
Current behavior:
|
||||
|
||||
- requires an existing conversation route
|
||||
- uses the session's channel, recipient, account id, and thread id
|
||||
- sends text only
|
||||
|
||||
### `permissions_list_open`
|
||||
|
||||
Lists pending exec/plugin approval requests the bridge has observed since it
|
||||
connected to the Gateway.
|
||||
|
||||
### `permissions_respond`
|
||||
|
||||
Resolves one pending exec/plugin approval request with:
|
||||
|
||||
- `allow-once`
|
||||
- `allow-always`
|
||||
- `deny`
|
||||
|
||||
## Event model
|
||||
### Event model
|
||||
|
||||
The bridge keeps an in-memory event queue while it is connected.
|
||||
|
||||
@@ -211,46 +199,43 @@ Current event types:
|
||||
- `plugin_approval_resolved`
|
||||
- `claude_permission_request`
|
||||
|
||||
Important limits:
|
||||
|
||||
<Warning>
|
||||
- the queue is live-only; it starts when the MCP bridge starts
|
||||
- `events_poll` and `events_wait` do not replay older Gateway history by
|
||||
themselves
|
||||
- `events_poll` and `events_wait` do not replay older Gateway history by themselves
|
||||
- durable backlog should be read with `messages_read`
|
||||
</Warning>
|
||||
|
||||
## Claude channel notifications
|
||||
### Claude channel notifications
|
||||
|
||||
The bridge can also expose Claude-specific channel notifications. This is the
|
||||
OpenClaw equivalent of a Claude Code channel adapter: standard MCP tools remain
|
||||
available, but live inbound messages can also arrive as Claude-specific MCP
|
||||
notifications.
|
||||
The bridge can also expose Claude-specific channel notifications. This is the OpenClaw equivalent of a Claude Code channel adapter: standard MCP tools remain available, but live inbound messages can also arrive as Claude-specific MCP notifications.
|
||||
|
||||
Flags:
|
||||
<Tabs>
|
||||
<Tab title="off">
|
||||
`--claude-channel-mode off`: standard MCP tools only.
|
||||
</Tab>
|
||||
<Tab title="on">
|
||||
`--claude-channel-mode on`: enable Claude channel notifications.
|
||||
</Tab>
|
||||
<Tab title="auto (default)">
|
||||
`--claude-channel-mode auto`: current default; same bridge behavior as `on`.
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
- `--claude-channel-mode off`: standard MCP tools only
|
||||
- `--claude-channel-mode on`: enable Claude channel notifications
|
||||
- `--claude-channel-mode auto`: current default; same bridge behavior as `on`
|
||||
|
||||
When Claude channel mode is enabled, the server advertises Claude experimental
|
||||
capabilities and can emit:
|
||||
When Claude channel mode is enabled, the server advertises Claude experimental capabilities and can emit:
|
||||
|
||||
- `notifications/claude/channel`
|
||||
- `notifications/claude/channel/permission`
|
||||
|
||||
Current bridge behavior:
|
||||
|
||||
- inbound `user` transcript messages are forwarded as
|
||||
`notifications/claude/channel`
|
||||
- inbound `user` transcript messages are forwarded as `notifications/claude/channel`
|
||||
- Claude permission requests received over MCP are tracked in-memory
|
||||
- if the linked conversation later sends `yes abcde` or `no abcde`, the bridge
|
||||
converts that to `notifications/claude/channel/permission`
|
||||
- these notifications are live-session only; if the MCP client disconnects,
|
||||
there is no push target
|
||||
- if the linked conversation later sends `yes abcde` or `no abcde`, the bridge converts that to `notifications/claude/channel/permission`
|
||||
- these notifications are live-session only; if the MCP client disconnects, there is no push target
|
||||
|
||||
This is intentionally client-specific. Generic MCP clients should rely on the
|
||||
standard polling tools.
|
||||
This is intentionally client-specific. Generic MCP clients should rely on the standard polling tools.
|
||||
|
||||
## MCP client config
|
||||
### MCP client config
|
||||
|
||||
Example stdio client config:
|
||||
|
||||
@@ -272,43 +257,52 @@ Example stdio client config:
|
||||
}
|
||||
```
|
||||
|
||||
For most generic MCP clients, start with the standard tool surface and ignore
|
||||
Claude mode. Turn Claude mode on only for clients that actually understand the
|
||||
Claude-specific notification methods.
|
||||
For most generic MCP clients, start with the standard tool surface and ignore Claude mode. Turn Claude mode on only for clients that actually understand the Claude-specific notification methods.
|
||||
|
||||
## Options
|
||||
### Options
|
||||
|
||||
`openclaw mcp serve` supports:
|
||||
|
||||
- `--url <url>`: Gateway WebSocket URL
|
||||
- `--token <token>`: Gateway token
|
||||
- `--token-file <path>`: read token from file
|
||||
- `--password <password>`: Gateway password
|
||||
- `--password-file <path>`: read password from file
|
||||
- `--claude-channel-mode <auto|on|off>`: Claude notification mode
|
||||
- `-v`, `--verbose`: verbose logs on stderr
|
||||
<ParamField path="--url" type="string">
|
||||
Gateway WebSocket URL.
|
||||
</ParamField>
|
||||
<ParamField path="--token" type="string">
|
||||
Gateway token.
|
||||
</ParamField>
|
||||
<ParamField path="--token-file" type="string">
|
||||
Read token from file.
|
||||
</ParamField>
|
||||
<ParamField path="--password" type="string">
|
||||
Gateway password.
|
||||
</ParamField>
|
||||
<ParamField path="--password-file" type="string">
|
||||
Read password from file.
|
||||
</ParamField>
|
||||
<ParamField path="--claude-channel-mode" type='"auto" | "on" | "off"'>
|
||||
Claude notification mode.
|
||||
</ParamField>
|
||||
<ParamField path="-v, --verbose" type="boolean">
|
||||
Verbose logs on stderr.
|
||||
</ParamField>
|
||||
|
||||
<Tip>
|
||||
Prefer `--token-file` or `--password-file` over inline secrets when possible.
|
||||
</Tip>
|
||||
|
||||
## Security and trust boundary
|
||||
### Security and trust boundary
|
||||
|
||||
The bridge does not invent routing. It only exposes conversations that Gateway
|
||||
already knows how to route.
|
||||
The bridge does not invent routing. It only exposes conversations that Gateway already knows how to route.
|
||||
|
||||
That means:
|
||||
|
||||
- sender allowlists, pairing, and channel-level trust still belong to the
|
||||
underlying OpenClaw channel configuration
|
||||
- sender allowlists, pairing, and channel-level trust still belong to the underlying OpenClaw channel configuration
|
||||
- `messages_send` can only reply through an existing stored route
|
||||
- approval state is live/in-memory only for the current bridge session
|
||||
- bridge auth should use the same Gateway token or password controls you would
|
||||
trust for any other remote Gateway client
|
||||
- bridge auth should use the same Gateway token or password controls you would trust for any other remote Gateway client
|
||||
|
||||
If a conversation is missing from `conversations_list`, the usual cause is not
|
||||
MCP configuration. It is missing or incomplete route metadata in the underlying
|
||||
Gateway session.
|
||||
If a conversation is missing from `conversations_list`, the usual cause is not MCP configuration. It is missing or incomplete route metadata in the underlying Gateway session.
|
||||
|
||||
## Testing
|
||||
### Testing
|
||||
|
||||
OpenClaw ships a deterministic Docker smoke for this bridge:
|
||||
|
||||
@@ -320,79 +314,60 @@ That smoke:
|
||||
|
||||
- starts a seeded Gateway container
|
||||
- starts a second container that spawns `openclaw mcp serve`
|
||||
- verifies conversation discovery, transcript reads, attachment metadata reads,
|
||||
live event queue behavior, and outbound send routing
|
||||
- validates Claude-style channel and permission notifications over the real
|
||||
stdio MCP bridge
|
||||
- verifies conversation discovery, transcript reads, attachment metadata reads, live event queue behavior, and outbound send routing
|
||||
- validates Claude-style channel and permission notifications over the real stdio MCP bridge
|
||||
|
||||
This is the fastest way to prove the bridge works without wiring a real
|
||||
Telegram, Discord, or iMessage account into the test run.
|
||||
This is the fastest way to prove the bridge works without wiring a real Telegram, Discord, or iMessage account into the test run.
|
||||
|
||||
For broader testing context, see [Testing](/help/testing).
|
||||
|
||||
## Troubleshooting
|
||||
### Troubleshooting
|
||||
|
||||
### No conversations returned
|
||||
<AccordionGroup>
|
||||
<Accordion title="No conversations returned">
|
||||
Usually means the Gateway session is not already routable. Confirm that the underlying session has stored channel/provider, recipient, and optional account/thread route metadata.
|
||||
</Accordion>
|
||||
<Accordion title="events_poll or events_wait misses older messages">
|
||||
Expected. The live queue starts when the bridge connects. Read older transcript history with `messages_read`.
|
||||
</Accordion>
|
||||
<Accordion title="Claude notifications do not show up">
|
||||
Check all of these:
|
||||
|
||||
Usually means the Gateway session is not already routable. Confirm that the
|
||||
underlying session has stored channel/provider, recipient, and optional
|
||||
account/thread route metadata.
|
||||
- the client kept the stdio MCP session open
|
||||
- `--claude-channel-mode` is `on` or `auto`
|
||||
- the client actually understands the Claude-specific notification methods
|
||||
- the inbound message happened after the bridge connected
|
||||
|
||||
### `events_poll` or `events_wait` misses older messages
|
||||
|
||||
Expected. The live queue starts when the bridge connects. Read older transcript
|
||||
history with `messages_read`.
|
||||
|
||||
### Claude notifications do not show up
|
||||
|
||||
Check all of these:
|
||||
|
||||
- the client kept the stdio MCP session open
|
||||
- `--claude-channel-mode` is `on` or `auto`
|
||||
- the client actually understands the Claude-specific notification methods
|
||||
- the inbound message happened after the bridge connected
|
||||
|
||||
### Approvals are missing
|
||||
|
||||
`permissions_list_open` only shows approval requests observed while the bridge
|
||||
was connected. It is not a durable approval history API.
|
||||
</Accordion>
|
||||
<Accordion title="Approvals are missing">
|
||||
`permissions_list_open` only shows approval requests observed while the bridge was connected. It is not a durable approval history API.
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
## OpenClaw as an MCP client registry
|
||||
|
||||
This is the `openclaw mcp list`, `show`, `set`, and `unset` path.
|
||||
|
||||
These commands do not expose OpenClaw over MCP. They manage OpenClaw-owned MCP
|
||||
server definitions under `mcp.servers` in OpenClaw config.
|
||||
These commands do not expose OpenClaw over MCP. They manage OpenClaw-owned MCP server definitions under `mcp.servers` in OpenClaw config.
|
||||
|
||||
Those saved definitions are for runtimes that OpenClaw launches or configures
|
||||
later, such as embedded Pi and other runtime adapters. OpenClaw stores the
|
||||
definitions centrally so those runtimes do not need to keep their own duplicate
|
||||
MCP server lists.
|
||||
Those saved definitions are for runtimes that OpenClaw launches or configures later, such as embedded Pi and other runtime adapters. OpenClaw stores the definitions centrally so those runtimes do not need to keep their own duplicate MCP server lists.
|
||||
|
||||
Important behavior:
|
||||
<AccordionGroup>
|
||||
<Accordion title="Important behavior">
|
||||
- these commands only read or write OpenClaw config
|
||||
- they do not connect to the target MCP server
|
||||
- they do not validate whether the command, URL, or remote transport is reachable right now
|
||||
- runtime adapters decide which transport shapes they actually support at execution time
|
||||
- embedded Pi exposes configured MCP tools in normal `coding` and `messaging` tool profiles; `minimal` still hides them, and `tools.deny: ["bundle-mcp"]` disables them explicitly
|
||||
- session-scoped bundled MCP runtimes are reaped after `mcp.sessionIdleTtlMs` milliseconds of idle time (default 10 minutes; set `0` to disable) and one-shot embedded runs clean them up at run end
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
- these commands only read or write OpenClaw config
|
||||
- they do not connect to the target MCP server
|
||||
- they do not validate whether the command, URL, or remote transport is
|
||||
reachable right now
|
||||
- runtime adapters decide which transport shapes they actually support at
|
||||
execution time
|
||||
- embedded Pi exposes configured MCP tools in normal `coding` and `messaging`
|
||||
tool profiles; `minimal` still hides them, and `tools.deny: ["bundle-mcp"]`
|
||||
disables them explicitly
|
||||
- session-scoped bundled MCP runtimes are reaped after `mcp.sessionIdleTtlMs`
|
||||
milliseconds of idle time (default 10 minutes; set `0` to disable) and
|
||||
one-shot embedded runs clean them up at run end
|
||||
Runtime adapters may normalize this shared registry into the shape their downstream client expects. For example, embedded Pi consumes OpenClaw `transport` values directly, while Claude Code and Gemini receive CLI-native `type` values such as `http`, `sse`, or `stdio`.
|
||||
|
||||
Runtime adapters may normalize this shared registry into the shape their
|
||||
downstream client expects. For example, embedded Pi consumes OpenClaw
|
||||
`transport` values directly, while Claude Code and Gemini receive CLI-native
|
||||
`type` values such as `http`, `sse`, or `stdio`.
|
||||
### Saved MCP server definitions
|
||||
|
||||
## Saved MCP server definitions
|
||||
|
||||
OpenClaw also stores a lightweight MCP server registry in config for surfaces
|
||||
that want OpenClaw-managed MCP definitions.
|
||||
OpenClaw also stores a lightweight MCP server registry in config for surfaces that want OpenClaw-managed MCP definitions.
|
||||
|
||||
Commands:
|
||||
|
||||
@@ -447,11 +422,13 @@ Launches a local child process and communicates over stdin/stdout.
|
||||
| `env` | Extra environment variables |
|
||||
| `cwd` / `workingDirectory` | Working directory for the process |
|
||||
|
||||
#### Stdio env safety filter
|
||||
<Warning>
|
||||
**Stdio env safety filter**
|
||||
|
||||
OpenClaw rejects interpreter-startup env keys that can alter how a stdio MCP server starts up before the first RPC, even if they appear in a server's `env` block. Blocked keys include `NODE_OPTIONS`, `PYTHONSTARTUP`, `PYTHONPATH`, `PERL5OPT`, `RUBYOPT`, `SHELLOPTS`, `PS4`, and similar runtime-control variables. Startup rejects these with a configuration error so they cannot inject an implicit prelude, swap the interpreter, or enable a debugger against the stdio process. Ordinary credential, proxy, and server-specific env vars (`GITHUB_TOKEN`, `HTTP_PROXY`, custom `*_API_KEY`, etc.) are unaffected.
|
||||
|
||||
If your MCP server genuinely needs one of the blocked variables, set it on the gateway host process instead of under the stdio server's `env`.
|
||||
</Warning>
|
||||
|
||||
### SSE / HTTP transport
|
||||
|
||||
@@ -480,8 +457,7 @@ Example:
|
||||
}
|
||||
```
|
||||
|
||||
Sensitive values in `url` (userinfo) and `headers` are redacted in logs and
|
||||
status output.
|
||||
Sensitive values in `url` (userinfo) and `headers` are redacted in logs and status output.
|
||||
|
||||
### Streamable HTTP transport
|
||||
|
||||
@@ -513,8 +489,9 @@ Example:
|
||||
}
|
||||
```
|
||||
|
||||
These commands manage saved config only. They do not start the channel bridge,
|
||||
open a live MCP client session, or prove the target server is reachable.
|
||||
<Note>
|
||||
These commands manage saved config only. They do not start the channel bridge, open a live MCP client session, or prove the target server is reachable.
|
||||
</Note>
|
||||
|
||||
## Current limits
|
||||
|
||||
@@ -526,8 +503,7 @@ Current limits:
|
||||
- no generic push protocol beyond the Claude-specific adapter
|
||||
- no message edit or react tools yet
|
||||
- HTTP/SSE/streamable-http transport connects to a single remote server; no multiplexed upstream yet
|
||||
- `permissions_list_open` only includes approvals observed while the bridge is
|
||||
connected
|
||||
- `permissions_list_open` only includes approvals observed while the bridge is connected
|
||||
|
||||
## Related
|
||||
|
||||
|
||||
@@ -114,6 +114,12 @@ Use `openclaw node run` for a foreground node host (no service).
|
||||
|
||||
Service commands accept `--json` for machine-readable output.
|
||||
|
||||
The node host retries Gateway restart and network closes in-process. If the
|
||||
Gateway reports a terminal token/password/bootstrap auth pause, the node host
|
||||
logs the close detail and exits non-zero so launchd/systemd can restart it with
|
||||
fresh config and credentials. Pairing-required pauses stay in the foreground
|
||||
flow so the pending request can be approved.
|
||||
|
||||
## Pairing
|
||||
|
||||
The first connection creates a pending device pairing request (`role: node`) on the Gateway.
|
||||
|
||||
@@ -18,14 +18,24 @@ configuration. They are different layers:
|
||||
| ------------- | ------------------------------------- | ------------------------------------------------------------------- |
|
||||
| Provider | `openai`, `anthropic`, `openai-codex` | How OpenClaw authenticates, discovers models, and names model refs. |
|
||||
| Model | `gpt-5.5`, `claude-opus-4-6` | The model selected for the agent turn. |
|
||||
| Agent runtime | `pi`, `codex`, ACP-backed runtimes | The low level loop that executes the prepared turn. |
|
||||
| Agent runtime | `pi`, `codex`, `claude-cli` | The low level loop or backend that executes the prepared turn. |
|
||||
| Channel | Telegram, Discord, Slack, WhatsApp | Where messages enter and leave OpenClaw. |
|
||||
|
||||
You will also see the word **harness** in code and config. A harness is the
|
||||
implementation that provides an agent runtime. For example, the bundled Codex
|
||||
harness implements the `codex` runtime. The config key is still named
|
||||
`embeddedHarness` for compatibility, but user-facing docs and status output
|
||||
should generally say runtime.
|
||||
You will also see the word **harness** in code. A harness is the implementation
|
||||
that provides an agent runtime. For example, the bundled Codex harness
|
||||
implements the `codex` runtime. Public config uses `agentRuntime.id`; `openclaw
|
||||
doctor --fix` rewrites older runtime-policy keys to that shape.
|
||||
|
||||
There are two runtime families:
|
||||
|
||||
- **Embedded harnesses** run inside OpenClaw's prepared agent loop. Today this
|
||||
is the built-in `pi` runtime plus registered plugin harnesses such as
|
||||
`codex`.
|
||||
- **CLI backends** run a local CLI process while keeping the model ref
|
||||
canonical. For example, `anthropic/claude-opus-4-7` with
|
||||
`agentRuntime.id: "claude-cli"` means "select the Anthropic model, execute
|
||||
through Claude CLI." `claude-cli` is not an embedded harness id and must not
|
||||
be passed to AgentHarness selection.
|
||||
|
||||
## Three things named Codex
|
||||
|
||||
@@ -34,7 +44,7 @@ Most confusion comes from three different surfaces sharing the Codex name:
|
||||
| Surface | OpenClaw name/config | What it does |
|
||||
| ---------------------------------------------------- | ------------------------------------ | --------------------------------------------------------------------------------------------------- |
|
||||
| Codex OAuth provider route | `openai-codex/*` model refs | Uses ChatGPT/Codex subscription OAuth through the normal OpenClaw PI runner. |
|
||||
| Native Codex app-server runtime | `embeddedHarness.runtime: "codex"` | Runs the embedded agent turn through the bundled Codex app-server harness. |
|
||||
| Native Codex app-server runtime | `agentRuntime.id: "codex"` | Runs the embedded agent turn through the bundled Codex app-server harness. |
|
||||
| Codex ACP adapter | `runtime: "acp"`, `agentId: "codex"` | Runs Codex through the external ACP/acpx control plane. Use only when ACP/acpx is explicitly asked. |
|
||||
| Native Codex chat-control command set | `/codex ...` | Binds, resumes, steers, stops, and inspects Codex app-server threads from chat. |
|
||||
| OpenAI Platform API route for GPT/Codex-style models | `openai/*` model refs | Uses OpenAI API-key auth unless a runtime override, such as `runtime: "codex"`, runs the turn. |
|
||||
@@ -52,8 +62,8 @@ The common Codex setup uses the `openai` provider with the `codex` runtime:
|
||||
agents: {
|
||||
defaults: {
|
||||
model: "openai/gpt-5.5",
|
||||
embeddedHarness: {
|
||||
runtime: "codex",
|
||||
agentRuntime: {
|
||||
id: "codex",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -76,7 +86,7 @@ This is the agent-facing decision tree:
|
||||
1. If the user asks for **Codex bind/control/thread/resume/steer/stop**, use the
|
||||
native `/codex` command surface when the bundled `codex` plugin is enabled.
|
||||
2. If the user asks for **Codex as the embedded runtime**, use
|
||||
`openai/<model>` with `embeddedHarness.runtime: "codex"`.
|
||||
`openai/<model>` with `agentRuntime.id: "codex"`.
|
||||
3. If the user asks for **Codex OAuth/subscription auth on the normal OpenClaw
|
||||
runner**, use `openai-codex/<model>` and leave the runtime as PI.
|
||||
4. If the user explicitly says **ACP**, **acpx**, or **Codex ACP adapter**, use
|
||||
@@ -87,7 +97,7 @@ This is the agent-facing decision tree:
|
||||
| You mean... | Use... |
|
||||
| --------------------------------------- | -------------------------------------------- |
|
||||
| Codex app-server chat/thread control | `/codex ...` from the bundled `codex` plugin |
|
||||
| Codex app-server embedded agent runtime | `embeddedHarness.runtime: "codex"` |
|
||||
| Codex app-server embedded agent runtime | `agentRuntime.id: "codex"` |
|
||||
| OpenAI Codex OAuth on the PI runner | `openai-codex/*` model refs |
|
||||
| Claude Code or other external harness | ACP/acpx |
|
||||
|
||||
@@ -122,9 +132,9 @@ OpenClaw chooses an embedded runtime after provider and model resolution:
|
||||
1. A session's recorded runtime wins. Config changes do not hot-switch an
|
||||
existing transcript to a different native thread system.
|
||||
2. `OPENCLAW_AGENT_RUNTIME=<id>` forces that runtime for new or reset sessions.
|
||||
3. `agents.defaults.embeddedHarness.runtime` or
|
||||
`agents.list[].embeddedHarness.runtime` can set `auto`, `pi`, or a registered
|
||||
runtime id such as `codex`.
|
||||
3. `agents.defaults.agentRuntime.id` or `agents.list[].agentRuntime.id` can set
|
||||
`auto`, `pi`, a registered embedded harness id such as `codex`, or a
|
||||
supported CLI backend alias such as `claude-cli`.
|
||||
4. In `auto` mode, registered plugin runtimes can claim supported provider/model
|
||||
pairs.
|
||||
5. If no runtime claims a turn in `auto` mode and `fallback: "pi"` is set
|
||||
@@ -137,6 +147,24 @@ Explicit plugin runtimes fail closed by default. For example,
|
||||
a broader fallback setting, so an agent-level `runtime: "codex"` is not silently
|
||||
routed back to PI just because defaults used `fallback: "pi"`.
|
||||
|
||||
CLI backend aliases are different from embedded harness ids. The preferred
|
||||
Claude CLI form is:
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
model: "anthropic/claude-opus-4-7",
|
||||
agentRuntime: { id: "claude-cli" },
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Legacy refs such as `claude-cli/claude-opus-4-7` remain supported for
|
||||
compatibility, but new config should keep the provider/model canonical and put
|
||||
the execution backend in `agentRuntime.id`.
|
||||
|
||||
`auto` mode is intentionally conservative. Plugin runtimes can claim
|
||||
provider/model pairs they understand, but the Codex plugin does not claim the
|
||||
`openai-codex` provider in `auto` mode. That keeps
|
||||
@@ -146,7 +174,7 @@ moving subscription-auth configs onto the native app-server harness.
|
||||
If `openclaw doctor` warns that the `codex` plugin is enabled while
|
||||
`openai-codex/*` still routes through PI, treat that as a diagnosis, not a
|
||||
migration. Keep the config unchanged when PI Codex OAuth is what you want.
|
||||
Switch to `openai/<model>` plus `runtime: "codex"` only when you want native
|
||||
Switch to `openai/<model>` plus `agentRuntime.id: "codex"` only when you want native
|
||||
Codex app-server execution.
|
||||
|
||||
## Compatibility contract
|
||||
|
||||
@@ -24,17 +24,17 @@ Reference for **LLM/model providers** (not chat channels like WhatsApp/Telegram)
|
||||
|
||||
- `openai/<model>` uses the direct OpenAI API-key provider in PI.
|
||||
- `openai-codex/<model>` uses Codex OAuth in PI.
|
||||
- `openai/<model>` plus `agents.defaults.embeddedHarness.runtime: "codex"` uses the native Codex app-server harness.
|
||||
- `openai/<model>` plus `agents.defaults.agentRuntime.id: "codex"` uses the native Codex app-server harness.
|
||||
|
||||
See [OpenAI](/providers/openai) and [Codex harness](/plugins/codex-harness). If the provider/runtime split is confusing, read [Agent runtimes](/concepts/agent-runtimes) first.
|
||||
|
||||
Plugin auto-enable follows the same boundary: `openai-codex/<model>` belongs to the OpenAI plugin, while the Codex plugin is enabled by `embeddedHarness.runtime: "codex"` or legacy `codex/<model>` refs.
|
||||
Plugin auto-enable follows the same boundary: `openai-codex/<model>` belongs to the OpenAI plugin, while the Codex plugin is enabled by `agentRuntime.id: "codex"` or legacy `codex/<model>` refs.
|
||||
|
||||
GPT-5.5 is available through `openai/gpt-5.5` for direct API-key traffic, `openai-codex/gpt-5.5` in PI for Codex OAuth, and the native Codex app-server harness when `embeddedHarness.runtime: "codex"` is set.
|
||||
GPT-5.5 is available through `openai/gpt-5.5` for direct API-key traffic, `openai-codex/gpt-5.5` in PI for Codex OAuth, and the native Codex app-server harness when `agentRuntime.id: "codex"` is set.
|
||||
|
||||
</Accordion>
|
||||
<Accordion title="CLI runtimes">
|
||||
CLI runtimes use the same split: choose canonical model refs such as `anthropic/claude-*`, `google/gemini-*`, or `openai/gpt-*`, then set `agents.defaults.embeddedHarness.runtime` to `claude-cli`, `google-gemini-cli`, or `codex-cli` when you want a local CLI backend.
|
||||
CLI runtimes use the same split: choose canonical model refs such as `anthropic/claude-*`, `google/gemini-*`, or `openai/gpt-*`, then set `agents.defaults.agentRuntime.id` to `claude-cli`, `google-gemini-cli`, or `codex-cli` when you want a local CLI backend.
|
||||
|
||||
Legacy `claude-cli/*`, `google-gemini-cli/*`, and `codex-cli/*` refs migrate back to canonical provider refs with the runtime recorded separately.
|
||||
|
||||
@@ -108,6 +108,10 @@ OpenClaw ships with the pi‑ai catalog. These providers require **no** `models.
|
||||
- Example model: `anthropic/claude-opus-4-6`
|
||||
- CLI: `openclaw onboard --auth-choice apiKey`
|
||||
- Direct public Anthropic requests support the shared `/fast` toggle and `params.fastMode`, including API-key and OAuth-authenticated traffic sent to `api.anthropic.com`; OpenClaw maps that to Anthropic `service_tier` (`auto` vs `standard_only`)
|
||||
- Preferred Claude CLI config keeps the model ref canonical and selects the CLI
|
||||
backend separately: `anthropic/claude-opus-4-7` with
|
||||
`agents.defaults.agentRuntime.id: "claude-cli"`. Legacy
|
||||
`claude-cli/claude-opus-4-7` refs still work for compatibility.
|
||||
|
||||
<Note>
|
||||
Anthropic staff told us OpenClaw-style Claude CLI usage is allowed again, so OpenClaw treats Claude CLI reuse and `claude -p` usage as sanctioned for this integration unless Anthropic publishes a new policy. Anthropic setup-token remains available as a supported OpenClaw token path, but OpenClaw now prefers Claude CLI reuse and `claude -p` when available.
|
||||
@@ -124,7 +128,7 @@ Anthropic staff told us OpenClaw-style Claude CLI usage is allowed again, so Ope
|
||||
- Provider: `openai-codex`
|
||||
- Auth: OAuth (ChatGPT)
|
||||
- PI model ref: `openai-codex/gpt-5.5`
|
||||
- Native Codex app-server harness ref: `openai/gpt-5.5` with `agents.defaults.embeddedHarness.runtime: "codex"`
|
||||
- Native Codex app-server harness ref: `openai/gpt-5.5` with `agents.defaults.agentRuntime.id: "codex"`
|
||||
- Native Codex app-server harness docs: [Codex harness](/plugins/codex-harness)
|
||||
- Legacy model refs: `codex/gpt-*`
|
||||
- Plugin boundary: `openai-codex/*` loads the OpenAI plugin; the native Codex app-server plugin is selected only by the Codex harness runtime or legacy `codex/*` refs.
|
||||
|
||||
@@ -13,7 +13,7 @@ Quick provider overview + examples: [/concepts/model-providers](/concepts/model-
|
||||
Model refs choose a provider and model. They do not usually choose the
|
||||
low-level agent runtime. For example, `openai/gpt-5.5` can run through the
|
||||
normal OpenAI provider path or through the Codex app-server runtime, depending
|
||||
on `agents.defaults.embeddedHarness.runtime`. See
|
||||
on `agents.defaults.agentRuntime.id`. See
|
||||
[/concepts/agent-runtimes](/concepts/agent-runtimes).
|
||||
|
||||
## How model selection works
|
||||
|
||||
@@ -316,8 +316,8 @@ Time format in system prompt. Default: `auto` (OS preference).
|
||||
fallbacks: ["openai/gpt-5.4-mini"],
|
||||
},
|
||||
params: { cacheRetention: "long" }, // global default provider params
|
||||
embeddedHarness: {
|
||||
runtime: "pi", // pi | auto | registered harness id, e.g. codex
|
||||
agentRuntime: {
|
||||
id: "pi", // pi | auto | registered harness id, e.g. codex
|
||||
fallback: "pi", // pi | none
|
||||
},
|
||||
pdfMaxBytesMb: 10,
|
||||
@@ -373,25 +373,25 @@ Time format in system prompt. Default: `auto` (OS preference).
|
||||
- `params.extra_body`/`params.extraBody`: advanced pass-through JSON merged into `api: "openai-completions"` request bodies for OpenAI-compatible proxies. If it collides with generated request keys, the extra body wins; non-native completions routes still strip OpenAI-only `store` afterward.
|
||||
- `params.chat_template_kwargs`: vLLM/OpenAI-compatible chat-template arguments merged into top-level `api: "openai-completions"` request bodies. For `vllm/nemotron-3-*` with thinking off, OpenClaw automatically sends `enable_thinking: false` and `force_nonempty_content: true`; explicit `chat_template_kwargs` override those defaults, and `extra_body.chat_template_kwargs` still has final precedence.
|
||||
- `params.preserveThinking`: Z.AI-only opt-in for preserved thinking. When enabled and thinking is on, OpenClaw sends `thinking.clear_thinking: false` and replays prior `reasoning_content`; see [Z.AI thinking and preserved thinking](/providers/zai#thinking-and-preserved-thinking).
|
||||
- `embeddedHarness`: default low-level embedded agent runtime policy. Omitted runtime defaults to OpenClaw Pi. Use `runtime: "pi"` to force the built-in PI harness, `runtime: "auto"` to let registered plugin harnesses claim supported models, or a registered harness id such as `runtime: "codex"`. Set `fallback: "none"` to disable automatic PI fallback. Explicit plugin runtimes such as `codex` fail closed by default unless you set `fallback: "pi"` in the same override scope. Keep model refs canonical as `provider/model`; select Codex, Claude CLI, Gemini CLI, and other execution backends through runtime config instead of legacy runtime provider prefixes. See [Agent runtimes](/concepts/agent-runtimes) for how this differs from provider/model selection.
|
||||
- `agentRuntime`: default low-level agent runtime policy. Omitted id defaults to OpenClaw Pi. Use `id: "pi"` to force the built-in PI harness, `id: "auto"` to let registered plugin harnesses claim supported models, a registered harness id such as `id: "codex"`, or a supported CLI backend alias such as `id: "claude-cli"`. Set `fallback: "none"` to disable automatic PI fallback. Explicit plugin runtimes such as `codex` fail closed by default unless you set `fallback: "pi"` in the same override scope. Keep model refs canonical as `provider/model`; select Codex, Claude CLI, Gemini CLI, and other execution backends through runtime config instead of legacy runtime provider prefixes. See [Agent runtimes](/concepts/agent-runtimes) for how this differs from provider/model selection.
|
||||
- Config writers that mutate these fields (for example `/models set`, `/models set-image`, and fallback add/remove commands) save canonical object form and preserve existing fallback lists when possible.
|
||||
- `maxConcurrent`: max parallel agent runs across sessions (each session still serialized). Default: 4.
|
||||
|
||||
### `agents.defaults.embeddedHarness`
|
||||
### `agents.defaults.agentRuntime`
|
||||
|
||||
`embeddedHarness` controls which low-level executor runs embedded agent turns.
|
||||
Most deployments should keep the default OpenClaw Pi runtime.
|
||||
Use it when a trusted plugin provides a native harness, such as the bundled
|
||||
Codex app-server harness. For the mental model, see
|
||||
[Agent runtimes](/concepts/agent-runtimes).
|
||||
`agentRuntime` controls which low-level executor runs agent turns. Most
|
||||
deployments should keep the default OpenClaw Pi runtime. Use it when a trusted
|
||||
plugin provides a native harness, such as the bundled Codex app-server harness,
|
||||
or when you want a supported CLI backend such as Claude CLI. For the mental
|
||||
model, see [Agent runtimes](/concepts/agent-runtimes).
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
model: "openai/gpt-5.5",
|
||||
embeddedHarness: {
|
||||
runtime: "codex",
|
||||
agentRuntime: {
|
||||
id: "codex",
|
||||
fallback: "none",
|
||||
},
|
||||
},
|
||||
@@ -399,12 +399,14 @@ Codex app-server harness. For the mental model, see
|
||||
}
|
||||
```
|
||||
|
||||
- `runtime`: `"auto"`, `"pi"`, or a registered plugin harness id. The bundled Codex plugin registers `codex`.
|
||||
- `fallback`: `"pi"` or `"none"`. In `runtime: "auto"`, omitted fallback defaults to `"pi"` so old configs can keep using PI when no plugin harness claims a run. In explicit plugin runtime mode, such as `runtime: "codex"`, omitted fallback defaults to `"none"` so a missing harness fails instead of silently using PI. Runtime overrides do not inherit fallback from a broader scope; set `fallback: "pi"` alongside the explicit runtime when you intentionally want that compatibility fallback. Selected plugin harness failures always surface directly.
|
||||
- Environment overrides: `OPENCLAW_AGENT_RUNTIME=<id|auto|pi>` overrides `runtime`; `OPENCLAW_AGENT_HARNESS_FALLBACK=pi|none` overrides fallback for that process.
|
||||
- For Codex-only deployments, set `model: "openai/gpt-5.5"` and `embeddedHarness.runtime: "codex"`. You may also set `embeddedHarness.fallback: "none"` explicitly for readability; it is the default for explicit plugin runtimes.
|
||||
- `id`: `"auto"`, `"pi"`, a registered plugin harness id, or a supported CLI backend alias. The bundled Codex plugin registers `codex`; the bundled Anthropic plugin provides the `claude-cli` CLI backend.
|
||||
- `fallback`: `"pi"` or `"none"`. In `id: "auto"`, omitted fallback defaults to `"pi"` so old configs can keep using PI when no plugin harness claims a run. In explicit plugin runtime mode, such as `id: "codex"`, omitted fallback defaults to `"none"` so a missing harness fails instead of silently using PI. Runtime overrides do not inherit fallback from a broader scope; set `fallback: "pi"` alongside the explicit runtime when you intentionally want that compatibility fallback. Selected plugin harness failures always surface directly.
|
||||
- Environment overrides: `OPENCLAW_AGENT_RUNTIME=<id|auto|pi>` overrides `id`; `OPENCLAW_AGENT_HARNESS_FALLBACK=pi|none` overrides fallback for that process.
|
||||
- For Codex-only deployments, set `model: "openai/gpt-5.5"` and `agentRuntime.id: "codex"`. You may also set `agentRuntime.fallback: "none"` explicitly for readability; it is the default for explicit plugin runtimes.
|
||||
- For Claude CLI deployments, prefer `model: "anthropic/claude-opus-4-7"` plus `agentRuntime.id: "claude-cli"`. Legacy `claude-cli/claude-opus-4-7` model refs still work for compatibility, but new config should keep provider/model selection canonical and put the execution backend in `agentRuntime.id`.
|
||||
- Older runtime-policy keys are rewritten to `agentRuntime` by `openclaw doctor --fix`.
|
||||
- Harness choice is pinned per session id after the first embedded run. Config/env changes affect new or reset sessions, not an existing transcript. Legacy sessions with transcript history but no recorded pin are treated as PI-pinned. `/status` reports the effective runtime, for example `Runtime: OpenClaw Pi Default` or `Runtime: OpenAI Codex`.
|
||||
- This only controls the embedded chat harness. Media generation, vision, PDF, music, video, and TTS still use their provider/model settings.
|
||||
- This only controls text agent-turn execution. Media generation, vision, PDF, music, video, and TTS still use their provider/model settings.
|
||||
|
||||
**Built-in alias shorthands** (only apply when the model is in `agents.defaults.models`):
|
||||
|
||||
@@ -923,7 +925,7 @@ for provider examples and precedence.
|
||||
thinkingDefault: "high", // per-agent thinking level override
|
||||
reasoningDefault: "on", // per-agent reasoning visibility override
|
||||
fastModeDefault: false, // per-agent fast mode override
|
||||
embeddedHarness: { runtime: "auto", fallback: "pi" },
|
||||
agentRuntime: { id: "auto", fallback: "pi" },
|
||||
params: { cacheRetention: "none" }, // overrides matching defaults.models params by key
|
||||
tts: {
|
||||
providers: {
|
||||
@@ -970,7 +972,7 @@ for provider examples and precedence.
|
||||
- `thinkingDefault`: optional per-agent default thinking level (`off | minimal | low | medium | high | xhigh | adaptive | max`). Overrides `agents.defaults.thinkingDefault` for this agent when no per-message or session override is set. The selected provider/model profile controls which values are valid; for Google Gemini, `adaptive` keeps provider-owned dynamic thinking (`thinkingLevel` omitted on Gemini 3/3.1, `thinkingBudget: -1` on Gemini 2.5).
|
||||
- `reasoningDefault`: optional per-agent default reasoning visibility (`on | off | stream`). Applies when no per-message or session reasoning override is set.
|
||||
- `fastModeDefault`: optional per-agent default for fast mode (`true | false`). Applies when no per-message or session fast-mode override is set.
|
||||
- `embeddedHarness`: optional per-agent low-level harness policy override. Use `{ runtime: "codex" }` to make one agent Codex-only while other agents keep the default PI fallback in `auto` mode.
|
||||
- `agentRuntime`: optional per-agent low-level runtime policy override. Use `{ id: "codex" }` to make one agent Codex-only while other agents keep the default PI fallback in `auto` mode.
|
||||
- `runtime`: optional per-agent runtime descriptor. Use `type: "acp"` with `runtime.acp` defaults (`agent`, `backend`, `mode`, `cwd`) when the agent should default to ACP harness sessions.
|
||||
- `identity.avatar`: workspace-relative path, `http(s)` URL, or `data:` URI.
|
||||
- `identity` derives defaults: `ackReaction` from `emoji`, `mentionPatterns` from `name`/`emoji`.
|
||||
|
||||
@@ -5,11 +5,10 @@ read_when:
|
||||
- Registering custom providers or overriding base URLs
|
||||
- Setting up OpenAI-compatible self-hosted endpoints
|
||||
title: "Configuration — tools and custom providers"
|
||||
sidebarTitle: "Tools and custom providers"
|
||||
---
|
||||
|
||||
`tools.*` config keys and custom provider / base-URL setup. For agents,
|
||||
channels, and other top-level config keys, see
|
||||
[Configuration reference](/gateway/configuration-reference).
|
||||
`tools.*` config keys and custom provider / base-URL setup. For agents, channels, and other top-level config keys, see [Configuration reference](/gateway/configuration-reference).
|
||||
|
||||
## Tools
|
||||
|
||||
@@ -17,7 +16,9 @@ channels, and other top-level config keys, see
|
||||
|
||||
`tools.profile` sets a base allowlist before `tools.allow`/`tools.deny`:
|
||||
|
||||
<Note>
|
||||
Local onboarding defaults new local configs to `tools.profile: "coding"` when unset (existing explicit profiles are preserved).
|
||||
</Note>
|
||||
|
||||
| Profile | Includes |
|
||||
| ----------- | ------------------------------------------------------------------------------------------------------------------------------- |
|
||||
@@ -113,8 +114,7 @@ Controls elevated exec access outside the sandbox:
|
||||
|
||||
### `tools.loopDetection`
|
||||
|
||||
Tool-loop safety checks are **disabled by default**. Set `enabled: true` to activate detection.
|
||||
Settings can be defined globally in `tools.loopDetection` and overridden per-agent at `agents.list[].tools.loopDetection`.
|
||||
Tool-loop safety checks are **disabled by default**. Set `enabled: true` to activate detection. Settings can be defined globally in `tools.loopDetection` and overridden per-agent at `agents.list[].tools.loopDetection`.
|
||||
|
||||
```json5
|
||||
{
|
||||
@@ -135,14 +135,31 @@ Settings can be defined globally in `tools.loopDetection` and overridden per-age
|
||||
}
|
||||
```
|
||||
|
||||
- `historySize`: max tool-call history retained for loop analysis.
|
||||
- `warningThreshold`: repeating no-progress pattern threshold for warnings.
|
||||
- `criticalThreshold`: higher repeating threshold for blocking critical loops.
|
||||
- `globalCircuitBreakerThreshold`: hard stop threshold for any no-progress run.
|
||||
- `detectors.genericRepeat`: warn on repeated same-tool/same-args calls.
|
||||
- `detectors.knownPollNoProgress`: warn/block on known poll tools (`process.poll`, `command_status`, etc.).
|
||||
- `detectors.pingPong`: warn/block on alternating no-progress pair patterns.
|
||||
- If `warningThreshold >= criticalThreshold` or `criticalThreshold >= globalCircuitBreakerThreshold`, validation fails.
|
||||
<ParamField path="historySize" type="number">
|
||||
Max tool-call history retained for loop analysis.
|
||||
</ParamField>
|
||||
<ParamField path="warningThreshold" type="number">
|
||||
Repeating no-progress pattern threshold for warnings.
|
||||
</ParamField>
|
||||
<ParamField path="criticalThreshold" type="number">
|
||||
Higher repeating threshold for blocking critical loops.
|
||||
</ParamField>
|
||||
<ParamField path="globalCircuitBreakerThreshold" type="number">
|
||||
Hard stop threshold for any no-progress run.
|
||||
</ParamField>
|
||||
<ParamField path="detectors.genericRepeat" type="boolean">
|
||||
Warn on repeated same-tool/same-args calls.
|
||||
</ParamField>
|
||||
<ParamField path="detectors.knownPollNoProgress" type="boolean">
|
||||
Warn/block on known poll tools (`process.poll`, `command_status`, etc.).
|
||||
</ParamField>
|
||||
<ParamField path="detectors.pingPong" type="boolean">
|
||||
Warn/block on alternating no-progress pair patterns.
|
||||
</ParamField>
|
||||
|
||||
<Warning>
|
||||
If `warningThreshold >= criticalThreshold` or `criticalThreshold >= globalCircuitBreakerThreshold`, validation fails.
|
||||
</Warning>
|
||||
|
||||
### `tools.web`
|
||||
|
||||
@@ -208,34 +225,33 @@ Configures inbound media understanding (image/audio/video):
|
||||
}
|
||||
```
|
||||
|
||||
<Accordion title="Media model entry fields">
|
||||
<AccordionGroup>
|
||||
<Accordion title="Media model entry fields">
|
||||
**Provider entry** (`type: "provider"` or omitted):
|
||||
|
||||
**Provider entry** (`type: "provider"` or omitted):
|
||||
- `provider`: API provider id (`openai`, `anthropic`, `google`/`gemini`, `groq`, etc.)
|
||||
- `model`: model id override
|
||||
- `profile` / `preferredProfile`: `auth-profiles.json` profile selection
|
||||
|
||||
- `provider`: API provider id (`openai`, `anthropic`, `google`/`gemini`, `groq`, etc.)
|
||||
- `model`: model id override
|
||||
- `profile` / `preferredProfile`: `auth-profiles.json` profile selection
|
||||
**CLI entry** (`type: "cli"`):
|
||||
|
||||
**CLI entry** (`type: "cli"`):
|
||||
- `command`: executable to run
|
||||
- `args`: templated args (supports `{{MediaPath}}`, `{{Prompt}}`, `{{MaxChars}}`, etc.)
|
||||
|
||||
- `command`: executable to run
|
||||
- `args`: templated args (supports `{{MediaPath}}`, `{{Prompt}}`, `{{MaxChars}}`, etc.)
|
||||
**Common fields:**
|
||||
|
||||
**Common fields:**
|
||||
- `capabilities`: optional list (`image`, `audio`, `video`). Defaults: `openai`/`anthropic`/`minimax` → image, `google` → image+audio+video, `groq` → audio.
|
||||
- `prompt`, `maxChars`, `maxBytes`, `timeoutSeconds`, `language`: per-entry overrides.
|
||||
- Failures fall back to the next entry.
|
||||
|
||||
- `capabilities`: optional list (`image`, `audio`, `video`). Defaults: `openai`/`anthropic`/`minimax` → image, `google` → image+audio+video, `groq` → audio.
|
||||
- `prompt`, `maxChars`, `maxBytes`, `timeoutSeconds`, `language`: per-entry overrides.
|
||||
- Failures fall back to the next entry.
|
||||
Provider auth follows standard order: `auth-profiles.json` → env vars → `models.providers.*.apiKey`.
|
||||
|
||||
Provider auth follows standard order: `auth-profiles.json` → env vars → `models.providers.*.apiKey`.
|
||||
**Async completion fields:**
|
||||
|
||||
**Async completion fields:**
|
||||
- `asyncCompletion.directSend`: when `true`, completed async `music_generate` and `video_generate` tasks try direct channel delivery first. Default: `false` (legacy requester-session wake/model-delivery path).
|
||||
|
||||
- `asyncCompletion.directSend`: when `true`, completed async `music_generate`
|
||||
and `video_generate` tasks try direct channel delivery first. Default: `false`
|
||||
(legacy requester-session wake/model-delivery path).
|
||||
|
||||
</Accordion>
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
### `tools.agentToAgent`
|
||||
|
||||
@@ -267,13 +283,15 @@ Default: `tree` (current session + sessions spawned by it, such as subagents).
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- `self`: only the current session key.
|
||||
- `tree`: current session + sessions spawned by the current session (subagents).
|
||||
- `agent`: any session belonging to the current agent id (can include other users if you run per-sender sessions under the same agent id).
|
||||
- `all`: any session. Cross-agent targeting still requires `tools.agentToAgent`.
|
||||
- Sandbox clamp: when the current session is sandboxed and `agents.defaults.sandbox.sessionToolsVisibility="spawned"`, visibility is forced to `tree` even if `tools.sessions.visibility="all"`.
|
||||
<AccordionGroup>
|
||||
<Accordion title="Visibility scopes">
|
||||
- `self`: only the current session key.
|
||||
- `tree`: current session + sessions spawned by the current session (subagents).
|
||||
- `agent`: any session belonging to the current agent id (can include other users if you run per-sender sessions under the same agent id).
|
||||
- `all`: any session. Cross-agent targeting still requires `tools.agentToAgent`.
|
||||
- Sandbox clamp: when the current session is sandboxed and `agents.defaults.sandbox.sessionToolsVisibility="spawned"`, visibility is forced to `tree` even if `tools.sessions.visibility="all"`.
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
### `tools.sessions_spawn`
|
||||
|
||||
@@ -295,14 +313,16 @@ Controls inline attachment support for `sessions_spawn`.
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- Attachments are only supported for `runtime: "subagent"`. ACP runtime rejects them.
|
||||
- Files are materialized into the child workspace at `.openclaw/attachments/<uuid>/` with a `.manifest.json`.
|
||||
- Attachment content is automatically redacted from transcript persistence.
|
||||
- Base64 inputs are validated with strict alphabet/padding checks and a pre-decode size guard.
|
||||
- File permissions are `0700` for directories and `0600` for files.
|
||||
- Cleanup follows the `cleanup` policy: `delete` always removes attachments; `keep` retains them only when `retainOnSessionKeep: true`.
|
||||
<AccordionGroup>
|
||||
<Accordion title="Attachment notes">
|
||||
- Attachments are only supported for `runtime: "subagent"`. ACP runtime rejects them.
|
||||
- Files are materialized into the child workspace at `.openclaw/attachments/<uuid>/` with a `.manifest.json`.
|
||||
- Attachment content is automatically redacted from transcript persistence.
|
||||
- Base64 inputs are validated with strict alphabet/padding checks and a pre-decode size guard.
|
||||
- File permissions are `0700` for directories and `0600` for files.
|
||||
- Cleanup follows the `cleanup` policy: `delete` always removes attachments; `keep` retains them only when `retainOnSessionKeep: true`.
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
<a id="toolsexperimental"></a>
|
||||
|
||||
@@ -320,8 +340,6 @@ Experimental built-in tool flags. Default off unless a strict-agentic GPT-5 auto
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- `planTool`: enables the structured `update_plan` tool for non-trivial multi-step work tracking.
|
||||
- Default: `false` unless `agents.defaults.embeddedPi.executionContract` (or a per-agent override) is set to `"strict-agentic"` for an OpenAI or OpenAI Codex GPT-5-family run. Set `true` to force the tool on outside that scope, or `false` to keep it off even for strict-agentic GPT-5 runs.
|
||||
- When enabled, the system prompt also adds usage guidance so the model only uses it for substantial work and keeps at most one step `in_progress`.
|
||||
@@ -382,286 +400,281 @@ OpenClaw uses the built-in model catalog. Add custom providers via `models.provi
|
||||
}
|
||||
```
|
||||
|
||||
- Use `authHeader: true` + `headers` for custom auth needs.
|
||||
- Override agent config root with `OPENCLAW_AGENT_DIR` (or `PI_CODING_AGENT_DIR`, a legacy environment variable alias).
|
||||
- Merge precedence for matching provider IDs:
|
||||
- Non-empty agent `models.json` `baseUrl` values win.
|
||||
- Non-empty agent `apiKey` values win only when that provider is not SecretRef-managed in current config/auth-profile context.
|
||||
- SecretRef-managed provider `apiKey` values are refreshed from source markers (`ENV_VAR_NAME` for env refs, `secretref-managed` for file/exec refs) instead of persisting resolved secrets.
|
||||
- SecretRef-managed provider header values are refreshed from source markers (`secretref-env:ENV_VAR_NAME` for env refs, `secretref-managed` for file/exec refs).
|
||||
- Empty or missing agent `apiKey`/`baseUrl` fall back to `models.providers` in config.
|
||||
- Matching model `contextWindow`/`maxTokens` use the higher value between explicit config and implicit catalog values.
|
||||
- Matching model `contextTokens` preserves an explicit runtime cap when present; use it to limit effective context without changing native model metadata.
|
||||
- Use `models.mode: "replace"` when you want config to fully rewrite `models.json`.
|
||||
- Marker persistence is source-authoritative: markers are written from the active source config snapshot (pre-resolution), not from resolved runtime secret values.
|
||||
<AccordionGroup>
|
||||
<Accordion title="Auth and merge precedence">
|
||||
- Use `authHeader: true` + `headers` for custom auth needs.
|
||||
- Override agent config root with `OPENCLAW_AGENT_DIR` (or `PI_CODING_AGENT_DIR`, a legacy environment variable alias).
|
||||
- Merge precedence for matching provider IDs:
|
||||
- Non-empty agent `models.json` `baseUrl` values win.
|
||||
- Non-empty agent `apiKey` values win only when that provider is not SecretRef-managed in current config/auth-profile context.
|
||||
- SecretRef-managed provider `apiKey` values are refreshed from source markers (`ENV_VAR_NAME` for env refs, `secretref-managed` for file/exec refs) instead of persisting resolved secrets.
|
||||
- SecretRef-managed provider header values are refreshed from source markers (`secretref-env:ENV_VAR_NAME` for env refs, `secretref-managed` for file/exec refs).
|
||||
- Empty or missing agent `apiKey`/`baseUrl` fall back to `models.providers` in config.
|
||||
- Matching model `contextWindow`/`maxTokens` use the higher value between explicit config and implicit catalog values.
|
||||
- Matching model `contextTokens` preserves an explicit runtime cap when present; use it to limit effective context without changing native model metadata.
|
||||
- Use `models.mode: "replace"` when you want config to fully rewrite `models.json`.
|
||||
- Marker persistence is source-authoritative: markers are written from the active source config snapshot (pre-resolution), not from resolved runtime secret values.
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
### Provider field details
|
||||
|
||||
- `models.mode`: provider catalog behavior (`merge` or `replace`).
|
||||
- `models.providers`: custom provider map keyed by provider id.
|
||||
- Safe edits: use `openclaw config set models.providers.<id> '<json>' --strict-json --merge` or `openclaw config set models.providers.<id>.models '<json-array>' --strict-json --merge` for additive updates. `config set` refuses destructive replacements unless you pass `--replace`.
|
||||
- `models.providers.*.api`: request adapter (`openai-completions`, `openai-responses`, `anthropic-messages`, `google-generative-ai`, etc).
|
||||
- `models.providers.*.apiKey`: provider credential (prefer SecretRef/env substitution).
|
||||
- `models.providers.*.auth`: auth strategy (`api-key`, `token`, `oauth`, `aws-sdk`).
|
||||
- `models.providers.*.injectNumCtxForOpenAICompat`: for Ollama + `openai-completions`, inject `options.num_ctx` into requests (default: `true`).
|
||||
- `models.providers.*.authHeader`: force credential transport in the `Authorization` header when required.
|
||||
- `models.providers.*.baseUrl`: upstream API base URL.
|
||||
- `models.providers.*.headers`: extra static headers for proxy/tenant routing.
|
||||
- `models.providers.*.request`: transport overrides for model-provider HTTP requests.
|
||||
- `request.headers`: extra headers (merged with provider defaults). Values accept SecretRef.
|
||||
- `request.auth`: auth strategy override. Modes: `"provider-default"` (use provider's built-in auth), `"authorization-bearer"` (with `token`), `"header"` (with `headerName`, `value`, optional `prefix`).
|
||||
- `request.proxy`: HTTP proxy override. Modes: `"env-proxy"` (use `HTTP_PROXY`/`HTTPS_PROXY` env vars), `"explicit-proxy"` (with `url`). Both modes accept an optional `tls` sub-object.
|
||||
- `request.tls`: TLS override for direct connections. Fields: `ca`, `cert`, `key`, `passphrase` (all accept SecretRef), `serverName`, `insecureSkipVerify`.
|
||||
- `request.allowPrivateNetwork`: when `true`, allow HTTPS to `baseUrl` when DNS resolves to private, CGNAT, or similar ranges, via the provider HTTP fetch guard (operator opt-in for trusted self-hosted OpenAI-compatible endpoints). WebSocket uses the same `request` for headers/TLS but not that fetch SSRF gate. Default `false`.
|
||||
- `models.providers.*.models`: explicit provider model catalog entries.
|
||||
- `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`; `openclaw models list` shows both values when they differ.
|
||||
- `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.
|
||||
- `plugins.entries.amazon-bedrock.config.discovery.providerFilter`: optional provider-id filter for targeted discovery.
|
||||
- `plugins.entries.amazon-bedrock.config.discovery.refreshInterval`: polling interval for discovery refresh.
|
||||
- `plugins.entries.amazon-bedrock.config.discovery.defaultContextWindow`: fallback context window for discovered models.
|
||||
- `plugins.entries.amazon-bedrock.config.discovery.defaultMaxTokens`: fallback max output tokens for discovered models.
|
||||
<AccordionGroup>
|
||||
<Accordion title="Top-level catalog">
|
||||
- `models.mode`: provider catalog behavior (`merge` or `replace`).
|
||||
- `models.providers`: custom provider map keyed by provider id.
|
||||
- Safe edits: use `openclaw config set models.providers.<id> '<json>' --strict-json --merge` or `openclaw config set models.providers.<id>.models '<json-array>' --strict-json --merge` for additive updates. `config set` refuses destructive replacements unless you pass `--replace`.
|
||||
</Accordion>
|
||||
<Accordion title="Provider connection and auth">
|
||||
- `models.providers.*.api`: request adapter (`openai-completions`, `openai-responses`, `anthropic-messages`, `google-generative-ai`, etc).
|
||||
- `models.providers.*.apiKey`: provider credential (prefer SecretRef/env substitution).
|
||||
- `models.providers.*.auth`: auth strategy (`api-key`, `token`, `oauth`, `aws-sdk`).
|
||||
- `models.providers.*.injectNumCtxForOpenAICompat`: for Ollama + `openai-completions`, inject `options.num_ctx` into requests (default: `true`).
|
||||
- `models.providers.*.authHeader`: force credential transport in the `Authorization` header when required.
|
||||
- `models.providers.*.baseUrl`: upstream API base URL.
|
||||
- `models.providers.*.headers`: extra static headers for proxy/tenant routing.
|
||||
</Accordion>
|
||||
<Accordion title="Request transport overrides">
|
||||
`models.providers.*.request`: transport overrides for model-provider HTTP requests.
|
||||
|
||||
- `request.headers`: extra headers (merged with provider defaults). Values accept SecretRef.
|
||||
- `request.auth`: auth strategy override. Modes: `"provider-default"` (use provider's built-in auth), `"authorization-bearer"` (with `token`), `"header"` (with `headerName`, `value`, optional `prefix`).
|
||||
- `request.proxy`: HTTP proxy override. Modes: `"env-proxy"` (use `HTTP_PROXY`/`HTTPS_PROXY` env vars), `"explicit-proxy"` (with `url`). Both modes accept an optional `tls` sub-object.
|
||||
- `request.tls`: TLS override for direct connections. Fields: `ca`, `cert`, `key`, `passphrase` (all accept SecretRef), `serverName`, `insecureSkipVerify`.
|
||||
- `request.allowPrivateNetwork`: when `true`, allow HTTPS to `baseUrl` when DNS resolves to private, CGNAT, or similar ranges, via the provider HTTP fetch guard (operator opt-in for trusted self-hosted OpenAI-compatible endpoints). WebSocket uses the same `request` for headers/TLS but not that fetch SSRF gate. Default `false`.
|
||||
|
||||
</Accordion>
|
||||
<Accordion title="Model catalog entries">
|
||||
- `models.providers.*.models`: explicit provider model catalog entries.
|
||||
- `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`; `openclaw models list` shows both values when they differ.
|
||||
- `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.
|
||||
</Accordion>
|
||||
<Accordion title="Amazon Bedrock discovery">
|
||||
- `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.
|
||||
- `plugins.entries.amazon-bedrock.config.discovery.providerFilter`: optional provider-id filter for targeted discovery.
|
||||
- `plugins.entries.amazon-bedrock.config.discovery.refreshInterval`: polling interval for discovery refresh.
|
||||
- `plugins.entries.amazon-bedrock.config.discovery.defaultContextWindow`: fallback context window for discovered models.
|
||||
- `plugins.entries.amazon-bedrock.config.discovery.defaultMaxTokens`: fallback max output tokens for discovered models.
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
### Provider examples
|
||||
|
||||
<Accordion title="Cerebras (GLM 4.6 / 4.7)">
|
||||
|
||||
```json5
|
||||
{
|
||||
env: { CEREBRAS_API_KEY: "sk-..." },
|
||||
agents: {
|
||||
defaults: {
|
||||
model: {
|
||||
primary: "cerebras/zai-glm-4.7",
|
||||
fallbacks: ["cerebras/zai-glm-4.6"],
|
||||
<AccordionGroup>
|
||||
<Accordion title="Cerebras (GLM 4.6 / 4.7)">
|
||||
```json5
|
||||
{
|
||||
env: { CEREBRAS_API_KEY: "sk-..." },
|
||||
agents: {
|
||||
defaults: {
|
||||
model: {
|
||||
primary: "cerebras/zai-glm-4.7",
|
||||
fallbacks: ["cerebras/zai-glm-4.6"],
|
||||
},
|
||||
models: {
|
||||
"cerebras/zai-glm-4.7": { alias: "GLM 4.7 (Cerebras)" },
|
||||
"cerebras/zai-glm-4.6": { alias: "GLM 4.6 (Cerebras)" },
|
||||
},
|
||||
},
|
||||
},
|
||||
models: {
|
||||
"cerebras/zai-glm-4.7": { alias: "GLM 4.7 (Cerebras)" },
|
||||
"cerebras/zai-glm-4.6": { alias: "GLM 4.6 (Cerebras)" },
|
||||
},
|
||||
},
|
||||
},
|
||||
models: {
|
||||
mode: "merge",
|
||||
providers: {
|
||||
cerebras: {
|
||||
baseUrl: "https://api.cerebras.ai/v1",
|
||||
apiKey: "${CEREBRAS_API_KEY}",
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
{ id: "zai-glm-4.7", name: "GLM 4.7 (Cerebras)" },
|
||||
{ id: "zai-glm-4.6", name: "GLM 4.6 (Cerebras)" },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Use `cerebras/zai-glm-4.7` for Cerebras; `zai/glm-4.7` for Z.AI direct.
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="OpenCode">
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "opencode/claude-opus-4-6" },
|
||||
models: { "opencode/claude-opus-4-6": { alias: "Opus" } },
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Set `OPENCODE_API_KEY` (or `OPENCODE_ZEN_API_KEY`). Use `opencode/...` refs for the Zen catalog or `opencode-go/...` refs for the Go catalog. Shortcut: `openclaw onboard --auth-choice opencode-zen` or `openclaw onboard --auth-choice opencode-go`.
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Z.AI (GLM-4.7)">
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "zai/glm-4.7" },
|
||||
models: { "zai/glm-4.7": {} },
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Set `ZAI_API_KEY`. `z.ai/*` and `z-ai/*` are accepted aliases. Shortcut: `openclaw onboard --auth-choice zai-api-key`.
|
||||
|
||||
- General endpoint: `https://api.z.ai/api/paas/v4`
|
||||
- Coding endpoint (default): `https://api.z.ai/api/coding/paas/v4`
|
||||
- For the general endpoint, define a custom provider with the base URL override.
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Moonshot AI (Kimi)">
|
||||
|
||||
```json5
|
||||
{
|
||||
env: { MOONSHOT_API_KEY: "sk-..." },
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "moonshot/kimi-k2.6" },
|
||||
models: { "moonshot/kimi-k2.6": { alias: "Kimi K2.6" } },
|
||||
},
|
||||
},
|
||||
models: {
|
||||
mode: "merge",
|
||||
providers: {
|
||||
moonshot: {
|
||||
baseUrl: "https://api.moonshot.ai/v1",
|
||||
apiKey: "${MOONSHOT_API_KEY}",
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
{
|
||||
id: "kimi-k2.6",
|
||||
name: "Kimi K2.6",
|
||||
reasoning: false,
|
||||
input: ["text", "image"],
|
||||
cost: { input: 0.95, output: 4, cacheRead: 0.16, cacheWrite: 0 },
|
||||
contextWindow: 262144,
|
||||
maxTokens: 262144,
|
||||
mode: "merge",
|
||||
providers: {
|
||||
cerebras: {
|
||||
baseUrl: "https://api.cerebras.ai/v1",
|
||||
apiKey: "${CEREBRAS_API_KEY}",
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
{ id: "zai-glm-4.7", name: "GLM 4.7 (Cerebras)" },
|
||||
{ id: "zai-glm-4.6", name: "GLM 4.6 (Cerebras)" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
}
|
||||
```
|
||||
|
||||
For the China endpoint: `baseUrl: "https://api.moonshot.cn/v1"` or `openclaw onboard --auth-choice moonshot-api-key-cn`.
|
||||
Use `cerebras/zai-glm-4.7` for Cerebras; `zai/glm-4.7` for Z.AI direct.
|
||||
|
||||
Native Moonshot endpoints advertise streaming usage compatibility on the shared
|
||||
`openai-completions` transport, and OpenClaw keys that off endpoint capabilities
|
||||
rather than the built-in provider id alone.
|
||||
</Accordion>
|
||||
<Accordion title="Kimi Coding">
|
||||
```json5
|
||||
{
|
||||
env: { KIMI_API_KEY: "sk-..." },
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "kimi/kimi-code" },
|
||||
models: { "kimi/kimi-code": { alias: "Kimi Code" } },
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
</Accordion>
|
||||
Anthropic-compatible, built-in provider. Shortcut: `openclaw onboard --auth-choice kimi-code-api-key`.
|
||||
|
||||
<Accordion title="Kimi Coding">
|
||||
|
||||
```json5
|
||||
{
|
||||
env: { KIMI_API_KEY: "sk-..." },
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "kimi/kimi-code" },
|
||||
models: { "kimi/kimi-code": { alias: "Kimi Code" } },
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Anthropic-compatible, built-in provider. Shortcut: `openclaw onboard --auth-choice kimi-code-api-key`.
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Synthetic (Anthropic-compatible)">
|
||||
|
||||
```json5
|
||||
{
|
||||
env: { SYNTHETIC_API_KEY: "sk-..." },
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "synthetic/hf:MiniMaxAI/MiniMax-M2.5" },
|
||||
models: { "synthetic/hf:MiniMaxAI/MiniMax-M2.5": { alias: "MiniMax M2.5" } },
|
||||
},
|
||||
},
|
||||
models: {
|
||||
mode: "merge",
|
||||
providers: {
|
||||
synthetic: {
|
||||
baseUrl: "https://api.synthetic.new/anthropic",
|
||||
apiKey: "${SYNTHETIC_API_KEY}",
|
||||
api: "anthropic-messages",
|
||||
models: [
|
||||
{
|
||||
id: "hf:MiniMaxAI/MiniMax-M2.5",
|
||||
name: "MiniMax M2.5",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 192000,
|
||||
maxTokens: 65536,
|
||||
</Accordion>
|
||||
<Accordion title="Local models (LM Studio)">
|
||||
See [Local Models](/gateway/local-models). TL;DR: run a large local model via LM Studio Responses API on serious hardware; keep hosted models merged for fallback.
|
||||
</Accordion>
|
||||
<Accordion title="MiniMax M2.7 (direct)">
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "minimax/MiniMax-M2.7" },
|
||||
models: {
|
||||
"minimax/MiniMax-M2.7": { alias: "Minimax" },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Base URL should omit `/v1` (Anthropic client appends it). Shortcut: `openclaw onboard --auth-choice synthetic-api-key`.
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="MiniMax M2.7 (direct)">
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "minimax/MiniMax-M2.7" },
|
||||
models: {
|
||||
"minimax/MiniMax-M2.7": { alias: "Minimax" },
|
||||
},
|
||||
},
|
||||
},
|
||||
models: {
|
||||
mode: "merge",
|
||||
providers: {
|
||||
minimax: {
|
||||
baseUrl: "https://api.minimax.io/anthropic",
|
||||
apiKey: "${MINIMAX_API_KEY}",
|
||||
api: "anthropic-messages",
|
||||
models: [
|
||||
{
|
||||
id: "MiniMax-M2.7",
|
||||
name: "MiniMax M2.7",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: { input: 0.3, output: 1.2, cacheRead: 0.06, cacheWrite: 0.375 },
|
||||
contextWindow: 204800,
|
||||
maxTokens: 131072,
|
||||
mode: "merge",
|
||||
providers: {
|
||||
minimax: {
|
||||
baseUrl: "https://api.minimax.io/anthropic",
|
||||
apiKey: "${MINIMAX_API_KEY}",
|
||||
api: "anthropic-messages",
|
||||
models: [
|
||||
{
|
||||
id: "MiniMax-M2.7",
|
||||
name: "MiniMax M2.7",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: { input: 0.3, output: 1.2, cacheRead: 0.06, cacheWrite: 0.375 },
|
||||
contextWindow: 204800,
|
||||
maxTokens: 131072,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
}
|
||||
```
|
||||
|
||||
Set `MINIMAX_API_KEY`. Shortcuts:
|
||||
`openclaw onboard --auth-choice minimax-global-api` or
|
||||
`openclaw onboard --auth-choice minimax-cn-api`.
|
||||
The model catalog defaults to M2.7 only.
|
||||
On the Anthropic-compatible streaming path, OpenClaw disables MiniMax thinking
|
||||
by default unless you explicitly set `thinking` yourself. `/fast on` or
|
||||
`params.fastMode: true` rewrites `MiniMax-M2.7` to
|
||||
`MiniMax-M2.7-highspeed`.
|
||||
Set `MINIMAX_API_KEY`. Shortcuts: `openclaw onboard --auth-choice minimax-global-api` or `openclaw onboard --auth-choice minimax-cn-api`. The model catalog defaults to M2.7 only. On the Anthropic-compatible streaming path, OpenClaw disables MiniMax thinking by default unless you explicitly set `thinking` yourself. `/fast on` or `params.fastMode: true` rewrites `MiniMax-M2.7` to `MiniMax-M2.7-highspeed`.
|
||||
|
||||
</Accordion>
|
||||
</Accordion>
|
||||
<Accordion title="Moonshot AI (Kimi)">
|
||||
```json5
|
||||
{
|
||||
env: { MOONSHOT_API_KEY: "sk-..." },
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "moonshot/kimi-k2.6" },
|
||||
models: { "moonshot/kimi-k2.6": { alias: "Kimi K2.6" } },
|
||||
},
|
||||
},
|
||||
models: {
|
||||
mode: "merge",
|
||||
providers: {
|
||||
moonshot: {
|
||||
baseUrl: "https://api.moonshot.ai/v1",
|
||||
apiKey: "${MOONSHOT_API_KEY}",
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
{
|
||||
id: "kimi-k2.6",
|
||||
name: "Kimi K2.6",
|
||||
reasoning: false,
|
||||
input: ["text", "image"],
|
||||
cost: { input: 0.95, output: 4, cacheRead: 0.16, cacheWrite: 0 },
|
||||
contextWindow: 262144,
|
||||
maxTokens: 262144,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
<Accordion title="Local models (LM Studio)">
|
||||
For the China endpoint: `baseUrl: "https://api.moonshot.cn/v1"` or `openclaw onboard --auth-choice moonshot-api-key-cn`.
|
||||
|
||||
See [Local Models](/gateway/local-models). TL;DR: run a large local model via LM Studio Responses API on serious hardware; keep hosted models merged for fallback.
|
||||
Native Moonshot endpoints advertise streaming usage compatibility on the shared `openai-completions` transport, and OpenClaw keys that off endpoint capabilities rather than the built-in provider id alone.
|
||||
|
||||
</Accordion>
|
||||
</Accordion>
|
||||
<Accordion title="OpenCode">
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "opencode/claude-opus-4-6" },
|
||||
models: { "opencode/claude-opus-4-6": { alias: "Opus" } },
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Set `OPENCODE_API_KEY` (or `OPENCODE_ZEN_API_KEY`). Use `opencode/...` refs for the Zen catalog or `opencode-go/...` refs for the Go catalog. Shortcut: `openclaw onboard --auth-choice opencode-zen` or `openclaw onboard --auth-choice opencode-go`.
|
||||
|
||||
</Accordion>
|
||||
<Accordion title="Synthetic (Anthropic-compatible)">
|
||||
```json5
|
||||
{
|
||||
env: { SYNTHETIC_API_KEY: "sk-..." },
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "synthetic/hf:MiniMaxAI/MiniMax-M2.5" },
|
||||
models: { "synthetic/hf:MiniMaxAI/MiniMax-M2.5": { alias: "MiniMax M2.5" } },
|
||||
},
|
||||
},
|
||||
models: {
|
||||
mode: "merge",
|
||||
providers: {
|
||||
synthetic: {
|
||||
baseUrl: "https://api.synthetic.new/anthropic",
|
||||
apiKey: "${SYNTHETIC_API_KEY}",
|
||||
api: "anthropic-messages",
|
||||
models: [
|
||||
{
|
||||
id: "hf:MiniMaxAI/MiniMax-M2.5",
|
||||
name: "MiniMax M2.5",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 192000,
|
||||
maxTokens: 65536,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Base URL should omit `/v1` (Anthropic client appends it). Shortcut: `openclaw onboard --auth-choice synthetic-api-key`.
|
||||
|
||||
</Accordion>
|
||||
<Accordion title="Z.AI (GLM-4.7)">
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "zai/glm-4.7" },
|
||||
models: { "zai/glm-4.7": {} },
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Set `ZAI_API_KEY`. `z.ai/*` and `z-ai/*` are accepted aliases. Shortcut: `openclaw onboard --auth-choice zai-api-key`.
|
||||
|
||||
- General endpoint: `https://api.z.ai/api/paas/v4`
|
||||
- Coding endpoint (default): `https://api.z.ai/api/coding/paas/v4`
|
||||
- For the general endpoint, define a custom provider with the base URL override.
|
||||
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
---
|
||||
|
||||
## Related
|
||||
|
||||
- [Configuration reference](/gateway/configuration-reference) — other top-level keys
|
||||
- [Configuration — agents](/gateway/config-agents)
|
||||
- [Configuration — channels](/gateway/config-channels)
|
||||
- [Configuration reference](/gateway/configuration-reference) — other top-level keys
|
||||
- [Tools and plugins](/tools)
|
||||
|
||||
@@ -85,6 +85,7 @@ cat ~/.openclaw/openclaw.json
|
||||
- Legacy on-disk state migration (sessions/agent dir/WhatsApp auth).
|
||||
- Legacy plugin manifest contract key migration (`speechProviders`, `realtimeTranscriptionProviders`, `realtimeVoiceProviders`, `mediaUnderstandingProviders`, `imageGenerationProviders`, `videoGenerationProviders`, `webFetchProviders`, `webSearchProviders` → `contracts`).
|
||||
- Legacy cron store migration (`jobId`, `schedule.cron`, top-level delivery/payload fields, payload `provider`, simple `notify: true` webhook fallback jobs).
|
||||
- Legacy agent runtime-policy migration to `agents.defaults.agentRuntime` and `agents.list[].agentRuntime`.
|
||||
</Accordion>
|
||||
<Accordion title="State and integrity">
|
||||
- Session lock file inspection and stale lock cleanup.
|
||||
@@ -237,7 +238,7 @@ That stages grounded durable candidates into the short-term dreaming store while
|
||||
If you previously added legacy OpenAI transport settings under `models.providers.openai-codex`, they can shadow the built-in Codex OAuth provider path that newer releases use automatically. Doctor warns when it sees those old transport settings alongside Codex OAuth so you can remove or rewrite the stale transport override and get the built-in routing/fallback behavior back. Custom proxies and header-only overrides are still supported and do not trigger this warning.
|
||||
</Accordion>
|
||||
<Accordion title="2f. Codex plugin route warnings">
|
||||
When the bundled Codex plugin is enabled, doctor also checks whether `openai-codex/*` primary model refs still resolve through the default PI runner. That combination is valid when you want Codex OAuth/subscription auth through PI, but it is easy to confuse with the native Codex app-server harness. Doctor warns and points to the explicit app-server shape: `openai/*` plus `embeddedHarness.runtime: "codex"` or `OPENCLAW_AGENT_RUNTIME=codex`.
|
||||
When the bundled Codex plugin is enabled, doctor also checks whether `openai-codex/*` primary model refs still resolve through the default PI runner. That combination is valid when you want Codex OAuth/subscription auth through PI, but it is easy to confuse with the native Codex app-server harness. Doctor warns and points to the explicit app-server shape: `openai/*` plus `agentRuntime.id: "codex"` or `OPENCLAW_AGENT_RUNTIME=codex`.
|
||||
|
||||
Doctor does not repair this automatically because both routes are valid:
|
||||
|
||||
|
||||
@@ -4,27 +4,38 @@ read_when:
|
||||
- Adjusting heartbeat cadence or messaging
|
||||
- Deciding between heartbeat and cron for scheduled tasks
|
||||
title: "Heartbeat"
|
||||
sidebarTitle: "Heartbeat"
|
||||
---
|
||||
|
||||
> **Heartbeat vs Cron?** See [Automation & Tasks](/automation) for guidance on when to use each.
|
||||
<Note>
|
||||
**Heartbeat vs cron?** See [Automation & Tasks](/automation) for guidance on when to use each.
|
||||
</Note>
|
||||
|
||||
Heartbeat runs **periodic agent turns** in the main session so the model can
|
||||
surface anything that needs attention without spamming you.
|
||||
Heartbeat runs **periodic agent turns** in the main session so the model can surface anything that needs attention without spamming you.
|
||||
|
||||
Heartbeat is a scheduled main-session turn — it does **not** create [background task](/automation/tasks) records.
|
||||
Task records are for detached work (ACP runs, subagents, isolated cron jobs).
|
||||
Heartbeat is a scheduled main-session turn — it does **not** create [background task](/automation/tasks) records. Task records are for detached work (ACP runs, subagents, isolated cron jobs).
|
||||
|
||||
Troubleshooting: [Scheduled Tasks](/automation/cron-jobs#troubleshooting)
|
||||
|
||||
## Quick start (beginner)
|
||||
|
||||
1. Leave heartbeats enabled (default is `30m`, or `1h` for Anthropic OAuth/token auth, including Claude CLI reuse) or set your own cadence.
|
||||
2. Create a tiny `HEARTBEAT.md` checklist or `tasks:` block in the agent workspace (optional but recommended).
|
||||
3. Decide where heartbeat messages should go (`target: "none"` is the default; set `target: "last"` to route to the last contact).
|
||||
4. Optional: enable heartbeat reasoning delivery for transparency.
|
||||
5. Optional: use lightweight bootstrap context if heartbeat runs only need `HEARTBEAT.md`.
|
||||
6. Optional: enable isolated sessions to avoid sending full conversation history each heartbeat.
|
||||
7. Optional: restrict heartbeats to active hours (local time).
|
||||
<Steps>
|
||||
<Step title="Pick a cadence">
|
||||
Leave heartbeats enabled (default is `30m`, or `1h` for Anthropic OAuth/token auth, including Claude CLI reuse) or set your own cadence.
|
||||
</Step>
|
||||
<Step title="Add HEARTBEAT.md (optional)">
|
||||
Create a tiny `HEARTBEAT.md` checklist or `tasks:` block in the agent workspace.
|
||||
</Step>
|
||||
<Step title="Decide where heartbeat messages should go">
|
||||
`target: "none"` is the default; set `target: "last"` to route to the last contact.
|
||||
</Step>
|
||||
<Step title="Optional tuning">
|
||||
- Enable heartbeat reasoning delivery for transparency.
|
||||
- Use lightweight bootstrap context if heartbeat runs only need `HEARTBEAT.md`.
|
||||
- Enable isolated sessions to avoid sending full conversation history each heartbeat.
|
||||
- Restrict heartbeats to active hours (local time).
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
Example config:
|
||||
|
||||
@@ -49,44 +60,30 @@ Example config:
|
||||
## Defaults
|
||||
|
||||
- Interval: `30m` (or `1h` when Anthropic OAuth/token auth is the detected auth mode, including Claude CLI reuse). Set `agents.defaults.heartbeat.every` or per-agent `agents.list[].heartbeat.every`; use `0m` to disable.
|
||||
- Prompt body (configurable via `agents.defaults.heartbeat.prompt`):
|
||||
`Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.`
|
||||
- The heartbeat prompt is sent **verbatim** as the user message. The system
|
||||
prompt includes a “Heartbeat” section only when heartbeats are enabled for the
|
||||
default agent, and the run is flagged internally.
|
||||
- When heartbeats are disabled with `0m`, normal runs also omit `HEARTBEAT.md`
|
||||
from bootstrap context so the model does not see heartbeat-only instructions.
|
||||
- Active hours (`heartbeat.activeHours`) are checked in the configured timezone.
|
||||
Outside the window, heartbeats are skipped until the next tick inside the window.
|
||||
- Prompt body (configurable via `agents.defaults.heartbeat.prompt`): `Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.`
|
||||
- The heartbeat prompt is sent **verbatim** as the user message. The system prompt includes a "Heartbeat" section only when heartbeats are enabled for the default agent, and the run is flagged internally.
|
||||
- When heartbeats are disabled with `0m`, normal runs also omit `HEARTBEAT.md` from bootstrap context so the model does not see heartbeat-only instructions.
|
||||
- Active hours (`heartbeat.activeHours`) are checked in the configured timezone. Outside the window, heartbeats are skipped until the next tick inside the window.
|
||||
|
||||
## What the heartbeat prompt is for
|
||||
|
||||
The default prompt is intentionally broad:
|
||||
|
||||
- **Background tasks**: “Consider outstanding tasks” nudges the agent to review
|
||||
follow-ups (inbox, calendar, reminders, queued work) and surface anything urgent.
|
||||
- **Human check-in**: “Checkup sometimes on your human during day time” nudges an
|
||||
occasional lightweight “anything you need?” message, but avoids night-time spam
|
||||
by using your configured local timezone (see [/concepts/timezone](/concepts/timezone)).
|
||||
- **Background tasks**: "Consider outstanding tasks" nudges the agent to review follow-ups (inbox, calendar, reminders, queued work) and surface anything urgent.
|
||||
- **Human check-in**: "Checkup sometimes on your human during day time" nudges an occasional lightweight "anything you need?" message, but avoids night-time spam by using your configured local timezone (see [Timezone](/concepts/timezone)).
|
||||
|
||||
Heartbeat can react to completed [background tasks](/automation/tasks), but a heartbeat run itself does not create a task record.
|
||||
|
||||
If you want a heartbeat to do something very specific (e.g. “check Gmail PubSub
|
||||
stats” or “verify gateway health”), set `agents.defaults.heartbeat.prompt` (or
|
||||
`agents.list[].heartbeat.prompt`) to a custom body (sent verbatim).
|
||||
If you want a heartbeat to do something very specific (e.g. "check Gmail PubSub stats" or "verify gateway health"), set `agents.defaults.heartbeat.prompt` (or `agents.list[].heartbeat.prompt`) to a custom body (sent verbatim).
|
||||
|
||||
## Response contract
|
||||
|
||||
- If nothing needs attention, reply with **`HEARTBEAT_OK`**.
|
||||
- During heartbeat runs, OpenClaw treats `HEARTBEAT_OK` as an ack when it appears
|
||||
at the **start or end** of the reply. The token is stripped and the reply is
|
||||
dropped if the remaining content is **≤ `ackMaxChars`** (default: 300).
|
||||
- If `HEARTBEAT_OK` appears in the **middle** of a reply, it is not treated
|
||||
specially.
|
||||
- During heartbeat runs, OpenClaw treats `HEARTBEAT_OK` as an ack when it appears at the **start or end** of the reply. The token is stripped and the reply is dropped if the remaining content is **≤ `ackMaxChars`** (default: 300).
|
||||
- If `HEARTBEAT_OK` appears in the **middle** of a reply, it is not treated specially.
|
||||
- For alerts, **do not** include `HEARTBEAT_OK`; return only the alert text.
|
||||
|
||||
Outside heartbeats, stray `HEARTBEAT_OK` at the start/end of a message is stripped
|
||||
and logged; a message that is only `HEARTBEAT_OK` is dropped.
|
||||
Outside heartbeats, stray `HEARTBEAT_OK` at the start/end of a message is stripped and logged; a message that is only `HEARTBEAT_OK` is dropped.
|
||||
|
||||
## Config
|
||||
|
||||
@@ -121,9 +118,7 @@ and logged; a message that is only `HEARTBEAT_OK` is dropped.
|
||||
|
||||
### Per-agent heartbeats
|
||||
|
||||
If any `agents.list[]` entry includes a `heartbeat` block, **only those agents**
|
||||
run heartbeats. The per-agent block merges on top of `agents.defaults.heartbeat`
|
||||
(so you can set shared defaults once and override per agent).
|
||||
If any `agents.list[]` entry includes a `heartbeat` block, **only those agents** run heartbeats. The per-agent block merges on top of `agents.defaults.heartbeat` (so you can set shared defaults once and override per agent).
|
||||
|
||||
Example: two agents, only the second agent runs heartbeats.
|
||||
|
||||
@@ -184,10 +179,11 @@ If you want heartbeats to run all day, use one of these patterns:
|
||||
- Omit `activeHours` entirely (no time-window restriction; this is the default behavior).
|
||||
- Set a full-day window: `activeHours: { start: "00:00", end: "24:00" }`.
|
||||
|
||||
Do not set the same `start` and `end` time (for example `08:00` to `08:00`).
|
||||
That is treated as a zero-width window, so heartbeats are always skipped.
|
||||
<Warning>
|
||||
Do not set the same `start` and `end` time (for example `08:00` to `08:00`). That is treated as a zero-width window, so heartbeats are always skipped.
|
||||
</Warning>
|
||||
|
||||
### Multi account example
|
||||
### Multi-account example
|
||||
|
||||
Use `accountId` to target a specific account on multi-account channels like Telegram:
|
||||
|
||||
@@ -218,63 +214,87 @@ Use `accountId` to target a specific account on multi-account channels like Tele
|
||||
|
||||
### Field notes
|
||||
|
||||
- `every`: heartbeat interval (duration string; default unit = minutes).
|
||||
- `model`: optional model override for heartbeat runs (`provider/model`).
|
||||
- `includeReasoning`: when enabled, also deliver the separate `Reasoning:` message when available (same shape as `/reasoning on`).
|
||||
- `lightContext`: when true, heartbeat runs use lightweight bootstrap context and keep only `HEARTBEAT.md` from workspace bootstrap files.
|
||||
- `isolatedSession`: when true, each heartbeat runs in a fresh session with no prior conversation history. Uses the same isolation pattern as cron `sessionTarget: "isolated"`. Dramatically reduces per-heartbeat token cost. Combine with `lightContext: true` for maximum savings. Delivery routing still uses the main session context.
|
||||
- `session`: optional session key for heartbeat runs.
|
||||
- `main` (default): agent main session.
|
||||
- Explicit session key (copy from `openclaw sessions --json` or the [sessions CLI](/cli/sessions)).
|
||||
- Session key formats: see [Sessions](/concepts/session) and [Groups](/channels/groups).
|
||||
- `target`:
|
||||
- `last`: deliver to the last used external channel.
|
||||
- explicit channel: any configured channel or plugin id, for example `discord`, `matrix`, `telegram`, or `whatsapp`.
|
||||
- `none` (default): run the heartbeat but **do not deliver** externally.
|
||||
- `directPolicy`: controls direct/DM delivery behavior:
|
||||
- `allow` (default): allow direct/DM heartbeat delivery.
|
||||
- `block`: suppress direct/DM delivery (`reason=dm-blocked`).
|
||||
- `to`: optional recipient override (channel-specific id, e.g. E.164 for WhatsApp or a Telegram chat id). For Telegram topics/threads, use `<chatId>:topic:<messageThreadId>`.
|
||||
- `accountId`: optional account id for multi-account channels. When `target: "last"`, the account id applies to the resolved last channel if it supports accounts; otherwise it is ignored. If the account id does not match a configured account for the resolved channel, delivery is skipped.
|
||||
- `prompt`: overrides the default prompt body (not merged).
|
||||
- `ackMaxChars`: max chars allowed after `HEARTBEAT_OK` before delivery.
|
||||
- `suppressToolErrorWarnings`: when true, suppresses tool error warning payloads during heartbeat runs.
|
||||
- `activeHours`: restricts heartbeat runs to a time window. Object with `start` (HH:MM, inclusive; use `00:00` for start-of-day), `end` (HH:MM exclusive; `24:00` allowed for end-of-day), and optional `timezone`.
|
||||
- Omitted or `"user"`: uses your `agents.defaults.userTimezone` if set, otherwise falls back to the host system timezone.
|
||||
- `"local"`: always uses the host system timezone.
|
||||
- Any IANA identifier (e.g. `America/New_York`): used directly; if invalid, falls back to the `"user"` behavior above.
|
||||
- `start` and `end` must not be equal for an active window; equal values are treated as zero-width (always outside the window).
|
||||
- Outside the active window, heartbeats are skipped until the next tick inside the window.
|
||||
<ParamField path="every" type="string">
|
||||
Heartbeat interval (duration string; default unit = minutes).
|
||||
</ParamField>
|
||||
<ParamField path="model" type="string">
|
||||
Optional model override for heartbeat runs (`provider/model`).
|
||||
</ParamField>
|
||||
<ParamField path="includeReasoning" type="boolean" default="false">
|
||||
When enabled, also deliver the separate `Reasoning:` message when available (same shape as `/reasoning on`).
|
||||
</ParamField>
|
||||
<ParamField path="lightContext" type="boolean" default="false">
|
||||
When true, heartbeat runs use lightweight bootstrap context and keep only `HEARTBEAT.md` from workspace bootstrap files.
|
||||
</ParamField>
|
||||
<ParamField path="isolatedSession" type="boolean" default="false">
|
||||
When true, each heartbeat runs in a fresh session with no prior conversation history. Uses the same isolation pattern as cron `sessionTarget: "isolated"`. Dramatically reduces per-heartbeat token cost. Combine with `lightContext: true` for maximum savings. Delivery routing still uses the main session context.
|
||||
</ParamField>
|
||||
<ParamField path="session" type="string">
|
||||
Optional session key for heartbeat runs.
|
||||
|
||||
- `main` (default): agent main session.
|
||||
- Explicit session key (copy from `openclaw sessions --json` or the [sessions CLI](/cli/sessions)).
|
||||
- Session key formats: see [Sessions](/concepts/session) and [Groups](/channels/groups).
|
||||
</ParamField>
|
||||
<ParamField path="target" type="string">
|
||||
- `last`: deliver to the last used external channel.
|
||||
- explicit channel: any configured channel or plugin id, for example `discord`, `matrix`, `telegram`, or `whatsapp`.
|
||||
- `none` (default): run the heartbeat but **do not deliver** externally.
|
||||
</ParamField>
|
||||
<ParamField path="directPolicy" type='"allow" | "block"' default="allow">
|
||||
Controls direct/DM delivery behavior. `allow`: allow direct/DM heartbeat delivery. `block`: suppress direct/DM delivery (`reason=dm-blocked`).
|
||||
</ParamField>
|
||||
<ParamField path="to" type="string">
|
||||
Optional recipient override (channel-specific id, e.g. E.164 for WhatsApp or a Telegram chat id). For Telegram topics/threads, use `<chatId>:topic:<messageThreadId>`.
|
||||
</ParamField>
|
||||
<ParamField path="accountId" type="string">
|
||||
Optional account id for multi-account channels. When `target: "last"`, the account id applies to the resolved last channel if it supports accounts; otherwise it is ignored. If the account id does not match a configured account for the resolved channel, delivery is skipped.
|
||||
</ParamField>
|
||||
<ParamField path="prompt" type="string">
|
||||
Overrides the default prompt body (not merged).
|
||||
</ParamField>
|
||||
<ParamField path="ackMaxChars" type="number" default="300">
|
||||
Max chars allowed after `HEARTBEAT_OK` before delivery.
|
||||
</ParamField>
|
||||
<ParamField path="suppressToolErrorWarnings" type="boolean">
|
||||
When true, suppresses tool error warning payloads during heartbeat runs.
|
||||
</ParamField>
|
||||
<ParamField path="activeHours" type="object">
|
||||
Restricts heartbeat runs to a time window. Object with `start` (HH:MM, inclusive; use `00:00` for start-of-day), `end` (HH:MM exclusive; `24:00` allowed for end-of-day), and optional `timezone`.
|
||||
|
||||
- Omitted or `"user"`: uses your `agents.defaults.userTimezone` if set, otherwise falls back to the host system timezone.
|
||||
- `"local"`: always uses the host system timezone.
|
||||
- Any IANA identifier (e.g. `America/New_York`): used directly; if invalid, falls back to the `"user"` behavior above.
|
||||
- `start` and `end` must not be equal for an active window; equal values are treated as zero-width (always outside the window).
|
||||
- Outside the active window, heartbeats are skipped until the next tick inside the window.
|
||||
</ParamField>
|
||||
|
||||
## Delivery behavior
|
||||
|
||||
- Heartbeats run in the agent’s main session by default (`agent:<id>:<mainKey>`),
|
||||
or `global` when `session.scope = "global"`. Set `session` to override to a
|
||||
specific channel session (Discord/WhatsApp/etc.).
|
||||
- `session` only affects the run context; delivery is controlled by `target` and `to`.
|
||||
- To deliver to a specific channel/recipient, set `target` + `to`. With
|
||||
`target: "last"`, delivery uses the last external channel for that session.
|
||||
- Heartbeat deliveries allow direct/DM targets by default. Set `directPolicy: "block"` to suppress direct-target sends while still running the heartbeat turn.
|
||||
- If the main queue is busy, the heartbeat is skipped and retried later.
|
||||
- If `target` resolves to no external destination, the run still happens but no
|
||||
outbound message is sent.
|
||||
- If `showOk`, `showAlerts`, and `useIndicator` are all disabled, the run is skipped up front as `reason=alerts-disabled`.
|
||||
- If only alert delivery is disabled, OpenClaw can still run the heartbeat, update due-task timestamps, restore the session idle timestamp, and suppress the outward alert payload.
|
||||
- If the resolved heartbeat target supports typing, OpenClaw shows typing while
|
||||
the heartbeat run is active. This uses the same target the heartbeat would
|
||||
send chat output to, and it is disabled by `typingMode: "never"`.
|
||||
- Heartbeat-only replies do **not** keep the session alive. Heartbeat metadata
|
||||
may update the session row, but idle expiry uses `lastInteractionAt` from the
|
||||
last real user/channel message, and daily expiry uses `sessionStartedAt`.
|
||||
- Control UI and WebChat history hide heartbeat prompts and OK-only
|
||||
acknowledgments. The underlying session transcript can still contain those
|
||||
turns for audit/replay.
|
||||
- Detached [background tasks](/automation/tasks) can enqueue a system event and wake heartbeat when the main session should notice something quickly. That wake does not make the heartbeat run a background task.
|
||||
<AccordionGroup>
|
||||
<Accordion title="Session and target routing">
|
||||
- Heartbeats run in the agent's main session by default (`agent:<id>:<mainKey>`), or `global` when `session.scope = "global"`. Set `session` to override to a specific channel session (Discord/WhatsApp/etc.).
|
||||
- `session` only affects the run context; delivery is controlled by `target` and `to`.
|
||||
- To deliver to a specific channel/recipient, set `target` + `to`. With `target: "last"`, delivery uses the last external channel for that session.
|
||||
- Heartbeat deliveries allow direct/DM targets by default. Set `directPolicy: "block"` to suppress direct-target sends while still running the heartbeat turn.
|
||||
- If the main queue is busy, the heartbeat is skipped and retried later.
|
||||
- If `target` resolves to no external destination, the run still happens but no outbound message is sent.
|
||||
</Accordion>
|
||||
<Accordion title="Visibility and skip behavior">
|
||||
- If `showOk`, `showAlerts`, and `useIndicator` are all disabled, the run is skipped up front as `reason=alerts-disabled`.
|
||||
- If only alert delivery is disabled, OpenClaw can still run the heartbeat, update due-task timestamps, restore the session idle timestamp, and suppress the outward alert payload.
|
||||
- If the resolved heartbeat target supports typing, OpenClaw shows typing while the heartbeat run is active. This uses the same target the heartbeat would send chat output to, and it is disabled by `typingMode: "never"`.
|
||||
</Accordion>
|
||||
<Accordion title="Session lifecycle and audit">
|
||||
- Heartbeat-only replies do **not** keep the session alive. Heartbeat metadata may update the session row, but idle expiry uses `lastInteractionAt` from the last real user/channel message, and daily expiry uses `sessionStartedAt`.
|
||||
- Control UI and WebChat history hide heartbeat prompts and OK-only acknowledgments. The underlying session transcript can still contain those turns for audit/replay.
|
||||
- Detached [background tasks](/automation/tasks) can enqueue a system event and wake heartbeat when the main session should notice something quickly. That wake does not make the heartbeat run a background task.
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
## Visibility controls
|
||||
|
||||
By default, `HEARTBEAT_OK` acknowledgments are suppressed while alert content is
|
||||
delivered. You can adjust this per channel or per account:
|
||||
By default, `HEARTBEAT_OK` acknowledgments are suppressed while alert content is delivered. You can adjust this per channel or per account:
|
||||
|
||||
```yaml
|
||||
channels:
|
||||
@@ -335,19 +355,11 @@ channels:
|
||||
|
||||
## HEARTBEAT.md (optional)
|
||||
|
||||
If a `HEARTBEAT.md` file exists in the workspace, the default prompt tells the
|
||||
agent to read it. Think of it as your “heartbeat checklist”: small, stable, and
|
||||
safe to include every 30 minutes.
|
||||
If a `HEARTBEAT.md` file exists in the workspace, the default prompt tells the agent to read it. Think of it as your "heartbeat checklist": small, stable, and safe to include every 30 minutes.
|
||||
|
||||
On normal runs, `HEARTBEAT.md` is only injected when heartbeat guidance is
|
||||
enabled for the default agent. Disabling the heartbeat cadence with `0m` or
|
||||
setting `includeSystemPromptSection: false` omits it from normal bootstrap
|
||||
context.
|
||||
On normal runs, `HEARTBEAT.md` is only injected when heartbeat guidance is enabled for the default agent. Disabling the heartbeat cadence with `0m` or setting `includeSystemPromptSection: false` omits it from normal bootstrap context.
|
||||
|
||||
If `HEARTBEAT.md` exists but is effectively empty (only blank lines and markdown
|
||||
headers like `# Heading`), OpenClaw skips the heartbeat run to save API calls.
|
||||
That skip is reported as `reason=empty-heartbeat-file`.
|
||||
If the file is missing, the heartbeat still runs and the model decides what to do.
|
||||
If `HEARTBEAT.md` exists but is effectively empty (only blank lines and markdown headers like `# Heading`), OpenClaw skips the heartbeat run to save API calls. That skip is reported as `reason=empty-heartbeat-file`. If the file is missing, the heartbeat still runs and the model decides what to do.
|
||||
|
||||
Keep it tiny (short checklist or reminders) to avoid prompt bloat.
|
||||
|
||||
@@ -357,14 +369,13 @@ Example `HEARTBEAT.md`:
|
||||
# Heartbeat checklist
|
||||
|
||||
- Quick scan: anything urgent in inboxes?
|
||||
- If it’s daytime, do a lightweight check-in if nothing else is pending.
|
||||
- If it's daytime, do a lightweight check-in if nothing else is pending.
|
||||
- If a task is blocked, write down _what is missing_ and ask Peter next time.
|
||||
```
|
||||
|
||||
### `tasks:` blocks
|
||||
|
||||
`HEARTBEAT.md` also supports a small structured `tasks:` block for interval-based
|
||||
checks inside heartbeat itself.
|
||||
`HEARTBEAT.md` also supports a small structured `tasks:` block for interval-based checks inside heartbeat itself.
|
||||
|
||||
Example:
|
||||
|
||||
@@ -384,14 +395,16 @@ tasks:
|
||||
- If nothing needs attention after all due tasks, reply HEARTBEAT_OK.
|
||||
```
|
||||
|
||||
Behavior:
|
||||
|
||||
- OpenClaw parses the `tasks:` block and checks each task against its own `interval`.
|
||||
- Only **due** tasks are included in the heartbeat prompt for that tick.
|
||||
- If no tasks are due, the heartbeat is skipped entirely (`reason=no-tasks-due`) to avoid a wasted model call.
|
||||
- Non-task content in `HEARTBEAT.md` is preserved and appended as additional context after the due-task list.
|
||||
- Task last-run timestamps are stored in session state (`heartbeatTaskState`), so intervals survive normal restarts.
|
||||
- Task timestamps are only advanced after a heartbeat run completes its normal reply path. Skipped `empty-heartbeat-file` / `no-tasks-due` runs do not mark tasks as completed.
|
||||
<AccordionGroup>
|
||||
<Accordion title="Behavior">
|
||||
- OpenClaw parses the `tasks:` block and checks each task against its own `interval`.
|
||||
- Only **due** tasks are included in the heartbeat prompt for that tick.
|
||||
- If no tasks are due, the heartbeat is skipped entirely (`reason=no-tasks-due`) to avoid a wasted model call.
|
||||
- Non-task content in `HEARTBEAT.md` is preserved and appended as additional context after the due-task list.
|
||||
- Task last-run timestamps are stored in session state (`heartbeatTaskState`), so intervals survive normal restarts.
|
||||
- Task timestamps are only advanced after a heartbeat run completes its normal reply path. Skipped `empty-heartbeat-file` / `no-tasks-due` runs do not mark tasks as completed.
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
Task mode is useful when you want one heartbeat file to hold several periodic checks without paying for all of them every tick.
|
||||
|
||||
@@ -399,18 +412,16 @@ Task mode is useful when you want one heartbeat file to hold several periodic ch
|
||||
|
||||
Yes — if you ask it to.
|
||||
|
||||
`HEARTBEAT.md` is just a normal file in the agent workspace, so you can tell the
|
||||
agent (in a normal chat) something like:
|
||||
`HEARTBEAT.md` is just a normal file in the agent workspace, so you can tell the agent (in a normal chat) something like:
|
||||
|
||||
- “Update `HEARTBEAT.md` to add a daily calendar check.”
|
||||
- “Rewrite `HEARTBEAT.md` so it’s shorter and focused on inbox follow-ups.”
|
||||
- "Update `HEARTBEAT.md` to add a daily calendar check."
|
||||
- "Rewrite `HEARTBEAT.md` so it's shorter and focused on inbox follow-ups."
|
||||
|
||||
If you want this to happen proactively, you can also include an explicit line in
|
||||
your heartbeat prompt like: “If the checklist becomes stale, update HEARTBEAT.md
|
||||
with a better one.”
|
||||
If you want this to happen proactively, you can also include an explicit line in your heartbeat prompt like: "If the checklist becomes stale, update HEARTBEAT.md with a better one."
|
||||
|
||||
Safety note: don’t put secrets (API keys, phone numbers, private tokens) into
|
||||
`HEARTBEAT.md` — it becomes part of the prompt context.
|
||||
<Warning>
|
||||
Don't put secrets (API keys, phone numbers, private tokens) into `HEARTBEAT.md` — it becomes part of the prompt context.
|
||||
</Warning>
|
||||
|
||||
## Manual wake (on-demand)
|
||||
|
||||
@@ -420,24 +431,19 @@ You can enqueue a system event and trigger an immediate heartbeat with:
|
||||
openclaw system event --text "Check for urgent follow-ups" --mode now
|
||||
```
|
||||
|
||||
If multiple agents have `heartbeat` configured, a manual wake runs each of those
|
||||
agent heartbeats immediately.
|
||||
If multiple agents have `heartbeat` configured, a manual wake runs each of those agent heartbeats immediately.
|
||||
|
||||
Use `--mode next-heartbeat` to wait for the next scheduled tick.
|
||||
|
||||
## Reasoning delivery (optional)
|
||||
|
||||
By default, heartbeats deliver only the final “answer” payload.
|
||||
By default, heartbeats deliver only the final "answer" payload.
|
||||
|
||||
If you want transparency, enable:
|
||||
|
||||
- `agents.defaults.heartbeat.includeReasoning: true`
|
||||
|
||||
When enabled, heartbeats will also deliver a separate message prefixed
|
||||
`Reasoning:` (same shape as `/reasoning on`). This can be useful when the agent
|
||||
is managing multiple sessions/codexes and you want to see why it decided to ping
|
||||
you — but it can also leak more internal detail than you want. Prefer keeping it
|
||||
off in group chats.
|
||||
When enabled, heartbeats will also deliver a separate message prefixed `Reasoning:` (same shape as `/reasoning on`). This can be useful when the agent is managing multiple sessions/codexes and you want to see why it decided to ping you — but it can also leak more internal detail than you want. Prefer keeping it off in group chats.
|
||||
|
||||
## Cost awareness
|
||||
|
||||
|
||||
@@ -19,6 +19,9 @@ works without code changes. For local file logs and how to read them, see
|
||||
and exec.
|
||||
- **`diagnostics-otel` plugin** subscribes to those events and exports them as
|
||||
OpenTelemetry **metrics**, **traces**, and **logs** over OTLP/HTTP.
|
||||
- **Provider calls** receive a W3C `traceparent` header from OpenClaw's
|
||||
trusted model-call span context when the provider transport accepts custom
|
||||
headers. Plugin-emitted trace context is not propagated.
|
||||
- Exporters only attach when both the diagnostics surface and the plugin are
|
||||
enabled, so the in-process cost stays near zero by default.
|
||||
|
||||
@@ -61,11 +64,11 @@ openclaw plugins enable diagnostics-otel
|
||||
|
||||
## Signals exported
|
||||
|
||||
| Signal | What goes in it |
|
||||
| ----------- | --------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Metrics** | Counters and histograms for token usage, cost, run duration, message flow, queue lanes, session state, exec, and memory pressure. |
|
||||
| **Traces** | Spans for model usage, model calls, tool execution, exec, webhook/message processing, context assembly, and tool loops. |
|
||||
| **Logs** | Structured `logging.file` records exported over OTLP when `diagnostics.otel.logs` is enabled. |
|
||||
| Signal | What goes in it |
|
||||
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| **Metrics** | Counters and histograms for token usage, cost, run duration, message flow, queue lanes, session state, exec, and memory pressure. |
|
||||
| **Traces** | Spans for model usage, model calls, harness lifecycle, tool execution, exec, webhook/message processing, context assembly, and tool loops. |
|
||||
| **Logs** | Structured `logging.file` records exported over OTLP when `diagnostics.otel.logs` is enabled. |
|
||||
|
||||
Toggle `traces`, `metrics`, and `logs` independently. All three default to on
|
||||
when `diagnostics.otel.enabled` is true.
|
||||
@@ -121,6 +124,11 @@ identifiers (channel, provider, model, error category, hash-only request ids)
|
||||
and never include prompt text, response text, tool inputs, tool outputs, or
|
||||
session keys.
|
||||
|
||||
Outbound model requests may include a W3C `traceparent` header. That header is
|
||||
generated only from OpenClaw-owned diagnostic trace context for the active model
|
||||
call. Existing caller-supplied `traceparent` headers are replaced, so plugins or
|
||||
custom provider options cannot spoof cross-service trace ancestry.
|
||||
|
||||
Set `diagnostics.otel.captureContent.*` to `true` only when your collector and
|
||||
retention policy are approved for prompt, response, tool, or system-prompt
|
||||
text. Each subkey is opt-in independently:
|
||||
@@ -176,6 +184,10 @@ When any subkey is enabled, model and tool spans get bounded, redacted
|
||||
- `openclaw.session.stuck_age_ms` (histogram, attrs: `openclaw.state`)
|
||||
- `openclaw.run.attempt` (counter, attrs: `openclaw.attempt`)
|
||||
|
||||
### Harness lifecycle
|
||||
|
||||
- `openclaw.harness.duration_ms` (histogram, attrs: `openclaw.harness.id`, `openclaw.harness.plugin`, `openclaw.outcome`, `openclaw.harness.phase` on errors)
|
||||
|
||||
### Exec
|
||||
|
||||
- `openclaw.exec.duration_ms` (histogram, attrs: `openclaw.exec.target`, `openclaw.exec.mode`, `openclaw.outcome`, `openclaw.failureKind`)
|
||||
@@ -201,6 +213,10 @@ When any subkey is enabled, model and tool spans get bounded, redacted
|
||||
- `gen_ai.system` by default, or `gen_ai.provider.name` when the latest GenAI semantic conventions are opted in
|
||||
- `gen_ai.request.model`, `gen_ai.operation.name`, `openclaw.provider`, `openclaw.model`, `openclaw.api`, `openclaw.transport`
|
||||
- `openclaw.provider.request_id_hash` (bounded SHA-based hash of the upstream provider request id; raw ids are not exported)
|
||||
- `openclaw.harness.run`
|
||||
- `openclaw.harness.id`, `openclaw.harness.plugin`, `openclaw.outcome`, `openclaw.provider`, `openclaw.model`, `openclaw.channel`
|
||||
- On completion: `openclaw.harness.result_classification`, `openclaw.harness.yield_detected`, `openclaw.harness.items.started`, `openclaw.harness.items.completed`, `openclaw.harness.items.active`
|
||||
- On error: `openclaw.harness.phase`, `openclaw.errorCategory`, optional `openclaw.harness.cleanup_failed`
|
||||
- `openclaw.tool.execution`
|
||||
- `gen_ai.tool.name`, `openclaw.toolName`, `openclaw.errorCategory`, `openclaw.tool.params.*`
|
||||
- `openclaw.exec`
|
||||
@@ -251,6 +267,16 @@ to them directly without OTLP export.
|
||||
- `run.attempt`
|
||||
- `diagnostic.heartbeat` (aggregate counters: webhooks/queue/session)
|
||||
|
||||
**Harness lifecycle**
|
||||
|
||||
- `harness.run.started` / `harness.run.completed` / `harness.run.error` —
|
||||
per-run lifecycle for the agent harness. Includes `harnessId`, optional
|
||||
`pluginId`, provider/model/channel, and run id. Completion adds
|
||||
`durationMs`, `outcome`, optional `resultClassification`, `yieldDetected`,
|
||||
and `itemLifecycle` counts. Errors add `phase`
|
||||
(`prepare`/`start`/`send`/`resolve`/`cleanup`), `errorCategory`, and
|
||||
optional `cleanupFailed`.
|
||||
|
||||
**Exec**
|
||||
|
||||
- `exec.process.completed` — terminal outcome, duration, target, mode, exit
|
||||
|
||||
@@ -5,11 +5,14 @@ read_when:
|
||||
- Operating secrets reload, audit, configure, and apply safely in production
|
||||
- Understanding startup fail-fast, inactive-surface filtering, and last-known-good behavior
|
||||
title: "Secrets management"
|
||||
sidebarTitle: "Secrets management"
|
||||
---
|
||||
|
||||
OpenClaw supports additive SecretRefs so supported credentials do not need to be stored as plaintext in configuration.
|
||||
|
||||
<Note>
|
||||
Plaintext still works. SecretRefs are opt-in per credential.
|
||||
</Note>
|
||||
|
||||
## Goals and runtime model
|
||||
|
||||
@@ -33,38 +36,32 @@ SecretRefs are validated only on effectively active surfaces.
|
||||
- Inactive surfaces: unresolved refs do not block startup/reload.
|
||||
- Inactive refs emit non-fatal diagnostics with code `SECRETS_REF_IGNORED_INACTIVE_SURFACE`.
|
||||
|
||||
Examples of inactive surfaces:
|
||||
|
||||
- Disabled channel/account entries.
|
||||
- Top-level channel credentials that no enabled account inherits.
|
||||
- Disabled tool/feature surfaces.
|
||||
- Web search provider-specific keys that are not selected by `tools.web.search.provider`.
|
||||
In auto mode (provider unset), keys are consulted by precedence for provider auto-detection until one resolves.
|
||||
After selection, non-selected provider keys are treated as inactive until selected.
|
||||
- Sandbox SSH auth material (`agents.defaults.sandbox.ssh.identityData`,
|
||||
`certificateData`, `knownHostsData`, plus per-agent overrides) is active only
|
||||
when the effective sandbox backend is `ssh` for the default agent or an enabled agent.
|
||||
- `gateway.remote.token` / `gateway.remote.password` SecretRefs are active if one of these is true:
|
||||
- `gateway.mode=remote`
|
||||
- `gateway.remote.url` is configured
|
||||
- `gateway.tailscale.mode` is `serve` or `funnel`
|
||||
- In local mode without those remote surfaces:
|
||||
- `gateway.remote.token` is active when token auth can win and no env/auth token is configured.
|
||||
- `gateway.remote.password` is active only when password auth can win and no env/auth password is configured.
|
||||
- `gateway.auth.token` SecretRef is inactive for startup auth resolution when `OPENCLAW_GATEWAY_TOKEN` is set, because env token input wins for that runtime.
|
||||
<AccordionGroup>
|
||||
<Accordion title="Examples of inactive surfaces">
|
||||
- Disabled channel/account entries.
|
||||
- Top-level channel credentials that no enabled account inherits.
|
||||
- Disabled tool/feature surfaces.
|
||||
- Web search provider-specific keys that are not selected by `tools.web.search.provider`. In auto mode (provider unset), keys are consulted by precedence for provider auto-detection until one resolves. After selection, non-selected provider keys are treated as inactive until selected.
|
||||
- Sandbox SSH auth material (`agents.defaults.sandbox.ssh.identityData`, `certificateData`, `knownHostsData`, plus per-agent overrides) is active only when the effective sandbox backend is `ssh` for the default agent or an enabled agent.
|
||||
- `gateway.remote.token` / `gateway.remote.password` SecretRefs are active if one of these is true:
|
||||
- `gateway.mode=remote`
|
||||
- `gateway.remote.url` is configured
|
||||
- `gateway.tailscale.mode` is `serve` or `funnel`
|
||||
- In local mode without those remote surfaces:
|
||||
- `gateway.remote.token` is active when token auth can win and no env/auth token is configured.
|
||||
- `gateway.remote.password` is active only when password auth can win and no env/auth password is configured.
|
||||
- `gateway.auth.token` SecretRef is inactive for startup auth resolution when `OPENCLAW_GATEWAY_TOKEN` is set, because env token input wins for that runtime.
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
## Gateway auth surface diagnostics
|
||||
|
||||
When a SecretRef is configured on `gateway.auth.token`, `gateway.auth.password`,
|
||||
`gateway.remote.token`, or `gateway.remote.password`, gateway startup/reload logs the
|
||||
surface state explicitly:
|
||||
When a SecretRef is configured on `gateway.auth.token`, `gateway.auth.password`, `gateway.remote.token`, or `gateway.remote.password`, gateway startup/reload logs the surface state explicitly:
|
||||
|
||||
- `active`: the SecretRef is part of the effective auth surface and must resolve.
|
||||
- `inactive`: the SecretRef is ignored for this runtime because another auth surface wins, or
|
||||
because remote auth is disabled/not active.
|
||||
- `inactive`: the SecretRef is ignored for this runtime because another auth surface wins, or because remote auth is disabled/not active.
|
||||
|
||||
These entries are logged with `SECRETS_GATEWAY_AUTH_SURFACE` and include the reason used by the
|
||||
active-surface policy, so you can see why a credential was treated as active or inactive.
|
||||
These entries are logged with `SECRETS_GATEWAY_AUTH_SURFACE` and include the reason used by the active-surface policy, so you can see why a credential was treated as active or inactive.
|
||||
|
||||
## Onboarding reference preflight
|
||||
|
||||
@@ -84,40 +81,43 @@ Use one object shape everywhere:
|
||||
{ source: "env" | "file" | "exec", provider: "default", id: "..." }
|
||||
```
|
||||
|
||||
### `source: "env"`
|
||||
<Tabs>
|
||||
<Tab title="env">
|
||||
```json5
|
||||
{ source: "env", provider: "default", id: "OPENAI_API_KEY" }
|
||||
```
|
||||
|
||||
```json5
|
||||
{ source: "env", provider: "default", id: "OPENAI_API_KEY" }
|
||||
```
|
||||
Validation:
|
||||
|
||||
Validation:
|
||||
- `provider` must match `^[a-z][a-z0-9_-]{0,63}$`
|
||||
- `id` must match `^[A-Z][A-Z0-9_]{0,127}$`
|
||||
|
||||
- `provider` must match `^[a-z][a-z0-9_-]{0,63}$`
|
||||
- `id` must match `^[A-Z][A-Z0-9_]{0,127}$`
|
||||
</Tab>
|
||||
<Tab title="file">
|
||||
```json5
|
||||
{ source: "file", provider: "filemain", id: "/providers/openai/apiKey" }
|
||||
```
|
||||
|
||||
### `source: "file"`
|
||||
Validation:
|
||||
|
||||
```json5
|
||||
{ source: "file", provider: "filemain", id: "/providers/openai/apiKey" }
|
||||
```
|
||||
- `provider` must match `^[a-z][a-z0-9_-]{0,63}$`
|
||||
- `id` must be an absolute JSON pointer (`/...`)
|
||||
- RFC6901 escaping in segments: `~` => `~0`, `/` => `~1`
|
||||
|
||||
Validation:
|
||||
</Tab>
|
||||
<Tab title="exec">
|
||||
```json5
|
||||
{ source: "exec", provider: "vault", id: "providers/openai/apiKey" }
|
||||
```
|
||||
|
||||
- `provider` must match `^[a-z][a-z0-9_-]{0,63}$`
|
||||
- `id` must be an absolute JSON pointer (`/...`)
|
||||
- RFC6901 escaping in segments: `~` => `~0`, `/` => `~1`
|
||||
Validation:
|
||||
|
||||
### `source: "exec"`
|
||||
- `provider` must match `^[a-z][a-z0-9_-]{0,63}$`
|
||||
- `id` must match `^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$`
|
||||
- `id` must not contain `.` or `..` as slash-delimited path segments (for example `a/../b` is rejected)
|
||||
|
||||
```json5
|
||||
{ source: "exec", provider: "vault", id: "providers/openai/apiKey" }
|
||||
```
|
||||
|
||||
Validation:
|
||||
|
||||
- `provider` must match `^[a-z][a-z0-9_-]{0,63}$`
|
||||
- `id` must match `^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$`
|
||||
- `id` must not contain `.` or `..` as slash-delimited path segments (for example `a/../b` is rejected)
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
## Provider config
|
||||
|
||||
@@ -155,138 +155,139 @@ Define providers under `secrets.providers`:
|
||||
}
|
||||
```
|
||||
|
||||
### Env provider
|
||||
<AccordionGroup>
|
||||
<Accordion title="Env provider">
|
||||
- Optional allowlist via `allowlist`.
|
||||
- Missing/empty env values fail resolution.
|
||||
</Accordion>
|
||||
<Accordion title="File provider">
|
||||
- Reads local file from `path`.
|
||||
- `mode: "json"` expects JSON object payload and resolves `id` as pointer.
|
||||
- `mode: "singleValue"` expects ref id `"value"` and returns file contents.
|
||||
- Path must pass ownership/permission checks.
|
||||
- Windows fail-closed note: if ACL verification is unavailable for a path, resolution fails. For trusted paths only, set `allowInsecurePath: true` on that provider to bypass path security checks.
|
||||
</Accordion>
|
||||
<Accordion title="Exec provider">
|
||||
- Runs configured absolute binary path, no shell.
|
||||
- By default, `command` must point to a regular file (not a symlink).
|
||||
- Set `allowSymlinkCommand: true` to allow symlink command paths (for example Homebrew shims). OpenClaw validates the resolved target path.
|
||||
- Pair `allowSymlinkCommand` with `trustedDirs` for package-manager paths (for example `["/opt/homebrew"]`).
|
||||
- Supports timeout, no-output timeout, output byte limits, env allowlist, and trusted dirs.
|
||||
- Windows fail-closed note: if ACL verification is unavailable for the command path, resolution fails. For trusted paths only, set `allowInsecurePath: true` on that provider to bypass path security checks.
|
||||
|
||||
- Optional allowlist via `allowlist`.
|
||||
- Missing/empty env values fail resolution.
|
||||
Request payload (stdin):
|
||||
|
||||
### File provider
|
||||
```json
|
||||
{ "protocolVersion": 1, "provider": "vault", "ids": ["providers/openai/apiKey"] }
|
||||
```
|
||||
|
||||
- Reads local file from `path`.
|
||||
- `mode: "json"` expects JSON object payload and resolves `id` as pointer.
|
||||
- `mode: "singleValue"` expects ref id `"value"` and returns file contents.
|
||||
- Path must pass ownership/permission checks.
|
||||
- Windows fail-closed note: if ACL verification is unavailable for a path, resolution fails. For trusted paths only, set `allowInsecurePath: true` on that provider to bypass path security checks.
|
||||
Response payload (stdout):
|
||||
|
||||
### Exec provider
|
||||
```jsonc
|
||||
{ "protocolVersion": 1, "values": { "providers/openai/apiKey": "<openai-api-key>" } } // pragma: allowlist secret
|
||||
```
|
||||
|
||||
- Runs configured absolute binary path, no shell.
|
||||
- By default, `command` must point to a regular file (not a symlink).
|
||||
- Set `allowSymlinkCommand: true` to allow symlink command paths (for example Homebrew shims). OpenClaw validates the resolved target path.
|
||||
- Pair `allowSymlinkCommand` with `trustedDirs` for package-manager paths (for example `["/opt/homebrew"]`).
|
||||
- Supports timeout, no-output timeout, output byte limits, env allowlist, and trusted dirs.
|
||||
- Windows fail-closed note: if ACL verification is unavailable for the command path, resolution fails. For trusted paths only, set `allowInsecurePath: true` on that provider to bypass path security checks.
|
||||
Optional per-id errors:
|
||||
|
||||
Request payload (stdin):
|
||||
```json
|
||||
{
|
||||
"protocolVersion": 1,
|
||||
"values": {},
|
||||
"errors": { "providers/openai/apiKey": { "message": "not found" } }
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{ "protocolVersion": 1, "provider": "vault", "ids": ["providers/openai/apiKey"] }
|
||||
```
|
||||
|
||||
Response payload (stdout):
|
||||
|
||||
```jsonc
|
||||
{ "protocolVersion": 1, "values": { "providers/openai/apiKey": "<openai-api-key>" } } // pragma: allowlist secret
|
||||
```
|
||||
|
||||
Optional per-id errors:
|
||||
|
||||
```json
|
||||
{
|
||||
"protocolVersion": 1,
|
||||
"values": {},
|
||||
"errors": { "providers/openai/apiKey": { "message": "not found" } }
|
||||
}
|
||||
```
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
## Exec integration examples
|
||||
|
||||
### 1Password CLI
|
||||
|
||||
```json5
|
||||
{
|
||||
secrets: {
|
||||
providers: {
|
||||
onepassword_openai: {
|
||||
source: "exec",
|
||||
command: "/opt/homebrew/bin/op",
|
||||
allowSymlinkCommand: true, // required for Homebrew symlinked binaries
|
||||
trustedDirs: ["/opt/homebrew"],
|
||||
args: ["read", "op://Personal/OpenClaw QA API Key/password"],
|
||||
passEnv: ["HOME"],
|
||||
jsonOnly: false,
|
||||
<AccordionGroup>
|
||||
<Accordion title="1Password CLI">
|
||||
```json5
|
||||
{
|
||||
secrets: {
|
||||
providers: {
|
||||
onepassword_openai: {
|
||||
source: "exec",
|
||||
command: "/opt/homebrew/bin/op",
|
||||
allowSymlinkCommand: true, // required for Homebrew symlinked binaries
|
||||
trustedDirs: ["/opt/homebrew"],
|
||||
args: ["read", "op://Personal/OpenClaw QA API Key/password"],
|
||||
passEnv: ["HOME"],
|
||||
jsonOnly: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
models: {
|
||||
providers: {
|
||||
openai: {
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
models: [{ id: "gpt-5", name: "gpt-5" }],
|
||||
apiKey: { source: "exec", provider: "onepassword_openai", id: "value" },
|
||||
models: {
|
||||
providers: {
|
||||
openai: {
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
models: [{ id: "gpt-5", name: "gpt-5" }],
|
||||
apiKey: { source: "exec", provider: "onepassword_openai", id: "value" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### HashiCorp Vault CLI
|
||||
|
||||
```json5
|
||||
{
|
||||
secrets: {
|
||||
providers: {
|
||||
vault_openai: {
|
||||
source: "exec",
|
||||
command: "/opt/homebrew/bin/vault",
|
||||
allowSymlinkCommand: true, // required for Homebrew symlinked binaries
|
||||
trustedDirs: ["/opt/homebrew"],
|
||||
args: ["kv", "get", "-field=OPENAI_API_KEY", "secret/openclaw"],
|
||||
passEnv: ["VAULT_ADDR", "VAULT_TOKEN"],
|
||||
jsonOnly: false,
|
||||
}
|
||||
```
|
||||
</Accordion>
|
||||
<Accordion title="HashiCorp Vault CLI">
|
||||
```json5
|
||||
{
|
||||
secrets: {
|
||||
providers: {
|
||||
vault_openai: {
|
||||
source: "exec",
|
||||
command: "/opt/homebrew/bin/vault",
|
||||
allowSymlinkCommand: true, // required for Homebrew symlinked binaries
|
||||
trustedDirs: ["/opt/homebrew"],
|
||||
args: ["kv", "get", "-field=OPENAI_API_KEY", "secret/openclaw"],
|
||||
passEnv: ["VAULT_ADDR", "VAULT_TOKEN"],
|
||||
jsonOnly: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
models: {
|
||||
providers: {
|
||||
openai: {
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
models: [{ id: "gpt-5", name: "gpt-5" }],
|
||||
apiKey: { source: "exec", provider: "vault_openai", id: "value" },
|
||||
models: {
|
||||
providers: {
|
||||
openai: {
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
models: [{ id: "gpt-5", name: "gpt-5" }],
|
||||
apiKey: { source: "exec", provider: "vault_openai", id: "value" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### `sops`
|
||||
|
||||
```json5
|
||||
{
|
||||
secrets: {
|
||||
providers: {
|
||||
sops_openai: {
|
||||
source: "exec",
|
||||
command: "/opt/homebrew/bin/sops",
|
||||
allowSymlinkCommand: true, // required for Homebrew symlinked binaries
|
||||
trustedDirs: ["/opt/homebrew"],
|
||||
args: ["-d", "--extract", '["providers"]["openai"]["apiKey"]', "/path/to/secrets.enc.json"],
|
||||
passEnv: ["SOPS_AGE_KEY_FILE"],
|
||||
jsonOnly: false,
|
||||
}
|
||||
```
|
||||
</Accordion>
|
||||
<Accordion title="sops">
|
||||
```json5
|
||||
{
|
||||
secrets: {
|
||||
providers: {
|
||||
sops_openai: {
|
||||
source: "exec",
|
||||
command: "/opt/homebrew/bin/sops",
|
||||
allowSymlinkCommand: true, // required for Homebrew symlinked binaries
|
||||
trustedDirs: ["/opt/homebrew"],
|
||||
args: ["-d", "--extract", '["providers"]["openai"]["apiKey"]', "/path/to/secrets.enc.json"],
|
||||
passEnv: ["SOPS_AGE_KEY_FILE"],
|
||||
jsonOnly: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
models: {
|
||||
providers: {
|
||||
openai: {
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
models: [{ id: "gpt-5", name: "gpt-5" }],
|
||||
apiKey: { source: "exec", provider: "sops_openai", id: "value" },
|
||||
models: {
|
||||
providers: {
|
||||
openai: {
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
models: [{ id: "gpt-5", name: "gpt-5" }],
|
||||
apiKey: { source: "exec", provider: "sops_openai", id: "value" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
}
|
||||
```
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
## MCP server environment variables
|
||||
|
||||
@@ -356,7 +357,9 @@ Canonical supported and unsupported credentials are listed in:
|
||||
|
||||
- [SecretRef Credential Surface](/reference/secretref-credential-surface)
|
||||
|
||||
<Note>
|
||||
Runtime-minted or rotating credentials and OAuth refresh material are intentionally excluded from read-only SecretRef resolution.
|
||||
</Note>
|
||||
|
||||
## Required behavior and precedence
|
||||
|
||||
@@ -415,15 +418,22 @@ Command paths can opt into supported SecretRef resolution via gateway snapshot R
|
||||
|
||||
There are two broad behaviors:
|
||||
|
||||
- Strict command paths (for example `openclaw memory` remote-memory paths and `openclaw qr --remote` when it needs remote shared-secret refs) read from the active snapshot and fail fast when a required SecretRef is unavailable.
|
||||
- Read-only command paths (for example `openclaw status`, `openclaw status --all`, `openclaw channels status`, `openclaw channels resolve`, `openclaw security audit`, and read-only doctor/config repair flows) also prefer the active snapshot, but degrade instead of aborting when a targeted SecretRef is unavailable in that command path.
|
||||
<Tabs>
|
||||
<Tab title="Strict command paths">
|
||||
For example `openclaw memory` remote-memory paths and `openclaw qr --remote` when it needs remote shared-secret refs. They read from the active snapshot and fail fast when a required SecretRef is unavailable.
|
||||
</Tab>
|
||||
<Tab title="Read-only command paths">
|
||||
For example `openclaw status`, `openclaw status --all`, `openclaw channels status`, `openclaw channels resolve`, `openclaw security audit`, and read-only doctor/config repair flows. They also prefer the active snapshot, but degrade instead of aborting when a targeted SecretRef is unavailable in that command path.
|
||||
|
||||
Read-only behavior:
|
||||
Read-only behavior:
|
||||
|
||||
- When the gateway is running, these commands read from the active snapshot first.
|
||||
- If gateway resolution is incomplete or the gateway is unavailable, they attempt targeted local fallback for the specific command surface.
|
||||
- If a targeted SecretRef is still unavailable, the command continues with degraded read-only output and explicit diagnostics such as “configured but unavailable in this command path”.
|
||||
- This degraded behavior is command-local only. It does not weaken runtime startup, reload, or send/auth paths.
|
||||
- When the gateway is running, these commands read from the active snapshot first.
|
||||
- If gateway resolution is incomplete or the gateway is unavailable, they attempt targeted local fallback for the specific command surface.
|
||||
- If a targeted SecretRef is still unavailable, the command continues with degraded read-only output and explicit diagnostics such as "configured but unavailable in this command path".
|
||||
- This degraded behavior is command-local only. It does not weaken runtime startup, reload, or send/auth paths.
|
||||
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
Other notes:
|
||||
|
||||
@@ -434,82 +444,97 @@ Other notes:
|
||||
|
||||
Default operator flow:
|
||||
|
||||
```bash
|
||||
openclaw secrets audit --check
|
||||
openclaw secrets configure
|
||||
openclaw secrets audit --check
|
||||
```
|
||||
<Steps>
|
||||
<Step title="Audit current state">
|
||||
```bash
|
||||
openclaw secrets audit --check
|
||||
```
|
||||
</Step>
|
||||
<Step title="Configure SecretRefs">
|
||||
```bash
|
||||
openclaw secrets configure
|
||||
```
|
||||
</Step>
|
||||
<Step title="Re-audit">
|
||||
```bash
|
||||
openclaw secrets audit --check
|
||||
```
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
### `secrets audit`
|
||||
<AccordionGroup>
|
||||
<Accordion title="secrets audit">
|
||||
Findings include:
|
||||
|
||||
Findings include:
|
||||
- plaintext values at rest (`openclaw.json`, `auth-profiles.json`, `.env`, and generated `agents/*/agent/models.json`)
|
||||
- plaintext sensitive provider header residues in generated `models.json` entries
|
||||
- unresolved refs
|
||||
- precedence shadowing (`auth-profiles.json` taking priority over `openclaw.json` refs)
|
||||
- legacy residues (`auth.json`, OAuth reminders)
|
||||
|
||||
- plaintext values at rest (`openclaw.json`, `auth-profiles.json`, `.env`, and generated `agents/*/agent/models.json`)
|
||||
- plaintext sensitive provider header residues in generated `models.json` entries
|
||||
- unresolved refs
|
||||
- precedence shadowing (`auth-profiles.json` taking priority over `openclaw.json` refs)
|
||||
- legacy residues (`auth.json`, OAuth reminders)
|
||||
Exec note:
|
||||
|
||||
Exec note:
|
||||
- By default, audit skips exec SecretRef resolvability checks to avoid command side effects.
|
||||
- Use `openclaw secrets audit --allow-exec` to execute exec providers during audit.
|
||||
|
||||
- By default, audit skips exec SecretRef resolvability checks to avoid command side effects.
|
||||
- Use `openclaw secrets audit --allow-exec` to execute exec providers during audit.
|
||||
Header residue note:
|
||||
|
||||
Header residue note:
|
||||
- Sensitive provider header detection is name-heuristic based (common auth/credential header names and fragments such as `authorization`, `x-api-key`, `token`, `secret`, `password`, and `credential`).
|
||||
|
||||
- Sensitive provider header detection is name-heuristic based (common auth/credential header names and fragments such as `authorization`, `x-api-key`, `token`, `secret`, `password`, and `credential`).
|
||||
</Accordion>
|
||||
<Accordion title="secrets configure">
|
||||
Interactive helper that:
|
||||
|
||||
### `secrets configure`
|
||||
- configures `secrets.providers` first (`env`/`file`/`exec`, add/edit/remove)
|
||||
- lets you select supported secret-bearing fields in `openclaw.json` plus `auth-profiles.json` for one agent scope
|
||||
- can create a new `auth-profiles.json` mapping directly in the target picker
|
||||
- captures SecretRef details (`source`, `provider`, `id`)
|
||||
- runs preflight resolution
|
||||
- can apply immediately
|
||||
|
||||
Interactive helper that:
|
||||
Exec note:
|
||||
|
||||
- configures `secrets.providers` first (`env`/`file`/`exec`, add/edit/remove)
|
||||
- lets you select supported secret-bearing fields in `openclaw.json` plus `auth-profiles.json` for one agent scope
|
||||
- can create a new `auth-profiles.json` mapping directly in the target picker
|
||||
- captures SecretRef details (`source`, `provider`, `id`)
|
||||
- runs preflight resolution
|
||||
- can apply immediately
|
||||
- Preflight skips exec SecretRef checks unless `--allow-exec` is set.
|
||||
- If you apply directly from `configure --apply` and the plan includes exec refs/providers, keep `--allow-exec` set for the apply step too.
|
||||
|
||||
Exec note:
|
||||
Helpful modes:
|
||||
|
||||
- Preflight skips exec SecretRef checks unless `--allow-exec` is set.
|
||||
- If you apply directly from `configure --apply` and the plan includes exec refs/providers, keep `--allow-exec` set for the apply step too.
|
||||
- `openclaw secrets configure --providers-only`
|
||||
- `openclaw secrets configure --skip-provider-setup`
|
||||
- `openclaw secrets configure --agent <id>`
|
||||
|
||||
Helpful modes:
|
||||
`configure` apply defaults:
|
||||
|
||||
- `openclaw secrets configure --providers-only`
|
||||
- `openclaw secrets configure --skip-provider-setup`
|
||||
- `openclaw secrets configure --agent <id>`
|
||||
- scrub matching static credentials from `auth-profiles.json` for targeted providers
|
||||
- scrub legacy static `api_key` entries from `auth.json`
|
||||
- scrub matching known secret lines from `<config-dir>/.env`
|
||||
|
||||
`configure` apply defaults:
|
||||
</Accordion>
|
||||
<Accordion title="secrets apply">
|
||||
Apply a saved plan:
|
||||
|
||||
- scrub matching static credentials from `auth-profiles.json` for targeted providers
|
||||
- scrub legacy static `api_key` entries from `auth.json`
|
||||
- scrub matching known secret lines from `<config-dir>/.env`
|
||||
```bash
|
||||
openclaw secrets apply --from /tmp/openclaw-secrets-plan.json
|
||||
openclaw secrets apply --from /tmp/openclaw-secrets-plan.json --allow-exec
|
||||
openclaw secrets apply --from /tmp/openclaw-secrets-plan.json --dry-run
|
||||
openclaw secrets apply --from /tmp/openclaw-secrets-plan.json --dry-run --allow-exec
|
||||
```
|
||||
|
||||
### `secrets apply`
|
||||
Exec note:
|
||||
|
||||
Apply a saved plan:
|
||||
- dry-run skips exec checks unless `--allow-exec` is set.
|
||||
- write mode rejects plans containing exec SecretRefs/providers unless `--allow-exec` is set.
|
||||
|
||||
```bash
|
||||
openclaw secrets apply --from /tmp/openclaw-secrets-plan.json
|
||||
openclaw secrets apply --from /tmp/openclaw-secrets-plan.json --allow-exec
|
||||
openclaw secrets apply --from /tmp/openclaw-secrets-plan.json --dry-run
|
||||
openclaw secrets apply --from /tmp/openclaw-secrets-plan.json --dry-run --allow-exec
|
||||
```
|
||||
For strict target/path contract details and exact rejection rules, see [Secrets Apply Plan Contract](/gateway/secrets-plan-contract).
|
||||
|
||||
Exec note:
|
||||
|
||||
- dry-run skips exec checks unless `--allow-exec` is set.
|
||||
- write mode rejects plans containing exec SecretRefs/providers unless `--allow-exec` is set.
|
||||
|
||||
For strict target/path contract details and exact rejection rules, see:
|
||||
|
||||
- [Secrets Apply Plan Contract](/gateway/secrets-plan-contract)
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
## One-way safety policy
|
||||
|
||||
<Warning>
|
||||
OpenClaw intentionally does not write rollback backups containing historical plaintext secret values.
|
||||
</Warning>
|
||||
|
||||
Safety model:
|
||||
|
||||
@@ -529,11 +554,11 @@ For static credentials, runtime no longer depends on plaintext legacy auth stora
|
||||
|
||||
Some SecretInput unions are easier to configure in raw editor mode than in form mode.
|
||||
|
||||
## Related docs
|
||||
## Related
|
||||
|
||||
- CLI commands: [secrets](/cli/secrets)
|
||||
- Plan contract details: [Secrets Apply Plan Contract](/gateway/secrets-plan-contract)
|
||||
- Credential surface: [SecretRef Credential Surface](/reference/secretref-credential-surface)
|
||||
- Auth setup: [Authentication](/gateway/authentication)
|
||||
- Security posture: [Security](/gateway/security)
|
||||
- Environment precedence: [Environment Variables](/help/environment)
|
||||
- [Authentication](/gateway/authentication) — auth setup
|
||||
- [CLI: secrets](/cli/secrets) — CLI commands
|
||||
- [Environment Variables](/help/environment) — environment precedence
|
||||
- [SecretRef Credential Surface](/reference/secretref-credential-surface) — credential surface
|
||||
- [Secrets Apply Plan Contract](/gateway/secrets-plan-contract) — plan contract details
|
||||
- [Security](/gateway/security) — security posture
|
||||
|
||||
@@ -597,7 +597,7 @@ and troubleshooting see the main [FAQ](/help/faq).
|
||||
`openai-codex/gpt-5.5` for Codex OAuth through the default PI runner. Use
|
||||
`openai/gpt-5.5` for direct OpenAI API-key access. GPT-5.5 can also use
|
||||
subscription/OAuth via `openai-codex/gpt-5.5` or native Codex app-server
|
||||
runs with `openai/gpt-5.5` and `embeddedHarness.runtime: "codex"`.
|
||||
runs with `openai/gpt-5.5` and `agentRuntime.id: "codex"`.
|
||||
See [Model providers](/concepts/model-providers) and [Onboarding (CLI)](/start/wizard).
|
||||
</Accordion>
|
||||
|
||||
@@ -607,7 +607,7 @@ and troubleshooting see the main [FAQ](/help/faq).
|
||||
|
||||
- `openai/gpt-5.5` = current direct OpenAI API-key route in PI
|
||||
- `openai-codex/gpt-5.5` = Codex OAuth route in PI
|
||||
- `openai/gpt-5.5` + `embeddedHarness.runtime: "codex"` = native Codex app-server route
|
||||
- `openai/gpt-5.5` + `agentRuntime.id: "codex"` = native Codex app-server route
|
||||
- `openai-codex:...` = auth profile id, not a model ref
|
||||
|
||||
If you want the direct OpenAI Platform billing/limit path, set
|
||||
|
||||
@@ -9,8 +9,7 @@ title: "Plugin internals"
|
||||
sidebarTitle: "Internals"
|
||||
---
|
||||
|
||||
This is the **deep architecture reference** for the OpenClaw plugin system. For
|
||||
practical guides, start with one of the focused pages below.
|
||||
This is the **deep architecture reference** for the OpenClaw plugin system. For practical guides, start with one of the focused pages below.
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Install and use plugins" icon="plug" href="/tools/plugin">
|
||||
@@ -32,8 +31,7 @@ practical guides, start with one of the focused pages below.
|
||||
|
||||
## Public capability model
|
||||
|
||||
Capabilities are the public **native plugin** model inside OpenClaw. Every
|
||||
native OpenClaw plugin registers against one or more capability types:
|
||||
Capabilities are the public **native plugin** model inside OpenClaw. Every native OpenClaw plugin registers against one or more capability types:
|
||||
|
||||
| Capability | Registration method | Example plugins |
|
||||
| ---------------------- | ------------------------------------------------ | ------------------------------------ |
|
||||
@@ -51,15 +49,13 @@ native OpenClaw plugin registers against one or more capability types:
|
||||
| Channel / messaging | `api.registerChannel(...)` | `msteams`, `matrix` |
|
||||
| Gateway discovery | `api.registerGatewayDiscoveryService(...)` | `bonjour` |
|
||||
|
||||
A plugin that registers zero capabilities but provides hooks, tools, discovery
|
||||
services, or background services is a **legacy hook-only** plugin. That pattern
|
||||
is still fully supported.
|
||||
<Note>
|
||||
A plugin that registers zero capabilities but provides hooks, tools, discovery services, or background services is a **legacy hook-only** plugin. That pattern is still fully supported.
|
||||
</Note>
|
||||
|
||||
### External compatibility stance
|
||||
|
||||
The capability model is landed in core and used by bundled/native plugins
|
||||
today, but external plugin compatibility still needs a tighter bar than "it is
|
||||
exported, therefore it is frozen."
|
||||
The capability model is landed in core and used by bundled/native plugins today, but external plugin compatibility still needs a tighter bar than "it is exported, therefore it is frozen."
|
||||
|
||||
| Plugin situation | Guidance |
|
||||
| ------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
|
||||
@@ -67,33 +63,32 @@ exported, therefore it is frozen."
|
||||
| New bundled/native plugins | Prefer explicit capability registration over vendor-specific reach-ins or new hook-only designs. |
|
||||
| External plugins adopting capability registration | Allowed, but treat capability-specific helper surfaces as evolving unless docs mark them stable. |
|
||||
|
||||
Capability registration is the intended direction. Legacy hooks remain the
|
||||
safest no-breakage path for external plugins during the transition. Exported
|
||||
helper subpaths are not all equal — prefer narrow documented contracts over
|
||||
incidental helper exports.
|
||||
Capability registration is the intended direction. Legacy hooks remain the safest no-breakage path for external plugins during the transition. Exported helper subpaths are not all equal — prefer narrow documented contracts over incidental helper exports.
|
||||
|
||||
### Plugin shapes
|
||||
|
||||
OpenClaw classifies every loaded plugin into a shape based on its actual
|
||||
registration behavior (not just static metadata):
|
||||
OpenClaw classifies every loaded plugin into a shape based on its actual registration behavior (not just static metadata):
|
||||
|
||||
- **plain-capability**: registers exactly one capability type (for example a
|
||||
provider-only plugin like `mistral`).
|
||||
- **hybrid-capability**: registers multiple capability types (for example
|
||||
`openai` owns text inference, speech, media understanding, and image
|
||||
generation).
|
||||
- **hook-only**: registers only hooks (typed or custom), no capabilities,
|
||||
tools, commands, or services.
|
||||
- **non-capability**: registers tools, commands, services, or routes but no
|
||||
capabilities.
|
||||
<AccordionGroup>
|
||||
<Accordion title="plain-capability">
|
||||
Registers exactly one capability type (for example a provider-only plugin like `mistral`).
|
||||
</Accordion>
|
||||
<Accordion title="hybrid-capability">
|
||||
Registers multiple capability types (for example `openai` owns text inference, speech, media understanding, and image generation).
|
||||
</Accordion>
|
||||
<Accordion title="hook-only">
|
||||
Registers only hooks (typed or custom), no capabilities, tools, commands, or services.
|
||||
</Accordion>
|
||||
<Accordion title="non-capability">
|
||||
Registers tools, commands, services, or routes but no capabilities.
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
Use `openclaw plugins inspect <id>` to see a plugin's shape and capability
|
||||
breakdown. See [CLI reference](/cli/plugins#inspect) for details.
|
||||
Use `openclaw plugins inspect <id>` to see a plugin's shape and capability breakdown. See [CLI reference](/cli/plugins#inspect) for details.
|
||||
|
||||
### Legacy hooks
|
||||
|
||||
The `before_agent_start` hook remains supported as a compatibility path for
|
||||
hook-only plugins. Legacy real-world plugins still depend on it.
|
||||
The `before_agent_start` hook remains supported as a compatibility path for hook-only plugins. Legacy real-world plugins still depend on it.
|
||||
|
||||
Direction:
|
||||
|
||||
@@ -105,8 +100,7 @@ Direction:
|
||||
|
||||
### Compatibility signals
|
||||
|
||||
When you run `openclaw doctor` or `openclaw plugins inspect <id>`, you may see
|
||||
one of these labels:
|
||||
When you run `openclaw doctor` or `openclaw plugins inspect <id>`, you may see one of these labels:
|
||||
|
||||
| Signal | Meaning |
|
||||
| -------------------------- | ------------------------------------------------------------ |
|
||||
@@ -115,98 +109,71 @@ one of these labels:
|
||||
| **legacy warning** | Plugin uses `before_agent_start`, which is deprecated |
|
||||
| **hard error** | Config is invalid or plugin failed to load |
|
||||
|
||||
Neither `hook-only` nor `before_agent_start` will break your plugin today:
|
||||
`hook-only` is advisory, and `before_agent_start` only triggers a warning. These
|
||||
signals also appear in `openclaw status --all` and `openclaw plugins doctor`.
|
||||
Neither `hook-only` nor `before_agent_start` will break your plugin today: `hook-only` is advisory, and `before_agent_start` only triggers a warning. These signals also appear in `openclaw status --all` and `openclaw plugins doctor`.
|
||||
|
||||
## Architecture overview
|
||||
|
||||
OpenClaw's plugin system has four layers:
|
||||
|
||||
1. **Manifest + discovery**
|
||||
OpenClaw finds candidate plugins from configured paths, workspace roots,
|
||||
global plugin roots, and bundled plugins. Discovery reads native
|
||||
`openclaw.plugin.json` manifests plus supported bundle manifests first.
|
||||
2. **Enablement + validation**
|
||||
Core decides whether a discovered plugin is enabled, disabled, blocked, or
|
||||
selected for an exclusive slot such as memory.
|
||||
3. **Runtime loading**
|
||||
Native OpenClaw plugins are loaded in-process via jiti and register
|
||||
capabilities into a central registry. Compatible bundles are normalized into
|
||||
registry records without importing runtime code.
|
||||
4. **Surface consumption**
|
||||
The rest of OpenClaw reads the registry to expose tools, channels, provider
|
||||
setup, hooks, HTTP routes, CLI commands, and services.
|
||||
<Steps>
|
||||
<Step title="Manifest + discovery">
|
||||
OpenClaw finds candidate plugins from configured paths, workspace roots, global plugin roots, and bundled plugins. Discovery reads native `openclaw.plugin.json` manifests plus supported bundle manifests first.
|
||||
</Step>
|
||||
<Step title="Enablement + validation">
|
||||
Core decides whether a discovered plugin is enabled, disabled, blocked, or selected for an exclusive slot such as memory.
|
||||
</Step>
|
||||
<Step title="Runtime loading">
|
||||
Native OpenClaw plugins are loaded in-process via jiti and register capabilities into a central registry. Compatible bundles are normalized into registry records without importing runtime code.
|
||||
</Step>
|
||||
<Step title="Surface consumption">
|
||||
The rest of OpenClaw reads the registry to expose tools, channels, provider setup, hooks, HTTP routes, CLI commands, and services.
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
For plugin CLI specifically, root command discovery is split in two phases:
|
||||
|
||||
- parse-time metadata comes from `registerCli(..., { descriptors: [...] })`
|
||||
- the real plugin CLI module can stay lazy and register on first invocation
|
||||
|
||||
That keeps plugin-owned CLI code inside the plugin while still letting OpenClaw
|
||||
reserve root command names before parsing.
|
||||
That keeps plugin-owned CLI code inside the plugin while still letting OpenClaw reserve root command names before parsing.
|
||||
|
||||
The important design boundary:
|
||||
|
||||
- manifest/config validation should work from **manifest/schema metadata**
|
||||
without executing plugin code
|
||||
- native capability discovery may load trusted plugin entry code to build a
|
||||
non-activating registry snapshot
|
||||
- native runtime behavior comes from the plugin module's `register(api)` path
|
||||
with `api.registrationMode === "full"`
|
||||
- manifest/config validation should work from **manifest/schema metadata** without executing plugin code
|
||||
- native capability discovery may load trusted plugin entry code to build a non-activating registry snapshot
|
||||
- native runtime behavior comes from the plugin module's `register(api)` path with `api.registrationMode === "full"`
|
||||
|
||||
That split lets OpenClaw validate config, explain missing/disabled plugins, and
|
||||
build UI/schema hints before the full runtime is active.
|
||||
That split lets OpenClaw validate config, explain missing/disabled plugins, and build UI/schema hints before the full runtime is active.
|
||||
|
||||
### Activation planning
|
||||
|
||||
Activation planning is part of the control plane. Callers can ask which plugins
|
||||
are relevant to a concrete command, provider, channel, route, agent harness, or
|
||||
capability before loading broader runtime registries.
|
||||
Activation planning is part of the control plane. Callers can ask which plugins are relevant to a concrete command, provider, channel, route, agent harness, or capability before loading broader runtime registries.
|
||||
|
||||
The planner keeps current manifest behavior compatible:
|
||||
|
||||
- `activation.*` fields are explicit planner hints
|
||||
- `providers`, `channels`, `commandAliases`, `setup.providers`,
|
||||
`contracts.tools`, and hooks remain manifest ownership fallback
|
||||
- `providers`, `channels`, `commandAliases`, `setup.providers`, `contracts.tools`, and hooks remain manifest ownership fallback
|
||||
- the ids-only planner API stays available for existing callers
|
||||
- the plan API reports reason labels so diagnostics can distinguish explicit
|
||||
hints from ownership fallback
|
||||
- the plan API reports reason labels so diagnostics can distinguish explicit hints from ownership fallback
|
||||
|
||||
Do not treat `activation` as a lifecycle hook or a replacement for
|
||||
`register(...)`. It is metadata used to narrow loading. Prefer ownership fields
|
||||
when they already describe the relationship; use `activation` only for extra
|
||||
planner hints.
|
||||
<Warning>
|
||||
Do not treat `activation` as a lifecycle hook or a replacement for `register(...)`. It is metadata used to narrow loading. Prefer ownership fields when they already describe the relationship; use `activation` only for extra planner hints.
|
||||
</Warning>
|
||||
|
||||
### Channel plugins and the shared message tool
|
||||
|
||||
Channel plugins do not need to register a separate send/edit/react tool for
|
||||
normal chat actions. OpenClaw keeps one shared `message` tool in core, and
|
||||
channel plugins own the channel-specific discovery and execution behind it.
|
||||
Channel plugins do not need to register a separate send/edit/react tool for normal chat actions. OpenClaw keeps one shared `message` tool in core, and channel plugins own the channel-specific discovery and execution behind it.
|
||||
|
||||
The current boundary is:
|
||||
|
||||
- core owns the shared `message` tool host, prompt wiring, session/thread
|
||||
bookkeeping, and execution dispatch
|
||||
- channel plugins own scoped action discovery, capability discovery, and any
|
||||
channel-specific schema fragments
|
||||
- channel plugins own provider-specific session conversation grammar, such as
|
||||
how conversation ids encode thread ids or inherit from parent conversations
|
||||
- core owns the shared `message` tool host, prompt wiring, session/thread bookkeeping, and execution dispatch
|
||||
- channel plugins own scoped action discovery, capability discovery, and any channel-specific schema fragments
|
||||
- channel plugins own provider-specific session conversation grammar, such as how conversation ids encode thread ids or inherit from parent conversations
|
||||
- channel plugins execute the final action through their action adapter
|
||||
|
||||
For channel plugins, the SDK surface is
|
||||
`ChannelMessageActionAdapter.describeMessageTool(...)`. That unified discovery
|
||||
call lets a plugin return its visible actions, capabilities, and schema
|
||||
contributions together so those pieces do not drift apart.
|
||||
For channel plugins, the SDK surface is `ChannelMessageActionAdapter.describeMessageTool(...)`. That unified discovery call lets a plugin return its visible actions, capabilities, and schema contributions together so those pieces do not drift apart.
|
||||
|
||||
When a channel-specific message-tool param carries a media source such as a
|
||||
local path or remote media URL, the plugin should also return
|
||||
`mediaSourceParams` from `describeMessageTool(...)`. Core uses that explicit
|
||||
list to apply sandbox path normalization and outbound media-access hints
|
||||
without hardcoding plugin-owned param names.
|
||||
Prefer action-scoped maps there, not one channel-wide flat list, so a
|
||||
profile-only media param does not get normalized on unrelated actions like
|
||||
`send`.
|
||||
When a channel-specific message-tool param carries a media source such as a local path or remote media URL, the plugin should also return `mediaSourceParams` from `describeMessageTool(...)`. Core uses that explicit list to apply sandbox path normalization and outbound media-access hints without hardcoding plugin-owned param names. Prefer action-scoped maps there, not one channel-wide flat list, so a profile-only media param does not get normalized on unrelated actions like `send`.
|
||||
|
||||
Core passes runtime scope into that discovery step. Important fields include:
|
||||
|
||||
@@ -219,107 +186,92 @@ Core passes runtime scope into that discovery step. Important fields include:
|
||||
- `agentId`
|
||||
- trusted inbound `requesterSenderId`
|
||||
|
||||
That matters for context-sensitive plugins. A channel can hide or expose
|
||||
message actions based on the active account, current room/thread/message, or
|
||||
trusted requester identity without hardcoding channel-specific branches in the
|
||||
core `message` tool.
|
||||
That matters for context-sensitive plugins. A channel can hide or expose message actions based on the active account, current room/thread/message, or trusted requester identity without hardcoding channel-specific branches in the core `message` tool.
|
||||
|
||||
This is why embedded-runner routing changes are still plugin work: the runner is
|
||||
responsible for forwarding the current chat/session identity into the plugin
|
||||
discovery boundary so the shared `message` tool exposes the right channel-owned
|
||||
surface for the current turn.
|
||||
This is why embedded-runner routing changes are still plugin work: the runner is responsible for forwarding the current chat/session identity into the plugin discovery boundary so the shared `message` tool exposes the right channel-owned surface for the current turn.
|
||||
|
||||
For channel-owned execution helpers, bundled plugins should keep the execution
|
||||
runtime inside their own extension modules. Core no longer owns the Discord,
|
||||
Slack, Telegram, or WhatsApp message-action runtimes under `src/agents/tools`.
|
||||
We do not publish separate `plugin-sdk/*-action-runtime` subpaths, and bundled
|
||||
plugins should import their own local runtime code directly from their
|
||||
extension-owned modules.
|
||||
For channel-owned execution helpers, bundled plugins should keep the execution runtime inside their own extension modules. Core no longer owns the Discord, Slack, Telegram, or WhatsApp message-action runtimes under `src/agents/tools`. We do not publish separate `plugin-sdk/*-action-runtime` subpaths, and bundled plugins should import their own local runtime code directly from their extension-owned modules.
|
||||
|
||||
The same boundary applies to provider-named SDK seams in general: core should
|
||||
not import channel-specific convenience barrels for Slack, Discord, Signal,
|
||||
WhatsApp, or similar extensions. If core needs a behavior, either consume the
|
||||
bundled plugin's own `api.ts` / `runtime-api.ts` barrel or promote the need
|
||||
into a narrow generic capability in the shared SDK.
|
||||
The same boundary applies to provider-named SDK seams in general: core should not import channel-specific convenience barrels for Slack, Discord, Signal, WhatsApp, or similar extensions. If core needs a behavior, either consume the bundled plugin's own `api.ts` / `runtime-api.ts` barrel or promote the need into a narrow generic capability in the shared SDK.
|
||||
|
||||
For polls specifically, there are two execution paths:
|
||||
|
||||
- `outbound.sendPoll` is the shared baseline for channels that fit the common
|
||||
poll model
|
||||
- `actions.handleAction("poll")` is the preferred path for channel-specific
|
||||
poll semantics or extra poll parameters
|
||||
- `outbound.sendPoll` is the shared baseline for channels that fit the common poll model
|
||||
- `actions.handleAction("poll")` is the preferred path for channel-specific poll semantics or extra poll parameters
|
||||
|
||||
Core now defers shared poll parsing until after plugin poll dispatch declines
|
||||
the action, so plugin-owned poll handlers can accept channel-specific poll
|
||||
fields without being blocked by the generic poll parser first.
|
||||
Core now defers shared poll parsing until after plugin poll dispatch declines the action, so plugin-owned poll handlers can accept channel-specific poll fields without being blocked by the generic poll parser first.
|
||||
|
||||
See [Plugin architecture internals](/plugins/architecture-internals) for the full startup sequence.
|
||||
|
||||
## Capability ownership model
|
||||
|
||||
OpenClaw treats a native plugin as the ownership boundary for a **company** or a
|
||||
**feature**, not as a grab bag of unrelated integrations.
|
||||
OpenClaw treats a native plugin as the ownership boundary for a **company** or a **feature**, not as a grab bag of unrelated integrations.
|
||||
|
||||
That means:
|
||||
|
||||
- a company plugin should usually own all of that company's OpenClaw-facing
|
||||
surfaces
|
||||
- a company plugin should usually own all of that company's OpenClaw-facing surfaces
|
||||
- a feature plugin should usually own the full feature surface it introduces
|
||||
- channels should consume shared core capabilities instead of re-implementing
|
||||
provider behavior ad hoc
|
||||
- channels should consume shared core capabilities instead of re-implementing provider behavior ad hoc
|
||||
|
||||
<Accordion title="Example ownership patterns across bundled plugins">
|
||||
- **Vendor multi-capability**: `openai` owns text inference, speech, realtime
|
||||
voice, media understanding, and image generation. `google` owns text
|
||||
inference plus media understanding, image generation, and web search.
|
||||
`qwen` owns text inference plus media understanding and video generation.
|
||||
- **Vendor single-capability**: `elevenlabs` and `microsoft` own speech;
|
||||
`firecrawl` owns web-fetch; `minimax` / `mistral` / `moonshot` / `zai` own
|
||||
media-understanding backends.
|
||||
- **Feature plugin**: `voice-call` owns call transport, tools, CLI, routes,
|
||||
and Twilio media-stream bridging, but consumes shared speech, realtime
|
||||
transcription, and realtime voice capabilities instead of importing vendor
|
||||
plugins directly.
|
||||
</Accordion>
|
||||
<AccordionGroup>
|
||||
<Accordion title="Vendor multi-capability">
|
||||
`openai` owns text inference, speech, realtime voice, media understanding, and image generation. `google` owns text inference plus media understanding, image generation, and web search. `qwen` owns text inference plus media understanding and video generation.
|
||||
</Accordion>
|
||||
<Accordion title="Vendor single-capability">
|
||||
`elevenlabs` and `microsoft` own speech; `firecrawl` owns web-fetch; `minimax` / `mistral` / `moonshot` / `zai` own media-understanding backends.
|
||||
</Accordion>
|
||||
<Accordion title="Feature plugin">
|
||||
`voice-call` owns call transport, tools, CLI, routes, and Twilio media-stream bridging, but consumes shared speech, realtime transcription, and realtime voice capabilities instead of importing vendor plugins directly.
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
The intended end state is:
|
||||
|
||||
- OpenAI lives in one plugin even if it spans text models, speech, images, and
|
||||
future video
|
||||
- OpenAI lives in one plugin even if it spans text models, speech, images, and future video
|
||||
- another vendor can do the same for its own surface area
|
||||
- channels do not care which vendor plugin owns the provider; they consume the
|
||||
shared capability contract exposed by core
|
||||
- channels do not care which vendor plugin owns the provider; they consume the shared capability contract exposed by core
|
||||
|
||||
This is the key distinction:
|
||||
|
||||
- **plugin** = ownership boundary
|
||||
- **capability** = core contract that multiple plugins can implement or consume
|
||||
|
||||
So if OpenClaw adds a new domain such as video, the first question is not
|
||||
"which provider should hardcode video handling?" The first question is "what is
|
||||
the core video capability contract?" Once that contract exists, vendor plugins
|
||||
can register against it and channel/feature plugins can consume it.
|
||||
So if OpenClaw adds a new domain such as video, the first question is not "which provider should hardcode video handling?" The first question is "what is the core video capability contract?" Once that contract exists, vendor plugins can register against it and channel/feature plugins can consume it.
|
||||
|
||||
If the capability does not exist yet, the right move is usually:
|
||||
|
||||
1. define the missing capability in core
|
||||
2. expose it through the plugin API/runtime in a typed way
|
||||
3. wire channels/features against that capability
|
||||
4. let vendor plugins register implementations
|
||||
<Steps>
|
||||
<Step title="Define the capability">
|
||||
Define the missing capability in core.
|
||||
</Step>
|
||||
<Step title="Expose through the SDK">
|
||||
Expose it through the plugin API/runtime in a typed way.
|
||||
</Step>
|
||||
<Step title="Wire consumers">
|
||||
Wire channels/features against that capability.
|
||||
</Step>
|
||||
<Step title="Vendor implementations">
|
||||
Let vendor plugins register implementations.
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
This keeps ownership explicit while avoiding core behavior that depends on a
|
||||
single vendor or a one-off plugin-specific code path.
|
||||
This keeps ownership explicit while avoiding core behavior that depends on a single vendor or a one-off plugin-specific code path.
|
||||
|
||||
### Capability layering
|
||||
|
||||
Use this mental model when deciding where code belongs:
|
||||
|
||||
- **core capability layer**: shared orchestration, policy, fallback, config
|
||||
merge rules, delivery semantics, and typed contracts
|
||||
- **vendor plugin layer**: vendor-specific APIs, auth, model catalogs, speech
|
||||
synthesis, image generation, future video backends, usage endpoints
|
||||
- **channel/feature plugin layer**: Slack/Discord/voice-call/etc. integration
|
||||
that consumes core capabilities and presents them on a surface
|
||||
<Tabs>
|
||||
<Tab title="Core capability layer">
|
||||
Shared orchestration, policy, fallback, config merge rules, delivery semantics, and typed contracts.
|
||||
</Tab>
|
||||
<Tab title="Vendor plugin layer">
|
||||
Vendor-specific APIs, auth, model catalogs, speech synthesis, image generation, future video backends, usage endpoints.
|
||||
</Tab>
|
||||
<Tab title="Channel/feature plugin layer">
|
||||
Slack/Discord/voice-call/etc. integration that consumes core capabilities and presents them on a surface.
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
For example, TTS follows this shape:
|
||||
|
||||
@@ -331,10 +283,7 @@ That same pattern should be preferred for future capabilities.
|
||||
|
||||
### Multi-capability company plugin example
|
||||
|
||||
A company plugin should feel cohesive from the outside. If OpenClaw has shared
|
||||
contracts for models, speech, realtime transcription, realtime voice, media
|
||||
understanding, image generation, video generation, web fetch, and web search,
|
||||
a vendor can own all of its surfaces in one place:
|
||||
A company plugin should feel cohesive from the outside. If OpenClaw has shared contracts for models, speech, realtime transcription, realtime voice, media understanding, image generation, video generation, web fetch, and web search, a vendor can own all of its surfaces in one place:
|
||||
|
||||
```ts
|
||||
import type { OpenClawPluginDefinition } from "openclaw/plugin-sdk/plugin-entry";
|
||||
@@ -393,117 +342,101 @@ What matters is not the exact helper names. The shape matters:
|
||||
- one plugin owns the vendor surface
|
||||
- core still owns the capability contracts
|
||||
- channels and feature plugins consume `api.runtime.*` helpers, not vendor code
|
||||
- contract tests can assert that the plugin registered the capabilities it
|
||||
claims to own
|
||||
- contract tests can assert that the plugin registered the capabilities it claims to own
|
||||
|
||||
### Capability example: video understanding
|
||||
|
||||
OpenClaw already treats image/audio/video understanding as one shared
|
||||
capability. The same ownership model applies there:
|
||||
OpenClaw already treats image/audio/video understanding as one shared capability. The same ownership model applies there:
|
||||
|
||||
1. core defines the media-understanding contract
|
||||
2. vendor plugins register `describeImage`, `transcribeAudio`, and
|
||||
`describeVideo` as applicable
|
||||
3. channels and feature plugins consume the shared core behavior instead of
|
||||
wiring directly to vendor code
|
||||
<Steps>
|
||||
<Step title="Core defines the contract">
|
||||
Core defines the media-understanding contract.
|
||||
</Step>
|
||||
<Step title="Vendor plugins register">
|
||||
Vendor plugins register `describeImage`, `transcribeAudio`, and `describeVideo` as applicable.
|
||||
</Step>
|
||||
<Step title="Consumers use the shared behavior">
|
||||
Channels and feature plugins consume the shared core behavior instead of wiring directly to vendor code.
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
That avoids baking one provider's video assumptions into core. The plugin owns
|
||||
the vendor surface; core owns the capability contract and fallback behavior.
|
||||
That avoids baking one provider's video assumptions into core. The plugin owns the vendor surface; core owns the capability contract and fallback behavior.
|
||||
|
||||
Video generation already uses that same sequence: core owns the typed
|
||||
capability contract and runtime helper, and vendor plugins register
|
||||
`api.registerVideoGenerationProvider(...)` implementations against it.
|
||||
Video generation already uses that same sequence: core owns the typed capability contract and runtime helper, and vendor plugins register `api.registerVideoGenerationProvider(...)` implementations against it.
|
||||
|
||||
Need a concrete rollout checklist? See
|
||||
[Capability Cookbook](/tools/capability-cookbook).
|
||||
Need a concrete rollout checklist? See [Capability Cookbook](/tools/capability-cookbook).
|
||||
|
||||
## Contracts and enforcement
|
||||
|
||||
The plugin API surface is intentionally typed and centralized in
|
||||
`OpenClawPluginApi`. That contract defines the supported registration points and
|
||||
the runtime helpers a plugin may rely on.
|
||||
The plugin API surface is intentionally typed and centralized in `OpenClawPluginApi`. That contract defines the supported registration points and the runtime helpers a plugin may rely on.
|
||||
|
||||
Why this matters:
|
||||
|
||||
- plugin authors get one stable internal standard
|
||||
- core can reject duplicate ownership such as two plugins registering the same
|
||||
provider id
|
||||
- core can reject duplicate ownership such as two plugins registering the same provider id
|
||||
- startup can surface actionable diagnostics for malformed registration
|
||||
- contract tests can enforce bundled-plugin ownership and prevent silent drift
|
||||
|
||||
There are two layers of enforcement:
|
||||
|
||||
1. **runtime registration enforcement**
|
||||
The plugin registry validates registrations as plugins load. Examples:
|
||||
duplicate provider ids, duplicate speech provider ids, and malformed
|
||||
registrations produce plugin diagnostics instead of undefined behavior.
|
||||
2. **contract tests**
|
||||
Bundled plugins are captured in contract registries during test runs so
|
||||
OpenClaw can assert ownership explicitly. Today this is used for model
|
||||
providers, speech providers, web search providers, and bundled registration
|
||||
ownership.
|
||||
<AccordionGroup>
|
||||
<Accordion title="Runtime registration enforcement">
|
||||
The plugin registry validates registrations as plugins load. Examples: duplicate provider ids, duplicate speech provider ids, and malformed registrations produce plugin diagnostics instead of undefined behavior.
|
||||
</Accordion>
|
||||
<Accordion title="Contract tests">
|
||||
Bundled plugins are captured in contract registries during test runs so OpenClaw can assert ownership explicitly. Today this is used for model providers, speech providers, web search providers, and bundled registration ownership.
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
The practical effect is that OpenClaw knows, up front, which plugin owns which
|
||||
surface. That lets core and channels compose seamlessly because ownership is
|
||||
declared, typed, and testable rather than implicit.
|
||||
The practical effect is that OpenClaw knows, up front, which plugin owns which surface. That lets core and channels compose seamlessly because ownership is declared, typed, and testable rather than implicit.
|
||||
|
||||
### What belongs in a contract
|
||||
|
||||
Good plugin contracts are:
|
||||
<Tabs>
|
||||
<Tab title="Good contracts">
|
||||
- typed
|
||||
- small
|
||||
- capability-specific
|
||||
- owned by core
|
||||
- reusable by multiple plugins
|
||||
- consumable by channels/features without vendor knowledge
|
||||
</Tab>
|
||||
<Tab title="Bad contracts">
|
||||
- vendor-specific policy hidden in core
|
||||
- one-off plugin escape hatches that bypass the registry
|
||||
- channel code reaching straight into a vendor implementation
|
||||
- ad hoc runtime objects that are not part of `OpenClawPluginApi` or `api.runtime`
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
- typed
|
||||
- small
|
||||
- capability-specific
|
||||
- owned by core
|
||||
- reusable by multiple plugins
|
||||
- consumable by channels/features without vendor knowledge
|
||||
|
||||
Bad plugin contracts are:
|
||||
|
||||
- vendor-specific policy hidden in core
|
||||
- one-off plugin escape hatches that bypass the registry
|
||||
- channel code reaching straight into a vendor implementation
|
||||
- ad hoc runtime objects that are not part of `OpenClawPluginApi` or
|
||||
`api.runtime`
|
||||
|
||||
When in doubt, raise the abstraction level: define the capability first, then
|
||||
let plugins plug into it.
|
||||
When in doubt, raise the abstraction level: define the capability first, then let plugins plug into it.
|
||||
|
||||
## Execution model
|
||||
|
||||
Native OpenClaw plugins run **in-process** with the Gateway. They are not
|
||||
sandboxed. A loaded native plugin has the same process-level trust boundary as
|
||||
core code.
|
||||
Native OpenClaw plugins run **in-process** with the Gateway. They are not sandboxed. A loaded native plugin has the same process-level trust boundary as core code.
|
||||
|
||||
<Warning>
|
||||
Implications:
|
||||
|
||||
- a native plugin can register tools, network handlers, hooks, and services
|
||||
- a native plugin bug can crash or destabilize the gateway
|
||||
- a malicious native plugin is equivalent to arbitrary code execution inside
|
||||
the OpenClaw process
|
||||
- a malicious native plugin is equivalent to arbitrary code execution inside the OpenClaw process
|
||||
</Warning>
|
||||
|
||||
Compatible bundles are safer by default because OpenClaw currently treats them
|
||||
as metadata/content packs. In current releases, that mostly means bundled
|
||||
skills.
|
||||
Compatible bundles are safer by default because OpenClaw currently treats them as metadata/content packs. In current releases, that mostly means bundled skills.
|
||||
|
||||
Use allowlists and explicit install/load paths for non-bundled plugins. Treat
|
||||
workspace plugins as development-time code, not production defaults.
|
||||
Use allowlists and explicit install/load paths for non-bundled plugins. Treat workspace plugins as development-time code, not production defaults.
|
||||
|
||||
For bundled workspace package names, keep the plugin id anchored in the npm
|
||||
name: `@openclaw/<id>` by default, or an approved typed suffix such as
|
||||
`-provider`, `-plugin`, `-speech`, `-sandbox`, or `-media-understanding` when
|
||||
the package intentionally exposes a narrower plugin role.
|
||||
For bundled workspace package names, keep the plugin id anchored in the npm name: `@openclaw/<id>` by default, or an approved typed suffix such as `-provider`, `-plugin`, `-speech`, `-sandbox`, or `-media-understanding` when the package intentionally exposes a narrower plugin role.
|
||||
|
||||
Important trust note:
|
||||
<Note>
|
||||
**Trust note:**
|
||||
|
||||
- `plugins.allow` trusts **plugin ids**, not source provenance.
|
||||
- A workspace plugin with the same id as a bundled plugin intentionally shadows
|
||||
the bundled copy when that workspace plugin is enabled/allowlisted.
|
||||
- A workspace plugin with the same id as a bundled plugin intentionally shadows the bundled copy when that workspace plugin is enabled/allowlisted.
|
||||
- This is normal and useful for local development, patch testing, and hotfixes.
|
||||
- Bundled-plugin trust is resolved from the source snapshot — the manifest and
|
||||
code on disk at load time — rather than from install metadata. A corrupted
|
||||
or substituted install record cannot silently widen a bundled plugin's trust
|
||||
surface beyond what the actual source claims.
|
||||
- Bundled-plugin trust is resolved from the source snapshot — the manifest and code on disk at load time — rather than from install metadata. A corrupted or substituted install record cannot silently widen a bundled plugin's trust surface beyond what the actual source claims.
|
||||
</Note>
|
||||
|
||||
## Export boundary
|
||||
|
||||
@@ -516,22 +449,14 @@ Keep capability registration public. Trim non-contract helper exports:
|
||||
- vendor-specific convenience helpers
|
||||
- setup/onboarding helpers that are implementation details
|
||||
|
||||
Some bundled-plugin helper subpaths still remain in the generated SDK export
|
||||
map for compatibility and bundled-plugin maintenance. Current examples include
|
||||
`plugin-sdk/feishu`, `plugin-sdk/feishu-setup`, `plugin-sdk/zalo`,
|
||||
`plugin-sdk/zalo-setup`, and several `plugin-sdk/matrix*` seams. Treat those as
|
||||
reserved implementation-detail exports, not as the recommended SDK pattern for
|
||||
new third-party plugins.
|
||||
Some bundled-plugin helper subpaths still remain in the generated SDK export map for compatibility and bundled-plugin maintenance. Current examples include `plugin-sdk/feishu`, `plugin-sdk/feishu-setup`, `plugin-sdk/zalo`, `plugin-sdk/zalo-setup`, and several `plugin-sdk/matrix*` seams. Treat those as reserved implementation-detail exports, not as the recommended SDK pattern for new third-party plugins.
|
||||
|
||||
## Internals and reference
|
||||
|
||||
For the load pipeline, registry model, provider runtime hooks, Gateway HTTP
|
||||
routes, message tool schemas, channel target resolution, provider catalogs,
|
||||
context engine plugins, and the guide to adding a new capability, see
|
||||
[Plugin architecture internals](/plugins/architecture-internals).
|
||||
For the load pipeline, registry model, provider runtime hooks, Gateway HTTP routes, message tool schemas, channel target resolution, provider catalogs, context engine plugins, and the guide to adding a new capability, see [Plugin architecture internals](/plugins/architecture-internals).
|
||||
|
||||
## Related
|
||||
|
||||
- [Building plugins](/plugins/building-plugins)
|
||||
- [Plugin SDK setup](/plugins/sdk-setup)
|
||||
- [Plugin manifest](/plugins/manifest)
|
||||
- [Plugin SDK setup](/plugins/sdk-setup)
|
||||
|
||||
@@ -26,7 +26,7 @@ The bundled `codex` plugin contributes several separate capabilities:
|
||||
|
||||
| Capability | How you use it | What it does |
|
||||
| --------------------------------- | --------------------------------------------------- | ----------------------------------------------------------------------------- |
|
||||
| Native embedded runtime | `embeddedHarness.runtime: "codex"` | Runs OpenClaw embedded agent turns through Codex app-server. |
|
||||
| Native embedded runtime | `agentRuntime.id: "codex"` | Runs OpenClaw embedded agent turns through Codex app-server. |
|
||||
| Native chat-control commands | `/codex bind`, `/codex resume`, `/codex steer`, ... | Binds and controls Codex app-server threads from a messaging conversation. |
|
||||
| Codex app-server provider/catalog | `codex` internals, surfaced through the harness | Lets the runtime discover and validate app-server models. |
|
||||
| Codex media-understanding path | `codex/*` image-model compatibility paths | Runs bounded Codex app-server turns for supported image understanding models. |
|
||||
@@ -69,7 +69,7 @@ and [Plugin guard behavior](/tools/plugin).
|
||||
|
||||
The harness is off by default. New configs should keep OpenAI model refs
|
||||
canonical as `openai/gpt-*` and explicitly force
|
||||
`embeddedHarness.runtime: "codex"` or `OPENCLAW_AGENT_RUNTIME=codex` when they
|
||||
`agentRuntime.id: "codex"` or `OPENCLAW_AGENT_RUNTIME=codex` when they
|
||||
want native app-server execution. Legacy `codex/*` model refs still auto-select
|
||||
the harness for compatibility, but runtime-backed legacy provider prefixes are
|
||||
not shown as normal model/provider choices.
|
||||
@@ -87,14 +87,14 @@ Use this table before changing config:
|
||||
| ------------------------------------------- | -------------------------- | -------------------------------------- | --------------------------- | ------------------------------ |
|
||||
| OpenAI API through normal OpenClaw runner | `openai/gpt-*` | omitted or `runtime: "pi"` | OpenAI provider | `Runtime: OpenClaw Pi Default` |
|
||||
| Codex OAuth/subscription through PI | `openai-codex/gpt-*` | omitted or `runtime: "pi"` | OpenAI Codex OAuth provider | `Runtime: OpenClaw Pi Default` |
|
||||
| Native Codex app-server embedded turns | `openai/gpt-*` | `embeddedHarness.runtime: "codex"` | `codex` plugin | `Runtime: OpenAI Codex` |
|
||||
| Mixed providers with conservative auto mode | provider-specific refs | `runtime: "auto", fallback: "pi"` | Optional plugin runtimes | Depends on selected runtime |
|
||||
| Native Codex app-server embedded turns | `openai/gpt-*` | `agentRuntime.id: "codex"` | `codex` plugin | `Runtime: OpenAI Codex` |
|
||||
| Mixed providers with conservative auto mode | provider-specific refs | `agentRuntime.id: "auto"` | Optional plugin runtimes | Depends on selected runtime |
|
||||
| Explicit Codex ACP adapter session | ACP prompt/model dependent | `sessions_spawn` with `runtime: "acp"` | healthy `acpx` backend | ACP task/session status |
|
||||
|
||||
The important split is provider versus runtime:
|
||||
|
||||
- `openai-codex/*` answers "which provider/auth route should PI use?"
|
||||
- `embeddedHarness.runtime: "codex"` answers "which loop should execute this
|
||||
- `agentRuntime.id: "codex"` answers "which loop should execute this
|
||||
embedded turn?"
|
||||
- `/codex ...` answers "which native Codex conversation should this chat bind
|
||||
or control?"
|
||||
@@ -106,11 +106,11 @@ OpenAI-family routes are prefix-specific. Use `openai-codex/*` when you want
|
||||
Codex OAuth through PI; use `openai/*` when you want direct OpenAI API access or
|
||||
when you are forcing the native Codex app-server harness:
|
||||
|
||||
| Model ref | Runtime path | Use when |
|
||||
| ----------------------------------------------------- | -------------------------------------------- | ------------------------------------------------------------------------- |
|
||||
| `openai/gpt-5.4` | OpenAI provider through OpenClaw/PI plumbing | You want current direct OpenAI Platform API access with `OPENAI_API_KEY`. |
|
||||
| `openai-codex/gpt-5.5` | OpenAI Codex OAuth through OpenClaw/PI | You want ChatGPT/Codex subscription auth with the default PI runner. |
|
||||
| `openai/gpt-5.5` + `embeddedHarness.runtime: "codex"` | Codex app-server harness | You want native Codex app-server execution for the embedded agent turn. |
|
||||
| Model ref | Runtime path | Use when |
|
||||
| --------------------------------------------- | -------------------------------------------- | ------------------------------------------------------------------------- |
|
||||
| `openai/gpt-5.4` | OpenAI provider through OpenClaw/PI plumbing | You want current direct OpenAI Platform API access with `OPENAI_API_KEY`. |
|
||||
| `openai-codex/gpt-5.5` | OpenAI Codex OAuth through OpenClaw/PI | You want ChatGPT/Codex subscription auth with the default PI runner. |
|
||||
| `openai/gpt-5.5` + `agentRuntime.id: "codex"` | Codex app-server harness | You want native Codex app-server execution for the embedded agent turn. |
|
||||
|
||||
GPT-5.5 is currently subscription/OAuth-only in OpenClaw. Use
|
||||
`openai-codex/gpt-5.5` for PI OAuth, or `openai/gpt-5.5` with the Codex
|
||||
@@ -123,7 +123,7 @@ refs and records the runtime policy separately, while fallback-only legacy refs
|
||||
are left unchanged because runtime is configured for the whole agent container.
|
||||
New PI Codex OAuth configs should use `openai-codex/gpt-*`; new native
|
||||
app-server harness configs should use `openai/gpt-*` plus
|
||||
`embeddedHarness.runtime: "codex"`.
|
||||
`agentRuntime.id: "codex"`.
|
||||
|
||||
`agents.defaults.imageModel` follows the same prefix split. Use
|
||||
`openai-codex/gpt-*` when image understanding should run through the OpenAI
|
||||
@@ -152,14 +152,14 @@ means:
|
||||
|
||||
- **No change is required** if you intended ChatGPT/Codex OAuth through PI.
|
||||
- Change the model to `openai/<model>` and set
|
||||
`embeddedHarness.runtime: "codex"` if you intended native app-server
|
||||
`agentRuntime.id: "codex"` if you intended native app-server
|
||||
execution.
|
||||
- Existing sessions still need `/new` or `/reset` after a runtime change,
|
||||
because session runtime pins are sticky.
|
||||
|
||||
Harness selection is not a live session control. When an embedded turn runs,
|
||||
OpenClaw records the selected harness id on that session and keeps using it for
|
||||
later turns in the same session id. Change `embeddedHarness` config or
|
||||
later turns in the same session id. Change `agentRuntime` config or
|
||||
`OPENCLAW_AGENT_RUNTIME` when you want future sessions to use another harness;
|
||||
use `/new` or `/reset` to start a fresh session before switching an existing
|
||||
conversation between PI and Codex. This avoids replaying one transcript through
|
||||
@@ -205,8 +205,8 @@ Use `openai/gpt-5.5`, enable the bundled plugin, and force the `codex` harness:
|
||||
agents: {
|
||||
defaults: {
|
||||
model: "openai/gpt-5.5",
|
||||
embeddedHarness: {
|
||||
runtime: "codex",
|
||||
agentRuntime: {
|
||||
id: "codex",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -230,11 +230,11 @@ If your config uses `plugins.allow`, include `codex` there too:
|
||||
|
||||
Legacy configs that set `agents.defaults.model` or an agent model to
|
||||
`codex/<model>` still auto-enable the bundled `codex` plugin. New configs should
|
||||
prefer `openai/<model>` plus the explicit `embeddedHarness` entry above.
|
||||
prefer `openai/<model>` plus the explicit `agentRuntime` entry above.
|
||||
|
||||
## Add Codex alongside other models
|
||||
|
||||
Do not set `runtime: "codex"` globally if the same agent should freely switch
|
||||
Do not set `agentRuntime.id: "codex"` globally if the same agent should freely switch
|
||||
between Codex and non-Codex provider models. A forced runtime applies to every
|
||||
embedded turn for that agent or session. If you select an Anthropic model while
|
||||
that runtime is forced, OpenClaw still tries the Codex harness and fails closed
|
||||
@@ -242,8 +242,8 @@ instead of silently routing that turn through PI.
|
||||
|
||||
Use one of these shapes instead:
|
||||
|
||||
- Put Codex on a dedicated agent with `embeddedHarness.runtime: "codex"`.
|
||||
- Keep the default agent on `runtime: "auto"` and PI fallback for normal mixed
|
||||
- Put Codex on a dedicated agent with `agentRuntime.id: "codex"`.
|
||||
- Keep the default agent on `agentRuntime.id: "auto"` and PI fallback for normal mixed
|
||||
provider usage.
|
||||
- Use legacy `codex/*` refs only for compatibility. New configs should prefer
|
||||
`openai/*` plus an explicit Codex runtime policy.
|
||||
@@ -262,8 +262,8 @@ adds a separate Codex agent:
|
||||
},
|
||||
agents: {
|
||||
defaults: {
|
||||
embeddedHarness: {
|
||||
runtime: "auto",
|
||||
agentRuntime: {
|
||||
id: "auto",
|
||||
fallback: "pi",
|
||||
},
|
||||
},
|
||||
@@ -277,8 +277,8 @@ adds a separate Codex agent:
|
||||
id: "codex",
|
||||
name: "Codex",
|
||||
model: "openai/gpt-5.5",
|
||||
embeddedHarness: {
|
||||
runtime: "codex",
|
||||
agentRuntime: {
|
||||
id: "codex",
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -302,7 +302,7 @@ Agents should route user requests by intent, not by the word "Codex" alone:
|
||||
| "Bind this chat to Codex" | `/codex bind` |
|
||||
| "Resume Codex thread `<id>` here" | `/codex resume <id>` |
|
||||
| "Show Codex threads" | `/codex threads` |
|
||||
| "Use Codex as the runtime for this agent" | config change to `embeddedHarness.runtime` |
|
||||
| "Use Codex as the runtime for this agent" | config change to `agentRuntime.id` |
|
||||
| "Use my ChatGPT/Codex subscription with normal OpenClaw" | `openai-codex/*` model refs |
|
||||
| "Run Codex through ACP/acpx" | ACP `sessions_spawn({ runtime: "acp", ... })` |
|
||||
| "Start Claude Code/Gemini/OpenCode/Cursor in a thread" | ACP/acpx, not `/codex` and not native sub-agents |
|
||||
@@ -323,8 +323,8 @@ uses Codex. Explicit plugin runtimes default to no PI fallback, so
|
||||
agents: {
|
||||
defaults: {
|
||||
model: "openai/gpt-5.5",
|
||||
embeddedHarness: {
|
||||
runtime: "codex",
|
||||
agentRuntime: {
|
||||
id: "codex",
|
||||
fallback: "none",
|
||||
},
|
||||
},
|
||||
@@ -352,8 +352,8 @@ auto-selection:
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
embeddedHarness: {
|
||||
runtime: "auto",
|
||||
agentRuntime: {
|
||||
id: "auto",
|
||||
fallback: "pi",
|
||||
},
|
||||
},
|
||||
@@ -367,8 +367,8 @@ auto-selection:
|
||||
id: "codex",
|
||||
name: "Codex",
|
||||
model: "openai/gpt-5.5",
|
||||
embeddedHarness: {
|
||||
runtime: "codex",
|
||||
agentRuntime: {
|
||||
id: "codex",
|
||||
fallback: "none",
|
||||
},
|
||||
},
|
||||
@@ -565,8 +565,8 @@ Codex-only harness validation:
|
||||
agents: {
|
||||
defaults: {
|
||||
model: "openai/gpt-5.5",
|
||||
embeddedHarness: {
|
||||
runtime: "codex",
|
||||
agentRuntime: {
|
||||
id: "codex",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -772,15 +772,15 @@ understanding continue to use the matching provider/model settings such as
|
||||
|
||||
**Codex does not appear as a normal `/model` provider:** that is expected for
|
||||
new configs. Select an `openai/gpt-*` model with
|
||||
`embeddedHarness.runtime: "codex"` (or a legacy `codex/*` ref), enable
|
||||
`agentRuntime.id: "codex"` (or a legacy `codex/*` ref), enable
|
||||
`plugins.entries.codex.enabled`, and check whether `plugins.allow` excludes
|
||||
`codex`.
|
||||
|
||||
**OpenClaw uses PI instead of Codex:** `runtime: "auto"` can still use PI as the
|
||||
**OpenClaw uses PI instead of Codex:** `agentRuntime.id: "auto"` can still use PI as the
|
||||
compatibility backend when no Codex harness claims the run. Set
|
||||
`embeddedHarness.runtime: "codex"` to force Codex selection while testing. A
|
||||
`agentRuntime.id: "codex"` to force Codex selection while testing. A
|
||||
forced Codex runtime now fails instead of falling back to PI unless you
|
||||
explicitly set `embeddedHarness.fallback: "pi"`. Once Codex app-server is
|
||||
explicitly set `agentRuntime.fallback: "pi"`. Once Codex app-server is
|
||||
selected, its failures surface directly without extra fallback config.
|
||||
|
||||
**The app-server is rejected:** upgrade Codex so the app-server handshake
|
||||
@@ -795,9 +795,9 @@ or disable discovery.
|
||||
and that the remote app-server speaks the same Codex app-server protocol version.
|
||||
|
||||
**A non-Codex model uses PI:** that is expected unless you forced
|
||||
`embeddedHarness.runtime: "codex"` for that agent or selected a legacy
|
||||
`agentRuntime.id: "codex"` for that agent or selected a legacy
|
||||
`codex/*` ref. Plain `openai/gpt-*` and other provider refs stay on their normal
|
||||
provider path in `auto` mode. If you force `runtime: "codex"`, every embedded
|
||||
provider path in `auto` mode. If you force `agentRuntime.id: "codex"`, every embedded
|
||||
turn for that agent must be a Codex-supported OpenAI model.
|
||||
|
||||
## Related
|
||||
|
||||
@@ -82,8 +82,8 @@ Current compatibility records include:
|
||||
- bundled plugin allowlist and enablement behavior
|
||||
- legacy provider/channel env-var manifest metadata
|
||||
- activation hints that are being replaced by manifest contribution ownership
|
||||
- `embeddedHarness` and `agent-harness` naming aliases while public naming moves
|
||||
toward `agentRuntime`
|
||||
- legacy runtime-policy config keys while doctor migrates operators to
|
||||
`agentRuntime`
|
||||
- generated bundled channel config metadata fallback while registry-first
|
||||
`channelConfigs` metadata lands
|
||||
- the persisted plugin registry disable env while repair flows migrate operators
|
||||
|
||||
@@ -524,6 +524,12 @@ Non-bundled plugins that declare `channels[]` should also declare matching
|
||||
cold-path config schema, setup, and Control UI surfaces cannot know the
|
||||
channel-owned option shape until plugin runtime executes.
|
||||
|
||||
`channelConfigs.<channel-id>.commands.nativeCommandsAutoEnabled` and
|
||||
`nativeSkillsAutoEnabled` can declare static `auto` defaults for command config
|
||||
checks that run before channel runtime loads. Bundled channels can also publish
|
||||
the same defaults through `package.json#openclaw.channel.commands` alongside
|
||||
their other package-owned channel catalog metadata.
|
||||
|
||||
```json
|
||||
{
|
||||
"channelConfigs": {
|
||||
@@ -543,6 +549,10 @@ channel-owned option shape until plugin runtime executes.
|
||||
},
|
||||
"label": "Matrix",
|
||||
"description": "Matrix homeserver connection",
|
||||
"commands": {
|
||||
"nativeCommandsAutoEnabled": true,
|
||||
"nativeSkillsAutoEnabled": true
|
||||
},
|
||||
"preferOver": ["matrix-legacy"]
|
||||
}
|
||||
}
|
||||
@@ -557,6 +567,7 @@ Each channel entry can include:
|
||||
| `uiHints` | `Record<string, object>` | Optional UI labels/placeholders/sensitive hints for that channel config section. |
|
||||
| `label` | `string` | Channel label merged into picker and inspect surfaces when runtime metadata is not ready. |
|
||||
| `description` | `string` | Short channel description for inspect and catalog surfaces. |
|
||||
| `commands` | `object` | Static native command and native skill auto-defaults for pre-runtime config checks. |
|
||||
| `preferOver` | `string[]` | Legacy or lower-priority plugin ids this channel should outrank in selection surfaces. |
|
||||
|
||||
### Replacing another channel plugin
|
||||
@@ -792,6 +803,7 @@ Important examples:
|
||||
| `openclaw.setupEntry` | Lightweight setup-only entrypoint used during onboarding, deferred channel startup, and read-only channel status/SecretRef discovery. Must stay inside the plugin package directory. |
|
||||
| `openclaw.runtimeSetupEntry` | Declares the built JavaScript setup entrypoint for installed packages. Must stay inside the plugin package directory. |
|
||||
| `openclaw.channel` | Cheap channel catalog metadata like labels, docs paths, aliases, and selection copy. |
|
||||
| `openclaw.channel.commands` | Static native command and native skill auto-default metadata used by config, audit, and command-list surfaces before channel runtime loads. |
|
||||
| `openclaw.channel.configuredState` | Lightweight configured-state checker metadata that can answer "does env-only setup already exist?" without loading the full channel runtime. |
|
||||
| `openclaw.channel.persistedAuthState` | Lightweight persisted-auth checker metadata that can answer "is anything already signed in?" without loading the full channel runtime. |
|
||||
| `openclaw.install.npmSpec` / `openclaw.install.localPath` | Install/update hints for bundled and externally published plugins. |
|
||||
|
||||
@@ -142,7 +142,7 @@ OpenClaw. The harness then claims that provider in `supports(...)`.
|
||||
The bundled Codex plugin follows this pattern:
|
||||
|
||||
- preferred user model refs: `openai/gpt-5.5` plus
|
||||
`embeddedHarness.runtime: "codex"`
|
||||
`agentRuntime.id: "codex"`
|
||||
- compatibility refs: legacy `codex/gpt-*` refs remain accepted, but new
|
||||
configs should not use them as normal provider/model refs
|
||||
- harness id: `codex`
|
||||
@@ -153,7 +153,7 @@ The bundled Codex plugin follows this pattern:
|
||||
|
||||
The Codex plugin is additive. Plain `openai/gpt-*` refs continue to use the
|
||||
normal OpenClaw provider path unless you force the Codex harness with
|
||||
`embeddedHarness.runtime: "codex"`. Older `codex/gpt-*` refs still select the
|
||||
`agentRuntime.id: "codex"`. Older `codex/gpt-*` refs still select the
|
||||
Codex provider and harness for compatibility.
|
||||
|
||||
For operator setup, model prefix examples, and Codex-only configs, see
|
||||
@@ -194,14 +194,14 @@ intentional silent replies such as `NO_REPLY` unclassified.
|
||||
The bundled `codex` harness is the native Codex mode for embedded OpenClaw
|
||||
agent turns. Enable the bundled `codex` plugin first, and include `codex` in
|
||||
`plugins.allow` if your config uses a restrictive allowlist. Native app-server
|
||||
configs should use `openai/gpt-*` with `embeddedHarness.runtime: "codex"`.
|
||||
configs should use `openai/gpt-*` with `agentRuntime.id: "codex"`.
|
||||
Use `openai-codex/*` for Codex OAuth through PI instead. Legacy `codex/*`
|
||||
model refs remain compatibility aliases for the native harness.
|
||||
|
||||
When this mode runs, Codex owns the native thread id, resume behavior,
|
||||
compaction, and app-server execution. OpenClaw still owns the chat channel,
|
||||
visible transcript mirror, tool policy, approvals, media delivery, and session
|
||||
selection. Use `embeddedHarness.runtime: "codex"` without a `fallback` override
|
||||
selection. Use `agentRuntime.id: "codex"` without a `fallback` override
|
||||
when you need to prove that only the Codex app-server path can claim the run.
|
||||
Explicit plugin runtimes already fail closed by default. Set `fallback: "pi"`
|
||||
only when you intentionally want PI to handle missing harness selection. Codex
|
||||
@@ -209,8 +209,8 @@ app-server failures already fail directly instead of retrying through PI.
|
||||
|
||||
## Disable PI fallback
|
||||
|
||||
By default, OpenClaw runs embedded agents with `agents.defaults.embeddedHarness`
|
||||
set to `{ runtime: "auto", fallback: "pi" }`. In `auto` mode, registered plugin
|
||||
By default, OpenClaw runs embedded agents with `agents.defaults.agentRuntime`
|
||||
set to `{ id: "auto", fallback: "pi" }`. In `auto` mode, registered plugin
|
||||
harnesses can claim a provider/model pair. If none match, OpenClaw falls back
|
||||
to PI.
|
||||
|
||||
@@ -228,8 +228,8 @@ For Codex-only embedded runs:
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"model": "openai/gpt-5.5",
|
||||
"embeddedHarness": {
|
||||
"runtime": "codex"
|
||||
"agentRuntime": {
|
||||
"id": "codex"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -244,8 +244,8 @@ the fallback:
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"embeddedHarness": {
|
||||
"runtime": "auto",
|
||||
"agentRuntime": {
|
||||
"id": "auto",
|
||||
"fallback": "none"
|
||||
}
|
||||
}
|
||||
@@ -259,8 +259,8 @@ Per-agent overrides use the same shape:
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"embeddedHarness": {
|
||||
"runtime": "auto",
|
||||
"agentRuntime": {
|
||||
"id": "auto",
|
||||
"fallback": "pi"
|
||||
}
|
||||
},
|
||||
@@ -268,8 +268,8 @@ Per-agent overrides use the same shape:
|
||||
{
|
||||
"id": "codex-only",
|
||||
"model": "openai/gpt-5.5",
|
||||
"embeddedHarness": {
|
||||
"runtime": "codex",
|
||||
"agentRuntime": {
|
||||
"id": "codex",
|
||||
"fallback": "none"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,6 +97,25 @@ Anthropic's current public docs:
|
||||
Setup and runtime details for the Claude CLI backend are in [CLI Backends](/gateway/cli-backends).
|
||||
</Note>
|
||||
|
||||
### Config example
|
||||
|
||||
Prefer the canonical Anthropic model ref plus a CLI runtime override:
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "anthropic/claude-opus-4-7" },
|
||||
agentRuntime: { id: "claude-cli" },
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Legacy `claude-cli/claude-opus-4-7` model refs still work for
|
||||
compatibility, but new config should keep provider/model selection as
|
||||
`anthropic/*` and put the execution backend in `agentRuntime.id`.
|
||||
|
||||
<Tip>
|
||||
If you want the clearest billing path, use an Anthropic API key instead. OpenClaw also supports subscription-style options from [OpenAI Codex](/providers/openai), [Qwen Cloud](/providers/qwen), [MiniMax](/providers/minimax), and [Z.AI / GLM](/providers/glm).
|
||||
</Tip>
|
||||
|
||||
@@ -13,7 +13,7 @@ Gemini Grounding.
|
||||
- Provider: `google`
|
||||
- Auth: `GEMINI_API_KEY` or `GOOGLE_API_KEY`
|
||||
- API: Google Gemini API
|
||||
- Runtime option: `agents.defaults.embeddedHarness.runtime: "google-gemini-cli"`
|
||||
- Runtime option: `agents.defaults.agentRuntime.id: "google-gemini-cli"`
|
||||
reuses Gemini CLI OAuth while keeping model refs canonical as `google/*`.
|
||||
|
||||
## Getting started
|
||||
|
||||
@@ -17,7 +17,7 @@ embedded agent loop:
|
||||
|
||||
- **API key** — direct OpenAI Platform access with usage-based billing (`openai/*` models)
|
||||
- **Codex subscription through PI** — ChatGPT/Codex sign-in with subscription access (`openai-codex/*` models)
|
||||
- **Codex app-server harness** — native Codex app-server execution (`openai/*` models plus `agents.defaults.embeddedHarness.runtime: "codex"`)
|
||||
- **Codex app-server harness** — native Codex app-server execution (`openai/*` models plus `agents.defaults.agentRuntime.id: "codex"`)
|
||||
|
||||
OpenAI explicitly supports subscription OAuth usage in external tools and workflows like OpenClaw.
|
||||
|
||||
@@ -27,13 +27,13 @@ changing config.
|
||||
|
||||
## Quick choice
|
||||
|
||||
| Goal | Use | Notes |
|
||||
| --------------------------------------------- | -------------------------------------------------------- | ---------------------------------------------------------------------------- |
|
||||
| Direct API-key billing | `openai/gpt-5.5` | Set `OPENAI_API_KEY` or run OpenAI API-key onboarding. |
|
||||
| GPT-5.5 with ChatGPT/Codex subscription auth | `openai-codex/gpt-5.5` | Default PI route for Codex OAuth. Best first choice for subscription setups. |
|
||||
| GPT-5.5 with native Codex app-server behavior | `openai/gpt-5.5` plus `embeddedHarness.runtime: "codex"` | Forces the Codex app-server harness for that model ref. |
|
||||
| Image generation or editing | `openai/gpt-image-2` | Works with either `OPENAI_API_KEY` or OpenAI Codex OAuth. |
|
||||
| Transparent-background images | `openai/gpt-image-1.5` | Use `outputFormat=png` or `webp` and `openai.background=transparent`. |
|
||||
| Goal | Use | Notes |
|
||||
| --------------------------------------------- | ------------------------------------------------ | ---------------------------------------------------------------------------- |
|
||||
| Direct API-key billing | `openai/gpt-5.5` | Set `OPENAI_API_KEY` or run OpenAI API-key onboarding. |
|
||||
| GPT-5.5 with ChatGPT/Codex subscription auth | `openai-codex/gpt-5.5` | Default PI route for Codex OAuth. Best first choice for subscription setups. |
|
||||
| GPT-5.5 with native Codex app-server behavior | `openai/gpt-5.5` plus `agentRuntime.id: "codex"` | Forces the Codex app-server harness for that model ref. |
|
||||
| Image generation or editing | `openai/gpt-image-2` | Works with either `OPENAI_API_KEY` or OpenAI Codex OAuth. |
|
||||
| Transparent-background images | `openai/gpt-image-1.5` | Use `outputFormat=png` or `webp` and `openai.background=transparent`. |
|
||||
|
||||
## Naming map
|
||||
|
||||
@@ -44,7 +44,7 @@ The names are similar but not interchangeable:
|
||||
| `openai` | Provider prefix | Direct OpenAI Platform API route. |
|
||||
| `openai-codex` | Provider prefix | OpenAI Codex OAuth/subscription route through the normal OpenClaw PI runner. |
|
||||
| `codex` plugin | Plugin | Bundled OpenClaw plugin that provides native Codex app-server runtime and `/codex` chat controls. |
|
||||
| `embeddedHarness.runtime: codex` | Agent runtime | Force the native Codex app-server harness for embedded turns. |
|
||||
| `agentRuntime.id: codex` | Agent runtime | Force the native Codex app-server harness for embedded turns. |
|
||||
| `/codex ...` | Chat command set | Bind/control Codex app-server threads from a conversation. |
|
||||
| `runtime: "acp", agentId: "codex"` | ACP session route | Explicit fallback path that runs Codex through ACP/acpx. |
|
||||
|
||||
@@ -57,7 +57,7 @@ combination so you can confirm it is intentional; it does not rewrite it.
|
||||
GPT-5.5 is available through both direct OpenAI Platform API-key access and
|
||||
subscription/OAuth routes. Use `openai/gpt-5.5` for direct `OPENAI_API_KEY`
|
||||
traffic, `openai-codex/gpt-5.5` for Codex OAuth through PI, or
|
||||
`openai/gpt-5.5` with `embeddedHarness.runtime: "codex"` for the native Codex
|
||||
`openai/gpt-5.5` with `agentRuntime.id: "codex"` for the native Codex
|
||||
app-server harness.
|
||||
</Note>
|
||||
|
||||
@@ -65,7 +65,7 @@ app-server harness.
|
||||
Enabling the OpenAI plugin, or selecting an `openai-codex/*` model, does not
|
||||
enable the bundled Codex app-server plugin. OpenClaw enables that plugin only
|
||||
when you explicitly select the native Codex harness with
|
||||
`embeddedHarness.runtime: "codex"` or use a legacy `codex/*` model ref.
|
||||
`agentRuntime.id: "codex"` or use a legacy `codex/*` model ref.
|
||||
If the bundled `codex` plugin is enabled but `openai-codex/*` still resolves
|
||||
through PI, `openclaw doctor` warns and leaves the route unchanged.
|
||||
</Note>
|
||||
@@ -76,7 +76,7 @@ through PI, `openclaw doctor` warns and leaves the route unchanged.
|
||||
| ------------------------- | ---------------------------------------------------------- | ------------------------------------------------------ |
|
||||
| Chat / Responses | `openai/<model>` model provider | Yes |
|
||||
| Codex subscription models | `openai-codex/<model>` with `openai-codex` OAuth | Yes |
|
||||
| Codex app-server harness | `openai/<model>` with `embeddedHarness.runtime: codex` | Yes |
|
||||
| Codex app-server harness | `openai/<model>` with `agentRuntime.id: codex` | Yes |
|
||||
| Server-side web search | Native OpenAI Responses tool | Yes, when web search is enabled and no provider pinned |
|
||||
| Images | `image_generate` | Yes |
|
||||
| Videos | `video_generate` | Yes |
|
||||
@@ -120,15 +120,15 @@ Choose your preferred auth method and follow the setup steps.
|
||||
|
||||
| Model ref | Runtime config | Route | Auth |
|
||||
| ---------------------- | -------------------------- | --------------------------- | ---------------- |
|
||||
| `openai/gpt-5.5` | omitted / `runtime: "pi"` | Direct OpenAI Platform API | `OPENAI_API_KEY` |
|
||||
| `openai/gpt-5.4-mini` | omitted / `runtime: "pi"` | Direct OpenAI Platform API | `OPENAI_API_KEY` |
|
||||
| `openai/gpt-5.5` | `runtime: "codex"` | Codex app-server harness | Codex app-server |
|
||||
| `openai/gpt-5.5` | omitted / `agentRuntime.id: "pi"` | Direct OpenAI Platform API | `OPENAI_API_KEY` |
|
||||
| `openai/gpt-5.4-mini` | omitted / `agentRuntime.id: "pi"` | Direct OpenAI Platform API | `OPENAI_API_KEY` |
|
||||
| `openai/gpt-5.5` | `agentRuntime.id: "codex"` | Codex app-server harness | Codex app-server |
|
||||
|
||||
<Note>
|
||||
`openai/*` is the direct OpenAI API-key route unless you explicitly force
|
||||
the Codex app-server harness. Use `openai-codex/*` for Codex OAuth through
|
||||
the default PI runner, or use `openai/gpt-5.5` with
|
||||
`embeddedHarness.runtime: "codex"` for native Codex app-server execution.
|
||||
`agentRuntime.id: "codex"` for native Codex app-server execution.
|
||||
</Note>
|
||||
|
||||
### Config example
|
||||
@@ -185,7 +185,7 @@ Choose your preferred auth method and follow the setup steps.
|
||||
|-----------|----------------|-------|------|
|
||||
| `openai-codex/gpt-5.5` | omitted / `runtime: "pi"` | ChatGPT/Codex OAuth through PI | Codex sign-in |
|
||||
| `openai-codex/gpt-5.5` | `runtime: "auto"` | Still PI unless a plugin explicitly claims `openai-codex` | Codex sign-in |
|
||||
| `openai/gpt-5.5` | `embeddedHarness.runtime: "codex"` | Codex app-server harness | Codex app-server auth |
|
||||
| `openai/gpt-5.5` | `agentRuntime.id: "codex"` | Codex app-server harness | Codex app-server auth |
|
||||
|
||||
<Note>
|
||||
Keep using the `openai-codex` provider id for auth/profile commands. The
|
||||
@@ -211,7 +211,7 @@ Choose your preferred auth method and follow the setup steps.
|
||||
The default PI harness appears as `Runtime: OpenClaw Pi Default`. When the
|
||||
bundled Codex app-server harness is selected, `/status` shows
|
||||
`Runtime: OpenAI Codex`. Existing sessions keep their recorded harness id, so use
|
||||
`/new` or `/reset` after changing `embeddedHarness` if you want `/status` to
|
||||
`/new` or `/reset` after changing `agentRuntime` if you want `/status` to
|
||||
reflect a new PI/Codex choice.
|
||||
|
||||
### Doctor warning
|
||||
@@ -220,7 +220,7 @@ Choose your preferred auth method and follow the setup steps.
|
||||
`openai-codex/*` route is selected, `openclaw doctor` warns that the model
|
||||
still resolves through PI. Keep the config unchanged when that is the
|
||||
intended subscription-auth route. Switch to `openai/<model>` plus
|
||||
`embeddedHarness.runtime: "codex"` only when you want native Codex
|
||||
`agentRuntime.id: "codex"` only when you want native Codex
|
||||
app-server execution.
|
||||
|
||||
### Context window cap
|
||||
@@ -380,7 +380,7 @@ See [Video Generation](/tools/video-generation) for shared tool parameters, prov
|
||||
|
||||
OpenClaw adds a shared GPT-5 prompt contribution for GPT-5-family runs across providers. It applies by model id, so `openai-codex/gpt-5.5`, `openai/gpt-5.5`, `openrouter/openai/gpt-5.5`, `opencode/gpt-5.5`, and other compatible GPT-5 refs receive the same overlay. Older GPT-4.x models do not.
|
||||
|
||||
The bundled native Codex harness uses the same GPT-5 behavior and heartbeat overlay through Codex app-server developer instructions, so `openai/gpt-5.x` sessions forced through `embeddedHarness.runtime: "codex"` keep the same follow-through and proactive heartbeat guidance even though Codex owns the rest of the harness prompt.
|
||||
The bundled native Codex harness uses the same GPT-5 behavior and heartbeat overlay through Codex app-server developer instructions, so `openai/gpt-5.x` sessions forced through `agentRuntime.id: "codex"` keep the same follow-through and proactive heartbeat guidance even though Codex owns the rest of the harness prompt.
|
||||
|
||||
The GPT-5 contribution adds a tagged behavior contract for persona persistence, execution safety, tool discipline, output shape, completion checks, and verification. Channel-specific reply and silent-message behavior stays in the shared OpenClaw system prompt and outbound delivery policy. The GPT-5 guidance is always enabled for matching models. The friendly interaction-style layer is separate and configurable.
|
||||
|
||||
@@ -766,7 +766,7 @@ the Server-side compaction accordion below.
|
||||
- Injects `context_management: [{ type: "compaction", compact_threshold: ... }]`
|
||||
- Default `compact_threshold`: 70% of `contextWindow` (or `80000` when unavailable)
|
||||
|
||||
This applies to the built-in Pi harness path and to OpenAI provider hooks used by embedded runs. The native Codex app-server harness manages its own context through Codex and is configured separately with `agents.defaults.embeddedHarness.runtime`.
|
||||
This applies to the built-in Pi harness path and to OpenAI provider hooks used by embedded runs. The native Codex app-server harness manages its own context through Codex and is configured separately with `agents.defaults.agentRuntime.id`.
|
||||
|
||||
<Tabs>
|
||||
<Tab title="Enable explicitly">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
---
|
||||
summary: "All configuration knobs for memory search, embedding providers, QMD, hybrid search, and multimodal indexing"
|
||||
title: "Memory configuration reference"
|
||||
sidebarTitle: "Memory config"
|
||||
read_when:
|
||||
- You want to configure memory search providers or embedding models
|
||||
- You want to set up the QMD backend
|
||||
@@ -8,28 +9,38 @@ read_when:
|
||||
- You want to enable multimodal memory indexing
|
||||
---
|
||||
|
||||
This page lists every configuration knob for OpenClaw memory search. For
|
||||
conceptual overviews, see:
|
||||
This page lists every configuration knob for OpenClaw memory search. For conceptual overviews, see:
|
||||
|
||||
- [Memory Overview](/concepts/memory) -- how memory works
|
||||
- [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 sub-agent for interactive sessions
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Memory overview" href="/concepts/memory">
|
||||
How memory works.
|
||||
</Card>
|
||||
<Card title="Builtin engine" href="/concepts/memory-builtin">
|
||||
Default SQLite backend.
|
||||
</Card>
|
||||
<Card title="QMD engine" href="/concepts/memory-qmd">
|
||||
Local-first sidecar.
|
||||
</Card>
|
||||
<Card title="Memory search" href="/concepts/memory-search">
|
||||
Search pipeline and tuning.
|
||||
</Card>
|
||||
<Card title="Active memory" href="/concepts/active-memory">
|
||||
Memory sub-agent for interactive sessions.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
All memory search settings live under `agents.defaults.memorySearch` in
|
||||
`openclaw.json` unless noted otherwise.
|
||||
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 sub-agent config,
|
||||
that lives under `plugins.entries.active-memory` instead of `memorySearch`.
|
||||
<Note>
|
||||
If you are looking for the **active memory** feature toggle and sub-agent 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.
|
||||
See [Active Memory](/concepts/active-memory) for the activation model, plugin-owned config, transcript persistence, and safe rollout pattern.
|
||||
</Note>
|
||||
|
||||
---
|
||||
|
||||
@@ -46,20 +57,35 @@ plugin-owned config, transcript persistence, and safe rollout pattern.
|
||||
|
||||
When `provider` is not set, OpenClaw selects the first available:
|
||||
|
||||
1. `local` -- if `memorySearch.local.modelPath` is configured and the file exists.
|
||||
2. `github-copilot` -- if a GitHub Copilot token can be resolved (env var or auth profile).
|
||||
3. `openai` -- if an OpenAI key can be resolved.
|
||||
4. `gemini` -- if a Gemini key can be resolved.
|
||||
5. `voyage` -- if a Voyage key can be resolved.
|
||||
6. `mistral` -- if a Mistral key can be resolved.
|
||||
7. `bedrock` -- if the AWS SDK credential chain resolves (instance role, access keys, profile, SSO, web identity, or shared config).
|
||||
<Steps>
|
||||
<Step title="local">
|
||||
Selected if `memorySearch.local.modelPath` is configured and the file exists.
|
||||
</Step>
|
||||
<Step title="github-copilot">
|
||||
Selected if a GitHub Copilot token can be resolved (env var or auth profile).
|
||||
</Step>
|
||||
<Step title="openai">
|
||||
Selected if an OpenAI key can be resolved.
|
||||
</Step>
|
||||
<Step title="gemini">
|
||||
Selected if a Gemini key can be resolved.
|
||||
</Step>
|
||||
<Step title="voyage">
|
||||
Selected if a Voyage key can be resolved.
|
||||
</Step>
|
||||
<Step title="mistral">
|
||||
Selected if a Mistral key can be resolved.
|
||||
</Step>
|
||||
<Step title="bedrock">
|
||||
Selected if the AWS SDK credential chain resolves (instance role, access keys, profile, SSO, web identity, or shared config).
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
`ollama` is supported but not auto-detected (set it explicitly).
|
||||
|
||||
### API key resolution
|
||||
|
||||
Remote embeddings require an API key. Bedrock uses the AWS SDK default
|
||||
credential chain instead (instance roles, SSO, access keys).
|
||||
Remote embeddings require an API key. Bedrock uses the AWS SDK default credential chain instead (instance roles, SSO, access keys).
|
||||
|
||||
| Provider | Env var | Config key |
|
||||
| -------------- | -------------------------------------------------- | --------------------------------- |
|
||||
@@ -71,8 +97,9 @@ credential chain instead (instance roles, SSO, access keys).
|
||||
| OpenAI | `OPENAI_API_KEY` | `models.providers.openai.apiKey` |
|
||||
| Voyage | `VOYAGE_API_KEY` | `models.providers.voyage.apiKey` |
|
||||
|
||||
Codex OAuth covers chat/completions only and does not satisfy embedding
|
||||
requests.
|
||||
<Note>
|
||||
Codex OAuth covers chat/completions only and does not satisfy embedding requests.
|
||||
</Note>
|
||||
|
||||
---
|
||||
|
||||
@@ -80,11 +107,15 @@ requests.
|
||||
|
||||
For custom OpenAI-compatible endpoints or overriding provider defaults:
|
||||
|
||||
| Key | Type | Description |
|
||||
| ---------------- | -------- | -------------------------------------------------- |
|
||||
| `remote.baseUrl` | `string` | Custom API base URL |
|
||||
| `remote.apiKey` | `string` | Override API key |
|
||||
| `remote.headers` | `object` | Extra HTTP headers (merged with provider defaults) |
|
||||
<ParamField path="remote.baseUrl" type="string">
|
||||
Custom API base URL.
|
||||
</ParamField>
|
||||
<ParamField path="remote.apiKey" type="string">
|
||||
Override API key.
|
||||
</ParamField>
|
||||
<ParamField path="remote.headers" type="object">
|
||||
Extra HTTP headers (merged with provider defaults).
|
||||
</ParamField>
|
||||
|
||||
```json5
|
||||
{
|
||||
@@ -105,130 +136,113 @@ For custom OpenAI-compatible endpoints or overriding provider defaults:
|
||||
|
||||
---
|
||||
|
||||
## Gemini-specific config
|
||||
## Provider-specific config
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
| ---------------------- | -------- | ---------------------- | ------------------------------------------ |
|
||||
| `model` | `string` | `gemini-embedding-001` | Also supports `gemini-embedding-2-preview` |
|
||||
| `outputDimensionality` | `number` | `3072` | For Embedding 2: 768, 1536, or 3072 |
|
||||
<AccordionGroup>
|
||||
<Accordion title="Gemini">
|
||||
| Key | Type | Default | Description |
|
||||
| ---------------------- | -------- | ---------------------- | ------------------------------------------ |
|
||||
| `model` | `string` | `gemini-embedding-001` | Also supports `gemini-embedding-2-preview` |
|
||||
| `outputDimensionality` | `number` | `3072` | For Embedding 2: 768, 1536, or 3072 |
|
||||
|
||||
<Warning>
|
||||
Changing model or `outputDimensionality` triggers an automatic full reindex.
|
||||
</Warning>
|
||||
<Warning>
|
||||
Changing model or `outputDimensionality` triggers an automatic full reindex.
|
||||
</Warning>
|
||||
|
||||
---
|
||||
</Accordion>
|
||||
<Accordion title="Bedrock">
|
||||
Bedrock uses the AWS SDK default credential chain — no API keys needed. If OpenClaw runs on EC2 with a Bedrock-enabled instance role, just set the provider and model:
|
||||
|
||||
## Bedrock embedding config
|
||||
|
||||
Bedrock uses the AWS SDK default credential chain -- no API keys needed.
|
||||
If OpenClaw runs on EC2 with a Bedrock-enabled instance role, just set the
|
||||
provider and model:
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
memorySearch: {
|
||||
provider: "bedrock",
|
||||
model: "amazon.titan-embed-text-v2:0",
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
memorySearch: {
|
||||
provider: "bedrock",
|
||||
model: "amazon.titan-embed-text-v2:0",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
}
|
||||
```
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
| ---------------------- | -------- | ------------------------------ | ------------------------------- |
|
||||
| `model` | `string` | `amazon.titan-embed-text-v2:0` | Any Bedrock embedding model ID |
|
||||
| `outputDimensionality` | `number` | model default | For Titan V2: 256, 512, or 1024 |
|
||||
| Key | Type | Default | Description |
|
||||
| ---------------------- | -------- | ------------------------------ | ------------------------------- |
|
||||
| `model` | `string` | `amazon.titan-embed-text-v2:0` | Any Bedrock embedding model ID |
|
||||
| `outputDimensionality` | `number` | model default | For Titan V2: 256, 512, or 1024 |
|
||||
|
||||
### Supported models
|
||||
**Supported models** (with family detection and dimension defaults):
|
||||
|
||||
The following models are supported (with family detection and dimension
|
||||
defaults):
|
||||
| Model ID | Provider | Default Dims | Configurable Dims |
|
||||
| ------------------------------------------ | ---------- | ------------ | -------------------- |
|
||||
| `amazon.titan-embed-text-v2:0` | Amazon | 1024 | 256, 512, 1024 |
|
||||
| `amazon.titan-embed-text-v1` | Amazon | 1536 | -- |
|
||||
| `amazon.titan-embed-g1-text-02` | Amazon | 1536 | -- |
|
||||
| `amazon.titan-embed-image-v1` | Amazon | 1024 | -- |
|
||||
| `amazon.nova-2-multimodal-embeddings-v1:0` | Amazon | 1024 | 256, 384, 1024, 3072 |
|
||||
| `cohere.embed-english-v3` | Cohere | 1024 | -- |
|
||||
| `cohere.embed-multilingual-v3` | Cohere | 1024 | -- |
|
||||
| `cohere.embed-v4:0` | Cohere | 1536 | 256-1536 |
|
||||
| `twelvelabs.marengo-embed-3-0-v1:0` | TwelveLabs | 512 | -- |
|
||||
| `twelvelabs.marengo-embed-2-7-v1:0` | TwelveLabs | 1024 | -- |
|
||||
|
||||
| Model ID | Provider | Default Dims | Configurable Dims |
|
||||
| ------------------------------------------ | ---------- | ------------ | -------------------- |
|
||||
| `amazon.titan-embed-text-v2:0` | Amazon | 1024 | 256, 512, 1024 |
|
||||
| `amazon.titan-embed-text-v1` | Amazon | 1536 | -- |
|
||||
| `amazon.titan-embed-g1-text-02` | Amazon | 1536 | -- |
|
||||
| `amazon.titan-embed-image-v1` | Amazon | 1024 | -- |
|
||||
| `amazon.nova-2-multimodal-embeddings-v1:0` | Amazon | 1024 | 256, 384, 1024, 3072 |
|
||||
| `cohere.embed-english-v3` | Cohere | 1024 | -- |
|
||||
| `cohere.embed-multilingual-v3` | Cohere | 1024 | -- |
|
||||
| `cohere.embed-v4:0` | Cohere | 1536 | 256-1536 |
|
||||
| `twelvelabs.marengo-embed-3-0-v1:0` | TwelveLabs | 512 | -- |
|
||||
| `twelvelabs.marengo-embed-2-7-v1:0` | TwelveLabs | 1024 | -- |
|
||||
Throughput-suffixed variants (e.g., `amazon.titan-embed-text-v1:2:8k`) inherit the base model's configuration.
|
||||
|
||||
Throughput-suffixed variants (e.g., `amazon.titan-embed-text-v1:2:8k`) inherit
|
||||
the base model's configuration.
|
||||
**Authentication:** Bedrock auth uses the standard AWS SDK credential resolution order:
|
||||
|
||||
### Authentication
|
||||
1. Environment variables (`AWS_ACCESS_KEY_ID` + `AWS_SECRET_ACCESS_KEY`)
|
||||
2. SSO token cache
|
||||
3. Web identity token credentials
|
||||
4. Shared credentials and config files
|
||||
5. ECS or EC2 metadata credentials
|
||||
|
||||
Bedrock auth uses the standard AWS SDK credential resolution order:
|
||||
Region is resolved from `AWS_REGION`, `AWS_DEFAULT_REGION`, the `amazon-bedrock` provider `baseUrl`, or defaults to `us-east-1`.
|
||||
|
||||
1. Environment variables (`AWS_ACCESS_KEY_ID` + `AWS_SECRET_ACCESS_KEY`)
|
||||
2. SSO token cache
|
||||
3. Web identity token credentials
|
||||
4. Shared credentials and config files
|
||||
5. ECS or EC2 metadata credentials
|
||||
**IAM permissions:** the IAM role or user needs:
|
||||
|
||||
Region is resolved from `AWS_REGION`, `AWS_DEFAULT_REGION`, the
|
||||
`amazon-bedrock` provider `baseUrl`, or defaults to `us-east-1`.
|
||||
```json
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": "bedrock:InvokeModel",
|
||||
"Resource": "*"
|
||||
}
|
||||
```
|
||||
|
||||
### IAM permissions
|
||||
For least-privilege, scope `InvokeModel` to the specific model:
|
||||
|
||||
The IAM role or user needs:
|
||||
```
|
||||
arn:aws:bedrock:*::foundation-model/amazon.titan-embed-text-v2:0
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": "bedrock:InvokeModel",
|
||||
"Resource": "*"
|
||||
}
|
||||
```
|
||||
</Accordion>
|
||||
<Accordion title="Local (GGUF + node-llama-cpp)">
|
||||
| Key | Type | Default | Description |
|
||||
| --------------------- | ------------------ | ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `local.modelPath` | `string` | auto-downloaded | Path to GGUF model file |
|
||||
| `local.modelCacheDir` | `string` | node-llama-cpp default | Cache dir for downloaded models |
|
||||
| `local.contextSize` | `number \| "auto"` | `4096` | Context window size for the embedding context. 4096 covers typical chunks (128–512 tokens) while bounding non-weight VRAM. Lower to 1024–2048 on constrained hosts. `"auto"` uses the model's trained maximum — not recommended for 8B+ models (Qwen3-Embedding-8B: 40 960 tokens → ~32 GB VRAM vs ~8.8 GB at 4096). |
|
||||
|
||||
For least-privilege, scope `InvokeModel` to the specific model:
|
||||
Default model: `embeddinggemma-300m-qat-Q8_0.gguf` (~0.6 GB, auto-downloaded). Requires native build: `pnpm approve-builds` then `pnpm rebuild node-llama-cpp`.
|
||||
|
||||
```
|
||||
arn:aws:bedrock:*::foundation-model/amazon.titan-embed-text-v2:0
|
||||
```
|
||||
Use the standalone CLI to verify the same provider path the Gateway uses:
|
||||
|
||||
---
|
||||
```bash
|
||||
openclaw memory status --deep --agent main
|
||||
openclaw memory index --force --agent main
|
||||
```
|
||||
|
||||
## Local embedding config
|
||||
If `provider` is `auto`, `local` is selected only when `local.modelPath` points to an existing local file. `hf:` and HTTP(S) model references can still be used explicitly with `provider: "local"`, but they do not make `auto` select local before the model is available on disk.
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
| --------------------- | ------------------ | ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `local.modelPath` | `string` | auto-downloaded | Path to GGUF model file |
|
||||
| `local.modelCacheDir` | `string` | node-llama-cpp default | Cache dir for downloaded models |
|
||||
| `local.contextSize` | `number \| "auto"` | `4096` | Context window size for the embedding context. 4096 covers typical chunks (128–512 tokens) while bounding non-weight VRAM. Lower to 1024–2048 on constrained hosts. `"auto"` uses the model's trained maximum — not recommended for 8B+ models (Qwen3-Embedding-8B: 40 960 tokens → ~32 GB VRAM vs ~8.8 GB at 4096). |
|
||||
|
||||
Default model: `embeddinggemma-300m-qat-Q8_0.gguf` (~0.6 GB, auto-downloaded).
|
||||
Requires native build: `pnpm approve-builds` then `pnpm rebuild node-llama-cpp`.
|
||||
|
||||
Use the standalone CLI to verify the same provider path the Gateway uses:
|
||||
|
||||
```bash
|
||||
openclaw memory status --deep --agent main
|
||||
openclaw memory index --force --agent main
|
||||
```
|
||||
|
||||
If `provider` is `auto`, `local` is selected only when `local.modelPath` points
|
||||
to an existing local file. `hf:` and HTTP(S) model references can still be used
|
||||
explicitly with `provider: "local"`, but they do not make `auto` select local
|
||||
before the model is available on disk.
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
### Inline embedding timeout
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
| ----------------------------------- | -------- | ---------------- | ------------------------------------------------------------------------ |
|
||||
| `sync.embeddingBatchTimeoutSeconds` | `number` | provider default | Override the timeout for inline embedding batches during memory indexing |
|
||||
<ParamField path="sync.embeddingBatchTimeoutSeconds" type="number">
|
||||
Override the timeout for inline embedding batches during memory indexing.
|
||||
|
||||
Unset uses the provider default: 600 seconds for local/self-hosted providers
|
||||
such as `local`, `ollama`, and `lmstudio`, and 120 seconds for hosted providers.
|
||||
|
||||
Increase this when local CPU-bound embedding batches are healthy but slow.
|
||||
Unset uses the provider default: 600 seconds for local/self-hosted providers such as `local`, `ollama`, and `lmstudio`, and 120 seconds for hosted providers. Increase this when local CPU-bound embedding batches are healthy but slow.
|
||||
</ParamField>
|
||||
|
||||
---
|
||||
|
||||
@@ -243,21 +257,23 @@ All under `memorySearch.query.hybrid`:
|
||||
| `textWeight` | `number` | `0.3` | Weight for BM25 scores (0-1) |
|
||||
| `candidateMultiplier` | `number` | `4` | Candidate pool size multiplier |
|
||||
|
||||
### MMR (diversity)
|
||||
<Tabs>
|
||||
<Tab title="MMR (diversity)">
|
||||
| Key | Type | Default | Description |
|
||||
| ------------- | --------- | ------- | ------------------------------------ |
|
||||
| `mmr.enabled` | `boolean` | `false` | Enable MMR re-ranking |
|
||||
| `mmr.lambda` | `number` | `0.7` | 0 = max diversity, 1 = max relevance |
|
||||
</Tab>
|
||||
<Tab title="Temporal decay (recency)">
|
||||
| Key | Type | Default | Description |
|
||||
| ---------------------------- | --------- | ------- | ------------------------- |
|
||||
| `temporalDecay.enabled` | `boolean` | `false` | Enable recency boost |
|
||||
| `temporalDecay.halfLifeDays` | `number` | `30` | Score halves every N days |
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
| ------------- | --------- | ------- | ------------------------------------ |
|
||||
| `mmr.enabled` | `boolean` | `false` | Enable MMR re-ranking |
|
||||
| `mmr.lambda` | `number` | `0.7` | 0 = max diversity, 1 = max relevance |
|
||||
Evergreen files (`MEMORY.md`, non-dated files in `memory/`) are never decayed.
|
||||
|
||||
### Temporal decay (recency)
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
| ---------------------------- | --------- | ------- | ------------------------- |
|
||||
| `temporalDecay.enabled` | `boolean` | `false` | Enable recency boost |
|
||||
| `temporalDecay.halfLifeDays` | `number` | `30` | Score halves every N days |
|
||||
|
||||
Evergreen files (`MEMORY.md`, non-dated files in `memory/`) are never decayed.
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
### Full example
|
||||
|
||||
@@ -300,19 +316,9 @@ Evergreen files (`MEMORY.md`, non-dated files in `memory/`) are never decayed.
|
||||
}
|
||||
```
|
||||
|
||||
Paths can be absolute or workspace-relative. Directories are scanned
|
||||
recursively for `.md` files. Symlink handling depends on the active backend:
|
||||
the builtin engine ignores symlinks, while QMD follows the underlying QMD
|
||||
scanner behavior.
|
||||
Paths can be absolute or workspace-relative. Directories are scanned recursively for `.md` files. Symlink handling depends on the active backend: the builtin engine ignores symlinks, while QMD follows the underlying QMD scanner behavior.
|
||||
|
||||
For agent-scoped cross-agent transcript search, use
|
||||
`agents.list[].memorySearch.qmd.extraCollections` instead of `memory.qmd.paths`.
|
||||
Those extra collections follow the same `{ path, name, pattern? }` shape, but
|
||||
they are merged per agent and can preserve explicit shared names when the path
|
||||
points outside the current workspace.
|
||||
If the same resolved path appears in both `memory.qmd.paths` and
|
||||
`memorySearch.qmd.extraCollections`, QMD keeps the first entry and skips the
|
||||
duplicate.
|
||||
For agent-scoped cross-agent transcript search, use `agents.list[].memorySearch.qmd.extraCollections` instead of `memory.qmd.paths`. Those extra collections follow the same `{ path, name, pattern? }` shape, but they are merged per agent and can preserve explicit shared names when the path points outside the current workspace. If the same resolved path appears in both `memory.qmd.paths` and `memorySearch.qmd.extraCollections`, QMD keeps the first entry and skips the duplicate.
|
||||
|
||||
---
|
||||
|
||||
@@ -326,11 +332,11 @@ Index images and audio alongside Markdown using Gemini Embedding 2:
|
||||
| `multimodal.modalities` | `string[]` | -- | `["image"]`, `["audio"]`, or `["all"]` |
|
||||
| `multimodal.maxFileBytes` | `number` | `10000000` | Max file size for indexing |
|
||||
|
||||
Only applies to files in `extraPaths`. Default memory roots stay Markdown-only.
|
||||
Requires `gemini-embedding-2-preview`. `fallback` must be `"none"`.
|
||||
<Note>
|
||||
Only applies to files in `extraPaths`. Default memory roots stay Markdown-only. Requires `gemini-embedding-2-preview`. `fallback` must be `"none"`.
|
||||
</Note>
|
||||
|
||||
Supported formats: `.jpg`, `.jpeg`, `.png`, `.webp`, `.gif`, `.heic`, `.heif`
|
||||
(images); `.mp3`, `.wav`, `.ogg`, `.opus`, `.m4a`, `.aac`, `.flac` (audio).
|
||||
Supported formats: `.jpg`, `.jpeg`, `.png`, `.webp`, `.gif`, `.heic`, `.heif` (images); `.mp3`, `.wav`, `.ogg`, `.opus`, `.m4a`, `.aac`, `.flac` (audio).
|
||||
|
||||
---
|
||||
|
||||
@@ -355,12 +361,9 @@ Prevents re-embedding unchanged text during reindex or transcript updates.
|
||||
| `remote.batch.pollIntervalMs` | `number` | -- | Poll interval |
|
||||
| `remote.batch.timeoutMinutes` | `number` | -- | Batch timeout |
|
||||
|
||||
Available for `openai`, `gemini`, and `voyage`. OpenAI batch is typically
|
||||
fastest and cheapest for large backfills.
|
||||
Available for `openai`, `gemini`, and `voyage`. OpenAI batch is typically fastest and cheapest for large backfills.
|
||||
|
||||
This is separate from `sync.embeddingBatchTimeoutSeconds`, which controls inline
|
||||
embedding calls used by local/self-hosted providers and hosted providers when
|
||||
provider batch APIs are not active.
|
||||
This is separate from `sync.embeddingBatchTimeoutSeconds`, which controls inline embedding calls used by local/self-hosted providers and hosted providers when provider batch APIs are not active.
|
||||
|
||||
---
|
||||
|
||||
@@ -375,9 +378,9 @@ Index session transcripts and surface them via `memory_search`:
|
||||
| `sync.sessions.deltaBytes` | `number` | `100000` | Byte threshold for reindex |
|
||||
| `sync.sessions.deltaMessages` | `number` | `50` | Message threshold for reindex |
|
||||
|
||||
Session indexing is opt-in and runs asynchronously. Results can be slightly
|
||||
stale. Session logs live on disk, so treat filesystem access as the trust
|
||||
boundary.
|
||||
<Warning>
|
||||
Session indexing is opt-in and runs asynchronously. Results can be slightly stale. Session logs live on disk, so treat filesystem access as the trust boundary.
|
||||
</Warning>
|
||||
|
||||
---
|
||||
|
||||
@@ -388,8 +391,7 @@ boundary.
|
||||
| `store.vector.enabled` | `boolean` | `true` | Use sqlite-vec for vector queries |
|
||||
| `store.vector.extensionPath` | `string` | bundled | Override sqlite-vec path |
|
||||
|
||||
When sqlite-vec is unavailable, OpenClaw falls back to in-process cosine
|
||||
similarity automatically.
|
||||
When sqlite-vec is unavailable, OpenClaw falls back to in-process cosine similarity automatically.
|
||||
|
||||
---
|
||||
|
||||
@@ -404,8 +406,7 @@ similarity automatically.
|
||||
|
||||
## QMD backend config
|
||||
|
||||
Set `memory.backend = "qmd"` to enable. All QMD settings live under
|
||||
`memory.qmd`:
|
||||
Set `memory.backend = "qmd"` to enable. All QMD settings live under `memory.qmd`:
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
| ------------------------ | --------- | -------- | -------------------------------------------- |
|
||||
@@ -417,70 +418,65 @@ Set `memory.backend = "qmd"` to enable. All QMD settings live under
|
||||
| `sessions.retentionDays` | `number` | -- | Transcript retention |
|
||||
| `sessions.exportDir` | `string` | -- | Export directory |
|
||||
|
||||
OpenClaw prefers the current QMD collection and MCP query shapes, but keeps
|
||||
older QMD releases working by falling back to legacy `--mask` collection flags
|
||||
and older MCP tool names when needed.
|
||||
OpenClaw prefers the current QMD collection and MCP query shapes, but keeps older QMD releases working by falling back to legacy `--mask` collection flags and older MCP tool names when needed.
|
||||
|
||||
QMD model overrides stay on the QMD side, not OpenClaw config. If you need to
|
||||
override QMD's models globally, set environment variables such as
|
||||
`QMD_EMBED_MODEL`, `QMD_RERANK_MODEL`, and `QMD_GENERATE_MODEL` in the gateway
|
||||
runtime environment.
|
||||
<Note>
|
||||
QMD model overrides stay on the QMD side, not OpenClaw config. If you need to override QMD's models globally, set environment variables such as `QMD_EMBED_MODEL`, `QMD_RERANK_MODEL`, and `QMD_GENERATE_MODEL` in the gateway runtime environment.
|
||||
</Note>
|
||||
|
||||
### Update schedule
|
||||
<AccordionGroup>
|
||||
<Accordion title="Update schedule">
|
||||
| Key | Type | Default | Description |
|
||||
| ------------------------- | --------- | ------- | ------------------------------------- |
|
||||
| `update.interval` | `string` | `5m` | Refresh interval |
|
||||
| `update.debounceMs` | `number` | `15000` | Debounce file changes |
|
||||
| `update.onBoot` | `boolean` | `true` | Refresh on startup |
|
||||
| `update.waitForBootSync` | `boolean` | `false` | Block startup until refresh completes |
|
||||
| `update.embedInterval` | `string` | -- | Separate embed cadence |
|
||||
| `update.commandTimeoutMs` | `number` | -- | Timeout for QMD commands |
|
||||
| `update.updateTimeoutMs` | `number` | -- | Timeout for QMD update operations |
|
||||
| `update.embedTimeoutMs` | `number` | -- | Timeout for QMD embed operations |
|
||||
</Accordion>
|
||||
<Accordion title="Limits">
|
||||
| Key | Type | Default | Description |
|
||||
| ------------------------- | -------- | ------- | -------------------------- |
|
||||
| `limits.maxResults` | `number` | `6` | Max search results |
|
||||
| `limits.maxSnippetChars` | `number` | -- | Clamp snippet length |
|
||||
| `limits.maxInjectedChars` | `number` | -- | Clamp total injected chars |
|
||||
| `limits.timeoutMs` | `number` | `4000` | Search timeout |
|
||||
</Accordion>
|
||||
<Accordion title="Scope">
|
||||
Controls which sessions can receive QMD search results. Same schema as [`session.sendPolicy`](/gateway/config-agents#session):
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
| ------------------------- | --------- | ------- | ------------------------------------- |
|
||||
| `update.interval` | `string` | `5m` | Refresh interval |
|
||||
| `update.debounceMs` | `number` | `15000` | Debounce file changes |
|
||||
| `update.onBoot` | `boolean` | `true` | Refresh on startup |
|
||||
| `update.waitForBootSync` | `boolean` | `false` | Block startup until refresh completes |
|
||||
| `update.embedInterval` | `string` | -- | Separate embed cadence |
|
||||
| `update.commandTimeoutMs` | `number` | -- | Timeout for QMD commands |
|
||||
| `update.updateTimeoutMs` | `number` | -- | Timeout for QMD update operations |
|
||||
| `update.embedTimeoutMs` | `number` | -- | Timeout for QMD embed operations |
|
||||
|
||||
### Limits
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
| ------------------------- | -------- | ------- | -------------------------- |
|
||||
| `limits.maxResults` | `number` | `6` | Max search results |
|
||||
| `limits.maxSnippetChars` | `number` | -- | Clamp snippet length |
|
||||
| `limits.maxInjectedChars` | `number` | -- | Clamp total injected chars |
|
||||
| `limits.timeoutMs` | `number` | `4000` | Search timeout |
|
||||
|
||||
### Scope
|
||||
|
||||
Controls which sessions can receive QMD search results. Same schema as
|
||||
[`session.sendPolicy`](/gateway/config-agents#session):
|
||||
|
||||
```json5
|
||||
{
|
||||
memory: {
|
||||
qmd: {
|
||||
scope: {
|
||||
default: "deny",
|
||||
rules: [{ action: "allow", match: { chatType: "direct" } }],
|
||||
```json5
|
||||
{
|
||||
memory: {
|
||||
qmd: {
|
||||
scope: {
|
||||
default: "deny",
|
||||
rules: [{ action: "allow", match: { chatType: "direct" } }],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
}
|
||||
```
|
||||
|
||||
The shipped default allows direct and channel sessions, while still denying
|
||||
groups.
|
||||
The shipped default allows direct and channel sessions, while still denying groups.
|
||||
|
||||
Default is DM-only. `match.keyPrefix` matches the normalized session key;
|
||||
`match.rawKeyPrefix` matches the raw key including `agent:<id>:`.
|
||||
Default is DM-only. `match.keyPrefix` matches the normalized session key; `match.rawKeyPrefix` matches the raw key including `agent:<id>:`.
|
||||
|
||||
### Citations
|
||||
</Accordion>
|
||||
<Accordion title="Citations">
|
||||
`memory.citations` applies to all backends:
|
||||
|
||||
`memory.citations` applies to all backends:
|
||||
| Value | Behavior |
|
||||
| ---------------- | --------------------------------------------------- |
|
||||
| `auto` (default) | Include `Source: <path#line>` footer in snippets |
|
||||
| `on` | Always include footer |
|
||||
| `off` | Omit footer (path still passed to agent internally) |
|
||||
|
||||
| Value | Behavior |
|
||||
| ---------------- | --------------------------------------------------- |
|
||||
| `auto` (default) | Include `Source: <path#line>` footer in snippets |
|
||||
| `on` | Always include footer |
|
||||
| `off` | Omit footer (path still passed to agent internally) |
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
### Full QMD example
|
||||
|
||||
@@ -507,11 +503,9 @@ Default is DM-only. `match.keyPrefix` matches the normalized session key;
|
||||
|
||||
## Dreaming
|
||||
|
||||
Dreaming is configured under `plugins.entries.memory-core.config.dreaming`,
|
||||
not under `agents.defaults.memorySearch`.
|
||||
Dreaming is configured under `plugins.entries.memory-core.config.dreaming`, not under `agents.defaults.memorySearch`.
|
||||
|
||||
Dreaming runs as one scheduled sweep and uses internal light/deep/REM phases as
|
||||
an implementation detail.
|
||||
Dreaming runs as one scheduled sweep and uses internal light/deep/REM phases as an implementation detail.
|
||||
|
||||
For conceptual behavior and slash commands, see [Dreaming](/concepts/dreaming).
|
||||
|
||||
@@ -541,14 +535,14 @@ For conceptual behavior and slash commands, see [Dreaming](/concepts/dreaming).
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
<Note>
|
||||
- Dreaming writes machine state to `memory/.dreams/`.
|
||||
- Dreaming writes human-readable narrative output to `DREAMS.md` (or existing `dreams.md`).
|
||||
- The light/deep/REM phase policy and thresholds are internal behavior, not user-facing config.
|
||||
</Note>
|
||||
|
||||
## Related
|
||||
|
||||
- [Configuration reference](/gateway/configuration-reference)
|
||||
- [Memory overview](/concepts/memory)
|
||||
- [Memory search](/concepts/memory-search)
|
||||
- [Configuration reference](/gateway/configuration-reference)
|
||||
|
||||
@@ -20,7 +20,7 @@ Codex has two OpenClaw routes:
|
||||
|
||||
| Route | Config/command | Setup page |
|
||||
| -------------------------- | ------------------------------------------------------ | --------------------------------------- |
|
||||
| Native Codex app-server | `/codex ...`, `embeddedHarness.runtime: "codex"` | [Codex harness](/plugins/codex-harness) |
|
||||
| Native Codex app-server | `/codex ...`, `agentRuntime.id: "codex"` | [Codex harness](/plugins/codex-harness) |
|
||||
| Explicit Codex ACP adapter | `/acp spawn codex`, `runtime: "acp", agentId: "codex"` | This page |
|
||||
|
||||
Prefer the native route unless you explicitly need ACP/acpx behavior.
|
||||
|
||||
@@ -20,7 +20,7 @@ Each ACP session spawn is tracked as a [background task](/automation/tasks).
|
||||
<Note>
|
||||
**ACP is the external-harness path, not the default Codex path.** The
|
||||
native Codex app-server plugin owns `/codex ...` controls and the
|
||||
`embeddedHarness.runtime: "codex"` embedded runtime; ACP owns
|
||||
`agentRuntime.id: "codex"` embedded runtime; ACP owns
|
||||
`/acp ...` controls and `sessions_spawn({ runtime: "acp" })` sessions.
|
||||
|
||||
If you want Codex or Claude Code to connect as an external MCP client
|
||||
@@ -172,7 +172,7 @@ Quick `/acp` flow from chat:
|
||||
</Accordion>
|
||||
<Accordion title="Model / provider / runtime selection cheat sheet">
|
||||
- `openai-codex/*` — PI Codex OAuth/subscription route.
|
||||
- `openai/*` plus `embeddedHarness.runtime: "codex"` — native Codex app-server embedded runtime.
|
||||
- `openai/*` plus `agentRuntime.id: "codex"` — native Codex app-server embedded runtime.
|
||||
- `/codex ...` — native Codex conversation control.
|
||||
- `/acp ...` or `runtime: "acp"` — explicit ACP/acpx control.
|
||||
</Accordion>
|
||||
|
||||
@@ -212,7 +212,7 @@ OpenClaw scans for plugins in this order (first match wins):
|
||||
runtime
|
||||
- OpenAI-family Codex routes keep separate plugin boundaries:
|
||||
`openai-codex/*` belongs to the OpenAI plugin, while the bundled Codex
|
||||
app-server plugin is selected by `embeddedHarness.runtime: "codex"` or legacy
|
||||
app-server plugin is selected by `agentRuntime.id: "codex"` or legacy
|
||||
`codex/*` model refs
|
||||
|
||||
## Troubleshooting runtime hooks
|
||||
|
||||
@@ -403,8 +403,41 @@ Precedence order for automatic replies, `/tts audio`, `/tts status`, and the
|
||||
|
||||
1. `messages.tts`
|
||||
2. active `agents.list[].tts`
|
||||
3. local `/tts` preferences for this host
|
||||
4. inline `[[tts:...]]` directives when [model overrides](#model-driven-directives) are enabled
|
||||
3. channel override, when the channel supports `channels.<channel>.tts`
|
||||
4. account override, when the channel passes `channels.<channel>.accounts.<id>.tts`
|
||||
5. local `/tts` preferences for this host
|
||||
6. inline `[[tts:...]]` directives when [model overrides](#model-driven-directives) are enabled
|
||||
|
||||
Channel and account overrides use the same shape as `messages.tts` and
|
||||
deep-merge over the earlier layers, so shared provider credentials can stay in
|
||||
`messages.tts` while a channel or bot account changes only voice, model, persona,
|
||||
or auto mode:
|
||||
|
||||
```json5
|
||||
{
|
||||
messages: {
|
||||
tts: {
|
||||
provider: "openai",
|
||||
providers: {
|
||||
openai: { apiKey: "${OPENAI_API_KEY}", model: "gpt-4o-mini-tts" },
|
||||
},
|
||||
},
|
||||
},
|
||||
channels: {
|
||||
feishu: {
|
||||
accounts: {
|
||||
english: {
|
||||
tts: {
|
||||
providers: {
|
||||
openai: { voice: "shimmer" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## Personas
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ read_when:
|
||||
- You want to operate the Gateway from a browser
|
||||
- You want Tailnet access without SSH tunnels
|
||||
title: "Control UI"
|
||||
sidebarTitle: "Control UI"
|
||||
---
|
||||
|
||||
The Control UI is a small **Vite + Lit** single-page app served by the Gateway:
|
||||
@@ -28,80 +29,52 @@ Auth is supplied during the WebSocket handshake via:
|
||||
- Tailscale Serve identity headers when `gateway.auth.allowTailscale: true`
|
||||
- trusted-proxy identity headers when `gateway.auth.mode: "trusted-proxy"`
|
||||
|
||||
The dashboard settings panel keeps a token for the current browser tab session
|
||||
and selected gateway URL; passwords are not persisted. Onboarding usually
|
||||
generates a gateway token for shared-secret auth on first connect, but password
|
||||
auth works too when `gateway.auth.mode` is `"password"`.
|
||||
The dashboard settings panel keeps a token for the current browser tab session and selected gateway URL; passwords are not persisted. Onboarding usually generates a gateway token for shared-secret auth on first connect, but password auth works too when `gateway.auth.mode` is `"password"`.
|
||||
|
||||
## Device pairing (first connection)
|
||||
|
||||
When you connect to the Control UI from a new browser or device, the Gateway
|
||||
requires a **one-time pairing approval** — even if you're on the same Tailnet
|
||||
with `gateway.auth.allowTailscale: true`. This is a security measure to prevent
|
||||
unauthorized access.
|
||||
When you connect to the Control UI from a new browser or device, the Gateway requires a **one-time pairing approval** — even if you're on the same Tailnet with `gateway.auth.allowTailscale: true`. This is a security measure to prevent unauthorized access.
|
||||
|
||||
**What you'll see:** "disconnected (1008): pairing required"
|
||||
|
||||
**To approve the device:**
|
||||
<Steps>
|
||||
<Step title="List pending requests">
|
||||
```bash
|
||||
openclaw devices list
|
||||
```
|
||||
</Step>
|
||||
<Step title="Approve by request ID">
|
||||
```bash
|
||||
openclaw devices approve <requestId>
|
||||
```
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
```bash
|
||||
# List pending requests
|
||||
openclaw devices list
|
||||
If the browser retries pairing with changed auth details (role/scopes/public key), the previous pending request is superseded and a new `requestId` is created. Re-run `openclaw devices list` before approval.
|
||||
|
||||
# Approve by request ID
|
||||
openclaw devices approve <requestId>
|
||||
```
|
||||
If the browser is already paired and you change it from read access to write/admin access, this is treated as an approval upgrade, not a silent reconnect. OpenClaw keeps the old approval active, blocks the broader reconnect, and asks you to approve the new scope set explicitly.
|
||||
|
||||
If the browser retries pairing with changed auth details (role/scopes/public
|
||||
key), the previous pending request is superseded and a new `requestId` is
|
||||
created. Re-run `openclaw devices list` before approval.
|
||||
Once approved, the device is remembered and won't require re-approval unless you revoke it with `openclaw devices revoke --device <id> --role <role>`. See [Devices CLI](/cli/devices) for token rotation and revocation.
|
||||
|
||||
If the browser is already paired and you change it from read access to
|
||||
write/admin access, this is treated as an approval upgrade, not a silent
|
||||
reconnect. OpenClaw keeps the old approval active, blocks the broader reconnect,
|
||||
and asks you to approve the new scope set explicitly.
|
||||
|
||||
Once approved, the device is remembered and won't require re-approval unless
|
||||
you revoke it with `openclaw devices revoke --device <id> --role <role>`. See
|
||||
[Devices CLI](/cli/devices) for token rotation and revocation.
|
||||
|
||||
**Notes:**
|
||||
|
||||
- Direct local loopback browser connections (`127.0.0.1` / `localhost`) are
|
||||
auto-approved.
|
||||
- Tailnet and LAN browser connects still require explicit approval, even when
|
||||
they originate from the same machine.
|
||||
- Each browser profile generates a unique device ID, so switching browsers or
|
||||
clearing browser data will require re-pairing.
|
||||
<Note>
|
||||
- Direct local loopback browser connections (`127.0.0.1` / `localhost`) are auto-approved.
|
||||
- Tailnet and LAN browser connects still require explicit approval, even when they originate from the same machine.
|
||||
- Each browser profile generates a unique device ID, so switching browsers or clearing browser data will require re-pairing.
|
||||
</Note>
|
||||
|
||||
## Personal identity (browser-local)
|
||||
|
||||
The Control UI supports a per-browser personal identity (display name and
|
||||
avatar) attached to outgoing messages for attribution in shared sessions. It
|
||||
lives in browser storage, is scoped to the current browser profile, and is not
|
||||
synced to other devices or persisted server-side beyond the normal transcript
|
||||
authorship metadata on messages you actually send. Clearing site data or
|
||||
switching browsers resets it to empty.
|
||||
The Control UI supports a per-browser personal identity (display name and avatar) attached to outgoing messages for attribution in shared sessions. It lives in browser storage, is scoped to the current browser profile, and is not synced to other devices or persisted server-side beyond the normal transcript authorship metadata on messages you actually send. Clearing site data or switching browsers resets it to empty.
|
||||
|
||||
The same browser-local pattern applies to the assistant avatar override.
|
||||
Uploaded assistant avatars overlay the gateway-resolved identity on the local
|
||||
browser only and never round-trip through `config.patch`. The shared
|
||||
`ui.assistant.avatar` config field is still available for non-UI clients
|
||||
writing the field directly (such as scripted gateways or custom dashboards).
|
||||
The same browser-local pattern applies to the assistant avatar override. Uploaded assistant avatars overlay the gateway-resolved identity on the local browser only and never round-trip through `config.patch`. The shared `ui.assistant.avatar` config field is still available for non-UI clients writing the field directly (such as scripted gateways or custom dashboards).
|
||||
|
||||
## Runtime config endpoint
|
||||
|
||||
The Control UI fetches its runtime settings from
|
||||
`/__openclaw/control-ui-config.json`. That endpoint is gated by the same
|
||||
gateway auth as the rest of the HTTP surface: unauthenticated browsers cannot
|
||||
fetch it, and a successful fetch requires either an already valid gateway
|
||||
token/password, Tailscale Serve identity, or a trusted-proxy identity.
|
||||
The Control UI fetches its runtime settings from `/__openclaw/control-ui-config.json`. That endpoint is gated by the same gateway auth as the rest of the HTTP surface: unauthenticated browsers cannot fetch it, and a successful fetch requires either an already valid gateway token/password, Tailscale Serve identity, or a trusted-proxy identity.
|
||||
|
||||
## Language support
|
||||
|
||||
The Control UI can localize itself on first load based on your browser locale.
|
||||
To override it later, open **Overview -> Gateway Access -> Language**. The
|
||||
locale picker lives in the Gateway Access card, not under Appearance.
|
||||
The Control UI can localize itself on first load based on your browser locale. To override it later, open **Overview -> Gateway Access -> Language**. The locale picker lives in the Gateway Access card, not under Appearance.
|
||||
|
||||
- Supported locales: `en`, `zh-CN`, `zh-TW`, `pt-BR`, `de`, `es`, `ja-JP`, `ko`, `fr`, `tr`, `uk`, `id`, `pl`, `th`
|
||||
- Non-English translations are lazy-loaded in the browser.
|
||||
@@ -110,95 +83,87 @@ locale picker lives in the Gateway Access card, not under Appearance.
|
||||
|
||||
## What it can do (today)
|
||||
|
||||
- Chat with the model via Gateway WS (`chat.history`, `chat.send`, `chat.abort`, `chat.inject`)
|
||||
- Talk to OpenAI Realtime directly from the browser via WebRTC. The Gateway
|
||||
mints a short-lived Realtime client secret with `talk.realtime.session`; the
|
||||
browser sends microphone audio directly to OpenAI and relays
|
||||
`openclaw_agent_consult` tool calls back through `chat.send` for the larger
|
||||
configured OpenClaw model.
|
||||
- Stream tool calls + live tool output cards in Chat (agent events)
|
||||
- Channels: built-in plus bundled/external plugin channels status, QR login, and per-channel config (`channels.status`, `web.login.*`, `config.patch`)
|
||||
- Instances: presence list + refresh (`system-presence`)
|
||||
- Sessions: list + per-session model/thinking/fast/verbose/trace/reasoning overrides (`sessions.list`, `sessions.patch`)
|
||||
- Dreams: dreaming status, enable/disable toggle, and Dream Diary reader (`doctor.memory.status`, `doctor.memory.dreamDiary`, `config.patch`)
|
||||
- Cron jobs: list/add/edit/run/enable/disable + run history (`cron.*`)
|
||||
- Skills: status, enable/disable, install, API key updates (`skills.*`)
|
||||
- Nodes: list + caps (`node.list`)
|
||||
- Exec approvals: edit gateway or node allowlists + ask policy for `exec host=gateway/node` (`exec.approvals.*`)
|
||||
- Config: view/edit `~/.openclaw/openclaw.json` (`config.get`, `config.set`)
|
||||
- Config: apply + restart with validation (`config.apply`) and wake the last active session
|
||||
- Config writes include a base-hash guard to prevent clobbering concurrent edits
|
||||
- Config writes (`config.set`/`config.apply`/`config.patch`) also preflight active SecretRef resolution for refs in the submitted config payload; unresolved active submitted refs are rejected before write
|
||||
- Config schema + form rendering (`config.schema` / `config.schema.lookup`,
|
||||
including field `title` / `description`, matched UI hints, immediate child
|
||||
summaries, docs metadata on nested object/wildcard/array/composition nodes,
|
||||
plus plugin + channel schemas when available); Raw JSON editor is
|
||||
available only when the snapshot has a safe raw round-trip
|
||||
- If a snapshot cannot safely round-trip raw text, Control UI forces Form mode and disables Raw mode for that snapshot
|
||||
- Raw JSON editor "Reset to saved" preserves the raw-authored shape (formatting, comments, `$include` layout) instead of re-rendering a flattened snapshot, so external edits survive a reset when the snapshot can safely round-trip
|
||||
- Structured SecretRef object values are rendered read-only in form text inputs to prevent accidental object-to-string corruption
|
||||
- Debug: status/health/models snapshots + event log + manual RPC calls (`status`, `health`, `models.list`)
|
||||
- Logs: live tail of gateway file logs with filter/export (`logs.tail`)
|
||||
- Update: run a package/git update + restart (`update.run`) with a restart report
|
||||
|
||||
Cron jobs panel notes:
|
||||
|
||||
- For isolated jobs, delivery defaults to announce summary. You can switch to none if you want internal-only runs.
|
||||
- Channel/target fields appear when announce is selected.
|
||||
- Webhook mode uses `delivery.mode = "webhook"` with `delivery.to` set to a valid HTTP(S) webhook URL.
|
||||
- For main-session jobs, webhook and none delivery modes are available.
|
||||
- Advanced edit controls include delete-after-run, clear agent override, cron exact/stagger options,
|
||||
agent model/thinking overrides, and best-effort delivery toggles.
|
||||
- Form validation is inline with field-level errors; invalid values disable the save button until fixed.
|
||||
- Set `cron.webhookToken` to send a dedicated bearer token, if omitted the webhook is sent without an auth header.
|
||||
- Deprecated fallback: stored legacy jobs with `notify: true` can still use `cron.webhook` until migrated.
|
||||
<AccordionGroup>
|
||||
<Accordion title="Chat and Talk">
|
||||
- Chat with the model via Gateway WS (`chat.history`, `chat.send`, `chat.abort`, `chat.inject`).
|
||||
- Talk to OpenAI Realtime directly from the browser via WebRTC. The Gateway mints a short-lived Realtime client secret with `talk.realtime.session`; the browser sends microphone audio directly to OpenAI and relays `openclaw_agent_consult` tool calls back through `chat.send` for the larger configured OpenClaw model.
|
||||
- Stream tool calls + live tool output cards in Chat (agent events).
|
||||
</Accordion>
|
||||
<Accordion title="Channels, instances, sessions, dreams">
|
||||
- Channels: built-in plus bundled/external plugin channels status, QR login, and per-channel config (`channels.status`, `web.login.*`, `config.patch`).
|
||||
- Instances: presence list + refresh (`system-presence`).
|
||||
- Sessions: list + per-session model/thinking/fast/verbose/trace/reasoning overrides (`sessions.list`, `sessions.patch`).
|
||||
- Dreams: dreaming status, enable/disable toggle, and Dream Diary reader (`doctor.memory.status`, `doctor.memory.dreamDiary`, `config.patch`).
|
||||
</Accordion>
|
||||
<Accordion title="Cron, skills, nodes, exec approvals">
|
||||
- Cron jobs: list/add/edit/run/enable/disable + run history (`cron.*`).
|
||||
- Skills: status, enable/disable, install, API key updates (`skills.*`).
|
||||
- Nodes: list + caps (`node.list`).
|
||||
- Exec approvals: edit gateway or node allowlists + ask policy for `exec host=gateway/node` (`exec.approvals.*`).
|
||||
</Accordion>
|
||||
<Accordion title="Config">
|
||||
- View/edit `~/.openclaw/openclaw.json` (`config.get`, `config.set`).
|
||||
- Apply + restart with validation (`config.apply`) and wake the last active session.
|
||||
- Writes include a base-hash guard to prevent clobbering concurrent edits.
|
||||
- Writes (`config.set`/`config.apply`/`config.patch`) preflight active SecretRef resolution for refs in the submitted config payload; unresolved active submitted refs are rejected before write.
|
||||
- Schema + form rendering (`config.schema` / `config.schema.lookup`, including field `title` / `description`, matched UI hints, immediate child summaries, docs metadata on nested object/wildcard/array/composition nodes, plus plugin + channel schemas when available); Raw JSON editor is available only when the snapshot has a safe raw round-trip.
|
||||
- If a snapshot cannot safely round-trip raw text, Control UI forces Form mode and disables Raw mode for that snapshot.
|
||||
- Raw JSON editor "Reset to saved" preserves the raw-authored shape (formatting, comments, `$include` layout) instead of re-rendering a flattened snapshot, so external edits survive a reset when the snapshot can safely round-trip.
|
||||
- Structured SecretRef object values are rendered read-only in form text inputs to prevent accidental object-to-string corruption.
|
||||
</Accordion>
|
||||
<Accordion title="Debug, logs, update">
|
||||
- Debug: status/health/models snapshots + event log + manual RPC calls (`status`, `health`, `models.list`).
|
||||
- Logs: live tail of gateway file logs with filter/export (`logs.tail`).
|
||||
- Update: run a package/git update + restart (`update.run`) with a restart report.
|
||||
</Accordion>
|
||||
<Accordion title="Cron jobs panel notes">
|
||||
- For isolated jobs, delivery defaults to announce summary. You can switch to none if you want internal-only runs.
|
||||
- Channel/target fields appear when announce is selected.
|
||||
- Webhook mode uses `delivery.mode = "webhook"` with `delivery.to` set to a valid HTTP(S) webhook URL.
|
||||
- For main-session jobs, webhook and none delivery modes are available.
|
||||
- Advanced edit controls include delete-after-run, clear agent override, cron exact/stagger options, agent model/thinking overrides, and best-effort delivery toggles.
|
||||
- Form validation is inline with field-level errors; invalid values disable the save button until fixed.
|
||||
- Set `cron.webhookToken` to send a dedicated bearer token, if omitted the webhook is sent without an auth header.
|
||||
- Deprecated fallback: stored legacy jobs with `notify: true` can still use `cron.webhook` until migrated.
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
## Chat behavior
|
||||
|
||||
- `chat.send` is **non-blocking**: it acks immediately with `{ runId, status: "started" }` and the response streams via `chat` events.
|
||||
- Re-sending with the same `idempotencyKey` returns `{ status: "in_flight" }` while running, and `{ status: "ok" }` after completion.
|
||||
- `chat.history` responses are size-bounded for UI safety. When transcript entries are too large, Gateway may truncate long text fields, omit heavy metadata blocks, and replace oversized messages with a placeholder (`[chat.history omitted: message too large]`).
|
||||
- Assistant/generated images are persisted as managed media references and served back through authenticated Gateway media URLs, so reloads do not depend on raw base64 image payloads staying in the chat history response.
|
||||
- `chat.history` also strips display-only inline directive tags from visible assistant text (for example `[[reply_to_*]]` and `[[audio_as_voice]]`), plain-text tool-call XML payloads (including `<tool_call>...</tool_call>`, `<function_call>...</function_call>`, `<tool_calls>...</tool_calls>`, `<function_calls>...</function_calls>`, and truncated tool-call blocks), and leaked ASCII/full-width model control tokens, and omits assistant entries whose whole visible text is only the exact silent token `NO_REPLY` / `no_reply`.
|
||||
- During an active send and the final history refresh, the chat view keeps local
|
||||
optimistic user/assistant messages visible if `chat.history` briefly returns
|
||||
an older snapshot; the canonical transcript replaces those local messages once
|
||||
the Gateway history catches up.
|
||||
- `chat.inject` appends an assistant note to the session transcript and broadcasts a `chat` event for UI-only updates (no agent run, no channel delivery).
|
||||
- The chat header model and thinking pickers patch the active session immediately through `sessions.patch`; they are persistent session overrides, not one-turn-only send options.
|
||||
- When fresh Gateway session usage reports show high context pressure, the chat
|
||||
composer area shows a context notice and, at recommended compaction levels, a
|
||||
compact button that runs the normal session compaction path. Stale token
|
||||
snapshots are hidden until the Gateway reports fresh usage again.
|
||||
- Talk mode uses a registered realtime voice provider that supports browser
|
||||
WebRTC sessions. Configure OpenAI with `talk.provider: "openai"` plus
|
||||
`talk.providers.openai.apiKey`, or reuse the Voice Call realtime provider
|
||||
config. The browser never receives the standard OpenAI API key; it receives
|
||||
only the ephemeral Realtime client secret. Google Live realtime voice is
|
||||
supported for backend Voice Call and Google Meet bridges, but not this browser
|
||||
WebRTC path yet. The Realtime session prompt is assembled by the Gateway;
|
||||
`talk.realtime.session` does not accept caller-provided instruction overrides.
|
||||
- In the Chat composer, the Talk control is the waves button next to the
|
||||
microphone dictation button. When Talk starts, the composer status row shows
|
||||
`Connecting Talk...`, then `Talk live` while audio is connected, or
|
||||
`Asking OpenClaw...` while a realtime tool call is consulting the configured
|
||||
larger model through `chat.send`.
|
||||
- Stop:
|
||||
- Click **Stop** (calls `chat.abort`)
|
||||
- While a run is active, normal follow-ups queue. Click **Steer** on a queued message to inject that follow-up into the running turn.
|
||||
- Type `/stop` (or standalone abort phrases like `stop`, `stop action`, `stop run`, `stop openclaw`, `please stop`) to abort out-of-band
|
||||
- `chat.abort` supports `{ sessionKey }` (no `runId`) to abort all active runs for that session
|
||||
- Abort partial retention:
|
||||
- When a run is aborted, partial assistant text can still be shown in the UI
|
||||
- Gateway persists aborted partial assistant text into transcript history when buffered output exists
|
||||
- Persisted entries include abort metadata so transcript consumers can tell abort partials from normal completion output
|
||||
<AccordionGroup>
|
||||
<Accordion title="Send and history semantics">
|
||||
- `chat.send` is **non-blocking**: it acks immediately with `{ runId, status: "started" }` and the response streams via `chat` events.
|
||||
- Re-sending with the same `idempotencyKey` returns `{ status: "in_flight" }` while running, and `{ status: "ok" }` after completion.
|
||||
- `chat.history` responses are size-bounded for UI safety. When transcript entries are too large, Gateway may truncate long text fields, omit heavy metadata blocks, and replace oversized messages with a placeholder (`[chat.history omitted: message too large]`).
|
||||
- Assistant/generated images are persisted as managed media references and served back through authenticated Gateway media URLs, so reloads do not depend on raw base64 image payloads staying in the chat history response.
|
||||
- `chat.history` also strips display-only inline directive tags from visible assistant text (for example `[[reply_to_*]]` and `[[audio_as_voice]]`), plain-text tool-call XML payloads (including `<tool_call>...</tool_call>`, `<function_call>...</function_call>`, `<tool_calls>...</tool_calls>`, `<function_calls>...</function_calls>`, and truncated tool-call blocks), and leaked ASCII/full-width model control tokens, and omits assistant entries whose whole visible text is only the exact silent token `NO_REPLY` / `no_reply`.
|
||||
- During an active send and the final history refresh, the chat view keeps local optimistic user/assistant messages visible if `chat.history` briefly returns an older snapshot; the canonical transcript replaces those local messages once the Gateway history catches up.
|
||||
- `chat.inject` appends an assistant note to the session transcript and broadcasts a `chat` event for UI-only updates (no agent run, no channel delivery).
|
||||
- The chat header model and thinking pickers patch the active session immediately through `sessions.patch`; they are persistent session overrides, not one-turn-only send options.
|
||||
- When fresh Gateway session usage reports show high context pressure, the chat composer area shows a context notice and, at recommended compaction levels, a compact button that runs the normal session compaction path. Stale token snapshots are hidden until the Gateway reports fresh usage again.
|
||||
</Accordion>
|
||||
<Accordion title="Talk mode (browser WebRTC)">
|
||||
Talk mode uses a registered realtime voice provider that supports browser WebRTC sessions. Configure OpenAI with `talk.provider: "openai"` plus `talk.providers.openai.apiKey`, or reuse the Voice Call realtime provider config. The browser never receives the standard OpenAI API key; it receives only the ephemeral Realtime client secret. Google Live realtime voice is supported for backend Voice Call and Google Meet bridges, but not this browser WebRTC path yet. The Realtime session prompt is assembled by the Gateway; `talk.realtime.session` does not accept caller-provided instruction overrides.
|
||||
|
||||
In the Chat composer, the Talk control is the waves button next to the microphone dictation button. When Talk starts, the composer status row shows `Connecting Talk...`, then `Talk live` while audio is connected, or `Asking OpenClaw...` while a realtime tool call is consulting the configured larger model through `chat.send`.
|
||||
|
||||
</Accordion>
|
||||
<Accordion title="Stop and abort">
|
||||
- Click **Stop** (calls `chat.abort`).
|
||||
- While a run is active, normal follow-ups queue. Click **Steer** on a queued message to inject that follow-up into the running turn.
|
||||
- Type `/stop` (or standalone abort phrases like `stop`, `stop action`, `stop run`, `stop openclaw`, `please stop`) to abort out-of-band.
|
||||
- `chat.abort` supports `{ sessionKey }` (no `runId`) to abort all active runs for that session.
|
||||
</Accordion>
|
||||
<Accordion title="Abort partial retention">
|
||||
- When a run is aborted, partial assistant text can still be shown in the UI.
|
||||
- Gateway persists aborted partial assistant text into transcript history when buffered output exists.
|
||||
- Persisted entries include abort metadata so transcript consumers can tell abort partials from normal completion output.
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
## PWA install and web push
|
||||
|
||||
The Control UI ships a `manifest.webmanifest` and a service worker, so
|
||||
modern browsers can install it as a standalone PWA. Web Push lets the
|
||||
Gateway wake the installed PWA with notifications even when the tab or
|
||||
browser window is not open.
|
||||
The Control UI ships a `manifest.webmanifest` and a service worker, so modern browsers can install it as a standalone PWA. Web Push lets the Gateway wake the installed PWA with notifications even when the tab or browser window is not open.
|
||||
|
||||
| Surface | What it does |
|
||||
| ----------------------------------------------------- | ------------------------------------------------------------------ |
|
||||
@@ -207,37 +172,38 @@ browser window is not open.
|
||||
| `push/vapid-keys.json` (under the OpenClaw state dir) | Auto-generated VAPID keypair used to sign Web Push payloads. |
|
||||
| `push/web-push-subscriptions.json` | Persisted browser subscription endpoints. |
|
||||
|
||||
Override the VAPID keypair through env vars on the Gateway process when
|
||||
you want to pin keys (for multi-host deployments, secrets rotation, or
|
||||
tests):
|
||||
Override the VAPID keypair through env vars on the Gateway process when you want to pin keys (for multi-host deployments, secrets rotation, or tests):
|
||||
|
||||
- `OPENCLAW_VAPID_PUBLIC_KEY`
|
||||
- `OPENCLAW_VAPID_PRIVATE_KEY`
|
||||
- `OPENCLAW_VAPID_SUBJECT` (defaults to `mailto:openclaw@localhost`)
|
||||
|
||||
The Control UI uses these scope-gated Gateway methods to register and
|
||||
test browser subscriptions:
|
||||
The Control UI uses these scope-gated Gateway methods to register and test browser subscriptions:
|
||||
|
||||
- `push.web.vapidPublicKey` — fetches the active VAPID public key.
|
||||
- `push.web.subscribe` — registers an `endpoint` plus `keys.p256dh`/`keys.auth`.
|
||||
- `push.web.unsubscribe` — removes a registered endpoint.
|
||||
- `push.web.test` — sends a test notification to the caller's subscription.
|
||||
|
||||
Web Push is independent of the iOS APNS relay path
|
||||
(see [Configuration](/gateway/configuration) for relay-backed push) and
|
||||
the existing `push.test` method, which target native mobile pairing.
|
||||
<Note>
|
||||
Web Push is independent of the iOS APNS relay path (see [Configuration](/gateway/configuration) for relay-backed push) and the existing `push.test` method, which target native mobile pairing.
|
||||
</Note>
|
||||
|
||||
## Hosted embeds
|
||||
|
||||
Assistant messages can render hosted web content inline with the `[embed ...]`
|
||||
shortcode. The iframe sandbox policy is controlled by
|
||||
`gateway.controlUi.embedSandbox`:
|
||||
Assistant messages can render hosted web content inline with the `[embed ...]` shortcode. The iframe sandbox policy is controlled by `gateway.controlUi.embedSandbox`:
|
||||
|
||||
- `strict`: disables script execution inside hosted embeds
|
||||
- `scripts`: allows interactive embeds while keeping origin isolation; this is
|
||||
the default and is usually enough for self-contained browser games/widgets
|
||||
- `trusted`: adds `allow-same-origin` on top of `allow-scripts` for same-site
|
||||
documents that intentionally need stronger privileges
|
||||
<Tabs>
|
||||
<Tab title="strict">
|
||||
Disables script execution inside hosted embeds.
|
||||
</Tab>
|
||||
<Tab title="scripts (default)">
|
||||
Allows interactive embeds while keeping origin isolation; this is the default and is usually enough for self-contained browser games/widgets.
|
||||
</Tab>
|
||||
<Tab title="trusted">
|
||||
Adds `allow-same-origin` on top of `allow-scripts` for same-site documents that intentionally need stronger privileges.
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
Example:
|
||||
|
||||
@@ -251,61 +217,52 @@ Example:
|
||||
}
|
||||
```
|
||||
|
||||
Use `trusted` only when the embedded document genuinely needs same-origin
|
||||
behavior. For most agent-generated games and interactive canvases, `scripts` is
|
||||
the safer choice.
|
||||
<Warning>
|
||||
Use `trusted` only when the embedded document genuinely needs same-origin behavior. For most agent-generated games and interactive canvases, `scripts` is the safer choice.
|
||||
</Warning>
|
||||
|
||||
Absolute external `http(s)` embed URLs stay blocked by default. If you
|
||||
intentionally want `[embed url="https://..."]` to load third-party pages, set
|
||||
`gateway.controlUi.allowExternalEmbedUrls: true`.
|
||||
Absolute external `http(s)` embed URLs stay blocked by default. If you intentionally want `[embed url="https://..."]` to load third-party pages, set `gateway.controlUi.allowExternalEmbedUrls: true`.
|
||||
|
||||
## Tailnet access (recommended)
|
||||
|
||||
### Integrated Tailscale Serve (preferred)
|
||||
<Tabs>
|
||||
<Tab title="Integrated Tailscale Serve (preferred)">
|
||||
Keep the Gateway on loopback and let Tailscale Serve proxy it with HTTPS:
|
||||
|
||||
Keep the Gateway on loopback and let Tailscale Serve proxy it with HTTPS:
|
||||
```bash
|
||||
openclaw gateway --tailscale serve
|
||||
```
|
||||
|
||||
```bash
|
||||
openclaw gateway --tailscale serve
|
||||
```
|
||||
Open:
|
||||
|
||||
Open:
|
||||
- `https://<magicdns>/` (or your configured `gateway.controlUi.basePath`)
|
||||
|
||||
- `https://<magicdns>/` (or your configured `gateway.controlUi.basePath`)
|
||||
By default, Control UI/WebSocket Serve requests can authenticate via Tailscale identity headers (`tailscale-user-login`) when `gateway.auth.allowTailscale` is `true`. OpenClaw verifies the identity by resolving the `x-forwarded-for` address with `tailscale whois` and matching it to the header, and only accepts these when the request hits loopback with Tailscale's `x-forwarded-*` headers. Set `gateway.auth.allowTailscale: false` if you want to require explicit shared-secret credentials even for Serve traffic. Then use `gateway.auth.mode: "token"` or `"password"`.
|
||||
|
||||
By default, Control UI/WebSocket Serve requests can authenticate via Tailscale identity headers
|
||||
(`tailscale-user-login`) when `gateway.auth.allowTailscale` is `true`. OpenClaw
|
||||
verifies the identity by resolving the `x-forwarded-for` address with
|
||||
`tailscale whois` and matching it to the header, and only accepts these when the
|
||||
request hits loopback with Tailscale’s `x-forwarded-*` headers. Set
|
||||
`gateway.auth.allowTailscale: false` if you want to require explicit shared-secret
|
||||
credentials even for Serve traffic. Then use `gateway.auth.mode: "token"` or
|
||||
`"password"`.
|
||||
For that async Serve identity path, failed auth attempts for the same client IP
|
||||
and auth scope are serialized before rate-limit writes. Concurrent bad retries
|
||||
from the same browser can therefore show `retry later` on the second request
|
||||
instead of two plain mismatches racing in parallel.
|
||||
Tokenless Serve auth assumes the gateway host is trusted. If untrusted local
|
||||
code may run on that host, require token/password auth.
|
||||
For that async Serve identity path, failed auth attempts for the same client IP and auth scope are serialized before rate-limit writes. Concurrent bad retries from the same browser can therefore show `retry later` on the second request instead of two plain mismatches racing in parallel.
|
||||
|
||||
### Bind to tailnet + token
|
||||
<Warning>
|
||||
Tokenless Serve auth assumes the gateway host is trusted. If untrusted local code may run on that host, require token/password auth.
|
||||
</Warning>
|
||||
|
||||
```bash
|
||||
openclaw gateway --bind tailnet --token "$(openssl rand -hex 32)"
|
||||
```
|
||||
</Tab>
|
||||
<Tab title="Bind to tailnet + token">
|
||||
```bash
|
||||
openclaw gateway --bind tailnet --token "$(openssl rand -hex 32)"
|
||||
```
|
||||
|
||||
Then open:
|
||||
Then open:
|
||||
|
||||
- `http://<tailscale-ip>:18789/` (or your configured `gateway.controlUi.basePath`)
|
||||
- `http://<tailscale-ip>:18789/` (or your configured `gateway.controlUi.basePath`)
|
||||
|
||||
Paste the matching shared secret into the UI settings (sent as
|
||||
`connect.params.auth.token` or `connect.params.auth.password`).
|
||||
Paste the matching shared secret into the UI settings (sent as `connect.params.auth.token` or `connect.params.auth.password`).
|
||||
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
## Insecure HTTP
|
||||
|
||||
If you open the dashboard over plain HTTP (`http://<lan-ip>` or `http://<tailscale-ip>`),
|
||||
the browser runs in a **non-secure context** and blocks WebCrypto. By default,
|
||||
OpenClaw **blocks** Control UI connections without device identity.
|
||||
If you open the dashboard over plain HTTP (`http://<lan-ip>` or `http://<tailscale-ip>`), the browser runs in a **non-secure context** and blocks WebCrypto. By default, OpenClaw **blocks** Control UI connections without device identity.
|
||||
|
||||
Documented exceptions:
|
||||
|
||||
@@ -318,47 +275,47 @@ Documented exceptions:
|
||||
- `https://<magicdns>/` (Serve)
|
||||
- `http://127.0.0.1:18789/` (on the gateway host)
|
||||
|
||||
**Insecure-auth toggle behavior:**
|
||||
<AccordionGroup>
|
||||
<Accordion title="Insecure-auth toggle behavior">
|
||||
```json5
|
||||
{
|
||||
gateway: {
|
||||
controlUi: { allowInsecureAuth: true },
|
||||
bind: "tailnet",
|
||||
auth: { mode: "token", token: "replace-me" },
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
```json5
|
||||
{
|
||||
gateway: {
|
||||
controlUi: { allowInsecureAuth: true },
|
||||
bind: "tailnet",
|
||||
auth: { mode: "token", token: "replace-me" },
|
||||
},
|
||||
}
|
||||
```
|
||||
`allowInsecureAuth` is a local compatibility toggle only:
|
||||
|
||||
`allowInsecureAuth` is a local compatibility toggle only:
|
||||
- It allows localhost Control UI sessions to proceed without device identity in non-secure HTTP contexts.
|
||||
- It does not bypass pairing checks.
|
||||
- It does not relax remote (non-localhost) device identity requirements.
|
||||
|
||||
- It allows localhost Control UI sessions to proceed without device identity in
|
||||
non-secure HTTP contexts.
|
||||
- It does not bypass pairing checks.
|
||||
- It does not relax remote (non-localhost) device identity requirements.
|
||||
</Accordion>
|
||||
<Accordion title="Break-glass only">
|
||||
```json5
|
||||
{
|
||||
gateway: {
|
||||
controlUi: { dangerouslyDisableDeviceAuth: true },
|
||||
bind: "tailnet",
|
||||
auth: { mode: "token", token: "replace-me" },
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
**Break-glass only:**
|
||||
<Warning>
|
||||
`dangerouslyDisableDeviceAuth` disables Control UI device identity checks and is a severe security downgrade. Revert quickly after emergency use.
|
||||
</Warning>
|
||||
|
||||
```json5
|
||||
{
|
||||
gateway: {
|
||||
controlUi: { dangerouslyDisableDeviceAuth: true },
|
||||
bind: "tailnet",
|
||||
auth: { mode: "token", token: "replace-me" },
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
`dangerouslyDisableDeviceAuth` disables Control UI device identity checks and is a
|
||||
severe security downgrade. Revert quickly after emergency use.
|
||||
|
||||
Trusted-proxy note:
|
||||
|
||||
- successful trusted-proxy auth can admit **operator** Control UI sessions without
|
||||
device identity
|
||||
- this does **not** extend to node-role Control UI sessions
|
||||
- same-host loopback reverse proxies still do not satisfy trusted-proxy auth; see
|
||||
[Trusted proxy auth](/gateway/trusted-proxy-auth)
|
||||
</Accordion>
|
||||
<Accordion title="Trusted-proxy note">
|
||||
- Successful trusted-proxy auth can admit **operator** Control UI sessions without device identity.
|
||||
- This does **not** extend to node-role Control UI sessions.
|
||||
- Same-host loopback reverse proxies still do not satisfy trusted-proxy auth; see [Trusted proxy auth](/gateway/trusted-proxy-auth).
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
See [Tailscale](/gateway/tailscale) for HTTPS setup guidance.
|
||||
|
||||
@@ -409,42 +366,42 @@ Then point the UI at your Gateway WS URL (e.g. `ws://127.0.0.1:18789`).
|
||||
|
||||
## Debugging/testing: dev server + remote Gateway
|
||||
|
||||
The Control UI is static files; the WebSocket target is configurable and can be
|
||||
different from the HTTP origin. This is handy when you want the Vite dev server
|
||||
locally but the Gateway runs elsewhere.
|
||||
The Control UI is static files; the WebSocket target is configurable and can be different from the HTTP origin. This is handy when you want the Vite dev server locally but the Gateway runs elsewhere.
|
||||
|
||||
1. Start the UI dev server: `pnpm ui:dev`
|
||||
2. Open a URL like:
|
||||
<Steps>
|
||||
<Step title="Start the UI dev server">
|
||||
```bash
|
||||
pnpm ui:dev
|
||||
```
|
||||
</Step>
|
||||
<Step title="Open with gatewayUrl">
|
||||
```text
|
||||
http://localhost:5173/?gatewayUrl=ws://<gateway-host>:18789
|
||||
```
|
||||
|
||||
```text
|
||||
http://localhost:5173/?gatewayUrl=ws://<gateway-host>:18789
|
||||
```
|
||||
Optional one-time auth (if needed):
|
||||
|
||||
Optional one-time auth (if needed):
|
||||
```text
|
||||
http://localhost:5173/?gatewayUrl=wss://<gateway-host>:18789#token=<gateway-token>
|
||||
```
|
||||
|
||||
```text
|
||||
http://localhost:5173/?gatewayUrl=wss://<gateway-host>:18789#token=<gateway-token>
|
||||
```
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
Notes:
|
||||
|
||||
- `gatewayUrl` is stored in localStorage after load and removed from the URL.
|
||||
- `token` should be passed via the URL fragment (`#token=...`) whenever possible. Fragments are not sent to the server, which avoids request-log and Referer leakage. Legacy `?token=` query params are still imported once for compatibility, but only as a fallback, and are stripped immediately after bootstrap.
|
||||
- `password` is kept in memory only.
|
||||
- When `gatewayUrl` is set, the UI does not fall back to config or environment credentials.
|
||||
Provide `token` (or `password`) explicitly. Missing explicit credentials is an error.
|
||||
- Use `wss://` when the Gateway is behind TLS (Tailscale Serve, HTTPS proxy, etc.).
|
||||
- `gatewayUrl` is only accepted in a top-level window (not embedded) to prevent clickjacking.
|
||||
- Non-loopback Control UI deployments must set `gateway.controlUi.allowedOrigins`
|
||||
explicitly (full origins). This includes remote dev setups.
|
||||
- Gateway startup may seed local origins such as `http://localhost:<port>` and
|
||||
`http://127.0.0.1:<port>` from the effective runtime bind and port, but remote
|
||||
browser origins still need explicit entries.
|
||||
- Do not use `gateway.controlUi.allowedOrigins: ["*"]` except for tightly controlled
|
||||
local testing. It means allow any browser origin, not “match whatever host I am
|
||||
using.”
|
||||
- `gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback=true` enables
|
||||
Host-header origin fallback mode, but it is a dangerous security mode.
|
||||
<AccordionGroup>
|
||||
<Accordion title="Notes">
|
||||
- `gatewayUrl` is stored in localStorage after load and removed from the URL.
|
||||
- `token` should be passed via the URL fragment (`#token=...`) whenever possible. Fragments are not sent to the server, which avoids request-log and Referer leakage. Legacy `?token=` query params are still imported once for compatibility, but only as a fallback, and are stripped immediately after bootstrap.
|
||||
- `password` is kept in memory only.
|
||||
- When `gatewayUrl` is set, the UI does not fall back to config or environment credentials. Provide `token` (or `password`) explicitly. Missing explicit credentials is an error.
|
||||
- Use `wss://` when the Gateway is behind TLS (Tailscale Serve, HTTPS proxy, etc.).
|
||||
- `gatewayUrl` is only accepted in a top-level window (not embedded) to prevent clickjacking.
|
||||
- Non-loopback Control UI deployments must set `gateway.controlUi.allowedOrigins` explicitly (full origins). This includes remote dev setups.
|
||||
- Gateway startup may seed local origins such as `http://localhost:<port>` and `http://127.0.0.1:<port>` from the effective runtime bind and port, but remote browser origins still need explicit entries.
|
||||
- Do not use `gateway.controlUi.allowedOrigins: ["*"]` except for tightly controlled local testing. It means allow any browser origin, not "match whatever host I am using."
|
||||
- `gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback=true` enables Host-header origin fallback mode, but it is a dangerous security mode.
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
Example:
|
||||
|
||||
@@ -463,6 +420,6 @@ Remote access setup details: [Remote access](/gateway/remote).
|
||||
## Related
|
||||
|
||||
- [Dashboard](/web/dashboard) — gateway dashboard
|
||||
- [WebChat](/web/webchat) — browser-based chat interface
|
||||
- [TUI](/web/tui) — terminal user interface
|
||||
- [Health Checks](/gateway/health) — gateway health monitoring
|
||||
- [TUI](/web/tui) — terminal user interface
|
||||
- [WebChat](/web/webchat) — browser-based chat interface
|
||||
|
||||
@@ -122,7 +122,7 @@ describe("anthropic cli migration", () => {
|
||||
primary: "anthropic/claude-opus-4-7",
|
||||
fallbacks: ["anthropic/claude-opus-4-6", "openai/gpt-5.2"],
|
||||
},
|
||||
embeddedHarness: { runtime: "claude-cli" },
|
||||
agentRuntime: { id: "claude-cli" },
|
||||
models: {
|
||||
"anthropic/claude-opus-4-7": { alias: "Opus" },
|
||||
"anthropic/claude-sonnet-4-6": {},
|
||||
@@ -153,7 +153,7 @@ describe("anthropic cli migration", () => {
|
||||
expect(result.configPatch).toEqual({
|
||||
agents: {
|
||||
defaults: {
|
||||
embeddedHarness: { runtime: "claude-cli" },
|
||||
agentRuntime: { id: "claude-cli" },
|
||||
models: {
|
||||
"openai/gpt-5.2": {},
|
||||
"anthropic/claude-opus-4-7": {},
|
||||
@@ -184,7 +184,7 @@ describe("anthropic cli migration", () => {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "anthropic/claude-opus-4-7" },
|
||||
embeddedHarness: { runtime: "claude-cli" },
|
||||
agentRuntime: { id: "claude-cli" },
|
||||
models: {
|
||||
"anthropic/claude-opus-4-7": {},
|
||||
"anthropic/claude-sonnet-4-6": {},
|
||||
@@ -325,7 +325,7 @@ describe("anthropic cli migration", () => {
|
||||
primary: "anthropic/claude-opus-4-7",
|
||||
fallbacks: ["anthropic/claude-opus-4-6", "openai/gpt-5.2"],
|
||||
},
|
||||
embeddedHarness: { runtime: "claude-cli" },
|
||||
agentRuntime: { id: "claude-cli" },
|
||||
models: {
|
||||
"anthropic/claude-opus-4-7": { alias: "Opus" },
|
||||
"anthropic/claude-opus-4-6": { alias: "Opus" },
|
||||
|
||||
@@ -12,9 +12,9 @@ import { CLAUDE_CLI_BACKEND_ID, CLAUDE_CLI_DEFAULT_ALLOWLIST_REFS } from "./cli-
|
||||
|
||||
type AgentDefaultsModel = NonNullable<NonNullable<OpenClawConfig["agents"]>["defaults"]>["model"];
|
||||
type AgentDefaultsModels = NonNullable<NonNullable<OpenClawConfig["agents"]>["defaults"]>["models"];
|
||||
type AgentDefaultsEmbeddedHarness = NonNullable<
|
||||
type AgentDefaultsRuntimePolicy = NonNullable<
|
||||
NonNullable<OpenClawConfig["agents"]>["defaults"]
|
||||
>["embeddedHarness"];
|
||||
>["agentRuntime"];
|
||||
type ClaudeCliCredential = NonNullable<ReturnType<typeof readClaudeCliCredentialsForSetup>>;
|
||||
|
||||
function toAnthropicModelRef(raw: string): string | null {
|
||||
@@ -125,16 +125,14 @@ function seedClaudeCliAllowlist(
|
||||
return next;
|
||||
}
|
||||
|
||||
function selectClaudeCliRuntime(
|
||||
embeddedHarness: AgentDefaultsEmbeddedHarness | undefined,
|
||||
): AgentDefaultsEmbeddedHarness {
|
||||
const currentRuntime = embeddedHarness?.runtime?.trim();
|
||||
function selectClaudeCliRuntime(agentRuntime: AgentDefaultsRuntimePolicy | undefined) {
|
||||
const currentRuntime = agentRuntime?.id?.trim();
|
||||
if (currentRuntime && currentRuntime !== "auto") {
|
||||
return embeddedHarness;
|
||||
return agentRuntime;
|
||||
}
|
||||
return {
|
||||
...embeddedHarness,
|
||||
runtime: CLAUDE_CLI_BACKEND_ID,
|
||||
...agentRuntime,
|
||||
id: CLAUDE_CLI_BACKEND_ID,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -198,7 +196,7 @@ export function buildAnthropicCliMigrationResult(
|
||||
agents: {
|
||||
defaults: {
|
||||
...(rewrittenModel.changed ? { model: rewrittenModel.value } : {}),
|
||||
embeddedHarness: selectClaudeCliRuntime(defaults?.embeddedHarness),
|
||||
agentRuntime: selectClaudeCliRuntime(defaults?.agentRuntime),
|
||||
models: nextModels,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -140,7 +140,7 @@ function isAnthropicCacheRetentionTarget(
|
||||
}
|
||||
|
||||
function usesClaudeCliModelSelection(config: OpenClawConfig): boolean {
|
||||
if (config.agents?.defaults?.embeddedHarness?.runtime === CLAUDE_CLI_BACKEND_ID) {
|
||||
if (config.agents?.defaults?.agentRuntime?.id === CLAUDE_CLI_BACKEND_ID) {
|
||||
return true;
|
||||
}
|
||||
const primary = resolveModelPrimaryValue(
|
||||
|
||||
@@ -176,7 +176,7 @@ describe("anthropic provider replay hooks", () => {
|
||||
},
|
||||
agents: {
|
||||
defaults: {
|
||||
embeddedHarness: { runtime: "claude-cli" },
|
||||
agentRuntime: { id: "claude-cli" },
|
||||
model: { primary: "anthropic/claude-opus-4-7" },
|
||||
models: {
|
||||
"anthropic/claude-opus-4-7": {},
|
||||
|
||||
@@ -40,6 +40,10 @@
|
||||
"blurb": "very well supported right now.",
|
||||
"systemImage": "bubble.left.and.bubble.right",
|
||||
"markdownCapable": true,
|
||||
"commands": {
|
||||
"nativeCommandsAutoEnabled": true,
|
||||
"nativeSkillsAutoEnabled": true
|
||||
},
|
||||
"configuredState": {
|
||||
"specifier": "./configured-state",
|
||||
"exportName": "hasDiscordConfiguredState"
|
||||
|
||||
@@ -196,6 +196,12 @@ function buildDiscordModelPickerCurrentModel(
|
||||
return `${defaultProvider}/${defaultModel}`;
|
||||
}
|
||||
|
||||
function resolveConfiguredAgentRuntimeId(value: {
|
||||
agentRuntime?: { id?: unknown };
|
||||
}): string | undefined {
|
||||
return normalizeOptionalString(value.agentRuntime?.id);
|
||||
}
|
||||
|
||||
function buildDiscordModelPickerAllowedModelRefs(
|
||||
data: Awaited<ReturnType<typeof loadDiscordModelPickerData>>,
|
||||
): Set<string> {
|
||||
@@ -386,15 +392,15 @@ function resolveDiscordModelPickerCurrentRuntime(params: {
|
||||
// Fall through to configured defaults when the session store is unavailable.
|
||||
}
|
||||
|
||||
const agentRuntime = normalizeOptionalString(
|
||||
const agentRuntime = resolveConfiguredAgentRuntimeId(
|
||||
params.cfg.agents?.list?.find(
|
||||
(entry) => normalizeOptionalString(entry.id) === params.route.agentId,
|
||||
)?.embeddedHarness?.runtime,
|
||||
) ?? {},
|
||||
);
|
||||
if (agentRuntime) {
|
||||
return agentRuntime;
|
||||
}
|
||||
return normalizeOptionalString(params.cfg.agents?.defaults?.embeddedHarness?.runtime) ?? "auto";
|
||||
return resolveConfiguredAgentRuntimeId(params.cfg.agents?.defaults ?? {}) ?? "auto";
|
||||
}
|
||||
|
||||
export async function replyWithDiscordModelPickerProviders(params: {
|
||||
|
||||
1
extensions/discord/thread-binding-api.ts
Normal file
1
extensions/discord/thread-binding-api.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const defaultTopLevelPlacement = "child" as const;
|
||||
@@ -220,6 +220,45 @@ describe("FeishuConfigSchema optimization flags", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("FeishuConfigSchema TTS overrides", () => {
|
||||
it("accepts top-level and account-level TTS overrides", () => {
|
||||
const result = FeishuConfigSchema.parse({
|
||||
tts: {
|
||||
auto: "always",
|
||||
provider: "openai",
|
||||
providers: {
|
||||
openai: {
|
||||
voice: "alloy",
|
||||
},
|
||||
},
|
||||
},
|
||||
accounts: {
|
||||
english: {
|
||||
tts: {
|
||||
providers: {
|
||||
openai: {
|
||||
voice: "shimmer",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.tts).toMatchObject({
|
||||
auto: "always",
|
||||
provider: "openai",
|
||||
});
|
||||
expect(result.accounts?.english?.tts).toMatchObject({
|
||||
providers: {
|
||||
openai: {
|
||||
voice: "shimmer",
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("FeishuConfigSchema actions", () => {
|
||||
it("accepts top-level reactions action gate", () => {
|
||||
const result = FeishuConfigSchema.parse({
|
||||
|
||||
@@ -20,6 +20,23 @@ const FeishuDomainSchema = z.union([
|
||||
z.string().url().startsWith("https://"),
|
||||
]);
|
||||
const FeishuConnectionModeSchema = z.enum(["websocket", "webhook"]);
|
||||
const TtsOverrideSchema = z
|
||||
.object({
|
||||
auto: z.enum(["off", "always", "inbound", "tagged"]).optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
mode: z.enum(["final", "all"]).optional(),
|
||||
provider: z.string().optional(),
|
||||
persona: z.string().optional(),
|
||||
personas: z.record(z.string(), z.record(z.string(), z.unknown())).optional(),
|
||||
summaryModel: z.string().optional(),
|
||||
modelOverrides: z.record(z.string(), z.unknown()).optional(),
|
||||
providers: z.record(z.string(), z.record(z.string(), z.unknown())).optional(),
|
||||
prefsPath: z.string().optional(),
|
||||
maxTextLength: z.number().int().min(1).optional(),
|
||||
timeoutMs: z.number().int().min(1000).max(120000).optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional();
|
||||
|
||||
const ToolPolicySchema = z
|
||||
.object({
|
||||
@@ -183,6 +200,7 @@ const FeishuSharedConfigShape = {
|
||||
reactionNotifications: ReactionNotificationModeSchema,
|
||||
typingIndicator: z.boolean().optional(),
|
||||
resolveSenderNames: z.boolean().optional(),
|
||||
tts: TtsOverrideSchema,
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -85,7 +85,7 @@ export function buildGoogleGeminiCliProvider(): ProviderPlugin {
|
||||
configPatch: {
|
||||
agents: {
|
||||
defaults: {
|
||||
embeddedHarness: { runtime: PROVIDER_ID },
|
||||
agentRuntime: { id: PROVIDER_ID },
|
||||
models: {
|
||||
[DEFAULT_MODEL]: {},
|
||||
},
|
||||
|
||||
@@ -2,7 +2,11 @@ import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createEmbeddedLobsterRunner, resolveLobsterCwd } from "./lobster-runner.js";
|
||||
import {
|
||||
createEmbeddedLobsterRunner,
|
||||
loadEmbeddedToolRuntimeFromPackage,
|
||||
resolveLobsterCwd,
|
||||
} from "./lobster-runner.js";
|
||||
|
||||
describe("resolveLobsterCwd", () => {
|
||||
it("defaults to the current working directory", () => {
|
||||
@@ -352,6 +356,60 @@ describe("createEmbeddedLobsterRunner", () => {
|
||||
expect(loadRuntime).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("falls back to the installed package core file when the core export is unavailable", async () => {
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-lobster-package-"));
|
||||
const packageRoot = path.join(tempDir, "node_modules", "@clawdbot", "lobster");
|
||||
const packageEntryPath = path.join(packageRoot, "dist", "src", "sdk", "index.js");
|
||||
const packageCorePath = path.join(packageRoot, "dist", "src", "core", "index.js");
|
||||
|
||||
try {
|
||||
await fs.mkdir(path.dirname(packageEntryPath), { recursive: true });
|
||||
await fs.mkdir(path.dirname(packageCorePath), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(packageRoot, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "@clawdbot/lobster",
|
||||
type: "module",
|
||||
main: "./dist/src/sdk/index.js",
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
await fs.writeFile(packageEntryPath, "export {};\n", "utf8");
|
||||
await fs.writeFile(
|
||||
packageCorePath,
|
||||
[
|
||||
"export async function runToolRequest() {",
|
||||
" return { ok: true, status: 'ok', output: [{ source: 'fallback' }], requiresApproval: null };",
|
||||
"}",
|
||||
"export async function resumeToolRequest() {",
|
||||
" return { ok: true, status: 'cancelled', output: [], requiresApproval: null };",
|
||||
"}",
|
||||
"",
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const runtime = await loadEmbeddedToolRuntimeFromPackage({
|
||||
importModule: async (specifier) => {
|
||||
if (specifier === "@clawdbot/lobster/core") {
|
||||
throw new Error("package export missing");
|
||||
}
|
||||
return (await import(`${specifier}?t=${Date.now()}`)) as object;
|
||||
},
|
||||
resolvePackageEntry: () => packageEntryPath,
|
||||
});
|
||||
|
||||
await expect(runtime.runToolRequest({ pipeline: "commands.list" })).resolves.toEqual({
|
||||
ok: true,
|
||||
status: "ok",
|
||||
output: [{ source: "fallback" }],
|
||||
requiresApproval: null,
|
||||
});
|
||||
} finally {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("requires a pipeline for run", async () => {
|
||||
const runner = createEmbeddedLobsterRunner({
|
||||
loadRuntime: vi.fn().mockResolvedValue({
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { stat } from "node:fs/promises";
|
||||
import { createRequire } from "node:module";
|
||||
import path from "node:path";
|
||||
import { Readable, Writable } from "node:stream";
|
||||
import {
|
||||
resumeToolRequest as embeddedResumeToolRequest,
|
||||
runToolRequest as embeddedRunToolRequest,
|
||||
} from "@clawdbot/lobster/core";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
export type LobsterEnvelope =
|
||||
| {
|
||||
@@ -97,6 +96,45 @@ type EmbeddedToolRuntime = {
|
||||
|
||||
type LoadEmbeddedToolRuntime = () => Promise<EmbeddedToolRuntime>;
|
||||
|
||||
type LoadEmbeddedToolRuntimeFromPackageOptions = {
|
||||
importModule?: (specifier: string) => Promise<Partial<EmbeddedToolRuntime>>;
|
||||
resolvePackageEntry?: (specifier: string) => string;
|
||||
};
|
||||
|
||||
const lobsterRequire = createRequire(import.meta.url);
|
||||
|
||||
function toEmbeddedToolRuntime(
|
||||
moduleExports: Partial<EmbeddedToolRuntime>,
|
||||
source: string,
|
||||
): EmbeddedToolRuntime {
|
||||
const { runToolRequest, resumeToolRequest } = moduleExports;
|
||||
if (typeof runToolRequest === "function" && typeof resumeToolRequest === "function") {
|
||||
return { runToolRequest, resumeToolRequest };
|
||||
}
|
||||
throw new Error(`${source} does not export Lobster embedded runtime functions`);
|
||||
}
|
||||
|
||||
function findLobsterPackageRoot(resolvedEntryPath: string): string {
|
||||
let dir = path.dirname(resolvedEntryPath);
|
||||
while (true) {
|
||||
const packageJsonPath = path.join(dir, "package.json");
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(packageJsonPath, "utf8")) as { name?: string };
|
||||
if (parsed.name === "@clawdbot/lobster") {
|
||||
return dir;
|
||||
}
|
||||
} catch {
|
||||
// Keep walking until the installed package root is found.
|
||||
}
|
||||
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) {
|
||||
throw new Error(`Could not locate @clawdbot/lobster package root from ${resolvedEntryPath}`);
|
||||
}
|
||||
dir = parent;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeForCwdSandbox(p: string): string {
|
||||
const normalized = path.normalize(p);
|
||||
return process.platform === "win32" ? normalized.toLowerCase() : normalized;
|
||||
@@ -255,11 +293,39 @@ async function withTimeout<T>(
|
||||
});
|
||||
}
|
||||
|
||||
async function loadEmbeddedToolRuntimeFromPackage(): Promise<EmbeddedToolRuntime> {
|
||||
return {
|
||||
runToolRequest: embeddedRunToolRequest,
|
||||
resumeToolRequest: embeddedResumeToolRequest,
|
||||
};
|
||||
export async function loadEmbeddedToolRuntimeFromPackage(
|
||||
options: LoadEmbeddedToolRuntimeFromPackageOptions = {},
|
||||
): Promise<EmbeddedToolRuntime> {
|
||||
const importModule =
|
||||
options.importModule ??
|
||||
(async (specifier: string) => (await import(specifier)) as Partial<EmbeddedToolRuntime>);
|
||||
const resolvePackageEntry =
|
||||
options.resolvePackageEntry ?? ((specifier: string) => lobsterRequire.resolve(specifier));
|
||||
|
||||
let coreLoadError: unknown;
|
||||
try {
|
||||
const coreSpecifier = ["@clawdbot", "lobster", "core"].join("/");
|
||||
return toEmbeddedToolRuntime(await importModule(coreSpecifier), "@clawdbot/lobster/core");
|
||||
} catch (error) {
|
||||
coreLoadError = error;
|
||||
}
|
||||
|
||||
let fallbackLoadError: unknown;
|
||||
try {
|
||||
const packageEntryPath = resolvePackageEntry("@clawdbot/lobster");
|
||||
const packageRoot = findLobsterPackageRoot(packageEntryPath);
|
||||
const coreRuntimeUrl = pathToFileURL(path.join(packageRoot, "dist/src/core/index.js")).href;
|
||||
return toEmbeddedToolRuntime(await importModule(coreRuntimeUrl), coreRuntimeUrl);
|
||||
} catch (error) {
|
||||
fallbackLoadError = error;
|
||||
}
|
||||
|
||||
throw new Error("Failed to load the Lobster embedded runtime", {
|
||||
cause: new AggregateError(
|
||||
[coreLoadError, fallbackLoadError],
|
||||
"Both Lobster embedded runtime load paths failed",
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
export function createEmbeddedLobsterRunner(options?: {
|
||||
|
||||
@@ -68,6 +68,7 @@ function makeUserInput(text: string) {
|
||||
}
|
||||
|
||||
const SESSIONS_SPAWN_TOOL = { type: "function", name: "sessions_spawn" } as const;
|
||||
const SESSIONS_YIELD_TOOL = { type: "function", name: "sessions_yield" } as const;
|
||||
const THREAD_SUBAGENT_CHILD_ERROR_TOKEN = "QA_SUBAGENT_CHILD_ERROR";
|
||||
const THREAD_SUBAGENT_TOOL_ERROR =
|
||||
"thread=true requested but thread delivery is unavailable in this test harness.";
|
||||
@@ -707,6 +708,75 @@ describe("qa mock openai server", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("drives yielded-parent subagent fallback QA through sessions_spawn and sessions_yield", async () => {
|
||||
const server = await startMockServer();
|
||||
const prompt =
|
||||
"Subagent direct fallback QA check: spawn one worker and yield until QA-SUBAGENT-DIRECT-FALLBACK-OK is delivered.";
|
||||
|
||||
await expectResponsesText(server, {
|
||||
stream: true,
|
||||
tools: [SESSIONS_SPAWN_TOOL, SESSIONS_YIELD_TOOL],
|
||||
input: [makeUserInput(prompt)],
|
||||
});
|
||||
|
||||
await expect(
|
||||
(await fetch(`${server.baseUrl}/debug/last-request`)).json(),
|
||||
).resolves.toMatchObject({
|
||||
plannedToolName: "sessions_spawn",
|
||||
plannedToolArgs: {
|
||||
label: "qa-direct-fallback-worker",
|
||||
thread: false,
|
||||
mode: "run",
|
||||
},
|
||||
});
|
||||
|
||||
const body = await expectResponsesText(server, {
|
||||
stream: true,
|
||||
tools: [SESSIONS_SPAWN_TOOL, SESSIONS_YIELD_TOOL],
|
||||
input: [
|
||||
makeUserInput(prompt),
|
||||
{
|
||||
type: "function_call_output",
|
||||
call_id: "call_mock_sessions_spawn_1",
|
||||
output: JSON.stringify({
|
||||
status: "accepted",
|
||||
childSessionKey: "agent:qa:subagent:child",
|
||||
runId: "run-child-1",
|
||||
}),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(body).toContain('"name":"sessions_yield"');
|
||||
expect(body).toContain("QA-SUBAGENT-DIRECT-FALLBACK-OK");
|
||||
await expect(
|
||||
(await fetch(`${server.baseUrl}/debug/last-request`)).json(),
|
||||
).resolves.toMatchObject({
|
||||
plannedToolName: "sessions_yield",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns no visible announce output for the direct fallback QA marker", async () => {
|
||||
const server = await startMockServer();
|
||||
|
||||
const body = await expectResponsesJson<{
|
||||
output?: Array<{ content?: Array<{ text?: string }> }>;
|
||||
}>(server, {
|
||||
stream: false,
|
||||
input: [
|
||||
makeUserInput(
|
||||
[
|
||||
"[Internal task completion event]",
|
||||
"Task: qa-direct-fallback-worker",
|
||||
"Result: QA-SUBAGENT-DIRECT-FALLBACK-OK",
|
||||
].join("\n"),
|
||||
),
|
||||
],
|
||||
});
|
||||
|
||||
expect(body.output?.[0]?.content?.[0]?.text).toBe("");
|
||||
});
|
||||
|
||||
it("surfaces sessions_spawn tool errors instead of echoing child-task tokens", async () => {
|
||||
const server = await startMockServer();
|
||||
|
||||
|
||||
@@ -147,6 +147,9 @@ const QA_EMPTY_RESPONSE_RECOVERY_PROMPT_RE = /empty response continuation qa che
|
||||
const QA_EMPTY_RESPONSE_EXHAUSTION_PROMPT_RE = /empty response exhaustion qa check/i;
|
||||
const QA_QUIET_STREAMING_PROMPT_RE = /quiet streaming qa check/i;
|
||||
const QA_BLOCK_STREAMING_PROMPT_RE = /block streaming qa check/i;
|
||||
const QA_SUBAGENT_DIRECT_FALLBACK_PROMPT_RE = /subagent direct fallback qa check/i;
|
||||
const QA_SUBAGENT_DIRECT_FALLBACK_WORKER_RE = /subagent direct fallback worker/i;
|
||||
const QA_SUBAGENT_DIRECT_FALLBACK_MARKER = "QA-SUBAGENT-DIRECT-FALLBACK-OK";
|
||||
const QA_REASONING_ONLY_RETRY_NEEDLE =
|
||||
"recorded reasoning but did not produce a user-visible answer";
|
||||
const QA_EMPTY_RESPONSE_RETRY_NEEDLE =
|
||||
@@ -784,6 +787,9 @@ function buildAssistantText(
|
||||
if (/fanout worker beta/i.test(prompt)) {
|
||||
return "BETA-OK";
|
||||
}
|
||||
if (QA_SUBAGENT_DIRECT_FALLBACK_WORKER_RE.test(prompt)) {
|
||||
return QA_SUBAGENT_DIRECT_FALLBACK_MARKER;
|
||||
}
|
||||
if (/report the visible code/i.test(prompt) && /FORKED-CONTEXT-ALPHA/i.test(allInputText)) {
|
||||
return "FORKED-CONTEXT-ALPHA";
|
||||
}
|
||||
@@ -1153,6 +1159,29 @@ async function buildResponsesPayload(
|
||||
const hasReasoningOnlyRetryInstruction = allInputText.includes(QA_REASONING_ONLY_RETRY_NEEDLE);
|
||||
const hasEmptyResponseRetryInstruction = allInputText.includes(QA_EMPTY_RESPONSE_RETRY_NEEDLE);
|
||||
const canCallSessionsSpawn = hasDeclaredTool(body, "sessions_spawn");
|
||||
const canCallSessionsYield = hasDeclaredTool(body, "sessions_yield");
|
||||
if (
|
||||
allInputText.includes(QA_SUBAGENT_DIRECT_FALLBACK_MARKER) &&
|
||||
/Internal task completion event/i.test(allInputText)
|
||||
) {
|
||||
return buildAssistantEvents("");
|
||||
}
|
||||
if (QA_SUBAGENT_DIRECT_FALLBACK_PROMPT_RE.test(allInputText)) {
|
||||
if (!toolOutput && canCallSessionsSpawn) {
|
||||
return buildToolCallEventsWithArgs("sessions_spawn", {
|
||||
task: `Subagent direct fallback worker: finish with exactly ${QA_SUBAGENT_DIRECT_FALLBACK_MARKER}.`,
|
||||
label: "qa-direct-fallback-worker",
|
||||
thread: false,
|
||||
mode: "run",
|
||||
runTimeoutSeconds: 30,
|
||||
});
|
||||
}
|
||||
if (toolOutput && canCallSessionsYield && !/\byielded\b/i.test(toolOutput)) {
|
||||
return buildToolCallEventsWithArgs("sessions_yield", {
|
||||
message: `Waiting for ${QA_SUBAGENT_DIRECT_FALLBACK_MARKER}.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (/remember this fact/i.test(prompt)) {
|
||||
return buildAssistantEvents(buildAssistantText(input, body, scenarioState));
|
||||
}
|
||||
|
||||
@@ -185,6 +185,7 @@ describe("dispatchOutbound", () => {
|
||||
text: "read this aloud",
|
||||
cfg: {},
|
||||
channel: "qqbot",
|
||||
accountId: "qq-main",
|
||||
});
|
||||
expect(audioFileToSilkBase64Mock).toHaveBeenCalledWith("/tmp/openclaw-qqbot/tts.wav");
|
||||
expect(sendVoiceMessageMock).toHaveBeenCalledWith(
|
||||
|
||||
@@ -57,7 +57,12 @@ export interface GatewayPluginRuntime {
|
||||
};
|
||||
};
|
||||
tts: {
|
||||
textToSpeech: (params: { text: string; cfg: unknown; channel: string }) => Promise<{
|
||||
textToSpeech: (params: {
|
||||
text: string;
|
||||
cfg: unknown;
|
||||
channel: string;
|
||||
accountId?: string;
|
||||
}) => Promise<{
|
||||
success: boolean;
|
||||
audioPath?: string;
|
||||
provider?: string;
|
||||
|
||||
@@ -37,7 +37,12 @@ import {
|
||||
/** TTS provider interface — injected from the outer layer. */
|
||||
export interface TTSProvider {
|
||||
/** Framework TTS: text → audio file path. */
|
||||
textToSpeech(params: { text: string; cfg: unknown; channel: string }): Promise<{
|
||||
textToSpeech(params: {
|
||||
text: string;
|
||||
cfg: unknown;
|
||||
channel: string;
|
||||
accountId?: string;
|
||||
}): Promise<{
|
||||
success: boolean;
|
||||
audioPath?: string;
|
||||
provider?: string;
|
||||
@@ -406,6 +411,7 @@ export async function sendTextAsVoiceReply(
|
||||
text: ttsText,
|
||||
cfg,
|
||||
channel: "qqbot",
|
||||
accountId: account.accountId,
|
||||
});
|
||||
if (!ttsResult.success || !ttsResult.audioPath) {
|
||||
log?.error(`TTS failed: ${ttsResult.error ?? "unknown"}`);
|
||||
|
||||
@@ -28,6 +28,10 @@
|
||||
"blurb": "supported (Socket Mode).",
|
||||
"systemImage": "number",
|
||||
"markdownCapable": true,
|
||||
"commands": {
|
||||
"nativeCommandsAutoEnabled": false,
|
||||
"nativeSkillsAutoEnabled": false
|
||||
},
|
||||
"configuredState": {
|
||||
"specifier": "./configured-state",
|
||||
"exportName": "hasSlackConfiguredState"
|
||||
|
||||
@@ -51,6 +51,7 @@ import {
|
||||
type SpeechVoiceOption,
|
||||
type TtsDirectiveOverrides,
|
||||
type TtsDirectiveParseResult,
|
||||
type TtsConfigResolutionContext,
|
||||
} from "../api.js";
|
||||
|
||||
export type {
|
||||
@@ -409,8 +410,11 @@ export function getResolvedSpeechProviderConfig(
|
||||
return resolveLazyProviderConfig(config, canonical, cfg);
|
||||
}
|
||||
|
||||
export function resolveTtsConfig(cfg: OpenClawConfig, agentId?: string): ResolvedTtsConfig {
|
||||
const raw: TtsConfig = resolveEffectiveTtsConfig(cfg, agentId);
|
||||
export function resolveTtsConfig(
|
||||
cfg: OpenClawConfig,
|
||||
contextOrAgentId?: string | TtsConfigResolutionContext,
|
||||
): ResolvedTtsConfig {
|
||||
const raw: TtsConfig = resolveEffectiveTtsConfig(cfg, contextOrAgentId);
|
||||
const providerSource = raw.provider ? "config" : "default";
|
||||
const timeoutMs = raw.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
||||
const auto = resolveConfiguredTtsAutoMode(raw);
|
||||
@@ -470,11 +474,17 @@ function resolveEffectiveTtsAutoState(params: {
|
||||
cfg: OpenClawConfig;
|
||||
sessionAuto?: string;
|
||||
agentId?: string;
|
||||
channelId?: string;
|
||||
accountId?: string;
|
||||
}): {
|
||||
autoMode: TtsAutoMode;
|
||||
prefsPath: string;
|
||||
} {
|
||||
const raw: TtsConfig = resolveEffectiveTtsConfig(params.cfg, params.agentId);
|
||||
const raw: TtsConfig = resolveEffectiveTtsConfig(params.cfg, {
|
||||
agentId: params.agentId,
|
||||
channelId: params.channelId,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
const prefsPath = resolveTtsPrefsPathValue(raw.prefsPath);
|
||||
const sessionAuto = normalizeTtsAutoMode(params.sessionAuto);
|
||||
if (sessionAuto) {
|
||||
@@ -654,11 +664,17 @@ export function resolveExplicitTtsOverrides(params: {
|
||||
modelId?: string;
|
||||
voiceId?: string;
|
||||
agentId?: string;
|
||||
channelId?: string;
|
||||
accountId?: string;
|
||||
}): TtsDirectiveOverrides {
|
||||
const providerInput = params.provider?.trim();
|
||||
const modelId = params.modelId?.trim();
|
||||
const voiceId = params.voiceId?.trim();
|
||||
const config = resolveTtsConfig(params.cfg, params.agentId);
|
||||
const config = resolveTtsConfig(params.cfg, {
|
||||
agentId: params.agentId,
|
||||
channelId: params.channelId,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
const prefsPath = params.prefsPath ?? resolveTtsPrefsPath(config);
|
||||
const selectedProvider =
|
||||
canonicalizeSpeechProviderId(providerInput, params.cfg) ??
|
||||
@@ -991,6 +1007,8 @@ function resolveTtsRequestSetup(params: {
|
||||
providerOverride?: TtsProvider;
|
||||
disableFallback?: boolean;
|
||||
agentId?: string;
|
||||
channelId?: string;
|
||||
accountId?: string;
|
||||
}):
|
||||
| {
|
||||
config: ResolvedTtsConfig;
|
||||
@@ -1000,7 +1018,11 @@ function resolveTtsRequestSetup(params: {
|
||||
| {
|
||||
error: string;
|
||||
} {
|
||||
const config = resolveTtsConfig(params.cfg, params.agentId);
|
||||
const config = resolveTtsConfig(params.cfg, {
|
||||
agentId: params.agentId,
|
||||
channelId: params.channelId,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
const prefsPath = params.prefsPath ?? resolveTtsPrefsPath(config);
|
||||
if (params.text.length > config.maxTextLength) {
|
||||
return {
|
||||
@@ -1027,6 +1049,7 @@ export async function textToSpeech(params: {
|
||||
disableFallback?: boolean;
|
||||
timeoutMs?: number;
|
||||
agentId?: string;
|
||||
accountId?: string;
|
||||
}): Promise<TtsResult> {
|
||||
const synthesis = await synthesizeSpeech(params);
|
||||
if (!synthesis.success || !synthesis.audioBuffer || !synthesis.fileExtension) {
|
||||
@@ -1077,6 +1100,7 @@ export async function synthesizeSpeech(params: {
|
||||
disableFallback?: boolean;
|
||||
timeoutMs?: number;
|
||||
agentId?: string;
|
||||
accountId?: string;
|
||||
}): Promise<TtsSynthesisResult> {
|
||||
const setup = resolveTtsRequestSetup({
|
||||
text: params.text,
|
||||
@@ -1085,6 +1109,8 @@ export async function synthesizeSpeech(params: {
|
||||
providerOverride: params.overrides?.provider,
|
||||
disableFallback: params.disableFallback,
|
||||
agentId: params.agentId,
|
||||
channelId: params.channel,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
if ("error" in setup) {
|
||||
return { success: false, error: setup.error };
|
||||
@@ -1365,6 +1391,7 @@ export async function maybeApplyTtsToPayload(params: {
|
||||
inboundAudio?: boolean;
|
||||
ttsAuto?: string;
|
||||
agentId?: string;
|
||||
accountId?: string;
|
||||
}): Promise<ReplyPayload> {
|
||||
if (params.payload.isCompactionNotice) {
|
||||
return params.payload;
|
||||
@@ -1373,11 +1400,17 @@ export async function maybeApplyTtsToPayload(params: {
|
||||
cfg: params.cfg,
|
||||
sessionAuto: params.ttsAuto,
|
||||
agentId: params.agentId,
|
||||
channelId: params.channel,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
if (autoMode === "off") {
|
||||
return params.payload;
|
||||
}
|
||||
const config = resolveTtsConfig(params.cfg, params.agentId);
|
||||
const config = resolveTtsConfig(params.cfg, {
|
||||
agentId: params.agentId,
|
||||
channelId: params.channel,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
const activeProvider = getTtsProvider(config, prefsPath);
|
||||
|
||||
const reply = resolveSendableOutboundReplyParts(params.payload);
|
||||
@@ -1486,6 +1519,7 @@ export async function maybeApplyTtsToPayload(params: {
|
||||
channel: params.channel,
|
||||
overrides: directives.overrides,
|
||||
agentId: params.agentId,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
|
||||
if (result.success && result.audioPath) {
|
||||
|
||||
@@ -38,6 +38,10 @@
|
||||
"https://openclaw.ai"
|
||||
],
|
||||
"markdownCapable": true,
|
||||
"commands": {
|
||||
"nativeCommandsAutoEnabled": true,
|
||||
"nativeSkillsAutoEnabled": true
|
||||
},
|
||||
"configuredState": {
|
||||
"specifier": "./configured-state",
|
||||
"exportName": "hasTelegramConfiguredState"
|
||||
|
||||
@@ -1593,7 +1593,7 @@
|
||||
"test:unit:fast:audit": "node scripts/test-unit-fast-audit.mjs",
|
||||
"test:voicecall:closedloop": "node scripts/test-voicecall-closedloop.mjs",
|
||||
"test:watch": "node scripts/test-projects.mjs --watch",
|
||||
"test:windows:ci": "node scripts/test-projects.mjs src/process/exec.windows.test.ts src/process/windows-command.test.ts src/infra/windows-install-roots.test.ts test/scripts/npm-runner.test.ts test/scripts/pnpm-runner.test.ts test/scripts/ui.test.ts test/scripts/vitest-process-group.test.ts",
|
||||
"test:windows:ci": "node scripts/test-projects.mjs src/process/exec.windows.test.ts src/process/windows-command.test.ts src/infra/windows-install-roots.test.ts extensions/lobster/src/lobster-runner.test.ts test/scripts/npm-runner.test.ts test/scripts/pnpm-runner.test.ts test/scripts/ui.test.ts test/scripts/vitest-process-group.test.ts",
|
||||
"tool-display:check": "node --import tsx scripts/tool-display.ts --check",
|
||||
"tool-display:write": "node --import tsx scripts/tool-display.ts --write",
|
||||
"ts-topology": "node --import tsx scripts/ts-topology.ts",
|
||||
|
||||
99
qa/scenarios/agents/subagent-completion-direct-fallback.md
Normal file
99
qa/scenarios/agents/subagent-completion-direct-fallback.md
Normal file
@@ -0,0 +1,99 @@
|
||||
# Subagent completion direct fallback
|
||||
|
||||
```yaml qa-scenario
|
||||
id: subagent-completion-direct-fallback
|
||||
title: Subagent completion direct fallback
|
||||
surface: subagents
|
||||
coverage:
|
||||
primary:
|
||||
- agents.subagents
|
||||
secondary:
|
||||
- runtime.delivery
|
||||
- channels.qa-channel
|
||||
objective: Verify a yielded parent still receives a successful subagent result through direct fallback delivery when the dormant announce turn produces no visible reply.
|
||||
successCriteria:
|
||||
- Parent launches a native subagent.
|
||||
- Parent yields instead of waiting in-turn.
|
||||
- Subagent completion result is delivered to the original QA DM without a thread id.
|
||||
- Durable task delivery is marked delivered, not failed.
|
||||
docsRefs:
|
||||
- docs/tools/subagents.md
|
||||
- docs/help/testing.md
|
||||
- docs/channels/qa-channel.md
|
||||
codeRefs:
|
||||
- src/agents/subagent-announce-delivery.ts
|
||||
- src/agents/subagent-registry-lifecycle.ts
|
||||
- src/agents/tools/sessions-yield-tool.ts
|
||||
- extensions/qa-lab/src/providers/mock-openai/server.ts
|
||||
execution:
|
||||
kind: flow
|
||||
summary: Reproduce yielded-parent subagent completion delivery and require frozen-result fallback to the QA DM.
|
||||
config:
|
||||
prompt: "Subagent direct fallback QA check: spawn one native subagent worker. The worker must finish with exactly QA-SUBAGENT-DIRECT-FALLBACK-OK. After spawning it, call sessions_yield and wait for the completion event. Do not use ACP."
|
||||
expectedMarker: QA-SUBAGENT-DIRECT-FALLBACK-OK
|
||||
expectedLabel: qa-direct-fallback-worker
|
||||
```
|
||||
|
||||
```yaml qa-flow
|
||||
steps:
|
||||
- name: yielded parent receives child completion through direct fallback
|
||||
actions:
|
||||
- call: waitForGatewayHealthy
|
||||
args:
|
||||
- ref: env
|
||||
- 120000
|
||||
- call: waitForQaChannelReady
|
||||
args:
|
||||
- ref: env
|
||||
- 120000
|
||||
- call: reset
|
||||
- set: sessionKey
|
||||
value:
|
||||
expr: "`agent:qa:subagent-direct-fallback:${randomUUID().slice(0, 8)}`"
|
||||
- call: runAgentPrompt
|
||||
args:
|
||||
- ref: env
|
||||
- sessionKey:
|
||||
ref: sessionKey
|
||||
message:
|
||||
expr: config.prompt
|
||||
timeoutMs:
|
||||
expr: liveTurnTimeoutMs(env, 90000)
|
||||
- call: waitForCondition
|
||||
saveAs: outbound
|
||||
args:
|
||||
- lambda:
|
||||
expr: "state.getSnapshot().messages.filter((message) => message.direction === 'outbound' && String(message.text ?? '').includes(config.expectedMarker)).at(-1)"
|
||||
- expr: liveTurnTimeoutMs(env, 60000)
|
||||
- expr: "env.providerMode === 'mock-openai' ? 100 : 250"
|
||||
- assert:
|
||||
expr: "String(outbound.text ?? '').trim().includes(config.expectedMarker)"
|
||||
message:
|
||||
expr: "`fallback completion marker missing from outbound QA DM: ${recentOutboundSummary(state)}`"
|
||||
- if:
|
||||
expr: "Boolean(env.mock)"
|
||||
then:
|
||||
- set: fallbackDebugRequests
|
||||
value:
|
||||
expr: "[...(await fetchJson(`${env.mock.baseUrl}/debug/requests`))]"
|
||||
- assert:
|
||||
expr: "fallbackDebugRequests.some((request) => !request.toolOutput && /subagent direct fallback qa check/i.test(String(request.allInputText ?? '')) && request.plannedToolName === 'sessions_spawn' && request.plannedToolArgs?.label === config.expectedLabel)"
|
||||
message:
|
||||
expr: "`expected sessions_spawn for yielded fallback scenario, saw ${JSON.stringify(fallbackDebugRequests.map((request) => ({ plannedToolName: request.plannedToolName ?? null, plannedToolArgs: request.plannedToolArgs ?? null })))}`"
|
||||
- assert:
|
||||
expr: "fallbackDebugRequests.some((request) => /subagent direct fallback qa check/i.test(String(request.allInputText ?? '')) && request.plannedToolName === 'sessions_yield')"
|
||||
message:
|
||||
expr: "`expected sessions_yield for yielded fallback scenario, saw ${JSON.stringify(fallbackDebugRequests.map((request) => request.plannedToolName ?? null))}`"
|
||||
- call: waitForCondition
|
||||
saveAs: deliveredTask
|
||||
args:
|
||||
- lambda:
|
||||
expr: "(async () => { const payload = await runQaCli(env, ['tasks', 'list', '--json', '--runtime', 'subagent'], { timeoutMs: liveTurnTimeoutMs(env, 60000), json: true }); return (payload.tasks ?? []).find((task) => task.label === config.expectedLabel && task.deliveryStatus === 'delivered' && task.status === 'succeeded') ?? null; })()"
|
||||
- expr: liveTurnTimeoutMs(env, 30000)
|
||||
- 250
|
||||
- assert:
|
||||
expr: "deliveredTask.deliveryStatus === 'delivered'"
|
||||
message:
|
||||
expr: "`expected delivered task status for ${config.expectedLabel}, got ${JSON.stringify(deliveredTask)}`"
|
||||
detailsExpr: "outbound.text"
|
||||
```
|
||||
@@ -26,6 +26,7 @@ docker run --rm \
|
||||
-e "OPENCLAW_SKIP_CHANNELS=1" \
|
||||
-e "OPENCLAW_SKIP_GMAIL_WATCHER=1" \
|
||||
-e "OPENCLAW_SKIP_CANVAS_HOST=1" \
|
||||
-e "OPENCLAW_ACPX_RUNTIME_STARTUP_PROBE=1" \
|
||||
-e "OPENCLAW_STATE_DIR=/tmp/openclaw-state" \
|
||||
-e "OPENCLAW_CONFIG_PATH=/tmp/openclaw-state/openclaw.json" \
|
||||
-e "GW_URL=ws://127.0.0.1:$PORT" \
|
||||
|
||||
@@ -80,6 +80,9 @@ async function main() {
|
||||
},
|
||||
agents: {
|
||||
defaults: {
|
||||
skipBootstrap: true,
|
||||
contextInjection: "never",
|
||||
skills: [],
|
||||
subagents: {
|
||||
runTimeoutSeconds: 8,
|
||||
},
|
||||
|
||||
@@ -26,6 +26,7 @@ docker run --rm \
|
||||
-e "OPENCLAW_SKIP_GMAIL_WATCHER=1" \
|
||||
-e "OPENCLAW_SKIP_CRON=1" \
|
||||
-e "OPENCLAW_SKIP_CANVAS_HOST=1" \
|
||||
-e "OPENCLAW_ACPX_RUNTIME_STARTUP_PROBE=1" \
|
||||
-e "OPENCLAW_STATE_DIR=/tmp/openclaw-state" \
|
||||
-e "OPENCLAW_CONFIG_PATH=/tmp/openclaw-state/openclaw.json" \
|
||||
-e "GW_URL=ws://127.0.0.1:$PORT" \
|
||||
|
||||
@@ -3,8 +3,16 @@ import { closeSync, openSync, readSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { buildCmdExeCommandLine } from "./windows-cmd-helpers.mjs";
|
||||
|
||||
function getPortableBasename(value) {
|
||||
return value.split(/[/\\]/).at(-1) ?? value;
|
||||
}
|
||||
|
||||
function getPortableExtension(value) {
|
||||
return path.posix.extname(getPortableBasename(value)).toLowerCase();
|
||||
}
|
||||
|
||||
function isPnpmExecPath(value) {
|
||||
return /^pnpm(?:-cli)?(?:\.(?:[cm]?js|cmd|exe))?$/.test(path.basename(value).toLowerCase());
|
||||
return /^pnpm(?:-cli)?(?:\.(?:[cm]?js|cmd|exe))?$/.test(getPortableBasename(value).toLowerCase());
|
||||
}
|
||||
|
||||
function hasScriptShebang(value) {
|
||||
@@ -30,7 +38,7 @@ function isNodeRunnablePnpmExecPath(value) {
|
||||
if (!isPnpmExecPath(value)) {
|
||||
return false;
|
||||
}
|
||||
const extension = path.extname(value).toLowerCase();
|
||||
const extension = getPortableExtension(value);
|
||||
if (extension === ".js" || extension === ".cjs" || extension === ".mjs") {
|
||||
return true;
|
||||
}
|
||||
@@ -48,16 +56,31 @@ export function resolvePnpmRunner(params = {}) {
|
||||
const platform = params.platform ?? process.platform;
|
||||
const comSpec = params.comSpec ?? process.env.ComSpec ?? "cmd.exe";
|
||||
|
||||
if (
|
||||
typeof npmExecPath === "string" &&
|
||||
npmExecPath.length > 0 &&
|
||||
isNodeRunnablePnpmExecPath(npmExecPath)
|
||||
) {
|
||||
return {
|
||||
command: nodeExecPath,
|
||||
args: [...nodeArgs, npmExecPath, ...pnpmArgs],
|
||||
shell: false,
|
||||
};
|
||||
if (typeof npmExecPath === "string" && npmExecPath.length > 0 && isPnpmExecPath(npmExecPath)) {
|
||||
if (isNodeRunnablePnpmExecPath(npmExecPath)) {
|
||||
return {
|
||||
command: nodeExecPath,
|
||||
args: [...nodeArgs, npmExecPath, ...pnpmArgs],
|
||||
shell: false,
|
||||
};
|
||||
}
|
||||
|
||||
const npmExecExtension = getPortableExtension(npmExecPath);
|
||||
if (platform === "win32" && npmExecExtension === ".exe") {
|
||||
return {
|
||||
command: npmExecPath,
|
||||
args: pnpmArgs,
|
||||
shell: false,
|
||||
};
|
||||
}
|
||||
if (platform === "win32" && npmExecExtension === ".cmd") {
|
||||
return {
|
||||
command: comSpec,
|
||||
args: ["/d", "/s", "/c", buildCmdExeCommandLine(npmExecPath, pnpmArgs)],
|
||||
shell: false,
|
||||
windowsVerbatimArguments: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (platform === "win32") {
|
||||
|
||||
@@ -76,6 +76,10 @@ export function assertSafeWindowsShellArgs(args, platform = process.platform) {
|
||||
);
|
||||
}
|
||||
|
||||
export function prepareSpawnCommand(cmd, platform = process.platform) {
|
||||
return shouldUseShellForCommand(cmd, platform) ? `"${cmd}"` : cmd;
|
||||
}
|
||||
|
||||
function createSpawnOptions(cmd, args, envOverride) {
|
||||
const useShell = shouldUseShellForCommand(cmd);
|
||||
if (useShell) {
|
||||
@@ -92,7 +96,7 @@ function createSpawnOptions(cmd, args, envOverride) {
|
||||
function run(cmd, args) {
|
||||
let child;
|
||||
try {
|
||||
child = spawn(cmd, args, createSpawnOptions(cmd, args));
|
||||
child = spawn(prepareSpawnCommand(cmd), args, createSpawnOptions(cmd, args));
|
||||
} catch (err) {
|
||||
console.error(`Failed to launch ${cmd}:`, err);
|
||||
process.exit(1);
|
||||
@@ -113,7 +117,7 @@ function run(cmd, args) {
|
||||
function runSync(cmd, args, envOverride) {
|
||||
let result;
|
||||
try {
|
||||
result = spawnSync(cmd, args, createSpawnOptions(cmd, args, envOverride));
|
||||
result = spawnSync(prepareSpawnCommand(cmd), args, createSpawnOptions(cmd, args, envOverride));
|
||||
} catch (err) {
|
||||
console.error(`Failed to launch ${cmd}:`, err);
|
||||
process.exit(1);
|
||||
|
||||
19
src/agents/agent-runtime-policy.ts
Normal file
19
src/agents/agent-runtime-policy.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { AgentRuntimePolicyConfig } from "../config/types.agents-shared.js";
|
||||
|
||||
type AgentRuntimePolicyContainer = {
|
||||
agentRuntime?: AgentRuntimePolicyConfig;
|
||||
};
|
||||
|
||||
export function resolveAgentRuntimePolicy(
|
||||
container: AgentRuntimePolicyContainer | undefined,
|
||||
): AgentRuntimePolicyConfig | undefined {
|
||||
const preferred = container?.agentRuntime;
|
||||
if (hasAgentRuntimePolicy(preferred)) {
|
||||
return preferred;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function hasAgentRuntimePolicy(value: AgentRuntimePolicyConfig | undefined): boolean {
|
||||
return Boolean(value?.id?.trim() || value?.fallback);
|
||||
}
|
||||
@@ -302,7 +302,7 @@ describe("Auth profile runtime contract - Pi and CLI adapter", () => {
|
||||
cfg: {
|
||||
agents: {
|
||||
defaults: {
|
||||
embeddedHarness: { runtime: "codex", fallback: "none" },
|
||||
agentRuntime: { id: "codex", fallback: "none" },
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
@@ -385,7 +385,7 @@ describe("Auth profile runtime contract - Pi and CLI adapter", () => {
|
||||
cfg: {
|
||||
agents: {
|
||||
defaults: {
|
||||
embeddedHarness: { runtime: "codex", fallback: "none" },
|
||||
agentRuntime: { id: "codex", fallback: "none" },
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
@@ -408,7 +408,7 @@ describe("Auth profile runtime contract - Pi and CLI adapter", () => {
|
||||
cfg: {
|
||||
agents: {
|
||||
defaults: {
|
||||
embeddedHarness: { runtime: "codex", fallback: "none" },
|
||||
agentRuntime: { id: "codex", fallback: "none" },
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
@@ -434,7 +434,7 @@ describe("Auth profile runtime contract - Pi and CLI adapter", () => {
|
||||
list: [
|
||||
{
|
||||
id: "main",
|
||||
embeddedHarness: { runtime: "codex", fallback: "none" },
|
||||
agentRuntime: { id: "codex", fallback: "none" },
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -469,6 +469,60 @@ describe("CLI attempt execution", () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("routes canonical Anthropic models through the configured Claude CLI runtime", async () => {
|
||||
const sessionKey = "agent:main:direct:canonical-claude-cli";
|
||||
const sessionEntry: SessionEntry = {
|
||||
sessionId: "openclaw-session-canonical-cli",
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
const sessionStore: Record<string, SessionEntry> = { [sessionKey]: sessionEntry };
|
||||
await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2), "utf-8");
|
||||
runCliAgentMock.mockResolvedValueOnce(makeCliResult("canonical cli"));
|
||||
|
||||
await runAgentAttempt({
|
||||
providerOverride: "anthropic",
|
||||
modelOverride: "claude-opus-4-7",
|
||||
cfg: {
|
||||
agents: {
|
||||
defaults: {
|
||||
agentRuntime: { id: "claude-cli", fallback: "none" },
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
sessionEntry,
|
||||
sessionId: sessionEntry.sessionId,
|
||||
sessionKey,
|
||||
sessionAgentId: "main",
|
||||
sessionFile: path.join(tmpDir, "session.jsonl"),
|
||||
workspaceDir: tmpDir,
|
||||
body: "route this",
|
||||
isFallbackRetry: false,
|
||||
resolvedThinkLevel: "medium",
|
||||
timeoutMs: 1_000,
|
||||
runId: "run-canonical-claude-cli",
|
||||
opts: { senderIsOwner: false } as Parameters<typeof runAgentAttempt>[0]["opts"],
|
||||
runContext: {} as Parameters<typeof runAgentAttempt>[0]["runContext"],
|
||||
spawnedBy: undefined,
|
||||
messageChannel: "telegram",
|
||||
skillsSnapshot: undefined,
|
||||
resolvedVerboseLevel: undefined,
|
||||
agentDir: tmpDir,
|
||||
onAgentEvent: vi.fn(),
|
||||
authProfileProvider: "anthropic",
|
||||
sessionStore,
|
||||
storePath,
|
||||
sessionHasHistory: false,
|
||||
});
|
||||
|
||||
expect(runEmbeddedPiAgentMock).not.toHaveBeenCalled();
|
||||
expect(runCliAgentMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
provider: "claude-cli",
|
||||
model: "claude-opus-4-7",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("embedded attempt harness pinning", () => {
|
||||
@@ -476,6 +530,7 @@ describe("embedded attempt harness pinning", () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-embedded-attempt-"));
|
||||
runCliAgentMock.mockReset();
|
||||
runEmbeddedPiAgentMock.mockReset();
|
||||
});
|
||||
|
||||
@@ -541,7 +596,7 @@ describe("embedded attempt harness pinning", () => {
|
||||
cfg: {
|
||||
agents: {
|
||||
defaults: {
|
||||
embeddedHarness: { runtime: "codex", fallback: "none" },
|
||||
agentRuntime: { id: "codex", fallback: "none" },
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
@@ -617,4 +672,54 @@ describe("embedded attempt harness pinning", () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not pass CLI runtime aliases as embedded harness ids for fallback providers", async () => {
|
||||
const sessionEntry: SessionEntry = {
|
||||
sessionId: "fallback-session",
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
runEmbeddedPiAgentMock.mockResolvedValueOnce({
|
||||
meta: { durationMs: 1 },
|
||||
} satisfies EmbeddedPiRunResult);
|
||||
|
||||
await runAgentAttempt({
|
||||
providerOverride: "openai",
|
||||
modelOverride: "gpt-5.4",
|
||||
cfg: {
|
||||
agents: {
|
||||
defaults: {
|
||||
agentRuntime: { id: "claude-cli", fallback: "none" },
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
sessionEntry,
|
||||
sessionId: sessionEntry.sessionId,
|
||||
sessionKey: "agent:main:main",
|
||||
sessionAgentId: "main",
|
||||
sessionFile: path.join(tmpDir, "session.jsonl"),
|
||||
workspaceDir: tmpDir,
|
||||
body: "fallback",
|
||||
isFallbackRetry: true,
|
||||
resolvedThinkLevel: "medium",
|
||||
timeoutMs: 1_000,
|
||||
runId: "run-openai-fallback-with-cli-runtime",
|
||||
opts: { senderIsOwner: false } as Parameters<typeof runAgentAttempt>[0]["opts"],
|
||||
runContext: {} as Parameters<typeof runAgentAttempt>[0]["runContext"],
|
||||
spawnedBy: undefined,
|
||||
messageChannel: undefined,
|
||||
skillsSnapshot: undefined,
|
||||
resolvedVerboseLevel: undefined,
|
||||
agentDir: tmpDir,
|
||||
onAgentEvent: vi.fn(),
|
||||
authProfileProvider: "openai",
|
||||
sessionHasHistory: false,
|
||||
});
|
||||
|
||||
expect(runCliAgentMock).not.toHaveBeenCalled();
|
||||
expect(runEmbeddedPiAgentMock).toHaveBeenCalledOnce();
|
||||
expect(runEmbeddedPiAgentMock.mock.calls[0]?.[0]).not.toHaveProperty(
|
||||
"agentHarnessId",
|
||||
"claude-cli",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@ import { runCliAgent } from "../cli-runner.js";
|
||||
import { getCliSessionBinding, setCliSessionBinding } from "../cli-session.js";
|
||||
import { FailoverError } from "../failover-error.js";
|
||||
import { resolveAgentHarnessPolicy } from "../harness/selection.js";
|
||||
import { isCliRuntimeAlias, resolveCliRuntimeExecutionProvider } from "../model-runtime-aliases.js";
|
||||
import { isCliProvider } from "../model-selection.js";
|
||||
import { prepareSessionManagerForRun } from "../pi-embedded-runner/session-manager-init.js";
|
||||
import { runEmbeddedPiAgent, type EmbeddedPiRunResult } from "../pi-embedded.js";
|
||||
@@ -276,6 +277,14 @@ export function runAgentAttempt(params: {
|
||||
sessionId: params.sessionId,
|
||||
sessionKey: params.sessionKey ?? params.sessionId,
|
||||
});
|
||||
const agentRuntimeOverride = params.sessionEntry?.agentRuntimeOverride?.trim();
|
||||
const cliExecutionProvider =
|
||||
resolveCliRuntimeExecutionProvider({
|
||||
provider: params.providerOverride,
|
||||
cfg: params.cfg,
|
||||
agentId: params.sessionAgentId,
|
||||
runtimeOverride: agentRuntimeOverride,
|
||||
}) ?? params.providerOverride;
|
||||
const agentHarnessPolicy = resolveAgentHarnessPolicy({
|
||||
provider: params.providerOverride,
|
||||
modelId: params.modelOverride,
|
||||
@@ -291,14 +300,14 @@ export function runAgentAttempt(params: {
|
||||
workspaceDir: params.workspaceDir,
|
||||
harnessId: sessionPinnedAgentHarnessId,
|
||||
harnessRuntime: agentHarnessPolicy.runtime,
|
||||
allowHarnessAuthProfileForwarding: !isCliProvider(params.providerOverride, params.cfg),
|
||||
allowHarnessAuthProfileForwarding: !isCliProvider(cliExecutionProvider, params.cfg),
|
||||
});
|
||||
const authProfileId = runtimeAuthPlan.forwardedAuthProfileId;
|
||||
if (isCliProvider(params.providerOverride, params.cfg)) {
|
||||
const cliSessionBinding = getCliSessionBinding(params.sessionEntry, params.providerOverride);
|
||||
if (isCliProvider(cliExecutionProvider, params.cfg)) {
|
||||
const cliSessionBinding = getCliSessionBinding(params.sessionEntry, cliExecutionProvider);
|
||||
const resolveReusableCliSessionBinding = async () => {
|
||||
if (
|
||||
!isClaudeCliProvider(params.providerOverride) ||
|
||||
!isClaudeCliProvider(cliExecutionProvider) ||
|
||||
!cliSessionBinding?.sessionId ||
|
||||
(await claudeCliSessionTranscriptHasContent({ sessionId: cliSessionBinding.sessionId }))
|
||||
) {
|
||||
@@ -306,13 +315,13 @@ export function runAgentAttempt(params: {
|
||||
}
|
||||
|
||||
log.warn(
|
||||
`cli session reset: provider=${sanitizeForLog(params.providerOverride)} reason=transcript-missing sessionKey=${params.sessionKey ?? params.sessionId}`,
|
||||
`cli session reset: provider=${sanitizeForLog(cliExecutionProvider)} reason=transcript-missing sessionKey=${params.sessionKey ?? params.sessionId}`,
|
||||
);
|
||||
|
||||
if (params.sessionKey && params.sessionStore && params.storePath) {
|
||||
params.sessionEntry =
|
||||
(await clearCliSessionInStore({
|
||||
provider: params.providerOverride,
|
||||
provider: cliExecutionProvider,
|
||||
sessionKey: params.sessionKey,
|
||||
sessionStore: params.sessionStore,
|
||||
storePath: params.storePath,
|
||||
@@ -334,7 +343,7 @@ export function runAgentAttempt(params: {
|
||||
workspaceDir: params.workspaceDir,
|
||||
config: params.cfg,
|
||||
prompt: effectivePrompt,
|
||||
provider: params.providerOverride,
|
||||
provider: cliExecutionProvider,
|
||||
model: params.modelOverride,
|
||||
thinkLevel: params.resolvedThinkLevel,
|
||||
timeoutMs: params.timeoutMs,
|
||||
@@ -370,12 +379,12 @@ export function runAgentAttempt(params: {
|
||||
params.storePath
|
||||
) {
|
||||
log.warn(
|
||||
`CLI session expired, clearing from session store: provider=${sanitizeForLog(params.providerOverride)} sessionKey=${params.sessionKey}`,
|
||||
`CLI session expired, clearing from session store: provider=${sanitizeForLog(cliExecutionProvider)} sessionKey=${params.sessionKey}`,
|
||||
);
|
||||
|
||||
params.sessionEntry =
|
||||
(await clearCliSessionInStore({
|
||||
provider: params.providerOverride,
|
||||
provider: cliExecutionProvider,
|
||||
sessionKey: params.sessionKey,
|
||||
sessionStore: params.sessionStore,
|
||||
storePath: params.storePath,
|
||||
@@ -393,7 +402,7 @@ export function runAgentAttempt(params: {
|
||||
const updatedEntry = { ...entry };
|
||||
setCliSessionBinding(
|
||||
updatedEntry,
|
||||
params.providerOverride,
|
||||
cliExecutionProvider,
|
||||
result.meta.agentMeta.cliSessionBinding,
|
||||
);
|
||||
updatedEntry.updatedAt = Date.now();
|
||||
@@ -503,7 +512,10 @@ function resolveConfiguredAgentHarnessId(params: {
|
||||
agentId: params.sessionAgentId,
|
||||
sessionKey: params.sessionKey,
|
||||
});
|
||||
return policy.runtime === "auto" ? undefined : policy.runtime;
|
||||
if (policy.runtime === "auto" || isCliRuntimeAlias(policy.runtime)) {
|
||||
return undefined;
|
||||
}
|
||||
return policy.runtime;
|
||||
}
|
||||
|
||||
export function buildAcpResult(params: {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js";
|
||||
import { isRecord } from "../utils.js";
|
||||
import { resolveAgentRuntimePolicy } from "./agent-runtime-policy.js";
|
||||
|
||||
export function collectConfiguredAgentHarnessRuntimes(
|
||||
config: OpenClawConfig,
|
||||
@@ -18,13 +19,13 @@ export function collectConfiguredAgentHarnessRuntimes(
|
||||
runtimes.add(normalized);
|
||||
};
|
||||
|
||||
pushRuntime(config.agents?.defaults?.embeddedHarness?.runtime);
|
||||
pushRuntime(resolveAgentRuntimePolicy(config.agents?.defaults)?.id);
|
||||
if (Array.isArray(config.agents?.list)) {
|
||||
for (const agent of config.agents.list) {
|
||||
if (!isRecord(agent)) {
|
||||
continue;
|
||||
}
|
||||
pushRuntime((agent.embeddedHarness as Record<string, unknown> | undefined)?.runtime);
|
||||
pushRuntime(resolveAgentRuntimePolicy(agent)?.id);
|
||||
}
|
||||
}
|
||||
pushRuntime(env.OPENCLAW_AGENT_RUNTIME);
|
||||
|
||||
@@ -124,7 +124,7 @@ describe("runAgentHarnessAttemptWithFallback", () => {
|
||||
|
||||
await expect(
|
||||
runAgentHarnessAttemptWithFallback(
|
||||
createAttemptParams({ agents: { defaults: { embeddedHarness: { fallback: "pi" } } } }),
|
||||
createAttemptParams({ agents: { defaults: { agentRuntime: { fallback: "pi" } } } }),
|
||||
),
|
||||
).rejects.toThrow('Requested agent harness "codex" is not registered');
|
||||
expect(piRunAttempt).not.toHaveBeenCalled();
|
||||
@@ -132,7 +132,7 @@ describe("runAgentHarnessAttemptWithFallback", () => {
|
||||
|
||||
it("falls back to the PI harness in auto mode when no plugin harness matches", async () => {
|
||||
const result = await runAgentHarnessAttemptWithFallback(
|
||||
createAttemptParams({ agents: { defaults: { embeddedHarness: { runtime: "auto" } } } }),
|
||||
createAttemptParams({ agents: { defaults: { agentRuntime: { id: "auto" } } } }),
|
||||
);
|
||||
|
||||
expect(result.sessionIdUsed).toBe("pi");
|
||||
@@ -144,7 +144,7 @@ describe("runAgentHarnessAttemptWithFallback", () => {
|
||||
|
||||
await expect(
|
||||
runAgentHarnessAttemptWithFallback(
|
||||
createAttemptParams({ agents: { defaults: { embeddedHarness: { runtime: "auto" } } } }),
|
||||
createAttemptParams({ agents: { defaults: { agentRuntime: { id: "auto" } } } }),
|
||||
),
|
||||
).rejects.toThrow("codex startup failed");
|
||||
expect(piRunAttempt).not.toHaveBeenCalled();
|
||||
@@ -164,7 +164,7 @@ describe("runAgentHarnessAttemptWithFallback", () => {
|
||||
|
||||
await expect(
|
||||
runAgentHarnessAttemptWithFallback(
|
||||
createAttemptParams({ agents: { defaults: { embeddedHarness: { runtime: "codex" } } } }),
|
||||
createAttemptParams({ agents: { defaults: { agentRuntime: { id: "codex" } } } }),
|
||||
),
|
||||
).rejects.toThrow("codex startup failed");
|
||||
expect(piRunAttempt).not.toHaveBeenCalled();
|
||||
@@ -185,7 +185,7 @@ describe("runAgentHarnessAttemptWithFallback", () => {
|
||||
);
|
||||
|
||||
const params = createAttemptParams({
|
||||
agents: { defaults: { embeddedHarness: { runtime: "auto" } } },
|
||||
agents: { defaults: { agentRuntime: { id: "auto" } } },
|
||||
});
|
||||
const result = await runAgentHarnessAttemptWithFallback(params);
|
||||
|
||||
@@ -205,7 +205,7 @@ describe("runAgentHarnessAttemptWithFallback", () => {
|
||||
await expect(
|
||||
runAgentHarnessAttemptWithFallback(
|
||||
createAttemptParams({
|
||||
agents: { defaults: { embeddedHarness: { runtime: "auto", fallback: "pi" } } },
|
||||
agents: { defaults: { agentRuntime: { id: "auto", fallback: "pi" } } },
|
||||
}),
|
||||
),
|
||||
).rejects.toThrow("PI fallback is disabled");
|
||||
@@ -215,7 +215,7 @@ describe("runAgentHarnessAttemptWithFallback", () => {
|
||||
it("fails for config-forced plugin harnesses when fallback is omitted", async () => {
|
||||
await expect(
|
||||
runAgentHarnessAttemptWithFallback(
|
||||
createAttemptParams({ agents: { defaults: { embeddedHarness: { runtime: "codex" } } } }),
|
||||
createAttemptParams({ agents: { defaults: { agentRuntime: { id: "codex" } } } }),
|
||||
),
|
||||
).rejects.toThrow('Requested agent harness "codex" is not registered');
|
||||
expect(piRunAttempt).not.toHaveBeenCalled();
|
||||
@@ -224,7 +224,7 @@ describe("runAgentHarnessAttemptWithFallback", () => {
|
||||
it("allows config-forced plugin harnesses to opt into PI fallback", async () => {
|
||||
const result = await runAgentHarnessAttemptWithFallback(
|
||||
createAttemptParams({
|
||||
agents: { defaults: { embeddedHarness: { runtime: "codex", fallback: "pi" } } },
|
||||
agents: { defaults: { agentRuntime: { id: "codex", fallback: "pi" } } },
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -237,8 +237,8 @@ describe("runAgentHarnessAttemptWithFallback", () => {
|
||||
runAgentHarnessAttemptWithFallback({
|
||||
...createAttemptParams({
|
||||
agents: {
|
||||
defaults: { embeddedHarness: { fallback: "pi" } },
|
||||
list: [{ id: "strict", embeddedHarness: { runtime: "codex" } }],
|
||||
defaults: { agentRuntime: { fallback: "pi" } },
|
||||
list: [{ id: "strict", agentRuntime: { id: "codex" } }],
|
||||
},
|
||||
}),
|
||||
sessionKey: "agent:strict:session-1",
|
||||
@@ -251,8 +251,8 @@ describe("runAgentHarnessAttemptWithFallback", () => {
|
||||
const result = await runAgentHarnessAttemptWithFallback({
|
||||
...createAttemptParams({
|
||||
agents: {
|
||||
defaults: { embeddedHarness: { fallback: "none" } },
|
||||
list: [{ id: "strict", embeddedHarness: { runtime: "codex", fallback: "pi" } }],
|
||||
defaults: { agentRuntime: { fallback: "none" } },
|
||||
list: [{ id: "strict", agentRuntime: { id: "codex", fallback: "pi" } }],
|
||||
},
|
||||
}),
|
||||
sessionKey: "agent:strict:session-1",
|
||||
@@ -328,7 +328,7 @@ describe("selectAgentHarness", () => {
|
||||
const harness = selectAgentHarness({
|
||||
provider: "codex",
|
||||
modelId: "gpt-5.4",
|
||||
config: { agents: { defaults: { embeddedHarness: { runtime: "auto" } } } },
|
||||
config: { agents: { defaults: { agentRuntime: { id: "auto" } } } },
|
||||
});
|
||||
|
||||
expect(harness.id).toBe("codex-high");
|
||||
@@ -362,20 +362,20 @@ describe("selectAgentHarness", () => {
|
||||
provider: "anthropic",
|
||||
modelId: "sonnet-4.6",
|
||||
config: {
|
||||
agents: { defaults: { embeddedHarness: { runtime: "auto", fallback: "none" } } },
|
||||
agents: { defaults: { agentRuntime: { id: "auto", fallback: "none" } } },
|
||||
},
|
||||
}),
|
||||
).toThrow("PI fallback is disabled");
|
||||
expect(piRunAttempt).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows per-agent embedded harness policy overrides", () => {
|
||||
it("allows per-agent runtime policy overrides", () => {
|
||||
const config: OpenClawConfig = {
|
||||
agents: {
|
||||
defaults: { embeddedHarness: { fallback: "pi" } },
|
||||
defaults: { agentRuntime: { fallback: "pi" } },
|
||||
list: [
|
||||
{ id: "main", default: true },
|
||||
{ id: "strict", embeddedHarness: { runtime: "auto", fallback: "none" } },
|
||||
{ id: "strict", agentRuntime: { id: "auto", fallback: "none" } },
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -393,6 +393,46 @@ describe("selectAgentHarness", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("uses agentRuntime as the runtime policy source", () => {
|
||||
const config: OpenClawConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
agentRuntime: { id: "auto", fallback: "none" },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(() =>
|
||||
selectAgentHarness({
|
||||
provider: "anthropic",
|
||||
modelId: "sonnet-4.6",
|
||||
config,
|
||||
}),
|
||||
).toThrow("PI fallback is disabled");
|
||||
});
|
||||
|
||||
it("does not treat CLI runtime aliases as embedded harness ids", async () => {
|
||||
const config: OpenClawConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
agentRuntime: { id: "claude-cli", fallback: "none" },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(selectAgentHarness({ provider: "openai", modelId: "gpt-5.4", config }).id).toBe("pi");
|
||||
|
||||
await expect(
|
||||
runAgentHarnessAttemptWithFallback({
|
||||
...createAttemptParams(config),
|
||||
provider: "openai",
|
||||
modelId: "gpt-5.4",
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
sessionIdUsed: "pi",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps an existing session pinned to PI even when config now forces a plugin harness", () => {
|
||||
registerFailingCodexHarness();
|
||||
|
||||
@@ -401,7 +441,7 @@ describe("selectAgentHarness", () => {
|
||||
provider: "codex",
|
||||
modelId: "gpt-5.4",
|
||||
agentHarnessId: "pi",
|
||||
config: { agents: { defaults: { embeddedHarness: { runtime: "codex" } } } },
|
||||
config: { agents: { defaults: { agentRuntime: { id: "codex" } } } },
|
||||
}).id,
|
||||
).toBe("pi");
|
||||
});
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import type { AgentEmbeddedHarnessConfig } from "../../config/types.agents-shared.js";
|
||||
import type { AgentRuntimePolicyConfig } from "../../config/types.agents-shared.js";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import { formatErrorMessage } from "../../infra/errors.js";
|
||||
import { createSubsystemLogger } from "../../logging/subsystem.js";
|
||||
import { normalizeAgentId } from "../../routing/session-key.js";
|
||||
import { resolveAgentRuntimePolicy } from "../agent-runtime-policy.js";
|
||||
import { listAgentEntries, resolveSessionAgentIds } from "../agent-scope.js";
|
||||
import { isCliRuntimeAlias } from "../model-runtime-aliases.js";
|
||||
import type { CompactEmbeddedPiSessionParams } from "../pi-embedded-runner/compact.types.js";
|
||||
import type {
|
||||
EmbeddedRunAttemptParams,
|
||||
@@ -314,10 +316,16 @@ export function resolveAgentHarnessPolicy(params: {
|
||||
agentId: params.agentId,
|
||||
sessionKey: params.sessionKey,
|
||||
});
|
||||
const defaultsPolicy = params.config?.agents?.defaults?.embeddedHarness;
|
||||
const defaultsPolicy = resolveAgentRuntimePolicy(params.config?.agents?.defaults);
|
||||
const runtime = env.OPENCLAW_AGENT_RUNTIME?.trim()
|
||||
? resolveEmbeddedAgentRuntime(env)
|
||||
: normalizeEmbeddedAgentRuntime(agentPolicy?.runtime ?? defaultsPolicy?.runtime);
|
||||
: normalizeEmbeddedAgentRuntime(agentPolicy?.id ?? defaultsPolicy?.id);
|
||||
if (isCliRuntimeAlias(runtime)) {
|
||||
return {
|
||||
runtime: "pi",
|
||||
fallback: "pi",
|
||||
};
|
||||
}
|
||||
return {
|
||||
runtime,
|
||||
fallback: resolveAgentHarnessFallbackPolicy({
|
||||
@@ -332,8 +340,8 @@ export function resolveAgentHarnessPolicy(params: {
|
||||
function resolveAgentHarnessFallbackPolicy(params: {
|
||||
env: NodeJS.ProcessEnv;
|
||||
runtime: EmbeddedAgentRuntime;
|
||||
agentPolicy?: AgentEmbeddedHarnessConfig;
|
||||
defaultsPolicy?: AgentEmbeddedHarnessConfig;
|
||||
agentPolicy?: AgentRuntimePolicyConfig;
|
||||
defaultsPolicy?: AgentRuntimePolicyConfig;
|
||||
}): EmbeddedAgentHarnessFallback {
|
||||
const envFallback = resolveEmbeddedAgentHarnessFallback(params.env);
|
||||
if (envFallback) {
|
||||
@@ -345,7 +353,7 @@ function resolveAgentHarnessFallbackPolicy(params: {
|
||||
return normalizeAgentHarnessFallback(undefined, params.runtime);
|
||||
}
|
||||
|
||||
if (params.agentPolicy?.runtime) {
|
||||
if (params.agentPolicy?.id) {
|
||||
return normalizeAgentHarnessFallback(params.agentPolicy.fallback, params.runtime);
|
||||
}
|
||||
|
||||
@@ -362,7 +370,7 @@ function isPluginAgentRuntime(runtime: EmbeddedAgentRuntime): boolean {
|
||||
function resolveAgentEmbeddedHarnessConfig(
|
||||
config: OpenClawConfig | undefined,
|
||||
params: { agentId?: string; sessionKey?: string },
|
||||
): AgentEmbeddedHarnessConfig | undefined {
|
||||
): AgentRuntimePolicyConfig | undefined {
|
||||
if (!config) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -371,12 +379,13 @@ function resolveAgentEmbeddedHarnessConfig(
|
||||
agentId: params.agentId,
|
||||
sessionKey: params.sessionKey,
|
||||
});
|
||||
return listAgentEntries(config).find((entry) => normalizeAgentId(entry.id) === sessionAgentId)
|
||||
?.embeddedHarness;
|
||||
return resolveAgentRuntimePolicy(
|
||||
listAgentEntries(config).find((entry) => normalizeAgentId(entry.id) === sessionAgentId),
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeAgentHarnessFallback(
|
||||
value: AgentEmbeddedHarnessConfig["fallback"] | undefined,
|
||||
value: AgentRuntimePolicyConfig["fallback"] | undefined,
|
||||
runtime: EmbeddedAgentRuntime,
|
||||
): EmbeddedAgentHarnessFallback {
|
||||
if (value) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { normalizeAgentId } from "../routing/session-key.js";
|
||||
import { resolveAgentRuntimePolicy } from "./agent-runtime-policy.js";
|
||||
import { normalizeProviderId } from "./provider-id.js";
|
||||
|
||||
export type LegacyRuntimeModelProviderAlias = {
|
||||
@@ -39,6 +40,12 @@ const CLI_RUNTIME_BY_PROVIDER = new Map(
|
||||
]),
|
||||
);
|
||||
|
||||
const CLI_RUNTIME_ALIASES = new Set(
|
||||
LEGACY_RUNTIME_MODEL_PROVIDER_ALIASES.filter((entry) => entry.cli).map((entry) =>
|
||||
normalizeProviderId(entry.runtime),
|
||||
),
|
||||
);
|
||||
|
||||
export function listLegacyRuntimeModelProviderAliases(): readonly LegacyRuntimeModelProviderAlias[] {
|
||||
return LEGACY_RUNTIME_MODEL_PROVIDER_ALIASES;
|
||||
}
|
||||
@@ -84,6 +91,11 @@ export function isLegacyRuntimeModelProvider(provider: string): boolean {
|
||||
return Boolean(resolveLegacyRuntimeModelProviderAlias(provider));
|
||||
}
|
||||
|
||||
export function isCliRuntimeAlias(runtime: string | undefined): boolean {
|
||||
const normalized = runtime?.trim();
|
||||
return normalized ? CLI_RUNTIME_ALIASES.has(normalizeProviderId(normalized)) : false;
|
||||
}
|
||||
|
||||
function resolveConfiguredRuntime(params: {
|
||||
cfg?: OpenClawConfig;
|
||||
agentId?: string;
|
||||
@@ -94,14 +106,15 @@ function resolveConfiguredRuntime(params: {
|
||||
return normalizeProviderId(override);
|
||||
}
|
||||
if (params.agentId) {
|
||||
const agentRuntime = params.cfg?.agents?.list
|
||||
?.find((entry) => normalizeAgentId(entry.id) === normalizeAgentId(params.agentId ?? ""))
|
||||
?.embeddedHarness?.runtime?.trim();
|
||||
const agentEntry = params.cfg?.agents?.list?.find(
|
||||
(entry) => normalizeAgentId(entry.id) === normalizeAgentId(params.agentId ?? ""),
|
||||
);
|
||||
const agentRuntime = resolveAgentRuntimePolicy(agentEntry)?.id?.trim();
|
||||
if (agentRuntime) {
|
||||
return normalizeProviderId(agentRuntime);
|
||||
}
|
||||
}
|
||||
const defaults = params.cfg?.agents?.defaults?.embeddedHarness?.runtime?.trim();
|
||||
const defaults = resolveAgentRuntimePolicy(params.cfg?.agents?.defaults)?.id?.trim();
|
||||
if (defaults) {
|
||||
return normalizeProviderId(defaults);
|
||||
}
|
||||
|
||||
@@ -254,6 +254,7 @@ export function createOpenClawTools(
|
||||
agentChannel: options?.agentChannel,
|
||||
config: resolvedConfig,
|
||||
agentId: sessionAgentId,
|
||||
agentAccountId: options?.agentAccountId,
|
||||
}),
|
||||
...collectPresentOpenClawTools([imageGenerateTool, musicGenerateTool, videoGenerateTool]),
|
||||
...(embedded
|
||||
|
||||
@@ -201,6 +201,51 @@ describe("createOpenClawTools TTS config wiring", () => {
|
||||
__testing.setDepsForTest();
|
||||
}
|
||||
});
|
||||
|
||||
it("passes the active account id into the tts tool", async () => {
|
||||
const injectedConfig = {
|
||||
channels: {
|
||||
feishu: {
|
||||
accounts: {
|
||||
"feishu-main": {
|
||||
tts: {
|
||||
provider: "microsoft",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies OpenClawConfig;
|
||||
|
||||
const { __testing, createOpenClawTools } = await import("./openclaw-tools.js");
|
||||
__testing.setDepsForTest({ config: injectedConfig });
|
||||
|
||||
try {
|
||||
const tool = createOpenClawTools({
|
||||
agentChannel: "feishu",
|
||||
agentAccountId: "feishu-main",
|
||||
disableMessageTool: true,
|
||||
disablePluginTools: true,
|
||||
}).find((candidate) => candidate.name === "tts");
|
||||
|
||||
if (!tool) {
|
||||
throw new Error("missing tts tool");
|
||||
}
|
||||
|
||||
await tool.execute("call-1", { text: "hello from account" });
|
||||
|
||||
expect(mocks.textToSpeech).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
text: "hello from account",
|
||||
cfg: injectedConfig,
|
||||
channel: "feishu",
|
||||
accountId: "feishu-main",
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
__testing.setDepsForTest();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("createOpenClawTools cron context wiring", () => {
|
||||
|
||||
@@ -264,7 +264,7 @@ describe("runEmbeddedPiAgent overflow compaction trigger routing", () => {
|
||||
config: {
|
||||
agents: {
|
||||
defaults: {
|
||||
embeddedHarness: { runtime: "codex", fallback: "none" },
|
||||
agentRuntime: { id: "codex", fallback: "none" },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -336,7 +336,7 @@ describe("runEmbeddedPiAgent overflow compaction trigger routing", () => {
|
||||
config: {
|
||||
agents: {
|
||||
defaults: {
|
||||
embeddedHarness: { runtime: "codex", fallback: "none" },
|
||||
agentRuntime: { id: "codex", fallback: "none" },
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -105,6 +105,60 @@ describe("wrapStreamFnWithDiagnosticModelCallEvents", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("propagates the trusted model-call traceparent without mutating caller headers", async () => {
|
||||
async function* stream() {
|
||||
yield { type: "text", text: "ok" };
|
||||
}
|
||||
const capturedOptions: Array<Parameters<StreamFn>[2]> = [];
|
||||
const callerOptions = {
|
||||
headers: {
|
||||
"X-Custom": "kept",
|
||||
TraceParent: "00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01",
|
||||
},
|
||||
sessionId: "provider-session",
|
||||
};
|
||||
const wrapped = wrapStreamFnWithDiagnosticModelCallEvents(
|
||||
((
|
||||
_model: Parameters<StreamFn>[0],
|
||||
_context: Parameters<StreamFn>[1],
|
||||
options: Parameters<StreamFn>[2],
|
||||
) => {
|
||||
capturedOptions.push(options);
|
||||
return stream();
|
||||
}) as unknown as StreamFn,
|
||||
{
|
||||
runId: "run-1",
|
||||
provider: "openai",
|
||||
model: "gpt-5.4",
|
||||
trace: createDiagnosticTraceContext({
|
||||
traceId: "4bf92f3577b34da6a3ce929d0e0e4736",
|
||||
spanId: "00f067aa0ba902b7",
|
||||
traceFlags: "01",
|
||||
}),
|
||||
nextCallId: () => "call-traceparent",
|
||||
},
|
||||
);
|
||||
|
||||
await drain(
|
||||
wrapped({} as never, {} as never, callerOptions) as unknown as AsyncIterable<unknown>,
|
||||
);
|
||||
|
||||
expect(capturedOptions).toHaveLength(1);
|
||||
expect(capturedOptions[0]).not.toBe(callerOptions);
|
||||
expect(capturedOptions[0]).toMatchObject({
|
||||
sessionId: "provider-session",
|
||||
headers: {
|
||||
"X-Custom": "kept",
|
||||
traceparent: expect.stringMatching(/^00-4bf92f3577b34da6a3ce929d0e0e4736-[0-9a-f]{16}-01$/),
|
||||
},
|
||||
});
|
||||
expect(capturedOptions[0]?.headers).not.toHaveProperty("TraceParent");
|
||||
expect(callerOptions.headers).toEqual({
|
||||
"X-Custom": "kept",
|
||||
TraceParent: "00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01",
|
||||
});
|
||||
});
|
||||
|
||||
it("emits error events when stream iteration fails", async () => {
|
||||
const requestId = "req_provider_123";
|
||||
const stream = {
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import {
|
||||
createChildDiagnosticTraceContext,
|
||||
freezeDiagnosticTraceContext,
|
||||
formatDiagnosticTraceparent,
|
||||
type DiagnosticTraceContext,
|
||||
} from "../../../infra/diagnostic-trace-context.js";
|
||||
import { getGlobalHookRunner } from "../../../plugins/hook-runner-global.js";
|
||||
@@ -48,6 +49,8 @@ type ModelCallEndedHookFields = Pick<
|
||||
>;
|
||||
|
||||
const MODEL_CALL_STREAM_RETURN_TIMEOUT_MS = 1000;
|
||||
const TRACEPARENT_HEADER_NAME = "traceparent";
|
||||
type ModelCallStreamOptions = Parameters<StreamFn>[2];
|
||||
|
||||
function isPromiseLike(value: unknown): value is PromiseLike<unknown> {
|
||||
if (value === null || (typeof value !== "object" && typeof value !== "function")) {
|
||||
@@ -197,6 +200,29 @@ function emitModelCallError(
|
||||
});
|
||||
}
|
||||
|
||||
function withDiagnosticTraceparentHeader(
|
||||
options: ModelCallStreamOptions,
|
||||
trace: DiagnosticTraceContext,
|
||||
): ModelCallStreamOptions {
|
||||
const traceparent = formatDiagnosticTraceparent(trace);
|
||||
if (!traceparent) {
|
||||
return options;
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(options?.headers ?? {})) {
|
||||
if (key.toLowerCase() === TRACEPARENT_HEADER_NAME) {
|
||||
continue;
|
||||
}
|
||||
headers[key] = value;
|
||||
}
|
||||
headers[TRACEPARENT_HEADER_NAME] = traceparent;
|
||||
return {
|
||||
...(options ?? {}),
|
||||
headers,
|
||||
};
|
||||
}
|
||||
|
||||
async function safeReturnIterator(iterator: AsyncIterator<unknown>): Promise<void> {
|
||||
let returnResult: unknown;
|
||||
try {
|
||||
@@ -316,9 +342,10 @@ export function wrapStreamFnWithDiagnosticModelCallEvents(
|
||||
const eventBase = baseModelCallEvent(ctx, callId, trace);
|
||||
emitModelCallStarted(eventBase);
|
||||
const startedAt = Date.now();
|
||||
const propagatedOptions = withDiagnosticTraceparentHeader(options, trace);
|
||||
|
||||
try {
|
||||
const result = streamFn(model, streamContext, options);
|
||||
const result = streamFn(model, streamContext, propagatedOptions);
|
||||
if (isPromiseLike(result)) {
|
||||
return result.then(
|
||||
(resolved) => observeModelCallResult(resolved, eventBase, startedAt),
|
||||
|
||||
@@ -115,6 +115,48 @@ async function deliverDiscordDirectMessageCompletion(params: {
|
||||
});
|
||||
}
|
||||
|
||||
async function deliverTelegramDirectMessageCompletion(params: {
|
||||
callGateway: typeof runtimeCallGateway;
|
||||
sendMessage?: typeof runtimeSendMessage;
|
||||
internalEvents?: AgentInternalEvent[];
|
||||
isActive?: boolean;
|
||||
queueEmbeddedPiMessage?: (sessionId: string, message: string) => boolean;
|
||||
}) {
|
||||
const origin = {
|
||||
channel: "telegram",
|
||||
to: "123456789",
|
||||
accountId: "bot-1",
|
||||
};
|
||||
__testing.setDepsForTest({
|
||||
callGateway: params.callGateway,
|
||||
getRequesterSessionActivity: () => ({
|
||||
sessionId: "requester-session-telegram",
|
||||
isActive: params.isActive === true,
|
||||
}),
|
||||
loadConfig: () => ({}) as never,
|
||||
...(params.queueEmbeddedPiMessage
|
||||
? { queueEmbeddedPiMessage: params.queueEmbeddedPiMessage }
|
||||
: {}),
|
||||
...(params.sendMessage ? { sendMessage: params.sendMessage } : {}),
|
||||
});
|
||||
|
||||
return deliverSubagentAnnouncement({
|
||||
requesterSessionKey: "agent:main:telegram:123456789",
|
||||
targetRequesterSessionKey: "agent:main:telegram:123456789",
|
||||
triggerMessage: "child done",
|
||||
steerMessage: "child done",
|
||||
requesterOrigin: origin,
|
||||
requesterSessionOrigin: origin,
|
||||
completionDirectOrigin: origin,
|
||||
directOrigin: origin,
|
||||
requesterIsSubagent: false,
|
||||
expectsCompletionMessage: true,
|
||||
bestEffortDeliver: true,
|
||||
directIdempotencyKey: "announce-telegram-dm-fallback",
|
||||
internalEvents: params.internalEvents,
|
||||
});
|
||||
}
|
||||
|
||||
async function deliverSlackChannelAnnouncement(params: {
|
||||
callGateway: typeof runtimeCallGateway;
|
||||
isActive: boolean;
|
||||
@@ -510,6 +552,92 @@ describe("deliverSubagentAnnouncement completion delivery", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("uses direct fallback for Telegram DMs when announce-agent delivery fails", async () => {
|
||||
const callGateway = vi.fn(async () => {
|
||||
throw new Error("UNAVAILABLE: requester wake failed");
|
||||
}) as unknown as typeof runtimeCallGateway;
|
||||
const sendMessage = createSendMessageMock();
|
||||
const result = await deliverTelegramDirectMessageCompletion({
|
||||
callGateway,
|
||||
sendMessage,
|
||||
internalEvents: [
|
||||
{
|
||||
type: "task_completion",
|
||||
source: "subagent",
|
||||
childSessionKey: "agent:worker:subagent:child",
|
||||
childSessionId: "child-session-id",
|
||||
announceType: "subagent task",
|
||||
taskLabel: "telegram completion smoke",
|
||||
status: "ok",
|
||||
statusLabel: "completed successfully",
|
||||
result: "child completion output",
|
||||
replyInstruction: "Summarize the result.",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
delivered: true,
|
||||
path: "direct-fallback",
|
||||
}),
|
||||
);
|
||||
expect(sendMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
channel: "telegram",
|
||||
accountId: "bot-1",
|
||||
to: "123456789",
|
||||
threadId: undefined,
|
||||
content: "child completion output",
|
||||
requesterSessionKey: "agent:main:telegram:123456789",
|
||||
bestEffort: true,
|
||||
idempotencyKey: "announce-telegram-dm-fallback",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses direct fallback when an active Telegram requester cannot be woken", async () => {
|
||||
const callGateway = createGatewayMock();
|
||||
const sendMessage = createSendMessageMock();
|
||||
const queueEmbeddedPiMessage = vi.fn(() => false);
|
||||
const result = await deliverTelegramDirectMessageCompletion({
|
||||
callGateway,
|
||||
sendMessage,
|
||||
isActive: true,
|
||||
queueEmbeddedPiMessage,
|
||||
internalEvents: [
|
||||
{
|
||||
type: "task_completion",
|
||||
source: "subagent",
|
||||
childSessionKey: "agent:worker:subagent:child",
|
||||
childSessionId: "child-session-id",
|
||||
announceType: "subagent task",
|
||||
taskLabel: "telegram wake smoke",
|
||||
status: "ok",
|
||||
statusLabel: "completed successfully",
|
||||
result: "child completion output",
|
||||
replyInstruction: "Summarize the result.",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
delivered: true,
|
||||
path: "direct-fallback",
|
||||
}),
|
||||
);
|
||||
expect(queueEmbeddedPiMessage).toHaveBeenCalledWith("requester-session-telegram", "child done");
|
||||
expect(callGateway).not.toHaveBeenCalled();
|
||||
expect(sendMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
channel: "telegram",
|
||||
to: "123456789",
|
||||
content: "child completion output",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses a direct thread fallback when announce-agent returns no visible output", async () => {
|
||||
const callGateway = createGatewayMock({
|
||||
result: {
|
||||
|
||||
@@ -681,6 +681,10 @@ async function sendSubagentAnnounceDirectly(params: {
|
||||
isGatewayMessageChannel(normalizedSessionOnlyOriginChannel)
|
||||
? normalizedSessionOnlyOriginChannel
|
||||
: undefined;
|
||||
const completionFallbackText =
|
||||
params.expectsCompletionMessage && deliveryTarget.deliver
|
||||
? extractThreadCompletionFallbackText(params.internalEvents)
|
||||
: "";
|
||||
const requesterActivity = resolveRequesterSessionActivity(canonicalRequesterSessionKey);
|
||||
if (params.expectsCompletionMessage && requesterActivity.sessionId) {
|
||||
const woke = requesterActivity.sessionId
|
||||
@@ -696,6 +700,32 @@ async function sendSubagentAnnounceDirectly(params: {
|
||||
};
|
||||
}
|
||||
if (requesterActivity.isActive) {
|
||||
try {
|
||||
const didFallback = await sendCompletionFallback({
|
||||
cfg,
|
||||
channel: deliveryTarget.channel,
|
||||
to: deliveryTarget.to,
|
||||
accountId: deliveryTarget.accountId,
|
||||
threadId: deliveryTarget.threadId,
|
||||
content: completionFallbackText,
|
||||
requesterSessionKey: canonicalRequesterSessionKey,
|
||||
bestEffortDeliver: params.bestEffortDeliver,
|
||||
idempotencyKey: params.directIdempotencyKey,
|
||||
signal: params.signal,
|
||||
});
|
||||
if (didFallback) {
|
||||
return {
|
||||
delivered: true,
|
||||
path: resolveCompletionFallbackPath(deliveryTarget.threadId),
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
return {
|
||||
delivered: false,
|
||||
path: "direct",
|
||||
error: `active requester session could not be woken; fallback send failed: ${summarizeDeliveryError(err)}`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
delivered: false,
|
||||
path: "direct",
|
||||
@@ -709,10 +739,6 @@ async function sendSubagentAnnounceDirectly(params: {
|
||||
path: "none",
|
||||
};
|
||||
}
|
||||
const completionFallbackText =
|
||||
params.expectsCompletionMessage && deliveryTarget.deliver
|
||||
? extractThreadCompletionFallbackText(params.internalEvents)
|
||||
: "";
|
||||
let directAnnounceResponse: unknown;
|
||||
try {
|
||||
directAnnounceResponse = await runAnnounceDeliveryWithRetry({
|
||||
@@ -758,22 +784,30 @@ async function sendSubagentAnnounceDirectly(params: {
|
||||
}),
|
||||
});
|
||||
} catch (err) {
|
||||
const didFallback = await sendCompletionFallback({
|
||||
cfg,
|
||||
channel: deliveryTarget.channel,
|
||||
to: deliveryTarget.to,
|
||||
accountId: deliveryTarget.accountId,
|
||||
threadId: deliveryTarget.threadId,
|
||||
content: deliveryTarget.threadId ? completionFallbackText : "",
|
||||
requesterSessionKey: canonicalRequesterSessionKey,
|
||||
bestEffortDeliver: params.bestEffortDeliver,
|
||||
idempotencyKey: params.directIdempotencyKey,
|
||||
signal: params.signal,
|
||||
});
|
||||
let didFallback = false;
|
||||
try {
|
||||
didFallback = await sendCompletionFallback({
|
||||
cfg,
|
||||
channel: deliveryTarget.channel,
|
||||
to: deliveryTarget.to,
|
||||
accountId: deliveryTarget.accountId,
|
||||
threadId: deliveryTarget.threadId,
|
||||
content: completionFallbackText,
|
||||
requesterSessionKey: canonicalRequesterSessionKey,
|
||||
bestEffortDeliver: params.bestEffortDeliver,
|
||||
idempotencyKey: params.directIdempotencyKey,
|
||||
signal: params.signal,
|
||||
});
|
||||
} catch (fallbackErr) {
|
||||
throw new Error(
|
||||
`${summarizeDeliveryError(err)}; fallback send failed: ${summarizeDeliveryError(fallbackErr)}`,
|
||||
{ cause: fallbackErr },
|
||||
);
|
||||
}
|
||||
if (didFallback) {
|
||||
return {
|
||||
delivered: true,
|
||||
path: "direct-thread-fallback",
|
||||
path: resolveCompletionFallbackPath(deliveryTarget.threadId),
|
||||
};
|
||||
}
|
||||
throw err;
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
resolveSubagentAnnounceTimeoutMs,
|
||||
resolveSubagentCompletionOrigin,
|
||||
} from "./subagent-announce-delivery.js";
|
||||
import type { SubagentAnnounceDeliveryResult } from "./subagent-announce-dispatch.js";
|
||||
import { resolveAnnounceOrigin } from "./subagent-announce-origin.js";
|
||||
import {
|
||||
applySubagentWaitOutcome,
|
||||
@@ -244,6 +245,7 @@ export async function runSubagentAnnounceFlow(params: {
|
||||
wakeOnDescendantSettle?: boolean;
|
||||
signal?: AbortSignal;
|
||||
bestEffortDeliver?: boolean;
|
||||
onDeliveryResult?: (delivery: SubagentAnnounceDeliveryResult) => void;
|
||||
}): Promise<boolean> {
|
||||
let didAnnounce = false;
|
||||
const expectsCompletionMessage = params.expectsCompletionMessage === true;
|
||||
@@ -562,6 +564,7 @@ export async function runSubagentAnnounceFlow(params: {
|
||||
directIdempotencyKey,
|
||||
signal: params.signal,
|
||||
});
|
||||
params.onDeliveryResult?.(delivery);
|
||||
didAnnounce = delivery.delivered;
|
||||
if (!delivery.delivered && delivery.path === "direct" && delivery.error) {
|
||||
defaultRuntime.error?.(
|
||||
|
||||
@@ -569,6 +569,82 @@ describe("subagent registry lifecycle hardening", () => {
|
||||
expect(persist).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("persists the concrete announce delivery error when cleanup gives up", async () => {
|
||||
const persist = vi.fn();
|
||||
const entry = createRunEntry({
|
||||
endedAt: 4_000,
|
||||
expectsCompletionMessage: true,
|
||||
retainAttachmentsOnKeep: true,
|
||||
});
|
||||
const runSubagentAnnounceFlow = vi.fn(
|
||||
async (announceParams: {
|
||||
onDeliveryResult?: (delivery: {
|
||||
delivered: false;
|
||||
path: "direct";
|
||||
error: string;
|
||||
phases: Array<{
|
||||
phase: "direct-primary" | "queue-fallback";
|
||||
delivered: boolean;
|
||||
path: "direct" | "none";
|
||||
error?: string;
|
||||
}>;
|
||||
}) => void;
|
||||
}) => {
|
||||
announceParams.onDeliveryResult?.({
|
||||
delivered: false,
|
||||
path: "direct",
|
||||
error: "UNAVAILABLE: requester wake failed",
|
||||
phases: [
|
||||
{
|
||||
phase: "direct-primary",
|
||||
delivered: false,
|
||||
path: "direct",
|
||||
error: "UNAVAILABLE: requester wake failed",
|
||||
},
|
||||
{
|
||||
phase: "queue-fallback",
|
||||
delivered: false,
|
||||
path: "none",
|
||||
},
|
||||
],
|
||||
});
|
||||
return false;
|
||||
},
|
||||
);
|
||||
|
||||
const controller = createLifecycleController({
|
||||
entry,
|
||||
persist,
|
||||
runSubagentAnnounceFlow,
|
||||
});
|
||||
|
||||
await expect(
|
||||
controller.completeSubagentRun({
|
||||
runId: entry.runId,
|
||||
endedAt: 4_000,
|
||||
outcome: { status: "ok" },
|
||||
reason: SUBAGENT_ENDED_REASON_COMPLETE,
|
||||
triggerCleanup: true,
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(taskExecutorMocks.setDetachedTaskDeliveryStatusByRunId).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
runId: entry.runId,
|
||||
runtime: "subagent",
|
||||
sessionKey: entry.childSessionKey,
|
||||
deliveryStatus: "failed",
|
||||
error:
|
||||
"UNAVAILABLE: requester wake failed; direct-primary: UNAVAILABLE: requester wake failed",
|
||||
}),
|
||||
);
|
||||
expect(entry.lastAnnounceDeliveryError).toBe(
|
||||
"UNAVAILABLE: requester wake failed; direct-primary: UNAVAILABLE: requester wake failed",
|
||||
);
|
||||
expect(entry.cleanupCompletedAt).toBeTypeOf("number");
|
||||
expect(persist).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips browser cleanup when steer restart suppresses cleanup flow", async () => {
|
||||
const entry = createRunEntry({
|
||||
expectsCompletionMessage: false,
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from "../tasks/detached-task-runtime.js";
|
||||
import { normalizeDeliveryContext } from "../utils/delivery-context.shared.js";
|
||||
import { retireSessionMcpRuntimeForSessionKey } from "./pi-bundle-mcp-tools.js";
|
||||
import type { SubagentAnnounceDeliveryResult } from "./subagent-announce-dispatch.js";
|
||||
import { type SubagentRunOutcome, withSubagentOutcomeTiming } from "./subagent-announce-output.js";
|
||||
import {
|
||||
SUBAGENT_ENDED_REASON_COMPLETE,
|
||||
@@ -126,10 +127,25 @@ export function createSubagentRegistryLifecycleController(params: {
|
||||
return name ? { name, message } : { message };
|
||||
};
|
||||
|
||||
const formatAnnounceDeliveryError = (delivery: SubagentAnnounceDeliveryResult): string => {
|
||||
const errors = [
|
||||
delivery.error,
|
||||
...(delivery.phases ?? []).map((phase) =>
|
||||
phase.error ? `${phase.phase}: ${phase.error}` : undefined,
|
||||
),
|
||||
]
|
||||
.map((value) => value?.trim())
|
||||
.filter((value): value is string => Boolean(value));
|
||||
return errors.length > 0
|
||||
? [...new Set(errors)].join("; ")
|
||||
: `delivery path ${delivery.path} did not complete`;
|
||||
};
|
||||
|
||||
const safeSetSubagentTaskDeliveryStatus = (args: {
|
||||
runId: string;
|
||||
childSessionKey: string;
|
||||
deliveryStatus: "delivered" | "failed";
|
||||
deliveryError?: string;
|
||||
}) => {
|
||||
try {
|
||||
setDetachedTaskDeliveryStatusByRunId({
|
||||
@@ -137,6 +153,7 @@ export function createSubagentRegistryLifecycleController(params: {
|
||||
runtime: "subagent",
|
||||
sessionKey: args.childSessionKey,
|
||||
deliveryStatus: args.deliveryStatus,
|
||||
error: args.deliveryStatus === "failed" ? args.deliveryError : undefined,
|
||||
});
|
||||
} catch (err) {
|
||||
params.warn("failed to update subagent background task delivery state", {
|
||||
@@ -301,6 +318,7 @@ export function createSubagentRegistryLifecycleController(params: {
|
||||
runId: giveUpParams.runId,
|
||||
childSessionKey: giveUpParams.entry.childSessionKey,
|
||||
deliveryStatus: "failed",
|
||||
deliveryError: giveUpParams.entry.lastAnnounceDeliveryError,
|
||||
});
|
||||
giveUpParams.entry.wakeOnDescendantSettle = undefined;
|
||||
giveUpParams.entry.fallbackFrozenResultText = undefined;
|
||||
@@ -464,6 +482,7 @@ export function createSubagentRegistryLifecycleController(params: {
|
||||
childSessionKey: entry.childSessionKey,
|
||||
deliveryStatus: "delivered",
|
||||
});
|
||||
entry.lastAnnounceDeliveryError = undefined;
|
||||
entry.wakeOnDescendantSettle = undefined;
|
||||
entry.fallbackFrozenResultText = undefined;
|
||||
entry.fallbackFrozenResultCapturedAt = undefined;
|
||||
@@ -518,6 +537,7 @@ export function createSubagentRegistryLifecycleController(params: {
|
||||
runId,
|
||||
childSessionKey: entry.childSessionKey,
|
||||
deliveryStatus: "failed",
|
||||
deliveryError: entry.lastAnnounceDeliveryError,
|
||||
});
|
||||
entry.wakeOnDescendantSettle = undefined;
|
||||
entry.fallbackFrozenResultText = undefined;
|
||||
@@ -571,7 +591,11 @@ export function createSubagentRegistryLifecycleController(params: {
|
||||
return false;
|
||||
}
|
||||
const requesterOrigin = normalizeDeliveryContext(entry.requesterOrigin);
|
||||
let latestDeliveryError = entry.lastAnnounceDeliveryError;
|
||||
const finalizeAnnounceCleanup = (didAnnounce: boolean) => {
|
||||
if (!didAnnounce && latestDeliveryError) {
|
||||
entry.lastAnnounceDeliveryError = latestDeliveryError;
|
||||
}
|
||||
void finalizeSubagentCleanup(runId, entry.cleanup, didAnnounce).catch((err) => {
|
||||
defaultRuntime.log(`[warn] subagent cleanup finalize failed (${runId}): ${String(err)}`);
|
||||
const current = params.runs.get(runId);
|
||||
@@ -603,6 +627,21 @@ export function createSubagentRegistryLifecycleController(params: {
|
||||
spawnMode: entry.spawnMode,
|
||||
expectsCompletionMessage: entry.expectsCompletionMessage,
|
||||
wakeOnDescendantSettle: entry.wakeOnDescendantSettle === true,
|
||||
onDeliveryResult: (delivery) => {
|
||||
if (delivery.delivered) {
|
||||
if (entry.lastAnnounceDeliveryError !== undefined) {
|
||||
entry.lastAnnounceDeliveryError = undefined;
|
||||
params.persist();
|
||||
}
|
||||
latestDeliveryError = undefined;
|
||||
return;
|
||||
}
|
||||
latestDeliveryError = formatAnnounceDeliveryError(delivery);
|
||||
if (entry.lastAnnounceDeliveryError !== latestDeliveryError) {
|
||||
entry.lastAnnounceDeliveryError = latestDeliveryError;
|
||||
params.persist();
|
||||
}
|
||||
},
|
||||
})
|
||||
.then((didAnnounce) => {
|
||||
finalizeAnnounceCleanup(didAnnounce);
|
||||
|
||||
@@ -30,6 +30,7 @@ export type SubagentRunRecord = {
|
||||
expectsCompletionMessage?: boolean;
|
||||
announceRetryCount?: number;
|
||||
lastAnnounceRetryAt?: number;
|
||||
lastAnnounceDeliveryError?: string;
|
||||
endedReason?: SubagentLifecycleEndedReason;
|
||||
wakeOnDescendantSettle?: boolean;
|
||||
frozenResultText?: string | null;
|
||||
|
||||
@@ -18,12 +18,12 @@ describe("agents_list tool", () => {
|
||||
loadConfigMock.mockReset();
|
||||
});
|
||||
|
||||
it("returns model and embedded harness metadata for allowed agents", async () => {
|
||||
it("returns model and agent runtime metadata for allowed agents", async () => {
|
||||
loadConfigMock.mockReturnValue({
|
||||
agents: {
|
||||
defaults: {
|
||||
model: "anthropic/claude-opus-4.5",
|
||||
embeddedHarness: { runtime: "pi", fallback: "pi" },
|
||||
agentRuntime: { id: "pi", fallback: "pi" },
|
||||
subagents: { allowAgents: ["codex"] },
|
||||
},
|
||||
list: [
|
||||
@@ -32,7 +32,7 @@ describe("agents_list tool", () => {
|
||||
id: "codex",
|
||||
name: "Codex",
|
||||
model: "openai/gpt-5.5",
|
||||
embeddedHarness: { runtime: "codex", fallback: "none" },
|
||||
agentRuntime: { id: "codex", fallback: "none" },
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -51,14 +51,14 @@ describe("agents_list tool", () => {
|
||||
id: "main",
|
||||
configured: true,
|
||||
model: "anthropic/claude-opus-4.5",
|
||||
embeddedHarness: { runtime: "pi", source: "defaults" },
|
||||
agentRuntime: { id: "pi", source: "defaults" },
|
||||
},
|
||||
{
|
||||
id: "codex",
|
||||
name: "Codex",
|
||||
configured: true,
|
||||
model: "openai/gpt-5.5",
|
||||
embeddedHarness: { runtime: "codex", fallback: "none", source: "agent" },
|
||||
agentRuntime: { id: "codex", fallback: "none", source: "agent" },
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -85,7 +85,7 @@ describe("agents_list tool", () => {
|
||||
agents: [
|
||||
{
|
||||
id: "main",
|
||||
embeddedHarness: { runtime: "codex", source: "env" },
|
||||
agentRuntime: { id: "codex", source: "env" },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
normalizeAgentId,
|
||||
parseAgentSessionKey,
|
||||
} from "../../routing/session-key.js";
|
||||
import { resolveAgentRuntimePolicy } from "../agent-runtime-policy.js";
|
||||
import {
|
||||
listAgentEntries,
|
||||
resolveAgentConfig,
|
||||
@@ -21,8 +22,8 @@ type AgentListEntry = {
|
||||
name?: string;
|
||||
configured: boolean;
|
||||
model?: string;
|
||||
embeddedHarness?: {
|
||||
runtime: string;
|
||||
agentRuntime?: {
|
||||
id: string;
|
||||
fallback?: "pi" | "none";
|
||||
source: "env" | "agent" | "defaults" | "implicit";
|
||||
};
|
||||
@@ -32,39 +33,41 @@ function normalizeRuntimeValue(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim() ? value.trim().toLowerCase() : undefined;
|
||||
}
|
||||
|
||||
function resolveAgentEmbeddedHarnessMetadata(
|
||||
function resolveAgentRuntimeMetadata(
|
||||
cfg: ReturnType<typeof loadConfig>,
|
||||
agentId: string,
|
||||
): AgentListEntry["embeddedHarness"] {
|
||||
): NonNullable<AgentListEntry["agentRuntime"]> {
|
||||
const envRuntime = normalizeRuntimeValue(process.env.OPENCLAW_AGENT_RUNTIME);
|
||||
if (envRuntime) {
|
||||
return {
|
||||
runtime: envRuntime,
|
||||
id: envRuntime,
|
||||
source: "env",
|
||||
};
|
||||
}
|
||||
|
||||
const agentEntry = listAgentEntries(cfg).find((entry) => normalizeAgentId(entry.id) === agentId);
|
||||
const agentRuntime = normalizeRuntimeValue(agentEntry?.embeddedHarness?.runtime);
|
||||
const agentPolicy = resolveAgentRuntimePolicy(agentEntry);
|
||||
const agentRuntime = normalizeRuntimeValue(agentPolicy?.id);
|
||||
if (agentRuntime) {
|
||||
return {
|
||||
runtime: agentRuntime,
|
||||
fallback: agentEntry?.embeddedHarness?.fallback,
|
||||
id: agentRuntime,
|
||||
fallback: agentPolicy?.fallback,
|
||||
source: "agent",
|
||||
};
|
||||
}
|
||||
|
||||
const defaultsRuntime = normalizeRuntimeValue(cfg.agents?.defaults?.embeddedHarness?.runtime);
|
||||
const defaultsPolicy = resolveAgentRuntimePolicy(cfg.agents?.defaults);
|
||||
const defaultsRuntime = normalizeRuntimeValue(defaultsPolicy?.id);
|
||||
if (defaultsRuntime) {
|
||||
return {
|
||||
runtime: defaultsRuntime,
|
||||
fallback: cfg.agents?.defaults?.embeddedHarness?.fallback,
|
||||
id: defaultsRuntime,
|
||||
fallback: defaultsPolicy?.fallback,
|
||||
source: "defaults",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
runtime: "pi",
|
||||
id: "pi",
|
||||
source: "implicit",
|
||||
};
|
||||
}
|
||||
@@ -136,13 +139,16 @@ export function createAgentsListTool(opts?: {
|
||||
.filter((id) => id !== requesterAgentId)
|
||||
.toSorted((a, b) => a.localeCompare(b));
|
||||
const ordered = [requesterAgentId, ...rest];
|
||||
const agents: AgentListEntry[] = ordered.map((id) => ({
|
||||
id,
|
||||
name: configuredNameMap.get(id),
|
||||
configured: configuredIds.includes(id),
|
||||
model: resolveAgentEffectiveModelPrimary(cfg, id),
|
||||
embeddedHarness: resolveAgentEmbeddedHarnessMetadata(cfg, id),
|
||||
}));
|
||||
const agents: AgentListEntry[] = ordered.map((id) => {
|
||||
const agentRuntime = resolveAgentRuntimeMetadata(cfg, id);
|
||||
return {
|
||||
id,
|
||||
name: configuredNameMap.get(id),
|
||||
configured: configuredIds.includes(id),
|
||||
model: resolveAgentEffectiveModelPrimary(cfg, id),
|
||||
agentRuntime,
|
||||
};
|
||||
});
|
||||
|
||||
return jsonResult({
|
||||
requester: requesterAgentId,
|
||||
|
||||
@@ -104,6 +104,25 @@ describe("createTtsTool", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("passes the active account id to speech generation", async () => {
|
||||
textToSpeechSpy.mockResolvedValue({
|
||||
success: true,
|
||||
audioPath: "/tmp/reply.opus",
|
||||
provider: "test",
|
||||
voiceCompatible: true,
|
||||
});
|
||||
|
||||
const tool = createTtsTool({ agentAccountId: "feishu-main" });
|
||||
await tool.execute("call-1", { text: "hello" });
|
||||
|
||||
expect(textToSpeechSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
text: "hello",
|
||||
accountId: "feishu-main",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("echoes longer utterances verbatim into the tool-result content", async () => {
|
||||
textToSpeechSpy.mockResolvedValue({
|
||||
success: true,
|
||||
|
||||
@@ -58,6 +58,7 @@ export function createTtsTool(opts?: {
|
||||
config?: OpenClawConfig;
|
||||
agentChannel?: GatewayMessageChannel;
|
||||
agentId?: string;
|
||||
agentAccountId?: string;
|
||||
}): AnyAgentTool {
|
||||
return {
|
||||
label: "TTS",
|
||||
@@ -77,6 +78,7 @@ export function createTtsTool(opts?: {
|
||||
channel: channel ?? opts?.agentChannel,
|
||||
timeoutMs,
|
||||
agentId: opts?.agentId,
|
||||
accountId: opts?.agentAccountId,
|
||||
});
|
||||
|
||||
if (result.success && result.audioPath) {
|
||||
|
||||
@@ -417,6 +417,50 @@ describe("runAgentTurnWithFallback", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("does not pass CLI runtime overrides as embedded harness ids for fallback providers", async () => {
|
||||
state.isCliProviderMock.mockImplementation((provider: unknown) => provider === "claude-cli");
|
||||
state.runWithModelFallbackMock.mockImplementationOnce(async (params: FallbackRunnerParams) => ({
|
||||
result: await params.run("openai", "gpt-5.4"),
|
||||
provider: "openai",
|
||||
model: "gpt-5.4",
|
||||
attempts: [],
|
||||
}));
|
||||
state.runEmbeddedPiAgentMock.mockResolvedValueOnce({
|
||||
payloads: [{ text: "fallback" }],
|
||||
meta: {},
|
||||
});
|
||||
|
||||
const runAgentTurnWithFallback = await getRunAgentTurnWithFallback();
|
||||
const followupRun = createFollowupRun();
|
||||
followupRun.run.provider = "anthropic";
|
||||
followupRun.run.model = "claude-opus-4-7";
|
||||
followupRun.run.config = {
|
||||
agents: {
|
||||
defaults: {
|
||||
agentRuntime: { id: "claude-cli", fallback: "none" },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = await runAgentTurnWithFallback({
|
||||
...createMinimalRunAgentTurnParams({ followupRun }),
|
||||
getActiveSessionEntry: () =>
|
||||
({
|
||||
sessionId: "session",
|
||||
updatedAt: Date.now(),
|
||||
agentRuntimeOverride: "claude-cli",
|
||||
}) as SessionEntry,
|
||||
});
|
||||
|
||||
expect(result.kind).toBe("success");
|
||||
expect(state.runCliAgentMock).not.toHaveBeenCalled();
|
||||
expect(state.runEmbeddedPiAgentMock).toHaveBeenCalledOnce();
|
||||
expect(state.runEmbeddedPiAgentMock.mock.calls[0]?.[0]).not.toHaveProperty(
|
||||
"agentHarnessId",
|
||||
"claude-cli",
|
||||
);
|
||||
});
|
||||
|
||||
it("forwards media-only tool results without typing text", async () => {
|
||||
const onToolResult = vi.fn();
|
||||
state.runEmbeddedPiAgentMock.mockImplementationOnce(async (params: EmbeddedAgentParams) => {
|
||||
|
||||
@@ -13,7 +13,10 @@ import { runCliAgent } from "../../agents/cli-runner.js";
|
||||
import { getCliSessionBinding } from "../../agents/cli-session.js";
|
||||
import { LiveSessionModelSwitchError } from "../../agents/live-model-switch-error.js";
|
||||
import { runWithModelFallback, isFallbackSummaryError } from "../../agents/model-fallback.js";
|
||||
import { resolveCliRuntimeExecutionProvider } from "../../agents/model-runtime-aliases.js";
|
||||
import {
|
||||
isCliRuntimeAlias,
|
||||
resolveCliRuntimeExecutionProvider,
|
||||
} from "../../agents/model-runtime-aliases.js";
|
||||
import { isCliProvider } from "../../agents/model-selection.js";
|
||||
import {
|
||||
BILLING_ERROR_USER_MESSAGE,
|
||||
@@ -1122,7 +1125,8 @@ export async function runAgentTurnWithFallback(params: {
|
||||
...runBaseParams,
|
||||
...(agentRuntimeOverride &&
|
||||
agentRuntimeOverride !== "auto" &&
|
||||
agentRuntimeOverride !== "default"
|
||||
agentRuntimeOverride !== "default" &&
|
||||
!isCliRuntimeAlias(agentRuntimeOverride)
|
||||
? { agentHarnessId: agentRuntimeOverride }
|
||||
: {}),
|
||||
sandboxSessionKey: params.runtimePolicySessionKey,
|
||||
|
||||
@@ -1667,7 +1667,7 @@ describe("runReplyAgent claude-cli routing", () => {
|
||||
messageProvider: "webchat",
|
||||
sessionFile: "/tmp/session.jsonl",
|
||||
workspaceDir: "/tmp",
|
||||
config: { agents: { defaults: { embeddedHarness: { runtime: "claude-cli" } } } },
|
||||
config: { agents: { defaults: { agentRuntime: { id: "claude-cli" } } } },
|
||||
skillsSnapshot: {},
|
||||
provider: "anthropic",
|
||||
model: "claude-opus-4-7",
|
||||
|
||||
@@ -489,7 +489,7 @@ describe("buildStatusReply subagent summary", () => {
|
||||
...baseCfg,
|
||||
agents: {
|
||||
defaults: {
|
||||
embeddedHarness: { runtime: "codex" },
|
||||
agentRuntime: { id: "codex" },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -529,7 +529,7 @@ describe("buildStatusReply subagent summary", () => {
|
||||
...baseCfg,
|
||||
agents: {
|
||||
defaults: {
|
||||
embeddedHarness: { runtime: "codex" },
|
||||
agentRuntime: { id: "codex" },
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -213,10 +213,13 @@ describe("handleTtsCommands status fallback reporting", () => {
|
||||
const result = await handleTtsCommands(buildTtsParams("/tts status", cfg, "reader"), true);
|
||||
|
||||
expect(result?.shouldContinue).toBe(false);
|
||||
expect(ttsMocks.resolveTtsConfig).toHaveBeenCalledWith(cfg, "reader");
|
||||
expect(ttsMocks.resolveTtsConfig).toHaveBeenCalledWith(
|
||||
cfg,
|
||||
expect.objectContaining({ agentId: "reader", channelId: "forum" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("passes the active agent id to /tts audio synthesis", async () => {
|
||||
it("passes the active agent and account ids to /tts audio synthesis", async () => {
|
||||
ttsMocks.textToSpeech.mockResolvedValue({
|
||||
success: true,
|
||||
audioPath: "/tmp/reader.ogg",
|
||||
@@ -227,7 +230,12 @@ describe("handleTtsCommands status fallback reporting", () => {
|
||||
agents: { list: [{ id: "reader", tts: { provider: PRIMARY_TTS_PROVIDER } }] },
|
||||
} as OpenClawConfig;
|
||||
|
||||
const result = await handleTtsCommands(buildTtsParams("/tts audio hello", cfg, "reader"), true);
|
||||
const result = await handleTtsCommands(
|
||||
buildTtsParams("/tts audio hello", cfg, "reader", {
|
||||
ctx: { AccountId: "feishu-main" },
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
expect(result?.shouldContinue).toBe(false);
|
||||
expect(ttsMocks.textToSpeech).toHaveBeenCalledWith(
|
||||
@@ -235,6 +243,7 @@ describe("handleTtsCommands status fallback reporting", () => {
|
||||
text: "hello",
|
||||
cfg,
|
||||
agentId: "reader",
|
||||
accountId: "feishu-main",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -119,6 +119,7 @@ async function buildTtsAudioReply(params: {
|
||||
text: string;
|
||||
cfg: Parameters<typeof textToSpeech>[0]["cfg"];
|
||||
channel: string;
|
||||
accountId?: string;
|
||||
prefsPath: string;
|
||||
agentId?: string;
|
||||
}): Promise<{ reply: ReplyPayload; provider?: string; hash?: string } | { error: string }> {
|
||||
@@ -127,6 +128,7 @@ async function buildTtsAudioReply(params: {
|
||||
text: params.text,
|
||||
cfg: params.cfg,
|
||||
channel: params.channel,
|
||||
accountId: params.accountId,
|
||||
prefsPath: params.prefsPath,
|
||||
agentId: params.agentId,
|
||||
});
|
||||
@@ -185,7 +187,12 @@ export const handleTtsCommands: CommandHandler = async (params, allowTextCommand
|
||||
return { shouldContinue: false };
|
||||
}
|
||||
|
||||
const config = resolveTtsConfig(params.cfg, params.agentId);
|
||||
const accountId = params.ctx?.AccountId;
|
||||
const config = resolveTtsConfig(params.cfg, {
|
||||
agentId: params.agentId,
|
||||
channelId: params.command.channel,
|
||||
accountId,
|
||||
});
|
||||
const prefsPath = resolveTtsPrefsPath(config);
|
||||
const action = parsed.action;
|
||||
const args = parsed.args;
|
||||
@@ -268,6 +275,7 @@ export const handleTtsCommands: CommandHandler = async (params, allowTextCommand
|
||||
text: latestText,
|
||||
cfg: params.cfg,
|
||||
channel: params.command.channel,
|
||||
accountId,
|
||||
prefsPath,
|
||||
agentId: params.agentId,
|
||||
});
|
||||
@@ -301,6 +309,7 @@ export const handleTtsCommands: CommandHandler = async (params, allowTextCommand
|
||||
text: args,
|
||||
cfg: params.cfg,
|
||||
channel: params.command.channel,
|
||||
accountId,
|
||||
prefsPath,
|
||||
agentId: params.agentId,
|
||||
});
|
||||
|
||||
@@ -91,6 +91,7 @@ async function maybeApplyAcpTts(params: {
|
||||
cfg: OpenClawConfig;
|
||||
agentId?: string;
|
||||
channel?: string;
|
||||
accountId?: string;
|
||||
kind: ReplyDispatchKind;
|
||||
inboundAudio: boolean;
|
||||
ttsAuto?: TtsAutoMode;
|
||||
@@ -103,6 +104,8 @@ async function maybeApplyAcpTts(params: {
|
||||
cfg: params.cfg,
|
||||
sessionAuto: params.ttsAuto,
|
||||
agentId: params.agentId,
|
||||
channelId: params.channel,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
if (!ttsStatus) {
|
||||
return params.payload;
|
||||
@@ -110,7 +113,14 @@ async function maybeApplyAcpTts(params: {
|
||||
if (ttsStatus.autoMode === "inbound" && !params.inboundAudio) {
|
||||
return params.payload;
|
||||
}
|
||||
if (params.kind !== "final" && resolveConfiguredTtsMode(params.cfg, params.agentId) === "final") {
|
||||
if (
|
||||
params.kind !== "final" &&
|
||||
resolveConfiguredTtsMode(params.cfg, {
|
||||
agentId: params.agentId,
|
||||
channelId: params.channel,
|
||||
accountId: params.accountId,
|
||||
}) === "final"
|
||||
) {
|
||||
return params.payload;
|
||||
}
|
||||
const { maybeApplyTtsToPayload } = await loadDispatchAcpTtsRuntime();
|
||||
@@ -122,6 +132,7 @@ async function maybeApplyAcpTts(params: {
|
||||
inboundAudio: params.inboundAudio,
|
||||
ttsAuto: params.ttsAuto,
|
||||
agentId: params.agentId,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -175,6 +186,17 @@ export function createAcpDispatchDeliveryCoordinator(params: {
|
||||
originatingTo?: string;
|
||||
onReplyStart?: () => Promise<void> | void;
|
||||
}): AcpDispatchDeliveryCoordinator {
|
||||
const directChannel = normalizeOptionalLowercaseString(params.ctx.Provider ?? params.ctx.Surface);
|
||||
const routedChannel = normalizeOptionalLowercaseString(params.originatingChannel);
|
||||
const deliverySessionKey = normalizeOptionalString(params.sessionKey) ?? params.ctx.SessionKey;
|
||||
const explicitAccountId = normalizeOptionalString(params.ctx.AccountId);
|
||||
const resolvedAccountId =
|
||||
explicitAccountId ??
|
||||
normalizeOptionalString(
|
||||
(
|
||||
params.cfg.channels as Record<string, { defaultAccount?: unknown } | undefined> | undefined
|
||||
)?.[routedChannel ?? directChannel ?? ""]?.defaultAccount,
|
||||
);
|
||||
const state: AcpDispatchDeliveryState = {
|
||||
startedReplyLifecycle: false,
|
||||
accumulatedBlockText: "",
|
||||
@@ -184,6 +206,8 @@ export function createAcpDispatchDeliveryCoordinator(params: {
|
||||
cfg: params.cfg,
|
||||
ttsAuto: params.sessionTtsAuto,
|
||||
agentId: params.agentId,
|
||||
channelId: params.ttsChannel,
|
||||
accountId: resolvedAccountId,
|
||||
})
|
||||
? createTtsDirectiveTextStreamCleaner()
|
||||
: undefined,
|
||||
@@ -200,18 +224,6 @@ export function createAcpDispatchDeliveryCoordinator(params: {
|
||||
},
|
||||
toolMessageByCallId: new Map(),
|
||||
};
|
||||
const directChannel = normalizeOptionalLowercaseString(params.ctx.Provider ?? params.ctx.Surface);
|
||||
const routedChannel = normalizeOptionalLowercaseString(params.originatingChannel);
|
||||
const deliverySessionKey = normalizeOptionalString(params.sessionKey) ?? params.ctx.SessionKey;
|
||||
const explicitAccountId = normalizeOptionalString(params.ctx.AccountId);
|
||||
const resolvedAccountId =
|
||||
explicitAccountId ??
|
||||
normalizeOptionalString(
|
||||
(
|
||||
params.cfg.channels as Record<string, { defaultAccount?: unknown } | undefined> | undefined
|
||||
)?.[routedChannel ?? directChannel ?? ""]?.defaultAccount,
|
||||
);
|
||||
|
||||
const settleDirectVisibleText = async () => {
|
||||
if (state.settledDirectVisibleText || state.queuedDirectVisibleTextDeliveries === 0) {
|
||||
return;
|
||||
@@ -336,6 +348,7 @@ export function createAcpDispatchDeliveryCoordinator(params: {
|
||||
cfg: params.cfg,
|
||||
agentId: params.agentId,
|
||||
channel: params.ttsChannel,
|
||||
accountId: resolvedAccountId,
|
||||
kind,
|
||||
inboundAudio: params.inboundAudio,
|
||||
ttsAuto: params.sessionTtsAuto,
|
||||
|
||||
@@ -191,12 +191,17 @@ async function finalizeAcpTurnOutput(params: {
|
||||
inboundAudio: boolean;
|
||||
sessionTtsAuto?: TtsAutoMode;
|
||||
ttsChannel?: string;
|
||||
ttsAccountId?: string;
|
||||
shouldEmitResolvedIdentityNotice: boolean;
|
||||
}): Promise<boolean> {
|
||||
await params.delivery.settleVisibleText();
|
||||
let queuedFinal =
|
||||
params.delivery.hasDeliveredVisibleText() && !params.delivery.hasFailedVisibleTextDelivery();
|
||||
const ttsMode = resolveConfiguredTtsMode(params.cfg, params.agentId);
|
||||
const ttsMode = resolveConfiguredTtsMode(params.cfg, {
|
||||
agentId: params.agentId,
|
||||
channelId: params.ttsChannel,
|
||||
accountId: params.ttsAccountId,
|
||||
});
|
||||
const accumulatedVisibleBlockText = params.delivery.getAccumulatedVisibleBlockText();
|
||||
const accumulatedBlockTtsText = params.delivery.getAccumulatedBlockTtsText();
|
||||
const hasAccumulatedBlockText = accumulatedBlockTtsText.trim().length > 0;
|
||||
@@ -204,6 +209,8 @@ async function finalizeAcpTurnOutput(params: {
|
||||
cfg: params.cfg,
|
||||
sessionAuto: params.sessionTtsAuto,
|
||||
agentId: params.agentId,
|
||||
channelId: params.ttsChannel,
|
||||
accountId: params.ttsAccountId,
|
||||
});
|
||||
const canAttemptFinalTts =
|
||||
ttsStatus != null && !(ttsStatus.autoMode === "inbound" && !params.inboundAudio);
|
||||
@@ -220,6 +227,7 @@ async function finalizeAcpTurnOutput(params: {
|
||||
inboundAudio: params.inboundAudio,
|
||||
ttsAuto: params.sessionTtsAuto,
|
||||
agentId: params.agentId,
|
||||
accountId: params.ttsAccountId,
|
||||
});
|
||||
if (ttsSyntheticReply.mediaUrl) {
|
||||
const delivered = await params.delivery.deliver("final", {
|
||||
@@ -487,6 +495,7 @@ export async function tryDispatchAcpReply(params: {
|
||||
inboundAudio: params.inboundAudio,
|
||||
sessionTtsAuto: params.sessionTtsAuto,
|
||||
ttsChannel: params.ttsChannel,
|
||||
ttsAccountId: effectiveDispatchAccountId,
|
||||
shouldEmitResolvedIdentityNotice,
|
||||
})) || queuedFinal;
|
||||
|
||||
|
||||
@@ -122,7 +122,13 @@ async function maybeApplyTtsToReplyPayload(
|
||||
params: Parameters<Awaited<ReturnType<typeof loadTtsRuntime>>["maybeApplyTtsToPayload"]>[0],
|
||||
) {
|
||||
if (
|
||||
!shouldAttemptTtsPayload({ cfg: params.cfg, ttsAuto: params.ttsAuto, agentId: params.agentId })
|
||||
!shouldAttemptTtsPayload({
|
||||
cfg: params.cfg,
|
||||
ttsAuto: params.ttsAuto,
|
||||
agentId: params.agentId,
|
||||
channelId: params.channel,
|
||||
accountId: params.accountId,
|
||||
})
|
||||
) {
|
||||
return params.payload;
|
||||
}
|
||||
@@ -734,6 +740,7 @@ export async function dispatchReplyFromConfig(
|
||||
inboundAudio,
|
||||
ttsAuto: sessionTtsAuto,
|
||||
agentId: sessionAgentId,
|
||||
accountId: replyRoute.accountId,
|
||||
});
|
||||
const normalizedPayload = await normalizeReplyMediaPayload(ttsPayload);
|
||||
const result = await routeReplyToOriginating(normalizedPayload);
|
||||
@@ -939,6 +946,8 @@ export async function dispatchReplyFromConfig(
|
||||
cfg,
|
||||
ttsAuto: sessionTtsAuto,
|
||||
agentId: sessionAgentId,
|
||||
channelId: deliveryChannel,
|
||||
accountId: replyRoute.accountId,
|
||||
})
|
||||
? createTtsDirectiveTextStreamCleaner()
|
||||
: undefined;
|
||||
@@ -1010,6 +1019,7 @@ export async function dispatchReplyFromConfig(
|
||||
inboundAudio,
|
||||
ttsAuto: sessionTtsAuto,
|
||||
agentId: sessionAgentId,
|
||||
accountId: replyRoute.accountId,
|
||||
});
|
||||
const normalizedPayload = await normalizeReplyMediaPayload(ttsPayload);
|
||||
const deliveryPayload = resolveToolDeliveryPayload(normalizedPayload);
|
||||
@@ -1128,6 +1138,7 @@ export async function dispatchReplyFromConfig(
|
||||
inboundAudio,
|
||||
ttsAuto: sessionTtsAuto,
|
||||
agentId: sessionAgentId,
|
||||
accountId: replyRoute.accountId,
|
||||
});
|
||||
const normalizedPayload = await normalizeReplyMediaPayload(ttsPayload);
|
||||
if (shouldRouteToOriginating) {
|
||||
@@ -1198,7 +1209,11 @@ export async function dispatchReplyFromConfig(
|
||||
routedFinalCount += finalReply.routedFinalCount;
|
||||
}
|
||||
|
||||
const ttsMode = resolveConfiguredTtsMode(cfg, sessionAgentId);
|
||||
const ttsMode = resolveConfiguredTtsMode(cfg, {
|
||||
agentId: sessionAgentId,
|
||||
channelId: deliveryChannel,
|
||||
accountId: replyRoute.accountId,
|
||||
});
|
||||
// Generate TTS-only reply after block streaming completes (when there's no final reply).
|
||||
// This handles the case where block streaming succeeds and drops final payloads,
|
||||
// but we still want TTS audio to be generated from the accumulated block content.
|
||||
@@ -1217,6 +1232,7 @@ export async function dispatchReplyFromConfig(
|
||||
inboundAudio,
|
||||
ttsAuto: sessionTtsAuto,
|
||||
agentId: sessionAgentId,
|
||||
accountId: replyRoute.accountId,
|
||||
});
|
||||
// Only send if TTS was actually applied (mediaUrl exists)
|
||||
if (ttsSyntheticReply.mediaUrl) {
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
normalizeOptionalLowercaseString,
|
||||
normalizeOptionalString,
|
||||
} from "../shared/string-coerce.js";
|
||||
import { getChannelPlugin, getLoadedChannelPlugin, normalizeChannelId } from "./plugins/index.js";
|
||||
import { getLoadedChannelPlugin, normalizeChannelId } from "./plugins/index.js";
|
||||
import { parseExplicitTargetForChannel } from "./plugins/target-parsing.js";
|
||||
import {
|
||||
resolveBundledChannelThreadBindingDefaultPlacement,
|
||||
@@ -233,11 +233,7 @@ export function resolveChannelDefaultBindingPlacement(
|
||||
}
|
||||
const pluginPlacement =
|
||||
resolveRuntimeChannelPlugin(channel)?.conversationBindings?.defaultTopLevelPlacement;
|
||||
return (
|
||||
pluginPlacement ??
|
||||
resolveBundledChannelThreadBindingDefaultPlacement(channel) ??
|
||||
getChannelPlugin(channel)?.conversationBindings?.defaultTopLevelPlacement
|
||||
);
|
||||
return pluginPlacement ?? resolveBundledChannelThreadBindingDefaultPlacement(channel);
|
||||
}
|
||||
|
||||
export function resolveCommandConversationResolution(
|
||||
@@ -405,23 +401,6 @@ export function resolveInboundConversationResolution(
|
||||
return artifactResolution;
|
||||
}
|
||||
|
||||
const bundledPlugin = getChannelPlugin(channel);
|
||||
const bundledConversation =
|
||||
bundledPlugin !== plugin
|
||||
? bundledPlugin?.messaging?.resolveInboundConversation?.(resolverParams)
|
||||
: undefined;
|
||||
const bundledResolution = normalizeResolutionTarget({
|
||||
channel,
|
||||
accountId,
|
||||
conversation: bundledConversation,
|
||||
source: "inbound-bundled-plugin",
|
||||
threadId,
|
||||
plugin: bundledPlugin ?? plugin,
|
||||
});
|
||||
if (bundledResolution || bundledConversation === null) {
|
||||
return bundledResolution;
|
||||
}
|
||||
|
||||
const parentConversationId =
|
||||
resolveChannelTargetId({
|
||||
channel,
|
||||
|
||||
@@ -15,6 +15,7 @@ import type { loadOpenClawPlugins as loadOpenClawPluginsType } from "../../plugi
|
||||
import type { PluginManifestRecord } from "../../plugins/manifest-registry.js";
|
||||
import { loadPluginManifestRegistryForPluginRegistry } from "../../plugins/plugin-registry.js";
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js";
|
||||
import { normalizeOptionalString } from "../../shared/string-coerce.js";
|
||||
import { sanitizeForLog } from "../../terminal/ansi.js";
|
||||
import { getBundledChannelSetupPlugin } from "./bundled.js";
|
||||
import { listChannelPlugins } from "./registry.js";
|
||||
@@ -72,6 +73,10 @@ type ReadOnlyChannelPluginResolution = {
|
||||
missingConfiguredChannelIds: string[];
|
||||
};
|
||||
type ManifestChannelConfigRecord = NonNullable<PluginManifestRecord["channelConfigs"]>[string];
|
||||
type ChannelCommandDefaults = Pick<
|
||||
NonNullable<ChannelPlugin["commands"]>,
|
||||
"nativeCommandsAutoEnabled" | "nativeSkillsAutoEnabled"
|
||||
>;
|
||||
|
||||
function addChannelPlugins(
|
||||
byId: Map<string, ChannelPlugin>,
|
||||
@@ -125,6 +130,26 @@ function normalizeManifestText(value: string | undefined, fallback: string): str
|
||||
return sanitizeForLog(value?.trim() || fallback).trim();
|
||||
}
|
||||
|
||||
function normalizeChannelCommandDefaults(
|
||||
value: ChannelCommandDefaults | undefined,
|
||||
): ChannelCommandDefaults | undefined {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
const nativeCommandsAutoEnabled =
|
||||
typeof value.nativeCommandsAutoEnabled === "boolean"
|
||||
? value.nativeCommandsAutoEnabled
|
||||
: undefined;
|
||||
const nativeSkillsAutoEnabled =
|
||||
typeof value.nativeSkillsAutoEnabled === "boolean" ? value.nativeSkillsAutoEnabled : undefined;
|
||||
return nativeCommandsAutoEnabled !== undefined || nativeSkillsAutoEnabled !== undefined
|
||||
? {
|
||||
...(nativeCommandsAutoEnabled !== undefined ? { nativeCommandsAutoEnabled } : {}),
|
||||
...(nativeSkillsAutoEnabled !== undefined ? { nativeSkillsAutoEnabled } : {}),
|
||||
}
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function rebindChannelConfig(
|
||||
cfg: OpenClawConfig,
|
||||
sourceChannelId: string,
|
||||
@@ -258,6 +283,9 @@ function buildManifestChannelPlugin(params: {
|
||||
channelConfig?.description ?? catalogMeta?.blurb,
|
||||
params.record.description || "",
|
||||
);
|
||||
const commands = normalizeChannelCommandDefaults(
|
||||
channelConfig?.commands ?? catalogMeta?.commands,
|
||||
);
|
||||
return {
|
||||
id: params.channelId,
|
||||
meta: {
|
||||
@@ -273,6 +301,7 @@ function buildManifestChannelPlugin(params: {
|
||||
: {}),
|
||||
},
|
||||
capabilities: { chatTypes: ["direct"] },
|
||||
...(commands ? { commands } : {}),
|
||||
...(channelConfig
|
||||
? {
|
||||
configSchema: {
|
||||
@@ -318,6 +347,47 @@ function canUseManifestChannelPlugin(record: PluginManifestRecord, channelId: st
|
||||
return record.channelCatalogMeta?.id === channelId;
|
||||
}
|
||||
|
||||
export function resolveReadOnlyChannelCommandDefaults(
|
||||
channelId: string,
|
||||
options: {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
stateDir?: string;
|
||||
workspaceDir?: string;
|
||||
} = {},
|
||||
): ChannelCommandDefaults | undefined {
|
||||
const normalizedChannelId = normalizeOptionalString(channelId) ?? "";
|
||||
if (!normalizedChannelId || !isSafeManifestChannelId(normalizedChannelId)) {
|
||||
return undefined;
|
||||
}
|
||||
const registry = loadPluginManifestRegistryForPluginRegistry({
|
||||
stateDir: options.stateDir,
|
||||
workspaceDir: options.workspaceDir,
|
||||
env: options.env ?? process.env,
|
||||
includeDisabled: true,
|
||||
});
|
||||
for (const record of registry.plugins) {
|
||||
if (!record.channels.includes(normalizedChannelId)) {
|
||||
continue;
|
||||
}
|
||||
const channelConfigValue = record.channelConfigs
|
||||
? readOwnRecordValue(record.channelConfigs as Record<string, unknown>, normalizedChannelId)
|
||||
: undefined;
|
||||
const channelConfig =
|
||||
channelConfigValue &&
|
||||
typeof channelConfigValue === "object" &&
|
||||
!Array.isArray(channelConfigValue)
|
||||
? (channelConfigValue as ManifestChannelConfigRecord)
|
||||
: undefined;
|
||||
const commands = normalizeChannelCommandDefaults(
|
||||
channelConfig?.commands ?? record.channelCatalogMeta?.commands,
|
||||
);
|
||||
if (commands) {
|
||||
return commands;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function rebindChannelPluginConfig(
|
||||
config: ChannelPlugin["config"],
|
||||
sourceChannelId: string,
|
||||
|
||||
@@ -5,7 +5,8 @@ import {
|
||||
resolveThreadBindingLifecycle as resolveSharedThreadBindingLifecycle,
|
||||
type ThreadBindingLifecycleRecord,
|
||||
} from "../shared/thread-binding-lifecycle.js";
|
||||
import { getChannelPlugin } from "./plugins/index.js";
|
||||
import { getLoadedChannelPlugin } from "./plugins/index.js";
|
||||
import { resolveBundledChannelThreadBindingDefaultPlacement } from "./plugins/thread-binding-api.js";
|
||||
|
||||
export {
|
||||
resolveThreadBindingLifecycle,
|
||||
@@ -64,7 +65,11 @@ function resolveDefaultTopLevelPlacement(channel: string): "current" | "child" {
|
||||
if (!normalized) {
|
||||
return "current";
|
||||
}
|
||||
return getChannelPlugin(normalized)?.conversationBindings?.defaultTopLevelPlacement ?? "current";
|
||||
return (
|
||||
getLoadedChannelPlugin(normalized)?.conversationBindings?.defaultTopLevelPlacement ??
|
||||
resolveBundledChannelThreadBindingDefaultPlacement(normalized) ??
|
||||
"current"
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeBoolean(value: unknown): boolean | undefined {
|
||||
|
||||
@@ -69,36 +69,44 @@ exit 0
|
||||
}
|
||||
|
||||
function expectWindowsRestartWaitOrdering(content: string, port = 18789) {
|
||||
const endCommand = 'schtasks /End /TN "';
|
||||
const pollAttemptsInit = "set /a attempts=0";
|
||||
const pollLabel = ":wait_for_port_release";
|
||||
const pollAttemptIncrement = "set /a attempts+=1";
|
||||
const pollNetstatCheck = `netstat -ano | findstr /R /C:":${port} .*LISTENING" >nul`;
|
||||
const forceKillLabel = ":force_kill_listener";
|
||||
const forceKillCommand = "taskkill /F /PID %%P >>";
|
||||
const portReleasedLabel = ":port_released";
|
||||
const runCommand = 'schtasks /Run /TN "';
|
||||
const endIndex = content.indexOf(endCommand);
|
||||
const attemptsInitIndex = content.indexOf(pollAttemptsInit, endIndex);
|
||||
const pollLabelIndex = content.indexOf(pollLabel, attemptsInitIndex);
|
||||
const pollAttemptIncrementIndex = content.indexOf(pollAttemptIncrement, pollLabelIndex);
|
||||
const pollNetstatCheckIndex = content.indexOf(pollNetstatCheck, pollAttemptIncrementIndex);
|
||||
const forceKillLabelIndex = content.indexOf(forceKillLabel, pollNetstatCheckIndex);
|
||||
const forceKillCommandIndex = content.indexOf(forceKillCommand, forceKillLabelIndex);
|
||||
const portReleasedLabelIndex = content.indexOf(portReleasedLabel, forceKillCommandIndex);
|
||||
const runIndex = content.indexOf(runCommand, portReleasedLabelIndex);
|
||||
const stateCheck = "$taskState = Get-OpenClawScheduledTaskState -TaskName $taskName";
|
||||
const runningGuard = 'if ($taskState -eq "Running")';
|
||||
const endCommand =
|
||||
'Invoke-OpenClawSchtasksWithTimeout -Arguments @("/End", "/TN", $taskName) -TimeoutSeconds 10';
|
||||
const skipEndLog = "openclaw restart skipped schtasks end";
|
||||
const pollLoop = "for ($attempt = 1; $attempt -le 10; $attempt++)";
|
||||
const pollCall = `Get-OpenClawListenerPids -Port $port`;
|
||||
const forceKillBranch = "if ($attempt -eq 10)";
|
||||
const forceKillCommand = "Stop-Process -Id $listenerPid -Force";
|
||||
const runCommand =
|
||||
'Invoke-OpenClawSchtasksWithTimeout -Arguments @("/Run", "/TN", $taskName) -TimeoutSeconds 30';
|
||||
const portAssignment = `$port = ${port}`;
|
||||
const stateCheckIndex = content.indexOf(stateCheck);
|
||||
const runningGuardIndex = content.indexOf(runningGuard, stateCheckIndex);
|
||||
const endIndex = content.indexOf(endCommand, runningGuardIndex);
|
||||
const skipEndLogIndex = content.indexOf(skipEndLog, endIndex);
|
||||
const portAssignmentIndex = content.indexOf(portAssignment);
|
||||
const pollLoopIndex = content.indexOf(pollLoop, skipEndLogIndex);
|
||||
const pollCallIndex = content.indexOf(pollCall, pollLoopIndex);
|
||||
const forceKillBranchIndex = content.indexOf(forceKillBranch, pollCallIndex);
|
||||
const forceKillCommandIndex = content.indexOf(forceKillCommand, forceKillBranchIndex);
|
||||
const runIndex = content.indexOf(runCommand, forceKillCommandIndex);
|
||||
|
||||
expect(endIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(attemptsInitIndex).toBeGreaterThan(endIndex);
|
||||
expect(pollLabelIndex).toBeGreaterThan(attemptsInitIndex);
|
||||
expect(pollAttemptIncrementIndex).toBeGreaterThan(pollLabelIndex);
|
||||
expect(pollNetstatCheckIndex).toBeGreaterThan(pollAttemptIncrementIndex);
|
||||
expect(forceKillLabelIndex).toBeGreaterThan(pollNetstatCheckIndex);
|
||||
expect(forceKillCommandIndex).toBeGreaterThan(forceKillLabelIndex);
|
||||
expect(portReleasedLabelIndex).toBeGreaterThan(forceKillCommandIndex);
|
||||
expect(runIndex).toBeGreaterThan(portReleasedLabelIndex);
|
||||
expect(stateCheckIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(runningGuardIndex).toBeGreaterThan(stateCheckIndex);
|
||||
expect(endIndex).toBeGreaterThan(runningGuardIndex);
|
||||
expect(skipEndLogIndex).toBeGreaterThan(endIndex);
|
||||
expect(portAssignmentIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(pollLoopIndex).toBeGreaterThan(skipEndLogIndex);
|
||||
expect(pollCallIndex).toBeGreaterThan(pollLoopIndex);
|
||||
expect(forceKillBranchIndex).toBeGreaterThan(pollCallIndex);
|
||||
expect(forceKillCommandIndex).toBeGreaterThan(forceKillBranchIndex);
|
||||
expect(runIndex).toBeGreaterThan(forceKillCommandIndex);
|
||||
|
||||
expect(content).not.toContain("timeout /t 3 /nobreak >nul");
|
||||
expect(content).not.toContain("findstr");
|
||||
expect(content).not.toContain("netstat -ano |");
|
||||
expect(content).not.toContain("schtasks /End /TN");
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -296,21 +304,25 @@ exit 0
|
||||
await cleanupScript(scriptPath);
|
||||
});
|
||||
|
||||
it("creates a schtasks restart script on Windows", async () => {
|
||||
it("creates a guarded schtasks restart script on Windows", async () => {
|
||||
Object.defineProperty(process, "platform", { value: "win32" });
|
||||
|
||||
const { scriptPath, content } = await prepareAndReadScript({
|
||||
OPENCLAW_PROFILE: "default",
|
||||
});
|
||||
expect(scriptPath.endsWith(".bat")).toBe(true);
|
||||
expect(scriptPath.endsWith(".cmd")).toBe(true);
|
||||
expect(content).toContain("@echo off");
|
||||
expect(content).toContain("powershell -NoProfile -ExecutionPolicy Bypass -Command");
|
||||
expect(content).not.toContain("-File");
|
||||
expect(content).toContain('$ErrorActionPreference = "Continue"');
|
||||
expect(content).toContain("gateway-restart.log");
|
||||
expect(content).toContain("openclaw restart attempt source=update target=OpenClaw Gateway");
|
||||
expect(content).toContain('schtasks /End /TN "OpenClaw Gateway"');
|
||||
expect(content).toContain('schtasks /Run /TN "OpenClaw Gateway" >>');
|
||||
expect(content).toContain("$taskName = 'OpenClaw Gateway'");
|
||||
expect(content).toContain("function Invoke-OpenClawSchtasksWithTimeout");
|
||||
expect(content).toContain("function Get-OpenClawScheduledTaskState");
|
||||
expect(content).toContain("Get-ScheduledTask -TaskName $TaskName");
|
||||
expect(content).toContain("openclaw restart skipped schtasks end");
|
||||
expectWindowsRestartWaitOrdering(content);
|
||||
// Batch self-cleanup
|
||||
expect(content).toContain('del "%~f0"');
|
||||
expect(content).toContain('del "%~f0" >nul 2>&1');
|
||||
await cleanupScript(scriptPath);
|
||||
});
|
||||
|
||||
@@ -321,8 +333,11 @@ exit 0
|
||||
OPENCLAW_PROFILE: "default",
|
||||
OPENCLAW_WINDOWS_TASK_NAME: "OpenClaw Gateway (custom)",
|
||||
});
|
||||
expect(content).toContain('schtasks /End /TN "OpenClaw Gateway (custom)"');
|
||||
expect(content).toContain('schtasks /Run /TN "OpenClaw Gateway (custom)"');
|
||||
expect(content).toContain("$taskName = 'OpenClaw Gateway (custom)'");
|
||||
expect(content).toContain("Get-OpenClawScheduledTaskState -TaskName $taskName");
|
||||
expect(content).toContain(
|
||||
'Invoke-OpenClawSchtasksWithTimeout -Arguments @("/End", "/TN", $taskName) -TimeoutSeconds 10',
|
||||
);
|
||||
expectWindowsRestartWaitOrdering(content);
|
||||
await cleanupScript(scriptPath);
|
||||
});
|
||||
@@ -337,10 +352,10 @@ exit 0
|
||||
},
|
||||
customPort,
|
||||
);
|
||||
expect(content).toContain(`netstat -ano | findstr /R /C:":${customPort} .*LISTENING" >nul`);
|
||||
expect(content).toContain(
|
||||
`for /f "tokens=5" %%P in ('netstat -ano ^| findstr /R /C:":${customPort} .*LISTENING"') do (`,
|
||||
);
|
||||
expect(content).toContain(`$port = ${customPort}`);
|
||||
expect(content).toContain("Get-NetTCPConnection -LocalPort $Port -State Listen");
|
||||
expect(content).toContain("& netstat.exe -ano -p tcp");
|
||||
expect(content).not.toContain("findstr");
|
||||
expectWindowsRestartWaitOrdering(content, customPort);
|
||||
await cleanupScript(scriptPath);
|
||||
});
|
||||
@@ -371,7 +386,7 @@ exit 0
|
||||
const { scriptPath, content } = await prepareAndReadScript({
|
||||
OPENCLAW_PROFILE: "production",
|
||||
});
|
||||
expect(content).toContain('schtasks /End /TN "OpenClaw Gateway (production)"');
|
||||
expect(content).toContain("$taskName = 'OpenClaw Gateway (production)'");
|
||||
expectWindowsRestartWaitOrdering(content);
|
||||
await cleanupScript(scriptPath);
|
||||
});
|
||||
|
||||
@@ -10,8 +10,8 @@ import {
|
||||
resolveGatewayWindowsTaskName,
|
||||
} from "../../daemon/constants.js";
|
||||
import {
|
||||
renderCmdRestartLogSetup,
|
||||
renderPosixRestartLogSetup,
|
||||
resolveGatewayRestartLogPath,
|
||||
shellEscapeRestartLogValue,
|
||||
} from "../../daemon/restart-logs.js";
|
||||
import { normalizeOptionalString } from "../../shared/string-coerce.js";
|
||||
@@ -25,12 +25,15 @@ function shellEscape(value: string): string {
|
||||
return value.replace(/'/g, "'\\''");
|
||||
}
|
||||
|
||||
/** Validates a string is safe for embedding in a batch (cmd.exe) script. */
|
||||
function isBatchSafe(value: string): boolean {
|
||||
// Reject characters that have special meaning in batch: & | < > ^ % " ` $
|
||||
/** Validates a task name is safe for embedding in Windows restart scripts. */
|
||||
function isWindowsTaskNameSafe(value: string): boolean {
|
||||
return /^[A-Za-z0-9 _\-().]+$/.test(value);
|
||||
}
|
||||
|
||||
function powerShellSingleQuote(value: string): string {
|
||||
return `'${value.replace(/'/g, "''")}'`;
|
||||
}
|
||||
|
||||
function resolveSystemdUnit(env: NodeJS.ProcessEnv): string {
|
||||
const override = normalizeOptionalString(env.OPENCLAW_SYSTEMD_UNIT);
|
||||
if (override) {
|
||||
@@ -138,45 +141,189 @@ exit "$status"
|
||||
`;
|
||||
} else if (platform === "win32") {
|
||||
const taskName = resolveWindowsTaskName(env);
|
||||
if (!isBatchSafe(taskName)) {
|
||||
if (!isWindowsTaskNameSafe(taskName)) {
|
||||
return null;
|
||||
}
|
||||
const port =
|
||||
Number.isFinite(gatewayPort) && gatewayPort > 0 ? gatewayPort : DEFAULT_GATEWAY_PORT;
|
||||
const restartLog = renderCmdRestartLogSetup({ ...process.env, ...env });
|
||||
filename = `openclaw-restart-${timestamp}.bat`;
|
||||
const restartLogPath = resolveGatewayRestartLogPath({ ...process.env, ...env });
|
||||
const quotedLogPath = powerShellSingleQuote(restartLogPath);
|
||||
const quotedTaskName = powerShellSingleQuote(taskName);
|
||||
filename = `openclaw-restart-${timestamp}.cmd`;
|
||||
scriptContent = `@echo off
|
||||
REM Standalone restart script — survives parent process termination.
|
||||
REM Wait briefly to ensure file locks are released after update.
|
||||
timeout /t 2 /nobreak >nul
|
||||
${restartLog.lines.join("\r\n")}
|
||||
>> ${restartLog.quotedLogPath} 2>&1 echo [%DATE% %TIME%] openclaw restart attempt source=update target=${taskName}
|
||||
schtasks /End /TN "${taskName}" >> ${restartLog.quotedLogPath} 2>&1
|
||||
REM Poll for gateway port release before rerun; force-kill listener if stuck.
|
||||
set /a attempts=0
|
||||
:wait_for_port_release
|
||||
set /a attempts+=1
|
||||
netstat -ano | findstr /R /C:":${port} .*LISTENING" >nul
|
||||
if errorlevel 1 goto port_released
|
||||
if %attempts% GEQ 10 goto force_kill_listener
|
||||
timeout /t 1 /nobreak >nul
|
||||
goto wait_for_port_release
|
||||
:force_kill_listener
|
||||
for /f "tokens=5" %%P in ('netstat -ano ^| findstr /R /C:":${port} .*LISTENING"') do (
|
||||
taskkill /F /PID %%P >> ${restartLog.quotedLogPath} 2>&1
|
||||
goto port_released
|
||||
)
|
||||
:port_released
|
||||
schtasks /Run /TN "${taskName}" >> ${restartLog.quotedLogPath} 2>&1
|
||||
REM Standalone restart script - survives parent process termination.
|
||||
REM Keep this as a cmd wrapper so Group Policy script execution policies
|
||||
REM cannot block the update restart handoff before schtasks.exe runs.
|
||||
setlocal
|
||||
set "OPENCLAW_RESTART_SCRIPT=%~f0"
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -Command "$p=$env:OPENCLAW_RESTART_SCRIPT; $s=Get-Content -Raw -LiteralPath $p; $m='# POWERSHELL'; $i=$s.IndexOf($m); if ($i -lt 0) { exit 1 }; Invoke-Expression $s.Substring($i)"
|
||||
set "status=%ERRORLEVEL%"
|
||||
if not "%status%"=="0" (
|
||||
>> ${restartLog.quotedLogPath} 2>&1 echo [%DATE% %TIME%] openclaw restart failed source=update status=%status%
|
||||
) else (
|
||||
>> ${restartLog.quotedLogPath} 2>&1 echo [%DATE% %TIME%] openclaw restart done source=update
|
||||
)
|
||||
REM Self-cleanup
|
||||
del "%~f0"
|
||||
del "%~f0" >nul 2>&1
|
||||
exit /b %status%
|
||||
# POWERSHELL
|
||||
# Wait briefly to ensure file locks are released after update.
|
||||
$ErrorActionPreference = "Continue"
|
||||
Start-Sleep -Seconds 2
|
||||
|
||||
$logPath = ${quotedLogPath}
|
||||
try {
|
||||
$logDir = Split-Path -Parent $logPath
|
||||
New-Item -ItemType Directory -Path $logDir -Force | Out-Null
|
||||
Add-Content -LiteralPath $logPath -Value "[$(Get-Date -Format o)] openclaw restart log initialized"
|
||||
} catch {
|
||||
# Restart should still run if log setup is unavailable.
|
||||
}
|
||||
|
||||
function Write-RestartLog {
|
||||
param([string]$Message)
|
||||
try {
|
||||
Add-Content -LiteralPath $logPath -Value "[$(Get-Date -Format o)] $Message"
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
function Join-OpenClawProcessArguments {
|
||||
param([string[]]$Arguments)
|
||||
($Arguments | ForEach-Object {
|
||||
if ($_ -match "\\s") {
|
||||
'"' + $_ + '"'
|
||||
} else {
|
||||
$_
|
||||
}
|
||||
}) -join " "
|
||||
}
|
||||
|
||||
function Invoke-OpenClawSchtasksWithTimeout {
|
||||
param(
|
||||
[string[]]$Arguments,
|
||||
[int]$TimeoutSeconds
|
||||
)
|
||||
$process = $null
|
||||
try {
|
||||
$startInfo = [System.Diagnostics.ProcessStartInfo]::new()
|
||||
$startInfo.FileName = "schtasks.exe"
|
||||
$startInfo.Arguments = Join-OpenClawProcessArguments -Arguments $Arguments
|
||||
$startInfo.UseShellExecute = $false
|
||||
$startInfo.RedirectStandardOutput = $true
|
||||
$startInfo.RedirectStandardError = $true
|
||||
$process = [System.Diagnostics.Process]::Start($startInfo)
|
||||
if (-not $process.WaitForExit($TimeoutSeconds * 1000)) {
|
||||
try {
|
||||
$process.Kill()
|
||||
} catch {
|
||||
}
|
||||
Write-RestartLog "openclaw restart schtasks timeout source=update args=$($Arguments -join ' ')"
|
||||
return 124
|
||||
}
|
||||
$stdout = $process.StandardOutput.ReadToEnd()
|
||||
$stderr = $process.StandardError.ReadToEnd()
|
||||
if ($stdout) {
|
||||
Write-RestartLog $stdout.Trim()
|
||||
}
|
||||
if ($stderr) {
|
||||
Write-RestartLog $stderr.Trim()
|
||||
}
|
||||
return $process.ExitCode
|
||||
} catch {
|
||||
Write-RestartLog "openclaw restart schtasks failed source=update args=$($Arguments -join ' ') error=$($_.Exception.Message)"
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
function Get-OpenClawScheduledTaskState {
|
||||
param([string]$TaskName)
|
||||
try {
|
||||
$task = Get-ScheduledTask -TaskName $TaskName -ErrorAction Stop
|
||||
if ($task -and $task.State) {
|
||||
return [string]$task.State
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
|
||||
try {
|
||||
$queryOutput = & schtasks.exe /Query /TN $TaskName /FO LIST 2>$null
|
||||
foreach ($line in $queryOutput) {
|
||||
if ($line -match "^\\s*Status:\\s*(.+?)\\s*$") {
|
||||
return $Matches[1]
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
|
||||
return "Unknown"
|
||||
}
|
||||
|
||||
function Get-OpenClawListenerPids {
|
||||
param([int]$Port)
|
||||
$listenerPids = @()
|
||||
|
||||
try {
|
||||
if (Get-Command Get-NetTCPConnection -ErrorAction SilentlyContinue) {
|
||||
$listenerPids += Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue |
|
||||
ForEach-Object { [int]$_.OwningProcess }
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
|
||||
if ($listenerPids.Count -eq 0) {
|
||||
try {
|
||||
$portPattern = [regex]::Escape(":$Port")
|
||||
$linePattern = "^\\s*TCP\\s+\\S+$portPattern\\s+\\S+\\s+LISTENING\\s+(\\d+)\\s*$"
|
||||
& netstat.exe -ano -p tcp 2>$null | ForEach-Object {
|
||||
if ($_ -match $linePattern) {
|
||||
$listenerPids += [int]$Matches[1]
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
$listenerPids | Sort-Object -Unique
|
||||
}
|
||||
|
||||
$taskName = ${quotedTaskName}
|
||||
$port = ${port}
|
||||
Write-RestartLog "openclaw restart attempt source=update target=$taskName"
|
||||
|
||||
$taskState = Get-OpenClawScheduledTaskState -TaskName $taskName
|
||||
if ($taskState -eq "Running") {
|
||||
$endStatus = Invoke-OpenClawSchtasksWithTimeout -Arguments @("/End", "/TN", $taskName) -TimeoutSeconds 10
|
||||
if ($endStatus -ne 0) {
|
||||
Write-RestartLog "openclaw restart schtasks end did not complete cleanly source=update status=$endStatus"
|
||||
}
|
||||
} else {
|
||||
Write-RestartLog "openclaw restart skipped schtasks end source=update state=$taskState"
|
||||
}
|
||||
|
||||
for ($attempt = 1; $attempt -le 10; $attempt++) {
|
||||
$listeners = @(Get-OpenClawListenerPids -Port $port)
|
||||
if ($listeners.Count -eq 0) {
|
||||
break
|
||||
}
|
||||
|
||||
if ($attempt -eq 10) {
|
||||
foreach ($listenerPid in $listeners) {
|
||||
try {
|
||||
Stop-Process -Id $listenerPid -Force -ErrorAction Stop
|
||||
Write-RestartLog "openclaw restart killed stale listener source=update pid=$listenerPid"
|
||||
} catch {
|
||||
Write-RestartLog "openclaw restart failed to kill stale listener source=update pid=$listenerPid error=$($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
Start-Sleep -Seconds 1
|
||||
}
|
||||
|
||||
$status = Invoke-OpenClawSchtasksWithTimeout -Arguments @("/Run", "/TN", $taskName) -TimeoutSeconds 30
|
||||
if ($status -eq 0) {
|
||||
Write-RestartLog "openclaw restart done source=update"
|
||||
} else {
|
||||
Write-RestartLog "openclaw restart failed source=update status=$status"
|
||||
}
|
||||
|
||||
exit $status
|
||||
`;
|
||||
} else {
|
||||
return null;
|
||||
|
||||
@@ -11,6 +11,7 @@ import type { AgentSummary } from "./agents.config.js";
|
||||
import { buildAgentSummaries } from "./agents.config.js";
|
||||
import {
|
||||
buildProviderStatusIndex,
|
||||
buildProviderSummaryMetadataIndex,
|
||||
listProvidersForAgent,
|
||||
summarizeBindings,
|
||||
} from "./agents.providers.js";
|
||||
@@ -107,11 +108,12 @@ export async function agentsListCommand(
|
||||
// catalog entry, this keeps `agents list --json` on the config-only path.
|
||||
const includeProviderDetails = !opts.json || opts.bindings === true;
|
||||
const providerStatus = includeProviderDetails ? await buildProviderStatusIndex(cfg) : null;
|
||||
const providerMetadata = includeProviderDetails ? buildProviderSummaryMetadataIndex(cfg) : null;
|
||||
|
||||
for (const summary of summaries) {
|
||||
const bindings = bindingMap.get(summary.id) ?? [];
|
||||
if (includeProviderDetails && providerStatus) {
|
||||
const routes = summarizeBindings(cfg, bindings);
|
||||
if (includeProviderDetails && providerStatus && providerMetadata) {
|
||||
const routes = summarizeBindings(cfg, bindings, providerMetadata);
|
||||
if (routes.length > 0) {
|
||||
summary.routes = routes;
|
||||
} else if (summary.isDefault) {
|
||||
@@ -123,6 +125,7 @@ export async function agentsListCommand(
|
||||
cfg,
|
||||
bindings,
|
||||
providerStatus,
|
||||
providerMetadata,
|
||||
});
|
||||
if (providerLines.length > 0) {
|
||||
summary.providers = providerLines;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { isChannelVisibleInConfiguredLists } from "../channels/plugins/exposure.js";
|
||||
import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js";
|
||||
import { getChannelPlugin, normalizeChannelId } from "../channels/plugins/index.js";
|
||||
import { normalizeChannelId } from "../channels/plugins/index.js";
|
||||
import { listReadOnlyChannelPluginsForConfig } from "../channels/plugins/read-only.js";
|
||||
import type { ChannelPlugin } from "../channels/plugins/types.plugin.js";
|
||||
import type { ChannelId } from "../channels/plugins/types.public.js";
|
||||
@@ -19,10 +19,37 @@ type ProviderAccountStatus = {
|
||||
visibleInConfiguredLists?: boolean;
|
||||
};
|
||||
|
||||
export type ProviderSummaryMetadata = {
|
||||
label: string;
|
||||
defaultAccountId: string;
|
||||
visibleInConfiguredLists: boolean;
|
||||
};
|
||||
|
||||
function providerAccountKey(provider: ChannelId, accountId?: string) {
|
||||
return `${provider}:${accountId ?? DEFAULT_ACCOUNT_ID}`;
|
||||
}
|
||||
|
||||
export function buildProviderSummaryMetadataIndex(
|
||||
cfg: OpenClawConfig,
|
||||
): Map<ChannelId, ProviderSummaryMetadata> {
|
||||
return new Map(
|
||||
listReadOnlyChannelPluginsForConfig(cfg, {
|
||||
includeSetupRuntimeFallback: false,
|
||||
}).map((plugin) => [
|
||||
plugin.id,
|
||||
{
|
||||
label: plugin.meta.label,
|
||||
defaultAccountId: resolveChannelDefaultAccountId({
|
||||
plugin,
|
||||
cfg,
|
||||
accountIds: plugin.config.listAccountIds(cfg),
|
||||
}),
|
||||
visibleInConfiguredLists: isChannelVisibleInConfiguredLists(plugin.meta),
|
||||
},
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
function isUnresolvedSecretRefResolutionError(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof Error &&
|
||||
@@ -37,8 +64,7 @@ function formatChannelAccountLabel(params: {
|
||||
accountId: string;
|
||||
name?: string;
|
||||
}): string {
|
||||
const label =
|
||||
params.providerLabel ?? getChannelPlugin(params.provider)?.meta.label ?? params.provider;
|
||||
const label = params.providerLabel ?? params.provider;
|
||||
const account = params.name?.trim()
|
||||
? `${params.accountId} (${params.name.trim()})`
|
||||
: params.accountId;
|
||||
@@ -134,31 +160,26 @@ export async function buildProviderStatusIndex(
|
||||
return map;
|
||||
}
|
||||
|
||||
function resolveDefaultAccountId(cfg: OpenClawConfig, provider: ChannelId): string {
|
||||
const plugin = getChannelPlugin(provider);
|
||||
if (!plugin) {
|
||||
return DEFAULT_ACCOUNT_ID;
|
||||
}
|
||||
return resolveChannelDefaultAccountId({ plugin, cfg });
|
||||
function resolveDefaultAccountId(
|
||||
provider: ChannelId,
|
||||
metadataByProvider: ReadonlyMap<ChannelId, ProviderSummaryMetadata>,
|
||||
): string {
|
||||
return metadataByProvider.get(provider)?.defaultAccountId ?? DEFAULT_ACCOUNT_ID;
|
||||
}
|
||||
|
||||
function shouldShowProviderEntry(entry: ProviderAccountStatus, cfg: OpenClawConfig): boolean {
|
||||
if (entry.visibleInConfiguredLists !== undefined) {
|
||||
if (!entry.visibleInConfiguredLists) {
|
||||
const providerConfig = (cfg as Record<string, unknown>)[entry.provider];
|
||||
return Boolean(entry.configured) || Boolean(providerConfig);
|
||||
}
|
||||
return Boolean(entry.configured);
|
||||
function shouldShowProviderEntry(params: {
|
||||
entry: ProviderAccountStatus;
|
||||
cfg: OpenClawConfig;
|
||||
metadataByProvider: ReadonlyMap<ChannelId, ProviderSummaryMetadata>;
|
||||
}): boolean {
|
||||
const visibleInConfiguredLists =
|
||||
params.entry.visibleInConfiguredLists ??
|
||||
params.metadataByProvider.get(params.entry.provider)?.visibleInConfiguredLists;
|
||||
if (visibleInConfiguredLists === false) {
|
||||
const providerConfig = (params.cfg as Record<string, unknown>)[params.entry.provider];
|
||||
return Boolean(params.entry.configured) || Boolean(providerConfig);
|
||||
}
|
||||
const plugin = getChannelPlugin(entry.provider);
|
||||
if (!plugin) {
|
||||
return Boolean(entry.configured);
|
||||
}
|
||||
if (!isChannelVisibleInConfiguredLists(plugin.meta)) {
|
||||
const providerConfig = (cfg as Record<string, unknown>)[plugin.id];
|
||||
return Boolean(entry.configured) || Boolean(providerConfig);
|
||||
}
|
||||
return Boolean(entry.configured);
|
||||
return Boolean(params.entry.configured);
|
||||
}
|
||||
|
||||
function formatProviderEntry(entry: ProviderAccountStatus): string {
|
||||
@@ -171,7 +192,11 @@ function formatProviderEntry(entry: ProviderAccountStatus): string {
|
||||
return `${label}: ${formatProviderState(entry)}`;
|
||||
}
|
||||
|
||||
export function summarizeBindings(cfg: OpenClawConfig, bindings: AgentBinding[]): string[] {
|
||||
export function summarizeBindings(
|
||||
cfg: OpenClawConfig,
|
||||
bindings: AgentBinding[],
|
||||
metadataByProvider = buildProviderSummaryMetadataIndex(cfg),
|
||||
): string[] {
|
||||
if (bindings.length === 0) {
|
||||
return [];
|
||||
}
|
||||
@@ -181,11 +206,13 @@ export function summarizeBindings(cfg: OpenClawConfig, bindings: AgentBinding[])
|
||||
if (!channel) {
|
||||
continue;
|
||||
}
|
||||
const accountId = binding.match.accountId ?? resolveDefaultAccountId(cfg, channel);
|
||||
const accountId =
|
||||
binding.match.accountId ?? resolveDefaultAccountId(channel, metadataByProvider);
|
||||
const key = providerAccountKey(channel, accountId);
|
||||
if (!seen.has(key)) {
|
||||
const label = formatChannelAccountLabel({
|
||||
provider: channel,
|
||||
providerLabel: metadataByProvider.get(channel)?.label,
|
||||
accountId,
|
||||
});
|
||||
seen.set(key, label);
|
||||
@@ -199,9 +226,12 @@ export function listProvidersForAgent(params: {
|
||||
cfg: OpenClawConfig;
|
||||
bindings: AgentBinding[];
|
||||
providerStatus: Map<string, ProviderAccountStatus>;
|
||||
providerMetadata?: ReadonlyMap<ChannelId, ProviderSummaryMetadata>;
|
||||
}): string[] {
|
||||
const allProviderEntries = [...params.providerStatus.values()];
|
||||
const providerLines: string[] = [];
|
||||
const metadataByProvider =
|
||||
params.providerMetadata ?? buildProviderSummaryMetadataIndex(params.cfg);
|
||||
if (params.bindings.length > 0) {
|
||||
const seen = new Set<string>();
|
||||
for (const binding of params.bindings) {
|
||||
@@ -209,7 +239,8 @@ export function listProvidersForAgent(params: {
|
||||
if (!channel) {
|
||||
continue;
|
||||
}
|
||||
const accountId = binding.match.accountId ?? resolveDefaultAccountId(params.cfg, channel);
|
||||
const accountId =
|
||||
binding.match.accountId ?? resolveDefaultAccountId(channel, metadataByProvider);
|
||||
const key = providerAccountKey(channel, accountId);
|
||||
if (seen.has(key)) {
|
||||
continue;
|
||||
@@ -220,7 +251,11 @@ export function listProvidersForAgent(params: {
|
||||
providerLines.push(formatProviderEntry(status));
|
||||
} else {
|
||||
providerLines.push(
|
||||
`${formatChannelAccountLabel({ provider: channel, accountId })}: unknown`,
|
||||
`${formatChannelAccountLabel({
|
||||
provider: channel,
|
||||
providerLabel: metadataByProvider.get(channel)?.label,
|
||||
accountId,
|
||||
})}: unknown`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -229,7 +264,7 @@ export function listProvidersForAgent(params: {
|
||||
|
||||
if (params.summaryIsDefault) {
|
||||
for (const entry of allProviderEntries) {
|
||||
if (shouldShowProviderEntry(entry, params.cfg)) {
|
||||
if (shouldShowProviderEntry({ entry, cfg: params.cfg, metadataByProvider })) {
|
||||
providerLines.push(formatProviderEntry(entry));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { resolveChannelDefaultAccountId } from "../../channels/plugins/helpers.js";
|
||||
import { listChannelPlugins } from "../../channels/plugins/index.js";
|
||||
import {
|
||||
createMessageActionDiscoveryContext,
|
||||
resolveMessageActionDiscoveryForPlugin,
|
||||
} from "../../channels/plugins/message-action-discovery.js";
|
||||
import { listReadOnlyChannelPluginsForConfig } from "../../channels/plugins/read-only.js";
|
||||
import type {
|
||||
ChannelCapabilities,
|
||||
ChannelCapabilitiesDiagnostics,
|
||||
@@ -239,7 +239,9 @@ export async function channelsCapabilitiesCommand(
|
||||
return;
|
||||
}
|
||||
|
||||
const plugins = listChannelPlugins();
|
||||
const plugins = listReadOnlyChannelPluginsForConfig(cfg, {
|
||||
includeSetupRuntimeFallback: true,
|
||||
});
|
||||
const selected =
|
||||
!rawChannel || rawChannel === "all"
|
||||
? plugins
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { resolveChannelDefaultAccountId } from "../../channels/plugins/helpers.js";
|
||||
import {
|
||||
getChannelPlugin,
|
||||
listChannelPlugins,
|
||||
normalizeChannelId,
|
||||
} from "../../channels/plugins/index.js";
|
||||
import { getChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js";
|
||||
import { listReadOnlyChannelPluginsForConfig } from "../../channels/plugins/read-only.js";
|
||||
import type { ChannelPlugin } from "../../channels/plugins/types.plugin.js";
|
||||
import { commitConfigWithPendingPluginInstalls } from "../../cli/plugins-install-record-commit.js";
|
||||
import { refreshPluginRegistryAfterConfigMutation } from "../../cli/plugins-registry-refresh.js";
|
||||
import { replaceConfigFile, type OpenClawConfig } from "../../config/config.js";
|
||||
@@ -24,8 +22,12 @@ export type ChannelsRemoveOptions = {
|
||||
delete?: boolean;
|
||||
};
|
||||
|
||||
function listAccountIds(cfg: OpenClawConfig, channel: ChatChannel): string[] {
|
||||
const plugin = getChannelPlugin(channel);
|
||||
function listAccountIds(
|
||||
cfg: OpenClawConfig,
|
||||
channel: ChatChannel,
|
||||
plugin?: ChannelPlugin,
|
||||
): string[] {
|
||||
plugin ??= getChannelPlugin(channel);
|
||||
if (!plugin) {
|
||||
return [];
|
||||
}
|
||||
@@ -47,23 +49,29 @@ export async function channelsRemoveCommand(
|
||||
const useWizard = shouldUseWizard(params);
|
||||
const prompter = useWizard ? createClackPrompter() : null;
|
||||
const rawChannel = normalizeOptionalString(opts.channel) ?? "";
|
||||
let lookupChannel = rawChannel;
|
||||
let channel: ChatChannel | null = normalizeChannelId(rawChannel);
|
||||
let accountId = normalizeAccountId(opts.account);
|
||||
const deleteConfig = Boolean(opts.delete);
|
||||
|
||||
if (useWizard && prompter) {
|
||||
await prompter.intro("Remove channel account");
|
||||
const readOnlyPlugins = listReadOnlyChannelPluginsForConfig(cfg, {
|
||||
includeSetupRuntimeFallback: true,
|
||||
});
|
||||
const selectedChannel = await prompter.select({
|
||||
message: "Channel",
|
||||
options: listChannelPlugins().map((plugin) => ({
|
||||
options: readOnlyPlugins.map((plugin) => ({
|
||||
value: plugin.id,
|
||||
label: plugin.meta.label,
|
||||
})),
|
||||
});
|
||||
channel = selectedChannel;
|
||||
lookupChannel = selectedChannel;
|
||||
|
||||
accountId = await (async () => {
|
||||
const ids = listAccountIds(cfg, selectedChannel);
|
||||
const readOnlyPlugin = readOnlyPlugins.find((plugin) => plugin.id === selectedChannel);
|
||||
const ids = listAccountIds(cfg, selectedChannel, readOnlyPlugin);
|
||||
const choice = await prompter.select({
|
||||
message: "Account",
|
||||
options: ids.map((id) => ({
|
||||
@@ -102,8 +110,7 @@ export async function channelsRemoveCommand(
|
||||
}
|
||||
}
|
||||
|
||||
const shouldResolveInstallablePlugin =
|
||||
!useWizard && rawChannel && (!channel || !getChannelPlugin(channel));
|
||||
const shouldResolveInstallablePlugin = Boolean(lookupChannel || channel);
|
||||
const resolvedPluginState = shouldResolveInstallablePlugin
|
||||
? await (async () => {
|
||||
const { resolveInstallableChannelPlugin } =
|
||||
@@ -111,7 +118,7 @@ export async function channelsRemoveCommand(
|
||||
return await resolveInstallableChannelPlugin({
|
||||
cfg,
|
||||
runtime,
|
||||
rawChannel,
|
||||
rawChannel: lookupChannel,
|
||||
allowInstall: true,
|
||||
});
|
||||
})()
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user