Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 24 additions & 6 deletions docs/plans/2026-09-06-multi-agent-board-collaboration.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ Nearly everything the execution layer needs already exists:
| Need | Existing machinery |
| -------------------------------------------------------------------- | --------------------------------------------------------------------- |
| Agent loop | `AgentCore` / `AgentInteractive` |
| Persona: prompt, restricted tools, private MCP | `convertToRuntimeConfig` |
| Persona: prompt and restricted tools | `convertToRuntimeConfig` |
| Durable log | `attachJsonlTranscriptWriter` |
| Reading that log in Web Shell | virtual subagent sessions + the existing panel |
| Deliver into a **running** agent | `BackgroundTaskRegistry.queueExternalInput` (boolean acknowledgement) |
Expand Down Expand Up @@ -154,6 +154,11 @@ Recorded so implementation does not relitigate them.
| 3 | Tool sets otherwise **follow a required agent definition**. | No second permission model. An enabled mesh agent with a missing definition is unavailable, never silently replaced by a generic persona. |
| 4 | Agents are **scoped to one workspace**. | Trust and permissions follow the workspace. Five repos means five rosters. |

Until the owner decides the MCP policy, the capability boundary fails
closed for every unlisted name, including MCP tools. This is a conservative
implementation default, not a settled decision that mesh agents can never use
private MCP servers.

### Identity and memory

| # | Decision | Consequence |
Expand Down Expand Up @@ -539,8 +544,10 @@ Dependencies, with an early vertical proof before reliability and UI breadth.
booking. It still has no launcher or dispatcher.
2. **Capability boundary** — built-in read-only shell allowlist intersected
with the agent definition. Explicitly exclude `save_memory`, context-file
writes, and every persistent-write tool. Prove disallowed commands cannot
reach execution.
writes, and every persistent-write tool. This step proves the classification
and shell predicate. Step 4/5 wires the predicate at invocation time and
proves refused commands cannot execute, because the existing name-level
execution allowlist cannot inspect command arguments.
3. **Versioned storage protocol** — add `schemaVersion`, the workspace mutation
lock, lock-issued run queue sequence, atomic same-thread booking, parent/
notification outbox replay, and fail-closed migration before any new process
Expand Down Expand Up @@ -589,6 +596,15 @@ npx vitest run src/agents/mesh/mentions.test.ts \
# 3 files, 38 tests passed
```

Supporting local evidence for step 2; the step gate is #11206 CI after the
child PR merges:

```bash
cd packages/core
npx vitest run src/agents/mesh/capability.test.ts
# 1 file, 10 tests passed
```

Targeted lint and core typecheck also pass on this branch. They establish
compile/style health only; they do not validate the design or the unbuilt
execution path. Update the test count above if the foundation changes.
Expand Down Expand Up @@ -618,7 +634,7 @@ Still to build:

| Piece | Where |
| ----------------------------------------------------------------------------------------------------------- | -------------------------------------- |
| Read-only shell boundary | `core/src/agents/mesh/` |
| Invocation-time enforcement of the read-only shell predicate | launcher/tool hook in steps 4-5 |
| Versioned workspace record, migration, workspace lock, and cross-file outbox protocol | `core/src/agents/mesh/` |
| Hidden host-session owner and programmatic launcher | `core/src/agents/` |
| Run envelope, delivery state, prompt assembler, ambient run context | `core/src/agents/mesh/` |
Expand Down Expand Up @@ -807,9 +823,11 @@ does host**. Making one the storage of the other fails in both directions:
mesh REST surface (§5.2 step 9) and be admitted as a *runtime*. That is
exactly §9.12, and it is a v2 question, not a v1 storage choice.

**Decision for v1: separate, with the convergence path fixed now.**
**Conservative v1 default while the owner decision remains open: separate
stores, with the convergence path recorded.**

1. The two stores stay separate. Neither imports the other. The user-facing
1. Unless the owner chooses otherwise before step 3, the two stores stay
separate and neither imports the other. The user-facing
names stay distinct: *board* is the foreign-process surface, *threads*
(with *agents*) is the orchestrated one. Do not call mesh threads a board.
2. The Board does not ship as a standalone user surface while this design is
Expand Down
14 changes: 9 additions & 5 deletions docs/plans/2026-09-07-mesh-implementation-acceptance.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ Gate: a table-driven test enumerates every registered tool name in core and asse
Deferred to step 4/5: proving the refusal happens in the tool layer. `executionAllowedTools` is name-level, so a per-command predicate needs an invocation-time hook that arrives with the launcher (see the steps 2-3 brief §2).
Evidence: the classification table, committed as data, not prose.

Supporting local observation: `capability.test.ts`, 10 tests passed. The step
is not complete until #11206 CI passes after the child PR merges.

### Step 3 — Versioned storage protocol

Lands: `schemaVersion` on every file; fail-closed on unknown version; migration under the workspace lock with the old file retained until the new one validates; the workspace mutation lock; lock-issued `queueSequence` and message `sequence`; `runs[].usageByRound` on the run record; outbox for parent reports and notifications only; deletion refusal for non-terminal runs, descendants, and unacknowledged outbox events.
Expand Down Expand Up @@ -80,13 +83,14 @@ Evidence: the channel transcript.

## 3. Product decisions the implementer must not make

Open in §9 of the design: envelope role transport (§9.9), parent-to-child replies (§9.10), human blocker acknowledgement scope (§9.11), token reservation vs accounting (§9.5), persona drift policy (§9.4). Until each is decided the implementation takes the conservative reading: user-role envelope, ambient-thread-only mutation, acknowledgement of every open blocker on a human post that books, accounting limit with overshoot, definition read at revive only.
Open in §9 of the design: envelope role transport (§9.9), parent-to-child replies (§9.10), human blocker acknowledgement scope (§9.11), token reservation vs accounting (§9.5), persona drift policy (§9.4), and runtime as a first-class concept (§9.12). Until each is decided the implementation takes the conservative reading: user-role envelope, ambient-thread-only mutation, acknowledgement of every open blocker on a human post that books, accounting limit with overshoot, definition read at revive only, and a local background-agent binding only.

The relationship to the Agent Board (#9402) is now recorded in the design's §7.1: separate stores in v1, distinct names, no imports from `board-*.ts` in step 3, and a convergence path that depends on §9.12 (runtime as a first-class concept). What remains the owner's call is §9.12 itself.
The relationship to the Agent Board (#9402) also remains the owner's call. Until it is decided, §7.1's conservative default keeps separate stores and distinct names, imports nothing from `board-*.ts` in step 3, and records a convergence path that depends on §9.12. MCP names likewise fail closed until the owner approves a policy that can preserve the read-only ceiling.

## 4. Working in one PR
## 4. Working through stacked step PRs

- #11206 is the only implementation PR and the only PR that merges to `main`. Each numbered step is committed directly to `codex/multi-agent-mesh-foundation` and pushed for #11206's whole-branch CI gate.
- #11206 is the only PR that merges to `main`. Each numbered step gets one child PR whose base is `codex/multi-agent-mesh-foundation`; merge one child at a time, then use #11206's whole-branch CI as that step's gate before opening or merging the next.
- Child PRs do not run the repository's unit-test or lint jobs. Their named local tests are supporting evidence only; the required CI signal appears after merge on #11206.
- Runtime preparation was merged in the order #11200 → #11204 → #11202. The expected final conflict keeps both contracts: structured external input and typed continuation outcomes. GitHub automatically records those draft PRs as merged because their base is this branch; no PR was merged separately to `main` or manually closed.
- Merge `main` into the mesh branch when it falls behind; never rebase (repo policy, and the force-push bot).
- Keep the design doc and this file current in the same commit as the code that changes them.
Expand All @@ -99,7 +103,7 @@ Ordered by how much damage a miss does. Each item names the step where it is pro
2. **Step 3 is where later bugs get blamed.** Sequence counter written before the thread file; outbox persisted before apply and acknowledged after; migration keeps the `.v0.json` backup until the migrated file reads back through the validator. Each has a crash-injection test in step 3's gate; do not weaken them to make the step land sooner.
3. **Ambient binding lives inside `runBody`, and mutating tools re-check it.** The per-turn `runWithMeshRunContext` frame is the only hard boundary against wrong-thread actions; the prompt frame is advisory. Every mutating tool reads the ambient triple and then verifies the run is still `running` on that thread before writing (step 5).
4. **No silent path.** Every admission result is persisted on the message and rendered; a quiescent thread with nothing runnable becomes `blocked`, never idle `in_progress`. Round 2 found more defects of this class than any other.
5. **Runtime hot paths change in isolated commits.** `agent-core.ts`, `background-tasks.ts`, `background-agent-resume.ts`, `agent-headless.ts`, `agent.ts` are shared with Agent Team and every subagent. Keep each such change minimal, pair it with its own tests, and land it directly in #11206 rather than opening another delivery PR.
5. **Runtime hot paths change in isolated child PRs.** `agent-core.ts`, `background-tasks.ts`, `background-agent-resume.ts`, `agent-headless.ts`, `agent.ts` are shared with Agent Team and every subagent. Keep each such change minimal, pair it with its own tests, and merge it into the foundation without creating another PR to `main`.
6. **Trust labels are not boundaries.** Until §9.9 is decided, no prompt heading is called "trusted" and no code treats one as a policy input. Provenance is derived from the ambient run, never from model or HTTP input.
7. **Product decisions stay open until decided.** §9.4, §9.5, §9.9, §9.10, §9.11, §9.12 and the #9402 relationship; the conservative defaults in §3 above apply meanwhile.
8. **Read the CI of #11206 after every numbered step.** Local and source-branch tests are supporting evidence; only the whole delivery branch is the gate.
92 changes: 92 additions & 0 deletions packages/core/src/agents/mesh/capability.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/

import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { ToolNames } from '../../tools/tool-names.js';
import { _resetParser, initParser } from '../../utils/shellAstParser.js';
import {
buildMeshToolConfig,
checkMeshShellCommand,
classifyMeshTool,
MESH_THREAD_TOOL_NAMES,
MESH_TOOL_CLASSIFICATION,
} from './capability.js';

beforeAll(async () => {
await initParser();
});

afterAll(() => {
_resetParser();
});

describe('mesh capability boundary', () => {
it('classifies every core and mesh thread tool exactly once', () => {
expect(new Set(Object.keys(MESH_TOOL_CLASSIFICATION))).toEqual(
new Set([...Object.values(ToolNames), ...MESH_THREAD_TOOL_NAMES]),
);
expect(Object.values(ToolNames).map(classifyMeshTool)).not.toContain(
'thread',
);
expect(MESH_THREAD_TOOL_NAMES.map(classifyMeshTool)).toEqual(
MESH_THREAD_TOOL_NAMES.map(() => 'thread'),
);
});

it('fails closed for tools outside the classification table', () => {
expect(classifyMeshTool('mcp__server__read')).toBe('deny');
expect(classifyMeshTool('__proto__')).toBe('deny');
});

it('applies the built-in ceiling and always adds thread tools', () => {
const full = buildMeshToolConfig();
const wildcard = buildMeshToolConfig(['*']);
const narrowed = buildMeshToolConfig([
ToolNames.READ_FILE,
ToolNames.EDIT,
'mcp__server__read',
]);

expect(wildcard).toEqual(full);
expect(full.tools).toContain(ToolNames.SHELL);
expect(full.tools).not.toContain(ToolNames.MEMORY);
expect(full.disallowedTools).toEqual(
expect.arrayContaining([
ToolNames.EDIT,
ToolNames.WRITE_FILE,
ToolNames.MEMORY,
]),
);
expect(narrowed.tools).toEqual([
ToolNames.READ_FILE,
...MESH_THREAD_TOOL_NAMES,
]);
expect(narrowed.executionAllowedTools).toEqual(narrowed.tools);
expect(narrowed.disallowedTools).toEqual(full.disallowedTools);
});

it.each(['cat package.json', 'git status', 'grep -r TODO packages/core'])(
'allows read-only shell command %s',
async (command) => {
await expect(
checkMeshShellCommand(command, process.cwd()),
).resolves.toEqual({ allowed: true });
},
);

it.each([
['rm -rf temp', 'write'],
['echo text > file', 'write'],
['git push', 'write'],
['unknownbin --x', 'unknown'],
])('refuses shell command %s classified as %s', async (command, safety) => {
const decision = await checkMeshShellCommand(command, process.cwd());
expect(decision).toEqual({
allowed: false,
reason: expect.stringContaining(safety),
});
});
});
125 changes: 125 additions & 0 deletions packages/core/src/agents/mesh/capability.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/

import type { ToolConfig } from '../runtime/agent-types.js';
import { ToolNames } from '../../tools/tool-names.js';
import { classifyShellCommandSafetyInDirectory } from '../../utils/shellAstParser.js';

export type MeshToolClassification = 'allow' | 'deny' | 'thread';

export const MESH_THREAD_TOOL_NAMES = [
'thread_post',
'thread_wait',
'thread_block',
'thread_review',
'thread_create',
'thread_read',
] as const;

type CoreToolName = (typeof ToolNames)[keyof typeof ToolNames];
type MeshThreadToolName = (typeof MESH_THREAD_TOOL_NAMES)[number];

export const MESH_TOOL_CLASSIFICATION = {
[ToolNames.EDIT]: 'deny',
[ToolNames.WRITE_FILE]: 'deny',
[ToolNames.READ_FILE]: 'allow',
[ToolNames.ZOOM_IMAGE]: 'allow',
[ToolNames.GREP]: 'allow',
[ToolNames.GLOB]: 'allow',
[ToolNames.SHELL]: 'allow',
[ToolNames.TODO_WRITE]: 'deny',
[ToolNames.MEMORY]: 'deny',
[ToolNames.AGENT]: 'deny',
[ToolNames.SKILL]: 'allow',
[ToolNames.EXIT_PLAN_MODE]: 'deny',
[ToolNames.ENTER_PLAN_MODE]: 'deny',
[ToolNames.WEB_FETCH]: 'deny',
[ToolNames.WEB_SEARCH]: 'deny',
[ToolNames.IMAGE_GEN]: 'deny',
[ToolNames.LS]: 'allow',
[ToolNames.LSP]: 'deny',
[ToolNames.ASK_USER_QUESTION]: 'deny',
[ToolNames.CRON_CREATE]: 'deny',
[ToolNames.CRON_LIST]: 'deny',
[ToolNames.CRON_DELETE]: 'deny',
[ToolNames.LOOP_WAKEUP]: 'deny',
[ToolNames.CREATE_SUB_SESSION]: 'deny',
[ToolNames.LIST_AGENTS]: 'deny',
[ToolNames.TASK_STOP]: 'deny',
[ToolNames.TASK_CREATE]: 'deny',
[ToolNames.TASK_UPDATE]: 'deny',
[ToolNames.TASK_LIST]: 'deny',
[ToolNames.TEAM_CREATE]: 'deny',
[ToolNames.TEAM_DELETE]: 'deny',
[ToolNames.TEAM_PLAN_APPROVAL]: 'deny',
[ToolNames.REQUEST_SHUTDOWN]: 'deny',
[ToolNames.SEND_MESSAGE]: 'deny',
[ToolNames.STRUCTURED_OUTPUT]: 'allow',
[ToolNames.MONITOR]: 'deny',
[ToolNames.NOTEBOOK_EDIT]: 'deny',
[ToolNames.TOOL_SEARCH]: 'allow',
[ToolNames.READ_MCP_RESOURCE]: 'deny',
[ToolNames.ENTER_WORKTREE]: 'deny',
[ToolNames.EXIT_WORKTREE]: 'deny',
[ToolNames.WORKFLOW]: 'deny',
[ToolNames.ARTIFACT]: 'deny',
[ToolNames.RECORD_ARTIFACT]: 'deny',
[ToolNames.REPORT_FINDINGS]: 'deny',
[ToolNames.GET_GOAL]: 'allow',
[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',
} as const satisfies Record<
CoreToolName | MeshThreadToolName,
MeshToolClassification
>;

export function classifyMeshTool(name: string): MeshToolClassification {
if (!Object.hasOwn(MESH_TOOL_CLASSIFICATION, name)) return 'deny';
return MESH_TOOL_CLASSIFICATION[
name as keyof typeof MESH_TOOL_CLASSIFICATION
];
}

export function buildMeshToolConfig(
definitionTools?: readonly string[],
): ToolConfig {
const allowAll =
definitionTools === undefined || definitionTools.includes('*');
const allowed = allowAll
? Object.entries(MESH_TOOL_CLASSIFICATION)
.filter(([, classification]) => classification === 'allow')
.map(([name]) => name)
: definitionTools.filter((name) => classifyMeshTool(name) === 'allow');
const tools = Array.from(new Set([...allowed, ...MESH_THREAD_TOOL_NAMES]));

return {
tools,
executionAllowedTools: [...tools],
disallowedTools: Object.entries(MESH_TOOL_CLASSIFICATION)
.filter(([, classification]) => classification === 'deny')
.map(([name]) => name),
};
}

export async function checkMeshShellCommand(
command: string,
cwd: string,
): Promise<{ allowed: true } | { allowed: false; reason: string }> {
const safety = await classifyShellCommandSafetyInDirectory(command, cwd);
return safety === 'read-only'
? { allowed: true }
: {
allowed: false,
reason: `Mesh agents may only run read-only shell commands; classified as ${safety}.`,
};
}
Loading