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

@@ -1,7 +1,9 @@
import crypto from "node:crypto";
import { formatThinkingLevels, normalizeThinkLevel } from "../auto-reply/thinking.js";
import { DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH } from "../config/agent-limits.js";
import { loadConfig } from "../config/config.js";
import { callGateway } from "../gateway/call.js";
import { getGlobalHookRunner } from "../plugins/hook-runner-global.js";
import { normalizeAgentId, parseAgentSessionKey } from "../routing/session-key.js";
import { normalizeDeliveryContext } from "../utils/delivery-context.js";
import { resolveAgentConfig } from "./agent-scope.js";
@@ -17,6 +19,9 @@ import {
resolveMainSessionAlias,
} from "./tools/sessions-helpers.js";
export const SUBAGENT_SPAWN_MODES = ["run", "session"] as const;
export type SpawnSubagentMode = (typeof SUBAGENT_SPAWN_MODES)[number];
export type SpawnSubagentParams = {
task: string;
label?: string;
@@ -24,6 +29,8 @@ export type SpawnSubagentParams = {
model?: string;
thinking?: string;
runTimeoutSeconds?: number;
thread?: boolean;
mode?: SpawnSubagentMode;
cleanup?: "delete" | "keep";
expectsCompletionMessage?: boolean;
};
@@ -42,11 +49,14 @@ export type SpawnSubagentContext = {
export const SUBAGENT_SPAWN_ACCEPTED_NOTE =
"auto-announces on completion, do not poll/sleep. The response will be sent back as an user message.";
export const SUBAGENT_SPAWN_SESSION_ACCEPTED_NOTE =
"thread-bound session stays active after this task; continue in-thread for follow-ups.";
export type SpawnSubagentResult = {
status: "accepted" | "forbidden" | "error";
childSessionKey?: string;
runId?: string;
mode?: SpawnSubagentMode;
note?: string;
modelApplied?: boolean;
error?: string;
@@ -67,6 +77,88 @@ export function splitModelRef(ref?: string) {
return { provider: undefined, model: trimmed };
}
function resolveSpawnMode(params: {
requestedMode?: SpawnSubagentMode;
threadRequested: boolean;
}): SpawnSubagentMode {
if (params.requestedMode === "run" || params.requestedMode === "session") {
return params.requestedMode;
}
// Thread-bound spawns should default to persistent sessions.
return params.threadRequested ? "session" : "run";
}
function summarizeError(err: unknown): string {
if (err instanceof Error) {
return err.message;
}
if (typeof err === "string") {
return err;
}
return "error";
}
async function ensureThreadBindingForSubagentSpawn(params: {
hookRunner: ReturnType<typeof getGlobalHookRunner>;
childSessionKey: string;
agentId: string;
label?: string;
mode: SpawnSubagentMode;
requesterSessionKey?: string;
requester: {
channel?: string;
accountId?: string;
to?: string;
threadId?: string | number;
};
}): Promise<{ status: "ok" } | { status: "error"; error: string }> {
const hookRunner = params.hookRunner;
if (!hookRunner?.hasHooks("subagent_spawning")) {
return {
status: "error",
error:
"thread=true is unavailable because no channel plugin registered subagent_spawning hooks.",
};
}
try {
const result = await hookRunner.runSubagentSpawning(
{
childSessionKey: params.childSessionKey,
agentId: params.agentId,
label: params.label,
mode: params.mode,
requester: params.requester,
threadRequested: true,
},
{
childSessionKey: params.childSessionKey,
requesterSessionKey: params.requesterSessionKey,
},
);
if (result?.status === "error") {
const error = result.error.trim();
return {
status: "error",
error: error || "Failed to prepare thread binding for this subagent session.",
};
}
if (result?.status !== "ok" || !result.threadBindingReady) {
return {
status: "error",
error:
"Unable to create or bind a thread for this subagent session. Session mode is unavailable for this target.",
};
}
return { status: "ok" };
} catch (err) {
return {
status: "error",
error: `Thread bind failed: ${summarizeError(err)}`,
};
}
}
export async function spawnSubagentDirect(
params: SpawnSubagentParams,
ctx: SpawnSubagentContext,
@@ -76,19 +168,37 @@ export async function spawnSubagentDirect(
const requestedAgentId = params.agentId;
const modelOverride = params.model;
const thinkingOverrideRaw = params.thinking;
const requestThreadBinding = params.thread === true;
const spawnMode = resolveSpawnMode({
requestedMode: params.mode,
threadRequested: requestThreadBinding,
});
if (spawnMode === "session" && !requestThreadBinding) {
return {
status: "error",
error: 'mode="session" requires thread=true so the subagent can stay bound to a thread.',
};
}
const cleanup =
params.cleanup === "keep" || params.cleanup === "delete" ? params.cleanup : "keep";
spawnMode === "session"
? "keep"
: params.cleanup === "keep" || params.cleanup === "delete"
? params.cleanup
: "keep";
const expectsCompletionMessage = params.expectsCompletionMessage !== false;
const requesterOrigin = normalizeDeliveryContext({
channel: ctx.agentChannel,
accountId: ctx.agentAccountId,
to: ctx.agentTo,
threadId: ctx.agentThreadId,
});
const hookRunner = getGlobalHookRunner();
const runTimeoutSeconds =
typeof params.runTimeoutSeconds === "number" && Number.isFinite(params.runTimeoutSeconds)
? Math.max(0, Math.floor(params.runTimeoutSeconds))
: 0;
let modelApplied = false;
let threadBindingReady = false;
const cfg = loadConfig();
const { mainKey, alias } = resolveMainSessionAlias(cfg);
@@ -107,7 +217,8 @@ export async function spawnSubagentDirect(
});
const callerDepth = getSubagentDepthFromSessionStore(requesterInternalKey, { cfg });
const maxSpawnDepth = cfg.agents?.defaults?.subagents?.maxSpawnDepth ?? 1;
const maxSpawnDepth =
cfg.agents?.defaults?.subagents?.maxSpawnDepth ?? DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH;
if (callerDepth >= maxSpawnDepth) {
return {
status: "forbidden",
@@ -227,6 +338,39 @@ export async function spawnSubagentDirect(
};
}
}
if (requestThreadBinding) {
const bindResult = await ensureThreadBindingForSubagentSpawn({
hookRunner,
childSessionKey,
agentId: targetAgentId,
label: label || undefined,
mode: spawnMode,
requesterSessionKey: requesterInternalKey,
requester: {
channel: requesterOrigin?.channel,
accountId: requesterOrigin?.accountId,
to: requesterOrigin?.to,
threadId: requesterOrigin?.threadId,
},
});
if (bindResult.status === "error") {
try {
await callGateway({
method: "sessions.delete",
params: { key: childSessionKey, emitLifecycleHooks: false },
timeoutMs: 10_000,
});
} catch {
// Best-effort cleanup only.
}
return {
status: "error",
error: bindResult.error,
childSessionKey,
};
}
threadBindingReady = true;
}
const childSystemPrompt = buildSubagentSystemPrompt({
requesterSessionKey,
requesterOrigin,
@@ -238,8 +382,13 @@ export async function spawnSubagentDirect(
});
const childTaskMessage = [
`[Subagent Context] You are running as a subagent (depth ${childDepth}/${maxSpawnDepth}). Results auto-announce to your requester; do not busy-poll for status.`,
spawnMode === "session"
? "[Subagent Context] This subagent session is persistent and remains available for thread follow-up messages."
: undefined,
`[Subagent Task]: ${task}`,
].join("\n\n");
]
.filter((line): line is string => Boolean(line))
.join("\n\n");
const childIdem = crypto.randomUUID();
let childRunId: string = childIdem;
@@ -271,8 +420,50 @@ export async function spawnSubagentDirect(
childRunId = response.runId;
}
} catch (err) {
const messageText =
err instanceof Error ? err.message : typeof err === "string" ? err : "error";
if (threadBindingReady) {
const hasEndedHook = hookRunner?.hasHooks("subagent_ended") === true;
let endedHookEmitted = false;
if (hasEndedHook) {
try {
await hookRunner?.runSubagentEnded(
{
targetSessionKey: childSessionKey,
targetKind: "subagent",
reason: "spawn-failed",
sendFarewell: true,
accountId: requesterOrigin?.accountId,
runId: childRunId,
outcome: "error",
error: "Session failed to start",
},
{
runId: childRunId,
childSessionKey,
requesterSessionKey: requesterInternalKey,
},
);
endedHookEmitted = true;
} catch {
// Spawn should still return an actionable error even if cleanup hooks fail.
}
}
// Always delete the provisional child session after a failed spawn attempt.
// If we already emitted subagent_ended above, suppress a duplicate lifecycle hook.
try {
await callGateway({
method: "sessions.delete",
params: {
key: childSessionKey,
deleteTranscript: true,
emitLifecycleHooks: !endedHookEmitted,
},
timeoutMs: 10_000,
});
} catch {
// Best-effort only.
}
}
const messageText = summarizeError(err);
return {
status: "error",
error: messageText,
@@ -292,14 +483,45 @@ export async function spawnSubagentDirect(
label: label || undefined,
model: resolvedModel,
runTimeoutSeconds,
expectsCompletionMessage: params.expectsCompletionMessage === true,
expectsCompletionMessage,
spawnMode,
});
if (hookRunner?.hasHooks("subagent_spawned")) {
try {
await hookRunner.runSubagentSpawned(
{
runId: childRunId,
childSessionKey,
agentId: targetAgentId,
label: label || undefined,
requester: {
channel: requesterOrigin?.channel,
accountId: requesterOrigin?.accountId,
to: requesterOrigin?.to,
threadId: requesterOrigin?.threadId,
},
threadRequested: requestThreadBinding,
mode: spawnMode,
},
{
runId: childRunId,
childSessionKey,
requesterSessionKey: requesterInternalKey,
},
);
} catch {
// Spawn should still return accepted if spawn lifecycle hooks fail.
}
}
return {
status: "accepted",
childSessionKey,
runId: childRunId,
note: SUBAGENT_SPAWN_ACCEPTED_NOTE,
mode: spawnMode,
note:
spawnMode === "session" ? SUBAGENT_SPAWN_SESSION_ACCEPTED_NOTE : SUBAGENT_SPAWN_ACCEPTED_NOTE,
modelApplied: resolvedModel ? modelApplied : undefined,
};
}