From a5cb041f47f23d264f1cab8fc8b414b9e759fc0b Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Fri, 21 Aug 2026 01:45:42 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=95=8A=EF=B8=8F=20feat:=20Yield=20to=20Su?= =?UTF-8?q?bagent=20Completion=20Wakeups=20(#15066)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 🕊️ feat: yield to subagent completion wakeups * ⚡ fix: bound wakeup status guidance --- .../services/Endpoints/agents/initialize.js | 20 ++- .../Endpoints/agents/subagentThreadStore.js | 1 + .../agents/__tests__/run-codeTools.test.ts | 44 ++++++- packages/api/src/agents/background.spec.ts | 120 +++++++++++++++++- packages/api/src/agents/background.ts | 75 ++++++++--- packages/api/src/agents/index.ts | 1 + packages/api/src/agents/run.ts | 13 ++ .../api/src/agents/subagentDelivery.spec.ts | 63 +++++++++ packages/api/src/agents/subagentDelivery.ts | 59 +++++++++ .../api/src/agents/subagentThreads.spec.ts | 11 ++ packages/api/src/agents/subagentThreads.ts | 9 +- 11 files changed, 390 insertions(+), 26 deletions(-) create mode 100644 packages/api/src/agents/subagentDelivery.spec.ts create mode 100644 packages/api/src/agents/subagentDelivery.ts diff --git a/api/server/services/Endpoints/agents/initialize.js b/api/server/services/Endpoints/agents/initialize.js index a3256f54bd..ded114cdea 100644 --- a/api/server/services/Endpoints/agents/initialize.js +++ b/api/server/services/Endpoints/agents/initialize.js @@ -1228,13 +1228,19 @@ const initializeClient = async ({ req.user.id !== '' && typeof conversationId === 'string' && conversationId !== '' - ? buildSubagentThreadTaskConfig(subagentThreadTaskStore, { - userId: req.user.id, - parentConversationId: conversationId, - ...(typeof req.user.tenantId === 'string' && req.user.tenantId !== '' - ? { tenantId: req.user.tenantId } - : {}), - }) + ? buildSubagentThreadTaskConfig( + subagentThreadTaskStore, + { + userId: req.user.id, + parentConversationId: conversationId, + ...(typeof req.user.tenantId === 'string' && req.user.tenantId !== '' + ? { tenantId: req.user.tenantId } + : {}), + }, + { + completionWakeups: subagentThreadTaskStore.completionWakeupsEnabled === true, + }, + ) : undefined; let hasExistingSubagentTask = false; if (trustedSubagentTasks != null && !(subagentsAvailableForRun && hasSpawnableSubagent)) { diff --git a/api/server/services/Endpoints/agents/subagentThreadStore.js b/api/server/services/Endpoints/agents/subagentThreadStore.js index 5f7ff966f6..d071990245 100644 --- a/api/server/services/Endpoints/agents/subagentThreadStore.js +++ b/api/server/services/Endpoints/agents/subagentThreadStore.js @@ -84,4 +84,5 @@ async function configureSubagentTaskRouting() { } module.exports = subagentThreadTaskStore; +module.exports.completionWakeupsEnabled = completionWakeupsEnabled; module.exports.configureSubagentTaskRouting = configureSubagentTaskRouting; diff --git a/packages/api/src/agents/__tests__/run-codeTools.test.ts b/packages/api/src/agents/__tests__/run-codeTools.test.ts index 8c170fc035..b3739e7632 100644 --- a/packages/api/src/agents/__tests__/run-codeTools.test.ts +++ b/packages/api/src/agents/__tests__/run-codeTools.test.ts @@ -1,4 +1,6 @@ import type { SubagentTaskConfig } from '@librechat/agents'; +import type { HostSubagentTaskConfig } from '~/agents/subagentDelivery'; +import { SUBAGENT_COMPLETION_DELIVERY } from '~/agents/subagentDelivery'; import { CHECK_BACKGROUND_TASK_NAME } from '~/agents/background'; import { createRun } from '~/agents/run'; @@ -58,7 +60,7 @@ jest.mock('~/agents/checkpointer', () => ({ getAgentCheckpointer: jest.fn().mockResolvedValue({}), })); -import { InMemorySubagentTaskStore, Run } from '@librechat/agents'; +import { HookRegistry, InMemorySubagentTaskStore, Run } from '@librechat/agents'; function makeAgent(overrides?: Record) { return { @@ -152,4 +154,44 @@ describe('createRun code-tool eager/session wiring', () => { ).not.toContain(CHECK_BACKGROUND_TASK_NAME); expect(selfConfig.agentInputs?.toolRegistry?.has(CHECK_BACKGROUND_TASK_NAME)).toBe(false); }); + + it('registers wakeup-aware schema and handle guidance for automatic subagent delivery', async () => { + const subagentTasks: HostSubagentTaskConfig = { + store: new InMemorySubagentTaskStore(), + scopeId: 'owner:wakeup-parent', + completionDelivery: SUBAGENT_COMPLETION_DELIVERY, + }; + const runConfig = await captureRunConfig( + makeAgent({ + subagents: { enabled: true, allowSelf: true }, + toolDefinitions: [], + toolRegistry: new Map(), + }), + subagentTasks, + ); + const [agentInput] = (runConfig.graphConfig as { agents: Array> }) + .agents; + const poll = (agentInput.toolDefinitions as Array<{ name: string; description: string }>).find( + (definition) => definition.name === CHECK_BACKGROUND_TASK_NAME, + ); + expect(poll?.description).toContain('automatic completion delivery'); + + const hooks = runConfig.hooks as HookRegistry; + const [matcher] = hooks.getMatchers('PostToolUse'); + expect(matcher.pattern).toBe('subagent'); + const result = await matcher.hooks[0]( + { + hook_event_name: 'PostToolUse', + runId: 'run-1', + toolName: 'subagent', + toolInput: {}, + toolOutput: JSON.stringify({ background_task_id: 'task-1', status: 'running' }), + toolUseId: 'call-1', + }, + new AbortController().signal, + ); + expect(JSON.parse(result.updatedOutput as string).message).toContain( + 'the host will resume you', + ); + }); }); diff --git a/packages/api/src/agents/background.spec.ts b/packages/api/src/agents/background.spec.ts index 97fbc2eed6..8934d7c47d 100644 --- a/packages/api/src/agents/background.spec.ts +++ b/packages/api/src/agents/background.spec.ts @@ -1,6 +1,12 @@ import { logger } from '@librechat/data-schemas'; import { InMemorySubagentTaskStore } from '@librechat/agents'; -import type { LCTool, LCToolRegistry, SubagentTaskConfig } from '@librechat/agents'; +import type { + LCTool, + LCToolRegistry, + SubagentTaskConfig, + SubagentTaskRuntime, +} from '@librechat/agents'; +import type { HostSubagentTaskConfig } from './subagentDelivery'; import { isBackgroundEligibleToolName, isBackgroundRequested, @@ -19,6 +25,7 @@ import { CHECK_BACKGROUND_TASK_NAME, RUN_IN_BACKGROUND_ARG, } from './background'; +import { SUBAGENT_COMPLETION_DELIVERY, SUBAGENT_WAKEUP_GUIDANCE } from './subagentDelivery'; import { SubagentTaskOwnerUnavailableError } from './subagentTaskRouting'; import { TOOL_SELECTION_WILDCARD } from './selection'; import { toolOptionsSchema } from './validation'; @@ -306,6 +313,26 @@ describe('registerBackgroundTaskTool', () => { collidingDef.description, ); }); + + it('advertises automatic delivery only for wakeup-enabled subagents', () => { + const registry: LCToolRegistry = new Map(); + const manual = registerBackgroundTaskTool({ toolRegistry: registry, toolDefinitions: [] }); + const manualDescription = manual.toolDefinitions[0].description ?? ''; + expect(manualDescription).toContain('Results are not pushed to you'); + + const automatic = registerBackgroundTaskTool({ + toolRegistry: registry, + toolDefinitions: manual.toolDefinitions, + subagentCompletionWakeups: true, + }); + expect(automatic.toolDefinitions).toHaveLength(1); + expect(automatic.toolDefinitions[0].description).toContain( + 'Detached subagent tasks use automatic completion delivery', + ); + expect(automatic.toolDefinitions[0].description).toContain( + 'Ordinary background tool tasks require polling', + ); + }); }); describe('synthesizeBackgroundToolOptions', () => { @@ -1291,6 +1318,97 @@ describe('runCheckBackgroundTask (singleton)', () => { expect(second.result).toBeUndefined(); }); + it('tells a wakeup-enabled parent to yield on an unchanged running subagent', async () => { + const store = new InMemorySubagentTaskStore(); + const subagentTasks: HostSubagentTaskConfig = { + store, + scopeId: 'owner:wakeup-parent', + completionDelivery: SUBAGENT_COMPLETION_DELIVERY, + }; + const started = store.start({ + scopeId: subagentTasks.scopeId, + idempotencyKey: 'parent-run:parent-agent:call-wakeup', + parentRunId: 'parent-run', + parentAgentId: 'parent-agent', + parentToolCallId: 'call-wakeup', + input: 'Research this.', + subagentKind: 'agent', + subagentType: 'researcher', + run: (runtime: SubagentTaskRuntime) => + new Promise((_, reject) => { + runtime.signal.addEventListener('abort', () => reject(runtime.signal.reason), { + once: true, + }); + }), + }); + if (!started.accepted) { + throw new Error('Expected subagent task to start.'); + } + + const polled = JSON.parse( + await runCheckBackgroundTask({ + userId: 'owner', + conversationId: 'wakeup-parent', + args: { background_task_id: started.task.taskId }, + subagentTasks, + }), + ); + expect(polled).toMatchObject({ + status: 'running', + message: SUBAGENT_WAKEUP_GUIDANCE, + }); + + const listed = JSON.parse( + await runCheckBackgroundTask({ + userId: 'owner', + conversationId: 'wakeup-parent', + args: {}, + subagentTasks, + }), + ); + expect(listed.message).toBe(SUBAGENT_WAKEUP_GUIDANCE); + expect(listed.tasks[0].message).toBeUndefined(); + + store.control(subagentTasks.scopeId, started.task.taskId, { action: 'cancel' }); + }); + + it('preserves poll-first running status when automatic delivery is disabled', async () => { + const store = new InMemorySubagentTaskStore(); + const subagentTasks: SubagentTaskConfig = { store, scopeId: 'owner:manual-parent' }; + const started = store.start({ + scopeId: subagentTasks.scopeId, + idempotencyKey: 'parent-run:parent-agent:call-manual', + parentRunId: 'parent-run', + parentAgentId: 'parent-agent', + parentToolCallId: 'call-manual', + input: 'Research this.', + subagentKind: 'agent', + subagentType: 'researcher', + run: (runtime: SubagentTaskRuntime) => + new Promise((_, reject) => { + runtime.signal.addEventListener('abort', () => reject(runtime.signal.reason), { + once: true, + }); + }), + }); + if (!started.accepted) { + throw new Error('Expected subagent task to start.'); + } + + const polled = JSON.parse( + await runCheckBackgroundTask({ + userId: 'owner', + conversationId: 'manual-parent', + args: { background_task_id: started.task.taskId }, + subagentTasks, + }), + ); + expect(polled).toMatchObject({ status: 'running' }); + expect(polled.message).toBeUndefined(); + + store.control(subagentTasks.scopeId, started.task.taskId, { action: 'cancel' }); + }); + it('routes parent control actions only to detached subagent tasks', async () => { const store = new InMemorySubagentTaskStore(); const subagentTasks: SubagentTaskConfig = { store, scopeId: 'owner:parent-thread' }; diff --git a/packages/api/src/agents/background.ts b/packages/api/src/agents/background.ts index 113bd2c7fb..6a1f18ae46 100644 --- a/packages/api/src/agents/background.ts +++ b/packages/api/src/agents/background.ts @@ -56,6 +56,7 @@ import { warnUnmatchedSelectionNames, synthesizeSelectionToolOptions, } from './selection'; +import { SUBAGENT_WAKEUP_GUIDANCE, usesSubagentCompletionWakeups } from './subagentDelivery'; import { SubagentTaskOwnerUnavailableError } from './subagentTaskRouting'; import { SET_MEMORY_TOOL_NAME, DELETE_MEMORY_TOOL_NAME } from './memory'; import { ASK_USER_QUESTION_TOOL_NAME } from './hitl/askUserQuestionTool'; @@ -307,6 +308,16 @@ const CHECK_BACKGROUND_TASK_DESCRIPTION = `Check, control, and retrieve tool or Provide a background_task_id to poll one task; omit it to list every background task in this thread. A task is only finished when its status is "completed", "error", or "cancelled" — never assume completion without polling. Results are not pushed to you; you must call this tool to collect them. Subagent tasks additionally accept steer, queue, interrupt, cancel, and cancel_message actions while running. Live subagent controls route across API replicas but do not survive a restart of the process that owns the executor. A completed subagent thread may be continued later through the subagent tool's durable thread id.`; +const CHECK_BACKGROUND_TASK_WAKEUP_DESCRIPTION = `Check, control, and retrieve tool or subagent tasks previously dispatched in the background (with run_in_background: true). + +Provide a background_task_id to inspect one task; omit it to list every background task in this thread. Ordinary background tool tasks require polling to retrieve their results. Detached subagent tasks use automatic completion delivery: continue independent work or end the turn instead of repeatedly polling an unchanged running task, and the host will resume you when one finishes. Use this tool for explicit status, steer, queue, interrupt, cancel, or cancel_message actions, or as a fallback if automatic delivery is unavailable. Live subagent controls route across API replicas but do not survive a restart of the process that owns the executor. A completed subagent thread may be continued later through the subagent tool's durable thread id.`; + +function checkBackgroundTaskDescription(subagentCompletionWakeups: boolean): string { + return subagentCompletionWakeups + ? CHECK_BACKGROUND_TASK_WAKEUP_DESCRIPTION + : CHECK_BACKGROUND_TASK_DESCRIPTION; +} + /** * `maxLength` is valid JSON Schema and is honored by providers, but the SDK's * `JsonSchemaType` does not declare it, so the model-facing bounds are typed here. @@ -357,11 +368,13 @@ const CHECK_BACKGROUND_TASK_PARAMETERS = Object.freeze({ - name: CHECK_BACKGROUND_TASK_NAME, - description: CHECK_BACKGROUND_TASK_DESCRIPTION, - parameters: CHECK_BACKGROUND_TASK_PARAMETERS, -}); +function buildCheckBackgroundTaskDefinition(subagentCompletionWakeups: boolean): LCTool { + return { + name: CHECK_BACKGROUND_TASK_NAME, + description: checkBackgroundTaskDescription(subagentCompletionWakeups), + parameters: CHECK_BACKGROUND_TASK_PARAMETERS, + }; +} /** * Idempotently registers the `check_background_task` poll tool into the run's @@ -370,17 +383,23 @@ const CHECK_BACKGROUND_TASK_DEF: LCTool = Object.freeze({ export function registerBackgroundTaskTool(params: { toolRegistry: LCToolRegistry | undefined; toolDefinitions: LCTool[] | undefined; + subagentCompletionWakeups?: boolean; }): { toolDefinitions: LCTool[] } { - const { toolRegistry, toolDefinitions } = params; + const { toolRegistry, toolDefinitions, subagentCompletionWakeups = false } = params; const defs = toolDefinitions ?? []; + const desiredDescription = checkBackgroundTaskDescription(subagentCompletionWakeups); const isOurs = (tool?: { description?: string }): boolean => - tool?.description === CHECK_BACKGROUND_TASK_DESCRIPTION; + tool?.description === CHECK_BACKGROUND_TASK_DESCRIPTION || + tool?.description === CHECK_BACKGROUND_TASK_WAKEUP_DESCRIPTION; const existingDef = defs.find((d) => d.name === CHECK_BACKGROUND_TASK_NAME); const existingRegistry = toolRegistry?.get(CHECK_BACKGROUND_TASK_NAME); /** Already registered by us — idempotent no-op. */ - if (isOurs(existingDef) || isOurs(existingRegistry)) { + if ( + existingDef?.description === desiredDescription && + (existingRegistry == null || existingRegistry.description === desiredDescription) + ) { return { toolDefinitions: defs }; } @@ -392,21 +411,29 @@ export function registerBackgroundTaskTool(params: { * and warn that the colliding tool is shadowed. */ const collides = existingDef != null || existingRegistry != null; - if (collides) { + const foreignCollision = + (existingDef != null && !isOurs(existingDef)) || + (existingRegistry != null && !isOurs(existingRegistry)); + if (foreignCollision) { logger.warn( `[background] A tool named "${CHECK_BACKGROUND_TASK_NAME}" collides with the reserved background poll tool; the host poll tool takes precedence and the colliding tool is shadowed for this run.`, ); } toolRegistry?.set(CHECK_BACKGROUND_TASK_NAME, { name: CHECK_BACKGROUND_TASK_NAME, - description: CHECK_BACKGROUND_TASK_DESCRIPTION, + description: desiredDescription, parameters: CHECK_BACKGROUND_TASK_PARAMETERS, allowed_callers: ['direct'], }); const withoutCollision = collides ? defs.filter((d) => d.name !== CHECK_BACKGROUND_TASK_NAME) : defs; - return { toolDefinitions: [...withoutCollision, CHECK_BACKGROUND_TASK_DEF] }; + return { + toolDefinitions: [ + ...withoutCollision, + buildCheckBackgroundTaskDefinition(subagentCompletionWakeups), + ], + }; } /** @@ -1053,7 +1080,12 @@ interface SerializedSubagentTask { function serializeSubagentSnapshot( task: SubagentTaskSnapshot, - options: { includeResult?: string; status?: string; controlId?: string } = {}, + options: { + includeResult?: string; + status?: string; + controlId?: string; + completionWakeups?: boolean; + } = {}, ): SerializedSubagentTask { return { background_task_id: task.taskId, @@ -1069,10 +1101,16 @@ function serializeSubagentSnapshot( ...(task.pendingControls > 0 ? { pending_controls: task.pendingControls } : {}), ...(task.error == null ? {} : { error: task.error }), ...(options.controlId == null ? {} : { control_id: options.controlId }), + ...(options.completionWakeups === true && task.status === 'running' + ? { message: SUBAGENT_WAKEUP_GUIDANCE } + : {}), }; } -function serializeSubagentClaim(claim: SubagentTaskClaim): SerializedSubagentTask | undefined { +function serializeSubagentClaim( + claim: SubagentTaskClaim, + completionWakeups: boolean, +): SerializedSubagentTask | undefined { if (claim.status === 'not_found') { return undefined; } @@ -1085,7 +1123,7 @@ function serializeSubagentClaim(claim: SubagentTaskClaim): SerializedSubagentTas error: claim.error, }; } - return serializeSubagentSnapshot(claim.task, { status: claim.status }); + return serializeSubagentSnapshot(claim.task, { status: claim.status, completionWakeups }); } function serializeSubagentControl( @@ -1216,7 +1254,10 @@ export async function runCheckBackgroundTask(params: { routedStore == null ? subagentTasks.store.claim(subagentTasks.scopeId, taskId) : await routedStore.claimTask(subagentTasks.scopeId, taskId, invocationId); - const claimed = serializeSubagentClaim(claim); + const claimed = serializeSubagentClaim( + claim, + usesSubagentCompletionWakeups(subagentTasks), + ); if (claimed != null) { return JSON.stringify(claimed); } @@ -1267,6 +1308,7 @@ export async function runCheckBackgroundTask(params: { const tasks = backgroundTaskRegistry.list(userId, conversationId); let subagentTasks: SerializedSubagentTask[] = []; let listWarning: string | undefined; + const completionWakeups = usesSubagentCompletionWakeups(params.subagentTasks); if (params.subagentTasks != null) { try { const routedStore = routedSubagentStore(params.subagentTasks.store); @@ -1297,6 +1339,9 @@ export async function runCheckBackgroundTask(params: { ...tasks.map((task) => serializeTask(task, { includeResult: false })), ...subagentTasks, ], + ...(completionWakeups && subagentTasks.some((task) => task.status === 'running') + ? { message: SUBAGENT_WAKEUP_GUIDANCE } + : {}), ...(listWarning != null && { partial: true, warning: listWarning }), }); } diff --git a/packages/api/src/agents/index.ts b/packages/api/src/agents/index.ts index cd77218f36..27dc43921c 100644 --- a/packages/api/src/agents/index.ts +++ b/packages/api/src/agents/index.ts @@ -50,5 +50,6 @@ export * from './steering'; export * from './triggers'; export * from './activityLabels'; export * from './activityPhases'; +export * from './subagentDelivery'; export * from './reasoningLabels'; export * from './toolValidation'; diff --git a/packages/api/src/agents/run.ts b/packages/api/src/agents/run.ts index 5a14ab853f..e198b13095 100644 --- a/packages/api/src/agents/run.ts +++ b/packages/api/src/agents/run.ts @@ -53,6 +53,10 @@ import { ASK_USER_QUESTION_TOOL_NAME, createAskUserQuestionTool, } from '~/agents/hitl/askUserQuestionTool'; +import { + createSubagentWakeupHandleHook, + usesSubagentCompletionWakeups, +} from '~/agents/subagentDelivery'; import { resolveToolApprovalPolicy, exemptAskUserQuestionFromApproval } from '~/agents/hitl/policy'; import { applyCustomHandoffPromptKeyCompatibility } from '~/agents/handoffPromptKeyCompatibility'; import { stripIntentFromToolRegistry, stripIntentFromToolDefinitions } from '~/agents/intent'; @@ -1632,6 +1636,7 @@ export async function createRun({ agentInput.toolDefinitions = registerBackgroundTaskTool({ toolRegistry: agentInput.toolRegistry, toolDefinitions: agentInput.toolDefinitions, + subagentCompletionWakeups: usesSubagentCompletionWakeups(subagentTasks), }).toolDefinitions; } agentInputs.push(agentInput); @@ -1729,6 +1734,14 @@ export async function createRun({ * this guard is defense in depth). */ let hooks = hitl?.hooks; + if (usesSubagentCompletionWakeups(subagentTasks)) { + hooks = hooks ?? new HookRegistry(); + hooks.register('PostToolUse', { + pattern: String(Constants.SUBAGENT), + hooks: [createSubagentWakeupHandleHook()], + internal: true, + }); + } /** Activity labels register BEFORE the steer drain: the label must claim * its slot while the batch's tool parts are still the content tail. If a * steer drained first, its injected part would flush the tool block in diff --git a/packages/api/src/agents/subagentDelivery.spec.ts b/packages/api/src/agents/subagentDelivery.spec.ts new file mode 100644 index 0000000000..e9b5893543 --- /dev/null +++ b/packages/api/src/agents/subagentDelivery.spec.ts @@ -0,0 +1,63 @@ +import type { PostToolUseHookInput } from '@librechat/agents'; +import { SUBAGENT_WAKEUP_GUIDANCE, createSubagentWakeupHandleHook } from './subagentDelivery'; + +const hookSignal = new AbortController().signal; + +function input(toolName: string, toolOutput: unknown): PostToolUseHookInput { + return { + hook_event_name: 'PostToolUse', + toolName, + toolInput: {}, + toolOutput, + toolUseId: 'call-1', + } as PostToolUseHookInput; +} + +describe('createSubagentWakeupHandleHook', () => { + it('replaces the legacy poll-first instruction on a running detached subagent handle', async () => { + const output = JSON.stringify({ + background_task_id: 'task-1', + subagent_thread_id: 'thread-1', + tool: 'subagent', + subagent_type: 'researcher', + status: 'running', + message: 'Poll the host background-task tool.', + }); + + const result = await createSubagentWakeupHandleHook()(input('subagent', output), hookSignal); + const updated = JSON.parse(result.updatedOutput as string); + + expect(updated).toMatchObject({ + background_task_id: 'task-1', + subagent_thread_id: 'thread-1', + status: 'running', + message: SUBAGENT_WAKEUP_GUIDANCE, + }); + }); + + it('leaves ordinary background tools and terminal subagent results unchanged', async () => { + const hook = createSubagentWakeupHandleHook(); + + await expect( + hook( + input('execute_code', JSON.stringify({ background_task_id: 'code-1', status: 'running' })), + hookSignal, + ), + ).resolves.toEqual({}); + await expect( + hook( + input('subagent', JSON.stringify({ background_task_id: 'task-1', status: 'completed' })), + hookSignal, + ), + ).resolves.toEqual({}); + }); + + it('fails closed on malformed or non-handle subagent output', async () => { + const hook = createSubagentWakeupHandleHook(); + + await expect(hook(input('subagent', 'not-json'), hookSignal)).resolves.toEqual({}); + await expect( + hook(input('subagent', JSON.stringify({ status: 'running' })), hookSignal), + ).resolves.toEqual({}); + }); +}); diff --git a/packages/api/src/agents/subagentDelivery.ts b/packages/api/src/agents/subagentDelivery.ts new file mode 100644 index 0000000000..c57add3b3d --- /dev/null +++ b/packages/api/src/agents/subagentDelivery.ts @@ -0,0 +1,59 @@ +import { Constants } from '@librechat/agents'; +import type { HookCallback, PostToolUseHookOutput, SubagentTaskConfig } from '@librechat/agents'; + +export const SUBAGENT_COMPLETION_DELIVERY = 'wakeup'; + +/** Host-owned detached-subagent scope with its model-facing result-delivery contract. */ +export interface HostSubagentTaskConfig extends SubagentTaskConfig { + completionDelivery?: typeof SUBAGENT_COMPLETION_DELIVERY; +} + +export const SUBAGENT_WAKEUP_GUIDANCE = + 'Automatic completion delivery is enabled for this subagent task. Continue independent work if available; otherwise end this turn and the host will resume you when the task finishes. Do not repeatedly poll an unchanged running task. Use check_background_task only for explicit status or control, or as a fallback if automatic delivery is unavailable.'; + +export function usesSubagentCompletionWakeups( + config: SubagentTaskConfig | undefined, +): config is HostSubagentTaskConfig { + return ( + (config as HostSubagentTaskConfig | undefined)?.completionDelivery === + SUBAGENT_COMPLETION_DELIVERY + ); +} + +function parseOutput(output: unknown): Record | undefined { + if (typeof output === 'object' && output !== null && !Array.isArray(output)) { + return output as Record; + } + if (typeof output !== 'string') { + return undefined; + } + try { + const parsed = JSON.parse(output) as unknown; + return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) + ? (parsed as Record) + : undefined; + } catch { + return undefined; + } +} + +/** Replaces the SDK's legacy poll-first handle with the host's durable delivery contract. */ +export function createSubagentWakeupHandleHook(): HookCallback<'PostToolUse'> { + return async (input): Promise => { + if (input.toolName !== String(Constants.SUBAGENT)) { + return {}; + } + const output = parseOutput(input.toolOutput); + if ( + output?.status !== 'running' || + typeof output.background_task_id !== 'string' || + output.background_task_id === '' + ) { + return {}; + } + const updated = { ...output, message: SUBAGENT_WAKEUP_GUIDANCE }; + return { + updatedOutput: typeof input.toolOutput === 'string' ? JSON.stringify(updated) : updated, + }; + }; +} diff --git a/packages/api/src/agents/subagentThreads.spec.ts b/packages/api/src/agents/subagentThreads.spec.ts index e9390160cb..9647af676a 100644 --- a/packages/api/src/agents/subagentThreads.spec.ts +++ b/packages/api/src/agents/subagentThreads.spec.ts @@ -34,6 +34,7 @@ import { SubagentThreadTaskStore, } from './subagentThreads'; import { SubagentTaskOwnerUnavailableError } from './subagentTaskRouting'; +import { SUBAGENT_COMPLETION_DELIVERY } from './subagentDelivery'; import { createSubagentAttemptKey } from './subagentThreadIds'; import { createSubagentUsageSink } from './usage'; @@ -253,6 +254,16 @@ beforeEach(async () => { }); describe('SubagentThreadTaskStore', () => { + it('records the host-selected automatic delivery contract only when wakeups are enabled', () => { + const store = new SubagentThreadTaskStore(methods); + const scope = { userId: 'delivery-user', parentConversationId: randomUUID() }; + + expect(buildSubagentThreadTaskConfig(store, scope).completionDelivery).toBeUndefined(); + expect( + buildSubagentThreadTaskConfig(store, scope, { completionWakeups: true }).completionDelivery, + ).toBe(SUBAGENT_COMPLETION_DELIVERY); + }); + it('maps one logical SDK thread to a durable, view-only LibreChat conversation', async () => { const userId = 'user-1'; const parentConversationId = randomUUID(); diff --git a/packages/api/src/agents/subagentThreads.ts b/packages/api/src/agents/subagentThreads.ts index b2b7199b71..a507f04d2c 100644 --- a/packages/api/src/agents/subagentThreads.ts +++ b/packages/api/src/agents/subagentThreads.ts @@ -9,7 +9,6 @@ import { import type { InMemorySubagentTaskStoreOptions, SubagentTaskClaim, - SubagentTaskConfig, SubagentTaskControlCommand, SubagentTaskControlResult, SubagentTaskRuntime, @@ -29,6 +28,7 @@ import type { import type { BaseMessage, StoredMessage } from '@librechat/agents/langchain/messages'; import type { SubagentTaskControlTransport } from './subagentTaskRouting'; import type { UsageMetadata } from '~/stream/interfaces/IJobStore'; +import type { HostSubagentTaskConfig } from './subagentDelivery'; import { boundedClaim, boundedTaskList, @@ -37,6 +37,7 @@ import { } from './subagentTaskRouting'; import { createSubagentAttemptKey, createSubagentThreadId } from './subagentThreadIds'; import { runWithDetachedSubagentUsage } from './subagentTaskContext'; +import { SUBAGENT_COMPLETION_DELIVERY } from './subagentDelivery'; import { createConcurrencyLimiter } from '~/utils/promise'; import { aggregateEmittedUsage } from './usage'; @@ -2174,9 +2175,13 @@ export function createSubagentThreadTaskStore( export function buildSubagentThreadTaskConfig( store: SubagentThreadTaskStore, scope: Omit, -): SubagentTaskConfig { + options: { completionWakeups?: boolean } = {}, +): HostSubagentTaskConfig { return { store, scopeId: serializeScope(scope), + ...(options.completionWakeups === true + ? { completionDelivery: SUBAGENT_COMPLETION_DELIVERY } + : {}), }; }