From a6c274e250e0f5a07d33fc3226bcd7b3bfef392b Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Fri, 14 Aug 2026 13:04:55 +0800 Subject: [PATCH 1/8] feat(team): add experimental Agent Teams CLI profile --- .../feature/2026-08-05-agent-teams.md | 6 +- .../feature/2026-08-05-agent-teams.zh.md | 6 +- apps/cli/tests/agent-team-headless.e2e.ts | 112 ++++++++++ apps/cli/tests/fixtures/team-llm.mjs | 194 ++++++++++++++++++ docs/config-catalog.md | 1 + docs/config-catalog.zh.md | 1 + .../agent-team-profile/README.i18n.yaml | 6 + .../experimental/agent-team-profile/README.md | 37 ++++ .../agent-team-profile/README.zh.md | 37 ++++ .../agent-team-profile/cordis.patch.yml | 40 ++++ .../agent-team-profile/package.json | 54 +++++ .../agent-team-profile/src/index.ts | 8 + .../agent-team-profile/src/invariant.ts | 26 +++ .../agent-team-profile/tests/profile.spec.ts | 53 +++++ .../agent-team-profile/tsconfig.json | 24 +++ .../agent-team/tests/team.spec.ts | 22 ++ scripts/check-workspace-constraints.spec.ts | 15 ++ scripts/check-workspace-constraints.ts | 2 +- 18 files changed, 637 insertions(+), 7 deletions(-) create mode 100644 apps/cli/tests/agent-team-headless.e2e.ts create mode 100644 apps/cli/tests/fixtures/team-llm.mjs create mode 100644 packages/experimental/agent-team-profile/README.i18n.yaml create mode 100644 packages/experimental/agent-team-profile/README.md create mode 100644 packages/experimental/agent-team-profile/README.zh.md create mode 100644 packages/experimental/agent-team-profile/cordis.patch.yml create mode 100644 packages/experimental/agent-team-profile/package.json create mode 100644 packages/experimental/agent-team-profile/src/index.ts create mode 100644 packages/experimental/agent-team-profile/src/invariant.ts create mode 100644 packages/experimental/agent-team-profile/tests/profile.spec.ts create mode 100644 packages/experimental/agent-team-profile/tsconfig.json diff --git a/.agents/notes/implemented/feature/2026-08-05-agent-teams.md b/.agents/notes/implemented/feature/2026-08-05-agent-teams.md index d37777a0cc..e415826483 100644 --- a/.agents/notes/implemented/feature/2026-08-05-agent-teams.md +++ b/.agents/notes/implemented/feature/2026-08-05-agent-teams.md @@ -10,7 +10,7 @@ The subagent seam supplies fresh/fork providers, durable child Sessions, FIFO fo All same-process Agents also share one checkout. Filesystem edit tools can reject an observed stale version, but Bash, formatters, generators, and external writers bypass that fence. Treating a teammate name or task owner as a file lock would hide rather than solve this concurrency boundary. -The model-visible Team tools remain opt-in so the default tool catalog and simple-task behavior do not change. An explicitly requested Team must survive child Activation settlement and mailbox delivery races long enough for the Lead to aggregate the result before process teardown. +Agent Teams needs an explicit source-checkout composition before its public contracts are stable enough for released CLI or Web bundles. The default tool catalog and simple-task behavior must remain unchanged, while an explicitly requested Team must survive child Activation settlement and mailbox delivery races long enough for the Lead to aggregate the result before process teardown. ## Decision @@ -54,7 +54,7 @@ Worktree isolation is not a harness runtime behavior. A deployment or prompt may **Create isolated worktrees automatically.** Rejected because worktree creation, branch naming, merge policy, ignored files, build artifacts, and cleanup are deployment choices. It also changes the same-world behavior existing subagents and sandboxes expose. -**Enable Teams in the default catalog.** Rejected because scoped Team controls would shadow same-named legacy globals and unsolicited delegation would add latency and token cost to simple tasks. Explicit composition keeps model-visible ownership unambiguous without changing shipped requests. +**Enable Teams in the default catalog.** Rejected because scoped Team controls would shadow same-named legacy globals and unsolicited delegation would add latency and token cost to simple tasks. A private profile bundle inserts Team and disables the legacy controls without adding Team packages to shipped dependency graphs. **Use an in-memory board and mailbox.** Rejected because child settlement, HMR, and process interruption would lose accepted coordination state and make retries ambiguous. @@ -62,7 +62,7 @@ Worktree isolation is not a harness runtime behavior. A deployment or prompt may ## Testing -Package tests cover identity, name and authority checks, provider selection, reserved-id persistence collisions, child-before-Lead flush ordering, durable provisioning failure and pending-inbox JSONL/SQLite reconciliation, concurrent target-local ordering, pending/history de-duplication, mailbox limits, post-flush notification, bounded disposal with in-flight creation and dispatch cancellation, failed-member cleanup, task CAS and DAG validation, write-scope warnings, wait cancellation/timeout, inbox-preserving interruption, ordinary-fork isolation, legacy-control shadowing, compact declared-schema result rendering, and scoped registration HMR at per-file 100% coverage. A keyless headless Loader snapshot assembles the real Team plugins and records teammate creation, peer mail, dependent tasks, waiting, and Lead aggregation. +Package tests cover identity, name and authority checks, provider selection, reserved-id persistence collisions, child-before-Lead flush ordering, durable provisioning failure and pending-inbox JSONL/SQLite reconciliation, concurrent target-local ordering, pending/history de-duplication, mailbox limits, post-flush notification, bounded disposal with in-flight creation and dispatch cancellation, failed-member cleanup, task CAS and DAG validation, write-scope warnings, wait cancellation/timeout, inbox-preserving interruption, ordinary-fork isolation, legacy-control shadowing, compact declared-schema result rendering, and scoped registration HMR at per-file 100% coverage. A keyless headless Loader snapshot assembles the real Team plugins and exercises two teammates, dependent tasks, peer delivery, waiting, completion, and Lead aggregation. A CLI product test loads the private Agent Teams profile bundle, starts `dsh run` through the real Loader and direct core front door, and verifies normal process exit with persisted Team and child logs. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md b/.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md index 91571bd28a..e253b1fa87 100644 --- a/.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md +++ b/.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md @@ -10,7 +10,7 @@ subagent seam 已提供 fresh/fork provider、持久 child Session、FIFO foll 同进程 Agent 还共享一个 checkout。文件系统 edit 工具可以拒绝已观察到的陈旧版本,但 Bash、formatter、generator 与外部 writer 会绕过该屏障。把 teammate name 或 task owner 当作文件锁只会掩盖而不是解决该并发边界。 -面向模型的 Team 工具保持显式启用,使默认工具目录与简单任务行为不变。显式请求的 Team 必须能跨越 child Activation settlement 与 mailbox 投递竞争,使 Lead 在进程 teardown 前汇总结果。 +在公开约定稳定到足以进入已发布 CLI 或 Web bundle 前,Agent Teams 需要显式的源码 checkout 组合。默认工具目录与简单任务行为必须保持不变;而显式请求的 Team 必须能跨越 child Activation settlement 与 mailbox 投递竞争,使 Lead 在进程 teardown 前汇总结果。 ## Decision @@ -54,7 +54,7 @@ Worktree isolation 不是 harness runtime 行为。deployment 或 prompt 可以 **自动创建隔离 worktree。** 拒绝,因为 worktree 创建、branch 命名、merge 策略、ignored file、构建产物与 cleanup 都是 deployment 选择;它也会改变既有 subagent 与 sandbox 暴露的 same-world 行为。 -**在默认工具目录中启用 Team。** 拒绝,因为 scoped Team control 会覆盖同名旧全局工具,主动 delegation 也会给简单任务增加延迟和 token 成本。显式组合可以保持面向模型的归属明确,同时不改变默认 request。 +**在默认工具目录中启用 Team。** 拒绝,因为 scoped Team control 会覆盖同名旧全局工具,主动 delegation 也会给简单任务增加延迟和 token 成本。私有 profile bundle 会插入 Team 并禁用旧 control,同时不向已发布依赖图添加 Team 包。 **使用内存 task board 与 mailbox。** 拒绝,因为 child settlement、HMR 与进程中断会丢失已接受协调状态,并让重试变得含糊。 @@ -62,7 +62,7 @@ Worktree isolation 不是 harness runtime 行为。deployment 或 prompt 可以 ## Testing -Package test 以逐文件 100% coverage 覆盖身份、名字与权限检查、provider 选择、预留 id 持久化冲突、child-before-Lead flush 顺序、持久 provisioning 失败与 pending-inbox JSONL/SQLite 对账、target-local 并发顺序、pending/history 去重、mailbox 限额、flush 后 notification、取消在途创建与 dispatch 的有界 dispose、failed member cleanup、task CAS 与 DAG 校验、write-scope warning、wait cancel/timeout、保留 inbox 的 interrupt、普通 fork 隔离、旧 control shadowing、声明 schema 的紧凑结果渲染与 scoped registration HMR。一条 keyless headless Loader 快照会组合真实 Team 插件,并记录 teammate 创建、peer mail、依赖任务、等待与 Lead 汇总。 +Package test 以逐文件 100% coverage 覆盖身份、名字与权限检查、provider 选择、预留 id 持久化冲突、child-before-Lead flush 顺序、持久 provisioning 失败与 pending-inbox JSONL/SQLite 对账、target-local 并发顺序、pending/history 去重、mailbox 限额、flush 后 notification、取消在途创建与 dispatch 的有界 dispose、failed member cleanup、task CAS 与 DAG 校验、write-scope warning、wait cancel/timeout、保留 inbox 的 interrupt、普通 fork 隔离、旧 control shadowing、声明 schema 的紧凑结果渲染与 scoped registration HMR。一条 keyless headless Loader 快照会组合真实 Team 插件,并运行两个 teammate、依赖任务、peer 投递、等待、完成与 Lead 汇总。CLI 产品测试会加载私有 Agent Teams profile bundle,通过真实 Loader 与直接 core front door 启动 `dsh run`,并验证带持久 Team 与 child 日志的正常退出。 ## Consequences diff --git a/apps/cli/tests/agent-team-headless.e2e.ts b/apps/cli/tests/agent-team-headless.e2e.ts new file mode 100644 index 0000000000..6e655a0151 --- /dev/null +++ b/apps/cli/tests/agent-team-headless.e2e.ts @@ -0,0 +1,112 @@ +import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { execa } from 'execa' +import { describe, expect, it } from 'vitest' +import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke' + +const dshBinScript = fileURLToPath(new URL('../src/bin.ts', import.meta.url)) +const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) +const fixturePlugin = pathToFileURL(fileURLToPath(new URL('./fixtures/team-llm.mjs', import.meta.url))).href + +function records(content: string): Record[] { + return content.split('\n').filter(Boolean).map(line => JSON.parse(line) as Record) +} + +describe('dsh run with Agent Teams enabled', () => { + it('runs two teammates, durable peer mail, dependent tasks, waiting, and final aggregation', async () => { + const cwd = await mkdtemp(join(tmpdir(), 'dsh-agent-team-headless-')) + try { + const home = join(cwd, '.dsh') + const sessions = join(home, 'sessions') + const profileDir = join(home, 'profiles', 'headless') + await mkdir(profileDir, { recursive: true }) + await writeFile(join(profileDir, 'package.json'), JSON.stringify({ + name: 'dsh-profile-headless', + private: true, + dependencies: { + '@deepseek-ai/dsh-agent-team-profile': 'workspace:^', + }, + dsh: { + profile: { + bundles: [ + '@deepseek-ai/dsh-base', + '@deepseek-ai/dsh-headless', + '@deepseek-ai/dsh-agent-team-profile', + ], + }, + }, + }, undefined, 2) + '\n') + await writeFile(join(profileDir, 'cordis.patch.yml'), [ + '- id: llm-deepseek', + ' disabled: true', + '- id: session-persistence-jsonl', + ' config:', + ` root: '${sessions}'`, + ' compression: none', + '- insert:', + ' - id: team-fixture-llm', + ` name: '${fixturePlugin}'`, + '', + ].join('\n')) + const launch = resolveExampleLaunch({ + srcBin: dshBinScript, + configArgs: ['--profile', 'headless', '请明确使用 Agent Teams,把调研和实现拆给两个 teammate,等待完成后汇总。'], + tsconfigPath, + env: { + DSH_HOME: home, + DSH_AGENTS_HOME: join(cwd, '.agents'), + DSH_TELEMETRY_DISABLED: '1', + DEEPSEEK_API_KEY: '', + NODE_OPTIONS: [ + process.env.NODE_OPTIONS, + '--disable-warning=ExperimentalWarning', + '--disable-warning=MODULE_TYPELESS_PACKAGE_JSON', + ].filter(Boolean).join(' '), + }, + }) + const result = await execa(launch.command, launch.args, { + cwd, + env: launch.env, + input: '', + timeout: 90_000, + killSignal: 'SIGKILL', + reject: false, + }) + expect( + result.exitCode, + `dsh headless profile exited unexpectedly.\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`, + ).toBe(0) + expect(result.stderr).toBe('') + expect(result.stdout).toContain('TEAM_WORKFLOW_OK') + + const files = (await readdir(sessions, { recursive: true })) + .filter(file => file.endsWith('.jsonl')) + expect(files).toHaveLength(3) + const logs = await Promise.all(files.map(file => readFile(join(sessions, file), 'utf8'))) + const parsed = logs.map(records) + const root = parsed.find((log) => { + const header = log[0] + return header?.type === 'session' && typeof header.parentSession !== 'string' + }) + expect(root).toBeDefined() + const eventTypes = root!.map(record => record.type) + expect(eventTypes.filter(type => type === 'team/member')).toHaveLength(4) + expect(eventTypes).toContain('team/message/queued') + expect(eventTypes).toContain('team/message/delivered') + const taskEvents = root!.filter(record => record.type === 'team/task') + expect(taskEvents.filter((record) => { + const data = record.data as { task?: { status?: string } } | undefined + return data?.task?.status === 'completed' + })).toHaveLength(2) + const toolNames = root!.filter(record => record.type === 'tool/call') + .map(record => (record.data as { name?: string } | undefined)?.name) + expect(toolNames).toContain('wait_agent') + expect(toolNames).toContain('team_task_list') + expect(toolNames).toContain('list_agents') + } finally { + await rm(cwd, { recursive: true, force: true }) + } + }, 105_000) +}) diff --git a/apps/cli/tests/fixtures/team-llm.mjs b/apps/cli/tests/fixtures/team-llm.mjs new file mode 100644 index 0000000000..49cc936112 --- /dev/null +++ b/apps/cli/tests/fixtures/team-llm.mjs @@ -0,0 +1,194 @@ +/** Deterministic keyless Agent Teams adapter for the real `dsh run` product test. */ + +import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm' + +let nextCall = 0 + +function calls(messages) { + return messages.flatMap(message => message.role === 'assistant' + ? message.content.filter(block => block.type === 'tool-call').map(block => block.name) + : []) +} + +function latestAssistantCalls(messages) { + const assistant = messages.findLast(message => message.role === 'assistant') + return assistant?.content.filter(block => block.type === 'tool-call').map(block => block.name) ?? [] +} + +function hasTaskAction(messages, action) { + return messages.some(message => message.role === 'assistant' + && message.content.some((block) => { + if (block.type !== 'tool-call' || block.name !== 'team_task_update') return false + try { + return JSON.parse(block.arguments).action === action + } catch { + return false + } + })) +} + +function latestToolText(messages) { + const message = messages.findLast(candidate => candidate.content.some(block => block.type === 'tool-result')) + if (message === undefined) return '' + return message.content.flatMap(block => block.type === 'tool-result' + ? block.content.filter(item => item.type === 'text').map(item => item.text) + : []).join('\n') +} + +function toolChunks(specs) { + const chunks = [] + for (const [index, spec] of specs.entries()) { + const id = CallId(`team-fixture-${++nextCall}`) + const args = JSON.stringify(spec.args) + chunks.push( + { type: 'block-start', index, blockType: 'tool-call' }, + { type: 'tool-call-delta', index, id, name: spec.name, argumentsDelta: args }, + { type: 'block-end', index, block: { type: 'tool-call', id, name: spec.name, arguments: args } }, + ) + } + chunks.push( + { type: 'usage', usage: { inputTokens: 10, outputTokens: 5 } }, + { type: 'finish', reason: { kind: 'tool-calls' } }, + ) + return chunks +} + +function textChunks(text) { + return [ + { type: 'block-start', index: 0, blockType: 'text' }, + { type: 'text-delta', index: 0, text }, + { type: 'block-end', index: 0, block: { type: 'text', text } }, + { type: 'usage', usage: { inputTokens: 10, outputTokens: 3 } }, + { type: 'finish', reason: { kind: 'stop' } }, + ] +} + +function researcher(messages) { + const names = calls(messages) + if (!names.includes('team_task_create')) { + return toolChunks([{ name: 'team_task_create', args: { + subject: 'Research', description: 'Collect the deterministic finding.', write_scopes: ['research'], + } }]) + } + if (!names.includes('team_task_update')) { + return toolChunks([{ name: 'team_task_update', args: { + task_id: 'task-1', expected_revision: 1, action: 'claim', + } }]) + } + if (!names.includes('send_message')) { + return toolChunks([ + { name: 'team_task_update', args: { task_id: 'task-1', expected_revision: 2, action: 'complete' } }, + { name: 'send_message', args: { target: 'implementer', message: 'Research complete: use the deterministic finding.' } }, + ]) + } + return textChunks('Research teammate complete.') +} + +function implementer(messages) { + const names = calls(messages) + const last = latestAssistantCalls(messages) + const text = latestToolText(messages) + if (!names.includes('team_task_create')) { + if (last.includes('team_task_get') && text.includes('"subject":"Research"')) { + return toolChunks([{ name: 'team_task_create', args: { + subject: 'Implementation', + description: 'Apply the deterministic finding.', + blocked_by: ['task-1'], + write_scopes: ['implementation'], + } }]) + } + if (last.includes('wait_agent')) { + return toolChunks([{ name: 'team_task_get', args: { task_id: 'task-1' } }]) + } + return toolChunks([{ name: 'wait_agent', args: { timeout_ms: 10000 } }]) + } + if (!hasTaskAction(messages, 'claim')) { + if (last.includes('team_task_get') && text.includes('"status":"completed"')) { + return toolChunks([{ name: 'team_task_update', args: { + task_id: 'task-2', expected_revision: 1, action: 'claim', + } }]) + } + if (last.includes('team_task_get')) { + return toolChunks([{ name: 'team_task_get', args: { task_id: 'task-1' } }]) + } + if (last.includes('wait_agent')) { + return toolChunks([{ name: 'team_task_get', args: { task_id: 'task-1' } }]) + } + return toolChunks([{ name: 'team_task_get', args: { task_id: 'task-1' } }]) + } + if (!names.includes('send_message')) { + return toolChunks([ + { name: 'team_task_update', args: { task_id: 'task-2', expected_revision: 2, action: 'complete' } }, + { name: 'send_message', args: { target: 'lead', message: 'Implementation complete and verified.' } }, + ]) + } + return textChunks('Implementation teammate complete.') +} + +function lead(messages) { + const names = calls(messages) + const last = latestAssistantCalls(messages) + const spawned = names.filter(name => name === 'spawn_teammate').length + if (spawned === 0) { + return toolChunks([{ + name: 'spawn_teammate', + args: { + name: 'implementer', + description: 'Own deterministic implementation.', + prompt: 'IMPLEMENTER_MARK: wait for research, complete dependent task 2, report to lead.', + context: 'fresh', + }, + }]) + } + if (spawned === 1) { + return toolChunks([{ + name: 'spawn_teammate', + args: { + name: 'researcher', + description: 'Own deterministic research.', + prompt: 'RESEARCHER_MARK: complete research task 1, message implementer, then finish.', + context: 'fresh', + }, + }]) + } + const result = latestToolText(messages) + if (last.includes('team_task_list')) { + const completed = result.match(/"status":"completed"/gu)?.length ?? 0 + if (completed >= 2) return toolChunks([{ name: 'list_agents', args: {} }]) + return toolChunks([{ name: 'team_task_list', args: {} }]) + } + if (last.includes('list_agents')) { + const inactive = result.match(/"status":"inactive"/gu)?.length ?? 0 + if (inactive >= 2) return textChunks('TEAM_WORKFLOW_OK: both teammates and dependent tasks completed.') + return toolChunks([{ name: 'list_agents', args: {} }]) + } + if (last.includes('wait_agent')) return toolChunks([{ name: 'team_task_list', args: {} }]) + return toolChunks([{ name: 'wait_agent', args: { timeout_ms: 10000 } }]) +} + +class TeamFixtureAdapter extends LlmAdapter { + async * stream(options) { + const userText = options.messages.flatMap(message => message.role === 'user' + ? message.content.filter(block => block.type === 'text').map(block => block.text) + : []).join('\n') + const chunks = userText.includes('RESEARCHER_MARK') + ? researcher(options.messages) + : userText.includes('IMPLEMENTER_MARK') + ? implementer(options.messages) + : lead(options.messages) + for (const chunk of chunks) { + options.signal?.throwIfAborted() + yield chunk + } + } +} + +/** Cordis plugin name. */ +export const name = 'team-fixture-llm' +/** LLM registry dependency. */ +export const inject = ['llm'] + +/** Register the keyless adapter on the shipped default provider route. */ +export function apply(ctx) { + ctx.llm.registerAdapter(['deepseek-official'], new TeamFixtureAdapter()) +} diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 6eca3a6253..e3527d9846 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -3418,6 +3418,7 @@ Abstract service classes — a deployment loads a concrete implementation packag Imported as libraries by other packages; a `cordis.yml` cannot load them. - `@deepseek-ai/dsh-agent-loop-testkit` ([`packages/test-support/agent-loop-testkit/src/index.ts`](../packages/test-support/agent-loop-testkit/src/index.ts)) +- `@deepseek-ai/dsh-agent-team-profile` ([`packages/experimental/agent-team-profile/src/index.ts`](../packages/experimental/agent-team-profile/src/index.ts)) - `@deepseek-ai/dsh-anonymous-user-id` ([`packages/identity/anonymous-user-id/src/index.ts`](../packages/identity/anonymous-user-id/src/index.ts)) - `@deepseek-ai/dsh-app-boot` ([`packages/boot/app-boot/src/index.ts`](../packages/boot/app-boot/src/index.ts)) - `@deepseek-ai/dsh-atomic-write` ([`packages/util/atomic-write/src/index.ts`](../packages/util/atomic-write/src/index.ts)) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index c19137938f..f4c0ed6c58 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -3419,6 +3419,7 @@ export interface Config { 由其他包作为库导入;`cordis.yml` 无法加载它们。 - `@deepseek-ai/dsh-agent-loop-testkit`([`packages/test-support/agent-loop-testkit/src/index.ts`](../packages/test-support/agent-loop-testkit/src/index.ts)) +- `@deepseek-ai/dsh-agent-team-profile`([`packages/experimental/agent-team-profile/src/index.ts`](../packages/experimental/agent-team-profile/src/index.ts)) - `@deepseek-ai/dsh-anonymous-user-id`([`packages/identity/anonymous-user-id/src/index.ts`](../packages/identity/anonymous-user-id/src/index.ts)) - `@deepseek-ai/dsh-app-boot`([`packages/boot/app-boot/src/index.ts`](../packages/boot/app-boot/src/index.ts)) - `@deepseek-ai/dsh-atomic-write`([`packages/util/atomic-write/src/index.ts`](../packages/util/atomic-write/src/index.ts)) diff --git a/packages/experimental/agent-team-profile/README.i18n.yaml b/packages/experimental/agent-team-profile/README.i18n.yaml new file mode 100644 index 0000000000..3a7545e5ef --- /dev/null +++ b/packages/experimental/agent-team-profile/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/experimental/agent-team-profile/README.md +README.md: 1de882f08ebd0ffe0ad6efbc3729db4e75b488e1 +README.zh.md: 75bc68b59dd1564e89ef0209a2f5374e8a8541ed diff --git a/packages/experimental/agent-team-profile/README.md b/packages/experimental/agent-team-profile/README.md new file mode 100644 index 0000000000..1de882f08e --- /dev/null +++ b/packages/experimental/agent-team-profile/README.md @@ -0,0 +1,37 @@ +# @deepseek-ai/dsh-agent-team-profile + +English | [中文](README.zh.md) + +Private profile bundle that enables [Agent Teams](../team/README.md) over `@deepseek-ai/dsh-base`. Its patch inserts the Team domain and scoped tools, disables the overlapping global continuable-child controls, and keeps the ordinary fresh and fork delegation tools as one-shot operations. Install this package explicitly into a source-checkout profile; it is excluded from official releases. + +## Profile installation + +From this repository checkout, add the package to an initialized profile: + +```sh +pnpm dsh plugin --profile headless add ./packages/experimental/agent-team-profile +pnpm dsh --profile headless "Use Agent Teams to split this task between two teammates, wait, and summarize." +``` + +The profile must already contain `@deepseek-ai/dsh-base`, whose Subagent services and provider rows this layer consumes. Removing the package with `dsh plugin --profile remove @deepseek-ai/dsh-agent-team-profile` removes the bundle from the profile's ordered layer list. + +## Model Experience + +### Team policy and tools + +#### What the model sees + +The Team policy and schemas belong to [`@deepseek-ai/dsh-tool-team`](../tool-team/README.md). This bundle changes composition only: Team-scoped `list_agents` and `send_message` replace the disabled global continuable-child controls, while `subagent` and `subagent_fork` remain available as one-shot delegation tools. + +#### Token effect + +The bundle adds the Team policy and tool schemas described by `dsh-tool-team`; it adds no prompt text of its own. + +#### KV Cache effect + +The bundle's composition is prefix-stable while its patch, Team identity, and configured tool schemas remain unchanged. + +## Known Limitations and Deferred Work + +- **Source-checkout only** — this private package is not present in official npm, CLI, Web, or Python release payloads. +- **Shared checkout** — every teammate observes the same working directory; this bundle adds no worktree isolation or filesystem locking. diff --git a/packages/experimental/agent-team-profile/README.zh.md b/packages/experimental/agent-team-profile/README.zh.md new file mode 100644 index 0000000000..75bc68b59d --- /dev/null +++ b/packages/experimental/agent-team-profile/README.zh.md @@ -0,0 +1,37 @@ +# @deepseek-ai/dsh-agent-team-profile + +[English](README.md) | 中文 + +在 `@deepseek-ai/dsh-base` 之上启用 [Agent Teams](../team/README.md) 的私有 profile bundle。它的 patch 会插入 Team domain 与 scoped 工具、禁用名称重叠的全局 continuable-child control,并保留普通的一次性 fresh/fork delegation 工具。必须从源码 checkout 将本包显式安装到 profile;正式发布会排除本包。 + +## Profile 安装 + +在本仓库 checkout 中,将本包添加到已初始化的 profile: + +```sh +pnpm dsh plugin --profile headless add ./packages/experimental/agent-team-profile +pnpm dsh --profile headless "Use Agent Teams to split this task between two teammates, wait, and summarize." +``` + +profile 必须已经包含 `@deepseek-ai/dsh-base`,本层会使用其中的 Subagent service 与 provider 配置行。执行 `dsh plugin --profile remove @deepseek-ai/dsh-agent-team-profile` 移除本包时,bundle 也会从 profile 的有序层列表中移除。 + +## Model Experience + +### Team 策略与工具 + +#### 模型会看到什么 + +Team 策略与 schema 由 [`@deepseek-ai/dsh-tool-team`](../tool-team/README.md) 所有。本 bundle 只改变 composition:Team-scoped `list_agents` 与 `send_message` 会替代已禁用的全局 continuable-child control,而 `subagent` 与 `subagent_fork` 仍作为一次性 delegation 工具可用。 + +#### Token 影响 + +本 bundle 会加入 `dsh-tool-team` 描述的 Team 策略与工具 schema;它自身不增加提示词文本。 + +#### KV Cache 影响 + +只要 bundle patch、Team identity 与配置的工具 schema 不变,本 bundle 的 composition 就保持前缀稳定。 + +## Known Limitations and Deferred Work + +- **仅限源码 checkout**:正式 npm、CLI、Web 与 Python 发布产物都不包含这个私有包。 +- **共享 checkout**:所有 teammate 都观察同一个工作目录;本 bundle 不提供 worktree 隔离或文件系统锁。 diff --git a/packages/experimental/agent-team-profile/cordis.patch.yml b/packages/experimental/agent-team-profile/cordis.patch.yml new file mode 100644 index 0000000000..3bff3a3fc3 --- /dev/null +++ b/packages/experimental/agent-team-profile/cordis.patch.yml @@ -0,0 +1,40 @@ +# Private Agent Teams profile layer. Apply after dsh-base so these replacements +# remove the global continuable-child controls before the scoped Team tools +# register the overlapping list_agents and send_message names. + +- id: tool-subagent-control + disabled: true + +- id: tool-subagent-list-agents + disabled: true + +- id: tool-subagent-report + disabled: true + +- id: tool-subagent + config: + provider: spawn + toolName: subagent + backgroundMode: one-shot + +- id: tool-subagent-fork + config: + provider: fork + toolName: subagent_fork + backgroundMode: one-shot + +- insert: + - id: team + name: '@deepseek-ai/dsh-team' + config: + maxMembers: 8 + maxTasks: 256 + maxPendingMessagesPerMember: 64 + maxMessageBytes: 65536 + disposalTimeoutMs: 5000 + + - id: tool-team + name: '@deepseek-ai/dsh-tool-team' + config: + freshProvider: spawn + forkProvider: fork diff --git a/packages/experimental/agent-team-profile/package.json b/packages/experimental/agent-team-profile/package.json new file mode 100644 index 0000000000..bad770a02f --- /dev/null +++ b/packages/experimental/agent-team-profile/package.json @@ -0,0 +1,54 @@ +{ + "name": "@deepseek-ai/dsh-agent-team-profile", + "description": "Private profile bundle enabling Agent Teams over dsh-base", + "version": "0.1.0-rc.7", + "private": true, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/experimental/agent-team-profile" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./cordis.patch.yml": "./cordis.patch.yml", + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "cordis.patch.yml", + "lib/types/**/*.d.ts" + ], + "license": "MIT", + "dsh": { + "bundle": { + "patch": "./cordis.patch.yml" + } + }, + "dependencies": { + "@deepseek-ai/dsh-team": "workspace:^", + "@deepseek-ai/dsh-tool-team": "workspace:^" + }, + "peerDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" + }, + "devDependencies": { + "@deepseek-ai/cordis-plugin-include": "workspace:^", + "@deepseek-ai/cordis-plugin-loader": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^", + "js-yaml": "^4.2.0" + } +} diff --git a/packages/experimental/agent-team-profile/src/index.ts b/packages/experimental/agent-team-profile/src/index.ts new file mode 100644 index 0000000000..1fa74f0e55 --- /dev/null +++ b/packages/experimental/agent-team-profile/src/index.ts @@ -0,0 +1,8 @@ +/** + * @deepseek-ai/dsh-agent-team-profile — private Agent Teams profile bundle. + * The package's runtime content is its `dsh.bundle.patch` document; this + * module exports no runtime API. + * @module @deepseek-ai/dsh-agent-team-profile + */ + +export {} diff --git a/packages/experimental/agent-team-profile/src/invariant.ts b/packages/experimental/agent-team-profile/src/invariant.ts new file mode 100644 index 0000000000..76eb8a608d --- /dev/null +++ b/packages/experimental/agent-team-profile/src/invariant.ts @@ -0,0 +1,26 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-agent-team-profile`. + * @module @deepseek-ai/dsh-agent-team-profile/invariant + */ + +import type { Context } from '@deepseek-ai/cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-agent-team-profile' + +/** Cordis companion plugin name. */ +export const name = 'agent-team-profile-invariant' +/** Service required before the companion can register. */ +export const inject = ['invariants'] + +// No runtime invariant: the package carries only a static profile patch. The +// Team domain and tool packages own the mutable relationships it activates. +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) diff --git a/packages/experimental/agent-team-profile/tests/profile.spec.ts b/packages/experimental/agent-team-profile/tests/profile.spec.ts new file mode 100644 index 0000000000..94f9b107b7 --- /dev/null +++ b/packages/experimental/agent-team-profile/tests/profile.spec.ts @@ -0,0 +1,53 @@ +/** The experimental bundle must carry one parseable, explicit Team layer. */ + +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { resolve } from 'node:path' +import { describe, expect, it } from 'vitest' +import * as yaml from 'js-yaml' +import { entryListSchema } from '@deepseek-ai/cordis-plugin-include' + +describe('Agent Teams profile bundle', () => { + it('declares a private parseable layer with Team-owned controls', () => { + const root = fileURLToPath(new URL('..', import.meta.url)) + const manifest = JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8')) as { + private?: boolean + publishConfig?: unknown + dependencies?: Record + dsh?: { bundle?: { patch?: string } } + } + expect(manifest.private).toBe(true) + expect(manifest.publishConfig).toBeUndefined() + expect(manifest.dsh?.bundle?.patch).toBe('./cordis.patch.yml') + expect(manifest.dependencies).toMatchObject({ + '@deepseek-ai/dsh-team': 'workspace:^', + '@deepseek-ai/dsh-tool-team': 'workspace:^', + }) + + const parsed = yaml.load( + readFileSync(resolve(root, manifest.dsh!.bundle!.patch!), 'utf8'), + { schema: entryListSchema }, + ) + expect(Array.isArray(parsed)).toBe(true) + const patches = parsed as { + id?: string + disabled?: boolean + config?: Record + insert?: { id?: string; name?: string; config?: Record }[] + }[] + expect(patches.find(patch => patch.id === 'tool-subagent-control')).toMatchObject({ disabled: true }) + expect(patches.find(patch => patch.id === 'tool-subagent-list-agents')).toMatchObject({ disabled: true }) + expect(patches.find(patch => patch.id === 'tool-subagent-report')).toMatchObject({ disabled: true }) + expect(patches.find(patch => patch.id === 'tool-subagent')?.config).toMatchObject({ backgroundMode: 'one-shot' }) + expect(patches.find(patch => patch.id === 'tool-subagent-fork')?.config).toMatchObject({ backgroundMode: 'one-shot' }) + const inserted = patches.flatMap(patch => patch.insert ?? []) + expect(inserted.find(entry => entry.id === 'team')).toMatchObject({ + name: '@deepseek-ai/dsh-team', + config: { maxMembers: 8 }, + }) + expect(inserted.find(entry => entry.id === 'tool-team')).toMatchObject({ + name: '@deepseek-ai/dsh-tool-team', + config: { freshProvider: 'spawn', forkProvider: 'fork' }, + }) + }) +}) diff --git a/packages/experimental/agent-team-profile/tsconfig.json b/packages/experimental/agent-team-profile/tsconfig.json new file mode 100644 index 0000000000..766072ba7e --- /dev/null +++ b/packages/experimental/agent-team-profile/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../runtime-diagnostics/invariants" + }, + { + "path": "../team" + }, + { + "path": "../tool-team" + } + ] +} diff --git a/packages/experimental/agent-team/tests/team.spec.ts b/packages/experimental/agent-team/tests/team.spec.ts index 3a805cea1f..9b606a3de3 100644 --- a/packages/experimental/agent-team/tests/team.spec.ts +++ b/packages/experimental/agent-team/tests/team.spec.ts @@ -838,6 +838,28 @@ describe('Team shared task DAG', () => { }) describe('Team mailbox and waiting', () => { + it('injects a quiet message addressed to the Lead and checkpoints its receipt', async () => { + const { ctx, lead } = await setup([]) + const message: TeamMessageSnapshot = { + id: TeamMessageId('quiet-lead-message'), + senderId: SessionId('team-worker'), + senderName: 'worker', + targetId: lead.id, + delivery: 'quiet', + content: content('quiet report'), + } + lead.session.append('team/message/queued', { + version: 1, + teamId: TeamId(lead.id), + message, + }) + + await expect(teamInternals(ctx).mailbox.tryDispatch(lead, message, SIGNAL)).resolves.toBe(true) + expect(lead.inbox.nextStep.some(input => input.source.kind === 'team-message' + && input.source.messageId === message.id)).toBe(true) + expect(durable(lead).pendingMessages).toEqual([]) + }) + it('acknowledges waking messages persisted by a busy Lead before model claim', async () => { const { ctx, lead, teamFiber } = await setup(['hang', 'hang'], { maxPendingMessagesPerMember: 1 }) const started = await spawn(ctx, lead, 'lead-reporter') diff --git a/scripts/check-workspace-constraints.spec.ts b/scripts/check-workspace-constraints.spec.ts index dc3b63227f..c0d13d2366 100644 --- a/scripts/check-workspace-constraints.spec.ts +++ b/scripts/check-workspace-constraints.spec.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from 'vitest' import { checkExperimentalDependencyIsolation, checkExperimentalManifest, + expectedDshPackageFiles, type WorkspaceManifest, } from './check-workspace-constraints.ts' @@ -74,3 +75,17 @@ describe('experimental workspace constraints', () => { ]) }) }) + +describe('package payload constraints', () => { + it('includes a declared profile patch without a package-name allowlist', () => { + expect(expectedDshPackageFiles({ + name: '@deepseek-ai/dsh-private-profile', + dsh: { bundle: { patch: './cordis.patch.yml' } }, + })).toEqual([ + 'lib/index.js', + 'lib/invariant.js', + 'cordis.patch.yml', + 'lib/types/**/*.d.ts', + ]) + }) +}) diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index cd9620e0fa..d9036ec78c 100644 --- a/scripts/check-workspace-constraints.ts +++ b/scripts/check-workspace-constraints.ts @@ -174,7 +174,7 @@ function sameStringList(actual: readonly string[] | undefined, expected: readonl return !!actual && actual.length === expected.length && actual.every((value, index) => value === expected[index]) } -function expectedDshPackageFiles(manifest: PackageManifest): readonly string[] { +export function expectedDshPackageFiles(manifest: PackageManifest): readonly string[] { const declaredPatch = manifest.dsh?.bundle?.patch const bundleFiles = declaredPatch === undefined ? [] : [declaredPatch.replace(/^\.\//, '')] const extras = [ From fd53e479b48b08981a0a1836046a585f6958d4ff Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Wed, 19 Aug 2026 12:10:17 +0800 Subject: [PATCH 2/8] fix(profile): isolate bundle module fallbacks --- packages/experimental/agent-team-profile/README.i18n.yaml | 4 ++-- packages/experimental/agent-team-profile/README.md | 2 +- packages/experimental/agent-team-profile/README.zh.md | 2 +- packages/experimental/agent-team-profile/cordis.patch.yml | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/experimental/agent-team-profile/README.i18n.yaml b/packages/experimental/agent-team-profile/README.i18n.yaml index 3a7545e5ef..63e2e8f219 100644 --- a/packages/experimental/agent-team-profile/README.i18n.yaml +++ b/packages/experimental/agent-team-profile/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/experimental/agent-team-profile/README.md -README.md: 1de882f08ebd0ffe0ad6efbc3729db4e75b488e1 -README.zh.md: 75bc68b59dd1564e89ef0209a2f5374e8a8541ed +README.md: da96a8cf4608a1427e318202da1975d19a33f7cf +README.zh.md: c382105374faa9f599234efebf3697b3b77d27fe diff --git a/packages/experimental/agent-team-profile/README.md b/packages/experimental/agent-team-profile/README.md index 1de882f08e..da96a8cf46 100644 --- a/packages/experimental/agent-team-profile/README.md +++ b/packages/experimental/agent-team-profile/README.md @@ -21,7 +21,7 @@ The profile must already contain `@deepseek-ai/dsh-base`, whose Subagent service #### What the model sees -The Team policy and schemas belong to [`@deepseek-ai/dsh-tool-team`](../tool-team/README.md). This bundle changes composition only: Team-scoped `list_agents` and `send_message` replace the disabled global continuable-child controls, while `subagent` and `subagent_fork` remain available as one-shot delegation tools. +The Team policy and schemas belong to [`@deepseek-ai/dsh-tool-team`](../tool-team/README.md). This bundle changes composition only: Team-scoped `list_agents`, `send_message`, and `interrupt_agent` replace the disabled global continuable-child controls. `subagent` and `subagent_fork` remain available as one-shot delegation tools, whose children do not receive the continuable-child `report` tool. #### Token effect diff --git a/packages/experimental/agent-team-profile/README.zh.md b/packages/experimental/agent-team-profile/README.zh.md index 75bc68b59d..c382105374 100644 --- a/packages/experimental/agent-team-profile/README.zh.md +++ b/packages/experimental/agent-team-profile/README.zh.md @@ -21,7 +21,7 @@ profile 必须已经包含 `@deepseek-ai/dsh-base`,本层会使用其中的 Su #### 模型会看到什么 -Team 策略与 schema 由 [`@deepseek-ai/dsh-tool-team`](../tool-team/README.md) 所有。本 bundle 只改变 composition:Team-scoped `list_agents` 与 `send_message` 会替代已禁用的全局 continuable-child control,而 `subagent` 与 `subagent_fork` 仍作为一次性 delegation 工具可用。 +Team 策略与 schema 由 [`@deepseek-ai/dsh-tool-team`](../tool-team/README.md) 所有。本 bundle 只改变 composition:Team-scoped `list_agents`、`send_message` 与 `interrupt_agent` 会替代已禁用的全局 continuable-child control。`subagent` 与 `subagent_fork` 仍作为一次性 delegation 工具可用,其子 agent 不会获得 continuable-child `report` 工具。 #### Token 影响 diff --git a/packages/experimental/agent-team-profile/cordis.patch.yml b/packages/experimental/agent-team-profile/cordis.patch.yml index 3bff3a3fc3..f6a9fc0ddc 100644 --- a/packages/experimental/agent-team-profile/cordis.patch.yml +++ b/packages/experimental/agent-team-profile/cordis.patch.yml @@ -1,6 +1,6 @@ # Private Agent Teams profile layer. Apply after dsh-base so these replacements # remove the global continuable-child controls before the scoped Team tools -# register the overlapping list_agents and send_message names. +# register the overlapping list_agents, send_message, and interrupt_agent names. - id: tool-subagent-control disabled: true From 6e4fabdc1f329bbc34ab3eec671a0cf68ba87ca0 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Thu, 20 Aug 2026 14:55:37 +0800 Subject: [PATCH 3/8] fix(boot): stabilize profile module fallbacks --- .../feature/2026-08-05-agent-teams.md | 2 +- .../feature/2026-08-05-agent-teams.zh.md | 2 +- apps/cli/tests/agent-team-headless.e2e.ts | 4 +- apps/cli/tests/fixtures/team-llm.mjs | 194 ------------------ .../headless/team.cordis.snapshot.yml | 42 +--- .../headless/tests/fixtures/team-llm.mjs | 2 +- .../agent-team-profile/package.json | 1 - 7 files changed, 12 insertions(+), 235 deletions(-) delete mode 100644 apps/cli/tests/fixtures/team-llm.mjs diff --git a/.agents/notes/implemented/feature/2026-08-05-agent-teams.md b/.agents/notes/implemented/feature/2026-08-05-agent-teams.md index e415826483..7c2220fba2 100644 --- a/.agents/notes/implemented/feature/2026-08-05-agent-teams.md +++ b/.agents/notes/implemented/feature/2026-08-05-agent-teams.md @@ -62,7 +62,7 @@ Worktree isolation is not a harness runtime behavior. A deployment or prompt may ## Testing -Package tests cover identity, name and authority checks, provider selection, reserved-id persistence collisions, child-before-Lead flush ordering, durable provisioning failure and pending-inbox JSONL/SQLite reconciliation, concurrent target-local ordering, pending/history de-duplication, mailbox limits, post-flush notification, bounded disposal with in-flight creation and dispatch cancellation, failed-member cleanup, task CAS and DAG validation, write-scope warnings, wait cancellation/timeout, inbox-preserving interruption, ordinary-fork isolation, legacy-control shadowing, compact declared-schema result rendering, and scoped registration HMR at per-file 100% coverage. A keyless headless Loader snapshot assembles the real Team plugins and exercises two teammates, dependent tasks, peer delivery, waiting, completion, and Lead aggregation. A CLI product test loads the private Agent Teams profile bundle, starts `dsh run` through the real Loader and direct core front door, and verifies normal process exit with persisted Team and child logs. +Package tests cover identity, name and authority checks, provider selection, reserved-id persistence collisions, child-before-Lead flush ordering, durable provisioning failure and pending-inbox JSONL/SQLite reconciliation, concurrent target-local ordering, pending/history de-duplication, mailbox limits, post-flush notification, bounded disposal with in-flight creation and dispatch cancellation, failed-member cleanup, task CAS and DAG validation, write-scope warnings, wait cancellation/timeout, inbox-preserving interruption, ordinary-fork isolation, legacy-control shadowing, compact declared-schema result rendering, and scoped registration HMR at per-file 100% coverage. A keyless product snapshot loads the private Agent Teams profile bundle through `dsh run` and pins its complete model-visible tool list, Team policy, and durable workflow projection for two teammates, dependent tasks, peer delivery, waiting, completion, and aggregation. A CLI e2e reuses the same deterministic adapter and verifies normal process exit with persisted Team and child logs. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md b/.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md index e253b1fa87..9cd1958712 100644 --- a/.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md +++ b/.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md @@ -62,7 +62,7 @@ Worktree isolation 不是 harness runtime 行为。deployment 或 prompt 可以 ## Testing -Package test 以逐文件 100% coverage 覆盖身份、名字与权限检查、provider 选择、预留 id 持久化冲突、child-before-Lead flush 顺序、持久 provisioning 失败与 pending-inbox JSONL/SQLite 对账、target-local 并发顺序、pending/history 去重、mailbox 限额、flush 后 notification、取消在途创建与 dispatch 的有界 dispose、failed member cleanup、task CAS 与 DAG 校验、write-scope warning、wait cancel/timeout、保留 inbox 的 interrupt、普通 fork 隔离、旧 control shadowing、声明 schema 的紧凑结果渲染与 scoped registration HMR。一条 keyless headless Loader 快照会组合真实 Team 插件,并运行两个 teammate、依赖任务、peer 投递、等待、完成与 Lead 汇总。CLI 产品测试会加载私有 Agent Teams profile bundle,通过真实 Loader 与直接 core front door 启动 `dsh run`,并验证带持久 Team 与 child 日志的正常退出。 +Package test 以逐文件 100% coverage 覆盖身份、名字与权限检查、provider 选择、预留 id 持久化冲突、child-before-Lead flush 顺序、持久 provisioning 失败与 pending-inbox JSONL/SQLite 对账、target-local 并发顺序、pending/history 去重、mailbox 限额、flush 后 notification、取消在途创建与 dispatch 的有界 dispose、failed member cleanup、task CAS 与 DAG 校验、write-scope warning、wait cancel/timeout、保留 inbox 的 interrupt、普通 fork 隔离、旧 control shadowing、声明 schema 的紧凑结果渲染与 scoped registration HMR。一条 keyless 产品快照会通过 `dsh run` 加载私有 Agent Teams profile bundle,并为两个 teammate、依赖任务、peer 投递、等待、完成和汇总固定完整的面向模型工具列表、Team policy 与持久 workflow 投影。CLI e2e 会复用同一个确定性 adapter,并验证带持久 Team 与 child 日志的正常退出。 ## Consequences diff --git a/apps/cli/tests/agent-team-headless.e2e.ts b/apps/cli/tests/agent-team-headless.e2e.ts index 6e655a0151..70500d6f96 100644 --- a/apps/cli/tests/agent-team-headless.e2e.ts +++ b/apps/cli/tests/agent-team-headless.e2e.ts @@ -8,7 +8,9 @@ import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke' const dshBinScript = fileURLToPath(new URL('../src/bin.ts', import.meta.url)) const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) -const fixturePlugin = pathToFileURL(fileURLToPath(new URL('./fixtures/team-llm.mjs', import.meta.url))).href +const fixturePlugin = pathToFileURL(fileURLToPath( + new URL('../../../examples/headless-agent/tests/fixtures/team-llm.mjs', import.meta.url), +)).href function records(content: string): Record[] { return content.split('\n').filter(Boolean).map(line => JSON.parse(line) as Record) diff --git a/apps/cli/tests/fixtures/team-llm.mjs b/apps/cli/tests/fixtures/team-llm.mjs deleted file mode 100644 index 49cc936112..0000000000 --- a/apps/cli/tests/fixtures/team-llm.mjs +++ /dev/null @@ -1,194 +0,0 @@ -/** Deterministic keyless Agent Teams adapter for the real `dsh run` product test. */ - -import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm' - -let nextCall = 0 - -function calls(messages) { - return messages.flatMap(message => message.role === 'assistant' - ? message.content.filter(block => block.type === 'tool-call').map(block => block.name) - : []) -} - -function latestAssistantCalls(messages) { - const assistant = messages.findLast(message => message.role === 'assistant') - return assistant?.content.filter(block => block.type === 'tool-call').map(block => block.name) ?? [] -} - -function hasTaskAction(messages, action) { - return messages.some(message => message.role === 'assistant' - && message.content.some((block) => { - if (block.type !== 'tool-call' || block.name !== 'team_task_update') return false - try { - return JSON.parse(block.arguments).action === action - } catch { - return false - } - })) -} - -function latestToolText(messages) { - const message = messages.findLast(candidate => candidate.content.some(block => block.type === 'tool-result')) - if (message === undefined) return '' - return message.content.flatMap(block => block.type === 'tool-result' - ? block.content.filter(item => item.type === 'text').map(item => item.text) - : []).join('\n') -} - -function toolChunks(specs) { - const chunks = [] - for (const [index, spec] of specs.entries()) { - const id = CallId(`team-fixture-${++nextCall}`) - const args = JSON.stringify(spec.args) - chunks.push( - { type: 'block-start', index, blockType: 'tool-call' }, - { type: 'tool-call-delta', index, id, name: spec.name, argumentsDelta: args }, - { type: 'block-end', index, block: { type: 'tool-call', id, name: spec.name, arguments: args } }, - ) - } - chunks.push( - { type: 'usage', usage: { inputTokens: 10, outputTokens: 5 } }, - { type: 'finish', reason: { kind: 'tool-calls' } }, - ) - return chunks -} - -function textChunks(text) { - return [ - { type: 'block-start', index: 0, blockType: 'text' }, - { type: 'text-delta', index: 0, text }, - { type: 'block-end', index: 0, block: { type: 'text', text } }, - { type: 'usage', usage: { inputTokens: 10, outputTokens: 3 } }, - { type: 'finish', reason: { kind: 'stop' } }, - ] -} - -function researcher(messages) { - const names = calls(messages) - if (!names.includes('team_task_create')) { - return toolChunks([{ name: 'team_task_create', args: { - subject: 'Research', description: 'Collect the deterministic finding.', write_scopes: ['research'], - } }]) - } - if (!names.includes('team_task_update')) { - return toolChunks([{ name: 'team_task_update', args: { - task_id: 'task-1', expected_revision: 1, action: 'claim', - } }]) - } - if (!names.includes('send_message')) { - return toolChunks([ - { name: 'team_task_update', args: { task_id: 'task-1', expected_revision: 2, action: 'complete' } }, - { name: 'send_message', args: { target: 'implementer', message: 'Research complete: use the deterministic finding.' } }, - ]) - } - return textChunks('Research teammate complete.') -} - -function implementer(messages) { - const names = calls(messages) - const last = latestAssistantCalls(messages) - const text = latestToolText(messages) - if (!names.includes('team_task_create')) { - if (last.includes('team_task_get') && text.includes('"subject":"Research"')) { - return toolChunks([{ name: 'team_task_create', args: { - subject: 'Implementation', - description: 'Apply the deterministic finding.', - blocked_by: ['task-1'], - write_scopes: ['implementation'], - } }]) - } - if (last.includes('wait_agent')) { - return toolChunks([{ name: 'team_task_get', args: { task_id: 'task-1' } }]) - } - return toolChunks([{ name: 'wait_agent', args: { timeout_ms: 10000 } }]) - } - if (!hasTaskAction(messages, 'claim')) { - if (last.includes('team_task_get') && text.includes('"status":"completed"')) { - return toolChunks([{ name: 'team_task_update', args: { - task_id: 'task-2', expected_revision: 1, action: 'claim', - } }]) - } - if (last.includes('team_task_get')) { - return toolChunks([{ name: 'team_task_get', args: { task_id: 'task-1' } }]) - } - if (last.includes('wait_agent')) { - return toolChunks([{ name: 'team_task_get', args: { task_id: 'task-1' } }]) - } - return toolChunks([{ name: 'team_task_get', args: { task_id: 'task-1' } }]) - } - if (!names.includes('send_message')) { - return toolChunks([ - { name: 'team_task_update', args: { task_id: 'task-2', expected_revision: 2, action: 'complete' } }, - { name: 'send_message', args: { target: 'lead', message: 'Implementation complete and verified.' } }, - ]) - } - return textChunks('Implementation teammate complete.') -} - -function lead(messages) { - const names = calls(messages) - const last = latestAssistantCalls(messages) - const spawned = names.filter(name => name === 'spawn_teammate').length - if (spawned === 0) { - return toolChunks([{ - name: 'spawn_teammate', - args: { - name: 'implementer', - description: 'Own deterministic implementation.', - prompt: 'IMPLEMENTER_MARK: wait for research, complete dependent task 2, report to lead.', - context: 'fresh', - }, - }]) - } - if (spawned === 1) { - return toolChunks([{ - name: 'spawn_teammate', - args: { - name: 'researcher', - description: 'Own deterministic research.', - prompt: 'RESEARCHER_MARK: complete research task 1, message implementer, then finish.', - context: 'fresh', - }, - }]) - } - const result = latestToolText(messages) - if (last.includes('team_task_list')) { - const completed = result.match(/"status":"completed"/gu)?.length ?? 0 - if (completed >= 2) return toolChunks([{ name: 'list_agents', args: {} }]) - return toolChunks([{ name: 'team_task_list', args: {} }]) - } - if (last.includes('list_agents')) { - const inactive = result.match(/"status":"inactive"/gu)?.length ?? 0 - if (inactive >= 2) return textChunks('TEAM_WORKFLOW_OK: both teammates and dependent tasks completed.') - return toolChunks([{ name: 'list_agents', args: {} }]) - } - if (last.includes('wait_agent')) return toolChunks([{ name: 'team_task_list', args: {} }]) - return toolChunks([{ name: 'wait_agent', args: { timeout_ms: 10000 } }]) -} - -class TeamFixtureAdapter extends LlmAdapter { - async * stream(options) { - const userText = options.messages.flatMap(message => message.role === 'user' - ? message.content.filter(block => block.type === 'text').map(block => block.text) - : []).join('\n') - const chunks = userText.includes('RESEARCHER_MARK') - ? researcher(options.messages) - : userText.includes('IMPLEMENTER_MARK') - ? implementer(options.messages) - : lead(options.messages) - for (const chunk of chunks) { - options.signal?.throwIfAborted() - yield chunk - } - } -} - -/** Cordis plugin name. */ -export const name = 'team-fixture-llm' -/** LLM registry dependency. */ -export const inject = ['llm'] - -/** Register the keyless adapter on the shipped default provider route. */ -export function apply(ctx) { - ctx.llm.registerAdapter(['deepseek-official'], new TeamFixtureAdapter()) -} diff --git a/apps/cli/tests/profiles/headless/team.cordis.snapshot.yml b/apps/cli/tests/profiles/headless/team.cordis.snapshot.yml index d9be33caab..b44e0e6ba3 100644 --- a/apps/cli/tests/profiles/headless/team.cordis.snapshot.yml +++ b/apps/cli/tests/profiles/headless/team.cordis.snapshot.yml @@ -1,36 +1,6 @@ -# Keyless Agent Teams composition over the real headless app and deterministic fixture adapter. -- id: base - name: '@deepseek-ai/cordis-plugin-include' - config: - path: ./cordis.yml - patches: - - id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' - disabled: true - - id: tool-subagent-control - name: '@deepseek-ai/dsh-tool-subagent-control' - disabled: true - - id: tool-subagent-report - name: '@deepseek-ai/dsh-tool-subagent-report' - disabled: true - - id: tool-subagent - name: '@deepseek-ai/dsh-tool-subagent' - config: - provider: spawn - toolName: subagent - backgroundMode: one-shot - maxDepth: 1 - - id: tool-subagent-fork - name: '@deepseek-ai/dsh-tool-subagent' - config: - provider: fork - toolName: subagent_fork - backgroundMode: one-shot - maxDepth: 1 - - insert: - - id: agent-team - name: '@deepseek-ai/dsh-experimental-agent-team' - - id: tool-agent-team - name: '@deepseek-ai/dsh-experimental-tool-agent-team' - - id: team-fixture-llm - name: './tests/fixtures/team-llm.mjs' +# Keyless overlay for the real Agent Teams profile bundle. +- id: llm-deepseek + disabled: true +- insert: + - id: team-fixture-llm + name: './tests/fixtures/team-llm.mjs' diff --git a/apps/cli/tests/profiles/headless/tests/fixtures/team-llm.mjs b/apps/cli/tests/profiles/headless/tests/fixtures/team-llm.mjs index c1c973c771..0ec1b9703a 100644 --- a/apps/cli/tests/profiles/headless/tests/fixtures/team-llm.mjs +++ b/apps/cli/tests/profiles/headless/tests/fixtures/team-llm.mjs @@ -1,4 +1,4 @@ -/** Deterministic keyless Agent Teams adapter for the real headless Loader snapshot. */ +/** Deterministic keyless Agent Teams adapter shared by profile snapshot and CLI e2e. */ import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm' diff --git a/packages/experimental/agent-team-profile/package.json b/packages/experimental/agent-team-profile/package.json index bad770a02f..01d81baf87 100644 --- a/packages/experimental/agent-team-profile/package.json +++ b/packages/experimental/agent-team-profile/package.json @@ -46,7 +46,6 @@ }, "devDependencies": { "@deepseek-ai/cordis-plugin-include": "workspace:^", - "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/cordis": "workspace:^", "js-yaml": "^4.2.0" From 2c16c20d2a93282b323c88b43b724a4769ec0ffe Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Thu, 20 Aug 2026 18:19:37 +0800 Subject: [PATCH 4/8] fix(profile): address Agent Teams CLI review --- .agents/notes/implemented/feature/2026-08-05-agent-teams.md | 2 +- .agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-05-agent-teams.md b/.agents/notes/implemented/feature/2026-08-05-agent-teams.md index 7c2220fba2..9924550a04 100644 --- a/.agents/notes/implemented/feature/2026-08-05-agent-teams.md +++ b/.agents/notes/implemented/feature/2026-08-05-agent-teams.md @@ -62,7 +62,7 @@ Worktree isolation is not a harness runtime behavior. A deployment or prompt may ## Testing -Package tests cover identity, name and authority checks, provider selection, reserved-id persistence collisions, child-before-Lead flush ordering, durable provisioning failure and pending-inbox JSONL/SQLite reconciliation, concurrent target-local ordering, pending/history de-duplication, mailbox limits, post-flush notification, bounded disposal with in-flight creation and dispatch cancellation, failed-member cleanup, task CAS and DAG validation, write-scope warnings, wait cancellation/timeout, inbox-preserving interruption, ordinary-fork isolation, legacy-control shadowing, compact declared-schema result rendering, and scoped registration HMR at per-file 100% coverage. A keyless product snapshot loads the private Agent Teams profile bundle through `dsh run` and pins its complete model-visible tool list, Team policy, and durable workflow projection for two teammates, dependent tasks, peer delivery, waiting, completion, and aggregation. A CLI e2e reuses the same deterministic adapter and verifies normal process exit with persisted Team and child logs. +Package tests cover identity, name and authority checks, provider selection, reserved-id persistence collisions, child-before-Lead flush ordering, durable provisioning failure and pending-inbox JSONL/SQLite reconciliation, concurrent target-local ordering, pending/history de-duplication, mailbox limits, post-flush notification, bounded disposal with in-flight creation and dispatch cancellation, failed-member cleanup, task CAS and DAG validation, write-scope warnings, wait cancellation/timeout, inbox-preserving interruption, ordinary-fork isolation, legacy-control shadowing, compact declared-schema result rendering, and scoped registration HMR at per-file 100% coverage. A keyless product snapshot loads the private Agent Teams profile bundle through `dsh --profile headless` and pins its complete model-visible tool list, Team policy, and durable workflow projection for two teammates, dependent tasks, peer delivery, waiting, completion, and aggregation. A CLI e2e reuses the same deterministic adapter and verifies normal process exit with persisted Team and child logs. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md b/.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md index 9cd1958712..91ae807f30 100644 --- a/.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md +++ b/.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md @@ -62,7 +62,7 @@ Worktree isolation 不是 harness runtime 行为。deployment 或 prompt 可以 ## Testing -Package test 以逐文件 100% coverage 覆盖身份、名字与权限检查、provider 选择、预留 id 持久化冲突、child-before-Lead flush 顺序、持久 provisioning 失败与 pending-inbox JSONL/SQLite 对账、target-local 并发顺序、pending/history 去重、mailbox 限额、flush 后 notification、取消在途创建与 dispatch 的有界 dispose、failed member cleanup、task CAS 与 DAG 校验、write-scope warning、wait cancel/timeout、保留 inbox 的 interrupt、普通 fork 隔离、旧 control shadowing、声明 schema 的紧凑结果渲染与 scoped registration HMR。一条 keyless 产品快照会通过 `dsh run` 加载私有 Agent Teams profile bundle,并为两个 teammate、依赖任务、peer 投递、等待、完成和汇总固定完整的面向模型工具列表、Team policy 与持久 workflow 投影。CLI e2e 会复用同一个确定性 adapter,并验证带持久 Team 与 child 日志的正常退出。 +Package test 以逐文件 100% coverage 覆盖身份、名字与权限检查、provider 选择、预留 id 持久化冲突、child-before-Lead flush 顺序、持久 provisioning 失败与 pending-inbox JSONL/SQLite 对账、target-local 并发顺序、pending/history 去重、mailbox 限额、flush 后 notification、取消在途创建与 dispatch 的有界 dispose、failed member cleanup、task CAS 与 DAG 校验、write-scope warning、wait cancel/timeout、保留 inbox 的 interrupt、普通 fork 隔离、旧 control shadowing、声明 schema 的紧凑结果渲染与 scoped registration HMR。一条 keyless 产品快照会通过 `dsh --profile headless` 加载私有 Agent Teams profile bundle,并为两个 teammate、依赖任务、peer 投递、等待、完成和汇总固定完整的面向模型工具列表、Team policy 与持久 workflow 投影。CLI e2e 会复用同一个确定性 adapter,并验证带持久 Team 与 child 日志的正常退出。 ## Consequences From 1477d5b9efe79bf354ca67cc257666f374a73419 Mon Sep 17 00:00:00 2001 From: fengsy Date: Tue, 25 Aug 2026 11:42:33 +0800 Subject: [PATCH 5/8] fix(profile): preserve current fallback architecture after rebase --- ...xperimental-agent-teams-packages.i18n.yaml | 4 +- ...08-18-experimental-agent-teams-packages.md | 8 +- ...18-experimental-agent-teams-packages.zh.md | 8 +- .../feature/2026-08-05-agent-teams.i18n.yaml | 4 +- apps/cli/package.json | 1 + apps/cli/reference/README.i18n.yaml | 4 +- apps/cli/reference/README.md | 2 +- apps/cli/reference/README.zh.md | 2 +- apps/cli/src/profile-boot.ts | 2 +- apps/cli/tests/agent-team-headless.e2e.ts | 6 +- .../headless/team.cordis.snapshot.yml | 55 ++- apps/cli/tests/web-agent-presets.e2e.ts | 2 +- apps/web/tests/scaffold.ts | 2 +- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 2 +- docs/config-catalog.zh.md | 2 +- docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 3 + docs/module-graph.zh.md | 3 + knip.json | 6 + packages/boot/app-boot/README.i18n.yaml | 4 +- packages/boot/app-boot/README.md | 2 +- packages/boot/app-boot/README.zh.md | 2 +- packages/boot/app-boot/src/index.ts | 1 + packages/boot/app-boot/src/profile.ts | 247 ++++++++++--- packages/boot/app-boot/tests/profile.spec.ts | 324 ++++++++++++++++-- packages/experimental/README.i18n.yaml | 4 +- packages/experimental/README.md | 1 + packages/experimental/README.zh.md | 1 + .../agent-team-profile/README.i18n.yaml | 4 +- .../experimental/agent-team-profile/README.md | 8 +- .../agent-team-profile/README.zh.md | 8 +- .../agent-team-profile/cordis.patch.yml | 8 +- .../agent-team-profile/package.json | 8 +- .../agent-team-profile/src/index.ts | 4 +- .../agent-team-profile/src/invariant.ts | 6 +- .../agent-team-profile/tests/profile.spec.ts | 12 +- .../agent-team-profile/tsconfig.json | 4 +- pnpm-lock.yaml | 25 ++ tsconfig.host.json | 1 + 40 files changed, 646 insertions(+), 152 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.i18n.yaml index 9ac53a7db3..d5fdbcc63f 100644 --- a/.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.md -2026-08-18-experimental-agent-teams-packages.md: cfaf7b23d03a1adecf8acc18a110717a62aa9ed1 -2026-08-18-experimental-agent-teams-packages.zh.md: 1d06addff6c8f347726408d3bb02bc72374492cb +2026-08-18-experimental-agent-teams-packages.md: 495922d57bd88a78f0ca6b61367ef49c820db632 +2026-08-18-experimental-agent-teams-packages.zh.md: 65aed57e223d8fa9b8ff97e2fcc109057b51474d diff --git a/.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.md b/.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.md index cfaf7b23d0..495922d57b 100644 --- a/.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.md +++ b/.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.md @@ -12,12 +12,16 @@ An experimental directory without a current package previously imposed placement ## Decision -`packages/experimental/agent-team` and `packages/experimental/tool-agent-team` are private workspace packages. The [experimental package naming decision](2026-08-19-experimental-package-name-prefix.md) owns their npm names and promotion rename; this note owns their placement, release exclusion, and dependency isolation. +`packages/experimental/agent-team`, `packages/experimental/tool-agent-team`, and `packages/experimental/agent-team-profile` are private workspace packages. The [experimental package naming decision](2026-08-19-experimental-package-name-prefix.md) owns their npm names and promotion rename; this note owns their placement, release exclusion, and dependency isolation. The dsh pack and publish set and the local baseline publisher exclude every manifest below `packages/experimental/`. `release:dsh` still advances their manifest versions with the shared dsh version without creating release tags. Workspace constraints require each experimental package to set `private: true` and omit `publishConfig`. The same top-level check rejects `dependencies`, `optionalDependencies`, and `peerDependencies` from release packages, release apps, or the Python runtime to an experimental package. Experimental packages may depend on release packages and each other; tests may use them through `devDependencies`, and examples may load them explicitly. The generic caller-reserved continuable child identity and selective direct-child drain remain in the stable Subagent service. They own Subagent identity and Activation lifecycle without importing or naming Agent Teams; the experimental Team service consumes them in the permitted direction. +The private Agent Teams profile bundle depends on the Team packages and applies after `dsh-base`. It inserts the Team rows, disables the global continuable-child controls whose model-visible names overlap the Team tools, and leaves the shipped base, CLI, Web, and Python runtime dependency graphs unchanged. + +Profile startup resolves selected bundles before healing module fallbacks. The shared fallback retains the dsh installation's carrier-specific entries: symlinks under plain Node and ESM proxies in a packaged executable. Missing packages from selected bundle closures are linked under the current profile's own `node_modules`, while pnpm-managed profile entries remain authoritative. Closure discovery starts from each explicit external bundle's real package directory and traverses every listed root even when an earlier dependency has the same package name. It excludes dsh-owned profile projections from later discovery, so a projected dependency cannot feed back into its own closure. Link ownership compares canonical parent paths so junction-normalized targets remain removable. A private profile layer can therefore carry experimental plugin rows without adding those plugins to a release app, requiring profile users to install transitive packages directly, weakening packaged-runtime module identity, or changing another profile's resolution. + Experimental status changes publication and compatibility expectations only. The packages retain the repository's ordinary documentation, invariant, lifecycle, security, unit, real-composition, and snapshot requirements. Promotion requires review of the public contracts, limitations, test evidence, release payload, runtime dependents, and a named owner accepting stable-package obligations. ## Alternatives considered @@ -30,6 +34,6 @@ Experimental status changes publication and compatibility expectations only. The ## Consequences -Agent Teams can use the full repository graph and quality checks without entering official tarballs or becoming a supported runtime dependency. A release package cannot expose Team until the Team packages are promoted, so CLI and Web experiments use explicit example or experimental compositions instead of the shipped base bundles. +Agent Teams can use the full repository graph and quality checks without entering official tarballs or becoming a supported runtime dependency. A release package cannot expose Team until the Team packages are promoted, so the CLI experiment installs an explicit private profile layer instead of changing shipped bundles. The generic profile launcher accepts that layer without making its plugin dependencies part of the dsh release closure. The product-role grouping is less direct while the packages incubate. Promotion creates path and npm-name churn as specified by the experimental package naming decision. diff --git a/.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.zh.md b/.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.zh.md index 1d06addff6..65aed57e22 100644 --- a/.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.zh.md @@ -12,12 +12,16 @@ Agent Teams 的服务与工具约定仍在变化,但它需要使用真实 Sess ## 决策 -`packages/experimental/agent-team` 与 `packages/experimental/tool-agent-team` 是私有 workspace 包。[实验性包命名决策](2026-08-19-experimental-package-name-prefix.zh.md)负责其 npm 名和 promotion 重命名;本记录负责其目录归属、发布排除与依赖隔离。 +`packages/experimental/agent-team`、`packages/experimental/tool-agent-team` 与 `packages/experimental/agent-team-profile` 是私有 workspace 包。[实验性包命名决策](2026-08-19-experimental-package-name-prefix.zh.md)负责其 npm 名和 promotion 重命名;本记录负责其目录归属、发布排除与依赖隔离。 dsh pack 与 publish 集合以及本地 baseline 发布器均排除 `packages/experimental/` 下的所有 manifest。`release:dsh` 仍会让这些 manifest 跟随 dsh 共享版本递增,但不会创建发布 tag。workspace 约束要求每个实验性包设置 `private: true` 并省略 `publishConfig`。同一个顶层检查会拒绝发布包、发布 app 或 Python runtime 通过 `dependencies`、`optionalDependencies` 或 `peerDependencies` 依赖实验性包。实验性包可以依赖发布包和其他实验性包;测试可以通过 `devDependencies` 使用它们,示例可以显式加载它们。 通用的调用方预留 continuable child 身份和精确 direct-child drain 仍属于稳定 Subagent 服务。它们负责 Subagent 身份与 Activation 生命周期,不 import 或命名 Agent Teams;实验性 Team 服务沿允许的方向消费这些能力。 +私有 Agent Teams profile bundle 依赖 Team 包,并应用在 `dsh-base` 之后。它插入 Team 配置行,禁用模型可见名称与 Team 工具重叠的全局 continuable-child control,并保持已发布 base、CLI、Web 与 Python runtime 的依赖图不变。 + +profile 启动会先解析所选 bundle,再修复模块 fallback。共享 fallback 保留 dsh 安装的载体专用条目:普通 Node 下使用 symlink,打包 executable 中使用 ESM proxy。仅由所选 bundle 闭包携带的缺失包会链接到当前 profile 自己的 `node_modules` 下,而 pnpm 管理的 profile 条目仍具有优先权。闭包发现从每个显式外部 bundle 的真实包目录开始;即使前一个依赖具有相同包名,也会遍历所有列出的根。后续发现会排除 dsh 所有的 profile projection,避免投影后的依赖重新进入自己的闭包。link ownership 通过规范化父路径比较,使 junction 规范化后的 target 仍可删除。因此,私有 profile 层可以携带实验性 plugin 配置行,而无需把这些 plugin 加入发布 app、要求 profile 用户直接安装传递依赖、破坏 packaged-runtime 的模块身份,或改变其他 profile 的解析结果。 + 实验性状态只改变发布与兼容性预期。这些包仍须满足仓库的一般文档、不变式、生命周期、安全、单元测试、真实组合测试和快照要求。promotion 前必须评审公开约定、限制、测试证据、发布 payload、运行时依赖方,并由一名具名 owner 接受稳定包义务。 ## 曾考虑的替代方案 @@ -30,6 +34,6 @@ dsh pack 与 publish 集合以及本地 baseline 发布器均排除 `packages/ex ## 后果 -Agent Teams 可以使用完整仓库依赖图与质量检查,而不进入正式 tarball,也不会成为受支持的运行时依赖。在 Team 包 promotion 前,发布包不能暴露 Team,因此 CLI 和 Web 实验使用显式示例或实验性组合,而不是已发布的基础组合包。 +Agent Teams 可以使用完整仓库依赖图与质量检查,而不进入正式 tarball,也不会成为受支持的运行时依赖。在 Team 包 promotion 前,发布包不能暴露 Team,因此 CLI 实验会安装显式的私有 profile 层,而不是修改已发布 bundle。通用 profile launcher 可以接受该层,而不会让它的 plugin 依赖进入 dsh 发布闭包。 孵化期间的产品职责分组不够直接。promotion 会按照实验性包命名决策产生路径和 npm 名改动。 diff --git a/.agents/notes/implemented/feature/2026-08-05-agent-teams.i18n.yaml b/.agents/notes/implemented/feature/2026-08-05-agent-teams.i18n.yaml index c5d1d4967b..7af7350d5b 100644 --- a/.agents/notes/implemented/feature/2026-08-05-agent-teams.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-05-agent-teams.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-05-agent-teams.md -2026-08-05-agent-teams.md: d37777a0cca467edcec5d38999aee53aaf14dc36 -2026-08-05-agent-teams.zh.md: 91571bd28aa44a21bebe1ef9cfc8434c2e8850a3 +2026-08-05-agent-teams.md: 9924550a04b636535ce1daa329865beb1c9e951d +2026-08-05-agent-teams.zh.md: 91ae807f3005c173a61f9fc32661d2b650692a04 diff --git a/apps/cli/package.json b/apps/cli/package.json index d940c56bb4..5076da6592 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -105,6 +105,7 @@ "@deepseek-ai/dsh-credentials-local": "workspace:^", "@deepseek-ai/dsh-deepseek-llm-api-extensions": "workspace:^", "@deepseek-ai/dsh-experimental-agent-team": "workspace:^", + "@deepseek-ai/dsh-experimental-agent-team-profile": "workspace:^", "@deepseek-ai/dsh-experimental-tool-agent-team": "workspace:^", "@deepseek-ai/dsh-fs-observation-policy": "workspace:^", "@deepseek-ai/dsh-fs-sandbox": "workspace:^", diff --git a/apps/cli/reference/README.i18n.yaml b/apps/cli/reference/README.i18n.yaml index ddebf7616b..0a9abcfe1b 100644 --- a/apps/cli/reference/README.i18n.yaml +++ b/apps/cli/reference/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/cli/reference/README.md -README.md: ba1804cb9985ae8b275c64690d63b1f56eb22041 -README.zh.md: c5fbff9310eee92251115cc61fb33abc5896aedd +README.md: 0391cb18d89263d6b53319d8b125731178ac5132 +README.zh.md: 13a4873bbd19401991d2a62f50602e133330aa35 diff --git a/apps/cli/reference/README.md b/apps/cli/reference/README.md index ba1804cb99..0391cb18d8 100644 --- a/apps/cli/reference/README.md +++ b/apps/cli/reference/README.md @@ -8,7 +8,7 @@ This reference defines the profile, web-alias, plugin-management, and config-dum `dsh --profile ` boots the profile at `$DSH_HOME/profiles/`. The effective tree is composed over an empty root by applying, in order: each bundle patch named in the profile manifest's `dsh.profile.bundles` list, the profile's own `cordis.patch.yml`, the home-level `$DSH_HOME/cordis.patch.yml` (machine-local preferences shared by every profile, so it outranks the per-profile layer), and each `--patch ` overlay in argv order. Later layers win per row; a patch replaces the targeted row's complete `config` value rather than deep-merging keys, and may insert new rows. `dsh.profile.patchReload` selects `live` patch-file watching or `startup` one-time loading; omission defaults a custom profile to `live`. A parse, schema, resolution, or plugin boot failure is reported and exits nonzero. SIGINT and SIGTERM dispose the mounted root before exit. -Bundle names resolve from the dsh installation first, then from the profile directory. In-box bundles (`@deepseek-ai/dsh-base`, `@deepseek-ai/dsh-web-app`, `@deepseek-ai/dsh-headless`, `@deepseek-ai/dsh-sdk-app`, `@deepseek-ai/dsh-sdk-minimal`, `@deepseek-ai/dsh-acp-app`) therefore always come from the same installation as the running `dsh`; out-of-tree bundles come from the profile's pnpm-managed `node_modules`. A bare plugin `name` in any patch row resolves through the profile directory's Node parent walk, which reaches the maintained installation fallback `$DSH_HOME/profiles/node_modules`. Plain Node installations place one healed symlink there per dependency-closure package. A pkg executable instead places a real ESM proxy that mirrors explicit exports and re-exports the virtual package URL, because operating-system symlinks cannot enter pkg's `/snapshot` filesystem. +Bundle names resolve from the dsh installation first, then from the profile directory. In-box bundles (`@deepseek-ai/dsh-base`, `@deepseek-ai/dsh-web-app`, `@deepseek-ai/dsh-headless`, `@deepseek-ai/dsh-sdk-app`, `@deepseek-ai/dsh-sdk-minimal`, `@deepseek-ai/dsh-acp-app`) therefore always come from the same installation as the running `dsh`; out-of-tree bundles come from the profile's pnpm-managed `node_modules`. A bare plugin `name` in any patch row resolves through the profile directory's Node parent walk, which reaches the maintained installation fallback `$DSH_HOME/profiles/node_modules`. Plain Node installations place one healed symlink there per dependency-closure package. A pkg executable instead places a real ESM proxy that mirrors explicit exports and re-exports the virtual package URL, because operating-system symlinks cannot enter pkg's `/snapshot` filesystem. Every launch also links packages carried only by selected external bundles through a dsh-owned directory into the current profile's `node_modules`; existing pnpm entries win, and each profile owns its links independently. The `web`, `headless`, `sdk`, `sdk-minimal`, and `acp` profiles auto-initialize from shipped templates on first use (`web`: base + web-app with live patches; `headless`: base + headless with startup-only patches; `sdk`: base + sdk-app with startup-only patches; `sdk-minimal`: its standalone bundle with startup-only patches; `acp`: base + acp-app with startup-only patches). Any other missing profile fails loud with a hint to run `dsh plugin --profile add `. diff --git a/apps/cli/reference/README.zh.md b/apps/cli/reference/README.zh.md index c5fbff9310..13a4873bbd 100644 --- a/apps/cli/reference/README.zh.md +++ b/apps/cli/reference/README.zh.md @@ -8,7 +8,7 @@ `dsh --profile ` 启动位于 `$DSH_HOME/profiles/` 的 profile。生效配置树以空根节点为起点,依次叠加 profile manifest(元数据清单)的 `dsh.profile.bundles` 列表中指定的各组合包 patch、profile 自身的 `cordis.patch.yml`、home 级的 `$DSH_HOME/cordis.patch.yml`(这是各 profile 共享的机器本地偏好,因此优先于逐 profile 配置层),以及按 argv 顺序指定的各个 `--patch ` 覆盖层。对同一配置行,后应用的层优先。patch 会替换目标行的整个 `config` 值,而不是深度合并其中的键;patch 也可以插入新行。`dsh.profile.patchReload` 可选择 `live` patch 文件监视或 `startup` 单次加载;自定义 profile 省略该值时默认使用 `live`。配置解析、schema 校验、模块解析或插件启动失败时,系统会报告错误并以非零状态退出。收到 SIGINT 或 SIGTERM 时,挂载的根节点会先 dispose(资源释放)再退出。 -组合包名称先从 dsh 安装目录解析,再从 profile 目录解析。因此,内置组合包(`@deepseek-ai/dsh-base`、`@deepseek-ai/dsh-web-app`、`@deepseek-ai/dsh-headless`、`@deepseek-ai/dsh-sdk-app`、`@deepseek-ai/dsh-sdk-minimal`、`@deepseek-ai/dsh-acp-app`)始终来自当前运行的 `dsh` 所属的安装;树外组合包则来自 profile 中由 pnpm 管理的 `node_modules`。patch 行中的裸插件 `name` 会从 profile 目录开始,按照 Node 的模块解析规则逐级向父目录查找,直至由 dsh 维护的安装后备目录 `$DSH_HOME/profiles/node_modules`。普通 Node 安装会为依赖闭包中的每个包放置并修复一个符号链接。pkg 可执行程序则放置真实 ESM 代理,镜像显式 exports 并重新导出虚拟包 URL,因为操作系统符号链接无法进入 pkg 的 `/snapshot` 文件系统。 +组合包名称先从 dsh 安装目录解析,再从 profile 目录解析。因此,内置组合包(`@deepseek-ai/dsh-base`、`@deepseek-ai/dsh-web-app`、`@deepseek-ai/dsh-headless`、`@deepseek-ai/dsh-sdk-app`、`@deepseek-ai/dsh-sdk-minimal`、`@deepseek-ai/dsh-acp-app`)始终来自当前运行的 `dsh` 所属的安装;树外组合包则来自 profile 中由 pnpm 管理的 `node_modules`。patch 行中的裸插件 `name` 会从 profile 目录开始,按照 Node 的模块解析规则逐级向父目录查找,直至由 dsh 维护的安装后备目录 `$DSH_HOME/profiles/node_modules`。普通 Node 安装会为依赖闭包中的每个包放置并修复一个符号链接。pkg 可执行程序则放置真实 ESM 代理,镜像显式 exports 并重新导出虚拟包 URL,因为操作系统符号链接无法进入 pkg 的 `/snapshot` 文件系统。每次启动还会把仅由所选外部 bundle 携带的包经 dsh 自有目录链接到当前 profile 的 `node_modules`;已有 pnpm 条目优先,且每个 profile 独立拥有自己的链接。 `web`、`headless`、`sdk`、`sdk-minimal` 和 `acp` profile 首次使用时会从随附模板自动初始化(`web`:base + web-app,实时应用 patch;`headless`:base + headless,只在启动时应用 patch;`sdk`:base + sdk-app,只在启动时应用 patch;`sdk-minimal`:独立组合包,只在启动时应用 patch;`acp`:base + acp-app,只在启动时应用 patch)。其他缺失的 profile 会显式报错,并提示运行 `dsh plugin --profile add `。 diff --git a/apps/cli/src/profile-boot.ts b/apps/cli/src/profile-boot.ts index 674095bca7..ad5c362d3d 100644 --- a/apps/cli/src/profile-boot.ts +++ b/apps/cli/src/profile-boot.ts @@ -157,8 +157,8 @@ async function composeProfile( name: string, patchFiles: readonly string[], ): Promise { - await healProfilesModuleFallback(INSTALL_ANCHOR) const profile = prepareProfile(name) + await healProfilesModuleFallback({ installAnchor: INSTALL_ANCHOR, profile }) const homePatches = loadOptionalPatches(NAME, homePatchPath()) ?? [] const overlays = patchFiles.flatMap(file => loadOverlayPatches(NAME, resolve(file))) const bundlePatches = profile.layers.flatMap(layer => layer.patches) diff --git a/apps/cli/tests/agent-team-headless.e2e.ts b/apps/cli/tests/agent-team-headless.e2e.ts index 70500d6f96..92b3b58548 100644 --- a/apps/cli/tests/agent-team-headless.e2e.ts +++ b/apps/cli/tests/agent-team-headless.e2e.ts @@ -9,7 +9,7 @@ import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke' const dshBinScript = fileURLToPath(new URL('../src/bin.ts', import.meta.url)) const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) const fixturePlugin = pathToFileURL(fileURLToPath( - new URL('../../../examples/headless-agent/tests/fixtures/team-llm.mjs', import.meta.url), + new URL('./profiles/headless/tests/fixtures/team-llm.mjs', import.meta.url), )).href function records(content: string): Record[] { @@ -28,14 +28,14 @@ describe('dsh run with Agent Teams enabled', () => { name: 'dsh-profile-headless', private: true, dependencies: { - '@deepseek-ai/dsh-agent-team-profile': 'workspace:^', + '@deepseek-ai/dsh-experimental-agent-team-profile': 'workspace:^', }, dsh: { profile: { bundles: [ '@deepseek-ai/dsh-base', '@deepseek-ai/dsh-headless', - '@deepseek-ai/dsh-agent-team-profile', + '@deepseek-ai/dsh-experimental-agent-team-profile', ], }, }, diff --git a/apps/cli/tests/profiles/headless/team.cordis.snapshot.yml b/apps/cli/tests/profiles/headless/team.cordis.snapshot.yml index b44e0e6ba3..06b1788910 100644 --- a/apps/cli/tests/profiles/headless/team.cordis.snapshot.yml +++ b/apps/cli/tests/profiles/headless/team.cordis.snapshot.yml @@ -1,6 +1,49 @@ -# Keyless overlay for the real Agent Teams profile bundle. -- id: llm-deepseek - disabled: true -- insert: - - id: team-fixture-llm - name: './tests/fixtures/team-llm.mjs' +# Keyless Agent Teams composition over the real headless app and deterministic fixture adapter. +- id: base + name: '@deepseek-ai/cordis-plugin-include' + config: + path: ./cordis.yml + patches: + - id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + disabled: true + - id: tool-subagent-control + name: '@deepseek-ai/dsh-tool-subagent-control' + disabled: true + - id: tool-subagent-list-agents + name: '@deepseek-ai/dsh-tool-subagent-control/list-agents' + disabled: true + - id: tool-subagent-report + name: '@deepseek-ai/dsh-tool-subagent-report' + disabled: true + - id: tool-subagent + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: spawn + toolName: subagent + backgroundMode: one-shot + maxDepth: 1 + - id: tool-subagent-fork + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: fork + toolName: subagent_fork + backgroundMode: one-shot + enableRunInBackground: false + maxDepth: 1 + - insert: + - id: agent-team + name: '@deepseek-ai/dsh-experimental-agent-team' + config: + maxMembers: 8 + maxTasks: 256 + maxPendingMessagesPerMember: 64 + maxMessageBytes: 65536 + disposalTimeoutMs: 5000 + - id: tool-agent-team + name: '@deepseek-ai/dsh-experimental-tool-agent-team' + config: + freshProvider: spawn + forkProvider: fork + - id: team-fixture-llm + name: './tests/fixtures/team-llm.mjs' diff --git a/apps/cli/tests/web-agent-presets.e2e.ts b/apps/cli/tests/web-agent-presets.e2e.ts index fa17f65b1e..883f202a34 100644 --- a/apps/cli/tests/web-agent-presets.e2e.ts +++ b/apps/cli/tests/web-agent-presets.e2e.ts @@ -108,7 +108,7 @@ async function bootWeb( // upward walk. The flat fallback the preset boot maintains is what makes // them resolvable — the same mechanism, not a test-only shim. const home = dirname(settingsFile) - await healProfilesModuleFallback(INSTALL_ANCHOR, home) + await healProfilesModuleFallback({ installAnchor: INSTALL_ANCHOR, home }) const profileDir = join(home, 'profiles', 'spec') await mkdir(profileDir, { recursive: true }) // Product Bundles are installed into the Profile, not the dsh app. Model diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index bde659deec..58e4e249b7 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -572,7 +572,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise/profiles. - await healProfilesModuleFallback(INSTALL_ANCHOR, harnessHome) + await healProfilesModuleFallback({ installAnchor: INSTALL_ANCHOR, home: harnessHome }) const profileDir = join(harnessHome, 'profiles', 'scaffold') await mkdir(profileDir, { recursive: true }) const rootConfig = join(profileDir, 'cordis.yml') diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 39d4865391..6f01d13ecf 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: 6eca3a6253928cb74ea857bf9c54521684dce0fd -config-catalog.zh.md: c19137938fcb0dbf3a5556f732ce8e97178d5603 +config-catalog.md: e4d60a02367bb35965564ee88dcb4065c97a5cf4 +config-catalog.zh.md: 9dc302705780297a4dd177174613ad3346516e2a diff --git a/docs/config-catalog.md b/docs/config-catalog.md index e3527d9846..e4d60a0236 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -3418,7 +3418,6 @@ Abstract service classes — a deployment loads a concrete implementation packag Imported as libraries by other packages; a `cordis.yml` cannot load them. - `@deepseek-ai/dsh-agent-loop-testkit` ([`packages/test-support/agent-loop-testkit/src/index.ts`](../packages/test-support/agent-loop-testkit/src/index.ts)) -- `@deepseek-ai/dsh-agent-team-profile` ([`packages/experimental/agent-team-profile/src/index.ts`](../packages/experimental/agent-team-profile/src/index.ts)) - `@deepseek-ai/dsh-anonymous-user-id` ([`packages/identity/anonymous-user-id/src/index.ts`](../packages/identity/anonymous-user-id/src/index.ts)) - `@deepseek-ai/dsh-app-boot` ([`packages/boot/app-boot/src/index.ts`](../packages/boot/app-boot/src/index.ts)) - `@deepseek-ai/dsh-atomic-write` ([`packages/util/atomic-write/src/index.ts`](../packages/util/atomic-write/src/index.ts)) @@ -3431,6 +3430,7 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them. - `@deepseek-ai/dsh-client-web` ([`packages/client/web/src/index.ts`](../packages/client/web/src/index.ts)) - `@deepseek-ai/dsh-cmdline` ([`packages/boot/cmdline/src/index.ts`](../packages/boot/cmdline/src/index.ts)) - `@deepseek-ai/dsh-code-runtime-python` ([`packages/code-runtime/code-runtime-python/src/index.ts`](../packages/code-runtime/code-runtime-python/src/index.ts)) +- `@deepseek-ai/dsh-experimental-agent-team-profile` ([`packages/experimental/agent-team-profile/src/index.ts`](../packages/experimental/agent-team-profile/src/index.ts)) - `@deepseek-ai/dsh-experimental-webworker-packer` ([`packages/experimental/webworker-packer/src/index.ts`](../packages/experimental/webworker-packer/src/index.ts)) - `@deepseek-ai/dsh-experimental-webworker-runtime` ([`packages/experimental/webworker-runtime/src/index.ts`](../packages/experimental/webworker-runtime/src/index.ts)) - `@deepseek-ai/dsh-home-paths` ([`packages/util/home-paths/src/index.ts`](../packages/util/home-paths/src/index.ts)) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index f4c0ed6c58..9dc3027057 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -3419,7 +3419,6 @@ export interface Config { 由其他包作为库导入;`cordis.yml` 无法加载它们。 - `@deepseek-ai/dsh-agent-loop-testkit`([`packages/test-support/agent-loop-testkit/src/index.ts`](../packages/test-support/agent-loop-testkit/src/index.ts)) -- `@deepseek-ai/dsh-agent-team-profile`([`packages/experimental/agent-team-profile/src/index.ts`](../packages/experimental/agent-team-profile/src/index.ts)) - `@deepseek-ai/dsh-anonymous-user-id`([`packages/identity/anonymous-user-id/src/index.ts`](../packages/identity/anonymous-user-id/src/index.ts)) - `@deepseek-ai/dsh-app-boot`([`packages/boot/app-boot/src/index.ts`](../packages/boot/app-boot/src/index.ts)) - `@deepseek-ai/dsh-atomic-write`([`packages/util/atomic-write/src/index.ts`](../packages/util/atomic-write/src/index.ts)) @@ -3432,6 +3431,7 @@ export interface Config { - `@deepseek-ai/dsh-client-web`([`packages/client/web/src/index.ts`](../packages/client/web/src/index.ts)) - `@deepseek-ai/dsh-cmdline`([`packages/boot/cmdline/src/index.ts`](../packages/boot/cmdline/src/index.ts)) - `@deepseek-ai/dsh-code-runtime-python`([`packages/code-runtime/code-runtime-python/src/index.ts`](../packages/code-runtime/code-runtime-python/src/index.ts)) +- `@deepseek-ai/dsh-experimental-agent-team-profile`([`packages/experimental/agent-team-profile/src/index.ts`](../packages/experimental/agent-team-profile/src/index.ts)) - `@deepseek-ai/dsh-experimental-webworker-packer`([`packages/experimental/webworker-packer/src/index.ts`](../packages/experimental/webworker-packer/src/index.ts)) - `@deepseek-ai/dsh-experimental-webworker-runtime`([`packages/experimental/webworker-runtime/src/index.ts`](../packages/experimental/webworker-runtime/src/index.ts)) - `@deepseek-ai/dsh-home-paths`([`packages/util/home-paths/src/index.ts`](../packages/util/home-paths/src/index.ts)) diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index a589753bfa..3eadb7c1a9 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/module-graph.md -module-graph.md: c0fcd9366e2b0c1c60a7672ecbb1f12fe3bac998 -module-graph.zh.md: d4c5600a67bdf29184b5376d7800fa1b329039c1 +module-graph.md: 3cafd70ae2cc982470d8594228764bf701ab687c +module-graph.zh.md: 0037321174ff91b59a3957d4bfa6f620af6dcc93 diff --git a/docs/module-graph.md b/docs/module-graph.md index c0fcd9366e..3cafd70ae2 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -205,6 +205,7 @@ flowchart TD end subgraph group_experimental["packages/experimental"] pkg_experimental_agent_team["experimental-agent-team"] + pkg_experimental_agent_team_profile["experimental-agent-team-profile"] pkg_experimental_tool_agent_team["experimental-tool-agent-team"] pkg_experimental_webworker_packer["experimental-webworker-packer"] pkg_experimental_webworker_runtime["experimental-webworker-runtime"] @@ -376,6 +377,7 @@ flowchart TD pkg_code_runtime --> pkg_invariants pkg_code_runtime_python --> pkg_invariants pkg_e2b --> pkg_invariants + pkg_experimental_agent_team_profile --> pkg_invariants pkg_experimental_webworker_packer --> pkg_invariants pkg_host_directory_picker --> pkg_invariants pkg_host_directory_picker_browse --> pkg_invariants @@ -1681,6 +1683,7 @@ flowchart TD | [`code-runtime`](../packages/code-runtime/code-runtime) | `code-runtime` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`code-runtime-python`](../packages/code-runtime/code-runtime-python) | `code-runtime` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`e2b`](../packages/e2b/e2b) | `e2b` | [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`experimental-agent-team-profile`](../packages/experimental/agent-team-profile) | `experimental` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`experimental-webworker-packer`](../packages/experimental/webworker-packer) | `experimental` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`host-directory-picker`](../packages/host/directory-picker) | `host` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | `host` | [`invariants`](../packages/runtime-diagnostics/invariants) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index d4c5600a67..0037321174 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -207,6 +207,7 @@ flowchart TD end subgraph group_experimental["packages/experimental"] pkg_experimental_agent_team["experimental-agent-team"] + pkg_experimental_agent_team_profile["experimental-agent-team-profile"] pkg_experimental_tool_agent_team["experimental-tool-agent-team"] pkg_experimental_webworker_packer["experimental-webworker-packer"] pkg_experimental_webworker_runtime["experimental-webworker-runtime"] @@ -378,6 +379,7 @@ flowchart TD pkg_code_runtime --> pkg_invariants pkg_code_runtime_python --> pkg_invariants pkg_e2b --> pkg_invariants + pkg_experimental_agent_team_profile --> pkg_invariants pkg_experimental_webworker_packer --> pkg_invariants pkg_host_directory_picker --> pkg_invariants pkg_host_directory_picker_browse --> pkg_invariants @@ -1683,6 +1685,7 @@ flowchart TD | [`code-runtime`](../packages/code-runtime/code-runtime) | `code-runtime` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`code-runtime-python`](../packages/code-runtime/code-runtime-python) | `code-runtime` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`e2b`](../packages/e2b/e2b) | `e2b` | [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`experimental-agent-team-profile`](../packages/experimental/agent-team-profile) | `experimental` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`experimental-webworker-packer`](../packages/experimental/webworker-packer) | `experimental` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`host-directory-picker`](../packages/host/directory-picker) | `host` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | `host` | [`invariants`](../packages/runtime-diagnostics/invariants) | diff --git a/knip.json b/knip.json index cd4a35cd7b..09e6327745 100644 --- a/knip.json +++ b/knip.json @@ -725,6 +725,12 @@ "tests/**/*.ts" ] }, + "packages/experimental/agent-team-profile": { + "ignoreDependencies": [ + "@deepseek-ai/dsh-experimental-agent-team", + "@deepseek-ai/dsh-experimental-tool-agent-team" + ] + }, "packages/bundle/base": { "ignoreDependencies": [ "@deepseek-ai/.+" diff --git a/packages/boot/app-boot/README.i18n.yaml b/packages/boot/app-boot/README.i18n.yaml index 5c976b9148..b980e35c55 100644 --- a/packages/boot/app-boot/README.i18n.yaml +++ b/packages/boot/app-boot/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/boot/app-boot/README.md -README.md: 20c93552113f39c349615ef56fdd9ee7f814c5bc -README.zh.md: a6cb2401c73d0dfeb68250c9cde338f807af7edd +README.md: 9094950a7b154d0feb0d8bd0b76e1f06ff2a10fb +README.zh.md: 3a3ef6c82182d23a50dfa460591b20e9ebfd291e diff --git a/packages/boot/app-boot/README.md b/packages/boot/app-boot/README.md index 20c9355211..9094950a7b 100644 --- a/packages/boot/app-boot/README.md +++ b/packages/boot/app-boot/README.md @@ -82,7 +82,7 @@ This section explains how the outcomes above are realized and points at the code - **Channel-neutral library.** The package carries no loader hooks and no dev-mode surface; the [`dsh` app](../../../apps/cli/README.md) owns its Node source-launch hook and consumes these helpers for the boot sequence, and built consumers use plain Node package resolution. - **Two Loader builtins.** `mountRootInclude` registers `cordis:include` and `cordis:group` as Loader builtins: a group row gives one `isolate` realm to a provider and its consumers together, and an agent preset outside this workspace cannot resolve `@deepseek-ai/cordis-plugin-group` by name. Both load through the ambient module pipeline rather than the included tree's own specifier resolution. -- **Profile module fallback.** Bare plugin specifiers resolve through the Loader from the config directory. Plain Node maintains one symlink per package in the installation dependency closure. A packaged executable instead reads each installed export map with Node ESM conditions and writes real proxy packages that re-export virtual module URLs, because an operating-system symlink cannot enter pkg's `/snapshot` tree. Missing exports stay unavailable, malformed maps fail startup, and a cross-process writer lock replaces stale entries without exposing partial proxies. +- **Profile module fallback.** Bare plugin specifiers resolve through the Loader from the config directory. Plain Node maintains one symlink per package in the installation dependency closure. A packaged executable instead reads each installed export map with Node ESM conditions and writes real proxy packages that re-export virtual module URLs, because an operating-system symlink cannot enter pkg's `/snapshot` tree. Missing exports stay unavailable, malformed maps fail startup, and a cross-process writer lock replaces stale entries without exposing partial proxies. A selected external bundle absent from the installation closure receives a profile-local `.dsh-module-fallback` link; existing pnpm entries win, projected links are excluded from later closure discovery, and cleanup removes only dsh-owned links. - **One rejection checkpoint.** `assertEntriesActivated` keeps the exact reasons it folds into the boot diagnostic visible through the next process rejection checkpoint, so `installFailLoud` coalesces Loader's duplicate notification while unrelated unhandled rejections remain fatal. - **Two-stage failure labels.** `boot()` distinguishes `host preparation failed` — `prepare` threw before any config-tree entry mounted — from `plugin tree failed to load`, and appends the deepest plugin error's stack so the startup diagnostic preserves the original activation error instead of only the wrap chain. diff --git a/packages/boot/app-boot/README.zh.md b/packages/boot/app-boot/README.zh.md index a6cb2401c7..3a3ef6c821 100644 --- a/packages/boot/app-boot/README.zh.md +++ b/packages/boot/app-boot/README.zh.md @@ -82,7 +82,7 @@ profile 是同一套 dsh 安装提供不同应用界面的方式:`web`、`head - **与渠道无关的库。** 此包不包含 loader 钩子,也不提供开发模式接口;[`dsh` 应用](../../../apps/cli/README.zh.md) 持有自己的 Node 源码启动钩子,并在启动序列中使用这些 helper,构建后的消费方则使用普通 Node 包解析。 - **两个 Loader builtin。** `mountRootInclude` 把 `cordis:include` 与 `cordis:group` 注册为 Loader builtin:group 行能把一个提供方与它的消费方放进同一个 `isolate` realm,而位于本工作区之外的 agent preset 无法按名称解析 `@deepseek-ai/cordis-plugin-group`。两者都通过宿主的模块管线加载,而非被包含树自身的说明符解析。 -- **Profile 模块后备机制。** 裸插件 specifier 由 Loader 从配置目录解析。普通 Node 会为安装依赖闭包中的每个包维护一个符号链接。打包可执行文件无法让操作系统符号链接进入 pkg 的 `/snapshot` 树,因此会按 Node ESM 条件读取已安装包的 export map,并写入重新导出虚拟模块 URL 的真实代理包。缺失 export 保持不可用,错误 export map 会让启动失败,跨进程 writer lock 则会在不暴露部分代理的情况下替换陈旧条目。 +- **Profile 模块后备机制。** 裸插件 specifier 由 Loader 从配置目录解析。普通 Node 会为安装依赖闭包中的每个包维护一个符号链接。打包可执行文件无法让操作系统符号链接进入 pkg 的 `/snapshot` 树,因此会按 Node ESM 条件读取已安装包的 export map,并写入重新导出虚拟模块 URL 的真实代理包。缺失 export 保持不可用,错误 export map 会让启动失败,跨进程 writer lock 则会在不暴露部分代理的情况下替换陈旧条目。所选外部 bundle 若不在安装闭包中,则会获得 profile 本地的 `.dsh-module-fallback` 链接;已有 pnpm 条目优先,后续闭包发现会排除投影链接,清理也只删除 dsh 自有链接。 - **单一 rejection 检查点。** `assertEntriesActivated` 把折入启动诊断的确切原因保持到下一个进程级 rejection 检查点可见,使 `installFailLoud` 能合并 Loader 的重复通知,而所有无关的未处理 rejection 仍然致命。 - **两阶段失败标签。** `boot()` 区分 `host preparation failed`(`prepare` 在任何配置树条目挂载前抛出)与 `plugin tree failed to load`(此后的一切失败),并追加最深层插件错误的堆栈,使启动诊断保留原始激活错误,而不只是包装链。 diff --git a/packages/boot/app-boot/src/index.ts b/packages/boot/app-boot/src/index.ts index e834ae3934..89f92762bb 100644 --- a/packages/boot/app-boot/src/index.ts +++ b/packages/boot/app-boot/src/index.ts @@ -47,6 +47,7 @@ export { type Profile, type ProfileLayer, type ProfileManifest, + type ProfileModuleFallbackOptions, type ProfilePatchReload, type ProfileTemplate, } from './profile.ts' diff --git a/packages/boot/app-boot/src/profile.ts b/packages/boot/app-boot/src/profile.ts index b99dca29c7..4b861ccc53 100644 --- a/packages/boot/app-boot/src/profile.ts +++ b/packages/boot/app-boot/src/profile.ts @@ -14,17 +14,19 @@ * * Module resolution is two-anchor by construction: a bundle name resolves * first from the dsh installation (the launcher's own package), then from the - * profile directory. The Loader's `baseUrl` is the profile directory, whose - * `node_modules` pnpm manages for out-of-tree plugins, while the maintained - * flat fallback directory `$DSH_HOME/profiles/node_modules` (one symlink per - * package the installation's app and bundles depend on) makes every in-box - * plugin Node-resolvable from any profile through the ordinary parent-walk. + * profile directory. Pnpm-managed entries in the profile's `node_modules` + * resolve first. Dsh-owned links add packages carried only by selected + * bundles, while `$DSH_HOME/profiles/node_modules` supplies the installation + * dependency closure through Node's ordinary parent-walk. Plain Node uses + * symlinks for that shared fallback; packaged executables use ESM proxies so + * external plugins retain the installation's module instances. * @module @deepseek-ai/dsh-app-boot/profile */ import { createRequire } from 'node:module' import { - existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, rmSync, statSync, symlinkSync, unlinkSync, writeFileSync, + existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, realpathSync, rmSync, statSync, + symlinkSync, unlinkSync, writeFileSync, } from 'node:fs' import { basename, dirname, join, relative, resolve } from 'node:path' import { pathToFileURL } from 'node:url' @@ -41,6 +43,9 @@ export const PROFILES_DIR = 'profiles' /** The user patch layer inside a profile directory (hot-reloaded on long-lived surfaces). */ export const PROFILE_PATCH_FILENAME = 'cordis.patch.yml' +/** Profile-private package links projected into its pnpm-managed node_modules. */ +const PROFILE_MODULE_FALLBACK_DIR = '.dsh-module-fallback' + /** The bundle half of the `dsh` manifest section: what a bundle package exports. */ export interface DshBundleManifest { /** The patch layer this bundle exports, relative to its package root. */ @@ -240,7 +245,7 @@ function ensureSymlink(link: string, target: string): void { stat = undefined } if (stat !== undefined) { - if (readlinkSync(link) === target) return + if (symlinkPointsTo(link, target)) return // unlink deletes the reparse point itself on Windows too; rmSync treats a // junction as a directory and throws EISDIR unless recursive. unlinkSync(link) @@ -255,12 +260,75 @@ function ensureSymlink(link: string, target: string): void { // staged deterministically from the public API. /* v8 ignore next 4 */ if ((error as NodeJS.ErrnoException).code !== 'EEXIST' - || !lstatSync(link).isSymbolicLink() || readlinkSync(link) !== target) { + || !lstatSync(link).isSymbolicLink() || !symlinkPointsTo(link, target)) { throw error } } } +/** Resolve a link target without following the final path component. */ +function canonicalLinkPath(path: string): string | undefined { + try { + return join(realpathSync.native(dirname(path)), basename(path)) + } catch (error) { + // A missing parent means the candidate cannot identify an existing owned link. + /* v8 ignore next 2 -- a non-ENOENT realpath failure requires a host filesystem fault */ + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined + /* v8 ignore next -- see the host-filesystem exception above */ + throw error + } +} + +/** Return whether a symlink or junction points at the same path as `target`. */ +function symlinkPointsTo(link: string, target: string): boolean { + const actual = resolve(dirname(link), readlinkSync(link)) + const canonicalActual = canonicalLinkPath(actual) + const canonicalTarget = canonicalLinkPath(resolve(target)) + return canonicalActual !== undefined && canonicalActual === canonicalTarget +} + +/** Add one profile-owned fallback link without replacing a pnpm-managed entry. */ +function ensureProfileSymlink(link: string, target: string): void { + try { + lstatSync(link) + return + } catch (error) { + /* v8 ignore next -- a non-ENOENT lstat failure requires a host filesystem fault */ + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + } + ensureSymlink(link, target) +} + +/** Package names represented by owned symlinks below one fallback node_modules. */ +function ownedPackageNames(modulesDir: string): string[] { + return readdirSync(modulesDir, { withFileTypes: true }).flatMap((entry) => { + if (entry.name.startsWith('@') && entry.isDirectory()) { + return readdirSync(join(modulesDir, entry.name), { withFileTypes: true }) + .filter(child => child.isSymbolicLink()) + .map(child => `${entry.name}/${child.name}`) + } + return entry.isSymbolicLink() ? [entry.name] : [] + }) +} + +/** Remove an obsolete owned target and its profile projection when still connected. */ +function removeProfileSymlink(profileModulesDir: string, ownedModulesDir: string, packageName: string): void { + const ownedLink = join(ownedModulesDir, packageName) + const profileLink = join(profileModulesDir, packageName) + try { + if (lstatSync(profileLink).isSymbolicLink() && symlinkPointsTo(profileLink, ownedLink)) unlinkSync(profileLink) + } catch (error) { + /* v8 ignore next -- a non-ENOENT lstat failure requires a host filesystem fault */ + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + } + try { + unlinkSync(ownedLink) + } catch (error) { + /* v8 ignore next -- concurrent identical cleanup may remove the link first */ + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + } +} + interface ModuleProxyManifest { name: string version: string @@ -416,7 +484,9 @@ type ModuleFallbackEntry = | { kind: 'proxy'; packageName: string; version: string; targets: Record } /** Resolve the installation generation that every profile must find through the fallback directory. */ -function resolveModuleFallbackEntries(installAnchor: string): ModuleFallbackEntry[] { +function resolveModuleFallbackEntries( + installAnchor: string, +): { entries: ModuleFallbackEntry[]; packageNames: ReadonlySet } { const appManifest = JSON.parse(readFileSync(installAnchor, 'utf8')) as ProfileManifest const links = new Map() /* v8 ignore next -- a real app manifest always declares its name */ @@ -440,15 +510,15 @@ function resolveModuleFallbackEntries(installAnchor: string): ModuleFallbackEntr queue.push({ anchor: manifestPath, manifest: JSON.parse(readFileSync(manifestPath, 'utf8')) as ProfileManifest }) } } - if (!isPackagedExecutable()) { - return [...links].map(([packageName, packageDir]) => ({ kind: 'symlink', packageName, packageDir })) - } - return [...links].flatMap(([packageName, packageDir]) => { - const source = packageProxySource(packageName, packageDir) - return Object.keys(source.targets).length === 0 - ? [] - : [{ kind: 'proxy' as const, packageName, version: source.version, targets: source.targets }] - }) + const entries = !isPackagedExecutable() + ? [...links].map(([packageName, packageDir]) => ({ kind: 'symlink' as const, packageName, packageDir })) + : [...links].flatMap(([packageName, packageDir]) => { + const source = packageProxySource(packageName, packageDir) + return Object.keys(source.targets).length === 0 + ? [] + : [{ kind: 'proxy' as const, packageName, version: source.version, targets: source.targets }] + }) + return { entries, packageNames: new Set(links.keys()) } } /** Return whether one existing fallback entry already matches its resolved installation generation. */ @@ -474,43 +544,41 @@ function moduleFallbackCurrent(modulesDir: string, entries: readonly ModuleFallb return entries.every(entry => moduleFallbackEntryCurrent(modulesDir, entry)) } +/** Inputs for {@link healProfilesModuleFallback}. */ +export interface ProfileModuleFallbackOptions { + /** Absolute package.json path of the running dsh installation. */ + installAnchor: string + /** Loaded profile whose selected bundles may carry profile-local plugins. */ + profile?: Profile + /** Harness home; defaults to {@link resolveDshHome}. */ + home?: string +} + /** - * Maintain the flat module fallback `$DSH_HOME/profiles/node_modules`: one - * entry per package in the dsh app's resolvable dependency CLOSURE (BFS - * over `dependencies` from the app manifest), each resolved from its own - * installation location. Plain Node uses symlinks. A pkg executable resolves - * exports under ESM import conditions and writes small proxy packages because - * the host filesystem cannot follow a symlink into pkg's virtual `/snapshot` - * tree; the proxy re-exports the virtual URL, preserving the executable's - * single module instance. A complete matching generation returns without a - * writer lock; actual repairs acquire and recheck one cross-process lock so - * partial proxies and carrier transitions remain serialized. Node's - * parent-directory walk from any profile finds this - * directory after the profile's own `node_modules`, so every in-box plugin - * resolves without pnpm ever managing it — the exact "bundles come from the - * installation" contract. The closure (not just direct dependencies) is - * required for out-of-tree plugins: their peer dependencies name Service - * Definition packages (`dsh-compaction`, `dsh-invariants`, ...) that the app - * reaches only through its Service Provider packages. Both a symlink target - * and a proxy's virtual target resolve transitive imports from the original - * package directory, so each package needs one flat fallback entry. - * Idempotent: correct entries are kept and changed installation targets are - * rewritten; under plain Node, a stale dangling link stays until its name is - * reused because resolution cannot discover it. - * @param installAnchor - absolute path of the dsh app's package.json. - * @param home - the Harness home; defaults to {@link resolveDshHome}. - * @returns settlement after current-state validation or a locked repair. + * Maintain module fallbacks for one profile launch. The shared + * `$DSH_HOME/profiles/node_modules` mirrors the dsh installation dependency + * closure. Plain Node writes symlinks; a packaged executable writes ESM + * proxies under a cross-process lock because operating-system links cannot + * enter pkg's virtual filesystem. Missing packages carried only by selected + * bundles are linked through a profile-owned directory into that profile's + * `node_modules`; pnpm-managed entries remain authoritative, and another + * profile's links cannot change its resolution. + * @param options - installation anchor, optional loaded profile, and Harness home. + * @returns settlement after the shared fallback and profile-local links are current. */ -export async function healProfilesModuleFallback(installAnchor: string, home: string = resolveDshHome()): Promise { +export async function healProfilesModuleFallback(options: ProfileModuleFallbackOptions): Promise { + const { installAnchor, profile, home = resolveDshHome() } = options const profilesDir = join(home, PROFILES_DIR) const modulesDir = join(profilesDir, 'node_modules') mkdirSync(modulesDir, { recursive: true }) - const entries = resolveModuleFallbackEntries(installAnchor) - if (moduleFallbackCurrent(modulesDir, entries)) return - await withFileLock(modulesDir, () => { - if (!moduleFallbackCurrent(modulesDir, entries)) healProfilesModuleFallbackLocked(entries, modulesDir) - return Promise.resolve() - }) + const { entries, packageNames } = resolveModuleFallbackEntries(installAnchor) + if (!moduleFallbackCurrent(modulesDir, entries)) { + await withFileLock(modulesDir, () => { + if (!moduleFallbackCurrent(modulesDir, entries)) healProfilesModuleFallbackLocked(entries, modulesDir) + return Promise.resolve() + }) + } + if (profile !== undefined) healProfileModuleFallback(profile, packageNames) } /** Heal one module-fallback generation while the cross-process writer lock is held. */ @@ -526,6 +594,78 @@ function healProfilesModuleFallbackLocked(entries: readonly ModuleFallbackEntry[ } } +/** Collect the first resolvable package directory for each dependency name. */ +function dependencyClosure( + anchors: readonly string[], reserved: ReadonlySet, + exclude: (candidate: string, packageName: string) => boolean, +): Map { + const links = new Map() + const visited = new Set(reserved) + for (const anchor of anchors) { + const canonicalAnchor = realpathSync.native(anchor) + const manifest = JSON.parse(readFileSync(canonicalAnchor, 'utf8')) as ProfileManifest + /* v8 ignore next -- an installable package manifest always declares its name */ + if (manifest.name === undefined) continue + if (!visited.has(manifest.name)) { + visited.add(manifest.name) + links.set(manifest.name, dirname(canonicalAnchor)) + } + const queue: { anchor: string; manifest: ProfileManifest }[] = [{ anchor: canonicalAnchor, manifest }] + for (let next = queue.shift(); next !== undefined; next = queue.shift()) { + // Service Provider packages commonly expose Service Definitions as peers. + /* v8 ignore next -- an installable package manifest always declares dependencies or peers */ + for (const dep of [...Object.keys(next.manifest.dependencies ?? {}), ...Object.keys(next.manifest.peerDependencies ?? {})]) { + if (visited.has(dep)) continue + const dir = packageDirFromAnchor(next.anchor, dep, exclude) + // A declared-but-uninstalled dependency cannot be loader-visible. + if (dir === undefined) continue + visited.add(dep) + links.set(dep, dir) + const manifestPath = join(dir, 'package.json') + queue.push({ anchor: manifestPath, manifest: JSON.parse(readFileSync(manifestPath, 'utf8')) as ProfileManifest }) + } + } + } + return links +} + +/** Reconcile packages carried only by selected bundles into one profile. */ +function healProfileModuleFallback(profile: Profile, installationPackageNames: ReadonlySet): void { + const profileModulesDir = join(profile.dir, 'node_modules') + const ownedModulesDir = join(profile.dir, PROFILE_MODULE_FALLBACK_DIR, 'node_modules') + mkdirSync(profileModulesDir, { recursive: true }) + mkdirSync(ownedModulesDir, { recursive: true }) + const bundleAnchors = profile.layers + .filter(layer => !installationPackageNames.has(layer.packageName)) + .map(layer => join(layer.packageDir, 'package.json')) + const bundleLinks = dependencyClosure(bundleAnchors, installationPackageNames, (candidate, packageName) => { + const profileLink = join(profileModulesDir, packageName) + if (canonicalLinkPath(candidate) !== canonicalLinkPath(profileLink)) return false + try { + return lstatSync(profileLink).isSymbolicLink() + && symlinkPointsTo(profileLink, join(ownedModulesDir, packageName)) + } catch (error) { + // A concurrent cleanup may remove the projection after package discovery. + /* v8 ignore next 2 -- a non-ENOENT lstat failure requires a host filesystem fault */ + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return true + /* v8 ignore next -- see the host-filesystem exception above */ + throw error + } + }) + for (const layer of profile.layers) bundleLinks.delete(layer.packageName) + for (const packageName of ownedPackageNames(ownedModulesDir)) { + if (!bundleLinks.has(packageName)) removeProfileSymlink(profileModulesDir, ownedModulesDir, packageName) + } + for (const [packageName, target] of bundleLinks) { + const ownedLink = join(ownedModulesDir, packageName) + mkdirSync(dirname(ownedLink), { recursive: true }) + ensureSymlink(ownedLink, target) + const profileLink = join(profileModulesDir, packageName) + mkdirSync(dirname(profileLink), { recursive: true }) + ensureProfileSymlink(profileLink, ownedLink) + } +} + /** * Read a profile's manifest. * @param binName - the diagnostic prefix on the thrown error. @@ -600,12 +740,15 @@ function normalizeShippedProfile(name: string, dir: string, manifest: ProfileMan * matches what the Loader would import from the same anchor, and * `existsSync` follows the symlinks pnpm's isolated layout uses. */ -function packageDirFromAnchor(anchor: string, packageName: string): string | undefined { +function packageDirFromAnchor( + anchor: string, packageName: string, + exclude: (candidate: string, packageName: string) => boolean = () => false, +): string | undefined { // resolve.paths returns null only for builtins, which no bundle name is. /* v8 ignore next */ for (const searchPath of createRequire(anchor).resolve.paths(packageName) ?? []) { const candidate = join(searchPath, packageName) - if (existsSync(join(candidate, 'package.json'))) return candidate + if (existsSync(join(candidate, 'package.json')) && !exclude(candidate, packageName)) return candidate } return undefined } diff --git a/packages/boot/app-boot/tests/profile.spec.ts b/packages/boot/app-boot/tests/profile.spec.ts index 4e7a2c5ddf..0681157f62 100644 --- a/packages/boot/app-boot/tests/profile.spec.ts +++ b/packages/boot/app-boot/tests/profile.spec.ts @@ -4,7 +4,10 @@ * empty-root composition, and the installation module-fallback healing. */ -import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs' +import { + existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync, realpathSync, rmSync, symlinkSync, + unlinkSync, writeFileSync, +} from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { withFileLock } from '@deepseek-ai/dsh-atomic-write' @@ -20,12 +23,16 @@ import { resolveBundleDir, resolveProfileDir, writeProfileManifest, + type Profile, } from '../src/index.ts' const tmp = (): string => mkdtempSync(join(tmpdir(), 'dsh-profile-')) /** Stage a fake installed app: package.json with deps and a node_modules holding bundles. */ -function stageInstallation(bundles: Record }>): string { +function stageInstallation( + bundles: Record }>, + appName = 'dsh-app', +): string { const root = tmp() const appDir = join(root, 'app') mkdirSync(join(appDir, 'node_modules'), { recursive: true }) @@ -46,12 +53,32 @@ function stageInstallation(bundles: Record { it('joins the home and rejects traversal-shaped names', () => { const home = tmp() @@ -290,7 +317,7 @@ describe('healProfilesModuleFallback', () => { mkdirSync(join(modules, 'dep-of-a'), { recursive: true }) writeFileSync(join(modules, 'dep-of-a', 'package.json'), JSON.stringify({ name: 'dep-of-a', version: '0.0.0' })) const home = tmp() - await healProfilesModuleFallback(anchor, home) + await healProfilesModuleFallback({ installAnchor: anchor, home }) const fallback = join(home, 'profiles', 'node_modules') // App deps, the bundle's own deps, and the bundle itself are linked; the // plain library is linked as an app dep (harmless), the app itself too. @@ -298,7 +325,7 @@ describe('healProfilesModuleFallback', () => { expect(lstatSync(join(fallback, name)).isSymbolicLink(), name).toBe(true) } // Idempotent, and a moved target is re-pointed. - await healProfilesModuleFallback(anchor, home) + await healProfilesModuleFallback({ installAnchor: anchor, home }) const before = readlinkSync(join(fallback, 'dep-of-a')) expect(before).toContain('dep-of-a') }) @@ -311,17 +338,248 @@ describe('healProfilesModuleFallback', () => { mkdirSync(join(entry, '..'), { recursive: true }) if (kind === 'directory') mkdirSync(entry) else writeFileSync(entry, '') - await expect(healProfilesModuleFallback(anchor, home)).rejects.toThrow('is not a symlink') + await expect(healProfilesModuleFallback({ installAnchor: anchor, home })).rejects.toThrow('is not a symlink') } }) + it('keeps selected bundle closures profile-local without overriding installation packages', async () => { + const installationAnchor = stageInstallation({ shared: {} }) + const bundleA = stageInstallation({ shared: {}, '@scope/bundle-only': {} }, 'selected-bundle-a') + const bundleB = stageInstallation({ shared: {}, '@scope/bundle-only': {} }, 'selected-bundle-b') + const home = tmp() + const profileA = stageProfile(home, 'a', bundleA) + const profileB = stageProfile(home, 'b', bundleB) + await healProfilesModuleFallback({ installAnchor: installationAnchor, profile: profileA, home }) + await healProfilesModuleFallback({ installAnchor: installationAnchor, profile: profileA, home }) + await healProfilesModuleFallback({ installAnchor: installationAnchor, profile: profileB, home }) + const sharedFallback = join(home, 'profiles', 'node_modules') + const ownedA = join(profileA.dir, '.dsh-module-fallback', 'node_modules', '@scope', 'bundle-only') + const ownedB = join(profileB.dir, '.dsh-module-fallback', 'node_modules', '@scope', 'bundle-only') + + expect(realpathSync.native(readlinkSync(join(sharedFallback, 'shared')))) + .toBe(realpathSync.native(join(installationAnchor, '..', 'node_modules', 'shared'))) + expect(existsSync(join(sharedFallback, '@scope', 'bundle-only'))).toBe(false) + expect(existsSync(join(profileA.dir, 'node_modules', 'shared'))).toBe(false) + expect(existsSync(join(profileB.dir, 'node_modules', 'shared'))).toBe(false) + expect(readlinkSync(join(profileA.dir, 'node_modules', '@scope', 'bundle-only'))).toBe(ownedA) + expect(readlinkSync(ownedA)) + .toBe(realpathSync.native(join(bundleA, '..', 'node_modules', '@scope', 'bundle-only'))) + expect(readlinkSync(join(profileB.dir, 'node_modules', '@scope', 'bundle-only'))).toBe(ownedB) + expect(readlinkSync(ownedB)) + .toBe(realpathSync.native(join(bundleB, '..', 'node_modules', '@scope', 'bundle-only'))) + + await healProfilesModuleFallback({ + installAnchor: installationAnchor, + profile: { ...profileA, layers: [] }, + home, + }) + expect(existsSync(join(profileA.dir, 'node_modules', '@scope', 'bundle-only'))).toBe(false) + expect(existsSync(ownedA)).toBe(false) + expect(existsSync(join(profileB.dir, 'node_modules', '@scope', 'bundle-only'))).toBe(true) + }) + + it('combines packaged installation proxies with profile-local bundle links', async () => { + const installationAnchor = stageInstallation({ shared: {} }) + const bundleAnchor = stageInstallation({ shared: {}, 'bundle-only': {} }, 'selected-bundle') + const home = tmp() + const profile = stageProfile(home, 'packaged', bundleAnchor) + Object.defineProperty(process, 'pkg', { configurable: true, value: {} }) + try { + await healProfilesModuleFallback({ installAnchor: installationAnchor, profile, home }) + expect(lstatSync(join(home, 'profiles', 'node_modules', 'shared')).isDirectory()).toBe(true) + expect(lstatSync(join(profile.dir, 'node_modules', 'bundle-only')).isSymbolicLink()).toBe(true) + } finally { + delete (process as NodeJS.Process & { pkg?: unknown }).pkg + } + }) + + it('discovers dependencies beside a symlinked bundle real path', async () => { + const installationAnchor = stageInstallation({}) + const home = tmp() + const dir = resolveProfileDir('symlinked', home) + const profileModules = join(dir, 'node_modules') + const storeModules = join(tmp(), 'node_modules', '.pnpm', 'selected-bundle@0.0.0', 'node_modules') + const realBundle = join(storeModules, 'selected-bundle') + const realDependency = join(storeModules, 'bundle-only') + mkdirSync(realBundle, { recursive: true }) + mkdirSync(realDependency) + writeFileSync(join(realBundle, 'package.json'), JSON.stringify({ + name: 'selected-bundle', + dependencies: { 'bundle-only': '0.0.0' }, + })) + writeFileSync(join(realDependency, 'package.json'), JSON.stringify({ name: 'bundle-only' })) + mkdirSync(profileModules, { recursive: true }) + const bundleLink = join(profileModules, 'selected-bundle') + symlinkSync(realBundle, bundleLink, 'junction') + const profile: Profile = { + name: 'symlinked', + dir, + layers: [{ + packageName: 'selected-bundle', + packageDir: bundleLink, + patchPath: join(bundleLink, 'cordis.patch.yml'), + patches: [], + }], + patchPath: join(dir, PROFILE_PATCH_FILENAME), + patches: [], + patchReload: 'live', + } + + await healProfilesModuleFallback({ installAnchor: installationAnchor, profile, home }) + + expect(readlinkSync(join(dir, '.dsh-module-fallback', 'node_modules', 'bundle-only'))) + .toBe(realpathSync.native(realDependency)) + }) + + it('traverses every explicit bundle root even when a nested package has the same name', async () => { + const installationAnchor = stageInstallation({}) + const home = tmp() + const root = tmp() + const bundleA = join(root, 'bundle-a') + const nestedBundleB = join(bundleA, 'node_modules', 'bundle-b') + const nestedOnly = join(nestedBundleB, 'node_modules', 'nested-only') + const bundleB = join(root, 'bundle-b') + const explicitOnly = join(bundleB, 'node_modules', 'explicit-only') + for (const dir of [bundleA, nestedBundleB, nestedOnly, bundleB, explicitOnly]) mkdirSync(dir, { recursive: true }) + writeFileSync(join(bundleA, 'package.json'), JSON.stringify({ + name: 'bundle-a', + dependencies: { 'bundle-b': '0.0.0' }, + })) + writeFileSync(join(nestedBundleB, 'package.json'), JSON.stringify({ + name: 'bundle-b', + dependencies: { 'nested-only': '0.0.0' }, + })) + writeFileSync(join(nestedOnly, 'package.json'), JSON.stringify({ name: 'nested-only' })) + writeFileSync(join(bundleB, 'package.json'), JSON.stringify({ + name: 'bundle-b', + dependencies: { 'explicit-only': '0.0.0' }, + })) + writeFileSync(join(explicitOnly, 'package.json'), JSON.stringify({ name: 'explicit-only' })) + const dir = resolveProfileDir('explicit-roots', home) + const profile: Profile = { + name: 'explicit-roots', + dir, + layers: ([['bundle-a', bundleA], ['bundle-b', bundleB]] as const).map(([packageName, packageDir]) => ({ + packageName, + packageDir, + patchPath: join(packageDir, 'cordis.patch.yml'), + patches: [], + })), + patchPath: join(dir, PROFILE_PATCH_FILENAME), + patches: [], + patchReload: 'live', + } + + await healProfilesModuleFallback({ installAnchor: installationAnchor, profile, home }) + + const ownedModules = join(dir, '.dsh-module-fallback', 'node_modules') + expect(readlinkSync(join(ownedModules, 'nested-only'))).toBe(realpathSync.native(nestedOnly)) + expect(readlinkSync(join(ownedModules, 'explicit-only'))).toBe(realpathSync.native(explicitOnly)) + }) + + it('ignores owned projections while recomputing an ordered bundle closure', async () => { + const installationAnchor = stageInstallation({}) + const home = tmp() + const dir = resolveProfileDir('ordered', home) + const profileModules = join(dir, 'node_modules') + const bundleA = join(profileModules, 'bundle-a') + const bundleB = join(profileModules, 'bundle-b') + const nested = join(bundleB, 'node_modules', 'bundle-only') + mkdirSync(bundleA, { recursive: true }) + mkdirSync(nested, { recursive: true }) + writeFileSync(join(bundleA, 'package.json'), JSON.stringify({ + name: 'bundle-a', + peerDependencies: { 'bundle-only': '0.0.0' }, + })) + writeFileSync(join(bundleB, 'package.json'), JSON.stringify({ + name: 'bundle-b', + dependencies: { 'bundle-only': '0.0.0' }, + })) + writeFileSync(join(nested, 'package.json'), JSON.stringify({ name: 'bundle-only' })) + const profile: Profile = { + name: 'ordered', + dir, + layers: ([['bundle-a', bundleA], ['bundle-b', bundleB]] as const).map(([packageName, packageDir]) => ({ + packageDir, + packageName, + patchPath: join(packageDir, 'cordis.patch.yml'), + patches: [], + })), + patchPath: join(dir, PROFILE_PATCH_FILENAME), + patches: [], + patchReload: 'live', + } + + await healProfilesModuleFallback({ installAnchor: installationAnchor, profile, home }) + await healProfilesModuleFallback({ installAnchor: installationAnchor, profile, home }) + + const owned = join(dir, '.dsh-module-fallback', 'node_modules', 'bundle-only') + expect(readlinkSync(owned)).toBe(realpathSync.native(nested)) + expect(JSON.parse(readFileSync(join(profileModules, 'bundle-only', 'package.json'), 'utf8'))) + .toMatchObject({ name: 'bundle-only' }) + }) + + it('cleans owned projections without removing profile-managed entries', async () => { + const installationAnchor = stageInstallation({}) + const bundleAnchor = stageInstallation({ fallback: {}, 'managed-dir': {}, 'managed-link': {} }, 'selected-bundle') + const home = tmp() + const profile = stageProfile(home, 'managed', bundleAnchor) + await healProfilesModuleFallback({ installAnchor: installationAnchor, profile, home }) + const ownedModules = join(profile.dir, '.dsh-module-fallback', 'node_modules') + const profileModules = join(profile.dir, 'node_modules') + const foreignTarget = tmp() + unlinkSync(join(profileModules, 'managed-dir')) + mkdirSync(join(profileModules, 'managed-dir')) + unlinkSync(join(profileModules, 'managed-link')) + symlinkSync(foreignTarget, join(profileModules, 'managed-link'), 'junction') + mkdirSync(join(ownedModules, 'foreign-directory')) + mkdirSync(join(ownedModules, '@foreign', 'directory'), { recursive: true }) + + await healProfilesModuleFallback({ + installAnchor: installationAnchor, + profile: { ...profile, layers: [] }, + home, + }) + + expect(existsSync(join(profileModules, 'fallback'))).toBe(false) + expect(lstatSync(join(profileModules, 'managed-dir')).isDirectory()).toBe(true) + expect(readlinkSync(join(profileModules, 'managed-link'))).toBe(foreignTarget) + expect(existsSync(join(ownedModules, 'fallback'))).toBe(false) + expect(existsSync(join(ownedModules, 'managed-dir'))).toBe(false) + expect(existsSync(join(ownedModules, 'managed-link'))).toBe(false) + }) + + it('cleans owned projections whose junction target uses a canonical parent path', async () => { + const installationAnchor = stageInstallation({}) + const realHome = tmp() + const aliasRoot = tmp() + const home = join(aliasRoot, 'home') + symlinkSync(realHome, home, 'junction') + const bundleAnchor = stageInstallation({ fallback: {} }, 'selected-bundle') + const profile = stageProfile(home, 'canonical', bundleAnchor) + await healProfilesModuleFallback({ installAnchor: installationAnchor, profile, home }) + const profileLink = join(profile.dir, 'node_modules', 'fallback') + const ownedModules = join(profile.dir, '.dsh-module-fallback', 'node_modules') + unlinkSync(profileLink) + symlinkSync(join(realpathSync(ownedModules), 'fallback'), profileLink, 'junction') + + await healProfilesModuleFallback({ + installAnchor: installationAnchor, + profile: { ...profile, layers: [] }, + home, + }) + + expect(existsSync(profileLink)).toBe(false) + expect(existsSync(join(ownedModules, 'fallback'))).toBe(false) + }) + it('replaces a wrong symlink', async () => { const anchor = stageInstallation({}) const home = tmp() const fallback = join(home, 'profiles', 'node_modules') mkdirSync(fallback, { recursive: true }) symlinkSync(tmp(), join(fallback, 'dsh-app'), 'junction') - await healProfilesModuleFallback(anchor, home) + await healProfilesModuleFallback({ installAnchor: anchor, home }) expect(readlinkSync(join(fallback, 'dsh-app'))).toContain('app') }) @@ -329,11 +587,11 @@ describe('healProfilesModuleFallback', () => { const anchor = stageInstallation({ 'bundle-a': { patch: '[]\n' } }) const home = tmp() const fallback = join(home, 'profiles', 'node_modules') - await healProfilesModuleFallback(anchor, home) + await healProfilesModuleFallback({ installAnchor: anchor, home }) const appTarget = readlinkSync(join(fallback, 'dsh-app')) unlinkSync(join(fallback, 'bundle-a')) - await healProfilesModuleFallback(anchor, home) + await healProfilesModuleFallback({ installAnchor: anchor, home }) expect(readlinkSync(join(fallback, 'dsh-app'))).toBe(appTarget) expect(lstatSync(join(fallback, 'bundle-a')).isSymbolicLink()).toBe(true) @@ -343,8 +601,8 @@ describe('healProfilesModuleFallback', () => { const anchor = stageInstallation({}) const home = tmp() await Promise.all([ - healProfilesModuleFallback(anchor, home), - healProfilesModuleFallback(anchor, home), + healProfilesModuleFallback({ installAnchor: anchor, home }), + healProfilesModuleFallback({ installAnchor: anchor, home }), ]) const fallback = join(home, 'profiles', 'node_modules') expect(lstatSync(join(fallback, 'dsh-app')).isSymbolicLink()).toBe(true) @@ -354,7 +612,7 @@ describe('healProfilesModuleFallback', () => { const anchor = stageInstallation({}) const home = tmp() const modules = join(home, 'profiles', 'node_modules') - await healProfilesModuleFallback(anchor, home) + await healProfilesModuleFallback({ installAnchor: anchor, home }) let releaseLock: (() => void) | undefined let reportLock: (() => void) | undefined const lockHeld = new Promise((resolve) => { reportLock = resolve }) @@ -365,7 +623,7 @@ describe('healProfilesModuleFallback', () => { }) await lockHeld - const healer = healProfilesModuleFallback(anchor, home) + const healer = healProfilesModuleFallback({ installAnchor: anchor, home }) const outcome = await Promise.race([ healer.then(() => 'complete' as const), new Promise<'blocked'>(resolve => setTimeout(() => { resolve('blocked') }, 100)), @@ -390,7 +648,7 @@ describe('healProfilesModuleFallback', () => { }) await lockHeld - const healer = healProfilesModuleFallback(anchor, home) + const healer = healProfilesModuleFallback({ installAnchor: anchor, home }) await new Promise(resolve => setTimeout(resolve, 20)) expect(existsSync(join(modules, 'dsh-app'))).toBe(false) releaseLock?.() @@ -413,7 +671,7 @@ describe('healProfilesModuleFallback', () => { const home = tmp() Object.defineProperty(process, 'pkg', { configurable: true, value: {} }) try { - await healProfilesModuleFallback(anchor, home) + await healProfilesModuleFallback({ installAnchor: anchor, home }) const fallback = join(home, 'profiles', 'node_modules') const proxy = join(fallback, 'bundle-a') expect(lstatSync(proxy).isDirectory()).toBe(true) @@ -429,7 +687,7 @@ describe('healProfilesModuleFallback', () => { expect(proxyManifest.dsh.moduleFallback.targets['.']).toEqual(expect.stringContaining('/bundle-a/index.js')) await expect(import(join(proxy, 'entry-0.js'))).resolves.toMatchObject({ packageName: 'bundle-a' }) await expect(import(join(proxy, 'entry-1.js'))).resolves.toMatchObject({ feature: 'proxied' }) - await healProfilesModuleFallback(anchor, home) + await healProfilesModuleFallback({ installAnchor: anchor, home }) } finally { delete (process as NodeJS.Process & { pkg?: unknown }).pkg } @@ -455,7 +713,7 @@ describe('healProfilesModuleFallback', () => { const home = tmp() Object.defineProperty(process, 'pkg', { configurable: true, value: {} }) try { - await healProfilesModuleFallback(anchor, home) + await healProfilesModuleFallback({ installAnchor: anchor, home }) const fallback = join(home, 'profiles', 'node_modules') await expect(import(join(fallback, 'bundle-a', 'entry-0.js'))).resolves.toMatchObject({ packageName: 'bundle-a' }) await expect(import(join(fallback, 'nested-esm', 'entry-0.js'))).resolves.toMatchObject({ nested: 'proxied' }) @@ -481,7 +739,7 @@ describe('healProfilesModuleFallback', () => { Object.defineProperty(process, 'pkg', { configurable: true, value: {} }) try { const home = tmp() - await healProfilesModuleFallback(anchor, home) + await healProfilesModuleFallback({ installAnchor: anchor, home }) const proxy = join(home, 'profiles', 'node_modules', 'bundle-a') await expect(import(join(proxy, 'entry-1.js'))).resolves.toMatchObject({ mini: true }) await expect(import(join(proxy, 'entry-2.js'))).resolves.toMatchObject({ web: true }) @@ -508,7 +766,7 @@ describe('healProfilesModuleFallback', () => { Object.defineProperty(process, 'pkg', { configurable: true, value: {} }) try { const home = tmp() - await healProfilesModuleFallback(anchor, home) + await healProfilesModuleFallback({ installAnchor: anchor, home }) const proxyManifest = JSON.parse(readFileSync( join(home, 'profiles', 'node_modules', 'linked-esm', 'package.json'), 'utf8', @@ -528,7 +786,7 @@ describe('healProfilesModuleFallback', () => { Object.defineProperty(process, 'pkg', { configurable: true, value: {} }) try { const home = tmp() - await healProfilesModuleFallback(anchor, home) + await healProfilesModuleFallback({ installAnchor: anchor, home }) await expect(import(join(home, 'profiles', 'node_modules', 'bundle-a', 'entry-0.js'))) .resolves.toMatchObject({ packageName: 'bundle-a' }) } finally { @@ -545,7 +803,7 @@ describe('healProfilesModuleFallback', () => { Object.defineProperty(process, 'pkg', { configurable: true, value: {} }) try { const home = tmp() - await healProfilesModuleFallback(anchor, home) + await healProfilesModuleFallback({ installAnchor: anchor, home }) await expect(import(join(home, 'profiles', 'node_modules', 'bundle-a', 'entry-0.js'))) .resolves.toMatchObject({ packageName: 'bundle-a' }) } finally { @@ -565,7 +823,7 @@ describe('healProfilesModuleFallback', () => { Object.defineProperty(process, 'pkg', { configurable: true, value: {} }) try { const home = tmp() - await healProfilesModuleFallback(anchor, home) + await healProfilesModuleFallback({ installAnchor: anchor, home }) const fallback = join(home, 'profiles', 'node_modules') expect(existsSync(join(fallback, 'dsh-app'))).toBe(false) expect(existsSync(join(fallback, 'bundle-a', 'entry-0.js'))).toBe(true) @@ -584,7 +842,7 @@ describe('healProfilesModuleFallback', () => { rmSync(join(bundleDir, 'index.js')) Object.defineProperty(process, 'pkg', { configurable: true, value: {} }) try { - await expect(healProfilesModuleFallback(anchor, tmp())).rejects.toThrow('main entry is missing') + await expect(healProfilesModuleFallback({ installAnchor: anchor, home: tmp() })).rejects.toThrow('main entry is missing') } finally { delete (process as NodeJS.Process & { pkg?: unknown }).pkg } @@ -608,10 +866,10 @@ describe('healProfilesModuleFallback', () => { try { const home = tmp() if (mode === 'missing' || mode === 'directory' || mode === 'absent-map') { - await healProfilesModuleFallback(anchor, home) + await healProfilesModuleFallback({ installAnchor: anchor, home }) expect(existsSync(join(home, 'profiles', 'node_modules', 'bundle-a'))).toBe(false) } else { - await expect(healProfilesModuleFallback(anchor, home)).rejects.toThrow( + await expect(healProfilesModuleFallback({ installAnchor: anchor, home })).rejects.toThrow( mode === 'null' || mode === 'null-subpath' ? 'cannot resolve ESM export bundle-a' : 'resolves outside its package', @@ -631,7 +889,7 @@ describe('healProfilesModuleFallback', () => { writeFileSync(join(bundleDir, 'package.json'), JSON.stringify(manifest)) Object.defineProperty(process, 'pkg', { configurable: true, value: {} }) try { - await expect(healProfilesModuleFallback(anchor, tmp())).rejects.toThrow( + await expect(healProfilesModuleFallback({ installAnchor: anchor, home: tmp() })).rejects.toThrow( 'installed package bundle-a must declare a non-empty version', ) } finally { @@ -642,20 +900,20 @@ describe('healProfilesModuleFallback', () => { it('replaces plain-node links and stale managed proxies in packaged mode', async () => { const anchor = stageInstallation({ 'bundle-a': { patch: '[]\n' } }) const home = tmp() - await healProfilesModuleFallback(anchor, home) + await healProfilesModuleFallback({ installAnchor: anchor, home }) const proxy = join(home, 'profiles', 'node_modules', 'bundle-a') expect(lstatSync(proxy).isSymbolicLink()).toBe(true) Object.defineProperty(process, 'pkg', { configurable: true, value: {} }) try { - await healProfilesModuleFallback(anchor, home) + await healProfilesModuleFallback({ installAnchor: anchor, home }) expect(lstatSync(proxy).isDirectory()).toBe(true) const stale = JSON.parse(readFileSync(join(proxy, 'package.json'), 'utf8')) as { version: string } stale.version = 'stale' writeFileSync(join(proxy, 'package.json'), JSON.stringify(stale)) - await healProfilesModuleFallback(anchor, home) + await healProfilesModuleFallback({ installAnchor: anchor, home }) expect(JSON.parse(readFileSync(join(proxy, 'package.json'), 'utf8'))).toMatchObject({ version: '0.0.0', }) @@ -670,13 +928,13 @@ describe('healProfilesModuleFallback', () => { const fallback = join(home, 'profiles', 'node_modules', 'bundle-a') Object.defineProperty(process, 'pkg', { configurable: true, value: {} }) try { - await healProfilesModuleFallback(anchor, home) + await healProfilesModuleFallback({ installAnchor: anchor, home }) expect(lstatSync(fallback).isDirectory()).toBe(true) } finally { delete (process as NodeJS.Process & { pkg?: unknown }).pkg } - await healProfilesModuleFallback(anchor, home) + await healProfilesModuleFallback({ installAnchor: anchor, home }) expect(lstatSync(fallback).isSymbolicLink()).toBe(true) }) @@ -689,7 +947,7 @@ describe('healProfilesModuleFallback', () => { const proxy = join(home, 'profiles', 'node_modules', 'bundle-a') mkdirSync(proxy, { recursive: true }) writeFileSync(join(proxy, 'package.json'), metadata) - await expect(healProfilesModuleFallback(anchor, home)).rejects.toThrow( + await expect(healProfilesModuleFallback({ installAnchor: anchor, home })).rejects.toThrow( 'exists and is not a dsh-managed module proxy', ) } diff --git a/packages/experimental/README.i18n.yaml b/packages/experimental/README.i18n.yaml index 5e772e6a48..b191b04710 100644 --- a/packages/experimental/README.i18n.yaml +++ b/packages/experimental/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/experimental/README.md -README.md: 7ea859177ee9935e557ffcdca4bb40083afe32f1 -README.zh.md: 59acaded479fe858334d2eab3123f0e9541d0b6e +README.md: ad658da84303d93ddede7d383d7950d8bca3f38f +README.zh.md: a8b777b40e44c1d3b95c744a5c972a2dca7192fb diff --git a/packages/experimental/README.md b/packages/experimental/README.md index 7ea859177e..ad658da843 100644 --- a/packages/experimental/README.md +++ b/packages/experimental/README.md @@ -24,6 +24,7 @@ The experimental group contains prototype capabilities that are not part of any | Package | Role | ctx key | |---|---|---| +| [`agent-team-profile`](agent-team-profile/README.md) | Explicit source-checkout profile layer for Agent Teams | — | | [`agent-team`](agent-team/README.md) | Named teammates with durable messages and a shared task board | `ctx.agentTeams` | | [`tool-agent-team`](tool-agent-team/README.md) | Ten tools that let the model create, message, and coordinate teammates | registers scoped tools on `ctx.tools` | | [`webworker-packer`](webworker-packer/README.md) | Builds the gzip-compressed VFS image consumed by the browser worker preview | library and CLI — no ctx key | diff --git a/packages/experimental/README.zh.md b/packages/experimental/README.zh.md index 59acaded47..a8b777b40e 100644 --- a/packages/experimental/README.zh.md +++ b/packages/experimental/README.zh.md @@ -24,6 +24,7 @@ kind: "package-group" | 包 | 职责 | ctx 键 | |---|---|---| +| [`agent-team-profile`](agent-team-profile/README.zh.md) | Agent Teams 的显式源码 checkout profile 层 | — | | [`agent-team`](agent-team/README.zh.md) | 具名 teammate,成员之间持久消息与共享任务板 | `ctx.agentTeams` | | [`tool-agent-team`](tool-agent-team/README.zh.md) | 让模型创建、发消息与协调 teammate 的十个工具 | 按作用域注册工具到 `ctx.tools` | | [`webworker-packer`](webworker-packer/README.zh.md) | 构建浏览器 worker 预览所消费的 gzip 压缩 VFS 镜像 | 库与 CLI,不使用 ctx key | diff --git a/packages/experimental/agent-team-profile/README.i18n.yaml b/packages/experimental/agent-team-profile/README.i18n.yaml index 63e2e8f219..d051117151 100644 --- a/packages/experimental/agent-team-profile/README.i18n.yaml +++ b/packages/experimental/agent-team-profile/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/experimental/agent-team-profile/README.md -README.md: da96a8cf4608a1427e318202da1975d19a33f7cf -README.zh.md: c382105374faa9f599234efebf3697b3b77d27fe +README.md: dd53b3da34a9f06299621d5f09e3b456c6aae583 +README.zh.md: 2d10af9e682c8c32f29c47290b3696c4fa550e9f diff --git a/packages/experimental/agent-team-profile/README.md b/packages/experimental/agent-team-profile/README.md index da96a8cf46..dd53b3da34 100644 --- a/packages/experimental/agent-team-profile/README.md +++ b/packages/experimental/agent-team-profile/README.md @@ -1,8 +1,8 @@ -# @deepseek-ai/dsh-agent-team-profile +# @deepseek-ai/dsh-experimental-agent-team-profile English | [中文](README.zh.md) -Private profile bundle that enables [Agent Teams](../team/README.md) over `@deepseek-ai/dsh-base`. Its patch inserts the Team domain and scoped tools, disables the overlapping global continuable-child controls, and keeps the ordinary fresh and fork delegation tools as one-shot operations. Install this package explicitly into a source-checkout profile; it is excluded from official releases. +Private profile bundle that enables [Agent Teams](../agent-team/README.md) over `@deepseek-ai/dsh-base`. Its patch inserts the Team domain and scoped tools, disables the overlapping global continuable-child controls, and keeps the ordinary fresh and fork delegation tools as one-shot operations. Install this package explicitly into a source-checkout profile; it is excluded from official releases. ## Profile installation @@ -13,7 +13,7 @@ pnpm dsh plugin --profile headless add ./packages/experimental/agent-team-profil pnpm dsh --profile headless "Use Agent Teams to split this task between two teammates, wait, and summarize." ``` -The profile must already contain `@deepseek-ai/dsh-base`, whose Subagent services and provider rows this layer consumes. Removing the package with `dsh plugin --profile remove @deepseek-ai/dsh-agent-team-profile` removes the bundle from the profile's ordered layer list. +The profile must already contain `@deepseek-ai/dsh-base`, whose Subagent services and provider rows this layer consumes. Removing the package with `dsh plugin --profile remove @deepseek-ai/dsh-experimental-agent-team-profile` removes the bundle from the profile's ordered layer list. ## Model Experience @@ -21,7 +21,7 @@ The profile must already contain `@deepseek-ai/dsh-base`, whose Subagent service #### What the model sees -The Team policy and schemas belong to [`@deepseek-ai/dsh-tool-team`](../tool-team/README.md). This bundle changes composition only: Team-scoped `list_agents`, `send_message`, and `interrupt_agent` replace the disabled global continuable-child controls. `subagent` and `subagent_fork` remain available as one-shot delegation tools, whose children do not receive the continuable-child `report` tool. +The Team policy and schemas belong to [`@deepseek-ai/dsh-experimental-tool-agent-team`](../tool-agent-team/README.md). This bundle changes composition only: Team-scoped `list_agents`, `send_message`, and `interrupt_agent` replace the disabled global continuable-child controls. `subagent` and `subagent_fork` remain available as one-shot delegation tools, whose children do not receive the continuable-child `report` tool. #### Token effect diff --git a/packages/experimental/agent-team-profile/README.zh.md b/packages/experimental/agent-team-profile/README.zh.md index c382105374..2d10af9e68 100644 --- a/packages/experimental/agent-team-profile/README.zh.md +++ b/packages/experimental/agent-team-profile/README.zh.md @@ -1,8 +1,8 @@ -# @deepseek-ai/dsh-agent-team-profile +# @deepseek-ai/dsh-experimental-agent-team-profile [English](README.md) | 中文 -在 `@deepseek-ai/dsh-base` 之上启用 [Agent Teams](../team/README.md) 的私有 profile bundle。它的 patch 会插入 Team domain 与 scoped 工具、禁用名称重叠的全局 continuable-child control,并保留普通的一次性 fresh/fork delegation 工具。必须从源码 checkout 将本包显式安装到 profile;正式发布会排除本包。 +在 `@deepseek-ai/dsh-base` 之上启用 [Agent Teams](../agent-team/README.zh.md) 的私有 profile bundle。它的 patch 会插入 Team domain 与 scoped 工具、禁用名称重叠的全局 continuable-child control,并保留普通的一次性 fresh/fork delegation 工具。必须从源码 checkout 将本包显式安装到 profile;正式发布会排除本包。 ## Profile 安装 @@ -13,7 +13,7 @@ pnpm dsh plugin --profile headless add ./packages/experimental/agent-team-profil pnpm dsh --profile headless "Use Agent Teams to split this task between two teammates, wait, and summarize." ``` -profile 必须已经包含 `@deepseek-ai/dsh-base`,本层会使用其中的 Subagent service 与 provider 配置行。执行 `dsh plugin --profile remove @deepseek-ai/dsh-agent-team-profile` 移除本包时,bundle 也会从 profile 的有序层列表中移除。 +profile 必须已经包含 `@deepseek-ai/dsh-base`,本层会使用其中的 Subagent service 与 provider 配置行。执行 `dsh plugin --profile remove @deepseek-ai/dsh-experimental-agent-team-profile` 移除本包时,bundle 也会从 profile 的有序层列表中移除。 ## Model Experience @@ -21,7 +21,7 @@ profile 必须已经包含 `@deepseek-ai/dsh-base`,本层会使用其中的 Su #### 模型会看到什么 -Team 策略与 schema 由 [`@deepseek-ai/dsh-tool-team`](../tool-team/README.md) 所有。本 bundle 只改变 composition:Team-scoped `list_agents`、`send_message` 与 `interrupt_agent` 会替代已禁用的全局 continuable-child control。`subagent` 与 `subagent_fork` 仍作为一次性 delegation 工具可用,其子 agent 不会获得 continuable-child `report` 工具。 +Team 策略与 schema 由 [`@deepseek-ai/dsh-experimental-tool-agent-team`](../tool-agent-team/README.zh.md) 所有。本 bundle 只改变 composition:Team-scoped `list_agents`、`send_message` 与 `interrupt_agent` 会替代已禁用的全局 continuable-child control。`subagent` 与 `subagent_fork` 仍作为一次性 delegation 工具可用,其子 agent 不会获得 continuable-child `report` 工具。 #### Token 影响 diff --git a/packages/experimental/agent-team-profile/cordis.patch.yml b/packages/experimental/agent-team-profile/cordis.patch.yml index f6a9fc0ddc..2824ed135f 100644 --- a/packages/experimental/agent-team-profile/cordis.patch.yml +++ b/packages/experimental/agent-team-profile/cordis.patch.yml @@ -24,8 +24,8 @@ backgroundMode: one-shot - insert: - - id: team - name: '@deepseek-ai/dsh-team' + - id: agent-team + name: '@deepseek-ai/dsh-experimental-agent-team' config: maxMembers: 8 maxTasks: 256 @@ -33,8 +33,8 @@ maxMessageBytes: 65536 disposalTimeoutMs: 5000 - - id: tool-team - name: '@deepseek-ai/dsh-tool-team' + - id: tool-agent-team + name: '@deepseek-ai/dsh-experimental-tool-agent-team' config: freshProvider: spawn forkProvider: fork diff --git a/packages/experimental/agent-team-profile/package.json b/packages/experimental/agent-team-profile/package.json index 01d81baf87..d18f2ebebb 100644 --- a/packages/experimental/agent-team-profile/package.json +++ b/packages/experimental/agent-team-profile/package.json @@ -1,7 +1,7 @@ { - "name": "@deepseek-ai/dsh-agent-team-profile", + "name": "@deepseek-ai/dsh-experimental-agent-team-profile", "description": "Private profile bundle enabling Agent Teams over dsh-base", - "version": "0.1.0-rc.7", + "version": "0.1.1-rc.2", "private": true, "repository": { "type": "git", @@ -37,8 +37,8 @@ } }, "dependencies": { - "@deepseek-ai/dsh-team": "workspace:^", - "@deepseek-ai/dsh-tool-team": "workspace:^" + "@deepseek-ai/dsh-experimental-agent-team": "workspace:^", + "@deepseek-ai/dsh-experimental-tool-agent-team": "workspace:^" }, "peerDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", diff --git a/packages/experimental/agent-team-profile/src/index.ts b/packages/experimental/agent-team-profile/src/index.ts index 1fa74f0e55..405a889b5e 100644 --- a/packages/experimental/agent-team-profile/src/index.ts +++ b/packages/experimental/agent-team-profile/src/index.ts @@ -1,8 +1,8 @@ /** - * @deepseek-ai/dsh-agent-team-profile — private Agent Teams profile bundle. + * @deepseek-ai/dsh-experimental-agent-team-profile — private Agent Teams profile bundle. * The package's runtime content is its `dsh.bundle.patch` document; this * module exports no runtime API. - * @module @deepseek-ai/dsh-agent-team-profile + * @module @deepseek-ai/dsh-experimental-agent-team-profile */ export {} diff --git a/packages/experimental/agent-team-profile/src/invariant.ts b/packages/experimental/agent-team-profile/src/invariant.ts index 76eb8a608d..38db2de5ff 100644 --- a/packages/experimental/agent-team-profile/src/invariant.ts +++ b/packages/experimental/agent-team-profile/src/invariant.ts @@ -1,12 +1,12 @@ /** - * Package-owned invariant companion for `@deepseek-ai/dsh-agent-team-profile`. - * @module @deepseek-ai/dsh-agent-team-profile/invariant + * Package-owned invariant companion for `@deepseek-ai/dsh-experimental-agent-team-profile`. + * @module @deepseek-ai/dsh-experimental-agent-team-profile/invariant */ import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' -const PACKAGE_NAME = '@deepseek-ai/dsh-agent-team-profile' +const PACKAGE_NAME = '@deepseek-ai/dsh-experimental-agent-team-profile' /** Cordis companion plugin name. */ export const name = 'agent-team-profile-invariant' diff --git a/packages/experimental/agent-team-profile/tests/profile.spec.ts b/packages/experimental/agent-team-profile/tests/profile.spec.ts index 94f9b107b7..7d2d3d92f0 100644 --- a/packages/experimental/agent-team-profile/tests/profile.spec.ts +++ b/packages/experimental/agent-team-profile/tests/profile.spec.ts @@ -20,8 +20,8 @@ describe('Agent Teams profile bundle', () => { expect(manifest.publishConfig).toBeUndefined() expect(manifest.dsh?.bundle?.patch).toBe('./cordis.patch.yml') expect(manifest.dependencies).toMatchObject({ - '@deepseek-ai/dsh-team': 'workspace:^', - '@deepseek-ai/dsh-tool-team': 'workspace:^', + '@deepseek-ai/dsh-experimental-agent-team': 'workspace:^', + '@deepseek-ai/dsh-experimental-tool-agent-team': 'workspace:^', }) const parsed = yaml.load( @@ -41,12 +41,12 @@ describe('Agent Teams profile bundle', () => { expect(patches.find(patch => patch.id === 'tool-subagent')?.config).toMatchObject({ backgroundMode: 'one-shot' }) expect(patches.find(patch => patch.id === 'tool-subagent-fork')?.config).toMatchObject({ backgroundMode: 'one-shot' }) const inserted = patches.flatMap(patch => patch.insert ?? []) - expect(inserted.find(entry => entry.id === 'team')).toMatchObject({ - name: '@deepseek-ai/dsh-team', + expect(inserted.find(entry => entry.id === 'agent-team')).toMatchObject({ + name: '@deepseek-ai/dsh-experimental-agent-team', config: { maxMembers: 8 }, }) - expect(inserted.find(entry => entry.id === 'tool-team')).toMatchObject({ - name: '@deepseek-ai/dsh-tool-team', + expect(inserted.find(entry => entry.id === 'tool-agent-team')).toMatchObject({ + name: '@deepseek-ai/dsh-experimental-tool-agent-team', config: { freshProvider: 'spawn', forkProvider: 'fork' }, }) }) diff --git a/packages/experimental/agent-team-profile/tsconfig.json b/packages/experimental/agent-team-profile/tsconfig.json index 766072ba7e..9fd6ff90fc 100644 --- a/packages/experimental/agent-team-profile/tsconfig.json +++ b/packages/experimental/agent-team-profile/tsconfig.json @@ -15,10 +15,10 @@ "path": "../../runtime-diagnostics/invariants" }, { - "path": "../team" + "path": "../agent-team" }, { - "path": "../tool-team" + "path": "../tool-agent-team" } ] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2842d8ff5c..1333ccfb18 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -364,6 +364,9 @@ importers: '@deepseek-ai/dsh-experimental-agent-team': specifier: workspace:^ version: link:../../packages/experimental/agent-team + '@deepseek-ai/dsh-experimental-agent-team-profile': + specifier: workspace:^ + version: link:../../packages/experimental/agent-team-profile '@deepseek-ai/dsh-experimental-tool-agent-team': specifier: workspace:^ version: link:../../packages/experimental/tool-agent-team @@ -4589,6 +4592,28 @@ importers: specifier: workspace:^ version: link:../../subagent/subagent-spawn-in-process + packages/experimental/agent-team-profile: + dependencies: + '@deepseek-ai/dsh-experimental-agent-team': + specifier: workspace:^ + version: link:../agent-team + '@deepseek-ai/dsh-experimental-tool-agent-team': + specifier: workspace:^ + version: link:../tool-agent-team + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-include': + specifier: workspace:^ + version: link:../../../vendor/include + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + js-yaml: + specifier: ^4.2.0 + version: 4.3.1 + packages/experimental/tool-agent-team: dependencies: '@deepseek-ai/schemastery': diff --git a/tsconfig.host.json b/tsconfig.host.json index 9afc5d3fdd..ebed7a86d2 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -288,6 +288,7 @@ { "path": "./packages/jobs/jobs-local" }, { "path": "./packages/jobs/tool-jobs" }, { "path": "./packages/experimental/agent-team" }, + { "path": "./packages/experimental/agent-team-profile" }, { "path": "./packages/experimental/tool-agent-team" }, { "path": "./packages/workflow/workflow" }, { "path": "./packages/workflow/workflow-worker-thread" }, From 42378a987e3570c0196dcda5433de411b49dfe96 Mon Sep 17 00:00:00 2001 From: fengsy Date: Tue, 25 Aug 2026 13:33:29 +0800 Subject: [PATCH 6/8] fix(profile): deduplicate fallback manifest traversal --- packages/boot/app-boot/src/profile.ts | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/packages/boot/app-boot/src/profile.ts b/packages/boot/app-boot/src/profile.ts index 4b861ccc53..52e8bcfafc 100644 --- a/packages/boot/app-boot/src/profile.ts +++ b/packages/boot/app-boot/src/profile.ts @@ -483,11 +483,21 @@ type ModuleFallbackEntry = | { kind: 'symlink'; packageName: string; packageDir: string } | { kind: 'proxy'; packageName: string; version: string; targets: Record } +/** Read one package manifest used while traversing a module-fallback dependency graph. */ +function readModuleFallbackManifest(anchor: string): ProfileManifest { + return JSON.parse(readFileSync(anchor, 'utf8')) as ProfileManifest +} + +/** Return dependency names that may be imported by a loader-visible plugin. */ +function profileDependencyNames(manifest: ProfileManifest): string[] { + return [...Object.keys(manifest.dependencies ?? {}), ...Object.keys(manifest.peerDependencies ?? {})] +} + /** Resolve the installation generation that every profile must find through the fallback directory. */ function resolveModuleFallbackEntries( installAnchor: string, ): { entries: ModuleFallbackEntry[]; packageNames: ReadonlySet } { - const appManifest = JSON.parse(readFileSync(installAnchor, 'utf8')) as ProfileManifest + const appManifest = readModuleFallbackManifest(installAnchor) const links = new Map() /* v8 ignore next -- a real app manifest always declares its name */ if (appManifest.name !== undefined) links.set(appManifest.name, dirname(installAnchor)) @@ -499,7 +509,7 @@ function resolveModuleFallbackEntries( // dsh-compaction, ...) are peers of their implementations, never plain // dependencies, yet out-of-tree plugins import them directly. /* v8 ignore next -- a real app manifest always declares dependencies */ - for (const dep of [...Object.keys(next.manifest.dependencies ?? {}), ...Object.keys(next.manifest.peerDependencies ?? {})]) { + for (const dep of profileDependencyNames(next.manifest)) { if (links.has(dep)) continue const dir = packageDirFromAnchor(next.anchor, dep) // A declared-but-uninstalled dependency cannot be a loader-visible @@ -507,7 +517,7 @@ function resolveModuleFallbackEntries( if (dir === undefined) continue links.set(dep, dir) const manifestPath = join(dir, 'package.json') - queue.push({ anchor: manifestPath, manifest: JSON.parse(readFileSync(manifestPath, 'utf8')) as ProfileManifest }) + queue.push({ anchor: manifestPath, manifest: readModuleFallbackManifest(manifestPath) }) } } const entries = !isPackagedExecutable() @@ -603,7 +613,7 @@ function dependencyClosure( const visited = new Set(reserved) for (const anchor of anchors) { const canonicalAnchor = realpathSync.native(anchor) - const manifest = JSON.parse(readFileSync(canonicalAnchor, 'utf8')) as ProfileManifest + const manifest = readModuleFallbackManifest(canonicalAnchor) /* v8 ignore next -- an installable package manifest always declares its name */ if (manifest.name === undefined) continue if (!visited.has(manifest.name)) { @@ -614,7 +624,7 @@ function dependencyClosure( for (let next = queue.shift(); next !== undefined; next = queue.shift()) { // Service Provider packages commonly expose Service Definitions as peers. /* v8 ignore next -- an installable package manifest always declares dependencies or peers */ - for (const dep of [...Object.keys(next.manifest.dependencies ?? {}), ...Object.keys(next.manifest.peerDependencies ?? {})]) { + for (const dep of profileDependencyNames(next.manifest)) { if (visited.has(dep)) continue const dir = packageDirFromAnchor(next.anchor, dep, exclude) // A declared-but-uninstalled dependency cannot be loader-visible. @@ -622,7 +632,7 @@ function dependencyClosure( visited.add(dep) links.set(dep, dir) const manifestPath = join(dir, 'package.json') - queue.push({ anchor: manifestPath, manifest: JSON.parse(readFileSync(manifestPath, 'utf8')) as ProfileManifest }) + queue.push({ anchor: manifestPath, manifest: readModuleFallbackManifest(manifestPath) }) } } } From d97868b94333cb55fb88add556c82366d52ad839 Mon Sep 17 00:00:00 2001 From: fengsy Date: Tue, 25 Aug 2026 20:10:11 +0800 Subject: [PATCH 7/8] test(subagent-acp): exempt Windows-inaccessible branches --- packages/subagent/subagent-acp/src/run.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/subagent/subagent-acp/src/run.ts b/packages/subagent/subagent-acp/src/run.ts index a0adcb1f4a..f97d62a812 100644 --- a/packages/subagent/subagent-acp/src/run.ts +++ b/packages/subagent/subagent-acp/src/run.ts @@ -124,6 +124,7 @@ function failureDiagnostic(facts: AcpFailureFacts): string { if (facts.outcome?.exitCode !== null && facts.outcome?.exitCode !== undefined) { fields.push(`exit code: ${facts.outcome.exitCode}`) } + /* v8 ignore next -- Windows does not report POSIX child signals in SubprocessOutcome. */ if (facts.outcome?.signal !== null && facts.outcome?.signal !== undefined) { fields.push(`signal: ${facts.outcome.signal}`) } @@ -284,6 +285,7 @@ function startupFailure( return new AcpRunFailure({ stage: 'process', category: 'process-start' }, error) } return new AcpRunFailure( + /* v8 ignore next -- Windows anonymous pipes cannot expose a live-child protocol close during startup. */ outcome === undefined ? { stage, category: 'transport' } : { stage, category: 'process-exit', outcome }, @@ -378,6 +380,7 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe const timeout = AbortSignal.timeout(Math.ceil(spec.disposeGraceMs)) const bound = signal === undefined ? timeout : AbortSignal.any([signal, timeout]) const aborted = Promise.withResolvers() + /* v8 ignore next -- Windows cannot expose the live-child protocol close needed to await this abort. */ const onObservationAbort = (): void => { aborted.resolve(undefined) } bound.addEventListener('abort', onObservationAbort, { once: true }) /* v8 ignore next -- closes the event-loop race between listener registration and the preceding derived-signal check. */ @@ -562,6 +565,7 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe } catch (error: unknown) { if (!flags.cancelled) { const outcome = await observeProcessOutcome(request.signal) + /* v8 ignore next -- Windows anonymous pipes cannot expose a live-child prompt transport failure. */ const facts = outcome === undefined ? { stage: 'prompt', category: 'transport' } as const : { stage: 'process', category: 'process-exit', outcome } as const From a28f543ae4449e2a3389f1a505f2c58245c15078 Mon Sep 17 00:00:00 2001 From: fengsy Date: Wed, 26 Aug 2026 00:53:57 +0800 Subject: [PATCH 8/8] docs(agent-team-profile): adopt package README standard --- .../agent-team-profile/README.i18n.yaml | 4 +- .../experimental/agent-team-profile/README.md | 75 ++++++++++++++++- .../agent-team-profile/README.zh.md | 83 +++++++++++++++++-- 3 files changed, 150 insertions(+), 12 deletions(-) diff --git a/packages/experimental/agent-team-profile/README.i18n.yaml b/packages/experimental/agent-team-profile/README.i18n.yaml index d051117151..7fd6eae729 100644 --- a/packages/experimental/agent-team-profile/README.i18n.yaml +++ b/packages/experimental/agent-team-profile/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/experimental/agent-team-profile/README.md -README.md: dd53b3da34a9f06299621d5f09e3b456c6aae583 -README.zh.md: 2d10af9e682c8c32f29c47290b3696c4fa550e9f +README.md: 7b0e2172b88287998f346d32010eb73ef3619eae +README.zh.md: b7323ebed66fe8dfc9c47749223d252a76e13363 diff --git a/packages/experimental/agent-team-profile/README.md b/packages/experimental/agent-team-profile/README.md index dd53b3da34..7b0e2172b8 100644 --- a/packages/experimental/agent-team-profile/README.md +++ b/packages/experimental/agent-team-profile/README.md @@ -1,12 +1,33 @@ +--- +description: "Private Agent Teams profile layer over dsh-base, for source-checkout users who want Team-scoped coordination tools while retaining one-shot delegation." +kind: "package-bundle" +--- + # @deepseek-ai/dsh-experimental-agent-team-profile English | [中文](README.zh.md) -Private profile bundle that enables [Agent Teams](../agent-team/README.md) over `@deepseek-ai/dsh-base`. Its patch inserts the Team domain and scoped tools, disables the overlapping global continuable-child controls, and keeps the ordinary fresh and fork delegation tools as one-shot operations. Install this package explicitly into a source-checkout profile; it is excluded from official releases. +## Summary -## Profile installation +`dsh-experimental-agent-team-profile` is a private profile layer that enables [Agent Teams](../agent-team/README.md) over `@deepseek-ai/dsh-base`. Its patch inserts the Team domain and Team-scoped tools, disables the overlapping global continuable-child controls, and keeps the ordinary fresh and fork delegation tools as one-shot operations. Add it explicitly to an initialized source-checkout profile; official releases exclude this package. -From this repository checkout, add the package to an initialized profile: +## Table of Contents + +- [Use this package](#use-this-package) +- [Understand the implementation](#understand-the-implementation) +- [Further Exploration](#further-exploration) +- [Model Experience](#model-experience) +- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) +- [Dev Note](#dev-note) + +----- + + +## Use this package + +### Install into a profile + +From this repository checkout, add the package to an initialized profile, then run a task that asks the Lead to delegate work: ```sh pnpm dsh plugin --profile headless add ./packages/experimental/agent-team-profile @@ -15,6 +36,41 @@ pnpm dsh --profile headless "Use Agent Teams to split this task between two team The profile must already contain `@deepseek-ai/dsh-base`, whose Subagent services and provider rows this layer consumes. Removing the package with `dsh plugin --profile remove @deepseek-ai/dsh-experimental-agent-team-profile` removes the bundle from the profile's ordered layer list. +### What you get + +The layer adds the Agent Teams domain and its scoped creation, roster, messaging, interruption, waiting, and task-board tools. It disables the global continuable-child control rows whose tool names overlap with Team controls, while leaving `subagent` and `subagent_fork` available as one-shot delegation tools. + +----- + + +## Understand the implementation + +
+Implementation internals — click to expand + +The package's runtime content is [`cordis.patch.yml`](cordis.patch.yml). Applied after `dsh-base`, the patch disables `tool-subagent-control`, `tool-subagent-list-agents`, and `tool-subagent-report`; sets the fresh and fork Subagent rows to `one-shot`; and inserts the Team service and tool rows with explicit providers and limits. + +| File | Role | +|---|---| +| [`cordis.patch.yml`](cordis.patch.yml) | Ordered patch over `dsh-base` | +| [`src/index.ts`](src/index.ts) | Empty module entry; the patch is the runtime content | +| [`src/invariant.ts`](src/invariant.ts) | Empty invariant companion for the static bundle | + +
+ +----- + + +## Further Exploration + +- [Experimental packages](../README.md) — incubation status and release exclusion. +- [Agent Teams service](../agent-team/README.md) — durable roster, messaging, and task-board behavior. +- [Agent Teams tools](../tool-agent-team/README.md) — the Team-scoped model tool surface. +- [Base bundle](../../bundle/base/README.md) — the profile layer this patch extends. + +----- + + ## Model Experience ### Team policy and tools @@ -33,5 +89,18 @@ The bundle's composition is prefix-stable while its patch, Team identity, and co ## Known Limitations and Deferred Work + + - **Source-checkout only** — this private package is not present in official npm, CLI, Web, or Python release payloads. - **Shared checkout** — every teammate observes the same working directory; this bundle adds no worktree isolation or filesystem locking. +- **Base profile required** — the patch depends on row ids and Subagent providers supplied by `dsh-base`; it is not a standalone profile. + + +### Dev Note + +
+Working context for maintainers — click to expand + +None. + +
diff --git a/packages/experimental/agent-team-profile/README.zh.md b/packages/experimental/agent-team-profile/README.zh.md index 2d10af9e68..b7323ebed6 100644 --- a/packages/experimental/agent-team-profile/README.zh.md +++ b/packages/experimental/agent-team-profile/README.zh.md @@ -1,12 +1,33 @@ +--- +description: "叠加在 dsh-base 上的私有 Agent Teams profile 层,供源码 checkout 用户使用 Team-scoped 协作工具,同时保留一次性 delegation。" +kind: "package-bundle" +--- + # @deepseek-ai/dsh-experimental-agent-team-profile [English](README.md) | 中文 -在 `@deepseek-ai/dsh-base` 之上启用 [Agent Teams](../agent-team/README.zh.md) 的私有 profile bundle。它的 patch 会插入 Team domain 与 scoped 工具、禁用名称重叠的全局 continuable-child control,并保留普通的一次性 fresh/fork delegation 工具。必须从源码 checkout 将本包显式安装到 profile;正式发布会排除本包。 +## 概述 -## Profile 安装 +`dsh-experimental-agent-team-profile` 是在 `@deepseek-ai/dsh-base` 之上启用 [Agent Teams](../agent-team/README.zh.md) 的私有 profile 层。它的 patch 会插入 Team domain 与 Team-scoped 工具、禁用名称重叠的全局 continuable-child control,并保留普通的一次性 fresh 与 fork delegation 工具。必须将本包显式添加到已初始化的源码 checkout profile;正式发布会排除本包。 -在本仓库 checkout 中,将本包添加到已初始化的 profile: +## 目录 + +- [使用本包](#use-this-package) +- [理解实现](#understand-the-implementation) +- [进一步探索](#further-exploration) +- [模型体验](#model-experience) +- [已知限制与延期工作](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) + +----- + + +## 使用本包 + +### 安装到 profile + +在本仓库 checkout 中,将本包添加到已初始化的 profile,然后运行一个要求 Lead 委派工作的任务: ```sh pnpm dsh plugin --profile headless add ./packages/experimental/agent-team-profile @@ -15,7 +36,42 @@ pnpm dsh --profile headless "Use Agent Teams to split this task between two team profile 必须已经包含 `@deepseek-ai/dsh-base`,本层会使用其中的 Subagent service 与 provider 配置行。执行 `dsh plugin --profile remove @deepseek-ai/dsh-experimental-agent-team-profile` 移除本包时,bundle 也会从 profile 的有序层列表中移除。 -## Model Experience +### 获得的功能 + +本层会添加 Agent Teams domain,以及 Team-scoped 创建、roster、消息、interrupt、等待与任务板工具。它会禁用工具名与 Team control 重叠的全局 continuable-child control 行,同时保留 `subagent` 与 `subagent_fork` 作为一次性 delegation 工具。 + +----- + + +## 理解实现 + +
+实现细节——点击展开 + +本包的运行时内容是 [`cordis.patch.yml`](cordis.patch.yml)。在 `dsh-base` 之后应用时,patch 会禁用 `tool-subagent-control`、`tool-subagent-list-agents` 与 `tool-subagent-report`,把 fresh 与 fork Subagent 行设置为 `one-shot`,并以显式 provider 和限制插入 Team service 与工具行。 + +| 文件 | 职责 | +|---|---| +| [`cordis.patch.yml`](cordis.patch.yml) | 叠加在 `dsh-base` 之上的有序 patch | +| [`src/index.ts`](src/index.ts) | 空模块入口;patch 是运行时内容 | +| [`src/invariant.ts`](src/invariant.ts) | 静态 bundle 的空不变式伴生插件 | + +
+ +----- + + +## 进一步探索 + +- [实验性包](../README.zh.md)——孵化状态与发布排除规则。 +- [Agent Teams service](../agent-team/README.zh.md)——持久 roster、消息与任务板行为。 +- [Agent Teams 工具](../tool-agent-team/README.zh.md)——Team-scoped 模型工具表层。 +- [Base bundle](../../bundle/base/README.zh.md)——本 patch 扩展的 profile 层。 + +----- + + +## 模型体验 ### Team 策略与工具 @@ -31,7 +87,20 @@ Team 策略与 schema 由 [`@deepseek-ai/dsh-experimental-tool-agent-team`](../t 只要 bundle patch、Team identity 与配置的工具 schema 不变,本 bundle 的 composition 就保持前缀稳定。 -## Known Limitations and Deferred Work +## 已知限制与延期工作 -- **仅限源码 checkout**:正式 npm、CLI、Web 与 Python 发布产物都不包含这个私有包。 -- **共享 checkout**:所有 teammate 都观察同一个工作目录;本 bundle 不提供 worktree 隔离或文件系统锁。 + + +- **仅限源码 checkout**——正式 npm、CLI、Web 与 Python 发布产物都不包含这个私有包。 +- **共享 checkout**——所有 teammate 都观察同一个工作目录;本 bundle 不提供 worktree 隔离或文件系统锁。 +- **需要 base profile**——本 patch 依赖 `dsh-base` 提供的配置行 id 与 Subagent provider;它不是独立 profile。 + + +### 开发备注 + +
+维护者的工作上下文——点击展开 + +无。 + +