Merge branch 'main' into feature/before-dispatch-hook
This commit is contained in:
commit
a8abdf8980
161
CHANGELOG.md
161
CHANGELOG.md
@ -4,21 +4,27 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Security
|
||||
|
||||
- Gateway/WebSocket: enforce browser origin validation for all browser-originated connections regardless of whether proxy headers are present, closing a cross-site WebSocket hijacking path in `trusted-proxy` mode that could grant untrusted origins `operator.admin` access. (GHSA-5wcw-8jjv-m286)
|
||||
|
||||
### Changes
|
||||
|
||||
- Gateway/node pending work: add narrow in-memory pending-work queue primitives (`node.pending.enqueue` / `node.pending.drain`) and wake-helper reuse as a foundation for dormant-node work delivery. (#41409) Thanks @mbelinky.
|
||||
- Git/runtime state: ignore the gateway-generated `.dev-state` file so local runtime state does not show up as untracked repo noise. (#41848) Thanks @smysle.
|
||||
- ACP/sessions_spawn: add optional `resumeSessionId` for `runtime: "acp"` so spawned ACP sessions can resume an existing ACPX/Codex conversation instead of always starting fresh. (#41847) Thanks @pejmanjohn.
|
||||
- Exec/child commands: mark child command environments with `OPENCLAW_CLI` so subprocesses can detect when they were launched from the OpenClaw CLI. (#41411) Thanks @vincentkoc.
|
||||
- OpenRouter/models: add temporary Hunter Alpha and Healer Alpha entries to the built-in catalog so OpenRouter users can try the new free stealth models during their roughly one-week availability window. (#43642) Thanks @ping-Toven.
|
||||
- iOS/Home canvas: add a bundled welcome screen with a live agent overview that refreshes on connect, reconnect, and foreground return, and move the compact connection pill off the top-left canvas overlay. (#42456) Thanks @ngutman.
|
||||
- iOS/Home canvas: replace floating controls with a docked toolbar, make the bundled home scaffold adapt to smaller phones, and open chat in the resolved main session instead of a synthetic `ios` session. (#42456) Thanks @ngutman.
|
||||
- Memory/Gemini: add `gemini-embedding-2-preview` memory-search support with configurable output dimensions and automatic reindexing when the configured dimensions change. (#42501) Thanks @BillChirico and @gumadeiras.
|
||||
- Discord/auto threads: add `autoArchiveDuration` channel config for auto-created threads so Discord thread archiving can stay at 1 hour, 1 day, 3 days, or 1 week instead of always using the 1-hour default. (#35065) Thanks @davidguttman.
|
||||
- OpenCode/onboarding: add new OpenCode Go provider, treat Zen and Go as one OpenCode setup in the wizard/docs while keeping the runtime providers split, store one shared OpenCode key for both profiles, and stop overriding the built-in `opencode-go` catalog routing. (#42313) Thanks @ImLukeF and @vincentkoc.
|
||||
- macOS/chat UI: add a chat model picker, persist explicit thinking-level selections across relaunch, and harden provider-aware session model sync for the shared chat composer. (#42314) Thanks @ImLukeF.
|
||||
- iOS/TestFlight: add a local beta release flow with Fastlane prepare/archive/upload support, canonical beta bundle IDs, and watch-app archive fixes. (#42991) Thanks @ngutman.
|
||||
- macOS/onboarding: detect when remote gateways need a shared auth token, explain where to find it on the gateway host, and clarify when a successful check used paired-device auth instead. (#43100) Thanks @ngutman.
|
||||
- Onboarding/Ollama: add first-class Ollama setup with Local or Cloud + Local modes, browser-based cloud sign-in, curated model suggestions, and cloud-model handling that skips unnecessary local pulls. (#41529) Thanks @BruceMacD.
|
||||
- OpenCode/onboarding: add new OpenCode Go provider, treat Zen and Go as one OpenCode setup in the wizard/docs while keeping the runtime providers split, store one shared OpenCode key for both profiles, and stop overriding the built-in `opencode-go` catalog routing. (#42313) Thanks @ImLukeF and @vincentkoc.
|
||||
- Memory: add opt-in multimodal image and audio indexing for `memorySearch.extraPaths` with Gemini `gemini-embedding-2-preview`, strict fallback gating, and scope-based reindexing. (#43460) Thanks @gumadeiras.
|
||||
- Memory/Gemini: add `gemini-embedding-2-preview` memory-search support with configurable output dimensions and automatic reindexing when the configured dimensions change. (#42501) Thanks @BillChirico and @gumadeiras.
|
||||
- macOS/onboarding: detect when remote gateways need a shared auth token, explain where to find it on the gateway host, and clarify when a successful check used paired-device auth instead. (#43100) Thanks @ngutman.
|
||||
- Discord/auto threads: add `autoArchiveDuration` channel config for auto-created threads so Discord thread archiving can stay at 1 hour, 1 day, 3 days, or 1 week instead of always using the 1-hour default. (#35065) Thanks @davidguttman.
|
||||
- iOS/TestFlight: add a local beta release flow with Fastlane prepare/archive/upload support, canonical beta bundle IDs, and watch-app archive fixes. (#42991) Thanks @ngutman.
|
||||
- ACP/sessions_spawn: add optional `resumeSessionId` for `runtime: "acp"` so spawned ACP sessions can resume an existing ACPX/Codex conversation instead of always starting fresh. (#41847) Thanks @pejmanjohn.
|
||||
- Gateway/node pending work: add narrow in-memory pending-work queue primitives (`node.pending.enqueue` / `node.pending.drain`) and wake-helper reuse as a foundation for dormant-node work delivery. (#41409) Thanks @mbelinky.
|
||||
- Git/runtime state: ignore the gateway-generated `.dev-state` file so local runtime state does not show up as untracked repo noise. (#41848) Thanks @smysle.
|
||||
- Exec/child commands: mark child command environments with `OPENCLAW_CLI` so subprocesses can detect when they were launched from the OpenClaw CLI. (#41411) Thanks @vincentkoc.
|
||||
|
||||
### Breaking
|
||||
|
||||
@ -27,87 +33,97 @@ Docs: https://docs.openclaw.ai
|
||||
### Fixes
|
||||
|
||||
- Agents/text sanitization: strip leaked model control tokens (`<|...|>` and full-width `<|...|>` variants) from user-facing assistant text, preventing GLM-5 and DeepSeek internal delimiters from reaching end users. (#42173) Thanks @imwyvern.
|
||||
- Resolve web tool SecretRefs atomically at runtime. (#41599) Thanks @joshavant.
|
||||
- Feishu/local image auto-convert: pass `mediaLocalRoots` through the `sendText` local-image shim so allowed local image paths upload as Feishu images again instead of falling back to raw path text. (#40623) Thanks @ayanesakura.
|
||||
- ACP/ACPX plugin: bump the bundled `acpx` pin to `0.1.16` so plugin-local installs and strict version checks match the latest published CLI. (#41975) Thanks @dutifulbob.
|
||||
- macOS/LaunchAgent install: tighten LaunchAgent directory and plist permissions during install so launchd bootstrap does not fail when the target home path or generated plist inherited group/world-writable modes.
|
||||
- iOS/gateway foreground recovery: reconnect immediately on foreground return after stale background sockets are torn down, so the app no longer stays disconnected until a later wake path happens. (#41384) Thanks @mbelinky.
|
||||
- Gateway/Control UI: keep dashboard auth tokens in session-scoped browser storage so same-tab refreshes preserve remote token auth without restoring long-lived localStorage token persistence, while scoping tokens to the selected gateway URL and fragment-only bootstrap flow. (#40892) thanks @velvet-shark.
|
||||
- Gateway/macOS launchd restarts: keep the LaunchAgent registered during explicit restarts, hand off self-restarts through a detached launchd helper, and recover config/hot reload restart paths without unloading the service. Fixes #43311, #43406, #43035, and #43049.
|
||||
- macOS/LaunchAgent install: tighten LaunchAgent directory and plist permissions during install so launchd bootstrap does not fail when the target home path or generated plist inherited group/world-writable modes.
|
||||
- Discord/reply chunking: resolve the effective `maxLinesPerMessage` config across live reply paths and preserve `chunkMode` in the fast send path so long Discord replies no longer split unexpectedly at the default 17-line limit. (#40133) thanks @rbutera.
|
||||
- Feishu/local image auto-convert: pass `mediaLocalRoots` through the `sendText` local-image shim so allowed local image paths upload as Feishu images again instead of falling back to raw path text. (#40623) Thanks @ayanesakura.
|
||||
- Models/Kimi Coding: send `anthropic-messages` tools in native Anthropic format again so `kimi-coding` stops degrading tool calls into XML/plain-text pseudo invocations instead of real `tool_use` blocks. (#38669, #39907, #40552) Thanks @opriz.
|
||||
- Telegram/outbound HTML sends: chunk long HTML-mode messages, preserve plain-text fallback and silent-delivery params across retries, and cut over to plain text when HTML chunk planning cannot safely preserve the full message. (#42240) thanks @obviyus.
|
||||
- Telegram/final preview delivery: split active preview lifecycle from cleanup retention so missing archived preview edits avoid duplicate fallback sends without clearing the live preview or blocking later in-place finalization. (#41662) thanks @hougangdev.
|
||||
- Telegram/final preview delivery followup: keep ambiguous missing-`message_id` finals only when a preview was already visible, while first-preview/no-id cases still fall back so Telegram users do not lose the final reply. (#41932) thanks @hougangdev.
|
||||
- Telegram/final preview cleanup follow-up: clear stale cleanup-retain state only for transient preview finals so archived-preview retains no longer leave a stale partial bubble beside a later fallback-sent final. (#41763) Thanks @obviyus.
|
||||
- Gateway/auth: allow one trusted device-token retry on shared-token mismatch with recovery hints to prevent reconnect churn during token drift. (#42507) Thanks @joshavant.
|
||||
- Gateway/config errors: surface up to three validation issues in top-level `config.set`, `config.patch`, and `config.apply` error messages while preserving structured issue details. (#42664) Thanks @huntharo.
|
||||
- Agents/Azure OpenAI Responses: include the `azure-openai` provider in the Responses API store override so Azure OpenAI multi-turn cron jobs and embedded agent runs no longer fail with HTTP 400 "store is set to false". (#42934, fixes #42800) Thanks @ademczuk.
|
||||
- Agents/error rendering: ignore stale assistant `errorMessage` fields on successful turns so background/tool-side failures no longer prepend synthetic billing errors over valid replies. (#40616) Thanks @ingyukoh.
|
||||
- Agents/billing recovery: probe single-provider billing cooldowns on the existing throttle so topping up credits can recover without a manual gateway restart. (#41422) thanks @altaywtf.
|
||||
- Agents/fallback: treat HTTP 499 responses as transient in both raw-text and structured failover paths so Anthropic-style client-closed overload responses trigger model fallback reliably. (#41468) thanks @zeroasterisk.
|
||||
- Agents/fallback: recognize Venice `402 Insufficient USD or Diem balance` billing errors so configured model fallbacks trigger instead of surfacing the raw provider error. (#43205) Thanks @Squabble9.
|
||||
- Agents/fallback: recognize Poe `402 You've used up your points!` billing errors so configured model fallbacks trigger instead of surfacing the raw provider error. (#42278) Thanks @CryUshio.
|
||||
- Agents/failover: treat Gemini `MALFORMED_RESPONSE` stop reasons as retryable timeouts so preview-model enum drift falls back cleanly instead of crashing the run, without also reclassifying malformed function-call errors. (#42292) Thanks @jnMetaCode.
|
||||
- Agents/cooldowns: default cooldown windows with no recorded failure history to `unknown` instead of `rate_limit`, avoiding false API rate-limit warnings while preserving cooldown recovery probes. (#42911) Thanks @VibhorGautam.
|
||||
- Auth/cooldowns: reset expired auth-profile cooldown error counters before computing the next backoff so stale on-disk counters do not re-escalate into long cooldown loops after expiry. (#41028) thanks @zerone0x.
|
||||
- Agents/memory flush: forward `memoryFlushWritePath` through `runEmbeddedPiAgent` so memory-triggered flush turns keep the append-only write guard without aborting before tool setup. Follows up on #38574. (#41761) Thanks @frankekn.
|
||||
- Agents/context pruning: prune image-only tool results during soft-trim, align context-pruning coverage with the new tool-result contract, and extend historical image cleanup to the same screenshot-heavy session path. (#43045) Thanks @MoerAI.
|
||||
- Sessions/reset model recompute: clear stale runtime model, context-token, and system-prompt metadata before session resets recompute the replacement session, so resets pick up current defaults and explicit overrides instead of reusing old runtime model state. (#41173) thanks @PonyX-lab.
|
||||
- Channels/allowlists: remove stale matcher caching so same-array allowlist edits and wildcard replacements take effect immediately, with regression coverage for in-place mutation cases.
|
||||
- Discord/Telegram outbound runtime config: thread runtime-resolved config through Discord and Telegram send paths so SecretRef-based credentials stay resolved during message delivery. (#42352) Thanks @joshavant.
|
||||
- Tools/web search: treat Brave `llm-context` grounding snippets as plain strings so `web_search` no longer returns empty snippet arrays in LLM Context mode. (#41387) thanks @zheliu2.
|
||||
- Tools/web search: recover OpenRouter Perplexity citation extraction from `message.annotations` when chat-completions responses omit top-level citations. (#40881) Thanks @laurieluo.
|
||||
- CLI/skills JSON: strip ANSI and C1 control bytes from `skills list --json`, `skills info --json`, and `skills check --json` so machine-readable output stays valid for terminals and skill metadata with embedded control characters. Fixes #27530. Related #27557. Thanks @Jimmy-xuzimo and @vincentkoc.
|
||||
- CLI/tables: default shared tables to ASCII borders on legacy Windows consoles while keeping Unicode borders on modern Windows terminals, so commands like `openclaw skills` stop rendering mojibake under GBK/936 consoles. Fixes #40853. Related #41015. Thanks @ApacheBin and @vincentkoc.
|
||||
- CLI/memory teardown: close cached memory search/index managers in the one-shot CLI shutdown path so watcher-backed memory caches no longer keep completed CLI runs alive after output finishes. (#40389) thanks @Julbarth.
|
||||
- Control UI/Sessions: restore single-column session table collapse on narrow viewport or container widths by moving the responsive table override next to the base grid rule and enabling inline-size container queries. (#12175) Thanks @benjipeng.
|
||||
- Telegram/network env-proxy: apply configured transport policy to proxied HTTPS dispatchers as well as direct `NO_PROXY` bypasses, so resolver-scoped IPv4 fallback and network settings work consistently for env-proxied Telegram traffic. (#40740) Thanks @sircrumpet.
|
||||
- Mattermost/Markdown formatting: preserve first-line indentation when stripping bot mentions so nested list items and indented code blocks keep their structure, and render Mattermost tables natively by default instead of fenced-code fallback. (#18655) thanks @echo931.
|
||||
- Mattermost/plugin send actions: normalize direct `replyTo` fallback handling so threaded plugin sends trim blank IDs and reuse the correct reply target again. (#41176) Thanks @hnykda.
|
||||
- MS Teams/allowlist resolution: use the General channel conversation ID as the resolved team key (with Graph GUID fallback) so Bot Framework runtime `channelData.team.id` matching works for team and team/channel allowlist entries. (#41838) Thanks @BradGroux.
|
||||
- Signal/config schema: accept `channels.signal.accountUuid` in strict config validation so loop-protection configs no longer fail with an unrecognized-key error. (#35578) Thanks @ingyukoh.
|
||||
- Telegram/config schema: accept `channels.telegram.actions.editMessage` and `createForumTopic` in strict config validation so existing Telegram action toggles no longer fail as unrecognized keys. (#35498) Thanks @ingyukoh.
|
||||
- Telegram/docs: clarify that `channels.telegram.groups` allowlists chats while `groupAllowFrom` allowlists users inside those chats, and point invalid negative chat IDs at the right config key. (#42451) Thanks @altaywtf.
|
||||
- Discord/config typing: expose channel-level `autoThread` on the canonical guild-channel config type so strict config loading matches the existing Discord schema and runtime behavior. (#35608) Thanks @ingyukoh.
|
||||
- fix(models): guard optional model.input capability checks (#42096) thanks @andyliu
|
||||
- Models/Alibaba Cloud Model Studio: wire `MODELSTUDIO_API_KEY` through shared env auth, implicit provider discovery, and shell-env fallback so onboarding works outside the wizard too. (#40634) Thanks @pomelo-nwu.
|
||||
- Resolve web tool SecretRefs atomically at runtime. (#41599) Thanks @joshavant.
|
||||
- Secret files: harden CLI and channel credential file reads against path-swap races by requiring direct regular files for `*File` secret inputs and rejecting symlink-backed secret files.
|
||||
- Archive extraction: harden TAR and external `tar.bz2` installs against destination symlink and pre-existing child-symlink escapes by extracting into staging first and merging into the canonical destination with safe file opens.
|
||||
- Models/Kimi Coding: send `anthropic-messages` tools in native Anthropic format again so `kimi-coding` stops degrading tool calls into XML/plain-text pseudo invocations instead of real `tool_use` blocks. (#38669, #39907, #40552) Thanks @opriz.
|
||||
- Context engine/tests: add bundled-registry regression coverage for cross-chunk resolution, plugin-sdk re-exports, and concurrent chunk registration. (#40460) thanks @dsantoreis.
|
||||
- Agents/embedded runner: bound compaction retry waiting and drain embedded runs during SIGUSR1 restart so session lanes recover instead of staying blocked behind compaction. (#40324) thanks @cgdusek.
|
||||
- Secrets/SecretRef: reject exec SecretRef traversal ids across schema, runtime, and gateway. (#42370) Thanks @joshavant.
|
||||
- Sandbox/fs bridge: pin staged writes to verified parent directories so temporary write files cannot materialize outside the allowed mount before atomic replace. Thanks @tdjackey.
|
||||
- Gateway/auth: fail closed when local `gateway.auth.*` SecretRefs are configured but unavailable, instead of silently falling back to `gateway.remote.*` credentials in local mode. (#42672) Thanks @joshavant.
|
||||
- Commands/config writes: enforce `configWrites` against both the originating account and the targeted account scope for `/config` and config-backed `/allowlist` edits, blocking sibling-account mutations while preserving gateway `operator.admin` flows. Thanks @tdjackey for reporting.
|
||||
- Security/system.run: fail closed for approval-backed interpreter/runtime commands when OpenClaw cannot bind exactly one concrete local file operand, while extending best-effort direct-file binding to additional runtime forms. Thanks @tdjackey for reporting.
|
||||
- Gateway/session reset auth: split conversation `/new` and `/reset` handling away from the admin-only `sessions.reset` control-plane RPC so write-scoped gateway callers can no longer reach the privileged reset path through `agent`. Thanks @tdjackey for reporting.
|
||||
- Security/plugin runtime: stop unauthenticated plugin HTTP routes from inheriting synthetic admin gateway scopes when they call `runtime.subagent.*`, so admin-only methods like `sessions.delete` stay blocked without gateway auth.
|
||||
- Security/session_status: enforce sandbox session-tree visibility and shared agent-to-agent access guards before reading or mutating target session state, so sandboxed subagents can no longer inspect parent session metadata or write parent model overrides via `session_status`.
|
||||
- Security/nodes: treat the `nodes` agent tool as owner-only fallback policy so non-owner senders cannot reach paired-node approval or invoke paths through the shared tool set.
|
||||
- Security/external content: treat whitespace-delimited `EXTERNAL UNTRUSTED CONTENT` boundary markers like underscore-delimited variants so prompt wrappers cannot bypass marker sanitization. (#35983) Thanks @urianpaul94.
|
||||
- Telegram/exec approvals: reject `/approve` commands aimed at other bots, keep deterministic approval prompts visible when tool-result delivery fails, and stop resolved exact IDs from matching other pending approvals by prefix. (#37233) Thanks @huntharo.
|
||||
- Subagents/authority: persist leaf vs orchestrator control scope at spawn time and route tool plus slash-command control through shared ownership checks, so leaf sessions cannot regain orchestration privileges after restore or flat-key lookups. Thanks @tdjackey.
|
||||
- ACP/ACPX plugin: bump the bundled `acpx` pin to `0.1.16` so plugin-local installs and strict version checks match the latest published CLI. (#41975) Thanks @dutifulbob.
|
||||
- ACP/sessions.patch: allow `spawnedBy` and `spawnDepth` lineage fields on ACP session keys so `sessions_spawn` with `runtime: "acp"` no longer fails during child-session setup. Fixes #40971. (#40995) thanks @xaeon2026.
|
||||
- ACP/stop reason mapping: resolve gateway chat `state: "error"` completions as ACP `end_turn` instead of `refusal` so transient backend failures are not surfaced as deliberate refusals. (#41187) thanks @pejmanjohn.
|
||||
- ACP/setSessionMode: propagate gateway `sessions.patch` failures back to ACP clients so rejected mode changes no longer return silent success. (#41185) thanks @pejmanjohn.
|
||||
- Agents/embedded logs: add structured, sanitized lifecycle and failover observation events so overload and provider failures are easier to tail and filter. (#41336) thanks @altaywtf.
|
||||
- iOS/gateway foreground recovery: reconnect immediately on foreground return after stale background sockets are torn down, so the app no longer stays disconnected until a later wake path happens. (#41384) Thanks @mbelinky.
|
||||
- Cron/subagent followup: do not misclassify empty or `NO_REPLY` cron responses as interim acknowledgements that need a rerun, so deliberately silent cron jobs are no longer retried. (#41383) thanks @jackal092927.
|
||||
- Auth/cooldowns: reset expired auth-profile cooldown error counters before computing the next backoff so stale on-disk counters do not re-escalate into long cooldown loops after expiry. (#41028) thanks @zerone0x.
|
||||
- Gateway/node pending drain followup: keep `hasMore` true when the deferred baseline status item still needs delivery, and avoid allocating empty pending-work state for drain-only nodes with no queued work. (#41429) Thanks @mbelinky.
|
||||
- ACP/bridge mode: reject unsupported per-session MCP server setup and propagate rejected session-mode changes so IDE clients see explicit bridge limitations instead of silent success. (#41424) Thanks @mbelinky.
|
||||
- ACP/session UX: replay stored user and assistant text on `loadSession`, expose Gateway-backed session controls and metadata, and emit approximate session usage updates so IDE clients restore context more faithfully. (#41425) Thanks @mbelinky.
|
||||
- ACP/tool streaming: enrich `tool_call` and `tool_call_update` events with best-effort text content and file-location hints so IDE clients can follow bridge tool activity more naturally. (#41442) Thanks @mbelinky.
|
||||
- ACP/runtime attachments: forward normalized inbound image attachments into ACP runtime turns so ACPX sessions can preserve image prompt content on the runtime path. (#41427) Thanks @mbelinky.
|
||||
- ACP/regressions: add gateway RPC coverage for ACP lineage patching, ACPX runtime coverage for image prompt serialization, and an operator smoke-test procedure for live ACP spawn verification. (#41456) Thanks @mbelinky.
|
||||
- Agents/billing recovery: probe single-provider billing cooldowns on the existing throttle so topping up credits can recover without a manual gateway restart. (#41422) thanks @altaywtf.
|
||||
- ACP/follow-up hardening: make session restore and prompt completion degrade gracefully on transcript/update failures, enforce bounded tool-location traversal, and skip non-image ACPX turns the runtime cannot serialize. (#41464) Thanks @mbelinky.
|
||||
- Agents/fallback observability: add structured, sanitized model-fallback decision and auth-profile failure-state events with correlated run IDs so cooldown probes and failover paths are easier to trace in logs. (#41337) thanks @altaywtf.
|
||||
- Protocol/Swift model sync: regenerate pending node work Swift bindings after the landed `node.pending.*` schema additions so generated protocol artifacts are consistent again. (#41477) Thanks @mbelinky.
|
||||
- Discord/reply chunking: resolve the effective `maxLinesPerMessage` config across live reply paths and preserve `chunkMode` in the fast send path so long Discord replies no longer split unexpectedly at the default 17-line limit. (#40133) thanks @rbutera.
|
||||
- Logging/probe observations: suppress structured embedded and model-fallback probe warnings on the console without hiding error or fatal output. (#41338) thanks @altaywtf.
|
||||
- Agents/fallback: treat HTTP 499 responses as transient in both raw-text and structured failover paths so Anthropic-style client-closed overload responses trigger model fallback reliably. (#41468) thanks @zeroasterisk.
|
||||
- ACP/sessions_spawn: implicitly stream `mode="run"` ACP spawns to parent only for eligible subagent orchestrator sessions (heartbeat `target: "last"` with a usable session-local route), restoring parent progress relays without thread binding. (#42404) Thanks @davidguttman.
|
||||
- ACP/main session aliases: canonicalize `main` before ACP session lookup so restarted ACP main sessions rehydrate instead of failing closed with `Session is not ACP-enabled: main`. (#43285, fixes #25692)
|
||||
- Plugins/context-engine model auth: expose `runtime.modelAuth` and plugin-sdk auth helpers so plugins can resolve provider/model API keys through the normal auth pipeline. (#41090) thanks @xinhuagu.
|
||||
- CLI/memory teardown: close cached memory search/index managers in the one-shot CLI shutdown path so watcher-backed memory caches no longer keep completed CLI runs alive after output finishes. (#40389) thanks @Julbarth.
|
||||
- Tools/web search: treat Brave `llm-context` grounding snippets as plain strings so `web_search` no longer returns empty snippet arrays in LLM Context mode. (#41387) thanks @zheliu2.
|
||||
- Telegram/exec approvals: reject `/approve` commands aimed at other bots, keep deterministic approval prompts visible when tool-result delivery fails, and stop resolved exact IDs from matching other pending approvals by prefix. (#37233) Thanks @huntharo.
|
||||
- Control UI/Sessions: restore single-column session table collapse on narrow viewport or container widths by moving the responsive table override next to the base grid rule and enabling inline-size container queries. (#12175) Thanks @benjipeng.
|
||||
- Telegram/final preview delivery: split active preview lifecycle from cleanup retention so missing archived preview edits avoid duplicate fallback sends without clearing the live preview or blocking later in-place finalization. (#41662) thanks @hougangdev.
|
||||
- Hooks/plugin context parity followup: pass `trigger` and `channelId` through embedded `llm_input`, `agent_end`, and `llm_output` hook contexts so plugins receive the same agent metadata across hook phases. (#42362) Thanks @zhoulf1006.
|
||||
- Plugins/global hook runner: harden singleton state handling so shared global hook runner reuse does not leak or corrupt runner state across executions. (#40184) Thanks @vincentkoc.
|
||||
- Context engine/tests: add bundled-registry regression coverage for cross-chunk resolution, plugin-sdk re-exports, and concurrent chunk registration. (#40460) thanks @dsantoreis.
|
||||
- Agents/embedded runner: bound compaction retry waiting and drain embedded runs during SIGUSR1 restart so session lanes recover instead of staying blocked behind compaction. (#40324) thanks @cgdusek.
|
||||
- Agents/embedded logs: add structured, sanitized lifecycle and failover observation events so overload and provider failures are easier to tail and filter. (#41336) thanks @altaywtf.
|
||||
- Agents/embedded overload logs: include the failing model and provider in error-path console output, with lifecycle regression coverage for the rendered and sanitized `consoleMessage`. (#41236) thanks @jiarung.
|
||||
- Agents/fallback observability: add structured, sanitized model-fallback decision and auth-profile failure-state events with correlated run IDs so cooldown probes and failover paths are easier to trace in logs. (#41337) thanks @altaywtf.
|
||||
- Logging/probe observations: suppress structured embedded and model-fallback probe warnings on the console without hiding error or fatal output. (#41338) thanks @altaywtf.
|
||||
- Agents/context-engine compaction: guard thrown engine-owned overflow compaction attempts and fire compaction hooks for `ownsCompaction` engines so overflow recovery no longer crashes and plugin subscribers still observe compact runs. (#41361) thanks @davidrudduck.
|
||||
- Gateway/node pending drain followup: keep `hasMore` true when the deferred baseline status item still needs delivery, and avoid allocating empty pending-work state for drain-only nodes with no queued work. (#41429) Thanks @mbelinky.
|
||||
- Protocol/Swift model sync: regenerate pending node work Swift bindings after the landed `node.pending.*` schema additions so generated protocol artifacts are consistent again. (#41477) Thanks @mbelinky.
|
||||
- Cron/subagent followup: do not misclassify empty or `NO_REPLY` cron responses as interim acknowledgements that need a rerun, so deliberately silent cron jobs are no longer retried. (#41383) thanks @jackal092927.
|
||||
- Cron/state errors: record `lastErrorReason` in cron job state and keep the gateway schema aligned with the full failover-reason set, including regression coverage for protocol conformance. (#14382) thanks @futuremind2026.
|
||||
- Tools/web search: recover OpenRouter Perplexity citation extraction from `message.annotations` when chat-completions responses omit top-level citations. (#40881) Thanks @laurieluo.
|
||||
- Security/external content: treat whitespace-delimited `EXTERNAL UNTRUSTED CONTENT` boundary markers like underscore-delimited variants so prompt wrappers cannot bypass marker sanitization. (#35983) Thanks @urianpaul94.
|
||||
- Telegram/network env-proxy: apply configured transport policy to proxied HTTPS dispatchers as well as direct `NO_PROXY` bypasses, so resolver-scoped IPv4 fallback and network settings work consistently for env-proxied Telegram traffic. (#40740) Thanks @sircrumpet.
|
||||
- Agents/memory flush: forward `memoryFlushWritePath` through `runEmbeddedPiAgent` so memory-triggered flush turns keep the append-only write guard without aborting before tool setup. Follows up on #38574. (#41761) Thanks @frankekn.
|
||||
- Browser/Browserbase 429 handling: surface stable no-retry rate-limit guidance without buffering discarded HTTP 429 response bodies from remote browser services. (#40491) thanks @mvanhorn.
|
||||
- CI/CodeQL Swift toolchain: select Xcode 26.1 before installing Swift build tools so the CodeQL Swift job uses Swift tools 6.2 on `macos-latest`. (#41787) thanks @BunsDev.
|
||||
- Sandbox/subagents: pass the real configured workspace through `sessions_spawn` inheritance when a parent agent runs in a copied-workspace sandbox, so child `/agent` mounts point at the configured workspace instead of the parent sandbox copy. (#40757) Thanks @dsantoreis.
|
||||
- Mattermost/plugin send actions: normalize direct `replyTo` fallback handling so threaded plugin sends trim blank IDs and reuse the correct reply target again. (#41176) Thanks @hnykda.
|
||||
- MS Teams/allowlist resolution: use the General channel conversation ID as the resolved team key (with Graph GUID fallback) so Bot Framework runtime `channelData.team.id` matching works for team and team/channel allowlist entries. (#41838) Thanks @BradGroux.
|
||||
- Mattermost/Markdown formatting: preserve first-line indentation when stripping bot mentions so nested list items and indented code blocks keep their structure, and render Mattermost tables natively by default instead of fenced-code fallback. (#18655) thanks @echo931.
|
||||
- Agents/fallback cooldown probing: cap cooldown-bypass probing to one attempt per provider per fallback run so multi-model same-provider cooldown chains can continue to cross-provider fallbacks instead of repeatedly stalling on duplicate cooldown probes. (#41711) Thanks @cgdusek.
|
||||
- Telegram/direct delivery: bridge direct delivery sends to internal `message:sent` hooks so internal hook listeners observe successful Telegram deliveries. (#40185) Thanks @vincentkoc.
|
||||
- Plugins/global hook runner: harden singleton state handling so shared global hook runner reuse does not leak or corrupt runner state across executions. (#40184) Thanks @vincentkoc.
|
||||
- Agents/fallback: recognize Poe `402 You've used up your points!` billing errors so configured model fallbacks trigger instead of surfacing the raw provider error. (#42278) Thanks @CryUshio.
|
||||
- Telegram/outbound HTML sends: chunk long HTML-mode messages, preserve plain-text fallback and silent-delivery params across retries, and cut over to plain text when HTML chunk planning cannot safely preserve the full message. (#42240) thanks @obviyus.
|
||||
- Agents/embedded overload logs: include the failing model and provider in error-path console output, with lifecycle regression coverage for the rendered and sanitized `consoleMessage`. (#41236) thanks @jiarung.
|
||||
- Agents/failover: treat Gemini `MALFORMED_RESPONSE` stop reasons as retryable timeouts so preview-model enum drift falls back cleanly instead of crashing the run, without also reclassifying malformed function-call errors. (#42292) Thanks @jnMetaCode.
|
||||
- Discord/Telegram outbound runtime config: thread runtime-resolved config through Discord and Telegram send paths so SecretRef-based credentials stay resolved during message delivery. (#42352) Thanks @joshavant.
|
||||
- Secrets/SecretRef: reject exec SecretRef traversal ids across schema, runtime, and gateway. (#42370) Thanks @joshavant.
|
||||
- Telegram/docs: clarify that `channels.telegram.groups` allowlists chats while `groupAllowFrom` allowlists users inside those chats, and point invalid negative chat IDs at the right config key. (#42451) Thanks @altaywtf.
|
||||
- Models/Alibaba Cloud Model Studio: wire `MODELSTUDIO_API_KEY` through shared env auth, implicit provider discovery, and shell-env fallback so onboarding works outside the wizard too. (#40634) Thanks @pomelo-nwu.
|
||||
- Subagents/authority: persist leaf vs orchestrator control scope at spawn time and route tool plus slash-command control through shared ownership checks, so leaf sessions cannot regain orchestration privileges after restore or flat-key lookups. Thanks @tdjackey.
|
||||
- ACP/sessions_spawn: implicitly stream `mode="run"` ACP spawns to parent only for eligible subagent orchestrator sessions (heartbeat `target: "last"` with a usable session-local route), restoring parent progress relays without thread binding. (#42404) Thanks @davidguttman.
|
||||
- Sessions/reset model recompute: clear stale runtime model, context-token, and system-prompt metadata before session resets recompute the replacement session, so resets pick up current defaults and explicit overrides instead of reusing old runtime model state. (#41173) thanks @PonyX-lab.
|
||||
- Browser/Browserbase 429 handling: surface stable no-retry rate-limit guidance without buffering discarded HTTP 429 response bodies from remote browser services. (#40491) thanks @mvanhorn.
|
||||
- Gateway/auth: allow one trusted device-token retry on shared-token mismatch with recovery hints to prevent reconnect churn during token drift. (#42507) Thanks @joshavant.
|
||||
- Channels/allowlists: remove stale matcher caching so same-array allowlist edits and wildcard replacements take effect immediately, with regression coverage for in-place mutation cases.
|
||||
- Gateway/auth: fail closed when local `gateway.auth.*` SecretRefs are configured but unavailable, instead of silently falling back to `gateway.remote.*` credentials in local mode. (#42672) Thanks @joshavant.
|
||||
- Sandbox/fs bridge: pin staged writes to verified parent directories so temporary write files cannot materialize outside the allowed mount before atomic replace. Thanks @tdjackey.
|
||||
- Commands/config writes: enforce `configWrites` against both the originating account and the targeted account scope for `/config` and config-backed `/allowlist` edits, blocking sibling-account mutations while preserving gateway `operator.admin` flows. Thanks @tdjackey for reporting.
|
||||
- Security/system.run: fail closed for approval-backed interpreter/runtime commands when OpenClaw cannot bind exactly one concrete local file operand, while extending best-effort direct-file binding to additional runtime forms. Thanks @tdjackey for reporting.
|
||||
- Gateway/session reset auth: split conversation `/new` and `/reset` handling away from the admin-only `sessions.reset` control-plane RPC so write-scoped gateway callers can no longer reach the privileged reset path through `agent`. Thanks @tdjackey for reporting.
|
||||
- Telegram/final preview delivery followup: keep ambiguous missing-`message_id` finals only when a preview was already visible, while first-preview/no-id cases still fall back so Telegram users do not lose the final reply. (#41932) thanks @hougangdev.
|
||||
- Agents/Azure OpenAI Responses: include the `azure-openai` provider in the Responses API store override so Azure OpenAI multi-turn cron jobs and embedded agent runs no longer fail with HTTP 400 "store is set to false". (#42934, fixes #42800) Thanks @ademczuk.
|
||||
- Agents/context pruning: prune image-only tool results during soft-trim, align context-pruning coverage with the new tool-result contract, and extend historical image cleanup to the same screenshot-heavy session path. (#43045) Thanks @MoerAI.
|
||||
- fix(models): guard optional model.input capability checks (#42096) thanks @andyliu
|
||||
- Security/plugin runtime: stop unauthenticated plugin HTTP routes from inheriting synthetic admin gateway scopes when they call `runtime.subagent.*`, so admin-only methods like `sessions.delete` stay blocked without gateway auth.
|
||||
- Security/session_status: enforce sandbox session-tree visibility and shared agent-to-agent access guards before reading or mutating target session state, so sandboxed subagents can no longer inspect parent session metadata or write parent model overrides via `session_status`.
|
||||
- Security/nodes: treat the `nodes` agent tool as owner-only fallback policy so non-owner senders cannot reach paired-node approval or invoke paths through the shared tool set.
|
||||
- Telegram/final preview cleanup follow-up: clear stale cleanup-retain state only for transient preview finals so archived-preview retains no longer leave a stale partial bubble beside a later fallback-sent final. (#41763) Thanks @obviyus.
|
||||
- Signal/config schema: accept `channels.signal.accountUuid` in strict config validation so loop-protection configs no longer fail with an unrecognized-key error. (#35578) Thanks @ingyukoh.
|
||||
- Telegram/config schema: accept `channels.telegram.actions.editMessage` and `createForumTopic` in strict config validation so existing Telegram action toggles no longer fail as unrecognized keys. (#35498) Thanks @ingyukoh.
|
||||
- Agents/cooldowns: default cooldown windows with no recorded failure history to `unknown` instead of `rate_limit`, avoiding false API rate-limit warnings while preserving cooldown recovery probes. (#42911) Thanks @VibhorGautam.
|
||||
- Discord/config typing: expose channel-level `autoThread` on the canonical guild-channel config type so strict config loading matches the existing Discord schema and runtime behavior. (#35608) Thanks @ingyukoh.
|
||||
- Agents/error rendering: ignore stale assistant `errorMessage` fields on successful turns so background/tool-side failures no longer prepend synthetic billing errors over valid replies. (#40616) Thanks @ingyukoh.
|
||||
- Dependencies: refresh workspace dependencies except the pinned Carbon package, and harden ACP session-config writes against non-string SDK values so newer ACP clients fail fast instead of tripping type/runtime mismatches.
|
||||
- Telegram/polling restarts: clear bounded cleanup timeout handles after `runner.stop()` and `bot.stop()` settle so stall recovery no longer leaves stray 15-second timers behind on clean shutdown. (#43188) thanks @kyohwang.
|
||||
|
||||
## 2026.3.8
|
||||
|
||||
@ -4023,6 +4039,7 @@ Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @Nic
|
||||
- Gateway/Daemon/Doctor: atomic config writes; repair gateway service entrypoint + install switches; non-interactive legacy migrations; systemd unit alignment + KillMode=process; node bridge keepalive/pings; Launch at Login persistence; bundle MoltbotKit resources + Swift 6.2 compat dylib; relay version check + remove smoke test; regen Swift GatewayModels + keep agent provider string; cron jobId alias + channel alias migration + main session key normalization; heartbeat Telegram accountId resolution; avoid WhatsApp fallback for internal runs; gateway listener error wording; serveBaseUrl param; honor gateway --dev; fix wide-area discovery updates; align agents.defaults schema; provider account metadata in daemon status; refresh Carbon patch for gateway fixes; restore doctor prompter initialValue handling.
|
||||
- Control UI/TUI: persist per-session verbose off + hide tool cards; logs tab opens at bottom; relative asset paths + landing cleanup; session labels lookup/persistence; stop pinning main session in recents; start logs at bottom; TUI status bar refresh + timeout handling + hide reasoning label when off.
|
||||
- Onboarding/Configure: QuickStart single-select provider picker; avoid Codex CLI false-expiry warnings; clarify WhatsApp owner prompt; fix Minimax hosted onboarding (agents.defaults + msteams heartbeat target); remove configure Control UI prompt; honor gateway --dev flag.
|
||||
- Agent loop: guard overflow compaction throws and restore compaction hooks for engine-owned context engines. (#41361) — thanks @davidrudduck
|
||||
|
||||
### Maintenance
|
||||
|
||||
|
||||
@ -63,8 +63,8 @@ android {
|
||||
applicationId = "ai.openclaw.app"
|
||||
minSdk = 31
|
||||
targetSdk = 36
|
||||
versionCode = 202603090
|
||||
versionName = "2026.3.9"
|
||||
versionCode = 202603110
|
||||
versionName = "2026.3.11-beta.1"
|
||||
ndk {
|
||||
// Support all major ABIs — native libs are tiny (~47 KB per ABI)
|
||||
abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")
|
||||
|
||||
@ -64,9 +64,9 @@ Release behavior:
|
||||
- Beta release uses canonical `ai.openclaw.client*` bundle IDs through a temporary generated xcconfig in `apps/ios/build/BetaRelease.xcconfig`.
|
||||
- The beta flow does not modify `apps/ios/.local-signing.xcconfig` or `apps/ios/LocalSigning.xcconfig`.
|
||||
- Root `package.json.version` is the only version source for iOS.
|
||||
- A root version like `2026.3.9-beta.1` becomes:
|
||||
- `CFBundleShortVersionString = 2026.3.9`
|
||||
- `CFBundleVersion = next TestFlight build number for 2026.3.9`
|
||||
- A root version like `2026.3.11-beta.1` becomes:
|
||||
- `CFBundleShortVersionString = 2026.3.11`
|
||||
- `CFBundleVersion = next TestFlight build number for 2026.3.11`
|
||||
|
||||
Archive without upload:
|
||||
|
||||
|
||||
@ -99,7 +99,7 @@ def normalize_release_version(raw_value)
|
||||
version = raw_value.to_s.strip.sub(/\Av/, "")
|
||||
UI.user_error!("Missing root package.json version.") unless env_present?(version)
|
||||
unless version.match?(/\A\d+\.\d+\.\d+(?:[.-]?beta[.-]\d+)?\z/i)
|
||||
UI.user_error!("Invalid package.json version '#{raw_value}'. Expected 2026.3.9 or 2026.3.9-beta.1.")
|
||||
UI.user_error!("Invalid package.json version '#{raw_value}'. Expected 2026.3.11 or 2026.3.11-beta.1.")
|
||||
end
|
||||
|
||||
version
|
||||
|
||||
@ -15,9 +15,9 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.3.9</string>
|
||||
<string>2026.3.11-beta.1</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>202603080</string>
|
||||
<string>202603110</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>OpenClaw</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
|
||||
@ -284,9 +284,46 @@ Notes:
|
||||
|
||||
- Paths can be absolute or workspace-relative.
|
||||
- Directories are scanned recursively for `.md` files.
|
||||
- Only Markdown files are indexed.
|
||||
- By default, only Markdown files are indexed.
|
||||
- If `memorySearch.multimodal.enabled = true`, OpenClaw also indexes supported image/audio files under `extraPaths` only. Default memory roots (`MEMORY.md`, `memory.md`, `memory/**/*.md`) stay Markdown-only.
|
||||
- Symlinks are ignored (files or directories).
|
||||
|
||||
### Multimodal memory files (Gemini image + audio)
|
||||
|
||||
OpenClaw can index image and audio files from `memorySearch.extraPaths` when using Gemini embedding 2:
|
||||
|
||||
```json5
|
||||
agents: {
|
||||
defaults: {
|
||||
memorySearch: {
|
||||
provider: "gemini",
|
||||
model: "gemini-embedding-2-preview",
|
||||
extraPaths: ["assets/reference", "voice-notes"],
|
||||
multimodal: {
|
||||
enabled: true,
|
||||
modalities: ["image", "audio"], // or ["all"]
|
||||
maxFileBytes: 10000000
|
||||
},
|
||||
remote: {
|
||||
apiKey: "YOUR_GEMINI_API_KEY"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- Multimodal memory is currently supported only for `gemini-embedding-2-preview`.
|
||||
- Multimodal indexing applies only to files discovered through `memorySearch.extraPaths`.
|
||||
- Supported modalities in this phase: image and audio.
|
||||
- `memorySearch.fallback` must stay `"none"` while multimodal memory is enabled.
|
||||
- Matching image/audio file bytes are uploaded to the configured Gemini embedding endpoint during indexing.
|
||||
- Supported image extensions: `.jpg`, `.jpeg`, `.png`, `.webp`, `.gif`, `.heic`, `.heif`.
|
||||
- Supported audio extensions: `.mp3`, `.wav`, `.ogg`, `.opus`, `.m4a`, `.aac`, `.flac`.
|
||||
- Search queries remain text, but Gemini can compare those text queries against indexed image/audio embeddings.
|
||||
- `memory_get` still reads Markdown only; binary files are searchable but not returned as raw file contents.
|
||||
|
||||
### Gemini embeddings (native)
|
||||
|
||||
Set the provider to `gemini` to use the Gemini embeddings API directly:
|
||||
|
||||
@ -357,7 +357,7 @@ Ollama is a local LLM runtime that provides an OpenAI-compatible API:
|
||||
- Provider: `ollama`
|
||||
- Auth: None required (local server)
|
||||
- Example model: `ollama/llama3.3`
|
||||
- Installation: [https://ollama.ai](https://ollama.ai)
|
||||
- Installation: [https://ollama.com/download](https://ollama.com/download)
|
||||
|
||||
```bash
|
||||
# Install Ollama, then pull a model:
|
||||
@ -372,7 +372,7 @@ ollama pull llama3.3
|
||||
}
|
||||
```
|
||||
|
||||
Ollama is automatically detected when running locally at `http://127.0.0.1:11434/v1`. See [/providers/ollama](/providers/ollama) for model recommendations and custom configuration.
|
||||
Ollama is detected locally at `http://127.0.0.1:11434` when you opt in with `OLLAMA_API_KEY`, and `openclaw onboard` can configure it directly as a first-class provider. See [/providers/ollama](/providers/ollama) for onboarding, cloud/local mode, and custom configuration.
|
||||
|
||||
### vLLM
|
||||
|
||||
|
||||
@ -11,6 +11,8 @@ title: "Local Models"
|
||||
|
||||
Local is doable, but OpenClaw expects large context + strong defenses against prompt injection. Small cards truncate context and leak safety. Aim high: **≥2 maxed-out Mac Studios or equivalent GPU rig (~$30k+)**. A single **24 GB** GPU works only for lighter prompts with higher latency. Use the **largest / full-size model variant you can run**; aggressively quantized or “small” checkpoints raise prompt-injection risk (see [Security](/gateway/security)).
|
||||
|
||||
If you want the lowest-friction local setup, start with [Ollama](/providers/ollama) and `openclaw onboard`. This page is the opinionated guide for higher-end local stacks and custom OpenAI-compatible local servers.
|
||||
|
||||
## Recommended: LM Studio + MiniMax M2.5 (Responses API, full-size)
|
||||
|
||||
Best current local stack. Load MiniMax M2.5 in LM Studio, enable the local server (default `http://127.0.0.1:1234`), and use Responses API to keep reasoning separate from final text.
|
||||
|
||||
@ -2084,8 +2084,21 @@ More context: [Models](/concepts/models).
|
||||
|
||||
### Can I use selfhosted models llamacpp vLLM Ollama
|
||||
|
||||
Yes. If your local server exposes an OpenAI-compatible API, you can point a
|
||||
custom provider at it. Ollama is supported directly and is the easiest path.
|
||||
Yes. Ollama is the easiest path for local models.
|
||||
|
||||
Quickest setup:
|
||||
|
||||
1. Install Ollama from `https://ollama.com/download`
|
||||
2. Pull a local model such as `ollama pull glm-4.7-flash`
|
||||
3. If you want Ollama Cloud too, run `ollama signin`
|
||||
4. Run `openclaw onboard` and choose `Ollama`
|
||||
5. Pick `Local` or `Cloud + Local`
|
||||
|
||||
Notes:
|
||||
|
||||
- `Cloud + Local` gives you Ollama Cloud models plus your local Ollama models
|
||||
- cloud models such as `kimi-k2.5:cloud` do not need a local pull
|
||||
- for manual switching, use `openclaw models list` and `openclaw models set ollama/<model>`
|
||||
|
||||
Security note: smaller or heavily quantized models are more vulnerable to prompt
|
||||
injection. We strongly recommend **large models** for any bot that can use tools.
|
||||
|
||||
@ -39,7 +39,7 @@ Notes:
|
||||
# Default is auto-derived from APP_VERSION when omitted.
|
||||
SKIP_NOTARIZE=1 \
|
||||
BUNDLE_ID=ai.openclaw.mac \
|
||||
APP_VERSION=2026.3.9 \
|
||||
APP_VERSION=2026.3.11 \
|
||||
BUILD_CONFIG=release \
|
||||
SIGN_IDENTITY="Developer ID Application: <Developer Name> (<TEAMID>)" \
|
||||
scripts/package-mac-dist.sh
|
||||
@ -47,10 +47,10 @@ scripts/package-mac-dist.sh
|
||||
# `package-mac-dist.sh` already creates the zip + DMG.
|
||||
# If you used `package-mac-app.sh` directly instead, create them manually:
|
||||
# If you want notarization/stapling in this step, use the NOTARIZE command below.
|
||||
ditto -c -k --sequesterRsrc --keepParent dist/OpenClaw.app dist/OpenClaw-2026.3.9.zip
|
||||
ditto -c -k --sequesterRsrc --keepParent dist/OpenClaw.app dist/OpenClaw-2026.3.11.zip
|
||||
|
||||
# Optional: build a styled DMG for humans (drag to /Applications)
|
||||
scripts/create-dmg.sh dist/OpenClaw.app dist/OpenClaw-2026.3.9.dmg
|
||||
scripts/create-dmg.sh dist/OpenClaw.app dist/OpenClaw-2026.3.11.dmg
|
||||
|
||||
# Recommended: build + notarize/staple zip + DMG
|
||||
# First, create a keychain profile once:
|
||||
@ -58,13 +58,13 @@ scripts/create-dmg.sh dist/OpenClaw.app dist/OpenClaw-2026.3.9.dmg
|
||||
# --apple-id "<apple-id>" --team-id "<team-id>" --password "<app-specific-password>"
|
||||
NOTARIZE=1 NOTARYTOOL_PROFILE=openclaw-notary \
|
||||
BUNDLE_ID=ai.openclaw.mac \
|
||||
APP_VERSION=2026.3.9 \
|
||||
APP_VERSION=2026.3.11 \
|
||||
BUILD_CONFIG=release \
|
||||
SIGN_IDENTITY="Developer ID Application: <Developer Name> (<TEAMID>)" \
|
||||
scripts/package-mac-dist.sh
|
||||
|
||||
# Optional: ship dSYM alongside the release
|
||||
ditto -c -k --keepParent apps/macos/.build/release/OpenClaw.app.dSYM dist/OpenClaw-2026.3.9.dSYM.zip
|
||||
ditto -c -k --keepParent apps/macos/.build/release/OpenClaw.app.dSYM dist/OpenClaw-2026.3.11.dSYM.zip
|
||||
```
|
||||
|
||||
## Appcast entry
|
||||
@ -72,7 +72,7 @@ ditto -c -k --keepParent apps/macos/.build/release/OpenClaw.app.dSYM dist/OpenCl
|
||||
Use the release note generator so Sparkle renders formatted HTML notes:
|
||||
|
||||
```bash
|
||||
SPARKLE_PRIVATE_KEY_FILE=/path/to/ed25519-private-key scripts/make_appcast.sh dist/OpenClaw-2026.3.9.zip https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml
|
||||
SPARKLE_PRIVATE_KEY_FILE=/path/to/ed25519-private-key scripts/make_appcast.sh dist/OpenClaw-2026.3.11.zip https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml
|
||||
```
|
||||
|
||||
Generates HTML release notes from `CHANGELOG.md` (via [`scripts/changelog-to-html.sh`](https://github.com/openclaw/openclaw/blob/main/scripts/changelog-to-html.sh)) and embeds them in the appcast entry.
|
||||
@ -80,7 +80,7 @@ Commit the updated `appcast.xml` alongside the release assets (zip + dSYM) when
|
||||
|
||||
## Publish & verify
|
||||
|
||||
- Upload `OpenClaw-2026.3.9.zip` (and `OpenClaw-2026.3.9.dSYM.zip`) to the GitHub release for tag `v2026.3.9`.
|
||||
- Upload `OpenClaw-2026.3.11.zip` (and `OpenClaw-2026.3.11.dSYM.zip`) to the GitHub release for tag `v2026.3.11`.
|
||||
- Ensure the raw appcast URL matches the baked feed: `https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml`.
|
||||
- Sanity checks:
|
||||
- `curl -I https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml` returns 200.
|
||||
|
||||
@ -153,30 +153,33 @@ sudo systemctl status openclaw
|
||||
journalctl -u openclaw -f
|
||||
```
|
||||
|
||||
## 9) Access the Dashboard
|
||||
## 9) Access the OpenClaw Dashboard
|
||||
|
||||
Since the Pi is headless, use an SSH tunnel:
|
||||
Replace `user@gateway-host` with your Pi username and hostname or IP address.
|
||||
|
||||
On your computer, ask the Pi to print a fresh dashboard URL:
|
||||
|
||||
```bash
|
||||
# From your laptop/desktop
|
||||
ssh -L 18789:localhost:18789 user@gateway-host
|
||||
|
||||
# Then open in browser
|
||||
open http://localhost:18789
|
||||
ssh user@gateway-host 'openclaw dashboard --no-open'
|
||||
```
|
||||
|
||||
Or use Tailscale for always-on access:
|
||||
The command prints `Dashboard URL:`. Depending on how `gateway.auth.token`
|
||||
is configured, the URL may be a plain `http://127.0.0.1:18789/` link or one
|
||||
that includes `#token=...`.
|
||||
|
||||
In another terminal on your computer, create the SSH tunnel:
|
||||
|
||||
```bash
|
||||
# On the Pi
|
||||
curl -fsSL https://tailscale.com/install.sh | sh
|
||||
sudo tailscale up
|
||||
|
||||
# Update config
|
||||
openclaw config set gateway.bind tailnet
|
||||
sudo systemctl restart openclaw
|
||||
ssh -N -L 18789:127.0.0.1:18789 user@gateway-host
|
||||
```
|
||||
|
||||
Then open the printed Dashboard URL in your local browser.
|
||||
|
||||
If the UI asks for auth, paste the token from `gateway.auth.token`
|
||||
(or `OPENCLAW_GATEWAY_TOKEN`) into Control UI settings.
|
||||
|
||||
For always-on remote access, see [Tailscale](/gateway/tailscale).
|
||||
|
||||
---
|
||||
|
||||
## Performance Optimizations
|
||||
|
||||
@ -8,7 +8,7 @@ title: "Ollama"
|
||||
|
||||
# Ollama
|
||||
|
||||
Ollama is a local LLM runtime that makes it easy to run open-source models on your machine. OpenClaw integrates with Ollama's native API (`/api/chat`), supporting streaming and tool calling, and can **auto-discover tool-capable models** when you opt in with `OLLAMA_API_KEY` (or an auth profile) and do not define an explicit `models.providers.ollama` entry.
|
||||
Ollama is a local LLM runtime that makes it easy to run open-source models on your machine. OpenClaw integrates with Ollama's native API (`/api/chat`), supports streaming and tool calling, and can auto-discover local Ollama models when you opt in with `OLLAMA_API_KEY` (or an auth profile) and do not define an explicit `models.providers.ollama` entry.
|
||||
|
||||
<Warning>
|
||||
**Remote Ollama users**: Do not use the `/v1` OpenAI-compatible URL (`http://host:11434/v1`) with OpenClaw. This breaks tool calling and models may output raw tool JSON as plain text. Use the native Ollama API URL instead: `baseUrl: "http://host:11434"` (no `/v1`).
|
||||
@ -16,21 +16,40 @@ Ollama is a local LLM runtime that makes it easy to run open-source models on yo
|
||||
|
||||
## Quick start
|
||||
|
||||
1. Install Ollama: [https://ollama.ai](https://ollama.ai)
|
||||
1. Install Ollama: [https://ollama.com/download](https://ollama.com/download)
|
||||
|
||||
2. Pull a model:
|
||||
2. Pull a local model if you want local inference:
|
||||
|
||||
```bash
|
||||
ollama pull glm-4.7-flash
|
||||
# or
|
||||
ollama pull gpt-oss:20b
|
||||
# or
|
||||
ollama pull llama3.3
|
||||
# or
|
||||
ollama pull qwen2.5-coder:32b
|
||||
# or
|
||||
ollama pull deepseek-r1:32b
|
||||
```
|
||||
|
||||
3. Enable Ollama for OpenClaw (any value works; Ollama doesn't require a real key):
|
||||
3. If you want Ollama Cloud models too, sign in:
|
||||
|
||||
```bash
|
||||
ollama signin
|
||||
```
|
||||
|
||||
4. Run onboarding and choose `Ollama`:
|
||||
|
||||
```bash
|
||||
openclaw onboard
|
||||
```
|
||||
|
||||
- `Local`: local models only
|
||||
- `Cloud + Local`: local models plus Ollama Cloud models
|
||||
- Cloud models such as `kimi-k2.5:cloud`, `minimax-m2.5:cloud`, and `glm-5:cloud` do **not** require a local `ollama pull`
|
||||
|
||||
OpenClaw currently suggests:
|
||||
|
||||
- local default: `glm-4.7-flash`
|
||||
- cloud defaults: `kimi-k2.5:cloud`, `minimax-m2.5:cloud`, `glm-5:cloud`
|
||||
|
||||
5. If you prefer manual setup, enable Ollama for OpenClaw directly (any value works; Ollama doesn't require a real key):
|
||||
|
||||
```bash
|
||||
# Set environment variable
|
||||
@ -40,13 +59,20 @@ export OLLAMA_API_KEY="ollama-local"
|
||||
openclaw config set models.providers.ollama.apiKey "ollama-local"
|
||||
```
|
||||
|
||||
4. Use Ollama models:
|
||||
6. Inspect or switch models:
|
||||
|
||||
```bash
|
||||
openclaw models list
|
||||
openclaw models set ollama/glm-4.7-flash
|
||||
```
|
||||
|
||||
7. Or set the default in config:
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "ollama/gpt-oss:20b" },
|
||||
model: { primary: "ollama/glm-4.7-flash" },
|
||||
},
|
||||
},
|
||||
}
|
||||
@ -56,14 +82,13 @@ openclaw config set models.providers.ollama.apiKey "ollama-local"
|
||||
|
||||
When you set `OLLAMA_API_KEY` (or an auth profile) and **do not** define `models.providers.ollama`, OpenClaw discovers models from the local Ollama instance at `http://127.0.0.1:11434`:
|
||||
|
||||
- Queries `/api/tags` and `/api/show`
|
||||
- Keeps only models that report `tools` capability
|
||||
- Marks `reasoning` when the model reports `thinking`
|
||||
- Reads `contextWindow` from `model_info["<arch>.context_length"]` when available
|
||||
- Sets `maxTokens` to 10× the context window
|
||||
- Queries `/api/tags`
|
||||
- Uses best-effort `/api/show` lookups to read `contextWindow` when available
|
||||
- Marks `reasoning` with a model-name heuristic (`r1`, `reasoning`, `think`)
|
||||
- Sets `maxTokens` to the default Ollama max-token cap used by OpenClaw
|
||||
- Sets all costs to `0`
|
||||
|
||||
This avoids manual model entries while keeping the catalog aligned with Ollama's capabilities.
|
||||
This avoids manual model entries while keeping the catalog aligned with the local Ollama instance.
|
||||
|
||||
To see what models are available:
|
||||
|
||||
@ -98,7 +123,7 @@ Use explicit config when:
|
||||
|
||||
- Ollama runs on another host/port.
|
||||
- You want to force specific context windows or model lists.
|
||||
- You want to include models that do not report tool support.
|
||||
- You want fully manual model definitions.
|
||||
|
||||
```json5
|
||||
{
|
||||
@ -170,7 +195,7 @@ Once configured, all your Ollama models are available:
|
||||
|
||||
### Reasoning models
|
||||
|
||||
OpenClaw marks models as reasoning-capable when Ollama reports `thinking` in `/api/show`:
|
||||
OpenClaw treats models with names such as `deepseek-r1`, `reasoning`, or `think` as reasoning-capable by default:
|
||||
|
||||
```bash
|
||||
ollama pull deepseek-r1:32b
|
||||
@ -230,7 +255,7 @@ When `api: "openai-completions"` is used with Ollama, OpenClaw injects `options.
|
||||
|
||||
### Context windows
|
||||
|
||||
For auto-discovered models, OpenClaw uses the context window reported by Ollama when available, otherwise it defaults to `8192`. You can override `contextWindow` and `maxTokens` in explicit provider config.
|
||||
For auto-discovered models, OpenClaw uses the context window reported by Ollama when available, otherwise it falls back to the default Ollama context window used by OpenClaw. You can override `contextWindow` and `maxTokens` in explicit provider config.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
@ -250,16 +275,17 @@ curl http://localhost:11434/api/tags
|
||||
|
||||
### No models available
|
||||
|
||||
OpenClaw only auto-discovers models that report tool support. If your model isn't listed, either:
|
||||
If your model is not listed, either:
|
||||
|
||||
- Pull a tool-capable model, or
|
||||
- Pull the model locally, or
|
||||
- Define the model explicitly in `models.providers.ollama`.
|
||||
|
||||
To add models:
|
||||
|
||||
```bash
|
||||
ollama list # See what's installed
|
||||
ollama pull gpt-oss:20b # Pull a tool-capable model
|
||||
ollama pull glm-4.7-flash
|
||||
ollama pull gpt-oss:20b
|
||||
ollama pull llama3.3 # Or another model
|
||||
```
|
||||
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
{
|
||||
"name": "@openclaw/acpx",
|
||||
"version": "2026.3.9",
|
||||
"version": "2026.3.11",
|
||||
"description": "OpenClaw ACP runtime backend via acpx",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"acpx": "0.1.16"
|
||||
"acpx": "0.2.0"
|
||||
},
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/bluebubbles",
|
||||
"version": "2026.3.9",
|
||||
"version": "2026.3.11",
|
||||
"description": "OpenClaw BlueBubbles channel plugin",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/copilot-proxy",
|
||||
"version": "2026.3.9",
|
||||
"version": "2026.3.11",
|
||||
"private": true,
|
||||
"description": "OpenClaw Copilot Proxy provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/diagnostics-otel",
|
||||
"version": "2026.3.9",
|
||||
"version": "2026.3.11",
|
||||
"description": "OpenClaw diagnostics OpenTelemetry exporter",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/diffs",
|
||||
"version": "2026.3.9",
|
||||
"version": "2026.3.11",
|
||||
"private": true,
|
||||
"description": "OpenClaw diff viewer plugin",
|
||||
"type": "module",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/discord",
|
||||
"version": "2026.3.9",
|
||||
"version": "2026.3.11",
|
||||
"description": "OpenClaw Discord channel plugin",
|
||||
"type": "module",
|
||||
"openclaw": {
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@openclaw/feishu",
|
||||
"version": "2026.3.9",
|
||||
"version": "2026.3.11",
|
||||
"description": "OpenClaw Feishu/Lark channel plugin (community maintained by @m1heng)",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@larksuiteoapi/node-sdk": "^1.59.0",
|
||||
"@sinclair/typebox": "0.34.48",
|
||||
"https-proxy-agent": "^7.0.6",
|
||||
"https-proxy-agent": "^8.0.0",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"openclaw": {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/google-gemini-cli-auth",
|
||||
"version": "2026.3.9",
|
||||
"version": "2026.3.11",
|
||||
"private": true,
|
||||
"description": "OpenClaw Gemini CLI OAuth provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@ -1,15 +1,12 @@
|
||||
{
|
||||
"name": "@openclaw/googlechat",
|
||||
"version": "2026.3.9",
|
||||
"version": "2026.3.11",
|
||||
"private": true,
|
||||
"description": "OpenClaw Google Chat channel plugin",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"google-auth-library": "^10.6.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"openclaw": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"openclaw": ">=2026.3.7"
|
||||
},
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/imessage",
|
||||
"version": "2026.3.9",
|
||||
"version": "2026.3.11",
|
||||
"private": true,
|
||||
"description": "OpenClaw iMessage channel plugin",
|
||||
"type": "module",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/irc",
|
||||
"version": "2026.3.9",
|
||||
"version": "2026.3.11",
|
||||
"description": "OpenClaw IRC channel plugin",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/line",
|
||||
"version": "2026.3.9",
|
||||
"version": "2026.3.11",
|
||||
"private": true,
|
||||
"description": "OpenClaw LINE channel plugin",
|
||||
"type": "module",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/llm-task",
|
||||
"version": "2026.3.9",
|
||||
"version": "2026.3.11",
|
||||
"private": true,
|
||||
"description": "OpenClaw JSON-only LLM task plugin",
|
||||
"type": "module",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/lobster",
|
||||
"version": "2026.3.9",
|
||||
"version": "2026.3.11",
|
||||
"description": "Lobster workflow tool plugin (typed pipelines + resumable approvals)",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
@ -1,5 +1,17 @@
|
||||
# Changelog
|
||||
|
||||
## 2026.3.11
|
||||
|
||||
### Changes
|
||||
|
||||
- Version alignment with core OpenClaw release numbers.
|
||||
|
||||
## 2026.3.10
|
||||
|
||||
### Changes
|
||||
|
||||
- Version alignment with core OpenClaw release numbers.
|
||||
|
||||
## 2026.3.9
|
||||
|
||||
### Changes
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/matrix",
|
||||
"version": "2026.3.9",
|
||||
"version": "2026.3.11",
|
||||
"description": "OpenClaw Matrix channel plugin",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/mattermost",
|
||||
"version": "2026.3.9",
|
||||
"version": "2026.3.11",
|
||||
"description": "OpenClaw Mattermost channel plugin",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
@ -1,12 +1,9 @@
|
||||
{
|
||||
"name": "@openclaw/memory-core",
|
||||
"version": "2026.3.9",
|
||||
"version": "2026.3.11",
|
||||
"private": true,
|
||||
"description": "OpenClaw core memory search plugin",
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"openclaw": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"openclaw": ">=2026.3.7"
|
||||
},
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/memory-lancedb",
|
||||
"version": "2026.3.9",
|
||||
"version": "2026.3.11",
|
||||
"private": true,
|
||||
"description": "OpenClaw LanceDB-backed long-term memory plugin with auto-recall/capture",
|
||||
"type": "module",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/minimax-portal-auth",
|
||||
"version": "2026.3.9",
|
||||
"version": "2026.3.11",
|
||||
"private": true,
|
||||
"description": "OpenClaw MiniMax Portal OAuth provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@ -1,5 +1,17 @@
|
||||
# Changelog
|
||||
|
||||
## 2026.3.11
|
||||
|
||||
### Changes
|
||||
|
||||
- Version alignment with core OpenClaw release numbers.
|
||||
|
||||
## 2026.3.10
|
||||
|
||||
### Changes
|
||||
|
||||
- Version alignment with core OpenClaw release numbers.
|
||||
|
||||
## 2026.3.9
|
||||
|
||||
### Changes
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/msteams",
|
||||
"version": "2026.3.9",
|
||||
"version": "2026.3.11",
|
||||
"description": "OpenClaw Microsoft Teams channel plugin",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/nextcloud-talk",
|
||||
"version": "2026.3.9",
|
||||
"version": "2026.3.11",
|
||||
"description": "OpenClaw Nextcloud Talk channel plugin",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
@ -1,5 +1,17 @@
|
||||
# Changelog
|
||||
|
||||
## 2026.3.11
|
||||
|
||||
### Changes
|
||||
|
||||
- Version alignment with core OpenClaw release numbers.
|
||||
|
||||
## 2026.3.10
|
||||
|
||||
### Changes
|
||||
|
||||
- Version alignment with core OpenClaw release numbers.
|
||||
|
||||
## 2026.3.9
|
||||
|
||||
### Changes
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/nostr",
|
||||
"version": "2026.3.9",
|
||||
"version": "2026.3.11",
|
||||
"description": "OpenClaw Nostr channel plugin for NIP-04 encrypted DMs",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/open-prose",
|
||||
"version": "2026.3.9",
|
||||
"version": "2026.3.11",
|
||||
"private": true,
|
||||
"description": "OpenProse VM skill pack plugin (slash command + telemetry).",
|
||||
"type": "module",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/signal",
|
||||
"version": "2026.3.9",
|
||||
"version": "2026.3.11",
|
||||
"private": true,
|
||||
"description": "OpenClaw Signal channel plugin",
|
||||
"type": "module",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/slack",
|
||||
"version": "2026.3.9",
|
||||
"version": "2026.3.11",
|
||||
"private": true,
|
||||
"description": "OpenClaw Slack channel plugin",
|
||||
"type": "module",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/synology-chat",
|
||||
"version": "2026.3.9",
|
||||
"version": "2026.3.11",
|
||||
"description": "Synology Chat channel plugin for OpenClaw",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/telegram",
|
||||
"version": "2026.3.9",
|
||||
"version": "2026.3.11",
|
||||
"private": true,
|
||||
"description": "OpenClaw Telegram channel plugin",
|
||||
"type": "module",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/tlon",
|
||||
"version": "2026.3.9",
|
||||
"version": "2026.3.11",
|
||||
"description": "OpenClaw Tlon/Urbit channel plugin",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
@ -1,5 +1,17 @@
|
||||
# Changelog
|
||||
|
||||
## 2026.3.11
|
||||
|
||||
### Changes
|
||||
|
||||
- Version alignment with core OpenClaw release numbers.
|
||||
|
||||
## 2026.3.10
|
||||
|
||||
### Changes
|
||||
|
||||
- Version alignment with core OpenClaw release numbers.
|
||||
|
||||
## 2026.3.9
|
||||
|
||||
### Changes
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/twitch",
|
||||
"version": "2026.3.9",
|
||||
"version": "2026.3.11",
|
||||
"description": "OpenClaw Twitch channel plugin",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
@ -1,5 +1,17 @@
|
||||
# Changelog
|
||||
|
||||
## 2026.3.11
|
||||
|
||||
### Changes
|
||||
|
||||
- Version alignment with core OpenClaw release numbers.
|
||||
|
||||
## 2026.3.10
|
||||
|
||||
### Changes
|
||||
|
||||
- Version alignment with core OpenClaw release numbers.
|
||||
|
||||
## 2026.3.9
|
||||
|
||||
### Changes
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/voice-call",
|
||||
"version": "2026.3.9",
|
||||
"version": "2026.3.11",
|
||||
"description": "OpenClaw voice-call plugin",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/whatsapp",
|
||||
"version": "2026.3.9",
|
||||
"version": "2026.3.11",
|
||||
"private": true,
|
||||
"description": "OpenClaw WhatsApp channel plugin",
|
||||
"type": "module",
|
||||
|
||||
@ -1,5 +1,17 @@
|
||||
# Changelog
|
||||
|
||||
## 2026.3.11
|
||||
|
||||
### Changes
|
||||
|
||||
- Version alignment with core OpenClaw release numbers.
|
||||
|
||||
## 2026.3.10
|
||||
|
||||
### Changes
|
||||
|
||||
- Version alignment with core OpenClaw release numbers.
|
||||
|
||||
## 2026.3.9
|
||||
|
||||
### Changes
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/zalo",
|
||||
"version": "2026.3.9",
|
||||
"version": "2026.3.11",
|
||||
"description": "OpenClaw Zalo channel plugin",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
@ -1,5 +1,17 @@
|
||||
# Changelog
|
||||
|
||||
## 2026.3.11
|
||||
|
||||
### Changes
|
||||
|
||||
- Version alignment with core OpenClaw release numbers.
|
||||
|
||||
## 2026.3.10
|
||||
|
||||
### Changes
|
||||
|
||||
- Version alignment with core OpenClaw release numbers.
|
||||
|
||||
## 2026.3.9
|
||||
|
||||
### Changes
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/zalouser",
|
||||
"version": "2026.3.9",
|
||||
"version": "2026.3.11",
|
||||
"description": "OpenClaw Zalo Personal Account plugin via native zca-js integration",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
22
package.json
22
package.json
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openclaw",
|
||||
"version": "2026.3.9",
|
||||
"version": "2026.3.11-beta.1",
|
||||
"description": "Multi-channel AI gateway with extensible messaging integrations",
|
||||
"keywords": [],
|
||||
"homepage": "https://github.com/openclaw/openclaw#readme",
|
||||
@ -338,11 +338,11 @@
|
||||
"ui:install": "node scripts/ui.js install"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "0.15.0",
|
||||
"@aws-sdk/client-bedrock": "^3.1004.0",
|
||||
"@agentclientprotocol/sdk": "0.16.1",
|
||||
"@aws-sdk/client-bedrock": "^3.1007.0",
|
||||
"@buape/carbon": "0.0.0-beta-20260216184201",
|
||||
"@clack/prompts": "^1.1.0",
|
||||
"@discordjs/voice": "^0.19.0",
|
||||
"@discordjs/voice": "^0.19.1",
|
||||
"@grammyjs/runner": "^2.0.3",
|
||||
"@grammyjs/transformer-throttler": "^1.2.1",
|
||||
"@homebridge/ciao": "^1.3.5",
|
||||
@ -364,13 +364,13 @@
|
||||
"cli-highlight": "^2.1.11",
|
||||
"commander": "^14.0.3",
|
||||
"croner": "^10.0.1",
|
||||
"discord-api-types": "^0.38.41",
|
||||
"discord-api-types": "^0.38.42",
|
||||
"dotenv": "^17.3.1",
|
||||
"express": "^5.2.1",
|
||||
"file-type": "^21.3.1",
|
||||
"grammy": "^1.41.1",
|
||||
"hono": "4.12.7",
|
||||
"https-proxy-agent": "^7.0.6",
|
||||
"https-proxy-agent": "^8.0.0",
|
||||
"ipaddr.js": "^2.3.0",
|
||||
"jiti": "^2.6.1",
|
||||
"json5": "^2.2.3",
|
||||
@ -399,18 +399,18 @@
|
||||
"@lit/context": "^1.1.6",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/markdown-it": "^14.1.2",
|
||||
"@types/node": "^25.3.5",
|
||||
"@types/node": "^25.4.0",
|
||||
"@types/qrcode-terminal": "^0.12.2",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260308.1",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260311.1",
|
||||
"@vitest/coverage-v8": "^4.0.18",
|
||||
"jscpd": "4.0.8",
|
||||
"lit": "^3.3.2",
|
||||
"oxfmt": "0.36.0",
|
||||
"oxlint": "^1.51.0",
|
||||
"oxfmt": "0.38.0",
|
||||
"oxlint": "^1.53.0",
|
||||
"oxlint-tsgolint": "^0.16.0",
|
||||
"signal-utils": "0.21.1",
|
||||
"tsdown": "0.21.0",
|
||||
"tsdown": "0.21.2",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.0.18"
|
||||
|
||||
1374
pnpm-lock.yaml
generated
1374
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -73,7 +73,7 @@ fi
|
||||
if [[ "${PACKAGE_VERSION}" =~ ^([0-9]{4}\.[0-9]{1,2}\.[0-9]{1,2})([.-]?beta[.-][0-9]+)?$ ]]; then
|
||||
MARKETING_VERSION="${BASH_REMATCH[1]}"
|
||||
else
|
||||
echo "Unsupported package.json.version '${PACKAGE_VERSION}'. Expected 2026.3.9 or 2026.3.9-beta.1." >&2
|
||||
echo "Unsupported package.json.version '${PACKAGE_VERSION}'. Expected 2026.3.11 or 2026.3.11-beta.1." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
@ -44,11 +44,11 @@ import {
|
||||
type TurnLatencyStats,
|
||||
} from "./manager.types.js";
|
||||
import {
|
||||
canonicalizeAcpSessionKey,
|
||||
createUnsupportedControlError,
|
||||
hasLegacyAcpIdentityProjection,
|
||||
normalizeAcpErrorCode,
|
||||
normalizeActorKey,
|
||||
normalizeSessionKey,
|
||||
requireReadySessionMeta,
|
||||
resolveAcpAgentFromSessionKey,
|
||||
resolveAcpSessionResolutionError,
|
||||
@ -87,7 +87,7 @@ export class AcpSessionManager {
|
||||
constructor(private readonly deps: AcpSessionManagerDeps = DEFAULT_DEPS) {}
|
||||
|
||||
resolveSession(params: { cfg: OpenClawConfig; sessionKey: string }): AcpSessionResolution {
|
||||
const sessionKey = normalizeSessionKey(params.sessionKey);
|
||||
const sessionKey = canonicalizeAcpSessionKey(params);
|
||||
if (!sessionKey) {
|
||||
return {
|
||||
kind: "none",
|
||||
@ -213,7 +213,10 @@ export class AcpSessionManager {
|
||||
handle: AcpRuntimeHandle;
|
||||
meta: SessionAcpMeta;
|
||||
}> {
|
||||
const sessionKey = normalizeSessionKey(input.sessionKey);
|
||||
const sessionKey = canonicalizeAcpSessionKey({
|
||||
cfg: input.cfg,
|
||||
sessionKey: input.sessionKey,
|
||||
});
|
||||
if (!sessionKey) {
|
||||
throw new AcpRuntimeError("ACP_SESSION_INIT_FAILED", "ACP session key is required.");
|
||||
}
|
||||
@ -321,7 +324,7 @@ export class AcpSessionManager {
|
||||
sessionKey: string;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<AcpSessionStatus> {
|
||||
const sessionKey = normalizeSessionKey(params.sessionKey);
|
||||
const sessionKey = canonicalizeAcpSessionKey(params);
|
||||
if (!sessionKey) {
|
||||
throw new AcpRuntimeError("ACP_SESSION_INIT_FAILED", "ACP session key is required.");
|
||||
}
|
||||
@ -397,7 +400,7 @@ export class AcpSessionManager {
|
||||
sessionKey: string;
|
||||
runtimeMode: string;
|
||||
}): Promise<AcpSessionRuntimeOptions> {
|
||||
const sessionKey = normalizeSessionKey(params.sessionKey);
|
||||
const sessionKey = canonicalizeAcpSessionKey(params);
|
||||
if (!sessionKey) {
|
||||
throw new AcpRuntimeError("ACP_SESSION_INIT_FAILED", "ACP session key is required.");
|
||||
}
|
||||
@ -452,7 +455,7 @@ export class AcpSessionManager {
|
||||
key: string;
|
||||
value: string;
|
||||
}): Promise<AcpSessionRuntimeOptions> {
|
||||
const sessionKey = normalizeSessionKey(params.sessionKey);
|
||||
const sessionKey = canonicalizeAcpSessionKey(params);
|
||||
if (!sessionKey) {
|
||||
throw new AcpRuntimeError("ACP_SESSION_INIT_FAILED", "ACP session key is required.");
|
||||
}
|
||||
@ -525,7 +528,7 @@ export class AcpSessionManager {
|
||||
sessionKey: string;
|
||||
patch: Partial<AcpSessionRuntimeOptions>;
|
||||
}): Promise<AcpSessionRuntimeOptions> {
|
||||
const sessionKey = normalizeSessionKey(params.sessionKey);
|
||||
const sessionKey = canonicalizeAcpSessionKey(params);
|
||||
const validatedPatch = validateRuntimeOptionPatch(params.patch);
|
||||
if (!sessionKey) {
|
||||
throw new AcpRuntimeError("ACP_SESSION_INIT_FAILED", "ACP session key is required.");
|
||||
@ -555,7 +558,7 @@ export class AcpSessionManager {
|
||||
cfg: OpenClawConfig;
|
||||
sessionKey: string;
|
||||
}): Promise<AcpSessionRuntimeOptions> {
|
||||
const sessionKey = normalizeSessionKey(params.sessionKey);
|
||||
const sessionKey = canonicalizeAcpSessionKey(params);
|
||||
if (!sessionKey) {
|
||||
throw new AcpRuntimeError("ACP_SESSION_INIT_FAILED", "ACP session key is required.");
|
||||
}
|
||||
@ -591,7 +594,10 @@ export class AcpSessionManager {
|
||||
}
|
||||
|
||||
async runTurn(input: AcpRunTurnInput): Promise<void> {
|
||||
const sessionKey = normalizeSessionKey(input.sessionKey);
|
||||
const sessionKey = canonicalizeAcpSessionKey({
|
||||
cfg: input.cfg,
|
||||
sessionKey: input.sessionKey,
|
||||
});
|
||||
if (!sessionKey) {
|
||||
throw new AcpRuntimeError("ACP_SESSION_INIT_FAILED", "ACP session key is required.");
|
||||
}
|
||||
@ -738,7 +744,7 @@ export class AcpSessionManager {
|
||||
sessionKey: string;
|
||||
reason?: string;
|
||||
}): Promise<void> {
|
||||
const sessionKey = normalizeSessionKey(params.sessionKey);
|
||||
const sessionKey = canonicalizeAcpSessionKey(params);
|
||||
if (!sessionKey) {
|
||||
throw new AcpRuntimeError("ACP_SESSION_INIT_FAILED", "ACP session key is required.");
|
||||
}
|
||||
@ -806,7 +812,10 @@ export class AcpSessionManager {
|
||||
}
|
||||
|
||||
async closeSession(input: AcpCloseSessionInput): Promise<AcpCloseSessionResult> {
|
||||
const sessionKey = normalizeSessionKey(input.sessionKey);
|
||||
const sessionKey = canonicalizeAcpSessionKey({
|
||||
cfg: input.cfg,
|
||||
sessionKey: input.sessionKey,
|
||||
});
|
||||
if (!sessionKey) {
|
||||
throw new AcpRuntimeError("ACP_SESSION_INIT_FAILED", "ACP session key is required.");
|
||||
}
|
||||
|
||||
@ -170,6 +170,57 @@ describe("AcpSessionManager", () => {
|
||||
expect(resolved.error.message).toContain("ACP metadata is missing");
|
||||
});
|
||||
|
||||
it("canonicalizes the main alias before ACP rehydrate after restart", async () => {
|
||||
const runtimeState = createRuntime();
|
||||
hoisted.requireAcpRuntimeBackendMock.mockReturnValue({
|
||||
id: "acpx",
|
||||
runtime: runtimeState.runtime,
|
||||
});
|
||||
hoisted.readAcpSessionEntryMock.mockImplementation((paramsUnknown: unknown) => {
|
||||
const sessionKey = (paramsUnknown as { sessionKey?: string }).sessionKey;
|
||||
if (sessionKey !== "agent:main:main") {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
sessionKey,
|
||||
storeSessionKey: sessionKey,
|
||||
acp: {
|
||||
...readySessionMeta(),
|
||||
agent: "main",
|
||||
runtimeSessionName: sessionKey,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const manager = new AcpSessionManager();
|
||||
const cfg = {
|
||||
...baseCfg,
|
||||
session: { mainKey: "main" },
|
||||
agents: { list: [{ id: "main", default: true }] },
|
||||
} as OpenClawConfig;
|
||||
|
||||
await manager.runTurn({
|
||||
cfg,
|
||||
sessionKey: "main",
|
||||
text: "after restart",
|
||||
mode: "prompt",
|
||||
requestId: "r-main",
|
||||
});
|
||||
|
||||
expect(hoisted.readAcpSessionEntryMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
cfg,
|
||||
sessionKey: "agent:main:main",
|
||||
}),
|
||||
);
|
||||
expect(runtimeState.ensureSession).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
agent: "main",
|
||||
sessionKey: "agent:main:main",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("serializes concurrent turns for the same ACP session", async () => {
|
||||
const runtimeState = createRuntime();
|
||||
hoisted.requireAcpRuntimeBackendMock.mockReturnValue({
|
||||
|
||||
@ -1,6 +1,14 @@
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import {
|
||||
canonicalizeMainSessionAlias,
|
||||
resolveMainSessionKey,
|
||||
} from "../../config/sessions/main-session.js";
|
||||
import type { SessionAcpMeta } from "../../config/sessions/types.js";
|
||||
import { normalizeAgentId, parseAgentSessionKey } from "../../routing/session-key.js";
|
||||
import {
|
||||
normalizeAgentId,
|
||||
normalizeMainKey,
|
||||
parseAgentSessionKey,
|
||||
} from "../../routing/session-key.js";
|
||||
import { ACP_ERROR_CODES, AcpRuntimeError } from "../runtime/errors.js";
|
||||
import type { AcpSessionResolution } from "./manager.types.js";
|
||||
|
||||
@ -42,6 +50,33 @@ export function normalizeSessionKey(sessionKey: string): string {
|
||||
return sessionKey.trim();
|
||||
}
|
||||
|
||||
export function canonicalizeAcpSessionKey(params: {
|
||||
cfg: OpenClawConfig;
|
||||
sessionKey: string;
|
||||
}): string {
|
||||
const normalized = normalizeSessionKey(params.sessionKey);
|
||||
if (!normalized) {
|
||||
return "";
|
||||
}
|
||||
const lowered = normalized.toLowerCase();
|
||||
if (lowered === "global" || lowered === "unknown") {
|
||||
return lowered;
|
||||
}
|
||||
const parsed = parseAgentSessionKey(lowered);
|
||||
if (parsed) {
|
||||
return canonicalizeMainSessionAlias({
|
||||
cfg: params.cfg,
|
||||
agentId: parsed.agentId,
|
||||
sessionKey: lowered,
|
||||
});
|
||||
}
|
||||
const mainKey = normalizeMainKey(params.cfg.session?.mainKey);
|
||||
if (lowered === "main" || lowered === mainKey) {
|
||||
return resolveMainSessionKey(params.cfg);
|
||||
}
|
||||
return lowered;
|
||||
}
|
||||
|
||||
export function normalizeActorKey(sessionKey: string): string {
|
||||
return sessionKey.trim().toLowerCase();
|
||||
}
|
||||
|
||||
@ -52,7 +52,7 @@ function createSetSessionModeRequest(sessionId: string, modeId: string): SetSess
|
||||
function createSetSessionConfigOptionRequest(
|
||||
sessionId: string,
|
||||
configId: string,
|
||||
value: string,
|
||||
value: string | boolean,
|
||||
): SetSessionConfigOptionRequest {
|
||||
return {
|
||||
sessionId,
|
||||
@ -644,6 +644,55 @@ describe("acp setSessionConfigOption bridge behavior", () => {
|
||||
|
||||
sessionStore.clearAllSessionsForTest();
|
||||
});
|
||||
|
||||
it("rejects non-string ACP config option values", async () => {
|
||||
const sessionStore = createInMemorySessionStore();
|
||||
const connection = createAcpConnection();
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method === "sessions.list") {
|
||||
return {
|
||||
ts: Date.now(),
|
||||
path: "/tmp/sessions.json",
|
||||
count: 1,
|
||||
defaults: {
|
||||
modelProvider: null,
|
||||
model: null,
|
||||
contextTokens: null,
|
||||
},
|
||||
sessions: [
|
||||
{
|
||||
key: "bool-config-session",
|
||||
kind: "direct",
|
||||
updatedAt: Date.now(),
|
||||
thinkingLevel: "minimal",
|
||||
modelProvider: "openai",
|
||||
model: "gpt-5.4",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
return { ok: true };
|
||||
}) as GatewayClient["request"];
|
||||
const agent = new AcpGatewayAgent(connection, createAcpGateway(request), {
|
||||
sessionStore,
|
||||
});
|
||||
|
||||
await agent.loadSession(createLoadSessionRequest("bool-config-session"));
|
||||
|
||||
await expect(
|
||||
agent.setSessionConfigOption(
|
||||
createSetSessionConfigOptionRequest("bool-config-session", "thought_level", false),
|
||||
),
|
||||
).rejects.toThrow(
|
||||
'ACP bridge does not support non-string session config option values for "thought_level".',
|
||||
);
|
||||
expect(request).not.toHaveBeenCalledWith(
|
||||
"sessions.patch",
|
||||
expect.objectContaining({ key: "bool-config-session" }),
|
||||
);
|
||||
|
||||
sessionStore.clearAllSessionsForTest();
|
||||
});
|
||||
});
|
||||
|
||||
describe("acp tool streaming bridge behavior", () => {
|
||||
|
||||
@ -937,11 +937,16 @@ export class AcpGatewayAgent implements Agent {
|
||||
|
||||
private resolveSessionConfigPatch(
|
||||
configId: string,
|
||||
value: string,
|
||||
value: string | boolean,
|
||||
): {
|
||||
overrides: Partial<GatewaySessionPresentationRow>;
|
||||
patch: Record<string, string>;
|
||||
} {
|
||||
if (typeof value !== "string") {
|
||||
throw new Error(
|
||||
`ACP bridge does not support non-string session config option values for "${configId}".`,
|
||||
);
|
||||
}
|
||||
switch (configId) {
|
||||
case ACP_THOUGHT_LEVEL_CONFIG_ID:
|
||||
return {
|
||||
|
||||
@ -131,6 +131,113 @@ describe("memory search config", () => {
|
||||
expect(resolved?.extraPaths).toEqual(["/shared/notes", "docs", "../team-notes"]);
|
||||
});
|
||||
|
||||
it("normalizes multimodal settings", () => {
|
||||
const cfg = asConfig({
|
||||
agents: {
|
||||
defaults: {
|
||||
memorySearch: {
|
||||
provider: "gemini",
|
||||
model: "gemini-embedding-2-preview",
|
||||
multimodal: {
|
||||
enabled: true,
|
||||
modalities: ["all"],
|
||||
maxFileBytes: 8192,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const resolved = resolveMemorySearchConfig(cfg, "main");
|
||||
expect(resolved?.multimodal).toEqual({
|
||||
enabled: true,
|
||||
modalities: ["image", "audio"],
|
||||
maxFileBytes: 8192,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps an explicit empty multimodal modalities list empty", () => {
|
||||
const cfg = asConfig({
|
||||
agents: {
|
||||
defaults: {
|
||||
memorySearch: {
|
||||
provider: "gemini",
|
||||
model: "gemini-embedding-2-preview",
|
||||
multimodal: {
|
||||
enabled: true,
|
||||
modalities: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const resolved = resolveMemorySearchConfig(cfg, "main");
|
||||
expect(resolved?.multimodal).toEqual({
|
||||
enabled: true,
|
||||
modalities: [],
|
||||
maxFileBytes: 10 * 1024 * 1024,
|
||||
});
|
||||
expect(resolved?.provider).toBe("gemini");
|
||||
});
|
||||
|
||||
it("does not enforce multimodal provider validation when no modalities are active", () => {
|
||||
const cfg = asConfig({
|
||||
agents: {
|
||||
defaults: {
|
||||
memorySearch: {
|
||||
provider: "openai",
|
||||
model: "text-embedding-3-small",
|
||||
fallback: "openai",
|
||||
multimodal: {
|
||||
enabled: true,
|
||||
modalities: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const resolved = resolveMemorySearchConfig(cfg, "main");
|
||||
expect(resolved?.multimodal).toEqual({
|
||||
enabled: true,
|
||||
modalities: [],
|
||||
maxFileBytes: 10 * 1024 * 1024,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects multimodal memory on unsupported providers", () => {
|
||||
const cfg = asConfig({
|
||||
agents: {
|
||||
defaults: {
|
||||
memorySearch: {
|
||||
provider: "openai",
|
||||
model: "text-embedding-3-small",
|
||||
multimodal: { enabled: true, modalities: ["image"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(() => resolveMemorySearchConfig(cfg, "main")).toThrow(
|
||||
/memorySearch\.multimodal requires memorySearch\.provider = "gemini"/,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects multimodal memory when fallback is configured", () => {
|
||||
const cfg = asConfig({
|
||||
agents: {
|
||||
defaults: {
|
||||
memorySearch: {
|
||||
provider: "gemini",
|
||||
model: "gemini-embedding-2-preview",
|
||||
fallback: "openai",
|
||||
multimodal: { enabled: true, modalities: ["image"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(() => resolveMemorySearchConfig(cfg, "main")).toThrow(
|
||||
/memorySearch\.multimodal does not support memorySearch\.fallback/,
|
||||
);
|
||||
});
|
||||
|
||||
it("includes batch defaults for openai without remote overrides", () => {
|
||||
const cfg = configWithDefaultProvider("openai");
|
||||
const resolved = resolveMemorySearchConfig(cfg, "main");
|
||||
|
||||
@ -3,6 +3,12 @@ import path from "node:path";
|
||||
import type { OpenClawConfig, MemorySearchConfig } from "../config/config.js";
|
||||
import { resolveStateDir } from "../config/paths.js";
|
||||
import type { SecretInput } from "../config/types.secrets.js";
|
||||
import {
|
||||
isMemoryMultimodalEnabled,
|
||||
normalizeMemoryMultimodalSettings,
|
||||
supportsMemoryMultimodalEmbeddings,
|
||||
type MemoryMultimodalSettings,
|
||||
} from "../memory/multimodal.js";
|
||||
import { clampInt, clampNumber, resolveUserPath } from "../utils.js";
|
||||
import { resolveAgentConfig } from "./agent-scope.js";
|
||||
|
||||
@ -10,6 +16,7 @@ export type ResolvedMemorySearchConfig = {
|
||||
enabled: boolean;
|
||||
sources: Array<"memory" | "sessions">;
|
||||
extraPaths: string[];
|
||||
multimodal: MemoryMultimodalSettings;
|
||||
provider: "openai" | "local" | "gemini" | "voyage" | "mistral" | "ollama" | "auto";
|
||||
remote?: {
|
||||
baseUrl?: string;
|
||||
@ -204,6 +211,11 @@ function mergeConfig(
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean);
|
||||
const extraPaths = Array.from(new Set(rawPaths));
|
||||
const multimodal = normalizeMemoryMultimodalSettings({
|
||||
enabled: overrides?.multimodal?.enabled ?? defaults?.multimodal?.enabled,
|
||||
modalities: overrides?.multimodal?.modalities ?? defaults?.multimodal?.modalities,
|
||||
maxFileBytes: overrides?.multimodal?.maxFileBytes ?? defaults?.multimodal?.maxFileBytes,
|
||||
});
|
||||
const vector = {
|
||||
enabled: overrides?.store?.vector?.enabled ?? defaults?.store?.vector?.enabled ?? true,
|
||||
extensionPath:
|
||||
@ -307,6 +319,7 @@ function mergeConfig(
|
||||
enabled,
|
||||
sources,
|
||||
extraPaths,
|
||||
multimodal,
|
||||
provider,
|
||||
remote,
|
||||
experimental: {
|
||||
@ -365,5 +378,22 @@ export function resolveMemorySearchConfig(
|
||||
if (!resolved.enabled) {
|
||||
return null;
|
||||
}
|
||||
const multimodalActive = isMemoryMultimodalEnabled(resolved.multimodal);
|
||||
if (
|
||||
multimodalActive &&
|
||||
!supportsMemoryMultimodalEmbeddings({
|
||||
provider: resolved.provider,
|
||||
model: resolved.model,
|
||||
})
|
||||
) {
|
||||
throw new Error(
|
||||
'agents.*.memorySearch.multimodal requires memorySearch.provider = "gemini" and model = "gemini-embedding-2-preview".',
|
||||
);
|
||||
}
|
||||
if (multimodalActive && resolved.fallback !== "none") {
|
||||
throw new Error(
|
||||
'agents.*.memorySearch.multimodal does not support memorySearch.fallback. Set fallback to "none".',
|
||||
);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
@ -10,6 +10,7 @@ import {
|
||||
} from "./huggingface-models.js";
|
||||
import { discoverKilocodeModels } from "./kilocode-models.js";
|
||||
import {
|
||||
enrichOllamaModelsWithContext,
|
||||
OLLAMA_DEFAULT_CONTEXT_WINDOW,
|
||||
OLLAMA_DEFAULT_COST,
|
||||
OLLAMA_DEFAULT_MAX_TOKENS,
|
||||
@ -46,38 +47,6 @@ type VllmModelsResponse = {
|
||||
}>;
|
||||
};
|
||||
|
||||
async function queryOllamaContextWindow(
|
||||
apiBase: string,
|
||||
modelName: string,
|
||||
): Promise<number | undefined> {
|
||||
try {
|
||||
const response = await fetch(`${apiBase}/api/show`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: modelName }),
|
||||
signal: AbortSignal.timeout(3000),
|
||||
});
|
||||
if (!response.ok) {
|
||||
return undefined;
|
||||
}
|
||||
const data = (await response.json()) as { model_info?: Record<string, unknown> };
|
||||
if (!data.model_info) {
|
||||
return undefined;
|
||||
}
|
||||
for (const [key, value] of Object.entries(data.model_info)) {
|
||||
if (key.endsWith(".context_length") && typeof value === "number" && Number.isFinite(value)) {
|
||||
const contextWindow = Math.floor(value);
|
||||
if (contextWindow > 0) {
|
||||
return contextWindow;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function discoverOllamaModels(
|
||||
baseUrl?: string,
|
||||
opts?: { quiet?: boolean },
|
||||
@ -107,27 +76,18 @@ async function discoverOllamaModels(
|
||||
`Capping Ollama /api/show inspection to ${OLLAMA_SHOW_MAX_MODELS} models (received ${data.models.length})`,
|
||||
);
|
||||
}
|
||||
const discovered: ModelDefinitionConfig[] = [];
|
||||
for (let index = 0; index < modelsToInspect.length; index += OLLAMA_SHOW_CONCURRENCY) {
|
||||
const batch = modelsToInspect.slice(index, index + OLLAMA_SHOW_CONCURRENCY);
|
||||
const batchDiscovered = await Promise.all(
|
||||
batch.map(async (model) => {
|
||||
const modelId = model.name;
|
||||
const contextWindow = await queryOllamaContextWindow(apiBase, modelId);
|
||||
return {
|
||||
id: modelId,
|
||||
name: modelId,
|
||||
reasoning: isReasoningModelHeuristic(modelId),
|
||||
input: ["text"],
|
||||
cost: OLLAMA_DEFAULT_COST,
|
||||
contextWindow: contextWindow ?? OLLAMA_DEFAULT_CONTEXT_WINDOW,
|
||||
maxTokens: OLLAMA_DEFAULT_MAX_TOKENS,
|
||||
} satisfies ModelDefinitionConfig;
|
||||
}),
|
||||
);
|
||||
discovered.push(...batchDiscovered);
|
||||
}
|
||||
return discovered;
|
||||
const discovered = await enrichOllamaModelsWithContext(apiBase, modelsToInspect, {
|
||||
concurrency: OLLAMA_SHOW_CONCURRENCY,
|
||||
});
|
||||
return discovered.map((model) => ({
|
||||
id: model.name,
|
||||
name: model.name,
|
||||
reasoning: isReasoningModelHeuristic(model.name),
|
||||
input: ["text"],
|
||||
cost: OLLAMA_DEFAULT_COST,
|
||||
contextWindow: model.contextWindow ?? OLLAMA_DEFAULT_CONTEXT_WINDOW,
|
||||
maxTokens: OLLAMA_DEFAULT_MAX_TOKENS,
|
||||
}));
|
||||
} catch (error) {
|
||||
if (!opts?.quiet) {
|
||||
log.warn(`Failed to discover Ollama models: ${String(error)}`);
|
||||
|
||||
@ -429,6 +429,24 @@ export function buildOpenrouterProvider(): ProviderConfig {
|
||||
contextWindow: OPENROUTER_DEFAULT_CONTEXT_WINDOW,
|
||||
maxTokens: OPENROUTER_DEFAULT_MAX_TOKENS,
|
||||
},
|
||||
{
|
||||
id: "openrouter/hunter-alpha",
|
||||
name: "Hunter Alpha",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: OPENROUTER_DEFAULT_COST,
|
||||
contextWindow: 1048576,
|
||||
maxTokens: 65536,
|
||||
},
|
||||
{
|
||||
id: "openrouter/healer-alpha",
|
||||
name: "Healer Alpha",
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
cost: OPENROUTER_DEFAULT_COST,
|
||||
contextWindow: 262144,
|
||||
maxTokens: 65536,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
61
src/agents/ollama-models.test.ts
Normal file
61
src/agents/ollama-models.test.ts
Normal file
@ -0,0 +1,61 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
enrichOllamaModelsWithContext,
|
||||
resolveOllamaApiBase,
|
||||
type OllamaTagModel,
|
||||
} from "./ollama-models.js";
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
function requestUrl(input: string | URL | Request): string {
|
||||
if (typeof input === "string") {
|
||||
return input;
|
||||
}
|
||||
if (input instanceof URL) {
|
||||
return input.toString();
|
||||
}
|
||||
return input.url;
|
||||
}
|
||||
|
||||
function requestBody(body: BodyInit | null | undefined): string {
|
||||
return typeof body === "string" ? body : "{}";
|
||||
}
|
||||
|
||||
describe("ollama-models", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("strips /v1 when resolving the Ollama API base", () => {
|
||||
expect(resolveOllamaApiBase("http://127.0.0.1:11434/v1")).toBe("http://127.0.0.1:11434");
|
||||
expect(resolveOllamaApiBase("http://127.0.0.1:11434///")).toBe("http://127.0.0.1:11434");
|
||||
});
|
||||
|
||||
it("enriches discovered models with context windows from /api/show", async () => {
|
||||
const models: OllamaTagModel[] = [{ name: "llama3:8b" }, { name: "deepseek-r1:14b" }];
|
||||
const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
|
||||
const url = requestUrl(input);
|
||||
if (!url.endsWith("/api/show")) {
|
||||
throw new Error(`Unexpected fetch: ${url}`);
|
||||
}
|
||||
const body = JSON.parse(requestBody(init?.body)) as { name?: string };
|
||||
if (body.name === "llama3:8b") {
|
||||
return jsonResponse({ model_info: { "llama.context_length": 65536 } });
|
||||
}
|
||||
return jsonResponse({});
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const enriched = await enrichOllamaModelsWithContext("http://127.0.0.1:11434", models);
|
||||
|
||||
expect(enriched).toEqual([
|
||||
{ name: "llama3:8b", contextWindow: 65536 },
|
||||
{ name: "deepseek-r1:14b", contextWindow: undefined },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@ -27,6 +27,12 @@ export type OllamaTagsResponse = {
|
||||
models?: OllamaTagModel[];
|
||||
};
|
||||
|
||||
export type OllamaModelWithContext = OllamaTagModel & {
|
||||
contextWindow?: number;
|
||||
};
|
||||
|
||||
const OLLAMA_SHOW_CONCURRENCY = 8;
|
||||
|
||||
/**
|
||||
* Derive the Ollama native API base URL from a configured base URL.
|
||||
*
|
||||
@ -43,6 +49,58 @@ export function resolveOllamaApiBase(configuredBaseUrl?: string): string {
|
||||
return trimmed.replace(/\/v1$/i, "");
|
||||
}
|
||||
|
||||
export async function queryOllamaContextWindow(
|
||||
apiBase: string,
|
||||
modelName: string,
|
||||
): Promise<number | undefined> {
|
||||
try {
|
||||
const response = await fetch(`${apiBase}/api/show`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: modelName }),
|
||||
signal: AbortSignal.timeout(3000),
|
||||
});
|
||||
if (!response.ok) {
|
||||
return undefined;
|
||||
}
|
||||
const data = (await response.json()) as { model_info?: Record<string, unknown> };
|
||||
if (!data.model_info) {
|
||||
return undefined;
|
||||
}
|
||||
for (const [key, value] of Object.entries(data.model_info)) {
|
||||
if (key.endsWith(".context_length") && typeof value === "number" && Number.isFinite(value)) {
|
||||
const contextWindow = Math.floor(value);
|
||||
if (contextWindow > 0) {
|
||||
return contextWindow;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export async function enrichOllamaModelsWithContext(
|
||||
apiBase: string,
|
||||
models: OllamaTagModel[],
|
||||
opts?: { concurrency?: number },
|
||||
): Promise<OllamaModelWithContext[]> {
|
||||
const concurrency = Math.max(1, Math.floor(opts?.concurrency ?? OLLAMA_SHOW_CONCURRENCY));
|
||||
const enriched: OllamaModelWithContext[] = [];
|
||||
for (let index = 0; index < models.length; index += concurrency) {
|
||||
const batch = models.slice(index, index + concurrency);
|
||||
const batchResults = await Promise.all(
|
||||
batch.map(async (model) => ({
|
||||
...model,
|
||||
contextWindow: await queryOllamaContextWindow(apiBase, model.name),
|
||||
})),
|
||||
);
|
||||
enriched.push(...batchResults);
|
||||
}
|
||||
return enriched;
|
||||
}
|
||||
|
||||
/** Heuristic: treat models with "r1", "reasoning", or "think" in the name as reasoning models. */
|
||||
export function isReasoningModelHeuristic(modelId: string): boolean {
|
||||
return /r1|reasoning|think|reason/i.test(modelId);
|
||||
|
||||
@ -30,6 +30,13 @@ function extractInputTypes(input: unknown[]) {
|
||||
.filter((t): t is string => typeof t === "string");
|
||||
}
|
||||
|
||||
function extractInputMessages(input: unknown[]) {
|
||||
return input.filter(
|
||||
(item): item is Record<string, unknown> =>
|
||||
!!item && typeof item === "object" && (item as Record<string, unknown>).type === "message",
|
||||
);
|
||||
}
|
||||
|
||||
const ZERO_USAGE = {
|
||||
input: 0,
|
||||
output: 0,
|
||||
@ -184,4 +191,36 @@ describe("openai-responses reasoning replay", () => {
|
||||
expect(types).toContain("reasoning");
|
||||
expect(types).toContain("message");
|
||||
});
|
||||
|
||||
it.each(["commentary", "final_answer"] as const)(
|
||||
"replays assistant message phase metadata for %s",
|
||||
async (phase) => {
|
||||
const assistantWithText = buildAssistantMessage({
|
||||
stopReason: "stop",
|
||||
content: [
|
||||
buildReasoningPart(),
|
||||
{
|
||||
type: "text",
|
||||
text: "hello",
|
||||
textSignature: JSON.stringify({ v: 1, id: `msg_${phase}`, phase }),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { input, types } = await runAbortedOpenAIResponsesStream({
|
||||
messages: [
|
||||
{ role: "user", content: "Hi", timestamp: Date.now() },
|
||||
assistantWithText,
|
||||
{ role: "user", content: "Ok", timestamp: Date.now() },
|
||||
],
|
||||
});
|
||||
|
||||
expect(types).toContain("message");
|
||||
|
||||
const replayedMessage = extractInputMessages(input).find(
|
||||
(item) => item.id === `msg_${phase}`,
|
||||
);
|
||||
expect(replayedMessage?.phase).toBe(phase);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@ -595,14 +595,12 @@ describe("OpenAIWebSocketManager", () => {
|
||||
|
||||
manager.warmUp({
|
||||
model: "gpt-5.2",
|
||||
tools: [{ type: "function", function: { name: "exec", description: "Run a command" } }],
|
||||
tools: [{ type: "function", name: "exec", description: "Run a command" }],
|
||||
});
|
||||
|
||||
const sent = JSON.parse(sock.sentMessages[0] ?? "{}") as Record<string, unknown>;
|
||||
expect(sent["tools"]).toHaveLength(1);
|
||||
expect((sent["tools"] as Array<{ function?: { name?: string } }>)[0]?.function?.name).toBe(
|
||||
"exec",
|
||||
);
|
||||
expect((sent["tools"] as Array<{ name?: string }>)[0]?.name).toBe("exec");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -37,12 +37,15 @@ export interface UsageInfo {
|
||||
total_tokens: number;
|
||||
}
|
||||
|
||||
export type OpenAIResponsesAssistantPhase = "commentary" | "final_answer";
|
||||
|
||||
export type OutputItem =
|
||||
| {
|
||||
type: "message";
|
||||
id: string;
|
||||
role: "assistant";
|
||||
content: Array<{ type: "output_text"; text: string }>;
|
||||
phase?: OpenAIResponsesAssistantPhase;
|
||||
status?: "in_progress" | "completed";
|
||||
}
|
||||
| {
|
||||
@ -190,6 +193,7 @@ export type InputItem =
|
||||
type: "message";
|
||||
role: "system" | "developer" | "user" | "assistant";
|
||||
content: string | ContentPart[];
|
||||
phase?: OpenAIResponsesAssistantPhase;
|
||||
}
|
||||
| { type: "function_call"; id?: string; call_id?: string; name: string; arguments: string }
|
||||
| { type: "function_call_output"; call_id: string; output: string }
|
||||
@ -204,11 +208,10 @@ export type ToolChoice =
|
||||
|
||||
export interface FunctionToolDefinition {
|
||||
type: "function";
|
||||
function: {
|
||||
name: string;
|
||||
description?: string;
|
||||
parameters?: Record<string, unknown>;
|
||||
};
|
||||
name: string;
|
||||
description?: string;
|
||||
parameters?: Record<string, unknown>;
|
||||
strict?: boolean;
|
||||
}
|
||||
|
||||
/** Standard response.create event payload (full turn) */
|
||||
|
||||
@ -14,6 +14,7 @@
|
||||
* Skipped in CI — no API key available and we avoid billable external calls.
|
||||
*/
|
||||
|
||||
import type { AssistantMessage, Context } from "@mariozechner/pi-ai";
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import {
|
||||
createOpenAIWebSocketStreamFn,
|
||||
@ -28,14 +29,13 @@ const testFn = LIVE ? it : it.skip;
|
||||
const model = {
|
||||
api: "openai-responses" as const,
|
||||
provider: "openai",
|
||||
id: "gpt-4o-mini",
|
||||
name: "gpt-4o-mini",
|
||||
baseUrl: "",
|
||||
reasoning: false,
|
||||
input: { maxTokens: 128_000 },
|
||||
output: { maxTokens: 16_384 },
|
||||
cache: false,
|
||||
compat: {},
|
||||
id: "gpt-5.2",
|
||||
name: "gpt-5.2",
|
||||
contextWindow: 128_000,
|
||||
maxTokens: 4_096,
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
} as unknown as Parameters<ReturnType<typeof createOpenAIWebSocketStreamFn>>[0];
|
||||
|
||||
type StreamFnParams = Parameters<ReturnType<typeof createOpenAIWebSocketStreamFn>>;
|
||||
@ -47,6 +47,61 @@ function makeContext(userMessage: string): StreamFnParams[1] {
|
||||
} as unknown as StreamFnParams[1];
|
||||
}
|
||||
|
||||
function makeToolContext(userMessage: string): StreamFnParams[1] {
|
||||
return {
|
||||
systemPrompt: "You are a precise assistant. Follow tool instructions exactly.",
|
||||
messages: [{ role: "user" as const, content: userMessage }],
|
||||
tools: [
|
||||
{
|
||||
name: "noop",
|
||||
description: "Return the supplied tool result to the user.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
],
|
||||
} as unknown as Context;
|
||||
}
|
||||
|
||||
function makeToolResultMessage(
|
||||
callId: string,
|
||||
output: string,
|
||||
): StreamFnParams[1]["messages"][number] {
|
||||
return {
|
||||
role: "toolResult" as const,
|
||||
toolCallId: callId,
|
||||
toolName: "noop",
|
||||
content: [{ type: "text" as const, text: output }],
|
||||
isError: false,
|
||||
timestamp: Date.now(),
|
||||
} as unknown as StreamFnParams[1]["messages"][number];
|
||||
}
|
||||
|
||||
async function collectEvents(
|
||||
stream: ReturnType<ReturnType<typeof createOpenAIWebSocketStreamFn>>,
|
||||
): Promise<Array<{ type: string; message?: AssistantMessage }>> {
|
||||
const events: Array<{ type: string; message?: AssistantMessage }> = [];
|
||||
for await (const event of stream as AsyncIterable<{ type: string; message?: AssistantMessage }>) {
|
||||
events.push(event);
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
function expectDone(events: Array<{ type: string; message?: AssistantMessage }>): AssistantMessage {
|
||||
const done = events.find((event) => event.type === "done")?.message;
|
||||
expect(done).toBeDefined();
|
||||
return done!;
|
||||
}
|
||||
|
||||
function assistantText(message: AssistantMessage): string {
|
||||
return message.content
|
||||
.filter((block) => block.type === "text")
|
||||
.map((block) => block.text)
|
||||
.join("");
|
||||
}
|
||||
|
||||
/** Each test gets a unique session ID to avoid cross-test interference. */
|
||||
const sessions: string[] = [];
|
||||
function freshSession(name: string): string {
|
||||
@ -68,26 +123,14 @@ describe("OpenAI WebSocket e2e", () => {
|
||||
async () => {
|
||||
const sid = freshSession("single");
|
||||
const streamFn = createOpenAIWebSocketStreamFn(API_KEY!, sid);
|
||||
const stream = streamFn(model, makeContext("What is 2+2?"), {});
|
||||
const stream = streamFn(model, makeContext("What is 2+2?"), { transport: "websocket" });
|
||||
const done = expectDone(await collectEvents(stream));
|
||||
|
||||
const events: Array<{ type: string }> = [];
|
||||
for await (const event of stream as AsyncIterable<{ type: string }>) {
|
||||
events.push(event);
|
||||
}
|
||||
|
||||
const done = events.find((e) => e.type === "done") as
|
||||
| { type: "done"; message: { content: Array<{ type: string; text?: string }> } }
|
||||
| undefined;
|
||||
expect(done).toBeDefined();
|
||||
expect(done!.message.content.length).toBeGreaterThan(0);
|
||||
|
||||
const text = done!.message.content
|
||||
.filter((c) => c.type === "text")
|
||||
.map((c) => c.text)
|
||||
.join("");
|
||||
expect(done.content.length).toBeGreaterThan(0);
|
||||
const text = assistantText(done);
|
||||
expect(text).toMatch(/4/);
|
||||
},
|
||||
30_000,
|
||||
45_000,
|
||||
);
|
||||
|
||||
testFn(
|
||||
@ -96,19 +139,80 @@ describe("OpenAI WebSocket e2e", () => {
|
||||
const sid = freshSession("temp");
|
||||
const streamFn = createOpenAIWebSocketStreamFn(API_KEY!, sid);
|
||||
const stream = streamFn(model, makeContext("Pick a random number between 1 and 1000."), {
|
||||
transport: "websocket",
|
||||
temperature: 0.8,
|
||||
});
|
||||
|
||||
const events: Array<{ type: string }> = [];
|
||||
for await (const event of stream as AsyncIterable<{ type: string }>) {
|
||||
events.push(event);
|
||||
}
|
||||
const events = await collectEvents(stream);
|
||||
|
||||
// Stream must complete (done or error with fallback) — must NOT hang.
|
||||
const hasTerminal = events.some((e) => e.type === "done" || e.type === "error");
|
||||
expect(hasTerminal).toBe(true);
|
||||
},
|
||||
30_000,
|
||||
45_000,
|
||||
);
|
||||
|
||||
testFn(
|
||||
"reuses the websocket session for tool-call follow-up turns",
|
||||
async () => {
|
||||
const sid = freshSession("tool-roundtrip");
|
||||
const streamFn = createOpenAIWebSocketStreamFn(API_KEY!, sid);
|
||||
const firstContext = makeToolContext(
|
||||
"Call the tool `noop` with {}. After the tool result arrives, reply with exactly the tool output and nothing else.",
|
||||
);
|
||||
const firstEvents = await collectEvents(
|
||||
streamFn(model, firstContext, {
|
||||
transport: "websocket",
|
||||
toolChoice: "required",
|
||||
maxTokens: 128,
|
||||
} as unknown as StreamFnParams[2]),
|
||||
);
|
||||
const firstDone = expectDone(firstEvents);
|
||||
const toolCall = firstDone.content.find((block) => block.type === "toolCall") as
|
||||
| { type: "toolCall"; id: string; name: string }
|
||||
| undefined;
|
||||
expect(toolCall?.name).toBe("noop");
|
||||
expect(toolCall?.id).toBeTruthy();
|
||||
|
||||
const secondContext = {
|
||||
...firstContext,
|
||||
messages: [
|
||||
...firstContext.messages,
|
||||
firstDone,
|
||||
makeToolResultMessage(toolCall!.id, "TOOL_OK"),
|
||||
],
|
||||
} as unknown as StreamFnParams[1];
|
||||
const secondDone = expectDone(
|
||||
await collectEvents(
|
||||
streamFn(model, secondContext, {
|
||||
transport: "websocket",
|
||||
maxTokens: 128,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
expect(assistantText(secondDone)).toMatch(/TOOL_OK/);
|
||||
},
|
||||
60_000,
|
||||
);
|
||||
|
||||
testFn(
|
||||
"supports websocket warm-up before the first request",
|
||||
async () => {
|
||||
const sid = freshSession("warmup");
|
||||
const streamFn = createOpenAIWebSocketStreamFn(API_KEY!, sid);
|
||||
const done = expectDone(
|
||||
await collectEvents(
|
||||
streamFn(model, makeContext("Reply with the word warmed."), {
|
||||
transport: "websocket",
|
||||
openaiWsWarmup: true,
|
||||
maxTokens: 32,
|
||||
} as unknown as StreamFnParams[2]),
|
||||
),
|
||||
);
|
||||
|
||||
expect(assistantText(done).toLowerCase()).toContain("warmed");
|
||||
},
|
||||
45_000,
|
||||
);
|
||||
|
||||
testFn(
|
||||
@ -119,16 +223,13 @@ describe("OpenAI WebSocket e2e", () => {
|
||||
|
||||
expect(hasWsSession(sid)).toBe(false);
|
||||
|
||||
const stream = streamFn(model, makeContext("Say hello."), {});
|
||||
for await (const _ of stream as AsyncIterable<unknown>) {
|
||||
/* consume */
|
||||
}
|
||||
await collectEvents(streamFn(model, makeContext("Say hello."), { transport: "websocket" }));
|
||||
|
||||
expect(hasWsSession(sid)).toBe(true);
|
||||
releaseWsSession(sid);
|
||||
expect(hasWsSession(sid)).toBe(false);
|
||||
},
|
||||
30_000,
|
||||
45_000,
|
||||
);
|
||||
|
||||
testFn(
|
||||
@ -137,15 +238,11 @@ describe("OpenAI WebSocket e2e", () => {
|
||||
const sid = freshSession("fallback");
|
||||
const streamFn = createOpenAIWebSocketStreamFn("sk-invalid-key", sid);
|
||||
const stream = streamFn(model, makeContext("Hello"), {});
|
||||
|
||||
const events: Array<{ type: string }> = [];
|
||||
for await (const event of stream as AsyncIterable<{ type: string }>) {
|
||||
events.push(event);
|
||||
}
|
||||
const events = await collectEvents(stream);
|
||||
|
||||
const hasTerminal = events.some((e) => e.type === "done" || e.type === "error");
|
||||
expect(hasTerminal).toBe(true);
|
||||
},
|
||||
30_000,
|
||||
45_000,
|
||||
);
|
||||
});
|
||||
|
||||
@ -224,6 +224,7 @@ type FakeMessage =
|
||||
| {
|
||||
role: "assistant";
|
||||
content: unknown[];
|
||||
phase?: "commentary" | "final_answer";
|
||||
stopReason: string;
|
||||
api: string;
|
||||
provider: string;
|
||||
@ -247,6 +248,7 @@ function userMsg(text: string): FakeMessage {
|
||||
function assistantMsg(
|
||||
textBlocks: string[],
|
||||
toolCalls: Array<{ id: string; name: string; args: Record<string, unknown> }> = [],
|
||||
phase?: "commentary" | "final_answer",
|
||||
): FakeMessage {
|
||||
const content: unknown[] = [];
|
||||
for (const t of textBlocks) {
|
||||
@ -258,6 +260,7 @@ function assistantMsg(
|
||||
return {
|
||||
role: "assistant",
|
||||
content,
|
||||
phase,
|
||||
stopReason: toolCalls.length > 0 ? "toolUse" : "stop",
|
||||
api: "openai-responses",
|
||||
provider: "openai",
|
||||
@ -302,6 +305,7 @@ function makeResponseObject(
|
||||
id: string,
|
||||
outputText?: string,
|
||||
toolCallName?: string,
|
||||
phase?: "commentary" | "final_answer",
|
||||
): ResponseObject {
|
||||
const output: ResponseObject["output"] = [];
|
||||
if (outputText) {
|
||||
@ -310,6 +314,7 @@ function makeResponseObject(
|
||||
id: "item_1",
|
||||
role: "assistant",
|
||||
content: [{ type: "output_text", text: outputText }],
|
||||
phase,
|
||||
});
|
||||
}
|
||||
if (toolCallName) {
|
||||
@ -357,18 +362,16 @@ describe("convertTools", () => {
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({
|
||||
type: "function",
|
||||
function: {
|
||||
name: "exec",
|
||||
description: "Run a command",
|
||||
parameters: { type: "object", properties: { cmd: { type: "string" } } },
|
||||
},
|
||||
name: "exec",
|
||||
description: "Run a command",
|
||||
parameters: { type: "object", properties: { cmd: { type: "string" } } },
|
||||
});
|
||||
});
|
||||
|
||||
it("handles tools without description", () => {
|
||||
const tools = [{ name: "ping", description: "", parameters: {} }];
|
||||
const result = convertTools(tools as Parameters<typeof convertTools>[0]);
|
||||
expect(result[0]?.function?.name).toBe("ping");
|
||||
expect(result[0]?.name).toBe("ping");
|
||||
});
|
||||
});
|
||||
|
||||
@ -391,6 +394,19 @@ describe("convertMessagesToInputItems", () => {
|
||||
expect(items[0]).toMatchObject({ type: "message", role: "assistant", content: "Hi there." });
|
||||
});
|
||||
|
||||
it("preserves assistant phase on replayed assistant messages", () => {
|
||||
const items = convertMessagesToInputItems([
|
||||
assistantMsg(["Working on it."], [], "commentary"),
|
||||
] as Parameters<typeof convertMessagesToInputItems>[0]);
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0]).toMatchObject({
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
content: "Working on it.",
|
||||
phase: "commentary",
|
||||
});
|
||||
});
|
||||
|
||||
it("converts an assistant message with a tool call", () => {
|
||||
const msg = assistantMsg(
|
||||
["Let me run that."],
|
||||
@ -408,10 +424,58 @@ describe("convertMessagesToInputItems", () => {
|
||||
call_id: "call_1",
|
||||
name: "exec",
|
||||
});
|
||||
expect(textItem).not.toHaveProperty("phase");
|
||||
const fc = fcItem as { arguments: string };
|
||||
expect(JSON.parse(fc.arguments)).toEqual({ cmd: "ls" });
|
||||
});
|
||||
|
||||
it("preserves assistant phase on commentary text before tool calls", () => {
|
||||
const msg = assistantMsg(
|
||||
["Let me run that."],
|
||||
[{ id: "call_1", name: "exec", args: { cmd: "ls" } }],
|
||||
"commentary",
|
||||
);
|
||||
const items = convertMessagesToInputItems([msg] as Parameters<
|
||||
typeof convertMessagesToInputItems
|
||||
>[0]);
|
||||
const textItem = items.find((i) => i.type === "message");
|
||||
expect(textItem).toMatchObject({
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
content: "Let me run that.",
|
||||
phase: "commentary",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves assistant phase from textSignature metadata without local phase field", () => {
|
||||
const msg = {
|
||||
role: "assistant" as const,
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: "Working on it.",
|
||||
textSignature: JSON.stringify({ v: 1, id: "msg_sig", phase: "commentary" }),
|
||||
},
|
||||
],
|
||||
stopReason: "stop",
|
||||
api: "openai-responses",
|
||||
provider: "openai",
|
||||
model: "gpt-5.2",
|
||||
usage: {},
|
||||
timestamp: 0,
|
||||
};
|
||||
const items = convertMessagesToInputItems([msg] as Parameters<
|
||||
typeof convertMessagesToInputItems
|
||||
>[0]);
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0]).toMatchObject({
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
content: "Working on it.",
|
||||
phase: "commentary",
|
||||
});
|
||||
});
|
||||
|
||||
it("converts a tool result message", () => {
|
||||
const items = convertMessagesToInputItems([toolResultMsg("call_1", "file.txt")] as Parameters<
|
||||
typeof convertMessagesToInputItems
|
||||
@ -518,6 +582,34 @@ describe("convertMessagesToInputItems", () => {
|
||||
expect((items[0] as { content?: unknown }).content).toBe("Here is my answer.");
|
||||
});
|
||||
|
||||
it("replays reasoning blocks from thinking signatures", () => {
|
||||
const msg = {
|
||||
role: "assistant" as const,
|
||||
content: [
|
||||
{
|
||||
type: "thinking" as const,
|
||||
thinking: "internal reasoning...",
|
||||
thinkingSignature: JSON.stringify({
|
||||
type: "reasoning",
|
||||
id: "rs_test",
|
||||
summary: [],
|
||||
}),
|
||||
},
|
||||
{ type: "text" as const, text: "Here is my answer." },
|
||||
],
|
||||
stopReason: "stop",
|
||||
api: "openai-responses",
|
||||
provider: "openai",
|
||||
model: "gpt-5.2",
|
||||
usage: {},
|
||||
timestamp: 0,
|
||||
};
|
||||
const items = convertMessagesToInputItems([msg] as Parameters<
|
||||
typeof convertMessagesToInputItems
|
||||
>[0]);
|
||||
expect(items.map((item) => item.type)).toEqual(["reasoning", "message"]);
|
||||
});
|
||||
|
||||
it("returns empty array for empty messages", () => {
|
||||
expect(convertMessagesToInputItems([])).toEqual([]);
|
||||
});
|
||||
@ -594,6 +686,16 @@ describe("buildAssistantMessageFromResponse", () => {
|
||||
expect(msg.content).toEqual([]);
|
||||
expect(msg.stopReason).toBe("stop");
|
||||
});
|
||||
|
||||
it("preserves phase from assistant message output items", () => {
|
||||
const response = makeResponseObject("resp_8", "Final answer", undefined, "final_answer");
|
||||
const msg = buildAssistantMessageFromResponse(response, modelInfo) as {
|
||||
phase?: string;
|
||||
content: Array<{ type: string; text?: string }>;
|
||||
};
|
||||
expect(msg.phase).toBe("final_answer");
|
||||
expect(msg.content[0]?.text).toBe("Final answer");
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@ -633,6 +735,7 @@ describe("createOpenAIWebSocketStreamFn", () => {
|
||||
releaseWsSession("sess-fallback");
|
||||
releaseWsSession("sess-incremental");
|
||||
releaseWsSession("sess-full");
|
||||
releaseWsSession("sess-phase");
|
||||
releaseWsSession("sess-tools");
|
||||
releaseWsSession("sess-store-default");
|
||||
releaseWsSession("sess-store-compat");
|
||||
@ -795,6 +898,40 @@ describe("createOpenAIWebSocketStreamFn", () => {
|
||||
expect(doneEvent?.message.content[0]?.text).toBe("Hello back!");
|
||||
});
|
||||
|
||||
it("keeps assistant phase on completed WebSocket responses", async () => {
|
||||
const streamFn = createOpenAIWebSocketStreamFn("sk-test", "sess-phase");
|
||||
const stream = streamFn(
|
||||
modelStub as Parameters<typeof streamFn>[0],
|
||||
contextStub as Parameters<typeof streamFn>[1],
|
||||
);
|
||||
|
||||
const events: unknown[] = [];
|
||||
const done = (async () => {
|
||||
for await (const ev of await resolveStream(stream)) {
|
||||
events.push(ev);
|
||||
}
|
||||
})();
|
||||
|
||||
await new Promise((r) => setImmediate(r));
|
||||
const manager = MockManager.lastInstance!;
|
||||
manager.simulateEvent({
|
||||
type: "response.completed",
|
||||
response: makeResponseObject("resp_phase", "Working...", "exec", "commentary"),
|
||||
});
|
||||
|
||||
await done;
|
||||
|
||||
const doneEvent = events.find((e) => (e as { type?: string }).type === "done") as
|
||||
| {
|
||||
type: string;
|
||||
reason: string;
|
||||
message: { phase?: string; stopReason: string };
|
||||
}
|
||||
| undefined;
|
||||
expect(doneEvent?.message.phase).toBe("commentary");
|
||||
expect(doneEvent?.message.stopReason).toBe("toolUse");
|
||||
});
|
||||
|
||||
it("falls back to HTTP when WebSocket connect fails (session pre-broken via flag)", async () => {
|
||||
// Set the class-level flag BEFORE calling streamFn so the new instance
|
||||
// fails on connect(). We patch the static default via MockManager directly.
|
||||
|
||||
@ -37,6 +37,7 @@ import {
|
||||
type ContentPart,
|
||||
type FunctionToolDefinition,
|
||||
type InputItem,
|
||||
type OpenAIResponsesAssistantPhase,
|
||||
type OpenAIWebSocketManagerOptions,
|
||||
type ResponseObject,
|
||||
} from "./openai-ws-connection.js";
|
||||
@ -100,6 +101,8 @@ export function hasWsSession(sessionId: string): boolean {
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
type AnyMessage = Message & { role: string; content: unknown };
|
||||
type AssistantMessageWithPhase = AssistantMessage & { phase?: OpenAIResponsesAssistantPhase };
|
||||
type ReplayModelInfo = { input?: ReadonlyArray<string> };
|
||||
|
||||
function toNonEmptyString(value: unknown): string | null {
|
||||
if (typeof value !== "string") {
|
||||
@ -109,6 +112,50 @@ function toNonEmptyString(value: unknown): string | null {
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
function normalizeAssistantPhase(value: unknown): OpenAIResponsesAssistantPhase | undefined {
|
||||
return value === "commentary" || value === "final_answer" ? value : undefined;
|
||||
}
|
||||
|
||||
function encodeAssistantTextSignature(params: {
|
||||
id: string;
|
||||
phase?: OpenAIResponsesAssistantPhase;
|
||||
}): string {
|
||||
return JSON.stringify({
|
||||
v: 1,
|
||||
id: params.id,
|
||||
...(params.phase ? { phase: params.phase } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
function parseAssistantTextSignature(
|
||||
value: unknown,
|
||||
): { id: string; phase?: OpenAIResponsesAssistantPhase } | null {
|
||||
if (typeof value !== "string" || value.trim().length === 0) {
|
||||
return null;
|
||||
}
|
||||
if (!value.startsWith("{")) {
|
||||
return { id: value };
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(value) as { v?: unknown; id?: unknown; phase?: unknown };
|
||||
if (parsed.v !== 1 || typeof parsed.id !== "string") {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id: parsed.id,
|
||||
...(normalizeAssistantPhase(parsed.phase)
|
||||
? { phase: normalizeAssistantPhase(parsed.phase) }
|
||||
: {}),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function supportsImageInput(modelOverride?: ReplayModelInfo): boolean {
|
||||
return !Array.isArray(modelOverride?.input) || modelOverride.input.includes("image");
|
||||
}
|
||||
|
||||
/** Convert pi-ai content (string | ContentPart[]) to plain text. */
|
||||
function contentToText(content: unknown): string {
|
||||
if (typeof content === "string") {
|
||||
@ -117,30 +164,50 @@ function contentToText(content: unknown): string {
|
||||
if (!Array.isArray(content)) {
|
||||
return "";
|
||||
}
|
||||
return (content as Array<{ type?: string; text?: string }>)
|
||||
.filter((p) => p.type === "text" && typeof p.text === "string")
|
||||
.map((p) => p.text as string)
|
||||
return content
|
||||
.filter(
|
||||
(part): part is { type?: string; text?: string } => Boolean(part) && typeof part === "object",
|
||||
)
|
||||
.filter(
|
||||
(part) =>
|
||||
(part.type === "text" || part.type === "input_text" || part.type === "output_text") &&
|
||||
typeof part.text === "string",
|
||||
)
|
||||
.map((part) => part.text as string)
|
||||
.join("");
|
||||
}
|
||||
|
||||
/** Convert pi-ai content to OpenAI ContentPart[]. */
|
||||
function contentToOpenAIParts(content: unknown): ContentPart[] {
|
||||
function contentToOpenAIParts(content: unknown, modelOverride?: ReplayModelInfo): ContentPart[] {
|
||||
if (typeof content === "string") {
|
||||
return content ? [{ type: "input_text", text: content }] : [];
|
||||
}
|
||||
if (!Array.isArray(content)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const includeImages = supportsImageInput(modelOverride);
|
||||
const parts: ContentPart[] = [];
|
||||
for (const part of content as Array<{
|
||||
type?: string;
|
||||
text?: string;
|
||||
data?: string;
|
||||
mimeType?: string;
|
||||
source?: unknown;
|
||||
}>) {
|
||||
if (part.type === "text" && typeof part.text === "string") {
|
||||
if (
|
||||
(part.type === "text" || part.type === "input_text" || part.type === "output_text") &&
|
||||
typeof part.text === "string"
|
||||
) {
|
||||
parts.push({ type: "input_text", text: part.text });
|
||||
} else if (part.type === "image" && typeof part.data === "string") {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!includeImages) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (part.type === "image" && typeof part.data === "string") {
|
||||
parts.push({
|
||||
type: "input_image",
|
||||
source: {
|
||||
@ -149,11 +216,60 @@ function contentToOpenAIParts(content: unknown): ContentPart[] {
|
||||
data: part.data,
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
part.type === "input_image" &&
|
||||
part.source &&
|
||||
typeof part.source === "object" &&
|
||||
typeof (part.source as { type?: unknown }).type === "string"
|
||||
) {
|
||||
parts.push({
|
||||
type: "input_image",
|
||||
source: part.source as
|
||||
| { type: "url"; url: string }
|
||||
| { type: "base64"; media_type: string; data: string },
|
||||
});
|
||||
}
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
function parseReasoningItem(value: unknown): Extract<InputItem, { type: "reasoning" }> | null {
|
||||
if (!value || typeof value !== "object") {
|
||||
return null;
|
||||
}
|
||||
const record = value as {
|
||||
type?: unknown;
|
||||
content?: unknown;
|
||||
encrypted_content?: unknown;
|
||||
summary?: unknown;
|
||||
};
|
||||
if (record.type !== "reasoning") {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
type: "reasoning",
|
||||
...(typeof record.content === "string" ? { content: record.content } : {}),
|
||||
...(typeof record.encrypted_content === "string"
|
||||
? { encrypted_content: record.encrypted_content }
|
||||
: {}),
|
||||
...(typeof record.summary === "string" ? { summary: record.summary } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function parseThinkingSignature(value: unknown): Extract<InputItem, { type: "reasoning" }> | null {
|
||||
if (typeof value !== "string" || value.trim().length === 0) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return parseReasoningItem(JSON.parse(value));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Convert pi-ai tool array to OpenAI FunctionToolDefinition[]. */
|
||||
export function convertTools(tools: Context["tools"]): FunctionToolDefinition[] {
|
||||
if (!tools || tools.length === 0) {
|
||||
@ -161,11 +277,9 @@ export function convertTools(tools: Context["tools"]): FunctionToolDefinition[]
|
||||
}
|
||||
return tools.map((tool) => ({
|
||||
type: "function" as const,
|
||||
function: {
|
||||
name: tool.name,
|
||||
description: typeof tool.description === "string" ? tool.description : undefined,
|
||||
parameters: (tool.parameters ?? {}) as Record<string, unknown>,
|
||||
},
|
||||
name: tool.name,
|
||||
description: typeof tool.description === "string" ? tool.description : undefined,
|
||||
parameters: (tool.parameters ?? {}) as Record<string, unknown>,
|
||||
}));
|
||||
}
|
||||
|
||||
@ -173,14 +287,24 @@ export function convertTools(tools: Context["tools"]): FunctionToolDefinition[]
|
||||
* Convert the full pi-ai message history to an OpenAI `input` array.
|
||||
* Handles user messages, assistant text+tool-call messages, and tool results.
|
||||
*/
|
||||
export function convertMessagesToInputItems(messages: Message[]): InputItem[] {
|
||||
export function convertMessagesToInputItems(
|
||||
messages: Message[],
|
||||
modelOverride?: ReplayModelInfo,
|
||||
): InputItem[] {
|
||||
const items: InputItem[] = [];
|
||||
|
||||
for (const msg of messages) {
|
||||
const m = msg as AnyMessage;
|
||||
const m = msg as AnyMessage & {
|
||||
phase?: unknown;
|
||||
toolCallId?: unknown;
|
||||
toolUseId?: unknown;
|
||||
};
|
||||
|
||||
if (m.role === "user") {
|
||||
const parts = contentToOpenAIParts(m.content);
|
||||
const parts = contentToOpenAIParts(m.content, modelOverride);
|
||||
if (parts.length === 0) {
|
||||
continue;
|
||||
}
|
||||
items.push({
|
||||
type: "message",
|
||||
role: "user",
|
||||
@ -194,87 +318,116 @@ export function convertMessagesToInputItems(messages: Message[]): InputItem[] {
|
||||
|
||||
if (m.role === "assistant") {
|
||||
const content = m.content;
|
||||
let assistantPhase = normalizeAssistantPhase(m.phase);
|
||||
if (Array.isArray(content)) {
|
||||
// Collect text blocks and tool calls separately
|
||||
const textParts: string[] = [];
|
||||
for (const block of content as Array<{
|
||||
type?: string;
|
||||
text?: string;
|
||||
id?: string;
|
||||
name?: string;
|
||||
arguments?: Record<string, unknown>;
|
||||
thinking?: string;
|
||||
}>) {
|
||||
if (block.type === "text" && typeof block.text === "string") {
|
||||
textParts.push(block.text);
|
||||
} else if (block.type === "thinking" && typeof block.thinking === "string") {
|
||||
// Skip thinking blocks — not sent back to the model
|
||||
} else if (block.type === "toolCall") {
|
||||
// Push accumulated text first
|
||||
if (textParts.length > 0) {
|
||||
items.push({
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
content: textParts.join(""),
|
||||
});
|
||||
textParts.length = 0;
|
||||
}
|
||||
const callId = toNonEmptyString(block.id);
|
||||
const toolName = toNonEmptyString(block.name);
|
||||
if (!callId || !toolName) {
|
||||
continue;
|
||||
}
|
||||
// Push function_call item
|
||||
items.push({
|
||||
type: "function_call",
|
||||
call_id: callId,
|
||||
name: toolName,
|
||||
arguments:
|
||||
typeof block.arguments === "string"
|
||||
? block.arguments
|
||||
: JSON.stringify(block.arguments ?? {}),
|
||||
});
|
||||
const pushAssistantText = () => {
|
||||
if (textParts.length === 0) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (textParts.length > 0) {
|
||||
items.push({
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
content: textParts.join(""),
|
||||
...(assistantPhase ? { phase: assistantPhase } : {}),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const text = contentToText(m.content);
|
||||
if (text) {
|
||||
textParts.length = 0;
|
||||
};
|
||||
|
||||
for (const block of content as Array<{
|
||||
type?: string;
|
||||
text?: string;
|
||||
textSignature?: unknown;
|
||||
id?: unknown;
|
||||
name?: unknown;
|
||||
arguments?: unknown;
|
||||
thinkingSignature?: unknown;
|
||||
}>) {
|
||||
if (block.type === "text" && typeof block.text === "string") {
|
||||
const parsedSignature = parseAssistantTextSignature(block.textSignature);
|
||||
if (!assistantPhase) {
|
||||
assistantPhase = parsedSignature?.phase;
|
||||
}
|
||||
textParts.push(block.text);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (block.type === "thinking") {
|
||||
pushAssistantText();
|
||||
const reasoningItem = parseThinkingSignature(block.thinkingSignature);
|
||||
if (reasoningItem) {
|
||||
items.push(reasoningItem);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (block.type !== "toolCall") {
|
||||
continue;
|
||||
}
|
||||
|
||||
pushAssistantText();
|
||||
const callIdRaw = toNonEmptyString(block.id);
|
||||
const toolName = toNonEmptyString(block.name);
|
||||
if (!callIdRaw || !toolName) {
|
||||
continue;
|
||||
}
|
||||
const [callId, itemId] = callIdRaw.split("|", 2);
|
||||
items.push({
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
content: text,
|
||||
type: "function_call",
|
||||
...(itemId ? { id: itemId } : {}),
|
||||
call_id: callId,
|
||||
name: toolName,
|
||||
arguments:
|
||||
typeof block.arguments === "string"
|
||||
? block.arguments
|
||||
: JSON.stringify(block.arguments ?? {}),
|
||||
});
|
||||
}
|
||||
|
||||
pushAssistantText();
|
||||
continue;
|
||||
}
|
||||
|
||||
const text = contentToText(content);
|
||||
if (!text) {
|
||||
continue;
|
||||
}
|
||||
items.push({
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
content: text,
|
||||
...(assistantPhase ? { phase: assistantPhase } : {}),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (m.role === "toolResult") {
|
||||
const tr = m as unknown as {
|
||||
toolCallId?: string;
|
||||
toolUseId?: string;
|
||||
content: unknown;
|
||||
isError: boolean;
|
||||
};
|
||||
const callId = toNonEmptyString(tr.toolCallId) ?? toNonEmptyString(tr.toolUseId);
|
||||
if (!callId) {
|
||||
continue;
|
||||
}
|
||||
const outputText = contentToText(tr.content);
|
||||
items.push({
|
||||
type: "function_call_output",
|
||||
call_id: callId,
|
||||
output: outputText,
|
||||
});
|
||||
if (m.role !== "toolResult") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const toolCallId = toNonEmptyString(m.toolCallId) ?? toNonEmptyString(m.toolUseId);
|
||||
if (!toolCallId) {
|
||||
continue;
|
||||
}
|
||||
const [callId] = toolCallId.split("|", 2);
|
||||
const parts = Array.isArray(m.content) ? contentToOpenAIParts(m.content, modelOverride) : [];
|
||||
const textOutput = contentToText(m.content);
|
||||
const imageParts = parts.filter((part) => part.type === "input_image");
|
||||
items.push({
|
||||
type: "function_call_output",
|
||||
call_id: callId,
|
||||
output: textOutput || (imageParts.length > 0 ? "(see attached image)" : ""),
|
||||
});
|
||||
if (imageParts.length > 0) {
|
||||
items.push({
|
||||
type: "message",
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "input_text", text: "Attached image(s) from tool result:" },
|
||||
...imageParts,
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
@ -289,12 +442,24 @@ export function buildAssistantMessageFromResponse(
|
||||
modelInfo: { api: string; provider: string; id: string },
|
||||
): AssistantMessage {
|
||||
const content: (TextContent | ToolCall)[] = [];
|
||||
let assistantPhase: OpenAIResponsesAssistantPhase | undefined;
|
||||
|
||||
for (const item of response.output ?? []) {
|
||||
if (item.type === "message") {
|
||||
const itemPhase = normalizeAssistantPhase(item.phase);
|
||||
if (itemPhase) {
|
||||
assistantPhase = itemPhase;
|
||||
}
|
||||
for (const part of item.content ?? []) {
|
||||
if (part.type === "output_text" && part.text) {
|
||||
content.push({ type: "text", text: part.text });
|
||||
content.push({
|
||||
type: "text",
|
||||
text: part.text,
|
||||
textSignature: encodeAssistantTextSignature({
|
||||
id: item.id,
|
||||
...(itemPhase ? { phase: itemPhase } : {}),
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (item.type === "function_call") {
|
||||
@ -321,7 +486,7 @@ export function buildAssistantMessageFromResponse(
|
||||
const hasToolCalls = content.some((c) => c.type === "toolCall");
|
||||
const stopReason: StopReason = hasToolCalls ? "toolUse" : "stop";
|
||||
|
||||
return buildAssistantMessage({
|
||||
const message = buildAssistantMessage({
|
||||
model: modelInfo,
|
||||
content,
|
||||
stopReason,
|
||||
@ -331,6 +496,10 @@ export function buildAssistantMessageFromResponse(
|
||||
totalTokens: response.usage?.total_tokens ?? 0,
|
||||
}),
|
||||
});
|
||||
|
||||
return assistantPhase
|
||||
? ({ ...message, phase: assistantPhase } as AssistantMessageWithPhase)
|
||||
: message;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@ -504,6 +673,7 @@ export function createOpenAIWebSocketStreamFn(
|
||||
|
||||
if (resolveWsWarmup(options) && !session.warmUpAttempted) {
|
||||
session.warmUpAttempted = true;
|
||||
let warmupFailed = false;
|
||||
try {
|
||||
await runWarmUp({
|
||||
manager: session.manager,
|
||||
@ -517,10 +687,33 @@ export function createOpenAIWebSocketStreamFn(
|
||||
if (signal?.aborted) {
|
||||
throw warmErr instanceof Error ? warmErr : new Error(String(warmErr));
|
||||
}
|
||||
warmupFailed = true;
|
||||
log.warn(
|
||||
`[ws-stream] warm-up failed for session=${sessionId}; continuing without warm-up. error=${String(warmErr)}`,
|
||||
);
|
||||
}
|
||||
if (warmupFailed && !session.manager.isConnected()) {
|
||||
try {
|
||||
session.manager.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
await session.manager.connect(apiKey);
|
||||
session.everConnected = true;
|
||||
log.debug(`[ws-stream] reconnected after warm-up failure for session=${sessionId}`);
|
||||
} catch (reconnectErr) {
|
||||
session.broken = true;
|
||||
wsRegistry.delete(sessionId);
|
||||
if (transport === "websocket") {
|
||||
throw reconnectErr instanceof Error ? reconnectErr : new Error(String(reconnectErr));
|
||||
}
|
||||
log.warn(
|
||||
`[ws-stream] reconnect after warm-up failed for session=${sessionId}; falling back to HTTP. error=${String(reconnectErr)}`,
|
||||
);
|
||||
return fallbackToHttp(model, context, options, eventStream, opts.signal);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 3. Compute incremental vs full input ─────────────────────────────
|
||||
@ -537,16 +730,16 @@ export function createOpenAIWebSocketStreamFn(
|
||||
log.debug(
|
||||
`[ws-stream] session=${sessionId}: no new tool results found; sending full context`,
|
||||
);
|
||||
inputItems = buildFullInput(context);
|
||||
inputItems = buildFullInput(context, model);
|
||||
} else {
|
||||
inputItems = convertMessagesToInputItems(toolResults);
|
||||
inputItems = convertMessagesToInputItems(toolResults, model);
|
||||
}
|
||||
log.debug(
|
||||
`[ws-stream] session=${sessionId}: incremental send (${inputItems.length} tool results) previous_response_id=${prevResponseId}`,
|
||||
);
|
||||
} else {
|
||||
// First turn: send full context
|
||||
inputItems = buildFullInput(context);
|
||||
inputItems = buildFullInput(context, model);
|
||||
log.debug(
|
||||
`[ws-stream] session=${sessionId}: full context send (${inputItems.length} items)`,
|
||||
);
|
||||
@ -605,10 +798,9 @@ export function createOpenAIWebSocketStreamFn(
|
||||
...extraParams,
|
||||
};
|
||||
const nextPayload = await options?.onPayload?.(payload, model);
|
||||
const requestPayload =
|
||||
nextPayload && typeof nextPayload === "object"
|
||||
? (nextPayload as Parameters<OpenAIWebSocketManager["send"]>[0])
|
||||
: (payload as Parameters<OpenAIWebSocketManager["send"]>[0]);
|
||||
const requestPayload = (nextPayload ?? payload) as Parameters<
|
||||
OpenAIWebSocketManager["send"]
|
||||
>[0];
|
||||
|
||||
try {
|
||||
session.manager.send(requestPayload);
|
||||
@ -734,8 +926,8 @@ export function createOpenAIWebSocketStreamFn(
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Build full input items from context (system prompt is passed via `instructions` field). */
|
||||
function buildFullInput(context: Context): InputItem[] {
|
||||
return convertMessagesToInputItems(context.messages);
|
||||
function buildFullInput(context: Context, model: ReplayModelInfo): InputItem[] {
|
||||
return convertMessagesToInputItems(context.messages, model);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -106,6 +106,9 @@ describe("isBillingErrorMessage", () => {
|
||||
"Payment Required",
|
||||
"HTTP 402 Payment Required",
|
||||
"plans & billing",
|
||||
// Venice returns "Insufficient USD or Diem balance" which has extra words
|
||||
// between "insufficient" and "balance"
|
||||
"Insufficient USD or Diem balance to complete request. Visit https://venice.ai/settings/api to add credits.",
|
||||
];
|
||||
for (const sample of samples) {
|
||||
expect(isBillingErrorMessage(sample)).toBe(true);
|
||||
@ -149,6 +152,11 @@ describe("isBillingErrorMessage", () => {
|
||||
expect(longResponse.length).toBeGreaterThan(512);
|
||||
expect(isBillingErrorMessage(longResponse)).toBe(false);
|
||||
});
|
||||
it("does not false-positive on short non-billing text that mentions insufficient and balance", () => {
|
||||
const sample = "The evidence is insufficient to reconcile the final balance after compaction.";
|
||||
expect(isBillingErrorMessage(sample)).toBe(false);
|
||||
expect(classifyFailoverReason(sample)).toBeNull();
|
||||
});
|
||||
it("still matches explicit 402 markers in long payloads", () => {
|
||||
const longStructuredError =
|
||||
'{"error":{"code":402,"message":"payment required","details":"' + "x".repeat(700) + '"}}';
|
||||
@ -650,6 +658,12 @@ describe("classifyFailoverReason", () => {
|
||||
expect(classifyFailoverReason(TOGETHER_ENGINE_OVERLOADED_MESSAGE)).toBe("overloaded");
|
||||
expect(classifyFailoverReason(GROQ_TOO_MANY_REQUESTS_MESSAGE)).toBe("rate_limit");
|
||||
expect(classifyFailoverReason(GROQ_SERVICE_UNAVAILABLE_MESSAGE)).toBe("overloaded");
|
||||
// Venice 402 billing error with extra words between "insufficient" and "balance"
|
||||
expect(
|
||||
classifyFailoverReason(
|
||||
"Insufficient USD or Diem balance to complete request. Visit https://venice.ai/settings/api to add credits.",
|
||||
),
|
||||
).toBe("billing");
|
||||
});
|
||||
|
||||
it("classifies internal and compatibility error messages", () => {
|
||||
|
||||
@ -52,6 +52,7 @@ const ERROR_PATTERNS = {
|
||||
"credit balance",
|
||||
"plans & billing",
|
||||
"insufficient balance",
|
||||
"insufficient usd or diem balance",
|
||||
],
|
||||
authPermanent: [
|
||||
/api[_ ]?key[_ ]?(?:revoked|invalid|deactivated|deleted)/i,
|
||||
|
||||
@ -276,7 +276,7 @@ describe("applyExtraParamsToAgent", () => {
|
||||
const payloads: Record<string, unknown>[] = [];
|
||||
const baseStreamFn: StreamFn = (_model, _context, options) => {
|
||||
const payload: Record<string, unknown> = { model: "deepseek/deepseek-r1" };
|
||||
options?.onPayload?.(payload, _model);
|
||||
options?.onPayload?.(payload, model);
|
||||
payloads.push(payload);
|
||||
return {} as ReturnType<StreamFn>;
|
||||
};
|
||||
@ -308,7 +308,7 @@ describe("applyExtraParamsToAgent", () => {
|
||||
const payloads: Record<string, unknown>[] = [];
|
||||
const baseStreamFn: StreamFn = (_model, _context, options) => {
|
||||
const payload: Record<string, unknown> = {};
|
||||
options?.onPayload?.(payload, _model);
|
||||
options?.onPayload?.(payload, model);
|
||||
payloads.push(payload);
|
||||
return {} as ReturnType<StreamFn>;
|
||||
};
|
||||
@ -332,7 +332,7 @@ describe("applyExtraParamsToAgent", () => {
|
||||
const payloads: Record<string, unknown>[] = [];
|
||||
const baseStreamFn: StreamFn = (_model, _context, options) => {
|
||||
const payload: Record<string, unknown> = { reasoning_effort: "high" };
|
||||
options?.onPayload?.(payload, _model);
|
||||
options?.onPayload?.(payload, model);
|
||||
payloads.push(payload);
|
||||
return {} as ReturnType<StreamFn>;
|
||||
};
|
||||
@ -357,7 +357,7 @@ describe("applyExtraParamsToAgent", () => {
|
||||
const payloads: Record<string, unknown>[] = [];
|
||||
const baseStreamFn: StreamFn = (_model, _context, options) => {
|
||||
const payload: Record<string, unknown> = { reasoning: { max_tokens: 256 } };
|
||||
options?.onPayload?.(payload, _model);
|
||||
options?.onPayload?.(payload, model);
|
||||
payloads.push(payload);
|
||||
return {} as ReturnType<StreamFn>;
|
||||
};
|
||||
@ -381,7 +381,7 @@ describe("applyExtraParamsToAgent", () => {
|
||||
const payloads: Record<string, unknown>[] = [];
|
||||
const baseStreamFn: StreamFn = (_model, _context, options) => {
|
||||
const payload: Record<string, unknown> = { reasoning_effort: "medium" };
|
||||
options?.onPayload?.(payload, _model);
|
||||
options?.onPayload?.(payload, model);
|
||||
payloads.push(payload);
|
||||
return {} as ReturnType<StreamFn>;
|
||||
};
|
||||
@ -588,7 +588,7 @@ describe("applyExtraParamsToAgent", () => {
|
||||
const payloads: Record<string, unknown>[] = [];
|
||||
const baseStreamFn: StreamFn = (_model, _context, options) => {
|
||||
const payload: Record<string, unknown> = { thinking: "off" };
|
||||
options?.onPayload?.(payload, _model);
|
||||
options?.onPayload?.(payload, model);
|
||||
payloads.push(payload);
|
||||
return {} as ReturnType<StreamFn>;
|
||||
};
|
||||
@ -619,7 +619,7 @@ describe("applyExtraParamsToAgent", () => {
|
||||
const payloads: Record<string, unknown>[] = [];
|
||||
const baseStreamFn: StreamFn = (_model, _context, options) => {
|
||||
const payload: Record<string, unknown> = { thinking: "off" };
|
||||
options?.onPayload?.(payload, _model);
|
||||
options?.onPayload?.(payload, model);
|
||||
payloads.push(payload);
|
||||
return {} as ReturnType<StreamFn>;
|
||||
};
|
||||
@ -650,7 +650,7 @@ describe("applyExtraParamsToAgent", () => {
|
||||
const payloads: Record<string, unknown>[] = [];
|
||||
const baseStreamFn: StreamFn = (_model, _context, options) => {
|
||||
const payload: Record<string, unknown> = {};
|
||||
options?.onPayload?.(payload, _model);
|
||||
options?.onPayload?.(payload, model);
|
||||
payloads.push(payload);
|
||||
return {} as ReturnType<StreamFn>;
|
||||
};
|
||||
@ -674,7 +674,7 @@ describe("applyExtraParamsToAgent", () => {
|
||||
const payloads: Record<string, unknown>[] = [];
|
||||
const baseStreamFn: StreamFn = (_model, _context, options) => {
|
||||
const payload: Record<string, unknown> = { tool_choice: "required" };
|
||||
options?.onPayload?.(payload, _model);
|
||||
options?.onPayload?.(payload, model);
|
||||
payloads.push(payload);
|
||||
return {} as ReturnType<StreamFn>;
|
||||
};
|
||||
@ -699,7 +699,7 @@ describe("applyExtraParamsToAgent", () => {
|
||||
const payloads: Record<string, unknown>[] = [];
|
||||
const baseStreamFn: StreamFn = (_model, _context, options) => {
|
||||
const payload: Record<string, unknown> = {};
|
||||
options?.onPayload?.(payload, _model);
|
||||
options?.onPayload?.(payload, model);
|
||||
payloads.push(payload);
|
||||
return {} as ReturnType<StreamFn>;
|
||||
};
|
||||
@ -749,7 +749,7 @@ describe("applyExtraParamsToAgent", () => {
|
||||
],
|
||||
tool_choice: { type: "tool", name: "read" },
|
||||
};
|
||||
options?.onPayload?.(payload, _model);
|
||||
options?.onPayload?.(payload, model);
|
||||
payloads.push(payload);
|
||||
return {} as ReturnType<StreamFn>;
|
||||
};
|
||||
@ -793,7 +793,7 @@ describe("applyExtraParamsToAgent", () => {
|
||||
},
|
||||
],
|
||||
};
|
||||
options?.onPayload?.(payload, _model);
|
||||
options?.onPayload?.(payload, model);
|
||||
payloads.push(payload);
|
||||
return {} as ReturnType<StreamFn>;
|
||||
};
|
||||
@ -832,7 +832,7 @@ describe("applyExtraParamsToAgent", () => {
|
||||
},
|
||||
],
|
||||
};
|
||||
options?.onPayload?.(payload, _model);
|
||||
options?.onPayload?.(payload, model);
|
||||
payloads.push(payload);
|
||||
return {} as ReturnType<StreamFn>;
|
||||
};
|
||||
@ -896,7 +896,7 @@ describe("applyExtraParamsToAgent", () => {
|
||||
},
|
||||
},
|
||||
};
|
||||
options?.onPayload?.(payload, _model);
|
||||
options?.onPayload?.(payload, model);
|
||||
payloads.push(payload);
|
||||
return {} as ReturnType<StreamFn>;
|
||||
};
|
||||
@ -943,7 +943,7 @@ describe("applyExtraParamsToAgent", () => {
|
||||
},
|
||||
},
|
||||
};
|
||||
options?.onPayload?.(payload, _model);
|
||||
options?.onPayload?.(payload, model);
|
||||
payloads.push(payload);
|
||||
return {} as ReturnType<StreamFn>;
|
||||
};
|
||||
@ -1081,7 +1081,7 @@ describe("applyExtraParamsToAgent", () => {
|
||||
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0]?.transport).toBe("auto");
|
||||
expect(calls[0]?.openaiWsWarmup).toBe(true);
|
||||
expect(calls[0]?.openaiWsWarmup).toBe(false);
|
||||
});
|
||||
|
||||
it("lets runtime options override OpenAI default transport", () => {
|
||||
|
||||
@ -7,6 +7,7 @@ const {
|
||||
sessionCompactImpl,
|
||||
triggerInternalHook,
|
||||
sanitizeSessionHistoryMock,
|
||||
contextEngineCompactMock,
|
||||
} = vi.hoisted(() => ({
|
||||
hookRunner: {
|
||||
hasHooks: vi.fn(),
|
||||
@ -28,6 +29,14 @@ const {
|
||||
})),
|
||||
triggerInternalHook: vi.fn(),
|
||||
sanitizeSessionHistoryMock: vi.fn(async (params: { messages: unknown[] }) => params.messages),
|
||||
contextEngineCompactMock: vi.fn(async () => ({
|
||||
ok: true as boolean,
|
||||
compacted: true as boolean,
|
||||
reason: undefined as string | undefined,
|
||||
result: { summary: "engine-summary", tokensAfter: 50 } as
|
||||
| { summary: string; tokensAfter: number }
|
||||
| undefined,
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("../../plugins/hook-runner-global.js", () => ({
|
||||
@ -123,6 +132,27 @@ vi.mock("../session-write-lock.js", () => ({
|
||||
resolveSessionLockMaxHoldFromTimeout: vi.fn(() => 0),
|
||||
}));
|
||||
|
||||
vi.mock("../../context-engine/index.js", () => ({
|
||||
ensureContextEnginesInitialized: vi.fn(),
|
||||
resolveContextEngine: vi.fn(async () => ({
|
||||
info: { ownsCompaction: true },
|
||||
compact: contextEngineCompactMock,
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("../../process/command-queue.js", () => ({
|
||||
enqueueCommandInLane: vi.fn((_lane: unknown, task: () => unknown) => task()),
|
||||
}));
|
||||
|
||||
vi.mock("./lanes.js", () => ({
|
||||
resolveSessionLane: vi.fn(() => "test-session-lane"),
|
||||
resolveGlobalLane: vi.fn(() => "test-global-lane"),
|
||||
}));
|
||||
|
||||
vi.mock("../context-window-guard.js", () => ({
|
||||
resolveContextWindowInfo: vi.fn(() => ({ tokens: 128_000 })),
|
||||
}));
|
||||
|
||||
vi.mock("../bootstrap-files.js", () => ({
|
||||
makeBootstrapWarn: vi.fn(() => () => {}),
|
||||
resolveBootstrapContextForRun: vi.fn(async () => ({ contextFiles: [] })),
|
||||
@ -160,7 +190,7 @@ vi.mock("../transcript-policy.js", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("./extensions.js", () => ({
|
||||
buildEmbeddedExtensionFactories: vi.fn(() => []),
|
||||
buildEmbeddedExtensionFactories: vi.fn(() => ({ factories: [] })),
|
||||
}));
|
||||
|
||||
vi.mock("./history.js", () => ({
|
||||
@ -251,7 +281,7 @@ vi.mock("./utils.js", () => ({
|
||||
|
||||
import { getApiProvider, unregisterApiProviders } from "@mariozechner/pi-ai";
|
||||
import { getCustomApiRegistrySourceId } from "../custom-api-registry.js";
|
||||
import { compactEmbeddedPiSessionDirect } from "./compact.js";
|
||||
import { compactEmbeddedPiSessionDirect, compactEmbeddedPiSession } from "./compact.js";
|
||||
|
||||
const sessionHook = (action: string) =>
|
||||
triggerInternalHook.mock.calls.find(
|
||||
@ -436,3 +466,103 @@ describe("compactEmbeddedPiSessionDirect hooks", () => {
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("compactEmbeddedPiSession hooks (ownsCompaction engine)", () => {
|
||||
beforeEach(() => {
|
||||
hookRunner.hasHooks.mockReset();
|
||||
hookRunner.runBeforeCompaction.mockReset();
|
||||
hookRunner.runAfterCompaction.mockReset();
|
||||
contextEngineCompactMock.mockReset();
|
||||
contextEngineCompactMock.mockResolvedValue({
|
||||
ok: true,
|
||||
compacted: true,
|
||||
reason: undefined,
|
||||
result: { summary: "engine-summary", tokensAfter: 50 },
|
||||
});
|
||||
resolveModelMock.mockReset();
|
||||
resolveModelMock.mockReturnValue({
|
||||
model: { provider: "openai", api: "responses", id: "fake", input: [] },
|
||||
error: null,
|
||||
authStorage: { setRuntimeApiKey: vi.fn() },
|
||||
modelRegistry: {},
|
||||
});
|
||||
});
|
||||
|
||||
it("fires before_compaction with sentinel -1 and after_compaction on success", async () => {
|
||||
hookRunner.hasHooks.mockReturnValue(true);
|
||||
|
||||
const result = await compactEmbeddedPiSession({
|
||||
sessionId: "session-1",
|
||||
sessionKey: "agent:main:session-1",
|
||||
sessionFile: "/tmp/session.jsonl",
|
||||
workspaceDir: "/tmp",
|
||||
messageChannel: "telegram",
|
||||
customInstructions: "focus on decisions",
|
||||
enqueue: (task) => task(),
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.compacted).toBe(true);
|
||||
|
||||
expect(hookRunner.runBeforeCompaction).toHaveBeenCalledWith(
|
||||
{ messageCount: -1, sessionFile: "/tmp/session.jsonl" },
|
||||
expect.objectContaining({
|
||||
sessionKey: "agent:main:session-1",
|
||||
messageProvider: "telegram",
|
||||
}),
|
||||
);
|
||||
expect(hookRunner.runAfterCompaction).toHaveBeenCalledWith(
|
||||
{
|
||||
messageCount: -1,
|
||||
compactedCount: -1,
|
||||
tokenCount: 50,
|
||||
sessionFile: "/tmp/session.jsonl",
|
||||
},
|
||||
expect.objectContaining({
|
||||
sessionKey: "agent:main:session-1",
|
||||
messageProvider: "telegram",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not fire after_compaction when compaction fails", async () => {
|
||||
hookRunner.hasHooks.mockReturnValue(true);
|
||||
contextEngineCompactMock.mockResolvedValue({
|
||||
ok: false,
|
||||
compacted: false,
|
||||
reason: "nothing to compact",
|
||||
result: undefined,
|
||||
});
|
||||
|
||||
const result = await compactEmbeddedPiSession({
|
||||
sessionId: "session-1",
|
||||
sessionKey: "agent:main:session-1",
|
||||
sessionFile: "/tmp/session.jsonl",
|
||||
workspaceDir: "/tmp",
|
||||
customInstructions: "focus on decisions",
|
||||
enqueue: (task) => task(),
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(hookRunner.runBeforeCompaction).toHaveBeenCalled();
|
||||
expect(hookRunner.runAfterCompaction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("catches and logs hook exceptions without aborting compaction", async () => {
|
||||
hookRunner.hasHooks.mockReturnValue(true);
|
||||
hookRunner.runBeforeCompaction.mockRejectedValue(new Error("hook boom"));
|
||||
|
||||
const result = await compactEmbeddedPiSession({
|
||||
sessionId: "session-1",
|
||||
sessionKey: "agent:main:session-1",
|
||||
sessionFile: "/tmp/session.jsonl",
|
||||
workspaceDir: "/tmp",
|
||||
customInstructions: "focus on decisions",
|
||||
enqueue: (task) => task(),
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.compacted).toBe(true);
|
||||
expect(contextEngineCompactMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@ -936,6 +936,43 @@ export async function compactEmbeddedPiSession(
|
||||
modelContextWindow: ceModel?.contextWindow,
|
||||
defaultTokens: DEFAULT_CONTEXT_TOKENS,
|
||||
});
|
||||
// When the context engine owns compaction, its compact() implementation
|
||||
// bypasses compactEmbeddedPiSessionDirect (which fires the hooks internally).
|
||||
// Fire before_compaction / after_compaction hooks here so plugin subscribers
|
||||
// are notified regardless of which engine is active.
|
||||
const engineOwnsCompaction = contextEngine.info.ownsCompaction === true;
|
||||
const hookRunner = engineOwnsCompaction ? getGlobalHookRunner() : null;
|
||||
const hookSessionKey = params.sessionKey?.trim() || params.sessionId;
|
||||
const { sessionAgentId } = resolveSessionAgentIds({
|
||||
sessionKey: params.sessionKey,
|
||||
config: params.config,
|
||||
});
|
||||
const resolvedMessageProvider = params.messageChannel ?? params.messageProvider;
|
||||
const hookCtx = {
|
||||
sessionId: params.sessionId,
|
||||
agentId: sessionAgentId,
|
||||
sessionKey: hookSessionKey,
|
||||
workspaceDir: resolveUserPath(params.workspaceDir),
|
||||
messageProvider: resolvedMessageProvider,
|
||||
};
|
||||
// Engine-owned compaction doesn't load the transcript at this level, so
|
||||
// message counts are unavailable. We pass sessionFile so hook subscribers
|
||||
// can read the transcript themselves if they need exact counts.
|
||||
if (hookRunner?.hasHooks("before_compaction")) {
|
||||
try {
|
||||
await hookRunner.runBeforeCompaction(
|
||||
{
|
||||
messageCount: -1,
|
||||
sessionFile: params.sessionFile,
|
||||
},
|
||||
hookCtx,
|
||||
);
|
||||
} catch (err) {
|
||||
log.warn("before_compaction hook failed", {
|
||||
errorMessage: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
const result = await contextEngine.compact({
|
||||
sessionId: params.sessionId,
|
||||
sessionFile: params.sessionFile,
|
||||
@ -944,6 +981,23 @@ export async function compactEmbeddedPiSession(
|
||||
force: params.trigger === "manual",
|
||||
runtimeContext: params as Record<string, unknown>,
|
||||
});
|
||||
if (result.ok && result.compacted && hookRunner?.hasHooks("after_compaction")) {
|
||||
try {
|
||||
await hookRunner.runAfterCompaction(
|
||||
{
|
||||
messageCount: -1,
|
||||
compactedCount: -1,
|
||||
tokenCount: result.result?.tokensAfter,
|
||||
sessionFile: params.sessionFile,
|
||||
},
|
||||
hookCtx,
|
||||
);
|
||||
} catch (err) {
|
||||
log.warn("after_compaction hook failed", {
|
||||
errorMessage: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
return {
|
||||
ok: result.ok,
|
||||
compacted: result.compacted,
|
||||
|
||||
@ -382,6 +382,40 @@ describe("resolveModel", () => {
|
||||
expect(result.model?.reasoning).toBe(true);
|
||||
});
|
||||
|
||||
it("matches prefixed OpenRouter native ids in configured fallback models", () => {
|
||||
const cfg = {
|
||||
models: {
|
||||
providers: {
|
||||
openrouter: {
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
{
|
||||
...makeModel("openrouter/healer-alpha"),
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
contextWindow: 262144,
|
||||
maxTokens: 65536,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
const result = resolveModel("openrouter", "openrouter/healer-alpha", "/tmp/agent", cfg);
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.model).toMatchObject({
|
||||
provider: "openrouter",
|
||||
id: "openrouter/healer-alpha",
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
contextWindow: 262144,
|
||||
maxTokens: 65536,
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers configured provider api metadata over discovered registry model", () => {
|
||||
mockDiscoveredModel({
|
||||
provider: "onehub",
|
||||
|
||||
@ -250,7 +250,7 @@ export function createOpenAIDefaultTransportWrapper(baseStreamFn: StreamFn | und
|
||||
const mergedOptions = {
|
||||
...options,
|
||||
transport: options?.transport ?? "auto",
|
||||
openaiWsWarmup: typedOptions?.openaiWsWarmup ?? true,
|
||||
openaiWsWarmup: typedOptions?.openaiWsWarmup ?? false,
|
||||
} as SimpleStreamOptions;
|
||||
return underlying(model, context, mergedOptions);
|
||||
};
|
||||
|
||||
@ -9,16 +9,18 @@ export function makeOverflowError(message: string = DEFAULT_OVERFLOW_ERROR_MESSA
|
||||
|
||||
export function makeCompactionSuccess(params: {
|
||||
summary: string;
|
||||
firstKeptEntryId: string;
|
||||
tokensBefore: number;
|
||||
firstKeptEntryId?: string;
|
||||
tokensBefore?: number;
|
||||
tokensAfter?: number;
|
||||
}) {
|
||||
return {
|
||||
ok: true as const,
|
||||
compacted: true as const,
|
||||
result: {
|
||||
summary: params.summary,
|
||||
firstKeptEntryId: params.firstKeptEntryId,
|
||||
tokensBefore: params.tokensBefore,
|
||||
...(params.firstKeptEntryId ? { firstKeptEntryId: params.firstKeptEntryId } : {}),
|
||||
...(params.tokensBefore !== undefined ? { tokensBefore: params.tokensBefore } : {}),
|
||||
...(params.tokensAfter !== undefined ? { tokensAfter: params.tokensAfter } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -55,8 +57,9 @@ type MockCompactDirect = {
|
||||
compacted: true;
|
||||
result: {
|
||||
summary: string;
|
||||
firstKeptEntryId: string;
|
||||
tokensBefore: number;
|
||||
firstKeptEntryId?: string;
|
||||
tokensBefore?: number;
|
||||
tokensAfter?: number;
|
||||
};
|
||||
}) => unknown;
|
||||
};
|
||||
|
||||
@ -2,9 +2,13 @@ import "./run.overflow-compaction.mocks.shared.js";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { isCompactionFailureError, isLikelyContextOverflowError } from "../pi-embedded-helpers.js";
|
||||
|
||||
vi.mock("../../utils.js", () => ({
|
||||
resolveUserPath: vi.fn((p: string) => p),
|
||||
}));
|
||||
vi.mock(import("../../utils.js"), async (importOriginal) => {
|
||||
const actual = await importOriginal();
|
||||
return {
|
||||
...actual,
|
||||
resolveUserPath: vi.fn((p: string) => p),
|
||||
};
|
||||
});
|
||||
|
||||
import { log } from "./logger.js";
|
||||
import { runEmbeddedPiAgent } from "./run.js";
|
||||
@ -16,6 +20,7 @@ import {
|
||||
queueOverflowAttemptWithOversizedToolOutput,
|
||||
} from "./run.overflow-compaction.fixture.js";
|
||||
import {
|
||||
mockedContextEngine,
|
||||
mockedCompactDirect,
|
||||
mockedRunEmbeddedAttempt,
|
||||
mockedSessionLikelyHasOversizedToolResults,
|
||||
@ -30,6 +35,11 @@ const mockedIsLikelyContextOverflowError = vi.mocked(isLikelyContextOverflowErro
|
||||
describe("overflow compaction in run loop", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockedRunEmbeddedAttempt.mockReset();
|
||||
mockedCompactDirect.mockReset();
|
||||
mockedSessionLikelyHasOversizedToolResults.mockReset();
|
||||
mockedTruncateOversizedToolResultsInSession.mockReset();
|
||||
mockedContextEngine.info.ownsCompaction = false;
|
||||
mockedIsCompactionFailureError.mockImplementation((msg?: string) => {
|
||||
if (!msg) {
|
||||
return false;
|
||||
@ -72,7 +82,9 @@ describe("overflow compaction in run loop", () => {
|
||||
|
||||
expect(mockedCompactDirect).toHaveBeenCalledTimes(1);
|
||||
expect(mockedCompactDirect).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ authProfileId: "test-profile" }),
|
||||
expect.objectContaining({
|
||||
runtimeContext: expect.objectContaining({ authProfileId: "test-profile" }),
|
||||
}),
|
||||
);
|
||||
expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(2);
|
||||
expect(log.warn).toHaveBeenCalledWith(
|
||||
|
||||
@ -6,6 +6,25 @@ import type {
|
||||
PluginHookBeforePromptBuildResult,
|
||||
} from "../../plugins/types.js";
|
||||
|
||||
type MockCompactionResult =
|
||||
| {
|
||||
ok: true;
|
||||
compacted: true;
|
||||
result: {
|
||||
summary: string;
|
||||
firstKeptEntryId?: string;
|
||||
tokensBefore?: number;
|
||||
tokensAfter?: number;
|
||||
};
|
||||
reason?: string;
|
||||
}
|
||||
| {
|
||||
ok: false;
|
||||
compacted: false;
|
||||
reason: string;
|
||||
result?: undefined;
|
||||
};
|
||||
|
||||
export const mockedGlobalHookRunner = {
|
||||
hasHooks: vi.fn((_hookName: string) => false),
|
||||
runBeforeAgentStart: vi.fn(
|
||||
@ -26,12 +45,35 @@ export const mockedGlobalHookRunner = {
|
||||
_ctx: PluginHookAgentContext,
|
||||
): Promise<PluginHookBeforeModelResolveResult | undefined> => undefined,
|
||||
),
|
||||
runBeforeCompaction: vi.fn(async () => undefined),
|
||||
runAfterCompaction: vi.fn(async () => undefined),
|
||||
};
|
||||
|
||||
export const mockedContextEngine = {
|
||||
info: { ownsCompaction: false as boolean },
|
||||
compact: vi.fn<(params: unknown) => Promise<MockCompactionResult>>(async () => ({
|
||||
ok: false as const,
|
||||
compacted: false as const,
|
||||
reason: "nothing to compact",
|
||||
})),
|
||||
};
|
||||
|
||||
export const mockedContextEngineCompact = vi.mocked(mockedContextEngine.compact);
|
||||
export const mockedEnsureRuntimePluginsLoaded: (...args: unknown[]) => void = vi.fn();
|
||||
|
||||
vi.mock("../../plugins/hook-runner-global.js", () => ({
|
||||
getGlobalHookRunner: vi.fn(() => mockedGlobalHookRunner),
|
||||
}));
|
||||
|
||||
vi.mock("../../context-engine/index.js", () => ({
|
||||
ensureContextEnginesInitialized: vi.fn(),
|
||||
resolveContextEngine: vi.fn(async () => mockedContextEngine),
|
||||
}));
|
||||
|
||||
vi.mock("../runtime-plugins.js", () => ({
|
||||
ensureRuntimePluginsLoaded: mockedEnsureRuntimePluginsLoaded,
|
||||
}));
|
||||
|
||||
vi.mock("../auth-profiles.js", () => ({
|
||||
isProfileInCooldown: vi.fn(() => false),
|
||||
markAuthProfileFailure: vi.fn(async () => {}),
|
||||
@ -141,9 +183,13 @@ vi.mock("../../process/command-queue.js", () => ({
|
||||
enqueueCommandInLane: vi.fn((_lane: string, task: () => unknown) => task()),
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/message-channel.js", () => ({
|
||||
isMarkdownCapableMessageChannel: vi.fn(() => true),
|
||||
}));
|
||||
vi.mock(import("../../utils/message-channel.js"), async (importOriginal) => {
|
||||
const actual = await importOriginal();
|
||||
return {
|
||||
...actual,
|
||||
isMarkdownCapableMessageChannel: vi.fn(() => true),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../agent-paths.js", () => ({
|
||||
resolveOpenClawAgentDir: vi.fn(() => "/tmp/agent-dir"),
|
||||
|
||||
@ -1,5 +1,8 @@
|
||||
import { vi } from "vitest";
|
||||
import { compactEmbeddedPiSessionDirect } from "./compact.js";
|
||||
import {
|
||||
mockedContextEngine,
|
||||
mockedContextEngineCompact,
|
||||
} from "./run.overflow-compaction.mocks.shared.js";
|
||||
import { runEmbeddedAttempt } from "./run/attempt.js";
|
||||
import {
|
||||
sessionLikelyHasOversizedToolResults,
|
||||
@ -7,13 +10,14 @@ import {
|
||||
} from "./tool-result-truncation.js";
|
||||
|
||||
export const mockedRunEmbeddedAttempt = vi.mocked(runEmbeddedAttempt);
|
||||
export const mockedCompactDirect = vi.mocked(compactEmbeddedPiSessionDirect);
|
||||
export const mockedCompactDirect = mockedContextEngineCompact;
|
||||
export const mockedSessionLikelyHasOversizedToolResults = vi.mocked(
|
||||
sessionLikelyHasOversizedToolResults,
|
||||
);
|
||||
export const mockedTruncateOversizedToolResultsInSession = vi.mocked(
|
||||
truncateOversizedToolResultsInSession,
|
||||
);
|
||||
export { mockedContextEngine };
|
||||
|
||||
export const overflowBaseRunParams = {
|
||||
sessionId: "test-session",
|
||||
|
||||
@ -11,6 +11,7 @@ import {
|
||||
} from "./run.overflow-compaction.fixture.js";
|
||||
import { mockedGlobalHookRunner } from "./run.overflow-compaction.mocks.shared.js";
|
||||
import {
|
||||
mockedContextEngine,
|
||||
mockedCompactDirect,
|
||||
mockedRunEmbeddedAttempt,
|
||||
mockedSessionLikelyHasOversizedToolResults,
|
||||
@ -22,6 +23,25 @@ const mockedPickFallbackThinkingLevel = vi.mocked(pickFallbackThinkingLevel);
|
||||
describe("runEmbeddedPiAgent overflow compaction trigger routing", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockedRunEmbeddedAttempt.mockReset();
|
||||
mockedCompactDirect.mockReset();
|
||||
mockedSessionLikelyHasOversizedToolResults.mockReset();
|
||||
mockedTruncateOversizedToolResultsInSession.mockReset();
|
||||
mockedGlobalHookRunner.runBeforeAgentStart.mockReset();
|
||||
mockedGlobalHookRunner.runBeforeCompaction.mockReset();
|
||||
mockedGlobalHookRunner.runAfterCompaction.mockReset();
|
||||
mockedContextEngine.info.ownsCompaction = false;
|
||||
mockedCompactDirect.mockResolvedValue({
|
||||
ok: false,
|
||||
compacted: false,
|
||||
reason: "nothing to compact",
|
||||
});
|
||||
mockedSessionLikelyHasOversizedToolResults.mockReturnValue(false);
|
||||
mockedTruncateOversizedToolResultsInSession.mockResolvedValue({
|
||||
truncated: false,
|
||||
truncatedCount: 0,
|
||||
reason: "no oversized tool results",
|
||||
});
|
||||
mockedGlobalHookRunner.hasHooks.mockImplementation(() => false);
|
||||
});
|
||||
|
||||
@ -81,8 +101,12 @@ describe("runEmbeddedPiAgent overflow compaction trigger routing", () => {
|
||||
expect(mockedCompactDirect).toHaveBeenCalledTimes(1);
|
||||
expect(mockedCompactDirect).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
trigger: "overflow",
|
||||
authProfileId: "test-profile",
|
||||
sessionId: "test-session",
|
||||
sessionFile: "/tmp/session.json",
|
||||
runtimeContext: expect.objectContaining({
|
||||
trigger: "overflow",
|
||||
authProfileId: "test-profile",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
@ -132,6 +156,63 @@ describe("runEmbeddedPiAgent overflow compaction trigger routing", () => {
|
||||
expect(result.meta.error?.kind).toBe("context_overflow");
|
||||
});
|
||||
|
||||
it("fires compaction hooks during overflow recovery for ownsCompaction engines", async () => {
|
||||
mockedContextEngine.info.ownsCompaction = true;
|
||||
mockedGlobalHookRunner.hasHooks.mockImplementation(
|
||||
(hookName) => hookName === "before_compaction" || hookName === "after_compaction",
|
||||
);
|
||||
mockedRunEmbeddedAttempt
|
||||
.mockResolvedValueOnce(makeAttemptResult({ promptError: makeOverflowError() }))
|
||||
.mockResolvedValueOnce(makeAttemptResult({ promptError: null }));
|
||||
mockedCompactDirect.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
compacted: true,
|
||||
result: {
|
||||
summary: "engine-owned compaction",
|
||||
tokensAfter: 50,
|
||||
},
|
||||
});
|
||||
|
||||
await runEmbeddedPiAgent(overflowBaseRunParams);
|
||||
|
||||
expect(mockedGlobalHookRunner.runBeforeCompaction).toHaveBeenCalledWith(
|
||||
{ messageCount: -1, sessionFile: "/tmp/session.json" },
|
||||
expect.objectContaining({
|
||||
sessionKey: "test-key",
|
||||
}),
|
||||
);
|
||||
expect(mockedGlobalHookRunner.runAfterCompaction).toHaveBeenCalledWith(
|
||||
{
|
||||
messageCount: -1,
|
||||
compactedCount: -1,
|
||||
tokenCount: 50,
|
||||
sessionFile: "/tmp/session.json",
|
||||
},
|
||||
expect.objectContaining({
|
||||
sessionKey: "test-key",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("guards thrown engine-owned overflow compaction attempts", async () => {
|
||||
mockedContextEngine.info.ownsCompaction = true;
|
||||
mockedGlobalHookRunner.hasHooks.mockImplementation(
|
||||
(hookName) => hookName === "before_compaction" || hookName === "after_compaction",
|
||||
);
|
||||
mockedRunEmbeddedAttempt.mockResolvedValueOnce(
|
||||
makeAttemptResult({ promptError: makeOverflowError() }),
|
||||
);
|
||||
mockedCompactDirect.mockRejectedValueOnce(new Error("engine boom"));
|
||||
|
||||
const result = await runEmbeddedPiAgent(overflowBaseRunParams);
|
||||
|
||||
expect(mockedCompactDirect).toHaveBeenCalledTimes(1);
|
||||
expect(mockedGlobalHookRunner.runBeforeCompaction).toHaveBeenCalledTimes(1);
|
||||
expect(mockedGlobalHookRunner.runAfterCompaction).not.toHaveBeenCalled();
|
||||
expect(result.meta.error?.kind).toBe("context_overflow");
|
||||
expect(result.payloads?.[0]?.isError).toBe(true);
|
||||
});
|
||||
|
||||
it("returns retry_limit when repeated retries never converge", async () => {
|
||||
mockedRunEmbeddedAttempt.mockClear();
|
||||
mockedCompactDirect.mockClear();
|
||||
|
||||
@ -1028,37 +1028,84 @@ export async function runEmbeddedPiAgent(
|
||||
log.warn(
|
||||
`context overflow detected (attempt ${overflowCompactionAttempts}/${MAX_OVERFLOW_COMPACTION_ATTEMPTS}); attempting auto-compaction for ${provider}/${modelId}`,
|
||||
);
|
||||
const compactResult = await contextEngine.compact({
|
||||
sessionId: params.sessionId,
|
||||
sessionFile: params.sessionFile,
|
||||
tokenBudget: ctxInfo.tokens,
|
||||
force: true,
|
||||
compactionTarget: "budget",
|
||||
runtimeContext: {
|
||||
sessionKey: params.sessionKey,
|
||||
messageChannel: params.messageChannel,
|
||||
messageProvider: params.messageProvider,
|
||||
agentAccountId: params.agentAccountId,
|
||||
authProfileId: lastProfileId,
|
||||
workspaceDir: resolvedWorkspace,
|
||||
agentDir,
|
||||
config: params.config,
|
||||
skillsSnapshot: params.skillsSnapshot,
|
||||
senderIsOwner: params.senderIsOwner,
|
||||
provider,
|
||||
model: modelId,
|
||||
runId: params.runId,
|
||||
thinkLevel,
|
||||
reasoningLevel: params.reasoningLevel,
|
||||
bashElevated: params.bashElevated,
|
||||
extraSystemPrompt: params.extraSystemPrompt,
|
||||
ownerNumbers: params.ownerNumbers,
|
||||
trigger: "overflow",
|
||||
diagId: overflowDiagId,
|
||||
attempt: overflowCompactionAttempts,
|
||||
maxAttempts: MAX_OVERFLOW_COMPACTION_ATTEMPTS,
|
||||
},
|
||||
});
|
||||
let compactResult: Awaited<ReturnType<typeof contextEngine.compact>>;
|
||||
// When the engine owns compaction, hooks are not fired inside
|
||||
// compactEmbeddedPiSessionDirect (which is bypassed). Fire them
|
||||
// here so subscribers (memory extensions, usage trackers) are
|
||||
// notified even on overflow-recovery compactions.
|
||||
const overflowEngineOwnsCompaction = contextEngine.info.ownsCompaction === true;
|
||||
const overflowHookRunner = overflowEngineOwnsCompaction ? hookRunner : null;
|
||||
if (overflowHookRunner?.hasHooks("before_compaction")) {
|
||||
try {
|
||||
await overflowHookRunner.runBeforeCompaction(
|
||||
{ messageCount: -1, sessionFile: params.sessionFile },
|
||||
hookCtx,
|
||||
);
|
||||
} catch (hookErr) {
|
||||
log.warn(
|
||||
`before_compaction hook failed during overflow recovery: ${String(hookErr)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
try {
|
||||
compactResult = await contextEngine.compact({
|
||||
sessionId: params.sessionId,
|
||||
sessionFile: params.sessionFile,
|
||||
tokenBudget: ctxInfo.tokens,
|
||||
force: true,
|
||||
compactionTarget: "budget",
|
||||
runtimeContext: {
|
||||
sessionKey: params.sessionKey,
|
||||
messageChannel: params.messageChannel,
|
||||
messageProvider: params.messageProvider,
|
||||
agentAccountId: params.agentAccountId,
|
||||
authProfileId: lastProfileId,
|
||||
workspaceDir: resolvedWorkspace,
|
||||
agentDir,
|
||||
config: params.config,
|
||||
skillsSnapshot: params.skillsSnapshot,
|
||||
senderIsOwner: params.senderIsOwner,
|
||||
provider,
|
||||
model: modelId,
|
||||
runId: params.runId,
|
||||
thinkLevel,
|
||||
reasoningLevel: params.reasoningLevel,
|
||||
bashElevated: params.bashElevated,
|
||||
extraSystemPrompt: params.extraSystemPrompt,
|
||||
ownerNumbers: params.ownerNumbers,
|
||||
trigger: "overflow",
|
||||
diagId: overflowDiagId,
|
||||
attempt: overflowCompactionAttempts,
|
||||
maxAttempts: MAX_OVERFLOW_COMPACTION_ATTEMPTS,
|
||||
},
|
||||
});
|
||||
} catch (compactErr) {
|
||||
log.warn(
|
||||
`contextEngine.compact() threw during overflow recovery for ${provider}/${modelId}: ${String(compactErr)}`,
|
||||
);
|
||||
compactResult = { ok: false, compacted: false, reason: String(compactErr) };
|
||||
}
|
||||
if (
|
||||
compactResult.ok &&
|
||||
compactResult.compacted &&
|
||||
overflowHookRunner?.hasHooks("after_compaction")
|
||||
) {
|
||||
try {
|
||||
await overflowHookRunner.runAfterCompaction(
|
||||
{
|
||||
messageCount: -1,
|
||||
compactedCount: -1,
|
||||
tokenCount: compactResult.result?.tokensAfter,
|
||||
sessionFile: params.sessionFile,
|
||||
},
|
||||
hookCtx,
|
||||
);
|
||||
} catch (hookErr) {
|
||||
log.warn(
|
||||
`after_compaction hook failed during overflow recovery: ${String(hookErr)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (compactResult.compacted) {
|
||||
autoCompactionCount += 1;
|
||||
log.info(`auto-compaction succeeded for ${provider}/${modelId}; retrying prompt`);
|
||||
|
||||
@ -1774,6 +1774,8 @@ export async function runEmbeddedAttempt(
|
||||
sessionId: params.sessionId,
|
||||
workspaceDir: params.workspaceDir,
|
||||
messageProvider: params.messageProvider ?? undefined,
|
||||
trigger: params.trigger,
|
||||
channelId: params.messageChannel ?? params.messageProvider ?? undefined,
|
||||
},
|
||||
)
|
||||
.catch((err) => {
|
||||
@ -1982,6 +1984,8 @@ export async function runEmbeddedAttempt(
|
||||
sessionId: params.sessionId,
|
||||
workspaceDir: params.workspaceDir,
|
||||
messageProvider: params.messageProvider ?? undefined,
|
||||
trigger: params.trigger,
|
||||
channelId: params.messageChannel ?? params.messageProvider ?? undefined,
|
||||
},
|
||||
)
|
||||
.catch((err) => {
|
||||
@ -2042,6 +2046,8 @@ export async function runEmbeddedAttempt(
|
||||
sessionId: params.sessionId,
|
||||
workspaceDir: params.workspaceDir,
|
||||
messageProvider: params.messageProvider ?? undefined,
|
||||
trigger: params.trigger,
|
||||
channelId: params.messageChannel ?? params.messageProvider ?? undefined,
|
||||
},
|
||||
)
|
||||
.catch((err) => {
|
||||
|
||||
@ -3,10 +3,14 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@mariozechner/pi-ai/oauth", () => ({
|
||||
getOAuthApiKey: () => undefined,
|
||||
getOAuthProviders: () => [],
|
||||
}));
|
||||
vi.mock("@mariozechner/pi-ai", async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import("@mariozechner/pi-ai")>();
|
||||
return {
|
||||
...original,
|
||||
getOAuthApiKey: () => undefined,
|
||||
getOAuthProviders: () => [],
|
||||
};
|
||||
});
|
||||
|
||||
import { createOpenClawCodingTools } from "./pi-tools.js";
|
||||
|
||||
|
||||
@ -24,6 +24,11 @@ export type PinnedSandboxEntry = {
|
||||
basename: string;
|
||||
};
|
||||
|
||||
export type AnchoredSandboxEntry = {
|
||||
canonicalParentPath: string;
|
||||
basename: string;
|
||||
};
|
||||
|
||||
export type PinnedSandboxDirectoryEntry = {
|
||||
mountRootPath: string;
|
||||
relativePath: string;
|
||||
@ -154,6 +159,48 @@ export class SandboxFsPathGuard {
|
||||
};
|
||||
}
|
||||
|
||||
async resolveAnchoredSandboxEntry(
|
||||
target: SandboxResolvedFsPath,
|
||||
action: string,
|
||||
): Promise<AnchoredSandboxEntry> {
|
||||
const basename = path.posix.basename(target.containerPath);
|
||||
if (!basename || basename === "." || basename === "/") {
|
||||
throw new Error(`Invalid sandbox entry target: ${target.containerPath}`);
|
||||
}
|
||||
const parentPath = normalizeContainerPath(path.posix.dirname(target.containerPath));
|
||||
const canonicalParentPath = await this.resolveCanonicalContainerPath({
|
||||
containerPath: parentPath,
|
||||
allowFinalSymlinkForUnlink: false,
|
||||
});
|
||||
this.resolveRequiredMount(canonicalParentPath, action);
|
||||
return {
|
||||
canonicalParentPath,
|
||||
basename,
|
||||
};
|
||||
}
|
||||
|
||||
async resolveAnchoredPinnedEntry(
|
||||
target: SandboxResolvedFsPath,
|
||||
action: string,
|
||||
): Promise<PinnedSandboxEntry> {
|
||||
const anchoredTarget = await this.resolveAnchoredSandboxEntry(target, action);
|
||||
const mount = this.resolveRequiredMount(anchoredTarget.canonicalParentPath, action);
|
||||
const relativeParentPath = path.posix.relative(
|
||||
mount.containerRoot,
|
||||
anchoredTarget.canonicalParentPath,
|
||||
);
|
||||
if (relativeParentPath.startsWith("..") || path.posix.isAbsolute(relativeParentPath)) {
|
||||
throw new Error(
|
||||
`Sandbox path escapes allowed mounts; cannot ${action}: ${target.containerPath}`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
mountRootPath: mount.containerRoot,
|
||||
relativeParentPath: relativeParentPath === "." ? "" : relativeParentPath,
|
||||
basename: anchoredTarget.basename,
|
||||
};
|
||||
}
|
||||
|
||||
resolvePinnedDirectoryEntry(
|
||||
target: SandboxResolvedFsPath,
|
||||
action: string,
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import type { PathSafetyCheck } from "./fs-bridge-path-safety.js";
|
||||
import type { AnchoredSandboxEntry, PathSafetyCheck } from "./fs-bridge-path-safety.js";
|
||||
import type { SandboxResolvedFsPath } from "./fs-paths.js";
|
||||
|
||||
export type SandboxFsCommandPlan = {
|
||||
@ -10,11 +10,14 @@ export type SandboxFsCommandPlan = {
|
||||
allowFailure?: boolean;
|
||||
};
|
||||
|
||||
export function buildStatPlan(target: SandboxResolvedFsPath): SandboxFsCommandPlan {
|
||||
export function buildStatPlan(
|
||||
target: SandboxResolvedFsPath,
|
||||
anchoredTarget: AnchoredSandboxEntry,
|
||||
): SandboxFsCommandPlan {
|
||||
return {
|
||||
checks: [{ target, options: { action: "stat files" } }],
|
||||
script: 'set -eu; stat -c "%F|%s|%Y" -- "$1"',
|
||||
args: [target.containerPath],
|
||||
script: 'set -eu\ncd -- "$1"\nstat -c "%F|%s|%Y" -- "$2"',
|
||||
args: [anchoredTarget.canonicalParentPath, anchoredTarget.basename],
|
||||
allowFailure: true,
|
||||
};
|
||||
}
|
||||
|
||||
@ -4,7 +4,12 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createSandbox,
|
||||
createSandboxFsBridge,
|
||||
dockerExecResult,
|
||||
findCallsByScriptFragment,
|
||||
findCallByDockerArg,
|
||||
findCallByScriptFragment,
|
||||
getDockerArg,
|
||||
getDockerScript,
|
||||
installFsBridgeTestHarness,
|
||||
mockedExecDockerRaw,
|
||||
withTempDir,
|
||||
@ -66,6 +71,13 @@ describe("sandbox fs bridge anchored ops", () => {
|
||||
});
|
||||
|
||||
const pinnedCases = [
|
||||
{
|
||||
name: "write pins canonical parent + basename",
|
||||
invoke: (bridge: ReturnType<typeof createSandboxFsBridge>) =>
|
||||
bridge.writeFile({ filePath: "nested/file.txt", data: "updated" }),
|
||||
expectedArgs: ["write", "/workspace", "nested", "file.txt", "1"],
|
||||
forbiddenArgs: ["/workspace/nested/file.txt"],
|
||||
},
|
||||
{
|
||||
name: "mkdirp pins mount root + relative path",
|
||||
invoke: (bridge: ReturnType<typeof createSandboxFsBridge>) =>
|
||||
@ -121,4 +133,74 @@ describe("sandbox fs bridge anchored ops", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it.runIf(process.platform !== "win32")(
|
||||
"write resolves symlink parents to canonical pinned paths",
|
||||
async () => {
|
||||
await withTempDir("openclaw-fs-bridge-contract-write-", async (stateDir) => {
|
||||
const workspaceDir = path.join(stateDir, "workspace");
|
||||
const realDir = path.join(workspaceDir, "real");
|
||||
await fs.mkdir(realDir, { recursive: true });
|
||||
await fs.symlink(realDir, path.join(workspaceDir, "alias"));
|
||||
|
||||
mockedExecDockerRaw.mockImplementation(async (args) => {
|
||||
const script = getDockerScript(args);
|
||||
if (script.includes('readlink -f -- "$cursor"')) {
|
||||
const target = getDockerArg(args, 1);
|
||||
return dockerExecResult(`${target.replace("/workspace/alias", "/workspace/real")}\n`);
|
||||
}
|
||||
if (script.includes('stat -c "%F|%s|%Y"')) {
|
||||
return dockerExecResult("regular file|1|2");
|
||||
}
|
||||
return dockerExecResult("");
|
||||
});
|
||||
|
||||
const bridge = createSandboxFsBridge({
|
||||
sandbox: createSandbox({
|
||||
workspaceDir,
|
||||
agentWorkspaceDir: workspaceDir,
|
||||
}),
|
||||
});
|
||||
|
||||
await bridge.writeFile({ filePath: "alias/note.txt", data: "updated" });
|
||||
|
||||
const writeCall = findCallByDockerArg(1, "write");
|
||||
expect(writeCall).toBeDefined();
|
||||
const args = writeCall?.[0] ?? [];
|
||||
expect(getDockerArg(args, 2)).toBe("/workspace");
|
||||
expect(getDockerArg(args, 3)).toBe("real");
|
||||
expect(getDockerArg(args, 4)).toBe("note.txt");
|
||||
expect(args).not.toContain("alias");
|
||||
|
||||
const canonicalCalls = findCallsByScriptFragment('readlink -f -- "$cursor"');
|
||||
expect(
|
||||
canonicalCalls.some(([callArgs]) => getDockerArg(callArgs, 1) === "/workspace/alias"),
|
||||
).toBe(true);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("stat anchors parent + basename", async () => {
|
||||
await withTempDir("openclaw-fs-bridge-contract-stat-", async (stateDir) => {
|
||||
const workspaceDir = path.join(stateDir, "workspace");
|
||||
await fs.mkdir(path.join(workspaceDir, "nested"), { recursive: true });
|
||||
await fs.writeFile(path.join(workspaceDir, "nested", "file.txt"), "bye", "utf8");
|
||||
|
||||
const bridge = createSandboxFsBridge({
|
||||
sandbox: createSandbox({
|
||||
workspaceDir,
|
||||
agentWorkspaceDir: workspaceDir,
|
||||
}),
|
||||
});
|
||||
|
||||
await bridge.stat({ filePath: "nested/file.txt" });
|
||||
|
||||
const statCall = findCallByScriptFragment('stat -c "%F|%s|%Y" -- "$2"');
|
||||
expect(statCall).toBeDefined();
|
||||
const args = statCall?.[0] ?? [];
|
||||
expect(getDockerArg(args, 1)).toBe("/workspace/nested");
|
||||
expect(getDockerArg(args, 2)).toBe("file.txt");
|
||||
expect(args).not.toContain("/workspace/nested/file.txt");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -118,7 +118,10 @@ class SandboxFsBridgeImpl implements SandboxFsBridge {
|
||||
const buffer = Buffer.isBuffer(params.data)
|
||||
? params.data
|
||||
: Buffer.from(params.data, params.encoding ?? "utf8");
|
||||
const pinnedWriteTarget = this.pathGuard.resolvePinnedEntry(target, "write files");
|
||||
const pinnedWriteTarget = await this.pathGuard.resolveAnchoredPinnedEntry(
|
||||
target,
|
||||
"write files",
|
||||
);
|
||||
await this.runCheckedCommand({
|
||||
...buildPinnedWritePlan({
|
||||
check: writeCheck,
|
||||
@ -218,7 +221,11 @@ class SandboxFsBridgeImpl implements SandboxFsBridge {
|
||||
signal?: AbortSignal;
|
||||
}): Promise<SandboxFsStat | null> {
|
||||
const target = this.resolveResolvedPath(params);
|
||||
const result = await this.runPlannedCommand(buildStatPlan(target), params.signal);
|
||||
const anchoredTarget = await this.pathGuard.resolveAnchoredSandboxEntry(target, "stat files");
|
||||
const result = await this.runPlannedCommand(
|
||||
buildStatPlan(target, anchoredTarget),
|
||||
params.signal,
|
||||
);
|
||||
if (result.code !== 0) {
|
||||
const stderr = result.stderr.toString("utf8");
|
||||
if (stderr.includes("No such file or directory")) {
|
||||
|
||||
11
src/agents/tool-catalog.test.ts
Normal file
11
src/agents/tool-catalog.test.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveCoreToolProfilePolicy } from "./tool-catalog.js";
|
||||
|
||||
describe("tool-catalog", () => {
|
||||
it("includes web_search and web_fetch in the coding profile policy", () => {
|
||||
const policy = resolveCoreToolProfilePolicy("coding");
|
||||
expect(policy).toBeDefined();
|
||||
expect(policy!.allow).toContain("web_search");
|
||||
expect(policy!.allow).toContain("web_fetch");
|
||||
});
|
||||
});
|
||||
@ -86,7 +86,7 @@ const CORE_TOOL_DEFINITIONS: CoreToolDefinition[] = [
|
||||
label: "web_search",
|
||||
description: "Search the web",
|
||||
sectionId: "web",
|
||||
profiles: [],
|
||||
profiles: ["coding"],
|
||||
includeInOpenClawGroup: true,
|
||||
},
|
||||
{
|
||||
@ -94,7 +94,7 @@ const CORE_TOOL_DEFINITIONS: CoreToolDefinition[] = [
|
||||
label: "web_fetch",
|
||||
description: "Fetch web content",
|
||||
sectionId: "web",
|
||||
profiles: [],
|
||||
profiles: ["coding"],
|
||||
includeInOpenClawGroup: true,
|
||||
},
|
||||
{
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { describe, vi } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { ReplyPayload } from "../../../auto-reply/types.js";
|
||||
import {
|
||||
installSendPayloadContractSuite,
|
||||
@ -34,4 +34,92 @@ describe("whatsappOutbound sendPayload", () => {
|
||||
chunking: { mode: "split", longTextLength: 5000, maxChunkLength: 4000 },
|
||||
createHarness,
|
||||
});
|
||||
|
||||
it("trims leading whitespace for direct text sends", async () => {
|
||||
const sendWhatsApp = vi.fn(async () => ({ messageId: "wa-1", toJid: "jid" }));
|
||||
|
||||
await whatsappOutbound.sendText!({
|
||||
cfg: {},
|
||||
to: "5511999999999@c.us",
|
||||
text: "\n \thello",
|
||||
deps: { sendWhatsApp },
|
||||
});
|
||||
|
||||
expect(sendWhatsApp).toHaveBeenCalledWith("5511999999999@c.us", "hello", {
|
||||
verbose: false,
|
||||
cfg: {},
|
||||
accountId: undefined,
|
||||
gifPlayback: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("trims leading whitespace for direct media captions", async () => {
|
||||
const sendWhatsApp = vi.fn(async () => ({ messageId: "wa-1", toJid: "jid" }));
|
||||
|
||||
await whatsappOutbound.sendMedia!({
|
||||
cfg: {},
|
||||
to: "5511999999999@c.us",
|
||||
text: "\n \tcaption",
|
||||
mediaUrl: "/tmp/test.png",
|
||||
deps: { sendWhatsApp },
|
||||
});
|
||||
|
||||
expect(sendWhatsApp).toHaveBeenCalledWith("5511999999999@c.us", "caption", {
|
||||
verbose: false,
|
||||
cfg: {},
|
||||
mediaUrl: "/tmp/test.png",
|
||||
mediaLocalRoots: undefined,
|
||||
accountId: undefined,
|
||||
gifPlayback: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("trims leading whitespace for sendPayload text and caption delivery", async () => {
|
||||
const sendWhatsApp = vi.fn(async () => ({ messageId: "wa-1", toJid: "jid" }));
|
||||
|
||||
await whatsappOutbound.sendPayload!({
|
||||
cfg: {},
|
||||
to: "5511999999999@c.us",
|
||||
text: "",
|
||||
payload: { text: "\n\nhello" },
|
||||
deps: { sendWhatsApp },
|
||||
});
|
||||
await whatsappOutbound.sendPayload!({
|
||||
cfg: {},
|
||||
to: "5511999999999@c.us",
|
||||
text: "",
|
||||
payload: { text: "\n\ncaption", mediaUrl: "/tmp/test.png" },
|
||||
deps: { sendWhatsApp },
|
||||
});
|
||||
|
||||
expect(sendWhatsApp).toHaveBeenNthCalledWith(1, "5511999999999@c.us", "hello", {
|
||||
verbose: false,
|
||||
cfg: {},
|
||||
accountId: undefined,
|
||||
gifPlayback: undefined,
|
||||
});
|
||||
expect(sendWhatsApp).toHaveBeenNthCalledWith(2, "5511999999999@c.us", "caption", {
|
||||
verbose: false,
|
||||
cfg: {},
|
||||
mediaUrl: "/tmp/test.png",
|
||||
mediaLocalRoots: undefined,
|
||||
accountId: undefined,
|
||||
gifPlayback: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("skips whitespace-only text payloads", async () => {
|
||||
const sendWhatsApp = vi.fn();
|
||||
|
||||
const result = await whatsappOutbound.sendPayload!({
|
||||
cfg: {},
|
||||
to: "5511999999999@c.us",
|
||||
text: "",
|
||||
payload: { text: "\n \t" },
|
||||
deps: { sendWhatsApp },
|
||||
});
|
||||
|
||||
expect(result).toEqual({ channel: "whatsapp", messageId: "" });
|
||||
expect(sendWhatsApp).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@ -5,6 +5,10 @@ import { resolveWhatsAppOutboundTarget } from "../../../whatsapp/resolve-outboun
|
||||
import type { ChannelOutboundAdapter } from "../types.js";
|
||||
import { sendTextMediaPayload } from "./direct-text-media.js";
|
||||
|
||||
function trimLeadingWhitespace(text: string | undefined): string {
|
||||
return text?.trimStart() ?? "";
|
||||
}
|
||||
|
||||
export const whatsappOutbound: ChannelOutboundAdapter = {
|
||||
deliveryMode: "gateway",
|
||||
chunker: chunkText,
|
||||
@ -13,12 +17,32 @@ export const whatsappOutbound: ChannelOutboundAdapter = {
|
||||
pollMaxOptions: 12,
|
||||
resolveTarget: ({ to, allowFrom, mode }) =>
|
||||
resolveWhatsAppOutboundTarget({ to, allowFrom, mode }),
|
||||
sendPayload: async (ctx) =>
|
||||
await sendTextMediaPayload({ channel: "whatsapp", ctx, adapter: whatsappOutbound }),
|
||||
sendPayload: async (ctx) => {
|
||||
const text = trimLeadingWhitespace(ctx.payload.text);
|
||||
const hasMedia = Boolean(ctx.payload.mediaUrl) || (ctx.payload.mediaUrls?.length ?? 0) > 0;
|
||||
if (!text && !hasMedia) {
|
||||
return { channel: "whatsapp", messageId: "" };
|
||||
}
|
||||
return await sendTextMediaPayload({
|
||||
channel: "whatsapp",
|
||||
ctx: {
|
||||
...ctx,
|
||||
payload: {
|
||||
...ctx.payload,
|
||||
text,
|
||||
},
|
||||
},
|
||||
adapter: whatsappOutbound,
|
||||
});
|
||||
},
|
||||
sendText: async ({ cfg, to, text, accountId, deps, gifPlayback }) => {
|
||||
const normalizedText = trimLeadingWhitespace(text);
|
||||
if (!normalizedText) {
|
||||
return { channel: "whatsapp", messageId: "" };
|
||||
}
|
||||
const send =
|
||||
deps?.sendWhatsApp ?? (await import("../../../web/outbound.js")).sendMessageWhatsApp;
|
||||
const result = await send(to, text, {
|
||||
const result = await send(to, normalizedText, {
|
||||
verbose: false,
|
||||
cfg,
|
||||
accountId: accountId ?? undefined,
|
||||
@ -27,9 +51,10 @@ export const whatsappOutbound: ChannelOutboundAdapter = {
|
||||
return { channel: "whatsapp", ...result };
|
||||
},
|
||||
sendMedia: async ({ cfg, to, text, mediaUrl, mediaLocalRoots, accountId, deps, gifPlayback }) => {
|
||||
const normalizedText = trimLeadingWhitespace(text);
|
||||
const send =
|
||||
deps?.sendWhatsApp ?? (await import("../../../web/outbound.js")).sendMessageWhatsApp;
|
||||
const result = await send(to, text, {
|
||||
const result = await send(to, normalizedText, {
|
||||
verbose: false,
|
||||
cfg,
|
||||
mediaUrl,
|
||||
|
||||
@ -10,7 +10,7 @@ const resolveGatewayProgramArguments = vi.fn(async (_opts?: unknown) => ({
|
||||
const serviceInstall = vi.fn().mockResolvedValue(undefined);
|
||||
const serviceUninstall = vi.fn().mockResolvedValue(undefined);
|
||||
const serviceStop = vi.fn().mockResolvedValue(undefined);
|
||||
const serviceRestart = vi.fn().mockResolvedValue(undefined);
|
||||
const serviceRestart = vi.fn().mockResolvedValue({ outcome: "completed" });
|
||||
const serviceIsLoaded = vi.fn().mockResolvedValue(false);
|
||||
const serviceReadCommand = vi.fn().mockResolvedValue(null);
|
||||
const serviceReadRuntime = vi.fn().mockResolvedValue({ status: "running" });
|
||||
@ -48,20 +48,24 @@ vi.mock("../daemon/program-args.js", () => ({
|
||||
resolveGatewayProgramArguments: (opts: unknown) => resolveGatewayProgramArguments(opts),
|
||||
}));
|
||||
|
||||
vi.mock("../daemon/service.js", () => ({
|
||||
resolveGatewayService: () => ({
|
||||
label: "LaunchAgent",
|
||||
loadedText: "loaded",
|
||||
notLoadedText: "not loaded",
|
||||
install: serviceInstall,
|
||||
uninstall: serviceUninstall,
|
||||
stop: serviceStop,
|
||||
restart: serviceRestart,
|
||||
isLoaded: serviceIsLoaded,
|
||||
readCommand: serviceReadCommand,
|
||||
readRuntime: serviceReadRuntime,
|
||||
}),
|
||||
}));
|
||||
vi.mock("../daemon/service.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../daemon/service.js")>();
|
||||
return {
|
||||
...actual,
|
||||
resolveGatewayService: () => ({
|
||||
label: "LaunchAgent",
|
||||
loadedText: "loaded",
|
||||
notLoadedText: "not loaded",
|
||||
install: serviceInstall,
|
||||
uninstall: serviceUninstall,
|
||||
stop: serviceStop,
|
||||
restart: serviceRestart,
|
||||
isLoaded: serviceIsLoaded,
|
||||
readCommand: serviceReadCommand,
|
||||
readRuntime: serviceReadRuntime,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../daemon/legacy.js", () => ({
|
||||
findLegacyGatewayServices: async () => [],
|
||||
|
||||
@ -65,7 +65,7 @@ describe("runServiceRestart config pre-flight (#35862)", () => {
|
||||
service.restart.mockClear();
|
||||
service.isLoaded.mockResolvedValue(true);
|
||||
service.readCommand.mockResolvedValue({ environment: {} });
|
||||
service.restart.mockResolvedValue(undefined);
|
||||
service.restart.mockResolvedValue({ outcome: "completed" });
|
||||
vi.unstubAllEnvs();
|
||||
vi.stubEnv("OPENCLAW_GATEWAY_TOKEN", "");
|
||||
vi.stubEnv("CLAWDBOT_GATEWAY_TOKEN", "");
|
||||
@ -163,7 +163,7 @@ describe("runServiceStart config pre-flight (#35862)", () => {
|
||||
service.isLoaded.mockClear();
|
||||
service.restart.mockClear();
|
||||
service.isLoaded.mockResolvedValue(true);
|
||||
service.restart.mockResolvedValue(undefined);
|
||||
service.restart.mockResolvedValue({ outcome: "completed" });
|
||||
});
|
||||
|
||||
it("aborts start when config is invalid", async () => {
|
||||
|
||||
@ -40,11 +40,12 @@ vi.mock("../../runtime.js", () => ({
|
||||
}));
|
||||
|
||||
let runServiceRestart: typeof import("./lifecycle-core.js").runServiceRestart;
|
||||
let runServiceStart: typeof import("./lifecycle-core.js").runServiceStart;
|
||||
let runServiceStop: typeof import("./lifecycle-core.js").runServiceStop;
|
||||
|
||||
describe("runServiceRestart token drift", () => {
|
||||
beforeAll(async () => {
|
||||
({ runServiceRestart, runServiceStop } = await import("./lifecycle-core.js"));
|
||||
({ runServiceRestart, runServiceStart, runServiceStop } = await import("./lifecycle-core.js"));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
@ -64,7 +65,7 @@ describe("runServiceRestart token drift", () => {
|
||||
service.readCommand.mockResolvedValue({
|
||||
environment: { OPENCLAW_GATEWAY_TOKEN: "service-token" },
|
||||
});
|
||||
service.restart.mockResolvedValue(undefined);
|
||||
service.restart.mockResolvedValue({ outcome: "completed" });
|
||||
vi.unstubAllEnvs();
|
||||
vi.stubEnv("OPENCLAW_GATEWAY_TOKEN", "");
|
||||
vi.stubEnv("CLAWDBOT_GATEWAY_TOKEN", "");
|
||||
@ -176,4 +177,41 @@ describe("runServiceRestart token drift", () => {
|
||||
expect(payload.result).toBe("restarted");
|
||||
expect(payload.message).toContain("unmanaged process");
|
||||
});
|
||||
|
||||
it("skips restart health checks when restart is only scheduled", async () => {
|
||||
const postRestartCheck = vi.fn(async () => {});
|
||||
service.restart.mockResolvedValue({ outcome: "scheduled" });
|
||||
|
||||
const result = await runServiceRestart({
|
||||
serviceNoun: "Gateway",
|
||||
service,
|
||||
renderStartHints: () => [],
|
||||
opts: { json: true },
|
||||
postRestartCheck,
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(postRestartCheck).not.toHaveBeenCalled();
|
||||
const jsonLine = runtimeLogs.find((line) => line.trim().startsWith("{"));
|
||||
const payload = JSON.parse(jsonLine ?? "{}") as { result?: string; message?: string };
|
||||
expect(payload.result).toBe("scheduled");
|
||||
expect(payload.message).toBe("restart scheduled, gateway will restart momentarily");
|
||||
});
|
||||
|
||||
it("emits scheduled when service start routes through a scheduled restart", async () => {
|
||||
service.restart.mockResolvedValue({ outcome: "scheduled" });
|
||||
|
||||
await runServiceStart({
|
||||
serviceNoun: "Gateway",
|
||||
service,
|
||||
renderStartHints: () => [],
|
||||
opts: { json: true },
|
||||
});
|
||||
|
||||
expect(service.isLoaded).toHaveBeenCalledTimes(1);
|
||||
const jsonLine = runtimeLogs.find((line) => line.trim().startsWith("{"));
|
||||
const payload = JSON.parse(jsonLine ?? "{}") as { result?: string; message?: string };
|
||||
expect(payload.result).toBe("scheduled");
|
||||
expect(payload.message).toBe("restart scheduled, gateway will restart momentarily");
|
||||
});
|
||||
});
|
||||
|
||||
@ -3,6 +3,8 @@ import { readBestEffortConfig, readConfigFileSnapshot } from "../../config/confi
|
||||
import { formatConfigIssueLines } from "../../config/issue-format.js";
|
||||
import { resolveIsNixMode } from "../../config/paths.js";
|
||||
import { checkTokenDrift } from "../../daemon/service-audit.js";
|
||||
import type { GatewayServiceRestartResult } from "../../daemon/service-types.js";
|
||||
import { describeGatewayServiceRestart } from "../../daemon/service.js";
|
||||
import type { GatewayService } from "../../daemon/service.js";
|
||||
import { renderSystemdUnavailableHints } from "../../daemon/systemd-hints.js";
|
||||
import { isSystemdUserServiceAvailable } from "../../daemon/systemd.js";
|
||||
@ -223,7 +225,20 @@ export async function runServiceStart(params: {
|
||||
}
|
||||
|
||||
try {
|
||||
await params.service.restart({ env: process.env, stdout });
|
||||
const restartResult = await params.service.restart({ env: process.env, stdout });
|
||||
const restartStatus = describeGatewayServiceRestart(params.serviceNoun, restartResult);
|
||||
if (restartStatus.scheduled) {
|
||||
emit({
|
||||
ok: true,
|
||||
result: restartStatus.daemonActionResult,
|
||||
message: restartStatus.message,
|
||||
service: buildDaemonServiceSnapshot(params.service, loaded),
|
||||
});
|
||||
if (!json) {
|
||||
defaultRuntime.log(restartStatus.message);
|
||||
}
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
const hints = params.renderStartHints();
|
||||
fail(`${params.serviceNoun} start failed: ${String(err)}`, hints);
|
||||
@ -317,7 +332,7 @@ export async function runServiceRestart(params: {
|
||||
renderStartHints: () => string[];
|
||||
opts?: DaemonLifecycleOptions;
|
||||
checkTokenDrift?: boolean;
|
||||
postRestartCheck?: (ctx: RestartPostCheckContext) => Promise<void>;
|
||||
postRestartCheck?: (ctx: RestartPostCheckContext) => Promise<GatewayServiceRestartResult | void>;
|
||||
onNotLoaded?: (ctx: NotLoadedActionContext) => Promise<NotLoadedActionResult | null>;
|
||||
}): Promise<boolean> {
|
||||
const json = Boolean(params.opts?.json);
|
||||
@ -402,11 +417,42 @@ export async function runServiceRestart(params: {
|
||||
}
|
||||
|
||||
try {
|
||||
let restartResult: GatewayServiceRestartResult = { outcome: "completed" };
|
||||
if (loaded) {
|
||||
await params.service.restart({ env: process.env, stdout });
|
||||
restartResult = await params.service.restart({ env: process.env, stdout });
|
||||
}
|
||||
let restartStatus = describeGatewayServiceRestart(params.serviceNoun, restartResult);
|
||||
if (restartStatus.scheduled) {
|
||||
emit({
|
||||
ok: true,
|
||||
result: restartStatus.daemonActionResult,
|
||||
message: restartStatus.message,
|
||||
service: buildDaemonServiceSnapshot(params.service, loaded),
|
||||
warnings: warnings.length ? warnings : undefined,
|
||||
});
|
||||
if (!json) {
|
||||
defaultRuntime.log(restartStatus.message);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (params.postRestartCheck) {
|
||||
await params.postRestartCheck({ json, stdout, warnings, fail });
|
||||
const postRestartResult = await params.postRestartCheck({ json, stdout, warnings, fail });
|
||||
if (postRestartResult) {
|
||||
restartStatus = describeGatewayServiceRestart(params.serviceNoun, postRestartResult);
|
||||
if (restartStatus.scheduled) {
|
||||
emit({
|
||||
ok: true,
|
||||
result: restartStatus.daemonActionResult,
|
||||
message: restartStatus.message,
|
||||
service: buildDaemonServiceSnapshot(params.service, loaded),
|
||||
warnings: warnings.length ? warnings : undefined,
|
||||
});
|
||||
if (!json) {
|
||||
defaultRuntime.log(restartStatus.message);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
let restarted = loaded;
|
||||
if (loaded) {
|
||||
|
||||
@ -132,6 +132,7 @@ describe("runDaemonRestart health checks", () => {
|
||||
programArguments: ["openclaw", "gateway", "--port", "18789"],
|
||||
environment: {},
|
||||
});
|
||||
service.restart.mockResolvedValue({ outcome: "completed" });
|
||||
|
||||
runServiceRestart.mockImplementation(async (params: RestartParams) => {
|
||||
const fail = (message: string, hints?: string[]) => {
|
||||
@ -204,6 +205,25 @@ describe("runDaemonRestart health checks", () => {
|
||||
expect(waitForGatewayHealthyRestart).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("skips stale-pid retry health checks when the retry restart is only scheduled", async () => {
|
||||
const unhealthy: RestartHealthSnapshot = {
|
||||
healthy: false,
|
||||
staleGatewayPids: [1993],
|
||||
runtime: { status: "stopped" },
|
||||
portUsage: { port: 18789, status: "busy", listeners: [], hints: [] },
|
||||
};
|
||||
waitForGatewayHealthyRestart.mockResolvedValueOnce(unhealthy);
|
||||
terminateStaleGatewayPids.mockResolvedValue([1993]);
|
||||
service.restart.mockResolvedValueOnce({ outcome: "scheduled" });
|
||||
|
||||
const result = await runDaemonRestart({ json: true });
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(terminateStaleGatewayPids).toHaveBeenCalledWith([1993]);
|
||||
expect(service.restart).toHaveBeenCalledTimes(1);
|
||||
expect(waitForGatewayHealthyRestart).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("fails restart when gateway remains unhealthy", async () => {
|
||||
const unhealthy: RestartHealthSnapshot = {
|
||||
healthy: false,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user