diff --git a/docs/plans/2026-09-06-multi-agent-board-collaboration.md b/docs/plans/2026-09-06-multi-agent-board-collaboration.md index f86aa5f0804..228a4adffac 100644 --- a/docs/plans/2026-09-06-multi-agent-board-collaboration.md +++ b/docs/plans/2026-09-06-multi-agent-board-collaboration.md @@ -1,9 +1,7 @@ # Multi-agent collaboration on a shared thread -> Status: Revised after source-backed review. Admission, runtime preparation, -> capability, and versioned storage are committed on #11206. The hidden-host -> launcher is locally implemented on its stacked step branch; dispatch remains -> unbuilt. +> Status: Implemented through the runtime wiring needed for the live slice; +> the end-to-end model run remains unverified. > Baseline: `origin/main` @ `703678136a` (2026-09-06) > Verification: targeted tests, build, typecheck, and lint are recorded in §0.2; > no agent has run this design end to end @@ -610,10 +608,11 @@ Dependencies, with an early vertical proof before reliability and UI breadth. failure it exists to catch, so it must fail loudly rather than shadow. 6. **Minimal in-process dispatcher, no recovery** — pick and atomically claim one queued run per agent by `queueSequence`; launch, continue resident, - resume `paused`, or cold revive; bind the session on success; and consume the - parent-report outbox. Handle `capacity_wait` by releasing the claim without - spending the attempt. This is intentionally the smallest dispatcher that - can make the next step executable. + resume `paused`, or cold revive; bind the session on success; record runtime + delivery and usage events; finish the mesh run when the body returns; and + consume the parent-report outbox. Handle `capacity_wait` by releasing the + claim without spending the attempt. This is intentionally the smallest + dispatcher that can make the next step executable. 7. **Minimal live vertical slice** — assigned parent → launch → assigned child → parent wait → child review → parent dependency wake → parent review. Run it against two live agents before building the full daemon; this is the first diff --git a/docs/plans/2026-09-07-mesh-implementation-acceptance.md b/docs/plans/2026-09-07-mesh-implementation-acceptance.md index 2b738e5038e..7607085c1f7 100644 --- a/docs/plans/2026-09-07-mesh-implementation-acceptance.md +++ b/docs/plans/2026-09-07-mesh-implementation-acceptance.md @@ -90,7 +90,7 @@ Gate (a): one `startRun`/`finishRun` per assignment — covered. Gate (b): two t Observed locally: 11 files, 127 tests passed; targeted ESLint clean. Writing the production port corrected the design's four-branch idle path to three. Whether a completed body still has a resident runtime is not a choice the dispatcher can make — only the registry knows, and #11204 already reports its own fallback — so a dispatcher choosing between "continue resident" and "cold revive" would be guessing at state it cannot see and would cold-revive a live body. The three entry points it does choose between are `launch`, `resume` (a restart-recovered `paused` entry, which the revive path rejects) and `continue_completed`. `dispatch-port.ts` binds those to the runtime and is the single place a non-local runtime would be substituted (§9.12). Its tests pin the registry-state mapping, the hot path not touching the transcript, capacity reported before any mutation, a state change under it not being forced, and a thrown runtime error becoming a typed failure rather than a start. -Still open in step 6: `runWithMeshRunContext` is not yet established at the turn seam; the six tools are not registered into a mesh agent's tool set; and `consumedMessageIds` / `usageByRound` still need the runtime event streams. Those are the wiring that makes step 7 runnable. +**Runtime wiring landed for the demo path.** Every launch, resident continuation, resume, and revive persists the next run binding, and both real background-turn seams establish it inside the turn body. Mesh agents see the six thread tools while ordinary subagents do not. Structured resident delivery uses the run id as its correlation id; the consumed event advances that run's accepted/consumed ids and watermark, usage events upsert cumulative rounds, and body completion terminalizes the mesh run. Launch/revive inputs are marked consumed when the runtime accepts their initial prompt. This correction was deliberately not expanded with new test code or a local CI/build pass; step 7's live model run is the next evidence gate. ### Step 7 — Live vertical slice (first integration gate) diff --git a/packages/core/src/agents/agent-transcript.ts b/packages/core/src/agents/agent-transcript.ts index 84230359c45..1a7d4f2729b 100644 --- a/packages/core/src/agents/agent-transcript.ts +++ b/packages/core/src/agents/agent-transcript.ts @@ -23,6 +23,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { randomUUID } from 'node:crypto'; +import type { MeshRunContext } from './mesh/run-context.js'; import { AgentEventType, type AgentEventEmitter, @@ -112,6 +113,8 @@ export interface AgentMeta { agentId: string; /** Durable mesh identity when this runtime belongs to the shared-thread mesh. */ meshAgentId?: string; + /** The mesh run this body's next turn executes. */ + meshRun?: MeshRunContext; agentType: string; description: string; /** SessionId of the user session that launched this agent. */ diff --git a/packages/core/src/agents/background-agent-resume.ts b/packages/core/src/agents/background-agent-resume.ts index 0e59a473029..dfe4803e713 100644 --- a/packages/core/src/agents/background-agent-resume.ts +++ b/packages/core/src/agents/background-agent-resume.ts @@ -90,6 +90,7 @@ import { buildMeshToolConfig, createMeshToolInvocationGuard, } from './mesh/capability.js'; +import { runMeshTurn } from './mesh/runtime-bridge.js'; const debugLogger = createDebugLogger('BACKGROUND_AGENT_RESUME'); @@ -1419,10 +1420,22 @@ export class BackgroundAgentResumeService { // Restore the persisted launch depth so a resumed nested agent keeps // its original nesting level (and spawn eligibility) instead of // recomputing to depth 0 from this top-level resume frame. + const meshRun = readAgentMeta(metaPath)?.meshRun; + const body = () => + runBody(turnContextState, turnAbortController, fireStartHook); const framedRunBody = () => runWithAgentContext( meta.agentId, - () => runBody(turnContextState, turnAbortController, fireStartHook), + meshRun + ? () => + runMeshTurn({ + projectRoot: this.config.getProjectRoot(), + context: meshRun, + emitter: bgEmitter, + metaPath, + body, + }) + : body, normalizeResumedAgentDepth(meta.depth), ); const invocationRunBody = () => diff --git a/packages/core/src/agents/mesh/capability.ts b/packages/core/src/agents/mesh/capability.ts index b39088e1d31..4c70128f56c 100644 --- a/packages/core/src/agents/mesh/capability.ts +++ b/packages/core/src/agents/mesh/capability.ts @@ -15,12 +15,12 @@ import { classifyShellCommandSafetyInDirectory } from '../../utils/shellAstParse export type MeshToolClassification = 'allow' | 'deny' | 'thread'; export const MESH_THREAD_TOOL_NAMES = [ - 'thread_post', - 'thread_wait', - 'thread_block', - 'thread_review', - 'thread_create', - 'thread_read', + ToolNames.THREAD_POST, + ToolNames.THREAD_WAIT, + ToolNames.THREAD_BLOCK, + ToolNames.THREAD_REVIEW, + ToolNames.THREAD_CREATE, + ToolNames.THREAD_READ, ] as const; type CoreToolName = (typeof ToolNames)[keyof typeof ToolNames]; @@ -76,12 +76,12 @@ export const MESH_TOOL_CLASSIFICATION = { [ToolNames.UPDATE_GOAL]: 'deny', [ToolNames.PROPOSE_GOAL]: 'deny', [ToolNames.DISPLAY_IMAGE]: 'allow', - thread_post: 'thread', - thread_wait: 'thread', - thread_block: 'thread', - thread_review: 'thread', - thread_create: 'thread', - thread_read: 'thread', + [ToolNames.THREAD_POST]: 'thread', + [ToolNames.THREAD_WAIT]: 'thread', + [ToolNames.THREAD_BLOCK]: 'thread', + [ToolNames.THREAD_REVIEW]: 'thread', + [ToolNames.THREAD_CREATE]: 'thread', + [ToolNames.THREAD_READ]: 'thread', } as const satisfies Record< CoreToolName | MeshThreadToolName, MeshToolClassification diff --git a/packages/core/src/agents/mesh/dispatch-port.test.ts b/packages/core/src/agents/mesh/dispatch-port.test.ts index 1f03ca7781c..d4d99fac57b 100644 --- a/packages/core/src/agents/mesh/dispatch-port.test.ts +++ b/packages/core/src/agents/mesh/dispatch-port.test.ts @@ -14,6 +14,19 @@ import { } from './dispatch-port.js'; import type { MeshAgent } from './types.js'; +vi.mock('../agent-transcript.js', () => ({ + getAgentMetaPath: () => '/mesh-agent.meta.json', + patchAgentMeta: () => {}, + readAgentMeta: () => ({ + meshRun: { + workspaceId: 'ws_1', + threadId: 'th_1', + runId: 'rn_1', + attempt: 1, + }, + }), +})); + const ALICE: MeshAgent = { id: 'ag_alice', name: 'alice', createdAt: 1 }; function makeConfig( @@ -29,6 +42,7 @@ function makeConfig( continueResidentAgent: vi.fn(() => overrides.continueResult ?? 'continued'), }; const config = { + getProjectRoot: () => '/workspace', getBackgroundTaskRegistry: () => registry, getSessionId: () => 'se_host', reviveCompletedBackgroundAgent: vi.fn(async () => overrides.revive), @@ -65,10 +79,12 @@ describe('createMeshDispatchPort', () => { action, agent: ALICE, prompt: 'YOUR RUN ...', + workspaceId: 'ws_1', threadId: 'th_1', rootThreadId: 'th_1', runId: 'rn_1', attempt: 1, + contextThroughSequence: 1, }); it('continues a completed body hot without touching the transcript', async () => { @@ -77,10 +93,12 @@ describe('createMeshDispatchPort', () => { await expect(start(config, 'continue_completed')).resolves.toEqual({ status: 'started', sessionId: 'se_host', + consumedOnStart: false, }); expect(registry.continueResidentAgent).toHaveBeenCalledWith( 'mesh-ag_alice', 'YOUR RUN ...', + 'rn_1', ); expect(config.reviveCompletedBackgroundAgent).not.toHaveBeenCalled(); }); diff --git a/packages/core/src/agents/mesh/dispatch-port.ts b/packages/core/src/agents/mesh/dispatch-port.ts index 9463cc2386f..0971c12f4f6 100644 --- a/packages/core/src/agents/mesh/dispatch-port.ts +++ b/packages/core/src/agents/mesh/dispatch-port.ts @@ -21,7 +21,13 @@ */ import type { Config } from '../../config/config.js'; +import { + getAgentMetaPath, + patchAgentMeta, + readAgentMeta, +} from '../agent-transcript.js'; import { launchMeshAgent } from './launcher.js'; +import type { MeshRunContext } from './run-context.js'; import type { MeshBodyState, MeshDispatchPort, @@ -81,12 +87,21 @@ async function continueCompleted( config: Config, agent: MeshAgent, prompt: string, + deliveryId: string, ): Promise { const registry = config.getBackgroundTaskRegistry(); const agentId = meshBackgroundAgentId(agent); - const outcome = registry.continueResidentAgent(agentId, prompt); + const outcome = registry.continueResidentAgent( + agentId, + prompt, + deliveryId, + ); if (outcome === 'continued') { - return { status: 'started', sessionId: config.getSessionId() }; + return { + status: 'started', + sessionId: config.getSessionId(), + consumedOnStart: false, + }; } if (outcome === 'capacity_wait') return { status: 'capacity_wait' }; if (outcome === 'not_completed') { @@ -104,7 +119,33 @@ async function continueCompleted( failureStage: 'revive', }; } - return { status: 'started', sessionId: config.getSessionId() }; + return { + status: 'started', + sessionId: config.getSessionId(), + consumedOnStart: true, + }; +} + +function bindNextTurn( + config: Config, + agent: MeshAgent, + binding: MeshRunContext, +): void { + const metaPath = getAgentMetaPath( + config.getProjectRoot(), + config.getSessionId(), + meshBackgroundAgentId(agent), + ); + patchAgentMeta(metaPath, { meshRun: binding }); + const stored = readAgentMeta(metaPath)?.meshRun; + if ( + stored?.workspaceId !== binding.workspaceId || + stored.threadId !== binding.threadId || + stored.runId !== binding.runId || + stored.attempt !== binding.attempt + ) { + throw new Error(`Could not bind mesh run "${binding.runId}" to its body.`); + } } /** @@ -119,13 +160,42 @@ export function createMeshDispatchPort(config: Config): MeshDispatchPort { async inspect(agent) { return inspectBody(config, agent); }, - async start({ action, agent, prompt }) { + async start({ + action, + agent, + prompt, + workspaceId, + threadId, + rootThreadId, + runId, + attempt, + contextThroughSequence, + }) { try { + const binding: MeshRunContext = { + workspaceId, + agentId: agent.id, + runId, + threadId, + rootThreadId, + attempt, + contextThroughSequence, + }; + if (action !== 'launch') bindNextTurn(config, agent, binding); switch (action) { case 'launch': { - const result = await launchMeshAgent(config, agent, prompt); + const result = await launchMeshAgent( + config, + agent, + prompt, + binding, + ); if (result.status === 'started') { - return { status: 'started', sessionId: result.sessionId }; + return { + status: 'started', + sessionId: result.sessionId, + consumedOnStart: true, + }; } if (result.status === 'capacity_wait') { return { status: 'capacity_wait' }; @@ -151,10 +221,14 @@ export function createMeshDispatchPort(config: Config): MeshDispatchPort { failureStage: 'resume', }; } - return { status: 'started', sessionId: config.getSessionId() }; + return { + status: 'started', + sessionId: config.getSessionId(), + consumedOnStart: true, + }; } case 'continue_completed': - return await continueCompleted(config, agent, prompt); + return await continueCompleted(config, agent, prompt, runId); default: { const exhaustive: never = action; return failure( diff --git a/packages/core/src/agents/mesh/dispatcher.ts b/packages/core/src/agents/mesh/dispatcher.ts index 4d096adcf21..9169fc8191c 100644 --- a/packages/core/src/agents/mesh/dispatcher.ts +++ b/packages/core/src/agents/mesh/dispatcher.ts @@ -61,7 +61,12 @@ export type MeshBodyState = export type MeshStartAction = 'launch' | 'resume' | 'continue_completed'; export type MeshStartResult = - | { status: 'started'; sessionId: string; transcriptStartOffset?: number } + | { + status: 'started'; + sessionId: string; + transcriptStartOffset?: number; + consumedOnStart?: boolean; + } | { status: 'capacity_wait' } | { status: 'agent_unavailable'; error: string } | { status: 'launch_failed'; error: string; failureStage?: string }; @@ -72,10 +77,12 @@ export interface MeshDispatchPort { action: MeshStartAction; agent: MeshAgent; prompt: string; + workspaceId: string; threadId: string; rootThreadId: string; runId: string; attempt: number; + contextThroughSequence: number; }): Promise; /** Definition content hash, when the port can supply one (§9.4). */ definitionVersion?(agent: MeshAgent): Promise; @@ -215,10 +222,12 @@ export async function dispatchOnce( action, agent, prompt: prompt.text, + workspaceId: workspace.workspaceId, threadId: thread.id, rootThreadId: thread.rootThreadId, runId: run.id, attempt: claimed.run.attempts, + contextThroughSequence: prompt.contextThroughSequence, }); if (result.status === 'started') { @@ -228,6 +237,7 @@ export async function dispatchOnce( attempt: claimed.run.attempts, sessionId: result.sessionId, contextThroughSequence: prompt.contextThroughSequence, + consumedOnStart: result.consumedOnStart, ...(definitionVersion ? { definitionVersion } : {}), ...(result.transcriptStartOffset !== undefined ? { transcriptStartOffset: result.transcriptStartOffset } diff --git a/packages/core/src/agents/mesh/launcher.ts b/packages/core/src/agents/mesh/launcher.ts index 82a91706367..cf0d9ea39b9 100644 --- a/packages/core/src/agents/mesh/launcher.ts +++ b/packages/core/src/agents/mesh/launcher.ts @@ -15,6 +15,7 @@ import { buildMeshToolConfig, createMeshToolInvocationGuard, } from './capability.js'; +import type { MeshRunContext } from './run-context.js'; import type { MeshAgent } from './types.js'; export type MeshAgentLaunchResult = @@ -32,6 +33,7 @@ export async function launchMeshAgent( config: Config, agent: MeshAgent, prompt: string, + meshRun?: MeshRunContext, ): Promise { if (agent.enabled === false) { return { @@ -83,6 +85,7 @@ export async function launchMeshAgent( { agentId: backgroundAgentId, meshAgentId: agent.id, + ...(meshRun ? { meshRun } : {}), subagentConfig: definition, toolConfig: buildMeshToolConfig(runtimeConfig.toolConfig), }, diff --git a/packages/core/src/agents/mesh/run-context.ts b/packages/core/src/agents/mesh/run-context.ts index d6e30179ca0..fc06b47570a 100644 --- a/packages/core/src/agents/mesh/run-context.ts +++ b/packages/core/src/agents/mesh/run-context.ts @@ -31,6 +31,8 @@ export interface MeshRunContext { rootThreadId: string; /** 1 for the first execution of this run; higher after a revive. */ attempt: number; + /** Last thread message included in this turn's delivery. */ + contextThroughSequence?: number; } const store = new AsyncLocalStorage(); diff --git a/packages/core/src/agents/mesh/runtime-bridge.ts b/packages/core/src/agents/mesh/runtime-bridge.ts new file mode 100644 index 00000000000..acdb5777662 --- /dev/null +++ b/packages/core/src/agents/mesh/runtime-bridge.ts @@ -0,0 +1,102 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { readAgentMeta } from '../agent-transcript.js'; +import { + AgentEventType, + type AgentEventEmitter, + type AgentExternalMessageEvent, + type AgentUsageEvent, +} from '../runtime/agent-events.js'; +import { + consumeRunDelivery, + finishRun, + upsertRunUsage, +} from './thread-actions.js'; +import { + runWithMeshRunContext, + type MeshRunContext, +} from './run-context.js'; + +export async function runMeshTurn(input: { + projectRoot: string; + context: MeshRunContext; + emitter: AgentEventEmitter; + metaPath: string; + body: () => Promise; +}): Promise { + let writes: Promise = Promise.resolve(); + let writeError: unknown; + const enqueue = (write: () => Promise) => { + writes = writes.then(write).catch((error: unknown) => { + writeError ??= error; + }); + }; + const onExternalMessage = (event: AgentExternalMessageEvent) => { + if (event.deliveryId === input.context.runId) { + enqueue(() => consumeRunDelivery(input.projectRoot, input.context)); + } + }; + const onUsage = (event: AgentUsageEvent) => { + const tokens = Number(event.usage.totalTokenCount ?? 0); + if (!Number.isFinite(tokens) || tokens < 0) return; + enqueue(() => + upsertRunUsage( + input.projectRoot, + input.context.threadId, + input.context.runId, + { + attempt: input.context.attempt, + round: event.round, + tokens, + }, + ), + ); + }; + + input.emitter.on(AgentEventType.EXTERNAL_MESSAGE, onExternalMessage); + input.emitter.on(AgentEventType.USAGE_METADATA, onUsage); + try { + return await runWithMeshRunContext(input.context, input.body); + } finally { + input.emitter.off(AgentEventType.EXTERNAL_MESSAGE, onExternalMessage); + input.emitter.off(AgentEventType.USAGE_METADATA, onUsage); + await writes; + + const meta = readAgentMeta(input.metaPath); + let outcome: Parameters[3]; + if (writeError) { + outcome = { + status: 'failed', + error: + writeError instanceof Error ? writeError.message : String(writeError), + failureStage: 'runtime_event', + }; + } else if (meta?.status === 'completed') { + outcome = { status: 'completed' }; + } else if (meta?.status === 'cancelled') { + outcome = { + status: 'cancelled', + ...(meta.lastError ? { error: meta.lastError } : {}), + }; + } else { + outcome = { + status: 'failed', + error: + meta?.lastError ?? + 'Background agent turn ended without a terminal status.', + failureStage: 'runtime', + }; + } + await finishRun( + input.projectRoot, + input.context.threadId, + input.context.runId, + outcome, + ); + if (writeError) throw writeError; + } +} diff --git a/packages/core/src/agents/mesh/thread-actions.ts b/packages/core/src/agents/mesh/thread-actions.ts index c80adf5b073..256198947d7 100644 --- a/packages/core/src/agents/mesh/thread-actions.ts +++ b/packages/core/src/agents/mesh/thread-actions.ts @@ -16,6 +16,7 @@ import { finishRunInTransaction, } from './run-lifecycle.js'; import { acknowledgeCloseObligations } from './thread-status.js'; +import type { MeshRunContext } from './run-context.js'; import { decideDispatch, resolveTargets, @@ -432,6 +433,8 @@ export interface BindRunSessionInput { * reports draining it. */ contextThroughSequence?: number; + /** Launch/revive input is already in history when start returns. */ + consumedOnStart?: boolean; /** Content hash of the agent definition in force, for drift audit (§9.4). */ definitionVersion?: string; /** Byte offset into the agent's transcript where this run's slice begins. */ @@ -455,8 +458,21 @@ export async function bindRunSession( `Run "${input.runId}" is not the claimed attempt on thread "${input.threadId}".`, ); } + const previousCommitted = + thread.deliveryByAgent[target.agentId]?.committedThroughSequence ?? 0; + const through = input.contextThroughSequence; + const deliveredMessageIds = + through === undefined + ? [] + : thread.messages + .filter( + (message) => + message.sequence > previousCommitted && + message.sequence <= through, + ) + .map((message) => message.id); const delivery = - input.contextThroughSequence === undefined + !input.consumedOnStart || input.contextThroughSequence === undefined ? thread.deliveryByAgent : { ...thread.deliveryByAgent, @@ -476,6 +492,17 @@ export async function bindRunSession( ? { ...run, sessionId: input.sessionId, + acceptedMessageIds: Array.from( + new Set([...run.acceptedMessageIds, ...deliveredMessageIds]), + ), + consumedMessageIds: input.consumedOnStart + ? Array.from( + new Set([ + ...run.consumedMessageIds, + ...deliveredMessageIds, + ]), + ) + : run.consumedMessageIds, ...(input.contextThroughSequence !== undefined ? { contextThroughSequence: input.contextThroughSequence } : {}), @@ -492,6 +519,65 @@ export async function bindRunSession( }); } +export async function consumeRunDelivery( + projectRoot: string, + context: MeshRunContext, +): Promise { + return withMeshStoreTransaction(projectRoot, async (transaction) => { + const thread = await transaction.readThread(context.threadId); + const run = thread?.runs.find((entry) => entry.id === context.runId); + if ( + transaction.workspaceId !== context.workspaceId || + thread?.rootThreadId !== context.rootThreadId || + !run || + run.agentId !== context.agentId || + run.attempts !== context.attempt || + (run.status !== 'running' && run.status !== 'finishing') + ) { + throw new Error( + `Run "${context.runId}" is no longer the active delivery attempt.`, + ); + } + + const through = + context.contextThroughSequence ?? run.contextThroughSequence; + if (through === undefined) return thread; + const previousCommitted = + thread.deliveryByAgent[run.agentId]?.committedThroughSequence ?? 0; + const deliveredMessageIds = thread.messages + .filter( + (message) => + message.sequence > previousCommitted && message.sequence <= through, + ) + .map((message) => message.id); + const acceptedMessageIds = Array.from( + new Set([...run.acceptedMessageIds, ...deliveredMessageIds]), + ); + + return transaction.writeThread({ + ...thread, + deliveryByAgent: { + ...thread.deliveryByAgent, + [run.agentId]: { + committedThroughSequence: Math.max(previousCommitted, through), + }, + }, + runs: thread.runs.map((entry) => + entry.id === run.id + ? { + ...entry, + acceptedMessageIds, + consumedMessageIds: Array.from( + new Set([...entry.consumedMessageIds, ...acceptedMessageIds]), + ), + contextThroughSequence: through, + } + : entry, + ), + }); + }); +} + export async function releaseRunClaim( projectRoot: string, input: { threadId: string; runId: string; attempt: number }, diff --git a/packages/core/src/agents/runtime/agent-core.ts b/packages/core/src/agents/runtime/agent-core.ts index 201cc9975d4..005a7294dc3 100644 --- a/packages/core/src/agents/runtime/agent-core.ts +++ b/packages/core/src/agents/runtime/agent-core.ts @@ -24,6 +24,7 @@ import { subagentNameContext, } from '../../utils/subagentNameContext.js'; import { runWithInvocationContext } from '../../utils/invocation-context.js'; +import { isMeshRun } from '../mesh/run-context.js'; import type { Config } from '../../config/config.js'; import { getCurrentAgentDepth, @@ -228,6 +229,12 @@ export const EXCLUDED_TOOLS_FOR_SUBAGENTS: ReadonlySet = new Set([ // fan-out: a subagent spawned by Workflow that calls Workflow would create // O(k^n) subagents. ToolNames.WORKFLOW, + ToolNames.THREAD_POST, + ToolNames.THREAD_WAIT, + ToolNames.THREAD_BLOCK, + ToolNames.THREAD_REVIEW, + ToolNames.THREAD_CREATE, + ToolNames.THREAD_READ, ]); /** @@ -289,19 +296,43 @@ const EXCLUDED_TOOLS_FOR_TEAMMATES: ReadonlySet = new Set([ // for nested agents — without WORKFLOW here, a teammate-launched // workflow re-arms the O(k^n) fan-out the subagent set prevents. ToolNames.WORKFLOW, + ToolNames.THREAD_POST, + ToolNames.THREAD_WAIT, + ToolNames.THREAD_BLOCK, + ToolNames.THREAD_REVIEW, + ToolNames.THREAD_CREATE, + ToolNames.THREAD_READ, ]); +const MESH_THREAD_TOOLS = [ + ToolNames.THREAD_POST, + ToolNames.THREAD_WAIT, + ToolNames.THREAD_BLOCK, + ToolNames.THREAD_REVIEW, + ToolNames.THREAD_CREATE, + ToolNames.THREAD_READ, +] as const; + +function exposeMeshThreadTools( + excluded: ReadonlySet, +): ReadonlySet { + if (!isMeshRun()) return excluded; + const current = new Set(excluded); + for (const name of MESH_THREAD_TOOLS) current.delete(name); + return current; +} + function getExcludedToolsForCurrentContext(): ReadonlySet { if (!isTeammate()) { - return EXCLUDED_TOOLS_FOR_SUBAGENTS; + return exposeMeshThreadTools(EXCLUDED_TOOLS_FOR_SUBAGENTS); } if (!isPlanRequiredTeammateContext()) { - return EXCLUDED_TOOLS_FOR_TEAMMATES; + return exposeMeshThreadTools(EXCLUDED_TOOLS_FOR_TEAMMATES); } const excluded = new Set(EXCLUDED_TOOLS_FOR_TEAMMATES); excluded.delete(ToolNames.EXIT_PLAN_MODE); - return excluded; + return exposeMeshThreadTools(excluded); } /** diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index d43424784eb..0c20e9d97f1 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -9687,6 +9687,33 @@ export class Config { // shape and permission gating in sync between the two paths. await registerStructuredOutputIfRequested(); + if (options?.forSubAgent) { + await registerLazy(ToolNames.THREAD_POST, async () => { + const { ThreadPostTool } = await import('../tools/mesh-thread.js'); + return new ThreadPostTool(this); + }); + await registerLazy(ToolNames.THREAD_WAIT, async () => { + const { ThreadWaitTool } = await import('../tools/mesh-thread.js'); + return new ThreadWaitTool(this); + }); + await registerLazy(ToolNames.THREAD_BLOCK, async () => { + const { ThreadBlockTool } = await import('../tools/mesh-thread.js'); + return new ThreadBlockTool(this); + }); + await registerLazy(ToolNames.THREAD_REVIEW, async () => { + const { ThreadReviewTool } = await import('../tools/mesh-thread.js'); + return new ThreadReviewTool(this); + }); + await registerLazy(ToolNames.THREAD_CREATE, async () => { + const { ThreadCreateTool } = await import('../tools/mesh-thread.js'); + return new ThreadCreateTool(this); + }); + await registerLazy(ToolNames.THREAD_READ, async () => { + const { ThreadReadTool } = await import('../tools/mesh-thread.js'); + return new ThreadReadTool(this); + }); + } + // Register cron tools unless disabled if (this.isCronEnabled()) { await registerLazy(ToolNames.CRON_CREATE, async () => { diff --git a/packages/core/src/permissions/rule-parser.ts b/packages/core/src/permissions/rule-parser.ts index 02f1e8812bc..32141c0ec59 100644 --- a/packages/core/src/permissions/rule-parser.ts +++ b/packages/core/src/permissions/rule-parser.ts @@ -262,6 +262,19 @@ export const TOOL_NAME_ALIASES: Readonly> = { display_image: 'display_image', DisplayImage: 'display_image', + thread_post: 'thread_post', + ThreadPost: 'thread_post', + thread_wait: 'thread_wait', + ThreadWait: 'thread_wait', + thread_block: 'thread_block', + ThreadBlock: 'thread_block', + thread_review: 'thread_review', + ThreadReview: 'thread_review', + thread_create: 'thread_create', + ThreadCreate: 'thread_create', + thread_read: 'thread_read', + ThreadRead: 'thread_read', + // Legacy edit tool name replace: 'edit', }; diff --git a/packages/core/src/tools/agent/agent.ts b/packages/core/src/tools/agent/agent.ts index 10ef42bb36f..aded8d99e45 100644 --- a/packages/core/src/tools/agent/agent.ts +++ b/packages/core/src/tools/agent/agent.ts @@ -24,6 +24,8 @@ import type { import type { PermissionDecision } from '../../permissions/types.js'; import type { SubagentManager } from '../../subagents/subagent-manager.js'; import type { SubagentConfig } from '../../subagents/types.js'; +import type { MeshRunContext } from '../../agents/mesh/run-context.js'; +import { runMeshTurn } from '../../agents/mesh/runtime-bridge.js'; import { BUBBLE_APPROVAL_MODE } from '../../subagents/types.js'; import { AgentTerminateMode } from '../../agents/runtime/agent-types.js'; import type { @@ -121,6 +123,7 @@ import { getAgentMetaPath, getAgentMetaTerminalSummary, attachJsonlTranscriptWriter, + readAgentMeta, patchAgentMeta, writeAgentMeta, type AgentPersistedCliFlags, @@ -279,6 +282,7 @@ export type ProgrammaticBackgroundAgentLaunchResult = interface ProgrammaticBackgroundAgentLaunchOptions { agentId: string; meshAgentId: string; + meshRun?: MeshRunContext; subagentConfig: SubagentConfig; toolConfig: ToolConfig; } @@ -3336,7 +3340,12 @@ class AgentToolInvocation extends BaseToolInvocation { writeAgentMeta(metaPath, { agentId: hookOpts.agentId, ...(this.programmatic - ? { meshAgentId: this.programmatic.meshAgentId } + ? { + meshAgentId: this.programmatic.meshAgentId, + ...(this.programmatic.meshRun + ? { meshRun: this.programmatic.meshRun } + : {}), + } : {}), agentType: hookOpts.agentType, description: this.params.description, @@ -3807,6 +3816,7 @@ class AgentToolInvocation extends BaseToolInvocation { turnAbortController: AbortController, fireStartHook: boolean, ) => { + const meshRun = readAgentMeta(metaPath)?.meshRun; const framedBgBody = () => this.runWithSubagentSpan( this.buildSubagentSpanSpec( @@ -3815,18 +3825,29 @@ class AgentToolInvocation extends BaseToolInvocation { isFork ? 'fork' : 'background', ), turnAbortController.signal, - (recordOutcome) => - runWithAgentContext( + (recordOutcome) => { + const body = () => + bgBody( + turnContextState, + turnAbortController, + recordOutcome, + fireStartHook, + ); + return runWithAgentContext( hookOpts.agentId, - () => - bgBody( - turnContextState, - turnAbortController, - recordOutcome, - fireStartHook, - ), + meshRun + ? () => + runMeshTurn({ + projectRoot: this.config.getProjectRoot(), + context: meshRun, + emitter: bgEventEmitter, + metaPath, + body, + }) + : body, launchDepth, - ), + ); + }, ); return isFork ? runInForkContext(framedBgBody) : framedBgBody(); }; diff --git a/packages/core/src/tools/tool-names.ts b/packages/core/src/tools/tool-names.ts index 4756e7d7672..2a947b81e32 100644 --- a/packages/core/src/tools/tool-names.ts +++ b/packages/core/src/tools/tool-names.ts @@ -67,6 +67,12 @@ export const ToolNames = { UPDATE_GOAL: 'update_goal', PROPOSE_GOAL: 'propose_goal', DISPLAY_IMAGE: 'display_image', + THREAD_POST: 'thread_post', + THREAD_WAIT: 'thread_wait', + THREAD_BLOCK: 'thread_block', + THREAD_REVIEW: 'thread_review', + THREAD_CREATE: 'thread_create', + THREAD_READ: 'thread_read', } as const; /** @@ -124,6 +130,12 @@ export const ToolDisplayNames = { UPDATE_GOAL: 'UpdateGoal', PROPOSE_GOAL: 'ProposeGoal', DISPLAY_IMAGE: 'DisplayImage', + THREAD_POST: 'ThreadPost', + THREAD_WAIT: 'ThreadWait', + THREAD_BLOCK: 'ThreadBlock', + THREAD_REVIEW: 'ThreadReview', + THREAD_CREATE: 'ThreadCreate', + THREAD_READ: 'ThreadRead', } as const; // Migration from old tool names to new tool names