From 45ad7a6ebbf04aa1984cf9d1401b44dfed042d74 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sun, 4 Oct 2026 22:33:35 -0400 Subject: [PATCH 01/19] =?UTF-8?q?=F0=9F=A7=AD=20feat:=20Let=20a=20Parent?= =?UTF-8?q?=20Choose=20Its=20Subagent's=20Code=20Machine=20Per=20Call?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A parent agent can now pass optional `machine` / `workspace` arguments on a subagent call to pick where the child runs, using the host-argument extension point added to @librechat/agents. - Each lazy child declares enums built only from machines it may reach: admitted in the conversation's sealed codeWorkspaces, on its default or allowlist, in the requesting principal's tenant-scoped list, and served by a ready worker. A child the conversation pinned to a machine offers only that machine; a child with no reachable machine declares nothing. - On use, a request-scoped routing session in packages/api re-validates the choice against the current request and live worker status and refuses an invalid one with a typed, model-visible error instead of falling back. A routed child is pinned to the chosen machine at every routing site, overriding parent inheritance. - Omitted arguments keep inherit-by-default routing, extended so a child follows the machine a per-call-routed parent runs on (passing through agents without code). A subagent keeps one machine for the whole request. - A child whose default workspace is unavailable stays guarded unless it has per-call choices; then it stays callable and its description asks the parent to pick a listed machine. - Routed children keep their tool context per execution for tool loading, provisioning and artifact handling. - Paused-approval bindings, the attached-machine policy hook, skill-file priming and tool-output references account for per-call choices. The declarations are gated on the installed SDK exporting SubagentHostArgumentError, so this change is inert until LibreChat depends on an @librechat/agents release that includes it. --- .../services/Endpoints/agents/initialize.js | 107 ++- .../Endpoints/agents/initialize.spec.js | 55 ++ .../services/Files/provisionCallback.js | 8 +- .../agents/__tests__/run-codeTools.test.ts | 21 + packages/api/src/agents/codeFilesSession.ts | 16 +- packages/api/src/agents/execution.ts | 80 +- packages/api/src/agents/files/binding.ts | 3 +- packages/api/src/agents/hitl/byom.ts | 6 +- packages/api/src/agents/run.ts | 14 +- packages/api/src/code/index.ts | 1 + .../api/src/code/targets.integration.spec.ts | 212 +++++ packages/api/src/code/targets.spec.ts | 882 ++++++++++++++++++ packages/api/src/code/targets.ts | 605 ++++++++++++ .../api/src/files/provision/callback.spec.ts | 57 ++ packages/api/src/files/provision/callback.ts | 24 +- 15 files changed, 2028 insertions(+), 63 deletions(-) create mode 100644 packages/api/src/code/targets.integration.spec.ts create mode 100644 packages/api/src/code/targets.spec.ts create mode 100644 packages/api/src/code/targets.ts diff --git a/api/server/services/Endpoints/agents/initialize.js b/api/server/services/Endpoints/agents/initialize.js index c9ef4f2d33d..2f6bc926271 100644 --- a/api/server/services/Endpoints/agents/initialize.js +++ b/api/server/services/Endpoints/agents/initialize.js @@ -32,7 +32,7 @@ const { resolveCodeExecutionWorkspaceContext, resolveSubagentCodeWorkspaceInheritance, resolveSubagentCodeAvailability, - guardUnavailableSubagent, + guardRoutableSubagent, createStatefulCodeEnvironmentPolicyError, buildSubagentThreadTaskConfig, backgroundCompletionWakeupsEnabled, @@ -53,6 +53,7 @@ const { retainScheduleMCPCompletion, getScheduleMCPExecution, getMCPRequestContext, + createSubagentCodeRouting, } = require('@librechat/api'); const { ResourceType, @@ -401,6 +402,10 @@ const initializeClientWithProvider = async ({ * }>} */ const agentToolContexts = new Map(); + /** Per-call subagent machine routing; assigned once the request's code inputs are known. */ + let subagentCodeRouting; + const getRoutedToolContext = (agentId, executionContext) => + subagentCodeRouting?.getToolContext(agentId, executionContext); let runFileBindings; const resolveMcpServerName = (toolName, agentId) => { if (typeof toolName !== 'string' || typeof agentId !== 'string') { @@ -449,6 +454,7 @@ const initializeClientWithProvider = async ({ const soleContext = agentToolContexts.size === 1 ? agentToolContexts.values().next().value : null; const trustedContext = + getRoutedToolContext(executingAgentId, metadata.executionContext) ?? (typeof executingAgentId === 'string' ? agentToolContexts.get(executingAgentId) : null) ?? soleContext; const callbackMetadata = { ...metadata }; @@ -480,7 +486,10 @@ const initializeClientWithProvider = async ({ runSignal, executionContext, ) => { - const ctx = runFileBindings.getContext(agentId, executionContext) ?? {}; + const ctx = + getRoutedToolContext(agentId, executionContext) ?? + runFileBindings.getContext(agentId, executionContext) ?? + {}; logger.debug(`[ON_TOOL_EXECUTE] ctx found: ${!!ctx.userMCPAuthMap}, agent: ${ctx.agent?.id}`); logger.debug(`[ON_TOOL_EXECUTE] toolRegistry size: ${ctx.toolRegistry?.size ?? 'undefined'}`); @@ -576,6 +585,7 @@ const initializeClientWithProvider = async ({ req, agentToolContexts, resolvePrimaryAgentId: () => primaryConfig?.id, + resolveExecutionContext: getRoutedToolContext, }), }; @@ -1138,6 +1148,17 @@ const initializeClientWithProvider = async ({ backgroundToolsAvailable, }); + subagentCodeRouting = createSubagentCodeRouting({ + allowEnvironmentSelection: + appConfig.endpoints?.agents?.statefulCodeSessions?.allowEnvironmentSelection, + persistedSelections: admittedConversation?.codeWorkspaces, + requestedSelections: runtimeRequestBody?.codeWorkspaces, + environments: appConfig?.endpoints?.[EModelEndpoint.agents]?.statefulCodeSessions?.environments, + userId, + conversationId, + getAppConfig, + }); + const toLazySubagentMetadata = async (agent) => { const configuredCodeEnvironments = appConfig?.endpoints?.[EModelEndpoint.agents]?.statefulCodeSessions?.environments; @@ -1198,16 +1219,22 @@ const initializeClientWithProvider = async ({ }, }); const { codeExecutionContext } = codeAvailability; - const { - alwaysApplySkillPrimes, - historicalToolNames, - historicalMcpServerNames, - skillAuthoringAvailable, - } = await lazyHistoryResolver.resolve({ - agent, - codeExecutionAvailable: lazyCodeEnvAvailable, - memoryAvailable, - }); + const [ + { + alwaysApplySkillPrimes, + historicalToolNames, + historicalMcpServerNames, + skillAuthoringAvailable, + }, + { subagentHostArgs, codeExecutionChoices }, + ] = await Promise.all([ + lazyHistoryResolver.resolve({ + agent, + codeExecutionAvailable: lazyCodeEnvAvailable, + memoryAvailable, + }), + subagentCodeRouting.describe(agent, { statefulCodeSessions, statefulCodeEnvironment }), + ]); return copyToolApprovalAdmissionMetadata( { id: agent.id, @@ -1225,6 +1252,9 @@ const initializeClientWithProvider = async ({ ...codeAvailability, statefulCodeEnvironment, codeSessionKey: codeExecutionContext?.codeSessionKey, + subagentHostArgs, + codeExecutionChoices, + subagentCodeFlags: { statefulCodeSessions, statefulCodeEnvironment }, includeReasoningHistory: getIncludeReasoningHistory(agent), alwaysApplySkillPrimes, historicalToolNames, @@ -1319,6 +1349,8 @@ const initializeClientWithProvider = async ({ configId, context, lazyChildren, + codeFlags, + codeWorkspaceUnavailable, viewAccessChecked = false, }) => { throwIfAborted(context.signal); @@ -1336,6 +1368,17 @@ const initializeClientWithProvider = async ({ if (!validation.isValid) { throw new Error(validation.error?.message ?? `Subagent ${agentId} failed model validation.`); } + const placement = await waitForAbort( + subagentCodeRouting.place({ + agent, + flags: codeFlags ?? {}, + context, + unavailableReason: codeWorkspaceUnavailable, + }), + context.signal, + ); + throwIfAborted(context.signal); + const routedAgent = placement.agent; const scopedSkillIds = resolveAgentScopedSkillIds({ agent, accessibleSkillIds, @@ -1353,7 +1396,7 @@ const initializeClientWithProvider = async ({ { req, res, - agent, + agent: routedAgent, loadTools: createToolLoader( req, res, @@ -1428,14 +1471,35 @@ const initializeClientWithProvider = async ({ if (config.userMCPAuthMap) { Object.assign(userMCPAuthMap, config.userMCPAuthMap); } - agentToolContexts.set(agentId, buildAgentToolContext({ agent, config })); + subagentCodeRouting.attach(agentToolContexts, { + agentId, + context, + placement, + codeExecutionContext: config.codeExecutionContext, + toolContext: buildAgentToolContext({ agent: routedAgent, config }), + }); endpointTokenConfigByAgentId.set(agentId, config.endpointTokenConfig); return config; }; - const initializeLazySubagent = async ({ agentId, configId, context, lazyChildren }) => { + const initializeLazySubagent = async ({ + agentId, + configId, + context, + lazyChildren, + codeFlags, + codeWorkspaceUnavailable, + }) => { throwIfAborted(context.signal); const agent = await waitForAbort(db.getAgentWithVersionCount({ id: agentId }), context.signal); - return initializeLoadedSubagent({ agent, agentId, configId, context, lazyChildren }); + return initializeLoadedSubagent({ + agent, + agentId, + configId, + context, + lazyChildren, + codeFlags, + codeWorkspaceUnavailable, + }); }; const buildLazySubagentDescriptors = async (agent, depth = 0, ancestors = new Set()) => { @@ -1509,6 +1573,8 @@ const initializeClientWithProvider = async ({ statefulCodeEnvironment: metadata.statefulCodeEnvironment, codeExecutionContext: metadata.codeExecutionContext, codeSessionKey: metadata.codeSessionKey, + subagentHostArgs: metadata.subagentHostArgs, + codeExecutionChoices: metadata.codeExecutionChoices, includeReasoningHistory: metadata.includeReasoningHistory, skillAuthoringAvailable: metadata.skillAuthoringAvailable, alwaysApplySkillPrimes: metadata.alwaysApplySkillPrimes, @@ -1517,18 +1583,23 @@ const initializeClientWithProvider = async ({ lazySubagentConfigs: lazyChildren, subagentAgentConfigs: eagerChildren, subagentGraphMemberMetadata, - ...guardUnavailableSubagent({ + ...guardRoutableSubagent({ description: metadata.description, codeWorkspaceUnavailable: metadata.codeWorkspaceUnavailable, + subagentHostArgs: metadata.subagentHostArgs, resolve: async (context) => { const config = await initializeLazySubagent({ agentId: metadata.id, configId: metadata.configId, context, lazyChildren, + codeFlags: metadata.subagentCodeFlags, + codeWorkspaceUnavailable: metadata.codeWorkspaceUnavailable, }); config.subagentAgentConfigs = eagerChildren; - graphMemberConfigsById.set(config.id, config); + if (!subagentCodeRouting.isRouted(context.executionId)) { + graphMemberConfigsById.set(config.id, config); + } await resolveGraphSubagentsFor(config, context.signal); return config; }, diff --git a/api/server/services/Endpoints/agents/initialize.spec.js b/api/server/services/Endpoints/agents/initialize.spec.js index 1b847ff1ad7..81e66741c04 100644 --- a/api/server/services/Endpoints/agents/initialize.spec.js +++ b/api/server/services/Endpoints/agents/initialize.spec.js @@ -2720,6 +2720,61 @@ describe('initializeClient — subagent loading', () => { }); }); + it('routes a reviewer to the machine its parent names and keeps it there this turn', async () => { + const req = await setup(); + const fetchSpy = mockWorkerStatus(); + try { + await initializeClient({ + req, + res: {}, + signal: new AbortController().signal, + endpointOption: makeEndpointOption(), + }); + const descriptor = agentClientArgs.agent.lazySubagentConfigs[0]; + const routedAgents = []; + mockInitializeAgent.mockImplementation(async (params) => { + routedAgents.push(params.agent); + return { + ...makeSubagentConfig(SUBAGENT_ID), + codeExecutionContext: { + environmentId: params.agent.code_environment_id, + environmentType: 'attached', + }, + }; + }); + + await descriptor.resolve({ + signal: new AbortController().signal, + executionId: 'run-routed', + hostArgs: { machine: SKYNET }, + }); + await descriptor.resolve({ + signal: new AbortController().signal, + executionId: 'run-later', + }); + await expect( + descriptor.resolve({ + signal: new AbortController().signal, + executionId: 'run-unadmitted', + hostArgs: { machine: 'code-unadmitted' }, + }), + ).rejects.toThrow(); + + expect(routedAgents).toHaveLength(2); + expect(routedAgents[0]).toMatchObject({ + id: SUBAGENT_ID, + code_environment_id: SKYNET, + code_environment_ids: [SKYNET], + }); + expect(routedAgents[1]).toMatchObject({ + code_environment_id: SKYNET, + code_environment_ids: [SKYNET], + }); + } finally { + fetchSpy.mockRestore(); + } + }); + it('keeps a reviewer that may not use the parent machine on its own default', async () => { const req = await setup({ reviewer: { code_environment_ids: [] } }); const fetchSpy = mockWorkerStatus(); diff --git a/api/server/services/Files/provisionCallback.js b/api/server/services/Files/provisionCallback.js index 328cca2d9be..0d3993b35bf 100644 --- a/api/server/services/Files/provisionCallback.js +++ b/api/server/services/Files/provisionCallback.js @@ -4,11 +4,17 @@ const db = require('~/models'); /* Wiring only: binds this workspace's provisioning service and file model to the * ON_TOOL_EXECUTE callback implemented in packages/api. */ -function createProvisionFilesCallback({ req, agentToolContexts, resolvePrimaryAgentId }) { +function createProvisionFilesCallback({ + req, + agentToolContexts, + resolvePrimaryAgentId, + resolveExecutionContext, +}) { return createCallback({ req, agentToolContexts, resolvePrimaryAgentId, + resolveExecutionContext, provisionToCodeEnv, provisionToVectorDB, updateFile: db.updateFile, diff --git a/packages/api/src/agents/__tests__/run-codeTools.test.ts b/packages/api/src/agents/__tests__/run-codeTools.test.ts index 7602d5c9aa1..a09921ab895 100644 --- a/packages/api/src/agents/__tests__/run-codeTools.test.ts +++ b/packages/api/src/agents/__tests__/run-codeTools.test.ts @@ -211,6 +211,27 @@ describe('createRun code-tool eager/session wiring', () => { expect(childInput.additional_instructions?.match(/\/mnt\/data\/data\.csv/g)).toHaveLength(1); }); + it('declares per-call machine choices only on lazy children that have them', async () => { + const resolve = jest.fn().mockResolvedValue(makeAgent({ id: 'child' })); + const subagentHostArgs = { + machine: { description: 'Machine the subagent runs on.', enum: ['laptop'] }, + }; + const config = await captureRunConfig( + makeAgent({ + subagents: { enabled: true, allowSelf: false, agent_ids: ['child', 'plain'] }, + lazySubagentConfigs: [ + { id: 'child', configId: 'child:1', resolve, subagentHostArgs }, + { id: 'plain', configId: 'plain:1', resolve }, + ], + }), + ); + const [parentInput] = (config.graphConfig as { agents: AgentInputs[] }).agents; + const [routed, plain] = parentInput.subagentConfigs ?? []; + + expect(routed).toHaveProperty('hostArgs', subagentHostArgs); + expect(plain).not.toHaveProperty('hostArgs'); + }); + it.each([false, true])( 'reconciles a lazy child’s live setup file, parent already provisioned=%s', async (parentProvisioned) => { diff --git a/packages/api/src/agents/codeFilesSession.ts b/packages/api/src/agents/codeFilesSession.ts index 7a59af3a69c..d368cd345c9 100644 --- a/packages/api/src/agents/codeFilesSession.ts +++ b/packages/api/src/agents/codeFilesSession.ts @@ -20,6 +20,8 @@ export interface CodeFilesAgent { id?: string; codeEnvAvailable?: boolean; codeExecutionContext?: CodeExecutionContext; + /** Routes a parent may place this subagent on per call; each is primed like the default. */ + codeExecutionChoices?: CodeExecutionContext[]; codeSessionKey?: string; primedCodeFiles?: CodeEnvFile[]; statefulCodeSessions?: boolean; @@ -83,14 +85,20 @@ export function collectCodeExecutionProfileRoutes( conversationId: scope.conversationId, }) : undefined); - if (agent.codeEnvAvailable === true && context) { - const routeKey = getCodeExecutionRouteKey(context); + const addRoute = (routeContext: CodeExecutionContext, codeSessionKey: string): void => { + const routeKey = getCodeExecutionRouteKey(routeContext); const route = routes.get(routeKey) ?? { - codeExecutionContext: context, + codeExecutionContext: routeContext, codeSessionKeys: new Set(), }; - route.codeSessionKeys.add(agent.codeSessionKey ?? context.codeSessionKey); + route.codeSessionKeys.add(codeSessionKey); routes.set(routeKey, route); + }; + if (agent.codeEnvAvailable === true && context) { + addRoute(context, agent.codeSessionKey ?? context.codeSessionKey); + } + for (const choice of agent.codeExecutionChoices ?? []) { + addRoute(choice, choice.codeSessionKey); } enqueueCodeFilesChildren(agent, queue, visited); } diff --git a/packages/api/src/agents/execution.ts b/packages/api/src/agents/execution.ts index 1567eacf938..2223f7d381a 100644 --- a/packages/api/src/agents/execution.ts +++ b/packages/api/src/agents/execution.ts @@ -106,8 +106,39 @@ export function getCodeWorkspaceSelections( type CodeExecutionApprovalAgent = { id?: string | null; codeExecutionContext?: CodeExecutionContext | null; + /** Alternate machines a parent may route this subagent to per call. */ + codeExecutionChoices?: readonly CodeExecutionContext[] | null; }; +function hashCodeExecutionTarget(agentId: string | null, context: CodeExecutionContext): string { + return createHash('sha256') + .update( + JSON.stringify([ + agentId, + context.executionProfile, + context.baseUrl, + context.codeSessionKey, + context.executionRouteKey ?? null, + context.runtimeSessionHint ?? null, + context.environmentId ?? null, + context.environmentType ?? null, + context.bridgeWorkerId ?? null, + context.codeWorkspace == null + ? null + : { + environmentId: context.codeWorkspace.environmentId, + workspaceId: context.codeWorkspace.workspaceId, + workspaceInstanceId: context.codeWorkspace.workspaceInstanceId ?? null, + operations: [...new Set(context.codeWorkspace.operations)].sort(), + ...(context.codeWorkspace.environment + ? { definitionFingerprint: context.codeWorkspace.environment.fingerprint } + : {}), + }, + ]), + ) + .digest('hex'); +} + const CODE_EXECUTION_TARGET_HASH = /^[a-f0-9]{64}$/; const MAX_CODE_EXECUTION_APPROVAL_TARGETS = 128; @@ -120,39 +151,28 @@ export function captureCodeExecutionApprovalBinding( agents: readonly (CodeExecutionApprovalAgent | null | undefined)[], ): Agents.CodeExecutionApprovalBinding | undefined { const targetsByIdentity = new Map(); + const addTarget = (target: Agents.CodeExecutionApprovalTargetBinding): void => { + targetsByIdentity.set(`${target.agentId ?? ''}\u0000${target.targetHash}`, target); + }; for (const agent of agents) { + const agentId = agent?.id ?? null; const context = agent?.codeExecutionContext; - if (context?.statefulSessions !== true) { - continue; + if (context?.statefulSessions === true) { + addTarget({ agentId, targetHash: hashCodeExecutionTarget(agentId, context) }); + } + /** All of an agent's per-call choices fold into one target, so a graph with many + * routable children stays within the binding's target limit. */ + const choiceHashes = (agent?.codeExecutionChoices ?? []) + .filter((choice) => choice.statefulSessions === true) + .map((choice) => hashCodeExecutionTarget(agentId, choice)); + if (choiceHashes.length > 0) { + addTarget({ + agentId, + targetHash: createHash('sha256') + .update(JSON.stringify(['choices', [...new Set(choiceHashes)].sort()])) + .digest('hex'), + }); } - const targetHash = createHash('sha256') - .update( - JSON.stringify([ - agent?.id ?? null, - context.executionProfile, - context.baseUrl, - context.codeSessionKey, - context.executionRouteKey ?? null, - context.runtimeSessionHint ?? null, - context.environmentId ?? null, - context.environmentType ?? null, - context.bridgeWorkerId ?? null, - context.codeWorkspace == null - ? null - : { - environmentId: context.codeWorkspace.environmentId, - workspaceId: context.codeWorkspace.workspaceId, - workspaceInstanceId: context.codeWorkspace.workspaceInstanceId ?? null, - operations: [...new Set(context.codeWorkspace.operations)].sort(), - ...(context.codeWorkspace.environment - ? { definitionFingerprint: context.codeWorkspace.environment.fingerprint } - : {}), - }, - ]), - ) - .digest('hex'); - const target = { agentId: agent?.id ?? null, targetHash }; - targetsByIdentity.set(`${target.agentId ?? ''}\u0000${target.targetHash}`, target); } const targets = [...targetsByIdentity.values()]; if (targets.length === 0) { diff --git a/packages/api/src/agents/files/binding.ts b/packages/api/src/agents/files/binding.ts index 239be439a41..18477d1c1db 100644 --- a/packages/api/src/agents/files/binding.ts +++ b/packages/api/src/agents/files/binding.ts @@ -20,6 +20,7 @@ type ProvisionCallback = ( names: string[], agentId?: string, signal?: AbortSignal, + context?: SubagentExecutionContext, ) => Promise; export interface ChatRunFileBindings extends RunFileHost { @@ -174,7 +175,7 @@ export function createChatRunFileBindings({ signal?: AbortSignal, context?: Parameters[1], ) => { - if (!host.session.isActive()) return fallback(names, agentId, signal); + if (!host.session.isActive()) return fallback(names, agentId, signal, context); if (!agentId || !signal) throw new Error('Shared-file provisioning requires an execution identity.'); return host.provisionPrepared(names, agentId, signal, context); diff --git a/packages/api/src/agents/hitl/byom.ts b/packages/api/src/agents/hitl/byom.ts index 4cd0c737abf..78164404fc7 100644 --- a/packages/api/src/agents/hitl/byom.ts +++ b/packages/api/src/agents/hitl/byom.ts @@ -65,6 +65,8 @@ type CodeEnvironmentPolicyAgent = { codeEnvironmentConfigSchema?: CodeEnvironmentUserConfigSchema; codeEnvironmentSettings?: CodeEnvironmentUserSettings; }; + /** Machines a parent may route this subagent to per call. */ + codeExecutionChoices?: readonly { environmentType?: string }[] | null; subagentAgentConfigs?: readonly (CodeEnvironmentPolicyAgent | null | undefined)[]; lazySubagentConfigs?: readonly (CodeEnvironmentPolicyAgent | null | undefined)[]; subagentGraphMemberMetadata?: readonly (CodeEnvironmentPolicyAgent | null | undefined)[]; @@ -189,7 +191,9 @@ export function collectAttachedCodeEnvironmentAgentIds( ): Set { const attachedAgentIds = new Set(); for (const agent of collectCodeEnvironmentPolicyAgents(roots)) { - if (agent.id && agent.codeExecutionContext?.environmentType === 'attached') { + const routable = + agent.codeExecutionChoices?.some((choice) => choice.environmentType === 'attached') === true; + if (agent.id && (agent.codeExecutionContext?.environmentType === 'attached' || routable)) { attachedAgentIds.add(agent.id); } } diff --git a/packages/api/src/agents/run.ts b/packages/api/src/agents/run.ts index 054c3e39ce1..48be916e917 100644 --- a/packages/api/src/agents/run.ts +++ b/packages/api/src/agents/run.ts @@ -63,6 +63,7 @@ import type { TerminalSteerHook } from '~/agents/steering/runtime'; import type { LangfuseTraceContext } from '~/langfuse/identity'; import type { ResolvedAlwaysApplySkill } from '~/agents/skills'; import type { CodeExecutionContext } from '~/agents/execution'; +import type { SubagentCodeHostArgSpecs } from '~/code/targets'; import type { MCPToolAlias } from '~/tools/classification'; import type { ReviewedToolApprovals } from './hitl/modes'; import type { SubagentUsageEvent } from '~/agents/usage'; @@ -529,6 +530,10 @@ type LazySubagentAgent = Pick< | 'mcpToolAliases' > & { configId: string; + /** Per-call machine choices declared on the child's subagent call. */ + subagentHostArgs?: SubagentCodeHostArgSpecs; + /** The routes behind those choices, for run-wide gates and approval bindings. */ + codeExecutionChoices?: CodeExecutionContext[]; subagentAgentConfigs?: RunAgent[]; lazySubagentConfigs?: LazySubagentAgent[]; /** Lightweight graph-member metadata used only by run-wide capability gates. */ @@ -551,6 +556,7 @@ type SubagentTreeNode = Pick< | 'includeReasoningHistory' | 'mcpToolAliases' > & { + codeExecutionChoices?: CodeExecutionContext[]; subagentAgentConfigs?: SubagentTreeNode[]; lazySubagentConfigs?: SubagentTreeNode[]; subagentGraphMemberMetadata?: SubagentTreeNode[]; @@ -1619,7 +1625,7 @@ function createLazySubagentConfig( prebuiltGraphInputs?: ReadonlyMap, onResolvedAgent?: (agent: RunAgent) => void, ): SubagentConfig { - return { + const config: SubagentConfig = { type: child.id, name: child.name ?? child.id, description: @@ -1660,6 +1666,10 @@ function createLazySubagentConfig( return childInputs; }, }; + /** Assigned rather than spread so the field typechecks against SDKs that predate it. */ + return child.subagentHostArgs == null + ? config + : Object.assign(config, { hostArgs: child.subagentHostArgs }); } function enqueueSubagentChildren( @@ -1762,7 +1772,7 @@ function anyAgentHasCodeEnv(agents: RunAgent[]): boolean { continue; } visited.add(agent.id); - if (agent.codeEnvAvailable === true) { + if (agent.codeEnvAvailable === true || (agent.codeExecutionChoices?.length ?? 0) > 0) { return true; } enqueueSubagentChildren(agent, pending, visited); diff --git a/packages/api/src/code/index.ts b/packages/api/src/code/index.ts index 45ac13a95f1..237e3a04561 100644 --- a/packages/api/src/code/index.ts +++ b/packages/api/src/code/index.ts @@ -10,3 +10,4 @@ export * from './decision'; export * from './inheritance'; export * from './errors'; export * from './instructions'; +export * from './targets'; diff --git a/packages/api/src/code/targets.integration.spec.ts b/packages/api/src/code/targets.integration.spec.ts new file mode 100644 index 00000000000..bb33a4958a0 --- /dev/null +++ b/packages/api/src/code/targets.integration.spec.ts @@ -0,0 +1,212 @@ +import mongoose, { Types } from 'mongoose'; +import { MongoMemoryServer } from 'mongodb-memory-server'; +import { createMethods, createModels, tenantStorage } from '@librechat/data-schemas'; +import type { AppConfig } from '@librechat/data-schemas'; +import type { CodeEnvironmentPrincipalContext } from './environments'; +import type { CodeEnvironmentConfig } from '~/agents/execution'; +import type { SubagentCodeAgent } from './targets'; +import { createCodeEnvironmentRegistry } from './environments'; +import { mergeAccessibleCodeEnvironments } from './config'; +import { createSubagentCodeRouting } from './targets'; + +/** + * Stands in for the SDK's `SubagentHostArgumentError` until LibreChat depends + * on an `@librechat/agents` release that exports it. + */ +jest.mock('@librechat/agents', () => { + const actual = jest.requireActual('@librechat/agents'); + class MockSubagentHostArgumentError extends Error { + constructor(name: string, reason: 'unavailable' | 'not_allowed') { + super('Subagent host argument was rejected.'); + this.name = 'SubagentHostArgumentError'; + Object.assign(this, { argument: name, rejection: reason }); + } + } + return { + ...actual, + SubagentHostArgumentError: actual.SubagentHostArgumentError ?? MockSubagentHostArgumentError, + }; +}); + +const BASE_URL = 'https://bridge.example'; +const TOKEN_ENV = 'TEST_SUBAGENT_TARGETS_INTEGRATION_TOKEN'; + +const controlPlane: CodeEnvironmentConfig = { + id: 'control-plane', + name: 'Control plane', + type: 'attached', + owner: 'deployment', + baseURL: BASE_URL, + pairing: { allowPrincipalWorkers: true, tokenEnv: TOKEN_ENV }, +}; + +const deploymentConfig = { + endpoints: { agents: { statefulCodeSessions: { environments: [controlPlane] } } }, +} as AppConfig; + +const sealed = [{ environmentId: 'danny-vm', workspaceId: 'agents' }]; +const reviewer: SubagentCodeAgent = { + id: 'agent_reviewer', + code_environment_ids: ['danny-vm'], +}; +const flags = { statefulCodeSessions: true, statefulCodeEnvironment: 'conversation' }; + +function actor(userId: Types.ObjectId): CodeEnvironmentPrincipalContext { + return { userId, role: 'USER', idOnTheSource: null }; +} + +function serveReadyWorker(): void { + jest.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { + const workerId = /\/bridge\/workers\/([^/]+)\/status$/.exec(String(input))?.[1] ?? ''; + return new Response( + JSON.stringify({ + protocolVersion: 1, + workerId, + online: true, + ready: true, + leaseExpiresInMs: 45_000, + capabilities: { + statefulWorkspace: true, + sandboxProfile: 'native-srt', + runtimes: ['bash'], + workspaceTools: { + protocolVersion: 1, + operations: ['read_file', 'execute_command'], + workspaces: [{ id: 'agents' }], + }, + }, + }), + ); + }); +} + +describe('subagent code targets against the principal-scoped machine list', () => { + let mongoServer: MongoMemoryServer; + + beforeAll(async () => { + mongoServer = await MongoMemoryServer.create(); + await mongoose.connect(mongoServer.getUri()); + createModels(mongoose); + await Promise.all(Object.values(mongoose.models).map((model) => model.init())); + }); + + afterAll(async () => { + await mongoose.disconnect(); + await mongoServer.stop(); + }); + + beforeEach(async () => { + await mongoose.connection.dropDatabase(); + await createMethods(mongoose).seedDefaultRoles(); + process.env[TOKEN_ENV] = `token-${Math.random()}`; + serveReadyWorker(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + delete process.env[TOKEN_ENV]; + }); + + async function registerMachine(ownerId: Types.ObjectId): Promise { + await createCodeEnvironmentRegistry(mongoose).register({ + actor: actor(ownerId), + environment: { + id: 'danny-vm', + name: "Danny's VM", + type: 'attached', + baseURL: BASE_URL, + controlPlaneId: 'control-plane', + workerId: 'w-danny', + }, + }); + } + + async function routingFor(userId: Types.ObjectId) { + const appConfig = await mergeAccessibleCodeEnvironments({ + appConfig: deploymentConfig, + deploymentConfig, + actor: actor(userId), + registry: createCodeEnvironmentRegistry(mongoose), + }); + return createSubagentCodeRouting({ + allowEnvironmentSelection: true, + persistedSelections: sealed, + environments: appConfig.endpoints?.agents?.statefulCodeSessions?.environments, + userId: userId.toString(), + conversationId: 'convo-1', + getAppConfig: async () => deploymentConfig, + }); + } + + it('offers and routes to a machine the requesting principal can use', async () => { + const ownerId = new Types.ObjectId(); + await registerMachine(ownerId); + const routing = await routingFor(ownerId); + + const description = await routing.describe(reviewer, flags); + const placed = await routing.place({ + agent: reviewer, + flags, + context: { executionId: 'run', hostArgs: { machine: 'danny-vm' } }, + }); + + expect(description.subagentHostArgs?.machine.enum).toEqual(['danny-vm']); + expect(description.subagentHostArgs?.workspace.enum).toEqual(['agents']); + expect(placed.agent.code_environment_id).toBe('danny-vm'); + expect(placed.target?.context).toMatchObject({ + environmentId: 'danny-vm', + bridgeWorkerId: 'w-danny', + codeWorkspace: { workspaceId: 'agents' }, + }); + }); + + it('refuses another principal the same admitted machine', async () => { + const ownerId = new Types.ObjectId(); + await registerMachine(ownerId); + const routing = await routingFor(new Types.ObjectId()); + + await expect(routing.describe(reviewer, flags)).resolves.toEqual({}); + await expect( + routing.place({ + agent: reviewer, + flags, + context: { executionId: 'run', hostArgs: { machine: 'danny-vm' } }, + }), + ).rejects.toMatchObject({ + argument: 'machine', + rejection: 'not_allowed', + }); + }); + + it('refuses a machine registered in another tenant', async () => { + const ownerId = new Types.ObjectId(); + await tenantStorage.run({ tenantId: 'tenant-a' }, () => registerMachine(ownerId)); + + await tenantStorage.run({ tenantId: 'tenant-b' }, async () => { + const routing = await routingFor(ownerId); + await expect(routing.describe(reviewer, flags)).resolves.toEqual({}); + await expect( + routing.place({ + agent: reviewer, + flags, + context: { executionId: 'run', hostArgs: { workspace: 'agents' } }, + }), + ).rejects.toMatchObject({ + argument: 'workspace', + rejection: 'not_allowed', + }); + }); + await tenantStorage.run({ tenantId: 'tenant-a' }, async () => { + const routing = await routingFor(ownerId); + await expect( + routing.place({ + agent: reviewer, + flags, + context: { executionId: 'run', hostArgs: { workspace: 'agents' } }, + }), + ).resolves.toMatchObject({ + target: { environmentId: 'danny-vm' }, + }); + }); + }); +}); diff --git a/packages/api/src/code/targets.spec.ts b/packages/api/src/code/targets.spec.ts new file mode 100644 index 00000000000..436be468154 --- /dev/null +++ b/packages/api/src/code/targets.spec.ts @@ -0,0 +1,882 @@ +import type { SubagentExecutionContext } from '@librechat/agents'; +import type { AppConfig } from '@librechat/data-schemas'; +import type { + SubagentCodeAgent, + SubagentCodeFlags, + SubagentCodeTargets, + SubagentCodeTargetParams, +} from './targets'; +import type { CodeEnvironmentConfig } from '~/agents/execution'; +import { + SUBAGENT_MACHINE_ARG, + SUBAGENT_WORKSPACE_ARG, + buildSubagentCodeHostArgs, + createSubagentCodeRouting, + guardRoutableSubagent, + getSubagentHostArgValues, + isSubagentHostArgsSupported, + placeSubagentOnCodeTarget, + resolveSubagentCodeTargets, + selectSubagentCodeTarget, +} from './targets'; +import { + resolveCodeExecutionContext, + assertCodeExecutionApprovalBinding, + captureCodeExecutionApprovalBinding, +} from '~/agents/execution'; +import { collectCodeExecutionProfileRoutes } from '~/agents/codeFilesSession'; +import { collectAttachedCodeEnvironmentAgentIds } from '~/agents/hitl/byom'; + +/** + * Stands in for the SDK's `SubagentHostArgumentError` until LibreChat depends + * on an `@librechat/agents` release that exports it; the real class wins once + * the installed SDK provides one. + */ +jest.mock('@librechat/agents', () => { + const actual = jest.requireActual('@librechat/agents'); + class MockSubagentHostArgumentError extends Error { + constructor(name: string, reason: 'unavailable' | 'not_allowed') { + super('Subagent host argument was rejected.'); + this.name = 'SubagentHostArgumentError'; + Object.assign(this, { argument: name, rejection: reason }); + } + } + return { + ...actual, + SubagentHostArgumentError: actual.SubagentHostArgumentError ?? MockSubagentHostArgumentError, + }; +}); + +const BASE_URL = 'https://bridge.example'; +const TOKEN_ENV = 'TEST_SUBAGENT_TARGETS_TOKEN'; + +const controlPlane: CodeEnvironmentConfig = { + id: 'control-plane', + name: 'Control plane', + type: 'attached', + owner: 'deployment', + baseURL: BASE_URL, + pairing: { allowPrincipalWorkers: true, tokenEnv: TOKEN_ENV }, +}; + +const machine = (id: string, workerId: string): CodeEnvironmentConfig => ({ + id, + name: `${id} machine`, + type: 'attached', + owner: 'principal', + baseURL: BASE_URL, + workerId, + controlPlaneId: 'control-plane', +}); + +const environments: CodeEnvironmentConfig[] = [ + controlPlane, + machine('laptop', 'w-laptop'), + machine('buildbox', 'w-buildbox'), + machine('spare', 'w-spare'), +]; + +const getAppConfig = jest.fn( + async () => + ({ + endpoints: { agents: { statefulCodeSessions: { environments: [controlPlane] } } }, + }) as AppConfig, +); + +const sealed = [ + { environmentId: 'buildbox', workspaceId: 'agents' }, + { environmentId: 'laptop', workspaceId: 'librechat' }, +]; + +type WorkerState = { online: boolean; workspaces: string[] }; + +function serveWorkers(workers: Record): jest.SpyInstance { + return jest.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { + const url = String(input); + const workerId = /\/bridge\/workers\/([^/]+)\/status$/.exec(url)?.[1] ?? ''; + const worker = workers[workerId]; + return new Response( + JSON.stringify({ + protocolVersion: 1, + workerId, + online: worker?.online === true, + ready: worker?.online === true, + leaseExpiresInMs: 45_000, + capabilities: { + statefulWorkspace: true, + sandboxProfile: 'native-srt', + runtimes: ['bash'], + workspaceTools: { + protocolVersion: 1, + operations: ['read_file', 'list_files', 'execute_command'], + workspaces: (worker?.workspaces ?? []).map((id) => ({ id })), + }, + }, + }), + ); + }); +} + +const allOnline: Record = { + 'w-laptop': { online: true, workspaces: ['librechat'] }, + 'w-buildbox': { online: true, workspaces: ['agents'] }, + 'w-spare': { online: true, workspaces: ['scratch'] }, +}; + +function params(overrides: Partial = {}): SubagentCodeTargetParams { + return { + agentId: 'agent_reviewer', + statefulSessions: true, + environment: 'conversation', + environmentId: 'laptop', + environmentIds: ['buildbox', 'spare'], + allowEnvironmentSelection: true, + persistedSelections: sealed, + environments, + userId: 'user-1', + conversationId: 'convo-1', + getAppConfig, + ...overrides, + }; +} + +function rejection(error: unknown): { argument?: string; rejection?: string } { + return error as { argument?: string; rejection?: string }; +} + +describe('subagent code targets', () => { + beforeEach(() => { + process.env[TOKEN_ENV] = `token-${Math.random()}`; + }); + + afterEach(() => { + jest.restoreAllMocks(); + delete process.env[TOKEN_ENV]; + }); + + it('detects SDK support for per-call host arguments', () => { + expect(isSubagentHostArgsSupported()).toBe(true); + }); + + it('lists only admitted, authorized machines with a ready worker', async () => { + serveWorkers(allOnline); + + const { targets, unavailableMachines } = await resolveSubagentCodeTargets(params()); + + expect(targets.map(({ environmentId, workspaceId }) => [environmentId, workspaceId])).toEqual([ + ['buildbox', 'agents'], + ['laptop', 'librechat'], + ]); + expect(unavailableMachines.size).toBe(0); + expect(targets[0].context).toMatchObject({ + environmentId: 'buildbox', + environmentType: 'attached', + bridgeWorkerId: 'w-buildbox', + codeWorkspace: { environmentId: 'buildbox', workspaceId: 'agents' }, + }); + }); + + it('excludes a machine outside the child allowlist', async () => { + serveWorkers(allOnline); + + const { targets } = await resolveSubagentCodeTargets(params({ environmentIds: ['spare'] })); + + expect(targets.map((target) => target.environmentId)).toEqual(['laptop']); + }); + + it('keeps only the default machine when per-chat machine choice is disabled', async () => { + serveWorkers(allOnline); + + const { targets } = await resolveSubagentCodeTargets( + params({ allowEnvironmentSelection: false }), + ); + + expect(targets.map((target) => target.environmentId)).toEqual(['laptop']); + }); + + it('excludes a machine missing from the principal’s list', async () => { + serveWorkers(allOnline); + + const { targets } = await resolveSubagentCodeTargets( + params({ environments: environments.filter((environment) => environment.id !== 'buildbox') }), + ); + + expect(targets.map((target) => target.environmentId)).toEqual(['laptop']); + }); + + it('never adds an allowed machine the conversation has not admitted', async () => { + serveWorkers(allOnline); + + const { targets } = await resolveSubagentCodeTargets(params({ environmentIds: ['spare'] })); + + expect(targets.some((target) => target.environmentId === 'spare')).toBe(false); + }); + + it('reports an authorized machine whose worker is offline as unavailable', async () => { + serveWorkers({ ...allOnline, 'w-buildbox': { online: false, workspaces: [] } }); + + const resolution = await resolveSubagentCodeTargets(params()); + + expect(resolution.targets.map((target) => target.environmentId)).toEqual(['laptop']); + expect([...resolution.unavailableMachines]).toEqual(['buildbox']); + expect([...resolution.unavailableWorkspaces]).toEqual(['agents']); + }); + + it('treats a missing sealed workspace as unavailable', async () => { + serveWorkers({ ...allOnline, 'w-buildbox': { online: true, workspaces: ['other'] } }); + + const resolution = await resolveSubagentCodeTargets(params()); + + expect([...resolution.unavailableMachines]).toEqual(['buildbox']); + }); + + it('pins a child to the machine the conversation assigned to it', async () => { + serveWorkers(allOnline); + + const { targets } = await resolveSubagentCodeTargets( + params({ + persistedSelections: [ + { environmentId: 'buildbox', workspaceId: 'agents', agentIds: ['agent_reviewer'] }, + { environmentId: 'laptop', workspaceId: 'librechat' }, + ], + }), + ); + + expect(targets.map((target) => target.environmentId)).toEqual(['buildbox']); + }); + + it('lists nothing without stateful sessions or an admitted decision', async () => { + const fetchSpy = serveWorkers(allOnline); + + await expect( + resolveSubagentCodeTargets(params({ statefulSessions: false })), + ).resolves.toMatchObject({ targets: [] }); + await expect( + resolveSubagentCodeTargets(params({ persistedSelections: [] })), + ).resolves.toMatchObject({ targets: [] }); + await expect( + resolveSubagentCodeTargets(params({ persistedSelections: 'tampered' })), + ).resolves.toMatchObject({ targets: [] }); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('prefers the sealed decision over a conflicting request', async () => { + serveWorkers(allOnline); + + const { targets, unavailableMachines } = await resolveSubagentCodeTargets( + params({ requestedSelections: [{ environmentId: 'spare', workspaceId: 'scratch' }] }), + ); + + expect(targets).toEqual([]); + expect([...unavailableMachines].sort()).toEqual(['buildbox', 'laptop']); + }); + + it('resolves the same route for the same inputs', async () => { + serveWorkers(allOnline); + + const first = await resolveSubagentCodeTargets(params()); + const second = await resolveSubagentCodeTargets(params()); + + expect(second.targets.map((target) => target.context)).toEqual( + first.targets.map((target) => target.context), + ); + expect(first.targets[0].context.codeSessionKey).toBe(second.targets[0].context.codeSessionKey); + }); +}); + +describe('subagent code host arguments', () => { + const resolution = ( + targets: Array<[string, string]>, + unavailable: Array<[string, string]> = [], + ): SubagentCodeTargets => ({ + targets: targets.map(([environmentId, workspaceId]) => ({ + environmentId, + workspaceId, + context: { + baseUrl: BASE_URL, + codeSessionKey: `session-${environmentId}`, + executionProfile: 'stateful', + statefulSessions: true, + environmentId, + environmentType: 'attached', + }, + })), + unavailableMachines: new Set(unavailable.map(([environmentId]) => environmentId)), + unavailableWorkspaces: new Set(unavailable.map(([, workspaceId]) => workspaceId)), + }); + + it('declares machine and workspace enums built only from reachable targets', () => { + const hostArgs = buildSubagentCodeHostArgs( + resolution([ + ['buildbox', 'agents'], + ['laptop', 'librechat'], + ]).targets, + ); + + expect(hostArgs?.[SUBAGENT_MACHINE_ARG].enum).toEqual(['buildbox', 'laptop']); + expect(hostArgs?.[SUBAGENT_WORKSPACE_ARG].enum).toEqual(['agents', 'librechat']); + for (const spec of Object.values(hostArgs ?? {})) { + expect(spec.description.length).toBeLessThan(1024); + } + }); + + it('omits workspace when two machines share a workspace ID, and declares nothing without targets', () => { + expect( + Object.keys( + buildSubagentCodeHostArgs( + resolution([ + ['buildbox', 'agents'], + ['laptop', 'agents'], + ]).targets, + ) ?? {}, + ), + ).toEqual([SUBAGENT_MACHINE_ARG]); + expect(buildSubagentCodeHostArgs([])).toBeUndefined(); + }); + + it('returns no target for omitted arguments', () => { + expect(selectSubagentCodeTarget(undefined, resolution([['laptop', 'librechat']]))).toBe( + undefined, + ); + expect(selectSubagentCodeTarget({}, resolution([['laptop', 'librechat']]))).toBe(undefined); + }); + + it('selects by machine, by workspace, or by a matching pair', () => { + const current = resolution([ + ['buildbox', 'agents'], + ['laptop', 'librechat'], + ]); + + expect(selectSubagentCodeTarget({ machine: 'buildbox' }, current)?.workspaceId).toBe('agents'); + expect(selectSubagentCodeTarget({ workspace: 'librechat' }, current)?.environmentId).toBe( + 'laptop', + ); + expect( + selectSubagentCodeTarget({ machine: 'laptop', workspace: 'librechat' }, current) + ?.environmentId, + ).toBe('laptop'); + }); + + it.each([ + [{ machine: 'buildbox' }, SUBAGENT_MACHINE_ARG, 'unavailable'], + [{ machine: 'spare' }, SUBAGENT_MACHINE_ARG, 'not_allowed'], + [{ machine: 'laptop', workspace: 'agents' }, SUBAGENT_WORKSPACE_ARG, 'not_allowed'], + [{ workspace: 'agents' }, SUBAGENT_WORKSPACE_ARG, 'unavailable'], + [{ workspace: 'scratch' }, SUBAGENT_WORKSPACE_ARG, 'not_allowed'], + ])('rejects %j with a %s refusal', (hostArgs, argument, expected) => { + const current = resolution([['laptop', 'librechat']], [['buildbox', 'agents']]); + let thrown: unknown; + try { + selectSubagentCodeTarget(hostArgs, current); + } catch (error) { + thrown = error; + } + expect(thrown).toBeInstanceOf(Error); + expect((thrown as Error).name).toBe('SubagentHostArgumentError'); + expect(rejection(thrown)).toMatchObject({ argument, rejection: expected }); + }); + + it('reads only string host arguments from a resolver context', () => { + expect(getSubagentHostArgValues(undefined)).toBeUndefined(); + expect( + getSubagentHostArgValues({ + executionId: 'run', + hostArgs: { machine: 'laptop', other: 3 }, + } as never), + ).toEqual({ machine: 'laptop' }); + expect(getSubagentHostArgValues({ executionId: 'run', hostArgs: {} } as never)).toBe(undefined); + }); + + it('pins the routed child document to the selected machine without mutating it', () => { + const agent = { + id: 'agent_reviewer', + code_environment_id: 'laptop', + code_environment_ids: ['buildbox', 'spare'], + }; + + const routed = placeSubagentOnCodeTarget(agent, { environmentId: 'buildbox' }); + + expect(routed).toEqual({ + id: 'agent_reviewer', + code_environment_id: 'buildbox', + code_environment_ids: ['buildbox'], + }); + expect(agent).toEqual({ + id: 'agent_reviewer', + code_environment_id: 'laptop', + code_environment_ids: ['buildbox', 'spare'], + }); + }); + + it('keeps a routed child on its machine even when its parent machine would be inherited', () => { + const routed = placeSubagentOnCodeTarget( + { id: 'agent_reviewer', code_environment_id: 'laptop', code_environment_ids: ['buildbox'] }, + { environmentId: 'buildbox' }, + ); + + const context = resolveCodeExecutionContext({ + statefulSessions: true, + environment: 'conversation', + environmentId: routed.code_environment_id, + environmentIds: routed.code_environment_ids, + allowEnvironmentSelection: true, + workspaceSelections: sealed, + inheritedEnvironments: new Map([['agent_reviewer', 'laptop']]), + environments, + userId: 'user-1', + agentId: 'agent_reviewer', + conversationId: 'convo-1', + }); + const inherited = resolveCodeExecutionContext({ + statefulSessions: true, + environment: 'conversation', + environmentId: 'spare', + environmentIds: ['laptop'], + allowEnvironmentSelection: true, + workspaceSelections: sealed, + inheritedEnvironments: new Map([['agent_reviewer', 'laptop']]), + environments, + userId: 'user-1', + agentId: 'agent_reviewer', + conversationId: 'convo-1', + }); + + expect(context.environmentId).toBe('buildbox'); + expect(inherited.environmentId).toBe('laptop'); + }); +}); + +describe('subagent code routing', () => { + const request = { + allowEnvironmentSelection: true, + persistedSelections: sealed, + environments, + userId: 'user-1', + conversationId: 'convo-1', + getAppConfig, + }; + const reviewer = { + id: 'agent_reviewer', + code_environment_id: 'laptop', + code_environment_ids: ['buildbox'], + }; + const flags = { statefulCodeSessions: true, statefulCodeEnvironment: 'conversation' }; + + beforeEach(() => { + process.env[TOKEN_ENV] = `token-${Math.random()}`; + }); + + afterEach(() => { + jest.restoreAllMocks(); + delete process.env[TOKEN_ENV]; + }); + + it('describes per-child choices and their approval targets', async () => { + serveWorkers(allOnline); + const routing = createSubagentCodeRouting(request); + + const description = await routing.describe(reviewer, flags); + const lone = await routing.describe( + { id: 'agent_writer', code_environment_id: 'laptop' }, + flags, + ); + const stateless = await routing.describe(reviewer, { statefulCodeSessions: false }); + + expect(description.subagentHostArgs?.machine.enum).toEqual(['buildbox', 'laptop']); + expect(description.codeExecutionChoices?.map((context) => context.environmentId)).toEqual([ + 'buildbox', + 'laptop', + ]); + expect(lone.subagentHostArgs?.machine.enum).toEqual(['laptop']); + expect(stateless).toEqual({}); + }); + + const call = (hostArgs?: Record, parentRunId?: string) => ({ + executionId: `run-${Math.random()}`, + ...(parentRunId == null ? {} : { parentRunId }), + ...(hostArgs == null ? {} : { hostArgs }), + }); + + it('re-validates on use and routes only to a currently reachable machine', async () => { + const routing = createSubagentCodeRouting(request); + serveWorkers(allOnline); + + const placed = await routing.place({ + agent: reviewer, + flags, + context: call({ machine: 'buildbox' }), + }); + const omitted = await createSubagentCodeRouting(request).place({ + agent: reviewer, + flags, + context: call(), + }); + + expect(placed.agent.code_environment_id).toBe('buildbox'); + expect(placed.target?.context.bridgeWorkerId).toBe('w-buildbox'); + expect(omitted).toEqual({ agent: reviewer }); + + jest.restoreAllMocks(); + process.env[TOKEN_ENV] = `token-${Math.random()}`; + serveWorkers({ ...allOnline, 'w-buildbox': { online: false, workspaces: [] } }); + await expect( + routing.place({ agent: reviewer, flags, context: call({ machine: 'buildbox' }) }), + ).rejects.toMatchObject({ argument: 'machine', rejection: 'unavailable' }); + }); + + it('keeps one machine per subagent for the whole request', async () => { + serveWorkers(allOnline); + const routing = createSubagentCodeRouting(request); + const contexts = new Map(); + + const first = await routing.place({ + agent: reviewer, + flags, + context: call({ machine: 'buildbox' }), + }); + const later = await routing.place({ agent: reviewer, flags, context: call() }); + const moved = routing.place({ agent: reviewer, flags, context: call({ machine: 'laptop' }) }); + + const writer = { + id: 'agent_writer', + code_environment_id: 'laptop', + code_environment_ids: ['buildbox'], + }; + const writerCall = call(); + const writerPlacement = await routing.place({ agent: writer, flags, context: writerCall }); + routing.attach(contexts, { + agentId: writer.id, + context: writerCall, + placement: writerPlacement, + codeExecutionContext: { environmentId: 'laptop' }, + toolContext: 'laptop', + }); + const writerMoved = routing.place({ + agent: writer, + flags, + context: call({ machine: 'buildbox' }), + }); + const writerSame = await routing.place({ + agent: writer, + flags, + context: call({ machine: 'laptop' }), + }); + + expect(first.target?.environmentId).toBe('buildbox'); + expect(later.target?.environmentId).toBe('buildbox'); + await expect(moved).rejects.toMatchObject({ argument: 'machine', rejection: 'unavailable' }); + expect(writerPlacement.target).toBeUndefined(); + await expect(writerMoved).rejects.toMatchObject({ + argument: 'machine', + rejection: 'unavailable', + }); + expect(writerSame.target?.environmentId).toBe('laptop'); + }); + + it('refuses an unavailable default only when no per-call route applies', async () => { + serveWorkers(allOnline); + const routing = createSubagentCodeRouting(request); + + await expect( + routing.place({ agent: reviewer, flags, context: call(), unavailableReason: 'missing' }), + ).rejects.toMatchObject({ name: 'CodeWorkspaceSelectionError', reason: 'missing' }); + await expect( + routing.place({ + agent: reviewer, + flags, + context: call({ machine: 'buildbox' }), + unavailableReason: 'missing', + }), + ).resolves.toMatchObject({ target: { environmentId: 'buildbox' } }); + }); + + it('passes a routed parent machine to its children, through agents without code', async () => { + serveWorkers(allOnline); + const routing = createSubagentCodeRouting(request); + const contexts = new Map(); + const routeAndAttach = async ( + agent: SubagentCodeAgent, + context: ReturnType, + agentFlags: SubagentCodeFlags = flags, + ) => { + const placement = await routing.place({ agent, flags: agentFlags, context }); + routing.attach(contexts, { + agentId: agent.id, + context, + placement, + codeExecutionContext: placement.target?.context, + toolContext: placement.agent.code_environment_id ?? 'default', + }); + return placement; + }; + + const parentCall = call({ machine: 'buildbox' }); + await routeAndAttach(reviewer, parentCall); + const writerCall = call(undefined, parentCall.executionId); + const writer = await routeAndAttach( + { id: 'agent_writer', code_environment_id: 'laptop' }, + writerCall, + { statefulCodeSessions: false }, + ); + const grandchild = await routeAndAttach( + { id: 'agent_tester', code_environment_id: 'laptop', code_environment_ids: ['buildbox'] }, + call(undefined, writerCall.executionId), + ); + const unreachable = await routing.place({ + agent: { id: 'agent_docs', code_environment_id: 'laptop' }, + flags, + context: call(undefined, parentCall.executionId), + }); + const explicit = await routing.place({ + agent: { + id: 'agent_lint', + code_environment_id: 'laptop', + code_environment_ids: ['buildbox'], + }, + flags, + context: call({ machine: 'laptop' }, parentCall.executionId), + }); + + expect(writer).toEqual({ + agent: { id: 'agent_writer', code_environment_id: 'laptop' }, + childEnvironmentId: 'buildbox', + }); + expect(grandchild.target?.environmentId).toBe('buildbox'); + expect(grandchild.agent.code_environment_ids).toEqual(['buildbox']); + expect(unreachable.target).toBeUndefined(); + expect(explicit.target?.environmentId).toBe('laptop'); + }); + + it('rejects a revoked allowlist entry and a principal that lost the machine', async () => { + serveWorkers(allOnline); + + await expect( + createSubagentCodeRouting(request).place({ + agent: { ...reviewer, code_environment_ids: [] }, + flags, + context: call({ machine: 'buildbox' }), + }), + ).rejects.toMatchObject({ argument: 'machine', rejection: 'not_allowed' }); + await expect( + createSubagentCodeRouting({ + ...request, + environments: environments.filter((environment) => environment.id !== 'buildbox'), + }).place({ agent: reviewer, flags, context: call({ machine: 'buildbox' }) }), + ).rejects.toMatchObject({ argument: 'machine', rejection: 'not_allowed' }); + }); + + it('binds every per-call choice into paused-approval targets', async () => { + serveWorkers(allOnline); + const description = await createSubagentCodeRouting(request).describe(reviewer, flags); + const descriptor = { + id: reviewer.id, + codeExecutionContext: description.codeExecutionChoices?.[1], + codeExecutionChoices: description.codeExecutionChoices, + }; + + const binding = captureCodeExecutionApprovalBinding([descriptor]); + const changedChoice = { + ...descriptor, + codeExecutionChoices: [ + { ...description.codeExecutionChoices![0], bridgeWorkerId: 'w-replaced' }, + description.codeExecutionChoices![1], + ], + }; + + expect(binding?.targets).toHaveLength(2); + expect(() => assertCodeExecutionApprovalBinding(binding, [descriptor])).not.toThrow(); + expect(() => assertCodeExecutionApprovalBinding(binding, [changedChoice])).toThrow( + 'The attached code environment changed', + ); + expect( + captureCodeExecutionApprovalBinding([ + { id: reviewer.id, codeExecutionContext: descriptor.codeExecutionContext }, + ])?.targets, + ).toHaveLength(1); + }); + + it('folds many per-call choices into one approval target per agent', () => { + const choice = (environmentId: string) => ({ + baseUrl: BASE_URL, + codeSessionKey: `session-${environmentId}`, + executionProfile: 'stateful' as const, + statefulSessions: true, + environmentId, + environmentType: 'attached' as const, + }); + const agents = Array.from({ length: 5 }, (_, agentIndex) => ({ + id: `agent_${agentIndex}`, + codeExecutionChoices: Array.from({ length: 32 }, (_, index) => choice(`m${index}`)), + })); + + const binding = captureCodeExecutionApprovalBinding(agents); + + expect(binding?.targets).toHaveLength(5); + expect(() => assertCodeExecutionApprovalBinding(binding, agents)).not.toThrow(); + }); +}); + +describe('unavailable subagents with per-call choices', () => { + const hostArgs = { machine: { description: 'Machine.', enum: ['buildbox'] } }; + + it('keeps a routable subagent callable and tells the parent to pick a machine', async () => { + const resolve = jest.fn(async () => 'config'); + + const guarded = guardRoutableSubagent({ + description: 'Reviews PRs.', + codeWorkspaceUnavailable: 'missing', + subagentHostArgs: hostArgs, + resolve, + }); + + expect(guarded.description).toContain('Unavailable in this conversation'); + expect(guarded.description).toContain('Pass "machine"'); + await expect(guarded.resolve({})).resolves.toBe('config'); + }); + + it('guards a subagent without choices exactly as before', async () => { + const resolve = jest.fn(async () => 'config'); + + const guarded = guardRoutableSubagent({ + description: 'Reviews PRs.', + codeWorkspaceUnavailable: 'missing', + resolve, + }); + const available = guardRoutableSubagent({ + description: 'Reviews PRs.', + subagentHostArgs: hostArgs, + resolve, + }); + + await expect(guarded.resolve({})).rejects.toMatchObject({ reason: 'missing' }); + expect(guarded.description).not.toContain('Pass "machine"'); + expect(resolve).not.toHaveBeenCalled(); + expect(available).toEqual({ description: 'Reviews PRs.', resolve }); + }); +}); + +describe('run-wide gates for per-call choices', () => { + const choice = { + baseUrl: BASE_URL, + codeSessionKey: 'execute_code:stateful:buildbox-session', + executionProfile: 'stateful' as const, + executionRouteKey: 'stateful:buildbox', + statefulSessions: true, + environmentId: 'buildbox', + environmentType: 'attached' as const, + }; + const parent = { + id: 'agent_parent', + lazySubagentConfigs: [ + { id: 'agent_reviewer', codeEnvAvailable: false, codeExecutionChoices: [choice] }, + ], + }; + + it('installs the attached-machine policy for a child that can only be routed there', () => { + expect([...collectAttachedCodeEnvironmentAgentIds([parent])]).toEqual(['agent_reviewer']); + }); + + it('primes skill files for every per-call route', () => { + expect(collectCodeExecutionProfileRoutes([parent])).toEqual([ + { codeExecutionContext: choice, codeSessionKeys: [choice.codeSessionKey] }, + ]); + }); +}); + +describe('subagent tool contexts', () => { + const executionContext = (...entries: Array<[string, string]>): SubagentExecutionContext => ({ + rootRunId: 'root', + hookSessionId: 'hooks', + depth: entries.length, + ancestry: entries.map(([subagentRunId, subagentAgentId]) => ({ + subagentRunId, + subagentType: subagentAgentId, + subagentKind: 'agent' as const, + subagentAgentId, + parentRunId: 'root', + })), + }); + const target = (environmentId: string) => ({ + environmentId, + workspaceId: `${environmentId}-workspace`, + context: { + baseUrl: BASE_URL, + codeSessionKey: environmentId, + executionProfile: 'stateful' as const, + statefulSessions: true, + environmentId, + }, + }); + const agent = { id: 'agent_reviewer' }; + const attach = ( + routing: ReturnType>, + contexts: Map, + executionId: string, + machine?: string, + ) => + routing.attach(contexts, { + agentId: agent.id, + context: { executionId }, + placement: machine == null ? { agent } : { agent, target: target(machine) }, + codeExecutionContext: { environmentId: machine ?? 'laptop' }, + toolContext: machine ?? 'laptop', + }); + + it('keeps concurrent siblings of one agent on their own routes', () => { + const routing = createSubagentCodeRouting({}); + const contexts = new Map(); + + attach(routing, contexts, 'run-a', 'buildbox'); + attach(routing, contexts, 'run-b'); + attach(routing, contexts, 'run-c', 'spare'); + + expect(routing.getToolContext(agent.id, executionContext(['run-a', agent.id]))).toBe( + 'buildbox', + ); + expect(routing.getToolContext(agent.id, executionContext(['run-c', agent.id]))).toBe('spare'); + expect(routing.getToolContext(agent.id, executionContext(['run-b', agent.id]))).toBe(undefined); + expect(contexts.get(agent.id)).toBe('laptop'); + expect(routing.isRouted('run-a')).toBe(true); + expect(routing.isRouted('run-b')).toBe(false); + }); + + it('matches the innermost execution of the asking agent only', () => { + const routing = createSubagentCodeRouting({}); + attach(routing, new Map(), 'run-parent', 'buildbox'); + + expect( + routing.getToolContext( + 'agent_helper', + executionContext(['run-parent', agent.id], ['run-child', 'agent_helper']), + ), + ).toBeUndefined(); + expect(routing.getToolContext(agent.id, undefined)).toBeUndefined(); + expect( + routing.getToolContext('agent_other', executionContext(['run-parent', agent.id])), + ).toBeUndefined(); + }); + + it('seeds the per-agent entry from a routed child only when none exists', () => { + const routing = createSubagentCodeRouting({}); + const contexts = new Map(); + + attach(routing, contexts, 'run-a', 'buildbox'); + + expect(contexts.get(agent.id)).toBe('buildbox'); + }); + + it('fails closed when initialization did not land on the routed machine', () => { + const routing = createSubagentCodeRouting({}); + + expect(() => + routing.attach(new Map(), { + agentId: agent.id, + context: { executionId: 'run-a' }, + placement: { agent, target: target('buildbox') }, + codeExecutionContext: { environmentId: 'laptop' }, + toolContext: 'laptop', + }), + ).toThrow(); + expect(routing.isRouted('run-a')).toBe(false); + }); +}); diff --git a/packages/api/src/code/targets.ts b/packages/api/src/code/targets.ts new file mode 100644 index 00000000000..19dc06b6bd7 --- /dev/null +++ b/packages/api/src/code/targets.ts @@ -0,0 +1,605 @@ +import * as agentsSdk from '@librechat/agents'; +import { isCodeWorkspaceSelections, stripAgentIdSuffix } from 'librechat-data-provider'; +import type { + CodeWorkspaceSelection, + StatefulCodeEnvironment, + CodeWorkspaceSelectionErrorReason, +} from 'librechat-data-provider'; +import type { SubagentExecutionContext } from '@librechat/agents'; +import type { CodeEnvironmentConfig, CodeExecutionContext } from '~/agents/execution'; +import type { CodeCapabilityConfigLoader } from './capabilities'; +import { + resolveCodeExecutionContext, + isExecutableAttachedEnvironment, + resolveAgentCodeEnvironmentRouting, +} from '~/agents/execution'; +import { CodeWorkspaceSelectionError, describeCodeWorkspaceUnavailableSubagent } from './errors'; +import { resolveCodeExecutionWorkspaceContext } from './capabilities'; +import { guardUnavailableSubagent } from '~/agents/lazySubagents'; +import { isCodeEnvironmentSelectionEnabled } from './protocol'; + +/** Subagent call property naming the attached machine the child runs on. */ +export const SUBAGENT_MACHINE_ARG = 'machine'; +/** Subagent call property naming the workspace the child opens on that machine. */ +export const SUBAGENT_WORKSPACE_ARG = 'workspace'; + +const MACHINE_DESCRIPTION = + 'ID of the attached code machine the subagent runs on. Pass the machine your own workspace tools use when the subagent must see the same files; omit it to let the subagent use its default placement.'; +const WORKSPACE_DESCRIPTION = + 'ID of the workspace the subagent opens. Each machine has one workspace in this conversation, so this is an alternative to machine; when both are passed they must belong together.'; + +/** + * Host argument declaration accepted by `@librechat/agents` subagent configs + * (`SubagentConfig.hostArgs`, added after 4.0.2). Declared locally so this + * package typechecks against older SDKs, which ignore the field. + */ +export interface SubagentCodeHostArgSpec { + description: string; + enum: string[]; +} + +export type SubagentCodeHostArgSpecs = Record; + +/** Validated per-call host argument values delivered to a lazy resolver. */ +export type SubagentHostArgValues = Readonly>; + +type HostArgumentRejection = 'unavailable' | 'not_allowed'; +type HostArgumentErrorConstructor = new ( + argument: string, + rejection: HostArgumentRejection, +) => Error; + +function getHostArgumentErrorConstructor(): HostArgumentErrorConstructor | undefined { + const candidate = (agentsSdk as { SubagentHostArgumentError?: HostArgumentErrorConstructor }) + .SubagentHostArgumentError; + return typeof candidate === 'function' ? candidate : undefined; +} + +/** Whether the installed SDK accepts per-call subagent host arguments. */ +export function isSubagentHostArgsSupported(): boolean { + return getHostArgumentErrorConstructor() != null; +} + +/** + * A refusal the SDK turns into a fixed, model-visible message naming the + * argument. The value and the reason detail never reach the model. + */ +export function createSubagentHostArgumentError( + argument: string, + rejection: HostArgumentRejection, +): Error { + const HostArgumentError = getHostArgumentErrorConstructor(); + return HostArgumentError == null + ? new Error(`Subagent host argument "${argument}" was rejected.`) + : new HostArgumentError(argument, rejection); +} + +/** Reads the host argument values the SDK attached to a resolver call. */ +export function getSubagentHostArgValues( + context: object | null | undefined, +): SubagentHostArgValues | undefined { + const values = (context as { hostArgs?: unknown } | null | undefined)?.hostArgs; + if (values == null || typeof values !== 'object' || Array.isArray(values)) { + return undefined; + } + const entries = Object.entries(values).filter( + (entry): entry is [string, string] => typeof entry[1] === 'string', + ); + return entries.length === 0 ? undefined : Object.fromEntries(entries); +} + +/** One reachable machine for a subagent, with its fully resolved live route. */ +export interface SubagentCodeTarget { + environmentId: string; + workspaceId: string; + context: CodeExecutionContext; +} + +export interface SubagentCodeTargets { + targets: SubagentCodeTarget[]; + /** Authorized machines whose worker or workspace failed live checks. */ + unavailableMachines: ReadonlySet; + /** Workspaces of those machines. */ + unavailableWorkspaces: ReadonlySet; +} + +export interface SubagentCodeTargetParams { + agentId: string; + /** Whether this child runs stateful code sessions at all in this request. */ + statefulSessions: boolean; + environment?: StatefulCodeEnvironment | string | null; + /** The child's saved default machine. */ + environmentId?: string | null; + /** The child's saved machine allowlist. */ + environmentIds?: readonly string[]; + allowEnvironmentSelection?: boolean; + /** The conversation's sealed decision; authoritative over the request. */ + persistedSelections?: unknown; + requestedSelections?: unknown; + /** The requesting principal's tenant-scoped machine list. */ + environments?: readonly CodeEnvironmentConfig[]; + userId?: string | null; + conversationId?: string | null; + getAppConfig?: CodeCapabilityConfigLoader; +} + +const NO_TARGETS: SubagentCodeTargets = Object.freeze({ + targets: [], + unavailableMachines: new Set(), + unavailableWorkspaces: new Set(), +}); + +function getDefaultEnvironmentId(params: SubagentCodeTargetParams): string | undefined { + return ( + params.environmentId ?? + resolveAgentCodeEnvironmentRouting({ environments: params.environments }).defaultEnvironment?.id + ); +} + +/** + * Admitted selections this child may be routed to: its own sealed choice when + * the conversation pinned one to it, otherwise every admitted machine that is + * its default or on its allowlist and that the principal can use. + */ +function getAuthorizedSelections( + params: SubagentCodeTargetParams, + selections: CodeWorkspaceSelection[], +): CodeWorkspaceSelection[] { + const stableAgentId = stripAgentIdSuffix(params.agentId); + const owned = selections.find((selection) => selection.agentIds?.includes(stableAgentId)); + const pool = owned == null ? selections : [owned]; + const defaultId = getDefaultEnvironmentId(params); + const allowlist = isCodeEnvironmentSelectionEnabled(params.allowEnvironmentSelection) + ? new Set(params.environmentIds ?? []) + : new Set(); + return pool.filter( + ({ environmentId }) => + (environmentId === defaultId || allowlist.has(environmentId)) && + isExecutableAttachedEnvironment(environmentId, params.environments), + ); +} + +type CandidateResult = + | { status: 'ready'; target: SubagentCodeTarget } + | { status: 'unavailable'; selection: CodeWorkspaceSelection } + | { status: 'unauthorized' }; + +async function resolveCandidate( + params: SubagentCodeTargetParams, + selections: CodeWorkspaceSelection[], + selection: CodeWorkspaceSelection, +): Promise { + let base: CodeExecutionContext; + try { + base = resolveCodeExecutionContext({ + statefulSessions: true, + environment: params.environment, + environmentId: selection.environmentId, + environmentIds: params.environmentIds, + allowEnvironmentSelection: params.allowEnvironmentSelection, + workspaceSelections: selections, + environments: params.environments, + userId: params.userId, + agentId: params.agentId, + conversationId: params.conversationId, + }); + } catch { + return { status: 'unauthorized' }; + } + if (base.environmentId !== selection.environmentId || base.environmentType !== 'attached') { + return { status: 'unauthorized' }; + } + try { + const context = await resolveCodeExecutionWorkspaceContext({ + context: base, + requestedSelections: params.requestedSelections, + persistedSelections: params.persistedSelections, + environments: params.environments, + getAppConfig: params.getAppConfig, + }); + if (context.codeWorkspace?.workspaceId !== selection.workspaceId) { + return { status: 'unavailable', selection }; + } + return { + status: 'ready', + target: { + environmentId: selection.environmentId, + workspaceId: selection.workspaceId, + context, + }, + }; + } catch { + return { status: 'unavailable', selection }; + } +} + +/** + * Lists the machines a parent may route this child to: admitted in the + * conversation's sealed decision, reachable by the requesting principal, on + * the child's default or allowlist, and live on a ready worker. Never adds a + * machine the conversation has not admitted. + */ +export async function resolveSubagentCodeTargets( + params: SubagentCodeTargetParams, +): Promise { + if (!params.statefulSessions) { + return NO_TARGETS; + } + const selections = params.persistedSelections ?? params.requestedSelections; + if (!isCodeWorkspaceSelections(selections) || selections.length === 0) { + return NO_TARGETS; + } + const authorized = getAuthorizedSelections(params, selections); + if (authorized.length === 0) { + return NO_TARGETS; + } + const results = await Promise.all( + authorized.map((selection) => resolveCandidate(params, selections, selection)), + ); + const targets: SubagentCodeTarget[] = []; + const unavailableMachines = new Set(); + const unavailableWorkspaces = new Set(); + for (const result of results) { + if (result.status === 'ready') { + targets.push(result.target); + } else if (result.status === 'unavailable') { + unavailableMachines.add(result.selection.environmentId); + unavailableWorkspaces.add(result.selection.workspaceId); + } + } + targets.sort((left, right) => (left.environmentId < right.environmentId ? -1 : 1)); + return { targets, unavailableMachines, unavailableWorkspaces }; +} + +/** + * Declares `machine` (and `workspace` when each machine's workspace is + * distinct) as enums of exactly the reachable targets. Returns nothing when + * the child has no reachable machine, leaving its call schema unchanged. + */ +export function buildSubagentCodeHostArgs( + targets: readonly SubagentCodeTarget[], +): SubagentCodeHostArgSpecs | undefined { + if (targets.length === 0) { + return undefined; + } + const workspaces = targets.map((target) => target.workspaceId); + const distinctWorkspaces = new Set(workspaces).size === workspaces.length; + return { + [SUBAGENT_MACHINE_ARG]: { + description: MACHINE_DESCRIPTION, + enum: targets.map((target) => target.environmentId), + }, + ...(distinctWorkspaces + ? { [SUBAGENT_WORKSPACE_ARG]: { description: WORKSPACE_DESCRIPTION, enum: workspaces } } + : {}), + }; +} + +/** + * Picks the target a call asked for, or `undefined` when it asked for none. + * Throws an SDK host-argument refusal for anything not currently reachable, + * so the parent sees which argument failed instead of a silent fallback. + */ +export function selectSubagentCodeTarget( + hostArgs: SubagentHostArgValues | undefined, + resolution: SubagentCodeTargets, +): SubagentCodeTarget | undefined { + const machine = hostArgs?.[SUBAGENT_MACHINE_ARG]; + const workspace = hostArgs?.[SUBAGENT_WORKSPACE_ARG]; + if (machine == null && workspace == null) { + return undefined; + } + if (machine != null) { + const target = resolution.targets.find((candidate) => candidate.environmentId === machine); + if (target == null) { + throw createSubagentHostArgumentError( + SUBAGENT_MACHINE_ARG, + resolution.unavailableMachines.has(machine) ? 'unavailable' : 'not_allowed', + ); + } + if (workspace != null && target.workspaceId !== workspace) { + throw createSubagentHostArgumentError(SUBAGENT_WORKSPACE_ARG, 'not_allowed'); + } + return target; + } + const matches = resolution.targets.filter((candidate) => candidate.workspaceId === workspace); + if (matches.length === 1) { + return matches[0]; + } + throw createSubagentHostArgumentError( + SUBAGENT_WORKSPACE_ARG, + matches.length === 0 && workspace != null && resolution.unavailableWorkspaces.has(workspace) + ? 'unavailable' + : 'not_allowed', + ); +} + +/** + * Routes a child agent document to the selected machine without mutating it. + * The machine becomes its default and its only allowlisted choice, so neither + * parent inheritance nor another admitted selection can move it elsewhere at + * any routing site that reads this document. + */ +export function placeSubagentOnCodeTarget< + T extends { code_environment_id?: string | null; code_environment_ids?: string[] | null }, +>(agent: T, target: Pick): T { + return { + ...agent, + code_environment_id: target.environmentId, + code_environment_ids: [target.environmentId], + }; +} + +/** Fails closed when initialization did not land on the selected route. */ +export function assertSubagentCodePlacement( + context: Pick | null | undefined, + target: Pick, +): void { + if (context?.environmentId !== target.environmentId) { + throw createSubagentHostArgumentError(SUBAGENT_MACHINE_ARG, 'unavailable'); + } +} + +/** + * Lists a lazy subagent whose default machine has no usable workspace. Without + * per-call choices it stays guarded exactly as before; with them its resolver + * stays live, the description tells the parent to pick a listed machine, and + * `SubagentCodeRouting.place` still refuses a call that names none. + */ +export function guardRoutableSubagent({ + description, + codeWorkspaceUnavailable, + subagentHostArgs, + resolve, +}: { + description?: string; + codeWorkspaceUnavailable?: CodeWorkspaceSelectionErrorReason; + subagentHostArgs?: SubagentCodeHostArgSpecs; + resolve: (context: TContext) => Promise; +}): { description?: string; resolve: (context: TContext) => Promise } { + if (!codeWorkspaceUnavailable || subagentHostArgs == null) { + return guardUnavailableSubagent({ description, codeWorkspaceUnavailable, resolve }); + } + return { + description: `${describeCodeWorkspaceUnavailableSubagent( + description, + codeWorkspaceUnavailable, + )} Pass "${SUBAGENT_MACHINE_ARG}" to run it on one of its listed machines.`, + resolve, + }; +} + +/** The saved child fields that decide its code route. */ +export interface SubagentCodeAgent { + id: string; + code_environment_id?: string | null; + code_environment_ids?: string[] | null; +} + +/** Child code flags the host already resolved for this request. */ +export interface SubagentCodeFlags { + statefulCodeSessions?: boolean; + statefulCodeEnvironment?: StatefulCodeEnvironment | string | null; +} + +/** Request-level inputs shared by every child of one parent request. */ +export type SubagentCodeRequest = Omit< + SubagentCodeTargetParams, + 'agentId' | 'statefulSessions' | 'environment' | 'environmentId' | 'environmentIds' +>; + +export interface SubagentCodeDescription { + /** `SubagentConfig.hostArgs` for the child's lazy descriptor. */ + subagentHostArgs?: SubagentCodeHostArgSpecs; + /** Alternate routes covered by paused-approval bindings. */ + codeExecutionChoices?: CodeExecutionContext[]; +} + +export interface SubagentCodePlacement { + agent: T; + /** The machine this execution was routed to per call. */ + target?: SubagentCodeTarget; + /** The per-call route this execution's own subagents inherit. */ + childEnvironmentId?: string; +} + +/** The parts of an SDK resolver context that routing reads. */ +export interface SubagentCodeCallContext { + /** This child execution; its own subagents name it as their `parentRunId`. */ + executionId?: string; + parentRunId?: string; + hostArgs?: SubagentHostArgValues; +} + +/** What one lazy child resolution needs to know about its call and parent. */ +export interface SubagentCodePlacementInput { + agent: T; + flags: SubagentCodeFlags; + /** The SDK resolver context; graph members initialized outside a call pass only a signal. */ + context?: SubagentCodeCallContext | null; + /** Why the child's default route is unusable this request, if it is. */ + unavailableReason?: CodeWorkspaceSelectionErrorReason; +} + +/** + * Request-scoped routing for subagents a parent may place per call. Owns the + * per-execution state, so the legacy initializer only wires it in. + */ +export interface SubagentCodeRouting { + /** Builds a child's per-call machine choices; empty on SDKs without host arguments. */ + describe(agent: SubagentCodeAgent, flags: SubagentCodeFlags): Promise; + /** + * Routes one child execution: an explicit call choice (re-validated against + * the current request), else the machine a per-call-routed parent runs on + * when this child may reach it, else the child's default route. A child whose + * default route is unavailable is refused unless one of those applies. + */ + place( + input: SubagentCodePlacementInput, + ): Promise>; + /** + * Stores a resolved child's tool context. A routed child is kept per + * execution and only seeds the per-agent entry when none exists, so it never + * replaces the route a default-placed sibling is using. + */ + attach( + contexts: Map, + input: { + agentId: string; + context?: Pick | null; + placement: SubagentCodePlacement; + codeExecutionContext?: Pick | null; + toolContext: TContext; + }, + ): void; + /** The routed tool context for the executing child, if it was routed per call. */ + getToolContext( + agentId: string | null | undefined, + executionContext: SubagentExecutionContext | null | undefined, + ): TContext | undefined; + /** Whether an execution was routed per call (its config must not be shared). */ + isRouted(executionId: string | null | undefined): boolean; +} + +export function createSubagentCodeRouting( + request: SubagentCodeRequest, +): SubagentCodeRouting { + const routedContexts = new Map(); + const childRoutes = new Map(); + /** + * One machine per subagent per request, as with parent inheritance: run-wide + * state such as the attached-machine permission policy is keyed by agent, so + * no call may move a subagent off the machine an earlier call settled on. + */ + const routeByAgent = new Map(); + const conflicts = (agentId: string, environmentId: string | null): boolean => { + const claimed = routeByAgent.get(agentId); + return claimed != null && claimed.environmentId !== environmentId; + }; + const paramsFor = ( + agent: SubagentCodeAgent, + flags: SubagentCodeFlags, + ): SubagentCodeTargetParams => ({ + ...request, + agentId: agent.id, + statefulSessions: flags.statefulCodeSessions === true, + environment: flags.statefulCodeEnvironment, + environmentId: agent.code_environment_id, + environmentIds: agent.code_environment_ids ?? undefined, + }); + const routeTo = ( + agent: T, + target: SubagentCodeTarget, + ): SubagentCodePlacement => { + routeByAgent.set(agent.id, { environmentId: target.environmentId, routed: true }); + return { + agent: placeSubagentOnCodeTarget(agent, target), + target, + childEnvironmentId: target.environmentId, + }; + }; + /** The machine an omitted call should follow: this subagent's earlier per-call + * route, else its routed parent's machine unless that would move it. */ + const inheritedRoute = (agentId: string, parentEnvironmentId?: string): string | undefined => { + const claimed = routeByAgent.get(agentId); + if (claimed?.routed === true && claimed.environmentId != null) { + return claimed.environmentId; + } + if (parentEnvironmentId == null || conflicts(agentId, parentEnvironmentId)) { + return undefined; + } + return parentEnvironmentId; + }; + return { + async describe(agent, flags) { + if (!isSubagentHostArgsSupported() || flags.statefulCodeSessions !== true) { + return {}; + } + const { targets } = await resolveSubagentCodeTargets(paramsFor(agent, flags)); + const subagentHostArgs = buildSubagentCodeHostArgs(targets); + return subagentHostArgs == null + ? {} + : { subagentHostArgs, codeExecutionChoices: targets.map((target) => target.context) }; + }, + async place({ agent, flags, context, unavailableReason }) { + const hostArgs = getSubagentHostArgValues(context); + const parentEnvironmentId = context?.parentRunId + ? childRoutes.get(context.parentRunId) + : undefined; + const requested = + hostArgs?.[SUBAGENT_MACHINE_ARG] != null || hostArgs?.[SUBAGENT_WORKSPACE_ARG] != null; + if (requested) { + const resolution = await resolveSubagentCodeTargets(paramsFor(agent, flags)); + const target = selectSubagentCodeTarget(hostArgs, resolution); + if (target != null) { + if (conflicts(agent.id, target.environmentId)) { + throw createSubagentHostArgumentError(SUBAGENT_MACHINE_ARG, 'unavailable'); + } + return routeTo(agent, target); + } + } + if (flags.statefulCodeSessions !== true) { + /** An agent that runs no stateful code passes its parent's machine on. */ + return parentEnvironmentId == null + ? { agent } + : { agent, childEnvironmentId: parentEnvironmentId }; + } + const inherited = inheritedRoute(agent.id, parentEnvironmentId); + if (inherited != null) { + const { targets } = await resolveSubagentCodeTargets(paramsFor(agent, flags)); + const target = targets.find((candidate) => candidate.environmentId === inherited); + if (target != null) { + return routeTo(agent, target); + } + if (routeByAgent.get(agent.id)?.routed === true) { + throw createSubagentHostArgumentError(SUBAGENT_MACHINE_ARG, 'unavailable'); + } + } + if (unavailableReason != null) { + throw new CodeWorkspaceSelectionError(unavailableReason); + } + return { agent }; + }, + attach(contexts, { agentId, context, placement, codeExecutionContext, toolContext }) { + const executionId = context?.executionId; + if (placement.target != null) { + assertSubagentCodePlacement(codeExecutionContext, placement.target); + } + if (executionId && placement.childEnvironmentId != null) { + childRoutes.set(executionId, placement.childEnvironmentId); + } + if (placement.target == null && !routeByAgent.has(agentId)) { + routeByAgent.set(agentId, { + environmentId: codeExecutionContext?.environmentId ?? null, + routed: false, + }); + } + if (placement.target == null || !executionId) { + contexts.set(agentId, toolContext); + return; + } + routedContexts.set(executionId, { agentId, toolContext }); + if (!contexts.has(agentId)) { + contexts.set(agentId, toolContext); + } + }, + getToolContext(agentId, executionContext) { + if (!agentId || routedContexts.size === 0) { + return undefined; + } + const ancestry = executionContext?.ancestry ?? []; + for (let index = ancestry.length - 1; index >= 0; index--) { + const entry = ancestry[index]; + if (entry.subagentAgentId !== agentId) { + continue; + } + const routed = routedContexts.get(entry.subagentRunId); + return routed?.agentId === agentId ? routed.toolContext : undefined; + } + return undefined; + }, + isRouted(executionId) { + return executionId != null && routedContexts.has(executionId); + }, + }; +} diff --git a/packages/api/src/files/provision/callback.spec.ts b/packages/api/src/files/provision/callback.spec.ts index 18662f5573b..28846bdb928 100644 --- a/packages/api/src/files/provision/callback.spec.ts +++ b/packages/api/src/files/provision/callback.spec.ts @@ -7,6 +7,7 @@ import type { CodeFileAgent } from '../code/queued'; import type { ServerRequest } from '~/types'; import { mergeCodeFilesIntoContext } from '~/agents/codeFilesSession'; import { prepareQueuedCodeFileContext } from '../code/queued'; +import { createSubagentCodeRouting } from '~/code/targets'; import { createProvisionFilesCallback } from './callback'; jest.mock('@librechat/data-schemas', () => ({ @@ -48,10 +49,14 @@ function buildHarness({ contexts, codeImpl, vectorImpl, + resolveExecutionContext, }: { contexts: Array<[string, ProvisionToolContext]>; codeImpl?: jest.Mock; vectorImpl?: jest.Mock; + resolveExecutionContext?: Parameters< + typeof createProvisionFilesCallback + >[0]['resolveExecutionContext']; }) { const provisionToCodeEnv = codeImpl ?? @@ -101,6 +106,7 @@ function buildHarness({ updateFile, updateCodeEnvRef, addEmbeddedEntity, + resolveExecutionContext, }), }; } @@ -301,6 +307,57 @@ describe('createProvisionFilesCallback', () => { expect(agent.provisionState.codeEnvFiles).toEqual([]); }); + it("provisions a per-call routed child into its own execution's context", async () => { + const shared = { provisionState: state([makeFile({ file_id: 'shared-route' })], []) }; + const routed = { provisionState: state([makeFile({ file_id: 'routed-route' })], []) }; + const routing = createSubagentCodeRouting({}); + routing.attach(new Map(), { + agentId: 'agent-a', + context: { executionId: 'run-routed' }, + placement: { + agent: { id: 'agent-a' }, + target: { + environmentId: 'machine', + workspaceId: 'workspace', + context: { + baseUrl: 'https://bridge.example', + codeSessionKey: 'machine', + executionProfile: 'stateful', + statefulSessions: true, + environmentId: 'machine', + }, + }, + }, + codeExecutionContext: { environmentId: 'machine' }, + toolContext: routed, + }); + const executionContext = { + rootRunId: 'root', + hookSessionId: 'hooks', + depth: 1, + ancestry: [ + { + subagentRunId: 'run-routed', + subagentType: 'agent-a', + subagentKind: 'agent' as const, + subagentAgentId: 'agent-a', + parentRunId: 'root', + }, + ], + }; + const { provisionFiles, provisionToCodeEnv } = buildHarness({ + contexts: [['agent-a', shared]], + resolveExecutionContext: routing.getToolContext, + }); + + await provisionFiles([Constants.EXECUTE_CODE], 'agent-a', undefined, executionContext); + await provisionFiles([Constants.EXECUTE_CODE], 'agent-a'); + + expect( + provisionToCodeEnv.mock.calls.map(([args]) => (args as { file: TFile }).file.file_id), + ).toEqual(['routed-route', 'shared-route']); + }); + it('scopes only the agent own resource files to its identity', async () => { /* A user can attach another agent's setup file to this conversation. Uploading it under * this agent would place it in a namespace this agent's other users share, so the diff --git a/packages/api/src/files/provision/callback.ts b/packages/api/src/files/provision/callback.ts index 6e48fb01230..7aee5546053 100644 --- a/packages/api/src/files/provision/callback.ts +++ b/packages/api/src/files/provision/callback.ts @@ -1,8 +1,8 @@ import { Constants } from '@librechat/agents'; import { logger } from '@librechat/data-schemas'; import { EToolResources } from 'librechat-data-provider'; +import type { CodeEnvFile, SubagentExecutionContext } from '@librechat/agents'; import type { TFile } from 'librechat-data-provider'; -import type { CodeEnvFile } from '@librechat/agents'; import type { CodeEnvRefUpdate, ProvisionService } from './service'; import type { ProvisionToolContext } from '../code/queued'; import type { ServerRequest } from '~/types'; @@ -42,6 +42,11 @@ export interface ProvisionCallbackDeps { req: ServerRequest; agentToolContexts: Map; resolvePrimaryAgentId?: () => string | undefined; + /** A child routed per call keeps its own context, keyed by its execution. */ + resolveExecutionContext?: ( + agentId: string | undefined, + executionContext: SubagentExecutionContext | undefined, + ) => ProvisionToolContext | undefined; provisionToCodeEnv: ProvisionService['provisionToCodeEnv']; provisionToVectorDB: ProvisionService['provisionToVectorDB']; updateFile: (update: FileUpdate) => Promise; @@ -90,6 +95,7 @@ export function createProvisionFilesCallback({ req, agentToolContexts, resolvePrimaryAgentId, + resolveExecutionContext, provisionToCodeEnv, provisionToVectorDB, updateFile, @@ -99,6 +105,7 @@ export function createProvisionFilesCallback({ toolNames: string[], agentId?: string, signal?: AbortSignal, + executionContext?: SubagentExecutionContext, ) => Promise { /* Agents in a handoff or parallel graph are initialized independently over the same * request attachments, so each holds its own ProvisionState for the same file. Keyed @@ -132,6 +139,7 @@ export function createProvisionFilesCallback({ toolNames: string[], agentId?: string, signal?: AbortSignal, + executionContext?: SubagentExecutionContext, ): Promise { signal?.throwIfAborted(); /* agentId is optional on this callback and a batch for the primary agent may omit @@ -141,11 +149,15 @@ export function createProvisionFilesCallback({ * while reading state from a fallback context would upload them as user-scoped, * then reconstruct them as agent-scoped on the next turn, and the entity id used to * query those vectors would no longer match the one they were stored under. */ - const { ctx, resolvedAgentId } = resolveProvisionContext({ - agentId, - agentToolContexts, - primaryAgentId: resolvePrimaryAgentId?.(), - }); + const placedCtx = resolveExecutionContext?.(agentId, executionContext); + const { ctx, resolvedAgentId } = + placedCtx != null + ? { ctx: placedCtx, resolvedAgentId: agentId } + : resolveProvisionContext({ + agentId, + agentToolContexts, + primaryAgentId: resolvePrimaryAgentId?.(), + }); if (!ctx?.provisionState) { return []; } From 9d2a9901901ebc5e069fb1338ab54c87e290b28a Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sun, 4 Oct 2026 22:48:27 -0400 Subject: [PATCH 02/19] =?UTF-8?q?=F0=9F=A7=AD=20fix:=20Reserve=20Default?= =?UTF-8?q?=20Routes=20Early=20and=20Route=20a=20Routed=20Subagent's=20Gra?= =?UTF-8?q?ph=20Members?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Reserve a subagent's default machine when it is placed, as initializeAgent will resolve it (including parent inheritance), so a concurrent call cannot claim another machine during initialization. - Graph members spawned by a per-call-routed subagent follow its machine when they may, unless they already initialized on their own route this request. --- .../services/Endpoints/agents/initialize.js | 71 +++++++++++++++++- .../Endpoints/agents/initialize.spec.js | 73 +++++++++++++++++++ packages/api/src/code/targets.spec.ts | 43 +++++++++++ packages/api/src/code/targets.ts | 40 +++++++++- 4 files changed, 220 insertions(+), 7 deletions(-) diff --git a/api/server/services/Endpoints/agents/initialize.js b/api/server/services/Endpoints/agents/initialize.js index 2f6bc926271..1d452e61ca6 100644 --- a/api/server/services/Endpoints/agents/initialize.js +++ b/api/server/services/Endpoints/agents/initialize.js @@ -1157,9 +1157,11 @@ const initializeClientWithProvider = async ({ userId, conversationId, getAppConfig, + getInheritedEnvironments: () => req.codeWorkspaceInheritance, }); - const toLazySubagentMetadata = async (agent) => { + /** The code flags a lazy subagent runs with in this request. */ + const getSubagentCodeFlags = (agent) => { const configuredCodeEnvironments = appConfig?.endpoints?.[EModelEndpoint.agents]?.statefulCodeSessions?.environments; const attachedEnvironmentOptOut = optsOutOfAttachedCodeEnvironment( @@ -1187,6 +1189,21 @@ const initializeClientWithProvider = async ({ ) { throw createStatefulCodeEnvironmentPolicyError(statefulCodeEnvironment); } + return { + configuredCodeEnvironments, + lazyCodeEnvAvailable, + statefulCodeSessions, + statefulCodeEnvironment, + }; + }; + + const toLazySubagentMetadata = async (agent) => { + const { + configuredCodeEnvironments, + lazyCodeEnvAvailable, + statefulCodeSessions, + statefulCodeEnvironment, + } = getSubagentCodeFlags(agent); const codeAvailability = await resolveSubagentCodeAvailability({ agentId: agent.id, codeEnvAvailable: lazyCodeEnvAvailable, @@ -1600,7 +1617,13 @@ const initializeClientWithProvider = async ({ if (!subagentCodeRouting.isRouted(context.executionId)) { graphMemberConfigsById.set(config.id, config); } - await resolveGraphSubagentsFor(config, context.signal); + await resolveGraphSubagentsFor( + config, + context.signal, + subagentCodeRouting.routesChildren(context.executionId) + ? context.executionId + : undefined, + ); return config; }, }), @@ -1705,7 +1728,43 @@ const initializeClientWithProvider = async ({ return waitForAbort(pending, graphSignal); }; - async function resolveGraphSubagentsFor(config, graphSignal = signal) { + /** A graph member spawned by a per-call-routed parent follows that parent's machine when it + * may, unless it already initialized on its own route this request. */ + const loadRoutedGraphMember = async (memberId, parentRunId, graphSignal) => { + throwIfAborted(graphSignal); + const cached = graphMemberConfigsById.get(memberId); + if (cached) return cached; + if (skippedAgentIds.has(memberId)) return null; + const agent = await waitForAbort(db.getAgentWithVersionCount({ id: memberId }), graphSignal); + if (!agent || !(await hasSubagentViewAccess(agent, memberId, graphSignal))) { + skippedAgentIds.add(memberId); + return null; + } + const executionId = `${parentRunId}:graph:${memberId}`; + try { + const config = await initializeLoadedSubagent({ + agent, + agentId: memberId, + configId: getLazySubagentConfigId(agent), + context: { signal: graphSignal, parentRunId, executionId }, + lazyChildren: [], + codeFlags: getSubagentCodeFlags(agent), + viewAccessChecked: true, + }); + if (!subagentCodeRouting.isRouted(executionId)) { + graphMemberConfigsById.set(memberId, config); + } + return config; + } catch (error) { + if (isFatalAgentInitializationError(error, { signal: graphSignal })) { + throw error; + } + logger.error(`[initializeClient] Error initializing routed graph member ${memberId}:`, error); + return null; + } + }; + + async function resolveGraphSubagentsFor(config, graphSignal = signal, routedParentRunId) { throwIfAborted(graphSignal); const definitions = subagentsAvailableForRun && config.subagents?.enabled === true @@ -1733,7 +1792,11 @@ const initializeClientWithProvider = async ({ } const memberConfigs = await Promise.all( memberIds.map((memberId) => - initializeGraphMember(() => loadGraphMember(memberId, graphSignal)), + initializeGraphMember(() => + routedParentRunId == null + ? loadGraphMember(memberId, graphSignal) + : loadRoutedGraphMember(memberId, routedParentRunId, graphSignal), + ), ), ); throwIfAborted(graphSignal); diff --git a/api/server/services/Endpoints/agents/initialize.spec.js b/api/server/services/Endpoints/agents/initialize.spec.js index 81e66741c04..b7ee85eac19 100644 --- a/api/server/services/Endpoints/agents/initialize.spec.js +++ b/api/server/services/Endpoints/agents/initialize.spec.js @@ -2775,6 +2775,79 @@ describe('initializeClient — subagent loading', () => { } }); + it("routes a routed reviewer's graph members to its machine when they may use it", async () => { + const req = await setup(); + const member = await createAgent({ + id: 'agent_graph_member', + name: 'Graph member', + provider: 'openai', + model: 'gpt-4', + author: new mongoose.Types.ObjectId(), + tools: ['execute_code'], + stateful_code_sessions: true, + stateful_code_environment: 'user', + code_environment_id: LIA_RAG, + code_environment_ids: [SKYNET], + }); + await grantView(member); + const fetchSpy = mockWorkerStatus(); + try { + await initializeClient({ + req, + res: {}, + signal: new AbortController().signal, + endpointOption: makeEndpointOption(), + }); + const descriptor = agentClientArgs.agent.lazySubagentConfigs[0]; + const initialized = []; + mockInitializeAgent.mockImplementation(async (params) => { + initialized.push(params.agent); + return { + ...makeSubagentConfig(params.agent.id), + ...(params.agent.id === SUBAGENT_ID + ? { + subagents: { + enabled: true, + allowSelf: false, + agent_ids: [], + graphs: [ + { + type: 'review_team', + name: 'Review team', + description: 'Reviews together.', + agent_ids: [member.id], + edges: [], + entryAgentId: member.id, + resultAgentId: member.id, + }, + ], + }, + } + : {}), + codeExecutionContext: { + environmentId: params.agent.code_environment_id, + environmentType: 'attached', + }, + }; + }); + + const config = await descriptor.resolve({ + signal: new AbortController().signal, + executionId: 'run-routed-graph', + hostArgs: { machine: SKYNET }, + }); + + expect(initialized.map((agent) => [agent.id, agent.code_environment_id])).toEqual([ + [SUBAGENT_ID, SKYNET], + [member.id, SKYNET], + ]); + expect(initialized[1].code_environment_ids).toEqual([SKYNET]); + expect(config.subagentGraphConfigs?.[0]?.memberConfigs?.[0]?.id).toBe(member.id); + } finally { + fetchSpy.mockRestore(); + } + }); + it('keeps a reviewer that may not use the parent machine on its own default', async () => { const req = await setup({ reviewer: { code_environment_ids: [] } }); const fetchSpy = mockWorkerStatus(); diff --git a/packages/api/src/code/targets.spec.ts b/packages/api/src/code/targets.spec.ts index 436be468154..1741572d287 100644 --- a/packages/api/src/code/targets.spec.ts +++ b/packages/api/src/code/targets.spec.ts @@ -524,6 +524,49 @@ describe('subagent code routing', () => { ).rejects.toMatchObject({ argument: 'machine', rejection: 'unavailable' }); }); + it('reserves a default route before initialization so a concurrent call cannot move it', async () => { + serveWorkers(allOnline); + const routing = createSubagentCodeRouting(request); + + const defaultCall = call(); + const placed = await routing.place({ agent: reviewer, flags, context: defaultCall }); + const concurrent = routing.place({ + agent: reviewer, + flags, + context: call({ machine: 'buildbox' }), + }); + const sameMachine = await routing.place({ + agent: reviewer, + flags, + context: call({ machine: 'laptop' }), + }); + + expect(placed).toEqual({ agent: reviewer }); + await expect(concurrent).rejects.toMatchObject({ + argument: 'machine', + rejection: 'unavailable', + }); + expect(sameMachine.target?.environmentId).toBe('laptop'); + expect(routing.routesChildren(defaultCall.executionId)).toBe(false); + }); + + it('reserves the inherited default a parent machine gives a child', async () => { + serveWorkers(allOnline); + const routing = createSubagentCodeRouting({ + ...request, + getInheritedEnvironments: () => new Map([['agent_reviewer', 'buildbox']]), + }); + + await routing.place({ agent: reviewer, flags, context: call() }); + + await expect( + routing.place({ agent: reviewer, flags, context: call({ machine: 'laptop' }) }), + ).rejects.toMatchObject({ argument: 'machine' }); + await expect( + routing.place({ agent: reviewer, flags, context: call({ machine: 'buildbox' }) }), + ).resolves.toMatchObject({ target: { environmentId: 'buildbox' } }); + }); + it('keeps one machine per subagent for the whole request', async () => { serveWorkers(allOnline); const routing = createSubagentCodeRouting(request); diff --git a/packages/api/src/code/targets.ts b/packages/api/src/code/targets.ts index 19dc06b6bd7..dd96234974a 100644 --- a/packages/api/src/code/targets.ts +++ b/packages/api/src/code/targets.ts @@ -459,11 +459,17 @@ export interface SubagentCodeRouting { ): TContext | undefined; /** Whether an execution was routed per call (its config must not be shared). */ isRouted(executionId: string | null | undefined): boolean; + /** Whether an execution's own subagents (including graph members) inherit a per-call route. */ + routesChildren(executionId: string | null | undefined): boolean; } -export function createSubagentCodeRouting( - request: SubagentCodeRequest, -): SubagentCodeRouting { +export function createSubagentCodeRouting({ + getInheritedEnvironments, + ...request +}: SubagentCodeRequest & { + /** Request-scoped parent inheritance (#16756), read when a default route is reserved. */ + getInheritedEnvironments?: () => ReadonlyMap | undefined; +}): SubagentCodeRouting { const routedContexts = new Map(); const childRoutes = new Map(); /** @@ -487,6 +493,27 @@ export function createSubagentCodeRouting( environmentId: agent.code_environment_id, environmentIds: agent.code_environment_ids ?? undefined, }); + /** The machine a call that names none lands on, as `initializeAgent` will resolve it. */ + const defaultRouteOf = (agent: SubagentCodeAgent, flags: SubagentCodeFlags): string | null => { + try { + const context = resolveCodeExecutionContext({ + statefulSessions: true, + environment: flags.statefulCodeEnvironment, + environmentId: agent.code_environment_id, + environmentIds: agent.code_environment_ids ?? undefined, + allowEnvironmentSelection: request.allowEnvironmentSelection, + workspaceSelections: request.persistedSelections ?? request.requestedSelections, + inheritedEnvironments: getInheritedEnvironments?.(), + environments: request.environments, + userId: request.userId, + agentId: agent.id, + conversationId: request.conversationId, + }); + return context.environmentType === 'attached' ? (context.environmentId ?? null) : null; + } catch { + return null; + } + }; const routeTo = ( agent: T, target: SubagentCodeTarget, @@ -558,8 +585,15 @@ export function createSubagentCodeRouting( if (unavailableReason != null) { throw new CodeWorkspaceSelectionError(unavailableReason); } + /** Reserved now, not after initialization, so a concurrent call cannot claim another machine. */ + if (!routeByAgent.has(agent.id)) { + routeByAgent.set(agent.id, { environmentId: defaultRouteOf(agent, flags), routed: false }); + } return { agent }; }, + routesChildren(executionId) { + return executionId != null && childRoutes.has(executionId); + }, attach(contexts, { agentId, context, placement, codeExecutionContext, toolContext }) { const executionId = context?.executionId; if (placement.target != null) { From b55612a0ecc520a76d8b1c1c38f158d5196d15f1 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sun, 4 Oct 2026 23:01:03 -0400 Subject: [PATCH 03/19] =?UTF-8?q?=F0=9F=A7=AD=20fix:=20Settle=20Concurrent?= =?UTF-8?q?=20Inherited=20Routes=20and=20Register=20Routed=20Graph=20Membe?= =?UTF-8?q?rs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Re-read a subagent's inherited route after resolving targets, so two concurrent calls from differently routed parents settle on one machine. - Attribute a workspace-only conflict to the workspace argument. - Register graph members a routed subagent initialized, so each gets the attached-machine policy of the machine it actually runs on. --- .../agents/__tests__/run-codeTools.test.ts | 12 ++++- packages/api/src/agents/run.ts | 17 ++++++- packages/api/src/code/targets.spec.ts | 47 +++++++++++++++++++ 3 files changed, 74 insertions(+), 2 deletions(-) diff --git a/packages/api/src/agents/__tests__/run-codeTools.test.ts b/packages/api/src/agents/__tests__/run-codeTools.test.ts index a09921ab895..90313a383e9 100644 --- a/packages/api/src/agents/__tests__/run-codeTools.test.ts +++ b/packages/api/src/agents/__tests__/run-codeTools.test.ts @@ -6,9 +6,9 @@ import type { ProvisionToolContext } from '~/files/provision/callback'; import type { ServerRequest } from '~/types'; import { createProvisionFilesCallback } from '~/files/provision/callback'; import { SUBAGENT_COMPLETION_DELIVERY } from '~/agents/subagentDelivery'; +import { createRun, collectResolvedSubagentAgents } from '~/agents/run'; import { mergeCodeFilesIntoContext } from '~/agents/codeFilesSession'; import { CHECK_BACKGROUND_TASK_NAME } from '~/agents/background'; -import { createRun } from '~/agents/run'; /** * Guards the code-tool eager/session wiring in `createRun`. The whole @@ -591,3 +591,13 @@ describe('createRun code-tool eager/session wiring', () => { ).resolves.toEqual(expect.objectContaining({ updatedOutput: expect.any(String) })); }); }); + +describe('collectResolvedSubagentAgents', () => { + it('registers a resolved child together with the graph members it initialized', () => { + const member = { id: 'member' }; + const child = { id: 'child', subagentGraphConfigs: [{ memberConfigs: [member] }] }; + + expect(collectResolvedSubagentAgents(child)).toEqual([child, member]); + expect(collectResolvedSubagentAgents({ id: 'solo' })).toEqual([{ id: 'solo' }]); + }); +}); diff --git a/packages/api/src/agents/run.ts b/packages/api/src/agents/run.ts index 48be916e917..a074d7d079b 100644 --- a/packages/api/src/agents/run.ts +++ b/packages/api/src/agents/run.ts @@ -1616,6 +1616,19 @@ function assertSubagentDepth(depth: number, agentId: string): void { } } +/** + * A resolved lazy child and the graph members it initialized with it. Each is registered with the + * run, so a member routed to the child's per-call machine gets that machine's policy. + */ +export function collectResolvedSubagentAgents< + T extends { subagentGraphConfigs?: Array<{ memberConfigs: T[] }> }, +>(resolvedChild: T): T[] { + return [ + resolvedChild, + ...(resolvedChild.subagentGraphConfigs ?? []).flatMap((graph) => graph.memberConfigs), + ]; +} + function createLazySubagentConfig( child: LazySubagentAgent, toInput: (child: RunAgent, opts?: { isSubagent?: boolean }) => AgentInputs, @@ -1642,7 +1655,9 @@ function createLazySubagentConfig( if (context.signal.aborted) { throw context.signal.reason ?? new Error('Subagent resolution was aborted.'); } - onResolvedAgent?.(resolvedChild); + for (const resolved of collectResolvedSubagentAgents(resolvedChild)) { + onResolvedAgent?.(resolved); + } const childInputs = buildIsolatedAgentInputs(resolvedChild, toInput); const resolutionState: SubagentBuildState = { configCount: 1, diff --git a/packages/api/src/code/targets.spec.ts b/packages/api/src/code/targets.spec.ts index 1741572d287..5c7dca65aff 100644 --- a/packages/api/src/code/targets.spec.ts +++ b/packages/api/src/code/targets.spec.ts @@ -567,6 +567,53 @@ describe('subagent code routing', () => { ).resolves.toMatchObject({ target: { environmentId: 'buildbox' } }); }); + it('settles concurrent inherited calls from differently routed parents on one machine', async () => { + serveWorkers(allOnline); + const routing = createSubagentCodeRouting(request); + const parentOn = async (machine: string) => { + const parent = { + id: `agent_parent_${machine}`, + code_environment_id: machine, + code_environment_ids: [machine], + }; + const parentCall = call({ machine }); + const placement = await routing.place({ agent: parent, flags, context: parentCall }); + routing.attach(new Map(), { + agentId: parent.id, + context: parentCall, + placement, + codeExecutionContext: placement.target?.context, + toolContext: machine, + }); + return parentCall.executionId; + }; + const fromLaptop = await parentOn('laptop'); + const fromBuildbox = await parentOn('buildbox'); + + const placements = await Promise.allSettled([ + routing.place({ agent: reviewer, flags, context: call(undefined, fromLaptop) }), + routing.place({ agent: reviewer, flags, context: call(undefined, fromBuildbox) }), + ]); + const machines = placements.map((result) => + result.status === 'fulfilled' ? result.value.target?.environmentId : 'rejected', + ); + + expect( + new Set(machines.filter((machine) => machine !== undefined && machine !== 'rejected')).size, + ).toBe(1); + }); + + it('attributes a workspace-only conflict to the workspace argument', async () => { + serveWorkers(allOnline); + const routing = createSubagentCodeRouting(request); + + await routing.place({ agent: reviewer, flags, context: call({ machine: 'laptop' }) }); + + await expect( + routing.place({ agent: reviewer, flags, context: call({ workspace: 'agents' }) }), + ).rejects.toMatchObject({ argument: 'workspace', rejection: 'unavailable' }); + }); + it('keeps one machine per subagent for the whole request', async () => { serveWorkers(allOnline); const routing = createSubagentCodeRouting(request); From aaeef870588eb47bb1d9af51488a7f72f2eeb9a5 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sun, 4 Oct 2026 23:01:44 -0400 Subject: [PATCH 04/19] =?UTF-8?q?=F0=9F=A7=AD=20fix:=20Re-read=20Inherited?= =?UTF-8?q?=20Routes=20After=20Resolving=20Subagent=20Targets?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Re-read a subagent's inherited route after the target resolution await, so two concurrent omitted-machine calls from differently routed parents settle on one machine. - Attribute a workspace-only conflict to the workspace argument. --- packages/api/src/code/targets.ts | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/packages/api/src/code/targets.ts b/packages/api/src/code/targets.ts index dd96234974a..45e8cad9b0d 100644 --- a/packages/api/src/code/targets.ts +++ b/packages/api/src/code/targets.ts @@ -560,7 +560,12 @@ export function createSubagentCodeRouting({ const target = selectSubagentCodeTarget(hostArgs, resolution); if (target != null) { if (conflicts(agent.id, target.environmentId)) { - throw createSubagentHostArgumentError(SUBAGENT_MACHINE_ARG, 'unavailable'); + throw createSubagentHostArgumentError( + hostArgs?.[SUBAGENT_MACHINE_ARG] != null + ? SUBAGENT_MACHINE_ARG + : SUBAGENT_WORKSPACE_ARG, + 'unavailable', + ); } return routeTo(agent, target); } @@ -571,10 +576,14 @@ export function createSubagentCodeRouting({ ? { agent } : { agent, childEnvironmentId: parentEnvironmentId }; } - const inherited = inheritedRoute(agent.id, parentEnvironmentId); - if (inherited != null) { + if (inheritedRoute(agent.id, parentEnvironmentId) != null) { const { targets } = await resolveSubagentCodeTargets(paramsFor(agent, flags)); - const target = targets.find((candidate) => candidate.environmentId === inherited); + /** Re-read after the await: a concurrent call may have settled this subagent meanwhile. */ + const inherited = inheritedRoute(agent.id, parentEnvironmentId); + const target = + inherited == null + ? undefined + : targets.find((candidate) => candidate.environmentId === inherited); if (target != null) { return routeTo(agent, target); } From c78629f5e50d11922afa97fa1be7d0b4a3221911 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sun, 4 Oct 2026 23:11:55 -0400 Subject: [PATCH 05/19] =?UTF-8?q?=F0=9F=A7=AA=20test:=20Prove=20a=20Routed?= =?UTF-8?q?=20Graph=20Member=20Takes=20Its=20Machine's=20Policy=20Through?= =?UTF-8?q?=20createRun?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Register a resolved child's graph members inline in the lazy resolver instead of through an exported generic helper whose test did not typecheck. - Drive the check through createRun's real PreToolUse hook: a member routed to a deny machine flips from its metadata machine's allow to deny once its parent resolves. --- .../agents/__tests__/run-codeTools.test.ts | 92 +++++++++++++++++-- packages/api/src/agents/run.ts | 21 ++--- 2 files changed, 90 insertions(+), 23 deletions(-) diff --git a/packages/api/src/agents/__tests__/run-codeTools.test.ts b/packages/api/src/agents/__tests__/run-codeTools.test.ts index 90313a383e9..7f9c2975f4e 100644 --- a/packages/api/src/agents/__tests__/run-codeTools.test.ts +++ b/packages/api/src/agents/__tests__/run-codeTools.test.ts @@ -6,9 +6,9 @@ import type { ProvisionToolContext } from '~/files/provision/callback'; import type { ServerRequest } from '~/types'; import { createProvisionFilesCallback } from '~/files/provision/callback'; import { SUBAGENT_COMPLETION_DELIVERY } from '~/agents/subagentDelivery'; -import { createRun, collectResolvedSubagentAgents } from '~/agents/run'; import { mergeCodeFilesIntoContext } from '~/agents/codeFilesSession'; import { CHECK_BACKGROUND_TASK_NAME } from '~/agents/background'; +import { createRun } from '~/agents/run'; /** * Guards the code-tool eager/session wiring in `createRun`. The whole @@ -66,7 +66,13 @@ jest.mock('~/agents/checkpointer', () => ({ getAgentCheckpointer: jest.fn().mockResolvedValue({}), })); -import { HookRegistry, InMemorySubagentTaskStore, Run } from '@librechat/agents'; +import { + Constants, + HookRegistry, + InMemorySubagentTaskStore, + Run, + executeHooks, +} from '@librechat/agents'; function makeAgent(overrides?: Record) { return { @@ -592,12 +598,82 @@ describe('createRun code-tool eager/session wiring', () => { }); }); -describe('collectResolvedSubagentAgents', () => { - it('registers a resolved child together with the graph members it initialized', () => { - const member = { id: 'member' }; - const child = { id: 'child', subagentGraphConfigs: [{ memberConfigs: [member] }] }; +describe('attached-machine policy for lazily initialized graph members', () => { + beforeEach(() => jest.clearAllMocks()); + + const attachedOn = (environmentId: string, commandExecution: 'allow' | 'deny') => ({ + environmentType: 'attached', + environmentId, + codeEnvironmentConfigSchema: { + permissions: { commandExecution: { allowed: ['allow', 'ask', 'deny'], default: 'ask' } }, + }, + codeEnvironmentSettings: { permissions: { commandExecution } }, + }); + + it('applies the policy of the machine a routed child sent its graph member to', async () => { + const definition = { + type: 'review_team', + name: 'Review team', + description: 'A one-member team', + agent_ids: ['member'], + edges: [], + entry_agent_id: 'member', + result_agent_id: 'member', + }; + const routedMember = makeAgent({ + id: 'member', + codeExecutionContext: attachedOn('buildbox', 'deny'), + }); + const child = makeAgent({ + id: 'child', + subagentGraphConfigs: [{ definition, memberConfigs: [routedMember] }], + }); + const resolve = jest.fn().mockResolvedValue(child); + await createRun({ + agents: [ + makeAgent({ + subagents: { enabled: true, allowSelf: false, agent_ids: ['child'] }, + lazySubagentConfigs: [ + { + id: 'child', + configId: 'child:1', + resolve, + subagentGraphMemberMetadata: [ + { id: 'member', codeExecutionContext: attachedOn('laptop', 'allow') }, + ], + }, + ], + }), + ] as never, + signal: new AbortController().signal, + streaming: true, + streamUsage: true, + hitlCapable: true, + }); + const runConfig = (Run.create as jest.Mock).mock.calls[0][0] as Record; + const decide = async () => + ( + await executeHooks({ + registry: runConfig.hooks as HookRegistry, + input: { + hook_event_name: 'PreToolUse', + runId: 'run-1', + toolName: Constants.BASH_TOOL, + toolInput: { command: 'ls' }, + toolUseId: 'call-1', + executingAgentId: 'member', + }, + matchQuery: Constants.BASH_TOOL, + }) + ).decision; + + expect(await decide()).toBe('allow'); + + const [parentInput] = (runConfig.graphConfig as { agents: AgentInputs[] }).agents; + const resolveInputs = parentInput.subagentConfigs?.[0].resolveAgentInputs; + if (!resolveInputs) throw new Error('Missing lazy subagent resolver'); + await resolveInputs({ signal: new AbortController().signal } as SubagentResolveContext); - expect(collectResolvedSubagentAgents(child)).toEqual([child, member]); - expect(collectResolvedSubagentAgents({ id: 'solo' })).toEqual([{ id: 'solo' }]); + expect(await decide()).toBe('deny'); }); }); diff --git a/packages/api/src/agents/run.ts b/packages/api/src/agents/run.ts index a074d7d079b..78a1b799f4a 100644 --- a/packages/api/src/agents/run.ts +++ b/packages/api/src/agents/run.ts @@ -1616,19 +1616,6 @@ function assertSubagentDepth(depth: number, agentId: string): void { } } -/** - * A resolved lazy child and the graph members it initialized with it. Each is registered with the - * run, so a member routed to the child's per-call machine gets that machine's policy. - */ -export function collectResolvedSubagentAgents< - T extends { subagentGraphConfigs?: Array<{ memberConfigs: T[] }> }, ->(resolvedChild: T): T[] { - return [ - resolvedChild, - ...(resolvedChild.subagentGraphConfigs ?? []).flatMap((graph) => graph.memberConfigs), - ]; -} - function createLazySubagentConfig( child: LazySubagentAgent, toInput: (child: RunAgent, opts?: { isSubagent?: boolean }) => AgentInputs, @@ -1655,8 +1642,12 @@ function createLazySubagentConfig( if (context.signal.aborted) { throw context.signal.reason ?? new Error('Subagent resolution was aborted.'); } - for (const resolved of collectResolvedSubagentAgents(resolvedChild)) { - onResolvedAgent?.(resolved); + onResolvedAgent?.(resolvedChild); + /** Graph members initialized with the child may run on its per-call machine. */ + for (const graph of resolvedChild.subagentGraphConfigs ?? []) { + for (const member of graph.memberConfigs) { + onResolvedAgent?.(member); + } } const childInputs = buildIsolatedAgentInputs(resolvedChild, toInput); const resolutionState: SubagentBuildState = { From e841b313a9c6b9ea2f9bbe10e88d885d9ed124c8 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sun, 4 Oct 2026 23:17:23 -0400 Subject: [PATCH 06/19] =?UTF-8?q?=F0=9F=A7=AD=20fix:=20Hold=20a=20Lazily?= =?UTF-8?q?=20Loaded=20Graph=20Member's=20Default=20Machine=20While=20It?= =?UTF-8?q?=20Initializes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Pass a graph member's code flags when it initializes on its default route, so the routing session reserves that machine before the asynchronous initialization instead of only after it, and a concurrent call cannot place the same agent on another machine meanwhile. --- .../services/Endpoints/agents/initialize.js | 1 + .../Endpoints/agents/initialize.spec.js | 100 ++++++++++++++++++ 2 files changed, 101 insertions(+) diff --git a/api/server/services/Endpoints/agents/initialize.js b/api/server/services/Endpoints/agents/initialize.js index 1d452e61ca6..5a099e8fa35 100644 --- a/api/server/services/Endpoints/agents/initialize.js +++ b/api/server/services/Endpoints/agents/initialize.js @@ -1691,6 +1691,7 @@ const initializeClientWithProvider = async ({ configId: getLazySubagentConfigId(agent), context: { signal }, lazyChildren: [], + codeFlags: getSubagentCodeFlags(agent), viewAccessChecked: true, }); graphMemberConfigsById.set(memberId, config); diff --git a/api/server/services/Endpoints/agents/initialize.spec.js b/api/server/services/Endpoints/agents/initialize.spec.js index b7ee85eac19..69ab6dae73b 100644 --- a/api/server/services/Endpoints/agents/initialize.spec.js +++ b/api/server/services/Endpoints/agents/initialize.spec.js @@ -2848,6 +2848,106 @@ describe('initializeClient — subagent loading', () => { } }); + it("holds a graph member's default machine while it initializes", async () => { + const req = await setup(); + const member = await createAgent({ + id: 'agent_graph_member', + name: 'Graph member', + provider: 'openai', + model: 'gpt-4', + author: new mongoose.Types.ObjectId(), + tools: ['execute_code'], + stateful_code_sessions: true, + stateful_code_environment: 'user', + code_environment_id: LIA_RAG, + code_environment_ids: [SKYNET], + }); + await grantView(member); + mockInitializeAgent.mockResolvedValue({ + ...makePrimaryConfig({ + subagents: { enabled: true, allowSelf: false, agent_ids: [SUBAGENT_ID, member.id] }, + }), + statefulCodeSessions: true, + codeExecutionContext: { environmentId: LIA_RAG, environmentType: 'attached' }, + }); + const fetchSpy = mockWorkerStatus(); + try { + await initializeClient({ + req, + res: {}, + signal: new AbortController().signal, + endpointOption: makeEndpointOption(), + }); + const descriptorFor = (id) => + agentClientArgs.agent.lazySubagentConfigs.find((descriptor) => descriptor.id === id); + let memberStarted; + const started = new Promise((resolve) => { + memberStarted = resolve; + }); + let releaseMember; + const memberGate = new Promise((resolve) => { + releaseMember = resolve; + }); + let memberLoads = 0; + mockInitializeAgent.mockImplementation(async (params) => { + if (params.agent.id === member.id && memberLoads++ === 0) { + memberStarted(); + await memberGate; + } + return { + ...makeSubagentConfig(params.agent.id), + ...(params.agent.id === SUBAGENT_ID + ? { + subagents: { + enabled: true, + allowSelf: false, + agent_ids: [], + graphs: [ + { + type: 'review_team', + name: 'Review team', + description: 'Reviews together.', + agent_ids: [member.id], + edges: [], + entryAgentId: member.id, + resultAgentId: member.id, + }, + ], + }, + } + : {}), + codeExecutionContext: { + environmentId: params.agent.code_environment_id, + environmentType: 'attached', + }, + }; + }); + + const reviewing = descriptorFor(SUBAGENT_ID).resolve({ + signal: new AbortController().signal, + executionId: 'run-reviewer', + }); + await started; + const moved = descriptorFor(member.id) + .resolve({ + signal: new AbortController().signal, + executionId: 'run-member', + hostArgs: { machine: SKYNET }, + }) + .then( + () => 'routed', + () => 'refused', + ); + const outcome = await moved; + releaseMember(); + await reviewing; + + expect(outcome).toBe('refused'); + } finally { + fetchSpy.mockRestore(); + } + }); + it('keeps a reviewer that may not use the parent machine on its own default', async () => { const req = await setup({ reviewer: { code_environment_ids: [] } }); const fetchSpy = mockWorkerStatus(); From a6ce0faf0b2b586dc3495887865c5ee37e8cc732 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sun, 4 Oct 2026 23:22:07 -0400 Subject: [PATCH 07/19] =?UTF-8?q?=F0=9F=A7=AD=20fix:=20Validate=20the=20Ap?= =?UTF-8?q?proval=20Mode=20Against=20Every=20Machine=20a=20Subagent=20May?= =?UTF-8?q?=20Use?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Collect each agent's default attached route and each attached machine a parent may route it to per call, and validate the requested approval mode against that set in createRun and both client checks. - A child whose default is unavailable but whose per-call machine permits accept edits no longer fails run creation; full access still requires every such machine to permit it. --- api/server/controllers/agents/client.js | 5 +- packages/api/src/agents/hitl/byom.spec.ts | 95 +++++++++++++++++------ packages/api/src/agents/hitl/byom.ts | 30 ++++++- packages/api/src/agents/run.ts | 3 +- 4 files changed, 104 insertions(+), 29 deletions(-) diff --git a/api/server/controllers/agents/client.js b/api/server/controllers/agents/client.js index 3e233348bc6..ca61783507a 100644 --- a/api/server/controllers/agents/client.js +++ b/api/server/controllers/agents/client.js @@ -61,6 +61,7 @@ const { buildToolApprovalExecutionConfig, collectAttachedCodeEnvironmentAgentIds, collectAttachedCodeEnvironmentPolicySettings, + collectAttachedCodeApprovalPolicies, buildAttachedCodeEnvironmentAdmissionHooks, resolveAttachedCodeApprovalMode, markNativeCodeToolApprovalRequests, @@ -1986,7 +1987,7 @@ class AgentClient extends BaseClient { const topLevelAgents = [this.options.agent, ...(this.agentConfigs?.values() ?? [])]; const codeApprovalMode = resolveAttachedCodeApprovalMode( this.options.req.body.codeApprovalMode, - collectAttachedCodeEnvironmentPolicySettings(topLevelAgents), + collectAttachedCodeApprovalPolicies(topLevelAgents), agentsEConfig?.toolApproval?.enabled !== false, ); const persistedCodeEnvironmentDecision = resolvePersistableCodeEnvironmentDecision({ @@ -4563,7 +4564,7 @@ class AgentClient extends BaseClient { collectAttachedCodeEnvironmentPolicySettings(topLevelAgents); const codeApprovalMode = resolveAttachedCodeApprovalMode( this.options.req.body.codeApprovalMode, - attachedCodeEnvironmentSettings, + collectAttachedCodeApprovalPolicies(topLevelAgents), agentsEConfig?.toolApproval?.enabled !== false, ); const effectiveToolApprovalPolicy = resolveToolApprovalPolicy({ diff --git a/packages/api/src/agents/hitl/byom.spec.ts b/packages/api/src/agents/hitl/byom.spec.ts index c1e21bea171..7a842002b1c 100644 --- a/packages/api/src/agents/hitl/byom.spec.ts +++ b/packages/api/src/agents/hitl/byom.spec.ts @@ -3,6 +3,7 @@ import { assertAttachedCodeEnvironmentApprovalSupported, buildAttachedCodeEnvironmentAdmissionHooks, collectAttachedCodeEnvironmentAgentIds, + collectAttachedCodeApprovalPolicies, collectAttachedCodeEnvironmentPolicySettings, createAttachedCodeEnvironmentPolicyHook, isStatefulCodeEnvironmentToolName, @@ -25,7 +26,7 @@ const fullAccessSettings: AttachedCodeEnvironmentPolicySettings = { describe('full access', () => { test('allows native writes and commands while preserving mandatory skill review', async () => { const settings = new Map([['attached-agent', fullAccessSettings]]); - const mode = resolveAttachedCodeApprovalMode('fullAccess', settings); + const mode = resolveAttachedCodeApprovalMode('fullAccess', settings.values()); const hook = createAttachedCodeEnvironmentPolicyHook(new Set(settings.keys()), settings, mode); for (const toolName of ['create_file', 'edit_file', 'bash_tool', 'execute_code']) { await expect( @@ -70,7 +71,7 @@ describe('full access', () => { expect(() => resolveAttachedCodeApprovalMode( 'fullAccess', - new Map(reverse ? entries.reverse() : entries), + new Map(reverse ? entries.reverse() : entries).values(), ), ).toThrow('not permitted'); }, @@ -82,7 +83,7 @@ describe('full access', () => { const hook = createAttachedCodeEnvironmentPolicyHook( attachedIds, settings, - resolveAttachedCodeApprovalMode('fullAccess', settings), + resolveAttachedCodeApprovalMode('fullAccess', settings.values()), ); settings.set('attached-agent', { ...fullAccessSettings, @@ -96,7 +97,7 @@ describe('full access', () => { await expect( hook({ toolName: 'bash_tool', executingAgentId: 'lazy-agent' } as never, signal), ).resolves.toMatchObject({ decision: 'ask' }); - expect(() => resolveAttachedCodeApprovalMode('fullAccess', settings, false)).toThrow( + expect(() => resolveAttachedCodeApprovalMode('fullAccess', settings.values(), false)).toThrow( 'not permitted', ); }); @@ -255,7 +256,7 @@ describe('createAttachedCodeEnvironmentPolicyHook', () => { }, ], ]); - const mode = resolveAttachedCodeApprovalMode('acceptEdits', settings); + const mode = resolveAttachedCodeApprovalMode('acceptEdits', settings.values()); const hook = createAttachedCodeEnvironmentPolicyHook( new Set(['attached-agent']), settings, @@ -272,19 +273,13 @@ describe('createAttachedCodeEnvironmentPolicyHook', () => { test('rejects accept edits when the attached machine excludes file-write allow', () => { expect(() => - resolveAttachedCodeApprovalMode( - 'acceptEdits', - new Map([ - [ - 'attached-agent', - { - configSchema: { - permissions: { fileWrite: { allowed: ['ask'], default: 'ask' } }, - }, - }, - ], - ]), - ), + resolveAttachedCodeApprovalMode('acceptEdits', [ + { + configSchema: { + permissions: { fileWrite: { allowed: ['ask'], default: 'ask' } }, + }, + }, + ]), ).toThrow('not permitted'); }); @@ -307,7 +302,7 @@ describe('createAttachedCodeEnvironmentPolicyHook', () => { }, ], ]); - const mode = resolveAttachedCodeApprovalMode('acceptEdits', settings); + const mode = resolveAttachedCodeApprovalMode('acceptEdits', settings.values()); const hook = createAttachedCodeEnvironmentPolicyHook( new Set(['permissive-agent', 'restrictive-agent']), settings, @@ -334,7 +329,7 @@ describe('createAttachedCodeEnvironmentPolicyHook', () => { }, ], ]); - const mode = resolveAttachedCodeApprovalMode('acceptEdits', settings); + const mode = resolveAttachedCodeApprovalMode('acceptEdits', settings.values()); const hook = createAttachedCodeEnvironmentPolicyHook(attachedIds, settings, mode); attachedIds.add('lazy-agent'); @@ -350,11 +345,11 @@ describe('createAttachedCodeEnvironmentPolicyHook', () => { }); test('rejects an explicit mode when approvals are disabled by the administrator', () => { - expect(resolveAttachedCodeApprovalMode('ask', new Map(), false)).toBeUndefined(); - expect(() => resolveAttachedCodeApprovalMode('acceptEdits', new Map(), false)).toThrow( + expect(resolveAttachedCodeApprovalMode('ask', [], false)).toBeUndefined(); + expect(() => resolveAttachedCodeApprovalMode('acceptEdits', [], false)).toThrow( 'not permitted', ); - expect(resolveAttachedCodeApprovalMode(undefined, new Map(), false)).toBeUndefined(); + expect(resolveAttachedCodeApprovalMode(undefined, [], false)).toBeUndefined(); }); test.each(['create_file', 'edit_file'])( @@ -601,6 +596,60 @@ describe('collectAttachedCodeEnvironmentAgentIds', () => { }); }); +describe('collectAttachedCodeApprovalPolicies', () => { + const acceptEditsMachine = { + environmentType: 'attached', + codeEnvironmentConfigSchema: fullAccessSettings.configSchema, + codeEnvironmentSettings: { permissions: { fileWrite: 'allow' as const } }, + }; + + test('validates a mode against a fallback-routable child by its per-call machines', () => { + const agents = [ + { + id: 'root', + codeExecutionContext: { environmentType: 'managed' }, + lazySubagentConfigs: [ + { + id: 'reviewer', + codeExecutionChoices: [{ environmentType: 'managed' }, acceptEditsMachine], + }, + ], + }, + ]; + + expect(() => + resolveAttachedCodeApprovalMode( + 'acceptEdits', + collectAttachedCodeEnvironmentPolicySettings(agents).values(), + ), + ).toThrow('not permitted'); + expect( + resolveAttachedCodeApprovalMode('acceptEdits', collectAttachedCodeApprovalPolicies(agents)), + ).toBe('acceptEdits'); + }); + + test('requires every machine a child may be routed to before granting full access', () => { + const agents = [ + { + id: 'reviewer', + codeExecutionContext: { ...acceptEditsMachine, codeEnvironmentSettings: undefined }, + codeExecutionChoices: [ + { + environmentType: 'attached', + codeEnvironmentConfigSchema: fullAccessSettings.configSchema, + }, + { environmentType: 'attached' }, + ], + }, + ]; + + expect(collectAttachedCodeApprovalPolicies(agents)).toHaveLength(3); + expect(() => + resolveAttachedCodeApprovalMode('fullAccess', collectAttachedCodeApprovalPolicies(agents)), + ).toThrow('not permitted'); + }); +}); + describe('assertAttachedCodeEnvironmentApprovalSupported', () => { test('rejects attached environments on callers without an approval/resume surface', () => { expect(() => diff --git a/packages/api/src/agents/hitl/byom.ts b/packages/api/src/agents/hitl/byom.ts index 78164404fc7..1f1d4a2c344 100644 --- a/packages/api/src/agents/hitl/byom.ts +++ b/packages/api/src/agents/hitl/byom.ts @@ -66,7 +66,13 @@ type CodeEnvironmentPolicyAgent = { codeEnvironmentSettings?: CodeEnvironmentUserSettings; }; /** Machines a parent may route this subagent to per call. */ - codeExecutionChoices?: readonly { environmentType?: string }[] | null; + codeExecutionChoices?: + | readonly { + environmentType?: string; + codeEnvironmentConfigSchema?: CodeEnvironmentUserConfigSchema; + codeEnvironmentSettings?: CodeEnvironmentUserSettings; + }[] + | null; subagentAgentConfigs?: readonly (CodeEnvironmentPolicyAgent | null | undefined)[]; lazySubagentConfigs?: readonly (CodeEnvironmentPolicyAgent | null | undefined)[]; subagentGraphMemberMetadata?: readonly (CodeEnvironmentPolicyAgent | null | undefined)[]; @@ -216,6 +222,24 @@ export function collectAttachedCodeEnvironmentPolicySettings( return settingsByAgentId; } +/** + * Every attached-machine policy the run may execute under, for validating the requested approval + * mode: each agent's default route and each machine a parent may route it to per call. + */ +export function collectAttachedCodeApprovalPolicies( + roots: readonly (CodeEnvironmentPolicyAgent | null | undefined)[], +): AttachedCodeEnvironmentPolicySettings[] { + return collectCodeEnvironmentPolicyAgents(roots).flatMap((agent) => + [agent.codeExecutionContext, ...(agent.codeExecutionChoices ?? [])] + .filter((context) => context?.environmentType === 'attached') + .map((context) => ({ + configSchema: context?.codeEnvironmentConfigSchema, + settings: context?.codeEnvironmentSettings, + skillAuthoringAvailable: agent.skillAuthoringAvailable === true, + })), + ); +} + function permissionDecision( policy: AttachedCodeEnvironmentPolicySettings | undefined, category: PermissionCategory, @@ -240,7 +264,7 @@ function permissionDecision( export function resolveAttachedCodeApprovalMode( requested: unknown, - settingsByAgentId: ReadonlyMap, + policies: Iterable, approvalsEnabled = true, ): CodeApprovalMode | undefined { if (!approvalsEnabled) { @@ -255,7 +279,7 @@ export function resolveAttachedCodeApprovalMode( } let resolved: CodeApprovalMode | undefined; let rejection: Error | undefined; - for (const policy of settingsByAgentId.values()) { + for (const policy of policies) { try { resolved = resolveCodeApprovalMode(requested, { environment: 'attached', diff --git a/packages/api/src/agents/run.ts b/packages/api/src/agents/run.ts index 78a1b799f4a..a6df4acc696 100644 --- a/packages/api/src/agents/run.ts +++ b/packages/api/src/agents/run.ts @@ -76,6 +76,7 @@ import { collectNativeEditFileAgentIds, collectAttachedCodeEnvironmentAgentIds, collectAttachedCodeEnvironmentPolicySettings, + collectAttachedCodeApprovalPolicies, createAttachedCodeEnvironmentPolicyHook, resolveAttachedCodeApprovalMode, } from '~/agents/hitl/byom'; @@ -2653,7 +2654,7 @@ export async function createRun({ const attachedCodeEnvironmentSettings = collectAttachedCodeEnvironmentPolicySettings(agents); const codeApprovalMode = resolveAttachedCodeApprovalMode( requestedCodeApprovalMode, - attachedCodeEnvironmentSettings, + collectAttachedCodeApprovalPolicies(agents), agentsEndpointConfig?.toolApproval?.enabled !== false, ); assertAttachedCodeEnvironmentApprovalSupported({ From ab9e7c70c012877c008e2bd8731c3fb3ae6fd031 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sun, 4 Oct 2026 23:37:25 -0400 Subject: [PATCH 08/19] =?UTF-8?q?=F0=9F=A7=AD=20fix:=20Admit,=20Prime,=20a?= =?UTF-8?q?nd=20Cancel=20Per-Call=20Subagent=20Routes=20Conservatively?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Build scheduled pause admission from every attached machine a parent may route an agent to, so a permissive default does not hide a per-call machine that asks. - Walk lazy graph-member metadata when collecting execution routes, so historical skill files are primed for every machine such a member may run on. - Stop placing a canceled call after its target resolution resolves, so it never claims a machine for an execution that will not initialize. --- api/server/controllers/agents/client.js | 2 + .../api/src/agents/codeFilesSession.spec.ts | 32 ++++++++++++ packages/api/src/agents/codeFilesSession.ts | 3 ++ packages/api/src/agents/hitl/byom.spec.ts | 39 +++++++++++++++ packages/api/src/agents/hitl/byom.ts | 49 ++++++++++++++----- packages/api/src/code/targets.spec.ts | 18 +++++++ packages/api/src/code/targets.ts | 10 ++++ 7 files changed, 140 insertions(+), 13 deletions(-) diff --git a/api/server/controllers/agents/client.js b/api/server/controllers/agents/client.js index ca61783507a..adb65f6cffe 100644 --- a/api/server/controllers/agents/client.js +++ b/api/server/controllers/agents/client.js @@ -62,6 +62,7 @@ const { collectAttachedCodeEnvironmentAgentIds, collectAttachedCodeEnvironmentPolicySettings, collectAttachedCodeApprovalPolicies, + collectAttachedCodeRoutePolicies, buildAttachedCodeEnvironmentAdmissionHooks, resolveAttachedCodeApprovalMode, markNativeCodeToolApprovalRequests, @@ -4585,6 +4586,7 @@ class AgentClient extends BaseClient { attachedCodeEnvironmentAgentIds, attachedCodeEnvironmentSettings, codeApprovalMode, + collectAttachedCodeRoutePolicies(topLevelAgents), ), ]; const askUserQuestionAdminDisabled = isAskUserQuestionAdminDisabled(appConfig); diff --git a/packages/api/src/agents/codeFilesSession.spec.ts b/packages/api/src/agents/codeFilesSession.spec.ts index 32c2a1f2b78..5d95de3c2df 100644 --- a/packages/api/src/agents/codeFilesSession.spec.ts +++ b/packages/api/src/agents/codeFilesSession.spec.ts @@ -664,6 +664,38 @@ describe('collectCodeExecutionProfileRoutes', () => { expect(routes).toEqual([{ codeExecutionContext: graphContext, codeSessionKeys: [graphKey] }]); }); + it('includes the per-call machines of a graph member a lazy child initializes later', () => { + const memberKey = 'execute_code:stateful:attached:buildbox:member'; + const buildbox = { + baseUrl: 'https://bridge.example.com/v1', + codeSessionKey: memberKey, + executionProfile: 'stateful' as const, + executionRouteKey: 'attached:buildbox', + runtimeSessionHint: 'member', + statefulSessions: true, + environmentId: 'buildbox', + environmentType: 'attached' as const, + }; + + const routes = collectCodeExecutionProfileRoutes([ + { + id: 'parent', + codeEnvAvailable: false, + lazySubagentConfigs: [ + { + id: 'reviewer', + codeEnvAvailable: false, + subagentGraphMemberMetadata: [ + { id: 'member', codeEnvAvailable: false, codeExecutionChoices: [buildbox] }, + ], + }, + ], + }, + ]); + + expect(routes).toEqual([{ codeExecutionContext: buildbox, codeSessionKeys: [memberKey] }]); + }); + it('keeps configured stateful deployments in separate routing namespaces', () => { const context = (executionRouteKey: string, baseUrl: string) => ({ baseUrl, diff --git a/packages/api/src/agents/codeFilesSession.ts b/packages/api/src/agents/codeFilesSession.ts index d368cd345c9..8ea8aa753d3 100644 --- a/packages/api/src/agents/codeFilesSession.ts +++ b/packages/api/src/agents/codeFilesSession.ts @@ -28,6 +28,8 @@ export interface CodeFilesAgent { statefulCodeEnvironment?: StatefulCodeEnvironment; subagentAgentConfigs?: CodeFilesAgent[]; lazySubagentConfigs?: CodeFilesAgent[]; + /** Graph members a lazy child initializes only when it is selected. */ + subagentGraphMemberMetadata?: CodeFilesAgent[]; subagentGraphConfigs?: Array<{ memberConfigs: CodeFilesAgent[] }>; } @@ -44,6 +46,7 @@ function enqueueCodeFilesChildren( for (const child of [ ...(agent.subagentAgentConfigs ?? []), ...(agent.lazySubagentConfigs ?? []), + ...(agent.subagentGraphMemberMetadata ?? []), ]) { if (child && !visited.has(child)) queue.push(child); } diff --git a/packages/api/src/agents/hitl/byom.spec.ts b/packages/api/src/agents/hitl/byom.spec.ts index 7a842002b1c..d665e180876 100644 --- a/packages/api/src/agents/hitl/byom.spec.ts +++ b/packages/api/src/agents/hitl/byom.spec.ts @@ -4,6 +4,7 @@ import { buildAttachedCodeEnvironmentAdmissionHooks, collectAttachedCodeEnvironmentAgentIds, collectAttachedCodeApprovalPolicies, + collectAttachedCodeRoutePolicies, collectAttachedCodeEnvironmentPolicySettings, createAttachedCodeEnvironmentPolicyHook, isStatefulCodeEnvironmentToolName, @@ -628,6 +629,44 @@ describe('collectAttachedCodeApprovalPolicies', () => { ).toBe('acceptEdits'); }); + test('lets an asking per-call machine make a permissive-by-default child pause-capable', () => { + const agents = [ + { + id: 'reviewer', + codeExecutionContext: { + ...acceptEditsMachine, + codeEnvironmentSettings: { + permissions: { fileWrite: 'allow' as const, commandExecution: 'allow' as const }, + }, + }, + codeExecutionChoices: [ + { + environmentType: 'attached', + codeEnvironmentConfigSchema: fullAccessSettings.configSchema, + }, + ], + }, + ]; + const ids = collectAttachedCodeEnvironmentAgentIds(agents); + const settings = collectAttachedCodeEnvironmentPolicySettings(agents); + const pausing = (hooks: ReturnType) => + hooks.flatMap((hook) => hook.toolNames ?? []); + + expect(pausing(buildAttachedCodeEnvironmentAdmissionHooks(ids, settings))).not.toContain( + 'bash_tool', + ); + expect( + pausing( + buildAttachedCodeEnvironmentAdmissionHooks( + ids, + settings, + undefined, + collectAttachedCodeRoutePolicies(agents), + ), + ), + ).toContain('bash_tool'); + }); + test('requires every machine a child may be routed to before granting full access', () => { const agents = [ { diff --git a/packages/api/src/agents/hitl/byom.ts b/packages/api/src/agents/hitl/byom.ts index 1f1d4a2c344..0939c005bb4 100644 --- a/packages/api/src/agents/hitl/byom.ts +++ b/packages/api/src/agents/hitl/byom.ts @@ -223,21 +223,33 @@ export function collectAttachedCodeEnvironmentPolicySettings( } /** - * Every attached-machine policy the run may execute under, for validating the requested approval - * mode: each agent's default route and each machine a parent may route it to per call. + * Each agent's attached-machine policies: its default route and every machine a parent may route + * it to per call. Preflight decisions use all of them, since the call picks the machine later. */ -export function collectAttachedCodeApprovalPolicies( +export function collectAttachedCodeRoutePolicies( roots: readonly (CodeEnvironmentPolicyAgent | null | undefined)[], -): AttachedCodeEnvironmentPolicySettings[] { - return collectCodeEnvironmentPolicyAgents(roots).flatMap((agent) => - [agent.codeExecutionContext, ...(agent.codeExecutionChoices ?? [])] +): Map { + const policiesByAgentId = new Map(); + for (const agent of collectCodeEnvironmentPolicyAgents(roots)) { + const policies = [agent.codeExecutionContext, ...(agent.codeExecutionChoices ?? [])] .filter((context) => context?.environmentType === 'attached') .map((context) => ({ configSchema: context?.codeEnvironmentConfigSchema, settings: context?.codeEnvironmentSettings, skillAuthoringAvailable: agent.skillAuthoringAvailable === true, - })), - ); + })); + if (agent.id && policies.length > 0) { + policiesByAgentId.set(agent.id, [...(policiesByAgentId.get(agent.id) ?? []), ...policies]); + } + } + return policiesByAgentId; +} + +/** Every attached-machine policy the run may execute under, for validating the requested mode. */ +export function collectAttachedCodeApprovalPolicies( + roots: readonly (CodeEnvironmentPolicyAgent | null | undefined)[], +): AttachedCodeEnvironmentPolicySettings[] { + return [...collectAttachedCodeRoutePolicies(roots).values()].flat(); } function permissionDecision( @@ -312,6 +324,11 @@ export function buildAttachedCodeEnvironmentAdmissionHooks( attachedAgentIds: ReadonlySet, settingsByAgentId: ReadonlyMap = new Map(), mode?: CodeApprovalMode, + /** Machines a parent may route an agent to per call; any of them that asks can pause. */ + routePoliciesByAgentId: ReadonlyMap< + string, + readonly AttachedCodeEnvironmentPolicySettings[] + > = new Map(), ): ResolvedToolApprovalHook[] { const hook = createAttachedCodeEnvironmentPolicyHook(attachedAgentIds, settingsByAgentId, mode); const hooks: ResolvedToolApprovalHook[] = []; @@ -319,11 +336,17 @@ export function buildAttachedCodeEnvironmentAdmissionHooks( const askCommandAgents = new Set(); const skillAuthoringAgents = new Set(); for (const agentId of attachedAgentIds) { - const policy = settingsByAgentId.get(agentId); - if (permissionDecision(policy, 'fileWrite', mode) === 'ask') askFileAgents.add(agentId); - if (permissionDecision(policy, 'commandExecution', mode) === 'ask') - askCommandAgents.add(agentId); - if (policy?.skillAuthoringAvailable === true) skillAuthoringAgents.add(agentId); + const policies = [ + settingsByAgentId.get(agentId), + ...(routePoliciesByAgentId.get(agentId) ?? []), + ]; + const asks = (category: PermissionCategory): boolean => + policies.some((policy) => permissionDecision(policy, category, mode) === 'ask'); + if (asks('fileWrite')) askFileAgents.add(agentId); + if (asks('commandExecution')) askCommandAgents.add(agentId); + if (policies.some((policy) => policy?.skillAuthoringAvailable === true)) { + skillAuthoringAgents.add(agentId); + } } if (askFileAgents.size > 0) { hooks.push({ diff --git a/packages/api/src/code/targets.spec.ts b/packages/api/src/code/targets.spec.ts index 5c7dca65aff..62303ae05f8 100644 --- a/packages/api/src/code/targets.spec.ts +++ b/packages/api/src/code/targets.spec.ts @@ -603,6 +603,24 @@ describe('subagent code routing', () => { ).toBe(1); }); + it('never claims a machine for a call canceled while its targets resolve', async () => { + serveWorkers(allOnline); + const routing = createSubagentCodeRouting(request); + const controller = new AbortController(); + + const canceled = routing.place({ + agent: reviewer, + flags, + context: { ...call({ machine: 'buildbox' }), signal: controller.signal }, + }); + controller.abort(); + + await expect(canceled).rejects.toBeDefined(); + await expect( + routing.place({ agent: reviewer, flags, context: call({ machine: 'laptop' }) }), + ).resolves.toMatchObject({ target: { environmentId: 'laptop' } }); + }); + it('attributes a workspace-only conflict to the workspace argument', async () => { serveWorkers(allOnline); const routing = createSubagentCodeRouting(request); diff --git a/packages/api/src/code/targets.ts b/packages/api/src/code/targets.ts index 45e8cad9b0d..c3751f8089b 100644 --- a/packages/api/src/code/targets.ts +++ b/packages/api/src/code/targets.ts @@ -409,6 +409,8 @@ export interface SubagentCodeCallContext { executionId?: string; parentRunId?: string; hostArgs?: SubagentHostArgValues; + /** Cancels this call; a canceled call never claims a machine. */ + signal?: AbortSignal; } /** What one lazy child resolution needs to know about its call and parent. */ @@ -463,6 +465,12 @@ export interface SubagentCodeRouting { routesChildren(executionId: string | null | undefined): boolean; } +function throwIfCanceled(signal?: AbortSignal): void { + if (signal?.aborted === true) { + throw signal.reason ?? new Error('Subagent resolution was aborted.'); + } +} + export function createSubagentCodeRouting({ getInheritedEnvironments, ...request @@ -557,6 +565,7 @@ export function createSubagentCodeRouting({ hostArgs?.[SUBAGENT_MACHINE_ARG] != null || hostArgs?.[SUBAGENT_WORKSPACE_ARG] != null; if (requested) { const resolution = await resolveSubagentCodeTargets(paramsFor(agent, flags)); + throwIfCanceled(context?.signal); const target = selectSubagentCodeTarget(hostArgs, resolution); if (target != null) { if (conflicts(agent.id, target.environmentId)) { @@ -578,6 +587,7 @@ export function createSubagentCodeRouting({ } if (inheritedRoute(agent.id, parentEnvironmentId) != null) { const { targets } = await resolveSubagentCodeTargets(paramsFor(agent, flags)); + throwIfCanceled(context?.signal); /** Re-read after the await: a concurrent call may have settled this subagent meanwhile. */ const inherited = inheritedRoute(agent.id, parentEnvironmentId); const target = From f570bca8cdc16a247298cd6e527ea0929285f4d3 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sun, 4 Oct 2026 23:54:16 -0400 Subject: [PATCH 09/19] =?UTF-8?q?=F0=9F=A7=AD=20fix:=20Release=20Unused=20?= =?UTF-8?q?Route=20Claims=20and=20Scope=20Per-Call=20Machines=20to=20Their?= =?UTF-8?q?=20Run?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Hold a subagent's route claim per placement: a failed or canceled initialization gives the machine back unless another execution still holds it, and a claim is permanent once a child on it attaches. - Fold an agent's default route into its per-call choice digest, so each agent contributes at most one paused-approval binding target. - Use only an agent's route policies for pause admission when it has them, so a missing default does not make a decided route look asking. - Drop a routable child's provisional attached-machine membership when it resolves off attached machines. - Offer and accept no attached machine per call while run files are shared, since shared children must run on managed code. --- .../services/Endpoints/agents/initialize.js | 146 +++++++++--------- .../Endpoints/agents/initialize.spec.js | 35 +++++ .../agents/__tests__/run-codeTools.test.ts | 56 +++++++ packages/api/src/agents/execution.ts | 32 ++-- packages/api/src/agents/files/session.ts | 21 ++- packages/api/src/agents/hitl/byom.spec.ts | 20 +++ packages/api/src/agents/hitl/byom.ts | 6 +- packages/api/src/agents/run.ts | 5 + packages/api/src/code/targets.spec.ts | 77 ++++++++- packages/api/src/code/targets.ts | 138 ++++++++++++++--- 10 files changed, 423 insertions(+), 113 deletions(-) diff --git a/api/server/services/Endpoints/agents/initialize.js b/api/server/services/Endpoints/agents/initialize.js index 5a099e8fa35..886bf341c05 100644 --- a/api/server/services/Endpoints/agents/initialize.js +++ b/api/server/services/Endpoints/agents/initialize.js @@ -43,6 +43,7 @@ const { getCodeApiAuthHeaders, getCodeExecutionBaseUrl, getAuthorizedRunFileSnapshot, + isRunFileSharingRequested, encodeAndFormatDocuments, encodeAndFormatAudios, encodeAndFormatVideos, @@ -1158,6 +1159,10 @@ const initializeClientWithProvider = async ({ conversationId, getAppConfig, getInheritedEnvironments: () => req.codeWorkspaceInheritance, + sharedRunFiles: isRunFileSharingRequested({ + policy: appConfig.endpoints?.agents?.fileSharing, + agent: primaryConfig, + }), }); /** The code flags a lazy subagent runs with in this request. */ @@ -1409,77 +1414,80 @@ const initializeClientWithProvider = async ({ ephemeralSkillsToggle, }); const config = await waitForAbort( - initializeAgent( - { - req, - res, - agent: routedAgent, - loadTools: createToolLoader( + subagentCodeRouting.settle( + placement, + initializeAgent( + { req, res, - context.signal, - streamId, - true, - jobCreatedAt, - upstreamTokenProvider, - upstreamTokenProviderResolver, - ), - requestFiles, - authorizedRunFiles: getAuthorizedRunFileSnapshot({ - policy: appConfig.endpoints?.agents?.fileSharing, - agent: primaryConfig, - files: primaryConfig.currentRequestAttachments, - }), - conversationId, - parentMessageId, - requestBody: runtimeRequestBody, - endpointOption: { ...endpointOption, endpoint: EModelEndpoint.agents }, - allowedProviders, - accessibleSkillIds: scopedSkillIds, - skillAuthoringAvailable: canAuthorSkillFiles({ - agent, - scopedEditableSkillIds, - skillCreateAllowed, - skillsCapabilityEnabled, - ephemeralSkillsToggle, - }), - codeEnvAvailable, - fileSearchAvailable, - resolveLinkedInstructions, - recordLinkedPromptUsage, - backgroundToolsAvailable, - toolIntentsAvailable, - statefulSessionsAvailable, - allowedStatefulCodeEnvironments, - memoryAvailable, - skillStates, - defaultActiveOnShare, - signal: context.signal, - }, - { - getProjectFiles: db.getProjectFiles, - getFiles: db.getFiles, - getUserKey: db.getUserKey, - getMessages: db.getMessages, - getConvoFiles: db.getConvoFiles, - getAccessibleMcpServerNames, - updateFilesUsage: db.updateFilesUsage, - getUserKeyValues: db.getUserKeyValues, - getUserCodeFiles: db.getUserCodeFiles, - getDeferredProvisionFiles: db.getDeferredProvisionFiles, - getToolFilesByIds: db.getToolFilesByIds, - getCodeGeneratedFiles: db.getCodeGeneratedFiles, - filterFilesByAgentAccess, - listSkillsByAccess: skillDbMethods.listSkillsByAccess, - listAlwaysApplySkills: skillDbMethods.listAlwaysApplySkills, - getSkillByName: skillDbMethods.getSkillByName, - provisionToCodeEnv, - provisionToVectorDB, - checkSessionsAlive, - loadCodeApiKey, - updateFile: db.updateFile, - getRoleByName: db.getRoleByName, - }, + agent: routedAgent, + loadTools: createToolLoader( + req, + res, + context.signal, + streamId, + true, + jobCreatedAt, + upstreamTokenProvider, + upstreamTokenProviderResolver, + ), + requestFiles, + authorizedRunFiles: getAuthorizedRunFileSnapshot({ + policy: appConfig.endpoints?.agents?.fileSharing, + agent: primaryConfig, + files: primaryConfig.currentRequestAttachments, + }), + conversationId, + parentMessageId, + requestBody: runtimeRequestBody, + endpointOption: { ...endpointOption, endpoint: EModelEndpoint.agents }, + allowedProviders, + accessibleSkillIds: scopedSkillIds, + skillAuthoringAvailable: canAuthorSkillFiles({ + agent, + scopedEditableSkillIds, + skillCreateAllowed, + skillsCapabilityEnabled, + ephemeralSkillsToggle, + }), + codeEnvAvailable, + fileSearchAvailable, + resolveLinkedInstructions, + recordLinkedPromptUsage, + backgroundToolsAvailable, + toolIntentsAvailable, + statefulSessionsAvailable, + allowedStatefulCodeEnvironments, + memoryAvailable, + skillStates, + defaultActiveOnShare, + signal: context.signal, + }, + { + getProjectFiles: db.getProjectFiles, + getFiles: db.getFiles, + getUserKey: db.getUserKey, + getMessages: db.getMessages, + getConvoFiles: db.getConvoFiles, + getAccessibleMcpServerNames, + updateFilesUsage: db.updateFilesUsage, + getUserKeyValues: db.getUserKeyValues, + getUserCodeFiles: db.getUserCodeFiles, + getDeferredProvisionFiles: db.getDeferredProvisionFiles, + getToolFilesByIds: db.getToolFilesByIds, + getCodeGeneratedFiles: db.getCodeGeneratedFiles, + filterFilesByAgentAccess, + listSkillsByAccess: skillDbMethods.listSkillsByAccess, + listAlwaysApplySkills: skillDbMethods.listAlwaysApplySkills, + getSkillByName: skillDbMethods.getSkillByName, + provisionToCodeEnv, + provisionToVectorDB, + checkSessionsAlive, + loadCodeApiKey, + updateFile: db.updateFile, + getRoleByName: db.getRoleByName, + }, + ), ), context.signal, ); diff --git a/api/server/services/Endpoints/agents/initialize.spec.js b/api/server/services/Endpoints/agents/initialize.spec.js index 69ab6dae73b..65adbf63760 100644 --- a/api/server/services/Endpoints/agents/initialize.spec.js +++ b/api/server/services/Endpoints/agents/initialize.spec.js @@ -2848,6 +2848,41 @@ describe('initializeClient — subagent loading', () => { } }); + it('gives back the machine of a failed initialization so a retry may name another', async () => { + const req = await setup(); + const fetchSpy = mockWorkerStatus(); + try { + await initializeClient({ + req, + res: {}, + signal: new AbortController().signal, + endpointOption: makeEndpointOption(), + }); + const descriptor = agentClientArgs.agent.lazySubagentConfigs[0]; + mockInitializeAgent.mockRejectedValueOnce(new Error('tools failed to load')); + mockInitializeAgent.mockImplementation(async (params) => ({ + ...makeSubagentConfig(SUBAGENT_ID), + codeExecutionContext: { + environmentId: params.agent.code_environment_id, + environmentType: 'attached', + }, + })); + + await expect( + descriptor.resolve({ signal: new AbortController().signal, executionId: 'run-failed' }), + ).rejects.toThrow('tools failed to load'); + const config = await descriptor.resolve({ + signal: new AbortController().signal, + executionId: 'run-retry', + hostArgs: { machine: SKYNET }, + }); + + expect(config.codeExecutionContext.environmentId).toBe(SKYNET); + } finally { + fetchSpy.mockRestore(); + } + }); + it("holds a graph member's default machine while it initializes", async () => { const req = await setup(); const member = await createAgent({ diff --git a/packages/api/src/agents/__tests__/run-codeTools.test.ts b/packages/api/src/agents/__tests__/run-codeTools.test.ts index 7f9c2975f4e..1b9bbfb9ca8 100644 --- a/packages/api/src/agents/__tests__/run-codeTools.test.ts +++ b/packages/api/src/agents/__tests__/run-codeTools.test.ts @@ -610,6 +610,62 @@ describe('attached-machine policy for lazily initialized graph members', () => { codeEnvironmentSettings: { permissions: { commandExecution } }, }); + it('stops applying attached-machine policy to a routable child that resolved on managed code', async () => { + const choice = { ...attachedOn('buildbox', 'deny'), statefulSessions: true }; + const resolve = jest + .fn() + .mockResolvedValue( + makeAgent({ id: 'child', codeExecutionContext: { environmentType: 'managed' } }), + ); + await createRun({ + agents: [ + makeAgent({ + subagents: { enabled: true, allowSelf: false, agent_ids: ['child'] }, + lazySubagentConfigs: [ + { id: 'child', configId: 'child:1', resolve, codeExecutionChoices: [choice] }, + ], + }), + ] as never, + signal: new AbortController().signal, + streaming: true, + streamUsage: true, + hitlCapable: true, + }); + const runConfig = (Run.create as jest.Mock).mock.calls[0][0] as Record; + /** The attached-machine policy's own verdict, apart from the run's other approval hooks. */ + const attachedVerdicts = async () => { + const verdicts: unknown[] = []; + for (const matcher of (runConfig.hooks as HookRegistry).getMatchers('PreToolUse')) { + for (const hook of matcher.hooks) { + const result = await hook( + { + hook_event_name: 'PreToolUse', + runId: 'run-1', + toolName: Constants.BASH_TOOL, + toolInput: { command: 'touch out.txt' }, + toolUseId: 'call-1', + executingAgentId: 'child', + }, + new AbortController().signal, + ); + if (String(result.reason ?? '').includes('attached code environment')) { + verdicts.push(result.decision); + } + } + } + return verdicts; + }; + + expect(await attachedVerdicts()).toEqual(['ask']); + + const [parentInput] = (runConfig.graphConfig as { agents: AgentInputs[] }).agents; + const resolveInputs = parentInput.subagentConfigs?.[0].resolveAgentInputs; + if (!resolveInputs) throw new Error('Missing lazy subagent resolver'); + await resolveInputs({ signal: new AbortController().signal } as SubagentResolveContext); + + expect(await attachedVerdicts()).toEqual([]); + }); + it('applies the policy of the machine a routed child sent its graph member to', async () => { const definition = { type: 'review_team', diff --git a/packages/api/src/agents/execution.ts b/packages/api/src/agents/execution.ts index 2223f7d381a..60279b7472e 100644 --- a/packages/api/src/agents/execution.ts +++ b/packages/api/src/agents/execution.ts @@ -156,23 +156,23 @@ export function captureCodeExecutionApprovalBinding( }; for (const agent of agents) { const agentId = agent?.id ?? null; - const context = agent?.codeExecutionContext; - if (context?.statefulSessions === true) { - addTarget({ agentId, targetHash: hashCodeExecutionTarget(agentId, context) }); - } - /** All of an agent's per-call choices fold into one target, so a graph with many - * routable children stays within the binding's target limit. */ - const choiceHashes = (agent?.codeExecutionChoices ?? []) - .filter((choice) => choice.statefulSessions === true) - .map((choice) => hashCodeExecutionTarget(agentId, choice)); - if (choiceHashes.length > 0) { - addTarget({ - agentId, - targetHash: createHash('sha256') - .update(JSON.stringify(['choices', [...new Set(choiceHashes)].sort()])) - .digest('hex'), - }); + const routeHashes = [agent?.codeExecutionContext, ...(agent?.codeExecutionChoices ?? [])] + .filter((context): context is CodeExecutionContext => context?.statefulSessions === true) + .map((context) => hashCodeExecutionTarget(agentId, context)); + if (routeHashes.length === 0) { + continue; } + /** An agent with per-call choices folds its default and every choice into one + * target, so each agent contributes at most one entry to the bounded binding. */ + addTarget({ + agentId, + targetHash: + (agent?.codeExecutionChoices?.length ?? 0) === 0 + ? routeHashes[0] + : createHash('sha256') + .update(JSON.stringify(['choices', [...new Set(routeHashes)].sort()])) + .digest('hex'), + }); } const targets = [...targetsByIdentity.values()]; if (targets.length === 0) { diff --git a/packages/api/src/agents/files/session.ts b/packages/api/src/agents/files/session.ts index b3657b7ba03..eddb4d33294 100644 --- a/packages/api/src/agents/files/session.ts +++ b/packages/api/src/agents/files/session.ts @@ -23,6 +23,21 @@ export interface SharedRunArtifact { source: RunArtifactDescriptor; } +/** Whether a root agent's run shares files with its subagents under the deployment policy. */ +export function isRunFileSharingRequested({ + policy, + agent, +}: { + policy?: TAgentsEndpoint['fileSharing']; + agent: Pick; +}): boolean { + return ( + policy?.enabled === true && + agent.subagents?.enabled === true && + agent.subagents.shareFiles === true + ); +} + export function getAuthorizedRunFileSnapshot({ policy, agent, @@ -32,11 +47,7 @@ export function getAuthorizedRunFileSnapshot({ agent: Pick; files: readonly TFile[]; }): readonly TFile[] | undefined { - return policy?.enabled === true && - agent.subagents?.enabled === true && - agent.subagents.shareFiles === true - ? files - : undefined; + return isRunFileSharingRequested({ policy, agent }) ? files : undefined; } export interface RunFilePreparation { diff --git a/packages/api/src/agents/hitl/byom.spec.ts b/packages/api/src/agents/hitl/byom.spec.ts index d665e180876..567dcbac08a 100644 --- a/packages/api/src/agents/hitl/byom.spec.ts +++ b/packages/api/src/agents/hitl/byom.spec.ts @@ -667,6 +667,26 @@ describe('collectAttachedCodeApprovalPolicies', () => { ).toContain('bash_tool'); }); + test('does not treat a missing default as asking when every per-call machine decides', () => { + const decides = { + environmentType: 'attached', + codeEnvironmentConfigSchema: fullAccessSettings.configSchema, + codeEnvironmentSettings: { + permissions: { fileWrite: 'allow' as const, commandExecution: 'deny' as const }, + }, + }; + const agents = [{ id: 'reviewer', codeExecutionChoices: [decides, decides] }]; + + expect( + buildAttachedCodeEnvironmentAdmissionHooks( + collectAttachedCodeEnvironmentAgentIds(agents), + collectAttachedCodeEnvironmentPolicySettings(agents), + undefined, + collectAttachedCodeRoutePolicies(agents), + ), + ).toEqual([]); + }); + test('requires every machine a child may be routed to before granting full access', () => { const agents = [ { diff --git a/packages/api/src/agents/hitl/byom.ts b/packages/api/src/agents/hitl/byom.ts index 0939c005bb4..ca9fd569b81 100644 --- a/packages/api/src/agents/hitl/byom.ts +++ b/packages/api/src/agents/hitl/byom.ts @@ -336,10 +336,8 @@ export function buildAttachedCodeEnvironmentAdmissionHooks( const askCommandAgents = new Set(); const skillAuthoringAgents = new Set(); for (const agentId of attachedAgentIds) { - const policies = [ - settingsByAgentId.get(agentId), - ...(routePoliciesByAgentId.get(agentId) ?? []), - ]; + const routePolicies = routePoliciesByAgentId.get(agentId) ?? []; + const policies = routePolicies.length > 0 ? routePolicies : [settingsByAgentId.get(agentId)]; const asks = (category: PermissionCategory): boolean => policies.some((policy) => permissionDecision(policy, category, mode) === 'ask'); if (asks('fileWrite')) askFileAgents.add(agentId); diff --git a/packages/api/src/agents/run.ts b/packages/api/src/agents/run.ts index a6df4acc696..841c6e1190e 100644 --- a/packages/api/src/agents/run.ts +++ b/packages/api/src/agents/run.ts @@ -2870,6 +2870,11 @@ export async function createRun({ settings: resolvedAgent.codeExecutionContext.codeEnvironmentSettings, skillAuthoringAvailable: resolvedAgent.skillAuthoringAvailable === true, }); + } else { + /** A routable child counted as attached before its call chose a route; it resolved + * off attached machines, and runs on that one route for the whole request. */ + attachedCodeEnvironmentAgentIds.delete(resolvedAgent.id); + attachedCodeEnvironmentSettings.delete(resolvedAgent.id); } const discoveredAliases = collectRunMCPToolAliases([resolvedAgent]).filter( ({ name, aliasName }) => { diff --git a/packages/api/src/code/targets.spec.ts b/packages/api/src/code/targets.spec.ts index 62303ae05f8..b96a5e4c105 100644 --- a/packages/api/src/code/targets.spec.ts +++ b/packages/api/src/code/targets.spec.ts @@ -621,6 +621,73 @@ describe('subagent code routing', () => { ).resolves.toMatchObject({ target: { environmentId: 'laptop' } }); }); + it('gives back a machine whose initialization failed unless another call still holds it', async () => { + serveWorkers(allOnline); + const routing = createSubagentCodeRouting(request); + const failed = new Error('initialization failed'); + + const first = await routing.place({ agent: reviewer, flags, context: call() }); + const second = await routing.place({ agent: reviewer, flags, context: call() }); + await expect(routing.settle(first, Promise.reject(failed))).rejects.toBe(failed); + await expect( + routing.place({ agent: reviewer, flags, context: call({ machine: 'buildbox' }) }), + ).rejects.toMatchObject({ argument: 'machine' }); + + await expect(routing.settle(second, Promise.reject(failed))).rejects.toBe(failed); + await expect( + routing.place({ agent: reviewer, flags, context: call({ machine: 'buildbox' }) }), + ).resolves.toMatchObject({ target: { environmentId: 'buildbox' } }); + }); + + it('gives back a machine when its call is canceled before the child attaches', async () => { + serveWorkers(allOnline); + const routing = createSubagentCodeRouting(request); + const controller = new AbortController(); + + await routing.place({ + agent: reviewer, + flags, + context: { ...call({ machine: 'buildbox' }), signal: controller.signal }, + }); + controller.abort(); + + await expect( + routing.place({ agent: reviewer, flags, context: call({ machine: 'laptop' }) }), + ).resolves.toMatchObject({ target: { environmentId: 'laptop' } }); + }); + + it('keeps a machine once a child on it has attached', async () => { + serveWorkers(allOnline); + const routing = createSubagentCodeRouting(request); + const controller = new AbortController(); + const routedCall = { ...call({ machine: 'buildbox' }), signal: controller.signal }; + + const placement = await routing.place({ agent: reviewer, flags, context: routedCall }); + routing.attach(new Map(), { + agentId: reviewer.id, + context: routedCall, + placement, + codeExecutionContext: placement.target?.context, + toolContext: 'buildbox', + }); + controller.abort(); + await expect(routing.settle(placement, Promise.reject(new Error('late')))).rejects.toThrow(); + + await expect( + routing.place({ agent: reviewer, flags, context: call({ machine: 'laptop' }) }), + ).rejects.toMatchObject({ argument: 'machine' }); + }); + + it('offers and accepts no attached machine while run files are shared', async () => { + serveWorkers(allOnline); + const routing = createSubagentCodeRouting({ ...request, sharedRunFiles: true }); + + await expect(routing.describe(reviewer, flags)).resolves.toEqual({}); + await expect( + routing.place({ agent: reviewer, flags, context: call({ machine: 'buildbox' }) }), + ).rejects.toMatchObject({ argument: 'machine', rejection: 'not_allowed' }); + }); + it('attributes a workspace-only conflict to the workspace argument', async () => { serveWorkers(allOnline); const routing = createSubagentCodeRouting(request); @@ -791,11 +858,19 @@ describe('subagent code routing', () => { ], }; - expect(binding?.targets).toHaveLength(2); + const changedDefault = { + ...descriptor, + codeExecutionContext: { ...descriptor.codeExecutionContext!, bridgeWorkerId: 'w-replaced' }, + }; + + expect(binding?.targets).toHaveLength(1); expect(() => assertCodeExecutionApprovalBinding(binding, [descriptor])).not.toThrow(); expect(() => assertCodeExecutionApprovalBinding(binding, [changedChoice])).toThrow( 'The attached code environment changed', ); + expect(() => assertCodeExecutionApprovalBinding(binding, [changedDefault])).toThrow( + 'The attached code environment changed', + ); expect( captureCodeExecutionApprovalBinding([ { id: reviewer.id, codeExecutionContext: descriptor.codeExecutionContext }, diff --git a/packages/api/src/code/targets.ts b/packages/api/src/code/targets.ts index c3751f8089b..ac26b59a576 100644 --- a/packages/api/src/code/targets.ts +++ b/packages/api/src/code/targets.ts @@ -463,6 +463,14 @@ export interface SubagentCodeRouting { isRouted(executionId: string | null | undefined): boolean; /** Whether an execution's own subagents (including graph members) inherit a per-call route. */ routesChildren(executionId: string | null | undefined): boolean; + /** + * Awaits a placed child's initialization. A failed initialization gives back + * the machine its placement reserved unless another execution still holds it. + */ + settle( + placement: SubagentCodePlacement, + initialization: Promise, + ): Promise; } function throwIfCanceled(signal?: AbortSignal): void { @@ -471,12 +479,27 @@ function throwIfCanceled(signal?: AbortSignal): void { } } +type RouteClaim = { + environmentId: string | null; + routed: boolean; + /** Set once an execution on this route initialized; a committed claim is never released. */ + committed: boolean; + /** Placements still initializing on this route. */ + holders: number; +}; + export function createSubagentCodeRouting({ getInheritedEnvironments, + sharedRunFiles, ...request }: SubagentCodeRequest & { /** Request-scoped parent inheritance (#16756), read when a default route is reserved. */ getInheritedEnvironments?: () => ReadonlyMap | undefined; + /** + * Run file sharing is active: shared children must run on managed code, so no + * attached machine is offered or accepted per call. + */ + sharedRunFiles?: boolean; }): SubagentCodeRouting { const routedContexts = new Map(); const childRoutes = new Map(); @@ -485,11 +508,74 @@ export function createSubagentCodeRouting({ * state such as the attached-machine permission policy is keyed by agent, so * no call may move a subagent off the machine an earlier call settled on. */ - const routeByAgent = new Map(); + const routeByAgent = new Map(); + /** Placements holding a claim until they attach or are given back. */ + const holds = new WeakMap void }>(); const conflicts = (agentId: string, environmentId: string | null): boolean => { const claimed = routeByAgent.get(agentId); return claimed != null && claimed.environmentId !== environmentId; }; + const release = (placement: object): void => { + const hold = holds.get(placement); + if (hold == null) { + return; + } + holds.delete(placement); + hold.stopWatching(); + const claimed = routeByAgent.get(hold.agentId); + if (claimed == null || claimed.committed) { + return; + } + claimed.holders -= 1; + if (claimed.holders <= 0) { + routeByAgent.delete(hold.agentId); + } + }; + /** Claims (or joins) a subagent's one route for a placement until it attaches or fails. */ + const hold = ( + placement: SubagentCodePlacement, + route: { environmentId: string | null; routed: boolean }, + signal?: AbortSignal, + ): SubagentCodePlacement => { + const agentId = placement.agent.id; + const claimed = routeByAgent.get(agentId); + if (claimed == null) { + routeByAgent.set(agentId, { ...route, committed: false, holders: 1 }); + } else { + claimed.holders += 1; + claimed.routed = claimed.routed || route.routed; + } + const onAbort = (): void => release(placement); + signal?.addEventListener('abort', onAbort, { once: true }); + holds.set(placement, { + agentId, + stopWatching: () => signal?.removeEventListener('abort', onAbort), + }); + return placement; + }; + const commit = (placement: object): void => { + const held = holds.get(placement); + if (held == null) { + return; + } + holds.delete(placement); + held.stopWatching(); + const claimed = routeByAgent.get(held.agentId); + if (claimed != null) { + claimed.committed = true; + } + }; + /** Live targets this request may place a subagent on per call. */ + const resolveTargets = async (params: SubagentCodeTargetParams): Promise => { + const resolution = await resolveSubagentCodeTargets(params); + if (sharedRunFiles !== true) { + return resolution; + } + return { + ...resolution, + targets: resolution.targets.filter((target) => target.context.environmentType !== 'attached'), + }; + }; const paramsFor = ( agent: SubagentCodeAgent, flags: SubagentCodeFlags, @@ -525,14 +611,17 @@ export function createSubagentCodeRouting({ const routeTo = ( agent: T, target: SubagentCodeTarget, - ): SubagentCodePlacement => { - routeByAgent.set(agent.id, { environmentId: target.environmentId, routed: true }); - return { - agent: placeSubagentOnCodeTarget(agent, target), - target, - childEnvironmentId: target.environmentId, - }; - }; + signal?: AbortSignal, + ): SubagentCodePlacement => + hold( + { + agent: placeSubagentOnCodeTarget(agent, target), + target, + childEnvironmentId: target.environmentId, + }, + { environmentId: target.environmentId, routed: true }, + signal, + ); /** The machine an omitted call should follow: this subagent's earlier per-call * route, else its routed parent's machine unless that would move it. */ const inheritedRoute = (agentId: string, parentEnvironmentId?: string): string | undefined => { @@ -550,7 +639,7 @@ export function createSubagentCodeRouting({ if (!isSubagentHostArgsSupported() || flags.statefulCodeSessions !== true) { return {}; } - const { targets } = await resolveSubagentCodeTargets(paramsFor(agent, flags)); + const { targets } = await resolveTargets(paramsFor(agent, flags)); const subagentHostArgs = buildSubagentCodeHostArgs(targets); return subagentHostArgs == null ? {} @@ -564,7 +653,7 @@ export function createSubagentCodeRouting({ const requested = hostArgs?.[SUBAGENT_MACHINE_ARG] != null || hostArgs?.[SUBAGENT_WORKSPACE_ARG] != null; if (requested) { - const resolution = await resolveSubagentCodeTargets(paramsFor(agent, flags)); + const resolution = await resolveTargets(paramsFor(agent, flags)); throwIfCanceled(context?.signal); const target = selectSubagentCodeTarget(hostArgs, resolution); if (target != null) { @@ -576,7 +665,7 @@ export function createSubagentCodeRouting({ 'unavailable', ); } - return routeTo(agent, target); + return routeTo(agent, target, context?.signal); } } if (flags.statefulCodeSessions !== true) { @@ -586,7 +675,7 @@ export function createSubagentCodeRouting({ : { agent, childEnvironmentId: parentEnvironmentId }; } if (inheritedRoute(agent.id, parentEnvironmentId) != null) { - const { targets } = await resolveSubagentCodeTargets(paramsFor(agent, flags)); + const { targets } = await resolveTargets(paramsFor(agent, flags)); throwIfCanceled(context?.signal); /** Re-read after the await: a concurrent call may have settled this subagent meanwhile. */ const inherited = inheritedRoute(agent.id, parentEnvironmentId); @@ -595,7 +684,7 @@ export function createSubagentCodeRouting({ ? undefined : targets.find((candidate) => candidate.environmentId === inherited); if (target != null) { - return routeTo(agent, target); + return routeTo(agent, target, context?.signal); } if (routeByAgent.get(agent.id)?.routed === true) { throw createSubagentHostArgumentError(SUBAGENT_MACHINE_ARG, 'unavailable'); @@ -605,10 +694,12 @@ export function createSubagentCodeRouting({ throw new CodeWorkspaceSelectionError(unavailableReason); } /** Reserved now, not after initialization, so a concurrent call cannot claim another machine. */ - if (!routeByAgent.has(agent.id)) { - routeByAgent.set(agent.id, { environmentId: defaultRouteOf(agent, flags), routed: false }); - } - return { agent }; + const claimed = routeByAgent.get(agent.id); + return hold( + { agent }, + claimed ?? { environmentId: defaultRouteOf(agent, flags), routed: false }, + context?.signal, + ); }, routesChildren(executionId) { return executionId != null && childRoutes.has(executionId); @@ -621,10 +712,13 @@ export function createSubagentCodeRouting({ if (executionId && placement.childEnvironmentId != null) { childRoutes.set(executionId, placement.childEnvironmentId); } + commit(placement); if (placement.target == null && !routeByAgent.has(agentId)) { routeByAgent.set(agentId, { environmentId: codeExecutionContext?.environmentId ?? null, routed: false, + committed: true, + holders: 0, }); } if (placement.target == null || !executionId) { @@ -654,5 +748,13 @@ export function createSubagentCodeRouting({ isRouted(executionId) { return executionId != null && routedContexts.has(executionId); }, + async settle(placement, initialization) { + try { + return await initialization; + } catch (error) { + release(placement); + throw error; + } + }, }; } From 9469c223305c2f0b1fe7e085d4cb4aaac038e796 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 5 Oct 2026 00:22:05 -0400 Subject: [PATCH 10/19] =?UTF-8?q?=F0=9F=A7=AD=20fix:=20Give=20Back=20a=20R?= =?UTF-8?q?oute=20When=20Its=20Placement=20Verification=20Fails?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Release a placement's hold when attach finds the child initialized on a different machine than the one it was routed to, so a retry that follows the unavailable-machine guidance may name another machine. --- packages/api/src/code/targets.spec.ts | 21 +++++++++++++++++++++ packages/api/src/code/targets.ts | 7 ++++++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/packages/api/src/code/targets.spec.ts b/packages/api/src/code/targets.spec.ts index b96a5e4c105..23e7a108c70 100644 --- a/packages/api/src/code/targets.spec.ts +++ b/packages/api/src/code/targets.spec.ts @@ -678,6 +678,27 @@ describe('subagent code routing', () => { ).rejects.toMatchObject({ argument: 'machine' }); }); + it('gives back a machine whose initialization landed elsewhere', async () => { + serveWorkers(allOnline); + const routing = createSubagentCodeRouting(request); + const routedCall = call({ machine: 'buildbox' }); + + const placement = await routing.place({ agent: reviewer, flags, context: routedCall }); + expect(() => + routing.attach(new Map(), { + agentId: reviewer.id, + context: routedCall, + placement, + codeExecutionContext: { environmentId: 'laptop' }, + toolContext: 'laptop', + }), + ).toThrow(); + + await expect( + routing.place({ agent: reviewer, flags, context: call({ machine: 'laptop' }) }), + ).resolves.toMatchObject({ target: { environmentId: 'laptop' } }); + }); + it('offers and accepts no attached machine while run files are shared', async () => { serveWorkers(allOnline); const routing = createSubagentCodeRouting({ ...request, sharedRunFiles: true }); diff --git a/packages/api/src/code/targets.ts b/packages/api/src/code/targets.ts index ac26b59a576..be103ef7181 100644 --- a/packages/api/src/code/targets.ts +++ b/packages/api/src/code/targets.ts @@ -707,7 +707,12 @@ export function createSubagentCodeRouting({ attach(contexts, { agentId, context, placement, codeExecutionContext, toolContext }) { const executionId = context?.executionId; if (placement.target != null) { - assertSubagentCodePlacement(codeExecutionContext, placement.target); + try { + assertSubagentCodePlacement(codeExecutionContext, placement.target); + } catch (error) { + release(placement); + throw error; + } } if (executionId && placement.childEnvironmentId != null) { childRoutes.set(executionId, placement.childEnvironmentId); From a9727a55990e2ae981e9f04a2f25c72a6d668a84 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 5 Oct 2026 00:33:58 -0400 Subject: [PATCH 11/19] =?UTF-8?q?=F0=9F=A7=AD=20fix:=20Commit=20a=20Lazy?= =?UTF-8?q?=20Child's=20Routes=20Only=20When=20Its=20Whole=20Resolution=20?= =?UTF-8?q?Succeeds?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Keep the routes a lazy child and its routed graph members hold pending through the child's entire resolution (settleExecution), committing them on success and giving them back on failure or cancellation unless another execution still holds them. - Placements outside any call (shared graph members) still commit when they attach. --- .../services/Endpoints/agents/initialize.js | 45 +++---- .../Endpoints/agents/initialize.spec.js | 84 +++++++++++++ packages/api/src/code/targets.spec.ts | 59 +++++++-- packages/api/src/code/targets.ts | 112 +++++++++++++----- 4 files changed, 238 insertions(+), 62 deletions(-) diff --git a/api/server/services/Endpoints/agents/initialize.js b/api/server/services/Endpoints/agents/initialize.js index 886bf341c05..d1a1e8616fe 100644 --- a/api/server/services/Endpoints/agents/initialize.js +++ b/api/server/services/Endpoints/agents/initialize.js @@ -1612,28 +1612,29 @@ const initializeClientWithProvider = async ({ description: metadata.description, codeWorkspaceUnavailable: metadata.codeWorkspaceUnavailable, subagentHostArgs: metadata.subagentHostArgs, - resolve: async (context) => { - const config = await initializeLazySubagent({ - agentId: metadata.id, - configId: metadata.configId, - context, - lazyChildren, - codeFlags: metadata.subagentCodeFlags, - codeWorkspaceUnavailable: metadata.codeWorkspaceUnavailable, - }); - config.subagentAgentConfigs = eagerChildren; - if (!subagentCodeRouting.isRouted(context.executionId)) { - graphMemberConfigsById.set(config.id, config); - } - await resolveGraphSubagentsFor( - config, - context.signal, - subagentCodeRouting.routesChildren(context.executionId) - ? context.executionId - : undefined, - ); - return config; - }, + resolve: (context) => + subagentCodeRouting.settleExecution(context, async () => { + const config = await initializeLazySubagent({ + agentId: metadata.id, + configId: metadata.configId, + context, + lazyChildren, + codeFlags: metadata.subagentCodeFlags, + codeWorkspaceUnavailable: metadata.codeWorkspaceUnavailable, + }); + config.subagentAgentConfigs = eagerChildren; + if (!subagentCodeRouting.isRouted(context.executionId)) { + graphMemberConfigsById.set(config.id, config); + } + await resolveGraphSubagentsFor( + config, + context.signal, + subagentCodeRouting.routesChildren(context.executionId) + ? context.executionId + : undefined, + ); + return config; + }), }), }, metadata, diff --git a/api/server/services/Endpoints/agents/initialize.spec.js b/api/server/services/Endpoints/agents/initialize.spec.js index 65adbf63760..a8b725abb56 100644 --- a/api/server/services/Endpoints/agents/initialize.spec.js +++ b/api/server/services/Endpoints/agents/initialize.spec.js @@ -2883,6 +2883,90 @@ describe('initializeClient — subagent loading', () => { } }); + it('gives back a routed reviewer\u2019s machine when its graph resolution is canceled', async () => { + const req = await setup(); + const member = await createAgent({ + id: 'agent_graph_member', + name: 'Graph member', + provider: 'openai', + model: 'gpt-4', + author: new mongoose.Types.ObjectId(), + tools: ['execute_code'], + stateful_code_sessions: true, + stateful_code_environment: 'user', + code_environment_id: LIA_RAG, + code_environment_ids: [SKYNET], + }); + await grantView(member); + const fetchSpy = mockWorkerStatus(); + try { + await initializeClient({ + req, + res: {}, + signal: new AbortController().signal, + endpointOption: makeEndpointOption(), + }); + const descriptor = agentClientArgs.agent.lazySubagentConfigs[0]; + let memberStarted; + const started = new Promise((resolve) => { + memberStarted = resolve; + }); + let memberLoads = 0; + mockInitializeAgent.mockImplementation(async (params) => { + if (params.agent.id === member.id && memberLoads++ === 0) { + memberStarted(); + await new Promise(() => {}); + } + return { + ...makeSubagentConfig(params.agent.id), + ...(params.agent.id === SUBAGENT_ID + ? { + subagents: { + enabled: true, + allowSelf: false, + agent_ids: [], + graphs: [ + { + type: 'review_team', + name: 'Review team', + description: 'Reviews together.', + agent_ids: [member.id], + edges: [], + entryAgentId: member.id, + resultAgentId: member.id, + }, + ], + }, + } + : {}), + codeExecutionContext: { + environmentId: params.agent.code_environment_id, + environmentType: 'attached', + }, + }; + }); + const controller = new AbortController(); + + const canceled = descriptor.resolve({ + signal: controller.signal, + executionId: 'run-canceled', + hostArgs: { machine: SKYNET }, + }); + await started; + controller.abort(); + await expect(canceled).rejects.toBeDefined(); + const config = await descriptor.resolve({ + signal: new AbortController().signal, + executionId: 'run-retry', + hostArgs: { machine: LIA_RAG }, + }); + + expect(config.codeExecutionContext.environmentId).toBe(LIA_RAG); + } finally { + fetchSpy.mockRestore(); + } + }); + it("holds a graph member's default machine while it initializes", async () => { const req = await setup(); const member = await createAgent({ diff --git a/packages/api/src/code/targets.spec.ts b/packages/api/src/code/targets.spec.ts index 23e7a108c70..d75153f1a31 100644 --- a/packages/api/src/code/targets.spec.ts +++ b/packages/api/src/code/targets.spec.ts @@ -656,19 +656,22 @@ describe('subagent code routing', () => { ).resolves.toMatchObject({ target: { environmentId: 'laptop' } }); }); - it('keeps a machine once a child on it has attached', async () => { + it('keeps a machine once the resolution that placed it succeeds', async () => { serveWorkers(allOnline); const routing = createSubagentCodeRouting(request); const controller = new AbortController(); const routedCall = { ...call({ machine: 'buildbox' }), signal: controller.signal }; - const placement = await routing.place({ agent: reviewer, flags, context: routedCall }); - routing.attach(new Map(), { - agentId: reviewer.id, - context: routedCall, - placement, - codeExecutionContext: placement.target?.context, - toolContext: 'buildbox', + const placement = await routing.settleExecution(routedCall, async () => { + const placed = await routing.place({ agent: reviewer, flags, context: routedCall }); + routing.attach(new Map(), { + agentId: reviewer.id, + context: routedCall, + placement: placed, + codeExecutionContext: placed.target?.context, + toolContext: 'buildbox', + }); + return placed; }); controller.abort(); await expect(routing.settle(placement, Promise.reject(new Error('late')))).rejects.toThrow(); @@ -678,6 +681,46 @@ describe('subagent code routing', () => { ).rejects.toMatchObject({ argument: 'machine' }); }); + it('gives back a child and its graph members when the rest of its resolution fails', async () => { + serveWorkers(allOnline); + const routing = createSubagentCodeRouting(request); + const member = { ...reviewer, id: 'agent_member' }; + const routedCall = call({ machine: 'buildbox' }); + const memberCall = { + executionId: `${routedCall.executionId}:graph:${member.id}`, + parentRunId: routedCall.executionId, + }; + await expect( + routing.settleExecution(routedCall, async () => { + const placed = await routing.place({ agent: reviewer, flags, context: routedCall }); + routing.attach(new Map(), { + agentId: reviewer.id, + context: routedCall, + placement: placed, + codeExecutionContext: placed.target?.context, + toolContext: 'buildbox', + }); + const memberPlaced = await routing.place({ agent: member, flags, context: memberCall }); + routing.attach(new Map(), { + agentId: member.id, + context: memberCall, + placement: memberPlaced, + codeExecutionContext: memberPlaced.target?.context, + toolContext: 'buildbox', + }); + expect(memberPlaced.target?.environmentId).toBe('buildbox'); + throw new Error('a graph member failed to load'); + }), + ).rejects.toThrow('a graph member failed to load'); + + await expect( + routing.place({ agent: reviewer, flags, context: call({ machine: 'laptop' }) }), + ).resolves.toMatchObject({ target: { environmentId: 'laptop' } }); + await expect( + routing.place({ agent: member, flags, context: call({ machine: 'laptop' }) }), + ).resolves.toMatchObject({ target: { environmentId: 'laptop' } }); + }); + it('gives back a machine whose initialization landed elsewhere', async () => { serveWorkers(allOnline); const routing = createSubagentCodeRouting(request); diff --git a/packages/api/src/code/targets.ts b/packages/api/src/code/targets.ts index be103ef7181..e20f9451313 100644 --- a/packages/api/src/code/targets.ts +++ b/packages/api/src/code/targets.ts @@ -471,6 +471,15 @@ export interface SubagentCodeRouting { placement: SubagentCodePlacement, initialization: Promise, ): Promise; + /** + * Runs one lazy child's whole resolution. The routes it and the graph members it + * initialized hold become permanent only when it succeeds, and are given back + * (unless another execution holds them) when it fails. + */ + settleExecution( + context: Pick | null | undefined, + resolve: () => Promise, + ): Promise; } function throwIfCanceled(signal?: AbortSignal): void { @@ -509,34 +518,64 @@ export function createSubagentCodeRouting({ * no call may move a subagent off the machine an earlier call settled on. */ const routeByAgent = new Map(); - /** Placements holding a claim until they attach or are given back. */ - const holds = new WeakMap void }>(); - const conflicts = (agentId: string, environmentId: string | null): boolean => { - const claimed = routeByAgent.get(agentId); - return claimed != null && claimed.environmentId !== environmentId; - }; - const release = (placement: object): void => { - const hold = holds.get(placement); - if (hold == null) { + /** Placements holding a claim until they are committed or given back. */ + const holds = new WeakMap< + object, + { agentId: string; executions: readonly string[]; stopWatching: () => void } + >(); + /** Pending placements by the execution whose resolution decides them. */ + const holdsByExecution = new Map>(); + /** Ends a placement's hold, keeping its claim when `keep` is true. */ + const endHold = (placement: object, keep: boolean): void => { + const held = holds.get(placement); + if (held == null) { return; } holds.delete(placement); - hold.stopWatching(); - const claimed = routeByAgent.get(hold.agentId); + held.stopWatching(); + for (const executionId of held.executions) { + const pending = holdsByExecution.get(executionId); + pending?.delete(placement); + if (pending?.size === 0) { + holdsByExecution.delete(executionId); + } + } + const claimed = routeByAgent.get(held.agentId); if (claimed == null || claimed.committed) { return; } + if (keep) { + claimed.committed = true; + return; + } claimed.holders -= 1; if (claimed.holders <= 0) { - routeByAgent.delete(hold.agentId); + routeByAgent.delete(held.agentId); } }; - /** Claims (or joins) a subagent's one route for a placement until it attaches or fails. */ + const release = (placement: object): void => endHold(placement, false); + const commit = (placement: object): void => endHold(placement, true); + const settleHoldsOf = (executionId: string | undefined, keep: boolean): void => { + const pending = executionId == null ? undefined : holdsByExecution.get(executionId); + for (const placement of [...(pending ?? [])]) { + endHold(placement, keep); + } + }; + const conflicts = (agentId: string, environmentId: string | null): boolean => { + const claimed = routeByAgent.get(agentId); + return claimed != null && claimed.environmentId !== environmentId; + }; + /** + * Claims (or joins) a subagent's one route for a placement. A call's placement is + * decided by its own resolution and a routed graph member's by its parent's; a + * placement outside any call is committed when it attaches. + */ const hold = ( placement: SubagentCodePlacement, route: { environmentId: string | null; routed: boolean }, - signal?: AbortSignal, + context?: SubagentCodeCallContext | null, ): SubagentCodePlacement => { + const signal = context?.signal; const agentId = placement.agent.id; const claimed = routeByAgent.get(agentId); if (claimed == null) { @@ -547,24 +586,21 @@ export function createSubagentCodeRouting({ } const onAbort = (): void => release(placement); signal?.addEventListener('abort', onAbort, { once: true }); + const executions = [context?.executionId, context?.parentRunId].filter( + (executionId): executionId is string => executionId != null && executionId !== '', + ); + for (const executionId of executions) { + const pending = holdsByExecution.get(executionId) ?? new Set(); + pending.add(placement); + holdsByExecution.set(executionId, pending); + } holds.set(placement, { agentId, + executions, stopWatching: () => signal?.removeEventListener('abort', onAbort), }); return placement; }; - const commit = (placement: object): void => { - const held = holds.get(placement); - if (held == null) { - return; - } - holds.delete(placement); - held.stopWatching(); - const claimed = routeByAgent.get(held.agentId); - if (claimed != null) { - claimed.committed = true; - } - }; /** Live targets this request may place a subagent on per call. */ const resolveTargets = async (params: SubagentCodeTargetParams): Promise => { const resolution = await resolveSubagentCodeTargets(params); @@ -611,7 +647,7 @@ export function createSubagentCodeRouting({ const routeTo = ( agent: T, target: SubagentCodeTarget, - signal?: AbortSignal, + context?: SubagentCodeCallContext | null, ): SubagentCodePlacement => hold( { @@ -620,7 +656,7 @@ export function createSubagentCodeRouting({ childEnvironmentId: target.environmentId, }, { environmentId: target.environmentId, routed: true }, - signal, + context, ); /** The machine an omitted call should follow: this subagent's earlier per-call * route, else its routed parent's machine unless that would move it. */ @@ -665,7 +701,7 @@ export function createSubagentCodeRouting({ 'unavailable', ); } - return routeTo(agent, target, context?.signal); + return routeTo(agent, target, context); } } if (flags.statefulCodeSessions !== true) { @@ -684,7 +720,7 @@ export function createSubagentCodeRouting({ ? undefined : targets.find((candidate) => candidate.environmentId === inherited); if (target != null) { - return routeTo(agent, target, context?.signal); + return routeTo(agent, target, context); } if (routeByAgent.get(agent.id)?.routed === true) { throw createSubagentHostArgumentError(SUBAGENT_MACHINE_ARG, 'unavailable'); @@ -698,7 +734,7 @@ export function createSubagentCodeRouting({ return hold( { agent }, claimed ?? { environmentId: defaultRouteOf(agent, flags), routed: false }, - context?.signal, + context, ); }, routesChildren(executionId) { @@ -717,7 +753,9 @@ export function createSubagentCodeRouting({ if (executionId && placement.childEnvironmentId != null) { childRoutes.set(executionId, placement.childEnvironmentId); } - commit(placement); + if ((holds.get(placement)?.executions.length ?? 0) === 0) { + commit(placement); + } if (placement.target == null && !routeByAgent.has(agentId)) { routeByAgent.set(agentId, { environmentId: codeExecutionContext?.environmentId ?? null, @@ -761,5 +799,15 @@ export function createSubagentCodeRouting({ throw error; } }, + async settleExecution(context, resolve) { + try { + const resolved = await resolve(); + settleHoldsOf(context?.executionId, true); + return resolved; + } catch (error) { + settleHoldsOf(context?.executionId, false); + throw error; + } + }, }; } From a6c17572c00b23d7754ed96e4348faf062bd417b Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 5 Oct 2026 00:43:57 -0400 Subject: [PATCH 12/19] =?UTF-8?q?=F0=9F=A7=AD=20fix:=20Drop=20a=20Failed?= =?UTF-8?q?=20Lazy=20Resolution's=20Config=20from=20the=20Graph=20Cache?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - When a lazy child's graph resolution fails or is canceled after its config was cached for graph reuse, remove that entry with its route, so a later graph never reuses a config for a machine the child gave back. --- .../services/Endpoints/agents/initialize.js | 25 ++++-- .../Endpoints/agents/initialize.spec.js | 87 +++++++++++++++++++ 2 files changed, 104 insertions(+), 8 deletions(-) diff --git a/api/server/services/Endpoints/agents/initialize.js b/api/server/services/Endpoints/agents/initialize.js index d1a1e8616fe..6a63e969825 100644 --- a/api/server/services/Endpoints/agents/initialize.js +++ b/api/server/services/Endpoints/agents/initialize.js @@ -1623,16 +1623,25 @@ const initializeClientWithProvider = async ({ codeWorkspaceUnavailable: metadata.codeWorkspaceUnavailable, }); config.subagentAgentConfigs = eagerChildren; - if (!subagentCodeRouting.isRouted(context.executionId)) { + const shared = !subagentCodeRouting.isRouted(context.executionId); + if (shared) { graphMemberConfigsById.set(config.id, config); } - await resolveGraphSubagentsFor( - config, - context.signal, - subagentCodeRouting.routesChildren(context.executionId) - ? context.executionId - : undefined, - ); + try { + await resolveGraphSubagentsFor( + config, + context.signal, + subagentCodeRouting.routesChildren(context.executionId) + ? context.executionId + : undefined, + ); + } catch (error) { + /** A failed resolution gives its route back, so its config must not be reused. */ + if (shared && graphMemberConfigsById.get(config.id) === config) { + graphMemberConfigsById.delete(config.id); + } + throw error; + } return config; }), }), diff --git a/api/server/services/Endpoints/agents/initialize.spec.js b/api/server/services/Endpoints/agents/initialize.spec.js index a8b725abb56..78a8bcf4df5 100644 --- a/api/server/services/Endpoints/agents/initialize.spec.js +++ b/api/server/services/Endpoints/agents/initialize.spec.js @@ -2967,6 +2967,93 @@ describe('initializeClient — subagent loading', () => { } }); + it('does not reuse the config of a resolution whose graph failed', async () => { + const req = await setup(); + const member = await createAgent({ + id: 'agent_graph_member', + name: 'Graph member', + provider: 'openai', + model: 'gpt-4', + author: new mongoose.Types.ObjectId(), + tools: ['execute_code'], + stateful_code_sessions: true, + stateful_code_environment: 'user', + code_environment_id: LIA_RAG, + code_environment_ids: [SKYNET], + }); + await grantView(member); + const fetchSpy = mockWorkerStatus(); + try { + await initializeClient({ + req, + res: {}, + signal: new AbortController().signal, + endpointOption: makeEndpointOption(), + }); + const descriptor = agentClientArgs.agent.lazySubagentConfigs[0]; + let memberStarted; + const started = new Promise((resolve) => { + memberStarted = resolve; + }); + let memberLoads = 0; + mockInitializeAgent.mockImplementation(async (params) => { + if (params.agent.id === member.id && memberLoads++ === 0) { + memberStarted(); + await new Promise(() => {}); + } + return { + ...makeSubagentConfig(params.agent.id), + ...(params.agent.id === SUBAGENT_ID + ? { + subagents: { + enabled: true, + allowSelf: false, + agent_ids: [], + graphs: [ + { + type: 'review_team', + name: 'Review team', + description: 'Reviews together.', + agent_ids: [SUBAGENT_ID, member.id], + edges: [], + entryAgentId: SUBAGENT_ID, + resultAgentId: member.id, + }, + ], + }, + } + : {}), + codeExecutionContext: { + environmentId: params.agent.code_environment_id, + environmentType: 'attached', + }, + }; + }); + const controller = new AbortController(); + + const canceled = descriptor.resolve({ + signal: controller.signal, + executionId: 'run-canceled', + }); + await started; + controller.abort(); + await expect(canceled).rejects.toBeDefined(); + const config = await descriptor.resolve({ + signal: new AbortController().signal, + executionId: 'run-retry', + hostArgs: { machine: LIA_RAG }, + }); + const graphReviewer = config.subagentGraphConfigs?.[0]?.memberConfigs?.find( + (memberConfig) => memberConfig.id === SUBAGENT_ID, + ); + + expect(config.codeExecutionContext.environmentId).toBe(LIA_RAG); + expect(graphReviewer?.codeExecutionContext.environmentId).toBe(LIA_RAG); + } finally { + fetchSpy.mockRestore(); + } + }); + it("holds a graph member's default machine while it initializes", async () => { const req = await setup(); const member = await createAgent({ From dcc783b7bd2471b4d181ea667dbb42b73423cfc1 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 5 Oct 2026 00:57:22 -0400 Subject: [PATCH 13/19] =?UTF-8?q?=F0=9F=A7=AD=20fix:=20Follow=20a=20Routed?= =?UTF-8?q?=20Agent=20Through=20Self-Spawned=20Runs=20and=20Scope=20Routed?= =?UTF-8?q?=20Member=20Configs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - A call whose parent run was never placed (an allowSelf spawn reuses its routed inputs) inherits the routed claim of its parent agent, which keeps one machine per request. - Never share a graph member initialized for a routed parent's execution through the graph-member cache, so a failed resolution leaves no config behind for a route it gave back. - Race lazy target discovery with the request signal. --- .../services/Endpoints/agents/initialize.js | 11 ++++--- packages/api/src/code/targets.spec.ts | 33 +++++++++++++++++++ packages/api/src/code/targets.ts | 19 +++++++++-- 3 files changed, 55 insertions(+), 8 deletions(-) diff --git a/api/server/services/Endpoints/agents/initialize.js b/api/server/services/Endpoints/agents/initialize.js index 6a63e969825..e5e35d233f7 100644 --- a/api/server/services/Endpoints/agents/initialize.js +++ b/api/server/services/Endpoints/agents/initialize.js @@ -1255,7 +1255,10 @@ const initializeClientWithProvider = async ({ codeExecutionAvailable: lazyCodeEnvAvailable, memoryAvailable, }), - subagentCodeRouting.describe(agent, { statefulCodeSessions, statefulCodeEnvironment }), + waitForAbort( + subagentCodeRouting.describe(agent, { statefulCodeSessions, statefulCodeEnvironment }), + signal, + ), ]); return copyToolApprovalAdmissionMetadata( { @@ -1748,7 +1751,8 @@ const initializeClientWithProvider = async ({ }; /** A graph member spawned by a per-call-routed parent follows that parent's machine when it - * may, unless it already initialized on its own route this request. */ + * may, unless it already initialized on its own route this request. Its config belongs to + * the parent's execution, so it is never shared through the graph-member cache. */ const loadRoutedGraphMember = async (memberId, parentRunId, graphSignal) => { throwIfAborted(graphSignal); const cached = graphMemberConfigsById.get(memberId); @@ -1770,9 +1774,6 @@ const initializeClientWithProvider = async ({ codeFlags: getSubagentCodeFlags(agent), viewAccessChecked: true, }); - if (!subagentCodeRouting.isRouted(executionId)) { - graphMemberConfigsById.set(memberId, config); - } return config; } catch (error) { if (isFatalAgentInitializationError(error, { signal: graphSignal })) { diff --git a/packages/api/src/code/targets.spec.ts b/packages/api/src/code/targets.spec.ts index d75153f1a31..c537ef09287 100644 --- a/packages/api/src/code/targets.spec.ts +++ b/packages/api/src/code/targets.spec.ts @@ -886,6 +886,39 @@ describe('subagent code routing', () => { expect(explicit.target?.environmentId).toBe('laptop'); }); + it('passes a routed parent machine on from a self-spawned run it never placed', async () => { + serveWorkers(allOnline); + const routing = createSubagentCodeRouting(request); + const parentCall = call({ machine: 'buildbox' }); + const placement = await routing.place({ agent: reviewer, flags, context: parentCall }); + routing.attach(new Map(), { + agentId: reviewer.id, + context: parentCall, + placement, + codeExecutionContext: placement.target?.context, + toolContext: 'buildbox', + }); + const tester = { + id: 'agent_tester', + code_environment_id: 'laptop', + code_environment_ids: ['buildbox'], + }; + + const fromSelfRun = await routing.place({ + agent: tester, + flags, + context: { ...call(undefined, 'self-run-never-placed'), parentAgentId: reviewer.id }, + }); + const fromUnroutedAgent = await routing.place({ + agent: { ...tester, id: 'agent_docs' }, + flags, + context: { ...call(undefined, 'other-run'), parentAgentId: 'agent_unrouted' }, + }); + + expect(fromSelfRun.target?.environmentId).toBe('buildbox'); + expect(fromUnroutedAgent.target).toBeUndefined(); + }); + it('rejects a revoked allowlist entry and a principal that lost the machine', async () => { serveWorkers(allOnline); diff --git a/packages/api/src/code/targets.ts b/packages/api/src/code/targets.ts index e20f9451313..4958d965e83 100644 --- a/packages/api/src/code/targets.ts +++ b/packages/api/src/code/targets.ts @@ -408,6 +408,8 @@ export interface SubagentCodeCallContext { /** This child execution; its own subagents name it as their `parentRunId`. */ executionId?: string; parentRunId?: string; + /** The agent that dispatched this call; read when its run was never placed (a self-spawn). */ + parentAgentId?: string; hostArgs?: SubagentHostArgValues; /** Cancels this call; a canceled call never claims a machine. */ signal?: AbortSignal; @@ -658,6 +660,19 @@ export function createSubagentCodeRouting({ { environmentId: target.environmentId, routed: true }, context, ); + /** + * The machine a call's parent runs on per call: its execution's recorded route, else + * the routed claim of the parent agent, since an agent keeps one machine per request + * even in runs that were never placed (an `allowSelf` spawn reuses its inputs). + */ + const parentRouteOf = (context?: SubagentCodeCallContext | null): string | undefined => { + const recorded = context?.parentRunId ? childRoutes.get(context.parentRunId) : undefined; + if (recorded != null || !context?.parentAgentId) { + return recorded; + } + const claimed = routeByAgent.get(context.parentAgentId); + return claimed?.routed === true ? (claimed.environmentId ?? undefined) : undefined; + }; /** The machine an omitted call should follow: this subagent's earlier per-call * route, else its routed parent's machine unless that would move it. */ const inheritedRoute = (agentId: string, parentEnvironmentId?: string): string | undefined => { @@ -683,9 +698,7 @@ export function createSubagentCodeRouting({ }, async place({ agent, flags, context, unavailableReason }) { const hostArgs = getSubagentHostArgValues(context); - const parentEnvironmentId = context?.parentRunId - ? childRoutes.get(context.parentRunId) - : undefined; + const parentEnvironmentId = parentRouteOf(context); const requested = hostArgs?.[SUBAGENT_MACHINE_ARG] != null || hostArgs?.[SUBAGENT_WORKSPACE_ARG] != null; if (requested) { From f63b34eca5d19f649e53a0b25b7e6c68ae605d26 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 5 Oct 2026 01:07:26 -0400 Subject: [PATCH 14/19] =?UTF-8?q?=F0=9F=A7=AD=20fix:=20Pass=20a=20Machine?= =?UTF-8?q?=20On=20Through=20Self-Spawned=20Runs=20of=20Agents=20Without?= =?UTF-8?q?=20Code?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Record the machine each agent without stateful code passes on, and let a call from that agent's unplaced run (an allowSelf spawn) follow it when the agent passed on exactly one machine this request. --- packages/api/src/code/targets.spec.ts | 52 +++++++++++++++++++++++++++ packages/api/src/code/targets.ts | 14 +++++++- 2 files changed, 65 insertions(+), 1 deletion(-) diff --git a/packages/api/src/code/targets.spec.ts b/packages/api/src/code/targets.spec.ts index c537ef09287..881fa63e2da 100644 --- a/packages/api/src/code/targets.spec.ts +++ b/packages/api/src/code/targets.spec.ts @@ -919,6 +919,58 @@ describe('subagent code routing', () => { expect(fromUnroutedAgent.target).toBeUndefined(); }); + it('passes a machine on from a self-spawned run of an agent without code', async () => { + serveWorkers(allOnline); + const routing = createSubagentCodeRouting(request); + const placeAndAttach = async ( + agent: SubagentCodeAgent, + context: ReturnType, + agentFlags: SubagentCodeFlags = flags, + ) => { + const placement = await routing.place({ agent, flags: agentFlags, context }); + routing.attach(new Map(), { + agentId: agent.id, + context, + placement, + codeExecutionContext: placement.target?.context, + toolContext: 'context', + }); + }; + const writer = { id: 'agent_writer', code_environment_id: 'laptop' }; + const tester = { + id: 'agent_tester', + code_environment_id: 'laptop', + code_environment_ids: ['buildbox'], + }; + const fromWriterSelfRun = () => + routing.place({ + agent: tester, + flags, + context: { ...call(undefined, `self-run-${Math.random()}`), parentAgentId: writer.id }, + }); + + const parentCall = call({ machine: 'buildbox' }); + await placeAndAttach(reviewer, parentCall); + await placeAndAttach(writer, call(undefined, parentCall.executionId), { + statefulCodeSessions: false, + }); + await expect(fromWriterSelfRun()).resolves.toMatchObject({ + target: { environmentId: 'buildbox' }, + }); + + const lintCall = call({ machine: 'laptop' }); + await placeAndAttach({ ...tester, id: 'agent_lint' }, lintCall); + await placeAndAttach(writer, call(undefined, lintCall.executionId), { + statefulCodeSessions: false, + }); + const ambiguous = await routing.place({ + agent: { ...tester, id: 'agent_docs' }, + flags, + context: { ...call(undefined, 'self-run-ambiguous'), parentAgentId: writer.id }, + }); + expect(ambiguous.target).toBeUndefined(); + }); + it('rejects a revoked allowlist entry and a principal that lost the machine', async () => { serveWorkers(allOnline); diff --git a/packages/api/src/code/targets.ts b/packages/api/src/code/targets.ts index 4958d965e83..5dd07f0a239 100644 --- a/packages/api/src/code/targets.ts +++ b/packages/api/src/code/targets.ts @@ -514,6 +514,8 @@ export function createSubagentCodeRouting({ }): SubagentCodeRouting { const routedContexts = new Map(); const childRoutes = new Map(); + /** Machines each agent that runs no stateful code passed on, for runs it never placed. */ + const passedOnByAgent = new Map>(); /** * One machine per subagent per request, as with parent inheritance: run-wide * state such as the attached-machine permission policy is keyed by agent, so @@ -671,7 +673,12 @@ export function createSubagentCodeRouting({ return recorded; } const claimed = routeByAgent.get(context.parentAgentId); - return claimed?.routed === true ? (claimed.environmentId ?? undefined) : undefined; + if (claimed?.routed === true) { + return claimed.environmentId ?? undefined; + } + /** Only an unambiguous pass-through is followed; otherwise static inheritance applies. */ + const passedOn = passedOnByAgent.get(context.parentAgentId); + return passedOn?.size === 1 ? [...passedOn][0] : undefined; }; /** The machine an omitted call should follow: this subagent's earlier per-call * route, else its routed parent's machine unless that would move it. */ @@ -766,6 +773,11 @@ export function createSubagentCodeRouting({ if (executionId && placement.childEnvironmentId != null) { childRoutes.set(executionId, placement.childEnvironmentId); } + if (placement.target == null && placement.childEnvironmentId != null) { + const passedOn = passedOnByAgent.get(agentId) ?? new Set(); + passedOn.add(placement.childEnvironmentId); + passedOnByAgent.set(agentId, passedOn); + } if ((holds.get(placement)?.executions.length ?? 0) === 0) { commit(placement); } From f3f8b82086d7c5904af87325177943fed69f159d Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 5 Oct 2026 01:20:12 -0400 Subject: [PATCH 15/19] =?UTF-8?q?=F0=9F=A7=AD=20fix:=20Keep=20a=20Pass-Thr?= =?UTF-8?q?ough=20Route=20Only=20When=20Its=20Resolution=20Succeeds?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Record the machine an agent without code passes on with the rest of its call's routing state: kept when the resolution deciding it succeeds, dropped when it fails, so a self-spawn never follows a failed attempt. --- packages/api/src/code/targets.spec.ts | 29 +++++++++++++------- packages/api/src/code/targets.ts | 39 +++++++++++++++++++++++---- 2 files changed, 54 insertions(+), 14 deletions(-) diff --git a/packages/api/src/code/targets.spec.ts b/packages/api/src/code/targets.spec.ts index 881fa63e2da..48aec5b6e5d 100644 --- a/packages/api/src/code/targets.spec.ts +++ b/packages/api/src/code/targets.spec.ts @@ -942,27 +942,38 @@ describe('subagent code routing', () => { code_environment_id: 'laptop', code_environment_ids: ['buildbox'], }; - const fromWriterSelfRun = () => + const fromWriterSelfRun = (id: string) => routing.place({ - agent: tester, + agent: { ...tester, id }, flags, context: { ...call(undefined, `self-run-${Math.random()}`), parentAgentId: writer.id }, }); const parentCall = call({ machine: 'buildbox' }); await placeAndAttach(reviewer, parentCall); - await placeAndAttach(writer, call(undefined, parentCall.executionId), { - statefulCodeSessions: false, - }); - await expect(fromWriterSelfRun()).resolves.toMatchObject({ + const failedWriterCall = call(undefined, parentCall.executionId); + await expect( + routing.settleExecution(failedWriterCall, async () => { + await placeAndAttach(writer, failedWriterCall, { statefulCodeSessions: false }); + throw new Error('the writer failed to resolve'); + }), + ).rejects.toThrow('the writer failed to resolve'); + expect((await fromWriterSelfRun('agent_after_failure')).target).toBeUndefined(); + + const writerCall = call(undefined, parentCall.executionId); + await routing.settleExecution(writerCall, () => + placeAndAttach(writer, writerCall, { statefulCodeSessions: false }), + ); + await expect(fromWriterSelfRun('agent_after_success')).resolves.toMatchObject({ target: { environmentId: 'buildbox' }, }); const lintCall = call({ machine: 'laptop' }); await placeAndAttach({ ...tester, id: 'agent_lint' }, lintCall); - await placeAndAttach(writer, call(undefined, lintCall.executionId), { - statefulCodeSessions: false, - }); + const secondWriterCall = call(undefined, lintCall.executionId); + await routing.settleExecution(secondWriterCall, () => + placeAndAttach(writer, secondWriterCall, { statefulCodeSessions: false }), + ); const ambiguous = await routing.place({ agent: { ...tester, id: 'agent_docs' }, flags, diff --git a/packages/api/src/code/targets.ts b/packages/api/src/code/targets.ts index 5dd07f0a239..363e5b5e624 100644 --- a/packages/api/src/code/targets.ts +++ b/packages/api/src/code/targets.ts @@ -450,7 +450,7 @@ export interface SubagentCodeRouting { contexts: Map, input: { agentId: string; - context?: Pick | null; + context?: Pick | null; placement: SubagentCodePlacement; codeExecutionContext?: Pick | null; toolContext: TContext; @@ -559,11 +559,33 @@ export function createSubagentCodeRouting({ }; const release = (placement: object): void => endHold(placement, false); const commit = (placement: object): void => endHold(placement, true); + const recordPassedOn = (agentId: string, environmentId: string): void => { + const passedOn = passedOnByAgent.get(agentId) ?? new Set(); + passedOn.add(environmentId); + passedOnByAgent.set(agentId, passedOn); + }; + /** Pass-throughs recorded by a call, kept only if the resolution deciding them succeeds. */ + const passOnsByExecution = new Map< + string, + Array<{ agentId: string; environmentId: string; settled: boolean }> + >(); const settleHoldsOf = (executionId: string | undefined, keep: boolean): void => { - const pending = executionId == null ? undefined : holdsByExecution.get(executionId); + if (executionId == null) { + return; + } + const pending = holdsByExecution.get(executionId); for (const placement of [...(pending ?? [])]) { endHold(placement, keep); } + for (const passOn of passOnsByExecution.get(executionId) ?? []) { + if (!passOn.settled) { + passOn.settled = true; + if (keep) { + recordPassedOn(passOn.agentId, passOn.environmentId); + } + } + } + passOnsByExecution.delete(executionId); }; const conflicts = (agentId: string, environmentId: string | null): boolean => { const claimed = routeByAgent.get(agentId); @@ -774,9 +796,16 @@ export function createSubagentCodeRouting({ childRoutes.set(executionId, placement.childEnvironmentId); } if (placement.target == null && placement.childEnvironmentId != null) { - const passedOn = passedOnByAgent.get(agentId) ?? new Set(); - passedOn.add(placement.childEnvironmentId); - passedOnByAgent.set(agentId, passedOn); + const executions = [context?.executionId, context?.parentRunId].filter( + (id): id is string => id != null && id !== '', + ); + if (executions.length === 0) { + recordPassedOn(agentId, placement.childEnvironmentId); + } + const passOn = { agentId, environmentId: placement.childEnvironmentId, settled: false }; + for (const id of executions) { + passOnsByExecution.set(id, [...(passOnsByExecution.get(id) ?? []), passOn]); + } } if ((holds.get(placement)?.executions.length ?? 0) === 0) { commit(placement); From 331c2e77dc70ef56e4264f1fa96d4a812f7df8bb Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 5 Oct 2026 01:41:50 -0400 Subject: [PATCH 16/19] =?UTF-8?q?=F0=9F=A7=AD=20fix:=20Settle=20a=20Lazy?= =?UTF-8?q?=20Child's=20Routing=20State=20at=20the=20SDK=20Handoff?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Wrap the run's whole lazy resolution, through the inputs handed to the SDK, in the descriptor's settle hook, so a cancel after the host resolver returns still gives the call's routes back. - Roll back the per-agent tool context a pending placement set (restoring the entry it replaced) when its resolution fails, so a retry on another machine seeds its own context. - Drop a failed call's shared config from the graph cache through the same settle hook. --- .../services/Endpoints/agents/initialize.js | 67 ++++++++------- .../Endpoints/agents/initialize.spec.js | 12 ++- .../agents/__tests__/run-codeTools.test.ts | 35 ++++++++ packages/api/src/agents/run.ts | 83 ++++++++++--------- packages/api/src/code/targets.spec.ts | 38 +++++++++ packages/api/src/code/targets.ts | 40 +++++++-- 6 files changed, 196 insertions(+), 79 deletions(-) diff --git a/api/server/services/Endpoints/agents/initialize.js b/api/server/services/Endpoints/agents/initialize.js index e5e35d233f7..1617cce5d01 100644 --- a/api/server/services/Endpoints/agents/initialize.js +++ b/api/server/services/Endpoints/agents/initialize.js @@ -1611,42 +1611,37 @@ const initializeClientWithProvider = async ({ lazySubagentConfigs: lazyChildren, subagentAgentConfigs: eagerChildren, subagentGraphMemberMetadata, + settle: (context, resolveInputs) => + subagentCodeRouting.settleExecution(context, resolveInputs, () => + forgetSharedConfig(context), + ), ...guardRoutableSubagent({ description: metadata.description, codeWorkspaceUnavailable: metadata.codeWorkspaceUnavailable, subagentHostArgs: metadata.subagentHostArgs, - resolve: (context) => - subagentCodeRouting.settleExecution(context, async () => { - const config = await initializeLazySubagent({ - agentId: metadata.id, - configId: metadata.configId, - context, - lazyChildren, - codeFlags: metadata.subagentCodeFlags, - codeWorkspaceUnavailable: metadata.codeWorkspaceUnavailable, - }); - config.subagentAgentConfigs = eagerChildren; - const shared = !subagentCodeRouting.isRouted(context.executionId); - if (shared) { - graphMemberConfigsById.set(config.id, config); - } - try { - await resolveGraphSubagentsFor( - config, - context.signal, - subagentCodeRouting.routesChildren(context.executionId) - ? context.executionId - : undefined, - ); - } catch (error) { - /** A failed resolution gives its route back, so its config must not be reused. */ - if (shared && graphMemberConfigsById.get(config.id) === config) { - graphMemberConfigsById.delete(config.id); - } - throw error; - } - return config; - }), + resolve: async (context) => { + const config = await initializeLazySubagent({ + agentId: metadata.id, + configId: metadata.configId, + context, + lazyChildren, + codeFlags: metadata.subagentCodeFlags, + codeWorkspaceUnavailable: metadata.codeWorkspaceUnavailable, + }); + config.subagentAgentConfigs = eagerChildren; + if (!subagentCodeRouting.isRouted(context.executionId)) { + graphMemberConfigsById.set(config.id, config); + sharedConfigByCall.set(context, config); + } + await resolveGraphSubagentsFor( + config, + context.signal, + subagentCodeRouting.routesChildren(context.executionId) + ? context.executionId + : undefined, + ); + return config; + }, }), }, metadata, @@ -1691,6 +1686,14 @@ const initializeClientWithProvider = async ({ const graphMemberConfigsById = new Map( rootSubagentConfigs.filter((config) => config?.id).map((config) => [config.id, config]), ); + /** A lazy call's shared config is reused only if that call's selection succeeds. */ + const sharedConfigByCall = new WeakMap(); + const forgetSharedConfig = (context) => { + const config = sharedConfigByCall.get(context); + if (config != null && graphMemberConfigsById.get(config.id) === config) { + graphMemberConfigsById.delete(config.id); + } + }; const graphMemberLoadsById = new Map(); const initializeGraphMember = createConcurrencyLimiter(SUBAGENT_GRAPH_LOAD_CONCURRENCY); const loadGraphMemberOnce = async (memberId) => { diff --git a/api/server/services/Endpoints/agents/initialize.spec.js b/api/server/services/Endpoints/agents/initialize.spec.js index 78a8bcf4df5..40b53dc4cd8 100644 --- a/api/server/services/Endpoints/agents/initialize.spec.js +++ b/api/server/services/Endpoints/agents/initialize.spec.js @@ -2685,6 +2685,10 @@ describe('initializeClient — subagent loading', () => { delete process.env.TEST_INHERIT_WORKSPACE_TOKEN; }); + /** Selects a lazy child the way the run does: the whole resolution inside its settle hook. */ + const select = (descriptor, context) => + descriptor.settle(context, () => descriptor.resolve(context)); + it("routes the reviewer to its parent's machine and workspace, not its own default", async () => { const req = await setup(); const fetchSpy = mockWorkerStatus(); @@ -2947,7 +2951,7 @@ describe('initializeClient — subagent loading', () => { }); const controller = new AbortController(); - const canceled = descriptor.resolve({ + const canceled = select(descriptor, { signal: controller.signal, executionId: 'run-canceled', hostArgs: { machine: SKYNET }, @@ -2955,7 +2959,7 @@ describe('initializeClient — subagent loading', () => { await started; controller.abort(); await expect(canceled).rejects.toBeDefined(); - const config = await descriptor.resolve({ + const config = await select(descriptor, { signal: new AbortController().signal, executionId: 'run-retry', hostArgs: { machine: LIA_RAG }, @@ -3031,14 +3035,14 @@ describe('initializeClient — subagent loading', () => { }); const controller = new AbortController(); - const canceled = descriptor.resolve({ + const canceled = select(descriptor, { signal: controller.signal, executionId: 'run-canceled', }); await started; controller.abort(); await expect(canceled).rejects.toBeDefined(); - const config = await descriptor.resolve({ + const config = await select(descriptor, { signal: new AbortController().signal, executionId: 'run-retry', hostArgs: { machine: LIA_RAG }, diff --git a/packages/api/src/agents/__tests__/run-codeTools.test.ts b/packages/api/src/agents/__tests__/run-codeTools.test.ts index 1b9bbfb9ca8..286735db7d7 100644 --- a/packages/api/src/agents/__tests__/run-codeTools.test.ts +++ b/packages/api/src/agents/__tests__/run-codeTools.test.ts @@ -217,6 +217,41 @@ describe('createRun code-tool eager/session wiring', () => { expect(childInput.additional_instructions?.match(/\/mnt\/data\/data\.csv/g)).toHaveLength(1); }); + it('settles a lazy child only after the run has its inputs, so a late cancel is given back', async () => { + const controller = new AbortController(); + const outcomes: string[] = []; + const settle = jest.fn((_context: unknown, resolveInputs: () => Promise) => + resolveInputs().then( + (inputs) => { + outcomes.push('kept'); + return inputs; + }, + (error: unknown) => { + outcomes.push('given back'); + throw error; + }, + ), + ); + const resolve = jest.fn(async () => { + controller.abort(new Error('canceled after resolving')); + return makeAgent({ id: 'child' }); + }); + const config = await captureRunConfig( + makeAgent({ + subagents: { enabled: true, allowSelf: false, agent_ids: ['child'] }, + lazySubagentConfigs: [{ id: 'child', configId: 'child:1', resolve, settle }], + }), + ); + const [parentInput] = (config.graphConfig as { agents: AgentInputs[] }).agents; + const resolveInputs = parentInput.subagentConfigs?.[0].resolveAgentInputs; + if (!resolveInputs) throw new Error('Missing lazy subagent resolver'); + + await expect( + resolveInputs({ signal: controller.signal } as SubagentResolveContext), + ).rejects.toThrow('canceled after resolving'); + expect(outcomes).toEqual(['given back']); + }); + it('declares per-call machine choices only on lazy children that have them', async () => { const resolve = jest.fn().mockResolvedValue(makeAgent({ id: 'child' })); const subagentHostArgs = { diff --git a/packages/api/src/agents/run.ts b/packages/api/src/agents/run.ts index 841c6e1190e..6548723bdcf 100644 --- a/packages/api/src/agents/run.ts +++ b/packages/api/src/agents/run.ts @@ -540,6 +540,11 @@ type LazySubagentAgent = Pick< /** Lightweight graph-member metadata used only by run-wide capability gates. */ subagentGraphMemberMetadata?: SubagentTreeNode[]; resolve: (context: SubagentResolveContext) => Promise; + /** + * Wraps the whole selection, through the inputs handed to the SDK, so host state the + * call reserved is kept only when the child is actually exposed. + */ + settle?: (context: SubagentResolveContext, resolveInputs: () => Promise) => Promise; }; type SubagentTreeNode = Pick< @@ -1626,6 +1631,43 @@ function createLazySubagentConfig( prebuiltGraphInputs?: ReadonlyMap, onResolvedAgent?: (agent: RunAgent) => void, ): SubagentConfig { + const resolveInputs = async (context: SubagentResolveContext): Promise => { + if (context.signal.aborted) { + throw context.signal.reason ?? new Error('Subagent resolution was aborted.'); + } + const resolvedChild = await child.resolve(context); + if (context.signal.aborted) { + throw context.signal.reason ?? new Error('Subagent resolution was aborted.'); + } + onResolvedAgent?.(resolvedChild); + /** Graph members initialized with the child may run on its per-call machine. */ + for (const graph of resolvedChild.subagentGraphConfigs ?? []) { + for (const member of graph.memberConfigs) { + onResolvedAgent?.(member); + } + } + const childInputs = buildIsolatedAgentInputs(resolvedChild, toInput); + const resolutionState: SubagentBuildState = { + configCount: 1, + rootAgentIds: [resolvedChild.id], + }; + const grandchildConfigs = buildSubagentConfigs( + resolvedChild, + childInputs, + toInput, + resolutionState, + agentsEConfig, + ancestors, + depth, + prebuiltGraphInputs, + false, + onResolvedAgent, + ); + if (grandchildConfigs.length > 0) { + childInputs.subagentConfigs = grandchildConfigs; + } + return childInputs; + }; const config: SubagentConfig = { type: child.id, name: child.name ?? child.id, @@ -1635,43 +1677,10 @@ function createLazySubagentConfig( configId: child.configId, allowNested: true, maxTurns: resolveSubagentMaxTurns(agentsEConfig, child), - resolveAgentInputs: async (context) => { - if (context.signal.aborted) { - throw context.signal.reason ?? new Error('Subagent resolution was aborted.'); - } - const resolvedChild = await child.resolve(context); - if (context.signal.aborted) { - throw context.signal.reason ?? new Error('Subagent resolution was aborted.'); - } - onResolvedAgent?.(resolvedChild); - /** Graph members initialized with the child may run on its per-call machine. */ - for (const graph of resolvedChild.subagentGraphConfigs ?? []) { - for (const member of graph.memberConfigs) { - onResolvedAgent?.(member); - } - } - const childInputs = buildIsolatedAgentInputs(resolvedChild, toInput); - const resolutionState: SubagentBuildState = { - configCount: 1, - rootAgentIds: [resolvedChild.id], - }; - const grandchildConfigs = buildSubagentConfigs( - resolvedChild, - childInputs, - toInput, - resolutionState, - agentsEConfig, - ancestors, - depth, - prebuiltGraphInputs, - false, - onResolvedAgent, - ); - if (grandchildConfigs.length > 0) { - childInputs.subagentConfigs = grandchildConfigs; - } - return childInputs; - }, + resolveAgentInputs: (context) => + child.settle == null + ? resolveInputs(context) + : child.settle(context, () => resolveInputs(context)), }; /** Assigned rather than spread so the field typechecks against SDKs that predate it. */ return child.subagentHostArgs == null diff --git a/packages/api/src/code/targets.spec.ts b/packages/api/src/code/targets.spec.ts index 48aec5b6e5d..3c88ea9815b 100644 --- a/packages/api/src/code/targets.spec.ts +++ b/packages/api/src/code/targets.spec.ts @@ -721,6 +721,44 @@ describe('subagent code routing', () => { ).resolves.toMatchObject({ target: { environmentId: 'laptop' } }); }); + it('gives back the per-agent tool context a failed resolution set', async () => { + serveWorkers(allOnline); + const routing = createSubagentCodeRouting(request); + const contexts = new Map(); + const member = { ...reviewer, id: 'agent_member' }; + const failedCall = call({ machine: 'buildbox' }); + const routedMember = { + executionId: `${failedCall.executionId}:graph:${member.id}`, + parentRunId: failedCall.executionId, + }; + + await expect( + routing.settleExecution(failedCall, async () => { + const placed = await routing.place({ agent: reviewer, flags, context: failedCall }); + routing.attach(contexts, { + agentId: reviewer.id, + context: failedCall, + placement: placed, + codeExecutionContext: placed.target?.context, + toolContext: 'reviewer-on-buildbox', + }); + const memberPlaced = await routing.place({ agent: member, flags, context: routedMember }); + routing.attach(contexts, { + agentId: member.id, + context: routedMember, + placement: memberPlaced, + codeExecutionContext: memberPlaced.target?.context, + toolContext: 'member-on-buildbox', + }); + expect(contexts.get(member.id)).toBe('member-on-buildbox'); + throw new Error('canceled'); + }), + ).rejects.toThrow('canceled'); + + expect(contexts.has(member.id)).toBe(false); + expect(contexts.has(reviewer.id)).toBe(false); + }); + it('gives back a machine whose initialization landed elsewhere', async () => { serveWorkers(allOnline); const routing = createSubagentCodeRouting(request); diff --git a/packages/api/src/code/targets.ts b/packages/api/src/code/targets.ts index 363e5b5e624..c14511fc92d 100644 --- a/packages/api/src/code/targets.ts +++ b/packages/api/src/code/targets.ts @@ -474,13 +474,15 @@ export interface SubagentCodeRouting { initialization: Promise, ): Promise; /** - * Runs one lazy child's whole resolution. The routes it and the graph members it - * initialized hold become permanent only when it succeeds, and are given back - * (unless another execution holds them) when it fails. + * Runs one lazy child's whole resolution, through to the inputs handed to the SDK. + * The routes and per-agent tool contexts it and its graph members hold become + * permanent only when it succeeds; when it fails they are given back (unless + * another execution holds them) and `onRelease` undoes the caller's own state. */ settleExecution( context: Pick | null | undefined, resolve: () => Promise, + onRelease?: () => void, ): Promise; } @@ -529,6 +531,11 @@ export function createSubagentCodeRouting({ >(); /** Pending placements by the execution whose resolution decides them. */ const holdsByExecution = new Map>(); + /** Per-agent tool contexts a pending placement set, with the entry it replaced. */ + const contextOwners = new Map< + string, + { placement: object; contexts: Map; previous?: { value: TContext } } + >(); /** Ends a placement's hold, keeping its claim when `keep` is true. */ const endHold = (placement: object, keep: boolean): void => { const held = holds.get(placement); @@ -537,6 +544,15 @@ export function createSubagentCodeRouting({ } holds.delete(placement); held.stopWatching(); + const owner = contextOwners.get(held.agentId); + if (owner?.placement === placement) { + contextOwners.delete(held.agentId); + if (!keep && owner.previous != null) { + owner.contexts.set(held.agentId, owner.previous.value); + } else if (!keep) { + owner.contexts.delete(held.agentId); + } + } for (const executionId of held.executions) { const pending = holdsByExecution.get(executionId); pending?.delete(placement); @@ -818,13 +834,24 @@ export function createSubagentCodeRouting({ holders: 0, }); } - if (placement.target == null || !executionId) { + /** A placement still pending owns the entry it sets until its resolution settles. */ + const seed = (): void => { + if (holds.has(placement)) { + contextOwners.set(agentId, { + placement, + contexts, + ...(contexts.has(agentId) ? { previous: { value: contexts.get(agentId)! } } : {}), + }); + } contexts.set(agentId, toolContext); + }; + if (placement.target == null || !executionId) { + seed(); return; } routedContexts.set(executionId, { agentId, toolContext }); if (!contexts.has(agentId)) { - contexts.set(agentId, toolContext); + seed(); } }, getToolContext(agentId, executionContext) { @@ -853,13 +880,14 @@ export function createSubagentCodeRouting({ throw error; } }, - async settleExecution(context, resolve) { + async settleExecution(context, resolve, onRelease) { try { const resolved = await resolve(); settleHoldsOf(context?.executionId, true); return resolved; } catch (error) { settleHoldsOf(context?.executionId, false); + onRelease?.(); throw error; } }, From faded6dbec3ce99eacd498a3501a71ae2fd43c73 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 5 Oct 2026 01:58:04 -0400 Subject: [PATCH 17/19] =?UTF-8?q?=F0=9F=A7=AD=20fix:=20Keep=20Per-Agent=20?= =?UTF-8?q?Tool=20Contexts=20on=20Live=20Placements=20and=20Queue=20Target?= =?UTF-8?q?=20Probes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Track every pending placement that uses an agent's per-agent tool context: a committed one sets it, a failed one hands it to a surviving placement, and only the last failure restores the earlier entry. - Let an unplaced run of an agent (an allowSelf spawn) defer to the routed run of the same agent that encloses it when looking up tool contexts. - Queue live target probes at eight in flight, below the shared status poller's ceiling, so concurrent descriptors never misreport a healthy machine as unavailable. --- packages/api/src/code/targets.spec.ts | 96 +++++++++++++++++++++++++++ packages/api/src/code/targets.ts | 88 +++++++++++++++++------- 2 files changed, 161 insertions(+), 23 deletions(-) diff --git a/packages/api/src/code/targets.spec.ts b/packages/api/src/code/targets.spec.ts index 3c88ea9815b..67725d8082f 100644 --- a/packages/api/src/code/targets.spec.ts +++ b/packages/api/src/code/targets.spec.ts @@ -759,6 +759,90 @@ describe('subagent code routing', () => { expect(contexts.has(reviewer.id)).toBe(false); }); + it('keeps the per-agent tool context of a surviving parallel placement', async () => { + serveWorkers(allOnline); + const routing = createSubagentCodeRouting(request); + const contexts = new Map(); + const select = async (toolContext: string, fail: boolean) => { + const selected = call({ machine: 'buildbox' }); + const placement = await routing.place({ agent: reviewer, flags, context: selected }); + routing.attach(contexts, { + agentId: reviewer.id, + context: selected, + placement, + codeExecutionContext: placement.target?.context, + toolContext, + }); + return { + settle: () => + routing.settleExecution(selected, async () => { + if (fail) throw new Error('canceled'); + }), + }; + }; + + const first = await select('first', true); + const second = await select('second', false); + await second.settle(); + await expect(first.settle()).rejects.toThrow('canceled'); + expect(contexts.get(reviewer.id)).toBe('second'); + + const third = await select('third', true); + const fourth = await select('fourth', true); + await expect(third.settle()).rejects.toThrow('canceled'); + await expect(fourth.settle()).rejects.toThrow('canceled'); + expect(contexts.get(reviewer.id)).toBe('second'); + }); + + it('queues live target probes below the shared status poller limit', async () => { + const machines = Array.from({ length: 12 }, (_, index) => machine(`m${index}`, `w-m${index}`)); + let inFlight = 0; + let peak = 0; + jest.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { + inFlight += 1; + peak = Math.max(peak, inFlight); + await new Promise((resolve) => setTimeout(resolve, 5)); + inFlight -= 1; + const workerId = /\/bridge\/workers\/([^/]+)\/status$/.exec(String(input))?.[1] ?? ''; + return new Response( + JSON.stringify({ + protocolVersion: 1, + workerId, + online: true, + ready: true, + leaseExpiresInMs: 45_000, + capabilities: { + statefulWorkspace: true, + sandboxProfile: 'native-srt', + runtimes: ['bash'], + workspaceTools: { + protocolVersion: 1, + operations: ['read_file'], + workspaces: [{ id: `ws-${workerId}` }], + }, + }, + }), + ); + }); + + const { targets } = await resolveSubagentCodeTargets({ + ...request, + agentId: 'agent_reviewer', + statefulSessions: true, + environment: 'conversation', + environmentId: 'm0', + environmentIds: machines.map((entry) => entry.id), + environments: [controlPlane, ...machines], + persistedSelections: machines.map((entry) => ({ + environmentId: entry.id, + workspaceId: `ws-w-${entry.id}`, + })), + }); + + expect(targets).toHaveLength(12); + expect(peak).toBeLessThanOrEqual(8); + }); + it('gives back a machine whose initialization landed elsewhere', async () => { serveWorkers(allOnline); const routing = createSubagentCodeRouting(request); @@ -1221,6 +1305,18 @@ describe('subagent tool contexts', () => { expect(routing.isRouted('run-b')).toBe(false); }); + it('lets an unplaced self-spawn defer to the routed run that spawned it', () => { + const routing = createSubagentCodeRouting({}); + attach(routing, new Map(), 'run-parent', 'buildbox'); + + expect( + routing.getToolContext( + agent.id, + executionContext(['run-parent', agent.id], ['run-self', agent.id]), + ), + ).toBe('buildbox'); + }); + it('matches the innermost execution of the asking agent only', () => { const routing = createSubagentCodeRouting({}); attach(routing, new Map(), 'run-parent', 'buildbox'); diff --git a/packages/api/src/code/targets.ts b/packages/api/src/code/targets.ts index c14511fc92d..56a5327bd8a 100644 --- a/packages/api/src/code/targets.ts +++ b/packages/api/src/code/targets.ts @@ -17,6 +17,7 @@ import { CodeWorkspaceSelectionError, describeCodeWorkspaceUnavailableSubagent } import { resolveCodeExecutionWorkspaceContext } from './capabilities'; import { guardUnavailableSubagent } from '~/agents/lazySubagents'; import { isCodeEnvironmentSelectionEnabled } from './protocol'; +import { createConcurrencyLimiter } from '~/utils/promise'; /** Subagent call property naming the attached machine the child runs on. */ export const SUBAGENT_MACHINE_ARG = 'machine'; @@ -123,6 +124,14 @@ export interface SubagentCodeTargetParams { getAppConfig?: CodeCapabilityConfigLoader; } +/** + * Live target probes run through the process-wide worker status poller, which + * refuses requests past its own concurrency ceiling; queue them well below it so + * concurrent descriptors never misreport a healthy machine as unavailable. + */ +const SUBAGENT_TARGET_PROBE_CONCURRENCY = 8; +const probeTarget = createConcurrencyLimiter(SUBAGENT_TARGET_PROBE_CONCURRENCY); + const NO_TARGETS: SubagentCodeTargets = Object.freeze({ targets: [], unavailableMachines: new Set(), @@ -234,7 +243,9 @@ export async function resolveSubagentCodeTargets( return NO_TARGETS; } const results = await Promise.all( - authorized.map((selection) => resolveCandidate(params, selections, selection)), + authorized.map((selection) => + probeTarget(() => resolveCandidate(params, selections, selection)), + ), ); const targets: SubagentCodeTarget[] = []; const unavailableMachines = new Set(); @@ -531,11 +542,44 @@ export function createSubagentCodeRouting({ >(); /** Pending placements by the execution whose resolution decides them. */ const holdsByExecution = new Map>(); - /** Per-agent tool contexts a pending placement set, with the entry it replaced. */ + /** + * Per-agent tool context entries pending placements use: each user's own context, + * and the entry that preceded them, so the entry always belongs to a live placement. + */ const contextOwners = new Map< string, - { placement: object; contexts: Map; previous?: { value: TContext } } + { + contexts: Map; + users: Map; + previous?: { value: TContext }; + } >(); + const settleContext = (agentId: string, placement: object, keep: boolean): void => { + const owner = contextOwners.get(agentId); + const own = owner?.users.get(placement); + if (owner == null || own === undefined) { + return; + } + owner.users.delete(placement); + if (keep) { + owner.contexts.set(agentId, own); + contextOwners.delete(agentId); + return; + } + const survivor = owner.users.values().next(); + if (!survivor.done) { + if (owner.contexts.get(agentId) === own) { + owner.contexts.set(agentId, survivor.value); + } + return; + } + contextOwners.delete(agentId); + if (owner.previous != null) { + owner.contexts.set(agentId, owner.previous.value); + } else { + owner.contexts.delete(agentId); + } + }; /** Ends a placement's hold, keeping its claim when `keep` is true. */ const endHold = (placement: object, keep: boolean): void => { const held = holds.get(placement); @@ -544,15 +588,7 @@ export function createSubagentCodeRouting({ } holds.delete(placement); held.stopWatching(); - const owner = contextOwners.get(held.agentId); - if (owner?.placement === placement) { - contextOwners.delete(held.agentId); - if (!keep && owner.previous != null) { - owner.contexts.set(held.agentId, owner.previous.value); - } else if (!keep) { - owner.contexts.delete(held.agentId); - } - } + settleContext(held.agentId, placement, keep); for (const executionId of held.executions) { const pending = holdsByExecution.get(executionId); pending?.delete(placement); @@ -834,25 +870,28 @@ export function createSubagentCodeRouting({ holders: 0, }); } - /** A placement still pending owns the entry it sets until its resolution settles. */ - const seed = (): void => { + /** A pending placement is a user of the per-agent entry until its resolution settles. */ + const register = (replace: boolean): void => { + const owner = contextOwners.get(agentId); if (holds.has(placement)) { - contextOwners.set(agentId, { - placement, + const record = owner ?? { contexts, + users: new Map(), ...(contexts.has(agentId) ? { previous: { value: contexts.get(agentId)! } } : {}), - }); + }; + record.users.set(placement, toolContext); + contextOwners.set(agentId, record); + } + if (replace || !contexts.has(agentId)) { + contexts.set(agentId, toolContext); } - contexts.set(agentId, toolContext); }; if (placement.target == null || !executionId) { - seed(); + register(true); return; } routedContexts.set(executionId, { agentId, toolContext }); - if (!contexts.has(agentId)) { - seed(); - } + register(false); }, getToolContext(agentId, executionContext) { if (!agentId || routedContexts.size === 0) { @@ -864,8 +903,11 @@ export function createSubagentCodeRouting({ if (entry.subagentAgentId !== agentId) { continue; } + /** An unplaced run of the agent (an `allowSelf` spawn) defers to an enclosing one. */ const routed = routedContexts.get(entry.subagentRunId); - return routed?.agentId === agentId ? routed.toolContext : undefined; + if (routed?.agentId === agentId) { + return routed.toolContext; + } } return undefined; }, From 1a256928186fbd2456cef7da0be94f9f7c195ad6 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 5 Oct 2026 02:18:27 -0400 Subject: [PATCH 18/19] =?UTF-8?q?=F0=9F=A7=AD=20refactor:=20Load=20Routed?= =?UTF-8?q?=20Graph=20Members=20Through=20a=20packages/api=20Service?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move the routed graph-member load (shared reuse, skip list, view check, execution identity, failure policy) into createRoutedGraphMemberLoader; the initializer only injects its dependencies. - Thread the request and call signals into target resolution so queued probes are skipped once canceled. - Probe nothing in shared-file runs, where every per-call target is attached and unusable. - Retire pending tool-context ownership when a committed placement installs the per-agent entry. --- .../services/Endpoints/agents/initialize.js | 48 ++++++-------- packages/api/src/agents/lazySubagents.spec.ts | 66 +++++++++++++++++++ packages/api/src/agents/lazySubagents.ts | 65 ++++++++++++++++++ packages/api/src/code/targets.spec.ts | 44 ++++++++++++- packages/api/src/code/targets.ts | 42 +++++++----- 5 files changed, 220 insertions(+), 45 deletions(-) diff --git a/api/server/services/Endpoints/agents/initialize.js b/api/server/services/Endpoints/agents/initialize.js index 1617cce5d01..dce3264ca5d 100644 --- a/api/server/services/Endpoints/agents/initialize.js +++ b/api/server/services/Endpoints/agents/initialize.js @@ -24,6 +24,7 @@ const { buildAgentContextAttachmentsByAgentId, collectCodeExecutionProfileRoutes, getLazySubagentConfigId, + createRoutedGraphMemberLoader, createViewableSubagentLoader, resolveCodeExecutionContext, resolveCodeExecutionWorkspaceSelections, @@ -1256,7 +1257,11 @@ const initializeClientWithProvider = async ({ memoryAvailable, }), waitForAbort( - subagentCodeRouting.describe(agent, { statefulCodeSessions, statefulCodeEnvironment }), + subagentCodeRouting.describe( + agent, + { statefulCodeSessions, statefulCodeEnvironment }, + signal, + ), signal, ), ]); @@ -1753,39 +1758,26 @@ const initializeClientWithProvider = async ({ return waitForAbort(pending, graphSignal); }; - /** A graph member spawned by a per-call-routed parent follows that parent's machine when it - * may, unless it already initialized on its own route this request. Its config belongs to - * the parent's execution, so it is never shared through the graph-member cache. */ - const loadRoutedGraphMember = async (memberId, parentRunId, graphSignal) => { - throwIfAborted(graphSignal); - const cached = graphMemberConfigsById.get(memberId); - if (cached) return cached; - if (skippedAgentIds.has(memberId)) return null; - const agent = await waitForAbort(db.getAgentWithVersionCount({ id: memberId }), graphSignal); - if (!agent || !(await hasSubagentViewAccess(agent, memberId, graphSignal))) { - skippedAgentIds.add(memberId); - return null; - } - const executionId = `${parentRunId}:graph:${memberId}`; - try { - const config = await initializeLoadedSubagent({ + const loadRoutedGraphMember = createRoutedGraphMemberLoader({ + getShared: (memberId) => graphMemberConfigsById.get(memberId), + isSkipped: (memberId) => skippedAgentIds.has(memberId), + skip: (memberId) => skippedAgentIds.add(memberId), + getAgent: (memberId, graphSignal) => + waitForAbort(db.getAgentWithVersionCount({ id: memberId }), graphSignal), + canView: (agent, memberId, graphSignal) => hasSubagentViewAccess(agent, memberId, graphSignal), + initialize: ({ agent, memberId, context }) => + initializeLoadedSubagent({ agent, agentId: memberId, configId: getLazySubagentConfigId(agent), - context: { signal: graphSignal, parentRunId, executionId }, + context, lazyChildren: [], codeFlags: getSubagentCodeFlags(agent), viewAccessChecked: true, - }); - return config; - } catch (error) { - if (isFatalAgentInitializationError(error, { signal: graphSignal })) { - throw error; - } - logger.error(`[initializeClient] Error initializing routed graph member ${memberId}:`, error); - return null; - } - }; + }), + isFatal: (error, graphSignal) => + isFatalAgentInitializationError(error, { signal: graphSignal }), + }); async function resolveGraphSubagentsFor(config, graphSignal = signal, routedParentRunId) { throwIfAborted(graphSignal); diff --git a/packages/api/src/agents/lazySubagents.spec.ts b/packages/api/src/agents/lazySubagents.spec.ts index 5e30e1f7e6d..8a17d562841 100644 --- a/packages/api/src/agents/lazySubagents.spec.ts +++ b/packages/api/src/agents/lazySubagents.spec.ts @@ -5,6 +5,7 @@ import { guardUnavailableSubagent, getLazySubagentConfigId, createViewableSubagentLoader, + createRoutedGraphMemberLoader, resolveSubagentCodeAvailability, } from './lazySubagents'; import { CodeWorkspaceSelectionError } from '~/code/errors'; @@ -227,3 +228,68 @@ describe('subagent code availability', () => { }); }); }); + +describe('createRoutedGraphMemberLoader', () => { + const signal = new AbortController().signal; + type Deps = Parameters>[0]; + const setup = (overrides: Partial = {}) => { + const skipped = new Set(); + const deps: Deps = { + getShared: jest.fn((_memberId: string): string | undefined => undefined), + isSkipped: jest.fn((memberId: string) => skipped.has(memberId)), + skip: jest.fn((memberId: string) => { + skipped.add(memberId); + }), + getAgent: jest.fn(async (memberId: string) => ({ id: memberId })), + canView: jest.fn(async () => true), + initialize: jest.fn( + async ({ memberId, context }: { memberId: string; context: { executionId: string } }) => + `${memberId}@${context.executionId}`, + ), + isFatal: jest.fn(() => false), + ...overrides, + }; + return { deps, load: createRoutedGraphMemberLoader<{ id: string }, string>(deps) }; + }; + + it('initializes a member as a child of the routed parent execution', async () => { + const { deps, load } = setup(); + + await expect(load('member', 'run-parent', signal)).resolves.toBe( + 'member@run-parent:graph:member', + ); + expect(deps.initialize).toHaveBeenCalledWith({ + agent: { id: 'member' }, + memberId: 'member', + context: { signal, parentRunId: 'run-parent', executionId: 'run-parent:graph:member' }, + }); + }); + + it('reuses a shared member and never initializes a skipped or unviewable one', async () => { + const shared = setup({ getShared: jest.fn(() => 'shared-config') }); + await expect(shared.load('member', 'run-parent', signal)).resolves.toBe('shared-config'); + expect(shared.deps.getAgent).not.toHaveBeenCalled(); + + const hidden = setup({ canView: jest.fn(async () => false) }); + await expect(hidden.load('member', 'run-parent', signal)).resolves.toBeNull(); + await expect(hidden.load('member', 'run-other', signal)).resolves.toBeNull(); + expect(hidden.deps.getAgent).toHaveBeenCalledTimes(1); + expect(hidden.deps.initialize).not.toHaveBeenCalled(); + }); + + it('drops a member that fails to initialize unless the failure is fatal', async () => { + const failure = new Error('tools failed to load'); + const recoverable = setup({ initialize: jest.fn().mockRejectedValue(failure) }); + await expect(recoverable.load('member', 'run-parent', signal)).resolves.toBeNull(); + + const fatal = setup({ + initialize: jest.fn().mockRejectedValue(failure), + isFatal: jest.fn(() => true), + }); + await expect(fatal.load('member', 'run-parent', signal)).rejects.toBe(failure); + + const aborted = new AbortController(); + aborted.abort(new Error('canceled')); + await expect(fatal.load('member', 'run-parent', aborted.signal)).rejects.toThrow('canceled'); + }); +}); diff --git a/packages/api/src/agents/lazySubagents.ts b/packages/api/src/agents/lazySubagents.ts index 9b70e13666d..021774507b5 100644 --- a/packages/api/src/agents/lazySubagents.ts +++ b/packages/api/src/agents/lazySubagents.ts @@ -193,6 +193,71 @@ export function createViewableSubagentLoader({ }; } +/** The SDK-facing context a routed graph member initializes under. */ +export interface RoutedGraphMemberContext { + signal: AbortSignal; + /** The per-call-routed parent execution the member follows. */ + parentRunId: string; + /** Stable per parent and member; routing groups the member's route with the parent's. */ + executionId: string; +} + +/** + * Loads the graph members a per-call-routed parent resolves. A member already shared + * this request (initialized on its own route) is reused; otherwise it initializes as + * a child of the parent's execution so it may follow the parent's machine, and its + * config stays with that execution rather than the request-wide graph cache. + */ +export function createRoutedGraphMemberLoader({ + getShared, + isSkipped, + skip, + getAgent, + canView, + initialize, + isFatal, +}: { + getShared: (memberId: string) => TConfig | undefined; + isSkipped: (memberId: string) => boolean; + skip: (memberId: string) => void; + getAgent: (memberId: string, signal: AbortSignal) => Promise; + canView: (agent: TAgent, memberId: string, signal: AbortSignal) => Promise; + initialize: (input: { + agent: TAgent; + memberId: string; + context: RoutedGraphMemberContext; + }) => Promise; + isFatal: (error: unknown, signal: AbortSignal) => boolean; +}): (memberId: string, parentRunId: string, signal: AbortSignal) => Promise { + return async (memberId, parentRunId, signal) => { + if (signal.aborted) { + throw signal.reason ?? new Error('Subagent resolution was aborted.'); + } + const shared = getShared(memberId); + if (shared != null) { + return shared; + } + if (isSkipped(memberId)) { + return null; + } + const agent = await getAgent(memberId, signal); + if (agent == null || !(await canView(agent, memberId, signal))) { + skip(memberId); + return null; + } + const executionId = `${parentRunId}:graph:${memberId}`; + try { + return await initialize({ agent, memberId, context: { signal, parentRunId, executionId } }); + } catch (error) { + if (isFatal(error, signal)) { + throw error; + } + logger.error(`[lazySubagents] Error initializing routed graph member ${memberId}:`, error); + return null; + } + }; +} + export interface SubagentCodeAvailability { codeEnvAvailable: boolean; statefulCodeSessions: boolean; diff --git a/packages/api/src/code/targets.spec.ts b/packages/api/src/code/targets.spec.ts index 67725d8082f..37ade394aa2 100644 --- a/packages/api/src/code/targets.spec.ts +++ b/packages/api/src/code/targets.spec.ts @@ -865,13 +865,55 @@ describe('subagent code routing', () => { }); it('offers and accepts no attached machine while run files are shared', async () => { - serveWorkers(allOnline); + const fetchSpy = serveWorkers(allOnline); const routing = createSubagentCodeRouting({ ...request, sharedRunFiles: true }); await expect(routing.describe(reviewer, flags)).resolves.toEqual({}); await expect( routing.place({ agent: reviewer, flags, context: call({ machine: 'buildbox' }) }), ).rejects.toMatchObject({ argument: 'machine', rejection: 'not_allowed' }); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('skips queued target probes once the resolution is canceled', async () => { + const fetchSpy = serveWorkers(allOnline); + const controller = new AbortController(); + controller.abort(new Error('canceled')); + + await expect(resolveSubagentCodeTargets(params({ signal: controller.signal }))).rejects.toThrow( + 'canceled', + ); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('keeps the tool context a committed placement installed over a pending one', async () => { + serveWorkers(allOnline); + const routing = createSubagentCodeRouting(request); + const contexts = new Map(); + const pendingCall = call(); + const pending = await routing.place({ agent: reviewer, flags, context: pendingCall }); + routing.attach(contexts, { + agentId: reviewer.id, + context: pendingCall, + placement: pending, + codeExecutionContext: { environmentId: 'laptop' }, + toolContext: 'pending', + }); + const shared = await routing.place({ agent: reviewer, flags, context: {} }); + routing.attach(contexts, { + agentId: reviewer.id, + context: {}, + placement: shared, + codeExecutionContext: { environmentId: 'laptop' }, + toolContext: 'shared-member', + }); + + await expect( + routing.settleExecution(pendingCall, async () => { + throw new Error('canceled'); + }), + ).rejects.toThrow('canceled'); + expect(contexts.get(reviewer.id)).toBe('shared-member'); }); it('attributes a workspace-only conflict to the workspace argument', async () => { diff --git a/packages/api/src/code/targets.ts b/packages/api/src/code/targets.ts index 56a5327bd8a..f874c5ae261 100644 --- a/packages/api/src/code/targets.ts +++ b/packages/api/src/code/targets.ts @@ -122,6 +122,8 @@ export interface SubagentCodeTargetParams { userId?: string | null; conversationId?: string | null; getAppConfig?: CodeCapabilityConfigLoader; + /** Cancels the resolution; probes still queued are skipped. */ + signal?: AbortSignal; } /** @@ -244,9 +246,14 @@ export async function resolveSubagentCodeTargets( } const results = await Promise.all( authorized.map((selection) => - probeTarget(() => resolveCandidate(params, selections, selection)), + probeTarget(() => + params.signal?.aborted === true + ? Promise.resolve({ status: 'unauthorized' }) + : resolveCandidate(params, selections, selection), + ), ), ); + throwIfCanceled(params.signal); const targets: SubagentCodeTarget[] = []; const unavailableMachines = new Set(); const unavailableWorkspaces = new Set(); @@ -442,7 +449,11 @@ export interface SubagentCodePlacementInput { */ export interface SubagentCodeRouting { /** Builds a child's per-call machine choices; empty on SDKs without host arguments. */ - describe(agent: SubagentCodeAgent, flags: SubagentCodeFlags): Promise; + describe( + agent: SubagentCodeAgent, + flags: SubagentCodeFlags, + signal?: AbortSignal, + ): Promise; /** * Routes one child execution: an explicit call choice (re-validated against * the current request), else the machine a per-call-routed parent runs on @@ -680,21 +691,16 @@ export function createSubagentCodeRouting({ return placement; }; /** Live targets this request may place a subagent on per call. */ - const resolveTargets = async (params: SubagentCodeTargetParams): Promise => { - const resolution = await resolveSubagentCodeTargets(params); - if (sharedRunFiles !== true) { - return resolution; - } - return { - ...resolution, - targets: resolution.targets.filter((target) => target.context.environmentType !== 'attached'), - }; - }; + /** Every per-call target is an attached machine, so a shared-file run probes none. */ + const resolveTargets = (params: SubagentCodeTargetParams): Promise => + sharedRunFiles === true ? Promise.resolve(NO_TARGETS) : resolveSubagentCodeTargets(params); const paramsFor = ( agent: SubagentCodeAgent, flags: SubagentCodeFlags, + signal?: AbortSignal, ): SubagentCodeTargetParams => ({ ...request, + signal, agentId: agent.id, statefulSessions: flags.statefulCodeSessions === true, environment: flags.statefulCodeEnvironment, @@ -767,11 +773,11 @@ export function createSubagentCodeRouting({ return parentEnvironmentId; }; return { - async describe(agent, flags) { + async describe(agent, flags, signal) { if (!isSubagentHostArgsSupported() || flags.statefulCodeSessions !== true) { return {}; } - const { targets } = await resolveTargets(paramsFor(agent, flags)); + const { targets } = await resolveTargets(paramsFor(agent, flags, signal)); const subagentHostArgs = buildSubagentCodeHostArgs(targets); return subagentHostArgs == null ? {} @@ -783,7 +789,7 @@ export function createSubagentCodeRouting({ const requested = hostArgs?.[SUBAGENT_MACHINE_ARG] != null || hostArgs?.[SUBAGENT_WORKSPACE_ARG] != null; if (requested) { - const resolution = await resolveTargets(paramsFor(agent, flags)); + const resolution = await resolveTargets(paramsFor(agent, flags, context?.signal)); throwIfCanceled(context?.signal); const target = selectSubagentCodeTarget(hostArgs, resolution); if (target != null) { @@ -805,7 +811,7 @@ export function createSubagentCodeRouting({ : { agent, childEnvironmentId: parentEnvironmentId }; } if (inheritedRoute(agent.id, parentEnvironmentId) != null) { - const { targets } = await resolveTargets(paramsFor(agent, flags)); + const { targets } = await resolveTargets(paramsFor(agent, flags, context?.signal)); throwIfCanceled(context?.signal); /** Re-read after the await: a concurrent call may have settled this subagent meanwhile. */ const inherited = inheritedRoute(agent.id, parentEnvironmentId); @@ -873,6 +879,10 @@ export function createSubagentCodeRouting({ /** A pending placement is a user of the per-agent entry until its resolution settles. */ const register = (replace: boolean): void => { const owner = contextOwners.get(agentId); + if (!holds.has(placement) && (replace || !contexts.has(agentId))) { + /** A committed placement now owns the entry; nothing pending may roll it back. */ + contextOwners.delete(agentId); + } if (holds.has(placement)) { const record = owner ?? { contexts, From f274737868a5a1862502fe5898e6d97f7acd1962 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 5 Oct 2026 02:57:27 -0400 Subject: [PATCH 19/19] =?UTF-8?q?=F0=9F=A7=AD=20fix:=20Share=20a=20Lazy=20?= =?UTF-8?q?Child's=20Config=20with=20Later=20Graphs=20Only=20After=20It=20?= =?UTF-8?q?Commits?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Publish a default-placed lazy child's config to the shared graph cache from the settle hook's commit, so no concurrent graph can borrow a config whose route may still be given back. - Resolve a child's own appearance in its graph to the child config itself. --- .../services/Endpoints/agents/initialize.js | 27 +++--- .../Endpoints/agents/initialize.spec.js | 84 +++++++++++++++++++ packages/api/src/code/targets.ts | 10 +-- 3 files changed, 103 insertions(+), 18 deletions(-) diff --git a/api/server/services/Endpoints/agents/initialize.js b/api/server/services/Endpoints/agents/initialize.js index dce3264ca5d..333979348b5 100644 --- a/api/server/services/Endpoints/agents/initialize.js +++ b/api/server/services/Endpoints/agents/initialize.js @@ -1617,9 +1617,9 @@ const initializeClientWithProvider = async ({ subagentAgentConfigs: eagerChildren, subagentGraphMemberMetadata, settle: (context, resolveInputs) => - subagentCodeRouting.settleExecution(context, resolveInputs, () => - forgetSharedConfig(context), - ), + subagentCodeRouting.settleExecution(context, resolveInputs, { + onCommit: () => publishSharedConfig(context), + }), ...guardRoutableSubagent({ description: metadata.description, codeWorkspaceUnavailable: metadata.codeWorkspaceUnavailable, @@ -1635,7 +1635,6 @@ const initializeClientWithProvider = async ({ }); config.subagentAgentConfigs = eagerChildren; if (!subagentCodeRouting.isRouted(context.executionId)) { - graphMemberConfigsById.set(config.id, config); sharedConfigByCall.set(context, config); } await resolveGraphSubagentsFor( @@ -1691,12 +1690,12 @@ const initializeClientWithProvider = async ({ const graphMemberConfigsById = new Map( rootSubagentConfigs.filter((config) => config?.id).map((config) => [config.id, config]), ); - /** A lazy call's shared config is reused only if that call's selection succeeds. */ + /** A lazy call's config joins the shared graph cache only once its route commits. */ const sharedConfigByCall = new WeakMap(); - const forgetSharedConfig = (context) => { + const publishSharedConfig = (context) => { const config = sharedConfigByCall.get(context); - if (config != null && graphMemberConfigsById.get(config.id) === config) { - graphMemberConfigsById.delete(config.id); + if (config != null && !graphMemberConfigsById.has(config.id)) { + graphMemberConfigsById.set(config.id, config); } }; const graphMemberLoadsById = new Map(); @@ -1807,11 +1806,13 @@ const initializeClientWithProvider = async ({ } const memberConfigs = await Promise.all( memberIds.map((memberId) => - initializeGraphMember(() => - routedParentRunId == null - ? loadGraphMember(memberId, graphSignal) - : loadRoutedGraphMember(memberId, routedParentRunId, graphSignal), - ), + memberId === config.id + ? config + : initializeGraphMember(() => + routedParentRunId == null + ? loadGraphMember(memberId, graphSignal) + : loadRoutedGraphMember(memberId, routedParentRunId, graphSignal), + ), ), ); throwIfAborted(graphSignal); diff --git a/api/server/services/Endpoints/agents/initialize.spec.js b/api/server/services/Endpoints/agents/initialize.spec.js index 40b53dc4cd8..ac9b3f5fc06 100644 --- a/api/server/services/Endpoints/agents/initialize.spec.js +++ b/api/server/services/Endpoints/agents/initialize.spec.js @@ -3058,6 +3058,90 @@ describe('initializeClient — subagent loading', () => { } }); + it('shares a lazy child config with later graphs only once its selection commits', async () => { + const req = await setup(); + const member = await createAgent({ + id: 'agent_graph_member', + name: 'Graph member', + provider: 'openai', + model: 'gpt-4', + author: new mongoose.Types.ObjectId(), + tools: ['execute_code'], + stateful_code_sessions: true, + stateful_code_environment: 'user', + code_environment_id: LIA_RAG, + code_environment_ids: [], + }); + await grantView(member); + mockInitializeAgent.mockResolvedValue({ + ...makePrimaryConfig({ + subagents: { enabled: true, allowSelf: false, agent_ids: [SUBAGENT_ID, member.id] }, + }), + statefulCodeSessions: true, + codeExecutionContext: { environmentId: LIA_RAG, environmentType: 'attached' }, + }); + const fetchSpy = mockWorkerStatus(); + try { + await initializeClient({ + req, + res: {}, + signal: new AbortController().signal, + endpointOption: makeEndpointOption(), + }); + const descriptorFor = (id) => + agentClientArgs.agent.lazySubagentConfigs.find((descriptor) => descriptor.id === id); + mockInitializeAgent.mockImplementation(async (params) => ({ + ...makeSubagentConfig(params.agent.id), + ...(params.agent.id === SUBAGENT_ID + ? { + subagents: { + enabled: true, + allowSelf: false, + agent_ids: [], + graphs: [ + { + type: 'review_team', + name: 'Review team', + description: 'Reviews together.', + agent_ids: [member.id], + edges: [], + entryAgentId: member.id, + resultAgentId: member.id, + }, + ], + }, + } + : {}), + codeExecutionContext: { + environmentId: params.agent.code_environment_id, + environmentType: 'attached', + }, + })); + const reviewWith = async (executionId) => { + const config = await select(descriptorFor(SUBAGENT_ID), { + signal: new AbortController().signal, + executionId, + hostArgs: { machine: SKYNET }, + }); + return config.subagentGraphConfigs?.[0]?.memberConfigs?.[0]; + }; + + const unsettled = await descriptorFor(member.id).resolve({ + signal: new AbortController().signal, + executionId: 'run-member-unsettled', + }); + expect(await reviewWith('run-review-a')).not.toBe(unsettled); + + const settled = await select(descriptorFor(member.id), { + signal: new AbortController().signal, + executionId: 'run-member-settled', + }); + expect(await reviewWith('run-review-b')).toBe(settled); + } finally { + fetchSpy.mockRestore(); + } + }); + it("holds a graph member's default machine while it initializes", async () => { const req = await setup(); const member = await createAgent({ diff --git a/packages/api/src/code/targets.ts b/packages/api/src/code/targets.ts index f874c5ae261..ea64a66ee5c 100644 --- a/packages/api/src/code/targets.ts +++ b/packages/api/src/code/targets.ts @@ -498,13 +498,13 @@ export interface SubagentCodeRouting { /** * Runs one lazy child's whole resolution, through to the inputs handed to the SDK. * The routes and per-agent tool contexts it and its graph members hold become - * permanent only when it succeeds; when it fails they are given back (unless - * another execution holds them) and `onRelease` undoes the caller's own state. + * permanent only when it succeeds (then `onCommit` publishes the caller's own + * state); when it fails they are given back unless another execution holds them. */ settleExecution( context: Pick | null | undefined, resolve: () => Promise, - onRelease?: () => void, + hooks?: { onCommit?: () => void }, ): Promise; } @@ -932,14 +932,14 @@ export function createSubagentCodeRouting({ throw error; } }, - async settleExecution(context, resolve, onRelease) { + async settleExecution(context, resolve, hooks) { try { const resolved = await resolve(); settleHoldsOf(context?.executionId, true); + hooks?.onCommit?.(); return resolved; } catch (error) { settleHoldsOf(context?.executionId, false); - onRelease?.(); throw error; } },