diff --git a/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.i18n.yaml index 4573f0a33f..e4b4714af1 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.i18n.yaml @@ -24,8 +24,8 @@ en: 26e4b71366bebd1b zh: 10598e12b28c586e /agent-note-cooperative-tool-cancellation-at-the-registry-boundary/verification: - en: fbd101d4576afa92 - zh: 35f5d3c2f6cad6a8 + en: 983da6d0c09f5c10 + zh: ca7c86117a1a4d5f /agent-note-cooperative-tool-cancellation-at-the-registry-boundary/alternatives-considered: en: c4d86feb135d2ac4 zh: 67c56c0bc2213949 diff --git a/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.md b/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.md index 967c7285df..8ad0af1d21 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.md +++ b/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.md @@ -46,7 +46,7 @@ This decision requires cancellation at the tool invocation boundary only. Making ## Verification -[`execution-signal-types.spec.ts`](../../../../packages/core/tools/tests/execution-signal-types.spec.ts) proves the required exact signal types, readonly observer and tool views, mutable-but-required around-dispatch view, and `defineTool()` inference. [`tools.spec.ts`](../../../../packages/core/tools/tests/tools.spec.ts) covers pre-aborted materialization, phase skipping, policy and wrapper races, body invocation classification, caller-signal fusion, error precedence, context retention, and quiescent drainage. [`tool-calls.spec.ts`](../../../../packages/core/agent-loop/tests/tool-calls.spec.ts) and [`contract-regressions.spec.ts`](../../../../packages/core/agent-loop/tests/contract-regressions.spec.ts) cover balanced durable results for undispatched siblings. [`ptc.spec.ts`](../../../../packages/core/tools/tests/ptc.spec.ts) and first-party integration suites cover explicit forwarding, while [`timeout-policy.spec.ts`](../../../../packages/guard/timeout-policy/tests/timeout-policy.spec.ts) preserves timeout ownership. +[`execution-signal-types.spec.ts`](../../../../packages/core/tools/tests/execution-signal-types.spec.ts) proves the required exact signal types, readonly observer and tool views, mutable-but-required around-dispatch view, and `defineTool()` inference. [`tools.spec.ts`](../../../../packages/core/tools/tests/tools.spec.ts) covers pre-aborted materialization, phase skipping, policy and wrapper races, body invocation classification, caller-signal fusion, error precedence, context retention, and quiescent drainage. [`tool-calls.spec.ts`](../../../../packages/core/agent-loop/tests/tool-calls.spec.ts) and [`contract-regressions.spec.ts`](../../../../packages/core/agent-loop/tests/contract-regressions.spec.ts) cover balanced durable results for undispatched siblings. [`ptc.spec.ts`](../../../../packages/core/tool-executor-ptc/tests/ptc.spec.ts) and first-party integration suites cover explicit forwarding, while [`timeout-policy.spec.ts`](../../../../packages/guard/timeout-policy/tests/timeout-policy.spec.ts) preserves timeout ownership. No registry test can prove that arbitrary third-party same-process code observes the signal or stops in bounded time. Capability tests continue to prove cancellation and quiescence at the boundary that owns each side effect. diff --git a/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.zh.md b/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.zh.md index 87318c3316..613db6dacd 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.zh.md @@ -46,7 +46,7 @@ Status: implemented ## 验证 -[`execution-signal-types.spec.ts`](../../../../packages/core/tools/tests/execution-signal-types.spec.ts) 证明必填的精确信号类型、观察者与工具的只读视图、环绕调度可替换但不可删除的视图,以及 `defineTool()` 推断。[`tools.spec.ts`](../../../../packages/core/tools/tests/tools.spec.ts) 覆盖进入时已中止的物化与阶段跳过、策略和包装层竞态、工具主体调用分类、调用方信号融合、错误优先级、上下文保留和完全停稳。[`tool-calls.spec.ts`](../../../../packages/core/agent-loop/tests/tool-calls.spec.ts) 与 [`contract-regressions.spec.ts`](../../../../packages/core/agent-loop/tests/contract-regressions.spec.ts) 覆盖为未调度的同批调用补齐持久化结果。[`ptc.spec.ts`](../../../../packages/core/tools/tests/ptc.spec.ts) 和第一方集成测试覆盖显式转发,[`timeout-policy.spec.ts`](../../../../packages/guard/timeout-policy/tests/timeout-policy.spec.ts) 保持超时归属。 +[`execution-signal-types.spec.ts`](../../../../packages/core/tools/tests/execution-signal-types.spec.ts) 证明必填的精确信号类型、观察者与工具的只读视图、环绕调度可替换但不可删除的视图,以及 `defineTool()` 推断。[`tools.spec.ts`](../../../../packages/core/tools/tests/tools.spec.ts) 覆盖进入时已中止的物化与阶段跳过、策略和包装层竞态、工具主体调用分类、调用方信号融合、错误优先级、上下文保留和完全停稳。[`tool-calls.spec.ts`](../../../../packages/core/agent-loop/tests/tool-calls.spec.ts) 与 [`contract-regressions.spec.ts`](../../../../packages/core/agent-loop/tests/contract-regressions.spec.ts) 覆盖为未调度的同批调用补齐持久化结果。[`ptc.spec.ts`](../../../../packages/core/tool-executor-ptc/tests/ptc.spec.ts) 和第一方集成测试覆盖显式转发,[`timeout-policy.spec.ts`](../../../../packages/guard/timeout-policy/tests/timeout-policy.spec.ts) 保持超时归属。 任何注册表测试都无法证明任意第三方同进程代码会观察信号或在有界时间内停止。各能力的测试仍需在拥有相应副作用的边界证明取消与完全停稳。 diff --git a/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.i18n.yaml index fbf95020c0..9378bc4f0b 100644 --- a/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.i18n.yaml @@ -54,8 +54,8 @@ en: 7865e159528e2d30 zh: 1399d54632f4d926 /agent-note-peer-principals-and-method-level-remote-access-control/decision/method-level-access-control/access-snapshot-stream: - en: 41d7ac1faf2448d7 - zh: 6d523895a9abae68 + en: a928a63e8c36177c + zh: c943c1fdc8c4bb45 /agent-note-peer-principals-and-method-level-remote-access-control/decision/method-level-access-control/client-face: en: 7be28cd7e19e5377 zh: 30b5aee957271d6c @@ -81,8 +81,8 @@ en: 89452c3c3f042e75 zh: 9bcad00a60c65303 /agent-note-peer-principals-and-method-level-remote-access-control/consequences/verification: - en: 048067eb02e6bf8a - zh: 12508b59f6b206b8 + en: 2279a8c153cc5b89 + zh: 63c0b6462722f0a3 /agent-note-peer-principals-and-method-level-remote-access-control/consequences/costs-and-limits: en: 1e254cec680b1a37 zh: 06a31e057ba965aa diff --git a/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.md b/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.md index 324606f3ab..c9894ae409 100644 --- a/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.md +++ b/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.md @@ -446,7 +446,7 @@ export const REMOTE_ACCESS_SNAPSHOT_KEY = '__DSH_REMOTE_ACCESS__' | Behavior | Detail | |---|---| -| First snapshot | `snapshotFor(principal)` computes a page's opening baseline. remote-access adds the global `REMOTE_ACCESS_SNAPSHOT_KEY`, set to the owner baseline `{ type: 'baseline', owner: true }`, to every index the webServer renders, through `webserver/index-inject`. A business package that renders a non-owner page (such as liveshare on the owner Host) places an assignment of that global to `snapshotFor(principal)` after every index injection row; the later assignment wins, and the Client reads the global when it mounts | +| First snapshot | `snapshotFor(principal)` computes a page's opening baseline. The webServer renders each index for the Peer the page speaks for, and `webserver/index-inject` passes that Peer to listeners; remote-access adds the global `REMOTE_ACCESS_SNAPSHOT_KEY`, set to `snapshotFor(peer.principal)`, to every index, so the owner's page receives the owner baseline `{ type: 'baseline', owner: true }` and a page rendered for a non-owner Peer (such as a liveshare viewer page on the owner Host) receives that principal's grants. The Client reads the global when it mounts | | Subscription | Each Peer subscribes to one access snapshot stream, whose baseline replaces the injected one | | Computation | All bindings of the policy for the principal's kind are merged by equal resource; each resource's `actions` are the endpoint actions of those bindings' roles that the same decision as a call allows (one resource, without args, conditions called without args); only a resource with at least one allowed endpoint action gets a grant. Decisions and snapshots share one source: for the same principal, action, and resource (without arguments), the snapshot answer equals `decide()` | | Endpoint actions only | `event:` actions do not enter the snapshot; the frontend decides entry visibility from endpoint actions only | @@ -638,7 +638,7 @@ The review rule's home is the "`@Remote` marks capability methods" section of [R | Resource resolution | A default field (top-level or first object argument) yields its resource; a lookup parameter that declares that kind as its `resource` yields its wire identity; a Context method gets its Session resource from the Agent Context identity in args without activating the Agent, and may declare other resources through `resources`; a `resources` reader overrides its kind, an array yields several resources, and `undefined` none; with nothing the call is `[global]`; a throw, an empty array, or a value that is not a non-empty string (including a Context identity) is invalid, refused by the Gateway with `resource-invalid` without asking `decide()`; a Context that declares no `resource` resolves by the ordinary rules; resources and `args` read the validated arguments, so an undeclared nested field changes no resource and no condition sees it, and a JSON argument failing validation returns `gateway/input-invalid` before the access check | Gateway host spec (gateway-access) | | Resource registry | `typert.resources` registers one default field per kind, rejects a second registration and `global`, and withdraws the registration with its fiber; a configured lookup resolver keeps the provider's `resource`; a Host Context adapter keeps its `resource` | typert-registry spec | | Forwarded events | A non-owner `$events` stream receives an event, whole, only when `event:` is granted on its resources; an event that declares no resources is decided on `global`; an event whose reader returns `undefined` does not reach non-owners, and `decide()` is not asked; the owner receives all | Gateway host spec (gateway-access) | -| First snapshot | `snapshotFor()` returns the owner baseline or the principal's grants; remote-access injects the owner baseline into every rendered index; the Client starts from the injected baseline and rejects a malformed one | remote-access host and client specs | +| First snapshot | `snapshotFor()` returns the owner baseline or the principal's grants; remote-access injects the snapshot of the Peer each index speaks for; the Client starts from the injected baseline and rejects a malformed one | remote-access host and client specs | | Type inference | The parameter types of `resources` readers are inferred from the method; lookup positions have wire types; a missing property, more parameters than the method has, and a `global` key fail compilation; a binding whose resource kind differs from its role's fails compilation | typert-protocol spec, remote-access spec (type tests) | | List filtering | `session/list`, `session/search`, `session/control`, and `workspace/follow` return non-owners only items for which `allows()` is true; `workspace/follow` sends a `replace` increment when a delivered Workspace loses visibility, and the Workspace reappears once access returns; owner results are unchanged | session-controller / workspace-controller host spec | | Access snapshots | The allowed set in a snapshot equals the Host decision for the same principal, action, and resource without arguments; it contains endpoint actions only; a resource with no allowed action gets no grant, and one whose allowed set becomes empty is removed; `invalidate()` and policy registration or removal push increments; a wait that a change ends leaves no abort listener behind; unloading the service ends every open snapshot stream; the owner receives only `{ owner: true }` | remote-access host spec | diff --git a/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.zh.md b/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.zh.md index afbd49de7f..ed124e8159 100644 --- a/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.zh.md +++ b/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.zh.md @@ -446,7 +446,7 @@ export const REMOTE_ACCESS_SNAPSHOT_KEY = '__DSH_REMOTE_ACCESS__' | 行为 | 说明 | |---|---| -| 首份快照 | `snapshotFor(principal)` 计算页面打开时的 baseline。remote-access 经 `webserver/index-inject` 为 webServer 渲染的每个 index 加入全局变量 `REMOTE_ACCESS_SNAPSHOT_KEY`,值为 owner baseline `{ type: 'baseline', owner: true }`。渲染非 owner 页面的业务包(例如 owner Host 上的 liveshare)在所有 index 注入行之后放置一条把该全局变量赋值为 `snapshotFor(principal)` 的语句;后写的赋值生效,Client 在挂载时读取该全局变量 | +| 首份快照 | `snapshotFor(principal)` 计算页面打开时的 baseline。webServer 按页面所代表的 Peer 渲染 index,`webserver/index-inject` 把该 Peer 传给监听方;remote-access 为每个 index 加入全局变量 `REMOTE_ACCESS_SNAPSHOT_KEY`,值为 `snapshotFor(peer.principal)`,owner 页面因此得到 owner baseline `{ type: 'baseline', owner: true }`,为非 owner Peer 渲染的页面(例如 owner Host 上的 liveshare 读者页)得到该 principal 的 grant。Client 在挂载时读取该全局变量 | | 订阅 | 每个 Peer 订阅一条访问快照流,其 baseline 取代注入的快照 | | 计算 | 取该 principal kind 的 policy 的全部 binding,按资源相等归并;每个资源的 `actions` 为这些 binding 的 role 中、经与调用相同的判定(单个资源、不带 args,condition 以无 args 调用)放行的端点 action;只为至少放行一个端点 action 的资源给出 grant。判定与快照同源:同一 principal、action 与资源(不带参数)上,快照答案与 `decide()` 一致 | | 只含端点 action | `event:` action 不进入快照;前端只据端点 action 决定入口显隐 | @@ -638,7 +638,7 @@ GlobalStandardProps.useWorkspaceAccess: (workspaceId: WorkspaceId, action: Remot | 资源解析 | 默认字段(顶层或首个对象参数)得到对应资源;声明了该种类 `resource` 的 lookup 参数得到其 wire 身份;Context 方法从 args 中的 Agent Context 身份得到其 session 资源且不激活 Agent,另可经 `resources` 声明其他资源;`resources` 读取函数覆盖该种类,数组为多个资源,`undefined` 为无;都没有为 `[global]`;抛错、空数组或非非空字符串(含 Context 身份)为非法,Gateway 以 `resource-invalid` 拒绝且不询问 `decide()`;未声明 `resource` 的 Context 按普通规则解析;资源与 `args` 读取校验后的参数,未声明的嵌套字段不改变资源、condition 也读不到它,JSON 参数校验失败在访问检查前返回 `gateway/input-invalid` | Gateway host spec(gateway-access) | | 资源注册表 | `typert.resources` 每个种类登记一个默认字段,拒绝重复登记与 `global`,并随 fiber 撤回;配置的 lookup resolver 保留 provider 的 `resource`;Host Context adapter 保留其 `resource` | typert-registry spec | | 转发事件 | 非 owner 的 `$events` 流只在 `event:` 于其资源上被授权时整条收到事件;未声明资源的事件按 `global` 判定;读取函数返回 `undefined` 的事件不送达非 owner,且不询问 `decide()`;owner 全部收到 | Gateway host spec(gateway-access) | -| 首份快照 | `snapshotFor()` 返回 owner baseline 或该 principal 的 grant;remote-access 为渲染的每个 index 注入 owner baseline;Client 从注入的 baseline 起步并拒绝格式错误的快照 | remote-access host 与 client spec | +| 首份快照 | `snapshotFor()` 返回 owner baseline 或该 principal 的 grant;remote-access 为每个 index 注入其所代表 Peer 的快照;Client 从注入的 baseline 起步并拒绝格式错误的快照 | remote-access host 与 client spec | | 类型推断 | `resources` 读取函数的参数类型从方法推断;lookup 位为 wire 类型;访问不存在的属性、参数多于方法参数与 `global` 键编译失败;binding 的资源种类与 role 不符编译失败 | typert-protocol spec、remote-access spec(类型测试) | | 列表过滤 | `session/list`、`session/search`、`session/control`、`workspace/follow` 对非 owner 只返回 `allows()` 为 true 的项;已下发工作区失去可见性时 `workspace/follow` 发送 `replace` 增量帧,访问恢复后该工作区重新出现;owner 结果不变 | session-controller / workspace-controller host spec | | 访问快照 | 快照中的允许集合与 Host 对同一 principal、action、资源(不带参数)的判定一致;只含端点 action;没有放行 action 的资源不出 grant,放行集合变空时推送 `remove`;`invalidate()` 与 policy 注册、撤回后推送增量;被变化结束的等待不残留 abort 监听器;卸载服务即结束所有打开的快照流;owner 只收 `{ owner: true }` | remote-access host spec | diff --git a/.agents/notes/implemented/architecture/2026-10-21-when-access.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-21-when-access.i18n.yaml new file mode 100644 index 0000000000..2a74d80a3d --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-21-when-access.i18n.yaml @@ -0,0 +1,28 @@ +# Bilingual-pair consistency record for 2026-10-21-when-access.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-21-when-access.md +/agent-note-whenaccess-registration-that-follows-access: + en: 2ef7cfef514c138a + zh: 064e3aecbfafcd9c +/agent-note-whenaccess-registration-that-follows-access/problem: + en: 4f88d3ee7868eaa5 + zh: 72a4ce2dde13bef7 +/agent-note-whenaccess-registration-that-follows-access/decision: + en: a15c7c13f167e5ac + zh: 63bcc2ef60c2f344 +/agent-note-whenaccess-registration-that-follows-access/decision/whenaccess: + en: c168de20989cf9a4 + zh: bf3b0361beea083d +/agent-note-whenaccess-registration-that-follows-access/decision/plugin-migration: + en: 73577ed282c656f3 + zh: 2f60a6824218e693 +/agent-note-whenaccess-registration-that-follows-access/alternatives-considered: + en: ddc432b693608163 + zh: 620294d8812b0a31 +/agent-note-whenaccess-registration-that-follows-access/consequences: + en: de44fa92c49bc335 + zh: 34436fe971ab013f +/agent-note-whenaccess-registration-that-follows-access/related: + en: 89073ffbfcff0641 + zh: 736a180d13a08981 diff --git a/.agents/notes/implemented/architecture/2026-10-21-when-access.md b/.agents/notes/implemented/architecture/2026-10-21-when-access.md new file mode 100644 index 0000000000..5936accdf5 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-21-when-access.md @@ -0,0 +1,88 @@ +# Agent Note: whenAccess registration that follows access + +Status: implemented + +English | [中文](2026-10-21-when-access.zh.md) + +## Problem + +Client plugins need to switch their own registrations on and off with the current Peer's access: sidebar entries, main panels, overlays, and data streams. The existing Client access surface covers only rendering: + +| Where | Existing capability | Gap | +|---|---|---| +| Components | `useRemoteAccess(action)` (global), `useSessionAccess(sessionId, action)`, `useWorkspaceAccess(workspaceId, action)`, and the Session-scope `useAccess(action)` | Readable only during render; a changed answer re-renders automatically | +| JS (`apply`, services) | `ctx.remoteAccess.source(kind, id, action)` returns a `HostObservable` | A plugin must read `getSnapshot` and subscribe to changes itself, then register and dispose by hand | + +Without a JS entry point, a plugin can only decide its registrations once by identity at `apply`, and they do not follow an access change. + +## Decision + +### whenAccess + +The JS side has a `whenAccess` for each of the three access Hooks, from the package that publishes that Hook: + +| Interface | Provider | Matching Hook | +|---|---|---| +| `ctx.remoteAccess.whenAccess(action, callback)` | remote-access | `useRemoteAccess(action)` | +| `ctx.uiSession.whenAccess(sessionId, action, callback)` | ui-session | `useSessionAccess(sessionId, action)` | +| `ctx.uiWorkspace.whenAccess(workspaceId, action, callback)` | ui-workspace | `useWorkspaceAccess(workspaceId, action)` | + +```ts ignore-check +import type { AccessEffect, RemoteEndpoint } from '@deepseek-ai/dsh-api-remote-access/client' + +/** The global-resource member; the Session and Workspace versions take the resource id first. */ +export interface WhenAccess { + /** + * Keep `callback`'s effects installed while the current Peer may call `action` + * on the resource: run it when the answer becomes true, dispose its effects in + * reverse order when it becomes false, and run it again on the next true. + * @returns disposer for the observation and any installed effects. + */ + whenAccess(action: RemoteEndpoint, callback: () => AccessEffect): () => void +} +``` + +`AccessEffect` is `(() => void) | Iterable<() => void, void, void>`. + +| Rule | Content | +|---|---| +| Semantics | Same as `ctx.slots.inject`: the callback returns one disposer or an iterable of disposers; an iterable installs as one transaction and disposes in reverse order; the controller belongs to the caller's fiber and disposes when the plugin unloads | +| Synchronous run | When the answer is already true, the callback runs synchronously; the owner's answer is always true, so registration order matches direct registration | +| Stream failure | A non-owner's answer turns false and the installed effects dispose | +| Callback failure | A setup failure at the call throws synchronously; a setup failure on a later grant stops the controller and is rethrown in a microtask | +| Layering | remote-access knows only `global`; the Session and Workspace versions are built over remote-access's lower-level `whenResourceAccess(kind, id, action, callback)`, as their Hooks are built over `source(kind, id, action)`. Only the package that owns a resource kind calls `source` and `whenResourceAccess`; other plugins use only the access Hooks and `whenAccess` and pass no kind. All four entry points share [`when-allowed.ts`](../../../../packages/api/remote-access/src/client/when-allowed.ts) | + +### Plugin migration + +| Plugin | Change | Action | +|---|---|---| +| ui-schedule | The task catalog source, delete toast overlay, right-sidebar task tab and title, main panel, sidebar entry, turn-tail card, Session-header reminder catalog, and Session row mark and hover list move into `remoteAccess.whenAccess`; the Session-header catalog reads the per-Session `schedule.list`, so its component also decides through the Session-scope `useAccess` whether to render; the task tab type and the turn definition stay registered outside | `schedule/catalog`, `schedule/list` | +| ui-plugin-manager | Host change subscriptions, refresh toast overlay, main panel (including `pluginNavigation`), and sidebar entry move into `remoteAccess.whenAccess`, and each grant refreshes a list already loaded; the controller and `pluginChanges` stay outside, and `pluginChanges` receives no updates while access is withdrawn. ui-entry-customization and voice-input use `pluginNavigation` optionally: without the service they hide the browse-plugins and open/details buttons and still start | `pluginManager/listBundles` | +| ui-settings-general | The settings shell `sidebar.settings` and the `settings.open` shortcut move into `remoteAccess.whenAccess` | `settings/describe` | +| ui-settings-account | The account state stream (including default model initialization), quota notice, settings launcher, account settings section, and `settings.models.sign-in` move into `remoteAccess.whenAccess`. On revocation the stream's disposer synchronously invalidates the account reads this grant started, closes the pending authorization tab, clears the account view, details, picture, and sign-in dialog state, ends this generation's bonus notices, which a frame of that stream begins, and then closes the stream; a frame delivered before the revocation but not yet handled is dropped. The notice controller stays outside and begins again under the next grant; the account section appears once the new stream's first frame arrives | `account/watch` | +| ui-directory-picker-native | Both directory flows move into `remoteAccess.whenAccess` | `directoryPicker/pick` | +| ui-message-feedback | The message feedback buttons stay registered and the component decides whether to render through the Session-scope `useAccess`; `messageFeedback/*` requests carry `sessionId`, which the Gateway resolves to that Session through the `session` resource's default field, so they declare nothing. The `/feedback` decoration has no Client check: the decoration registry is global while `sessionFeedback/record` is decided per Session, so without access the dialog still opens and the Host refuses the submission, which the dialog reports with its failure code | `messageFeedback/put` | +| ui-deliverables, ui-open-in-app | Unchanged: an owner-only HTTP route that cannot be read falls back as before | — | + +## Alternatives considered + +**Return after an owner check at the start of `apply` (the #6486 approach)**: fixed on identity; does not follow an access change. + +**Trim the plugin list per connection**: a plugin cannot reload at once when access changes. + +**Declare access in the plugin package manifest**: defines an access-control concept in the package manifest. + +**One generic method with a resource parameter, or all three versions on remote-access**: business code would pass a kind, and remote-access would name the Session and Workspace resource kinds. + +## Consequences + +| Consequence | Note | +|---|---| +| Registrations follow access changes | Without a grant, a migrated plugin registers none of the corresponding entries for a non-owner; they appear after a grant and disappear after a revocation; the owner's answer is always true, so its registrations match direct registration | +| The callback must hand back disposers | An effect registered through the plugin's `ctx` inside the callback also hangs on the plugin fiber; a registration whose disposer is not yielded does not dispose on access loss, only when the plugin unloads | +| Readers receive all plugin code | Plugins load as before; the Gateway decides each call, and `whenAccess` and the Hooks control the UI | +| Test stand-ins | [`dsh-client-test-runtime`](../../../../packages/test-support/client-runtime/README.md) replaces remote-access with fixed answers through `StaticRemoteAccess`, `staticAccessPlugin`, and `TestClientOptions.access`, so plugin specs cover the refused path | + +## Related + +- [Peer, principal, and remote access](2026-10-12-peer-principal-and-remote-access.md): this Note extends its Client access face. diff --git a/.agents/notes/implemented/architecture/2026-10-21-when-access.zh.md b/.agents/notes/implemented/architecture/2026-10-21-when-access.zh.md new file mode 100644 index 0000000000..242be55f65 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-21-when-access.zh.md @@ -0,0 +1,88 @@ +# Agent Note: 按访问状态注册的 whenAccess + +Status: implemented + +[English](2026-10-21-when-access.md) | 中文 + +## 问题 + +Client 插件需要按当前 Peer 的访问状态开关自己的注册:侧栏入口、主面板、浮层、数据流。现有 Client 访问面只覆盖渲染: + +| 位置 | 现有能力 | 缺口 | +|---|---|---| +| 组件 | `useRemoteAccess(action)`(global)、`useSessionAccess(sessionId, action)`、`useWorkspaceAccess(workspaceId, action)`、会话作用域的 `useAccess(action)` | 只能在渲染时读,答案变化时自动重渲染 | +| JS(`apply`、服务) | `ctx.remoteAccess.source(kind, id, action)` 返回 `HostObservable` | 插件要自己读 `getSnapshot`、订阅变化,再手动注册与撤销 | + +没有 JS 侧入口时,插件只能在 `apply` 时按身份一次性决定注册与否,授权变化后注册不跟随。 + +## 决策 + +### whenAccess + +JS 侧有与三个访问 Hook 一一对应的 `whenAccess`,由各自 Hook 的发布者提供: + +| 接口 | 提供者 | 对应 Hook | +|---|---|---| +| `ctx.remoteAccess.whenAccess(action, callback)` | remote-access | `useRemoteAccess(action)` | +| `ctx.uiSession.whenAccess(sessionId, action, callback)` | ui-session | `useSessionAccess(sessionId, action)` | +| `ctx.uiWorkspace.whenAccess(workspaceId, action, callback)` | ui-workspace | `useWorkspaceAccess(workspaceId, action)` | + +```ts ignore-check +import type { AccessEffect, RemoteEndpoint } from '@deepseek-ai/dsh-api-remote-access/client' + +/** The global-resource member; the Session and Workspace versions take the resource id first. */ +export interface WhenAccess { + /** + * Keep `callback`'s effects installed while the current Peer may call `action` + * on the resource: run it when the answer becomes true, dispose its effects in + * reverse order when it becomes false, and run it again on the next true. + * @returns disposer for the observation and any installed effects. + */ + whenAccess(action: RemoteEndpoint, callback: () => AccessEffect): () => void +} +``` + +`AccessEffect` 为 `(() => void) | Iterable<() => void, void, void>`。 + +| 规则 | 内容 | +|---|---| +| 语义 | 与 `ctx.slots.inject` 相同:回调返回一个 disposer 或一组 disposer;一组时按事务安装、逆序撤销;控制器属于调用方 fiber,插件卸载时撤销 | +| 同步执行 | 调用时答案为 true 则回调同步执行;owner 的答案恒为 true,注册顺序与直接注册相同 | +| 快照流失败 | 非 owner 的答案变为 false,已安装的效果撤销 | +| 回调失败 | 调用时安装失败同步抛出;之后重新授权时安装失败,控制器停止并在微任务中抛出 | +| 分层 | remote-access 只认识 global;会话版与工作区版经 remote-access 的底层方法 `whenResourceAccess(kind, id, action, callback)` 实现,与它们用 `source(kind, id, action)` 构造 Hook 相同。`source` 与 `whenResourceAccess` 只由拥有该资源种类的包调用,其他插件只用访问 Hook 与 `whenAccess`,不传 kind。四个入口共用 [`when-allowed.ts`](../../../../packages/api/remote-access/src/client/when-allowed.ts) | + +### 插件迁移 + +| 插件 | 改法 | action | +|---|---|---| +| ui-schedule | 任务列表数据源、删除提示浮层、右栏任务 tab 与标题、主面板、侧栏入口、turn 尾部卡片、会话头部提醒、会话行标记与悬浮列表包进 `remoteAccess.whenAccess`;会话头部提醒读取按会话判定的 `schedule.list`,组件另在会话作用域里用 `useAccess` 决定是否渲染;任务 tab 类型与 turn 定义的注册留在外面 | `schedule/catalog`、`schedule/list` | +| ui-plugin-manager | Host 变更订阅、刷新提示浮层、主面板(含 `pluginNavigation`)、侧栏入口包进 `remoteAccess.whenAccess`,每次授权时刷新已加载过的列表;controller 与 `pluginChanges` 留在外面,撤权期间 `pluginChanges` 不更新。依赖 `pluginNavigation` 的 ui-entry-customization 与 voice-input 改为可选使用:服务不在时隐藏「浏览插件」与「打开 / 查看详情」按钮,插件照常启动 | `pluginManager/listBundles` | +| ui-settings-general | 设置外壳 `sidebar.settings` 与 `settings.open` 快捷键包进 `remoteAccess.whenAccess` | `settings/describe` | +| ui-settings-account | 账号状态流(含默认模型初始化)、额度提醒、设置启动器、账号设置分区与 `settings.models.sign-in` 包进 `remoteAccess.whenAccess`。撤权时账号流的 disposer 同步作废这一轮发出的账号读取,关闭待授权的浏览器标签页,清空账号视图、详情、头像与登录对话框状态,结束本轮奖励提示(提示由该流的帧开始),再关闭流;撤权前已交付但尚未处理的帧被丢弃。提示控制器留在外面,下次授权时重新开始;账号分区等新流的第一帧到达后才出现 | `account/watch` | +| ui-directory-picker-native | 两个目录选择流程包进 `remoteAccess.whenAccess` | `directoryPicker/pick` | +| ui-message-feedback | 消息反馈按钮照常注册,组件在会话作用域里用 `useAccess` 判断是否渲染;`messageFeedback/*` 的请求带 `sessionId`,Gateway 按 `session` 资源的默认字段自动解析为该 Session,无需声明。`/feedback` 装饰不做前端判断:装饰注册表是全局的,而 `sessionFeedback/record` 按会话判定;没有权限时对话框照常打开,提交被 Host 拒绝并显示失败码 | `messageFeedback/put` | +| ui-deliverables、ui-open-in-app | 不动:owner 专用 HTTP 路由读不到时走已有回退 | — | + +## 考虑过的替代方案 + +**插件 `apply` 开头判断 owner 后 return(#6486 的做法)**:按身份写死,授权变化后不跟随。 + +**按连接裁剪插件列表**:授权变化后插件无法立即重新加载。 + +**插件包清单声明访问性**:在包清单上定义访问控制概念。 + +**一个带资源参数的通用方法,或三个版本都挂在 remote-access 上**:业务代码要传 kind,且 remote-access 会出现会话、工作区的资源种类名。 + +## 后果 + +| 后果 | 说明 | +|---|---| +| 注册跟随授权变化 | 迁移的插件在非 owner 无授权时不注册相应入口,授予后出现,撤销后消失;owner 的答案恒为 true,注册与直接注册相同 | +| 回调必须交还 disposer | 回调里经插件 `ctx` 注册的效果也挂在插件 fiber 上;不 yield 其 disposer 的注册在失权时不会撤销,只在插件卸载时撤销 | +| 读者拿到全部插件代码 | 插件照常加载;访问由 Gateway 逐次判定,界面由 `whenAccess` 与 Hook 控制 | +| 测试替身 | [`dsh-client-test-runtime`](../../../../packages/test-support/client-runtime/README.zh.md) 的 `StaticRemoteAccess`、`staticAccessPlugin` 与 `TestClientOptions.access` 以固定答案替换 remote-access,供插件测试无权限路径 | + +## 相关 + +- [Peer、principal 与 remote-access](2026-10-12-peer-principal-and-remote-access.zh.md):本 Note 扩展其 Client 访问面。 diff --git a/apps/cli/composition.md b/apps/cli/composition.md index cda1e0bb96..21150774cc 100644 --- a/apps/cli/composition.md +++ b/apps/cli/composition.md @@ -188,6 +188,10 @@ flowchart LR cfg --> plugin_dsh_base_mcp_resources plugin_dsh_base_tools["tools
@deepseek-ai/dsh-tools"] cfg --> plugin_dsh_base_tools + plugin_dsh_base_tool_executor_native["tool-executor-native
@deepseek-ai/dsh-tool-executor-native"] + cfg --> plugin_dsh_base_tool_executor_native + plugin_dsh_base_tool_executor_ptc["tool-executor-ptc
@deepseek-ai/dsh-tool-executor-ptc"] + cfg --> plugin_dsh_base_tool_executor_ptc plugin_dsh_base_system_prompt["system-prompt
@deepseek-ai/dsh-system-prompt"] cfg --> plugin_dsh_base_system_prompt plugin_dsh_base_agent_loop["agent-loop
@deepseek-ai/dsh-agent-loop"] @@ -292,6 +296,8 @@ flowchart LR | `tool-web` | `@deepseek-ai/dsh-tool-web` | | `mcp-resources` | `@deepseek-ai/dsh-mcp-resources` | | `tools` | `@deepseek-ai/dsh-tools` | +| `tool-executor-native` | `@deepseek-ai/dsh-tool-executor-native` | +| `tool-executor-ptc` | `@deepseek-ai/dsh-tool-executor-ptc` | | `system-prompt` | `@deepseek-ai/dsh-system-prompt` | | `agent-loop` | `@deepseek-ai/dsh-agent-loop` | | `fs-sandbox` | `@deepseek-ai/dsh-fs-sandbox` | diff --git a/apps/cli/config/examples/ptc.yml b/apps/cli/config/examples/ptc.yml new file mode 100644 index 0000000000..d0c2cc888a --- /dev/null +++ b/apps/cli/config/examples/ptc.yml @@ -0,0 +1,5 @@ +- id: tool-executor-native + disabled: true + +- id: tool-executor-ptc + disabled: false diff --git a/apps/cli/package.json b/apps/cli/package.json index d7c3e78f72..65c32f037a 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -155,6 +155,7 @@ "@deepseek-ai/dsh-subagent-spawn-in-process": "workspace:*", "@deepseek-ai/dsh-subprocess-local": "workspace:*", "@deepseek-ai/dsh-system-prompt": "workspace:*", + "@deepseek-ai/dsh-tool-testkit": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", "@deepseek-ai/dsh-user-approval": "workspace:*", "@types/js-yaml": "^4.0.9", diff --git a/apps/cli/reference/README.i18n.yaml b/apps/cli/reference/README.i18n.yaml index d7cbbd9ac6..ffd79b8daa 100644 --- a/apps/cli/reference/README.i18n.yaml +++ b/apps/cli/reference/README.i18n.yaml @@ -21,8 +21,8 @@ en: 8d2cfff9bab7327f zh: 21515ab6e6798504 /dsh-cli-behavior-reference/web-profile: - en: 850434e8ca162221 - zh: 4893613f97a21eb8 + en: 149c5571acd51baf + zh: 300e3c2425273a92 /dsh-cli-behavior-reference/shared-deployment-behavior: en: f40edcf903a51f3f zh: 8a9a915451047c8b diff --git a/apps/cli/reference/README.md b/apps/cli/reference/README.md index 98b7197a92..a375450048 100644 --- a/apps/cli/reference/README.md +++ b/apps/cli/reference/README.md @@ -130,7 +130,7 @@ The base-backed modes treat the invoking directory as the default workspace root New sessions in base-backed profiles default to the `workspace-write` permission preset. Bash and filesystem mutations are restricted to the session workspace and platform temporary roots; reads and network access are not confined, while process visibility depends on the selected sandbox backend — bwrap runs commands in a private PID namespace that hides host processes, and Landlock and Seatbelt leave host process visibility unchanged. `DSH_PERMISSION_MODE` changes the process fallback. Stored General-settings permissions affect later Web sessions, not an already-open one. The standalone `sdk-minimal` tree instead pins `danger-full-access` and mounts no approval or permission-settings service. -`DSH_TOOLS_MODE` selects `native` or `ptc` for the process; another value fails at boot. The shipped `minimal` agent preset keeps that deployment presentation, fixes the complete system prompt to `You are a helpful software engineer assistant.`, and provides the platform-selected persistent shell, `read_image`, and `working_directory`. It supports automatic context compaction and the human `/compact` command. Select 极简模式 when creating a Web session; the shared browser, workspace, persistence, sandbox, and permission host remains in place. +Web, headless, and SDK inherit native tool execution from the base bundle; use a [PTC overlay](../config/examples/ptc.yml) to select PTC. The shipped `minimal` agent preset keeps that deployment presentation, fixes the complete system prompt to `You are a helpful software engineer assistant.`, and provides the platform-selected persistent shell, `read_image`, and `working_directory`. It supports automatic context compaction and the human `/compact` command. Select 极简模式 when creating a Web session; the shared browser, workspace, persistence, sandbox, and permission host remains in place. ## Shared deployment behavior diff --git a/apps/cli/reference/README.zh.md b/apps/cli/reference/README.zh.md index 6441309f1c..642fa18bc5 100644 --- a/apps/cli/reference/README.zh.md +++ b/apps/cli/reference/README.zh.md @@ -132,7 +132,7 @@ dsh web --help 基于 base 的 profile 中,新会话默认使用 `workspace-write` 权限预设。Bash 和文件系统修改仅限于会话 workspace 与平台临时根目录;读取和网络访问不受限制,进程可见性则取决于所选沙箱后端——bwrap 在私有 PID 命名空间中运行命令并隐藏宿主进程,Landlock 与 Seatbelt 保持宿主进程可见性不变。`DSH_PERMISSION_MODE` 更改进程后备值。General settings 中存储的权限影响后续 Web 会话,不改变已打开的会话。独立的 `sdk-minimal` 配置树则固定为 `danger-full-access`,且不挂载 approval 或权限 settings 服务。 -`DSH_TOOLS_MODE` 为进程选择 `native` 或 `ptc`;其他值会导致启动失败。随附的 `minimal` agent preset 会保留该部署的呈现方式,将完整系统提示词固定为 `You are a helpful software engineer assistant.`,并提供按平台选择的持久 shell、`read_image` 和 `working_directory`。它支持自动上下文压缩和用户 `/compact` 命令。创建 Web 会话时请选择极简模式;共享的浏览器、workspace、持久化、沙箱与权限宿主保持不变。 +Web、headless 与 SDK 继承 base bundle 的原生工具执行;使用 [PTC overlay](../config/examples/ptc.yml) 可选择 PTC。随附的 `minimal` agent preset 会保留该部署的呈现方式,将完整系统提示词固定为 `You are a helpful software engineer assistant.`,并提供按平台选择的持久 shell、`read_image` 和 `working_directory`。它支持自动上下文压缩和用户 `/compact` 命令。创建 Web 会话时请选择极简模式;共享的浏览器、workspace、持久化、沙箱与权限宿主保持不变。 ## 共享部署行为 diff --git a/apps/cli/tests/built-bin.e2e.ts b/apps/cli/tests/built-bin.e2e.ts index cd8b5aad9c..7a80f1c760 100644 --- a/apps/cli/tests/built-bin.e2e.ts +++ b/apps/cli/tests/built-bin.e2e.ts @@ -1268,6 +1268,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', ['session-title', '@deepseek-ai/dsh-session-title'], ['system-prompt', '@deepseek-ai/dsh-system-prompt'], ['tools', '@deepseek-ai/dsh-tools'], + ['tool-executor-native', '@deepseek-ai/dsh-tool-executor-native'], ['mcp-resources', '@deepseek-ai/dsh-mcp-resources'], ['agent', '@deepseek-ai/dsh-agent'], ['llm-retry', '@deepseek-ai/dsh-llm-retry'], diff --git a/apps/cli/tests/fixtures/dsh-badge/snapshot.ts b/apps/cli/tests/fixtures/dsh-badge/snapshot.ts index a8c1850472..db8cc56e5d 100644 --- a/apps/cli/tests/fixtures/dsh-badge/snapshot.ts +++ b/apps/cli/tests/fixtures/dsh-badge/snapshot.ts @@ -1,3 +1,4 @@ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' import { fileURLToPath } from 'node:url' import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent' import { ToolCallId } from '@deepseek-ai/dsh-llm' @@ -52,7 +53,7 @@ try { && message.source.kind === 'skill-catalog')?.content : undefined const summary = (await ctx.skills.list()).find(skill => skill.name === 'dsh-badge') - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { callId: ToolCallId('dsh-badge-snapshot'), name: 'skill', arguments: { name: 'dsh-badge' }, diff --git a/apps/cli/tests/optional-bundle-transitions.e2e.ts b/apps/cli/tests/optional-bundle-transitions.e2e.ts index f084cb29cb..b5a5eecbaf 100644 --- a/apps/cli/tests/optional-bundle-transitions.e2e.ts +++ b/apps/cli/tests/optional-bundle-transitions.e2e.ts @@ -1,4 +1,5 @@ /** Optional bundles through the shipped Web Loader, live package manager, and real Agent loop. */ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' import { randomUUID } from 'node:crypto' import { existsSync } from 'node:fs' import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' @@ -232,7 +233,7 @@ describe.skipIf(!existsSync(join(root, 'apps/cli/lib/bin.js')))('Official option it('loads the actual badge asset only while its bundle is selected', async () => { const owner = await agent() - const load = (callId: string) => ctx.tools.execute({ + const load = (callId: string) => executeToolFixture(ctx, { callId: ToolCallId(callId), name: 'skill', arguments: { name: 'dsh-badge' }, agent: owner.agent, signal: new AbortController().signal, }) diff --git a/apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts b/apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts index b71ffc778c..a2ae0c9f19 100644 --- a/apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts @@ -555,7 +555,7 @@ describe('headless stream-json snapshots', () => { '--permission-mode', 'auto', '--approval-policy', 'never', '--json', 'Run the CLI probe.', ], tsconfigPath, - env: { DSH_AUTO_REVIEW_TEST_MODE: mode, DSH_TOOLS_MODE: 'native', DSH_TELEMETRY_DISABLED: '1' }, + env: { DSH_AUTO_REVIEW_TEST_MODE: mode, DSH_TELEMETRY_DISABLED: '1' }, inspect: async (cwd) => { const logs = await persistedLogs(cwd, join(cwd, '.dsh', 'sessions')) expect(logs).toHaveLength(1) @@ -605,7 +605,7 @@ describe('headless stream-json snapshots', () => { const first = await runLoaderSmoke({ label: 'headless Auto denied wake', cwd, binScript: dshBinScript, configPath: autoReviewOverlayPath, binArgs: [...args, '--permission-mode', 'auto', '--approval-policy', 'never', 'Run the CLI probe.'], tsconfigPath, - env: { DSH_AUTO_REVIEW_TEST_MODE: 'deny', DSH_TOOLS_MODE: 'native', DSH_TELEMETRY_DISABLED: '1' }, + env: { DSH_AUTO_REVIEW_TEST_MODE: 'deny', DSH_TELEMETRY_DISABLED: '1' }, }) const sessionId = parseJsonl(first.stdout)[0]?.sessionId if (typeof sessionId !== 'string') throw new Error('missing Auto Session identity') @@ -615,7 +615,7 @@ describe('headless stream-json snapshots', () => { const second = await runLoaderSmoke({ label: 'headless Auto authorized wake', cwd, binScript: dshBinScript, configPath: autoReviewOverlayPath, binArgs: [...args, '--session-id', sessionId, 'I authorize the exact CLI probe. Retry it.'], tsconfigPath, - env: { DSH_AUTO_REVIEW_TEST_MODE: 'allow', DSH_TOOLS_MODE: 'native', DSH_TELEMETRY_DISABLED: '1' }, + env: { DSH_AUTO_REVIEW_TEST_MODE: 'allow', DSH_TELEMETRY_DISABLED: '1' }, inspect: async (directory) => { const logs = await persistedLogs(directory, join(directory, '.dsh', 'sessions')) expect(logs).toHaveLength(1) diff --git a/apps/cli/tests/profiles/headless/tests/profile-resolution.ts b/apps/cli/tests/profiles/headless/tests/profile-resolution.ts index 9c4b58df8d..137941c43c 100644 --- a/apps/cli/tests/profiles/headless/tests/profile-resolution.ts +++ b/apps/cli/tests/profiles/headless/tests/profile-resolution.ts @@ -40,7 +40,7 @@ interface ResolutionEvidence { sourceToolsCjs: string | null sourceLeafCjs: string sourceLeafExplicitCjs: string - scheduler: boolean + executor: boolean modules: string[] } @@ -139,7 +139,7 @@ export function testProfileResolution(mode: ExampleMode): void { const staleTools = join(root, 'stale-tools') const staleToolsLink = join(sharedModules, '@deepseek-ai', 'dsh-tools') await writePackage(staleTools, { name: '@deepseek-ai/dsh-tools', version: '0.0.0', exports }, { - 'index.mjs': "export default class Tools {}\nexport const TOOL_RUNTIME_SCHEDULER = Symbol()\nthrow new Error('STALE_DSH_TOOLS')", + 'index.mjs': "export default class Tools {}\nthrow new Error('STALE_DSH_TOOLS')", 'index.cjs': "throw new Error('STALE_DSH_TOOLS')", }) const sourceDir = join(sourcePackageDir, 'src') @@ -183,7 +183,7 @@ export function testProfileResolution(mode: ExampleMode): void { 'probe.mjs': [ "import { createRequire } from 'node:module'", "import { getEnvironmentData } from 'node:worker_threads'", - "import Tools, { TOOL_RUNTIME_SCHEDULER } from '@deepseek-ai/dsh-tools'", + "import Tools from '@deepseek-ai/dsh-tools'", `import { leaf } from '${leafName}'`, `import { leaf as bridgeLeaf } from '${bridgeName}'`, `import { external } from '${externalName}'`, @@ -214,7 +214,7 @@ export function testProfileResolution(mode: ExampleMode): void { ' sourceToolsInstance: ctx.tools instanceof SourceTools,', ` profileToolsCjs: ${toolsCjsExpression},`, ' sourceToolsCjs, sourceLeafCjs, sourceLeafExplicitCjs,', - " scheduler: typeof ctx.tools[TOOL_RUNTIME_SCHEDULER]?.prepare === 'function',", + " executor: typeof ctx.tools.execute === 'function',", ' modules: [...ctx.loader.internal.loadCache.keys()]', ' .filter(url => /\\/packages\\/core\\/(?:tools|agent-loop)\\//.test(url)),', ' }', @@ -292,7 +292,7 @@ export function testProfileResolution(mode: ExampleMode): void { expect(evidence.sourceLeafCjs).toBe(evidence.cjs.filename) expect(evidence.sourceLeafExplicitCjs).toBe(realpathSync.native(join(sourceDevLeaf, 'index.cjs'))) expect(evidence.sourceLeafCjs).not.toBe(evidence.sourceLeafExplicitCjs) - expect(evidence.scheduler).toBe(true) + expect(evidence.executor).toBe(true) expect(evidence.execArgv).toEqual(mode === 'src' ? launch.args.slice(0, 2) : []) for (const name of ['tools', 'agent-loop']) { const selected = mode === 'src' ? 'src/index.ts' : 'lib/index.js' diff --git a/apps/cli/tests/profiles/headless/tests/ptc.e2e.ts b/apps/cli/tests/profiles/headless/tests/ptc.e2e.ts index cce567d62e..0eb7a187b0 100644 --- a/apps/cli/tests/profiles/headless/tests/ptc.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/ptc.e2e.ts @@ -1,3 +1,4 @@ +import { executeModelToolFixture } from '@deepseek-ai/dsh-tool-testkit' import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -96,7 +97,7 @@ function runCode( signal: AbortSignal = testToolSignal, agent?: Agent, ): Promise { - return harness.tools.execute({ + return executeModelToolFixture(harness, { callId: ToolCallId(`keyless-code-${++keylessCall}`), name: RUN_CODE_NAME, arguments: { code, description: 'Run the e2e program' }, @@ -308,7 +309,7 @@ describe('PTC mode typed values: keyless real-process contracts', () => { return { provider: provider.id, names: inspected.data.tools.map(tool => tool.name) }; `, testToolSignal, agent)) - expect(value).toEqual({ provider: 'Tool', names: ['cordis_inspect_list', 'cordis_inspect_query', 'run_code'] }) + expect(value).toEqual({ provider: 'Tool', names: ['cordis_inspect_list', 'cordis_inspect_query'] }) }) }) diff --git a/apps/cli/tests/profiles/headless/tests/source-tool.built.e2e.ts b/apps/cli/tests/profiles/headless/tests/source-tool.built.e2e.ts index 4988ef80fd..03f94856f4 100644 --- a/apps/cli/tests/profiles/headless/tests/source-tool.built.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/source-tool.built.e2e.ts @@ -58,7 +58,6 @@ describe.skipIf(!existsSync(join(repoRoot, 'apps/cli/lib/bin.js')))('dsh SOURCE DSH_HOME: join(root, 'home'), DSH_AGENTS_HOME: join(root, 'agents'), DSH_TELEMETRY_DISABLED: '1', - DSH_TOOLS_MODE: 'native', DSH_CLI_MOCK_FAILURE: '0', }, input: '', diff --git a/apps/cli/tests/profiles/sdk/dynamic-tool-cache.e2e.ts b/apps/cli/tests/profiles/sdk/dynamic-tool-cache.e2e.ts index b1b2c88128..ec5fe6ad94 100644 --- a/apps/cli/tests/profiles/sdk/dynamic-tool-cache.e2e.ts +++ b/apps/cli/tests/profiles/sdk/dynamic-tool-cache.e2e.ts @@ -85,7 +85,6 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('SDK native tool updates with rea retryPolicy: { mode: 'normal', maxRetries: 0 } satisfies RetryPolicyConfig, models: [{ id: 'deepseek-flash', systemPromptUpdate: 'in-history', toolUpdate: 'addition-only' }], } }, - { id: 'tools', config: { mode: 'native' } }, { id: 'session-log-deepseek', disabled: true }, { id: 'plugin-package-inventory-deepseek', disabled: true }, { insert: [{ id: 'sdk-dynamic-tool-cache-fixture', name: fixturePath, config: { evidencePath, callsPath, sampleLabel, updatePrompt } }] }, diff --git a/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts b/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts index a65f11c29e..32e43b8be8 100644 --- a/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts +++ b/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts @@ -58,7 +58,7 @@ describe('Python SDK dsh profile keyless smoke', () => { { label: 'reports max-token turns with mapping enabled through env', envValue: 'true', editorEnabled: false }, { label: 'reports max-token turns with mapping disabled through env', envValue: 'false', editorEnabled: false }, { label: 'allows an explicit patch to enable str_replace_editor', envValue: undefined, editorEnabled: true }, - { label: 'uses desktop tools through the PTC environment switch', envValue: undefined, editorEnabled: false, ptc: true }, + { label: 'uses the PTC executor overlay despite the retired environment selector', envValue: undefined, editorEnabled: false, ptc: true }, ])('$label', async (scenario) => { const { envValue, editorEnabled } = scenario const ptc = 'ptc' in scenario && scenario.ptc @@ -92,6 +92,7 @@ describe('Python SDK dsh profile keyless smoke', () => { '--profile', 'sdk', ...(editorEnabled ? ['--patch', editorPatch] : []), + ...(ptc ? ['--patch', join(repoRoot, 'apps/cli/config/examples/ptc.yml')] : []), ], { cwd: repoRoot, env: { @@ -99,7 +100,7 @@ describe('Python SDK dsh profile keyless smoke', () => { DSH_HOME: join(root, '.dsh'), DSH_PERMISSION_MODE: 'danger-full-access', DSH_TELEMETRY_DISABLED: '1', - DSH_TOOLS_MODE: ptc ? 'ptc' : 'native', + DSH_TOOLS_MODE: ptc ? 'native' : 'ptc', DEEPSEEK_API_KEY: 'keyless-smoke-no-call', DEEPSEEK_BASE_URL: `http://127.0.0.1:${address.port}`, ...(envValue === undefined ? {} : { DSH_MAX_TOKENS_AS_SUCCESS: envValue }), diff --git a/apps/cli/tests/profiles/sdk/office-cli.e2e.ts b/apps/cli/tests/profiles/sdk/office-cli.e2e.ts index 2c90e5fc6e..7c8a94748d 100644 --- a/apps/cli/tests/profiles/sdk/office-cli.e2e.ts +++ b/apps/cli/tests/profiles/sdk/office-cli.e2e.ts @@ -58,7 +58,7 @@ it.skipIf(!process.env.DEEPSEEK_API_KEY || process.platform === 'win32')(`agent ], { cwd: root, env: { PATH: path, DSH_HOME: join(root, 'home'), DSH_AGENTS_HOME: join(root, 'agents'), - DSH_PERMISSION_MODE: 'danger-full-access', DSH_TELEMETRY_DISABLED: '1', DSH_TOOLS_MODE: 'native', + DSH_PERMISSION_MODE: 'danger-full-access', DSH_TELEMETRY_DISABLED: '1', DSH_PRIMARY_RUNTIME: carrier === 'sdk' ? undefined : '', }, timeout: 110_000, killSignal: 'SIGKILL', reject: false, diff --git a/apps/cli/tests/profiles/sdk/schedule-resume.e2e.ts b/apps/cli/tests/profiles/sdk/schedule-resume.e2e.ts index 46b880cb78..f3efcf6dd1 100644 --- a/apps/cli/tests/profiles/sdk/schedule-resume.e2e.ts +++ b/apps/cli/tests/profiles/sdk/schedule-resume.e2e.ts @@ -49,7 +49,7 @@ it('delivers an overdue reminder with the SDK route and lets the client resume t await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) const address = server.address() if (address === null || typeof address === 'string') throw new Error('model fixture has no port') - const env = { ...process.env, DSH_TOOLS_MODE: 'native', DSH_PERMISSION_MODE: 'danger-full-access', DSH_TELEMETRY_DISABLED: '1', + const env = { ...process.env, DSH_PERMISSION_MODE: 'danger-full-access', DSH_TELEMETRY_DISABLED: '1', DEEPSEEK_API_KEY: 'keyless-schedule-fixture', DEEPSEEK_BASE_URL: `http://127.0.0.1:${String(address.port)}` } clearAmbientProxyEnv(env) const first = new DeepSeekHarness({ cwd: root, dshHome: join(root, '.dsh'), patches: [patch], diff --git a/apps/cli/tests/profiles/sdk/user-questions.e2e.ts b/apps/cli/tests/profiles/sdk/user-questions.e2e.ts index 3185cf4aa9..b13b0dd542 100644 --- a/apps/cli/tests/profiles/sdk/user-questions.e2e.ts +++ b/apps/cli/tests/profiles/sdk/user-questions.e2e.ts @@ -3,6 +3,7 @@ import { createServer } from 'node:http' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { fileURLToPath } from 'node:url' import { DeepSeekHarness, type SdkUserQuestionRequest } from '@deepseek-ai/dsh-sdk-client' import { clearAmbientProxyEnv } from '../../../../../scripts/test-proxy-environment.ts' import { expect, it, onTestFinished } from 'vitest' @@ -47,13 +48,14 @@ it.each([ await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) const address = server.address() if (address === null || typeof address === 'string') throw new Error('model fixture has no port') - const env = { ...process.env, DSH_TOOLS_MODE: mode, DSH_PERMISSION_MODE: 'danger-full-access', DSH_TELEMETRY_DISABLED: '1', + const env = { ...process.env, DSH_PERMISSION_MODE: 'danger-full-access', DSH_TELEMETRY_DISABLED: '1', DEEPSEEK_API_KEY: 'keyless-question-fixture', DEEPSEEK_BASE_URL: `http://127.0.0.1:${String(address.port)}` } clearAmbientProxyEnv(env) const seen: SdkUserQuestionRequest[] = [] const entered = Promise.withResolvers() const harness = state.harness = new DeepSeekHarness({ cwd: root, dshHome: join(root, '.dsh'), env, model: 'deepseek-v4-pro', maxTokens: 1234, initializeTimeoutMs: 30_000, + patches: mode === 'ptc' ? [fileURLToPath(new URL('../../../config/examples/ptc.yml', import.meta.url))] : [], onUserQuestion: async (request, { signal }) => { expect(signal.aborted).toBe(false) seen.push(request) diff --git a/apps/cli/tests/profiles/web/tests/fixtures/creator-plugin-manager.mjs b/apps/cli/tests/profiles/web/tests/fixtures/creator-plugin-manager.mjs index b13cc06455..3b0f7bdc32 100644 --- a/apps/cli/tests/profiles/web/tests/fixtures/creator-plugin-manager.mjs +++ b/apps/cli/tests/profiles/web/tests/fixtures/creator-plugin-manager.mjs @@ -1,4 +1,6 @@ /** Test-only IPC assertions over real Creator presets and profile management. */ +import { executeModelToolFixture } from '@deepseek-ai/dsh-tool-testkit' + export const inject = ['agents', 'agentPresets', 'tools', 'pluginManager', 'permissionPresets'] export function apply(ctx, config) { @@ -30,7 +32,7 @@ export function apply(ctx, config) { try { const first = await make(`${phase}-first`) const before = names(first) - const manage = args => ctx.tools.execute({ name: 'plugin_manager', arguments: args, agent: first, + const manage = args => executeModelToolFixture(ctx, { name: 'plugin_manager', arguments: args, agent: first, callId: `${phase}-manage`, signal: new AbortController().signal }) ctx.permissionPresets.set(first.session, 'workspace-write') first.session.append('turn/start', { turn: 1 }) @@ -45,7 +47,7 @@ export function apply(ctx, config) { const second = await make(`${phase}-second`) const after = names(first) const other = names(second) - const ping = await ctx.tools.execute({ name: 'mcp__demo__ping', arguments: {}, agent: first, + const ping = await executeModelToolFixture(ctx, { name: 'mcp__demo__ping', arguments: {}, agent: first, callId: `${phase}-ping`, signal: new AbortController().signal }) const removed = phase === 'restart' ? JSON.parse((await manage({ action: 'remove_bundle', target: '@test/creator-mcp' })).value) : undefined diff --git a/apps/cli/tests/web-agent-presets.e2e.ts b/apps/cli/tests/web-agent-presets.e2e.ts index adef832e6c..b06fd1b4e4 100644 --- a/apps/cli/tests/web-agent-presets.e2e.ts +++ b/apps/cli/tests/web-agent-presets.e2e.ts @@ -1,3 +1,4 @@ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' import { agentPresetProjectionDefinition } from '@deepseek-ai/dsh-agent-preset-registry' import { subagentModelSelectionProjectionDefinition } from '@deepseek-ai/dsh-tool-subagent/src/model-selection-state.ts' import { createRequire } from 'node:module' @@ -629,7 +630,7 @@ describe('the shipped Web composition', () => { expect(scoped).toContain('project-proof') // The preset's own loader tool resolves the global-layer skill. - const loaded = await ctx.tools.execute({ + const loaded = await executeToolFixture(ctx, { callId: ToolCallId('preset-skills-load'), name: 'skill', arguments: { name: 'dsh-badge' }, @@ -787,7 +788,7 @@ describe('a user preset declared from the shipped cordis rows', () => { ])) } const signal = new AbortController().signal - const listed = await copyCtx.tools.execute({ + const listed = await executeToolFixture(copyCtx, { callId: ToolCallId('copied-preset-inspect-list'), name: 'cordis_inspect_list', arguments: {}, @@ -802,7 +803,7 @@ describe('a user preset declared from the shipped cordis rows', () => { // The `Tool` provider is the one built over the host context: it // answers with the shared registry's view of the REQUESTING agent, // so the copy sees its own preset's tools, not the host's empty set. - const queried = await copyCtx.tools.execute({ + const queried = await executeToolFixture(copyCtx, { callId: ToolCallId('copied-preset-inspect-tools'), name: 'cordis_inspect_query', arguments: { platform: 'host', provider: 'Tool', method: 'listTools' }, @@ -819,12 +820,12 @@ describe('a user preset declared from the shipped cordis rows', () => { expect(queriedText).toContain(`"name": "${toolName}"`) } - // The `Config` provider reads the booted profile tree: the shipped `tools` row declares a Config, + // The `Config` provider reads the booted profile tree: the Agent loop declares a Config, // and the bootstrap include row is a carrier. The name filter keeps each page small. type ConfigRow = { id: string; patchId: string; name: string; status: string } type ConfigPage = { data: { entries: ConfigRow[]; total: number; nextOffset: number | null } } const listConfigs = async (input: Record, callId: string): Promise => { - const page = await copyCtx.tools.execute({ + const page = await executeToolFixture(copyCtx, { callId: ToolCallId(callId), name: 'cordis_inspect_query', arguments: { platform: 'host', provider: 'Config', method: 'listConfigs', input }, @@ -834,16 +835,16 @@ describe('a user preset declared from the shipped cordis rows', () => { expect(page.isError, resultText(page)).toBe(false) return (JSON.parse(resultText(page)) as ConfigPage).data } - const toolsRows = await listConfigs({ name: '@deepseek-ai/dsh-tools' }, 'copied-preset-inspect-configs') + const toolsRows = await listConfigs({ name: '@deepseek-ai/dsh-agent-loop' }, 'copied-preset-inspect-configs') const toolsRow = toolsRows.entries[0] - expect(toolsRow).toMatchObject({ patchId: 'tools', status: 'schema' }) + expect(toolsRow).toMatchObject({ patchId: 'agent-loop', status: 'schema' }) expect(toolsRows).toMatchObject({ total: 1, nextOffset: null }) const includes = await listConfigs({ name: 'cordis:include' }, 'copied-preset-inspect-includes') expect(includes.entries.find(entry => entry.patchId === 'include')).toMatchObject({ status: 'tree' }) const firstPage = await listConfigs({ limit: 5 }, 'copied-preset-inspect-page') expect(firstPage.entries).toHaveLength(5) expect(firstPage.nextOffset).toBe(5) - const projected = await copyCtx.tools.execute({ + const projected = await executeToolFixture(copyCtx, { callId: ToolCallId('copied-preset-inspect-config'), name: 'cordis_inspect_query', arguments: { platform: 'host', provider: 'Config', method: 'listConfigs', input: { entry: toolsRow!.id } }, @@ -855,7 +856,7 @@ describe('a user preset declared from the shipped cordis rows', () => { const { data } = JSON.parse(resultText(projected)) as { data: Projected } expect(data.status).toBe('schema') expect(data.schema.$defs).toHaveProperty('loaderExpression') - expect(JSON.stringify(data.schema)).toContain('"mode"') + expect(JSON.stringify(data.schema)).toContain('"maxParallelToolCalls"') } finally { await copied.dispose() await shipped.dispose() @@ -954,7 +955,7 @@ describe('a delegated child', () => { ] as const for (const [name, args] of calls) { expect(toolNames(ctx, child.agent)).toContain(name) - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { callId: ToolCallId(`child-${name}`), signal: new AbortController().signal, name, arguments: args, agent: child.agent, }) diff --git a/apps/desktop-host/package.json b/apps/desktop-host/package.json index e79792f570..2d7441061c 100644 --- a/apps/desktop-host/package.json +++ b/apps/desktop-host/package.json @@ -30,6 +30,7 @@ "devDependencies": { "@deepseek-ai/cordis-plugin-loader": "workspace:~", "@deepseek-ai/cordis-plugin-include": "workspace:~", - "@deepseek-ai/dsh-skill": "workspace:*" + "@deepseek-ai/dsh-skill": "workspace:*", + "@deepseek-ai/dsh-tool-executor-native": "workspace:*" } } diff --git a/apps/desktop-host/src/index.ts b/apps/desktop-host/src/index.ts index 7ff0cb6473..6ce80e1aa5 100644 --- a/apps/desktop-host/src/index.ts +++ b/apps/desktop-host/src/index.ts @@ -104,7 +104,7 @@ async function main(): Promise { if (process.connected) process.send?.({ type: 'platform-session', session }) }) const url = ctx.connection.authenticatedUrl(`${ctx.webServer.protocol}//127.0.0.1:${String(ctx.webServer.port)}`) - if (process.connected) process.send?.({ type: 'ready', url, injections: ctx.webServer.collectIndexInjections() }, (error) => { if (error !== null) console.error(error) }) + if (process.connected) process.send?.({ type: 'ready', url, injections: ctx.webServer.collectIndexInjections(ctx.connection.browserPeer) }, (error) => { if (error !== null) console.error(error) }) } /** Upper bound of the startup diagnostic carried over IPC; the head holds the message and stack. */ diff --git a/apps/desktop-host/tests/office.spec.ts b/apps/desktop-host/tests/office.spec.ts index 02e741d4ce..33adbf68e7 100644 --- a/apps/desktop-host/tests/office.spec.ts +++ b/apps/desktop-host/tests/office.spec.ts @@ -1,3 +1,4 @@ +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { cp, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join, sep } from 'node:path' @@ -29,7 +30,7 @@ it('loads Desktop Office CLI paths and removes them on disposal', async () => { expect('default' in desktopOffice).toBe(false) expect(ctx.loader.unwrapExports(desktopOffice)).toBe(desktopOffice) const modules = new Map([ - ['agents', AgentRegistry], ['systemPrompt', SystemPrompt], ['tools', ToolRuntime], + ['agents', AgentRegistry], ['systemPrompt', SystemPrompt], ['tools', ToolRuntime], ['toolExecutor', NativeToolExecutor], ['skills', SkillRegistry], ['office', desktopOffice], ]) ctx.loader.internal = { diff --git a/apps/web/package.json b/apps/web/package.json index 8fc868a93f..b5ec233d82 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -48,6 +48,7 @@ "@deepseek-ai/dsh-experimental-webworker-runtime": "workspace:*", "@deepseek-ai/dsh-remote-mock": "workspace:*", "@deepseek-ai/dsh-session-projection-cache": "workspace:*", + "@deepseek-ai/dsh-tool-testkit": "workspace:*", "@deepseek-ai/dsh-util-values": "workspace:*", "@types/node": "^22.20.1", "@types/ws": "8.18.1", diff --git a/apps/web/tests/background-job-list.e2e.ts b/apps/web/tests/background-job-list.e2e.ts index f407bdb53e..fab978232b 100644 --- a/apps/web/tests/background-job-list.e2e.ts +++ b/apps/web/tests/background-job-list.e2e.ts @@ -1,3 +1,4 @@ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' // Session-header background jobs driven by a real `ctx.jobs` entry. No model // call is involved. import { readFile, writeFile } from 'node:fs/promises' @@ -87,7 +88,7 @@ describe.skipIf(MODE === 'record')('web e2e: background job list', () => { const trigger = page.getByRole('button', { name: '1 background job running' }) expect(await trigger.count()).toBe(0) - const started = await scaffold.ctx.tools.execute({ + const started = await executeToolFixture(scaffold.ctx, { signal: new AbortController().signal, callId: ToolCallId('background-job-list-e2e'), name: 'bash', diff --git a/apps/web/tests/cua-permissions.e2e.ts b/apps/web/tests/cua-permissions.e2e.ts index 43ba40e4e0..fa8f95a4f8 100644 --- a/apps/web/tests/cua-permissions.e2e.ts +++ b/apps/web/tests/cua-permissions.e2e.ts @@ -56,9 +56,9 @@ it.skipIf(process.platform !== 'darwin')('keeps system requests pending until fr await scaffold.ctx.settings.mutate('cua', [{ op: 'set', path: ['allowedApps'], value: [] }]) // Application consent remains independent of the session's tool-approval policy. const grant = scaffold.ctx.tools.execute({ agent, signal: new AbortController().signal, - callId: ToolCallId('cua-app-approval'), name: 'run_code', - arguments: { code: 'return await tools.cua({ method: "getApp", params: { selector: "Fixture" } })', - description: 'Request application access' } }) + calls: [{ type: 'tool-call', id: ToolCallId('cua-app-approval'), name: 'run_code', + arguments: JSON.stringify({ code: 'return await tools.cua({ method: "getApp", params: { selector: "Fixture" } })', + description: 'Request application access' }) }] }) const approval = page.locator('[data-approval-key]') await Promise.race([approval.waitFor(), grant.then((result) => { throw new Error(`Application selection completed before consent: ${JSON.stringify(result)}`) @@ -73,7 +73,7 @@ it.skipIf(process.platform !== 'darwin')('keeps system requests pending until fr await compareOrRefreshGolden(fileURLToPath(new URL('./expected/cua-permissions/application-approval.expected.md', import.meta.url)), await captureStableAria(page, '[data-approval-key]', scaffold.workspaceCwd), webSnapshotMode()) await approval.getByRole('button', { name: 'Always allow', exact: true }).click() - expect((await grant).isError).toBe(false) + expect((await grant).results).toMatchObject([{ result: { isError: false } }]) await approval.waitFor({ state: 'hidden' }) await page.getByRole('button', { name: 'Plugins', exact: true }).click() const card = page.locator('[data-plugin-package="@deepseek-ai/dsh-cua-bundle"]') diff --git a/apps/web/tests/expected/navigation-panes/copy-markdown.expected.md b/apps/web/tests/expected/navigation-panes/copy-markdown.expected.md new file mode 100644 index 0000000000..7de5e961c1 --- /dev/null +++ b/apps/web/tests/expected/navigation-panes/copy-markdown.expected.md @@ -0,0 +1,81 @@ +# NavScenario: first run bash to + +Session file: + +```text +{{sessionFile}} +``` + +> NavScenario: first run bash to print exactly NAVIGATION_OK, then read nav-a.md and nav-b.md using two read calls in ONE assistant message, then reply with the single word FIRST_DONE and stop. + +
3 previous messages + +**Tool: `bash`** + +```json +{ + "command": "echo NAVIGATION_OK", + "description": "Print NAVIGATION_OK" +} +``` + +**Result** + +``` +NAVIGATION_OK +``` + +**Tool: `read`** + +```json +{ + "file_path": "nav-a.md" +} +``` + +**Result** + +``` +{{cwd}}/workspace/nav-a.md +file + +1: # alpha nav + +(End of file - total 1 lines) + +``` + +**Tool: `read`** + +```json +{ + "file_path": "nav-b.md" +} +``` + +**Result** + +``` +{{cwd}}/workspace/nav-b.md +file + +1: # beta nav + +(End of file - total 1 lines) + +``` + +
+ +FIRST_DONE + +> Reply in markdown with: a level-2 heading "Navigation Summary", a bulleted list of exactly two items, and a fenced code block containing echo WATERFALL. Then stop. + +## Navigation Summary + +- alpha nav +- beta nav + +``` +echo WATERFALL +``` diff --git a/apps/web/tests/jobs-menu-layering.e2e.ts b/apps/web/tests/jobs-menu-layering.e2e.ts index 040531ba95..7388c9f4b3 100644 --- a/apps/web/tests/jobs-menu-layering.e2e.ts +++ b/apps/web/tests/jobs-menu-layering.e2e.ts @@ -1,4 +1,5 @@ /** Background-job menu geometry over the normal Browser dock in the shipped Web composition. */ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' import { mkdir, readFile, writeFile } from 'node:fs/promises' import { fileURLToPath } from 'node:url' import { join } from 'node:path' @@ -116,7 +117,7 @@ describe.skipIf(MODE === 'record')('web e2e: jobs menu layering', () => { const opened = scaffold.ctx.agents.get(SEED_ID) if (opened === undefined) throw new Error('opened session has no live Agent') agent = opened - const started = await scaffold.ctx.tools.execute({ + const started = await executeToolFixture(scaffold.ctx, { signal: new AbortController().signal, callId: ToolCallId('jobs-menu-layering-e2e'), name: 'bash', diff --git a/apps/web/tests/live-job-stream.e2e.ts b/apps/web/tests/live-job-stream.e2e.ts index 2b4fb64d68..4bffdb8a62 100644 --- a/apps/web/tests/live-job-stream.e2e.ts +++ b/apps/web/tests/live-job-stream.e2e.ts @@ -1,3 +1,4 @@ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' // Session-header job list driven by a real background bash run: the job row // arrives over the job roster stream, expanding it opens the output // observation stream, and the panel shows the process's real output while it @@ -87,7 +88,7 @@ describe.skipIf(MODE === 'record')('web e2e: live job stream', () => { const trigger = page.getByRole('button', { name: '1 background job running' }) expect(await trigger.count()).toBe(0) - const started = await scaffold.ctx.tools.execute({ + const started = await executeToolFixture(scaffold.ctx, { signal: new AbortController().signal, callId: ToolCallId('live-job-stream-e2e'), name: 'bash', diff --git a/apps/web/tests/minimal-preset.snapshot.ts b/apps/web/tests/minimal-preset.snapshot.ts index 16eebb7273..bec53293a7 100644 --- a/apps/web/tests/minimal-preset.snapshot.ts +++ b/apps/web/tests/minimal-preset.snapshot.ts @@ -1,3 +1,4 @@ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' import { mkdir, readFile, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { fileURLToPath } from 'node:url' @@ -97,14 +98,14 @@ describe('minimal agent preset', () => { const stateDir = join(scaffold.workspaceCwd, 'persistent-state') await mkdir(stateDir) const signal = new AbortController().signal - await scaffold.ctx.tools.execute({ + await executeToolFixture(scaffold.ctx, { signal, callId: ToolCallId('minimal-bash-state-setup'), name: 'bash', arguments: { command: `cd ${JSON.stringify(stateDir)} && export DSH_MINIMAL_STATE=PERSISTED` }, agent: agentHandle.agent, }) - const bash = await scaffold.ctx.tools.execute({ + const bash = await executeToolFixture(scaffold.ctx, { signal, callId: ToolCallId('minimal-bash-state-read'), name: 'bash', diff --git a/apps/web/tests/navigation-panes.e2e.ts b/apps/web/tests/navigation-panes.e2e.ts index effd31c913..ae8726c23f 100644 --- a/apps/web/tests/navigation-panes.e2e.ts +++ b/apps/web/tests/navigation-panes.e2e.ts @@ -6,7 +6,7 @@ // under the standard discipline: turn 1 produces a bash call plus two // parallel reads in one assistant message (tool-call density for the // trajectory ledger/timing lanes), turn 2 a markdown-rich reply. -import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { mkdir, readFile, stat, writeFile } from 'node:fs/promises' import { fileURLToPath } from 'node:url' import { join } from 'node:path' import type { Browser, Page, Response } from 'playwright' @@ -14,7 +14,7 @@ import { chromium } from 'playwright' import { strFromU8, unzipSync } from 'fflate' import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, onTestFailed } from 'vitest' import { parseSessionLog } from '@deepseek-ai/dsh-llm-replay' -import { SESSION_FORMAT_VERSION, type SessionEvent } from '@deepseek-ai/dsh-session' +import { SESSION_FORMAT_VERSION, SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import { assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, launchWebScaffold, recordFixture, seedSession, watchConsole, webSnapshotMode, type WebScaffold, @@ -32,6 +32,7 @@ const TERMINAL_EXPECTED = join(SNAPSHOT_DIR, 'terminal-card.expected.md') const CODE_CARD_DIR = fileURLToPath(new URL('./expected/navigation-panes', import.meta.url)) const COMMAND_WRAP_EXPECTED = join(CODE_CARD_DIR, 'command-wrap.expected.md') const CODE_CARD_EXPECTED = join(CODE_CARD_DIR, 'code-card.expected.md') +const COPY_MARKDOWN_EXPECTED = join(CODE_CARD_DIR, 'copy-markdown.expected.md') const MODE = webSnapshotMode() const SEED_ID = 'navigation-panes-web-e2e' const EXPORTED_LOG_FILE = `session.v${SESSION_FORMAT_VERSION}.jsonl` @@ -424,6 +425,66 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { } }, 120_000) + it.skipIf(MODE === 'record')('copies the complete Session as Markdown from the Session Header', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-copy-markdown')) + await ensureSeedOpen(page) + await page.context().grantPermissions(['clipboard-read', 'clipboard-write']) + expect(await page.evaluate(() => typeof ClipboardItem)).toBe('function') + expect(await page.evaluate(() => typeof navigator.clipboard.write)).toBe('function') + await page.evaluate(() => { + const clipboard = navigator.clipboard + const originalWrite = clipboard.write.bind(clipboard) + Reflect.set(globalThis, '__dshClipboardWrites', 0) + Object.defineProperty(clipboard, 'write', { + configurable: true, + value: (...args: Parameters) => { + Reflect.set(globalThis, '__dshClipboardWrites', Number(Reflect.get(globalThis, '__dshClipboardWrites')) + 1) + return originalWrite(...args) + }, + }) + }) + const markdownResponse = page.waitForResponse(response => + response.request().method() === 'GET' + && new URL(response.url()).pathname === '/api/session.markdown', { timeout: 30_000 }) + const copyButton = page.getByRole('button', { name: 'More actions' }) + await copyButton.click() + await page.getByRole('menuitem', { name: 'Copy Session as Markdown' }).click() + const response = await markdownResponse + expect(response.status()).toBe(200) + expect(response.headers()['content-type']).toBe('text/markdown; charset=utf-8') + await page.getByText('Session copied as Markdown.', { exact: true }).waitFor({ timeout: 30_000 }) + expect(await page.evaluate(() => Number(Reflect.get(globalThis, '__dshClipboardWrites')))).toBe(1) + + const sourcePath = (await scaffold.ctx.sessionPersistence.stat(SessionId(SEED_ID)))?.location?.path + if (sourcePath === undefined) throw new Error('seeded Session has no materialized artifact location') + expect((await stat(sourcePath)).isFile()).toBe(true) + expect(sourcePath.startsWith(`${scaffold.persistenceRoot}/`)).toBe(true) + const markdown = await page.evaluate(() => navigator.clipboard.readText()) + expect(markdown).toContain(`Session file:\n\n\`\`\`text\n${sourcePath}\n\`\`\``) + const normalized = markdown + .split(sourcePath).join('{{sessionFile}}') + .split(scaffold.workspaceCwd).join('{{cwd}}') + // compareOrRefreshGolden appends its own newline; the clipboard text already ends with exactly one. + await compareOrRefreshGolden(COPY_MARKDOWN_EXPECTED, normalized.replace(/\n$/u, ''), MODE) + expect(normalized).toContain('Session file:\n\n```text\n{{sessionFile}}\n```') + expect(normalized).toContain('# NavScenario: first run bash to') + expect(normalized).toContain('> NavScenario: first run bash to print exactly NAVIGATION_OK') + expect(normalized).toContain('
3 previous messages') + expect(normalized).toContain('**Tool: `bash`**') + expect(normalized).toContain('NAVIGATION_OK') + expect(normalized).toContain('FIRST_DONE') + expect(normalized).toContain('Navigation Summary') + expect(normalized).not.toContain('The user wants me to follow') + expect(normalized).not.toContain('{{system}}') + expect(normalized).not.toContain('request/header') + expect(normalized.endsWith('\n')).toBe(true) + expect(normalized.endsWith('\n\n')).toBe(false) + + await copyButton.click() + expect(await page.getByRole('menuitem', { name: 'Copy Session as Markdown' }).isDisabled()).toBe(false) + await page.keyboard.press('Escape') + }, 60_000) + it.skipIf(MODE === 'record')('focuses the ledger by dragging an overview interval', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-timeline')) await ensureSeedOpen(page) @@ -561,7 +622,9 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { }, 60_000) it.skipIf(MODE === 'record')('keeps the recorded fixture inventory exact', async () => { - await assertFixtureInventory(CODE_CARD_DIR, ['code-card.expected.md', 'command-wrap.expected.md']) + await assertFixtureInventory(CODE_CARD_DIR, [ + 'code-card.expected.md', 'command-wrap.expected.md', 'copy-markdown.expected.md', + ]) await assertFixtureInventory(SNAPSHOT_DIR, [ 'session.v3.jsonl', 'search-id-results.expected.md', 'search-results.expected.md', 'trajectory.expected.md', 'terminal-card.expected.md', 'timing.expected.md', diff --git a/apps/web/tests/question-composer.e2e.ts b/apps/web/tests/question-composer.e2e.ts index ae8f9818ec..b999cab7cf 100644 --- a/apps/web/tests/question-composer.e2e.ts +++ b/apps/web/tests/question-composer.e2e.ts @@ -68,9 +68,43 @@ async function capMetrics(field: Locator): Promise<{ textLines: number; scrolls: }) } -// The options carry long descriptions on purpose: the squeeze assertion below -// needs option copy that WRAPS, which is the only text layout that reproduces a -// collapsed row painting its copy outside its own box. +/** + * Read how the composer draws one long-copy element: the lines it shows, + * whether CSS clipped it, whether its text overflows the line budget, and what + * its toggle does while that toggle is visible. + * @param element - a question heading or an option description. + * @returns the drawn state of the copy. + */ +function readCopy(element: HTMLElement): { lines: number; clipped: boolean; overflows: boolean; toggle: 'expand' | 'collapse' | undefined } { + const style = getComputedStyle(element) + const toggle = element.tagName === 'H2' + ? element.nextElementSibling + : element.closest('[data-option-row]')?.querySelector(':scope > button:not([role])') + return { + // A clipped box keeps its full budget and gives the dropped line back through its bottom margin. + lines: Math.round((element.getBoundingClientRect().height + parseFloat(style.marginBottom)) / parseFloat(style.lineHeight)), + clipped: style.maskImage !== 'none', + overflows: element.scrollHeight > element.clientHeight + 0.5, + toggle: toggle instanceof HTMLElement && getComputedStyle(toggle).visibility === 'visible' + ? toggle.getAttribute('aria-expanded') === 'true' ? 'collapse' : 'expand' + : undefined, + } +} + +/** + * Tell whether an expanded copy's toggle sits below the copy's last text line. + * @param toggle - the copy's toggle button. + * @param copy - the expanded question heading or option description. + * @returns whether the toggle starts at or below the bottom of the text; false without the copy. + */ +function belowText(toggle: HTMLElement, copy: Element | null): boolean { + if (copy === null) return false + const textBottom = copy.getBoundingClientRect().bottom - parseFloat(getComputedStyle(copy).paddingBottom) + return toggle.getBoundingClientRect().top >= textBottom - 0.5 +} + +// The options carry descriptions on purpose: the squeeze assertion below +// verifies that each description keeps to its two-line budget in the card scrollport. const PROMPT = 'Use the ask_user_question tool to ask me exactly one multi-select question with id "color", question "Which color do you prefer?", header "Pick one", and two options: label "Blue" with description "A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.", and label "Green" with description "A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions." Set multi_select to true. After I answer, reply with the single word DONE and stop.' function isRecord(value: unknown): value is Record { @@ -338,53 +372,85 @@ describe('web e2e: resident question composer round trip', () => { // This golden owns the stable question surface; the answered-state // golden below owns the resulting transcript. const snapshot = await captureStableAria(page, '[data-question-key]', scaffold.workspaceCwd) - await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) + const descriptions = await page.locator('[data-question-key] [role="checkbox"]').evaluateAll(nodes => + nodes.map(node => ({ + label: node.getAttribute('aria-label'), + description: (node.getAttribute('aria-describedby') ?? '').split(/\s+/) + .filter(Boolean).map(id => document.getElementById(id)?.textContent ?? '').join(' '), + }))) + await compareOrRefreshGolden(UI_EXPECTED, `${snapshot}\nChoice descriptions: ${JSON.stringify(descriptions)}`, MODE) const sidebar = await captureStableAria(page, '[role="treeitem"][aria-selected="true"]', scaffold.workspaceCwd) await compareOrRefreshGolden(SIDEBAR_EXPECTED, sidebar, MODE) } - // Squeezed card: the option rows are the capped card's scroll content, so - // shrinking the seat must push overflow into the option list, never - // collapse a row below the height its own copy needs — a collapsed row - // paints its centered copy outside the row box, over the title and the - // neighbouring rows. Measured on the live composer at seat heights that - // force the cap, then restored for the answer gesture below. Replay only: - // record mode must reach the recording write below, not abort on layout. + // Squeezed card: the question and compact option rows share one scrollport, + // while navigation and submission stay outside it. Measured on the live + // composer at seat heights that force the cap, then restored for the answer + // gesture below. Replay only: record mode must reach the recording write + // below, not abort on layout. + const originalViewport = page.viewportSize() ?? { width: 1680, height: 1000 } if (MODE !== 'record') { - const original = page.viewportSize() ?? { width: 1680, height: 1000 } + const scrollStates: boolean[] = [] for (const height of [520, 440, 380]) { - await page.setViewportSize({ width: 900, height }) + await page.setViewportSize({ width: 640, height }) const squeeze = await composer.evaluate((card) => { - // Role/ARIA selectors, not the CSS-module class names: the built - // client hashes those. - const rows = [...card.querySelectorAll( - '[role="radio"], [role="checkbox"], [aria-expanded]', - )] + const rows = [...card.querySelectorAll('[data-option-row]')] const spill = rows.map(row => Math.max(...[...row.children].map((child) => { const box = row.getBoundingClientRect() const inner = child.getBoundingClientRect() return Math.max(box.top - inner.top, inner.bottom - box.bottom) }))) const list = card.querySelector('[data-question-scroll]') + const title = card.querySelector('h2') return { rows: rows.length, spill: Math.max(...spill), - // Wrapped option text is what overflows a collapsed row, and a - // scrolling list proves the seat is genuinely capped. Without both, - // the spill assertion would hold vacuously. - wrappedRows: rows.filter(row => row.getBoundingClientRect().height > 42).length, scrolls: list === null ? false : list.scrollHeight > list.clientHeight, + titleInScrollport: list?.contains(title) === true, } }) expect(squeeze.rows).toBeGreaterThan(0) - expect(squeeze.wrappedRows).toBeGreaterThan(0) - expect(squeeze.scrolls).toBe(true) + for (const description of await composer.locator('[data-option-description]').all()) { + const copy = await description.evaluate(readCopy) + expect(copy.lines).toBeLessThanOrEqual(2) + expect(copy.clipped).toBe(copy.overflows) + expect(copy.toggle).toBe(copy.clipped ? 'expand' : undefined) + } + scrollStates.push(squeeze.scrolls) + expect(squeeze.titleInScrollport).toBe(true) + expect(await composer.getByRole('button', { name: 'Submit', exact: true }).isVisible()).toBe(true) // Sub-pixel tolerance: every row's copy stays inside its border box. expect(squeeze.spill).toBeLessThan(0.6) } - await page.setViewportSize(original) + expect(scrollStates).toContain(true) + await page.setViewportSize(originalViewport) } + // The narrowest supported card clips Blue's description to one line; its + // toggle expands the text in place without choosing Blue. + await page.setViewportSize({ width: 390, height: 700 }) + const blue = composer.getByRole('checkbox', { name: 'Blue' }) + const blueText = composer.locator('[data-option-description]').first() + const blueToggle = composer.locator(`button[aria-controls=${JSON.stringify(await blueText.getAttribute('id'))}]`) + await expect.poll(() => blueText.evaluate(readCopy)).toEqual({ lines: 1, clipped: true, overflows: true, toggle: 'expand' }) + // A clipped description's dropped line takes no clicks: the custom row's top edge belongs to it. + expect(await composer.locator('[data-option-description]').last().evaluate(readCopy)).toMatchObject({ clipped: true }) + expect(await composer.locator('textarea').evaluate((field) => { + const row = field.closest('[class*="customRow"]') + if (!(row instanceof HTMLElement)) return false + const box = row.getBoundingClientRect() + const hit = document.elementFromPoint(box.left + box.width / 2, box.top + 2) + return hit !== null && row.contains(hit) + })).toBe(true) + await blueToggle.click() + expect(await blue.getAttribute('aria-checked')).toBe('false') + expect(await blueToggle.getAttribute('aria-expanded')).toBe('true') + expect(await blueText.evaluate(readCopy)).toMatchObject({ clipped: false, overflows: false, toggle: 'collapse' }) + await blueToggle.click() + // A collapsed copy's scroll timeline reports the overflow from the next frame. + await expect.poll(() => blueText.evaluate(readCopy)).toEqual({ lines: 1, clipped: true, overflows: true, toggle: 'expand' }) + await page.setViewportSize(originalViewport) + // Multi-line custom answer: the field is a textarea whose hidden mirror // owns the box height, so a soft-wrapped or line-broken draft GROWS the // field instead of scrolling one line, and Shift+Enter breaks the line @@ -415,7 +481,6 @@ describe('web e2e: resident question composer round trip', () => { await custom.fill('') } - const blue = composer.getByRole('checkbox', { name: 'Blue' }) await blue.click() await custom.fill('Include accessibility notes') expect(await blue.getAttribute('aria-checked')).toBe('true') @@ -536,6 +601,436 @@ describe('web e2e: resident question composer round trip', () => { await expect.poll(() => page.locator('[data-question-key]').count(), { timeout: 10_000 }).toBe(0) }, 60_000) + it.skipIf(MODE === 'record')('keeps short option explanations beside their labels', async () => { + const sessionId = answeredSession + expect(sessionId).toBeDefined() + const agent = scaffold.ctx.agents.get(sessionId as SessionId) + expect(agent).toBeDefined() + const controller = new AbortController() + const asked = scaffold.ctx.userQuestions.ask({ + agent: agent as NonNullable, + signal: controller.signal, + questions: [{ + id: 'reasoning', + header: '确认思考等级', + question: '选择 HF provider 的思考等级?', + options: [ + { label: '高(high)', description: '倾向更充分的推理,通常更慢、消耗更多 token,需模型支持。' }, + { label: '中(medium)', description: '平衡推理深度与速度;需模型支持。' }, + { label: '低(low)', description: '优先速度与较低 token 消耗;需模型支持。' }, + { label: '先查看当前配置', description: '先确认 HF provider 的模型及其支持的思考参数,再决定。' }, + ], + }], + }) + const outcome = asked.then(value => ({ value }), (error: unknown) => ({ error })) + const composer = page.locator('[data-question-key]') + const original = page.viewportSize() ?? { width: 1680, height: 1000 } + let completed = false + try { + await page.setViewportSize({ width: 1328, height: 720 }) + await composer.getByRole('heading', { name: '选择 HF provider 的思考等级?' }).waitFor() + expect(await composer.getByRole('heading').evaluate(readCopy)) + .toEqual({ lines: 1, clipped: false, overflows: false, toggle: undefined }) + expect(await composer.getByRole('button', { name: 'Expand', exact: true }).count()).toBe(0) + const rows = await composer.locator('[data-option-row]').evaluateAll(options => options.map((row) => { + const label = row.querySelector('[data-option-line] > span:first-child') + const description = row.querySelector('[data-option-description]') + if (label === null || description === null) return null + const style = getComputedStyle(description) + return { + sameLine: description.getBoundingClientRect().top < label.getBoundingClientRect().bottom, + fontSize: style.fontSize, + lineHeight: style.lineHeight, + maskImage: style.maskImage, + } + })) + expect(rows).toHaveLength(4) + expect(rows).toEqual(Array.from({ length: 4 }, () => ({ + sameLine: true, fontSize: '13px', lineHeight: '20px', maskImage: 'none', + }))) + await composer.getByRole('radio', { name: '中(medium)' }).click() + await composer.getByRole('button', { name: 'Submit', exact: true }).click() + expect(await outcome).toEqual({ value: { answers: [{ id: 'reasoning', selected: ['中(medium)'] }] } }) + completed = true + } finally { + controller.abort() + const cleanup = await Promise.allSettled([outcome, page.setViewportSize(original)]) + if (completed) { + const failure = cleanup.find((result): result is PromiseRejectedResult => result.status === 'rejected') + if (failure !== undefined) throw failure.reason + } + } + }, 60_000) + + it.skipIf(MODE === 'record')('keeps long questions answerable in short and zoomed viewports', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-question-long-copy')) + const sessionId = answeredSession + expect(sessionId).toBeDefined() + const agent = scaffold.ctx.agents.get(sessionId as SessionId) + expect(agent).toBeDefined() + const question = Array.from({ length: 8 }, () => 'The release gate reports that the installed application graph still contains an experimental package through a default dependency. Which approach should we use to preserve explicit installation while keeping the supported application graph valid?').join(' ') + const detail = 'Review the shipped profile, package ownership, upgrade impact, and the path users follow before choosing. The answer must preserve the existing release invariant, avoid a hidden compatibility path, and remain understandable to people who did not participate in the implementation discussion.' + const description = Array.from({ length: 48 }, (_, index) => + `Migration step ${String(index + 1)} removes the experimental client from shipped templates, requires an explicit profile install, names the affected users, and records the cross-platform validation before rollout.`).join(' ') + const controller = new AbortController() + const asked = scaffold.ctx.userQuestions.ask({ + agent: agent as NonNullable, + signal: controller.signal, + questions: [{ + id: 'release', + header: 'Release decision', + question, + detail, + multiSelect: true, + options: [ + { label: 'Explicit install', description }, + { label: 'Keep the default edge', description: 'Keep the current setup.' }, + { label: 'Move the package', description: 'Move it to a separately installed package with an explicit profile entry.' }, + ], + }, { id: 'followup', question: 'Who should own the rollout?', options: [{ label: 'Release team' }] }], + }) + const outcome = asked.then(value => ({ value }), (error: unknown) => ({ error })) + + const composer = page.locator('[data-question-key]') + const original = page.viewportSize() ?? { width: 1680, height: 1000 } + let bodyCompleted = false + try { + await composer.waitFor({ timeout: 30_000 }) + const heading = composer.getByRole('heading', { name: question }) + const descriptionOf = (label: string) => composer.locator('[data-option-row]', { + has: page.getByRole('checkbox', { name: label, exact: true }), + }).locator('[data-option-description]') + const toggleFor = async (copy: Locator) => + composer.locator(`button[aria-controls=${JSON.stringify(await copy.getAttribute('id'))}]`) + const readMetrics = () => composer.evaluate((card) => { + const scroll = card.querySelector('[data-question-scroll]') + const title = card.querySelector('h2') + const submit = [...card.querySelectorAll('button')] + .find(button => button.textContent === 'Next') + if (scroll === null || title === null || submit === undefined) return null + const submitBox = submit.getBoundingClientRect() + return { + footerFits: submitBox.top >= -0.5 && submitBox.bottom <= window.innerHeight + 0.5, + scrolls: scroll.scrollHeight > scroll.clientHeight, + titleInScrollport: scroll.contains(title), + } + }) + // Cover common desktop sizes plus the CSS-pixel equivalents of a + // progressively zoomed 640x420 window without changing browser-global zoom. + const scrollStates: boolean[] = [] + for (const viewport of [ + { width: 1440, height: 900 }, + { width: 900, height: 600 }, + { width: 640, height: 420 }, + { width: 512, height: 336 }, + { width: 427, height: 280 }, + { width: 390, height: 700 }, + ]) { + await page.setViewportSize(viewport) + await expect.poll(readMetrics).not.toBeNull() + const metrics = await readMetrics() + expect(metrics?.footerFits).toBe(true) + expect(metrics?.titleInScrollport).toBe(true) + scrollStates.push(metrics?.scrolls ?? false) + // Past its three-line budget the question keeps two lines; past its + // two-line budget a description keeps one. Copy within budget shows in full. + await expect.poll(() => heading.evaluate(readCopy)) + .toEqual({ lines: 2, clipped: true, overflows: true, toggle: 'expand' }) + expect(await descriptionOf('Explicit install').evaluate(readCopy)) + .toEqual({ lines: 1, clipped: true, overflows: true, toggle: 'expand' }) + expect(await descriptionOf('Keep the default edge').evaluate(readCopy)) + .toEqual({ lines: 1, clipped: false, overflows: false, toggle: undefined }) + for (const copy of await composer.locator('[data-option-description]').all()) { + const drawn = await copy.evaluate(readCopy) + expect(drawn.clipped).toBe(drawn.overflows) + expect(drawn.toggle).toBe(drawn.clipped ? 'expand' : undefined) + expect(drawn.lines).toBeLessThanOrEqual(drawn.clipped ? 1 : 2) + } + if (viewport.width === 512) { + expect(await descriptionOf('Move the package').evaluate(readCopy)) + .toEqual({ lines: 2, clipped: false, overflows: false, toggle: undefined }) + } + const textMetrics = await composer.locator('[data-option-description]').evaluateAll(nodes => nodes.map((node) => { + const style = getComputedStyle(node) + const rect = node.getBoundingClientRect() + return { fontSize: style.fontSize, lineHeight: style.lineHeight, + fitsViewport: rect.left >= 0 && rect.right <= innerWidth } + })) + expect(textMetrics).toEqual(Array.from({ length: 3 }, () => ({ + fontSize: '13px', lineHeight: '20px', fitsViewport: true, + }))) + } + expect(scrollStates).toContain(false) + expect(scrollStates).toContain(true) + + // A clipped question's dropped line takes no clicks: the detail's first line belongs to the detail. + expect(await composer.getByText(detail, { exact: true }).evaluate((text) => { + const box = text.getBoundingClientRect() + const hit = document.elementFromPoint(box.left + 8, box.top + 3) + return hit !== null && text.contains(hit) + })).toBe(true) + + // The question expands in place; its choices move down inside the + // scrollport while navigation and submission stay reachable. + const questionToggle = await toggleFor(heading) + expect(await questionToggle.getAttribute('aria-expanded')).toBe('false') + expect(await questionToggle.getAttribute('aria-label')).toBe('Expand') + expect(await questionToggle.getAttribute('aria-describedby')).toBe(await heading.getAttribute('id')) + await questionToggle.click() + expect(await questionToggle.getAttribute('aria-expanded')).toBe('true') + expect(await questionToggle.getAttribute('aria-label')).toBe('Collapse') + const expandedQuestion = await heading.evaluate(readCopy) + expect(expandedQuestion).toMatchObject({ clipped: false, overflows: false, toggle: 'collapse' }) + expect(expandedQuestion.lines).toBeGreaterThan(3) + expect(await questionToggle.evaluate(belowText, await heading.elementHandle())).toBe(true) + expect(await readMetrics()).toEqual({ footerFits: true, scrolls: true, titleInScrollport: true }) + await questionToggle.click() + await expect.poll(() => heading.evaluate(readCopy)).toEqual({ lines: 2, clipped: true, overflows: true, toggle: 'expand' }) + + const detailText = composer.getByText(detail, { exact: true }) + expect(await detailText.evaluate(node => node.closest('[data-question-scroll]') !== null)).toBe(true) + await detailText.scrollIntoViewIfNeeded() + expect(await detailText.isVisible()).toBe(true) + expect(await composer.getByRole('button', { name: 'Next', exact: true }).isVisible()).toBe(true) + + // A description expands in place without choosing its option, and its + // toggle stays described by the text it controls. + const explicit = composer.getByRole('checkbox', { name: 'Explicit install' }) + const explicitText = descriptionOf('Explicit install') + const descriptionToggle = await toggleFor(explicitText) + const descriptionId = await explicitText.getAttribute('id') + expect(await explicit.getAttribute('aria-describedby')).toBe(descriptionId) + expect(await descriptionToggle.getAttribute('aria-describedby')).toBe(descriptionId) + expect(await descriptionToggle.getAttribute('aria-label')).toBe('Expand') + await descriptionToggle.click() + expect(await explicit.getAttribute('aria-checked')).toBe('false') + const expandedDescription = await explicitText.evaluate(readCopy) + expect(expandedDescription).toMatchObject({ clipped: false, overflows: false, toggle: 'collapse' }) + expect(expandedDescription.lines).toBeGreaterThan(2) + expect(await descriptionToggle.evaluate(belowText, await explicitText.elementHandle())).toBe(true) + expect(await explicitText.evaluate((text) => { + const row = text.closest('[data-option-row]') as HTMLElement + return [...row.querySelectorAll('*')].every(child => + child.getBoundingClientRect().bottom <= row.getBoundingClientRect().bottom + 0.5) + })).toBe(true) + + // The toggle's icon keeps non-text contrast on the card in both themes. + const palette: string[] = [] + const originalDarkTheme = await page.locator('body').getAttribute('data-ds-dark-theme') + try { + for (const dark of [false, true]) { + await page.evaluate((value) => { document.body.toggleAttribute('data-ds-dark-theme', value) }, dark) + const colors = await descriptionToggle.evaluate((toggle) => { + const card = toggle.closest('section') + return card === null ? null : { text: getComputedStyle(toggle).color, surface: getComputedStyle(card).backgroundColor } + }) + if (colors === null) throw new Error('The question card is unavailable') + const channels = (color: string) => color.match(/[\d.]+/g)?.slice(0, 3).map(Number) ?? [] + const luminance = (color: string) => channels(color).reduce((total, channel, index) => { + const normalized = channel / 255 + const linear = normalized <= 0.04045 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4 + return total + linear * [0.2126, 0.7152, 0.0722][index]! + }, 0) + const foreground = luminance(colors.text) + const background = luminance(colors.surface) + expect((Math.max(foreground, background) + 0.05) / (Math.min(foreground, background) + 0.05)) + .toBeGreaterThanOrEqual(3) + palette.push(colors.surface) + } + expect(palette[0]).not.toBe(palette[1]) + } finally { + await page.evaluate((previous) => { + if (previous === null) document.body.removeAttribute('data-ds-dark-theme') + else document.body.setAttribute('data-ds-dark-theme', previous) + }, originalDarkTheme) + } + await descriptionToggle.click() + await expect.poll(() => explicitText.evaluate(readCopy)) + .toEqual({ lines: 1, clipped: true, overflows: true, toggle: 'expand' }) + + await composer.getByRole('checkbox', { name: 'Explicit install' }).click() + const answer = composer.getByRole('textbox') + await answer.fill('Keep the migration steps explicit.') + await answer.press('Enter') + await composer.getByRole('heading', { name: 'Who should own the rollout?' }).waitFor() + expect(await answer.evaluate(node => document.activeElement === node)).toBe(true) + await page.keyboard.type('Release owner') + expect(await answer.inputValue()).toBe('Release owner') + await composer.getByRole('button', { name: 'Submit', exact: true }).click() + expect(await outcome).toEqual({ + value: { + answers: [{ + id: 'release', + selected: ['Explicit install'], + custom: 'Keep the migration steps explicit.', + }, { id: 'followup', selected: [], custom: 'Release owner' }], + }, + }) + await expect.poll(() => page.locator('[data-question-key]').count(), { timeout: 10_000 }).toBe(0) + bodyCompleted = true + } finally { + controller.abort() + const cleanup = await Promise.allSettled([outcome, page.setViewportSize(original)]) + if (bodyCompleted) { + const failure = cleanup.find((result): result is PromiseRejectedResult => result.status === 'rejected') + if (failure !== undefined) throw failure.reason + } + } + }, 60_000) + + it.skipIf(MODE === 'record')('re-evaluates long copy when the chat column or the window changes width', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-question-chat-width')) + const sessionId = answeredSession + expect(sessionId).toBeDefined() + const agent = scaffold.ctx.agents.get(sessionId as SessionId) + expect(agent).toBeDefined() + // Sized for the chat-width drag range: the question takes three lines or + // fewer in the widest chat column and more in the narrowest, and the staged + // description takes two lines or fewer and more. + const question = `${Array.from({ length: 2 }, () => 'The rollout plan must name the release owner, the verification lanes that gate it, and the rollback path before the candidate is cut.').join(' ')} Which plan should we adopt?` + const staged = 'Stage the rollout behind the release channel first, keep the experimental client out of every shipped template, ask plugin authors to declare the dependency explicitly, and publish the upgrade note together with the cross-platform validation results.' + const explicit = Array.from({ length: 24 }, (_, index) => + `Step ${String(index + 1)} removes the experimental client from shipped templates and records the validation before rollout.`).join(' ') + const controller = new AbortController() + const asked = scaffold.ctx.userQuestions.ask({ + agent: agent as NonNullable, + signal: controller.signal, + questions: [{ + id: 'rollout', + header: 'Rollout decision', + question, + options: [ + { label: 'Explicit install', description: explicit }, + { label: 'Stage the rollout', description: staged }, + { label: 'Keep the default edge', description: 'Keep the current setup.' }, + ], + }], + }) + const outcome = asked.then(value => ({ value }), (error: unknown) => ({ error })) + + const composer = page.locator('[data-question-key]') + const frame = page.locator('[style*="grid-template-columns"]').first() + const original = page.viewportSize() ?? { width: 1680, height: 1000 } + let bodyCompleted = false + try { + await page.setViewportSize({ width: 1680, height: 1000 }) + await composer.waitFor({ timeout: 30_000 }) + const heading = composer.getByRole('heading', { name: question }) + const descriptionOf = (label: string) => composer.locator('[data-option-row]', { + has: page.getByRole('radio', { name: label, exact: true }), + }).locator('[data-option-description]') + const toggleFor = async (copy: Locator) => + composer.locator(`button[aria-controls=${JSON.stringify(await copy.getAttribute('id'))}]`) + // Settled layout: no AppFrame column transition in flight, then every + // collapsed copy drawn as its own overflow decides, the expanded copy + // still expanded, and the actions reachable. + const settle = async () => { + await expect.poll(() => frame.evaluate(node => node.hasAttribute('data-animating'))).toBe(false) + const read = async () => { + const card = await composer.locator('section').evaluate((section) => { + const scroll = section.querySelector('[data-question-scroll]') + const submit = [...section.querySelectorAll('button')] + .find(button => button.textContent === 'Submit') + const submitBox = submit?.getBoundingClientRect() + return { + width: Math.round(section.getBoundingClientRect().width), + actionsReachable: submitBox !== undefined && submitBox.top >= -0.5 && submitBox.bottom <= window.innerHeight + 0.5, + titleInScrollport: scroll?.contains(section.querySelector('h2')) === true, + } + }) + return { + card, + question: await heading.evaluate(readCopy), + explicit: await descriptionOf('Explicit install').evaluate(readCopy), + staged: await descriptionOf('Stage the rollout').evaluate(readCopy), + short: await descriptionOf('Keep the default edge').evaluate(readCopy), + } + } + await expect.poll(async () => { + const state = await read() + const decided = (copy: Awaited>['question'], budget: number) => + copy.clipped === copy.overflows && copy.toggle === (copy.clipped ? 'expand' : undefined) + && copy.lines <= (copy.clipped ? budget - 1 : budget) + return state.card.actionsReachable && state.card.titleInScrollport + && decided(state.question, 3) && decided(state.staged, 2) && decided(state.short, 2) + && !state.explicit.clipped && !state.explicit.overflows && state.explicit.toggle === 'collapse' + }).toBe(true) + return await read() + } + // The right transcript handle widens the content column by twice its + // outward travel. The composer seat covers the handle's lower part, so + // the gesture grabs it beside the transcript. + const dragChatWidth = async (travel: number) => { + const box = await page.locator('[data-width-handle="right"]').boundingBox() + if (box === null) throw new Error('The chat width handle is not rendered') + const x = box.x + box.width / 2 + const y = box.y + 120 + await page.mouse.move(x, y) + await page.mouse.down() + try { + await page.mouse.move(x + travel, y, { steps: 8 }) + } finally { + await page.mouse.up() + } + } + + // Expanded copy is the user's choice and survives every width change below. + const explicitToggle = await toggleFor(descriptionOf('Explicit install')) + await explicitToggle.click() + expect(await explicitToggle.getAttribute('aria-expanded')).toBe('true') + expect(await composer.getByRole('radio', { name: 'Explicit install', exact: true }).getAttribute('aria-checked')).toBe('false') + + await dragChatWidth(-400) + const narrow = await settle() + expect(narrow.question).toEqual({ lines: 2, clipped: true, overflows: true, toggle: 'expand' }) + expect(narrow.staged).toEqual({ lines: 1, clipped: true, overflows: true, toggle: 'expand' }) + + await dragChatWidth(600) + const wide = await settle() + expect(wide.card.width).toBeGreaterThan(narrow.card.width) + expect(wide.question).toMatchObject({ clipped: false, overflows: false, toggle: undefined }) + expect(wide.staged).toEqual({ lines: 2, clipped: false, overflows: false, toggle: undefined }) + expect(wide.short).toEqual({ lines: 1, clipped: false, overflows: false, toggle: undefined }) + + // The right sidebar narrows the conversation column, and collapsing it restores the wide state. + await page.getByRole('button', { name: 'Open right sidebar', exact: true }).click() + const withSidebar = await settle() + expect(withSidebar.card.width).toBeLessThan(wide.card.width) + await page.getByRole('button', { name: 'Collapse right sidebar', exact: true }).click() + expect(await settle()).toEqual(wide) + + // A smaller window clamps the dragged width; restoring the window restores it. + await page.setViewportSize({ width: 900, height: 700 }) + const smallWindow = await settle() + expect(smallWindow.card.width).toBeLessThan(wide.card.width) + expect(smallWindow.question).toEqual({ lines: 2, clipped: true, overflows: true, toggle: 'expand' }) + expect(smallWindow.staged).toEqual({ lines: 1, clipped: true, overflows: true, toggle: 'expand' }) + await page.setViewportSize({ width: 1680, height: 1000 }) + expect(await settle()).toEqual(wide) + + await explicitToggle.click() + await expect.poll(() => descriptionOf('Explicit install').evaluate(readCopy)) + .toEqual({ lines: 1, clipped: true, overflows: true, toggle: 'expand' }) + await composer.getByRole('radio', { name: 'Stage the rollout', exact: true }).click() + await composer.getByRole('button', { name: 'Submit', exact: true }).click() + expect(await outcome).toEqual({ value: { answers: [{ id: 'rollout', selected: ['Stage the rollout'] }] } }) + await expect.poll(() => page.locator('[data-question-key]').count(), { timeout: 10_000 }).toBe(0) + bodyCompleted = true + } finally { + controller.abort() + // The dragged width is a persisted preference; dropping it and resizing republishes the default. + const cleanup = await Promise.allSettled([ + outcome, + page.evaluate(() => { localStorage.removeItem('dsh.conversation.contentWidth') }) + .then(() => page.setViewportSize(original)), + ]) + if (bodyCompleted) { + const failure = cleanup.find((result): result is PromiseRejectedResult => result.status === 'rejected') + if (failure !== undefined) throw failure.reason + } + } + }, 90_000) + }) describe.skipIf(MODE === 'record')('web e2e: cancelled question transcript', () => { diff --git a/apps/web/tests/replay-round-trip.e2e.ts b/apps/web/tests/replay-round-trip.e2e.ts index c5a382a55e..6e7725cde6 100644 --- a/apps/web/tests/replay-round-trip.e2e.ts +++ b/apps/web/tests/replay-round-trip.e2e.ts @@ -1,3 +1,4 @@ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' // Web e2e scenario: fresh round trip. A real chromium types a prompt into the // real composer; the wire, Remote gateway, agent loop, and the REAL bash tool (echo // in the temp workspace) all run; the model adapter is dsh-llm-replay (keyless) @@ -249,7 +250,7 @@ describe('web e2e: fresh round trip through the real assembly', () => { setup: agentCtx => probe.ctx.agentPresets.mount(agentCtx).then(() => undefined), }) try { - const result = await probe.ctx.tools.execute({ + const result = await executeToolFixture(probe.ctx, { signal: AbortSignal.timeout(5_000), callId: ToolCallId('web-url-probe'), name: 'bash', diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index 61af9d9e5c..f23a3d948e 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -384,13 +384,6 @@ export interface LaunchOptions { paceMs?: number /** Synthetic model capacity for UI scenarios whose seeded history must remain uncompacted. */ replayContextWindow?: number - /** - * Tool presentation mode patched onto the shipped `tools` row (`code` - * collapses the wire to run_code + the SDK prompt section). Omit for the - * yml default. The PTC runtime row is always in the tree, so no extra - * insertion is needed. - */ - toolsMode?: 'native' | 'ptc' /** * Keep the shipped DeepSeek adapter mounted while masking the process * environment's DEEPSEEK_API_KEY for this scaffold lifetime. This is the @@ -689,7 +682,6 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise ({ id: `preset-${config.id}`, name: '@deepseek-ai/dsh-agent-preset', config })) }, ], - ...options.toolsMode === undefined ? [] : [{ id: 'tools', config: { mode: options.toolsMode } }], ...options.deepSeekSearch === undefined ? [] : [{ diff --git a/apps/web/tests/schedule-after.e2e.ts b/apps/web/tests/schedule-after.e2e.ts index 89318180b1..1fc1ef2395 100644 --- a/apps/web/tests/schedule-after.e2e.ts +++ b/apps/web/tests/schedule-after.e2e.ts @@ -1,4 +1,5 @@ /** Keyless assembled-Web evidence for conversational Schedule delivery. */ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { join } from 'node:path' @@ -354,7 +355,7 @@ describe.skipIf(MODE === 'record')('web e2e: conversational reminders', () => { source: { kind: 'user' }, }) await workspace.attachSession(afterHandle.agent.id) - const afterCreated = await scaffold.ctx.tools.execute({ + const afterCreated = await executeToolFixture(scaffold.ctx, { signal: AbortSignal.timeout(10_000), callId: ToolCallId('schedule-after-create'), name: 'schedule_create', @@ -420,7 +421,7 @@ describe.skipIf(MODE === 'record')('web e2e: conversational reminders', () => { }) await scaffold.ctx.schedule.delete({ sessionId: everyHandle.agent.id, id: wake.id }) await workspace.attachSession(everyHandle.agent.id) - const everyListed = await scaffold.ctx.tools.execute({ + const everyListed = await executeToolFixture(scaffold.ctx, { signal: AbortSignal.timeout(10_000), callId: ToolCallId('schedule-every-list'), name: 'schedule_list', @@ -1181,7 +1182,7 @@ describe.skipIf(MODE === 'record')('web e2e: active Schedule catalog', () => { }) const time = clock.format(new Date(Date.now() + 3_600_000)) const prompt = 'Inspect the daily calendar rule' - const result = await scaffold.ctx.tools.execute({ + const result = await executeToolFixture(scaffold.ctx, { signal: AbortSignal.timeout(10_000), callId: ToolCallId('schedule-daily-catalog-create'), name: 'schedule_create', arguments: { prompt, title: prompt, daily: { time, time_zone: timeZone } }, agent, }) diff --git a/apps/web/tests/shipped-composition.e2e.ts b/apps/web/tests/shipped-composition.e2e.ts index 0ce404f5ac..ceb4b4644b 100644 --- a/apps/web/tests/shipped-composition.e2e.ts +++ b/apps/web/tests/shipped-composition.e2e.ts @@ -1,3 +1,4 @@ +import { executeToolFixture, executeModelToolFixture } from '@deepseek-ai/dsh-tool-testkit' // Boots the shipped Web composition over the built dist this lane already uses // and asserts its catalog, defaults, Loader lifecycle, and one complete Auto // producer-to-tool path. Browser scenarios in this lane own visual behavior. @@ -679,7 +680,7 @@ it('lets a preset producer reach the background-job registry', async () => { // `tool-bash` is a preset row and `tasks` is a host registry; the producer // resolves it with `ctx.get`, so a registry hidden behind a preset realm // fails here — with every task control still listed in the catalog above. - const started = await ctx.tools.execute({ + const started = await executeModelToolFixture(ctx, { signal, callId: ToolCallId('shipped-bash-background'), name: 'bash', @@ -701,7 +702,7 @@ it('lets a preset producer reach the background-job registry', async () => { // The controller reads what the producer started: same registry, one // owner. A per-preset registry would list nothing here even on success. - const listed = await ctx.tools.execute({ + const listed = await executeToolFixture(ctx, { signal, callId: ToolCallId('shipped-task-list'), name: 'job_list', @@ -714,7 +715,7 @@ it('lets a preset producer reach the background-job registry', async () => { // The full round trip: the output a host-plane producer wrote is collected // through a preset-plane control, which is the linkage the realm severed. - const collected = await ctx.tools.execute({ + const collected = await executeToolFixture(ctx, { signal, callId: ToolCallId('shipped-task-output'), name: 'job_output', @@ -1036,7 +1037,7 @@ it('rolls back a failed shipped Auto initialization before publishing or interce // contribution performs no review, so success proves the failed shipped // integration left no pre-execute listener behind. It is not supported // Auto execution behavior. - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { signal: new AbortController().signal, callId: ToolCallId('shipped-auto-init-rollback-write'), name: 'write', diff --git a/apps/web/tests/sidebar-right.e2e.ts b/apps/web/tests/sidebar-right.e2e.ts index 989b773556..79fc706ec9 100644 --- a/apps/web/tests/sidebar-right.e2e.ts +++ b/apps/web/tests/sidebar-right.e2e.ts @@ -1103,6 +1103,58 @@ describe('web e2e: shipped right Sidebar', () => { expect(tripwire.warnings).toEqual([]) }, 90_000) + it('keeps a dragged tab when a middle click is chorded with the primary button', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-right-middle-drag')) + const column = await resetSidebar(page) + await proseChip(page).click() + await expect.poll(() => tabTitles(column)).toEqual(['Files', SAMPLE_NAME]) + const sample = column.getByRole('tab').filter({ hasText: SAMPLE_NAME }) + const from = await pointIn(sample, 0.25, 0.5) + const to = await pointIn(column.getByRole('tab').filter({ hasText: 'Files' }), 0.25, 0.5) + await page.mouse.move(from.x, from.y) + await page.mouse.down() + try { + await page.mouse.move(to.x, to.y, { steps: 8 }) + await column.locator('[data-dockkit-caret="0"]').waitFor({ state: 'visible' }) + await page.mouse.down({ button: 'middle' }) + await page.mouse.up({ button: 'middle' }) + } finally { + await page.mouse.up() + } + await expect.poll(() => tabTitles(column)).toEqual([SAMPLE_NAME, 'Files']) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }) + + it('closes background and active tabs with the middle button and keeps the last guide', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-right-middle-close')) + const column = await resetSidebar(page) + await proseChip(page).click() + await expect.poll(() => tabTitles(column)).toEqual(['Files', SAMPLE_NAME]) + const sample = column.getByRole('tab').filter({ hasText: SAMPLE_NAME }) + const files = column.getByRole('tab').filter({ hasText: 'Files' }) + const selected = () => column.locator('[role="tab"][aria-selected="true"] [data-dockkit-tab-title]').allTextContents() + await expect.poll(selected).toEqual([SAMPLE_NAME]) + + await files.click({ button: 'middle' }) + await expect.poll(() => tabTitles(column)).toEqual([SAMPLE_NAME]) + expect(await selected()).toEqual([SAMPLE_NAME]) + await column.locator('[data-dockkit-add-tab]').click() + await expect.poll(() => tabTitles(column)).toEqual([SAMPLE_NAME, 'Start']) + await sample.click() + await expect.poll(selected).toEqual([SAMPLE_NAME]) + await sample.click({ button: 'middle' }) + await expect.poll(() => tabTitles(column)).toEqual(['Start']) + expect(await selected()).toEqual(['Start']) + + await column.getByRole('tab').click({ button: 'middle' }) + expect(await tabTitles(column)).toEqual(['Start']) + expect(await column.locator('[data-sidebar-right-open]').count()).toBe(1) + expect(await column.locator('[data-dockkit-tab-quiet]').count()).toBe(1) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }) + it('preserves the open surface after a reload', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-right-reload')) const frame = page.locator('[class*="frame"]').first() diff --git a/apps/web/tests/smoke-real.e2e.ts b/apps/web/tests/smoke-real.e2e.ts index cf1546ccf2..a980c2d02d 100644 --- a/apps/web/tests/smoke-real.e2e.ts +++ b/apps/web/tests/smoke-real.e2e.ts @@ -603,7 +603,7 @@ describe('dsh web keyless CLI smoke', () => { } }, 120_000) - it('DSH_TOOLS_MODE=ptc collapses the provider wire tools to run_code with the SDK prompt section', async () => { + it.each(['native', 'ptc'] as const)('selects the %s executor through profile overlays and ignores the retired environment selector', async (mode) => { requireDist() const workspace = mkdtempSync(join(tmpdir(), 'dsh-web-ptc-')) @@ -632,14 +632,17 @@ describe('dsh web keyless CLI smoke', () => { const tsxLoader = pathToFileURL(createRequire(join(REPO_ROOT, 'package.json')).resolve('tsx')).href const child = spawn( process.execPath, - ['--import', tsxLoader, join(REPO_ROOT, 'apps/cli/src/bin.ts'), 'web', '--no-open', '--port', '0'], + ['--import', tsxLoader, join(REPO_ROOT, 'apps/cli/src/bin.ts'), 'web', + ...mode === 'ptc' ? ['--patch', join(REPO_ROOT, 'apps/cli/config/examples/ptc.yml')] : [], + '--no-open', '--port', '0', + ], { cwd: workspace, env: { ...process.env, DEEPSEEK_API_KEY: 'keyless-web-ptc', DEEPSEEK_BASE_URL: `http://127.0.0.1:${address.port}`, - DSH_TOOLS_MODE: 'ptc', + DSH_TOOLS_MODE: mode === 'native' ? 'ptc' : 'native', DSH_HOME: join(workspace, '.dsh'), DSH_AGENTS_HOME: join(workspace, '.agents'), TSX_TSCONFIG_PATH: join(REPO_ROOT, 'tsconfig.json'), @@ -662,9 +665,15 @@ describe('dsh web keyless CLI smoke', () => { setTimeout(() => { reject(new Error('provider request not received in 10s')) }, 10_000).unref() }), ]) - expect(captured.tools?.map(tool => tool.name)).toEqual(['run_code']) - expect(captured.system).toContain('## Writing code for run_code') - expect(captured.system).toContain('declare const tools') + if (mode === 'ptc') { + expect(captured.tools?.map(tool => tool.name)).toEqual(['run_code']) + expect(captured.system).toContain('## Writing code for run_code') + expect(captured.system).toContain('declare const tools') + } else { + expect(captured.tools?.map(tool => tool.name)).not.toContain('run_code') + expect(captured.tools?.length).toBeGreaterThan(0) + expect(captured.system).not.toContain('## Writing code for run_code') + } } finally { const closed = child.exitCode === null ? new Promise((resolveClose) => { child.once('close', () => { resolveClose() }) }) diff --git a/apps/web/tests/steering.e2e.ts b/apps/web/tests/steering.e2e.ts index 2d457fcb98..3b47c0a4b5 100644 --- a/apps/web/tests/steering.e2e.ts +++ b/apps/web/tests/steering.e2e.ts @@ -224,6 +224,8 @@ describe.skipIf(MODE === 'record')('web e2e: retained steering and a post-stop m let hostClock: MockInstance | undefined const sessionEvents: SessionEvent[] = [] const requestInputs: string[][] = [] + let firstRequestSignal: AbortSignal | undefined + const questionStarted = Promise.withResolvers() const releaseQuestion = Promise.withResolvers() const FOLLOWUP = 'Also acknowledge this second message.' const RESPONSE = "Great, let's move forward. BANANA!" @@ -239,9 +241,13 @@ describe.skipIf(MODE === 'record')('web e2e: retained steering and a post-stop m message.role === 'user' ? message.content.flatMap(block => block.type === 'text' ? [block.text] : []) : [])) + if (requestInputs.length === 1) firstRequestSignal = options.signal for await (const chunk of next()) { yield chunk - if (requestInputs.length === 1 && chunk.type === 'block-start') await releaseQuestion.promise + if (requestInputs.length === 1 && chunk.type === 'block-start') { + questionStarted.resolve(undefined) + await releaseQuestion.promise + } } }, { prepend: true }) browser = await chromium.launch() @@ -271,11 +277,13 @@ describe.skipIf(MODE === 'record')('web e2e: retained steering and a post-stop m const input = page.locator('[data-composer-input][contenteditable="true"]').first() await input.fill(PROMPT) await input.press('Enter') + await questionStarted.promise await expect.poll(() => requestInputs.length, { timeout: 10_000 }).toBe(1) await input.fill(STEER) await input.press('Meta+Enter') await page.locator('[data-pending-steering]').filter({ hasText: STEER }).waitFor({ timeout: 10_000 }) await page.getByRole('button', { name: 'Stop generating', exact: true }).click() + await expect.poll(() => firstRequestSignal?.aborted).toBe(true) releaseQuestion.resolve(undefined) await expect.poll(() => sessionEvents.filter(event => event.type === 'turn/end').length, { timeout: 10_000 }).toBe(1) expect(claimedMessages(sessionEvents, STEER)).toHaveLength(0) diff --git a/benchmarks/agent-continuation/agent-continuation.worker.ts b/benchmarks/agent-continuation/agent-continuation.worker.ts index b19cc2c43c..775ca0ccd3 100644 --- a/benchmarks/agent-continuation/agent-continuation.worker.ts +++ b/benchmarks/agent-continuation/agent-continuation.worker.ts @@ -10,7 +10,7 @@ import { createUserMessage, LlmAdapter } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, LlmResolvedModelInfo, StreamChunk } from '@deepseek-ai/dsh-llm' import { SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session' import * as JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' -import { defineContentToolFixture } from '@deepseek-ai/dsh-tools' +import { defineContentToolFixture } from '@deepseek-ai/dsh-tool-testkit' import { assertBuiltBenchmarkRuntime } from '../support/built-worker.ts' import { PARENT_ID, response, resultText, syntheticHistory, TIME_ZERO, WORKLOAD } from './workload.ts' diff --git a/benchmarks/package.json b/benchmarks/package.json index d36d79bfdb..4601a6e972 100644 --- a/benchmarks/package.json +++ b/benchmarks/package.json @@ -42,6 +42,7 @@ "@deepseek-ai/dsh-storage-domain": "workspace:*", "@deepseek-ai/dsh-storage-json": "workspace:*", "@deepseek-ai/dsh-subagent": "workspace:*", + "@deepseek-ai/dsh-tool-testkit": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", "@deepseek-ai/dsh-token-meter": "workspace:*", "@deepseek-ai/dsh-typert-protocol": "workspace:*", diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index d7cc383707..2936edd59e 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -24,8 +24,8 @@ en: e89802f066e4955d zh: ea2fb4bef94e086d /deepseek-harness-architecture/turn-flow: - en: d2e54d2e197cee82 - zh: e085148bb4ae8edd + en: 1a79a70b62c04cdb + zh: c86d765e21430405 /deepseek-harness-architecture/session-log: en: 8a7f19b95d426c99 zh: d5bb0538404e20ea diff --git a/docs/architecture.md b/docs/architecture.md index 1b58e416e1..9f45d7f95f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -109,7 +109,7 @@ Factory setup follows SessionStore entry and `session/created`, preceding `agent One inbox feeds the driver; injected context waits for a waking message. AgentLoop’s durable `inbox` projection exposes pending input without live Agents. -The loop snapshots Session model selection before assembly, sharing it through `AssembleContext.modelSelection` and `agent/pre-step.modelSelection`; retries retain that selection, and omitted reasoning effort uses adapter defaults. `agent/pre-step` decides the accepted input. Listeners may rewrite or reject claimed messages; a rejected or empty first claim closes a durable turn without a step. An enter decision may set `startsRequestSeries`: the loop logs a fresh `request/header` (reason `series`, or `change` with `startsSeries: true` when the envelope also changed). Wrapping listeners preserve that declaration with `{ ...decision, messages }`. After assembly and `step/start`, the deprecated `agent/request` hook adjusts controls without changing the provider, model, or effort selection, and `prepareCall()` binds the adapter before the system prompt and accepted users are committed; cancellation during either async phase commits neither. Prepared call capabilities govern prompt admission, not preceding `request/context`. Each attempt refreshes registered runtime facts, reconciles the bound prompt, admits users once, and restores removed runtime context, logs header/context as needed, and freezes the request before streaming the bound call. Retries do not repeat assembly or `agent/pre-step`. Surface replacements and image-offload decisions after attachment start a new request series, including during the first resumed pre-step; unchanged resume continues the series. The system prompt travels as `system/message` history: empty text clears it, capable routes append updates, and other routes consolidate it at the first system node. [Agent-loop](../packages/core/agent-loop/README.md#understand-the-implementation) owns prompt admission and replacement details. +Before assembly, the loop snapshots Session model selection in `AssembleContext.modelSelection` and `agent/pre-step.modelSelection`; retries retain it; omitted reasoning effort uses adapter defaults. `agent/pre-step` listeners accept, rewrite, or reject claimed messages; a rejected or empty first claim closes a durable turn without a step. An enter decision may set `startsRequestSeries`: the loop logs a fresh `request/header` (reason `series`, or `change` with `startsSeries: true` when the envelope also changed). Wrapping listeners preserve that declaration with `{ ...decision, messages }`. After assembly and `step/start`, the deprecated `agent/request` hook adjusts controls without changing the provider, model, or effort selection, and `prepareCall()` binds the adapter before the system prompt and accepted users are committed; cancellation during either async phase commits neither. Prepared call capabilities govern prompt admission, not preceding `request/context`. Each attempt refreshes registered runtime facts, reconciles the bound prompt, admits users once, and restores removed runtime context, logs header/context as needed, and freezes the request before streaming the bound call. Retries do not repeat assembly or `agent/pre-step`. Surface replacements and image-offload decisions after attachment start a new request series, including during the first resumed pre-step; unchanged resume continues the series. The system prompt travels as `system/message` history: empty text clears it, capable routes append updates, and other routes consolidate it at the first system node. [Agent-loop](../packages/core/agent-loop/README.md#understand-the-implementation) owns prompt admission and replacement details. The loop sends immutable requests with live cancellation and reuses freeze evidence for frozen identities; [agent-loop](../packages/core/agent-loop/README.md) owns construction and cancellation causes. @@ -117,6 +117,8 @@ Failed steps [record missing tool results](../packages/core/agent-loop/README.md Details: the [sequence diagram](agent-lifecycle.md), the [tool pipeline](tool-execution-pipeline.md), and [cancellation and error recovery](subsystems/core.md#the-agent-handle). +[`ctx.tools.execute()`](subsystems/tools.md) receives raw batches and concurrency limits. + ## Session log The session log is the source of the context the model sees. `deriveMessages()` projects model history from it. Each `assistant/message` embeds the exact compact timed stream that produced its assembled content; `assistant/attempt` retains settled failed, retried, cancelled, and stream-error attempts without adding model history. Fork, resume, transcripts, telemetry, and persistence all derive from these durable settlements, while live UI incrementality comes from `agent/assistant-stream`; a hard process loss before settlement leaves no durable attempt stream ([decision](../.agents/notes/implemented/architecture/2026-09-01-v2-embedded-assistant-streams.md)). diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index 00d1bd8f7e..b6fae57225 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -113,7 +113,7 @@ turn/end 输入通过同一个 inbox 到达驱动器;注入的上下文等待一条唤醒消息。AgentLoop 的持久 `inbox` 投影使待处理输入在没有活跃 Agent 时仍可读取。 -循环在组装前快照 Session 模型选择,并通过 `AssembleContext.modelSelection` 与 `agent/pre-step.modelSelection` 传递该选择;重试保留该选择,省略推理强度时使用适配器默认值。`agent/pre-step` 决定接纳的输入。监听器可以改写或拒绝已领取消息;首次领取被拒绝或为空时,关闭不含步骤的持久轮次。enter 决策可设置 `startsRequestSeries`:循环记录新的 `request/header`(原因为 `series`,或在封装同时变化时为携带 `startsSeries: true` 的 `change`)。包装监听器通过 `{ ...decision, messages }` 保留该声明。组装与 `step/start` 之后,已弃用的 `agent/request` hook 在不改变提供方、模型或推理强度选择的前提下调整控制参数,`prepareCall()` 绑定适配器,再提交系统提示词与已接纳用户消息;在任一异步阶段取消都不会提交这两者。提示词准入依据已准备调用的能力,而非先前的 `request/context`。每次尝试刷新已注册运行时事实、协调绑定的提示词、仅接纳一次用户消息并恢复被移除的运行时上下文、按需记录 header/context,再冻结请求并通过绑定调用发起流式请求。重试不重复组装或 `agent/pre-step`。附接后的 surface 替换和图片省略决定开启新请求序列,包括恢复后的首次 pre-step 中发生的替换;未变化的恢复延续序列。系统提示词通过 `system/message` 历史传递:空文本清除提示词,具备能力的路由追加更新,其他路由将其归并到首个系统节点。[Agent-loop](../packages/core/agent-loop/README.zh.md#understand-the-implementation) 说明提示词准入与替换的细节。 +循环在组装前将 Session 模型选择快照到 `AssembleContext.modelSelection` 与 `agent/pre-step.modelSelection`;重试保留该选择,省略推理强度时使用适配器默认值。`agent/pre-step` 监听器接纳、改写或拒绝已领取消息;首次领取被拒绝或为空时,关闭不含步骤的持久轮次。enter 决策可设置 `startsRequestSeries`:循环记录新的 `request/header`(原因为 `series`,或在封装同时变化时为携带 `startsSeries: true` 的 `change`)。包装监听器通过 `{ ...decision, messages }` 保留该声明。组装与 `step/start` 之后,已弃用的 `agent/request` hook 在不改变提供方、模型或推理强度选择的前提下调整控制参数,`prepareCall()` 绑定适配器,再提交系统提示词与已接纳用户消息;在任一异步阶段取消都不会提交这两者。提示词准入依据已准备调用的能力,而非先前的 `request/context`。每次尝试刷新已注册运行时事实、协调绑定的提示词、仅接纳一次用户消息并恢复被移除的运行时上下文、按需记录 header/context,再冻结请求并通过绑定调用发起流式请求。重试不重复组装或 `agent/pre-step`。附接后的 surface 替换和图片省略决定开启新请求序列,包括恢复后的首次 pre-step 中发生的替换;未变化的恢复延续序列。系统提示词通过 `system/message` 历史传递:空文本清除提示词,具备能力的路由追加更新,其他路由将其归并到首个系统节点。[Agent-loop](../packages/core/agent-loop/README.zh.md#understand-the-implementation) 说明提示词准入与替换的细节。 循环发送不可变请求,同时保持取消有效,仅对完全冻结的对象复用冻结证据;[agent-loop](../packages/core/agent-loop/README.zh.md) 负责请求构建与取消原因。 @@ -121,6 +121,8 @@ turn/end 详情见[时序图](agent-lifecycle.zh.md)、[工具流水线](tool-execution-pipeline.zh.md)和[取消与错误恢复](subsystems/core.zh.md#the-agent-handle)。 +[`ctx.tools.execute()`](subsystems/tools.zh.md) 接收原始调用批次和并发上限。 + ## 会话日志 会话日志是模型所见上下文的来源。`deriveMessages()` 从中投影出模型历史。每个 `assistant/message` 都嵌入产生其组装内容的精确紧凑带时间 stream;`assistant/attempt` 保留已到达 settlement 的失败、重试、取消与 stream error attempt,且不添加模型历史。fork、恢复、transcript(文本记录)、遥测与持久化都从这些持久 settlement 派生,实时 UI 增量则来自 `agent/assistant-stream`;如果进程在 settlement 前硬中断,则不会留下持久 attempt stream(见[决策](../.agents/notes/implemented/architecture/2026-09-01-v2-embedded-assistant-streams.zh.md))。 diff --git a/docs/capability-seams.i18n.yaml b/docs/capability-seams.i18n.yaml index ae1d83cdf3..e2cd201a0d 100644 --- a/docs/capability-seams.i18n.yaml +++ b/docs/capability-seams.i18n.yaml @@ -6,5 +6,5 @@ en: 41d772192075e133 zh: 5ef3da69b4de1d8a /capability-seams-and-core-services: - en: 753b3da3abc6aeea - zh: aab02ec16c3f744b + en: f2546ac3454d220d + zh: 642a473c0405e81f diff --git a/docs/capability-seams.md b/docs/capability-seams.md index f2b52ade47..07f93f002f 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -166,7 +166,7 @@ flowchart LR pkg_tools["tools"] pkg_tool_terminal["tool-terminal"] pkg_tool_web["tool-web"] - svc_tools["ctx.tools
Tool registry and guarded execution pipeline"] + svc_tools["ctx.tools
Scoped tool registry and executor selection"] pkg_tool_ask_user["tool-ask-user"] pkg_tool_cordis["tool-cordis"] pkg_tool_skill["tool-skill"] @@ -235,6 +235,7 @@ flowchart LR svc_ptcRuntime["ctx.ptcRuntime
PTC execution seam"] pkg_ptc_runtime_node["ptc-runtime-node"] pkg_experimental_ptc_runtime_python["experimental-ptc-runtime-python"] + pkg_tool_executor_ptc["tool-executor-ptc"] pkg_workflow_ptc["workflow-ptc"] pkg_fs["fs"] svc_fs["ctx.fs
Filesystem provider seam"] @@ -516,7 +517,7 @@ flowchart LR svc_pluginMarket --> pkg_plugin_market svc_pluginRegistryProbe --> pkg_client_ui_plugin_manager svc_profileContext --> pkg_plugin_manager - svc_ptcRuntime --> pkg_tools + svc_ptcRuntime --> pkg_tool_executor_ptc svc_ptcRuntime --> pkg_workflow_ptc svc_remoteAccess --> pkg_api_gateway svc_remoteAccess --> pkg_api_session_controller @@ -677,7 +678,7 @@ flowchart LR | `ctx.sessionReferenceResolver` | `core` | [`session-reference`](../packages/context/session-reference) | - | - | - | Projects bounded current-surface conversation snapshots into durable untrusted message context; host adapters own mention syntax. | | `ctx.sessionTitle` | `seam` | [`session-title`](../packages/session/session-title) | [`session-title-first-prompt-llm`](../packages/session/session-title-first-prompt-llm), [`session-title-all-prompts-llm`](../packages/experimental/session-title-all-prompts-llm) | - | - | Owns the deterministic fallback, latest-title fold, and sole optional asynchronous provider registration. | | `ctx.systemPrompt` | `core` | [`system-prompt`](../packages/core/system-prompt) | - | [`agent-loop`](../packages/core/agent-loop), [`tools`](../packages/core/tools), [`tool-fs`](../packages/fs/tool-fs), [`tool-terminal`](../packages/experimental/tool-terminal), [`tool-web`](../packages/web/tool-web) | - | Collects prompt sections and model-facing tool schemas for each step. | -| `ctx.tools` | `core` | [`tools`](../packages/core/tools) | - | [`agent-loop`](../packages/core/agent-loop), [`tool-ask-user`](../packages/interaction/tool-ask-user), [`tool-bash`](../packages/shell/tool-bash), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-fs`](../packages/fs/tool-fs), [`tool-terminal`](../packages/experimental/tool-terminal), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-todo`](../packages/todo/tool-todo), [`tool-web`](../packages/web/tool-web) | - | Registers capabilities, owns PTC mode transport, and routes calls through pre-policy, monotonic guards, around dispatch, post-policy, and final-result observation. | +| `ctx.tools` | `core` | [`tools`](../packages/core/tools) | - | [`agent-loop`](../packages/core/agent-loop), [`tool-ask-user`](../packages/interaction/tool-ask-user), [`tool-bash`](../packages/shell/tool-bash), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-fs`](../packages/fs/tool-fs), [`tool-terminal`](../packages/experimental/tool-terminal), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-todo`](../packages/todo/tool-todo), [`tool-web`](../packages/web/tool-web) | - | Registers capabilities and scoped executors. Native and PTC providers own descriptions and complete raw call batches; their optional shared pipeline handles tool policy and dispatch. | | `ctx.userQuestions` | `seam` | [`user-questions`](../packages/interaction/user-questions) | - | [`tool-ask-user`](../packages/interaction/tool-ask-user) | - | UI front ends provide the active human-answer provider; tool-ask-user pauses a tool call on the provider-neutral ask() promise. | | `ctx.planMode` | `core` | [`plan-mode`](../packages/plan/plan-mode) | - | - | - | Folds logged plan/mode state, flushes user selections at turn boundaries, renders deployment-owned guidance, registers /plan, and keeps the plan-exit schema stable across transitions. | | `ctx.agentPresets` | `core` | [`agent-preset-registry`](../packages/preset/agent-preset-registry) | - | - | - | Eagerly mounts YAML-declared preset revisions, binds Agents and cold readers to scoped contributions, and retains retired revisions until their last user releases them. | @@ -699,7 +700,7 @@ flowchart LR | `ctx.sandboxPolicy` | `core` | [`sandbox-policy`](../packages/sandbox/sandbox-policy) | - | [`bash-sandbox`](../packages/shell/bash-sandbox), [`fs-sandbox`](../packages/fs/fs-sandbox), [`terminal-bash`](../packages/terminal/terminal-bash) | - | The one home for the deployment default mode + workspace root; only the sandboxed executor and provider read the service (the tool layers use the pure `sandbox/mode` fold it also exports). Both enforcing families read it so bash and fs cannot confine to different roots. | | `ctx.approval` | `seam` | [`user-approval`](../packages/interaction/user-approval) | - | [`tools`](../packages/core/tools), [`tool-bash`](../packages/shell/tool-bash), [`acp`](../packages/acp/acp) | - | One-shot permission decisions dispatched over the `approval/request` waterfall; answerers are listeners (the ACP bridge for its own agents), absence fails closed to `unavailable`. | | `ctx.permissionPresets` | `core` | [`permission-presets`](../packages/interaction/permission-presets) | - | - | - | User-facing preset table (`workspace-write`/`danger-full-access`) bundling the sandbox-mode and approval-policy knobs; a switch writes one `permission/preset` event through to both knob events. | -| `ctx.ptcRuntime` | `seam` | [`ptc-runtime`](../packages/ptc-runtime/ptc-runtime) | [`ptc-runtime-node`](../packages/ptc-runtime/ptc-runtime-node), [`experimental-ptc-runtime-python`](../packages/experimental/ptc-runtime-python) | [`tools`](../packages/core/tools), [`workflow-ptc`](../packages/workflow/workflow-ptc) | - | Runs programs against host-provided async bindings; tools owns PTC presentation and workflow-ptc owns workflow orchestration. | +| `ctx.ptcRuntime` | `seam` | [`ptc-runtime`](../packages/ptc-runtime/ptc-runtime) | [`ptc-runtime-node`](../packages/ptc-runtime/ptc-runtime-node), [`experimental-ptc-runtime-python`](../packages/experimental/ptc-runtime-python) | [`tool-executor-ptc`](../packages/core/tool-executor-ptc), [`workflow-ptc`](../packages/workflow/workflow-ptc) | - | Runs programs against host-provided async bindings; tool-executor-ptc owns PTC presentation and workflow-ptc owns workflow orchestration. | | `ctx.fs` | `seam` | [`fs`](../packages/fs/fs) | [`fs-local`](../packages/fs/fs-local), [`fs-sandbox`](../packages/fs/fs-sandbox), [`fs-ssh`](../packages/ssh/fs-ssh) | [`tool-fs`](../packages/fs/tool-fs) | [`fs-observation-policy`](../packages/fs/fs-observation-policy) | tool-fs executes read/write/edit through ctx.fs; fs-sandbox fences mutations by the shared sandbox mode; fs-observation-policy contributes observed-state checks through the fs/* event gate. | | `ctx.compaction` | `seam` | [`compaction`](../packages/compaction/compaction) | [`compaction-basic`](../packages/compaction/compaction-basic) | [`compaction-basic`](../packages/compaction/compaction-basic) | - | The basic backend consumes post-step pressure and request-error recovery events; there is no model-facing compact tool. | | `ctx.subagents` | `seam` | [`subagent`](../packages/subagent/subagent) | [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process), [`subagent-fork-in-process`](../packages/subagent/subagent-fork-in-process), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-codex`](../packages/subagent/subagent-codex), [`subagent-claude-code`](../packages/subagent/subagent-claude-code), [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-subagent-control`](../packages/subagent/tool-subagent-control), [`tool-ralph`](../packages/experimental/tool-ralph) | - | Providers prepare local children or execute external tasks; one activation manager owns both lifecycles. The delegation tool selects a backend, control tools deliver local follow-ups, and workflows await activation results. | diff --git a/docs/capability-seams.zh.md b/docs/capability-seams.zh.md index fede454e27..1b10ace20d 100644 --- a/docs/capability-seams.zh.md +++ b/docs/capability-seams.zh.md @@ -168,7 +168,7 @@ flowchart LR pkg_tools["tools"] pkg_tool_terminal["tool-terminal"] pkg_tool_web["tool-web"] - svc_tools["ctx.tools
Tool registry and guarded execution pipeline"] + svc_tools["ctx.tools
Scoped tool registry and executor selection"] pkg_tool_ask_user["tool-ask-user"] pkg_tool_cordis["tool-cordis"] pkg_tool_skill["tool-skill"] @@ -237,6 +237,7 @@ flowchart LR svc_ptcRuntime["ctx.ptcRuntime
PTC execution seam"] pkg_ptc_runtime_node["ptc-runtime-node"] pkg_experimental_ptc_runtime_python["experimental-ptc-runtime-python"] + pkg_tool_executor_ptc["tool-executor-ptc"] pkg_workflow_ptc["workflow-ptc"] pkg_fs["fs"] svc_fs["ctx.fs
Filesystem provider seam"] @@ -518,7 +519,7 @@ flowchart LR svc_pluginMarket --> pkg_plugin_market svc_pluginRegistryProbe --> pkg_client_ui_plugin_manager svc_profileContext --> pkg_plugin_manager - svc_ptcRuntime --> pkg_tools + svc_ptcRuntime --> pkg_tool_executor_ptc svc_ptcRuntime --> pkg_workflow_ptc svc_remoteAccess --> pkg_api_gateway svc_remoteAccess --> pkg_api_session_controller @@ -679,7 +680,7 @@ flowchart LR | `ctx.sessionReferenceResolver` | `core` | [`session-reference`](../packages/context/session-reference) | - | - | - | 将当前表层中有界的对话快照投影为持久但不可信的消息上下文;Host 适配器负责提及语法。 | | `ctx.sessionTitle` | `seam` | [`session-title`](../packages/session/session-title) | [`session-title-first-prompt-llm`](../packages/session/session-title-first-prompt-llm), [`session-title-all-prompts-llm`](../packages/experimental/session-title-all-prompts-llm) | - | - | 负责确定性回退、最新标题折叠区,以及唯一的可选异步提供方注册。 | | `ctx.systemPrompt` | `core` | [`system-prompt`](../packages/core/system-prompt) | - | [`agent-loop`](../packages/core/agent-loop), [`tools`](../packages/core/tools), [`tool-fs`](../packages/fs/tool-fs), [`tool-terminal`](../packages/experimental/tool-terminal), [`tool-web`](../packages/web/tool-web) | - | 为每个步骤收集提示词各部分和面向模型的工具 schema。 | -| `ctx.tools` | `core` | [`tools`](../packages/core/tools) | - | [`agent-loop`](../packages/core/agent-loop), [`tool-ask-user`](../packages/interaction/tool-ask-user), [`tool-bash`](../packages/shell/tool-bash), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-fs`](../packages/fs/tool-fs), [`tool-terminal`](../packages/experimental/tool-terminal), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-todo`](../packages/todo/tool-todo), [`tool-web`](../packages/web/tool-web) | - | 注册能力,负责 PTC mode 传输,并让调用依次经过策略前处理、单调守卫、环绕分派、策略后处理和最终结果观测。 | +| `ctx.tools` | `core` | [`tools`](../packages/core/tools) | - | [`agent-loop`](../packages/core/agent-loop), [`tool-ask-user`](../packages/interaction/tool-ask-user), [`tool-bash`](../packages/shell/tool-bash), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-fs`](../packages/fs/tool-fs), [`tool-terminal`](../packages/experimental/tool-terminal), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-todo`](../packages/todo/tool-todo), [`tool-web`](../packages/web/tool-web) | - | 注册能力和作用域执行器。原生与 PTC 提供者负责工具描述及完整原始调用批次;它们可选用共享流水线处理工具策略和分发。 | | `ctx.userQuestions` | `seam` | [`user-questions`](../packages/interaction/user-questions) | - | [`tool-ask-user`](../packages/interaction/tool-ask-user) | - | UI 前端提供当前生效的人工回答提供方;tool-ask-user 在提供方无关的 ask() promise 上暂停工具调用。 | | `ctx.planMode` | `core` | [`plan-mode`](../packages/plan/plan-mode) | - | - | - | 折叠已记录的计划/模式状态,在轮次边界刷新用户选择,渲染由部署方拥有的指导信息,注册 /plan,并在状态转换期间保持计划退出 schema 稳定。 | | `ctx.agentPresets` | `core` | [`agent-preset-registry`](../packages/preset/agent-preset-registry) | - | - | - | 立即挂载 YAML 声明的 preset 版本,把 Agent 和冷读取绑定到作用域内的贡献,并保留已替换的版本,直到最后一个使用者释放它。 | @@ -701,7 +702,7 @@ flowchart LR | `ctx.sandboxPolicy` | `core` | [`sandbox-policy`](../packages/sandbox/sandbox-policy) | - | [`bash-sandbox`](../packages/shell/bash-sandbox), [`fs-sandbox`](../packages/fs/fs-sandbox), [`terminal-bash`](../packages/terminal/terminal-bash) | - | 统一保存部署默认模式和工作区根目录;只有沙箱执行器和提供方读取该服务(工具层使用它同时导出的纯 `sandbox/mode` 折叠区)。两类强制执行组件都读取该服务,因此 bash 与 fs 不会限制到不同的根目录。 | | `ctx.approval` | `seam` | [`user-approval`](../packages/interaction/user-approval) | - | [`tools`](../packages/core/tools), [`tool-bash`](../packages/shell/tool-bash), [`acp`](../packages/acp/acp) | - | 一次性权限决策通过 `approval/request` waterfall(瀑布式事件)分派;回答方是监听器(即 ACP 为自身 agent 提供的桥接),没有回答方时以 `unavailable` 关闭失败。 | | `ctx.permissionPresets` | `core` | [`permission-presets`](../packages/interaction/permission-presets) | - | - | - | 面向用户的预设表(`workspace-write`/`danger-full-access`),将沙箱模式与审批策略选项组合在一起;一次切换会写入一个 `permission/preset` 事件,并贯通到两个选项事件。 | -| `ctx.ptcRuntime` | `seam` | [`ptc-runtime`](../packages/ptc-runtime/ptc-runtime) | [`ptc-runtime-node`](../packages/ptc-runtime/ptc-runtime-node), [`experimental-ptc-runtime-python`](../packages/experimental/ptc-runtime-python) | [`tools`](../packages/core/tools), [`workflow-ptc`](../packages/workflow/workflow-ptc) | - | 使用 Host 提供的异步绑定运行程序;tools 负责 PTC 呈现,workflow-ptc 负责工作流编排。 | +| `ctx.ptcRuntime` | `seam` | [`ptc-runtime`](../packages/ptc-runtime/ptc-runtime) | [`ptc-runtime-node`](../packages/ptc-runtime/ptc-runtime-node), [`experimental-ptc-runtime-python`](../packages/experimental/ptc-runtime-python) | [`tool-executor-ptc`](../packages/core/tool-executor-ptc), [`workflow-ptc`](../packages/workflow/workflow-ptc) | - | 使用 Host 提供的异步绑定运行程序;tool-executor-ptc 负责 PTC 呈现,workflow-ptc 负责工作流编排。 | | `ctx.fs` | `seam` | [`fs`](../packages/fs/fs) | [`fs-local`](../packages/fs/fs-local), [`fs-sandbox`](../packages/fs/fs-sandbox), [`fs-ssh`](../packages/ssh/fs-ssh) | [`tool-fs`](../packages/fs/tool-fs) | [`fs-observation-policy`](../packages/fs/fs-observation-policy) | tool-fs 通过 ctx.fs 执行读取/写入/编辑;fs-sandbox 按共享沙箱模式限制变更;fs-observation-policy 通过 fs/* 事件门禁贡献基于观测状态的检查。 | | `ctx.compaction` | `seam` | [`compaction`](../packages/compaction/compaction) | [`compaction-basic`](../packages/compaction/compaction-basic) | [`compaction-basic`](../packages/compaction/compaction-basic) | - | 基础后端消费步骤后的压力事件和请求错误恢复事件;不存在面向模型的压缩工具。 | | `ctx.subagents` | `seam` | [`subagent`](../packages/subagent/subagent) | [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process), [`subagent-fork-in-process`](../packages/subagent/subagent-fork-in-process), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-codex`](../packages/subagent/subagent-codex), [`subagent-claude-code`](../packages/subagent/subagent-claude-code), [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-subagent-control`](../packages/subagent/tool-subagent-control), [`tool-ralph`](../packages/experimental/tool-ralph) | - | 提供方准备本地子级或执行外部任务;一个 activation 管理器持有两类生命周期。委派工具选择后端,控制工具投递本地后续消息,工作流等待 activation 结果。 | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index d71171b7bb..30ad8160c8 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -162,11 +162,13 @@ export interface Config { ## `@deepseek-ai/dsh-agent-tool-presentation` - `inject`: `tools` -- `refs`: [`ToolPresentationMode`](subsystems/tools.md) -- `source`: [`packages/core/agent-tool-presentation/src/index.ts:38`](../packages/core/agent-tool-presentation/src/index.ts) +- `source`: [`packages/core/agent-tool-presentation/src/index.ts:25`](../packages/core/agent-tool-presentation/src/index.ts) ```ts config-catalog -/** Plugin config. */ +/** + * Plugin config. + * @deprecated Select the native or PTC executor plugin directly instead of configuring a mode. + */ export interface Config { /** * The form this agent's model sees. `native` sends every visible schema, @@ -175,7 +177,7 @@ export interface Config { * without this row already gets, so an omitted value would mean the row was * composed for nothing. */ - mode: ToolPresentationMode + mode: 'native' | 'ptc' } ``` @@ -1706,7 +1708,7 @@ export interface Config { ## `@deepseek-ai/dsh-host-webserver` -- `source`: [`packages/host/webserver/src/index.ts:76`](../packages/host/webserver/src/index.ts) +- `source`: [`packages/host/webserver/src/index.ts:79`](../packages/host/webserver/src/index.ts) ```ts config-catalog /** Web server listen, TLS, and response-compression config. */ @@ -2890,7 +2892,7 @@ export interface Config { ## `@deepseek-ai/dsh-session-log-export` - `inject`: `commands` · `connection` -- `source`: [`packages/session-query/session-log-export/src/index.ts:46`](../packages/session-query/session-log-export/src/index.ts) +- `source`: [`packages/session-query/session-log-export/src/index.ts:48`](../packages/session-query/session-log-export/src/index.ts) ```ts config-catalog /** Session-log archive policy. */ @@ -3270,7 +3272,7 @@ export interface Config { ## `@deepseek-ai/dsh-spill-policy` - `inject`: `tools` -- `source`: [`packages/spill/spill-policy/src/index.ts:25`](../packages/spill/spill-policy/src/index.ts) +- `source`: [`packages/spill/spill-policy/src/index.ts:26`](../packages/spill/spill-policy/src/index.ts) ```ts config-catalog /** Optional result-retention budget. */ @@ -3849,6 +3851,23 @@ export interface Config { ``` + + + +## `@deepseek-ai/dsh-tool-executor-ptc` + +- `inject`: `tools` · `systemPrompt` · `ptcRuntime` +- `source`: [`packages/core/tool-executor-ptc/src/config.ts:5`](../packages/core/tool-executor-ptc/src/config.ts) + +```ts config-catalog +/** Program execution limits. */ +export interface Config { + /** Maximum overlapping concurrency-safe program subcalls. */ + maxParallelSubCalls: number +} +``` + + @@ -4320,43 +4339,6 @@ export interface Config { ``` - - - -## `@deepseek-ai/dsh-tools` - -- `inject`: `systemPrompt` -- `source`: [`packages/core/tools/src/index.ts:675`](../packages/core/tools/src/index.ts) - -```ts config-catalog -/** Plugin config: how the registered tools are presented to the model. */ -export interface Config { - /** - * Model presentation. `native` (default) sends every visible schema; `ptc` - * sends only `run_code` plus a generated SDK prompt and collapses the - * executor to the same surface (a model-direct call may only name - * `run_code`; `run_code` SDK sub-dispatches keep every visible tool). - * PTC mode requires a `ctx.ptcRuntime` whose `language` - * has a registered SDK renderer (TypeScript or Python) and fails prompt - * assembly when it is absent or has no renderer. Under `ptc`, native names - * in `toolOrder` are invalid. - */ - mode?: ToolPresentationMode - /** - * Concurrency cap for a `run_code` program's overlapping sub-calls - * (default 10, the loop scheduler's own default). Sub-calls follow the - * native scheduling contract — only calls whose tools classify - * concurrency-safe overlap; exclusive calls form barriers — so `1` - * restores strictly serial dispatch. Must be a positive integer. - */ - maxParallelSubCalls?: number -} - -/** How the registry presents its tools to the model (see {@link Config.mode}). */ -export type ToolPresentationMode = 'native' | 'ptc' -``` - - @@ -4801,9 +4783,11 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co | `@deepseek-ai/dsh-terminal` | — | [`packages/terminal/terminal/src/index.ts`](../packages/terminal/terminal/src/index.ts) | | `@deepseek-ai/dsh-tool-call-timeout-policy` | `tools` | [`packages/guard/timeout-policy/src/index.ts`](../packages/guard/timeout-policy/src/index.ts) | | `@deepseek-ai/dsh-tool-cordis` | `tools` · `cordisInspect` | [`packages/extensions/tool-cordis/src/index.ts`](../packages/extensions/tool-cordis/src/index.ts) | +| `@deepseek-ai/dsh-tool-executor-native` | `tools` | [`packages/core/tool-executor-native/src/index.ts`](../packages/core/tool-executor-native/src/index.ts) | | `@deepseek-ai/dsh-tool-schedule` | `tools` | [`packages/schedule/tool-schedule/src/index.ts`](../packages/schedule/tool-schedule/src/index.ts) | | `@deepseek-ai/dsh-tool-subagent-control` | `tools` · `subagents` · `agents` | [`packages/subagent/tool-subagent-control/src/index.ts`](../packages/subagent/tool-subagent-control/src/index.ts) | | `@deepseek-ai/dsh-tool-working-directory` | `tools` · `workingDirectory` | [`packages/session/tool-working-directory/src/index.ts`](../packages/session/tool-working-directory/src/index.ts) | +| `@deepseek-ai/dsh-tools` | `systemPrompt` | [`packages/core/tools/src/index.ts`](../packages/core/tools/src/index.ts) | | `@deepseek-ai/dsh-user-questions` | — | [`packages/interaction/user-questions/src/index.ts`](../packages/interaction/user-questions/src/index.ts) | | `@deepseek-ai/dsh-webhook` | `agents` · `agentDefaultModel` · `agentPresets` · `permissionPresets` · `sessionTitle` · `workspaceRegistry` | [`packages/experimental/webhook/src/index.ts`](../packages/experimental/webhook/src/index.ts) | | `@deepseek-ai/dsh-workspace` | `storageDomain` · `sessionPersistence` | [`packages/workspace/workspace/src/index.ts`](../packages/workspace/workspace/src/index.ts) | @@ -4900,6 +4884,7 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them. | `@deepseek-ai/dsh-session-title-llm` | — | [`packages/session/session-title-llm/src/index.ts`](../packages/session/session-title-llm/src/index.ts) | | `@deepseek-ai/dsh-ssh-helper-runtime` | — | [`packages/ssh/ssh-helper-runtime/src/index.ts`](../packages/ssh/ssh-helper-runtime/src/index.ts) | | `@deepseek-ai/dsh-timeout` | — | [`packages/util/timeout/src/index.ts`](../packages/util/timeout/src/index.ts) | +| `@deepseek-ai/dsh-tool-testkit` | — | [`packages/test-support/tool-testkit/src/index.ts`](../packages/test-support/tool-testkit/src/index.ts) | | `@deepseek-ai/dsh-typert-generator` | — | [`packages/typert/generator/src/index.ts`](../packages/typert/generator/src/index.ts) | | `@deepseek-ai/dsh-typert-protocol` | — | [`packages/typert/protocol/src/index.ts`](../packages/typert/protocol/src/index.ts) | | `@deepseek-ai/dsh-typert-registry` | — | [`packages/typert/registry/src/index.ts`](../packages/typert/registry/src/index.ts) | diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index c5c978b2dc..48549bc3e1 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -164,11 +164,13 @@ export interface Config { ## `@deepseek-ai/dsh-agent-tool-presentation` - `inject`: `tools` -- `refs`: [`ToolPresentationMode`](subsystems/tools.zh.md) -- `source`: [`packages/core/agent-tool-presentation/src/index.ts:38`](../packages/core/agent-tool-presentation/src/index.ts) +- `source`: [`packages/core/agent-tool-presentation/src/index.ts:25`](../packages/core/agent-tool-presentation/src/index.ts) ```ts config-catalog -/** Plugin config. */ +/** + * Plugin config. + * @deprecated Select the native or PTC executor plugin directly instead of configuring a mode. + */ export interface Config { /** * The form this agent's model sees. `native` sends every visible schema, @@ -177,7 +179,7 @@ export interface Config { * without this row already gets, so an omitted value would mean the row was * composed for nothing. */ - mode: ToolPresentationMode + mode: 'native' | 'ptc' } ``` @@ -1708,7 +1710,7 @@ export interface Config { ## `@deepseek-ai/dsh-host-webserver` -- `source`: [`packages/host/webserver/src/index.ts:76`](../packages/host/webserver/src/index.ts) +- `source`: [`packages/host/webserver/src/index.ts:79`](../packages/host/webserver/src/index.ts) ```ts config-catalog /** Web server listen, TLS, and response-compression config. */ @@ -2892,7 +2894,7 @@ export interface Config { ## `@deepseek-ai/dsh-session-log-export` - `inject`: `commands` · `connection` -- `source`: [`packages/session-query/session-log-export/src/index.ts:46`](../packages/session-query/session-log-export/src/index.ts) +- `source`: [`packages/session-query/session-log-export/src/index.ts:48`](../packages/session-query/session-log-export/src/index.ts) ```ts config-catalog /** Session-log archive policy. */ @@ -3272,7 +3274,7 @@ export interface Config { ## `@deepseek-ai/dsh-spill-policy` - `inject`: `tools` -- `source`: [`packages/spill/spill-policy/src/index.ts:25`](../packages/spill/spill-policy/src/index.ts) +- `source`: [`packages/spill/spill-policy/src/index.ts:26`](../packages/spill/spill-policy/src/index.ts) ```ts config-catalog /** Optional result-retention budget. */ @@ -3851,6 +3853,23 @@ export interface Config { ``` + + + +## `@deepseek-ai/dsh-tool-executor-ptc` + +- `inject`: `tools` · `systemPrompt` · `ptcRuntime` +- `source`: [`packages/core/tool-executor-ptc/src/config.ts:5`](../packages/core/tool-executor-ptc/src/config.ts) + +```ts config-catalog +/** Program execution limits. */ +export interface Config { + /** Maximum overlapping concurrency-safe program subcalls. */ + maxParallelSubCalls: number +} +``` + + @@ -4322,43 +4341,6 @@ export interface Config { ``` - - - -## `@deepseek-ai/dsh-tools` - -- `inject`: `systemPrompt` -- `source`: [`packages/core/tools/src/index.ts:675`](../packages/core/tools/src/index.ts) - -```ts config-catalog -/** Plugin config: how the registered tools are presented to the model. */ -export interface Config { - /** - * Model presentation. `native` (default) sends every visible schema; `ptc` - * sends only `run_code` plus a generated SDK prompt and collapses the - * executor to the same surface (a model-direct call may only name - * `run_code`; `run_code` SDK sub-dispatches keep every visible tool). - * PTC mode requires a `ctx.ptcRuntime` whose `language` - * has a registered SDK renderer (TypeScript or Python) and fails prompt - * assembly when it is absent or has no renderer. Under `ptc`, native names - * in `toolOrder` are invalid. - */ - mode?: ToolPresentationMode - /** - * Concurrency cap for a `run_code` program's overlapping sub-calls - * (default 10, the loop scheduler's own default). Sub-calls follow the - * native scheduling contract — only calls whose tools classify - * concurrency-safe overlap; exclusive calls form barriers — so `1` - * restores strictly serial dispatch. Must be a positive integer. - */ - maxParallelSubCalls?: number -} - -/** How the registry presents its tools to the model (see {@link Config.mode}). */ -export type ToolPresentationMode = 'native' | 'ptc' -``` - - @@ -4803,9 +4785,11 @@ export interface Config { | `@deepseek-ai/dsh-terminal` | — | [`packages/terminal/terminal/src/index.ts`](../packages/terminal/terminal/src/index.ts) | | `@deepseek-ai/dsh-tool-call-timeout-policy` | `tools` | [`packages/guard/timeout-policy/src/index.ts`](../packages/guard/timeout-policy/src/index.ts) | | `@deepseek-ai/dsh-tool-cordis` | `tools` · `cordisInspect` | [`packages/extensions/tool-cordis/src/index.ts`](../packages/extensions/tool-cordis/src/index.ts) | +| `@deepseek-ai/dsh-tool-executor-native` | `tools` | [`packages/core/tool-executor-native/src/index.ts`](../packages/core/tool-executor-native/src/index.ts) | | `@deepseek-ai/dsh-tool-schedule` | `tools` | [`packages/schedule/tool-schedule/src/index.ts`](../packages/schedule/tool-schedule/src/index.ts) | | `@deepseek-ai/dsh-tool-subagent-control` | `tools` · `subagents` · `agents` | [`packages/subagent/tool-subagent-control/src/index.ts`](../packages/subagent/tool-subagent-control/src/index.ts) | | `@deepseek-ai/dsh-tool-working-directory` | `tools` · `workingDirectory` | [`packages/session/tool-working-directory/src/index.ts`](../packages/session/tool-working-directory/src/index.ts) | +| `@deepseek-ai/dsh-tools` | `systemPrompt` | [`packages/core/tools/src/index.ts`](../packages/core/tools/src/index.ts) | | `@deepseek-ai/dsh-user-questions` | — | [`packages/interaction/user-questions/src/index.ts`](../packages/interaction/user-questions/src/index.ts) | | `@deepseek-ai/dsh-webhook` | `agents` · `agentDefaultModel` · `agentPresets` · `permissionPresets` · `sessionTitle` · `workspaceRegistry` | [`packages/experimental/webhook/src/index.ts`](../packages/experimental/webhook/src/index.ts) | | `@deepseek-ai/dsh-workspace` | `storageDomain` · `sessionPersistence` | [`packages/workspace/workspace/src/index.ts`](../packages/workspace/workspace/src/index.ts) | @@ -4902,6 +4886,7 @@ export interface Config { | `@deepseek-ai/dsh-session-title-llm` | — | [`packages/session/session-title-llm/src/index.ts`](../packages/session/session-title-llm/src/index.ts) | | `@deepseek-ai/dsh-ssh-helper-runtime` | — | [`packages/ssh/ssh-helper-runtime/src/index.ts`](../packages/ssh/ssh-helper-runtime/src/index.ts) | | `@deepseek-ai/dsh-timeout` | — | [`packages/util/timeout/src/index.ts`](../packages/util/timeout/src/index.ts) | +| `@deepseek-ai/dsh-tool-testkit` | — | [`packages/test-support/tool-testkit/src/index.ts`](../packages/test-support/tool-testkit/src/index.ts) | | `@deepseek-ai/dsh-typert-generator` | — | [`packages/typert/generator/src/index.ts`](../packages/typert/generator/src/index.ts) | | `@deepseek-ai/dsh-typert-protocol` | — | [`packages/typert/protocol/src/index.ts`](../packages/typert/protocol/src/index.ts) | | `@deepseek-ai/dsh-typert-registry` | — | [`packages/typert/registry/src/index.ts`](../packages/typert/registry/src/index.ts) | diff --git a/docs/cordis-tutorial/07-into-the-harness.md b/docs/cordis-tutorial/07-into-the-harness.md index a1285e63ed..f0c07b4741 100644 --- a/docs/cordis-tutorial/07-into-the-harness.md +++ b/docs/cordis-tutorial/07-into-the-harness.md @@ -37,12 +37,13 @@ export function apply(ctx: Context) { // the model. ToolCallId brands the correlation id a provider would issue. void (async () => { const result = await ctx.tools.execute({ - callId: brandString('demo-1'), - name: 'greet', - arguments: { name: 'Cordis' }, + calls: [{ + type: 'tool-call', id: brandString('demo-1'), + name: 'greet', arguments: JSON.stringify({ name: 'Cordis' }), + }], signal: new AbortController().signal, }) - console.log('tool replied:', JSON.stringify(result.content)) + console.log('tool replied:', JSON.stringify(result.results[0]?.result.content)) })() } ``` diff --git a/docs/cordis-tutorial/07-into-the-harness.zh.md b/docs/cordis-tutorial/07-into-the-harness.zh.md index 30c91c8e40..b12aae828a 100644 --- a/docs/cordis-tutorial/07-into-the-harness.zh.md +++ b/docs/cordis-tutorial/07-into-the-harness.zh.md @@ -37,12 +37,13 @@ export function apply(ctx: Context) { // the model. ToolCallId brands the correlation id a provider would issue. void (async () => { const result = await ctx.tools.execute({ - callId: brandString('demo-1'), - name: 'greet', - arguments: { name: 'Cordis' }, + calls: [{ + type: 'tool-call', id: brandString('demo-1'), + name: 'greet', arguments: JSON.stringify({ name: 'Cordis' }), + }], signal: new AbortController().signal, }) - console.log('tool replied:', JSON.stringify(result.content)) + console.log('tool replied:', JSON.stringify(result.results[0]?.result.content)) })() } ``` diff --git a/docs/defensive-patterns/package-surface.i18n.yaml b/docs/defensive-patterns/package-surface.i18n.yaml index 31367ff0de..6b216a0443 100644 --- a/docs/defensive-patterns/package-surface.i18n.yaml +++ b/docs/defensive-patterns/package-surface.i18n.yaml @@ -12,8 +12,8 @@ en: bf79c6acf35c90a5 zh: 9f2ce6c7f6493b17 /package-surface-rules/package-json-exports-uses-the-standard-key-set: - en: 3604bf435be1c430 - zh: 169621c8a0c2dc4c + en: 692ded0ff903609d + zh: c871ab58ca92ad70 /package-surface-rules/dependency-declarations-and-version-consistency-are-enforced-by-the-dependency-scan: en: e68bfcde70f845b4 zh: 73e5c4fb067c4fa1 diff --git a/docs/defensive-patterns/package-surface.md b/docs/defensive-patterns/package-surface.md index 0ca52f1b03..5876dfa463 100644 --- a/docs/defensive-patterns/package-surface.md +++ b/docs/defensive-patterns/package-surface.md @@ -78,6 +78,7 @@ A `package.json` `exports` map uses only the standard key set, in which every ke - Standard keys, each with one meaning repo-wide, across `packages/*/*/package.json` and `apps/*/package.json`: `.` (the package entry), `./package.json` (toolchain access, not a semantic export), `./src/*` (source passthrough for tests and tools, whose documented status is still open), `./client` (the browser entry, paired with the `dsh.client` declaration), `./types` (the type entry), `./typert` (the typert type runtime), `./remote` (the `@Remote` call surface), `./locale/*.json` (locale resources), `./cordis.patch.yml` (the bundle patch layer declared by `dsh.bundle`). Counts are not listed here because they rot on the next package; the recompute command below prints them. - Keys outside that set (`./brand` in 5 packages, `./worker` in 4, `./startup`, `./runner`, and `./protocol` in 2 each, plus many one-off keys) have no documented meaning; they are pending review, not a pattern to copy. +- `dsh-tools` additionally exposes `./pipeline` for reusable invocation stages and batch scheduling. Its main and pipeline bundles share runtime chunks to preserve error-class identity; workspace constraints check the export targets and publication files. - Judged condition: does a change add an export key outside the standard set? - Exception: none by default; a new key changes the owning document and the check first, with the domain owner's confirmation. - Recompute the distribution: diff --git a/docs/defensive-patterns/package-surface.zh.md b/docs/defensive-patterns/package-surface.zh.md index 09f861ba9f..8452120e4c 100644 --- a/docs/defensive-patterns/package-surface.zh.md +++ b/docs/defensive-patterns/package-surface.zh.md @@ -78,6 +78,7 @@ import { isSurfaceEligibleType } from '../../core/session/src/surface.ts' - 标准键,每个键在全仓只有一个含义,统计口径是 `packages/*/*/package.json` 与 `apps/*/package.json`:`.`(包主入口)、`./package.json`(工具链访问,不是语义导出)、`./src/*`(测试与工具的源码透传,文档状态仍待定)、`./client`(浏览器入口,与 `dsh.client` 声明配对)、`./types`(类型入口)、`./typert`(typert 类型运行时)、`./remote`(`@Remote` 调用面)、`./locale/*.json`(本地化资源)、`./cordis.patch.yml`(`dsh.bundle` 声明的 bundle 补丁层)。这里不列各键的包数,因为下一个包就会让它过期;下面的复算命令会打印当前数字。 - 集合之外的键(`./brand` 5 个包、`./worker` 4 个、`./startup`、`./runner`、`./protocol` 各 2 个,另有大量一次性键)没有文档含义,属待核项,不是可照抄的先例。 +- `dsh-tools` 另行提供 `./pipeline`,用于复用调用阶段与批量调度。主入口与 pipeline bundle 共享运行时 chunk,以保持错误类的身份一致;工作区检查验证导出目标与发布文件。 - 判定条件:改动是否新增了标准集合以外的 export 键? - 例外:无默认例外;新增键须先改正家文档与检查,并取得对应领域专家确认。 - 复算分布: diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 6aff657d15..fb20e4f569 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -65,7 +65,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:44`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - | | `session/created` | `emit` | [`packages/core/session/src/index.ts:56`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`schedule`](../packages/schedule/schedule), `server`, `session-controller`, [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:65`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`workspace-changes`](../packages/deliverables/workspace-changes) | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:76`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-preset-registry`](../packages/preset/agent-preset-registry), `agent-team`, `claude-code-mods`, [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`loader-smoke`](../packages/test-support/loader-smoke), `product-analytics`, [`ptc-runtime-node`](../packages/ptc-runtime/ptc-runtime-node), `server`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-questions`](../packages/interaction/user-questions), [`workspace-changes`](../packages/deliverables/workspace-changes) | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:76`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-preset-registry`](../packages/preset/agent-preset-registry), `agent-team`, `claude-code-mods`, [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`loader-smoke`](../packages/test-support/loader-smoke), `product-analytics`, [`ptc-runtime-node`](../packages/ptc-runtime/ptc-runtime-node), `server`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter), [`tool-executor-ptc`](../packages/core/tool-executor-ptc), [`user-questions`](../packages/interaction/user-questions), [`workspace-changes`](../packages/deliverables/workspace-changes) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:84`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-telemetry`](../packages/session/session-telemetry) | | `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:75`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`emit`) | `remotes` | | `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:296`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - | @@ -75,14 +75,14 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:158`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code) | | `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`) | `browser-use-runtime`, [`working-directory`](../packages/session/working-directory) | | `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:209`](../packages/core/tools/src/index.ts) | [`agent-preset-registry`](../packages/preset/agent-preset-registry) (`emit`), [`tools`](../packages/core/tools) (`emit`) | `browser-use-runtime`, [`tool-subagent`](../packages/subagent/tool-subagent) | -| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:165`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `browser-use-runtime`, `browser-use-stagehand-native`, `claude-code-mods`, `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:177`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `claude-code-mods`, [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/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:154`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `auto-review`, [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`tool-jobs`](../packages/jobs/tool-jobs), [`workspace-changes`](../packages/deliverables/workspace-changes) | -| `tools/ptc-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:191`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | -| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:199`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, [`subagent`](../packages/subagent/subagent), [`tool-present`](../packages/deliverables/tool-present) | +| `tools/change` | `emit` | [`packages/core/tools/src/events.ts:56`](../packages/core/tools/src/events.ts) | [`agent-preset-registry`](../packages/preset/agent-preset-registry) (`emit`), [`tools`](../packages/core/tools) (`emit`) | `browser-use-runtime`, [`tool-subagent`](../packages/subagent/tool-subagent) | +| `tools/execute` | `waterfall` | [`packages/core/tools/src/events.ts:29`](../packages/core/tools/src/events.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `browser-use-runtime`, `browser-use-stagehand-native`, `claude-code-mods`, `computer-use-cua-driver-native`, [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), `timeout-policy` | +| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/events.ts:41`](../packages/core/tools/src/events.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `claude-code-mods`, [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/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/events.ts:18`](../packages/core/tools/src/events.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `auto-review`, [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`tool-jobs`](../packages/jobs/tool-jobs), [`workspace-changes`](../packages/deliverables/workspace-changes) | +| `tools/ptc-dispatch-log` | `waterfall` | [`packages/core/tool-executor-ptc/src/runtime-types.ts:46`](../packages/core/tool-executor-ptc/src/runtime-types.ts) | [`tool-executor-ptc`](../packages/core/tool-executor-ptc) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | +| `tools/result` | `emit` | [`packages/core/tools/src/events.ts:49`](../packages/core/tools/src/events.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, [`subagent`](../packages/subagent/subagent), [`tool-present`](../packages/deliverables/tool-present) | | `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:174`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes`, `server` | -| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:39`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules`, `remote-access`, `shortcuts`, `ui-sidebar-documentpreview`, `ui-theme` | +| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:42`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules`, `remote-access`, `shortcuts`, `ui-sidebar-documentpreview`, `ui-theme` | | `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) | | `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:68`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) | | `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:89`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index fe22e279d9..7d5a14b931 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -67,7 +67,7 @@ | `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:44`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - | | `session/created` | `emit` | [`packages/core/session/src/index.ts:56`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`schedule`](../packages/schedule/schedule), `server`, `session-controller`, [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:65`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`workspace-changes`](../packages/deliverables/workspace-changes) | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:76`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-preset-registry`](../packages/preset/agent-preset-registry), `agent-team`, `claude-code-mods`, [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`loader-smoke`](../packages/test-support/loader-smoke), `product-analytics`, [`ptc-runtime-node`](../packages/ptc-runtime/ptc-runtime-node), `server`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-questions`](../packages/interaction/user-questions), [`workspace-changes`](../packages/deliverables/workspace-changes) | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:76`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-preset-registry`](../packages/preset/agent-preset-registry), `agent-team`, `claude-code-mods`, [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`loader-smoke`](../packages/test-support/loader-smoke), `product-analytics`, [`ptc-runtime-node`](../packages/ptc-runtime/ptc-runtime-node), `server`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter), [`tool-executor-ptc`](../packages/core/tool-executor-ptc), [`user-questions`](../packages/interaction/user-questions), [`workspace-changes`](../packages/deliverables/workspace-changes) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:84`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-telemetry`](../packages/session/session-telemetry) | | `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:75`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`emit`) | `remotes` | | `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:296`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - | @@ -77,14 +77,14 @@ | `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:158`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code) | | `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`) | `browser-use-runtime`, [`working-directory`](../packages/session/working-directory) | | `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:209`](../packages/core/tools/src/index.ts) | [`agent-preset-registry`](../packages/preset/agent-preset-registry) (`emit`), [`tools`](../packages/core/tools) (`emit`) | `browser-use-runtime`, [`tool-subagent`](../packages/subagent/tool-subagent) | -| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:165`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `browser-use-runtime`, `browser-use-stagehand-native`, `claude-code-mods`, `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:177`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `claude-code-mods`, [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/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:154`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `auto-review`, [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`tool-jobs`](../packages/jobs/tool-jobs), [`workspace-changes`](../packages/deliverables/workspace-changes) | -| `tools/ptc-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:191`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | -| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:199`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, [`subagent`](../packages/subagent/subagent), [`tool-present`](../packages/deliverables/tool-present) | +| `tools/change` | `emit` | [`packages/core/tools/src/events.ts:56`](../packages/core/tools/src/events.ts) | [`agent-preset-registry`](../packages/preset/agent-preset-registry) (`emit`), [`tools`](../packages/core/tools) (`emit`) | `browser-use-runtime`, [`tool-subagent`](../packages/subagent/tool-subagent) | +| `tools/execute` | `waterfall` | [`packages/core/tools/src/events.ts:29`](../packages/core/tools/src/events.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `browser-use-runtime`, `browser-use-stagehand-native`, `claude-code-mods`, `computer-use-cua-driver-native`, [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), `timeout-policy` | +| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/events.ts:41`](../packages/core/tools/src/events.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `claude-code-mods`, [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/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/events.ts:18`](../packages/core/tools/src/events.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `auto-review`, [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`tool-jobs`](../packages/jobs/tool-jobs), [`workspace-changes`](../packages/deliverables/workspace-changes) | +| `tools/ptc-dispatch-log` | `waterfall` | [`packages/core/tool-executor-ptc/src/runtime-types.ts:46`](../packages/core/tool-executor-ptc/src/runtime-types.ts) | [`tool-executor-ptc`](../packages/core/tool-executor-ptc) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | +| `tools/result` | `emit` | [`packages/core/tools/src/events.ts:49`](../packages/core/tools/src/events.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, [`subagent`](../packages/subagent/subagent), [`tool-present`](../packages/deliverables/tool-present) | | `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:174`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes`, `server` | -| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:39`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules`, `remote-access`, `shortcuts`, `ui-sidebar-documentpreview`, `ui-theme` | +| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:42`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules`, `remote-access`, `shortcuts`, `ui-sidebar-documentpreview`, `ui-theme` | | `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) | | `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:68`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) | | `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:89`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | diff --git a/docs/module-graph.md b/docs/module-graph.md index 02f1745630..185121b606 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -47,6 +47,8 @@ flowchart TD pkg_scope["scope"] pkg_session["session"] pkg_system_prompt["system-prompt"] + pkg_tool_executor_native["tool-executor-native"] + pkg_tool_executor_ptc["tool-executor-ptc"] pkg_tools["tools"] end subgraph group_goal["packages/goal"] @@ -445,6 +447,7 @@ flowchart TD pkg_loader_smoke["loader-smoke"] pkg_remote_mock["remote-mock"] pkg_session_snapshot["session-snapshot"] + pkg_tool_testkit["tool-testkit"] end subgraph group_typert["packages/typert"] pkg_typert_generator["typert-generator"] @@ -726,14 +729,10 @@ flowchart TD pkg_llm_deepseek_account --> pkg_timeout pkg_tools --> pkg_agent pkg_tools --> pkg_llm - pkg_tools --> pkg_ptc_runtime - pkg_tools --> pkg_sandbox - pkg_tools --> pkg_sandbox_policy pkg_tools --> pkg_scope pkg_tools --> pkg_session pkg_tools --> pkg_system_prompt pkg_tools --> pkg_user_approval - pkg_tools --> pkg_working_directory pkg_command_goal --> pkg_commands pkg_command_goal --> pkg_goal pkg_command_goal --> pkg_llm @@ -830,6 +829,17 @@ flowchart TD pkg_agent_loop --> pkg_system_prompt pkg_agent_loop --> pkg_tools pkg_agent_tool_presentation --> pkg_tools + pkg_tool_executor_native --> pkg_tools + pkg_tool_executor_ptc --> pkg_agent + pkg_tool_executor_ptc --> pkg_llm + pkg_tool_executor_ptc --> pkg_ptc_runtime + pkg_tool_executor_ptc --> pkg_sandbox + pkg_tool_executor_ptc --> pkg_sandbox_policy + pkg_tool_executor_ptc --> pkg_scope + pkg_tool_executor_ptc --> pkg_session + pkg_tool_executor_ptc --> pkg_tools + pkg_tool_executor_ptc --> pkg_user_approval + pkg_tool_executor_ptc --> pkg_working_directory pkg_tool_goal --> pkg_agent pkg_tool_goal --> pkg_goal pkg_tool_goal --> pkg_llm @@ -1041,6 +1051,8 @@ flowchart TD pkg_llm_replay --> pkg_deepseek_llm_api_extensions pkg_llm_replay --> pkg_llm pkg_llm_replay --> pkg_session + pkg_tool_testkit --> pkg_llm + pkg_tool_testkit --> pkg_tools pkg_tool_workflow --> pkg_agent pkg_tool_workflow --> pkg_jobs pkg_tool_workflow --> pkg_llm @@ -1673,7 +1685,7 @@ flowchart TD | [`loader-smoke`](../packages/test-support/loader-smoke) | `test-support` | [`agent`](../packages/core/agent), [`http-proxy`](../packages/util/http-proxy), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`workflow`](../packages/workflow/workflow) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`llm-deepseek-account`](../packages/llm/llm-deepseek-account) | `llm` | [`deepseek-account`](../packages/credentials/deepseek-account), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`timeout`](../packages/util/timeout) | -| [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`ptc-runtime`](../packages/ptc-runtime/ptc-runtime), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`user-approval`](../packages/interaction/user-approval), [`working-directory`](../packages/session/working-directory) | +| [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`user-approval`](../packages/interaction/user-approval) | | [`command-goal`](../packages/goal/command-goal) | `goal` | [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`llm`](../packages/llm/llm) | | [`fs-sandbox`](../packages/fs/fs-sandbox) | `fs` | [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | | [`web-deepseek-api`](../packages/web/web-deepseek-api) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`deepseek-account`](../packages/credentials/deepseek-account), [`launch-environment`](../packages/util/launch-environment), [`session`](../packages/core/session), [`web`](../packages/web/web) | @@ -1694,6 +1706,8 @@ flowchart TD | [`terminal-bash`](../packages/terminal/terminal-bash) | `terminal` | [`agent`](../packages/core/agent), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess), [`terminal`](../packages/terminal/terminal), [`working-directory`](../packages/session/working-directory) | | [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`agent-tool-presentation`](../packages/core/agent-tool-presentation) | `core` | [`tools`](../packages/core/tools) | +| [`tool-executor-native`](../packages/core/tool-executor-native) | `core` | [`tools`](../packages/core/tools) | +| [`tool-executor-ptc`](../packages/core/tool-executor-ptc) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`ptc-runtime`](../packages/ptc-runtime/ptc-runtime), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`working-directory`](../packages/session/working-directory) | | [`tool-goal`](../packages/goal/tool-goal) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`attachment`](../packages/attachment/attachment), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`working-directory`](../packages/session/working-directory) | | [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | @@ -1735,6 +1749,7 @@ flowchart TD | [`tool-pwsh-persistent`](../packages/shell/tool-pwsh-persistent) | `shell` | [`agent`](../packages/core/agent), [`output-retention`](../packages/util/output-retention), [`terminal`](../packages/terminal/terminal), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | | [`ssh`](../packages/ssh/ssh) | `ssh` | [`brand`](../packages/util/brand), [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`fs-sandbox`](../packages/fs/fs-sandbox), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-local`](../packages/sandbox/sandbox-local), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session-projection`](../packages/session/session-projection), [`subprocess`](../packages/subprocess/subprocess), [`subprocess-local`](../packages/subprocess/subprocess-local), [`util-values`](../packages/util/values) | | [`llm-replay`](../packages/test-support/llm-replay) | `test-support` | [`compaction`](../packages/compaction/compaction), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | +| [`tool-testkit`](../packages/test-support/tool-testkit) | `test-support` | [`llm`](../packages/llm/llm), [`tools`](../packages/core/tools) | | [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`plugin-package-inventory-deepseek`](../packages/llm/plugin-package-inventory-deepseek) | `llm` | [`agent`](../packages/core/agent), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`session`](../packages/core/session) | | [`token-meter`](../packages/llm/token-meter) | `llm` | [`compaction`](../packages/compaction/compaction), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 037a7096ce..ac77475bf1 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -49,6 +49,8 @@ flowchart TD pkg_scope["scope"] pkg_session["session"] pkg_system_prompt["system-prompt"] + pkg_tool_executor_native["tool-executor-native"] + pkg_tool_executor_ptc["tool-executor-ptc"] pkg_tools["tools"] end subgraph group_goal["packages/goal"] @@ -447,6 +449,7 @@ flowchart TD pkg_loader_smoke["loader-smoke"] pkg_remote_mock["remote-mock"] pkg_session_snapshot["session-snapshot"] + pkg_tool_testkit["tool-testkit"] end subgraph group_typert["packages/typert"] pkg_typert_generator["typert-generator"] @@ -728,14 +731,10 @@ flowchart TD pkg_llm_deepseek_account --> pkg_timeout pkg_tools --> pkg_agent pkg_tools --> pkg_llm - pkg_tools --> pkg_ptc_runtime - pkg_tools --> pkg_sandbox - pkg_tools --> pkg_sandbox_policy pkg_tools --> pkg_scope pkg_tools --> pkg_session pkg_tools --> pkg_system_prompt pkg_tools --> pkg_user_approval - pkg_tools --> pkg_working_directory pkg_command_goal --> pkg_commands pkg_command_goal --> pkg_goal pkg_command_goal --> pkg_llm @@ -832,6 +831,17 @@ flowchart TD pkg_agent_loop --> pkg_system_prompt pkg_agent_loop --> pkg_tools pkg_agent_tool_presentation --> pkg_tools + pkg_tool_executor_native --> pkg_tools + pkg_tool_executor_ptc --> pkg_agent + pkg_tool_executor_ptc --> pkg_llm + pkg_tool_executor_ptc --> pkg_ptc_runtime + pkg_tool_executor_ptc --> pkg_sandbox + pkg_tool_executor_ptc --> pkg_sandbox_policy + pkg_tool_executor_ptc --> pkg_scope + pkg_tool_executor_ptc --> pkg_session + pkg_tool_executor_ptc --> pkg_tools + pkg_tool_executor_ptc --> pkg_user_approval + pkg_tool_executor_ptc --> pkg_working_directory pkg_tool_goal --> pkg_agent pkg_tool_goal --> pkg_goal pkg_tool_goal --> pkg_llm @@ -1043,6 +1053,8 @@ flowchart TD pkg_llm_replay --> pkg_deepseek_llm_api_extensions pkg_llm_replay --> pkg_llm pkg_llm_replay --> pkg_session + pkg_tool_testkit --> pkg_llm + pkg_tool_testkit --> pkg_tools pkg_tool_workflow --> pkg_agent pkg_tool_workflow --> pkg_jobs pkg_tool_workflow --> pkg_llm @@ -1675,7 +1687,7 @@ flowchart TD | [`loader-smoke`](../packages/test-support/loader-smoke) | `test-support` | [`agent`](../packages/core/agent), [`http-proxy`](../packages/util/http-proxy), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`workflow`](../packages/workflow/workflow) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`llm-deepseek-account`](../packages/llm/llm-deepseek-account) | `llm` | [`deepseek-account`](../packages/credentials/deepseek-account), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`timeout`](../packages/util/timeout) | -| [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`ptc-runtime`](../packages/ptc-runtime/ptc-runtime), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`user-approval`](../packages/interaction/user-approval), [`working-directory`](../packages/session/working-directory) | +| [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`user-approval`](../packages/interaction/user-approval) | | [`command-goal`](../packages/goal/command-goal) | `goal` | [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`llm`](../packages/llm/llm) | | [`fs-sandbox`](../packages/fs/fs-sandbox) | `fs` | [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | | [`web-deepseek-api`](../packages/web/web-deepseek-api) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`deepseek-account`](../packages/credentials/deepseek-account), [`launch-environment`](../packages/util/launch-environment), [`session`](../packages/core/session), [`web`](../packages/web/web) | @@ -1696,6 +1708,8 @@ flowchart TD | [`terminal-bash`](../packages/terminal/terminal-bash) | `terminal` | [`agent`](../packages/core/agent), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess), [`terminal`](../packages/terminal/terminal), [`working-directory`](../packages/session/working-directory) | | [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`agent-tool-presentation`](../packages/core/agent-tool-presentation) | `core` | [`tools`](../packages/core/tools) | +| [`tool-executor-native`](../packages/core/tool-executor-native) | `core` | [`tools`](../packages/core/tools) | +| [`tool-executor-ptc`](../packages/core/tool-executor-ptc) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`ptc-runtime`](../packages/ptc-runtime/ptc-runtime), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`working-directory`](../packages/session/working-directory) | | [`tool-goal`](../packages/goal/tool-goal) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`attachment`](../packages/attachment/attachment), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`working-directory`](../packages/session/working-directory) | | [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | @@ -1737,6 +1751,7 @@ flowchart TD | [`tool-pwsh-persistent`](../packages/shell/tool-pwsh-persistent) | `shell` | [`agent`](../packages/core/agent), [`output-retention`](../packages/util/output-retention), [`terminal`](../packages/terminal/terminal), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | | [`ssh`](../packages/ssh/ssh) | `ssh` | [`brand`](../packages/util/brand), [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`fs-sandbox`](../packages/fs/fs-sandbox), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-local`](../packages/sandbox/sandbox-local), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session-projection`](../packages/session/session-projection), [`subprocess`](../packages/subprocess/subprocess), [`subprocess-local`](../packages/subprocess/subprocess-local), [`util-values`](../packages/util/values) | | [`llm-replay`](../packages/test-support/llm-replay) | `test-support` | [`compaction`](../packages/compaction/compaction), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | +| [`tool-testkit`](../packages/test-support/tool-testkit) | `test-support` | [`llm`](../packages/llm/llm), [`tools`](../packages/core/tools) | | [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`plugin-package-inventory-deepseek`](../packages/llm/plugin-package-inventory-deepseek) | `llm` | [`agent`](../packages/core/agent), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`session`](../packages/core/session) | | [`token-meter`](../packages/llm/token-meter) | `llm` | [`compaction`](../packages/compaction/compaction), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index aad6840526..8ae37ef48e 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.i18n.yaml @@ -249,11 +249,11 @@ en: 03f36237cbf9e267 zh: 83fe2e69bbbfd3c1 /session-persistence-event-catalog/events/tool/tool-ptc-dispatch-log-only: - en: 3d5d72b99e702a8c - zh: e8c3b82cd5361c4a + en: a62b38c00afed655 + zh: 793bef93e856eafa /session-persistence-event-catalog/events/tool/tool-ptc-dispatch-start-log-only: - en: de38cf70d81ad7c3 - zh: dd0a2a8be099271d + en: da841b1ed3b22c87 + zh: 4c9a4d4bc5bd76b6 /session-persistence-event-catalog/events/tool/tool-result-surface: en: 283e3cf1fdd57f05 zh: 0bc3a15ae4f47aaf @@ -1164,14 +1164,14 @@ en: bb11552744e477b8 zh: afb881f3ebf97e68 /session-persistence-event-catalog/resolved-persistence-types/principal: - en: d1e45e03f161dc7c - zh: 4f80af59ca4967c5 + en: 6d0d81e0e833403e + zh: fa47cd75ee75f948 /session-persistence-event-catalog/resolved-persistence-types/ptcdispatcheventdata: - en: 81e66c5a07e17dd6 - zh: 0ecf3cfceba14d2b + en: c0f7b22b41cb0608 + zh: de14a1b5cac17665 /session-persistence-event-catalog/resolved-persistence-types/ptcdispatchstarteventdata: - en: fbbf650635960ec8 - zh: 9457953f4cbe07c6 + en: aca35dac4f9d2add + zh: 6a4bdb95e3c7b0ce /session-persistence-event-catalog/resolved-persistence-types/reasoningblock: en: 07e04c3e336f880f zh: 6f8986ede9994fa1 @@ -1284,11 +1284,11 @@ en: 036b2595215410bb zh: 41792808ebac1fa3 /session-persistence-event-catalog/resolved-persistence-types/toolremovalblock: - en: f43a635a14568780 - zh: 8d22226b045122eb + en: fdac0df45529e14c + zh: c5e38de83a0e0a42 /session-persistence-event-catalog/resolved-persistence-types/toolrestriction: - en: d7d3c0238d9b373e - zh: f49d93ee54e7a780 + en: 9d64c653e33bf2b6 + zh: 7bb427b48756f30f /session-persistence-event-catalog/resolved-persistence-types/toolresultmessage: en: 2258683c3cfa4ce3 zh: 5663aa76c911761b @@ -1443,8 +1443,8 @@ en: a1f188c9058cf621 zh: eb43c0f045fb9afb /session-persistence-event-catalog/resolved-persistence-types/code-name-reason: - en: 404a76066a59f3c6 - zh: ec9d510bdbac9786 + en: 39c67256532a22f9 + zh: 59f823e98630c5e2 /session-persistence-event-catalog/resolved-persistence-types/commandid-kind-sourceeventseq-text: en: 87b03e1c73d2a326 zh: d7b9f06863cad891 @@ -1674,8 +1674,8 @@ en: c53129cf7214286c zh: 8c5183b6dabbf714 /session-persistence-event-catalog/resolved-persistence-types/kind-ptc-mode: - en: 8de77980e11a0562 - zh: 6de199e70a666a71 + en: b4fd9b219667fb26 + zh: bc56baa64fd7ffd8 /session-persistence-event-catalog/resolved-persistence-types/kind-repeat-tool-reminder: en: e3a0ef98a197374e zh: 95e9363529a62cc3 @@ -1830,8 +1830,8 @@ en: 08be4349548406a9 zh: 8e7cf9966198ef78 /session-persistence-event-catalog/resolved-persistence-types/kind-tool-registry: - en: 809a23c25ded8497 - zh: e9cd23e281f8d31f + en: c59d020828d8211f + zh: 154a30cb69e79489 /session-persistence-event-catalog/resolved-persistence-types/kind-user: en: 43f0c6042d410afd zh: a0b68de4e5d220bd diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 7e8cbf874e..1dbbadbe25 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -1116,7 +1116,7 @@ Source: [`packages/core/session/src/types.ts:362`](../packages/core/session/src/ 'tool/ptc-dispatch': PtcDispatchEventData ``` -Source: [`packages/core/tools/src/types.ts:61`](../packages/core/tools/src/types.ts) +Source: [`packages/core/tool-executor-ptc/src/types.ts:61`](../packages/core/tool-executor-ptc/src/types.ts) @@ -1139,7 +1139,7 @@ Source: [`packages/core/tools/src/types.ts:61`](../packages/core/tools/src/types 'tool/ptc-dispatch-start': PtcDispatchStartEventData ``` -Source: [`packages/core/tools/src/types.ts:45`](../packages/core/tools/src/types.ts) +Source: [`packages/core/tool-executor-ptc/src/types.ts:45`](../packages/core/tool-executor-ptc/src/types.ts) @@ -4442,7 +4442,7 @@ Sources: [`packages/typert/protocol/src/types.ts:457`](../packages/typert/protoc - + @@ -4450,7 +4450,7 @@ Sources: [`packages/typert/protocol/src/types.ts:457`](../packages/typert/protoc SHA-256: `cef171adf021a1867d244618a3dbbaafc22beb17c7f3df3ee203393271c8c6f6` -Sources: [`packages/core/tools/src/types.ts:21`](../packages/core/tools/src/types.ts) +Sources: [`packages/core/tool-executor-ptc/src/types.ts:21`](../packages/core/tool-executor-ptc/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -4466,7 +4466,7 @@ Sources: [`packages/core/tools/src/types.ts:21`](../packages/core/tools/src/type - + @@ -4474,7 +4474,7 @@ Sources: [`packages/core/tools/src/types.ts:21`](../packages/core/tools/src/type SHA-256: `2fe5026daf14bd4ff2b7bfb7cf9791c302d9cbba06802178b6dfec00b892698f` -Sources: [`packages/core/tools/src/types.ts:12`](../packages/core/tools/src/types.ts) +Sources: [`packages/core/tool-executor-ptc/src/types.ts:12`](../packages/core/tool-executor-ptc/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -5185,7 +5185,7 @@ Sources: [`packages/llm/llm/src/types.ts:127`](../packages/llm/llm/src/types.ts) - + @@ -5193,7 +5193,7 @@ Sources: [`packages/llm/llm/src/types.ts:127`](../packages/llm/llm/src/types.ts) SHA-256: `3cfc3a56502da1f8c6153c2c56657bab4b749056968634dd0991d325ec1c919f` -Sources: [`packages/core/tools/src/index.ts:701`](../packages/core/tools/src/index.ts) +Sources: [`packages/core/tools/src/contracts.ts:326`](../packages/core/tools/src/contracts.ts) | Property | Presence | Type | |---|---|---| @@ -5979,7 +5979,7 @@ SHA-256: `cbbe8377ee744964537dbafd83607219e58ba79a13f6db0d0645c7ad3d5e8a09` SHA-256: `a81059ca06999da1e712414520c2c65e491d052fcf9640fae20aa38f0a5dc735` -Sources: [`packages/core/session/src/types.ts:384`](../packages/core/session/src/types.ts) · [`packages/core/tools/src/types.ts:27`](../packages/core/tools/src/types.ts) +Sources: [`packages/core/session/src/types.ts:384`](../packages/core/session/src/types.ts) · [`packages/core/tool-executor-ptc/src/types.ts:27`](../packages/core/tool-executor-ptc/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -6960,7 +6960,7 @@ Sources: [`packages/session/session-title/src/types.ts:30`](../packages/session/ SHA-256: `dba54c09c6e7aafc9acbed7792ecc920d99f92d0784aeeed10af55f7fe0bc8cd` -Sources: [`packages/core/tools/src/ptc.ts:15`](../packages/core/tools/src/ptc.ts) +Sources: [`packages/core/tool-executor-ptc/src/types.ts:68`](../packages/core/tool-executor-ptc/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -7542,7 +7542,7 @@ SHA-256: `c5228b7a55498d84f529591744a0ab26e0b93ade18a70a26db1377a1ecfa6b49` SHA-256: `8fcb0b44a575e998665c053643d1ab04c82f39253ebd190ac0c975b78e40b543` -Sources: [`packages/core/tools/src/index.ts:35`](../packages/core/tools/src/index.ts) +Sources: [`packages/core/tools/src/index.ts:18`](../packages/core/tools/src/index.ts) | Property | Presence | Type | |---|---|---| diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index 4233189dfd..674a9c8e74 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -1118,7 +1118,7 @@ export type SessionEvent = { 'tool/ptc-dispatch': PtcDispatchEventData ``` -来源:[`packages/core/tools/src/types.ts:61`](../packages/core/tools/src/types.ts) +来源:[`packages/core/tool-executor-ptc/src/types.ts:61`](../packages/core/tool-executor-ptc/src/types.ts) @@ -1141,7 +1141,7 @@ export type SessionEvent = { 'tool/ptc-dispatch-start': PtcDispatchStartEventData ``` -来源:[`packages/core/tools/src/types.ts:45`](../packages/core/tools/src/types.ts) +来源:[`packages/core/tool-executor-ptc/src/types.ts:45`](../packages/core/tool-executor-ptc/src/types.ts) @@ -4444,7 +4444,7 @@ SHA-256: `deebfc7c62177ea840f23c6362b8615bc78ffa93377dd5d243c04e541c413af5` - + @@ -4452,7 +4452,7 @@ SHA-256: `deebfc7c62177ea840f23c6362b8615bc78ffa93377dd5d243c04e541c413af5` SHA-256: `cef171adf021a1867d244618a3dbbaafc22beb17c7f3df3ee203393271c8c6f6` -来源:[`packages/core/tools/src/types.ts:21`](../packages/core/tools/src/types.ts) +来源:[`packages/core/tool-executor-ptc/src/types.ts:21`](../packages/core/tool-executor-ptc/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -4468,7 +4468,7 @@ SHA-256: `cef171adf021a1867d244618a3dbbaafc22beb17c7f3df3ee203393271c8c6f6` - + @@ -4476,7 +4476,7 @@ SHA-256: `cef171adf021a1867d244618a3dbbaafc22beb17c7f3df3ee203393271c8c6f6` SHA-256: `2fe5026daf14bd4ff2b7bfb7cf9791c302d9cbba06802178b6dfec00b892698f` -来源:[`packages/core/tools/src/types.ts:12`](../packages/core/tools/src/types.ts) +来源:[`packages/core/tool-executor-ptc/src/types.ts:12`](../packages/core/tool-executor-ptc/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -5187,7 +5187,7 @@ SHA-256: `43e311235aa537df1bacdd92d144df8a952337a76e9ac892cdf670f1a797bad8` - + @@ -5195,7 +5195,7 @@ SHA-256: `43e311235aa537df1bacdd92d144df8a952337a76e9ac892cdf670f1a797bad8` SHA-256: `3cfc3a56502da1f8c6153c2c56657bab4b749056968634dd0991d325ec1c919f` -来源:[`packages/core/tools/src/index.ts:701`](../packages/core/tools/src/index.ts) +来源:[`packages/core/tools/src/contracts.ts:326`](../packages/core/tools/src/contracts.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -5981,7 +5981,7 @@ SHA-256: `cbbe8377ee744964537dbafd83607219e58ba79a13f6db0d0645c7ad3d5e8a09` SHA-256: `a81059ca06999da1e712414520c2c65e491d052fcf9640fae20aa38f0a5dc735` -来源:[`packages/core/session/src/types.ts:384`](../packages/core/session/src/types.ts) · [`packages/core/tools/src/types.ts:27`](../packages/core/tools/src/types.ts) +来源:[`packages/core/session/src/types.ts:384`](../packages/core/session/src/types.ts) · [`packages/core/tool-executor-ptc/src/types.ts:27`](../packages/core/tool-executor-ptc/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -6962,7 +6962,7 @@ SHA-256: `b81e9de81a481b24e1e1f9adf038bfc59e57b4f35ce62f8f1e45284186f4f812` SHA-256: `dba54c09c6e7aafc9acbed7792ecc920d99f92d0784aeeed10af55f7fe0bc8cd` -来源:[`packages/core/tools/src/ptc.ts:15`](../packages/core/tools/src/ptc.ts) +来源:[`packages/core/tool-executor-ptc/src/types.ts:68`](../packages/core/tool-executor-ptc/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -7544,7 +7544,7 @@ SHA-256: `c5228b7a55498d84f529591744a0ab26e0b93ade18a70a26db1377a1ecfa6b49` SHA-256: `8fcb0b44a575e998665c053643d1ab04c82f39253ebd190ac0c975b78e40b543` -来源:[`packages/core/tools/src/index.ts:35`](../packages/core/tools/src/index.ts) +来源:[`packages/core/tools/src/index.ts:18`](../packages/core/tools/src/index.ts) | 属性 | 存在性 | 类型 | |---|---|---| diff --git a/docs/persistence-schema.json b/docs/persistence-schema.json index 391f4a2637..2edd9b5121 100644 --- a/docs/persistence-schema.json +++ b/docs/persistence-schema.json @@ -190,7 +190,7 @@ {"digest":"2eafefdc6f79a4922d68a787a09eaf4dd2b2369c3777fba4c05e800b5678ac8e","names":["packages/experimental/agent-team/src/types.ts#TeamMessageSnapshot"],"sources":["packages/experimental/agent-team/src/types.ts:129"]}, {"digest":"2f1e6a7ebace43d727ae138ddcd352eb3813960009e5805b8c5ac84300c97716","names":["packages/attachment/attachment/src/types.ts#ImageMediaType"],"sources":["packages/attachment/attachment/src/types.ts:8"]}, {"digest":"2f3f201c8846e19c20bb75c02280e3d4eb3c57509e4715f0c0a4d1d4f91a7a06","names":[],"sources":[]}, - {"digest":"2fe5026daf14bd4ff2b7bfb7cf9791c302d9cbba06802178b6dfec00b892698f","names":["packages/core/tools/src/types.ts#PtcDispatchStartEventData"],"sources":["packages/core/tools/src/types.ts:12"]}, + {"digest":"2fe5026daf14bd4ff2b7bfb7cf9791c302d9cbba06802178b6dfec00b892698f","names":["packages/core/tool-executor-ptc/src/types.ts#PtcDispatchStartEventData"],"sources":["packages/core/tool-executor-ptc/src/types.ts:12"]}, {"digest":"30d5a537b296685770b1241c4b4bbde19d5a31ff81aa23df73fab5e90b8fc65f","names":["packages/session/session-title/src/types.ts#SessionTitleSource"],"sources":["packages/session/session-title/src/types.ts:28"]}, {"digest":"312aee2f0c42aabbae6cc60d92d596d8453c0aeb061c5868d1755a69ef49e69a","names":[],"sources":[]}, {"digest":"31907de9563751822af6d9be02bafaf26faa098bbd1cb412524134d577cc1648","names":["packages/compaction/compaction/src/checkpoint.ts#CompactionCheckpointSource"],"sources":["packages/compaction/compaction/src/checkpoint.ts:22"]}, @@ -220,7 +220,7 @@ {"digest":"3bec87f9e6ab3da03346f49923d3c51621debc0159df68605e543c3b2a026f90","names":[],"sources":["packages/llm/llm/src/types.ts:471"]}, {"digest":"3bfeb47b58606f4661904bc723da612782214c463d01e6d61cd6d6193d7374e1","names":[],"sources":[]}, {"digest":"3c2535ca0a2062f172f15344f8040755002b5ac506cf94fc4ebaa822b15feb8c","names":[],"sources":[]}, - {"digest":"3cfc3a56502da1f8c6153c2c56657bab4b749056968634dd0991d325ec1c919f","names":["packages/core/tools/src/index.ts#ToolRestriction"],"sources":["packages/core/tools/src/index.ts:701"]}, + {"digest":"3cfc3a56502da1f8c6153c2c56657bab4b749056968634dd0991d325ec1c919f","names":["packages/core/tools/src/contracts.ts#ToolRestriction"],"sources":["packages/core/tools/src/contracts.ts:326"]}, {"digest":"3d7d07060a7be77984feb651ea2bf1925d3e0477911d9d3ed86330d1e8ca6182","names":["packages/feedback/message-feedback/src/types.ts#MessageFeedbackItem"],"sources":["packages/feedback/message-feedback/src/types.ts:20"]}, {"digest":"3e0eff5946255ff05d88a10839c60305135801f97794ea48d409f8503303317e","names":[],"sources":[]}, {"digest":"3e2ece4967cfebe95f450a93f45523dff99d6749f6269f2f60f84dfecfcf3693","names":[],"sources":[]}, @@ -416,7 +416,7 @@ {"digest":"8e8e91646a5cae3fc78f7532a0013ff2088ae47077642c2edd507a508209b8af","names":["packages/schedule/schedule/src/types.ts#OneShotScheduleDispatchChange"],"sources":["packages/schedule/schedule/src/types.ts:237"]}, {"digest":"8f324cea3916c0517261e88167def8efb3a9994c1135e23d40ed9fd48f8c7bd4","names":[],"sources":[]}, {"digest":"8f6d609794bf5afc01d7ccf3e03811d32f4b8047f58a2c9a98db0154c2ad7cb9","names":[],"sources":[]}, - {"digest":"8fcb0b44a575e998665c053643d1ab04c82f39253ebd190ac0c975b78e40b543","names":[],"sources":["packages/core/tools/src/index.ts:35"]}, + {"digest":"8fcb0b44a575e998665c053643d1ab04c82f39253ebd190ac0c975b78e40b543","names":[],"sources":["packages/core/tools/src/index.ts:18"]}, {"digest":"904bf499d98be4a3a5f836fcac533b6c4899569b018071e6604e1d32657d00fd","names":[],"sources":[]}, {"digest":"930c1cd289def119d443e19dd90008916bf035f0a4d23e07241e7f050f2b89b7","names":["packages/interaction/user-approval/src/types.ts#ApprovalOutcome"],"sources":["packages/interaction/user-approval/src/types.ts:32"]}, {"digest":"935c8d486292b13c87b57ca280eb8c58b5b2f5239be86fcd4334c33fdeb87682","names":[],"sources":[]}, @@ -471,7 +471,7 @@ {"digest":"a6da9ad5d42ae83a6833a8288b076688e8e0801633ea36cbdd8a30a6d9f3160c","names":["packages/workflow/tool-workflow/src/types.ts#ToolWorkflowRunStartData"],"sources":["packages/workflow/tool-workflow/src/types.ts:14"]}, {"digest":"a720757f704a2512e9d2f20da4a093e582472176236a75ab8b8eb7aaff17d85a","names":[],"sources":[]}, {"digest":"a7cf43ce7c2a4c038feed1885cd7a00d5c6ee2d90a7e0d56b46f78a3e1ca327f","names":[],"sources":[]}, - {"digest":"a81059ca06999da1e712414520c2c65e491d052fcf9640fae20aa38f0a5dc735","names":[],"sources":["packages/core/session/src/types.ts:384","packages/core/tools/src/types.ts:27"]}, + {"digest":"a81059ca06999da1e712414520c2c65e491d052fcf9640fae20aa38f0a5dc735","names":[],"sources":["packages/core/session/src/types.ts:384","packages/core/tool-executor-ptc/src/types.ts:27"]}, {"digest":"a853902cfab417b8f08aa51ad6855fea46f34ed4588a74e47403035111d0fb86","names":[],"sources":["packages/interaction/commands/src/types.ts:105"]}, {"digest":"a8a2b9011fc067862df7ce40748868467e5778283d7ac193c13f77684a914c57","names":[],"sources":[]}, {"digest":"a9059a81430b320792bbb7c2ee1f8f6c77ff71e40ee44c18aec72e157c522694","names":[],"sources":[]}, @@ -570,7 +570,7 @@ {"digest":"cda2345a9be9d139df49ccbd16dcba716f3440178bf20ff8b801fda1f5eb77dc","names":[],"sources":[]}, {"digest":"cdca80b8dad24d4e7b1d5a288a8475ba8d8d46be385bd2da85f122ee836f1670","names":[],"sources":[]}, {"digest":"cdee10e87dbed8440d8b6935888624884878efc7af95402e94171b29f173dced","names":[],"sources":["packages/llm/llm-retry/src/types.ts:17"]}, - {"digest":"cef171adf021a1867d244618a3dbbaafc22beb17c7f3df3ee203393271c8c6f6","names":["packages/core/tools/src/types.ts#PtcDispatchEventData"],"sources":["packages/core/tools/src/types.ts:21"]}, + {"digest":"cef171adf021a1867d244618a3dbbaafc22beb17c7f3df3ee203393271c8c6f6","names":["packages/core/tool-executor-ptc/src/types.ts#PtcDispatchEventData"],"sources":["packages/core/tool-executor-ptc/src/types.ts:21"]}, {"digest":"cffcdc19881ab61ec0a9ce18743996f7ac44ac8889f34dfac36ae745574aa22f","names":["packages/goal/goal/src/domain.ts#GoalClearChangeMeta"],"sources":["packages/goal/goal/src/domain.ts:35"]}, {"digest":"d0fbb0ca1bbba059830826f33f801da1aced35a2504376d61e020bdfec8d50c0","names":[],"sources":[]}, {"digest":"d28765f87d6b6ab408e0b2f3e7e297791b004b806d8e302c49903ebae815feb2","names":[],"sources":[]}, @@ -590,7 +590,7 @@ {"digest":"db06ef35b27a38425046b2228fa6bce9582abe140d781d363137eb2f4d233a1c","names":[],"sources":[]}, {"digest":"db6c5246911d47a7969f6b4cb47c7d0dbbde75c402710b22ace3672fa97bebe0","names":[],"sources":[]}, {"digest":"db874d463b0fdec77e9da1c4568f37cb70bd6596781eb93800db44fb8a116965","names":[],"sources":[]}, - {"digest":"dba54c09c6e7aafc9acbed7792ecc920d99f92d0784aeeed10af55f7fe0bc8cd","names":[],"sources":["packages/core/tools/src/ptc.ts:15"]}, + {"digest":"dba54c09c6e7aafc9acbed7792ecc920d99f92d0784aeeed10af55f7fe0bc8cd","names":[],"sources":["packages/core/tool-executor-ptc/src/types.ts:68"]}, {"digest":"dbdafceb9a900183cccc80596498d386f3bf7bddead2b8cfde68453aa4739edc","names":[],"sources":[]}, {"digest":"dd2780e7e07684332f7a9ab047237798d260e7eaffb1321cb38eca695d184c3d","names":[],"sources":[]}, {"digest":"dd5ee8762bd8a14b6c6bffedd1753e4e78505bb0f9ea1621018d03a54eef59d5","names":[],"sources":[]}, diff --git a/docs/subsystems/persistence.i18n.yaml b/docs/subsystems/persistence.i18n.yaml index aaf59a0f16..054615f66d 100644 --- a/docs/subsystems/persistence.i18n.yaml +++ b/docs/subsystems/persistence.i18n.yaml @@ -14,9 +14,9 @@ /session-persistence/crash-recovery-preserves-an-interrupted-turn: en: 534044a0ce482d52 zh: d2e8a7dfeb066946 -/session-persistence/sessionlocation-refusal-diagnostics-artifact-target: - en: 886589f052a916d7 - zh: 2ca126aa700c933c +/session-persistence/sessionlocation-per-session-artifact-metadata: + en: e310ff426d848262 + zh: 8a7667349b0549a7 /session-persistence/sessionheader-metadata-beside-the-log: en: 5795ae03d2d163cd zh: a46ceeb22cfbb6a1 diff --git a/docs/subsystems/persistence.md b/docs/subsystems/persistence.md index 3c0c2586a7..8f35e17f05 100644 --- a/docs/subsystems/persistence.md +++ b/docs/subsystems/persistence.md @@ -113,16 +113,16 @@ Repair therefore writes only under write ownership: a live session's write handl Read-only observation is `open(id, 'read')`: the handle serves validated contiguous prefix slices, never a torn tail, and repeated reads on one handle never observe an older state than a prior read. There is no persistence-side prepared-Session cache: session-query owns its cold-read cache, keying one balanced cold Session per id on the `stat().revision` change token and re-reading only when the token changes. The [handle-based persistence Agent Note](../../.agents/notes/implemented/architecture/2026-08-27-handle-based-session-persistence.md) owns this lifecycle; the archived [Session preparation record](../../.agents/notes/archived/architecture/2026-08-05-session-preparation.md) documents the original publication-boundary `SessionPreparation` decision. -## `SessionLocation` — refusal-diagnostics artifact target +## `SessionLocation` — per-Session artifact metadata -`SessionLocation` is not a consumer-facing query: log access goes through a session handle's `read`. It survives only as refusal diagnostics, letting a `SessionFormatUnsupportedError` name the raw log a build refused to interpret. JSONL supplies the absolute transcript path inside its project/session directory; a backend without one artifact per session supplies nothing. +`SessionLocation` is observational metadata for diagnostics and export; log access still goes through a session handle's `read`. Materialized persistence snapshots carry the selected artifact path, and a `SessionFormatUnsupportedError` can name the raw log a build refused to interpret. JSONL supplies the absolute transcript path inside its project/session directory; a backend without one artifact per session supplies nothing. ```ts type-equiv /** - * A backend-resolved, per-session local artifact location. Carried only by - * refusal diagnostics ({@link SessionFormatUnsupportedError}) so a user can - * find the raw log a build refused to interpret; it is not a consumer-facing - * query — log access goes through a session handle's `read`. + * A backend-resolved, per-Session local artifact path used by observational + * snapshots and refusal diagnostics. It names a backend target for the Session + * and does not authorize reads; log access still goes through a session + * handle's `read`. It is not persisted Session content. */ interface SessionLocation { /** Backend-specific artifact kind, for example `jsonl`. */ @@ -319,6 +319,12 @@ interface SessionPersistenceSnapshot { readonly eventCount?: number /** Physical artifact byte size, when the backend can provide it cheaply (JSONL); otherwise absent. */ readonly sizeBytes?: number + /** + * Backend-resolved location of the selected materialized per-Session + * artifact, for diagnostics and export. Absent when the backend has no + * materialized per-Session file, including an unmaterialized pending Session. + */ + readonly location?: SessionLocation } ``` diff --git a/docs/subsystems/persistence.zh.md b/docs/subsystems/persistence.zh.md index dba0685409..c88b884fdb 100644 --- a/docs/subsystems/persistence.zh.md +++ b/docs/subsystems/persistence.zh.md @@ -113,16 +113,16 @@ interface SessionHandle extends AsyncDisposable { 只读观察即 `open(id, 'read')`:句柄提供经过验证的连续前缀切片,绝不返回撕裂尾部,且同一句柄上的重复读取绝不会观察到比先前读取更旧的状态。持久化侧不存在已准备 Session 缓存:session-query 拥有自己的冷读缓存,按 `stat().revision` 变更令牌为每个 id 缓存一个已配平的冷 Session,仅在令牌变化时重新读取。该生命周期由[基于句柄的持久化 Agent Note](../../.agents/notes/implemented/architecture/2026-08-27-handle-based-session-persistence.zh.md)定义;已归档的 [Session 准备阶段记录](../../.agents/notes/archived/architecture/2026-08-05-session-preparation.md)记载了发布边界 `SessionPreparation` 最初的决策。 -## `SessionLocation`——拒绝诊断的产物目标 +## `SessionLocation`——每一 Session 的产物元数据 -`SessionLocation` 不是面向消费者的查询:日志访问走会话句柄的 `read`。它仅作为拒绝诊断存在,使 `SessionFormatUnsupportedError` 能指出本构建拒绝解读的原始日志。JSONL 提供其项目/会话目录内 transcript(文本记录)的绝对路径;没有逐会话工件的后端则不提供。 +`SessionLocation` 用于诊断与导出的观察元数据;日志访问仍走会话句柄的 `read`。已物化的持久化快照携带选中产物路径,`SessionFormatUnsupportedError` 也能指出本构建拒绝解读的原始日志。JSONL 提供其项目/会话目录内 transcript(文本记录)的绝对路径;没有逐会话工件的后端则不提供。 ```ts type-equiv /** - * A backend-resolved, per-session local artifact location. Carried only by - * refusal diagnostics ({@link SessionFormatUnsupportedError}) so a user can - * find the raw log a build refused to interpret; it is not a consumer-facing - * query — log access goes through a session handle's `read`. + * A backend-resolved, per-Session local artifact path used by observational + * snapshots and refusal diagnostics. It names a backend target for the Session + * and does not authorize reads; log access still goes through a session + * handle's `read`. It is not persisted Session content. */ interface SessionLocation { /** Backend-specific artifact kind, for example `jsonl`. */ @@ -319,6 +319,12 @@ interface SessionPersistenceSnapshot { readonly eventCount?: number /** Physical artifact byte size, when the backend can provide it cheaply (JSONL); otherwise absent. */ readonly sizeBytes?: number + /** + * Backend-resolved location of the selected materialized per-Session + * artifact, for diagnostics and export. Absent when the backend has no + * materialized per-Session file, including an unmaterialized pending Session. + */ + readonly location?: SessionLocation } ``` diff --git a/docs/subsystems/tools.i18n.yaml b/docs/subsystems/tools.i18n.yaml index 32a5981c35..b05b723c74 100644 --- a/docs/subsystems/tools.i18n.yaml +++ b/docs/subsystems/tools.i18n.yaml @@ -14,9 +14,12 @@ /tools/toolrestriction-one-scope-s-live-filter-over-what-it-inherits: en: 8c3fc4ca26f7e314 zh: 2e27263cb25243b9 +/tools/executors-and-raw-call-batches: + en: 14522295539ea2fb + zh: 572c462f2c6b70ad /tools/execution-extensible-waterfalls-plus-monotonic-policy: - en: dcac8fa88ef3e719 - zh: aae2f4ce8852d8d8 + en: 356bf8128685d0fe + zh: a28541da29c3deed /tools/the-enforced-raw-json-schema-subset: en: 28e354bfa2f9cebb zh: be1f75abccf8afdf diff --git a/docs/subsystems/tools.md b/docs/subsystems/tools.md index 585ce1582a..bff827d584 100644 --- a/docs/subsystems/tools.md +++ b/docs/subsystems/tools.md @@ -30,7 +30,7 @@ interface ToolDefinition extends ToolSchema { /** * Run one accepted call and return only its canonical lossless-JSON value. * Async work must observe or forward `exec.signal` and settle only after its - * owned work reaches quiescence. The registry preserves caller cancellation + * owned work reaches quiescence. The executor preserves caller cancellation * through around-dispatch signal replacement and does not abandon this * promise, but it cannot hard-kill same-process code. * @param args - losslessly snapshotted, frozen model arguments. @@ -49,12 +49,12 @@ interface ToolDefinition extends ToolSchema { */ projectContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined /** - * Synchronous last-mile transform for model-facing content. The registry + * Synchronous last-mile transform for model-facing content. The executor * snapshots this callback when execution starts and invokes it exactly once * for every normalized outcome, including pipeline failures that bypass * `tools/post-execute`, immediately before lossless materialization. * Returning `undefined` preserves the content; every other result field - * remains registry-owned. The callback must be total and must not throw. + * remains executor-owned. The callback must be total and must not throw. * @param exec - immutable execution identity and arguments. * @param result - complete normalized outcome before materialization. * @returns replacement content, or `undefined` to preserve it. @@ -177,9 +177,91 @@ interface ToolRestriction { } ``` +## Executors and raw call batches + +`ctx.tools.execute()` delegates a complete raw model-call batch to the nearest scoped executor. The service selects the current provider when each batch starts; prompt assembly returns only model input. `agent` is absent for standalone service calls; `log` is absent for programmatic calls outside a model step, such as initialization hooks. An Agent without `log` still scopes tool access but produces no root call/result events. Tool-owned events may still use its Session. + +```ts type-equiv +/** Model-call schemas produced by an executor. */ +type ToolModelInput = Pick +``` + +```ts type-equiv +/** Raw model calls, with optional Agent ownership and model-step logging. */ +interface ToolBatchInput { + readonly calls: readonly ToolCallBlock[] + readonly signal: AbortSignal + /** + * Positive integer overriding the executor's concurrency limit for this batch. + * Applies to concurrency-safe root calls; program subcall limits remain separate. + * Undefined uses the executor's configured limit. + */ + readonly maxParallelToolCalls?: number + /** + * Owning Agent supplies scoped tools and execution context, including outside a turn. + * Undefined for standalone calls outside an Agent, such as a service invocation. + */ + readonly agent?: Agent + /** + * Committed model step receiving root call/result events; requires `agent`. + * Undefined when `agent` is absent or for calls outside a model response, such + * as session initialization hooks. Without `log`, root call/result events are + * omitted while `agent`, when provided, still supplies scope and execution context. + */ + readonly log?: { + readonly turn: number + readonly step: number + } +} +``` + +```ts type-equiv +/** One settled result correlated with its original model call. */ +interface ToolCallResult { + readonly callId: ToolCallId + readonly result: ToolExecutionResult +} +``` + +```ts type-equiv +/** Model-ordered outcomes and context accepted for the next step. */ +interface ToolBatchResult { + readonly results: readonly ToolCallResult[] + readonly additionalContexts: readonly UserMessage[] + readonly concluded: boolean +} +``` + +```ts type-equiv +/** A plugin owns argument parsing, dispatch, scheduling, cancellation, and logging. */ +interface ToolExecutor { + /** Guidance registered and disposed with this executor; absent means no executor-owned sections. */ + readonly sections?: readonly ToolPromptSection[] + /** + * Describe the model's callable tools. + * @param tools - currently visible capability definitions, after scoped restrictions. + * @returns schemas and names used to validate tool ordering. + */ + describe(tools: readonly ToolDefinition[]): ToolModelInput + /** + * Execute a complete batch. With log, commit root call/result events to the + * Agent's Session; without log, omit those events. Tool-owned events may still + * use the owning Agent's Session. Return + * one outcome per input call in input order, including skipped calls on abort. + * Never settle while started work is still running. Internal failures reject + * with ToolBatchExecutionError after draining, preserving committed outcomes + * and context; callers must stop dependent work and repair missing log results. + * An explicit maxParallelToolCalls takes precedence over executor configuration. + * @param input - original calls, cancellation, optional concurrency override, Agent and logging step. + * @returns settled results, deferred context, and whether a success concludes the turn. + */ + execute(input: ToolBatchInput): Promise +} +``` + ## Execution: extensible waterfalls plus monotonic policy -`ctx.tools.execute()` accepts a caller-owned `ToolExecutionInput` with a required readonly `signal`, materializes its parsed JSON arguments once into a pipeline-owned `ToolExecution`, and runs that call through `tools/pre-execute` (the reorderable allow/deny/ask waterfall) → registered monotonic guards → `tools/execute` (around-dispatch wrappers) → `projectContent` → `tools/post-execute` (inspect/replace the result) → optional definition-owned `finalizeContent` → `tools/result` (the immutable authoritative outcome). Only the `tools/execute` view may replace the required signal. The outcome is a `ToolExecutionResult`. +Built-in executors pass parsed `ToolExecutionInput` values to the shared `ToolExecutionPipeline`. The pipeline owns argument snapshots, execution identities, policy waterfalls, dispatch, and final-result notification, returning one `ToolExecutionResult` per invocation. Custom executors can implement their own execution; the public `ToolExecutor` requires only descriptions and batch execution. ```ts type-equiv /** Opaque call identity that permits correlation without exposing mutable execution state. */ @@ -188,8 +270,8 @@ type ToolExecutionToken = symbol & { readonly [toolExecutionTokenBrand]: true } ```ts type-equiv /** - * Caller-supplied description of one tool call. {@link ToolRuntime.execute} - * adds the registry-owned token to form a pipeline {@link ToolExecution}; + * Parsed input to the shared invocation pipeline. The pipeline + * adds its own token to form a {@link ToolExecution}; * callers do not choose that token. */ interface ToolExecutionInput { @@ -211,9 +293,9 @@ interface ToolExecutionInput { * mode sets this on SDK sub-dispatches so commit-style observers can wait for * the outer `run_code` outcome without receiving its live mutable execution. * The token also marks the call as a transport sub-dispatch rather than a - * model-direct call: under `mode: 'ptc'`, only calls WITH a parent may + * model-direct call: with the PTC executor selected, only calls with a parent may * execute a native tool name — a model-direct call (no parent) is denied as - * `UNKNOWN_TOOL` before the policy pipeline. See {@link ToolRuntime.execute}. + * `UNKNOWN_TOOL` before the policy pipeline. */ readonly parent?: ToolExecutionToken /** Required caller-owned cancellation for this invocation. */ @@ -225,7 +307,7 @@ A tool body receives the runtime extension. `deferContext()` attaches context to ```ts type-equiv /** - * Runtime context handed to a tool implementation after the registry has + * Runtime context handed to a tool implementation after the executor has * accepted a {@link ToolExecution}. {@link deferContext} attaches context to * this execution's own result — a composite tool ferries nested-dispatch * context back to the outer result, and a leaf tool may mint a fresh @@ -294,16 +376,16 @@ interface PtcDispatchLog { ```ts type-equiv /** - * One pending tool call inside the registry pipeline. Parsed arguments cross + * One pending tool call inside the executor pipeline. Parsed arguments cross * one lossless-JSON materialization boundary before policy and are deep-frozen; - * call identity, the caller signal, and the registry-assigned {@link token} are - * readonly. The registry freezes the complete object before `tools/result` + * call identity, the caller signal, and the executor-assigned {@link token} are + * readonly. The executor freezes the complete object before `tools/result` * observers run. */ interface ToolExecution extends ToolExecutionInput { /** Root model-requested call, resolved for every root and nested execution. */ readonly rootCallId: ToolCallId - /** Registry-assigned identity shared with nested calls only as their opaque `parent` token. */ + /** Executor-assigned identity shared with nested calls only as their opaque `parent` token. */ readonly token: ToolExecutionToken } ``` @@ -312,7 +394,7 @@ interface ToolExecution extends ToolExecutionInput { /** * Around-dispatch view of a {@link ToolExecution}. A `tools/execute` wrapper * may replace the signal for its delegated lifetime, but it cannot remove it. - * The registry fuses every replacement with the captured caller signal. + * The executor fuses every replacement with the captured caller signal. */ interface ToolDispatchExecution extends Omit { /** Cancellation signal visible to the next wrapper or tool body. */ @@ -501,26 +583,13 @@ The full presentation field docs live in [`packages/core/tools/src/presentation. Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md). - + -### `ctx.tools` — `ToolRuntime` +### `ctx.tools` — `ToolsService` -Tool registry and execution pipeline. Scoped registrations shadow globals; one visibility resolver feeds presentation, lookup, and dispatch. +Concrete tool registry and scoped executor dispatcher. ```ts cordis-catalog -/** - * Present the calling scope's tools in `mode` instead of the deployment - * default. Nearest scope on the chain wins, so a preset's standing - * declaration covers every agent joined under it. - * - * Scoped only, and one declaration per scope: this is how an agent preset - * composes PTC mode agents beside native ones in the same process, and a - * process-global override would be the `mode` config field instead. - * @param mode - the presentation the covered agents' models see. - * @returns the exact disposer that restores the deployment default. - */ -presentAs(mode: ToolPresentationMode): () => void - /** * Register globally or in the calling agent scope. Scoped tools shadow * globals; duplicates within one layer and the reserved `run_code` name fail. @@ -538,18 +607,6 @@ register(definition: ToolDefinition): () => void */ restrict(filter: ToolRestriction): () => void -/** - * Register a monotonic guard after the extensible `tools/pre-execute` - * waterfall. A plain-context guard applies globally; one registered through - * `agent.ctx` applies only to that agent. Any matching guard may deny by - * returning a reason, while no guard can force-allow a call another guard - * denied. The exact effect disposer is returned for ordered ownership and - * HMR cleanup. - * @param guard - synchronous check; a returned string denies the execution. - * @returns the exact disposer that unregisters the guard. - */ -guard(guard: ToolGuard): () => void - /** * Look up a tool as one scope sees it (scoped * shadows global; a restricted-away global reads as absent). Presenters pass @@ -561,6 +618,13 @@ guard(guard: ToolGuard): () => void */ get(name: string, scope?: ScopeKey): ToolDefinition | undefined +/** + * Read the visible capability definitions for descriptions and program bindings. + * @param scope - viewing Agent or preset; omitted selects the global view. + * @returns definitions after scoped shadowing and restrictions. + */ +definitions(scope?: ScopeKey): readonly ToolDefinition[] + /** * Project visible definitions onto the allowlisted model-facing schema fields, * excluding execution and presentation callbacks. @@ -570,29 +634,34 @@ get(name: string, scope?: ScopeKey): ToolDefinition | undefined schemas(scope?: ScopeKey): ToolSchema[] /** - * Classify a pending call through the caller's visible tool definition. Only - * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or - * throwing classifiers are exclusive. - * @param exec - call name, parsed arguments, and optional agent scope. - * @returns the fail-closed scheduling mode. + * Provide the executor for the calling scope; an unscoped contribution is the deployment default. + * Rendering any executor-owned section requires the viewing scope's selection to remain available. + * @param executor - initialized provider owned by the contributing plugin. + * @returns the effect disposer. Unloading leaves the selection unavailable until it is replaced. */ -executionMode(exec: ToolExecutionInput): ToolExecutionMode +setExecutor(executor: ToolExecutor): () => void /** - * Execute through pre-policy, guards, around-dispatch, post-policy, - * definition-owned content finalization, and final notification. Tool and - * listener failures resolve as materialized error results; an invisible tool - * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen - * snapshot final observers receive. Cancellation - * arriving after entry and before final result materialization skips a - * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a - * successful started outcome with `ABORTED`; already-started work is still - * drained and may retain a tool-owned structured error. - * @param exec - the typed same-process call input. The registry assigns its - * correlation token before policy begins. - * @returns the materialized final result. + * Delegate a raw call batch to its Agent's provider or the deployment default. + * Select at dispatch; accepted batches settle through that provider even if it unloads. + * @param input - original calls, optional owning Agent, and optional logging step. + * @returns the selected executor's settled batch; unavailable selections reject. */ -async execute(exec: ToolExecutionInput): Promise +async execute(input: ToolBatchInput): Promise + +/** + * Register a monotonic denial in the calling scope. + * @param guard - evaluated after extensible pre-execute policy by the shared pipeline. + * @returns the effect disposer that removes this guard. + */ +guard(guard: ToolGuard): () => void + +/** + * Check global and inherited guards after pre-execute policy and approval resolve. + * @param exec - invocation whose guards are checked before dispatch. + * @returns the first denial reason, or undefined when no guard denies. + */ +checkGuards(exec: ToolExecution): string | undefined ``` Types: [ScopeKey](scope.md) @@ -607,22 +676,19 @@ Source: [`packages/core/tools/src/index.ts`](../../packages/core/tools/src/index #### `tools/change` — emit -A tool was registered or unregistered, or a scoped restriction changed (the available tool set changed — possibly for one scope only). An UNFILTERED registry-subject notification, deliberately not scope-filtered dispatch: a global change concerns every agent's next assembly, so a scoped listener subscribing here sees every change, not just its own scope's. +A tool was registered or unregistered, or a scoped restriction changed. Unfiltered registry notification: scoped listeners receive changes from every scope because a global contribution can affect every agent's next assembly. ```ts cordis-catalog /** - * A tool was registered or unregistered, or a scoped restriction changed - * (the available tool set changed — possibly for one scope only). An - * UNFILTERED registry-subject notification, deliberately not scope-filtered - * dispatch: a global change concerns every agent's next assembly, so a - * scoped listener subscribing here sees every change, not just its own - * scope's. + * A tool was registered or unregistered, or a scoped restriction changed. + * Unfiltered registry notification: scoped listeners receive changes from every + * scope because a global contribution can affect every agent's next assembly. * @mode emit */ 'tools/change'(): void ``` -Source: [`packages/core/tools/src/index.ts`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/events.ts`](../../packages/core/tools/src/events.ts) @@ -641,12 +707,12 @@ Around-dispatch waterfall for timeout, retry, or metrics. `next()` returns a nor * @param exec - the allowed call about to dispatch (name, parsed arguments, caller agent, signal). * @mode waterfall */ -'tools/execute'(this: Scoped, exec: ToolDispatchExecution, next: () => Promise): Promise +'tools/execute'(this: Scoped, exec: ToolDispatchExecution, next: () => Promise): Promise ``` Types: [Scoped](scope.md) -Source: [`packages/core/tools/src/index.ts`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/events.ts`](../../packages/core/tools/src/events.ts) @@ -666,12 +732,12 @@ Accept, replace, enrich, or block a normalized dispatch result. `next()` accepts * @param result - the dispatch outcome a listener may accept, replace, or block. * @mode waterfall */ -'tools/post-execute'(this: Scoped, exec: ToolExecution, result: Readonly, next: () => Promise): Promise +'tools/post-execute'(this: Scoped, exec: ToolExecution, result: Readonly, next: () => Promise): Promise ``` Types: [Scoped](scope.md) -Source: [`packages/core/tools/src/index.ts`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/events.ts`](../../packages/core/tools/src/events.ts) @@ -690,12 +756,12 @@ Allow, deny, cancel, or ask before dispatch. `next()` delegates to allow; `cance * @param exec - the pending call (name, parsed arguments, caller agent). * @mode waterfall */ -'tools/pre-execute'(this: Scoped, exec: ToolExecution, next: () => Promise): Promise +'tools/pre-execute'(this: Scoped, exec: ToolExecution, next: () => Promise): Promise ``` Types: [Scoped](scope.md) -Source: [`packages/core/tools/src/index.ts`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/events.ts`](../../packages/core/tools/src/events.ts) @@ -717,12 +783,12 @@ Allow a listener to replace content in the DURABLE LOG COPY of one `run_code` su * @param dispatch - the parent execution, sub-call identity, and the settled content to log. * @mode waterfall */ -'tools/ptc-dispatch-log'(this: Scoped, dispatch: PtcDispatchLog, next: () => Promise): Promise +'tools/ptc-dispatch-log'(this: Scoped, dispatch: PtcDispatchLog, next: () => Promise): Promise ``` Types: [ContentBlock](llm-streaming.md) · [Scoped](scope.md) -Source: [`packages/core/tools/src/index.ts`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tool-executor-ptc/src/runtime-types.ts`](../../packages/core/tool-executor-ptc/src/runtime-types.ts) @@ -738,10 +804,10 @@ Observe the frozen, lossless-JSON final outcome. Listener failures are contained * @param result - a deep-frozen snapshot of the final returned result. * @mode emit */ -'tools/result'(this: Scoped, exec: Readonly, result: Readonly): undefined +'tools/result'(this: Scoped, exec: Readonly, result: Readonly): undefined ``` Types: [Scoped](scope.md) -Source: [`packages/core/tools/src/index.ts`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/events.ts`](../../packages/core/tools/src/events.ts) diff --git a/docs/subsystems/tools.zh.md b/docs/subsystems/tools.zh.md index 4ee27fcb4d..53dbd43fb7 100644 --- a/docs/subsystems/tools.zh.md +++ b/docs/subsystems/tools.zh.md @@ -30,7 +30,7 @@ interface ToolDefinition extends ToolSchema { /** * Run one accepted call and return only its canonical lossless-JSON value. * Async work must observe or forward `exec.signal` and settle only after its - * owned work reaches quiescence. The registry preserves caller cancellation + * owned work reaches quiescence. The executor preserves caller cancellation * through around-dispatch signal replacement and does not abandon this * promise, but it cannot hard-kill same-process code. * @param args - losslessly snapshotted, frozen model arguments. @@ -49,12 +49,12 @@ interface ToolDefinition extends ToolSchema { */ projectContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined /** - * Synchronous last-mile transform for model-facing content. The registry + * Synchronous last-mile transform for model-facing content. The executor * snapshots this callback when execution starts and invokes it exactly once * for every normalized outcome, including pipeline failures that bypass * `tools/post-execute`, immediately before lossless materialization. * Returning `undefined` preserves the content; every other result field - * remains registry-owned. The callback must be total and must not throw. + * remains executor-owned. The callback must be total and must not throw. * @param exec - immutable execution identity and arguments. * @param result - complete normalized outcome before materialization. * @returns replacement content, or `undefined` to preserve it. @@ -177,9 +177,91 @@ interface ToolRestriction { } ``` +## 执行器与原始调用批次 + +`ctx.tools.execute()` 把完整的原始模型调用批次委托给最近作用域的执行器。服务在每个批次开始时选择当前提供者;提示词组装仅返回模型输入。`agent` 在独立服务调用中不存在;`log` 在模型步骤之外的程序调用(例如初始化钩子)中不存在。有 Agent 而没有 `log` 时,调用仍受该 Agent 的工具作用域约束,但不记录根调用/结果事件。工具自身的事件仍可使用 Agent 的 Session。 + +```ts type-equiv +/** Model-call schemas produced by an executor. */ +type ToolModelInput = Pick +``` + +```ts type-equiv +/** Raw model calls, with optional Agent ownership and model-step logging. */ +interface ToolBatchInput { + readonly calls: readonly ToolCallBlock[] + readonly signal: AbortSignal + /** + * Positive integer overriding the executor's concurrency limit for this batch. + * Applies to concurrency-safe root calls; program subcall limits remain separate. + * Undefined uses the executor's configured limit. + */ + readonly maxParallelToolCalls?: number + /** + * Owning Agent supplies scoped tools and execution context, including outside a turn. + * Undefined for standalone calls outside an Agent, such as a service invocation. + */ + readonly agent?: Agent + /** + * Committed model step receiving root call/result events; requires `agent`. + * Undefined when `agent` is absent or for calls outside a model response, such + * as session initialization hooks. Without `log`, root call/result events are + * omitted while `agent`, when provided, still supplies scope and execution context. + */ + readonly log?: { + readonly turn: number + readonly step: number + } +} +``` + +```ts type-equiv +/** One settled result correlated with its original model call. */ +interface ToolCallResult { + readonly callId: ToolCallId + readonly result: ToolExecutionResult +} +``` + +```ts type-equiv +/** Model-ordered outcomes and context accepted for the next step. */ +interface ToolBatchResult { + readonly results: readonly ToolCallResult[] + readonly additionalContexts: readonly UserMessage[] + readonly concluded: boolean +} +``` + +```ts type-equiv +/** A plugin owns argument parsing, dispatch, scheduling, cancellation, and logging. */ +interface ToolExecutor { + /** Guidance registered and disposed with this executor; absent means no executor-owned sections. */ + readonly sections?: readonly ToolPromptSection[] + /** + * Describe the model's callable tools. + * @param tools - currently visible capability definitions, after scoped restrictions. + * @returns schemas and names used to validate tool ordering. + */ + describe(tools: readonly ToolDefinition[]): ToolModelInput + /** + * Execute a complete batch. With log, commit root call/result events to the + * Agent's Session; without log, omit those events. Tool-owned events may still + * use the owning Agent's Session. Return + * one outcome per input call in input order, including skipped calls on abort. + * Never settle while started work is still running. Internal failures reject + * with ToolBatchExecutionError after draining, preserving committed outcomes + * and context; callers must stop dependent work and repair missing log results. + * An explicit maxParallelToolCalls takes precedence over executor configuration. + * @param input - original calls, cancellation, optional concurrency override, Agent and logging step. + * @returns settled results, deferred context, and whether a success concludes the turn. + */ + execute(input: ToolBatchInput): Promise +} +``` + ## 执行:可扩展的 waterfall(瀑布式事件)加单调策略 -`ctx.tools.execute()` 接受由调用方拥有且包含必需 readonly `signal` 的 `ToolExecutionInput`,将其解析后的 JSON 参数一次性物化为流水线拥有的 `ToolExecution`,然后让调用依次经过 `tools/pre-execute`(可重排的 allow/deny/ask waterfall)→ 已注册的单调 guard → `tools/execute`(环绕分派包装层)→ `projectContent` → `tools/post-execute`(检查/替换结果)→ 可选且由定义拥有的 `finalizeContent` → `tools/result`(不可变的权威结果)。只有 `tools/execute` 视图可以替换必需的 signal。最终产出为 `ToolExecutionResult`。 +内置执行器把解析后的 `ToolExecutionInput` 交给共享 `ToolExecutionPipeline`。流水线负责参数快照、执行身份、策略瀑布、工具分发和最终结果通知,每次返回一个 `ToolExecutionResult`。自定义执行器可以独立实现这些行为;公开的 `ToolExecutor` 只要求工具描述和批次执行方法。 ```ts type-equiv /** Opaque call identity that permits correlation without exposing mutable execution state. */ @@ -188,8 +270,8 @@ type ToolExecutionToken = symbol & { readonly [toolExecutionTokenBrand]: true } ```ts type-equiv /** - * Caller-supplied description of one tool call. {@link ToolRuntime.execute} - * adds the registry-owned token to form a pipeline {@link ToolExecution}; + * Parsed input to the shared invocation pipeline. The pipeline + * adds its own token to form a {@link ToolExecution}; * callers do not choose that token. */ interface ToolExecutionInput { @@ -211,9 +293,9 @@ interface ToolExecutionInput { * mode sets this on SDK sub-dispatches so commit-style observers can wait for * the outer `run_code` outcome without receiving its live mutable execution. * The token also marks the call as a transport sub-dispatch rather than a - * model-direct call: under `mode: 'ptc'`, only calls WITH a parent may + * model-direct call: with the PTC executor selected, only calls with a parent may * execute a native tool name — a model-direct call (no parent) is denied as - * `UNKNOWN_TOOL` before the policy pipeline. See {@link ToolRuntime.execute}. + * `UNKNOWN_TOOL` before the policy pipeline. */ readonly parent?: ToolExecutionToken /** Required caller-owned cancellation for this invocation. */ @@ -225,7 +307,7 @@ interface ToolExecutionInput { ```ts type-equiv /** - * Runtime context handed to a tool implementation after the registry has + * Runtime context handed to a tool implementation after the executor has * accepted a {@link ToolExecution}. {@link deferContext} attaches context to * this execution's own result — a composite tool ferries nested-dispatch * context back to the outer result, and a leaf tool may mint a fresh @@ -294,16 +376,16 @@ interface PtcDispatchLog { ```ts type-equiv /** - * One pending tool call inside the registry pipeline. Parsed arguments cross + * One pending tool call inside the executor pipeline. Parsed arguments cross * one lossless-JSON materialization boundary before policy and are deep-frozen; - * call identity, the caller signal, and the registry-assigned {@link token} are - * readonly. The registry freezes the complete object before `tools/result` + * call identity, the caller signal, and the executor-assigned {@link token} are + * readonly. The executor freezes the complete object before `tools/result` * observers run. */ interface ToolExecution extends ToolExecutionInput { /** Root model-requested call, resolved for every root and nested execution. */ readonly rootCallId: ToolCallId - /** Registry-assigned identity shared with nested calls only as their opaque `parent` token. */ + /** Executor-assigned identity shared with nested calls only as their opaque `parent` token. */ readonly token: ToolExecutionToken } ``` @@ -312,7 +394,7 @@ interface ToolExecution extends ToolExecutionInput { /** * Around-dispatch view of a {@link ToolExecution}. A `tools/execute` wrapper * may replace the signal for its delegated lifetime, but it cannot remove it. - * The registry fuses every replacement with the captured caller signal. + * The executor fuses every replacement with the captured caller signal. */ interface ToolDispatchExecution extends Omit { /** Cancellation signal visible to the next wrapper or tool body. */ @@ -501,26 +583,13 @@ type ObjectJsonSchema = JsonSchemaNode & { type: 'object' } Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.zh.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md). - + -### `ctx.tools` — `ToolRuntime` +### `ctx.tools` — `ToolsService` -Tool registry and execution pipeline. Scoped registrations shadow globals; one visibility resolver feeds presentation, lookup, and dispatch. +Concrete tool registry and scoped executor dispatcher. ```ts cordis-catalog -/** - * Present the calling scope's tools in `mode` instead of the deployment - * default. Nearest scope on the chain wins, so a preset's standing - * declaration covers every agent joined under it. - * - * Scoped only, and one declaration per scope: this is how an agent preset - * composes PTC mode agents beside native ones in the same process, and a - * process-global override would be the `mode` config field instead. - * @param mode - the presentation the covered agents' models see. - * @returns the exact disposer that restores the deployment default. - */ -presentAs(mode: ToolPresentationMode): () => void - /** * Register globally or in the calling agent scope. Scoped tools shadow * globals; duplicates within one layer and the reserved `run_code` name fail. @@ -538,18 +607,6 @@ register(definition: ToolDefinition): () => void */ restrict(filter: ToolRestriction): () => void -/** - * Register a monotonic guard after the extensible `tools/pre-execute` - * waterfall. A plain-context guard applies globally; one registered through - * `agent.ctx` applies only to that agent. Any matching guard may deny by - * returning a reason, while no guard can force-allow a call another guard - * denied. The exact effect disposer is returned for ordered ownership and - * HMR cleanup. - * @param guard - synchronous check; a returned string denies the execution. - * @returns the exact disposer that unregisters the guard. - */ -guard(guard: ToolGuard): () => void - /** * Look up a tool as one scope sees it (scoped * shadows global; a restricted-away global reads as absent). Presenters pass @@ -561,6 +618,13 @@ guard(guard: ToolGuard): () => void */ get(name: string, scope?: ScopeKey): ToolDefinition | undefined +/** + * Read the visible capability definitions for descriptions and program bindings. + * @param scope - viewing Agent or preset; omitted selects the global view. + * @returns definitions after scoped shadowing and restrictions. + */ +definitions(scope?: ScopeKey): readonly ToolDefinition[] + /** * Project visible definitions onto the allowlisted model-facing schema fields, * excluding execution and presentation callbacks. @@ -570,29 +634,34 @@ get(name: string, scope?: ScopeKey): ToolDefinition | undefined schemas(scope?: ScopeKey): ToolSchema[] /** - * Classify a pending call through the caller's visible tool definition. Only - * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or - * throwing classifiers are exclusive. - * @param exec - call name, parsed arguments, and optional agent scope. - * @returns the fail-closed scheduling mode. + * Provide the executor for the calling scope; an unscoped contribution is the deployment default. + * Rendering any executor-owned section requires the viewing scope's selection to remain available. + * @param executor - initialized provider owned by the contributing plugin. + * @returns the effect disposer. Unloading leaves the selection unavailable until it is replaced. */ -executionMode(exec: ToolExecutionInput): ToolExecutionMode +setExecutor(executor: ToolExecutor): () => void /** - * Execute through pre-policy, guards, around-dispatch, post-policy, - * definition-owned content finalization, and final notification. Tool and - * listener failures resolve as materialized error results; an invisible tool - * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen - * snapshot final observers receive. Cancellation - * arriving after entry and before final result materialization skips a - * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a - * successful started outcome with `ABORTED`; already-started work is still - * drained and may retain a tool-owned structured error. - * @param exec - the typed same-process call input. The registry assigns its - * correlation token before policy begins. - * @returns the materialized final result. + * Delegate a raw call batch to its Agent's provider or the deployment default. + * Select at dispatch; accepted batches settle through that provider even if it unloads. + * @param input - original calls, optional owning Agent, and optional logging step. + * @returns the selected executor's settled batch; unavailable selections reject. */ -async execute(exec: ToolExecutionInput): Promise +async execute(input: ToolBatchInput): Promise + +/** + * Register a monotonic denial in the calling scope. + * @param guard - evaluated after extensible pre-execute policy by the shared pipeline. + * @returns the effect disposer that removes this guard. + */ +guard(guard: ToolGuard): () => void + +/** + * Check global and inherited guards after pre-execute policy and approval resolve. + * @param exec - invocation whose guards are checked before dispatch. + * @returns the first denial reason, or undefined when no guard denies. + */ +checkGuards(exec: ToolExecution): string | undefined ``` Types: [ScopeKey](scope.zh.md) @@ -607,22 +676,19 @@ Source: [`packages/core/tools/src/index.ts`](../../packages/core/tools/src/index #### `tools/change` — emit -A tool was registered or unregistered, or a scoped restriction changed (the available tool set changed — possibly for one scope only). An UNFILTERED registry-subject notification, deliberately not scope-filtered dispatch: a global change concerns every agent's next assembly, so a scoped listener subscribing here sees every change, not just its own scope's. +A tool was registered or unregistered, or a scoped restriction changed. Unfiltered registry notification: scoped listeners receive changes from every scope because a global contribution can affect every agent's next assembly. ```ts cordis-catalog /** - * A tool was registered or unregistered, or a scoped restriction changed - * (the available tool set changed — possibly for one scope only). An - * UNFILTERED registry-subject notification, deliberately not scope-filtered - * dispatch: a global change concerns every agent's next assembly, so a - * scoped listener subscribing here sees every change, not just its own - * scope's. + * A tool was registered or unregistered, or a scoped restriction changed. + * Unfiltered registry notification: scoped listeners receive changes from every + * scope because a global contribution can affect every agent's next assembly. * @mode emit */ 'tools/change'(): void ``` -Source: [`packages/core/tools/src/index.ts`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/events.ts`](../../packages/core/tools/src/events.ts) @@ -641,12 +707,12 @@ Around-dispatch waterfall for timeout, retry, or metrics. `next()` returns a nor * @param exec - the allowed call about to dispatch (name, parsed arguments, caller agent, signal). * @mode waterfall */ -'tools/execute'(this: Scoped, exec: ToolDispatchExecution, next: () => Promise): Promise +'tools/execute'(this: Scoped, exec: ToolDispatchExecution, next: () => Promise): Promise ``` Types: [Scoped](scope.zh.md) -Source: [`packages/core/tools/src/index.ts`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/events.ts`](../../packages/core/tools/src/events.ts) @@ -666,12 +732,12 @@ Accept, replace, enrich, or block a normalized dispatch result. `next()` accepts * @param result - the dispatch outcome a listener may accept, replace, or block. * @mode waterfall */ -'tools/post-execute'(this: Scoped, exec: ToolExecution, result: Readonly, next: () => Promise): Promise +'tools/post-execute'(this: Scoped, exec: ToolExecution, result: Readonly, next: () => Promise): Promise ``` Types: [Scoped](scope.zh.md) -Source: [`packages/core/tools/src/index.ts`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/events.ts`](../../packages/core/tools/src/events.ts) @@ -690,12 +756,12 @@ Allow, deny, cancel, or ask before dispatch. `next()` delegates to allow; `cance * @param exec - the pending call (name, parsed arguments, caller agent). * @mode waterfall */ -'tools/pre-execute'(this: Scoped, exec: ToolExecution, next: () => Promise): Promise +'tools/pre-execute'(this: Scoped, exec: ToolExecution, next: () => Promise): Promise ``` Types: [Scoped](scope.zh.md) -Source: [`packages/core/tools/src/index.ts`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/events.ts`](../../packages/core/tools/src/events.ts) @@ -717,12 +783,12 @@ Allow a listener to replace content in the DURABLE LOG COPY of one `run_code` su * @param dispatch - the parent execution, sub-call identity, and the settled content to log. * @mode waterfall */ -'tools/ptc-dispatch-log'(this: Scoped, dispatch: PtcDispatchLog, next: () => Promise): Promise +'tools/ptc-dispatch-log'(this: Scoped, dispatch: PtcDispatchLog, next: () => Promise): Promise ``` Types: [ContentBlock](llm-streaming.zh.md) · [Scoped](scope.zh.md) -Source: [`packages/core/tools/src/index.ts`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tool-executor-ptc/src/runtime-types.ts`](../../packages/core/tool-executor-ptc/src/runtime-types.ts) @@ -738,10 +804,10 @@ Observe the frozen, lossless-JSON final outcome. Listener failures are contained * @param result - a deep-frozen snapshot of the final returned result. * @mode emit */ -'tools/result'(this: Scoped, exec: Readonly, result: Readonly): undefined +'tools/result'(this: Scoped, exec: Readonly, result: Readonly): undefined ``` Types: [Scoped](scope.zh.md) -Source: [`packages/core/tools/src/index.ts`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/events.ts`](../../packages/core/tools/src/events.ts) diff --git a/docs/subsystems/typert.md b/docs/subsystems/typert.md index 29b5d4e3b7..d68d4a1a36 100644 --- a/docs/subsystems/typert.md +++ b/docs/subsystems/typert.md @@ -561,10 +561,9 @@ decide(request: RemoteAccessRequest): RemoteAccessDecision allows(resource: RemoteAccessResource): boolean /** - * The baseline a page opens with before its snapshot stream connects. A - * business package rendering a non-owner page assigns this value to - * {@link REMOTE_ACCESS_SNAPSHOT_KEY} after every index injection row, - * replacing the owner baseline the webServer index carries. + * The baseline a page opens with before its snapshot stream connects; every + * index the webServer renders carries it under + * {@link REMOTE_ACCESS_SNAPSHOT_KEY} for the Peer the page speaks for. * @param principal - the page's caller identity. * @returns the owner baseline for the owner; otherwise the principal's current grants. */ diff --git a/docs/subsystems/typert.zh.md b/docs/subsystems/typert.zh.md index ce33a70436..270e5509bd 100644 --- a/docs/subsystems/typert.zh.md +++ b/docs/subsystems/typert.zh.md @@ -561,10 +561,9 @@ decide(request: RemoteAccessRequest): RemoteAccessDecision allows(resource: RemoteAccessResource): boolean /** - * The baseline a page opens with before its snapshot stream connects. A - * business package rendering a non-owner page assigns this value to - * {@link REMOTE_ACCESS_SNAPSHOT_KEY} after every index injection row, - * replacing the owner baseline the webServer index carries. + * The baseline a page opens with before its snapshot stream connects; every + * index the webServer renders carries it under + * {@link REMOTE_ACCESS_SNAPSHOT_KEY} for the Peer the page speaks for. * @param principal - the page's caller identity. * @returns the owner baseline for the owner; otherwise the principal's current grants. */ diff --git a/docs/subsystems/web-server.i18n.yaml b/docs/subsystems/web-server.i18n.yaml index 9a9d78794a..bd4c01c442 100644 --- a/docs/subsystems/web-server.i18n.yaml +++ b/docs/subsystems/web-server.i18n.yaml @@ -3,8 +3,8 @@ # After editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/web-server.md /http-server: - en: c9b2f67d3248c496 - zh: 5fa36d2837ff4f63 + en: 56b268a69964f651 + zh: c8ac8f16bf58a98a /http-server/routes: en: e949c3738a35b58f zh: 138e3aaeb319ddc4 @@ -12,5 +12,5 @@ en: 4b162bd9f280eb06 zh: 3bea98dfee88c582 /http-server/the-service: - en: cc36972b8458279f - zh: c129044935d44769 + en: bbf84a6cd0460dc2 + zh: cc63ce45b0675c6d diff --git a/docs/subsystems/web-server.md b/docs/subsystems/web-server.md index c85f7a9b08..c6c731343a 100644 --- a/docs/subsystems/web-server.md +++ b/docs/subsystems/web-server.md @@ -2,7 +2,7 @@ English | [中文](web-server.zh.md) -[dsh-host-webserver](../../packages/host/webserver) is the browser HTTP carrier for the GUI host: one `node:http` server — `node:https` when `tls` is configured — providing `ctx.webServer`, a named-route registry, optional gzip response compression, index.html transform callbacks, and one fallback handler that a plugin may claim. It is not part of the agent loop and not a capability seam; it knows no harness concepts, and another plugin registers every feature route, including the `/api` bridge, plugin bundles, and the HMR event stream ([layering reference](../../packages/boot/app-boot/README.md)). It serves browsers only: Electron loads the built files over `file://` and sends fetch requests through an IPC bridge instead of this server. +[dsh-host-webserver](../../packages/host/webserver) is the browser HTTP carrier for the GUI host: one `node:http` server — `node:https` when `tls` is configured — providing `ctx.webServer`, a named-route registry, optional gzip response compression, index.html transform callbacks, and one fallback handler that a plugin may claim. It is not part of the agent loop and not a capability seam; its only harness concept is the Peer an index render speaks for, and another plugin registers every feature route, including the `/api` bridge, plugin bundles, and the HMR event stream ([layering reference](../../packages/boot/app-boot/README.md)). It serves browsers only: Electron loads the built files over `file://` and sends fetch requests through an IPC bridge instead of this server. Source: [`packages/host/webserver/src/index.ts`](../../packages/host/webserver/src/index.ts) @@ -81,7 +81,7 @@ No composition can bind every interface at once: the unspecified address is reje ## The service -`WebServer` (`ctx.webServer`) listens immediately on activation; a listen failure (EADDRINUSE…) rejects initialization, and the boot process reports the failed fiber. The `tls` pair is read once before the listener binds, and an unreadable, empty, or mismatched pair rejects initialization the same way instead of falling back to HTTP. `register(route)` adds one named route and returns its disposer; a duplicate `(kind, path)` throws because route patterns are a composition-level contract and a collision is a misconfiguration. Gzip wraps eligible socket-backed responses inside the server, so route handlers retain direct `ServerResponse` ownership and no response-writing API is added to the service. Existing content encodings, `Cache-Control: no-transform`, ranges, SSE, ZIP, and the packaged `.gz` Worker image remain identity responses. `collectIndexInjections()` gathers structured `IndexInjection` rows over one `webserver/index-inject` emit, and `renderIndex(html)` renders them into successful root and configured index responses before applying the raw `tapIndex(transform)` escape-hatch transforms in registration order; [dsh-client-modules](../../packages/client/modules) answers the event with the boot manifest rows. `port` reads the listening port, including the port assigned by the OS when `config.port` is 0, and `protocol` reports the scheme the listener serves (`http:` or `https:`). +`WebServer` (`ctx.webServer`) listens immediately on activation; a listen failure (EADDRINUSE…) rejects initialization, and the boot process reports the failed fiber. The `tls` pair is read once before the listener binds, and an unreadable, empty, or mismatched pair rejects initialization the same way instead of falling back to HTTP. `register(route)` adds one named route and returns its disposer; a duplicate `(kind, path)` throws because route patterns are a composition-level contract and a collision is a misconfiguration. Gzip wraps eligible socket-backed responses inside the server, so route handlers retain direct `ServerResponse` ownership and no response-writing API is added to the service. Existing content encodings, `Cache-Control: no-transform`, ranges, SSE, ZIP, and the packaged `.gz` Worker image remain identity responses. `collectIndexInjections(peer)` gathers structured `IndexInjection` rows over one `webserver/index-inject` emit that passes the Peer the page speaks for, and `renderIndex(html, peer)` renders them into successful root and configured index responses before applying the raw `tapIndex(transform)` escape-hatch transforms in registration order; [dsh-client-modules](../../packages/client/modules) answers the event with the boot manifest rows. `port` reads the listening port, including the port assigned by the OS when `config.port` is 0, and `protocol` reports the scheme the listener serves (`http:` or `https:`). A request whose handling throws (a malformed %-escape hitting `decodeURIComponent`, a client dropping mid-body) is logged as a warning and answered 400 — or the socket destroyed when headers are already out — never a process exit. Disposal pairs `close()` with `closeAllConnections()` because a handler may hold its response open (SSE) and such connections never end on their own; without the force-close, teardown would hang. A TLS socket counts as a connection only after its handshake, so the server tracks raw TLS sockets beside upgraded ones and destroys both at disposal. The package never prints: the URL line belongs to the shell. Per-package operational detail, including the dev-mode bundle watch pipeline, stays in the [README](../../packages/host/webserver/README.md). @@ -235,20 +235,23 @@ tapIndex(transform: (html: string) => string): () => void applyIndexTaps(html: string): string /** - * Gather the structured injection table: one `webserver/index-inject` emit, - * every subscriber pushes its current rows. Fresh per call, so subscribers - * read live state (module graph, theme preference) at emit time. + * Gather the structured injection table for one Peer: one + * `webserver/index-inject` emit, every subscriber pushes its current rows. + * Fresh per call, so subscribers read live state (module graph, theme + * preference, the Peer's access) at emit time. + * @param peer - the Peer the page speaks for. * @returns rows in subscriber activation order. */ -collectIndexInjections(): IndexInjection[] +collectIndexInjections(peer: PeerScope): IndexInjection[] /** - * Render one index.html body: the structured injection table first, then - * the raw `tapIndex` transforms over the result. + * Render one index.html body for one Peer: the structured injection table + * first, then the raw `tapIndex` transforms over the result. * @param html - the raw index.html body. + * @param peer - the Peer the page speaks for. * @returns the transformed body. */ -renderIndex(html: string): string +renderIndex(html: string, peer: PeerScope): string ``` Source: [`packages/host/webserver/src/index.ts`](../../packages/host/webserver/src/index.ts) @@ -285,17 +288,19 @@ Source: [`packages/client/connection/src/index.ts`](../../packages/client/connec #### `webserver/index-inject` — emit -Collect the structured index injection table. Emitted on every index render and every worker boot-payload request; listeners push their current rows, so a row's data is read fresh at emit time. +Collect the structured index injection table for the page `peer` speaks for. Emitted on every index render and every worker boot-payload request; listeners push their current rows, so a row's data is read fresh at emit time. ```ts cordis-catalog /** - * Collect the structured index injection table. Emitted on every index - * render and every worker boot-payload request; listeners push their - * current rows, so a row's data is read fresh at emit time. + * Collect the structured index injection table for the page `peer` speaks + * for. Emitted on every index render and every worker boot-payload + * request; listeners push their current rows, so a row's data is read + * fresh at emit time. * @param table - Mutable row table; listeners append in activation order. + * @param peer - The Peer the rendered page speaks for. * @mode emit */ -'webserver/index-inject'(table: IndexInjection[]): void +'webserver/index-inject'(table: IndexInjection[], peer: PeerScope): void ``` Source: [`packages/host/webserver/src/index.ts`](../../packages/host/webserver/src/index.ts) diff --git a/docs/subsystems/web-server.zh.md b/docs/subsystems/web-server.zh.md index d108ad5f67..21a97be800 100644 --- a/docs/subsystems/web-server.zh.md +++ b/docs/subsystems/web-server.zh.md @@ -2,7 +2,7 @@ [English](web-server.md) | 中文 -[dsh-host-webserver](../../packages/host/webserver) 是 GUI Host 的浏览器 HTTP 载体:一个 `node:http` 服务器——配置 `tls` 时改用 `node:https`——提供 `ctx.webServer`、具名路由注册表、可选的 gzip 响应压缩、index.html 转换回调,以及一个可由插件认领的回退处理器。它不属于 agent loop(智能体循环),也不是能力 seam;它不了解任何 harness 概念。其他插件负责注册所有功能路由,包括 `/api` 桥接、插件 bundle 和 HMR(热模块替换)事件流([分层说明](../../packages/boot/app-boot/README.zh.md))。该服务器只服务浏览器:Electron 通过 `file://` 加载已构建文件,并经 IPC 桥接发送 fetch 请求,不使用本服务器。 +[dsh-host-webserver](../../packages/host/webserver) 是 GUI Host 的浏览器 HTTP 载体:一个 `node:http` 服务器——配置 `tls` 时改用 `node:https`——提供 `ctx.webServer`、具名路由注册表、可选的 gzip 响应压缩、index.html 转换回调,以及一个可由插件认领的回退处理器。它不属于 agent loop(智能体循环),也不是能力 seam;它唯一涉及的 harness 概念是 index 渲染所代表的 Peer。其他插件负责注册所有功能路由,包括 `/api` 桥接、插件 bundle 和 HMR(热模块替换)事件流([分层说明](../../packages/boot/app-boot/README.zh.md))。该服务器只服务浏览器:Electron 通过 `file://` 加载已构建文件,并经 IPC 桥接发送 fetch 请求,不使用本服务器。 源码:[`packages/host/webserver/src/index.ts`](../../packages/host/webserver/src/index.ts) @@ -81,7 +81,7 @@ interface TlsConfig { ## 服务 -`WebServer`(`ctx.webServer`)在激活时立即监听;监听失败(EADDRINUSE 等)会使初始化被拒绝,启动进程会报告失败的 fiber。`tls` 证书对在监听器绑定前读取一次;文件不可读、为空或二者不匹配时同样会使初始化被拒绝,而不是回退到 HTTP。`register(route)` 添加一条具名路由并返回其 disposer;重复的 `(kind, path)` 抛出异常,因为路由模式是组合层约定,冲突即配置错误。Gzip 在服务器内部包装符合条件且基于 socket 的响应,因此 route handler 继续直接持有 `ServerResponse`,服务也不新增响应写出 API。已有内容编码、`Cache-Control: no-transform`、范围响应、SSE、ZIP 与打包后的 `.gz` Worker 镜像均保持 identity 响应。`collectIndexInjections()` 经一次 `webserver/index-inject` emit 收集结构化 `IndexInjection` 行,`renderIndex(html)` 把它们渲染进成功的根路径和配置 index 响应,随后再按注册顺序应用原始的 `tapIndex(transform)` 逃生口转换;[dsh-client-modules](../../packages/client/modules) 以启动 manifest(元数据清单)行回应该事件。`port` 读取监听端口,包括 `config.port` 为 0 时操作系统分配的端口,`protocol` 则报告监听器提供的协议(`http:` 或 `https:`)。 +`WebServer`(`ctx.webServer`)在激活时立即监听;监听失败(EADDRINUSE 等)会使初始化被拒绝,启动进程会报告失败的 fiber。`tls` 证书对在监听器绑定前读取一次;文件不可读、为空或二者不匹配时同样会使初始化被拒绝,而不是回退到 HTTP。`register(route)` 添加一条具名路由并返回其 disposer;重复的 `(kind, path)` 抛出异常,因为路由模式是组合层约定,冲突即配置错误。Gzip 在服务器内部包装符合条件且基于 socket 的响应,因此 route handler 继续直接持有 `ServerResponse`,服务也不新增响应写出 API。已有内容编码、`Cache-Control: no-transform`、范围响应、SSE、ZIP 与打包后的 `.gz` Worker 镜像均保持 identity 响应。`collectIndexInjections(peer)` 经一次传入页面所代表 Peer 的 `webserver/index-inject` emit 收集结构化 `IndexInjection` 行,`renderIndex(html, peer)` 把它们渲染进成功的根路径和配置 index 响应,随后再按注册顺序应用原始的 `tapIndex(transform)` 逃生口转换;[dsh-client-modules](../../packages/client/modules) 以启动 manifest(元数据清单)行回应该事件。`port` 读取监听端口,包括 `config.port` 为 0 时操作系统分配的端口,`protocol` 则报告监听器提供的协议(`http:` 或 `https:`)。 处理过程中抛出异常的请求(畸形的 % 转义撞上 `decodeURIComponent`、客户端在请求体中途断开)会记录为警告并应答 400(响应头已发出时则销毁 socket),绝不导致进程退出。dispose(资源释放)把 `close()` 与 `closeAllConnections()` 配对使用,因为处理器可能像 SSE(Server-Sent Events)那样保持响应打开,而这类连接永远不会自行结束;没有强制关闭,拆卸就会挂起。TLS socket 只有完成握手后才计入连接,因此服务器会把原始 TLS socket 与升级后的 socket 一并跟踪,并在 dispose 时销毁两者。该包从不打印输出:URL 行归 shell 所有。逐包运维细节(含开发模式的 bundle 监视流水线)留在 [README](../../packages/host/webserver/README.zh.md) 中。 @@ -235,20 +235,23 @@ tapIndex(transform: (html: string) => string): () => void applyIndexTaps(html: string): string /** - * Gather the structured injection table: one `webserver/index-inject` emit, - * every subscriber pushes its current rows. Fresh per call, so subscribers - * read live state (module graph, theme preference) at emit time. + * Gather the structured injection table for one Peer: one + * `webserver/index-inject` emit, every subscriber pushes its current rows. + * Fresh per call, so subscribers read live state (module graph, theme + * preference, the Peer's access) at emit time. + * @param peer - the Peer the page speaks for. * @returns rows in subscriber activation order. */ -collectIndexInjections(): IndexInjection[] +collectIndexInjections(peer: PeerScope): IndexInjection[] /** - * Render one index.html body: the structured injection table first, then - * the raw `tapIndex` transforms over the result. + * Render one index.html body for one Peer: the structured injection table + * first, then the raw `tapIndex` transforms over the result. * @param html - the raw index.html body. + * @param peer - the Peer the page speaks for. * @returns the transformed body. */ -renderIndex(html: string): string +renderIndex(html: string, peer: PeerScope): string ``` Source: [`packages/host/webserver/src/index.ts`](../../packages/host/webserver/src/index.ts) @@ -285,17 +288,19 @@ Source: [`packages/client/connection/src/index.ts`](../../packages/client/connec #### `webserver/index-inject` — emit -Collect the structured index injection table. Emitted on every index render and every worker boot-payload request; listeners push their current rows, so a row's data is read fresh at emit time. +Collect the structured index injection table for the page `peer` speaks for. Emitted on every index render and every worker boot-payload request; listeners push their current rows, so a row's data is read fresh at emit time. ```ts cordis-catalog /** - * Collect the structured index injection table. Emitted on every index - * render and every worker boot-payload request; listeners push their - * current rows, so a row's data is read fresh at emit time. + * Collect the structured index injection table for the page `peer` speaks + * for. Emitted on every index render and every worker boot-payload + * request; listeners push their current rows, so a row's data is read + * fresh at emit time. * @param table - Mutable row table; listeners append in activation order. + * @param peer - The Peer the rendered page speaks for. * @mode emit */ -'webserver/index-inject'(table: IndexInjection[]): void +'webserver/index-inject'(table: IndexInjection[], peer: PeerScope): void ``` Source: [`packages/host/webserver/src/index.ts`](../../packages/host/webserver/src/index.ts) diff --git a/docs/tool-catalog.i18n.yaml b/docs/tool-catalog.i18n.yaml index 60e63a6e1e..8265143029 100644 --- a/docs/tool-catalog.i18n.yaml +++ b/docs/tool-catalog.i18n.yaml @@ -9,8 +9,8 @@ en: 6cc2cd9ced70bb09 zh: 1c0e6cb4ba2ed637 /tool-schema-catalog/tool-package-map: - en: 19883cdd5d6181cb - zh: 7d0cac18206184ec + en: 10fe940a3d17cde7 + zh: ef1e8863c28e3c05 /tool-schema-catalog/deepseek-ai-dsh-plugin-manager: en: e0143916bb634762 zh: e0143916bb634762 @@ -60,14 +60,14 @@ en: e713a4a11a15d13c zh: e713a4a11a15d13c /tool-schema-catalog/deepseek-ai-dsh-tool-ask-user/ask-user-question: - en: 055268ba4dcd32f9 - zh: a1a3c4bfcbf698ed -/tool-schema-catalog/deepseek-ai-dsh-tools: - en: 76fbbf0fe38aef10 - zh: 76fbbf0fe38aef10 -/tool-schema-catalog/deepseek-ai-dsh-tools/run-code: - en: a2368a9d8136276e - zh: d1a4918fc45a7c87 + en: 2f86adf8ae823afb + zh: a5dad3d821e75e55 +/tool-schema-catalog/deepseek-ai-dsh-tool-executor-ptc: + en: 8c8643ef46fd075f + zh: 8c8643ef46fd075f +/tool-schema-catalog/deepseek-ai-dsh-tool-executor-ptc/run-code: + en: a5d49232a604a2fb + zh: 720040d11a3e31c2 /tool-schema-catalog/deepseek-ai-dsh-plan-mode: en: b500b530845e26a9 zh: b500b530845e26a9 diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 7d276b4a58..24101e126f 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -20,7 +20,7 @@ This table connects model-visible tool names to the plugin package and service s | `@deepseek-ai/dsh-mcp-resources` | `list_mcp_resource_templates`, `list_mcp_resources`, `read_mcp_resource` | `ctx.tools`, `ctx.mcpResources` | `tool/call`, `tool/result` | - | - | | `@deepseek-ai/dsh-experimental-browser-use-stagehand-native` | `stagehand_act`, `stagehand_extract`, `stagehand_navigate`, `stagehand_observe`, `stagehand_screenshot`, `stagehand_tabs` | `ctx.browserUse`, `ctx.agents`, `ctx.tools`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | - | | `@deepseek-ai/dsh-tool-ask-user` | `ask_user_question` | `ctx.tools`, `ctx.userQuestions` | `tool/call`, `tool/result after an answer or timeout`, `late user/message` | - | ask_user_question keeps the original blocking behavior by default; set `mode: timed` to opt into a foreground timeout and pending result while the question remains answerable. In timed mode, `timeout: -1` keeps that call blocking indefinitely. | -| `@deepseek-ai/dsh-tools` | `run_code` | `ctx.workingDirectory (Agent executions)`, `ctx.tools`, `ctx.ptcRuntime (execution time)`, `ctx.systemPrompt` | `tool/call`, `one tool/ptc-dispatch-start + tool/ptc-dispatch pair per bridged sub-call`, `tool/result` | - | Owned by the tool registry as a reserved transport outside filterable capability layers under `mode: ptc` (see the PTC mode Agent Note). Under `ptc` it is the registry's only wire contribution; the other visible capabilities are declared in a generated SDK section in the loaded runtime's language, and a program calls them through bindings scheduled under the native concurrency contract (submission-ordered starts and policy; concurrency-safe bodies overlap up to `maxParallelSubCalls`) that re-enter the complete guarded tool pipeline and link each nested execution to this outer result. | +| `@deepseek-ai/dsh-tool-executor-ptc` | `run_code` | `ctx.workingDirectory (Agent executions)`, `ctx.tools`, `ctx.ptcRuntime`, `ctx.systemPrompt` | `tool/call`, `one tool/ptc-dispatch-start + tool/ptc-dispatch pair per bridged sub-call`, `tool/result` | - | The PTC executor exposes only run_code to the model and declares other visible capabilities in a generated SDK in the runtime’s language. Program bindings enter the shared guarded tool pipeline in submission order; concurrency-safe bodies overlap up to maxParallelSubCalls, and nested results remain linked to the outer program. | | `@deepseek-ai/dsh-plan-mode` | `exit_plan_mode` | `ctx.tools`, `ctx.systemPrompt`, `ctx.agents`, `ctx.userQuestions (execution time, opportunistic)` | `tool/call`, `plan/mode inactive on an approved review`, `tool/result` | - | exit_plan_mode stays in the model-facing schema while planning is inactive so transitions add no tool-catalog churn on top of the plan-policy change. Its execute path rejects calls outside plan mode; in plan mode it presents the plan over the user-questions seam (approve / keep planning with feedback), and approval logs plan mode inactive at the step boundary. | | `@deepseek-ai/dsh-tool-bash` | `bash` | `ctx.workingDirectory`, `ctx.tools`, `ctx.shell`, `ctx.systemPrompt`, `ctx.shellEnv`, `ctx.jobs for run_in_background and the job-backed foreground path` | `tool/call`, `tool/result` | - | The bash tool is the model-facing consumer of the bash executor seam. With a job registry composed every call registers with the generic `ctx.jobs` runtime as it starts, collected/stopped through the `job_*` tools from `@deepseek-ai/dsh-tool-jobs`; without one, or with `enableRunInBackground: false`, the tool registers a foreground-only schema without the `run_in_background` parameter. | | `@deepseek-ai/dsh-tool-present` | `present` | `ctx.workingDirectory`, `ctx.tools`, `ctx.fs`, `ctx.agents` | `tool/call`, `deliverables/presented after a successful final result`, `tool/result` | - | Deliveries belong to the calling Session; Web ui-deliverables supplies source-file opening and cards. | @@ -571,13 +571,13 @@ Source: [`packages/interaction/tool-ask-user/src/index.ts`](../packages/interact ask_user_question keeps the original blocking behavior by default; set `mode: timed` to opt into a foreground timeout and pending result while the question remains answerable. In timed mode, `timeout: -1` keeps that call blocking indefinitely. - + -## `@deepseek-ai/dsh-tools` +## `@deepseek-ai/dsh-tool-executor-ptc` ### `run_code` -Execute a TypeScript program against the available tools. Takes two required arguments: `code`, the program to execute, and `description`, a short summary of what the program does. Call tools as `await tools.name(args)` per the declarations in the system prompt. Curate the program output. Image-bearing subtool results are attached after the run. +Execute a TypeScript program against the available tools. Takes two required arguments: `code`, the BODY of an async function (erasable syntax only; top-level `await` and `return` work), and `description`, a short summary of what the program does. Call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return is program output — curate it. Image-bearing subtool results are attached after the run. The program is the body of an async TypeScript function; only erasable syntax is accepted, with no enum or namespaces. Type annotations are advisory. Top-level await and return are supported. Emit output with console.log(...) or return a lossless JSON value. Only printed values and the returned value become program output. Each call runs in a fresh Node process. Node APIs are available through await import(...). Relative paths use the supplied working directory; process.env starts empty. Direct filesystem access follows this execution's sandbox policy. Each program starts in the Session's current directory. Running programs keep their initial directory. A sandbox escalation approves this complete program for one execution only. Nested tools retain their own policies and approvals. Request wider access only after evidence of a denial. Earlier effects may already have completed: inspect them before explicitly retrying. Programs are never replayed automatically. ```json { @@ -585,7 +585,7 @@ Execute a TypeScript program against the available tools. Takes two required arg "properties": { "code": { "type": "string", - "description": "The TypeScript program to execute, following this tool's execution instructions." + "description": "The program: the body of an async TypeScript function." }, "description": { "type": "string", @@ -593,7 +593,7 @@ Execute a TypeScript program against the available tools. Takes two required arg }, "timeoutMs": { "type": "number", - "description": "Positive elapsed-time budget in milliseconds, capped by the deployment maximum." + "description": "Positive elapsed-time budget in milliseconds, including nested tool and approval waits. Default 120000; capped at 600000. Zero does not disable the deadline." }, "sandbox_permissions": { "type": "string", @@ -615,9 +615,9 @@ Execute a TypeScript program against the available tools. Takes two required arg } ``` -Source: [`packages/core/tools/src/ptc.ts`](../packages/core/tools/src/ptc.ts) +Source: [`packages/core/tool-executor-ptc/src/ptc.ts`](../packages/core/tool-executor-ptc/src/ptc.ts) -Owned by the tool registry as a reserved transport outside filterable capability layers under `mode: ptc` (see the PTC mode Agent Note). Under `ptc` it is the registry's only wire contribution; the other visible capabilities are declared in a generated SDK section in the loaded runtime's language, and a program calls them through bindings scheduled under the native concurrency contract (submission-ordered starts and policy; concurrency-safe bodies overlap up to `maxParallelSubCalls`) that re-enter the complete guarded tool pipeline and link each nested execution to this outer result. +The PTC executor exposes only run_code to the model and declares other visible capabilities in a generated SDK in the runtime’s language. Program bindings enter the shared guarded tool pipeline in submission order; concurrency-safe bodies overlap up to maxParallelSubCalls, and nested results remain linked to the outer program. diff --git a/docs/tool-catalog.zh.md b/docs/tool-catalog.zh.md index 64cc4e220b..27670f9964 100644 --- a/docs/tool-catalog.zh.md +++ b/docs/tool-catalog.zh.md @@ -24,7 +24,7 @@ | `@deepseek-ai/dsh-mcp-resources` | `list_mcp_resource_templates`, `list_mcp_resources`, `read_mcp_resource` | `ctx.tools`, `ctx.mcpResources` | `tool/call`, `tool/result` | - | - | | `@deepseek-ai/dsh-experimental-browser-use-stagehand-native` | `stagehand_act`、`stagehand_extract`、`stagehand_navigate`、`stagehand_observe`、`stagehand_screenshot`、`stagehand_tabs` | `ctx.browserUse`、`ctx.agents`、`ctx.tools`、`ctx.systemPrompt` | `tool/call`、`tool/result` | - | - | | `@deepseek-ai/dsh-tool-ask-user` | `ask_user_question` | `ctx.tools`、`ctx.userQuestions` | `tool/call`、`tool/result after an answer or timeout`、`late user/message` | - | ask_user_question 默认保持原有阻塞行为;设置 `mode: timed` 后才启用前台超时与 pending 结果,同时问题仍可回答;timed 模式内 `timeout: -1` 让本次调用无限期阻塞。 | -| `@deepseek-ai/dsh-tools` | `run_code` | `ctx.workingDirectory (Agent executions)`、`ctx.tools`、`ctx.ptcRuntime (execution time)`、`ctx.systemPrompt` | `tool/call`、`one tool/ptc-dispatch-start + tool/ptc-dispatch pair per bridged sub-call`、`tool/result` | - | 在 `mode: ptc` 下,它由工具注册表所有,作为可过滤能力层之外的保留传输机制(参见 PTC mode Agent Note)。在 `ptc` 下,它是注册表对协议格式(wire format)的唯一贡献;其他可见能力在使用已加载运行时语言生成的 SDK 章节中声明。程序通过 binding 调用这些能力,调用按照原生并发约定调度:启动顺序和策略遵循提交顺序,并发安全的函数体最多重叠执行 `maxParallelSubCalls` 个。调用会重新进入完整且受守卫保护的工具流水线,并将每个嵌套执行关联到此外层结果。 | +| `@deepseek-ai/dsh-tool-executor-ptc` | `run_code` | `ctx.workingDirectory (Agent executions)`、`ctx.tools`、`ctx.ptcRuntime`、`ctx.systemPrompt` | `tool/call`、`one tool/ptc-dispatch-start + tool/ptc-dispatch pair per bridged sub-call`、`tool/result` | - | PTC 执行器只向模型公开 run_code,并在运行时语言生成的 SDK 中声明其他可见能力。程序绑定按提交顺序进入共享的受守卫工具流水线;并行安全的函数体最多重叠 maxParallelSubCalls 个,嵌套结果保持与外层程序关联。 | | `@deepseek-ai/dsh-plan-mode` | `exit_plan_mode` | `ctx.tools`、`ctx.systemPrompt`、`ctx.agents`、`ctx.userQuestions (execution time, opportunistic)` | `tool/call`、`plan/mode inactive on an approved review`、`tool/result` | - | 规划未激活时,exit_plan_mode 仍保留在面向模型的 schema 中,这样状态转换不会在规划策略变更之外额外造成工具目录变动。其执行路径会拒绝规划模式之外的调用;在规划模式下,它通过用户交互 seam 提交计划(批准/根据反馈继续规划),批准后会在步骤边界记录规划模式已停用。 | | `@deepseek-ai/dsh-tool-bash` | `bash` | `ctx.workingDirectory`、`ctx.tools`、`ctx.shell`、`ctx.systemPrompt`、`ctx.shellEnv`、`ctx.jobs for run_in_background and the job-backed foreground path` | `tool/call`、`tool/result` | - | bash 工具是 bash 执行器 seam 面向模型的消费方。组合中有 job 注册表时,每次调用一启动就注册到通用 `ctx.jobs` 运行时,并通过 `job_*` 工具(来自 `@deepseek-ai/dsh-tool-jobs`)收集/停止;没有注册表或 `enableRunInBackground: false` 时,工具注册不带 `run_in_background` 参数的纯前台 schema。 | | `@deepseek-ai/dsh-tool-present` | `present` | `ctx.workingDirectory`、`ctx.tools`, `ctx.fs`, `ctx.agents` | `tool/call`, `deliverables/presented 在成功的最终结果之后`, `tool/result` | - | 交付归调用方 Session 所有;Web ui-deliverables 提供源文件打开与卡片。 | @@ -41,7 +41,7 @@ | `@deepseek-ai/dsh-tool-lsp` | `lsp` | `ctx.workingDirectory`、`ctx.tools`、`ctx.lsp`、`ctx.systemPrompt` | `tool/call`、`tool/result` | - | lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后,因此其模型可见 schema 在更换提供方时保持稳定。运行时要求已注册提供方,例如 `@deepseek-ai/dsh-lsp-stdio`;如果没有提供方,查询会返回结构化 `LSP_UNAVAILABLE` 错误,而不会改变 schema。 | | `@deepseek-ai/dsh-tool-ralph` | `ralph` | `ctx.tools`、`ctx.workflowEngine`、`ctx.subagents`、`ctx.systemPrompt`、`a calling Agent (exec.agent parents every fresh round)` | `tool/call`、`tool/result`、`workflow and child session events during execution` | - | 固定的前台工作流会在每个 Round 启动一个全新的结构化子级;模型只能选择不可变目标和可选的 Round 上限。 | | `@deepseek-ai/dsh-tool-skill` | `skill` | `ctx.workingDirectory`、`ctx.tools`、`ctx.agents`、`ctx.skills` | `tool/call`、`tool/result`、`user/message replacement catalogs via agent.inject()` | - | - | -| `@deepseek-ai/dsh-tool-session-query` | `session_event_read`、`session_event_search`、`session_event_trace`、`session_search`、`session_trace` | `ctx.tools`、`ctx.systemPrompt`、`ctx.sessionQuery`、`ctx.agents`、`a calling Agent for workspace authority` | `tool/call`、`tool/result` | - | 这 5 个只读工具会隐藏提供方游标,并根据不可变的调用 agent 会话为每个结果授权。该包需要选择启用;需要强制截止时间或限制行内输出的组合还会挂载通用超时或 spill 策略。 | +| `@deepseek-ai/dsh-tool-session-query` | `session_event_read`、`session_event_search`、`session_event_trace`、`session_search`、`session_trace` | `ctx.tools`、`ctx.systemPrompt`、`ctx.sessionQuery`、`a calling Agent for workspace authority` | `tool/call`、`tool/result` | - | 这 5 个只读工具会隐藏提供方游标,并根据不可变的调用 agent 会话为每个结果授权。该包需要选择启用;需要强制截止时间或限制行内输出的组合还会挂载通用超时或 spill 策略。 | | `@deepseek-ai/dsh-experimental-tool-subagent-oar` | `subagent_oar` | `ctx.tools`, `ctx.subagents` | `tool/call`, `tool/result`, `parent subagent catalog and completion notices` | - | subagent_oar 工具在为外部提供方配置的别名之间选择。本目录展示 Codex 和 Claude Code 别名;每个部署自行选择提供方。原生模型和权限设置由所选提供方的配置决定。 | | `@deepseek-ai/dsh-tool-subagent` | `list_subagent_models`、`subagent` | `ctx.tools`、`ctx.subagents`、`ctx.systemPrompt`、`ctx.sessionProjections`、`用于模型发现和所选路由校验的 ctx.llm` | `tool/call`、`tool/result`、`child session events through the chosen provider` | `subagent`、`subagent_fork` | 加载时的 `toolName` 选择委派名称(默认为 `subagent`);`modelSelection` 增加可选路由字段。`modelDiscovery` 默认关闭,每个作用域由一个实例注册共享发现工具。Web 与 Desktop preset 启用 spawn 发现和路由字段,并为 `subagent_fork` 保持路由字段关闭。Host 设置服务为每个新顶层 Session 记录授权;子 Session 继承该授权。没有已记录策略时,发现和显式选择只允许当前父级路由。每次委派都会立即返回子代理 id;本地子代理接受后续消息,外部后端自动汇报单次执行结果。 | | `@deepseek-ai/dsh-tool-subagent-control` | `interrupt_agent`、`list_agents`、`send_message` | `ctx.tools`、`ctx.subagents`、`ctx.agents` | `tool/call`、`tool/result`、`child session events through ctx.subagents` | - | 这些是用于发现、中断托管 subagent 和向本地子代理发送消息的全局命名工具:绑定提供方的 `tool-subagent` 实例注册不同的委派工具;本包的一个实例注册 `send_message`、`interrupt_agent` 和 `list_agents`。 | @@ -575,13 +575,13 @@ ask_user_question 默认保持原有阻塞行为;设置 `mode: timed` 后才启用前台超时与 pending 结果,同时问题仍可回答;timed 模式内 `timeout: -1` 让本次调用无限期阻塞。 - + -## `@deepseek-ai/dsh-tools` +## `@deepseek-ai/dsh-tool-executor-ptc` ### `run_code` -针对可用工具执行 TypeScript 程序。接受两个必填参数:`code`,要执行的程序;以及 `description`,简要说明该程序做什么。请根据系统提示词中的声明,以 `await tools.name(args)` 形式调用工具。请筛选程序输出。含图片的子工具结果会在运行结束后附加。 +针对可用工具执行 TypeScript 程序。接受两个必填参数:`code`,异步函数的函数体(仅限可擦除语法,支持顶层 `await` 和 `return`);以及 `description`,简要说明程序做什么。请按系统提示词中的声明,以 `await tools.name(args)` 调用工具。只有打印或返回的内容构成程序输出,请筛选这些内容。含图片的子工具结果会在运行结束后附加。程序是异步 TypeScript 函数的函数体,只接受可擦除语法,不支持 enum 或 namespace。类型标注只作提示。支持顶层 await 和 return。使用 console.log(...) 或返回无损 JSON 值来输出。只有打印值和返回值成为程序输出。每次调用在新的 Node 进程中运行,可通过 await import(...) 使用 Node API。相对路径以传入的工作目录为准;process.env 初始为空。直接文件系统访问遵循本次执行的沙箱策略。每个程序从 Session 的当前目录启动,运行中保留初始目录。沙箱提权只为本次完整程序执行授权。嵌套工具保留自身策略与审批。只有在获得拒绝证据后才请求更宽访问。先前效果可能已经发生,请在显式重试前检查。程序不会自动重放。 ```json { @@ -589,7 +589,7 @@ ask_user_question 默认保持原有阻塞行为;设置 `mode: timed` 后才 "properties": { "code": { "type": "string", - "description": "The TypeScript program to execute, following this tool's execution instructions." + "description": "The program: the body of an async TypeScript function." }, "description": { "type": "string", @@ -597,7 +597,7 @@ ask_user_question 默认保持原有阻塞行为;设置 `mode: timed` 后才 }, "timeoutMs": { "type": "number", - "description": "Positive elapsed-time budget in milliseconds, capped by the deployment maximum." + "description": "Positive elapsed-time budget in milliseconds, including nested tool and approval waits. Default 120000; capped at 600000. Zero does not disable the deadline." }, "sandbox_permissions": { "type": "string", @@ -619,9 +619,9 @@ ask_user_question 默认保持原有阻塞行为;设置 `mode: timed` 后才 } ``` -来源:[`packages/core/tools/src/ptc.ts`](../packages/core/tools/src/ptc.ts) +来源:[`packages/core/tool-executor-ptc/src/ptc.ts`](../packages/core/tool-executor-ptc/src/ptc.ts) -在 `mode: ptc` 下,它由工具注册表所有,作为可过滤能力层之外的保留传输机制(参见 PTC mode Agent Note)。在 `ptc` 下,它是注册表对协议格式的唯一贡献;其他可见能力在使用已加载运行时语言生成的 SDK 章节中声明。程序通过 binding 调用这些能力,调用按照原生并发约定调度:启动顺序和策略遵循提交顺序,并发安全的函数体最多重叠执行 `maxParallelSubCalls` 个。调用会重新进入完整且受守卫保护的工具流水线,并将每个嵌套执行关联到此外层结果。 +PTC 执行器只向模型公开 run_code,并在运行时语言生成的 SDK 中声明其他可见能力。程序绑定按提交顺序进入共享的受守卫工具流水线;并行安全的函数体最多重叠 maxParallelSubCalls 个,嵌套结果保持与外层程序关联。 diff --git a/docs/tool-execution-pipeline.i18n.yaml b/docs/tool-execution-pipeline.i18n.yaml index 7269b198f5..0d35b299dd 100644 --- a/docs/tool-execution-pipeline.i18n.yaml +++ b/docs/tool-execution-pipeline.i18n.yaml @@ -6,5 +6,5 @@ en: 41d772192075e133 zh: 0eb190f00f396176 /tool-execution-pipeline: - en: f2811b12640c6f8e - zh: 9e0323523dcbb04c + en: a1103f6055f81a4b + zh: 6eefe6bfa59b1692 diff --git a/docs/tool-execution-pipeline.md b/docs/tool-execution-pipeline.md index 53b7481183..bdb8152984 100644 --- a/docs/tool-execution-pipeline.md +++ b/docs/tool-execution-pipeline.md @@ -3,7 +3,7 @@ # Tool Execution Pipeline -This graph shows where policy, hooks, sandboxing, filesystem guards, result rewriting, final-outcome observation, and UI rendering run without changing the loop. The `tools/pre-execute` waterfall runs first, monotonic guards run next, and the `tools/execute` and `tools/post-execute` waterfalls follow; the three waterfalls may transform a call. Definition-owned `projectContent` installs prepared content before post-execute; `finalizeContent` and `tools/result` run afterward. +Built-in executors own raw batch parsing, scheduling, cancellation, and Session call/result logging. This graph shows where their shared invocation pipeline applies policy, hooks, sandboxing, filesystem guards, result rewriting, final-outcome observation, and UI rendering without changing the loop. The `tools/pre-execute` waterfall runs first, monotonic guards run next, and the `tools/execute` and `tools/post-execute` waterfalls follow; the three waterfalls may transform a call. Definition-owned `projectContent` installs prepared content before post-execute; `finalizeContent` and `tools/result` run afterward. ```mermaid flowchart TD @@ -20,14 +20,15 @@ flowchart TD owned["Tool-owned session events
todo/write, fs/observed, hook/invoked, hook/result, tool/ptc-dispatch"] project["ToolDefinition.projectContent
execution-prepared text and images"] post["tools/post-execute waterfall
accept, block, replace, add context"] - normalized["Registry outer normalization
pipeline/result snapshot throws become isError"] + normalized["Invocation pipeline normalization
pipeline/result snapshot throws become isError"] finalize["ToolDefinition.finalizeContent
last content-only invariant"] final["tools/result synchronous notification
frozen authoritative outcome"] context["Active-batch additionalContexts FIFO
injected user/message after recorded tool results"] toolResult["Session event: tool/result
single model-facing outcome"] allResults["Tool batch settled
recorded tool/result events complete"] presentResult["UI completed card
presentResult(args, result)"] - model --> toolCall + executor["Selected ToolExecutor.execute
raw batch, Agent, optional logging step"] + model --> executor --> toolCall toolCall --> presentCall toolCall --> pre pre -->|allow| guards @@ -60,6 +61,6 @@ flowchart TD allResults --> context ``` -Filesystem read-before-edit checks stay below `tool-fs` on `fs/*` events. Generic pre/post waterfalls host hooks and approval policy; `ctx.approval` resolves asks before monotonic guards, and owner policy that must not be reordered remains a registered guard. Around-dispatch concerns such as timeouts wrap `tools/execute`. The registry losslessly snapshots the candidate result and normalizes a snapshot failure before the visible definition's snapshotted `finalizeContent` callback enforces its synchronous content-only invariant. `tools/result` then observes the immutable, lossless-JSON outcome. This lets hooks span tool families without coupling the tools to one policy service. PTC mode sends both the reserved `run_code` transport and its serialized sub-calls through the pipeline; sub-calls carry the parent token, log `tool/ptc-dispatch`, return denials as binding rejections, and omit `additionalContexts` to preserve call/result adjacency. +Filesystem read-before-edit checks stay below `tool-fs` on `fs/*` events. Generic pre/post waterfalls host hooks and approval policy; `ctx.approval` resolves asks before monotonic guards, and owner policy that must not be reordered remains a registered guard. Around-dispatch concerns such as timeouts wrap `tools/execute`. The pipeline losslessly snapshots the candidate result and normalizes a snapshot failure before the visible definition's snapshotted `finalizeContent` callback enforces its synchronous content-only invariant. `tools/result` then observes the immutable, lossless-JSON outcome. This lets hooks span tool families without coupling the tools to one policy service. PTC mode sends both the reserved `run_code` transport and its ordered sub-calls through the pipeline; sub-calls carry the parent token, log `tool/ptc-dispatch`, return denials as binding rejections, and omit `additionalContexts` to preserve call/result adjacency. Maintenance mode: curated Mermaid flow; exact tool schemas and event signatures live in generated catalogs. diff --git a/docs/tool-execution-pipeline.zh.md b/docs/tool-execution-pipeline.zh.md index 89d2ba505e..823c5f62df 100644 --- a/docs/tool-execution-pipeline.zh.md +++ b/docs/tool-execution-pipeline.zh.md @@ -5,7 +5,7 @@ [English](tool-execution-pipeline.md) | 中文 -此图展示策略、钩子、沙箱、文件系统守卫、结果重写、最终结果观察和 UI 渲染在不改变循环的情况下何时运行。`tools/pre-execute` waterfall(瀑布式事件)首先运行,随后是单调守卫,然后运行 `tools/execute` 和 `tools/post-execute` waterfall;这三个 waterfall 可以改写一次调用。由定义自身控制的 `projectContent` 在执行后策略之前安装已准备内容;`finalizeContent` 和 `tools/result` 在此之后运行。 +内置执行器负责原始批次解析、调度、取消和 Session 调用/结果日志。此图展示其共享调用流水线如何执行策略、钩子、沙箱、文件系统守卫、结果改写、最终结果观测和 UI 呈现,而无需改动循环。`tools/pre-execute` 瀑布先执行,然后是单调守卫,再是 `tools/execute` 和 `tools/post-execute` 瀑布;三个瀑布可以转换调用。定义拥有的 `projectContent` 在后置策略之前安装准备好的内容,`finalizeContent` 和 `tools/result` 在之后执行。 ```mermaid flowchart TD @@ -22,14 +22,15 @@ flowchart TD owned["Tool-owned session events
todo/write, fs/observed, hook/invoked, hook/result, tool/ptc-dispatch"] project["ToolDefinition.projectContent
execution-prepared text and images"] post["tools/post-execute waterfall
accept, block, replace, add context"] - normalized["Registry outer normalization
pipeline/result snapshot throws become isError"] + normalized["Invocation pipeline normalization
pipeline/result snapshot throws become isError"] finalize["ToolDefinition.finalizeContent
last content-only invariant"] final["tools/result synchronous notification
frozen authoritative outcome"] context["Active-batch additionalContexts FIFO
injected user/message after recorded tool results"] toolResult["Session event: tool/result
single model-facing outcome"] allResults["Tool batch settled
recorded tool/result events complete"] presentResult["UI completed card
presentResult(args, result)"] - model --> toolCall + executor["Selected ToolExecutor.execute
raw batch, Agent, optional logging step"] + model --> executor --> toolCall toolCall --> presentCall toolCall --> pre pre -->|allow| guards diff --git a/docs/upgrade-guide/v0.2.1-rc.2/sdk-desktop-context/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-rc.2/sdk-desktop-context/guide.i18n.yaml index 54c20e070f..a0657e1ef4 100644 --- a/docs/upgrade-guide/v0.2.1-rc.2/sdk-desktop-context/guide.i18n.yaml +++ b/docs/upgrade-guide/v0.2.1-rc.2/sdk-desktop-context/guide.i18n.yaml @@ -9,8 +9,8 @@ en: d202616bf6e03fa4 zh: cc276adda4b952be /sdk-desktop-tools-and-context/change: - en: 85ac2825c52e2503 - zh: fd6fdbdca131c778 + en: ef89799953ecb457 + zh: 86fdb487b9a15a6f /sdk-desktop-tools-and-context/migration: en: 9caef4f8b153622a zh: 1b4901f5b18b72fd diff --git a/docs/upgrade-guide/v0.2.1-rc.2/sdk-desktop-context/guide.md b/docs/upgrade-guide/v0.2.1-rc.2/sdk-desktop-context/guide.md index 2f0ae5051e..23a9efdd57 100644 --- a/docs/upgrade-guide/v0.2.1-rc.2/sdk-desktop-context/guide.md +++ b/docs/upgrade-guide/v0.2.1-rc.2/sdk-desktop-context/guide.md @@ -9,7 +9,7 @@ English | [中文](guide.zh.md) ## Change -The existing `sdk` profile now includes `present`, `ask_user_question`, schedule tools, file-reference guidance and time context. It does not start a Web server. Standard tool calling remains the default; `DSH_TOOLS_MODE=ptc` selects programmatic tool calling in the same profile. `sdk-minimal` is unchanged. +The existing `sdk` profile now includes `present`, `ask_user_question`, schedule tools, file-reference guidance and time context. It does not start a Web server. Native tool execution remains the default; select PTC through an [executor overlay](../tool-executor-plugins/guide.md). `sdk-minimal` is unchanged. ## Migration diff --git a/docs/upgrade-guide/v0.2.1-rc.2/sdk-desktop-context/guide.zh.md b/docs/upgrade-guide/v0.2.1-rc.2/sdk-desktop-context/guide.zh.md index 463d9d947d..719b105344 100644 --- a/docs/upgrade-guide/v0.2.1-rc.2/sdk-desktop-context/guide.zh.md +++ b/docs/upgrade-guide/v0.2.1-rc.2/sdk-desktop-context/guide.zh.md @@ -9,7 +9,7 @@ description: 现有 SDK profile 包含原生桌面工具和模型上下文。 ## 变更 -现有 `sdk` profile 现在包含 `present`、`ask_user_question`、定时任务工具、文件引用说明和时间上下文。它不启动 Web server。默认仍使用标准工具调用;`DSH_TOOLS_MODE=ptc` 在同一 profile 中选择程序化工具调用。`sdk-minimal` 保持不变。 +现有 `sdk` profile 现在包含 `present`、`ask_user_question`、定时任务工具、文件引用说明和时间上下文。它不启动 Web server。默认仍使用原生工具执行;通过[执行器 overlay](../tool-executor-plugins/guide.zh.md) 选择 PTC。`sdk-minimal` 保持不变。 ## 迁移 diff --git a/docs/upgrade-guide/v0.2.1-rc.2/tool-executor-plugins/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-rc.2/tool-executor-plugins/guide.i18n.yaml new file mode 100644 index 0000000000..8c3acb8f44 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.2/tool-executor-plugins/guide.i18n.yaml @@ -0,0 +1,16 @@ +# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.1-rc.2/tool-executor-plugins/guide.md +/: + en: 3b1ca9e0f78709e0 + zh: 13f23adcc722f319 +/mount-a-tool-executor: + en: 73002a2963b9ea27 + zh: a67ea382d7c70c61 +/mount-a-tool-executor/change: + en: fac0b27c9452c4f2 + zh: 2f53fd013ff460ba +/mount-a-tool-executor/migration: + en: 4d91c0177d06f4bd + zh: e93047b58a5aef4c diff --git a/docs/upgrade-guide/v0.2.1-rc.2/tool-executor-plugins/guide.md b/docs/upgrade-guide/v0.2.1-rc.2/tool-executor-plugins/guide.md new file mode 100644 index 0000000000..17045a20b3 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.2/tool-executor-plugins/guide.md @@ -0,0 +1,35 @@ +--- +kind: upgrade-guide +description: "Tool execution modes move from dsh-tools configuration into explicit executor plugins." +--- + +# Mount a tool executor + +English | [中文](guide.zh.md) + +## Change + +`@deepseek-ai/dsh-tools` keeps tool registration and executor selection, but rejects the removed `mode` and `maxParallelSubCalls` configuration fields at load time. Native and PTC execution require `@deepseek-ai/dsh-tool-executor-native` or `@deepseek-ai/dsh-tool-executor-ptc`. Shipped profiles mount an executor; custom compositions must add one. The existing `dsh-agent-tool-presentation` selector remains supported but is deprecated. Enabled `tool-executor-native` and `tool-executor-ptc` rows are required at startup; invalid executor configuration now stops startup. Web, headless, and SDK no longer read `DSH_TOOLS_MODE`; all inherit native execution from base. + +## Migration + +1. Keep the `dsh-tools` row and remove its execution configuration. Mount one native or PTC executor in the intended deployment or preset scope. For shipped profile patches, target the executor row instead of the `tools` row. +2. Move `maxParallelSubCalls` to the PTC executor configuration and mount a compatible `ctx.ptcRuntime` provider. The executor declares this service as a required injection; a missing runtime stops startup, and unloading the runtime withdraws the executor. `agent-loop.maxParallelToolCalls` and saved overrides stay in place with default `10`. The loop forwards the current limit to each batch; live edits apply to the next batch instead of the next tool group. +3. Custom executors implement `describe` and `execute`. Programmatic callers pass raw `calls` and `signal`, optional `agent`, and optional `log: { turn, step }`; read single-call outcomes from `results`. Omit `log` outside a committed model step. +4. Import PTC SDK renderers and `CodeRunFailedError` from `@deepseek-ai/dsh-tool-executor-ptc`. Run a task with the selected interaction mode and confirm its tool results appear in the Session. +5. Import `ToolExecutionPipeline` and `executeToolBatch` from `@deepseek-ai/dsh-tools/pipeline`. Complete calls, including single calls, use batches; custom schedulers call `prepare`, `dispatch`, and `complete` directly. Add `@deepseek-ai/dsh-tool-testkit` as a development dependency for tool fixtures and import them from that package. Neither is exported by the tool registry entry. +6. Import shared dispatch events and the `ptc-mode` message-source declaration from `@deepseek-ai/dsh-tool-executor-ptc/types`; Client code uses its `/client` re-export. Host policy extensions use `import type` from `@deepseek-ai/dsh-tool-executor-ptc` for `PtcDispatchLog` and `tools/ptc-dispatch-log`. + +7. Remove `DSH_TOOLS_MODE` from launch environments. For PTC, save the following as `ptc.yml` and pass it with `--patch`; native execution needs no overlay. The same patch works with `--profile web` and `--profile sdk`; TypeScript and Python SDK clients pass its path through `patches`. Confirm that the model receives only `run_code` in PTC mode. + +```yaml +- id: tool-executor-native + disabled: true + +- id: tool-executor-ptc + disabled: false +``` + +```bash +dsh --profile headless --patch ./ptc.yml "task" +``` diff --git a/docs/upgrade-guide/v0.2.1-rc.2/tool-executor-plugins/guide.zh.md b/docs/upgrade-guide/v0.2.1-rc.2/tool-executor-plugins/guide.zh.md new file mode 100644 index 0000000000..132e886e31 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.2/tool-executor-plugins/guide.zh.md @@ -0,0 +1,35 @@ +--- +kind: upgrade-guide +description: "工具执行模式从 dsh-tools 配置移至显式执行器插件。" +--- + +# 挂载工具执行器 + +[English](guide.md) | 中文 + +## 变更 + +`@deepseek-ai/dsh-tools` 保留工具注册和执行器选择,但加载时会拒绝已移除的 `mode` 和 `maxParallelSubCalls` 配置字段。原生和 PTC 执行分别要求 `@deepseek-ai/dsh-tool-executor-native` 或 `@deepseek-ai/dsh-tool-executor-ptc`。内置 profile 已挂载执行器,自定义组合必须添加一个。现有 `dsh-agent-tool-presentation` 选择器仍受支持,但已弃用。启用的 `tool-executor-native` 和 `tool-executor-ptc` 条目属于启动必需项;无效的执行器配置会终止启动。Web、headless 与 SDK 不再读取 `DSH_TOOLS_MODE`,均继承 base 的原生执行。 + +## 迁移 + +1. 保留 `dsh-tools` 条目并移除其执行配置。在目标部署或预设作用域挂载一个原生或 PTC 执行器。内置 profile 的补丁应修改执行器条目,而非 `tools` 条目。 +2. 把 `maxParallelSubCalls` 移到 PTC 执行器配置,并挂载兼容的 `ctx.ptcRuntime` 提供方。执行器将该服务声明为必需注入;缺少运行时会阻止启动,卸载运行时会撤回执行器。`agent-loop.maxParallelToolCalls` 和已保存的覆盖值保留原位,默认值为 `10`。循环向每个批次传入当前上限;实时修改从下一个批次生效,不再从下一个工具组生效。 +3. 自定义执行器实现 `describe` 和 `execute`。程序调用方传入原始 `calls` 和 `signal`,以及可选的 `agent` 和 `log: { turn, step }`;从 `results` 读取单次结果。不属于已提交模型步骤的调用应省略 `log`。 +4. 从 `@deepseek-ai/dsh-tool-executor-ptc` 导入 PTC SDK 渲染器和 `CodeRunFailedError`。用所选交互模式运行任务,确认工具结果出现在 Session 中。 +5. 从 `@deepseek-ai/dsh-tools/pipeline` 导入 `ToolExecutionPipeline` 和 `executeToolBatch`。完整执行(包括单次调用)使用批次;自定义调度器直接调用 `prepare`、`dispatch` 和 `complete`。把 `@deepseek-ai/dsh-tool-testkit` 添加为开发依赖,并从该包导入工具 fixture。工具注册表主入口不再导出这些成员。 +6. 从 `@deepseek-ai/dsh-tool-executor-ptc/types` 导入共享分派事件与 `ptc-mode` 消息来源声明;Client 代码使用其 `/client` 重导出。Host 策略扩展通过 `@deepseek-ai/dsh-tool-executor-ptc` 的 `import type` 获取 `PtcDispatchLog` 与 `tools/ptc-dispatch-log`。 + +7. 从启动环境中移除 `DSH_TOOLS_MODE`。需要 PTC 时,将以下内容保存为 `ptc.yml` 并通过 `--patch` 加载;原生执行无需 overlay。同一补丁也适用于 `--profile web` 和 `--profile sdk`;TypeScript 和 Python SDK 客户端通过 `patches` 传入其路径。确认 PTC 模式下模型只收到 `run_code`。 + +```yaml +- id: tool-executor-native + disabled: true + +- id: tool-executor-ptc + disabled: false +``` + +```bash +dsh --profile headless --patch ./ptc.yml "task" +``` diff --git a/docs/upgrade-guide/v0.2.1-rc.2/webserver-index-peer/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-rc.2/webserver-index-peer/guide.i18n.yaml new file mode 100644 index 0000000000..1a41ab4629 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.2/webserver-index-peer/guide.i18n.yaml @@ -0,0 +1,16 @@ +# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.1-rc.2/webserver-index-peer/guide.md +/: + en: 4182112137303167 + zh: 8451ce873cfca164 +/webserver-index-rendering-takes-a-peer: + en: 7986c2aaf1511f53 + zh: a6f78fc166e1ea19 +/webserver-index-rendering-takes-a-peer/change: + en: 07a11388305a0124 + zh: 662003b29c238d30 +/webserver-index-rendering-takes-a-peer/migration: + en: 7ef144b29ffb2f39 + zh: defb62ecc4ba6c2e diff --git a/docs/upgrade-guide/v0.2.1-rc.2/webserver-index-peer/guide.md b/docs/upgrade-guide/v0.2.1-rc.2/webserver-index-peer/guide.md new file mode 100644 index 0000000000..42f8bdc773 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.2/webserver-index-peer/guide.md @@ -0,0 +1,17 @@ +--- +kind: upgrade-guide +description: "WebServer index rendering takes the Peer the page speaks for, and the `webserver/index-inject` event passes that Peer to listeners." +--- +# WebServer index rendering takes a Peer + +English | [中文](guide.zh.md) + +## Change + +`renderIndex(html)` and `collectIndexInjections()` on `ctx.webServer` from `@deepseek-ai/dsh-host-webserver` took no Peer, and every index carried the owner's access snapshot. They are now `renderIndex(html, peer)` and `collectIndexInjections(peer)`, and the `webserver/index-inject` event passes the same Peer as its second argument, so the remote-access snapshot in the index belongs to that Peer. Code that calls either method breaks at compile time; listeners that read only the table keep working. + +## Migration + +1. Pass the Peer the page speaks for to every `ctx.webServer.renderIndex(html)` and `ctx.webServer.collectIndexInjections()` call. A page served to the owner's browser passes `ctx.connection.browserPeer`. +2. In a test that emits `webserver/index-inject` itself, pass a Peer after the table: `ctx.emit('webserver/index-inject', table, peer)`. +3. Run your TypeScript build and confirm no call to either method reports a missing argument. diff --git a/docs/upgrade-guide/v0.2.1-rc.2/webserver-index-peer/guide.zh.md b/docs/upgrade-guide/v0.2.1-rc.2/webserver-index-peer/guide.zh.md new file mode 100644 index 0000000000..de6d1c58da --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.2/webserver-index-peer/guide.zh.md @@ -0,0 +1,17 @@ +--- +kind: upgrade-guide +description: "WebServer 渲染 index 时需要传入页面所代表的 Peer,`webserver/index-inject` 事件会把该 Peer 传给监听方。" +--- +# WebServer 渲染 index 需要传入 Peer + +[English](guide.md) | 中文 + +## 变更 + +`@deepseek-ai/dsh-host-webserver` 中 `ctx.webServer` 的 `renderIndex(html)` 与 `collectIndexInjections()` 不接收 Peer,每个 index 携带的都是 owner 的访问快照。现在它们改为 `renderIndex(html, peer)` 与 `collectIndexInjections(peer)`,`webserver/index-inject` 事件以第二个参数传入同一个 Peer,index 中的 remote-access 快照因此属于该 Peer。调用这两个方法的代码会在编译时报错;只读取表格的监听方不受影响。 + +## 迁移 + +1. 每处 `ctx.webServer.renderIndex(html)` 与 `ctx.webServer.collectIndexInjections()` 调用都传入页面所代表的 Peer。提供给 owner 浏览器的页面传入 `ctx.connection.browserPeer`。 +2. 自行 emit `webserver/index-inject` 的测试在表格之后传入一个 Peer:`ctx.emit('webserver/index-inject', table, peer)`。 +3. 运行 TypeScript 构建,确认这两个方法的调用都不再报缺少参数。 diff --git a/package.json b/package.json index cf59063403..5bc1973359 100644 --- a/package.json +++ b/package.json @@ -224,6 +224,8 @@ "devDependencies": { "@deepseek-ai/dsh-hooks-claude-code": "workspace:*", "@deepseek-ai/dsh-hooks-codex": "workspace:*", + "@deepseek-ai/dsh-tool-executor-native": "workspace:*", + "@deepseek-ai/dsh-tool-executor-ptc": "workspace:*", "@deepseek-ai/dsh-tool-ralph": "workspace:*", "@deepseek-ai/dsh-tool-terminal": "workspace:*", "@deepseek-ai/dsh-agent": "workspace:*", diff --git a/packages/acp/acp/package.json b/packages/acp/acp/package.json index a41bc2ec1f..ca9ad8609d 100644 --- a/packages/acp/acp/package.json +++ b/packages/acp/acp/package.json @@ -63,6 +63,7 @@ "@deepseek-ai/dsh-session-projection": "workspace:*", "@deepseek-ai/dsh-subagent": "workspace:*", "@deepseek-ai/dsh-token-meter": "workspace:*", + "@deepseek-ai/dsh-tool-testkit": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", "@deepseek-ai/dsh-user-approval": "workspace:*" } diff --git a/packages/acp/acp/tests/bridge.spec.ts b/packages/acp/acp/tests/bridge.spec.ts index b0911a4f9b..dcfcfddef6 100644 --- a/packages/acp/acp/tests/bridge.spec.ts +++ b/packages/acp/acp/tests/bridge.spec.ts @@ -9,7 +9,7 @@ import { ToolCallId, type StreamChunk } from '@deepseek-ai/dsh-llm' import { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session' import type { SessionHeader } from '@deepseek-ai/dsh-session' import { SessionPersistenceRevision, type SessionPersistenceSnapshot } from '@deepseek-ai/dsh-session-persistence' -import { defineContentToolFixture } from '@deepseek-ai/dsh-tools' +import { defineContentToolFixture } from '@deepseek-ai/dsh-tool-testkit' import { makeBridgeHarness, textResponse, type BridgeHarness } from './harness.ts' import { startHttpMcpFixture } from '../../../mcp/mcp-client/tests/http-fixture.ts' diff --git a/packages/acp/acp/tests/edges.spec.ts b/packages/acp/acp/tests/edges.spec.ts index 8df1a98ad5..e0885db03d 100644 --- a/packages/acp/acp/tests/edges.spec.ts +++ b/packages/acp/acp/tests/edges.spec.ts @@ -3,7 +3,7 @@ import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk' import { createUserMessage, ToolCallId, type StreamChunk } from '@deepseek-ai/dsh-llm' import type { ContextFormed } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' -import { defineContentToolFixture } from '@deepseek-ai/dsh-tools' +import { defineContentToolFixture } from '@deepseek-ai/dsh-tool-testkit' import { makeBridgeHarness, textResponse, type BridgeHarness } from './harness.ts' declare module '@deepseek-ai/dsh-llm' { diff --git a/packages/acp/acp/tests/turns.spec.ts b/packages/acp/acp/tests/turns.spec.ts index 23a412da93..e85c2553d9 100644 --- a/packages/acp/acp/tests/turns.spec.ts +++ b/packages/acp/acp/tests/turns.spec.ts @@ -7,7 +7,7 @@ import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk' import { SessionId } from '@deepseek-ai/dsh-session' import { setImmediate } from 'node:timers/promises' import SubagentRuntime, { NO_START_CAPABILITIES } from '@deepseek-ai/dsh-subagent' -import { defineContentToolFixture } from '@deepseek-ai/dsh-tools' +import { defineContentToolFixture } from '@deepseek-ai/dsh-tool-testkit' import { toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' import { errorResponse, diff --git a/packages/api/remote-access/README.i18n.yaml b/packages/api/remote-access/README.i18n.yaml index df8ad59235..461af0d1f9 100644 --- a/packages/api/remote-access/README.i18n.yaml +++ b/packages/api/remote-access/README.i18n.yaml @@ -15,11 +15,11 @@ en: a18ecc232e3c3933 zh: b8e43580403f6d77 /deepseek-ai-dsh-api-remote-access/host-service-remoteaccessservice-ctx-key-remoteaccess: - en: 9175835ea5c5bff1 - zh: 0e33b266fdac3d13 + en: 8a4be795c7aa2ef0 + zh: 02550b975e2e8d61 /deepseek-ai-dsh-api-remote-access/client-face: - en: 1db01235b6af84fa - zh: c0ced511cc1841a0 + en: d4cf8f646fdf2d39 + zh: 247f643e991aa806 /deepseek-ai-dsh-api-remote-access/model-experience: en: 8b07250cecacd3aa zh: d28ab93980bdcdad diff --git a/packages/api/remote-access/README.md b/packages/api/remote-access/README.md index 8d9df9e542..ab35a6b598 100644 --- a/packages/api/remote-access/README.md +++ b/packages/api/remote-access/README.md @@ -43,7 +43,7 @@ When `bindings()`, a condition, or `explain()` throws, the decision is refused w A forwarded event is decided as the action `event:` on the resources its forwarding entry's readers name, or on `global` when the entry declares none, and reaches a non-owner whole or not at all; its payload is never trimmed. An event carries every resource it declares, so a reader that returns `undefined` makes the event's resources invalid, and the Gateway delivers it to no non-owner without asking `decide()`. An event names at most one resource, so collection changes travel on follow streams (`session/follow`, `session/control`, `workspace/follow`), whose methods decide each item inline through `allows()`. -The service adds a `global` row named `REMOTE_ACCESS_SNAPSHOT_KEY` (`__DSH_REMOTE_ACCESS__`) holding the owner baseline `{ type: 'baseline', owner: true }` to every index the webServer renders through `webserver/index-inject`. A business package that renders a non-owner page, such as a liveshare viewer page rendered on the owner Host, places an assignment of that global to `ctx.remoteAccess.snapshotFor(principal)` after every index injection row, for example a `global` row appended to the end of `ctx.webServer.collectIndexInjections()`. The later assignment replaces the owner baseline, and the Client entry reads the global when it mounts, so the page opens with the viewer's grants instead of the owner's. +The service adds a `global` row named `REMOTE_ACCESS_SNAPSHOT_KEY` (`__DSH_REMOTE_ACCESS__`) holding `snapshotFor(peer.principal)` to every index the webServer renders through `webserver/index-inject`, for the Peer that render speaks for: the owner's browser page opens with `{ type: 'baseline', owner: true }`, and a page rendered for a non-owner Peer, such as a liveshare viewer page rendered on the owner Host, opens with that viewer's grants. The Client entry reads the global when it mounts. | `details.reason` of `remote-access/denied` | Cause | |---|---| @@ -60,9 +60,10 @@ Snapshot grants list, per resource the principal's bindings name, the sorted end The Client entry reads the baseline the index injected under `REMOTE_ACCESS_SNAPSHOT_KEY` synchronously, mounts the generated `remoteAccess` contribution itself through `mountAccess(ctx, contribution, initial)`, follows the snapshot stream, and provides: - the Client service `ctx.remoteAccess.source(kind, id, action)`, an identity-stable boolean source per `(kind, id, action)` for one resource of a declared kind; +- `ctx.remoteAccess.whenAccess(action, callback)`, which keeps the callback's effects installed while the `global` resource allows `action`: the callback runs when the answer becomes true, its effects dispose in reverse order when it becomes false, and it runs again on the next true. The callback returns one disposer or an iterable of disposers, as with `ctx.slots.inject`; it runs synchronously when the answer is already true, as it always is for the owner; the controller belongs to the caller's fiber. A setup failure while allowed throws at once; a later one retires the controller and is rethrown in a microtask. `whenResourceAccess(kind, id, action, callback)` is the same for one resource of a declared kind; - the standard Hook `useRemoteAccess(action)`, which answers on the `global` resource. -Domain UI packages publish their resource Hooks over `source()`: [ui-session](../../client/ui-session/README.md) provides `useSessionAccess(sessionId, action)` and the Session-scope `useAccess(action)` on Session resources, a different name from the `global` `useRemoteAccess`, and [ui-workspace](../../client/ui-workspace/README.md) provides `useWorkspaceAccess(workspaceId, action)` on Workspace resources. A Hook's `action` is a `RemoteEndpoint`, an endpoint some Client Remote namespace declares. A non-owner answer is whether that resource's grant lists the action and never falls back to the `global` answer, so a resource without a grant, or whose grant lists no action, answers false. Every answer is true for the owner; a non-owner page without an injected baseline, and a non-owner page whose stream fails terminally, answers false; the terminal failure is reported with its error, for a non-owner with the advice to reload the page and for the owner with the note that its answers are unaffected; a reconnect keeps the last snapshot. A malformed injected baseline fails the mount. Hooks never issue requests, and the Host still checks every call. [`dsh-client-test-runtime`](../../test-support/client-runtime/README.md) replaces this row with allow-all access in Client specs. +Domain UI packages publish their resource Hooks over `source()` and their `whenAccess` over `whenResourceAccess()`: [ui-session](../../client/ui-session/README.md) provides `useSessionAccess(sessionId, action)`, the Session-scope `useAccess(action)`, and `ctx.uiSession.whenAccess(sessionId, action, callback)` on Session resources, a different name from the `global` `useRemoteAccess`, and [ui-workspace](../../client/ui-workspace/README.md) provides `useWorkspaceAccess(workspaceId, action)` and `ctx.uiWorkspace.whenAccess(workspaceId, action, callback)` on Workspace resources. Only the package that owns a resource kind calls `source()` and `whenResourceAccess()`; other plugins read access through the access Hooks and the `whenAccess` methods. A Hook's `action` is a `RemoteEndpoint`, an endpoint some Client Remote namespace declares. A non-owner answer is whether that resource's grant lists the action and never falls back to the `global` answer, so a resource without a grant, or whose grant lists no action, answers false. Every answer is true for the owner; a non-owner page without an injected baseline, and a non-owner page whose stream fails terminally, answers false; the terminal failure is reported with its error, for a non-owner with the advice to reload the page and for the owner with the note that its answers are unaffected; a reconnect keeps the last snapshot. A malformed injected baseline fails the mount. Hooks never issue requests, and the Host still checks every call. [`dsh-client-test-runtime`](../../test-support/client-runtime/README.md) replaces this row with fixed answers, allow-all by default, in Client specs. ## Model Experience diff --git a/packages/api/remote-access/README.zh.md b/packages/api/remote-access/README.zh.md index 8d593f7132..514f885b33 100644 --- a/packages/api/remote-access/README.zh.md +++ b/packages/api/remote-access/README.zh.md @@ -43,7 +43,7 @@ Gateway 依次从 `@Remote` 与 `@RemoteScope` 的 `resources` 读取函数、lo 转发事件按其转发条目的读取函数指明的资源(条目未声明时为 `global`)判定 action `event:`,对非 owner 整条送达或整条不送,从不裁剪 payload。事件携带其声明的每个资源,因此读取函数返回 `undefined` 时该事件的资源非法,Gateway 不询问 `decide()`,不送达任何非 owner。一个事件至多指明一个资源,因此集合类变化走 follow 类流(`session/follow`、`session/control`、`workspace/follow`),由这些方法内联经 `allows()` 逐项判定。 -本服务经 `webserver/index-inject` 为 webServer 渲染的每个 index 加入名为 `REMOTE_ACCESS_SNAPSHOT_KEY`(`__DSH_REMOTE_ACCESS__`)的 `global` 行,值为 owner baseline `{ type: 'baseline', owner: true }`。渲染非 owner 页面的业务包(例如在 owner Host 上渲染 liveshare 读者页)在所有 index 注入行之后放置一条把该全局赋值为 `ctx.remoteAccess.snapshotFor(principal)` 的语句,例如追加到 `ctx.webServer.collectIndexInjections()` 末尾的一条 `global` 行。后写的赋值覆盖 owner baseline,Client 入口在挂载时读取该全局,因此页面以读者的 grant 而非 owner 的快照打开。 +本服务经 `webserver/index-inject` 为 webServer 渲染的每个 index 加入名为 `REMOTE_ACCESS_SNAPSHOT_KEY`(`__DSH_REMOTE_ACCESS__`)的 `global` 行,值为该次渲染所代表 Peer 的 `snapshotFor(peer.principal)`:owner 的浏览器页面以 `{ type: 'baseline', owner: true }` 打开,为非 owner Peer 渲染的页面(例如在 owner Host 上渲染的 liveshare 读者页)以该读者的 grant 打开。Client 入口在挂载时读取该全局。 | `remote-access/denied` 的 `details.reason` | 原因 | |---|---| @@ -60,9 +60,10 @@ Gateway 依次从 `@Remote` 与 `@RemoteScope` 的 `resources` 读取函数、lo Client 入口同步读取 index 注入在 `REMOTE_ACCESS_SNAPSHOT_KEY` 下的 baseline,经 `mountAccess(ctx, contribution, initial)` 自行挂载生成的 `remoteAccess` contribution,订阅快照流,并提供: - Client 服务 `ctx.remoteAccess.source(kind, id, action)`,为已声明种类的某个资源上的每个 `(kind, id, action)` 提供一个身份稳定的布尔 source; +- `ctx.remoteAccess.whenAccess(action, callback)`,在 `global` 资源允许 `action` 期间保持回调的效果:答案变为 true 时执行回调,变为 false 时逆序撤销其效果,下一次变为 true 时再次执行。回调返回一个 disposer 或一组 disposer,与 `ctx.slots.inject` 相同;答案已为 true 时(owner 恒为 true)回调同步执行;控制器属于调用方 fiber。允许期间的安装失败立即抛出;之后的安装失败使控制器停止,并在微任务中重新抛出。`whenResourceAccess(kind, id, action, callback)` 对已声明种类的某个资源做同样的事; - 标准 Hook `useRemoteAccess(action)`,回答 `global` 资源上的访问。 -领域 UI 包在 `source()` 之上发布各自资源的 Hook:[ui-session](../../client/ui-session/README.zh.md) 提供 Session 资源上的 `useSessionAccess(sessionId, action)` 与会话作用域的 `useAccess(action)`(与 `global` 资源上的 `useRemoteAccess` 不同名),[ui-workspace](../../client/ui-workspace/README.zh.md) 提供 Workspace 资源上的 `useWorkspaceAccess(workspaceId, action)`。Hook 的 `action` 类型为 `RemoteEndpoint`,即某个 Client Remote namespace 声明的端点。非 owner 的答案是该资源的 grant 是否列出该 action,不回退 `global` 答案,因此没有 grant 或 grant 未列出任何 action 的资源回答 false。owner 的所有答案为 true;没有注入 baseline 的非 owner 页面、快照流终止失败的非 owner 页面,所有答案为 false;终止失败会连同错误一并报告,非 owner 附“刷新页面重连”的提示,owner 附“答案不受影响”的说明;重连期间保留上次快照。注入的 baseline 格式错误时挂载失败。Hook 从不发请求,Host 仍检查每次调用。[`dsh-client-test-runtime`](../../test-support/client-runtime/README.zh.md) 在 Client spec 中以全允许的访问替换本行。 +领域 UI 包在 `source()` 之上发布各自资源的 Hook,在 `whenResourceAccess()` 之上发布各自的 `whenAccess`:[ui-session](../../client/ui-session/README.zh.md) 提供 Session 资源上的 `useSessionAccess(sessionId, action)`、会话作用域的 `useAccess(action)` 与 `ctx.uiSession.whenAccess(sessionId, action, callback)`(与 `global` 资源上的 `useRemoteAccess` 不同名),[ui-workspace](../../client/ui-workspace/README.zh.md) 提供 Workspace 资源上的 `useWorkspaceAccess(workspaceId, action)` 与 `ctx.uiWorkspace.whenAccess(workspaceId, action, callback)`。只有拥有某个资源种类的包才调用 `source()` 与 `whenResourceAccess()`;其他插件经访问 Hook 与各 `whenAccess` 读取访问。Hook 的 `action` 类型为 `RemoteEndpoint`,即某个 Client Remote namespace 声明的端点。非 owner 的答案是该资源的 grant 是否列出该 action,不回退 `global` 答案,因此没有 grant 或 grant 未列出任何 action 的资源回答 false。owner 的所有答案为 true;没有注入 baseline 的非 owner 页面、快照流终止失败的非 owner 页面,所有答案为 false;终止失败会连同错误一并报告,非 owner 附“刷新页面重连”的提示,owner 附“答案不受影响”的说明;重连期间保留上次快照。注入的 baseline 格式错误时挂载失败。Hook 从不发请求,Host 仍检查每次调用。[`dsh-client-test-runtime`](../../test-support/client-runtime/README.zh.md) 在 Client spec 中以固定答案(默认全允许)的访问替换本行。 ## 模型体验 diff --git a/packages/api/remote-access/src/client/index.ts b/packages/api/remote-access/src/client/index.ts index 396f56a16a..f3e5123b5b 100644 --- a/packages/api/remote-access/src/client/index.ts +++ b/packages/api/remote-access/src/client/index.ts @@ -2,8 +2,11 @@ * Client face: starts from the access snapshot the index injected, follows * the calling Peer's snapshot stream, provides the `remoteAccess` Client * service whose `source(kind, id, action)` answers for any declared-kind - * resource, and publishes `useRemoteAccess(action)` on the global resource. - * Domain UI adapters publish their own resource Hooks over `source()`. Every + * resource and whose `whenAccess(action, callback)` keeps effects installed + * while the global resource allows an action, and publishes + * `useRemoteAccess(action)` on the global resource. Domain UI adapters + * publish their own resource Hooks over `source()` and their own + * `whenAccess` over `whenResourceAccess()`. Every * answer is a boolean: true for the owner, false without a snapshot or after * a non-owner stream fails; the Host enforces every call regardless. * @module @deepseek-ai/dsh-api-remote-access/client @@ -18,7 +21,7 @@ import { parseInjectedBaseline } from './state.ts' export type { RemoteAccessAction, RemoteAccessEndpoint, RemoteAccessFrame, RemoteAccessGrant, RemoteAccessResource, TypertResourceKind, } from '../types.ts' -export type { ClientRemoteAccess, RemoteEndpoint } from './mount.ts' +export type { AccessEffect, ClientRemoteAccess, RemoteEndpoint } from './mount.ts' /** Required Client services: the Remote mount face and the Slot registry. */ export const inject = ['remote', 'slots'] diff --git a/packages/api/remote-access/src/client/mount.ts b/packages/api/remote-access/src/client/mount.ts index 257aa396c2..68b868a8a7 100644 --- a/packages/api/remote-access/src/client/mount.ts +++ b/packages/api/remote-access/src/client/mount.ts @@ -19,6 +19,9 @@ import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' import type { TypertRemoteContribution, TypertRemoteMap, TypertResourceKind } from '@deepseek-ai/dsh-typert-protocol' import type { RemoteAccessFrame } from '../types.ts' import { ClientAccessState, type Baseline } from './state.ts' +import { whenAllowed, type AccessEffect } from './when-allowed.ts' + +export type { AccessEffect } from './when-allowed.ts' /** Every endpoint the Client Remote namespaces declare. */ export type RemoteEndpoint = Extract @@ -29,12 +32,38 @@ export interface ClientRemoteAccess { * Whether the current Peer may call `action` on one resource. * True for the owner; false without a baseline and after a non-owner * stream fails; otherwise whether the resource's grant lists the action. + * Only the package that owns a resource kind calls it, to build that kind's + * access Hook; other plugins read access through the access Hooks or + * `whenAccess`. * @param kind - resource kind declared in `TypertResourceMap`. * @param id - resource identity. * @param action - endpoint action the caller would invoke. * @returns identity-stable boolean source for this `(kind, id, action)`. */ source(kind: TypertResourceKind, id: string, action: string): HostObservable + /** + * Keep `callback`'s effects installed while the current Peer may call + * `action` on the global resource: run it when the answer becomes true, + * dispose its effects in reverse order when it becomes false, and run it + * again on the next true. The callback runs synchronously when the answer + * is already true, as it always is for the owner. Effect forms and fiber + * ownership match `ctx.slots.inject`. + * @param action - endpoint action the guarded effects depend on. + * @param callback - creates one disposer or an iterable of disposers. + * @returns idempotent disposer for the observation and any installed effects. + */ + whenAccess(action: RemoteEndpoint, callback: () => AccessEffect): () => void + /** + * {@link whenAccess} for one resource of a declared kind. Only the package + * that owns a resource kind calls it, to build that kind's `whenAccess`, as + * it builds the kind's access Hook over {@link source}. + * @param kind - resource kind declared in `TypertResourceMap`. + * @param id - resource identity. + * @param action - endpoint action the guarded effects depend on. + * @param callback - creates one disposer or an iterable of disposers. + * @returns idempotent disposer for the observation and any installed effects. + */ + whenResourceAccess(kind: TypertResourceKind, id: string, action: RemoteEndpoint, callback: () => AccessEffect): () => void } declare module '@deepseek-ai/cordis' { @@ -66,6 +95,16 @@ class ClientRemoteAccessService extends Service implements ClientRemoteAccess { source(kind: TypertResourceKind, id: string, action: string): HostObservable { return this.state.resource(kind, id, action) } + + // Prototype methods: the Cordis service proxy binds `this.ctx` to the caller's Context. + whenAccess(action: RemoteEndpoint, callback: () => AccessEffect): () => void { + return whenAllowed(this.ctx, this.state.global(action), callback, `remoteAccess.whenAccess(${JSON.stringify(action)})`) + } + + whenResourceAccess(kind: TypertResourceKind, id: string, action: RemoteEndpoint, callback: () => AccessEffect): () => void { + return whenAllowed(this.ctx, this.state.resource(kind, id, action), callback, + `remoteAccess.whenResourceAccess(${JSON.stringify(kind)}, ${JSON.stringify(id)}, ${JSON.stringify(action)})`) + } } /** diff --git a/packages/api/remote-access/src/client/when-allowed.ts b/packages/api/remote-access/src/client/when-allowed.ts new file mode 100644 index 0000000000..e1c2ba890c --- /dev/null +++ b/packages/api/remote-access/src/client/when-allowed.ts @@ -0,0 +1,85 @@ +/** + * The effect controller behind `whenAccess`: install a callback's effects + * while one access answer is true and dispose them while it is false. + * @module @deepseek-ai/dsh-api-remote-access/src/client/when-allowed + */ + +import type { Context } from '@deepseek-ai/cordis' +import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots' + +/** One synchronous effect installed while access holds: a disposer, or disposers installed as one transaction. */ +export type AccessEffect = (() => void) | Iterable<() => void, void, void> + +/** + * Install `callback`'s effects while `answer` is true. The callback runs + * synchronously when the answer is already true; a later true runs it again + * after a false disposed the previous installation in reverse order. The + * controller belongs to `ctx`'s fiber. + * @param ctx - the caller's Context; it owns the controller and every installation. + * @param answer - the access answer to follow. + * @param callback - creates one disposer or an iterable of disposers. + * @param label - effect label for diagnostics. + * @returns idempotent disposer for the controller and any installed effects. + * @throws callback setup failures synchronously when the answer is already true. + */ +export function whenAllowed( + ctx: Context, + answer: HostObservable, + callback: () => AccessEffect, + label: string, +): () => void { + const disposeController = ctx.effect(() => { + let installed: (() => void) | undefined + let stopped = false + + const uninstall = (): void => { + const dispose = installed + installed = undefined + dispose?.() + } + + const reconcile = (): void => { + if (stopped) return + const allowed = answer.getSnapshot() + if (allowed === (installed !== undefined)) return + if (!allowed) { + uninstall() + return + } + // Each installation is a nested Cordis effect, so an iterable callback + // installs transactionally and disposes in reverse order. + const disposeEffect = ctx.effect(callback, `${label}: allowed`) + installed = () => { void disposeEffect() } + } + + const unsubscribe = answer.subscribe(() => { changed() }) + + const stop = (): void => { + if (stopped) return + // A failed installation retires the controller: a later grant never retries it. + stopped = true + unsubscribe() + uninstall() + } + + const changed = (): void => { + try { + reconcile() + } catch (error) { + stop() + if ((error as { code?: unknown } | null)?.code === 'INACTIVE_EFFECT') return + const failure = error instanceof Error ? error : new Error(String(error)) + queueMicrotask(() => { throw failure }) + } + } + + try { + reconcile() + } catch (error) { + stop() + throw error + } + return stop + }, label) + return () => { void disposeController() } +} diff --git a/packages/api/remote-access/src/index.ts b/packages/api/remote-access/src/index.ts index 5b3cf41e0f..98ee668ca4 100644 --- a/packages/api/remote-access/src/index.ts +++ b/packages/api/remote-access/src/index.ts @@ -134,10 +134,10 @@ export class RemoteAccessService extends TypertRemoteService { constructor(ctx: Context) { super(ctx, 'remoteAccess') ctx.effect(() => () => { this.lifetime.abort() }, 'remoteAccess: snapshot generations') - // Every index the webServer renders belongs to the owner; snapshotFor documents non-owner pages. + // Every index opens with the access snapshot of the Peer it speaks for. ctx.inject(['webServer'], (webCtx) => { - webCtx.on('webserver/index-inject', (table) => { - table.push({ kind: 'global', name: REMOTE_ACCESS_SNAPSHOT_KEY, value: OWNER_BASELINE }) + webCtx.on('webserver/index-inject', (table, peer) => { + table.push({ kind: 'global', name: REMOTE_ACCESS_SNAPSHOT_KEY, value: this.snapshotFor(peer.principal) }) }) }) } @@ -244,10 +244,9 @@ export class RemoteAccessService extends TypertRemoteService { } /** - * The baseline a page opens with before its snapshot stream connects. A - * business package rendering a non-owner page assigns this value to - * {@link REMOTE_ACCESS_SNAPSHOT_KEY} after every index injection row, - * replacing the owner baseline the webServer index carries. + * The baseline a page opens with before its snapshot stream connects; every + * index the webServer renders carries it under + * {@link REMOTE_ACCESS_SNAPSHOT_KEY} for the Peer the page speaks for. * @param principal - the page's caller identity. * @returns the owner baseline for the owner; otherwise the principal's current grants. */ diff --git a/packages/api/remote-access/tests/client-access.client.spec.ts b/packages/api/remote-access/tests/client-access.client.spec.ts index b73944ec58..5a70579b01 100644 --- a/packages/api/remote-access/tests/client-access.client.spec.ts +++ b/packages/api/remote-access/tests/client-access.client.spec.ts @@ -5,6 +5,7 @@ import type { TypertRemoteContribution, TypertResource } from '@deepseek-ai/dsh- import type { RemoteAccessFrame, RemoteAccessGrant } from '../src/types.ts' import { ClientAccessState, parseInjectedBaseline, type Baseline } from '../src/client/state.ts' import { mountAccess } from '../src/client/mount.ts' +import { whenAllowed } from '../src/client/when-allowed.ts' declare module '@deepseek-ai/dsh-typert-protocol' { interface TypertResourceMap { @@ -12,6 +13,10 @@ declare module '@deepseek-ai/dsh-typert-protocol' { 'test-shelf': TypertResource 'test-desk': TypertResource } + interface TypertRemoteMap { + /** Test-only endpoint the `whenAccess` specs guard effects on. */ + 'test/guarded': () => Promise + } } const GRANTED: Extract = { @@ -289,3 +294,223 @@ describe('remote-access Client mount', () => { await access.dispose() }) }) + +/** A baseline whose grants allow `test/guarded` on the listed resources. */ +function followOn(...resources: RemoteAccessGrant['resource'][]): Extract { + return { type: 'baseline', owner: false, grants: resources.map(resource => ({ resource, actions: ['test/guarded'] })) } +} + +describe('remote-access whenAccess', () => { + it('installs the effects while the global resource allows the action, disposes them in reverse order on loss, and reinstalls on the next grant', async () => { + const access = await mounted() + const log: string[] = [] + const guarded = access.ctx.plugin({ + inject: ['remoteAccess'], + apply: (ctx: Context) => { + ctx.remoteAccess.whenAccess('test/guarded', function* () { + log.push('install') + yield () => { log.push('dispose first') } + yield () => { log.push('dispose second') } + }) + }, + }) + await guarded.await() + expect(log).toEqual([]) + + access.stream().push(followOn({ kind: 'global' })) + await vi.waitFor(() => { expect(log).toEqual(['install']) }) + // A frame that leaves the answer unchanged installs nothing again. + access.stream().push({ type: 'upsert', grant: { resource: { kind: 'test-shelf', id: 's1' }, actions: ['session/fork'] } }) + access.stream().push({ type: 'upsert', grant: { resource: { kind: 'global' }, actions: ['session/list'] } }) + await vi.waitFor(() => { expect(log).toEqual(['install', 'dispose second', 'dispose first']) }) + access.stream().push({ type: 'upsert', grant: { resource: { kind: 'global' }, actions: ['test/guarded'] } }) + await vi.waitFor(() => { expect(log).toHaveLength(4) }) + expect(log[3]).toBe('install') + + await guarded.dispose() + expect(log.slice(4)).toEqual(['dispose second', 'dispose first']) + await access.dispose() + }) + + it('runs the callback at once for the owner and disposes it with the returned disposer', async () => { + const access = await mounted({ type: 'baseline', owner: true }) + const dispose = vi.fn() + const callback = vi.fn(() => dispose) + const stop = access.ctx.remoteAccess.whenAccess('test/guarded', callback) + expect(callback).toHaveBeenCalledOnce() + stop() + await vi.waitFor(() => { expect(dispose).toHaveBeenCalledOnce() }) + await access.dispose() + }) + + it('follows one resource of a declared kind through whenResourceAccess', async () => { + const access = await mounted(followOn({ kind: 'test-shelf', id: 's1' })) + const dispose = vi.fn() + const shelf = vi.fn(() => dispose) + const desk = vi.fn(() => () => {}) + access.ctx.remoteAccess.whenResourceAccess('test-shelf', 's1', 'test/guarded', shelf) + access.ctx.remoteAccess.whenResourceAccess('test-desk', 's1', 'test/guarded', desk) + expect(shelf).toHaveBeenCalledOnce() + expect(desk).not.toHaveBeenCalled() + access.stream().push({ type: 'remove', resource: { kind: 'test-shelf', id: 's1' } }) + await vi.waitFor(() => { expect(dispose).toHaveBeenCalledOnce() }) + await access.dispose() + }) + + it('disposes a non-owner\'s effects once its stream fails terminally', async () => { + const access = await mounted(followOn({ kind: 'global' })) + vi.spyOn(access.ctx.logger, 'error').mockImplementation(() => undefined) + const dispose = vi.fn() + access.ctx.remoteAccess.whenAccess('test/guarded', () => dispose) + access.stream().fail(new Error('carrier closed for good')) + await vi.waitFor(() => { expect(dispose).toHaveBeenCalledOnce() }) + await access.dispose() + }) + + it('throws a setup failure at once while allowed, and reports a later one without retrying it', async () => { + const owner = await mounted({ type: 'baseline', owner: true }) + expect(() => owner.ctx.remoteAccess.whenAccess('test/guarded', () => { throw new Error('setup failed') })) + .toThrow('setup failed') + await owner.dispose() + + const access = await mounted() + const failures: unknown[] = [] + const onLoud = (error: unknown): void => { failures.push(error) } + process.on('uncaughtException', onLoud) + try { + const failing = vi.fn((): () => void => { throw new Error('late failure') }) + const throwsValue = vi.fn(function* (): Generator<() => void> { + yield () => {} + throw new Error('late value') + }) + const later = vi.fn(() => () => {}) + access.ctx.remoteAccess.whenAccess('test/guarded', failing) + access.ctx.remoteAccess.whenAccess('test/guarded', throwsValue) + access.ctx.remoteAccess.whenAccess('test/guarded', later) + access.stream().push(followOn({ kind: 'global' })) + await vi.waitFor(() => { expect(failures).toHaveLength(2) }) + expect(failures.map(String)).toEqual(['Error: late failure', 'Error: late value']) + expect(later).toHaveBeenCalledOnce() + access.stream().push({ type: 'remove', resource: { kind: 'global' } }) + access.stream().push({ type: 'upsert', grant: { resource: { kind: 'global' }, actions: ['test/guarded'] } }) + await vi.waitFor(() => { expect(later).toHaveBeenCalledTimes(2) }) + expect(failing).toHaveBeenCalledOnce() + expect(throwsValue).toHaveBeenCalledOnce() + } finally { + process.off('uncaughtException', onLoud) + } + await access.dispose() + }) + + it('skips a controller another installation stopped during the same notification', async () => { + const access = await mounted() + let stopLater = (): void => {} + const first = vi.fn(() => { + stopLater() + return () => {} + }) + const later = vi.fn(() => () => {}) + access.ctx.remoteAccess.whenAccess('test/guarded', first) + stopLater = access.ctx.remoteAccess.whenAccess('test/guarded', later) + access.stream().push(followOn({ kind: 'global' })) + await vi.waitFor(() => { expect(first).toHaveBeenCalledOnce() }) + expect(later).not.toHaveBeenCalled() + await access.dispose() + }) + + it('installs nothing for a grant that arrives while its caller is unloading', async () => { + const access = await mounted() + const callback = vi.fn(() => () => {}) + let release = (): void => {} + const blocked = new Promise((resolve) => { release = resolve }) + const pauseUnload = vi.fn(async () => { await blocked }) + const caller = access.ctx.plugin({ + inject: ['remoteAccess'], + apply: (ctx: Context) => { + ctx.remoteAccess.whenAccess('test/guarded', callback) + ctx.effect(() => pauseUnload, 'pause caller unload') + }, + }) + await caller.await() + const disposing = caller.dispose() + await vi.waitFor(() => { expect(pauseUnload).toHaveBeenCalledOnce() }) + access.stream().push(followOn({ kind: 'global' })) + await vi.waitFor(() => { expect(value(access.global('test/guarded'))).toBe(true) }) + expect(callback).not.toHaveBeenCalled() + release() + await disposing + await access.dispose() + }) +}) + +/** A hand-driven access answer that notifies synchronously. */ +function drivenAnswer(): { readonly answer: HostObservable; set(allowed: boolean): void } { + let allowed = false + const listeners = new Set<() => void>() + return { + answer: { + getSnapshot: () => allowed, + subscribe: (listener) => { + listeners.add(listener) + return () => { listeners.delete(listener) } + }, + }, + set(next) { + allowed = next + for (const listener of [...listeners]) listener() + }, + } +} + +describe('whenAllowed', () => { + it('installs nothing when the answer turns true while its caller is unloading', async () => { + const root = new Context() + const driven = drivenAnswer() + const callback = vi.fn(() => () => {}) + const caller = root.plugin({ + apply: (ctx: Context) => { + whenAllowed(ctx, driven.answer, callback, 'test') + // Disposed before the controller: it turns the answer true mid-unload. + ctx.effect(() => () => { driven.set(true) }, 'grant during unload') + }, + }) + await caller.await() + await caller.dispose() + expect(callback).not.toHaveBeenCalled() + await root.fiber.dispose() + }) + + it('ignores its disposer after a later failure retired it, and reports a non-Error failure as an Error', async () => { + const root = new Context() + const driven = drivenAnswer() + const failures: unknown[] = [] + const onLoud = (error: unknown): void => { failures.push(error) } + process.on('uncaughtException', onLoud) + try { + const stop = whenAllowed(root, driven.answer, () => { throw new Error('late failure') }, 'test') + driven.set(true) + await vi.waitFor(() => { expect(failures).toHaveLength(1) }) + expect(() => { stop() }).not.toThrow() + // A faulty answer that throws a non-Error value is reported as an Error naming it. + let reads = 0 + const listeners: (() => void)[] = [] + whenAllowed(root, { + getSnapshot: () => { + reads++ + if (reads > 1) throw 'unreadable answer' + return false + }, + subscribe: (listener) => { + listeners.push(listener) + return () => {} + }, + }, () => () => {}, 'test') + for (const listener of listeners) listener() + await vi.waitFor(() => { expect(failures).toHaveLength(2) }) + expect(String(failures[1])).toBe('Error: unreadable answer') + } finally { + process.off('uncaughtException', onLoud) + } + await root.fiber.dispose() + }) +}) diff --git a/packages/api/remote-access/tests/remote-access.host.spec.ts b/packages/api/remote-access/tests/remote-access.host.spec.ts index f29d4d1744..101991e0c4 100644 --- a/packages/api/remote-access/tests/remote-access.host.spec.ts +++ b/packages/api/remote-access/tests/remote-access.host.spec.ts @@ -400,18 +400,27 @@ describe('first snapshots', () => { expect(REMOTE_ACCESS_SNAPSHOT_KEY).toBe('__DSH_REMOTE_ACCESS__') }) - it('injects the owner baseline into every index the webServer renders', async () => { - const { ctx } = await setup() - const render = (): IndexInjection[] => { + it('injects the access snapshot of the Peer each index speaks for', async () => { + const { ctx, access } = await setup() + const policy = new GuestPolicy() + policy.hold(GUEST, { role: READER, resource: GLOBAL }) + access.registerPolicy('test-guest', policy) + const render = (principal: Principal): IndexInjection[] => { const table: IndexInjection[] = [] - ctx.emit('webserver/index-inject', table) + ctx.emit('webserver/index-inject', table, new Peer(ctx, { principal })) return table } - expect(render()).toEqual([]) + expect(render(OWNER)).toEqual([]) ctx.provide('webServer', {} as Context['webServer']) await vi.waitFor(() => { - expect(render()).toEqual([{ kind: 'global', name: '__DSH_REMOTE_ACCESS__', value: { type: 'baseline', owner: true } }]) + expect(render(OWNER)).toEqual([{ kind: 'global', name: '__DSH_REMOTE_ACCESS__', value: { type: 'baseline', owner: true } }]) }) + expect(render(GUEST)).toEqual([{ kind: 'global', name: '__DSH_REMOTE_ACCESS__', value: { + type: 'baseline', owner: false, grants: [{ resource: GLOBAL, actions: ['notes/read', 'notes/rooms', 'notes/write'] }], + } }]) + // A grant change reaches the next render. + policy.hold(GUEST) + expect(render(GUEST)).toEqual([{ kind: 'global', name: '__DSH_REMOTE_ACCESS__', value: { type: 'baseline', owner: false, grants: [] } }]) }) it('lists per bound resource the endpoint actions decide() allows there without arguments, and no grant where none is allowed', async () => { diff --git a/packages/api/remote-access/tsconfig.client.json b/packages/api/remote-access/tsconfig.client.json index 0ddeaeac30..c365c4f780 100644 --- a/packages/api/remote-access/tsconfig.client.json +++ b/packages/api/remote-access/tsconfig.client.json @@ -9,6 +9,7 @@ "src/client/index.ts", "src/client/mount.ts", "src/client/state.ts", + "src/client/when-allowed.ts", "src/snapshot.ts", "src/types.ts" ], diff --git a/packages/boot/app-boot/README.i18n.yaml b/packages/boot/app-boot/README.i18n.yaml index be702d4775..82f7210aef 100644 --- a/packages/boot/app-boot/README.i18n.yaml +++ b/packages/boot/app-boot/README.i18n.yaml @@ -21,8 +21,8 @@ en: 2fdfe02f2ed4a258 zh: d748228ece579c4a /deepseek-ai-dsh-app-boot/use-this-package/starting-the-app: - en: 4a424cd401556f20 - zh: 0b34dd18e0db6cea + en: 65bf2834b81f87da + zh: c864ac60a3005b99 /deepseek-ai-dsh-app-boot/use-this-package/profiles: en: 0a1b5f4f40d835a7 zh: d8f4b54160aa033b diff --git a/packages/boot/app-boot/README.md b/packages/boot/app-boot/README.md index 4b43180589..39b2684db0 100644 --- a/packages/boot/app-boot/README.md +++ b/packages/boot/app-boot/README.md @@ -40,7 +40,7 @@ installFailLoud('dsh') const ctx = await boot('dsh', resolveConfigPath(argv[2], process.env.DSH_SNAPSHOT)) ``` -`installFailLoud` writes one labelled `util.inspect` diagnostic to stderr for an unhandled rejection or an uncaught exception, awaits the surface's release hook under a fixed timeout, and exits 1; control never returns to the failed operation, because only the throw site knows which state is intact, and the event loop runs only until the release settles or times out. With that entry point, startup keeps every plugin that can activate. An enabled failed plugin produces a labelled warning. A failed required entry makes startup dispose the whole app and exit nonzero; required ids absent from a profile and disabled required entries do not affect startup. The global required list covers shared Agent execution, application endpoints, and Web bootstrap/transport: `agent-loop`, `webserver`, `web-runtime`, `modules`, `connection`, `headless-runner`, `acp`, and `sdk-jsonrpc-server`. +`installFailLoud` writes one labelled `util.inspect` diagnostic to stderr for an unhandled rejection or an uncaught exception, awaits the surface's release hook under a fixed timeout, and exits 1; control never returns to the failed operation, because only the throw site knows which state is intact, and the event loop runs only until the release settles or times out. With that entry point, startup keeps every plugin that can activate. An enabled failed plugin produces a labelled warning. A failed required entry makes startup dispose the whole app and exit nonzero; required ids absent from a profile and disabled required entries do not affect startup. The global required list covers shared Agent execution, application endpoints, and Web bootstrap/transport: `agent-loop`, `tool-executor-native`, `tool-executor-ptc`, `webserver`, `web-runtime`, `modules`, `connection`, `headless-runner`, `acp`, and `sdk-jsonrpc-server`. ### Profiles diff --git a/packages/boot/app-boot/README.zh.md b/packages/boot/app-boot/README.zh.md index eee5749ba6..04d9237ea0 100644 --- a/packages/boot/app-boot/README.zh.md +++ b/packages/boot/app-boot/README.zh.md @@ -40,7 +40,7 @@ installFailLoud('dsh') const ctx = await boot('dsh', resolveConfigPath(argv[2], process.env.DSH_SNAPSHOT)) ``` -`installFailLoud` 会为未处理 rejection 或未捕获异常向 stderr 写一条带标签的 `util.inspect` 诊断,在固定超时内等待界面的 release 钩子,然后以 1 退出;控制流不会回到失败的操作,因为只有抛出点知道哪些状态仍然完整,事件循环只运行到 release 结束或超时。有了这个入口,启动会保留所有能够激活的插件。启用但失败的插件会产生带标签的警告。required entry 失败时,启动会拆卸整个应用并以非零码退出;profile 中不存在的 required id 和已禁用的 required entry 不影响启动。全局 required list 覆盖共享 Agent 执行、应用 endpoint,以及 Web 启动与传输:`agent-loop`、`webserver`、`web-runtime`、`modules`、`connection`、`headless-runner`、`acp` 和 `sdk-jsonrpc-server`。 +`installFailLoud` 会为未处理 rejection 或未捕获异常向 stderr 写一条带标签的 `util.inspect` 诊断,在固定超时内等待界面的 release 钩子,然后以 1 退出;控制流不会回到失败的操作,因为只有抛出点知道哪些状态仍然完整,事件循环只运行到 release 结束或超时。有了这个入口,启动会保留所有能够激活的插件。启用但失败的插件会产生带标签的警告。required entry 失败时,启动会拆卸整个应用并以非零码退出;profile 中不存在的 required id 和已禁用的 required entry 不影响启动。全局 required list 覆盖共享 Agent 执行、应用 endpoint,以及 Web 启动与传输:`agent-loop`、`tool-executor-native`、`tool-executor-ptc`、`webserver`、`web-runtime`、`modules`、`connection`、`headless-runner`、`acp` 和 `sdk-jsonrpc-server`。 ### Profile diff --git a/packages/boot/app-boot/src/index.ts b/packages/boot/app-boot/src/index.ts index fded02c9c8..67c8618b9d 100644 --- a/packages/boot/app-boot/src/index.ts +++ b/packages/boot/app-boot/src/index.ts @@ -950,6 +950,8 @@ const FIBER_DISPOSED = 4 as FiberState.DISPOSED */ const requiredStartupEntryIds = new Set([ 'agent-loop', + 'tool-executor-native', + 'tool-executor-ptc', 'webserver', 'web-runtime', 'modules', diff --git a/packages/boot/app-boot/tests/app-boot.spec.ts b/packages/boot/app-boot/tests/app-boot.spec.ts index 5e83da0b17..48f2e4456c 100644 --- a/packages/boot/app-boot/tests/app-boot.spec.ts +++ b/packages/boot/app-boot/tests/app-boot.spec.ts @@ -557,6 +557,8 @@ describe('installFailLoud', () => { describe('auditStartupEntries', () => { const requiredIds = [ 'agent-loop', + 'tool-executor-native', + 'tool-executor-ptc', 'webserver', 'web-runtime', 'modules', diff --git a/packages/boot/plugin-manager/package.json b/packages/boot/plugin-manager/package.json index 333f88f939..55ede9391f 100644 --- a/packages/boot/plugin-manager/package.json +++ b/packages/boot/plugin-manager/package.json @@ -101,6 +101,8 @@ "@deepseek-ai/dsh-host-plugin-inventory": "workspace:*", "@deepseek-ai/dsh-subprocess": "workspace:*", "@deepseek-ai/dsh-typert-protocol": "workspace:*", + "@deepseek-ai/dsh-tool-executor-native": "workspace:*", + "@deepseek-ai/dsh-tool-testkit": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", "@deepseek-ai/dsh-system-prompt": "workspace:*", "@deepseek-ai/dsh-hmr": "workspace:*", diff --git a/packages/boot/plugin-manager/tests/tools.spec.ts b/packages/boot/plugin-manager/tests/tools.spec.ts index f0818457c9..c5056f884d 100644 --- a/packages/boot/plugin-manager/tests/tools.spec.ts +++ b/packages/boot/plugin-manager/tests/tools.spec.ts @@ -1,8 +1,10 @@ -import { setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy' /** Agent controls use the same manager methods as the Web and bound inventory reads. */ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' +import { setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy' import { Context } from '@deepseek-ai/cordis' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRuntime from '@deepseek-ai/dsh-tools' +import ToolRuntime, { type ToolExecutionResult } from '@deepseek-ai/dsh-tools' import { ToolCallId } from '@deepseek-ai/dsh-llm' import type { Agent } from '@deepseek-ai/dsh-agent' import SandboxPolicy from '@deepseek-ai/dsh-sandbox-policy' @@ -14,7 +16,7 @@ import type PluginManager from '../src/index.ts' import type { BundleInfo } from '../src/types.ts' import * as tool from '../src/tools.ts' -function resultText(result: Awaited>): string { +function resultText(result: ToolExecutionResult): string { if (typeof result.value !== 'string') throw new Error('Expected a serialized manager result') return result.value } @@ -35,11 +37,12 @@ async function fixture(mode: 'read-only' | 'workspace-write' | 'danger-full-acce ctx.provide('pluginManager', manager as unknown as PluginManager) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(SessionProjections) await ctx.plugin(SandboxPolicy, { mode }) if (approval !== undefined) await ctx.plugin(ApprovalService, { policy: approval }) const fiber = await ctx.plugin(tool) - const call = (args: unknown, agent?: Agent, signal = new AbortController().signal) => ctx.tools.execute({ name: 'plugin_manager', arguments: args, + const call = (args: unknown, agent?: Agent, signal = new AbortController().signal) => executeToolFixture(ctx, { name: 'plugin_manager', arguments: args, ...agent === undefined ? {} : { agent }, callId: ToolCallId('manager-call'), signal }) return { ctx, manager, call, fiber } diff --git a/packages/boot/plugin-market/package.json b/packages/boot/plugin-market/package.json index c4ed435d1a..8dd7d7e0ec 100644 --- a/packages/boot/plugin-market/package.json +++ b/packages/boot/plugin-market/package.json @@ -92,6 +92,8 @@ "@deepseek-ai/dsh-settings": "workspace:*", "@deepseek-ai/dsh-subprocess": "workspace:*", "@deepseek-ai/dsh-system-prompt": "workspace:*", + "@deepseek-ai/dsh-tool-executor-native": "workspace:*", + "@deepseek-ai/dsh-tool-testkit": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", "@deepseek-ai/dsh-typert-protocol": "workspace:*", "@types/hosted-git-info": "^3.0.5", diff --git a/packages/boot/plugin-market/tests/tools.spec.ts b/packages/boot/plugin-market/tests/tools.spec.ts index 14c5c7a479..8a0c4fcbbd 100644 --- a/packages/boot/plugin-market/tests/tools.spec.ts +++ b/packages/boot/plugin-market/tests/tools.spec.ts @@ -1,4 +1,6 @@ /** The read-only market listing tool over the same service the Discover page uses. */ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { Context, Service } from '@deepseek-ai/cordis' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRuntime from '@deepseek-ai/dsh-tools' @@ -32,9 +34,10 @@ async function fixture() { await ctx.plugin(PluginMarket, { indexes: [URL_A], detailCacheDir: UNUSED_CACHE }) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) const fiber = await ctx.plugin(tool) const call = async (args: unknown, signal = new AbortController().signal) => { - const result = await ctx.tools.execute({ name: 'plugin_market', arguments: args, callId: ToolCallId('market-call'), signal }) + const result = await executeToolFixture(ctx, { name: 'plugin_market', arguments: args, callId: ToolCallId('market-call'), signal }) return { ...result, json: typeof result.value === 'string' ? JSON.parse(result.value) as Record : undefined } } return { ctx, call, fiber } @@ -168,6 +171,7 @@ it('registers the tool only while the pluginMarket service is mounted', async () await ctx.plugin(Manager) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(tool) expect(ctx.tools.get('plugin_market')).toBeUndefined() const market = await ctx.plugin(PluginMarket, { indexes: [], detailCacheDir: UNUSED_CACHE }) diff --git a/packages/bundle/base/cordis.patch.yml b/packages/bundle/base/cordis.patch.yml index c087b81f23..863d194bf5 100644 --- a/packages/bundle/base/cordis.patch.yml +++ b/packages/bundle/base/cordis.patch.yml @@ -490,11 +490,17 @@ # ── rows every mode mounts, whose values each overlay may state ────────────── - # The tool registry. Presentation mode is a deployment choice; omitting it here - # keeps the schema default (native). + # Shared tool definitions and the deployment’s native executor. - id: tools name: '@deepseek-ai/dsh-tools' + - id: tool-executor-native + name: '@deepseek-ai/dsh-tool-executor-native' + + - id: tool-executor-ptc + name: '@deepseek-ai/dsh-tool-executor-ptc' + disabled: true + # The deployment persona is a deployment choice; plan-mode and tool plugins own # their own prompt sections. - id: system-prompt diff --git a/packages/bundle/base/package.json b/packages/bundle/base/package.json index 934043be6d..0a439190ee 100644 --- a/packages/bundle/base/package.json +++ b/packages/bundle/base/package.json @@ -98,6 +98,8 @@ "@deepseek-ai/dsh-token-meter": "workspace:*", "@deepseek-ai/dsh-tool-bash": "workspace:*", "@deepseek-ai/dsh-tool-present": "workspace:*", + "@deepseek-ai/dsh-tool-executor-native": "workspace:*", + "@deepseek-ai/dsh-tool-executor-ptc": "workspace:*", "@deepseek-ai/dsh-tool-fs": "workspace:*", "@deepseek-ai/dsh-tool-fs-search": "workspace:*", "@deepseek-ai/dsh-tool-goal": "workspace:*", diff --git a/packages/bundle/headless/README.i18n.yaml b/packages/bundle/headless/README.i18n.yaml index 1b403edbb2..64186c0f36 100644 --- a/packages/bundle/headless/README.i18n.yaml +++ b/packages/bundle/headless/README.i18n.yaml @@ -48,8 +48,8 @@ en: fdef19c52550fdf8 zh: c4bab4fb6972cf10 /deepseek-ai-dsh-headless/understand-the-implementation/patch-surface-over-base: - en: 290f49af13bb0ae1 - zh: 5b599df1df8e6b7b + en: e192267973792ad1 + zh: ef78428a68dff9e4 /deepseek-ai-dsh-headless/understand-the-implementation/exit-mapping: en: c2466d3d8a26177a zh: b75743bfa2e7723a diff --git a/packages/bundle/headless/README.md b/packages/bundle/headless/README.md index be01f57c86..528a2e7e36 100644 --- a/packages/bundle/headless/README.md +++ b/packages/bundle/headless/README.md @@ -127,7 +127,7 @@ The runner awaits the complete application (`ctx.get('loader')?.await()`) so the ### Patch surface over base -The patch rides over `dsh-base`: it inherits the projection cache and shared PTC runtime, sets the coding persona prefix and separate cwd suffix on the base `system-prompt` row, keeps the same temporary process-wide PTC mode opt-in (`DSH_TOOLS_MODE`) as the Web surface, disables the shared HMR row, and mounts the startup provider and the runner. The cache checkpoints each persisted one-shot session for later consumers; its durability barrier flushes each covered log prefix before publishing the cache row and may split otherwise coalesced JSONL runs. The startup provider ([`src/startup.ts`](src/startup.ts)) injects `ctx.cmdlineArgs` ([`dsh-cmdline`](../../boot/cmdline/README.md)), reads the task and invocation options, prints the app's `--help`, and provides `headlessStartup`; the runner reads its task, Session identity, and output mode from lazy config. It reads the explicit CLI permission preset and approval policy directly from `headlessStartup`, with runner config as the default. The sandbox-policy row also waits for this service, retaining the base mode and workspace defaults while receiving the explicit directory grants. +The patch rides over `dsh-base`: it inherits the projection cache and shared PTC runtime, sets the coding persona prefix and separate cwd suffix on the base `system-prompt` row, inherits native tool execution and accepts a [PTC overlay](../../../apps/cli/config/examples/ptc.yml), disables the shared HMR row, and mounts the startup provider and the runner. The cache checkpoints each persisted one-shot session for later consumers; its durability barrier flushes each covered log prefix before publishing the cache row and may split otherwise coalesced JSONL runs. The startup provider ([`src/startup.ts`](src/startup.ts)) injects `ctx.cmdlineArgs` ([`dsh-cmdline`](../../boot/cmdline/README.md)), reads the task and invocation options, prints the app's `--help`, and provides `headlessStartup`; the runner reads its task, Session identity, and output mode from lazy config. It reads the explicit CLI permission preset and approval policy directly from `headlessStartup`, with runner config as the default. The sandbox-policy row also waits for this service, retaining the base mode and workspace defaults while receiving the explicit directory grants. ### Exit mapping diff --git a/packages/bundle/headless/README.zh.md b/packages/bundle/headless/README.zh.md index 5f462cf147..97845ae19c 100644 --- a/packages/bundle/headless/README.zh.md +++ b/packages/bundle/headless/README.zh.md @@ -127,7 +127,7 @@ runner 等待整个应用结算(`ctx.get('loader')?.await()`),确保已组 ### 基于 base 的 patch 内容 -patch 叠加在 `dsh-base` 之上:继承投影缓存与共享 PTC 运行时,在基础 `system-prompt` 行上设置编码 persona 前缀与独立的 cwd 后缀,保留与 Web 表层相同的临时进程级 PTC mode 开关(`DSH_TOOLS_MODE`),禁用共享的 HMR(热模块替换)行,并挂载启动提供方与 runner。缓存为每个已持久化的一次性会话写入检查点,供后续消费方使用;其持久性屏障会在发布缓存行前 flush 所覆盖的日志前缀,因此可能拆分原本会合并的 JSONL 连续段。启动提供方([`src/startup.ts`](src/startup.ts))注入 `ctx.cmdlineArgs`([`dsh-cmdline`](../../boot/cmdline/README.zh.md)),读取任务和调用选项、打印应用自己的 `--help`,并提供 `headlessStartup`;runner 从延迟配置读取任务、Session 标识与输出模式。它直接从 `headlessStartup` 读取显式 CLI 权限预设与审批策略,以 runner 配置作为默认值。sandbox-policy 行也等待此服务,在保留基础模式和工作区默认值的同时接收显式目录授权。 +patch 叠加在 `dsh-base` 之上:继承投影缓存与共享 PTC 运行时,在基础 `system-prompt` 行上设置编码 persona 前缀与独立的 cwd 后缀,继承原生工具执行,并支持 [PTC overlay](../../../apps/cli/config/examples/ptc.yml),禁用共享的 HMR(热模块替换)行,并挂载启动提供方与 runner。缓存为每个已持久化的一次性会话写入检查点,供后续消费方使用;其持久性屏障会在发布缓存行前 flush 所覆盖的日志前缀,因此可能拆分原本会合并的 JSONL 连续段。启动提供方([`src/startup.ts`](src/startup.ts))注入 `ctx.cmdlineArgs`([`dsh-cmdline`](../../boot/cmdline/README.zh.md)),读取任务和调用选项、打印应用自己的 `--help`,并提供 `headlessStartup`;runner 从延迟配置读取任务、Session 标识与输出模式。它直接从 `headlessStartup` 读取显式 CLI 权限预设与审批策略,以 runner 配置作为默认值。sandbox-policy 行也等待此服务,在保留基础模式和工作区默认值的同时接收显式目录授权。 ### 退出映射 diff --git a/packages/bundle/headless/cordis.patch.yml b/packages/bundle/headless/cordis.patch.yml index 68fb193f93..0a0ea09b7f 100644 --- a/packages/bundle/headless/cordis.patch.yml +++ b/packages/bundle/headless/cordis.patch.yml @@ -11,11 +11,6 @@ personaPrefix: >- You are a coding agent powered by the {{model}} model. -- id: tools - config: - # Keep the same temporary process-wide PTC mode opt-in as the Web surface. - mode: !!js process.env.DSH_TOOLS_MODE - - insert: - id: headless-startup name: '@deepseek-ai/dsh-headless/startup' diff --git a/packages/bundle/sdk-app/README.i18n.yaml b/packages/bundle/sdk-app/README.i18n.yaml index ac256bac4f..ed528810fc 100644 --- a/packages/bundle/sdk-app/README.i18n.yaml +++ b/packages/bundle/sdk-app/README.i18n.yaml @@ -15,8 +15,8 @@ en: 4e53164159b5ce41 zh: 89582dc0294d0776 /deepseek-ai-dsh-sdk-app/use-this-package: - en: b76bd7ea9880f529 - zh: e80c7b9fa331dd9b + en: b9a0e01492ffe278 + zh: 7c6ffd1f9e9c0906 /deepseek-ai-dsh-sdk-app/model-experience: en: 215e7ba838619b7b zh: a311da3843709f90 diff --git a/packages/bundle/sdk-app/README.md b/packages/bundle/sdk-app/README.md index b07ddae97b..d1be289939 100644 --- a/packages/bundle/sdk-app/README.md +++ b/packages/bundle/sdk-app/README.md @@ -23,7 +23,7 @@ The SDK stdio application as a `dsh` profile bundle over [`dsh-base`](../base/RE ## Use this package -The `sdk` profile includes native desktop tools and model context. Standard tool calling is the default; set `DSH_TOOLS_MODE=ptc` for programmatic tool calling. Both modes use the same plugins. +The `sdk` profile includes native desktop tools and model context. Native tool execution is the default. For PTC, pass the [executor overlay](../../../apps/cli/config/examples/ptc.yml) through `--patch` or the `patches` option in the TypeScript or Python SDK. `DSH_TOOLS_MODE` is ignored. The startup provider binds stdin EOF to the launcher's bounded successful shutdown. SDK protocol `shutdown`, SIGINT, and SIGTERM retain their owning server or launcher paths; disposal drains the root profile tree and persistence. Stdout is reserved for newline-delimited JSON-RPC frames. The bundle disables model-generated session titles because the SDK exposes no title surface; deterministic fallback titles remain durable without an auxiliary model request. The inherited projection cache checkpoints SDK-created sessions for later consumers; its durability barrier flushes each covered log prefix before publishing the cache row and may split otherwise coalesced JSONL runs. A deployment selects a different complete composition through profile bundles and patch files, not another app bin. diff --git a/packages/bundle/sdk-app/README.zh.md b/packages/bundle/sdk-app/README.zh.md index fa9e3a8702..70523123d7 100644 --- a/packages/bundle/sdk-app/README.zh.md +++ b/packages/bundle/sdk-app/README.zh.md @@ -23,7 +23,7 @@ kind: "package-bundle" ## 使用本包 -`sdk` profile 包含原生桌面工具和模型上下文。默认使用标准工具调用;设置 `DSH_TOOLS_MODE=ptc` 可使用程序化工具调用。两种模式使用相同插件。 +`sdk` profile 包含原生桌面工具和模型上下文。默认使用原生工具执行。需要 PTC 时,通过 `--patch` 或 TypeScript、Python SDK 的 `patches` 选项传入[执行器 overlay](../../../apps/cli/config/examples/ptc.yml)。`DSH_TOOLS_MODE` 不生效。 启动提供方把 stdin EOF 接到启动器的有界成功关闭流程。SDK 协议 `shutdown`、SIGINT 与 SIGTERM 继续使用各自所属的 server 或启动器路径;dispose(资源释放)会排空根 profile 配置树与持久化。stdout 专用于按换行分隔的 JSON-RPC 帧。SDK 不提供 title 表层,因此本组合包禁用模型生成的会话标题;确定性的 fallback title 仍会持久化,但不发起辅助模型请求。继承的投影缓存会为 SDK 创建的会话写入检查点,供后续消费方使用;其持久性屏障会在发布缓存行前 flush 所覆盖的日志前缀,因此可能拆分原本会合并的 JSONL 行。部署通过 profile 组合包与 patch 文件选择另一套完整组合,而不是使用另一个应用 bin。 diff --git a/packages/bundle/sdk-app/cordis.patch.yml b/packages/bundle/sdk-app/cordis.patch.yml index 52e258605d..afcbfd2c0d 100644 --- a/packages/bundle/sdk-app/cordis.patch.yml +++ b/packages/bundle/sdk-app/cordis.patch.yml @@ -41,10 +41,6 @@ node: !!js "process.env.DSH_BUNDLED_OFFICE_NODE ?? process.getBuiltinModule('node:path').resolve(process.env.DSH_PRIMARY_RUNTIME ?? process.env.DSH_BUNDLED_PRIMARY_RUNTIME ?? '', 'dependencies', 'node', 'bin', process.platform === 'win32' ? 'node.exe' : 'node')" # Share native desktop tools and model context without starting a Web server. -- id: tools - config: - mode: !!js process.env.DSH_TOOLS_MODE - - insert: - id: connection name: '@deepseek-ai/dsh-client-connection' diff --git a/packages/bundle/sdk-minimal/cordis.patch.yml b/packages/bundle/sdk-minimal/cordis.patch.yml index afb1e0019e..e46eed1e0f 100644 --- a/packages/bundle/sdk-minimal/cordis.patch.yml +++ b/packages/bundle/sdk-minimal/cordis.patch.yml @@ -120,6 +120,9 @@ - id: tools name: '@deepseek-ai/dsh-tools' + - id: tool-executor-native + name: '@deepseek-ai/dsh-tool-executor-native' + - id: mcp-resources name: '@deepseek-ai/dsh-mcp-resources' diff --git a/packages/bundle/sdk-minimal/package.json b/packages/bundle/sdk-minimal/package.json index f5a82a3662..512f282bc7 100644 --- a/packages/bundle/sdk-minimal/package.json +++ b/packages/bundle/sdk-minimal/package.json @@ -63,6 +63,7 @@ "@deepseek-ai/dsh-terminal-bash": "workspace:*", "@deepseek-ai/dsh-token-meter": "workspace:*", "@deepseek-ai/dsh-tool-bash-persistent": "workspace:*", + "@deepseek-ai/dsh-tool-executor-native": "workspace:*", "@deepseek-ai/dsh-tool-fs": "workspace:*", "@deepseek-ai/dsh-tool-pwsh-persistent": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", diff --git a/packages/bundle/sdk-minimal/tests/sdk-minimal.spec.ts b/packages/bundle/sdk-minimal/tests/sdk-minimal.spec.ts index ce122661ae..2a96ffc076 100644 --- a/packages/bundle/sdk-minimal/tests/sdk-minimal.spec.ts +++ b/packages/bundle/sdk-minimal/tests/sdk-minimal.spec.ts @@ -53,6 +53,7 @@ describe('dsh-sdk-minimal bundle', () => { ['session-title', '@deepseek-ai/dsh-session-title'], ['system-prompt', '@deepseek-ai/dsh-system-prompt'], ['tools', '@deepseek-ai/dsh-tools'], + ['tool-executor-native', '@deepseek-ai/dsh-tool-executor-native'], ['mcp-resources', '@deepseek-ai/dsh-mcp-resources'], ['agent', '@deepseek-ai/dsh-agent'], ['llm-retry', '@deepseek-ai/dsh-llm-retry'], diff --git a/packages/bundle/web-app/README.i18n.yaml b/packages/bundle/web-app/README.i18n.yaml index 94ccc5ea6b..ac78994ab7 100644 --- a/packages/bundle/web-app/README.i18n.yaml +++ b/packages/bundle/web-app/README.i18n.yaml @@ -36,8 +36,8 @@ en: 323742a00cfe67b2 zh: eff5245a82fc16e7 /deepseek-ai-dsh-web-app/understand-the-implementation/patch-semantics: - en: 8a2554b3d17daa1f - zh: 66f18946791cabb6 + en: a6d126f30b1594d7 + zh: 7f0ccc8a55d6f743 /deepseek-ai-dsh-web-app/understand-the-implementation/advertised-application-url: en: 0b22e03e28d41420 zh: fd45a97dd4336c25 diff --git a/packages/bundle/web-app/README.md b/packages/bundle/web-app/README.md index cae944ff12..cb974443e5 100644 --- a/packages/bundle/web-app/README.md +++ b/packages/bundle/web-app/README.md @@ -102,7 +102,7 @@ The bundle is one patch layer of five files plus one runtime glue plugin: `cordi ### Patch semantics -A patch replaces the targeted row's whole `config`, so each web row restates every key it owns: the persona prefix template, the `DSH_TOOLS_MODE` PTC mode opt-in, and the `session-query-sqlite` values on the base rows, then `insert` adds the web host rows, transport, and browser roster. The `webserver`, `web-runtime`, and `connection` rows inject the `webStartup` provider and read their invocation values directly; Connection composes its accepted authorities from `ctx.webStartup.trustedHosts`, and the Host/Origin fence accepts the listener's bind IP independently of that list. The per-agent tool rows the base mounts process-wide are disabled here and the preset roster takes over; the reasoning for each host-plane versus preset-plane decision is inline in the patch. +A patch replaces the targeted row's whole `config`, so each web row restates every key it owns: the persona prefix template and the `session-query-sqlite` values on the base rows, then `insert` adds the web host rows, transport, and browser roster. The `webserver`, `web-runtime`, and `connection` rows inject the `webStartup` provider and read their invocation values directly; Connection composes its accepted authorities from `ctx.webStartup.trustedHosts`, and the Host/Origin fence accepts the listener's bind IP independently of that list. The bundle inherits native tool execution; a [PTC overlay](../../../apps/cli/config/examples/ptc.yml) changes the deployment executor. The per-agent tool rows the base mounts process-wide are disabled here and the preset roster takes over; the reasoning for each host-plane versus preset-plane decision is inline in the patch. ### Advertised application URL diff --git a/packages/bundle/web-app/README.zh.md b/packages/bundle/web-app/README.zh.md index 6ffe021a95..88bbae54a5 100644 --- a/packages/bundle/web-app/README.zh.md +++ b/packages/bundle/web-app/README.zh.md @@ -102,7 +102,7 @@ dsh --profile web --no-open --host "$(hostname -i | awk '{print $1}')" --public- ### patch 语义 -patch 会替换目标行的整个 `config`,因此每个 Web 行都重述自己拥有的每个键:基础行上的 persona 前缀模板、`DSH_TOOLS_MODE` PTC mode 开关与 `session-query-sqlite` 值,随后 `insert` 添加 Web 宿主行、传输层与浏览器名录。`webserver`、`web-runtime` 与 `connection` 行都注入 `webStartup` 提供方并直接读取本次调用的取值;Connection 的被接受 authority 全部来自 `ctx.webStartup.trustedHosts`,Host/Origin 栅栏则独立于该列表接受监听器的绑定 IP。base 以进程级挂载的按 agent 工具行在这里被禁用,由 preset 名录接管;每项宿主层与 preset 层归属决策的理由以行内注释写在 patch 里。 +patch 会替换目标行的整个 `config`,因此每个 Web 行都重述自己拥有的每个键:基础行上的 persona 前缀模板与 `session-query-sqlite` 值,随后 `insert` 添加 Web 宿主行、传输层与浏览器名录。`webserver`、`web-runtime` 与 `connection` 行都注入 `webStartup` 提供方并直接读取本次调用的取值;Connection 的被接受 authority 全部来自 `ctx.webStartup.trustedHosts`,Host/Origin 栅栏则独立于该列表接受监听器的绑定 IP。bundle 继承原生工具执行;[PTC overlay](../../../apps/cli/config/examples/ptc.yml) 可切换部署执行器。base 以进程级挂载的按 agent 工具行在这里被禁用,由 preset 名录接管;每项宿主层与 preset 层归属决策的理由以行内注释写在 patch 里。 ### 公告应用 URL diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index 1b44f09b93..5afee068e2 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -29,14 +29,6 @@ path: ':memory:' openAt: never -- id: tools - config: - # TEMPORARY workaround: DSH_TOOLS_MODE (native|ptc) opts a whole dsh - # process into PTC mode while per-session tool-presentation selection is being - # designed; unset keeps the schema default (native). Remove the env seam - # once the web UI owns the choice per session. - mode: !!js process.env.DSH_TOOLS_MODE - # ── web-only host rows, the transport layer, and the browser roster ───────── # `dsh.client` rows are the browser roster the modules node half scans into diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index a6aeab97d7..ed2a93bcd0 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -58,7 +58,6 @@ "@deepseek-ai/dsh-agent-instructions": "workspace:*", "@deepseek-ai/dsh-agent-preset": "workspace:*", "@deepseek-ai/dsh-agent-preset-registry": "workspace:*", - "@deepseek-ai/dsh-agent-tool-presentation": "workspace:*", "@deepseek-ai/dsh-api-job-controller": "workspace:*", "@deepseek-ai/dsh-api-remotes": "workspace:*", "@deepseek-ai/dsh-api-session-controller": "workspace:*", @@ -162,6 +161,7 @@ "@deepseek-ai/dsh-tool-bash": "workspace:*", "@deepseek-ai/dsh-tool-bash-persistent": "workspace:*", "@deepseek-ai/dsh-tool-cordis": "workspace:*", + "@deepseek-ai/dsh-tool-executor-ptc": "workspace:*", "@deepseek-ai/dsh-tool-fs": "workspace:*", "@deepseek-ai/dsh-tool-fs-search": "workspace:*", "@deepseek-ai/dsh-tool-goal": "workspace:*", diff --git a/packages/bundle/web-app/presets/ptc.patch.yml b/packages/bundle/web-app/presets/ptc.patch.yml index 9a10e54366..034a2a6457 100644 --- a/packages/bundle/web-app/presets/ptc.patch.yml +++ b/packages/bundle/web-app/presets/ptc.patch.yml @@ -119,10 +119,8 @@ config: fetch: true searchTimeoutMs: 60000 - - id: tool-presentation - name: '@deepseek-ai/dsh-agent-tool-presentation' - config: - mode: ptc + - id: tool-executor-ptc + name: '@deepseek-ai/dsh-tool-executor-ptc' - id: present name: '@deepseek-ai/dsh-tool-present' - id: tool-plugin-manager diff --git a/packages/bundle/web-app/tests/document-preview.spec.ts b/packages/bundle/web-app/tests/document-preview.spec.ts index dcf2bcff18..9af5ca6a4f 100644 --- a/packages/bundle/web-app/tests/document-preview.spec.ts +++ b/packages/bundle/web-app/tests/document-preview.spec.ts @@ -10,7 +10,7 @@ import { loadOverlayPatches } from '@deepseek-ai/dsh-app-boot' import WorkspaceFiles, { type WorkspaceFileScope } from '@deepseek-ai/dsh-api-workspace-files' import OfficeToPdf from '@deepseek-ai/dsh-office-to-pdf' import * as DocumentPreview from '@deepseek-ai/dsh-client-ui-sidebar-documentpreview' -import type { IndexInjection } from '@deepseek-ai/dsh-host-webserver' +import type { IndexInjection, WebServer } from '@deepseek-ai/dsh-host-webserver' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy' @@ -20,6 +20,9 @@ import TypertRegistry from '@deepseek-ai/dsh-typert-registry' import type { Converter, ConverterOptions } from '@deepseek-ai/libreoffice-kit' import { expect, it, onTestFinished, vi } from 'vitest' +/** The Peer an index render speaks for; this listener reads no field of it. */ +const PAGE_PEER = { principal: { kind: 'owner' } } as Parameters[1] + const kit = vi.hoisted(() => ({ create: vi.fn<(options?: ConverterOptions) => Promise>() })) vi.mock('@deepseek-ai/libreoffice-kit', () => ({ createConverter: kit.create })) @@ -92,7 +95,7 @@ it('loads the shipped Office rows with separately patched settings and authorize const entries = new Map([...ctx.loader.entries()].map(entry => [entry.options.id, entry])) for (const row of configured) await entries.get(row.id)!.fiber!.await() const injections: IndexInjection[] = [] - ctx.emit('webserver/index-inject', injections) + ctx.emit('webserver/index-inject', injections, PAGE_PEER) expect(injections).toEqual([ { kind: 'global', name: '__DSH_DOCUMENT_PREVIEW_CONFIG__', value: clientConfig }, ]) diff --git a/packages/client/connection/tests/node-half.host.spec.ts b/packages/client/connection/tests/node-half.host.spec.ts index b3793c9cfd..c3497a95c2 100644 --- a/packages/client/connection/tests/node-half.host.spec.ts +++ b/packages/client/connection/tests/node-half.host.spec.ts @@ -22,6 +22,9 @@ import { import { DEFAULT_MAX_REQUEST_BODY_BYTES } from '../src/http-bridge.ts' import { provideBrowserCredentials } from './browser-credentials.ts' +/** The Peer an index render speaks for; this listener reads no field of it. */ +const PAGE_PEER = { principal: { kind: 'owner' } } as Parameters[1] + /** Structural webServer fake recording both route registries. */ function fakeHttpServer( routes: WebRoute[], @@ -272,7 +275,7 @@ describe('connection node half', () => { const { ctx, dispose } = await mounted({ recovery: { generationReadyTimeoutMs: 25_000 } }) try { const rows: IndexInjection[] = [] - ctx.emit('webserver/index-inject', rows) + ctx.emit('webserver/index-inject', rows, PAGE_PEER) expect(rows).toEqual([{ kind: 'global', name: '__DSH_CONNECTION_RECOVERY__', value: { backoffBaseMs: 500, backoffFactor: 2, backoffMaxMs: 10_000, @@ -281,7 +284,7 @@ describe('connection node half', () => { }]) await dispose() const after: IndexInjection[] = [] - ctx.emit('webserver/index-inject', after) + ctx.emit('webserver/index-inject', after, PAGE_PEER) expect(after).toEqual([]) } finally { await dispose() diff --git a/packages/client/shortcuts/tests/config.host.spec.ts b/packages/client/shortcuts/tests/config.host.spec.ts index 13ad7bc00a..ec507217c5 100644 --- a/packages/client/shortcuts/tests/config.host.spec.ts +++ b/packages/client/shortcuts/tests/config.host.spec.ts @@ -1,8 +1,11 @@ import { Context } from '@deepseek-ai/cordis' -import type { IndexInjection } from '@deepseek-ai/dsh-host-webserver' +import type { IndexInjection, WebServer } from '@deepseek-ai/dsh-host-webserver' import { expect, it } from 'vitest' import { Config, apply } from '../src/index.ts' +/** The Peer an index render speaks for; this listener reads no field of it. */ +const PAGE_PEER = { principal: { kind: 'owner' } } as Parameters[1] + it('validates positive sequence timing and publishes it through the product page injection', async () => { expect(Config({}).stopSequenceMs).toBe(500) expect(Config({ stopSequenceMs: 2_147_483_646 }).stopSequenceMs).toBe(2_147_483_646) @@ -11,7 +14,7 @@ it('validates positive sequence timing and publishes it through the product page try { apply(ctx, Config({ stopSequenceMs: 800 })) const table: IndexInjection[] = [] - await ctx.parallel('webserver/index-inject', table) + await ctx.parallel('webserver/index-inject', table, PAGE_PEER) expect(table).toEqual([{ kind: 'global', name: '__DSH_SHORTCUTS_CONFIG__', value: { stopSequenceMs: 800 } }]) } finally { await ctx.fiber.dispose() } }) diff --git a/packages/client/ui-chat/package.json b/packages/client/ui-chat/package.json index 492b4b4b39..0f31c600be 100644 --- a/packages/client/ui-chat/package.json +++ b/packages/client/ui-chat/package.json @@ -88,8 +88,8 @@ "@deepseek-ai/dsh-session-turn-outline": "workspace:*", "@deepseek-ai/dsh-subagent": "workspace:*", "@deepseek-ai/dsh-token-meter": "workspace:*", - "@deepseek-ai/dsh-tools": "workspace:*", "@deepseek-ai/dsh-util-workspace-path": "workspace:*", + "@deepseek-ai/dsh-tool-executor-ptc": "workspace:*", "@deepseek-ai/dsh-util-values": "workspace:*", "@tanstack/react-virtual": "3.14.10", "react": "^18.2.0", diff --git a/packages/client/ui-chat/src/client/conversation-nodes/tool.ts b/packages/client/ui-chat/src/client/conversation-nodes/tool.ts index 0252539059..3ed7b86d13 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/tool.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/tool.ts @@ -4,7 +4,7 @@ import type { ToolCallBlock, ToolResultNode, } from '@deepseek-ai/dsh-client-ui-conversation/client' import { isAppendSurfaceEvent } from '@deepseek-ai/dsh-session/surface' -import type {} from '@deepseek-ai/dsh-tools/types' +import type {} from '@deepseek-ai/dsh-tool-executor-ptc/client' import { PartialArguments } from '@deepseek-ai/dsh-util-values' import type { ChatNode, ToolChatData } from '../contract/chat-nodes.ts' import { CHAT_SYNTHETIC_SEQ_OFFSETS, chatNode, contextLocation } from './common.ts' diff --git a/packages/client/ui-chat/src/client/conversation-nodes/turn-process.ts b/packages/client/ui-chat/src/client/conversation-nodes/turn-process.ts index 9f9d8ed329..a0ad62091b 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/turn-process.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/turn-process.ts @@ -4,7 +4,7 @@ import type { } from '@deepseek-ai/dsh-client-ui-conversation/client' import type {} from '@deepseek-ai/dsh-llm-retry/types' import type { StreamChunk } from '@deepseek-ai/dsh-llm' -import type {} from '@deepseek-ai/dsh-tools/types' +import type {} from '@deepseek-ai/dsh-tool-executor-ptc/client' import { hasAssistantReplyContent } from '../contract/assistant-content.ts' import type { AssistantChatData, ChatNode, FinalAssistantChatData } from '../contract/chat-nodes.ts' import { diff --git a/packages/client/ui-chat/src/client/model/tool-call-tree.ts b/packages/client/ui-chat/src/client/model/tool-call-tree.ts index 2be2121414..d9f3244abf 100644 --- a/packages/client/ui-chat/src/client/model/tool-call-tree.ts +++ b/packages/client/ui-chat/src/client/model/tool-call-tree.ts @@ -1,5 +1,5 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session/types' -import type {} from '@deepseek-ai/dsh-tools/types' +import type {} from '@deepseek-ai/dsh-tool-executor-ptc/client' import type { ConversationNode, RunningToolCall, ToolCallBlock, ToolResultNode, } from '@deepseek-ai/dsh-client-ui-conversation/client' diff --git a/packages/client/ui-chat/tsconfig.client.json b/packages/client/ui-chat/tsconfig.client.json index 528a54b447..9b241459b1 100644 --- a/packages/client/ui-chat/tsconfig.client.json +++ b/packages/client/ui-chat/tsconfig.client.json @@ -42,9 +42,6 @@ { "path": "../../core/session" }, - { - "path": "../../core/tools" - }, { "path": "../../interaction/commands" }, @@ -119,6 +116,9 @@ }, { "path": "../product-analytics/tsconfig.client.json" + }, + { + "path": "../../core/tool-executor-ptc" } ], "exclude": [ diff --git a/packages/client/ui-directory-picker-native/README.i18n.yaml b/packages/client/ui-directory-picker-native/README.i18n.yaml index 80a96f4d74..d7fe01a1f6 100644 --- a/packages/client/ui-directory-picker-native/README.i18n.yaml +++ b/packages/client/ui-directory-picker-native/README.i18n.yaml @@ -18,8 +18,8 @@ en: 1e2172c756246031 zh: f33dc7b40b14cceb /deepseek-ai-dsh-client-ui-directory-picker-native/use-this-package/when-to-choose-it: - en: bcba761168d2162c - zh: d484e2305e074c0b + en: f30bb7c6aae98fec + zh: 1d28613c86531119 /deepseek-ai-dsh-client-ui-directory-picker-native/understand-the-implementation: en: f7a287740b4b08f9 zh: 3347375c4e486b22 diff --git a/packages/client/ui-directory-picker-native/README.md b/packages/client/ui-directory-picker-native/README.md index b23d48bbb5..7a77767050 100644 --- a/packages/client/ui-directory-picker-native/README.md +++ b/packages/client/ui-directory-picker-native/README.md @@ -33,6 +33,8 @@ In the local Electron application, this flow uses the narrow preload directory-p Choose this surface when the browser runs on the same machine as the Host, so an OS dialog can open there. Choose the [`-browse`](../ui-directory-picker-browse/README.md) surface when the browser is remote or in-process and no local chooser exists. The two surfaces fill the same slots, so switching is a composition change, not a code change. +Access: both directory flows are registered only while the current Peer may call `directoryPicker/pick`. The owner always has access. + ----- diff --git a/packages/client/ui-directory-picker-native/README.zh.md b/packages/client/ui-directory-picker-native/README.zh.md index 57cbad2d5d..0bb78f28d1 100644 --- a/packages/client/ui-directory-picker-native/README.zh.md +++ b/packages/client/ui-directory-picker-native/README.zh.md @@ -33,6 +33,8 @@ kind: "package-reference" 当浏览器与 Host 运行在同一台机器上、操作系统对话框可以在那里打开时,选择此表面。当浏览器为远程或进程内、没有本地选择器时,选择 [`-browse`](../ui-directory-picker-browse/README.zh.md) 表面。两个表面填充相同的 slot,因此切换只是组合改动,而非代码改动。 +访问控制:两个目录选择流程只在当前 Peer 可以调用 `directoryPicker/pick` 时注册。owner 始终有权限。 + ----- diff --git a/packages/client/ui-directory-picker-native/package.json b/packages/client/ui-directory-picker-native/package.json index 6f54248246..ff25c9ae17 100644 --- a/packages/client/ui-directory-picker-native/package.json +++ b/packages/client/ui-directory-picker-native/package.json @@ -44,6 +44,7 @@ "@deepseek-ai/cordis": "workspace:~" }, "devDependencies": { + "@deepseek-ai/dsh-api-remote-access": "workspace:*", "@deepseek-ai/dsh-client-test-runtime": "workspace:*", "@deepseek-ai/dsh-client-ui-renderer": "workspace:*", "@deepseek-ai/dsh-client-ui-workspace": "workspace:*", diff --git a/packages/client/ui-directory-picker-native/src/client/index.ts b/packages/client/ui-directory-picker-native/src/client/index.ts index efec56fc49..1bdcafecdd 100644 --- a/packages/client/ui-directory-picker-native/src/client/index.ts +++ b/packages/client/ui-directory-picker-native/src/client/index.ts @@ -1,5 +1,7 @@ /** Native directory flow using the local desktop bridge or the Host's OS chooser. */ import type { Context as ClientContext } from '@deepseek-ai/cordis' +// Type-only: pulls the remoteAccess service merge (ctx.remoteAccess). +import type {} from '@deepseek-ai/dsh-api-remote-access/client' // Type-only: pulls the SlotMap merge declaring the directory-flow holes. import type {} from '@deepseek-ai/dsh-client-ui-workspace/client' // Type-only: pulls the SlotRegistry service merge (ctx.slots). @@ -8,13 +10,14 @@ import type { NativeFlowInjected } from './flow.ts' import { NativeDirectoryFlow } from './flow.ts' -/** Required services (cordis fiber inject): the slot registry and workspace UI service. */ -export const inject = ['slots', 'uiWorkspace'] +/** Required services (cordis fiber inject): the slot registry, workspace UI service, and access. */ +export const inject = ['slots', 'uiWorkspace', 'remoteAccess'] /** - * Client plugin body: register the renderless native flow into both - * directory-flow holes through `slots.inject()` because the ui-workspace - * entries may activate later or replace their declarations. + * Client plugin body: while the current Peer may open the Host's directory + * chooser, register the renderless native flow into both directory-flow holes + * through `slots.inject()` because the ui-workspace entries may activate later + * or replace their declarations. * @param ctx - client root context. */ export function apply(ctx: ClientContext): void { @@ -26,13 +29,14 @@ export function apply(ctx: ClientContext): void { // Both declaration lifetimes must be live before the pair installs; the // generator makes the two registrations one transactional effect. The // outer/inner nesting order is arbitrary; neither hole has precedence. - ctx.slots.inject('conversation.hero.workspace.directoryFlow', () => - ctx.slots.inject('sidebar.workspaces.directoryFlow', function* () { - yield ctx.slots.register({ - name: 'conversation.hero.workspace.directoryFlow', inject: injected, - }, NativeDirectoryFlow) - yield ctx.slots.register({ - name: 'sidebar.workspaces.directoryFlow', inject: injected, - }, NativeDirectoryFlow) - })) + ctx.remoteAccess.whenAccess('directoryPicker/pick', () => + ctx.slots.inject('conversation.hero.workspace.directoryFlow', () => + ctx.slots.inject('sidebar.workspaces.directoryFlow', function* () { + yield ctx.slots.register({ + name: 'conversation.hero.workspace.directoryFlow', inject: injected, + }, NativeDirectoryFlow) + yield ctx.slots.register({ + name: 'sidebar.workspaces.directoryFlow', inject: injected, + }, NativeDirectoryFlow) + }))) } diff --git a/packages/client/ui-directory-picker-native/tests/client-flow.client.spec.tsx b/packages/client/ui-directory-picker-native/tests/client-flow.client.spec.tsx index ed033999aa..fd3b22d315 100644 --- a/packages/client/ui-directory-picker-native/tests/client-flow.client.spec.tsx +++ b/packages/client/ui-directory-picker-native/tests/client-flow.client.spec.tsx @@ -4,6 +4,7 @@ import { describe, expect, it, vi } from 'vitest' import { act, cleanup, render } from '@testing-library/react' import { afterEach } from 'vitest' import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' +import { StaticRemoteAccess } from '@deepseek-ai/dsh-client-test-runtime' import type { DirectoryFlowOwnerProps } from '@deepseek-ai/dsh-client-ui-workspace/client' import { apply, inject } from '../src/client/index.ts' import { NativeDirectoryFlow } from '../src/client/flow.ts' @@ -29,9 +30,10 @@ afterEach(() => { cleanup(); vi.unstubAllGlobals(); vi.restoreAllMocks(); deskto const HOLES = ['conversation.hero.workspace.directoryFlow', 'sidebar.workspaces.directoryFlow'] as const -async function bench() { +async function bench(allows: (action: string) => boolean = () => true) { const ctx = new Context() await ctx.plugin(SlotRegistry).await() + new StaticRemoteAccess(ctx, allows) const pickDirectory = vi.fn(async (): Promise => '/tmp/picked') ctx.provide('uiWorkspace', { pickDirectory } as never) const slots = ctx.get('slots') as SlotRegistry @@ -52,7 +54,19 @@ function owner(overrides: Partial = {}): DirectoryFlowO describe('directory-picker-native client half', () => { it('declares the services it drives', () => { - expect(inject).toEqual(['slots', 'uiWorkspace']) + expect(inject).toEqual(['slots', 'uiWorkspace', 'remoteAccess']) + }) + + it('fills no directory flow while the current Peer may not open the Host chooser', async () => { + const allows = vi.fn(() => false) + const b = await bench(allows) + b.declare() + const fiber = b.ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + expect(allows).toHaveBeenCalledWith('directoryPicker/pick') + for (const hole of HOLES) expect(b.slots.entries(hole)).toEqual([]) + await fiber.dispose() + await b.ctx.fiber.dispose() }) it('fills both directory-flow holes for declarations before or after apply, and leaves with its fiber', async () => { diff --git a/packages/client/ui-dockkit/README.i18n.yaml b/packages/client/ui-dockkit/README.i18n.yaml index 9adce0d3b1..2f06976029 100644 --- a/packages/client/ui-dockkit/README.i18n.yaml +++ b/packages/client/ui-dockkit/README.i18n.yaml @@ -15,11 +15,11 @@ en: 42038fdc402b9c2a zh: 99890f8f908624ce /deepseek-ai-dsh-client-ui-dockkit/the-two-layers: - en: ea872ff2c2089037 - zh: b3ec3a218eacd5c8 + en: 99356ea8e3d1e7aa + zh: 870f746730d0e6cf /deepseek-ai-dsh-client-ui-dockkit/embedding-it: - en: bc9ecb9633fb3208 - zh: fa141e77c2bf6eae + en: c4ba08038bfa6f30 + zh: 95db30618b52babe /deepseek-ai-dsh-client-ui-dockkit/interaction-rules-worth-keeping: en: 7e5c1e9a9f9f244f zh: 7e3a8fcef86bc7dd diff --git a/packages/client/ui-dockkit/README.md b/packages/client/ui-dockkit/README.md index ff8e81e5eb..0ba54c2c8e 100644 --- a/packages/client/ui-dockkit/README.md +++ b/packages/client/ui-dockkit/README.md @@ -39,6 +39,8 @@ A docking layout kit: a split tree of tabbed panes with invertible operations, a **The components** render a layout snapshot and report settled intents — one per gesture, never a drag frame. A drag previews in local state while the gesture's own facts stay in its closure; on release the net result leaves through one `DockIntents` call — a strip release reports the caret slot as drawn, the dragged chip counted, and `planPlaceTab` turns that into the reorder or the move. That is what lets an embedder record exactly one history entry per gesture. The strip follows the WAI-ARIA tabs pattern with manual activation: the selected chip is in the tab order; Left and Right (wrapping), Home, and End move focus between chips without selecting; Enter or Space selects the focused chip through the same intent as a click. A chip is a capsule carrying one control, its close; the context menu (a secondary press on the chip) carries the same close plus the embedder's items — a menu that would hold no item at all never shows — and renders in a portal positioned against the chip because the chip box clips its overflow on purpose (see below). After the chips sits the add control, which asks the embedder (`DockIntents.addTab`) to seat its seeded tab; the embedder's `canAddTab(paneId)` decides per pane whether the control is drawn at all. Copying a tab has no kit control — it is the embedder's API — and floating is the drag released clear of the surface. +Middle-clicking anywhere on a closable tab chip, including its close control, closes it through the same intent as its close button, without selecting it first. The chip owns the middle click and suppresses nested title content’s default action. Closing a background tab preserves the selected tab. Only the primary button starts tab dragging; a middle click while another mouse button is held does not close the tab. + Tab context menus show the close action name without a shortcut hint: they act on the clicked tab, while shortcuts can target another focused pane. An unmodified Escape dismisses the foreground tab menu without closing the tab and returns focus from a menu item to its tab without drawing a focus outline. Tab and arrow navigation retain the tab’s visible focus indicator. Composition and held-key repeats do not dismiss it. @@ -52,7 +54,7 @@ Everything host-specific arrives through props: | `TabRenderer` | one tab's body (`renderTab`), drawn flush to the pane's edges and the unbordered strip's bottom edge with the insets it chooses, and optionally what its chip or panel header shows as a title (`renderTabTitle`, falling back to the record's `title`); the embedder dispatches on `tab.kind` | | `DockIntents` | the settled results of every gesture | -`DockController` satisfies `DockIntents` as written, so an embedding can pass it to `DockLayout` for retained Sidebar layouts or `DockSurface` for recursive split trees. An embedder that routes through its own store implements the same method names instead. Three props carry control policy rather than gestures: `canSplit` (surface-wide, the pane budget; disables the split control with `splitPaneDisabled`), `canAddTab(paneId)` (per pane, omits the add control; leave it out to draw one in every pane), and `canCloseTab(tabId)` (per tab, withholds the chip's close control and the menu's close item together; leave it out to keep every tab closable). Hiding the add control moves nothing else in the strip, and a withheld close moves nothing in the chip — the close control paints over the title's end rather than beside it. A pane's lone chip whose close is withheld draws quiet — no capsule, no hover fill — since there is nothing to select against and nothing to do to it. The kit adds one policy of its own, the room rule below, which disables a pane's split control with `splitPaneNarrow`; `onRoom(fits)` reports its readings so an embedder splitting programmatically can honour the same rule. A visible blocked split control supports click or tap to read its explanation without splitting or focusing the pane. +`DockController` satisfies `DockIntents` as written, so an embedding can pass it to `DockLayout` for retained Sidebar layouts or `DockSurface` for recursive split trees. An embedder that routes through its own store implements the same method names instead. Three props carry control policy rather than gestures: `canSplit` (surface-wide, the pane budget; disables the split control with `splitPaneDisabled`), `canAddTab(paneId)` (per pane, omits the add control; leave it out to draw one in every pane), and `canCloseTab(tabId)` (per tab, withholds the chip's close control and the menu's close item and prevents middle-click closing; leave it out to keep every tab closable). Hiding the add control moves nothing else in the strip, and a withheld close moves nothing in the chip — the close control paints over the title's end rather than beside it. A pane's lone chip whose close is withheld draws quiet — no capsule, no hover fill — since there is nothing to select against and nothing to do to it. The kit adds one policy of its own, the room rule below, which disables a pane's split control with `splitPaneNarrow`; `onRoom(fits)` reports its readings so an embedder splitting programmatically can honour the same rule. A visible blocked split control supports click or tap to read its explanation without splitting or focusing the pane. `dropZones="horizontal"` offers two half-pane hints; once budget or width forbids another split, the whole body accepts a move. A hint is a dashed card inset 8px inside its region, showing the zone's glyph and `labels.dropZone[zone]`; the preview layer covers all tab-body content, while the card under the pointer takes the accent and its neighbour stays a quiet outline. `minPaneFraction` sets the preview minimum, and `planResizeSplit` accepts the same minimum for the committed operation. The Sidebar uses 0.2 and enforces two panes in its own store. The generic engine retains its tree and other split directions. `hideSplitWhenBlocked` hides a blocked split control — pane budget spent or pane too narrow — instead of rendering it disabled; its default is false. diff --git a/packages/client/ui-dockkit/README.zh.md b/packages/client/ui-dockkit/README.zh.md index 7eddb644ce..39acc02792 100644 --- a/packages/client/ui-dockkit/README.zh.md +++ b/packages/client/ui-dockkit/README.zh.md @@ -39,6 +39,8 @@ kind: "package-reference" **组件**渲染布局快照并上报已落定的意图——每次手势一条,绝不上报拖动帧。拖动过程中在本地状态里预览,手势自身的事实留在它的闭包里;松手时净结果通过一次 `DockIntents` 调用离开——在标签条上松手上报的是按绘制顺序数出的插入槽位(被拖的 chip 也计入),由 `planPlaceTab` 换算成重排或移动。正是这一点让嵌入方能为每次手势记录恰好一条历史。标签条遵循 WAI-ARIA tabs 模式的手动激活:选中的 chip 在 Tab 键序里;左右方向键(循环)、Home、End 只在 chip 之间移动焦点而不选中;Enter 或空格选中当前聚焦的 chip,走与点击相同的意图。chip 是一个胶囊,携带唯一的控件——它的关闭按钮;上下文菜单(在 chip 上的次键按下)携带同样的关闭项加上嵌入方的条目——一个连一项都没有的菜单绝不展示——并渲染在按 chip 定位的 portal 里,因为 chip 盒会故意裁掉溢出(见下文)。chip 之后是添加控件,它请嵌入方(`DockIntents.addTab`)安放其种子 tab;嵌入方的 `canAddTab(paneId)` 按格决定是否绘制该控件。复制 tab 没有套件控件——那是嵌入方的 API——而浮出就是把拖动松手在停靠区之外。 +中键点击可关闭标签页 chip 的任意位置(包括关闭控件)时,通过与关闭按钮相同的意图关闭,无需先选中。chip 接管中键点击,并阻止嵌套标题内容的默认动作。关闭后台标签页会保留当前选中的标签页。只有主键会开始拖动标签页;其它鼠标按键仍按住时,中键点击不会关闭标签页。 + 标签上下文菜单只显示关闭操作名,不显示快捷键:菜单操作针对被点击的标签,快捷键则可能作用于其他焦点分栏。无修饰键的 Esc 关闭前台标签菜单而不关闭标签,并将菜单项的焦点交还标签,不绘制焦点外轮廓线。Tab 和方向键导航保留标签页的可见焦点提示。输入法组合输入和长按重复事件不关闭菜单。 @@ -52,7 +54,7 @@ kind: "package-reference" | `TabRenderer` | 一个 tab 的正文(`renderTab`),贴着格的边缘和(不带边线的)tab 条底边绘制、自己决定留白,以及可选的 chip 或浮窗头部显示的标题(`renderTabTitle`,回退到记录的 `title`);嵌入方按 `tab.kind` 分发 | | `DockIntents` | 每次手势落定的结果 | -`DockController` 原样满足 `DockIntents`,因此嵌入方可将它交给 `DockLayout` 来保活 Sidebar 布局,或交给 `DockSurface` 来渲染递归分割树。经由自己 store 路由的嵌入方则实现同名方法。有三个 props 承载的是控制策略而非手势:`canSplit`(整面有效,即格预算;用 `splitPaneDisabled` 禁用分栏控件)、`canAddTab(paneId)`(按格,省略添加控件;不传则每格都画)与 `canCloseTab(tabId)`(按 tab,把 chip 的关闭控件和菜单的关闭项一并收起;不传则每个 tab 都可关闭)。隐藏添加控件不会移动 tab 条里的其它任何东西,收起关闭也不会移动 chip 里的任何东西——关闭控件压在标题末端之上而非并排。某格仅剩的一个 chip 在关闭被收起时画成安静样式——没有胶囊底色,没有悬停填充——因为既没有别的 tab 可供选择,也没有任何可对它做的事。套件自己再加一条策略,即下文的空间规则,它用 `splitPaneNarrow` 禁用某格的分栏控件;`onRoom(fits)` 上报其读数,让以编程方式分栏的嵌入方能遵守同一规则。可见的禁用分栏控件支持鼠标或触摸点击查看原因,不会分栏或聚焦窗格。 +`DockController` 原样满足 `DockIntents`,因此嵌入方可将它交给 `DockLayout` 来保活 Sidebar 布局,或交给 `DockSurface` 来渲染递归分割树。经由自己 store 路由的嵌入方则实现同名方法。有三个 props 承载的是控制策略而非手势:`canSplit`(整面有效,即格预算;用 `splitPaneDisabled` 禁用分栏控件)、`canAddTab(paneId)`(按格,省略添加控件;不传则每格都画)与 `canCloseTab(tabId)`(按 tab,把 chip 的关闭控件和菜单的关闭项一并收起,并禁止中键关闭;不传则每个 tab 都可关闭)。隐藏添加控件不会移动 tab 条里的其它任何东西,收起关闭也不会移动 chip 里的任何东西——关闭控件压在标题末端之上而非并排。某格仅剩的一个 chip 在关闭被收起时画成安静样式——没有胶囊底色,没有悬停填充——因为既没有别的 tab 可供选择,也没有任何可对它做的事。套件自己再加一条策略,即下文的空间规则,它用 `splitPaneNarrow` 禁用某格的分栏控件;`onRoom(fits)` 上报其读数,让以编程方式分栏的嵌入方能遵守同一规则。可见的禁用分栏控件支持鼠标或触摸点击查看原因,不会分栏或聚焦窗格。 `dropZones="horizontal"` 提供左右两个半区提示;预算或宽度不允许再拆时,正文整格接收移动。提示是一张内缩 8px 的虚线卡片,显示该落区的图形和 `labels.dropZone[zone]`;预览层覆盖全部 tab 正文,指针所在的卡片取强调色,另一张保持安静的轮廓。`minPaneFraction` 控制预览的最小比例,`planResizeSplit` 接受相同最小值以约束提交;Sidebar使用0.2并在自己的store限制两格。通用引擎仍保留原有树与其它分割方向。 `hideSplitWhenBlocked` 在分栏被阻止时(窗格预算已满或格太窄)直接隐藏分栏控件而不是渲染禁用态,默认值为 false。 diff --git a/packages/client/ui-dockkit/src/components/DockSurface.tsx b/packages/client/ui-dockkit/src/components/DockSurface.tsx index 4803955cb0..eea2ea1724 100644 --- a/packages/client/ui-dockkit/src/components/DockSurface.tsx +++ b/packages/client/ui-dockkit/src/components/DockSurface.tsx @@ -47,9 +47,9 @@ export interface DockSurfaceProps { */ readonly canAddTab?: (paneId: PaneId) => boolean /** - * Whether a tab draws its close control and its menu's close item. Called + * Whether a tab may close via its close control, menu item, or middle click. Called * per rendered chip on every render; omit to keep every tab closable. - * `false` removes both routes without moving the chip: the close control + * `false` blocks these routes without moving the chip: the close control * paints over the title's end rather than beside it, so the chip is the same * width either way. The menu still opens and carries the embedder's items. */ diff --git a/packages/client/ui-dockkit/src/components/TabMenu.tsx b/packages/client/ui-dockkit/src/components/TabMenu.tsx index 3effad9f79..beb7c926b6 100644 --- a/packages/client/ui-dockkit/src/components/TabMenu.tsx +++ b/packages/client/ui-dockkit/src/components/TabMenu.tsx @@ -105,13 +105,11 @@ export function TabMenu({ labels, anchor, onClose, onDismiss, extras }: TabMenuP data-dockkit-tab-menu // Unplaced for one layout pass: measured, then positioned. style={position ?? { visibility: 'hidden', top: 0, left: 0 }} - // React bubbles a portal's events to its tab chip, so both halves of a - // press must stop here: the press would otherwise start a tab drag and - // capture the pointer, which retargets the release and swallows this - // item's click; the click would otherwise also focus a tab the item may - // have removed. + // Portal events reach the owning chip: a press would start its drag, + // a click would focus it, and a middle click would close it. onPointerDown={(event) => { event.stopPropagation() }} onClick={(event) => { event.stopPropagation() }} + onAuxClick={(event) => { event.stopPropagation() }} > {onClose !== undefined && (
diff --git a/packages/client/ui-entry-customization/README.zh.md b/packages/client/ui-entry-customization/README.zh.md index 4b0169538c..daa91719b3 100644 --- a/packages/client/ui-entry-customization/README.zh.md +++ b/packages/client/ui-entry-customization/README.zh.md @@ -34,6 +34,8 @@ kind: "package-reference" 在桌面端,**浏览更多插件**与**创建你的插件**在执行前上报 `more_menu_item_click`,`action` 为 `browse_plugins` 或 `create_plugin`,`region` 为菜单所在区域:**更多**为 `sidebar`,**更多会话视图**为 `conversation`,**更多工具**为 `rightbar`。打开入口和更改 Pin 状态不上报,菜单也不上报曝光事件。没有埋点服务时,两项操作行为不变且不上报。 +「浏览插件」一行只在 `pluginNavigation` 服务存在时出现;没有该服务时插件照常启动。 + ----- @@ -44,7 +46,7 @@ kind: "package-reference" [`apply`](src/client/index.ts) 通过 `ctx.slots.inject` 以 id `entry-customization` 把 [`EntryMenuContent`](src/client/EntryMenuContent.tsx) 注册到 `sidebar.panellist.actions`、`conversation.session.header.views.actions` 和 `sidebar.right.tab.guide.actions`,每个注册项跟随所在区域声明的生命周期。各区域在自己的 `Menu` 中渲染这些 slot,并以 owner props 提供入口、Pin 状态、`onOpen`、`onPinnedChange` 与 `onDismiss`。菜单行先调用 `onDismiss`,再打开入口或执行底部操作;Pin 变化不关闭菜单。 -每个注册项的 `inject` 提供所属区域与两个回调。每个回调先调用 `ctx.get('productAnalytics')?.track('more_menu_item_click', { region, action })`。浏览随后调用 `pluginNavigation.openList()`。创建随后按区域取本地化提示语,调用 `uiWorkspace.startSession(undefined, { agentPreset: 'cordis', prompt, whenDraftExists: 'append' })`;该调用不经过 `ui-agent-preset`,预设标签不播放介绍动画。[`dsh-web-app` bundle patch](../../bundle/web-app/cordis.patch.yml) 挂载浏览器插件。 +每个注册项的 `inject` 提供所属区域与两个回调。每个回调先调用 `ctx.get('productAnalytics')?.track('more_menu_item_click', { region, action })`。浏览随后调用 `ctx.get('pluginNavigation')?.openList()`;ui-plugin-manager 只在插件页有权限时提供该服务,因此本包按可选方式读取,不把它写进 `inject`。创建随后按区域取本地化提示语,调用 `uiWorkspace.startSession(undefined, { agentPreset: 'cordis', prompt, whenDraftExists: 'append' })`;该调用不经过 `ui-agent-preset`,预设标签不播放介绍动画。[`dsh-web-app` bundle patch](../../bundle/web-app/cordis.patch.yml) 挂载浏览器插件。 diff --git a/packages/client/ui-entry-customization/package.json b/packages/client/ui-entry-customization/package.json index 51ef4894fb..f98c8ed620 100644 --- a/packages/client/ui-entry-customization/package.json +++ b/packages/client/ui-entry-customization/package.json @@ -25,6 +25,7 @@ "@deepseek-ai/cordis": "workspace:~", "@deepseek-ai/dsh-client-locale": "workspace:*", "@deepseek-ai/dsh-client-product-analytics": "workspace:*", + "@deepseek-ai/dsh-client-store": "workspace:*", "@deepseek-ai/dsh-client-ui-sidebar": "workspace:*", "@deepseek-ai/dsh-client-ui-conversation": "workspace:*", "@deepseek-ai/dsh-client-ui-sidebar-right": "workspace:*", diff --git a/packages/client/ui-entry-customization/src/client/EntryMenuContent.tsx b/packages/client/ui-entry-customization/src/client/EntryMenuContent.tsx index 0370d547ee..22a73babc1 100644 --- a/packages/client/ui-entry-customization/src/client/EntryMenuContent.tsx +++ b/packages/client/ui-entry-customization/src/client/EntryMenuContent.tsx @@ -1,8 +1,8 @@ -/** Rows of a navigation region's More menu: customizable entries, then plugin discovery and creation. */ +/** Rows of a navigation region's More menu: customizable entries, plugin discovery while reachable, and creation. */ import { IconPluginPinwheelOutlineRegular, IconPlusOutlineRegular, MenuItemButton, } from '@deepseek-ai/dsh-client-ui-primitives' -import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type { HostObservable, InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client' import { EntryPickerRow } from './EntryPickerRow.tsx' @@ -13,6 +13,10 @@ export type EntryRegion = 'sidebar' | 'conversation' | 'rightbar' export interface EntryMenuInjected { /** Region of this registration; selects pin copy and the Creator prompt. */ readonly region: EntryRegion + readonly hooks: { + /** Whether the Plugins page navigation service is present; the discovery row shows only while it is. */ + readonly pluginNavigation: HostObservable + } /** Open the Plugins main list. */ readonly browsePlugins: () => void /** Start a Creator draft with this region's prompt without sending it. */ @@ -29,8 +33,9 @@ type EntryMenuContentProps = PropsRuntime<'sidebar.panellist.actions'> * @returns Menu rows for the region's More menu. */ export function EntryMenuContent({ - entries, onOpen, onPinnedChange, onDismiss, region, browsePlugins, createPlugin, t, + entries, onOpen, onPinnedChange, onDismiss, region, usePluginNavigation, browsePlugins, createPlugin, t, }: EntryMenuContentProps) { + const browsable = usePluginNavigation(present => present) return <> {entries.map((entry) => { const pinLabel = t(`${entry.pinned ? 'unpin' : 'pin'}.${region}`) @@ -39,11 +44,12 @@ export function EntryMenuContent({ onOpen={() => { onDismiss(); onOpen(entry.id) }} onPin={() => { onPinnedChange(entry.id, !entry.pinned) }} /> })} - } separatorBefore={entries.length > 0} + {browsable && } separatorBefore={entries.length > 0} onSelect={() => { onDismiss(); browsePlugins() }}> {t('install')} - - } onSelect={() => { onDismiss(); createPlugin() }}> + } + } separatorBefore={!browsable && entries.length > 0} + onSelect={() => { onDismiss(); createPlugin() }}> {t('create')} diff --git a/packages/client/ui-entry-customization/src/client/index.ts b/packages/client/ui-entry-customization/src/client/index.ts index d838a25749..ba41d131a9 100644 --- a/packages/client/ui-entry-customization/src/client/index.ts +++ b/packages/client/ui-entry-customization/src/client/index.ts @@ -1,5 +1,6 @@ /** Rows for the More menus of the left sidebar, Conversation View tabs, and right-sidebar guide. */ import type { Context } from '@deepseek-ai/cordis' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import type {} from '@deepseek-ai/dsh-client-locale/client' import type {} from '@deepseek-ai/dsh-client-product-analytics/client' import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' @@ -18,21 +19,30 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { } } -/** Services used by the menu rows' footer actions. */ -export const inject = ['slots', 'locale', 'pluginNavigation', 'uiWorkspace'] +/** Services used by the menu rows' footer actions; the optional `pluginNavigation` shows the discovery row while present. */ +export const inject = ['slots', 'locale', 'uiWorkspace'] /** * Register the same menu rows into each region's More menu. - * @param ctx - Browser context with slot, locale, plugin navigation, and Workspace services. + * @param ctx - Browser context with slot, locale, and Workspace services, and the optional plugin navigation. */ export function apply(ctx: Context): void { ctx.effect(() => ctx.locale.register('entryCustomization', { en, zh, ja })) const t = ctx.locale.bind('entryCustomization') + // The Plugins page provides its navigation only while the current Peer may use it. + const pluginNavigation = createSnapshotStore(false) + ctx.inject(['pluginNavigation'], (scope: Context) => { + scope.effect(() => { + pluginNavigation.set(true) + return () => { pluginNavigation.set(false) } + }, 'entry-customization: plugin navigation presence') + }) const injected = (region: EntryRegion): EntryMenuInjected => ({ region, + hooks: { pluginNavigation }, browsePlugins: () => { ctx.get('productAnalytics')?.track('more_menu_item_click', { region, action: 'browse_plugins' }) - ctx.pluginNavigation.openList() + ctx.get('pluginNavigation')?.openList() }, createPlugin: () => { ctx.get('productAnalytics')?.track('more_menu_item_click', { region, action: 'create_plugin' }) diff --git a/packages/client/ui-entry-customization/tests/menu.client.spec.tsx b/packages/client/ui-entry-customization/tests/menu.client.spec.tsx index 25560a9f65..1a38e84430 100644 --- a/packages/client/ui-entry-customization/tests/menu.client.spec.tsx +++ b/packages/client/ui-entry-customization/tests/menu.client.spec.tsx @@ -34,6 +34,7 @@ function content(overrides: Partial = {}) { const props = { entries: [{ id: 'notes', label: 'Notes', pinned: true }, { id: 'tasks', label: 'Tasks', pinned: false }], region: 'sidebar', t: makeTranslate(en), renderFactorySlot: unused, + usePluginNavigation: (select: (present: boolean) => T): T => select(true), usePanelInfo: unused, useSessions: unused, useSessionStatus: unused, useSessionRetainInfo: unused, useResource: unused, useWorkspaces: unused, ...callbacks, @@ -112,7 +113,10 @@ function Frame({ renderSlot }: PropsRenderSlots) { } -async function bench(language: 'en' | 'zh' = 'en', { analytics = true }: { analytics?: boolean } = {}) { +async function bench( + language: 'en' | 'zh' = 'en', + { analytics = true, navigation = true }: { analytics?: boolean; navigation?: boolean } = {}, +) { const runtime = await SlotTestRuntime.create() runtimes.add(runtime) const openList = vi.fn(), startSession = vi.fn(), track = vi.fn() @@ -123,7 +127,7 @@ async function bench(language: 'en' | 'zh' = 'en', { analytics = true }: { analy locale.setLocale(language) ctx.provide('locale', locale) ctx.slots.installLocale(locale) - ctx.provide('pluginNavigation', { openList } as never) + if (navigation) ctx.provide('pluginNavigation', { openList } as never) ctx.provide('uiWorkspace', { startSession } as never) if (analytics) ctx.provide('productAnalytics', { track } as never) }, @@ -139,6 +143,29 @@ async function bench(language: 'en' | 'zh' = 'en', { analytics = true }: { analy } describe('More menu registrations', () => { + it('shows plugin discovery only while the Plugins page navigation is present', async () => { + const b = await bench('en', { navigation: false }) + const sidebar = () => within(b.view.getByRole('menu', { name: 'sidebar.panellist.actions' })) + expect(sidebar().queryByRole('menuitem', { name: 'Browse other plugins' })).toBeNull() + expect(sidebar().getByRole('menuitem', { name: 'Create your plugin' })).toBeTruthy() + // The Creator row takes the separator the absent discovery row would carry. + expect(sidebar().getAllByRole('separator')).toHaveLength(1) + const injected = b.runtime.slots.entries('sidebar.panellist.actions')[0]!.inject!() as { browsePlugins: () => void } + expect(() => { injected.browsePlugins() }).not.toThrow() + + const navigation = await b.runtime.mount({ + inject: [], + apply(ctx: Context) { ctx.provide('pluginNavigation', { openList: b.openList } as never) }, + }) + await act(async () => { await Promise.resolve() }) + fireEvent.click(sidebar().getByRole('menuitem', { name: 'Browse other plugins' })) + expect(b.openList).toHaveBeenCalledExactlyOnceWith() + + await navigation.dispose() + await act(async () => { await Promise.resolve() }) + expect(sidebar().queryByRole('menuitem', { name: 'Browse other plugins' })).toBeNull() + }) + it('registers one row set per region and removes all of them when the plugin unloads', async () => { const b = await bench() for (const seat of SEATS) { diff --git a/packages/client/ui-entry-customization/tests/reader-boot.client.spec.ts b/packages/client/ui-entry-customization/tests/reader-boot.client.spec.ts new file mode 100644 index 0000000000..539dd64964 --- /dev/null +++ b/packages/client/ui-entry-customization/tests/reader-boot.client.spec.ts @@ -0,0 +1,12 @@ +// @vitest-environment jsdom +/** A page whose Peer may not list plugin bundles still boots, and the More menus keep their other rows. */ +import { expect } from 'vitest' +import { createClientTest, webApp } from '@deepseek-ai/dsh-client-test-runtime/src/assembly/index.ts' + +const withoutPluginManager = createClientTest({ roster: webApp }, { access: action => action !== 'pluginManager/listBundles' }) + +withoutPluginManager('boots the shipped Web client without the Plugins page navigation', async ({ start }) => { + const c = await start() + expect(c.ctx.get('pluginNavigation')).toBeUndefined() + expect(c.ctx.slots.entries('sidebar.panellist.actions').map(entry => entry.options.id)).toContain('entry-customization') +}, 60_000) diff --git a/packages/client/ui-entry-customization/tsconfig.json b/packages/client/ui-entry-customization/tsconfig.json index 4933d135c5..e65933f5ea 100644 --- a/packages/client/ui-entry-customization/tsconfig.json +++ b/packages/client/ui-entry-customization/tsconfig.json @@ -13,6 +13,7 @@ { "path": "../../client/ui-workspace" }, { "path": "../../client/ui-primitives" }, { "path": "../../client/ui-renderer" }, - { "path": "../../client/ui-slots" } + { "path": "../../client/ui-slots" }, + { "path": "../store" } ] } diff --git a/packages/client/ui-message-feedback/README.i18n.yaml b/packages/client/ui-message-feedback/README.i18n.yaml index e171909bad..91205d3552 100644 --- a/packages/client/ui-message-feedback/README.i18n.yaml +++ b/packages/client/ui-message-feedback/README.i18n.yaml @@ -18,8 +18,8 @@ en: e0127a604ff4aebb zh: 38f1b3db30f81b22 /deepseek-ai-dsh-client-ui-message-feedback/use-this-package/failures: - en: f060de7d9f44b058 - zh: 60770de36be6e90f + en: e4807e47bca06517 + zh: 649a3f9f62771865 /deepseek-ai-dsh-client-ui-message-feedback/understand-the-implementation: en: 61260495346ceb3e zh: 32e05d3eb82fcaf2 diff --git a/packages/client/ui-message-feedback/README.md b/packages/client/ui-message-feedback/README.md index ef293870b6..feffb193d6 100644 --- a/packages/client/ui-message-feedback/README.md +++ b/packages/client/ui-message-feedback/README.md @@ -31,6 +31,8 @@ Mount this plugin alongside `ui-conversation` and `ui-commands`; the Like/Dislik A rating or list-load failure shows inline in the row; a submission failure shows in a warning toast while the dialog stays open so the draft can be corrected. Only finalized messages reach the message entry — an interruption-frozen partial carries no `messageId` and therefore no feedback controls. +Access: the rating buttons render only while the current Peer may call `messageFeedback/put` on the Session; the `/feedback` decoration has no Client check, and the Host refuses a submission without access. + ----- diff --git a/packages/client/ui-message-feedback/README.zh.md b/packages/client/ui-message-feedback/README.zh.md index fce7d27049..27a879c2c8 100644 --- a/packages/client/ui-message-feedback/README.zh.md +++ b/packages/client/ui-message-feedback/README.zh.md @@ -31,6 +31,8 @@ kind: "package-reference" 评分或列表加载失败在行内展示;提交失败通过警告 toast 展示,弹窗保持打开以便修正草稿。只有已定稿的消息能到达消息条目——被中断冻结的部分输出不带 `messageId`,因此没有反馈控件。 +访问控制:评分按钮只在当前 Peer 可以在该会话上调用 `messageFeedback/put` 时渲染;`/feedback` 装饰不做前端判断,没有权限时提交由 Host 拒绝。 + ----- diff --git a/packages/client/ui-message-feedback/src/client/MessageFeedbackActions.tsx b/packages/client/ui-message-feedback/src/client/MessageFeedbackActions.tsx index 6709f79984..c95a28d21b 100644 --- a/packages/client/ui-message-feedback/src/client/MessageFeedbackActions.tsx +++ b/packages/client/ui-message-feedback/src/client/MessageFeedbackActions.tsx @@ -17,12 +17,17 @@ import type { MessageFeedbackActionProps } from './slots.ts' import css from './MessageFeedbackActions.module.css' /** - * One message's feedback controls. - * @param props - the owner's message identity, the injected verbs, and the - * shared feedback hook. - * @returns the rating buttons with any failure notice beside them. + * One message's feedback controls, rendered only while the current Peer may + * record message feedback in this Session. + * @param props - the owner's message identity, the injected verbs, the + * shared feedback hook, and the Session access Hook. + * @returns the rating buttons with any failure notice beside them, or nothing without access. */ -export function MessageFeedbackActions({ +export function MessageFeedbackActions(props: MessageFeedbackActionProps) { + return props.useAccess('messageFeedback/put') ? : null +} + +function FeedbackRatingButtons({ messageId, ensure, current, retract, openDialog, useFeedback, t, }: MessageFeedbackActionProps) { const item = useFeedback(view => view.items.get(messageId)) diff --git a/packages/client/ui-message-feedback/tests/message-feedback-actions.client.spec.tsx b/packages/client/ui-message-feedback/tests/message-feedback-actions.client.spec.tsx index 4121676697..2e80707a58 100644 --- a/packages/client/ui-message-feedback/tests/message-feedback-actions.client.spec.tsx +++ b/packages/client/ui-message-feedback/tests/message-feedback-actions.client.spec.tsx @@ -45,6 +45,8 @@ function mount(options: { ensureResult?: MessageFeedbackActionResult retractResult?: MessageFeedbackActionResult status?: MessageFeedbackView['status'] + /** The Session access answer for recording message feedback; allowed when omitted. */ + allowed?: boolean } = {}) { const view: MessageFeedbackView = { status: options.status ?? 'ready', @@ -58,12 +60,22 @@ function mount(options: { const current = vi.fn((_id: MessageId) => options.committed ?? options.current) const useFeedback = ((select: (v: MessageFeedbackView) => T): T => useSyncExternalStore(() => () => {}, () => select(view))) as never + const useAccess = vi.fn((_action: string) => options.allowed ?? true) const props = { messageId: MSG, ensure, current, retract, openDialog, useFeedback, t } as unknown as Parameters[0] - return { ...render(), ensure, retract, openDialog } + return { ...render(), ensure, retract, openDialog, useAccess } } describe('MessageFeedbackActions', () => { + it('renders nothing and reads nothing while the current Peer may not record message feedback in this Session', () => { + const ui = mount({ allowed: false }) + + expect(ui.useAccess).toHaveBeenCalledWith('messageFeedback/put') + expect(ui.queryByLabelText(zh['action.like'])).toBeNull() + expect(ui.queryByLabelText(zh['action.dislike'])).toBeNull() + expect(ui.ensure).not.toHaveBeenCalled() + }) + it('renders both rating buttons unpressed with no recorded feedback', () => { const ui = mount() @@ -222,7 +234,7 @@ describe('MessageFeedbackActions', () => { useFeedback, t, } as unknown as Parameters[0] - const ui = render() + const ui = render( true} />) const errors: unknown[] = [] const onError = (event: ErrorEvent): void => { errors.push(event.error) } window.addEventListener('error', onError) @@ -252,7 +264,7 @@ describe('MessageFeedbackActions', () => { useFeedback, t, } as unknown as Parameters[0] - const ui = render() + const ui = render( true} />) const errors: unknown[] = [] const onError = (event: ErrorEvent): void => { errors.push(event.error) } window.addEventListener('error', onError) @@ -286,7 +298,7 @@ describe('MessageFeedbackActions', () => { useFeedback, t, } as unknown as Parameters[0] - const ui = render() + const ui = render( true} />) const errors: unknown[] = [] const onError = (event: ErrorEvent): void => { errors.push(event.error) } window.addEventListener('error', onError) diff --git a/packages/client/ui-plan/package.json b/packages/client/ui-plan/package.json index f6dd3292cd..a6a3dea5cf 100644 --- a/packages/client/ui-plan/package.json +++ b/packages/client/ui-plan/package.json @@ -68,9 +68,9 @@ "@deepseek-ai/dsh-client-ui-sidebar-right": "workspace:*", "@deepseek-ai/dsh-client-ui-user-questions": "workspace:*", "@deepseek-ai/dsh-client-resources": "workspace:*", + "@deepseek-ai/dsh-tool-executor-ptc": "workspace:*", "@deepseek-ai/dsh-typert-protocol": "workspace:*", "@deepseek-ai/dsh-llm": "workspace:*", - "@deepseek-ai/dsh-tools": "workspace:*", "@deepseek-ai/dsh-util-crypto": "workspace:*" }, "files": [ diff --git a/packages/client/ui-plan/src/client/plan-definition.ts b/packages/client/ui-plan/src/client/plan-definition.ts index 8c742f71fa..3bb675b3d9 100644 --- a/packages/client/ui-plan/src/client/plan-definition.ts +++ b/packages/client/ui-plan/src/client/plan-definition.ts @@ -1,5 +1,5 @@ /** Per-invocation plans use resolved Conversation locations for final Turn artifacts. */ -import type {} from '@deepseek-ai/dsh-tools/types' +import type {} from '@deepseek-ai/dsh-tool-executor-ptc/client' import type { ChatNode } from '@deepseek-ai/dsh-client-ui-chat/client' import type { ConversationNodeDefinition } from '@deepseek-ai/dsh-client-ui-conversation/client' import { submittedPlan, type SubmittedPlan } from './plan.ts' diff --git a/packages/client/ui-plan/src/client/plan.ts b/packages/client/ui-plan/src/client/plan.ts index d44b8f2dd9..6ec1937f5c 100644 --- a/packages/client/ui-plan/src/client/plan.ts +++ b/packages/client/ui-plan/src/client/plan.ts @@ -1,4 +1,5 @@ /** Plan text and resource identities derived from logged native or PTC calls. */ +import type {} from '@deepseek-ai/dsh-tool-executor-ptc/client' import type { ToolCallId } from '@deepseek-ai/dsh-llm/brand' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { SessionAddress } from '@deepseek-ai/dsh-api-session-controller/types' diff --git a/packages/client/ui-plan/tsconfig.json b/packages/client/ui-plan/tsconfig.json index 5e7d0fb6df..13d4930dd1 100644 --- a/packages/client/ui-plan/tsconfig.json +++ b/packages/client/ui-plan/tsconfig.json @@ -60,10 +60,10 @@ "path": "../../llm/llm" }, { - "path": "../../core/tools" + "path": "../../util/crypto" }, { - "path": "../../util/crypto" + "path": "../../core/tool-executor-ptc" } ] } diff --git a/packages/client/ui-plugin-manager/README.i18n.yaml b/packages/client/ui-plugin-manager/README.i18n.yaml index 922ddc8b0f..66bac65580 100644 --- a/packages/client/ui-plugin-manager/README.i18n.yaml +++ b/packages/client/ui-plugin-manager/README.i18n.yaml @@ -36,20 +36,20 @@ en: 54d7666c630da471 zh: d7ded257d0fd5559 /deepseek-ai-dsh-client-ui-plugin-manager/use-this-package/page-tabs: - en: 1de4fe390871d0da - zh: 1723ae12ccbe3697 + en: fc469bab4fbd6028 + zh: 8f5a04e1b9a46471 /deepseek-ai-dsh-client-ui-plugin-manager/understand-the-implementation: en: 203168fc87e939bd zh: 72d0ccd2c54f3f44 /deepseek-ai-dsh-client-ui-plugin-manager/understand-the-implementation/registration: - en: de110cb51ae1e984 - zh: 8703ded36ae7a1f1 + en: 93871919adaf1082 + zh: 67c9ef0b500e6917 /deepseek-ai-dsh-client-ui-plugin-manager/understand-the-implementation/the-store: en: 21a0bbe8eb92229c zh: 096ff63db61992bb /deepseek-ai-dsh-client-ui-plugin-manager/understand-the-implementation/configuration-slots: - en: 954a30f20c8b3d39 - zh: e76919d88f19c89a + en: b3b8bd3606fc9e97 + zh: 7cd90e182073c8eb /deepseek-ai-dsh-client-ui-plugin-manager/further-exploration: en: f9a42dfed0360d6a zh: 455a70bcb6c69624 diff --git a/packages/client/ui-plugin-manager/README.md b/packages/client/ui-plugin-manager/README.md index 5a56e1e745..99c1a4fb43 100644 --- a/packages/client/ui-plugin-manager/README.md +++ b/packages/client/ui-plugin-manager/README.md @@ -112,6 +112,8 @@ A row's page exists only while a `plugins.row.config` entry names the row, so a Another plugin adds a tab after the page's own **Installed** tab by registering into the `plugins.tab` list slot; the entry's `label` names the tab and `order` places it. The tabs appear under the page header once one entry exists and are hidden on a bundle's, row's, or official plugin's page; the selected tab survives leaving **Plugins**, and a tab whose entry leaves falls back to **Installed**. The page renders the selected entry in place of its groups and hides its own refresh and add buttons there. The owner props carry `install({ spec, registry?, title, version })` and `openPackage(name)`. `install` is for a version the person already confirmed in the tab: the install dialog opens at its check screen, without the spec form, names the plugin with `title` and `version` above the spec, which it shows in smaller monospace text, keeps that name on the progress and result screens, with the installed version once done, asks `registry` first for that install only and never remembers it, and offers **Retry** after a refusal; a hidden installation reopens instead. `openPackage` switches to **Installed** and opens the bundle's page when that tab lists it, and that page's back button returns to the tab that opened it; a bundle the page leaves out, such as a built-in profile bundle, leaves the Installed list open. A confirmed install offers no GitHub mirror recovery, since that recovery reopens the spec form, and its back button cancels a running install. [`ui-plugin-market`](../ui-plugin-market/README.md) contributes the **Discover** tab here. +Access: the Plugins entry, page, refresh notice, Host change subscriptions, and `pluginNavigation` exist only while the current Peer may call `pluginManager/listBundles`; each later grant re-reads a list already loaded, and `pluginChanges` receives no updates while access is withdrawn. The owner always has access. + ----- @@ -130,7 +132,7 @@ The browser plugin registers the `plugins` sidebar entry and its `main` panel th The add control reuses `Button`, `Menu`, and `MenuItemButton`. Both installation actions call the same `openInstall` callback and use `InstallDialog` and `PluginManagerController`. The root-scoped list slot `plugins.add.actions` accepts contributed `MenuItemButton` rows after the install action; each contribution receives an `onDismiss()` callback to close the menu before starting its action. -Other Client plugins can inject `pluginNavigation` and call `openList()` to select the Plugins main list, including when a detail page is already open. This browsing action preserves the current Session and opens no installation dialog. Installation remains an explicit action on the Plugins page. +While the Plugins page has access, other Client plugins can read `pluginNavigation` and call `openList()` to select the Plugins main list, including when a detail page is already open. This browsing action preserves the current Session and opens no installation dialog. Installation remains an explicit action on the Plugins page. ### The store @@ -140,7 +142,7 @@ Other Client plugins can inject `pluginNavigation` and call `openList()` to sele Custom item pages use the Host entry id as their registration id; row pages use the bundle package and row id. The page owner supplies `form.state` and `form.mutate(operations, expectedRevision)` when the entry exposes editable Config fields. A custom page owns its draft and validation display, and may reuse `ConfigField` from ui-primitives. Bundle-wide pages can contain several entries and have no single form. -The page's `main` registration declares `plugins.item`, `plugins.bundle.config`, and `plugins.row.config` as its children, so the slots exist while the page does and a registrant's `ctx.slots.inject` waits for them. `configLedgerSource` projects the three ledgers into one observable — the official items in ledger order with their labels resolved in the active locale, and the bundle and row keys — cached until a ledger or the locale moves; the page binds it as `useConfigLedger` beside the store and never names a configurable plugin itself. The registration-owned navigation store selects the main list, the full experimental plugin list, a bundle, an official plugin, or a bundle row; switching away from the Plugins panel resets it to the list, while React remounts preserve the selected target. A fresh registration starts on the main list. Installation highlighting scrolls to a card only when it is on the current page; it does not change the selected page. Other Client plugins inject `pluginNavigation` and call `ctx.pluginNavigation.openBundle(packageName)` to open bundle details without changing the current Session. The target survives the initial inventory load; an absent bundle displays the list. A registration lives with the browser half that made it. `dsh-client-modules` attaches a package's browser half to the Loader row whose specifier is the bare package name, so every page a bundle registers, for itself or for any of its rows, goes away when that row is switched off; a sub-plugin whose page must outlive the other rows ships as its own package. Cards and detail pages show the Experimental marker only for packages in `EXPERIMENTAL_BUNDLES` in [presentation.ts](src/client/presentation.ts); package-name prefixes do not select the marker. +The page's `main` registration declares `plugins.item`, `plugins.bundle.config`, and `plugins.row.config` as its children, so the slots exist while the page does and a registrant's `ctx.slots.inject` waits for them. `configLedgerSource` projects the three ledgers into one observable — the official items in ledger order with their labels resolved in the active locale, and the bundle and row keys — cached until a ledger or the locale moves; the page binds it as `useConfigLedger` beside the store and never names a configurable plugin itself. The registration-owned navigation store selects the main list, the full experimental plugin list, a bundle, an official plugin, or a bundle row; switching away from the Plugins panel resets it to the list, while React remounts preserve the selected target. A fresh registration starts on the main list. Installation highlighting scrolls to a card only when it is on the current page; it does not change the selected page. Other Client plugins read `pluginNavigation` optionally, through `ctx.get` or `ctx.inject`, and call its `openBundle(packageName)` to open bundle details without changing the current Session; listing it in a plugin's `inject` keeps that plugin pending on a page without access. The target survives the initial inventory load; an absent bundle displays the list. A registration lives with the browser half that made it. `dsh-client-modules` attaches a package's browser half to the Loader row whose specifier is the bare package name, so every page a bundle registers, for itself or for any of its rows, goes away when that row is switched off; a sub-plugin whose page must outlive the other rows ships as its own package. Cards and detail pages show the Experimental marker only for packages in `EXPERIMENTAL_BUNDLES` in [presentation.ts](src/client/presentation.ts); package-name prefixes do not select the marker. `plugins.bundle.config` supplies bundle detail configuration, keyed by npm package name. Client plugins can subscribe to the read-only `ctx.pluginChanges.bundle(packageName)` source to pause their queries while a bundle or one of its rows is being changed. Notifications precede the management request and continue when the page closes; subscribers release their observation on disposal. `plugins.bundle.activation` offers bundle-owned guidance after explicit enablement from the list, with callbacks to dismiss it or open the bundle details. It does not appear merely because an enabled bundle was listed. diff --git a/packages/client/ui-plugin-manager/README.zh.md b/packages/client/ui-plugin-manager/README.zh.md index 6b9f54f19e..996dba9132 100644 --- a/packages/client/ui-plugin-manager/README.zh.md +++ b/packages/client/ui-plugin-manager/README.zh.md @@ -112,6 +112,8 @@ ctx.slots.inject('plugins.detail.section', () => ctx.slots.register({ 其他插件向 `plugins.tab` 列表 slot 注册条目,即可在页面自带的**已安装**标签页之后添加一个标签页;条目的 `label` 为标签页命名,`order` 决定其位置。至少存在一个条目时,标签页显示在页头下方;在组合包、行或官方插件的页面上隐藏。离开**插件**后,所选标签页保持不变;所选标签页的条目被移除时,回到**已安装**。页面渲染所选条目以代替自身的分组,并在此时隐藏自身的刷新和添加按钮。owner props 提供 `install({ spec, registry?, title, version })` 和 `openPackage(name)`。`install` 用于用户已在标签页中确认的版本:安装对话框在检查界面打开,不显示 spec 表单,在以较小等宽字显示的 spec 上方用 `title` 和 `version` 标明插件,并在进度和结果界面保持该名称,完成后显示已安装的版本;只在这一次安装中首先使用 `registry`,且从不记住它;被拒绝后提供**重试**;如果有隐藏中的安装任务,则改为重新打开该任务。`openPackage` 切换到**已安装**,并在该标签页列出该组合包时打开其页面,该页面的返回按钮回到打开它的标签页;页面不列出的组合包(例如内置 profile 组合包)只打开「已安装」列表。已确认的安装不提供 GitHub 镜像恢复,因为该恢复会重新打开 spec 表单;其返回按钮用于取消正在进行的安装。[`ui-plugin-market`](../ui-plugin-market/README.zh.md) 在这里贡献**发现**标签页。 +访问控制:插件入口、页面、刷新提示、Host 变更订阅与 `pluginNavigation` 只在当前 Peer 可以调用 `pluginManager/listBundles` 时存在;之后每次授权都会重读已加载过的列表,撤权期间 `pluginChanges` 不更新。owner 始终有权限。 + ----- @@ -130,7 +132,7 @@ Host 入口通过生成的 Remote 接口暴露 `pluginRegistryProbe.fastest()` 添加控件复用 `Button`、`Menu` 和 `MenuItemButton`。两个安装入口调用同一个 `openInstall` 回调,使用 `InstallDialog` 与 `PluginManagerController`。root 作用域的 list slot `plugins.add.actions` 接受贡献的 `MenuItemButton` 行,放在安装项之后;每个贡献者接收 `onDismiss()` 回调,在开始动作前关闭菜单。 -其他 Client 插件可注入 `pluginNavigation` 并调用 `openList()`,选中插件主列表,已打开详情页时也如此。此浏览操作保留当前 Session,不打开安装对话框。安装仍需在插件页显式发起。 +插件页有权限时,其他 Client 插件可读取 `pluginNavigation` 并调用 `openList()`,选中插件主列表,已打开详情页时也如此。此浏览操作保留当前 Session,不打开安装对话框。安装仍需在插件页显式发起。 ### store @@ -140,7 +142,7 @@ Host 入口通过生成的 Remote 接口暴露 `pluginRegistryProbe.fastest()` 自定义条目页以 Host 条目 id 作为注册 id;行页面使用 bundle 包名和行 id。当条目提供可编辑 Config 字段时,页面宿主传入 `form.state` 和 `form.mutate(operations, expectedRevision)`。自定义页面负责草稿和校验提示,并可复用 ui-primitives 的 `ConfigField`。整个 bundle 的页面可以包含多个条目,因此没有单一表单。 -页面的 `main` 注册把 `plugins.item`、`plugins.bundle.config` 与 `plugins.row.config` 声明为子 slot,因此它们与页面同生,注册方的 `ctx.slots.inject` 会等到它们出现。`configLedgerSource` 把三份账本投影成一个可观察对象——按账本顺序排列、标签按当前语言解析的官方条目,以及组合包与行的键——在账本或语言变化前保持缓存;页面把它作为 `useConfigLedger` 绑在 store 旁边,自身从不点名任何可配置插件。注册拥有的导航 store 选择主列表、完整的实验性插件列表、某个组合包、某个官方插件或组合包的某一行;切换离开插件面板时重置为列表,React 重新挂载则保留所选目标。新的注册从主列表开始。安装后的高亮仅在当前页面有对应卡片时滚动定位,不改变所选页面。其他 Client 插件注入 `pluginNavigation`,调用 `ctx.pluginNavigation.openBundle(packageName)` 即可打开组合包详情,不改变当前 Session。首次读取清单期间保留导航目标;组合包不存在时显示列表。注册与做出它的浏览器半侧同生共死。`dsh-client-modules` 只把一个包的浏览器半侧挂在说明符恰为包名的那一行 Loader 行上,所以组合包为自己或任一行注册的页面,都会在那一行被关闭时一起消失;需要在其他行关闭时仍保留页面的子插件,应作为独立的包发布。 卡片和详情页仅为 [presentation.ts](src/client/presentation.ts) 中 `EXPERIMENTAL_BUNDLES` 名单内的包显示实验性标签,包名前缀不决定标签。 +页面的 `main` 注册把 `plugins.item`、`plugins.bundle.config` 与 `plugins.row.config` 声明为子 slot,因此它们与页面同生,注册方的 `ctx.slots.inject` 会等到它们出现。`configLedgerSource` 把三份账本投影成一个可观察对象——按账本顺序排列、标签按当前语言解析的官方条目,以及组合包与行的键——在账本或语言变化前保持缓存;页面把它作为 `useConfigLedger` 绑在 store 旁边,自身从不点名任何可配置插件。注册拥有的导航 store 选择主列表、完整的实验性插件列表、某个组合包、某个官方插件或组合包的某一行;切换离开插件面板时重置为列表,React 重新挂载则保留所选目标。新的注册从主列表开始。安装后的高亮仅在当前页面有对应卡片时滚动定位,不改变所选页面。其他 Client 插件经 `ctx.get` 或 `ctx.inject` 以可选方式读取 `pluginNavigation`,调用其 `openBundle(packageName)` 即可打开组合包详情,不改变当前 Session;把它写进插件的 `inject` 会让该插件在无权限的页面上一直等待。首次读取清单期间保留导航目标;组合包不存在时显示列表。注册与做出它的浏览器半侧同生共死。`dsh-client-modules` 只把一个包的浏览器半侧挂在说明符恰为包名的那一行 Loader 行上,所以组合包为自己或任一行注册的页面,都会在那一行被关闭时一起消失;需要在其他行关闭时仍保留页面的子插件,应作为独立的包发布。 卡片和详情页仅为 [presentation.ts](src/client/presentation.ts) 中 `EXPERIMENTAL_BUNDLES` 名单内的包显示实验性标签,包名前缀不决定标签。 `plugins.bundle.config` 以 npm 包名为 key,提供 Bundle 详情配置。Client 插件可订阅只读的 `ctx.pluginChanges.bundle(packageName)`,在组合包或其组件行变更期间暂停查询。通知先于管理请求发出,页面关闭后仍会更新;订阅方在卸载时释放观察。`plugins.bundle.activation` 在用户从列表显式启用后提供 Bundle 自有引导,并传入关闭引导和打开详情的回调。仅列出已启用的 Bundle 不会触发引导。 diff --git a/packages/client/ui-plugin-manager/package.json b/packages/client/ui-plugin-manager/package.json index 1cf5abf9b8..a9eceb424d 100644 --- a/packages/client/ui-plugin-manager/package.json +++ b/packages/client/ui-plugin-manager/package.json @@ -55,6 +55,7 @@ }, "devDependencies": { "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-api-remote-access": "workspace:*", "@deepseek-ai/dsh-api-remotes": "workspace:*", "@deepseek-ai/dsh-client-locale": "workspace:*", "@deepseek-ai/dsh-client-store": "workspace:*", diff --git a/packages/client/ui-plugin-manager/src/client/index.ts b/packages/client/ui-plugin-manager/src/client/index.ts index 52506f9fb7..2a6a3a48da 100644 --- a/packages/client/ui-plugin-manager/src/client/index.ts +++ b/packages/client/ui-plugin-manager/src/client/index.ts @@ -16,6 +16,8 @@ import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots' import type { MainPanelId } from '@deepseek-ai/dsh-client-ui-layout/client' import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client' import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' +// Type-only: the ctx.remoteAccess Context merge. +import type {} from '@deepseek-ai/dsh-api-remote-access/client' // Type-only: the ctx.remote Context merge and the forwarded-event key face. import type {} from '@deepseek-ai/dsh-api-remotes/client' // Type-only: the forwarded events' own declaration (`$on`'s key face resolves @@ -79,12 +81,13 @@ export const NS = 'pluginManager' /** The id shared by the sidebar entry and the main panel it opens. */ export const PANEL_ID = 'plugins' as MainPanelId -/** Services required by the sidebar registration and the Remote methods; the inventory says whether the Host manages a profile. */ -export const inject = ['slots', 'locale', 'remote', 'remote.pluginManager', 'remote.pluginInventory', 'remote.pluginRegistryProbe', 'configForms', 'layout'] +/** Services required by the sidebar registration, the Remote methods, and access; the inventory says whether the Host manages a profile. */ +export const inject = ['slots', 'locale', 'remote', 'remote.pluginManager', 'remote.pluginInventory', 'remote.pluginRegistryProbe', 'configForms', 'layout', 'remoteAccess'] /** * Contribute the Plugins entry to the sidebar with the management page it - * opens, and keep it current on the Host's change events. + * opens, and keep it current on the Host's change events, while the current + * Peer may list the installed bundles. * @param ctx - the browser plugin context. */ export function apply(ctx: ClientContext): void { @@ -93,82 +96,79 @@ export function apply(ctx: ClientContext): void { const controller = new PluginManagerController(ctx) ctx.effect(() => () => { controller.dispose() }, 'ui-plugin-manager: controller') ctx.provide('pluginChanges', { bundle: packageName => controller.observeChanges(packageName) }) - // The Host says when what is installed, enabled, or composed changed — from - // this page, the CLI, or another browser — and streams install output. - ctx.effect(() => { - // A page never rendered holds no snapshot to refresh. - const refresh = (): void => { - // A profile change can replace a bundle's files, and a restarted Host is read afresh. - controller.details.reset() - if (controller.getSnapshot().status !== 'idle') void controller.load() - } - const disposers = [ - ctx.remote.$on('plugin-manager/changed', refresh), - ctx.remote.$on('plugin-manager/install-log', (chunk) => { controller.appendLog(chunk) }), - ctx.remote.$on('plugin-manager/install-state', (progress) => { controller.installProgress(progress) }), - ctx.remote.$on('plugin-manager/update-state', (progress) => { controller.updateProgress(progress) }), - ctx.on('connection/reset', refresh), - ] - return () => { for (const dispose of disposers) dispose() } - }, 'ui-plugin-manager: host invalidations') - // The page is a global panel: it belongs to the profile, not to a Session, // and the sidebar's entry selects it. What is installed and switched on is // the page's own; a plugin's configuration arrives through the slots the // page declares here, so the page never names a configurable plugin. const configLedger = configLedgerSource(ctx) const face = controller.inject(configLedger, tabLedgerSource(ctx), text => ctx.locale.resolveText(text)) - ctx.slots.inject('shell.overlay', () => ctx.slots.register({ - name: 'shell.overlay', id: 'plugin-manager.refresh-toast', locale: NS, - inject: (): PluginRefreshToastFace => ({ - hooks: { pluginManager: face.hooks.pluginManager }, - dismissNotice: face.dismissNotice, - }), - }, PluginRefreshToast)) - ctx.slots.inject('main', function* () { - const handle = createNavigationStore(), instance = handle.create() - const store: typeof handle = { ...handle, create: () => instance } - yield ctx.slots.register({ - name: 'main', - key: PANEL_ID, + ctx.remoteAccess.whenAccess('pluginManager/listBundles', function* () { + // The Host says when what is installed, enabled, or composed changed — from + // this page, the CLI, or another browser — and streams install output. + // A page never rendered holds no snapshot to refresh. + const refresh = (): void => { + // A profile change can replace a bundle's files, and a restarted Host is read afresh. + controller.details.reset() + if (controller.getSnapshot().status !== 'idle') void controller.load() + } + // Host changes made while access was withdrawn reached no subscription below. + refresh() + yield ctx.remote.$on('plugin-manager/changed', refresh) + yield ctx.remote.$on('plugin-manager/install-log', (chunk) => { controller.appendLog(chunk) }) + yield ctx.remote.$on('plugin-manager/install-state', (progress) => { controller.installProgress(progress) }) + yield ctx.remote.$on('plugin-manager/update-state', (progress) => { controller.updateProgress(progress) }) + yield ctx.on('connection/reset', refresh) + yield ctx.slots.inject('shell.overlay', () => ctx.slots.register({ + name: 'shell.overlay', id: 'plugin-manager.refresh-toast', locale: NS, + inject: (): PluginRefreshToastFace => ({ + hooks: { pluginManager: face.hooks.pluginManager }, + dismissNotice: face.dismissNotice, + }), + }, PluginRefreshToast)) + yield ctx.slots.inject('main', function* () { + const handle = createNavigationStore(), instance = handle.create() + const store: typeof handle = { ...handle, create: () => instance } + yield ctx.slots.register({ + name: 'main', + key: PANEL_ID, + locale: NS, + store, + inject: () => face, + children: { + 'plugins.add.actions': { kind: 'list', scope: 'root' }, + 'plugins.tab': { kind: 'list', scope: 'root' }, + 'plugins.item': { kind: 'list', scope: 'root' }, + 'plugins.bundle.activation': { kind: 'keyed', scope: 'root' }, + 'plugins.bundle.config': { kind: 'keyed', scope: 'root' }, + 'plugins.row.config': { kind: 'keyed', scope: 'root' }, + 'plugins.detail.actions': { kind: 'list', scope: 'root' }, + 'plugins.detail.badge': { kind: 'list', scope: 'root' }, + 'plugins.detail.section': { kind: 'list', scope: 'root' }, + }, + }, PluginManagerPage) + yield ctx.layout.panelInfo.subscribe(() => { + if (ctx.layout.panelInfo.getSnapshot().activePanelId !== PANEL_ID) instance.actions.setView({ kind: 'list' }) + }) + const disposeNavigation = ctx.reflect.provide('pluginNavigation', { + openList: () => { + ctx.layout.selectPanel(PANEL_ID) + instance.actions.setTab('installed') + instance.actions.setView({ kind: 'list' }) + }, + openBundle: (packageName: string) => { + ctx.layout.selectPanel(PANEL_ID) + instance.actions.setTab('installed') + instance.actions.setView({ kind: 'package', name: packageName }) + }, + }) + yield () => { void disposeNavigation() } + }) + yield ctx.slots.inject('sidebar.panellist', () => ctx.slots.register({ + name: 'sidebar.panellist', + id: PANEL_ID, + order: 0, + label: () => t('panel'), locale: NS, - store, - inject: () => face, - children: { - 'plugins.add.actions': { kind: 'list', scope: 'root' }, - 'plugins.tab': { kind: 'list', scope: 'root' }, - 'plugins.item': { kind: 'list', scope: 'root' }, - 'plugins.bundle.activation': { kind: 'keyed', scope: 'root' }, - 'plugins.bundle.config': { kind: 'keyed', scope: 'root' }, - 'plugins.row.config': { kind: 'keyed', scope: 'root' }, - 'plugins.detail.actions': { kind: 'list', scope: 'root' }, - 'plugins.detail.badge': { kind: 'list', scope: 'root' }, - 'plugins.detail.section': { kind: 'list', scope: 'root' }, - }, - }, PluginManagerPage) - yield ctx.layout.panelInfo.subscribe(() => { - if (ctx.layout.panelInfo.getSnapshot().activePanelId !== PANEL_ID) instance.actions.setView({ kind: 'list' }) - }) - const disposeNavigation = ctx.reflect.provide('pluginNavigation', { - openList: () => { - ctx.layout.selectPanel(PANEL_ID) - instance.actions.setTab('installed') - instance.actions.setView({ kind: 'list' }) - }, - openBundle: (packageName: string) => { - ctx.layout.selectPanel(PANEL_ID) - instance.actions.setTab('installed') - instance.actions.setView({ kind: 'package', name: packageName }) - }, - }) - yield () => { void disposeNavigation() } + }, PluginsPanelIcon)) }) - ctx.slots.inject('sidebar.panellist', () => ctx.slots.register({ - name: 'sidebar.panellist', - id: PANEL_ID, - order: 0, - label: () => t('panel'), - locale: NS, - }, PluginsPanelIcon)) - } diff --git a/packages/client/ui-plugin-manager/tests/browser-plugin.client.spec.tsx b/packages/client/ui-plugin-manager/tests/browser-plugin.client.spec.tsx index 83f72145d7..2a6f019ad4 100644 --- a/packages/client/ui-plugin-manager/tests/browser-plugin.client.spec.tsx +++ b/packages/client/ui-plugin-manager/tests/browser-plugin.client.spec.tsx @@ -8,7 +8,7 @@ import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import type { ILayout, PanelInfo } from '@deepseek-ai/dsh-client-ui-layout/client' import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' -import { allowAllAccess, TestRemote, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' +import { allowAllAccess, StaticRemoteAccess, TestRemote, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' import * as settings from '@deepseek-ai/dsh-client-ui-settings/client' import { apply, inject, NS, PANEL_ID } from '../src/client/index.ts' import { PluginManagerPage } from '../src/client/PluginManagerPage.tsx' @@ -21,10 +21,53 @@ usePinnedBrowserLanguages('zh-CN') beforeEach(() => { localStorage.clear() }) afterEach(cleanup) -async function bench() { +type AccessEffect = (() => void) | Iterable<() => void> + +/** One guarded callback, the Context that owns it, and the disposer of its current installation. */ +interface Guard { + readonly owner: Context + readonly callback: () => AccessEffect + dispose: (() => void) | undefined +} + +/** A `remoteAccess` stand-in whose one answer a spec switches, installing or withdrawing every guarded effect. */ +class SwitchableAccess extends Service { + private allowed = true + private readonly guards: Guard[] = [] + + constructor(serviceCtx: Context) { + super(serviceCtx, 'remoteAccess') + } + + whenAccess(_action: string, callback: () => AccessEffect): () => void { + const guard: Guard = { owner: this.ctx, callback, dispose: undefined } + this.guards.push(guard) + this.install(guard) + return () => {} + } + + /** @param allowed - the answer every guarded effect follows from now on. */ + set(allowed: boolean): void { + this.allowed = allowed + for (const guard of this.guards) { + if (allowed) this.install(guard) + else { guard.dispose?.(); guard.dispose = undefined } + } + } + + private install(guard: Guard): void { + if (!this.allowed || guard.dispose !== undefined) return + const dispose = guard.owner.effect(guard.callback) + guard.dispose = () => { void dispose() } + } +} + +async function bench(allows: ((action: string) => boolean) | 'switchable' = () => true) { const ctx = new Context() onTestFinished(async () => { await ctx.fiber.dispose() }) await ctx.plugin(SlotRegistry).await() + const access = allows === 'switchable' ? new SwitchableAccess(ctx) : undefined + if (allows !== 'switchable') new StaticRemoteAccess(ctx, allows) const locale = new LocaleRuntime(ctx) ctx.provide('locale', locale) class LocaleHolder extends Service { @@ -50,7 +93,7 @@ async function bench() { ctx.provide('layout', { panelInfo, selectPanel, beginNavigation: () => new AbortController().signal, toggleSidebar: vi.fn(), openRightbar: vi.fn(), closeRightbar: vi.fn() }) await ctx.plugin(settings).await() - return { ctx, slots: ctx.get('slots') as SlotRegistry, locale, list, remote, selectPanel, panelInfo } + return { ctx, slots: ctx.get('slots') as SlotRegistry, locale, list, remote, selectPanel, panelInfo, access } } function declare(slots: SlotRegistry): () => void { @@ -153,7 +196,41 @@ describe('ui-plugin-manager browser plugin', () => { }) it('declares only the services the page and its Remote methods use', () => { - expect(inject).toEqual(['slots', 'locale', 'remote', 'remote.pluginManager', 'remote.pluginInventory', 'remote.pluginRegistryProbe', 'configForms', 'layout']) + expect(inject).toEqual(['slots', 'locale', 'remote', 'remote.pluginManager', 'remote.pluginInventory', 'remote.pluginRegistryProbe', 'configForms', 'layout', 'remoteAccess']) + }) + + it('reads the Host again on a later grant after showing the page, and not while access is withdrawn', async () => { + const b = await bench('switchable') + declare(b.slots) + await b.ctx.plugin({ inject: [...inject], apply }).await() + const injected: object = b.slots.entries('main')[0]!.inject!() + const face = injected as PluginManagerFace + face.ensure() + await vi.waitFor(() => { expect(face.hooks.pluginManager.getSnapshot().status).toBe('ready') }) + expect(b.list).toHaveBeenCalledTimes(1) + + b.access!.set(false) + expect(b.slots.entries('main')).toEqual([]) + b.remote.emit('plugin-manager/changed', [{ reason: 'install' }]) + await Promise.resolve() + expect(b.list).toHaveBeenCalledTimes(1) + + b.access!.set(true) + expect(b.slots.entries('main')).toHaveLength(1) + await vi.waitFor(() => { expect(b.list).toHaveBeenCalledTimes(2) }) + }) + + it('contributes the entry, page, and navigation only while the current Peer may list bundles, and observes changes either way', async () => { + const allows = vi.fn(() => false) + const b = await bench(allows) + declare(b.slots) + const fiber = b.ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + expect(allows).toHaveBeenCalledWith('pluginManager/listBundles') + for (const name of ['main', 'shell.overlay', 'sidebar.panellist'] as const) expect(b.slots.entries(name)).toEqual([]) + expect(b.ctx.get('pluginNavigation')).toBeUndefined() + expect(b.ctx.get('pluginChanges')?.bundle('dsh-any').getSnapshot()).toBe(false) + await fiber.dispose() }) it('registers the sidebar entry and its page, which reads the Host only once rendered and follows Host changes', async () => { diff --git a/packages/client/ui-schedule/README.i18n.yaml b/packages/client/ui-schedule/README.i18n.yaml index d3f7274631..f21ddb5997 100644 --- a/packages/client/ui-schedule/README.i18n.yaml +++ b/packages/client/ui-schedule/README.i18n.yaml @@ -18,8 +18,8 @@ en: 4c0dca55e4e9866d zh: 9ee6b565c7a81353 /deepseek-ai-dsh-client-ui-schedule/use-this-package/time-zone-data-source: - en: 6bd6882dbbbefcb0 - zh: 03aa15f1a65d13dc + en: 47cd16c142e82385 + zh: 181849a3c7a167fd /deepseek-ai-dsh-client-ui-schedule/understand-the-implementation: en: 959145f9a08c3913 zh: caae642a82d2acd3 diff --git a/packages/client/ui-schedule/README.md b/packages/client/ui-schedule/README.md index bfcb80179c..da8d202d0d 100644 --- a/packages/client/ui-schedule/README.md +++ b/packages/client/ui-schedule/README.md @@ -57,6 +57,8 @@ The Sidebar's Session rows mark scheduled Sessions: while a row is idle, unarchi Time-zone inventory and localization are offline. `Intl` reads ICU/CLDR tables embedded in the browser runtime and never fetches or downloads zone data while DSH runs. There is no crawler to refresh; a third-party locale supplies `time.locale` and translates only the selector's own UI copy. +Access: the plugin registers these surfaces only while the current Peer may call `schedule/catalog`, and the Session-header reminder catalog renders only while that Session also allows `schedule/list`; the task tab type and the turn definition stay registered. The owner always has access. + ## Understand the implementation diff --git a/packages/client/ui-schedule/README.zh.md b/packages/client/ui-schedule/README.zh.md index b201a5ca5d..057e42a2ac 100644 --- a/packages/client/ui-schedule/README.zh.md +++ b/packages/client/ui-schedule/README.zh.md @@ -57,6 +57,8 @@ kind: "package-reference" 时区清单与本地化完全离线。`Intl` 读取浏览器运行时内嵌的 ICU/CLDR 表,DSH 运行时不会获取或下载时区数据。没有需要刷新的爬虫;第三方语言包提供 `time.locale`,并仅翻译选择器自身的 UI 文案。 +访问控制:只有当前 Peer 可以调用 `schedule/catalog` 时本插件才注册这些界面,会话头部的提醒还要求该会话允许 `schedule/list` 才渲染;任务 tab 类型与 turn 定义始终注册。owner 始终有权限。 + ## 理解实现 diff --git a/packages/client/ui-schedule/package.json b/packages/client/ui-schedule/package.json index dce1c30814..5d149a8d39 100644 --- a/packages/client/ui-schedule/package.json +++ b/packages/client/ui-schedule/package.json @@ -54,6 +54,7 @@ "@deepseek-ai/cordis": "workspace:~" }, "devDependencies": { + "@deepseek-ai/dsh-api-remote-access": "workspace:*", "@deepseek-ai/dsh-api-remotes": "workspace:*", "@deepseek-ai/dsh-api-session-controller": "workspace:*", "@deepseek-ai/dsh-api-workspace-controller": "workspace:*", diff --git a/packages/client/ui-schedule/src/client/ScheduleCatalogAction.tsx b/packages/client/ui-schedule/src/client/ScheduleCatalogAction.tsx index abeba05542..9aa03b8f81 100644 --- a/packages/client/ui-schedule/src/client/ScheduleCatalogAction.tsx +++ b/packages/client/ui-schedule/src/client/ScheduleCatalogAction.tsx @@ -31,8 +31,17 @@ export type ScheduleCatalogActionProps = const SECOND_MS = 1_000 const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 } -/** Current-Session reminder catalog with durable deletion. */ -export function ScheduleCatalogAction({ +/** + * Current-Session reminder catalog with durable deletion, rendered only while + * the current Peer may list this Session's reminders. + * @param props - the Session seats, the injected catalog and verbs, and localized copy. + * @returns the reminder catalog action, or nothing without access. + */ +export function ScheduleCatalogAction(props: ScheduleCatalogActionProps) { + return props.useAccess('schedule/list') ? : null +} + +function SessionScheduleCatalog({ useSession, useCatalog, onDelete, onRetry, openTaskDetail, t, }: ScheduleCatalogActionProps) { const openState = useSession(snapshot => snapshot.openState) diff --git a/packages/client/ui-schedule/src/client/index.ts b/packages/client/ui-schedule/src/client/index.ts index 4aa83ae1a8..8c6dcb3c5f 100644 --- a/packages/client/ui-schedule/src/client/index.ts +++ b/packages/client/ui-schedule/src/client/index.ts @@ -38,6 +38,7 @@ import type {} from '@deepseek-ai/dsh-client-ui-sidebar-right/client' import type {} from '@deepseek-ai/dsh-client-ui-tool/client' import type {} from '@deepseek-ai/dsh-client-ui-workspace/client' import type { MainPanelId } from '@deepseek-ai/dsh-client-ui-layout/client' +import type {} from '@deepseek-ai/dsh-api-remote-access/client' import type {} from '@deepseek-ai/dsh-api-remotes/client' import type {} from '@deepseek-ai/dsh-client-connection/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' @@ -73,16 +74,16 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { } } -/** Required services for catalogs, ambient Session marks, the right Sidebar, Remote queries, and original-Session navigation. */ +/** Required services for catalogs, ambient Session marks, the right Sidebar, Remote queries, original-Session navigation, and access. */ export const inject = [ 'slots', 'locale', 'remote', 'remote.schedule', 'conversation', 'uiConversation', 'uiWorkspace', 'sessions', - 'workspaces', 'sidebarRightTabs', 'sidebarRight', + 'workspaces', 'sidebarRightTabs', 'sidebarRight', 'remoteAccess', ] /** * Register the Host task page, the Session-header reminder catalog, the * right-Sidebar page that shows one task's detail, and the transcript card of - * one created task. + * one created task while the current Peer may read the task catalog. * @param ctx - browser services used by these contributions. */ export function apply(ctx: ClientContext): void { @@ -90,157 +91,159 @@ export function apply(ctx: ClientContext): void { const managerDictionaries = { zh: managerZh, en: managerEn, ja: managerJa } ctx.effect(() => ctx.locale.register(MANAGER_NS, managerDictionaries), 'ui-schedule: manager dictionaries') const t = ctx.locale.bind(MANAGER_NS) - const manager: CatalogInjected = createCatalogSource({ - list: () => ctx.remote.schedule.catalog(), - remove: async (id) => { - const record = manager.hooks.catalog.getSnapshot().records.find(item => item.id === id) - if (record === undefined) return { ok: true, value: { id, deleted: false, code: 'schedule_not_found' } } - return ctx.remote.schedule.delete({ sessionId: record.sessionId, id }) - }, - subscribeChanged: listener => ctx.remote.$on('schedule/changed', listener), - subscribeReset: listener => ctx.on('connection/reset', listener), - }) - // One outcome store behind one `shell.overlay` entry announces every deletion - // this plugin's surfaces settle, so the notice outlives the panel, popover, or - // tab the deletion started in. - const deleteToast = createDeleteToastSource() - const reportedDelete = (onDelete: (id: ScheduleId) => Promise) => - async (id: ScheduleId): Promise => { - const outcome = await onDelete(id) - deleteToast.report(outcome) - return outcome - } - ctx.slots.inject('shell.overlay', () => ctx.slots.register({ - name: 'shell.overlay', id: 'schedule.delete-toast', locale: MANAGER_NS, - inject: () => ({ hooks: deleteToast.hooks, dismiss: deleteToast.dismiss }), - }, ScheduleDeleteToast)) - /** - * Forward one compare-and-update request, which may replace the task name, - * the instruction, and/or the timing, and refresh the authoritative catalog - * after an accepted or stale outcome. - * @param request - complete expected record with the optional content and timing change. - * @returns the original Remote mutation result. - */ - const updateTask = async (request: ScheduleUpdateRequest) => { - const result = await ctx.remote.schedule.update(request) - if (result.ok && ('record' in result.value || result.value.code === 'schedule_conflict' - || result.value.code === 'schedule_ended' || result.value.code === 'schedule_not_found')) { - // The write is in, so this readback must not join a read that was sent - // before it: naming every request seen so far makes the source issue a new - // one, the way the post-deletion readback supersedes. - await manager.onRetry(manager.hooks.catalog.getSnapshot().readRequest) - } - return result - } - const loadHistory = (request: ScheduleDeliveryHistoryRequest) => ctx.remote.schedule.history(request) - const openSession = (id: SessionId): void => { - if (sessionLinkState(id, ctx.sessions.list.getSnapshot(), ctx.workspaces.list.getSnapshot()) === 'available') { - ctx.uiWorkspace.openSession(id) - } - } - const detail: TaskDetailInjected & ScheduleTaskCatalogInjected = { - hooks: manager.hooks, - onDelete: reportedDelete(manager.onDelete), - onRetry: manager.onRetry, - onUpdateTiming: updateTask, - loadHistory, - onOpenSession: openSession, - } - // The Sidebar persists a tab's layout record and not its navigation - // parameters, so this page type keeps the task it last showed per Session and - // tab id. That Session's committed tab ids bound every write, so a closed tab - // leaves no entry behind. - const taskBindings = new TaskTabBindings( - sessionId => ctx.sidebarRight.tabsIn(sessionId).map(tab => tab.id), - ) ctx.effect(() => ctx.sidebarRightTabs.register(scheduleTaskDefinition(t)), 'ui-schedule: task tab type') - ctx.slots.inject('sidebar.right.pane.tab', () => ctx.slots.register({ - name: 'sidebar.right.pane.tab', - key: SCHEDULE_TASK_ID, - locale: MANAGER_NS, - inject: (): ScheduleTaskTabInjected => ({ ...detail, taskBindings }), - }, ScheduleTaskTab)) - ctx.slots.inject('sidebar.right.pane.tab.title', () => ctx.slots.register({ - name: 'sidebar.right.pane.tab.title', - key: SCHEDULE_TASK_ID, - locale: MANAGER_NS, - inject: (): ScheduleTaskCatalogInjected & ScheduleTaskBindingInjected => ({ hooks: detail.hooks, taskBindings }), - }, ScheduleTaskTabTitle)) - ctx.slots.inject('main', () => ctx.slots.register({ - name: 'main', - key: PANEL_ID, - locale: MANAGER_NS, - inject: (): TaskManagerInjected => ({ - ...detail, - // The page has no creation form: a new reminder starts in a Session. - onNewTask: () => { ctx.uiWorkspace.startSession() }, - }), - }, TaskManagerPage)) - ctx.slots.inject('sidebar.panellist', () => ctx.slots.register({ - name: 'sidebar.panellist', - id: PANEL_ID, - order: 10, - locale: MANAGER_NS, - label: () => t('panel'), - }, TaskManagerIcon)) // The created task is a Turn-level element, not a Tool-group row: the Turn - // Definition publishes the settled result and this tail list entry renders - // the card beneath the closing prose. + // Definition publishes the settled result and the tail list entry below + // renders the card beneath the closing prose. ctx.uiConversation.events.register(scheduleTurnDefinition) - ctx.slots.inject('conversation.chat.turnTail', () => ctx.slots.register({ - name: 'conversation.chat.turnTail', - id: 'schedule-created', - order: 20, - locale: MANAGER_NS, - inject: (sessionId: SessionId): ScheduleTurnCardInjected => ({ - hooks: { catalog: manager.hooks.catalog }, + ctx.remoteAccess.whenAccess('schedule/catalog', function* () { + const manager: CatalogInjected = createCatalogSource({ + list: () => ctx.remote.schedule.catalog(), + remove: async (id) => { + const record = manager.hooks.catalog.getSnapshot().records.find(item => item.id === id) + if (record === undefined) return { ok: true, value: { id, deleted: false, code: 'schedule_not_found' } } + return ctx.remote.schedule.delete({ sessionId: record.sessionId, id }) + }, + subscribeChanged: listener => ctx.remote.$on('schedule/changed', listener), + subscribeReset: listener => ctx.on('connection/reset', listener), + }) + // One outcome store behind one `shell.overlay` entry announces every deletion + // this plugin's surfaces settle, so the notice outlives the panel, popover, or + // tab the deletion started in. + const deleteToast = createDeleteToastSource() + const reportedDelete = (onDelete: (id: ScheduleId) => Promise) => + async (id: ScheduleId): Promise => { + const outcome = await onDelete(id) + deleteToast.report(outcome) + return outcome + } + yield ctx.slots.inject('shell.overlay', () => ctx.slots.register({ + name: 'shell.overlay', id: 'schedule.delete-toast', locale: MANAGER_NS, + inject: () => ({ hooks: deleteToast.hooks, dismiss: deleteToast.dismiss }), + }, ScheduleDeleteToast)) + /** + * Forward one compare-and-update request, which may replace the task name, + * the instruction, and/or the timing, and refresh the authoritative catalog + * after an accepted or stale outcome. + * @param request - complete expected record with the optional content and timing change. + * @returns the original Remote mutation result. + */ + const updateTask = async (request: ScheduleUpdateRequest) => { + const result = await ctx.remote.schedule.update(request) + if (result.ok && ('record' in result.value || result.value.code === 'schedule_conflict' + || result.value.code === 'schedule_ended' || result.value.code === 'schedule_not_found')) { + // The write is in, so this readback must not join a read that was sent + // before it: naming every request seen so far makes the source issue a new + // one, the way the post-deletion readback supersedes. + await manager.onRetry(manager.hooks.catalog.getSnapshot().readRequest) + } + return result + } + const loadHistory = (request: ScheduleDeliveryHistoryRequest) => ctx.remote.schedule.history(request) + const openSession = (id: SessionId): void => { + if (sessionLinkState(id, ctx.sessions.list.getSnapshot(), ctx.workspaces.list.getSnapshot()) === 'available') { + ctx.uiWorkspace.openSession(id) + } + } + const detail: TaskDetailInjected & ScheduleTaskCatalogInjected = { + hooks: manager.hooks, + onDelete: reportedDelete(manager.onDelete), onRetry: manager.onRetry, - openTaskDetail: (id) => { - ctx.sidebarRight.openTab(SCHEDULE_TASK_KIND, { params: { sessionId, id } }) - }, - }), - }, ScheduleTurnCard)) - // The Session header keeps one per-Session source: it lists the open - // Session's active tasks and starts only while that header is mounted. The - // Sidebar row seats read the shared `manager` catalog instead, so every - // visible row projects one Host query rather than issuing its own. - const createSource: SessionScheduleSourceFactory = sessionId => - createSessionScheduleSource(ctx, sessionId) - ctx.slots.inject( - 'conversation.session.header.utilities', - () => ctx.slots.register({ - name: 'conversation.session.header.utilities', - id: 'schedule-catalog', - // Sits left of the overflow menu (order 0) and right of the header's - // leading utilities (order -10), matching the shipped mock. - order: -5, + onUpdateTiming: updateTask, + loadHistory, + onOpenSession: openSession, + } + // The Sidebar persists a tab's layout record and not its navigation + // parameters, so this page type keeps the task it last showed per Session and + // tab id. That Session's committed tab ids bound every write, so a closed tab + // leaves no entry behind. + const taskBindings = new TaskTabBindings( + sessionId => ctx.sidebarRight.tabsIn(sessionId).map(tab => tab.id), + ) + yield ctx.slots.inject('sidebar.right.pane.tab', () => ctx.slots.register({ + name: 'sidebar.right.pane.tab', + key: SCHEDULE_TASK_ID, + locale: MANAGER_NS, + inject: (): ScheduleTaskTabInjected => ({ ...detail, taskBindings }), + }, ScheduleTaskTab)) + yield ctx.slots.inject('sidebar.right.pane.tab.title', () => ctx.slots.register({ + name: 'sidebar.right.pane.tab.title', + key: SCHEDULE_TASK_ID, + locale: MANAGER_NS, + inject: (): ScheduleTaskCatalogInjected & ScheduleTaskBindingInjected => ({ hooks: detail.hooks, taskBindings }), + }, ScheduleTaskTabTitle)) + yield ctx.slots.inject('main', () => ctx.slots.register({ + name: 'main', + key: PANEL_ID, + locale: MANAGER_NS, + inject: (): TaskManagerInjected => ({ + ...detail, + // The page has no creation form: a new reminder starts in a Session. + onNewTask: () => { ctx.uiWorkspace.startSession() }, + }), + }, TaskManagerPage)) + yield ctx.slots.inject('sidebar.panellist', () => ctx.slots.register({ + name: 'sidebar.panellist', + id: PANEL_ID, + order: 10, + locale: MANAGER_NS, + label: () => t('panel'), + }, TaskManagerIcon)) + yield ctx.slots.inject('conversation.chat.turnTail', () => ctx.slots.register({ + name: 'conversation.chat.turnTail', + id: 'schedule-created', + order: 20, + locale: MANAGER_NS, + inject: (sessionId: SessionId): ScheduleTurnCardInjected => ({ + hooks: { catalog: manager.hooks.catalog }, + onRetry: manager.onRetry, + openTaskDetail: (id) => { + ctx.sidebarRight.openTab(SCHEDULE_TASK_KIND, { params: { sessionId, id } }) + }, + }), + }, ScheduleTurnCard)) + // The Session header keeps one per-Session source: it lists the open + // Session's active tasks and starts only while that header is mounted. The + // Sidebar row seats read the shared `manager` catalog instead, so every + // visible row projects one Host query rather than issuing its own. + const createSource: SessionScheduleSourceFactory = sessionId => + createSessionScheduleSource(ctx, sessionId) + yield ctx.slots.inject( + 'conversation.session.header.utilities', + () => ctx.slots.register({ + name: 'conversation.session.header.utilities', + id: 'schedule-catalog', + // Sits left of the overflow menu (order 0) and right of the header's + // leading utilities (order -10), matching the shipped mock. + order: -5, + locale: NS, + inject: (sessionId) => { + const source = createSource(sessionId) + return { + ...source, + // The header's deletions settle through the same app-wide toast as the + // Tasks page and the task tab. + onDelete: reportedDelete(source.onDelete), + openTaskDetail: (id: ScheduleId) => { + ctx.sidebarRight.openTab(SCHEDULE_TASK_KIND, { params: { sessionId, id } }) + }, + } + }, + }, ScheduleCatalogAction), + ) + yield ctx.slots.inject('sidebar.session.row.leading', () => ctx.slots.register({ + name: 'sidebar.session.row.leading', + id: 'schedule-mark', + order: 10, locale: NS, - inject: (sessionId) => { - const source = createSource(sessionId) - return { - ...source, - // The header's deletions settle through the same app-wide toast as the - // Tasks page and the task tab. - onDelete: reportedDelete(source.onDelete), - openTaskDetail: (id: ScheduleId) => { - ctx.sidebarRight.openTab(SCHEDULE_TASK_KIND, { params: { sessionId, id } }) - }, - } - }, - }, ScheduleCatalogAction), - ) - ctx.slots.inject('sidebar.session.row.leading', () => ctx.slots.register({ - name: 'sidebar.session.row.leading', - id: 'schedule-mark', - order: 10, - locale: NS, - inject: () => ({ hooks: { catalog: manager.hooks.catalog } }), - }, SessionScheduleMark)) - ctx.slots.inject('sidebar.session.row.hover', () => ctx.slots.register({ - name: 'sidebar.session.row.hover', - id: 'schedule-tasks', - order: 10, - locale: NS, - inject: () => ({ hooks: { catalog: manager.hooks.catalog } }), - }, SessionScheduleHover)) + inject: () => ({ hooks: { catalog: manager.hooks.catalog } }), + }, SessionScheduleMark)) + yield ctx.slots.inject('sidebar.session.row.hover', () => ctx.slots.register({ + name: 'sidebar.session.row.hover', + id: 'schedule-tasks', + order: 10, + locale: NS, + inject: () => ({ hooks: { catalog: manager.hooks.catalog } }), + }, SessionScheduleHover)) + }) } diff --git a/packages/client/ui-schedule/tests/browser-plugin.client.spec.ts b/packages/client/ui-schedule/tests/browser-plugin.client.spec.ts index 4fa73699b9..768c478bef 100644 --- a/packages/client/ui-schedule/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-schedule/tests/browser-plugin.client.spec.ts @@ -4,7 +4,7 @@ import { describe, expect, it, vi, type Mock } from 'vitest' import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' import { UiConversation } from '@deepseek-ai/dsh-client-ui-conversation/client' import { resolveSlotLabel, type HostObservable, type StoredEntry } from '@deepseek-ai/dsh-client-ui-slots' -import { stubConfigForm } from '@deepseek-ai/dsh-client-test-runtime' +import { StaticRemoteAccess, stubConfigForm } from '@deepseek-ai/dsh-client-test-runtime' import { apply as applyLocale, inject as localeInject } from '@deepseek-ai/dsh-client-locale/client' import { apply, inject } from '../src/client/index.ts' import { apply as applyNode } from '../src/index.ts' @@ -86,9 +86,11 @@ class RemoteStub extends Service { async function baseContext( remote: RemoteStubInput = { $on: () => () => {} }, sidebar: SidebarStubs = sidebarStubs(), + allows: (action: string) => boolean = () => true, ): Promise { const ctx = new Context() await ctx.plugin(SlotRegistry).await() + new StaticRemoteAccess(ctx, allows) new UiConversation(ctx, { binding: () => undefined } as never) ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never) ctx.provide('configForms', { developerTools: { enabled: createSnapshotStore(true) }, get: () => stubConfigForm().scope } as never) @@ -140,10 +142,29 @@ describe('ui-schedule browser half', () => { it('declares only the services used by registration', () => { expect(inject).toEqual([ 'slots', 'locale', 'remote', 'remote.schedule', 'conversation', 'uiConversation', 'uiWorkspace', 'sessions', - 'workspaces', 'sidebarRightTabs', 'sidebarRight', + 'workspaces', 'sidebarRightTabs', 'sidebarRight', 'remoteAccess', ]) }) + it('contributes its surfaces only while the current Peer may read the task catalog', async () => { + for (const allowed of [false, true]) { + const sidebar = sidebarStubs() + const allows = vi.fn(() => allowed) + const ctx = await baseContext(undefined, sidebar, allows) + const header = declareHeader(ctx) + const fiber = ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + expect(allows).toHaveBeenCalledWith('schedule/catalog') + // The Session-header reminder catalog is this plugin's entry point into the page. + expect(headerEntryIds(ctx)).toEqual(allowed ? ['schedule-catalog'] : []) + // The task tab type is a definition, registered either way. + expect(sidebar.register).toHaveBeenCalledOnce() + await fiber.dispose() + header() + await ctx.fiber.dispose() + } + }) + it('waits for the header declaration, orders between static context and Jobs, and tears down', async () => { const ctx = await baseContext() const fiber = ctx.plugin({ inject: [...inject], apply }) diff --git a/packages/client/ui-schedule/tests/schedule-catalog-action.client.spec.tsx b/packages/client/ui-schedule/tests/schedule-catalog-action.client.spec.tsx index 9aadcc8bfb..5e0a6e1670 100644 --- a/packages/client/ui-schedule/tests/schedule-catalog-action.client.spec.tsx +++ b/packages/client/ui-schedule/tests/schedule-catalog-action.client.spec.tsx @@ -108,6 +108,7 @@ function props( records: readonly ScheduleRecord[] | undefined, openState: SessionSnapshot['openState'] = 'open', dictionary: typeof zh | typeof en = en, + allowed = true, ): ScheduleCatalogActionProps { const snapshot = sessionSnapshot(openState) const useSession = (select: (value: SessionSnapshot) => T): T => select(snapshot) @@ -120,6 +121,7 @@ function props( return { sessionId: SESSION, useSession, + useAccess: vi.fn((_action: string) => allowed), useCatalog, onDelete: vi.fn(async () => {}), onRetry: vi.fn(async () => {}), @@ -140,6 +142,19 @@ function metadataLines(): Element[] { } describe('ScheduleCatalogAction visibility', () => { + it('renders nothing and reads no catalog while the current Peer may not list this Session\'s reminders', () => { + const refused = props([record('active', 'after', START + 60_000)], 'open', en, false) + let catalogReads = 0 + const useCatalog: ScheduleCatalogActionProps['useCatalog'] = (select) => { + catalogReads++ + return refused.useCatalog(select) + } + const view = render() + expect(view.container.innerHTML).toBe('') + expect(refused.useAccess).toHaveBeenCalledWith('schedule/list') + expect(catalogReads).toBe(0) + }) + it('hides the entry once a successful read proves there are no active reminders', () => { const active = [record('active', 'after', START + 60_000)] const view = render() diff --git a/packages/client/ui-session/README.i18n.yaml b/packages/client/ui-session/README.i18n.yaml index 447f8f489b..3a3d92d9d5 100644 --- a/packages/client/ui-session/README.i18n.yaml +++ b/packages/client/ui-session/README.i18n.yaml @@ -15,8 +15,8 @@ en: 12e1ffc25a2d6695 zh: 321156dea1356e55 /deepseek-ai-dsh-client-ui-session/access-hooks: - en: 619ba442c3e40fb0 - zh: af0206a774ac9bc1 + en: 7bf4bca5607248fa + zh: 665d5c4e9cd29f68 /deepseek-ai-dsh-client-ui-session/model-experience: en: cb2d4451e8bf8520 zh: 6d3adbb930d90c59 diff --git a/packages/client/ui-session/README.md b/packages/client/ui-session/README.md index 79ac9f0e73..4c3928b35d 100644 --- a/packages/client/ui-session/README.md +++ b/packages/client/ui-session/README.md @@ -24,7 +24,7 @@ Running status comes from Host list baselines or status events. Subagent catalog ## Access Hooks -The package injects the Client `remoteAccess` service and publishes Session access Hooks over its `source()`: the root `useSessionAccess(sessionId, action)` answers on one Session resource, and the Session-scope `useAccess(action)` answers on the bound Session's resource; the `global` resource's answer is a different Hook, `useRemoteAccess(action)`, so neither shadows the other. The SessionMaybe-scope `useAccess(action)` returns `undefined` while no Session is bound, which callers treat as not allowed; a decision on the `global` resource reads `useRemoteAccess` instead. Snapshot semantics belong to [`dsh-api-remote-access`](../../api/remote-access/README.md#client-face). +The package injects the Client `remoteAccess` service and publishes Session access Hooks over its `source()`: the root `useSessionAccess(sessionId, action)` answers on one Session resource, and the Session-scope `useAccess(action)` answers on the bound Session's resource; the `global` resource's answer is a different Hook, `useRemoteAccess(action)`, so neither shadows the other. `ctx.uiSession.whenAccess(sessionId, action, callback)` is the JS counterpart of `useSessionAccess`: it keeps the callback's effects installed while the current Peer may call `action` on that Session, with the semantics of `ctx.remoteAccess.whenAccess`. The SessionMaybe-scope `useAccess(action)` returns `undefined` while no Session is bound, which callers treat as not allowed; a decision on the `global` resource reads `useRemoteAccess` instead. Snapshot semantics belong to [`dsh-api-remote-access`](../../api/remote-access/README.md#client-face). ## Model Experience diff --git a/packages/client/ui-session/README.zh.md b/packages/client/ui-session/README.zh.md index 157cc79a7f..fb63e227cd 100644 --- a/packages/client/ui-session/README.zh.md +++ b/packages/client/ui-session/README.zh.md @@ -24,7 +24,7 @@ kind: "package-reference" ## 访问 Hook -本包注入 Client `remoteAccess` 服务,并在其 `source()` 之上发布会话访问 Hook:根作用域的 `useSessionAccess(sessionId, action)` 回答某一个 Session 资源上的访问,Session 作用域的 `useAccess(action)` 回答所绑定 Session 资源上的访问;`global` 资源上的答案是另一个 Hook `useRemoteAccess(action)`,两者互不遮蔽。SessionMaybe 作用域的 `useAccess(action)` 在未绑定 Session 时返回 `undefined`,调用方将其视为不允许;需要 `global` 资源上的判断时读 `useRemoteAccess`。快照语义归 [`dsh-api-remote-access`](../../api/remote-access/README.zh.md#client-face)。 +本包注入 Client `remoteAccess` 服务,并在其 `source()` 之上发布会话访问 Hook:根作用域的 `useSessionAccess(sessionId, action)` 回答某一个 Session 资源上的访问,Session 作用域的 `useAccess(action)` 回答所绑定 Session 资源上的访问;`global` 资源上的答案是另一个 Hook `useRemoteAccess(action)`,两者互不遮蔽。`ctx.uiSession.whenAccess(sessionId, action, callback)` 是 `useSessionAccess` 在 JS 侧的对应:当前 Peer 能在该 Session 上调用 `action` 期间保持回调的效果,语义同 `ctx.remoteAccess.whenAccess`。SessionMaybe 作用域的 `useAccess(action)` 在未绑定 Session 时返回 `undefined`,调用方将其视为不允许;需要 `global` 资源上的判断时读 `useRemoteAccess`。快照语义归 [`dsh-api-remote-access`](../../api/remote-access/README.zh.md#client-face)。 ## 模型体验 diff --git a/packages/client/ui-session/src/client/index.ts b/packages/client/ui-session/src/client/index.ts index 29aeebce04..860a82287c 100644 --- a/packages/client/ui-session/src/client/index.ts +++ b/packages/client/ui-session/src/client/index.ts @@ -12,7 +12,7 @@ import type { } from '@deepseek-ai/dsh-api-session-controller/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type {} from '@deepseek-ai/dsh-api-remotes/client' -import type { RemoteEndpoint } from '@deepseek-ai/dsh-api-remote-access/client' +import type { AccessEffect, RemoteEndpoint } from '@deepseek-ai/dsh-api-remote-access/client' import { notifySubscribers } from '@deepseek-ai/dsh-client-store' import { WeakMapWithValues } from '@deepseek-ai/dsh-util-values' import { standardHookPropName } from '@deepseek-ai/dsh-client-ui-slots' @@ -360,6 +360,24 @@ export class UiSession extends Service { return this.sourceFor(owner) } + /** + * Keep `callback`'s effects installed while the current Peer may call + * `action` on one Session resource, with the semantics of + * `ctx.remoteAccess.whenAccess`: `useSessionAccess` answers the same + * question during render. + * @param sessionId - the Session resource the guarded effects depend on. + * @param action - endpoint action the guarded effects depend on. + * @param callback - creates one disposer or an iterable of disposers. + * @returns idempotent disposer owned by the caller's Cordis fiber. + */ + whenAccess(sessionId: SessionId, action: RemoteEndpoint, callback: () => AccessEffect): () => void { + // `ctx.get` traces the service to the caller's Context, which then owns the controller. + const access = this.ctx.get('remoteAccess') + /* v8 ignore next -- this plugin injects remoteAccess, so the service is active while uiSession is reachable. */ + if (access === undefined) throw new Error('ui-session: whenAccess requires the remoteAccess service') + return access.whenResourceAccess('session', sessionId, action, callback) + } + /** * Register one Session-scoped standard-source contribution. * @param descriptor - static member roster and per-binding resolver. diff --git a/packages/client/ui-session/tests/ui-session.client.spec.ts b/packages/client/ui-session/tests/ui-session.client.spec.ts index ae105e7b8e..c12901a0d8 100644 --- a/packages/client/ui-session/tests/ui-session.client.spec.ts +++ b/packages/client/ui-session/tests/ui-session.client.spec.ts @@ -10,6 +10,7 @@ import type { } from '@deepseek-ai/dsh-api-session-controller/client' import { MutableSessionEventSource } from '@deepseek-ai/dsh-api-session-controller/client' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' +import { StaticRemoteAccess } from '@deepseek-ai/dsh-client-test-runtime' import type { HostObservable, RootStandardSourceContribution } from '@deepseek-ai/dsh-client-ui-slots' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { afterEach, describe, expect, it, vi } from 'vitest' @@ -728,7 +729,10 @@ describe('ui-session apply', () => { } return source }), + whenAccess: vi.fn(() => () => {}), + whenResourceAccess: vi.fn((): () => void => stopGuard), } + const stopGuard = vi.fn() ctx.provide('sessions', bench.sessions) ctx.provide('slots', slots as never) ctx.provide('remoteAccess', remoteAccess) @@ -758,6 +762,30 @@ describe('ui-session apply', () => { const binding = bench.binding(sessionId('s2')) const scoped = ctx.uiSession.bindingSource(bench.reference(binding)).getSnapshot() expect(scoped.keyedHooks.access?.('session/fork')).toBe(remoteAccess.source('session', sessionId('s2'), 'session/fork')) + + // `whenAccess` follows the same Session resource through the service's resource form. + const guarded = vi.fn(() => () => {}) + expect(ctx.uiSession.whenAccess(sessionId('s1'), 'session/fork', guarded)).toBe(stopGuard) + expect(remoteAccess.whenResourceAccess).toHaveBeenCalledWith('session', sessionId('s1'), 'session/fork', guarded) + }) + + it('hangs Session-guarded effects on the calling plugin, so they leave when it unloads', async () => { + const ctx = new Context() + const bench = createSessionsBench(ctx) + ctx.provide('sessions', bench.sessions) + ctx.provide('slots', { provideRoot: vi.fn(), installScope: vi.fn(), bindStoreScope: vi.fn() } as never) + new StaticRemoteAccess(ctx) + apply(ctx) + const dispose = vi.fn() + const caller = ctx.plugin({ + inject: ['uiSession'], + apply: (callerCtx: Context) => { callerCtx.uiSession.whenAccess(sessionId('s1'), 'session/fork', () => dispose) }, + }) + await caller.await() + expect(dispose).not.toHaveBeenCalled() + await caller.dispose() + expect(dispose).toHaveBeenCalledOnce() + await ctx.fiber.dispose() }) it('keeps the Host loader half inert', () => { diff --git a/packages/client/ui-settings-account/README.i18n.yaml b/packages/client/ui-settings-account/README.i18n.yaml index 1612f7825a..f6f6403a3f 100644 --- a/packages/client/ui-settings-account/README.i18n.yaml +++ b/packages/client/ui-settings-account/README.i18n.yaml @@ -18,8 +18,8 @@ en: 45aa7b44d5424d9b zh: 20c7d344c4e9bce0 /deepseek-ai-dsh-client-ui-settings-account/use-this-package/desktop-onboarding: - en: c1c4db71fd09ca13 - zh: a40c0a01e9951945 + en: 7ae4e68f9491b068 + zh: f7478269bf8d28b3 /deepseek-ai-dsh-client-ui-settings-account/understand-the-implementation: en: 186b2046cfb5ef76 zh: 57b042a618fb612e diff --git a/packages/client/ui-settings-account/README.md b/packages/client/ui-settings-account/README.md index 2b02a9d990..6388b08d0b 100644 --- a/packages/client/ui-settings-account/README.md +++ b/packages/client/ui-settings-account/README.md @@ -63,6 +63,8 @@ The Desktop preload marker enables the introduction after account credentials ar The welcome page requires Get started. The credit page always appears. A positive balance confirmed at first entry selects Continue as the primary action and Add credits as the secondary action. This choice stays fixed until the onboarding controller is recreated and is never persisted; late balance results cannot change the buttons. Only a confirmed absence of positive credit adds a confirmation before continuing without recharge; pending or failed queries do not block continuing. Purpose descriptions remain unchanged when selected. Returning from the native top-up view keeps the credit page and re-reads profile and balance in the background after any read already running, whether loading or payment succeeded. That read does not recompute the funded-mode choice, so a completed recharge leaves the buttons as first entered while continuing still evaluates the refreshed balance. Next confirms only a known zero balance; Skip uses the credit warning on the credit page and the setup warning on the question pages. Office-only use completes with compact presentation; development or both purposes also ask for process detail. Back preserves selections, and explicit skip from any supported step applies standard process, compact performance/usage, and disabled Coding Tools. This flow creates no workspace or demonstration task. +Access: the account stream, quota notice, settings launcher, account settings section, and models sign-in entry exist only while the current Peer may call `account/watch`; Desktop onboarding and the platform page are not gated. Losing that access discards the account reads that grant started, closes a pending authorization tab, and clears the account view, details, picture, and sign-in dialog state; a later grant shows the account section again once its first frame arrives. The owner always has access. + ## Understand the implementation diff --git a/packages/client/ui-settings-account/README.zh.md b/packages/client/ui-settings-account/README.zh.md index 7485d31445..8200c6e468 100644 --- a/packages/client/ui-settings-account/README.zh.md +++ b/packages/client/ui-settings-account/README.zh.md @@ -63,6 +63,8 @@ Desktop preload 标记在账号凭证已存储后启用引导。进度属于本 欢迎页必须点击“开始设置”。额度页始终展示。首次进入时若已确认有正余额,则主操作为“继续”,次操作为“去充值”。该选择在引导控制器重建前保持固定且不持久化,后到的余额结果不会改变按钮。仅在确认不存在正余额时,继续而不充值才需确认;查询中或失败时不阻止继续。用途卡片选中后描述不变。从原生充值视图返回后保留额度页,并在等待任何已在进行的读取结束后于后台重读资料与余额,不受页面加载或支付结果影响。该读取不会重新计算该选择,因此充值成功后按钮仍保持首次进入时的状态,而继续前行仍按刷新后的余额判断。“下一步”仅在确认零余额时提示;额度页的“跳过”使用充值提示,答题页使用设置提示。仅选办公用途时以简洁展示完成;选择开发或两种用途时还会询问过程详情。上一步保留已有选择,任意可跳过步骤均采用标准工作过程、简洁性能与用量以及关闭代码工作工具的默认值。此流程不创建工作区或示例任务。 +访问控制:账号状态流、额度提醒、设置启动器、账号设置分区与模型登录入口只在当前 Peer 可以调用 `account/watch` 时存在;桌面引导与平台页面不受此控制。失去该权限时,这一轮发出的账号读取作废,待授权的浏览器标签页关闭,账号视图、详情、头像与登录对话框状态清空;之后再次授权时,账号分区等新流的第一帧到达后才重新出现。owner 始终有权限。 + ## 理解实现 diff --git a/packages/client/ui-settings-account/package.json b/packages/client/ui-settings-account/package.json index 681232a393..41b221656b 100644 --- a/packages/client/ui-settings-account/package.json +++ b/packages/client/ui-settings-account/package.json @@ -50,6 +50,7 @@ }, "devDependencies": { "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-api-remote-access": "workspace:*", "@deepseek-ai/dsh-api-remotes": "workspace:*", "@deepseek-ai/dsh-client-locale": "workspace:*", "@deepseek-ai/dsh-client-store": "workspace:*", diff --git a/packages/client/ui-settings-account/src/client/index.ts b/packages/client/ui-settings-account/src/client/index.ts index 4847b9b6e9..b70e5097a1 100644 --- a/packages/client/ui-settings-account/src/client/index.ts +++ b/packages/client/ui-settings-account/src/client/index.ts @@ -8,6 +8,7 @@ import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' import type {} from '@deepseek-ai/dsh-client-ui-theme/client' import type {} from '@deepseek-ai/dsh-client-ui-chat/client' import type {} from '@deepseek-ai/dsh-client-ui-layout/client' +import type {} from '@deepseek-ai/dsh-api-remote-access/client' import type {} from '@deepseek-ai/dsh-api-remotes/client' import type { AccountView, AccountDetails } from '@deepseek-ai/dsh-deepseek-account/types' import type { OnboardingChange } from './onboarding-contract.ts' @@ -41,7 +42,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { } /** Services required by account settings. */ -export const inject = ['slots', 'locale', 'remote', 'remote.account', 'remote.session', 'theme', 'configForms'] +export const inject = ['slots', 'locale', 'remote', 'remote.account', 'remote.session', 'theme', 'configForms', 'remoteAccess'] /** Register account UI in Desktop and plain Web clients. @param ctx - client plugin context. */ export function apply(ctx: Context): void { const desktop = 'dshDesktop' in globalThis @@ -116,36 +117,58 @@ export function apply(ctx: Context): void { return request } ctx.effect(() => () => { revision++ }, 'account: details request lifetime') - const stream = ctx.remote.$stream({ - name: 'account', open: signal => ctx.remote.account.watch(signal), ended: () => new Error('account stream ended'), - }) - let disposed = false - ctx.effect(() => () => { disposed = true; return stream.dispose() }, 'account: state stream') - void (async () => { - for await (const frame of stream) { + // The account stream, and the reads it starts, follow access to this host's account state. + ctx.remoteAccess.whenAccess('account/watch', function* () { + const stream = ctx.remote.$stream({ + name: 'account', open: signal => ctx.remote.account.watch(signal), ended: () => new Error('account stream ended'), + }) + // Read across closures: the disposer ends this grant while the frame loop awaits. + const grant = { ended: false } + // Withdrawing access invalidates the reads this grant started, closes the + // pending authorization tab, and clears the account view and the sign-in + // dialog state, synchronously, so the next grant starts from no account + // state. `notices.end()` disposes this generation's notice lifetime, which a + // frame of this stream begins; the notice controller stays outside and + // begins again under the next grant. + yield () => { + grant.ended = true revision++ refreshing = undefined avatar.reset() - const initialize = frame.value.status === 'credential-stored' && frame.value.attempt?.phase === 'succeeded' - && snapshot.view?.attempt?.phase !== 'succeeded' - const { notices: previousNotices, ...previous } = snapshot - publish({ ...previous, view: frame.value, details: undefined, avatar: undefined, failed: false, - ...(frame.value.status === 'credential-stored' && previousNotices ? { notices: previousNotices } : {}) }) - browser.update(frame.value) - frame.accept() - if (frame.value.status === 'credential-stored') notices.begin() - else notices.end() - if (initialize) void (async () => { - try { - const initialized = await ctx.remote.session.initializeDefaultModel() - if (!initialized.ok) console.error('[deepseek-account] default model initialization failed', { reason: 'refused' }) - } catch (_error) { - console.error('[deepseek-account] default model initialization failed', { reason: 'disconnected' }) - } - })() - void refresh() + browser.dispose() + const { loginFailed: _loginFailed, ...current } = snapshot + publish({ ...current, view: undefined, details: undefined, avatar: undefined, failed: false, loginVisible: false }) + notices.end() + return stream.dispose() } - })().catch(() => { if (!disposed) publish({ ...snapshot, failed: true }) }) + void (async () => { + for await (const frame of stream) { + // A frame the stream delivered before the revocation is not this grant's to apply. + if (grant.ended) return + revision++ + refreshing = undefined + avatar.reset() + const initialize = frame.value.status === 'credential-stored' && frame.value.attempt?.phase === 'succeeded' + && snapshot.view?.attempt?.phase !== 'succeeded' + const { notices: previousNotices, ...previous } = snapshot + publish({ ...previous, view: frame.value, details: undefined, avatar: undefined, failed: false, + ...(frame.value.status === 'credential-stored' && previousNotices ? { notices: previousNotices } : {}) }) + browser.update(frame.value) + frame.accept() + if (frame.value.status === 'credential-stored') notices.begin() + else notices.end() + if (initialize) void (async () => { + try { + const initialized = await ctx.remote.session.initializeDefaultModel() + if (!initialized.ok) console.error('[deepseek-account] default model initialization failed', { reason: 'refused' }) + } catch (_error) { + console.error('[deepseek-account] default model initialization failed', { reason: 'disconnected' }) + } + })() + void refresh() + } + })().catch(() => { if (!grant.ended) publish({ ...snapshot, failed: true }) }) + }) const nativePlatform = (globalThis as typeof globalThis & { dshPlatform?: PlatformBridge }).dshPlatform // One request channel for the single native Platform view. It always exists // so every entry can observe whether a page is showing; only a native bridge @@ -287,17 +310,17 @@ export function apply(ctx: Context): void { }), }, DesktopOnboardingEntry)) } - ctx.slots.inject('settings.models.sign-in', () => ctx.slots.register({ + ctx.remoteAccess.whenAccess('account/watch', () => ctx.slots.inject('settings.models.sign-in', () => ctx.slots.register({ name: 'settings.models.sign-in', locale: 'settings.account', inject: () => operations, - }, AccountOnboarding)) - ctx.slots.inject('shell.quota-notice', () => ctx.slots.register({ + }, AccountOnboarding))) + ctx.remoteAccess.whenAccess('account/watch', () => ctx.slots.inject('shell.quota-notice', () => ctx.slots.register({ name: 'shell.quota-notice', locale: 'settings.account', select: owner => owner.code === 'ACCOUNT_QUOTA' ? owner : null, inject: (): AccountQuotaNoticeInjected => ({ hooks: { account: operations.hooks.account, platformPage: platformPages }, ...nativePlatform === undefined ? {} : { openPlatformPage: platformPageOpener(refreshAccount) }, }), - }, AccountQuotaNotice)) + }, AccountQuotaNotice))) if (nativePlatform !== undefined) { ctx.slots.inject('shell.overlay', () => ctx.slots.register({ name: 'shell.overlay', id: 'account.platform-page', locale: 'settings.account', @@ -307,10 +330,10 @@ export function apply(ctx: Context): void { }), }, AccountPlatformHost)) } - ctx.slots.inject('settings.launcher', () => ctx.slots.register({ + ctx.remoteAccess.whenAccess('account/watch', () => ctx.slots.inject('settings.launcher', () => ctx.slots.register({ name: 'settings.launcher', locale: 'settings.account', inject: () => operations, - }, AccountMenu)) - ctx.slots.inject('settings.section', () => { + }, AccountMenu))) + ctx.remoteAccess.whenAccess('account/watch', () => ctx.slots.inject('settings.section', () => { let unregister: (() => void) | undefined const update = () => { if (snapshot.view?.status === 'credential-stored') { @@ -326,5 +349,5 @@ export function apply(ctx: Context): void { listeners.add(update) update() return () => { listeners.delete(update); unregister?.() } - }) + })) } diff --git a/packages/client/ui-settings-account/tests/access-revocation.client.spec.ts b/packages/client/ui-settings-account/tests/access-revocation.client.spec.ts new file mode 100644 index 0000000000..c969148d1b --- /dev/null +++ b/packages/client/ui-settings-account/tests/access-revocation.client.spec.ts @@ -0,0 +1,312 @@ +// @vitest-environment jsdom +/** Account surfaces follow `account/watch` access: a revocation withdraws them and a later grant restores them. */ +import { afterEach, expect, it, vi } from 'vitest' +import type { AccountView } from '@deepseek-ai/dsh-deepseek-account/types' +import { apply, type AccountSectionInjected } from '../src/client/index.ts' + +afterEach(() => { vi.unstubAllEnvs() }) + +type AccessEffect = (() => void) | Iterable<() => void> + +/** One `whenAccess` controller: its callback and the disposers of its current installation. */ +interface Guard { + readonly action: string + readonly install: () => AccessEffect + installed: (() => void)[] | undefined +} + +/** + * Install every guard's current effect. + * @param guards - Guards registering this run's access controllers. + */ +function grant(guards: readonly Guard[]): void { + for (const guard of guards) { + const effect = guard.install() + guard.installed = typeof effect === 'function' ? [effect] : [...effect] + } +} + +/** + * Dispose every installed effect in reverse install order. + * @param guards - Guards registering this run's access controllers. + */ +function revoke(guards: readonly Guard[]): void { + for (const guard of [...guards].reverse()) { + for (const dispose of (guard.installed ?? []).reverse()) dispose() + guard.installed = undefined + } +} + +it('withdraws every account surface and the account stream on revocation, and restores them on the next grant', async () => { + vi.stubEnv('DSH_CLIENT_VERSION', '0.0.0-test') + const links = { usageUrl: 'https://example.test/usage', topUpUrl: 'https://example.test/top_up' } + const signedIn: AccountView = { status: 'credential-stored', links, attempt: null } + const guards: Guard[] = [] + const registered = new Map() + const streams: { readonly signal: AbortSignal; readonly accepted: Promise }[] = [] + const lifetime: (() => void)[] = [] + const ctx = { + effect: (install: () => (() => void) | undefined) => { const dispose = install(); if (dispose) lifetime.push(dispose) }, + locale: { register: () => () => {}, bind: () => (key: string) => key, getSnapshot: () => ({ active: 'en' }) }, + configForms: { + get: () => ({ subscribe: () => () => {}, getSnapshot: () => ({ status: 'loading', value: undefined }) }), + describe: () => ({ subscribe: () => () => {}, getSnapshot: () => ({ error: null }) }), + }, + slots: { + inject: (_name: string, install: () => (() => void) | undefined) => { + const dispose = install() + return () => { dispose?.() } + }, + register: (entry: { readonly name: string }) => { + registered.set(entry.name, (registered.get(entry.name) ?? 0) + 1) + return () => { registered.set(entry.name, registered.get(entry.name)! - 1) } + }, + }, + remoteAccess: { + whenAccess: (action: string, install: () => AccessEffect) => { + guards.push({ action, install, installed: undefined }) + return () => {} + }, + }, + remote: { + $on: () => () => {}, + session: { initializeDefaultModel: async () => ({ ok: true, value: undefined }) }, + account: { getProfile: async () => ({ ok: true, value: null }), getBalance: async () => ({ ok: true, value: null }) }, + $stream: () => { + const abort = new AbortController() + const accepted = Promise.withResolvers() + streams.push({ signal: abort.signal, accepted: accepted.promise }) + return { + signal: abort.signal, + dispose: () => { abort.abort() }, + async *[Symbol.asyncIterator]() { + yield { value: signedIn, accept: () => { accepted.resolve(undefined) } } + await new Promise((resolve) => { abort.signal.addEventListener('abort', resolve) }) + }, + } + }, + }, + } + const surfaces = (): Record => Object.fromEntries( + ['settings.launcher', 'shell.quota-notice', 'settings.models.sign-in', 'settings.section'].map(name => [name, registered.get(name)]), + ) + try { + apply(ctx as never) + expect(new Set(guards.map(guard => guard.action))).toEqual(new Set(['account/watch'])) + grant(guards) + await streams[0]!.accepted + expect(surfaces()).toEqual({ 'settings.launcher': 1, 'shell.quota-notice': 1, 'settings.models.sign-in': 1, 'settings.section': 1 }) + + revoke(guards) + expect(streams[0]!.signal.aborted).toBe(true) + expect(surfaces()).toEqual({ 'settings.launcher': 0, 'shell.quota-notice': 0, 'settings.models.sign-in': 0, 'settings.section': 0 }) + + grant(guards) + await streams[1]!.accepted + expect(surfaces()).toEqual({ 'settings.launcher': 1, 'shell.quota-notice': 1, 'settings.models.sign-in': 1, 'settings.section': 1 }) + } finally { + revoke(guards) + for (const dispose of lifetime.reverse()) dispose() + } +}) + +it('drops reads a revoked grant started, and the next grant shows no account section before its first frame', async () => { + vi.stubEnv('DSH_CLIENT_VERSION', '0.0.0-test') + const links = { usageUrl: 'https://example.test/usage', topUpUrl: 'https://example.test/top_up' } + const signedIn: AccountView = { status: 'credential-stored', links, attempt: null } + const guards: Guard[] = [] + const registered = new Map() + let operations: AccountSectionInjected | undefined + const profile = Promise.withResolvers<{ ok: true; value: { status: 'failed' } }>() + const firstFrames: { release: () => void; accepted: Promise }[] = [] + const lifetime: (() => void)[] = [] + const ctx = { + effect: (install: () => (() => void) | undefined) => { const dispose = install(); if (dispose) lifetime.push(dispose) }, + locale: { register: () => () => {}, bind: () => (key: string) => key, getSnapshot: () => ({ active: 'en' }) }, + configForms: { + get: () => ({ subscribe: () => () => {}, getSnapshot: () => ({ status: 'loading', value: undefined }) }), + describe: () => ({ subscribe: () => () => {}, getSnapshot: () => ({ error: null }) }), + }, + slots: { + inject: (_name: string, install: () => (() => void) | undefined) => { + const dispose = install() + return () => { dispose?.() } + }, + register: (entry: { readonly name: string; readonly inject?: () => AccountSectionInjected }) => { + if (entry.name === 'settings.launcher') operations = entry.inject?.() + registered.set(entry.name, (registered.get(entry.name) ?? 0) + 1) + return () => { registered.set(entry.name, registered.get(entry.name)! - 1) } + }, + }, + remoteAccess: { + whenAccess: (action: string, install: () => AccessEffect) => { + guards.push({ action, install, installed: undefined }) + return () => {} + }, + }, + remote: { + $on: () => () => {}, + session: { initializeDefaultModel: async () => ({ ok: true, value: undefined }) }, + account: { getProfile: async () => await profile.promise, getBalance: async () => ({ ok: true, value: null }) }, + $stream: () => { + const abort = new AbortController() + const gate = Promise.withResolvers() + const accepted = Promise.withResolvers() + firstFrames.push({ release: () => { gate.resolve(undefined) }, accepted: accepted.promise }) + return { + signal: abort.signal, + dispose: () => { abort.abort() }, + async *[Symbol.asyncIterator]() { + await gate.promise + yield { value: signedIn, accept: () => { accepted.resolve(undefined) } } + await new Promise((resolve) => { abort.signal.addEventListener('abort', resolve) }) + }, + } + }, + }, + } + try { + apply(ctx as never) + grant(guards) + firstFrames[0]!.release() + await firstFrames[0]!.accepted + expect(registered.get('settings.section')).toBe(1) + + // The profile read started under the first grant settles only after the revocation. + revoke(guards) + expect(operations!.hooks.account.getSnapshot()).toMatchObject({ view: undefined, details: undefined }) + profile.resolve({ ok: true, value: { status: 'failed' } }) + await new Promise(resolve => setTimeout(resolve, 0)) + expect(operations!.hooks.account.getSnapshot().details).toBeUndefined() + + grant(guards) + expect(registered.get('settings.section')).toBe(0) + firstFrames[1]!.release() + await firstFrames[1]!.accepted + expect(registered.get('settings.section')).toBe(1) + } finally { + revoke(guards) + for (const dispose of lifetime.reverse()) dispose() + } +}) + +/** Stream behaviour of one grant's account stream in {@link revocationHarness}. */ +type StreamScript = (yieldFrame: () => void) => void + +/** + * The account plugin over hand-built services whose account streams follow `scripts`, one per grant. + * @param scripts - per-grant hooks run right before that stream delivers its first frame. + * @returns the plugin's access guards, Remote spies, slot counts, and teardown. + */ +function revocationHarness(scripts: StreamScript[]) { + const links = { usageUrl: 'https://example.test/usage', topUpUrl: 'https://example.test/top_up' } + const signedIn: AccountView = { status: 'credential-stored', links, attempt: null } + const guards: Guard[] = [] + const registered = new Map() + const accepted: Promise[] = [] + const lifetime: (() => void)[] = [] + let operations: AccountSectionInjected | undefined + const getProfile = vi.fn(async () => ({ ok: true, value: null })) + const getUnnotifiedBonuses = vi.fn(async () => ({ ok: true, value: null })) + const startSignIn = vi.fn<() => Promise<{ ok: false }>>() + const ctx = { + effect: (install: () => (() => void) | undefined) => { const dispose = install(); if (dispose) lifetime.push(dispose) }, + locale: { register: () => () => {}, bind: () => (key: string) => key, getSnapshot: () => ({ active: 'en' }) }, + configForms: { + get: () => ({ subscribe: () => () => {}, getSnapshot: () => ({ status: 'loading', value: undefined }) }), + describe: () => ({ subscribe: () => () => {}, getSnapshot: () => ({ error: null }) }), + }, + theme: { getTheme: () => ({ active: { colorScheme: 'light' } }) }, + slots: { + inject: (_name: string, install: () => (() => void) | undefined) => { + const dispose = install() + return () => { dispose?.() } + }, + register: (entry: { readonly name: string; readonly inject?: () => AccountSectionInjected }) => { + if (entry.name === 'settings.launcher') operations = entry.inject?.() + registered.set(entry.name, (registered.get(entry.name) ?? 0) + 1) + return () => { registered.set(entry.name, registered.get(entry.name)! - 1) } + }, + }, + remoteAccess: { + whenAccess: (action: string, install: () => AccessEffect) => { + guards.push({ action, install, installed: undefined }) + return () => {} + }, + }, + remote: { + $on: () => () => {}, + session: { initializeDefaultModel: async () => ({ ok: true, value: undefined }) }, + account: { + getProfile, getBalance: async () => ({ ok: true, value: null }), getUnnotifiedBonuses, startSignIn, + }, + $stream: () => { + const abort = new AbortController() + const delivered = Promise.withResolvers() + const script = scripts[accepted.length]! + accepted.push(delivered.promise) + return { + signal: abort.signal, + dispose: () => { abort.abort() }, + async *[Symbol.asyncIterator]() { + await Promise.resolve() + script(() => {}) + yield { value: signedIn, accept: () => { delivered.resolve(undefined) } } + await new Promise((resolve) => { abort.signal.addEventListener('abort', resolve) }) + }, + } + }, + }, + } + apply(ctx as never) + return { + guards, registered, accepted, getProfile, getUnnotifiedBonuses, startSignIn, + operations: () => operations!, + dispose: () => { revoke(guards); for (const dispose of lifetime.reverse()) dispose() }, + } +} + +it('ignores a frame the stream delivered just before the revocation', async () => { + vi.stubEnv('DSH_CLIENT_VERSION', '0.0.0-test') + // The stream hands out its first frame after access is already withdrawn. + const b = revocationHarness([() => { revoke(b.guards) }]) + try { + grant(b.guards) + await new Promise(resolve => setTimeout(resolve, 0)) + expect(b.operations().hooks.account.getSnapshot().view).toBeUndefined() + expect(b.registered.get('settings.section') ?? 0).toBe(0) + expect(b.getProfile).not.toHaveBeenCalled() + expect(b.getUnnotifiedBonuses).not.toHaveBeenCalled() + } finally { + b.dispose() + } +}) + +it('closes the sign-in dialog, its failure, and the pending authorization tab on revocation', async () => { + vi.stubEnv('DSH_CLIENT_VERSION', '0.0.0-test') + const popup = { opener: null, closed: false, close: vi.fn(), location: { replace: vi.fn() } } + const open = vi.spyOn(window, 'open').mockReturnValue(popup as never) + const b = revocationHarness([() => {}, () => {}]) + try { + grant(b.guards) + await b.accepted[0] + b.startSignIn.mockResolvedValueOnce({ ok: false }) + await expect(b.operations().start()).rejects.toThrow('account start failed') + expect(b.operations().hooks.account.getSnapshot()).toMatchObject({ loginVisible: true, loginFailed: true }) + revoke(b.guards) + expect(b.operations().hooks.account.getSnapshot().loginVisible).toBe(false) + expect(b.operations().hooks.account.getSnapshot().loginFailed).toBeUndefined() + + grant(b.guards) + await b.accepted[1] + popup.close.mockClear() + b.startSignIn.mockReturnValueOnce(new Promise(() => {})) + void b.operations().start() + expect(open).toHaveBeenCalledTimes(2) + revoke(b.guards) + expect(popup.close).toHaveBeenCalledOnce() + } finally { + b.dispose() + open.mockRestore() + } +}) diff --git a/packages/client/ui-settings-account/tests/apply.client.spec.ts b/packages/client/ui-settings-account/tests/apply.client.spec.ts index 0146bbfa4a..300641d109 100644 --- a/packages/client/ui-settings-account/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-account/tests/apply.client.spec.ts @@ -23,6 +23,7 @@ import { AccountQuotaNotice } from '../src/client/AccountQuotaNotice.tsx' import type { AccountQuotaNoticeInjected } from '../src/client/AccountQuotaNotice.tsx' const it = createClientTest({ roster: webApp }) +const withoutAccountAccess = createClientTest({ roster: webApp }, { access: action => action !== 'account/watch' }) const SELF = '@deepseek-ai/dsh-client-ui-settings-account' const view: AccountView = { status: 'signed-out', attempt: null, @@ -77,6 +78,15 @@ it('registers account sign-in in a plain browser without the Desktop-only surfac } }, 60_000) +withoutAccountAccess('opens no account stream and registers no account surface while the current Peer may not watch the account', async ({ start }) => { + const c = await start() + expect(c.mock.streams.end('account/watch')).toBe(0) + expect(c.ctx.slots.entries('settings.launcher')).toHaveLength(0) + expect(quotaNoticeEntry(c)).toBeUndefined() + expect(c.ctx.slots.entries('settings.models.sign-in')).toHaveLength(0) + expect(c.ctx.slots.entries('settings.section').some(entry => entry.options.id === 'account')).toBe(false) +}, 60_000) + it('claims account balance notices from the frame-wide quota chain and declines generic quota', async ({ start }) => { vi.stubGlobal('dshDesktop', {}) const c = await start() @@ -312,7 +322,8 @@ it('coalesces refreshes, publishes independent failures, and rejects stale respo await c.unload(SELF) pendingAgain.resolve(ok(profile)) await request - expect(actions.hooks.account.getSnapshot().details?.profile).toEqual({ status: 'failed' }) + // Unloading ends the grant, which clears the account state; the late profile publishes nothing. + expect(actions.hooks.account.getSnapshot()).toMatchObject({ view: undefined, details: undefined }) }, 60_000) for (const desktop of [false, true]) it(`uses the browser or Desktop login carrier (Desktop: ${desktop}) and exposes operation errors`, async ({ start, mock }) => { diff --git a/packages/client/ui-settings-account/tests/config.client.spec.ts b/packages/client/ui-settings-account/tests/config.client.spec.ts index a5a33dca61..850cf78660 100644 --- a/packages/client/ui-settings-account/tests/config.client.spec.ts +++ b/packages/client/ui-settings-account/tests/config.client.spec.ts @@ -1,24 +1,27 @@ /** Public questionnaire options are emitted in the page bootstrap. */ import { Context } from '@deepseek-ai/cordis' import { expect, it, onTestFinished, vi } from 'vitest' -import type { IndexInjection } from '@deepseek-ai/dsh-host-webserver' +import type { IndexInjection, WebServer } from '@deepseek-ai/dsh-host-webserver' import { apply, Config } from '../src/index.ts' import { CONTACT_CONFIG_GLOBAL } from '../src/contact-config.ts' +/** The Peer an index render speaks for; this listener reads no field of it. */ +const PAGE_PEER = { principal: { kind: 'owner' } } as Parameters[1] + it('injects configured contact options and removes the listener on disposal', async () => { const ctx = new Context() onTestFinished(() => ctx.fiber.dispose()) const config: Config = Config({ contactSource: 'harness', bonusAckRetryDelayMs: 25, bonusAckRetryMaxDelayMs: 100 }) await ctx.plugin({ Config, apply }, { contactSource: 'harness', bonusAckRetryDelayMs: 25, bonusAckRetryMaxDelayMs: 100 }) const table: IndexInjection[] = [] - ctx.emit('webserver/index-inject', table) + ctx.emit('webserver/index-inject', table, PAGE_PEER) expect(table).toEqual([{ kind: 'global', name: CONTACT_CONFIG_GLOBAL, value: { contactFormUrl: config.contactFormUrl, contactSource: config.contactSource, bonusAckRetryDelayMs: 25, bonusAckRetryMaxDelayMs: 100, } }]) await ctx.fiber.dispose() table.length = 0 - ctx.emit('webserver/index-inject', table) + ctx.emit('webserver/index-inject', table, PAGE_PEER) expect(table).toEqual([]) }) diff --git a/packages/client/ui-settings-account/tests/initialization.client.spec.ts b/packages/client/ui-settings-account/tests/initialization.client.spec.ts index daa962d263..a17217f0d2 100644 --- a/packages/client/ui-settings-account/tests/initialization.client.spec.ts +++ b/packages/client/ui-settings-account/tests/initialization.client.spec.ts @@ -44,6 +44,14 @@ it.each(['accepted', 'refused', 'disconnected', 'pending'] as const)('initialize return () => {} }, }, + remoteAccess: { + whenAccess: (_action: string, install: () => (() => void) | Iterable<() => void> | undefined) => { + const effect = install() + if (typeof effect === 'function') disposers.push(effect) + else if (effect !== undefined) for (const dispose of effect) disposers.push(dispose) + return () => {} + }, + }, remote: { $on: () => () => {}, session: { initializeDefaultModel }, diff --git a/packages/client/ui-settings-general/README.i18n.yaml b/packages/client/ui-settings-general/README.i18n.yaml index 85030a3d13..377d9797cd 100644 --- a/packages/client/ui-settings-general/README.i18n.yaml +++ b/packages/client/ui-settings-general/README.i18n.yaml @@ -24,8 +24,8 @@ en: 597df68b5913e8d7 zh: bc9114f3e877679a /deepseek-ai-dsh-client-ui-settings-general/use-this-package/onboarding-steps: - en: 27f0f6ec55f00228 - zh: 69b47ff1efd81c17 + en: ba55fe92bf49dc5a + zh: 9a0e1345496a0ae6 /deepseek-ai-dsh-client-ui-settings-general/understand-the-implementation: en: 1531b45780d0689f zh: 844548208c08a9c9 diff --git a/packages/client/ui-settings-general/README.md b/packages/client/ui-settings-general/README.md index 1bfd4e9aab..0b14da3bcb 100644 --- a/packages/client/ui-settings-general/README.md +++ b/packages/client/ui-settings-general/README.md @@ -59,6 +59,8 @@ On a loopback browser, the shell renders **Open configuration file** only when t The onboarding ledger projects in ascending order and mounts exactly one step at a time. Registrants own durable completion, capability readiness, copy, mutations, and their visible wrapper, so independently registered flows cannot stack and the shell does not become a second configuration fact source. Visible steps own their dialog chrome and app-root `inert` lifecycle. +Access: the settings shell and the `settings.open` shortcut are registered only while the current Peer may call `settings/describe`. The owner always has access. + ----- diff --git a/packages/client/ui-settings-general/README.zh.md b/packages/client/ui-settings-general/README.zh.md index 932dc56e46..d1e276b141 100644 --- a/packages/client/ui-settings-general/README.zh.md +++ b/packages/client/ui-settings-general/README.zh.md @@ -59,6 +59,8 @@ Web 与桌面端的通用设置底部显示当前发布版本,使用构建注 引导账本按升序投影,每次只挂载一个步骤。注册方持有持久化完成状态、能力就绪状态、文案、变更操作与可见包装,因此独立注册的流程无法堆叠,外壳也不会成为第二个配置事实来源。可见步骤自行持有弹窗框架与应用根节点 `inert` 生命周期。 +访问控制:设置外壳与 `settings.open` 快捷键只在当前 Peer 可以调用 `settings/describe` 时注册。owner 始终有权限。 + ----- diff --git a/packages/client/ui-settings-general/package.json b/packages/client/ui-settings-general/package.json index 29d5e63785..944ce57932 100644 --- a/packages/client/ui-settings-general/package.json +++ b/packages/client/ui-settings-general/package.json @@ -57,6 +57,7 @@ }, "devDependencies": { "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-api-remote-access": "workspace:*", "@deepseek-ai/dsh-api-remotes": "workspace:*", "@deepseek-ai/dsh-client-connection": "workspace:*", "@deepseek-ai/dsh-client-locale": "workspace:*", diff --git a/packages/client/ui-settings-general/src/client/index.ts b/packages/client/ui-settings-general/src/client/index.ts index 7183dab668..180df12f10 100644 --- a/packages/client/ui-settings-general/src/client/index.ts +++ b/packages/client/ui-settings-general/src/client/index.ts @@ -9,6 +9,8 @@ * Export discipline: packages/client/AGENTS.md. */ import type { Context as ClientContext } from '@deepseek-ai/cordis' +// Type-only: pulls the remoteAccess service merge (ctx.remoteAccess). +import type {} from '@deepseek-ai/dsh-api-remote-access/client' // Type-only: pulls the ctx.remote merge and its fixed Host facts. import type {} from '@deepseek-ai/dsh-api-remotes/client' import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' @@ -70,7 +72,7 @@ const NS = 'settings' * ui-settings' apply, whose activation order relative to this one is NOT * constrained; registrations depend on their slots through `slots.inject()`. */ -export const inject = ['slots', 'locale', 'connection', 'remote', 'remote.settings', 'configForms', 'shortcuts', 'modules'] +export const inject = ['slots', 'locale', 'connection', 'remote', 'remote.settings', 'configForms', 'shortcuts', 'modules', 'remoteAccess'] /** * Register the `settings` dictionaries, the chrome content, and the General @@ -185,46 +187,48 @@ export function apply(ctx: ClientContext): void { }, }, }) - ctx.slots.inject('sidebar.settings', () => { - const shellHandle = createSettingsShellStore() - const shellInstance = shellHandle.create() - const shellStore: typeof shellHandle = { ...shellHandle, create: () => shellInstance } - const disposeCommand = ctx.shortcuts.register({ - id: 'settings.open' as ShortcutCommandId, label: () => t('shortcut.open'), aliases: ['settings', 'preferences'], - defaults: { - 'desktop:macos': { code: 'Comma', modifiers: ['primary'] }, - 'desktop:windows': { code: 'Comma', modifiers: ['primary'] }, - 'desktop:linux': { code: 'Comma', modifiers: ['primary'] }, - 'web:macos': { code: 'Comma', modifiers: ['primary', 'alt'] }, - 'web:windows': { code: 'Comma', modifiers: ['primary', 'alt'] }, - }, - regions: ['page', 'editable', 'terminal'], modals: ['settings'], - resolve: ({ modal }) => { - if (modal !== null && modal !== 'settings') return { status: 'blocked', reason: 'modal' } - return { status: 'handled', run: () => { - if (modal === 'settings') closeTopModal(document) - else shellInstance.actions.open() - } } - }, - }) + // The settings shell reads this host's configuration, so it follows access to its description. + ctx.remoteAccess.whenAccess('settings/describe', () => + ctx.slots.inject('sidebar.settings', () => { + const shellHandle = createSettingsShellStore() + const shellInstance = shellHandle.create() + const shellStore: typeof shellHandle = { ...shellHandle, create: () => shellInstance } + const disposeCommand = ctx.shortcuts.register({ + id: 'settings.open' as ShortcutCommandId, label: () => t('shortcut.open'), aliases: ['settings', 'preferences'], + defaults: { + 'desktop:macos': { code: 'Comma', modifiers: ['primary'] }, + 'desktop:windows': { code: 'Comma', modifiers: ['primary'] }, + 'desktop:linux': { code: 'Comma', modifiers: ['primary'] }, + 'web:macos': { code: 'Comma', modifiers: ['primary', 'alt'] }, + 'web:windows': { code: 'Comma', modifiers: ['primary', 'alt'] }, + }, + regions: ['page', 'editable', 'terminal'], modals: ['settings'], + resolve: ({ modal }) => { + if (modal !== null && modal !== 'settings') return { status: 'blocked', reason: 'modal' } + return { status: 'handled', run: () => { + if (modal === 'settings') closeTopModal(document) + else shellInstance.actions.open() + } } + }, + }) - const disposeSlot = ctx.slots.register({ - name: 'sidebar.settings', - locale: NS, - store: shellStore, - children: { - 'settings.launcher': { kind: 'single', scope: 'root' }, - 'settings.trigger': { kind: 'single', scope: 'root' }, - 'settings.header': { kind: 'single', scope: 'root' }, - 'settings.action': { kind: 'list', scope: 'root' }, - 'settings.close': { kind: 'single', scope: 'root' }, - 'settings.section': { kind: 'list', scope: 'root' }, - 'settings.onboarding': { kind: 'list', scope: 'root' }, - }, - inject: shellInjected, - }, SettingsRoot) - return () => { disposeCommand(); disposeSlot() } - }) + const disposeSlot = ctx.slots.register({ + name: 'sidebar.settings', + locale: NS, + store: shellStore, + children: { + 'settings.launcher': { kind: 'single', scope: 'root' }, + 'settings.trigger': { kind: 'single', scope: 'root' }, + 'settings.header': { kind: 'single', scope: 'root' }, + 'settings.action': { kind: 'list', scope: 'root' }, + 'settings.close': { kind: 'single', scope: 'root' }, + 'settings.section': { kind: 'list', scope: 'root' }, + 'settings.onboarding': { kind: 'list', scope: 'root' }, + }, + inject: shellInjected, + }, SettingsRoot) + return () => { disposeCommand(); disposeSlot() } + })) ctx.slots.inject('settings.trigger', () => ctx.slots.register({ name: 'settings.trigger', locale: NS }, TriggerContent)) diff --git a/packages/client/ui-settings-general/tests/apply.client.spec.ts b/packages/client/ui-settings-general/tests/apply.client.spec.ts index 691a9ca717..2b3a09a468 100644 --- a/packages/client/ui-settings-general/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-general/tests/apply.client.spec.ts @@ -96,7 +96,7 @@ function setPageUrl(url: string): void { describe('ui-settings-general apply', () => { it('declares the services it uses', () => { - expect(inject).toEqual(['slots', 'locale', 'connection', 'remote', 'remote.settings', 'configForms', 'shortcuts', 'modules']) + expect(inject).toEqual(['slots', 'locale', 'connection', 'remote', 'remote.settings', 'configForms', 'shortcuts', 'modules', 'remoteAccess']) }) it('fills the five seats of the shell it declares, with the locale-following General label', async ({ mock, start }) => { diff --git a/packages/client/ui-settings-general/tests/shell.client.spec.ts b/packages/client/ui-settings-general/tests/shell.client.spec.ts index 47b7460e2c..a072a81df9 100644 --- a/packages/client/ui-settings-general/tests/shell.client.spec.ts +++ b/packages/client/ui-settings-general/tests/shell.client.spec.ts @@ -16,6 +16,7 @@ import type { DesktopUpdatePresentation } from '../src/types.ts' const SELF = '@deepseek-ai/dsh-client-ui-settings-general' const SIDEBAR = '@deepseek-ai/dsh-client-ui-sidebar' const it = createClientTest({ roster: webApp }) +const withoutSettingsAccess = createClientTest({ roster: webApp }, { access: action => action !== 'settings/describe' }) /** The whole roster's first boot pays the cold module transform of every plugin package. */ const COLD_BOOT_TIMEOUT_MS = 60_000 @@ -77,7 +78,7 @@ describe('ui-settings-general shell', () => { }, COLD_BOOT_TIMEOUT_MS) it('declares its services', () => { - expect(inject).toEqual(['slots', 'locale', 'connection', 'remote', 'remote.settings', 'configForms', 'shortcuts', 'modules']) + expect(inject).toEqual(['slots', 'locale', 'connection', 'remote', 'remote.settings', 'configForms', 'shortcuts', 'modules', 'remoteAccess']) }) it('occupies sidebar.settings, declared by ui-sidebar, and declares every child slot', async ({ start }) => { @@ -86,6 +87,11 @@ describe('ui-settings-general shell', () => { for (const name of CHILD_NAMES) expect(c.ctx.slots.spec(name)).toEqual(CHILD_SPECS[name]) }, COLD_BOOT_TIMEOUT_MS) + withoutSettingsAccess('leaves sidebar.settings unoccupied while the current Peer may not read the settings description', async ({ start }) => { + const c = await start() + expect(c.ctx.slots.entries('sidebar.settings')).toEqual([]) + }, COLD_BOOT_TIMEOUT_MS) + it('projects the section ledger: product sections in order, defaults for bare rows, stable snapshots', async ({ start }) => { const c = await start() const { sections } = injectedOf(c).hooks diff --git a/packages/client/ui-settings-models/tests/apply.client.spec.ts b/packages/client/ui-settings-models/tests/apply.client.spec.ts index f9dac62ceb..66e3eae78c 100644 --- a/packages/client/ui-settings-models/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-models/tests/apply.client.spec.ts @@ -17,10 +17,13 @@ import { import { ModelsSection } from '../src/client/ModelsSection.tsx' import { DeepSeekOnboardingDialog } from '../src/client/DeepSeekOnboardingDialog.tsx' import { WelcomeNotice } from '../src/client/WelcomeNotice.tsx' -import type { IndexInjection } from '@deepseek-ai/dsh-host-webserver' +import type { IndexInjection, WebServer } from '@deepseek-ai/dsh-host-webserver' import * as hostPlugin from '../src/index.ts' import { ONBOARDING_CONFIG_GLOBAL } from '../src/onboarding-config.ts' +/** The Peer an index render speaks for; this listener reads no field of it. */ +const PAGE_PEER = { principal: { kind: 'owner' } } as Parameters[1] + afterEach(() => { vi.unstubAllGlobals() }) // These specs assert the shipped Chinese copy. The lane has no jsdom `window`, @@ -74,7 +77,7 @@ describe('ui-settings-models apply', () => { const host = ctx.plugin(hostPlugin, { credentialOnboarding: false }) await host.await() const rows: IndexInjection[] = [] - ctx.emit('webserver/index-inject', rows) + ctx.emit('webserver/index-inject', rows, PAGE_PEER) expect(rows).toEqual([{ kind: 'global', name: ONBOARDING_CONFIG_GLOBAL, value: { credentialOnboarding: false } }]) for (const row of rows) if (row.kind === 'global') vi.stubGlobal(row.name, row.value) const plugin = ctx.plugin({ inject: [...inject], apply }) @@ -87,7 +90,7 @@ describe('ui-settings-models apply', () => { expect(slots.entries('settings.onboarding')).toEqual([]) await host.dispose() const after: IndexInjection[] = [] - ctx.emit('webserver/index-inject', after) + ctx.emit('webserver/index-inject', after, PAGE_PEER) expect(after).toEqual([]) } finally { await ctx.fiber.dispose() diff --git a/packages/client/ui-sidebar-documentpreview/tests/config.host.spec.ts b/packages/client/ui-sidebar-documentpreview/tests/config.host.spec.ts index 6646008850..8a09bdd497 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/config.host.spec.ts +++ b/packages/client/ui-sidebar-documentpreview/tests/config.host.spec.ts @@ -1,10 +1,13 @@ /** Host configuration supplies bounded Office reuse settings to browser pages. */ import { Context } from '@deepseek-ai/cordis' -import type { IndexInjection } from '@deepseek-ai/dsh-host-webserver' +import type { IndexInjection, WebServer } from '@deepseek-ai/dsh-host-webserver' import { expect, it, onTestFinished } from 'vitest' import { Config } from '../src/config.ts' import * as host from '../src/index.ts' +/** The Peer an index render speaks for; this listener reads no field of it. */ +const PAGE_PEER = { principal: { kind: 'owner' } } as Parameters[1] + it('exposes Client cache and spreadsheet limits on the Host entry', () => { expect(host.Config).toBe(Config) expect(Config({})).toEqual({ office: { @@ -22,10 +25,10 @@ it('embeds YAML cache settings in browser pages and withdraws them on disposal', const fiber = ctx.plugin(host, config) await fiber.await() const rows: IndexInjection[] = [] - ctx.emit('webserver/index-inject', rows) + ctx.emit('webserver/index-inject', rows, PAGE_PEER) expect(rows).toEqual([{ kind: 'global', name: '__DSH_DOCUMENT_PREVIEW_CONFIG__', value: config }]) await fiber.dispose() const after: IndexInjection[] = [] - ctx.emit('webserver/index-inject', after) + ctx.emit('webserver/index-inject', after, PAGE_PEER) expect(after).toEqual([]) }) diff --git a/packages/client/ui-sidebar-right/README.i18n.yaml b/packages/client/ui-sidebar-right/README.i18n.yaml index 4c93ea2e93..c781146562 100644 --- a/packages/client/ui-sidebar-right/README.i18n.yaml +++ b/packages/client/ui-sidebar-right/README.i18n.yaml @@ -24,8 +24,8 @@ en: 2614d3f179290606 zh: 88c08ee260ba57b7 /deepseek-ai-dsh-client-ui-sidebar-right/state: - en: 2b15cdf5f1383291 - zh: 57018036d77363ba + en: 98597ce087c48075 + zh: c5466bf7955ea7b0 /deepseek-ai-dsh-client-ui-sidebar-right/extension-seats: en: ef2af36136c4a919 zh: 48d992e2c923a130 @@ -36,8 +36,8 @@ en: 717b86e05b44ebdd zh: 0f4f694d0ea1d7f8 /deepseek-ai-dsh-client-ui-sidebar-right/the-guide: - en: dfb9e0911e4278f1 - zh: e395e10f61ad040d + en: c67935eddef999fe + zh: 0648664ded92ee82 /deepseek-ai-dsh-client-ui-sidebar-right/copy: en: 4315a1c03ca5fef3 zh: c09a09f1d9b99107 diff --git a/packages/client/ui-sidebar-right/README.md b/packages/client/ui-sidebar-right/README.md index ea0269b716..b5839bd05d 100644 --- a/packages/client/ui-sidebar-right/README.md +++ b/packages/client/ui-sidebar-right/README.md @@ -69,7 +69,7 @@ Carrying the mint counter in the surface is what makes a recorded sequence repla After every action the kit's settle planner keeps the expanded surface populated: a docked pane whose last tab was moved out or floated is merged away, and an emptied root pane receives the default page. A collapsed surface holds no such backfill — a session starts collapsed and empty, and a close that collapses the column leaves it empty — the expansion that would first show an empty layout is what seeds the default page. Explicit closing follows [the default-page and close rules](#the-guide). While expanded there is always at least one tab, and never an empty pane — so there is no separate "close pane" gesture. -The docked surface's last tab carries one more rule, decided in the store's `closeTab` and mirrored to the kit through `canCloseTab`: the guide standing as the only docked tab draws no close control and no menu close item — its chip sits quiet, and with no extension item contributed a secondary press opens no menu — and a programmatic close of it records nothing; any other tab standing alone closes together with the column in one entry — the layout stays empty until the next expansion seeds its current default page. Floating panels take no part in the rule: the foreground Session's floats render whether or not the column is expanded, and their tabs close freely. +The docked surface's last tab carries one more rule, decided in the store's `closeTab` and mirrored to the kit through `canCloseTab`: the guide standing as the only docked tab draws no close control or menu close item and ignores middle clicks — its chip sits quiet, and with no extension item contributed a secondary press opens no menu — and a programmatic close of it records nothing; any other tab standing alone closes together with the column in one entry — the layout stays empty until the next expansion seeds its current default page. Floating panels take no part in the rule: the foreground Session's floats render whether or not the column is expanded, and their tabs close freely. The store saves each Session's layout, tab identities, selection, split ratios, floating rectangles, presentation and identity counter as JSON under `dsh.sidebar-right.v1.` in localStorage. Reload restores that layout before tab bodies render; switching Sessions keeps their layouts independent. Providers restore their own content from the retained tab identity and resource address, including [terminal reconnection](../ui-sidebar-terminal/README.md#use-this-package). Navigation parameters, resource contents and live connections are not layout state. Undo history stays in memory and resets on reload. Saved field types, node membership, selection, identity counters and the one-pane or two-horizontal-pane dock layout are validated before adoption or startup discovery; invalid data clears only its Session key. Storage failures leave the current layout usable in memory. Windows at the same origin share the last saved layout per Session; each active window keeps its own current layout until reload. @@ -118,7 +118,7 @@ While the root-scoped `sidebar.right.tab.guide.actions` list has registrations, A tab type can declare `multiple: true` for independent instances such as terminals. Each open receives a distinct content address, so placement and docking preserve those instances; ordinary pages retain per-pane uniqueness. -Default pages depend on the number of registered guide entries, not the number of tab types or open tabs. Exactly one entry opens its page directly; zero or multiple entries open the guide. Explicitly adding a guide still opens the guide, even with one entry. The sole docked guide is the only tab that cannot close; closing any other sole tab also collapses the column. The chip, context menu, and `close` API apply the same rule. +Default pages depend on the number of registered guide entries, not the number of tab types or open tabs. Exactly one entry opens its page directly; zero or multiple entries open the guide. Explicitly adding a guide still opens the guide, even with one entry. The sole docked guide is the only tab that cannot close; closing any other sole tab also collapses the column. The close control, context menu, middle click, and `close` API apply the same rule. The guide tab is a muted compass over one entry capsule per `guide` entry the registered types contributed, in `order`, centred in the body; the guide has no words of its own. A capsule shows the entry's glyph — or the guide's quieter cube placeholder when the entry registered none — and its title; while at most four entries are listed, an entry that registered a `description` shows it under the title, and a longer list drops every description. Picking a default capsule calls `tab.actions.openTab(entry.kind, { replaceTab: true })`, so the guide gives way to the page it opened. A pane holds at most one guide tab. The strip's add control is drawn only while its pane holds none and opens one there with `openTab('guide', { paneId, revealIfOpened: false })`, so a guide in another pane does not capture the click; opening the guide into a pane that already has one focuses it instead; a guide dragged, dropped, or docked into such a pane merges into it — the arriving guide closes and the pane's own is focused; `duplicateTab` on the guide records nothing. A split, an expanded empty root pane, and the pane a sole tab vacates by dropping on its own edge use the same default-page rule, one tab per new pane; the self-edge drop leaves the dragged tab focused. A plain `openTab('guide')` opens or focuses the guide only within the active or named pane. Splitting an empty pane does nothing and returns no new pane. The product allows two horizontal panes, initially equal, with divider ratios limited to 20%–80%. Insufficient width blocks a new split; with two panes already present, a body drop moves the tab between panes instead of creating a third. At the two-pane limit, split controls are disabled with a focusable reason; closing back to one pane enables them. diff --git a/packages/client/ui-sidebar-right/README.zh.md b/packages/client/ui-sidebar-right/README.zh.md index 052b38e2c8..cfb16e3e53 100644 --- a/packages/client/ui-sidebar-right/README.zh.md +++ b/packages/client/ui-sidebar-right/README.zh.md @@ -69,7 +69,7 @@ root 作用域的 `rightbar` 入口通过独立的 `rightbar.session` 子树渲 每个动作之后,套件的 settle planner 保证展开的停靠面有内容:最后一个 tab 被搬走或浮出的停靠格会被并掉;根格被清空时填入默认页。折叠的停靠面没有这种回填——会话以折叠且为空的停靠面开始,收起整列的关闭也让它保持为空——首次会展示空布局的那次展开才播种默认页。显式关闭遵循[默认页与关闭规则](#the-guide)。展开期间永远至少有一个 tab,永远没有空格——因此没有单独的「关闭格」手势。 -停靠面的最后一个 tab 还多带一条规则,由 store 的 `closeTab` 决定并经 `canCloseTab` 镜像给套件:作为唯一停靠 tab 的引导页不画关闭控件也不画菜单里的关闭项——它的 chip 呈安静样式,在没有扩展条目时次键按下也不弹出菜单——对它的编程式关闭什么都不记录;任何其它 tab 独自留下时,点击关闭会连同整列一起收起,记为一条历史,布局保持为空,直到下次展开时创建当时的默认页。浮动面板不参与这条规则:前台 Session 的浮窗无论列是否展开都会渲染,其 tab 照常关闭。 +停靠面的最后一个 tab 还多带一条规则,由 store 的 `closeTab` 决定并经 `canCloseTab` 镜像给套件:作为唯一停靠 tab 的引导页不画关闭控件或菜单里的关闭项,也忽略中键点击——它的 chip 呈安静样式,在没有扩展条目时次键按下也不弹出菜单——对它的编程式关闭什么都不记录;任何其它 tab 独自留下时,点击关闭会连同整列一起收起,记为一条历史,布局保持为空,直到下次展开时创建当时的默认页。浮动面板不参与这条规则:前台 Session 的浮窗无论列是否展开都会渲染,其 tab 照常关闭。 store 将每个 Session 的布局、标签身份、选中项、分栏比例、浮窗矩形、呈现方式和身份计数以 JSON 保存到 localStorage 的 `dsh.sidebar-right.v1.`。刷新时在渲染标签正文前恢复布局;不同 Session 的布局独立。provider 根据保留的标签身份和资源地址恢复自身内容,包括[终端重连](../ui-sidebar-terminal/README.zh.md#use-this-package)。导航参数、资源内容和活动连接不属于布局状态。撤销历史只保留在内存中,刷新后清空。采用布局或启动探测前会校验保存字段类型、节点成员关系、选中项、身份计数,以及单格或左右两格的停靠布局;无效数据只清除对应 Session 的 key。存储失败时,当前布局仍可在内存中使用。同源窗口共享每个 Session 最后保存的布局;活动窗口在刷新前保留各自的当前布局。 @@ -118,7 +118,7 @@ root 作用域的 `sidebar.right.tab.guide.actions` list 有注册项时,引 终端等需要独立实例的 tab 类型可声明 `multiple: true`。每次打开都会获得独立内容地址,因此移动和停靠会保留这些实例;普通页面仍在每个格内去重。 -默认页取决于已注册的引导入口数,不取决于 tab 类型数或已打开的 tab 数。恰好一个入口时直接打开对应页面;没有入口或有多个入口时打开引导页。即使只有一个入口,显式添加引导页仍会打开引导页。只有作为唯一停靠 tab 的引导页不可关闭;关闭其它任何唯一 tab 时会同时收起整列。chip、上下文菜单与 `close` API 使用同一规则。 +默认页取决于已注册的引导入口数,不取决于 tab 类型数或已打开的 tab 数。恰好一个入口时直接打开对应页面;没有入口或有多个入口时打开引导页。即使只有一个入口,显式添加引导页仍会打开引导页。只有作为唯一停靠 tab 的引导页不可关闭;关闭其它任何唯一 tab 时会同时收起整列。关闭控件、上下文菜单、中键与 `close` API 使用同一规则。 引导 tab 是一枚弱化的罗盘,下方是各已注册类型贡献的每个 `guide` 条目一个入口胶囊,按 `order` 排列并在正文中居中;引导页自己没有文字。胶囊显示条目的图标——条目没注册图标时用引导页自己更浅的立方体占位符——和标题;列出的条目不超过四个时,注册了 `description` 的条目在标题下显示它,更长的列表则去掉所有描述。点击默认胶囊会调用 `tab.actions.openTab(entry.kind, { replaceTab: true })`,于是引导页让位给它打开的页。一个格最多持有一个引导 tab。tab 条的添加控件只在该格没有引导 tab 时绘制,并以 `openTab('guide', { paneId, revealIfOpened: false })` 在该格打开一个,这样别的格里的引导页不会截走这次点击;把引导页开进已有引导页的格则改为聚焦它;把引导页拖入、放入或收回到这样的格会合并进去——来者关闭,该格自己的被聚焦;对引导页 `duplicateTab` 不记录任何东西。分栏、展开且为空的根格、以及唯一 tab 拖到本格边缘分屏后腾出的格使用相同的默认页规则,每个新格一个 tab;这种本格边缘拖放让被拖的 tab 保持聚焦。普通的 `openTab('guide')` 只在活跃或指定分栏内打开或聚焦引导页。对空分栏执行 split 不产生变化,也不返回新分栏。产品最多保留左右两格,默认均分,分隔条限定在 20%~80%。宽度不足以容纳两格时不允许新分栏;已有两格时,正文拖放用于跨格移动,不再创建第三格。达到两格上限时禁用分栏控件并提供可聚焦的原因提示;关闭回单格后重新启用。 diff --git a/packages/client/ui-theme/tests/config.host.spec.ts b/packages/client/ui-theme/tests/config.host.spec.ts index 69a10570b8..0de2aac907 100644 --- a/packages/client/ui-theme/tests/config.host.spec.ts +++ b/packages/client/ui-theme/tests/config.host.spec.ts @@ -1,6 +1,6 @@ import { Context } from '@deepseek-ai/cordis' import { describe, expect, it } from 'vitest' -import type { IndexInjection } from '@deepseek-ai/dsh-host-webserver' +import type { IndexInjection, WebServer } from '@deepseek-ai/dsh-host-webserver' import * as HostPlugin from '../src/index.ts' import { liveConfig, omitsGeneratedPage } from '../../../settings/settings/tests/live-config.ts' import { plainConfig } from '../../../settings/settings/src/schema.ts' @@ -8,11 +8,14 @@ import { DEFAULT_PREFERENCE, Config, apply, } from '@deepseek-ai/dsh-client-ui-theme' +/** The Peer an index render speaks for; this listener reads no field of it. */ +const PAGE_PEER = { principal: { kind: 'owner' } } as Parameters[1] + /** Collect the injection table the way an index render or boot payload does. */ function collect(ctx: Context): IndexInjection[] { const table: IndexInjection[] = [] - ctx.emit('webserver/index-inject', table) + ctx.emit('webserver/index-inject', table, PAGE_PEER) return table } diff --git a/packages/client/ui-tool/package.json b/packages/client/ui-tool/package.json index 938f6bf8d2..a836628ab0 100644 --- a/packages/client/ui-tool/package.json +++ b/packages/client/ui-tool/package.json @@ -51,7 +51,10 @@ "@deepseek-ai/dsh-llm": "workspace:*", "@deepseek-ai/dsh-session": "workspace:*", "@deepseek-ai/dsh-system-prompt": "workspace:*", + "@deepseek-ai/dsh-tool-testkit": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", + "@deepseek-ai/dsh-tool-executor-native": "workspace:*", + "@deepseek-ai/dsh-tool-executor-ptc": "workspace:*", "@deepseek-ai/dsh-tool-jobs": "workspace:*", "@deepseek-ai/dsh-tool-session-query": "workspace:*", "@deepseek-ai/dsh-tool-terminal": "workspace:*", diff --git a/packages/client/ui-tool/src/client/tool/models/todo-history.ts b/packages/client/ui-tool/src/client/tool/models/todo-history.ts index 591bc05ca5..c6873ab56d 100644 --- a/packages/client/ui-tool/src/client/tool/models/todo-history.ts +++ b/packages/client/ui-tool/src/client/tool/models/todo-history.ts @@ -1,5 +1,5 @@ /** Indexed recorded todo predecessors for root and nested Tool calls. */ -import type {} from '@deepseek-ai/dsh-tools/types' +import type {} from '@deepseek-ai/dsh-tool-executor-ptc/client' import type { Context } from '@deepseek-ai/cordis' import type { ConversationNodeDefinition, ConversationViewDefinition, ConversationViewNode, TodoItem, diff --git a/packages/client/ui-tool/tests/spill-policy-terminal.client.spec.ts b/packages/client/ui-tool/tests/spill-policy-terminal.client.spec.ts index 6aed393a37..1b92e65a1f 100644 --- a/packages/client/ui-tool/tests/spill-policy-terminal.client.spec.ts +++ b/packages/client/ui-tool/tests/spill-policy-terminal.client.spec.ts @@ -1,4 +1,7 @@ /** Producer-to-UI regression for shell results bounded by the real spill policy. */ +import { executeModelToolFixture } from '@deepseek-ai/dsh-tool-testkit' +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' +import * as PtcToolExecutor from '@deepseek-ai/dsh-tool-executor-ptc' import { Context } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import type { ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client' @@ -11,7 +14,8 @@ import { SpillLocator, SpillStore, type SaveTextSpill, type SpillRef } from '@de import * as SpillPolicy from '@deepseek-ai/dsh-spill-policy' import { formatSpillNotice } from '@deepseek-ai/dsh-spill-policy/notice' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRuntime, { defineContentToolFixture } from '@deepseek-ai/dsh-tools' +import ToolRuntime from '@deepseek-ai/dsh-tools' +import { defineContentToolFixture } from '@deepseek-ai/dsh-tool-testkit' import { PartialArguments } from '@deepseek-ai/dsh-util-values' import { describe, expect, it } from 'vitest' import { isSpilledShellCall, terminalCardModel } from '../src/client/tool/models/terminal-card-model.ts' @@ -40,7 +44,8 @@ async function executeShell(text: string, nested: boolean, name = 'bash', maxInl const ctx = new Context() try { await ctx.plugin(SystemPrompt) - await ctx.plugin(ToolRuntime, { mode: nested ? 'ptc' : 'native' }) + await ctx.plugin(ToolRuntime) + await ctx.plugin((nested ? 'ptc' : 'native') === 'ptc' ? PtcToolExecutor : NativeToolExecutor) ctx.provide('workingDirectory', { ensure: async () => process.cwd() }) await ctx.plugin(MemorySpillStore) await ctx.plugin(SpillPolicy, { maxInlineTokens }) @@ -75,7 +80,7 @@ async function executeShell(text: string, nested: boolean, name = 'bash', maxInl // Nested PTC registers retained-environment cleanup on the owning context. const agent = { ctx, session } as Agent const callId = ToolCallId('shell-call') - const result = await ctx.tools.execute({ + const result = await executeModelToolFixture(ctx, { callId, agent, signal: new AbortController().signal, diff --git a/packages/client/ui-tool/tsconfig.json b/packages/client/ui-tool/tsconfig.json index 966cb32e1a..b6ff14828a 100644 --- a/packages/client/ui-tool/tsconfig.json +++ b/packages/client/ui-tool/tsconfig.json @@ -55,6 +55,9 @@ }, { "path": "../../util/workspace-path" + }, + { + "path": "../../core/tool-executor-ptc" } ] } diff --git a/packages/client/ui-trajectory/package.json b/packages/client/ui-trajectory/package.json index 9c6438828a..cbf1118f14 100644 --- a/packages/client/ui-trajectory/package.json +++ b/packages/client/ui-trajectory/package.json @@ -52,10 +52,10 @@ "@deepseek-ai/dsh-client-test-runtime": "workspace:*", "@deepseek-ai/dsh-client-ui-primitives": "workspace:*", "@deepseek-ai/dsh-client-ui-conversation": "workspace:*", + "@deepseek-ai/dsh-tool-executor-ptc": "workspace:*", "@deepseek-ai/dsh-util-values": "workspace:*", "@deepseek-ai/dsh-client-ui-slots": "workspace:*", "@deepseek-ai/dsh-compaction": "workspace:*", - "@deepseek-ai/dsh-tools": "workspace:*", "@deepseek-ai/cordis": "workspace:~", "react": "^18.2.0", "react-dom": "^18.2.0", diff --git a/packages/client/ui-trajectory/src/client/trajectory-tool-definition.ts b/packages/client/ui-trajectory/src/client/trajectory-tool-definition.ts index aa0f4489b1..ba06b40f9b 100644 --- a/packages/client/ui-trajectory/src/client/trajectory-tool-definition.ts +++ b/packages/client/ui-trajectory/src/client/trajectory-tool-definition.ts @@ -3,7 +3,7 @@ import type { ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, StartedToolCall, ToolResultNode, } from '@deepseek-ai/dsh-client-ui-conversation/client' -import type {} from '@deepseek-ai/dsh-tools/types' +import type {} from '@deepseek-ai/dsh-tool-executor-ptc/client' import { PartialArguments } from '@deepseek-ai/dsh-util-values' import { trajectoryNode } from './trajectory-definition-common.ts' diff --git a/packages/client/ui-trajectory/tsconfig.json b/packages/client/ui-trajectory/tsconfig.json index 744091c364..fb862eeaec 100644 --- a/packages/client/ui-trajectory/tsconfig.json +++ b/packages/client/ui-trajectory/tsconfig.json @@ -41,9 +41,6 @@ { "path": "../../core/session" }, - { - "path": "../../core/tools" - }, { "path": "../../compaction/compaction" }, @@ -52,6 +49,9 @@ }, { "path": "../../attachment/attachment" + }, + { + "path": "../../core/tool-executor-ptc" } ] } diff --git a/packages/client/ui-user-questions/README.i18n.yaml b/packages/client/ui-user-questions/README.i18n.yaml index e2e000bc53..a307a1361c 100644 --- a/packages/client/ui-user-questions/README.i18n.yaml +++ b/packages/client/ui-user-questions/README.i18n.yaml @@ -18,8 +18,8 @@ en: 8d3815c19858ac85 zh: 94fc905fa202d334 /deepseek-ai-dsh-client-ui-user-questions/use-this-package/answering: - en: 0fa156d3d698cf0e - zh: cbcd082b9a188d6b + en: 8900fbf9cbd72083 + zh: 67a951d58a029a58 /deepseek-ai-dsh-client-ui-user-questions/use-this-package/reading-a-settled-question-back: en: 10975f68ff9c6fcc zh: 92246aadc363f0eb @@ -27,8 +27,8 @@ en: 6b12f1362c614a39 zh: 27d9cf54d4cbd5d2 /deepseek-ai-dsh-client-ui-user-questions/use-this-package/failure-and-recovery: - en: 3509678f4d183834 - zh: 625542b79e434eb5 + en: 3ad0e0fd224aabfc + zh: db15f400e6f2bfd7 /deepseek-ai-dsh-client-ui-user-questions/understand-the-implementation: en: 77e1b2585acdf67b zh: d433ec185c6b8f57 diff --git a/packages/client/ui-user-questions/README.md b/packages/client/ui-user-questions/README.md index 9a6175d227..a637ac1251 100644 --- a/packages/client/ui-user-questions/README.md +++ b/packages/client/ui-user-questions/README.md @@ -29,7 +29,7 @@ When the agent asks a question, the attached card temporarily occupies the compo ### Answering -A multi-select draft keeps its selected labels while the user opens or edits the custom answer, so its submitted item may carry both `selected` and `custom`; a single-select custom answer remains exclusive. Question detail reuses the assistant-output `MarkdownText` primitive, including its GFM rendering and untrusted-content policy. The capped card keeps its title, navigation, and submission actions fixed while long detail and choices share an internal scroll region. "Skip" retains other drafts and emits the existing blank `{ selected: [] }` result for that item. Close is not an answer: a question the Host named by tool call only leaves the composer seat, and its `ask_user_question` tool call row brings the panel back; a request that carries no tool call has no row to return from, so closing it rejects the whole wait as `ASK_CANCELLED`. +A multi-select draft keeps its selected labels while the user opens or edits the custom answer, so its submitted item may carry both `selected` and `custom`; a single-select custom answer remains exclusive. Question detail reuses the assistant-output `MarkdownText` primitive, including its GFM rendering and untrusted-content policy. The capped card keeps navigation and submission actions fixed while the question, long detail, and choices share an internal scroll region. A question appears in full up to three rendered lines; a longer one shows two, fades at the end of the second, and places a downward-chevron expand button there that expands it in place. Option descriptions use 13px text with a 20px line height and sit beside their label when they fit, otherwise below it; a description appears in full up to two lines, and a longer one shows one line with the same fade and expand button. Expanded copy keeps an upward-chevron collapse button on a line below it. Both buttons are named **Expand** or **Collapse**, show that name in a hover tooltip, and are described by the text they control. CSS scroll timelines decide which copy overflows; a browser without them shows all copy in full. "Skip" retains other drafts and emits the existing blank `{ selected: [] }` result for that item. Close is not an answer: a question the Host named by tool call only leaves the composer seat, and its `ask_user_question` tool call row brings the panel back; a request that carries no tool call has no row to return from, so closing it rejects the whole wait as `ASK_CANCELLED`. Fixed approval questions use the same pending-question and answer channel. A feature plugin can supply a higher-priority composer presentation. When none matches, the generic form keeps the offered options answerable; the caller still decides which responses grant permission. @@ -45,7 +45,7 @@ A `plan-review` intent — set by `dsh-plan-mode` on the `exit_plan_mode` review ### Failure and recovery -An answer field autofocuses only when its answer channel is ready and no countdown exists. Manual focus and blur during the claim handshake are retained: the countdown starts paused only if the answer surface still has focus when the remaining duration arrives. +An answer field autofocuses only when its answer channel is ready and no countdown exists. Custom-answer focus survives Enter navigation between option questions; navigation resets the question scroll position and collapses expanded copy. Manual focus and blur during the claim handshake are retained: the countdown starts paused only if the answer surface still has focus when the remaining duration arrives. Returning a local answer does not release its foreground claim: the Host closes that stream after accepting the waterfall outcome. The plugin owns the claim through this delivery interval. Explicit delegation releases it before calling the next answerer, and plugin disposal cancels any remaining claims. diff --git a/packages/client/ui-user-questions/README.zh.md b/packages/client/ui-user-questions/README.zh.md index b62f3c1db2..85a4cc8902 100644 --- a/packages/client/ui-user-questions/README.zh.md +++ b/packages/client/ui-user-questions/README.zh.md @@ -29,7 +29,7 @@ Web 客户端在聊天输入框旁显示 agent(智能体)的提问。用户 ### 作答 -用户打开或编辑自定义答案时,多选题草稿会保留已选中的标签,因此提交项可以同时携带 `selected` 与 `custom`;单选题的自定义答案仍保持互斥。问题详情复用助手输出的 `MarkdownText` 原语,包括其 GFM 渲染与不受信任内容策略。限高卡片保持标题、导航与提交动作固定,超长的详情与选项共享内部滚动区。「跳过」会保留其他草稿,并为该项发出既有的空 `{ selected: [] }` 结果。关闭不是一种回答:Host 以工具调用命名的问题只是离开编辑器位置,其 `ask_user_question` 工具调用行会把面板带回来;不携带工具调用的请求没有可返回的行,关闭它仍以 `ASK_CANCELLED` 拒绝整个等待。 +用户打开或编辑自定义答案时,多选题草稿会保留已选中的标签,因此提交项可以同时携带 `selected` 与 `custom`;单选题的自定义答案仍保持互斥。问题详情复用助手输出的 `MarkdownText` 原语,包括其 GFM 渲染与不受信任内容策略。限高卡片保持导航与提交动作固定,问题正文、超长详情和选项共享内部滚动区。问题在实际排版不超过三行时完整显示;更长的问题只显示两行,第二行末尾渐隐并放置向下箭头的展开按钮,点击后原地展开。选项说明使用 13px 字号和 20px 行高,放得下时排在标签旁,否则排在标签下方;说明不超过两行时完整显示,更长的说明只显示一行,同样渐隐并放置展开按钮。展开后的文字下方单独一行显示向上箭头的收起按钮。两种按钮名为「展开」或「收起」,悬停时显示同样的提示,并以其控制的文字作为无障碍描述。由 CSS 滚动时间线判断文字是否溢出;不支持该特性的浏览器完整显示全部文字。「跳过」会保留其他草稿,并为该项发出既有的空 `{ selected: [] }` 结果。关闭不是一种回答:Host 以工具调用命名的问题只是离开编辑器位置,其 `ask_user_question` 工具调用行会把面板带回来;不携带工具调用的请求没有可返回的行,关闭它仍以 `ASK_CANCELLED` 拒绝整个等待。 固定审批问题复用待处理问题和回答通道。功能插件可以提供优先级更高的 composer;没有匹配的呈现时,通用表单仍可选择并提交原有选项,由调用方判断哪些回答可以授予权限。 @@ -45,7 +45,7 @@ Web 客户端在聊天输入框旁显示 agent(智能体)的提问。用户 ### 失败与恢复 -回答输入框只有在回答通道就绪且没有倒计时时才自动聚焦。接手握手期间的手动聚焦与失焦会被保留:剩余时长到达时,只有回答区域仍持有焦点,倒计时才以暂停状态开始。 +回答输入框只有在回答通道就绪且没有倒计时时才自动聚焦。在带选项的问题间按 Enter 切题时,自定义回答框保留焦点;切题会重置问题滚动位置并收起已展开的文字。接手握手期间的手动聚焦与失焦会被保留:剩余时长到达时,只有回答区域仍持有焦点,倒计时才以暂停状态开始。 本地返回回答不会释放前台接手记录:Host 接受 waterfall 结果后才关闭该 stream。插件在这段传输期间继续拥有接手记录。显式委托会在调用下一个回答方前释放它,插件卸载则取消剩余接手记录。 diff --git a/packages/client/ui-user-questions/package.json b/packages/client/ui-user-questions/package.json index 19fd208e09..6f9c2bf77b 100644 --- a/packages/client/ui-user-questions/package.json +++ b/packages/client/ui-user-questions/package.json @@ -57,6 +57,7 @@ "@deepseek-ai/dsh-client-locale": "workspace:*", "@deepseek-ai/dsh-client-store": "workspace:*", "@deepseek-ai/dsh-system-prompt": "workspace:*", + "@deepseek-ai/dsh-tool-executor-native": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", "@deepseek-ai/dsh-user-questions": "workspace:*", "@deepseek-ai/dsh-client-ui-primitives": "workspace:*", diff --git a/packages/client/ui-user-questions/src/client/QuestionComposer.module.css b/packages/client/ui-user-questions/src/client/QuestionComposer.module.css index 8de8c84028..5c530aef72 100644 --- a/packages/client/ui-user-questions/src/client/QuestionComposer.module.css +++ b/packages/client/ui-user-questions/src/client/QuestionComposer.module.css @@ -60,6 +60,7 @@ align-items: center; gap: 4px; flex-shrink: 0; + margin-left: auto; } .waitButton { @@ -85,6 +86,7 @@ } .headingBlock { + flex: 1; min-width: 0; } @@ -103,8 +105,70 @@ font-weight: 500; } +/* Long copy keeps one line fewer than its budget once it overflows: a + question shows in full up to three lines, an option description up to two; + longer copy drops its last line, fades at the end of the line before, and + shows its expand button there. Each box keeps its full budget, plus a 2px + allowance for glyphs taller than the line, so the scroll timeline that + reports the overflow never depends on the collapsed layout. A negative + bottom margin, not a smaller box, gives the dropped line back to the row, + and clip-path removes that line from hit testing as well as from view. */ +.questionTitle { + position: relative; + flex-shrink: 0; + margin: 5px 24px 0; + timeline-scope: --question-copy; +} + +.questionTitle[data-expanded] { + padding-bottom: 24px; +} + +.titlePreview { + max-height: calc(3lh + 2px); + margin: 0 0 -2px; + padding-bottom: 2px; + overflow: hidden; + overflow-wrap: anywhere; + white-space: pre-wrap; + scroll-timeline: --question-copy block; + /* Longhands: the bundled lightningcss folds animation-timeline into the + animation shorthand, a form browsers reject. */ + animation-name: question-clipped; + animation-fill-mode: both; + animation-timeline: --question-copy; +} + +@keyframes question-clipped { + from, to { + margin-bottom: calc(-1lh - 2px); + clip-path: inset(0 0 calc(1lh + 2px) 0); + mask-image: linear-gradient(#000, #000), linear-gradient(to right, #000 calc(100% - 56px), transparent calc(100% - 28px)); + mask-size: 100% 1lh, 100% 1lh; + mask-position: 0 0, 0 1lh; + mask-repeat: no-repeat; + } +} + +.questionToggle { + position: absolute; + right: -4px; + bottom: -1px; + animation-name: copy-toggle-shown; + animation-fill-mode: both; + animation-timeline: --question-copy; +} + +.cardMinimized .title { + display: -webkit-box; + overflow: hidden; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +} + .detail { - margin: 0 2px 8px; + flex-shrink: 0; + margin: 0 24px 8px; } .footerActions { @@ -175,29 +239,32 @@ padding: 4px 12px; } +.optionRow { + position: relative; + width: 100%; + min-height: 40px; + flex-shrink: 0; + border: 0.5px solid transparent; + border-radius: var(--dsw-radius-md); + background: transparent; + transition: background-color 120ms ease, border-color 120ms ease; + timeline-scope: --description-copy; +} + .option { display: flex; - /* flex-start, not center: with a wrapped description the indicator must - stay on the FIRST line (centering drifts it down the taller copy block). - The 8px padding makes a single-line row 40px exactly, so nothing reads - as top-heavy; .number/.checkbox re-center against the first line box. */ + /* flex-start keeps the indicator aligned with the first label line. */ align-items: flex-start; gap: 8px; width: 100%; + min-width: 0; min-height: 40px; - /* Rows are the scroll content, never the slack absorber: a shrinkable row - collapses to min-height while its wrapped copy keeps the taller - intrinsic height, and centered content then paints outside the row box — - over the title and the next row. Overflow belongs to .options. */ - flex-shrink: 0; padding: 8px 12px 8px 8px; - border: 1px solid transparent; - border-radius: var(--dsw-radius-md); + border: 0; background: transparent; color: inherit; text-align: left; cursor: pointer; - transition: background-color 120ms ease, border-color 120ms ease; } .optionSelected { @@ -205,7 +272,7 @@ } @media (hover: hover) { - .option:hover:not(:disabled) { + .optionRowInteractive:hover { background: var(--dsw-alias-interactive-bg-hover); } } @@ -277,12 +344,17 @@ } .optionCopy { - min-width: 0; + display: flex; flex: 1; + flex-wrap: wrap; + align-items: baseline; + gap: 0 6px; + min-width: 0; } .optionLine { display: flex; + max-width: 100%; align-items: baseline; flex-wrap: wrap; gap: 2px 6px; @@ -305,10 +377,84 @@ } .description { + display: block; + width: max-content; + max-width: 100%; + min-width: 0; + max-height: calc(2lh + 2px); + margin-bottom: -2px; + padding-bottom: 2px; + overflow: hidden; + overflow-wrap: anywhere; + white-space: pre-wrap; color: var(--dsw-alias-label-tertiary); - font-size: 14px; - line-height: 24px; + font-size: 13px; + line-height: 20px; font-weight: 400; + scroll-timeline: --description-copy block; + animation-name: description-clipped; + animation-fill-mode: both; + animation-timeline: --description-copy; +} + +@keyframes description-clipped { + from, to { + margin-bottom: calc(-1lh - 2px); + clip-path: inset(0 0 calc(1lh + 2px) 0); + mask-image: linear-gradient(to right, #000 calc(100% - 56px), transparent calc(100% - 28px)); + mask-size: 100% 1lh; + mask-repeat: no-repeat; + } +} + +.descriptionToggle { + position: absolute; + right: 8px; + bottom: 6px; + animation-name: copy-toggle-shown; + animation-fill-mode: both; + animation-timeline: --description-copy; +} + +/* Expanded copy keeps its collapse button on a line of its own below the text. */ +.optionRow[data-expanded] .option { + padding-bottom: 32px; +} + +.questionTitle[data-expanded] .titlePreview, +.optionRow[data-expanded] .description { + max-height: none; + animation: none; +} + +/* Shares .iconButton's 24px round control; hidden until its copy overflows or expands. */ +.copyToggle { + visibility: hidden; +} + +@keyframes copy-toggle-shown { + from, to { + visibility: visible; + } +} + +.questionTitle[data-expanded] .copyToggle, +.optionRow[data-expanded] .copyToggle { + visibility: visible; + animation: none; +} + +/* Without scroll timelines clipped copy cannot be told apart, so it shows in full. */ +@supports not (animation-timeline: scroll()) { + .titlePreview, + .description { + max-height: none; + animation: none; + } + + .copyToggle { + display: none; + } } /* Read-only note standing in for a question the user skipped: it takes the @@ -337,7 +483,7 @@ it pushes the inline input past the footer. */ flex-shrink: 0; padding: 8px 12px 8px 8px; - border: 1px solid transparent; + border: 0.5px solid transparent; border-radius: var(--dsw-radius-md); transition: background-color 120ms ease, border-color 120ms ease; } @@ -477,6 +623,10 @@ padding: 8px 6px; } + .descriptionToggle { + right: 2px; + } + .footer { align-items: flex-end; padding: 0 10px; @@ -488,7 +638,7 @@ } @media (prefers-reduced-motion: reduce) { - .option, + .optionRow, .customRow { transition: none; } diff --git a/packages/client/ui-user-questions/src/client/QuestionComposer.tsx b/packages/client/ui-user-questions/src/client/QuestionComposer.tsx index 8d3ad551ba..0baf6e54c5 100644 --- a/packages/client/ui-user-questions/src/client/QuestionComposer.tsx +++ b/packages/client/ui-user-questions/src/client/QuestionComposer.tsx @@ -1,12 +1,12 @@ import { - useEffect, useMemo, useRef, useState, + useEffect, useId, useMemo, useRef, useState, type ChangeEvent, type FocusEvent, type KeyboardEvent, } from 'react' import clsx from 'clsx' import { Button, IconCheckOutlineRegular, IconChevronDownOutlineRegular, IconChevronLeftOutlineRegular, IconChevronRightOutlineRegular, IconChevronUpOutlineRegular, IconCloseOutlineRegular, - IconEditOutlineRegular, MarkdownText, + IconEditOutlineRegular, MarkdownText, Tooltip, } from '@deepseek-ai/dsh-client-ui-primitives' import { planReviewOf, type QuestionAnswer, type QuestionCardSnapshot, type QuestionComposerProps } from './contract/slots.ts' import type { PendingQuestion } from './contract/slots.ts' @@ -27,6 +27,16 @@ const REMOVED_CARD: QuestionCardSnapshot = { state: 'open', waitState: 'counting', countdown: undefined, channel: 'none', closed: true, } +/** Long copy the user expanded on one question: the question itself or an option description by index. */ +interface ExpandedCopy { + /** Index of the question the expansions belong to. */ + readonly index: number + /** Expanded copy of that question: `'question'` or an option index. */ + readonly copies: ReadonlySet<'question' | number> +} + +const NOTHING_EXPANDED: ExpandedCopy['copies'] = new Set() + /** * Split the conventional recommendation suffix without changing the answer value. * @param label - Original option label returned if selected. @@ -157,6 +167,7 @@ type QuestionFlowProps = { pending: PendingQuestion } & Pick function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: QuestionFlowProps) { + const descriptionIdPrefix = useId() const questions = pending.questions // A read-only card built from a settled call's transcript: the same panel // over the recorded answers, with nothing left to submit. @@ -241,8 +252,25 @@ function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: Questi // oxlint-disable-next-line typescript/no-non-null-assertion const draft = drafts[index]! const hasOptions = (question.options?.length ?? 0) > 0 + // CSS decides which copy is clipped; only the user's expansions are state, + // and they apply only while their question is the one shown. + const [expandedCopy, setExpandedCopy] = useState({ index, copies: NOTHING_EXPANDED }) + const expanded = expandedCopy.index === index ? expandedCopy.copies : NOTHING_EXPANDED + + const questionToggleLabel = t(expanded.has('question') ? 'text.collapse' : 'text.expand') + + const toggleCopy = (copy: 'question' | number): void => { + const copies = new Set(expanded) + if (!copies.delete(copy)) copies.add(copy) + setExpandedCopy({ index, copies }) + } const replaceProgress = (nextIndex: number, nextDrafts: QuestionDraftAnswer[]): void => { + if (nextIndex !== index) { + // Another question starts at its top with its long copy collapsed. + if (answerSurface.current !== null) answerSurface.current.scrollTop = 0 + setExpandedCopy({ index: nextIndex, copies: NOTHING_EXPANDED }) + } actions.replace(pending.key, { index: nextIndex, drafts: nextDrafts, @@ -436,12 +464,16 @@ function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: Questi aria-labelledby={`question-${pending.key}-${String(index)}`} >
-
- {question.header !== undefined &&
{question.header}
} -

- {question.question} -

-
+ {(question.header !== undefined || minimized) && ( +
+ {question.header !== undefined &&
{question.header}
} + {minimized && ( +

+ {question.question} +

+ )} +
+ )}
{countdown !== undefined && card.waitState !== 'waiting' && card.waitState !== 'editing' && card.waitState !== 'continued' && ( @@ -492,6 +524,27 @@ function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: Questi onFocusCapture={focusAnswerSurface} onBlurCapture={blurAnswerSurface} > +
+

+ {question.question} +

+ + + +
{question.detail !== undefined && (
)} @@ -499,40 +552,73 @@ function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: Questi {(question.options ?? []).map((option, optionIndex) => { const selected = draft.selected.includes(option.label) const display = parseRecommendedLabel(option.label) + const hasDescription = option.description !== undefined && option.description.trim() !== '' + const descriptionExpanded = expanded.has(optionIndex) + const descriptionToggleLabel = t(descriptionExpanded ? 'text.collapse' : 'text.expand') + const descriptionId = `${descriptionIdPrefix}-question-${String(index)}-option-${String(optionIndex)}-description` return ( - + + {hasDescription && ( + + + + )} +
) })} diff --git a/packages/client/ui-user-questions/src/client/locales.ts b/packages/client/ui-user-questions/src/client/locales.ts index 95fa1c3560..83ec122186 100644 --- a/packages/client/ui-user-questions/src/client/locales.ts +++ b/packages/client/ui-user-questions/src/client/locales.ts @@ -25,6 +25,8 @@ export const zh = { 'nav.maximize': '展开问题卡片', 'nav.cancel': '放弃整组问题', 'nav.close': '收起问题面板,可从工具调用重新打开', + 'text.expand': '展开', + 'text.collapse': '收起', 'option.recommended': '推荐', 'custom.placeholder': '输入你的答案', 'action.skip': '跳过', @@ -63,6 +65,8 @@ export const en = { 'nav.maximize': 'Expand the question card', 'nav.cancel': 'Dismiss all questions', 'nav.close': 'Close the panel — reopen it from the tool call', + 'text.expand': 'Expand', + 'text.collapse': 'Collapse', 'option.recommended': 'Recommended', 'custom.placeholder': 'Type your answer', 'action.skip': 'Skip', @@ -98,6 +102,8 @@ export const ja = { 'nav.maximize': '質問カードを展開', 'nav.cancel': 'すべての質問を閉じる', 'nav.close': 'パネルを閉じる — ツール呼び出しから再度開けます', + 'text.expand': '展開', + 'text.collapse': '折りたたむ', 'option.recommended': '推奨', 'custom.placeholder': '回答を入力', 'action.skip': 'スキップ', diff --git a/packages/client/ui-user-questions/tests/node-plugin.client.spec.ts b/packages/client/ui-user-questions/tests/node-plugin.client.spec.ts index 1ffb0a9caf..0ba71458f9 100644 --- a/packages/client/ui-user-questions/tests/node-plugin.client.spec.ts +++ b/packages/client/ui-user-questions/tests/node-plugin.client.spec.ts @@ -1,3 +1,4 @@ +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { Context } from '@deepseek-ai/cordis' import { afterEach, describe, expect, it } from 'vitest' import ToolRuntime from '@deepseek-ai/dsh-tools' @@ -17,6 +18,7 @@ describe('ui-user-questions node plugin', () => { ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(UserQuestionService) await ctx.plugin({ apply }).await() diff --git a/packages/client/ui-user-questions/tests/question-composer-styles.client.spec.ts b/packages/client/ui-user-questions/tests/question-composer-styles.client.spec.ts new file mode 100644 index 0000000000..7778302296 --- /dev/null +++ b/packages/client/ui-user-questions/tests/question-composer-styles.client.spec.ts @@ -0,0 +1,111 @@ +/** Checks CSS layout declarations that jsdom cannot measure. */ +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' + +const stylesheet = readFileSync( + fileURLToPath(new URL('../src/client/QuestionComposer.module.css', import.meta.url)), + 'utf8', +).replace(/\/\*[\s\S]*?\*\//g, ' ') + +const escape = (text: string): string => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + +function split(body: string | undefined): string[] { + return (body ?? '').split(';').map(part => part.trim()).filter(Boolean) +} + +function declarations(selector: string): string[] { + const rule = new RegExp(`(?:^|\\})\\s*${escape(selector)}\\s*\\{([^{}]*)\\}`).exec(stylesheet) + if (rule === null) throw new Error(`QuestionComposer.module.css has no \`${selector}\` rule`) + return split(rule[1]) +} + +function keyframes(name: string): string[] { + const rule = new RegExp(`@keyframes ${escape(name)}\\s*\\{\\s*from, to\\s*\\{([^{}]*)\\}`).exec(stylesheet) + if (rule === null) throw new Error(`QuestionComposer.module.css has no \`${name}\` keyframes`) + return split(rule[1]) +} + +describe('QuestionComposer long-copy layout', () => { + it('reads question overflow against three lines and keeps two once it overflows', () => { + expect(declarations('.questionTitle')).toEqual(expect.arrayContaining(['flex-shrink: 0', 'timeline-scope: --question-copy'])) + expect(declarations('.titlePreview')).toEqual(expect.arrayContaining([ + 'max-height: calc(3lh + 2px)', + 'margin: 0 0 -2px', + 'padding-bottom: 2px', + 'overflow: hidden', + 'scroll-timeline: --question-copy block', + 'animation-name: question-clipped', + 'animation-fill-mode: both', + 'animation-timeline: --question-copy', + ])) + expect(keyframes('question-clipped')).toEqual(expect.arrayContaining([ + 'margin-bottom: calc(-1lh - 2px)', + 'clip-path: inset(0 0 calc(1lh + 2px) 0)', + 'mask-size: 100% 1lh, 100% 1lh', + 'mask-position: 0 0, 0 1lh', + ])) + expect(declarations('.questionToggle')).toEqual(expect.arrayContaining([ + 'animation-name: copy-toggle-shown', + 'animation-timeline: --question-copy', + ])) + }) + + it('reads description overflow against two lines and keeps one once it overflows', () => { + expect(declarations('.optionRow')).toContain('timeline-scope: --description-copy') + expect(declarations('.description')).toEqual(expect.arrayContaining([ + 'display: block', + 'width: max-content', + 'max-width: 100%', + 'max-height: calc(2lh + 2px)', + 'margin-bottom: -2px', + 'padding-bottom: 2px', + 'overflow: hidden', + 'font-size: 13px', + 'line-height: 20px', + 'scroll-timeline: --description-copy block', + 'animation-name: description-clipped', + 'animation-fill-mode: both', + 'animation-timeline: --description-copy', + ])) + expect(keyframes('description-clipped')).toEqual(expect.arrayContaining([ + 'margin-bottom: calc(-1lh - 2px)', + 'clip-path: inset(0 0 calc(1lh + 2px) 0)', + 'mask-size: 100% 1lh', + ])) + expect(declarations('.descriptionToggle')).toEqual(expect.arrayContaining([ + 'animation-name: copy-toggle-shown', + 'animation-timeline: --description-copy', + ])) + }) + + it('shows a toggle only through the overflow timeline or an expansion', () => { + expect(declarations('.copyToggle')).toContain('visibility: hidden') + expect(keyframes('copy-toggle-shown')).toEqual(['visibility: visible']) + expect(declarations('.questionTitle[data-expanded] .titlePreview,\n.optionRow[data-expanded] .description')) + .toEqual(['max-height: none', 'animation: none']) + expect(declarations('.questionTitle[data-expanded] .copyToggle,\n.optionRow[data-expanded] .copyToggle')) + .toEqual(['visibility: visible', 'animation: none']) + }) + + it('names timelines through longhands that the bundled CSS keeps apart', () => { + // lightningcss merges `animation` with `animation-timeline` into a shorthand browsers reject. + const timed = [...stylesheet.matchAll(/\{([^{}]*animation-timeline[^{}]*)\}/g)].map(match => match[1] ?? '') + expect(timed).toHaveLength(4) + for (const body of timed) expect(split(body).some(part => part.startsWith('animation:'))).toBe(false) + }) + + it('shows all copy where scroll timelines are unsupported', () => { + const fallback = /@supports not \(animation-timeline: scroll\(\)\)\s*\{([\s\S]*?)\n\}/.exec(stylesheet)?.[1] ?? '' + expect(fallback).toMatch(/\.titlePreview,\s*\.description\s*\{\s*max-height: none;\s*animation: none;\s*\}/) + expect(fallback).toMatch(/\.copyToggle\s*\{\s*display: none;\s*\}/) + }) + + it('keeps the question and choices in the card scrollport', () => { + expect(declarations('.body')).toEqual(expect.arrayContaining([ + 'min-height: 0', + 'overflow-y: auto', + ])) + expect(declarations('.questionTitle')).toContain('flex-shrink: 0') + }) +}) diff --git a/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx b/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx index 9b4dd8e69d..4b46adcbd0 100644 --- a/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx +++ b/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx @@ -24,7 +24,13 @@ afterEach(cleanup) const SID = 's1' as SessionId const seatOver = (dict: Record, common: Record): QuestionComposerProps['t'] => - (key => dict[key] ?? common[key] ?? key) + (key, params) => { + const template = dict[key] ?? common[key] ?? key + return params === undefined + ? template + : template.replace(/\{(\w+)\}/g, (match, name: string) => + name in params ? String(params[name]) : match) + } type SessionState = Parameters[0]>[0] type ConversationState = Parameters[0]>[0] @@ -258,6 +264,7 @@ describe('QuestionComposer', () => { const detail = screen.getByText('按当前空缺岗位的优先级选择。') const scrollRegion = detail.closest('[data-question-scroll]') expect(scrollRegion).toBeTruthy() + expect(scrollRegion?.contains(screen.getByRole('heading', { name: '选择候选人类型' }))).toBe(true) expect(scrollRegion?.contains(screen.getByRole('radio', { name: /工程落地型/ }))).toBe(true) expect(scrollRegion?.contains(screen.getByText('下一题').closest('button'))).toBe(false) fireEvent.click(screen.getByRole('radio', { name: /工程落地型/ })) @@ -294,6 +301,104 @@ describe('QuestionComposer', () => { expect(screen.getByRole('button', { name: '正在提交…' }).disabled).toBe(true) }) + it('expands long copy in place without choosing an option', () => { + const description = 'Remove the experimental client from every shipped template and require an explicit profile install.' + const { carrier } = wait([{ + id: 'release', question: 'How should the release graph change?', + options: [ + { label: 'Explicit install', description }, + { label: 'Optional bundle' }, + { label: 'Empty description', description: '' }, + { label: 'Whitespace description', description: ' ' }, + ], + }]) + render() + + // CSS shows each toggle only while its copy overflows; every candidate is rendered. + const heading = screen.getByRole('heading', { name: 'How should the release graph change?' }) + const [questionToggle, descriptionToggle, ...rest] = screen.getAllByRole('button', { name: '展开' }) + expect(rest).toHaveLength(0) + if (questionToggle === undefined || descriptionToggle === undefined) throw new Error('Both copy toggles must render') + expect(questionToggle.getAttribute('aria-describedby')).toBe(heading.id) + expect(questionToggle.getAttribute('aria-controls')).toBe(heading.id) + const option = screen.getByRole('radio', { name: 'Explicit install' }) + const text = screen.getByText(description) + expect(option.getAttribute('aria-describedby')).toBe(text.id) + expect(descriptionToggle.getAttribute('aria-controls')).toBe(text.id) + expect(descriptionToggle.getAttribute('aria-describedby')).toBe(text.id) + expect(descriptionToggle.getAttribute('aria-expanded')).toBe('false') + + fireEvent.click(descriptionToggle) + expect(descriptionToggle.getAttribute('aria-expanded')).toBe('true') + expect(descriptionToggle.getAttribute('aria-label')).toBe('收起') + expect(text.closest('[data-option-row]')?.hasAttribute('data-expanded')).toBe(true) + expect(screen.getByText(description)).toBe(text) + expect(option.getAttribute('aria-checked')).toBe('false') + expect(questionToggle.getAttribute('aria-expanded')).toBe('false') + + fireEvent.click(questionToggle) + expect(questionToggle.getAttribute('aria-expanded')).toBe('true') + expect(questionToggle.getAttribute('aria-label')).toBe('收起') + expect(heading.parentElement?.hasAttribute('data-expanded')).toBe(true) + fireEvent.click(questionToggle) + fireEvent.click(descriptionToggle) + expect(heading.parentElement?.hasAttribute('data-expanded')).toBe(false) + expect(text.closest('[data-option-row]')?.hasAttribute('data-expanded')).toBe(false) + expect(screen.getAllByRole('button', { name: '展开' })).toEqual([questionToggle, descriptionToggle]) + }) + + it('resets scrolling and expanded copy when the question changes', () => { + const firstDescription = 'First question detail that was expanded.' + const secondDescription = 'Second question detail starts collapsed.' + const { carrier } = wait([ + { + id: 'first', question: 'First question?', + options: [{ label: 'Continue', description: firstDescription }], + }, + { + id: 'second', question: 'Second question?', + options: [{ label: 'Continue', description: secondDescription }], + }, + ]) + render() + + expect(screen.getByRole('region', { name: 'First question?' })).toBeTruthy() + const firstScroll = screen.getByText('First question?').closest('[data-question-scroll]') as HTMLElement + firstScroll.scrollTop = 80 + for (const toggle of screen.getAllByRole('button', { name: '展开' })) fireEvent.click(toggle) + expect(screen.getAllByRole('button', { name: '收起' }).map(toggle => toggle.getAttribute('aria-expanded'))).toEqual(['true', 'true']) + fireEvent.click(screen.getByRole('radio', { name: 'Continue' })) + + const secondScroll = screen.getByText('Second question?').closest('[data-question-scroll]') as HTMLElement + expect(screen.queryByRole('region', { name: 'First question?' })).toBeNull() + expect(screen.getByRole('region', { name: 'Second question?' })).toBeTruthy() + expect(secondScroll).toBe(firstScroll) + expect(secondScroll.scrollTop).toBe(0) + expect(screen.queryByRole('button', { name: '收起' })).toBeNull() + expect(secondScroll.querySelector('[data-expanded]')).toBeNull() + expect(screen.queryByText(firstDescription)).toBeNull() + const descriptionToggle = screen.getAllByRole('button', { name: '展开' })[1] + expect(document.getElementById(descriptionToggle?.getAttribute('aria-controls') ?? '')?.textContent).toBe(secondDescription) + }) + + it('keeps expansions with their question when the shared position moves elsewhere', () => { + const { carrier } = wait([ + { id: 'first', question: 'First question?', options: [{ label: 'Continue', description: 'First detail.' }] }, + { id: 'second', question: 'Second question?', options: [{ label: 'Continue', description: 'Second detail.' }] }, + ]) + render() + fireEvent.click(screen.getAllByRole('button', { name: '展开' })[1] as HTMLElement) + expect(screen.getByRole('button', { name: '收起' })).toBeTruthy() + + // Another surface of the same Session moves the shared question position. + const blank = { selected: [], custom: '', skipped: false } + act(() => { kit.actions.replace(carrier.key, { index: 1, drafts: [blank, blank] }) }) + + expect(screen.getByRole('region', { name: 'Second question?' })).toBeTruthy() + expect(screen.queryByRole('button', { name: '收起' })).toBeNull() + expect(document.querySelector('[data-expanded]')).toBeNull() + }) + it('renders plan detail through the shared assistant Markdown primitive', () => { const { carrier } = wait([{ id: 'plan', @@ -864,6 +969,8 @@ describe('review card', () => { const chosen = screen.getByRole('radio', { name: '工程落地型' }) expect(chosen.getAttribute('aria-checked')).toBe('true') expect(chosen.disabled).toBe(true) + // Long copy stays readable on a frozen card. + expect(screen.getAllByRole('button', { name: '展开' }).map(toggle => toggle.disabled)).toEqual([false, false, false]) // Nothing is left to send, and the unused free-text field would read as // somewhere to type. expect(screen.queryByRole('button', { name: '跳过' })).toBeNull() @@ -938,3 +1045,25 @@ describe('review card', () => { expect(screen.getByText(en['review.skipped'])).toBeTruthy() }) }) + +it('retains custom-answer focus across Enter navigation between option questions', () => { + const { carrier } = wait([ + { id: 'one', question: 'First?', options: [{ label: 'One', description: 'First description' }] }, + { id: 'two', question: 'Second?', options: [{ label: 'Two' }] }, + ]) + render() + const choice = screen.getByRole('radio', { name: 'One' }) + expect(choice.getAttribute('aria-describedby')).toBeTruthy() + expect(document.getElementById(choice.getAttribute('aria-describedby') as string)?.textContent).toBe('First description') + const first = screen.getByRole('textbox') + act(() => { first.focus() }) + fireEvent.change(first, { target: { value: 'first answer' } }) + fireEvent.keyDown(first, { key: 'Enter' }) + expect(screen.getByRole('heading', { name: 'Second?' })).toBeTruthy() + const second = screen.getByRole('textbox') + expect(second).toBe(first) + expect(document.activeElement).toBe(second) + fireEvent.change(second, { target: { value: 'second answer' } }) + expect((second as HTMLTextAreaElement).value).toBe('second answer') + expect(screen.getByRole('radio', { name: 'Two' }).getAttribute('aria-describedby')).toBeNull() +}) diff --git a/packages/client/ui-workspace/README.i18n.yaml b/packages/client/ui-workspace/README.i18n.yaml index c512f2ef5d..01c4594cba 100644 --- a/packages/client/ui-workspace/README.i18n.yaml +++ b/packages/client/ui-workspace/README.i18n.yaml @@ -33,8 +33,8 @@ en: 9fb88260380bf093 zh: 3bace2b96ecd4a82 /deepseek-ai-dsh-client-ui-workspace/use-this-package/active-schedule-markers: - en: be2c2bc1d846e9d4 - zh: 5a5e0f4c2c43b588 + en: a1a53a1ee77ea2da + zh: 8c7bfb7d64a2a5ba /deepseek-ai-dsh-client-ui-workspace/understand-the-implementation: en: 96a37e74555a36b7 zh: d03941523509a5cf diff --git a/packages/client/ui-workspace/README.md b/packages/client/ui-workspace/README.md index 792f2ffbb4..9b7a3e5e06 100644 --- a/packages/client/ui-workspace/README.md +++ b/packages/client/ui-workspace/README.md @@ -83,7 +83,7 @@ Once both Workspace and Session startup baselines are ready, an empty installati The first-use directory name and its stored title are fixed, so neither follows the reader's language: `workspaces.initializeDefault` carries no names, and one installation keeps one on-disk path and one stored title across language switches. A Workspace still carrying that automatic title is labeled with the localized default name wherever this package shows it — sidebar rows and their hover card, search result meta, the hero picker menu, and the rename and delete dialogs — through the controller's `workspaceDisplayTitle`. The rename dialog is seeded with the label on screen while the stored title decides whether confirming saves, so confirming the untouched prefill on such a Workspace pins that name and the row stops following the language; the duplicate-title check excludes the target by Workspace identity rather than by title. -The package injects the Client `remoteAccess` service and provides the root standard Hook `useWorkspaceAccess(workspaceId, action)`, which answers whether the current Peer may call `action` on one Workspace resource; its snapshot semantics belong to [`dsh-api-remote-access`](../../api/remote-access/README.md#client-face). +The package injects the Client `remoteAccess` service and provides the root standard Hook `useWorkspaceAccess(workspaceId, action)`, which answers whether the current Peer may call `action` on one Workspace resource, and `ctx.uiWorkspace.whenAccess(workspaceId, action, callback)`, which keeps the callback's effects installed while that answer is true, with the semantics of `ctx.remoteAccess.whenAccess`; snapshot semantics belong to [`dsh-api-remote-access`](../../api/remote-access/README.md#client-face). ## Understand the implementation diff --git a/packages/client/ui-workspace/README.zh.md b/packages/client/ui-workspace/README.zh.md index 8d983780c0..a74f3f7d1b 100644 --- a/packages/client/ui-workspace/README.zh.md +++ b/packages/client/ui-workspace/README.zh.md @@ -83,7 +83,7 @@ Workspace 和 Session 的启动基线均就绪后,空安装环境调用 `works 首次使用的目录名与其存储标题都是固定的,都不跟随读者语言:`workspaces.initializeDefault` 不携带任何名称,同一安装环境在切换语言后仍只有一个磁盘路径和一个存储标题。仍保留该自动标题的工作区,在本包展示它的所有位置——侧栏行及其悬浮卡片、搜索结果元信息、hero 选择器菜单、重命名与删除对话框——都通过控制器的 `workspaceDisplayTitle` 显示为本地化的默认名称。重命名对话框以屏幕上的标签作为初值,而判断确认是否需要保存用的是存储标题,因此对这类工作区直接确认未改动的初值即可固定该名称,该行不再跟随语言;重名检查按工作区身份而非标题排除目标自身。 -本包注入 Client `remoteAccess` 服务,并提供根作用域标准 Hook `useWorkspaceAccess(workspaceId, action)`,回答当前 Peer 能否在某个 Workspace 资源上调用 `action`;其快照语义归 [`dsh-api-remote-access`](../../api/remote-access/README.zh.md#client-face)。 +本包注入 Client `remoteAccess` 服务,并提供根作用域标准 Hook `useWorkspaceAccess(workspaceId, action)`,回答当前 Peer 能否在某个 Workspace 资源上调用 `action`,以及 `ctx.uiWorkspace.whenAccess(workspaceId, action, callback)`,在该答案为 true 期间保持回调的效果,语义同 `ctx.remoteAccess.whenAccess`;快照语义归 [`dsh-api-remote-access`](../../api/remote-access/README.zh.md#client-face)。 ## 理解实现 diff --git a/packages/client/ui-workspace/src/client/navigation.ts b/packages/client/ui-workspace/src/client/navigation.ts index dbb35b55a4..6b4221ae19 100644 --- a/packages/client/ui-workspace/src/client/navigation.ts +++ b/packages/client/ui-workspace/src/client/navigation.ts @@ -1,6 +1,7 @@ /** Workspace archive and directory UI capability. */ import { Service, type Context } from '@deepseek-ai/cordis' +import type { AccessEffect, RemoteEndpoint } from '@deepseek-ai/dsh-api-remote-access/client' import type { ClientRemote, DirectoryListing, RemoteFailure } from '@deepseek-ai/dsh-api-remotes/client' import type { ISessions, @@ -130,6 +131,17 @@ export interface UiWorkspace { * @returns created absolute path. */ createDirectory(path: string, name: string): Promise + /** + * Keep `callback`'s effects installed while the current Peer may call + * `action` on one Workspace resource, with the semantics of + * `ctx.remoteAccess.whenAccess`: `useWorkspaceAccess` answers the same + * question during render. + * @param workspaceId - the Workspace resource the guarded effects depend on. + * @param action - endpoint action the guarded effects depend on. + * @param callback - creates one disposer or an iterable of disposers. + * @returns idempotent disposer owned by the caller's Cordis fiber. + */ + whenAccess(workspaceId: WorkspaceId, action: RemoteEndpoint, callback: () => AccessEffect): () => void } declare module '@deepseek-ai/cordis' { @@ -407,6 +419,14 @@ class UiWorkspaceService extends Service implements UiWorkspace { return result.value } + whenAccess(workspaceId: WorkspaceId, action: RemoteEndpoint, callback: () => AccessEffect): () => void { + // `ctx.get` traces the service to the caller's Context, which then owns the controller. + const access = this.ctx.get('remoteAccess') + /* v8 ignore next -- this plugin injects remoteAccess, so the service is active while uiWorkspace is reachable. */ + if (access === undefined) throw new Error('ui-workspace: whenAccess requires the remoteAccess service') + return access.whenResourceAccess('workspace', workspaceId, action, callback) + } + private watchNavigation(): () => void { let initial: 'waiting' | 'connecting' | 'done' = 'waiting' const reconcile = (): void => { diff --git a/packages/client/ui-workspace/tests/apply.client.spec.ts b/packages/client/ui-workspace/tests/apply.client.spec.ts index eade6885ff..bf5acfea6b 100644 --- a/packages/client/ui-workspace/tests/apply.client.spec.ts +++ b/packages/client/ui-workspace/tests/apply.client.spec.ts @@ -10,7 +10,7 @@ import type { SessionId } from '@deepseek-ai/dsh-session/types' import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' import type { HostObservable, PropsRenderSlots, StoredEntry } from '@deepseek-ai/dsh-client-ui-slots' import type { ShortcutCommand } from '@deepseek-ai/dsh-client-shortcuts/client' -import { RemoteError, TestRemote } from '@deepseek-ai/dsh-client-test-runtime' +import { RemoteError, StaticRemoteAccess, TestRemote } from '@deepseek-ai/dsh-client-test-runtime' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import { apply, inject } from '@deepseek-ai/dsh-client-ui-workspace/client' import type { WorkspaceBrowserInjected, WorkspacePickerInjected } from '@deepseek-ai/dsh-client-ui-workspace/client' @@ -56,7 +56,7 @@ const workspaceState = ( const ALLOWED: HostObservable = createSnapshotStore(true) -async function bench() { +async function bench(access: 'fake' | 'static' = 'fake') { const ctx = new Context() const commands = new Map() ctx.provide('shortcuts', { @@ -69,7 +69,10 @@ async function bench() { ctx.provide('uiConversation', {}) await ctx.plugin(SlotRegistry).await() const accessSource = vi.fn((_kind: string, _id: string, _action: string): HostObservable => ALLOWED) - ctx.provide('remoteAccess', { source: accessSource }) + const stopGuard = vi.fn() + const whenResourceAccess = vi.fn((): () => void => stopGuard) + if (access === 'static') new StaticRemoteAccess(ctx) + else ctx.provide('remoteAccess', { source: accessSource, whenAccess: vi.fn(() => () => {}), whenResourceAccess }) const create = vi.fn(async (input: { name: string } | { path: string }) => ({ workspaceId: 'ws-new' as never, path: 'name' in input ? `/projects/${input.name}` : input.path, @@ -162,7 +165,7 @@ async function bench() { locale.setLocale('zh') ctx.provide('locale', locale) return { - ctx, slots: ctx.get('slots') as SlotRegistry, locale, create, rename, commands, accessSource, + ctx, slots: ctx.get('slots') as SlotRegistry, locale, create, rename, commands, accessSource, whenResourceAccess, stopGuard, retain, using, selectPanel, search, renameSession, binding, fork, pickDirectory, pinSession, unpinSession, workspacesSubscribe, initializeDefault, history, scope, sessionOf, setWorkspaces: (snapshot: WorkspaceSnapshot): void => { workspaceSnapshot = snapshot }, @@ -233,6 +236,25 @@ describe('ui-workspace apply', () => { expect(keyed?.('w1', 'workspace/follow')).toBe(ALLOWED) expect(b.accessSource).toHaveBeenCalledWith('workspace', 'w1', 'workspace/follow') expect(keyed?.('w1')).toBeUndefined() + // `whenAccess` follows the same Workspace resource through the service's resource form. + const guarded = vi.fn(() => () => {}) + expect(b.ctx.uiWorkspace.whenAccess('w1' as WorkspaceId, 'workspace/follow', guarded)).toBe(b.stopGuard) + expect(b.whenResourceAccess).toHaveBeenCalledWith('workspace', 'w1', 'workspace/follow', guarded) + }) + + it('hangs Workspace-guarded effects on the calling plugin, so they leave when it unloads', async () => { + const b = await bench('static') + onTestFinished(() => b.ctx.fiber.dispose()) + await b.ctx.plugin({ inject: [...inject], apply }).await() + const dispose = vi.fn() + const caller = b.ctx.plugin({ + inject: ['uiWorkspace'], + apply: (callerCtx: Context) => { callerCtx.uiWorkspace.whenAccess('w1' as WorkspaceId, 'workspace/follow', () => dispose) }, + }) + await caller.await() + expect(dispose).not.toHaveBeenCalled() + await caller.dispose() + expect(dispose).toHaveBeenCalledOnce() }) it('reports a default Workspace creation failure through the shared notice overlay', async () => { diff --git a/packages/compaction/compaction-basic/package.json b/packages/compaction/compaction-basic/package.json index 97a87bf015..88b07cd3e9 100644 --- a/packages/compaction/compaction-basic/package.json +++ b/packages/compaction/compaction-basic/package.json @@ -62,6 +62,7 @@ "@deepseek-ai/dsh-session": "workspace:*", "@deepseek-ai/dsh-session-projection": "workspace:*", "@deepseek-ai/dsh-token-meter": "workspace:*", + "@deepseek-ai/dsh-tool-testkit": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", "@deepseek-ai/dsh-compaction-image-offload": "workspace:*" } diff --git a/packages/compaction/compaction-basic/tests/compaction-loop-repro.spec.ts b/packages/compaction/compaction-basic/tests/compaction-loop-repro.spec.ts index 012fc20bd4..2777a6e29b 100644 --- a/packages/compaction/compaction-basic/tests/compaction-loop-repro.spec.ts +++ b/packages/compaction/compaction-basic/tests/compaction-loop-repro.spec.ts @@ -4,7 +4,7 @@ import { toolPairingBalancedAfter, toolPairingBalancedBefore } from '@deepseek-a import { createUserMessage, createSystemMessage, CONTEXT_WINDOW_EXCEEDED_CODE, LlmError, resolveRetryPolicy , createMessage } from '@deepseek-ai/dsh-llm' import type { ContentBlock, GenerateOptions, LlmResolvedModelInfo, ResolvedRetryPolicy, StreamChunk } from '@deepseek-ai/dsh-llm' import { ToolCallId, LlmAdapter } from '@deepseek-ai/dsh-llm' -import { defineContentToolFixture } from '@deepseek-ai/dsh-tools' +import { defineContentToolFixture } from '@deepseek-ai/dsh-tool-testkit' import { type Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' diff --git a/packages/compaction/compaction-basic/tests/runtime-context-admission.spec.ts b/packages/compaction/compaction-basic/tests/runtime-context-admission.spec.ts index 54d5603bfc..2c3bdbb95f 100644 --- a/packages/compaction/compaction-basic/tests/runtime-context-admission.spec.ts +++ b/packages/compaction/compaction-basic/tests/runtime-context-admission.spec.ts @@ -8,7 +8,7 @@ import type { GenerateOptions, LlmResolvedModelInfo, StreamChunk } from '@deepse import { SessionId } from '@deepseek-ai/dsh-session' import { mountAgentLoopTestDependencies, mountAgentLoopTestHarness } from '@deepseek-ai/dsh-agent-loop-testkit' import TokenMeter from '@deepseek-ai/dsh-token-meter' -import { defineContentToolFixture } from '@deepseek-ai/dsh-tools' +import { defineContentToolFixture } from '@deepseek-ai/dsh-tool-testkit' import BasicCompaction from '@deepseek-ai/dsh-compaction-basic' import { expect, it } from 'vitest' diff --git a/packages/context/agent-instructions/package.json b/packages/context/agent-instructions/package.json index 72331f71e6..26cc233da0 100644 --- a/packages/context/agent-instructions/package.json +++ b/packages/context/agent-instructions/package.json @@ -57,7 +57,9 @@ "@deepseek-ai/dsh-session": "workspace:*", "@deepseek-ai/dsh-session-projection": "workspace:*", "@deepseek-ai/dsh-system-prompt": "workspace:*", + "@deepseek-ai/dsh-tool-executor-native": "workspace:*", "@deepseek-ai/dsh-tool-fs": "workspace:*", + "@deepseek-ai/dsh-tool-testkit": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", "@deepseek-ai/dsh-working-directory": "workspace:*" } diff --git a/packages/context/agent-instructions/tests/agent-instructions.spec.ts b/packages/context/agent-instructions/tests/agent-instructions.spec.ts index fe3b899de7..84012eb9c1 100644 --- a/packages/context/agent-instructions/tests/agent-instructions.spec.ts +++ b/packages/context/agent-instructions/tests/agent-instructions.spec.ts @@ -1,3 +1,5 @@ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { chmod, mkdtemp, mkdir, rm, stat, symlink, utimes, writeFile } from 'node:fs/promises' import { provideWorkingDirectoryFixture } from '@deepseek-ai/dsh-agent-loop-testkit' import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path' @@ -26,7 +28,8 @@ import type { } from '@deepseek-ai/dsh-fs' import LocalFileSystem from '@deepseek-ai/dsh-fs-local' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRuntime, { defineContentToolFixture } from '@deepseek-ai/dsh-tools' +import ToolRuntime from '@deepseek-ai/dsh-tools' +import { defineContentToolFixture } from '@deepseek-ai/dsh-tool-testkit' import type { ToolExecution, ToolExecutionToken, @@ -294,6 +297,7 @@ async function mountAgentInstructions(ctx: Context, config: AgentInstructionsCon async function mountFileToolsAndAgentInstructions(ctx: Context, config: AgentInstructionsConfig): Promise>> { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(LocalFileSystem, { cwd: '/' }) await ctx.plugin(ToolFs) return mountAgentInstructionsPlugin(ctx, config) @@ -3234,7 +3238,7 @@ describe('workspace context request injection', () => { expect(baselineEvents(agent)).toHaveLength(1) await write(join(root, 'AGENTS.md'), 'updated repo rule') - await ctx.tools.execute({ + await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('read-after-remount'), name: 'read', @@ -3450,7 +3454,7 @@ describe('workspace context request injection', () => { await composeBaselinePrefix(ctx, agent) await write(join(root, 'AGENTS.md'), 'new root rule with more detail') - await ctx.tools.execute({ + await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('read-after-baseline-change'), name: 'read', arguments: { file_path: 'file.txt' }, agent, }) @@ -3482,7 +3486,7 @@ describe('workspace context request injection', () => { await composeBaselinePrefix(ctx, agent) await rm(join(root, 'AGENTS.md')) - await ctx.tools.execute({ + await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('read-after-baseline-remove'), name: 'read', arguments: { file_path: 'file.txt' }, agent, }) @@ -4280,6 +4284,7 @@ describe('dynamic nested workspace context injection', () => { await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(AgentRegistry) await ctx.plugin(LocalFileSystem, { cwd: '/' }) await ctx.plugin(ToolFs) @@ -4406,7 +4411,7 @@ describe('dynamic nested workspace context injection', () => { await mountFileToolsAndAgentInstructions(ctx, { maxBytes: 65536 }) const agent = await stubAgent(root) - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('read-nested'), name: 'read', @@ -4490,7 +4495,7 @@ describe('dynamic nested workspace context injection', () => { }) const agent = await stubAgent(root) - await ctx.tools.execute({ + await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('read-configured-nested-candidate'), name: 'read', @@ -4526,7 +4531,7 @@ describe('dynamic nested workspace context injection', () => { await mountFileToolsAndAgentInstructions(ctx, { maxBytes: 65536 }) const agent = await stubAgent(root) - await ctx.tools.execute({ + await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('read-nested-overlay'), name: 'read', @@ -4571,7 +4576,7 @@ describe('dynamic nested workspace context injection', () => { }) const agent = await stubAgent(root) - await ctx.tools.execute({ + await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('read-nested-overlay-disabled'), name: 'read', @@ -4602,7 +4607,7 @@ describe('dynamic nested workspace context injection', () => { await mountFileToolsAndAgentInstructions(ctx, { maxBytes: 65536 }) const agent = await stubAgent(root) - const first = await ctx.tools.execute({ + const first = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('read-nested-1'), name: 'read', @@ -4610,7 +4615,7 @@ describe('dynamic nested workspace context injection', () => { agent, }) await appendAdditionalContexts(ctx, agent) - const second = await ctx.tools.execute({ + const second = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('read-nested-2'), name: 'read', @@ -4636,6 +4641,7 @@ describe('dynamic nested workspace context injection', () => { try { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(RecordingFileSystem) const fs = ctx.fs as RecordingFileSystem const instructionPath = join(root, 'pkg/AGENTS.md') @@ -4648,12 +4654,12 @@ describe('dynamic nested workspace context injection', () => { await mountAgentInstructionsPlugin(ctx, { maxBytes: 65536 }) const agent = await stubAgent(root) - const first = await ctx.tools.execute({ + const first = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('read-before-version-fast-path'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, }) await appendAdditionalContexts(ctx, agent) - const second = await ctx.tools.execute({ + const second = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('read-with-version-fast-path'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, }) @@ -4677,6 +4683,7 @@ describe('dynamic nested workspace context injection', () => { try { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(RecordingFileSystem) const fs = ctx.fs as RecordingFileSystem const instructionPath = join(root, 'pkg/AGENTS.md') @@ -4688,18 +4695,18 @@ describe('dynamic nested workspace context injection', () => { await mountAgentInstructionsPlugin(ctx, { maxBytes: 65536 }) const agent = await stubAgent(root) - await ctx.tools.execute({ + await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('read-before-same-digest-version-change'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, }) await appendAdditionalContexts(ctx, agent) fs.entries.set(instructionPath, { type: 'file', content: 'same package rule', version: FsVersion('revision-2') }) - const afterVersionChange = await ctx.tools.execute({ + const afterVersionChange = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('read-after-same-digest-version-change'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, }) await syncAgentInstructions(ctx, agent) - const afterRefresh = await ctx.tools.execute({ + const afterRefresh = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('read-after-version-cache-refresh'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, }) @@ -4725,6 +4732,7 @@ describe('dynamic nested workspace context injection', () => { try { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(RecordingFileSystem) const fs = ctx.fs as RecordingFileSystem const instructionPath = join(root, 'pkg/AGENTS.md') @@ -4737,11 +4745,11 @@ describe('dynamic nested workspace context injection', () => { const firstAgent = await stubAgent(root) const secondAgent = await stubAgent(root) - const first = await ctx.tools.execute({ + const first = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('read-from-first-session'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent: firstAgent, }) - const second = await ctx.tools.execute({ + const second = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('read-from-second-session'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent: secondAgent, }) @@ -4772,13 +4780,13 @@ describe('dynamic nested workspace context injection', () => { await mountFileToolsAndAgentInstructions(ctx, { maxBytes: 65536 }) const agent = await stubAgent(root) - await ctx.tools.execute({ + await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('read-before-change'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, }) await appendAdditionalContexts(ctx, agent) await write(join(root, 'pkg/AGENTS.md'), 'new package rule with more detail') - await ctx.tools.execute({ + await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('read-after-change'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, }) @@ -4818,7 +4826,7 @@ describe('dynamic nested workspace context injection', () => { await mountFileToolsAndAgentInstructions(ctx, { maxBytes: 65536 }) const agent = await stubAgent(root) - await ctx.tools.execute({ + await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('read-both-siblings'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, }) @@ -4827,7 +4835,7 @@ describe('dynamic nested workspace context injection', () => { expect(firstText).toContain('sibling package rule') await appendAdditionalContexts(ctx, agent) await rm(join(root, 'pkg/AGENTS.md')) - await ctx.tools.execute({ + await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('read-after-one-sibling-removed'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, }) @@ -4859,7 +4867,7 @@ describe('dynamic nested workspace context injection', () => { await mountFileToolsAndAgentInstructions(ctx, { maxBytes: 65536 }) const agent = await stubAgent(root) - await ctx.tools.execute({ + await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('read-nested-dup-siblings'), name: 'read', arguments: { file_path: join('pkg', 'deep', 'file.txt') }, agent, }) @@ -5097,7 +5105,7 @@ describe('dynamic nested workspace context injection', () => { await mountFileToolsAndAgentInstructions(ctx, { maxBytes: 65536 }) const agent = await stubAgent(root) - await ctx.tools.execute({ + await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('read-before-dup-convergence'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, }) @@ -5106,7 +5114,7 @@ describe('dynamic nested workspace context injection', () => { expect(firstText).toContain('initial divergent nested rule') await appendAdditionalContexts(ctx, agent) await write(join(root, 'pkg/CLAUDE.md'), 'canonical nested rule') - await ctx.tools.execute({ + await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('read-after-dup-convergence'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, }) @@ -5137,14 +5145,14 @@ describe('dynamic nested workspace context injection', () => { await mountFileToolsAndAgentInstructions(ctx, { maxBytes: 65536 }) const agent = await stubAgent(root) - await ctx.tools.execute({ + await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('read-before-earlier-converges'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, }) await appendAdditionalContexts(ctx, agent) // Only the earlier candidate changes; the sibling stays byte-identical but now duplicates it. await write(join(root, 'pkg/AGENTS.md'), 'secondary nested rule') - await ctx.tools.execute({ + await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('read-after-earlier-converges'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, }) @@ -5178,13 +5186,13 @@ describe('dynamic nested workspace context injection', () => { await mountFileToolsAndAgentInstructions(ctx, { maxBytes: 65536 }) const agent = await stubAgent(root) - await ctx.tools.execute({ + await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('read-before-remove'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, }) await appendAdditionalContexts(ctx, agent) await rm(join(root, 'pkg/AGENTS.md')) - await ctx.tools.execute({ + await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('read-after-remove'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, }) @@ -5221,7 +5229,7 @@ describe('dynamic nested workspace context injection', () => { await mountFileToolsAndAgentInstructions(ctx, { maxBytes: 65536 }) const agent = await stubAgent(root) - await ctx.tools.execute({ + await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('read-before-symlink-dir'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, }) @@ -5235,7 +5243,7 @@ describe('dynamic nested workspace context injection', () => { await rm(join(root, 'pkg/AGENTS.md')) await mkdir(join(root, 'pkg/elsewhere'), { recursive: true }) await symlink(join(root, 'pkg/elsewhere'), join(root, 'pkg/AGENTS.md')) - await ctx.tools.execute({ + await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('read-after-symlink-dir'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, }) @@ -5264,20 +5272,20 @@ describe('dynamic nested workspace context injection', () => { await mountFileToolsAndAgentInstructions(ctx, { maxBytes: 65536 }) const agent = await stubAgent(root) - await ctx.tools.execute({ + await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('read-before-tombstone'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, }) await appendAdditionalContexts(ctx, agent) await rm(join(root, 'pkg/AGENTS.md')) - await ctx.tools.execute({ + await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('read-to-create-tombstone'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, }) await appendAdditionalContexts(ctx, agent) await write(join(root, 'pkg/AGENTS.md'), 'restored package rule') - await ctx.tools.execute({ + await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('read-after-tombstone'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, }) @@ -5302,6 +5310,7 @@ describe('dynamic nested workspace context injection', () => { try { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(RecordingFileSystem) const fs = ctx.fs as RecordingFileSystem fs.entries.set(join(root, '.git'), { type: 'directory' }) @@ -5312,13 +5321,13 @@ describe('dynamic nested workspace context injection', () => { await mountAgentInstructionsPlugin(ctx, { maxBytes: 65536 }) const agent = await stubAgent(root) - const first = await ctx.tools.execute({ + const first = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('read-before-provider-failure'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, }) await appendAdditionalContexts(ctx, agent) fs.throwOnStat.add(join(root, 'pkg/AGENTS.md')) - const duringFailure = await ctx.tools.execute({ + const duringFailure = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('read-during-provider-failure'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, }) @@ -5345,7 +5354,7 @@ describe('dynamic nested workspace context injection', () => { pinHarnessHome(home) await mountFileToolsAndAgentInstructions(ctx, { maxBytes: 65536 }) const agent = await stubAgent(root) - const first = await ctx.tools.execute({ + const first = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('read-before-resume'), name: 'read', @@ -5355,7 +5364,7 @@ describe('dynamic nested workspace context injection', () => { await appendAdditionalContexts(ctx, agent) const resumed = await stubAgent(root, agent.session.snapshotEvents()) - const afterResume = await ctx.tools.execute({ + const afterResume = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('read-after-resume'), name: 'read', @@ -5384,7 +5393,7 @@ describe('dynamic nested workspace context injection', () => { pinHarnessHome(home) await mountFileToolsAndAgentInstructions(ctx, { maxBytes: 65536 }) const original = await stubAgent(root) - await ctx.tools.execute({ + await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('read-before-offline-change'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent: original, }) @@ -5418,7 +5427,7 @@ describe('dynamic nested workspace context injection', () => { pinHarnessHome(home) await mountFileToolsAndAgentInstructions(ctx, { maxBytes: 65536 }) const agent = await stubAgent(root) - const first = await ctx.tools.execute({ + const first = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('read-before-compact'), name: 'read', @@ -5426,7 +5435,7 @@ describe('dynamic nested workspace context injection', () => { agent, }) const contextSeq = (await appendAdditionalContexts(ctx, agent))! - const visibleBeforeCompact = await ctx.tools.execute({ + const visibleBeforeCompact = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('read-while-visible'), name: 'read', @@ -5442,7 +5451,7 @@ describe('dynamic nested workspace context injection', () => { sourceEventSeqs: [SessionSeq(contextSeq)], }) - const afterCompact = await ctx.tools.execute({ + const afterCompact = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('read-after-compact'), name: 'read', @@ -5477,7 +5486,7 @@ describe('dynamic nested workspace context injection', () => { const baseline = baselineEvents(agent)[0] expect(baseline).toBeDefined() - const whileVisible = await ctx.tools.execute({ + const whileVisible = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('read-visible-baseline'), name: 'read', @@ -5492,7 +5501,7 @@ describe('dynamic nested workspace context injection', () => { sourceEventSeqs: [baseline!.seq], }) - await ctx.tools.execute({ + await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('read-compacted-baseline'), name: 'read', @@ -5501,7 +5510,7 @@ describe('dynamic nested workspace context injection', () => { }) const rearmedContext = (await syncedAgentInstructions(ctx, agent)) await appendAdditionalContexts(ctx, agent) - const afterRearm = await ctx.tools.execute({ + const afterRearm = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('read-rearmed-baseline'), name: 'read', @@ -5536,7 +5545,7 @@ describe('dynamic nested workspace context injection', () => { pinHarnessHome(home) await mountFileToolsAndAgentInstructions(ctx, { maxBytes: 65536 }) const agent = await stubAgent(root) - await ctx.tools.execute({ + await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('read-package'), name: 'read', @@ -5546,7 +5555,7 @@ describe('dynamic nested workspace context injection', () => { const firstText = blocksText(((await syncedAgentInstructions(ctx, agent))).content) await appendAdditionalContexts(ctx, agent) - await ctx.tools.execute({ + await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('read-subtree'), name: 'read', @@ -5577,7 +5586,7 @@ describe('dynamic nested workspace context injection', () => { pinHarnessHome(home) await mountFileToolsAndAgentInstructions(ctx, { maxBytes: 700 }) const agent = await stubAgent(root) - await ctx.tools.execute({ + await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('read-subtree-omitting-parent'), name: 'read', @@ -5587,7 +5596,7 @@ describe('dynamic nested workspace context injection', () => { const firstText = blocksText(((await syncedAgentInstructions(ctx, agent))).content) await appendAdditionalContexts(ctx, agent) - await ctx.tools.execute({ + await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('read-parent-after-omit'), name: 'read', @@ -5642,7 +5651,7 @@ describe('dynamic nested workspace context injection', () => { source: { kind: 'other' }, }), { surfaceOp: 'append' }) - await ctx.tools.execute({ + await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('read-after-spoofed-state'), name: 'read', @@ -5672,14 +5681,14 @@ describe('dynamic nested workspace context injection', () => { await mountFileToolsAndAgentInstructions(ctx, { maxBytes: 65536 }) const agent = await stubAgent(root) - const rootResult = await ctx.tools.execute({ + const rootResult = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('read-root-file'), name: 'read', arguments: { file_path: 'root.txt' }, agent, }) - await ctx.tools.execute({ + await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('read-absolute-nested-file'), name: 'read', @@ -5707,6 +5716,7 @@ describe('dynamic nested workspace context injection', () => { const nested = join(root, 'pkg/AGENTS.md') await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(RecordingFileSystem) const fs = ctx.fs as RecordingFileSystem fs.entries.set(join(root, '.git'), { type: 'directory' }) @@ -5718,7 +5728,7 @@ describe('dynamic nested workspace context injection', () => { await mountAgentInstructionsPlugin(ctx, { maxBytes: 65536 }) const agent = await stubAgent(root) - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('read-with-unreadable-nested-instruction'), name: 'read', @@ -5766,7 +5776,7 @@ describe('dynamic nested workspace context injection', () => { })], })) - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('read-with-downstream'), name: 'read', @@ -5822,7 +5832,7 @@ describe('dynamic nested workspace context injection', () => { feedback: [{ type: 'text' as const, text: 'blocked downstream' }], })) - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('read-blocked-downstream'), name: 'read', @@ -5855,6 +5865,7 @@ describe('dynamic nested workspace context injection', () => { await write(join(root, 'pkg/deep/file.txt'), 'hello') await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(LocalFileSystem, { cwd: '/' }) await ctx.plugin(ToolFs) let shouldBlock = true @@ -5868,7 +5879,7 @@ describe('dynamic nested workspace context injection', () => { await mountAgentInstructionsPlugin(ctx, { maxBytes: 65536 }) const agent = await stubAgent(root) - const blocked = await ctx.tools.execute({ + const blocked = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('outer-block-first'), name: 'read', @@ -5879,7 +5890,7 @@ describe('dynamic nested workspace context injection', () => { expect(agent.inbox.nextStep).toEqual([]) shouldBlock = false - const accepted = await ctx.tools.execute({ + const accepted = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('outer-block-retry'), name: 'read', @@ -5910,6 +5921,7 @@ describe('dynamic nested workspace context injection', () => { await write(join(root, 'pkg/deep/file.txt'), 'hello') await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(LocalFileSystem, { cwd: '/' }) await ctx.plugin(ToolFs) ctx.tools.register(defineContentToolFixture({ @@ -5917,7 +5929,7 @@ describe('dynamic nested workspace context injection', () => { description: 'read through a nested dispatch', parameters: {}, async execute(_args, exec) { - const nested = await ctx.tools.execute({ + const nested = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId(`${exec.callId}:nested`), name: 'read', @@ -5940,7 +5952,7 @@ describe('dynamic nested workspace context injection', () => { await mountAgentInstructionsPlugin(ctx, { maxBytes: 65536 }) const agent = await stubAgent(root) - const blocked = await ctx.tools.execute({ + const blocked = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('composite-first'), name: 'composite-read', arguments: {}, agent, }) @@ -6155,7 +6167,7 @@ describe('dynamic nested workspace context injection', () => { pinHarnessHome(home) await mountFileToolsAndAgentInstructions(ctx, { maxBytes: 0 }) - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('read-with-disabled-budget'), name: 'read', @@ -6180,6 +6192,7 @@ describe('dynamic nested workspace context injection', () => { try { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(RecordingFileSystem) const fs = ctx.fs as RecordingFileSystem const instructionPath = join(root, 'pkg/AGENTS.md') @@ -6191,12 +6204,12 @@ describe('dynamic nested workspace context injection', () => { await mountAgentInstructionsPlugin(ctx, { maxBytes: 20 }) const agent = await stubAgent(root) - const first = await ctx.tools.execute({ + const first = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('read-tiny-budget-1'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, }) await syncAgentInstructions(ctx, agent) - const second = await ctx.tools.execute({ + const second = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('read-tiny-budget-2'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, }) @@ -6227,7 +6240,7 @@ describe('dynamic nested workspace context injection', () => { pinHarnessHome(home) await mountFileToolsAndAgentInstructions(ctx, { maxBytes: 65536 }) - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('read-missing'), name: 'read', @@ -6258,7 +6271,7 @@ describe('dynamic nested workspace context injection', () => { await fiber.dispose() const agent = await stubAgent(root) - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('read-after-dispose'), name: 'read', @@ -6355,7 +6368,7 @@ describe('workspace context inbox synchronization', () => { pinHarnessHome(home) await mountFileToolsAndAgentInstructions(ctx, { maxBytes: 65536 }) const agent = await stubAgent(root) - await ctx.tools.execute({ + await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('pending-v1'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, }) @@ -6368,7 +6381,7 @@ describe('workspace context inbox synchronization', () => { agent.inbox.append('next-step', duplicate) await write(join(root, 'pkg/AGENTS.md'), 'pending version two with more detail') - await ctx.tools.execute({ + await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('pending-v2'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, }) @@ -6379,7 +6392,7 @@ describe('workspace context inbox synchronization', () => { expect(blocksText(agent.inbox.nextStep[0]?.content)).not.toContain('pending version one') await rm(join(root, 'pkg/AGENTS.md')) - await ctx.tools.execute({ + await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('pending-delete'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, }) @@ -6490,14 +6503,14 @@ describe('workspace context inbox synchronization', () => { pinHarnessHome(home) await mountFileToolsAndAgentInstructions(ctx, { maxBytes: 65536 }) const original = await stubAgent(root) - await ctx.tools.execute({ + await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('recover-pending-a'), name: 'read', arguments: { file_path: join('a', 'file.txt') }, agent: original, }) await syncAgentInstructions(ctx, original) const resumed = await stubAgent(root, original.session.snapshotEvents()) - await ctx.tools.execute({ + await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('recover-pending-b'), name: 'read', arguments: { file_path: join('b', 'file.txt') }, agent: resumed, }) diff --git a/packages/context/file-reference-local/package.json b/packages/context/file-reference-local/package.json index e8d4497f0f..5d10f64970 100644 --- a/packages/context/file-reference-local/package.json +++ b/packages/context/file-reference-local/package.json @@ -48,6 +48,8 @@ "@deepseek-ai/dsh-agent": "workspace:*", "@deepseek-ai/dsh-file-reference": "workspace:*", "@deepseek-ai/dsh-system-prompt": "workspace:*", + "@deepseek-ai/dsh-tool-executor-native": "workspace:*", + "@deepseek-ai/dsh-tool-testkit": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", "@deepseek-ai/cordis": "workspace:~", "@deepseek-ai/dsh-working-directory": "workspace:*", diff --git a/packages/context/file-reference-local/tests/service.spec.ts b/packages/context/file-reference-local/tests/service.spec.ts index 88cef08184..df94c8ab64 100644 --- a/packages/context/file-reference-local/tests/service.spec.ts +++ b/packages/context/file-reference-local/tests/service.spec.ts @@ -1,3 +1,4 @@ +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -10,7 +11,8 @@ import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt' -import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools' +import ToolRegistry from '@deepseek-ai/dsh-tools' +import { defineContentToolFixture } from '@deepseek-ai/dsh-tool-testkit' import { FILE_REFERENCE_PROMPT } from '@deepseek-ai/dsh-file-reference' import LocalFileReferenceService, { WorkspaceFileSearch } from '../src/index.ts' @@ -27,6 +29,7 @@ async function harness(): Promise { await ctx.plugin(SystemPrompt, { personaPrefix: '' }) await ctx.plugin(ToolRegistry) await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(AgentRegistry) await ctx.plugin(LocalFileSystem) await ctx.plugin(WorkingDirectory) @@ -74,6 +77,7 @@ describe('LocalFileReferenceService', () => { expect(ctx.agents.get(agent.id)).toBe(agent) await ctx.plugin(SystemPrompt, { personaPrefix: '' }) await ctx.plugin(ToolRegistry) + await ctx.plugin(NativeToolExecutor) ctx.tools.register(defineContentToolFixture({ name: 'read', description: 'read a file', parameters: {}, execute: () => Promise.resolve([]), })) diff --git a/packages/context/session-reference/package.json b/packages/context/session-reference/package.json index a950885dba..8cefe88f74 100644 --- a/packages/context/session-reference/package.json +++ b/packages/context/session-reference/package.json @@ -94,7 +94,9 @@ "@deepseek-ai/dsh-subagent": "workspace:*", "@deepseek-ai/dsh-spill-local": "workspace:*", "@deepseek-ai/dsh-system-prompt": "workspace:*", + "@deepseek-ai/dsh-tool-executor-native": "workspace:*", "@deepseek-ai/dsh-tool-fs": "workspace:*", + "@deepseek-ai/dsh-tool-testkit": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", "@deepseek-ai/dsh-typert-protocol": "workspace:*", "@deepseek-ai/dsh-working-directory": "workspace:*" diff --git a/packages/context/session-reference/tests/fixtures/cordis.yml b/packages/context/session-reference/tests/fixtures/cordis.yml index 2be7c197bb..61cb99ae4d 100644 --- a/packages/context/session-reference/tests/fixtures/cordis.yml +++ b/packages/context/session-reference/tests/fixtures/cordis.yml @@ -1,6 +1,7 @@ - name: '@deepseek-ai/dsh-session' - name: '@deepseek-ai/dsh-system-prompt' - name: '@deepseek-ai/dsh-tools' +- name: '@deepseek-ai/dsh-tool-executor-native' - name: '@deepseek-ai/dsh-fs-local' - name: '@deepseek-ai/dsh-session-projection' - name: '@deepseek-ai/dsh-working-directory' diff --git a/packages/context/session-reference/tests/loader-composition.spec.ts b/packages/context/session-reference/tests/loader-composition.spec.ts index fe417d4f07..c8d9ea4763 100644 --- a/packages/context/session-reference/tests/loader-composition.spec.ts +++ b/packages/context/session-reference/tests/loader-composition.spec.ts @@ -1,5 +1,7 @@ /** Real Loader composition preserves retrievable source text outside the bounded preview. */ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' @@ -51,6 +53,7 @@ describe('session-reference real Loader composition', () => { ['@deepseek-ai/dsh-working-directory', workingDirectoryPlugin], ['@deepseek-ai/dsh-system-prompt', systemPromptPlugin], ['@deepseek-ai/dsh-tools', toolsPlugin], + ['@deepseek-ai/dsh-tool-executor-native', NativeToolExecutor], ['@deepseek-ai/dsh-fs-local', fsPlugin], ['@deepseek-ai/dsh-tool-fs', toolFsPlugin], ['@deepseek-ai/dsh-session-query-sqlite', queryPlugin], @@ -111,7 +114,7 @@ describe('session-reference real Loader composition', () => { const readLines: string[] = [] let totalLines = Infinity for (let offset = 1; offset <= totalLines; offset += 7) { - const read = await ctx.tools.execute({ + const read = await executeToolFixture(ctx, { name: 'read', callId: ToolCallId(`read-${offset}`), arguments: { file_path: notice.fullSnapshot.locator, offset, limit: 7 }, signal: new AbortController().signal, diff --git a/packages/context/time-context/package.json b/packages/context/time-context/package.json index 44f5899cbf..b56d424e66 100644 --- a/packages/context/time-context/package.json +++ b/packages/context/time-context/package.json @@ -56,6 +56,7 @@ "@deepseek-ai/dsh-session-projection": "workspace:*", "@deepseek-ai/dsh-subprocess-local": "workspace:*", "@deepseek-ai/dsh-system-prompt": "workspace:*", + "@deepseek-ai/dsh-tool-testkit": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", "@deepseek-ai/dsh-util-time": "workspace:*" } diff --git a/packages/context/time-context/tests/time-context.spec.ts b/packages/context/time-context/tests/time-context.spec.ts index 5092079372..ec0595d197 100644 --- a/packages/context/time-context/tests/time-context.spec.ts +++ b/packages/context/time-context/tests/time-context.spec.ts @@ -6,7 +6,7 @@ import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' import type { ContextFormed } from '@deepseek-ai/dsh-llm' import { Session, SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import AgentRegistry, { agentEvents, type Agent } from '@deepseek-ai/dsh-agent' -import { defineContentToolFixture } from '@deepseek-ai/dsh-tools' +import { defineContentToolFixture } from '@deepseek-ai/dsh-tool-testkit' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { unsupportedModel, unsupportedInbox, mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' diff --git a/packages/core/agent-loop/README.i18n.yaml b/packages/core/agent-loop/README.i18n.yaml index cbacc39598..05acb79fbc 100644 --- a/packages/core/agent-loop/README.i18n.yaml +++ b/packages/core/agent-loop/README.i18n.yaml @@ -9,8 +9,8 @@ en: b711ded29c7b9a17 zh: cbccba927743f35f /deepseek-ai-dsh-agent-loop/summary: - en: 9256331cd3baf930 - zh: 02b4c10cf3286eb3 + en: b2971780b74fd785 + zh: 05a3043f49fb646d /deepseek-ai-dsh-agent-loop/table-of-contents: en: d152484eb41ac6b4 zh: 09388d293f9be9cb @@ -18,14 +18,14 @@ en: 35a960c0100a6aa4 zh: 438e133fb4c24a9e /deepseek-ai-dsh-agent-loop/use-this-package/configure-declarative-agents: - en: 057e5f8b0090b1e2 - zh: 1133bbcf54e495ed + en: d5ac4be018308358 + zh: 4c429a40f666abfb /deepseek-ai-dsh-agent-loop/use-this-package/create-or-resume-agents-programmatically: en: 82ec792637e8e57f zh: e32f8ec5ee17afcf /deepseek-ai-dsh-agent-loop/use-this-package/what-a-step-does: - en: 15ce4783b53532a2 - zh: b04a18337281237a + en: b789acbbfd6a3094 + zh: 85b4d093ae23ce7d /deepseek-ai-dsh-agent-loop/understand-the-implementation: en: ce7e776e9d516bdb zh: 59d2751743db7795 @@ -36,8 +36,8 @@ en: 21547fd3591f1ff7 zh: 80b622e5a0827505 /deepseek-ai-dsh-agent-loop/understand-the-implementation/source-map: - en: 2a8b93869eb5a2f9 - zh: ddab8e738d188a61 + en: ec2bf804bd1a5439 + zh: 316e767507e859ef /deepseek-ai-dsh-agent-loop/understand-the-implementation/creation-and-teardown: en: 1127b2c51e3f2d1a zh: 15a24452c18f0686 diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index 22c986b686..4d5455df16 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -`dsh-agent-loop` creates fresh agents or resumes persisted sessions, then drives each turn through model requests, streamed responses, tool execution, and durable session history. Mount it for standard agent compositions; declarative entries start agents at boot, while the public `ctx.agents` API supports programmatic creation and resume. `maxParallelToolCalls` limits concurrent parallel-safe calls, and exclusive calls retain ordering. Cancellation preserves streamed text already delivered to the user. Choose a custom `Agent` implementation only when the standard "call model, run tools, repeat" lifecycle is insufficient. +`dsh-agent-loop` creates fresh agents or resumes persisted sessions, then drives each turn through model requests, streamed responses, tool execution, and durable session history. Mount it for standard agent compositions; declarative entries start agents at boot, while the public `ctx.agents` API supports programmatic creation and resume. The selected tool executor owns dispatch, concurrency, cancellation, and tool logging. Cancellation preserves streamed text already delivered to the user. Choose a custom `Agent` implementation only when the standard "call model, run tools, repeat" lifecycle is insufficient. ## Table of Contents @@ -34,7 +34,6 @@ Agents declared in config start automatically when the plugin loads. Each entry ```yaml - name: '@deepseek-ai/dsh-agent-loop' config: - maxParallelToolCalls: 10 agents: - id: 'main' provider: deepseek @@ -45,7 +44,6 @@ Agents declared in config start automatically when the plugin loads. Each entry | Field | Default | Meaning | |---|---|---| -| `maxParallelToolCalls` | `10` | Parallel-safe tool calls in flight per step; `1` is serial | | `agents[].id` | required | Stable label; a fresh session mints `${id}-session-` unless `sessionId` is set | | `agents[].provider` / `agents[].model` | — | Model route; both required before step preparation | | `agents[].reasoningEffort` | — | Non-empty initial reasoning effort; later choices are recorded in the Session | @@ -54,7 +52,7 @@ Agents declared in config start automatically when the plugin loads. Each entry | `agents[].sessionId` | — | Exact identity: first use creates, a remount resumes materialized history | | `agents[].resumeSessionId` | — | Load this persisted session instead of creating one; mutually exclusive with `sessionId` | -The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-agent-loop) lists every accepted field. `maxParallelToolCalls` is a volatile Config field sampled for the next tool group; `agents` remains startup configuration. +The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-agent-loop) lists every accepted field. `maxParallelToolCalls` remains a volatile positive-integer Config field with default `10`; `1` selects serial execution. The loop passes its current value to each batch's `execute()` call, taking precedence over executor configuration. Live edits apply to the next batch; `agents` remains startup configuration. A configured resume records the declared route as the Session selection before publication. Programmatic resume preserves the Session choice unless its setup explicitly records another route; initial `agentOptions` alone do not override saved history. @@ -74,7 +72,7 @@ Every inbox mutation commits one normalized `agent/inbox/spliced` event. The pro ### What a step does -Each step sends the session's derived history — with the latest non-empty `system/message` node as the effective prompt, or no system messages when the rendered prompt is empty — and its visible tool schemas; the model's tool calls run through the guarded tool pipeline and every accepted fact is appended to the session log before the next step derives from it. Parallel-safe calls may overlap up to `maxParallelToolCalls`; exclusive calls run alone as ordering barriers. Cancellation is cooperative: `agent.cancel()` aborts the current activity and, unless `keepInbox` is set, clears pending work; a cancelled stream finalizes the text already delivered to the user. +Each step sends the session's derived history — with the latest non-empty `system/message` node as the effective prompt, or no system messages when the rendered prompt is empty — and its visible tool schemas; the model's tool calls run through the guarded tool pipeline and every accepted fact is appended to the session log before the next step derives from it. The loop passes the complete raw call batch to `ctx.tools.execute()`; built-in executors preserve model-order results and exclusive barriers. Cancellation is cooperative: `agent.cancel()` aborts the current activity and, unless `keepInbox` is set, clears pending work; a cancelled stream finalizes the text already delivered to the user. ----- @@ -105,9 +103,7 @@ The loop deep-freezes each derived message identity on its first request and reu | [`src/index.ts`](src/index.ts) | Plugin entry: `AgentLoop` service, config schema, declarative agent startup, factory registration | | [`src/agent.ts`](src/agent.ts) | The concrete `ReactLoopAgent` driver: inbox, turn/step machine, cancellation | | [`src/inbox.ts`](src/inbox.ts) | Package-internal `ReactLoopInbox`: durable projection, structural commands, and loop-only claim state | -| [`src/tool-calls.ts`](src/tool-calls.ts) | Tool scheduling: exclusive barriers and the bounded parallel pool | | [`src/runtime-context.ts`](src/runtime-context.ts) | Per-step runtime-context snapshot handling | -| [`src/constants.ts`](src/constants.ts) | `DEFAULT_MAX_PARALLEL_TOOL_CALLS` | ### Creation and teardown diff --git a/packages/core/agent-loop/README.zh.md b/packages/core/agent-loop/README.zh.md index 70d6242d3b..f7fc114449 100644 --- a/packages/core/agent-loop/README.zh.md +++ b/packages/core/agent-loop/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -`dsh-agent-loop` 创建全新 agent 或恢复持久化会话,随后通过模型请求、流式响应、工具执行和持久会话历史驱动每个轮次。标准 agent 组合应挂载本包;声明式条目会在启动时启动 agent,公开的 `ctx.agents` API 则支持以编程方式创建和恢复 agent。`maxParallelToolCalls` 限制同时运行的并行安全调用数量,独占调用保留顺序。取消会保留已经流式交付给用户的文本。只有标准的「调用模型、运行工具、重复」生命周期无法满足需求时,才应选择自定义 `Agent` 实现。 +`dsh-agent-loop` 创建全新 agent 或恢复持久化会话,随后通过模型请求、流式响应、工具执行和持久会话历史驱动每个轮次。标准 agent 组合应挂载本包;声明式条目会在启动时启动 agent,公开的 `ctx.agents` API 则支持以编程方式创建和恢复 agent。所选工具执行器负责分发、并发、取消和工具日志。取消会保留已经流式交付给用户的文本。只有标准的「调用模型、运行工具、重复」生命周期无法满足需求时,才应选择自定义 `Agent` 实现。 ## 目录 @@ -34,7 +34,6 @@ kind: "package-reference" ```yaml - name: '@deepseek-ai/dsh-agent-loop' config: - maxParallelToolCalls: 10 agents: - id: 'main' provider: deepseek @@ -45,7 +44,6 @@ kind: "package-reference" | 字段 | 默认值 | 含义 | |---|---|---| -| `maxParallelToolCalls` | `10` | 每个步骤同时在途的并行安全工具调用数;`1` 为串行 | | `agents[].id` | 必填 | 稳定标签;未设置 `sessionId` 时,全新会话会生成 `${id}-session-` | | `agents[].provider` / `agents[].model` | — | 模型路由;步骤准备前两者都必须存在 | | `agents[].reasoningEffort` | — | 非空的初始推理强度;后续选择记录在 Session 中 | @@ -54,7 +52,7 @@ kind: "package-reference" | `agents[].sessionId` | — | 确切身份:首次使用创建,重新挂载时恢复已实体化的历史 | | `agents[].resumeSessionId` | — | 加载这个持久化会话而不是创建新会话;与 `sessionId` 互斥 | -生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-agent-loop) 列出所有接受的字段。`maxParallelToolCalls` 是在下一个工具组开始时读取的 volatile Config 字段;`agents` 仍是启动配置。 +生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-agent-loop) 列出所有接受的字段。`maxParallelToolCalls` 仍是 volatile 正整数 Config 字段,默认值为 `10`;`1` 表示串行执行。循环在每个批次调用 `execute()` 时传入当前值,其优先级高于执行器配置。实时修改从下一个批次生效;`agents` 仍是启动配置。 配置式恢复会在发布前将声明的路由记录为 Session 选择。程序化恢复保留 Session 选择,除非其 setup 显式记录另一条路由;仅传入初始 `agentOptions` 不会覆盖已有历史。 @@ -74,7 +72,7 @@ const handle = await ctx.agents.create({ ### 一个步骤做什么 -每个步骤都会发送会话的派生历史——最新的非空 `system/message` 节点是有效提示词,渲染提示词为空时则没有系统消息——及其可见工具 schema;模型的工具调用经过受守卫的工具流水线,每个被接纳的事实都会在下一步据此派生之前追加到会话日志。并行安全调用最多可重叠 `maxParallelToolCalls` 个;独占调用单独运行并构成排序屏障。取消是协作式的:`agent.cancel()` 中止当前活动,并在未设置 `keepInbox` 时清除待处理工作;被取消的流会终结已送达用户的文本。 +每个步骤都会发送会话的派生历史——最新的非空 `system/message` 节点是有效提示词,渲染提示词为空时则没有系统消息——及其可见工具 schema;模型的工具调用经过受守卫的工具流水线,每个被接纳的事实都会在下一步据此派生之前追加到会话日志。循环把完整的原始调用批次传给 `ctx.tools.execute()`;内置执行器保留模型顺序的结果和独占屏障。取消是协作式的:`agent.cancel()` 中止当前活动,并在未设置 `keepInbox` 时清除待处理工作;被取消的流会终结已送达用户的文本。 ----- @@ -105,9 +103,7 @@ const handle = await ctx.agents.create({ | [`src/index.ts`](src/index.ts) | 插件入口:`AgentLoop` 服务、配置 schema、声明式 agent 启动、工厂注册 | | [`src/agent.ts`](src/agent.ts) | 具体 `ReactLoopAgent` 驱动器:收件箱、轮次/步骤状态机、取消 | | [`src/inbox.ts`](src/inbox.ts) | 包内部的 `ReactLoopInbox`:持久投影、结构化命令与仅供循环使用的领取状态 | -| [`src/tool-calls.ts`](src/tool-calls.ts) | 工具调度:独占屏障与有界并行池 | | [`src/runtime-context.ts`](src/runtime-context.ts) | 逐步骤运行时上下文快照处理 | -| [`src/constants.ts`](src/constants.ts) | `DEFAULT_MAX_PARALLEL_TOOL_CALLS` | ### 创建与拆除 diff --git a/packages/core/agent-loop/package.json b/packages/core/agent-loop/package.json index 6792353436..a5d470f452 100644 --- a/packages/core/agent-loop/package.json +++ b/packages/core/agent-loop/package.json @@ -56,6 +56,9 @@ "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:*", "@deepseek-ai/dsh-session-projection": "workspace:*", "@deepseek-ai/dsh-system-prompt": "workspace:*", + "@deepseek-ai/dsh-tool-executor-native": "workspace:*", + "@deepseek-ai/dsh-tool-executor-ptc": "workspace:*", + "@deepseek-ai/dsh-tool-testkit": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*" } } diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index 43469c65ee..d263ed1d14 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -34,12 +34,12 @@ import type { EpochHeader, RequestContext, Session, SessionId, SessionSeq, TurnE import { canonicalHeader, headerEquals, ToolCallRecovery } from '@deepseek-ai/dsh-session' import { joinContextSections, renderContextSections, renderPrompt } from '@deepseek-ai/dsh-system-prompt' import type { PromptAssembly } from '@deepseek-ai/dsh-system-prompt' +import { ToolBatchExecutionError } from '@deepseek-ai/dsh-tools' import type { Context } from '@deepseek-ai/cordis' import { ReactLoopInbox } from './inbox.ts' import { RuntimeContextProjection } from './runtime-context.ts' import { AssistantStreamAttempt } from './assistant-stream.ts' import { SystemPromptProjection } from './runtime-context.ts' -import { executeToolCalls } from './tool-calls.ts' type Phase = | { kind: 'idle'; lastTurn: number } @@ -560,11 +560,23 @@ export class ReactLoopAgent implements Agent { const toolCalls = message.content.filter(block => block.type === 'tool-call') if (toolCalls.length === 0) return { kind: 'completed' } - const { concluded } = await executeToolCalls( - this.loopCtx, turn, step, toolCalls, signal, - context => this.inbox.splice('next-step', this.inbox.nextStep.length, 0, [context]), - ) - return concluded ? { kind: 'completed' } : null + const maxParallelToolCalls = this.loopCtx.agentLoop.config.maxParallelToolCalls.get() + try { + const result = await this.loopCtx.tools.execute({ + calls: toolCalls, signal, agent: this, log: { turn, step }, + maxParallelToolCalls, + }) + for (const context of result.additionalContexts) { + this.inbox.splice('next-step', this.inbox.nextStep.length, 0, [context]) + } + return result.concluded ? { kind: 'completed' } : null + } catch (error: unknown) { + if (!(error instanceof ToolBatchExecutionError)) throw error + for (const context of error.partial.additionalContexts) { + this.inbox.splice('next-step', this.inbox.nextStep.length, 0, [context]) + } + throw error.cause + } } catch (error: unknown) { if (!live.ended) live.abandon() throw error diff --git a/packages/core/agent-loop/src/tool-calls.ts b/packages/core/agent-loop/src/tool-calls.ts deleted file mode 100644 index c12de68d19..0000000000 --- a/packages/core/agent-loop/src/tool-calls.ts +++ /dev/null @@ -1,290 +0,0 @@ -/** - * Schedules one assistant step's tool calls. Exclusive calls form barriers; - * parallel calls use a bounded rolling pool and are reclassified before start. - * Dispatch may overlap, while policy, results, and result context remain - * model-ordered. Abort or an internal scheduler failure stops replenishment - * and drains started calls. - * - * Abort records synthetic error results for skipped calls so replay stays - * valid. A terminal scheduler failure rejects after draining; the owning step - * records conservative recovery results before closing. - * @module dsh-agent-loop/tool-calls - */ - -import type { Context } from '@deepseek-ai/cordis' -import { createToolResultMessage, type ToolCallBlock } from '@deepseek-ai/dsh-llm' -import type { Session, SessionSeq, UserMessage } from '@deepseek-ai/dsh-session' -import { TOOL_ABORTED_BEFORE_DISPATCH, TOOL_RUNTIME_SCHEDULER, type ToolExecutionInput, type ToolExecutionMode, type ToolExecutionResult, type ToolRunContext } from '@deepseek-ai/dsh-tools' -import { assertNever } from '@deepseek-ai/dsh-util-values' - -/** One tool call after argument parsing, ready to schedule. */ -interface PlannedCall { - block: ToolCallBlock - exec: ToolExecutionInput -} - -/** Settled dispatch awaiting model-order finalization. */ -interface Slot { - exec: ToolRunContext - result: ToolExecutionResult - needsPost: boolean -} - -/** One scheduler group outcome, including a drained cancellation. */ -interface GroupOutcome { - consumed: number - aborted: boolean - /** Whether any committed result carried {@link ToolExecutionResult.concludesTurn}. */ - concluded: boolean -} - -/** - * Schedule one assistant step's tool calls by their live concurrency mode. - * Ordinary completion and abort commit started-call results in order. Abort - * drains them, records synthetic results for unstarted calls, and returns with - * the signal still aborted after accepting started-call context through the - * caller-supplied acceptor (the machine stages it in its next-step inbox for the - * step boundary). An internal scheduler failure stops new dispatches, drains - * already-started dispatches, and rejects with the first failure. The owning - * step supplies error results for requests without a committed outcome. - * The committed step's AgentLoop driver boundary supplies the initiating Agent - * that becomes each explicit {@link ToolExecutionInput.agent}. - * - * @param ctx - loop context that owns the tool registry and carries the initiating Agent. - * @param turn - current turn number. - * @param step - current step number. - * @param toolCalls - assistant calls in model order. - * @param signal - abort signal shared by the step. - * @param acceptContext - accepts committed result context for the next step boundary. - */ -export async function executeToolCalls( - ctx: Context, - turn: number, - step: number, - toolCalls: ToolCallBlock[], - signal: AbortSignal, - acceptContext: (context: UserMessage) => void, -): Promise<{ concluded: boolean }> { - const agent = ctx.agents.requireInitiator() - const { session } = agent - - // Inputs are distinct because tools/execute wrappers may replace `exec.signal`. - const planned: PlannedCall[] = toolCalls.map(block => ({ - block, - exec: { - callId: block.id, - name: block.name, - arguments: parseArguments(block.arguments), - agent, - signal, - }, - })) - - let next = 0 - let concluded = false - while (next < planned.length) { - // Commit before classifying again so registry changes affect unstarted calls. - // oxlint-disable-next-line typescript/no-non-null-assertion -- bounded by the loop condition - const first = planned[next]! - const mode = ctx.tools.executionMode(first.exec).kind - const group = mode === 'parallel' ? planned.slice(next) : [first] - const outcome = await runGroup( - ctx, turn, step, group, mode, signal, acceptContext, - ) - next += outcome.consumed - concluded ||= outcome.concluded - if (outcome.aborted) { - for (const call of planned.slice(next)) appendSkippedToolCall(session, turn, step, call.block) - return { concluded } - } - } - return { concluded } -} - -/** Parse model arguments, preserving invalid JSON as text and mapping empty input to `{}`. */ -function parseArguments(raw: string): unknown { - try { - return raw ? JSON.parse(raw) : {} - } catch { - return raw - } -} - -/** - * Run one exclusive barrier or parallel pool. Later calls are reclassified - * before start; an exclusive reclassification waits for the current pool to - * drain and remains for the caller's next barrier. Results and contexts commit - * in model order. Abort stops starts, drains and commits started calls, accepts - * their contexts into the owning batch, records results for skipped calls, and - * returns an aborted outcome. Scheduler failure drains dispatches and rejects - * for the owning step to record recovery results. - */ -async function runGroup( - ctx: Context, - turn: number, - step: number, - group: PlannedCall[], - mode: ToolExecutionMode['kind'], - signal: AbortSignal, - acceptContext: (context: UserMessage) => void, -): Promise { - const { session } = ctx.agents.requireInitiator() - const maxParallelToolCalls = ctx.agentLoop.config.maxParallelToolCalls.get() - const slots: (Slot | undefined)[] = group.map(() => undefined) - // Started slots retain their `tool/call` seq so the result can cite it. - const callSeqs: Array = group.map(() => undefined) - let nextToStart = 0 - let committed = 0 - let started = 0 - let aborted: boolean = signal.aborted - let concluded = false - let schedulerFailure: { error: unknown } | undefined - const throwSchedulerFailure = (): void => { - if (schedulerFailure !== undefined) throw schedulerFailure.error - } - - // `committed` advances only across contiguous model-order slots. - const commitReady = async (): Promise => { - while (committed < group.length) { - const slot = slots[committed] - if (slot === undefined) break - const call = group[committed] - const result = slot.needsPost - ? await ctx.tools[TOOL_RUNTIME_SCHEDULER].finalize(slot.exec, slot.result) - : ctx.tools[TOOL_RUNTIME_SCHEDULER].finish(slot.exec, slot.result) - // oxlint-disable-next-line typescript/no-non-null-assertion -- bounded index - appendToolResult(session, turn, step, call!.block, result, callSeqs[committed]!) - for (const context of result.additionalContexts ?? []) acceptContext(context) - concluded ||= result.concludesTurn === true - committed++ - } - } - - const inFlight = new Map>() - - const startCall = async (index: number): Promise => { - // oxlint-disable-next-line typescript/no-non-null-assertion -- bounded index - const call = group[index]! - callSeqs[index] = appendToolCall(session, turn, step, call.block) - started++ - const prepared = await ctx.tools[TOOL_RUNTIME_SCHEDULER].prepare(call.exec) - throwSchedulerFailure() - switch (prepared.kind) { - case 'dispatch': { - const promise = ctx.tools[TOOL_RUNTIME_SCHEDULER].dispatch(prepared.exec).then( - (outcome) => { - slots[index] = { exec: prepared.exec, result: outcome.result, needsPost: outcome.kind === 'post-result' } - return index - }, - (error: unknown) => { - schedulerFailure ??= { error } - return index - }, - ) - inFlight.set(index, promise) - break - } - case 'post-result': - slots[index] = { exec: prepared.exec, result: prepared.result, needsPost: true } - break - case 'final-result': - slots[index] = { exec: prepared.exec, result: prepared.result, needsPost: false } - break - /* v8 ignore next -- closed-union exhaustiveness guard */ - default: - assertNever(prepared, 'tool-call scheduler prepare result') - } - } - - const fillPool = async (): Promise => { - while (!aborted && nextToStart < group.length && inFlight.size < maxParallelToolCalls) { - // Re-read later modes after ordered commits so registry changes can create a barrier. - // oxlint-disable-next-line typescript/no-non-null-assertion -- bounded by the loop condition - const nextCall = group[nextToStart]! - if (nextToStart > 0 && mode === 'parallel' - && ctx.tools.executionMode(nextCall.exec).kind !== 'parallel') break - await startCall(nextToStart) - nextToStart++ - throwSchedulerFailure() - await commitReady() - throwSchedulerFailure() - // Abort may arrive while pre-execute awaits. - if (signal.aborted) aborted = true - } - } - - // Ordered pre-execute may await; only dispatch/body overlaps. A scheduler - // failure stops new dispatches and reaches the turn boundary after every - // already-started dispatch settles. - try { - await fillPool() - while (inFlight.size > 0) { - const settledIndex = await Promise.race(inFlight.values()) - inFlight.delete(settledIndex) - throwSchedulerFailure() - await commitReady() - throwSchedulerFailure() - // Abort may arrive while a tool or ordered commit awaits. - - if (signal.aborted) aborted = true - await fillPool() - } - } catch (error: unknown) { - schedulerFailure ??= { error } - await Promise.allSettled(inFlight.values()) - throw schedulerFailure.error - } - - if (aborted) { - // Started calls and accepted context settle first; every remaining model - // call then receives an ordered synthetic result before the turn aborts. - for (const call of group.slice(started)) appendSkippedToolCall(session, turn, step, call.block) - return { consumed: group.length, aborted: true, concluded } - } - /* v8 ignore next -- unreachable: a non-aborted group commits every started call */ - if (committed !== started) throw new Error('tool-call scheduler: uncommitted settled calls') - return { consumed: started, aborted: false, concluded } -} - -/** Append the durable call/result pair for a model call skipped after cancellation. */ -function appendSkippedToolCall(session: Session, turn: number, step: number, block: ToolCallBlock): void { - const callSeq = appendToolCall(session, turn, step, block) - appendToolResult(session, turn, step, block, { - content: [{ type: 'text', text: 'Error: tool call aborted before dispatch' }], - isError: true, - error: { - message: 'tool call aborted before dispatch', - info: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH }, - }, - }, callSeq) -} - -/** Append a started call and return the event seq that its result must cite. */ -function appendToolCall(session: Session, turn: number, step: number, block: ToolCallBlock): SessionSeq { - const event = session.append('tool/call', { turn, step, callId: block.id, name: block.name, arguments: block.arguments }) - return event.seq -} - -/** Append a model-ordered result linked to its call event. */ -function appendToolResult( - session: Session, - turn: number, - step: number, - block: ToolCallBlock, - result: ToolExecutionResult, - callSeq: SessionSeq, -): void { - const message = createToolResultMessage({ - callId: block.id, - content: result.content, - isError: result.isError, - }) - session.append('tool/result', { - turn, step, - message, - ...result.error?.info ? { error: result.error.info } : {}, - // The tool's private presentation payload (e.g. a result-time diff), - // persisted so a UI bridge reproduces the card on replay. - ...result.meta !== undefined ? { meta: result.meta } : {}, - }, { surfaceOp: 'append', sourceEventSeqs: [callSeq] }) -} diff --git a/packages/core/agent-loop/tests/agent-initiator.spec.ts b/packages/core/agent-loop/tests/agent-initiator.spec.ts index 0b9d32ec06..15c2967fdb 100644 --- a/packages/core/agent-loop/tests/agent-initiator.spec.ts +++ b/packages/core/agent-loop/tests/agent-initiator.spec.ts @@ -1,3 +1,5 @@ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { describe, expect, it } from 'vitest' import { Context, type Fiber } from '@deepseek-ai/cordis' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' @@ -7,7 +9,8 @@ import LlmRuntime, { createUserMessage, ToolCallId, LlmAdapter } from '@deepsee import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRuntime, { defineContentToolFixture } from '@deepseek-ai/dsh-tools' +import ToolRuntime from '@deepseek-ai/dsh-tools' +import { defineContentToolFixture } from '@deepseek-ai/dsh-tool-testkit' import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts' const testToolSignal = new AbortController().signal @@ -25,6 +28,7 @@ async function harness(adapter: LlmAdapter): Promise { await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) const agentsFiber = await ctx.plugin(AgentRegistry) const loopFiber = await ctx.plugin(AgentLoop, { agents: [] }) ctx.llm.registerAdapter(['mock'], adapter) @@ -126,6 +130,7 @@ describe('AgentLoop initiator scope', () => { await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(AgentRegistry) await ctx.plugin(AgentLoop, { agents: [] }) ctx.llm.registerAdapter(['mock'], adapter) @@ -312,7 +317,7 @@ describe('AgentLoop initiator scope', () => { }, })) - const direct = await ctx.tools.execute({ + const direct = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('direct'), name: 'agentless-probe', @@ -387,6 +392,7 @@ describe('AgentLoop initiator scope', () => { await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(AgentRegistry) await ctx.plugin(AgentLoop, { agents: [] }) ctx.llm.registerAdapter(['mock'], adapter) diff --git a/packages/core/agent-loop/tests/agent.spec.ts b/packages/core/agent-loop/tests/agent.spec.ts index 180edf291d..133103d12d 100644 --- a/packages/core/agent-loop/tests/agent.spec.ts +++ b/packages/core/agent-loop/tests/agent.spec.ts @@ -1,3 +1,4 @@ +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { createUserMessage } from '@deepseek-ai/dsh-llm' import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' @@ -26,6 +27,7 @@ async function harness(adapter: MockAdapter): Promise { await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(AgentRegistry) await ctx.plugin(AgentLoop, { agents: [] }) ctx.llm.registerAdapter(['mock'], adapter) diff --git a/packages/core/agent-loop/tests/cancel.spec.ts b/packages/core/agent-loop/tests/cancel.spec.ts index add362835b..c702b64e89 100644 --- a/packages/core/agent-loop/tests/cancel.spec.ts +++ b/packages/core/agent-loop/tests/cancel.spec.ts @@ -1,4 +1,3 @@ -import { ToolCallId, createUserMessage, expandAssistantStream } from '@deepseek-ai/dsh-llm' /** * Tests for the queue-aware `Agent.cancel()` primitive. The default clears * queued and steering work, while `keepInbox` preserves pending input for a @@ -7,13 +6,16 @@ import { ToolCallId, createUserMessage, expandAssistantStream } from '@deepseek- * the cause each cancelled turn records. * @module dsh-agent-loop/tests/cancel */ +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' +import { ToolCallId, createUserMessage, expandAssistantStream } from '@deepseek-ai/dsh-llm' import { describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import LlmRuntime from '@deepseek-ai/dsh-llm' import SessionStore, { Session, SessionId, SessionLogOffset, TurnEndReason } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRuntime, { defineContentToolFixture, TOOL_ABORTED_BEFORE_DISPATCH } from '@deepseek-ai/dsh-tools' +import ToolRuntime, { TOOL_ABORTED_BEFORE_DISPATCH } from '@deepseek-ai/dsh-tools' +import { defineContentToolFixture } from '@deepseek-ai/dsh-tool-testkit' import AgentRegistry, { type Agent, type AgentCancelCause } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' @@ -30,6 +32,7 @@ async function harness(adapter: MockAdapter) { await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(AgentRegistry) await ctx.plugin(AgentLoop, { agents: [] }) ctx.llm.registerAdapter(['mock'], adapter) @@ -828,6 +831,7 @@ describe('Agent.cancel()', () => { await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(AgentRegistry) await ctx.plugin(AgentLoop, { agents: [] }) ctx.llm.registerAdapter(['mock'], adapter) diff --git a/packages/core/agent-loop/tests/config-session-id.spec.ts b/packages/core/agent-loop/tests/config-session-id.spec.ts index d24e7f14d1..784d7bbb56 100644 --- a/packages/core/agent-loop/tests/config-session-id.spec.ts +++ b/packages/core/agent-loop/tests/config-session-id.spec.ts @@ -1,3 +1,4 @@ +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { createUserMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm' import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' @@ -42,6 +43,7 @@ async function makeCoreContext(): Promise { await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(AgentRegistry) return ctx } @@ -373,6 +375,7 @@ describe('config-driven session id', () => { await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(AgentRegistry) const loopFiber = await ctx.plugin(AgentLoop, { agents: [{ id: SessionId('main'), provider: 'mock', model: 'mock', resumeSessionId: SessionId('deferred') }], @@ -396,6 +399,7 @@ describe('config-driven session id', () => { await ctx1.plugin(SessionProjectionRegistry) await ctx1.plugin(SystemPrompt) await ctx1.plugin(ToolRuntime) + await ctx1.plugin(NativeToolExecutor) await ctx1.plugin(AgentRegistry) await ctx1.plugin(JsonlSessionPersistence, { root }) await ctx1.plugin(AgentLoop, { agents: [{ id: SessionId('cfg'), provider: 'mock', model: 'mock' }] }) @@ -419,6 +423,7 @@ describe('config-driven session id', () => { await ctx2.plugin(SessionProjectionRegistry) await ctx2.plugin(SystemPrompt) await ctx2.plugin(ToolRuntime) + await ctx2.plugin(NativeToolExecutor) await ctx2.plugin(AgentRegistry) await ctx2.plugin(JsonlSessionPersistence, { root }) await ctx2.plugin(AgentLoop, { agents: [{ id: SessionId('cfg'), provider: 'mock', model: 'mock' }] }) @@ -445,6 +450,7 @@ describe('config-driven session id', () => { await ctx1.plugin(SessionProjectionRegistry) await ctx1.plugin(SystemPrompt) await ctx1.plugin(ToolRuntime) + await ctx1.plugin(NativeToolExecutor) await ctx1.plugin(AgentRegistry) await ctx1.plugin(JsonlSessionPersistence, { root }) await ctx1.plugin(AgentLoop, { agents: [] }) @@ -463,6 +469,7 @@ describe('config-driven session id', () => { await ctx2.plugin(SessionProjectionRegistry) await ctx2.plugin(SystemPrompt) await ctx2.plugin(ToolRuntime) + await ctx2.plugin(NativeToolExecutor) await ctx2.plugin(AgentRegistry) await ctx2.plugin(AgentLoop, { agents: [{ id: SessionId('main'), provider: 'mock', model: 'mock', resumeSessionId: SessionId('sticky-1') }] }) await ctx2.plugin(JsonlSessionPersistence, { root }) @@ -517,6 +524,7 @@ describe('config-driven session id', () => { await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(AgentRegistry) await ctx.plugin(AgentLoop, { agents: [{ id: SessionId('main'), provider: 'mock', model: 'mock', resumeSessionId: SessionId('does-not-exist') }] }) const warn = vi.spyOn((ctx.agentLoop as unknown as { ctx: { logger: { warn: (...a: unknown[]) => void } } }).ctx.logger, 'warn') diff --git a/packages/core/agent-loop/tests/contract-regressions.spec.ts b/packages/core/agent-loop/tests/contract-regressions.spec.ts index 8a7d835d31..ce9dc4b4cd 100644 --- a/packages/core/agent-loop/tests/contract-regressions.spec.ts +++ b/packages/core/agent-loop/tests/contract-regressions.spec.ts @@ -1,10 +1,12 @@ +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import LlmRuntime, { type RequestMessage, createUserMessage, ToolCallId, LlmError, MessageSource, ProviderRequestId, StreamChunk } from '@deepseek-ai/dsh-llm' import type { ContextFormed } from '@deepseek-ai/dsh-llm' import SessionStore, { Session, SessionEvent, SessionId, TurnEndReason, type UserMessage } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRuntime, { defineContentToolFixture, type PostToolDecision } from '@deepseek-ai/dsh-tools' +import ToolRuntime, { type PostToolDecision } from '@deepseek-ai/dsh-tools' +import { defineContentToolFixture } from '@deepseek-ai/dsh-tool-testkit' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' @@ -29,6 +31,7 @@ async function harness(adapter: MockAdapter) { await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(AgentRegistry) await ctx.plugin(AgentLoop, { agents: [] }) ctx.llm.registerAdapter(['mock'], adapter) @@ -576,6 +579,7 @@ describe('turn numbering continues across seeded sessions', () => { await ctx2.plugin(SessionProjectionRegistry) await ctx2.plugin(SystemPrompt) await ctx2.plugin(ToolRuntime) + await ctx2.plugin(NativeToolExecutor) await ctx2.plugin(AgentRegistry) await ctx2.plugin(AgentLoop, { agents: [] }) ctx2.llm.registerAdapter(['mock'], second) @@ -1292,6 +1296,7 @@ describe('disposal and cancellation during pre-step assembly', () => { await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(AgentRegistry) await ctx.plugin(AgentLoop, { agents: [] }) ctx.llm.registerAdapter(['mock'], adapter) @@ -1342,6 +1347,7 @@ describe('disposal and cancellation during pre-step assembly', () => { await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(AgentRegistry) await ctx.plugin(AgentLoop, { agents: [] }) ctx.llm.registerAdapter(['mock'], adapter) @@ -1392,6 +1398,7 @@ describe('disposal and cancellation during pre-step assembly', () => { await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(AgentRegistry) await ctx.plugin(AgentLoop, { agents: [] }) ctx.llm.registerAdapter(['mock'], adapter) @@ -1438,6 +1445,7 @@ describe('disposal and cancellation during pre-step assembly', () => { await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(AgentRegistry) await ctx.plugin(AgentLoop, { agents: [] }) ctx.llm.registerAdapter(['mock'], adapter) @@ -1486,6 +1494,7 @@ describe('disposal and cancellation during pre-step assembly', () => { await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(AgentRegistry) await ctx.plugin(AgentLoop, { agents: [] }) ctx.llm.registerAdapter(['mock'], adapter) diff --git a/packages/core/agent-loop/tests/coverage-edges.spec.ts b/packages/core/agent-loop/tests/coverage-edges.spec.ts index 38387dc3ad..57a9887b3a 100644 --- a/packages/core/agent-loop/tests/coverage-edges.spec.ts +++ b/packages/core/agent-loop/tests/coverage-edges.spec.ts @@ -1,3 +1,4 @@ +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import LlmRuntime, { createUserMessage, ToolCallId, LlmError, StreamChunk, errorChain } from '@deepseek-ai/dsh-llm' @@ -5,7 +6,8 @@ import type { ContextFormed } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId, TurnEndReason } from '@deepseek-ai/dsh-session' import type { SessionEvent } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRuntime, { defineContentToolFixture } from '@deepseek-ai/dsh-tools' +import ToolRuntime from '@deepseek-ai/dsh-tools' +import { defineContentToolFixture } from '@deepseek-ai/dsh-tool-testkit' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' @@ -29,6 +31,7 @@ async function harness(adapter: MockAdapter) { await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(AgentRegistry) await ctx.plugin(AgentLoop, { agents: [] }) ctx.llm.registerAdapter(['mock'], adapter) diff --git a/packages/core/agent-loop/tests/interception.spec.ts b/packages/core/agent-loop/tests/interception.spec.ts index 352583df36..0f0efc14b0 100644 --- a/packages/core/agent-loop/tests/interception.spec.ts +++ b/packages/core/agent-loop/tests/interception.spec.ts @@ -1,3 +1,4 @@ +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import LlmRuntime, { createUserMessage, ToolCallId } from '@deepseek-ai/dsh-llm' @@ -9,7 +10,8 @@ import SessionStore, { type UserMessage, } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRuntime, { defineContentToolFixture, type PostToolDecision, type PreToolDecision } from '@deepseek-ai/dsh-tools' +import ToolRuntime, { type PostToolDecision, type PreToolDecision } from '@deepseek-ai/dsh-tools' +import { defineContentToolFixture } from '@deepseek-ai/dsh-tool-testkit' import AgentRegistry, { type Agent, type PreStepDecision, @@ -47,6 +49,7 @@ async function harness(adapter: MockAdapter) { await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(AgentRegistry) await ctx.plugin(AgentLoop, { agents: [] }) ctx.llm.registerAdapter(['mock'], adapter) diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts index dbe1ef6564..170de98fb6 100644 --- a/packages/core/agent-loop/tests/loop.spec.ts +++ b/packages/core/agent-loop/tests/loop.spec.ts @@ -1,3 +1,4 @@ +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { describe, expect, it, onTestFinished, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import LlmRuntime, { createUserMessage, ToolCallId, LlmError, ReasoningEffortId, StreamChunk, expandAssistantStream } from '@deepseek-ai/dsh-llm' @@ -5,7 +6,8 @@ import type { ContextFormed } from '@deepseek-ai/dsh-llm' import type { GenerateOptions } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId, TurnEndReason } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRuntime, { defineContentToolFixture } from '@deepseek-ai/dsh-tools' +import ToolRuntime from '@deepseek-ai/dsh-tools' +import { defineContentToolFixture } from '@deepseek-ai/dsh-tool-testkit' import AgentRegistry, { type Agent, type AssistantStreamFrame } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' @@ -35,6 +37,7 @@ async function harness(adapter: MockAdapter, persona = '') { await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt, { personaPrefix: persona }) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(AgentRegistry) await ctx.plugin(AgentLoop, { agents: [] }) ctx.llm.registerAdapter(['mock'], adapter) @@ -1905,6 +1908,7 @@ describe('agent loop', () => { await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(AgentRegistry) await ctx.plugin(AgentLoop, { agents: [{ id: SessionId('config-agent'), provider: 'mock', model: 'mock', reasoningEffort: effort }], @@ -1935,6 +1939,7 @@ describe('agent loop', () => { await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(AgentRegistry) await ctx.plugin(AgentLoop, { agents: [{ id: SessionId('config-agent'), provider: 'mock', model: 'mock', cwd: '/work/project' }], diff --git a/packages/core/agent-loop/tests/properties.spec.ts b/packages/core/agent-loop/tests/properties.spec.ts index daea36f629..2ed219491f 100644 --- a/packages/core/agent-loop/tests/properties.spec.ts +++ b/packages/core/agent-loop/tests/properties.spec.ts @@ -9,6 +9,7 @@ * idle→running→idle, while teardown is a registry lifecycle. */ +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import LlmRuntime from '@deepseek-ai/dsh-llm' @@ -43,6 +44,7 @@ async function harness() { await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(AgentRegistry) await ctx.plugin(AgentLoop, { agents: [] }) ctx.llm.registerAdapter(['mock'], new EchoAdapter()) diff --git a/packages/core/agent-loop/tests/request-cache.e2e.ts b/packages/core/agent-loop/tests/request-cache.e2e.ts index e36281607d..9e68d64401 100644 --- a/packages/core/agent-loop/tests/request-cache.e2e.ts +++ b/packages/core/agent-loop/tests/request-cache.e2e.ts @@ -1,10 +1,12 @@ +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { createUserMessage } from '@deepseek-ai/dsh-llm' import { afterEach, describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import LlmRuntime from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRuntime, { defineContentToolFixture } from '@deepseek-ai/dsh-tools' +import ToolRuntime from '@deepseek-ai/dsh-tools' +import { defineContentToolFixture } from '@deepseek-ai/dsh-tool-testkit' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' @@ -45,6 +47,7 @@ async function loopHarness(): Promise { await created.plugin(SessionProjectionRegistry) await created.plugin(SystemPrompt, { personaPrefix: SYSTEM }) await created.plugin(ToolRuntime) + await created.plugin(NativeToolExecutor) await created.plugin(AgentRegistry) await created.plugin(AgentLoop, { agents: [] }) await created.plugin(LlmDeepSeek) diff --git a/packages/core/agent-loop/tests/request-error.spec.ts b/packages/core/agent-loop/tests/request-error.spec.ts index 616a817082..5287cb4deb 100644 --- a/packages/core/agent-loop/tests/request-error.spec.ts +++ b/packages/core/agent-loop/tests/request-error.spec.ts @@ -1,3 +1,4 @@ +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import AgentRegistry from '@deepseek-ai/dsh-agent' @@ -17,6 +18,7 @@ async function harness(adapter: MockAdapter): Promise { await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(AgentRegistry) await ctx.plugin(AgentLoop, { agents: [] }) ctx.llm.registerAdapter(['mock'], adapter) diff --git a/packages/core/agent-loop/tests/request-reconstruction.spec.ts b/packages/core/agent-loop/tests/request-reconstruction.spec.ts index 93b398cd99..8b8034d93d 100644 --- a/packages/core/agent-loop/tests/request-reconstruction.spec.ts +++ b/packages/core/agent-loop/tests/request-reconstruction.spec.ts @@ -5,6 +5,7 @@ * replacement or header change explains the difference. */ +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import LlmRuntime, { createUserMessage, LlmError, ReasoningEffortId } from '@deepseek-ai/dsh-llm' @@ -12,7 +13,8 @@ import type { GenerateOptions, LlmModelReasoningInfo, LlmResolvedModelInfo, Stre import type { ContextFormed } from '@deepseek-ai/dsh-llm' import SessionStore, { Session, SessionId, foldRequestHeader } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRuntime, { defineContentToolFixture } from '@deepseek-ai/dsh-tools' +import ToolRuntime from '@deepseek-ai/dsh-tools' +import { defineContentToolFixture } from '@deepseek-ai/dsh-tool-testkit' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' @@ -41,6 +43,7 @@ async function harnessRoutes( await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt, { personaPrefix: persona }) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(AgentRegistry) await ctx.plugin(AgentLoop, { agents: [] }) for (const [provider, adapter] of adapters) ctx.llm.registerAdapter([provider], adapter) @@ -343,6 +346,7 @@ describe('request stability across the loop', () => { await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt, { personaPrefix: 'stable base' }) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(AgentRegistry) await ctx.plugin(AgentLoop, { agents: [] }) const started = Promise.withResolvers() @@ -462,6 +466,7 @@ describe('request stability across the loop', () => { await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt, { personaPrefix: 'stable base' }) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(AgentRegistry) await ctx.plugin(AgentLoop, { agents: [] }) let observed: GenerateOptions | undefined diff --git a/packages/core/agent-loop/tests/resource-lifecycle.spec.ts b/packages/core/agent-loop/tests/resource-lifecycle.spec.ts index eececf3f92..991746f567 100644 --- a/packages/core/agent-loop/tests/resource-lifecycle.spec.ts +++ b/packages/core/agent-loop/tests/resource-lifecycle.spec.ts @@ -1,3 +1,4 @@ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' import { setImmediate } from 'node:timers/promises' import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' @@ -113,7 +114,7 @@ it.each(['loop', 'root'] as const)('unloads %s while a background child is stopp await release.promise return next() }) - const started = await ctx.tools.execute({ + const started = await executeToolFixture(ctx, { agent: parent, signal: new AbortController().signal, callId: ToolCallId('background-start'), name: 'subagent', arguments: { description: 'child', prompt: 'wait' }, diff --git a/packages/core/agent-loop/tests/resume.spec.ts b/packages/core/agent-loop/tests/resume.spec.ts index bd60cda20c..dc920d11bb 100644 --- a/packages/core/agent-loop/tests/resume.spec.ts +++ b/packages/core/agent-loop/tests/resume.spec.ts @@ -1,3 +1,4 @@ +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { ToolCallId, createMessage, createUserMessage } from '@deepseek-ai/dsh-llm' import { afterEach, describe, expect, it, vi, type MockInstance } from 'vitest' import { Context } from '@deepseek-ai/cordis' @@ -9,7 +10,8 @@ import type { ContextFormed } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionLogOffset, SessionSeq, Session, SessionId, TOOL_NOT_STARTED, TOOL_OUTCOME_UNKNOWN } from '@deepseek-ai/dsh-session' import type { SessionEvent } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRuntime, { defineContentToolFixture, TOOL_RUNTIME_SCHEDULER } from '@deepseek-ai/dsh-tools' +import ToolRuntime from '@deepseek-ai/dsh-tools' +import { defineContentToolFixture } from '@deepseek-ai/dsh-tool-testkit' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import { SessionAlreadyExistsError, type SessionHandle } from '@deepseek-ai/dsh-session-persistence' @@ -40,6 +42,7 @@ async function mountPersistentHarness(root: string, adapter: MockAdapter, compre await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(AgentRegistry) // The backend mounts BEFORE the loop so root teardown unwinds the loop // first: live agents drain their writers into still-open handles. @@ -934,6 +937,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(AgentRegistry) const loopFiber = await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(JsonlSessionPersistence, { root }) @@ -1053,9 +1057,17 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', name: 'probe', description: 'probe', parameters: {}, async execute() { return [{ type: 'text', text: 'unexpected execution' }] }, })) - first.tools[TOOL_RUNTIME_SCHEDULER].prepare = () => { throw new Error('scheduler prepare failed') } const sessionId = SessionId('failed-tool-history') const initial = await first.agents.create({ sessionId, agentOptions: { provider: 'mock', model: 'mock' } }) + const executor = new NativeToolExecutor.NativeToolExecutor(initial.agent.ctx) + executor.execute = async (input) => { + if (input.log === undefined || input.agent === undefined) throw new Error('test expects a logged Agent step') + const { agent, log: { turn, step } } = input + const call = input.calls[0]! + agent.session.append('tool/call', { turn, step, callId: call.id, name: call.name, arguments: call.arguments }) + throw new Error('executor failed after accepting its first call') + } + initial.agent.ctx.tools.setExecutor(executor) const failed = waitForIdle(first, initial.agent) initial.agent.followup(createUserMessage({ content: [{ type: 'text', text: 'try tools' }], source: { kind: 'user' } })) await failed @@ -1069,7 +1081,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', event.data.message.toolCallId, event.data.error?.code, ])).toEqual([[ToolCallId('started'), TOOL_OUTCOME_UNKNOWN], [ToolCallId('pending'), TOOL_NOT_STARTED]]) expect(stored.at(-1)).toMatchObject({ - type: 'turn/end', data: { reason: { kind: 'error', error: { code: 'UNKNOWN', message: 'scheduler prepare failed' } } }, + type: 'turn/end', data: { reason: { kind: 'error', error: { code: 'UNKNOWN', message: 'executor failed after accepting its first call' } } }, }) const continued = mode === 'resume' @@ -1149,6 +1161,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(AgentRegistry) await ctx.plugin(AgentLoop, { agents: [] }) ctx.llm.registerAdapter(['mock'], adapter) @@ -1417,6 +1430,7 @@ describe('configured-start failure edges', () => { await configured.plugin(SessionProjectionRegistry) await configured.plugin(SystemPrompt) await configured.plugin(ToolRuntime) + await configured.plugin(NativeToolExecutor) await configured.plugin(AgentRegistry) await configured.plugin(JsonlSessionPersistence, { root }) configured.llm.registerAdapter(['mock'], new MockAdapter([])) @@ -1451,6 +1465,7 @@ describe('configured-start failure edges', () => { await configured.plugin(SessionProjectionRegistry) await configured.plugin(SystemPrompt) await configured.plugin(ToolRuntime) + await configured.plugin(NativeToolExecutor) await configured.plugin(AgentRegistry) await configured.plugin(JsonlSessionPersistence, { root }) configured.llm.registerAdapter(['mock'], new MockAdapter([])) diff --git a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts index 5b61d9722f..a0b1a0a9c6 100644 --- a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts +++ b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts @@ -1,10 +1,12 @@ +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { createUserMessage } from '@deepseek-ai/dsh-llm' import { describe, expect, it, onTestFinished, vi } from 'vitest' import { Context, symbols, type EffectMeta, type Fiber } from '@deepseek-ai/cordis' import LlmRuntime from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRuntime, { defineContentToolFixture } from '@deepseek-ai/dsh-tools' +import ToolRuntime from '@deepseek-ai/dsh-tools' +import { defineContentToolFixture } from '@deepseek-ai/dsh-tool-testkit' import AgentRegistry, { agentEvents, assembleContextFor } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' @@ -21,6 +23,7 @@ async function harnessWithLoop(adapter: MockAdapter = new MockAdapter([textRespo await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt, { personaPrefix: 'You are the deployment.' }) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(AgentRegistry) const loopFiber = await ctx.plugin(AgentLoop, { agents: [] }) ctx.llm.registerAdapter(['mock'], adapter) diff --git a/packages/core/agent-loop/tests/settings.spec.ts b/packages/core/agent-loop/tests/settings.spec.ts index 4a0fd1c154..daa715a3f9 100644 --- a/packages/core/agent-loop/tests/settings.spec.ts +++ b/packages/core/agent-loop/tests/settings.spec.ts @@ -1,5 +1,6 @@ /** The `agent-loop` settings section layered over the composition entry. */ +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { expect, it, onTestFinished } from 'vitest' import { Context } from '@deepseek-ai/cordis' import LlmRuntime from '@deepseek-ai/dsh-llm' @@ -11,7 +12,7 @@ import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { liveConfig } from '../../../settings/settings/tests/live-config.ts' import AgentLoop from '@deepseek-ai/dsh-agent-loop' -async function boot() { +async function boot(maxParallelToolCalls?: number) { const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) @@ -20,13 +21,14 @@ async function boot() { await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) onTestFinished(() => ctx.fiber.dispose()) - const live = await liveConfig(ctx, AgentLoop, { agents: [], maxParallelToolCalls: 4 }) + await ctx.plugin(NativeToolExecutor) + const live = await liveConfig(ctx, AgentLoop, { agents: [], ...maxParallelToolCalls === undefined ? {} : { maxParallelToolCalls } }) return { ctx, live } } it('updates future scheduler budgets while preserving composed agents and the running loop', async () => { - const { ctx, live } = await boot() + const { ctx, live } = await boot(4) const before = live.fiber await live.update({ maxParallelToolCalls: 1 }) expect(live.entry.fiber === before).toBe(true) @@ -35,3 +37,18 @@ it('updates future scheduler budgets while preserving composed agents and the ru await expect(live.update({ maxParallelToolCalls: 0 })).rejects.toThrow() expect(ctx.agentLoop.config.maxParallelToolCalls.get()).toBe(1) }) + +it('defaults to ten and restores that default without remounting the loop', async () => { + const { ctx, live } = await boot() + const loop = ctx.agentLoop + expect(loop.config.maxParallelToolCalls.get()).toBe(10) + await live.update({ maxParallelToolCalls: 2 }) + expect(loop.config.maxParallelToolCalls.get()).toBe(2) + await expect(live.update({ maxParallelToolCalls: 0 })).rejects.toThrow() + await expect(live.update({ maxParallelToolCalls: 1.5 })).rejects.toThrow() + expect(loop.config.maxParallelToolCalls.get()).toBe(2) + await live.replace({ agents: [] }) + expect(loop.config.maxParallelToolCalls.get()).toBe(10) + expect(ctx.agentLoop.config.maxParallelToolCalls.get()).toBe(10) + expect(live.entry.fiber === live.fiber).toBe(true) +}) diff --git a/packages/core/agent-loop/tests/shutdown-drain.spec.ts b/packages/core/agent-loop/tests/shutdown-drain.spec.ts index d89928395b..553cc05bba 100644 --- a/packages/core/agent-loop/tests/shutdown-drain.spec.ts +++ b/packages/core/agent-loop/tests/shutdown-drain.spec.ts @@ -1,5 +1,6 @@ /** Root-fiber shutdown drains buffered session events durably (both mount orders). */ +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { describe, expect, it, afterEach } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { mkdtemp, rm } from 'node:fs/promises' @@ -36,6 +37,7 @@ async function mount(order: 'backend-first' | 'loop-first'): Promise<{ ctx: Cont await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(AgentRegistry) if (order === 'backend-first') { await ctx.plugin(JsonlSessionPersistence, { root }) diff --git a/packages/core/agent-loop/tests/system-prompt-admission.spec.ts b/packages/core/agent-loop/tests/system-prompt-admission.spec.ts index a5c9bb3838..641a51951b 100644 --- a/packages/core/agent-loop/tests/system-prompt-admission.spec.ts +++ b/packages/core/agent-loop/tests/system-prompt-admission.spec.ts @@ -1,3 +1,4 @@ +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import AgentRegistry, { type Agent, type ModelSelection } from '@deepseek-ai/dsh-agent' @@ -8,7 +9,8 @@ import { toPiContext } from '@deepseek-ai/dsh-llm-pi-ai/src/context.ts' import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRuntime, { defineContentToolFixture } from '@deepseek-ai/dsh-tools' +import ToolRuntime from '@deepseek-ai/dsh-tools' +import { defineContentToolFixture } from '@deepseek-ai/dsh-tool-testkit' import { MockAdapter, textResponse } from './mock-adapter.ts' declare module '@deepseek-ai/dsh-llm' { @@ -30,6 +32,7 @@ async function harness(capable = new MockAdapter(Array.from({ length: 8 }, () => await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt, { personaPrefix: '', personaSuffix: '' }) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(AgentRegistry) await ctx.plugin(AgentLoop, { agents: [] }) capable.systemPromptUpdate = 'in-history' diff --git a/packages/core/agent-loop/tests/tool-calls.spec.ts b/packages/core/agent-loop/tests/tool-calls.spec.ts index 8aa74cec2d..d08e9f6bca 100644 --- a/packages/core/agent-loop/tests/tool-calls.spec.ts +++ b/packages/core/agent-loop/tests/tool-calls.spec.ts @@ -2,7 +2,10 @@ * Exercises scheduler ordering and cancellation with deterministic gated tools. * ACP expected outputs own transcript-facing coverage. */ +import type { ToolExecutionPipeline } from '@deepseek-ai/dsh-tools/pipeline' +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' +import * as PtcToolExecutor from '@deepseek-ai/dsh-tool-executor-ptc' import { describe, expect, it, onTestFinished } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { createUserMessage, ToolCallId, StreamChunk } from '@deepseek-ai/dsh-llm' @@ -10,7 +13,8 @@ import SessionStore, { SessionEvent, SessionId, TOOL_NOT_STARTED, TOOL_OUTCOME_U import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import LlmRuntime from '@deepseek-ai/dsh-llm' import type { ContextFormed } from '@deepseek-ai/dsh-llm' -import ToolRuntime, { defineContentToolFixture, TOOL_ABORTED_BEFORE_DISPATCH, TOOL_RUNTIME_SCHEDULER, type PostToolDecision, type PreToolDecision } from '@deepseek-ai/dsh-tools' +import ToolRuntime, { TOOL_ABORTED_BEFORE_DISPATCH, type PostToolDecision, type PreToolDecision } from '@deepseek-ai/dsh-tools' +import { defineContentToolFixture } from '@deepseek-ai/dsh-tool-testkit' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' @@ -24,20 +28,29 @@ declare module '@deepseek-ai/dsh-llm' { } } -async function harness(adapter: MockAdapter, maxParallelToolCalls?: number) { +async function harness(adapter: MockAdapter, maxParallelToolCalls?: number, executorLimit?: number) { const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt, { personaPrefix: '' }) await ctx.plugin(ToolRuntime) + let toolPipeline!: ToolExecutionPipeline + await ctx.plugin({ + inject: ['tools'], + apply(owner: Context) { + const executor = new NativeToolExecutor.NativeToolExecutor(owner, () => executorLimit ?? 10) + toolPipeline = executor['pipeline'] + owner.tools.setExecutor(executor) + }, + }) await ctx.plugin(AgentRegistry) await ctx.plugin(AgentLoop, { agents: [], ...maxParallelToolCalls === undefined ? {} : { maxParallelToolCalls }, }) ctx.llm.registerAdapter(['mock'], adapter) - return ctx + return Object.assign(ctx, { toolPipeline }) } function waitForIdle(ctx: Context, agent: Agent): Promise { @@ -271,6 +284,46 @@ describe('tool-call scheduler: model-order results despite out-of-order settleme }) describe('tool-call scheduler: rolling pool honors maxParallelToolCalls', () => { + it.each([ + { executorLimit: 1, loopLimit: 2, expected: 2 }, + { executorLimit: 3, loopLimit: 1, expected: 1 }, + { executorLimit: 2, loopLimit: undefined, expected: 10 }, + ])('uses $expected parallel calls with executor $executorLimit and loop limit $loopLimit', async ({ executorLimit, loopLimit, expected }) => { + const adapter = new MockAdapter([ + multiCall(Array.from({ length: 12 }, (_, n) => ({ id: `c${n}`, name: 'overlap', args: {} }))), + textResponse('done'), + ]) + const ctx = await harness(adapter, loopLimit, executorLimit) + onTestFinished(() => ctx.fiber.dispose()) + let active = 0 + let peak = 0 + ctx.tools.register(defineContentToolFixture({ + name: 'overlap', description: 'Observe simultaneous tool bodies.', parameters: {}, + isConcurrencySafe: () => true, + async execute() { + peak = Math.max(peak, ++active) + // Let the scheduler start every available sibling before any body settles. + await new Promise(resolve => setImmediate(resolve)) + active-- + return [{ type: 'text', text: 'done' }] + }, + })) + const agent = await ctx.agentLoop.create(SessionId('override'), { provider: 'mock', model: 'mock' }) + const idle = waitForIdle(ctx, agent) + agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) + await idle + expect(peak).toBe(expected) + expect(active).toBe(0) + expect(events(agent).filter(event => event.type === 'tool/result')).toHaveLength(12) + }) + + it.each([0, 1.5])('rejects an invalid per-call concurrency override %s', async (maxParallelToolCalls) => { + const ctx = await harness(new MockAdapter([])) + onTestFinished(() => ctx.fiber.dispose()) + await expect(ctx.tools.execute({ calls: [], signal: new AbortController().signal, maxParallelToolCalls })) + .rejects.toThrow('maxParallelToolCalls must be a positive integer') + }) + it('rejects invalid global maxParallelToolCalls config at plugin load', async () => { await expect(harness(new MockAdapter([]), 0)).rejects.toThrow() await expect(harness(new MockAdapter([]), 1.5)).rejects.toThrow() @@ -338,6 +391,7 @@ describe('tool-call scheduler: rolling pool honors maxParallelToolCalls', () => await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt, { personaPrefix: '' }) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(AgentRegistry) await ctx.plugin(AgentLoop, { agents: [], maxParallelToolCalls: 1 }) ctx.llm.registerAdapter(['mock'], adapter) @@ -631,7 +685,7 @@ describe('tool-call scheduler: abort handling', () => { }) describe('tool-call scheduler: failure quiescence', () => { - it.each(['execution-mode', 'prepare', 'finalize'] as const)('pairs outstanding requests after %s fails and preserves committed results', async (phase) => { + it.each(['execution-mode', 'prepare', 'complete'] as const)('pairs outstanding requests after %s fails and preserves committed results', async (phase) => { const adapter = new MockAdapter([ multiCall([ { id: 'c1', name: 'p', args: { id: '1' } }, @@ -645,18 +699,21 @@ describe('tool-call scheduler: failure quiescence', () => { const executed: string[] = [] ctx.tools.register(defineContentToolFixture({ name: 'p', description: 'exclusive', parameters: { id: { type: 'string', required: true } }, - async execute(args) { + async execute(args, exec) { executed.push(args.id) + if (args.id === '1') { + exec.deferContext(createUserMessage({ content: [{ type: 'text', text: 'committed context' }], source: { kind: 'p' } })) + } return [{ type: 'text', text: `done-${args.id}` }] }, })) - const scheduler = ctx.tools[TOOL_RUNTIME_SCHEDULER] - const executionMode = ctx.tools.executionMode.bind(ctx.tools) + const scheduler = ctx.toolPipeline + const executionMode = ctx.toolPipeline.executionMode.bind(ctx.toolPipeline) const prepare = scheduler.prepare.bind(scheduler) - const finalize = scheduler.finalize.bind(scheduler) + const complete = scheduler.complete.bind(scheduler) const failure = new Error(`${phase} failed`) if (phase === 'execution-mode') { - ctx.tools.executionMode = (exec) => { + ctx.toolPipeline.executionMode = (exec) => { if (exec.callId === ToolCallId('c2')) throw failure return executionMode(exec) } @@ -666,9 +723,9 @@ describe('tool-call scheduler: failure quiescence', () => { return prepare(exec) } } else { - scheduler.finalize = (exec, result) => { + scheduler.complete = (exec, outcome) => { if (exec.callId === ToolCallId('c2')) throw failure - return finalize(exec, result) + return complete(exec, outcome) } } const agent = await ctx.agentLoop.create(SessionId(`failure-${phase}`), { provider: 'mock', model: 'mock' }) @@ -681,7 +738,7 @@ describe('tool-call scheduler: failure quiescence', () => { const failedEvents = events(agent) const calls = failedEvents.filter(event => event.type === 'tool/call') const results = failedEvents.filter(event => event.type === 'tool/result') - expect(executed).toEqual(phase === 'finalize' ? ['1', '2'] : ['1']) + expect(executed).toEqual(phase === 'complete' ? ['1', '2'] : ['1']) expect(results.map(event => [event.data.message.toolCallId, event.data.error?.code])).toEqual([ [ToolCallId('c1'), undefined], [ToolCallId('c2'), phase === 'execution-mode' ? TOOL_NOT_STARTED : TOOL_OUTCOME_UNKNOWN], @@ -702,9 +759,9 @@ describe('tool-call scheduler: failure quiescence', () => { }) expect(listenerCount()).toBe(idleListenerCount) - ctx.tools.executionMode = executionMode + ctx.toolPipeline.executionMode = executionMode scheduler.prepare = prepare - scheduler.finalize = finalize + scheduler.complete = complete const nextTurn = waitForIdle(ctx, agent) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'continue' }], source: { kind: 'user' } })) await nextTurn @@ -716,7 +773,10 @@ describe('tool-call scheduler: failure quiescence', () => { if (message.role === 'user' && block.type === 'text') return [`user:${block.text}`] return [] }) - })).toEqual(['user:go', 'call:c1', 'call:c2', 'call:c3', 'result:c1', 'result:c2', 'result:c3', 'user:continue']) + })).toEqual([ + 'user:go', 'call:c1', 'call:c2', 'call:c3', 'result:c1', 'result:c2', 'result:c3', + 'user:committed context', 'user:continue', + ]) expect(events(agent).filter(event => event.type === 'tool/result')).toHaveLength(3) expect(events(agent).findLast(event => event.type === 'turn/end')?.data.reason.kind).toBe('completed') expect(listenerCount()).toBe(idleListenerCount) @@ -728,7 +788,7 @@ describe('tool-call scheduler: failure quiescence', () => { onTestFinished(() => ctx.fiber.dispose()) const failure = new Error('prepare failed') const recoveryFailure = new Error('recovery result rejected') - ctx.tools[TOOL_RUNTIME_SCHEDULER].prepare = () => { throw failure } + ctx.toolPipeline.prepare = () => { throw failure } ctx.on('internal/dispatch', (_mode, name, args) => { if (name === 'session/event' && (args[1] as SessionEvent).type === 'tool/result') throw recoveryFailure }) @@ -763,7 +823,7 @@ describe('tool-call scheduler: failure quiescence', () => { name: 'p', description: 'probe', parameters: {}, async execute() { return [{ type: 'text', text: 'completed' }] }, })) - const scheduler = ctx.tools[TOOL_RUNTIME_SCHEDULER] + const scheduler = ctx.toolPipeline const dispatch = scheduler.dispatch.bind(scheduler) scheduler.dispatch = async (exec) => { if (exec.agent?.id === SessionId('first-agent')) { @@ -818,7 +878,7 @@ describe('tool-call scheduler: failure quiescence', () => { ctx.tools.register(gated.tool) // The registry contains expected failures as results; replace its internal // view only to inject the invariant violation this boundary must contain. - const scheduler = ctx.tools[TOOL_RUNTIME_SCHEDULER] + const scheduler = ctx.toolPipeline const prepare = scheduler.prepare.bind(scheduler) const dispatch = scheduler.dispatch.bind(scheduler) const prepareGate = Promise.withResolvers() @@ -897,7 +957,8 @@ describe('PTC mode native-tool denial through the agent loop', () => { await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt, { personaPrefix: '' }) - await ctx.plugin(ToolRuntime, { mode: 'ptc' }) + await ctx.plugin(ToolRuntime) + await ctx.plugin(PtcToolExecutor) // eslint-disable-next-line @typescript-eslint/no-explicit-any -- FakePtcRuntime is an internal test helper with an opaque type shape await ctx.plugin(FakePtcRuntime as any) await ctx.plugin(AgentRegistry) diff --git a/packages/core/agent-loop/tests/tool-executors.spec.ts b/packages/core/agent-loop/tests/tool-executors.spec.ts new file mode 100644 index 0000000000..eaf3a4726d --- /dev/null +++ b/packages/core/agent-loop/tests/tool-executors.spec.ts @@ -0,0 +1,329 @@ +/** Scoped executor selection and lifetime through the real Agent loop. */ +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' +import { expect, it, onTestFinished } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import type { Agent } from '@deepseek-ai/dsh-agent' +import LlmRuntime, { createUserMessage, createToolResultMessage, ToolCallId } from '@deepseek-ai/dsh-llm' +import { bindScopeParent, createScope, type Scope, type ScopeKey } from '@deepseek-ai/dsh-scope' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import SessionProjections from '@deepseek-ai/dsh-session-projection' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import type { AssembleContext, PromptAssembly } from '@deepseek-ai/dsh-system-prompt' +import Tools, { defineTool, ToolsService } from '@deepseek-ai/dsh-tools' +import type { ToolExecutor, ToolBatchInput, ToolExecutionToken, ToolRunContext } from '@deepseek-ai/dsh-tools' +import AgentLoop from '../src/index.ts' +import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts' + +/** An independent execution implementation: no PTC executor inheritance or delegation. */ +function independent(ctx: Context, name: string, trace: string[]): ToolExecutor { + const transport = defineTool({ + name, description: `Execute through ${name}.`, parameters: {}, + output: { schema: { type: 'string' }, render: (_args, value) => [{ type: 'text', text: value }] }, + async execute(_args, exec) { + const tool = ctx.tools.get('echo', exec.agent) + if (tool === undefined) throw new Error('echo is not visible') + return `${name}:${String(await tool.execute({}, exec))}` + }, + }) + const executor: ToolExecutor = { + sections: [ + { name: 'tools:transport', order: 800, text: `Call ${name}.`, interpolate: false }, + { name: 'tools:sdk', order: 5000, text: tools => tools.map(tool => tool.name).join(', '), interpolate: false }, + ], + describe: () => ({ schemas: [transport], knownNames: [name] }), + async execute(input) { + trace.push(`${name}:execute`) + const results = [] + for (const call of input.calls) { + if (call.name !== name) throw new Error(`unexpected transport ${call.name}`) + const callSeq = input.log === undefined ? undefined : input.agent!.session.append('tool/call', { + turn: input.log.turn, step: input.log.step, + callId: call.id, name: call.name, arguments: call.arguments, + }).seq + const exec: ToolRunContext = { + callId: call.id, name: call.name, arguments: JSON.parse(call.arguments), signal: input.signal, + ...input.agent === undefined ? {} : { agent: input.agent }, + rootCallId: call.id, token: Symbol() as ToolExecutionToken, + deferContext() {}, concludeTurn() {}, + } + const value = String(await transport.execute(exec.arguments, exec)) + const result = { isError: false as const, value, content: [{ type: 'text' as const, text: value }] } + if (input.log !== undefined) { + const { agent, log: { turn, step } } = input + agent!.session.append('tool/result', { + turn, step, message: createToolResultMessage({ callId: call.id, content: result.content, isError: false }), + }, { surfaceOp: 'append', ...callSeq === undefined ? {} : { sourceEventSeqs: [callSeq] } }) + } + results.push({ callId: call.id, result }) + } + return { results, additionalContexts: [], concluded: false } + }, + } + return executor +} + +async function setup(adapter = new MockAdapter([]), maxParallelToolCalls?: number) { + const ctx = new Context() + onTestFinished(() => ctx.fiber.dispose()) + await ctx.plugin(LlmRuntime) + await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjections) + await ctx.plugin(SystemPrompt) + await ctx.plugin(Tools) + await ctx.plugin(NativeToolExecutor) + await ctx.plugin(AgentRegistry) + await ctx.plugin(AgentLoop, { agents: [], ...maxParallelToolCalls === undefined ? {} : { maxParallelToolCalls } }) + ctx.llm.registerAdapter(['mock'], adapter) + ctx.tools.register(defineTool({ + name: 'echo', description: 'Shared capability.', parameters: {}, + output: { schema: { type: 'string' }, render: (_args, value) => [{ type: 'text', text: value }] }, + execute: async () => 'shared', + })) + return ctx +} + +async function scoped(ctx: Context, key: ScopeKey): Promise { + let scope!: Scope + await ctx.plugin(Object.assign((inner: Context) => { scope = createScope(inner, key) }, + { inject: ['tools', 'systemPrompt'] })) + return scope +} + +async function run(ctx: Context, agent: Agent): Promise { + const idle = new Promise((resolve) => { + const dispose = ctx.on('agent/status', (event) => { + if (event.agent === agent && event.status === 'idle') { dispose(); resolve() } + }) + }) + agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) + await idle +} + +it('shares the registry and selects an independent executor through the preset scope chain', async () => { + const trace: string[] = [] + const adapter = new MockAdapter([ + toolCallResponse('sdk-call', 'sdk_eval', {}), textResponse('sdk done'), + toolCallResponse('native-call', 'echo', {}), textResponse('native done'), + ]) + const ctx = await setup(adapter) + const presetKey = { preset: 'sdk' } + const preset = await scoped(ctx, presetKey) + preset.ctx.tools.setExecutor(independent(preset.ctx, 'sdk_eval', trace)) + const sdk = await ctx.agentLoop.create(SessionId('sdk'), { provider: 'mock', model: 'mock' }) + bindScopeParent(sdk, presetKey) + const native = await ctx.agentLoop.create(SessionId('native'), { provider: 'mock', model: 'mock' }) + expect(ctx.tools).toBeInstanceOf(ToolsService) + expect('registerExecutor' in ctx.tools).toBe(false) + expect(sdk.ctx.tools.get('echo', sdk)).toBe(ctx.tools.get('echo')) + expect(native.ctx.tools.get('echo', native)).toBe(ctx.tools.get('echo')) + await run(ctx, sdk) + await run(ctx, native) + expect(adapter.requests[0]?.tools?.map(tool => tool.name)).toEqual(['sdk_eval']) + expect(adapter.requests[2]?.tools?.map(tool => tool.name)).toEqual(['echo']) + expect(trace).toEqual(['sdk_eval:execute']) + expect(sdk.session.snapshotEvents().find(event => event.type === 'tool/result')).toMatchObject({ + data: { message: { content: [{ type: 'text', text: 'sdk_eval:shared' }], isError: false } }, + }) +}) + +it('dispatches through the current Agent scope after prompt listeners rebuild the assembly', async () => { + const trace: string[] = [] + const adapter = new MockAdapter([ + () => { selectNearer(); return toolCallResponse('held', 'first_eval', {}) }, + textResponse('done'), + ]) + const ctx = await setup(adapter) + ctx.on('system-prompt/assemble', async (_assembly, _context, next) => { + const { sections, contexts, tools, variables } = await next() + return { sections, contexts, tools, variables } + }) + const presetKey = { preset: 'first' } + const preset = await scoped(ctx, presetKey) + preset.ctx.tools.setExecutor(independent(preset.ctx, 'first_eval', trace)) + const agent = await ctx.agentLoop.create(SessionId('changing'), { provider: 'mock', model: 'mock' }) + bindScopeParent(agent, presetKey) + const selectNearer = () => { + const nearer = independent(agent.ctx, 'first_eval', trace) + const execute = nearer.execute.bind(nearer) + nearer.execute = (input) => { trace.push('nearer'); return execute(input) } + agent.ctx.tools.setExecutor(nearer) + } + await run(ctx, agent) + expect(adapter.requests[0]?.tools?.map(tool => tool.name)).toEqual(['first_eval']) + expect(adapter.requests[1]?.tools?.map(tool => tool.name)).toEqual(['first_eval']) + expect(trace).toEqual(['nearer', 'first_eval:execute']) +}) + +it('rejects new batches after unload while an accepted batch settles on its original executor', async () => { + const trace: string[] = [] + const ctx = await setup() + const key = { preset: 'temporary' } + const scope = await scoped(ctx, key) + const agent = await ctx.agentLoop.create(SessionId('temporary'), { provider: 'mock', model: 'mock' }) + bindScopeParent(agent, key) + const provider = independent(scope.ctx, 'old_eval', trace) + const entered = Promise.withResolvers() + const release = Promise.withResolvers() + onTestFinished(() => { release.resolve(undefined) }) + const execute = provider.execute.bind(provider) + provider.execute = async (input) => { entered.resolve(undefined); await release.promise; return execute(input) } + const dispose = scope.ctx.tools.setExecutor(provider) + const input = { + calls: [{ type: 'tool-call', name: 'old_eval', id: ToolCallId('accepted'), arguments: '{}' }], + signal: new AbortController().signal, + agent, + } satisfies ToolBatchInput + const pending = ctx.tools.execute(input) + await entered.promise + dispose() + await expect(ctx.systemPrompt.assemble({ scope: key })).rejects.toThrow('selected tool executor is unavailable') + await expect(ctx.tools.execute(input)).rejects.toThrow('selected tool executor is unavailable') + scope.ctx.tools.setExecutor(independent(scope.ctx, 'replacement_eval', trace)) + release.resolve(undefined) + const result = await pending + expect(result.results[0]?.result).toMatchObject({ isError: false, value: 'old_eval:shared' }) + const replacement = await ctx.tools.execute({ + ...input, + calls: [{ type: 'tool-call', name: 'replacement_eval', id: ToolCallId('replacement'), arguments: '{}' }], + }) + expect(replacement.results[0]?.result).toMatchObject({ isError: false, value: 'replacement_eval:shared' }) + expect(trace).toEqual(['old_eval:execute', 'replacement_eval:execute']) +}) + +it('refreshes executor projections without emitting tool registry changes', async () => { + const ctx = await setup() + let registryChanges = 0 + ctx.on('tools/change', () => { registryChanges++ }) + const key = { preset: 'refresh' } + const scope = await scoped(ctx, key) + const context: AssembleContext = { scope: key } + expect((await ctx.systemPrompt.assemble(context)).tools.map(tool => tool.name)).toEqual(['echo']) + const dispose = scope.ctx.tools.setExecutor(independent(scope.ctx, 'new_eval', [])) + expect((await ctx.systemPrompt.assemble(context)).tools.map(tool => tool.name)).toEqual(['new_eval']) + expect(registryChanges).toBe(0) + dispose() + await expect(ctx.systemPrompt.assemble(context)).rejects.toThrow('selected tool executor is unavailable') + expect(registryChanges).toBe(0) +}) + +it.each([ + ['occupied', 1, 'already registered'], + ['invalid-order', Number.NaN, 'order must be a finite number'], +] as const)('rolls back executor section registrations when %s is rejected', async (name, order, message) => { + const ctx = await setup() + const key = { preset: name } + const scope = await scoped(ctx, key) + scope.ctx.systemPrompt.section({ name: 'occupied', order: 1, text: 'Existing guidance.' }) + const executor: ToolExecutor = { + ...independent(scope.ctx, 'rejected_eval', []), + sections: [ + { name: 'transient', order: 0, text: 'Must be rolled back.' }, + { name, order, text: 'Invalid contribution.' }, + ], + } + expect(() => scope.ctx.tools.setExecutor(executor)).toThrow(message) + const assembly = await ctx.systemPrompt.assemble({ scope: key }) + expect(assembly.tools.map(tool => tool.name)).toEqual(['echo']) + expect(assembly.sections.map(section => section.name)).not.toContain('transient') +}) + +it('assembles independent tools and section text for overlapping prompt assemblies', async () => { + const ctx = await setup() + const key = { preset: 'overlapping' } + const scope = await scoped(ctx, key) + const context: AssembleContext = { scope: key } + let entered!: () => void + const reached = new Promise((resolve) => { entered = resolve }) + let release!: () => void + const resume = new Promise((resolve) => { release = resolve }) + let calls = 0 + ctx.on('system-prompt/assemble', async (_assembly, _context, next) => { + if (calls++ === 0) { entered(); await resume } + return { ...await next() } + }) + const pending = ctx.systemPrompt.assemble(context) + await reached + const replacement = independent(scope.ctx, 'next_eval', []) + const describe = replacement.describe.bind(replacement) + let projections = 0 + replacement.describe = (tools) => { projections++; return describe(tools) } + let second: PromptAssembly + try { + scope.ctx.tools.setExecutor(replacement) + second = await ctx.systemPrompt.assemble(context) + } finally { + release() + } + const first = await pending + expect(first.tools.map(tool => tool.name)).toEqual(['echo']) + expect(first).not.toHaveProperty('toolExecutor') + expect(second.tools.map(tool => tool.name)).toEqual(['next_eval']) + expect(second.sections).toContainEqual({ name: 'tools:transport', text: 'Call next_eval.', interpolate: false }) + expect(second.sections).toContainEqual({ name: 'tools:sdk', text: 'echo', interpolate: false }) + expect(projections).toBe(1) +}) + +it('keeps the foundation usable for registration and requires an explicit executor for calls', async () => { + const ctx = new Context() + onTestFinished(() => ctx.fiber.dispose()) + await ctx.plugin(SystemPrompt) + await ctx.plugin(Tools) + ctx.tools.register(defineTool({ + name: 'echo', description: 'Registered without an executor.', parameters: {}, + output: { schema: { type: 'string' }, render: (_args, value) => [{ type: 'text', text: value }] }, + execute: async () => 'shared', + })) + expect(ctx.tools.schemas().map(tool => tool.name)).toEqual(['echo']) + await expect(ctx.systemPrompt.assemble()).rejects.toThrow('selected tool executor is unavailable') + await expect(ctx.tools.execute({ + calls: [{ type: 'tool-call', name: 'echo', id: ToolCallId('unconfigured'), arguments: '{}' }], signal: new AbortController().signal, + })).rejects.toThrow('selected tool executor is unavailable') +}) + +it.each([undefined, 1, 6])('hands the executor raw calls and the resolved loop limit %s', async (maxParallelToolCalls) => { + const raw = ['{ "value": 1 }', '[2, 3]'] + const adapter = new MockAdapter([[ + { type: 'block-start', index: 0, blockType: 'tool-call' }, + { type: 'block-end', index: 0, block: { type: 'tool-call', id: ToolCallId('one'), name: 'custom', arguments: raw[0]! } }, + { type: 'block-start', index: 1, blockType: 'tool-call' }, + { type: 'block-end', index: 1, block: { type: 'tool-call', id: ToolCallId('two'), name: 'custom', arguments: raw[1]! } }, + { type: 'finish', reason: { kind: 'tool-calls' } }, + ], textResponse('done')]) + const ctx = await setup(adapter, maxParallelToolCalls) + const agent = await ctx.agentLoop.create(SessionId('raw-batch'), { provider: 'mock', model: 'mock' }) + const executor = independent(agent.ctx, 'custom', []) + const batches: ToolBatchInput[] = [] + const execute = executor.execute.bind(executor) + executor.execute = (input) => { batches.push(input); return execute(input) } + agent.ctx.tools.setExecutor(executor) + await run(ctx, agent) + expect(batches).toHaveLength(1) + expect(batches[0]?.calls.map(call => call.arguments)).toEqual(raw) + expect(batches[0]?.agent).toBe(agent) + expect(batches[0]?.log).toMatchObject({ turn: 1, step: 1 }) + expect(batches[0]?.maxParallelToolCalls).toBe(maxParallelToolCalls ?? 10) + expect(agent.session.snapshotEvents().filter(event => event.type === 'tool/result').map(event => event.data.message.toolCallId)) + .toEqual(['one', 'two']) +}) + +it('keeps Agent scope without a model step and logs raw calls only with a logging step', async () => { + const ctx = await setup() + const agent = await ctx.agentLoop.create(SessionId('batch-scope'), { provider: 'mock', model: 'mock' }) + agent.ctx.tools.restrict({ deny: ['echo'] }) + const input = { + calls: [{ type: 'tool-call', id: ToolCallId('scoped'), name: 'echo', arguments: '{ }' }], + signal: new AbortController().signal, + } satisfies ToolBatchInput + const scoped = await ctx.tools.execute({ ...input, agent }) + expect(scoped.results[0]?.result.error?.info?.code).toBe('UNKNOWN_TOOL') + expect(agent.session.snapshotEvents().filter(event => event.type === 'tool/call')).toHaveLength(0) + const owned = await ctx.tools.execute({ ...input, agent, log: { turn: 1, step: 1 } }) + expect(owned.results[0]?.result.error?.info?.code).toBe('UNKNOWN_TOOL') + const calls = agent.session.snapshotEvents().filter(event => event.type === 'tool/call') + expect(calls).toHaveLength(1) + expect(calls[0]?.data.arguments).toBe('{ }') + const unowned = await ctx.tools.execute(input) + expect(unowned.results[0]?.result).toMatchObject({ isError: false, value: 'shared' }) + expect(agent.session.snapshotEvents().filter(event => event.type === 'tool/call')).toHaveLength(1) +}) diff --git a/packages/core/agent-loop/tests/tool-order.spec.ts b/packages/core/agent-loop/tests/tool-order.spec.ts index 287f5fe761..8767b485e8 100644 --- a/packages/core/agent-loop/tests/tool-order.spec.ts +++ b/packages/core/agent-loop/tests/tool-order.spec.ts @@ -1,4 +1,3 @@ -import { createUserMessage } from '@deepseek-ai/dsh-llm' /** * Loop-level tool-order determinism: the request/header event — and therefore the frozen * request the adapter receives — carries the assembly's canonical tool order (system-prompt's @@ -6,6 +5,8 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm' * happened to register in. Registration order is a concurrent loading artifact * and must not leak downstream. */ +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' +import { createUserMessage } from '@deepseek-ai/dsh-llm' import { describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' @@ -13,7 +14,8 @@ import LlmRuntime from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId, foldRequestHeader } from '@deepseek-ai/dsh-session' import SystemPrompt, { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt' import type { Config as SystemPromptConfig } from '@deepseek-ai/dsh-system-prompt' -import ToolRuntime, { defineContentToolFixture } from '@deepseek-ai/dsh-tools' +import ToolRuntime from '@deepseek-ai/dsh-tools' +import { defineContentToolFixture } from '@deepseek-ai/dsh-tool-testkit' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' @@ -27,6 +29,7 @@ async function harness(adapter: MockAdapter, toolOrder?: SystemPromptConfig['too await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt, { personaPrefix: 'stable base', ...toolOrder !== undefined ? { toolOrder } : {} }) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(AgentRegistry) await ctx.plugin(AgentLoop, { agents: [] }) ctx.llm.registerAdapter(['mock'], adapter) diff --git a/packages/core/agent-loop/tests/tool-updates.spec.ts b/packages/core/agent-loop/tests/tool-updates.spec.ts index 6eca806085..bc192e179e 100644 --- a/packages/core/agent-loop/tests/tool-updates.spec.ts +++ b/packages/core/agent-loop/tests/tool-updates.spec.ts @@ -1,4 +1,5 @@ /** The loop logs tool changes as developer messages and carries session tool history to the runtime. */ +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { afterEach, describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' @@ -8,7 +9,8 @@ import type { GenerateOptions } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRuntime, { defineContentToolFixture } from '@deepseek-ai/dsh-tools' +import ToolRuntime from '@deepseek-ai/dsh-tools' +import { defineContentToolFixture } from '@deepseek-ai/dsh-tool-testkit' import { MockAdapter, textResponse } from './mock-adapter.ts' const contexts: Context[] = [] @@ -24,6 +26,7 @@ async function harness(adapter: MockAdapter) { await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt, { personaPrefix: '', personaSuffix: '' }) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(AgentRegistry) await ctx.plugin(AgentLoop, { agents: [] }) ctx.llm.registerAdapter(['mock'], adapter) diff --git a/packages/core/agent-tool-presentation/README.i18n.yaml b/packages/core/agent-tool-presentation/README.i18n.yaml index a1ca4c3c4e..ca8b3a37ca 100644 --- a/packages/core/agent-tool-presentation/README.i18n.yaml +++ b/packages/core/agent-tool-presentation/README.i18n.yaml @@ -6,23 +6,23 @@ en: 1c67fea11cb4a325 zh: 7dc1256148273b02 /deepseek-ai-dsh-agent-tool-presentation: - en: e3bfa19f60197528 - zh: c8e9adfb80141de2 + en: db5b1de2af3a6f37 + zh: e4b10a1484df8b29 /deepseek-ai-dsh-agent-tool-presentation/summary: - en: 9c67450fb880d639 - zh: 74b88ce5e4ac1e64 + en: 4e7556ae08aa7259 + zh: 1d178d483a426267 /deepseek-ai-dsh-agent-tool-presentation/table-of-contents: en: d152484eb41ac6b4 zh: 09388d293f9be9cb /deepseek-ai-dsh-agent-tool-presentation/use-this-package: - en: ed4e4b103ea482bf - zh: 41754979babb3649 + en: 9ad88c3b4d6e389e + zh: f5d78ac15598aabb /deepseek-ai-dsh-agent-tool-presentation/use-this-package/add-the-row-to-a-preset: en: 1550e3dcc5a3aa1e zh: 47b2d1e0e3e3cbf2 /deepseek-ai-dsh-agent-tool-presentation/use-this-package/what-ptc-mode-requires: - en: efaec011a8267e5f - zh: 32619d7e03c7c2ac + en: 4bc61559223f7d60 + zh: 3e11c92b13292d54 /deepseek-ai-dsh-agent-tool-presentation/use-this-package/one-presentation-per-agent: en: 46bf80f051175567 zh: 8f5e1623da5d1520 @@ -30,17 +30,17 @@ en: ce7e776e9d516bdb zh: 59d2751743db7795 /deepseek-ai-dsh-agent-tool-presentation/understand-the-implementation/design-concept: - en: 6f8bb6bd853cbc13 - zh: 422fca4b20b79fbd + en: 0fbb3dd32193e54c + zh: 5b3835aef5115bb7 /deepseek-ai-dsh-agent-tool-presentation/understand-the-implementation/source-map: - en: 2a0a0fb2dfdfce75 - zh: c425560fedcdb31a + en: 89ecdb3d5d032e7f + zh: 64ae2feb4344c679 /deepseek-ai-dsh-agent-tool-presentation/understand-the-implementation/behavior-notes: - en: 562acace386b25ca - zh: 3eb362bd2730a64c + en: d9300d0a040f3e70 + zh: 11969cc8c9e60ad0 /deepseek-ai-dsh-agent-tool-presentation/further-exploration: - en: 0f8de64b5862b78e - zh: 4bba0c1ab780aa54 + en: 6a1d0096e4da25e4 + zh: 9d5de770addb69d2 /deepseek-ai-dsh-agent-tool-presentation/model-experience: en: 35621b26757d462e zh: a8869139783ae475 @@ -48,8 +48,8 @@ en: c75b16e38577f026 zh: f802d115af4bf9a1 /deepseek-ai-dsh-agent-tool-presentation/known-limitations-and-deferred-work: - en: 17a0a213ec65c95f - zh: ff96d985f6360338 + en: 583b046bb6d61acf + zh: 155601a35aac8ca8 /deepseek-ai-dsh-agent-tool-presentation/known-limitations-and-deferred-work/dev-note: en: e67f633dc6fe7773 zh: 3bb0c20c93f16f98 diff --git a/packages/core/agent-tool-presentation/README.md b/packages/core/agent-tool-presentation/README.md index 430e272e71..b20d954b04 100644 --- a/packages/core/agent-tool-presentation/README.md +++ b/packages/core/agent-tool-presentation/README.md @@ -7,9 +7,11 @@ kind: "package-reference" English | [中文](README.zh.md) +> **Deprecated:** For new configurations, mount `@deepseek-ai/dsh-tool-executor-native` or `@deepseek-ai/dsh-tool-executor-ptc` directly. Existing configurations remain supported. To migrate, replace this selector row with the corresponding executor row and remove `config.mode`; do not load both in the same scope. + ## Summary -Use `dsh-agent-tool-presentation` in an [agent preset](../../preset/agent-preset-registry/README.md) to fix whether models see every native tool schema or only `run_code` with a generated SDK. Each preset can choose independently, so native and PTC agents can share one process without sharing tool catalogs. Selecting `ptc` requires a compatible PTC runtime; a deployment without one rejects the preset at mount time before its first prompt. The `mode` field is required when this package is present, while omitting the package keeps the deployment default. +Existing [agent presets](../../preset/agent-preset-registry/README.md) can keep using `dsh-agent-tool-presentation` to select whether models see every native tool schema or only `run_code` with a generated SDK. Each preset can choose independently, so native and PTC agents can share one process without sharing tool catalogs. Selecting `ptc` requires a compatible PTC runtime. The `mode` field is required when this package is present, while omitting the package keeps the deployment default. ## Table of Contents @@ -25,7 +27,7 @@ Use `dsh-agent-tool-presentation` in an [agent preset](../../preset/agent-preset ## Use this package -Add this row to an agent preset to fix how every agent joined to that preset sees its tools. `native` presents each visible tool schema as a function definition; `ptc` presents only the `run_code` transport plus a generated SDK and the rule that only `run_code` may be called directly. Agents that declare nothing get the deployment-wide `mode` on the [`dsh-tools`](../tools/README.md) row. +Add this row to an agent preset to fix how every agent joined to that preset sees its tools. `native` presents each visible tool schema as a function definition; `ptc` presents only the `run_code` transport plus a generated SDK and the rule that only `run_code` may be called directly. Agents that declare nothing inherit the nearest executor mounted in their composition. ### Add the row to a preset @@ -43,7 +45,7 @@ The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-a ### What PTC mode requires -Selecting `ptc` needs a composed PTC runtime (`ctx.ptcRuntime`) whose language has a registered SDK renderer — the TypeScript runtime ships via [`dsh-ptc-runtime-node`](../../ptc-runtime/ptc-runtime-node/README.md), and both the TypeScript and Python SDK renderers are built into `dsh-tools`. A preset that selects a PTC mode against a deployment composing no such runtime refuses to mount, naming this row, so the failure lands where the operator can act instead of at the session's first request. +Selecting `ptc` needs a composed PTC runtime (`ctx.ptcRuntime`) whose language has a registered SDK renderer — the TypeScript runtime ships via [`dsh-ptc-runtime-node`](../../ptc-runtime/ptc-runtime-node/README.md), and both the TypeScript and Python SDK renderers are built into `dsh-tool-executor-ptc`. Before the runtime first becomes available, this compatibility selector leaves the inherited executor active. Mount `dsh-tool-executor-ptc` directly when preset activation must reject a missing runtime. ### One presentation per agent @@ -61,17 +63,17 @@ This section explains how the package realizes the behavior above; the observabl ### Design concept -The tool registry cannot move into a preset: its consumers are all host-plane — the agent loop reads its scheduler, the API proxy reads its presenters, and every tool plugin registers into it — and a service only moves down when all of its consumers move with it. What a preset can own is the presentation of that registry. `ctx.tools.presentAs()` declares it for the mounting scope, which is the preset's standing mount, so the declaration covers every agent joined to that preset and a PTC mode preset runs beside native ones in one process. One row per composition, not one per session. +The tool registry cannot move into a preset: its consumers are all host-plane — the agent loop resolves its executor, the API proxy reads its presenters, and every tool plugin registers into it — and a service only moves down when all of its consumers move with it. What a preset can own is the presentation of that registry. `ctx.tools.setExecutor()` declares an executor for the mounting scope, which is the preset's standing mount, so the declaration covers every agent joined to that preset and a PTC mode preset runs beside native ones in one process. One row per composition, not one per session. ### Source map | File | Role | |---|---| -| [`src/index.ts`](src/index.ts) | Plugin entry: `mode` config, `apply` wiring `ctx.tools.presentAs` for the mounting scope | +| [`src/index.ts`](src/index.ts) | Plugin entry: `mode` config, `apply` wiring `ctx.tools.setExecutor` for the mounting scope | ### Behavior notes -`native` applies immediately. A PTC mode instead waits for `ctx.ptcRuntime`, a host-plane service: a preset selecting PTC mode against a deployment composing no runtime holds this row pending, and `dsh-agent-preset-registry` refuses the mount naming this id. `presentAs` is itself the effect, so the declaration unwinds with this row without a second wrapper owning it. +The selected child plugin registers its executor when its required services are available. After registration, unloading the child or its runtime makes the selected executor unavailable until a replacement registers; calls do not fall back to an inherited executor. @@ -82,7 +84,7 @@ The tool registry cannot move into a preset: its consumers are all host-plane The package-level contract is enough for most consumers; read these when you need the surrounding domain. -- [tools package](../tools/README.md) — the tool presentation modes and `presentAs` API. +- [tools package](../tools/README.md) — the scoped registry and executor API. - [agent-presets package](../../preset/agent-preset-registry/README.md) — how presets compose agents and their standing mounts. - [Node ptc-runtime package](../../ptc-runtime/ptc-runtime-node/README.md) — the TypeScript runtime a PTC mode needs. - [historical PTC mode executor-collapse note](../../../.agents/notes/archived/bug-fix/2026-08-07-ptc-executor-collapse.md) — why the announced and callable surfaces stay the same. @@ -106,7 +108,7 @@ No direct invalidation; the presentation is fixed when the agent is composed, so These limits define when this row needs special care. They are current package constraints, not a task backlog. -- **The runtime stays host-plane** — a preset can select PTC mode but cannot supply the TypeScript runtime it needs; a deployment that composes none can compose no PTC preset. +- **The runtime stays host-plane** — a preset can select PTC mode but cannot supply the TypeScript runtime it needs; PTC selection remains pending until the deployment supplies a runtime. ### Dev Note diff --git a/packages/core/agent-tool-presentation/README.zh.md b/packages/core/agent-tool-presentation/README.zh.md index a7b63c2311..2b8bfc6ce7 100644 --- a/packages/core/agent-tool-presentation/README.zh.md +++ b/packages/core/agent-tool-presentation/README.zh.md @@ -7,9 +7,11 @@ kind: "package-reference" [English](README.md) | 中文 +> **已弃用:** 新配置请直接挂载 `@deepseek-ai/dsh-tool-executor-native` 或 `@deepseek-ai/dsh-tool-executor-ptc`。现有配置仍受支持。迁移时,将本选择器条目替换为对应的 executor 条目,并移除 `config.mode`;不要在同一作用域同时加载两者。 + ## 概述 -在 [agent preset](../../preset/agent-preset-registry/README.zh.md) 中使用 `dsh-agent-tool-presentation`,可固定模型看到全部原生工具 schema,还是只有带生成 SDK 的 `run_code`。每个 preset 可独立选择,因此 native 与 PTC agent 可以共享同一进程,而不共享工具目录。选择 `ptc` 需要兼容的 PTC 运行时;没有该运行时的部署会在挂载时拒绝 preset,不会等到收到第一条提示词。使用本包时 `mode` 字段为必填;省略本包则沿用部署默认值。 +现有 [agent preset](../../preset/agent-preset-registry/README.zh.md) 可以继续使用 `dsh-agent-tool-presentation`,选择模型看到全部原生工具 schema,还是只有带生成 SDK 的 `run_code`。每个 preset 可独立选择,因此 native 与 PTC agent 可以共享同一进程,而不共享工具目录。选择 `ptc` 需要兼容的 PTC 运行时。使用本包时 `mode` 字段为必填;省略本包则沿用部署默认值。 ## 目录 @@ -25,7 +27,7 @@ kind: "package-reference" ## 使用本包 -把这一行加入 agent preset,以固定每个加入该 preset 的 agent 看到其工具的方式。`native` 以函数定义的形式呈现每个可见工具 schema;`ptc` 只呈现 `run_code` 传输、一份生成的 SDK 以及「只有 `run_code` 可被直接调用」这条规则。未作声明的 agent 会拿到 [`dsh-tools`](../tools/README.zh.md) 那一行上的部署级 `mode`。 +把这一行加入 agent preset,以固定每个加入该 preset 的 agent 看到其工具的方式。`native` 以函数定义的形式呈现每个可见工具 schema;`ptc` 只呈现 `run_code` 传输、一份生成的 SDK 以及「只有 `run_code` 可被直接调用」这条规则。未作声明的 agent 继承组合中最近的执行器。 ### 把这一行加入 preset @@ -43,7 +45,7 @@ kind: "package-reference" ### PTC 模式需要什么 -选择 `ptc` 需要已组合的 PTC 运行时(`ctx.ptcRuntime`),且其语言有已注册的 SDK 渲染器——TypeScript 运行时经 [`dsh-ptc-runtime-node`](../../ptc-runtime/ptc-runtime-node/README.zh.md) 交付,TypeScript 与 Python 的 SDK 渲染器都内置在 `dsh-tools` 中。针对未组装此类运行时的部署选择 PTC 模式的 preset 会拒绝挂载并点名这一行,使失败落在操作者可以行动的地方,而不是落在会话的第一次请求上。 +选择 `ptc` 需要已组合的 PTC 运行时(`ctx.ptcRuntime`),且其语言有已注册的 SDK 渲染器——TypeScript 运行时经 [`dsh-ptc-runtime-node`](../../ptc-runtime/ptc-runtime-node/README.zh.md) 交付,TypeScript 与 Python 的 SDK 渲染器都内置在 `dsh-tool-executor-ptc` 中。在运行时首次可用之前,此兼容选择器会保留继承的执行器。若需要在 preset 激活时拒绝缺少运行时的配置,请直接挂载 `dsh-tool-executor-ptc`。 ### 每个 agent 只声明一次呈现方式 @@ -61,17 +63,17 @@ kind: "package-reference" ### 设计理念 -工具注册表搬不进 preset:它的消费方全在宿主平面——agent loop(智能体循环)读它的调度器,API proxy 读它的展示转换器,每个工具插件都往里注册——而一个服务只有在所有消费方一起下沉时才能下沉。preset 能拥有的是这份注册表的呈现方式。`ctx.tools.presentAs()` 为挂载作用域声明它,而挂载作用域就是 preset 的常驻挂载,因此该声明覆盖每个加入该 preset 的 agent,一个 PTC mode preset 可以与多个 native preset 同进程并存。每个组合一行,而不是每个会话一行。 +工具注册表搬不进 preset:它的消费方全在宿主平面——agent loop(智能体循环)读它的调度器,API proxy 读它的展示转换器,每个工具插件都往里注册——而一个服务只有在所有消费方一起下沉时才能下沉。preset 能拥有的是这份注册表的呈现方式。`ctx.tools.setExecutor()` 为挂载作用域声明它,而挂载作用域就是 preset 的常驻挂载,因此该声明覆盖每个加入该 preset 的 agent,一个 PTC mode preset 可以与多个 native preset 同进程并存。每个组合一行,而不是每个会话一行。 ### 源码地图 | 文件 | 职责 | |---|---| -| [`src/index.ts`](src/index.ts) | 插件入口:`mode` 配置、把 `ctx.tools.presentAs` 接到挂载作用域的 `apply` | +| [`src/index.ts`](src/index.ts) | 插件入口:`mode` 配置、把 `ctx.tools.setExecutor` 接到挂载作用域的 `apply` | ### 行为说明 -`native` 立即生效。PTC 模式则等待 `ctx.ptcRuntime`——这是一个宿主平面服务:针对未组装运行时的部署选择 PTC mode 的 preset 会让这一行停在 pending,`dsh-agent-preset-registry` 会指名此 id 拒绝挂载。`presentAs` 本身就是 effect,因此该声明随这一行撤销,无需第二个包装层拥有它。 +选中的子插件在所需服务可用时注册执行器。注册后,卸载子插件或其运行时会使选中的执行器不可用,直到替代执行器完成注册;调用不会回退到继承的执行器。 @@ -82,7 +84,7 @@ kind: "package-reference" 包级约定对大多数消费方已经足够;需要周边领域时再阅读以下页面。 -- [tools 包](../tools/README.zh.md)——工具呈现模式与 `presentAs` API。 +- [tools 包](../tools/README.zh.md)——工具呈现模式与 `setExecutor` API。 - [agent-presets 包](../../preset/agent-preset-registry/README.zh.md)——preset 如何组合 agent 及其常驻挂载。 - [Node ptc-runtime 包](../../ptc-runtime/ptc-runtime-node/README.zh.md)——PTC 模式所需的 TypeScript 运行时。 - [历史PTC mode 执行器塌缩 note](../../../.agents/notes/archived/bug-fix/2026-08-07-ptc-executor-collapse.md)——通告面与可调用面为何保持一致。 @@ -106,7 +108,7 @@ kind: "package-reference" 这些限制说明这一行何时需要特别留意。它们是当前包约束,不是任务积压。 -- **运行时仍在宿主平面**——preset 可以选择 PTC mode,却无法自带它所需的 TypeScript 运行时;未组装运行时的部署也就无法组装任何 PTC 模式的 preset。 +- **运行时仍在宿主平面**——preset 可以选择 PTC mode,却无法自带它所需的 TypeScript 运行时;在部署提供运行时之前,PTC 选择会保持等待状态。 ### 开发备注 diff --git a/packages/core/agent-tool-presentation/package.json b/packages/core/agent-tool-presentation/package.json index 513fb3e527..00554b1d37 100644 --- a/packages/core/agent-tool-presentation/package.json +++ b/packages/core/agent-tool-presentation/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-agent-tool-presentation", - "description": "Agent-plane presentation selector: composes one agent's tools as PTC mode or native tool calls", + "description": "Deprecated tool presentation selector; use dsh-tool-executor-native or dsh-tool-executor-ptc directly", "version": "0.2.1-rc.2", "publishConfig": { "access": "public" @@ -29,6 +29,8 @@ ], "license": "MIT", "dependencies": { + "@deepseek-ai/dsh-tool-executor-native": "workspace:*", + "@deepseek-ai/dsh-tool-executor-ptc": "workspace:*", "@deepseek-ai/schemastery": "workspace:~" }, "peerDependencies": { diff --git a/packages/core/agent-tool-presentation/src/index.ts b/packages/core/agent-tool-presentation/src/index.ts index 1470fe8f26..59d36daf76 100644 --- a/packages/core/agent-tool-presentation/src/index.ts +++ b/packages/core/agent-tool-presentation/src/index.ts @@ -1,40 +1,27 @@ /** - * Agent-plane presentation selector: the row an agent preset carries to say - * which form of its tools the model sees. - * - * The tool registry itself stays on the host plane — the agent loop's - * scheduler, the API proxy's presenters, and every tool plugin are all its - * consumers, so it cannot move into a preset. What a preset CAN own is the - * presentation: `ctx.tools.presentAs()` declares it for the mounting SCOPE, - * which is the preset's standing mount, so the declaration covers every agent - * joined to that preset and a PTC mode preset runs beside native ones in one - * process. One row per composition, not one per session. - * - * A PTC mode needs a TypeScript PTC runtime, which is a host-plane service - * ([`dsh-ptc-runtime-node`](../../../ptc-runtime/ptc-runtime-node/README.md)). - * This row therefore waits for it rather than assuming it: a preset selecting - * PTC mode against a deployment that composes no runtime fails at mount, named - * in the preset's own activation audit, instead of at the first prompt. - * @module @deepseek-ai/dsh-agent-tool-presentation + * Select built-in tool execution for every Agent in the mounting scope. + * @module + * @deprecated Mount @deepseek-ai/dsh-tool-executor-native or @deepseek-ai/dsh-tool-executor-ptc directly. */ import type { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' -import type { ToolPresentationMode } from '@deepseek-ai/dsh-tools' -// Type-only: brings the `ctx.tools` Context merge into this program. -import type {} from '@deepseek-ai/dsh-tools' +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' +import * as PtcToolExecutor from '@deepseek-ai/dsh-tool-executor-ptc' /** Cordis plugin name. */ export const name = 'tool-presentation' /** - * Required services. `ptcRuntime` is NOT listed: a `native` row must mount in - * a deployment that composes no runtime, and the mode-dependent wait is - * declared inside {@link apply} instead. + * Native selection does not require a program runtime; the PTC child waits + * for its runtime before registering an executor in this scope. */ export const inject = ['tools'] -/** Plugin config. */ +/** + * Plugin config. + * @deprecated Select the native or PTC executor plugin directly instead of configuring a mode. + */ export interface Config { /** * The form this agent's model sees. `native` sends every visible schema, @@ -43,10 +30,14 @@ export interface Config { * without this row already gets, so an omitted value would mean the row was * composed for nothing. */ - mode: ToolPresentationMode + mode: 'native' | 'ptc' } -/** Runtime schema. */ +/** + * Runtime schema. + * @deprecated Select the native or PTC executor plugin directly instead of configuring a mode. + */ +// oxlint-disable-next-line typescript/no-deprecated -- The compatibility schema validates its own deprecated configuration. export const Config: z = z.object({ mode: z.union(['native', 'ptc'] as const).required(), }) @@ -55,18 +46,9 @@ export const Config: z = z.object({ * Declare the tool presentation for every agent this composition covers. * @param ctx - the mounting composition's scope context (a preset's standing scope). * @param config - the selected presentation. + * @deprecated Mount @deepseek-ai/dsh-tool-executor-native or @deepseek-ai/dsh-tool-executor-ptc directly. */ +// oxlint-disable-next-line typescript/no-deprecated -- The compatibility plugin still accepts this configuration. export function apply(ctx: Context, config: Config): void { - // `presentAs` is itself the effect — it registers through the calling - // context and hands back that exact disposer — so the declaration unwinds - // with this row without a second wrapper owning it. - if (config.mode === 'native') { - ctx.tools.presentAs('native') - return - } - // The wait is the loud failure: an entry still pending on `ptcRuntime` is - // what `dsh-agent-preset-registry` reports as an unusable row, naming this id. - ctx.inject(['ptcRuntime'], (runtimeCtx: Context) => { - runtimeCtx.tools.presentAs(config.mode) - }) + ctx.plugin(config.mode === 'native' ? NativeToolExecutor : PtcToolExecutor) } diff --git a/packages/core/agent-tool-presentation/tests/agent-tool-presentation.spec.ts b/packages/core/agent-tool-presentation/tests/agent-tool-presentation.spec.ts index 1d65d968c9..4f43b87f08 100644 --- a/packages/core/agent-tool-presentation/tests/agent-tool-presentation.spec.ts +++ b/packages/core/agent-tool-presentation/tests/agent-tool-presentation.spec.ts @@ -1,12 +1,12 @@ /** * The row an agent preset carries to pick its tool presentation. What it owes * its caller: the choice reaches THIS agent and no other, it unwinds with the - * agent, and a code mode composed against a deployment with no PTC runtime - * stops at mount — where a preset's activation audit can name it — rather - * than at the first prompt assembly. + * agent, and PTC selection waits for a runtime before replacing the + * inherited executor. */ -import { describe, expect, it } from 'vitest' +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' +import { describe, expect, it, onTestFinished } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { createScope } from '@deepseek-ai/dsh-scope' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' @@ -32,8 +32,10 @@ class StubRuntime extends PtcRuntime { /** A host plane with one tool, optionally carrying a PTC runtime. */ async function host(options: { runtime?: boolean } = {}) { const ctx = new Context() + onTestFinished(() => ctx.fiber.dispose()) await ctx.plugin(SystemPrompt, {}) - await ctx.plugin(ToolRuntime, {}) + await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) if (options.runtime !== false) await ctx.plugin(StubRuntime) ctx.tools.register(defineTool({ name: 'echo', @@ -46,6 +48,7 @@ async function host(options: { runtime?: boolean } = {}) { } /** Mount the row under one agent's scope, as a preset subtree does. */ +// oxlint-disable-next-line typescript/no-deprecated -- Exercise the supported compatibility plugin configuration. async function mount(ctx: Context, config: Config, id = 'agent') { const agent = { id: SessionId(id) } as Agent let inner!: Context @@ -60,12 +63,12 @@ async function mount(ctx: Context, config: Config, id = 'agent') { describe('the tool-presentation row', () => { it('rejects an unsupported presentation mode in configuration', () => { + // oxlint-disable-next-line typescript/no-deprecated -- The compatibility schema must reject unsupported modes. expect(() => Config({ mode: 'both' } as never)).toThrow() }) it('declares the services it uses without holding a PTC runtime hostage', () => { - // A `native` row must mount where no runtime is composed, so the wait is - // conditional inside apply rather than static metadata. + // Native execution does not require a program runtime. expect(inject).toEqual(['tools']) }) @@ -82,30 +85,24 @@ describe('the tool-presentation row', () => { expect(plainAssembly.tools.map(tool => tool.name)).toEqual(['echo']) }) - it('restores the deployment default when the agent unloads', async () => { + it('rejects the selected scope after its provider unloads', async () => { const ctx = await host() const { agent, row } = await mount(ctx, { mode: 'ptc' }) await row.dispose() - // HMR safety: the preset subtree is torn down with its agent, and the - // presentation must go with it rather than outliving the composition. - const assembly = await ctx.systemPrompt.assemble({ scope: agent }) - expect(assembly.tools.map(tool => tool.name)).toEqual(['echo']) - expect(assembly.sections.some(section => section.name === 'tools:sdk')).toBe(false) + await expect(ctx.systemPrompt.assemble({ scope: agent })).rejects.toThrow('selected tool executor is unavailable') }) - it('waits for a PTC runtime the deployment does not compose', async () => { + it('keeps the inherited executor while waiting for a PTC runtime', async () => { const ctx = await host({ runtime: false }) const { agent, row } = await mount(ctx, { mode: 'ptc' }) - // Pending, not applied: `dsh-agent-preset-registry` rejects a mount holding a row - // that never reached a usable state, naming this id — so the preset fails - // where the operator can act, instead of at the first request. expect(row.ctx.get('ptcRuntime')).toBeUndefined() const assembly = await ctx.systemPrompt.assemble({ scope: agent }) expect(assembly.tools.map(tool => tool.name)).toEqual(['echo']) + expect(assembly.sections.some(section => section.name === 'tools:sdk')).toBe(false) }) it('applies once the runtime arrives', async () => { @@ -121,6 +118,7 @@ describe('the tool-presentation row', () => { it('requires a mode rather than defaulting one', () => { // An omitted value would mean the row was composed for nothing: a preset // without this row already gets the deployment default. + // oxlint-disable-next-line typescript/no-deprecated -- The compatibility schema still requires an explicit mode. expect(() => Config({} as never)).toThrow() }) }) diff --git a/packages/core/agent-tool-presentation/tsconfig.json b/packages/core/agent-tool-presentation/tsconfig.json index a3e6344b9d..7d77c04f8b 100644 --- a/packages/core/agent-tool-presentation/tsconfig.json +++ b/packages/core/agent-tool-presentation/tsconfig.json @@ -19,6 +19,12 @@ }, { "path": "../../core/tools" + }, + { + "path": "../tool-executor-native" + }, + { + "path": "../tool-executor-ptc" } ] } diff --git a/packages/core/system-prompt/README.i18n.yaml b/packages/core/system-prompt/README.i18n.yaml index a5c3f5913c..637bafaf9a 100644 --- a/packages/core/system-prompt/README.i18n.yaml +++ b/packages/core/system-prompt/README.i18n.yaml @@ -72,8 +72,8 @@ en: 3039bc5e7a796b21 zh: 6b7a9eaf69c9ea53 /deepseek-ai-dsh-system-prompt/model-experience/tool-schemas/what-the-model-sees: - en: a983ef220593cf73 - zh: 28770612ffc1be60 + en: 793f59c178ee2b7e + zh: 871fbaee803335c5 /deepseek-ai-dsh-system-prompt/model-experience/tool-schemas/token-effect: en: f3a7d374c55e1720 zh: 0ee48af678cb875f diff --git a/packages/core/system-prompt/README.md b/packages/core/system-prompt/README.md index 6c9d7338ec..b554f11205 100644 --- a/packages/core/system-prompt/README.md +++ b/packages/core/system-prompt/README.md @@ -157,7 +157,7 @@ Prefix-stable while identity, persona, variables, section text, and order render #### What the model sees -For shipped tools, the model receives the per-agent-visible subset of the [generated tool schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tools), ordered by configuration or lexicographically after restrictions and assembly interception. Extensions can contribute additional definitions through the same registry. Sections and schema providers are separate assembly inputs. A restriction does not remove a section registration: tool-guidance plugins use `text({ scope })` and `ctx.tools.get(name, scope)` to return empty text or select applicable fragments. Arbitrary static sections are not automatically rewritten. +For shipped tools, the model receives the per-agent-visible subset of the [generated tool schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-executor-ptc), ordered by configuration or lexicographically after restrictions and assembly interception. Extensions can contribute additional definitions through the same registry. Sections and schema providers are separate assembly inputs. A restriction does not remove a section registration: tool-guidance plugins use `text({ scope })` and `ctx.tools.get(name, scope)` to return empty text or select applicable fragments. Arbitrary static sections are not automatically rewritten. #### Token effect diff --git a/packages/core/system-prompt/README.zh.md b/packages/core/system-prompt/README.zh.md index 1807bf0411..739ea878d3 100644 --- a/packages/core/system-prompt/README.zh.md +++ b/packages/core/system-prompt/README.zh.md @@ -157,7 +157,7 @@ You are an AI agent powered by DeepSeek Harness. #### 模型看到什么 -对于已交付工具,模型会收到[生成工具 schema](../../../docs/tool-catalog.zh.md#deepseek-aidsh-tools)中对每个 agent 可见的子集;限制与组装拦截完成后,按配置或字典序排列。扩展可以通过同一注册表贡献其他定义。段与 schema 提供方是独立的组装输入。限制不会移除段落注册:工具指导插件通过 `text({ scope })` 与 `ctx.tools.get(name, scope)` 返回空文本或选择适用片段。任意静态段落不会被自动改写。 +对于已交付工具,模型会收到[生成工具 schema](../../../docs/tool-catalog.zh.md#deepseek-aidsh-tool-executor-ptc)中对每个 agent 可见的子集;限制与组装拦截完成后,按配置或字典序排列。扩展可以通过同一注册表贡献其他定义。段与 schema 提供方是独立的组装输入。限制不会移除段落注册:工具指导插件通过 `text({ scope })` 与 `ctx.tools.get(name, scope)` 返回空文本或选择适用片段。任意静态段落不会被自动改写。 #### Token 影响 diff --git a/packages/core/tool-executor-native/README.i18n.yaml b/packages/core/tool-executor-native/README.i18n.yaml new file mode 100644 index 0000000000..8d8ed7b889 --- /dev/null +++ b/packages/core/tool-executor-native/README.i18n.yaml @@ -0,0 +1,46 @@ +# Bilingual-pair consistency record for README.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/core/tool-executor-native/README.md +/: + en: a167e9f03780ad63 + zh: 2e5dc0bd2d08c46e +/deepseek-ai-dsh-tool-executor-native: + en: 7e09b7d3b4389e09 + zh: 2c774c33639bdd64 +/deepseek-ai-dsh-tool-executor-native/summary: + en: 0ef12f2cb7b8f23e + zh: bc79ea00254bfb21 +/deepseek-ai-dsh-tool-executor-native/table-of-contents: + en: d152484eb41ac6b4 + zh: 09388d293f9be9cb +/deepseek-ai-dsh-tool-executor-native/use-this-package: + en: 0e73b1a49308a0f6 + zh: 968b3ee68284d849 +/deepseek-ai-dsh-tool-executor-native/understand-the-implementation: + en: 6a06c6b318f58436 + zh: 4f572feca095e3a6 +/deepseek-ai-dsh-tool-executor-native/further-exploration: + en: e8ae407b0ecdc5d3 + zh: 022a8a714d24da4b +/deepseek-ai-dsh-tool-executor-native/model-experience: + en: 215e7ba838619b7b + zh: a311da3843709f90 +/deepseek-ai-dsh-tool-executor-native/model-experience/visible-tools: + en: 735cc6f4f3bf4cda + zh: 8dab814b4bba5a7d +/deepseek-ai-dsh-tool-executor-native/model-experience/visible-tools/what-the-model-sees: + en: e1f3bbf06e45c22b + zh: 490cb060c38ccd4e +/deepseek-ai-dsh-tool-executor-native/model-experience/visible-tools/token-effect: + en: 81684f49f932e3a3 + zh: 6db2d0a125d990d8 +/deepseek-ai-dsh-tool-executor-native/model-experience/visible-tools/kv-cache-effect: + en: c0d457297034cf64 + zh: 1dfb5c7f83033d92 +/deepseek-ai-dsh-tool-executor-native/known-limitations-and-deferred-work: + en: 4271a88c7d41a6a2 + zh: 99e9db876937a281 +/deepseek-ai-dsh-tool-executor-native/known-limitations-and-deferred-work/dev-note: + en: e67f633dc6fe7773 + zh: 3bb0c20c93f16f98 diff --git a/packages/core/tool-executor-native/README.md b/packages/core/tool-executor-native/README.md new file mode 100644 index 0000000000..0841540ba6 --- /dev/null +++ b/packages/core/tool-executor-native/README.md @@ -0,0 +1,94 @@ +--- +description: "Native function calls with scoped tools and bounded batch execution." +kind: "package-reference" +--- + +# @deepseek-ai/dsh-tool-executor-native + +English | [中文](README.zh.md) + +## Summary + +Use this executor when models should call each visible tool directly. Mount it beside the tool registry in the deployment, a preset, or an Agent scope. Parallel-safe calls can overlap; exclusive calls form barriers, and results remain in model order. + +## Table of Contents + +- [Use this package](#use-this-package) +- [Understand the implementation](#understand-the-implementation) +- [Further Exploration](#further-exploration) +- [Model Experience](#model-experience) +- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) +- [Dev Note](#dev-note) + +----- + + +## Use this package + +Add this row to a composition with `dsh-tools` mounted. The nearest scoped executor selects tool behavior; one scope permits only one executor. + +```yaml +- name: '@deepseek-ai/dsh-tool-executor-native' +``` + +Configure [agent-loop concurrency](../agent-loop/README.md) for Agent steps; the loop reads the limit once per batch. This plugin has no configuration fields. Direct `NativeToolExecutor` callers may pass a constructor callback, defaulting to `10`, which is sampled for each tool group when the batch omits `maxParallelToolCalls`. Limits must be positive integers; `1` selects serial execution. + +----- + + +## Understand the implementation + +
+Implementation internals — click to expand + +The executor receives the entire raw call batch and owns parsing, scheduling, cancellation, and optional model-step logging. It reuses the shared invocation pipeline for policy, tool bodies, and result notifications. After abort, it waits for started work and returns ordered error results for skipped calls. + +- [`src/index.ts`](src/index.ts) — Scoped executor registration; tool descriptions and batch execution. + +
+ +----- + + +## Further Exploration + +- [Tool registry](../tools/README.md) +- [Agent loop](../agent-loop/README.md) +- [Alternative executor](../tool-executor-ptc/README.md) + +----- + + +## Model Experience + +### Visible tools + +#### What the model sees + +Each visible tool’s `name`, `description`, and JSON Schema parameters; no additional prompt section. + +#### Token effect + +Request cost grows with visible definitions; hiding a definition removes its schema cost. + +#### KV Cache effect + +The prefix remains stable while tool descriptions and ordering stay unchanged. + +## Known Limitations and Deferred Work + + + +These constraints apply to accepted calls. + +- Same-process tools must observe cancellation; the executor cannot forcibly terminate a tool that ignores its signal. + + +### Dev Note + +
+Working context for maintainers — click to expand + +None. + +
diff --git a/packages/core/tool-executor-native/README.zh.md b/packages/core/tool-executor-native/README.zh.md new file mode 100644 index 0000000000..4c9ea6ee19 --- /dev/null +++ b/packages/core/tool-executor-native/README.zh.md @@ -0,0 +1,94 @@ +--- +description: "具有作用域工具和有界批次执行的原生函数调用。" +kind: "package-reference" +--- + +# @deepseek-ai/dsh-tool-executor-native + +[English](README.md) | 中文 + +## 概述 + +当模型应直接调用每个可见工具时,使用此执行器。在部署、预设或 Agent 作用域中,将它与工具注册表一同挂载。并行安全调用可以重叠,独占调用构成屏障,结果保留模型顺序。 + +## 目录 + +- [使用本包](#use-this-package) +- [理解实现](#understand-the-implementation) +- [进一步探索](#further-exploration) +- [模型体验](#model-experience) +- [已知限制与延期工作](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) + +----- + + +## 使用本包 + +将此行加入已经挂载 `dsh-tools` 的组合。最近作用域的执行器决定工具行为;同一作用域只允许一个执行器。 + +```yaml +- name: '@deepseek-ai/dsh-tool-executor-native' +``` + +Agent 步骤使用 [agent-loop 并发配置](../agent-loop/README.zh.md),循环在每个批次开始时读取一次上限。本插件没有配置字段。直接构造 `NativeToolExecutor` 的调用方可以传入回调,默认返回 `10`;批次未指定 `maxParallelToolCalls` 时,每个工具组读取该回调。上限必须是正整数;`1` 表示串行执行。 + +----- + + +## 理解实现 + +
+实现细节——点击展开 + +执行器接收完整的原始调用批次,负责解析、调度、取消和可选的模型步骤日志。它复用共享调用流水线来执行策略、工具和结果通知。中止后,它等待已开始的工作完成,并为跳过的调用返回有序错误结果。 + +- [`src/index.ts`](src/index.ts) — 作用域执行器注册;工具描述和批次执行。 + +
+ +----- + + +## 进一步探索 + +- [工具注册表](../tools/README.zh.md) +- [Agent 循环](../agent-loop/README.zh.md) +- [其他执行器](../tool-executor-ptc/README.zh.md) + +----- + + +## 模型体验 + +### 可见工具 + +#### 模型看到什么 + +每个可见工具的 `name`、`description` 和 JSON Schema 参数;不会增加提示词章节。 + +#### Token 影响 + +请求成本与可见定义成正比;隐藏定义会移除对应 schema 成本。 + +#### KV Cache 影响 + +只要工具描述和顺序不变,前缀就保持稳定。 + +## 已知限制与延期工作 + + + +以下约束适用于已开始的调用。 + +- 同进程工具必须遵守取消信号;执行器无法强制终止忽略信号的工具。 + + +### 开发备注 + +
+维护者的工作上下文——点击展开 + +无。 + +
diff --git a/packages/core/tool-executor-native/locale/en.json b/packages/core/tool-executor-native/locale/en.json new file mode 100644 index 0000000000..29f1f19f77 --- /dev/null +++ b/packages/core/tool-executor-native/locale/en.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Native tool execution", + "description": "Executes model tool calls directly." + } +} diff --git a/packages/core/tool-executor-native/locale/ja.json b/packages/core/tool-executor-native/locale/ja.json new file mode 100644 index 0000000000..0eb5ef4723 --- /dev/null +++ b/packages/core/tool-executor-native/locale/ja.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "ネイティブツール実行", + "description": "モデルからのツール呼び出しを直接実行します。" + } +} diff --git a/packages/core/tool-executor-native/locale/zh.json b/packages/core/tool-executor-native/locale/zh.json new file mode 100644 index 0000000000..5cc7483485 --- /dev/null +++ b/packages/core/tool-executor-native/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "原生工具执行", + "description": "直接执行模型发起的工具调用。" + } +} diff --git a/packages/core/tool-executor-native/package.json b/packages/core/tool-executor-native/package.json new file mode 100644 index 0000000000..2ce899c77a --- /dev/null +++ b/packages/core/tool-executor-native/package.json @@ -0,0 +1,39 @@ +{ + "name": "@deepseek-ai/dsh-tool-executor-native", + "description": "Native tool execution plugin", + "version": "0.2.1-rc.2", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/core/tool-executor-native" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "license": "MIT", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json", + "./locale/*.json": "./locale/*.json" + }, + "files": [ + "locale/*.json", + "lib/index.js", + "lib/types/**/*.d.ts" + ], + "peerDependencies": { + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-tools": "workspace:*" + }, + "devDependencies": { + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-tools": "workspace:*" + } +} diff --git a/packages/core/tool-executor-native/src/index.ts b/packages/core/tool-executor-native/src/index.ts new file mode 100644 index 0000000000..457749152c --- /dev/null +++ b/packages/core/tool-executor-native/src/index.ts @@ -0,0 +1,35 @@ +/** Native execution plugin for the mounting Agent, preset, or deployment scope. @module */ +import type { Context } from '@deepseek-ai/cordis' +import { projectToolSchema } from '@deepseek-ai/dsh-tools' +import type { ToolDefinition, ToolExecutor, ToolModelInput, ToolBatchInput, ToolBatchResult } from '@deepseek-ai/dsh-tools' +import { ToolExecutionPipeline, executeToolBatch } from '@deepseek-ai/dsh-tools/pipeline' + +/** Executes visible capabilities directly and owns their complete batch settlement. */ +export class NativeToolExecutor implements ToolExecutor { + private readonly pipeline: ToolExecutionPipeline + + constructor(ctx: Context, private readonly maxParallel: () => number = () => 10) { + this.pipeline = new ToolExecutionPipeline(ctx) + } + + describe(tools: readonly ToolDefinition[]): ToolModelInput { + return { schemas: tools.map(definition => projectToolSchema(definition, false)) } + } + + execute(input: ToolBatchInput): Promise { + return executeToolBatch(input, this.pipeline, this.maxParallel) + } +} + +/** Cordis plugin name. */ +export const name = 'native-tool-executor' +/** Tool registration and scoped dispatch. */ +export const inject = ['tools'] + +/** + * Select native execution in the mounting scope. + * @param ctx - context owning this executor's selection and lifetime. + */ +export function apply(ctx: Context): void { + ctx.tools.setExecutor(new NativeToolExecutor(ctx)) +} diff --git a/packages/core/tool-executor-native/tsconfig.json b/packages/core/tool-executor-native/tsconfig.json new file mode 100644 index 0000000000..527fb0d8f8 --- /dev/null +++ b/packages/core/tool-executor-native/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../tools" + }, + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/schemastery" + } + ] +} diff --git a/packages/core/tool-executor-ptc/README.i18n.yaml b/packages/core/tool-executor-ptc/README.i18n.yaml new file mode 100644 index 0000000000..8b9724e5c1 --- /dev/null +++ b/packages/core/tool-executor-ptc/README.i18n.yaml @@ -0,0 +1,52 @@ +# Bilingual-pair consistency record for README.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/core/tool-executor-ptc/README.md +/: + en: 93b346254eff025a + zh: 69aa6364ca2ffa7d +/deepseek-ai-dsh-tool-executor-ptc: + en: e253ed9cec03a396 + zh: 922fde282e2b0fd2 +/deepseek-ai-dsh-tool-executor-ptc/summary: + en: 66e159c308afb6c1 + zh: 29b1a646ae7f918a +/deepseek-ai-dsh-tool-executor-ptc/table-of-contents: + en: d152484eb41ac6b4 + zh: 09388d293f9be9cb +/deepseek-ai-dsh-tool-executor-ptc/use-this-package: + en: b6b7ffab35e1a725 + zh: fc5db2524e1507d6 +/deepseek-ai-dsh-tool-executor-ptc/understand-the-implementation: + en: 9d4f61c351945d30 + zh: 1c575526230b0e02 +/deepseek-ai-dsh-tool-executor-ptc/understand-the-implementation/ptc-mode: + en: 11d32e05ba397f86 + zh: 4e071ec4e6222d9d +/deepseek-ai-dsh-tool-executor-ptc/further-exploration: + en: ee69078c38c655a9 + zh: fb0873407f23c8c5 +/deepseek-ai-dsh-tool-executor-ptc/model-experience: + en: 215e7ba838619b7b + zh: a311da3843709f90 +/deepseek-ai-dsh-tool-executor-ptc/model-experience/ptc-mode-schema-and-system-prompt: + en: eaefd7303f22977d + zh: 9c425f9501128224 +/deepseek-ai-dsh-tool-executor-ptc/model-experience/ptc-mode-schema-and-system-prompt/what-the-model-sees: + en: 4980bfab8310efc0 + zh: ef318d5b8ac7bf76 +/deepseek-ai-dsh-tool-executor-ptc/model-experience/ptc-mode-schema-and-system-prompt/what-the-model-sees/typescript-ptc-mode-sdk-instructions-with-bash: + en: 3a8df15e4ae32f9e + zh: fc4a800ba363e835 +/deepseek-ai-dsh-tool-executor-ptc/model-experience/ptc-mode-schema-and-system-prompt/token-effect: + en: 12296fcafc14562c + zh: f8e9351bb8c6437d +/deepseek-ai-dsh-tool-executor-ptc/model-experience/ptc-mode-schema-and-system-prompt/kv-cache-effect: + en: 78906628d634441f + zh: 492f133aea50cfe3 +/deepseek-ai-dsh-tool-executor-ptc/known-limitations-and-deferred-work: + en: 73836fc806368269 + zh: 943ecf50e19b3c69 +/deepseek-ai-dsh-tool-executor-ptc/known-limitations-and-deferred-work/dev-note: + en: e67f633dc6fe7773 + zh: 3bb0c20c93f16f98 diff --git a/packages/core/tool-executor-ptc/README.md b/packages/core/tool-executor-ptc/README.md new file mode 100644 index 0000000000..5a1e781989 --- /dev/null +++ b/packages/core/tool-executor-ptc/README.md @@ -0,0 +1,133 @@ +--- +description: "Program-based tool execution, generated SDKs, and nested-call limits." +kind: "package-reference" +--- + +# @deepseek-ai/dsh-tool-executor-ptc + +English | [中文](README.zh.md) + +## Summary + +Use this executor when models should call tools through `run_code` programs. Mount a compatible PTC runtime and tool registry first. Scoped restrictions determine the generated SDK; only `run_code` is directly callable. Configure concurrency for parallel-safe program subcalls. + +## Table of Contents + +- [Use this package](#use-this-package) +- [Understand the implementation](#understand-the-implementation) +- [Further Exploration](#further-exploration) +- [Model Experience](#model-experience) +- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) +- [Dev Note](#dev-note) + +----- + + +## Use this package + +Add this row to a composition with `dsh-tools` mounted. The nearest scoped executor selects tool behavior; one scope permits only one executor. + +```yaml +- name: '@deepseek-ai/dsh-tool-executor-ptc' + config: + maxParallelSubCalls: 10 +``` + +Root `run_code` calls are exclusive and execute serially. `maxParallelSubCalls` limits parallel-safe nested calls within each program and defaults to `10`; it must be a positive integer, with `1` selecting serial execution. Exclusive calls form barriers. + +This package declares `ctx.ptcRuntime` and `ctx.systemPrompt` as required injections; Agent-owned programs need `ctx.workingDirectory`. The executor activates only after its runtime is available and withdraws when that runtime unloads. An enabled PTC executor without a runtime stops application startup. A withdrawn PTC selection never falls back to native execution. + +Shared dispatch events and the `ptc-mode` message-source declaration live in `@deepseek-ai/dsh-tool-executor-ptc/types`; Client code imports the same declarations through `@deepseek-ai/dsh-tool-executor-ptc/client`. Both entries exclude Host service declarations. Host policy extensions use `import type` from the package entry for `PtcDispatchLog` and `tools/ptc-dispatch-log` without mounting the executor. + +[Configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-tool-executor-ptc) + +----- + + +## Understand the implementation + +
+Implementation internals — click to expand + +The executor receives the entire raw call batch and owns parsing, scheduling, cancellation, and optional model-step logging. It reuses the shared invocation pipeline for policy, tool bodies, and result notifications. After abort, it waits for started work and returns ordered error results for skipped calls. Unloading the executor aborts active sandboxed programs and waits for their nested calls to settle. Accepted batches still settle through that executor, potentially with cancellation errors. + +### PTC mode + +The PTC executor exposes the reserved `run_code` transport plus a deterministic SDK generated in the loaded runtime's language. Each SDK binding captures a frozen ToolSchema and passes it through the scheduler to its execution context. Before policy, a started call records only pairing ids, name, and normalized arguments; its settle event preserves the rendered result, optional presentation metadata, and structured error. Metadata is projected from that call’s canonical value and frozen; it changes neither the program value nor model content. Description and parameters remain transient and never enter Session events or SDK output. Calls are scheduled through a per-run pool that reuses the native concurrency contract. Under `ptc` alone, a model-direct call naming any other visible tool resolves to `UNKNOWN_TOOL` before policy — the announced surface and the callable surface stay the same. Intermediate binding values are execution-local; only the outer `run_code` result has a hard size cap. + +New sub-calls use `:ptc:` ids. Consumers treat these ids as opaque and correlate events by exact equality; restored historical ids retain their original bytes. The [PTC mode decision](../../../.agents/notes/implemented/feature/2026-06-15-ptc.md) owns durable naming and restoration rules. + +Successful image-bearing subcall results become deferred user-message context with `source.kind` set to `ptc-mode`. Other additional contexts retain their producing tool's attribution. + +`run_code` accepts `timeoutMs` when the mounted runtime supports an override; its schema reports the configured default and maximum, and the runtime's usage instructions. The Node default is 120,000 ms with a 600,000 ms cap, including nested tool and approval waits. A wider `sandbox_permissions` mode requires a non-empty `justification` and approval before the program starts. The schema asks the model to write that reason in the current user's language. The grant applies to that complete execution; standing Session policy and nested tools retain their own authority. Programs are never replayed automatically: inspect earlier effects before explicitly retrying a denied program. Retained PTC environments follow the standing file policy. PTC observes committed Session mode changes and immediately cancels affected calls in both Node modes. Later PTC calls wait for their nested-tool cleanup before resolving the latest policy. The runtime owns process invalidation and cleanup; permission setters remain synchronous. One-time escalations omit the state owner and complete in a temporary environment; standing variables remain available to ordinary calls. + +- [`src/index.ts`](src/index.ts) — Validated configuration and scoped registration; tool descriptions and batch execution. + +
+ +----- + + +## Further Exploration + +- [Tool registry](../tools/README.md) +- [Agent loop](../agent-loop/README.md) +- [Alternative executor](../tool-executor-native/README.md) + +----- + + +## Model Experience + +### PTC mode schema and system prompt + +#### What the model sees + +PTC mode exposes the generated [`run_code` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-executor-ptc), the SDK instructions below, and the generated exact SDK block for the loaded runtime's language. Both runtime languages list `code` before `description`; this is presentation order, not an enforced JSON member order. The TypeScript instructions use a return-value example for function-body runtimes and a printed-output example for REPL runtimes. They identify generated declarations as program-only bindings. When the current `bash` parameter schema accepts the example arguments, they also show a complete `run_code` call around `tools.bash(...)`. The `tools:sdk` section uses first-party order 5000 and disables prompt-variable interpolation, preserving literal `{{…}}` text in tool descriptions and schemas for both runtime languages. Under `ptc`, the prompt also carries the `tools:ptc-only` rule earlier in the first-party order, so the model reads which tools it may call before it reads what each one is for. + +##### TypeScript PTC mode SDK instructions with bash + +```markdown +## Writing code for run_code + +`run_code` takes two required arguments: `code`, the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped), and `description`, a short summary of what the program does. The declarations below are SDK bindings for this program. A declaration does not make its name a directly callable tool; only names supplied as separate tool schemas may be called directly. When no separate `bash` schema is supplied, invoke a declared `bash` binding inside `run_code`: + +`run_code({ code: "return await tools.bash({ description: 'Show current directory', command: 'pwd' })", description: "Show current directory" })` + +Inside the program: + +- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools["my-tool"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON. +- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue. +- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`. +- Emit results with `return` and/or `console.log(...)`. Only what you print or return is program output. A successful tool result containing an image is attached after the run so you can inspect it on the next step; every other intermediate result stays out of the conversation, so extract just what you need. + +Program-only SDK bindings: +``` + +#### Token effect + +Fixed per-request cost proportional to the visible definitions. PTC mode trades end-tool schemas for generated SDK text plus one transport schema rather than promising a universal reduction. + +#### KV Cache effect + +Prefix-stable while the PTC mode selection, generated SDK, transport schema, and visible tool set are unchanged. Mode or filter changes may invalidate reuse from the first changed prompt or schema token. + +## Known Limitations and Deferred Work + + + +These constraints apply to accepted calls. + +- **PTC mode's SDK language follows the one loaded runtime, and a presentation is per agent rather than per tool** — PTC execution rejects prompt assembly unless `ctx.ptcRuntime.language` has a registered SDK renderer; within one agent no tool can be native-only while another is ptc-only. +- **PTC mode intermediate values are execution-local and unbounded by bytes** — they cannot be reconstructed from session replay and may exhaust process or worker memory; only the outer `run_code` output has the worker's configurable hard cap. +- **State depends on the runtime**: the default backend starts fresh; a REPL backend retains memory per live Agent. Agent disposal awaits release. Restart and execution failures can clear memory; model-visible guidance explains how to recreate missing variables. Programs and results remain in the Session log, without serializing live objects. + + +### Dev Note + +
+Working context for maintainers — click to expand + +None. + +
diff --git a/packages/core/tool-executor-ptc/README.zh.md b/packages/core/tool-executor-ptc/README.zh.md new file mode 100644 index 0000000000..c2b0ad1356 --- /dev/null +++ b/packages/core/tool-executor-ptc/README.zh.md @@ -0,0 +1,133 @@ +--- +description: "基于程序的工具执行、生成 SDK 与嵌套调用限制。" +kind: "package-reference" +--- + +# @deepseek-ai/dsh-tool-executor-ptc + +[English](README.md) | 中文 + +## 概述 + +当模型应通过 `run_code` 程序调用工具时,使用此执行器。先挂载兼容的 PTC 运行时和工具注册表。作用域限制决定生成的 SDK;只有 `run_code` 能被直接调用。可配置并行安全的程序子调用并发上限。 + +## 目录 + +- [使用本包](#use-this-package) +- [理解实现](#understand-the-implementation) +- [进一步探索](#further-exploration) +- [模型体验](#model-experience) +- [已知限制与延期工作](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) + +----- + + +## 使用本包 + +将此行加入已经挂载 `dsh-tools` 的组合。最近作用域的执行器决定工具行为;同一作用域只允许一个执行器。 + +```yaml +- name: '@deepseek-ai/dsh-tool-executor-ptc' + config: + maxParallelSubCalls: 10 +``` + +根 `run_code` 调用是独占调用,按串行执行。`maxParallelSubCalls` 限制每个程序中并行安全的嵌套调用数,默认值为 `10`;值必须是正整数,`1` 表示串行执行。独占调用构成屏障。 + +本包将 `ctx.ptcRuntime` 和 `ctx.systemPrompt` 声明为必需注入;带 Agent 的程序要求 `ctx.workingDirectory`。执行器在运行时可用后才激活,并在运行时卸载时撤回。启用 PTC 执行器但未提供运行时会阻止应用启动。已撤回的 PTC 选择不会回退到原生执行。 + +共享分派事件与 `ptc-mode` 消息来源声明位于 `@deepseek-ai/dsh-tool-executor-ptc/types`;Client 代码通过 `@deepseek-ai/dsh-tool-executor-ptc/client` 导入同一组声明。两个入口都不引入 Host 服务声明。Host 策略扩展通过包主入口的 `import type` 获取 `PtcDispatchLog` 与 `tools/ptc-dispatch-log`,无需挂载执行器。 + +[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-tool-executor-ptc) + +----- + + +## 理解实现 + +
+实现细节——点击展开 + +执行器接收完整的原始调用批次,负责解析、调度、取消和可选的模型步骤日志。它复用共享调用流水线来执行策略、工具和结果通知。中止后,它等待已开始的工作完成,并为跳过的调用返回有序错误结果。卸载执行器会中止运行中的沙箱程序,并等待其嵌套调用结束。已接纳批次仍由该执行器结算,但可能返回取消错误。 + +### PTC mode + +PTC 执行器公开保留的 `run_code` 传输以及按所加载运行时语言生成的确定性 SDK。每个 SDK 绑定捕获冻结的 ToolSchema,经由调度器传入该次执行上下文。已开始的调用在策略之前只记录配对 id、名称和规范化参数;其结算事件保留渲染结果、可选展示元数据与结构化错误;元数据由该次调用的规范值投影并冻结,不改变程序读取的值或模型内容。描述与参数 schema 仅临时存活,不进入 Session 事件或 SDK 输出。调用通过复用原生并发约定的每次运行独有池调度。在纯 `ptc` 下,模型直呼其他任何可见工具都会在策略之前解析为 `UNKNOWN_TOOL`——通告面与可调用面保持一致。中间绑定值只存在于执行局部;只有外层 `run_code` 结果有硬大小上限。 + +新子调用使用 `:ptc:` 标识。消费方将这些标识视为不透明值,并通过精确相等关联事件;恢复的历史标识保留原始字节。[PTC mode 决策](../../../.agents/notes/implemented/feature/2026-06-15-ptc.zh.md) 负责持久化命名与恢复规则。 + +成功且包含图片的子调用结果会成为延后的 user-message 上下文,其 `source.kind` 为 `ptc-mode`。其他 additional context 保留其生产工具的归属。 + +当已挂载运行时支持覆盖时,`run_code` 接受 `timeoutMs`;其 schema 报告配置的默认值和上限以及运行时使用说明。Node 默认值为 120,000 ms,上限为 600,000 ms,包含嵌套工具和审批等待。更宽的 `sandbox_permissions` 模式要求非空 `justification`,并在程序启动前获得审批。schema 提示模型使用当前用户的语言撰写理由。授权仅用于该次完整执行;常驻 Session 策略与嵌套工具保留各自权限。程序不会自动重放:显式重试被拒程序前,应检查先前已发生的效果。 保留状态的 PTC 环境遵循会话文件策略。PTC 观察已提交的 Session 模式变化,并立即取消两种 Node 模式下受影响的调用。后续 PTC 调用等待这些调用的嵌套工具清理后,再解析最新策略。运行时负责进程失效和清理,权限设置保持同步。单次提权省略状态所有者,在临时环境中执行;普通调用仍可访问原环境的变量。 + +- [`src/index.ts`](src/index.ts) — 配置校验和作用域注册;工具描述和批次执行。 + +
+ +----- + + +## 进一步探索 + +- [工具注册表](../tools/README.zh.md) +- [Agent 循环](../agent-loop/README.zh.md) +- [其他执行器](../tool-executor-native/README.zh.md) + +----- + + +## 模型体验 + +### PTC mode schema 与系统提示词 + +#### 模型看到什么 + +PTC mode 会公开生成的 [`run_code` schema](../../../docs/tool-catalog.zh.md#deepseek-aidsh-tool-executor-ptc)、下方 SDK 说明,以及按所加载运行时语言生成的精确 SDK 块。两种运行时语言都将 `code` 列在 `description` 前;这是呈现顺序,不强制 JSON 成员顺序。TypeScript 说明对函数体运行时使用返回值示例,对 REPL 运行时使用打印输出示例,并把生成声明明确标为只能在程序内使用的绑定。当当前 `bash` 参数 schema 接受示例参数时,说明还会给出以 `run_code` 包住 `tools.bash(...)` 的完整调用。`tools:sdk` 段使用 first-party 顺序 5000,并关闭提示词变量插值,使两种运行时语言都原样保留工具描述和 schema 中的 `{{…}}` 文本。在 `ptc` 下,提示词还会带上处于更早 first-party 顺序的 `tools:ptc-only` 规则,让模型先读到「可以调用哪些工具」再读「每个工具做什么」。 + +##### 带 bash 的 TypeScript PTC mode SDK 说明 + +```markdown +## Writing code for run_code + +`run_code` takes two required arguments: `code`, the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped), and `description`, a short summary of what the program does. The declarations below are SDK bindings for this program. A declaration does not make its name a directly callable tool; only names supplied as separate tool schemas may be called directly. When no separate `bash` schema is supplied, invoke a declared `bash` binding inside `run_code`: + +`run_code({ code: "return await tools.bash({ description: 'Show current directory', command: 'pwd' })", description: "Show current directory" })` + +Inside the program: + +- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools["my-tool"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON. +- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue. +- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`. +- Emit results with `return` and/or `console.log(...)`. Only what you print or return is program output. A successful tool result containing an image is attached after the run so you can inspect it on the next step; every other intermediate result stays out of the conversation, so extract just what you need. + +Program-only SDK bindings: +``` + +#### Token 影响 + +每次请求的固定成本与可见定义成正比。PTC mode 使用生成的 SDK 文本加一个传输 schema 取代最终工具 schema,但不承诺普遍减少成本。 + +#### KV Cache 影响 + +只要 PTC mode 选择、生成的 SDK、传输 schema 与可见工具集合不变,前缀就保持稳定。模式或筛选器变更可能从第一个改变的提示词或 schema token 起使复用失效。 + +## 已知限制与延期工作 + + + +以下约束适用于已开始的调用。 + +- **PTC mode 的 SDK 语言由当前加载的运行时决定,且呈现方式按 agent 而非按工具**:PTC 会拒绝组装提示词,除非 `ctx.ptcRuntime.language` 有已注册的 SDK 渲染器;同一个 agent 内不能让一个工具仅使用 Native,而另一个仅使用 PTC。 +- **PTC mode 中间值只存在于执行局部,且没有字节上限**:它们无法从会话回放重建,并可能耗尽进程或 worker 内存;只有外层 `run_code` 输出受 worker 可配置的硬上限约束。 +- **状态由运行时决定**:默认后端每次重新开始;REPL 后端按存活 Agent 保留内存。Agent 销毁时等待环境释放。重启与执行失败可能清除内存;模型说明会指导重新创建缺失变量。程序与结果仍写入 Session 日志,不序列化运行中的对象。 + + +### 开发备注 + +
+维护者的工作上下文——点击展开 + +无。 + +
diff --git a/packages/core/tool-executor-ptc/locale/en.json b/packages/core/tool-executor-ptc/locale/en.json new file mode 100644 index 0000000000..3c9ea5fbe1 --- /dev/null +++ b/packages/core/tool-executor-ptc/locale/en.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "PTC tool execution", + "description": "Executes tools through run_code programs and generates SDK bindings." + } +} diff --git a/packages/core/tool-executor-ptc/locale/ja.json b/packages/core/tool-executor-ptc/locale/ja.json new file mode 100644 index 0000000000..4b4ace49ce --- /dev/null +++ b/packages/core/tool-executor-ptc/locale/ja.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "PTC ツール実行", + "description": "run_code プログラム経由でツールを実行し、SDK バインディングを生成します。" + } +} diff --git a/packages/core/tool-executor-ptc/locale/zh.json b/packages/core/tool-executor-ptc/locale/zh.json new file mode 100644 index 0000000000..331e5eecfa --- /dev/null +++ b/packages/core/tool-executor-ptc/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "PTC 工具执行", + "description": "通过 run_code 程序执行工具并生成 SDK 绑定。" + } +} diff --git a/packages/core/tool-executor-ptc/package.json b/packages/core/tool-executor-ptc/package.json new file mode 100644 index 0000000000..c52063e814 --- /dev/null +++ b/packages/core/tool-executor-ptc/package.json @@ -0,0 +1,75 @@ +{ + "name": "@deepseek-ai/dsh-tool-executor-ptc", + "description": "PTC program execution, tool bindings, and generated SDK prompts", + "version": "0.2.1-rc.2", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/core/tool-executor-ptc" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "license": "MIT", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./types": { + "types": "./lib/types/types.d.ts", + "default": "./lib/types/types.js" + }, + "./client": { + "types": "./lib/types/client.d.ts", + "default": "./lib/types/client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json", + "./locale/*.json": "./locale/*.json" + }, + "files": [ + "locale/*.json", + "lib/index.js", + "lib/types/**/*.js", + "lib/types/**/*.d.ts" + ], + "peerDependencies": { + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-agent": "workspace:*", + "@deepseek-ai/dsh-llm": "workspace:*", + "@deepseek-ai/dsh-ptc-runtime": "workspace:*", + "@deepseek-ai/dsh-sandbox": "workspace:*", + "@deepseek-ai/dsh-sandbox-policy": "workspace:*", + "@deepseek-ai/dsh-scope": "workspace:*", + "@deepseek-ai/dsh-session": "workspace:*", + "@deepseek-ai/dsh-tools": "workspace:*", + "@deepseek-ai/dsh-user-approval": "workspace:*", + "@deepseek-ai/dsh-working-directory": "workspace:*" + }, + "dependencies": { + "@deepseek-ai/dsh-brand": "workspace:*", + "@deepseek-ai/dsh-util-values": "workspace:*", + "@deepseek-ai/schemastery": "workspace:~" + }, + "devDependencies": { + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-agent": "workspace:*", + "@deepseek-ai/dsh-agent-loop-testkit": "workspace:*", + "@deepseek-ai/dsh-llm": "workspace:*", + "@deepseek-ai/dsh-ptc-runtime": "workspace:*", + "@deepseek-ai/dsh-sandbox": "workspace:*", + "@deepseek-ai/dsh-sandbox-policy": "workspace:*", + "@deepseek-ai/dsh-scope": "workspace:*", + "@deepseek-ai/dsh-session": "workspace:*", + "@deepseek-ai/dsh-session-projection": "workspace:*", + "@deepseek-ai/dsh-tool-executor-native": "workspace:*", + "@deepseek-ai/dsh-tool-testkit": "workspace:*", + "@deepseek-ai/dsh-tools": "workspace:*", + "@deepseek-ai/dsh-user-approval": "workspace:*", + "@deepseek-ai/dsh-working-directory": "workspace:*" + } +} diff --git a/packages/core/tool-executor-ptc/src/client.ts b/packages/core/tool-executor-ptc/src/client.ts new file mode 100644 index 0000000000..540306c5a5 --- /dev/null +++ b/packages/core/tool-executor-ptc/src/client.ts @@ -0,0 +1,2 @@ +/** Client entry for shared PTC event and message-source declarations. @module */ +export type * from './types.ts' diff --git a/packages/core/tool-executor-ptc/src/config.ts b/packages/core/tool-executor-ptc/src/config.ts new file mode 100644 index 0000000000..b2710764e2 --- /dev/null +++ b/packages/core/tool-executor-ptc/src/config.ts @@ -0,0 +1,13 @@ +/** Configuration shared by plugin mounting and direct executor construction. @module */ +import z from '@deepseek-ai/schemastery' + +/** Program execution limits. */ +export interface Config { + /** Maximum overlapping concurrency-safe program subcalls. */ + maxParallelSubCalls: number +} + +/** Validated program subcall concurrency; one makes subcalls serial. */ +export const Config: z, Config> = z.object({ + maxParallelSubCalls: z.natural().min(1).default(10), +}) diff --git a/packages/core/tool-executor-ptc/src/errors.ts b/packages/core/tool-executor-ptc/src/errors.ts new file mode 100644 index 0000000000..67d468e9d0 --- /dev/null +++ b/packages/core/tool-executor-ptc/src/errors.ts @@ -0,0 +1,16 @@ +/** Program execution failures reported by PTC. @module */ +import { HarnessError } from '@deepseek-ai/dsh-llm' + +/** + * Thrown by `run_code` when the program run itself failed — a program + * exception, a budget expiry, an abort, or substrate death. Extends + * {@link HarnessError} (`code: 'CODE_RUN_FAILED'`); the registry's execution + * pipeline converts it into a structured `isError` result whose text carries + * the failure kind plus the captured logs, so the model can self-correct. + */ +export class CodeRunFailedError extends HarnessError { + constructor(message: string) { + super(message, 'CODE_RUN_FAILED') + this.name = 'CodeRunFailedError' + } +} diff --git a/packages/core/tool-executor-ptc/src/index.ts b/packages/core/tool-executor-ptc/src/index.ts new file mode 100644 index 0000000000..319c109d60 --- /dev/null +++ b/packages/core/tool-executor-ptc/src/index.ts @@ -0,0 +1,83 @@ +/** PTC execution plugin for the mounting Agent, preset, or deployment scope. @module */ +import type { Context } from '@deepseek-ai/cordis' +import { RUN_CODE_NAME, ToolNotFoundError, projectToolSchema } from '@deepseek-ai/dsh-tools' +import type { ToolDefinition, ToolExecutor, ToolModelInput, ToolPromptSection, ToolBatchInput, ToolBatchResult } from '@deepseek-ai/dsh-tools' +import { ToolExecutionPipeline, executeToolBatch } from '@deepseek-ai/dsh-tools/pipeline' +import { createRunCodeTool, requirePtcRuntime, type PtcSdkLanguage } from './ptc.ts' +import { renderToolsSdk, type ToolSdkSchema } from './ts-types.ts' +import { renderToolsSdkPy } from './py-types.ts' +import { Config } from './config.ts' + +const PTC_ONLY_INSTRUCTION = `\`${RUN_CODE_NAME}\` is the only tool you can call directly — a tool call naming any other tool fails. Reach every tool the SDK declares below from inside the program.` + +const SDK_RENDERERS = { + typescript: renderToolsSdk, + python: renderToolsSdkPy, +} satisfies Record string> + +/** Runs programs whose nested tool calls use the shared guarded invocation pipeline. */ +export class PtcExecutor implements ToolExecutor { + readonly sections: readonly ToolPromptSection[] + private readonly pipeline: ToolExecutionPipeline + private readonly maxParallelSubCalls: number + private ptcTransport: ToolDefinition | undefined + + constructor(private readonly ctx: Context, config: Partial = {}, private readonly maxParallel: () => number = () => 10) { + this.maxParallelSubCalls = Config(config).maxParallelSubCalls + const tools = ctx.tools + this.sections = [ + { name: 'tools:ptc-only', order: ctx.systemPrompt.getSectionOrder('PTC_ONLY'), text: PTC_ONLY_INSTRUCTION, interpolate: false }, + { name: 'tools:sdk', order: ctx.systemPrompt.getSectionOrder('TOOLS_SDK'), text: tools => this.renderSdk(tools), interpolate: false }, + ] + this.pipeline = new ToolExecutionPipeline(ctx, { + get: (name, scope) => name === RUN_CODE_NAME ? this.requirePtcTransport() : tools.get(name, scope), + reject: (name, nested) => !nested && name !== RUN_CODE_NAME + ? new ToolNotFoundError(name, `only \`${RUN_CODE_NAME}\` is callable directly — call \`${name}\` from inside a \`${RUN_CODE_NAME}\` program instead`) + : undefined, + }) + } + + describe(_tools: readonly ToolDefinition[]): ToolModelInput { + return { schemas: [projectToolSchema(this.requirePtcTransport(), false)], knownNames: [RUN_CODE_NAME] } + } + + private renderSdk(tools: readonly ToolDefinition[]): string { + const runtime = requirePtcRuntime(this.ctx) + const render = SDK_RENDERERS[runtime.language] + return render(tools.map(definition => ({ + ...projectToolSchema(definition, false), + output: definition.output.schema, + })), runtime.programFormat) + } + + execute(input: ToolBatchInput): Promise { + return executeToolBatch(input, this.pipeline, this.maxParallel) + } + + /** Keep the reserved transport outside registered capabilities and their restrictions. */ + private requirePtcTransport(): ToolDefinition { + this.ptcTransport ??= createRunCodeTool(this.ctx, this.pipeline, this.maxParallelSubCalls) + return this.ptcTransport + } +} + +export { Config } from './config.ts' +export { CodeRunFailedError } from './errors.ts' +export type { PtcDispatchLog } from './runtime-types.ts' +export type { PtcDispatchStartEventData, PtcDispatchEventData } from './types.ts' +export { jsonSchemaToTs, renderToolsSdk, type ToolSdkSchema } from './ts-types.ts' +export { jsonSchemaToPy, renderToolsSdkPy } from './py-types.ts' + +/** Cordis plugin name. */ +export const name = 'ptc-tool-executor' +/** Activate the executor only while its registry, prompt service, and runtime are available. */ +export const inject = ['tools', 'systemPrompt', 'ptcRuntime'] + +/** + * Select PTC execution in the mounting scope. + * @param ctx - context owning this executor's selection and lifetime. + * @param config - program subcall concurrency limits. + */ +export function apply(ctx: Context, config: Config): void { + ctx.tools.setExecutor(new PtcExecutor(ctx, config)) +} diff --git a/packages/core/tools/src/ptc-calls.ts b/packages/core/tool-executor-ptc/src/ptc-calls.ts similarity index 91% rename from packages/core/tools/src/ptc-calls.ts rename to packages/core/tool-executor-ptc/src/ptc-calls.ts index 16de96380c..c088ff92b4 100644 --- a/packages/core/tools/src/ptc-calls.ts +++ b/packages/core/tool-executor-ptc/src/ptc-calls.ts @@ -15,8 +15,8 @@ export class PtcCalls { private disposed = false /** - * Observe policy changes for the owning tool registry. - * @param ctx - Registry lifecycle and Session events. + * Observe policy changes for the owning executor. + * @param ctx - Executor lifecycle and Session events. */ constructor(ctx: Context) { ctx.on('session/event', (session, event) => { @@ -28,7 +28,7 @@ export class PtcCalls { ctx.effect(() => async () => { this.disposed = true const calls = [...this.calls.values()].flatMap(calls => [...calls]) - for (const call of calls) call.controller.abort(new Error('PTC tool registry disposed')) + for (const call of calls) call.controller.abort(new Error('PTC tool executor disposed')) await Promise.all(calls.map(call => call.done)) }, 'PTC calls') } @@ -48,7 +48,7 @@ export class PtcCalls { await Promise.all(pending.map(call => call.done)) } signal.throwIfAborted() - if (this.disposed) throw new Error('PTC tool registry disposed') + if (this.disposed) throw new Error('PTC tool executor disposed') const controller = new AbortController() const done = Promise.withResolvers() const call = { mode: mode(), controller, done: done.promise } diff --git a/packages/core/tools/src/ptc.ts b/packages/core/tool-executor-ptc/src/ptc.ts similarity index 77% rename from packages/core/tools/src/ptc.ts rename to packages/core/tool-executor-ptc/src/ptc.ts index b3d85428c1..c24cd0984c 100644 --- a/packages/core/tools/src/ptc.ts +++ b/packages/core/tool-executor-ptc/src/ptc.ts @@ -3,42 +3,35 @@ * tools through nested executions scheduled under the native concurrency * contract; each sub-dispatch is logged for reconstruction, while only the * outer curated result enters model history. - * @module @deepseek-ai/dsh-tools/src/ptc + * @module */ +import type { PtcDispatchLog } from './runtime-types.ts' import { symbols, type Context } from '@deepseek-ai/cordis' import { brandString } from '@deepseek-ai/dsh-brand' -import { createUserMessage, HarnessError } from '@deepseek-ai/dsh-llm' -declare module '@deepseek-ai/dsh-llm' { - interface MessageSourceMap { - /** Images deferred from a successful PTC subcall's final result. */ - 'ptc-mode': { kind: 'ptc-mode' } - } -} +import { createUserMessage } from '@deepseek-ai/dsh-llm' import type { ContentBlock, ToolCallId, ToolSchema } from '@deepseek-ai/dsh-llm' import type { PtcBindingFunction, PtcRunResult, PtcRunSandbox, PtcRuntime } from '@deepseek-ai/dsh-ptc-runtime' import { approveEscalation, ESCALATION_TARGETS, validateEscalationArgs } from '@deepseek-ai/dsh-sandbox' import type { SandboxExecutionPolicy } from '@deepseek-ai/dsh-sandbox' -import type { ApprovalService } from '@deepseek-ai/dsh-user-approval' +import type {} from '@deepseek-ai/dsh-user-approval' +import type {} from '@deepseek-ai/dsh-working-directory' +import type {} from '@deepseek-ai/dsh-sandbox-policy' import { deepFreeze, snapshotJsonValue, type JsonValue } from '@deepseek-ai/dsh-util-values' -import { defineTool, parameterSchemaSpecToJsonSchema } from './schema.ts' +import { defineTool, parameterSchemaSpecToJsonSchema, RUN_CODE_NAME } from '@deepseek-ai/dsh-tools' +import type { ToolExecutionPipeline } from '@deepseek-ai/dsh-tools/pipeline' +import { toolErrorMessage } from '@deepseek-ai/dsh-tools/pipeline' +import { scopeTarget } from '@deepseek-ai/dsh-scope' import { PtcCalls } from './ptc-calls.ts' -import { TOOL_RUNTIME_SCHEDULER } from './index.ts' -import type { PtcDispatchLog, ToolDefinition, ToolExecutionResult, ToolRuntime, ToolRunContext } from './index.ts' +import { CodeRunFailedError } from './errors.ts' +import type { ToolDefinition, ToolExecutionResult, ToolRunContext } from '@deepseek-ai/dsh-tools' import type {} from './types.ts' /** The model-facing name of the PTC mode tool. */ -export const RUN_CODE_NAME = 'run_code' +export { RUN_CODE_NAME } -/** - * The language-specific `run_code` schema text: the tool `description` and its - * `code` parameter description, kept together so a language's two model-facing - * strings share one source of truth. Keyed by `PtcRuntime.language`, mirroring - * `SDK_RENDERERS` in {@link ./index.ts}. The emitted flavor MUST match the - * semantics the same language's SDK instructions promise, so the model never - * receives a TypeScript schema beside a Python SDK (or vice versa). - */ +/** Language-specific tool and code-parameter descriptions. */ interface RunCodeFlavor { /** The tool `description` the model sees for this language. */ readonly description: string @@ -69,23 +62,33 @@ const TYPESCRIPT_FUNCTION_FLAVOR = runCodeFlavor('TypeScript', { }) const PYTHON_FLAVOR = runCodeFlavor('Python') -/** - * The languages PTC mode ships a presentation for. Both per-language tables — - * {@link RUN_CODE_FLAVORS} here and `SDK_RENDERERS` in {@link ./index.ts} — are - * checked against this union with `satisfies`, so a language added to one and - * not the other fails `typecheck` instead of waiting for a runtime that reports - * it. The tables stay declared `Record` because `PtcRuntime.language` - * is an unconstrained `string`: this union pins what the harness ships, while the - * `Object.hasOwn` guards reject what a mounted runtime may report. - */ +/** Languages with both a transport schema and an SDK renderer. */ export type PtcSdkLanguage = 'typescript' | 'python' -/** Per-language `run_code` schema flavors (see {@link RunCodeFlavor}); one entry per {@link PtcSdkLanguage}. */ -const RUN_CODE_FLAVORS: Record = { +type SupportedPtcRuntime = PtcRuntime & { readonly language: PtcSdkLanguage } + +const RUN_CODE_FLAVORS = { typescript: TYPESCRIPT_FLAVOR, python: PYTHON_FLAVOR, } satisfies Record +/** + * Read the active runtime, rejecting missing providers and unsupported languages. + * @param ctx - executor context containing the runtime provider. + * @returns the runtime with a supported SDK language. + */ +export function requirePtcRuntime(ctx: Context): SupportedPtcRuntime { + const runtime = ctx.get('ptcRuntime') + if (runtime === undefined) { + throw new Error('dsh-tools: mode "ptc" requires a PTC runtime — load a ctx.ptcRuntime implementation (e.g. @deepseek-ai/dsh-ptc-runtime-node) or load @deepseek-ai/dsh-tool-executor-native') + } + if (!Object.hasOwn(RUN_CODE_FLAVORS, runtime.language)) { + const known = Object.keys(RUN_CODE_FLAVORS).map(name => JSON.stringify(name)).join(', ') + throw new Error(`dsh-tools: no SDK renderer registered for runtime language ${JSON.stringify(runtime.language)} (known: ${known})`) + } + return runtime as SupportedPtcRuntime +} + /** * The `description` parameter's model-facing description: language-independent * (the UI label contract is the same for every runtime), shared between the @@ -104,9 +107,7 @@ const RUN_CODE_CONTROLS = { justification: { type: 'string', description: 'Reason this complete program needs wider access, shown to the user for approval. Use the language of the user’s current request.' }, } as const -function controlParameters(runtime: PtcRuntime | undefined) { - // Catalog readers have no mounted runtime; real model assembly requires one. - if (runtime === undefined) return RUN_CODE_CONTROLS +function controlParameters(runtime: PtcRuntime) { return { ...runtime.timeout === undefined ? {} : { timeoutMs: { ...RUN_CODE_CONTROLS.timeoutMs, @@ -119,58 +120,17 @@ function controlParameters(runtime: PtcRuntime | undefined) { } } -function escalationGuidance(runtime: PtcRuntime | undefined): string { - return runtime?.sandboxMode === undefined ? '' +function escalationGuidance(mode: PtcRuntime['sandboxMode']): string { + return mode === undefined ? '' : ' A sandbox escalation approves this complete program for one execution only. Nested tools retain their own policies and approvals. Request wider access only after evidence of a denial. Earlier effects may already have completed: inspect them before explicitly retrying. Programs are never replayed automatically.' } -/** - * Resolve the {@link RunCodeFlavor} for the loaded runtime's language, read at - * schema-emission time so the model-visible `run_code` schema always matches - * the SDK section's language. `peekRuntime` returns `undefined` only when no - * runtime is mounted, which reaches this function through definition readers - * and `schemas()` — the doc-catalog harvest is the only shipped one, and none - * of them feeds a model, because `wireSchemas` calls `requirePtcRuntime` - * before projecting — so that path degrades to {@link TYPESCRIPT_FLAVOR}. A - * mounted runtime whose language has no flavor entry fails loud, exactly as - * `requirePtcRuntime` rejects it at assembly. Keeping this table in step with - * `SDK_RENDERERS` is the compiler's job ({@link PtcSdkLanguage}); what this - * guard owns is the runtime-supplied language neither table knows, which never - * yields a wrong-language schema for a real runtime. - */ -function resolveFlavor(peekRuntime: () => PtcRuntime | undefined): RunCodeFlavor { - const runtime = peekRuntime() - if (runtime === undefined) { - // No runtime mounted: reached by definition readers and `schemas()`, of - // which the doc-catalog harvest is the only shipped one. None feeds a - // model — `wireSchemas` calls `requirePtcRuntime` before projecting, so - // the assembly path never arrives here. Degrade to the TS default. - return TYPESCRIPT_FLAVOR - } - // Own-property read: a language like `toString`/`constructor` would otherwise - // resolve an inherited Object.prototype member as a flavor. - const flavor = RUN_CODE_FLAVORS[runtime.language] - if (!Object.hasOwn(RUN_CODE_FLAVORS, runtime.language) || flavor === undefined) { - const known = Object.keys(RUN_CODE_FLAVORS).map(name => JSON.stringify(name)).join(', ') - throw new Error(`dsh-tools: no run_code schema flavor registered for runtime language ${JSON.stringify(runtime.language)} (known: ${known})`) - } +/** Match schema text to the runtime language and program syntax. */ +function resolveFlavor(runtime: SupportedPtcRuntime): RunCodeFlavor { return runtime.language === 'typescript' && runtime.programFormat === 'function-body' - ? TYPESCRIPT_FUNCTION_FLAVOR : flavor + ? TYPESCRIPT_FUNCTION_FLAVOR : RUN_CODE_FLAVORS[runtime.language] } -/** - * Thrown by `run_code` when the program run itself failed — a program - * exception, a budget expiry, an abort, or substrate death. Extends - * {@link HarnessError} (`code: 'CODE_RUN_FAILED'`); the registry's execution - * pipeline converts it into a structured `isError` result whose text carries - * the failure kind plus the captured logs, so the model can self-correct. - */ -export class CodeRunFailedError extends HarnessError { - constructor(message: string) { - super(message, 'CODE_RUN_FAILED') - this.name = 'CodeRunFailedError' - } -} /** * Snapshot one binding call's argument as lossless JSON, then snapshot that @@ -288,47 +248,50 @@ function renderValue(value: JsonValue): string { /** Canonical value returned by the outer PTC mode transport. */ type RunCodeOutput = { logs: string[]; result?: JsonValue; sandbox?: PtcRunSandbox } -/** - * Registry-private capabilities the bridge receives at construction — the - * `requireRuntime` idiom: operations only the owning registry can mint stay - * off its public service API and flow here as closures instead. - */ -export interface RunCodeBridgeOptions { - /** Captures the calling Session current directory before program creation. */ - resolveWorkingDirectory: (exec: ToolRunContext) => Promise - /** Reads the approval channel when a program requests a wider sandbox mode. */ - peekApprover: () => ApprovalService | undefined - /** Resolves standing Session authority only for a runtime that enforces file policy. */ - resolveSandboxPolicy: (exec: ToolRunContext) => SandboxExecutionPolicy - /** Resolves `ctx.ptcRuntime` or throws the loud misconfiguration error (shared with the registry's assembly-time checks). */ - requireRuntime: () => PtcRuntime - /** - * Reads `ctx.ptcRuntime` without throwing: `undefined` when none is mounted. - * Lets schema emission tell "no runtime" (degrade to TS; the readers that - * reach it are {@link resolveFlavor}'s) apart from "unknown language" (fail - * loud). - */ - peekRuntime: () => PtcRuntime | undefined - /** The run's overlap cap for parallel-classified sub-calls (the registry passes its validated `maxParallelSubCalls`). */ - maxParallel: number - /** Runs the contained `tools/ptc-dispatch-log` waterfall over one settled sub-dispatch (the registry's private invoker). */ - shapeDispatchLog: (dispatch: PtcDispatchLog) => Promise +/** Unowned programs do not require Session directory state. */ +async function resolveWorkingDirectory(ctx: Context, exec: ToolRunContext): Promise { + if (exec.agent === undefined) return undefined + const directories = ctx.get('workingDirectory') + if (directories === undefined) throw new Error('dsh-tools: run_code with an Agent requires workingDirectory') + return directories.ensure(exec.agent, exec.signal) +} + +/** Resolve standing authority for a runtime that enforces file policy. */ +function resolveSandboxPolicy(ctx: Context, exec: ToolRunContext): SandboxExecutionPolicy { + const policy = ctx.get('sandboxPolicy') + if (policy === undefined) throw new Error('dsh-tools: confined PTC runtime requires sandboxPolicy') + return policy.resolve(exec.agent === undefined ? {} : { session: exec.agent.session }) } /** - * Build the `run_code` {@link ToolDefinition}: required `code` and - * `description` parameters, executed through the dispatch bridge described - * above. The - * registry reserves it as presentation infrastructure under non-native modes, - * outside the filterable global/scoped capability layers. - * @param registry - the owning registry (sub-calls go through its `execute`, - * bindings cover its registered tools). - * @param options - the registry-private capabilities described above. - * @param ctx - Owning registry context, independent of the calling Agent. - * @returns the registry-ready definition. + * Build the reserved `run_code` transport outside registered capabilities. + * @param ctx - executor context owning policy listeners and active program cleanup. + * @param executor - shared invocation pipeline for nested calls. + * @param maxParallel - maximum overlapping concurrency-safe subcalls. + * @returns the transport definition with runtime-dependent schema text. */ -export function createRunCodeTool(registry: ToolRuntime, options: RunCodeBridgeOptions, ctx: Context): ToolDefinition { - const { requireRuntime, peekRuntime, maxParallel, shapeDispatchLog } = options +export function createRunCodeTool( + ctx: Context, + executor: ToolExecutionPipeline, + maxParallel: number, +): ToolDefinition { + // Retain services for log settlement after the executor context becomes inactive. + const tools = ctx.tools + const logger = ctx.logger + + /** A failed log-content listener preserves the settled content and cannot fail the dispatch. */ + async function shapeDispatchLog(dispatch: PtcDispatchLog): Promise { + try { + return await ctx.waterfall( + scopeTarget(tools, dispatch.agent), 'tools/ptc-dispatch-log', dispatch, + () => Promise.resolve(dispatch.content), + ) + } catch (error: unknown) { + logger.warn(`tools: ptc-dispatch-log listener failed for ${dispatch.name}: ${toolErrorMessage(error)}; logging the original settled content`) + return dispatch.content + } + } + const stateOwners = new WeakMap>() const calls = new PtcCalls(ctx) const definition = defineTool({ @@ -371,7 +334,7 @@ export function createRunCodeTool(registry: ToolRuntime, options: RunCodeBridgeO const rendered = value.result === undefined ? '' : renderValue(value.result) const parts = [value.logs.join('\n'), rendered].filter(part => part.length > 0) if (value.sandbox?.enforcement === 'partial') parts.push('File sandbox enforcement is partial on this host.') - if (value.sandbox?.denied) parts.push(`The ${value.sandbox.mode} file sandbox denied an operation.${escalationGuidance(peekRuntime())}`) + if (value.sandbox?.denied) parts.push(`The ${value.sandbox.mode} file sandbox denied an operation.${escalationGuidance(ctx.get('ptcRuntime')?.sandboxMode)}`) return [{ type: 'text', text: parts.length > 0 ? parts.join('\n') : '(run_code completed with no output)' }] }, }, @@ -379,7 +342,7 @@ export function createRunCodeTool(registry: ToolRuntime, options: RunCodeBridgeO if (args.description.trim().length === 0) { throw new Error('invalid description: expected a non-empty string') } - const runtime = requireRuntime() + const runtime = requirePtcRuntime(ctx) const run = async (callSignal: AbortSignal): Promise => { validateEscalationArgs(args.sandbox_permissions, args.justification) if (args.timeoutMs !== undefined && runtime.timeout === undefined) { @@ -388,7 +351,7 @@ export function createRunCodeTool(registry: ToolRuntime, options: RunCodeBridgeO if (args.timeoutMs !== undefined && (!Number.isFinite(args.timeoutMs) || args.timeoutMs <= 0)) { throw new Error('invalid timeoutMs: expected a positive finite number') } - const standingPolicy = runtime.sandboxMode === undefined ? undefined : options.resolveSandboxPolicy(exec) + const standingPolicy = runtime.sandboxMode === undefined ? undefined : resolveSandboxPolicy(ctx, exec) let policy = standingPolicy if (args.sandbox_permissions !== undefined && args.justification !== undefined) { if (standingPolicy === undefined) throw new Error('sandbox_permissions is not available for this PTC runtime') @@ -398,12 +361,12 @@ export function createRunCodeTool(registry: ToolRuntime, options: RunCodeBridgeO effectiveMode: standingPolicy.mode, subject: 'program', }, { - approver: options.peekApprover(), agent: exec.agent, callId: exec.callId, + approver: ctx.get('approval'), agent: exec.agent, callId: exec.callId, toolName: RUN_CODE_NAME, signal: callSignal, }) policy = { ...standingPolicy, mode: approvedMode } } - const cwd = await options.resolveWorkingDirectory({ ...exec, signal: callSignal }) + const cwd = await resolveWorkingDirectory(ctx, { ...exec, signal: callSignal }) callSignal.throwIfAborted() // The run-scoped abort: follows the outer signal in, and fires when the @@ -418,7 +381,7 @@ export function createRunCodeTool(registry: ToolRuntime, options: RunCodeBridgeO // The per-run scheduler uses the registry's staged interface and follows // the same concurrency rules as the native loop. It also follows the // native loop's SEQUENCING: every ordered stage (the dispatch-start - // append, prepare = pre-execute/guards, finalize/finish = post-execute, + // append, prepare = pre-execute/guards, complete = post-execute, // context deferral, the settle append) runs inside ONE driver lane, so // ordered policy stages never overlap each other and only the // around-dispatch/body stage runs concurrently. Starts are strictly @@ -555,7 +518,6 @@ export function createRunCodeTool(registry: ToolRuntime, options: RunCodeBridgeO signal: runController.signal, } type DispatchOutcome = { isError: true; message: string } | { isError: false; value: JsonValue } - const scheduler = registry[TOOL_RUNTIME_SCHEDULER] const outcome = await new Promise((resolve, reject) => { // Set by the dispatch stage (or start() for a pre-settled result): what commit() finalizes in submission order. let parked: @@ -606,7 +568,7 @@ export function createRunCodeTool(registry: ToolRuntime, options: RunCodeBridgeO settled: false, // Re-read per driver pass against the same agent view the SDK // declared; fail-closed exclusive when undeclared/invalid. - classify: () => registry.executionMode(input).kind, + classify: () => executor.executionMode(input).kind, abandon: () => { reject(new Error(`run_code run is over (${String(runController.signal.reason)}); ${name} tool call abandoned`)) }, @@ -621,9 +583,9 @@ export function createRunCodeTool(registry: ToolRuntime, options: RunCodeBridgeO // Ordered prepare runs INSIDE the driver lane: the next entry's // pre-execute waits for this resolution, as under the native // scheduler. Only the launched body below overlaps. - const prepared = await scheduler.prepare(input) + const prepared = await executor.prepare(input) if (prepared.kind === 'dispatch') { - this.flight = scheduler.dispatch(prepared.exec).then((dispatchOutcome) => { + this.flight = executor.dispatch(prepared.exec).then((dispatchOutcome) => { parked = { kind: dispatchOutcome.kind, exec: prepared.exec, result: dispatchOutcome.result } this.settled = true }) @@ -635,9 +597,7 @@ export function createRunCodeTool(registry: ToolRuntime, options: RunCodeBridgeO async commit(): Promise { /* v8 ignore next -- commit() runs only after `settled` flipped, which set parked. */ if (parked === undefined) return - const result = parked.kind === 'post-result' - ? await scheduler.finalize(parked.exec, parked.result) - : scheduler.finish(parked.exec, parked.result) + const result = await executor.complete(parked.exec, parked) if (!result.isError && result.content.some(block => block.type === 'image')) { exec.deferContext(createUserMessage({ content: result.content, @@ -688,8 +648,7 @@ export function createRunCodeTool(registry: ToolRuntime, options: RunCodeBridgeO // restricted globals vanish) — the same view the SDK section declared, // so a program can bind exactly what its prompt promised; sub-dispatch // re-resolves per call through the same view (exec.agent threads down). - for (const schema of registry.schemas(exec.agent)) { - if (schema.name === RUN_CODE_NAME) continue + for (const schema of tools.schemas(exec.agent)) { Object.defineProperty(functions, schema.name, { enumerable: true, value: binding(deepFreeze(schema)) }) } @@ -701,7 +660,7 @@ export function createRunCodeTool(registry: ToolRuntime, options: RunCodeBridgeO let runtimes = stateOwners.get(agent) if (runtimes === undefined) stateOwners.set(agent, runtimes = new WeakSet()) // Cordis can wrap service returns repeatedly; cleanup belongs to the original provider. - let provider = runtime + let provider: PtcRuntime = runtime for (;;) { const original = (provider as PtcRuntime & { [symbols.original]?: PtcRuntime })[symbols.original] if (original === undefined) break @@ -737,7 +696,7 @@ export function createRunCodeTool(registry: ToolRuntime, options: RunCodeBridgeO const logsText = result.logs.length > 0 ? `\nCaptured output:\n${result.logs.join('\n')}` : '' const sandboxText = result.sandbox === undefined ? '' : `\nFile sandbox: ${result.sandbox.mode}${result.sandbox.enforcement === undefined ? '' : `; enforcement: ${result.sandbox.enforcement}`}${result.sandbox.denied ? '; operation denied' : ''}.` - throw new CodeRunFailedError(`code run failed (${result.error.kind}): ${result.error.message}${logsText}${sandboxText}${result.sandbox?.denied ? escalationGuidance(runtime) : ''}`) + throw new CodeRunFailedError(`code run failed (${result.error.kind}): ${result.error.message}${logsText}${sandboxText}${result.sandbox?.denied ? escalationGuidance(runtime.sandboxMode) : ''}`) } return { logs: result.logs, @@ -749,7 +708,7 @@ export function createRunCodeTool(registry: ToolRuntime, options: RunCodeBridgeO } } return runtime.sandboxMode === undefined || exec.agent === undefined ? run(exec.signal) - : calls.run(exec.agent.session.id, exec.signal, () => options.resolveSandboxPolicy(exec).mode, run) + : calls.run(exec.agent.session.id, exec.signal, () => resolveSandboxPolicy(ctx, exec).mode, run) }, // The model-authored description is the call's always-visible UI label // (the bash `description` precedent); the program itself rides rawInput. @@ -763,30 +722,30 @@ export function createRunCodeTool(registry: ToolRuntime, options: RunCodeBridgeO // title and reads durable result content without duplicating a large raw // result into the host view payload. }) - // Resolve the language flavor lazily, at the moment the registry projects the - // schema (`schemaOf` destructures `description`/`parameters`). The definition - // is minted once at registration, before a runtime is known; deferring here - // is the least invasive point that still emits the loaded runtime's language. + // Read current runtime instructions and controls whenever the schema is projected. Object.defineProperty(definition, 'description', { enumerable: true, get: () => { - const runtime = peekRuntime() - const instructions = runtime?.executionInstructions - return resolveFlavor(peekRuntime).description + const runtime = requirePtcRuntime(ctx) + const instructions = runtime.executionInstructions + return resolveFlavor(runtime).description + (instructions ? ` ${instructions}` : '') - + (runtime === undefined ? '' : " Each program starts in the Session's current directory. Running programs keep their initial directory.") - + escalationGuidance(runtime) + + " Each program starts in the Session's current directory. Running programs keep their initial directory." + + escalationGuidance(runtime.sandboxMode) }, }) Object.defineProperty(definition, 'parameters', { enumerable: true, // Recompile through the same spec→schema projection defineTool used, so // the emitted schema always matches the validated specification. - get: () => parameterSchemaSpecToJsonSchema({ - code: { type: 'string', required: true, description: resolveFlavor(peekRuntime).codeDescription }, - description: { type: 'string', required: true, description: RUN_CODE_DESCRIPTION_PARAM_DESCRIPTION }, - ...controlParameters(peekRuntime()), - }), + get: () => { + const runtime = requirePtcRuntime(ctx) + return parameterSchemaSpecToJsonSchema({ + code: { type: 'string', required: true, description: resolveFlavor(runtime).codeDescription }, + description: { type: 'string', required: true, description: RUN_CODE_DESCRIPTION_PARAM_DESCRIPTION }, + ...controlParameters(runtime), + }) + }, }) return definition } diff --git a/packages/core/tools/src/py-types.ts b/packages/core/tool-executor-ptc/src/py-types.ts similarity index 98% rename from packages/core/tools/src/py-types.ts rename to packages/core/tool-executor-ptc/src/py-types.ts index 65110f6b51..2d5e9d9e5a 100644 --- a/packages/core/tools/src/py-types.ts +++ b/packages/core/tool-executor-ptc/src/py-types.ts @@ -5,16 +5,16 @@ * store, keyed by the loaded {@link @deepseek-ai/dsh-ptc-runtime#PtcRuntime.language | PTC * runtime's language}. * - * Under `mode: 'ptc'` the native tool schemas are omitted from the request, so this generated - * SDK is the model's ONLY source for each tool's argument names, required fields, types, + * With the PTC executor selected, native tool schemas are omitted from the request. This generated + * SDK is the model's only source for each tool's argument names, required fields, types, * descriptions, and canonical output types. Object-shaped arguments and outputs render as one * named `TypedDict` per tool (and per nested object), not an opaque `dict[str, Any]`, so the * shape survives into the program under the mode that has nothing else to carry it. - * @module @deepseek-ai/dsh-tools/src/py-types + * @module */ -import { assertSupportedJsonSchema } from './json-schema.ts' -import type { JsonSchemaNode, JsonSchemaScalar } from './json-schema.ts' +import { assertSupportedJsonSchema } from '@deepseek-ai/dsh-tools' +import type { JsonSchemaNode, JsonSchemaScalar } from '@deepseek-ai/dsh-tools' import type { ToolSdkSchema } from './ts-types.ts' /** @@ -31,7 +31,7 @@ const IDENTIFIER = /^[\p{XID_Start}_]\p{XID_Continue}*$/u * Python identifiers are not ASCII: `路径` is as legal a field name as `path`, * and rejecting it would degrade the whole enclosing object, dropping every * field's name, requiredness, and type — information whose only source under - * `mode: 'ptc'` is this generated text. + * PTC execution is this generated text. * * NFKC stability is a second and separate condition, because CPython * normalizes identifiers at compile time while JSON keys are compared as @@ -162,7 +162,7 @@ interface RenderState { * (`SyntaxError: source code string cannot contain null bytes`), whether it * sits in a docstring or in a comment, so one such byte anywhere in a schema * description would make the whole generated SDK unparseable — under - * `mode: 'ptc'`, the model's only declaration of the tools. The rest are + * PTC execution, the model's only declaration of the tools. The rest are * legal but invisible; escaping them with the same rule keeps the emitted text * readable and the treatment uniform. * @@ -578,7 +578,7 @@ function renderType(schema: unknown, className: string, state: RenderState): str } } // TypedDict syntax cannot express openness, so an open object states it - // in-band: the annotation is advisory either way, and `mode: 'ptc'` + // in-band: the annotation is advisory either way, and PTC execution // omits the native schemas, making this line the model's only signal // that extra keys are accepted. if (node.additionalProperties !== false) { @@ -759,7 +759,7 @@ The available tools:` * (the caller excludes `run_code` itself). * @returns the complete section text. */ -export function renderToolsSdkPy(schemas: ToolSdkSchema[]): string { +export function renderToolsSdkPy(schemas: readonly ToolSdkSchema[]): string { const sorted = [...schemas].sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0) const state: RenderState = { classes: [], usedClassNames: new Set(), nextClassCounter: new Map(), typing: new Set(['Protocol']) } // ONE ordered member stream, matching the documented lexicographic contract @@ -777,8 +777,8 @@ export function renderToolsSdkPy(schemas: ToolSdkSchema[]): string { // of that method's body. Emitted before the `async def` it would instead // become the `Tools` class docstring (for the first tool) or a dead // expression (for every later one), leaving every method undocumented — - // and under `mode: 'ptc'` this SDK is the model's only description of - // what a tool does. A docstring is a complete body, so the `...` stub is + // and with PTC selected, this SDK is the model's only description of what + // a tool does. A docstring is a complete body, so the `...` stub is // only for the description-less case. const doc = docLines(schema.description, 2) members.push(doc.length > 0 diff --git a/packages/core/tool-executor-ptc/src/runtime-types.ts b/packages/core/tool-executor-ptc/src/runtime-types.ts new file mode 100644 index 0000000000..b36b2731bc --- /dev/null +++ b/packages/core/tool-executor-ptc/src/runtime-types.ts @@ -0,0 +1,48 @@ +/** Host PTC dispatch policy declarations with live Agent and tool execution types. @module */ +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { ContentBlock, ToolCallId } from '@deepseek-ai/dsh-llm' +import type { Scoped } from '@deepseek-ai/dsh-scope' +import type { ToolExecution, ToolsService } from '@deepseek-ai/dsh-tools' + +/** + * One settled `run_code` sub-dispatch about to be logged, as seen by the + * `tools/ptc-dispatch-log` waterfall: the parent execution (session owner, + * outer call identity), the sub-call identity, and the outcome whose durable + * copy a listener may reshape. `content` is the RENDERED result projection + * (what a native `tool/result` would carry) — the program itself received + * the structured `value` (or just the error message on failure); only the + * `tool/ptc-dispatch` event's copy changes. + */ +export interface PtcDispatchLog { + /** The outer `run_code` execution. */ + readonly exec: ToolExecution + /** The calling agent (the scope routing key and the spill owner), when the outer call has one. */ + readonly agent?: Agent + /** Opaque sub-call id; new calls use `:ptc:`. */ + readonly subCallId: ToolCallId + /** The dispatched sub-tool name. */ + readonly name: string + /** Whether the sub-call settled as an error. */ + readonly isError: boolean + /** The sub-call's complete model-facing content (the settle event's default payload). */ + readonly content: ContentBlock[] +} + +declare module '@deepseek-ai/cordis' { + interface Events { + /** + * Allow a listener to replace content in the DURABLE LOG COPY of one + * `run_code` sub-dispatch outcome before the bridge appends its + * `tool/ptc-dispatch` event. `next()` keeps the + * content unchanged; a listener may return replacement blocks (e.g. the + * spill policy's preview + locator for an oversized text result). Only the + * logged copy is affected — the program already received the complete + * value, and the model sees neither. A throwing listener is contained: + * the bridge falls back to logging the original settled content. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's dispatches. + * @param dispatch - the parent execution, sub-call identity, and the settled content to log. + * @mode waterfall + */ + 'tools/ptc-dispatch-log'(this: Scoped, dispatch: PtcDispatchLog, next: () => Promise): Promise + } +} diff --git a/packages/core/tools/src/ts-types.ts b/packages/core/tool-executor-ptc/src/ts-types.ts similarity index 97% rename from packages/core/tools/src/ts-types.ts rename to packages/core/tool-executor-ptc/src/ts-types.ts index 1c1444e0b1..007c95c16d 100644 --- a/packages/core/tools/src/ts-types.ts +++ b/packages/core/tool-executor-ptc/src/ts-types.ts @@ -7,12 +7,12 @@ */ import type { ToolSchema } from '@deepseek-ai/dsh-llm' -import { assertSupportedJsonSchema } from './json-schema.ts' -import type { JsonSchemaNode, JsonSchemaScalar } from './json-schema.ts' +import { assertSupportedJsonSchema } from '@deepseek-ai/dsh-tools' +import type { JsonSchemaNode, JsonSchemaScalar } from '@deepseek-ai/dsh-tools' /** Internal PTC mode projection: the model-facing schema plus the canonical output schema. */ -export interface ToolSdkSchema extends ToolSchema { +export interface ToolSdkSchema extends Readonly { /** Validated canonical value returned by the tool binding. */ - output: JsonSchemaNode + readonly output: JsonSchemaNode } /** Property names that are valid bare TS identifiers; anything else is quoted. */ @@ -309,7 +309,7 @@ function renderBashExample(schemas: ToolSdkSchema[], programFormat: 'function-bo * @param programFormat - Runtime syntax; function bodies support returning a result, REPLs print it. * @returns the complete section text. */ -export function renderToolsSdk(schemas: ToolSdkSchema[], programFormat: 'function-body' | 'repl' = 'function-body'): string { +export function renderToolsSdk(schemas: readonly ToolSdkSchema[], programFormat: 'function-body' | 'repl' = 'function-body'): string { const sorted = [...schemas].sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0) const argsMembers: string[] = [] const outputMembers: string[] = [] diff --git a/packages/core/tools/src/types.ts b/packages/core/tool-executor-ptc/src/types.ts similarity index 90% rename from packages/core/tools/src/types.ts rename to packages/core/tool-executor-ptc/src/types.ts index dc41c299a0..861ff1ff15 100644 --- a/packages/core/tools/src/types.ts +++ b/packages/core/tool-executor-ptc/src/types.ts @@ -1,7 +1,7 @@ /** - * Durable Tool event vocabulary shared with type-only consumers. + * Client-safe PTC dispatch events and message-source declarations. * - * @module @deepseek-ai/dsh-tools/types + * @module @deepseek-ai/dsh-tool-executor-ptc/types */ import type { ToolCallId } from '@deepseek-ai/dsh-llm/brand' @@ -61,3 +61,10 @@ declare module '@deepseek-ai/dsh-session/types' { 'tool/ptc-dispatch': PtcDispatchEventData } } + +declare module '@deepseek-ai/dsh-llm' { + interface MessageSourceMap { + /** Images deferred from a successful PTC subcall's final result. */ + 'ptc-mode': { kind: 'ptc-mode' } + } +} diff --git a/packages/core/tools/tests/ptc-calls.spec.ts b/packages/core/tool-executor-ptc/tests/ptc-calls.spec.ts similarity index 95% rename from packages/core/tools/tests/ptc-calls.spec.ts rename to packages/core/tool-executor-ptc/tests/ptc-calls.spec.ts index bf5dce527a..5fe9fd7ec8 100644 --- a/packages/core/tools/tests/ptc-calls.spec.ts +++ b/packages/core/tool-executor-ptc/tests/ptc-calls.spec.ts @@ -67,7 +67,7 @@ it('honors cancellation while waiting for a canceled predecessor', async () => { expect(next).not.toHaveBeenCalled() }) -it('unloads the registry only after calls stop and refuses late admission', async () => { +it('unloads the executor only after calls stop and refuses late admission', async () => { const { ctx, session, calls, mode, signal } = await setup() const cleanup = Promise.withResolvers() const stopped = Promise.withResolvers() @@ -79,11 +79,11 @@ it('unloads the registry only after calls stop and refuses late admission', asyn try { await stopped.promise const next = calls.run(session.id, signal, mode, async () => 'unexpected') - const rejected = expect(next).rejects.toThrow('PTC tool registry disposed') + const rejected = expect(next).rejects.toThrow('PTC tool executor disposed') cleanup.resolve(undefined) await first await rejected await disposed - await expect(calls.run(session.id, signal, mode, async () => 'unexpected')).rejects.toThrow('PTC tool registry disposed') + await expect(calls.run(session.id, signal, mode, async () => 'unexpected')).rejects.toThrow('PTC tool executor disposed') } finally { cleanup.resolve(undefined); await first; await disposed } }) diff --git a/packages/core/tools/tests/ptc.spec.ts b/packages/core/tool-executor-ptc/tests/ptc.spec.ts similarity index 91% rename from packages/core/tools/tests/ptc.spec.ts rename to packages/core/tool-executor-ptc/tests/ptc.spec.ts index c1e4475140..9972e5d8a0 100644 --- a/packages/core/tools/tests/ptc.spec.ts +++ b/packages/core/tool-executor-ptc/tests/ptc.spec.ts @@ -1,3 +1,8 @@ +import type { ScopeKey } from '@deepseek-ai/dsh-scope' +import type { ToolExecutionInput } from '@deepseek-ai/dsh-tools' +import * as PtcToolExecutor from '@deepseek-ai/dsh-tool-executor-ptc' +import { PtcExecutor, CodeRunFailedError } from '../src/index.ts' +import { NativeToolExecutor } from '@deepseek-ai/dsh-tool-executor-native' import { setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy' import { describe, expect, it, onTestFinished, vi } from 'vitest' import { provideWorkingDirectoryFixture } from '@deepseek-ai/dsh-agent-loop-testkit' @@ -10,10 +15,11 @@ import type { Scope } from '@deepseek-ai/dsh-scope' import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt' import { PtcRuntime } from '@deepseek-ai/dsh-ptc-runtime' import type { PtcRunRequest, PtcRunResult } from '@deepseek-ai/dsh-ptc-runtime' -import ToolRuntime, { CodeRunFailedError, RUN_CODE_NAME, TOOL_ABORTED_BEFORE_DISPATCH, defineContentToolFixture, defineTool } from '@deepseek-ai/dsh-tools' -import type { Config, JsonSchemaNode, PostToolDecision, ToolExecutionResult } from '@deepseek-ai/dsh-tools' +import ToolRuntime, { RUN_CODE_NAME, TOOL_ABORTED_BEFORE_DISPATCH, defineTool } from '@deepseek-ai/dsh-tools' +import { defineContentToolFixture } from '@deepseek-ai/dsh-tool-testkit' +import type { JsonSchemaNode, PostToolDecision, ToolExecutionResult } from '@deepseek-ai/dsh-tools' import type { Agent } from '@deepseek-ai/dsh-agent' -import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' +import SessionStore, { Session, SessionId, SessionSeq } from '@deepseek-ai/dsh-session' import ApprovalService, { type ApprovalOutcome, type ApprovalRequest } from '@deepseek-ai/dsh-user-approval' import type { SessionEventMap } from '@deepseek-ai/dsh-session' import type { JsonValue } from '@deepseek-ai/dsh-util-values' @@ -58,7 +64,7 @@ class FakeRuntime extends PtcRuntime { } interface SetupOptions { - mode?: Config['mode'] + mode?: 'native' | 'ptc' maxParallelSubCalls?: number runtime?: false | { language?: string } toolOrder?: string[] @@ -68,13 +74,18 @@ async function setup(options: SetupOptions = {}) { const ctx = new Context() provideWorkingDirectoryFixture(ctx) await ctx.plugin(SystemPrompt, { ...options.toolOrder ? { toolOrder: options.toolOrder } : {} }) - await ctx.plugin(ToolRuntime, { mode: options.mode ?? 'ptc', ...options.maxParallelSubCalls !== undefined ? { maxParallelSubCalls: options.maxParallelSubCalls } : {} }) + await ctx.plugin(ToolRuntime) + const executor = options.mode === 'native' ? new NativeToolExecutor(ctx) : new PtcExecutor(ctx, { ...options.maxParallelSubCalls !== undefined ? { maxParallelSubCalls: options.maxParallelSubCalls } : {} }) + ctx.tools.setExecutor(executor) let runtime: FakeRuntime | undefined if (options.runtime !== false) { await ctx.plugin(FakeRuntime, options.runtime ?? {}) runtime = ctx.ptcRuntime as FakeRuntime } - return { ctx, tools: ctx.tools, systemPrompt: ctx.systemPrompt, runtime: runtime! } + return { ctx, tools: { + get: (name: string, scope?: ScopeKey) => executor instanceof PtcExecutor && name === RUN_CODE_NAME ? executor['requirePtcTransport']() : ctx.tools.get(name, scope), + execute: (input: ToolExecutionInput) => executeCall(ctx, input), + }, systemPrompt: ctx.systemPrompt, runtime: runtime! } } /** Mint an agent scope configured like production that can register scoped tool policy. */ @@ -110,23 +121,37 @@ function fakeAgent(): { agent: Agent; events: { type: string; data: unknown }[] const events: { type: string; data: unknown }[] = [] const ctx = new Context() onTestFinished(async () => { await ctx.fiber.dispose() }) + let seq = 0 const agent = { ctx, session: { header: { cwd: '/workspace' }, - append: (type: string, data: unknown) => { events.push({ type, data }) }, + append: (type: string, data: unknown) => { + if (type.startsWith('tool/ptc-')) events.push({ type, data }) + return { seq: SessionSeq(++seq) } + }, }, } as Agent return { agent, events } } -/** Dispatch run_code through the registry pipeline, as the loop would. */ +/** Invoke one raw call through the selected model executor. */ +async function executeCall(ctx: Context, input: ToolExecutionInput): Promise { + const batch = await ctx.tools.execute({ + calls: [{ type: 'tool-call', id: input.callId, name: input.name, arguments: JSON.stringify(input.arguments) }], + signal: input.signal, + ...input.agent === undefined ? {} : { agent: input.agent }, + }) + return batch.results[0]!.result +} + +/** Dispatch run_code through a single-call batch. */ async function runCode( ctx: Context, code: string, extras: { agent?: Agent; signal?: AbortSignal; description?: string } = {}, ): Promise { - return ctx.tools.execute({ + return executeCall(ctx, { signal: testToolSignal, callId: ToolCallId('call-1'), name: RUN_CODE_NAME, @@ -173,8 +198,33 @@ it('logs detached frozen nested metadata without changing the binding value or l }) describe('mode-aware wire contribution', () => { - it('rejects an unsupported presentation mode in configuration', () => { - expect(() => ToolRuntime.Config({ mode: 'both' } as never)).toThrow() + it('waits for its runtime and follows runtime withdrawal and replacement', async () => { + const ctx = new Context() + onTestFinished(async () => { await ctx.fiber.dispose() }) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRuntime) + const executor = await ctx.plugin(PtcToolExecutor) + + await expect(runCode(ctx, 'return 1')).rejects.toThrow('selected tool executor is unavailable') + + const runtime = await ctx.plugin(FakeRuntime) + await executor.await() + const first = await ctx.systemPrompt.assemble() + expect(first.tools.map(tool => tool.name)).toEqual([RUN_CODE_NAME]) + expect(first.tools[0]?.description).toContain('TypeScript') + + await runtime.dispose() + await expect(runCode(ctx, 'return 1')).rejects.toThrow('selected tool executor is unavailable') + + await ctx.plugin(FakeRuntime, { language: 'python' }) + await executor.await() + const replacement = await ctx.systemPrompt.assemble() + expect(replacement.tools.map(tool => tool.name)).toEqual([RUN_CODE_NAME]) + expect(replacement.tools[0]?.description).toContain('Python') + }) + + it('rejects a non-positive PTC concurrency limit in configuration', () => { + expect(() => PtcToolExecutor.Config({ maxParallelSubCalls: 0 })).toThrow() }) it.each([ @@ -389,7 +439,7 @@ describe('mode-aware wire contribution', () => { expect(transports[0]?.description).toContain('Execute a TypeScript program') expect(assembly.sections.find(section => section.name === 'scoped-note')?.text).toBe('safe note') expect(assembly.sections.find(section => section.name === 'tools:sdk')?.text).toContain('scoped_safe:') - expect(ctx.tools.get(RUN_CODE_NAME, agent)).toBe(ctx.tools.get(RUN_CODE_NAME)) + expect(assembly.tools.map(tool => tool.name)).toEqual([RUN_CODE_NAME]) const result = await runCode(ctx, 'return 1', { agent }) expect(result.content).toEqual([{ type: 'text', text: '(run_code completed with no output)' }]) }) @@ -465,7 +515,7 @@ describe('mode-aware wire contribution', () => { it.each(['typescript', 'python'])('lists code before description for a %s runtime', async (language) => { const { ctx, systemPrompt } = await setup({ mode: 'ptc', runtime: { language } }) try { - const parameters = ctx.tools.get(RUN_CODE_NAME)!.parameters + const parameters = new PtcExecutor(ctx)['requirePtcTransport']().parameters expect(Object.keys(parameters.properties as Record).slice(0, 2)) .toEqual(['code', 'description']) expect(parameters).toHaveProperty('required', ['code', 'description']) @@ -477,7 +527,7 @@ describe('mode-aware wire contribution', () => { .toContain('arguments: `code`') // defineTool validates its captured static schema, independently of the runtime parameters getter. - const rejected = await ctx.tools.execute({ + const rejected = await executeCall(ctx, { signal: testToolSignal, callId: ToolCallId('argument-order'), name: RUN_CODE_NAME, arguments: {}, }) expect(rejected).toHaveProperty('error.message', @@ -531,35 +581,19 @@ describe('mode-aware wire contribution', () => { expect(codeParam.description).toBe("The Python program to execute, following this tool's execution instructions.") }) - it('resolves the run_code schema flavor lazily and fails loud on a language absent from the flavor table', async () => { - // The flavor getter reads the runtime directly (peekRuntime), so it — not - // requirePtcRuntime — owns the flavor-table guard. Keeping - // RUN_CODE_FLAVORS in step with SDK_RENDERERS is the compiler's job (both - // are `satisfies`-checked against PtcSdkLanguage), so what the guard - // covers is a mounted runtime naming a language absent from both tables, - // which throws when the schema is projected. Assembly's - // requirePtcRuntime rejects such a language earlier; this reaches the - // guard on its own. - const { ctx } = await setup({ mode: 'ptc', runtime: { language: 'ruby' } }) - const definition = ctx.tools.get(RUN_CODE_NAME) - // Names the known languages, symmetric with the SDK_RENDERERS guard: this - // is the reachable rejection, so it must be at least as diagnosable. - expect(() => definition?.description) - .toThrow(/no run_code schema flavor registered for runtime language "ruby" \(known: "typescript", "python"\)/) + it.each(['ruby', 'constructor'])('rejects transport schema reads for unsupported runtime language %s', async (language) => { + const { ctx } = await setup({ mode: 'ptc', runtime: { language } }) + const definition = new PtcExecutor(ctx)['requirePtcTransport']() + const message = `no SDK renderer registered for runtime language "${language}" (known: "typescript", "python")` + expect(() => definition.description).toThrow(message) + expect(() => definition.parameters).toThrow(message) }) - it('degrades the run_code flavor to TypeScript when no runtime is mounted', async () => { - // Any reader of the definition without a mounted runtime uses this fallback; the - // shipped one is the tool-catalog generator, which boots the registry under - // `mode: ptc` and reads run_code's schema WITHOUT a runtime. peekRuntime - // returns undefined there, so the flavor getter degrades to the TS default - // rather than throwing. None of those readers feeds a model: assembly goes - // through wireSchemas, which requires a runtime first. + it('rejects transport schema reads when no runtime is mounted', async () => { const { ctx } = await setup({ mode: 'ptc', runtime: false }) - const definition = ctx.tools.get(RUN_CODE_NAME) - expect(definition?.description).toContain('Execute a TypeScript program') - const params = definition?.parameters as { properties: { code: { description: string } } } - expect(params.properties.code.description).toBe("The TypeScript program to execute, following this tool's execution instructions.") + const definition = new PtcExecutor(ctx)['requirePtcTransport']() + expect(() => definition.description).toThrow('requires a PTC runtime') + expect(() => definition.parameters).toThrow('requires a PTC runtime') }) it("rejects the assembly when toolOrder names a native tool that mode 'ptc' no longer contributes", async () => { @@ -573,8 +607,9 @@ describe('mode-aware wire contribution', () => { provideWorkingDirectoryFixture(ctx) await ctx.plugin(SystemPrompt, {}) await ctx.plugin(FakeRuntime, {}) - const fiber = await ctx.plugin(ToolRuntime, { mode: 'ptc' }) - expect(ctx.tools.get(RUN_CODE_NAME)).toBeDefined() + const fiber = await ctx.plugin(ToolRuntime) + await ctx.plugin(PtcToolExecutor) + expect(new PtcExecutor(ctx)['requirePtcTransport']()).toBeDefined() await fiber.dispose() const assembly = await ctx.systemPrompt.assemble() expect(assembly.tools).toEqual([]) @@ -708,14 +743,8 @@ describe('the sub-dispatch scheduler (native concurrency contract)', () => { const gated = registerGated(ctx, 'safe_read', true) runtime.behavior = async (request) => { const tools = request.bindings[0]!.functions - const all = Promise.all([ - tools.safe_read!({ id: 'a' }), - tools.safe_read!({ id: 'b' }), - tools.safe_read!({ id: 'c' }), - ]) + const all = Promise.all(Array.from({ length: 3 }, (_, i) => tools.safe_read!({ id: String(i) }))) await expect.poll(() => gated.pending()).toBe(2) - // The third call waits for a slot. - expect(gated.pending()).toBe(2) gated.release() await expect.poll(() => gated.pending()).toBe(2) gated.releaseAll() @@ -723,7 +752,6 @@ describe('the sub-dispatch scheduler (native concurrency contract)', () => { return { logs: [], value: 'capped' } } const result = await runCode(ctx, 'program') - if (result.isError) console.error('CAP-FAIL:', (result.content[0] as { text: string }).text) expect(result.isError).toBe(false) expect(gated.peakLive()).toBe(2) }) @@ -1611,7 +1639,8 @@ describe('the run_code dispatch bridge', () => { const ctx = new Context() provideWorkingDirectoryFixture(ctx) await ctx.plugin(SystemPrompt, {}) - await ctx.plugin(ToolRuntime, { mode: 'ptc' }) + await ctx.plugin(ToolRuntime) + ctx.tools.setExecutor(new PtcExecutor(ctx)) const result = await runCode(ctx, 'program') expect(result.isError).toBe(true) expect((result.content[0] as { text: string }).text).toContain('requires a PTC runtime') @@ -1621,7 +1650,8 @@ describe('the run_code dispatch bridge', () => { const ctx = new Context() try { await ctx.plugin(SystemPrompt) - await ctx.plugin(ToolRuntime, { mode: 'ptc' }) + await ctx.plugin(ToolRuntime) + ctx.tools.setExecutor(new PtcExecutor(ctx)) await ctx.plugin(FakeRuntime) const runtime = ctx.ptcRuntime as FakeRuntime const calls = registerEcho(ctx) @@ -1647,7 +1677,7 @@ describe('the run_code dispatch bridge', () => { it('presents the model-authored description as the execute-card title over the program input', async () => { const { ctx } = await setup({ mode: 'ptc' }) - const tool = ctx.tools.get(RUN_CODE_NAME)! + const tool = new PtcExecutor(ctx)['requirePtcTransport']() // The description labels the card (the bash description precedent); the // program itself remains the expanded raw input. expect(tool.presentCall?.({ code: 'return 1', description: 'Return the constant one' })).toEqual({ @@ -1675,7 +1705,7 @@ describe('the run_code dispatch bridge', () => { runtime.behavior = () => Promise.resolve(output) const result = await runCode(ctx, 'return 1') - const tool = ctx.tools.get(RUN_CODE_NAME)! + const tool = new PtcExecutor(ctx)['requirePtcTransport']() expect(result.content).toEqual([{ type: 'text', text }]) // Presenters keep the pending program title and render this durable content @@ -1694,7 +1724,7 @@ describe('the run_code dispatch bridge', () => { }) const result = await runCode(ctx, 'return 1') - const tool = ctx.tools.get(RUN_CODE_NAME)! + const tool = new PtcExecutor(ctx)['requirePtcTransport']() expect(result.content).toEqual([{ type: 'text', text: preview }]) expect('presentResult' in tool).toBe(false) @@ -1708,7 +1738,7 @@ describe('the run_code dispatch bridge', () => { }) const result = await runCode(ctx, 'return 1') - const tool = ctx.tools.get(RUN_CODE_NAME)! + const tool = new PtcExecutor(ctx)['requirePtcTransport']() expect(result.isError).toBe(true) expect(result.content).toEqual([{ @@ -1988,28 +2018,31 @@ describe('the run_code dispatch bridge', () => { expect(derived[0]?.role).toBe('user') }) - it('direct construction rejects a non-positive parallel sub-call cap at load', async () => { + it('direct construction rejects a non-positive parallel subcall limit', async () => { const ctx = new Context() - provideWorkingDirectoryFixture(ctx) - await ctx.plugin(SystemPrompt, {}) - expect(() => new ToolRuntime(ctx, { mode: 'ptc', maxParallelSubCalls: 0 })) - .toThrow('maxParallelSubCalls must be a positive integer') + try { + await ctx.plugin(SystemPrompt) + expect(() => new PtcExecutor(ctx, { maxParallelSubCalls: 0 })).toThrow('maxParallelSubCalls') + } finally { await ctx.fiber.dispose() } }) - it('direct construction in PTC mode defaults the parallel sub-call cap', async () => { + it('direct construction exposes the PTC transport without configuration', async () => { const ctx = new Context() provideWorkingDirectoryFixture(ctx) await ctx.plugin(SystemPrompt, {}) - const registry = new ToolRuntime(ctx, { mode: 'ptc' }) - expect(registry.get(RUN_CODE_NAME)).toBeDefined() + await ctx.plugin(ToolRuntime) + const registry = new PtcExecutor(ctx) + expect(registry['requirePtcTransport']()).toBeDefined() }) it('defaults to native mode under direct construction with no config', async () => { const ctx = new Context() provideWorkingDirectoryFixture(ctx) await ctx.plugin(SystemPrompt, {}) - const registry = new ToolRuntime(ctx) - expect(registry.get(RUN_CODE_NAME)).toBeUndefined() + await ctx.plugin(ToolRuntime) + const registry = new NativeToolExecutor(ctx) + ctx.tools.setExecutor(registry) + expect(registry.describe(ctx.tools.definitions()).schemas.some(tool => tool.name === RUN_CODE_NAME)).toBe(false) const assembly = await ctx.systemPrompt.assemble() expect(assembly.sections.some(section => section.name === 'tools:sdk')).toBe(false) }) @@ -2017,9 +2050,11 @@ describe('the run_code dispatch bridge', () => { const ctx = new Context() provideWorkingDirectoryFixture(ctx) await ctx.plugin(SystemPrompt, {}) - const registry = new ToolRuntime(ctx, { mode: 'ptc' }) + await ctx.plugin(ToolRuntime) + const registry = new PtcExecutor(ctx) registerEcho(ctx, 'write') - const result = await registry.execute({ + ctx.tools.setExecutor(registry) + const result = await executeCall(ctx, { signal: testToolSignal, callId: ToolCallId('call-1'), name: 'write', @@ -2038,11 +2073,13 @@ describe('the run_code dispatch bridge', () => { const ctx = new Context() provideWorkingDirectoryFixture(ctx) await ctx.plugin(SystemPrompt, {}) - const registry = new ToolRuntime(ctx, { mode: 'ptc' }) + await ctx.plugin(ToolRuntime) + const registry = new PtcExecutor(ctx) registerEcho(ctx, 'write') const aborted = new AbortController() aborted.abort() - const result = await registry.execute({ + ctx.tools.setExecutor(registry) + const result = await executeCall(ctx, { signal: aborted.signal, callId: ToolCallId('call-1'), name: 'write', @@ -2066,7 +2103,7 @@ describe('per-agent presentation', () => { const calls = registerEcho(ctx) const { scope, agent } = await mintAgentScope(ctx) - scope.ctx.tools.presentAs('ptc') + scope.ctx.tools.setExecutor(new PtcExecutor(scope.ctx)) const coded = await systemPrompt.assemble({ scope: agent }) expect(coded.tools.map(tool => tool.name)).toEqual([RUN_CODE_NAME]) @@ -2074,7 +2111,7 @@ describe('per-agent presentation', () => { .toContain('echo') // Announced surface and callable surface must agree for THIS agent, whose // mode is its own rather than the deployment's. - const denied = await ctx.tools.execute({ + const denied = await executeCall(ctx, { signal: testToolSignal, callId: ToolCallId('coded-direct'), name: 'echo', @@ -2097,12 +2134,12 @@ describe('per-agent presentation', () => { // The preset's standing scope declares once; the agent only PARENTS to it // (the per-preset standing mount configuration has no per-agent declaration). const standing = await mintAgentScope(ctx, 'preset:ptc-like') - standing.scope.ctx.tools.presentAs('ptc') + standing.scope.ctx.tools.setExecutor(new PtcExecutor(standing.scope.ctx)) const joined = await mintAgentScope(ctx, 'joined-agent') bindScopeParent(joined.agent, standing.agent) const loner = await mintAgentScope(ctx, 'loner-agent') - expect(ctx.tools.get(RUN_CODE_NAME, joined.agent)).toBeDefined() + expect((await ctx.systemPrompt.assemble({ scope: joined.agent })).tools.find(tool => tool.name === RUN_CODE_NAME)).toBeDefined() const coded = await systemPrompt.assemble({ scope: joined.agent }) expect(coded.tools.map(tool => tool.name)).toEqual([RUN_CODE_NAME]) // Through the EXECUTOR, not just the wire: the deployment default is @@ -2110,14 +2147,14 @@ describe('per-agent presentation', () => { // scope's effective mode would announce [run_code] and still execute the // native call — the bypass, reopened for exactly the preset composition // `dsh-agent-tool-presentation` produces. - expect(ctx.tools.executionMode({ + expect(new PtcExecutor(ctx)['pipeline'].executionMode({ signal: testToolSignal, callId: ToolCallId('preset-coded-schedule'), name: 'echo', arguments: { value: 'joined' }, agent: joined.agent, })).toEqual({ kind: 'exclusive' }) - const denied = await ctx.tools.execute({ + const denied = await executeCall(ctx, { signal: testToolSignal, callId: ToolCallId('preset-coded-direct'), name: 'echo', @@ -2127,10 +2164,10 @@ describe('per-agent presentation', () => { expect(denied.error?.info).toEqual({ name: 'ToolNotFoundError', code: 'UNKNOWN_TOOL' }) expect(calls).toEqual([]) // A sibling that never parented stays native, as does the global view. - expect(ctx.tools.get(RUN_CODE_NAME, loner.agent)).toBeUndefined() + expect((await ctx.systemPrompt.assemble({ scope: loner.agent })).tools.find(tool => tool.name === RUN_CODE_NAME)).toBeUndefined() const native = await systemPrompt.assemble({ scope: loner.agent }) expect(native.tools.map(tool => tool.name)).toEqual(['echo']) - const allowed = await ctx.tools.execute({ + const allowed = await executeCall(ctx, { signal: testToolSignal, callId: ToolCallId('native-sibling-direct'), name: 'echo', @@ -2146,13 +2183,13 @@ describe('per-agent presentation', () => { registerEcho(ctx) const coded = await mintAgentScope(ctx, 'coded') const plain = await mintAgentScope(ctx, 'plain') - coded.scope.ctx.tools.presentAs('ptc') + coded.scope.ctx.tools.setExecutor(new PtcExecutor(coded.scope.ctx)) // Not merely hidden from the prompt: the transport one agent presents must // not be dispatchable by another that never presented it. - expect(ctx.tools.get(RUN_CODE_NAME, coded.agent)).toBeDefined() - expect(ctx.tools.get(RUN_CODE_NAME, plain.agent)).toBeUndefined() - expect(ctx.tools.get(RUN_CODE_NAME)).toBeUndefined() + expect((await ctx.systemPrompt.assemble({ scope: coded.agent })).tools.find(tool => tool.name === RUN_CODE_NAME)).toBeDefined() + expect((await ctx.systemPrompt.assemble({ scope: plain.agent })).tools.find(tool => tool.name === RUN_CODE_NAME)).toBeUndefined() + expect((await ctx.systemPrompt.assemble()).tools.find(tool => tool.name === RUN_CODE_NAME)).toBeUndefined() }) it('lets an agent opt out of a PTC mode deployment', async () => { @@ -2160,44 +2197,41 @@ describe('per-agent presentation', () => { registerEcho(ctx) const { scope, agent } = await mintAgentScope(ctx) - scope.ctx.tools.presentAs('native') + scope.ctx.tools.setExecutor(new NativeToolExecutor(scope.ctx)) const assembly = await systemPrompt.assemble({ scope: agent }) expect(assembly.tools.map(tool => tool.name)).toEqual(['echo']) - // The deployment's global section still reaches this scope; rendering it - // empty is what keeps the opted-out agent's prompt free of an SDK. expect(assembly.sections.find(section => section.name === 'tools:sdk')?.text).toBe('') + expect(assembly.sections.find(section => section.name === 'tools:ptc-only')?.text).toBe('') }) - it('restores the deployment default when the agent unloads', async () => { + it('rejects an unloaded executor without falling back to the deployment default', async () => { const { ctx, systemPrompt } = await setup({ mode: 'native' }) registerEcho(ctx) const { scope, agent } = await mintAgentScope(ctx) - const dispose = scope.ctx.tools.presentAs('ptc') + const dispose = scope.ctx.tools.setExecutor(new PtcExecutor(scope.ctx)) dispose() - const assembly = await systemPrompt.assemble({ scope: agent }) - expect(assembly.tools.map(tool => tool.name)).toEqual(['echo']) - expect(assembly.sections.some(section => section.name === 'tools:sdk')).toBe(false) + await expect(systemPrompt.assemble({ scope: agent })).rejects.toThrow('selected tool executor is unavailable') }) it('refuses a second declaration for the same agent', async () => { const { ctx } = await setup({ mode: 'native' }) const { scope } = await mintAgentScope(ctx) - scope.ctx.tools.presentAs('ptc') + scope.ctx.tools.setExecutor(new PtcExecutor(scope.ctx)) // Two answers to "which form does the model see" is a contradiction, and // silently keeping either one would make the composition unreadable. - expect(() => scope.ctx.tools.presentAs('native')) - .toThrow('conflicts with "ptc" already declared') + expect(() => scope.ctx.tools.setExecutor(new NativeToolExecutor(scope.ctx))) + .toThrow('Only one tool executor may be registered in each scope') }) - it('refuses an unscoped declaration', async () => { + it('refuses a second deployment default', async () => { const { ctx } = await setup({ mode: 'native' }) - expect(() => ctx.tools.presentAs('ptc')) - .toThrow('requires a scoped context') + expect(() => ctx.tools.setExecutor(new PtcExecutor(ctx))) + .toThrow('Only one tool executor may be registered in each scope') }) it('reserves run_code even where no agent presents it', async () => { @@ -2212,7 +2246,7 @@ describe('per-agent presentation', () => { const { ctx, systemPrompt } = await setup({ mode: 'native', runtime: false }) registerEcho(ctx) const { scope, agent } = await mintAgentScope(ctx) - scope.ctx.tools.presentAs('ptc') + scope.ctx.tools.setExecutor(new PtcExecutor(scope.ctx)) await expect(systemPrompt.assemble({ scope: agent })) .rejects.toThrow('mode "ptc" requires a PTC runtime') @@ -2318,9 +2352,9 @@ describe('per-program execution controls', () => { }) it('advertises configured timeout values and one-execution scope only when supported', async () => { - const { ctx, tools } = await controlledSetup() + const { ctx, systemPrompt } = await controlledSetup() try { - const schema = tools.schemas().find(tool => tool.name === RUN_CODE_NAME)! + const schema = (await systemPrompt.assemble()).tools.find(tool => tool.name === RUN_CODE_NAME)! expect(JSON.stringify(schema.parameters)).toContain('Default 120000; capped at 600000') expect(JSON.stringify(schema.parameters)).toContain('sandbox_permissions') expect(schema.description).toContain('Nested tools retain their own policies') @@ -2329,7 +2363,7 @@ describe('per-program execution controls', () => { } finally { await ctx.fiber.dispose() } const python = await setup({ runtime: { language: 'python' } }) try { - const schema = python.tools.schemas().find(tool => tool.name === RUN_CODE_NAME)! + const schema = (await python.systemPrompt.assemble()).tools.find(tool => tool.name === RUN_CODE_NAME)! expect(JSON.stringify(schema.parameters)).not.toContain('timeoutMs') expect(JSON.stringify(schema.parameters)).not.toContain('sandbox_permissions') expect(schema.description).not.toContain('Programs start with an empty environment.') @@ -2503,7 +2537,7 @@ describe('PTC state ownership', () => { const format = vi.spyOn(runtime, 'programFormat', 'get').mockReturnValue(programFormat) const release = vi.spyOn(runtime, 'release') try { - expect(ctx.tools.schemas().find(tool => tool.name === 'run_code')?.description).not.toContain('persistent-in-memory') + expect(new PtcExecutor(ctx).describe([]).schemas.find(tool => tool.name === 'run_code')?.description).not.toContain('persistent-in-memory') expect((await runCode(ctx, 'let value = 1', { agent })).isError).toBe(false) expect((await runCode(ctx, 'value', { agent })).isError).toBe(false) expect(runtime.lastRequest?.stateOwner).toBe(agent) @@ -2519,10 +2553,59 @@ describe('PTC state ownership', () => { const format = vi.spyOn(runtime, 'programFormat', 'get').mockReturnValue('repl') const instructions = vi.spyOn(runtime, 'executionInstructions', 'get').mockReturnValue('Each call evaluates Python expressions in a fresh environment.') try { - const description = ctx.tools.schemas().find(tool => tool.name === 'run_code')?.description + const description = new PtcExecutor(ctx).describe([]).schemas.find(tool => tool.name === 'run_code')?.description expect(description).toContain('Each call evaluates Python expressions in a fresh environment.') expect(description).not.toContain('persistent-in-memory') expect(description).not.toContain('TypeScript') } finally { format.mockRestore(); instructions.mockRestore(); await ctx.fiber.dispose() } }) }) + +it.each([ + { owned: false, unload: 'executor' }, + { owned: true, unload: 'executor' }, + { owned: false, unload: 'runtime' }, + { owned: true, unload: 'runtime' }, +] as const)('settles nested calls after the PTC $unload fiber unloads (Agent: $owned)', async ({ owned, unload }) => { + const ctx = new Context() + provideWorkingDirectoryFixture(ctx) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRuntime) + const runtimeProvider = await ctx.plugin(FakeRuntime) + const provider = ctx.plugin(PtcToolExecutor) + await provider + ctx.tools.register(defineTool({ + name: 'echo', description: 'Retained capability.', parameters: {}, + output: { schema: { type: 'string' }, render: (_args, value) => [{ type: 'text', text: value }] }, + execute: async () => 'settled', + })) + const entered = Promise.withResolvers() + const release = Promise.withResolvers() + const runtime = ctx.ptcRuntime as FakeRuntime + runtime.behavior = async (request) => { + entered.resolve(undefined) + await release.promise + return { logs: [], value: await request.bindings[0]!.functions.echo!({}) } + } + const agent = { ctx, id: SessionId('unloading-ptc'), session: Session.create(SessionId('unloading-ptc')) } as Agent + const input = { + calls: [{ type: 'tool-call' as const, id: ToolCallId('unloading-program'), name: RUN_CODE_NAME, + arguments: JSON.stringify({ code: 'return await tools.echo({})', description: 'Invoke retained tool after unload' }) }], + signal: new AbortController().signal, + ...owned ? { agent } : {}, + } + const pending = ctx.tools.execute(input) + try { + await entered.promise + await (unload === 'runtime' ? runtimeProvider : provider).dispose() + await expect(ctx.tools.execute(input)).rejects.toThrow('selected tool executor is unavailable') + release.resolve(undefined) + expect((await pending).results[0]?.result).toMatchObject({ isError: false, value: { logs: [], result: 'settled' } }) + if (owned) expect(agent.session.snapshotEvents().filter(event => event.type === 'tool/ptc-dispatch')) + .toMatchObject([{ data: { name: 'echo', isError: false, content: [{ type: 'text', text: 'settled' }] } }]) + } finally { + release.resolve(undefined) + await Promise.allSettled([pending]) + await ctx.fiber.dispose() + } +}) diff --git a/packages/core/tools/tests/py-types.spec.ts b/packages/core/tool-executor-ptc/tests/py-types.spec.ts similarity index 99% rename from packages/core/tools/tests/py-types.spec.ts rename to packages/core/tool-executor-ptc/tests/py-types.spec.ts index b96cf16df5..81da1c67f0 100644 --- a/packages/core/tools/tests/py-types.spec.ts +++ b/packages/core/tool-executor-ptc/tests/py-types.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' -import { jsonSchemaToPy, renderToolsSdkPy } from '@deepseek-ai/dsh-tools/src/py-types.ts' +import { jsonSchemaToPy, renderToolsSdkPy } from '@deepseek-ai/dsh-tool-executor-ptc/src/py-types.ts' import { parameterSchemaSpecToJsonSchema } from '@deepseek-ai/dsh-tools' -import type { ToolSdkSchema } from '@deepseek-ai/dsh-tools/src/ts-types.ts' +import type { ToolSdkSchema } from '@deepseek-ai/dsh-tool-executor-ptc/src/ts-types.ts' describe('jsonSchemaToPy', () => { it('maps the defineTool DSL subset', () => { diff --git a/packages/core/tools/tests/ts-types.spec.ts b/packages/core/tool-executor-ptc/tests/ts-types.spec.ts similarity index 98% rename from packages/core/tools/tests/ts-types.spec.ts rename to packages/core/tool-executor-ptc/tests/ts-types.spec.ts index 818e1fa423..2ec79e974c 100644 --- a/packages/core/tools/tests/ts-types.spec.ts +++ b/packages/core/tool-executor-ptc/tests/ts-types.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { jsonSchemaToTs, renderToolsSdk } from '@deepseek-ai/dsh-tools/src/ts-types.ts' -import type { ToolSdkSchema } from '@deepseek-ai/dsh-tools/src/ts-types.ts' +import { jsonSchemaToTs, renderToolsSdk } from '@deepseek-ai/dsh-tool-executor-ptc/src/ts-types.ts' +import type { ToolSdkSchema } from '@deepseek-ai/dsh-tool-executor-ptc/src/ts-types.ts' import type { JsonSchemaNode } from '@deepseek-ai/dsh-tools/src/json-schema.ts' import { parameterSchemaSpecToJsonSchema } from '@deepseek-ai/dsh-tools' diff --git a/packages/core/tool-executor-ptc/tsconfig.json b/packages/core/tool-executor-ptc/tsconfig.json new file mode 100644 index 0000000000..307c0de803 --- /dev/null +++ b/packages/core/tool-executor-ptc/tsconfig.json @@ -0,0 +1,57 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../util/brand" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../ptc-runtime/ptc-runtime" + }, + { + "path": "../../sandbox/sandbox" + }, + { + "path": "../../sandbox/sandbox-policy" + }, + { + "path": "../scope" + }, + { + "path": "../session" + }, + { + "path": "../tools" + }, + { + "path": "../../interaction/user-approval" + }, + { + "path": "../../util/values" + }, + { + "path": "../../session/working-directory" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../agent" + } + ] +} diff --git a/packages/core/tools/README.i18n.yaml b/packages/core/tools/README.i18n.yaml index f90a284f26..1f9c8098ed 100644 --- a/packages/core/tools/README.i18n.yaml +++ b/packages/core/tools/README.i18n.yaml @@ -9,20 +9,20 @@ en: bb2af1250bfefe4b zh: 893e3ab2e563a8a5 /deepseek-ai-dsh-tools/summary: - en: 8c0a34155997a980 - zh: ae6031db472b33e3 + en: ad53df5cc542fc32 + zh: 1ab915da46e01d20 /deepseek-ai-dsh-tools/table-of-contents: en: d152484eb41ac6b4 zh: 09388d293f9be9cb /deepseek-ai-dsh-tools/use-this-package: - en: dec8e6df8cc94bc2 - zh: e2f1f15ea7afd7b3 + en: d382227271bf92ae + zh: 1f66b181418c91b9 /deepseek-ai-dsh-tools/use-this-package/register-a-tool: en: 9d2e6caa39c53d62 zh: 096a777f828c7565 -/deepseek-ai-dsh-tools/use-this-package/configure-the-presentation-mode: - en: 4e27c45ae889369e - zh: 7a0cd7a9dc7fe139 +/deepseek-ai-dsh-tools/use-this-package/select-an-executor: + en: bdf27b6f339ea866 + zh: b89205eedea1db0a /deepseek-ai-dsh-tools/use-this-package/restrict-tools-per-agent: en: c6f98b2283cc2e47 zh: c2cdce5d13473f15 @@ -36,23 +36,23 @@ en: ce7e776e9d516bdb zh: 59d2751743db7795 /deepseek-ai-dsh-tools/understand-the-implementation/design-concept: - en: 92e8af53e6067bad + en: 516a6195a21e742b zh: 33c7b99b42c5acac /deepseek-ai-dsh-tools/understand-the-implementation/source-map: - en: c14e20d6a5c4c408 - zh: ab9dc0fd38006749 + en: 2dee0a37e0c1fa38 + zh: 0963cba32685612e /deepseek-ai-dsh-tools/understand-the-implementation/execution-and-cancellation: en: dc44a88997777988 zh: 56454aeace1c8b77 /deepseek-ai-dsh-tools/understand-the-implementation/ptc-mode: - en: 387700aa396f7ca2 - zh: d440b283027e4fbd + en: 83cf9a5077158879 + zh: 80508a70a3674226 /deepseek-ai-dsh-tools/understand-the-implementation/extension-points: en: c2a14d6f22bc9428 zh: 89c1414fca465931 /deepseek-ai-dsh-tools/further-exploration: - en: d8e2dfcfef2a2284 - zh: b33111cbc20dc498 + en: b112e13c7db15bf3 + zh: 39e726a15ca40f21 /deepseek-ai-dsh-tools/model-experience: en: 215e7ba838619b7b zh: a311da3843709f90 @@ -60,29 +60,14 @@ en: 483b01d9bb2cb49a zh: 1d95095e76fb0996 /deepseek-ai-dsh-tools/model-experience/normal-tool-schemas/what-the-model-sees: - en: beda295a874b30a1 - zh: 253c4cf007c4db3f + en: d6a5c32a421bfe97 + zh: 542d49df376e70a4 /deepseek-ai-dsh-tools/model-experience/normal-tool-schemas/token-effect: en: d594b71ff5e4cc87 zh: dcec1ea0dd2a0fa6 /deepseek-ai-dsh-tools/model-experience/normal-tool-schemas/kv-cache-effect: en: 7c5e75964e020b2c zh: d50d0402d15232d9 -/deepseek-ai-dsh-tools/model-experience/ptc-mode-schema-and-system-prompt: - en: eaefd7303f22977d - zh: 9c425f9501128224 -/deepseek-ai-dsh-tools/model-experience/ptc-mode-schema-and-system-prompt/what-the-model-sees: - en: 3e170a222d746e98 - zh: 1ea442fc59874cc5 -/deepseek-ai-dsh-tools/model-experience/ptc-mode-schema-and-system-prompt/what-the-model-sees/typescript-ptc-mode-sdk-instructions-with-bash: - en: 3a8df15e4ae32f9e - zh: fc4a800ba363e835 -/deepseek-ai-dsh-tools/model-experience/ptc-mode-schema-and-system-prompt/token-effect: - en: 12296fcafc14562c - zh: f8e9351bb8c6437d -/deepseek-ai-dsh-tools/model-experience/ptc-mode-schema-and-system-prompt/kv-cache-effect: - en: 78906628d634441f - zh: 492f133aea50cfe3 /deepseek-ai-dsh-tools/model-experience/tool-call-history-and-results: en: a8503137ad03720c zh: 611a2dadf7c0c706 @@ -96,8 +81,8 @@ en: 2fbf2175168e8244 zh: ad59a120b54a0996 /deepseek-ai-dsh-tools/known-limitations-and-deferred-work: - en: 4a412f89b8a71b5a - zh: 803d995b036d5dbf + en: e8c410dee66e1639 + zh: ae4965fa70234fd1 /deepseek-ai-dsh-tools/known-limitations-and-deferred-work/dev-note: en: e67f633dc6fe7773 zh: 3bb0c20c93f16f98 diff --git a/packages/core/tools/README.md b/packages/core/tools/README.md index a5a7044916..3f0db3fcf6 100644 --- a/packages/core/tools/README.md +++ b/packages/core/tools/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -Use `dsh-tools` to expose typed capabilities to models, validate calls, enforce allow/deny/ask policy, and return finalized results without ending a turn on ordinary tool failures. Choose native Function Calling or [PTC mode](#ptc-mode) with `mode`; an agent can override the default through `presentAs`. Tool authors use `defineTool` to declare typed parameters and outputs, cooperative timeouts, parallel-safety, and optional UI presentation. Models see each permitted tool's declared name, description, and parameter schema; per-agent restrictions can narrow that visible set. +Use `dsh-tools` to expose typed capabilities to models, validate calls, enforce allow/deny/ask policy, and return finalized results without ending a turn on ordinary tool failures. Mount a native or PTC executor to choose the model interaction and batch execution behavior. Tool authors use `defineTool` to declare typed parameters and outputs, cooperative timeouts, parallel-safety, and optional UI presentation. Models see each permitted tool's declared name, description, and parameter schema; per-agent restrictions can narrow that visible set. ## Table of Contents @@ -25,9 +25,9 @@ Use `dsh-tools` to expose typed capabilities to models, validate calls, enforce ## Use this package -Each `run_code` program captures the Session's current working directory when it starts. A nested directory change affects subsequent tool calls, while the running program keeps its initial directory. +`ctx.tools.execute()` accepts a complete raw call batch. `agent` is absent for standalone service calls; `log` is absent for programmatic calls outside a model step, including initialization hooks. An Agent without `log` still supplies tool scope but does not produce root call/result events. -Mount `dsh-tools` wherever agents call tools: it provides `ctx.tools`, the registry every tool plugin registers into and the loop dispatches through. Registering a tool is enough to make it visible — the registry feeds its schemas into the system-prompt assembly automatically. +Mount `dsh-tools` wherever agents call tools: it provides `ctx.tools`, the registry every tool plugin registers into and the selected executor resolves. Registering a tool is enough to make it visible — the registry feeds its schemas into the system-prompt assembly automatically. ### Register a tool @@ -63,22 +63,16 @@ ctx.tools.register(defineTool({ The unified schema DSL supports `string`, `number`, `integer`, `boolean`, `null`, `array`, `object`, author-only `json`, and exact-one `oneOf`; `InferValue` preserves exact types through 16 container levels before widening to `JsonValue`. A raw JSON Schema (`JsonSchemaNode`) is the wire-level counterpart shared with subagents, workflows, and MCP. -### Configure the presentation mode +### Select an executor -The `mode` config decides what the model sees: `native` (every visible schema) or `ptc` (only `run_code` plus a generated SDK). The runtime supplies evaluation syntax, output syntax and state-retention guidance through `executionInstructions`; this package owns tool-call rules, SDK declarations and image-result attachment. +Mount one executor in the deployment, preset, or Agent scope. The nearest scope selects the provider; duplicate providers in one scope fail. Native exposes visible capabilities directly; PTC exposes `run_code` and a generated SDK. Executor registration and disposal do not emit `tools/change`; prompt assemblies and batches resolve the current executor when they run. ```yaml - name: '@deepseek-ai/dsh-tools' - config: - mode: native +- name: '@deepseek-ai/dsh-tool-executor-native' ``` -| Field | Default | Meaning | -|---|---|---| -| `mode` | `native` | How visible tools are presented to the model: `native` or `ptc` | -| `maxParallelSubCalls` | `10` | Concurrency cap for a `run_code` program's overlapping sub-calls; `1` restores strictly serial dispatch | - -The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-tools) is the exhaustive source for every accepted field. Non-native modes require a composed `ctx.ptcRuntime` whose language has a registered SDK renderer; an agent preset selects its own presentation with [`dsh-agent-tool-presentation`](../agent-tool-presentation/README.md), and one agent can shadow the default with `presentAs(mode)`. +Agent-step concurrency uses the [agent-loop configuration](../agent-loop/README.md). See the [native](../tool-executor-native/README.md) or [PTC](../tool-executor-ptc/README.md) package for executor settings. Custom providers implement only `describe(definitions)` and `execute(batch)`; the shared pipeline is optional. The registry selects the current scoped executor separately for prompt descriptions and each `ctx.tools.execute()` batch. An accepted batch settles through its selected provider even if that provider unloads; settlement may include cancellation errors according to the provider’s disposal policy. New batches reject until a replacement is registered. ### Restrict tools per agent @@ -106,20 +100,19 @@ This section explains how the package realizes the behavior above; the observabl ### Design concept -The registry holds typed `ToolDefinition`s in scoped layers and projects them onto the model-facing `ToolSchema` set at request time — `output`, `execute`, `finalizeContent`, `timeoutMs`, and presentation callbacks never leak onto the wire. Every call runs a fixed pipeline: `tools/pre-execute` (extensible allow/deny/ask) → registered monotonic guards → `tools/execute` (around-dispatch wrappers) → `tools/post-execute` (inspect/replace, attach context) → definition-owned `finalizeContent` → the observe-only `tools/result` event. Only the `tools/execute` view may replace the required signal, and the registry re-fuses the caller signal before the body. +The registry holds typed `ToolDefinition`s in scoped layers and projects them onto the model-facing `ToolSchema` set at request time — `output`, `execute`, `finalizeContent`, `timeoutMs`, and presentation callbacks never leak onto the wire. Built-in executors reuse the shared pipeline: `tools/pre-execute` (extensible allow/deny/ask) → registered monotonic guards → `tools/execute` (around-dispatch wrappers) → `tools/post-execute` (inspect/replace, attach context) → definition-owned `finalizeContent` → the observe-only `tools/result` event. Only the `tools/execute` view may replace the required signal, and the registry re-fuses the caller signal before the body. ### Source map -| File | Role | -|---|---| -| [`src/index.ts`](src/index.ts) | Plugin entry: `ToolRuntime` service, config, registry, execution pipeline | -| [`src/types.ts`](src/types.ts) | `ToolDefinition`, `ToolExecution`, `ToolExecutionResult`, guard and decision types | -| [`src/schema.ts`](src/schema.ts) | The `defineTool` DSL: `ValueSchemaSpec`, `ParameterSchemaSpec`, `InferValue`, `InferArgs` | -| [`src/json-schema.ts`](src/json-schema.ts) | The enforced raw JSON Schema subset and validation | -| [`src/presentation.ts`](src/presentation.ts) | The `card`-tagged UI render intents | -| [`src/ptc.ts`](src/ptc.ts) | PTC mode: SDK generation, `run_code` dispatch bridge, settlement | -| [`src/ts-types.ts`](src/ts-types.ts) | TypeScript SDK type rendering | -| [`src/py-types.ts`](src/py-types.ts) | Python SDK type rendering | +- [`src/index.ts`](src/index.ts) — Plugin entry and public tool APIs; scoped tool service and executor selection. +- [`src/contracts.ts`](src/contracts.ts) — Tool definitions, invocation results, guards and decisions. +- [`src/executor.ts`](src/executor.ts) — Executor descriptions and batch input/output. +- [`src/pipeline.ts`](src/pipeline.ts) — Shared `prepare`, `dispatch`, and `complete` stages, their result types, and the batch entry point. +- [`src/batch.ts`](src/batch.ts) — Concurrency, ordered results and optional Session logging. +- [`src/events.ts`](src/events.ts) — Shared execution policy events. +- [`src/schema.ts`](src/schema.ts) — Typed tool definitions and model-facing schema projection. +- [`src/json-schema.ts`](src/json-schema.ts) — Supported JSON Schema validation. +- [`src/presentation.ts`](src/presentation.ts) — Tool-card render intents. ### Execution and cancellation @@ -127,13 +120,7 @@ Each typed invocation materializes and freezes parsed arguments, assigns an opaq ### PTC mode -Under `ptc`, the registry exposes the reserved `run_code` transport plus a deterministic SDK generated in the loaded runtime's language. Each SDK binding captures a frozen ToolSchema and passes it through the scheduler to its execution context. Before policy, a started call records only pairing ids, name, and normalized arguments; its settle event preserves the rendered result, optional presentation metadata, and structured error. Metadata is projected from that call’s canonical value and frozen; it changes neither the program value nor model content. Description and parameters remain transient and never enter Session events or SDK output. Calls are scheduled through a per-run pool that reuses the native concurrency contract. Under `ptc` alone, a model-direct call naming any other visible tool resolves to `UNKNOWN_TOOL` before policy — the announced surface and the callable surface stay the same. Intermediate binding values are execution-local; only the outer `run_code` result has a hard size cap. - -New sub-calls use `:ptc:` ids. Consumers treat these ids as opaque and correlate events by exact equality; restored historical ids retain their original bytes. The [PTC mode decision](../../../.agents/notes/implemented/feature/2026-06-15-ptc.md) owns durable naming and restoration rules. - -Successful image-bearing subcall results become deferred user-message context with `source.kind` set to `ptc-mode`. Other additional contexts retain their producing tool's attribution. - -`run_code` accepts `timeoutMs` when the mounted runtime supports an override; its schema reports the configured default and maximum, and the runtime's usage instructions. The Node default is 120,000 ms with a 600,000 ms cap, including nested tool and approval waits. A wider `sandbox_permissions` mode requires a non-empty `justification` and approval before the program starts. The schema asks the model to write that reason in the current user's language. The grant applies to that complete execution; standing Session policy and nested tools retain their own authority. Programs are never replayed automatically: inspect earlier effects before explicitly retrying a denied program. Retained PTC environments follow the standing file policy. PTC observes committed Session mode changes and immediately cancels affected calls in both Node modes. Later PTC calls wait for their nested-tool cleanup before resolving the latest policy. The runtime owns process invalidation and cleanup; permission setters remain synchronous. One-time escalations omit the state owner and complete in a temporary environment; standing variables remain available to ordinary calls. +The [PTC executor](../tool-executor-ptc/README.md) owns program transport, SDK generation, nested dispatch, and image context. ### Extension points @@ -150,9 +137,10 @@ Tool plugins call `ctx.tools.register()` and their schemas flow into prompt asse The package-level contract is enough for most consumers; read these when you need the surrounding domain. - [Tools subsystem](../../../docs/subsystems/tools.md) — the full pipeline types, schema DSL, and generated service API. -- [Generated tool catalog](../../../docs/tool-catalog.md#deepseek-aidsh-tools) — the shipped tools' schemas the model receives. +- [Generated tool catalog](../../../docs/tool-catalog.md#deepseek-aidsh-tool-executor-ptc) — the shipped tools' schemas the model receives. - [Tool execution pipeline](../../../docs/tool-execution-pipeline.md) — the pipeline visualized. - [Adding a tool cookbook](../../../docs/cookbook/adding-a-tool.md) — step-by-step tool authoring. +- [Tool testkit](../../test-support/tool-testkit/README.md) — tool-definition and execution fixtures for tests and benchmarks. - [Cooperative cancellation Agent Note](../../../.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.md) — the full cancellation contract. - [Core group map](../README.md) — how the core packages compose. @@ -165,7 +153,7 @@ The package-level contract is enough for most consumers; read these when you nee #### What the model sees -In normal mode the model sees each visible definition's exact name, description, and JSON Schema; the shipped definitions are recorded in the generated [tool catalog](../../../docs/tool-catalog.md#deepseek-aidsh-tools). Agent-scoped restrictions, shadows, and extension registrations change that agent's end-tool set. +In normal mode the model sees each visible definition's exact name, description, and JSON Schema; the shipped definitions are recorded in the generated [tool catalog](../../../docs/tool-catalog.md#deepseek-aidsh-tool-executor-ptc). Agent-scoped restrictions, shadows, and extension registrations change that agent's end-tool set. #### Token effect @@ -175,39 +163,6 @@ Fixed per-request cost proportional to the visible definitions. Restrictions tha Prefix-stable while visible definitions and their order are unchanged. Registration, disposal, or scoped restriction may invalidate reuse from the first changed schema token. -### PTC mode schema and system prompt - -#### What the model sees - -PTC mode exposes the generated [`run_code` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tools), the SDK instructions below, and the generated exact SDK block for the loaded runtime's language. Both runtime languages list `code` before `description`; this is presentation order, not an enforced JSON member order. The TypeScript instructions use a return-value example for function-body runtimes and a printed-output example for REPL runtimes. They identify generated declarations as program-only bindings. When the current `bash` parameter schema accepts the example arguments, they also show a complete `run_code` call around `tools.bash(...)`. The `tools:sdk` section uses first-party order 5000 and disables prompt-variable interpolation, preserving literal `{{…}}` text in tool descriptions and schemas for both runtime languages. Under `ptc`, the prompt also carries the `tools:ptc-only` rule earlier in the first-party order, so the model reads which tools it may call before it reads what each one is for. - -##### TypeScript PTC mode SDK instructions with bash - -```markdown -## Writing code for run_code - -`run_code` takes two required arguments: `code`, the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped), and `description`, a short summary of what the program does. The declarations below are SDK bindings for this program. A declaration does not make its name a directly callable tool; only names supplied as separate tool schemas may be called directly. When no separate `bash` schema is supplied, invoke a declared `bash` binding inside `run_code`: - -`run_code({ code: "return await tools.bash({ description: 'Show current directory', command: 'pwd' })", description: "Show current directory" })` - -Inside the program: - -- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools["my-tool"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON. -- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue. -- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`. -- Emit results with `return` and/or `console.log(...)`. Only what you print or return is program output. A successful tool result containing an image is attached after the run so you can inspect it on the next step; every other intermediate result stays out of the conversation, so extract just what you need. - -Program-only SDK bindings: -``` - -#### Token effect - -Fixed per-request cost proportional to the visible definitions. PTC mode trades end-tool schemas for generated SDK text plus one transport schema rather than promising a universal reduction. - -#### KV Cache effect - -Prefix-stable while the PTC mode selection, generated SDK, transport schema, and visible tool set are unchanged. Mode or filter changes may invalidate reuse from the first changed prompt or schema token. - ### Tool-call history and results #### What the model sees @@ -233,9 +188,6 @@ These limits define when the registry needs special care. They are current packa - **`tools/pre-execute` deliberately cannot rewrite `exec.arguments`** — logged and rendered args would desync from what ran; the rewrite design is [a proposed Agent Note](../../../.agents/notes/proposed/feature/2026-06-30-pre-tool-input-rewrite.md). - **Caller-defined subagent and workflow structured outputs remain object-rooted** — this is a consumer-level guard; the shared schema vocabulary and tool outputs support every JSON root. - **`timeoutMs` on a definition is declarative only** — the registry never enforces deadlines; enforcement requires the `@deepseek-ai/dsh-tool-call-timeout-policy` wrapper. -- **PTC mode's SDK language follows the one loaded runtime, and a presentation is per agent rather than per tool** — `mode: ptc` rejects prompt assembly unless `ctx.ptcRuntime.language` has a registered SDK renderer; within one agent no tool can be native-only while another is ptc-only. -- **PTC mode intermediate values are execution-local and unbounded by bytes** — they cannot be reconstructed from session replay and may exhaust process or worker memory; only the outer `run_code` output has the worker's configurable hard cap. -- **State depends on the runtime**: the default backend starts fresh; a REPL backend retains memory per live Agent. Agent disposal awaits release. Restart and execution failures can clear memory; model-visible guidance explains how to recreate missing variables. Programs and results remain in the Session log, without serializing live objects. `defineTool()`, registry schema projection, and system-prompt assembly preserve `deferLoading: true`. The marker requests deferred definition loading and does not imply a `tool-addition` record; the [LLM package](../../../packages/llm/llm/README.md#known-limitations-and-deferred-work) documents provider enforcement limits. diff --git a/packages/core/tools/README.zh.md b/packages/core/tools/README.zh.md index 6316543088..012ff9798a 100644 --- a/packages/core/tools/README.zh.md +++ b/packages/core/tools/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -使用 `dsh-tools` 可向模型公开类型化能力、校验调用、执行允许/拒绝/询问策略,并在普通工具失败时返回最终结果而不中止当前轮次。通过 `mode` 选择原生 Function Calling(函数调用)或 [PTC mode](#ptc-mode);单个 agent(智能体)可用 `presentAs` 覆盖默认值。工具作者使用 `defineTool` 声明类型化参数与输出、协作式超时、并行安全属性和可选 UI 展示。模型会看到每个获准工具声明的名称、描述与参数 schema;按 agent 设置的限制可缩小该可见集合。 +使用 `dsh-tools` 可向模型公开类型化能力、校验调用、执行允许/拒绝/询问策略,并在普通工具失败时返回最终结果而不中止当前轮次。挂载原生或 PTC 执行器以选择模型交互和批次执行行为。工具作者使用 `defineTool` 声明类型化参数与输出、协作式超时、并行安全属性和可选 UI 展示。模型会看到每个获准工具声明的名称、描述与参数 schema;按 agent 设置的限制可缩小该可见集合。 ## 目录 @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用本包 -每个 `run_code` 程序启动时会固定使用 Session 当时的工作目录。嵌套目录切换会影响后续工具调用,而运行中的程序仍保留启动目录。 +`ctx.tools.execute()` 接受完整的原始调用批次。独立服务调用没有 `agent`;模型步骤之外的程序调用(包括初始化钩子)没有 `log`。没有 `log` 的 Agent 仍提供工具作用域,但不会产生根调用/结果事件。 在任何 agent 调用工具的地方挂载 `dsh-tools`:它提供 `ctx.tools`,即每个工具插件注册进去、循环分发所经过的注册表。注册一个工具就足以让它可见——注册表会自动把其 schema 送入系统提示词组装。 @@ -63,22 +63,16 @@ ctx.tools.register(defineTool({ 统一 schema DSL 支持 `string`、`number`、`integer`、`boolean`、`null`、`array`、`object`、仅供作者使用的 `json` 与恰好匹配一个分支的 `oneOf`;`InferValue` 在 16 层容器内保留精确类型,之后加宽为 `JsonValue`。原始 JSON Schema(`JsonSchemaNode`)是与 subagent、工作流和 MCP 共享的协议级对应类型。 -### 配置呈现模式 +### 选择执行器 -`mode` 配置决定模型看到什么:`native`(每个可见 schema)或 `ptc`(只有 `run_code` 加一份生成 SDK)。运行时通过 `executionInstructions` 提供求值语法、输出方式和状态保留指引;本包负责工具调用规则、SDK 声明和图片结果附加。 +在部署、预设或 Agent 作用域中挂载一个执行器。最近的作用域决定提供者,同一作用域的重复提供者会报错。原生执行器直接公开可见能力;PTC 执行器公开 `run_code` 和生成的 SDK。执行器注册和卸载不触发 `tools/change`;提示词组装和批次执行在运行时解析当前执行器。 ```yaml - name: '@deepseek-ai/dsh-tools' - config: - mode: native +- name: '@deepseek-ai/dsh-tool-executor-native' ``` -| 字段 | 默认值 | 含义 | -|---|---|---| -| `mode` | `native` | 可见工具向模型呈现的方式:`native` 或 `ptc` | -| `maxParallelSubCalls` | `10` | `run_code` 程序重叠子调用的并发上限;`1` 恢复严格串行分发 | - -生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-tools)是每个受支持字段的穷尽式真源。非原生模式要求已组合的 `ctx.ptcRuntime` 且其语言有已注册的 SDK 渲染器;agent preset 通过 [`dsh-agent-tool-presentation`](../agent-tool-presentation/README.zh.md) 自行选择呈现方式,单个 agent 可用 `presentAs(mode)` 遮蔽默认值。 +Agent 步骤的并发上限使用 [agent-loop 配置](../agent-loop/README.zh.md)。执行器设置参见[原生](../tool-executor-native/README.zh.md)或 [PTC](../tool-executor-ptc/README.zh.md) 包。自定义提供者只需实现 `describe(definitions)` 和 `execute(batch)`,共享流水线为可选项。注册表分别为提示词描述和每次 `ctx.tools.execute()` 批次选择当前作用域的执行器。已接纳批次仍由所选提供者结算,即使该提供者已卸载;根据提供者的卸载策略,结果可能包含取消错误。注册替代提供者之前,新批次会被拒绝。 ### 按 agent 限制工具 @@ -110,16 +104,15 @@ ctx.tools.register(defineTool({ ### 源码地图 -| 文件 | 职责 | -|---|---| -| [`src/index.ts`](src/index.ts) | 插件入口:`ToolRuntime` 服务、配置、注册表、执行流水线 | -| [`src/types.ts`](src/types.ts) | `ToolDefinition`、`ToolExecution`、`ToolExecutionResult`、守卫与决策类型 | -| [`src/schema.ts`](src/schema.ts) | `defineTool` DSL:`ValueSchemaSpec`、`ParameterSchemaSpec`、`InferValue`、`InferArgs` | -| [`src/json-schema.ts`](src/json-schema.ts) | 强制执行的原始 JSON Schema 子集与校验 | -| [`src/presentation.ts`](src/presentation.ts) | 带 `card` 标签的 UI 呈现意图 | -| [`src/ptc.ts`](src/ptc.ts) | PTC mode:SDK 生成、`run_code` 分发桥接层、结算 | -| [`src/ts-types.ts`](src/ts-types.ts) | TypeScript SDK 类型渲染 | -| [`src/py-types.ts`](src/py-types.ts) | Python SDK 类型渲染 | +- [`src/index.ts`](src/index.ts) — 插件入口与公共工具 API;作用域工具服务与执行器选择. +- [`src/contracts.ts`](src/contracts.ts) — 工具定义、调用结果、守卫与策略决策. +- [`src/executor.ts`](src/executor.ts) — 执行器描述与批量输入输出. +- [`src/pipeline.ts`](src/pipeline.ts) — 共享的 `prepare`、`dispatch`、`complete` 阶段、阶段结果类型与批次入口。 +- [`src/batch.ts`](src/batch.ts) — 并发调度、结果顺序与可选 Session 日志. +- [`src/events.ts`](src/events.ts) — 共享执行策略事件. +- [`src/schema.ts`](src/schema.ts) — 类型化工具定义与面向模型的 schema 投影。 +- [`src/json-schema.ts`](src/json-schema.ts) — 支持的 JSON Schema 校验. +- [`src/presentation.ts`](src/presentation.ts) — 工具卡片呈现意图. ### 执行与取消 @@ -127,13 +120,7 @@ ctx.tools.register(defineTool({ ### PTC mode -在 `ptc` 下,注册表公开保留的 `run_code` 传输以及按所加载运行时语言生成的确定性 SDK。每个 SDK 绑定捕获冻结的 ToolSchema,经由调度器传入该次执行上下文。已开始的调用在策略之前只记录配对 id、名称和规范化参数;其结算事件保留渲染结果、可选展示元数据与结构化错误;元数据由该次调用的规范值投影并冻结,不改变程序读取的值或模型内容。描述与参数 schema 仅临时存活,不进入 Session 事件或 SDK 输出。调用通过复用原生并发约定的每次运行独有池调度。在纯 `ptc` 下,模型直呼其他任何可见工具都会在策略之前解析为 `UNKNOWN_TOOL`——通告面与可调用面保持一致。中间绑定值只存在于执行局部;只有外层 `run_code` 结果有硬大小上限。 - -新子调用使用 `:ptc:` 标识。消费方将这些标识视为不透明值,并通过精确相等关联事件;恢复的历史标识保留原始字节。[PTC mode 决策](../../../.agents/notes/implemented/feature/2026-06-15-ptc.zh.md) 负责持久化命名与恢复规则。 - -成功且包含图片的子调用结果会成为延后的 user-message 上下文,其 `source.kind` 为 `ptc-mode`。其他 additional context 保留其生产工具的归属。 - -当已挂载运行时支持覆盖时,`run_code` 接受 `timeoutMs`;其 schema 报告配置的默认值和上限以及运行时使用说明。Node 默认值为 120,000 ms,上限为 600,000 ms,包含嵌套工具和审批等待。更宽的 `sandbox_permissions` 模式要求非空 `justification`,并在程序启动前获得审批。schema 提示模型使用当前用户的语言撰写理由。授权仅用于该次完整执行;常驻 Session 策略与嵌套工具保留各自权限。程序不会自动重放:显式重试被拒程序前,应检查先前已发生的效果。 保留状态的 PTC 环境遵循会话文件策略。PTC 观察已提交的 Session 模式变化,并立即取消两种 Node 模式下受影响的调用。后续 PTC 调用等待这些调用的嵌套工具清理后,再解析最新策略。运行时负责进程失效和清理,权限设置保持同步。单次提权省略状态所有者,在临时环境中执行;普通调用仍可访问原环境的变量。 +[PTC 执行器](../tool-executor-ptc/README.zh.md) 负责程序传输、SDK 生成、嵌套分发和图片上下文。 ### 扩展点 @@ -150,9 +137,10 @@ ctx.tools.register(defineTool({ 包级约定对大多数消费方已经足够;需要周边领域时再阅读以下页面。 - [工具子系统](../../../docs/subsystems/tools.zh.md)——完整流水线类型、schema DSL 与生成的服务 API。 -- [生成工具目录](../../../docs/tool-catalog.zh.md#deepseek-aidsh-tools)——模型收到的已交付工具 schema。 +- [生成工具目录](../../../docs/tool-catalog.zh.md#deepseek-aidsh-tool-executor-ptc)——模型收到的已交付工具 schema。 - [工具执行流水线](../../../docs/tool-execution-pipeline.zh.md)——可视化流水线。 - [添加工具实操手册](../../../docs/cookbook/adding-a-tool.zh.md)——分步骤的工具编写指南。 +- [工具测试工具包](../../test-support/tool-testkit/README.zh.md)——测试和基准测试使用的工具定义与执行 fixture。 - [协作式取消 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.zh.md)——完整取消约定。 - [core 分组地图](../README.zh.md)——core 各包如何组合。 @@ -165,7 +153,7 @@ ctx.tools.register(defineTool({ #### 模型看到什么 -在普通模式下,模型会看到每个可见定义的确切名称、描述与 JSON Schema;已交付定义记录在生成的[工具目录](../../../docs/tool-catalog.zh.md#deepseek-aidsh-tools)中。agent 作用域的限制、遮蔽与扩展注册会改变该 agent 的最终工具集合。 +在普通模式下,模型会看到每个可见定义的确切名称、描述与 JSON Schema;已交付定义记录在生成的[工具目录](../../../docs/tool-catalog.zh.md#deepseek-aidsh-tool-executor-ptc)中。agent 作用域的限制、遮蔽与扩展注册会改变该 agent 的最终工具集合。 #### Token 影响 @@ -175,39 +163,6 @@ ctx.tools.register(defineTool({ 只要可见定义及其顺序不变,前缀就保持稳定。注册、dispose 或作用域限制可能从第一个改变的 schema token 起使复用失效。 -### PTC mode schema 与系统提示词 - -#### 模型看到什么 - -PTC mode 会公开生成的 [`run_code` schema](../../../docs/tool-catalog.zh.md#deepseek-aidsh-tools)、下方 SDK 说明,以及按所加载运行时语言生成的精确 SDK 块。两种运行时语言都将 `code` 列在 `description` 前;这是呈现顺序,不强制 JSON 成员顺序。TypeScript 说明对函数体运行时使用返回值示例,对 REPL 运行时使用打印输出示例,并把生成声明明确标为只能在程序内使用的绑定。当当前 `bash` 参数 schema 接受示例参数时,说明还会给出以 `run_code` 包住 `tools.bash(...)` 的完整调用。`tools:sdk` 段使用 first-party 顺序 5000,并关闭提示词变量插值,使两种运行时语言都原样保留工具描述和 schema 中的 `{{…}}` 文本。在 `ptc` 下,提示词还会带上处于更早 first-party 顺序的 `tools:ptc-only` 规则,让模型先读到「可以调用哪些工具」再读「每个工具做什么」。 - -##### 带 bash 的 TypeScript PTC mode SDK 说明 - -```markdown -## Writing code for run_code - -`run_code` takes two required arguments: `code`, the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped), and `description`, a short summary of what the program does. The declarations below are SDK bindings for this program. A declaration does not make its name a directly callable tool; only names supplied as separate tool schemas may be called directly. When no separate `bash` schema is supplied, invoke a declared `bash` binding inside `run_code`: - -`run_code({ code: "return await tools.bash({ description: 'Show current directory', command: 'pwd' })", description: "Show current directory" })` - -Inside the program: - -- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools["my-tool"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON. -- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue. -- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`. -- Emit results with `return` and/or `console.log(...)`. Only what you print or return is program output. A successful tool result containing an image is attached after the run so you can inspect it on the next step; every other intermediate result stays out of the conversation, so extract just what you need. - -Program-only SDK bindings: -``` - -#### Token 影响 - -每次请求的固定成本与可见定义成正比。PTC mode 使用生成的 SDK 文本加一个传输 schema 取代最终工具 schema,但不承诺普遍减少成本。 - -#### KV Cache 影响 - -只要 PTC mode 选择、生成的 SDK、传输 schema 与可见工具集合不变,前缀就保持稳定。模式或筛选器变更可能从第一个改变的提示词或 schema token 起使复用失效。 - ### 工具调用历史与结果 #### 模型看到什么 @@ -233,9 +188,6 @@ Program-only SDK bindings: - **`tools/pre-execute` 有意不允许改写 `exec.arguments`**:否则日志记录与呈现的参数会与实际运行内容失去同步;改写设计记录在[拟议的 Agent Note](../../../.agents/notes/proposed/feature/2026-06-30-pre-tool-input-rewrite.zh.md)中。 - **调用方定义的 subagent 与工作流结构化输出仍要求对象根**:这是消费方层面的守卫;共享 schema 词汇与工具输出支持任意 JSON 根。 - **定义中的 `timeoutMs` 仅作声明之用**:注册表绝不会强制执行截止时间;要强制执行,必须使用 `@deepseek-ai/dsh-tool-call-timeout-policy` 包装层。 -- **PTC mode 的 SDK 语言由当前加载的运行时决定,且呈现方式按 agent 而非按工具**:`mode: ptc` 会拒绝组装提示词,除非 `ctx.ptcRuntime.language` 有已注册的 SDK 渲染器;同一个 agent 内不能让一个工具仅使用 Native,而另一个仅使用 PTC。 -- **PTC mode 中间值只存在于执行局部,且没有字节上限**:它们无法从会话回放重建,并可能耗尽进程或 worker 内存;只有外层 `run_code` 输出受 worker 可配置的硬上限约束。 -- **状态由运行时决定**:默认后端每次重新开始;REPL 后端按存活 Agent 保留内存。Agent 销毁时等待环境释放。重启与执行失败可能清除内存;模型说明会指导重新创建缺失变量。程序与结果仍写入 Session 日志,不序列化运行中的对象。 `defineTool()`、注册表模式投影和系统提示组装会保留 `deferLoading: true`。该标记请求延迟加载工具定义,并不意味着存在 `tool-addition` 记录;提供方执行语义的限制见 [LLM 包](../../../packages/llm/llm/README.zh.md#known-limitations-and-deferred-work)。 diff --git a/packages/core/tools/package.json b/packages/core/tools/package.json index 5b7136522a..f87e90cbee 100644 --- a/packages/core/tools/package.json +++ b/packages/core/tools/package.json @@ -18,21 +18,23 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, - "./types": { - "types": "./lib/types/types.d.ts", - "default": "./lib/types/types.js" - }, "./presentation": { "types": "./lib/types/presentation.d.ts", "default": "./lib/types/presentation.js" }, "./src/*": "./src/*", "./package.json": "./package.json", - "./locale/*.json": "./locale/*.json" + "./locale/*.json": "./locale/*.json", + "./pipeline": { + "types": "./lib/types/pipeline.d.ts", + "default": "./lib/pipeline.js" + } }, "files": [ "locale/*.json", "lib/index.js", + "lib/pipeline.js", + "lib/shared-*.js", "lib/types/**/*.js", "lib/types/**/*.d.ts" ], @@ -40,15 +42,11 @@ "peerDependencies": { "@deepseek-ai/cordis": "workspace:~", "@deepseek-ai/dsh-agent": "workspace:*", - "@deepseek-ai/dsh-ptc-runtime": "workspace:*", "@deepseek-ai/dsh-llm": "workspace:*", "@deepseek-ai/dsh-scope": "workspace:*", "@deepseek-ai/dsh-session": "workspace:*", "@deepseek-ai/dsh-system-prompt": "workspace:*", - "@deepseek-ai/dsh-user-approval": "workspace:*", - "@deepseek-ai/dsh-sandbox": "workspace:*", - "@deepseek-ai/dsh-sandbox-policy": "workspace:*", - "@deepseek-ai/dsh-working-directory": "workspace:*" + "@deepseek-ai/dsh-user-approval": "workspace:*" }, "dependencies": { "@deepseek-ai/dsh-brand": "workspace:*", @@ -58,16 +56,14 @@ "devDependencies": { "@deepseek-ai/cordis": "workspace:~", "@deepseek-ai/dsh-agent": "workspace:*", - "@deepseek-ai/dsh-ptc-runtime": "workspace:*", "@deepseek-ai/dsh-llm": "workspace:*", "@deepseek-ai/dsh-scope": "workspace:*", "@deepseek-ai/dsh-session": "workspace:*", "@deepseek-ai/dsh-session-projection": "workspace:*", "@deepseek-ai/dsh-system-prompt": "workspace:*", + "@deepseek-ai/dsh-tool-executor-native": "workspace:*", + "@deepseek-ai/dsh-tool-testkit": "workspace:*", "@deepseek-ai/dsh-user-approval": "workspace:*", - "@deepseek-ai/dsh-sandbox": "workspace:*", - "@deepseek-ai/dsh-sandbox-policy": "workspace:*", - "@deepseek-ai/dsh-working-directory": "workspace:*", "@deepseek-ai/dsh-agent-loop-testkit": "workspace:*" } } diff --git a/packages/core/tools/src/batch.ts b/packages/core/tools/src/batch.ts new file mode 100644 index 0000000000..d1b0e1a6f4 --- /dev/null +++ b/packages/core/tools/src/batch.ts @@ -0,0 +1,284 @@ +/** + * Schedules one assistant step's tool calls. Exclusive calls form barriers; + * parallel calls use a bounded rolling pool and are reclassified before start. + * Dispatch may overlap, while policy, results, and result context remain + * model-ordered. Abort or an internal scheduler failure stops replenishment + * and drains started calls. + * + * Abort records synthetic error results for skipped calls so replay stays + * valid. A terminal scheduler failure rejects after draining; the owning step + * records conservative recovery results before closing. + * @module dsh-tools/batch + */ + +import { ToolBatchExecutionError } from './executor.ts' +import { TOOL_ABORTED_BEFORE_DISPATCH } from './contracts.ts' +import type { ToolBatchInput, ToolBatchResult, ToolCallResult } from './executor.ts' +import { createToolResultMessage, type ToolCallBlock } from '@deepseek-ai/dsh-llm' +import type { SessionSeq, UserMessage } from '@deepseek-ai/dsh-session' +import type { ToolExecutionInput, ToolExecutionMode, ToolExecutionResult, ToolRunContext } from './contracts.ts' +import type { ScheduledToolDispatch, ToolExecutionPipeline } from './pipeline.ts' +import { assertNever } from '@deepseek-ai/dsh-util-values' + +/** One tool call after argument parsing, ready to schedule. */ +interface PlannedCall { + block: ToolCallBlock + exec: ToolExecutionInput +} + +/** Settled dispatch awaiting model-order finalization. */ +type Slot = ScheduledToolDispatch & { exec: ToolRunContext } + +/** One scheduler group outcome, including a drained cancellation. */ +interface GroupOutcome { + consumed: number + aborted: boolean + /** Whether any committed result carried {@link ToolExecutionResult.concludesTurn}. */ + concluded: boolean +} + +/** + * Execute raw calls with ordered policy and results around overlapping tool bodies. + * Abort drains started calls and synthesizes results for skipped calls. Internal + * failures drain before rejecting with the already committed results and context. + * @param input - raw calls, optional concurrency override, and optional Session owner. + * @param pipeline - executor-owned invocation implementation. + * @param maxParallel - reads the executor's current validated limit when input has no override. + * @returns model-ordered outcomes after every started body has settled. + * @throws RangeError when the explicit concurrency override is not a positive integer. + */ +export async function executeToolBatch( + input: ToolBatchInput, + pipeline: ToolExecutionPipeline, + maxParallel: () => number, +): Promise { + const { calls: toolCalls, signal, agent, maxParallelToolCalls } = input + if (maxParallelToolCalls !== undefined && (!Number.isInteger(maxParallelToolCalls) || maxParallelToolCalls < 1)) { + throw new RangeError('maxParallelToolCalls must be a positive integer') + } + const settlement = new BatchSettlement(input) + try { + // Inputs are distinct because tools/execute wrappers may replace `exec.signal`. + const planned: PlannedCall[] = toolCalls.map(block => ({ + block, + exec: { + callId: block.id, + name: block.name, + arguments: parseArguments(block.arguments), + ...agent === undefined ? {} : { agent }, + signal, + }, + })) + + let next = 0 + let concluded = false + while (next < planned.length) { + // Commit before classifying again so registry changes affect unstarted calls. + // oxlint-disable-next-line typescript/no-non-null-assertion -- bounded by the loop condition + const first = planned[next]! + const mode = pipeline.executionMode(first.exec).kind + const group = mode === 'parallel' ? planned.slice(next) : [first] + const outcome = await runGroup( + pipeline, settlement, maxParallelToolCalls ?? maxParallel(), group, mode, signal, + ) + next += outcome.consumed + concluded ||= outcome.concluded + if (outcome.aborted) { + for (const call of planned.slice(next)) settlement.skip(call.block) + return settlement.result(concluded) + } + } + return settlement.result(concluded) + } catch (error: unknown) { + throw new ToolBatchExecutionError(error, settlement.result()) + } +} + +/** Parse model arguments, preserving invalid JSON as text and mapping empty input to `{}`. */ +function parseArguments(raw: string): unknown { + try { + return raw ? JSON.parse(raw) : {} + } catch (_error: unknown) { + // Preserve malformed model input for tool-owned argument diagnostics. + return raw + } +} + +/** + * Run one exclusive barrier or parallel pool. Later calls are reclassified + * before start; an exclusive reclassification waits for the current pool to + * drain and remains for the caller's next barrier. Results and contexts commit + * in model order. Abort stops starts, drains and commits started calls, accepts + * their contexts into the owning batch, records results for skipped calls, and + * returns an aborted outcome. Scheduler failure drains dispatches and rejects + * for the owning step to record recovery results. + */ +async function runGroup( + pipeline: ToolExecutionPipeline, + settlement: BatchSettlement, + maxParallelToolCalls: number, + group: PlannedCall[], + mode: ToolExecutionMode['kind'], + signal: AbortSignal, +): Promise { + const slots: (Slot | undefined)[] = group.map(() => undefined) + // Started slots retain their `tool/call` seq so the result can cite it. + const callSeqs: Array = group.map(() => undefined) + let nextToStart = 0 + let committed = 0 + let started = 0 + let aborted: boolean = signal.aborted + let concluded = false + let schedulerFailure: { error: unknown } | undefined + const throwSchedulerFailure = (): void => { + if (schedulerFailure !== undefined) throw schedulerFailure.error + } + + // `committed` advances only across contiguous model-order slots. + const commitReady = async (): Promise => { + while (committed < group.length) { + const slot = slots[committed] + if (slot === undefined) break + const call = group[committed] + const result = await pipeline.complete(slot.exec, slot) + // oxlint-disable-next-line typescript/no-non-null-assertion -- bounded index + settlement.commit(call!.block, result, callSeqs[committed]) + concluded ||= result.concludesTurn === true + committed++ + } + } + + const inFlight = new Map>() + + const startCall = async (index: number): Promise => { + // oxlint-disable-next-line typescript/no-non-null-assertion -- bounded index + const call = group[index]! + callSeqs[index] = settlement.start(call.block) + started++ + const prepared = await pipeline.prepare(call.exec) + throwSchedulerFailure() + switch (prepared.kind) { + case 'dispatch': { + const promise = pipeline.dispatch(prepared.exec).then( + (outcome) => { + slots[index] = { ...outcome, exec: prepared.exec } + return index + }, + (error: unknown) => { + schedulerFailure ??= { error } + return index + }, + ) + inFlight.set(index, promise) + break + } + case 'post-result': + case 'final-result': + slots[index] = prepared + break + /* v8 ignore next -- closed-union exhaustiveness guard */ + default: + assertNever(prepared, 'tool-call scheduler prepare result') + } + } + + const fillPool = async (): Promise => { + while (!aborted && nextToStart < group.length && inFlight.size < maxParallelToolCalls) { + // Re-read later modes after ordered commits so registry changes can create a barrier. + // oxlint-disable-next-line typescript/no-non-null-assertion -- bounded by the loop condition + const nextCall = group[nextToStart]! + if (nextToStart > 0 && mode === 'parallel' + && pipeline.executionMode(nextCall.exec).kind !== 'parallel') break + await startCall(nextToStart) + nextToStart++ + throwSchedulerFailure() + await commitReady() + throwSchedulerFailure() + // Abort may arrive while pre-execute awaits. + if (signal.aborted) aborted = true + } + } + + // Ordered pre-execute may await; only dispatch/body overlaps. A scheduler + // failure stops new dispatches and reaches the turn boundary after every + // already-started dispatch settles. + try { + await fillPool() + while (inFlight.size > 0) { + const settledIndex = await Promise.race(inFlight.values()) + inFlight.delete(settledIndex) + throwSchedulerFailure() + await commitReady() + throwSchedulerFailure() + // Abort may arrive while a tool or ordered commit awaits. + + if (signal.aborted) aborted = true + await fillPool() + } + } catch (error: unknown) { + schedulerFailure ??= { error } + await Promise.allSettled(inFlight.values()) + throw schedulerFailure.error + } + + if (aborted) { + // Started calls and accepted context settle first; every remaining model + // call then receives an ordered synthetic result before the turn aborts. + for (const call of group.slice(started)) settlement.skip(call.block) + return { consumed: group.length, aborted: true, concluded } + } + /* v8 ignore next -- unreachable: a non-aborted group commits every started call */ + if (committed !== started) throw new Error('tool-call scheduler: uncommitted settled calls') + return { consumed: started, aborted: false, concluded } +} + +/** Collect committed results and optionally append their durable Session events. */ +class BatchSettlement { + private readonly results: ToolCallResult[] = [] + private readonly contexts: UserMessage[] = [] + + constructor(private readonly input: ToolBatchInput) {} + + start(block: ToolCallBlock): SessionSeq | undefined { + if (this.input.log === undefined) return undefined + const { agent, log: { turn, step } } = this.input + // oxlint-disable-next-line typescript/no-non-null-assertion -- ToolBatchInput requires agent whenever log is provided + return agent!.session.append('tool/call', { + turn, step, callId: block.id, name: block.name, arguments: block.arguments, + }).seq + } + + commit(block: ToolCallBlock, result: ToolExecutionResult, callSeq: SessionSeq | undefined): void { + if (this.input.log !== undefined) { + const { agent, log: { turn, step } } = this.input + // oxlint-disable-next-line typescript/no-non-null-assertion -- ToolBatchInput requires agent whenever log is provided + agent!.session.append('tool/result', { + turn, step, + message: createToolResultMessage({ callId: block.id, content: result.content, isError: result.isError }), + ...result.error?.info ? { error: result.error.info } : {}, + ...result.meta !== undefined ? { meta: result.meta } : {}, + }, { + surfaceOp: 'append', + // oxlint-disable-next-line typescript/no-non-null-assertion -- start records a call seq whenever this batch logs results + sourceEventSeqs: [callSeq!], + }) + } + this.results.push({ callId: block.id, result }) + this.contexts.push(...result.additionalContexts ?? []) + } + + skip(block: ToolCallBlock): void { + this.commit(block, { + content: [{ type: 'text', text: 'Error: tool call aborted before dispatch' }], + isError: true, + error: { + message: 'tool call aborted before dispatch', + info: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH }, + }, + }, this.start(block)) + } + + result(concluded = this.results.some(({ result }) => result.concludesTurn === true)): ToolBatchResult { + return { results: this.results, additionalContexts: this.contexts, concluded } + } +} diff --git a/packages/core/tools/src/contracts.ts b/packages/core/tools/src/contracts.ts new file mode 100644 index 0000000000..8ca74bcc65 --- /dev/null +++ b/packages/core/tools/src/contracts.ts @@ -0,0 +1,341 @@ +/** Tool definitions and invocation data shared by registries and executors. @module */ +import { HarnessError } from '@deepseek-ai/dsh-llm' +import type { ToolCallId, ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { UserMessage } from '@deepseek-ai/dsh-session' +import type { JsonValue } from '@deepseek-ai/dsh-util-values' +import type { JsonSchemaNode } from './json-schema.ts' +import type { ToolCallView, ToolResultView } from './presentation.ts' + +/** Reserved model-facing program transport name. */ +export const RUN_CODE_NAME = 'run_code' +declare const toolExecutionTokenBrand: unique symbol + +/** Tool-owned canonical output contract used after the body returns a JSON value. */ +export interface ToolOutputDefinition { + /** Raw supported JSON Schema enforced against every successful canonical value. */ + readonly schema: JsonSchemaNode + /** Pure projection from validated arguments and value to Native/model content. */ + render(args: unknown, value: JsonValue): ContentBlock[] + /** Pure replayable presentation projection for native and nested calls. */ + presentationMeta?(args: unknown, value: JsonValue): JsonValue +} + +/** A registered tool: its schema plus the execution function. */ +export interface ToolDefinition extends ToolSchema { + /** Mandatory canonical output declaration. */ + readonly output: ToolOutputDefinition + /** + * Run one accepted call and return only its canonical lossless-JSON value. + * Async work must observe or forward `exec.signal` and settle only after its + * owned work reaches quiescence. The executor preserves caller cancellation + * through around-dispatch signal replacement and does not abandon this + * promise, but it cannot hard-kill same-process code. + * @param args - losslessly snapshotted, frozen model arguments. + * @param exec - execution identity, cancellation signal, and context deferral. + * @returns the canonical value declared by `output.schema`. + */ + execute(args: unknown, exec: ToolRunContext): Promise + /** + * Install execution-prepared content before `tools/post-execute` policies. + * The callback is captured when the call starts and runs once for a + * normalized outcome entering post-execute. Policy replacements remain + * authoritative; pipeline failures that bypass post-execute skip projection. + * @param exec - immutable execution identity and arguments. + * @param result - normalized result before post-execute policy. + * @returns replacement content, or undefined to preserve the renderer output. + */ + projectContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined + /** + * Synchronous last-mile transform for model-facing content. The executor + * snapshots this callback when execution starts and invokes it exactly once + * for every normalized outcome, including pipeline failures that bypass + * `tools/post-execute`, immediately before lossless materialization. + * Returning `undefined` preserves the content; every other result field + * remains executor-owned. The callback must be total and must not throw. + * @param exec - immutable execution identity and arguments. + * @param result - complete normalized outcome before materialization. + * @returns replacement content, or `undefined` to preserve it. + */ + finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined + /** + * Cooperative tool-call timeout budget in milliseconds. Omit for no deadline. + * Enforced by `@deepseek-ai/dsh-tool-call-timeout-policy` (a `tools/execute` wrapper); it + * is NEVER sent to the model — `schemas()` whitelists only name/description/ + * parameters. Declaring it asserts this tool forwards `exec.signal` to a + * cooperative implementation that can reach quiescence when the signal aborts. + */ + timeoutMs?: number + /** + * Pure synchronous classifier for overlap with sibling tool calls. Only + * `true` opts in; omission, exceptions, non-`true` returns, and invalid + * `defineTool` arguments are exclusive. This metadata is never model-visible. + * + * Opted-in executions must not mutate parent-owned state. Shared state must + * tolerate concurrent dispatch; recorder races are permitted only when they + * commute or fail closed. See the + * [parallel-tool-call Agent Note](../../../../.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md) + * for the full contract. + * @param args - parsed arguments; `defineTool` validates before calling. + * @returns Whether this call may join a parallel group. + */ + isConcurrencySafe?(args: unknown): boolean + /** + * Optional: how to present the PENDING state of one call in a UI, derived from + * the call's `args` (parsed arguments, `unknown` — the tool validates/narrows + * its own input). Returns a {@link ToolCallView} (a `card`-tagged render intent), + * or `undefined` (or omit the method) to fall back to a generic presentation + * (title = tool name, raw args as input). Pure and side-effect-free: a UI may + * call it during live streaming AND a session-log replay, so it must depend + * only on `args`. + */ + presentCall?(args: unknown): ToolCallView | undefined + /** + * Optional: how to present the COMPLETED state, given the same `args` and the + * durable result projection (`content`, failure state, and optional `meta`). Returns a + * {@link ToolResultView}, or `undefined` (or omit the method) to keep the + * pending title and render the raw result content. Pure and side-effect-free + * for the same replay reason. + */ + presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined +} + +/** The completed outcome handed to {@link ToolDefinition.presentResult}. */ +export interface ToolResult { + /** The final model-facing content (or the rendered error text on failure). */ + content: ContentBlock[] + /** Whether the call failed. */ + isError: boolean + /** + * The tool-private presentation payload projected by its output declaration. + * It is persisted on `tool/result` or `tool/ptc-dispatch` for Host presenters + * and Client renderers to narrow independently. Absent when the tool + * declared no projector. + */ + meta?: JsonValue +} + +/** Opaque call identity that permits correlation without exposing mutable execution state. */ +export type ToolExecutionToken = symbol & { readonly [toolExecutionTokenBrand]: true } + +/** + * Parsed input to the shared invocation pipeline. The pipeline + * adds its own token to form a {@link ToolExecution}; + * callers do not choose that token. + */ +export interface ToolExecutionInput { + readonly callId: ToolCallId + /** + * Root model-requested call owning this execution tree. Callers omit it for + * a root execution; nested dispatchers propagate the enclosing value. + */ + readonly rootCallId?: ToolCallId + readonly name: string + /** Binding-time tool schema for a PTC inner call; frozen by its producer and never logged. */ + readonly schema?: ToolSchema + /** Losslessly JSON-serializable parsed arguments (tools validate their own schema). */ + readonly arguments: unknown + /** The agent on whose behalf the call runs (set by the agent loop). */ + readonly agent?: Agent + /** + * Opaque token of the enclosing transport execution, when one exists. PTC + * mode sets this on SDK sub-dispatches so commit-style observers can wait for + * the outer `run_code` outcome without receiving its live mutable execution. + * The token also marks the call as a transport sub-dispatch rather than a + * model-direct call: with the PTC executor selected, only calls with a parent may + * execute a native tool name — a model-direct call (no parent) is denied as + * `UNKNOWN_TOOL` before the policy pipeline. + */ + readonly parent?: ToolExecutionToken + /** Required caller-owned cancellation for this invocation. */ + readonly signal: AbortSignal +} + +/** + * Scheduling mode for one pending call. `parallel` may overlap with siblings; + * `exclusive` runs alone and forms an ordering barrier. + */ +export type ToolExecutionMode = + | { kind: 'parallel' } + | { kind: 'exclusive' } + +/** + * One pending tool call inside the executor pipeline. Parsed arguments cross + * one lossless-JSON materialization boundary before policy and are deep-frozen; + * call identity, the caller signal, and the executor-assigned {@link token} are + * readonly. The executor freezes the complete object before `tools/result` + * observers run. + */ +export interface ToolExecution extends ToolExecutionInput { + /** Root model-requested call, resolved for every root and nested execution. */ + readonly rootCallId: ToolCallId + /** Executor-assigned identity shared with nested calls only as their opaque `parent` token. */ + readonly token: ToolExecutionToken +} + +/** + * Around-dispatch view of a {@link ToolExecution}. A `tools/execute` wrapper + * may replace the signal for its delegated lifetime, but it cannot remove it. + * The executor fuses every replacement with the captured caller signal. + */ +export interface ToolDispatchExecution extends Omit { + /** Cancellation signal visible to the next wrapper or tool body. */ + signal: AbortSignal +} + +/** + * Runtime context handed to a tool implementation after the executor has + * accepted a {@link ToolExecution}. {@link deferContext} attaches context to + * this execution's own result — a composite tool ferries nested-dispatch + * context back to the outer result, and a leaf tool may mint a fresh + * plugin-sourced instruction; the loop appends it only after the + * `tool/result`. + */ +export interface ToolRunContext extends ToolExecution { + /** + * Defer one context — typically a nested-dispatch context ferried by a + * composite tool, or a fresh plugin-sourced instruction — until this tool's + * final result reaches the agent loop. Contexts retain their individual + * source and metadata and are emitted in call order. + */ + deferContext(context: UserMessage): void + /** + * Mark a successful final result as terminal for the current agent turn. + * The marker rides this execution's own result (`concludesTurn` exists only + * on {@link ToolExecutionSuccess}); a composite that dispatches nested + * calls forwards it from the nested result, exactly like + * `additionalContexts`, so only an authoritative nested success can + * conclude the enclosing run. + */ + concludeTurn(): void +} + +/** Canonical error code for cancellation after a tool body was invoked. */ +export const TOOL_ABORTED = 'ABORTED' + +/** Canonical error code for cancellation before a tool body was invoked. */ +export const TOOL_ABORTED_BEFORE_DISPATCH = 'ABORTED_BEFORE_DISPATCH' + +/** Structured error metadata for a failed tool call (alongside the model-facing text). */ +export interface ToolErrorInfo { + name: string + code: string + /** Optional raw user-facing detail; durable projections preserve it but model-facing content does not include it. */ + reason?: string +} + +/** Canonical failure detail; internal routing information remains optional. */ +export interface ToolFailure { + /** Human-readable failure message without the Native `Error: ` envelope. */ + message: string + /** Internal error class/code used by policy and durable diagnostics. */ + info?: ToolErrorInfo +} + +/** + * Thrown (internally) when the model requests a tool that isn't registered. + * Extends {@link HarnessError} (`code: 'UNKNOWN_TOOL'`) so an unknown-tool + * failure is as routable as a tool-thrown one — retry/sandbox/replay code can + * distinguish it from a tool body's own error. + */ +export class ToolNotFoundError extends HarnessError { + /** + * @param toolName - the name the caller asked for. + * @param reachableFrom - how the model reaches this tool instead, when the + * name IS visible and only the presentation denies calling it directly. + * Omitted for a name that is registered nowhere. + */ + constructor(toolName: string, reachableFrom?: string) { + super( + reachableFrom === undefined + ? `unknown tool "${toolName}"` + : `unknown tool "${toolName}": ${reachableFrom}`, + 'UNKNOWN_TOOL', + ) + this.name = 'ToolNotFoundError' + } +} + +/** Thrown when a tool body or post-policy value violates its declared output. */ +export class ToolOutputError extends HarnessError { + /** Schema/value violations in validation order. */ + readonly violations: string[] + + constructor(toolName: string, violations: string[]) { + super(`tool "${toolName}" returned invalid output: ${violations.join('; ')}`, 'INVALID_TOOL_OUTPUT') + this.name = 'ToolOutputError' + this.violations = violations + } +} + +/** Successful canonical tool execution, including its Native/model projection. */ +export interface ToolExecutionSuccess { + readonly isError: false + /** Execution-local canonical value; deliberately omitted from durable events. */ + readonly value: JsonValue + readonly content: ContentBlock[] + readonly error?: never + readonly meta?: JsonValue + readonly additionalContexts?: UserMessage[] + /** The agent loop stops after committing this successful result batch. */ + readonly concludesTurn?: true +} + +/** Failed canonical tool execution; failures never carry a successful value. */ +export interface ToolExecutionFailure { + readonly isError: true + readonly error: ToolFailure + readonly value?: never + readonly content: ContentBlock[] + readonly meta?: JsonValue + readonly additionalContexts?: UserMessage[] + readonly concludesTurn?: never +} + +/** The discriminated, execution-local outcome of one tool call. */ +export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure + +/** + * Pre-dispatch decision. `allow` runs the call; `deny` materializes its + * model-facing reason and optional structured error identity; `cancel` selects + * the canonical cancellation result without presenting a policy denial; `ask` + * runs only after an approval service returns `allowed-once` and otherwise + * denies; its `reason` is the audited approval reason and its optional + * `displayReason` is the localized prompt text. Input rewriting is excluded because arguments are already logged and + * presented. + */ +export type PreToolDecision = + | { kind: 'allow' } + | { kind: 'deny'; reason: string; info?: ToolErrorInfo } + | { kind: 'cancel' } + | { kind: 'ask'; reason?: string; displayReason?: { readonly en: string; readonly [locale: string]: string } } + +/** + * Post-dispatch decision: accept, replace one projection, attach context for the + * next request, or block by turning corrective feedback into an error result. + */ +export type PostToolDecision = + | { kind: 'accept'; content?: ContentBlock[]; value?: never; additionalContexts?: UserMessage[] } + | { kind: 'accept'; value: JsonValue; content?: never; additionalContexts?: UserMessage[] } + | { kind: 'block'; feedback: ContentBlock[]; additionalContexts?: UserMessage[] } + +/** + * Per-scope filter over global tools. Restrictions intersect and do not affect + * scoped registrations or the reserved PTC mode transport. + */ +export interface ToolRestriction { + /** Global tool names that stay visible; everything else is removed. */ + readonly allow?: readonly string[] + /** Global tool names removed from visibility. */ + readonly deny?: readonly string[] +} + +/** + * A monotonic execution guard evaluated after every `tools/pre-execute` + * listener and before the tool body. Returning a reason denies the call; + * returning `undefined` leaves it unchanged. Because guards have no allow + * result, listener ordering cannot turn a denial back into permission. + * @param execution - the identity-protected call after extensible pre-execute policy completed. + * @returns a final denial reason, or `undefined` to leave the call allowed. + */ +export type ToolGuard = (execution: Readonly) => string | undefined diff --git a/packages/core/tools/src/events.ts b/packages/core/tools/src/events.ts new file mode 100644 index 0000000000..f736de8908 --- /dev/null +++ b/packages/core/tools/src/events.ts @@ -0,0 +1,58 @@ +/** Tool contribution changes and shared execution policy events. @module */ +import type { Scoped } from '@deepseek-ai/dsh-scope' +import type { ToolExecution, ToolDispatchExecution, ToolExecutionResult, PreToolDecision, PostToolDecision } from './contracts.ts' +import type { ToolsService } from './index.ts' + +declare module '@deepseek-ai/cordis' { + interface Events { + /** + * Allow, deny, cancel, or ask before dispatch. `next()` delegates to allow; + * `cancel` selects the canonical pre-dispatch cancellation result, and missing + * approval support turns `ask` into denial. Async gates must observe + * `exec.signal`; the registry rechecks cancellation after they settle but + * never abandons their promise. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls. + * @param exec - the pending call (name, parsed arguments, caller agent). + * @mode waterfall + */ + 'tools/pre-execute'(this: Scoped, exec: ToolExecution, next: () => Promise): Promise + /** + * Around-dispatch waterfall for timeout, retry, or metrics. `next()` returns + * a normalized result; wrappers may change only `exec.signal`, while call + * identity remains immutable. The registry re-fuses the original caller + * signal before the body, so replacement cannot detach caller cancellation; + * wrappers must still restore their signal and reach quiescence. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls. + * @param exec - the allowed call about to dispatch (name, parsed arguments, caller agent, signal). + * @mode waterfall + */ + 'tools/execute'(this: Scoped, exec: ToolDispatchExecution, next: () => Promise): Promise + /** + * Accept, replace, enrich, or block a normalized dispatch result. `next()` + * accepts it unchanged; thrown tools still reach this waterfall as errors. Async + * listeners must observe `exec.signal`; after they settle, caller + * cancellation replaces only a successful accepted outcome with the code + * selected by whether the tool body was invoked. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls. + * @param exec - the call that just ran (name, parsed arguments, caller agent). + * @param result - the dispatch outcome a listener may accept, replace, or block. + * @mode waterfall + */ + 'tools/post-execute'(this: Scoped, exec: ToolExecution, result: Readonly, next: () => Promise): Promise + /** + * Observe the frozen, lossless-JSON final outcome. Listener failures are contained. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): keyed by `exec.agent`. + * @param exec - the execution object that traversed the pipeline. + * @param result - a deep-frozen snapshot of the final returned result. + * @mode emit + */ + 'tools/result'(this: Scoped, exec: Readonly, result: Readonly): undefined + /** + * A tool was registered or unregistered, or a scoped restriction changed. + * Unfiltered registry notification: scoped listeners receive changes from every + * scope because a global contribution can affect every agent's next assembly. + * @mode emit + */ + 'tools/change'(): void + } +} diff --git a/packages/core/tools/src/executor.ts b/packages/core/tools/src/executor.ts new file mode 100644 index 0000000000..c7a508870b --- /dev/null +++ b/packages/core/tools/src/executor.ts @@ -0,0 +1,97 @@ +/** Model tool descriptions and complete batch execution owned by plugins. @module */ +import { HarnessError } from '@deepseek-ai/dsh-llm' +import type { ToolCallBlock, ToolCallId } from '@deepseek-ai/dsh-llm' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { UserMessage } from '@deepseek-ai/dsh-session' +import type { PromptSection, ToolProviderResult } from '@deepseek-ai/dsh-system-prompt' +import type { ToolDefinition, ToolExecutionResult } from './contracts.ts' + +/** Model-call schemas produced by an executor. */ +export type ToolModelInput = Pick + +/** Executor-owned guidance registered through the system-prompt section API. */ +export interface ToolPromptSection extends Pick { + /** Static guidance or text generated from this step's visible capability definitions. */ + readonly text: string | ((tools: readonly ToolDefinition[]) => string) +} + +/** Raw model calls, with optional Agent ownership and model-step logging. */ +export interface ToolBatchInput { + readonly calls: readonly ToolCallBlock[] + readonly signal: AbortSignal + /** + * Positive integer overriding the executor's concurrency limit for this batch. + * Applies to concurrency-safe root calls; program subcall limits remain separate. + * Undefined uses the executor's configured limit. + */ + readonly maxParallelToolCalls?: number + /** + * Owning Agent supplies scoped tools and execution context, including outside a turn. + * Undefined for standalone calls outside an Agent, such as a service invocation. + */ + readonly agent?: Agent + /** + * Committed model step receiving root call/result events; requires `agent`. + * Undefined when `agent` is absent or for calls outside a model response, such + * as session initialization hooks. Without `log`, root call/result events are + * omitted while `agent`, when provided, still supplies scope and execution context. + */ + readonly log?: { + readonly turn: number + readonly step: number + } +} + +/** One settled result correlated with its original model call. */ +export interface ToolCallResult { + readonly callId: ToolCallId + readonly result: ToolExecutionResult +} + +/** Model-ordered outcomes and context accepted for the next step. */ +export interface ToolBatchResult { + readonly results: readonly ToolCallResult[] + readonly additionalContexts: readonly UserMessage[] + readonly concluded: boolean +} + +/** A plugin owns argument parsing, dispatch, scheduling, cancellation, and logging. */ +export interface ToolExecutor { + /** Guidance registered and disposed with this executor; absent means no executor-owned sections. */ + readonly sections?: readonly ToolPromptSection[] + /** + * Describe the model's callable tools. + * @param tools - currently visible capability definitions, after scoped restrictions. + * @returns schemas and names used to validate tool ordering. + */ + describe(tools: readonly ToolDefinition[]): ToolModelInput + /** + * Execute a complete batch. With log, commit root call/result events to the + * Agent's Session; without log, omit those events. Tool-owned events may still + * use the owning Agent's Session. Return + * one outcome per input call in input order, including skipped calls on abort. + * Never settle while started work is still running. Internal failures reject + * with ToolBatchExecutionError after draining, preserving committed outcomes + * and context; callers must stop dependent work and repair missing log results. + * An explicit maxParallelToolCalls takes precedence over executor configuration. + * @param input - original calls, cancellation, optional concurrency override, Agent and logging step. + * @returns settled results, deferred context, and whether a success concludes the turn. + */ + execute(input: ToolBatchInput): Promise +} + +/** A drained batch failed internally; only the included results are committed. */ +export class ToolBatchExecutionError extends Error { + constructor(cause: unknown, readonly partial: ToolBatchResult) { + super('Tool batch execution failed', { cause }) + this.name = 'ToolBatchExecutionError' + } +} + +/** The selected provider is missing or unloaded; another provider must not take its calls. */ +export class ToolExecutorUnavailableError extends HarnessError { + constructor() { + super('The selected tool executor is unavailable. Load an executor in this composition before running the agent.', 'TOOL_EXECUTOR_UNAVAILABLE') + this.name = 'ToolExecutorUnavailableError' + } +} diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index ca939d4dfb..3bbf45ad6a 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -1,33 +1,16 @@ -/** - * Tool registry, model presentation modes, and pre/guard/around/post/result - * execution pipeline. - * @module @deepseek-ai/dsh-tools - */ - -import type {} from '@deepseek-ai/dsh-working-directory' +/** Tool definitions and scoped dispatch to plugin-owned executors. @module */ import { Context, Service } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' -import { AnonymousEntries, NamedEntries, ScopedLayers, scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope' -import type { ScopeKey, ScopeLayer, Scoped } from '@deepseek-ai/dsh-scope' -import type { ToolCallId, ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm' -import { HarnessError } from '@deepseek-ai/dsh-llm' -import type { Agent } from '@deepseek-ai/dsh-agent' -import type { UserMessage } from '@deepseek-ai/dsh-session' -import { assertNever, deepFreeze, snapshotJsonValue, type JsonValue } from '@deepseek-ai/dsh-util-values' -import type { PromptSection, ToolProviderResult } from '@deepseek-ai/dsh-system-prompt' -import type { PtcRuntime } from '@deepseek-ai/dsh-ptc-runtime' -import type {} from '@deepseek-ai/dsh-sandbox-policy' -// Type-only: makes `ctx.get('approval')` resolve to the ApprovalService -// augmentation. The seam stays optional at runtime — see `serviceAsk`. -import type {} from '@deepseek-ai/dsh-user-approval' -import type { ToolCallView, ToolResultView } from './presentation.ts' -import { assertSupportedJsonSchema, validateJsonSchemaValue } from './json-schema.ts' -import type { JsonSchemaNode } from './json-schema.ts' -import { createRunCodeTool, RUN_CODE_NAME } from './ptc.ts' -import type { PtcSdkLanguage } from './ptc.ts' -import { renderToolsSdk } from './ts-types.ts' -import type { ToolSdkSchema } from './ts-types.ts' -import { renderToolsSdkPy } from './py-types.ts' +import { AnonymousEntries, NamedEntries, ScopedLayers, scopeOf, scopeChainOf } from '@deepseek-ai/dsh-scope' +import type { ScopeKey, ScopeLayer } from '@deepseek-ai/dsh-scope' +import { ToolExecutorUnavailableError } from './executor.ts' +import type { ToolExecutor, ToolBatchInput, ToolBatchResult } from './executor.ts' +import './events.ts' +import type { ToolSchema } from '@deepseek-ai/dsh-llm' +import { projectToolSchema } from './schema.ts' +import { assertSupportedJsonSchema } from './json-schema.ts' +import { RUN_CODE_NAME } from './contracts.ts' +import type { ToolDefinition, ToolRestriction, ToolExecution, ToolGuard } from './contracts.ts' declare module '@deepseek-ai/dsh-llm' { interface MessageSourceMap { @@ -36,673 +19,10 @@ declare module '@deepseek-ai/dsh-llm' { } } -/** - * Language → SDK-section renderer. The registry looks up the loaded - * `ctx.ptcRuntime.language` in this table when assembling the `tools:sdk` - * section under a non-native mode; a runtime whose language is not a key - * fails the assembly loudly (same idiom as `toolOrder` violations). Adding a - * new backend language is three parallel edits — a {@link PtcSdkLanguage} - * member, an entry here, and a `RUN_CODE_FLAVORS` entry in `ptc.ts` for - * its `run_code` schema strings — plus the renderer function this table points - * at. The `satisfies` clause pins this table's key set to that union, which - * the flavor table is checked against too, so any of the three left out is a - * typecheck failure. What no check reaches is the prose that names the values - * instead of deriving them: the seam's `dsh-ptc-runtime` README pair, its - * `PtcRuntime.language` JSDoc, and `docs/subsystems/ptc-runtime.md` - * with its zh pair, plus this package's own README pair and the - * {@link Config.mode} JSDoc. - */ -/** - * The model-facing statement of the `ptc` collapse. Names the consequence - * (the call fails) and the route (inside the program), because a rule the - * model can only discover by being denied is one it corrects too late. - */ -const PTC_ONLY_INSTRUCTION = `\`${RUN_CODE_NAME}\` is the only tool you can call directly — a tool call naming any other tool fails. Reach every tool the SDK declares below from inside the program.` - -const SDK_RENDERERS: Record string> = { - typescript: renderToolsSdk, - python: renderToolsSdkPy, -} satisfies Record string> - -export { - defineTool, - valueSchemaSpecToJsonSchema, - parameterSchemaSpecToJsonSchema, - validateArgs, - ToolArgsError, - type ValueSchemaAnnotations, - type StringValueSchemaSpec, - type NumberValueSchemaSpec, - type IntegerValueSchemaSpec, - type BooleanValueSchemaSpec, - type NullValueSchemaSpec, - type ArrayValueSchemaSpec, - type ObjectValueSchemaSpec, - type JsonValueSchemaSpec, - type OneOfValueSchemaSpec, - type ValueSchemaSpec, - type ParameterPropertySpec, - type ParameterSchemaSpec, - type ParameterJsonSchema, - type InferValue, - type InferArgs, - type DefineToolOptions, -} from './schema.ts' - -export { - assertSupportedJsonSchema, - assertObjectJsonSchema, - validateJsonSchemaValue, - JsonSchemaError, - type JsonSchemaNode, - type ObjectJsonSchema, - type JsonSchemaType, - type JsonSchemaScalar, -} from './json-schema.ts' - -export type { PtcDispatchEventData, PtcDispatchStartEventData } from './types.ts' - -export { CodeRunFailedError, RUN_CODE_NAME } from './ptc.ts' -export { jsonSchemaToTs, renderToolsSdk } from './ts-types.ts' -export { jsonSchemaToPy, renderToolsSdkPy } from './py-types.ts' -export { defineContentToolFixture, type ContentToolFixtureOptions } from './testing.ts' - -// The render-intent vocabulary a tool declares via `presentCall`/`presentResult` -// lives in its own UI-facing module; re-export it so `@deepseek-ai/dsh-tools` -// stays the single public API for tool producers and UI adapters. -export type { - ToolCallKind, - FileLocation, - FileDiff, - ReadFileLine, - ToolCallView, - GenericCallView, - TerminalCallView, - DiffCallView, - ToolResultView, - GenericResultView, - TerminalResultView, - DiffResultView, - SearchResultView, - SearchMatchesResultView, - SearchPathsResultView, - SearchFileMatches, - SearchLineMatch, - ReadResultView, - WebResultView, - WebSearchResultView, - WebFetchResultView, - WebSource, -} from './presentation.ts' - declare module '@deepseek-ai/cordis' { interface Context { - tools: ToolRuntime + tools: ToolsService } - - interface Events { - /** - * Allow, deny, cancel, or ask before dispatch. `next()` delegates to allow; - * `cancel` selects the canonical pre-dispatch cancellation result, and missing - * approval support turns `ask` into denial. Async gates must observe - * `exec.signal`; the registry rechecks cancellation after they settle but - * never abandons their promise. - * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls. - * @param exec - the pending call (name, parsed arguments, caller agent). - * @mode waterfall - */ - 'tools/pre-execute'(this: Scoped, exec: ToolExecution, next: () => Promise): Promise - /** - * Around-dispatch waterfall for timeout, retry, or metrics. `next()` returns - * a normalized result; wrappers may change only `exec.signal`, while call - * identity remains immutable. The registry re-fuses the original caller - * signal before the body, so replacement cannot detach caller cancellation; - * wrappers must still restore their signal and reach quiescence. - * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls. - * @param exec - the allowed call about to dispatch (name, parsed arguments, caller agent, signal). - * @mode waterfall - */ - 'tools/execute'(this: Scoped, exec: ToolDispatchExecution, next: () => Promise): Promise - /** - * Accept, replace, enrich, or block a normalized dispatch result. `next()` - * accepts it unchanged; thrown tools still reach this waterfall as errors. Async - * listeners must observe `exec.signal`; after they settle, caller - * cancellation replaces only a successful accepted outcome with the code - * selected by whether the tool body was invoked. - * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls. - * @param exec - the call that just ran (name, parsed arguments, caller agent). - * @param result - the dispatch outcome a listener may accept, replace, or block. - * @mode waterfall - */ - 'tools/post-execute'(this: Scoped, exec: ToolExecution, result: Readonly, next: () => Promise): Promise - /** - * Allow a listener to replace content in the DURABLE LOG COPY of one - * `run_code` sub-dispatch outcome before the bridge appends its - * `tool/ptc-dispatch` event. `next()` keeps the - * content unchanged; a listener may return replacement blocks (e.g. the - * spill policy's preview + locator for an oversized text result). Only the - * logged copy is affected — the program already received the complete - * value, and the model sees neither. A throwing listener is contained: - * the bridge falls back to logging the original settled content. - * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's dispatches. - * @param dispatch - the parent execution, sub-call identity, and the settled content to log. - * @mode waterfall - */ - 'tools/ptc-dispatch-log'(this: Scoped, dispatch: PtcDispatchLog, next: () => Promise): Promise - /** - * Observe the frozen, lossless-JSON final outcome. Listener failures are contained. - * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): keyed by `exec.agent`. - * @param exec - the execution object that traversed the pipeline. - * @param result - a deep-frozen snapshot of the final returned result. - * @mode emit - */ - 'tools/result'(this: Scoped, exec: Readonly, result: Readonly): undefined - /** - * A tool was registered or unregistered, or a scoped restriction changed - * (the available tool set changed — possibly for one scope only). An - * UNFILTERED registry-subject notification, deliberately not scope-filtered - * dispatch: a global change concerns every agent's next assembly, so a - * scoped listener subscribing here sees every change, not just its own - * scope's. - * @mode emit - */ - 'tools/change'(): void - } -} - -/** Tool-owned canonical output contract used after the body returns a JSON value. */ -export interface ToolOutputDefinition { - /** Raw supported JSON Schema enforced against every successful canonical value. */ - readonly schema: JsonSchemaNode - /** Pure projection from validated arguments and value to Native/model content. */ - render(args: unknown, value: JsonValue): ContentBlock[] - /** Pure replayable presentation projection for native and nested calls. */ - presentationMeta?(args: unknown, value: JsonValue): JsonValue -} - -/** A registered tool: its schema plus the execution function. */ -export interface ToolDefinition extends ToolSchema { - /** Mandatory canonical output declaration. */ - readonly output: ToolOutputDefinition - /** - * Run one accepted call and return only its canonical lossless-JSON value. - * Async work must observe or forward `exec.signal` and settle only after its - * owned work reaches quiescence. The registry preserves caller cancellation - * through around-dispatch signal replacement and does not abandon this - * promise, but it cannot hard-kill same-process code. - * @param args - losslessly snapshotted, frozen model arguments. - * @param exec - execution identity, cancellation signal, and context deferral. - * @returns the canonical value declared by `output.schema`. - */ - execute(args: unknown, exec: ToolRunContext): Promise - /** - * Install execution-prepared content before `tools/post-execute` policies. - * The callback is captured when the call starts and runs once for a - * normalized outcome entering post-execute. Policy replacements remain - * authoritative; pipeline failures that bypass post-execute skip projection. - * @param exec - immutable execution identity and arguments. - * @param result - normalized result before post-execute policy. - * @returns replacement content, or undefined to preserve the renderer output. - */ - projectContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined - /** - * Synchronous last-mile transform for model-facing content. The registry - * snapshots this callback when execution starts and invokes it exactly once - * for every normalized outcome, including pipeline failures that bypass - * `tools/post-execute`, immediately before lossless materialization. - * Returning `undefined` preserves the content; every other result field - * remains registry-owned. The callback must be total and must not throw. - * @param exec - immutable execution identity and arguments. - * @param result - complete normalized outcome before materialization. - * @returns replacement content, or `undefined` to preserve it. - */ - finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined - /** - * Cooperative tool-call timeout budget in milliseconds. Omit for no deadline. - * Enforced by `@deepseek-ai/dsh-tool-call-timeout-policy` (a `tools/execute` wrapper); it - * is NEVER sent to the model — `schemas()` whitelists only name/description/ - * parameters. Declaring it asserts this tool forwards `exec.signal` to a - * cooperative implementation that can reach quiescence when the signal aborts. - */ - timeoutMs?: number - /** - * Pure synchronous classifier for overlap with sibling tool calls. Only - * `true` opts in; omission, exceptions, non-`true` returns, and invalid - * `defineTool` arguments are exclusive. This metadata is never model-visible. - * - * Opted-in executions must not mutate parent-owned state. Shared state must - * tolerate concurrent dispatch; recorder races are permitted only when they - * commute or fail closed. See the - * [parallel-tool-call Agent Note](../../../../.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md) - * for the full contract. - * @param args - parsed arguments; `defineTool` validates before calling. - * @returns Whether this call may join a parallel group. - */ - isConcurrencySafe?(args: unknown): boolean - /** - * Optional: how to present the PENDING state of one call in a UI, derived from - * the call's `args` (parsed arguments, `unknown` — the tool validates/narrows - * its own input). Returns a {@link ToolCallView} (a `card`-tagged render intent), - * or `undefined` (or omit the method) to fall back to a generic presentation - * (title = tool name, raw args as input). Pure and side-effect-free: a UI may - * call it during live streaming AND a session-log replay, so it must depend - * only on `args`. - */ - presentCall?(args: unknown): ToolCallView | undefined - /** - * Optional: how to present the COMPLETED state, given the same `args` and the - * durable result projection (`content`, failure state, and optional `meta`). Returns a - * {@link ToolResultView}, or `undefined` (or omit the method) to keep the - * pending title and render the raw result content. Pure and side-effect-free - * for the same replay reason. - */ - presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined -} - -/** The completed outcome handed to {@link ToolDefinition.presentResult}. */ -export interface ToolResult { - /** The final model-facing content (or the rendered error text on failure). */ - content: ContentBlock[] - /** Whether the call failed. */ - isError: boolean - /** - * The tool-private presentation payload projected by its output declaration. - * It is persisted on `tool/result` or `tool/ptc-dispatch` for Host presenters - * and Client renderers to narrow independently. Absent when the tool - * declared no projector. - */ - meta?: JsonValue -} - -declare const toolExecutionTokenBrand: unique symbol - -/** Opaque call identity that permits correlation without exposing mutable execution state. */ -export type ToolExecutionToken = symbol & { readonly [toolExecutionTokenBrand]: true } - -/** - * Caller-supplied description of one tool call. {@link ToolRuntime.execute} - * adds the registry-owned token to form a pipeline {@link ToolExecution}; - * callers do not choose that token. - */ -export interface ToolExecutionInput { - readonly callId: ToolCallId - /** - * Root model-requested call owning this execution tree. Callers omit it for - * a root execution; nested dispatchers propagate the enclosing value. - */ - readonly rootCallId?: ToolCallId - readonly name: string - /** Binding-time tool schema for a PTC inner call; frozen by its producer and never logged. */ - readonly schema?: ToolSchema - /** Losslessly JSON-serializable parsed arguments (tools validate their own schema). */ - readonly arguments: unknown - /** The agent on whose behalf the call runs (set by the agent loop). */ - readonly agent?: Agent - /** - * Opaque token of the enclosing transport execution, when one exists. PTC - * mode sets this on SDK sub-dispatches so commit-style observers can wait for - * the outer `run_code` outcome without receiving its live mutable execution. - * The token also marks the call as a transport sub-dispatch rather than a - * model-direct call: under `mode: 'ptc'`, only calls WITH a parent may - * execute a native tool name — a model-direct call (no parent) is denied as - * `UNKNOWN_TOOL` before the policy pipeline. See {@link ToolRuntime.execute}. - */ - readonly parent?: ToolExecutionToken - /** Required caller-owned cancellation for this invocation. */ - readonly signal: AbortSignal -} - -/** - * Scheduling mode for one pending call. `parallel` may overlap with siblings; - * `exclusive` runs alone and forms an ordering barrier. - */ -export type ToolExecutionMode = - | { kind: 'parallel' } - | { kind: 'exclusive' } - -/** - * One settled `run_code` sub-dispatch about to be logged, as seen by the - * `tools/ptc-dispatch-log` waterfall: the parent execution (session owner, - * outer call identity), the sub-call identity, and the outcome whose durable - * copy a listener may reshape. `content` is the RENDERED result projection - * (what a native `tool/result` would carry) — the program itself received - * the structured `value` (or just the error message on failure); only the - * `tool/ptc-dispatch` event's copy changes. - */ -export interface PtcDispatchLog { - /** The outer `run_code` execution. */ - readonly exec: ToolExecution - /** The calling agent (the scope routing key and the spill owner), when the outer call has one. */ - readonly agent?: Agent - /** Opaque sub-call id; new calls use `:ptc:`. */ - readonly subCallId: ToolCallId - /** The dispatched sub-tool name. */ - readonly name: string - /** Whether the sub-call settled as an error. */ - readonly isError: boolean - /** The sub-call's complete model-facing content (the settle event's default payload). */ - readonly content: ContentBlock[] -} - -/** - * One pending tool call inside the registry pipeline. Parsed arguments cross - * one lossless-JSON materialization boundary before policy and are deep-frozen; - * call identity, the caller signal, and the registry-assigned {@link token} are - * readonly. The registry freezes the complete object before `tools/result` - * observers run. - */ -export interface ToolExecution extends ToolExecutionInput { - /** Root model-requested call, resolved for every root and nested execution. */ - readonly rootCallId: ToolCallId - /** Registry-assigned identity shared with nested calls only as their opaque `parent` token. */ - readonly token: ToolExecutionToken -} - -/** - * Around-dispatch view of a {@link ToolExecution}. A `tools/execute` wrapper - * may replace the signal for its delegated lifetime, but it cannot remove it. - * The registry fuses every replacement with the captured caller signal. - */ -export interface ToolDispatchExecution extends Omit { - /** Cancellation signal visible to the next wrapper or tool body. */ - signal: AbortSignal -} - -/** - * Runtime context handed to a tool implementation after the registry has - * accepted a {@link ToolExecution}. {@link deferContext} attaches context to - * this execution's own result — a composite tool ferries nested-dispatch - * context back to the outer result, and a leaf tool may mint a fresh - * plugin-sourced instruction; the loop appends it only after the - * `tool/result`. - */ -export interface ToolRunContext extends ToolExecution { - /** - * Defer one context — typically a nested-dispatch context ferried by a - * composite tool, or a fresh plugin-sourced instruction — until this tool's - * final result reaches the agent loop. Contexts retain their individual - * source and metadata and are emitted in call order. - */ - deferContext(context: UserMessage): void - /** - * Mark a successful final result as terminal for the current agent turn. - * The marker rides this execution's own result (`concludesTurn` exists only - * on {@link ToolExecutionSuccess}); a composite that dispatches nested - * calls forwards it from the nested result, exactly like - * `additionalContexts`, so only an authoritative nested success can - * conclude the enclosing run. - */ - concludeTurn(): void -} - -/** Registry-owned live execution object; public pipeline views stay readonly. */ -type MutableToolRunContext = Omit & { signal: AbortSignal } - -/** - * Scheduler-only result after ordered pre-execute and guards. A `post-result` - * still receives post-execute; a `final-result` bypasses it. - * @internal - */ -export type ScheduledToolPreparation = - | { kind: 'dispatch'; exec: ToolRunContext } - | { kind: 'post-result'; exec: ToolRunContext; result: ToolExecutionResult } - | { kind: 'final-result'; exec: ToolRunContext; result: ToolExecutionResult } - -/** - * Scheduler-only dispatch result. A `post-result` still receives post-execute; - * a `final-result` already matches {@link ToolRuntime.execute} failure semantics. - * @internal - */ -export type ScheduledToolDispatch = - | { kind: 'post-result'; result: ToolExecutionResult } - | { kind: 'final-result'; result: ToolExecutionResult } - -/** - * Symbol-keyed scheduler view that keeps pre/post policy ordered while - * overlapping dispatch. Ordinary callers use {@link ToolRuntime.execute}; - * this is not a plugin extension point. - * @internal - */ -export interface ToolRuntimeScheduler { - /** Materialize input, run the ordered pre-execute/guard gate, and decide what stage follows. */ - prepare(exec: ToolExecutionInput): Promise - /** Run only the around-dispatch/body stage. */ - dispatch(exec: ToolRunContext): Promise - /** Run post-execute and definition-owned content finalization, then materialize and notify. */ - finalize(exec: ToolRunContext, result: ToolExecutionResult): Promise - /** Run definition-owned content finalization, then materialize and notify without post-execute. */ - finish(exec: ToolRunContext, result: ToolExecutionResult): ToolExecutionResult -} - -/** - * Scheduler entry point omitted from the generated named service API. - * @internal - */ -export const TOOL_RUNTIME_SCHEDULER: unique symbol = Symbol('@deepseek-ai/dsh-tools.scheduler') - -/** Canonical error code for cancellation after a tool body was invoked. */ -export const TOOL_ABORTED = 'ABORTED' - -/** Canonical error code for cancellation before a tool body was invoked. */ -export const TOOL_ABORTED_BEFORE_DISPATCH = 'ABORTED_BEFORE_DISPATCH' - -/** Structured error metadata for a failed tool call (alongside the model-facing text). */ -export interface ToolErrorInfo { - name: string - code: string - /** Optional raw user-facing detail; durable projections preserve it but model-facing content does not include it. */ - reason?: string -} - -/** Canonical failure detail; internal routing information remains optional. */ -export interface ToolFailure { - /** Human-readable failure message without the Native `Error: ` envelope. */ - message: string - /** Internal error class/code used by policy and durable diagnostics. */ - info?: ToolErrorInfo -} - -/** - * Thrown (internally) when the model requests a tool that isn't registered. - * Extends {@link HarnessError} (`code: 'UNKNOWN_TOOL'`) so an unknown-tool - * failure is as routable as a tool-thrown one — retry/sandbox/replay code can - * distinguish it from a tool body's own error. - */ -export class ToolNotFoundError extends HarnessError { - /** - * @param toolName - the name the caller asked for. - * @param reachableFrom - how the model reaches this tool instead, when the - * name IS visible and only the presentation denies calling it directly. - * Omitted for a name that is registered nowhere. - */ - constructor(toolName: string, reachableFrom?: string) { - super( - reachableFrom === undefined - ? `unknown tool "${toolName}"` - : `unknown tool "${toolName}": ${reachableFrom}`, - 'UNKNOWN_TOOL', - ) - this.name = 'ToolNotFoundError' - } -} - -/** Thrown when a tool body or post-policy value violates its declared output. */ -export class ToolOutputError extends HarnessError { - /** Schema/value violations in validation order. */ - readonly violations: string[] - - constructor(toolName: string, violations: string[]) { - super(`tool "${toolName}" returned invalid output: ${violations.join('; ')}`, 'INVALID_TOOL_OUTPUT') - this.name = 'ToolOutputError' - this.violations = violations - } -} - -/** Convert one projector exception into the canonical invalid-output failure. */ -function projectionError(toolName: string, projector: 'render' | 'presentationMeta', error: unknown): ToolOutputError { - return new ToolOutputError(toolName, [`output.${projector} failed: ${errorMessage(error)}`]) -} - -/** Snapshot one projector result before later durable-result materialization. */ -function snapshotProjection(toolName: string, projector: 'render' | 'presentationMeta', candidate: T): T { - try { - const detached = snapshotJsonValue(candidate) - if (detached === undefined) { - throw new ToolOutputError(toolName, [`output.${projector} returned non-lossless JSON`]) - } - return detached - } catch (error: unknown) { - if (error instanceof ToolOutputError) throw error - throw projectionError(toolName, projector, error) - } -} - -/** Snapshot one body or policy value into the canonical invalid-output failure class. */ -function snapshotToolValue(toolName: string, candidate: unknown): JsonValue { - try { - const detached = snapshotJsonValue(candidate) - if (detached === undefined) throw new ToolOutputError(toolName, ['value is not lossless JSON']) - return detached as JsonValue - } catch (error: unknown) { - if (error instanceof ToolOutputError) throw error - throw new ToolOutputError(toolName, [`value snapshot failed: ${errorMessage(error)}`]) - } -} - -/** Successful canonical tool execution, including its Native/model projection. */ -export interface ToolExecutionSuccess { - readonly isError: false - /** Execution-local canonical value; deliberately omitted from durable events. */ - readonly value: JsonValue - readonly content: ContentBlock[] - readonly error?: never - readonly meta?: JsonValue - readonly additionalContexts?: UserMessage[] - /** The agent loop stops after committing this successful result batch. */ - readonly concludesTurn?: true -} - -/** Failed canonical tool execution; failures never carry a successful value. */ -export interface ToolExecutionFailure { - readonly isError: true - readonly error: ToolFailure - readonly value?: never - readonly content: ContentBlock[] - readonly meta?: JsonValue - readonly additionalContexts?: UserMessage[] - readonly concludesTurn?: never -} - -/** The discriminated, execution-local outcome of one tool call. */ -export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure - -/** - * Pre-dispatch decision. `allow` runs the call; `deny` materializes its - * model-facing reason and optional structured error identity; `cancel` selects - * the canonical cancellation result without presenting a policy denial; `ask` - * runs only after an approval service returns `allowed-once` and otherwise - * denies; its `reason` is the audited approval reason and its optional - * `displayReason` is the localized prompt text. Input rewriting is excluded because arguments are already logged and - * presented. - */ -export type PreToolDecision = - | { kind: 'allow' } - | { kind: 'deny'; reason: string; info?: ToolErrorInfo } - | { kind: 'cancel' } - | { kind: 'ask'; reason?: string; displayReason?: { readonly en: string; readonly [locale: string]: string } } - -/** - * Post-dispatch decision: accept, replace one projection, attach context for the - * next request, or block by turning corrective feedback into an error result. - */ -export type PostToolDecision = - | { kind: 'accept'; content?: ContentBlock[]; value?: never; additionalContexts?: UserMessage[] } - | { kind: 'accept'; value: JsonValue; content?: never; additionalContexts?: UserMessage[] } - | { kind: 'block'; feedback: ContentBlock[]; additionalContexts?: UserMessage[] } - -/** - * Best-effort human-readable message from an arbitrary thrown value: Error - * instances use `.message`; non-Error objects with a string `message` - * property (e.g. `throw { message: 'denied' }`) use it too; everything else - * is stringified. - */ -function errorMessage(error: unknown): string { - try { - if (error instanceof Error) return error.message - if (typeof error === 'object' && error !== null - && 'message' in error && typeof error.message === 'string') { - return error.message - } - return String(error) - } catch { - // A hostile thrown value can trap `instanceof`, property access, or string - // coercion. Error normalization is the outermost safety boundary, so its - // fallback must itself be total. - return '' - } -} - -/** Derive one failure message from policy feedback without changing its rendered blocks. */ -function failureMessageFromContent(content: ContentBlock[]): string { - const text = content - .map(block => block.type === 'text' ? block.text : `[${block.type} content]`) - .join('\n') - return text.length > 0 ? text : 'tool result blocked by post-execute policy' -} - -/** Snapshot and freeze one durable tool-result projection or reject lossy data. */ -function materializePresentation(candidate: T): T { - const detached = snapshotJsonValue(candidate) - if (detached === undefined) { - throw new TypeError('tool result must be losslessly JSON-serializable') - } - return deepFreeze(detached) -} - -/** Structured `{ name, code }` for a thrown HarnessError, else undefined. */ -function errorInfo(error: unknown): ToolErrorInfo | undefined { - try { - return error instanceof HarnessError ? { name: error.name, code: error.code } : undefined - } catch { - return undefined - } -} - -/** How the registry presents its tools to the model (see {@link Config.mode}). */ -export type ToolPresentationMode = 'native' | 'ptc' - -/** Plugin config: how the registered tools are presented to the model. */ -export interface Config { - /** - * Model presentation. `native` (default) sends every visible schema; `ptc` - * sends only `run_code` plus a generated SDK prompt and collapses the - * executor to the same surface (a model-direct call may only name - * `run_code`; `run_code` SDK sub-dispatches keep every visible tool). - * PTC mode requires a `ctx.ptcRuntime` whose `language` - * has a registered SDK renderer (TypeScript or Python) and fails prompt - * assembly when it is absent or has no renderer. Under `ptc`, native names - * in `toolOrder` are invalid. - */ - mode?: ToolPresentationMode - /** - * Concurrency cap for a `run_code` program's overlapping sub-calls - * (default 10, the loop scheduler's own default). Sub-calls follow the - * native scheduling contract — only calls whose tools classify - * concurrency-safe overlap; exclusive calls form barriers — so `1` - * restores strictly serial dispatch. Must be a positive integer. - */ - maxParallelSubCalls?: number -} - -/** - * Per-scope filter over global tools. Restrictions intersect and do not affect - * scoped registrations or the reserved PTC mode transport. - */ -export interface ToolRestriction { - /** Global tool names that stay visible; everything else is removed. */ - readonly allow?: readonly string[] - /** Global tool names removed from visibility. */ - readonly deny?: readonly string[] } /** One restriction compiled at registration for repeated live-global lookup. */ @@ -713,7 +33,7 @@ interface CompiledToolRestriction { /** One scope's complete registry view, derived in a single layer traversal. */ interface ToolView { - /** Visible definitions after restrictions, scoped shadowing, and transport insertion. */ + /** Visible definitions after restrictions, scoped shadowing, and inherited restrictions. */ readonly visible: ReadonlyMap /** Pre-restriction capability names used by prompt-order validation. */ readonly knownNames: ReadonlySet @@ -721,27 +41,10 @@ interface ToolView { readonly restrictableNames: ReadonlySet } -/** - * A monotonic execution guard evaluated after every `tools/pre-execute` - * listener and before the tool body. Returning a reason denies the call; - * returning `undefined` leaves it unchanged. Because guards have no allow - * result, listener ordering cannot turn a denial back into permission. - * @param execution - the identity-protected call after extensible pre-execute policy completed. - * @returns a final denial reason, or `undefined` to leave the call allowed. - */ -export type ToolGuard = (execution: Readonly) => string | undefined - /** One scope's complete tool-registry contribution. */ class ToolLayer implements ScopeLayer { readonly tools: NamedEntries readonly restrictions = new AnonymousEntries() - readonly guards = new AnonymousEntries() - /** - * Presentation this scope's agent declared for itself, shadowing the - * deployment default. One cell rather than an entry table: two answers to - * "which form does the model see" is a contradiction, not a merge. - */ - mode: ToolPresentationMode | undefined constructor(scope: ScopeKey | undefined) { this.tools = new NamedEntries(name => new Error(scope === undefined @@ -751,8 +54,7 @@ class ToolLayer implements ScopeLayer { /** Whether every contribution table in this aggregate layer is empty. */ isEmpty(): boolean { - return this.tools.isEmpty() && this.restrictions.isEmpty() && this.guards.isEmpty() - && this.mode === undefined + return this.tools.isEmpty() && this.restrictions.isEmpty() } /** Whether every compiled restriction in this layer admits a global tool name. */ @@ -764,303 +66,40 @@ class ToolLayer implements ScopeLayer { return true } - /** First monotonic denial from this layer's live guard registrations. */ - guardReason(exec: ToolExecution): string | undefined { - for (const guard of this.guards.values()) { - const reason = guard(exec) - if (reason !== undefined) return reason - } - return undefined - } } -/** Approval decision plus whether the approval channel reported cancellation. */ -interface ToolAskResolution { - readonly decision: Extract - readonly approvalCancelled: boolean +interface Registration { + executor: ToolExecutor | undefined } -/** Caller cancellation and dispatch state kept outside the around-wrapper view. */ -interface ToolCancellationState { - readonly callerSignal: AbortSignal - bodyInvoked: boolean +class GuardLayer { + readonly guards = new AnonymousEntries() + isEmpty(): boolean { return this.guards.isEmpty() } } -/** One dispatch-scoped fused signal plus listener cleanup after the body settles. */ -interface FusedToolSignal { - readonly signal: AbortSignal - dispose(): void -} - -/** Resolve the run_code overlap cap at the owning config boundary (direct construction bypasses the Loader schema). */ -function resolveMaxParallelSubCalls(value: number | undefined): number { - const maxParallelSubCalls = value ?? 10 - if (!Number.isInteger(maxParallelSubCalls) || maxParallelSubCalls < 1) { - throw new Error('maxParallelSubCalls must be a positive integer') - } - return maxParallelSubCalls -} - -/** - * Tool registry and execution pipeline. Scoped registrations shadow globals; - * one visibility resolver feeds presentation, lookup, and dispatch. - */ -export class ToolRuntime extends Service { +/** Concrete tool registry and scoped executor dispatcher. */ +export class ToolsService extends Service { static inject = ['systemPrompt'] - - static Config: z = z.object({ - mode: z.union(['native', 'ptc'] as const).default('native'), - maxParallelSubCalls: z.natural().min(1).default(10), + static Config = z.object({ + mode: z.never().description('Removed. Mount a native or PTC tool executor instead.'), + maxParallelSubCalls: z.never().description('Moved to the PTC tool executor configuration.'), }) - /** Internal staged view consumed by `dsh-agent-loop`'s parallel scheduler. */ - readonly [TOOL_RUNTIME_SCHEDULER]: ToolRuntimeScheduler = { - prepare: exec => this.prepareScheduledExecution(exec), - dispatch: exec => this.dispatchScheduledExecution(exec), - finalize: (exec, result) => this.finalizeScheduledExecution(exec, result), - finish: (exec, result) => this.finishScheduledExecution(exec, result), - } + private readonly layers = new ScopedLayers(scope => new ToolLayer(scope), () => { this.ctx.emit('tools/change') }) + private readonly scoped = new WeakMap() + private root: Registration | undefined + private readonly guards = new ScopedLayers(() => new GuardLayer(), () => {}) - /** Context deferred by a running tool body, keyed by its scheduler-owned execution. */ - private deferredContexts = new WeakMap() - /** Executions whose tool body declared the current turn complete. */ - private concludingExecutions = new WeakSet() - /** Original caller cancellation, kept outside the wrapper-mutable execution object. */ - private cancellationStates = new WeakMap() - /** Definition-owned final content transform snapshotted before policy begins. */ - private contentFinalizers = new WeakMap() - /** Execution-prepared content installed before post-execute policy. */ - private contentProjectors = new WeakMap() - private readonly layers = new ScopedLayers( - scope => new ToolLayer(scope), - () => { this.ctx.emit('tools/change') }, - ) - /** Presentation for scopes that declare none; {@link presentAs} shadows it per scope. */ - private readonly defaultMode: ToolPresentationMode - private readonly maxParallelSubCalls: number - /** - * Reserved presentation transport, kept outside the filterable registration - * layers. Built on first need rather than at construction: which agents run - * a PTC mode is no longer known when the service is constructed, and the - * transport owns its PTC call tracking and state-owner registrations. - */ - private ptcTransport: ToolDefinition | undefined - private readonly ptcContext: Context - - constructor(ctx: Context, config: Config = {}) { + constructor(ctx: Context) { super(ctx, 'tools') - this.ptcContext = ctx - // The schema already defaulted an omitted mode; the ?? narrows the - // optional-input type for direct (non-Loader) construction in tests. - this.defaultMode = config.mode ?? 'native' - this.maxParallelSubCalls = resolveMaxParallelSubCalls(config.maxParallelSubCalls) - ctx.systemPrompt.tools(context => this.wireSchemas(context.scope)) - if (this.defaultMode !== 'native') { - ctx.systemPrompt.section(this.collapseSection()) - ctx.systemPrompt.section(this.sdkSection()) - } + ctx.systemPrompt.tools(({ scope }) => { + const executor = this.selectExecutor(scope) + const view = this.view(scope) + const description = executor.describe([...view.visible.values()]) + return { ...description, knownNames: description.knownNames ?? [...view.knownNames] } + }) } - /** - * The prompt statement of the `ptc` executor collapse, registered wherever - * {@link sdkSection} is and rendering empty outside an effective `ptc`. - * - * Every tool contributes its own guidance section naming its tool, none of - * them qualify how that tool is reached, and they all render before the SDK. - * Without this the model reads a catalog of tools it is told to use and no - * statement that only `run_code` may be called, so it emits a native call, - * receives `UNKNOWN_TOOL` for a tool the prompt just declared, and concludes - * the deployment is inconsistent. Its order places the rule before that - * guidance rather than after it. - * - * Native scopes render no instruction. - * @returns the section registration. - */ - private collapseSection(): PromptSection { - return { - name: 'tools:ptc-only', - order: this.ctx.systemPrompt.getSectionOrder('PTC_ONLY'), - // The SAME predicate the executor denies by, so the prompt cannot state - // a rule the registry does not enforce (see `collapses`). - text: context => this.modeFor(context.scope) === 'ptc' ? PTC_ONLY_INSTRUCTION : '', - } - } - - /** - * The generated-SDK prompt section, registered globally by a PTC mode - * deployment and per scope by {@link presentAs}. - * - * The body regenerates from the CALLING scope, and renders empty for an - * agent presenting natively — an agent that opted out under a PTC mode - * deployment still sees the global registration, and an empty section is - * dropped from the rendered prompt. - * @returns the section registration. - */ - private sdkSection(): PromptSection { - return { - name: 'tools:sdk', - order: this.ctx.systemPrompt.getSectionOrder('TOOLS_SDK'), - interpolate: false, - // Regenerate from the calling scope's visible tools in stable order. - text: (context) => { - const mode = this.modeFor(context.scope) - if (mode === 'native') return '' - const runtime = this.requirePtcRuntime(mode) - // Own-property read: a language like `toString`/`constructor` would - // otherwise resolve an inherited Object.prototype member as a renderer. - const render = SDK_RENDERERS[runtime.language] - /* v8 ignore next -- requirePtcRuntime rejects an unknown language before this runs. */ - if (render === undefined) throw new Error(`dsh-tools: no SDK renderer for ${runtime.language}`) - return render(this.sdkSchemas(context.scope), runtime.programFormat) - }, - } - } - - /** - * The presentation one scope's agent sees: its own declaration, else the - * deployment default. - * @param scope - the calling agent, or undefined for the global view. - * @returns the resolved presentation mode. - */ - private modeFor(scope?: ScopeKey): ToolPresentationMode { - // Nearest scope wins along the chain: a preset's standing declaration - // covers every agent parented under it, and an agent's own (were one ever - // declared) would override its preset's. The mode decides what the model - // SEES, which is exactly the class of fact the chain inherits. - const layers = this.layers.chainLayers(scope) - for (let index = layers.length - 1; index >= 0; index -= 1) { - const mode = layers[index]?.mode - if (mode !== undefined) return mode - } - return this.defaultMode - } - - /** - * The reserved `run_code` transport, built on first need. - * - * It never enters the global layer: per-agent restrictions must not remove - * it, and a scoped registration must not shadow it. The visibility resolver - * appends it after resolving the filterable global/scoped capability layers, - * and only for scopes whose mode actually presents it. - * @returns the shared transport definition. - */ - private requirePtcTransport(): ToolDefinition { - this.ptcTransport ??= createRunCodeTool(this, { - resolveWorkingDirectory: async (exec) => { - // Only Agent-owned PTC requires directory state; native registries - // and unowned programs can run without this service. - if (exec.agent === undefined) return undefined - const directories = this.ctx.get('workingDirectory') - if (directories === undefined) throw new Error('dsh-tools: run_code with an Agent requires workingDirectory') - return directories.ensure(exec.agent, exec.signal) - }, - requireRuntime: () => this.requirePtcRuntime(this.defaultMode), - peekApprover: () => this.ctx.get('approval'), - resolveSandboxPolicy: (exec) => { - const policy = this.ctx.get('sandboxPolicy') - if (policy === undefined) throw new Error('dsh-tools: confined PTC runtime requires sandboxPolicy') - return policy.resolve(exec.agent === undefined ? {} : { session: exec.agent.session }) - }, - // The language-aware description/parameters getters read the runtime - // without demanding one, so a native-default process can still project - // the transport for an agent that chose code. - peekRuntime: () => this.ctx.get('ptcRuntime'), - maxParallel: this.maxParallelSubCalls, - shapeDispatchLog: dispatch => this.shapeDispatchLog(dispatch), - }, this.ptcContext) - return this.ptcTransport - } - - /** - * Present the calling scope's tools in `mode` instead of the deployment - * default. Nearest scope on the chain wins, so a preset's standing - * declaration covers every agent joined under it. - * - * Scoped only, and one declaration per scope: this is how an agent preset - * composes PTC mode agents beside native ones in the same process, and a - * process-global override would be the `mode` config field instead. - * @param mode - the presentation the covered agents' models see. - * @returns the exact disposer that restores the deployment default. - */ - presentAs(mode: ToolPresentationMode): () => void { - const ctx = this.ctx - if (scopeOf(ctx) === undefined) { - throw new Error('tools.presentAs() requires a scoped context (agent.ctx): a context-global presentation is the `mode` config field on the tools row') - } - const dispose = ctx.effect(function* (this: ToolRuntime) { - yield this.layers.effect( - ctx, - (layer) => { - if (layer.mode !== undefined) { - throw new Error(`tools.presentAs("${mode}") conflicts with "${layer.mode}" already declared for this scope; one composition selects one presentation`) - } - layer.mode = mode - return () => { layer.mode = undefined } - }, - { label: 'tools.presentAs()' }, - ) - // The SDK and collapse sections are per scope for the same reason the - // mode is. Under a deployment that already defaults to PTC mode this - // shadows the global registration with an identical body, which costs - // nothing and keeps one rule instead of a case analysis. - if (mode !== 'native') { - yield ctx.systemPrompt.section(this.collapseSection()) - yield ctx.systemPrompt.section(this.sdkSection()) - } - }.bind(this), 'tools.presentAs()') - // oxlint-disable-next-line typescript/no-misused-promises -- synchronous composite teardown - return dispose - } - - /** - * Build one scope's wire schemas and names for prompt-order validation. - * Restrictions do not make known tools invalid, but a mode collapse does. - */ - private wireSchemas(scope?: ScopeKey): ToolProviderResult { - const view = this.view(scope) - const mode = this.modeFor(scope) - if (mode === 'native') { - const schemas = [...view.visible.values()].map(definition => this.schemaOf(definition, false)) - return { schemas, knownNames: [...view.knownNames] } - } - // Validate the runtime language BEFORE projecting schemas: schemaOf reads - // run_code's language-aware description/parameters getters, whose own - // flavor-table guard would otherwise surface first. This keeps the - // renderer-table rejection the canonical assembly-time error for a - // language with no SDK renderer. - this.requirePtcRuntime(mode) - const schemas = [...view.visible.values()].map(definition => this.schemaOf(definition, false)) - return { - schemas: schemas.filter(schema => schema.name === RUN_CODE_NAME), - knownNames: [RUN_CODE_NAME], - } - } - - /** - * Resolve the PTC runtime or throw the actionable misconfiguration error. - * Read at use time (assembly / run_code execution), NOT via static - * `inject`: an inject entry would hold `ctx.tools` — and every tool plugin - * behind it — hostage to a PTC runtime existing even under `mode: - * 'native'`. - * - * Assembly and `run_code` execution read separately, so the language is not - * bound to a request. Harmless while one published backend exists — both - * reads return the same flavor — but a reload that swapped in a second - * language between them would hand a program written against one SDK to the - * other. Binding it is deferred until a second backend ships (the first - * point it is testable). - */ - private requirePtcRuntime(mode: ToolPresentationMode): PtcRuntime { - const runtime = this.ctx.get('ptcRuntime') - if (!runtime) { - throw new Error(`dsh-tools: mode "${mode}" requires a PTC runtime — load a ctx.ptcRuntime implementation (e.g. @deepseek-ai/dsh-ptc-runtime-node) or set tools mode to "native"`) - } - if (!Object.hasOwn(SDK_RENDERERS, runtime.language)) { - const known = Object.keys(SDK_RENDERERS).map(name => JSON.stringify(name)).join(', ') - throw new Error(`dsh-tools: no SDK renderer registered for runtime language ${JSON.stringify(runtime.language)} (known: ${known})`) - } - return runtime - } /** * Register globally or in the calling agent scope. Scoped tools shadow @@ -1132,66 +171,16 @@ export class ToolRuntime extends Service { } /** - * Register a monotonic guard after the extensible `tools/pre-execute` - * waterfall. A plain-context guard applies globally; one registered through - * `agent.ctx` applies only to that agent. Any matching guard may deny by - * returning a reason, while no guard can force-allow a call another guard - * denied. The exact effect disposer is returned for ordered ownership and - * HMR cleanup. - * @param guard - synchronous check; a returned string denies the execution. - * @returns the exact disposer that unregisters the guard. - */ - guard(guard: ToolGuard): () => void { - return this.layers.effect( - this.ctx, - layer => layer.guards.append(guard), - { label: 'tools.guard()', notify: false }, - ) - } - - /** First monotonic denial from the global then the scope chain's guard layers, farthest first. */ - private guardReason(exec: ToolExecution): string | undefined { - const globalReason = this.layers.global.guardReason(exec) - if (globalReason !== undefined) return globalReason - if (exec.agent === undefined) return undefined - for (const layer of this.layers.chainLayers(exec.agent)) { - const reason = layer.guardReason(exec) - if (reason !== undefined) return reason - } - return undefined - } - - /** - * Resolve every registry fact one scope needs in one layer traversal. The - * visible map applies restrictions to the INHERITED surface, then the - * scope's own registrations and the reserved presentation transport; the - * other sets retain the pre-restriction facts needed by restriction and - * prompt-order validation. - * - * A restriction filters what a scope inherits — the global layer and every - * ancestor layer on its chain — and never what its OWN layer registers. - * That exemption is what a per-child capability filter has to keep intact: - * the delegation runtime registers a child's structured-output tool into the - * child's own layer, and a filter naming the capabilities the child may use - * must not strip the machinery it answers through. - * - * Reading the exempt set as "the global layer" instead of "not mine" held - * only while every model-facing tool sat in the host composition. Once - * presets moved them onto the agent plane they became an ANCESTOR - * contribution, so a child's filter silently stopped constraining anything - * it was given. + * Resolve visible tools and pre-restriction names. Restrictions across the + * scope chain intersect over inherited global and ancestor tools; the viewing + * scope's own registrations remain exempt. Nearer registrations shadow + * farther ones. Pre-restriction names support restriction and prompt-order validation. * @param scope - the viewing scope (the agent), or undefined for the global view. * @returns the complete derived view for that scope. */ private view(scope?: ScopeKey): ToolView { - // Scope-chain layers, farthest ancestor first, the exact scope last. const layers = this.layers.chainLayers(scope) - // Chain-blind on purpose: this is the ONE layer whose registrations the - // scope owns rather than inherits, and it is absent until the scope - // contributes something. const own = this.layers.peek(scope) - // Inherited surface, nearest ancestor last: a nearer scope's same-name - // entry shadows a farther one, and the global layer is the farthest. const inherited = new Map(this.layers.global.tools.entries()) for (const layer of layers) { if (layer === own) continue @@ -1203,26 +192,14 @@ export class ToolRuntime extends Service { for (const [name, definition] of inherited) { knownNames.add(name) restrictableNames.add(name) - // Restrictions intersect across the whole chain: any scope on it may - // mask an inherited name for everything nested inside it. if (layers.every(layer => layer.admits(name))) visible.set(name, definition) } - // The scope's own registrations last, shadowing an inherited name and - // outside the filter above. if (own !== undefined) { for (const [name, definition] of own.tools.entries()) { knownNames.add(name) visible.set(name, definition) } } - // Presentation infrastructure is resolved last and outside capability - // filtering. Registration rejects this reserved name, so the insertion is - // an invariant assertion as well as protection against future layer - // changes. Per scope: a native agent must not find `run_code` in its - // dispatch table because some other agent in the process presents it. - if (this.modeFor(scope) !== 'native') { - visible.set(RUN_CODE_NAME, this.requirePtcTransport()) - } return { visible, knownNames, restrictableNames } } @@ -1240,23 +217,12 @@ export class ToolRuntime extends Service { } /** - * Resolve the definition that MAY EXECUTE for a call, applying the mode - * collapse at the operation boundary that owns it. The registry view - * (`get`) is presentation-agnostic; here a MODEL-DIRECT call under `ptc` - * may only name the reserved `run_code` transport, while a nested - * sub-dispatch (a `parent` token set — the `run_code` SDK calling a tool - * it bound) may call any visible tool. Denial surfaces as `UNKNOWN_TOOL` - * through the executor, matching an absent definition. - * @param name - the tool name as registered. - * @param scope - the viewing scope (the agent); omitted = the global view. - * @param nested - whether the call is a transport sub-dispatch, not a model-direct call. - * @returns the definition that may run, or undefined when the call must be rejected. + * Read the visible capability definitions for descriptions and program bindings. + * @param scope - viewing Agent or preset; omitted selects the global view. + * @returns definitions after scoped shadowing and restrictions. */ - private resolveExecution(name: string, scope: ScopeKey | undefined, nested: boolean): ToolDefinition | undefined { - const tool = this.get(name, scope) - if (tool === undefined) return undefined - if (this.collapses(name, scope, nested)) return undefined - return tool + definitions(scope?: ScopeKey): readonly ToolDefinition[] { + return [...this.view(scope).visible.values()] } /** @@ -1266,728 +232,99 @@ export class ToolRuntime extends Service { * @returns one deep-cloned schema per visible tool. */ schemas(scope?: ScopeKey): ToolSchema[] { - return [...this.view(scope).visible.values()].map(definition => this.schemaOf(definition, true)) + return [...this.view(scope).visible.values()].map(definition => projectToolSchema(definition, true)) } - - /** Project visible callable tools onto the generated PTC mode SDK contract. */ - private sdkSchemas(scope?: ScopeKey): ToolSdkSchema[] { - return [...this.view(scope).visible.values()] - .filter(definition => definition.name !== RUN_CODE_NAME) - .map((definition): ToolSdkSchema => { - const output = snapshotJsonValue(definition.output.schema) - /* v8 ignore next -- registration already validated and retained this schema as lossless JSON. */ - if (output === undefined) { - throw new Error(`tool "${definition.name}" output schema must be lossless JSON before SDK projection`) - } - return { - ...this.schemaOf(definition, true), - output, - } - }) - } - - /** Project one definition onto the model-facing schema fields. */ - private schemaOf(definition: ToolDefinition, detachParameters: boolean): ToolSchema { - const { name, description, parameters, deferLoading } = definition - const detached = detachParameters ? snapshotJsonValue(parameters) : parameters - if (detached === undefined) { - throw new Error(`tool "${name}" parameters must be lossless JSON before schema projection`) - } - return { - name, - description, - parameters: detached, - ...deferLoading === true ? { deferLoading } : {}, - } - } - /** - * Classify a pending call through the caller's visible tool definition. Only - * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or - * throwing classifiers are exclusive. - * @param exec - call name, parsed arguments, and optional agent scope. - * @returns the fail-closed scheduling mode. + * Provide the executor for the calling scope; an unscoped contribution is the deployment default. + * Rendering any executor-owned section requires the viewing scope's selection to remain available. + * @param executor - initialized provider owned by the contributing plugin. + * @returns the effect disposer. Unloading leaves the selection unavailable until it is replaced. */ - executionMode(exec: ToolExecutionInput): ToolExecutionMode { - const tool = this.resolveExecution(exec.name, exec.agent, exec.parent !== undefined) - if (!tool?.isConcurrencySafe) return { kind: 'exclusive' } - try { - const concurrencySafe: unknown = tool.isConcurrencySafe(exec.arguments) - return concurrencySafe === true ? { kind: 'parallel' } : { kind: 'exclusive' } - } catch { - return { kind: 'exclusive' } - } - } - - /** - * Run the `tools/ptc-dispatch-log` waterfall over one settled sub-dispatch - * and return the content the bridge should log on `tool/ptc-dispatch`. - * Contained: when a listener throws, the method logs the original settled - * content; that failure must not fail the dispatch or omit the settle event. Private: - * the ONE consumer is the `run_code` bridge this registry constructs, which - * receives it as a capability parameter (the `requireRuntime` idiom) — the - * waterfall, not this invoker, is the public extension point. - */ - private async shapeDispatchLog(dispatch: PtcDispatchLog): Promise { - try { - return await this.ctx.waterfall( - scopeTarget(this, dispatch.agent), 'tools/ptc-dispatch-log', dispatch, - () => Promise.resolve(dispatch.content), - ) - } catch (error: unknown) { - this.ctx.logger.warn(`tools: ptc-dispatch-log listener failed for ${dispatch.name}: ${errorMessage(error)}; logging the original settled content`) - return dispatch.content - } - } - - /** - * Whether the `ptc` mode collapse denies a model-direct call: only the - * reserved `run_code` transport may be named. Nested sub-dispatches (a - * `parent` token set) bypass the collapse. One home for the - * security-relevant predicate, shared by {@link resolveExecution} and - * {@link createExecution} so the two can never drift apart. - * - * Resolved through {@link modeFor}, NOT `defaultMode`: an agent given `ptc` - * by an agent preset under a native deployment is the composition - * `dsh-agent-tool-presentation` exists for, and reading the deployment default would - * leave exactly that agent uncollapsed — announcing one surface while - * executing another, which is the bypass this collapse closes. - * @param name - the tool name as registered. - * @param scope - the viewing scope whose effective presentation mode applies. - * @param nested - whether the call is a transport sub-dispatch, not a model-direct call. - */ - private collapses(name: string, scope: ScopeKey | undefined, nested: boolean): boolean { - return !nested && this.modeFor(scope) === 'ptc' && name !== RUN_CODE_NAME - } - - /** - * Execute through pre-policy, guards, around-dispatch, post-policy, - * definition-owned content finalization, and final notification. Tool and - * listener failures resolve as materialized error results; an invisible tool - * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen - * snapshot final observers receive. Cancellation - * arriving after entry and before final result materialization skips a - * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a - * successful started outcome with `ABORTED`; already-started work is still - * drained and may retain a tool-owned structured error. - * @param exec - the typed same-process call input. The registry assigns its - * correlation token before policy begins. - * @returns the materialized final result. - */ - async execute(exec: ToolExecutionInput): Promise { - return this.prepareExecution(exec, prepared => this.completeScheduledExecution(prepared)) - } - - private async completeScheduledExecution(prepared: ScheduledToolPreparation): Promise { - switch (prepared.kind) { - case 'dispatch': { - const dispatched = await this.dispatchScheduledExecution(prepared.exec) - return dispatched.kind === 'post-result' - ? await this.finalizeScheduledExecution(prepared.exec, dispatched.result) - : this.finishScheduledExecution(prepared.exec, dispatched.result) - } - case 'post-result': - return await this.finalizeScheduledExecution(prepared.exec, prepared.result) - case 'final-result': - return this.finishScheduledExecution(prepared.exec, prepared.result) - /* v8 ignore next -- closed-union exhaustiveness guard */ - default: - return assertNever(prepared, 'scheduled tool preparation') - } - } - - private createExecution(exec: ToolExecutionInput): ScheduledToolPreparation | { kind: 'ready'; exec: MutableToolRunContext } { - const deferredContexts: UserMessage[] = [] - const token = createExecutionToken() - const callId = exec.callId - const rootCallId = exec.rootCallId ?? callId - const name = exec.name - const agent = exec.agent - const parent = exec.parent - const signal = exec.signal - // Distinguish a mode-collapsed call (visible in the scope, denied only by - // the `ptc` collapse) from a genuinely unknown tool. A collapsed call is - // deterministically denied, so it terminates BEFORE the extensible policy - // pipeline: pre-execute listeners, approval `ask`, and guards must never - // observe — or worse, approve — a call that can only fail. An unknown tool - // keeps the historical dispatch-stage `UNKNOWN_TOOL` path so policy - // listeners still see every name that reaches the registry. - const visible = this.get(name, agent) - const collapsed = visible !== undefined && this.collapses(name, agent, parent !== undefined) - const concludingExecutions = this.concludingExecutions - const base = { - token, - callId, - rootCallId, - name, - signal, - ...agent !== undefined ? { agent } : {}, - ...parent !== undefined ? { parent } : {}, - ...exec.schema !== undefined ? { schema: exec.schema } : {}, - deferContext(context: UserMessage): void { - deferredContexts.push(context) - }, - concludeTurn(): void { - concludingExecutions.add(this as unknown as ToolExecution) - }, - } - // Capture the finalizer BEFORE argument materialization: the - // `finalizeContent` contract snapshots the callback when the call starts, - // and an arguments getter can replace or clear the registered callback - // during `snapshotJsonValue`. The collapse only decides whether the - // CAPTURED callback is retained: the pre-dispatch abort path keeps it - // (the cancellation contract routes aborted results through it — a getter - // that aborts mid-materialization before an invalid-args failure lands in - // the same retained path), while the `UNKNOWN_TOOL` denial and the - // invalid-args failure of a NON-ABORTED collapsed call drop it (the call - // could never execute). - const capturedFinalizer = visible?.finalizeContent?.bind(visible) - const capturedProjector = visible?.projectContent?.bind(visible) - const finalizerFor = (): ToolDefinition['finalizeContent'] | undefined => - collapsed && !signal.aborted ? undefined : capturedFinalizer - try { - const detached = snapshotJsonValue(exec.arguments) - if (detached === undefined) { - throw new TypeError('tool execution arguments must be losslessly JSON-serializable') - } - const execution: MutableToolRunContext = { ...base, arguments: deepFreeze(detached) } - this.deferredContexts.set(execution, deferredContexts) - this.contentFinalizers.set(execution, finalizerFor()) - if (!collapsed) this.contentProjectors.set(execution, capturedProjector) - this.cancellationStates.set(execution, { - callerSignal: signal, - bodyInvoked: false, - }) - if (collapsed) { - // The collapse denies the call before the policy pipeline, but a - // pre-dispatch abort still keeps the established cancellation - // contract: `prepare`'s caller-cancellation check is skipped for - // final-results, so honor the abort here instead of surfacing - // `UNKNOWN_TOOL` on an already-cancelled call. - if (signal.aborted) { - return { kind: 'final-result', exec: execution, result: toolAbortedBeforeDispatchResult() } - } - // The name IS visible here, so the denial carries the route the model - // must take instead. Without it the model reads a bare `unknown tool` - // for a tool the prompt just declared and concludes the deployment is - // broken rather than correcting itself. - return { - kind: 'final-result', - exec: execution, - result: toolErrorResult(new ToolNotFoundError( - name, - `only \`${RUN_CODE_NAME}\` is callable directly — call \`${name}\` from inside a \`${RUN_CODE_NAME}\` program instead`, - )), - } - } - return { kind: 'ready', exec: execution } - } catch (error: unknown) { - const execution: MutableToolRunContext = { ...base, arguments: undefined } - this.contentFinalizers.set(execution, finalizerFor()) - return { kind: 'final-result', exec: execution, result: toolErrorResult(error) } - } - } - - /** - * Run the ordered pre-execute and monotonic guard stages for the scheduler. - * @param input - the caller-supplied execution input. - * @returns the prepared execution plus the next scheduler stage. - * @internal - */ - private async prepareScheduledExecution(input: ToolExecutionInput): Promise { - return this.prepareExecution(input, prepared => prepared) - } - - private async prepareExecution( - input: ToolExecutionInput, - next: (prepared: ScheduledToolPreparation) => T | PromiseLike, - ): Promise { - const created = this.createExecution(input) - if (created.kind !== 'ready') return next(created) - const exec = created.exec - if (this.callerCancelled(exec)) { - return next({ kind: 'final-result', exec, result: toolAbortedBeforeDispatchResult() }) - } - try { - const carrier = scopeTarget(this, exec.agent) - const gate = await this.ctx.waterfall( - carrier, 'tools/pre-execute', exec, - () => Promise.resolve({ kind: 'allow' }), - ) - const askResolution = gate.kind === 'ask' - ? await this.serviceAsk(exec, gate) - : { decision: gate, approvalCancelled: false } - const { decision } = askResolution - if (this.callerCancelled(exec) && askResolution.approvalCancelled) { - return await next({ kind: 'post-result', exec, result: toolAbortedBeforeDispatchResult() }) - } - if (decision.kind === 'cancel') { - return await next({ kind: 'post-result', exec, result: toolAbortedBeforeDispatchResult() }) - } - const denialReason = decision.kind === 'allow' ? this.guardReason(exec) : decision.reason - const denialInfo = decision.kind === 'deny' ? decision.info : undefined - if (denialReason !== undefined) { - return await next({ - kind: 'post-result', - exec, - result: this.materializeFinalResult({ - content: [{ type: 'text', text: `Error: ${denialReason}` }], - isError: true, - error: { message: denialReason, ...denialInfo === undefined ? {} : { info: denialInfo } }, - }), + setExecutor(executor: ToolExecutor): () => void { + const scope = scopeOf(this.ctx) + const existing = scope === undefined ? this.root : this.scoped.get(scope) + if (existing?.executor !== undefined) throw new Error('Only one tool executor may be registered in each scope') + const record: Registration = { executor } + // oxlint-disable-next-line typescript/no-misused-promises -- synchronous cleanup; preserve the Cordis disposer identity + return this.ctx.effect(function* (this: ToolsService) { + for (const section of executor.sections ?? []) { + yield this.ctx.systemPrompt.section({ + name: section.name, + order: section.order, + ...section.interpolate === undefined ? {} : { interpolate: section.interpolate }, + text: ({ scope }) => { + if (this.selectExecutor(scope) !== executor) return '' + return typeof section.text === 'function' ? section.text(this.definitions(scope)) : section.text + }, }) } - if (this.callerCancelled(exec)) { - return await next({ kind: 'post-result', exec, result: toolAbortedBeforeDispatchResult() }) + if (scope === undefined) this.root = record + else this.scoped.set(scope, record) + yield () => { + // Keep an unavailable selection so a program cannot fall through to a different executor. + record.executor = undefined } - return await next({ kind: 'dispatch', exec }) - } catch (error: unknown) { - return next({ kind: 'final-result', exec, result: toolErrorResult(error) }) - } - } - - /** Whether the original caller signal is currently aborted. */ - private callerCancelled(exec: ToolRunContext): boolean { - const state = this.cancellationStates.get(exec) - /* v8 ignore next -- only registry-minted executions reach the staged scheduler methods */ - if (state === undefined) throw new Error('tool registry scheduler invariant violated: missing cancellation state') - return state.callerSignal.aborted - } - - /** Canonical cancellation outcome selected by whether the tool body started. */ - private cancellationResult(exec: ToolRunContext, prior?: ToolExecutionResult): ToolExecutionResult { - const state = this.cancellationStates.get(exec) - /* v8 ignore next -- only registry-minted executions reach the staged scheduler methods */ - if (state === undefined) throw new Error('tool registry scheduler invariant violated: missing cancellation state') - return state.bodyInvoked - ? toolAbortedResult(prior) - : toolAbortedBeforeDispatchResult(prior) + }.bind(this), 'tools.setExecutor()') } /** - * Dispatch the registered body with the original caller signal fused back - * into any around-wrapper replacement. Cancellation never abandons the body: - * a started promise reaches quiescence before its outcome becomes `ABORTED`. + * Resolve the nearest scoped provider, or the deployment default. + * @param scope - Agent or preset scope; omitted selects only the root contribution. + * @returns the selected executor; throws when it is missing or unloaded. */ - private async dispatchToolBody(exec: MutableToolRunContext): Promise { - const state = this.cancellationStates.get(exec) - /* v8 ignore next -- only registry-minted executions reach the staged scheduler methods */ - if (state === undefined) throw new Error('tool registry scheduler invariant violated: missing cancellation state') - const wrapperSignal = exec.signal - const fused = fuseToolSignals(state.callerSignal, wrapperSignal) - const signal = fused.signal - - if (isAborted(signal)) { - fused.dispose() - return toolAbortedBeforeDispatchResult() - } - exec.signal = signal - try { - const tool = this.resolveExecution(exec.name, exec.agent, exec.parent !== undefined) - if (!tool) throw new ToolNotFoundError(exec.name) - state.bodyInvoked = true - const returned = await tool.execute(exec.arguments, exec) - const result = this.createSuccessResult(exec, tool, returned) - return isAborted(signal) - ? toolAbortedResult(result) - : result - } catch (error: unknown) { - return toolErrorResult(error) - } finally { - fused.dispose() - exec.signal = wrapperSignal + private selectExecutor(scope?: ScopeKey): ToolExecutor { + let record: Registration | undefined + for (const key of scopeChainOf(scope)) { + record = this.scoped.get(key) + if (record !== undefined) break } + record ??= this.root + const executor = record?.executor + if (executor === undefined) throw new ToolExecutorUnavailableError() + return executor } /** - * Run around-dispatch and the tool body. Tool and unknown-tool failures still - * receive post-execute; pipeline failures are already final. - * @param exec - the prepared execution. - * @returns whether the result still needs post-execute. - * @internal + * Delegate a raw call batch to its Agent's provider or the deployment default. + * Select at dispatch; accepted batches settle through that provider even if it unloads. + * @param input - original calls, optional owning Agent, and optional logging step. + * @returns the selected executor's settled batch; unavailable selections reject. */ - private async dispatchScheduledExecution(exec: ToolRunContext): Promise { - try { - const mutableExec = exec as MutableToolRunContext - const carrier = scopeTarget(this, exec.agent) - const result = await this.ctx.waterfall( - carrier, 'tools/execute', mutableExec, - () => this.dispatchToolBody(mutableExec), - ) - const normalized = this.normalizeDispatchResult(exec, result) - const deferredContexts = this.deferredContexts.get(exec) - /* v8 ignore next -- dispatch only receives executions minted by this registry's prepare stage */ - if (deferredContexts === undefined) throw new Error('tool registry scheduler invariant violated: unprepared execution') - const resultWithDeferredContexts: ToolExecutionResult = deferredContexts.length === 0 - ? normalized - : this.markCanonical(exec, { - ...normalized, - additionalContexts: [ - ...deferredContexts, - ...normalized.additionalContexts ?? [], - ], - }) - return { - kind: 'post-result', - result: this.callerCancelled(exec) && !resultWithDeferredContexts.isError - ? this.cancellationResult(exec, resultWithDeferredContexts) - : resultWithDeferredContexts, - } - } catch (error: unknown) { - return { kind: 'final-result', result: toolErrorResult(error) } - } + async execute(input: ToolBatchInput): Promise { + return this.selectExecutor(input.agent).execute(input) } /** - * Run ordered post-execute, then apply definition-owned content finalization, - * materialize, and notify the final outcome. - * @param exec - the prepared execution. - * @param result - dispatch/pre result that still needs post-execute. - * @returns the materialized final result. - * @internal + * Register a monotonic denial in the calling scope. + * @param guard - evaluated after extensible pre-execute policy by the shared pipeline. + * @returns the effect disposer that removes this guard. */ - private async finalizeScheduledExecution(exec: ToolRunContext, result: ToolExecutionResult): Promise { - try { - const project = this.contentProjectors.get(exec) - this.contentProjectors.delete(exec) - const content = project?.(exec, result) - const projected = content === undefined - ? result - : this.markCanonical(exec, this.materializeFinalResult({ ...result, content })) - const postResult = await this.postExecute(exec, projected) - return this.finishScheduledExecution( - exec, - this.callerCancelled(exec) && !postResult.isError - ? this.cancellationResult(exec, postResult) - : postResult, - ) - } catch (error: unknown) { - return this.finishScheduledExecution(exec, toolErrorResult(error)) - } + guard(guard: ToolGuard): () => void { + return this.guards.effect(this.ctx, layer => layer.guards.append(guard), { label: 'tools.guard()' }) } /** - * Materialize the candidate, apply definition-owned content finalization, - * then materialize and notify the authoritative result. - * @param exec - the prepared execution. - * @param result - final result. - * @returns the materialized final result. - * @internal + * Check global and inherited guards after pre-execute policy and approval resolve. + * @param exec - invocation whose guards are checked before dispatch. + * @returns the first denial reason, or undefined when no guard denies. */ - private finishScheduledExecution(exec: ToolRunContext, result: ToolExecutionResult): ToolExecutionResult { - let materializedResult: ToolExecutionResult - try { - materializedResult = this.materializeFinalResult(result) - } catch (error: unknown) { - materializedResult = this.materializeFinalResult(toolErrorResult(error)) - } - let finalResult: ToolExecutionResult - try { - finalResult = this.materializeFinalResult(this.applyFinalContent(exec, materializedResult)) - } catch (error: unknown) { - finalResult = this.materializeFinalResult(toolErrorResult(error)) - } - this.notifyResult(exec, finalResult) - return finalResult - } - - /** Apply the snapshotted tool-owned content transform without exposing other result fields. */ - private applyFinalContent(exec: ToolRunContext, result: ToolExecutionResult): ToolExecutionResult { - const finalizeContent = this.contentFinalizers.get(exec) - if (finalizeContent === undefined) return result - const content = finalizeContent(exec, result) - return content === undefined ? result : { ...result, content } - } - - /** Notify observers without exposing a mutation or error channel into the outcome. */ - private notifyResult(exec: ToolExecution, result: ToolExecutionResult): void { - // Freeze the registry's live object before observers receive its readonly - // WeakMap-keyable view. - Object.freeze(exec) - const { name: toolName, callId } = exec - const reportFailure = (error: unknown): void => { - this.ctx.logger.warn(`tool "${toolName}" (${callId}): tools/result observer failed: ${errorMessage(error)}`) - } - const callbacks = this.ctx.events.dispatch('emit', [ - scopeTarget(this, exec.agent), 'tools/result', exec, result, - ]) - for (const callback of callbacks) { - try { - const returned: unknown = callback(exec, result) - void Promise.resolve(returned).catch(reportFailure) - } catch (error: unknown) { - reportFailure(error) + checkGuards(exec: ToolExecution): string | undefined { + for (const layer of [this.guards.global, ...this.guards.chainLayers(exec.agent)]) { + for (const guard of layer.guards.values()) { + const reason = guard(exec) + if (reason !== undefined) return reason } } + return undefined } - /** - * Resolve an `ask` decision to allow/deny through the approval seam. The - * seam is consumed opportunistically with `ctx.get('approval')` — a - * deployment that composes no ApprovalService keeps the historical degrade - * to deny, and an unmount mid-session degrades the same way on the next ask. - * An agent-less execution also degrades: without an agent there is no - * session to audit to and no UI to route to. Otherwise the outcome maps - * one-to-one — `allowed-once` proceeds; the three non-grants deny with - * distinct reasons so the model can tell a human "no" from an absent - * approval channel. - */ - private async serviceAsk( - exec: ToolExecution, - ask: Extract, - ): Promise { - const approval = this.ctx.get('approval') - if (approval === undefined) { - return { - decision: { kind: 'deny', reason: ask.reason ?? `tool "${exec.name}" requires approval (not yet supported)` }, - approvalCancelled: false, - } - } - if (exec.agent === undefined) { - return { - decision: { kind: 'deny', reason: `tool "${exec.name}" requires approval, but the call has no agent to route it through` }, - approvalCancelled: false, - } - } - const outcome = await approval.request({ - agent: exec.agent, - toolName: exec.name, - callId: exec.callId, - ...ask.reason !== undefined ? { reason: ask.reason } : {}, - ...ask.displayReason !== undefined ? { displayReason: ask.displayReason } : {}, - signal: exec.signal, - }) - switch (outcome) { - case 'allowed-once': return { decision: { kind: 'allow' }, approvalCancelled: false } - case 'rejected': return { - decision: { kind: 'deny', reason: `the user rejected tool "${exec.name}"` }, - approvalCancelled: false, - } - case 'cancelled': return { - decision: { kind: 'deny', reason: `approval for tool "${exec.name}" was cancelled` }, - approvalCancelled: true, - } - case 'unavailable': return { - decision: { kind: 'deny', reason: `tool "${exec.name}" requires approval, but no approval channel is available` }, - approvalCancelled: false, - } - default: return assertNever(outcome, 'ApprovalOutcome') - } - } - - /** - * Run the `tools/post-execute` waterfall over a dispatched `result` and apply - * its {@link PostToolDecision}: `accept` keeps the call successful (replacing - * `content` when given), `block` turns it into an `isError` whose content is - * the corrective `feedback`. Either decision may attach `additionalContexts`, - * which are ferried on the returned result for the loop's active-batch FIFO. - * Context deferred by the tool body survives an accepted result but is - * discarded when the outer call is blocked; a block exposes only context the - * blocking decision explicitly supplied. - * Runs inside `execute`'s outer try/catch (a throwing listener → isError). - */ - private async postExecute(exec: ToolExecution, result: ToolExecutionResult): Promise { - const decision = await this.ctx.waterfall( - scopeTarget(this, exec.agent), 'tools/post-execute', exec, result, - () => Promise.resolve({ kind: 'accept' }), - ) - const decisionContexts = decision.additionalContexts ?? [] - if (decision.kind === 'block') { - const message = failureMessageFromContent(decision.feedback) - return this.markCanonical(exec, { - content: decision.feedback, - isError: true, - error: { message }, - ...decisionContexts.length > 0 ? { additionalContexts: decisionContexts } : {}, - }) - } - if (Object.hasOwn(decision, 'content') && Object.hasOwn(decision, 'value')) { - throw new TypeError('tools/post-execute accept decision cannot replace both value and content') - } - const additionalContexts = [ - ...result.additionalContexts ?? [], - ...decisionContexts, - ] - if (Object.hasOwn(decision, 'value')) { - if (result.isError) { - throw new TypeError('tools/post-execute cannot replace the value of a failed result') - } - const tool = this.resolveExecution(exec.name, exec.agent, exec.parent !== undefined) - if (tool === undefined) throw new ToolNotFoundError(exec.name) - const replaced = this.createSuccessResult(exec, tool, decision.value) - return this.markCanonical(exec, { - ...replaced, - ...additionalContexts.length > 0 ? { additionalContexts } : {}, - }) - } - return this.markCanonical(exec, { - ...result, - ...decision.content !== undefined ? { content: decision.content } : {}, - ...additionalContexts.length > 0 ? { additionalContexts } : {}, - }) - } - - /** Registry-normalized results and the exact dispatch that validated each value. */ - private readonly canonicalResults = new WeakMap() - - /** Mark one registry-normalized result as canonical only for its owning dispatch. */ - private markCanonical(exec: ToolExecution, result: T): T { - this.canonicalResults.set(result, exec.token) - return result - } - - /** Snapshot, validate, render, and optionally project one successful body value. */ - private createSuccessResult(exec: ToolExecution, tool: ToolDefinition, candidate: unknown): ToolExecutionSuccess { - const detached = snapshotToolValue(tool.name, candidate) - const violations = validateJsonSchemaValue(tool.output.schema, detached, 'value') - if (violations.length > 0) throw new ToolOutputError(tool.name, violations) - const value = deepFreeze(detached) - let rendered: ContentBlock[] - try { - rendered = tool.output.render(exec.arguments, value) - } catch (error: unknown) { - throw projectionError(tool.name, 'render', error) - } - const content = snapshotProjection(tool.name, 'render', rendered) - let meta: JsonValue | undefined - if (tool.output.presentationMeta !== undefined) { - let projected: JsonValue - try { - projected = tool.output.presentationMeta(exec.arguments, value) - } catch (error: unknown) { - throw projectionError(tool.name, 'presentationMeta', error) - } - meta = snapshotProjection(tool.name, 'presentationMeta', projected) - } - const concludesTurn = this.concludingExecutions.has(exec) - return this.markCanonical(exec, this.materializeFinalResult({ - isError: false, - value, - content, - ...meta !== undefined ? { meta } : {}, - ...concludesTurn ? { concludesTurn: true as const } : {}, - }) as ToolExecutionSuccess) - } - - /** Normalize an around-dispatch wrapper's authored result through the owning output contract. */ - private normalizeDispatchResult(exec: ToolExecution, result: ToolExecutionResult): ToolExecutionResult { - if (this.canonicalResults.get(result) === exec.token) return result - if (result.isError) { - return this.markCanonical(exec, { - isError: true, - error: result.error, - content: result.content, - ...result.meta !== undefined ? { meta: result.meta } : {}, - ...result.additionalContexts !== undefined ? { additionalContexts: result.additionalContexts } : {}, - }) - } - const tool = this.resolveExecution(exec.name, exec.agent, exec.parent !== undefined) - if (tool === undefined) throw new ToolNotFoundError(exec.name) - const normalized = this.createSuccessResult(exec, tool, result.value) - return this.markCanonical(exec, { - ...normalized, - ...result.additionalContexts !== undefined ? { additionalContexts: result.additionalContexts } : {}, - }) - } - - /** Materialize the authoritative commit outcome once, immediately before `tools/result`. */ - private materializeFinalResult(result: ToolExecutionResult): ToolExecutionResult { - const presentation = { - content: result.content, - ...result.meta !== undefined ? { meta: result.meta } : {}, - ...result.additionalContexts !== undefined ? { additionalContexts: result.additionalContexts } : {}, - } - if (result.isError) { - return materializePresentation({ isError: true as const, error: result.error, ...presentation }) - } - const detached = materializePresentation({ - isError: false as const, - ...presentation, - ...result.concludesTurn === true ? { concludesTurn: true as const } : {}, - }) - return deepFreeze({ ...detached, value: result.value }) - } } -/** Mint a same-process correlation token whose identity is its value. */ -function createExecutionToken(): ToolExecutionToken { - return Symbol('dsh.tool.execution') as ToolExecutionToken -} +export default ToolsService -function toolErrorResult(error: unknown): ToolExecutionResult { - const info = errorInfo(error) - const message = errorMessage(error) - return { - content: [{ type: 'text', text: `Error: ${message}` }], - isError: true, - error: { message, ...info ? { info } : {} }, - } -} - -/** Read live abort state across an await without treating it as synchronously immutable. */ -function isAborted(signal: AbortSignal): boolean { - return signal.aborted -} - -/** - * Fuse caller and wrapper cancellation without nesting `AbortSignal.any`. - * Keeping the relay dispatch-scoped also removes listeners when work settles. - */ -function fuseToolSignals(caller: AbortSignal, wrapper: AbortSignal): FusedToolSignal { - if (caller === wrapper) return { signal: caller, dispose() {} } - - const controller = new AbortController() - let listening = false - const dispose = (): void => { - if (!listening) return - listening = false - caller.removeEventListener('abort', abortFromCaller) - wrapper.removeEventListener('abort', abortFromWrapper) - } - const abortFrom = (source: AbortSignal): void => { - const reason: unknown = source.reason - controller.abort(reason) - dispose() - } - const abortFromCaller = (): void => { abortFrom(caller) } - const abortFromWrapper = (): void => { abortFrom(wrapper) } - - if (wrapper.aborted) abortFromWrapper() - else if (caller.aborted) abortFromCaller() - else { - listening = true - caller.addEventListener('abort', abortFromCaller, { once: true }) - wrapper.addEventListener('abort', abortFromWrapper, { once: true }) - } - return { signal: controller.signal, dispose } -} - -/** Canonical result when cancellation supersedes success after body invocation. */ -function toolAbortedResult(prior?: ToolExecutionResult): ToolExecutionResult { - const additionalContexts = prior?.additionalContexts ?? [] - return { - content: [{ type: 'text', text: 'Error: tool call aborted' }], - isError: true, - error: { - message: 'tool call aborted', - info: { name: 'AbortError', code: TOOL_ABORTED }, - }, - ...additionalContexts.length > 0 ? { additionalContexts } : {}, - } -} - -/** Canonical result when cancellation prevents tool body invocation. */ -function toolAbortedBeforeDispatchResult(prior?: ToolExecutionResult): ToolExecutionResult { - const additionalContexts = prior?.additionalContexts ?? [] - return { - content: [{ type: 'text', text: 'Error: tool call aborted before dispatch' }], - isError: true, - error: { - message: 'tool call aborted before dispatch', - info: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH }, - }, - ...additionalContexts.length > 0 ? { additionalContexts } : {}, - } -} - -export default ToolRuntime +export * from './contracts.ts' +export * from './executor.ts' +export * from './schema.ts' +export * from './json-schema.ts' +export * from './presentation.ts' +export { ToolsService as ToolRuntime } diff --git a/packages/core/tools/src/pipeline.ts b/packages/core/tools/src/pipeline.ts new file mode 100644 index 0000000000..db5f0321d2 --- /dev/null +++ b/packages/core/tools/src/pipeline.ts @@ -0,0 +1,763 @@ +/** Shared tool invocation policy, cancellation, and result settlement. @module */ +import type { Context } from '@deepseek-ai/cordis' +import type { ScopeKey } from '@deepseek-ai/dsh-scope' +import { scopeTarget } from '@deepseek-ai/dsh-scope' +import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import { HarnessError } from '@deepseek-ai/dsh-llm' +import type { UserMessage } from '@deepseek-ai/dsh-session' +import type {} from '@deepseek-ai/dsh-user-approval' +import { assertNever, deepFreeze, snapshotJsonValue, type JsonValue } from '@deepseek-ai/dsh-util-values' +import { TOOL_ABORTED, TOOL_ABORTED_BEFORE_DISPATCH, ToolNotFoundError, ToolOutputError } from './contracts.ts' +import { validateJsonSchemaValue } from './json-schema.ts' +import type { ToolsService } from './index.ts' +import type { ToolDefinition, ToolExecutionInput, ToolExecutionMode, ToolExecution, ToolRunContext, ToolExecutionResult, ToolExecutionSuccess, ToolErrorInfo, ToolExecutionToken, PreToolDecision, PostToolDecision } from './contracts.ts' + +/** Executor-owned lookup and restrictions applied before extensible execution policy. */ +export interface ToolExecutionSource { + /** Override registry lookup for executor-owned capabilities; omitted uses the retained registry. */ + get?(name: string, scope?: ScopeKey): ToolDefinition | undefined + /** Reject an otherwise visible invocation before policy; omitted permits it. */ + reject?(name: string, nested: boolean): Error | undefined +} + +/** Prepared invocation; immediate results carry their post-execute requirement. */ +export type ScheduledToolPreparation = + | { kind: 'dispatch'; exec: ToolRunContext } + | ({ exec: ToolRunContext } & ScheduledToolDispatch) + +/** A `post-result` receives post-execute; a `final-result` bypasses it. */ +export type ScheduledToolDispatch = + | { kind: 'post-result'; result: ToolExecutionResult } + | { kind: 'final-result'; result: ToolExecutionResult } + +/** Registry-owned live execution object; public pipeline views stay readonly. */ +type MutableToolRunContext = Omit & { signal: AbortSignal } + +/** Approval decision plus whether the approval channel reported cancellation. */ +interface ToolAskResolution { + readonly decision: Extract + readonly approvalCancelled: boolean +} + +/** Caller cancellation and dispatch state kept outside the around-wrapper view. */ +interface ToolCancellationState { + readonly callerSignal: AbortSignal + bodyInvoked: boolean +} + +/** One dispatch-scoped fused signal plus listener cleanup after the body settles. */ +interface FusedToolSignal { + readonly signal: AbortSignal + dispose(): void +} + +/** + * Shared invocation stages for batch and program schedulers. Schedulers order + * prepare and complete calls while dispatch may overlap. Use executeToolBatch + * for complete calls, including a single-call batch. + */ +export class ToolExecutionPipeline { + private readonly tools: ToolsService + + constructor(private readonly ctx: Context, private readonly source: ToolExecutionSource = {}) { + // Accepted calls retain their event subject while the service is withdrawn during shutdown. + this.tools = ctx.tools + } + + private get(name: string, scope?: ScopeKey): ToolDefinition | undefined { + return this.source.get === undefined ? this.tools.get(name, scope) : this.source.get(name, scope) + } + + private resolveExecution(name: string, scope: ScopeKey | undefined, nested: boolean): ToolDefinition | undefined { + const tool = this.get(name, scope) + return tool !== undefined && this.source.reject?.(name, nested) === undefined ? tool : undefined + } + + /** Context deferred by a running tool body, keyed by its scheduler-owned execution. */ + private deferredContexts = new WeakMap() + + /** Executions whose tool body declared the current turn complete. */ + private concludingExecutions = new WeakSet() + + /** Original caller cancellation, kept outside the wrapper-mutable execution object. */ + private cancellationStates = new WeakMap() + + /** Definition-owned final content transform snapshotted before policy begins. */ + private contentFinalizers = new WeakMap() + + /** Execution-prepared content installed before post-execute policy. */ + private contentProjectors = new WeakMap() + + /** + * Classify a pending call through the caller's visible tool definition. Only + * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or + * throwing classifiers are exclusive. + * @param exec - call name, parsed arguments, and optional agent scope. + * @returns the fail-closed scheduling mode. + */ + executionMode(exec: ToolExecutionInput): ToolExecutionMode { + const tool = this.resolveExecution(exec.name, exec.agent, exec.parent !== undefined) + if (!tool?.isConcurrencySafe) return { kind: 'exclusive' } + try { + const concurrencySafe: unknown = tool.isConcurrencySafe(exec.arguments) + return concurrencySafe === true ? { kind: 'parallel' } : { kind: 'exclusive' } + } catch { + return { kind: 'exclusive' } + } + } + + private createExecution(exec: ToolExecutionInput): ScheduledToolPreparation | { kind: 'ready'; exec: MutableToolRunContext } { + const deferredContexts: UserMessage[] = [] + const token = createExecutionToken() + const callId = exec.callId + const rootCallId = exec.rootCallId ?? callId + const name = exec.name + const agent = exec.agent + const parent = exec.parent + const signal = exec.signal + // Executor refusals precede extensible policy; unknown tools retain dispatch-stage handling. + const visible = this.get(name, agent) + const rejection = visible === undefined ? undefined : this.source.reject?.(name, parent !== undefined) + const rejected = rejection !== undefined + const concludingExecutions = this.concludingExecutions + const base = { + token, + callId, + rootCallId, + name, + signal, + ...agent !== undefined ? { agent } : {}, + ...parent !== undefined ? { parent } : {}, + ...exec.schema !== undefined ? { schema: exec.schema } : {}, + deferContext(context: UserMessage): void { + deferredContexts.push(context) + }, + concludeTurn(this: ToolExecution): void { + concludingExecutions.add(this) + }, + } + // Capture callbacks before argument getters run; rejected calls retain finalization only when aborted. + const capturedFinalizer = visible?.finalizeContent?.bind(visible) + const capturedProjector = visible?.projectContent?.bind(visible) + const finalizerFor = (): ToolDefinition['finalizeContent'] | undefined => + rejected && !signal.aborted ? undefined : capturedFinalizer + try { + const detached = snapshotJsonValue(exec.arguments) + if (detached === undefined) { + throw new TypeError('tool execution arguments must be losslessly JSON-serializable') + } + const execution: MutableToolRunContext = { ...base, arguments: deepFreeze(detached) } + this.deferredContexts.set(execution, deferredContexts) + this.contentFinalizers.set(execution, finalizerFor()) + if (!rejected) this.contentProjectors.set(execution, capturedProjector) + this.cancellationStates.set(execution, { + callerSignal: signal, + bodyInvoked: false, + }) + if (rejected) { + // Cancellation takes precedence over an executor refusal. + if (signal.aborted) { + return { kind: 'final-result', exec: execution, result: toolAbortedBeforeDispatchResult() } + } + return { + kind: 'final-result', + exec: execution, + result: toolErrorResult(rejection), + } + } + return { kind: 'ready', exec: execution } + } catch (error: unknown) { + const execution: MutableToolRunContext = { ...base, arguments: undefined } + this.contentFinalizers.set(execution, finalizerFor()) + return { kind: 'final-result', exec: execution, result: toolErrorResult(error) } + } + } + + /** + * Snapshot input, assign its correlation token, and run ordered pre-execute + * policy and guards. Cancellation skips a body that has not started. + * @param input - the caller-supplied execution input. + * @returns the prepared execution plus the next scheduler stage. + */ + async prepare(input: ToolExecutionInput): Promise { + const created = this.createExecution(input) + if (created.kind !== 'ready') return created + const exec = created.exec + if (this.callerCancelled(exec)) { + return { kind: 'final-result', exec, result: toolAbortedBeforeDispatchResult() } + } + try { + const carrier = scopeTarget(this.tools, exec.agent) + const gate = await this.ctx.waterfall( + carrier, 'tools/pre-execute', exec, + () => Promise.resolve({ kind: 'allow' }), + ) + const askResolution = gate.kind === 'ask' + ? await this.serviceAsk(exec, gate) + : { decision: gate, approvalCancelled: false } + const { decision } = askResolution + if (this.callerCancelled(exec) && askResolution.approvalCancelled) { + return { kind: 'post-result', exec, result: toolAbortedBeforeDispatchResult() } + } + if (decision.kind === 'cancel') { + return { kind: 'post-result', exec, result: toolAbortedBeforeDispatchResult() } + } + const denialReason = decision.kind === 'allow' ? this.tools.checkGuards(exec) : decision.reason + const denialInfo = decision.kind === 'deny' ? decision.info : undefined + if (denialReason !== undefined) { + return { + kind: 'post-result', + exec, + result: this.materializeFinalResult({ + content: [{ type: 'text', text: `Error: ${denialReason}` }], + isError: true, + error: { message: denialReason, ...denialInfo === undefined ? {} : { info: denialInfo } }, + }), + } + } + if (this.callerCancelled(exec)) { + return { kind: 'post-result', exec, result: toolAbortedBeforeDispatchResult() } + } + return { kind: 'dispatch', exec } + } catch (error: unknown) { + return { kind: 'final-result', exec, result: toolErrorResult(error) } + } + } + + /** Whether the original caller signal is currently aborted. */ + private callerCancelled(exec: ToolRunContext): boolean { + const state = this.cancellationStates.get(exec) + /* v8 ignore next -- only registry-minted executions reach the staged scheduler methods */ + if (state === undefined) throw new Error('tool registry scheduler invariant violated: missing cancellation state') + return state.callerSignal.aborted + } + + /** Canonical cancellation outcome selected by whether the tool body started. */ + private cancellationResult(exec: ToolRunContext, prior?: ToolExecutionResult): ToolExecutionResult { + const state = this.cancellationStates.get(exec) + /* v8 ignore next -- only registry-minted executions reach the staged scheduler methods */ + if (state === undefined) throw new Error('tool registry scheduler invariant violated: missing cancellation state') + return state.bodyInvoked + ? toolAbortedResult(prior) + : toolAbortedBeforeDispatchResult(prior) + } + + /** + * Dispatch the registered body with the original caller signal fused back + * into any around-wrapper replacement. Cancellation never abandons the body: + * a started promise reaches quiescence before its outcome becomes `ABORTED`. + */ + private async dispatchToolBody(exec: MutableToolRunContext): Promise { + const state = this.cancellationStates.get(exec) + /* v8 ignore next -- only registry-minted executions reach the staged scheduler methods */ + if (state === undefined) throw new Error('tool registry scheduler invariant violated: missing cancellation state') + const wrapperSignal = exec.signal + const fused = fuseToolSignals(state.callerSignal, wrapperSignal) + const signal = fused.signal + + if (isAborted(signal)) { + fused.dispose() + return toolAbortedBeforeDispatchResult() + } + exec.signal = signal + try { + const tool = this.resolveExecution(exec.name, exec.agent, exec.parent !== undefined) + if (!tool) throw new ToolNotFoundError(exec.name) + state.bodyInvoked = true + const returned = await tool.execute(exec.arguments, exec) + const result = this.createSuccessResult(exec, tool, returned) + return isAborted(signal) + ? toolAbortedResult(result) + : result + } catch (error: unknown) { + return toolErrorResult(error) + } finally { + fused.dispose() + exec.signal = wrapperSignal + } + } + + /** + * Run around-dispatch and the tool body. Tool and unknown-tool failures still + * receive post-execute; pipeline failures are already final. Started bodies + * drain on cancellation before a successful outcome becomes `ABORTED`. + * @param exec - the execution returned by this pipeline's prepare stage. + * @returns whether the result still needs post-execute. + */ + async dispatch(exec: ToolRunContext): Promise { + try { + const mutableExec = exec as MutableToolRunContext + const carrier = scopeTarget(this.tools, exec.agent) + const result = await this.ctx.waterfall( + carrier, 'tools/execute', mutableExec, + () => this.dispatchToolBody(mutableExec), + ) + const normalized = this.normalizeDispatchResult(exec, result) + const deferredContexts = this.deferredContexts.get(exec) + /* v8 ignore next -- dispatch only receives executions minted by this registry's prepare stage */ + if (deferredContexts === undefined) throw new Error('tool registry scheduler invariant violated: unprepared execution') + const resultWithDeferredContexts: ToolExecutionResult = deferredContexts.length === 0 + ? normalized + : this.markCanonical(exec, { + ...normalized, + additionalContexts: [ + ...deferredContexts, + ...normalized.additionalContexts ?? [], + ], + }) + return { + kind: 'post-result', + result: this.callerCancelled(exec) && !resultWithDeferredContexts.isError + ? this.cancellationResult(exec, resultWithDeferredContexts) + : resultWithDeferredContexts, + } + } catch (error: unknown) { + return { kind: 'final-result', result: toolErrorResult(error) } + } + } + + /** + * Run post-execute when required, apply definition-owned content finalization, + * and publish the lossless, frozen result. Policy and finalization failures + * become error results. Cancellation replaces a successful outcome before + * final materialization; tool-owned errors remain intact. + * @param exec - the execution returned by this pipeline's prepare stage. + * @param outcome - the preparation or dispatch result awaiting completion. + * @returns the same materialized result delivered to final observers. + */ + async complete(exec: ToolRunContext, outcome: ScheduledToolDispatch): Promise { + switch (outcome.kind) { + case 'final-result': + return this.finish(exec, outcome.result) + case 'post-result': + break + /* v8 ignore next -- closed-union exhaustiveness guard */ + default: + return assertNever(outcome, 'scheduled tool outcome') + } + const { result } = outcome + try { + const project = this.contentProjectors.get(exec) + this.contentProjectors.delete(exec) + const content = project?.(exec, result) + const projected = content === undefined + ? result + : this.markCanonical(exec, this.materializeFinalResult({ ...result, content })) + const postResult = await this.postExecute(exec, projected) + return this.finish( + exec, + this.callerCancelled(exec) && !postResult.isError + ? this.cancellationResult(exec, postResult) + : postResult, + ) + } catch (error: unknown) { + return this.finish(exec, toolErrorResult(error)) + } + } + + /** + * Materialize the candidate, apply definition-owned content finalization, + * then materialize and notify the authoritative result. + * @param exec - the prepared execution. + * @param result - final result. + * @returns the materialized final result. + * @internal + */ + private finish(exec: ToolRunContext, result: ToolExecutionResult): ToolExecutionResult { + let materializedResult: ToolExecutionResult + try { + materializedResult = this.materializeFinalResult(result) + } catch (error: unknown) { + materializedResult = this.materializeFinalResult(toolErrorResult(error)) + } + let finalResult: ToolExecutionResult + try { + finalResult = this.materializeFinalResult(this.applyFinalContent(exec, materializedResult)) + } catch (error: unknown) { + finalResult = this.materializeFinalResult(toolErrorResult(error)) + } + this.notifyResult(exec, finalResult) + return finalResult + } + + /** Apply the snapshotted tool-owned content transform without exposing other result fields. */ + private applyFinalContent(exec: ToolRunContext, result: ToolExecutionResult): ToolExecutionResult { + const finalizeContent = this.contentFinalizers.get(exec) + if (finalizeContent === undefined) return result + const content = finalizeContent(exec, result) + return content === undefined ? result : { ...result, content } + } + + /** Notify observers without exposing a mutation or error channel into the outcome. */ + private notifyResult(exec: ToolExecution, result: ToolExecutionResult): void { + // Freeze the registry's live object before observers receive its readonly + // WeakMap-keyable view. + Object.freeze(exec) + const { name: toolName, callId } = exec + const reportFailure = (error: unknown): void => { + this.ctx.logger.warn(`tool "${toolName}" (${callId}): tools/result observer failed: ${toolErrorMessage(error)}`) + } + const callbacks = this.ctx.events.dispatch('emit', [ + scopeTarget(this.tools, exec.agent), 'tools/result', exec, result, + ]) + for (const callback of callbacks) { + try { + const returned: unknown = callback(exec, result) + void Promise.resolve(returned).catch(reportFailure) + } catch (error: unknown) { + reportFailure(error) + } + } + } + + /** + * Resolve an `ask` decision to allow/deny through the approval seam. The + * seam is consumed opportunistically with `ctx.get('approval')` — a + * deployment that composes no ApprovalService keeps the historical degrade + * to deny, and an unmount mid-session degrades the same way on the next ask. + * An agent-less execution also degrades: without an agent there is no + * session to audit to and no UI to route to. Otherwise the outcome maps + * one-to-one — `allowed-once` proceeds; the three non-grants deny with + * distinct reasons so the model can tell a human "no" from an absent + * approval channel. + */ + private async serviceAsk( + exec: ToolExecution, + ask: Extract, + ): Promise { + const approval = this.ctx.get('approval') + if (approval === undefined) { + return { + decision: { kind: 'deny', reason: ask.reason ?? `tool "${exec.name}" requires approval (not yet supported)` }, + approvalCancelled: false, + } + } + if (exec.agent === undefined) { + return { + decision: { kind: 'deny', reason: `tool "${exec.name}" requires approval, but the call has no agent to route it through` }, + approvalCancelled: false, + } + } + const outcome = await approval.request({ + agent: exec.agent, + toolName: exec.name, + callId: exec.callId, + ...ask.reason !== undefined ? { reason: ask.reason } : {}, + ...ask.displayReason !== undefined ? { displayReason: ask.displayReason } : {}, + signal: exec.signal, + }) + switch (outcome) { + case 'allowed-once': return { decision: { kind: 'allow' }, approvalCancelled: false } + case 'rejected': return { + decision: { kind: 'deny', reason: `the user rejected tool "${exec.name}"` }, + approvalCancelled: false, + } + case 'cancelled': return { + decision: { kind: 'deny', reason: `approval for tool "${exec.name}" was cancelled` }, + approvalCancelled: true, + } + case 'unavailable': return { + decision: { kind: 'deny', reason: `tool "${exec.name}" requires approval, but no approval channel is available` }, + approvalCancelled: false, + } + default: return assertNever(outcome, 'ApprovalOutcome') + } + } + + /** + * Run the `tools/post-execute` waterfall over a dispatched `result` and apply + * its {@link PostToolDecision}: `accept` keeps the call successful (replacing + * `content` when given), `block` turns it into an `isError` whose content is + * the corrective `feedback`. Either decision may attach `additionalContexts`, + * which are ferried on the returned result for the loop's active-batch FIFO. + * Context deferred by the tool body survives an accepted result but is + * discarded when the outer call is blocked; a block exposes only context the + * blocking decision explicitly supplied. + * Runs inside `execute`'s outer try/catch (a throwing listener → isError). + */ + private async postExecute(exec: ToolExecution, result: ToolExecutionResult): Promise { + const decision = await this.ctx.waterfall( + scopeTarget(this.tools, exec.agent), 'tools/post-execute', exec, result, + () => Promise.resolve({ kind: 'accept' }), + ) + const decisionContexts = decision.additionalContexts ?? [] + if (decision.kind === 'block') { + const message = failureMessageFromContent(decision.feedback) + return this.markCanonical(exec, { + content: decision.feedback, + isError: true, + error: { message }, + ...decisionContexts.length > 0 ? { additionalContexts: decisionContexts } : {}, + }) + } + if (Object.hasOwn(decision, 'content') && Object.hasOwn(decision, 'value')) { + throw new TypeError('tools/post-execute accept decision cannot replace both value and content') + } + const additionalContexts = [ + ...result.additionalContexts ?? [], + ...decisionContexts, + ] + if (Object.hasOwn(decision, 'value')) { + if (result.isError) { + throw new TypeError('tools/post-execute cannot replace the value of a failed result') + } + const tool = this.resolveExecution(exec.name, exec.agent, exec.parent !== undefined) + if (tool === undefined) throw new ToolNotFoundError(exec.name) + const replaced = this.createSuccessResult(exec, tool, decision.value) + return this.markCanonical(exec, { + ...replaced, + ...additionalContexts.length > 0 ? { additionalContexts } : {}, + }) + } + return this.markCanonical(exec, { + ...result, + ...decision.content !== undefined ? { content: decision.content } : {}, + ...additionalContexts.length > 0 ? { additionalContexts } : {}, + }) + } + + /** Registry-normalized results and the exact dispatch that validated each value. */ + private readonly canonicalResults = new WeakMap() + + /** Mark one registry-normalized result as canonical only for its owning dispatch. */ + private markCanonical(exec: ToolExecution, result: T): T { + this.canonicalResults.set(result, exec.token) + return result + } + + /** Snapshot, validate, render, and optionally project one successful body value. */ + private createSuccessResult(exec: ToolExecution, tool: ToolDefinition, candidate: unknown): ToolExecutionSuccess { + const detached = snapshotToolValue(tool.name, candidate) + const violations = validateJsonSchemaValue(tool.output.schema, detached, 'value') + if (violations.length > 0) throw new ToolOutputError(tool.name, violations) + const value = deepFreeze(detached) + let rendered: ContentBlock[] + try { + rendered = tool.output.render(exec.arguments, value) + } catch (error: unknown) { + throw projectionError(tool.name, 'render', error) + } + const content = snapshotProjection(tool.name, 'render', rendered) + let meta: JsonValue | undefined + if (tool.output.presentationMeta !== undefined) { + let projected: JsonValue + try { + projected = tool.output.presentationMeta(exec.arguments, value) + } catch (error: unknown) { + throw projectionError(tool.name, 'presentationMeta', error) + } + meta = snapshotProjection(tool.name, 'presentationMeta', projected) + } + const concludesTurn = this.concludingExecutions.has(exec) + return this.markCanonical(exec, this.materializeFinalResult({ + isError: false, + value, + content, + ...meta !== undefined ? { meta } : {}, + ...concludesTurn ? { concludesTurn: true as const } : {}, + }) as ToolExecutionSuccess) + } + + /** Normalize an around-dispatch wrapper's authored result through the owning output contract. */ + private normalizeDispatchResult(exec: ToolExecution, result: ToolExecutionResult): ToolExecutionResult { + if (this.canonicalResults.get(result) === exec.token) return result + if (result.isError) { + return this.markCanonical(exec, { + isError: true, + error: result.error, + content: result.content, + ...result.meta !== undefined ? { meta: result.meta } : {}, + ...result.additionalContexts !== undefined ? { additionalContexts: result.additionalContexts } : {}, + }) + } + const tool = this.resolveExecution(exec.name, exec.agent, exec.parent !== undefined) + if (tool === undefined) throw new ToolNotFoundError(exec.name) + const normalized = this.createSuccessResult(exec, tool, result.value) + return this.markCanonical(exec, { + ...normalized, + ...result.additionalContexts !== undefined ? { additionalContexts: result.additionalContexts } : {}, + }) + } + + /** Materialize the authoritative commit outcome once, immediately before `tools/result`. */ + private materializeFinalResult(result: ToolExecutionResult): ToolExecutionResult { + const presentation = { + content: result.content, + ...result.meta !== undefined ? { meta: result.meta } : {}, + ...result.additionalContexts !== undefined ? { additionalContexts: result.additionalContexts } : {}, + } + if (result.isError) { + return materializePresentation({ isError: true as const, error: result.error, ...presentation }) + } + const detached = materializePresentation({ + isError: false as const, + ...presentation, + ...result.concludesTurn === true ? { concludesTurn: true as const } : {}, + }) + return deepFreeze({ ...detached, value: result.value }) + } +} + +/** Convert one projector exception into the canonical invalid-output failure. */ +function projectionError(toolName: string, projector: 'render' | 'presentationMeta', error: unknown): ToolOutputError { + return new ToolOutputError(toolName, [`output.${projector} failed: ${toolErrorMessage(error)}`]) +} + +/** Snapshot one projector result before later durable-result materialization. */ +function snapshotProjection(toolName: string, projector: 'render' | 'presentationMeta', candidate: T): T { + try { + const detached = snapshotJsonValue(candidate) + if (detached === undefined) { + throw new ToolOutputError(toolName, [`output.${projector} returned non-lossless JSON`]) + } + return detached + } catch (error: unknown) { + if (error instanceof ToolOutputError) throw error + throw projectionError(toolName, projector, error) + } +} + +/** Snapshot one body or policy value into the canonical invalid-output failure class. */ +function snapshotToolValue(toolName: string, candidate: unknown): JsonValue { + try { + const detached = snapshotJsonValue(candidate) + if (detached === undefined) throw new ToolOutputError(toolName, ['value is not lossless JSON']) + return detached as JsonValue + } catch (error: unknown) { + if (error instanceof ToolOutputError) throw error + throw new ToolOutputError(toolName, [`value snapshot failed: ${toolErrorMessage(error)}`]) + } +} + +/** + * Best-effort human-readable message from an arbitrary thrown value: Error + * instances use `.message`; non-Error objects with a string `message` + * property (e.g. `throw { message: 'denied' }`) use it too; everything else + * is stringified. + * @param error - thrown value from a tool or policy callback. + * @returns a printable failure message, including for hostile thrown values. + */ +export function toolErrorMessage(error: unknown): string { + try { + if (error instanceof Error) return error.message + if (typeof error === 'object' && error !== null + && 'message' in error && typeof error.message === 'string') { + return error.message + } + return String(error) + } catch { + // A hostile thrown value can trap `instanceof`, property access, or string + // coercion. Error normalization is the outermost safety boundary, so its + // fallback must itself be total. + return '' + } +} + +/** Derive one failure message from policy feedback without changing its rendered blocks. */ +function failureMessageFromContent(content: ContentBlock[]): string { + const text = content + .map(block => block.type === 'text' ? block.text : `[${block.type} content]`) + .join('\n') + return text.length > 0 ? text : 'tool result blocked by post-execute policy' +} + +/** Snapshot and freeze one durable tool-result projection or reject lossy data. */ +function materializePresentation(candidate: T): T { + const detached = snapshotJsonValue(candidate) + if (detached === undefined) { + throw new TypeError('tool result must be losslessly JSON-serializable') + } + return deepFreeze(detached) +} + +/** Structured `{ name, code }` for a thrown HarnessError, else undefined. */ +function errorInfo(error: unknown): ToolErrorInfo | undefined { + try { + return error instanceof HarnessError ? { name: error.name, code: error.code } : undefined + } catch { + return undefined + } +} + +/** Mint a same-process correlation token whose identity is its value. */ +function createExecutionToken(): ToolExecutionToken { + return Symbol('dsh.tool.execution') as ToolExecutionToken +} + +function toolErrorResult(error: unknown): ToolExecutionResult { + const info = errorInfo(error) + const message = toolErrorMessage(error) + return { + content: [{ type: 'text', text: `Error: ${message}` }], + isError: true, + error: { message, ...info ? { info } : {} }, + } +} + +/** Read live abort state across an await without treating it as synchronously immutable. */ +function isAborted(signal: AbortSignal): boolean { + return signal.aborted +} + +/** + * Fuse caller and wrapper cancellation without nesting `AbortSignal.any`. + * Keeping the relay dispatch-scoped also removes listeners when work settles. + */ +function fuseToolSignals(caller: AbortSignal, wrapper: AbortSignal): FusedToolSignal { + if (caller === wrapper) return { signal: caller, dispose() {} } + + const controller = new AbortController() + let listening = false + const dispose = (): void => { + if (!listening) return + listening = false + caller.removeEventListener('abort', abortFromCaller) + wrapper.removeEventListener('abort', abortFromWrapper) + } + const abortFrom = (source: AbortSignal): void => { + const reason: unknown = source.reason + controller.abort(reason) + dispose() + } + const abortFromCaller = (): void => { abortFrom(caller) } + const abortFromWrapper = (): void => { abortFrom(wrapper) } + + if (wrapper.aborted) abortFromWrapper() + else if (caller.aborted) abortFromCaller() + else { + listening = true + caller.addEventListener('abort', abortFromCaller, { once: true }) + wrapper.addEventListener('abort', abortFromWrapper, { once: true }) + } + return { signal: controller.signal, dispose } +} + +/** Canonical result when cancellation supersedes success after body invocation. */ +function toolAbortedResult(prior?: ToolExecutionResult): ToolExecutionResult { + const additionalContexts = prior?.additionalContexts ?? [] + return { + content: [{ type: 'text', text: 'Error: tool call aborted' }], + isError: true, + error: { + message: 'tool call aborted', + info: { name: 'AbortError', code: TOOL_ABORTED }, + }, + ...additionalContexts.length > 0 ? { additionalContexts } : {}, + } +} + +/** Canonical result when cancellation prevents tool body invocation. */ +function toolAbortedBeforeDispatchResult(prior?: ToolExecutionResult): ToolExecutionResult { + const additionalContexts = prior?.additionalContexts ?? [] + return { + content: [{ type: 'text', text: 'Error: tool call aborted before dispatch' }], + isError: true, + error: { + message: 'tool call aborted before dispatch', + info: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH }, + }, + ...additionalContexts.length > 0 ? { additionalContexts } : {}, + } +} + +export { executeToolBatch } from './batch.ts' diff --git a/packages/core/tools/src/schema.ts b/packages/core/tools/src/schema.ts index 2cc6f0cb0b..eb6b6f30f9 100644 --- a/packages/core/tools/src/schema.ts +++ b/packages/core/tools/src/schema.ts @@ -1,9 +1,10 @@ -/** Unified JSON-value schema DSL, inference, compilation, and typed tool helper. @module dsh-tools/schema */ +/** Unified JSON-value schema DSL, inference, compilation, and tool definition helpers. @module dsh-tools/schema */ import { HarnessError } from '@deepseek-ai/dsh-llm' -import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import type { ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm' +import { snapshotJsonValue } from '@deepseek-ai/dsh-util-values' import type { JsonValue } from '@deepseek-ai/dsh-util-values' -import type { ToolDefinition, ToolExecution, ToolExecutionResult, ToolRunContext, ToolResult } from './index.ts' +import type { ToolDefinition, ToolExecution, ToolExecutionResult, ToolRunContext, ToolResult } from './contracts.ts' import { assertSupportedJsonSchema, isJsonSchemaRecord, isPlainJsonArray, JsonSchemaError, validateJsonSchemaValue } from './json-schema.ts' import type { JsonSchemaNode, JsonSchemaScalar, ObjectJsonSchema } from './json-schema.ts' import type { ToolCallView, ToolResultView } from './presentation.ts' @@ -630,3 +631,16 @@ export function defineTool = Omit< - DefineToolOptions, - 'output' | 'execute' -> & { - /** Produce the fixture's content blocks as its canonical test value. */ - execute(args: import('./schema.ts').InferArgs, exec: ToolRunContext): Promise -} - -/** - * Define a test fixture that deliberately uses its content blocks as the - * canonical JSON value. Product tools must declare domain-owned DTOs instead. - * @param options - ordinary fixture fields plus a content-producing body. - * @returns a registry-ready tool with an explicit JSON-array output contract. - * @internal - */ -export function defineContentToolFixture( - options: ContentToolFixtureOptions, -): ToolDefinition { - // oxlint-disable-next-line typescript/unbound-method - const execute = options.execute - return defineTool({ - ...options, - output: { - schema: CONTENT_VALUE_SCHEMA, - render: (_args, value) => value as unknown as ContentBlock[], - }, - async execute(args, exec) { - return await execute(args, exec) as unknown as JsonValue[] - }, - }) -} diff --git a/packages/core/tools/tests/execution-mode.spec.ts b/packages/core/tools/tests/execution-mode.spec.ts index 333909c24a..d506ad08a8 100644 --- a/packages/core/tools/tests/execution-mode.spec.ts +++ b/packages/core/tools/tests/execution-mode.spec.ts @@ -1,15 +1,13 @@ /** Covers fail-closed per-call classification and model-schema isolation. */ +import { ToolExecutionPipeline } from '@deepseek-ai/dsh-tools/pipeline' +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { describe, expect, expectTypeOf, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { ToolCallId } from '@deepseek-ai/dsh-llm' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRuntime, { - defineContentToolFixture, - type ToolDefinition, - type ToolExecutionInput, - type ToolExecutionMode, -} from '@deepseek-ai/dsh-tools' +import ToolRuntime, { type ToolDefinition, type ToolExecutionInput, type ToolExecutionMode } from '@deepseek-ai/dsh-tools' +import { defineContentToolFixture } from '@deepseek-ai/dsh-tool-testkit' const testToolSignal = new AbortController().signal @@ -17,6 +15,7 @@ async function setup() { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) return ctx } @@ -24,7 +23,7 @@ function exec(name: string, args: unknown): ToolExecutionInput { return { signal: testToolSignal, callId: ToolCallId('c1'), name, arguments: args } } -describe('ToolRuntime.executionMode', () => { +describe('ToolExecutionPipeline.executionMode', () => { it('returns parallel only for an explicit true classifier', async () => { const ctx = await setup() ctx.tools.register(defineContentToolFixture({ @@ -34,7 +33,7 @@ describe('ToolRuntime.executionMode', () => { isConcurrencySafe: () => true, async execute() { return [] }, })) - expect(ctx.tools.executionMode(exec('safe', {}))).toEqual({ kind: 'parallel' }) + expect(new ToolExecutionPipeline(ctx).executionMode(exec('safe', {}))).toEqual({ kind: 'parallel' }) }) it('defaults to exclusive for a tool with no isConcurrencySafe declaration', async () => { @@ -45,12 +44,12 @@ describe('ToolRuntime.executionMode', () => { parameters: {}, async execute() { return [] }, })) - expect(ctx.tools.executionMode(exec('plain', {}))).toEqual({ kind: 'exclusive' }) + expect(new ToolExecutionPipeline(ctx).executionMode(exec('plain', {}))).toEqual({ kind: 'exclusive' }) }) it('returns exclusive for an unknown tool', async () => { const ctx = await setup() - expect(ctx.tools.executionMode(exec('nonexistent', {}))).toEqual({ kind: 'exclusive' }) + expect(new ToolExecutionPipeline(ctx).executionMode(exec('nonexistent', {}))).toEqual({ kind: 'exclusive' }) }) it('returns exclusive when the classifier returns false for these args', async () => { @@ -62,8 +61,8 @@ describe('ToolRuntime.executionMode', () => { isConcurrencySafe: args => args.mode === 'read', async execute() { return [] }, })) - expect(ctx.tools.executionMode(exec('rw', { mode: 'read' }))).toEqual({ kind: 'parallel' }) - expect(ctx.tools.executionMode(exec('rw', { mode: 'write' }))).toEqual({ kind: 'exclusive' }) + expect(new ToolExecutionPipeline(ctx).executionMode(exec('rw', { mode: 'read' }))).toEqual({ kind: 'parallel' }) + expect(new ToolExecutionPipeline(ctx).executionMode(exec('rw', { mode: 'write' }))).toEqual({ kind: 'exclusive' }) }) it('classifies invalid defineContentToolFixture arguments as exclusive without throwing', async () => { @@ -75,7 +74,7 @@ describe('ToolRuntime.executionMode', () => { isConcurrencySafe: () => true, async execute() { return [] }, })) - expect(ctx.tools.executionMode(exec('needs-mode', {}))).toEqual({ kind: 'exclusive' }) + expect(new ToolExecutionPipeline(ctx).executionMode(exec('needs-mode', {}))).toEqual({ kind: 'exclusive' }) }) it('treats a throwing raw classifier as exclusive', async () => { @@ -89,7 +88,7 @@ describe('ToolRuntime.executionMode', () => { async execute() { return null }, } ctx.tools.register(raw) - expect(ctx.tools.executionMode(exec('thrower', {}))).toEqual({ kind: 'exclusive' }) + expect(new ToolExecutionPipeline(ctx).executionMode(exec('thrower', {}))).toEqual({ kind: 'exclusive' }) }) it('treats a truthy non-boolean raw result as exclusive', async () => { @@ -103,7 +102,7 @@ describe('ToolRuntime.executionMode', () => { async execute() { return null }, } as unknown as ToolDefinition ctx.tools.register(raw) - expect(ctx.tools.executionMode(exec('truthy', {}))).toEqual({ kind: 'exclusive' }) + expect(new ToolExecutionPipeline(ctx).executionMode(exec('truthy', {}))).toEqual({ kind: 'exclusive' }) }) it('passes parsed arguments directly to a raw definition', async () => { @@ -117,7 +116,7 @@ describe('ToolRuntime.executionMode', () => { isConcurrencySafe(args) { seen = args; return true }, async execute() { return null }, }) - expect(ctx.tools.executionMode(exec('raw-safe', { anything: 1 }))).toEqual({ kind: 'parallel' }) + expect(new ToolExecutionPipeline(ctx).executionMode(exec('raw-safe', { anything: 1 }))).toEqual({ kind: 'parallel' }) expect(seen).toEqual({ anything: 1 }) }) diff --git a/packages/core/tools/tests/exports.built.e2e.ts b/packages/core/tools/tests/exports.built.e2e.ts new file mode 100644 index 0000000000..bac653511f --- /dev/null +++ b/packages/core/tools/tests/exports.built.e2e.ts @@ -0,0 +1,45 @@ +/** Public tool entries must share runtime identities without exporting test helpers from the plugin. */ +import { execFile } from 'node:child_process' +import { existsSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { promisify } from 'node:util' +import { expect, it } from 'vitest' + +const packageRoot = fileURLToPath(new URL('..', import.meta.url)) +const pipelineBundle = fileURLToPath(new URL('../lib/pipeline.js', import.meta.url)) +const execFileAsync = promisify(execFile) +const probe = String.raw` +import assert from 'node:assert/strict' +import { Context } from '@deepseek-ai/cordis' +import * as tools from '@deepseek-ai/dsh-tools' +import { ToolExecutionPipeline, executeToolBatch } from '@deepseek-ai/dsh-tools/pipeline' +import { defineContentToolFixture } from '@deepseek-ai/dsh-tool-testkit' + +assert.equal(Object.hasOwn(tools, 'defineContentToolFixture'), false) +assert.equal(Object.hasOwn(tools, 'executeToolFixture'), false) +assert.equal(Object.hasOwn(tools, 'executeModelToolFixture'), false) +await assert.rejects(import('@deepseek-ai/dsh-tools/testing'), { code: 'ERR_PACKAGE_PATH_NOT_EXPORTED' }) +const ctx = new Context() +try { + const cause = new Error('registry lookup failed') + const pipeline = new ToolExecutionPipeline(ctx, { get() { throw cause } }) + assert.equal(typeof pipeline.prepare, 'function') + await assert.rejects(executeToolBatch({ + calls: [{ type: 'tool-call', id: 'probe', name: 'probe', arguments: '{}' }], + signal: new AbortController().signal, + }, pipeline, () => 1), error => error instanceof tools.ToolBatchExecutionError && error.cause === cause) + const definition = defineContentToolFixture({ + name: 'probe', description: 'probe', parameters: {}, + async execute() { return [{ type: 'text', text: 'ok' }] }, + }) + assert.equal(definition.name, 'probe') +} finally { + await ctx.fiber.dispose() +} +console.log('shared tool entries verified') +` + +it.skipIf(!existsSync(pipelineBundle))('shares batch failures and scheduler stages across built entries', { retry: 0 }, async ({ signal }) => { + const { stdout } = await execFileAsync(process.execPath, ['--input-type=module', '-e', probe], { cwd: packageRoot, signal }) + expect(stdout.trim()).toBe('shared tool entries verified') +}) diff --git a/packages/core/tools/tests/gen-tool-catalog.spec.ts b/packages/core/tools/tests/gen-tool-catalog.spec.ts index 13d7b9e7c7..d571a19a80 100644 --- a/packages/core/tools/tests/gen-tool-catalog.spec.ts +++ b/packages/core/tools/tests/gen-tool-catalog.spec.ts @@ -67,6 +67,8 @@ describe('gen-tool-catalog collectToolCatalog', () => { it('attributes each harvested tool with its registering plugin source', async () => { const catalog = await collectToolCatalog() + const ptc = catalog.find(entry => entry.pkg === '@deepseek-ai/dsh-tool-executor-ptc') + expect(ptc?.sources.run_code).toBe('packages/core/tool-executor-ptc/src/ptc.ts') const bash = catalog.find(entry => entry.pkg === '@deepseek-ai/dsh-tool-bash') expect(bash?.sources.bash).toBe('packages/shell/tool-bash/src/index.ts') const control = catalog.find(entry => entry.pkg === '@deepseek-ai/dsh-tool-subagent-control') diff --git a/packages/core/tools/tests/scoped.spec.ts b/packages/core/tools/tests/scoped.spec.ts index 7eac6489f7..2a45fccfb0 100644 --- a/packages/core/tools/tests/scoped.spec.ts +++ b/packages/core/tools/tests/scoped.spec.ts @@ -1,3 +1,5 @@ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { describe, expect, expectTypeOf, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import type { Events } from '@deepseek-ai/cordis' @@ -18,6 +20,7 @@ async function mount(): Promise { const ctx = new Context() await ctx.plugin(SystemPrompt, {}) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) return ctx } @@ -47,7 +50,7 @@ function tool(name: string, reply = `ran:${name}`): ToolDefinition { } async function run(ctx: Context, name: string, agent?: Agent): Promise { - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c1'), name, @@ -416,7 +419,7 @@ describe('scoped execution dispatch', () => { expect(await run(ctx, 'danger', key)).toBe('Error: danger denied') const callerArguments = { source: true } - const safeResult = await ctx.tools.execute({ + const safeResult = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('safe-call'), name: 'safe', @@ -461,7 +464,7 @@ describe('scoped execution dispatch', () => { if (exec.name === 'parent') parent = exec.token return next() }) - await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('parent'), name: 'parent', arguments: {} }) + await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('parent'), name: 'parent', arguments: {} }) stopCapture() policyCalls = 0 const signal = new AbortController().signal @@ -476,7 +479,7 @@ describe('scoped execution dispatch', () => { ctx.on('tools/result', () => { globalObserved += 1 }) const callerArguments = { invalid: () => undefined } - const scopedResult = await ctx.tools.execute({ + const scopedResult = await executeToolFixture(ctx, { callId: ToolCallId('non-cloneable'), name: 't', arguments: callerArguments, @@ -484,7 +487,7 @@ describe('scoped execution dispatch', () => { parent, signal, }) - const subjectlessResult = await ctx.tools.execute({ + const subjectlessResult = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('non-cloneable-subjectless'), name: 't', @@ -535,7 +538,7 @@ describe('scoped execution dispatch', () => { }, } as ToolExecutionInput - const result = await ctx.tools.execute(input) + const result = await executeToolFixture(ctx, input) expect(result.isError).toBe(false) expect(parentReads).toBe(1) @@ -553,7 +556,7 @@ describe('scoped execution dispatch', () => { if (exec.name === 'parent') parent = exec.token return next() }) - await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('parent'), name: 'parent', arguments: {} }) + await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('parent'), name: 'parent', arguments: {} }) stopCapture() const acceptedSignal = new AbortController().signal const driftSignal = new AbortController().signal @@ -572,7 +575,7 @@ describe('scoped execution dispatch', () => { ctx.on('tools/result', (exec) => { observed = exec }) scope.ctx.on('tools/result', () => { scopedObserved += 1 }) - const result = await ctx.tools.execute(input) + const result = await executeToolFixture(ctx, input) expect(result.isError).toBe(true) expect(reads).toEqual({ callId: 1, name: 1, arguments: 1, agent: 1, parent: 1, signal: 1 }) @@ -607,7 +610,7 @@ describe('scoped execution dispatch', () => { }, } as ToolExecutionInput - const result = await ctx.tools.execute(input) + const result = await executeToolFixture(ctx, input) expect(result.isError).toBe(true) expect(result.content).toEqual([{ type: 'text', text: 'Error: getter exploded' }]) @@ -640,7 +643,7 @@ describe('scoped execution dispatch', () => { expect(result.isError).toBe(true) }) - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('bad-arguments'), name: 't', arguments: argumentsValue, }) @@ -661,7 +664,7 @@ describe('scoped execution dispatch', () => { get: () => ++reads === 1 ? 'safe' : new Map([['mutable', true]]), }) - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('unstable-arguments'), name: 't', arguments: argumentsValue, }) @@ -705,7 +708,7 @@ describe('scoped execution dispatch', () => { ctx.on('tools/result', () => Promise.reject(new Error('async observer failure')) as never) ctx.on('tools/result', (_exec, result) => { seen.push(result.isError) }) - const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('final'), name: 't', arguments: {}, agent: key }) + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('final'), name: 't', arguments: {}, agent: key }) await Promise.resolve() expect(result).toMatchObject({ isError: true, content: [{ type: 'text', text: 'outer failure' }] }) expect(seen).toEqual([true, true]) diff --git a/packages/core/tools/tests/tools.spec.ts b/packages/core/tools/tests/tools.spec.ts index 870c72fe4c..785927d780 100644 --- a/packages/core/tools/tests/tools.spec.ts +++ b/packages/core/tools/tests/tools.spec.ts @@ -1,3 +1,6 @@ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' +import { ToolExecutionPipeline } from '@deepseek-ai/dsh-tools/pipeline' +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { describe, expect, expectTypeOf, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import LlmRuntime, { createUserMessage, ToolCallId, HarnessError, type ContentBlock } from '@deepseek-ai/dsh-llm' @@ -8,12 +11,8 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import ApprovalService, { type ApprovalOutcome, type ApprovalRequest } from '@deepseek-ai/dsh-user-approval' -import ToolRuntime, { - defineContentToolFixture, defineTool, JsonSchemaError, parameterSchemaSpecToJsonSchema, validateArgs, ToolArgsError, ToolNotFoundError, - TOOL_ABORTED, TOOL_ABORTED_BEFORE_DISPATCH, - type InferArgs, type ParameterSchemaSpec, type PreToolDecision, type PostToolDecision, - type JsonSchemaNode, type ToolDefinition, type ToolDispatchExecution, type ToolExecutionResult, type ToolExecutionToken, -} from '@deepseek-ai/dsh-tools' +import ToolRuntime, { defineTool, JsonSchemaError, parameterSchemaSpecToJsonSchema, validateArgs, ToolArgsError, ToolNotFoundError, TOOL_ABORTED, TOOL_ABORTED_BEFORE_DISPATCH, type InferArgs, type ParameterSchemaSpec, type PreToolDecision, type PostToolDecision, type JsonSchemaNode, type ToolDefinition, type ToolDispatchExecution, type ToolExecutionResult, type ToolExecutionToken } from '@deepseek-ai/dsh-tools' +import { defineContentToolFixture } from '@deepseek-ai/dsh-tool-testkit' import type { JsonValue } from '@deepseek-ai/dsh-util-values' declare module '@deepseek-ai/dsh-llm' { @@ -35,6 +34,7 @@ async function setup() { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) return ctx } @@ -52,6 +52,48 @@ const echoTool = defineTool({ }) describe('ToolRuntime', () => { + it.each([{ mode: 'ptc' }, { maxParallelSubCalls: 4 }])('rejects removed execution configuration %j during plugin mounting', async (config) => { + const ctx = new Context() + try { + await ctx.plugin(SystemPrompt) + // @ts-expect-error Removed fields can still arrive from untyped configuration files. + await expect(ctx.plugin(ToolRuntime, config).await()).rejects.toThrow('invalid config') + } finally { await ctx.fiber.dispose() } + }) + + it('settles accepted calls after the actual native executor fiber unloads', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRuntime) + const provider = ctx.plugin(NativeToolExecutor) + await provider + ctx.tools.register(echoTool) + const entered = Promise.withResolvers() + const release = Promise.withResolvers() + ctx.on('tools/pre-execute', async (exec, next) => { + if (exec.callId === 'first') { entered.resolve(undefined); await release.promise } + return next() + }) + const input = { + calls: ['first', 'second'].map(id => ({ type: 'tool-call' as const, id: ToolCallId(id), name: 'echo', arguments: JSON.stringify({ text: id }) })), + signal: new AbortController().signal, + } + const pending = ctx.tools.execute(input) + try { + await entered.promise + await provider.dispose() + await expect(ctx.tools.execute(input)).rejects.toThrow('selected tool executor is unavailable') + release.resolve(undefined) + expect((await pending).results.map(({ result }) => result)).toMatchObject([ + { isError: false, value: 'first' }, { isError: false, value: 'second' }, + ]) + } finally { + release.resolve(undefined) + await Promise.allSettled([pending]) + await ctx.fiber.dispose() + } + }) + it('preserves deferred loading through registry and prompt schema projection', async () => { const ctx = await setup() try { @@ -121,7 +163,7 @@ describe('ToolRuntime', () => { }) try { expect(Object.keys(ctx.tools.schemas()[0]!)).not.toContain('projectContent') - const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('projection'), name: 'projected', arguments: {} }) + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('projection'), name: 'projected', arguments: {} }) expect(result.content).toEqual([{ type: 'text', text: 'policy result' }]) if (result.isError) throw new Error('expected successful projection') expect(result.value).toBe('canonical') @@ -134,7 +176,7 @@ describe('ToolRuntime', () => { ctx.tools.register({ ...echoTool, projectContent() { throw new Error('projection failed') } }) ctx.on('tools/post-execute', async (_exec, _result, next) => { postCalls++; return next() }) try { - const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('projection-error'), name: 'echo', arguments: {} }) + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('projection-error'), name: 'echo', arguments: {} }) expect(result.isError).toBe(true) expect(JSON.stringify(result.content)).toContain('projection failed') expect(postCalls).toBe(0) @@ -157,7 +199,7 @@ describe('ToolRuntime', () => { ctx.tools.register(echoTool) let observed: ToolExecutionResult | undefined ctx.on('tools/result', (_exec, result) => { observed = result }) - const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('c1'), name: 'echo', arguments: { text: 'hi' } }) + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c1'), name: 'echo', arguments: { text: 'hi' } }) expect(result).toEqual({ content: [{ type: 'text', text: 'hi' }], isError: false, value: 'hi' }) expect(observed).toEqual(result) }) @@ -175,7 +217,7 @@ describe('ToolRuntime', () => { return 'ok' }, }) - const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('c1'), name: 'meta-tool', arguments: {} }) + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c1'), name: 'meta-tool', arguments: {} }) expect(result).toEqual({ content: [{ type: 'text', text: 'ok' }], isError: false, @@ -193,7 +235,7 @@ describe('ToolRuntime', () => { return 'ok' }, }) - const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('c1'), name: 'no-meta-tool', arguments: {} }) + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c1'), name: 'no-meta-tool', arguments: {} }) expect(result).toEqual({ content: [{ type: 'text', text: 'ok' }], isError: false, value: 'ok' }) expect('meta' in result).toBe(false) }) @@ -214,7 +256,7 @@ describe('ToolRuntime', () => { }, }) - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('bad-meta'), name: 'bad-meta', arguments: {}, }) @@ -252,7 +294,7 @@ describe('ToolRuntime', () => { }, }) - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('throwing-meta'), name: 'throwing-meta', arguments: {}, }) @@ -277,7 +319,7 @@ describe('ToolRuntime', () => { }, }) - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('throwing-finalizer'), name: 'throwing-finalizer', arguments: {}, }) @@ -320,8 +362,8 @@ describe('ToolRuntime', () => { execute: async () => 42 as unknown as string, })) - const lossy = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('lossy'), name: 'lossy-output', arguments: {} }) - const mismatch = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('mismatch'), name: 'wrong-output', arguments: {} }) + const lossy = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('lossy'), name: 'lossy-output', arguments: {} }) + const mismatch = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('mismatch'), name: 'wrong-output', arguments: {} }) expect(lossy.error).toMatchObject({ info: { name: 'ToolOutputError', code: 'INVALID_TOOL_OUTPUT' } }) expect(lossy.content[0]?.type === 'text' ? lossy.content[0].text : '').toContain('not lossless JSON') expect(mismatch.error).toMatchObject({ info: { name: 'ToolOutputError', code: 'INVALID_TOOL_OUTPUT' } }) @@ -342,7 +384,7 @@ describe('ToolRuntime', () => { execute: async () => hostile as JsonValue, })) - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('hostile-body'), name: 'hostile-body', arguments: {}, }) @@ -370,7 +412,7 @@ describe('ToolRuntime', () => { execute: async () => 'ok', })) - const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId(projector), name: `throwing-${projector}`, arguments: {} }) + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId(projector), name: `throwing-${projector}`, arguments: {} }) expect(result.isError).toBe(true) expect(result.error?.message) .toContain(projector === 'render' ? 'renderer exploded' : 'metadata exploded') @@ -400,7 +442,7 @@ describe('ToolRuntime', () => { execute: async () => 'ok', })) - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId(`hostile-${projector}`), name: `hostile-${projector}`, arguments: {}, }) @@ -439,9 +481,9 @@ describe('ToolRuntime', () => { } }) - const content = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('content'), name: 'projected', arguments: {} }) + const content = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('content'), name: 'projected', arguments: {} }) replacement = 'value' - const value = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('value'), name: 'projected', arguments: {} }) + const value = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('value'), name: 'projected', arguments: {} }) expect(content).toEqual({ isError: false, @@ -471,7 +513,7 @@ describe('ToolRuntime', () => { value: 'replacement', content: [{ type: 'text', text: 'also replacement' }], } as unknown as PostToolDecision)) - const bothResult = await both.tools.execute({ signal: testToolSignal, callId: ToolCallId('both'), name: 'echo', arguments: {} }) + const bothResult = await executeToolFixture(both, { signal: testToolSignal, callId: ToolCallId('both'), name: 'echo', arguments: {} }) expect(bothResult).toMatchObject({ isError: true, error: { message: 'tools/post-execute accept decision cannot replace both value and content' }, @@ -480,7 +522,7 @@ describe('ToolRuntime', () => { const invalid = await setup() invalid.tools.register(echoTool) invalid.on('tools/post-execute', async () => ({ kind: 'accept', value: 1 })) - const invalidResult = await invalid.tools.execute({ signal: testToolSignal, callId: ToolCallId('invalid'), name: 'echo', arguments: {} }) + const invalidResult = await executeToolFixture(invalid, { signal: testToolSignal, callId: ToolCallId('invalid'), name: 'echo', arguments: {} }) expect(invalidResult.error).toMatchObject({ info: { code: 'INVALID_TOOL_OUTPUT' } }) expect('value' in invalidResult).toBe(false) }) @@ -493,7 +535,7 @@ describe('ToolRuntime', () => { feedback: [{ type: 'text', text: 'blocked by policy' }], })) - const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('block'), name: 'echo', arguments: { text: 'secret' } }) + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('block'), name: 'echo', arguments: { text: 'secret' } }) expect(result).toEqual({ isError: true, error: { message: 'blocked by policy' }, @@ -507,7 +549,7 @@ describe('ToolRuntime', () => { ctx.tools.register(echoTool) ctx.on('tools/post-execute', async () => ({ kind: 'accept', value: 'replacement' })) - const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('replace-value'), name: 'echo', arguments: {} }) + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('replace-value'), name: 'echo', arguments: {} }) expect(result).toEqual({ isError: false, value: 'replacement', @@ -523,7 +565,7 @@ describe('ToolRuntime', () => { ctx.tools.register(echoTool) ctx.on('tools/post-execute', async () => ({ kind: 'block', feedback: [...feedback] })) - const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('block-message'), name: 'echo', arguments: {} }) + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('block-message'), name: 'echo', arguments: {} }) expect(result.error?.message).toBe(message) }) @@ -535,7 +577,7 @@ describe('ToolRuntime', () => { feedback: [{ type: 'text', text: 'blocked', invalid: () => undefined } as never], })) - const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('invalid-block'), name: 'echo', arguments: {} }) + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('invalid-block'), name: 'echo', arguments: {} }) expect(result).toMatchObject({ isError: true, error: { message: 'tool result must be losslessly JSON-serializable' }, @@ -551,7 +593,7 @@ describe('ToolRuntime', () => { }) ctx.on('tools/post-execute', async () => ({ kind: 'accept', value: 'replacement' })) - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('failed-replace'), name: 'throw-before-replace', arguments: {}, }) @@ -566,7 +608,7 @@ describe('ToolRuntime', () => { return { kind: 'accept', value: 'replacement' } }) - const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('post-disposed'), name: 'echo', arguments: {} }) + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('post-disposed'), name: 'echo', arguments: {} }) expect(result.error).toEqual({ message: 'unknown tool "echo"', info: { name: 'ToolNotFoundError', code: 'UNKNOWN_TOOL' }, @@ -586,7 +628,7 @@ describe('ToolRuntime', () => { })], })) - const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('wrapper-failure'), name: 'echo', arguments: {} }) + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('wrapper-failure'), name: 'echo', arguments: {} }) expect(result).toEqual({ isError: true, error: { message: 'wrapped failure' }, @@ -609,7 +651,7 @@ describe('ToolRuntime', () => { return { isError: false, value: 'replacement', content: [] } }) - const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('wrapper-disposed'), name: 'echo', arguments: {} }) + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('wrapper-disposed'), name: 'echo', arguments: {} }) expect(result.error).toEqual({ message: 'unknown tool "echo"', info: { name: 'ToolNotFoundError', code: 'UNKNOWN_TOOL' }, @@ -623,8 +665,8 @@ describe('ToolRuntime', () => { name: 'meta-suppression', output: { ...echoTool.output, presentationMeta: () => ({ card: true }) }, }) - const direct = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('direct'), name: 'meta-suppression', arguments: {} }) - const nested = await ctx.tools.execute({ + const direct = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('direct'), name: 'meta-suppression', arguments: {} }) + const nested = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('nested'), name: 'meta-suppression', @@ -657,7 +699,7 @@ describe('ToolRuntime', () => { name: 'composite', async execute(_args, exec) { call += 1 - const nested = await ctx.tools.execute({ + const nested = await executeToolFixture(ctx, { signal: exec.signal, callId: ToolCallId(`nested-${call}`), name: 'terminal-nested', arguments: {}, parent: exec.token, }) if (nested.concludesTurn) exec.concludeTurn() @@ -671,7 +713,7 @@ describe('ToolRuntime', () => { if (exec.name !== 'terminal-nested') return next() return { kind: 'block', feedback: [{ type: 'text', text: 'nested success rejected' }] } }) - const recovered = await ctx.tools.execute({ + const recovered = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('composite-vetoed'), name: 'composite', arguments: {}, }) expect(recovered.isError).toBe(false) @@ -680,7 +722,7 @@ describe('ToolRuntime', () => { // The same nested call succeeding carries the marker; the composite // forwards it onto its own successful result. - const concluded = await ctx.tools.execute({ + const concluded = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('composite-ok'), name: 'composite', arguments: {}, }) expect(concluded.isError).toBe(false) @@ -697,7 +739,7 @@ describe('ToolRuntime', () => { }, }) - const unknown = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('c1'), name: 'nope', arguments: {} }) + const unknown = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c1'), name: 'nope', arguments: {} }) expect(unknown.isError).toBe(true) expect(unknown.content[0]).toMatchObject({ text: 'Error: unknown tool "nope"' }) // An unknown tool is a routable failure class, same as a tool-thrown one. @@ -706,7 +748,7 @@ describe('ToolRuntime', () => { info: { name: 'ToolNotFoundError', code: 'UNKNOWN_TOOL' }, }) - const thrown = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('c2'), name: 'boom', arguments: {} }) + const thrown = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c2'), name: 'boom', arguments: {} }) expect(thrown.isError).toBe(true) expect(thrown.content[0]).toMatchObject({ text: 'Error: exploded' }) }) @@ -725,7 +767,7 @@ describe('ToolRuntime', () => { }, }) - await expect(ctx.tools.execute({ + await expect(executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('hostile'), name: 'hostile-throw', arguments: {}, })).resolves.toMatchObject({ @@ -764,7 +806,7 @@ describe('ToolRuntime', () => { return next() }) - const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('c1'), name: 'echo', arguments: { text: 'hi' } }) + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c1'), name: 'echo', arguments: { text: 'hi' } }) expect(result.isError).toBe(true) expect(result.content[0]).toMatchObject({ text: 'Error: denied by policy' }) expect(result.error).toEqual({ @@ -781,7 +823,7 @@ describe('ToolRuntime', () => { ctx.on('tools/pre-execute', async (_exec, _next): Promise => ({ kind: 'ask', reason: 'needs approval' })) - const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('c1'), name: 'echo', arguments: { text: 'hi' } }) + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c1'), name: 'echo', arguments: { text: 'hi' } }) expect(result.isError).toBe(true) expect(result.content[0]).toMatchObject({ text: 'Error: needs approval' }) }) @@ -792,7 +834,7 @@ describe('ToolRuntime', () => { ctx.on('tools/pre-execute', async (_exec, _next): Promise => ({ kind: 'ask' })) - const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('c1'), name: 'echo', arguments: { text: 'hi' } }) + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c1'), name: 'echo', arguments: { text: 'hi' } }) expect(result.isError).toBe(true) expect(result.content[0]).toMatchObject({ text: 'Error: tool "echo" requires approval (not yet supported)' }) }) @@ -811,6 +853,7 @@ describe('ToolRuntime', () => { await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(AgentRegistry) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(ApprovalService) @@ -830,7 +873,7 @@ describe('ToolRuntime', () => { ctx.on('tools/pre-execute', async (_exec, _next): Promise => ({ kind: 'ask', reason: 'hook wants a human', displayReason: { en: 'Allow it?', zh: '允许吗?' } })) - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { callId: ToolCallId('c1'), name: 'echo', arguments: { text: 'hi' }, agent, signal: controller.signal, }) @@ -847,7 +890,7 @@ describe('ToolRuntime', () => { ctx.on('approval/request', () => Promise.resolve('rejected')) ctx.on('tools/pre-execute', async (_exec, _next): Promise => ({ kind: 'ask' })) - const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('c1'), name: 'echo', arguments: {}, agent: fakeAgent() }) + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c1'), name: 'echo', arguments: {}, agent: fakeAgent() }) expect(result.isError).toBe(true) expect(result.content[0]).toMatchObject({ text: 'Error: the user rejected tool "echo"' }) }) @@ -857,7 +900,7 @@ describe('ToolRuntime', () => { ctx.on('approval/request', () => Promise.resolve('cancelled')) ctx.on('tools/pre-execute', async (_exec, _next): Promise => ({ kind: 'ask' })) - const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('c1'), name: 'echo', arguments: {}, agent: fakeAgent() }) + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c1'), name: 'echo', arguments: {}, agent: fakeAgent() }) expect(result.isError).toBe(true) expect(result.content[0]).toMatchObject({ text: 'Error: approval for tool "echo" was cancelled' }) }) @@ -878,7 +921,7 @@ describe('ToolRuntime', () => { }) ctx.on('tools/pre-execute', async (_exec, _next): Promise => ({ kind: 'ask' })) const controller = new AbortController() - const pending = ctx.tools.execute({ + const pending = executeToolFixture(ctx, { callId: ToolCallId('approval-cancelled'), name: 'approval-probe', arguments: {}, @@ -901,7 +944,7 @@ describe('ToolRuntime', () => { const ctx = await approvalSetup() ctx.on('tools/pre-execute', async (_exec, _next): Promise => ({ kind: 'ask' })) - const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('c1'), name: 'echo', arguments: {}, agent: fakeAgent() }) + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c1'), name: 'echo', arguments: {}, agent: fakeAgent() }) expect(result.isError).toBe(true) expect(result.content[0]).toMatchObject({ text: 'Error: tool "echo" requires approval, but no approval channel is available' }) }) @@ -915,7 +958,7 @@ describe('ToolRuntime', () => { }) ctx.on('tools/pre-execute', async (_exec, _next): Promise => ({ kind: 'ask' })) - const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('c1'), name: 'echo', arguments: {} }) + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c1'), name: 'echo', arguments: {} }) expect(asked).toBe(false) expect(result.isError).toBe(true) expect(result.content[0]).toMatchObject({ text: 'Error: tool "echo" requires approval, but the call has no agent to route it through' }) @@ -930,7 +973,7 @@ describe('ToolRuntime', () => { ctx.provide('approval', { request: () => Promise.resolve('yolo') } as unknown as ApprovalService) ctx.on('tools/pre-execute', async (_exec, _next): Promise => ({ kind: 'ask' })) - const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('c1'), name: 'echo', arguments: {}, agent: fakeAgent() }) + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c1'), name: 'echo', arguments: {}, agent: fakeAgent() }) expect(result.isError).toBe(true) const text = result.content[0]?.type === 'text' ? result.content[0].text : '' expect(text).toContain('unreachable') @@ -944,7 +987,7 @@ describe('ToolRuntime', () => { ctx.on('tools/post-execute', async (_exec, _result, _next): Promise => ({ kind: 'accept', content: [{ type: 'text', text: 'rewritten' }] })) - const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('c1'), name: 'echo', arguments: { text: 'hi' } }) + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c1'), name: 'echo', arguments: { text: 'hi' } }) expect(result.isError).toBe(false) expect(result.content[0]).toMatchObject({ text: 'rewritten' }) }) @@ -956,7 +999,7 @@ describe('ToolRuntime', () => { ctx.on('tools/post-execute', async (_exec, _result, _next): Promise => ({ kind: 'block', feedback: [{ type: 'text', text: 'output rejected: try again' }] })) - const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('c1'), name: 'echo', arguments: { text: 'hi' } }) + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c1'), name: 'echo', arguments: { text: 'hi' } }) expect(result.isError).toBe(true) expect(result.content[0]).toMatchObject({ text: 'output rejected: try again' }) }) @@ -979,7 +1022,7 @@ describe('ToolRuntime', () => { throw new HarnessError('policy failed', 'POLICY_FAILED') }) - const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('bounded'), name: 'bounded', arguments: {} }) + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('bounded'), name: 'bounded', arguments: {} }) expect(result).toEqual({ content: [{ type: 'text', text: 'bounded failure' }], @@ -1003,7 +1046,7 @@ describe('ToolRuntime', () => { }, }) - const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('identity-finalizer'), name: 'identity-finalizer', arguments: {} }) + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('identity-finalizer'), name: 'identity-finalizer', arguments: {} }) expect(result.isError).toBe(false) expect(result.content).toEqual([{ type: 'text', text: '' }]) @@ -1023,7 +1066,7 @@ describe('ToolRuntime', () => { })], })) - const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('c1'), name: 'echo', arguments: { text: 'hi' } }) + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c1'), name: 'echo', arguments: { text: 'hi' } }) expect(result.isError).toBe(true) expect(result.content[0]).toMatchObject({ text: 'rejected' }) expect(result.additionalContexts).toMatchObject([{ content: [{ text: 'why it was rejected' }], source: { kind: 'test' } }]) @@ -1038,7 +1081,7 @@ describe('ToolRuntime', () => { content: [{ type: 'text', text: 'fyi' }], source: { kind: 'test' }, })] })) - const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('c1'), name: 'echo', arguments: { text: 'hi' } }) + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c1'), name: 'echo', arguments: { text: 'hi' } }) expect(result.additionalContexts).toMatchObject([{ content: [{ text: 'fyi' }], source: { kind: 'test' } }]) }) @@ -1083,7 +1126,7 @@ describe('ToolRuntime', () => { } }) - const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('composite'), name: 'composite', arguments: {} }) + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('composite'), name: 'composite', arguments: {} }) expect(result.additionalContexts?.map(context => context.source)).toEqual([ { kind: 'nested-1' }, @@ -1107,7 +1150,7 @@ describe('ToolRuntime', () => { }, })) - const failed = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('failed'), name: 'failing-composite', arguments: {} }) + const failed = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('failed'), name: 'failing-composite', arguments: {} }) expect(failed.isError).toBe(true) expect(failed.additionalContexts?.map(context => context.source)).toEqual([{ kind: 'nested' }]) @@ -1118,7 +1161,7 @@ describe('ToolRuntime', () => { content: [{ type: 'text', text: 'block-only' }], source: { kind: 'blocker' }, })], })) - const blocked = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('blocked'), name: 'failing-composite', arguments: {} }) + const blocked = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('blocked'), name: 'failing-composite', arguments: {} }) expect(blocked.isError).toBe(true) expect(blocked.additionalContexts?.map(context => context.source)).toEqual([{ kind: 'blocker' }]) }) @@ -1141,7 +1184,7 @@ describe('ToolRuntime', () => { return decision }) - const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('c1'), name: 'echo', arguments: { text: 'x' } }) + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c1'), name: 'echo', arguments: { text: 'x' } }) expect(result.isError).toBe(false) // pre runs fully (gate) before dispatch, then post runs over the result. expect(order).toEqual(['pre:before', 'pre:after', 'post:before', 'post:after']) @@ -1169,7 +1212,7 @@ describe('ToolRuntime', () => { }) ctx.on('tools/post-execute', async (_exec, _result, next) => { order.push('post'); return next() }) - const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('c1'), name: 'traced', arguments: { text: 'hi' } }) + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c1'), name: 'traced', arguments: { text: 'hi' } }) expect(result).toEqual({ content: [{ type: 'text', text: 'hi' }], isError: false, value: [{ type: 'text', text: 'hi' }] }) // The around-dispatch extension point wraps dispatch; pre gates before it, post runs over its result. expect(order).toEqual(['pre', 'execute:before', 'dispatch', 'execute:after', 'post']) @@ -1192,7 +1235,7 @@ describe('ToolRuntime', () => { }) const controller = new AbortController() - const pending = ctx.tools.execute({ + const pending = executeToolFixture(ctx, { callId: ToolCallId('cancelled-in-pre'), name: 'must-not-run', arguments: {}, signal: controller.signal, }) await entered.promise @@ -1217,7 +1260,7 @@ describe('ToolRuntime', () => { }) ctx.on('tools/pre-execute', async () => ({ kind: 'cancel' })) - await expect(ctx.tools.execute({ + await expect(executeToolFixture(ctx, { callId: ToolCallId('cancelled-by-policy'), name: 'cancelled-by-policy', arguments: {}, @@ -1249,7 +1292,7 @@ describe('ToolRuntime', () => { return { kind: 'deny', reason: 'policy denied the call' } }) const controller = new AbortController() - const pending = ctx.tools.execute({ + const pending = executeToolFixture(ctx, { callId: ToolCallId('denied-after-cancel'), name: 'denied-after-cancel', arguments: {}, signal: controller.signal, }) @@ -1282,7 +1325,7 @@ describe('ToolRuntime', () => { }) const controller = new AbortController() - const pending = ctx.tools.execute({ + const pending = executeToolFixture(ctx, { callId: ToolCallId('cancelled-pre-error'), name: 'must-not-run', arguments: {}, signal: controller.signal, }) await entered.promise @@ -1321,7 +1364,7 @@ describe('ToolRuntime', () => { }) const controller = new AbortController() - const pending = ctx.tools.execute({ + const pending = executeToolFixture(ctx, { callId: ToolCallId('cancelled-in-around'), name: 'must-not-run', arguments: {}, signal: controller.signal, }) await entered.promise @@ -1355,7 +1398,7 @@ describe('ToolRuntime', () => { }) const controller = new AbortController() - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { callId: ToolCallId('cancelled-wrapper'), name: 'must-not-run', arguments: {}, signal: controller.signal, }) @@ -1390,7 +1433,7 @@ describe('ToolRuntime', () => { } }) const controller = new AbortController() - const pending = ctx.tools.execute({ + const pending = executeToolFixture(ctx, { callId: ToolCallId('cancelled-short-circuit'), name: 'short-circuited', arguments: {}, @@ -1432,7 +1475,7 @@ describe('ToolRuntime', () => { return result }) const controller = new AbortController() - const pending = ctx.tools.execute({ + const pending = executeToolFixture(ctx, { callId: ToolCallId('cancelled-after-body'), name: 'completed-before-wrapper', arguments: {}, signal: controller.signal, }) await entered.promise @@ -1475,7 +1518,7 @@ describe('ToolRuntime', () => { } }) const controller = new AbortController() - const pending = ctx.tools.execute({ + const pending = executeToolFixture(ctx, { callId: ToolCallId('cancelled-in-post'), name: 'completed-before-post', arguments: {}, signal: controller.signal, }) await entered.promise @@ -1509,7 +1552,7 @@ describe('ToolRuntime', () => { throw new HarnessError('wrapper failed', 'WRAPPER_FAILURE') }) const controller = new AbortController() - const pending = ctx.tools.execute({ + const pending = executeToolFixture(ctx, { callId: ToolCallId('wrapper-failure'), name: 'wrapper-failure', arguments: {}, signal: controller.signal, }) @@ -1541,7 +1584,7 @@ describe('ToolRuntime', () => { }, }) const controller = new AbortController() - const pending = ctx.tools.execute({ + const pending = executeToolFixture(ctx, { callId: ToolCallId('tool-failure'), name: 'tool-failure', arguments: {}, signal: controller.signal, }) @@ -1566,7 +1609,7 @@ describe('ToolRuntime', () => { throw new HarnessError('post-policy failed', 'POST_FAILURE') }) const controller = new AbortController() - const pending = ctx.tools.execute({ + const pending = executeToolFixture(ctx, { callId: ToolCallId('post-failure'), name: 'echo', arguments: {}, signal: controller.signal, }) @@ -1609,7 +1652,7 @@ describe('ToolRuntime', () => { }) const controller = new AbortController() - const pending = ctx.tools.execute({ + const pending = executeToolFixture(ctx, { callId: ToolCallId('cancelled-body'), name: 'cooperative', arguments: {}, signal: controller.signal, }) await entered.promise @@ -1643,7 +1686,7 @@ describe('ToolRuntime', () => { }) const controller = new AbortController() - await ctx.tools.execute({ + await executeToolFixture(ctx, { callId: ToolCallId('restored-signal'), name: 'echo', arguments: {}, signal: controller.signal, }) @@ -1667,7 +1710,7 @@ describe('ToolRuntime', () => { }, }) const controller = new AbortController() - const pending = ctx.tools.execute({ + const pending = executeToolFixture(ctx, { callId: ToolCallId('drain-body'), name: 'uncooperative', arguments: {}, signal: controller.signal, }) await entered.promise @@ -1714,7 +1757,7 @@ describe('ToolRuntime', () => { observedResult = result }) - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { callId: ToolCallId('pre-aborted'), name: 'domain-abort', get arguments() { argumentReads += 1; return callerArguments }, @@ -1746,7 +1789,7 @@ describe('ToolRuntime', () => { let observed = 0 ctx.on('tools/result', () => { observed += 1 }) - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { callId: ToolCallId('invalid-pre-aborted'), name: 'missing', arguments: { invalid: () => undefined }, @@ -1761,6 +1804,25 @@ describe('ToolRuntime', () => { expect(observed).toBe(1) }) + it('reports cancellation before an executor refusal without entering policy', async ({ onTestFinished }) => { + const ctx = await setup() + onTestFinished(() => ctx.fiber.dispose()) + let policyCalls = 0 + ctx.on('tools/pre-execute', async (_exec, next) => { policyCalls += 1; return next() }) + const pipeline = new ToolExecutionPipeline(ctx, { + get: () => echoTool, + reject: () => new Error('executor refuses direct calls'), + }) + const prepared = await pipeline.prepare({ + callId: ToolCallId('cancelled-refusal'), name: 'echo', arguments: {}, signal: AbortSignal.abort(), + }) + expect(prepared.kind).toBe('final-result') + if (prepared.kind === 'dispatch') throw new Error('cancelled call was dispatched') + const result = await pipeline.complete(prepared.exec, prepared) + expect(result.error?.info?.code).toBe(TOOL_ABORTED_BEFORE_DISPATCH) + expect(policyCalls).toBe(0) + }) + it('a pre-execute deny short-circuits before tools/execute (the seam never runs)', async () => { const ctx = await setup() ctx.tools.register(echoTool) @@ -1772,7 +1834,7 @@ describe('ToolRuntime', () => { return next() }) - const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('c1'), name: 'echo', arguments: { text: 'hi' } }) + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c1'), name: 'echo', arguments: { text: 'hi' } }) expect(result.isError).toBe(true) expect(result.content[0]).toMatchObject({ text: 'Error: nope' }) expect(entered).toBe(false) // A denied call never enters the around-dispatch extension point. @@ -1795,7 +1857,7 @@ describe('ToolRuntime', () => { return result }) - const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('c1'), name: 'boom', arguments: {} }) + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c1'), name: 'boom', arguments: {} }) expect(seen).toEqual({ isError: true, error: { message: 'kaboom', info: { name: 'HarnessError', code: 'BOOM' } }, @@ -1818,7 +1880,7 @@ describe('ToolRuntime', () => { return next() }) - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('frozen-canonical'), name: 'echo', arguments: { text: 'original' }, }) @@ -1841,7 +1903,7 @@ describe('ToolRuntime', () => { return next() }) - const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('c1'), name: 'boom', arguments: {} }) + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c1'), name: 'boom', arguments: {} }) expect(postSaw).toBe(true) // the normalized isError still flows through post-execute expect(result.isError).toBe(true) expect(result.content[0]).toMatchObject({ text: 'Error: exploded' }) @@ -1869,7 +1931,7 @@ describe('ToolRuntime', () => { return next() }) - await ctx.tools.execute({ callId: ToolCallId('c1'), name: 'signal-probe', arguments: {}, signal: upstream }) + await executeToolFixture(ctx, { callId: ToolCallId('c1'), name: 'signal-probe', arguments: {}, signal: upstream }) expect(seenSignal).toBeDefined() expect(seenSignal).not.toBe(upstream) expect(seenSignal).not.toBe(replacement) @@ -1887,7 +1949,7 @@ describe('ToolRuntime', () => { ctx.on('tools/execute', async (_exec: ToolDispatchExecution, _next: () => Promise): Promise => ({ content: [{ type: 'text', text: 'ignored authored content' }], isError: false, value: 'short-circuited' })) - const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('c1'), name: 'never-runs', arguments: {} }) + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c1'), name: 'never-runs', arguments: {} }) expect(dispatched).toBe(false) // returning without next() skips core dispatch expect(result.content[0]).toMatchObject({ text: 'short-circuited' }) }) @@ -1926,10 +1988,10 @@ describe('ToolRuntime', () => { return next() }) - const first = await ctx.tools.execute({ + const first = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('cached-first'), name: 'string-output', arguments: {}, }) - const second = await ctx.tools.execute({ + const second = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('cached-second'), name: 'object-output', arguments: {}, }) @@ -1954,7 +2016,7 @@ describe('ToolRuntime', () => { })], })) - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('around-context'), name: 'echo', arguments: {}, }) @@ -1971,7 +2033,7 @@ describe('ToolRuntime', () => { ctx.tools.register(echoTool) ctx.on('tools/execute', async () => { throw new Error('wrapper broke') }) - const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('c1'), name: 'echo', arguments: { text: 'hi' } }) + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c1'), name: 'echo', arguments: { text: 'hi' } }) expect(result).toEqual({ content: [{ type: 'text', text: 'Error: wrapper broke' }], error: { message: 'wrapper broke' }, @@ -1986,7 +2048,7 @@ describe('ToolRuntime', () => { throw new Error('permission hook broke') }) - const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('c1'), name: 'echo', arguments: { text: 'hi' } }) + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c1'), name: 'echo', arguments: { text: 'hi' } }) expect(result).toEqual({ content: [{ type: 'text', text: 'Error: permission hook broke' }], @@ -2002,7 +2064,7 @@ describe('ToolRuntime', () => { throw new Error('post hook broke') }) - const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('c1'), name: 'echo', arguments: { text: 'hi' } }) + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c1'), name: 'echo', arguments: { text: 'hi' } }) expect(result).toEqual({ content: [{ type: 'text', text: 'Error: post hook broke' }], @@ -2018,7 +2080,7 @@ describe('ToolRuntime', () => { throw new HarnessError('denied', 'DENIED') }) - const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('c1'), name: 'echo', arguments: { text: 'hi' } }) + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c1'), name: 'echo', arguments: { text: 'hi' } }) expect(result).toMatchObject({ isError: true, @@ -2244,7 +2306,7 @@ describe('defineTool / schema DSL', () => { }, }]) - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c1'), name: 'typed-echo', @@ -2303,7 +2365,7 @@ describe('defineTool / schema DSL', () => { }) // Execution round-trip - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c1'), name: 'roundtrip', @@ -2340,7 +2402,7 @@ describe('defineTool / schema DSL', () => { required: ['path'], }) - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c1'), name: 'raw-tool', @@ -2519,7 +2581,7 @@ describe('schema DSL optional and nested contracts', () => { throw { message: 'denied by object' } }, }) - const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('c1'), name: 'object-thrower', arguments: {} }) + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c1'), name: 'object-thrower', arguments: {} }) expect(result.isError).toBe(true) expect(result.content[0]).toMatchObject({ text: 'Error: denied by object' }) }) @@ -2534,7 +2596,7 @@ describe('schema DSL optional and nested contracts', () => { throw 'kaboom' }, }) - const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('c1'), name: 'string-thrower', arguments: {} }) + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c1'), name: 'string-thrower', arguments: {} }) expect(result.isError).toBe(true) expect(result.content[0]).toMatchObject({ text: 'Error: kaboom' }) }) @@ -2549,7 +2611,7 @@ describe('schema DSL optional and nested contracts', () => { throw { code: 500 } }, }) - const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('c1'), name: 'object-no-message', arguments: {} }) + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c1'), name: 'object-no-message', arguments: {} }) expect(result.isError).toBe(true) const firstContent = result.content[0]! expect(firstContent.type).toBe('text') @@ -2688,7 +2750,7 @@ describe('defineTool validation (the runtime-validation Agent Note, part 1)', () }, })) - const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('c1'), name: 'reader', arguments: {} }) + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c1'), name: 'reader', arguments: {} }) expect(result.isError).toBe(true) expect(result.content[0]).toMatchObject({ text: 'Error: invalid arguments: missing required property "path"', @@ -2705,7 +2767,7 @@ describe('defineTool validation (the runtime-validation Agent Note, part 1)', () return [{ type: 'text', text: `read ${args.path}` }] }, })) - const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('c1'), name: 'reader', arguments: { path: '/x' } }) + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c1'), name: 'reader', arguments: { path: '/x' } }) expect(result).toEqual({ content: [{ type: 'text', text: 'read /x' }], isError: false, @@ -2732,7 +2794,7 @@ describe('defineTool validation (the runtime-validation Agent Note, part 1)', () return [{ type: 'text', text: args.path }] }, })) - const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('c1'), name: 'reader', arguments: {} }) + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c1'), name: 'reader', arguments: {} }) expect(result.isError).toBe(true) expect(result.error).toEqual({ message: 'invalid arguments: missing required property "path"', @@ -2750,7 +2812,7 @@ describe('defineTool validation (the runtime-validation Agent Note, part 1)', () throw new HarnessError('disk full', 'ENOSPC') }, }) - const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('c1'), name: 'coded', arguments: {} }) + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c1'), name: 'coded', arguments: {} }) expect(result.isError).toBe(true) expect(result.error).toEqual({ message: 'disk full', info: { name: 'HarnessError', code: 'ENOSPC' } }) expect(result.content[0]).toMatchObject({ text: 'Error: disk full' }) @@ -2765,7 +2827,7 @@ describe('defineTool validation (the runtime-validation Agent Note, part 1)', () throw new Error('just a message') }, }) - const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('c1'), name: 'plain', arguments: {} }) + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c1'), name: 'plain', arguments: {} }) expect(result.isError).toBe(true) expect(result.error).toEqual({ message: 'just a message' }) expect(result.content[0]).toMatchObject({ text: 'Error: just a message' }) @@ -2788,7 +2850,7 @@ describe('defineTool validation (the runtime-validation Agent Note, part 1)', () }) // Missing the "required" path — but raw tools validate their own input, so // this reaches execute rather than being rejected by the harness. - const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('c1'), name: 'raw', arguments: {} }) + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c1'), name: 'raw', arguments: {} }) expect(result.isError).toBe(false) }) @@ -2893,3 +2955,52 @@ describe('defineTool presentation (presentCall / presentResult)', () => { expect(tool.presentResult?.({ wrong: 1 }, { content: [], isError: false })).toBeUndefined() }) }) + +it('executes raw native calls without an Agent or Session and keeps input order', async () => { + const ctx = await setup() + try { + ctx.tools.register(echoTool) + const batch = await ctx.tools.execute({ + signal: testToolSignal, + calls: [ + { type: 'tool-call', id: ToolCallId('raw'), name: 'echo', arguments: '{ "text": "raw value" }' }, + { type: 'tool-call', id: ToolCallId('malformed'), name: 'echo', arguments: '{invalid' }, + { type: 'tool-call', id: ToolCallId('empty'), name: 'echo', arguments: '' }, + ], + }) + expect(batch.results.map(outcome => outcome.callId)).toEqual(['raw', 'malformed', 'empty']) + expect(batch.results[0]?.result).toMatchObject({ isError: false, value: 'raw value' }) + expect(batch.results[1]?.result.isError).toBe(true) + expect(batch.results[2]?.result).toMatchObject({ isError: false, value: '' }) + expect(batch.additionalContexts).toEqual([]) + expect(batch.concluded).toBe(false) + expect(ctx.get('sessions')).toBeUndefined() + } finally { + await ctx.fiber.dispose() + } +}) + +it('settles every unowned call when cancellation precedes the batch', async () => { + const ctx = await setup() + try { + let invoked = false + ctx.tools.register(defineTool({ + ...echoTool, + parameters: {}, + output: { schema: { type: 'string' }, render: (_args, value) => [{ type: 'text', text: value }] }, + async execute() { invoked = true; return 'unexpected' }, + })) + const abort = new AbortController() + abort.abort() + const batch = await ctx.tools.execute({ + signal: abort.signal, + calls: ['first', 'second'].map(id => ({ type: 'tool-call', id: ToolCallId(id), name: 'echo', arguments: '{}' })), + }) + expect(invoked).toBe(false) + expect(batch.results.map(({ result }) => result.error?.info?.code)).toEqual([ + TOOL_ABORTED_BEFORE_DISPATCH, TOOL_ABORTED_BEFORE_DISPATCH, + ]) + } finally { + await ctx.fiber.dispose() + } +}) diff --git a/packages/core/tools/tsconfig.json b/packages/core/tools/tsconfig.json index 95c7ee1a48..f26682444c 100644 --- a/packages/core/tools/tsconfig.json +++ b/packages/core/tools/tsconfig.json @@ -11,9 +11,6 @@ { "path": "../../core/session" }, - { - "path": "../../ptc-runtime/ptc-runtime" - }, { "path": "../../../vendor/cosmokit" }, @@ -37,15 +34,6 @@ }, { "path": "../../interaction/user-approval" - }, - { - "path": "../../sandbox/sandbox" - }, - { - "path": "../../sandbox/sandbox-policy" - }, - { - "path": "../../session/working-directory" } ] } diff --git a/packages/core/tools/tsdown.config.ts b/packages/core/tools/tsdown.config.ts new file mode 100644 index 0000000000..62fc0d1b77 --- /dev/null +++ b/packages/core/tools/tsdown.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'tsdown' + +export default defineConfig({ + entry: { index: 'lib/types/index.js', pipeline: 'lib/types/pipeline.js' }, + outDir: 'lib', + format: ['esm'], + platform: 'node', + target: 'es2024', + fixedExtension: false, + dts: false, + clean: false, + outputOptions: { chunkFileNames: 'shared-[hash].js' }, +}) diff --git a/packages/deliverables/tool-present/package.json b/packages/deliverables/tool-present/package.json index 4b88f219e4..ba38ec84c8 100644 --- a/packages/deliverables/tool-present/package.json +++ b/packages/deliverables/tool-present/package.json @@ -52,6 +52,8 @@ "@deepseek-ai/dsh-llm": "workspace:*", "@deepseek-ai/dsh-session": "workspace:*", "@deepseek-ai/dsh-session-projection": "workspace:*", + "@deepseek-ai/dsh-tool-executor-native": "workspace:*", + "@deepseek-ai/dsh-tool-testkit": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", "@deepseek-ai/dsh-agent-loop": "workspace:*", "@deepseek-ai/dsh-agent-loop-testkit": "workspace:*", diff --git a/packages/deliverables/tool-present/tests/built-errors.e2e.ts b/packages/deliverables/tool-present/tests/built-errors.e2e.ts index 5ab826ff8b..702924fb69 100644 --- a/packages/deliverables/tool-present/tests/built-errors.e2e.ts +++ b/packages/deliverables/tool-present/tests/built-errors.e2e.ts @@ -16,6 +16,7 @@ import SessionProjectionRegistry from './packages/session/session-projection/lib import LocalFileSystem from './packages/fs/fs-local/lib/index.js' import SystemPrompt from './packages/core/system-prompt/lib/index.js' import ToolRuntime from './packages/core/tools/lib/index.js' +import { executeToolFixture } from './packages/test-support/tool-testkit/lib/index.js' import { Session, SESSION_FORMAT_VERSION } from './packages/core/session/lib/index.js' import * as Present from './packages/deliverables/tool-present/lib/index.js' import { mkdtemp, rm } from 'node:fs/promises' @@ -40,7 +41,7 @@ try { ctx.on('tools/result', (_exec, result) => events.push(result)) let n = 0 for (const [files, code] of [[[{ path: 'missing' }], 'FS_NOT_FOUND'], [[{ path: 42 }], 'INVALID_ARGS']]) { - const result = await ctx.tools.execute({ signal: new AbortController().signal, name: 'present', callId: 'call-' + (++n), arguments: { files }, agent: owner }) + const result = await executeToolFixture(ctx, { signal: new AbortController().signal, name: 'present', callId: 'call-' + (++n), arguments: { files }, agent: owner }) assert.equal(result.isError, true) assert.equal(result.error.info.code, code) assert.equal(events.at(-1).error.info.code, code) diff --git a/packages/deliverables/tool-present/tests/present.spec.ts b/packages/deliverables/tool-present/tests/present.spec.ts index c175c39ae8..18da272027 100644 --- a/packages/deliverables/tool-present/tests/present.spec.ts +++ b/packages/deliverables/tool-present/tests/present.spec.ts @@ -1,4 +1,6 @@ /** Explicit deliveries commit only after a successful final tool result. */ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { unsupportedModel, provideWorkingDirectoryFixture, unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit' import { mkdtemp, rm, writeFile, symlink, realpath } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -60,13 +62,14 @@ async function setup() { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(AgentRegistry) await ctx.plugin(LocalFileSystem, { cwd: root }) const fiber = ctx.plugin(Present, { maxFiles: 2 }) await fiber const owner = await agent(ctx, root) owner.session.append('turn/start', { turn: 1 }) - const execute = (files: unknown) => ctx.tools.execute({ + const execute = (files: unknown) => executeToolFixture(ctx, { signal: new AbortController().signal, callId: ToolCallId(`call-${++callNumber}`), name: 'present', arguments: { files }, agent: owner, }) @@ -159,13 +162,13 @@ it('validates deployment limits before registering the tool', async () => { it('requires an agent, an open turn, and a workspace', async () => { const { ctx, owner, execute } = await setup() - const detached = await ctx.tools.execute({ signal: new AbortController().signal, callId: ToolCallId('detached'), name: 'present', arguments: { files: [{ path: 'a' }] } }) + const detached = await executeToolFixture(ctx, { signal: new AbortController().signal, callId: ToolCallId('detached'), name: 'present', arguments: { files: [{ path: 'a' }] } }) expect(detached.isError).toBe(true) owner.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) expect((await execute([{ path: 'a' }])).isError).toBe(true) const noWorkspace = await agent(ctx, undefined) noWorkspace.session.append('turn/start', { turn: 1 }) - const absent = await ctx.tools.execute({ signal: new AbortController().signal, callId: ToolCallId('no-workspace'), name: 'present', arguments: { files: [{ path: 'a' }] }, agent: noWorkspace }) + const absent = await executeToolFixture(ctx, { signal: new AbortController().signal, callId: ToolCallId('no-workspace'), name: 'present', arguments: { files: [{ path: 'a' }] }, agent: noWorkspace }) expect(absent.isError).toBe(true) }) diff --git a/packages/experimental/auto-review/README.md b/packages/experimental/auto-review/README.md index dc961fff6d..273c40ce5f 100644 --- a/packages/experimental/auto-review/README.md +++ b/packages/experimental/auto-review/README.md @@ -50,7 +50,7 @@ Enable the experimental layer explicitly for the headless profile: ```sh dsh plugin --profile headless add @deepseek-ai/dsh-experimental-auto-review -DSH_TOOLS_MODE=native dsh headless --permission-mode auto --approval-policy never --json - < task.txt > run.jsonl +dsh headless --permission-mode auto --approval-policy never --json - < task.txt > run.jsonl ``` For a one-off run from this checkout, pass `--patch /absolute/path/to/packages/experimental/auto-review/cordis.patch.yml` instead of installing a profile layer. An unavailable Auto integration fails preset selection before reading stdin or starting model work. Auto uses full host access; `--add-dir` is unnecessary in this mode. Native tools keep every tool body on the review path; direct Node effects in PTC remain outside it. diff --git a/packages/experimental/auto-review/README.zh.md b/packages/experimental/auto-review/README.zh.md index 5b49175cc9..a7893291e6 100644 --- a/packages/experimental/auto-review/README.zh.md +++ b/packages/experimental/auto-review/README.zh.md @@ -50,7 +50,7 @@ pnpm dsh plugin --profile web remove @deepseek-ai/dsh-experimental-auto-review ```sh dsh plugin --profile headless add @deepseek-ai/dsh-experimental-auto-review -DSH_TOOLS_MODE=native dsh headless --permission-mode auto --approval-policy never --json - < task.txt > run.jsonl +dsh headless --permission-mode auto --approval-policy never --json - < task.txt > run.jsonl ``` 如需从此 checkout 单次运行,可传入 `--patch /absolute/path/to/packages/experimental/auto-review/cordis.patch.yml`,无需安装 profile 层。Auto integration 不可用时,权限预设选择会在读取 stdin 或开始模型工作前失败。Auto 使用完全主机访问权限;此模式不需要 `--add-dir`。原生工具让每个工具 body 都经过审查;PTC 的直接 Node 效果仍不在审查范围内。 diff --git a/packages/experimental/auto-review/package.json b/packages/experimental/auto-review/package.json index 0aae1e64c6..f24edf48cb 100644 --- a/packages/experimental/auto-review/package.json +++ b/packages/experimental/auto-review/package.json @@ -68,8 +68,11 @@ "@deepseek-ai/dsh-subprocess-local": "workspace:*", "@deepseek-ai/dsh-system-prompt": "workspace:*", "@deepseek-ai/dsh-tool-bash": "workspace:*", + "@deepseek-ai/dsh-tool-executor-native": "workspace:*", + "@deepseek-ai/dsh-tool-executor-ptc": "workspace:*", "@deepseek-ai/dsh-tool-fs": "workspace:*", "@deepseek-ai/dsh-tool-subagent": "workspace:*", + "@deepseek-ai/dsh-tool-testkit": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", "@deepseek-ai/dsh-user-approval": "workspace:*", "@deepseek-ai/cordis": "workspace:~", diff --git a/packages/experimental/auto-review/src/index.ts b/packages/experimental/auto-review/src/index.ts index de2134ffd4..f612447720 100644 --- a/packages/experimental/auto-review/src/index.ts +++ b/packages/experimental/auto-review/src/index.ts @@ -8,6 +8,7 @@ * @module @deepseek-ai/dsh-experimental-auto-review */ +import type {} from '@deepseek-ai/dsh-tool-executor-ptc/types' import type {} from '@deepseek-ai/dsh-working-directory' import { FiberState, type Context } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' diff --git a/packages/experimental/auto-review/tests/auto-review.e2e.ts b/packages/experimental/auto-review/tests/auto-review.e2e.ts index 04f9c613db..8fe8ac3924 100644 --- a/packages/experimental/auto-review/tests/auto-review.e2e.ts +++ b/packages/experimental/auto-review/tests/auto-review.e2e.ts @@ -1,3 +1,4 @@ +import * as PtcToolExecutor from '@deepseek-ai/dsh-tool-executor-ptc' import { randomUUID } from 'node:crypto' import { provideWorkingDirectoryFixture, mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' @@ -314,7 +315,7 @@ it('certifies eight Auto risk/authorization cases with zero retries and zero ski sessionId: SessionId(randomUUID()), meta: { cwd: workspace }, agentOptions: { provider: PROVIDER, model }, }) try { - if (path === 'ptc-inner') handle.agent.ctx.tools.presentAs('ptc') + if (path === 'ptc-inner') await handle.agent.ctx.plugin(PtcToolExecutor) selectFinalAuto(ctx, handle.agent.session) const command = `rm -- ${quote(target)}` await runCase(handle.agent, 'M01-unauthorized', path, command, @@ -375,7 +376,7 @@ it.each(['native', 'ptc-inner'] as const)('feeds denial back, re-reviews a new c sessionId: SessionId(randomUUID()), meta: { cwd: root }, agentOptions: { provider: PROVIDER, model: FLASH }, }) try { - if (path === 'ptc-inner') handle.agent.ctx.tools.presentAs('ptc') + if (path === 'ptc-inner') await handle.agent.ctx.plugin(PtcToolExecutor) selectFinalAuto(ctx, handle.agent.session) const rawReason = ` TEST_ONLY_SECRET_${'x'.repeat(16_384)}\nexact deletion was not authorized ` for (let attempt = 0; attempt < 2; attempt += 1) { diff --git a/packages/experimental/auto-review/tests/auto-review.spec.ts b/packages/experimental/auto-review/tests/auto-review.spec.ts index 3cef848ac8..035ae3c923 100644 --- a/packages/experimental/auto-review/tests/auto-review.spec.ts +++ b/packages/experimental/auto-review/tests/auto-review.spec.ts @@ -1,3 +1,5 @@ +import { executeToolFixture, executeModelToolFixture } from '@deepseek-ai/dsh-tool-testkit' +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { externalTestParent } from '../../../subagent/subagent/tests/external-activation-helpers.ts' import { afterEach, describe, expect, it, vi } from 'vitest' import { provideWorkingDirectoryFixture } from '@deepseek-ai/dsh-agent-loop-testkit' @@ -36,13 +38,8 @@ import SubagentRuntime, { import type {} from '@deepseek-ai/dsh-shell' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import * as ToolSubagent from '@deepseek-ai/dsh-tool-subagent' -import ToolRuntime, { - defineContentToolFixture, - RUN_CODE_NAME, - TOOL_ABORTED_BEFORE_DISPATCH, - type PreToolDecision, - type ToolExecutionToken, -} from '@deepseek-ai/dsh-tools' +import ToolRuntime, { RUN_CODE_NAME, TOOL_ABORTED_BEFORE_DISPATCH, type PreToolDecision, type ToolExecutionToken } from '@deepseek-ai/dsh-tools' +import { defineContentToolFixture } from '@deepseek-ai/dsh-tool-testkit' import ApprovalService, { setApprovalPolicy, type ApprovalOutcome } from '@deepseek-ai/dsh-user-approval' import * as AutoReview from '@deepseek-ai/dsh-experimental-auto-review' @@ -148,6 +145,7 @@ async function harness( yield (await ctx.plugin(AgentRegistry)).dispose yield (await ctx.plugin(SystemPrompt, {})).dispose yield (await ctx.plugin(ToolRuntime)).dispose + yield (await ctx.plugin(NativeToolExecutor)).dispose yield ctx.provide('shell', { sandboxMode: 'workspace-write', resolve() { throw new Error('auto-review tests do not execute shell requests') }, @@ -273,7 +271,7 @@ function requestSections(request: GenerateOptions): Record { } } -function expectReviewFailure(result: Awaited>, cause?: string): void { +function expectReviewFailure(result: Awaited>, cause?: string): void { if (!result.isError) throw new Error('the reviewed call executed') expect(result.error.info).toEqual({ name: 'AutoReviewFailedError', code: 'AUTO_REVIEW_FAILED' }) const prefix = 'Auto review of tool "probe" failed; its body was not executed: auto-review: ' @@ -399,7 +397,7 @@ describe('native review request', () => { arguments: { path: 'second-ptc-history' }, }) - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { signal: new AbortController().signal, callId: currentCallId, name: 'probe', @@ -530,7 +528,7 @@ describe('native review request', () => { ], 1, 2) appendNativeCall(session, callId, 'probe', '{"path":"current"}', 1, 2) - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { signal: new AbortController().signal, callId, name: 'probe', @@ -603,7 +601,7 @@ describe('native review request', () => { appendAssistant(session, [{ type: 'tool-call', id: callId, name: 'probe', arguments: '{}' }]) appendNativeCall(session, callId, 'probe', '{}') - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { signal: new AbortController().signal, callId, name: 'probe', @@ -642,7 +640,7 @@ describe('native review request', () => { type: 'tool-call', id: incompleteCallId, name: 'probe', arguments: '{}', }]) appendNativeCall(incomplete, incompleteCallId, 'probe', '{}') - await ctx.tools.execute({ + await executeToolFixture(ctx, { signal: new AbortController().signal, callId: incompleteCallId, name: 'probe', @@ -695,7 +693,7 @@ describe('native review request', () => { appendAssistant(session, [{ type: 'tool-call', id: callId, name: 'probe', arguments: '{}' }]) appendNativeCall(session, callId, 'probe', '{}') - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { signal: new AbortController().signal, callId, name: 'probe', arguments: {}, agent: agentFor(session), }) expect(result.isError).toBe(true) @@ -726,7 +724,7 @@ describe('native review request', () => { const callId = ToolCallId('unverified-creation-call') appendAssistant(session, [{ type: 'tool-call', id: callId, name: 'probe', arguments: '{}' }]) appendNativeCall(session, callId, 'probe', '{}') - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { signal: new AbortController().signal, callId, name: 'probe', arguments: {}, agent: agentFor(session), }) expect(result.isError).toBe(true) @@ -759,7 +757,7 @@ describe('native review request', () => { appendAssistant(session, [{ type: 'tool-call', id: callId, name: 'probe', arguments: '{}' }]) appendNativeCall(session, callId, 'probe', '{}') - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { signal: new AbortController().signal, callId, name: 'probe', @@ -794,7 +792,7 @@ describe('native review request', () => { const firstId = ToolCallId('append-only-first') appendAssistant(session, [{ type: 'tool-call', id: firstId, name: 'probe', arguments: '{"path":"a"}' }]) appendNativeCall(session, firstId, 'probe', '{"path":"a"}') - await ctx.tools.execute({ + await executeToolFixture(ctx, { signal: new AbortController().signal, callId: firstId, name: 'probe', arguments: { path: 'a' }, agent, }) @@ -803,7 +801,7 @@ describe('native review request', () => { const secondId = ToolCallId('append-only-second') appendAssistant(session, [{ type: 'tool-call', id: secondId, name: 'probe', arguments: '{"path":"b"}' }], 1, 2) appendNativeCall(session, secondId, 'probe', '{"path":"b"}', 1, 2) - await ctx.tools.execute({ + await executeToolFixture(ctx, { signal: new AbortController().signal, callId: secondId, name: 'probe', arguments: { path: 'b' }, agent, }) @@ -853,7 +851,7 @@ describe('native review request', () => { appendAssistant(session, [{ type: 'tool-call', id: callId, name: 'probe', arguments: '{}' }]) appendNativeCall(session, callId, 'probe', '{}') - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { signal: new AbortController().signal, callId, name: 'probe', @@ -884,7 +882,7 @@ describe('native review request', () => { const emptyId = ToolCallId('empty-arguments') appendAssistant(session, [{ type: 'tool-call', id: emptyId, name: 'probe', arguments: '' }]) appendNativeCall(session, emptyId, 'probe', '') - await ctx.tools.execute({ + await executeToolFixture(ctx, { signal: new AbortController().signal, callId: emptyId, name: 'probe', @@ -896,7 +894,7 @@ describe('native review request', () => { session.append('step/end', { turn: 1, step: 1 }) appendAssistant(session, [{ type: 'tool-call', id: invalidId, name: 'probe', arguments: 'not-json' }], 1, 2) appendNativeCall(session, invalidId, 'probe', 'not-json', 1, 2) - await ctx.tools.execute({ + await executeToolFixture(ctx, { signal: new AbortController().signal, callId: invalidId, name: 'probe', @@ -952,7 +950,7 @@ describe('native review request', () => { const callId = ToolCallId(`legal-${item.id}-call`) appendAssistant(session, [{ type: 'tool-call', id: callId, name: 'probe', arguments: '{}' }]) appendNativeCall(session, callId, 'probe', '{}') - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { signal: new AbortController().signal, callId, name: 'probe', @@ -993,7 +991,7 @@ describe('native review request', () => { appendAssistant(session, [{ type: 'tool-call', id: callId, name: 'probe', arguments: '{}' }]) appendNativeCall(session, callId, 'probe', '{}') - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { signal: new AbortController().signal, callId, name: 'probe', arguments: {}, agent, }) @@ -1020,7 +1018,7 @@ describe('native review request', () => { appendAssistant(session, [{ type: 'tool-call', id: callId, name: 'probe', arguments: '{}' }]) appendNativeCall(session, callId, 'probe', '{}') - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { signal: new AbortController().signal, callId, name: 'probe', arguments: {}, agent, }) @@ -1116,7 +1114,7 @@ describe('native review request', () => { const callId = ToolCallId(`${id}-call`) appendAssistant(session, [{ type: 'tool-call', id: callId, name: 'probe', arguments: '{}' }]) appendNativeCall(session, callId, 'probe', '{}') - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { signal: new AbortController().signal, callId, name: 'probe', @@ -1149,7 +1147,7 @@ describe('native review request', () => { appendAssistant(session, [{ type: 'tool-call', id: callId, name: 'probe', arguments: '{}' }]) appendNativeCall(session, callId, 'probe', '{}') - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { signal: new AbortController().signal, callId, name: 'probe', arguments: {}, agent, }) @@ -1191,7 +1189,7 @@ describe('PTC and bypass semantics', () => { arguments: { path: 'inside' }, }) - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { signal: new AbortController().signal, rootCallId: outerCallId, parent: Symbol('outer execution') as ToolExecutionToken, @@ -1286,7 +1284,7 @@ describe('PTC and bypass semantics', () => { arguments: { path: 'current' }, }) - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { signal: new AbortController().signal, rootCallId: outerCallId, parent: Symbol('outer execution') as ToolExecutionToken, @@ -1325,20 +1323,20 @@ describe('PTC and bypass semantics', () => { const ordinaryAgent = agentFor(ordinary) const auto = autoSession(ctx, 'outer-run-code') - await expect(ctx.tools.execute({ + await expect(executeToolFixture(ctx, { signal: new AbortController().signal, callId: ToolCallId('without-agent'), name: 'probe', arguments: {}, })).resolves.toMatchObject({ isError: false }) - await expect(ctx.tools.execute({ + await expect(executeToolFixture(ctx, { signal: new AbortController().signal, callId: ToolCallId('ordinary'), name: 'probe', arguments: {}, agent: ordinaryAgent, })).resolves.toMatchObject({ isError: false }) - await expect(ctx.tools.execute({ + await expect(executeModelToolFixture(ctx, { signal: new AbortController().signal, callId: ToolCallId('outer-run-code'), name: RUN_CODE_NAME, @@ -1410,7 +1408,7 @@ describe('out-of-process delegation boundary', () => { type: 'tool-call', id: deniedId, name: 'delegate_remote', arguments: rawArgs, }]) appendNativeCall(session, deniedId, 'delegate_remote', rawArgs) - const denied = await ctx.tools.execute({ + const denied = await executeToolFixture(ctx, { signal: new AbortController().signal, callId: deniedId, name: 'delegate_remote', @@ -1435,7 +1433,7 @@ describe('out-of-process delegation boundary', () => { type: 'tool-call', id: allowedId, name: 'delegate_remote', arguments: rawArgs, }], 2, 1) appendNativeCall(session, allowedId, 'delegate_remote', rawArgs, 2, 1) - const allowed = await ctx.tools.execute({ + const allowed = await executeToolFixture(ctx, { signal: new AbortController().signal, callId: allowedId, name: 'delegate_remote', @@ -1509,7 +1507,7 @@ describe('cancellation and integration teardown', () => { const callId = ToolCallId(`shutdown-${outcome}-call`) appendAssistant(session, [{ type: 'tool-call', id: callId, name: 'probe', arguments: '{}' }]) appendNativeCall(session, callId, 'probe', '{}') - const pending = ctx.tools.execute({ + const pending = executeToolFixture(ctx, { signal: new AbortController().signal, callId, name: 'probe', arguments: {}, agent, }) await entered.promise @@ -1567,7 +1565,7 @@ describe('cancellation and integration teardown', () => { appendNativeCall(session, callId, 'probe', '{}') const controller = new AbortController() - const pending = ctx.tools.execute({ signal: controller.signal, callId, name: 'probe', arguments: {}, agent }) + const pending = executeToolFixture(ctx, { signal: controller.signal, callId, name: 'probe', arguments: {}, agent }) await entered.promise controller.abort(new Error('caller stopped')) expect(adapter.requests[0]?.signal?.aborted).toBe(true) @@ -1596,7 +1594,7 @@ describe('cancellation and integration teardown', () => { const callId = ToolCallId(`dispose-${outcome}-call`) appendAssistant(session, [{ type: 'tool-call', id: callId, name: 'probe', arguments: '{}' }]) appendNativeCall(session, callId, 'probe', '{}') - const pending = ctx.tools.execute({ + const pending = executeToolFixture(ctx, { signal: new AbortController().signal, callId, name: 'probe', arguments: {}, agent, }) await entered.promise @@ -1606,7 +1604,7 @@ describe('cancellation and integration teardown', () => { expect(ctx.permissionPresets.current(session)).toBe('danger-full-access') expect(ctx.permissionPresets.names).toContain(AUTO_PRESET) - const afterClose = await ctx.tools.execute({ + const afterClose = await executeToolFixture(ctx, { signal: new AbortController().signal, callId: ToolCallId(`after-close-${outcome}`), name: 'probe', @@ -1641,7 +1639,7 @@ describe('cancellation and integration teardown', () => { appendNativeCall(second.session, callId, 'probe', '{}') let observedPreset: string | undefined let selectionFailure: unknown - let competingCall: ReturnType | undefined + let competingCall: ReturnType | undefined ctx.on('session/event', (session, event) => { if (session !== first.session || event.type !== 'permission/preset' || event.data.preset !== 'danger-full-access') return @@ -1651,7 +1649,7 @@ describe('cancellation and integration teardown', () => { } catch (error) { selectionFailure = error } - competingCall = ctx.tools.execute({ + competingCall = executeToolFixture(ctx, { signal: new AbortController().signal, callId, name: 'probe', @@ -1693,7 +1691,7 @@ describe('cancellation and integration teardown', () => { const callId = ToolCallId('dispose-downstream-call') appendAssistant(session, [{ type: 'tool-call', id: callId, name: 'probe', arguments: '{}' }]) appendNativeCall(session, callId, 'probe', '{}') - const pending = ctx.tools.execute({ + const pending = executeToolFixture(ctx, { signal: new AbortController().signal, callId, name: 'probe', arguments: {}, agent, }) await downstreamEntered.promise @@ -1728,7 +1726,7 @@ describe('cancellation and integration teardown', () => { appendAssistant(session, [{ type: 'tool-call', id: callId, name: 'probe', arguments: '{}' }]) appendNativeCall(session, callId, 'probe', '{}') - await expect(ctx.tools.execute({ + await expect(executeToolFixture(ctx, { signal: controller.signal, callId, name: 'probe', arguments: {}, agent, })).resolves.toMatchObject({ isError: true, @@ -1762,6 +1760,7 @@ describe('cancellation and integration teardown', () => { await invalid.plugin(AgentRegistry) await invalid.plugin(SystemPrompt, {}) await invalid.plugin(ToolRuntime) + await invalid.plugin(NativeToolExecutor) invalid.provide('shell', { sandboxMode: 'workspace-write', resolve() { throw new Error('unused') }, @@ -1919,7 +1918,7 @@ describe('logged-fact failures', () => { const { session, agent } = autoSession(ctx, id) const callId = ToolCallId(`${id}-call`) prepare(session, callId) - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { signal: new AbortController().signal, callId, name: 'probe', @@ -1942,7 +1941,7 @@ describe('logged-fact failures', () => { const callId = ToolCallId('missing-cwd-call') appendAssistant(session, [{ type: 'tool-call', id: callId, name: 'probe', arguments: '{}' }]) appendNativeCall(session, callId, 'probe', '{}') - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { signal: new AbortController().signal, callId, name: 'probe', @@ -2051,7 +2050,7 @@ describe('logged-fact failures', () => { session.append('step/start', { turn: 1, step: 1 }) } item.starts(session, outer, inner) - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { signal: new AbortController().signal, rootCallId: item.execute?.root ?? outer, parent: Symbol('parent') as ToolExecutionToken, diff --git a/packages/experimental/auto-review/tsconfig.json b/packages/experimental/auto-review/tsconfig.json index efbd2586aa..c1573e29d0 100644 --- a/packages/experimental/auto-review/tsconfig.json +++ b/packages/experimental/auto-review/tsconfig.json @@ -46,6 +46,9 @@ }, { "path": "../../session/working-directory" + }, + { + "path": "../../core/tool-executor-ptc" } ] } diff --git a/packages/experimental/browser-use-runtime/package.json b/packages/experimental/browser-use-runtime/package.json index 8d2888fff0..634de5372f 100644 --- a/packages/experimental/browser-use-runtime/package.json +++ b/packages/experimental/browser-use-runtime/package.json @@ -50,6 +50,9 @@ "@deepseek-ai/dsh-browser-use": "workspace:*", "@deepseek-ai/dsh-mcp-client": "workspace:*", "@deepseek-ai/dsh-scope": "workspace:*", + "@deepseek-ai/dsh-tool-executor-native": "workspace:*", + "@deepseek-ai/dsh-tool-executor-ptc": "workspace:*", + "@deepseek-ai/dsh-tool-testkit": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", "@deepseek-ai/dsh-agent-loop-testkit": "workspace:*", "@deepseek-ai/dsh-agent-loop": "workspace:*", diff --git a/packages/experimental/browser-use-runtime/tests/mcp-upstream.ts b/packages/experimental/browser-use-runtime/tests/mcp-upstream.ts index 003ea14822..be8fd7ead9 100644 --- a/packages/experimental/browser-use-runtime/tests/mcp-upstream.ts +++ b/packages/experimental/browser-use-runtime/tests/mcp-upstream.ts @@ -1,4 +1,6 @@ /** Real upstream MCP browser checks against private loopback pages and disposable Chromium. */ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { spawn } from 'node:child_process' import { once } from 'node:events' import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' @@ -58,7 +60,7 @@ export async function verifyMcpBrowser( browserConfig = { mode: 'attach', endpoint: `http://127.0.0.1:${port}` } } const modules = new Map([ - ['browserUse', BrowserUse], ['prompt', SystemPrompt], ['tools', Tools], ['llm', Llm], + ['browserUse', BrowserUse], ['prompt', SystemPrompt], ['tools', Tools], ['toolExecutor', NativeToolExecutor], ['llm', Llm], ['sessions', Sessions], ['agents', Agents], ['loop', AgentLoop], ['projections', Projections], ['browser', provider], ['fs', LocalFileSystem], ['workingDirectory', WorkingDirectory], ]) @@ -82,7 +84,7 @@ export async function verifyMcpBrowser( const schemas = ctx.tools.schemas(owner.agent) expect(schemas.length).toBeGreaterThan(5) expect(schemas.every(tool => tool.name.startsWith(`mcp__${namespace}__`))).toBe(true) - const result = await ctx.tools.execute({ agent: owner.agent, name: `mcp__${namespace}__${navigate.name}`, arguments: navigate.arguments(url), callId: ToolCallId('navigate'), signal: new AbortController().signal }) + const result = await executeToolFixture(ctx, { agent: owner.agent, name: `mcp__${namespace}__${navigate.name}`, arguments: navigate.arguments(url), callId: ToolCallId('navigate'), signal: new AbortController().signal }) expect(result.isError, JSON.stringify(result.content)).toBe(false) expect(observed.has(`/${namespace}-${mode}`)).toBe(true) expect(result.content.length).toBeGreaterThan(0) diff --git a/packages/experimental/browser-use-runtime/tests/mcp.spec.ts b/packages/experimental/browser-use-runtime/tests/mcp.spec.ts index fdb9a6a749..5feec06e44 100644 --- a/packages/experimental/browser-use-runtime/tests/mcp.spec.ts +++ b/packages/experimental/browser-use-runtime/tests/mcp.spec.ts @@ -1,3 +1,6 @@ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' +import * as PtcToolExecutor from '@deepseek-ai/dsh-tool-executor-ptc' import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -69,7 +72,7 @@ async function load(exclusive = false, mode?: string, toolCallTimeoutMs?: number roots.push(root) const model = new FixtureModel() const modules = new Map([ - ['browserUse', BrowserUse], ['prompt', SystemPrompt], ['tools', Tools], ['llm', Llm], + ['browserUse', BrowserUse], ['prompt', SystemPrompt], ['tools', Tools], ['toolExecutor', NativeToolExecutor], ['llm', Llm], ['sessions', Sessions], ['agents', Agents], ['loop', AgentLoop], ['projections', Projections], ['fs', LocalFileSystem], ['workingDirectory', WorkingDirectory], ['model', { inject: ['llm'], apply(ctx: Context) { ctx.effect(() => ctx.llm.registerAdapter(['fixture'], model)) } }], @@ -103,11 +106,11 @@ async function warm(ctx: Context, agent: Agent, signal = new AbortController().s } function execute(ctx: Context, agent: Agent, name = TOOL) { - return ctx.tools.execute({ agent, name, arguments: name === TOOL ? { label: 'direct' } : {}, callId: ToolCallId('direct'), signal: new AbortController().signal }) + return executeToolFixture(ctx, { agent, name, arguments: name === TOOL ? { label: 'direct' } : {}, callId: ToolCallId('direct'), signal: new AbortController().signal }) } function resource(ctx: Context, agent: Agent | undefined, name = 'read_mcp_resource', server = 'browser-fixture', callId = name) { - return ctx.tools.execute({ + return executeToolFixture(ctx, { ...agent === undefined ? {} : { agent }, name, arguments: { server, ...name === 'read_mcp_resource' ? { uri: 'browser-fixture://state' } : {} }, callId: ToolCallId(callId), signal: new AbortController().signal, @@ -351,7 +354,7 @@ describe('Session MCP Loader composition', () => { expect((await resource(ctx, child.agent, 'read_mcp_resource', 'docs')).value) .toEqual({ contents: [{ uri: 'docs://memo', text: 'Independent document.' }] }) for (const args of [null, 'invalid arguments']) { - expect((await ctx.tools.execute({ agent: child.agent, name: 'read_mcp_resource', arguments: args, callId: ToolCallId('invalid-resource'), signal: new AbortController().signal })).isError).toBe(true) + expect((await executeToolFixture(ctx, { agent: child.agent, name: 'read_mcp_resource', arguments: args, callId: ToolCallId('invalid-resource'), signal: new AbortController().signal })).isError).toBe(true) } expect(browserState(await resource(ctx, parent.agent)).counter).toBe(0) await parent.dispose() @@ -424,7 +427,7 @@ describe('Session MCP Loader composition', () => { sessionId: SessionId('ptc-first-request'), agentOptions: { provider: 'fixture', model: 'fixture' }, setup: (inner, agent) => { initializing = agent - inner.tools.presentAs('ptc') + inner.plugin(PtcToolExecutor) inner.on('agent/created', () => { agent.followup(createUserMessage({ content: [{ type: 'text', text: 'Describe the available browser tools.' }], source: { kind: 'user' } })) }, { prepend: true }) diff --git a/packages/experimental/browser-use-stagehand-native/package.json b/packages/experimental/browser-use-stagehand-native/package.json index 9066afb30d..4805d55e82 100644 --- a/packages/experimental/browser-use-stagehand-native/package.json +++ b/packages/experimental/browser-use-stagehand-native/package.json @@ -64,6 +64,8 @@ "@deepseek-ai/dsh-session": "workspace:*", "@deepseek-ai/dsh-session-projection": "workspace:*", "@deepseek-ai/dsh-system-prompt": "workspace:*", + "@deepseek-ai/dsh-tool-executor-native": "workspace:*", + "@deepseek-ai/dsh-tool-testkit": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", "tsx": "^4.23.13" } diff --git a/packages/experimental/browser-use-stagehand-native/tests/fixtures/built-attachment.mjs b/packages/experimental/browser-use-stagehand-native/tests/fixtures/built-attachment.mjs index 7ed3e7e07c..5ddbe44748 100644 --- a/packages/experimental/browser-use-stagehand-native/tests/fixtures/built-attachment.mjs +++ b/packages/experimental/browser-use-stagehand-native/tests/fixtures/built-attachment.mjs @@ -4,6 +4,7 @@ import { mountAgentLoopTestDependencies, mountAgentLoopTestHarness } from '@deep import BrowserUseRegistry from '@deepseek-ai/dsh-browser-use' import { ToolCallId } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' +import { executeModelToolFixture } from '@deepseek-ai/dsh-tool-testkit' import * as Provider from '../../lib/index.js' const [endpoint, url] = process.argv.slice(2) @@ -19,7 +20,7 @@ try { const harness = await mountAgentLoopTestHarness(ctx) const agent = await harness.create(SessionId('built-stagehand')) const call = async (method, args) => { - const result = await ctx.tools.execute({ agent, name: `stagehand_${method}`, arguments: args, callId: ToolCallId(`built-${method}`), signal: new AbortController().signal }) + const result = await executeModelToolFixture(ctx, { agent, name: `stagehand_${method}`, arguments: args, callId: ToolCallId(`built-${method}`), signal: new AbortController().signal }) if (result.isError) throw new Error(JSON.stringify(result.content)) return result } diff --git a/packages/experimental/browser-use-stagehand-native/tests/loader-composition.spec.ts b/packages/experimental/browser-use-stagehand-native/tests/loader-composition.spec.ts index 94479f9c8b..d85c08e565 100644 --- a/packages/experimental/browser-use-stagehand-native/tests/loader-composition.spec.ts +++ b/packages/experimental/browser-use-stagehand-native/tests/loader-composition.spec.ts @@ -1,5 +1,6 @@ /** A real Loader and AgentLoop exercise independently configured browser inference and durable screenshots. */ +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -81,6 +82,7 @@ it('loads browser tools from cordis.yml, logs browser results, and admits the sc ['@deepseek-ai/dsh-system-prompt', SystemPrompt], ['@deepseek-ai/dsh-tools', ToolRuntime], + ['@deepseek-ai/dsh-tool-executor-native', NativeToolExecutor], ['@deepseek-ai/dsh-agent', AgentRegistry], ['@deepseek-ai/dsh-agent-loop', AgentLoop], ['@deepseek-ai/dsh-attachment-local', LocalAttachmentStore], diff --git a/packages/experimental/browser-use-stagehand-native/tests/native.e2e.ts b/packages/experimental/browser-use-stagehand-native/tests/native.e2e.ts index 99ed0a1498..ce6226b1a9 100644 --- a/packages/experimental/browser-use-stagehand-native/tests/native.e2e.ts +++ b/packages/experimental/browser-use-stagehand-native/tests/native.e2e.ts @@ -1,4 +1,5 @@ /** Opt-in installed Stagehand smoke against an owned local page and optional real model. */ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' import { createServer } from 'node:http' import { execFile, spawn } from 'node:child_process' @@ -49,7 +50,7 @@ it.skipIf(process.env.DSH_STAGEHAND_E2E !== '1')('launches and attaches to insta ctx.llm.registerAdapter(['visual-fixture'], new ImageCapabilities()) const harness = await mountAgentLoopTestHarness(ctx) const agent = await harness.create(SessionId('stagehand-live'), { provider: 'visual-fixture', model: 'vision' }) - const execute = (suffix: string, args: unknown) => ctx.tools.execute({ + const execute = (suffix: string, args: unknown) => executeToolFixture(ctx, { agent, name: `stagehand_${suffix}`, arguments: args, callId: ToolCallId(`live-${suffix}`), signal, }) const launched = ctx.plugin(Provider, { model: nativeModel, mode: 'launch', executablePath: executable }) @@ -123,7 +124,7 @@ it.skipIf(process.env.DSH_STAGEHAND_E2E !== '1' || !process.env.DSH_STAGEHAND_MO await ctx.plugin(Provider, { model: stagehandModelSchema.parse({ modelName: process.env.DSH_STAGEHAND_MODEL, apiKey: process.env.DSH_STAGEHAND_MODEL_API_KEY }), mode: 'launch', ...process.env.DSH_BROWSER_EXECUTABLE === undefined ? {} : { executablePath: process.env.DSH_BROWSER_EXECUTABLE } }) const harness = await mountAgentLoopTestHarness(ctx) const agent = await harness.create(SessionId('stagehand-real-model'), {}) - const execute = (suffix: string, args: unknown) => ctx.tools.execute({ agent, name: `stagehand_${suffix}`, arguments: args, callId: ToolCallId(`api-${suffix}`), signal }) + const execute = (suffix: string, args: unknown) => executeToolFixture(ctx, { agent, name: `stagehand_${suffix}`, arguments: args, callId: ToolCallId(`api-${suffix}`), signal }) const navigation = await execute('navigate', { url: `http://127.0.0.1:${address.port}` }) expect(navigation.isError, JSON.stringify(navigation.content)).toBe(false) const result = await execute('extract', { @@ -183,7 +184,7 @@ it.skipIf(process.env.DSH_STAGEHAND_E2E !== '1' || process.platform === 'win32') await mountAgentLoopTestHarness(ctx) await ctx.plugin(Provider, { model: nativeModel, mode: 'launch', executablePath: wrapper }) const owner = await ctx.agents.create({ sessionId: SessionId('stagehand-stalled-init') }) - const operation = owner.agent.runMaintenance(signal => ctx.tools.execute({ + const operation = owner.agent.runMaintenance(signal => executeToolFixture(ctx, { agent: owner.agent, name: 'stagehand_tabs', arguments: { action: 'list' }, callId: ToolCallId('stalled-init'), signal, })) await connected.promise @@ -232,7 +233,7 @@ it.skipIf(process.env.DSH_STAGEHAND_E2E !== '1')('drains canceled navigation bef await mountAgentLoopTestHarness(ctx) await ctx.plugin(Provider, { model: nativeModel, mode: 'launch', executablePath: executable }) const owner = await ctx.agents.create({ sessionId: SessionId('stagehand-cancel-reconnect') }) - const call = (method: string, args: unknown) => owner.agent.runMaintenance(signal => ctx.tools.execute({ + const call = (method: string, args: unknown) => owner.agent.runMaintenance(signal => executeToolFixture(ctx, { agent: owner.agent, name: `stagehand_${method}`, arguments: args, callId: ToolCallId(`reconnect-${method}`), signal, })) expect((await call('navigate', { url: url.replace('/pending', '/retained') })).isError).toBe(false) diff --git a/packages/experimental/browser-use-stagehand-native/tests/provider.spec.ts b/packages/experimental/browser-use-stagehand-native/tests/provider.spec.ts index 4cc9491f08..abe6bd38be 100644 --- a/packages/experimental/browser-use-stagehand-native/tests/provider.spec.ts +++ b/packages/experimental/browser-use-stagehand-native/tests/provider.spec.ts @@ -1,4 +1,5 @@ /** Native browser ownership through real Agent, Session, and ToolRuntime services. */ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' import { afterEach, beforeEach, expect, it, vi } from 'vitest' import { StagehandDrainError } from '../src/native.ts' @@ -90,7 +91,7 @@ beforeEach(async () => { afterEach(async () => { await ctx.fiber.dispose() }) function execute(agent: Agent, suffix: string, args: unknown, signal = new AbortController().signal) { - return ctx.tools.execute({ agent, name: `stagehand_${suffix}`, arguments: args, callId: ToolCallId(`test-${suffix}`), signal }) + return executeToolFixture(ctx, { agent, name: `stagehand_${suffix}`, arguments: args, callId: ToolCallId(`test-${suffix}`), signal }) } it('lazily launches distinct browsers and preserves each Session tab state', async () => { @@ -148,9 +149,9 @@ it('uses its independent model for actions, observations, and a caller-selected it('refuses unavailable tabs and calls without an exact live Agent', async () => { await ctx.plugin(Provider, { model: nativeModel, mode: 'launch' }) expect((await execute(first, 'navigate', { url: 'https://example.com', pageId: 'missing' })).isError).toBe(true) - expect((await ctx.tools.execute({ name: 'stagehand_tabs', arguments: { action: 'list' }, callId: ToolCallId('agentless'), signal: new AbortController().signal })).isError).toBe(true) + expect((await executeToolFixture(ctx, { name: 'stagehand_tabs', arguments: { action: 'list' }, callId: ToolCallId('agentless'), signal: new AbortController().signal })).isError).toBe(true) ctx.tools.register({ name: 'unrelated', description: 'An independent tool.', parameters: { type: 'object' }, output: { schema: { type: 'boolean' }, render: () => [{ type: 'text', text: 'independent' }] }, execute: async () => true }) - const unrelated = await ctx.tools.execute({ agent: first, name: 'unrelated', arguments: {}, callId: ToolCallId('unrelated'), signal: new AbortController().signal }) + const unrelated = await executeToolFixture(ctx, { agent: first, name: 'unrelated', arguments: {}, callId: ToolCallId('unrelated'), signal: new AbortController().signal }) expect(unrelated.isError).toBe(false) }) diff --git a/packages/experimental/claude-code-mods/package.json b/packages/experimental/claude-code-mods/package.json index bca0d2a63f..e38a0ea770 100644 --- a/packages/experimental/claude-code-mods/package.json +++ b/packages/experimental/claude-code-mods/package.json @@ -94,6 +94,8 @@ "@deepseek-ai/dsh-subprocess-local": "workspace:*", "@deepseek-ai/dsh-system-prompt": "workspace:*", "@deepseek-ai/dsh-token-meter": "workspace:*", + "@deepseek-ai/dsh-tool-executor-native": "workspace:*", + "@deepseek-ai/dsh-tool-testkit": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", "@deepseek-ai/dsh-typert-protocol": "workspace:*", "@deepseek-ai/dsh-user-questions": "workspace:*", diff --git a/packages/experimental/claude-code-mods/src/host-ops.ts b/packages/experimental/claude-code-mods/src/host-ops.ts index 84f04d6138..47bf900671 100644 --- a/packages/experimental/claude-code-mods/src/host-ops.ts +++ b/packages/experimental/claude-code-mods/src/host-ops.ts @@ -354,16 +354,19 @@ export function createHostOps(options: HostOpsOptions): OpTable { // The pipeline raises `tool.call` for this call from the calling mod, so only earlier mods see it. options.callOrigins.set(callId, context.mod) try { - const result = await registry.execute({ - callId, - name: aliases.toHarness(requireString(tool, '$.tool.call tool')), - arguments: args, + const batch = await registry.execute({ + calls: [{ + type: 'tool-call', id: callId, + name: aliases.toHarness(requireString(tool, '$.tool.call tool')), + arguments: JSON.stringify(args), + }], ...agent === undefined ? {} : { agent }, signal: context.signal, }) // Guidance a tool defers to the next request still reaches the model when a mod made the call. - if (agent !== undefined) for (const context of result.additionalContexts ?? []) agent.inject(context) - return toolCallResultOf(result) + if (agent !== undefined) for (const context of batch.additionalContexts) agent.inject(context) + // oxlint-disable-next-line typescript/no-non-null-assertion -- execute returns one result per call + return toolCallResultOf(batch.results[0]!.result) } finally { options.callOrigins.delete(callId) } diff --git a/packages/experimental/claude-code-mods/tests/bridge.spec.ts b/packages/experimental/claude-code-mods/tests/bridge.spec.ts index e5d0d19775..aeacb91677 100644 --- a/packages/experimental/claude-code-mods/tests/bridge.spec.ts +++ b/packages/experimental/claude-code-mods/tests/bridge.spec.ts @@ -1,3 +1,4 @@ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { createServer } from 'node:http' import type { AddressInfo } from 'node:net' @@ -23,7 +24,7 @@ import Storage from '@deepseek-ai/dsh-storage' import * as StorageDomain from '@deepseek-ai/dsh-storage-domain' import * as StorageJson from '@deepseek-ai/dsh-storage-json' import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local' -import { defineContentToolFixture } from '@deepseek-ai/dsh-tools' +import { defineContentToolFixture } from '@deepseek-ai/dsh-tool-testkit' import UserQuestionService from '@deepseek-ai/dsh-user-questions' import ClaudeCodeMods, { defineMod, MODS_API_VERSION, type Config, type ModPlugin, type ModRegister } from '../src/index.ts' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' @@ -416,7 +417,7 @@ describe('tool.call: answers and rewrites', () => { 'claude-code-mods: reroute-mod: tool.call hook skipped: rerouted the call from echo to Read; the logged call runs the tool it named', ) // A call without an agent (a host-side execute) is answered by the same schema rule. - const direct = await h.ctx.tools.execute({ callId: ToolCallIdOf('direct'), name: 'greet', arguments: {}, signal: new AbortController().signal }) + const direct = await executeToolFixture(h.ctx, { callId: ToolCallIdOf('direct'), name: 'greet', arguments: {}, signal: new AbortController().signal }) expect(direct.isError).toBe(false) expect(direct.content).toEqual([{ type: 'text', text: 'rendered hello from answer-mod' }]) const refusals = h.warn.mock.calls.map(call => String(call[0])).filter(line => line.includes('rewrote the arguments of echo')) @@ -786,7 +787,7 @@ describe('subagents, programmatic calls, and malformed tool arguments', () => { 'watch-mod: tool.call echo agent=root n=1', 'watch-mod: turn.complete agent=root inputs=[{}]', ]) - const direct = await h.ctx.tools.execute({ callId: ToolCallIdOf('direct'), name: 'echo', arguments: { command: 'direct' }, signal: new AbortController().signal }) + const direct = await executeToolFixture(h.ctx, { callId: ToolCallIdOf('direct'), name: 'echo', arguments: { command: 'direct' }, signal: new AbortController().signal }) expect(direct.isError).toBe(false) // The nested `$.tool.call` reaches only mods loaded before watch-mod, so its own hook is not re-entered. expect(h.info).toHaveBeenCalledWith('watch-mod: direct saw echo and {"result":"ran nested"}') diff --git a/packages/experimental/claude-code-mods/tests/loader-composition.spec.ts b/packages/experimental/claude-code-mods/tests/loader-composition.spec.ts index 7cc3c64472..a0a2e6cce0 100644 --- a/packages/experimental/claude-code-mods/tests/loader-composition.spec.ts +++ b/packages/experimental/claude-code-mods/tests/loader-composition.spec.ts @@ -1,5 +1,6 @@ /** Real Loader composition: the bridge mounted from a cordis.yml row beside the shipped core plugins. */ +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' @@ -17,7 +18,8 @@ import LlmRuntime, { createUserMessage } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRuntime, { defineContentToolFixture } from '@deepseek-ai/dsh-tools' +import ToolRuntime from '@deepseek-ai/dsh-tools' +import { defineContentToolFixture } from '@deepseek-ai/dsh-tool-testkit' import * as ClaudeCodeMods from '../src/index.ts' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' @@ -45,6 +47,7 @@ it('loads from cordis.yml, counts the model\'s tool calls, and answers /tally th ['@deepseek-ai/dsh-fs-local', LocalFileSystem], ['@deepseek-ai/dsh-working-directory', WorkingDirectory], ['@deepseek-ai/dsh-tools', ToolRuntime], + ['@deepseek-ai/dsh-tool-executor-native', NativeToolExecutor], ['@deepseek-ai/dsh-agent', AgentRegistry], ['@deepseek-ai/dsh-agent-loop', AgentLoop], ['@deepseek-ai/dsh-commands', CommandRuntime], diff --git a/packages/experimental/client-ui-voice-input/README.i18n.yaml b/packages/experimental/client-ui-voice-input/README.i18n.yaml index c906ffc9c7..6aa37a6ef0 100644 --- a/packages/experimental/client-ui-voice-input/README.i18n.yaml +++ b/packages/experimental/client-ui-voice-input/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 364c80ee00a374ff /deepseek-ai-dsh-experimental-client-ui-voice-input/use-this-package: - en: 4db1c67430cfa691 - zh: 484808eeff0d7692 + en: b7a99b656839e8a0 + zh: 5c6e2635eb20fc11 /deepseek-ai-dsh-experimental-client-ui-voice-input/understand-the-implementation: en: 8fe96eeef9afe7d7 zh: 11936f647ded39eb diff --git a/packages/experimental/client-ui-voice-input/README.md b/packages/experimental/client-ui-voice-input/README.md index 3eba1c3825..2aa4fdd707 100644 --- a/packages/experimental/client-ui-voice-input/README.md +++ b/packages/experimental/client-ui-voice-input/README.md @@ -31,6 +31,8 @@ Use the native **Input device** selector in bundle details to follow the system Before downloading or retrying, **Model download source** offers Automatic and the origins advertised by the Host. SenseVoice normally offers Hugging Face and HF-Mirror; a fixed private deployment exposes only its configured origin. Manual selection uses only that source, while Automatic preserves the provider’s comparison and fallback policy. The choice is retained for retries in the current card, without changing deployment configuration or recognition preferences. Source controls are unavailable during preparation and hidden when resources are ready. +`pluginNavigation` is optional: the plugin starts without it, and the setup dialog offers the details button only while the service is present. + ----- diff --git a/packages/experimental/client-ui-voice-input/README.zh.md b/packages/experimental/client-ui-voice-input/README.zh.md index b7e9164a02..3dadc0d92d 100644 --- a/packages/experimental/client-ui-voice-input/README.zh.md +++ b/packages/experimental/client-ui-voice-input/README.zh.md @@ -31,6 +31,8 @@ kind: "package-reference" 下载或重试前,**模型下载源**提供自动选择及 Host 公布的源。SenseVoice 默认提供 Hugging Face 和 HF-Mirror;固定私有部署仅显示配置的源。手动选择只使用指定源,自动选择沿用提供方的比较与回退策略。当前卡片中的重试会保留选择,不修改部署配置或识别偏好。准备期间不能改源,资源就绪后隐藏源选择器。 +`pluginNavigation` 为可选服务:没有它时插件照常启动,设置对话框只在该服务存在时提供详情按钮。 + ----- diff --git a/packages/experimental/client-ui-voice-input/src/client/VoiceInput.tsx b/packages/experimental/client-ui-voice-input/src/client/VoiceInput.tsx index 7b60ca9b7e..a396f1d7ab 100644 --- a/packages/experimental/client-ui-voice-input/src/client/VoiceInput.tsx +++ b/packages/experimental/client-ui-voice-input/src/client/VoiceInput.tsx @@ -31,7 +31,12 @@ export interface VoiceInputActions { /** Entry-injected Host readiness and microphone operations. */ export interface VoiceInputInjected extends VoiceInputActions { - hooks: { speechReadiness: HostObservable; microphoneDevice: HostObservable } + hooks: { + speechReadiness: HostObservable + microphoneDevice: HostObservable + /** Whether the Plugins page navigation service is present; the setup dialog offers the details only while it is. */ + pluginNavigation: HostObservable + } } /** Composer-owned expansion and draft actions; preferences stay in plugin settings. */ @@ -58,8 +63,9 @@ async function disposeRecording(capture: Recording): Promise { /** Render a compact microphone or an expanded capture, transcription, or retry row. */ export function VoiceInput({ sessionId, inputActions, locked, onActiveChange, - createRecording, transcribe, openSettings, useSpeechReadiness, t }: VoiceInputProps) { + createRecording, transcribe, openSettings, useSpeechReadiness, usePluginNavigation, t }: VoiceInputProps) { const readiness = useSpeechReadiness(value => value), catalog = readiness.catalog + const navigable = usePluginNavigation(present => present) const provider = catalog?.providers.find(item => item.id === catalog.selection.providerId) const usable = readiness.connected && (provider?.preparation.phase === 'ready' || provider?.preparation.phase === 'standby' || provider?.preparation.phase === 'waking') @@ -153,7 +159,8 @@ export function VoiceInput({ sessionId, inputActions, locked, onActiveChange, { setSetupOpen(false) }} onOpenDetails={() => { setSetupOpen(false); openSettings() }} t={t} /> + onDismiss={() => { setSetupOpen(false) }} + onOpenDetails={navigable ? () => { setSetupOpen(false); openSettings() } : undefined} t={t} /> return
- + {onOpenDetails !== undefined && } }>

{t(needsInstallation ? 'setupPrompt.body' : 'setupPrompt.unavailableBody')}

diff --git a/packages/experimental/client-ui-voice-input/src/client/mount.ts b/packages/experimental/client-ui-voice-input/src/client/mount.ts index d79d281def..bfe499f91b 100644 --- a/packages/experimental/client-ui-voice-input/src/client/mount.ts +++ b/packages/experimental/client-ui-voice-input/src/client/mount.ts @@ -6,6 +6,7 @@ import type {} from '@deepseek-ai/dsh-client-locale/client' import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' import type { TypertRemoteContribution } from '@deepseek-ai/dsh-typert-protocol' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { VoiceInput, type VoiceInputInjected } from './VoiceInput.tsx' import { Recording } from './audio.ts' import { createMicrophoneDeviceStore } from './microphone-device.ts' @@ -15,7 +16,7 @@ import { observeReadiness } from './readiness.ts' import { VoicePreparation } from './PreparationCard.tsx' import { VoiceSetupPrompt } from './VoiceSetupPrompt.tsx' -export const inject = ['remote', 'slots', 'locale', 'pluginNavigation'] +export const inject = ['remote', 'slots', 'locale'] function registerUi(ctx: Context): void { ctx.effect(() => ctx.locale.register(NS, { zh, en, ja })) @@ -24,9 +25,17 @@ function registerUi(ctx: Context): void { const readiness = observeReadiness(ctx) ctx.effect(() => readiness.dispose) ctx.effect(() => async () => { await Promise.all([...recordings].map(recording => recording.dispose())) }) + // The Plugins page provides its navigation only while the current Peer may use it. + const pluginNavigation = createSnapshotStore(false) + ctx.inject(['pluginNavigation'], (scope: Context) => { + scope.effect(() => { + pluginNavigation.set(true) + return () => { pluginNavigation.set(false) } + }, 'voice-input: plugin navigation presence') + }) const actions: VoiceInputInjected = { - openSettings: () => { ctx.pluginNavigation.openBundle('@deepseek-ai/dsh-experimental-voice-input-bundle') }, - hooks: { speechReadiness: readiness.state, microphoneDevice: microphone }, + openSettings: () => { ctx.get('pluginNavigation')?.openBundle('@deepseek-ai/dsh-experimental-voice-input-bundle') }, + hooks: { speechReadiness: readiness.state, microphoneDevice: microphone, pluginNavigation }, selectMicrophone: (device) => { microphone.set(device) }, createRecording: () => { const recording = new Recording(() => { recordings.delete(recording) }, microphone.getSnapshot().id) @@ -62,7 +71,7 @@ function registerUi(ctx: Context): void { */ export async function mountVoiceInput(ctx: Context, contribution: TypertRemoteContribution): Promise<() => Promise> { const disposeRemote = await ctx.remote.$mount(contribution) - const ui = ctx.inject(['remote.speech', 'slots', 'locale', 'pluginNavigation'], registerUi) + const ui = ctx.inject(['remote.speech', 'slots', 'locale'], registerUi) try { await ui } catch (error) { await ui.dispose(); await disposeRemote(); throw error } return async () => { await ui.dispose(); await disposeRemote() } } diff --git a/packages/experimental/client-ui-voice-input/tests/mount.client.spec.ts b/packages/experimental/client-ui-voice-input/tests/mount.client.spec.ts index 3287f92a1f..a328473da6 100644 --- a/packages/experimental/client-ui-voice-input/tests/mount.client.spec.ts +++ b/packages/experimental/client-ui-voice-input/tests/mount.client.spec.ts @@ -34,7 +34,7 @@ function assertVoiceActions(value: Record): asserts value is Re assert(typeof value.hooks === 'object' && value.hooks !== null) } -async function fixture(fail = false) { +async function fixture(fail = false, navigation = true) { const ctx = new Context(), unmount = vi.fn(async () => {}) class Remote extends Service { constructor() { super(ctx, 'remote') } @@ -45,7 +45,7 @@ async function fixture(fail = false) { } new Remote() const openBundle = vi.fn() - ctx.provide('pluginNavigation', { openBundle }) + if (navigation) ctx.provide('pluginNavigation', { openBundle }) const configure = vi.fn(async () => ({ ok: true, value: {} })) const prepare = vi.fn(async () => ({ ok: true, value: {} })) const cancelPreparation = vi.fn(async () => ({ ok: true, value: {} })) @@ -63,6 +63,28 @@ async function fixture(fail = false) { return { ctx, unmount, openBundle, configure, prepare, cancelPreparation, transcribe } } +it('starts without the Plugins page navigation and follows its presence', async () => { + hostApply() + const b = await fixture(false, false) + try { + await b.ctx.plugin({ inject: [...inject], apply: ctx => mountVoiceInput(ctx, REMOTE) }) + const entry = b.ctx.slots.entries('conversation.input.activity').find(item => item.component === VoiceInput) + const actions = entry!.inject!() + assertVoiceActions(actions) + expect(actions.hooks.pluginNavigation.getSnapshot()).toBe(false) + expect(() => { actions.openSettings() }).not.toThrow() + const navigation = b.ctx.plugin({ apply: (ctx: Context) => { ctx.provide('pluginNavigation', { openBundle: b.openBundle } as never) } }) + await navigation + await vi.waitFor(() => { expect(actions.hooks.pluginNavigation.getSnapshot()).toBe(true) }) + actions.openSettings() + expect(b.openBundle).toHaveBeenCalledWith('@deepseek-ai/dsh-experimental-voice-input-bundle') + await navigation.dispose() + expect(actions.hooks.pluginNavigation.getSnapshot()).toBe(false) + } finally { + await b.ctx.fiber.dispose() + } +}) + it('withdraws its Remote, localized slot and microphone captures on disposal', async () => { hostApply() const b = await fixture() diff --git a/packages/experimental/client-ui-voice-input/tests/voice-input.client.spec.tsx b/packages/experimental/client-ui-voice-input/tests/voice-input.client.spec.tsx index 2dd0f150cf..b0e23ff464 100644 --- a/packages/experimental/client-ui-voice-input/tests/voice-input.client.spec.tsx +++ b/packages/experimental/client-ui-voice-input/tests/voice-input.client.spec.tsx @@ -18,7 +18,7 @@ beforeEach(() => { vi.stubGlobal('requestAnimationFrame', vi.fn(() => 1)); vi.st afterEach(() => { cleanup(); vi.useRealTimers(); vi.unstubAllGlobals() }) const id = 'sensevoice-local' as SpeechProviderId const transcript: Transcript = { text: '检查 TypeScript 类型', audioSeconds: 2, inferenceSeconds: 0.4 } -function fixture(recording?: Recording) { +function fixture(recording?: Recording, navigable = true) { const capture = Object.assign(new Recording(() => {}), { start: vi.fn(async () => {}), stop: vi.fn(async () => new Uint8Array(48)), amplitude: () => 0, dispose: vi.fn(async () => {}) }) const inputActions = { notify: vi.fn(), insertQuote: () => false, @@ -35,6 +35,7 @@ function fixture(recording?: Recording) { openSettings: vi.fn(), prepare: vi.fn(async () => {}), cancelPreparation: vi.fn(async () => {}), configure: vi.fn(async () => {}), selectMicrophone: vi.fn(), useMicrophoneDevice: bindSnapshotSelector(createSnapshotStore({ id: '', label: '' })), useSpeechReadiness: bindSnapshotSelector(readiness), createRecording: () => recording ?? capture, + usePluginNavigation: bindSnapshotSelector(createSnapshotStore(navigable)), t: makeTranslate(zh, commonZh) } const view = render() return { props, capture, inputActions, readiness, transcribe, view } @@ -335,6 +336,18 @@ it.each([ expect(b.props.openSettings).toHaveBeenCalledOnce() }) +it('offers no plugin details from setup guidance while the Plugins page navigation is absent', () => { + const b = fixture(undefined, false), state = b.readiness.getSnapshot() + act(() => { b.readiness.set({ ...state, catalog: { ...state.catalog!, providers: [ + { ...state.catalog!.providers[0]!, preparation: { phase: 'unprepared' } }, + ] } }) }) + fireEvent.click(screen.getByRole('button', { name: zh['setupPrompt.trigger'] })) + const dialog = within(screen.getByRole('dialog')) + expect(dialog.queryByRole('button', { name: zh['setupPrompt.open'] })).toBeNull() + expect(dialog.getByRole('button', { name: zh['setupPrompt.later'] })).toBeTruthy() + expect(b.props.openSettings).not.toHaveBeenCalled() +}) + it('moves focus into setup guidance, contains Tab traversal, and restores the draft focus on dismissal', () => { const b = fixture() render(