feat: thread-bound subagents on Discord (#21805)

* docs: thread-bound subagents plan

* docs: add exact thread-bound subagent implementation touchpoints

* Docs: prioritize auto thread-bound subagent flow

* Docs: add ACP harness thread-binding extensions

* Discord: add thread-bound session routing and auto-bind spawn flow

* Subagents: add focus commands and ACP/session binding lifecycle hooks

* Tests: cover thread bindings, focus commands, and ACP unbind hooks

* Docs: add plugin-hook appendix for thread-bound subagents

* Plugins: add subagent lifecycle hook events

* Core: emit subagent lifecycle hooks and decouple Discord bindings

* Discord: handle subagent bind lifecycle via plugin hooks

* Subagents: unify completion finalizer and split registry modules

* Add subagent lifecycle events module

* Hooks: fix subagent ended context key

* Discord: share thread bindings across ESM and Jiti

* Subagents: add persistent sessions_spawn mode for thread-bound sessions

* Subagents: clarify thread intro and persistent completion copy

* test(subagents): stabilize sessions_spawn lifecycle cleanup assertions

* Discord: add thread-bound session TTL with auto-unfocus

* Subagents: fail session spawns when thread bind fails

* Subagents: cover thread session failure cleanup paths

* Session: add thread binding TTL config and /session ttl controls

* Tests: align discord reaction expectations

* Agent: persist sessionFile for keyed subagent sessions

* Discord: normalize imports after conflict resolution

* Sessions: centralize sessionFile resolve/persist helper

* Discord: harden thread-bound subagent session routing

* Rebase: resolve upstream/main conflicts

* Subagents: move thread binding into hooks and split bindings modules

* Docs: add channel-agnostic subagent routing hook plan

* Agents: decouple subagent routing from Discord

* Discord: refactor thread-bound subagent flows

* Subagents: prevent duplicate end hooks and orphaned failed sessions

* Refactor: split subagent command and provider phases

* Subagents: honor hook delivery target overrides

* Discord: add thread binding kill switches and refresh plan doc

* Discord: fix thread bind channel resolution

* Routing: centralize account id normalization

* Discord: clean up thread bindings on startup failures

* Discord: add startup cleanup regression tests

* Docs: add long-term thread-bound subagent architecture

* Docs: split session binding plan and dedupe thread-bound doc

* Subagents: add channel-agnostic session binding routing

* Subagents: stabilize announce completion routing tests

* Subagents: cover multi-bound completion routing

* Subagents: suppress lifecycle hooks on failed thread bind

* tests: fix discord provider mock typing regressions

* docs/protocol: sync slash command aliases and delete param models

* fix: add changelog entry for Discord thread-bound subagents (#21805) (thanks @onutc)

---------

Co-authored-by: Shadow <hi@shadowing.dev>
This commit is contained in:
Onur
2026-02-21 16:14:55 +01:00
committed by GitHub
parent 166068dfbe
commit 8178ea472d
114 changed files with 12214 additions and 1659 deletions

View File

@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import {
DEFAULT_ACCOUNT_ID,
normalizeAccountId,
normalizeOptionalAccountId,
} from "./account-id.js";
describe("account id normalization", () => {
it("defaults missing values to default account", () => {
expect(normalizeAccountId(undefined)).toBe(DEFAULT_ACCOUNT_ID);
expect(normalizeAccountId(null)).toBe(DEFAULT_ACCOUNT_ID);
expect(normalizeAccountId(" ")).toBe(DEFAULT_ACCOUNT_ID);
});
it("normalizes valid ids to lowercase", () => {
expect(normalizeAccountId(" Business_1 ")).toBe("business_1");
});
it("sanitizes invalid characters into canonical ids", () => {
expect(normalizeAccountId(" Prod/US East ")).toBe("prod-us-east");
});
it("preserves optional semantics without forcing default", () => {
expect(normalizeOptionalAccountId(undefined)).toBeUndefined();
expect(normalizeOptionalAccountId(" ")).toBeUndefined();
expect(normalizeOptionalAccountId(" !!! ")).toBeUndefined();
expect(normalizeOptionalAccountId(" Business ")).toBe("business");
});
});

34
src/routing/account-id.ts Normal file
View File

@@ -0,0 +1,34 @@
export const DEFAULT_ACCOUNT_ID = "default";
const VALID_ID_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/i;
const INVALID_CHARS_RE = /[^a-z0-9_-]+/g;
const LEADING_DASH_RE = /^-+/;
const TRAILING_DASH_RE = /-+$/;
function canonicalizeAccountId(value: string): string {
if (VALID_ID_RE.test(value)) {
return value.toLowerCase();
}
return value
.toLowerCase()
.replace(INVALID_CHARS_RE, "-")
.replace(LEADING_DASH_RE, "")
.replace(TRAILING_DASH_RE, "")
.slice(0, 64);
}
export function normalizeAccountId(value: string | undefined | null): string {
const trimmed = (value ?? "").trim();
if (!trimmed) {
return DEFAULT_ACCOUNT_ID;
}
return canonicalizeAccountId(trimmed) || DEFAULT_ACCOUNT_ID;
}
export function normalizeOptionalAccountId(value: string | undefined | null): string | undefined {
const trimmed = (value ?? "").trim();
if (!trimmed) {
return undefined;
}
return canonicalizeAccountId(trimmed) || undefined;
}

View File

@@ -342,6 +342,21 @@ describe("resolveAgentRoute", () => {
expect(route.matchedBy).toBe("binding.channel");
});
test("binding accountId matching is canonicalized", () => {
const cfg: OpenClawConfig = {
bindings: [{ agentId: "biz", match: { channel: "discord", accountId: "BIZ" } }],
};
const route = resolveAgentRoute({
cfg,
channel: "discord",
accountId: " biz ",
peer: { kind: "direct", id: "u-1" },
});
expect(route.agentId).toBe("biz");
expect(route.matchedBy).toBe("binding.account");
expect(route.accountId).toBe("biz");
});
test("defaultAgentId is used when no binding matches", () => {
const cfg: OpenClawConfig = {
agents: {

View File

@@ -10,6 +10,7 @@ import {
buildAgentPeerSessionKey,
DEFAULT_ACCOUNT_ID,
DEFAULT_MAIN_KEY,
normalizeAccountId,
normalizeAgentId,
sanitizeAgentId,
} from "./session-key.js";
@@ -71,11 +72,6 @@ function normalizeId(value: unknown): string {
return "";
}
function normalizeAccountId(value: string | undefined | null): string {
const trimmed = (value ?? "").trim();
return trimmed ? trimmed : DEFAULT_ACCOUNT_ID;
}
function matchesAccountId(match: string | undefined, actual: string): boolean {
const trimmed = (match ?? "").trim();
if (!trimmed) {
@@ -84,7 +80,7 @@ function matchesAccountId(match: string | undefined, actual: string): boolean {
if (trimmed === "*") {
return true;
}
return trimmed === actual;
return normalizeAccountId(trimmed) === actual;
}
export function buildAgentSessionKey(params: {

View File

@@ -1,5 +1,6 @@
import type { ChatType } from "../channels/chat-type.js";
import { parseAgentSessionKey, type ParsedAgentSessionKey } from "../sessions/session-key-utils.js";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "./account-id.js";
export {
getSubagentDepth,
@@ -9,10 +10,14 @@ export {
parseAgentSessionKey,
type ParsedAgentSessionKey,
} from "../sessions/session-key-utils.js";
export {
DEFAULT_ACCOUNT_ID,
normalizeAccountId,
normalizeOptionalAccountId,
} from "./account-id.js";
export const DEFAULT_AGENT_ID = "main";
export const DEFAULT_MAIN_KEY = "main";
export const DEFAULT_ACCOUNT_ID = "default";
export type SessionKeyShape = "missing" | "agent" | "legacy_or_alias" | "malformed_agent";
// Pre-compiled regex
@@ -111,24 +116,6 @@ export function sanitizeAgentId(value: string | undefined | null): string {
);
}
export function normalizeAccountId(value: string | undefined | null): string {
const trimmed = (value ?? "").trim();
if (!trimmed) {
return DEFAULT_ACCOUNT_ID;
}
if (VALID_ID_RE.test(trimmed)) {
return trimmed.toLowerCase();
}
return (
trimmed
.toLowerCase()
.replace(INVALID_CHARS_RE, "-")
.replace(LEADING_DASH_RE, "")
.replace(TRAILING_DASH_RE, "")
.slice(0, 64) || DEFAULT_ACCOUNT_ID
);
}
export function buildAgentMainSessionKey(params: {
agentId: string;
mainKey?: string | undefined;