diff --git a/apps/cli/config/agent-presets/core-web/agent.cordis.yml b/apps/cli/config/agent-presets/core-web/agent.cordis.yml index 9a251461c5..e9003aa975 100644 --- a/apps/cli/config/agent-presets/core-web/agent.cordis.yml +++ b/apps/cli/config/agent-presets/core-web/agent.cordis.yml @@ -15,20 +15,12 @@ text: >- You are a coding agent powered by the {{model}} model. Your working directory is {{cwd}}. -# `tool-bash` provides the `bashEnv` service, so it needs a realm even alone. -- id: shell - name: cordis:group - group: true - isolate: - bashEnv: true - config: - # The registry and its consumer share the realm: a consumer left outside - # would resolve the host's `bashEnv`, which this plane no longer provides. - - id: bash-env - name: '@deepseek-ai/dsh-bash-env' - - - id: tool-bash - name: '@deepseek-ai/dsh-tool-bash' +# `bash-env` stays in the HOST composition: `apps/cli/src/web.ts` injects it to +# publish `DSH_WEB_URL`/`DSH_WEB_MODE`, and a host row that injects a service is +# the criterion for host-plane ownership. `tool-bash` consumes that host +# registry from here. +- id: tool-bash + name: '@deepseek-ai/dsh-tool-bash' - id: tool-str-replace-editor name: '@deepseek-ai/dsh-tool-str-replace-editor' diff --git a/apps/cli/config/agent-presets/standard/agent.cordis.yml b/apps/cli/config/agent-presets/standard/agent.cordis.yml index 407e4d7d27..ec4f71a849 100644 --- a/apps/cli/config/agent-presets/standard/agent.cordis.yml +++ b/apps/cli/config/agent-presets/standard/agent.cordis.yml @@ -31,22 +31,15 @@ # ── shell ─────────────────────────────────────────────────────────────────── -# `tool-bash` reads as a tool but provides the `bashEnv` service, so it needs a -# realm like any other provider. The executor behind it (`bash-sandbox`) stays -# in the host composition, where the sandbox policy owns it. -- id: shell - name: cordis:group - group: true - isolate: - bashEnv: true - config: - # The registry and its consumer share the realm: a consumer left outside - # would resolve the host's `bashEnv`, which this plane no longer provides. - - id: bash-env - name: '@deepseek-ai/dsh-bash-env' - - - id: tool-bash - name: '@deepseek-ai/dsh-tool-bash' +# `bash-env` stays in the HOST composition: `apps/cli/src/web.ts` injects it to +# publish `DSH_WEB_URL`/`DSH_WEB_MODE`, and a host row that injects a service is +# the criterion for host-plane ownership — injection resolves before any session +# exists, so there is no agent to key by. Behind a preset realm those variables +# never reached the model's shell at all. `tool-bash` consumes the host registry +# from here; the executor behind it (`bash-sandbox`) is host-plane too, where the +# sandbox policy owns it. +- id: tool-bash + name: '@deepseek-ai/dsh-tool-bash' # ── filesystem ────────────────────────────────────────────────────────────── @@ -172,29 +165,21 @@ # ── delegation and workflows ──────────────────────────────────────────────── -# Every backend and every tool that reaches `subagents` or `workflows` shares -# one realm: a consumer left outside it would resolve the host's registry -# instead, which this preset does not populate. +# The `subagents` registry and its spawn/fork backends live in the HOST +# composition: the registry is a process singleton whose cross-session queries +# the api-proxy serves to the browser, and a provider name may only be +# registered once. This preset contributes the delegation TOOLS, which resolve +# that host registry. +# +# `workflows` is different — nothing outside an agent reads it — so every row +# that reaches it shares one entry-local realm here, and a consumer left +# outside would resolve a host registry this preset does not populate. - id: delegation name: cordis:group group: true isolate: - subagents: true workflows: true config: - - id: subagent - name: '@deepseek-ai/dsh-subagent' - - - id: subagent-spawn - name: '@deepseek-ai/dsh-subagent-spawn' - config: - providerName: spawn - - - id: subagent-fork - name: '@deepseek-ai/dsh-subagent-fork' - config: - providerName: fork - - id: tool-subagent-control name: '@deepseek-ai/dsh-tool-subagent-control' @@ -215,8 +200,11 @@ toolName: subagent_fork backgroundMode: continuable - - id: tool-subagent-report - name: '@deepseek-ai/dsh-tool-subagent-report' + # `tool-subagent-report` is host-plane for the same reason as the registry, + # not because a preset may not want it: it registers a CONTINUABLE SETUP on + # that singleton rather than a tool this agent calls, and the setup list is + # not scope-aware — one copy per mounted preset means every child gets + # `report` registered once per live session, which throws on the second. - id: workflow-workerthread name: '@deepseek-ai/dsh-workflow-workerthread' @@ -234,6 +222,9 @@ # ── remaining model-facing rows ───────────────────────────────────────────── +- id: tool-ask-user + name: '@deepseek-ai/dsh-tool-ask-user' + - id: tool-todo name: '@deepseek-ai/dsh-tool-todo' diff --git a/apps/cli/package.json b/apps/cli/package.json index d651ba2537..976e542896 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -20,17 +20,44 @@ "@deepseek-ai/dsh-app-boot": "workspace:^", "@deepseek-ai/dsh-base": "workspace:^", "@deepseek-ai/dsh-client-ui-agent-preset": "workspace:^", + "@deepseek-ai/dsh-command-compact": "workspace:^", + "@deepseek-ai/dsh-command-goal": "workspace:^", + "@deepseek-ai/dsh-compact-basic": "workspace:^", + "@deepseek-ai/dsh-compact-tool-result-prune": "workspace:^", + "@deepseek-ai/dsh-goal": "workspace:^", + "@deepseek-ai/dsh-goal-session": "workspace:^", "@deepseek-ai/dsh-headless": "workspace:^", "@deepseek-ai/dsh-mcp-client": "workspace:^", "@deepseek-ai/dsh-paths": "workspace:^", + "@deepseek-ai/dsh-persona": "workspace:^", + "@deepseek-ai/dsh-plan-mode": "workspace:^", "@deepseek-ai/dsh-pty": "workspace:^", "@deepseek-ai/dsh-pty-local": "workspace:^", "@deepseek-ai/dsh-session-reference": "workspace:^", + "@deepseek-ai/dsh-skill": "workspace:^", + "@deepseek-ai/dsh-skill-local": "workspace:^", + "@deepseek-ai/dsh-tasks-local": "workspace:^", "@deepseek-ai/dsh-tmux-context": "workspace:^", + "@deepseek-ai/dsh-token-meter": "workspace:^", "@deepseek-ai/dsh-tool-ask-user": "workspace:^", + "@deepseek-ai/dsh-tool-bash": "workspace:^", "@deepseek-ai/dsh-tool-bash-persistent": "workspace:^", "@deepseek-ai/dsh-tool-cordis": "workspace:^", + "@deepseek-ai/dsh-tool-fs": "workspace:^", + "@deepseek-ai/dsh-tool-fs-search": "workspace:^", + "@deepseek-ai/dsh-tool-goal": "workspace:^", + "@deepseek-ai/dsh-tool-ralph": "workspace:^", + "@deepseek-ai/dsh-tool-skill": "workspace:^", + "@deepseek-ai/dsh-tool-str-replace-editor": "workspace:^", + "@deepseek-ai/dsh-tool-subagent": "workspace:^", + "@deepseek-ai/dsh-tool-subagent-control": "workspace:^", + "@deepseek-ai/dsh-tool-tasks": "workspace:^", + "@deepseek-ai/dsh-tool-todo": "workspace:^", + "@deepseek-ai/dsh-tool-web": "workspace:^", + "@deepseek-ai/dsh-tool-workflow": "workspace:^", "@deepseek-ai/dsh-web-app": "workspace:^", + "@deepseek-ai/dsh-workflow-workerthread": "workspace:^", + "@deepseek-ai/dsh-workspace-context": "workspace:^", "commander": "^15.0.0", "cordis": "^4.0.0-rc.7", "js-yaml": "^4.2.0", diff --git a/apps/cli/tests/web-agent-presets.spec.ts b/apps/cli/tests/web-agent-presets.spec.ts index 8ee2730dfc..54a4061dbe 100644 --- a/apps/cli/tests/web-agent-presets.spec.ts +++ b/apps/cli/tests/web-agent-presets.spec.ts @@ -66,11 +66,13 @@ beforeAll(async () => { }, 120_000) describe('the shipped Web composition', () => { - it('leaves only the host UI tool in the global layer', () => { - // `ask_user_question` is the host's own interaction surface, not an agent - // capability, so it stays global. Every other tool now belongs to a - // preset; a regression here means an agent-plane row came back to base. - expect(toolNames(ctx)).toEqual(['ask_user_question']) + it('leaves the global tool layer empty', () => { + // Every model-facing tool belongs to a preset, `ask_user_question` + // included: a tool in the global layer reaches EVERY agent regardless of + // which preset composed it, so a two-tool benchmark surface would really + // present three. A regression here means an agent-plane row came back to + // the host composition. + expect(toolNames(ctx)).toEqual([]) }) it('supplies both shipped presets, and only those, from the system root', async () => { @@ -110,7 +112,8 @@ describe('the shipped Web composition', () => { setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'core-web').then(() => undefined), }) try { - expect(toolNames(ctx, handle.agent)).toEqual(['ask_user_question', 'bash', 'str_replace_editor']) + // Exactly what the preset lists — nothing arrives from the host. + expect(toolNames(ctx, handle.agent)).toEqual(['bash', 'str_replace_editor']) } finally { await handle.dispose() } @@ -126,14 +129,14 @@ describe('the shipped Web composition', () => { setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'core-web').then(() => undefined), }) try { - expect(toolNames(ctx, minimal.agent)).toEqual(['ask_user_question', 'bash', 'str_replace_editor']) + expect(toolNames(ctx, minimal.agent)).toEqual(['bash', 'str_replace_editor']) expect(toolNames(ctx, full.agent).length).toBeGreaterThan(10) await minimal.dispose() // Tearing the minimal session down leaves the full one whole. expect(toolNames(ctx, full.agent).length).toBeGreaterThan(10) - expect(toolNames(ctx)).toEqual(['ask_user_question']) + expect(toolNames(ctx)).toEqual([]) } finally { await full.dispose() } diff --git a/apps/web/tests/scaffold-hermetic.e2e.ts b/apps/web/tests/scaffold-hermetic.e2e.ts index 6e14eebfa5..8b7c2ccea3 100644 --- a/apps/web/tests/scaffold-hermetic.e2e.ts +++ b/apps/web/tests/scaffold-hermetic.e2e.ts @@ -3,6 +3,8 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { expect, it } from 'vitest' import type {} from '@deepseek-ai/dsh-skill' +import { SessionId } from '@deepseek-ai/dsh-session' +import type {} from '@deepseek-ai/dsh-agent-presets' import { launchWebScaffold, type WebScaffold } from './scaffold.ts' async function writeSkill(root: string, name: string): Promise { @@ -37,10 +39,26 @@ it('isolates replay skill discovery from every ambient host root', async () => { let scaffold: WebScaffold | undefined try { scaffold = await launchWebScaffold() - const names = (await scaffold.ctx.skills.list({ cwd: scaffold.workspaceCwd })).map(skill => skill.name) - expect(names).not.toContain('ambient-dsh') - expect(names).not.toContain('ambient-agents') - expect(names).not.toContain('ambient-bundled') + const ctx = scaffold.ctx + // The skill registry belongs to one agent's preset, behind an `isolate` + // realm the host cannot resolve by name — so the roots under test are only + // reachable through a composed agent, which is also the only shape that + // ever asks. `serviceFor` is the same addressing the gateway's `skill.list` + // uses for a browser request about a session. + const handle = await ctx.agents.create({ + sessionId: SessionId('hermetic-skills'), + setup: agentCtx => ctx.agentPresets.mount(agentCtx).then(() => undefined), + }) + try { + const skills = ctx.agentPresets.serviceFor(handle.agent, 'skills') + if (skills === undefined) throw new Error('composed agent mounts no skill registry') + const names = (await skills.list({ cwd: scaffold.workspaceCwd })).map(skill => skill.name) + expect(names).not.toContain('ambient-dsh') + expect(names).not.toContain('ambient-agents') + expect(names).not.toContain('ambient-bundled') + } finally { + await handle.dispose() + } } finally { try { await scaffold?.close() diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index 8d8f866740..b1c6d66bf9 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -240,6 +240,31 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise [key, process.env[key]]), + ) + let skillRootEnvironmentRestored = false + const restoreSkillRootEnvironment = (): void => { + if (skillRootEnvironmentRestored) return + skillRootEnvironmentRestored = true + for (const [key, value] of Object.entries(originalSkillRootEnvironment)) { + if (value === undefined) Reflect.deleteProperty(process.env, key) + else process.env[key] = value + } + } + Object.assign(process.env, skillRootEnvironment) let persistenceRoot: string try { persistenceRoot = await mkdtemp(join(tmpdir(), 'dsh-web-e2e-sessions-')) @@ -415,6 +440,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise 0) { throw new AggregateError([error, ...cleanupFailures], 'web scaffold setup failed and cleanup was incomplete') } @@ -462,6 +488,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise 0) throw new AggregateError(failures, 'web scaffold teardown failed') }, diff --git a/apps/web/tests/seeded-history.e2e.ts b/apps/web/tests/seeded-history.e2e.ts index f9ef1055d1..c91f654440 100644 --- a/apps/web/tests/seeded-history.e2e.ts +++ b/apps/web/tests/seeded-history.e2e.ts @@ -18,6 +18,7 @@ import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import { createUserMessage } from '@deepseek-ai/dsh-llm' import type { ContentBlock, Message } from '@deepseek-ai/dsh-llm' import { deriveEventMessage, SessionId } from '@deepseek-ai/dsh-session' +import type {} from '@deepseek-ai/dsh-agent-presets' import type { SessionEvent } from '@deepseek-ai/dsh-session' import type { TokenMeterService } from '@deepseek-ai/dsh-token-meter' import { join } from 'node:path' @@ -170,10 +171,22 @@ describe('web e2e: seeded history renders through cold resume', () => { if (MODE !== 'record') { const raw = await readFile(SEED, 'utf8') expect(fixtureUserPrompts(raw), 'seed fixture must carry exactly the drive prompt').toEqual([PROMPT]) - const meter = scaffold.ctx.get('tokenMeter') - if (meter === undefined) throw new Error('seeded-history requires the composed token meter') - const realized = realizeSeedFixture(scaffold, raw, SEED_ID) - await seedSession(scaffold, withCompaction(realized, meter), SEED_ID) + // The meter belongs to an agent's preset, not to the process — token + // accounting is per session. It is used here as a pure pricing function + // over fixture content, so a throwaway composition is enough to reach one. + const priced = await scaffold.ctx.agents.create({ + sessionId: SessionId('seeded-history-pricing'), + setup: agentCtx => scaffold.ctx.agentPresets.mount(agentCtx).then(() => undefined), + }) + let realizedWithCompaction: string + try { + const meter = scaffold.ctx.agentPresets.serviceFor(priced.agent, 'tokenMeter') + if (meter === undefined) throw new Error('seeded-history requires the composed token meter') + realizedWithCompaction = withCompaction(realizeSeedFixture(scaffold, raw, SEED_ID), meter) + } finally { + await priced.dispose() + } + await seedSession(scaffold, realizedWithCompaction, SEED_ID) } browser = await chromium.launch() page = await newEnglishPage(browser) @@ -220,11 +233,17 @@ describe('web e2e: seeded history renders through cold resume', () => { const projections = body.result.value?.projections expect(projections).toBeDefined() expect(projections?.asOfSeq).toBeGreaterThanOrEqual(0) - // The seed carries a session/title event: the title unit must serve it. + // The seed carries a session/title event: the title unit is host-plane, so + // it folds the detached log and serves the value with nothing composed. expect(typeof projections?.values.title).toBe('string') - // tool-todo is composed but the seed has no todo/write: whole-value null, - // key PRESENT (absence would mean the unit never registered). - expect(projections?.values).toHaveProperty('todos', null) + // `todos` is NOT here, and that is the contract rather than a gap. Its unit + // is registered by `tool-todo` inside an agent's preset, so a detached + // session yields it from exactly one place: a durable checkpoint written + // while the session was live. This seed was written straight to persistence + // and never ran, so it recorded none — and the answer no longer depends on + // whether some UNRELATED session happens to be composed right now, which is + // the whole reason the checkpoint row carries its own view. + expect(projections?.values).not.toHaveProperty('todos') }) it.skipIf(MODE === 'record')('lists the seeded session cold and renders its history from the log', async () => { diff --git a/apps/web/tests/shipped-composition.e2e.ts b/apps/web/tests/shipped-composition.e2e.ts index 782279f074..a0aaf3429e 100644 --- a/apps/web/tests/shipped-composition.e2e.ts +++ b/apps/web/tests/shipped-composition.e2e.ts @@ -11,6 +11,8 @@ import type {} from '@deepseek-ai/dsh-tools' import type {} from '@deepseek-ai/dsh-sandbox-policy' import type {} from '@deepseek-ai/dsh-user-approval' import type {} from '@deepseek-ai/dsh-permission' +import { SessionId } from '@deepseek-ai/dsh-session' +import type {} from '@deepseek-ai/dsh-agent-presets' import { launchWebScaffold, type WebScaffold } from './scaffold.ts' /** @@ -63,11 +65,26 @@ afterEach(async () => { it('assembles the shipped Web catalog with the confined access default', async () => { scaffold = await launchWebScaffold() - const names = scaffold.ctx.tools.schemas().map(schema => schema.name).sort() - expect(names.filter(name => !RIPGREP_TOOLS.includes(name))).toEqual(EXPECTED_TOOLS) - // The packaged ripgrep binary ships with the dependency, so the pair is a - // fixed roster member on every host. - expect(names.filter(name => RIPGREP_TOOLS.includes(name))).toEqual(RIPGREP_TOOLS) + const ctx = scaffold.ctx + // The catalog belongs to an AGENT, not to the process: every model-facing row + // now lives in a preset mounted under one session's scope, so the global + // layer holds nothing and a caller must name the agent to see anything. This + // composes from the deployment default — what a session that names no preset + // gets — which is the shape this test has always been about. + expect(ctx.tools.schemas().map(schema => schema.name)).toEqual([]) + const handle = await ctx.agents.create({ + sessionId: SessionId('shipped-composition'), + setup: agentCtx => ctx.agentPresets.mount(agentCtx).then(() => undefined), + }) + try { + const names = ctx.tools.schemas(handle.agent).map(schema => schema.name).sort() + expect(names.filter(name => !RIPGREP_TOOLS.includes(name))).toEqual(EXPECTED_TOOLS) + // The packaged ripgrep binary ships with the dependency, so the pair is a + // fixed roster member on every host. + expect(names.filter(name => RIPGREP_TOOLS.includes(name))).toEqual(RIPGREP_TOOLS) + } finally { + await handle.dispose() + } // `workspace-write` is not "the workspace and nothing else": the shared roots // helper always admits the temp directories too. Pinning it against an // explicit mode keeps the claim independent of this surface's default, and diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 9ed40b681d..9bd98540a9 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2530,7 +2530,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-client-ui-models` ([`packages/client/ui-models/src/index.ts`](../packages/client/ui-models/src/index.ts)) - `@deepseek-ai/dsh-client-ui-permission` ([`packages/client/ui-permission/src/index.ts`](../packages/client/ui-permission/src/index.ts)) - `@deepseek-ai/dsh-client-ui-plan` ([`packages/client/ui-plan/src/index.ts`](../packages/client/ui-plan/src/index.ts)) -- `@deepseek-ai/dsh-client-ui-question` — requires `tools` · `userInteraction` ([`packages/client/ui-question/src/index.ts`](../packages/client/ui-question/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-question` ([`packages/client/ui-question/src/index.ts`](../packages/client/ui-question/src/index.ts)) - `@deepseek-ai/dsh-client-ui-settings` ([`packages/client/ui-settings/src/index.ts`](../packages/client/ui-settings/src/index.ts)) - `@deepseek-ai/dsh-client-ui-settings-general` ([`packages/client/ui-settings-general/src/index.ts`](../packages/client/ui-settings-general/src/index.ts)) - `@deepseek-ai/dsh-client-ui-sidebar` ([`packages/client/ui-sidebar/src/index.ts`](../packages/client/ui-sidebar/src/index.ts)) diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index 328427d3f1..7fbfeaa662 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -209,8 +209,11 @@ # absent from a surface overlay would silently reappear the day someone reorders # the composition. -- id: bash-env - disabled: true +# `bash-env` STAYS in the host plane: `apps/cli/src/web.ts` injects it to +# publish `DSH_WEB_URL`/`DSH_WEB_MODE`, and a host row that injects a service is +# the criterion for host-plane ownership — injection resolves before any session +# exists, so there is no agent to key by. Behind a preset realm those variables +# would never reach the model's shell at all. - id: tool-bash disabled: true @@ -266,14 +269,12 @@ - id: tool-result-prune disabled: true -- id: subagent - disabled: true - -- id: subagent-spawn - disabled: true - -- id: subagent-fork - disabled: true +# The subagent registry and its backends STAY in the host plane. `subagents` is +# a process singleton with a cross-session query surface (`listChildren`, +# `followup`) that the host api-proxy serves to the browser, and a provider +# registers under a globally unique name, so a per-session copy would both +# starve that host row and collide on the second session. What a preset +# chooses is which delegation TOOLS its agent sees, below. - id: tool-subagent-control disabled: true @@ -287,8 +288,11 @@ - id: tool-subagent-fork disabled: true -- id: tool-subagent-report - disabled: true +# `tool-subagent-report` is host-plane for the same reason as the registry, not +# because a preset may not want it: it registers a CONTINUABLE SETUP on that +# singleton rather than a tool this agent calls, and the setup list is not +# scope-aware — one copy per mounted preset means every child gets `report` +# registered once per live session, which throws on the second. - id: workflow-workerthread disabled: true diff --git a/packages/client/ui-question/README.i18n.yaml b/packages/client/ui-question/README.i18n.yaml index bca51d908b..a00cd9bb55 100644 --- a/packages/client/ui-question/README.i18n.yaml +++ b/packages/client/ui-question/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/client/ui-question/README.md -README.md: 72d94396771eec0a90b96008b1fd5e4a736a398c -README.zh.md: 6344327d268f1d0c2ec0aaaf29657ea040e51691 +README.md: d31ceb62c46cb7a720b52d9e2a6c92e98d1c7e42 +README.zh.md: 9f9ad01c3f1f661f60fe11ec072f487cb18c170a diff --git a/packages/client/ui-question/README.md b/packages/client/ui-question/README.md index 72d9439677..d31ceb62c4 100644 --- a/packages/client/ui-question/README.md +++ b/packages/client/ui-question/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Web `ask_user_question` feature plugin. Its host half mounts `dsh-tool-ask-user` only when the Web feature is selected; its browser half registers the `question` entry in the conversation-owned `conversation.composer` keyed slot. +Web question feature plugin: its browser half registers the `question` entry in the conversation-owned `conversation.composer` keyed slot. Its host half is empty on purpose — mounting `dsh-tool-ask-user` there put the tool in the registry's GLOBAL layer, which merges into every agent regardless of the preset that composed it, so a two-tool benchmark preset really presented three. Rendering a question is a host UI capability; having the tool is an agent capability, so the `tool-ask-user` row belongs to the presets that want it (and to the TUI composition, which has no presets). The component renders one question at a time with progress navigation, single- and multi-select choices, recommendation badges derived from label suffixes, and custom answers. A multi-select draft keeps its selected labels while the user opens or edits the custom answer, so its submitted item may carry both `selected` and `custom`; a single-select custom answer remains exclusive. Question detail reuses the assistant-output `MarkdownText` primitive, including its GFM rendering and untrusted-content policy. The capped card keeps its title, navigation, and submission actions fixed while long detail and choices share an internal scroll region. Single-select choices advance immediately, and Enter submits once every question is answered or skipped; Enter during IME composition confirms the input candidate without advancing. It submits one structured answer batch for the whole request: “Skip this question” retains other drafts and emits the existing blank `{ selected: [] }` shape for that item, while close rejects the whole wait as `ASK_CANCELLED`. diff --git a/packages/client/ui-question/README.zh.md b/packages/client/ui-question/README.zh.md index 6344327d26..9f9ad01c3f 100644 --- a/packages/client/ui-question/README.zh.md +++ b/packages/client/ui-question/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -Web `ask_user_question` 功能插件。只有选择 Web 功能时,其主机侧才会挂载 `dsh-tool-ask-user`;浏览器侧会把 `question` 配置项注册到会话拥有的 `conversation.composer` 键控 slot 中。 +Web 提问功能插件:其浏览器侧把 `question` 配置项注册到会话拥有的 `conversation.composer` 键控 slot 中。其主机侧刻意为空——在那里挂载 `dsh-tool-ask-user` 会把工具放进注册表的**全局层**,而全局层会并入每一个 agent,无论它由哪个 preset 组装,于是一个"两工具"的 benchmark preset 实际会呈现三个。渲染提问是宿主的 UI 能力,拥有该工具则是 agent 的能力,因此 `tool-ask-user` 行属于需要它的各个 preset(以及没有 preset 的 TUI 组装)。 组件每次渲染一个问题,提供进度导航、单选和多选选项、由标签后缀派生的推荐徽标,以及自定义答案。用户打开或编辑自定义答案时,多选题草稿会保留已选中的标签,因此提交项可以同时携带 `selected` 与 `custom`;单选题的自定义答案仍保持互斥。问题详情复用助手输出的 `MarkdownText` 原语,包括其 GFM 渲染与不受信任内容策略。限高卡片保持标题、导航与提交动作固定,超长的详情与选项共享内部滚动区。单选选项会立即前进;所有问题均已回答或跳过后,Enter 会提交;IME 输入法组合期间按 Enter 只会确认输入候选,不会前进。组件为整个请求提交一批结构化答案:「跳过此问题」会保留其他草稿,并为该项发出既有的空 `{ selected: [] }` 形状;关闭则以 `ASK_CANCELLED` 拒绝整个等待。 diff --git a/packages/client/ui-question/package.json b/packages/client/ui-question/package.json index 7416596284..7e129c9731 100644 --- a/packages/client/ui-question/package.json +++ b/packages/client/ui-question/package.json @@ -40,7 +40,6 @@ "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", - "@deepseek-ai/dsh-tool-ask-user": "workspace:^", "clsx": "^2.0.0", "react": "^18.2.0" }, diff --git a/packages/client/ui-question/src/index.ts b/packages/client/ui-question/src/index.ts index 901e832c14..4ceb9e0bd1 100644 --- a/packages/client/ui-question/src/index.ts +++ b/packages/client/ui-question/src/index.ts @@ -1,17 +1,14 @@ /** - * Web question plugin, node half: enabling this UI feature also exposes the - * model-facing ask_user_question tool on the host composition. + * Web question plugin, node half. + * + * Deliberately empty. Mounting `ask_user_question` here put it in the tools + * registry's GLOBAL layer, so every agent saw it no matter which preset + * composed it — a two-tool benchmark preset actually presented three, and a + * locally authored `bash-only` preset presented two. Rendering a question is + * a host UI capability; having the tool is an agent capability, and only a + * preset decides that. The `tool-ask-user` row belongs in the presets that + * want it (and in the TUI composition, which has no presets). */ -import type { Context } from 'cordis' -import * as toolAskUser from '@deepseek-ai/dsh-tool-ask-user' -/** Host services required by the model-facing tool. */ -export const inject = ['tools', 'userInteraction'] - -/** - * Mount ask_user_question for hosts that selected the Web question plugin. - * @param ctx - Host plugin context carrying tools and userInteraction. - */ -export function apply(ctx: Context): void { - toolAskUser.apply(ctx) -} +/** Host plugin body — the model-facing tool is composed per preset, not here. */ +export function apply(): void {} diff --git a/packages/client/ui-question/tests/node-plugin.spec.ts b/packages/client/ui-question/tests/node-plugin.spec.ts index 9bc34e9599..4602ef0bed 100644 --- a/packages/client/ui-question/tests/node-plugin.spec.ts +++ b/packages/client/ui-question/tests/node-plugin.spec.ts @@ -3,7 +3,7 @@ import { afterEach, describe, expect, it } from 'vitest' import ToolRegistry from '@deepseek-ai/dsh-tools' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import UserInteractionService from '@deepseek-ai/dsh-user-interaction' -import { apply, inject } from '../src/index.ts' +import { apply } from '../src/index.ts' let ctx: Context | undefined @@ -13,16 +13,19 @@ afterEach(async () => { }) describe('ui-question node plugin', () => { - it('exposes ask_user_question only for the selected Web feature lifecycle', async () => { + it('mounts no model-facing tool', async () => { ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(UserInteractionService) - const feature = ctx.plugin({ inject: [...inject], apply }) - await feature.await() - expect(ctx.tools.get('ask_user_question')).toBeDefined() - await feature.dispose() + await ctx.plugin({ apply }).await() + + // Selecting the Web question FEATURE must not hand every agent the tool. + // `ctx.tools.register` on an unscoped host context files into the global + // layer, which merges into every agent's view regardless of the preset + // that composed it — so a two-tool benchmark preset would really present + // three. The `tool-ask-user` row belongs to the presets that want it. expect(ctx.tools.get('ask_user_question')).toBeUndefined() }) }) diff --git a/packages/client/ui-question/tsconfig.json b/packages/client/ui-question/tsconfig.json index 6b5b0acc3a..72d4e96b0f 100644 --- a/packages/client/ui-question/tsconfig.json +++ b/packages/client/ui-question/tsconfig.json @@ -29,9 +29,7 @@ { "path": "../ui-slots" }, - { - "path": "../../ui/tool-ask-user" - }, + { "path": "../../support/invariants" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e3efceac7a..dd3e62e75c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -149,6 +149,24 @@ importers: '@deepseek-ai/dsh-client-ui-agent-preset': specifier: workspace:^ version: link:../../packages/client/ui-agent-preset + '@deepseek-ai/dsh-command-compact': + specifier: workspace:^ + version: link:../../packages/compact/command-compact + '@deepseek-ai/dsh-command-goal': + specifier: workspace:^ + version: link:../../packages/goal/command-goal + '@deepseek-ai/dsh-compact-basic': + specifier: workspace:^ + version: link:../../packages/compact/compact-basic + '@deepseek-ai/dsh-compact-tool-result-prune': + specifier: workspace:^ + version: link:../../packages/compact/compact-tool-result-prune + '@deepseek-ai/dsh-goal': + specifier: workspace:^ + version: link:../../packages/goal/goal + '@deepseek-ai/dsh-goal-session': + specifier: workspace:^ + version: link:../../packages/goal/goal-session '@deepseek-ai/dsh-headless': specifier: workspace:^ version: link:../../packages/bundle/headless @@ -158,6 +176,12 @@ importers: '@deepseek-ai/dsh-paths': specifier: workspace:^ version: link:../../packages/util/paths + '@deepseek-ai/dsh-persona': + specifier: workspace:^ + version: link:../../packages/preset/persona + '@deepseek-ai/dsh-plan-mode': + specifier: workspace:^ + version: link:../../packages/plan/plan-mode '@deepseek-ai/dsh-pty': specifier: workspace:^ version: link:../../packages/pty/pty @@ -167,21 +191,78 @@ importers: '@deepseek-ai/dsh-session-reference': specifier: workspace:^ version: link:../../packages/context/session-reference + '@deepseek-ai/dsh-skill': + specifier: workspace:^ + version: link:../../packages/skill/skill + '@deepseek-ai/dsh-skill-local': + specifier: workspace:^ + version: link:../../packages/skill/skill-local + '@deepseek-ai/dsh-tasks-local': + specifier: workspace:^ + version: link:../../packages/tasks/tasks-local '@deepseek-ai/dsh-tmux-context': specifier: workspace:^ version: link:../../packages/context/tmux-context + '@deepseek-ai/dsh-token-meter': + specifier: workspace:^ + version: link:../../packages/llm/token-meter '@deepseek-ai/dsh-tool-ask-user': specifier: workspace:^ version: link:../../packages/ui/tool-ask-user + '@deepseek-ai/dsh-tool-bash': + specifier: workspace:^ + version: link:../../packages/bash/tool-bash '@deepseek-ai/dsh-tool-bash-persistent': specifier: workspace:^ version: link:../../packages/pty/tool-bash-persistent '@deepseek-ai/dsh-tool-cordis': specifier: workspace:^ version: link:../../packages/cordis/tool-cordis + '@deepseek-ai/dsh-tool-fs': + specifier: workspace:^ + version: link:../../packages/fs/tool-fs + '@deepseek-ai/dsh-tool-fs-search': + specifier: workspace:^ + version: link:../../packages/fs/tool-fs-search + '@deepseek-ai/dsh-tool-goal': + specifier: workspace:^ + version: link:../../packages/goal/tool-goal + '@deepseek-ai/dsh-tool-ralph': + specifier: workspace:^ + version: link:../../packages/workflow/tool-ralph + '@deepseek-ai/dsh-tool-skill': + specifier: workspace:^ + version: link:../../packages/skill/tool-skill + '@deepseek-ai/dsh-tool-str-replace-editor': + specifier: workspace:^ + version: link:../../packages/fs/tool-str-replace-editor + '@deepseek-ai/dsh-tool-subagent': + specifier: workspace:^ + version: link:../../packages/subagent/tool-subagent + '@deepseek-ai/dsh-tool-subagent-control': + specifier: workspace:^ + version: link:../../packages/subagent/tool-subagent-control + '@deepseek-ai/dsh-tool-tasks': + specifier: workspace:^ + version: link:../../packages/tasks/tool-tasks + '@deepseek-ai/dsh-tool-todo': + specifier: workspace:^ + version: link:../../packages/todo/tool-todo + '@deepseek-ai/dsh-tool-web': + specifier: workspace:^ + version: link:../../packages/web/tool-web + '@deepseek-ai/dsh-tool-workflow': + specifier: workspace:^ + version: link:../../packages/workflow/tool-workflow '@deepseek-ai/dsh-web-app': specifier: workspace:^ version: link:../../packages/bundle/web-app + '@deepseek-ai/dsh-workflow-workerthread': + specifier: workspace:^ + version: link:../../packages/workflow/workflow-workerthread + '@deepseek-ai/dsh-workspace-context': + specifier: workspace:^ + version: link:../../packages/context/workspace-context commander: specifier: ^15.0.0 version: 15.0.0 @@ -1917,9 +1998,6 @@ importers: '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots - '@deepseek-ai/dsh-tool-ask-user': - specifier: workspace:^ - version: link:../../ui/tool-ask-user clsx: specifier: ^2.0.0 version: 2.1.1