mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-07 23:21:23 +00:00
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:
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user