1
0
Эх сурвалжийг харах

Merge branch 'stack/agent-profiles-4-settings' into stack/agent-profiles-5-web-ui

# Conflicts:
#	apps/cli/package.json
#	pnpm-lock.yaml
Yichen Jiang 1 сар өмнө
parent
commit
c4e76384f4

+ 6 - 14
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'

+ 26 - 35
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'
 

+ 27 - 0
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",

+ 11 - 8
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()
     }

+ 22 - 4
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<void> {
@@ -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()

+ 27 - 0
apps/web/tests/scaffold.ts

@@ -240,6 +240,31 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
   // paths at load, and an in-process boot must NEVER touch the developer's
   // real ~/.dsh document or credential file.
   const harnessHome = join(workspaceCwd, '.dsh-home')
+  // Skill discovery is model-visible input, and its roots now resolve inside a
+  // PRESET — a subtree this lane's include patches cannot reach, because the
+  // roster mounts it directly per session rather than as a row of the booted
+  // tree. The row's documented fallback is the environment, so pin that: the
+  // whole scaffold lifetime, not just the boot, since presets mount when a
+  // session is created. Without this a developer's real ~/.dsh/skills silently
+  // enters replay requests and goldens while CI sees none.
+  const skillRootEnvironment = {
+    DSH_HOME: join(workspaceCwd, '.dsh-home'),
+    DSH_AGENTS_HOME: join(workspaceCwd, '.agents-home'),
+    DSH_BUNDLED_SKILL_DIR: join(workspaceCwd, '.bundled-skills'),
+  }
+  const originalSkillRootEnvironment = Object.fromEntries(
+    Object.keys(skillRootEnvironment).map(key => [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<We
     if (process.cwd() !== originalCwd) process.chdir(originalCwd)
     const cleanupFailures = await cleanupScaffoldWorld(ctx, workspaceCwd, persistenceRoot)
     restoreCredentialEnvironment()
+    restoreSkillRootEnvironment()
     if (cleanupFailures.length > 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<We
         failures.push(...await cleanupScaffoldWorld(ctx, workspaceCwd, persistenceRoot))
       } finally {
         restoreCredentialEnvironment()
+        restoreSkillRootEnvironment()
       }
       if (failures.length > 0) throw new AggregateError(failures, 'web scaffold teardown failed')
     },

+ 27 - 8
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 () => {

+ 22 - 5
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

+ 1 - 1
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))

+ 16 - 12
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

+ 2 - 2
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

+ 1 - 1
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`.
 

+ 1 - 1
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` 拒绝整个等待。
 

+ 0 - 1
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"
   },

+ 11 - 14
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 {}

+ 9 - 6
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()
   })
 })

+ 1 - 3
packages/client/ui-question/tsconfig.json

@@ -29,9 +29,7 @@
     {
       "path": "../ui-slots"
     },
-    {
-      "path": "../../ui/tool-ask-user"
-    },
+
     {
       "path": "../../support/invariants"
     }

+ 81 - 3
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