Переглянути джерело

fix(browser-use): initialize MCP clients without new core APIs

Tianyi Cui 1 місяць тому
батько
коміт
b84a8226bd
44 змінених файлів з 354 додано та 282 видалено
  1. 2 2
      .agents/notes/implemented/architecture/2026-09-12-browser-use-provider-registration.i18n.yaml
  2. 7 1
      .agents/notes/implemented/architecture/2026-09-12-browser-use-provider-registration.md
  3. 7 1
      .agents/notes/implemented/architecture/2026-09-12-browser-use-provider-registration.zh.md
  4. 2 2
      docs/config-catalog.i18n.yaml
  5. 3 3
      docs/config-catalog.md
  6. 3 3
      docs/config-catalog.zh.md
  7. 2 2
      docs/event-producer-consumer.i18n.yaml
  8. 4 5
      docs/event-producer-consumer.md
  9. 4 5
      docs/event-producer-consumer.zh.md
  10. 2 2
      docs/subsystems/browser-use.i18n.yaml
  11. 6 0
      docs/subsystems/browser-use.md
  12. 6 0
      docs/subsystems/browser-use.zh.md
  13. 2 2
      docs/subsystems/system-prompt.i18n.yaml
  14. 1 24
      docs/subsystems/system-prompt.md
  15. 1 24
      docs/subsystems/system-prompt.zh.md
  16. 0 1
      packages/core/scope/src/scoped-events.generated.ts
  17. 0 1
      packages/core/scope/tests/invariant.spec.ts
  18. 2 2
      packages/core/system-prompt/README.i18n.yaml
  19. 1 3
      packages/core/system-prompt/README.md
  20. 1 3
      packages/core/system-prompt/README.zh.md
  21. 1 14
      packages/core/system-prompt/src/index.ts
  22. 0 66
      packages/core/system-prompt/tests/prepare.spec.ts
  23. 2 2
      packages/experimental/browser-use-chrome-devtools-mcp/README.i18n.yaml
  24. 5 5
      packages/experimental/browser-use-chrome-devtools-mcp/README.md
  25. 5 5
      packages/experimental/browser-use-chrome-devtools-mcp/README.zh.md
  26. 2 1
      packages/experimental/browser-use-chrome-devtools-mcp/package.json
  27. 2 2
      packages/experimental/browser-use-chrome-devtools-mcp/src/index.ts
  28. 2 2
      packages/experimental/browser-use-playwright-mcp/README.i18n.yaml
  29. 5 5
      packages/experimental/browser-use-playwright-mcp/README.md
  30. 5 5
      packages/experimental/browser-use-playwright-mcp/README.zh.md
  31. 2 1
      packages/experimental/browser-use-playwright-mcp/package.json
  32. 2 2
      packages/experimental/browser-use-playwright-mcp/src/index.ts
  33. 2 2
      packages/experimental/browser-use-runtime/README.i18n.yaml
  34. 7 2
      packages/experimental/browser-use-runtime/README.md
  35. 7 2
      packages/experimental/browser-use-runtime/README.zh.md
  36. 1 0
      packages/experimental/browser-use-runtime/package.json
  37. 70 31
      packages/experimental/browser-use-runtime/src/mcp.ts
  38. 12 2
      packages/experimental/browser-use-runtime/tests/mcp-fixture.mjs
  39. 1 0
      packages/experimental/browser-use-runtime/tests/mcp-upstream.ts
  40. 157 34
      packages/experimental/browser-use-runtime/tests/mcp.spec.ts
  41. 1 9
      packages/extensions/tool-cordis/src/api-catalog.ts
  42. 3 0
      pnpm-lock.yaml
  43. 2 2
      snapshots/session/browser-use-chrome-devtools-mcp/provider-fixture.mjs
  44. 2 2
      snapshots/session/browser-use-playwright-mcp/provider-fixture.mjs

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-12-browser-use-provider-registration.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-09-12-browser-use-provider-registration.md
-2026-09-12-browser-use-provider-registration.md: 47eae853d1a84b98065e34fe71a94f1bdbcae6ac
-2026-09-12-browser-use-provider-registration.zh.md: b1594977ee37581c0ae6ecd4a01602a1566e3f25
+2026-09-12-browser-use-provider-registration.md: 1e8ce789754291eef5eaf16750c8460369dccead
+2026-09-12-browser-use-provider-registration.zh.md: 7efb054a08db690e27ce45af178b63803ca202b5

+ 7 - 1
.agents/notes/implemented/architecture/2026-09-12-browser-use-provider-registration.md

@@ -22,12 +22,18 @@ Stagehand's launcher inherits its process environment, and SDK initialization ca
 
 Stagehand uses its supported custom-model callback to request structured results through the Session's selected DSH model. A provider-local adapter requests one result tool call and validates its arguments against Stagehand's requested JSON Schema; it does not execute additional model tool calls. Auxiliary requests are logged and flushed before model dispatch, and settled responses are logged and flushed before automation continues. These events remain separate from the main conversation, preserving the browser operation's model input without altering the agent loop.
 
-Per-Session MCP discovery runs in the awaited serial `system-prompt/prepare` event, before prompt assembly collects its scoped registrations and tool providers. The discovered catalog therefore enters the normal tool-mode, restriction, and ordering pipeline on the first request. Discovery at `agent/pre-step` is too late because prompt assembly has already collected the catalog; the preparation event keeps this ownership in system-prompt assembly without changing the agent loop. The same Session queue guards shared resource requests addressed to that browser server; nonowners cannot execute them or receive its server-instruction section.
+MCP client activation waits for connection and tool discovery, but provider activation can finish before any Session exists. That activation promise cannot represent the clients owned by future Sessions. Each MCP browser provider observes future `agent/created` publications and immediately claims the Agent's existing `runMaintenance` phase for one client startup attempt. Maintenance runs outside a turn and holds queued input until discovery settles, so ordinary prompt assembly sees the completed catalog. Direct callers await `agent.whenIdle()` before inspecting prompt assembly or scoped tools.
+
+Startup failure remains failed for that live activation and rejects prompt assembly and model requests. A busy attachment skips startup permanently for the activation while its other work continues; a newly created or resumed Agent can acquire the attachment after release. Late installation and reload apply only to future activations, following the [Schedule mounting policy](../../../../packages/schedule/schedule/README.md#use-this-package). Browser tools and resource requests for a successful client share the Session queue; other Sessions cannot execute those requests or receive that server's instructions.
 
 ## Alternatives considered
 
 **Unified browser action API.** Playwright, Chrome DevTools, and Stagehand have different native semantics. No current consumer requires interchangeable action methods, so provider-owned tools retain those semantics.
 
+**A new core startup API.** Existing Agent maintenance already prevents turns during Session-owned setup; another initialization API would duplicate that ownership.
+
+**Discovery during prompt assembly.** Prompt and tool collection need ready registrations. Starting discovery there either exposes an incomplete catalog or requires another collection pass; maintenance completes discovery before a turn begins.
+
 **One shared browser across Sessions.** Browser tabs, navigation, and login state can be isolated per Session. Sharing them would introduce cross-Session interference that the desktop integrations cannot generally avoid.
 
 **Fresh context when attaching.** A new browser context does not inherit the existing logged-in state. Exclusive use of the attached browser preserves the workflow that attachment enables.

+ 7 - 1
.agents/notes/implemented/architecture/2026-09-12-browser-use-provider-registration.zh.md

@@ -22,12 +22,18 @@ Stagehand 的启动器继承其进程环境,SDK 初始化可能在清理完成
 
 Stagehand 使用受支持的自定义模型回调,通过 Session 选定的 DSH 模型请求结构化结果。提供方内的适配器请求一个结果工具调用,并依据 Stagehand 请求的 JSON Schema 验证其参数;它不执行额外的模型工具调用。辅助请求在模型分派前记录并刷新,已结算响应在自动化继续前记录并刷新。这些事件保持独立于主对话,在不改变 agent loop(智能体循环)的情况下保留浏览器操作的模型输入。
 
-各 Session 的 MCP 发现在会被等待的串行 `system-prompt/prepare` 事件中运行,先于提示词组装收集作用域注册与工具提供方。因此发现的目录在首次请求时就进入常规工具模式、限制与排序管线。在 `agent/pre-step` 发现为时已晚,因为提示词组装已经收集目录;准备事件将此所有权保留在系统提示词组装中,而不改变 agent loop。同一个 Session 队列也保护发往该浏览器服务器的共享资源请求;非所有者不能执行这些请求,也不能收到其服务器指令段落。
+MCP 客户端激活会等待连接和工具发现,但提供方可能在任何 Session 存在之前就完成激活。该激活 promise 无法代表未来各 Session 拥有的客户端。每个 MCP 浏览器提供方观察后续的 `agent/created` 发布,并立即占用 Agent 现有的 `runMaintenance` 阶段,执行一次客户端启动尝试。维护在轮次之外运行,并将输入保持在队列中直到发现结束,使常规提示词组装读取完整目录。直接调用方在检查提示词组装或作用域工具前等待 `agent.whenIdle()`。
+
+启动失败会保留为本次激活的失败状态,并拒绝提示词组装和模型请求。附加连接被占用时,本次激活永久跳过启动,但其他工作继续运行;连接释放后,新创建或恢复的 Agent 可以获取连接。较晚安装和重新加载只作用于后续激活,与 [Schedule 的挂载策略](../../../../packages/schedule/schedule/README.zh.md#use-this-package)一致。成功客户端的浏览器工具与资源请求共享 Session 队列;其他 Session 不能执行这些请求,也不能收到该服务器的指导。
 
 ## 考虑过的替代方案
 
 **统一浏览器动作 API。** Playwright、Chrome DevTools 与 Stagehand 的原生语义不同。当前没有消费方要求可互换的动作方法,因此由提供方拥有工具以保留这些语义。
 
+**新增核心启动 API。** 现有 Agent 维护阶段已能在 Session 自有设置期间阻止轮次运行;另一个初始化 API 会重复这份所有权。
+
+**在提示词组装期间发现。** 提示词和工具收集需要已就绪的注册。此时启动发现要么暴露不完整目录,要么要求再次收集;维护在轮次开始前完成发现。
+
 **多个 Session 共享一个浏览器。** 浏览器标签页、导航与登录状态可以按 Session 隔离。共享它们会引入桌面集成通常无法避免的跨 Session 干扰。
 
 **附加时创建全新 context。** 新浏览器 context 不继承现有登录状态。独占使用附加的浏览器可保留附加所支持的工作流。

+ 2 - 2
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: 3c2c641520bed70ce8f5fda9c8b800d7d9fe72a2
-config-catalog.zh.md: ac5a472f86965cfe04d14f98283ff107168af753
+config-catalog.md: 8949c8bb942416761f79fae692225af53274f11f
+config-catalog.zh.md: 0285b00ac21cdac04e91cc19a7707b720d614dac

+ 3 - 3
docs/config-catalog.md

@@ -531,7 +531,7 @@ Source: [`packages/experimental/agent-team/src/types.ts:130`](../packages/experi
 
 ## `@deepseek-ai/dsh-experimental-browser-use-chrome-devtools-mcp`
 
-Requires: `browserUse` · `agents` · `tools`
+Requires: `browserUse` · `agents` · `tools` · `systemPrompt`
 
 ```ts config-catalog
 /** Fixed Chromium launch or existing-browser attachment settings. */
@@ -546,7 +546,7 @@ Source: [`packages/experimental/browser-use-chrome-devtools-mcp/src/index.ts:14`
 
 ## `@deepseek-ai/dsh-experimental-browser-use-playwright-mcp`
 
-Requires: `browserUse` · `agents` · `tools`
+Requires: `browserUse` · `agents` · `tools` · `systemPrompt`
 
 ```ts config-catalog
 /** Fixed Chromium launch or existing-browser attachment settings. */
@@ -2707,7 +2707,7 @@ export interface Config {
 }
 ```
 
-Source: [`packages/core/system-prompt/src/index.ts:258`](../packages/core/system-prompt/src/index.ts)
+Source: [`packages/core/system-prompt/src/index.ts:248`](../packages/core/system-prompt/src/index.ts)
 
 <a id="deepseek-aidsh-terminal-bash"></a>
 

+ 3 - 3
docs/config-catalog.zh.md

@@ -533,7 +533,7 @@ export interface Config {
 
 ## `@deepseek-ai/dsh-experimental-browser-use-chrome-devtools-mcp`
 
-需要:`browserUse` · `agents` · `tools`
+需要:`browserUse` · `agents` · `tools` · `systemPrompt`
 
 ```ts config-catalog
 /** Fixed Chromium launch or existing-browser attachment settings. */
@@ -548,7 +548,7 @@ export type Config = BrowserMcpConfig
 
 ## `@deepseek-ai/dsh-experimental-browser-use-playwright-mcp`
 
-需要:`browserUse` · `agents` · `tools`
+需要:`browserUse` · `agents` · `tools` · `systemPrompt`
 
 ```ts config-catalog
 /** Fixed Chromium launch or existing-browser attachment settings. */
@@ -2709,7 +2709,7 @@ export interface Config {
 }
 ```
 
-来源:[`packages/core/system-prompt/src/index.ts:258`](../packages/core/system-prompt/src/index.ts)
+来源:[`packages/core/system-prompt/src/index.ts:248`](../packages/core/system-prompt/src/index.ts)
 
 <a id="deepseek-aidsh-terminal-bash"></a>
 

+ 2 - 2
docs/event-producer-consumer.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/event-producer-consumer.md
-event-producer-consumer.md: 2ea90fa9f8e5c11e94917a7869bfc9096eaecd60
-event-producer-consumer.zh.md: 66ec5a20b074a0ba485c27bad6005a98df50375f
+event-producer-consumer.md: d58336c9d782a4d61a9df295ac64623e1b533408
+event-producer-consumer.zh.md: 2a97fdae3fd629e210cd80aeeb7968606292dc57

+ 4 - 5
docs/event-producer-consumer.md

@@ -10,7 +10,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
 | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:246`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - |
 | `agent-preset/selected` | `emit` | [`packages/preset/agent-presets/src/types.ts:82`](../packages/preset/agent-presets/src/types.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`) | `remotes` |
 | `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:373`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` |
-| `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:258`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-presets`](../packages/preset/agent-presets), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`loader-smoke`](../packages/test-support/loader-smoke), [`schedule`](../packages/schedule/schedule), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
+| `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:258`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-presets`](../packages/preset/agent-presets), `browser-use-runtime`, [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`loader-smoke`](../packages/test-support/loader-smoke), [`schedule`](../packages/schedule/schedule), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
 | `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:267`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
 | `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:403`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`session-telemetry`](../packages/session/session-telemetry) |
 | `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:296`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) |
@@ -60,10 +60,9 @@ This matrix shows which packages dispatch each harness-owned event and which pac
 | `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:144`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
 | `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:150`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
 | `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:161`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`subagent`](../packages/subagent/subagent) |
-| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:41`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), `browser-use-runtime`, [`session-reference`](../packages/context/session-reference), [`system-prompt`](../packages/core/system-prompt) |
-| `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:47`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - |
-| `system-prompt/prepare` | `serial` | [`packages/core/system-prompt/src/index.ts:28`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`serial`) | `browser-use-runtime` |
-| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:201`](../packages/core/tools/src/index.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`), [`tools`](../packages/core/tools) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) |
+| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:31`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), `browser-use-runtime`, [`session-reference`](../packages/context/session-reference), [`system-prompt`](../packages/core/system-prompt) |
+| `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:37`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - |
+| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:201`](../packages/core/tools/src/index.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`), [`tools`](../packages/core/tools) (`emit`) | `browser-use-runtime`, [`tool-subagent`](../packages/subagent/tool-subagent) |
 | `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:157`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `browser-use-runtime`, `browser-use-stagehand-native`, `computer-use-cua-driver-native`, [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), `timeout-policy` |
 | `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:169`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search) |
 | `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:146`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `auto-review`, [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-jobs`](../packages/jobs/tool-jobs) |

+ 4 - 5
docs/event-producer-consumer.zh.md

@@ -12,7 +12,7 @@
 | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:246`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - |
 | `agent-preset/selected` | `emit` | [`packages/preset/agent-presets/src/types.ts:82`](../packages/preset/agent-presets/src/types.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`) | `remotes` |
 | `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:373`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` |
-| `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:258`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-presets`](../packages/preset/agent-presets), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`loader-smoke`](../packages/test-support/loader-smoke), [`schedule`](../packages/schedule/schedule), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
+| `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:258`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-presets`](../packages/preset/agent-presets), `browser-use-runtime`, [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`loader-smoke`](../packages/test-support/loader-smoke), [`schedule`](../packages/schedule/schedule), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
 | `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:267`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
 | `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:403`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`session-telemetry`](../packages/session/session-telemetry) |
 | `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:296`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) |
@@ -62,10 +62,9 @@
 | `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:144`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
 | `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:150`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
 | `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:161`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`subagent`](../packages/subagent/subagent) |
-| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:41`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), `browser-use-runtime`, [`session-reference`](../packages/context/session-reference), [`system-prompt`](../packages/core/system-prompt) |
-| `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:47`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - |
-| `system-prompt/prepare` | `serial` | [`packages/core/system-prompt/src/index.ts:28`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`serial`) | `browser-use-runtime` |
-| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:201`](../packages/core/tools/src/index.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`), [`tools`](../packages/core/tools) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) |
+| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:31`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), `browser-use-runtime`, [`session-reference`](../packages/context/session-reference), [`system-prompt`](../packages/core/system-prompt) |
+| `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:37`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - |
+| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:201`](../packages/core/tools/src/index.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`), [`tools`](../packages/core/tools) (`emit`) | `browser-use-runtime`, [`tool-subagent`](../packages/subagent/tool-subagent) |
 | `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:157`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `browser-use-runtime`, `browser-use-stagehand-native`, `computer-use-cua-driver-native`, [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), `timeout-policy` |
 | `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:169`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search) |
 | `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:146`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `auto-review`, [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-jobs`](../packages/jobs/tool-jobs) |

+ 2 - 2
docs/subsystems/browser-use.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/subsystems/browser-use.md
-browser-use.md: 0f310906fe55b1e38ead7c9877280360255d508c
-browser-use.zh.md: e0cfc059f74108decca7ab9d68161c4dc2e966d7
+browser-use.md: 97397ec656970803331587347e7a1c4a487ff156
+browser-use.zh.md: 5c8b764a5aab17ac16050e2a68014cf8cc0378a4

+ 6 - 0
docs/subsystems/browser-use.md

@@ -24,6 +24,12 @@ An attached browser remains externally owned. The provider reserves it for one S
 
 Provider shutdown stops tool admission and waits for owned work and resource cleanup before releasing the shared provider registration. Cancellation cannot undo a browser action already delivered.
 
+## MCP initialization
+
+An MCP provider initializes one client for each live Agent created after the provider loads. Existing Agent maintenance holds queued input until connection and discovery settle; the client then remains with the Session across turns. Direct callers inspecting prompt assembly or scoped tools first await `agent.whenIdle()`. A failed startup remains failed for that activation and rejects prompt assembly and model requests.
+
+If an attachment is busy, that activation continues without the browser and does not retry on later turns. After release, a newly created or resumed activation can acquire it. Loading or reloading the provider does not adopt already active Sessions; the [shared runtime](../../packages/experimental/browser-use-runtime/README.md) owns these initialization rules.
+
 ## Tools and recorded results
 
 Provider tools use the normal DSH execution pipeline and Session log. The providers own their tool schemas, result rendering, image support, configuration, and upstream limitations; the shared service adds no model-visible content. Stagehand's AI-assisted operations use the Session's selected DSH model and record their auxiliary requests and settled results separately from the main conversation; they do not delegate the DSH task loop to another agent.

+ 6 - 0
docs/subsystems/browser-use.zh.md

@@ -24,6 +24,12 @@
 
 提供方关闭时先停止接收工具调用,并等待自有工作与资源清理完成,再释放共享提供方注册。取消无法撤销已交付的浏览器操作。
 
+## MCP 初始化
+
+MCP 提供方为其加载后创建的每个活动 Agent 初始化一个客户端。现有 Agent 维护阶段将输入保持在队列中,直到连接和发现结束;客户端随后跨轮次归 Session 所有。直接检查提示词组装或作用域工具的调用方先等待 `agent.whenIdle()`。启动失败会保留为本次激活的失败状态,并拒绝提示词组装和模型请求。
+
+如果附加连接已被占用,本次激活不使用浏览器,但继续运行,后续轮次不会重试。连接释放后,新创建或恢复的激活可以获取它。加载或重新加载提供方不会接管已经活动的 Session;[共享运行时](../../packages/experimental/browser-use-runtime/README.zh.md)拥有这些初始化规则。
+
 ## 工具与记录结果
 
 提供方工具使用常规 DSH 执行管线与 Session 日志。提供方拥有自己的工具 schema、结果渲染、图像支持、配置和上游限制;共享服务不添加模型可见内容。Stagehand 的 AI 辅助操作使用 Session 选定的 DSH 模型,并将辅助请求与已结算结果独立于主对话记录;它们不会将 DSH 任务循环委托给另一个 agent(智能体)。

+ 2 - 2
docs/subsystems/system-prompt.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/subsystems/system-prompt.md
-system-prompt.md: a01786cda07c80e856fad70f4d835da211acaab4
-system-prompt.zh.md: 61056fd72bf8dfbf05dc0c7baa114488848eed98
+system-prompt.md: 898520e8fc88a9fafd8429723fc48bad3b4ba210
+system-prompt.zh.md: 82e557a309a48f546d7c7fa5a8c1097152b4d145

+ 1 - 24
docs/subsystems/system-prompt.md

@@ -162,7 +162,7 @@ tools(provider: (context: AssembleContext) => ToolProviderResult): () => void
 variable(name: string, provider: (context: AssembleContext) => string | undefined): () => void
 
 /**
- * Await scoped preparation, assemble providers, detach tool parameters, apply
+ * Assemble global and scoped providers, detach tool parameters, apply
  * canonical ordering, then run the assembly waterfall. Scoped sections and
  * variables shadow globals. The returned waterfall value is authoritative
  * except that an effective complete section is restored afterwards as the
@@ -220,28 +220,5 @@ Emitted when any prompt provider changes. This registry notification is unfilter
 'system-prompt/change'(): void
 ```
 
-Source: [`packages/core/system-prompt/src/index.ts`](../../packages/core/system-prompt/src/index.ts)
-
-<a id="system-promptprepare--serial"></a>
-
-#### `system-prompt/prepare` — serial
-
-Await scope-owned resource and catalog preparation before reading providers. Scope-filtered dispatch: scoped listeners prepare only their scope's assemblies. A rejected listener rejects this assembly; subsequent collection applies the normal visibility, presentation, and ordering rules to new registrations. The signal belongs to this assembly and must not control later turns.
-
-```ts cordis-catalog
-/**
- * Await scope-owned resource and catalog preparation before reading providers.
- * Scope-filtered dispatch: scoped listeners prepare only their scope's assemblies.
- * A rejected listener rejects this assembly; subsequent collection applies
- * the normal visibility, presentation, and ordering rules to new registrations.
- * The signal belongs to this assembly and must not control later turns.
- * @param context - the caller's per-assembly context and optional cancellation.
- * @mode serial
- */
-'system-prompt/prepare'(this: Scoped<SystemPrompt>, context: AssembleContext): Promise<void>
-```
-
-Types: [Scoped](scope.md)
-
 Source: [`packages/core/system-prompt/src/index.ts`](../../packages/core/system-prompt/src/index.ts)
 <!-- END GENERATED cordis-surface -->

+ 1 - 24
docs/subsystems/system-prompt.zh.md

@@ -162,7 +162,7 @@ tools(provider: (context: AssembleContext) => ToolProviderResult): () => void
 variable(name: string, provider: (context: AssembleContext) => string | undefined): () => void
 
 /**
- * Await scoped preparation, assemble providers, detach tool parameters, apply
+ * Assemble global and scoped providers, detach tool parameters, apply
  * canonical ordering, then run the assembly waterfall. Scoped sections and
  * variables shadow globals. The returned waterfall value is authoritative
  * except that an effective complete section is restored afterwards as the
@@ -220,28 +220,5 @@ Emitted when any prompt provider changes. This registry notification is unfilter
 'system-prompt/change'(): void
 ```
 
-Source: [`packages/core/system-prompt/src/index.ts`](../../packages/core/system-prompt/src/index.ts)
-
-<a id="system-promptprepare--serial"></a>
-
-#### `system-prompt/prepare` — serial
-
-Await scope-owned resource and catalog preparation before reading providers. Scope-filtered dispatch: scoped listeners prepare only their scope's assemblies. A rejected listener rejects this assembly; subsequent collection applies the normal visibility, presentation, and ordering rules to new registrations. The signal belongs to this assembly and must not control later turns.
-
-```ts cordis-catalog
-/**
- * Await scope-owned resource and catalog preparation before reading providers.
- * Scope-filtered dispatch: scoped listeners prepare only their scope's assemblies.
- * A rejected listener rejects this assembly; subsequent collection applies
- * the normal visibility, presentation, and ordering rules to new registrations.
- * The signal belongs to this assembly and must not control later turns.
- * @param context - the caller's per-assembly context and optional cancellation.
- * @mode serial
- */
-'system-prompt/prepare'(this: Scoped<SystemPrompt>, context: AssembleContext): Promise<void>
-```
-
-Types: [Scoped](scope.zh.md)
-
 Source: [`packages/core/system-prompt/src/index.ts`](../../packages/core/system-prompt/src/index.ts)
 <!-- END GENERATED cordis-surface -->

+ 0 - 1
packages/core/scope/src/scoped-events.generated.ts

@@ -30,7 +30,6 @@ const scopedSubjectResolvers: Readonly<Record<string, ScopedSubjectResolver | nu
   'subagent/end': null,
   'subagent/start': null,
   'system-prompt/assemble': args => (args[1] as Record<string, unknown>)['scope'],
-  'system-prompt/prepare': args => (args[0] as Record<string, unknown>)['scope'],
   'tools/execute': args => (args[0] as Record<string, unknown>)['agent'],
   'tools/post-execute': args => (args[0] as Record<string, unknown>)['agent'],
   'tools/pre-execute': args => (args[0] as Record<string, unknown>)['agent'],

+ 0 - 1
packages/core/scope/tests/invariant.spec.ts

@@ -80,7 +80,6 @@ describe('scoped-dispatch invariants', () => {
       ['approval/request', [{ agent, toolName: 'echo' }, () => Promise.resolve('unavailable')]],
       ['goal/changed', [{ agent, change: { operation: 'create', ref: { id: 'goal-a', revision: 1 } } }]],
       ['system-prompt/assemble', [[], { scope: agent }]],
-      ['system-prompt/prepare', [{ scope: agent, signal }]],
       ['tools/ptc-dispatch-log', [{ exec: { callId: 'c', name: 't', arguments: {} }, agent, subCallId: 'c:code:1', name: 't', isError: false, content: [] }, () => Promise.resolve([])]],
       ['tools/execute', [{ callId: 'c', name: 't', arguments: {}, agent }, () => Promise.resolve({ content: [], isError: false })]],
       ['tools/post-execute', [{ callId: 'c', name: 't', arguments: {}, agent }, { content: [], isError: false }, () => Promise.resolve({ kind: 'accept' })]],

+ 2 - 2
packages/core/system-prompt/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/core/system-prompt/README.md
-README.md: fcd260a343fb504acc35313780e7f5e75c47c838
-README.zh.md: c1d9a22910374c0ebbde2d916efe01ba6bb4ba7d
+README.md: a76ae5c66f0801cb85cd8cf22d0c3ebbc68e65b8
+README.zh.md: 98c6e733399c954c5bbc56b905aefcc6b080381d

+ 1 - 3
packages/core/system-prompt/README.md

@@ -77,8 +77,6 @@ ctx.systemPrompt.variable('cwd', ({ agent }) => agent?.session.header.cwd)
 
 Tool-schema providers are evaluated per assembly and contribute the model-visible `ToolSchema` set; `ToolRuntime` registers itself automatically, so most tools need no manual wiring here. A provider returns the post-restriction visible set plus the pre-restriction name universe used by `toolOrder`.
 
-Asynchronous contributors use `system-prompt/prepare` to complete registrations before assembly collects sections, variables, runtime context, or tool schemas. Assembly awaits its serial listeners with the assembly context; preparation failure rejects assembly. Discovered tools then pass through the ordinary tool-mode, restriction, and ordering rules, including on the first model request.
-
 ### Suppress runtime context
 
 `suppressRuntimeContext()` removes every dynamic runtime-context contribution for the calling scope without disabling the services that own the underlying facts; multiple suppressors compose and the effect restores context when none remains.
@@ -110,7 +108,7 @@ Assembly resolves and renders in two stages: `assemble()` returns sections with
 
 ### Scoping
 
-Scoped sections, variables, and tool providers shadow globals for one agent; both preparation and the assembly waterfall dispatch scope-filtered. Registry-change notifications (`system-prompt/change`) are deliberately unfiltered because a global change affects every scope.
+Scoped sections, variables, and tool providers shadow globals for one agent, and the assembly waterfall dispatches scope-filtered. Registry-change notifications (`system-prompt/change`) are deliberately unfiltered because a global change affects every scope.
 
 </details>
 

+ 1 - 3
packages/core/system-prompt/README.zh.md

@@ -77,8 +77,6 @@ ctx.systemPrompt.variable('cwd', ({ agent }) => agent?.session.header.cwd)
 
 工具 schema 提供方在每次组装时求值,并贡献模型可见的 `ToolSchema` 集合;`ToolRuntime` 会自动注册自身,因此大多数工具在此无需手动接线。提供方返回限制后的可见集合,外加 `toolOrder` 使用的限制前名称全集。
 
-异步贡献方使用 `system-prompt/prepare`,在组装收集段落、变量、运行时上下文或工具 schema 前完成注册。组装使用组装上下文等待其串行监听器;准备失败会拒绝组装。发现的工具随后经过常规工具模式、限制与排序规则,首次模型请求也不例外。
-
 ### 抑制运行时上下文
 
 `suppressRuntimeContext()` 移除调用作用域的所有动态运行时上下文贡献,但不禁用拥有底层事实的服务;多个抑制器独立组合,当不再存在抑制器时该 effect 会恢复上下文。
@@ -110,7 +108,7 @@ ctx.systemPrompt.variable('cwd', ({ agent }) => agent?.session.header.cwd)
 
 ### 作用域
 
-带作用域的段、变量与工具提供方会为单个 agent 遮蔽全局项;准备事件与组装 waterfall 都按作用域筛选分发。注册表变更通知(`system-prompt/change`)刻意不经过筛选,因为全局变更影响每个作用域。
+带作用域的段、变量与工具提供方会为单个 agent 遮蔽全局项,组装 waterfall 按作用域筛选分发。注册表变更通知(`system-prompt/change`)刻意不经过筛选,因为全局变更影响每个作用域。
 
 </details>
 

+ 1 - 14
packages/core/system-prompt/src/index.ts

@@ -16,16 +16,6 @@ declare module '@deepseek-ai/cordis' {
   }
 
   interface Events {
-    /**
-     * Await scope-owned resource and catalog preparation before reading providers.
-     * Scope-filtered dispatch: scoped listeners prepare only their scope's assemblies.
-     * A rejected listener rejects this assembly; subsequent collection applies
-     * the normal visibility, presentation, and ordering rules to new registrations.
-     * The signal belongs to this assembly and must not control later turns.
-     * @param context - the caller's per-assembly context and optional cancellation.
-     * @mode serial
-     */
-    'system-prompt/prepare'(this: Scoped<SystemPrompt>, context: AssembleContext): Promise<void>
     /**
      * Expert waterfall over the assembled sections, contexts, tools, and variables.
      * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): scoped listeners
@@ -557,7 +547,7 @@ export class SystemPrompt extends Service {
   }
 
   /**
-   * Await scoped preparation, assemble providers, detach tool parameters, apply
+   * Assemble global and scoped providers, detach tool parameters, apply
    * canonical ordering, then run the assembly waterfall. Scoped sections and
    * variables shadow globals. The returned waterfall value is authoritative
    * except that an effective complete section is restored afterwards as the
@@ -568,9 +558,6 @@ export class SystemPrompt extends Service {
   // Keep configuration failures on the declared asynchronous error path.
   async assemble(context: AssembleContext = {}): Promise<PromptAssembly> {
     const scope = context.scope
-    context.signal?.throwIfAborted()
-    await this.ctx.serial(scopeTarget(this, scope), 'system-prompt/prepare', context)
-    context.signal?.throwIfAborted()
     const scopeLayers = this.layers.chainLayers(scope)
     const runtimeContextSuppressed = !this.layers.global.runtimeContextSuppressors.isEmpty()
       || scopeLayers.some(layer => !layer.runtimeContextSuppressors.isEmpty())

+ 0 - 66
packages/core/system-prompt/tests/prepare.spec.ts

@@ -1,66 +0,0 @@
-import { Context } from '@deepseek-ai/cordis'
-import { createScope } from '@deepseek-ai/dsh-scope'
-import SystemPrompt from '../src/index.ts'
-import { afterEach, expect, it, vi } from 'vitest'
-
-const contexts: Context[] = []
-
-afterEach(async () => {
-  await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
-})
-
-it('awaits catalog preparation before collecting and ordering first-request schemas', async () => {
-  const ctx = new Context()
-  contexts.push(ctx)
-  await ctx.plugin(SystemPrompt, { toolOrder: ['late', '<unlisted-tools>'] })
-  const entered = Promise.withResolvers<undefined>()
-  const ready = Promise.withResolvers<undefined>()
-  const collected = vi.fn(() => ({ schemas: [{ name: 'existing', description: '', parameters: {} }] }))
-  ctx.systemPrompt.tools(collected)
-  const prepare = ctx.on('system-prompt/prepare', async () => {
-    entered.resolve(undefined)
-    await ready.promise
-    ctx.systemPrompt.tools(() => ({ schemas: [{ name: 'late', description: '', parameters: {} }] }))
-  })
-  const assembling = ctx.systemPrompt.assemble()
-  await entered.promise
-  expect(collected).not.toHaveBeenCalled()
-  ready.resolve(undefined)
-  expect((await assembling).tools.map(tool => tool.name)).toEqual(['late', 'existing'])
-  prepare()
-  expect((await ctx.systemPrompt.assemble()).tools.map(tool => tool.name)).toEqual(['late', 'existing'])
-})
-
-it('filters preparation by scope and unregisters it with its owner', async () => {
-  const ctx = new Context()
-  contexts.push(ctx)
-  await ctx.plugin(SystemPrompt, {})
-  const a = {}
-  const b = {}
-  const scoped = createScope(ctx, a)
-  const prepare = vi.fn(async () => {})
-  scoped.ctx.on('system-prompt/prepare', prepare)
-  await ctx.systemPrompt.assemble({ scope: b })
-  expect(prepare).not.toHaveBeenCalled()
-  await ctx.systemPrompt.assemble({ scope: a })
-  expect(prepare).toHaveBeenCalledOnce()
-  await scoped.dispose()
-  await ctx.systemPrompt.assemble({ scope: a })
-  expect(prepare).toHaveBeenCalledOnce()
-})
-
-it('rejects failed or cancelled preparation before reading any provider', async () => {
-  const ctx = new Context()
-  contexts.push(ctx)
-  await ctx.plugin(SystemPrompt, {})
-  const collected = vi.fn(() => ({ schemas: [] }))
-  ctx.systemPrompt.tools(collected)
-  const failed = ctx.on('system-prompt/prepare', async () => { throw new Error('catalog unavailable') })
-  await expect(ctx.systemPrompt.assemble()).rejects.toThrow('catalog unavailable')
-  failed()
-  const controller = new AbortController()
-  ctx.on('system-prompt/prepare', async () => { controller.abort(new Error('cancel preparation')) })
-  await expect(ctx.systemPrompt.assemble({ signal: controller.signal })).rejects.toThrow('cancel preparation')
-  await expect(ctx.systemPrompt.assemble({ signal: controller.signal })).rejects.toThrow('cancel preparation')
-  expect(collected).not.toHaveBeenCalled()
-})

+ 2 - 2
packages/experimental/browser-use-chrome-devtools-mcp/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/browser-use-chrome-devtools-mcp/README.md
-README.md: 9c1a398e096b35b8567e1a01ec4586556a010362
-README.zh.md: 088f530a1e80206e38ad59b2baa9c1a1df59e623
+README.md: aecfd951801c01e16aceedbd7e430c07046dc1f8
+README.zh.md: f6eae68b74589a21c25a9481f4b2467811ad22d2

+ 5 - 5
packages/experimental/browser-use-chrome-devtools-mcp/README.md

@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
 
 ## Summary
 
-Use Chrome DevTools MCP to inspect pages and operate Chromium through its upstream tools. Each live Session receives its own MCP connection. Launch a separate browser or attach one Session to an existing browser with its current tabs and login state. This published experimental package activates only when explicitly mounted.
+Use Chrome DevTools MCP to inspect pages and operate Chromium through its upstream tools. The provider initializes a Session's MCP connection before queued input runs and retains it across turns. Launch a separate browser or attach one Session to an existing browser with its current tabs and login state. This published experimental package activates only when explicitly mounted.
 
 ## Table of Contents
 
@@ -25,7 +25,7 @@ Use Chrome DevTools MCP to inspect pages and operate Chromium through its upstre
 <a id="use-this-package"></a>
 ## Use this package
 
-Mount both entries in a profile composition that supplies Agents, tools, and system prompts. Browser installation follows the upstream runtime; select an existing Chromium installation with `executablePath`.
+Mount both entries before creating or resuming a Session, in a profile composition that supplies Agents, tools, and system prompts. Loading or reloading this provider does not adopt Sessions that are already active. Browser installation follows the upstream runtime; select an existing Chromium installation with `executablePath`.
 
 ```yaml
 - name: '@deepseek-ai/dsh-browser-use'
@@ -35,7 +35,7 @@ Mount both entries in a profile composition that supplies Agents, tools, and sys
     headless: true
 ```
 
-Use `mode: attach` and set `endpoint` to an HTTP(S) debugging URL or WS(S) browser endpoint to operate an existing browser. A Session claims the attachment while loading its browser tools and retains it until unloading. Other Sessions and subagents continue their turns without these tools; a later turn can acquire the attachment after cleanup. Direct calls from another Session fail. Cleanup disconnects and leaves the external browser and its pages running.
+Use `mode: attach` and set `endpoint` to an HTTP(S) debugging URL or WS(S) browser endpoint to operate an existing browser. The new live Session claims the attachment during initialization and retains it until unloading. If the attachment is busy, that activation continues without this browser and does not retry on later turns. After release, a newly created or resumed activation can acquire it. Direct calls from another Session fail. Cleanup disconnects and leaves the external browser and its pages running.
 
 | Field | Default | Meaning |
 |---|---|---|
@@ -55,7 +55,7 @@ The [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-exper
 <details>
 <summary>Implementation internals — click to expand</summary>
 
-The provider resolves its pinned npm executable and starts it under the current Node executable. Protocol negotiation may start a temporary probe; one serving process retains each Session's browser state. The [shared runtime](../browser-use-runtime/README.md) discovers tools before model schema collection and serializes browser tools and resource calls within each Session. The [MCP client](../../mcp/mcp-client/README.md) owns stdio, registration, cancellation, and result projection. No runtime invariant companion is published because the provider maintains no separate observation of the shared runtime or MCP connection.
+The provider resolves its pinned npm entry and starts it under the current Node executable. A temporary protocol probe may precede the serving process. The [shared runtime](../browser-use-runtime/README.md) owns initialization during Agent maintenance, per-Session serialization, and cleanup; the [MCP client](../../mcp/mcp-client/README.md) owns transport, discovery, and result projection. No runtime invariant companion is published because the provider maintains no independent connection observation.
 
 Browser state survives turns while its live Session remains attached. Disposal waits for server shutdown before releasing resources. Resume after reload starts fresh browser runtime state; stored conversation history does not restore cookies or pages.
 
@@ -96,7 +96,7 @@ An unchanged catalog preserves its tool-definition prefix. Results append to his
 The integration retains the pinned server's browser and tool restrictions.
 
 - Chromium only; Firefox and WebKit are not selectable.
-- A lost server connection leaves calls failing until provider reload or host restart. Reconnection is disabled to avoid silently replacing browser state.
+- Startup failure remains a failure for that live activation and rejects prompt assembly and model requests. A failed or disconnected client is not retried; after fixing the cause, create a new Session or unload and resume the existing one.
 - Attachment exclusivity is local to this provider instance. Other processes and browser users can still modify the same pages.
 - The shared resource-server inventory can show inherited server names; it does not grant access to another Session's browser.
 - Cancellation does not undo navigation, clicks, or other actions already delivered to the browser.

+ 5 - 5
packages/experimental/browser-use-chrome-devtools-mcp/README.zh.md

@@ -9,7 +9,7 @@ kind: "package-reference"
 
 ## 概述
 
-通过 Chrome DevTools MCP 的上游工具检查网页并操作 Chromium。每个活动 Session 使用独立 MCP 连接。可以启动独立浏览器,也可以让一个 Session 接入已有浏览器,使用其现有标签页和登录状态。本包以实验状态发布,仅在显式挂载后启用。
+通过 Chrome DevTools MCP 的上游工具检查网页并操作 Chromium。提供方在处理排队输入前初始化 Session 的 MCP 连接,并跨轮次保留连接。可以启动独立浏览器,也可以让一个 Session 接入已有浏览器,使用其现有标签页和登录状态。本包以实验状态发布,仅在显式挂载后启用。
 
 ## 目录
 
@@ -25,7 +25,7 @@ kind: "package-reference"
 <a id="use-this-package"></a>
 ## 使用此包
 
-在已提供 Agent、工具和系统提示词的 profile 组合中挂载以下条目。浏览器安装遵循上游运行时;使用 `executablePath` 选择已有 Chromium 安装。
+在创建或恢复 Session 前,将以下条目挂载到已提供 Agent、工具和系统提示词的 profile 组合中。加载或重新加载此提供方不会接管已经活动的 Session。浏览器安装遵循上游运行时;使用 `executablePath` 选择已有 Chromium 安装。
 
 ```yaml
 - name: '@deepseek-ai/dsh-browser-use'
@@ -35,7 +35,7 @@ kind: "package-reference"
     headless: true
 ```
 
-使用 `mode: attach`,并在 `endpoint` 中设置 HTTP(S) 调试 URL 或 WS(S) 浏览器端点,即可操作已有浏览器。Session 在加载浏览器工具时占用附加连接,并持续保留直到卸载。其他 Session 和子 Agent 可继续各自的轮次,但不获得这些工具;清理完成后,后续轮次可以获取附加连接。其他 Session 的直接调用会失败。清理只断开连接,保留外部浏览器及其页面。
+使用 `mode: attach`,并在 `endpoint` 中设置 HTTP(S) 调试 URL 或 WS(S) 浏览器端点,即可操作已有浏览器。新的活动 Session 在初始化时占用附加连接,并持续保留直到卸载。如果连接已被占用,本次激活不使用该浏览器,但继续运行,后续轮次不会重试。连接释放后,新创建或恢复的激活可以获取它。其他 Session 的直接调用会失败。清理只断开连接,保留外部浏览器及其页面。
 
 | 字段 | 默认值 | 含义 |
 |---|---|---|
@@ -55,7 +55,7 @@ kind: "package-reference"
 <details>
 <summary>实现细节 — 点击展开</summary>
 
-提供方解析固定版本 npm 包的可执行入口,并使用当前 Node 启动。协议协商可能先启动临时探测进程;每个 Session 的浏览器状态由一个服务进程保留。[共享运行时](../browser-use-runtime/README.zh.md)在模型 schema 收集前发现工具,并串行执行同一 Session 的浏览器工具与资源调用。[MCP 客户端](../../mcp/mcp-client/README.zh.md)负责标准输入输出、注册、取消和结果投影。提供方不另行维护共享运行时或 MCP 连接的状态观测,因此不发布运行时不变量配套入口。
+提供方解析固定版本 npm 包的可执行入口,并使用当前 Node 启动。服务进程之前可能运行临时协议探测进程。[共享运行时](../browser-use-runtime/README.zh.md)负责 Agent 维护阶段的初始化、逐 Session 串行执行与清理;[MCP 客户端](../../mcp/mcp-client/README.zh.md)负责传输、发现和结果投影。提供方不维护独立的连接观测,因此不发布运行时不变量配套入口。
 
 只要活动 Session 保持连接,浏览器状态就会跨轮次保留。销毁会等待服务器关闭,再释放资源。重新加载后恢复 Session 会创建新的浏览器运行状态;已保存的对话历史不会还原 Cookie 或页面。
 
@@ -96,7 +96,7 @@ kind: "package-reference"
 本集成保留固定版本服务器的浏览器与工具限制。
 
 - 仅支持 Chromium;不可选择 Firefox 或 WebKit。
-- 服务器连接丢失后,调用持续失败,直到重新加载提供方或重启主机。重连已禁用,避免静默替换浏览器状态。
+- 启动失败会保留为本次激活的失败状态,并拒绝提示词组装和模型请求。失败或断开的客户端不会重试;修复原因后,创建新 Session,或卸载并恢复已有 Session。
 - 连接独占仅在此提供方实例内有效。其他进程与浏览器用户仍可修改相同页面。
 - 共享资源服务器目录可以显示继承的服务器名称,但不会授予对其他 Session 浏览器的访问权限。
 - 取消不会撤销已发送给浏览器的导航、点击或其他操作。

+ 2 - 1
packages/experimental/browser-use-chrome-devtools-mcp/package.json

@@ -34,7 +34,8 @@
     "@deepseek-ai/cordis": "workspace:^",
     "@deepseek-ai/dsh-browser-use": "workspace:^",
     "@deepseek-ai/dsh-agent": "workspace:^",
-    "@deepseek-ai/dsh-tools": "workspace:^"
+    "@deepseek-ai/dsh-tools": "workspace:^",
+    "@deepseek-ai/dsh-system-prompt": "workspace:^"
   },
   "devDependencies": {
     "@deepseek-ai/cordis": "workspace:^",

+ 2 - 2
packages/experimental/browser-use-chrome-devtools-mcp/src/index.ts

@@ -7,8 +7,8 @@ import { BrowserMcpConfig, mountSessionMcp, validateBrowserMcpConfig } from '@de
 /** Cordis identity for the Chrome DevTools MCP browser provider. */
 export const name = 'experimental-browser-use-chrome-devtools-mcp'
 
-/** Services needed for provider registration and Session-owned tool discovery. */
-export const inject = ['browserUse', 'agents', 'tools']
+/** Services required for scoped MCP startup and prompt readiness checks. */
+export const inject = ['browserUse', 'agents', 'tools', 'systemPrompt']
 
 /** Fixed Chromium launch or existing-browser attachment settings. */
 export type Config = BrowserMcpConfig

+ 2 - 2
packages/experimental/browser-use-playwright-mcp/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/browser-use-playwright-mcp/README.md
-README.md: 0f6f88fbcc233730b3b7623d71702d2dc4a1b172
-README.zh.md: c75ec8a64ebe1e488abe60f46603f9f6b3780578
+README.md: 586acb40c2bb310e50a1390b3320a1bf5275c44e
+README.zh.md: 4161a31e42a1341d951cc1f3239404c9a110b841

+ 5 - 5
packages/experimental/browser-use-playwright-mcp/README.md

@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
 
 ## Summary
 
-Use Playwright MCP to inspect pages and operate Chromium through its upstream tools. Each live Session receives its own MCP connection. Launch a separate browser or attach one Session to an existing browser with its current tabs and login state. This published experimental package activates only when explicitly mounted.
+Use Playwright MCP to inspect pages and operate Chromium through its upstream tools. The provider initializes a Session's MCP connection before queued input runs and retains it across turns. Launch a separate browser or attach one Session to an existing browser with its current tabs and login state. This published experimental package activates only when explicitly mounted.
 
 ## Table of Contents
 
@@ -25,7 +25,7 @@ Use Playwright MCP to inspect pages and operate Chromium through its upstream to
 <a id="use-this-package"></a>
 ## Use this package
 
-Mount both entries in a profile composition that supplies Agents, tools, and system prompts. Browser installation follows the upstream runtime; select an existing Chromium installation with `executablePath`.
+Mount both entries before creating or resuming a Session, in a profile composition that supplies Agents, tools, and system prompts. Loading or reloading this provider does not adopt Sessions that are already active. Browser installation follows the upstream runtime; select an existing Chromium installation with `executablePath`.
 
 ```yaml
 - name: '@deepseek-ai/dsh-browser-use'
@@ -35,7 +35,7 @@ Mount both entries in a profile composition that supplies Agents, tools, and sys
     headless: true
 ```
 
-Use `mode: attach` and set `endpoint` to an HTTP(S) debugging URL or WS(S) browser endpoint to operate an existing browser. A Session claims the attachment while loading its browser tools and retains it until unloading. Other Sessions and subagents continue their turns without these tools; a later turn can acquire the attachment after cleanup. Direct calls from another Session fail. Cleanup disconnects and leaves the external browser and its pages running.
+Use `mode: attach` and set `endpoint` to an HTTP(S) debugging URL or WS(S) browser endpoint to operate an existing browser. The new live Session claims the attachment during initialization and retains it until unloading. If the attachment is busy, that activation continues without this browser and does not retry on later turns. After release, a newly created or resumed activation can acquire it. Direct calls from another Session fail. Cleanup disconnects and leaves the external browser and its pages running.
 
 | Field | Default | Meaning |
 |---|---|---|
@@ -55,7 +55,7 @@ The [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-exper
 <details>
 <summary>Implementation internals — click to expand</summary>
 
-The provider resolves its pinned npm executable and starts it under the current Node executable. Protocol negotiation may start a temporary probe; one serving process retains each Session's browser state. The [shared runtime](../browser-use-runtime/README.md) discovers tools before model schema collection and serializes browser tools and resource calls within each Session. The [MCP client](../../mcp/mcp-client/README.md) owns stdio, registration, cancellation, and result projection. No runtime invariant companion is published because the provider maintains no separate observation of the shared runtime or MCP connection.
+The provider resolves its pinned npm entry and starts it under the current Node executable. A temporary protocol probe may precede the serving process. The [shared runtime](../browser-use-runtime/README.md) owns initialization during Agent maintenance, per-Session serialization, and cleanup; the [MCP client](../../mcp/mcp-client/README.md) owns transport, discovery, and result projection. No runtime invariant companion is published because the provider maintains no independent connection observation.
 
 Browser state survives turns while its live Session remains attached. Disposal waits for server shutdown before releasing resources. Resume after reload starts fresh browser runtime state; stored conversation history does not restore cookies or pages.
 
@@ -96,7 +96,7 @@ An unchanged catalog preserves its tool-definition prefix. Results append to his
 The integration retains the pinned server's browser and tool restrictions.
 
 - Chromium only; Firefox and WebKit are not selectable.
-- A lost server connection leaves calls failing until provider reload or host restart. Reconnection is disabled to avoid silently replacing browser state.
+- Startup failure remains a failure for that live activation and rejects prompt assembly and model requests. A failed or disconnected client is not retried; after fixing the cause, create a new Session or unload and resume the existing one.
 - Attachment exclusivity is local to this provider instance. Other processes and browser users can still modify the same pages.
 - The shared resource-server inventory can show inherited server names; it does not grant access to another Session's browser.
 - Cancellation does not undo navigation, clicks, or other actions already delivered to the browser.

+ 5 - 5
packages/experimental/browser-use-playwright-mcp/README.zh.md

@@ -9,7 +9,7 @@ kind: "package-reference"
 
 ## 概述
 
-通过 Playwright MCP 的上游工具检查网页并操作 Chromium。每个活动 Session 使用独立 MCP 连接。可以启动独立浏览器,也可以让一个 Session 接入已有浏览器,使用其现有标签页和登录状态。本包以实验状态发布,仅在显式挂载后启用。
+通过 Playwright MCP 的上游工具检查网页并操作 Chromium。提供方在处理排队输入前初始化 Session 的 MCP 连接,并跨轮次保留连接。可以启动独立浏览器,也可以让一个 Session 接入已有浏览器,使用其现有标签页和登录状态。本包以实验状态发布,仅在显式挂载后启用。
 
 ## 目录
 
@@ -25,7 +25,7 @@ kind: "package-reference"
 <a id="use-this-package"></a>
 ## 使用此包
 
-在已提供 Agent、工具和系统提示词的 profile 组合中挂载以下条目。浏览器安装遵循上游运行时;使用 `executablePath` 选择已有 Chromium 安装。
+在创建或恢复 Session 前,将以下条目挂载到已提供 Agent、工具和系统提示词的 profile 组合中。加载或重新加载此提供方不会接管已经活动的 Session。浏览器安装遵循上游运行时;使用 `executablePath` 选择已有 Chromium 安装。
 
 ```yaml
 - name: '@deepseek-ai/dsh-browser-use'
@@ -35,7 +35,7 @@ kind: "package-reference"
     headless: true
 ```
 
-使用 `mode: attach`,并在 `endpoint` 中设置 HTTP(S) 调试 URL 或 WS(S) 浏览器端点,即可操作已有浏览器。Session 在加载浏览器工具时占用附加连接,并持续保留直到卸载。其他 Session 和子 Agent 可继续各自的轮次,但不获得这些工具;清理完成后,后续轮次可以获取附加连接。其他 Session 的直接调用会失败。清理只断开连接,保留外部浏览器及其页面。
+使用 `mode: attach`,并在 `endpoint` 中设置 HTTP(S) 调试 URL 或 WS(S) 浏览器端点,即可操作已有浏览器。新的活动 Session 在初始化时占用附加连接,并持续保留直到卸载。如果连接已被占用,本次激活不使用该浏览器,但继续运行,后续轮次不会重试。连接释放后,新创建或恢复的激活可以获取它。其他 Session 的直接调用会失败。清理只断开连接,保留外部浏览器及其页面。
 
 | 字段 | 默认值 | 含义 |
 |---|---|---|
@@ -55,7 +55,7 @@ kind: "package-reference"
 <details>
 <summary>实现细节 — 点击展开</summary>
 
-提供方解析固定版本 npm 包的可执行入口,并使用当前 Node 启动。协议协商可能先启动临时探测进程;每个 Session 的浏览器状态由一个服务进程保留。[共享运行时](../browser-use-runtime/README.zh.md)在模型 schema 收集前发现工具,并串行执行同一 Session 的浏览器工具与资源调用。[MCP 客户端](../../mcp/mcp-client/README.zh.md)负责标准输入输出、注册、取消和结果投影。提供方不另行维护共享运行时或 MCP 连接的状态观测,因此不发布运行时不变量配套入口。
+提供方解析固定版本 npm 包的可执行入口,并使用当前 Node 启动。服务进程之前可能运行临时协议探测进程。[共享运行时](../browser-use-runtime/README.zh.md)负责 Agent 维护阶段的初始化、逐 Session 串行执行与清理;[MCP 客户端](../../mcp/mcp-client/README.zh.md)负责传输、发现和结果投影。提供方不维护独立的连接观测,因此不发布运行时不变量配套入口。
 
 只要活动 Session 保持连接,浏览器状态就会跨轮次保留。销毁会等待服务器关闭,再释放资源。重新加载后恢复 Session 会创建新的浏览器运行状态;已保存的对话历史不会还原 Cookie 或页面。
 
@@ -96,7 +96,7 @@ kind: "package-reference"
 本集成保留固定版本服务器的浏览器与工具限制。
 
 - 仅支持 Chromium;不可选择 Firefox 或 WebKit。
-- 服务器连接丢失后,调用持续失败,直到重新加载提供方或重启主机。重连已禁用,避免静默替换浏览器状态。
+- 启动失败会保留为本次激活的失败状态,并拒绝提示词组装和模型请求。失败或断开的客户端不会重试;修复原因后,创建新 Session,或卸载并恢复已有 Session。
 - 连接独占仅在此提供方实例内有效。其他进程与浏览器用户仍可修改相同页面。
 - 共享资源服务器目录可以显示继承的服务器名称,但不会授予对其他 Session 浏览器的访问权限。
 - 取消不会撤销已发送给浏览器的导航、点击或其他操作。

+ 2 - 1
packages/experimental/browser-use-playwright-mcp/package.json

@@ -34,7 +34,8 @@
     "@deepseek-ai/cordis": "workspace:^",
     "@deepseek-ai/dsh-browser-use": "workspace:^",
     "@deepseek-ai/dsh-agent": "workspace:^",
-    "@deepseek-ai/dsh-tools": "workspace:^"
+    "@deepseek-ai/dsh-tools": "workspace:^",
+    "@deepseek-ai/dsh-system-prompt": "workspace:^"
   },
   "devDependencies": {
     "@deepseek-ai/cordis": "workspace:^",

+ 2 - 2
packages/experimental/browser-use-playwright-mcp/src/index.ts

@@ -8,8 +8,8 @@ import { BrowserMcpConfig, mountSessionMcp, validateBrowserMcpConfig } from '@de
 /** Cordis identity for the Playwright MCP browser provider. */
 export const name = 'experimental-browser-use-playwright-mcp'
 
-/** Services needed for provider registration and Session-owned tool discovery. */
-export const inject = ['browserUse', 'agents', 'tools']
+/** Services required for scoped MCP startup and prompt readiness checks. */
+export const inject = ['browserUse', 'agents', 'tools', 'systemPrompt']
 
 /** Fixed Chromium launch or existing-browser attachment settings. */
 export type Config = BrowserMcpConfig

+ 2 - 2
packages/experimental/browser-use-runtime/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/browser-use-runtime/README.md
-README.md: 53e908bd3aeeab33de8a8a2d3868c1a5ec1a66f9
-README.zh.md: a3f7710e3e442cd99e739c4cb9be13886d25cb0c
+README.md: b0c0378c13badffa8322bc0c70ed0071714af70c
+README.zh.md: 35f5e685220f0fbbaf682193e5e3cb99266eb87e

+ 7 - 2
packages/experimental/browser-use-runtime/README.md

@@ -29,7 +29,11 @@ This public experimental library is a dependency of the browser providers. It ha
 
 Native providers construct `SessionResources` from the package root, supplying resource acquisition and cleanup callbacks. Calls pass the exact live Agent to `run()`; stale owners and a second owner of an exclusive attachment fail before acquisition. Canceling an acquisition wait leaves initialization available to other callers in the same Session; Session disposal aborts and awaits that initialization. Providers keep their registration until `dispose()` finishes.
 
-MCP providers use `mountSessionMcp` from `@deepseek-ai/dsh-experimental-browser-use-runtime/mcp`. They supply their fixed server name, executable, arguments, and ownership policy. The helper mounts one scoped MCP client per live Session and discovers tools during `system-prompt/prepare`, before ordinary tool-schema collection for its first model request. A busy attachment leaves other Sessions without this provider's tools while their turns continue; after cleanup, a later request can acquire it. Startup or discovery failure rejects that step after cleanup. Reconnection is disabled; a closed client does not silently replace the Session's browser state.
+MCP providers use `mountSessionMcp` from `@deepseek-ai/dsh-experimental-browser-use-runtime/mcp`, supplying their fixed server name, executable, arguments, and ownership policy. The helper observes future `agent/created` events and immediately claims each Agent's existing `runMaintenance` phase for one scoped client startup and discovery attempt. Queued input waits until maintenance settles; a successful client remains owned by that Session across turns.
+
+A busy attachment skips startup permanently for that live activation while its other work continues. Releasing the attachment does not retry skipped activations; a newly created or resumed Agent can acquire it. A failed startup remains failed for the activation and rejects prompt assembly and model requests. User cancellation during startup likewise leaves that activation failed, even if Session-owned acquisition later succeeds; disposal still closes the owned resource. Reconnection is disabled. Loading or reloading a provider applies only to future Agent activations.
+
+Direct callers inspecting prompt assembly or scoped tool definitions after Agent creation must first await `agent.whenIdle()`. That wait establishes startup settlement, not success; assembly still reports a retained startup failure.
 
 Browser tools and resource requests targeting this server use the same queue and require the calling Session's own connection. Other MCP servers remain usable. Inherited server instructions are omitted without ownership; the shared server-name inventory keeps its normal scope behavior.
 
@@ -45,7 +49,7 @@ The [resource manager](src/index.ts) keys ownership by live Agent identity and j
 
 Disposed-cause cancellation starts resource cleanup before AgentHandle waits for idle. Cleanup closes resources before waiting for running operations, allowing connection teardown to interrupt upstream APIs without abort support. A failed close rejects disposal and retains ownership. Agent-scoped cleanup prevents a resumed Session with the same durable id from inheriting a previous browser.
 
-The [MCP helper](src/mcp.ts) places discovery and execution in each Agent's scope. It retains the provider registration through resource cleanup and uses the [MCP client](../../mcp/mcp-client/README.md) for transport, schema discovery, result conversion, and durable image admission.
+The [MCP helper](src/mcp.ts) claims maintenance synchronously when the Agent is published, so input cannot race initial discovery. MCP client activation completes within that maintenance task; prompt assembly neither initiates startup nor recursively waits for it. The helper retains the provider registration through resource cleanup and uses the [MCP client](../../mcp/mcp-client/README.md) for transport, schema discovery, result conversion, and durable image admission.
 
 No runtime invariant companion is published: resource ownership and pending work are private lifecycle state, with no separately maintained runtime projection to compare. Owner tests cover isolation, disposal, and failed cleanup.
 
@@ -77,6 +81,7 @@ The library adds no prompt text. Discovered tool schemas and provider guidance d
 
 Providers remain responsible for the browser operations they supply.
 
+- **MCP activation** — providers do not adopt existing live Agents; skipped or failed startup is not retried within that activation.
 - **Attachment scope** — exclusive ownership applies to one resource manager, not separate providers, processes, or external browser clients.
 - **Cancellation** — abort signals and connection closure cannot undo browser actions already delivered. An upstream operation that ignores both can delay cleanup.
 - **Recovery** — a failed close retains ownership; this manager does not retry disposal or restore browser state from the Session log.

+ 7 - 2
packages/experimental/browser-use-runtime/README.zh.md

@@ -29,7 +29,11 @@ kind: "package-library"
 
 原生提供方从包根入口构造 `SessionResources`,提供资源获取与清理回调。调用向 `run()` 传递确切的实时 Agent;失效的所有者与独占附加的第二个所有者在获取资源前失败。取消资源获取等待不会终止初始化,同一 Session 的其他调用方仍可继续等待;释放 Session 会中止并等待该初始化结束。提供方将注册保留到 `dispose()` 完成。
 
-MCP 提供方使用 `@deepseek-ai/dsh-experimental-browser-use-runtime/mcp` 中的 `mountSessionMcp`。它们提供固定服务器名称、可执行文件、参数与所有权策略。辅助库为每个实时 Session 挂载一个有作用域的 MCP 客户端,并在 `system-prompt/prepare` 期间发现工具,先于其首次模型请求的常规工具 schema 收集。附加连接被占用时,其他 Session 不获得此提供方的工具,但可继续各自的轮次;清理完成后,后续请求可以获取该连接。启动或发现失败会在清理后拒绝该步骤。重连已禁用;已关闭的客户端不会静默替换 Session 的浏览器状态。
+MCP 提供方使用 `@deepseek-ai/dsh-experimental-browser-use-runtime/mcp` 中的 `mountSessionMcp`,提供固定服务器名称、可执行文件、参数与所有权策略。辅助库观察后续的 `agent/created` 事件,并立即占用各 Agent 现有的 `runMaintenance` 阶段,执行一次有作用域的客户端启动与发现尝试。排队输入等待维护结束;成功的客户端跨轮次归该 Session 所有。
+
+附加连接被占用时,本次激活永久跳过启动,但其他工作继续运行。连接释放不会触发被跳过激活的重试;新创建或恢复的 Agent 可以获取连接。启动失败会保留为本次激活的失败状态,并拒绝提示词组装和模型请求。用户在启动期间取消操作,也会使本次激活保持失败,即使 Session 自有的资源获取随后成功;最终释放仍会关闭该资源。重连已禁用。加载或重新加载提供方只作用于后续的 Agent 激活。
+
+Agent 创建后,直接检查提示词组装或作用域工具定义的调用方必须先等待 `agent.whenIdle()`。等待只表示启动已结束,不代表成功;组装仍会报告保留的启动失败。
 
 指向此服务器的浏览器工具调用与资源请求使用同一队列,且要求调用 Session 自己拥有连接。其他 MCP 服务器仍可使用。没有所有权时会省略继承的服务器指导;共享服务器名称目录保留常规作用域行为。
 
@@ -45,7 +49,7 @@ MCP 提供方使用 `@deepseek-ai/dsh-experimental-browser-use-runtime/mcp` 中
 
 因释放而取消时,在 AgentHandle 等待空闲前开始资源清理。清理先关闭资源再等待运行中的操作,使连接清理能够中断不支持 abort 的上游 API。关闭失败会拒绝释放并保留所有权。Agent 作用域清理防止使用同一持久 id 恢复的 Session 继承之前的浏览器。
 
-[MCP 辅助库](src/mcp.ts)将发现与执行放入各 Agent 的作用域。它在资源清理期间保留提供方注册,并通过 [MCP 客户端](../../mcp/mcp-client/README.zh.md)处理传输、schema 发现、结果转换与持久图像接纳。
+[MCP 辅助库](src/mcp.ts)在 Agent 发布时同步占用维护阶段,使输入无法与首次发现并发执行。MCP 客户端激活在该维护任务中完成;提示词组装既不发起启动,也不递归等待启动。辅助库在资源清理期间保留提供方注册,并通过 [MCP 客户端](../../mcp/mcp-client/README.zh.md)处理传输、schema 发现、结果转换与持久图像接纳。
 
 不发布运行时不变量伴随入口:资源所有权与待处理工作是私有生命周期状态,没有可供比较的独立维护运行时投影。所属测试覆盖隔离、释放与清理失败。
 
@@ -77,6 +81,7 @@ MCP 提供方使用 `@deepseek-ai/dsh-experimental-browser-use-runtime/mcp` 中
 
 提供方仍负责自己提供的浏览器操作。
 
+- **MCP 激活** — 提供方不接管已有的活动 Agent;跳过或失败的启动不会在本次激活中重试。
 - **附加范围** — 独占所有权作用于一个资源管理器,不约束独立提供方、进程或外部浏览器客户端。
 - **取消** — abort 信号与连接关闭无法撤销已交付的浏览器操作。同时忽略两者的上游操作可能延迟清理。
 - **恢复** — 关闭失败会保留所有权;此管理器不重试释放,也不从 Session 日志恢复浏览器状态。

+ 1 - 0
packages/experimental/browser-use-runtime/package.json

@@ -54,6 +54,7 @@
     "@deepseek-ai/dsh-session-projection": "workspace:^",
     "@deepseek-ai/dsh-system-prompt": "workspace:^",
     "@deepseek-ai/dsh-mcp-resources": "workspace:^",
+    "@deepseek-ai/dsh-ptc-runtime": "workspace:^",
     "@deepseek-ai/dsh-llm": "workspace:^",
     "@deepseek-ai/cordis-plugin-loader": "workspace:^",
     "@deepseek-ai/cordis-plugin-include": "workspace:^",

+ 70 - 31
packages/experimental/browser-use-runtime/src/mcp.ts

@@ -85,19 +85,42 @@ export interface SessionMcpOptions {
   toolCallTimeoutMs?: number
 }
 
+interface ClientState {
+  status: { kind: 'pending' | 'ready' | 'blocked' } | { kind: 'failed'; error: unknown }
+  mask?: Scope
+}
+
 /**
- * Reserve browser use and discover one MCP catalog before each Session's first request.
- * A busy attachment contributes no browser tools to other Sessions, which can continue their turns.
+ * Initialize one MCP client during each future Agent's first maintenance task.
+ * A busy attachment leaves that activation without browser tools; its other turns continue.
  * Calls are serialized per Session; unload closes every server before releasing registration.
- * @param ctx - provider context supplying browser use, Agents, and tools.
+ * @param ctx - provider context supplying browser use, Agents, tools, and prompt assembly.
  * @param options - provider identity, attachment exclusivity, and executable configuration.
  */
 export function mountSessionMcp(ctx: Context, options: SessionMcpOptions): void {
   let resources!: SessionResources<Scope>
-  const readyAgents = new WeakSet<Agent>()
-  const inheritedMasks = new WeakMap<Agent, Scope>()
+  const clients = new Map<Agent, ClientState>()
   const toolPrefix = `mcp__${options.name}__`
   const resourceTools = new Set(['list_mcp_resources', 'list_mcp_resource_templates', 'read_mcp_resource'])
+  let stopping = false
+  let refreshingMasks = false
+
+  const refreshBlockedMasks = (): void => {
+    if (stopping || refreshingMasks) return
+    refreshingMasks = true
+    try {
+      for (const [agent, state] of clients) {
+        if (state.status.kind !== 'blocked') continue
+        const inherited = ctx.tools.schemas(agent).filter(tool => tool.name.startsWith(toolPrefix))
+        if (inherited.length === 0) continue
+        state.mask ??= createScope(ctx, agent)
+        state.mask.ctx.tools.restrict({ deny: inherited.map(tool => tool.name) })
+      }
+    } finally {
+      refreshingMasks = false
+    }
+  }
+
   ctx.effect(function* () {
     yield ctx.browserUse.register(BrowserUseProviderName(options.name))
     resources = new SessionResources(ctx, {
@@ -130,11 +153,10 @@ export function mountSessionMcp(ctx: Context, options: SessionMcpOptions): void
             reconnect: { enabled: false },
           }))
           signal.throwIfAborted()
-          readyAgents.add(agent)
           return {
             value: scope,
             close() {
-              readyAgents.delete(agent)
+              clients.delete(agent)
               return scope.dispose()
             },
           }
@@ -146,15 +168,53 @@ export function mountSessionMcp(ctx: Context, options: SessionMcpOptions): void
         }
       },
     })
-    yield () => resources.dispose()
+    yield async () => {
+      stopping = true
+      await resources.dispose()
+      clients.clear()
+    }
   }, `${options.name}.sessions`)
+  ctx.systemPrompt.tools(({ agent, scope }) => {
+    const status = clients.get((agent ?? scope) as Agent)?.status
+    if (status?.kind === 'pending') throw new Error(`${options.name}: browser initialization is pending`)
+    if (status?.kind === 'failed') throw status.error
+    return { schemas: [] }
+  })
+  ctx.on('agent/created', ({ agent }) => {
+    const state: ClientState = { status: { kind: resources.available(agent) ? 'pending' : 'blocked' } }
+    clients.set(agent, state)
+    agent.ctx.effect(() => async () => {
+      clients.delete(agent)
+      await state.mask?.dispose()
+    }, `${options.name}.activation`)
+    if (state.status.kind === 'blocked') {
+      refreshBlockedMasks()
+      return
+    }
+    try {
+      const startup = agent.runMaintenance(async (signal) => {
+        try {
+          await resources.run(agent, signal, async () => {})
+          state.status = { kind: 'ready' }
+        } catch (error) {
+          state.status = { kind: 'failed', error }
+          throw error
+        }
+      })
+      // The synchronous prompt guard reports the captured failure before model dispatch.
+      void startup.catch(() => {})
+    } catch (error) {
+      state.status = { kind: 'failed', error }
+    }
+  }, { prepend: true })
+  ctx.on('tools/change', refreshBlockedMasks)
   ctx.on('tools/execute', async (exec, next) => {
     const ownResource = resourceTools.has(exec.name)
       && typeof exec.arguments === 'object' && exec.arguments !== null
       && (exec.arguments as { server?: unknown }).server === options.name
     if (!exec.name.startsWith(toolPrefix) && !ownResource) return next()
     const agent = exec.agent
-    if (agent === undefined || !readyAgents.has(agent)) {
+    if (agent === undefined || clients.get(agent)?.status.kind !== 'ready') {
       throw new Error(`${options.name}: browser tool belongs to another Session`)
     }
     return resources.run(agent, exec.signal, async (_scope, combined) => {
@@ -167,30 +227,9 @@ export function mountSessionMcp(ctx: Context, options: SessionMcpOptions): void
       }
     })
   })
-  ctx.on('system-prompt/prepare', async ({ agent, signal }) => {
-    if (agent === undefined) return
-    signal?.throwIfAborted()
-    if (!resources.available(agent)) {
-      const inherited = ctx.tools.schemas(agent).filter(tool => tool.name.startsWith(toolPrefix))
-      if (inherited.length > 0) {
-        let scope = inheritedMasks.get(agent)
-        if (scope === undefined) {
-          scope = createScope(ctx, agent)
-          const owned = scope
-          agent.ctx.effect(() => () => owned.dispose(), `${options.name}.inherited-tools`)
-          inheritedMasks.set(agent, scope)
-        }
-        // Restrictions hide inherited tools; this Agent's later own registrations remain visible.
-        scope.ctx.tools.restrict({ deny: inherited.map(tool => tool.name) })
-      }
-      return
-    }
-    await resources.get(agent, signal)
-    signal?.throwIfAborted()
-  })
   ctx.on('system-prompt/assemble', async (_assembly, { agent }, next) => {
     const assembly = await next()
-    if (agent === undefined || readyAgents.has(agent)) return assembly
+    if (agent === undefined || clients.get(agent)?.status.kind === 'ready') return assembly
     return { ...assembly, sections: assembly.sections.filter(section => section.name !== `mcp:${options.name}`) }
   })
 }

+ 12 - 2
packages/experimental/browser-use-runtime/tests/mcp-fixture.mjs

@@ -1,5 +1,5 @@
 /** Private stdio browser fixture; each process owns independent state. */
-import { appendFileSync } from 'node:fs'
+import { appendFileSync, existsSync, watch } from 'node:fs'
 import { join } from 'node:path'
 import { createInterface } from 'node:readline'
 
@@ -19,7 +19,17 @@ lines.on('line', line => {
       record('probe')
       if (mode === 'fail') process.exit(1)
       if (mode === 'hold') return
-      process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: request.id, error: { code: -32601, message: 'Legacy browser fixture' } }) + '\n')
+      {
+        const respond = () => process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: request.id, error: { code: -32601, message: 'Legacy browser fixture' } }) + '\n')
+        if (mode === 'gate' && !existsSync(join(root, 'release'))) {
+          const watcher = watch(root, () => {
+            if (!existsSync(join(root, 'release'))) return
+            watcher.close()
+            respond()
+          })
+          if (existsSync(join(root, 'release'))) { watcher.close(); respond() }
+        } else respond()
+      }
       break
     case 'initialize':
       record('initialize')

+ 1 - 0
packages/experimental/browser-use-runtime/tests/mcp-upstream.ts

@@ -74,6 +74,7 @@ export async function verifyMcpBrowser(
     await ctx.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(configPath).href } })
     await ctx.loader.await()
     const owner = await ctx.agents.create({ sessionId: SessionId('browser-upstream'), meta: { cwd: root } })
+    await owner.agent.whenIdle()
     await ctx.systemPrompt.assemble({ agent: owner.agent, scope: owner.agent, signal: new AbortController().signal })
     const schemas = ctx.tools.schemas(owner.agent)
     expect(schemas.length).toBeGreaterThan(5)

+ 157 - 34
packages/experimental/browser-use-runtime/tests/mcp.spec.ts

@@ -10,6 +10,7 @@ import BrowserUse from '@deepseek-ai/dsh-browser-use'
 import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
 import Tools from '@deepseek-ai/dsh-tools'
 import McpResources from '@deepseek-ai/dsh-mcp-resources'
+import { PtcRuntime } from '@deepseek-ai/dsh-ptc-runtime'
 import Llm, { LlmAdapter, ToolCallId, createUserMessage } from '@deepseek-ai/dsh-llm'
 import type { GenerateOptions, LlmResolvedModelInfo, StreamChunk } from '@deepseek-ai/dsh-llm'
 import Sessions, { SessionId } from '@deepseek-ai/dsh-session'
@@ -52,7 +53,14 @@ class FixtureModel extends LlmAdapter {
   }
 }
 
-async function load(exclusive = false, mode?: string, toolCallTimeoutMs?: number) {
+class PresentationRuntime extends PtcRuntime {
+  readonly language = 'typescript'
+  readonly isolation = 'fixture'
+  resolve(): never { throw new Error('Unexpected PTC execution in a presentation test') }
+  run(): Promise<never> { return Promise.reject(new Error('Unexpected PTC execution in a presentation test')) }
+}
+
+async function load(exclusive = false, mode?: string, toolCallTimeoutMs?: number, toolOrder?: string[]) {
   const root = await mkdtemp(join(tmpdir(), 'dsh-browser-mcp-'))
   roots.push(root)
   const model = new FixtureModel()
@@ -60,12 +68,12 @@ async function load(exclusive = false, mode?: string, toolCallTimeoutMs?: number
     ['browserUse', BrowserUse], ['prompt', SystemPrompt], ['tools', Tools], ['llm', Llm],
     ['sessions', Sessions], ['agents', Agents], ['loop', AgentLoop], ['projections', Projections],
     ['model', { inject: ['llm'], apply(ctx: Context) { ctx.effect(() => ctx.llm.registerAdapter(['fixture'], model)) } }],
-    ['browser', { inject: ['browserUse', 'agents', 'tools'], apply(ctx: Context) {
+    ['browser', { inject: ['browserUse', 'agents', 'tools', 'systemPrompt'], apply(ctx: Context) {
       mountSessionMcp(ctx, { name: 'browser-fixture', exclusive, command: process.execPath, args: [fixture, root, ...mode === undefined ? [] : [mode]], ...toolCallTimeoutMs === undefined ? {} : { toolCallTimeoutMs, env: {} } })
     } }],
   ])
   const configPath = join(root, 'cordis.yml')
-  await writeFile(configPath, JSON.stringify([...modules.keys()].map(name => ({ id: name, name, config: name === 'loop' ? { agents: [] } : {} }))))
+  await writeFile(configPath, JSON.stringify([...modules.keys()].map(name => ({ id: name, name, config: name === 'loop' ? { agents: [] } : name === 'prompt' && toolOrder !== undefined ? { toolOrder } : {} }))))
   const ctx = new Context()
   contexts.push(ctx)
   ctx.baseUrl = pathToFileURL(root).href + '/'
@@ -84,7 +92,8 @@ async function load(exclusive = false, mode?: string, toolCallTimeoutMs?: number
   return { ctx, root, model, browser }
 }
 
-function warm(ctx: Context, agent: Agent, signal = new AbortController().signal) {
+async function warm(ctx: Context, agent: Agent, signal = new AbortController().signal) {
+  await agent.whenIdle()
   return ctx.systemPrompt.assemble({ agent, scope: agent, signal })
 }
 
@@ -130,14 +139,14 @@ it('requires a browser mode and validates launch and attachment settings', () =>
   }
 })
 
-it('waits for SystemPrompt through the required ToolRuntime before reserving the provider', async () => {
+it('waits for SystemPrompt and ToolRuntime before reserving the provider', async () => {
   const ctx = new Context()
   contexts.push(ctx)
   await ctx.plugin(BrowserUse)
   await ctx.plugin(Agents)
   const tools = ctx.plugin(Tools)
   const browser = ctx.plugin({
-    inject: ['browserUse', 'agents', 'tools'],
+    inject: ['browserUse', 'agents', 'tools', 'systemPrompt'],
     apply(provider: Context) {
       mountSessionMcp(provider, { name: 'browser-fixture', exclusive: true, command: process.execPath, args: [fixture] })
     },
@@ -152,7 +161,7 @@ it('waits for SystemPrompt through the required ToolRuntime before reserving the
 
 describe('Session MCP Loader composition', () => {
   it('discovers schemas before the first model request and retains distinct state across turns', async () => {
-    const { ctx, root, model, browser } = await load(false, undefined, 5000)
+    const { ctx, root, model, browser } = await load(false, undefined, 5000, [TOOL, '<unlisted-tools>'])
     const first = await ctx.agents.create({ sessionId: SessionId('first'), meta: { cwd: root }, agentOptions: { provider: 'fixture', model: 'fixture' } })
     const second = await ctx.agents.create({ sessionId: SessionId('second') })
     expect(ctx.tools.schemas()).toEqual([])
@@ -188,14 +197,17 @@ describe('Session MCP Loader composition', () => {
   })
 
   it('keeps unrelated and child Sessions running without a busy attachment and admits a later owner', async () => {
-    const { ctx, root, model } = await load(true)
+    const { ctx, root, model } = await load(true, 'gate')
     registerIndependentTool(ctx)
     const first = await ctx.agents.create({ sessionId: SessionId('first') })
     const second = await ctx.agents.create({ sessionId: SessionId('second'), agentOptions: { provider: 'fixture', model: 'fixture' } })
-    const child = await ctx.agents.create({ sessionId: SessionId('child'), agentOptions: { provider: 'fixture', model: 'fixture' } })
-    bindScopeParent(child.agent, first.agent)
+    const child = await ctx.agents.create({
+      sessionId: SessionId('child'), parentAgent: first.agent, agentOptions: { provider: 'fixture', model: 'fixture' },
+      setup: (_inner, agent) => { bindScopeParent(agent, first.agent) },
+    })
+    await writeFile(join(root, 'release'), '')
     await warm(ctx, first.agent)
-    expect(ctx.tools.schemas(child.agent).some(tool => tool.name === TOOL)).toBe(true)
+    expect(ctx.tools.schemas(child.agent).some(tool => tool.name === TOOL)).toBe(false)
     for (const { agent } of [second, child]) {
       agent.followup(createUserMessage({ content: [{ type: 'text', text: 'Answer without using the browser.' }], source: { kind: 'user' } }))
     }
@@ -213,40 +225,47 @@ describe('Session MCP Loader composition', () => {
       output: { schema: { type: 'boolean' }, render: () => [{ type: 'text', text: 'Late browser result.' }] },
       execute: async () => true,
     })
-    expect(ctx.tools.schemas(child.agent).filter(tool => tool.name.startsWith('mcp__browser-fixture__')).map(tool => tool.name)).toEqual(['mcp__browser-fixture__late'])
+    expect(ctx.tools.schemas(child.agent).filter(tool => tool.name.startsWith('mcp__browser-fixture__'))).toEqual([])
     expect((await warm(ctx, child.agent)).tools.map(tool => tool.name)).toEqual(['unrelated'])
     expect((await execute(ctx, child.agent, 'mcp__browser-fixture__late')).isError).toBe(true)
     expect((await events(root)).filter(event => event.event === 'initialize')).toHaveLength(1)
     expect((await events(root)).filter(event => event.event === 'call')).toEqual([])
     expect((await execute(ctx, first.agent)).isError).toBe(false)
     await first.dispose()
-    expect((await warm(ctx, child.agent)).tools.some(tool => tool.name === TOOL)).toBe(true)
-    expect((await execute(ctx, child.agent)).isError).toBe(false)
+    expect((await warm(ctx, child.agent)).tools.some(tool => tool.name === TOOL)).toBe(false)
+    expect((await execute(ctx, child.agent)).isError).toBe(true)
     expect((await warm(ctx, second.agent)).tools.map(tool => tool.name)).toEqual(['unrelated'])
-    await child.dispose()
-    expect((await warm(ctx, second.agent)).tools.some(tool => tool.name === TOOL)).toBe(true)
-    expect((await execute(ctx, second.agent)).isError).toBe(false)
+    const successor = await ctx.agents.create({ sessionId: SessionId('successor') })
+    expect((await warm(ctx, successor.agent)).tools.some(tool => tool.name === TOOL)).toBe(true)
+    expect((await execute(ctx, successor.agent)).isError).toBe(false)
+    await successor.dispose()
+    await second.dispose()
+    const resumed = await ctx.agents.create({ sessionId: SessionId('second') })
+    expect((await warm(ctx, resumed.agent)).tools.some(tool => tool.name === TOOL)).toBe(true)
+    expect((await execute(ctx, resumed.agent)).isError).toBe(false)
   })
 
   it('allows another Session to answer while attached-browser discovery is pending', async () => {
     const { ctx, root, model, browser } = await load(true, 'hold')
     const first = await ctx.agents.create({ sessionId: SessionId('first') })
     const second = await ctx.agents.create({ sessionId: SessionId('second'), agentOptions: { provider: 'fixture', model: 'fixture' } })
-    const preparing = warm(ctx, first.agent).catch((error: unknown) => error)
     await vi.waitFor(async () => { expect((await events(root)).some(event => event.event === 'probe')).toBe(true) })
+    await expect(ctx.systemPrompt.assemble({ agent: first.agent, scope: first.agent })).rejects.toThrow('initialization is pending')
     second.agent.followup(createUserMessage({ content: [{ type: 'text', text: 'Answer without using the browser.' }], source: { kind: 'user' } }))
     await second.agent.whenIdle()
     expect(model.requests).toHaveLength(1)
     expect(model.requests[0]?.tools ?? []).toEqual([])
     expect((await events(root)).filter(event => event.event === 'start')).toHaveLength(1)
     await browser.dispose()
-    expect(await preparing).toBeInstanceOf(Error)
+    await first.agent.whenIdle()
   })
 
   it('rolls back failed discovery and stops a child when unload interrupts discovery', async () => {
-    const failed = await load(false, 'fail')
-    const owner = await failed.ctx.agents.create({ sessionId: SessionId('failure') })
+    const failed = await load(false, 'fail', undefined, [TOOL, '<unlisted-tools>'])
+    const owner = await failed.ctx.agents.create({ sessionId: SessionId('failure'), agentOptions: { provider: 'fixture', model: 'fixture' } })
+    owner.agent.followup(createUserMessage({ content: [{ type: 'text', text: 'Visit the fixture.' }], source: { kind: 'user' } }))
     await expect(warm(failed.ctx, owner.agent)).rejects.toThrow('initial connection')
+    expect(failed.model.requests).toEqual([])
     expect(failed.ctx.tools.schemas(owner.agent)).toEqual([])
     const failedEvents = await events(failed.root)
     expect(failedEvents.filter(event => event.event === 'initialize')).toHaveLength(1)
@@ -255,10 +274,9 @@ describe('Session MCP Loader composition', () => {
     }
     const held = await load(false, 'hold')
     const pendingOwner = await held.ctx.agents.create({ sessionId: SessionId('pending') })
-    const pending = warm(held.ctx, pendingOwner.agent).catch((error: unknown) => error)
     await vi.waitFor(async () => { expect((await events(held.root)).some(event => event.event === 'probe')).toBe(true) })
     await held.browser.dispose()
-    expect(await pending).toBeInstanceOf(Error)
+    await pendingOwner.agent.whenIdle()
     for (const { pid } of (await events(held.root)).filter(event => event.event === 'start')) {
       expect(() => process.kill(pid, 0)).toThrow(expect.objectContaining({ code: 'ESRCH' }))
     }
@@ -277,16 +295,16 @@ describe('Session MCP Loader composition', () => {
   })
 
   it('keeps browser ownership exact when scopes inherit tools and leaves unrelated tools usable', async () => {
-    const { ctx, root } = await load()
+    const { ctx } = await load()
     await ctx.systemPrompt.assemble()
     const owner = await ctx.agents.create({ sessionId: SessionId('parent') })
-    const child = await ctx.agents.create({ sessionId: SessionId('child') })
-    bindScopeParent(child.agent, owner.agent)
+    const child = await ctx.agents.create({
+      sessionId: SessionId('child'), parentAgent: owner.agent,
+      setup: (_inner, agent) => { bindScopeParent(agent, owner.agent) },
+    })
     await warm(ctx, owner.agent)
     registerIndependentTool(ctx)
     expect((await execute(ctx, owner.agent, 'unrelated')).content).toEqual([{ type: 'text', text: 'Independent.' }])
-    expect((await execute(ctx, child.agent)).content).toEqual([{ type: 'text', text: 'Error: browser-fixture: browser tool belongs to another Session' }])
-    expect((await events(root)).filter(event => event.event === 'call')).toEqual([])
     await warm(ctx, child.agent)
     expect((await execute(ctx, child.agent)).content).toEqual([{ type: 'text', text: 'Visit 1: direct' }])
   })
@@ -296,8 +314,10 @@ describe('Session MCP Loader composition', () => {
     await ctx.plugin(McpResources)
     ctx.mcpResources.register('docs', { request: async () => ({ contents: [{ uri: 'docs://memo', text: 'Independent document.' }] }) })
     const parent = await ctx.agents.create({ sessionId: SessionId('resource-parent') })
-    const child = await ctx.agents.create({ sessionId: SessionId('resource-child') })
-    bindScopeParent(child.agent, parent.agent)
+    const child = await ctx.agents.create({
+      sessionId: SessionId('resource-child'), parentAgent: parent.agent,
+      setup: (_inner, agent) => { bindScopeParent(agent, parent.agent) },
+    })
     expect(renderPrompt(await warm(ctx, parent.agent))).toContain('BROWSER_FIXTURE_INSTRUCTION')
     const blocked = await warm(ctx, child.agent)
     expect(blocked.tools.some(tool => tool.name === TOOL)).toBe(false)
@@ -315,16 +335,21 @@ describe('Session MCP Loader composition', () => {
     }
     expect(browserState(await resource(ctx, parent.agent)).counter).toBe(0)
     await parent.dispose()
-    expect(renderPrompt(await warm(ctx, child.agent))).toContain('BROWSER_FIXTURE_INSTRUCTION')
-    expect(browserState(await resource(ctx, child.agent)).counter).toBe(0)
+    expect(renderPrompt(await warm(ctx, child.agent))).not.toContain('BROWSER_FIXTURE_INSTRUCTION')
+    expect((await resource(ctx, child.agent)).isError).toBe(true)
+    const successor = await ctx.agents.create({ sessionId: SessionId('resource-successor') })
+    expect(renderPrompt(await warm(ctx, successor.agent))).toContain('BROWSER_FIXTURE_INSTRUCTION')
+    expect(browserState(await resource(ctx, successor.agent)).counter).toBe(0)
   })
 
   it('uses the child connection for resources and serializes them with browser tools exactly once', async () => {
     const { ctx } = await load()
     await ctx.plugin(McpResources)
     const parent = await ctx.agents.create({ sessionId: SessionId('parent') })
-    const child = await ctx.agents.create({ sessionId: SessionId('child') })
-    bindScopeParent(child.agent, parent.agent)
+    const child = await ctx.agents.create({
+      sessionId: SessionId('child'), parentAgent: parent.agent,
+      setup: (_inner, agent) => { bindScopeParent(agent, parent.agent) },
+    })
     await warm(ctx, parent.agent)
     await execute(ctx, parent.agent)
     await warm(ctx, child.agent)
@@ -369,4 +394,102 @@ describe('Session MCP Loader composition', () => {
       stop()
     }
   })
+
+  it('holds an immediate PTC turn until the first SDK includes browser tools', async () => {
+    const { ctx, root, model } = await load(false, 'gate', undefined, ['run_code', '<unlisted-tools>'])
+    await ctx.plugin(PresentationRuntime)
+    const owner = await ctx.agents.create({
+      sessionId: SessionId('ptc-first-request'), agentOptions: { provider: 'fixture', model: 'fixture' },
+      setup: (inner) => { inner.tools.presentAs('ptc') },
+    })
+    owner.agent.followup(createUserMessage({ content: [{ type: 'text', text: 'Describe the available browser tools.' }], source: { kind: 'user' } }))
+    await vi.waitFor(async () => { expect((await events(root)).some(event => event.event === 'probe')).toBe(true) })
+    expect(model.requests).toEqual([])
+    await writeFile(join(root, 'release'), '')
+    await owner.agent.whenIdle()
+    expect(model.requests).toHaveLength(1)
+    expect(model.requests[0]?.tools?.map(tool => tool.name)).toEqual(['run_code'])
+    expect(JSON.stringify(model.requests[0]?.messages)).toContain(TOOL)
+  })
+
+  it('keeps a canceled startup failed and disposes its later-completing owned client', async () => {
+    const { ctx, root, model } = await load(false, 'gate')
+    const owner = await ctx.agents.create({ sessionId: SessionId('canceled-startup'), agentOptions: { provider: 'fixture', model: 'fixture' } })
+    await vi.waitFor(async () => { expect((await events(root)).some(event => event.event === 'probe')).toBe(true) })
+    owner.agent.cancel({ kind: 'user' })
+    await owner.agent.whenIdle()
+    await expect(warm(ctx, owner.agent)).rejects.toThrow('browser operation canceled')
+    await writeFile(join(root, 'release'), '')
+    await vi.waitFor(() => { expect(ctx.tools.schemas(owner.agent).some(tool => tool.name === TOOL)).toBe(true) })
+    owner.agent.followup(createUserMessage({ content: [{ type: 'text', text: 'Visit the fixture.' }], source: { kind: 'user' } }))
+    await owner.agent.whenIdle()
+    expect(model.requests).toEqual([])
+    expect((await execute(ctx, owner.agent)).isError).toBe(true)
+    await owner.dispose()
+    for (const { pid } of (await events(root)).filter(event => event.event === 'start')) {
+      expect(() => process.kill(pid, 0)).toThrow(expect.objectContaining({ code: 'ESRCH' }))
+    }
+  })
+
+  it('retains a maintenance claim failure before dispatching queued model work', async () => {
+    const { ctx, model } = await load(false, undefined, undefined, [TOOL, '<unlisted-tools>'])
+    const release = Promise.withResolvers<undefined>()
+    try {
+      const owner = await ctx.agents.create({
+        sessionId: SessionId('claimed-maintenance'), agentOptions: { provider: 'fixture', model: 'fixture' },
+        setup: (inner) => {
+          inner.on('agent/created', ({ agent }) => { void agent.runMaintenance(() => release.promise) }, { prepend: true })
+        },
+      })
+      owner.agent.followup(createUserMessage({ content: [{ type: 'text', text: 'Visit the fixture.' }], source: { kind: 'user' } }))
+      release.resolve(undefined)
+      await owner.agent.whenIdle()
+      await expect(warm(ctx, owner.agent)).rejects.toThrow('already has active work')
+      expect(model.requests).toEqual([])
+      expect(ctx.tools.schemas(owner.agent)).toEqual([])
+      expect((await execute(ctx, owner.agent)).isError).toBe(true)
+    } finally {
+      release.resolve(undefined)
+    }
+  })
+
+  it('retains cancellation from a later creation listener before the startup operation runs', async () => {
+    const { ctx, root, model } = await load(false, 'gate')
+    const cause = { kind: 'user' as const }
+    const owner = await ctx.agents.create({
+      sessionId: SessionId('canceled-at-creation'), agentOptions: { provider: 'fixture', model: 'fixture' },
+      setup: (inner) => {
+        inner.on('agent/created', ({ agent }) => { agent.cancel(cause) })
+      },
+    })
+    await owner.agent.whenIdle()
+    await expect(warm(ctx, owner.agent)).rejects.toBe(cause)
+    owner.agent.followup(createUserMessage({ content: [{ type: 'text', text: 'Visit the fixture.' }], source: { kind: 'user' } }))
+    await owner.agent.whenIdle()
+    expect(model.requests).toEqual([])
+    await writeFile(join(root, 'release'), '')
+    await vi.waitFor(() => { expect(ctx.tools.schemas(owner.agent).some(tool => tool.name === TOOL)).toBe(true) })
+    expect((await execute(ctx, owner.agent)).isError).toBe(true)
+    await owner.dispose()
+    for (const { pid } of (await events(root)).filter(event => event.event === 'start')) {
+      expect(() => process.kill(pid, 0)).toThrow(expect.objectContaining({ code: 'ESRCH' }))
+    }
+  })
+
+  it('initializes only future activations after provider reload', async () => {
+    const { ctx, root, browser } = await load()
+    await browser.dispose()
+    const existing = await ctx.agents.create({ sessionId: SessionId('existing') })
+    await ctx.plugin({
+      inject: ['browserUse', 'agents', 'tools', 'systemPrompt'],
+      apply(inner: Context) {
+        mountSessionMcp(inner, { name: 'browser-fixture', exclusive: false, command: process.execPath, args: [fixture, root] })
+      },
+    })
+    expect((await warm(ctx, existing.agent)).tools).toEqual([])
+    const future = await ctx.agents.create({ sessionId: SessionId('future') })
+    expect((await warm(ctx, future.agent)).tools.some(tool => tool.name === TOOL)).toBe(true)
+    expect((await execute(ctx, existing.agent)).isError).toBe(true)
+    expect((await execute(ctx, future.agent)).isError).toBe(false)
+  })
 })

+ 1 - 9
packages/extensions/tool-cordis/src/api-catalog.ts

@@ -2509,7 +2509,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
       },
       {
         signature: 'async assemble(context: AssembleContext = {}): Promise<PromptAssembly>',
-        description: 'Await scoped preparation, assemble providers, detach tool parameters, apply canonical ordering, then run the assembly waterfall. Scoped sections and variables shadow globals. The returned waterfall value is authoritative except that an effective complete section is restored afterwards as the sole prompt section.',
+        description: 'Assemble global and scoped providers, detach tool parameters, apply canonical ordering, then run the assembly waterfall. Scoped sections and variables shadow globals. The returned waterfall value is authoritative except that an effective complete section is restored afterwards as the sole prompt section.',
         parameters: [{ name: 'context', description: 'the optional scope and plugin-defined assembly fields.' }],
         returns: 'the post-waterfall assembly with any complete prompt enforced.',
       },
@@ -3528,14 +3528,6 @@ export const EVENT_API: readonly EventApiEntry[] = [
     description: 'Emitted when any prompt provider changes. This registry notification is unfiltered because a global change affects every scope.',
     parameters: [],
   },
-  {
-    name: 'system-prompt/prepare',
-    mode: 'serial',
-    signature: '\'system-prompt/prepare\'(this: Scoped<SystemPrompt>, context: AssembleContext): Promise<void>',
-    summary: 'Await scope-owned resource and catalog preparation before reading providers.',
-    description: 'Await scope-owned resource and catalog preparation before reading providers. Scope-filtered dispatch: scoped listeners prepare only their scope\'s assemblies. A rejected listener rejects this assembly; subsequent collection applies the normal visibility, presentation, and ordering rules to new registrations. The signal belongs to this assembly and must not control later turns.',
-    parameters: [{ name: 'context', description: 'the caller\'s per-assembly context and optional cancellation.' }],
-  },
   {
     name: 'tools/change',
     mode: 'emit',

+ 3 - 0
pnpm-lock.yaml

@@ -5707,6 +5707,9 @@ importers:
       '@deepseek-ai/dsh-mcp-resources':
         specifier: workspace:^
         version: link:../../mcp/mcp-resources
+      '@deepseek-ai/dsh-ptc-runtime':
+        specifier: workspace:^
+        version: link:../../ptc-runtime/ptc-runtime
       '@deepseek-ai/dsh-session':
         specifier: workspace:^
         version: link:../../core/session

+ 2 - 2
snapshots/session/browser-use-chrome-devtools-mcp/provider-fixture.mjs

@@ -22,10 +22,10 @@ export async function apply(ctx) {
   const provider = await import('@deepseek-ai/dsh-experimental-browser-use-chrome-devtools-mcp')
   await ctx.plugin(provider, { mode: 'launch' })
   if (!replaced) throw new Error('Chrome DevTools snapshot did not replace the upstream executable')
-  ctx.on('system-prompt/prepare', async ({ agent }) => {
-    if (agent === undefined) return
+  ctx.on('agent/pre-step', async (_payload, next) => {
     if (await readFile(resolve('.dsh/browser-fixture.started'), 'utf8') !== 'chrome-devtools-mcp\n') {
       throw new Error('Chrome DevTools snapshot did not start its fixture process')
     }
+    return next()
   })
 }

+ 2 - 2
snapshots/session/browser-use-playwright-mcp/provider-fixture.mjs

@@ -22,10 +22,10 @@ export async function apply(ctx) {
   const provider = await import('@deepseek-ai/dsh-experimental-browser-use-playwright-mcp')
   await ctx.plugin(provider, { mode: 'launch' })
   if (!replaced) throw new Error('Playwright snapshot did not replace the upstream executable')
-  ctx.on('system-prompt/prepare', async ({ agent }) => {
-    if (agent === undefined) return
+  ctx.on('agent/pre-step', async (_payload, next) => {
     if (await readFile(resolve('.dsh/browser-fixture.started'), 'utf8') !== 'playwright-mcp\n') {
       throw new Error('Playwright snapshot did not start its fixture process')
     }
+    return next()
   })
 }