mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:30:42 +00:00
feat: simplify thread-bound session spawning
This commit is contained in:
@@ -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:<name>` across channel auth paths. (#75813)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -540,7 +540,7 @@ curl "https://api.telegram.org/bot<bot_token>/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 <agent> --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 <agent> --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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"`.
|
||||
|
||||
</Accordion>
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -279,7 +279,7 @@ Examples:
|
||||
<Accordion title="Binding rules and exclusivity">
|
||||
- `--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.
|
||||
|
||||
</Tab>
|
||||
|
||||
@@ -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.
|
||||
</Note>
|
||||
|
||||
## Slash command
|
||||
@@ -179,7 +181,7 @@ session to confirm the effective tool list.
|
||||
`require` rejects spawn unless the target child runtime is sandboxed.
|
||||
</ParamField>
|
||||
<ParamField path="context" type='"isolated" | "fork"' default="isolated">
|
||||
`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`.
|
||||
</ParamField>
|
||||
|
||||
<Warning>
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -372,7 +372,7 @@ export const MATRIX_QA_SCENARIOS: MatrixQaScenarioDefinition[] = [
|
||||
},
|
||||
threadBindings: {
|
||||
enabled: true,
|
||||
spawnSubagentSessions: true,
|
||||
spawnSessions: true,
|
||||
},
|
||||
toolProfile: "coding",
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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 ?? "<default>"}`,
|
||||
`threadBindings.enabled=${snapshot.threadBindings.enabled ?? "<default>"}`,
|
||||
`threadBindings.spawnSubagentSessions=${snapshot.threadBindings.spawnSubagentSessions ?? "<default>"}`,
|
||||
`threadBindings.spawnSessions=${snapshot.threadBindings.spawnSessions ?? "<default>"}`,
|
||||
`approvals.exec.enabled=${formatMatrixQaBoolean(snapshot.approvalForwarding.exec)}`,
|
||||
`approvals.plugin.enabled=${formatMatrixQaBoolean(snapshot.approvalForwarding.plugin)}`,
|
||||
].join(", ");
|
||||
|
||||
@@ -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<string, ChannelConfigUiHint>;
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
42
src/agents/runtime-capabilities.test.ts
Normal file
42
src/agents/runtime-capabilities.test.ts
Normal file
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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).",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<void> {
|
||||
}
|
||||
}
|
||||
|
||||
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<string, unknown>;
|
||||
const unsupportedParam = UNSUPPORTED_SESSIONS_SPAWN_PARAM_KEYS.find((key) =>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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).`;
|
||||
}
|
||||
|
||||
@@ -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<string, unknown>,
|
||||
).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({
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
pathPrefix: string;
|
||||
@@ -53,6 +72,63 @@ function migrateThreadBindingsTtlHoursForPath(params: {
|
||||
return true;
|
||||
}
|
||||
|
||||
function resolveMigratedSpawnSessions(
|
||||
threadBindings: Record<string, unknown>,
|
||||
): 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<string, unknown>;
|
||||
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.<id>.threadBindings.ttlHours was renamed to channels.<id>.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.<id>.threadBindings.spawnSubagentSessions/spawnAcpSessions were replaced by channels.<id>.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;
|
||||
|
||||
@@ -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".',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1461,6 +1461,10 @@ export const FIELD_HELP: Record<string, string> = {
|
||||
"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":
|
||||
|
||||
@@ -723,6 +723,8 @@ export const FIELD_LABELS: Record<string, string> = {
|
||||
"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",
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -57,7 +57,11 @@ export type ExtensionChannelConfig = {
|
||||
execApprovals?: Record<string, unknown>;
|
||||
threadBindings?: {
|
||||
enabled?: boolean;
|
||||
spawnSessions?: boolean;
|
||||
defaultSpawnContext?: "isolated" | "fork";
|
||||
/** @deprecated Use spawnSessions instead. */
|
||||
spawnAcpSessions?: boolean;
|
||||
/** @deprecated Use spawnSessions instead. */
|
||||
spawnSubagentSessions?: boolean;
|
||||
};
|
||||
spawnSubagentSessions?: boolean;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -45,6 +45,7 @@ export {
|
||||
} from "../channels/thread-bindings-messages.js";
|
||||
export {
|
||||
formatThreadBindingDisabledError,
|
||||
formatThreadBindingSpawnDisabledError,
|
||||
resolveThreadBindingEffectiveExpiresAt,
|
||||
resolveThreadBindingIdleTimeoutMs,
|
||||
resolveThreadBindingIdleTimeoutMsForChannel,
|
||||
|
||||
Reference in New Issue
Block a user