From 8612af754b4d3ed4e0b3369e081b37f20913763a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 2 May 2026 04:52:17 +0100 Subject: [PATCH] feat: simplify thread-bound session spawning --- CHANGELOG.md | 1 + docs/.generated/config-baseline.sha256 | 8 +- docs/channels/discord.md | 10 +- docs/channels/matrix.md | 14 +- docs/channels/telegram.md | 2 +- docs/concepts/session-tool.md | 2 + docs/gateway/config-agents.md | 2 + docs/gateway/config-channels.md | 6 +- docs/help/faq.md | 2 +- docs/tools/acp-agents-setup.md | 4 +- docs/tools/acp-agents.md | 12 +- docs/tools/subagents.md | 10 +- extensions/discord/src/config-ui-hints.ts | 12 +- extensions/discord/src/subagent-hooks.test.ts | 33 ++--- extensions/discord/src/subagent-hooks.ts | 52 ++++---- extensions/line/src/config-schema.ts | 2 + extensions/line/src/types.ts | 4 + extensions/matrix/src/config-schema.ts | 2 + .../matrix/src/matrix/subagent-hooks.test.ts | 87 +++++++------ .../matrix/src/matrix/subagent-hooks.ts | 53 ++++---- extensions/matrix/src/types.ts | 4 + .../src/runners/contract/scenario-catalog.ts | 2 +- .../qa-matrix/src/substrate/config.test.ts | 4 +- extensions/qa-matrix/src/substrate/config.ts | 6 +- extensions/telegram/src/config-ui-hints.ts | 12 +- src/agents/acp-spawn.test.ts | 18 +-- src/agents/runtime-capabilities.test.ts | 42 ++++++ src/agents/runtime-capabilities.ts | 28 +++- src/agents/subagent-spawn.context.test.ts | 36 ++++++ src/agents/subagent-spawn.ts | 34 ++++- src/agents/system-prompt.test.ts | 22 ++++ src/agents/system-prompt.ts | 13 +- src/agents/tool-description-presets.ts | 9 +- src/agents/tools/sessions-spawn-tool.test.ts | 52 ++++++++ src/agents/tools/sessions-spawn-tool.ts | 63 ++++++++- src/auto-reply/reply/commands-acp.test.ts | 21 +-- .../reply/commands-subagents-focus.test.ts | 12 +- src/channels/thread-bindings-policy.test.ts | 71 +++++++++++ src/channels/thread-bindings-policy.ts | 26 +++- .../shared/legacy-config-migrate.test.ts | 50 ++++++++ .../legacy-config-migrations.channels.ts | 120 ++++++++++++++++++ ...ndled-channel-config-metadata.generated.ts | 73 +++++++++-- src/config/schema.base.generated.ts | 23 ++++ src/config/schema.help.quality.test.ts | 2 + src/config/schema.help.ts | 4 + src/config/schema.labels.ts | 2 + src/config/types.base.ts | 11 ++ src/config/types.channels.ts | 4 + src/config/types.discord.ts | 16 ++- src/config/types.telegram.ts | 6 +- src/config/zod-schema.providers-core.ts | 4 + src/config/zod-schema.session.ts | 2 + src/plugin-sdk/conversation-runtime.ts | 1 + 53 files changed, 892 insertions(+), 219 deletions(-) create mode 100644 src/agents/runtime-capabilities.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 899824aad19..db95b3dcbb3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ Docs: https://docs.openclaw.ai - Plugins/ClawHub: persist ClawPack digest metadata on ClawHub plugin install and update records so registry refreshes and download verification can reuse stored artifact facts. Thanks @vincentkoc. - Plugins/ClawHub: allow official bundled-plugin cutovers to prefer ClawHub installs with npm fallback only when the ClawHub package or version is absent. Thanks @vincentkoc. - Plugins/Crestodian: add ClawHub plugin search plus Crestodian plugin list/search/install/uninstall operations, with approval and audit coverage for install and uninstall. +- Channels/thread bindings: replace split subagent/ACP thread-spawn toggles with `threadBindings.spawnSessions`, default thread-bound spawns on, and let `openclaw doctor --fix` migrate the legacy keys. - Providers/OpenAI: add `extraBody`/`extra_body` passthrough for OpenAI-compatible TTS endpoints, so custom speech servers can receive fields such as `lang` in `/audio/speech` requests. Fixes #39900. Thanks @R3NK0R. - Dependencies: refresh workspace dependency pins, including TypeBox 1.1.37, AWS SDK 3.1041.0, Microsoft Teams 2.0.9, and Marked 18.0.3. Thanks @mariozechner, @aws, and @microsoft. - Discord/channels: add reusable message-channel access groups plus Discord channel-audience DM authorization, so allowlists can reference `accessGroup:` across channel auth paths. (#75813) diff --git a/docs/.generated/config-baseline.sha256 b/docs/.generated/config-baseline.sha256 index 3e953061db2..64a475b3e21 100644 --- a/docs/.generated/config-baseline.sha256 +++ b/docs/.generated/config-baseline.sha256 @@ -1,4 +1,4 @@ -94f7879b0771e81973c0749c719c19283fdc26e0e42fe6536f8ee563be6a44e5 config-baseline.json -a38ea77d2f0f0188f14ce0e3a8a564ff80e51415849359042f51921eb01ec2d9 config-baseline.core.json -eab8a85eefa2792fb8b98a07698e5ec31ff0b6f8af6222767e8049dcc5c4f529 config-baseline.channel.json -6bd6c72b17801072b2d3285c82f4c21adcc95f0edffc1e6f64e767d0a07b678f config-baseline.plugin.json +d0b1fc318d2f737c91c21ffffae2fe12197b4ba6d49859c4786ecbc586cf5a82 config-baseline.json +3f9c52903905d82d4b4ca9dbda530cac2e059870b08c69965099ebcd09a270a3 config-baseline.core.json +f42329d45c095881bd226bdb192c235980658fd250606d0c0badc2b12f12f5d3 config-baseline.channel.json +af71b84b2411d8ccabcc6e09de0ee41f8212ff9869a6677698b6e7e3afdfaa47 config-baseline.plugin.json diff --git a/docs/channels/discord.md b/docs/channels/discord.md index e1d7cd38b8b..ac324dcd426 100644 --- a/docs/channels/discord.md +++ b/docs/channels/discord.md @@ -738,7 +738,8 @@ Default slash command settings: enabled: true, idleHours: 24, maxAgeHours: 0, - spawnSubagentSessions: false, // opt-in + spawnSessions: true, + defaultSpawnContext: "fork", }, }, }, @@ -749,8 +750,9 @@ Default slash command settings: - `session.threadBindings.*` sets global defaults. - `channels.discord.threadBindings.*` overrides Discord behavior. - - `spawnSubagentSessions` must be true to auto-create/bind threads for `sessions_spawn({ thread: true })`. - - `spawnAcpSessions` must be true to auto-create/bind threads for ACP (`/acp spawn ... --thread ...` or `sessions_spawn({ runtime: "acp", thread: true })`). + - `spawnSessions` controls auto-create/bind threads for `sessions_spawn({ thread: true })` and ACP thread spawns. Default: `true`. + - `defaultSpawnContext` controls native subagent context for thread-bound spawns. Default: `"fork"`. + - Deprecated `spawnSubagentSessions`/`spawnAcpSessions` keys are migrated by `openclaw doctor --fix`. - If thread bindings are disabled for an account, `/focus` and related thread binding operations are unavailable. See [Sub-agents](/tools/subagents), [ACP Agents](/tools/acp-agents), and [Configuration Reference](/gateway/configuration-reference). @@ -816,7 +818,7 @@ Default slash command settings: - `/acp spawn codex --bind here` binds the current channel or thread in place and keeps future messages on the same ACP session. Thread messages inherit the parent channel binding. - In a bound channel or thread, `/new` and `/reset` reset the same ACP session in place. Temporary thread bindings can override target resolution while active. - - `spawnAcpSessions` is only required when OpenClaw needs to create/bind a child thread via `--thread auto|here`. + - `spawnSessions` gates child thread creation/binding via `--thread auto|here`. See [ACP Agents](/tools/acp-agents) for binding behavior details. diff --git a/docs/channels/matrix.md b/docs/channels/matrix.md index 361eaa6b5f8..65698fd613d 100644 --- a/docs/channels/matrix.md +++ b/docs/channels/matrix.md @@ -530,7 +530,7 @@ Explicit conversation bindings always win over `sessionScope`, so bound rooms an - Message-tool sends auto-inherit the current Matrix thread when targeting the same room (or the same DM user target), unless an explicit `threadId` is provided. - DM user-target reuse only kicks in when the current session metadata proves the same DM peer on the same Matrix account; otherwise OpenClaw falls back to normal user-scoped routing. - `/focus`, `/unfocus`, `/agents`, `/session idle`, `/session max-age`, and thread-bound `/acp spawn` all work in Matrix rooms and DMs. -- Top-level `/focus` creates a new Matrix thread and binds it to the target session when `threadBindings.spawnSubagentSessions: true`. +- Top-level `/focus` creates a new Matrix thread and binds it to the target session when `threadBindings.spawnSessions` is enabled. - Running `/focus` or `/acp spawn --thread here` inside an existing Matrix thread binds that thread in place. When OpenClaw detects a Matrix DM room colliding with another DM room on the same shared session, it posts a one-time `m.notice` in that room pointing to the `/focus` escape hatch and suggesting a `dm.sessionScope` change. The notice only appears when thread bindings are enabled. @@ -550,7 +550,7 @@ Fast operator flow: Notes: - `--bind here` does not create a child Matrix thread. -- `threadBindings.spawnAcpSessions` is only required for `/acp spawn --thread auto|here`, where OpenClaw needs to create or bind a child Matrix thread. +- `threadBindings.spawnSessions` gates `/acp spawn --thread auto|here`, where OpenClaw needs to create or bind a child Matrix thread. ### Thread binding config @@ -559,13 +559,13 @@ Matrix inherits global defaults from `session.threadBindings`, and also supports - `threadBindings.enabled` - `threadBindings.idleHours` - `threadBindings.maxAgeHours` -- `threadBindings.spawnSubagentSessions` -- `threadBindings.spawnAcpSessions` +- `threadBindings.spawnSessions` +- `threadBindings.defaultSpawnContext` -Matrix thread-bound spawn flags are opt-in: +Matrix thread-bound session spawns default on: -- Set `threadBindings.spawnSubagentSessions: true` to allow top-level `/focus` to create and bind new Matrix threads. -- Set `threadBindings.spawnAcpSessions: true` to allow `/acp spawn --thread auto|here` to bind ACP sessions to Matrix threads. +- Set `threadBindings.spawnSessions: false` to block top-level `/focus` and `/acp spawn --thread auto|here` from creating/binding Matrix threads. +- Set `threadBindings.defaultSpawnContext: "isolated"` when native subagent thread spawns should not fork the parent transcript. ## Reactions diff --git a/docs/channels/telegram.md b/docs/channels/telegram.md index 599aa25ec80..9f969df2325 100644 --- a/docs/channels/telegram.md +++ b/docs/channels/telegram.md @@ -540,7 +540,7 @@ curl "https://api.telegram.org/bot/getUpdates" **Persistent ACP topic binding**: Forum topics can pin ACP harness sessions through top-level typed ACP bindings (`bindings[]` with `type: "acp"` and `match.channel: "telegram"`, `peer.kind: "group"`, and a topic-qualified id like `-1001234567890:topic:42`). Currently scoped to forum topics in groups/supergroups. See [ACP Agents](/tools/acp-agents). - **Thread-bound ACP spawn from chat**: `/acp spawn --thread here|auto` binds the current topic to a new ACP session; follow-ups route there directly. OpenClaw pins the spawn confirmation in-topic. Requires `channels.telegram.threadBindings.spawnAcpSessions=true`. + **Thread-bound ACP spawn from chat**: `/acp spawn --thread here|auto` binds the current topic to a new ACP session; follow-ups route there directly. OpenClaw pins the spawn confirmation in-topic. Requires `channels.telegram.threadBindings.spawnSessions` to remain enabled (default: `true`). Template context exposes `MessageThreadId` and `IsForum`. DM chats with `message_thread_id` keep DM routing but use thread-aware session keys. diff --git a/docs/concepts/session-tool.md b/docs/concepts/session-tool.md index e0500ee001f..21d5fe1980c 100644 --- a/docs/concepts/session-tool.md +++ b/docs/concepts/session-tool.md @@ -143,6 +143,8 @@ Key options: - `sandbox: "require"` to enforce sandboxing on the child. - `context: "fork"` for native sub-agents when the child needs the current requester transcript; omit it or use `context: "isolated"` for a clean child. + Thread-bound native sub-agents default to `context: "fork"` unless + `threadBindings.defaultSpawnContext` says otherwise. Default leaf sub-agents do not get session tools. When `maxSpawnDepth >= 2`, depth-1 orchestrator sub-agents additionally receive diff --git a/docs/gateway/config-agents.md b/docs/gateway/config-agents.md index 8f9aa861099..0f0627c9072 100644 --- a/docs/gateway/config-agents.md +++ b/docs/gateway/config-agents.md @@ -1229,6 +1229,8 @@ See [Multi-Agent Sandbox & Tools](/tools/multi-agent-sandbox-tools) for preceden - `enabled`: master default switch (providers can override; Discord uses `channels.discord.threadBindings.enabled`) - `idleHours`: default inactivity auto-unfocus in hours (`0` disables; providers can override) - `maxAgeHours`: default hard max age in hours (`0` disables; providers can override) + - `spawnSessions`: default gate for creating thread-bound work sessions from `sessions_spawn` and ACP thread spawns. Defaults to `true` when thread bindings are enabled; providers/accounts can override. + - `defaultSpawnContext`: default native subagent context for thread-bound spawns (`"fork"` or `"isolated"`). Defaults to `"fork"`. diff --git a/docs/gateway/config-channels.md b/docs/gateway/config-channels.md index fe464449565..66af032501e 100644 --- a/docs/gateway/config-channels.md +++ b/docs/gateway/config-channels.md @@ -285,7 +285,8 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat enabled: true, idleHours: 24, maxAgeHours: 0, - spawnSubagentSessions: false, // opt-in for sessions_spawn({ thread: true }) + spawnSessions: true, + defaultSpawnContext: "fork", }, voice: { enabled: true, @@ -336,7 +337,8 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat - `enabled`: Discord override for thread-bound session features (`/focus`, `/unfocus`, `/agents`, `/session idle`, `/session max-age`, and bound delivery/routing) - `idleHours`: Discord override for inactivity auto-unfocus in hours (`0` disables) - `maxAgeHours`: Discord override for hard max age in hours (`0` disables) - - `spawnSubagentSessions`: opt-in switch for `sessions_spawn({ thread: true })` auto thread creation/binding + - `spawnSessions`: switch for `sessions_spawn({ thread: true })` and ACP thread-spawn auto thread creation/binding (default: `true`) + - `defaultSpawnContext`: native subagent context for thread-bound spawns (`"fork"` by default) - Top-level `bindings[]` entries with `type: "acp"` configure persistent ACP bindings for channels and threads (use channel/thread id in `match.peer.id`). Field semantics are shared in [ACP Agents](/tools/acp-agents#channel-specific-settings). - `channels.discord.ui.components.accentColor` sets the accent color for Discord components v2 containers. - `channels.discord.voice` enables Discord voice channel conversations and optional auto-join + LLM + TTS overrides. Text-only Discord configs leave voice off by default; set `channels.discord.voice.enabled=true` to opt in. diff --git a/docs/help/faq.md b/docs/help/faq.md index c9db380d743..52419b68fc7 100644 --- a/docs/help/faq.md +++ b/docs/help/faq.md @@ -215,7 +215,7 @@ lives on the [First-run FAQ](/help/faq-first-run). - Global defaults: `session.threadBindings.enabled`, `session.threadBindings.idleHours`, `session.threadBindings.maxAgeHours`. - Discord overrides: `channels.discord.threadBindings.enabled`, `channels.discord.threadBindings.idleHours`, `channels.discord.threadBindings.maxAgeHours`. - - Auto-bind on spawn: set `channels.discord.threadBindings.spawnSubagentSessions: true`. + - Auto-bind on spawn: `channels.discord.threadBindings.spawnSessions` defaults to `true`; set it to `false` to disable thread-bound session spawns. Docs: [Sub-agents](/tools/subagents), [Discord](/channels/discord), [Configuration Reference](/gateway/configuration-reference), [Slash commands](/tools/slash-commands). diff --git a/docs/tools/acp-agents-setup.md b/docs/tools/acp-agents-setup.md index 44787262c0f..317d20b0e48 100644 --- a/docs/tools/acp-agents-setup.md +++ b/docs/tools/acp-agents-setup.md @@ -109,7 +109,7 @@ Thread binding config is channel-adapter specific. Example for Discord: discord: { threadBindings: { enabled: true, - spawnAcpSessions: true, + spawnSessions: true, }, }, }, @@ -118,7 +118,7 @@ Thread binding config is channel-adapter specific. Example for Discord: If thread-bound ACP spawn does not work, verify the adapter feature flag first: -- Discord: `channels.discord.threadBindings.spawnAcpSessions=true` +- Discord: `channels.discord.threadBindings.spawnSessions=true` Current-conversation binds do not require child-thread creation. They require an active conversation context and a channel adapter that exposes ACP conversation bindings. diff --git a/docs/tools/acp-agents.md b/docs/tools/acp-agents.md index c1ec71ea779..2076dcc098d 100644 --- a/docs/tools/acp-agents.md +++ b/docs/tools/acp-agents.md @@ -279,7 +279,7 @@ Examples: - `--bind here` and `--thread ...` are mutually exclusive. - `--bind here` only works on channels that advertise current-conversation binding; OpenClaw returns a clear unsupported message otherwise. Bindings persist across gateway restarts. - - On Discord, `spawnAcpSessions` is only required when OpenClaw needs to create a child thread for `--thread auto|here` — not for `--bind here`. + - On Discord, `spawnSessions` gates child thread creation for `--thread auto|here` — not `--bind here`. - If you spawn to a different ACP agent without `--cwd`, OpenClaw inherits the **target agent's** workspace by default. Missing inherited paths (`ENOENT`/`ENOTDIR`) fall back to the backend default; other access errors (e.g. `EACCES`) surface as spawn errors. - Gateway management commands stay local in bound conversations — `/acp ...` commands are handled by OpenClaw even when normal follow-up text routes to the bound ACP session; `/status` and `/unfocus` also stay local whenever command handling is enabled for that surface. @@ -297,9 +297,9 @@ Examples: - `acp.enabled=true` - `acp.dispatch.enabled` is on by default (set `false` to pause automatic ACP thread dispatch; explicit `sessions_spawn({ runtime: "acp" })` calls still work). - - Channel-adapter ACP thread-spawn flag enabled (adapter-specific): - - Discord: `channels.discord.threadBindings.spawnAcpSessions=true` - - Telegram: `channels.telegram.threadBindings.spawnAcpSessions=true` + - Channel-adapter thread session spawns enabled (default: `true`): + - Discord: `channels.discord.threadBindings.spawnSessions=true` + - Telegram: `channels.telegram.threadBindings.spawnSessions=true` Thread binding support is adapter-specific. If the active channel adapter does not support thread bindings, OpenClaw returns a clear @@ -592,8 +592,8 @@ Two ways to start an ACP session: - On non-thread binding surfaces, default behavior is effectively `off`. - Thread-bound spawn requires channel policy support: - - Discord: `channels.discord.threadBindings.spawnAcpSessions=true` - - Telegram: `channels.telegram.threadBindings.spawnAcpSessions=true` + - Discord: `channels.discord.threadBindings.spawnSessions=true` + - Telegram: `channels.telegram.threadBindings.spawnSessions=true` - Use `--bind here` when you want to pin the current conversation without creating a child thread. diff --git a/docs/tools/subagents.md b/docs/tools/subagents.md index 6c1b4544140..d48497b9f29 100644 --- a/docs/tools/subagents.md +++ b/docs/tools/subagents.md @@ -26,8 +26,10 @@ Primary goals: default. For heavy or repetitive tasks, set a cheaper model for sub-agents and keep your main agent on a higher-quality model. Configure via `agents.defaults.subagents.model` or per-agent overrides. When a child -genuinely needs the requester's current transcript, the agent can request -`context: "fork"` on that one spawn. + genuinely needs the requester's current transcript, the agent can request + `context: "fork"` on that one spawn. Thread-bound subagent sessions default + to `context: "fork"` because they branch the current conversation into a + follow-up thread. ## Slash command @@ -179,7 +181,7 @@ session to confirm the effective tool list. `require` rejects spawn unless the target child runtime is sandboxed. - `fork` branches the requester's current transcript into the child session. Native sub-agents only. Use `fork` only when the child needs the current transcript. + `fork` branches the requester's current transcript into the child session. Native sub-agents only. Thread-bound spawns default to `fork`; non-thread spawns default to `isolated`. @@ -203,7 +205,7 @@ persistent thread-bound subagent sessions (`sessions_spawn` with `channels.discord.threadBindings.enabled`, `channels.discord.threadBindings.idleHours`, `channels.discord.threadBindings.maxAgeHours`, and -`channels.discord.threadBindings.spawnSubagentSessions`. +`channels.discord.threadBindings.spawnSessions`. ### Quick flow diff --git a/extensions/discord/src/config-ui-hints.ts b/extensions/discord/src/config-ui-hints.ts index baaa5873646..0c0377a096f 100644 --- a/extensions/discord/src/config-ui-hints.ts +++ b/extensions/discord/src/config-ui-hints.ts @@ -113,13 +113,13 @@ export const discordChannelConfigUiHints = { label: "Discord Thread Binding Max Age (hours)", help: "Optional hard max age in hours for Discord thread-bound sessions. Set 0 to disable hard cap (default: 0). Overrides session.threadBindings.maxAgeHours when set.", }, - "threadBindings.spawnSubagentSessions": { - label: "Discord Thread-Bound Subagent Spawn", - help: "Allow subagent spawns with thread=true to auto-create and bind Discord threads (default: false; opt-in). Set true to enable thread-bound subagent spawns for this account/channel.", + "threadBindings.spawnSessions": { + label: "Discord Thread-Bound Session Spawn", + help: "Allow sessions_spawn(thread=true) and ACP thread spawns to auto-create and bind Discord threads (default: true). Set false to disable for this account/channel.", }, - "threadBindings.spawnAcpSessions": { - label: "Discord Thread-Bound ACP Spawn", - help: "Allow /acp spawn to auto-create and bind Discord threads for ACP sessions (default: false; opt-in). Set true to enable thread-bound ACP spawns for this account/channel.", + "threadBindings.defaultSpawnContext": { + label: "Discord Thread Spawn Context", + help: 'Default native subagent context for thread-bound spawns. "fork" starts from the requester transcript; "isolated" starts clean. Default: "fork".', }, "ui.components.accentColor": { label: "Discord Component Accent Color", diff --git a/extensions/discord/src/subagent-hooks.test.ts b/extensions/discord/src/subagent-hooks.test.ts index 952972d5bd4..2534f49a6a4 100644 --- a/extensions/discord/src/subagent-hooks.test.ts +++ b/extensions/discord/src/subagent-hooks.test.ts @@ -15,7 +15,7 @@ type MockResolvedDiscordAccount = { config: { threadBindings?: { enabled?: boolean; - spawnSubagentSessions?: boolean; + spawnSessions?: boolean; }; }; }; @@ -26,7 +26,7 @@ const hookMocks = vi.hoisted(() => ({ accountId: params?.accountId?.trim() || "default", config: { threadBindings: { - spawnSubagentSessions: true, + spawnSessions: true, }, }, }), @@ -54,7 +54,7 @@ function registerHandlersForTest( channels: { discord: { threadBindings: { - spawnSubagentSessions: true, + spawnSessions: true, }, }, }, @@ -176,7 +176,7 @@ describe("discord subagent hook handlers", () => { accountId: params?.accountId?.trim() || "default", config: { threadBindings: { - spawnSubagentSessions: true, + spawnSessions: true, }, }, })); @@ -197,7 +197,7 @@ describe("discord subagent hook handlers", () => { channels: expect.objectContaining({ discord: expect.objectContaining({ threadBindings: expect.objectContaining({ - spawnSubagentSessions: true, + spawnSessions: true, }), }), }), @@ -220,12 +220,12 @@ describe("discord subagent hook handlers", () => { channels: { discord: { threadBindings: { - spawnSubagentSessions: false, + spawnSessions: false, }, }, }, }, - errorContains: "spawnSubagentSessions=true", + errorContains: "spawnSessions=true", }); }); @@ -240,7 +240,7 @@ describe("discord subagent hook handlers", () => { channels: { discord: { threadBindings: { - spawnSubagentSessions: true, + spawnSessions: true, }, }, }, @@ -262,7 +262,7 @@ describe("discord subagent hook handlers", () => { work: { threadBindings: { enabled: true, - spawnSubagentSessions: true, + spawnSessions: true, }, }, }, @@ -274,16 +274,17 @@ describe("discord subagent hook handlers", () => { expect(result).toMatchObject({ status: "ok", threadBindingReady: true }); }); - it("defaults thread-bound subagent spawn to disabled when unset", async () => { - await expectSubagentSpawningError({ - config: { - channels: { - discord: { - threadBindings: {}, - }, + it("defaults thread-bound subagent spawn to enabled when unset", async () => { + const result = await runSubagentSpawning({ + channels: { + discord: { + threadBindings: {}, }, }, }); + + expect(hookMocks.autoBindSpawnedDiscordSubagent).toHaveBeenCalledTimes(1); + expect(result).toMatchObject({ status: "ok", threadBindingReady: true }); }); it("no-ops when thread binding is requested on non-discord channel", async () => { diff --git a/extensions/discord/src/subagent-hooks.ts b/extensions/discord/src/subagent-hooks.ts index d531939ad78..040bdfacc5a 100644 --- a/extensions/discord/src/subagent-hooks.ts +++ b/extensions/discord/src/subagent-hooks.ts @@ -1,9 +1,13 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk/channel-plugin-common"; +import { + formatThreadBindingDisabledError, + formatThreadBindingSpawnDisabledError, + resolveThreadBindingSpawnPolicy, +} from "openclaw/plugin-sdk/conversation-runtime"; import { normalizeOptionalLowercaseString, normalizeOptionalStringifiedId, } from "openclaw/plugin-sdk/text-runtime"; -import { resolveDiscordAccount } from "./accounts.js"; import { autoBindSpawnedDiscordSubagent, listThreadBindingsBySessionKey, @@ -76,27 +80,6 @@ function normalizeThreadBindingTargetKind(raw?: string): ThreadBindingTargetKind return undefined; } -function resolveThreadBindingFlags(api: OpenClawPluginApi, accountId?: string) { - const account = resolveDiscordAccount({ - cfg: api.config, - accountId, - }); - const baseThreadBindings = api.config.channels?.discord?.threadBindings; - const accountThreadBindings = - api.config.channels?.discord?.accounts?.[account.accountId]?.threadBindings; - return { - enabled: - accountThreadBindings?.enabled ?? - baseThreadBindings?.enabled ?? - api.config.session?.threadBindings?.enabled ?? - true, - spawnSubagentSessions: - accountThreadBindings?.spawnSubagentSessions ?? - baseThreadBindings?.spawnSubagentSessions ?? - false, - }; -} - export async function handleDiscordSubagentSpawning( api: OpenClawPluginApi, event: DiscordSubagentSpawningEvent, @@ -108,19 +91,30 @@ export async function handleDiscordSubagentSpawning( if (channel !== "discord") { return undefined; } - const threadBindingFlags = resolveThreadBindingFlags(api, event.requester?.accountId); - if (!threadBindingFlags.enabled) { + const threadBindingPolicy = resolveThreadBindingSpawnPolicy({ + cfg: api.config, + channel: "discord", + accountId: event.requester?.accountId, + kind: "subagent", + }); + if (!threadBindingPolicy.enabled) { return { status: "error" as const, - error: - "Discord thread bindings are disabled (set channels.discord.threadBindings.enabled=true to override for this account, or session.threadBindings.enabled=true globally).", + error: formatThreadBindingDisabledError({ + channel: threadBindingPolicy.channel, + accountId: threadBindingPolicy.accountId, + kind: "subagent", + }), }; } - if (!threadBindingFlags.spawnSubagentSessions) { + if (!threadBindingPolicy.spawnEnabled) { return { status: "error" as const, - error: - "Discord thread-bound subagent spawns are disabled for this account (set channels.discord.threadBindings.spawnSubagentSessions=true to enable).", + error: formatThreadBindingSpawnDisabledError({ + channel: threadBindingPolicy.channel, + accountId: threadBindingPolicy.accountId, + kind: "subagent", + }), }; } try { diff --git a/extensions/line/src/config-schema.ts b/extensions/line/src/config-schema.ts index 29c77d1ac6e..372fd8cd966 100644 --- a/extensions/line/src/config-schema.ts +++ b/extensions/line/src/config-schema.ts @@ -8,6 +8,8 @@ const ThreadBindingsSchema = z enabled: z.boolean().optional(), idleHours: z.number().optional(), maxAgeHours: z.number().optional(), + spawnSessions: z.boolean().optional(), + defaultSpawnContext: z.enum(["isolated", "fork"]).optional(), spawnSubagentSessions: z.boolean().optional(), spawnAcpSessions: z.boolean().optional(), }) diff --git a/extensions/line/src/types.ts b/extensions/line/src/types.ts index 399359c3f21..635d70cec22 100644 --- a/extensions/line/src/types.ts +++ b/extensions/line/src/types.ts @@ -6,7 +6,11 @@ interface LineThreadBindingsConfig { enabled?: boolean; idleHours?: number; maxAgeHours?: number; + spawnSessions?: boolean; + defaultSpawnContext?: "isolated" | "fork"; + /** @deprecated Use spawnSessions instead. */ spawnSubagentSessions?: boolean; + /** @deprecated Use spawnSessions instead. */ spawnAcpSessions?: boolean; } diff --git a/extensions/matrix/src/config-schema.ts b/extensions/matrix/src/config-schema.ts index dfd7ebc1f94..54db054c648 100644 --- a/extensions/matrix/src/config-schema.ts +++ b/extensions/matrix/src/config-schema.ts @@ -26,6 +26,8 @@ const matrixThreadBindingsSchema = z enabled: z.boolean().optional(), idleHours: z.number().nonnegative().optional(), maxAgeHours: z.number().nonnegative().optional(), + spawnSessions: z.boolean().optional(), + defaultSpawnContext: z.enum(["isolated", "fork"]).optional(), spawnSubagentSessions: z.boolean().optional(), spawnAcpSessions: z.boolean().optional(), }) diff --git a/extensions/matrix/src/matrix/subagent-hooks.test.ts b/extensions/matrix/src/matrix/subagent-hooks.test.ts index f1cce2140dd..167921830c1 100644 --- a/extensions/matrix/src/matrix/subagent-hooks.test.ts +++ b/extensions/matrix/src/matrix/subagent-hooks.test.ts @@ -90,9 +90,8 @@ describe("handleMatrixSubagentSpawning", () => { getManagerMock.mockReset(); resolveMatrixBaseConfigMock.mockReset(); findMatrixAccountConfigMock.mockReset(); - // Default: bindings enabled, spawn enabled resolveMatrixBaseConfigMock.mockReturnValue({ - threadBindings: { enabled: true, spawnSubagentSessions: true }, + threadBindings: { enabled: true, spawnSessions: true }, }); findMatrixAccountConfigMock.mockReturnValue(undefined); getCapabilitiesMock.mockReturnValue({ @@ -140,40 +139,46 @@ describe("handleMatrixSubagentSpawning", () => { }); it("returns error when thread bindings are disabled", async () => { - resolveMatrixBaseConfigMock.mockReturnValue({ - threadBindings: { enabled: false, spawnSubagentSessions: true }, - }); - const result = await handleMatrixSubagentSpawning(fakeApi, makeSpawnEvent()); + const result = await handleMatrixSubagentSpawning( + { + config: { + channels: { + matrix: { + threadBindings: { enabled: false, spawnSessions: true }, + }, + }, + }, + } as never, + makeSpawnEvent(), + ); + expect(result).toEqual(expect.objectContaining({ status: "error" })); + expect((result as { error?: string }).error).toMatch(/thread bindings are disabled/i); + }); + + it("returns error when spawnSessions is false", async () => { + const result = await handleMatrixSubagentSpawning( + { + config: { + channels: { + matrix: { + threadBindings: { enabled: true, spawnSessions: false }, + }, + }, + }, + } as never, + makeSpawnEvent(), + ); expect(result).toEqual( expect.objectContaining({ status: "error", - error: expect.stringContaining("thread bindings are disabled"), + error: expect.stringContaining("spawnSessions"), }), ); }); - it("returns error when spawnSubagentSessions is false", async () => { - resolveMatrixBaseConfigMock.mockReturnValue({ - threadBindings: { enabled: true, spawnSubagentSessions: false }, - }); + it("allows thread-bound subagent spawn by default", async () => { const result = await handleMatrixSubagentSpawning(fakeApi, makeSpawnEvent()); - expect(result).toEqual( - expect.objectContaining({ - status: "error", - error: expect.stringContaining("spawnSubagentSessions"), - }), - ); - }); - - it("returns error when spawnSubagentSessions defaults to false (no config)", async () => { - resolveMatrixBaseConfigMock.mockReturnValue({}); - const result = await handleMatrixSubagentSpawning(fakeApi, makeSpawnEvent()); - expect(result).toEqual( - expect.objectContaining({ - status: "error", - error: expect.stringContaining("spawnSubagentSessions"), - }), - ); + expect(result).toMatchObject({ status: "ok", threadBindingReady: true }); }); it("returns error when requester.to has no room target", async () => { @@ -295,17 +300,23 @@ describe("handleMatrixSubagentSpawning", () => { }); it("respects per-account threadBindings override over base config", async () => { - // Base says spawnSubagentSessions=false; account override says true - resolveMatrixBaseConfigMock.mockReturnValue({ - threadBindings: { enabled: true, spawnSubagentSessions: false }, - }); - findMatrixAccountConfigMock.mockReturnValue({ - threadBindings: { spawnSubagentSessions: true }, - }); bindMock.mockResolvedValue({ conversation: {} }); const result = await handleMatrixSubagentSpawning( - fakeApi, + { + config: { + channels: { + matrix: { + threadBindings: { enabled: true, spawnSessions: false }, + accounts: { + forge: { + threadBindings: { spawnSessions: true }, + }, + }, + }, + }, + }, + } as never, makeSpawnEvent({ accountId: "forge" }), ); expect(result).toMatchObject({ status: "ok", threadBindingReady: true }); @@ -322,7 +333,7 @@ describe("matrix subagent hook registration", () => { listBindingsForAccountMock.mockReset(); listAllBindingsMock.mockReset(); resolveMatrixBaseConfigMock.mockReturnValue({ - threadBindings: { enabled: true, spawnSubagentSessions: true }, + threadBindings: { enabled: true, spawnSessions: true }, }); findMatrixAccountConfigMock.mockReturnValue(undefined); getCapabilitiesMock.mockReturnValue({ @@ -784,7 +795,7 @@ describe("concurrent spawns across accounts", () => { resolveMatrixBaseConfigMock.mockReset(); findMatrixAccountConfigMock.mockReset(); resolveMatrixBaseConfigMock.mockReturnValue({ - threadBindings: { enabled: true, spawnSubagentSessions: true }, + threadBindings: { enabled: true, spawnSessions: true }, }); findMatrixAccountConfigMock.mockReturnValue(undefined); getCapabilitiesMock.mockReturnValue({ diff --git a/extensions/matrix/src/matrix/subagent-hooks.ts b/extensions/matrix/src/matrix/subagent-hooks.ts index 22eecc9cc68..c318793c5e0 100644 --- a/extensions/matrix/src/matrix/subagent-hooks.ts +++ b/extensions/matrix/src/matrix/subagent-hooks.ts @@ -3,9 +3,13 @@ import { getSessionBindingService, type SessionBindingRecord, } from "openclaw/plugin-sdk/conversation-binding-runtime"; +import { + formatThreadBindingDisabledError, + formatThreadBindingSpawnDisabledError, + resolveThreadBindingSpawnPolicy, +} from "openclaw/plugin-sdk/conversation-runtime"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core"; import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; -import { findMatrixAccountConfig, resolveMatrixBaseConfig } from "./account-config.js"; import { resolveMatrixTargetIdentity } from "./target-ids.js"; import { getMatrixThreadBindingManager, @@ -76,28 +80,6 @@ function summarizeError(err: unknown): string { return "error"; } -function resolveThreadBindingFlags( - api: OpenClawPluginApi, - accountId?: string, -): { enabled: boolean; spawnSubagentSessions: boolean } { - const matrix = resolveMatrixBaseConfig(api.config); - const baseThreadBindings = matrix.threadBindings; - const accountThreadBindings = accountId - ? findMatrixAccountConfig(api.config, accountId)?.threadBindings - : undefined; - return { - enabled: - accountThreadBindings?.enabled ?? - baseThreadBindings?.enabled ?? - api.config.session?.threadBindings?.enabled ?? - true, - spawnSubagentSessions: - accountThreadBindings?.spawnSubagentSessions ?? - baseThreadBindings?.spawnSubagentSessions ?? - false, - }; -} - function resolveMatrixBindingThreadId(binding: SessionBindingRecord): string | undefined { const { conversationId, parentConversationId } = binding.conversation; return parentConversationId && parentConversationId !== conversationId @@ -136,20 +118,31 @@ export async function handleMatrixSubagentSpawning( // Falls back to DEFAULT_ACCOUNT_ID so accounts.default.threadBindings.* is // respected even when the requester omits accountId. const accountId = normalizeOptionalString(event.requester?.accountId) || DEFAULT_ACCOUNT_ID; - const flags = resolveThreadBindingFlags(api, accountId); + const policy = resolveThreadBindingSpawnPolicy({ + cfg: api.config, + channel: "matrix", + accountId, + kind: "subagent", + }); - if (!flags.enabled) { + if (!policy.enabled) { return { status: "error", - error: - "Matrix thread bindings are disabled (set channels.matrix.threadBindings.enabled=true to override for this account, or session.threadBindings.enabled=true globally).", + error: formatThreadBindingDisabledError({ + channel: policy.channel, + accountId: policy.accountId, + kind: "subagent", + }), } satisfies SpawningResult; } - if (!flags.spawnSubagentSessions) { + if (!policy.spawnEnabled) { return { status: "error", - error: - "Matrix thread-bound subagent spawns are disabled for this account (set channels.matrix.threadBindings.spawnSubagentSessions=true to enable).", + error: formatThreadBindingSpawnDisabledError({ + channel: policy.channel, + accountId: policy.accountId, + kind: "subagent", + }), }; } diff --git a/extensions/matrix/src/types.ts b/extensions/matrix/src/types.ts index a6674adba0c..8fd263ddf9b 100644 --- a/extensions/matrix/src/types.ts +++ b/extensions/matrix/src/types.ts @@ -63,7 +63,11 @@ type MatrixThreadBindingsConfig = { enabled?: boolean; idleHours?: number; maxAgeHours?: number; + spawnSessions?: boolean; + defaultSpawnContext?: "isolated" | "fork"; + /** @deprecated Use spawnSessions instead. */ spawnSubagentSessions?: boolean; + /** @deprecated Use spawnSessions instead. */ spawnAcpSessions?: boolean; }; diff --git a/extensions/qa-matrix/src/runners/contract/scenario-catalog.ts b/extensions/qa-matrix/src/runners/contract/scenario-catalog.ts index fc2aa36b51a..7188579c877 100644 --- a/extensions/qa-matrix/src/runners/contract/scenario-catalog.ts +++ b/extensions/qa-matrix/src/runners/contract/scenario-catalog.ts @@ -372,7 +372,7 @@ export const MATRIX_QA_SCENARIOS: MatrixQaScenarioDefinition[] = [ }, threadBindings: { enabled: true, - spawnSubagentSessions: true, + spawnSessions: true, }, toolProfile: "coding", }, diff --git a/extensions/qa-matrix/src/substrate/config.test.ts b/extensions/qa-matrix/src/substrate/config.test.ts index af3c2401b46..58abc31452f 100644 --- a/extensions/qa-matrix/src/substrate/config.test.ts +++ b/extensions/qa-matrix/src/substrate/config.test.ts @@ -122,7 +122,7 @@ describe("matrix qa config", () => { threadBindings: { enabled: true, idleHours: 1, - spawnSubagentSessions: true, + spawnSessions: true, }, threadReplies: "always", toolProfile: "coding", @@ -182,7 +182,7 @@ describe("matrix qa config", () => { threadBindings: { enabled: true, idleHours: 1, - spawnSubagentSessions: true, + spawnSessions: true, }, threadReplies: "always", }); diff --git a/extensions/qa-matrix/src/substrate/config.ts b/extensions/qa-matrix/src/substrate/config.ts index ce02b969e70..de9c1548837 100644 --- a/extensions/qa-matrix/src/substrate/config.ts +++ b/extensions/qa-matrix/src/substrate/config.ts @@ -57,7 +57,11 @@ type MatrixQaThreadBindingsConfigOverrides = { enabled?: boolean; idleHours?: number; maxAgeHours?: number; + spawnSessions?: boolean; + defaultSpawnContext?: "isolated" | "fork"; + /** @deprecated Use spawnSessions instead. */ spawnAcpSessions?: boolean; + /** @deprecated Use spawnSessions instead. */ spawnSubagentSessions?: boolean; }; @@ -544,7 +548,7 @@ export function summarizeMatrixQaConfigSnapshot(snapshot: MatrixQaConfigSnapshot `encryption=${formatMatrixQaBoolean(snapshot.encryption)}`, `startupVerification=${snapshot.startupVerification ?? ""}`, `threadBindings.enabled=${snapshot.threadBindings.enabled ?? ""}`, - `threadBindings.spawnSubagentSessions=${snapshot.threadBindings.spawnSubagentSessions ?? ""}`, + `threadBindings.spawnSessions=${snapshot.threadBindings.spawnSessions ?? ""}`, `approvals.exec.enabled=${formatMatrixQaBoolean(snapshot.approvalForwarding.exec)}`, `approvals.plugin.enabled=${formatMatrixQaBoolean(snapshot.approvalForwarding.plugin)}`, ].join(", "); diff --git a/extensions/telegram/src/config-ui-hints.ts b/extensions/telegram/src/config-ui-hints.ts index 90660c6afbb..b92ff68fb43 100644 --- a/extensions/telegram/src/config-ui-hints.ts +++ b/extensions/telegram/src/config-ui-hints.ts @@ -161,12 +161,12 @@ export const telegramChannelConfigUiHints = { label: "Telegram Thread Binding Max Age (hours)", help: "Optional hard max age in hours for Telegram bound sessions. Set 0 to disable hard cap (default: 0). Overrides session.threadBindings.maxAgeHours when set.", }, - "threadBindings.spawnSubagentSessions": { - label: "Telegram Thread-Bound Subagent Spawn", - help: "Allow subagent spawns with thread=true to auto-bind Telegram current conversations when supported.", + "threadBindings.spawnSessions": { + label: "Telegram Thread-Bound Session Spawn", + help: "Allow sessions_spawn(thread=true) and ACP thread spawns to auto-bind Telegram current conversations when supported.", }, - "threadBindings.spawnAcpSessions": { - label: "Telegram Thread-Bound ACP Spawn", - help: "Allow ACP spawns with thread=true to auto-bind Telegram current conversations when supported.", + "threadBindings.defaultSpawnContext": { + label: "Telegram Thread Spawn Context", + help: 'Default native subagent context for thread-bound spawns. "fork" starts from the requester transcript; "isolated" starts clean. Default: "fork".', }, } satisfies Record; diff --git a/src/agents/acp-spawn.test.ts b/src/agents/acp-spawn.test.ts index 4053115dca6..0c5c6d7bcb9 100644 --- a/src/agents/acp-spawn.test.ts +++ b/src/agents/acp-spawn.test.ts @@ -36,7 +36,7 @@ function createDefaultSpawnConfig(): OpenClawConfig { discord: { threadBindings: { enabled: true, - spawnAcpSessions: true, + spawnSessions: true, }, }, }, @@ -358,7 +358,7 @@ function enableMatrixAcpThreadBindings(): void { matrix: { threadBindings: { enabled: true, - spawnAcpSessions: true, + spawnSessions: true, }, }, }, @@ -418,7 +418,7 @@ function enableLineCurrentConversationBindings(): void { line: { threadBindings: { enabled: true, - spawnAcpSessions: true, + spawnSessions: true, }, }, }, @@ -1566,13 +1566,13 @@ describe("spawnAcpDirect", () => { defaultAccount: "work", threadBindings: { enabled: true, - spawnAcpSessions: true, + spawnSessions: true, }, accounts: { work: { threadBindings: { enabled: true, - spawnAcpSessions: true, + spawnSessions: true, }, }, }, @@ -1661,13 +1661,13 @@ describe("spawnAcpDirect", () => { matrix: { threadBindings: { enabled: true, - spawnAcpSessions: true, + spawnSessions: true, }, accounts: { "bot-alpha": { threadBindings: { enabled: true, - spawnAcpSessions: true, + spawnSessions: true, }, }, }, @@ -2028,7 +2028,7 @@ describe("spawnAcpDirect", () => { discord: { threadBindings: { enabled: true, - spawnAcpSessions: false, + spawnSessions: false, }, }, }, @@ -2048,7 +2048,7 @@ describe("spawnAcpDirect", () => { }, ); - expect(expectFailedSpawn(result, "error").error).toContain("spawnAcpSessions=true"); + expect(expectFailedSpawn(result, "error").error).toContain("spawnSessions=true"); }); it("forbids ACP spawn from sandboxed requester sessions", async () => { diff --git a/src/agents/runtime-capabilities.test.ts b/src/agents/runtime-capabilities.test.ts new file mode 100644 index 00000000000..3cce5d2afa8 --- /dev/null +++ b/src/agents/runtime-capabilities.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import { collectRuntimeChannelCapabilities } from "./runtime-capabilities.js"; + +describe("collectRuntimeChannelCapabilities", () => { + it("adds thread-bound spawn capabilities when the channel account allows unified spawns", () => { + const capabilities = collectRuntimeChannelCapabilities({ + channel: "discord", + accountId: "default", + cfg: { + channels: { + discord: { + threadBindings: { + spawnSessions: true, + }, + }, + }, + }, + }); + + expect(capabilities).toContain("threadbound-subagent-spawn"); + expect(capabilities).toContain("threadbound-acp-spawn"); + }); + + it("omits thread-bound spawn capabilities when unified spawns are disabled", () => { + const capabilities = collectRuntimeChannelCapabilities({ + channel: "discord", + accountId: "default", + cfg: { + channels: { + discord: { + threadBindings: { + spawnSessions: false, + }, + }, + }, + }, + }); + + expect(capabilities ?? []).not.toContain("threadbound-subagent-spawn"); + expect(capabilities ?? []).not.toContain("threadbound-acp-spawn"); + }); +}); diff --git a/src/agents/runtime-capabilities.ts b/src/agents/runtime-capabilities.ts index c05efc30e5b..fd58b282ce0 100644 --- a/src/agents/runtime-capabilities.ts +++ b/src/agents/runtime-capabilities.ts @@ -1,8 +1,15 @@ +import { + resolveThreadBindingSpawnPolicy, + supportsAutomaticThreadBindingSpawn, +} from "../channels/thread-bindings-policy.js"; import { resolveChannelCapabilities } from "../config/channel-capabilities.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js"; import { resolveChannelPromptCapabilities } from "./channel-tools.js"; +const THREAD_BOUND_SUBAGENT_SPAWN_CAPABILITY = "threadbound-subagent-spawn"; +const THREAD_BOUND_ACP_SPAWN_CAPABILITY = "threadbound-acp-spawn"; + function mergeRuntimeCapabilities( base?: readonly string[] | null, additions: readonly string[] = [], @@ -32,8 +39,27 @@ export function collectRuntimeChannelCapabilities(params: { if (!params.channel) { return undefined; } + const threadSpawnCapabilities: string[] = []; + if (params.cfg && supportsAutomaticThreadBindingSpawn(params.channel)) { + for (const [kind, capability] of [ + ["subagent", THREAD_BOUND_SUBAGENT_SPAWN_CAPABILITY], + ["acp", THREAD_BOUND_ACP_SPAWN_CAPABILITY], + ] as const) { + const policy = resolveThreadBindingSpawnPolicy({ + cfg: params.cfg, + channel: params.channel, + accountId: params.accountId ?? undefined, + kind, + }); + if (policy.enabled && policy.spawnEnabled) { + threadSpawnCapabilities.push(capability); + } + } + } return mergeRuntimeCapabilities( resolveChannelCapabilities(params), - params.cfg ? resolveChannelPromptCapabilities(params) : [], + params.cfg + ? [...resolveChannelPromptCapabilities(params), ...threadSpawnCapabilities] + : threadSpawnCapabilities, ); } diff --git a/src/agents/subagent-spawn.context.test.ts b/src/agents/subagent-spawn.context.test.ts index 57b350639f5..ad15e52ff51 100644 --- a/src/agents/subagent-spawn.context.test.ts +++ b/src/agents/subagent-spawn.context.test.ts @@ -115,6 +115,42 @@ describe("sessions_spawn context modes", () => { ); }); + it("forks by default for thread-bound subagent sessions", async () => { + const store: SessionStore = { + main: { + sessionId: "parent-session-id", + sessionFile: "/tmp/parent-session.jsonl", + updatedAt: 1, + totalTokens: 1200, + }, + }; + usePersistentStoreMock(store); + forkSessionFromParentMock.mockImplementation(async () => ({ + sessionId: "forked-session-id", + sessionFile: "/tmp/forked-session.jsonl", + })); + const prepareSubagentSpawn = vi.fn(async () => undefined); + resolveContextEngineMock.mockResolvedValue({ prepareSubagentSpawn }); + + const result = await spawnSubagentDirect( + { task: "spin this into a thread", thread: true }, + { + agentSessionKey: "main", + agentChannel: "discord", + agentAccountId: "default", + agentTo: "channel:123", + }, + ); + + expect(result.status).toBe("error"); + expect(forkSessionFromParentMock).toHaveBeenCalledWith({ + parentEntry: store.main, + agentId: "main", + sessionsDir: path.dirname(storePath), + }); + expect(prepareSubagentSpawn).not.toHaveBeenCalled(); + }); + it("initializes built-in context engines before resolving spawn preparation", async () => { let initialized = false; ensureContextEnginesInitializedMock.mockImplementation(() => { diff --git a/src/agents/subagent-spawn.ts b/src/agents/subagent-spawn.ts index 10c467921d7..c5654676658 100644 --- a/src/agents/subagent-spawn.ts +++ b/src/agents/subagent-spawn.ts @@ -2,6 +2,7 @@ import crypto from "node:crypto"; import { promises as fs } from "node:fs"; import path from "node:path"; import { isAcpRuntimeSpawnAvailable } from "../acp/runtime/availability.js"; +import { resolveThreadBindingSpawnPolicy } from "../channels/thread-bindings-policy.js"; import type { SessionEntry } from "../config/sessions/types.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { SubagentSpawnPreparation } from "../context-engine/types.js"; @@ -525,6 +526,29 @@ function resolveSpawnMode(params: { return params.threadRequested ? "session" : "run"; } +function resolveSubagentContextMode(params: { + requestedContext?: SpawnSubagentContextMode; + threadRequested: boolean; + cfg: OpenClawConfig; + requester: { + channel?: string; + accountId?: string; + }; +}): SpawnSubagentContextMode { + if (params.requestedContext === "fork" || params.requestedContext === "isolated") { + return params.requestedContext; + } + if (!params.threadRequested || !params.requester.channel) { + return "isolated"; + } + return resolveThreadBindingSpawnPolicy({ + cfg: params.cfg, + channel: params.requester.channel, + accountId: params.requester.accountId, + kind: "subagent", + }).defaultSpawnContext; +} + function summarizeError(err: unknown): string { if (err instanceof Error) { return err.message; @@ -649,7 +673,6 @@ export async function spawnSubagentDirect( const thinkingOverrideRaw = params.thinking; const requestThreadBinding = params.thread === true; const sandboxMode = params.sandbox === "require" ? "require" : "inherit"; - const contextMode: SpawnSubagentContextMode = params.context === "fork" ? "fork" : "isolated"; const spawnMode = resolveSpawnMode({ requestedMode: params.mode, threadRequested: requestThreadBinding, @@ -682,6 +705,15 @@ export async function spawnSubagentDirect( let modelApplied = false; let threadBindingReady = false; let hasBoundThreadDeliveryOrigin = false; + const contextMode = resolveSubagentContextMode({ + requestedContext: params.context, + threadRequested: requestThreadBinding, + cfg, + requester: { + channel: ctx.agentChannel, + accountId: ctx.agentAccountId, + }, + }); const { mainKey, alias } = resolveMainSessionAlias(cfg); const requesterSessionKey = ctx.agentSessionKey; const requesterInternalKey = requesterSessionKey diff --git a/src/agents/system-prompt.test.ts b/src/agents/system-prompt.test.ts index e03a79624f9..9359811d0bb 100644 --- a/src/agents/system-prompt.test.ts +++ b/src/agents/system-prompt.test.ts @@ -362,6 +362,10 @@ describe("buildAgentSystemPrompt", () => { "Use ACP for Codex only when the user explicitly asks for ACP/acpx or wants to test the ACP path.", ], acpEnabled: true, + runtimeInfo: { + channel: "discord", + capabilities: ["threadbound-acp-spawn"], + }, }); expect(prompt).toContain("Native Codex app-server plugin is available"); @@ -381,6 +385,24 @@ describe("buildAgentSystemPrompt", () => { ); }); + it("omits ACP thread-spawn guidance when the runtime capability is absent", () => { + const prompt = buildAgentSystemPrompt({ + workspaceDir: "/tmp/openclaw", + toolNames: ["sessions_spawn", "exec"], + acpEnabled: true, + runtimeInfo: { + channel: "discord", + capabilities: [], + }, + }); + + expect(prompt).toContain( + 'For requests like "do this in claude code/cursor/gemini/opencode" or similar ACP harnesses, treat it as ACP harness intent', + ); + expect(prompt).not.toContain("default ACP harness requests to thread-bound"); + expect(prompt).not.toContain('use `sessions_spawn` (`runtime: "acp"`, `thread: true`)'); + }); + it("omits ACP harness guidance when ACP is disabled", () => { const prompt = buildAgentSystemPrompt({ workspaceDir: "/tmp/openclaw", diff --git a/src/agents/system-prompt.ts b/src/agents/system-prompt.ts index 1306649fa80..8fc3086c03f 100644 --- a/src/agents/system-prompt.ts +++ b/src/agents/system-prompt.ts @@ -665,6 +665,7 @@ export function buildAgentSystemPrompt(params: { runtimeCapabilities.map((cap) => normalizeLowercaseStringOrEmpty(cap)).filter(Boolean), ); const inlineButtonsEnabled = runtimeCapabilitiesLower.has("inlinebuttons"); + const threadBoundAcpSpawnEnabled = runtimeCapabilitiesLower.has("threadbound-acp-spawn"); const messageChannelOptions = listDeliverableMessageChannels().join("|"); const promptMode = params.promptMode ?? "full"; const isMinimal = promptMode === "minimal" || promptMode === "none"; @@ -754,9 +755,17 @@ export function buildAgentSystemPrompt(params: { ...(acpHarnessSpawnAllowed ? [ 'For requests like "do this in claude code/cursor/gemini/opencode" or similar ACP harnesses, treat it as ACP harness intent and call `sessions_spawn` with `runtime: "acp"`.', - 'On Discord, default ACP harness requests to thread-bound persistent sessions (`thread: true`, `mode: "session"`) unless the user asks otherwise.', + ...(runtimeChannel === "discord" && threadBoundAcpSpawnEnabled + ? [ + 'On Discord, default ACP harness requests to thread-bound persistent sessions (`thread: true`, `mode: "session"`) unless the user asks otherwise.', + ] + : []), "Set `agentId` explicitly unless `acp.defaultAgent` is configured, and do not route ACP harness requests through `subagents`/`agents_list` or local PTY exec flows.", - 'For ACP harness thread spawns, do not call `message` with `action=thread-create`; use `sessions_spawn` (`runtime: "acp"`, `thread: true`) as the single thread creation path.', + ...(threadBoundAcpSpawnEnabled + ? [ + 'For ACP harness thread spawns, do not call `message` with `action=thread-create`; use `sessions_spawn` (`runtime: "acp"`, `thread: true`) as the single thread creation path.', + ] + : []), ] : []), "Do not poll `subagents list` / `sessions_list` in a loop; only check status on-demand (for intervention, debugging, or when explicitly asked).", diff --git a/src/agents/tool-description-presets.ts b/src/agents/tool-description-presets.ts index a285dd17826..d2b1c012ded 100644 --- a/src/agents/tool-description-presets.ts +++ b/src/agents/tool-description-presets.ts @@ -33,10 +33,15 @@ export function describeSessionsSendTool(): string { ].join(" "); } -export function describeSessionsSpawnTool(options?: { acpAvailable?: boolean }): string { +export function describeSessionsSpawnTool(options?: { + acpAvailable?: boolean; + threadAvailable?: boolean; +}): string { const baseDescription = [ 'Spawn a clean isolated session by default with `runtime="subagent"` or `runtime="acp"`.', - '`mode="run"` is one-shot and `mode="session"` is persistent or thread-bound.', + options?.threadAvailable + ? '`mode="run"` is one-shot and `mode="session"` is persistent and thread-bound.' + : '`mode="run"` is one-shot background work.', "Subagents inherit the parent workspace directory automatically.", 'For native subagents only, set `context="fork"` when the child needs the current transcript context; otherwise omit it or use `context="isolated"`.', "Use this when the work should happen in a fresh child session instead of the current one.", diff --git a/src/agents/tools/sessions-spawn-tool.test.ts b/src/agents/tools/sessions-spawn-tool.test.ts index db695e92620..db3ecf3c46d 100644 --- a/src/agents/tools/sessions-spawn-tool.test.ts +++ b/src/agents/tools/sessions-spawn-tool.test.ts @@ -186,6 +186,58 @@ describe("sessions_spawn tool", () => { expect(schema.properties?.runtime?.enum).toEqual(["subagent", "acp"]); }); + it("hides thread-bound spawn fields when current channel disables spawnSessions", () => { + const tool = createSessionsSpawnTool({ + agentChannel: "discord", + agentAccountId: "default", + config: { + channels: { + discord: { + threadBindings: { + spawnSessions: false, + }, + }, + }, + }, + }); + const schema = tool.parameters as { + properties?: { + thread?: unknown; + mode?: { enum?: string[] }; + }; + }; + + expect(schema.properties?.thread).toBeUndefined(); + expect(schema.properties?.mode?.enum).toEqual(["run"]); + expect(tool.description).not.toContain("thread-bound"); + }); + + it("shows thread-bound spawn fields when current channel allows spawnSessions", () => { + const tool = createSessionsSpawnTool({ + agentChannel: "discord", + agentAccountId: "default", + config: { + channels: { + discord: { + threadBindings: { + spawnSessions: true, + }, + }, + }, + }, + }); + const schema = tool.parameters as { + properties?: { + thread?: unknown; + mode?: { enum?: string[] }; + }; + }; + + expect(schema.properties?.thread).toBeDefined(); + expect(schema.properties?.mode?.enum).toEqual(["run", "session"]); + expect(tool.description).toContain("thread-bound"); + }); + it("uses subagent runtime by default", async () => { const tool = createSessionsSpawnTool({ agentSessionKey: "agent:main:main", diff --git a/src/agents/tools/sessions-spawn-tool.ts b/src/agents/tools/sessions-spawn-tool.ts index 3d5b52bda90..95dcf1fc7cb 100644 --- a/src/agents/tools/sessions-spawn-tool.ts +++ b/src/agents/tools/sessions-spawn-tool.ts @@ -1,5 +1,9 @@ import { Type } from "typebox"; import { isAcpRuntimeSpawnAvailable } from "../../acp/runtime/availability.js"; +import { + resolveThreadBindingSpawnPolicy, + supportsAutomaticThreadBindingSpawn, +} from "../../channels/thread-bindings-policy.js"; import { getRuntimeConfig } from "../../config/config.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { callGateway } from "../../gateway/call.js"; @@ -100,7 +104,45 @@ async function cleanupUntrackedAcpSession(sessionKey: string): Promise { } } -function createSessionsSpawnToolSchema(params: { acpAvailable: boolean }) { +type SessionsSpawnThreadAvailability = { + subagent: boolean; + acp: boolean; +}; + +function hasAnyThreadAvailability(availability: SessionsSpawnThreadAvailability): boolean { + return availability.subagent || availability.acp; +} + +function resolveSessionsSpawnThreadAvailability(opts?: { + config?: OpenClawConfig; + agentChannel?: GatewayMessageChannel; + agentAccountId?: string; +}): SessionsSpawnThreadAvailability { + const channel = opts?.agentChannel; + const cfg = opts?.config; + if (!channel || !cfg || !supportsAutomaticThreadBindingSpawn(channel)) { + return { subagent: false, acp: false }; + } + const resolve = (kind: "subagent" | "acp") => { + const policy = resolveThreadBindingSpawnPolicy({ + cfg, + channel, + accountId: opts?.agentAccountId, + kind, + }); + return policy.enabled && policy.spawnEnabled; + }; + return { + subagent: resolve("subagent"), + acp: resolve("acp"), + }; +} + +function createSessionsSpawnToolSchema(params: { + acpAvailable: boolean; + threadAvailable: boolean; +}) { + const spawnModes = params.threadAvailable ? SUBAGENT_SPAWN_MODES : (["run"] as const); const schema = { task: Type.String(), label: Type.Optional(Type.String()), @@ -114,8 +156,17 @@ function createSessionsSpawnToolSchema(params: { acpAvailable: boolean }) { runTimeoutSeconds: Type.Optional(Type.Number({ minimum: 0 })), // Back-compat: older callers used timeoutSeconds for this tool. timeoutSeconds: Type.Optional(Type.Number({ minimum: 0 })), - thread: Type.Optional(Type.Boolean()), - mode: optionalStringEnum(SUBAGENT_SPAWN_MODES), + ...(params.threadAvailable + ? { + thread: Type.Optional( + Type.Boolean({ + description: + 'Bind the spawned session to a new chat thread when the current channel/account supports thread-bound session spawns. `thread=true` defaults mode to "session".', + }), + ), + } + : {}), + mode: optionalStringEnum(spawnModes), cleanup: optionalStringEnum(["delete", "keep"] as const), sandbox: optionalStringEnum(SESSIONS_SPAWN_SANDBOX_MODES), context: optionalStringEnum(SUBAGENT_SPAWN_CONTEXT_MODES, { @@ -194,14 +245,16 @@ export function createSessionsSpawnTool( config: opts?.config, sandboxed: opts?.sandboxed, }); + const threadAvailability = resolveSessionsSpawnThreadAvailability(opts); + const threadAvailable = hasAnyThreadAvailability(threadAvailability); return { label: "Sessions", name: "sessions_spawn", displaySummary: acpAvailable ? SESSIONS_SPAWN_TOOL_DISPLAY_SUMMARY : SESSIONS_SPAWN_SUBAGENT_TOOL_DISPLAY_SUMMARY, - description: describeSessionsSpawnTool({ acpAvailable }), - parameters: createSessionsSpawnToolSchema({ acpAvailable }), + description: describeSessionsSpawnTool({ acpAvailable, threadAvailable }), + parameters: createSessionsSpawnToolSchema({ acpAvailable, threadAvailable }), execute: async (_toolCallId, args) => { const params = args as Record; const unsupportedParam = UNSUPPORTED_SESSIONS_SPAWN_PARAM_KEYS.find((key) => diff --git a/src/auto-reply/reply/commands-acp.test.ts b/src/auto-reply/reply/commands-acp.test.ts index d342d6a6999..5b48ee78613 100644 --- a/src/auto-reply/reply/commands-acp.test.ts +++ b/src/auto-reply/reply/commands-acp.test.ts @@ -505,7 +505,7 @@ const baseCfg = { discord: { threadBindings: { enabled: true, - spawnAcpSessions: true, + spawnSessions: true, }, }, }, @@ -1178,7 +1178,7 @@ describe("/acp command", () => { discord: { threadBindings: { enabled: true, - spawnAcpSessions: false, + spawnSessions: false, }, }, }, @@ -1290,7 +1290,7 @@ describe("/acp command", () => { matrix: { threadBindings: { enabled: true, - spawnAcpSessions: false, + spawnSessions: false, }, }, }, @@ -1318,7 +1318,7 @@ describe("/acp command", () => { matrix: { threadBindings: { enabled: true, - spawnAcpSessions: true, + spawnSessions: true, }, }, }, @@ -1346,7 +1346,7 @@ describe("/acp command", () => { matrix: { threadBindings: { enabled: true, - spawnAcpSessions: true, + spawnSessions: true, }, }, }, @@ -1417,14 +1417,14 @@ describe("/acp command", () => { expect(hoisted.sessionBindingBindMock).not.toHaveBeenCalled(); }); - it("rejects thread-bound ACP spawn when spawnAcpSessions is disabled", async () => { + it("rejects thread-bound ACP spawn when spawnSessions is disabled", async () => { const cfg = { ...baseCfg, channels: { discord: { threadBindings: { enabled: true, - spawnAcpSessions: false, + spawnSessions: false, }, }, }, @@ -1432,7 +1432,7 @@ describe("/acp command", () => { const result = await runDiscordAcpCommand("/acp spawn codex", cfg); - expect(result?.reply?.text).toContain("spawnAcpSessions=true"); + expect(result?.reply?.text).toContain("spawnSessions=true"); expect(hoisted.closeMock).toHaveBeenCalledTimes(2); expect(hoisted.callGatewayMock).toHaveBeenCalledWith( expect.objectContaining({ method: "sessions.delete" }), @@ -1442,13 +1442,14 @@ describe("/acp command", () => { ); }); - it("rejects Matrix thread-bound ACP spawn when spawnAcpSessions is unset", async () => { + it("rejects Matrix thread-bound ACP spawn when spawnSessions is disabled", async () => { const cfg = { ...baseCfg, channels: { matrix: { threadBindings: { enabled: true, + spawnSessions: false, }, }, }, @@ -1456,7 +1457,7 @@ describe("/acp command", () => { const result = await runMatrixAcpCommand("/acp spawn codex", cfg); - expect(result?.reply?.text).toContain("spawnAcpSessions=true"); + expect(result?.reply?.text).toContain("spawnSessions=true"); expect(hoisted.sessionBindingBindMock).not.toHaveBeenCalled(); }); diff --git a/src/auto-reply/reply/commands-subagents-focus.test.ts b/src/auto-reply/reply/commands-subagents-focus.test.ts index 4ec4df6230a..b40afa7cc5c 100644 --- a/src/auto-reply/reply/commands-subagents-focus.test.ts +++ b/src/auto-reply/reply/commands-subagents-focus.test.ts @@ -77,7 +77,7 @@ vi.mock("../../channels/thread-bindings-policy.js", () => ({ formatThreadBindingDisabledError: (params: { channel: string }) => `channels.${params.channel}.threadBindings.enabled=true required`, formatThreadBindingSpawnDisabledError: (params: { channel: string }) => - `channels.${params.channel}.threadBindings.spawnSubagentSessions=true`, + `channels.${params.channel}.threadBindings.spawnSessions=true`, resolveThreadBindingIdleTimeoutMsForChannel: () => 24 * 60 * 60 * 1000, resolveThreadBindingMaxAgeMsForChannel: () => undefined, resolveThreadBindingPlacementForCurrentContext: (params: { @@ -92,9 +92,10 @@ vi.mock("../../channels/thread-bindings-policy.js", () => ({ const settings = params.cfg.channels?.[params.channel]?.threadBindings; return { enabled: settings?.enabled !== false, - spawnEnabled: settings?.spawnSubagentSessions === true, + spawnEnabled: settings?.spawnSessions !== false, channel: params.channel, accountId: params.accountId, + defaultSpawnContext: "fork", }; }, })); @@ -367,7 +368,7 @@ describe("focus actions", () => { [ROOM_CHANNEL]: { threadBindings: { enabled: true, - spawnSubagentSessions: true, + spawnSessions: true, }, }, } as OpenClawConfig["channels"], @@ -411,7 +412,7 @@ describe("focus actions", () => { ); }); - it("rejects room top-level thread creation when spawnSubagentSessions is disabled", async () => { + it("rejects room top-level thread creation when spawnSessions is disabled", async () => { hoisted.resolveConversationBindingContextMock.mockReturnValue({ channel: ROOM_CHANNEL, accountId: "default", @@ -426,6 +427,7 @@ describe("focus actions", () => { [ROOM_CHANNEL]: { threadBindings: { enabled: true, + spawnSessions: false, }, }, } as OpenClawConfig["channels"], @@ -434,7 +436,7 @@ describe("focus actions", () => { ); expect(result.reply?.text).toContain( - `channels.${ROOM_CHANNEL}.threadBindings.spawnSubagentSessions=true`, + `channels.${ROOM_CHANNEL}.threadBindings.spawnSessions=true`, ); expect(hoisted.sessionBindingBindMock).not.toHaveBeenCalled(); }); diff --git a/src/channels/thread-bindings-policy.test.ts b/src/channels/thread-bindings-policy.test.ts index c58a065e764..51eb0181d0d 100644 --- a/src/channels/thread-bindings-policy.test.ts +++ b/src/channels/thread-bindings-policy.test.ts @@ -4,6 +4,7 @@ import { createChannelTestPluginBase, createTestRegistry } from "../test-utils/c import { requiresNativeThreadContextForThreadHere, resolveThreadBindingPlacementForCurrentContext, + resolveThreadBindingSpawnPolicy, supportsAutomaticThreadBindingSpawn, } from "./thread-bindings-policy.js"; @@ -66,4 +67,74 @@ describe("thread binding spawn policy helpers", () => { }), ).toBe("current"); }); + + it("enables unified thread-bound session spawns by default", () => { + const policy = resolveThreadBindingSpawnPolicy({ + cfg: {}, + channel: "discord", + kind: "subagent", + }); + + expect(policy).toMatchObject({ + enabled: true, + spawnEnabled: true, + defaultSpawnContext: "fork", + }); + }); + + it("uses spawnSessions for both subagent and ACP spawn policy", () => { + const cfg = { + channels: { + discord: { + threadBindings: { + spawnSessions: false, + }, + }, + }, + }; + + expect( + resolveThreadBindingSpawnPolicy({ + cfg, + channel: "discord", + kind: "subagent", + }).spawnEnabled, + ).toBe(false); + expect( + resolveThreadBindingSpawnPolicy({ + cfg, + channel: "discord", + kind: "acp", + }).spawnEnabled, + ).toBe(false); + }); + + it("lets account config override channel spawnSessions and spawn context", () => { + const policy = resolveThreadBindingSpawnPolicy({ + cfg: { + channels: { + discord: { + threadBindings: { + spawnSessions: false, + defaultSpawnContext: "fork", + }, + accounts: { + work: { + threadBindings: { + spawnSessions: true, + defaultSpawnContext: "isolated", + }, + }, + }, + }, + }, + }, + channel: "discord", + accountId: "work", + kind: "subagent", + }); + + expect(policy.spawnEnabled).toBe(true); + expect(policy.defaultSpawnContext).toBe("isolated"); + }); }); diff --git a/src/channels/thread-bindings-policy.ts b/src/channels/thread-bindings-policy.ts index b3b76a45dc0..62d2e873fae 100644 --- a/src/channels/thread-bindings-policy.ts +++ b/src/channels/thread-bindings-policy.ts @@ -20,8 +20,10 @@ type SessionThreadBindingsConfigShape = { enabled?: unknown; idleHours?: unknown; maxAgeHours?: unknown; + spawnSessions?: unknown; spawnSubagentSessions?: unknown; spawnAcpSessions?: unknown; + defaultSpawnContext?: unknown; }; type ChannelThreadBindingsContainerShape = { @@ -36,8 +38,11 @@ export type ThreadBindingSpawnPolicy = { accountId: string; enabled: boolean; spawnEnabled: boolean; + defaultSpawnContext: ThreadBindingSpawnContext; }; +export type ThreadBindingSpawnContext = "isolated" | "fork"; + function normalizeChannelId(value: string | undefined | null): string { return normalizeLowercaseStringOrEmpty(value); } @@ -153,6 +158,10 @@ function resolveSpawnFlagKey( return kind === "subagent" ? "spawnSubagentSessions" : "spawnAcpSessions"; } +function normalizeSpawnContext(value: unknown): ThreadBindingSpawnContext | undefined { + return value === "isolated" || value === "fork" ? value : undefined; +} + export function resolveThreadBindingSpawnPolicy(params: { cfg: OpenClawConfig; channel: string; @@ -173,13 +182,23 @@ export function resolveThreadBindingSpawnPolicy(params: { true; const spawnFlagKey = resolveSpawnFlagKey(params.kind); const spawnEnabledRaw = - normalizeBoolean(account?.[spawnFlagKey]) ?? normalizeBoolean(root?.[spawnFlagKey]); - const spawnEnabled = spawnEnabledRaw ?? resolveDefaultTopLevelPlacement(channel) !== "child"; + normalizeBoolean(account?.[spawnFlagKey]) ?? + normalizeBoolean(account?.spawnSessions) ?? + normalizeBoolean(root?.[spawnFlagKey]) ?? + normalizeBoolean(root?.spawnSessions) ?? + normalizeBoolean(params.cfg.session?.threadBindings?.spawnSessions); + const spawnEnabled = spawnEnabledRaw ?? true; + const defaultSpawnContext = + normalizeSpawnContext(account?.defaultSpawnContext) ?? + normalizeSpawnContext(root?.defaultSpawnContext) ?? + normalizeSpawnContext(params.cfg.session?.threadBindings?.defaultSpawnContext) ?? + "fork"; return { channel, accountId, enabled, spawnEnabled, + defaultSpawnContext, }; } @@ -234,6 +253,5 @@ export function formatThreadBindingSpawnDisabledError(params: { accountId: string; kind: ThreadBindingSpawnKind; }): string { - const spawnFlagKey = resolveSpawnFlagKey(params.kind); - return `Thread-bound ${params.kind} spawns are disabled for ${params.channel} (set channels.${params.channel}.threadBindings.${spawnFlagKey}=true to enable).`; + return `Thread-bound session spawns are disabled for ${params.channel} (set channels.${params.channel}.threadBindings.spawnSessions=true to enable).`; } diff --git a/src/commands/doctor/shared/legacy-config-migrate.test.ts b/src/commands/doctor/shared/legacy-config-migrate.test.ts index bb91f66ee4e..0a92548891b 100644 --- a/src/commands/doctor/shared/legacy-config-migrate.test.ts +++ b/src/commands/doctor/shared/legacy-config-migrate.test.ts @@ -41,6 +41,56 @@ describe("legacy session maintenance migrate", () => { }); }); +describe("legacy thread binding spawn migrate", () => { + it("moves matching split spawn flags to unified spawnSessions", () => { + const res = migrateLegacyConfigForTest({ + channels: { + discord: { + threadBindings: { + enabled: true, + spawnSubagentSessions: true, + spawnAcpSessions: true, + }, + }, + }, + }); + + expect(res.config?.channels?.discord?.threadBindings).toEqual({ + enabled: true, + spawnSessions: true, + }); + expect(res.changes).toContain( + "Moved channels.discord.threadBindings.spawnSubagentSessions/spawnAcpSessions → channels.discord.threadBindings.spawnSessions (true).", + ); + }); + + it("collapses conflicting split spawn flags conservatively", () => { + const res = migrateLegacyConfigForTest({ + channels: { + discord: { + accounts: { + work: { + threadBindings: { + spawnSubagentSessions: true, + spawnAcpSessions: false, + }, + }, + }, + }, + }, + }); + + expect( + res.config?.channels?.discord?.accounts?.work?.threadBindings as Record, + ).toEqual({ + spawnSessions: false, + }); + expect(res.changes).toContain( + "Collapsed conflicting channels.discord.accounts.work.threadBindings.spawnSubagentSessions/spawnAcpSessions → channels.discord.accounts.work.threadBindings.spawnSessions (false).", + ); + }); +}); + describe("legacy migrate audio transcription", () => { it("does not rewrite removed routing.transcribeAudio migrations", () => { const res = migrateLegacyConfigForTest({ diff --git a/src/commands/doctor/shared/legacy-config-migrations.channels.ts b/src/commands/doctor/shared/legacy-config-migrations.channels.ts index eb795a5062e..65b0c8826a6 100644 --- a/src/commands/doctor/shared/legacy-config-migrations.channels.ts +++ b/src/commands/doctor/shared/legacy-config-migrations.channels.ts @@ -14,6 +14,15 @@ function hasLegacyThreadBindingTtl(value: unknown): boolean { return Boolean(threadBindings && hasOwnKey(threadBindings, "ttlHours")); } +function hasLegacyThreadBindingSpawnSplit(value: unknown): boolean { + const threadBindings = getRecord(value); + return Boolean( + threadBindings && + (hasOwnKey(threadBindings, "spawnSubagentSessions") || + hasOwnKey(threadBindings, "spawnAcpSessions")), + ); +} + function hasLegacyThreadBindingTtlInAccounts(value: unknown): boolean { const accounts = getRecord(value); if (!accounts) { @@ -24,6 +33,16 @@ function hasLegacyThreadBindingTtlInAccounts(value: unknown): boolean { ); } +function hasLegacyThreadBindingSpawnSplitInAccounts(value: unknown): boolean { + const accounts = getRecord(value); + if (!accounts) { + return false; + } + return Object.values(accounts).some((entry) => + hasLegacyThreadBindingSpawnSplit(getRecord(entry)?.threadBindings), + ); +} + function migrateThreadBindingsTtlHoursForPath(params: { owner: Record; pathPrefix: string; @@ -53,6 +72,63 @@ function migrateThreadBindingsTtlHoursForPath(params: { return true; } +function resolveMigratedSpawnSessions( + threadBindings: Record, +): boolean | undefined { + const subagent = threadBindings.spawnSubagentSessions; + const acp = threadBindings.spawnAcpSessions; + const subagentBool = typeof subagent === "boolean" ? subagent : undefined; + const acpBool = typeof acp === "boolean" ? acp : undefined; + if (subagentBool === undefined) { + return acpBool; + } + if (acpBool === undefined) { + return subagentBool; + } + return subagentBool && acpBool; +} + +function migrateThreadBindingsSpawnSessionsForPath(params: { + owner: Record; + pathPrefix: string; + changes: string[]; +}): boolean { + const threadBindings = getRecord(params.owner.threadBindings); + if (!threadBindings || !hasLegacyThreadBindingSpawnSplit(threadBindings)) { + return false; + } + + const hadSpawnSessions = threadBindings.spawnSessions !== undefined; + const resolved = resolveMigratedSpawnSessions(threadBindings); + const oldSubagent = threadBindings.spawnSubagentSessions; + const oldAcp = threadBindings.spawnAcpSessions; + delete threadBindings.spawnSubagentSessions; + delete threadBindings.spawnAcpSessions; + if (!hadSpawnSessions && resolved !== undefined) { + threadBindings.spawnSessions = resolved; + } + params.owner.threadBindings = threadBindings; + + if (hadSpawnSessions) { + params.changes.push( + `Removed deprecated ${params.pathPrefix}.threadBindings.spawnSubagentSessions/spawnAcpSessions (${params.pathPrefix}.threadBindings.spawnSessions already set).`, + ); + } else if ( + typeof oldSubagent === "boolean" && + typeof oldAcp === "boolean" && + oldSubagent !== oldAcp + ) { + params.changes.push( + `Collapsed conflicting ${params.pathPrefix}.threadBindings.spawnSubagentSessions/spawnAcpSessions → ${params.pathPrefix}.threadBindings.spawnSessions (${String(resolved)}).`, + ); + } else { + params.changes.push( + `Moved ${params.pathPrefix}.threadBindings.spawnSubagentSessions/spawnAcpSessions → ${params.pathPrefix}.threadBindings.spawnSessions (${String(resolved)}).`, + ); + } + return true; +} + function hasLegacyThreadBindingTtlInAnyChannel(value: unknown): boolean { const channels = getRecord(value); if (!channels) { @@ -70,6 +146,23 @@ function hasLegacyThreadBindingTtlInAnyChannel(value: unknown): boolean { }); } +function hasLegacyThreadBindingSpawnSplitInAnyChannel(value: unknown): boolean { + const channels = getRecord(value); + if (!channels) { + return false; + } + return Object.values(channels).some((entry) => { + const channel = getRecord(entry); + if (!channel) { + return false; + } + return ( + hasLegacyThreadBindingSpawnSplit(channel.threadBindings) || + hasLegacyThreadBindingSpawnSplitInAccounts(channel.accounts) + ); + }); +} + const THREAD_BINDING_RULES: LegacyConfigRule[] = [ { path: ["session", "threadBindings"], @@ -83,6 +176,18 @@ const THREAD_BINDING_RULES: LegacyConfigRule[] = [ 'channels..threadBindings.ttlHours was renamed to channels..threadBindings.idleHours. Run "openclaw doctor --fix".', match: (value) => hasLegacyThreadBindingTtlInAnyChannel(value), }, + { + path: ["session", "threadBindings"], + message: + 'session.threadBindings.spawnSubagentSessions/spawnAcpSessions were replaced by session.threadBindings.spawnSessions. Run "openclaw doctor --fix".', + match: (value) => hasLegacyThreadBindingSpawnSplit(value), + }, + { + path: ["channels"], + message: + 'channels..threadBindings.spawnSubagentSessions/spawnAcpSessions were replaced by channels..threadBindings.spawnSessions. Run "openclaw doctor --fix".', + match: (value) => hasLegacyThreadBindingSpawnSplitInAnyChannel(value), + }, ]; export const LEGACY_CONFIG_MIGRATIONS_CHANNELS: LegacyConfigMigrationSpec[] = [ @@ -99,6 +204,11 @@ export const LEGACY_CONFIG_MIGRATIONS_CHANNELS: LegacyConfigMigrationSpec[] = [ pathPrefix: "session", changes, }); + migrateThreadBindingsSpawnSessionsForPath({ + owner: session, + pathPrefix: "session", + changes, + }); raw.session = session; } @@ -117,6 +227,11 @@ export const LEGACY_CONFIG_MIGRATIONS_CHANNELS: LegacyConfigMigrationSpec[] = [ pathPrefix: `channels.${channelId}`, changes, }); + migrateThreadBindingsSpawnSessionsForPath({ + owner: channel, + pathPrefix: `channels.${channelId}`, + changes, + }); const accounts = getRecord(channel.accounts); if (accounts) { @@ -130,6 +245,11 @@ export const LEGACY_CONFIG_MIGRATIONS_CHANNELS: LegacyConfigMigrationSpec[] = [ pathPrefix: `channels.${channelId}.accounts.${accountId}`, changes, }); + migrateThreadBindingsSpawnSessionsForPath({ + owner: account, + pathPrefix: `channels.${channelId}.accounts.${accountId}`, + changes, + }); accounts[accountId] = account; } channel.accounts = accounts; diff --git a/src/config/bundled-channel-config-metadata.generated.ts b/src/config/bundled-channel-config-metadata.generated.ts index b93c850864f..f41ca866e0a 100644 --- a/src/config/bundled-channel-config-metadata.generated.ts +++ b/src/config/bundled-channel-config-metadata.generated.ts @@ -1451,6 +1451,13 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [ type: "number", minimum: 0, }, + spawnSessions: { + type: "boolean", + }, + defaultSpawnContext: { + type: "string", + enum: ["isolated", "fork"], + }, spawnSubagentSessions: { type: "boolean", }, @@ -2837,6 +2844,13 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [ type: "number", minimum: 0, }, + spawnSessions: { + type: "boolean", + }, + defaultSpawnContext: { + type: "string", + enum: ["isolated", "fork"], + }, spawnSubagentSessions: { type: "boolean", }, @@ -3565,13 +3579,13 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [ label: "Discord Thread Binding Max Age (hours)", help: "Optional hard max age in hours for Discord thread-bound sessions. Set 0 to disable hard cap (default: 0). Overrides session.threadBindings.maxAgeHours when set.", }, - "threadBindings.spawnSubagentSessions": { - label: "Discord Thread-Bound Subagent Spawn", - help: "Allow subagent spawns with thread=true to auto-create and bind Discord threads (default: false; opt-in). Set true to enable thread-bound subagent spawns for this account/channel.", + "threadBindings.spawnSessions": { + label: "Discord Thread-Bound Session Spawn", + help: "Allow sessions_spawn(thread=true) and ACP thread spawns to auto-create and bind Discord threads (default: true). Set false to disable for this account/channel.", }, - "threadBindings.spawnAcpSessions": { - label: "Discord Thread-Bound ACP Spawn", - help: "Allow /acp spawn to auto-create and bind Discord threads for ACP sessions (default: false; opt-in). Set true to enable thread-bound ACP spawns for this account/channel.", + "threadBindings.defaultSpawnContext": { + label: "Discord Thread Spawn Context", + help: 'Default native subagent context for thread-bound spawns. "fork" starts from the requester transcript; "isolated" starts clean. Default: "fork".', }, "ui.components.accentColor": { label: "Discord Component Accent Color", @@ -7035,6 +7049,13 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [ maxAgeHours: { type: "number", }, + spawnSessions: { + type: "boolean", + }, + defaultSpawnContext: { + type: "string", + enum: ["isolated", "fork"], + }, spawnSubagentSessions: { type: "boolean", }, @@ -7127,6 +7148,13 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [ maxAgeHours: { type: "number", }, + spawnSessions: { + type: "boolean", + }, + defaultSpawnContext: { + type: "string", + enum: ["isolated", "fork"], + }, spawnSubagentSessions: { type: "boolean", }, @@ -7527,6 +7555,13 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [ type: "number", minimum: 0, }, + spawnSessions: { + type: "boolean", + }, + defaultSpawnContext: { + type: "string", + enum: ["isolated", "fork"], + }, spawnSubagentSessions: { type: "boolean", }, @@ -13628,6 +13663,13 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [ type: "number", minimum: 0, }, + spawnSessions: { + type: "boolean", + }, + defaultSpawnContext: { + type: "string", + enum: ["isolated", "fork"], + }, spawnSubagentSessions: { type: "boolean", }, @@ -14669,6 +14711,13 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [ type: "number", minimum: 0, }, + spawnSessions: { + type: "boolean", + }, + defaultSpawnContext: { + type: "string", + enum: ["isolated", "fork"], + }, spawnSubagentSessions: { type: "boolean", }, @@ -14935,13 +14984,13 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [ label: "Telegram Thread Binding Max Age (hours)", help: "Optional hard max age in hours for Telegram bound sessions. Set 0 to disable hard cap (default: 0). Overrides session.threadBindings.maxAgeHours when set.", }, - "threadBindings.spawnSubagentSessions": { - label: "Telegram Thread-Bound Subagent Spawn", - help: "Allow subagent spawns with thread=true to auto-bind Telegram current conversations when supported.", + "threadBindings.spawnSessions": { + label: "Telegram Thread-Bound Session Spawn", + help: "Allow sessions_spawn(thread=true) and ACP thread spawns to auto-bind Telegram current conversations when supported.", }, - "threadBindings.spawnAcpSessions": { - label: "Telegram Thread-Bound ACP Spawn", - help: "Allow ACP spawns with thread=true to auto-bind Telegram current conversations when supported.", + "threadBindings.defaultSpawnContext": { + label: "Telegram Thread Spawn Context", + help: 'Default native subagent context for thread-bound spawns. "fork" starts from the requester transcript; "isolated" starts clean. Default: "fork".', }, }, }, diff --git a/src/config/schema.base.generated.ts b/src/config/schema.base.generated.ts index 96fa59249a4..537e85535cd 100644 --- a/src/config/schema.base.generated.ts +++ b/src/config/schema.base.generated.ts @@ -20848,6 +20848,19 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = { description: "Optional hard max age in hours for thread-bound sessions across providers/channels (0 disables hard cap). Default: 0.", }, + spawnSessions: { + type: "boolean", + title: "Thread-Bound Session Spawns", + description: + "Global default gate for creating thread-bound work sessions from sessions_spawn and ACP thread spawns. Default: true when thread bindings are enabled.", + }, + defaultSpawnContext: { + type: "string", + enum: ["isolated", "fork"], + title: "Thread Spawn Context", + description: + 'Default native subagent context for thread-bound spawns. Use "fork" to start from the requester transcript or "isolated" for a clean child. Default: "fork".', + }, }, additionalProperties: false, title: "Session Thread Bindings", @@ -27913,6 +27926,16 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = { help: "Optional hard max age in hours for thread-bound sessions across providers/channels (0 disables hard cap). Default: 0.", tags: ["performance", "storage"], }, + "session.threadBindings.spawnSessions": { + label: "Thread-Bound Session Spawns", + help: "Global default gate for creating thread-bound work sessions from sessions_spawn and ACP thread spawns. Default: true when thread bindings are enabled.", + tags: ["storage"], + }, + "session.threadBindings.defaultSpawnContext": { + label: "Thread Spawn Context", + help: 'Default native subagent context for thread-bound spawns. Use "fork" to start from the requester transcript or "isolated" for a clean child. Default: "fork".', + tags: ["storage"], + }, "session.maintenance": { label: "Session Maintenance", help: "Automatic session-store maintenance controls for pruning age, entry caps, reset archive retention, and disk budget cleanup. Start in warn mode to observe impact, then enforce once thresholds are tuned.", diff --git a/src/config/schema.help.quality.test.ts b/src/config/schema.help.quality.test.ts index 79bc6e0d480..3646d5ef4fe 100644 --- a/src/config/schema.help.quality.test.ts +++ b/src/config/schema.help.quality.test.ts @@ -176,6 +176,8 @@ const TARGET_KEYS = [ "session.threadBindings.enabled", "session.threadBindings.idleHours", "session.threadBindings.maxAgeHours", + "session.threadBindings.spawnSessions", + "session.threadBindings.defaultSpawnContext", "session.maintenance", "session.maintenance.mode", "session.maintenance.pruneAfter", diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index 46f4eb4cf34..232455c81d2 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -1461,6 +1461,10 @@ export const FIELD_HELP: Record = { "Default inactivity window in hours for thread-bound sessions across providers/channels (0 disables idle auto-unfocus). Default: 24.", "session.threadBindings.maxAgeHours": "Optional hard max age in hours for thread-bound sessions across providers/channels (0 disables hard cap). Default: 0.", + "session.threadBindings.spawnSessions": + "Global default gate for creating thread-bound work sessions from sessions_spawn and ACP thread spawns. Default: true when thread bindings are enabled.", + "session.threadBindings.defaultSpawnContext": + 'Default native subagent context for thread-bound spawns. Use "fork" to start from the requester transcript or "isolated" for a clean child. Default: "fork".', "session.maintenance": "Automatic session-store maintenance controls for pruning age, entry caps, reset archive retention, and disk budget cleanup. Start in warn mode to observe impact, then enforce once thresholds are tuned.", "session.maintenance.mode": diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index 8ce53676f99..9e32c8e0cf7 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -723,6 +723,8 @@ export const FIELD_LABELS: Record = { "session.threadBindings.enabled": "Thread Binding Enabled", "session.threadBindings.idleHours": "Thread Binding Idle Timeout (hours)", "session.threadBindings.maxAgeHours": "Thread Binding Max Age (hours)", + "session.threadBindings.spawnSessions": "Thread-Bound Session Spawns", + "session.threadBindings.defaultSpawnContext": "Thread Spawn Context", "session.maintenance": "Session Maintenance", "session.maintenance.mode": "Session Maintenance Mode", "session.maintenance.pruneAfter": "Session Prune After", diff --git a/src/config/types.base.ts b/src/config/types.base.ts index 79b53813bee..91b562110da 100644 --- a/src/config/types.base.ts +++ b/src/config/types.base.ts @@ -153,6 +153,17 @@ export type SessionThreadBindingsConfig = { * Session auto-unfocuses once this age is reached even if active. Set to 0 to disable. Default: 0. */ maxAgeHours?: number; + /** + * Allow channel integrations to create thread-bound work sessions from + * sessions_spawn or native ACP spawn flows. Channel/account keys can override. + * Default: true when thread bindings are enabled. + */ + spawnSessions?: boolean; + /** + * Default context mode for native subagents spawned into a bound thread. + * Default: "fork" so the child starts from the requester transcript. + */ + defaultSpawnContext?: "isolated" | "fork"; }; export type SessionConfig = { diff --git a/src/config/types.channels.ts b/src/config/types.channels.ts index 1e7328acc2e..b73fcf3737e 100644 --- a/src/config/types.channels.ts +++ b/src/config/types.channels.ts @@ -57,7 +57,11 @@ export type ExtensionChannelConfig = { execApprovals?: Record; threadBindings?: { enabled?: boolean; + spawnSessions?: boolean; + defaultSpawnContext?: "isolated" | "fork"; + /** @deprecated Use spawnSessions instead. */ spawnAcpSessions?: boolean; + /** @deprecated Use spawnSessions instead. */ spawnSubagentSessions?: boolean; }; spawnSubagentSessions?: boolean; diff --git a/src/config/types.discord.ts b/src/config/types.discord.ts index 40c8e8adb71..46a22b181b4 100644 --- a/src/config/types.discord.ts +++ b/src/config/types.discord.ts @@ -197,13 +197,21 @@ export type DiscordThreadBindingsConfig = { */ maxAgeHours?: number; /** - * Allow `sessions_spawn({ thread: true })` to auto-create + bind Discord - * threads for subagent sessions. Default: false (opt-in). + * Allow session spawns to auto-create + bind Discord threads. + * Applies to native subagent and ACP thread spawns. Default: true. + */ + spawnSessions?: boolean; + /** + * Default context mode for native subagents spawned into a bound Discord thread. + * Default: "fork". + */ + defaultSpawnContext?: "isolated" | "fork"; + /** + * @deprecated Use spawnSessions instead. */ spawnSubagentSessions?: boolean; /** - * Allow `/acp spawn` to auto-create + bind Discord threads for ACP - * sessions. Default: false (opt-in). + * @deprecated Use spawnSessions instead. */ spawnAcpSessions?: boolean; }; diff --git a/src/config/types.telegram.ts b/src/config/types.telegram.ts index cfb9e840800..dd69f297f12 100644 --- a/src/config/types.telegram.ts +++ b/src/config/types.telegram.ts @@ -32,13 +32,11 @@ export type TelegramActionConfig = { export type TelegramThreadBindingsConfig = SessionThreadBindingsConfig & { /** - * Allow `sessions_spawn({ thread: true })` to auto-create + bind Telegram - * topics for subagent sessions. Default: false (opt-in). + * @deprecated Use spawnSessions instead. */ spawnSubagentSessions?: boolean; /** - * Allow `/acp spawn` to auto-create + bind Telegram topics for ACP - * sessions. Default: false (opt-in). + * @deprecated Use spawnSessions instead. */ spawnAcpSessions?: boolean; }; diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts index 934a34e2371..f878a170ce8 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -313,6 +313,8 @@ export const TelegramAccountSchemaBase = z enabled: z.boolean().optional(), idleHours: z.number().nonnegative().optional(), maxAgeHours: z.number().nonnegative().optional(), + spawnSessions: z.boolean().optional(), + defaultSpawnContext: z.enum(["isolated", "fork"]).optional(), spawnSubagentSessions: z.boolean().optional(), spawnAcpSessions: z.boolean().optional(), }) @@ -612,6 +614,8 @@ export const DiscordAccountSchema = z enabled: z.boolean().optional(), idleHours: z.number().nonnegative().optional(), maxAgeHours: z.number().nonnegative().optional(), + spawnSessions: z.boolean().optional(), + defaultSpawnContext: z.enum(["isolated", "fork"]).optional(), spawnSubagentSessions: z.boolean().optional(), spawnAcpSessions: z.boolean().optional(), }) diff --git a/src/config/zod-schema.session.ts b/src/config/zod-schema.session.ts index 1527288fe41..7b5f7c53b9c 100644 --- a/src/config/zod-schema.session.ts +++ b/src/config/zod-schema.session.ts @@ -67,6 +67,8 @@ export const SessionSchema = z enabled: z.boolean().optional(), idleHours: z.number().nonnegative().optional(), maxAgeHours: z.number().nonnegative().optional(), + spawnSessions: z.boolean().optional(), + defaultSpawnContext: z.enum(["isolated", "fork"]).optional(), }) .strict() .optional(), diff --git a/src/plugin-sdk/conversation-runtime.ts b/src/plugin-sdk/conversation-runtime.ts index 51a7fea2a77..1f67f25268b 100644 --- a/src/plugin-sdk/conversation-runtime.ts +++ b/src/plugin-sdk/conversation-runtime.ts @@ -45,6 +45,7 @@ export { } from "../channels/thread-bindings-messages.js"; export { formatThreadBindingDisabledError, + formatThreadBindingSpawnDisabledError, resolveThreadBindingEffectiveExpiresAt, resolveThreadBindingIdleTimeoutMs, resolveThreadBindingIdleTimeoutMsForChannel,