diff --git a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.i18n.yaml index ea59089b5d..6fb4396a74 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-30-web-config-plane.md -2026-07-30-web-config-plane.md: 1326f50a792b6c6f791c9515ea03cc362d16fc7e -2026-07-30-web-config-plane.zh.md: bea4bbc237f44864a5fa9bf677267ca0055c13ed +2026-07-30-web-config-plane.md: ac989cb100190e9a41ebf04b5b2d80125d49e0cb +2026-07-30-web-config-plane.zh.md: 26f11352b4362d3cbded3d1dc9205e289ee9b18c diff --git a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md index 1326f50a79..ac989cb100 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md +++ b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md @@ -22,7 +22,7 @@ The request-level configuration seam made LLM adapter configuration restart-free **A hand-written editor over a schema model layer.** `ctx.settingsSchema`, provided by `dsh-client-ui-settings`, rehydrates the wire's `toJSON()` envelope into live schemastery nodes for validation, path resolution, and immutable draft editing — but no generic rendering: the first cut shipped a full schema-driven form renderer, and the resulting page was an unstyled schema dump (every advanced field flattened onto the card, raw field names as labels, the `retryPolicy` unsupported-fallback in the main flow). The hand-written direction won over adding a hint/grouping system, and a further simplification removed the reference input entirely: the card's primary field is one **API key** input, a whole-section provider without a configured key opens as its setup card, and the collapsed 自定义设置 fold carries the curated per-family extras (`baseURL` for both families, `reasoningEffort` for deepseek / `reasoning` for pi-ai, plus direct DeepSeek model rows with `id`, `name`, and `contextWindow`). Existing model fields outside that visible set survive array edits; retry policy, timeouts, and other fields remain owned by `settings.yaml`. Validation still runs the rehydrated schema before writing, while adapter-specific checks reject catalog invariants that the serialized schema cannot express. The card's colors resolve through the `--dsw-alias-*` design tokens; it had named `--border`/`--surface`/`--text-*`, which nothing in this app defines, so it rendered their light-mode fallbacks and stayed light under the dark theme. The model catalog takes the row shape the pi-ai provider form introduces: one bordered entry per model, id and display name on the row, and the capacities behind the row's own disclosure, so the two editors read as one design rather than diverging. Every field keeps the indexed `aria-label` that names it. Both capacities are text fields reading a decimal `K`/`M` suffix (`1M` is 1000K, matching how capacities are quoted) and storing the plain count: a field holds the typed text while it has focus, because re-deriving it from the parsed count on every keystroke would rewrite `1000` to `1K` mid-word, and text that does not parse stays on screen so the save-time rejection names a row the user can still see. The shared class names carry only declared token spellings: `--dsw-alias-border-subtle`, `--dsw-alias-text-tertiary`, and `--dsw-alias-text-primary` are undeclared, so naming them resolves to the light-mode literals in their fallback slots. A styles test now rejects any `--dsw-*` name the token sheet does not declare, so the next editor to name one fails rather than shipping a light-only surface. -**The Models page is a three-domain join with service-shaped apply semantics.** Rows are configured providers; the add card's select is the dormant directory remainder. Route liveness still gates readiness and invalidates the join, but the page does not render it as provider status because configuration presence and runtime availability are distinct. The key path stays reference-shaped without ever showing a reference: a typed key stores **write-only** through `credentials.set` under the profile's `apiKeyEnv`, deriving `_API_KEY` when none exists (the pi-ai profile records the derivation only when a key is entered), so `settings.yaml` never carries a key value; a blank pi-ai key materializes a reference-free profile and preserves provider-native authentication. Profile edits and removals land as minimal path-addressed `settings.mutate` operations against the redacted user section, which never names a secret the page did not receive. Removing a user-layer provider first opens a localized confirmation dialog whose row actions, title, description, and final action identify the same provider; confirmation removes an exact configured+writable derived credential before the profile, while custom, environment, and unidentified targets remain untouched. Both stages are idempotent and a partial failure stays in the dialog for retry. DeepSeek's model list is array-replace configuration: inherited effective rows remain visible until the first edit materializes the complete list in the user layer, and reset unsets the list override. The partial-commit and credential-ownership rationale lives in the [provider credential lifecycle note](../bug-fix/2026-08-06-provider-credential-lifecycle.md). +**The Models page is a three-domain join with service-shaped apply semantics.** Rows are configured providers; the add card's select is the dormant directory remainder. Route liveness still gates readiness and invalidates the join, but the page does not render it as provider status because configuration presence and runtime availability are distinct. The key path stays reference-shaped without ever showing a reference: a typed key stores **write-only** through `credentials.set` under the profile's `apiKeyEnv`, deriving `_API_KEY` when none exists (the pi-ai profile records the derivation only when a key is entered), so `settings.yaml` never carries a key value; a blank pi-ai key materializes a reference-free profile and preserves provider-native authentication. Profile edits and removals land as minimal path-addressed `settings.mutate` operations against the redacted user section, which never names a secret the page did not receive. Removing a user-layer provider first opens a localized confirmation dialog whose row actions, title, description, and final action identify the same provider; confirmation removes an exact configured+writable derived credential before the profile, while custom, environment, and unidentified targets remain untouched. Both stages are idempotent and a partial failure stays in the dialog for retry. DeepSeek's model list is array-replace configuration: inherited effective rows remain visible until the first edit materializes the complete list in the user layer, and reset unsets the list override. `llm.discoverModels` results stay in picker-local state until **Add selected**; configured ids start unchecked, while **Select all** / **Deselect all** changes only that local set, so bulk selection preserves the same capacity-protection rule. The partial-commit and credential-ownership rationale lives in the [provider credential lifecycle note](../bug-fix/2026-08-06-provider-credential-lifecycle.md). ## Alternatives considered @@ -36,4 +36,4 @@ The request-level configuration seam made LLM adapter configuration restart-free ## Consequences -The whole loop is pinned keyless in the browser lane (`apps/web/tests/models-settings.e2e.ts`): the add card offers the dormant pi-ai catalog, adding `minimax-cn` with a typed key writes the reference-only profile into `settings.yaml`, stores the value into the harness home's `.env` under the derived `MINIMAX_CN_API_KEY`, registers the route live on the topology frame, and the customized fold merges `reasoning` beside the reference — zero model calls, ARIA goldens for the add-card, configured, and identified delete-confirmation states, plus a scaffold `harnessHome` so tests never touch a real `~/.dsh` (the provider under test is one whose derived reference cannot collide with a developer's exported keys). The settings-shell scenario intercepts the pathless native intent; Service Definition, provider, wire, React, and native-opener tests separately pin provider absence, custom-path resolution, absent-file materialization, owner-only permissions, hidden remote/unavailable states, duplicate-click collapse, localized failure, macOS text-editor dispatch, and Linux/Windows desktop dispatch. The removal scenario proves cancellation leaves both profile and key intact, then confirmation removes both the profile and its identified managed credential. The DeepSeek onboarding fixture edits the default catalog into a user-owned list, persists an arbitrary model id/name/context window, removes the active row, and observes the model selector's empty-selection fallback. The rename touched 239 files (fixtures, goldens, docs, python) with no compatibility alias. The renderer replacement needed no wire change: apply semantics, redaction, and the directory join were renderer-agnostic all along. Deferred: a per-row models preview (the picker already lists models) and a page address for live routes that never declared configurability. +The whole loop is pinned keyless in the browser lane (`apps/web/tests/models-settings.e2e.ts`): the add card offers the dormant pi-ai catalog, adding `minimax-cn` with a typed key writes the reference-only profile into `settings.yaml`, stores the value into the harness home's `.env` under the derived `MINIMAX_CN_API_KEY`, registers the route live on the topology frame, and the customized fold merges `reasoning` beside the reference — zero model calls, ARIA goldens for the add-card, configured, model-picker, and identified delete-confirmation states, plus a scaffold `harnessHome` so tests never touch a real `~/.dsh` (the provider under test is one whose derived reference cannot collide with a developer's exported keys). The component suite pins configured-id exclusions and both directions of the bulk toggle. The settings-shell scenario intercepts the pathless native intent; Service Definition, provider, wire, React, and native-opener tests separately pin provider absence, custom-path resolution, absent-file materialization, owner-only permissions, hidden remote/unavailable states, duplicate-click collapse, localized failure, macOS text-editor dispatch, and Linux/Windows desktop dispatch. The removal scenario proves cancellation leaves both profile and key intact, then confirmation removes both the profile and its identified managed credential. The DeepSeek onboarding fixture edits the default catalog into a user-owned list, persists an arbitrary model id/name/context window, removes the active row, and observes the model selector's empty-selection fallback. The rename touched 239 files (fixtures, goldens, docs, python) with no compatibility alias. The renderer replacement needed no wire change: apply semantics, redaction, and the directory join were renderer-agnostic all along. Deferred: a per-row models preview (the picker already lists models) and a page address for live routes that never declared configurability. diff --git a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md index bea4bbc237..26f11352b4 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md @@ -22,7 +22,7 @@ Status: implemented **架在 schema 模型层之上的手写编辑器。**`dsh-client-ui-settings` 提供的 `ctx.settingsSchema` 把 wire 的 `toJSON()` 信封还原(rehydrate)为活的 schemastery 节点,用于校验、路径解析与不可变草稿编辑——但不做通用渲染:第一版交付了完整的 schema 驱动表单渲染器,得到的却是一个未加样式、把 schema 原样倾倒出来的页面(每个进阶字段都平铺到卡片上、原始字段名直接充当标签、`retryPolicy` 的「不支持」回退落在主流程里)。手写方向胜过了再加一套提示/分组系统,进一步的简化又把引用输入框整个移除:卡片的主字段是一个 **API 密钥** 输入框,未配置密钥的整分节提供方会以其设置卡片的形式打开,收起的「自定义设置」折叠区承载按家族精选的额外字段(两个家族都有 `baseURL`,deepseek 有 `reasoningEffort`/pi-ai 有 `reasoning`,另有直接 DeepSeek 模型行的 `id`、`name` 和 `contextWindow`)。现有模型字段中不在可见集合内的部分会在数组编辑后保留;重试策略、超时及其他字段仍归 `settings.yaml` 所有。校验仍会在写入前运行还原出的 schema,适配器特有的检查则会拒绝序列化 schema 无法表达的目录不变量。卡片的颜色经 `--dsw-alias-*` 设计 token 解析;它此前引用的 `--border`/`--surface`/`--text-*` 在本应用中无人定义,于是渲染出的是它们的亮色模式回退值,在暗色主题下依旧保持亮色。模型目录采用 pi-ai 提供方表单引入的行形态:每个模型一个带边框的条目,ID 与显示名称落在行上,容量则收在该行自己的折叠区里,使两个编辑器呈现为同一套设计,而不是各自分岔。每个字段都保留那个为其命名的带序号 `aria-label`。两项容量都是文本输入框,读取十进制的 `K`/`M` 后缀(`1M` 即 1000K,与容量的通行标注方式一致)并存储纯数值:字段持有焦点期间保留键入的文本,因为若每次按键都从解析出的数值重新推导该文本,`1000` 会在尚未输完时就被改写成 `1K`;无法解析的文本也会留在屏幕上,因此保存时的拒绝点名的是用户仍能看见的那一行。共用的类名只承载已声明的 token 写法:`--dsw-alias-border-subtle`、`--dsw-alias-text-tertiary` 和 `--dsw-alias-text-primary` 均未声明,写出它们就会解析为各自回退槽位中的亮色模式字面值。现在有一个样式测试会拒绝 token 表未声明的任何 `--dsw-*` 名称,因此下一个写出这类名称的编辑者会当场失败,而不是交付一个只有亮色的界面。 -**Models 页是一次三领域联接,应用语义与服务同形。**每一行是一个已配置的提供方;「新增」卡片的选择框是可配置提供方目录中剩余的休眠条目。路由存活状态仍用于就绪判定,并会使该联接失效,但页面不将其渲染为提供方状态,因为配置存在与运行时可用性是两个不同概念。密钥通道保持引用形态,却从不展示任何引用:键入的密钥经 `credentials.set` **只写**存入 profile 的 `apiKeyEnv` 之下,引用不存在时便派生 `_API_KEY`(仅在输入密钥时,pi-ai profile 才会记录该派生),因此 `settings.yaml` 从不携带密钥值;留空 pi-ai 密钥会具化一个不带引用的 profile,并保留提供方原生认证。profile 的编辑和删除会针对脱敏后的用户分节,以按路径寻址的最小 `settings.mutate` 操作落地,绝不会点名页面未收到的机密。删除用户层提供方时,会先打开本地化确认对话框,其行操作、标题、说明和最终操作都会点名同一个提供方;确认后会先清除与派生目标精确匹配且已配置、可写的凭据,再删除 profile,自定义目标、环境目标和无法识别的目标则保持不变。两个阶段都具备幂等性,部分失败会留在对话框中供重试。DeepSeek 的模型列表是数组替换配置:继承而来的生效模型行会一直显示,直到第一次编辑将完整列表具化到用户层;重置则会取消设置该列表覆盖。部分提交与凭据所有权的理由记录在[提供方凭据生命周期 note](../bug-fix/2026-08-06-provider-credential-lifecycle.md)中。 +**Models 页是一次三领域联接,应用语义与服务同形。**每一行是一个已配置的提供方;「新增」卡片的选择框是可配置提供方目录中剩余的休眠条目。路由存活状态仍用于就绪判定,并会使该联接失效,但页面不将其渲染为提供方状态,因为配置存在与运行时可用性是两个不同概念。密钥通道保持引用形态,却从不展示任何引用:键入的密钥经 `credentials.set` **只写**存入 profile 的 `apiKeyEnv` 之下,引用不存在时便派生 `_API_KEY`(仅在输入密钥时,pi-ai profile 才会记录该派生),因此 `settings.yaml` 从不携带密钥值;留空 pi-ai 密钥会具化一个不带引用的 profile,并保留提供方原生认证。profile 的编辑和删除会针对脱敏后的用户分节,以按路径寻址的最小 `settings.mutate` 操作落地,绝不会点名页面未收到的机密。删除用户层提供方时,会先打开本地化确认对话框,其行操作、标题、说明和最终操作都会点名同一个提供方;确认后会先清除与派生目标精确匹配且已配置、可写的凭据,再删除 profile,自定义目标、环境目标和无法识别的目标则保持不变。两个阶段都具备幂等性,部分失败会留在对话框中供重试。DeepSeek 的模型列表是数组替换配置:继承而来的生效模型行会一直显示,直到第一次编辑将完整列表具化到用户层;重置则会取消设置该列表覆盖。`llm.discoverModels` 的结果会留在选择框的本地状态中,直至点击**添加所选**;已配置的 ID 默认不勾选,**全选**/**取消全选**也只会改变这一本地集合,因此批量选择仍遵守同一条容量保护规则。部分提交与凭据所有权的理由记录在[提供方凭据生命周期 note](../bug-fix/2026-08-06-provider-credential-lifecycle.md)中。 ## 曾考虑的替代方案 @@ -36,4 +36,4 @@ Status: implemented ## 后果 -整条闭环以无密钥方式固定在浏览器测试通道(`apps/web/tests/models-settings.e2e.ts`):「新增」卡片提供休眠的 pi-ai catalog,携键入的密钥添加 `minimax-cn` 会把只含引用的 profile 写入 `settings.yaml`、把密钥值存入 harness 家目录 `.env` 中派生的 `MINIMAX_CN_API_KEY` 之下、路由随拓扑帧注册为存活,「自定义设置」折叠区则把 `reasoning` 合并到引用旁边——全程零模型调用,「新增」卡片态、已配置态与已点名目标的删除确认态各有 ARIA golden,另有脚手架式的 `harnessHome`,测试绝不触碰真实的 `~/.dsh`(受测提供方是派生引用不可能与开发者已导出密钥相撞的那一个)。设置外壳场景会截获无路径参数的原生意图;Service Definition、提供方、wire、React 与原生打开器测试分别固定了提供方缺失、自定义路径解析、缺失文件创建、仅属主权限、远程/不可用时隐藏、重复点击合并、本地化失败、macOS 文本编辑器分发,以及 Linux/Windows 桌面分发。删除场景证明,取消会保留 profile 和密钥,随后的确认会同时删除 profile 及其已识别的受管凭据。DeepSeek 首次使用 fixture 会把默认目录编辑为用户自有列表、持久化任意模型的 ID/名称/上下文窗口、移除活动模型行,并观察模型选择器的空选择回退。这次重命名触及 239 个文件(fixture(测试前置数据)、golden、文档、python),未保留兼容别名。替换渲染器不需要任何 wire 变更:应用语义、脱敏与目录联接从一开始就与渲染器无关。延后事项:每行的模型预览(选择器已能列出模型)和为从未声明可配置性的存活路由提供页面地址。 +整条闭环以无密钥方式固定在浏览器测试通道(`apps/web/tests/models-settings.e2e.ts`):「新增」卡片提供休眠的 pi-ai catalog,携键入的密钥添加 `minimax-cn` 会把只含引用的 profile 写入 `settings.yaml`、把密钥值存入 harness 家目录 `.env` 中派生的 `MINIMAX_CN_API_KEY` 之下、路由随拓扑帧注册为存活,「自定义设置」折叠区则把 `reasoning` 合并到引用旁边——全程零模型调用,「新增」卡片态、已配置态、模型选择框态与已点名目标的删除确认态各有 ARIA golden,另有脚手架式的 `harnessHome`,测试绝不触碰真实的 `~/.dsh`(受测提供方是派生引用不可能与开发者已导出密钥相撞的那一个)。组件测试固定了已配置 ID 的排除与批量切换的两个方向。设置外壳场景会截获无路径参数的原生意图;Service Definition、提供方、wire、React 与原生打开器测试分别固定了提供方缺失、自定义路径解析、缺失文件创建、仅属主权限、远程/不可用时隐藏、重复点击合并、本地化失败、macOS 文本编辑器分发,以及 Linux/Windows 桌面分发。删除场景证明,取消会保留 profile 和密钥,随后的确认会同时删除 profile 及其已识别的受管凭据。DeepSeek 首次使用 fixture 会把默认目录编辑为用户自有列表、持久化任意模型的 ID/名称/上下文窗口、移除活动模型行,并观察模型选择器的空选择回退。这次重命名触及 239 个文件(fixture(测试前置数据)、golden、文档、python),未保留兼容别名。替换渲染器不需要任何 wire 变更:应用语义、脱敏与目录联接从一开始就与渲染器无关。延后事项:每行的模型预览(选择器已能列出模型)和为从未声明可配置性的存活路由提供页面地址。 diff --git a/.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.i18n.yaml index 385a4b6621..436e346da5 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.md -2026-08-10-fork-children-stay-one-shot.md: 10e2607e55b67671519fd18c26d0ae8fc0ca7268 -2026-08-10-fork-children-stay-one-shot.zh.md: d9a5561ed6fb8a32ca6c47553a49e2948eb59256 +2026-08-10-fork-children-stay-one-shot.md: 44b947a3e0580263f1973aaf24534b7b2f01c0b6 +2026-08-10-fork-children-stay-one-shot.zh.md: 4a9e5ab10ba1f437e4a8caf6c08f08412113c23d diff --git a/.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.md b/.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.md index 10e2607e55..44b947a3e0 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.md +++ b/.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.md @@ -42,7 +42,7 @@ The reintroduction condition is recorded as a `TODO(fork-continuable-prefix-reus - A forked child's request prefix stays byte-identical to its parent's unless the deployment configures `persona` or `toolFilter` on the fork delegation tool, so the token cost of seeding buys provider-side reuse again. - The fork provider's continuable path has no production caller and no assembled-composition coverage. It keeps its package-level tests, and the seam still accepts it, so a bundle or `--patch` overlay can reintroduce it with no code change and no warning. - `subagent_fork`'s model-visible schema changes: the continuable background wording is replaced by the one-shot task wording in the base bundle, and disappears entirely from the two examples. The affected keyless snapshot tool-schema sidecars are re-recorded in the same change. -- The report obligation's reach narrows to spawned children in shipped deployments. Its default `wakeup` scheduling, authority model, and coverage are unchanged. +- The report obligation's reach narrows to spawned children in shipped deployments. Its default `next-step` scheduling, authority model, and coverage remain independent of fork composition. ### Accepted risks diff --git a/.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.zh.md b/.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.zh.md index d9a5561ed6..4a9e5ab10b 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.zh.md @@ -42,7 +42,7 @@ one-shot child——前台与后台皆然——经由 `SubagentRuntime.start()` - 除非部署在 fork 委派工具上配置了 `persona` 或 `toolFilter`,fork child 的请求前缀与其 parent 逐字节相同,因此初始内容的 token 成本重新换来了提供方侧的复用。 - fork 提供方的可继续路径没有生产调用方,也没有整体组装层面的覆盖。它保留自己的包内测试,seam 也仍然接受它,因此某个组合包或 `--patch` 覆盖层可以无需改动代码、也不会有任何警告地把它重新引入。 - `subagent_fork` 面向模型的 schema 发生变化:base 组合包中可继续的后台措辞被 one-shot 的 task 措辞取代,在两个示例中则完全消失。受影响的无密钥快照工具 schema 伴随文件在同一次改动中重新记录。 -- 在随附部署中,report 义务的覆盖范围收窄到 spawn 出的 child。它的 `wakeup` 默认调度、权限模型与覆盖均保持不变。 +- 在随附部署中,report 义务的覆盖范围收窄到 spawn 出的 child。它的 `next-step` 默认调度、权限模型与覆盖仍独立于 fork 组合。 ### 已接受的风险 diff --git a/.agents/notes/implemented/simplification/2026-08-11-remove-empty-experimental-package-group.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.i18n.yaml similarity index 52% rename from .agents/notes/implemented/simplification/2026-08-11-remove-empty-experimental-package-group.i18n.yaml rename to .agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.i18n.yaml index 945a68054c..1b56228555 100644 --- a/.agents/notes/implemented/simplification/2026-08-11-remove-empty-experimental-package-group.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-08-11-remove-empty-experimental-package-group.md -2026-08-11-remove-empty-experimental-package-group.md: e5e81e3e3763f216921b3f3b74709b64be3dee37 -2026-08-11-remove-empty-experimental-package-group.zh.md: d44d0daaf346a5fea317f8c8c6a23f26eaa3cec0 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.md +2026-08-18-experimental-agent-teams-packages.md: cec73be48a5cde25fae570587d2ecd17a4a87d01 +2026-08-18-experimental-agent-teams-packages.zh.md: 71b702daa8694fddec6e2a0972704f19955cb509 diff --git a/.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.md b/.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.md new file mode 100644 index 0000000000..cec73be48a --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.md @@ -0,0 +1,37 @@ +# Agent Note: Incubate Agent Teams as private experimental packages + +Status: implemented + +English | [中文](2026-08-18-experimental-agent-teams-packages.zh.md) + +## Problem + +Agent Teams needs the real Session log, subagent lifecycle, tools, examples, snapshots, and repository checks while its service and tool contracts continue to change. Placing those packages in a product-role group makes them members of the dsh release family and gives them the same publication expectation as stable packages. + +An experimental directory without a current package previously imposed placement, dependency, promotion, and release rules on no consumer. Agent Teams supplies the concrete consumer, but the directory needs mechanical release exclusion and dependency isolation rather than a documentation-only status. + +## Decision + +`packages/experimental/team` and `packages/experimental/tool-team` are private workspace packages. Their npm names remain `@deepseek-ai/dsh-team` and `@deepseek-ai/dsh-tool-team`, so promotion changes paths and release metadata without renaming imports. + +The dsh release family and local baseline publisher exclude every manifest below `packages/experimental/`. Workspace constraints require each experimental package to set `private: true` and omit `publishConfig`. The same top-level check rejects `dependencies`, `optionalDependencies`, and `peerDependencies` from release packages, release apps, or the Python runtime to an experimental package. Experimental packages may depend on release packages and each other; tests may use them through `devDependencies`, and examples may load them explicitly. + +The generic caller-reserved continuable child identity and selective direct-child drain remain in the stable Subagent service. They own Subagent identity and Activation lifecycle without importing or naming Agent Teams; the experimental Team service consumes them in the permitted direction. + +Experimental status changes publication and compatibility expectations only. The packages retain the repository's ordinary documentation, invariant, lifecycle, security, unit, real-composition, and snapshot requirements. Promotion requires review of the public contracts, limitations, test evidence, release payload, runtime dependents, and a named owner accepting stable-package obligations. + +## Alternatives considered + +**Keep Agent Teams in a product-role group and describe it as opt-in.** Opt-in composition controls model behavior but does not exclude packages from publication or prevent stable packages from taking runtime dependencies on them. + +**Reserve an empty experimental group.** A directory without a current package has no owner or release mechanism to test. The group exists only while concrete packages need its enforced treatment. + +**Move the Subagent prerequisites into the experimental directory.** Child identity allocation and Activation teardown belong to the Subagent owner and contain no Team-specific contract. Moving or duplicating them would invert the dependency or split one lifecycle across packages. + +**Publish experimental packages under different npm names.** Experimental suffixes would make promotion rename every import and configuration row without strengthening release exclusion; private manifests and release-family filtering enforce the actual requirement. + +## Consequences + +Agent Teams can use the full repository graph and quality checks without entering official tarballs or becoming a supported runtime dependency. A release package cannot expose Team until the Team packages are promoted, so CLI and Web experiments use explicit example or experimental compositions instead of the shipped base bundles. + +The product-role grouping is less direct while the packages incubate, and promotion creates path churn. Keeping npm names stable limits that churn to repository paths, metadata, documentation, and composition. diff --git a/.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.zh.md b/.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.zh.md new file mode 100644 index 0000000000..71b702daa8 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.zh.md @@ -0,0 +1,37 @@ +# Agent Note: 将 Agent Teams 作为私有实验性包孵化 + +Status: implemented + +[English](2026-08-18-experimental-agent-teams-packages.md) | 中文 + +## 问题 + +Agent Teams 的服务与工具约定仍在变化,但它需要使用真实 Session 日志、subagent 生命周期、工具、示例、快照和仓库检查。把这些包放在产品职责组会使其成为 dsh 发布系列成员,并获得与稳定包相同的发布预期。 + +没有实际包的 experimental 目录曾经让没有消费方的放置、依赖、promotion 和发布规则长期存在。Agent Teams 提供了具体消费方,但该目录需要机械强制的发布排除与依赖隔离,不能只用文档标记状态。 + +## 决策 + +`packages/experimental/team` 与 `packages/experimental/tool-team` 是私有 workspace 包。其 npm 名仍为 `@deepseek-ai/dsh-team` 与 `@deepseek-ai/dsh-tool-team`,因此 promotion 只需修改路径与发布元数据,无需重命名 import。 + +dsh 发布系列与本地 baseline 发布器均排除 `packages/experimental/` 下的所有 manifest。workspace 约束要求每个实验性包设置 `private: true` 并省略 `publishConfig`。同一个顶层检查会拒绝发布包、发布 app 或 Python runtime 通过 `dependencies`、`optionalDependencies` 或 `peerDependencies` 依赖实验性包。实验性包可以依赖发布包和其他实验性包;测试可以通过 `devDependencies` 使用它们,示例可以显式加载它们。 + +通用的调用方预留 continuable child 身份和精确 direct-child drain 仍属于稳定 Subagent 服务。它们负责 Subagent 身份与 Activation 生命周期,不 import 或命名 Agent Teams;实验性 Team 服务沿允许的方向消费这些能力。 + +实验性状态只改变发布与兼容性预期。这些包仍须满足仓库的一般文档、不变式、生命周期、安全、单元测试、真实组合测试和快照要求。promotion 前必须评审公开约定、限制、测试证据、发布 payload、运行时依赖方,并由一名具名 owner 接受稳定包义务。 + +## 曾考虑的替代方案 + +**把 Agent Teams 留在产品职责组,并标为显式启用。** 显式启用的组合可以控制模型行为,但不会阻止包发布,也不能阻止稳定包对其建立运行时依赖。 + +**预留空的 experimental 组。** 没有实际包的目录没有 owner,也没有可供测试的发布机制。只有具体包需要这套强制处理时,该组才存在。 + +**把 Subagent 前置能力移入 experimental 目录。** child 身份分配与 Activation teardown 属于 Subagent owner,且不包含 Team 专用约定。移动或复制这些能力会反转依赖方向,或把同一个生命周期拆到多个包中。 + +**使用不同的 npm 名发布实验性包。** experimental 后缀会让 promotion 重命名所有 import 与配置项,却不能加强发布排除;私有 manifest 与发布系列过滤已经强制执行实际要求。 + +## 后果 + +Agent Teams 可以使用完整仓库依赖图与质量检查,而不进入正式 tarball,也不会成为受支持的运行时依赖。在 Team 包 promotion 前,发布包不能暴露 Team,因此 CLI 和 Web 实验使用显式示例或实验性组合,而不是已发布的基础组合包。 + +孵化期间的产品职责分组不够直接,promotion 也会产生路径改动。保持 npm 名稳定后,改动范围仅包括仓库路径、元数据、文档和组合。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-17-image-dimension-admission-limit.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-17-image-dimension-admission-limit.i18n.yaml new file mode 100644 index 0000000000..d04cbbe0e1 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-17-image-dimension-admission-limit.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-17-image-dimension-admission-limit.md +2026-08-17-image-dimension-admission-limit.md: 027259c0949d142ce8d8af27e7daa2abd54769ab +2026-08-17-image-dimension-admission-limit.zh.md: 3b66fe9a474f965653f94dacc7e0b8b0d0b9229a diff --git a/.agents/notes/implemented/bug-fix/2026-08-17-image-dimension-admission-limit.md b/.agents/notes/implemented/bug-fix/2026-08-17-image-dimension-admission-limit.md new file mode 100644 index 0000000000..027259c094 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-17-image-dimension-admission-limit.md @@ -0,0 +1,30 @@ +# Agent Note: Per-side image dimension admission limit + +Status: implemented + +English | [中文](2026-08-17-image-dimension-admission-limit.zh.md) + +## Problem + +`read_image` durably committed an image and appended its block to session history before any dimension check beyond byte count and total pixels. Deployed model routes reject a request with HTTP 400 when it carries many images and any of them has a side above 2000px. An admitted image rides every later request of its session, so one oversized read poisoned the durable history: the next model request failed, and so did every retry, permanently killing the session. The same gap applied to every other image producer (host uploads, MCP tool images) because admission had no per-side bound at all. + +## Decision + +`ImageAttachmentLimits` carries `maxImageDimension`, enforced during the admission full decode (`detectImage`) as `IMAGE_DIMENSION_TOO_LARGE`, so every producer that commits through the attachment service refuses an oversized image before anything reaches durable history. `LocalAttachmentStore` exposes it as the `maxImageDimension` config field with default `DEFAULT_MAX_IMAGE_DIMENSION = 2000`, the strictest per-side bound deployed routes enforce; deployments with laxer routes raise it from cordis.yml. `read_image` maps `IMAGE_DIMENSION_TOO_LARGE` and `IMAGE_TOO_MANY_PIXELS` to model-facing errors that name the resolved path and the limit and tell the model to downscale and retry — the turn continues as a recoverable tool error. The Web composer surfaces `IMAGE_DIMENSION_TOO_LARGE` with dedicated copy naming the limit. The `read-image-dimension` snapshot scenario replays the refusal keylessly through the assembled app: a 2001x1 workspace fixture, a recoverable tool error, and a completed turn. + +## Alternatives considered + +- **Downscale at admission instead of refusing.** Resampling changes the stored bytes away from what the caller supplied, adds a resampling-quality policy, and hides the limit from the model. Refusal keeps admission a pure gate; the model or user can downscale with full knowledge. Worth revisiting only if refusals prove frequent in practice. +- **Enforce at the provider adapter per route.** Too late: by the time a request is assembled the image is already durable history, so every route and every retry re-fails. Admission is the last point where a provider-rejected image can be kept out. +- **Repair already-poisoned sessions** (drop or replace the oversized block on later requests). Out of scope for this fix; admission prevents new poisonings, and history rewriting needs its own design against the model-visible ⟺ logged invariant. + +## Related + +- [Minimal read_image tool](../feature/2026-08-10-minimal-read-image-tool.md) — the tool whose admission gap this closes. +- [Web image intake and limits alignment](../feature/2026-08-12-web-image-intake-and-limits-alignment.md) — the composer-side surfacing of the same `ImageAttachmentLimits`. + +## Consequences + +- One oversized `read_image` can no longer break a session; the model sees an actionable error and the turn completes. +- Images with a side above 2000px are refused even in compositions whose routes would accept them on small requests; such deployments must raise `maxImageDimension` explicitly. +- Sessions that already carry an oversized image remain broken; this change does not repair existing history. diff --git a/.agents/notes/implemented/bug-fix/2026-08-17-image-dimension-admission-limit.zh.md b/.agents/notes/implemented/bug-fix/2026-08-17-image-dimension-admission-limit.zh.md new file mode 100644 index 0000000000..3b66fe9a47 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-17-image-dimension-admission-limit.zh.md @@ -0,0 +1,30 @@ +# Agent Note: 图片单边尺寸准入上限 + +Status: implemented + +[English](2026-08-17-image-dimension-admission-limit.md) | 中文 + +## Problem + +`read_image` 在字节数与总像素之外没有任何尺寸检查,就把图片持久提交并追加进会话历史。已部署的模型路由在请求携带多张图片且其中任何一张单边超过 2000px 时会以 HTTP 400 拒绝整个请求。已接纳的图片会随该会话之后的每次请求发送,因此一次超限读取就毒化了持久历史:下一次模型请求失败,之后的每次重试同样失败,会话被永久杀死。其他图片来源(宿主上传、MCP 工具图片)存在同样的缺口,因为准入完全没有单边上限。 + +## Decision + +`ImageAttachmentLimits` 增加 `maxImageDimension`,在准入完整解码(`detectImage`)中以 `IMAGE_DIMENSION_TOO_LARGE` 强制执行,因此所有经附件服务提交的来源都会在任何内容进入持久历史之前拒绝超限图片。`LocalAttachmentStore` 将其暴露为 `maxImageDimension` 配置项,默认值 `DEFAULT_MAX_IMAGE_DIMENSION = 2000`,即已部署路由强制执行的最严格单边上限;路由更宽松的部署可在 cordis.yml 中调高。`read_image` 把 `IMAGE_DIMENSION_TOO_LARGE` 与 `IMAGE_TOO_MANY_PIXELS` 映射为面向模型的错误,指明解析后的路径与上限并提示缩图重试,本轮以可恢复的工具错误继续。Web 输入框对 `IMAGE_DIMENSION_TOO_LARGE` 给出指明上限的专用文案。`read-image-dimension` 快照场景通过组装后的应用无 key 回放这次拒绝:2001x1 的工作区 fixture、一条可恢复的工具错误、一个正常完成的轮次。 + +## Alternatives considered + +- **准入时缩图而非拒绝。** 重采样会让存储字节偏离调用方提供的内容,引入重采样质量策略,还会对模型隐藏上限。拒绝让准入保持为纯粹的门禁;模型或用户可以在知情的前提下自行缩图。只有当拒绝在实践中频繁出现时才值得重新考虑。 +- **在 provider 适配器按路由强制执行。** 为时已晚:组装请求时图片已是持久历史,每条路由、每次重试都会再次失败。准入是把必然被上游拒绝的图片挡在外面的最后一道关口。 +- **修复已被毒化的会话**(在之后的请求中丢弃或替换超限图片块)。不在本次修复范围内;准入阻止新的毒化,而重写历史需要针对「模型可见 ⟺ 已记录」不变量单独设计。 + +## Related + +- [最小 read_image 工具](../feature/2026-08-10-minimal-read-image-tool.md),本次修复补上的正是该工具的准入缺口。 +- [Web 图片摄入与限制对齐](../feature/2026-08-12-web-image-intake-and-limits-alignment.md),同一组 `ImageAttachmentLimits` 在输入框侧的呈现。 + +## Consequences + +- 一次超限的 `read_image` 不再能弄坏会话;模型看到可操作的错误,轮次正常完成。 +- 单边超过 2000px 的图片即使在其路由本可接受(小请求)的组合中也会被拒绝;这类部署必须显式调高 `maxImageDimension`。 +- 已经携带超限图片的会话仍然是坏的;本次改动不修复既有历史。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-17-subagent-report-settlement-ordering.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-17-subagent-report-settlement-ordering.i18n.yaml new file mode 100644 index 0000000000..a43c34d0d1 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-17-subagent-report-settlement-ordering.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-17-subagent-report-settlement-ordering.md +2026-08-17-subagent-report-settlement-ordering.md: 30dfab5e96a7cea2ef6d4f03f480d17a86c5e775 +2026-08-17-subagent-report-settlement-ordering.zh.md: 658eb18e3a8cb40734136af32c6c62faef066a6e diff --git a/.agents/notes/implemented/bug-fix/2026-08-17-subagent-report-settlement-ordering.md b/.agents/notes/implemented/bug-fix/2026-08-17-subagent-report-settlement-ordering.md new file mode 100644 index 0000000000..30dfab5e96 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-17-subagent-report-settlement-ordering.md @@ -0,0 +1,44 @@ +# Agent Note: Subagent reports precede their settlement notices + +Status: implemented + +English | [中文](2026-08-17-subagent-report-settlement-ordering.zh.md) + +## Problem + +A continuable child can explicitly report selected content and later produce an unconditional manager-authored settlement notice. Report delivery used `Agent.followup()` and entered the parent's `next-turn` queue, while settlement delivery to a running parent used `Agent.steer()` and entered `next-step`. The first step of a turn claims the complete `next-step` batch before one `next-turn` message, so the later settlement notice could reach the model before the earlier report. The assembled report scenario required `reportDelivery: quiet` to avoid that nondeterministic interleaving. [Issue #2600](https://github.com/deepseek-harness/deepseek-harness/issues/2600) records the defect. + +The report tool tells a child to report whenever a finding changes what its parent should do next. Deferring that message to a later turn contradicted the tool's scheduling meaning and separated causally ordered messages across queues with different claim priority. + +## Decision + +`SubagentReportDelivery` is `'quiet' | 'next-step'`, and `next-step` is the default. Next-step delivery calls `parent.steer()`, so a running parent reads the report at its nearest safe step boundary and an idle parent starts a turn. Quiet delivery continues to call `parent.inject()` and enters the same queue without waking an idle parent. + +The continuation manager retains `sendWaking()` and `admitWaking()` around next-step reports delivered to resident continuable parents. Their purpose is waking-send admission accounting, independent of whether the message targets a step or a turn: the receiving Activation remains live between synchronous inbox insertion and the microtask that observes the wake. + +### Ordering across parent states + +A running parent receives an accepted report and the child's later settlement notice in the same `next-step` FIFO. If the parent becomes idle before settlement arrives, it has already claimed the report; settlement may then open a later turn without reversing the observed order. + +During parent maintenance, the report occupies `next-step` and latches a wake, while settlement may occupy `next-turn` because maintenance reports idle status. The initial claim still takes next-step input before the queued turn. Waking input submitted after cancellation is redirected by `Agent.send()` to `next-turn`, so report and settlement follow the core agent's cancellation convergence rather than bypassing it. + +### Verification + +The report package holds a parent inside an active model request, submits a child report, settles that child, and asserts the pending parent batch is ordered `subagent-report`, then `subagent-settled`, with no queued later turn. Separate coverage pins repeated reports as one FIFO next-step batch, idle-parent wakeup, and waking admission accounting for a continuable parent. + +The assembled ACP report scenario uses the shipped default. Its scheduling fence keeps the child behind the parent's delegation turn and holds the parent in maintenance until settlement follows the report. The report latches the wake while the settlement notice queues a turn; when maintenance ends, the parent claims next-step input before next-turn input and observes both notices in causal order without a quiet-delivery overlay. + +## Alternatives considered + +**Keep the `wakeup` name but change its implementation to `steer()`.** The existing public description defined `wakeup` as one later parent turn. Reusing the value for a different inbox target would leave configuration unable to state the behavior it selects. The pre-release configuration instead names `next-step` directly. + +**Expose `quiet | next-step | next-turn`.** A next-turn report still permits a later next-step settlement notice to overtake it. Preserving report-before-settlement would require a cross-queue ordering barrier, and no current deployment requires next-turn isolation strongly enough to own that mechanism. + +**Move settlement notices to `next-turn`.** Settlement batching deliberately uses the next-step queue so several children finishing together cost one parent step instead of one turn each. Moving settlement would increase latency and model work to retain a report scheduling mode with no current consumer. + +## Consequences + +- A report may extend an open parent turn. It never interrupts the active model request or tool execution; the agent loop admits it only at a step boundary. +- Reports accepted together share one next-step batch, preserving FIFO order and reducing the turn amplification of the former one-turn-per-report behavior. +- The `wakeup` configuration value is rejected rather than retained as an alias. This repository has no external pre-release compatibility promise for Cordis configuration. +- `quiet` remains the deployment escape for reports that must not wake a parked parent, with the existing risk that no model reads them until another waking input arrives. diff --git a/.agents/notes/implemented/bug-fix/2026-08-17-subagent-report-settlement-ordering.zh.md b/.agents/notes/implemented/bug-fix/2026-08-17-subagent-report-settlement-ordering.zh.md new file mode 100644 index 0000000000..658eb18e3a --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-17-subagent-report-settlement-ordering.zh.md @@ -0,0 +1,44 @@ +# Agent Note: Subagent report 先于其结算通知 + +Status: implemented + +[English](2026-08-17-subagent-report-settlement-ordering.md) | 中文 + +## 问题 + +可继续 child 可以显式上报选中内容,之后还会产生一条由管理器撰写且无条件投递的结算通知。报告投递曾使用 `Agent.followup()` 并进入 parent 的 `next-turn` 队列,而面向运行中 parent 的结算投递使用 `Agent.steer()` 并进入 `next-step`。一个轮次的第一个 step 会先领取完整 `next-step` 批次,再领取一条 `next-turn` 消息,因此较晚的结算通知可能先于较早的报告到达模型。整体组装的报告场景必须使用 `reportDelivery: quiet`,才能避开这种不确定交错。[Issue #2600](https://github.com/deepseek-harness/deepseek-harness/issues/2600)记录了该缺陷。 + +report 工具要求 child 在发现会改变 parent 下一步动作的信息时上报。把这条消息推迟到后续轮次,既违背了工具的调度含义,也让具有因果顺序的消息分散到领取优先级不同的队列中。 + +## 决策 + +`SubagentReportDelivery` 为 `'quiet' | 'next-step'`,默认值为 `next-step`。Next-step 投递调用 `parent.steer()`,因此运行中的 parent 会在最近的安全 step 边界读取报告,空闲 parent 则会启动一个轮次。静默投递继续调用 `parent.inject()`,进入同一队列但不唤醒空闲 parent。 + +对于投递到驻留可继续 parent 的 next-step 报告,继续执行管理器会保留外围的 `sendWaking()` 与 `admitWaking()`。它们负责唤醒发送的准入记账,与消息面向 step 还是 turn 无关:接收方 Activation 在同步插入 inbox 与观察该唤醒的微任务之间保持在线。 + +### 不同 parent 状态下的顺序 + +运行中的 parent 会在同一个 `next-step` FIFO 中接收已接受的报告和该 child 稍后的结算通知。若 parent 在结算到达前变为空闲,它已经领取了报告;结算随后可以开启一个更晚的轮次,而不会反转观察顺序。 + +parent 处于 maintenance 时,报告占据 `next-step` 并锁存一次唤醒,而结算可能因为 maintenance 呈现空闲状态而占据 `next-turn`。首次领取仍会先取 next-step 输入,再取排队轮次。取消后提交的唤醒输入会由 `Agent.send()` 重定向到 `next-turn`,因此报告和结算会遵循核心 agent 的取消收敛,而不会绕过它。 + +### 验证 + +report 包把 parent 保持在一个活动模型请求中,提交 child 报告,再让该 child 结算,并断言等待中的 parent 批次按 `subagent-report`、`subagent-settled` 排序,且没有排队的后续轮次。独立覆盖还会固定重复报告形成一个 FIFO next-step 批次、空闲 parent 唤醒,以及可继续 parent 的唤醒准入记账。 + +整体组装的 ACP 报告场景使用随附默认值。调度围栏让 child 等到 parent 的委派轮次之后,并让 parent 保持 maintenance,直至结算跟在报告之后到达。报告会锁存唤醒,结算通知则排入后续轮次;maintenance 结束时,parent 先领取 next-step 输入、再领取 next-turn 输入,因此无需静默投递 overlay 也能按因果顺序观察两条通知。 + +## 备选方案 + +**保留 `wakeup` 名称,但把其实现改为 `steer()`。** 既有公开描述把 `wakeup` 定义为一个后续 parent 轮次。让该值复用于不同的 inbox 目标,会使配置无法准确说明自己选择的行为。预发布配置因此直接使用 `next-step` 名称。 + +**暴露 `quiet | next-step | next-turn`。** Next-turn 报告仍可能被稍后的 next-step 结算通知超越。要保住报告先于结算,需要跨队列顺序屏障;当前没有任何部署对 next-turn 隔离的需求强到足以承担该机制。 + +**把结算通知移到 `next-turn`。** 结算批处理刻意使用 next-step 队列,使多个一起结束的 child 只花费 parent 的一个 step,而不是各自一个轮次。移动结算会增加延迟和模型工作量,只为保留一个没有当前消费方的报告调度模式。 + +## 后果 + +- 报告可能延长已打开的 parent 轮次。它绝不会打断活动模型请求或工具执行;agent loop 只会在 step 边界准入它。 +- 一起接受的报告会共享一个 next-step 批次,保持 FIFO 顺序,并减少原先每份报告各占一个轮次所造成的轮次放大。 +- `wakeup` 配置值会被拒绝,而不是保留为别名。本仓库对预发布 Cordis 配置不作外部兼容承诺。 +- 对于不得唤醒停驻 parent 的报告,`quiet` 仍是部署退路,同时保留既有风险:在另一条唤醒输入到达之前,没有模型会读取这些报告。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-18-request-image-payload-bound.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-18-request-image-payload-bound.i18n.yaml new file mode 100644 index 0000000000..f797c08086 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-18-request-image-payload-bound.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-18-request-image-payload-bound.md +2026-08-18-request-image-payload-bound.md: df10df39d18c7da4660b566e8f0b6a5a60ff8dc1 +2026-08-18-request-image-payload-bound.zh.md: 070f2d194f1459f3f2728fdf9d5d2db2c3a24385 diff --git a/.agents/notes/implemented/bug-fix/2026-08-18-request-image-payload-bound.md b/.agents/notes/implemented/bug-fix/2026-08-18-request-image-payload-bound.md new file mode 100644 index 0000000000..df10df39d1 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-18-request-image-payload-bound.md @@ -0,0 +1,35 @@ +# Agent Note: Request-level image payload bound + +Status: implemented + +English | [中文](2026-08-18-request-image-payload-bound.zh.md) + +## Problem + +Every image in session history is base64-inlined into every model request by the pi-ai adapter, so a long session's request body grows monotonically with each admitted image. Gateways cap request-body size; once the accumulated payload crossed such a cap the request was rejected with 413 (`Failed to buffer the request body: length limit exceeded`), and because nothing bounds or trims the assembled request, every retry resent the same oversized body. The session was permanently unusable, and the failure text matched no `classifyPiAiError` rule, so it surfaced as the generic `PI_AI_ERROR`. Admission bounds (per image, per message) cannot prevent this: each image is individually admissible, and the sum still grows without bound. Two screenshots were enough to trigger it in production. + +## Decision + +The pi-ai provider profile carries `maxRequestImageBytes` (default `DEFAULT_MAX_REQUEST_IMAGE_BYTES = 20MiB`, a positive integer, per route, changeable from cordis.yml and the `llm-pi-ai` settings section). At request conversion, `toPiContext` sums the base64 length of every image in history (derived from `ImageAttachmentRef.bytes` without reading data) and, while the sum exceeds the bound, replaces the oldest images with a fixed model-facing placeholder. The placeholder tells the model to read the file again when a path is available or ask the user to attach the image again. The most recent images are omitted last; an image larger than the bound is itself omitted. Offload locations use message and nested block indexes rather than object identity, so replaying the same JSON log produces the same request. Offloaded images are never read from the attachment store. `classifyPiAiError` classifies 413 and specific request-body-cap wording as `INVALID_REQUEST` (resending the same body cannot succeed). Four images admitted at the attachment store's 3.5MiB raw-image default occupy at most 18.67MiB after base64 expansion. The 20MiB request-image default therefore retains four such images and reserves the rest of a 32MiB request for system prompts, history, tools, and JSON. Deployments behind stricter gateways lower the value per route. + +## Offload is conversion, not history + +The placeholder is model-visible but not logged as a session event. It stays within the model-visible ⟺ logged invariant the same way the adapter's other serialization does (`(no output)` fallbacks, text-only folding): the offload locations are a pure function of the logged history and the route configuration, so the exact request remains reconstructable from the session log plus the composition. A logged elision event becomes necessary only when offload decisions gain non-deterministic inputs (for example live gateway feedback), which belongs to the deferred capability-metadata design. + +## Alternatives considered + +- **Fail the request with a clear error instead of offloading.** Keeps the model informed but leaves the session wedged: the user cannot remove images from durable history, so a hard failure at the bound is permanent. Offload keeps the session serviceable, which is the point of the fix. +- **Upload images once and reference them by URL / file id.** Removes the linear body growth entirely and is the right medium-term shape (providers and the internal gateway both document a Files path), but it introduces upload lifecycle management across providers and is far beyond a P0 hotfix. +- **Count the full request body, not only images.** Text and tools contribute little and their sizes are only known after full serialization per protocol; bounding the dominant term with explicit headroom is accurate enough for the failure being fixed and much simpler. Revisit inside the route-capability design. +- **Trim at admission instead.** Admission cannot see future accumulation; only the assembled request knows its total. Admission-side bounds (per-side dimension, bytes) remain as the first layer and are owned by [the dimension-limit note](2026-08-17-image-dimension-admission-limit.md). + +## Related + +- [Per-side image dimension admission limit](2026-08-17-image-dimension-admission-limit.md) — the admission-layer companion fix; together they close the two observed session-poisoning failures (400 dimension, 413 body size). + +## Consequences + +- An image-heavy long session keeps completing requests. The oldest images are omitted first; the most recent image is omitted only when it cannot fit within the bound. +- Crossing the bound rewrites an early message, so the provider prompt-cache prefix ends at the newly offloaded image until the offloaded prefix stabilizes. +- The bound counts base64 image payload only; deployments must keep it below their gateway's request-body cap with headroom, and the shipped default cannot know a private gateway's cap. +- Route capability metadata driving admission and assembly together (image count, per-image size, request size, provider token formulas) remains deferred design work tracked outside this fix. diff --git a/.agents/notes/implemented/bug-fix/2026-08-18-request-image-payload-bound.zh.md b/.agents/notes/implemented/bug-fix/2026-08-18-request-image-payload-bound.zh.md new file mode 100644 index 0000000000..070f2d194f --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-18-request-image-payload-bound.zh.md @@ -0,0 +1,35 @@ +# Agent Note: 请求级图片载荷上限 + +Status: implemented + +[English](2026-08-18-request-image-payload-bound.md) | 中文 + +## Problem + +pi-ai 适配器把会话历史中的每张图片 base64 内联进每一个模型请求,长会话的请求体随每张入库图片单调增长。网关对请求体大小设有上限;累积载荷一旦越线,请求被以 413 拒绝(`Failed to buffer the request body: length limit exceeded`),而组装层没有任何约束或裁剪,每次重试都会原样重发同一个超限请求体,会话永久不可用。该报错文本不匹配 `classifyPiAiError` 的任何规则,只能落进笼统的 `PI_AI_ERROR`。准入上限(单图、单消息)无法阻止这一点:每张图片单独看都合规,总和仍然无界增长。线上两张截图即可触发。 + +## Decision + +pi-ai provider profile 增加 `maxRequestImageBytes`(默认 `DEFAULT_MAX_REQUEST_IMAGE_BYTES = 20MiB`,正整数,按路由生效,可从 cordis.yml 与 `llm-pi-ai` settings 段修改)。请求转换时,`toPiContext` 由 `ImageAttachmentRef.bytes` 推算每张历史图片的 base64 长度(无需读取数据)求和,总和超过上限时从最老的图片开始替换为一段固定的模型可见占位文本。占位文本要求模型在有路径时重新读取文件,否则请用户重新附上图片。越新的图片越晚被省略;单张图片本身超过上限时也会被省略。offload 位置用消息与嵌套块的索引表示,不依赖对象身份,因此重放同一份 JSON 日志会产生相同请求。被 offload 的图片不会从附件存储读取。`classifyPiAiError` 把 413 与明确的请求体上限措辞归类为 `INVALID_REQUEST`(原样重发不可能成功)。四张按附件存储默认上限准入的 3.5MiB 原始图片,经 base64 膨胀后最多占 18.67MiB。20MiB 请求图片默认上限因此可保留四张这样的图片,并在 32MiB 请求内为系统提示词、历史、工具与 JSON 保留其余容量。网关更严格的部署按路由调低该值。 + +## offload 是转换而非历史 + +占位文本模型可见,但不记录为会话事件。它与适配器的其他序列化(`(no output)` 回退、纯文本折叠)以同样的方式满足「模型可见 ⟺ 已记录」不变量:offload 位置是已记录历史与路由配置的纯函数,确切请求仍可由会话日志加组合配置重建。只有当 offload 决策引入非确定性输入(例如网关的实时反馈)时才需要记录省略事件,那属于暂缓的能力元数据设计。 + +## Alternatives considered + +- **在上限处直接报错而不 offload。** 模型知情,但会话仍然卡死:用户无法从持久历史中删除图片,越线即永久失败。offload 让会话保持可用,这正是本修复的目标。 +- **图片上传一次、按 URL / file id 引用。** 从结构上消除请求体线性增长,是正确的中期形态(各提供方与内部网关都有 Files 路径),但要跨提供方管理上传生命周期,远超 P0 热修复范围。 +- **统计完整请求体而非只统计图片。** 文本与工具占比很小,且其大小要到按协议完整序列化后才可知;对主导项设上限并留出显式余量,对所修故障足够精确且简单得多。留到路由能力设计中再议。 +- **改在准入侧裁剪。** 准入看不到未来的累积,只有组装后的请求知道自己的总量。准入侧上限(单边尺寸、字节)作为第一层保留,归[尺寸上限笔记](2026-08-17-image-dimension-admission-limit.md)所有。 + +## Related + +- [图片单边尺寸准入上限](2026-08-17-image-dimension-admission-limit.md),准入层的配套修复;两者合起来封住已观测到的两类会话毒化故障(400 尺寸、413 请求体)。 + +## Consequences + +- 图片较多的长会话持续可用。最老的图片优先省略;仅当最新图片本身无法装进上限时才会省略它。 +- 越过上限会改写较早的一条消息,提供方 prompt cache 前缀在新被 offload 的图片处截止,直到被 offload 的前缀稳定。 +- 上限只统计 base64 图片载荷;部署必须让它低于自家网关的请求体上限并留出余量,发行默认值无法预知私有网关的上限。 +- 由路由能力元数据同时驱动准入与组装(图片数量、单图大小、请求大小、提供方 token 公式)的设计仍为暂缓工作,在本修复之外跟踪。 diff --git a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.i18n.yaml b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.i18n.yaml index 1ed54f14c2..842f2c9cd3 100644 --- a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md -2026-07-22-web-multimodal-image-input-and-durable-attachments.md: 5162e0d6f63715ffd83cff1a622e07c110a7938e -2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md: d3a5cf18a7d308753d5e46741cea2b2993536f70 +2026-07-22-web-multimodal-image-input-and-durable-attachments.md: 6a12380992c3205d6bb3f5701f093b3335fae2f9 +2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md: a8e94154e435a5359e7928d3b11eba7d1aee92f7 diff --git a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md index 5162e0d6f6..6a12380992 100644 --- a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md +++ b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md @@ -140,9 +140,9 @@ Composer thumbnails and each `MessageImage` own ephemeral original-preview state ### Limits and trust boundaries -Version one accepts PNG, JPEG, WebP, and GIF only. SVG and remote URLs are excluded. Default limits are 5 MiB per image, 20 images and 100 MiB aggregate image bytes per message, and 40 million intrinsic pixels per image. These deployment-varying limits are validated backend configuration and enforced by the host before persistence. The client connection carrier has an independent configurable `maxRequestBodyBytes` cap (160 MiB by default) for every API request and fails load if it cannot hold the attachment service's aggregate image limit after base64 and envelope expansion; lowering image policy therefore never silently lowers the carrier limit for valid text or other RPCs. A body without a declared length is rejected the moment it crosses the cap rather than drained to its end. +Version one accepts PNG, JPEG, WebP, and GIF only. SVG and remote URLs are excluded. Default limits are 3.5 MiB per image, 20 images and 100 MiB aggregate image bytes per message, 40 million intrinsic pixels per image, and 2000 pixels on either side. These deployment-varying limits are validated backend configuration and enforced by the host before persistence. The client connection carrier has an independent configurable `maxRequestBodyBytes` cap (160 MiB by default) for every API request and fails load if it cannot hold the attachment service's aggregate image limit after base64 and envelope expansion; lowering image policy therefore never silently lowers the carrier limit for valid text or other RPCs. A body without a declared length is rejected the moment it crosses the cap rather than drained to its end. -Malformed base64, unsupported or mismatched media, truncated image payloads, excess bytes, excess image count, excess pixels, missing objects, and integrity mismatches return stable structured failures. Original filenames are reduced to a display basename, control characters are removed, and no local path is logged or returned to the browser. +Malformed base64, unsupported or mismatched media, truncated image payloads, excess bytes, excess image count, excess pixels, excess per-side dimensions, missing objects, and integrity mismatches return stable structured failures. Original filenames are reduced to a display basename, control characters are removed, and no local path is logged or returned to the browser. ### Package and surface changes diff --git a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md index d3a5cf18a7..a8e94154e4 100644 --- a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md +++ b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md @@ -140,9 +140,9 @@ Pi-AI 适配器是首条视觉输入路径:它在请求时解析 `ctx.attachme ### 限制与信任边界 -第一版仅接受 PNG、JPEG、WebP 和 GIF。不接受 SVG 和远程 URL。默认限制为每张图片 5 MiB、每条消息 20 张图片和 100 MiB 图片总字节数,以及每张图片 4,000 万个固有像素。这些随部署变化的限制属于经过校验的后端配置,并由宿主在持久化前强制执行。客户端连接载体为每个 API 请求设置独立且可配置的 `maxRequestBodyBytes` 上限(默认 160 MiB);如果该上限无法容纳附件服务的图片总量限制经 base64 和请求封装膨胀后的大小,加载就会失败。因此,降低图片策略绝不会静默降低有效文本或其他 RPC 的载体上限。未声明长度的请求体在越过上限的瞬间即被拒绝,而不是先读完再拒。 +第一版仅接受 PNG、JPEG、WebP 和 GIF。不接受 SVG 和远程 URL。默认限制为每张图片 3.5 MiB、每条消息 20 张图片和 100 MiB 图片总字节数、每张图片 4,000 万个固有像素,以及任一边 2,000 像素。这些随部署变化的限制属于经过校验的后端配置,并由宿主在持久化前强制执行。客户端连接载体为每个 API 请求设置独立且可配置的 `maxRequestBodyBytes` 上限(默认 160 MiB);如果该上限无法容纳附件服务的图片总量限制经 base64 和请求封装膨胀后的大小,加载就会失败。因此,降低图片策略绝不会静默降低有效文本或其他 RPC 的载体上限。未声明长度的请求体在越过上限的瞬间即被拒绝,而不是先读完再拒。 -格式错误的 base64、不支持或不匹配的媒体、截断的图片数据、超出字节限制、超出图片数量、超出像素限制、对象缺失和完整性不匹配都会返回稳定的结构化错误。原始文件名只保留用于显示的末段,控制字符会被移除,并且任何本地路径都不会写入日志或返回浏览器。 +格式错误的 base64、不支持或不匹配的媒体、截断的图片数据、超出字节限制、超出图片数量、超出像素限制、超出单边尺寸限制、对象缺失和完整性不匹配都会返回稳定的结构化错误。原始文件名只保留用于显示的末段,控制字符会被移除,并且任何本地路径都不会写入日志或返回浏览器。 ### 包与接口变更 diff --git a/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.i18n.yaml index 00ab095a71..145c22563f 100644 --- a/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md -2026-07-28-continuable-subagent-conversations.md: f00b3c8bf4da08363ca0b46ddda581811fbda214 -2026-07-28-continuable-subagent-conversations.zh.md: 6e1d279db8fec6cfb08371275a4a1fe97e9d7da0 +2026-07-28-continuable-subagent-conversations.md: f456bacbf775bf914b47051e19639811e2385f65 +2026-07-28-continuable-subagent-conversations.zh.md: b7f2080b157285e5928022b4ef9b9bf411c70191 diff --git a/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md index f00b3c8bf4..f456bacbf7 100644 --- a/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md +++ b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md @@ -157,7 +157,7 @@ It adds no host-user continuation, subagent steering operation, durable mailbox, **Let the provider create, resume, or deliver through an Agent handle.** Initial providers own only `prepareContinuable()` and its detached creation-spec distinction: whether a child begins fresh or with a parent prefix. The manager must call `ctx.agents.create()` through its private activation-owner scope so that scope is a structural owner of every handle. A persisted in-process Session already contains the initial prefix and generic reconstruction descriptor, while delivery belongs to the Agent inbox. Giving providers any later handle, `SubagentRun`, or message ownership would retain provider ownership with no shipped behavior to justify it. -**Make report delivery part of the base lifecycle.** Repeatable child-to-parent reporting is compatible with this lifecycle, but quiet versus waking delivery, acknowledgement, durability, and retry behavior are independent product choices. The later report package remains optional and consumes an explicit child-setup hook, so continuable residency does not silently grant a return channel. +**Make report delivery part of the base lifecycle.** Repeatable child-to-parent reporting is compatible with this lifecycle, but quiet versus next-step delivery, acknowledgement, durability, and retry behavior are independent product choices. The later report package remains optional and consumes an explicit child-setup hook, so continuable residency does not silently grant a return channel. **Treat `SessionHeader.parentSession` as live ownership.** Durable lineage does not prove that the recorded parent currently owns the child. Membership in the live parent's `ownedChildren` records the process-local relationship without changing the durable parent id. @@ -209,7 +209,7 @@ Retaining an Activation while descendants run consumes Agent resources proportio The process-local inbox and ownership graph do not coordinate two harness processes. Deployments allowing concurrent access to one persistence store still require a durable lease and mailbox protocol. -Without the optional report package, completing a child turn neither sends its content to nor wakes the historical parent. With the package, only an explicit `report` call sends selected content; quiet delivery does not wake the parent, while waking delivery enqueues one later turn. In every case the detailed child output remains in its durable Session. +Without the optional report package, completing a child turn neither sends its content to nor wakes the historical parent. With the package, only an explicit `report` call sends selected content; quiet delivery does not wake the parent, while next-step delivery wakes it and joins its nearest step boundary. In every case the detailed child output remains in its durable Session. Queueing every continuation message means a parent cannot correct an in-progress child turn immediately; the correction runs as the next turn. A later UI steering action may reduce that latency without changing follow-up ordering. diff --git a/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.zh.md b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.zh.md index 6e1d279db8..b7f2080b15 100644 --- a/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.zh.md +++ b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.zh.md @@ -157,7 +157,7 @@ activation-owner 作用域之所以存在,是因为普通 Cordis owner effect **让提供方通过 Agent handle 创建、恢复 child 或投递消息。** 初始提供方只持有 `prepareContinuable()` 及其分离式创建规格这一项差异:child 是全新启动,还是带有 parent 前缀。管理器必须通过私有 activation-owner 作用域自行调用 `ctx.agents.create()`,使该作用域成为每个 handle 的结构化所有者。持久化的进程内会话已经包含初始前缀及通用重建描述符,消息投递则属于 Agent inbox。让提供方持有任何后续 handle、`SubagentRun` 或消息所有权,会让提供方保留所有权,却没有已发布行为需要它。 -**将报告投递纳入基础生命周期。** 可重复的 child 到 parent 报告与该生命周期兼容,但静默投递还是唤醒投递、确认、持久性和重试行为都是独立的产品决策。后续的 report 包保持可选,并消费一个显式的 child 设置钩子,因此可继续驻留不会默认授予返回通道。 +**将报告投递纳入基础生命周期。** 可重复的 child 到 parent 报告与该生命周期兼容,但静默投递还是 next-step 投递、确认、持久性和重试行为都是独立的产品决策。后续的 report 包保持可选,并消费一个显式的 child 设置钩子,因此可继续驻留不会默认授予返回通道。 **将 `SessionHeader.parentSession` 视为在线所有权。** 持久化谱系不能证明已记录的 parent 当前持有 child。在线 parent 的 `ownedChildren` 成员关系会记录进程内关系,而不改变持久化 parent id。 @@ -209,7 +209,7 @@ activation-owner 作用域之所以存在,是因为普通 Cordis owner effect 进程内 inbox 和所有权图无法协调两个 harness 进程。允许多个进程并发访问同一持久化存储的部署,仍需要持久化 lease 和邮箱协议。 -未安装可选 report 包时,完成 child 轮次既不会把内容发送给历史 parent,也不会唤醒它。安装后,只有显式调用 `report` 才会发送选中内容;静默投递不唤醒 parent,唤醒投递则会排入一个后续轮次。无论如何,child 的详细输出都会保留在其持久化会话中。 +未安装可选 report 包时,完成 child 轮次既不会把内容发送给历史 parent,也不会唤醒它。安装后,只有显式调用 `report` 才会发送选中内容;静默投递不唤醒 parent,next-step 投递则会唤醒它并加入最近的 step 边界。无论如何,child 的详细输出都会保留在其持久化会话中。 将每条继续执行消息排队,意味着 parent 无法立即纠正正在进行的 child 轮次;纠正操作会在下一个轮次执行。后续 UI steering 操作可以缩短该延迟,而不改变 follow-up 排序。 diff --git a/.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.i18n.yaml index 7b2c3e6523..031760f25d 100644 --- a/.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.md -2026-07-30-continuable-subagent-report-tool.md: f120e65facc9c2549afd8aed589c6fc54921ef99 -2026-07-30-continuable-subagent-report-tool.zh.md: f35764cc82896f23ff50b07790078f125763f5c0 +2026-07-30-continuable-subagent-report-tool.md: 07d17f18f318a86070d9b8612512fa3c2a3815e2 +2026-07-30-continuable-subagent-report-tool.zh.md: 3d9947f2c12c6d2e34633113b67051894cac9f75 diff --git a/.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.md b/.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.md index f120e65fac..07d17f18f3 100644 --- a/.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.md +++ b/.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.md @@ -8,7 +8,7 @@ English | [中文](2026-07-30-continuable-subagent-report-tool.zh.md) Continuable in-process subagents can receive later parent messages, retain descendants, settle, and cold-resume, but the base lifecycle gives them no way to send selected content back to their direct parent. Their complete output already remains reconstructable from the durable child Session, so the missing capability is explicit delivery rather than result storage. -Treating every final assistant message as an implicit result would conflate turn completion with reporting. A long-lived child may have nothing useful to report in one turn, may report progress several times in another, and must remain available after reporting. Recipient authority, quiet versus waking delivery, acknowledgement, durability, and retry behavior therefore need one explicit contract. +Treating every final assistant message as an implicit result would conflate turn completion with reporting. A long-lived child may have nothing useful to report in one turn, may report progress several times in another, and must remain available after reporting. Recipient authority, quiet versus next-step delivery, acknowledgement, durability, and retry behavior therefore need one explicit contract. ## Decision @@ -20,7 +20,7 @@ The feature is a collaboration control, not a result-bearing execution wrapper. `report` accepts exactly `{ output: string }` and returns exactly `{ messageId: string }`. It accepts no child id, recipient id, or delivery mode. `exec.agent` binds the tool call to the reporting child, the service derives the sole recipient from durable `parentSession`, and deployment config owns scheduling. -`messageId` is the stable `MessageId` of the user-role message accepted by the parent. It is not an `InboxItemId`: quiet delivery creates no inbox occurrence, while waking delivery creates one occurrence for the same stable message. It is also not a read receipt, parent-log acknowledgement, turn-completion receipt, or persistence flush. +`messageId` is the stable `MessageId` of the user-role message accepted into the parent's inbox. It is not a read receipt, parent-log acknowledgement, turn-completion receipt, or persistence flush. The description states that reporting is required before finishing, repeatable, direct-parent-only, and non-terminal. It warns that a failed tool result may still follow an accepted send because a later `tools/post-execute` failure can replace the result. Without an idempotency key, stronger wording would encourage duplicate retries after ambiguous failure. @@ -36,17 +36,17 @@ Nested reporting crosses exactly one edge. A grandchild reports to its direct ch ### Delivery policy -The package validates `reportDelivery: 'quiet' | 'wakeup'`; the default is `wakeup` ([why the default reversed](2026-08-06-continuable-child-report-obligation.md)). +The package validates `reportDelivery: 'quiet' | 'next-step'`; the default is `next-step` ([ordering decision](../bug-fix/2026-08-17-subagent-report-settlement-ordering.md)). -Quiet delivery calls `parent.inject()`. It adds model-visible context without starting a parent model request: an idle parent appends before the call returns, while an admitting or running parent stages the report for the next safe log position. It creates no inbox occurrence and therefore no synthetic continuation-manager acceptance record. +Quiet delivery calls `parent.inject()`. It adds model-visible next-step context without waking an idle parent; a running parent stages the report for the next safe log position. -Waking delivery calls `parent.followup()`. It creates one ordinary FIFO parent turn, wakes a parked parent driver, and never steers an open turn. When that parent is itself a continuable Activation, the send uses the manager's existing admission accounting so the parent cannot settle between synchronous enqueue and the admission microtask. +Next-step delivery calls `parent.steer()`. It wakes a parked parent and joins a running parent's nearest step boundary. When that parent is itself a continuable Activation, the send uses the manager's existing admission accounting so the parent cannot settle between synchronous inbox insertion and the admission microtask. Reports share the next-step FIFO with a later settlement notice, preserving their accepted causal order. Both modes frame one user-role message as `Background subagent reported:` followed by the exact `output`. The durable message source is `{ kind: 'subagent-report', senderSessionId: child.id }`. Normal Agent ordering governs concurrent sends; the subagent layer creates no second queue. ### Acknowledgement and recovery -Success means the exact live parent synchronously accepted the message. An idle quiet injection is already appended at that boundary, while staged quiet context becomes reconstructable only when it reaches its normal log boundary. Waking delivery has an inbox occurrence whose id remains separate from the returned stable message id. +Success means the exact live parent synchronously accepted the message. The context becomes reconstructable only when it reaches its normal log boundary; a next-step delivery has woken the parent, while quiet delivery may remain pending. The inbox message id remains separate from the returned stable message id. The first version provides no durable mailbox, idempotency key, delivery receipt, retry protocol, or exactly-once claim. A process failure can leave the caller uncertain, and retry after an unknown outcome may duplicate a report. The durable child transcript remains the recovery source when the parent is unavailable. @@ -62,7 +62,7 @@ This seam keeps the continuation manager unaware of tool names. The report packa The ACP snapshot harness adds `waitForSubagentTurnEnd`, selecting the Nth harvested child by the same order as `session.N.jsonl`. It waits for a closed child turn containing a request header so a continuable child's earlier descriptor-seed turn cannot satisfy the boundary. This lets the assembled scenario wait for the child-side report without inventing a parent-visible signal. -The authored snapshot starts a continuable child, executes the real scope-local `report` tool, observes the one ordinary parent turn the default waking delivery creates, and then submits a later parent prompt that consumes the framed report. It declares child pins `1`, so the otherwise non-global `report` schema and the child's own prompt are checked against `tool-schemas.1.expected.json` and `system-prompt.1.expected.md` while the root keeps the class pins. The generated tool catalog separately mints a child scope to include the same scope-local schema. +The authored snapshot starts a continuable child, executes the real scope-local `report` tool, and observes default next-step delivery before the manager's later settlement notice. A snapshot-only maintenance fence holds the parent until both messages are pending, proving next-step input is claimed before queued next-turn input when the parent resumes. It declares child pins `1`, so the otherwise non-global `report` schema and the child's own prompt are checked against `tool-schemas.1.expected.json` and `system-prompt.1.expected.md` while the root keeps the class pins. The generated tool catalog separately mints a child scope to include the same scope-local schema. ## Alternatives considered @@ -76,7 +76,7 @@ Waking on every report creates unsolicited turns and can cascade through nested ### Let the child choose the delivery mode -Giving the model a mode argument grants it control over scheduler pressure and makes behavior deployment-dependent. The child chooses content and timing; deployment config chooses whether that content starts another Agent turn. +Giving the model a mode argument grants it control over scheduler pressure and makes behavior deployment-dependent. The child chooses content and timing; deployment config chooses whether that content wakes the parent. ### Register a global tool @@ -101,18 +101,18 @@ A post-creation revocation check can reject the Activation only after the Agent ## Consequences - A continuable in-process child exposes exactly one scope-local `report` schema only while the report package's contribution is installed; unrelated Agents never expose it. -- The tool returns the parent message's stable `MessageId`. Quiet delivery has no `InboxItemId`; waking delivery has a separate inbox occurrence. +- The tool returns the parent message's stable `MessageId`; its inbox occurrence is not a separate public identity. - Only the exact resident child may report, and only to the exact live direct parent derived from durable lineage. The service has no recipient parameter or offline fallback. -- Waking delivery is the validated default: it creates exactly one later FIFO turn and never steers an open turn. Quiet delivery never starts a parent request. +- Next-step delivery is the validated default: it wakes an idle parent or extends a running parent's turn at the nearest step boundary. Quiet delivery never wakes an idle parent. - Child cancellation or disposal after parent acceptance does not retract the report. Before acceptance, child disposal, drain, parent loss, or caller cancellation rejects the operation. - Fresh and resumed Activations compose current setup contributions before publication. Grants wait for the next Activation; revocation is immediate for resident children. - Unit coverage pins visibility, allow-list behavior, both delivery modes, stable message and sender identities, nested routing, invalid senders, absent parents, cancellation, drain, revocation races, and the absence of Jobs or implicit final reporting. -- The keyless assembled snapshot proves the real child tool, the one waking parent turn, durable parent framing, and later parent consumption. +- The keyless assembled snapshot proves the real child tool, default next-step ordering before settlement, and durable parent framing. ### Accepted risks The acceptance boundary is weaker than durable end-to-end delivery. A crash can leave the result ambiguous, and retries may duplicate reports. -Waking delivery can amplify model work when nested children report frequently. Deployment ownership through `reportDelivery` bounds but does not remove that risk. +Next-step delivery can amplify model work when nested children report frequently. Reports waiting together share one step, and deployment ownership through `reportDelivery` bounds but does not remove that risk. Registry presence is the parent liveness signal. A host-owned parent whose `AgentHandle.dispose()` has started but has not yet unwound its scope can still accept and append a report that it will not act on in this process. Closing that gap requires an Agent-level disposal-start signal rather than subagent-layer inference. diff --git a/.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.zh.md b/.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.zh.md index f35764cc82..3d9947f2c1 100644 --- a/.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.zh.md +++ b/.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.zh.md @@ -8,7 +8,7 @@ Status: implemented 可继续的进程内 subagent 能够接收 parent 后续发来的消息、保留后代、结算并冷恢复,但基础生命周期无法让它们将选中内容发送给直接 parent。child 的完整输出已可从持久化会话中重建,因此缺失的能力是显式投递,而非结果存储。 -如果将每条 assistant 最终消息都视为隐式结果,就会混淆轮次完成与报告。长期运行的 child 可能在某个轮次中无内容可报告,也可能在另一个轮次多次报告进展,而且报告后必须仍可继续工作。因此,接收方权限、静默投递与唤醒投递、确认、持久性和重试行为都需要一份显式约定。 +如果将每条 assistant 最终消息都视为隐式结果,就会混淆轮次完成与报告。长期运行的 child 可能在某个轮次中无内容可报告,也可能在另一个轮次多次报告进展,而且报告后必须仍可继续工作。因此,接收方权限、静默投递与 next-step 投递、确认、持久性和重试行为都需要一份显式约定。 ## 决策 @@ -20,7 +20,7 @@ Status: implemented `report` 只接受 `{ output: string }`,也只返回 `{ messageId: string }`。它不接受 child id、接收方 id 或投递模式。`exec.agent` 将工具调用绑定到发送报告的 child;服务从持久化 `parentSession` 中推导唯一接收方,调度则由部署配置决定。 -`messageId` 是 parent 接受的用户角色消息所对应的稳定 `MessageId`。它不是 `InboxItemId`:静默投递不创建 inbox 条目实例,唤醒投递则会为同一条稳定消息创建一个条目实例。它也不是已读回执、parent 日志确认、轮次完成回执或持久化 flush。 +`messageId` 是已接受进入 parent inbox 的用户角色消息所对应的稳定 `MessageId`。它不是已读回执、parent 日志确认、轮次完成回执或持久化 flush。 工具描述会明确报告操作在结束前必须执行、可重复、仅限直接 parent 且不会结束轮次。它还会警告:发送被接受后,后续 `tools/post-execute` 失败可能替换工具结果,因此工具结果失败时内容仍可能已经送达。没有幂等键时,更强的表述会诱导调用方在结果不明确的失败后重复重试。 @@ -36,17 +36,17 @@ root、one-shot child、伪造对象、陈旧 Agent 和同 id 替换对象都以 ### 投递策略 -该包会校验 `reportDelivery: 'quiet' | 'wakeup'`,默认值为 `wakeup`(见[默认值反转的理由](2026-08-06-continuable-child-report-obligation.md))。 +该包会校验 `reportDelivery: 'quiet' | 'next-step'`,默认值为 `next-step`(见[顺序决策](../bug-fix/2026-08-17-subagent-report-settlement-ordering.md))。 -静默投递调用 `parent.inject()`。它会添加模型可见上下文,但不启动 parent 模型请求:若 parent 空闲,则在调用返回前追加消息;若 parent 正在准入或运行,则暂存报告,留到下一个安全日志位置。该模式不创建 inbox 条目实例,因此也不会产生虚构的继续执行管理器接受记录。 +静默投递调用 `parent.inject()`。它会添加模型可见的 next-step 上下文,但不唤醒空闲 parent;运行中的 parent 会把报告暂存到下一个安全日志位置。 -唤醒投递调用 `parent.followup()`。它会创建一个普通的 FIFO parent 轮次,唤醒已停驻的 parent driver,且绝不 steering(中途引导)已开始的轮次。当该 parent 本身也是可继续 Activation 时,发送会使用管理器现有的准入计数,防止 parent 在同步入队与准入微任务之间结算。 +Next-step 投递调用 `parent.steer()`。它会唤醒停驻的 parent,并加入运行中 parent 最近的 step 边界。当该 parent 本身也是可继续 Activation 时,发送会使用管理器现有的准入记账,防止 parent 在同步插入 inbox 与准入微任务之间结算。报告与稍后的结算通知共享 next-step FIFO,从而保持其被接受时的因果顺序。 两种模式都会将一条用户角色消息封装为 `Background subagent reported:`,后面跟随完全原样的 `output`。持久化消息来源为 `{ kind: 'subagent-report', senderSessionId: child.id }`。并发发送的顺序由 Agent 的常规规则决定;subagent 层不会创建第二条队列。 ### 确认与恢复 -成功表示确切的在线 parent 已同步接受该消息。空闲 parent 在接受静默注入时已经完成追加,而暂存的静默上下文只有到达正常日志边界后才可重建。唤醒投递包含一个 inbox 条目实例,其 id 与返回的稳定消息 id 保持分离。 +成功表示确切的在线 parent 已同步接受该消息。上下文只有到达正常日志边界后才可重建;next-step 投递已经唤醒 parent,而静默投递可能继续等待。inbox 消息 id 不会成为另一个公开身份。 首个版本不提供持久化邮箱、幂等键、投递回执、重试协议或恰好一次保证。进程故障可能让调用方无法确定结果,在结果未知时重试则可能重复报告。parent 不可用时,持久化 child transcript(文本记录)仍是恢复来源。 @@ -62,7 +62,7 @@ subagent seam 新增 `registerContinuableSetup(contribution): () => void`,由 ACP(Agent Client Protocol)快照 harness 新增 `waitForSubagentTurnEnd`,按与 `session.N.jsonl` 相同的顺序选择第 N 个已收集 child。它会等待一个包含请求 header 的已闭合 child 轮次,以防可继续 child 早期播种描述符的轮次错误满足该边界。这样,整体组装的场景无需伪造 parent 可见信号,就能等待 child 侧报告。 -手写快照会启动一个可继续 child,执行真实的作用域局部 `report` 工具,观察默认唤醒投递所产生的那一个普通 parent 轮次,然后提交一条后续 parent 提示词,使其消费封装后的报告。它声明 child pin `1`,因此本不属于全局的 `report` schema 与该 child 自身的提示词会分别与 `tool-schemas.1.expected.json` 和 `system-prompt.1.expected.md` 比对,root 则继续使用类别 pin。生成的工具目录会另外铸造一个 child 作用域,以收录同一个作用域局部 schema。 +手写快照会启动一个可继续 child,执行真实的作用域局部 `report` 工具,并观察默认 next-step 投递先于管理器稍后的结算通知。一个仅用于快照的 maintenance 围栏会保持 parent,直至两条消息都处于待领取状态,从而证明 parent 恢复时先领取 next-step 输入、再领取排队的 next-turn 输入。它声明 child pin `1`,因此本不属于全局的 `report` schema 与该 child 自身的提示词会分别与 `tool-schemas.1.expected.json` 和 `system-prompt.1.expected.md` 比对,root 则继续使用类别 pin。生成的工具目录会另外铸造一个 child 作用域,以收录同一个作用域局部 schema。 ## 曾考虑的替代方案 @@ -76,7 +76,7 @@ ACP(Agent Client Protocol)快照 harness 新增 `waitForSubagentTurnEnd`, ### 允许 child 选择投递模式 -向模型提供 mode 参数会赋予其控制调度器压力的能力,并使行为依赖部署。child 只决定内容和时机;该内容是否启动另一个 Agent 轮次,由部署配置决定。 +向模型提供 mode 参数会赋予其控制调度器压力的能力,并使行为依赖部署。child 只决定内容和时机;该内容是否唤醒 parent,由部署配置决定。 ### 注册全局工具 @@ -101,18 +101,18 @@ ACP(Agent Client Protocol)快照 harness 新增 `waitForSubagentTurnEnd`, ## 影响 - 只有安装 report 包贡献时,可继续进程内 child 才会恰好暴露一个作用域局部 `report` schema;无关 Agent 永远不会暴露该 schema。 -- 工具返回 parent 消息的稳定 `MessageId`。静默投递没有 `InboxItemId`;唤醒投递会产生一个单独的 inbox 条目实例。 +- 工具返回 parent 消息的稳定 `MessageId`;其 inbox 中的出现不会成为另一个公开身份。 - 只有确切的驻留 child 才能报告,且只能报告给根据持久化谱系推导的确切在线直接 parent。服务不接受接收方参数,也不提供离线 fallback。 -- 唤醒投递是校验后的默认模式:它会恰好创建一个后续 FIFO 轮次,绝不 steering 已开始的轮次。静默投递则绝不会启动 parent 请求。 +- Next-step 投递是校验后的默认模式:它会唤醒空闲 parent,或在最近的 step 边界延长运行中 parent 的轮次。静默投递绝不会唤醒空闲 parent。 - parent 接受后取消或 dispose child 不会撤回报告。接受前,child dispose、drain、parent 丢失或调用方取消都会拒绝操作。 - 新建和恢复的 Activation 都会在发布前组合当前设置贡献。新授权等待下一个 Activation 才生效,而已驻留 child 的授权撤销立即生效。 - 单元覆盖固定可见性、allow-list 行为、两种投递模式、稳定的消息与发送方身份、嵌套路由、无效发送方、缺失的 parent、取消、drain、撤销竞争,以及不存在 Task 或隐式最终报告。 -- 无密钥整体组装快照证明真实 child 工具、那一个被唤醒的 parent 轮次、持久化 parent 封装,以及 parent 后续消费。 +- 无密钥整体组装快照证明真实 child 工具、默认 next-step 顺序先于结算,以及持久化 parent 封装。 ### 已接受的风险 该接受边界弱于持久化端到端投递。崩溃可能导致结果不明,重试则可能重复报告。 -唤醒投递可能在嵌套 child 频繁报告时放大模型工作量。通过 `reportDelivery` 交由部署所有者控制,可以限制该风险,但无法完全消除。 +嵌套 child 频繁报告时,next-step 投递可能放大模型工作量。一起等待的报告会共享一个 step,通过 `reportDelivery` 交由部署所有者控制也会限制该风险,但无法完全消除。 注册表中的存在性就是 parent 在线信号。宿主拥有的 parent 如果已开始 `AgentHandle.dispose()` 但尚未完成其作用域清理,仍可能接受并追加一条本进程不会再处理的报告。要弥合这个缺口,需要 Agent 层面的 dispose 开始信号,不能由 subagent 层推断。 diff --git a/.agents/notes/implemented/feature/2026-08-05-agent-teams.i18n.yaml b/.agents/notes/implemented/feature/2026-08-05-agent-teams.i18n.yaml new file mode 100644 index 0000000000..f5ec3193a1 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-05-agent-teams.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-05-agent-teams.md +2026-08-05-agent-teams.md: 43e92d4a9807e6a178436d96722b141b1bac45bb +2026-08-05-agent-teams.zh.md: 72989623bb49334beae3b49ce2f6973781558c69 diff --git a/.agents/notes/implemented/feature/2026-08-05-agent-teams.md b/.agents/notes/implemented/feature/2026-08-05-agent-teams.md new file mode 100644 index 0000000000..43e92d4a98 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-05-agent-teams.md @@ -0,0 +1,73 @@ +# Agent Note: Durable Agent Teams over continuable children + +Status: implemented + +English | [中文](2026-08-05-agent-teams.zh.md) + +## Problem + +The subagent seam supplies fresh/fork providers, durable child Sessions, FIFO follow-ups, and cold-resumable Activations. Its direct-parent controls do not provide peer communication, a stable named roster, or shared task ownership. A coordinator can create several workers, but workers cannot address one another, durable follow-up intent lives only in target inboxes, and no common compare-and-set board prevents stale assignment updates. + +All same-process Agents also share one checkout. Filesystem edit tools can reject an observed stale version, but Bash, formatters, generators, and external writers bypass that fence. Treating a teammate name or task owner as a file lock would hide rather than solve this concurrency boundary. + +The model-visible Team tools remain opt-in so the default tool catalog and simple-task behavior do not change. An explicitly requested Team must survive child Activation settlement and mailbox delivery races long enough for the Lead to aggregate the result before process teardown. + +## Decision + +Every ordinary runtime root is the implicit Lead of a Team identified by that root's `SessionId`. The Team has no creation event: its Lead pseudo-row exists by identity, while durable state begins with the first member, message, or task event. A roster is flat and contains at most the configured number of immutable lowercase-kebab-case names. Each teammate is a continuable direct child with a reserved Session id; only the Lead creates or interrupts teammates. Ordinary provider-owned subagents outside the roster are not Team members, and an ordinary fork is a new root whose inherited Team records are excluded by their ancestor `TeamId`. + +The implementation is split into `@deepseek-ai/dsh-team`, which owns `ctx.teams` and durable semantics, and `@deepseek-ai/dsh-tool-team`, which owns scoped schemas and model guidance. Every Team tool declares its complete result schema and renders that value as compact JSON, so the compiler checks each `execute` against what the model is promised and no result spends tokens on indentation. Deployments mount both plugins explicitly and may disable legacy continuable controls with the same model-visible names. The explicit delegation policy permits Team creation only when the user asks for Agent Teams or teammates. Both packages are private members of `packages/experimental/`; the [experimental package decision](../architecture/2026-08-18-experimental-agent-teams-packages.md) owns release exclusion, dependency isolation, and promotion. + +The Lead must wait for required work before its final answer. Process teardown remains the final lifecycle owner and drains continuation Activations; a Team task owner is durable state and is not automatically released by idle, interruption, or process exit. + +## Provisioning and recovery + +Creation first appends and flushes a `team/member` provisioning snapshot in the Lead Session, then starts the reserved continuable child through the selected fresh or fork provider. Failure before initial inbox acceptance appends a failed snapshot. Success flushes the child's accepted inbox item before appending active. Recovery recognizes that initial message while it is still pending or after it enters user-message history. Names are reserved by the first provisioning record and never reused, including after failure. Disposal closes admission, aborts and awaits admitted creation and mailbox-dispatch transactions, then stops every live child recorded by the roster; a failed child remains cleanup-owned until its Activation exits, and cleanup rejection fails disposal. + +A root recovery reconciles an unterminated provisioning record against the child's independently persisted Session. Matching direct-parent and continuable descriptors plus a recorded initial user message prove successful admission and produce active; absence, corruption, mismatched provider/lineage, or a missing admitted message produces failed. The creator re-reads the terminal phase under the same Lead-log serializer; if recovery marked failed while creation succeeded, it drains the child and reports a provisioning conflict instead of retaining an orphan. This avoids reconstructing an initial prompt that was never retained in the Team log and contains plugin-reload races. + +Fresh children have no inherited conversation. Fork children capture the Lead's completed-turn prefix once and retain it as their own durable seed. The current delegation turn remains excluded, matching the existing fork provider contract. + +## Mailbox and task transactions + +Peer communication is a Lead-log mailbox. `team/message/queued` is appended and flushed before delivery. The target message carries the stable message id and sender identity in both durable source metadata and a short model-visible prefix. A target receipt is acknowledged with `team/message/delivered` only after its pending inbox item or recorded user message is flushed. Immediate admission is serialized per target in queued-log order, recovery retries queued-minus-delivered in the same order, and delivery folds live or persisted target inbox/history state before cold resume. Every current-version Team payload is runtime-validated before entering replay state. The Team runtime tracks dispatch and asynchronous acknowledgement work from synchronous admission until settlement; disposal closes admission and awaits both before removing the service. Current waiters wake only after the owning Team event flush succeeds. + +Quiet `send_message` injects, flushes, and acknowledges immediately for a live target without waking it; an inactive target remains queued until another event materializes that teammate. Waking `followup_task` becomes the target's next FIFO turn and may cold-resume it. Success means the message is already durable even when immediate delivery is deferred. The mechanism provides process-local retry and target-Session de-duplication, not a cross-process exactly-once claim. + +Shared tasks are complete snapshots with Team-local ids and monotonic revisions. Every mutation carries `expectedRevision`. Any member creates, reads, or claims a ready unowned task; the owner or Lead edits and transitions it, while only the Lead assigns another member. Numeric task ids remain within the safe-integer allocation range, and exhaustion fails without reusing an id. Dependencies must name non-deleted tasks and form a complete DAG. Deleted tasks are retained tombstones. `writeScopes` are normalized path prefixes that produce overlap diagnostics but never block claim or authorize a write. + +`wait_agent` blocks on one roster, mailbox, task, or live-status edge registered after the call starts instead of encouraging model polling. It does not replay an earlier edge, so callers re-read authoritative state after wakeup or timeout. Lead-only interruption cancels the current turn with inbox preservation and does not alter mailbox or task ownership. + +## Shared checkout boundary + +All members use the same cwd and observe writes immediately. The policy tells members to partition tasks, record advisory write scopes, order dependent work, and let the Lead inspect the final diff and run tests. A filesystem stale-version rejection requires rereading and rebasing the intended change. No equivalent guarantee is claimed for Bash, formatters, code generation, or direct external writes. + +Worktree isolation is not a harness runtime behavior. A deployment or prompt may arrange separate worktrees, but the Team domain does not infer branches, merge changes, or silently change cwd. This preserves the existing same-world subagent and sandbox contracts. + +## Alternatives considered + +**Extend direct-child subagent tools with peer ids.** Rejected because parent/child authority and Team peer membership are different domains. Adding peer access to the continuation seam would weaken its exact-parent authorization and still leave roster and tasks without a persistence owner. + +**Store mail in each target Session before delivery.** Rejected because an inactive target is intentionally not materialized for quiet mail. The always-live Lead Session is the transaction home; target recording is the acknowledgement and de-duplication boundary. + +**Treat task ownership or write scopes as locks.** Rejected because external writers bypass them, crashed owners remain durable, and path-prefix overlap cannot prove semantic independence. False mutual exclusion is more dangerous than an explicit warning. + +**Create isolated worktrees automatically.** Rejected because worktree creation, branch naming, merge policy, ignored files, build artifacts, and cleanup are deployment choices. It also changes the same-world behavior existing subagents and sandboxes expose. + +**Enable Teams in the default catalog.** Rejected because scoped Team controls would shadow same-named legacy globals and unsolicited delegation would add latency and token cost to simple tasks. Explicit composition keeps model-visible ownership unambiguous without changing shipped requests. + +**Use an in-memory board and mailbox.** Rejected because child settlement, HMR, and process interruption would lose accepted coordination state and make retries ambiguous. + +**Return Team tool results as untyped JSON.** Rejected because an undeclared result type lets `execute` drift from the value the model is promised without a compiler error, and it invites indentation that costs tokens on every roster, task, and receipt. Each Team tool therefore declares its complete result schema and one shared helper renders it compactly. + +## Testing + +Package tests cover identity, name and authority checks, provider selection, reserved-id persistence collisions, child-before-Lead flush ordering, durable provisioning failure and pending-inbox JSONL/SQLite reconciliation, concurrent target-local ordering, pending/history de-duplication, mailbox limits, post-flush notification, bounded disposal with in-flight creation and dispatch cancellation, failed-member cleanup, task CAS and DAG validation, write-scope warnings, wait cancellation/timeout, inbox-preserving interruption, ordinary-fork isolation, legacy-control shadowing, compact declared-schema result rendering, and scoped registration HMR at per-file 100% coverage. A keyless headless Loader snapshot assembles the real Team plugins and records teammate creation, peer mail, dependent tasks, waiting, and Lead aggregation. + +## Consequences + +The Lead Session grows with whole task/member snapshots and mailbox acknowledgements. This favors independently inspectable recovery over compact deltas; configured task and pending-mail bounds cap active state, while deleted and delivered history remains append-only until broader Session retention applies. + +An active roster member can be non-resident, so `inactive` is not failure and a wakeup can incur cold-resume latency. A quiet message for an inactive target can remain pending indefinitely until the target is otherwise materialized. A failed member permanently consumes its name and member slot, making provisioning failures visible instead of silently recycling identity. + +Coordination reduces likely checkout conflicts but cannot eliminate writes outside filesystem compare-and-set tools. The final diff and tests remain the Lead's integration boundary. diff --git a/.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md b/.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md new file mode 100644 index 0000000000..72989623bb --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md @@ -0,0 +1,73 @@ +# Agent Note: Durable Agent Teams over continuable children + +Status: implemented + +[English](2026-08-05-agent-teams.md) | 中文 + +## Problem + +subagent seam 已提供 fresh/fork provider、持久 child Session、FIFO follow-up 与可冷恢复 Activation。它的直接 parent control 不提供 peer 通讯、稳定具名 roster 或共享任务 owner。coordinator 可以创建多个 worker,但 worker 无法互相寻址;持久 follow-up 意图只存在于 target inbox;也没有公共 compare-and-set 任务板来阻止陈旧 assignment 更新。 + +同进程 Agent 还共享一个 checkout。文件系统 edit 工具可以拒绝已观察到的陈旧版本,但 Bash、formatter、generator 与外部 writer 会绕过该屏障。把 teammate name 或 task owner 当作文件锁只会掩盖而不是解决该并发边界。 + +面向模型的 Team 工具保持显式启用,使默认工具目录与简单任务行为不变。显式请求的 Team 必须能跨越 child Activation settlement 与 mailbox 投递竞争,使 Lead 在进程 teardown 前汇总结果。 + +## Decision + +每个普通运行时 Root 都是一个隐式 Team 的 Lead,Team id 等于该 Root 的 `SessionId`。Team 没有 creation event:Lead pseudo-row 由身份直接存在,持久状态从第一条 member、message 或 task event 开始。roster 是扁平结构,最多包含配置数量、不可变且采用小写 kebab-case 的名字。每个 teammate 都是使用预留 Session id 的 continuable 直接 child;只有 Lead 可以创建或 interrupt teammate。roster 外由 provider 管理的普通 subagent 不是 Team member;普通 fork 是新的 Root,继承的 Team 记录会因 ancestor `TeamId` 被排除。 + +实现拆分为 `@deepseek-ai/dsh-team` 与 `@deepseek-ai/dsh-tool-team`:前者负责 `ctx.teams` 和持久语义,后者负责 scoped schema 与模型指引。每个 Team 工具都声明完整的结果 schema,并把该值渲染为紧凑 JSON,因此编译器会检查每个 `execute` 是否符合对模型的承诺,也没有结果把 token 花在缩进上。部署显式挂载两个插件,并可禁用具有相同模型可见名称的旧 continuable control。显式 delegation 策略只允许在用户要求 Agent Teams 或 teammate 时创建 Team。 两个包都是 `packages/experimental/` 的私有成员;[实验性包决策](../architecture/2026-08-18-experimental-agent-teams-packages.md)负责发布排除、依赖隔离与 promotion。 + +Lead 必须等待所需工作后才能给出最终答案。进程 teardown 仍是最终生命周期 owner,并会 drain continuation Activation;Team task owner 是持久状态,不会因 idle、interrupt 或进程退出自动释放。 + +## Provisioning and recovery + +创建操作先在 Lead Session 中追加并 flush `team/member` provisioning 快照,再通过选定 fresh 或 fork provider 启动预留的 continuable child。初始 inbox 获准前的失败会追加 failed 快照;成功会先 flush child 中已接受的 inbox 条目,再追加 active。恢复会在初始消息仍处于 pending 或已进入用户消息历史时识别它。名字由第一条 provisioning 记录永久保留,包括失败后也不能复用。dispose 会关闭准入,中止并等待已获准的创建与 mailbox dispatch 事务,再停止 roster 记录的所有 live child;failed child 在 Activation 退出前仍由 cleanup 拥有,cleanup 拒绝会让 dispose 失败。 + +Root 恢复时会把未终结 provisioning 记录与独立持久 child Session 对账。直接 parent 与 continuable descriptor 匹配,并且已经记录初始用户消息,才能证明准入成功并转为 active;缺失、损坏、provider/lineage 不匹配或缺少已准入消息都会转为 failed。creator 会在同一 Lead 日志 serializer 内重读终态;如果 recovery 在创建成功时先标记 failed,creator 会 drain child 并报告 provisioning conflict,而不是遗留孤儿。这样既无需重建从未保存在 Team 日志中的初始 prompt,也能约束插件 reload 竞争。 + +fresh child 不继承对话。fork child 只捕获一次 Lead 已完成 turn 前缀,并保留为自己的持久 seed。当前 delegation turn 保持排除,与既有 fork provider 契约一致。 + +## Mailbox and task transactions + +Peer 通讯使用 Lead 日志 mailbox。投递前先追加并 flush `team/message/queued`。target message 会在持久 source metadata 与短模型可见前缀中同时携带稳定 message id 和 sender identity。只有 pending inbox 条目或已记录用户消息完成 flush,Lead 日志才写入 `team/message/delivered` acknowledgement。即时准入按 target 和 queued 日志顺序串行化,恢复按同一顺序重试 queued-minus-delivered,并在冷恢复前折叠 live 或 persisted target 的 inbox/历史状态。每个当前版本 Team payload 都会经过运行时验证后才进入 replay state。Team runtime 从同步准入到 settlement 全程跟踪 dispatch 与异步 acknowledgement 工作;dispose 会关闭准入,并在移除服务前等待两者。当前 waiter 只在所属 Team event flush 成功后被唤醒。 + +对于 live target,quiet `send_message` 会立即注入、flush 并确认,但不会唤醒它;inactive target 会保持 queued,直到其他事件 materialize 该 teammate。waking `followup_task` 成为 target 的下一个 FIFO turn,并可冷恢复。即使即时投递被推迟,成功也表示消息已经持久化。该机制提供进程内重试与 target Session 去重,不宣称跨进程 exactly-once。 + +共享 task 是带 Team-local id 与单调 revision 的完整快照。每次变更都携带 `expectedRevision`。任意 member 可以创建、读取或 claim ready 且无 owner 的任务;Owner 或 Lead 可以编辑和转换;只有 Lead 可以分配给另一个 member。数字 task id 保持在安全整数分配范围内;该范围耗尽时会失败,不会复用 id。依赖必须指向未删除任务,并形成完整 DAG。删除任务保留为 tombstone。`writeScopes` 是规范化路径前缀,只产生重叠诊断,绝不会阻止 claim 或授予写权限。 + +`wait_agent` 等待调用注册后发生的下一条 roster、mailbox、task 或实时 status 边,避免模型轮询。它不会回放更早的边,因此调用方需要在唤醒或超时后重新读取权威状态。仅限 Lead 的 interrupt 使用 inbox preservation 取消当前 turn,不改变 mailbox 或 task owner。 + +## Shared checkout boundary + +所有 member 使用相同 cwd,并立即观察写入。策略要求 member 切分任务、记录提示性 write scope、为有序工作添加依赖,并由 Lead 检查最终 diff 和运行测试。文件系统 stale-version 拒绝后必须重新读取并 rebase 修改意图。Bash、formatter、codegen 与直接外部写入不具备等价保证。 + +Worktree isolation 不是 harness runtime 行为。deployment 或 prompt 可以安排独立 worktree,但 Team 领域不会推断 branch、merge 变更或静默改变 cwd。这样保留既有 same-world subagent 与 sandbox 契约。 + +## Alternatives considered + +**用 peer id 扩展 direct-child subagent tool。** 拒绝,因为 parent/child 权限与 Team peer membership 是不同领域。向 continuation seam 增加 peer access 会削弱 exact-parent authorization,仍无法为 roster 与 task 提供持久 owner。 + +**投递前把 mail 存入每个 target Session。** 拒绝,因为 quiet mail 不会 materialize inactive target。始终 live 的 Lead Session 是事务 owner;target recording 是 acknowledgement 与去重边界。 + +**把 task ownership 或 write scope 当作锁。** 拒绝,因为外部 writer 会绕过它们,崩溃 owner 会持久保留,而路径前缀重叠不能证明语义独立。虚假的互斥保证比明确 warning 更危险。 + +**自动创建隔离 worktree。** 拒绝,因为 worktree 创建、branch 命名、merge 策略、ignored file、构建产物与 cleanup 都是 deployment 选择;它也会改变既有 subagent 与 sandbox 暴露的 same-world 行为。 + +**在默认工具目录中启用 Team。** 拒绝,因为 scoped Team control 会覆盖同名旧全局工具,主动 delegation 也会给简单任务增加延迟和 token 成本。显式组合可以保持面向模型的归属明确,同时不改变默认 request。 + +**使用内存 task board 与 mailbox。** 拒绝,因为 child settlement、HMR 与进程中断会丢失已接受协调状态,并让重试变得含糊。 + +**让 Team 工具返回未类型化 JSON。** 拒绝,因为未声明的结果类型会让 `execute` 在没有编译错误的情况下偏离对模型的承诺,也会引入在每份 roster、task 与回执上都消耗 token 的缩进。因此每个 Team 工具都声明完整的结果 schema,并由一个共享 helper 紧凑渲染。 + +## Testing + +Package test 以逐文件 100% coverage 覆盖身份、名字与权限检查、provider 选择、预留 id 持久化冲突、child-before-Lead flush 顺序、持久 provisioning 失败与 pending-inbox JSONL/SQLite 对账、target-local 并发顺序、pending/history 去重、mailbox 限额、flush 后 notification、取消在途创建与 dispatch 的有界 dispose、failed member cleanup、task CAS 与 DAG 校验、write-scope warning、wait cancel/timeout、保留 inbox 的 interrupt、普通 fork 隔离、旧 control shadowing、声明 schema 的紧凑结果渲染与 scoped registration HMR。一条 keyless headless Loader 快照会组合真实 Team 插件,并记录 teammate 创建、peer mail、依赖任务、等待与 Lead 汇总。 + +## Consequences + +Lead Session 会随着完整 task/member 快照与 mailbox acknowledgement 增长。该设计用可独立检查的恢复能力换取更紧凑的 delta;配置的 task 与 pending-mail 限额限制 active state,而 deleted 与 delivered 历史会保持 append-only,直到更广泛的 Session retention 生效。 + +active roster member 可以不驻留,因此 `inactive` 不表示失败,wakeup 可能产生 cold-resume 延迟。发往 inactive target 的 quiet message 可能无限等待,直到 target 因其他原因 materialize。failed member 会永久占用名字与 member slot,使 provisioning failure 保持可见而不是静默回收身份。 + +协调可以降低 checkout 冲突概率,但无法消除文件系统 CAS 工具之外的写入。最终 diff 与测试仍是 Lead 的集成边界。 diff --git a/.agents/notes/implemented/feature/2026-08-06-continuable-child-report-obligation.i18n.yaml b/.agents/notes/implemented/feature/2026-08-06-continuable-child-report-obligation.i18n.yaml index 652a4711f1..59063cae6b 100644 --- a/.agents/notes/implemented/feature/2026-08-06-continuable-child-report-obligation.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-06-continuable-child-report-obligation.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-06-continuable-child-report-obligation.md -2026-08-06-continuable-child-report-obligation.md: f152ec1b8c353f094f2ba70785112eb1e165c510 -2026-08-06-continuable-child-report-obligation.zh.md: 4ec17e4642ffac385e6ce5464f41a3f4b3bebdbf +2026-08-06-continuable-child-report-obligation.md: e771e81831147dd02a6c32a543c8d8944c2ec2f4 +2026-08-06-continuable-child-report-obligation.zh.md: 7b4f8e7f5dead9f6b5803236e3593551c66da12d diff --git a/.agents/notes/implemented/feature/2026-08-06-continuable-child-report-obligation.md b/.agents/notes/implemented/feature/2026-08-06-continuable-child-report-obligation.md index f152ec1b8c..e771e81831 100644 --- a/.agents/notes/implemented/feature/2026-08-06-continuable-child-report-obligation.md +++ b/.agents/notes/implemented/feature/2026-08-06-continuable-child-report-obligation.md @@ -17,7 +17,7 @@ The return channel is an instruction the child receives, not a capability it may - the `report` tool, whose description now states that the child calls it once before finishing with a self-contained final result, and earlier for progress that changes what the parent should do next; - a `tool:report` system-prompt section at order 117 carrying the same obligation in the child's own voice, so a child that never reads tool descriptions closely still receives it. -`reportDelivery` now defaults to `wakeup`. An accepted report creates exactly one ordinary later parent turn and wakes a parked parent driver; it still never steers an open turn. `quiet` remains available for deployments that prefer unread reports over turn amplification. +`reportDelivery` defaults to `next-step`. An accepted report wakes a parked parent driver or joins a running parent's nearest step boundary, matching the instruction to report findings that change the parent's next action. `quiet` remains available for deployments that prefer unread reports over model-work amplification. The [report/settlement ordering decision](../bug-fix/2026-08-17-subagent-report-settlement-ordering.md) owns the scheduling rationale. ### Why the section and the description both exist @@ -33,7 +33,7 @@ That boundary is deliberate: prompt text can only reach a child that is still ru ### Snapshot coverage -The assembled ACP `subagent-report` scenario now exercises the shipped default: the child reports, the parked parent takes one ordinary turn on that report, and a later prompt still reads the report back out of the durable log. Because the child's scope now composes a prompt the class pin cannot describe, the snapshot harness gained `pinsChildSystemPrompts`, the exact counterpart of the existing `pinsChildToolSchemas`: it moves one child fixture's prompt into `system-prompt..expected.md`, leaves every other request-header field to the class pin, requires the sidecar exactly when declared, and rejects a sidecar identical to that class pin so a redundant copy cannot drift. +The assembled ACP `subagent-report` scenario exercises the shipped default: the child reports while the parent is in maintenance, the later settlement notice queues behind it, and the resumed parent claims the next-step report before next-turn settlement. Because the child's scope composes a prompt the class pin cannot describe, the snapshot harness has `pinsChildSystemPrompts`, the exact counterpart of `pinsChildToolSchemas`: it moves one child fixture's prompt into `system-prompt..expected.md`, leaves every other request-header field to the class pin, requires the sidecar exactly when declared, and rejects a sidecar identical to that class pin so a redundant copy cannot drift. ## Alternatives considered @@ -48,13 +48,13 @@ The assembled ACP `subagent-report` scenario now exercises the shipped default: ## Consequences - Every continuable in-process child with this package loaded carries one extra prompt section and a longer `report` description in every request; no other Agent's request changes. -- The default deployment wakes the parent once per accepted report. A nested tree that reports frequently consumes extra parent turns; `quiet` is the documented escape. +- The default deployment wakes the parent once per accepted report. A nested tree that reports frequently consumes extra parent requests, while reports waiting together share one step; `quiet` is the documented escape. - `installReportTool` requires `ctx.systemPrompt` in the child scope, so the package declares `systemPrompt` in `inject` and fails at load rather than at the next child materialization. - Unit coverage pins the new default, two load-bearing instruction phrases, the section's child-only scope against both the parent and a sibling, and rollback or revocation of both registrations. - Three assembled ACP scenarios with continuable children pin the complete instruction text through the new sidecar; a future change to any child-scoped section fails those scenarios instead of passing silently. ### Accepted risks -Waking by default amplifies model work in deep trees. The deployment owns that through `reportDelivery`, and the amplification is bounded by one turn per accepted report. +Next-step delivery by default amplifies model work in deep trees. The deployment owns that through `reportDelivery`; reports waiting together share one step, and one accepted report causes at most one wake. A child can still finish without reporting, and this change cannot detect it. Only the runtime's own [settlement account](2026-08-06-manager-owned-subagent-settlement-delivery.md) closes that case. diff --git a/.agents/notes/implemented/feature/2026-08-06-continuable-child-report-obligation.zh.md b/.agents/notes/implemented/feature/2026-08-06-continuable-child-report-obligation.zh.md index 4ec17e4642..7b4f8e7f5d 100644 --- a/.agents/notes/implemented/feature/2026-08-06-continuable-child-report-obligation.zh.md +++ b/.agents/notes/implemented/feature/2026-08-06-continuable-child-report-obligation.zh.md @@ -17,7 +17,7 @@ Status: implemented - `report` 工具,其描述现在说明 child 要在结束前调用一次并给出自足的最终结果,并在部分进展会改变 parent 下一步动作时提前调用; - 一个 order 为 117 的 `tool:report` 系统提示词 section,用 child 自己的语气承载同一条义务,使从不细读工具描述的 child 仍能收到它。 -`reportDelivery` 的默认值现在是 `wakeup`。一条被接受的报告恰好创建一个普通的后续 parent 轮次并唤醒停驻的 parent 驱动;它仍然绝不 steering(中途引导)已开始的轮次。对于宁可让报告无人阅读也要避免轮次放大的部署,`quiet` 依旧可用。 +`reportDelivery` 的默认值为 `next-step`。一条被接受的报告会唤醒停驻的 parent driver,或加入运行中 parent 最近的 step 边界,与发现会改变 parent 下一步动作时上报的指令一致。对于宁可让报告无人阅读也要避免模型工作量放大的部署,`quiet` 依旧可用。[报告与结算顺序决策](../bug-fix/2026-08-17-subagent-report-settlement-ordering.md)负责调度理由。 ### 为什么 section 与描述同时存在 @@ -33,7 +33,7 @@ Status: implemented ### 快照覆盖 -整体组装的 ACP `subagent-report` 场景现在演练随附的默认行为:child 上报,停驻的 parent 就该报告执行一个普通轮次,随后的提示词仍能从持久化日志中把报告读回来。由于该 child 的作用域现在组合出类别 pin 无法描述的提示词,快照 harness 新增了 `pinsChildSystemPrompts`,它与既有 `pinsChildToolSchemas` 完全对称:把一个 child fixture 的提示词移入 `system-prompt..expected.md`,其余请求 header 字段仍归类别 pin 所有,要求 sidecar 恰好在声明时存在,并拒绝与该类别 pin 完全相同的 sidecar,使冗余副本无法悄悄漂移。 +整体组装的 ACP `subagent-report` 场景演练随附的默认行为:child 在 parent 处于 maintenance 时上报,稍后的结算通知排在其后,而恢复的 parent 会先领取 next-step 报告、再领取 next-turn 结算。由于该 child 的作用域组合出类别 pin 无法描述的提示词,快照 harness 提供 `pinsChildSystemPrompts`,它与 `pinsChildToolSchemas` 完全对称:把一个 child fixture 的提示词移入 `system-prompt..expected.md`,其余请求 header 字段仍归类别 pin 所有,要求 sidecar 恰好在声明时存在,并拒绝与该类别 pin 完全相同的 sidecar,使冗余副本无法悄悄漂移。 ## 备选方案 @@ -48,13 +48,13 @@ Status: implemented ## 后果 - 加载本包后,每个可继续进程内 child 的每次请求都会多出一个提示词 section 和一段更长的 `report` 描述;其他任何 Agent 的请求都不变。 -- 默认部署会为每条被接受的报告唤醒 parent 一次。频繁上报的嵌套树会消耗额外的 parent 轮次;`quiet` 是有文档记载的退路。 +- 默认部署会为每条被接受的报告唤醒 parent 一次。频繁上报的嵌套树会消耗额外的 parent 请求,而一起等待的报告会共享一个 step;`quiet` 是有文档记载的退路。 - `installReportTool` 需要 child 作用域中的 `ctx.systemPrompt`,因此本包在 `inject` 中声明 `systemPrompt`,从而在加载时失败,而不是等到下一次 child 物化时。 - 单元覆盖固定了新默认值、两处关键指令措辞、该 section 相对 parent 与同级均仅限 child 的作用域,以及两项注册在安装回滚或撤销时的清理。 - 三个带可继续 child 的整体组装 ACP 场景通过新的 sidecar 逐字固定完整的 child 提示词;今后任何对 child 作用域 section 的改动都会让这些场景失败,而不是悄悄通过。 ### 已接受的风险 -默认唤醒会在深层树中放大模型工作量。部署通过 `reportDelivery` 掌握该取舍,且放大幅度以每条被接受报告一个轮次为界。 +默认 next-step 投递会在深层树中放大模型工作量。部署通过 `reportDelivery` 掌握该取舍;一起等待的报告会共享一个 step,且每条被接受的报告至多产生一次唤醒。 child 仍可能不上报就结束,本次改动无法检测这一点。只有运行时自己的[结算记账](2026-08-06-manager-owned-subagent-settlement-delivery.md)才能补上这一情形。 diff --git a/.agents/notes/implemented/feature/2026-08-06-manager-owned-subagent-settlement-delivery.i18n.yaml b/.agents/notes/implemented/feature/2026-08-06-manager-owned-subagent-settlement-delivery.i18n.yaml index 40eb28f89b..96312b7694 100644 --- a/.agents/notes/implemented/feature/2026-08-06-manager-owned-subagent-settlement-delivery.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-06-manager-owned-subagent-settlement-delivery.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-06-manager-owned-subagent-settlement-delivery.md -2026-08-06-manager-owned-subagent-settlement-delivery.md: b3e7d3794cd075d1ef7d20568b99457520bc1fde -2026-08-06-manager-owned-subagent-settlement-delivery.zh.md: b4f1e8e120cf88ceb5d9a696fbb58d61ecff09d8 +2026-08-06-manager-owned-subagent-settlement-delivery.md: 27daa6d5150950efb50bf23dea945498651d2c09 +2026-08-06-manager-owned-subagent-settlement-delivery.zh.md: 19abce4fc9c151872d1e02e4ca2efa3953be065b diff --git a/.agents/notes/implemented/feature/2026-08-06-manager-owned-subagent-settlement-delivery.md b/.agents/notes/implemented/feature/2026-08-06-manager-owned-subagent-settlement-delivery.md index b3e7d3794c..27daa6d515 100644 --- a/.agents/notes/implemented/feature/2026-08-06-manager-owned-subagent-settlement-delivery.md +++ b/.agents/notes/implemented/feature/2026-08-06-manager-owned-subagent-settlement-delivery.md @@ -62,7 +62,7 @@ Three assembled ACP scenarios cover the notice: a child that never reports, a ch A keyless headless Loader snapshot covers the user-visible path end to end. Its replay parent omits `run_in_background` to exercise the continuable background default, never calls `list_agents`, `send_message`, or Task tools, consumes the manager-authored `subagent-settled` notice, and produces its final answer. The child never calls `report`, so the transcript cannot pass through the cooperative report path. A test-only Loader fence holds the parent's post-spawn request until the real manager notice enters its inbox, removing platform scheduling from the transcript without synthesizing the notice. -`subagent-report` needed one more concession. With the shipped waking report default, that scenario has two independent parent wakes — the report and the settlement — and whether the second extends the first's turn or opens its own is a genuine coin flip that measured 50/50 across runs. No authored transcript can hold both orders. Its overlay therefore pins `reportDelivery: quiet`, leaving settlement as the only wake, and a snapshot-only pre-step fence holds the child until the parent's spawn turn ends so that wake opens one deterministic turn claiming both messages. The waking report default keeps its coverage in the report package's own tests. +The `subagent-report` scenario uses the default next-step report delivery. A snapshot-only fence holds the child until the parent's spawn turn ends, then holds the parent in maintenance until settlement follows the report. The resumed parent claims the next-step report before the queued next-turn settlement. The [report/settlement ordering decision](../bug-fix/2026-08-17-subagent-report-settlement-ordering.md) owns this cross-state ordering. The refusal and interruption wordings are pinned verbatim in unit tests rather than in a replayed transcript: producing them needs a rejecting policy plugin or a cancellation fenced at a step boundary, which the keyless assemblies do not otherwise carry, and the assembled scenarios already pin the notice pathway itself end to end. @@ -87,7 +87,7 @@ The refusal and interruption wordings are pinned verbatim in unit tests rather t - `Activation` carries `parentSession` and `announced`. The first exists because the child handle is disposed before delivery; the second is what keeps a rolled-back materialization silent. - `foldConsumedWork()` replaces `dsh-session`'s `findLastMessageTurnEnd()` and moves to `dsh-agent`, which owns the inbox marker it reads; the one-shot in-process path folds the same answer and does not classify a cut-short one-shot child as `completed`. - Unit coverage pins the unconditional contract, each terminal reason, idle and busy scheduling, the batch, the maintenance regression, the pre-release ordering, a parent that is gone, and a rejected send that must not fail teardown. -- Three ACP scenarios use an explicit settlement fence, and `subagent-report` has a config overlay that pins quiet report delivery. +- Three ACP scenarios use an explicit settlement fence, and `subagent-report` pins the default report-before-settlement next-step order. - A keyless headless Loader snapshot pins background start → manager-authored settlement notice → final parent answer with no polling or child `report` call. ### Accepted risks @@ -100,4 +100,4 @@ Stop-reason attribution is a best effort over the log's existing splice vocabula Turn amplification is real for deep or wide trees, and it is not configurable by design. The step-boundary batch bounds it for simultaneous settlement but not for children that settle apart. -Two independent waking sources cannot be ordered in an authored transcript. The assembled coverage pins each separately rather than their interleaving. +Reports and their later settlement notices are ordered through the parent's next-step FIFO. Independent settlements from sibling children retain their actual delivery order rather than a synthetic sibling ordering. diff --git a/.agents/notes/implemented/feature/2026-08-06-manager-owned-subagent-settlement-delivery.zh.md b/.agents/notes/implemented/feature/2026-08-06-manager-owned-subagent-settlement-delivery.zh.md index b4f1e8e120..19abce4fc9 100644 --- a/.agents/notes/implemented/feature/2026-08-06-manager-owned-subagent-settlement-delivery.zh.md +++ b/.agents/notes/implemented/feature/2026-08-06-manager-owned-subagent-settlement-delivery.zh.md @@ -62,7 +62,7 @@ Status: implemented 另有一个无密钥的 headless Loader 快照端到端覆盖用户可见路径。其重放父级省略 `run_in_background` 以覆盖可继续后台默认路径,从不调用 `list_agents`、`send_message` 或 Task 工具,消费管理器写入的 `subagent-settled` 通知,并给出最终答案。child 从不调用 `report`,因此该 transcript 不可能经由协作式上报路径通过。一个仅用于测试的 Loader 栅栏会把父级启动后的请求保持到真实管理器通知进入其 inbox 为止,从 transcript 中排除平台调度差异,但不会伪造该通知。 -`subagent-report` 还需要多做一步让步。在随附的唤醒上报默认值下,该场景有两个互相独立的父级唤醒——上报与结算——而第二个究竟是延长第一个的轮次还是另开一个轮次,是一枚真正的硬币,多次运行实测约为五五开。任何手写 transcript 都无法同时容纳两种顺序。因此它的 overlay 固定 `reportDelivery: quiet`,使结算成为唯一唤醒;另一个仅用于快照的 pre-step 栅栏会把 child 保持到父级启动轮次结束,使这次唤醒开启一个确定轮次并同时认领两条消息。唤醒上报默认值的覆盖则保留在 report 包自身的测试中。 +`subagent-report` 场景使用默认 next-step 报告投递。一个仅用于快照的围栏会让 child 等到 parent 的派生轮次结束,随后让 parent 保持 maintenance,直至结算跟在报告之后到达。恢复的 parent 会先领取 next-step 报告、再领取排队的 next-turn 结算。[报告与结算顺序决策](../bug-fix/2026-08-17-subagent-report-settlement-ordering.md)负责说明这种跨状态顺序。 拒绝与中断两种措辞在单元测试中逐字钉死,而不进入重放 transcript:触发它们需要一个会拒绝的策略插件、或一次在 step 边界被栅栏卡住的取消,而无密钥组装本身并不携带这些;通知通路本身已由整体组装场景端到端钉住。 @@ -87,7 +87,7 @@ Status: implemented - `Activation` 携带 `parentSession` 与 `announced`。前者存在是因为 child handle 在投递前已被 dispose;后者让被回滚的物化保持静默。 - `foldConsumedWork()` 取代 `dsh-session` 的 `findLastMessageTurnEnd()`,并迁移到 `dsh-agent`——它拥有该 fold 所读取的 inbox 标记;一次性 in-process 路径折叠同一个答案,不会把被中途切断的一次性 child 归类为 `completed`。 - 单元覆盖固定了无条件约定、每种终止原因、空闲与繁忙两种调度、批量语义、维护期回归、释放前顺序、父级已消失,以及一次不得让拆卸失败的发送被拒。 -- 三个 ACP 场景使用显式的结算栅栏,`subagent-report` 带有固定静默上报投递的配置 overlay。 +- 三个 ACP 场景使用显式的结算围栏,`subagent-report` 固定默认的报告先于结算的 next-step 顺序。 - 一个无密钥的 headless Loader 快照固定了「后台启动 → 管理器写入的结算通知 → 父级最终答案」路径,其中没有轮询,也没有 child `report` 调用。 ### 已接受的风险 @@ -100,4 +100,4 @@ Status: implemented 对于深或宽的树,轮次放大是真实存在的,而且按设计不可配置。step 边界的批量语义只能约束同时结算的情形,无法约束分散结算的 child。 -两个互相独立的唤醒源无法在手写 transcript 中排序。整体组装覆盖分别固定它们,而不固定它们的交错。 +报告与其稍后的结算通知通过 parent 的 next-step FIFO 排序。来自同级 child 的独立结算保留其实际投递顺序,不会虚构同级间的顺序。 diff --git a/.agents/notes/implemented/feature/2026-08-11-background-first-continuable-delegation.i18n.yaml b/.agents/notes/implemented/feature/2026-08-11-background-first-continuable-delegation.i18n.yaml index 855c9b755d..13ae111421 100644 --- a/.agents/notes/implemented/feature/2026-08-11-background-first-continuable-delegation.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-11-background-first-continuable-delegation.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-11-background-first-continuable-delegation.md -2026-08-11-background-first-continuable-delegation.md: 3eab4ba36d8e6b2a1984450149021450741f3b89 -2026-08-11-background-first-continuable-delegation.zh.md: b8f14e0c40c341fc91b2fd0dc8c3f00fc2e14b66 +2026-08-11-background-first-continuable-delegation.md: 59232ae8821ef4a093fd610ecbbb39690316ce6c +2026-08-11-background-first-continuable-delegation.zh.md: 928d1aef31babe853cb48f6df114c79a188da345 diff --git a/.agents/notes/implemented/feature/2026-08-11-background-first-continuable-delegation.md b/.agents/notes/implemented/feature/2026-08-11-background-first-continuable-delegation.md index 3eab4ba36d..59232ae882 100644 --- a/.agents/notes/implemented/feature/2026-08-11-background-first-continuable-delegation.md +++ b/.agents/notes/implemented/feature/2026-08-11-background-first-continuable-delegation.md @@ -20,7 +20,7 @@ The model-facing text divides responsibility by location: - the `run_in_background` parameter states the lifecycle-specific default and when to override it; - a `tool:` system-prompt section tells the model to start independent delegations together, continue useful work while they run, and choose foreground only when the next action depends on the result. The section renders only when that tool remains visible in the assembly scope, so a child tool restriction removes the schema and its guidance together. -The [continuable child report obligation](2026-08-06-continuable-child-report-obligation.md) remains unchanged: the child prompt requires one self-contained final report and earlier reports for findings that change the parent's next action. Manager-owned settlement remains unconditional and does not inspect whether a report arrived. The two messages may repeat final content, but they retain distinct authors and purposes: `report` is the child's explicit handoff, while settlement records how the run ended and preserves terminal output when the child cannot cooperate. `reportDelivery` remains deployment scheduling policy with `wakeup` as its default. +The [continuable child report obligation](2026-08-06-continuable-child-report-obligation.md) remains unchanged: the child prompt requires one self-contained final report and earlier reports for findings that change the parent's next action. Manager-owned settlement remains unconditional and does not inspect whether a report arrived. The two messages may repeat final content, but they retain distinct authors and purposes: `report` is the child's explicit handoff, while settlement records how the run ended and preserves terminal output when the child cannot cooperate. `reportDelivery` remains deployment scheduling policy with `next-step` as its default, preserving report-before-settlement order through the parent inbox. The keyless headless `subagent-settlement` scenario omits `run_in_background`, receives the immediate child id, and reaches the final parent answer through the manager-authored settlement notice even though its fixture deliberately does not call `report`. Package tests separately pin explicit `false` as foreground, the parent scheduling text, and the child's mandatory-report prompt. diff --git a/.agents/notes/implemented/feature/2026-08-11-background-first-continuable-delegation.zh.md b/.agents/notes/implemented/feature/2026-08-11-background-first-continuable-delegation.zh.md index b8f14e0c40..928d1aef31 100644 --- a/.agents/notes/implemented/feature/2026-08-11-background-first-continuable-delegation.zh.md +++ b/.agents/notes/implemented/feature/2026-08-11-background-first-continuable-delegation.zh.md @@ -20,7 +20,7 @@ child 作用域的 `report` 提示词要求发送自包含的最终报告,而[ - `run_in_background` 参数说明具体生命周期的默认值以及何时覆盖; - `tool:` 系统提示词 section 会告诉模型同时启动相互独立的委派、在它们运行时继续有用工作,并且仅当下一步动作依赖结果时选择前台。只有当该工具在组装作用域中仍可见时才会渲染这个 section,因此子级工具限制会同时移除 schema 与对应指引。 -[可继续 child 上报义务](2026-08-06-continuable-child-report-obligation.md)保持不变:child 提示词要求发送一份自包含的最终报告,并在发现会改变 parent 下一步动作的信息时提前报告。由管理器负责的结算仍然无条件执行,不检查报告是否已经到达。这两条消息可能重复最终内容,但作者和用途不同:`report` 是 child 的显式交接,结算则记录本次运行如何结束,并在 child 无法配合时保留终止输出。`reportDelivery` 仍是部署调度策略,默认值仍为 `wakeup`。 +[可继续 child 上报义务](2026-08-06-continuable-child-report-obligation.md)保持不变:child 提示词要求发送一份自包含的最终报告,并在发现会改变 parent 下一步动作的信息时提前报告。由管理器负责的结算仍然无条件执行,不检查报告是否已经到达。这两条消息可能重复最终内容,但作者和用途不同:`report` 是 child 的显式交接,结算则记录本次运行如何结束,并在 child 无法配合时保留终止输出。`reportDelivery` 仍是部署调度策略,默认值为 `next-step`,通过 parent inbox 保持报告先于结算的顺序。 无密钥 headless `subagent-settlement` 场景省略 `run_in_background`,收到立即返回的 child id;尽管 fixture(测试前置数据)有意不调用 `report`,它仍通过管理器生成的结算通知到达 parent 最终答案。包测试另行固定了显式 `false` 的前台语义、parent 调度文本以及 child 的强制报告提示词。 diff --git a/.agents/notes/implemented/feature/2026-08-11-background-job-completion-wakes-an-idle-owner.i18n.yaml b/.agents/notes/implemented/feature/2026-08-11-background-job-completion-wakes-an-idle-owner.i18n.yaml index 779e692e5f..5b1413fa51 100644 --- a/.agents/notes/implemented/feature/2026-08-11-background-job-completion-wakes-an-idle-owner.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-11-background-job-completion-wakes-an-idle-owner.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-11-background-job-completion-wakes-an-idle-owner.md -2026-08-11-background-job-completion-wakes-an-idle-owner.md: 5193fda633dac78f06e6eb5e7b97be6aaa0ea94c -2026-08-11-background-job-completion-wakes-an-idle-owner.zh.md: fa338f06b2950ad9b1d88450ddac9f13a643cd15 +2026-08-11-background-job-completion-wakes-an-idle-owner.md: 15ff0fbdc173f6cadaa2f75e265effe61e512f15 +2026-08-11-background-job-completion-wakes-an-idle-owner.zh.md: b7b528ce9294fde39ca82c293edf099b8b9fddef diff --git a/.agents/notes/implemented/feature/2026-08-11-background-job-completion-wakes-an-idle-owner.md b/.agents/notes/implemented/feature/2026-08-11-background-job-completion-wakes-an-idle-owner.md index 5193fda633..15ff0fbdc1 100644 --- a/.agents/notes/implemented/feature/2026-08-11-background-job-completion-wakes-an-idle-owner.md +++ b/.agents/notes/implemented/feature/2026-08-11-background-job-completion-wakes-an-idle-owner.md @@ -32,7 +32,7 @@ Injection is correct there. A cancelled turn is a user pressing stop, and reopen The bound exists because this chain is self-exciting in a way subagent settlement is not. Settlement is bounded by how many children the model spawned; a woken turn can start the background job whose completion wakes it again, with nobody watching. `dsh run` needs no separate policy: its one user message is claimed in the first turn and never repeats, so the budget is spent monotonically and the process terminates. -`completionDelivery: quiet` restores the old lane for idle owners. It exists for deterministic transcripts, and mirrors the `reportDelivery` switch on `tool-subagent-report` in name, values, and default. +`completionDelivery: quiet` restores the old lane for idle owners. It exists for deterministic transcripts; job completion independently retains `quiet | wakeup` because its bounded owner-turn policy differs from next-step subagent reports. ### Teardown claims the report diff --git a/.agents/notes/implemented/feature/2026-08-11-background-job-completion-wakes-an-idle-owner.zh.md b/.agents/notes/implemented/feature/2026-08-11-background-job-completion-wakes-an-idle-owner.zh.md index fa338f06b2..b7b528ce92 100644 --- a/.agents/notes/implemented/feature/2026-08-11-background-job-completion-wakes-an-idle-owner.zh.md +++ b/.agents/notes/implemented/feature/2026-08-11-background-job-completion-wakes-an-idle-owner.zh.md @@ -32,7 +32,7 @@ Status: implemented 设界是因为这条链会自激,而 subagent 结算不会。结算受限于模型派生了多少子 agent;被唤醒的一轮却可能启动某个后台任务,而它的完成又会唤醒同一个所有者,且无人旁观。`dsh run` 不需要单独策略:它唯一的用户消息在第一轮就被领取且不会重复,因此预算单调消耗,进程必然终止。 -`completionDelivery: quiet` 为空闲所有者恢复旧通道。它的存在是为了确定性 transcript,并在名称、取值与默认值上都对齐 `tool-subagent-report` 的 `reportDelivery` 开关。 +`completionDelivery: quiet` 为空闲所有者恢复旧通道。它的存在是为了确定性 transcript;后台任务完成会独立保留 `quiet | wakeup`,因为其有界的所有者轮次策略不同于 next-step subagent 报告。 ### 销毁自行认领报告 diff --git a/.agents/notes/implemented/feature/2026-08-12-web-image-intake-and-limits-alignment.i18n.yaml b/.agents/notes/implemented/feature/2026-08-12-web-image-intake-and-limits-alignment.i18n.yaml index 1ea48bbef6..01144af297 100644 --- a/.agents/notes/implemented/feature/2026-08-12-web-image-intake-and-limits-alignment.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-12-web-image-intake-and-limits-alignment.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-12-web-image-intake-and-limits-alignment.md -2026-08-12-web-image-intake-and-limits-alignment.md: 2f8b99bb4850d9875dcba0a03ae8ad9f340d1506 -2026-08-12-web-image-intake-and-limits-alignment.zh.md: 62d5ebd54275ae8de0e0b9ba701ba34042dfcde7 +2026-08-12-web-image-intake-and-limits-alignment.md: 00cf7ea99d63e848c4b5839da1d97d94c9fb8464 +2026-08-12-web-image-intake-and-limits-alignment.zh.md: d88ba25a3f3e3be4a3655080a45123ada6cdebcd diff --git a/.agents/notes/implemented/feature/2026-08-12-web-image-intake-and-limits-alignment.md b/.agents/notes/implemented/feature/2026-08-12-web-image-intake-and-limits-alignment.md index 2f8b99bb48..00cf7ea99d 100644 --- a/.agents/notes/implemented/feature/2026-08-12-web-image-intake-and-limits-alignment.md +++ b/.agents/notes/implemented/feature/2026-08-12-web-image-intake-and-limits-alignment.md @@ -16,7 +16,7 @@ The second alignment step for issue #2248, after the [attachment display note](2 **History thumbnails (DeepSeek Chat rules).** A message's lone image renders at 240px on its long edge with the displayed ratio clamped to [0.25, 4], cropped by `cover` with the anchor at the top of very tall images and the left of very wide ones, never upscaled; several images render as fixed 64px square tiles in one wrapping row (10px gap, user messages right-aligned). Consecutive assistant `image` blocks merge into one gallery so they tile instead of each opening a one-image row. -**Limits aligned and projected.** Defaults are 20 images / 5 MiB per image / 100 MiB aggregate (`attachment-local`), with the HTTP carrier cap raised to one shared `DEFAULT_MAX_REQUEST_BODY_BYTES = 160 MiB` (http-bridge, previously two independent 32 MiB literals) to satisfy the load-time capacity assertion (aggregate × 4/3 + headroom ≈ 134.3 MiB). Consumer products cluster at 10–20 attachments (ChatGPT 10, Gemini 10, Claude 20; DeepSeek Chat's 50 is the outlier), and a vision-model image costs roughly 1300–4800 tokens, so 50 images can fill a 200k context in one message. The 5 MiB per-image default admits images across Anthropic routes that impose either a 5 MiB or 10 MiB maximum; deployments using only routes with the larger limit can override it. A 512 MiB aggregate cannot pass this transport because base64-in-JSON would need a single JSON string past V8's ~512 MiB string ceiling. The limits reach clients as the `imageLimits` session projection — a constant-per-boot unit (`apply` returns the same state reference, so baselines alone carry it and no change frames exist) registered by **apiproxy**, not the attachment Service Definition: `dsh-llm` depends on `dsh-attachment` (`ImageBlock` → `ImageAttachmentRef`), so the seam package referencing `dsh-session-projection` (whose graph reaches `dsh-llm` through `dsh-session`) closes a project-reference cycle, and the per-message count/aggregate rules the value describes are the proxy's own admission checks anyway. The `SessionProjectionMap` merge rides the proxy's sessions wire-contract file, which every client program already includes through the carrier's type re-exports. +**Limits aligned and projected.** Defaults are 20 images / 3.5 MiB per image / 100 MiB aggregate (`attachment-local`), with the HTTP carrier cap raised to one shared `DEFAULT_MAX_REQUEST_BODY_BYTES = 160 MiB` (http-bridge, previously two independent 32 MiB literals) to satisfy the load-time capacity assertion (aggregate × 4/3 + headroom ≈ 134.3 MiB). Consumer products cluster at 10–20 attachments (ChatGPT 10, Gemini 10, Claude 20; DeepSeek Chat's 50 is the outlier), and a vision-model image costs roughly 1300–4800 tokens, so 50 images can fill a 200k context in one message. Including base64 padding, a 3.5 MiB encoded file occupies at most 4.67 MiB and leaves 0.33 MiB below a 5 MiB route check. Deployments using only routes with larger limits can override it. A 512 MiB aggregate cannot pass this transport because base64-in-JSON would need a single JSON string past V8's ~512 MiB string ceiling. The limits reach clients as the `imageLimits` session projection — a constant-per-boot unit (`apply` returns the same state reference, so baselines alone carry it and no change frames exist) registered by **apiproxy**, not the attachment Service Definition: `dsh-llm` depends on `dsh-attachment` (`ImageBlock` → `ImageAttachmentRef`), so the seam package referencing `dsh-session-projection` (whose graph reaches `dsh-llm` through `dsh-session`) closes a project-reference cycle, and the per-message count/aggregate rules the value describes are the proxy's own admission checks anyway. The `SessionProjectionMap` merge rides the proxy's sessions wire-contract file, which every client program already includes through the carrier's type re-exports. **Intake pre-check and error copy.** Both intake gestures converge on one `intakeImages` wrapper in InputBar that checks count, per-image bytes, and aggregate bytes against the projection before `addImages`: a violating batch is refused whole (DeepSeek Chat semantics) with an immediate banner naming the limit — no submit-time rollback theater. The host checks stay as the backstop for callers that bypass the composer. Banner copy follows one principle the user set: reasons a user can act on (model without vision, count, size, resolution, format — now a positive list of supported formats instead of echoing the rejected MIME type) get product sentences naming the way out; reasons they cannot act on (corrupt base64, lost references, read failures) fold into one send-failed sentence that keeps the reason code, because the product currently faces developers and a reportable code beats a dead end. Non-attachment error codes keep the raw message + code presentation. diff --git a/.agents/notes/implemented/feature/2026-08-12-web-image-intake-and-limits-alignment.zh.md b/.agents/notes/implemented/feature/2026-08-12-web-image-intake-and-limits-alignment.zh.md index 62d5ebd542..d88ba25a3f 100644 --- a/.agents/notes/implemented/feature/2026-08-12-web-image-intake-and-limits-alignment.zh.md +++ b/.agents/notes/implemented/feature/2026-08-12-web-image-intake-and-limits-alignment.zh.md @@ -16,7 +16,7 @@ issue #2248 的第二步对齐,接在[附件展示 note](2026-08-11-web-attach **历史缩略图(DeepSeek Chat 规则)。** 一条消息仅有的一张图长边 240px、展示比例钳制在 [0.25, 4],`cover` 裁切,特别高的图锚定顶部、特别宽的锚定左侧,从不放大;多张图渲染为固定 64px 方块,单个可换行的横排(10px 间距,用户消息右对齐)。assistant 连续的 `image` 块合并进同一个画廊,平铺而不是各占一行。 -**上限对齐并投影。** 默认值为每条消息 20 张、单图 5 MiB、总量 100 MiB(`attachment-local`),HTTP 载体上限提为唯一共享的 `DEFAULT_MAX_REQUEST_BODY_BYTES = 160 MiB`(http-bridge,原先是两个独立的 32 MiB 字面量),以满足加载时的容量断言(总量 × 4/3 加余量 ≈ 134.3 MiB)。消费级产品集中在 10 到 20 个附件(ChatGPT 10、Gemini 10、Claude 20;DeepSeek Chat 的 50 是例外),且视觉模型一张图约 1300 到 4800 token,因此 50 张图可在一条消息中填满 200k 上下文。默认单图上限采用 5 MiB,可适用于分别采用 5 MiB 或 10 MiB 上限的 Anthropic 路由;仅使用较大上限路由的部署可以覆盖该值。512 MiB 总量无法通过当前传输,因为 base64 进 JSON 需要一个超过 V8 约 512 MiB 字符串上限的单个 JSON 字符串。限额以 `imageLimits` 会话投影到达客户端。它是每次启动恒定的单元(`apply` 返回同一状态引用,因此只靠基线携带、不存在变更帧),由 **apiproxy** 而非 attachment Service Definition 注册:`dsh-llm` 依赖 `dsh-attachment`(`ImageBlock` → `ImageAttachmentRef`),seam 包引用 `dsh-session-projection`(其图谱经 `dsh-session` 到达 `dsh-llm`)会闭合 project-reference 环,而该值描述的每消息数量与总量规则本来就是 proxy 自己的准入检查。`SessionProjectionMap` 合并放在 proxy 的 sessions 协议文件里,每个客户端程序都经载体的类型再导出包含它。 +**上限对齐并投影。** 默认值为每条消息 20 张、单图 3.5 MiB、总量 100 MiB(`attachment-local`),HTTP 载体上限提为唯一共享的 `DEFAULT_MAX_REQUEST_BODY_BYTES = 160 MiB`(http-bridge,原先是两个独立的 32 MiB 字面量),以满足加载时的容量断言(总量 × 4/3 加余量 ≈ 134.3 MiB)。消费级产品集中在 10 到 20 个附件(ChatGPT 10、Gemini 10、Claude 20;DeepSeek Chat 的 50 是例外),且视觉模型一张图约 1300 到 4800 token,因此 50 张图可在一条消息中填满 200k 上下文。3.5 MiB 编码文件包括 base64 填充在内最多占 4.67 MiB,在 5 MiB 路由检查下保留 0.33 MiB 余量。仅使用较大上限路由的部署可以覆盖该值。512 MiB 总量无法通过当前传输,因为 base64 进 JSON 需要一个超过 V8 约 512 MiB 字符串上限的单个 JSON 字符串。限额以 `imageLimits` 会话投影到达客户端。它是每次启动恒定的单元(`apply` 返回同一状态引用,因此只靠基线携带、不存在变更帧),由 **apiproxy** 而非 attachment Service Definition 注册:`dsh-llm` 依赖 `dsh-attachment`(`ImageBlock` → `ImageAttachmentRef`),seam 包引用 `dsh-session-projection`(其图谱经 `dsh-session` 到达 `dsh-llm`)会闭合 project-reference 环,而该值描述的每消息数量与总量规则本来就是 proxy 自己的准入检查。`SessionProjectionMap` 合并放在 proxy 的 sessions 协议文件里,每个客户端程序都经载体的类型再导出包含它。 **加入预检与错误文案。** 两种加入手势汇合到 InputBar 的一个 `intakeImages` 包装:在 `addImages` 之前按投影检查数量、单图字节与总字节,违规的一批整体拒收(DeepSeek Chat 语义)并立刻弹出点名上限的横幅——不再有提交时的回滚戏码。宿主检查保留,兜底绕过 composer 的调用方。横幅文案遵循用户定下的一条原则:用户能解决的原因(模型不支持视觉、数量、大小、分辨率、格式——格式改为正面列出支持列表而不是回显被拒的 MIME 类型)用点明出路的产品句子;用户无法解决的原因(base64 损坏、引用丢失、读取失败)折叠为一条保留原因码的发送失败句子,因为产品当前面向开发者,可上报的码好过死胡同。非附件错误码保留原文加错误码的展示。 diff --git a/.agents/notes/implemented/process/2026-08-17-readme-assets-on-cdn.i18n.yaml b/.agents/notes/implemented/process/2026-08-17-readme-assets-on-cdn.i18n.yaml new file mode 100644 index 0000000000..61a6e6de7c --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-17-readme-assets-on-cdn.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-17-readme-assets-on-cdn.md +2026-08-17-readme-assets-on-cdn.md: 8918e2118123382553e1ea295351482030e0e640 +2026-08-17-readme-assets-on-cdn.zh.md: a1314068ff0bbebfef5bb0053f04aebe650f6101 diff --git a/.agents/notes/implemented/process/2026-08-17-readme-assets-on-cdn.md b/.agents/notes/implemented/process/2026-08-17-readme-assets-on-cdn.md new file mode 100644 index 0000000000..8918e21181 --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-17-readme-assets-on-cdn.md @@ -0,0 +1,35 @@ +# Agent Note: README assets publish from a dedicated repository + +Status: implemented + +English | [中文](2026-08-17-readme-assets-on-cdn.zh.md) + +## Problem + +The public Chinese README embeds three community QR codes. Repository-relative images make each replacement depend on a source change and the separate public-repository publication flow, even though the image bytes do not change product code or documentation text. + +The images need stable public URLs while their source bytes, publication credentials, cache behavior, and update history remain explicit and reviewable. + +## Decision + +The README references fixed URLs under `https://cdn.deepseek.com/harness/readme/`. The private [`deepseek-harness/readme-cdn-assets`](https://github.com/deepseek-harness/readme-cdn-assets) repository owns the three allowlisted PNG files, their tests, and their publication code. A push to its `master` branch runs `publish.yml`, which installs the pinned Huawei OBS SDK, tests `scripts/upload.mjs`, and publishes the images. + +The uploader accepts only the three README filenames, verifies each source is a PNG file, and uploads it to `dp-cdn-deepseek/harness/readme/` with `Content-Type: image/png` and `Cache-Control: no-store`. It checks the OBS response status, reports the resulting public URL, and closes the client on both success and failure. Repository Actions Secrets supply `OBS_DSH_README_ACCESS_KEY_ID` and `OBS_DSH_README_SECRET_ACCESS_KEY`; the OBS identity needs write access only to that object prefix. + +The assets repository provides the update history and rollback source. The public README keeps the same URLs across image replacements, so ordinary image updates do not require a product-repository change or a public-repository synchronization. + +## Alternatives considered + +**Keep repository-relative images on `master`.** This preserves GitHub as the only image host, but every operational QR-code replacement remains coupled to the code review and public-repository publication path. + +**Keep a long-lived assets branch in the product repository.** A branch avoids product `master` changes, but it leaves image ownership, OBS credentials, and publication workflow attached to the product repository and its repository-wide automation. A dedicated repository gives that operational source one default branch and one narrow responsibility. + +**Use content-addressed CDN object names.** Immutable objects avoid stale caches, but each image replacement must also change the README URL, which removes the independent update path this workflow exists to provide. + +**Allow the uploader to publish arbitrary paths.** A generic uploader could serve future assets without code changes, but the same credentials could then overwrite unrelated CDN objects. The fixed allowlist keeps this publication job limited to the README images it owns. + +## Consequences + +Community QR codes can change through one assets-repository push while the public README remains unchanged. The product repository carries no OBS dependency or credential, uploads retain an auditable git source, and CDN responses carry `Cache-Control: no-store`. + +The README depends on the public CDN and GitHub's image proxy, while publication depends on a second private repository and its two Actions Secrets. `no-store` gives up edge and browser caching for these small files. diff --git a/.agents/notes/implemented/process/2026-08-17-readme-assets-on-cdn.zh.md b/.agents/notes/implemented/process/2026-08-17-readme-assets-on-cdn.zh.md new file mode 100644 index 0000000000..a1314068ff --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-17-readme-assets-on-cdn.zh.md @@ -0,0 +1,35 @@ +# Agent Note: README 资产通过专用仓库发布 + +Status: implemented + +[English](2026-08-17-readme-assets-on-cdn.md) | 中文 + +## 问题 + +公开中文 README 嵌入了 3 张社区二维码。使用仓库相对路径时,每次替换都依赖源码变更以及独立的公开仓库发布流程,即使图片字节并未改变产品代码或文档文字。 + +这些图片需要稳定的公开 URL,同时必须明确并可评审地保存源文件字节、发布凭证、缓存行为和更新历史。 + +## 决策 + +README 引用 `https://cdn.deepseek.com/harness/readme/` 下的固定 URL。私有仓库 [`deepseek-harness/readme-cdn-assets`](https://github.com/deepseek-harness/readme-cdn-assets) 负责管理 3 张允许发布的 PNG 文件、相应测试和发布代码。向该仓库的 `master` 分支 push 会运行 `publish.yml`,安装固定版本的华为云 OBS SDK、测试 `scripts/upload.mjs` 并发布图片。 + +上传脚本只接受 3 个 README 图片文件名,验证每个源文件均为 PNG,并以 `Content-Type: image/png` 和 `Cache-Control: no-store` 上传到 `dp-cdn-deepseek/harness/readme/`。脚本检查 OBS 响应状态、报告对应公开 URL,并在成功或失败后关闭客户端。仓库级 GitHub Actions Secret 提供 `OBS_DSH_README_ACCESS_KEY_ID` 和 `OBS_DSH_README_SECRET_ACCESS_KEY`;OBS 身份只需拥有该对象前缀的写权限。 + +资产仓库提供更新记录和回滚真源。图片替换后,公开 README 继续使用相同 URL,因此常规图片更新无需修改产品仓库或同步公开仓库。 + +## 曾考虑的替代方案 + +**继续在 `master` 上使用仓库相对图片。**这种做法只使用 GitHub 托管图片,但每次运营二维码替换仍与代码评审和公开仓库发布流程耦合。 + +**在产品仓库中保留长期资产分支。**资产分支可以避免修改产品 `master`,但图片所有权、OBS 凭证和发布工作流仍依附于产品仓库及其全仓自动化。专用仓库为这项运营资源提供单一默认分支和单一职责。 + +**使用内容寻址的 CDN 对象名。**不可变对象不会产生陈旧缓存,但每次替换图片还必须修改 README URL,无法提供此工作流所需的独立更新路径。 + +**允许上传脚本发布任意路径。**通用上传脚本可以在不改代码的情况下支持未来资产,但同一组凭证也能覆盖无关 CDN 对象。固定允许列表将发布任务限制在它负责的 README 图片内。 + +## 后果 + +社区二维码可以通过一次资产仓库 push 更新,公开 README 无需改变。产品仓库不携带 OBS 依赖或凭证;上传内容保留可审计的 git 真源;CDN 响应携带 `Cache-Control: no-store`。 + +README 依赖公开 CDN 和 GitHub 图片代理,发布流程则依赖另一个私有仓库及其 2 个 GitHub Actions Secret。`no-store` 为这些小文件放弃边缘节点和浏览器缓存。 diff --git a/.agents/notes/implemented/simplification/2026-08-11-remove-empty-experimental-package-group.md b/.agents/notes/implemented/simplification/2026-08-11-remove-empty-experimental-package-group.md deleted file mode 100644 index e5e81e3e37..0000000000 --- a/.agents/notes/implemented/simplification/2026-08-11-remove-empty-experimental-package-group.md +++ /dev/null @@ -1,37 +0,0 @@ -# Agent Note: Remove the empty experimental package group - -Status: implemented - -English | [中文](2026-08-11-remove-empty-experimental-package-group.zh.md) - -## Problem - -The package hierarchy reserves `packages/experimental/` for prototypes and internal-only plugins, but no package has used the group. The empty group adds placement, dependency, promotion, and release rules without a current package or release mechanism that needs them. - -The original group aimed to let the team share prototypes against the real plugin graph without implying product support. That need remains possible, but it does not justify a permanent repository category before a concrete package exists. - -## Decision - -The package hierarchy has no reserved experimental or internal-only group. Packages continue to live in groups selected for their current product role. - -A concrete package that needs different release, stability, or dependency treatment requires a decision based on its actual consumers and release mechanism. That decision may reintroduce a dedicated group when it can also define and enforce the exclusion rules. - -This note consolidates and supersedes the experimental-package-group decision, whose active triplet is removed with the empty directory. - -## Alternatives considered - -**Keep the empty group.** It provides an obvious future incubation location, but it also keeps repository rules with no current owner, package, or enforcement mechanism. - -**Move the experimental rules into the general package instructions.** This preserves the policy without an empty directory, but makes every package change carry rules for a hypothetical package class. - -**Put concrete experimental packages in product-role groups with README labels.** This preserves product-role colocation, but labels alone cannot enforce release and runtime-dependency rules. A future package can evaluate this option against its actual release mechanism. - -**Treat every package as experimental until the first tagged release.** This applies a broad temporary status without providing durable treatment for packages that remain experimental after releases begin. - -**Require prototypes to stay outside the repository.** This would lose access to the real plugin graph, examples, snapshots, and lifecycle checks. Removing the reserved group does not impose that restriction; a concrete prototype can establish the placement it needs. - -## Consequences - -The hierarchy loses an unused group and its special release and dependency policy. It also gives up a predeclared location for team discovery and a ready-made promotion path. - -The first package that needs experimental or internal-only treatment must define where it lives, how releases exclude it, which runtime dependencies are allowed, and what condition promotes or removes it. A dedicated group can return when those rules have a current consumer and enforceable mechanism. diff --git a/.agents/notes/implemented/simplification/2026-08-11-remove-empty-experimental-package-group.zh.md b/.agents/notes/implemented/simplification/2026-08-11-remove-empty-experimental-package-group.zh.md deleted file mode 100644 index d44d0daaf3..0000000000 --- a/.agents/notes/implemented/simplification/2026-08-11-remove-empty-experimental-package-group.zh.md +++ /dev/null @@ -1,37 +0,0 @@ -# Agent Note: 移除空的实验性包分组 - -Status: implemented - -[English](2026-08-11-remove-empty-experimental-package-group.md) | 中文 - -## 问题 - -包层级结构预留 `packages/experimental/` 用于原型和内部专用插件,但从未有包使用该分组。这个空分组添加了放置、依赖、提升和发布规则,却没有需要这些规则的现有包或发布机制。 - -原分组旨在让团队基于真实插件图共享原型,同时不暗示产品会提供支持。这项需求将来可能出现,但在具体包出现前,不足以支持一个永久的仓库类别。 - -## 决策 - -包层级结构不再预留实验性或内部专用分组。包继续按照当前产品职责放入对应分组。 - -如果具体包需要不同的发布、稳定性或依赖处理,必须根据其实际消费方和发布机制做出决策。只要该决策同时定义并强制执行排除规则,就可以重新引入专用分组。 - -本 Agent Note 整合并取代实验性包分组决策;该旧决策的活跃三文件组随空目录一并移除。 - -## 考虑过的替代方案 - -**保留空分组。** 它为未来孵化工作提供明确位置,但也会保留没有当前负责人、包或强制执行机制的仓库规则。 - -**将实验性规则移入通用包指令。** 这可以在不保留空目录的情况下延续政策,但会让每次包变更都携带针对假设包类别的规则。 - -**将具体实验性包放入产品职责分组,并用 README 标注。** 这会保持产品职责共置,但仅靠标注无法强制执行发布和运行时依赖规则。未来的包可以根据实际发布机制评估此选项。 - -**在首个带标签的版本发布前,将每个包都视为实验性。** 这会施加宽泛的临时状态,却无法为发布开始后仍处于实验状态的包提供持久处理方式。 - -**要求原型留在仓库外。** 这会失去真实插件图、示例、快照和生命周期检查。移除预留分组并不施加这项限制;具体原型可以建立自身所需的放置规则。 - -## 后果 - -包层级结构移除了未使用的分组及其特殊发布和依赖政策,同时也放弃了预先声明的团队发现位置和现成的提升路径。 - -第一个需要实验性或内部专用处理的包必须定义其存放位置、发布版本如何排除它、允许哪些运行时依赖,以及包在何种条件下获得提升或被移除。当这些规则具有当前消费方和可强制执行的机制时,可以恢复专用分组。 diff --git a/.agents/notes/implemented/simplification/2026-08-12-trim-agent-teams-read-and-lifecycle-surface.i18n.yaml b/.agents/notes/implemented/simplification/2026-08-12-trim-agent-teams-read-and-lifecycle-surface.i18n.yaml new file mode 100644 index 0000000000..226d604242 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-12-trim-agent-teams-read-and-lifecycle-surface.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-08-12-trim-agent-teams-read-and-lifecycle-surface.md +2026-08-12-trim-agent-teams-read-and-lifecycle-surface.md: 736ed2587c8b2ffd9d824efa14dd0d249c43f669 +2026-08-12-trim-agent-teams-read-and-lifecycle-surface.zh.md: be30d9239d4dc0c44adda63d9488e7a25d217a9e diff --git a/.agents/notes/implemented/simplification/2026-08-12-trim-agent-teams-read-and-lifecycle-surface.md b/.agents/notes/implemented/simplification/2026-08-12-trim-agent-teams-read-and-lifecycle-surface.md new file mode 100644 index 0000000000..736ed2587c --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-12-trim-agent-teams-read-and-lifecycle-surface.md @@ -0,0 +1,57 @@ +# Agent Note: Trim the Agent Teams read and lifecycle surface + +Status: implemented + +English | [中文](2026-08-12-trim-agent-teams-read-and-lifecycle-surface.zh.md) + +## Problem + +Agent Teams correctly owns durable roster, peer-mailbox, and shared-task policy, while the subagent continuation manager owns continuable child Activations. The first implementation nevertheless duplicated data and lifecycle mechanics across those roles. + +The read surface published `TeamSnapshot`, including pending mail that no production caller read. Web `team.get` called that snapshot for Team identity and a global revision, discarded its collections, then called `listMembers()` and `listTasks()`; the browser used neither the Team id it had already addressed nor the global revision. Public member and task views also repeated internal fields: member `error` duplicated `diagnostics`, task `ownerId` duplicated the model/UI owner name, and task timestamps had no reader. `SpawnTeammateResult.initialMessageId`, `TeamDeliverySource`, and the public resolved config type likewise had no consumer. + +Durable member, task, message, and acknowledgement payloads copied timestamps already owned by the Session event envelope. Message `targetName` repeated the immutable roster lookup. The fold validated these values only against their previous copies, so the extra fields added format and validation code without deciding behavior. + +`waitForChange()` returned a zero-or-one `changes` array with a domain kind and Lead-log revision even though every caller immediately re-listed authoritative state. The accompanying `team/changed` event had no production listener. Team interruption bypassed subagent authorization and cancellation semantics by calling `Agent.cancel()` directly. Team teardown separately combined cancellation, descendant drain, `whenIdle()`, and Agent-registry polling even though the continuation manager alone owns Activation release. + +## Decision + +The Team service keeps the distinct product responsibilities: durable named roster, Lead-log mailbox, and task DAG. It does not merge with the generic subagent catalog or task service. + +Inside `@deepseek-ai/dsh-team`, `TeamService` is the Cordis-facing façade and disposal coordinator. `TeamJournal` owns per-Lead transaction order and append-plus-flush publication; `TeamRoster` owns membership and provisioning; `TeamMailbox` owns target-local dispatch, acknowledgement, and retry state; `TeamTaskBoard` owns task authorization, DAG transitions, and derived views; `TeamActivity` owns current waiters; and `TeamRuntimeLifecycle` owns the single admission cutoff and bounded settlement. These package-private collaborators share the existing service capability without publishing additional Cordis services. + +The unused snapshot API and global Team revision are removed. Host reads return only roster and task views; they do not repeat the already-addressed Team id. Member failures appear once in `diagnostics`. Task views expose `ownerName` but keep `ownerId` inside the durable service implementation. Spawn returns the member view only, and validated config is private. + +Durable Team values retain only fields needed to replay Team behavior. Session event `seq` and `time` own ordering and timing; roster membership owns immutable names. Member/task/message timestamps, message `targetName`, and acknowledgement `deliveredAt` are removed. Task CAS retains its task-local `revision`, which is behavioral rather than observational metadata. + +`waitForChange()` now returns `{ timedOut }`. A committed Team append or live member-status edge wakes current waiters after the owning flush, and callers re-list. The unused `team/changed` event, change kind, change revision, and disposal sentinel are removed. + +Team `interrupt()` resolves the durable roster name, then delegates to `SubagentService.interrupt()` with exact ancestor authority. Team teardown selects the roster's exact live direct-child ids and calls the new `drainContinuableChildren(parent, childIds)` continuation operation. That operation authorizes exact direct ownership, opens selected Activation disposal synchronously, recursively releases descendants child-first, leaves siblings and parent-wide admission alone, and treats absent targets as no-ops. Full teardown clears pending inbox work; only interrupt promises `keepInbox`. + +Creation and dispatch remain separate in-flight sets because disposal must await creation before dispatches that creation recovery can register. The mailbox's durable enqueue/acknowledgement, target-side de-duplication, FIFO dispatch repair, provisioning reconciliation, and Host fold fallback remain unchanged. + +## Alternatives considered + +**Merge Team messaging into subagent follow-up.** Rejected. Subagent follow-up addresses a child by Session id and owns Activation delivery; Team messaging adds immutable names, peer authorization, durable enqueue-before-delivery, quiet inactive behavior, acknowledgement, retry, and sender framing. + +**Replace Team tasks with the generic task service.** Rejected. The Team board is a Lead-log DAG with CAS revisions, member ownership, dependencies, tombstones, and advisory write scopes. Those are product semantics, not duplicate storage plumbing. + +**Keep the public fields for future consumers.** Rejected before the first tagged release. Every removed field lacked a production reader, and pending mail or timing can be projected from the authoritative log if a concrete product later needs them. + +**Subscribe to `agent/disposed` from Team teardown.** Rejected. The Team fiber is already unwinding when teardown runs, so new event registration is invalid. More importantly, an observer would still duplicate the continuation manager's ownership instead of asking that owner to release exact children. + +**Use `drainContinuableDescendants()` on the Lead.** Rejected because it would stop non-Team continuable children and close admission for the whole Lead lineage. Draining descendants of each teammate stops only grandchildren and leaves the teammate Activation itself to Team polling. The exact-child operation expresses the required set directly. + +**Preserve teammate inboxes during full teardown.** Rejected after testing the real handle lifecycle. `AgentHandle.dispose()` is a full release and clears unclaimed inbox work. Describing it as resumable parking would be false; interruption remains the non-disposing operation that preserves pending input. + +**Keep all runtime responsibilities in one `TeamService` class.** Rejected because the class would own unrelated task policy, roster provisioning, mailbox delivery queues, waiters, and shutdown settlement. Package-private state owners retain one public service while making each asynchronous set and lifecycle controller belong to the operation family that settles it. + +## Testing + +Subagent tests cover exact-child selection, duplicate ids, sibling isolation, recursive descendant release, wrong-parent authorization, and a manager-less no-op. Team tests cover delegated interrupt, bounded exact-child teardown, provisioning cleanup, mailbox recovery, wait wake/timeout/disposal, and the reduced views and durable records; white-box failure injection addresses the package-private roster, mailbox, and journal owners instead of widening `TeamService`. Host, tool, and client tests cover the reduced wire and model-visible results. Type checking covers the public deletion across host and browser faces. + +## Consequences + +Team and subagent remain separate capability seams with one lifecycle owner. Team chooses which roster children belong to its runtime; subagent performs interruption and Activation teardown. The Team surface is smaller, persisted records no longer mirror their Session envelope, and wait consumers cannot mistake an advisory change kind or revision for a coherent snapshot. Package-private state ownership keeps `TeamService` focused on the public operations, Cordis event wiring, recovery order, and disposal order; the split adds internal modules but no public API or durable-format change. + +Web `team.get` still folds once for members and once for tasks. A coherent combined snapshot was not a consumer requirement, and adding an incremental cache would introduce a separate consistency mechanism rather than simplify this seam. diff --git a/.agents/notes/implemented/simplification/2026-08-12-trim-agent-teams-read-and-lifecycle-surface.zh.md b/.agents/notes/implemented/simplification/2026-08-12-trim-agent-teams-read-and-lifecycle-surface.zh.md new file mode 100644 index 0000000000..be30d9239d --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-12-trim-agent-teams-read-and-lifecycle-surface.zh.md @@ -0,0 +1,57 @@ +# Agent Note: Trim the Agent Teams read and lifecycle surface + +Status: implemented + +[English](2026-08-12-trim-agent-teams-read-and-lifecycle-surface.md) | 中文 + +## Problem + +Agent Teams 合理地拥有持久 roster、peer mailbox 与共享任务策略,subagent continuation manager 则拥有 continuable child Activation。但第一版仍在这两类角色之间重复了数据和生命周期机制。 + +读取表面发布了 `TeamSnapshot`,其中包含没有生产调用方读取的 pending mail。Web `team.get` 为 Team identity 与全局 revision 调用该 snapshot,丢弃其中的集合,再调用 `listMembers()` 和 `listTasks()`;浏览器既不用已经寻址过的 Team id,也不用全局 revision。公开 member 与 task view 还重复内部字段:member `error` 与 `diagnostics` 重复,task `ownerId` 与模型/UI 使用的 owner name 重复,task 时间戳没有读取方。`SpawnTeammateResult.initialMessageId`、`TeamDeliverySource` 和公开的 resolved config type 同样没有消费者。 + +持久 member、task、message 与 acknowledgement payload 复制了 Session event envelope 已拥有的时间戳。message `targetName` 重复不可变 roster lookup。fold 只拿这些值与其旧副本互相校验,因此额外字段增加格式与校验代码,却不决定行为。 + +`waitForChange()` 返回长度为零或一的 `changes` 数组,携带领域 kind 与 Lead-log revision,但所有调用方都会立即重新列出权威状态。配套 `team/changed` event 没有生产 listener。Team interrupt 绕过 subagent 授权与取消语义,直接调用 `Agent.cancel()`。Team teardown 又自行组合 cancel、descendant drain、`whenIdle()` 与 Agent registry 轮询,尽管只有 continuation manager 拥有 Activation release。 + +## Decision + +Team 服务保留独立的产品职责:持久具名 roster、Lead-log mailbox 与 task DAG。它不会与通用 subagent catalog 或 task service 合并。 + +在 `@deepseek-ai/dsh-team` 内,`TeamService` 是面向 Cordis 的 façade 与 disposal 协调者。`TeamJournal` 负责每个 Lead 的 transaction 顺序以及 append-plus-flush 发布;`TeamRoster` 负责 membership 与 provisioning;`TeamMailbox` 负责 target-local dispatch、acknowledgement 与 retry 状态;`TeamTaskBoard` 负责 task 授权、DAG transition 与派生 view;`TeamActivity` 负责当前 waiter;`TeamRuntimeLifecycle` 负责唯一的准入截止与有界 settlement。这些包内 collaborator 共享现有 service capability,不发布额外 Cordis service。 + +删除未使用的 snapshot API 与全局 Team revision。Host 读取只返回 roster 与 task view,不重复已经寻址的 Team id。member failure 只在 `diagnostics` 出现一次。task view 暴露 `ownerName`,把 `ownerId` 留在持久服务实现内部。spawn 只返回 member view,已校验 config 改为私有。 + +持久 Team value 只保留回放 Team 行为所需字段。Session event 的 `seq` 与 `time` 负责顺序和时间;roster membership 负责不可变名字。member/task/message 时间戳、message `targetName` 与 acknowledgement `deliveredAt` 均删除。task CAS 保留 task-local `revision`,因为它是行为字段,而非观测元数据。 + +`waitForChange()` 现在返回 `{ timedOut }`。已提交 Team append 或 live member-status edge 会在所属 flush 后唤醒当前 waiter,调用方随后重新列出状态。未使用的 `team/changed` event、change kind、change revision 与 disposal sentinel 一并删除。 + +Team `interrupt()` 先解析持久 roster name,再以确切 ancestor authority 委托 `SubagentService.interrupt()`。Team teardown 选择 roster 中确切的 live direct-child id,并调用新的 continuation 操作 `drainContinuableChildren(parent, childIds)`。该操作验证确切直接所有权,同步开启所选 Activation 的 disposal,递归以 child-first 释放后代,不影响 sibling 与 parent-wide admission,缺席目标视为 no-op。完整 teardown 会清空 pending inbox;只有 interrupt 承诺 `keepInbox`。 + +创建与 dispatch 继续使用分离的 in-flight set,因为 dispose 必须先等待创建,再等待创建 recovery 可能注册的 dispatch。mailbox 的持久 enqueue/acknowledgement、target-side 去重、FIFO dispatch 修复、provisioning 对账与 Host fold fallback 保持不变。 + +## Alternatives considered + +**把 Team messaging 合并进 subagent follow-up。** 否决。subagent follow-up 按 Session id 寻址 child 并负责 Activation 投递;Team messaging 额外提供不可变名字、peer 授权、先持久 enqueue 再投递、quiet inactive 行为、acknowledgement、retry 与 sender framing。 + +**用通用 task service 替换 Team task。** 否决。Team board 是带 CAS revision、member owner、dependency、tombstone 与 advisory write scope 的 Lead-log DAG。这些是产品语义,不是重复存储 plumbing。 + +**为未来消费者保留公开字段。** 在首次 tag 发布前否决。每个删除字段都没有生产读取方;若未来有具体产品需要 pending mail 或时间,可从权威日志投影。 + +**在 Team teardown 中订阅 `agent/disposed`。** 否决。teardown 运行时 Team fiber 已在解绑,新 event registration 无效。更重要的是,observer 仍会重复 continuation manager 的所有权,而不是要求 owner 释放确切 child。 + +**对 Lead 使用 `drainContinuableDescendants()`。** 否决,因为它会停止非 Team continuable child,并关闭整个 Lead 谱系的准入。对每个 teammate drain descendants 又只会停止孙级,把 teammate Activation 本身留给 Team 轮询。exact-child 操作直接表达所需集合。 + +**在完整 teardown 时保留 teammate inbox。** 在真实 handle lifecycle 测试后否决。`AgentHandle.dispose()` 是完整 release,会清空未 claim 的 inbox 工作。把它描述成可恢复 parking 是错误的;interrupt 仍是保留 pending input 的非 disposing 操作。 + +**把全部 runtime 职责保留在一个 `TeamService` class 中。** 否决,因为该 class 会同时拥有互不相关的 task policy、roster provisioning、mailbox delivery queue、waiter 与 shutdown settlement。包内 state owner 在保留单一公开 service 的同时,让每组异步状态与 lifecycle controller 归属于负责结算它们的 operation family。 + +## Testing + +Subagent 测试覆盖 exact-child selection、重复 id、sibling 隔离、递归 descendant release、错误 parent 授权与 manager 缺席时的 no-op。Team 测试覆盖委托 interrupt、有界 exact-child teardown、provisioning cleanup、mailbox recovery、wait 的 wake/timeout/dispose,以及缩减后的 view 与持久记录;白盒 failure injection 直接访问包内 roster、mailbox 与 journal owner,不扩宽 `TeamService`。Host、tool 和 client 测试覆盖缩减后的 wire 与模型可见结果。typecheck 覆盖 Host 与浏览器表面的公开删除。 + +## Consequences + +Team 与 subagent 仍是独立 capability seam,但只有一个生命周期 owner。Team 选择哪些 roster child 属于其运行时;subagent 执行 interrupt 与 Activation teardown。Team 表面更小,持久记录不再镜像 Session envelope,wait 消费方也不会把提示性的 change kind 或 revision 误当作一致 snapshot。包内 state ownership 让 `TeamService` 专注于公开 operation、Cordis event wiring、recovery 顺序与 disposal 顺序;该拆分增加内部 module,但不改变公开 API 或持久格式。 + +Web `team.get` 仍为 member 与 task 各折叠一次。消费方不要求一致的组合 snapshot,而增加增量 cache 会引入独立一致性机制,不是对该 seam 的简化。 diff --git a/AGENTS.md b/AGENTS.md index 53f4d17bdb..99b31077db 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,6 +43,7 @@ packages/ @deepseek-ai/dsh- workspaces at packages/// boot/ shared app-bin glue sdk/ JSON-RPC protocol, server, and TypeScript client examples/ demo bundles (agent-spine + CLI/ACP/JSON-RPC bins) + experimental/ private prototypes excluded from official releases support/ dev/test infrastructure util/ zero-dependency utilities python/ Python SDK and bundled runtime (see python/README.md) diff --git a/README.i18n.yaml b/README.i18n.yaml index 47ae1591e8..8daa789977 100644 --- a/README.i18n.yaml +++ b/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write README.md README.md: 8a4bd01332a23ce4144c661784bc549e0ba72d21 -README.zh.md: b7bc214bfb1fd8a76a47de3f0aa242122aeb7603 +README.zh.md: c507bf884bd426feead6a96adbdb5c136456e3b5 diff --git a/README.zh.md b/README.zh.md index b7bc214bfb..c507bf884b 100644 --- a/README.zh.md +++ b/README.zh.md @@ -50,9 +50,9 @@ pnpm dsh web - DeepSeek Harness 企微小助手二维码 - DeepSeek Harness 入群问卷二维码 - DeepSeek Harness 团队微信公众号二维码 + DeepSeek Harness 企微小助手二维码 + DeepSeek Harness 入群问卷二维码 + DeepSeek Harness 团队微信公众号二维码 diff --git a/apps/web/tests/assembled-boot.ts b/apps/web/tests/assembled-boot.ts index d19271d6c8..cb079a344c 100644 --- a/apps/web/tests/assembled-boot.ts +++ b/apps/web/tests/assembled-boot.ts @@ -186,9 +186,10 @@ export function installAssembledBootEnv(): void { /** * Mount the assembled application on the fixture transport; the teardown * registered by installAssembledBootEnv disposes it. + * @param search - fixture query string used to select deterministic host behavior. */ -export function mountAssembledApp(): void { - history.replaceState(null, '', '/?fixture') +export function mountAssembledApp(search = '?fixture'): void { + history.replaceState(null, '', `/${search}`) const root = document.createElement('div') root.id = 'root' document.body.appendChild(root) diff --git a/apps/web/tests/cordis-tool-round.e2e.ts b/apps/web/tests/cordis-tool-round.e2e.ts index 1aa99383ba..66d1a3b132 100644 --- a/apps/web/tests/cordis-tool-round.e2e.ts +++ b/apps/web/tests/cordis-tool-round.e2e.ts @@ -108,12 +108,16 @@ describe('web e2e: Cordis tools use their owned cards', () => { // NOT the plugin running. Until a person answers, the browser half has not // been fetched, evaluated, or mounted anywhere on this page. expect(await page.locator('[data-snapshot-probe]').count()).toBe(0) + const sessionId = await runTurnSettled + // Approving from idle makes the run-outcome steer a distinct continuation + // turn, matching the recorded replay and keeping turn grouping deterministic. const approvalTurnSettled = scaffold.whenTurnSettled() await approve.click() await expect.poll(() => page.locator('[data-snapshot-probe]').count(), { timeout: 30_000 }).toBe(1) await approvalTurnSettled - - const sessionId = await runTurnSettled + await expect.poll(() => page.getByText('The Cordis Plugin is running.', { exact: true }).count(), { timeout: 15_000 }) + .toBeGreaterThanOrEqual(1) + await expect.poll(() => input.isEnabled(), { timeout: 15_000 }).toBe(true) const stopTurnSettled = scaffold.whenTurnSettled() await input.fill(STOP_PROMPT) await input.press('Enter') diff --git a/apps/web/tests/image-display.snapshot.ts b/apps/web/tests/image-display.snapshot.ts index 679060e64b..fcdcc19f0f 100644 --- a/apps/web/tests/image-display.snapshot.ts +++ b/apps/web/tests/image-display.snapshot.ts @@ -197,3 +197,35 @@ it('accepts a whole-page drop under the limits-labeled overlay and refuses an ov const rail = document.querySelector('[role="group"][aria-label="Pending images"]') expect([...(rail?.querySelectorAll('img') ?? [])]).toHaveLength(1) }) + +it('renders a host dimension rejection with the projected 2000px limit', async () => { + mountAssembledApp('?fixture&fixturePrompt=reject') + + const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 }) + const start = tree.querySelector('button[aria-label="New session in fixture"]') + if (start === null) throw new Error('fixture Workspace new-session action missing') + fireEvent.click(start) + + const textarea = await screen.findByPlaceholderText('Describe what you want to build', {}, { timeout: 10_000 }) + const image = new File([new Uint8Array([137, 80, 78, 71])], 'too-wide.png', { type: 'image/png' }) + fireEvent.paste(textarea, { + clipboardData: { + items: [{ kind: 'file', type: 'image/png', getAsFile: () => image }], + getData: () => '', + }, + }) + await waitFor(() => { + expect(document.querySelector('[role="group"][aria-label="Pending images"]')).not.toBeNull() + }) + fireEvent.keyDown(textarea, { key: 'Enter' }) + + const message = 'Image sides must be at most 2000px; downscale it and try again' + const toast = await screen.findByText(message) + expect({ role: toast.closest('[role="alert"]')?.getAttribute('role'), text: toast.textContent }).toMatchInlineSnapshot(` + { + "role": "alert", + "text": "Image sides must be at most 2000px; downscale it and try again", + } + `) + expect(document.querySelector('[role="group"][aria-label="Pending images"]')).not.toBeNull() +}) diff --git a/apps/web/tests/models-settings.e2e.ts b/apps/web/tests/models-settings.e2e.ts index e07426cce1..bb84b2ec03 100644 --- a/apps/web/tests/models-settings.e2e.ts +++ b/apps/web/tests/models-settings.e2e.ts @@ -30,6 +30,7 @@ const EMPTY_EXPECTED = join(SNAPSHOT_DIR, 'empty.expected.md') const CONFIGURED_EXPECTED = join(SNAPSHOT_DIR, 'configured.expected.md') const DECLARED_EXPECTED = join(SNAPSHOT_DIR, 'declared.expected.md') const DECLARED_EDIT_EXPECTED = join(SNAPSHOT_DIR, 'declared-edit.expected.md') +const MODEL_PICKER_EXPECTED = join(SNAPSHOT_DIR, 'model-picker.expected.md') const NATIVE_DELETE_EXPECTED = join(SNAPSHOT_DIR, 'native-delete.expected.md') const DELETE_EXPECTED = join(SNAPSHOT_DIR, 'delete.expected.md') const MODE = webSnapshotMode() @@ -178,6 +179,42 @@ describe('web e2e: Models settings page configures a dormant provider', () => { expect(tripwire.pageErrors).toEqual([]) }, 60_000) + it('selects and clears the discovered model catalog in one action', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-models-picker')) + const settingsDialog = page.getByRole('dialog', { name: '设置' }) + await settingsDialog.getByRole('button', { name: '编辑 minimax-cn' }).click() + await settingsDialog.getByText('自定义设置').click() + await settingsDialog.getByRole('button', { name: '获取可用模型' }).click() + + const picker = page.getByRole('dialog', { name: '选择要添加的模型' }) + await picker.waitFor({ timeout: 10_000 }) + const boxes = picker.getByRole('checkbox') + const count = await boxes.count() + expect(count).toBeGreaterThan(0) + expect(await boxes.evaluateAll(nodes => nodes.map(node => (node as HTMLInputElement).checked))).toEqual( + Array.from({ length: count }, () => true), + ) + + await picker.getByRole('button', { name: '取消全选' }).click() + expect(await boxes.evaluateAll(nodes => nodes.map(node => (node as HTMLInputElement).checked))).toEqual( + Array.from({ length: count }, () => false), + ) + await picker.getByRole('button', { name: '全选' }).waitFor() + const snapshot = await captureStableAria( + page, + '[role="dialog"][aria-label="选择要添加的模型"]', + scaffold.workspaceCwd, + ) + await compareOrRefreshGolden(MODEL_PICKER_EXPECTED, snapshot, MODE) + + await picker.getByRole('button', { name: '全选' }).click() + expect(await boxes.evaluateAll(nodes => nodes.map(node => (node as HTMLInputElement).checked))).toEqual( + Array.from({ length: count }, () => true), + ) + await picker.getByRole('button', { name: '取消', exact: true }).click() + await settingsDialog.getByRole('button', { name: '取消', exact: true }).click() + }, 60_000) + it('declares a route the adapter does not ship', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-models-declare')) const dialog = page.getByRole('dialog', { name: '设置' }) @@ -280,7 +317,8 @@ describe('web e2e: Models settings page configures a dormant provider', () => { it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { await assertFixtureInventory(SNAPSHOT_DIR, [ 'configured.expected.md', 'declared-edit.expected.md', 'declared.expected.md', - 'delete.expected.md', 'empty.expected.md', 'native-delete.expected.md', + 'delete.expected.md', 'empty.expected.md', 'model-picker.expected.md', + 'native-delete.expected.md', ]) }) }) diff --git a/apps/web/tests/snapshots/models-settings/model-picker.expected.md b/apps/web/tests/snapshots/models-settings/model-picker.expected.md new file mode 100644 index 0000000000..3a482d8414 --- /dev/null +++ b/apps/web/tests/snapshots/models-settings/model-picker.expected.md @@ -0,0 +1,18 @@ +- dialog "选择要添加的模型": + - heading "选择要添加的模型" [level=2] + - button "关闭": + - img + - paragraph: 以下是模型提供方的可用模型,勾选要添加的模型。 + - button "全选" + - list: + - listitem: + - checkbox "MiniMax-M2.7" + - text: MiniMax-M2.7 + - listitem: + - checkbox "MiniMax-M2.7-highspeed" + - text: MiniMax-M2.7-highspeed + - listitem: + - checkbox "MiniMax-M3" + - text: MiniMax-M3 + - button "取消" + - button "添加所选" diff --git a/assets/community-wechat-official-account.png b/assets/community-wechat-official-account.png deleted file mode 100644 index 222a051dc8..0000000000 Binary files a/assets/community-wechat-official-account.png and /dev/null differ diff --git a/assets/community-wecom-assistant.png b/assets/community-wecom-assistant.png deleted file mode 100644 index b735f2ccc7..0000000000 Binary files a/assets/community-wecom-assistant.png and /dev/null differ diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index ab7a7558b8..f4d299db18 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/architecture.md -architecture.md: a1507fa5e54f6703e89f09a5d387e6c9afc81ade -architecture.zh.md: 4642a1e7691bccf4d52d9a84c92c8237c3c6658b +architecture.md: 2a51992a4f99d1827259c0a2ca2daa309f0ddd95 +architecture.zh.md: d1629a088ed8d2fe61a7e20ab2ab1fd3709abd50 diff --git a/docs/architecture.md b/docs/architecture.md index a1507fa5e5..2a51992a4f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -101,6 +101,8 @@ A **seam** is a swappable capability with three roles: a **Service Definition** Seams are why one provider swap changes the whole product. Filesystem and subprocess providers share one execution world, so pointing them at a remote sandbox moves Bash, PTY, and LSP with them, with no provider forks. [Subagent providers](subsystems/subagent.md) vary just as widely behind one interface, from a fresh child agent to a delegated turn in another product. +[Experimental Agent Teams](subsystems/team.md) is a private opt-in coordination seam on `ctx.teams`, with a durable roster, task board, and mailbox layered over continuable subagents. + ## Where new behavior goes New behavior attaches to a documented extension point. Changing the loop itself updates this map. diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index 4642a1e769..d1629a088e 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -105,6 +105,8 @@ turn/end seam 正是替换一个提供方就能改变整个产品的原因。文件系统与进程提供方共享同一个执行世界,因此把它们指向远程沙箱,也就把 Bash、PTY 和 LSP 一并搬了过去,无需提供方专用 fork。[subagent 提供方](subsystems/subagent.md)在同一个接口之后同样千差万别,从新建一个子 agent,到把一个轮次委派给另一个产品。 +[实验性 Agent Teams](subsystems/team.md) 是 `ctx.teams` 上的私有显式启用协作 seam,在可继续 subagent 之上提供持久 roster、任务板和 mailbox。 + ## 新行为的归属位置 新行为附加到已有文档记录的扩展点。改动循环本身时,本映射随之更新。 diff --git a/docs/capability-seams.i18n.yaml b/docs/capability-seams.i18n.yaml index 4584a0f345..e12756c849 100644 --- a/docs/capability-seams.i18n.yaml +++ b/docs/capability-seams.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/capability-seams.md -capability-seams.md: a990a9dd4d92d10e37b82e6a63caa4a5a469c441 -capability-seams.zh.md: 441d9222835e67c4a9f657d2335860005934094b +capability-seams.md: 710c399510b6b123123e0a9586d86bfc3a96dff9 +capability-seams.zh.md: e0559d464ba1ecd2160eaba40c24bfd829e4a0d6 diff --git a/docs/capability-seams.md b/docs/capability-seams.md index a990a9dd4d..710c399510 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -157,6 +157,9 @@ flowchart LR pkg_subagent_dsh_sdk["subagent-dsh-sdk"] pkg_tool_subagent_control["tool-subagent-control"] pkg_tool_ralph["tool-ralph"] + pkg_team["team"] + svc_teams["ctx.teams
Agent Teams coordination domain"] + pkg_tool_team["tool-team"] pkg_jobs["jobs"] svc_jobs["ctx.jobs
Background job registry"] pkg_jobs_local["jobs-local"] @@ -279,6 +282,7 @@ flowchart LR pkg_subprocess_e2b --> svc_subprocess pkg_subprocess_local --> svc_subprocess pkg_system_prompt --> svc_systemPrompt + pkg_team --> svc_teams pkg_terminal --> svc_terminals pkg_terminal_bash --> svc_terminals pkg_token_meter --> svc_tokenMeter @@ -383,6 +387,7 @@ flowchart LR svc_systemPrompt --> pkg_tool_terminal svc_systemPrompt --> pkg_tool_web svc_systemPrompt --> pkg_tools + svc_teams --> pkg_tool_team svc_terminals --> pkg_tool_terminal svc_tokenMeter --> pkg_compaction_basic svc_toolResultPruner --> pkg_compaction_basic @@ -456,6 +461,7 @@ flowchart LR | `ctx.fs` | `seam` | [`fs`](../packages/fs/fs) | [`fs-local`](../packages/fs/fs-local), [`fs-sandbox`](../packages/fs/fs-sandbox), [`fs-e2b`](../packages/e2b/fs-e2b) | [`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/workflow/tool-ralph) | - | Providers implement transports; the service also owns optional Activation-based continuation orchestration, tool-subagent selects one-shot or continuable delegation, tool-subagent-control delivers follow-ups, and tool-ralph requires one fresh structured-output route. | +| `ctx.teams` | `core` | [`team`](../packages/experimental/team) | - | [`tool-team`](../packages/experimental/tool-team) | - | Owns the implicit-root roster, durable peer mailbox, shared task DAG, and continuable-child lifecycle; tool-team contributes the scoped model policy and controls. | | `ctx.jobs` | `seam` | [`jobs`](../packages/jobs/jobs) | [`jobs-local`](../packages/jobs/jobs-local) | [`tool-bash`](../packages/shell/tool-bash), [`tool-terminal`](../packages/terminal/tool-terminal), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | - | Producers (background bash, PTY sends, and subagent delegations) register running work; tool-jobs is the model-facing controller that reads, lists, and kills it; jobs-local is the process-local registry. | | `ctx.web` | `seam` | [`web`](../packages/web/web) | [`web-search-exa`](../packages/web/web-search-exa), [`web-search-perplexity`](../packages/web/web-search-perplexity), [`web-search-deepseek`](../packages/web/web-search-deepseek), [`web-fetch-http`](../packages/web/web-fetch-http) | [`tool-web`](../packages/web/tool-web) | - | Search and fetch providers register into one ctx.web seam; tool-web owns the stable model-facing names. | | `ctx.spillStore` | `seam` | [`spill`](../packages/spill/spill) | [`spill-local`](../packages/spill/spill-local) | [`spill-policy`](../packages/spill/spill-policy) | - | The backend saves oversized tool text and returns a model-facing locator plus retrieval hint; spill-policy is the tools/post-execute consumer that decides when to spill. | diff --git a/docs/capability-seams.zh.md b/docs/capability-seams.zh.md index 441d922283..e0559d464b 100644 --- a/docs/capability-seams.zh.md +++ b/docs/capability-seams.zh.md @@ -159,6 +159,9 @@ flowchart LR pkg_subagent_dsh_sdk["subagent-dsh-sdk"] pkg_tool_subagent_control["tool-subagent-control"] pkg_tool_ralph["tool-ralph"] + pkg_team["team"] + svc_teams["ctx.teams
Agent Teams coordination domain"] + pkg_tool_team["tool-team"] pkg_jobs["jobs"] svc_jobs["ctx.jobs
Background job registry"] pkg_jobs_local["jobs-local"] @@ -281,6 +284,7 @@ flowchart LR pkg_subprocess_e2b --> svc_subprocess pkg_subprocess_local --> svc_subprocess pkg_system_prompt --> svc_systemPrompt + pkg_team --> svc_teams pkg_terminal --> svc_terminals pkg_terminal_bash --> svc_terminals pkg_token_meter --> svc_tokenMeter @@ -385,6 +389,7 @@ flowchart LR svc_systemPrompt --> pkg_tool_terminal svc_systemPrompt --> pkg_tool_web svc_systemPrompt --> pkg_tools + svc_teams --> pkg_tool_team svc_terminals --> pkg_tool_terminal svc_tokenMeter --> pkg_compaction_basic svc_toolResultPruner --> pkg_compaction_basic @@ -458,6 +463,7 @@ flowchart LR | `ctx.fs` | `seam` | [`fs`](../packages/fs/fs) | [`fs-local`](../packages/fs/fs-local), [`fs-sandbox`](../packages/fs/fs-sandbox), [`fs-e2b`](../packages/e2b/fs-e2b) | [`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/workflow/tool-ralph) | - | 提供方实现传输;该服务还负责可选的、基于 Activation 的延续编排,tool-subagent 选择一次性或可延续委派,tool-subagent-control 传递后续消息,而 tool-ralph 要求一条全新的结构化输出路由。 | +| `ctx.teams` | `core` | [`team`](../packages/experimental/team) | - | [`tool-team`](../packages/experimental/tool-team) | - | 负责隐式 Root roster、持久 peer mailbox、共享任务 DAG 与 continuable child 生命周期;tool-team 提供作用域化模型策略和控制工具。 | | `ctx.jobs` | `seam` | [`jobs`](../packages/jobs/jobs) | [`jobs-local`](../packages/jobs/jobs-local) | [`tool-bash`](../packages/shell/tool-bash), [`tool-terminal`](../packages/terminal/tool-terminal), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | - | 生产方(后台 bash、PTY 发送和 subagent 委派)登记正在运行的工作;tool-jobs 是面向模型的控制器,用于读取、列出和终止这些工作;jobs-local 是进程本地注册表。 | | `ctx.web` | `seam` | [`web`](../packages/web/web) | [`web-search-exa`](../packages/web/web-search-exa), [`web-search-perplexity`](../packages/web/web-search-perplexity), [`web-search-deepseek`](../packages/web/web-search-deepseek), [`web-fetch-http`](../packages/web/web-fetch-http) | [`tool-web`](../packages/web/tool-web) | - | 搜索和抓取提供方注册到同一个 ctx.web seam;tool-web 负责稳定的面向模型名称。 | | `ctx.spillStore` | `seam` | [`spill`](../packages/spill/spill) | [`spill-local`](../packages/spill/spill-local) | [`spill-policy`](../packages/spill/spill-policy) | - | 后端保存过大的工具文本,并返回面向模型的定位信息和取回提示;spill-policy 是 tools/post-execute 消费方,负责决定何时 spill。 | diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 1f371748b7..7b71e2324c 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: ad9bb6284354b934bd6e0bf3235dcd0b74f33cc5 -config-catalog.zh.md: 7038b951a14115873a032fab0066be2a990e3a3f +config-catalog.md: 35855f511f5c574bfad46aa9aac901a1c151f868 +config-catalog.zh.md: 1cd5fc5d7af19b414a6259783cd0a7c7a18990d6 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index ad9bb62843..35855f511f 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -335,10 +335,12 @@ export interface Config { maxMessageImageBytes?: number /** Maximum intrinsic width multiplied by height accepted for one image. */ maxImagePixels?: number + /** Maximum intrinsic width and maximum intrinsic height accepted for one image. */ + maxImageDimension?: number } ``` -Source: [`packages/attachment/attachment-local/src/index.ts:24`](../packages/attachment/attachment-local/src/index.ts) +Source: [`packages/attachment/attachment-local/src/index.ts:31`](../packages/attachment/attachment-local/src/index.ts) @@ -984,6 +986,13 @@ export interface PiAiProviderProfile { websocketConnectTimeoutMs?: number /** Maximum provider idle time while one stream read is outstanding. */ streamIdleTimeoutMs?: number + /** + * Maximum base64-encoded image payload per request. When a request's + * accumulated images exceed it, the oldest images are replaced by text + * placeholders until the request fits, so a long session keeps completing + * requests instead of being rejected by a request-size cap. + */ + maxRequestImageBytes?: number /** Provider-owned model-request retry policy; omission uses normal defaults. */ retryPolicy?: RetryPolicyConfig } @@ -1117,7 +1126,7 @@ export type PiAiThinkingFormat = NonNullable @@ -2282,6 +2291,30 @@ export interface Config { Source: [`packages/core/system-prompt/src/index.ts:186`](../packages/core/system-prompt/src/index.ts) + + +## `@deepseek-ai/dsh-team` + +Requires: `agents` · `sessions` · `sessionPersistence` · `subagents` + +```ts config-catalog +/** Team-service deployment limits. */ +export interface Config { + /** Maximum immutable teammate names retained by one Team. */ + readonly maxMembers?: number + /** Maximum non-deleted tasks retained by one Team. */ + readonly maxTasks?: number + /** Maximum queued-minus-delivered messages for one target member. */ + readonly maxPendingMessagesPerMember?: number + /** Maximum UTF-8 bytes in one complete sender-framed delivery. */ + readonly maxMessageBytes?: number + /** Maximum milliseconds allowed for Team-owned runtime disposal. */ + readonly disposalTimeoutMs?: number +} +``` + +Source: [`packages/experimental/team/src/types.ts:125`](../packages/experimental/team/src/types.ts) + ## `@deepseek-ai/dsh-terminal-bash` @@ -2702,9 +2735,9 @@ Requires: `subagents` · `tools` · `systemPrompt` /** Config: how accepted reports are scheduled on the parent. */ export interface Config { /** - * Parent scheduling (default `wakeup`). `wakeup` creates one ordinary later - * parent turn; `quiet` adds context without waking, so a parked parent learns - * of the report only when something else wakes it. + * Parent scheduling (default `next-step`). `next-step` wakes the parent and + * enters at its nearest step boundary; `quiet` adds the same context without + * waking, so a parked parent waits for another waking input. */ reportDelivery?: SubagentReportDelivery } @@ -2714,6 +2747,24 @@ Depends on: [`SubagentReportDelivery`](subsystems/subagent.md) Source: [`packages/subagent/tool-subagent-report/src/index.ts:27`](../packages/subagent/tool-subagent-report/src/index.ts) + + +## `@deepseek-ai/dsh-tool-team` + +Requires: `agents` · `teams` · `tools` · `systemPrompt` + +```ts config-catalog +/** Tool routing configuration. */ +export interface Config { + /** Continuable-subagent provider used for fresh teammates. */ + readonly freshProvider?: string + /** Continuable-subagent provider used for completed-prefix fork teammates. */ + readonly forkProvider?: string +} +``` + +Source: [`packages/experimental/tool-team/src/index.ts:17`](../packages/experimental/tool-team/src/index.ts) + ## `@deepseek-ai/dsh-tool-terminal` diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 7038b951a1..1cd5fc5d7a 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -337,10 +337,12 @@ export interface Config { maxMessageImageBytes?: number /** Maximum intrinsic width multiplied by height accepted for one image. */ maxImagePixels?: number + /** Maximum intrinsic width and maximum intrinsic height accepted for one image. */ + maxImageDimension?: number } ``` -来源:[`packages/attachment/attachment-local/src/index.ts:24`](../packages/attachment/attachment-local/src/index.ts) +来源:[`packages/attachment/attachment-local/src/index.ts:31`](../packages/attachment/attachment-local/src/index.ts) @@ -986,6 +988,13 @@ export interface PiAiProviderProfile { websocketConnectTimeoutMs?: number /** Maximum provider idle time while one stream read is outstanding. */ streamIdleTimeoutMs?: number + /** + * Maximum base64-encoded image payload per request. When a request's + * accumulated images exceed it, the oldest images are replaced by text + * placeholders until the request fits, so a long session keeps completing + * requests instead of being rejected by a request-size cap. + */ + maxRequestImageBytes?: number /** Provider-owned model-request retry policy; omission uses normal defaults. */ retryPolicy?: RetryPolicyConfig } @@ -1119,7 +1128,7 @@ export type PiAiThinkingFormat = NonNullable @@ -2284,6 +2293,31 @@ export interface Config { 来源:[`packages/core/system-prompt/src/index.ts:186`](../packages/core/system-prompt/src/index.ts) + + +## `@deepseek-ai/dsh-team` + +需要:`agents` · `sessions` · `sessionPersistence` · `subagents` + +```ts config-catalog +/** Team-service deployment limits. */ +export interface Config { + /** Maximum immutable teammate names retained by one Team. */ + readonly maxMembers?: number + /** Maximum non-deleted tasks retained by one Team. */ + readonly maxTasks?: number + /** Maximum queued-minus-delivered messages for one target member. */ + readonly maxPendingMessagesPerMember?: number + /** Maximum UTF-8 bytes in one complete sender-framed delivery. */ + readonly maxMessageBytes?: number + /** Maximum milliseconds allowed for Team-owned runtime disposal. */ + readonly disposalTimeoutMs?: number +} +``` + +来源:[`packages/experimental/team/src/types.ts:125`](../packages/experimental/team/src/types.ts) + + ## `@deepseek-ai/dsh-terminal-bash` @@ -2704,9 +2738,9 @@ export interface Config { /** Config: how accepted reports are scheduled on the parent. */ export interface Config { /** - * Parent scheduling (default `wakeup`). `wakeup` creates one ordinary later - * parent turn; `quiet` adds context without waking, so a parked parent learns - * of the report only when something else wakes it. + * Parent scheduling (default `next-step`). `next-step` wakes the parent and + * enters at its nearest step boundary; `quiet` adds the same context without + * waking, so a parked parent waits for another waking input. */ reportDelivery?: SubagentReportDelivery } @@ -2716,6 +2750,25 @@ export interface Config { 来源:[`packages/subagent/tool-subagent-report/src/index.ts:27`](../packages/subagent/tool-subagent-report/src/index.ts) + + +## `@deepseek-ai/dsh-tool-team` + +需要:`agents` · `teams` · `tools` · `systemPrompt` + +```ts config-catalog +/** Tool routing configuration. */ +export interface Config { + /** Continuable-subagent provider used for fresh teammates. */ + readonly freshProvider?: string + /** Continuable-subagent provider used for completed-prefix fork teammates. */ + readonly forkProvider?: string +} +``` + +来源:[`packages/experimental/tool-team/src/index.ts:17`](../packages/experimental/tool-team/src/index.ts) + + ## `@deepseek-ai/dsh-tool-terminal` diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index 7e67006a40..93519e4a57 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/event-producer-consumer.md -event-producer-consumer.md: 6a79e6f7ce5addc64b10efa8da7a886dcfb36dc2 -event-producer-consumer.zh.md: f7576a8e28e4f1db2c65c324595c05c98b8fe488 +event-producer-consumer.md: c7b474a15c701781a70019f0703c0d60da87bcae +event-producer-consumer.zh.md: 2e0667288ea44f83b7030e686db194f04d550a9b diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 6a79e6f7ce..c7b474a15c 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -9,8 +9,8 @@ This matrix shows which packages dispatch each harness-owned event and which pac | --- | --- | --- | --- | --- | | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:183`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | | `agent-preset/selected` | `emit` | [`packages/preset/agent-presets/src/types.ts:13`](../packages/preset/agent-presets/src/types.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`) | `apiproxy` | -| `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:159`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-presets`](../packages/preset/agent-presets), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule) | -| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:168`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) | +| `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:159`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-presets`](../packages/preset/agent-presets), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), [`tool-team`](../packages/experimental/tool-team) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:168`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-team`](../packages/experimental/tool-team) | | `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:290`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), `apiproxy`, [`goal-round-driver`](../packages/goal/goal-round-driver), [`session-telemetry`](../packages/session/session-telemetry) | | `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:197`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | | `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:205`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) | @@ -18,8 +18,8 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:231`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent-instructions`](../packages/context/agent-instructions), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-skill`](../packages/skill/tool-skill) | | `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:244`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) | | `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:260`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`llm-retry`](../packages/llm/llm-retry) | -| `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:217`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:178`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `apiproxy`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server` | +| `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:217`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`team`](../packages/experimental/team) | +| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:178`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `apiproxy`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, [`team`](../packages/experimental/team) | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:278`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/index.ts:30`](../packages/interaction/user-approval/src/index.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `apiproxy` | | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:72`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `apiproxy` | @@ -39,8 +39,8 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:64`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) | | `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - | | `session/created` | `emit` | [`packages/core/session/src/index.ts:54`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-telemetry`](../packages/session/session-telemetry), [`time-context`](../packages/context/time-context), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | -| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:64`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session/session-persistence), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) | -| `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-presets`](../packages/preset/agent-presets), `apiproxy`, [`compaction`](../packages/compaction/compaction), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/test-support/loader-smoke), `server`, [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`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), [`token-meter`](../packages/llm/token-meter), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | +| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:64`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session/session-persistence), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`team`](../packages/experimental/team) | +| `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-presets`](../packages/preset/agent-presets), `apiproxy`, [`compaction`](../packages/compaction/compaction), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/test-support/loader-smoke), `server`, [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`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), [`team`](../packages/experimental/team), [`token-meter`](../packages/llm/token-meter), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:85`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session/session-persistence), [`session-telemetry`](../packages/session/session-telemetry) | | `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:48`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `apiproxy` | | `settings/updated` | `emit` | [`packages/settings/settings/src/types.ts:35`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) | @@ -68,7 +68,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event string | Dispatchers | Listeners | | --- | --- | --- | -| `internal/dispatch` | - | [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`schedule`](../packages/schedule/schedule), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`terminal-bash`](../packages/terminal/terminal-bash), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`workflow`](../packages/workflow/workflow) | +| `internal/dispatch` | - | [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`schedule`](../packages/schedule/schedule), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`team`](../packages/experimental/team), [`terminal-bash`](../packages/terminal/terminal-bash), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`workflow`](../packages/workflow/workflow) | | `internal/plugin` | - | `loader`, [`lsp-stdio`](../packages/lsp/lsp-stdio), `webserver` | | `internal/service` | - | [`agent-presets`](../packages/preset/agent-presets), `gateway` | | `internal/status` | - | [`agent`](../packages/core/agent) | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index f7576a8e28..2e0667288e 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -11,8 +11,8 @@ | --- | --- | --- | --- | --- | | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:183`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | | `agent-preset/selected` | `emit` | [`packages/preset/agent-presets/src/types.ts:13`](../packages/preset/agent-presets/src/types.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`) | `apiproxy` | -| `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:159`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-presets`](../packages/preset/agent-presets), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule) | -| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:168`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) | +| `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:159`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-presets`](../packages/preset/agent-presets), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), [`tool-team`](../packages/experimental/tool-team) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:168`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-team`](../packages/experimental/tool-team) | | `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:290`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), `apiproxy`, [`goal-round-driver`](../packages/goal/goal-round-driver), [`session-telemetry`](../packages/session/session-telemetry) | | `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:197`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | | `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:205`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) | @@ -20,8 +20,8 @@ | `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:231`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent-instructions`](../packages/context/agent-instructions), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-skill`](../packages/skill/tool-skill) | | `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:244`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) | | `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:260`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`llm-retry`](../packages/llm/llm-retry) | -| `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:217`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:178`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `apiproxy`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server` | +| `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:217`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`team`](../packages/experimental/team) | +| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:178`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `apiproxy`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, [`team`](../packages/experimental/team) | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:278`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/index.ts:30`](../packages/interaction/user-approval/src/index.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `apiproxy` | | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:72`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `apiproxy` | @@ -41,8 +41,8 @@ | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:64`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) | | `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - | | `session/created` | `emit` | [`packages/core/session/src/index.ts:54`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-telemetry`](../packages/session/session-telemetry), [`time-context`](../packages/context/time-context), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | -| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:64`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session/session-persistence), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) | -| `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-presets`](../packages/preset/agent-presets), `apiproxy`, [`compaction`](../packages/compaction/compaction), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/test-support/loader-smoke), `server`, [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`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), [`token-meter`](../packages/llm/token-meter), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | +| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:64`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session/session-persistence), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`team`](../packages/experimental/team) | +| `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-presets`](../packages/preset/agent-presets), `apiproxy`, [`compaction`](../packages/compaction/compaction), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/test-support/loader-smoke), `server`, [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`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), [`team`](../packages/experimental/team), [`token-meter`](../packages/llm/token-meter), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:85`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session/session-persistence), [`session-telemetry`](../packages/session/session-telemetry) | | `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:48`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `apiproxy` | | `settings/updated` | `emit` | [`packages/settings/settings/src/types.ts:35`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) | @@ -70,7 +70,7 @@ | 事件字符串 | 派发方 | 监听方 | | --- | --- | --- | -| `internal/dispatch` | - | [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`schedule`](../packages/schedule/schedule), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`terminal-bash`](../packages/terminal/terminal-bash), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`workflow`](../packages/workflow/workflow) | +| `internal/dispatch` | - | [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`schedule`](../packages/schedule/schedule), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`team`](../packages/experimental/team), [`terminal-bash`](../packages/terminal/terminal-bash), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`workflow`](../packages/workflow/workflow) | | `internal/plugin` | - | `loader`, [`lsp-stdio`](../packages/lsp/lsp-stdio), `webserver` | | `internal/service` | - | [`agent-presets`](../packages/preset/agent-presets), `gateway` | | `internal/status` | - | [`agent`](../packages/core/agent) | diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 4861e774da..d56eabfa45 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/module-graph.md -module-graph.md: 285caa985be5f2cf3cb363651d0e0a250c232873 -module-graph.zh.md: a571f53da1726637b88b95cdf07ab9e3fe952168 +module-graph.md: acc9af96bcb004acc3ce780294797df31587a6d6 +module-graph.zh.md: 0347a6f48c52e74b371d3ab7d94045dee23c4db7 diff --git a/docs/module-graph.md b/docs/module-graph.md index 285caa985b..acc9af96bc 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -188,6 +188,10 @@ flowchart TD pkg_agent_spine_demo["agent-spine-demo"] pkg_sdk_jsonrpc_demo["sdk-jsonrpc-demo"] end + subgraph group_experimental["packages/experimental"] + pkg_team["team"] + pkg_tool_team["tool-team"] + end subgraph group_extensions["packages/extensions"] pkg_client_ui_cordis["client-ui-cordis"] pkg_cordis_client_runner["cordis-client-runner"] @@ -998,6 +1002,13 @@ flowchart TD pkg_compaction_tool_result_pruner --> pkg_llm pkg_compaction_tool_result_pruner --> pkg_session pkg_compaction_tool_result_pruner --> pkg_token_meter + pkg_team --> pkg_agent + pkg_team --> pkg_brand + pkg_team --> pkg_invariants + pkg_team --> pkg_llm + pkg_team --> pkg_session + pkg_team --> pkg_session_persistence + pkg_team --> pkg_subagent pkg_tool_cordis --> pkg_agent pkg_tool_cordis --> pkg_cordis_host_runner pkg_tool_cordis --> pkg_invariants @@ -1103,6 +1114,12 @@ flowchart TD pkg_agent_spine_demo --> pkg_tool_jobs pkg_agent_spine_demo --> pkg_tool_skill pkg_agent_spine_demo --> pkg_tools + pkg_tool_team --> pkg_agent + pkg_tool_team --> pkg_invariants + pkg_tool_team --> pkg_session + pkg_tool_team --> pkg_system_prompt + pkg_tool_team --> pkg_team + pkg_tool_team --> pkg_tools pkg_sdk_client --> pkg_invariants pkg_sdk_client --> pkg_llm pkg_sdk_client --> pkg_sdk_protocol @@ -1560,6 +1577,7 @@ flowchart TD | [`hooks-claude-code`](../packages/hooks/hooks-claude-code) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`web-app`](../packages/bundle/web-app) | `bundle` | [`invariants`](../packages/runtime-diagnostics/invariants), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt) | | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | +| [`team`](../packages/experimental/team) | `experimental` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`subagent`](../packages/subagent/subagent) | | [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`sdk-protocol`](../packages/sdk/protocol) | `sdk` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | @@ -1573,6 +1591,7 @@ flowchart TD | [`client-connection`](../packages/client/connection) | `client` | [`attachment`](../packages/attachment/attachment), [`commands`](../packages/interaction/commands), [`host-apiproxy`](../packages/host/apiproxy), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`compaction-basic`](../packages/compaction/compaction-basic) | `compaction` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs-local`](../packages/jobs/jobs-local), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`shell-env`](../packages/shell/shell-env), [`skill`](../packages/skill/skill), [`skill-filesystem`](../packages/skill/skill-filesystem), [`system-prompt`](../packages/core/system-prompt), [`tool-bash`](../packages/shell/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-jobs`](../packages/jobs/tool-jobs), [`tool-skill`](../packages/skill/tool-skill), [`tools`](../packages/core/tools) | +| [`tool-team`](../packages/experimental/tool-team) | `experimental` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`team`](../packages/experimental/team), [`tools`](../packages/core/tools) | | [`sdk-client`](../packages/sdk/client) | `sdk` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session) | | [`sdk-jsonrpc-server`](../packages/sdk/server) | `sdk` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sdk-client`](../packages/sdk/client), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index a571f53da1..0347a6f48c 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -190,6 +190,10 @@ flowchart TD pkg_agent_spine_demo["agent-spine-demo"] pkg_sdk_jsonrpc_demo["sdk-jsonrpc-demo"] end + subgraph group_experimental["packages/experimental"] + pkg_team["team"] + pkg_tool_team["tool-team"] + end subgraph group_extensions["packages/extensions"] pkg_client_ui_cordis["client-ui-cordis"] pkg_cordis_client_runner["cordis-client-runner"] @@ -1000,6 +1004,13 @@ flowchart TD pkg_compaction_tool_result_pruner --> pkg_llm pkg_compaction_tool_result_pruner --> pkg_session pkg_compaction_tool_result_pruner --> pkg_token_meter + pkg_team --> pkg_agent + pkg_team --> pkg_brand + pkg_team --> pkg_invariants + pkg_team --> pkg_llm + pkg_team --> pkg_session + pkg_team --> pkg_session_persistence + pkg_team --> pkg_subagent pkg_tool_cordis --> pkg_agent pkg_tool_cordis --> pkg_cordis_host_runner pkg_tool_cordis --> pkg_invariants @@ -1105,6 +1116,12 @@ flowchart TD pkg_agent_spine_demo --> pkg_tool_jobs pkg_agent_spine_demo --> pkg_tool_skill pkg_agent_spine_demo --> pkg_tools + pkg_tool_team --> pkg_agent + pkg_tool_team --> pkg_invariants + pkg_tool_team --> pkg_session + pkg_tool_team --> pkg_system_prompt + pkg_tool_team --> pkg_team + pkg_tool_team --> pkg_tools pkg_sdk_client --> pkg_invariants pkg_sdk_client --> pkg_llm pkg_sdk_client --> pkg_sdk_protocol @@ -1562,6 +1579,7 @@ flowchart TD | [`hooks-claude-code`](../packages/hooks/hooks-claude-code) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`web-app`](../packages/bundle/web-app) | `bundle` | [`invariants`](../packages/runtime-diagnostics/invariants), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt) | | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | +| [`team`](../packages/experimental/team) | `experimental` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`subagent`](../packages/subagent/subagent) | | [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`sdk-protocol`](../packages/sdk/protocol) | `sdk` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | @@ -1575,6 +1593,7 @@ flowchart TD | [`client-connection`](../packages/client/connection) | `client` | [`attachment`](../packages/attachment/attachment), [`commands`](../packages/interaction/commands), [`host-apiproxy`](../packages/host/apiproxy), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`compaction-basic`](../packages/compaction/compaction-basic) | `compaction` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs-local`](../packages/jobs/jobs-local), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`shell-env`](../packages/shell/shell-env), [`skill`](../packages/skill/skill), [`skill-filesystem`](../packages/skill/skill-filesystem), [`system-prompt`](../packages/core/system-prompt), [`tool-bash`](../packages/shell/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-jobs`](../packages/jobs/tool-jobs), [`tool-skill`](../packages/skill/tool-skill), [`tools`](../packages/core/tools) | +| [`tool-team`](../packages/experimental/tool-team) | `experimental` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`team`](../packages/experimental/team), [`tools`](../packages/core/tools) | | [`sdk-client`](../packages/sdk/client) | `sdk` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session) | | [`sdk-jsonrpc-server`](../packages/sdk/server) | `sdk` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sdk-client`](../packages/sdk/client), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index bc6e3dde66..f73e849acf 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/persistence-catalog.md -persistence-catalog.md: c400298f7d37c590918820bcbda10e6550f197e8 -persistence-catalog.zh.md: 65ec0e3fbdd226c51a371dc9a90f10db5c929c7a +persistence-catalog.md: cf796ca322027886b1a0b78d69ac1d3a98d9459f +persistence-catalog.zh.md: 5f82254813ef6fb0b3c9244a2bd03fb2177e5559 diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index c400298f7d..cf796ca322 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -706,6 +706,65 @@ Source: [`packages/core/session/src/types.ts:254`](../packages/core/session/src/ Source: [`packages/subagent/subagent/src/descriptor.ts:37`](../packages/subagent/subagent/src/descriptor.ts) +### `team/*` + + + +#### `team/member` — log-only + +```ts persistence-catalog +/** Whole teammate lifecycle value, stored only in the Team Lead Session. */ +'team/member': { version: 1; teamId: TeamId; member: TeamMemberSnapshot } +``` + +Types: [TeamId](subsystems/team.md) · [TeamMemberSnapshot](subsystems/team.md) + +Source: [`packages/experimental/team/src/types.ts:206`](../packages/experimental/team/src/types.ts) + + + +#### `team/message/delivered` — log-only + +```ts persistence-catalog +/** Durable acknowledgement that the target Session recorded the message. */ +'team/message/delivered': { + version: 1 + teamId: TeamId + messageId: TeamMessageId + targetId: SessionId +} +``` + +Types: [TeamId](subsystems/team.md) · [TeamMessageId](subsystems/team.md) + +Source: [`packages/experimental/team/src/types.ts:212`](../packages/experimental/team/src/types.ts) + + + +#### `team/message/queued` — log-only + +```ts persistence-catalog +/** Durable mailbox enqueue, stored before delivery is attempted. */ +'team/message/queued': { version: 1; teamId: TeamId; message: TeamMessageSnapshot } +``` + +Types: [TeamId](subsystems/team.md) · [TeamMessageSnapshot](subsystems/team.md) + +Source: [`packages/experimental/team/src/types.ts:210`](../packages/experimental/team/src/types.ts) + + + +#### `team/task` — log-only + +```ts persistence-catalog +/** Whole shared-task value, stored only in the Team Lead Session. */ +'team/task': { version: 1; teamId: TeamId; task: TeamTaskSnapshot } +``` + +Types: [TeamId](subsystems/team.md) · [TeamTaskSnapshot](subsystems/team.md) + +Source: [`packages/experimental/team/src/types.ts:208`](../packages/experimental/team/src/types.ts) + ### `todo/*` diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index 65ec0e3fbd..5f82254813 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -708,6 +708,65 @@ export type SessionEvent = { 来源:[`packages/subagent/subagent/src/descriptor.ts:37`](../packages/subagent/subagent/src/descriptor.ts) +### `team/*` + + + +#### `team/member` — log-only + +```ts persistence-catalog +/** Whole teammate lifecycle value, stored only in the Team Lead Session. */ +'team/member': { version: 1; teamId: TeamId; member: TeamMemberSnapshot } +``` + +类型:[TeamId](subsystems/team.md) · [TeamMemberSnapshot](subsystems/team.md) + +来源:[`packages/experimental/team/src/types.ts:206`](../packages/experimental/team/src/types.ts) + + + +#### `team/message/delivered` — log-only + +```ts persistence-catalog +/** Durable acknowledgement that the target Session recorded the message. */ +'team/message/delivered': { + version: 1 + teamId: TeamId + messageId: TeamMessageId + targetId: SessionId +} +``` + +类型:[TeamId](subsystems/team.md) · [TeamMessageId](subsystems/team.md) + +来源:[`packages/experimental/team/src/types.ts:212`](../packages/experimental/team/src/types.ts) + + + +#### `team/message/queued` — log-only + +```ts persistence-catalog +/** Durable mailbox enqueue, stored before delivery is attempted. */ +'team/message/queued': { version: 1; teamId: TeamId; message: TeamMessageSnapshot } +``` + +类型:[TeamId](subsystems/team.md) · [TeamMessageSnapshot](subsystems/team.md) + +来源:[`packages/experimental/team/src/types.ts:210`](../packages/experimental/team/src/types.ts) + + + +#### `team/task` — log-only + +```ts persistence-catalog +/** Whole shared-task value, stored only in the Team Lead Session. */ +'team/task': { version: 1; teamId: TeamId; task: TeamTaskSnapshot } +``` + +类型:[TeamId](subsystems/team.md) · [TeamTaskSnapshot](subsystems/team.md) + +来源:[`packages/experimental/team/src/types.ts:208`](../packages/experimental/team/src/types.ts) + ### `todo/*` diff --git a/docs/subsystems/README.i18n.yaml b/docs/subsystems/README.i18n.yaml index 1a8744fb32..55e453c3f2 100644 --- a/docs/subsystems/README.i18n.yaml +++ b/docs/subsystems/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/README.md -README.md: a1c2262f40aebdf4b0fdcdca4b0c05bae443fe2f -README.zh.md: 0b5c00e6321a67a11dc6f98caedc7e26fb98eec2 +README.md: b3832e44d4fc2150efc4e81aab00d1006cb816b6 +README.zh.md: 5f5a44e9f21956921472c23a141eabac236704e9 diff --git a/docs/subsystems/README.md b/docs/subsystems/README.md index a1c2262f40..b3832e44d4 100644 --- a/docs/subsystems/README.md +++ b/docs/subsystems/README.md @@ -38,6 +38,7 @@ One page per subsystem of the DeepSeek Harness: what it is, the data structures | [skills.md](skills.md) | the skill service: discovery priority, `SkillSummary`/`SkillDefinition`, session-prefix catalog, model-facing `skill` loading | | [compaction.md](compaction.md) | the compaction seam: the `compaction/*` session events, `CompactionResult`, the `CompactionEngine` interface | | [subagent.md](subagent.md) | the subagent seam: the named-provider registry, `SubagentStartRequest`/`Result`/`Run`, the start-time-vs-runtime capability split | +| [team.md](team.md) | Agent Teams: implicit Lead identity, named continuable teammates, durable peer mailbox, and shared task DAG | | [web.md](web.md) | the web access seam: `WebSearchRequest`/`Result`, `WebFetchRequest`/`Result`, `WebFetchBody`, provider availability, `WebError` | | [spill.md](spill.md) | the spill storage seam: `SaveTextSpill`, `SpillOwner`/`SpillSource`, `SpillRef`, the branded `SpillLocator` | | [workflow.md](workflow.md) | the workflow seam: `WorkflowStartRequest`, `WorkflowMeta`, `WorkflowRun`/`Result`, the `workflow/*` event payloads, `WorkflowError` fatality | diff --git a/docs/subsystems/README.zh.md b/docs/subsystems/README.zh.md index 0b5c00e632..5f5a44e9f2 100644 --- a/docs/subsystems/README.zh.md +++ b/docs/subsystems/README.zh.md @@ -38,6 +38,7 @@ | [skills.md](skills.md) | skill(技能)服务:发现优先级、`SkillSummary`/`SkillDefinition`、会话前缀目录、面向模型的 `skill` 加载 | | [compaction.md](compaction.md) | 压缩(compaction)seam:`compaction/*` 会话事件、`CompactionResult`、`CompactionEngine` 接口 | | [subagent.md](subagent.md) | subagent seam:命名提供方注册表、`SubagentStartRequest`/`Result`/`Run`、启动时与运行时能力拆分 | +| [team.md](team.md) | Agent Teams:隐式 Lead 身份、具名 continuable teammate、持久 peer mailbox 与共享任务 DAG | | [web.md](web.md) | Web 访问 seam:`WebSearchRequest`/`Result`、`WebFetchRequest`/`Result`、`WebFetchBody`、提供方可用性、`WebError` | | [spill.md](spill.md) | spill 存储 seam:`SaveTextSpill`、`SpillOwner`/`SpillSource`、`SpillRef`、品牌类型 `SpillLocator` | | [workflow.md](workflow.md) | 工作流 seam:`WorkflowStartRequest`、`WorkflowMeta`、`WorkflowRun`/`Result`、`workflow/*` 事件载荷、`WorkflowError` 致命性 | diff --git a/docs/subsystems/attachment.i18n.yaml b/docs/subsystems/attachment.i18n.yaml index a70ac6dbd6..023bbafab0 100644 --- a/docs/subsystems/attachment.i18n.yaml +++ b/docs/subsystems/attachment.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/attachment.md -attachment.md: ff5a802b23b0111dff4481394772438f5d68feab -attachment.zh.md: e3db8cd58e9bf2eebad66dacb78353cad98d2fd5 +attachment.md: 21e60dbc40504f22229ef98a2dd112eda82fffdd +attachment.zh.md: 886e569b6db9f2a5b1dca39125785d8286e22c7a diff --git a/docs/subsystems/attachment.md b/docs/subsystems/attachment.md index ff5a802b23..21e60dbc40 100644 --- a/docs/subsystems/attachment.md +++ b/docs/subsystems/attachment.md @@ -42,6 +42,8 @@ interface ImageAttachmentLimits { maxImagesPerMessage: number maxMessageImageBytes: number maxImagePixels: number + /** Maximum intrinsic width and maximum intrinsic height in pixels for one image. */ + maxImageDimension: number mediaTypes: readonly ImageMediaType[] } ``` diff --git a/docs/subsystems/attachment.zh.md b/docs/subsystems/attachment.zh.md index e3db8cd58e..886e569b6d 100644 --- a/docs/subsystems/attachment.zh.md +++ b/docs/subsystems/attachment.zh.md @@ -42,6 +42,8 @@ interface ImageAttachmentLimits { maxImagesPerMessage: number maxMessageImageBytes: number maxImagePixels: number + /** Maximum intrinsic width and maximum intrinsic height in pixels for one image. */ + maxImageDimension: number mediaTypes: readonly ImageMediaType[] } ``` diff --git a/docs/subsystems/subagent.i18n.yaml b/docs/subsystems/subagent.i18n.yaml index a86dc8de4a..daad549c62 100644 --- a/docs/subsystems/subagent.i18n.yaml +++ b/docs/subsystems/subagent.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/subagent.md -subagent.md: a683a679e6017351540ee4b73adc74375ef0a1d6 -subagent.zh.md: 61391cd297c0eb14f4c0d8eac4539b551cb60bda +subagent.md: 07ed5ce4a1da9fd68678ec14949d9ba249d7805f +subagent.zh.md: e1b3ab465841ab16d2dc81bb12d8c49663533aa7 diff --git a/docs/subsystems/subagent.md b/docs/subsystems/subagent.md index a683a679e6..07ed5ce4a1 100644 --- a/docs/subsystems/subagent.md +++ b/docs/subsystems/subagent.md @@ -191,7 +191,7 @@ interface ContinuableStart { An optional continuable-child setup contribution can install scope-local capabilities after base child composition and before Activation publication. The registry is ordered and transactional: a failed or revoked setup rolls back the unpublished Activation, child-scope disposal releases every installation, new registrations affect the next Activation, and registration removal revokes every resident installation immediately. -`SubagentRuntime.reportFrom()` uses that extension point without adding a second queue or a result-bearing child wrapper. The exact live child Agent authorizes the call; callers cannot name a recipient. The manager derives the only recipient from the child's durable `parentSession`, requires that parent Agent to be live, frames the selected content as one `subagent-report` user message, and returns the message's stable `MessageId`. Quiet delivery uses `Agent.inject()` and creates no inbox occurrence or parent turn; waking delivery uses `Agent.followup()` and creates one ordinary later parent turn. Neither mode concludes the child's turn, and no final answer reports implicitly. +`SubagentRuntime.reportFrom()` uses that extension point without adding a second queue or a result-bearing child wrapper. The exact live child Agent authorizes the call; callers cannot name a recipient. The manager derives the only recipient from the child's durable `parentSession`, requires that parent Agent to be live, frames the selected content as one `subagent-report` user message, and returns the message's stable `MessageId`. Quiet delivery uses `Agent.inject()` and does not wake the parent; next-step delivery uses `Agent.steer()`, waking an idle parent or joining a running parent's nearest step boundary. Neither mode concludes the child's turn, and no final answer reports implicitly. ```ts type-equiv /** Durable attribution for a continuable child's explicit parent report. */ @@ -206,7 +206,7 @@ interface SubagentReportMessageSource { ```ts type-equiv /** Deployment scheduling policy for accepted child reports. */ -type SubagentReportDelivery = 'quiet' | 'wakeup' +type SubagentReportDelivery = 'quiet' | 'next-step' ``` Reporting is the child's own choice, so the manager keeps a separate account of its own: when a resident Activation settles, it delivers one notice to the child's durable direct parent describing how that epoch ended and carrying its final assistant content. That delivery is unconditional for every child whose id a caller received, happens before the ownership release that would let the parent be judged settled, and reaches a resident parent through the same waking-admission accounting as a report. A parent whose own lineage is already tearing down receives it without a wake, because waking a quiescent Agent starts a turn rather than queueing work. Its provenance is a distinct kind so a transcript never presents a runtime account as something the child wrote. @@ -562,6 +562,18 @@ registerContinuableSetup(contribution: ContinuableSetupContribution): () => void */ async drainContinuableDescendants(parents: readonly Agent[]): Promise +/** + * Release selected resident continuable direct children of one exact live + * parent. Other children of the same parent remain admitted and resident. + * Absent targets and a manager-less composition are accepted no-ops. + * @param parent - exact live direct parent authorizing the selected release. + * @param childIds - durable direct-child ids to release when resident. + * @returns once every selected Activation released its `AgentHandle`. + * @throws {SubagentError} `UNAUTHORIZED` when a resident target belongs to a + * different parent or the supplied parent identity is stale. + */ +async drainContinuableChildren(parent: Agent, childIds: readonly SessionId[]): Promise + /** * Enumerate the parent's direct session-backed subagents without loading or * resuming an Agent and without any query service: the listing merges the live diff --git a/docs/subsystems/subagent.zh.md b/docs/subsystems/subagent.zh.md index 61391cd297..e1b3ab4658 100644 --- a/docs/subsystems/subagent.zh.md +++ b/docs/subsystems/subagent.zh.md @@ -191,7 +191,7 @@ interface ContinuableStart { 可选的可继续 child 设置贡献可以在 child 基础组合完成后、Activation 发布前安装限定在作用域内的能力。该注册表按顺序执行且具有事务性:设置失败或被撤销时会回滚未发布的 Activation;child 作用域 dispose 时会释放所有安装;新注册项在下一个 Activation 生效;移除注册项时则会立即撤销每个驻留中的安装。 -`SubagentRuntime.reportFrom()` 通过该扩展点实现报告,无需新增第二条队列或承载结果的 child 包装层。调用由确切的在线 child Agent 授权,调用方不能指定接收方。管理器从 child 的持久化 `parentSession` 中推导唯一接收方,要求该 parent Agent 必须在线,将选中内容封装为一条 `subagent-report` 用户消息,并返回该消息的稳定 `MessageId`。静默投递使用 `Agent.inject()`,不产生 inbox 条目实例或 parent 轮次;唤醒投递使用 `Agent.followup()`,会产生一个普通的后续 parent 轮次。两种模式都不会结束 child 轮次,最终回答也不会隐式报告。 +`SubagentRuntime.reportFrom()` 通过该扩展点实现报告,无需新增第二条队列或承载结果的 child 包装层。调用由确切的在线 child Agent 授权,调用方不能指定接收方。管理器从 child 的持久化 `parentSession` 中推导唯一接收方,要求该 parent Agent 必须在线,将选中内容封装为一条 `subagent-report` 用户消息,并返回该消息的稳定 `MessageId`。静默投递使用 `Agent.inject()`,不会唤醒 parent;next-step 投递使用 `Agent.steer()`,会唤醒空闲 parent,或加入运行中 parent 最近的 step 边界。两种模式都不会结束 child 轮次,最终回答也不会隐式报告。 ```ts type-equiv /** Durable attribution for a continuable child's explicit parent report. */ @@ -206,7 +206,7 @@ interface SubagentReportMessageSource { ```ts type-equiv /** Deployment scheduling policy for accepted child reports. */ -type SubagentReportDelivery = 'quiet' | 'wakeup' +type SubagentReportDelivery = 'quiet' | 'next-step' ``` 上报是 child 自己的选择,因此管理器还保有一份属于自己的记账:当驻留 Activation 结算时,它会向该 child 持久化的直接 parent 投递一条通知,说明该 epoch 如何结束,并携带其最终 assistant 内容。对每个调用方拿到过 id 的 child,这条投递都是无条件的;它发生在会让 parent 被判定为已结算的所有权释放之前,并通过与上报相同的唤醒准入记账到达驻留 parent。若 parent 自身所在的谱系已在拆卸中,这条通知会以不唤醒的方式送达,因为唤醒一个静息 Agent 是开启一个轮次,而不是排队等待工作。其来源信息使用一个独立的 kind,因此 transcript(文本记录)绝不会把运行时的记账呈现为 child 自己写下的内容。 @@ -564,6 +564,18 @@ registerContinuableSetup(contribution: ContinuableSetupContribution): () => void */ async drainContinuableDescendants(parents: readonly Agent[]): Promise +/** + * Release selected resident continuable direct children of one exact live + * parent. Other children of the same parent remain admitted and resident. + * Absent targets and a manager-less composition are accepted no-ops. + * @param parent - exact live direct parent authorizing the selected release. + * @param childIds - durable direct-child ids to release when resident. + * @returns once every selected Activation released its `AgentHandle`. + * @throws {SubagentError} `UNAUTHORIZED` when a resident target belongs to a + * different parent or the supplied parent identity is stale. + */ +async drainContinuableChildren(parent: Agent, childIds: readonly SessionId[]): Promise + /** * Enumerate the parent's direct session-backed subagents without loading or * resuming an Agent and without any query service: the listing merges the live diff --git a/docs/subsystems/team.i18n.yaml b/docs/subsystems/team.i18n.yaml new file mode 100644 index 0000000000..b58b85085c --- /dev/null +++ b/docs/subsystems/team.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/subsystems/team.md +team.md: 926434bb85d8cb3739e66fdf559c1c51d807613d +team.zh.md: 7eeb4ef5aeeeee119ea931912390d4ff3d42bc85 diff --git a/docs/subsystems/team.md b/docs/subsystems/team.md new file mode 100644 index 0000000000..926434bb85 --- /dev/null +++ b/docs/subsystems/team.md @@ -0,0 +1,183 @@ +# Agent Teams + +English | [中文](team.zh.md) + +Types shared by the experimental implicit-root Team domain, model tools, and host adapters. The [Agent Teams Agent Note](../../.agents/notes/implemented/feature/2026-08-05-agent-teams.md) owns identity, mailbox, task, and shared-checkout decisions; this page records the literal durable forms from [`packages/experimental/team/src/types.ts`](../../packages/experimental/team/src/types.ts). + +## Identity and roster + +`TeamId` is the root `SessionId` under a distinct [brand](core.md#branded-ids). `TeamTaskId` is Team-local and monotonically allocated as `task-`; `TeamMessageId` is globally random. A teammate's Session id remains its persistent identity, while `name` is an immutable model/UI label. + +```ts type-equiv +/** Whole durable value written on every teammate lifecycle change. */ +interface TeamMemberSnapshot { + readonly id: SessionId + readonly name: string + readonly description: string + readonly provider: string + readonly context: 'fresh' | 'fork' + readonly phase: TeamMemberPhase + readonly error?: string +} +``` + +Every member starts in `provisioning` and reaches exactly one terminal roster phase, `active` or `failed`. Runtime `running`/`idle`/`inactive` status is derived separately and never rewrites this record. + +## Durable mailbox + +The Lead Session first stores the complete queued message. A target receipt is acknowledged only after its pending inbox item or recorded user message is durable, leaving queued-minus-delivered as the recovery mailbox. + +```ts type-equiv +/** One peer message retained until its target Session records it. */ +interface TeamMessageSnapshot { + readonly id: TeamMessageId + readonly senderId: SessionId + readonly senderName: string + readonly targetId: SessionId + readonly delivery: 'quiet' | 'wakeup' + readonly content: ContentBlock[] +} +``` + +The target Session keeps message identity and sender attribution on both the pending inbox item and the eventual user message. Folding that source across inbox and history is the target-side de-duplication key; the model-visible framing repeats the id and sender. + +```ts type-equiv +/** Source retained by the target Session for durable mailbox de-duplication. */ +interface TeamMessageSource { + readonly kind: 'team-message' + readonly teamId: TeamId + readonly messageId: TeamMessageId + readonly senderId: SessionId + readonly senderName: string +} +``` + +## Shared task DAG + +Every task event stores a complete snapshot. `revision` is the compare-and-set value and increments by one per mutation. `blockedBy` edges must name non-deleted tasks and keep the graph acyclic. `writeScopes` are normalized advisory path prefixes rather than locks. + +```ts type-equiv +/** Whole durable task snapshot; every mutation increments {@link revision}. */ +interface TeamTaskSnapshot { + readonly id: TeamTaskId + readonly revision: number + readonly subject: string + readonly description: string + readonly status: TeamTaskStatus + readonly ownerId?: SessionId + readonly blockedBy: TeamTaskId[] + readonly writeScopes: string[] +} +``` + +`pending` is unstarted or released, `in_progress` carries an owner, `completed` satisfies blockers, and `deleted` is a retained tombstone. Views add owner name, readiness, and write-scope overlap warnings without changing the durable snapshot. + +## Replay + +`foldTeam()` replays one root Session into the roster, task board, and queued-minus-delivered mailbox that every Team operation reads. It selects records by `TeamId`, so events inherited by an ordinary fork retain the ancestor id and never enter the new root's state. Session event `seq` and `time` remain the ordering and timing record; Team snapshots do not duplicate them. Roster and task reads reach callers as views that add owner name, readiness, and write-scope warnings, while pending mail stays internal to delivery and recovery. The package [README](../../packages/experimental/team/README.md) owns operation, authorization, recovery, and limit behavior. + + + + + +## Cordis API + +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`) — this section is byte-identical in both language sides of the page. 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.teams` — `TeamService` + +Agent Teams service backed by the exact live Lead Session log. + +```ts cordis-catalog +/** + * Resolve one exact live Agent's Team role. + * @param agent - exact live Agent used as the authority credential. + * @returns its root, Team identity, role, and model-facing name. + */ +membership(agent: Agent): TeamMembership + +/** + * List the runtime-enriched roster visible to one Team member. + * @param agent - exact live Team member. + * @returns Lead and teammate rows in creation order. + */ +listMembers(agent: Agent): TeamMemberView[] + +/** + * Create one named, continuable direct child of the Team Lead. + * @param caller - exact live Lead Agent. + * @param request - immutable name, description, prompt, context mode, provider, and cancellation. + * @returns the active roster row. + */ +async spawnTeammate(caller: Agent, request: SpawnTeammateRequest): Promise + +/** + * Queue one durable peer message, then attempt immediate delivery. + * @param caller - exact live sending Team member. + * @param request - target name, content, scheduling mode, and pre-queue cancellation. + * @returns durable message identity and immediate-delivery observation. + */ +async sendMessage(caller: Agent, request: SendTeamMessageRequest): Promise + +/** + * Create one unowned pending task in the Team Lead log. + * @param caller - exact live Team member creating the task. + * @param request - task text, blockers, and advisory write scopes. + * @returns the revision-one task view. + */ +async createTask(caller: Agent, request: CreateTeamTaskRequest): Promise + +/** + * Return one task, including a deleted tombstone. + * @param caller - exact live Team member reading the task. + * @param id - Team-local task identity. + * @returns the latest task value and derived readiness diagnostics. + */ +getTask(caller: Agent, id: TeamTaskId): TeamTaskView + +/** + * List current non-deleted tasks in numeric creation order. + * @param caller - exact live Team member reading the board. + * @returns detached current task views. + */ +listTasks(caller: Agent): TeamTaskView[] + +/** + * Compare-and-set one authorized task transition. + * @param caller - exact live Team member authorizing the mutation. + * @param request - task identity, expected revision, action, and action fields. + * @returns the committed next task revision. + */ +async updateTask(caller: Agent, request: UpdateTeamTaskRequest): Promise + +/** + * Wait for the next Team-domain or member-status change. + * @param caller - exact live Team member waiting for activity. + * @param timeoutMs - bounded wait duration from ten seconds through one hour. + * @param signal - caller cancellation for the wait only. + * @returns one observed change or a timeout result. + */ +async waitForChange(caller: Agent, timeoutMs: number, signal: AbortSignal): Promise + +/** + * Interrupt one live teammate turn without clearing its pending inbox. + * @param caller - exact live Lead Agent. + * @param targetName - durable teammate name. + * @returns the target status sampled before cancellation. + */ +interrupt(caller: Agent, targetName: string): { previousStatus: 'running' | 'idle' | 'inactive' } + +/** + * Resolve a caller without throwing, used by scoped-tool installation and observers. + * @param agent - candidate exact live Agent. + * @returns Team membership, or undefined for non-Team subagents and stale identities. + */ +tryMembership(agent: Agent): TeamMembership | undefined +``` + +Types: [Agent](core.md) + +Source: [`packages/experimental/team/src/index.ts:56`](../../packages/experimental/team/src/index.ts) + diff --git a/docs/subsystems/team.zh.md b/docs/subsystems/team.zh.md new file mode 100644 index 0000000000..7eeb4ef5ae --- /dev/null +++ b/docs/subsystems/team.zh.md @@ -0,0 +1,183 @@ +# Agent Teams + +[English](team.md) | 中文 + +实验性隐式 Root Team 领域、模型工具与宿主适配器共享的类型。[Agent Teams Agent Note](../../.agents/notes/implemented/feature/2026-08-05-agent-teams.md)负责身份、mailbox、task 与共享 checkout 决策;本页记录 [`packages/experimental/team/src/types.ts`](../../packages/experimental/team/src/types.ts) 中的字面持久形式。 + +## 身份与 roster + +`TeamId` 是具有独立[品牌](core.md#branded-ids)的 Root `SessionId`。`TeamTaskId` 在 Team 内按 `task-` 单调分配;`TeamMessageId` 是全局随机值。teammate 的 Session id 始终是持久身份,而 `name` 是不可变的模型/UI 标签。 + +```ts type-equiv +/** Whole durable value written on every teammate lifecycle change. */ +interface TeamMemberSnapshot { + readonly id: SessionId + readonly name: string + readonly description: string + readonly provider: string + readonly context: 'fresh' | 'fork' + readonly phase: TeamMemberPhase + readonly error?: string +} +``` + +每个 member 都从 `provisioning` 开始,并且只到达一个终态 roster phase:`active` 或 `failed`。运行时 `running`/`idle`/`inactive` 状态单独派生,绝不会重写该记录。 + +## 持久 mailbox + +Lead Session 首先存储完整 queued message。只有 target 的 pending inbox 条目或已记录用户消息完成持久化,才会写入独立 acknowledgement event,queued-minus-delivered 因而构成恢复 mailbox。 + +```ts type-equiv +/** One peer message retained until its target Session records it. */ +interface TeamMessageSnapshot { + readonly id: TeamMessageId + readonly senderId: SessionId + readonly senderName: string + readonly targetId: SessionId + readonly delivery: 'quiet' | 'wakeup' + readonly content: ContentBlock[] +} +``` + +target Session 会在 pending inbox 条目和最终用户消息上保留消息身份与发送者归因。跨 inbox 与历史折叠该 source 构成 target 侧去重键;模型可见的 framing 会重复 id 和发送者。 + +```ts type-equiv +/** Source retained by the target Session for durable mailbox de-duplication. */ +interface TeamMessageSource { + readonly kind: 'team-message' + readonly teamId: TeamId + readonly messageId: TeamMessageId + readonly senderId: SessionId + readonly senderName: string +} +``` + +## 共享任务 DAG + +每条 task event 都存储完整快照。`revision` 是 compare-and-set 值,每次变更递增 1。`blockedBy` edge 必须指向未删除任务,并维持无环图。`writeScopes` 是规范化的提示性路径前缀,不是锁。 + +```ts type-equiv +/** Whole durable task snapshot; every mutation increments {@link revision}. */ +interface TeamTaskSnapshot { + readonly id: TeamTaskId + readonly revision: number + readonly subject: string + readonly description: string + readonly status: TeamTaskStatus + readonly ownerId?: SessionId + readonly blockedBy: TeamTaskId[] + readonly writeScopes: string[] +} +``` + +`pending` 表示尚未开始或已经释放,`in_progress` 携带 owner,`completed` 满足 blocker,`deleted` 是保留的 tombstone。view 会添加 owner name、readiness 和 write-scope 重叠警告,但不会改变持久快照。 + +## 回放 + +`foldTeam()` 把一个 Root Session 回放成每个 Team 操作所读取的 roster、任务板与 queued-minus-delivered mailbox。它按 `TeamId` 选取记录,因此普通 fork 继承的 event 保留 ancestor id,绝不会进入新 Root 的状态。Session event 的 `seq` 与 `time` 继续负责顺序和时间记录,Team snapshot 不再重复保存它们。roster 与 task 读取以 view 形式到达调用方,附带 owner name、readiness 与 write-scope 警告,而 pending 邮件仅供投递与恢复内部使用。包 [README](../../packages/experimental/team/README.md)负责 operation、authorization、recovery 和限制行为。 + + + + + +## Cordis API + +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`) — this section is byte-identical in both language sides of the page. 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.teams` — `TeamService` + +Agent Teams service backed by the exact live Lead Session log. + +```ts cordis-catalog +/** + * Resolve one exact live Agent's Team role. + * @param agent - exact live Agent used as the authority credential. + * @returns its root, Team identity, role, and model-facing name. + */ +membership(agent: Agent): TeamMembership + +/** + * List the runtime-enriched roster visible to one Team member. + * @param agent - exact live Team member. + * @returns Lead and teammate rows in creation order. + */ +listMembers(agent: Agent): TeamMemberView[] + +/** + * Create one named, continuable direct child of the Team Lead. + * @param caller - exact live Lead Agent. + * @param request - immutable name, description, prompt, context mode, provider, and cancellation. + * @returns the active roster row. + */ +async spawnTeammate(caller: Agent, request: SpawnTeammateRequest): Promise + +/** + * Queue one durable peer message, then attempt immediate delivery. + * @param caller - exact live sending Team member. + * @param request - target name, content, scheduling mode, and pre-queue cancellation. + * @returns durable message identity and immediate-delivery observation. + */ +async sendMessage(caller: Agent, request: SendTeamMessageRequest): Promise + +/** + * Create one unowned pending task in the Team Lead log. + * @param caller - exact live Team member creating the task. + * @param request - task text, blockers, and advisory write scopes. + * @returns the revision-one task view. + */ +async createTask(caller: Agent, request: CreateTeamTaskRequest): Promise + +/** + * Return one task, including a deleted tombstone. + * @param caller - exact live Team member reading the task. + * @param id - Team-local task identity. + * @returns the latest task value and derived readiness diagnostics. + */ +getTask(caller: Agent, id: TeamTaskId): TeamTaskView + +/** + * List current non-deleted tasks in numeric creation order. + * @param caller - exact live Team member reading the board. + * @returns detached current task views. + */ +listTasks(caller: Agent): TeamTaskView[] + +/** + * Compare-and-set one authorized task transition. + * @param caller - exact live Team member authorizing the mutation. + * @param request - task identity, expected revision, action, and action fields. + * @returns the committed next task revision. + */ +async updateTask(caller: Agent, request: UpdateTeamTaskRequest): Promise + +/** + * Wait for the next Team-domain or member-status change. + * @param caller - exact live Team member waiting for activity. + * @param timeoutMs - bounded wait duration from ten seconds through one hour. + * @param signal - caller cancellation for the wait only. + * @returns one observed change or a timeout result. + */ +async waitForChange(caller: Agent, timeoutMs: number, signal: AbortSignal): Promise + +/** + * Interrupt one live teammate turn without clearing its pending inbox. + * @param caller - exact live Lead Agent. + * @param targetName - durable teammate name. + * @returns the target status sampled before cancellation. + */ +interrupt(caller: Agent, targetName: string): { previousStatus: 'running' | 'idle' | 'inactive' } + +/** + * Resolve a caller without throwing, used by scoped-tool installation and observers. + * @param agent - candidate exact live Agent. + * @returns Team membership, or undefined for non-Team subagents and stale identities. + */ +tryMembership(agent: Agent): TeamMembership | undefined +``` + +Types: [Agent](core.md) + +Source: [`packages/experimental/team/src/index.ts:56`](../../packages/experimental/team/src/index.ts) + diff --git a/docs/tool-catalog.i18n.yaml b/docs/tool-catalog.i18n.yaml index 69b0495168..a760dd4c01 100644 --- a/docs/tool-catalog.i18n.yaml +++ b/docs/tool-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/tool-catalog.md -tool-catalog.md: 02501d0721768d38c1e51bdfb87dcc79696bfd04 -tool-catalog.zh.md: 3583662dbaf72ea78bddb6a2b7d6b18360f3f3e1 +tool-catalog.md: b3f59ed76ad1a26a4da207c52bf0e64c40148a94 +tool-catalog.zh.md: 27ae60554393fc390386b7b0aef64f6d0758cf28 diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 02501d0721..b3f59ed76a 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -36,6 +36,7 @@ This table connects model-visible tool names to the plugin package and service s | `@deepseek-ai/dsh-tool-subagent-control` | `interrupt_agent`, `list_agents`, `send_message` | `ctx.tools`, `ctx.subagents`, `ctx.agents and ctx.sessionProjections (list_agents only)` | `tool/call`, `tool/result`, `child session events through ctx.subagents` | - | The globally named control tools over continuable background subagents: provider-bound `tool-subagent` instances register distinct delegation tools, while this package registers `send_message` and `interrupt_agent` once, plus `list_agents` from its separately loaded `/list-agents` plugin (whose catalog rows use the sessionProjections and live Agent registries). | | `@deepseek-ai/dsh-tool-subagent-report` | `report` | `ctx.subagents`, `ctx.systemPrompt`, `a live continuable in-process child Agent` | `tool/call`, `tool/result`, `a user-role message in the direct parent session` | - | Registered per continuable in-process child rather than globally, so this schema is visible only inside such a child and survives its global `toolFilter`. The same contribution installs the child-scoped `tool:report` prompt section, which this catalog does not render. The parent-facing `send_message` tool is installed independently. | | `@deepseek-ai/dsh-tool-jobs` | `job_kill`, `job_list`, `job_output` | `ctx.tools`, `ctx.jobs`, `ctx.systemPrompt` | `tool/call`, `tool/result`, `user/message via agent.inject() for background completion notices` | - | The kind-agnostic background-job controller: background bash commands, PTY sends, and subagents are read, listed, and killed through the same three tools. Loading the plugin attaches the controller that arms producers' `ctx.jobs.start()`. | +| `@deepseek-ai/dsh-tool-team` | `followup_task`, `interrupt_agent`, `list_agents`, `send_message`, `spawn_teammate`, `team_task_create`, `team_task_get`, `team_task_list`, `team_task_update`, `wait_agent` | `ctx.tools`, `ctx.systemPrompt`, `ctx.teams`, `an exact live Team member Agent` | `tool/call`, `team/member`, `team/message/queued`, `team/message/delivered`, `team/task`, `tool/result` | - | All ten tools are scoped to implicit Team Leads and durable teammates. The shipped dsh-base bundle keeps the package disabled; the documented Agent Teams profile patch enables it while disabling the legacy continuable-child control names. | | `@deepseek-ai/dsh-tool-todo` | `todo_write` | `ctx.tools`, `owning Agent session` | `tool/call`, `todo/write`, `tool/result` | - | todo_write is session-owned state; UIs render the latest todo/write event as a checklist. `allowParallelInProgress` is required with no default, so the catalog states its choice: `true`, whose description invites several `in_progress` items. A deployment choosing `false` receives the same tool with a description asking for exactly one active task. | | `@deepseek-ai/dsh-tool-workflow` | `workflow` | `ctx.tools`, `ctx.workflowEngine`, `ctx.systemPrompt`, `a calling Agent (exec.agent parents the script children)` | `tool/call`, `tool/result` | - | - | | `@deepseek-ai/dsh-tool-web` | `web_fetch`, `web_search` | `ctx.tools`, `ctx.web`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | web_search and web_fetch keep provider selection behind ctx.web so model-visible schemas stay stable across backend swaps. | @@ -1679,6 +1680,322 @@ Source: [`packages/jobs/tool-jobs/src/index.ts`](../packages/jobs/tool-jobs/src/ The kind-agnostic background-job controller: background bash commands, PTY sends, and subagents are read, listed, and killed through the same three tools. Loading the plugin attaches the controller that arms producers' `ctx.jobs.start()`. + + +## `@deepseek-ai/dsh-tool-team` + +### `followup_task` + +Send a durable follow-up task to another Team member and start a turn when needed. + +```json +{ + "type": "object", + "properties": { + "target": { + "type": "string", + "description": "Team member name, or lead." + }, + "message": { + "type": "string", + "description": "Self-contained message for the target." + } + }, + "required": [ + "target", + "message" + ] +} +``` + +Source: [`packages/experimental/tool-team/src/index.ts`](../packages/experimental/tool-team/src/index.ts) + +### `interrupt_agent` + +Interrupt one teammate's current turn while preserving its pending inbox. Team Lead only. + +```json +{ + "type": "object", + "properties": { + "target": { + "type": "string", + "description": "Teammate name." + } + }, + "required": [ + "target" + ] +} +``` + +Source: [`packages/experimental/tool-team/src/index.ts`](../packages/experimental/tool-team/src/index.ts) + +### `list_agents` + +List the Lead and every durable teammate with current runtime status. + +```json +{ + "type": "object", + "properties": {} +} +``` + +Source: [`packages/experimental/tool-team/src/index.ts`](../packages/experimental/tool-team/src/index.ts) + +### `send_message` + +Send durable information to another Team member without starting an idle member. + +```json +{ + "type": "object", + "properties": { + "target": { + "type": "string", + "description": "Team member name, or lead." + }, + "message": { + "type": "string", + "description": "Self-contained message for the target." + } + }, + "required": [ + "target", + "message" + ] +} +``` + +Source: [`packages/experimental/tool-team/src/index.ts`](../packages/experimental/tool-team/src/index.ts) + +### `spawn_teammate` + +Create one named, durable teammate. Only the Team Lead may call this tool. + +```json +{ + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Unique lower-kebab-case teammate name." + }, + "description": { + "type": "string", + "description": "Short description of the delegated responsibility." + }, + "prompt": { + "type": "string", + "description": "Complete initial task for the teammate." + }, + "context": { + "type": "string", + "description": "fresh starts without Lead history; fork inherits completed Lead turns. Defaults to fresh.", + "enum": [ + "fresh", + "fork" + ] + } + }, + "required": [ + "name", + "description", + "prompt" + ] +} +``` + +Source: [`packages/experimental/tool-team/src/index.ts`](../packages/experimental/tool-team/src/index.ts) + +### `team_task_create` + +Create one unowned pending task on the shared Team task board. + +```json +{ + "type": "object", + "properties": { + "subject": { + "type": "string", + "description": "Concise task title." + }, + "description": { + "type": "string", + "description": "Complete task details and acceptance criteria." + }, + "blocked_by": { + "type": "array", + "description": "Task ids that must complete first.", + "items": { + "type": "string" + } + }, + "write_scopes": { + "type": "array", + "description": "Advisory workspace-relative file or directory prefixes this task expects to modify.", + "items": { + "type": "string" + } + } + }, + "required": [ + "subject", + "description" + ] +} +``` + +Source: [`packages/experimental/tool-team/src/index.ts`](../packages/experimental/tool-team/src/index.ts) + +### `team_task_get` + +Read the complete latest value of one shared task before changing or executing it. + +```json +{ + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "Shared task id." + } + }, + "required": [ + "task_id" + ] +} +``` + +Source: [`packages/experimental/tool-team/src/index.ts`](../packages/experimental/tool-team/src/index.ts) + +### `team_task_list` + +List shared tasks, including readiness, owner, revision, blockers, and write-scope warnings. + +```json +{ + "type": "object", + "properties": { + "status": { + "type": "string", + "description": "Optional exact status filter.", + "enum": [ + "pending", + "in_progress", + "completed" + ] + }, + "owner": { + "type": "string", + "description": "Optional member-name filter; use unowned for tasks without an owner." + }, + "ready": { + "type": "boolean", + "description": "Optional readiness filter." + }, + "cursor": { + "type": "integer", + "description": "Zero-based result offset. Defaults to 0." + }, + "limit": { + "type": "integer", + "description": "Number of rows, 1 through 100. Defaults to 50." + } + } +} +``` + +Source: [`packages/experimental/tool-team/src/index.ts`](../packages/experimental/tool-team/src/index.ts) + +### `team_task_update` + +Compare-and-set a shared task action using the latest revision from team_task_get or team_task_list. + +```json +{ + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "Shared task id." + }, + "expected_revision": { + "type": "integer", + "description": "Current task revision used as the CAS precondition." + }, + "action": { + "type": "string", + "description": "Task transition to apply.", + "enum": [ + "claim", + "release", + "edit", + "set_dependencies", + "complete", + "reopen", + "reassign", + "delete" + ] + }, + "subject": { + "type": "string", + "description": "Replacement title for edit." + }, + "description": { + "type": "string", + "description": "Replacement details for edit." + }, + "blocked_by": { + "type": "array", + "description": "Complete blocker list for set_dependencies.", + "items": { + "type": "string" + } + }, + "write_scopes": { + "type": "array", + "description": "Replacement advisory write scopes for edit.", + "items": { + "type": "string" + } + }, + "owner": { + "type": "string", + "description": "Member name for Lead-only reassign; omit to unassign." + } + }, + "required": [ + "task_id", + "expected_revision", + "action" + ] +} +``` + +Source: [`packages/experimental/tool-team/src/index.ts`](../packages/experimental/tool-team/src/index.ts) + +### `wait_agent` + +Wait for the next teammate status, mailbox, or shared-task change after this call starts. This never wakes inactive members and returns noProgress immediately when no other member is running or provisioning. Re-list after wakeup or timeout instead of polling. + +```json +{ + "type": "object", + "properties": { + "timeout_ms": { + "type": "integer", + "description": "Wait duration in milliseconds, from 10000 through 3600000. Defaults to 30000." + } + } +} +``` + +Source: [`packages/experimental/tool-team/src/index.ts`](../packages/experimental/tool-team/src/index.ts) + +All ten tools are scoped to implicit Team Leads and durable teammates. The shipped dsh-base bundle keeps the package disabled; the documented Agent Teams profile patch enables it while disabling the legacy continuable-child control names. + ## `@deepseek-ai/dsh-tool-todo` diff --git a/docs/tool-catalog.zh.md b/docs/tool-catalog.zh.md index 3583662dba..27ae605543 100644 --- a/docs/tool-catalog.zh.md +++ b/docs/tool-catalog.zh.md @@ -38,6 +38,7 @@ | `@deepseek-ai/dsh-tool-subagent-control` | `interrupt_agent`、`list_agents`、`send_message` | `ctx.tools`、`ctx.subagents`、`ctx.agents and ctx.sessionProjections (list_agents only)` | `tool/call`、`tool/result`、`child session events through ctx.subagents` | - | 这些是控制可继续后台 subagent 的全局命名工具:绑定提供方的 `tool-subagent` 实例注册不同的委派工具;本包注册一次 `send_message` 和 `interrupt_agent`,另由 `list_agents` 通过单独加载的 `/list-agents` 插件提供,其目录行使用 sessionProjections 和实时 Agent 注册表。 | | `@deepseek-ai/dsh-tool-subagent-report` | `report` | `ctx.subagents`、`ctx.systemPrompt`、`a live continuable in-process child Agent` | `tool/call`、`tool/result`、`a user-role message in the direct parent session` | - | 按可继续的进程内子级注册,而非全局注册,因此该 schema 仅在这种子级内部可见,并且不受其全局 `toolFilter` 影响。同一份贡献还会安装子级作用域的 `tool:report` 系统提示词 section,本目录不渲染该 section。面向父级的 `send_message` 工具单独安装。 | | `@deepseek-ai/dsh-tool-jobs` | `job_kill`、`job_list`、`job_output` | `ctx.tools`、`ctx.jobs`、`ctx.systemPrompt` | `tool/call`、`tool/result`、`user/message via agent.inject() for background completion notices` | - | 与任务种类无关的后台任务控制器:后台 bash 命令、PTY 发送和 subagent 都通过相同的 3 个工具读取、列出和终止。加载该插件会挂接控制器,从而启用生产方的 `ctx.jobs.start()`。 | +| `@deepseek-ai/dsh-tool-team` | `followup_task`、`interrupt_agent`、`list_agents`、`send_message`、`spawn_teammate`、`team_task_create`、`team_task_get`、`team_task_list`、`team_task_update`、`wait_agent` | `ctx.tools`、`ctx.systemPrompt`、`ctx.teams`、`an exact live Team member Agent` | `tool/call`、`team/member`、`team/message/queued`、`team/message/delivered`、`team/task`、`tool/result` | - | 这 10 个工具限定于隐式 Team Lead 与持久 teammate 作用域。随产品发布的 dsh-base bundle 默认禁用该包;文档中的 Agent Teams profile patch 会启用它,并禁用旧 continuable child 的同名控制工具。 | | `@deepseek-ai/dsh-tool-todo` | `todo_write` | `ctx.tools`、`owning Agent session` | `tool/call`、`todo/write`、`tool/result` | - | todo_write 是会话所有的状态;UI 将最新的 todo/write 事件渲染为检查清单。`allowParallelInProgress` 是没有默认值的必填项,因此本目录明确选择 `true`,对应描述允许同时存在多个 `in_progress` 项。选择 `false` 的部署会获得同一工具,但描述会要求只能有 1 个活动任务。 | | `@deepseek-ai/dsh-tool-workflow` | `workflow` | `ctx.tools`、`ctx.workflowEngine`、`ctx.systemPrompt`、`a calling Agent (exec.agent parents the script children)` | `tool/call`、`tool/result` | - | - | | `@deepseek-ai/dsh-tool-web` | `web_fetch`、`web_search` | `ctx.tools`、`ctx.web`、`ctx.systemPrompt` | `tool/call`、`tool/result` | - | web_search 和 web_fetch 将提供方选择置于 ctx.web 之后,使模型可见 schema 在更换后端时保持稳定。 | @@ -1685,6 +1686,321 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后, +## `@deepseek-ai/dsh-tool-team` + +### `followup_task` + +向另一名 Team member 发送持久 follow-up task,并在需要时启动一个 turn。 + +```json +{ + "type": "object", + "properties": { + "target": { + "type": "string", + "description": "Team member name, or lead." + }, + "message": { + "type": "string", + "description": "Self-contained message for the target." + } + }, + "required": [ + "target", + "message" + ] +} +``` + +来源:[`packages/experimental/tool-team/src/index.ts`](../packages/experimental/tool-team/src/index.ts) + +### `interrupt_agent` + +中断一名 teammate 的当前 turn,同时保留其待处理 inbox。仅 Team Lead 可用。 + +```json +{ + "type": "object", + "properties": { + "target": { + "type": "string", + "description": "Teammate name." + } + }, + "required": [ + "target" + ] +} +``` + +来源:[`packages/experimental/tool-team/src/index.ts`](../packages/experimental/tool-team/src/index.ts) + +### `list_agents` + +列出 Lead 与所有持久 teammate,以及各自当前的运行时状态。 + +```json +{ + "type": "object", + "properties": {} +} +``` + +来源:[`packages/experimental/tool-team/src/index.ts`](../packages/experimental/tool-team/src/index.ts) + +### `send_message` + +向另一名 Team member 发送持久信息,但不启动 idle member。 + +```json +{ + "type": "object", + "properties": { + "target": { + "type": "string", + "description": "Team member name, or lead." + }, + "message": { + "type": "string", + "description": "Self-contained message for the target." + } + }, + "required": [ + "target", + "message" + ] +} +``` + +来源:[`packages/experimental/tool-team/src/index.ts`](../packages/experimental/tool-team/src/index.ts) + +### `spawn_teammate` + +创建一名具名、持久的 teammate。只有 Team Lead 可以调用此工具。 + +```json +{ + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Unique lower-kebab-case teammate name." + }, + "description": { + "type": "string", + "description": "Short description of the delegated responsibility." + }, + "prompt": { + "type": "string", + "description": "Complete initial task for the teammate." + }, + "context": { + "type": "string", + "description": "fresh starts without Lead history; fork inherits completed Lead turns. Defaults to fresh.", + "enum": [ + "fresh", + "fork" + ] + } + }, + "required": [ + "name", + "description", + "prompt" + ] +} +``` + +来源:[`packages/experimental/tool-team/src/index.ts`](../packages/experimental/tool-team/src/index.ts) + +### `team_task_create` + +在共享 Team 任务板上创建一个无 owner 的 pending task。 + +```json +{ + "type": "object", + "properties": { + "subject": { + "type": "string", + "description": "Concise task title." + }, + "description": { + "type": "string", + "description": "Complete task details and acceptance criteria." + }, + "blocked_by": { + "type": "array", + "description": "Task ids that must complete first.", + "items": { + "type": "string" + } + }, + "write_scopes": { + "type": "array", + "description": "Advisory workspace-relative file or directory prefixes this task expects to modify.", + "items": { + "type": "string" + } + } + }, + "required": [ + "subject", + "description" + ] +} +``` + +来源:[`packages/experimental/tool-team/src/index.ts`](../packages/experimental/tool-team/src/index.ts) + +### `team_task_get` + +在修改或执行共享任务前,读取其完整的最新值。 + +```json +{ + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "Shared task id." + } + }, + "required": [ + "task_id" + ] +} +``` + +来源:[`packages/experimental/tool-team/src/index.ts`](../packages/experimental/tool-team/src/index.ts) + +### `team_task_list` + +列出共享任务,包括 readiness、owner、revision、blocker 与 write-scope warning。 + +```json +{ + "type": "object", + "properties": { + "status": { + "type": "string", + "description": "Optional exact status filter.", + "enum": [ + "pending", + "in_progress", + "completed" + ] + }, + "owner": { + "type": "string", + "description": "Optional member-name filter; use unowned for tasks without an owner." + }, + "ready": { + "type": "boolean", + "description": "Optional readiness filter." + }, + "cursor": { + "type": "integer", + "description": "Zero-based result offset. Defaults to 0." + }, + "limit": { + "type": "integer", + "description": "Number of rows, 1 through 100. Defaults to 50." + } + } +} +``` + +来源:[`packages/experimental/tool-team/src/index.ts`](../packages/experimental/tool-team/src/index.ts) + +### `team_task_update` + +使用 team_task_get 或 team_task_list 返回的最新 revision,对共享任务操作执行 compare-and-set。 + +```json +{ + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "Shared task id." + }, + "expected_revision": { + "type": "integer", + "description": "Current task revision used as the CAS precondition." + }, + "action": { + "type": "string", + "description": "Task transition to apply.", + "enum": [ + "claim", + "release", + "edit", + "set_dependencies", + "complete", + "reopen", + "reassign", + "delete" + ] + }, + "subject": { + "type": "string", + "description": "Replacement title for edit." + }, + "description": { + "type": "string", + "description": "Replacement details for edit." + }, + "blocked_by": { + "type": "array", + "description": "Complete blocker list for set_dependencies.", + "items": { + "type": "string" + } + }, + "write_scopes": { + "type": "array", + "description": "Replacement advisory write scopes for edit.", + "items": { + "type": "string" + } + }, + "owner": { + "type": "string", + "description": "Member name for Lead-only reassign; omit to unassign." + } + }, + "required": [ + "task_id", + "expected_revision", + "action" + ] +} +``` + +来源:[`packages/experimental/tool-team/src/index.ts`](../packages/experimental/tool-team/src/index.ts) + +### `wait_agent` + +等待本次调用开始后下一次 teammate 状态、mailbox 或共享任务变更。它绝不会唤醒 inactive member;若没有其他 member 正在 running 或 provisioning,则立即返回 noProgress。唤醒或超时后应重新列出状态,而不是轮询。 + +```json +{ + "type": "object", + "properties": { + "timeout_ms": { + "type": "integer", + "description": "Wait duration in milliseconds, from 10000 through 3600000. Defaults to 30000." + } + } +} +``` + +来源:[`packages/experimental/tool-team/src/index.ts`](../packages/experimental/tool-team/src/index.ts) + +这 10 个工具限定于隐式 Team Lead 与持久 teammate 作用域。随产品发布的 dsh-base bundle 默认禁用该包;文档中的 Agent Teams profile patch 会启用它,并禁用旧 continuable child 的同名控制工具。 + + ## `@deepseek-ai/dsh-tool-todo` ### `todo_write` diff --git a/examples/acp-agent/subagent-report-quiet.cordis.yml b/examples/acp-agent/subagent-report-quiet.cordis.yml deleted file mode 100644 index cb1c2b6d34..0000000000 --- a/examples/acp-agent/subagent-report-quiet.cordis.yml +++ /dev/null @@ -1,17 +0,0 @@ -# Snapshot-only overlay pinning quiet report delivery. The shipped default wakes -# the parent on every accepted report, and the runtime's settlement notice wakes -# it again when the child's Activation ends; two independent wakes have no single -# authored order. Quiet delivery leaves settlement as the only wake, while the -# fixture below holds the child until the parent's spawn turn has closed. -- id: base - name: '@deepseek-ai/cordis-plugin-include' - config: - path: ./cordis.yml - patches: - - id: tool-subagent-report - name: '@deepseek-ai/dsh-tool-subagent-report' - config: - reportDelivery: quiet - -- id: report-fence - name: './tests/fixtures/subagent-report-fence.ts' diff --git a/examples/acp-agent/subagent-report-quiet.cordis.snapshot.yml b/examples/acp-agent/subagent-report.cordis.snapshot.yml similarity index 81% rename from examples/acp-agent/subagent-report-quiet.cordis.snapshot.yml rename to examples/acp-agent/subagent-report.cordis.snapshot.yml index 4d28b399f0..c18d0f3bac 100644 --- a/examples/acp-agent/subagent-report-quiet.cordis.snapshot.yml +++ b/examples/acp-agent/subagent-report.cordis.snapshot.yml @@ -1,6 +1,5 @@ -# Keyless counterpart to subagent-report-quiet.cordis.yml: replace the live -# adapter with replay, keep report delivery quiet, and fence the child behind -# the end of its parent's spawn turn so settlement opens the next turn. +# Keyless counterpart to subagent-report.cordis.yml: replace the live adapter +# with replay and preserve its child and parent scheduling fence. - id: base name: '@deepseek-ai/cordis-plugin-include' config: @@ -32,10 +31,6 @@ - passthrough-runner runnerFailureSignatures: - 'passthrough-runner: profile rejected' - - id: tool-subagent-report - name: '@deepseek-ai/dsh-tool-subagent-report' - config: - reportDelivery: quiet - insert: - id: llm-replay name: '@deepseek-ai/dsh-llm-replay' diff --git a/examples/acp-agent/subagent-report.cordis.yml b/examples/acp-agent/subagent-report.cordis.yml new file mode 100644 index 0000000000..038e598e65 --- /dev/null +++ b/examples/acp-agent/subagent-report.cordis.yml @@ -0,0 +1,10 @@ +# Snapshot-only overlay fencing the child behind its parent's spawn turn and +# holding the parent in maintenance until settlement follows the default +# next-step report. The resumed parent claims both notices in causal order. +- id: base + name: '@deepseek-ai/cordis-plugin-include' + config: + path: ./cordis.yml + +- id: report-fence + name: './tests/fixtures/subagent-report-fence.ts' diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index 173a903e1d..e617206dcb 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -1,11 +1,19 @@ import { fileURLToPath } from 'node:url' import { readFileSync } from 'node:fs' import { spawnSync } from 'node:child_process' +import { createServer } from 'node:http' +import type { IncomingMessage, ServerResponse } from 'node:http' import { mkdir, utimes, writeFile } from 'node:fs/promises' import { dirname, join } from 'node:path' import { homedir } from 'node:os' import { expect, it } from 'vitest' -import { defineAcpSnapshotSuite, type Scenario, type SnapshotSuiteOptions } from '@deepseek-ai/dsh-acp-snapshot' +import { + defineAcpSnapshotSuite, + runScenario, + type InputScript, + type Scenario, + type SnapshotSuiteOptions, +} from '@deepseek-ai/dsh-acp-snapshot' import { resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local' import { decodeStorageRecord } from '@deepseek-ai/dsh-session' @@ -40,6 +48,7 @@ const ADVANCED_CONFIG = fileURLToPath(new URL('../advanced.cordis.yml', import.m const FS_CONFIG = fileURLToPath(new URL('../fs.cordis.yml', import.meta.url)) const SESSION_QUERY_CONFIG = fileURLToPath(new URL('../session-query.cordis.yml', import.meta.url)) const IMAGE_CONFIG = fileURLToPath(new URL('../image.cordis.yml', import.meta.url)) +const IMAGE_OFFLOAD_CONFIG = fileURLToPath(new URL('./fixtures/image-offload.cordis.yml', import.meta.url)) const IMAGE_TEXT_ROUTE_CONFIG = fileURLToPath(new URL('../image-text-route.cordis.yml', import.meta.url)) const PTY_CONFIG = fileURLToPath(new URL('../pty.cordis.yml', import.meta.url)) const DEPTH_TWO_CONFIG = fileURLToPath(new URL('../depth-two.cordis.yml', import.meta.url)) @@ -47,8 +56,8 @@ const CHILD_QUESTION_CONFIG = fileURLToPath(new URL('../child-question.cordis.ym const SESSION_SANDBOX_ROOT_CONFIG = fileURLToPath(new URL('../session-sandbox-root.cordis.yml', import.meta.url)) const RETRY_CONFIG = fileURLToPath(new URL('../retry.cordis.yml', import.meta.url)) const SESSION_TITLE_CONFIG = fileURLToPath(new URL('../session-title.cordis.yml', import.meta.url)) -const SUBAGENT_REPORT_QUIET_CONFIG = fileURLToPath( - new URL('../subagent-report-quiet.cordis.yml', import.meta.url), +const SUBAGENT_REPORT_CONFIG = fileURLToPath( + new URL('../subagent-report.cordis.yml', import.meta.url), ) const SUBAGENT_DURABILITY_FAILURE_CONFIG = fileURLToPath( new URL('../subagent-durability-failure.cordis.yml', import.meta.url), @@ -213,6 +222,17 @@ const SCENARIOS: Scenario[] = [ headerClass: 'image', configPath: IMAGE_TEXT_ROUTE_CONFIG, }, + // Authored keyless replay of the oversized-image refusal: admission rejects + // the 2001x1 fixture at the default 2000px per-side limit, the model sees a + // recoverable tool error, and the turn still completes — the image never + // enters durable history. + { + name: 'read-image-dimension', + hasModelTurn: true, + recorded: false, + headerClass: 'image', + configPath: IMAGE_CONFIG, + }, { name: 'inline-image-prompt', hasModelTurn: true, @@ -456,16 +476,15 @@ const SCENARIOS: Scenario[] = [ configPath: SUBAGENT_DURABILITY_FAILURE_CONFIG, }, // Authored child-to-parent transcript: the child calls its scope-local - // `report`, and the runtime's unconditional settlement notice then wakes the - // parked parent into one ordinary turn that claims both. The overlay pins - // quiet report delivery because two independent wakes have no orderable - // transcript; the shipped waking default is covered by package tests. + // `report` through the shipped next-step policy. A maintenance fence holds + // the parent until the runtime's unconditional settlement notice follows; + // the resumed parent then claims both messages in causal order. { name: 'subagent-report', hasModelTurn: true, recorded: false, overridden: false, - configPath: SUBAGENT_REPORT_QUIET_CONFIG, + configPath: SUBAGENT_REPORT_CONFIG, pinsChildToolSchemas: [1], pinsChildSystemPrompts: [1], }, @@ -645,6 +664,92 @@ defineAcpSnapshotSuite({ hasPwsh, }) +it('pins pi-ai image offload in the request sent by the assembled app', async () => { + const requests: Record[] = [] + const server = createServer((request: IncomingMessage, response: ServerResponse) => { + let body = '' + request.setEncoding('utf8') + request.on('data', (chunk: string) => { body += chunk }) + request.on('end', () => { + requests.push(JSON.parse(body) as Record) + response.writeHead(200, { 'content-type': 'text/event-stream' }) + response.end([ + 'data: {"choices":[{"delta":{"role":"assistant","content":""},"index":0,"finish_reason":null}]}', + 'data: {"choices":[{"delta":{"content":"DONE"},"index":0,"finish_reason":null}]}', + 'data: {"choices":[{"delta":{},"index":0,"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}', + 'data: [DONE]', + '', + ].join('\n\n')) + }) + }) + 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('image-offload snapshot server has no port') + + const image = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC' + const input: InputScript = { + steps: [ + { op: 'initialize' }, + { op: 'newSession' }, + { + op: 'promptContent', + content: [ + { type: 'text', text: 'Compare the older image ' }, + { type: 'image', data: image, mimeType: 'image/png' }, + { type: 'text', text: ' with the newer image ' }, + { type: 'image', data: image, mimeType: 'image/png' }, + { type: 'text', text: ', then reply with DONE.' }, + ], + }, + ], + } + + try { + const result = await runScenario(input, { + agent: AGENT, + mode: 'record', + configPath: IMAGE_OFFLOAD_CONFIG, + fixtureFile: join(SNAPSHOTS_DIR, 'image-offload-request', 'session.jsonl'), + env: { + DSH_SNAPSHOT_API_KEY: 'snapshot-key', + DSH_SNAPSHOT_BASE_URL: `http://127.0.0.1:${address.port}/v1`, + }, + }) + expect(result.stderr).toBe('') + expect(requests).toHaveLength(1) + const messages = requests[0]?.messages as { content?: unknown }[] | undefined + const offloaded = messages?.find(message => JSON.stringify(message.content).includes('[image omitted')) + expect(offloaded?.content).toMatchInlineSnapshot(` + [ + { + "text": "Compare the older image ", + "type": "text", + }, + { + "text": "[image omitted to keep the request within its image limit; older images are omitted first. If this image is still needed, read its file again when a path is available; otherwise ask the user to attach it again.]", + "type": "text", + }, + { + "text": " with the newer image ", + "type": "text", + }, + { + "image_url": { + "url": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC", + }, + "type": "image_url", + }, + { + "text": ", then reply with DONE.", + "type": "text", + }, + ] + `) + } finally { + await new Promise(resolve => server.close(() => { resolve() })) + } +}, 45_000) + it('packed ACP fixture retains every chunk row kind without changing the logical session', () => { const source = fixtureRecords(PACKED_CHUNKS_SOURCE) const packed = fixtureRecords('packed-chunks') diff --git a/examples/acp-agent/tests/fixtures/image-offload.cordis.yml b/examples/acp-agent/tests/fixtures/image-offload.cordis.yml new file mode 100644 index 0000000000..6007c5d44d --- /dev/null +++ b/examples/acp-agent/tests/fixtures/image-offload.cordis.yml @@ -0,0 +1,41 @@ +# Keyless assembled-request snapshot for pi-ai image offload. The local +# provider endpoint is supplied by the snapshot test; the real attachment +# store and ACP bridge carry two uploaded images into one model request. +- id: base + name: '@deepseek-ai/cordis-plugin-include' + config: + path: ../../cordis.yml + patches: + - id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + disabled: true + - id: acp-agent + name: '@deepseek-ai/dsh-acp-demo' + config: + provider: snapshot + model: vision + persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' + persistenceCompression: none + workspaceContext: + maxBytes: 65536 + persona: | + You are a coding assistant powered by the {{model}} model. Your working directory is {{cwd}}. + + Keep answers brief and factual. + - insert: + - id: attachment-local + name: '@deepseek-ai/dsh-attachment-local' + - id: llm-pi-ai + name: '@deepseek-ai/dsh-llm-pi-ai' + config: + providers: + snapshot: + apiKeyEnv: DSH_SNAPSHOT_API_KEY + api: openai-completions + baseURL: !!js process.env.DSH_SNAPSHOT_BASE_URL + maxRequestImageBytes: 92 + models: + - id: vision + contextWindow: 32768 + maxTokens: 1024 + input: [text, image] diff --git a/examples/acp-agent/tests/fixtures/subagent-report-fence.ts b/examples/acp-agent/tests/fixtures/subagent-report-fence.ts index 754343e4ec..b7aaeaddb8 100644 --- a/examples/acp-agent/tests/fixtures/subagent-report-fence.ts +++ b/examples/acp-agent/tests/fixtures/subagent-report-fence.ts @@ -1,5 +1,6 @@ /** - * Loader fixture that holds the report child until its parent's spawn turn ends. + * Loader fixture that holds the report child until its parent's spawn turn ends, + * then parks the parent until child settlement follows the report. * @module subagent-report-fence */ @@ -10,13 +11,16 @@ import type {} from '@deepseek-ai/dsh-agent-loop' export const name = 'subagent-report-fence' /** - * Keep replay scheduling from folding settlement into the parent's first turn. + * Keep replay scheduling from folding settlement into the parent's first turn + * or starting a second parent request between report and settlement. * @param ctx - assembled ACP-agent context. */ export function apply(ctx: Context): void { const childReady = Promise.withResolvers() const parentStopped = Promise.withResolvers() + const childSettled = Promise.withResolvers() let hasStopped = false + let parentMaintenance: Promise | undefined ctx.effect(() => { const disposeSession = ctx.root.on('session/event', (session, event) => { @@ -24,6 +28,26 @@ export function apply(ctx: Context): void { hasStopped = true parentStopped.resolve(undefined) }) + const disposeStatus = ctx.root.on('agent/status', ({ agent, status }) => { + if ( + agent.session.header.parentSession === undefined && + status === 'idle' && + hasStopped && + parentMaintenance === undefined + ) { + parentMaintenance = agent.runMaintenance(async () => { + await childSettled.promise + }) + } + }) + const disposeInbox = ctx.root.on('agent/inbox/inserted', ({ agent, message }) => { + if ( + agent.session.header.parentSession === undefined && + message.source.kind === 'subagent-settled' + ) { + childSettled.resolve(undefined) + } + }) const disposeStep = ctx.root.on('agent/pre-step', async ({ agent, turn, step }, next) => { if (agent.session.header.parentSession !== undefined) { childReady.resolve(undefined) @@ -34,7 +58,10 @@ export function apply(ctx: Context): void { return next() }) return () => { + childSettled.resolve(undefined) disposeStep() + disposeInbox() + disposeStatus() disposeSession() } }, 'subagent-report-fence.listeners') diff --git a/examples/acp-agent/tests/snapshots/read-image-dimension/input.json b/examples/acp-agent/tests/snapshots/read-image-dimension/input.json new file mode 100644 index 0000000000..43e6299ef8 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/read-image-dimension/input.json @@ -0,0 +1,14 @@ +{ + "steps": [ + { + "op": "initialize" + }, + { + "op": "newSession" + }, + { + "op": "prompt", + "text": "Use read_image on wide.png in the current directory. If the tool refuses because the image is too large, reply with exactly the single word TOOLARGE." + } + ] +} diff --git a/examples/acp-agent/tests/snapshots/read-image-dimension/session.jsonl b/examples/acp-agent/tests/snapshots/read-image-dimension/session.jsonl new file mode 100644 index 0000000000..0b979e801f --- /dev/null +++ b/examples/acp-agent/tests/snapshots/read-image-dimension/session.jsonl @@ -0,0 +1,26 @@ +{"type":"session","version":0,"id":"33333333-3333-4333-8333-333333333333","createdAt":1783951000000,"cwd":"{{cwd}}","delegationDepth":0} +{"type":"agent/inbox/spliced","seq":0,"time":1783951000001,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Use read_image on wide.png in the current directory. If the tool refuses because the image is too large, reply with exactly the single word TOOLARGE."}],"source":{"kind":"user"},"role":"user","id":"0a0a0a0a-0000-4000-8000-000000000001"}]}} +{"type":"turn/start","seq":1,"time":1783951000002,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":1783951000002,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":3,"time":1783951000003,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":4,"time":1783951000003,"data":{"content":[{"type":"text","text":"Use read_image on wide.png in the current directory. If the tool refuses because the image is too large, reply with exactly the single word TOOLARGE."}],"source":{"kind":"user"},"role":"user","id":"0a0a0a0a-0000-4000-8000-000000000001"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1783951000004,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"11a08f07-014a-408b-bfc5-634770ce7179"},"surfaceOp":"append"} +{"type":"session/title","seq":6,"time":1783951000004,"data":{"title":"Use read_image on wide.png in","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":7,"time":1783951000004,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":8,"time":1783951000005,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":9,"time":1783951000007,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":10,"time":1783951000008,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"read-image-dimension","name":"read_image","arguments":"{\"file_path\":\"wide.png\"}"}}}} +{"type":"assistant/chunk","seq":11,"time":1783951000008,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":12,"time":1783951000008,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":13,"time":1783951000009,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"read-image-dimension","name":"read_image","arguments":"{\"file_path\":\"wide.png\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"9676ac40-f7a8-4a7b-9326-a45fef18f11e"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[9,10,11,12],"surfaceOp":"append"} +{"type":"tool/call","seq":14,"time":1783951000009,"data":{"turn":1,"step":1,"callId":"read-image-dimension","name":"read_image","arguments":"{\"file_path\":\"wide.png\"}"}} +{"type":"tool/result","seq":15,"time":1783951000014,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"read-image-dimension"},"content":[{"type":"tool-result","toolCallId":"read-image-dimension","content":[{"type":"text","text":"Error: cannot read \"{{cwd}}/wide.png\": at least one image side exceeds the 2000px limit; downscale the image and read the smaller copy"}],"isError":true}],"role":"user","id":"ee31751e-df5a-458e-8497-8113cf6107ef"}},"sourceEventSeqs":[14],"surfaceOp":"append"} +{"type":"step/end","seq":16,"time":1783951000014,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":17,"time":1783951000015,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":18,"time":1783951000016,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":19,"time":1783951000017,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"TOOLARGE"}}}} +{"type":"assistant/chunk","seq":20,"time":1783951000017,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":21,"time":1783951000017,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":22,"time":1783951000018,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"TOOLARGE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"1c15b391-a95a-4113-9d47-2a1dfc991cf9"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[18,19,20,21],"surfaceOp":"append"} +{"type":"step/end","seq":23,"time":1783951000018,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":24,"time":1783951000018,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/read-image-dimension/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/read-image-dimension/stdout.expected.jsonl new file mode 100644 index 0000000000..7dbc881712 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/read-image-dimension/stdout.expected.jsonl @@ -0,0 +1,4 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":true,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"TOOLARGE"}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/read-image-dimension/workspace/wide.png b/examples/acp-agent/tests/snapshots/read-image-dimension/workspace/wide.png new file mode 100644 index 0000000000..cfdedb6b17 Binary files /dev/null and b/examples/acp-agent/tests/snapshots/read-image-dimension/workspace/wide.png differ diff --git a/examples/acp-agent/tests/snapshots/subagent-report/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/subagent-report/stdout.expected.jsonl index 785ee28128..b6de818dea 100644 --- a/examples/acp-agent/tests/snapshots/subagent-report/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-report/stdout.expected.jsonl @@ -1,7 +1,7 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"STARTED"}}}} -{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"SUBAGENT_SETTLED_NOTED"}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"CHILD_REPORT_OK"}}}} {"jsonrpc":"2.0","id":4,"result":{"stopReason":"end_turn"}} diff --git a/examples/headless-agent/team.cordis.snapshot.yml b/examples/headless-agent/team.cordis.snapshot.yml new file mode 100644 index 0000000000..1d0ab574f4 --- /dev/null +++ b/examples/headless-agent/team.cordis.snapshot.yml @@ -0,0 +1,36 @@ +# Keyless Agent Teams composition over the real headless app and deterministic fixture adapter. +- id: base + name: '@deepseek-ai/cordis-plugin-include' + config: + path: ./cordis.yml + patches: + - id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + disabled: true + - id: tool-subagent-control + name: '@deepseek-ai/dsh-tool-subagent-control' + disabled: true + - id: tool-subagent-report + name: '@deepseek-ai/dsh-tool-subagent-report' + disabled: true + - id: tool-subagent + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: spawn + toolName: subagent + backgroundMode: one-shot + maxDepth: 1 + - id: tool-subagent-fork + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: fork + toolName: subagent_fork + backgroundMode: one-shot + maxDepth: 1 + - insert: + - id: team + name: '@deepseek-ai/dsh-team' + - id: tool-team + name: '@deepseek-ai/dsh-tool-team' + - id: team-fixture-llm + name: './tests/fixtures/team-llm.mjs' diff --git a/examples/headless-agent/tests/fixtures/team-llm.mjs b/examples/headless-agent/tests/fixtures/team-llm.mjs new file mode 100644 index 0000000000..c1c973c771 --- /dev/null +++ b/examples/headless-agent/tests/fixtures/team-llm.mjs @@ -0,0 +1,194 @@ +/** Deterministic keyless Agent Teams adapter for the real headless Loader snapshot. */ + +import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm' + +let nextCall = 0 + +function calls(messages) { + return messages.flatMap(message => message.role === 'assistant' + ? message.content.filter(block => block.type === 'tool-call').map(block => block.name) + : []) +} + +function latestAssistantCalls(messages) { + const assistant = messages.findLast(message => message.role === 'assistant') + return assistant?.content.filter(block => block.type === 'tool-call').map(block => block.name) ?? [] +} + +function hasTaskAction(messages, action) { + return messages.some(message => message.role === 'assistant' + && message.content.some((block) => { + if (block.type !== 'tool-call' || block.name !== 'team_task_update') return false + try { + return JSON.parse(block.arguments).action === action + } catch { + return false + } + })) +} + +function latestToolText(messages) { + const message = messages.findLast(candidate => candidate.content.some(block => block.type === 'tool-result')) + if (message === undefined) return '' + return message.content.flatMap(block => block.type === 'tool-result' + ? block.content.filter(item => item.type === 'text').map(item => item.text) + : []).join('\n') +} + +function toolChunks(specs) { + const chunks = [] + for (const [index, spec] of specs.entries()) { + const id = CallId(`team-fixture-${++nextCall}`) + const args = JSON.stringify(spec.args) + chunks.push( + { type: 'block-start', index, blockType: 'tool-call' }, + { type: 'tool-call-delta', index, id, name: spec.name, argumentsDelta: args }, + { type: 'block-end', index, block: { type: 'tool-call', id, name: spec.name, arguments: args } }, + ) + } + chunks.push( + { type: 'usage', usage: { inputTokens: 10, outputTokens: 5 } }, + { type: 'finish', reason: { kind: 'tool-calls' } }, + ) + return chunks +} + +function textChunks(text) { + return [ + { type: 'block-start', index: 0, blockType: 'text' }, + { type: 'text-delta', index: 0, text }, + { type: 'block-end', index: 0, block: { type: 'text', text } }, + { type: 'usage', usage: { inputTokens: 10, outputTokens: 3 } }, + { type: 'finish', reason: { kind: 'stop' } }, + ] +} + +function researcher(messages) { + const names = calls(messages) + if (!names.includes('team_task_create')) { + return toolChunks([{ name: 'team_task_create', args: { + subject: 'Research', description: 'Collect the deterministic finding.', write_scopes: ['research'], + } }]) + } + if (!names.includes('team_task_update')) { + return toolChunks([{ name: 'team_task_update', args: { + task_id: 'task-1', expected_revision: 1, action: 'claim', + } }]) + } + if (!names.includes('send_message')) { + return toolChunks([ + { name: 'team_task_update', args: { task_id: 'task-1', expected_revision: 2, action: 'complete' } }, + { name: 'send_message', args: { target: 'implementer', message: 'Research complete: use the deterministic finding.' } }, + ]) + } + return textChunks('Research teammate complete.') +} + +function implementer(messages) { + const names = calls(messages) + const last = latestAssistantCalls(messages) + const text = latestToolText(messages) + if (!names.includes('team_task_create')) { + if (last.includes('team_task_get') && text.includes('"subject":"Research"')) { + return toolChunks([{ name: 'team_task_create', args: { + subject: 'Implementation', + description: 'Apply the deterministic finding.', + blocked_by: ['task-1'], + write_scopes: ['implementation'], + } }]) + } + if (last.includes('wait_agent')) { + return toolChunks([{ name: 'team_task_get', args: { task_id: 'task-1' } }]) + } + return toolChunks([{ name: 'wait_agent', args: { timeout_ms: 10000 } }]) + } + if (!hasTaskAction(messages, 'claim')) { + if (last.includes('team_task_get') && text.includes('"status":"completed"')) { + return toolChunks([{ name: 'team_task_update', args: { + task_id: 'task-2', expected_revision: 1, action: 'claim', + } }]) + } + if (last.includes('team_task_get')) { + return toolChunks([{ name: 'team_task_get', args: { task_id: 'task-1' } }]) + } + if (last.includes('wait_agent')) { + return toolChunks([{ name: 'team_task_get', args: { task_id: 'task-1' } }]) + } + return toolChunks([{ name: 'team_task_get', args: { task_id: 'task-1' } }]) + } + if (!names.includes('send_message')) { + return toolChunks([ + { name: 'team_task_update', args: { task_id: 'task-2', expected_revision: 2, action: 'complete' } }, + { name: 'send_message', args: { target: 'lead', message: 'Implementation complete and verified.' } }, + ]) + } + return textChunks('Implementation teammate complete.') +} + +function lead(messages) { + const names = calls(messages) + const last = latestAssistantCalls(messages) + const spawned = names.filter(name => name === 'spawn_teammate').length + if (spawned === 0) { + return toolChunks([{ + name: 'spawn_teammate', + args: { + name: 'implementer', + description: 'Own deterministic implementation.', + prompt: 'IMPLEMENTER_MARK: wait for research, complete dependent task 2, report to lead.', + context: 'fresh', + }, + }]) + } + if (spawned === 1) { + return toolChunks([{ + name: 'spawn_teammate', + args: { + name: 'researcher', + description: 'Own deterministic research.', + prompt: 'RESEARCHER_MARK: complete research task 1, message implementer, then finish.', + context: 'fresh', + }, + }]) + } + const result = latestToolText(messages) + if (last.includes('team_task_list')) { + const completed = result.match(/"status":"completed"/gu)?.length ?? 0 + if (completed >= 2) return toolChunks([{ name: 'list_agents', args: {} }]) + return toolChunks([{ name: 'team_task_list', args: {} }]) + } + if (last.includes('list_agents')) { + const inactive = result.match(/"status":"inactive"/gu)?.length ?? 0 + if (inactive >= 2) return textChunks('TEAM_WORKFLOW_OK: both teammates and dependent tasks completed.') + return toolChunks([{ name: 'list_agents', args: {} }]) + } + if (last.includes('wait_agent')) return toolChunks([{ name: 'team_task_list', args: {} }]) + return toolChunks([{ name: 'wait_agent', args: { timeout_ms: 10000 } }]) +} + +class TeamFixtureAdapter extends LlmAdapter { + async * stream(options) { + const userText = options.messages.flatMap(message => message.role === 'user' + ? message.content.filter(block => block.type === 'text').map(block => block.text) + : []).join('\n') + const chunks = userText.includes('RESEARCHER_MARK') + ? researcher(options.messages) + : userText.includes('IMPLEMENTER_MARK') + ? implementer(options.messages) + : lead(options.messages) + for (const chunk of chunks) { + options.signal?.throwIfAborted() + yield chunk + } + } +} + +/** Cordis plugin name. */ +export const name = 'team-fixture-llm' +/** LLM registry dependency. */ +export const inject = ['llm'] + +/** Register the keyless adapter on the shipped default provider route. */ +export function apply(ctx) { + ctx.llm.registerAdapter(['deepseek-official'], new TeamFixtureAdapter()) +} diff --git a/examples/headless-agent/tests/headless.snapshot.ts b/examples/headless-agent/tests/headless.snapshot.ts index d22a48a7ab..19247c3c6b 100644 --- a/examples/headless-agent/tests/headless.snapshot.ts +++ b/examples/headless-agent/tests/headless.snapshot.ts @@ -47,6 +47,7 @@ const ralphScenarioDir = join(snapshotsDir, 'ralph-loop') const ralphConfigPath = fileURLToPath(new URL('../ralph.cordis.snapshot.yml', import.meta.url)) const settlementScenarioDir = join(snapshotsDir, 'subagent-settlement') const settlementConfigPath = fileURLToPath(new URL('../subagent-settlement.cordis.snapshot.yml', import.meta.url)) +const teamConfigPath = fileURLToPath(new URL('../team.cordis.snapshot.yml', import.meta.url)) const startupFailureConfigPath = fileURLToPath(new URL('./fixtures/startup-activation-error/cordis.yml', import.meta.url)) const startupFailureExpected = join(snapshotsDir, 'startup-activation-error', 'stderr.expected.txt') const binScript = fileURLToPath(new URL('./fixtures/headless-driver.ts', import.meta.url)) @@ -645,6 +646,85 @@ describe('headless stream-json snapshots', () => { expect(normalized).toBe(await readFile(advancedStreamExpected, 'utf8')) }, LOADER_SMOKE_TEST_TIMEOUT_MS) + it('runs a keyless Agent Team with peer mail, dependent tasks, waiting, and Lead aggregation', async () => { + let projection: unknown + const result = await runLoaderSmoke({ + label: 'Agent Teams headless snapshot', + tempDirPrefix: 'headless-snapshot-agent-team-', + binScript, + libBinScript: binScript, + configPath: teamConfigPath, + binArgs: [ + teamConfigPath, + '请明确使用 Agent Teams,把调研和实现拆给两个 teammate,等待完成后汇总。', + ], + tsconfigPath, + processTimeoutMs: 60_000, + env: { + DSH_SNAPSHOT: 'team', + NODE_OPTIONS: [process.env.NODE_OPTIONS, '--disable-warning=ExperimentalWarning'].filter(Boolean).join(' '), + }, + inspect: async (cwd) => { + const logs = await persistedLogs(cwd) + const parent = logs.find(log => typeof log.header.parentSession !== 'string') + if (parent === undefined) throw new Error('Agent Teams snapshot did not persist its Lead') + const rows = parseJsonl(parent.content) + const members = rows.filter(row => row.type === 'team/member') + .map(row => ((row.data as JsonObject).member as JsonObject)) + const tasks = rows.filter(row => row.type === 'team/task') + .map(row => ((row.data as JsonObject).task as JsonObject)) + const latestTasks = Object.values(Object.fromEntries(tasks.map(task => [String(task.subject), task]))) + projection = { + sessions: logs.length, + memberEdges: members.length, + activeMembers: members.filter(member => member.phase === 'active').map(member => member.name).sort(), + tasks: latestTasks.map(task => ({ + subject: task.subject, + revision: task.revision, + status: task.status, + })).sort((left, right) => String(left.subject).localeCompare(String(right.subject))), + queuedMessages: rows.filter(row => row.type === 'team/message/queued').length, + deliveredMessages: rows.filter(row => row.type === 'team/message/delivered').length, + waited: rows.some(row => row.type === 'tool/call' + && (row.data as JsonObject).name === 'wait_agent'), + checkedRoster: rows.some(row => row.type === 'tool/call' + && (row.data as JsonObject).name === 'list_agents'), + } + }, + }) + expect(result.stderr).toBe('') + expect(parseJsonl(result.stdout).at(-1)).toMatchObject({ + type: 'result', + output: 'TEAM_WORKFLOW_OK: both teammates and dependent tasks completed.', + }) + expect(projection).toMatchInlineSnapshot(` + { + "activeMembers": [ + "implementer", + "researcher", + ], + "checkedRoster": true, + "deliveredMessages": 2, + "memberEdges": 4, + "queuedMessages": 2, + "sessions": 3, + "tasks": [ + { + "revision": 3, + "status": "completed", + "subject": "Implementation", + }, + { + "revision": 3, + "status": "completed", + "subject": "Research", + }, + ], + "waited": true, + } + `) + }, 75_000) + it('replays persisted goal tools through the one-shot app', async () => { const prompt = await scenarioPrompt(goalScenarioDir, 'goal-tools') const streamExpected = join(goalScenarioDir, 'stream-json.expected.jsonl') diff --git a/examples/package.json b/examples/package.json index cd348d5fe2..021ed0dc32 100644 --- a/examples/package.json +++ b/examples/package.json @@ -82,6 +82,7 @@ "@deepseek-ai/dsh-subprocess-local": "workspace:*", "@deepseek-ai/dsh-system-prompt": "workspace:*", "@deepseek-ai/dsh-jobs-local": "workspace:*", + "@deepseek-ai/dsh-team": "workspace:*", "@deepseek-ai/dsh-time-context": "workspace:*", "@deepseek-ai/dsh-tool-call-timeout-policy": "workspace:*", "@deepseek-ai/dsh-token-meter": "workspace:*", @@ -103,6 +104,7 @@ "@deepseek-ai/dsh-tool-subagent-control": "workspace:*", "@deepseek-ai/dsh-tool-subagent-report": "workspace:*", "@deepseek-ai/dsh-tool-jobs": "workspace:*", + "@deepseek-ai/dsh-tool-team": "workspace:*", "@deepseek-ai/dsh-tool-todo": "workspace:*", "@deepseek-ai/dsh-tool-web": "workspace:*", "@deepseek-ai/dsh-tool-workflow": "workspace:*", diff --git a/packages/README.i18n.yaml b/packages/README.i18n.yaml index cdc5428234..ee425f39d7 100644 --- a/packages/README.i18n.yaml +++ b/packages/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/README.md -README.md: 7904ec1fea8dbab361fcd90b2b32f1c743ce5537 -README.zh.md: c39b65dad7e9c277779a85d7327982597628e007 +README.md: a410d7148d14503a61edb9c4848b521552050ca4 +README.zh.md: 780d1356f2095c7dcc526e5cdac8994267e4a460 diff --git a/packages/README.md b/packages/README.md index 7904ec1fea..a410d7148d 100644 --- a/packages/README.md +++ b/packages/README.md @@ -31,6 +31,7 @@ Groups hold `packages///`; names stay `@deepseek-ai/dsh-`. **Gr | [`context/`](context/README.md) | Model-visible request context, including workspace instructions and time context | Product — stable API | | [`subagent/`](subagent/README.md) | Subagent capability family: the provider-registry contract and the model-facing delegation tool | Product — stable API | | [`jobs/`](jobs/README.md) | Generic background-job runtime and model-facing `job_*` control tools | Product — stable API | +| [`experimental/`](experimental/README.md) | Private prototypes and internal-only plugins | Unreleased | | [`workflow/`](workflow/README.md) | Workflow seam, worker-thread engine, and model-facing `workflow`/`ralph` tools | Product — stable API | | [`web/`](web/README.md) | Web capability family: seam, search/fetch provider impls, and the model-facing web tools | Product — stable API | | [`attachment/`](attachment/README.md) | Durable attachment identity, validation, local content-addressed storage | Product — stable API | diff --git a/packages/README.zh.md b/packages/README.zh.md index c39b65dad7..780d1356f2 100644 --- a/packages/README.zh.md +++ b/packages/README.zh.md @@ -31,6 +31,7 @@ npm scope 为 `@deepseek-ai/dsh-*`;Cordis `Service` 子类和函数插件通 | [`context/`](context/README.md) | 模型可见请求上下文,包括 workspace 指令和时间上下文 | 产品:稳定 API | | [`subagent/`](subagent/README.md) | subagent 能力系列:提供方注册表约定和面向模型的委托工具 | 产品:稳定 API | | [`jobs/`](jobs/README.md) | 通用后台任务运行时和面向模型的 `job_*` 控制工具 | 产品:稳定 API | +| [`experimental/`](experimental/README.md) | 私有原型与内部专用插件 | 不发布 | | [`workflow/`](workflow/README.md) | 工作流 seam、worker 线程引擎和面向模型的 `workflow`/`ralph` 工具 | 产品:稳定 API | | [`web/`](web/README.md) | Web 能力系列:seam、搜索/获取提供方实现和面向模型的 Web 工具 | 产品:稳定 API | | [`attachment/`](attachment/README.md) | 持久附件标识、校验、本地内容寻址存储 | 产品:稳定 API | diff --git a/packages/acp/acp/tests/harness.ts b/packages/acp/acp/tests/harness.ts index ae66f9f841..ce6e93794f 100644 --- a/packages/acp/acp/tests/harness.ts +++ b/packages/acp/acp/tests/harness.ts @@ -82,6 +82,7 @@ const IMAGE_LIMITS: ImageAttachmentLimits = { maxImagesPerMessage: 4, maxMessageImageBytes: 2048, maxImagePixels: 1024, + maxImageDimension: 2000, mediaTypes: ['image/png', 'image/jpeg', 'image/webp', 'image/gif'], } diff --git a/packages/attachment/attachment-local/README.i18n.yaml b/packages/attachment/attachment-local/README.i18n.yaml index d875ce6519..1d7c63c469 100644 --- a/packages/attachment/attachment-local/README.i18n.yaml +++ b/packages/attachment/attachment-local/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/attachment/attachment-local/README.md -README.md: ba0b9efb2cf51bfef671020bed4a2c16f6ee0119 -README.zh.md: 8e2474357a0dbb5e8834a3b25de7a977827a29e3 +README.md: e4f2d5748768a1dc2a6b79c3ed9e364c56a67248 +README.zh.md: 6b548fb993faef996f1508ba9f9efc31b20fea64 diff --git a/packages/attachment/attachment-local/README.md b/packages/attachment/attachment-local/README.md index ba0b9efb2c..e4f2d57487 100644 --- a/packages/attachment/attachment-local/README.md +++ b/packages/attachment/attachment-local/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The private local implementation of [`@deepseek-ai/dsh-attachment`](../attachment). Objects land at `/attachments/v1/objects//` and are addressed by an opaque `sha256:` id. Each process proves a home durable once by syncing every ancestor entry to the filesystem root, so a directory another process created but has not yet synced is never mistaken for a safe boundary. Writes then use a private staging directory, owner-only files, a synced temporary file, an atomic exclusive hard-link publish, and directory syncs on the publication path (POSIX; Windows relies on filesystem metadata journaling) so the reported reference survives a crash. Write admission and reads fully decode the raster before accepting its format and dimensions; reads also re-check the digest and logged metadata. Byte and pixel limits are write-time admission policy, so a later policy reduction does not make already-admitted history unreadable. +The private local implementation of [`@deepseek-ai/dsh-attachment`](../attachment). Objects land at `/attachments/v1/objects//` and are addressed by an opaque `sha256:` id. Each process proves a home durable once by syncing every ancestor entry to the filesystem root, so a directory another process created but has not yet synced is never mistaken for a safe boundary. Writes then use a private staging directory, owner-only files, a synced temporary file, an atomic exclusive hard-link publish, and directory syncs on the publication path (POSIX; Windows relies on filesystem metadata journaling) so the reported reference survives a crash. Write admission and reads fully decode the raster before accepting its format and dimensions; reads also re-check the digest and logged metadata. Byte, total-pixel, and per-side dimension limits are write-time admission policy, so a later policy reduction does not make already-admitted history unreadable. The per-side default (2000px) stays below the strictest dimension bound deployed model routes enforce on requests carrying many images: an admitted image rides every later request of its session, so admission is the last point where a provider-rejected image can be kept out of durable history. `DSH_HOME` resolves through the shared path policy: explicit config, `$DSH_HOME`, then `~/.dsh`. Session logs contain only the reference and verified metadata, never this host path. `readImage` forwards optional cancellation into the filesystem read, observes it around verification, and preserves it instead of wrapping it as `ATTACHMENT_READ_FAILED`. diff --git a/packages/attachment/attachment-local/README.zh.md b/packages/attachment/attachment-local/README.zh.md index 8e2474357a..6b548fb993 100644 --- a/packages/attachment/attachment-local/README.zh.md +++ b/packages/attachment/attachment-local/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -这是 [`@deepseek-ai/dsh-attachment`](../attachment) 的私有本地实现。对象存放在 `/attachments/v1/objects//`,并通过不透明的 `sha256:` 标识符寻址。每个进程都会通过将每个祖先目录项逐级同步到文件系统根目录,为某个 home 一次性证明其持久性,因此绝不会把另一个进程已经创建但尚未同步的目录误认为安全边界。随后,写入过程使用私有暂存目录、仅所有者可访问的文件、经过同步的临时文件、原子且排他的硬链接发布,并对发布路径执行目录同步(适用于 POSIX;Windows 依赖文件系统元数据日志),确保已报告的引用能够在崩溃后继续存在。写入准入与读取都会完整解码光栅图片,之后才接受其格式和尺寸;读取还会重新校验摘要和已记录的元数据。字节和像素限制属于写入时的准入策略,因此后续收紧限制不会导致已经接纳的历史记录变得不可读。 +这是 [`@deepseek-ai/dsh-attachment`](../attachment) 的私有本地实现。对象存放在 `/attachments/v1/objects//`,并通过不透明的 `sha256:` 标识符寻址。每个进程都会通过将每个祖先目录项逐级同步到文件系统根目录,为某个 home 一次性证明其持久性,因此绝不会把另一个进程已经创建但尚未同步的目录误认为安全边界。随后,写入过程使用私有暂存目录、仅所有者可访问的文件、经过同步的临时文件、原子且排他的硬链接发布,并对发布路径执行目录同步(适用于 POSIX;Windows 依赖文件系统元数据日志),确保已报告的引用能够在崩溃后继续存在。写入准入与读取都会完整解码光栅图片,之后才接受其格式和尺寸;读取还会重新校验摘要和已记录的元数据。字节、总像素和单边尺寸限制属于写入时的准入策略,因此后续收紧限制不会导致已经接纳的历史记录变得不可读。单边默认值(2000px)低于已部署模型路由对携带多张图片的请求所强制执行的最严格尺寸上限:一张已接纳的图片会随会话之后的每次请求发送,准入是把必然被上游拒绝的图片挡在持久历史之外的最后一道关口。 `DSH_HOME` 按共享路径策略解析:显式配置、`$DSH_HOME`,最后是 `~/.dsh`。会话日志只包含引用和经过校验的元数据,绝不包含这个宿主路径。`readImage` 会把可选取消信号传入文件系统读取、在校验前后观察该信号,并保留取消语义,而不会将其包装成 `ATTACHMENT_READ_FAILED`。 diff --git a/packages/attachment/attachment-local/src/image.ts b/packages/attachment/attachment-local/src/image.ts index e06bf459df..b067ea80ff 100644 --- a/packages/attachment/attachment-local/src/image.ts +++ b/packages/attachment/attachment-local/src/image.ts @@ -44,19 +44,30 @@ export async function probeImage(data: Uint8Array): Promise { } } +/** Admission limits applied to a decoded raster's intrinsic dimensions. */ +export interface DecodedImageLimits { + /** Decoded-pixel (width times height) admission limit. */ + maxPixels?: number + /** Per-side admission limit applied to width and height independently. */ + maxDimension?: number +} + /** * Fully decode a supported raster and return its intrinsic metadata. * @param data - complete encoded image bytes. - * @param maxPixels - decoded-pixel admission limit. + * @param limits - intrinsic-dimension admission limits. * @returns verified format and dimensions. */ -export async function detectImage(data: Uint8Array, maxPixels?: number): Promise { +export async function detectImage(data: Uint8Array, limits?: DecodedImageLimits): Promise { try { const image = sharp(data, { failOn: 'error', limitInputPixels: false }) const detected = await imageMetadata(image) - if (maxPixels !== undefined && detected.width * detected.height > maxPixels) { + if (limits?.maxPixels !== undefined && detected.width * detected.height > limits.maxPixels) { throw new AttachmentError('Image exceeds the configured decoded-pixel limit.', 'IMAGE_TOO_MANY_PIXELS') } + if (limits?.maxDimension !== undefined && Math.max(detected.width, detected.height) > limits.maxDimension) { + throw new AttachmentError('Image exceeds the configured per-side pixel limit.', 'IMAGE_DIMENSION_TOO_LARGE') + } await image.raw().toBuffer() return detected } catch (error) { diff --git a/packages/attachment/attachment-local/src/index.ts b/packages/attachment/attachment-local/src/index.ts index 98268895f6..a4047da1f1 100644 --- a/packages/attachment/attachment-local/src/index.ts +++ b/packages/attachment/attachment-local/src/index.ts @@ -8,17 +8,24 @@ import type { ImageAttachmentLimits, ImageAttachmentRef, SaveImageAttachment, St import { resolveDshHome } from '@deepseek-ai/dsh-home-paths' import { readImageFile, saveImageFile, validateImageFile } from './store.ts' -export { detectImage } from './image.ts' export { readImageFile, saveImageFile, validateImageFile } from './store.ts' /** Default maximum encoded bytes for one image. */ -export const DEFAULT_MAX_IMAGE_BYTES = 5 * 1024 * 1024 +export const DEFAULT_MAX_IMAGE_BYTES = 3.5 * 1024 * 1024 /** Default maximum images in one prompt. */ export const DEFAULT_MAX_IMAGES_PER_MESSAGE = 20 /** Default maximum aggregate image bytes in one prompt. */ export const DEFAULT_MAX_MESSAGE_IMAGE_BYTES = 100 * 1024 * 1024 /** Default maximum intrinsic pixels for one image. */ export const DEFAULT_MAX_IMAGE_PIXELS = 40_000_000 +/** + * Default maximum intrinsic width and height for one image. Deployed model + * routes reject any request whose history carries an image with a side above + * 2000px once the request holds many images, and an admitted image rides + * every later request of its session, so admission refuses at the same line + * to keep the durable history streamable. + */ +export const DEFAULT_MAX_IMAGE_DIMENSION = 2000 /** Local attachment backend configuration. */ export interface Config { @@ -32,6 +39,8 @@ export interface Config { maxMessageImageBytes?: number /** Maximum intrinsic width multiplied by height accepted for one image. */ maxImagePixels?: number + /** Maximum intrinsic width and maximum intrinsic height accepted for one image. */ + maxImageDimension?: number } /** Persistent content-addressed local attachment store. */ @@ -42,6 +51,7 @@ export class LocalAttachmentStore extends AttachmentStore { maxImagesPerMessage: z.number().step(1).min(1).default(DEFAULT_MAX_IMAGES_PER_MESSAGE), maxMessageImageBytes: z.number().step(1).min(1).default(DEFAULT_MAX_MESSAGE_IMAGE_BYTES), maxImagePixels: z.number().step(1).min(1).default(DEFAULT_MAX_IMAGE_PIXELS), + maxImageDimension: z.number().step(1).min(1).default(DEFAULT_MAX_IMAGE_DIMENSION), }) /** Absolute versioned storage root. */ @@ -56,6 +66,7 @@ export class LocalAttachmentStore extends AttachmentStore { maxImagesPerMessage: config.maxImagesPerMessage ?? DEFAULT_MAX_IMAGES_PER_MESSAGE, maxMessageImageBytes: config.maxMessageImageBytes ?? DEFAULT_MAX_MESSAGE_IMAGE_BYTES, maxImagePixels: config.maxImagePixels ?? DEFAULT_MAX_IMAGE_PIXELS, + maxImageDimension: config.maxImageDimension ?? DEFAULT_MAX_IMAGE_DIMENSION, mediaTypes: Object.freeze(['image/png', 'image/jpeg', 'image/webp', 'image/gif'] as const), }) } diff --git a/packages/attachment/attachment-local/src/store.ts b/packages/attachment/attachment-local/src/store.ts index 8e4e83c1c9..723df98720 100644 --- a/packages/attachment/attachment-local/src/store.ts +++ b/packages/attachment/attachment-local/src/store.ts @@ -46,10 +46,10 @@ function ensureReference(ref: ImageAttachmentRef): string { async function inspectMetadata( data: Uint8Array, declaredMediaType: ImageAttachmentRef['mediaType'], - maxPixels?: number, + limits: ImageAttachmentLimits, ): Promise> { if (data.byteLength === 0) throw new AttachmentError('Image is empty.', 'INVALID_IMAGE') - const detected = await detectImage(data, maxPixels) + const detected = await detectImage(data, { maxPixels: limits.maxImagePixels, maxDimension: limits.maxImageDimension }) if (detected.mediaType !== declaredMediaType) throw new AttachmentError('Declared image type does not match its bytes.', 'IMAGE_TYPE_MISMATCH') return { ...detected, bytes: data.byteLength } } @@ -64,7 +64,7 @@ export async function validateImageFile(input: SaveImageAttachment, limits: Imag if (input.data.byteLength > limits.maxImageBytes) { throw new AttachmentError('Image exceeds the configured byte limit.', 'IMAGE_TOO_LARGE') } - await inspectMetadata(input.data, input.mediaType, limits.maxImagePixels) + await inspectMetadata(input.data, input.mediaType, limits) } /** @@ -135,7 +135,7 @@ async function ensureDurableHome(path: string): Promise { */ export async function saveImageFile(root: string, input: SaveImageAttachment, limits: ImageAttachmentLimits): Promise { if (input.data.byteLength > limits.maxImageBytes) throw new AttachmentError('Image exceeds the configured byte limit.', 'IMAGE_TOO_LARGE') - const metadata = await inspectMetadata(input.data, input.mediaType, limits.maxImagePixels) + const metadata = await inspectMetadata(input.data, input.mediaType, limits) const sha256 = digest(input.data) const bucket = join(root, 'objects', sha256.slice(0, 2)) const staging = join(root, 'tmp') diff --git a/packages/attachment/attachment-local/tests/image.spec.ts b/packages/attachment/attachment-local/tests/image.spec.ts index 72a258e2bd..6b1cea6bfb 100644 --- a/packages/attachment/attachment-local/tests/image.spec.ts +++ b/packages/attachment/attachment-local/tests/image.spec.ts @@ -23,10 +23,17 @@ describe('raster decoding', () => { }) it('rejects excess decoded pixels before decoding', async () => { - await expect(detectImage(await raster('png'), 5)) + await expect(detectImage(await raster('png'), { maxPixels: 5 })) .rejects.toMatchObject({ code: 'IMAGE_TOO_MANY_PIXELS' }) }) + it('rejects a side above the per-side limit and accepts a side exactly at it', async () => { + await expect(detectImage(await raster('png'), { maxDimension: 2 })) + .rejects.toMatchObject({ code: 'IMAGE_DIMENSION_TOO_LARGE' }) + await expect(detectImage(await raster('png'), { maxDimension: 3 })) + .resolves.toEqual({ mediaType: 'image/png', width: 3, height: 2 }) + }) + it('rejects malformed bytes and truncated payloads with readable headers', async () => { await expect(detectImage(Uint8Array.of(1, 2, 3))) .rejects.toMatchObject({ code: 'INVALID_IMAGE' }) diff --git a/packages/attachment/attachment-local/tests/index.spec.ts b/packages/attachment/attachment-local/tests/index.spec.ts index 75629d3635..92bbe3c0aa 100644 --- a/packages/attachment/attachment-local/tests/index.spec.ts +++ b/packages/attachment/attachment-local/tests/index.spec.ts @@ -6,6 +6,7 @@ import { join } from 'node:path' import { describe, expect, it } from 'vitest' import LocalAttachmentStore, { DEFAULT_MAX_IMAGE_BYTES, + DEFAULT_MAX_IMAGE_DIMENSION, DEFAULT_MAX_IMAGE_PIXELS, DEFAULT_MAX_IMAGES_PER_MESSAGE, DEFAULT_MAX_MESSAGE_IMAGE_BYTES, @@ -14,12 +15,13 @@ import LocalAttachmentStore, { describe('local attachment service', () => { it('resolves every omitted admission limit explicitly', () => { const service = new LocalAttachmentStore(new Context(), {}) - expect(DEFAULT_MAX_IMAGE_BYTES).toBe(5 * 1024 * 1024) + expect(DEFAULT_MAX_IMAGE_BYTES).toBe(3.5 * 1024 * 1024) expect(service.imageLimits).toEqual({ maxImageBytes: DEFAULT_MAX_IMAGE_BYTES, maxImagesPerMessage: DEFAULT_MAX_IMAGES_PER_MESSAGE, maxMessageImageBytes: DEFAULT_MAX_MESSAGE_IMAGE_BYTES, maxImagePixels: DEFAULT_MAX_IMAGE_PIXELS, + maxImageDimension: DEFAULT_MAX_IMAGE_DIMENSION, mediaTypes: ['image/png', 'image/jpeg', 'image/webp', 'image/gif'], }) }) diff --git a/packages/attachment/attachment-local/tests/store.spec.ts b/packages/attachment/attachment-local/tests/store.spec.ts index ec3551abb2..a5b831e933 100644 --- a/packages/attachment/attachment-local/tests/store.spec.ts +++ b/packages/attachment/attachment-local/tests/store.spec.ts @@ -43,6 +43,7 @@ const LIMITS: ImageAttachmentLimits = { maxImagesPerMessage: 2, maxMessageImageBytes: 2048, maxImagePixels: 16, + maxImageDimension: 2000, mediaTypes: ['image/png', 'image/jpeg', 'image/webp', 'image/gif'], } @@ -176,6 +177,9 @@ describe('local attachment store', () => { await expect(saveImageFile(storageRoot, { data: wide, mediaType: 'image/png', }, LIMITS)).rejects.toMatchObject({ code: 'IMAGE_TOO_MANY_PIXELS' }) + await expect(saveImageFile(storageRoot, { + data: wide, mediaType: 'image/png', + }, { ...LIMITS, maxImagePixels: 25, maxImageDimension: 4 })).rejects.toMatchObject({ code: 'IMAGE_DIMENSION_TOO_LARGE' }) const unnamed = await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png', name: '\u0000', }, LIMITS) diff --git a/packages/attachment/attachment/src/error.ts b/packages/attachment/attachment/src/error.ts index 125d31ad13..2e2d695dae 100644 --- a/packages/attachment/attachment/src/error.ts +++ b/packages/attachment/attachment/src/error.ts @@ -9,6 +9,7 @@ const IMAGE_ADMISSION_ERROR_CODES = [ 'IMAGE_TYPE_MISMATCH', 'IMAGE_TOO_LARGE', 'IMAGE_TOO_MANY_PIXELS', + 'IMAGE_DIMENSION_TOO_LARGE', ] as const /** Caller-correctable attachment failure codes raised while admitting image input. */ diff --git a/packages/attachment/attachment/src/types.ts b/packages/attachment/attachment/src/types.ts index 102209553b..62ff598fee 100644 --- a/packages/attachment/attachment/src/types.ts +++ b/packages/attachment/attachment/src/types.ts @@ -29,6 +29,8 @@ export interface ImageAttachmentLimits { maxImagesPerMessage: number maxMessageImageBytes: number maxImagePixels: number + /** Maximum intrinsic width and maximum intrinsic height in pixels for one image. */ + maxImageDimension: number mediaTypes: readonly ImageMediaType[] } diff --git a/packages/attachment/attachment/tests/index.spec.ts b/packages/attachment/attachment/tests/index.spec.ts index 61caacda0f..622b797ce2 100644 --- a/packages/attachment/attachment/tests/index.spec.ts +++ b/packages/attachment/attachment/tests/index.spec.ts @@ -15,6 +15,7 @@ const LIMITS = { maxImagesPerMessage: 2, maxMessageImageBytes: 5, maxImagePixels: 4, + maxImageDimension: 2000, mediaTypes: ['image/png'] as const, } diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index dd0566486e..0d7199be14 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -1077,6 +1077,7 @@ function projectionValuesOf(log: readonly SessionEvent[]): Record block.type === 'image')) { + return err(request, { + code: 'attachment-error', + message: 'fixture: image side exceeds the deployment limit', + details: { reason: 'IMAGE_DIMENSION_TOO_LARGE' }, + }) + } return err(request, { code: 'agent-busy', message: 'fixture: prompt rejected before acceptance', diff --git a/packages/client/connection/tests/fixture.client.spec.ts b/packages/client/connection/tests/fixture.client.spec.ts index 11c4ddab8a..4fe4a4ff2d 100644 --- a/packages/client/connection/tests/fixture.client.spec.ts +++ b/packages/client/connection/tests/fixture.client.spec.ts @@ -173,6 +173,7 @@ describe('createFixtureApi', () => { maxImagesPerMessage: 20, maxMessageImageBytes: 100 * 1024 * 1024, maxImagePixels: 40_000_000, + maxImageDimension: 2000, mediaTypes: ['image/png', 'image/jpeg', 'image/webp', 'image/gif'], }, } }, @@ -866,6 +867,15 @@ describe('createFixtureApi', () => { content: [{ type: 'text' as const, text: 'keep me' }], })) expect(prompt.result).toMatchObject({ ok: false, error: { code: 'agent-busy' } }) + const imagePrompt = await rejecting.sessions.prompt(req({ + sessionId: real.result.value.sessionId, + mode: 'queue' as const, + content: [{ type: 'image' as const, mediaType: 'image/png' as const, data: 'iVBORw0KGgo=' }], + })) + expect(imagePrompt.result).toMatchObject({ + ok: false, + error: { code: 'attachment-error', details: { reason: 'IMAGE_DIMENSION_TOO_LARGE' } }, + }) }) it('timing hooks: history delay + one-shot failure, silent append, and breakStreams end open generators', async () => { diff --git a/packages/client/ui-conversation/src/client/image-labels.ts b/packages/client/ui-conversation/src/client/image-labels.ts index e322755473..6d4ee7a130 100644 --- a/packages/client/ui-conversation/src/client/image-labels.ts +++ b/packages/client/ui-conversation/src/client/image-labels.ts @@ -33,6 +33,9 @@ export function attachmentErrorText( case 'MODEL_DOES_NOT_SUPPORT_IMAGES': return t('image.modelUnsupported') case 'SUBAGENT_IMAGE_UNSUPPORTED': return t('image.subagentUnsupported') case 'IMAGE_TOO_MANY_PIXELS': return t('image.tooManyPixels') + case 'IMAGE_DIMENSION_TOO_LARGE': + if (limits !== undefined) return t('image.dimensionTooLarge', { size: limits.maxImageDimension }) + break // Undecodable bytes or a declared type its bytes contradict: solvable by // replacing or re-exporting the file, so it reads as a format problem. case 'INVALID_IMAGE': diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index c9b6f658ca..368475c583 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -46,6 +46,7 @@ export const zh = { 'image.fileTooLarge': '单张图片不能超过 {size}', 'image.totalTooLarge': '图片总大小超过 {size},请移除部分图片', 'image.tooManyPixels': '图片分辨率过大,请压缩后重试', + 'image.dimensionTooLarge': '图片宽高不能超过 {size}px,请缩小后重试', 'image.modelUnsupported': '当前模型不支持图片,请切换支持图片的模型', 'image.subagentUnsupported': '子智能体会话暂不支持图片', 'image.sendFailed': '图片发送失败({reason}),请重新添加图片后再试', @@ -215,6 +216,7 @@ export const en = { 'image.fileTooLarge': 'Each image must be smaller than {size}', 'image.totalTooLarge': 'Images exceed {size} in total; remove some and try again', 'image.tooManyPixels': 'Image resolution is too high; compress it and try again', + 'image.dimensionTooLarge': 'Image sides must be at most {size}px; downscale it and try again', 'image.modelUnsupported': 'The current model does not support images; switch to a model that does', 'image.subagentUnsupported': 'Subagent sessions do not support images yet', 'image.sendFailed': 'Sending images failed ({reason}); re-add them and try again', diff --git a/packages/client/ui-conversation/tests/image-labels.client.spec.tsx b/packages/client/ui-conversation/tests/image-labels.client.spec.tsx index 5b01fc5712..1fc4af3987 100644 --- a/packages/client/ui-conversation/tests/image-labels.client.spec.tsx +++ b/packages/client/ui-conversation/tests/image-labels.client.spec.tsx @@ -46,6 +46,7 @@ describe('attachment rejection copy', () => { maxImagesPerMessage: 20, maxMessageImageBytes: 100 * 1024 * 1024, maxImagePixels: 40_000_000, + maxImageDimension: 2000, mediaTypes: ['image/png'] as const, } @@ -63,6 +64,7 @@ describe('attachment rejection copy', () => { expect(attachmentErrorText(t, 'TOO_MANY_IMAGES', limits)).toBe('一条消息最多添加 20 张图片') expect(attachmentErrorText(t, 'IMAGE_TOO_LARGE', limits)).toBe('单张图片不能超过 5MB') expect(attachmentErrorText(t, 'IMAGES_TOO_LARGE', limits)).toBe('图片总大小超过 100MB,请移除部分图片') + expect(attachmentErrorText(t, 'IMAGE_DIMENSION_TOO_LARGE', limits)).toBe('图片宽高不能超过 2000px,请缩小后重试') expect(attachmentErrorText(enT, 'TOO_MANY_IMAGES', limits)).toBe('A message can include up to 20 images') }) @@ -71,6 +73,7 @@ describe('attachment rejection copy', () => { expect(attachmentErrorText(t, 'TOO_MANY_IMAGES')).toBe('图片发送失败(TOO_MANY_IMAGES),请重新添加图片后再试') expect(attachmentErrorText(t, 'IMAGE_TOO_LARGE')).toBe('图片发送失败(IMAGE_TOO_LARGE),请重新添加图片后再试') expect(attachmentErrorText(t, 'IMAGES_TOO_LARGE')).toBe('图片发送失败(IMAGES_TOO_LARGE),请重新添加图片后再试') + expect(attachmentErrorText(t, 'IMAGE_DIMENSION_TOO_LARGE')).toBe('图片发送失败(IMAGE_DIMENSION_TOO_LARGE),请重新添加图片后再试') }) }) diff --git a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx index b5dd4bdd97..ae8be641ea 100644 --- a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx @@ -64,6 +64,7 @@ interface BenchOptions { maxImagesPerMessage: number maxMessageImageBytes: number maxImagePixels: number + maxImageDimension: number mediaTypes: readonly ('image/png' | 'image/jpeg' | 'image/webp' | 'image/gif')[] } draft?: string @@ -236,6 +237,7 @@ describe('image draft rail', () => { maxImagesPerMessage: 2, maxMessageImageBytes: 2 * 1024 * 1024, maxImagePixels: 40_000_000, + maxImageDimension: 2000, mediaTypes: ['image/png'] as const, } const png = (bytes: number, name: string) => new File([new ArrayBuffer(bytes)], name, { type: 'image/png' }) @@ -279,6 +281,7 @@ describe('image draft rail', () => { maxImagesPerMessage: 1, maxMessageImageBytes: 8, maxImagePixels: 40_000_000, + maxImageDimension: 2000, mediaTypes: ['image/png'] as const, }, }) @@ -300,6 +303,7 @@ describe('image draft rail', () => { maxImagesPerMessage: 20, maxMessageImageBytes: 100 * 1024 * 1024, maxImagePixels: 40_000_000, + maxImageDimension: 2000, mediaTypes: ['image/png'] as const, }, }) diff --git a/packages/client/ui-settings-models/README.i18n.yaml b/packages/client/ui-settings-models/README.i18n.yaml index f26ca67bc3..950124960a 100644 --- a/packages/client/ui-settings-models/README.i18n.yaml +++ b/packages/client/ui-settings-models/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-settings-models/README.md -README.md: f0094c4f2687beaf6a5e5f7a1a8dc3d4ebdbcbc0 -README.zh.md: 68bb64ddeebfd65644edbb5e11fd2e147214d44a +README.md: 0d76d5f31585721ad8f5cd76dd223d72353fdd0c +README.zh.md: 8ac336245c9d82420c10132b3b935cf00f5ea889 diff --git a/packages/client/ui-settings-models/README.md b/packages/client/ui-settings-models/README.md index f0094c4f26..0d76d5f315 100644 --- a/packages/client/ui-settings-models/README.md +++ b/packages/client/ui-settings-models/README.md @@ -16,7 +16,7 @@ Every edit lands as `settings.mutate` path ops against the stored section — a A pi-ai profile's `models` list is edited on the card: one row per model showing its id and display name, with the context window and output cap behind a per-row disclosure and two label-free actions — expand and delete — on the right. An empty list means "serve this route's built-in catalog", so a row is only ever added deliberately; clearing a capacity drops it rather than storing a value the schema would reject, and the adapter's route-level fallbacks size whatever configuration leaves out — an empty capacity shows those fallbacks' magnitude as its placeholder, a hint rather than a mirror, since the field counts `K` as 1000 and a deployment may override them. A capacity that is not a positive integer is simply not stored. -**Fetch available models** asks `llm.discoverModels` about the endpoint the form **currently shows**, including a base URL edited but not yet saved and a key typed but not yet stored, so adding a provider is one pass instead of save-then-return. The reply opens a picker rather than being written: candidates already configured start unchecked, so adopting a selection never overwrites a capacity the user corrected. A provider that cannot be interrogated is a detour, not a dead end — the adapter's own message appears beside the rows, which stay editable by hand. +**Fetch available models** asks `llm.discoverModels` about the endpoint the form **currently shows**, including a base URL edited but not yet saved and a key typed but not yet stored, so adding a provider is one pass instead of save-then-return. The reply opens a picker rather than being written: candidates already configured start unchecked, so adopting a selection never overwrites a capacity the user corrected. **Select all** / **Deselect all** changes only the picker's current checkboxes; nothing is written until **Add selected**. A provider that cannot be interrogated is a detour, not a dead end — the adapter's own message appears beside the rows, which stay editable by hand. **Add a custom provider** declares a route pi-ai does not ship. It is its own card rather than the editor with extra fields, because the route id is being chosen here and the settings address does not exist until it is: one `settings.mutate` sets the whole profile at `providers.`, and the key travels separately through `credentials.set` under the same `_API_KEY` derivation an existing provider uses. What a hand-declared route cannot default gates the create button — a unique **Provider ID**, an endpoint, a protocol, and at least one uniquely-identified model — so the failure names the field while the user is still looking at it. The id must start with a lowercase letter, because it is also the stem of the derived credential reference and a reference is a POSIX shell identifier: a digit-leading id otherwise passes every check this card makes and then fails at the credential seam with a raw regular expression. Capacities do not gate it: the adapter's fallbacks size a model the endpoint described by id alone, which is what most listings return. The protocol choices are read out of the namespace's own schema rather than a wire field or a constant, so they cannot drift from the ones the adapter accepts. The card records the conventional `apiKeyEnv` reference only when a key is typed, the same rule the editor applies, so a route declared for provider-native authentication is not born pointing at a reference nothing will ever set. When the profile write lands but the key write fails, the provider already exists: the card settles the fields describing it, retries the credential alone — re-running the profile write would carry the revision that write just superseded, so the Host would answer `settings-conflict` and the key could never be stored from here — and reports the created provider even if the user then cancels. diff --git a/packages/client/ui-settings-models/README.zh.md b/packages/client/ui-settings-models/README.zh.md index 68bb64ddee..8ac336245c 100644 --- a/packages/client/ui-settings-models/README.zh.md +++ b/packages/client/ui-settings-models/README.zh.md @@ -16,7 +16,7 @@ pi-ai profile 的 `models` 列表就在卡片上编辑:一行一个模型,行上显示 id 与显示名称,上下文窗口与输出上限收在该行的展开区内,右侧是两个无文字的操作——展开与删除。空列表意味着「使用该路由的内置 catalog」,因此每一行都只会被刻意添加;清空容量会丢弃它,而不是存入一个 schema 会拒绝的值,配置留空的部分由适配器的路由级回退值定尺寸——留空的容量以这些回退值的量级作为占位符,那只是提示而非镜像:该字段按 1000 计 `K`,且部署可以覆盖这些回退值。不是正整数的容量根本不会被存下。 -**获取可用模型**会针对表单**当前显示**的端点调用 `llm.discoverModels`,包括已修改但尚未保存的 API 地址和已键入但尚未存储的密钥,因此新增一个提供方是一趟走完,而不是「先保存再回来」。回复会打开一个选择框而不是直接写入:已配置过的候选默认不勾选,因此采纳一次选择绝不会覆盖用户已更正的容量。无法被询问的提供方只是绕路而非死路——适配器自己的消息会显示在各行旁边,而这些行仍可手工编辑。 +**获取可用模型**会针对表单**当前显示**的端点调用 `llm.discoverModels`,包括已修改但尚未保存的 API 地址和已键入但尚未存储的密钥,因此新增一个提供方是一趟走完,而不是「先保存再回来」。回复会打开一个选择框而不是直接写入:已配置过的候选默认不勾选,因此采纳一次选择绝不会覆盖用户已更正的容量。**全选**/**取消全选**只会改变选择框当前的勾选状态;点击**添加所选**之前不会写入任何内容。无法被询问的提供方只是绕路而非死路——适配器自己的消息会显示在各行旁边,而这些行仍可手工编辑。 **添加自定义提供方**用来声明 pi-ai 未提供的路由。它是独立的一张卡片而非在编辑器上加字段,因为路由 id 正是在这里被*选定*的,而在选定之前 settings 地址并不存在:一次 `settings.mutate` 在 `providers.` 上设置整个 profile,密钥则经 `credentials.set` 单独传递,使用与既有提供方相同的 `_API_KEY` 派生。手工声明的路由无法默认的东西会门控创建按钮——唯一的 **Provider ID**、端点、协议,以及至少一个标识唯一的模型——因此失败会在用户仍看着该字段时点名它。该 id 必须以小写字母开头,因为它同时是派生凭据引用的词干,而引用是 POSIX shell 标识符:数字开头的 id 否则会通过这张卡片的每一项检查,然后在凭据 seam 上抛出原始正则表达式错误。容量不参与门控:端点只按 id 描述的模型(这正是多数列表返回的形态)由适配器的回退值定尺寸。协议选项读自该 namespace 自己的 schema,而非某个协议字段或常量,因此它们不会与适配器实际接受的集合发生漂移。只有键入了密钥,这张卡片才记录约定的 `apiKeyEnv` 引用,与编辑器同一条规则,因此一条为提供方原生认证声明的路由不会一出生就指向一个永远不会被设置的引用。当 profile 写入成功而密钥写入失败时,提供方已经存在:卡片会把描述它的字段定住,只重试凭据——再跑一次 profile 写入会带着刚被自己这次写入取代的 revision,宿主将以 `settings-conflict` 应答,密钥就再也无法从这里存下——并且即使用户随后取消,也照实报告提供方已创建。 diff --git a/packages/client/ui-settings-models/src/client/ModelListEditor.tsx b/packages/client/ui-settings-models/src/client/ModelListEditor.tsx index 6d88484ac5..a4b80f52a0 100644 --- a/packages/client/ui-settings-models/src/client/ModelListEditor.tsx +++ b/packages/client/ui-settings-models/src/client/ModelListEditor.tsx @@ -290,6 +290,18 @@ export function ModelListEditor(props: ModelListEditorProps): ReactNode { }) } + const activeCandidates = candidates ?? [] + const allCandidatesPicked = activeCandidates.length > 0 + && activeCandidates.every(candidate => picked.has(candidate.id)) + + const toggleAllCandidates = (): void => { + setPicked((current) => { + return activeCandidates.every(candidate => current.has(candidate.id)) + ? new Set() + : new Set(activeCandidates.map(candidate => candidate.id)) + }) + } + // A route the adapter already describes answers without an endpoint; only a // draft with neither has nothing to ask about. const askable = probe.provider !== undefined || (probe.baseURL !== undefined && probe.baseURL.length > 0) @@ -445,6 +457,11 @@ export function ModelListEditor(props: ModelListEditorProps): ReactNode { )} > +
+ +
    {(candidates ?? []).map(candidate => (
  • diff --git a/packages/client/ui-settings-models/src/client/ModelsSection.module.css b/packages/client/ui-settings-models/src/client/ModelsSection.module.css index 5203524517..6b8c2047a4 100644 --- a/packages/client/ui-settings-models/src/client/ModelsSection.module.css +++ b/packages/client/ui-settings-models/src/client/ModelsSection.module.css @@ -644,6 +644,12 @@ select.input { --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2); } +.candidateActions { + display: flex; + justify-content: flex-end; + margin-bottom: 6px; +} + .candidateList { display: flex; flex-direction: column; diff --git a/packages/client/ui-settings-models/src/client/locales.ts b/packages/client/ui-settings-models/src/client/locales.ts index a49a4f0ef2..856ef64c7b 100644 --- a/packages/client/ui-settings-models/src/client/locales.ts +++ b/packages/client/ui-settings-models/src/client/locales.ts @@ -72,6 +72,8 @@ export const en = { fetchEmpty: 'The provider listed no models. Add them by hand.', fetchTitle: 'Choose models to add', fetchDescription: 'These are the models this provider has available. Choose the ones to add.', + fetchSelectAll: 'Select all', + fetchDeselectAll: 'Deselect all', fetchAdopt: 'Add selected', customAdd: 'Add a custom provider', customTitle: 'Custom provider', @@ -172,6 +174,8 @@ export const zh: { [Key in keyof typeof en]: string } = { fetchEmpty: '该提供方没有列出任何模型,请手动添加。', fetchTitle: '选择要添加的模型', fetchDescription: '以下是模型提供方的可用模型,勾选要添加的模型。', + fetchSelectAll: '全选', + fetchDeselectAll: '取消全选', fetchAdopt: '添加所选', customAdd: '添加自定义提供方', customTitle: '自定义提供方', diff --git a/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx b/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx index e5e32498d1..4bc30f558a 100644 --- a/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx @@ -605,6 +605,27 @@ describe('endpoint interrogation', () => { // A disclosed output cap rides along with the candidate that has one. expect(firstMutate(mutate).ops[0]?.value).toEqual([{ id: 'a' }, { id: 'b', maxTokens: 2048 }]) }) + + it('selects and clears every discovered candidate in one action', async () => { + const discover = vi.fn(() => Promise.resolve(ok({ + models: [{ id: 'a' }, { id: 'b' }, { id: 'c' }], + }))) + await mountSection({ discover }) + openEditor('openai') + + fireEvent.click(screen.getByText(en.fetchModels)) + const dialog = await screen.findByRole('dialog') + const boxes = [...dialog.querySelectorAll('input[type="checkbox"]')] + expect(boxes.map(box => box.checked)).toEqual([true, true, true]) + + fireEvent.click(within_(dialog, en.fetchDeselectAll)) + expect(boxes.map(box => box.checked)).toEqual([false, false, false]) + expect(within_(dialog, en.fetchSelectAll)).toBeTruthy() + + fireEvent.click(within_(dialog, en.fetchSelectAll)) + expect(boxes.map(box => box.checked)).toEqual([true, true, true]) + expect(within_(dialog, en.fetchDeselectAll)).toBeTruthy() + }) }) describe('provider rows', () => { diff --git a/packages/core/session/src/known-event-types.ts b/packages/core/session/src/known-event-types.ts index d65935f1b8..098743dd8a 100644 --- a/packages/core/session/src/known-event-types.ts +++ b/packages/core/session/src/known-event-types.ts @@ -48,6 +48,10 @@ export const KNOWN_SESSION_EVENT_TYPES: ReadonlySet = new Set([ 'step/end', 'step/start', 'subagent/descriptor', + 'team/member', + 'team/message/delivered', + 'team/message/queued', + 'team/task', 'todo/write', 'tool-workflow/agent-end', 'tool-workflow/agent-start', diff --git a/packages/core/session/tests/invariant.spec.ts b/packages/core/session/tests/invariant.spec.ts index 674755321f..b98391cc3b 100644 --- a/packages/core/session/tests/invariant.spec.ts +++ b/packages/core/session/tests/invariant.spec.ts @@ -277,7 +277,7 @@ describe('session-log invariants', () => { content: [{ ...original.data.message.content[0], content: [{ type: 'text', text: 'pruned' }], - }], + }] satisfies typeof original.data.message.content, }), }, { surfaceOp: { op: 'replace', start: original.seq, end: original.seq }, @@ -316,7 +316,7 @@ describe('session-log invariants', () => { content: [{ ...original.data.message.content[0], content: [{ type: 'text', text: 'pruned' }], - }], + }] satisfies typeof original.data.message.content, }), }, { surfaceOp: { op: 'replace', start: original.seq, end: original.seq }, diff --git a/packages/core/tools/tests/gen-tool-catalog.spec.ts b/packages/core/tools/tests/gen-tool-catalog.spec.ts index 921784c992..43945ea8db 100644 --- a/packages/core/tools/tests/gen-tool-catalog.spec.ts +++ b/packages/core/tools/tests/gen-tool-catalog.spec.ts @@ -25,7 +25,20 @@ describe('gen-tool-catalog collectToolCatalog', () => { it('boots every shipped tool package and harvests its model-facing schemas', async () => { const catalog = await collectToolCatalog() const names = catalog.flatMap(entry => entry.schemas.map(s => s.name)).sort() - expect(names).toEqual(['ask_user_question', 'bash', 'bash', 'cordis_define', 'cordis_inspect_list', 'cordis_inspect_query', 'cordis_inspect_self', 'cordis_run', 'cordis_stop', 'cordis_undefine', 'create_goal', 'edit', 'exit_plan_mode', 'get_goal', 'glob', 'grep', 'interrupt_agent', 'job_kill', 'job_list', 'job_output', 'list_agents', 'lsp', 'pwsh', 'ralph', 'read', 'read_image', 'report', 'run_code', 'schedule_create', 'schedule_delete', 'schedule_list', 'send_message', 'session_event_read', 'session_event_search', 'session_event_trace', 'session_search', 'session_trace', 'skill', 'str_replace_editor', 'subagent', 'terminal_close', 'terminal_list', 'terminal_open', 'terminal_read', 'terminal_send', 'terminal_signal', 'todo_write', 'update_goal', 'web_fetch', 'web_search', 'workflow', 'write']) + expect(names).toEqual([ + 'ask_user_question', 'bash', 'bash', 'cordis_define', 'cordis_inspect_list', + 'cordis_inspect_query', 'cordis_inspect_self', 'cordis_run', 'cordis_stop', + 'cordis_undefine', 'create_goal', 'edit', 'exit_plan_mode', 'followup_task', 'get_goal', 'glob', 'grep', + 'interrupt_agent', 'interrupt_agent', 'job_kill', 'job_list', 'job_output', + 'list_agents', 'list_agents', 'lsp', 'pwsh', 'ralph', + 'read', 'read_image', 'report', 'run_code', 'schedule_create', 'schedule_delete', + 'schedule_list', 'send_message', 'send_message', 'session_event_read', 'session_event_search', + 'session_event_trace', 'session_search', 'session_trace', 'skill', 'spawn_teammate', + 'str_replace_editor', 'subagent', 'team_task_create', + 'team_task_get', 'team_task_list', 'team_task_update', 'terminal_close', 'terminal_list', + 'terminal_open', 'terminal_read', 'terminal_send', 'terminal_signal', 'todo_write', + 'update_goal', 'wait_agent', 'web_fetch', 'web_search', 'workflow', 'write', + ]) // Every tool carries a JSON-Schema `parameters` object (what the model sees). for (const entry of catalog) { for (const schema of entry.schemas) { diff --git a/packages/experimental/AGENTS.md b/packages/experimental/AGENTS.md new file mode 100644 index 0000000000..cab30b777d --- /dev/null +++ b/packages/experimental/AGENTS.md @@ -0,0 +1,9 @@ +# AGENTS.md — Experimental packages + +These rules supplement the [package rules](../AGENTS.md). The [experimental Agent Teams package decision](../../.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.md) owns the rationale. + +- A package belongs here only when its complete public contract is experimental or internal-only. An experimental option inside a release package stays with its owning product role. +- Every package here sets `private: true` and omits `publishConfig`; the dsh release family excludes this directory. +- Release packages and apps must not name packages here in `dependencies`, `optionalDependencies`, or `peerDependencies`. Experimental packages may depend on release packages and each other. Tests may use experimental packages through `devDependencies`; examples may load them explicitly. +- Experimental status does not relax engineering, security, documentation, lifecycle, testing, invariant, or snapshot requirements. +- Promotion moves a package to its product-role group without renaming its `@deepseek-ai/dsh-*` package. Review its public contract, limitations, test evidence, release payload, runtime dependents, and named stable owner before promotion. diff --git a/packages/experimental/README.i18n.yaml b/packages/experimental/README.i18n.yaml new file mode 100644 index 0000000000..f2db56f558 --- /dev/null +++ b/packages/experimental/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/experimental/README.md +README.md: 1c3fd8c76f3331a6606455563b2ea56ff4832dc9 +README.zh.md: d7d40b21ad21764b9f64a71afec8a08446fbdc2e diff --git a/packages/experimental/README.md b/packages/experimental/README.md new file mode 100644 index 0000000000..1c3fd8c76f --- /dev/null +++ b/packages/experimental/README.md @@ -0,0 +1,12 @@ +# experimental/ — private experimental packages + +English | [中文](README.zh.md) + +This group contains prototypes and internal-only Cordis plugins that use the repository's real runtime without joining an official release. Its packages are private, carry no stability or support promise, and retain the same engineering, security, documentation, lifecycle, testing, and snapshot requirements as release packages. + +| Package | Role | ctx key | +|---|---|---| +| `team/` | Implicit-root Agent Teams roster, durable peer mailbox, shared task DAG, and runtime coordination | `ctx.teams` | +| `tool-team/` | Scoped model-facing Agent Teams tools and collaboration guidance | — | + +The [subtree rules](AGENTS.md) define dependency isolation, release exclusion, and promotion. diff --git a/packages/experimental/README.zh.md b/packages/experimental/README.zh.md new file mode 100644 index 0000000000..d7d40b21ad --- /dev/null +++ b/packages/experimental/README.zh.md @@ -0,0 +1,12 @@ +# experimental/:私有实验性包 + +[English](README.md) | 中文 + +本组包含使用仓库真实运行时、但不进入正式发布的原型与内部专用 Cordis 插件。组内包均为私有包,不承诺稳定性或支持,但仍须满足与发布包相同的工程、安全、文档、生命周期、测试和快照要求。 + +| 包 | 职责 | ctx key | +|---|---|---| +| `team/` | 隐式 root Agent Teams roster、持久 peer mailbox、共享任务 DAG 与运行时协调 | `ctx.teams` | +| `tool-team/` | 按 Agent 作用域提供的 Agent Teams 模型工具与协作指引 | — | + +[子树规则](AGENTS.md)规定依赖隔离、发布排除与 promotion。 diff --git a/packages/experimental/team/README.i18n.yaml b/packages/experimental/team/README.i18n.yaml new file mode 100644 index 0000000000..679ab3418b --- /dev/null +++ b/packages/experimental/team/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/experimental/team/README.md +README.md: f676f5b2245fd15559c9e5cd5f969ceece6625c8 +README.zh.md: e85dc59dcd349f69d23408038b4ecead01fbeca6 diff --git a/packages/experimental/team/README.md b/packages/experimental/team/README.md new file mode 100644 index 0000000000..f676f5b224 --- /dev/null +++ b/packages/experimental/team/README.md @@ -0,0 +1,76 @@ +# @deepseek-ai/dsh-team + +English | [中文](README.zh.md) + +Implicit-root Agent Teams domain. `ctx.teams` owns a flat Lead/teammate roster, a durable peer mailbox, and a shared task DAG in the Lead Session log. The [Agent Teams Agent Note](../../../.agents/notes/implemented/feature/2026-08-05-agent-teams.md) owns the coordination and isolation decisions; the [Team subsystem catalog](../../../docs/subsystems/team.md) records the literal durable shapes and service API. + +## Config + +```yaml +- id: team + name: '@deepseek-ai/dsh-team' + config: + maxMembers: 8 + maxTasks: 256 + maxPendingMessagesPerMember: 64 + maxMessageBytes: 65536 + disposalTimeoutMs: 5000 +``` + +Every limit must be a positive safe integer. `maxMembers` counts every name ever provisioned, including failed members, because names are never reusable. `maxTasks` counts non-deleted tasks. The mailbox limit is per target; the byte limit covers the complete framed delivery, including its stable id and sender name. `disposalTimeoutMs` bounds admitted creation, mailbox dispatch, and Team-owned Activation settlement so plugin reload and process shutdown fail visibly instead of waiting forever. + +The service requires Agent, Session, Session persistence, and continuable-subagent services. A composition without durable Session storage does not activate it. + +## Team identity and roster + +Every ordinary runtime root is the implicit Lead of a Team whose `TeamId` equals its `SessionId`; creating a Team is therefore state-free until the first member, message, or task record. A teammate is a named, continuable direct child recorded in that root's Session. Names are lowercase kebab-case, at most 64 characters, and immutable for the Team lifetime. Session ids remain the persistence and authorization identities. + +`spawnTeammate()` first appends and flushes a provisioning member, then asks the configured spawn or fork provider to create the reserved child id. A provider failure appends a durable failed member. Successful inbox admission is flushed in the child Session before the active edge commits. On root recovery, a provisioning record becomes active only when the independently persisted child has matching direct-parent and continuable descriptors plus its initial user message, either still pending in the durable inbox or already recorded in history; otherwise it becomes failed. If recovery wins a same-process provisioning race, the creator accepts the matching terminal state or reports `TEAM_PROVISIONING_CONFLICT` and drains a child that recovery already marked failed. Disposal closes admission, aborts and awaits admitted creation and mailbox-dispatch transactions, then asks the continuation owner to release the roster's exact live direct children and their descendants. Non-Team continuable children of the Lead remain untouched. Cleanup failures make disposal fail visibly. This closes crashes and reload races between root provisioning and its terminal member edge without reusing a name or retaining an orphan Activation. + +Fresh children have no parent-history seed. Fork children capture the Lead's completed-turn prefix once; the in-flight delegation turn is excluded. Inherited Team records carry the old root's `TeamId` and are ignored when an ordinary fork becomes an independent runtime root. Provider-owned subagents outside the roster do not become nested Team Leads. + +The roster reports durable provisioning/failed phases and live `running`/`idle` status. An active but non-resident teammate is `inactive`; later waking delivery cold-resumes it through the continuation owner. + +## Durable mailbox + +`sendMessage()` validates peer membership, appends `team/message/queued`, and flushes before attempting delivery. The result always identifies that durable message; `queued` means immediate delivery was deferred and is not an instruction to resend. Quiet delivery injects, flushes, and acknowledges context immediately when the target is live, but never activates an inactive target; an inactive target's quiet message remains queued. Wakeup delivery becomes the target's next FIFO turn and cold-resumes it when needed. + +The target message begins with `Team message from :` and retains the same id and sender in `TeamMessageSource`. Once the target Session durably holds that identity either in its pending inbox or recorded user-message history, the Lead log appends `team/message/delivered`. Immediate admissions are serialized per target in durable queue order, and recovery dispatches queued-minus-delivered records in the same order. Delivery folds both live and persisted inbox/history state before retrying, so a crash between inbox acceptance and model claim does not duplicate the message. A successful Lead-log flush wakes current `waitForChange()` callers, which then re-list authoritative state. + +The guarantee is process-local retry plus target-Session de-duplication, not cross-process exactly-once delivery. This release has no shared mailbox transaction across processes and no mailbox timeline UI. + +## Shared task board + +Tasks are complete versioned snapshots. Every mutation carries `expectedRevision`; stale callers receive `TEAM_TASK_STALE_REVISION` instead of overwriting a newer value. Any member can create, read, or claim a ready unowned task. The owner or Lead can edit, release, complete, reopen, or delete it; only the Lead can assign another member. Numeric `task-` ids require a safe-integer suffix; creation reports `TEAM_TASK_LIMIT` instead of reusing the final safe id. + +Dependencies must name current non-deleted tasks and form a complete DAG with no self or duplicate edge. A pending task is ready only after every blocker completes. Deleting a task that still has a non-deleted dependent is rejected. Deleted tasks remain tombstones for replay and id stability but do not consume `maxTasks` or appear in `listTasks()`. + +`writeScopes` are normalized workspace-relative prefixes. Views warn when they overlap an in-progress task, but they never block claim or authorize filesystem writes. They are coordination hints, not locks. + +`waitForChange()` waits for one roster, task, mailbox, or live-status edge that occurs after registration, for 10 seconds through one hour; it reports only whether the wait timed out and does not replay a change that already happened. Runtime disposal releases current waits and makes later waits return immediately without a timeout. Callers re-read authoritative state after wakeup or timeout. Cancellation preserves an Error reason or reports a non-Error reason through `TEAM_WAIT_ABORTED` with structural inspection instead of object coercion. `interrupt()` is Lead-only and delegates to the continuable-subagent interrupt path, which cancels only a live teammate's current turn with `keepInbox`; it neither releases task ownership nor deletes durable mail. + +The separate `./invariant` companion replays each candidate Team event against its committed Session prefix. Replay validates every current-version Team payload before it enters folded state, then rejects invalid member transitions, reused names, out-of-range numeric task ids, discontinuous task revisions, invalid task dependencies, duplicate queue/ack records, and acknowledgements with the wrong target before append. Session event `seq` and `time` own ordering and timing instead of duplicated snapshot timestamps. + +## Model Experience + +### Peer messages + +#### What the model sees + +Each delivered peer message is a user-role message. A short first text block names its stable message id and sender; the sender's original content blocks follow unchanged. Roster, task, and mailbox records themselves are log-only and never enter derived model history. + +#### Token effect + +Each peer delivery adds the sender prefix plus message content to the target history. Task and roster mutations add no model tokens; their model-facing representation belongs to `@deepseek-ai/dsh-tool-team` results. + +#### KV Cache effect + +Peer messages append after the target's reusable history prefix. Cold resume reuses the persisted conversation before appending a previously undelivered item. + +## Known Limitations and Deferred Work + +- **One process and one shared checkout** — members share cwd and observe edits immediately; this package provides no worktree, remote member, merge, or filesystem lock. +- **Advisory write scopes** — Bash, formatters, code generators, and direct external writers can bypass filesystem version checks; Leads must coordinate ownership and review the final diff. +- **Flat immutable roster** — only the Lead creates direct teammates; there is no nested Team, rename, deletion, or name reuse. +- **No automatic ownership release** — idle, interruption, process exit, and failed work do not release a task owner. +- **Mailbox is not cross-process exactly-once** — concurrent harness processes over one Team are unsupported. diff --git a/packages/experimental/team/README.zh.md b/packages/experimental/team/README.zh.md new file mode 100644 index 0000000000..e85dc59dcd --- /dev/null +++ b/packages/experimental/team/README.zh.md @@ -0,0 +1,76 @@ +# @deepseek-ai/dsh-team + +[English](README.md) | 中文 + +隐式 Root Agent Teams 领域。`ctx.teams` 在 Lead Session 日志中维护扁平的 Lead/teammate roster、持久 peer mailbox 与共享任务 DAG。[Agent Teams Agent Note](../../../.agents/notes/implemented/feature/2026-08-05-agent-teams.md)负责协作和隔离决策;[Team 子系统目录](../../../docs/subsystems/team.md)记录持久数据的字面形态与服务 API。 + +## 配置 + +```yaml +- id: team + name: '@deepseek-ai/dsh-team' + config: + maxMembers: 8 + maxTasks: 256 + maxPendingMessagesPerMember: 64 + maxMessageBytes: 65536 + disposalTimeoutMs: 5000 +``` + +每个限制都必须是正的安全整数。`maxMembers` 统计所有曾 provision 的名字,包括失败成员,因为名字永不复用。`maxTasks` 统计未删除任务。mailbox 限额按目标成员计算;字节限制覆盖完整的投递帧,包括稳定 id 与发送者名称。`disposalTimeoutMs` 限制已获准创建、mailbox dispatch 与 Team 自有 Activation 的 settlement 时长,使插件 reload 与进程 shutdown 在异常时明确失败,而不是无限等待。 + +该服务要求 Agent、Session、Session persistence 与 continuable-subagent 服务。没有持久 Session 存储的组合不会激活它。 + +## Team 身份与 roster + +每个普通运行时 Root 都是一个隐式 Team 的 Lead,其 `TeamId` 等于 `SessionId`;因此,在写入第一条成员、消息或任务记录前,创建 Team 不需要额外状态。teammate 是记录在 Root Session 中的具名 continuable 直接 child。名字采用小写 kebab-case,最长 64 个字符,在 Team 生命周期内不可变。Session id 始终是持久化与授权身份。 + +`spawnTeammate()` 先追加并 flush provisioning member,再要求配置的 spawn 或 fork provider 使用预留 child id 创建成员。provider 失败会追加持久 failed member。初始 inbox 消息获准后,先 flush child Session,再提交 active 边。Root 恢复时,只有独立持久 child 的直接 parent 与 continuable descriptor 匹配,并且其初始用户消息仍在持久 inbox 中或已经记录进历史,provisioning 才转为 active;否则转为 failed。如果 recovery 在同进程 provisioning 竞争中先完成,creator 会接受匹配终态,或报告 `TEAM_PROVISIONING_CONFLICT` 并 drain 已被 recovery 标为 failed 的 child。dispose 会关闭准入,中止并等待已获准的创建与 mailbox dispatch 事务,再让 continuation owner 释放 roster 中确切的 live direct child 及其后代;Lead 的非 Team continuable child 不受影响。cleanup 失败会让 dispose 明确失败。该对账覆盖 Root provisioning 与终态成员边之间的崩溃和 reload 窗口,同时不复用名字或遗留孤儿 Activation。 + +fresh child 不带 parent 历史 seed。fork child 只捕获一次 Lead 的已完成 turn 前缀,不包含正在执行 delegation 的 turn。继承的 Team 记录带有旧 Root 的 `TeamId`,普通 fork 成为独立运行时 Root 后会忽略这些记录。roster 之外、由 provider 管理的 subagent 不会被误认为嵌套 Team Lead。 + +roster 同时报告持久 provisioning/failed phase 与实时 `running`/`idle` 状态。active 但不驻留的 teammate 显示为 `inactive`;后续 wakeup 投递会经 continuation owner 冷恢复它。 + +## 持久 mailbox + +`sendMessage()` 校验 peer 成员关系,追加 `team/message/queued` 并 flush,之后才尝试投递。结果始终标识该持久消息;`queued` 表示即时投递被推迟,并不表示需要重发。target 为 live 时,quiet 投递会立即注入、flush 并确认上下文,但绝不会激活 inactive target;inactive target 的 quiet 消息会保持 queued。wakeup 投递成为 target 的下一个 FIFO turn,并在需要时冷恢复它。 + +目标消息以 `Team message from :` 开头,并在 `TeamMessageSource` 中保留同一 id 与发送者。target Session 在 pending inbox 或已记录的用户消息历史中持久保存该身份后,Lead 日志才追加 `team/message/delivered`。即时准入按 target 和持久 queue 顺序串行化,恢复也按同一顺序重新投递 queued-minus-delivered 记录。重试前会同时折叠 live 与持久 target 的 inbox/历史状态,因此 inbox 已接受但模型尚未 claim 时发生崩溃也不会复制消息。Lead 日志 flush 成功后会唤醒当前 `waitForChange()` 调用方,调用方随后重新列出权威状态。 + +该保证是进程内重试加 target Session 去重,而不是跨进程 exactly-once。本版本没有跨进程共享 mailbox 事务,也没有 mailbox 时间线 UI。 + +## 共享任务板 + +任务是完整的版本化快照。每次变更都携带 `expectedRevision`;陈旧调用方会收到 `TEAM_TASK_STALE_REVISION`,不会覆盖更新值。任意成员都可以创建、读取或 claim ready 且无 owner 的任务。Owner 或 Lead 可以编辑、释放、完成、重开或删除任务;只有 Lead 可以分配给其他成员。数字 `task-` id 的后缀必须是安全整数;最后一个安全 id 已被占用时,创建会报告 `TEAM_TASK_LIMIT`,而不会复用该 id。 + +依赖必须指向当前未删除任务,并组成完整 DAG,不允许 self edge 或重复 edge。只有所有 blocker 都 completed,pending 任务才 ready。仍被未删除任务依赖的任务不能删除。删除任务作为 tombstone 保留以供回放和维持 id 稳定,但不占用 `maxTasks`,也不出现在 `listTasks()` 中。 + +`writeScopes` 会规范化为 workspace-relative 路径前缀。view 会对与 in-progress 任务的重叠发出警告,但绝不会阻止 claim 或授予文件写权限。它们是协作提示,不是锁。 + +`waitForChange()` 可以等待注册后发生的下一条 roster、task、mailbox 或实时 status 边,时长范围为 10 秒到 1 小时;它只报告等待是否超时,也不会回放调用前已经发生的变化。运行时 dispose 会释放当前等待,并使后续等待不经超时立即返回。调用方需要在唤醒或超时后重新读取权威状态。取消会保留 Error reason;非 Error reason 则通过 `TEAM_WAIT_ABORTED` 以结构化检查结果报告,不再强制转成 object 字符串。`interrupt()` 仅限 Lead,并委托 continuable-subagent 的 interrupt 路径以 `keepInbox` 只取消 live teammate 的当前 turn;它既不释放任务 owner,也不删除持久 mail。 + +独立的 `./invariant` 配套模块会把每条候选 Team event 对照已提交 Session 前缀回放。回放会先验证每个当前版本 Team payload,再将其纳入折叠状态;随后会在 append 前拒绝非法 member 转换、名字复用、超出范围的数字 task id、不连续任务 revision、非法任务依赖、重复 queue/ack,以及 target 不匹配的 acknowledgement。顺序与时间由 Session event 的 `seq` 和 `time` 负责,不在 snapshot 中重复保存。 + +## 模型体验 + +### Peer 消息 + +#### 模型看到的内容 + +每条已投递 peer 消息都是用户角色消息。第一个短文本块包含稳定消息 id 与发送者,之后原样附加发送者的内容块。roster、task 和 mailbox 记录本身只存在于日志,不进入派生模型历史。 + +#### Token 影响 + +每次 peer 投递都会把发送者前缀与消息内容加入 target 历史。任务和 roster 变更不增加模型 token;其面向模型的呈现属于 `@deepseek-ai/dsh-tool-team` 结果。 + +#### KV Cache 影响 + +Peer 消息追加在 target 可复用历史前缀之后。冷恢复会先复用持久对话,再追加尚未投递的消息。 + +## 已知限制与暂缓事项 + +- **单进程、共享 checkout**:所有成员共享 cwd,修改立即可见;本包不提供 worktree、远端成员、自动 merge 或文件锁。 +- **write scope 仅作提示**:Bash、formatter、codegen 和直接外部写入可以绕过文件版本检查;Lead 必须协调 owner 并检查最终 diff。 +- **扁平且不可变的 roster**:只有 Lead 可以创建直接 teammate;不支持嵌套 Team、重命名、删除或名字复用。 +- **不会自动释放 owner**:idle、interrupt、进程退出与工作失败都不会释放任务 owner。 +- **mailbox 不保证跨进程 exactly-once**:不支持多个 harness 进程并发操作同一 Team。 diff --git a/packages/experimental/team/package.json b/packages/experimental/team/package.json new file mode 100644 index 0000000000..8a2423093a --- /dev/null +++ b/packages/experimental/team/package.json @@ -0,0 +1,62 @@ +{ + "name": "@deepseek-ai/dsh-team", + "description": "Implicit-root Agent Teams roster, durable peer mailbox, and shared task DAG", + "version": "0.1.0-rc.7", + "private": true, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/experimental/team" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.d.ts" + ], + "license": "MIT", + "dependencies": { + "@deepseek-ai/schemastery": "workspace:^", + "zod": "^4.4.3" + }, + "peerDependencies": { + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-persistence": "workspace:^", + "@deepseek-ai/dsh-subagent": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" + }, + "devDependencies": { + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-agent-loop": "workspace:^", + "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", + "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-persistence": "workspace:^", + "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", + "@deepseek-ai/dsh-session-persistence-sqlite": "workspace:^", + "@deepseek-ai/dsh-subagent": "workspace:^", + "@deepseek-ai/dsh-subagent-fork-in-process": "workspace:^", + "@deepseek-ai/dsh-subagent-spawn-in-process": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" + } +} diff --git a/packages/experimental/team/src/activity.ts b/packages/experimental/team/src/activity.ts new file mode 100644 index 0000000000..3b33f3320b --- /dev/null +++ b/packages/experimental/team/src/activity.ts @@ -0,0 +1,87 @@ +/** One-shot Team change waiters independent of durable state projection. */ + +import type { TeamId, TeamWaitResult } from './types.ts' +import { errorMessage, TeamError } from './error.ts' + +interface Waiter { + readonly resolve: () => void +} + +/** Owns current Team change waiters and releases each at most once. */ +export class TeamActivity { + private readonly waiters = new Map>() + private closed = false + + /** + * Wait for one later Team-domain or member-status change. + * @param id - Team whose next edge wakes the caller. + * @param timeoutMs - bounded wait duration from ten seconds through one hour. + * @param signal - caller cancellation for this wait only. + * @returns whether the wait ended by timeout. + */ + async wait(id: TeamId, timeoutMs: number, signal: AbortSignal): Promise { + if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 10_000 || timeoutMs > 3_600_000) { + throw new TeamError('timeoutMs must be an integer from 10000 through 3600000', 'TEAM_INVALID_TIMEOUT') + } + signal.throwIfAborted() + if (this.closed) return { timedOut: false } + const changed = await new Promise((resolve, reject) => { + let waiters = this.waiters.get(id) + if (waiters === undefined) { + waiters = new Set() + this.waiters.set(id, waiters) + } + let settled = false + const finish = (settle: () => void): void => { + /* v8 ignore next -- timeout, abort, and notification may race after one winner removes the others. */ + if (settled) return + settled = true + clearTimeout(timer) + signal.removeEventListener('abort', onAbort) + waiters.delete(waiter) + if (waiters.size === 0) this.waiters.delete(id) + settle() + } + const onAbort = (): void => { + finish(() => { + const reason: unknown = signal.reason + reject(reason instanceof Error + ? reason + : new TeamError(`wait_agent aborted: ${errorMessage(reason)}`, 'TEAM_WAIT_ABORTED')) + }) + } + const waiter: Waiter = { + resolve: () => { + finish(() => { resolve(true) }) + }, + } + waiters.add(waiter) + const timer = setTimeout(() => { finish(() => { resolve(false) }) }, timeoutMs) + signal.addEventListener('abort', onAbort, { once: true }) + // AbortSignal does not replay an abort that wins between the pre-check and listener registration. + /* v8 ignore next -- requires an abort in the synchronous gap between the pre-check and listener registration. */ + if (signal.aborted) onAbort() + }) + return { timedOut: !changed } + } + + /** + * Wake and remove every current waiter for one Team. + * @param id - Team whose current waiters observe the change. + */ + notify(id: TeamId): void { + const waiters = this.waiters.get(id) + if (waiters === undefined) return + this.waiters.delete(id) + for (const waiter of waiters) waiter.resolve() + } + + /** Close admission and wake every current waiter during runtime disposal. */ + close(): void { + this.closed = true + for (const waiters of this.waiters.values()) { + for (const waiter of waiters) waiter.resolve() + } + this.waiters.clear() + } +} diff --git a/packages/experimental/team/src/error.ts b/packages/experimental/team/src/error.ts new file mode 100644 index 0000000000..e0b89b72bd --- /dev/null +++ b/packages/experimental/team/src/error.ts @@ -0,0 +1,23 @@ +/** Typed Agent Teams failures. */ + +import { inspect } from 'node:util' +import { HarnessError } from '@deepseek-ai/dsh-llm' + +/** Stable failure raised by the Team domain. */ +export class TeamError extends HarnessError { + constructor(message: string, code: string, options?: ErrorOptions) { + super(message, code, options) + this.name = 'TeamError' + } +} + +/** + * Render an arbitrary thrown value without replacing the original rejection. + * @param error - caught value used in a diagnostic or durable failure record. + * @returns one bounded single-line description. + */ +export function errorMessage(error: unknown): string { + if (error instanceof Error) return error.message + if (typeof error === 'string') return error + return inspect(error, { breakLength: Infinity, compact: true, depth: 4 }) +} diff --git a/packages/experimental/team/src/fold.ts b/packages/experimental/team/src/fold.ts new file mode 100644 index 0000000000..cf439a5322 --- /dev/null +++ b/packages/experimental/team/src/fold.ts @@ -0,0 +1,291 @@ +/** Strict replay fold for Agent Teams log-only events. */ + +import { z } from 'zod' +import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import { SessionId } from '@deepseek-ai/dsh-session' +import type { SessionEvent, SessionEventMap } from '@deepseek-ai/dsh-session' +import type { + TeamId, + TeamMemberSnapshot, + TeamMessageId, + TeamMessageSnapshot, + TeamTaskId, + TeamTaskSnapshot, +} from './types.ts' +import { + TeamId as toTeamId, + TeamMessageId as toTeamMessageId, + TeamTaskId as toTeamTaskId, +} from './types.ts' +import { assertTaskGraphCandidate } from './task-graph.ts' + +const nonNegativeSafeInteger = z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER) +const positiveSafeInteger = nonNegativeSafeInteger.min(1) +const sessionIdSchema = z.string().min(1).transform(value => SessionId(value)) +const teamIdSchema = z.string().min(1).transform(value => toTeamId(value)) +const numericTaskIdPattern = /^task-(\d+)$/u +const teamTaskIdSchema = z.string().min(1).refine((value) => { + const match = numericTaskIdPattern.exec(value) + return match === null || Number.isSafeInteger(Number(match[1])) +}, { message: 'numeric task id suffix must be a safe integer' }).transform(value => toTeamTaskId(value)) +const teamMessageIdSchema = z.string().min(1).transform(value => toTeamMessageId(value)) + +const coreContentBlockTypes = new Set(['text', 'reasoning', 'image', 'tool-call', 'tool-result']) +const imageAttachmentSchema = z.object({ + attachmentId: z.string().min(1), + mediaType: z.enum(['image/png', 'image/jpeg', 'image/webp', 'image/gif']), + bytes: nonNegativeSafeInteger, + width: positiveSafeInteger, + height: positiveSafeInteger, + name: z.string().optional(), +}).strict() + +// ContentBlockMap is merge-extensible. Validate every core variant exactly, +// while retaining JSON-decoded plugin variants under an unknown type tag. +const contentBlockSchema: z.ZodType = z.lazy(() => z.union([ + z.object({ type: z.literal('text'), text: z.string() }).strict(), + z.object({ type: z.literal('reasoning'), text: z.string() }).strict(), + z.object({ type: z.literal('image'), attachment: imageAttachmentSchema }).strict(), + z.object({ + type: z.literal('tool-call'), + id: z.string().min(1), + name: z.string(), + arguments: z.string(), + }).strict(), + z.object({ + type: z.literal('tool-result'), + toolCallId: z.string().min(1), + content: z.array(contentBlockSchema), + isError: z.boolean().optional(), + }).strict(), + z.object({ type: z.string().min(1) }).loose().refine( + block => !coreContentBlockTypes.has(block.type), + { message: 'known content block types must match their declared fields' }, + ), +])) as z.ZodType + +const teamMemberSnapshotSchema = z.object({ + id: sessionIdSchema, + name: z.string(), + description: z.string(), + provider: z.string(), + context: z.enum(['fresh', 'fork']), + phase: z.enum(['provisioning', 'active', 'failed']), + error: z.string().optional(), +}).strict() as z.ZodType + +const teamTaskSnapshotSchema = z.object({ + id: teamTaskIdSchema, + revision: positiveSafeInteger, + subject: z.string(), + description: z.string(), + status: z.enum(['pending', 'in_progress', 'completed', 'deleted']), + ownerId: sessionIdSchema.optional(), + blockedBy: z.array(teamTaskIdSchema), + writeScopes: z.array(z.string()), +}).strict() as z.ZodType + +const teamMessageSnapshotSchema = z.object({ + id: teamMessageIdSchema, + senderId: sessionIdSchema, + senderName: z.string(), + targetId: sessionIdSchema, + delivery: z.enum(['quiet', 'wakeup']), + content: z.array(contentBlockSchema), +}).strict() as z.ZodType + +const teamEventSelectorSchema = z.object({ + version: nonNegativeSafeInteger, + teamId: teamIdSchema, +}).loose() + +const teamMemberEventSchema = z.object({ + version: z.literal(1), + teamId: teamIdSchema, + member: teamMemberSnapshotSchema, +}).strict() as z.ZodType + +const teamTaskEventSchema = z.object({ + version: z.literal(1), + teamId: teamIdSchema, + task: teamTaskSnapshotSchema, +}).strict() as z.ZodType + +const teamMessageQueuedEventSchema = z.object({ + version: z.literal(1), + teamId: teamIdSchema, + message: teamMessageSnapshotSchema, +}).strict() as z.ZodType + +const teamMessageDeliveredEventSchema = z.object({ + version: z.literal(1), + teamId: teamIdSchema, + messageId: teamMessageIdSchema, + targetId: sessionIdSchema, +}).strict() as z.ZodType + +/** Mutable internal replay state. */ +export interface TeamFoldState { + readonly id: TeamId + readonly members: Map + readonly memberIdsByName: Map + readonly tasks: Map + readonly messages: Map + readonly delivered: Set + nextTaskNumber: number +} + +/** + * Construct an empty Team fold for one root Session. + * @param rootId - Session whose TeamId selects applicable records. + * @returns mutable empty replay state. + */ +export function emptyTeamFoldState(rootId: SessionId): TeamFoldState { + return { + id: toTeamId(rootId), + members: new Map(), + memberIdsByName: new Map(), + tasks: new Map(), + messages: new Map(), + delivered: new Set(), + nextTaskNumber: 1, + } +} + +/** Whether one event belongs to the Team domain. */ +export type TeamEventType = + | 'team/member' + | 'team/task' + | 'team/message/queued' + | 'team/message/delivered' + +/** One event owned by the Team domain. */ +export type TeamSessionEvent = SessionEvent + +/** + * Test whether a Session event belongs to the Team domain. + * @param event - candidate Session event. + * @returns whether the event has a Team-owned type. + */ +export function isTeamEvent(event: SessionEvent): event is TeamSessionEvent { + return event.type === 'team/member' + || event.type === 'team/task' + || event.type === 'team/message/queued' + || event.type === 'team/message/delivered' +} + +/** Decode one persisted Team value and retain the schema failure as its cause. */ +function parsePersisted(type: TeamEventType, schema: z.ZodType, value: unknown): T { + try { + return schema.parse(value) + } catch (error: unknown) { + throw new Error(`persisted Agent Teams ${type} payload is invalid`, { cause: error }) + } +} + +/** Decode the complete current-version payload selected by one Team event type. */ +function parseCurrentTeamEvent(event: TeamSessionEvent): TeamSessionEvent { + switch (event.type) { + case 'team/member': + return { ...event, data: parsePersisted(event.type, teamMemberEventSchema, event.data) } + case 'team/task': + return { ...event, data: parsePersisted(event.type, teamTaskEventSchema, event.data) } + case 'team/message/queued': + return { ...event, data: parsePersisted(event.type, teamMessageQueuedEventSchema, event.data) } + case 'team/message/delivered': + return { ...event, data: parsePersisted(event.type, teamMessageDeliveredEventSchema, event.data) } + /* v8 ignore next 2 -- TeamEventType is closed and every member is handled above. */ + default: + return event + } +} + +/** + * Apply one event, ignoring Team records inherited by a different root fork. + * @param state - mutable Team replay state. + * @param event - next contiguous Session event. + */ +export function applyTeamEvent(state: TeamFoldState, event: SessionEvent): void { + if (!isTeamEvent(event)) return + const selector = parsePersisted(event.type, teamEventSelectorSchema, event.data) + if (selector.version !== 1) { + if (selector.teamId !== state.id) return + throw new Error(`unsupported Agent Teams event version ${String(selector.version)}`) + } + const decoded = parseCurrentTeamEvent(event) + if (decoded.data.teamId !== state.id) return + + switch (decoded.type) { + case 'team/member': { + const member = decoded.data.member + const prior = state.members.get(member.id) + const named = state.memberIdsByName.get(member.name) + if (named !== undefined && named !== member.id) { + throw new Error(`teammate name "${member.name}" is reused by another member`) + } + if (prior === undefined) { + if (member.phase !== 'provisioning') throw new Error(`teammate "${member.name}" must begin provisioning`) + state.memberIdsByName.set(member.name, member.id) + } else { + if (prior.name !== member.name || prior.provider !== member.provider || prior.context !== member.context) { + throw new Error(`teammate "${member.id}" changed immutable identity fields`) + } + if (prior.phase !== 'provisioning' || member.phase === 'provisioning') { + throw new Error(`teammate "${member.name}" has an invalid ${prior.phase} -> ${member.phase} transition`) + } + } + state.members.set(member.id, member) + break + } + case 'team/task': { + const task = decoded.data.task + const prior = state.tasks.get(task.id) + if (prior === undefined && task.revision !== 1) { + throw new Error(`team task "${task.id}" must begin at revision 1`) + } + if (prior !== undefined && task.revision !== prior.revision + 1) { + throw new Error(`team task "${task.id}" revision is not contiguous`) + } + assertTaskGraphCandidate(state.tasks, task) + const match = numericTaskIdPattern.exec(task.id) + if (match !== null) { + const number = Number(match[1]) + state.nextTaskNumber = Math.max( + state.nextTaskNumber, + number === Number.MAX_SAFE_INTEGER ? number : number + 1, + ) + } + state.tasks.set(task.id, task) + break + } + case 'team/message/queued': { + const message = decoded.data.message + if (state.messages.has(message.id)) throw new Error(`team message "${message.id}" was queued twice`) + state.messages.set(message.id, message) + break + } + case 'team/message/delivered': { + const queued = state.messages.get(decoded.data.messageId) + if (queued === undefined) throw new Error(`team message "${decoded.data.messageId}" was delivered before queueing`) + if (queued.targetId !== decoded.data.targetId) throw new Error(`team message "${decoded.data.messageId}" target changed`) + if (state.delivered.has(decoded.data.messageId)) throw new Error(`team message "${decoded.data.messageId}" was delivered twice`) + state.delivered.add(decoded.data.messageId) + break + } + /* v8 ignore next 2 -- TeamEventType is closed and every member is handled above. */ + default: + return + } +} + +/** + * Replay one root Session into its current Team state. + * @param rootId - root Session identity selecting Team-owned records. + * @param events - complete contiguous Session log. + * @returns mutable replay state at the end of the log. + */ +export function foldTeam(rootId: SessionId, events: readonly SessionEvent[]): TeamFoldState { + const state = emptyTeamFoldState(rootId) + for (const event of events) applyTeamEvent(state, event) + return state +} diff --git a/packages/experimental/team/src/index.ts b/packages/experimental/team/src/index.ts new file mode 100644 index 0000000000..9b80d4d8dc --- /dev/null +++ b/packages/experimental/team/src/index.ts @@ -0,0 +1,261 @@ +/** Agent Teams service façade over roster, mailbox, task, and runtime lifecycle owners. */ + +import { Context, Service } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type {} from '@deepseek-ai/dsh-session-persistence' +import { TeamActivity } from './activity.ts' +import { errorMessage, TeamError } from './error.ts' +import { TeamJournal } from './journal.ts' +import { TeamRuntimeLifecycle } from './lifecycle.ts' +import { TeamMailbox } from './mailbox.ts' +import { TeamRoster } from './roster.ts' +import type { TeamMembership } from './roster.ts' +import { TeamTaskBoard } from './task-board.ts' +import { TeamId, TeamTaskId } from './types.ts' +import type { + Config, + CreateTeamTaskRequest, + SendTeamMessageRequest, + SendTeamMessageResult, + SpawnTeammateRequest, + SpawnTeammateResult, + TeamMemberView, + TeamTaskView, + TeamWaitResult, + UpdateTeamTaskRequest, +} from './types.ts' + +export type * from './types.ts' +export type { TeamMembership } from './roster.ts' +export { TeamId, TeamMessageId, TeamTaskId } from './types.ts' +export { TeamError } from './error.ts' +export { foldTeam } from './fold.ts' + +declare module '@deepseek-ai/cordis' { + interface Context { + teams: TeamService + } +} + +const DEFAULT_MAX_MEMBERS = 8 +const DEFAULT_MAX_TASKS = 256 +const DEFAULT_MAX_PENDING_MESSAGES = 64 +const DEFAULT_MAX_MESSAGE_BYTES = 65_536 +const DEFAULT_DISPOSAL_TIMEOUT_MS = 5_000 + +/** Validate one positive safe-integer deployment limit. */ +function positiveLimit(name: string, value: number): number { + if (!Number.isSafeInteger(value) || value < 1) { + throw new TeamError(`${name} must be a positive safe integer`, 'TEAM_INVALID_CONFIG') + } + return value +} + +/** Agent Teams service backed by the exact live Lead Session log. */ +export class TeamService extends Service { + static inject = ['agents', 'sessions', 'sessionPersistence', 'subagents'] + + static Config: z = z.object({ + maxMembers: z.number().step(1).min(1).default(DEFAULT_MAX_MEMBERS), + maxTasks: z.number().step(1).min(1).default(DEFAULT_MAX_TASKS), + maxPendingMessagesPerMember: z.number().step(1).min(1).default(DEFAULT_MAX_PENDING_MESSAGES), + maxMessageBytes: z.number().step(1).min(1).default(DEFAULT_MAX_MESSAGE_BYTES), + disposalTimeoutMs: z.number().step(1).min(1).default(DEFAULT_DISPOSAL_TIMEOUT_MS), + }) + + /** Validated deployment limits used by every Team operation. */ + private readonly config: Required + + private readonly activity: TeamActivity + private readonly lifecycle: TeamRuntimeLifecycle + private readonly journal: TeamJournal + private readonly roster: TeamRoster + private readonly mailbox: TeamMailbox + private readonly tasks: TeamTaskBoard + + constructor(ctx: Context, config: Config = {}) { + super(ctx, 'teams') + this.config = { + maxMembers: positiveLimit('maxMembers', config.maxMembers ?? DEFAULT_MAX_MEMBERS), + maxTasks: positiveLimit('maxTasks', config.maxTasks ?? DEFAULT_MAX_TASKS), + maxPendingMessagesPerMember: positiveLimit( + 'maxPendingMessagesPerMember', + config.maxPendingMessagesPerMember ?? DEFAULT_MAX_PENDING_MESSAGES, + ), + maxMessageBytes: positiveLimit('maxMessageBytes', config.maxMessageBytes ?? DEFAULT_MAX_MESSAGE_BYTES), + disposalTimeoutMs: positiveLimit( + 'disposalTimeoutMs', + config.disposalTimeoutMs ?? DEFAULT_DISPOSAL_TIMEOUT_MS, + ), + } + + this.activity = new TeamActivity() + this.lifecycle = new TeamRuntimeLifecycle(this.config.disposalTimeoutMs) + this.journal = new TeamJournal(ctx, (root) => { this.activity.notify(TeamId(root.id)) }) + this.roster = new TeamRoster(ctx, this.journal, this.lifecycle, this.config.maxMembers) + this.mailbox = new TeamMailbox( + ctx, + this.journal, + this.roster, + this.lifecycle, + this.config.maxPendingMessagesPerMember, + this.config.maxMessageBytes, + ) + this.tasks = new TeamTaskBoard(this.journal, this.config.maxTasks) + + ctx.on('session/event', (session, event) => { this.mailbox.observeSessionEvent(session, event) }) + ctx.on('agent/session-start', ({ agent }) => { this.scheduleRecovery(agent) }) + ctx.on('agent/status', ({ agent }) => { + const membership = this.roster.tryMembership(agent) + if (membership !== undefined) this.activity.notify(membership.id) + }) + ctx.effect(() => () => this.disposeRuntime(), 'teams.runtimeLifecycle()') + for (const agent of ctx.agents.list()) this.scheduleRecovery(agent) + } + + /** + * Resolve one exact live Agent's Team role. + * @param agent - exact live Agent used as the authority credential. + * @returns its root, Team identity, role, and model-facing name. + */ + membership(agent: Agent): TeamMembership { + return this.roster.membership(agent) + } + + /** + * List the runtime-enriched roster visible to one Team member. + * @param agent - exact live Team member. + * @returns Lead and teammate rows in creation order. + */ + listMembers(agent: Agent): TeamMemberView[] { + return this.roster.list(this.roster.membership(agent)) + } + + /** + * Create one named, continuable direct child of the Team Lead. + * @param caller - exact live Lead Agent. + * @param request - immutable name, description, prompt, context mode, provider, and cancellation. + * @returns the active roster row. + */ + async spawnTeammate(caller: Agent, request: SpawnTeammateRequest): Promise { + return await this.roster.spawn(caller, request) + } + + /** + * Queue one durable peer message, then attempt immediate delivery. + * @param caller - exact live sending Team member. + * @param request - target name, content, scheduling mode, and pre-queue cancellation. + * @returns durable message identity and immediate-delivery observation. + */ + async sendMessage(caller: Agent, request: SendTeamMessageRequest): Promise { + return await this.mailbox.send(caller, request) + } + + /** + * Create one unowned pending task in the Team Lead log. + * @param caller - exact live Team member creating the task. + * @param request - task text, blockers, and advisory write scopes. + * @returns the revision-one task view. + */ + async createTask(caller: Agent, request: CreateTeamTaskRequest): Promise { + return await this.tasks.create(this.roster.membership(caller), request) + } + + /** + * Return one task, including a deleted tombstone. + * @param caller - exact live Team member reading the task. + * @param id - Team-local task identity. + * @returns the latest task value and derived readiness diagnostics. + */ + getTask(caller: Agent, id: TeamTaskId): TeamTaskView { + return this.tasks.get(this.roster.membership(caller), id) + } + + /** + * List current non-deleted tasks in numeric creation order. + * @param caller - exact live Team member reading the board. + * @returns detached current task views. + */ + listTasks(caller: Agent): TeamTaskView[] { + return this.tasks.list(this.roster.membership(caller)) + } + + /** + * Compare-and-set one authorized task transition. + * @param caller - exact live Team member authorizing the mutation. + * @param request - task identity, expected revision, action, and action fields. + * @returns the committed next task revision. + */ + async updateTask(caller: Agent, request: UpdateTeamTaskRequest): Promise { + return await this.tasks.update(caller, this.roster.membership(caller), request) + } + + /** + * Wait for the next Team-domain or member-status change. + * @param caller - exact live Team member waiting for activity. + * @param timeoutMs - bounded wait duration from ten seconds through one hour. + * @param signal - caller cancellation for the wait only. + * @returns one observed change or a timeout result. + */ + async waitForChange(caller: Agent, timeoutMs: number, signal: AbortSignal): Promise { + const membership = this.roster.membership(caller) + return await this.activity.wait(membership.id, timeoutMs, signal) + } + + /** + * Interrupt one live teammate turn without clearing its pending inbox. + * @param caller - exact live Lead Agent. + * @param targetName - durable teammate name. + * @returns the target status sampled before cancellation. + */ + interrupt(caller: Agent, targetName: string): { previousStatus: 'running' | 'idle' | 'inactive' } { + return this.roster.interrupt(caller, targetName) + } + + /** + * Resolve a caller without throwing, used by scoped-tool installation and observers. + * @param agent - candidate exact live Agent. + * @returns Team membership, or undefined for non-Team subagents and stale identities. + */ + tryMembership(agent: Agent): TeamMembership | undefined { + return this.roster.tryMembership(agent) + } + + /** Queue one contained recovery pass after publication has unwound. */ + private scheduleRecovery(agent: Agent): void { + queueMicrotask(() => { + if (this.lifecycle.disposed) return + void this.recoverFor(agent).catch((error: unknown) => { + if (this.lifecycle.disposed) return + this.ctx.logger.warn(`Agent Teams recovery for "${agent.id}" failed: ${errorMessage(error)}`) + }) + }) + } + + /** Reconcile roster provisioning before retrying that member's pending mailbox. */ + private async recoverFor(agent: Agent): Promise { + await this.roster.recoverFor(agent, this.lifecycle.signal) + await this.mailbox.recoverFor(agent, this.lifecycle.signal) + } + + /** Stop Team-owned live branches and release every waiter before service disposal completes. */ + private async disposeRuntime(): Promise { + this.lifecycle.close() + this.activity.close() + + const failures: unknown[] = [] + await this.lifecycle.settle(this.roster.pendingCreations(), failures) + await this.lifecycle.settle(this.mailbox.pendingDispatches(), failures) + for (const [root, childIds] of this.roster.liveChildrenByRoot()) { + try { + await this.roster.stopTeammates(root, childIds) + } catch (error: unknown) { + failures.push(error) + } + } + if (failures.length > 0) throw new AggregateError(failures, 'Agent Teams runtime disposal failed') + } +} + +export default TeamService diff --git a/packages/experimental/team/src/invariant.ts b/packages/experimental/team/src/invariant.ts new file mode 100644 index 0000000000..52e28491fe --- /dev/null +++ b/packages/experimental/team/src/invariant.ts @@ -0,0 +1,34 @@ +/** Package-owned relational checks for Agent Teams durable records. */ + +import type { Context } from '@deepseek-ai/cordis' +import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' +import { applyTeamEvent, foldTeam, isTeamEvent } from './fold.ts' + +const PACKAGE_NAME = '@deepseek-ai/dsh-team' + +/** Cordis companion plugin name. */ +export const name = 'team-invariant' +/** Invariant registry required by the companion. */ +export const inject = ['invariants'] + +/** Validate candidate Team events against the committed prefix before append. */ +const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => { + ctx.on('internal/dispatch', (_mode, eventName, args) => { + if (eventName !== 'session/event') return + const [session, event] = args as [Session, SessionEvent] + if (!isTeamEvent(event)) return + try { + const state = foldTeam(session.id, session.events) + applyTeamEvent(state, event) + } catch (error: unknown) { + /* v8 ignore next -- the strict Team fold throws Error instances. */ + const message = error instanceof Error ? error.message : String(error) + fail(`session event ${event.seq} violates the Agent Teams stream: ${message}`) + } + }, { global: true }) +}, { inject: ['sessions'] }) + +/** Register the package invariant companion. */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) diff --git a/packages/experimental/team/src/journal.ts b/packages/experimental/team/src/journal.ts new file mode 100644 index 0000000000..f97e7776f9 --- /dev/null +++ b/packages/experimental/team/src/journal.ts @@ -0,0 +1,71 @@ +/** Serialized Team transactions over the exact live Lead Session log. */ + +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { Context } from '@deepseek-ai/cordis' +import type { SessionEventMap, SessionId } from '@deepseek-ai/dsh-session' +import { foldTeam } from './fold.ts' +import type { TeamEventType, TeamFoldState } from './fold.ts' + +type AppendTeamEvent = (type: T, data: SessionEventMap[T]) => void +type MutableTeamEventType = 'team/member' | 'team/task' | 'team/message/queued' | 'team/message/delivered' + +/** Owns per-Lead transaction order and committed Team event publication. */ +export class TeamJournal { + private readonly tails = new Map>() + + /** + * @param ctx - Team service context with the injected Session service. + * @param onCommit - synchronous notification after the Team event flush succeeds. + */ + constructor( + private readonly ctx: Context, + private readonly onCommit: (root: Agent) => void, + ) {} + + /** + * Fold authoritative Team state for one exact live Lead. + * @param root - exact live Team Lead. + * @returns current replay state selected by the Lead Team id. + */ + state(root: Agent): TeamFoldState { + return foldTeam(root.id, root.session.events) + } + + /** + * Serialize one Lead's asynchronous mutation operation. + * @param rootId - Lead Session identity selecting the transaction queue. + * @param operation - complete read-check-append operation. + * @returns the operation result. + */ + async transact(rootId: SessionId, operation: () => Promise): Promise { + const prior = this.tails.get(rootId) ?? Promise.resolve() + const run = prior.then(operation, operation) + const tail = run.then(() => undefined, () => undefined) + this.tails.set(rootId, tail) + try { + return await run + } finally { + if (this.tails.get(rootId) === tail) this.tails.delete(rootId) + } + } + + /** + * Append and checkpoint one root-owned Team event before publication. + * @param root - exact live Lead whose Session owns the event. + * @param type - Team event discriminant. + * @param data - payload correlated with the event type. + */ + async appendAndFlush( + root: Agent, + type: T, + data: SessionEventMap[T], + ): Promise { + // Team events never enter the conversation surface. This narrower local + // capability removes Session.append's conditional surface argument while + // preserving the event-key/payload correlation. + const append = root.session.append.bind(root.session) as unknown as AppendTeamEvent + append(type, data) + await this.ctx.sessions.flush(root.session) + this.onCommit(root) + } +} diff --git a/packages/experimental/team/src/lifecycle.ts b/packages/experimental/team/src/lifecycle.ts new file mode 100644 index 0000000000..55bdfadb9e --- /dev/null +++ b/packages/experimental/team/src/lifecycle.ts @@ -0,0 +1,87 @@ +/** Shared admission cutoff and bounded settlement for the Team runtime. */ + +import { TeamError } from './error.ts' + +/** Owns the single Team runtime cancellation fact and disposal timeout. */ +export class TeamRuntimeLifecycle { + private readonly controller = new AbortController() + + /** + * @param disposalTimeoutMs - maximum wait for one disposal settlement operation. + */ + constructor(private readonly disposalTimeoutMs: number) {} + + /** Signal aborted exactly when Team runtime admission closes. */ + get signal(): AbortSignal { + return this.controller.signal + } + + /** Whether Team runtime admission is closed. */ + get disposed(): boolean { + return this.signal.aborted + } + + /** The exact cancellation reason used to distinguish expected disposal rejection. */ + get reason(): unknown { + const reason: unknown = this.signal.reason + return reason + } + + /** Whether a rejection is the runtime cancellation, directly or through an Error cause chain. */ + private isCancellation(reason: unknown): boolean { + const seen = new Set() + let current = reason + while (!seen.has(current)) { + if (this.disposed && current === this.reason) return true + if (this.disposed && current instanceof TeamError && current.code === 'TEAM_DISPOSED') return true + if (!(current instanceof Error)) return false + seen.add(current) + current = current.cause + } + return false + } + + /** Close Team runtime admission and cancel admitted interruptible work. */ + close(): void { + this.controller.abort(new TeamError('Agent Teams service disposed', 'TEAM_DISPOSED')) + } + + /** + * Await admitted operations and retain failures other than runtime cancellation. + * @param operations - admitted operations captured after the admission cutoff. + * @param failures - aggregate destination for unexpected rejection or timeout. + */ + async settle(operations: readonly Promise[], failures: unknown[]): Promise { + if (operations.length === 0) return + try { + const outcomes = await this.withTimeout(Promise.allSettled(operations)) + for (const outcome of outcomes) { + if (outcome.status === 'rejected' && !this.isCancellation(outcome.reason)) failures.push(outcome.reason) + } + } catch (error: unknown) { + failures.push(error) + } + } + + /** + * Bound one runtime settlement operation. + * @param operation - settlement that may otherwise block HMR or process shutdown. + * @returns the operation result. + */ + async withTimeout(operation: Promise): Promise { + let timer!: ReturnType + const timeout = new Promise((_resolve, reject) => { + timer = setTimeout(() => { + reject(new TeamError( + `Agent Teams runtime disposal exceeded ${this.disposalTimeoutMs}ms`, + 'TEAM_DISPOSAL_TIMEOUT', + )) + }, this.disposalTimeoutMs) + }) + try { + return await Promise.race([operation, timeout]) + } finally { + clearTimeout(timer) + } + } +} diff --git a/packages/experimental/team/src/mailbox.ts b/packages/experimental/team/src/mailbox.ts new file mode 100644 index 0000000000..b234cef408 --- /dev/null +++ b/packages/experimental/team/src/mailbox.ts @@ -0,0 +1,338 @@ +/** Durable Team mailbox admission, target-local dispatch, acknowledgement, and recovery. */ + +import { randomUUID } from 'node:crypto' +import type { Context } from '@deepseek-ai/cordis' +import type { Agent } from '@deepseek-ai/dsh-agent' +import { createUserMessage } from '@deepseek-ai/dsh-llm' +import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import { SessionId } from '@deepseek-ai/dsh-session' +import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' +import { errorMessage, TeamError } from './error.ts' +import type { TeamJournal } from './journal.ts' +import type { TeamRuntimeLifecycle } from './lifecycle.ts' +import type { TeamRoster } from './roster.ts' +import { resolveActiveMember } from './roster.ts' +import { messageAccepted } from './session-message.ts' +import { TeamId, TeamMessageId } from './types.ts' +import type { + SendTeamMessageRequest, + SendTeamMessageResult, + TeamMessageSnapshot, +} from './types.ts' + +/** Owns every process-local state transition for the durable Team mailbox. */ +export class TeamMailbox { + private readonly dispatchTails = new Map>() + private readonly activeDispatches = new Map() + private readonly inFlightMessages = new Set() + private readonly inFlightDispatches = new Set>() + + /** + * @param ctx - Team service context with Agent, Session, persistence, and subagent services. + * @param journal - authoritative Lead-log transaction owner. + * @param roster - Team membership and member-name resolver. + * @param lifecycle - shared Team runtime admission cutoff. + * @param maxPendingMessagesPerMember - per-target queued-minus-delivered limit. + * @param maxMessageBytes - maximum complete sender-framed delivery size. + */ + constructor( + private readonly ctx: Context, + private readonly journal: TeamJournal, + private readonly roster: TeamRoster, + private readonly lifecycle: TeamRuntimeLifecycle, + private readonly maxPendingMessagesPerMember: number, + private readonly maxMessageBytes: number, + ) {} + + /** + * Queue one durable peer message, then attempt immediate delivery. + * @param caller - exact live sending Team member. + * @param request - target name, content, scheduling mode, and pre-queue cancellation. + * @returns durable message identity and immediate-delivery observation. + */ + async send(caller: Agent, request: SendTeamMessageRequest): Promise { + if (this.lifecycle.disposed) throw new TeamError('Agent Teams service is disposing', 'TEAM_DISPOSED') + const operation = this.sendAdmitted(caller, { + ...request, + signal: AbortSignal.any([request.signal, this.lifecycle.signal]), + }) + return await this.trackDispatch(operation) + } + + /** + * Observe target-side durable receipts and checkpoint their Lead-log acknowledgement. + * @param session - exact target Session receiving the event. + * @param event - newly appended Session event. + */ + observeSessionEvent(session: Session, event: SessionEvent): void { + if (this.lifecycle.disposed || event.type !== 'user/message' || event.data.source.kind !== 'team-message') return + const source = event.data.source + const acknowledgement = Promise.resolve().then(async () => { + const root = this.ctx.agents.get(SessionId(source.teamId)) + if (root !== undefined) await this.checkpointDelivered(root, session, source.messageId) + }).catch((error: unknown) => { + this.ctx.logger.warn(`Team message "${source.messageId}" acknowledgement failed: ${errorMessage(error)}`) + }) + void this.trackDispatch(acknowledgement) + } + + /** + * Retry durable pending messages relevant to one started Team member. + * @param agent - newly started exact live Agent. + * @param signal - shared runtime cancellation. + */ + async recoverFor(agent: Agent, signal: AbortSignal): Promise { + signal.throwIfAborted() + const membership = this.roster.tryMembership(agent) + if (membership === undefined) return + const state = this.journal.state(membership.root) + const messages = [...state.messages.values()].filter(message => + !state.delivered.has(message.id) + && (membership.role === 'lead' || message.targetId === agent.id)) + for (const message of messages) { + signal.throwIfAborted() + if (membership.role === 'lead' && message.delivery === 'quiet' + && message.targetId !== membership.root.id && this.ctx.agents.get(message.targetId) === undefined) continue + await this.tryDispatch(membership.root, message, signal) + } + } + + /** + * Return admitted dispatch and acknowledgement operations captured for disposal. + * @returns detached snapshot ordered only by Set insertion. + */ + pendingDispatches(): readonly Promise[] { + return [...this.inFlightDispatches] + } + + /** Queue and dispatch one mailbox item admitted before the disposal cutoff. */ + private async sendAdmitted( + caller: Agent, + request: SendTeamMessageRequest, + ): Promise { + const membership = this.roster.membership(caller) + request.signal.throwIfAborted() + const root = membership.root + const content = structuredClone(request.content) + const queued = await this.journal.transact(root.id, async () => { + request.signal.throwIfAborted() + const state = this.journal.state(root) + const target = resolveActiveMember(root, state, request.target) + if (target.id === caller.id) throw new TeamError('a Team member cannot message itself', 'TEAM_SELF_MESSAGE') + const pendingForTarget = [...state.messages.values()].filter(candidate => + candidate.targetId === target.id && !state.delivered.has(candidate.id)).length + if (pendingForTarget >= this.maxPendingMessagesPerMember) { + throw new TeamError( + `teammate "${target.name}" has ${pendingForTarget} pending messages`, + 'TEAM_MAILBOX_FULL', + ) + } + const queued: TeamMessageSnapshot = { + id: TeamMessageId(`team-message-${randomUUID()}`), + senderId: caller.id, + senderName: membership.name, + targetId: target.id, + delivery: request.delivery, + content, + } + if (Buffer.byteLength(JSON.stringify(this.deliveryContent(queued)), 'utf8') > this.maxMessageBytes) { + throw new TeamError(`team message exceeds ${this.maxMessageBytes} bytes`, 'TEAM_MESSAGE_TOO_LARGE') + } + await this.journal.appendAndFlush(root, 'team/message/queued', { + version: 1, + teamId: TeamId(root.id), + message: queued, + }) + // Register dispatch before releasing the root transaction so concurrent + // senders enter the target-local queue in durable mailbox order. + return { message: queued, dispatch: this.tryDispatch(root, queued, request.signal) } + }) + const accepted = await queued.dispatch + return { messageId: queued.message.id, status: accepted ? 'accepted' : 'queued' } + } + + /** Attempt one queued message exactly once in this process at a time. */ + private tryDispatch(root: Agent, message: TeamMessageSnapshot, signal: AbortSignal): Promise { + if (this.lifecycle.disposed) return Promise.resolve(false) + if (this.inFlightMessages.has(message.id)) return Promise.resolve(false) + this.inFlightMessages.add(message.id) + const operation = this.trackDispatch( + this.tryDispatchAdmitted( + root, + message, + AbortSignal.any([signal, this.lifecycle.signal]), + ), + ) + const forget = (): void => { + this.inFlightMessages.delete(message.id) + } + void operation.then(forget, forget) + return operation + } + + /** Track one dispatch transaction through delivery admission or contained failure. */ + private trackDispatch(operation: Promise): Promise { + this.inFlightDispatches.add(operation) + void operation.then(() => { + this.inFlightDispatches.delete(operation) + }, () => { + this.inFlightDispatches.delete(operation) + }) + return operation + } + + /** Attempt one queued message admitted before the service lifecycle cutoff. */ + private async tryDispatchAdmitted( + root: Agent, + message: TeamMessageSnapshot, + signal: AbortSignal, + ): Promise { + const active = this.activeDispatches.get(message.targetId) + const live = message.targetId === root.id ? root : this.ctx.agents.get(message.targetId) + if (active !== undefined && live !== undefined && message.delivery === 'quiet' + && this.messagePrecedes(root, message.id, active.id)) { + return await this.dispatchOnce(root, message, signal) + } + return await this.serializeDispatch(message, () => this.dispatchOnce(root, message, signal)) + } + + /** Serialize delivery admission for one durable target in queued order. */ + private async serializeDispatch( + message: TeamMessageSnapshot, + operation: () => Promise, + ): Promise { + const targetId = message.targetId + const prior = this.dispatchTails.get(targetId) ?? Promise.resolve() + const dispatch = async (): Promise => { + this.activeDispatches.set(targetId, message) + try { + return await operation() + } finally { + this.activeDispatches.delete(targetId) + } + } + /* v8 ignore next -- dispatch tails absorb rejection, so the recovery callback is a fail-safe backstop. */ + const run = prior.then(dispatch, dispatch) + /* v8 ignore next -- dispatchOnce contains delivery failures and serializeDispatch itself does not throw. */ + const tail = run.then(() => undefined, () => undefined) + this.dispatchTails.set(targetId, tail) + try { + return await run + } finally { + if (this.dispatchTails.get(targetId) === tail) this.dispatchTails.delete(targetId) + } + } + + /** Attempt one queued delivery after target-local ordering admits it. */ + private async dispatchOnce(root: Agent, message: TeamMessageSnapshot, signal: AbortSignal): Promise { + try { + const target = message.targetId === root.id ? root : this.ctx.agents.get(message.targetId) + if (target !== undefined && this.targetRecorded(target.session, message.id)) { + return await this.checkpointDelivered(root, target.session, message.id) + } + const source = { + kind: 'team-message' as const, + teamId: TeamId(root.id), + messageId: message.id, + senderId: message.senderId, + senderName: message.senderName, + } + const content = this.deliveryContent(message) + if (message.targetId === root.id) { + const input = createUserMessage({ content, source }) + if (message.delivery === 'wakeup') { + root.followup(input) + return await this.checkpointDelivered(root, root.session, message.id) + } + root.inject(input) + return await this.checkpointDelivered(root, root.session, message.id) + } + if (message.delivery === 'quiet') { + if (target === undefined) return false + target.inject(createUserMessage({ content, source })) + return await this.checkpointDelivered(root, target.session, message.id) + } + if (target === undefined) { + const recorded = await this.persistedTargetRecorded(message.targetId, message.id, signal) + if (recorded === undefined) return false + if (recorded) { + await this.markDelivered(root, message.id, message.targetId) + return true + } + } + await this.ctx.subagents.followup(root, message.targetId, content, { source, signal }) + return target === undefined + ? true + : await this.checkpointDelivered(root, target.session, message.id) + } catch (error: unknown) { + this.ctx.logger.warn(`team message "${message.id}" remains queued: ${errorMessage(error)}`) + return false + } + } + + /** Whether `left` was durably queued before `right` in one Lead log. */ + private messagePrecedes(root: Agent, left: TeamMessageId, right: TeamMessageId): boolean { + const ids = [...this.journal.state(root).messages.keys()] + return ids.indexOf(left) < ids.indexOf(right) + } + + /** Flush one live target receipt before the Lead records its delivered edge. */ + private async checkpointDelivered( + root: Agent, + target: Session, + messageId: TeamMessageId, + ): Promise { + await this.ctx.sessions.flush(target) + if (!this.targetRecorded(target, messageId)) return false + await this.markDelivered(root, messageId, target.id) + return true + } + + /** Record delivery unless the acknowledgement already exists. */ + private async markDelivered(root: Agent, messageId: TeamMessageId, targetId: SessionId): Promise { + await this.journal.transact(root.id, async () => { + const state = this.journal.state(root) + if (state.delivered.has(messageId)) return + const queued = state.messages.get(messageId) + if (queued === undefined || queued.targetId !== targetId) return + await this.journal.appendAndFlush(root, 'team/message/delivered', { + version: 1, + teamId: TeamId(root.id), + messageId, + targetId, + }) + }) + } + + /** Whether a target Session already contains the durable message identity. */ + private targetRecorded(session: Session, messageId: TeamMessageId): boolean { + const suffix = session.events.slice(session.header.seedLength ?? 0) + return messageAccepted(suffix, message => message.source.kind === 'team-message' + && message.source.messageId === messageId) + } + + /** Frame peer content with stable sender and message identity for the receiving model. */ + private deliveryContent(message: TeamMessageSnapshot): ContentBlock[] { + return [ + { type: 'text', text: `Team message ${message.id} from ${message.senderName}:` }, + ...structuredClone(message.content), + ] + } + + /** Inspect an inactive target before cold resume; uncertainty keeps the mailbox queued. */ + private async persistedTargetRecorded( + targetId: SessionId, + messageId: TeamMessageId, + signal: AbortSignal, + ): Promise { + try { + const stored = await this.ctx.sessionPersistence.inspect(targetId, signal) + const suffix = stored.events.slice(stored.meta.seedLength ?? 0) + return messageAccepted(suffix, message => message.source.kind === 'team-message' + && message.source.messageId === messageId) + } catch (error: unknown) { + this.ctx.logger.warn(`cannot inspect Team message target "${targetId}": ${errorMessage(error)}`) + return undefined + } + } +} diff --git a/packages/experimental/team/src/roster.ts b/packages/experimental/team/src/roster.ts new file mode 100644 index 0000000000..2d8d190fce --- /dev/null +++ b/packages/experimental/team/src/roster.ts @@ -0,0 +1,485 @@ +/** Team membership, continuable-child provisioning, and roster-owned teardown. */ + +import { randomUUID } from 'node:crypto' +import type { Context } from '@deepseek-ai/cordis' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { MessageId } from '@deepseek-ai/dsh-llm' +import { SessionId } from '@deepseek-ai/dsh-session' +import { foldSubagentDescriptor } from '@deepseek-ai/dsh-subagent' +import type { ContinuableStart } from '@deepseek-ai/dsh-subagent' +import { errorMessage, TeamError } from './error.ts' +import type { TeamFoldState } from './fold.ts' +import type { TeamJournal } from './journal.ts' +import type { TeamRuntimeLifecycle } from './lifecycle.ts' +import { messageAccepted } from './session-message.ts' +import { TeamId } from './types.ts' +import type { + SpawnTeammateRequest, + SpawnTeammateResult, + TeamMemberSnapshot, + TeamMemberView, +} from './types.ts' +import { requiredText } from './validation.ts' + +const MEMBER_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u + +/** Caller identity inside one implicit Team. */ +export interface TeamMembership { + readonly root: Agent + readonly id: TeamId + readonly role: 'lead' | 'teammate' + readonly name: string +} + +/** + * Resolve one active Team member by model-facing name, including the Lead pseudo-row. + * @param root - exact live Team Lead. + * @param state - current Team fold. + * @param rawName - candidate member name. + * @returns resolved durable id and normalized name. + */ +export function resolveActiveMember( + root: Agent, + state: TeamFoldState, + rawName: string, +): { id: SessionId; name: string } { + const name = rawName.trim() + if (name === 'lead') return { id: root.id, name } + const id = state.memberIdsByName.get(name) + const member = id === undefined ? undefined : state.members.get(id) + if (member === undefined || member.phase !== 'active') { + throw new TeamError(`active teammate "${name}" not found`, 'TEAM_MEMBER_NOT_FOUND') + } + return { id: member.id, name } +} + +/** Owns Team identities and the lifecycle of rostered continuable children. */ +export class TeamRoster { + private readonly inFlightCreations = new Set>() + + /** + * @param ctx - Team service context with Agent, Session, persistence, and subagent services. + * @param journal - authoritative Lead-log transaction owner. + * @param lifecycle - shared Team runtime admission cutoff. + * @param maxMembers - maximum immutable roster entries per Team. + */ + constructor( + private readonly ctx: Context, + private readonly journal: TeamJournal, + private readonly lifecycle: TeamRuntimeLifecycle, + private readonly maxMembers: number, + ) {} + + /** + * Resolve one exact live Agent's Team role. + * @param agent - exact live Agent used as the authority credential. + * @returns its root, Team identity, role, and model-facing name. + */ + membership(agent: Agent): TeamMembership { + const membership = this.tryMembership(agent) + if (membership === undefined) { + throw new TeamError(`agent "${agent.id}" is not a member of an active Agent Team`, 'TEAM_NOT_MEMBER') + } + return membership + } + + /** + * Resolve a caller without throwing for scoped installation and lifecycle observers. + * @param agent - candidate exact live Agent. + * @returns Team membership, or undefined for non-Team subagents and stale identities. + */ + tryMembership(agent: Agent): TeamMembership | undefined { + if (this.ctx.agents.get(agent.id) !== agent) return undefined + try { + const parentId = agent.session.header.parentSession + if (parentId !== undefined) { + const root = this.ctx.agents.get(parentId) + if (root !== undefined) { + const member = this.journal.state(root).members.get(agent.id) + if (member?.phase === 'active' || member?.phase === 'provisioning') { + return { root, id: TeamId(root.id), role: 'teammate', name: member.name } + } + // A direct child outside the durable roster is not a teammate. Ordinary + // host forks are independent roots; subagent descriptors distinguish + // provider-owned workers that must not receive a nested Team identity. + if (this.subagentDescriptor(agent)) return undefined + return { root: agent, id: TeamId(agent.id), role: 'lead', name: 'lead' } + } + } + // A continuation can briefly outlive its parent during child-first teardown. + // Do not reinterpret that durable child as a new implicit root Team. A host- + // resumed ordinary fork has no descriptor in its own suffix and remains a + // valid new root whose inherited Team records fold out by TeamId. + if (this.subagentDescriptor(agent)) return undefined + return { root: agent, id: TeamId(agent.id), role: 'lead', name: 'lead' } + } catch { + // This method is used by lifecycle observers and teardown discovery. A + // malformed durable stream is surfaced by authoritative Team operations; + // the non-throwing probe must not veto unrelated Agent lifecycle edges. + return undefined + } + } + + /** + * List the runtime-enriched roster visible to one Team member. + * @param membership - exact caller membership resolved by this roster. + * @returns Lead and teammate rows in creation order. + */ + list(membership: TeamMembership): TeamMemberView[] { + const { root } = membership + const state = this.journal.state(root) + const result: TeamMemberView[] = [{ + id: root.id, + name: 'lead', + role: 'lead', + status: root.status, + ...root.options.model === undefined ? {} : { model: root.options.model }, + diagnostics: [], + }] + for (const member of state.members.values()) { + const live = this.ctx.agents.get(member.id) + const model = live?.options.model ?? root.options.model + result.push({ + id: member.id, + name: member.name, + role: 'teammate', + status: member.phase === 'failed' + ? 'failed' + : member.phase === 'provisioning' + ? 'provisioning' + : live?.status ?? 'inactive', + description: member.description, + provider: member.provider, + context: member.context, + ...model === undefined ? {} : { model }, + diagnostics: member.error === undefined ? [] : [member.error], + }) + } + return result + } + + /** + * Create one named, continuable direct child of the Team Lead. + * @param caller - exact live Lead Agent. + * @param request - immutable name, description, prompt, context mode, provider, and cancellation. + * @returns the active roster row. + */ + async spawn(caller: Agent, request: SpawnTeammateRequest): Promise { + if (this.lifecycle.disposed) throw new TeamError('Agent Teams service is disposing', 'TEAM_DISPOSED') + const operation = this.spawnAdmitted(caller, request) + this.inFlightCreations.add(operation) + try { + return await operation + } finally { + this.inFlightCreations.delete(operation) + } + } + + /** + * Return admitted creation operations captured for ordered disposal. + * @returns detached snapshot ordered only by Set insertion. + */ + pendingCreations(): readonly Promise[] { + return [...this.inFlightCreations] + } + + /** + * Reconcile provisioning state when one Team member Session starts. + * @param agent - newly started exact live Agent. + * @param signal - shared runtime cancellation. + */ + async recoverFor(agent: Agent, signal: AbortSignal): Promise { + signal.throwIfAborted() + const membership = this.tryMembership(agent) + if (membership?.role === 'lead') await this.reconcileProvisioning(membership.root, signal) + } + + /** + * Interrupt one live teammate turn without clearing its pending inbox. + * @param caller - exact live Lead Agent. + * @param targetName - durable teammate name. + * @returns the target status sampled before cancellation. + */ + interrupt(caller: Agent, targetName: string): { previousStatus: 'running' | 'idle' | 'inactive' } { + const membership = this.membership(caller) + if (membership.role !== 'lead') throw new TeamError('only the Team Lead can interrupt teammates', 'TEAM_LEAD_REQUIRED') + const state = this.journal.state(membership.root) + const target = resolveActiveMember(membership.root, state, targetName) + if (target.id === membership.root.id) throw new TeamError('the Team Lead cannot interrupt itself', 'TEAM_INVALID_TARGET') + const live = this.ctx.agents.get(target.id) + if (live === undefined) return { previousStatus: 'inactive' } + const previousStatus = live.status + this.ctx.subagents.interrupt(target.id, { kind: 'ancestor', agent: caller }) + return { previousStatus } + } + + /** + * Group exact live roster children by their current Lead for runtime teardown. + * @returns each live Lead and the roster child ids currently in the Agent registry. + */ + liveChildrenByRoot(): Map { + const teams = new Map() + for (const agent of this.ctx.agents.list()) { + const rootId = agent.session.header.parentSession + if (rootId === undefined) continue + const root = this.ctx.agents.get(rootId) + if (root === undefined || !this.journal.state(root).members.has(agent.id)) continue + const children = teams.get(root) ?? [] + children.push(agent.id) + teams.set(root, children) + } + return teams + } + + /** + * Release exact teammate Activations through the continuation lifecycle owner. + * @param root - exact live Team Lead authorizing release. + * @param childIds - selected roster child ids. + */ + async stopTeammates(root: Agent, childIds: readonly SessionId[]): Promise { + await this.lifecycle.withTimeout(this.ctx.subagents.drainContinuableChildren(root, childIds)) + } + + /** Perform one creation admitted before the Team runtime disposal cutoff. */ + private async spawnAdmitted( + caller: Agent, + request: SpawnTeammateRequest, + ): Promise { + const membership = this.membership(caller) + if (membership.role !== 'lead') { + throw new TeamError('only the Team Lead can create teammates', 'TEAM_LEAD_REQUIRED') + } + const signal = AbortSignal.any([request.signal, this.lifecycle.signal]) + signal.throwIfAborted() + const root = membership.root + const name = this.memberName(request.name) + const description = requiredText(request.description, 'description', 200) + const childId = SessionId(randomUUID()) + const member: TeamMemberSnapshot = { + id: childId, + name, + description, + provider: requiredText(request.provider, 'provider', 200), + context: request.context, + phase: 'provisioning', + } + + await this.journal.transact(root.id, async () => { + const state = this.journal.state(root) + if (state.memberIdsByName.has(name)) { + throw new TeamError(`teammate name "${name}" was already used in this Team`, 'TEAM_MEMBER_NAME_TAKEN') + } + if (state.members.size >= this.maxMembers) { + throw new TeamError(`Team member limit ${this.maxMembers} reached`, 'TEAM_MEMBER_LIMIT') + } + await this.journal.appendAndFlush(root, 'team/member', { version: 1, teamId: TeamId(root.id), member }) + }) + + let started: ContinuableStart + try { + started = await this.ctx.subagents.startContinuable({ + childId, + provider: request.provider, + label: description, + request: { + prompt: request.prompt, + parent: root, + }, + signal, + }) + await this.checkpointInitialPrompt(childId, started.messageId, signal) + } catch (error: unknown) { + const failed: TeamMemberSnapshot = { + ...member, + phase: 'failed', + error: errorMessage(error), + } + try { + const phase = await this.settleProvisioning(root, failed) + await this.stopTeammates(root, [childId]) + if (phase === 'active') { + throw new TeamError( + `teammate "${name}" became active while its creator reported failure`, + 'TEAM_PROVISIONING_CONFLICT', + { cause: error }, + ) + } + } catch (recordError: unknown) { + throw new AggregateError([error, recordError], 'teammate creation and durable failure recording both failed') + } + throw error + } + const active = { + ...member, + phase: 'active' as const, + } satisfies TeamMemberSnapshot + // Once the continuation accepted its first prompt, it is a real child. If + // this checkpoint fails, keep the in-memory active edge instead of inventing + // an impossible active -> failed transition; restart reconciliation covers + // the provisioning-only durable prefix. + const settledPhase = await this.settleProvisioning(root, active) + if (settledPhase === 'failed') { + const conflict = new TeamError( + `teammate "${name}" was reconciled as failed while creation was in progress`, + 'TEAM_PROVISIONING_CONFLICT', + ) + try { + await this.stopTeammates(root, [childId]) + } catch (cleanupError: unknown) { + /* v8 ignore next -- requires the independently tested HMR settlement conflict and cleanup failure together. */ + throw new AggregateError([conflict, cleanupError], 'provisioning conflict cleanup failed') + } + throw conflict + } + return { member: this.memberView(active) } + } + + /** Flush the accepted initial inbox item before the Lead can commit `active`. */ + private async checkpointInitialPrompt( + childId: SessionId, + messageId: MessageId, + signal: AbortSignal, + ): Promise { + while (true) { + signal.throwIfAborted() + const session = this.ctx.sessions.get(childId) + if (session === undefined) { + const stored = await this.ctx.sessionPersistence.inspect(childId, signal) + const suffix = stored.events.slice(stored.meta.seedLength ?? 0) + if (messageAccepted(suffix, message => message.id === messageId)) return + throw new TeamError( + `teammate "${childId}" initial prompt was not durably accepted`, + 'TEAM_PROVISIONING_CONFLICT', + ) + } + + const progress = Promise.withResolvers() + // Abort can win while the durability flush is still pending; mark the + // later-awaited rejection handled without changing its eventual result. + void progress.promise.catch(() => undefined) + const stopEvent = this.ctx.on('session/event', (candidate) => { + if (candidate === session) progress.resolve() + }) + const stopDisposed = this.ctx.on('session/disposed', (candidate) => { + if (candidate === session) progress.resolve() + }) + const onAbort = (): void => { + const reason: unknown = signal.reason + progress.reject(reason instanceof Error + ? reason + : new TeamError(`teammate creation aborted: ${errorMessage(reason)}`, 'TEAM_DISPOSED')) + } + signal.addEventListener('abort', onAbort, { once: true }) + try { + signal.throwIfAborted() + await this.ctx.sessions.flush(session) + const suffix = session.events.slice(session.header.seedLength ?? 0) + if (messageAccepted(suffix, message => message.id === messageId)) return + if (this.ctx.sessions.get(childId) !== session) continue + await progress.promise + } finally { + signal.removeEventListener('abort', onAbort) + stopDisposed() + stopEvent() + } + } + } + + /** Settle provisioning-only members from their independently durable child Sessions. */ + private async reconcileProvisioning(root: Agent, signal: AbortSignal): Promise { + const provisioning = [...this.journal.state(root).members.values()].filter(member => member.phase === 'provisioning') + for (const member of provisioning) { + signal.throwIfAborted() + // A live child means creation is still completing in this process. Its + // creator owns the terminal member edge. + if (this.ctx.agents.get(member.id) !== undefined) continue + let phase: 'active' | 'failed' = 'failed' + let failure = 'provisioning did not leave a resumable child Session' + try { + const loaded = await this.ctx.sessionPersistence.inspect(member.id, signal) + const suffix = loaded.events.slice(loaded.meta.seedLength ?? 0) + const descriptor = foldSubagentDescriptor(suffix) + const acceptedInitialPrompt = messageAccepted(suffix, message => message.source.kind === 'user') + if (loaded.meta.parentSession === root.id + && descriptor?.mode === 'continuable' + && descriptor.provider === member.provider + && acceptedInitialPrompt) { + phase = 'active' + } else { + failure = 'persisted child Session does not match the provisioned continuation' + } + } catch (error: unknown) { + failure = `child Session recovery failed: ${errorMessage(error)}` + } + signal.throwIfAborted() + await this.journal.transact(root.id, async () => { + signal.throwIfAborted() + const current = this.journal.state(root).members.get(member.id) + if (current?.phase !== 'provisioning') return + const settled: TeamMemberSnapshot = { + ...current, + phase, + ...phase === 'failed' ? { error: failure } : {}, + } + await this.journal.appendAndFlush(root, 'team/member', { + version: 1, + teamId: TeamId(root.id), + member: settled, + }) + }) + } + } + + /** Build one runtime member row after successful creation. */ + private memberView(member: TeamMemberSnapshot & { readonly phase: 'active' }): TeamMemberView { + const live = this.ctx.agents.get(member.id) + return { + id: member.id, + name: member.name, + role: 'teammate', + status: live?.status ?? 'inactive', + description: member.description, + provider: member.provider, + context: member.context, + ...live?.options.model === undefined ? {} : { model: live.options.model }, + diagnostics: [], + } + } + + /** Validate a never-reused model-facing teammate name. */ + private memberName(value: string): string { + if (!MEMBER_NAME.test(value) || value.length > 64 || value === 'lead') { + throw new TeamError( + 'teammate name must be lower-kebab-case, at most 64 characters, and not "lead"', + 'TEAM_INVALID_MEMBER_NAME', + ) + } + return value + } + + /** Append one terminal provisioning edge unless recovery already settled it. */ + private async settleProvisioning( + root: Agent, + terminal: TeamMemberSnapshot, + ): Promise<'active' | 'failed'> { + return this.journal.transact(root.id, async () => { + const current = this.journal.state(root).members.get(terminal.id) + /* v8 ignore next 3 -- the append-only provisioning event is committed by this operation before settlement. */ + if (current === undefined) { + throw new TeamError(`provisioned teammate "${terminal.id}" disappeared`, 'TEAM_PROVISIONING_CONFLICT') + } + if (current.phase !== 'provisioning') return current.phase + await this.journal.appendAndFlush(root, 'team/member', { + version: 1, + teamId: TeamId(root.id), + member: terminal, + }) + return terminal.phase === 'active' ? 'active' : 'failed' + }) + } + + /** Whether a Session's own suffix identifies a provider-owned subagent child. */ + private subagentDescriptor(agent: Agent): boolean { + return foldSubagentDescriptor(agent.session.events.slice(agent.session.header.seedLength ?? 0)) !== undefined + } +} diff --git a/packages/experimental/team/src/session-message.ts b/packages/experimental/team/src/session-message.ts new file mode 100644 index 0000000000..1942c37352 --- /dev/null +++ b/packages/experimental/team/src/session-message.ts @@ -0,0 +1,31 @@ +/** Durable Session-message acceptance checks shared by provisioning and mailbox recovery. */ + +import type { UserMessage } from '@deepseek-ai/dsh-llm' +import type { SessionEvent } from '@deepseek-ai/dsh-session' + +type InboxProjection = Record<'next-turn' | 'next-step', UserMessage[]> + +/** Fold the durable inbox suffix into the messages still awaiting a claim. */ +function pendingInboxMessages(events: readonly SessionEvent[]): UserMessage[] { + const inbox: InboxProjection = { 'next-turn': [], 'next-step': [] } + for (const event of events) { + if (event.type !== 'agent/inbox/spliced') continue + const pending = inbox[event.data.target] + pending.splice(event.data.start, event.data.removedCount ?? 0, ...event.data.inserted) + } + return [...inbox['next-turn'], ...inbox['next-step']] +} + +/** + * Test whether one message is model-visible or still durably pending. + * @param events - one Session's non-inherited event suffix. + * @param predicate - identity check for the accepted message. + * @returns whether history or the current inbox contains a match. + */ +export function messageAccepted( + events: readonly SessionEvent[], + predicate: (message: UserMessage) => boolean, +): boolean { + return events.some(event => event.type === 'user/message' && predicate(event.data)) + || pendingInboxMessages(events).some(predicate) +} diff --git a/packages/experimental/team/src/task-board.ts b/packages/experimental/team/src/task-board.ts new file mode 100644 index 0000000000..f987dd9217 --- /dev/null +++ b/packages/experimental/team/src/task-board.ts @@ -0,0 +1,297 @@ +/** Shared Team task DAG commands and runtime-enriched views. */ + +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { TeamMembership } from './roster.ts' +import { TeamError } from './error.ts' +import type { TeamFoldState } from './fold.ts' +import type { TeamJournal } from './journal.ts' +import { resolveActiveMember } from './roster.ts' +import { assertTaskGraphCandidate, TeamTaskGraphError } from './task-graph.ts' +import type { TeamTaskGraphViolation } from './task-graph.ts' +import { TeamId, TeamTaskId } from './types.ts' +import type { + CreateTeamTaskRequest, + TeamTaskSnapshot, + TeamTaskView, + UpdateTeamTaskRequest, +} from './types.ts' +import { requiredText, writeScope } from './validation.ts' + +/** Whether two normalized file or directory prefixes overlap on path components. */ +function scopesOverlap(left: string, right: string): boolean { + return left === right || left.startsWith(`${right}/`) || right.startsWith(`${left}/`) +} + +const TASK_GRAPH_ERROR_CODES: Record = { + missing: 'TEAM_TASK_NOT_FOUND', + duplicate: 'TEAM_INVALID_ARGUMENT', + cycle: 'TEAM_TASK_DEPENDENCY_CYCLE', +} + +/** Owns Team task limits, authorization, transitions, and derived views. */ +export class TeamTaskBoard { + /** + * @param journal - authoritative Lead-log transaction owner. + * @param maxTasks - maximum non-deleted tasks retained by one Team. + */ + constructor( + private readonly journal: TeamJournal, + private readonly maxTasks: number, + ) {} + + /** + * Create one unowned pending task in the Team Lead log. + * @param membership - exact caller membership resolved by the Team roster. + * @param request - task text, blockers, and advisory write scopes. + * @returns the revision-one task view. + */ + async create(membership: TeamMembership, request: CreateTeamTaskRequest): Promise { + const { root } = membership + return this.journal.transact(root.id, async () => { + const state = this.journal.state(root) + const active = [...state.tasks.values()].filter(task => task.status !== 'deleted').length + if (active >= this.maxTasks) { + throw new TeamError(`Team task limit ${this.maxTasks} reached`, 'TEAM_TASK_LIMIT') + } + const id = TeamTaskId(`task-${state.nextTaskNumber}`) + if (state.tasks.has(id)) { + throw new TeamError('Team task id space exhausted', 'TEAM_TASK_LIMIT') + } + const task: TeamTaskSnapshot = { + id, + revision: 1, + subject: requiredText(request.subject, 'subject', 200), + description: requiredText(request.description, 'description', 16_384), + status: 'pending', + blockedBy: this.dependencies(request.blockedBy ?? [], state), + writeScopes: this.writeScopes(request.writeScopes ?? []), + } + this.assertTaskGraph(state, task) + await this.journal.appendAndFlush(root, 'team/task', { version: 1, teamId: TeamId(root.id), task }) + return this.taskView(root, state, task) + }) + } + + /** + * Return one task, including a deleted tombstone. + * @param membership - exact caller membership resolved by the Team roster. + * @param id - Team-local task identity. + * @returns the latest task value and derived readiness diagnostics. + */ + get(membership: TeamMembership, id: TeamTaskId): TeamTaskView { + const { root } = membership + const state = this.journal.state(root) + const task = state.tasks.get(id) + if (task === undefined) throw new TeamError(`team task "${id}" not found`, 'TEAM_TASK_NOT_FOUND') + return this.taskView(root, state, task) + } + + /** + * List current non-deleted tasks in numeric creation order. + * @param membership - exact caller membership resolved by the Team roster. + * @returns detached current task views. + */ + list(membership: TeamMembership): TeamTaskView[] { + const { root } = membership + const state = this.journal.state(root) + return [...state.tasks.values()] + .filter(task => task.status !== 'deleted') + .map(task => this.taskView(root, state, task)) + } + + /** + * Compare-and-set one authorized task transition. + * @param caller - exact live Team member authorizing the mutation. + * @param membership - caller role and exact live Lead. + * @param request - task identity, expected revision, action, and action fields. + * @returns the committed next task revision. + */ + async update( + caller: Agent, + membership: TeamMembership, + request: UpdateTeamTaskRequest, + ): Promise { + const root = membership.root + return this.journal.transact(root.id, async () => { + const state = this.journal.state(root) + const current = state.tasks.get(request.taskId) + if (current === undefined) throw new TeamError(`team task "${request.taskId}" not found`, 'TEAM_TASK_NOT_FOUND') + if (current.revision !== request.expectedRevision) { + throw new TeamError( + `stale team task "${current.id}" revision ${request.expectedRevision}; current revision is ${current.revision}`, + 'TEAM_TASK_STALE_REVISION', + ) + } + if (current.status === 'deleted') throw new TeamError(`team task "${current.id}" is deleted`, 'TEAM_TASK_DELETED') + const lead = membership.role === 'lead' + const owner = current.ownerId === caller.id + const authorizeOwner = (): void => { + if (!lead && !owner) throw new TeamError('task mutation requires its owner or Team Lead', 'TEAM_TASK_UNAUTHORIZED') + } + let next: TeamTaskSnapshot + switch (request.action) { + case 'claim': + if (current.ownerId !== undefined && current.ownerId !== caller.id) { + throw new TeamError(`team task "${current.id}" is owned by another member`, 'TEAM_TASK_ALREADY_CLAIMED') + } + if (current.status !== 'pending' || !this.taskReady(state, current)) { + throw new TeamError(`team task "${current.id}" is not ready to claim`, 'TEAM_TASK_BLOCKED') + } + next = { ...current, status: 'in_progress', ownerId: caller.id } + break + case 'release': + authorizeOwner() + if (current.status !== 'in_progress') throw new TeamError('only an in-progress task can be released', 'TEAM_TASK_INVALID_TRANSITION') + next = this.withoutOwner({ ...current, status: 'pending' }) + break + case 'edit': + authorizeOwner() + if (request.subject === undefined && request.description === undefined && request.writeScopes === undefined) { + throw new TeamError('task edit requires subject, description, or write_scopes', 'TEAM_INVALID_ARGUMENT') + } + next = { + ...current, + ...request.subject === undefined ? {} : { subject: requiredText(request.subject, 'subject', 200) }, + ...request.description === undefined + ? {} + : { description: requiredText(request.description, 'description', 16_384) }, + ...request.writeScopes === undefined ? {} : { writeScopes: this.writeScopes(request.writeScopes) }, + } + break + case 'set_dependencies': + authorizeOwner() + if (request.blockedBy === undefined) throw new TeamError('set_dependencies requires blocked_by', 'TEAM_INVALID_ARGUMENT') + next = { ...current, blockedBy: this.dependencies(request.blockedBy, state, current.id) } + break + case 'complete': + authorizeOwner() + if (current.status !== 'in_progress') throw new TeamError('only an in-progress task can complete', 'TEAM_TASK_INVALID_TRANSITION') + next = { ...current, status: 'completed' } + break + case 'reopen': + authorizeOwner() + if (current.status !== 'completed') throw new TeamError('only a completed task can reopen', 'TEAM_TASK_INVALID_TRANSITION') + next = this.withoutOwner({ ...current, status: 'pending' }) + break + case 'reassign': { + if (!lead) throw new TeamError('only the Team Lead can reassign tasks', 'TEAM_LEAD_REQUIRED') + if (current.status !== 'pending' && current.status !== 'in_progress') { + throw new TeamError( + 'only a pending or in-progress task can be reassigned', + 'TEAM_TASK_INVALID_TRANSITION', + ) + } + if (request.owner === undefined || request.owner.trim().length === 0) { + next = this.withoutOwner({ ...current, status: 'pending' }) + break + } + if (!this.taskReady(state, current)) throw new TeamError(`team task "${current.id}" is blocked`, 'TEAM_TASK_BLOCKED') + const assignee = resolveActiveMember(root, state, request.owner) + next = { ...current, status: 'in_progress', ownerId: assignee.id } + break + } + case 'delete': { + authorizeOwner() + const dependent = [...state.tasks.values()].find(task => + task.status !== 'deleted' && task.id !== current.id && task.blockedBy.includes(current.id)) + if (dependent !== undefined) { + throw new TeamError(`team task "${current.id}" still blocks "${dependent.id}"`, 'TEAM_TASK_HAS_DEPENDENTS') + } + next = { ...current, status: 'deleted' } + break + } + /* v8 ignore next 2 -- TeamTaskAction is closed and every member is handled above. */ + default: + throw new TeamError(`unsupported task action ${String(request.action)}`, 'TEAM_INVALID_ARGUMENT') + } + const task: TeamTaskSnapshot = { + ...next, + revision: current.revision + 1, + } + this.assertTaskGraph(state, task) + await this.journal.appendAndFlush(root, 'team/task', { version: 1, teamId: TeamId(root.id), task }) + return this.taskView(root, state, task) + }) + } + + /** Validate and de-duplicate dependency ids against the current task graph. */ + private dependencies( + values: readonly TeamTaskId[], + state: TeamFoldState, + self?: TeamTaskId, + ): TeamTaskId[] { + const seen = new Set() + const result: TeamTaskId[] = [] + for (const id of values) { + if (id === self) throw new TeamError('a team task cannot block itself', 'TEAM_TASK_DEPENDENCY_CYCLE') + if (seen.has(id)) throw new TeamError(`duplicate blocker "${id}"`, 'TEAM_INVALID_ARGUMENT') + const task = state.tasks.get(id) + if (task === undefined || task.status === 'deleted') { + throw new TeamError(`blocker task "${id}" not found`, 'TEAM_TASK_NOT_FOUND') + } + seen.add(id) + result.push(id) + } + return result + } + + /** Normalize and de-duplicate task write scopes. */ + private writeScopes(values: readonly string[]): string[] { + return [...new Set(values.map(writeScope))] + } + + /** Map shared task-graph validation onto stable command error codes. */ + private assertTaskGraph(state: TeamFoldState, candidate: TeamTaskSnapshot): void { + try { + assertTaskGraphCandidate(state.tasks, candidate) + } catch (error: unknown) { + /* v8 ignore next -- the shared validator is the only statement in the try and throws this exact error. */ + if (!(error instanceof TeamTaskGraphError)) throw error + throw new TeamError(error.message, TASK_GRAPH_ERROR_CODES[error.violation], { cause: error }) + } + } + + /** Whether all current blockers completed. */ + private taskReady(state: TeamFoldState, task: TeamTaskSnapshot): boolean { + return task.blockedBy.every(id => state.tasks.get(id)?.status === 'completed') + } + + /** Remove an optional owner field under exactOptionalPropertyTypes. */ + private withoutOwner(task: TeamTaskSnapshot): TeamTaskSnapshot { + const { ownerId: _ownerId, ...without } = task + return without + } + + /** + * Build one task view with owner name, readiness, and advisory write overlaps. + * A committing caller may pass its pre-append fold because `task` supplies the + * new value explicitly; owner names, blocker readiness, and other task scopes + * do not change when that snapshot is appended. + */ + private taskView(root: Agent, state: TeamFoldState, task: TeamTaskSnapshot): TeamTaskView { + const ownerName = task.ownerId === undefined + ? undefined + : task.ownerId === root.id + ? 'lead' + : state.members.get(task.ownerId)?.name + const warnings = new Set() + for (const other of state.tasks.values()) { + if (other.id === task.id || other.status !== 'in_progress') continue + if (task.writeScopes.some(left => other.writeScopes.some(right => scopesOverlap(left, right)))) { + warnings.add(`write scopes overlap with ${other.id}`) + } + } + return { + id: task.id, + revision: task.revision, + subject: task.subject, + description: task.description, + status: task.status, + blockedBy: structuredClone(task.blockedBy), + writeScopes: structuredClone(task.writeScopes), + ...ownerName === undefined ? {} : { ownerName }, + ready: task.status === 'pending' && this.taskReady(state, task), + writeScopeWarnings: [...warnings], + } + } +} diff --git a/packages/experimental/team/src/task-graph.ts b/packages/experimental/team/src/task-graph.ts new file mode 100644 index 0000000000..38d9dd144c --- /dev/null +++ b/packages/experimental/team/src/task-graph.ts @@ -0,0 +1,69 @@ +/** Complete dependency validation for current Team task snapshots. */ + +import type { TeamTaskId, TeamTaskSnapshot } from './types.ts' + +/** Task dependency relation rejected by the shared graph validator. */ +export type TeamTaskGraphViolation = 'missing' | 'duplicate' | 'cycle' + +/** Package-private task dependency failure retained for command error mapping. */ +export class TeamTaskGraphError extends Error { + /** + * @param message - concrete invalid dependency relation. + * @param violation - stable relation category used by Team commands. + */ + constructor(message: string, readonly violation: TeamTaskGraphViolation) { + super(message) + this.name = 'TeamTaskGraphError' + } +} + +/** + * Validate the complete active task graph after replacing one candidate snapshot. + * @param current - current task snapshots before the candidate event. + * @param candidate - new or next-revision task snapshot. + * @throws {TeamTaskGraphError} when an active dependency is missing, duplicated, self-referential, or cyclic. + */ +export function assertTaskGraphCandidate( + current: ReadonlyMap, + candidate: TeamTaskSnapshot, +): void { + const tasks = new Map(current) + tasks.set(candidate.id, candidate) + + for (const task of tasks.values()) { + if (task.status === 'deleted') continue + const seen = new Set() + for (const blockerId of task.blockedBy) { + if (blockerId === task.id) { + throw new TeamTaskGraphError(`team task "${task.id}" cannot block itself`, 'cycle') + } + if (seen.has(blockerId)) { + throw new TeamTaskGraphError(`team task "${task.id}" repeats blocker "${blockerId}"`, 'duplicate') + } + const blocker = tasks.get(blockerId) + if (blocker === undefined || blocker.status === 'deleted') { + throw new TeamTaskGraphError( + `blocker task "${blockerId}" for "${task.id}" is missing or deleted`, + 'missing', + ) + } + seen.add(blockerId) + } + } + + const visiting = new Set() + const visited = new Set() + const visit = (id: TeamTaskId): void => { + if (visiting.has(id)) { + throw new TeamTaskGraphError(`task dependency cycle includes "${id}"`, 'cycle') + } + if (visited.has(id)) return + const task = tasks.get(id) + if (task === undefined || task.status === 'deleted') return + visiting.add(id) + for (const blockerId of task.blockedBy) visit(blockerId) + visiting.delete(id) + visited.add(id) + } + for (const task of tasks.values()) visit(task.id) +} diff --git a/packages/experimental/team/src/types.ts b/packages/experimental/team/src/types.ts new file mode 100644 index 0000000000..cee2b1064a --- /dev/null +++ b/packages/experimental/team/src/types.ts @@ -0,0 +1,219 @@ +/** Public Agent Teams identities, durable records, and service request values. */ + +import type { Branded } from '@deepseek-ai/dsh-brand' +import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import type { SessionId } from '@deepseek-ai/dsh-session' + +/** Identifies the implicit team rooted at one top-level Session. */ +export type TeamId = Branded<'TeamId'> + +/** + * Brand one root Session identity as its implicit Team identity. + * @param id - Root Session identity. + * @returns the same string branded as a Team identity. + */ +export function TeamId(id: SessionId | string): TeamId { + return id as TeamId +} + +/** Stable identifier for one task in a Team. */ +export type TeamTaskId = Branded<'TeamTaskId'> + +/** + * Brand a validated task id. + * @param id - Team-local task identity. + * @returns the same string branded as a Team task identity. + */ +export function TeamTaskId(id: string): TeamTaskId { + return id as TeamTaskId +} + +/** Stable identifier for one durable peer message. */ +export type TeamMessageId = Branded<'TeamMessageId'> + +/** + * Brand a generated peer-message id. + * @param id - Durable mailbox message identity. + * @returns the same string branded as a Team message identity. + */ +export function TeamMessageId(id: string): TeamMessageId { + return id as TeamMessageId +} + +/** Durable teammate lifecycle. */ +export type TeamMemberPhase = 'provisioning' | 'active' | 'failed' + +/** Whole durable value written on every teammate lifecycle change. */ +export interface TeamMemberSnapshot { + readonly id: SessionId + readonly name: string + readonly description: string + readonly provider: string + readonly context: 'fresh' | 'fork' + readonly phase: TeamMemberPhase + readonly error?: string +} + +/** Current runtime-enriched roster row. */ +export interface TeamMemberView { + readonly id: SessionId + readonly name: string + readonly role: 'lead' | 'teammate' + readonly status: 'running' | 'idle' | 'inactive' | 'provisioning' | 'failed' + readonly description?: string + readonly provider?: string + readonly context?: 'fresh' | 'fork' + readonly model?: string + readonly diagnostics: string[] +} + +/** Durable task lifecycle. */ +export type TeamTaskStatus = 'pending' | 'in_progress' | 'completed' | 'deleted' + +/** Whole durable task snapshot; every mutation increments {@link revision}. */ +export interface TeamTaskSnapshot { + readonly id: TeamTaskId + readonly revision: number + readonly subject: string + readonly description: string + readonly status: TeamTaskStatus + readonly ownerId?: SessionId + readonly blockedBy: TeamTaskId[] + readonly writeScopes: string[] +} + +/** Runtime-enriched task view returned to tools and hosts. */ +export interface TeamTaskView { + readonly id: TeamTaskId + readonly revision: number + readonly subject: string + readonly description: string + readonly status: TeamTaskStatus + readonly blockedBy: TeamTaskId[] + readonly writeScopes: string[] + readonly ownerName?: string + readonly ready: boolean + readonly writeScopeWarnings: string[] +} + +/** One peer message retained until its target Session records it. */ +export interface TeamMessageSnapshot { + readonly id: TeamMessageId + readonly senderId: SessionId + readonly senderName: string + readonly targetId: SessionId + readonly delivery: 'quiet' | 'wakeup' + readonly content: ContentBlock[] +} + +/** Source retained by the target Session for durable mailbox de-duplication. */ +export interface TeamMessageSource { + readonly kind: 'team-message' + readonly teamId: TeamId + readonly messageId: TeamMessageId + readonly senderId: SessionId + readonly senderName: string +} + +declare module '@deepseek-ai/dsh-llm' { + interface MessageSourceMap { + 'team-message': TeamMessageSource + } +} + +/** Team-service deployment limits. */ +export interface Config { + /** Maximum immutable teammate names retained by one Team. */ + readonly maxMembers?: number + /** Maximum non-deleted tasks retained by one Team. */ + readonly maxTasks?: number + /** Maximum queued-minus-delivered messages for one target member. */ + readonly maxPendingMessagesPerMember?: number + /** Maximum UTF-8 bytes in one complete sender-framed delivery. */ + readonly maxMessageBytes?: number + /** Maximum milliseconds allowed for Team-owned runtime disposal. */ + readonly disposalTimeoutMs?: number +} + +/** Input for creating one durable teammate. */ +export interface SpawnTeammateRequest { + readonly name: string + readonly description: string + readonly prompt: ContentBlock[] + readonly context: 'fresh' | 'fork' + readonly provider: string + readonly signal: AbortSignal +} + +/** Result after one teammate reaches a durable active or failed edge. */ +export interface SpawnTeammateResult { + readonly member: TeamMemberView +} + +/** Input for one durable peer message. */ +export interface SendTeamMessageRequest { + readonly target: string + readonly content: ContentBlock[] + readonly delivery: 'quiet' | 'wakeup' + readonly signal: AbortSignal +} + +/** Result after a peer message enters the durable mailbox. */ +export interface SendTeamMessageResult { + readonly messageId: TeamMessageId + readonly status: 'accepted' | 'queued' +} + +/** Input for creating one shared task. */ +export interface CreateTeamTaskRequest { + readonly subject: string + readonly description: string + readonly blockedBy?: readonly TeamTaskId[] + readonly writeScopes?: readonly string[] +} + +/** Supported task mutation actions. */ +export type TeamTaskAction = + | 'claim' + | 'release' + | 'edit' + | 'set_dependencies' + | 'complete' + | 'reopen' + | 'reassign' + | 'delete' + +/** Compare-and-set mutation of one shared task. */ +export interface UpdateTeamTaskRequest { + readonly taskId: TeamTaskId + readonly expectedRevision: number + readonly action: TeamTaskAction + readonly subject?: string + readonly description?: string + readonly blockedBy?: readonly TeamTaskId[] + readonly writeScopes?: readonly string[] + readonly owner?: string +} + +/** Result of waiting for Team activity. */ +export interface TeamWaitResult { + readonly timedOut: boolean +} + +declare module '@deepseek-ai/dsh-session/types' { + interface SessionEventMap { + /** Whole teammate lifecycle value, stored only in the Team Lead Session. */ + 'team/member': { version: 1; teamId: TeamId; member: TeamMemberSnapshot } + /** Whole shared-task value, stored only in the Team Lead Session. */ + 'team/task': { version: 1; teamId: TeamId; task: TeamTaskSnapshot } + /** Durable mailbox enqueue, stored before delivery is attempted. */ + 'team/message/queued': { version: 1; teamId: TeamId; message: TeamMessageSnapshot } + /** Durable acknowledgement that the target Session recorded the message. */ + 'team/message/delivered': { + version: 1 + teamId: TeamId + messageId: TeamMessageId + targetId: SessionId + } + } +} diff --git a/packages/experimental/team/src/validation.ts b/packages/experimental/team/src/validation.ts new file mode 100644 index 0000000000..01de64f57c --- /dev/null +++ b/packages/experimental/team/src/validation.ts @@ -0,0 +1,34 @@ +/** Input normalization shared by Team roster and task commands. */ + +import { TeamError } from './error.ts' + +/** + * Normalize one required human-authored string. + * @param value - raw input value. + * @param field - diagnostic field name. + * @param maxLength - maximum normalized character count. + * @returns trimmed non-empty text. + */ +export function requiredText(value: string, field: string, maxLength: number): string { + const text = value.trim() + if (text.length === 0) throw new TeamError(`${field} must be non-empty`, 'TEAM_INVALID_ARGUMENT') + if (text.length > maxLength) { + throw new TeamError(`${field} exceeds ${maxLength} characters`, 'TEAM_INVALID_ARGUMENT') + } + return text +} + +/** + * Normalize one workspace-relative path prefix without treating it as a lock. + * @param value - user-authored path prefix. + * @returns normalized slash-separated prefix. + */ +export function writeScope(value: string): string { + const normalized = value.replaceAll('\\', '/').replace(/^\.\//u, '').replace(/\/+$/u, '') + const segments = normalized.split('/') + if (normalized.length === 0 || normalized.startsWith('/') || /^[a-z]:/iu.test(normalized) + || segments.some(segment => segment.length === 0 || segment === '.' || segment === '..')) { + throw new TeamError(`invalid workspace-relative write scope ${JSON.stringify(value)}`, 'TEAM_INVALID_WRITE_SCOPE') + } + return normalized +} diff --git a/packages/experimental/team/tests/fold.spec.ts b/packages/experimental/team/tests/fold.spec.ts new file mode 100644 index 0000000000..d1ed095a99 --- /dev/null +++ b/packages/experimental/team/tests/fold.spec.ts @@ -0,0 +1,325 @@ +import { describe, expect, it } from 'vitest' +import { SessionId } from '@deepseek-ai/dsh-session' +import type { SessionEvent, SessionEventMap, SessionEventType } from '@deepseek-ai/dsh-session' +import { + applyTeamEvent, + emptyTeamFoldState, + foldTeam, + isTeamEvent, +} from '../src/fold.ts' +import type { TeamFoldState } from '../src/fold.ts' +import { TeamId, TeamMessageId, TeamTaskId } from '../src/types.ts' +import type { TeamMemberSnapshot, TeamMessageSnapshot, TeamTaskSnapshot } from '../src/types.ts' + +const ROOT = SessionId('team-root') +const TEAM = TeamId(ROOT) +const CHILD = SessionId('child-a') + +function event(type: T, data: SessionEventMap[T], seq: number): SessionEvent { + return { type, data, seq, time: seq } as SessionEvent +} + +/** Queued-minus-delivered mail, the recovery mailbox the fold is responsible for. */ +function pending(state: TeamFoldState): TeamMessageSnapshot[] { + return [...state.messages.values()].filter(message => !state.delivered.has(message.id)) +} + +/** Whether one fold reached the end of its log without applying any Team record. */ +function isEmptyFold(state: TeamFoldState): boolean { + return state.members.size === 0 && state.tasks.size === 0 + && state.messages.size === 0 && state.delivered.size === 0 +} + +function member(overrides: Partial = {}): TeamMemberSnapshot { + return { + id: CHILD, + name: 'worker-a', + description: 'worker', + provider: 'spawn', + context: 'fresh', + phase: 'provisioning', + ...overrides, + } +} + +function task(overrides: Partial = {}): TeamTaskSnapshot { + return { + id: TeamTaskId('task-1'), + revision: 1, + subject: 'subject', + description: 'description', + status: 'pending', + blockedBy: [], + writeScopes: [], + ...overrides, + } +} + +function message(overrides: Partial = {}): TeamMessageSnapshot { + return { + id: TeamMessageId('message-1'), + senderId: ROOT, + senderName: 'lead', + targetId: CHILD, + delivery: 'quiet', + content: [{ type: 'text', text: 'hello' }], + ...overrides, + } +} + +describe('Agent Teams fold', () => { + it('folds current-team records and ignores inherited records', () => { + const records: SessionEvent[] = [ + event('team/member', { version: 1, teamId: TeamId('ancestor'), member: member() }, 0), + event('team/member', { version: 1, teamId: TEAM, member: member() }, 1), + event('team/member', { + version: 1, + teamId: TEAM, + member: member({ phase: 'active' }), + }, 2), + event('team/task', { version: 1, teamId: TEAM, task: task({ id: TeamTaskId('task-7') }) }, 3), + event('team/message/queued', { version: 1, teamId: TEAM, message: message() }, 4), + ] + const state = foldTeam(ROOT, records) + + expect(state).toMatchObject({ id: TEAM }) + expect(state.members.size).toBe(1) + expect(state.tasks.size).toBe(1) + expect(pending(state)).toHaveLength(1) + expect(state.nextTaskNumber).toBe(8) + expect(state.members.get(CHILD)?.name).toBe('worker-a') + expect(isTeamEvent(records[0]!)).toBe(true) + expect(isTeamEvent(event('turn/start', { turn: 1 }, 5))).toBe(false) + }) + + it('enforces teammate identity and lifecycle', () => { + const base = event('team/member', { version: 1, teamId: TEAM, member: member() }, 0) + expect(() => foldTeam(ROOT, [event('team/member', { + version: 1, + teamId: TEAM, + member: member({ phase: 'active' }), + }, 0)])).toThrow(/must begin provisioning/) + expect(() => foldTeam(ROOT, [base, event('team/member', { + version: 1, + teamId: TEAM, + member: member({ name: 'renamed', phase: 'active' }), + }, 1)])).toThrow(/immutable identity/) + expect(() => foldTeam(ROOT, [base, event('team/member', { + version: 1, + teamId: TEAM, + member: member({ phase: 'active' }), + }, 1), event('team/member', { + version: 1, + teamId: TEAM, + member: member({ phase: 'failed' }), + }, 2)])).toThrow(/invalid active -> failed/) + + const duplicateName = member({ id: SessionId('child-b') }) + expect(() => foldTeam(ROOT, [base, event('team/member', { + version: 1, + teamId: TEAM, + member: duplicateName, + }, 1)])).toThrow(/name .* reused/) + }) + + it('enforces task revision continuity', () => { + const first = event('team/task', { version: 1, teamId: TEAM, task: task() }, 0) + expect(() => foldTeam(ROOT, [event('team/task', { + version: 1, + teamId: TEAM, + task: task({ revision: 2 }), + }, 0)])).toThrow(/begin at revision 1/) + expect(() => foldTeam(ROOT, [first, event('team/task', { + version: 1, + teamId: TEAM, + task: task({ revision: 3 }), + }, 1)])).toThrow(/revision is not contiguous/) + }) + + it('rejects every invalid persisted task dependency relation', () => { + const first = event('team/task', { version: 1, teamId: TEAM, task: task() }, 0) + const second = event('team/task', { + version: 1, + teamId: TEAM, + task: task({ + id: TeamTaskId('task-2'), + blockedBy: [TeamTaskId('task-1')], + }), + }, 1) + const invalid: Array<{ records: SessionEvent[]; message: RegExp }> = [ + { + records: [event('team/task', { + version: 1, + teamId: TEAM, + task: task({ blockedBy: [TeamTaskId('missing')] }), + }, 0)], + message: /blocker task "missing" .* is missing or deleted/, + }, + { + records: [event('team/task', { + version: 1, + teamId: TEAM, + task: task({ blockedBy: [TeamTaskId('task-1')] }), + }, 0)], + message: /cannot block itself/, + }, + { + records: [first, event('team/task', { + ...second.data, + task: { ...second.data.task, blockedBy: [TeamTaskId('task-1'), TeamTaskId('task-1')] }, + }, 1)], + message: /repeats blocker/, + }, + { + records: [first, second, event('team/task', { + version: 1, + teamId: TEAM, + task: task({ revision: 2, blockedBy: [TeamTaskId('task-2')] }), + }, 2)], + message: /dependency cycle/, + }, + { + records: [first, second, event('team/task', { + version: 1, + teamId: TEAM, + task: task({ revision: 2, status: 'deleted' }), + }, 2)], + message: /blocker task "task-1" .* is missing or deleted/, + }, + ] + + for (const { records, message: expected } of invalid) { + expect(() => foldTeam(ROOT, records)).toThrow(expected) + } + }) + + it('leaves numeric allocation unchanged for a branded nonstandard task id', () => { + const state = foldTeam(ROOT, [event('team/task', { + version: 1, + teamId: TEAM, + task: task({ id: TeamTaskId('external-task') }), + }, 0)]) + expect(state.nextTaskNumber).toBe(1) + }) + + it('rejects a persisted numeric task id outside the safe integer range', () => { + expect(() => foldTeam(ROOT, [event('team/task', { + version: 1, + teamId: TEAM, + task: task({ id: TeamTaskId('task-9007199254740992') }), + }, 0)])).toThrow(/persisted Agent Teams team\/task payload is invalid/) + }) + + it('enforces mailbox queue and acknowledgement relations', () => { + const queued = event('team/message/queued', { version: 1, teamId: TEAM, message: message() }, 0) + const delivered = event('team/message/delivered', { + version: 1, + teamId: TEAM, + messageId: TeamMessageId('message-1'), + targetId: CHILD, + }, 1) + expect(pending(foldTeam(ROOT, [queued, delivered]))).toEqual([]) + expect(() => foldTeam(ROOT, [queued, queued])).toThrow(/queued twice/) + expect(() => foldTeam(ROOT, [delivered])).toThrow(/delivered before queueing/) + expect(() => foldTeam(ROOT, [queued, event('team/message/delivered', { + ...delivered.data, + targetId: SessionId('other'), + }, 1)])).toThrow(/target changed/) + expect(() => foldTeam(ROOT, [queued, delivered, { ...delivered, seq: 2 }])).toThrow(/delivered twice/) + }) + + it('validates every current-version persisted payload before folding it', () => { + const malformed = [ + { + ...event('team/member', { version: 1, teamId: TEAM, member: member() }, 0), + data: { version: 1, teamId: TEAM, member: { ...member(), name: 42 } }, + }, + { + ...event('team/task', { version: 1, teamId: TEAM, task: task() }, 0), + data: { version: 1, teamId: TEAM, task: { ...task(), blockedBy: [42] } }, + }, + { + ...event('team/message/queued', { version: 1, teamId: TEAM, message: message() }, 0), + data: { + version: 1, + teamId: TEAM, + message: { ...message(), content: [{ type: 'text', text: 42 }] }, + }, + }, + { + ...event('team/message/delivered', { + version: 1, + teamId: TEAM, + messageId: TeamMessageId('message-1'), + targetId: CHILD, + }, 0), + data: { + version: 1, + teamId: TEAM, + messageId: TeamMessageId('message-1'), + targetId: 42, + }, + }, + { + ...event('team/member', { version: 1, teamId: TEAM, member: member() }, 0), + data: { version: 1, teamId: TEAM, member: member(), unexpected: true }, + }, + { + ...event('team/task', { version: 1, teamId: TEAM, task: task() }, 0), + data: { version: 1, teamId: 42, task: task() }, + }, + ] as unknown as SessionEvent[] + + for (const candidate of malformed) { + expect(() => foldTeam(ROOT, [candidate])) + .toThrow(/persisted Agent Teams .* payload is invalid/) + } + }) + + it('retains merge-extensible content blocks while rejecting malformed core variants', () => { + const extension = { type: 'plugin/custom', payload: { value: 1 } } as never + const state = foldTeam(ROOT, [event('team/message/queued', { + version: 1, + teamId: TEAM, + message: message({ content: [extension] }), + }, 0)]) + expect(pending(state)[0]?.content).toEqual([extension]) + }) + + it('rejects unsupported event versions without mutating an empty state', () => { + const state = emptyTeamFoldState(ROOT) + const invalid = event('team/task', { + version: 2 as 1, + teamId: TEAM, + task: task(), + }, 0) + expect(() => { applyTeamEvent(state, invalid) }).toThrow(/unsupported Agent Teams event version 2/) + expect(isEmptyFold(state)).toBe(true) + }) + + it('ignores unsupported inherited Team records before decoding their version', () => { + const inherited = event('team/task', { + version: 2 as 1, + teamId: TeamId('ancestor'), + task: task(), + }, 0) + expect(isEmptyFold(foldTeam(ROOT, [inherited]))).toBe(true) + }) + + it('still validates complete current-version records inherited from another Team', () => { + const inherited = { + ...event('team/task', { + version: 1, + teamId: TeamId('ancestor'), + task: task(), + }, 0), + data: { + version: 1, + teamId: TeamId('ancestor'), + task: { ...task(), subject: 42 }, + }, + } as unknown as SessionEvent + expect(() => foldTeam(ROOT, [inherited])) + .toThrow(/persisted Agent Teams team\/task payload is invalid/) + }) +}) diff --git a/packages/experimental/team/tests/invariant.spec.ts b/packages/experimental/team/tests/invariant.spec.ts new file mode 100644 index 0000000000..0bf994ecab --- /dev/null +++ b/packages/experimental/team/tests/invariant.spec.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import InvariantService, { InvariantError } from '@deepseek-ai/dsh-invariants' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import * as TeamInvariant from '../src/invariant.ts' +import { TeamId, TeamTaskId } from '../src/types.ts' + +async function setup(): Promise { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(InvariantService, { enabled: true }) + await ctx.plugin(TeamInvariant) + return ctx +} + +describe('Agent Teams stream invariant', () => { + it('accepts provisioning and rejects a terminal member as the first edge', async () => { + const ctx = await setup() + const session = ctx.sessions.create(SessionId('team-invariant')) + const member = { + id: SessionId('team-invariant-child'), + name: 'worker', + description: 'worker responsibility', + provider: 'spawn', + context: 'fresh' as const, + phase: 'provisioning' as const, + } + expect(() => { + session.append('team/member', { version: 1, teamId: TeamId(session.id), member }) + }).not.toThrow() + + const invalid = ctx.sessions.create(SessionId('team-invariant-invalid')) + expect(() => { + invalid.append('team/member', { + version: 1, + teamId: TeamId(invalid.id), + member: { ...member, phase: 'active' }, + }) + }).toThrow(expect.objectContaining>({ + code: 'INVARIANT', + packageName: '@deepseek-ai/dsh-team', + })) + expect(invalid.events).toEqual([]) + }) + + it('rejects an invalid task dependency before publication', async () => { + const ctx = await setup() + const session = ctx.sessions.create(SessionId('team-task-invariant')) + + expect(() => { + session.append('team/task', { + version: 1, + teamId: TeamId(session.id), + task: { + id: TeamTaskId('task-1'), + revision: 1, + subject: 'invalid dependency', + description: 'references a missing blocker', + status: 'pending', + blockedBy: [TeamTaskId('missing')], + writeScopes: [], + }, + }) + }).toThrow(expect.objectContaining>({ + code: 'INVARIANT', + packageName: '@deepseek-ai/dsh-team', + })) + expect(session.events).toEqual([]) + }) +}) diff --git a/packages/experimental/team/tests/persistence.spec.ts b/packages/experimental/team/tests/persistence.spec.ts new file mode 100644 index 0000000000..121a98445c --- /dev/null +++ b/packages/experimental/team/tests/persistence.spec.ts @@ -0,0 +1,485 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync } from 'node:fs' +import { rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Context } from '@deepseek-ai/cordis' +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' +import { createUserMessage } from '@deepseek-ai/dsh-llm' +import { SessionId } from '@deepseek-ai/dsh-session' +import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' +import SqliteSessionPersistence from '@deepseek-ai/dsh-session-persistence-sqlite' +import SubagentService, { seedDescriptorTurn, snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent' +import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn-in-process' +import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' +import TeamService, { foldTeam, TeamId, TeamMessageId } from '../src/index.ts' +import type { TeamMemberSnapshot, TeamMessageSnapshot, TeamTaskSnapshot } from '../src/index.ts' + +const SIGNAL = new AbortController().signal +const PERSISTENCE_TEST_TIMEOUT_MS = 15_000 +const roots: string[] = [] +const contexts = new Set() + +/** Detached durable Team read: the service exposes views, so assertions fold the Lead log. */ +function durable(agent: Agent): { + members: TeamMemberSnapshot[] + tasks: TeamTaskSnapshot[] + pendingMessages: TeamMessageSnapshot[] +} { + const state = foldTeam(agent.id, agent.session.events) + return { + members: [...state.members.values()], + tasks: [...state.tasks.values()], + pendingMessages: [...state.messages.values()].filter(message => !state.delivered.has(message.id)), + } +} + +async function disposeContext(ctx: Context): Promise { + try { + await ctx.fiber.dispose() + } finally { + contexts.delete(ctx) + } +} + +afterEach(async () => { + const failures: unknown[] = [] + for (const ctx of [...contexts].reverse()) { + try { + await disposeContext(ctx) + } catch (error: unknown) { + failures.push(error) + } + } + for (const root of roots.splice(0)) { + try { + await rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }) + } catch (error: unknown) { + failures.push(error) + } + } + if (failures.length > 0) throw new AggregateError(failures, 'Agent Teams persistence test cleanup failed') +}) + +interface PersistenceMount { + readonly name: string + mount(ctx: Context, root: string): Promise<{ dispose(): Promise }> +} + +const backends: PersistenceMount[] = [ + { + name: 'JSONL', + mount: async (ctx, root) => await ctx.plugin(JsonlSessionPersistence, { + root: join(root, 'jsonl'), + compression: 'none', + }), + }, + { + name: 'SQLite', + mount: async (ctx, root) => await ctx.plugin(SqliteSessionPersistence, { + path: join(root, 'sessions.sqlite'), + journalMode: 'delete', + }), + }, +] + +async function stack( + backend: PersistenceMount, + root: string, + script: ConstructorParameters[0], +) { + const ctx = new Context() + contexts.add(ctx) + await mountAgentLoopTestDependencies(ctx) + await backend.mount(ctx, root) + await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(SubagentService) + await ctx.plugin(SubagentSpawn, { providerName: 'spawn' }) + await ctx.plugin(TeamService) + const adapter = new MockAdapter(script) + ctx.llm.registerAdapter(['mock'], adapter) + return { + ctx, + adapter, + dispose: async () => { await disposeContext(ctx) }, + } +} + +function provisioning(childId: SessionId, name: string): TeamMemberSnapshot { + return { + id: childId, + name, + description: `${name} recovery`, + provider: 'spawn', + context: 'fresh', + phase: 'provisioning', + } +} + +function persistedChild( + ctx: Context, + rootId: SessionId, + childId: SessionId, + message: ReturnType, +) { + const seed = seedDescriptorTurn(childId, undefined, snapshotSubagentDescriptor({ + mode: 'continuable', + provider: 'spawn', + label: 'persisted child fixture', + agentProvider: 'mock', + agentModel: 'mock', + })) + const child = ctx.sessions.create(childId, { + seed, + meta: { parentSession: rootId, seedLength: 0, origin: 'subagent' }, + }) + child.append('agent/inbox/spliced', { + target: 'next-turn', + start: 0, + inserted: [message], + }) + return child +} + +for (const backend of backends) { + describe(`${backend.name} Agent Teams recovery`, () => { + it('reconciles a persisted child to active and a missing child to durable failed', { + timeout: PERSISTENCE_TEST_TIMEOUT_MS, + }, async () => { + const storageRoot = mkdtempSync(join(tmpdir(), `dsh-team-${backend.name.toLowerCase()}-`)) + roots.push(storageRoot) + const first = await stack(backend, storageRoot, [textResponse('initial child answer')]) + const activeRootId = SessionId(`${backend.name.toLowerCase()}-active-root`) + const failedRootId = SessionId(`${backend.name.toLowerCase()}-failed-root`) + const childId = SessionId(`${backend.name.toLowerCase()}-child`) + const activeRoot = first.ctx.agentLoop.create(activeRootId, { provider: 'mock', model: 'mock' }) + const failedRoot = first.ctx.agentLoop.create(failedRootId, { provider: 'mock', model: 'mock' }) + // Let each root's startup recovery observe the empty initial log before + // simulating the crash-only provisioning prefix. + await Promise.resolve() + await Promise.resolve() + + activeRoot.session.append('team/member', { + version: 1, + teamId: TeamId(activeRoot.id), + member: provisioning(childId, 'recoverable'), + }) + failedRoot.session.append('team/member', { + version: 1, + teamId: TeamId(failedRoot.id), + member: provisioning(SessionId(`${backend.name}-missing`), 'missing'), + }) + await Promise.all([ + first.ctx.sessions.flush(activeRoot.session), + first.ctx.sessions.flush(failedRoot.session), + ]) + await first.ctx.subagents.startContinuable({ + childId, + provider: 'spawn', + label: 'recoverable recovery', + request: { + prompt: [{ type: 'text', text: 'persist before active edge' }], + parent: activeRoot, + }, + signal: SIGNAL, + }) + await vi.waitFor(() => { expect(first.ctx.agents.get(childId)).toBeUndefined() }, { timeout: 5_000 }) + expect((await first.ctx.sessionPersistence.inspect(childId)).events + .some(event => event.type === 'user/message')).toBe(true) + await first.dispose() + + const second = await stack(backend, storageRoot, [textResponse('cold resumed answer')]) + const activeHandle = await second.ctx.agents.resume({ + resumeSessionId: activeRootId, + agentOptions: { provider: 'mock', model: 'mock' }, + }) + const failedHandle = await second.ctx.agents.resume({ + resumeSessionId: failedRootId, + agentOptions: { provider: 'mock', model: 'mock' }, + }) + await vi.waitFor(() => { + expect(durable(activeHandle.agent).members[0]?.phase).toBe('active') + const failedMember = durable(failedHandle.agent).members[0] + expect(failedMember?.phase).toBe('failed') + expect(failedMember?.error).toContain('child Session recovery failed') + }, { timeout: 5_000 }) + + const receipt = await second.ctx.teams.sendMessage(activeHandle.agent, { + target: 'recoverable', + content: [{ type: 'text', text: 'resume after reconciliation' }], + delivery: 'wakeup', + signal: SIGNAL, + }) + expect(receipt.status).toBe('accepted') + await vi.waitFor(() => { expect(second.ctx.agents.get(childId)).toBeUndefined() }, { timeout: 5_000 }) + await vi.waitFor(() => { expect(durable(activeHandle.agent).pendingMessages).toEqual([]) }) + + await activeHandle.dispose() + await failedHandle.dispose() + await second.dispose() + }) + + it('reconciles a provisioning child whose initial prompt is durably pending', { + timeout: PERSISTENCE_TEST_TIMEOUT_MS, + }, async () => { + const storageRoot = mkdtempSync(join(tmpdir(), `dsh-team-pending-${backend.name.toLowerCase()}-`)) + roots.push(storageRoot) + const rootId = SessionId(`${backend.name.toLowerCase()}-pending-root`) + const childId = SessionId(`${backend.name.toLowerCase()}-pending-child`) + const first = await stack(backend, storageRoot, []) + const root = first.ctx.agentLoop.create(rootId, { provider: 'mock', model: 'mock' }) + await Promise.resolve() + await Promise.resolve() + root.session.append('team/member', { + version: 1, + teamId: TeamId(root.id), + member: provisioning(childId, 'pending-worker'), + }) + const initial = createUserMessage({ + content: [{ type: 'text', text: 'durably pending initial task' }], + source: { kind: 'user' }, + }) + const child = persistedChild(first.ctx, rootId, childId, initial) + await Promise.all([ + first.ctx.sessions.flush(root.session), + first.ctx.sessions.flush(child), + ]) + await first.dispose() + + const second = await stack(backend, storageRoot, []) + const rootHandle = await second.ctx.agents.resume({ + resumeSessionId: rootId, + agentOptions: { provider: 'mock', model: 'mock' }, + }) + await vi.waitFor(() => { + expect(durable(rootHandle.agent).members[0]?.phase).toBe('active') + }) + expect(second.adapter.requests).toEqual([]) + const stored = await second.ctx.sessionPersistence.inspect(childId) + expect(stored.events.some(event => event.type === 'agent/inbox/spliced' + && event.data.inserted.some(message => message.id === initial.id))).toBe(true) + + await rootHandle.dispose() + await second.dispose() + }) + + it('replays queued-minus-delivered mail in FIFO order without waking for quiet mail', { + timeout: PERSISTENCE_TEST_TIMEOUT_MS, + }, async () => { + const storageRoot = mkdtempSync(join(tmpdir(), `dsh-team-mail-${backend.name.toLowerCase()}-`)) + roots.push(storageRoot) + const rootId = SessionId(`${backend.name.toLowerCase()}-mail-root`) + + const first = await stack(backend, storageRoot, [textResponse('initial teammate answer')]) + const firstLead = first.ctx.agentLoop.create(rootId, { provider: 'mock', model: 'mock' }) + const started = await first.ctx.teams.spawnTeammate(firstLead, { + name: 'mail-worker', + description: 'mail recovery worker', + prompt: [{ type: 'text', text: 'finish before restart' }], + context: 'fresh', + provider: 'spawn', + signal: SIGNAL, + }) + await vi.waitFor(() => { expect(first.ctx.agents.get(started.member.id)).toBeUndefined() }, { timeout: 5_000 }) + const quiet = await first.ctx.teams.sendMessage(firstLead, { + target: 'mail-worker', + content: [{ type: 'text', text: 'durable quiet context' }], + delivery: 'quiet', + signal: SIGNAL, + }) + expect(quiet.status).toBe('queued') + expect(durable(firstLead).pendingMessages.map(message => message.id)).toEqual([quiet.messageId]) + await first.dispose() + + const second = await stack(backend, storageRoot, [textResponse('resumed teammate answer')]) + const rootHandle = await second.ctx.agents.resume({ + resumeSessionId: rootId, + agentOptions: { provider: 'mock', model: 'mock' }, + }) + await vi.waitFor(() => { + expect(durable(rootHandle.agent).pendingMessages.map(message => message.id)) + .toEqual([quiet.messageId]) + }) + expect(second.ctx.agents.get(started.member.id)).toBeUndefined() + + const waking = await second.ctx.teams.sendMessage(rootHandle.agent, { + target: 'mail-worker', + content: [{ type: 'text', text: 'resume after restart' }], + delivery: 'wakeup', + signal: SIGNAL, + }) + expect(waking.status).toBe('accepted') + await vi.waitFor(() => { expect(second.ctx.agents.get(started.member.id)).toBeUndefined() }, { timeout: 5_000 }) + await vi.waitFor(() => { expect(durable(rootHandle.agent).pendingMessages).toEqual([]) }) + + const child = await second.ctx.sessionPersistence.inspect(started.member.id) + const peerIds = child.events.flatMap(event => event.type === 'user/message' + && event.data.source.kind === 'team-message' + ? [event.data.source.messageId] + : []) + expect(peerIds).toEqual([quiet.messageId, waking.messageId]) + + await rootHandle.dispose() + await second.dispose() + }) + + it('acknowledges target-recorded mail after restart without delivering it twice', { + timeout: PERSISTENCE_TEST_TIMEOUT_MS, + }, async () => { + const storageRoot = mkdtempSync(join(tmpdir(), `dsh-team-dedup-${backend.name.toLowerCase()}-`)) + roots.push(storageRoot) + const rootId = SessionId(`${backend.name.toLowerCase()}-dedup-root`) + const messageId = TeamMessageId(`${backend.name.toLowerCase()}-recorded-message`) + + const first = await stack(backend, storageRoot, [textResponse('initial teammate answer')]) + const firstLead = first.ctx.agentLoop.create(rootId, { provider: 'mock', model: 'mock' }) + const started = await first.ctx.teams.spawnTeammate(firstLead, { + name: 'dedup-worker', + description: 'mail deduplication worker', + prompt: [{ type: 'text', text: 'finish before the crash window' }], + context: 'fresh', + provider: 'spawn', + signal: SIGNAL, + }) + await vi.waitFor(() => { expect(first.ctx.agents.get(started.member.id)).toBeUndefined() }, { timeout: 5_000 }) + + const targetHandle = await first.ctx.agents.resume({ + resumeSessionId: started.member.id, + agentOptions: { provider: 'mock', model: 'mock' }, + }) + targetHandle.agent.session.append('user/message', createUserMessage({ + content: [ + { type: 'text', text: `Team message ${messageId} from lead:` }, + { type: 'text', text: 'already recorded before acknowledgement' }, + ], + source: { + kind: 'team-message', + teamId: TeamId(rootId), + messageId, + senderId: rootId, + senderName: 'lead', + }, + }), { surfaceOp: 'append' }) + await first.ctx.sessions.flush(targetHandle.agent.session) + // Let the pre-queue acknowledgement observer prove there is no mailbox + // row yet before authoring the simulated crash prefix below. + await new Promise((resolve) => { setTimeout(resolve, 0) }) + await targetHandle.dispose() + + const queued: TeamMessageSnapshot = { + id: messageId, + senderId: rootId, + senderName: 'lead', + targetId: started.member.id, + delivery: 'wakeup', + content: [{ type: 'text', text: 'already recorded before acknowledgement' }], + } + firstLead.session.append('team/message/queued', { + version: 1, + teamId: TeamId(rootId), + message: queued, + }) + await first.ctx.sessions.flush(firstLead.session) + expect(durable(firstLead).pendingMessages.map(message => message.id)).toEqual([messageId]) + await first.dispose() + + const second = await stack(backend, storageRoot, []) + const rootHandle = await second.ctx.agents.resume({ + resumeSessionId: rootId, + agentOptions: { provider: 'mock', model: 'mock' }, + }) + await vi.waitFor(() => { expect(durable(rootHandle.agent).pendingMessages).toEqual([]) }) + expect(second.ctx.agents.get(started.member.id)).toBeUndefined() + expect(second.adapter.requests).toEqual([]) + + const child = await second.ctx.sessionPersistence.inspect(started.member.id) + const occurrences = child.events.filter(event => event.type === 'user/message' + && event.data.source.kind === 'team-message' + && event.data.source.messageId === messageId) + expect(occurrences).toHaveLength(1) + + await rootHandle.dispose() + await second.dispose() + }) + + it('acknowledges durably pending target mail without cold-resume duplication', { + timeout: PERSISTENCE_TEST_TIMEOUT_MS, + }, async () => { + const storageRoot = mkdtempSync(join(tmpdir(), `dsh-team-inbox-${backend.name.toLowerCase()}-`)) + roots.push(storageRoot) + const rootId = SessionId(`${backend.name.toLowerCase()}-inbox-root`) + const childId = SessionId(`${backend.name.toLowerCase()}-inbox-child`) + const messageId = TeamMessageId(`${backend.name.toLowerCase()}-pending-team-message`) + const first = await stack(backend, storageRoot, []) + const root = first.ctx.agentLoop.create(rootId, { provider: 'mock', model: 'mock' }) + await Promise.resolve() + await Promise.resolve() + const provisioned = provisioning(childId, 'pending-mail-worker') + const active: TeamMemberSnapshot = { + ...provisioned, + phase: 'active', + } + const queued: TeamMessageSnapshot = { + id: messageId, + senderId: rootId, + senderName: 'lead', + targetId: childId, + delivery: 'wakeup', + content: [{ type: 'text', text: 'already durable in target inbox' }], + } + root.session.append('team/member', { + version: 1, + teamId: TeamId(root.id), + member: provisioned, + }) + root.session.append('team/member', { + version: 1, + teamId: TeamId(root.id), + member: active, + }) + root.session.append('team/message/queued', { + version: 1, + teamId: TeamId(root.id), + message: queued, + }) + const pending = createUserMessage({ + content: [{ type: 'text', text: 'already durable in target inbox' }], + source: { + kind: 'team-message', + teamId: TeamId(rootId), + messageId, + senderId: rootId, + senderName: 'lead', + }, + }) + const child = persistedChild(first.ctx, rootId, childId, pending) + await Promise.all([ + first.ctx.sessions.flush(root.session), + first.ctx.sessions.flush(child), + ]) + await first.dispose() + + const second = await stack(backend, storageRoot, []) + const rootHandle = await second.ctx.agents.resume({ + resumeSessionId: rootId, + agentOptions: { provider: 'mock', model: 'mock' }, + }) + await vi.waitFor(() => { + expect(durable(rootHandle.agent).pendingMessages).toEqual([]) + }) + expect(second.adapter.requests).toEqual([]) + expect(second.ctx.agents.get(childId)).toBeUndefined() + const stored = await second.ctx.sessionPersistence.inspect(childId) + const pendingCopies = stored.events.flatMap(event => event.type === 'agent/inbox/spliced' + ? event.data.inserted.filter(message => message.source.kind === 'team-message' + && message.source.messageId === messageId) + : []) + expect(pendingCopies).toHaveLength(1) + + await rootHandle.dispose() + await second.dispose() + }) + }) +} diff --git a/packages/experimental/team/tests/team.spec.ts b/packages/experimental/team/tests/team.spec.ts new file mode 100644 index 0000000000..dd03cd6438 --- /dev/null +++ b/packages/experimental/team/tests/team.spec.ts @@ -0,0 +1,1692 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Context } from '@deepseek-ai/cordis' +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' +import { createUserMessage } from '@deepseek-ai/dsh-llm' +import { SessionId, type Session } from '@deepseek-ai/dsh-session' +import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' +import SubagentService from '@deepseek-ai/dsh-subagent' +import * as SubagentFork from '@deepseek-ai/dsh-subagent-fork-in-process' +import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn-in-process' +import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' +import TeamService, { foldTeam, TeamError, TeamId, TeamMessageId, TeamTaskId } from '../src/index.ts' +import { TeamRuntimeLifecycle } from '../src/lifecycle.ts' +import type { TeamMemberSnapshot, TeamMessageSnapshot, TeamTaskSnapshot } from '../src/index.ts' + +const SIGNAL = new AbortController().signal +const roots: string[] = [] + +afterEach(() => { + vi.useRealTimers() + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) +}) + +/** Detached durable Team read: the service exposes views, so assertions fold the Lead log. */ +function durable(agent: Agent): { + members: TeamMemberSnapshot[] + tasks: TeamTaskSnapshot[] + pendingMessages: TeamMessageSnapshot[] +} { + const state = foldTeam(agent.id, agent.session.events) + return { + members: [...state.members.values()], + tasks: [...state.tasks.values()], + pendingMessages: [...state.messages.values()].filter(message => !state.delivered.has(message.id)), + } +} + +async function setup( + script: ConstructorParameters[0], + config: ConstructorParameters[1] = {}, +) { + const ctx = new Context() + await mountAgentLoopTestDependencies(ctx) + const storageRoot = mkdtempSync(join(tmpdir(), 'dsh-team-')) + roots.push(storageRoot) + await ctx.plugin(JsonlSessionPersistence, { root: storageRoot }) + await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(SubagentService) + await ctx.plugin(SubagentSpawn, { providerName: 'spawn' }) + await ctx.plugin(SubagentFork, { providerName: 'fork' }) + const teamFiber = await ctx.plugin(TeamService, config) + const adapter = new MockAdapter(script) + ctx.llm.registerAdapter(['mock'], adapter) + const lead = ctx.agentLoop.create(SessionId('lead'), { provider: 'mock', model: 'mock' }) + return { ctx, lead, adapter, storageRoot, teamFiber } +} + +function content(text: string) { + return [{ type: 'text' as const, text }] +} + +interface TeamServiceInternals { + readonly roster: { + readonly inFlightCreations: Set> + checkpointInitialPrompt(childId: SessionId, messageId: string, signal: AbortSignal): Promise + reconcileProvisioning(root: Agent, signal: AbortSignal): Promise + liveChildrenByRoot(): Map + } + readonly mailbox: { + tryDispatch(root: Agent, message: TeamMessageSnapshot, signal: AbortSignal): Promise + serializeDispatch(message: TeamMessageSnapshot, operation: () => Promise): Promise + markDelivered(root: Agent, messageId: ReturnType, targetId: SessionId): Promise + } + readonly journal: { + state(root: Agent): unknown + } + disposeRuntime(): Promise + recoverFor(agent: Agent): Promise + scheduleRecovery(agent: Agent): void +} + +/** White-box access follows the runtime owners so coverage does not widen the service API. */ +function teamInternals(ctx: Context): TeamServiceInternals { + return ctx.teams as unknown as TeamServiceInternals +} + +function spawn( + ctx: Context, + lead: Agent, + name: string, + options: { context?: 'fresh' | 'fork'; provider?: string } = {}, +) { + const context = options.context ?? 'fresh' + return ctx.teams.spawnTeammate(lead, { + name, + description: `${name} responsibility`, + prompt: content(`${name} initial`), + context, + provider: options.provider ?? (context === 'fork' ? 'fork' : 'spawn'), + signal: SIGNAL, + }) +} + +async function waitNoAgent(ctx: Context, id: SessionId): Promise { + await vi.waitFor(() => { expect(ctx.agents.get(id)).toBeUndefined() }, { timeout: 5_000 }) +} + +async function waitRunning(ctx: Context, id: SessionId): Promise { + return vi.waitFor(() => { + const agent = ctx.agents.get(id) + expect(agent?.status).toBe('running') + return agent! + }, { timeout: 5_000 }) +} + +describe('Team identity and provisioning', () => { + it('rejects deployment limits that are not positive safe integers', async () => { + const fields = [ + 'maxMembers', + 'maxTasks', + 'maxPendingMessagesPerMember', + 'maxMessageBytes', + 'disposalTimeoutMs', + ] as const + for (const field of fields) { + for (const value of [0, 1.5, Number.MAX_SAFE_INTEGER + 1]) { + await expect(setup([], { [field]: value })).rejects.toThrow() + } + } + }) + + it('supports direct-constructor defaults and recovers roots that already exist', async () => { + const ctx = new Context() + await mountAgentLoopTestDependencies(ctx) + const storageRoot = mkdtempSync(join(tmpdir(), 'dsh-team-direct-')) + roots.push(storageRoot) + await ctx.plugin(JsonlSessionPersistence, { root: storageRoot }) + await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(SubagentService) + const lead = ctx.agentLoop.create(SessionId('preexisting-lead'), {}) + const service = new TeamService(ctx) + + expect(service.listMembers(lead)).toEqual([expect.objectContaining({ + name: 'lead', + status: 'idle', + diagnostics: [], + })]) + const provisioning = { + id: SessionId('preexisting-child'), + name: 'preexisting-worker', + description: 'preexisting responsibility', + provider: 'spawn', + context: 'fresh' as const, + phase: 'provisioning' as const, + } + lead.session.append('team/member', { + version: 1, + teamId: TeamId(lead.id), + member: provisioning, + }) + expect(service.listMembers(lead)[1]).toEqual(expect.objectContaining({ + name: 'preexisting-worker', + status: 'provisioning', + diagnostics: [], + })) + expect(service.listMembers(lead)[1]).not.toHaveProperty('model') + await Promise.resolve() + }) + + it('creates fresh and fork teammates with immutable names and bounded roster size', async () => { + const { ctx, lead } = await setup([ + textResponse('lead answer'), + textResponse('fork answer'), + textResponse('fresh answer'), + ], { maxMembers: 2 }) + lead.followup(createUserMessage({ content: content('lead turn'), source: { kind: 'user' } })) + await lead.whenIdle() + + const forked = await spawn(ctx, lead, 'fork-worker', { context: 'fork' }) + await waitNoAgent(ctx, forked.member.id) + const fresh = await spawn(ctx, lead, 'fresh-worker') + await waitNoAgent(ctx, fresh.member.id) + + expect((await ctx.sessionPersistence.inspect(forked.member.id)).meta.seedLength).toBeGreaterThan(0) + expect((await ctx.sessionPersistence.inspect(fresh.member.id)).meta.seedLength ?? 0).toBe(0) + expect(ctx.teams.listMembers(lead).map(row => [row.name, row.context, row.status])).toEqual([ + ['lead', undefined, 'idle'], + ['fork-worker', 'fork', 'inactive'], + ['fresh-worker', 'fresh', 'inactive'], + ]) + await expect(spawn(ctx, lead, 'third-worker')).rejects.toMatchObject({ code: 'TEAM_MEMBER_LIMIT' }) + await expect(spawn(ctx, lead, 'fresh-worker')).rejects.toMatchObject({ code: 'TEAM_MEMBER_NAME_TAKEN' }) + }) + + it('flushes the accepted child prompt before committing the active roster edge', async () => { + const { ctx, lead } = await setup([textResponse('checkpointed child answer')]) + const flush = ctx.sessions.flush.bind(ctx.sessions) + const order: string[] = [] + vi.spyOn(ctx.sessions, 'flush').mockImplementation(async (session) => { + if (session.id === lead.id && durable(lead).members[0]?.phase === 'active') { + order.push('lead-active') + } else if (session.id !== lead.id) { + order.push('child') + } + return flush(session) + }) + + const started = await spawn(ctx, lead, 'checkpoint-worker') + expect(order.indexOf('child')).toBeGreaterThanOrEqual(0) + expect(order.indexOf('child')).toBeLessThan(order.indexOf('lead-active')) + await waitNoAgent(ctx, started.member.id) + }) + + it('checkpoints live and detached inbox receipts and aborts an unresolved checkpoint', async () => { + const { ctx, lead } = await setup([]) + const internal = teamInternals(ctx).roster + let liveSession: Session | undefined + const liveFiber = await ctx.plugin(Object.assign(function checkpointFixture(childCtx: Context) { + liveSession = childCtx.sessions.create(SessionId('checkpoint-child')) + }, { inject: ['sessions'] })) + if (liveSession === undefined) throw new Error('checkpoint fixture did not create its Session') + const initial = createUserMessage({ content: content('checkpoint me'), source: { kind: 'user' } }) + const checkpoint = internal.checkpointInitialPrompt(liveSession.id, initial.id, SIGNAL) + await Promise.resolve() + lead.inject(createUserMessage({ content: content('unrelated progress'), source: { kind: 'user' } })) + const unrelatedFiber = await ctx.plugin(Object.assign(function unrelatedCheckpointFixture(childCtx: Context) { + childCtx.sessions.create(SessionId('unrelated-checkpoint-child')) + }, { inject: ['sessions'] })) + await unrelatedFiber.dispose() + liveSession.append('agent/inbox/spliced', { + target: 'next-turn', start: 0, inserted: [initial], + }) + await checkpoint + await liveFiber.dispose() + + await expect(internal.checkpointInitialPrompt(liveSession.id, initial.id, SIGNAL)).resolves.toBeUndefined() + const missing = createUserMessage({ content: content('missing'), source: { kind: 'user' } }) + await expect(internal.checkpointInitialPrompt(liveSession.id, missing.id, SIGNAL)) + .rejects.toMatchObject({ code: 'TEAM_PROVISIONING_CONFLICT' }) + + let disposedSession: Session | undefined + const disposedFiber = await ctx.plugin(Object.assign(function disposedCheckpointFixture(childCtx: Context) { + disposedSession = childCtx.sessions.create(SessionId('disposed-checkpoint-child')) + }, { inject: ['sessions'] })) + if (disposedSession === undefined) throw new Error('disposed checkpoint fixture did not create its Session') + const disposed = internal.checkpointInitialPrompt(disposedSession.id, missing.id, SIGNAL) + const disposedResult = expect(disposed).rejects.toThrow('not found') + await Promise.resolve() + await disposedFiber.dispose() + await disposedResult + + let abortedSession: Session | undefined + const abortedFiber = await ctx.plugin(Object.assign(function abortedCheckpointFixture(childCtx: Context) { + abortedSession = childCtx.sessions.create(SessionId('aborted-checkpoint-child')) + }, { inject: ['sessions'] })) + if (abortedSession === undefined) throw new Error('aborted checkpoint fixture did not create its Session') + const controller = new AbortController() + const aborted = internal.checkpointInitialPrompt(abortedSession.id, missing.id, controller.signal) + await Promise.resolve() + controller.abort({ kind: 'test' }) + await expect(aborted).rejects.toMatchObject({ code: 'TEAM_DISPOSED' }) + + const errorController = new AbortController() + const errorAborted = internal.checkpointInitialPrompt(abortedSession.id, missing.id, errorController.signal) + const errorResult = expect(errorAborted).rejects.toThrow('checkpoint stopped') + await Promise.resolve() + errorController.abort(new Error('checkpoint stopped')) + await errorResult + await abortedFiber.dispose() + }) + + it('drains an accepted child when its initial durability checkpoint fails', async () => { + const { ctx, lead } = await setup(['hang']) + vi.spyOn(teamInternals(ctx).roster, 'checkpointInitialPrompt') + .mockRejectedValueOnce(new Error('checkpoint failed')) + + await expect(spawn(ctx, lead, 'checkpoint-failure')).rejects.toThrow('checkpoint failed') + const member = durable(lead).members[0] + expect(member).toMatchObject({ phase: 'failed', error: 'checkpoint failed' }) + if (member !== undefined) await waitNoAgent(ctx, member.id) + }) + + it('records failed provisioning durably, reserves its name, and counts it against the limit', async () => { + const { ctx, lead } = await setup([], { maxMembers: 1 }) + await expect(spawn(ctx, lead, 'failed-worker', { provider: 'missing' })).rejects.toThrow() + + expect(ctx.teams.listMembers(lead)[1]).toMatchObject({ + name: 'failed-worker', + status: 'failed', + provider: 'missing', + }) + await expect(spawn(ctx, lead, 'failed-worker')).rejects.toMatchObject({ code: 'TEAM_MEMBER_NAME_TAKEN' }) + await expect(spawn(ctx, lead, 'other-worker')).rejects.toMatchObject({ code: 'TEAM_MEMBER_LIMIT' }) + }) + + it('records non-Error provider failures and contains a reversed provisioning settlement race', async () => { + const first = await setup([]) + vi.spyOn(first.ctx.subagents, 'startContinuable').mockRejectedValueOnce('string provider failure') + await expect(spawn(first.ctx, first.lead, 'string-failure')).rejects.toBe('string provider failure') + expect(first.ctx.teams.listMembers(first.lead)[1]).toMatchObject({ + status: 'failed', + diagnostics: ['string provider failure'], + }) + await expect(first.ctx.teams.sendMessage(first.lead, { + target: 'string-failure', content: content('cannot deliver'), delivery: 'quiet', signal: SIGNAL, + })).rejects.toMatchObject({ code: 'TEAM_MEMBER_NOT_FOUND' }) + + const second = await setup([]) + vi.spyOn(second.ctx.subagents, 'startContinuable').mockImplementationOnce(async () => { + const provisioning = durable(second.lead).members[0] + if (provisioning === undefined) throw new Error('missing provisioning edge') + second.lead.session.append('team/member', { + version: 1, + teamId: TeamId(second.lead.id), + member: { ...provisioning, phase: 'active' }, + }) + await second.ctx.sessions.flush(second.lead.session) + throw new Error('creator failed after recovery settled active') + }) + await expect(spawn(second.ctx, second.lead, 'reverse-race')).rejects.toBeInstanceOf(AggregateError) + expect(durable(second.lead).members[0]?.phase).toBe('active') + }) + + it('cleans up a child when recovery settles its provisioning record first', async () => { + const { ctx, lead } = await setup(['hang']) + const start = ctx.subagents.startContinuable.bind(ctx.subagents) + const entered = Promise.withResolvers() + const release = Promise.withResolvers() + let childId: SessionId | undefined + vi.spyOn(ctx.subagents, 'startContinuable').mockImplementation(async (spec) => { + childId = spec.childId + entered.resolve(undefined) + await release.promise + return start(spec) + }) + + const spawning = spawn(ctx, lead, 'racing-worker') + const rejected = expect(spawning).rejects.toMatchObject({ code: 'TEAM_PROVISIONING_CONFLICT' }) + await entered.promise + await teamInternals(ctx).roster.reconcileProvisioning(lead, SIGNAL) + expect(durable(lead).members[0]?.phase).toBe('failed') + + release.resolve(undefined) + await rejected + if (childId === undefined) throw new Error('reserved child id was not observed') + await waitNoAgent(ctx, childId) + }) + + it('handles a continuation that settles before the active roster view or conflict cleanup lookup', async () => { + const first = await setup([]) + vi.spyOn(teamInternals(first.ctx).roster, 'checkpointInitialPrompt').mockResolvedValueOnce() + vi.spyOn(first.ctx.subagents, 'startContinuable').mockImplementationOnce(async spec => ({ + childId: spec.childId!, + messageId: createUserMessage({ content: content('accepted'), source: { kind: 'user' } }).id, + })) + const inactive = await spawn(first.ctx, first.lead, 'instant-worker') + expect(inactive.member).toMatchObject({ status: 'inactive', diagnostics: [] }) + expect(inactive.member).not.toHaveProperty('model') + + const second = await setup([]) + vi.spyOn(teamInternals(second.ctx).roster, 'checkpointInitialPrompt').mockResolvedValueOnce() + const entered = Promise.withResolvers() + const release = Promise.withResolvers() + vi.spyOn(second.ctx.subagents, 'startContinuable').mockImplementationOnce(async (spec) => { + entered.resolve(undefined) + await release.promise + return { + childId: spec.childId!, + messageId: createUserMessage({ content: content('accepted'), source: { kind: 'user' } }).id, + } + }) + const spawning = spawn(second.ctx, second.lead, 'instant-conflict') + const rejected = expect(spawning).rejects.toMatchObject({ code: 'TEAM_PROVISIONING_CONFLICT' }) + await entered.promise + await teamInternals(second.ctx).roster.reconcileProvisioning(second.lead, SIGNAL) + release.resolve(undefined) + await rejected + }) + + it('validates names and permits only the Lead to create or interrupt teammates', async () => { + const { ctx, lead } = await setup(['hang']) + for (const name of ['Lead', 'lead', '-bad', 'bad-', 'bad_name', 'x'.repeat(65)]) { + await expect(spawn(ctx, lead, name)).rejects.toMatchObject({ code: 'TEAM_INVALID_MEMBER_NAME' }) + } + const started = await spawn(ctx, lead, 'worker') + const worker = await waitRunning(ctx, started.member.id) + await expect(spawn(ctx, worker, 'nested')).rejects.toMatchObject({ code: 'TEAM_LEAD_REQUIRED' }) + expect(() => ctx.teams.interrupt(worker, 'worker')).toThrow(expect.objectContaining({ code: 'TEAM_LEAD_REQUIRED' })) + expect(ctx.teams.interrupt(lead, 'worker')).toEqual({ previousStatus: 'running' }) + await waitNoAgent(ctx, worker.id) + expect(ctx.teams.interrupt(lead, 'worker')).toEqual({ previousStatus: 'inactive' }) + expect(() => ctx.teams.interrupt(lead, 'lead')).toThrow(expect.objectContaining({ code: 'TEAM_INVALID_TARGET' })) + }) + + it('validates teammate text fields and pre-provisioning cancellation', async () => { + const { ctx, lead } = await setup([]) + await expect(ctx.teams.spawnTeammate(lead, { + name: 'empty-description', + description: ' ', + prompt: content('unused'), + context: 'fresh', + provider: 'spawn', + signal: SIGNAL, + })).rejects.toMatchObject({ code: 'TEAM_INVALID_ARGUMENT' }) + await expect(ctx.teams.spawnTeammate(lead, { + name: 'empty-provider', + description: 'valid description', + prompt: content('unused'), + context: 'fresh', + provider: ' ', + signal: SIGNAL, + })).rejects.toMatchObject({ code: 'TEAM_INVALID_ARGUMENT' }) + const controller = new AbortController() + controller.abort(new TeamError('cancelled before provisioning', 'TEST_CANCELLED')) + await expect(ctx.teams.spawnTeammate(lead, { + name: 'cancelled-worker', + description: 'never provisioned', + prompt: content('unused'), + context: 'fresh', + provider: 'spawn', + signal: controller.signal, + })).rejects.toMatchObject({ code: 'TEST_CANCELLED' }) + expect(durable(lead).members).toEqual([]) + }) + + it('treats an ordinary fork as a new Root Team and filters inherited Team state', async () => { + const { ctx, lead } = await setup([]) + await ctx.teams.createTask(lead, { subject: 'parent task', description: 'belongs to parent' }) + const handle = await ctx.agents.create({ + sessionId: SessionId('ordinary-fork'), + seed: lead.session.events, + meta: { parentSession: lead.id, seedLength: lead.session.seq }, + agentOptions: { provider: 'mock', model: 'mock' }, + }) + + expect(ctx.teams.membership(handle.agent)).toMatchObject({ + id: TeamId(handle.agent.id), + role: 'lead', + name: 'lead', + }) + expect(durable(handle.agent)).toMatchObject({ members: [], tasks: [], pendingMessages: [] }) + await handle.dispose() + }) + + it('rejects stale Agent identities and non-Team subagent children', async () => { + const { ctx, lead } = await setup([textResponse('done')]) + const started = await ctx.subagents.startContinuable({ + provider: 'spawn', + label: 'ordinary worker', + request: { prompt: content('ordinary'), parent: lead }, + signal: SIGNAL, + }) + const live = ctx.agents.get(started.childId) + if (live !== undefined) expect(ctx.teams.tryMembership(live)).toBeUndefined() + await waitNoAgent(ctx, started.childId) + expect(() => ctx.teams.membership(lead)).not.toThrow() + + const impostor = { ...lead } as Agent + expect(ctx.teams.tryMembership(impostor)).toBeUndefined() + expect(() => ctx.teams.membership(impostor)).toThrow(expect.objectContaining({ code: 'TEAM_NOT_MEMBER' })) + + const orphanRoot = await ctx.agents.create({ + sessionId: SessionId('orphan-ordinary-root'), + meta: { parentSession: SessionId('absent-parent') }, + agentOptions: { provider: 'mock', model: 'mock' }, + }) + expect(ctx.teams.membership(orphanRoot.agent)).toMatchObject({ role: 'lead', name: 'lead' }) + await orphanRoot.dispose() + }) + + it('does not reinterpret an orphaned provider child or malformed parent stream as a Team root', async () => { + const first = await setup([textResponse('ordinary child done')]) + const parent = await first.ctx.agents.create({ + sessionId: SessionId('temporary-parent'), + agentOptions: { provider: 'mock', model: 'mock' }, + }) + const started = await first.ctx.subagents.startContinuable({ + provider: 'spawn', + label: 'ordinary child', + request: { prompt: content('finish'), parent: parent.agent }, + signal: SIGNAL, + }) + await waitNoAgent(first.ctx, started.childId) + await parent.dispose() + const orphan = await first.ctx.agents.resume({ + resumeSessionId: started.childId, + agentOptions: { provider: 'mock', model: 'mock' }, + }) + expect(first.ctx.teams.tryMembership(orphan.agent)).toBeUndefined() + expect(teamInternals(first.ctx).roster.liveChildrenByRoot()).toEqual(new Map()) + await orphan.dispose() + + const second = await setup([]) + const child = await second.ctx.agents.create({ + sessionId: SessionId('malformed-parent-child'), + meta: { parentSession: second.lead.id }, + agentOptions: { provider: 'mock', model: 'mock' }, + }) + const journal = teamInternals(second.ctx).journal + const state = journal.state.bind(journal) + journal.state = () => { throw new Error('malformed Team stream') } + expect(second.ctx.teams.tryMembership(child.agent)).toBeUndefined() + journal.state = state + await child.dispose() + }) +}) + +describe('Team shared task DAG', () => { + it('fails loudly when the durable numeric task id space is exhausted', async () => { + const { ctx, lead } = await setup([]) + const id = TeamTaskId(`task-${Number.MAX_SAFE_INTEGER}`) + lead.session.append('team/task', { + version: 1, + teamId: TeamId(lead.id), + task: { + id, + revision: 1, + subject: 'last numeric task', + description: 'occupies the final safe numeric task id', + status: 'pending', + blockedBy: [], + writeScopes: [], + }, + }) + await ctx.sessions.flush(lead.session) + + await expect(ctx.teams.createTask(lead, { + subject: 'cannot allocate', + description: 'no safe numeric task id remains', + })).rejects.toMatchObject({ code: 'TEAM_TASK_LIMIT' }) + }) + + it('bounds non-deleted tasks while retaining deleted task ids as tombstones', async () => { + const { ctx, lead } = await setup([], { maxTasks: 1 }) + const first = await ctx.teams.createTask(lead, { subject: 'first', description: 'first task' }) + await expect(ctx.teams.createTask(lead, { subject: 'overflow', description: 'overflow task' })) + .rejects.toMatchObject({ code: 'TEAM_TASK_LIMIT' }) + + const deleted = await ctx.teams.updateTask(lead, { + taskId: first.id, + expectedRevision: first.revision, + action: 'delete', + }) + const second = await ctx.teams.createTask(lead, { subject: 'second', description: 'second task' }) + expect(deleted.status).toBe('deleted') + expect(second.id).toBe(TeamTaskId('task-2')) + expect(ctx.teams.getTask(lead, first.id).status).toBe('deleted') + expect(ctx.teams.listTasks(lead).map(task => task.id)).toEqual([second.id]) + }) + + it('enforces CAS, ownership, dependencies, transitions, and write-scope warnings', async () => { + const { ctx, lead } = await setup(['hang', 'hang']) + const firstMember = await spawn(ctx, lead, 'alpha') + const alpha = await waitRunning(ctx, firstMember.member.id) + const secondMember = await spawn(ctx, lead, 'beta') + const beta = await waitRunning(ctx, secondMember.member.id) + + const first = await ctx.teams.createTask(alpha, { + subject: 'first', + description: 'first task', + writeScopes: ['src', './src/', 'src'], + }) + const second = await ctx.teams.createTask(beta, { + subject: 'second', + description: 'second task', + blockedBy: [first.id], + writeScopes: ['src/feature'], + }) + expect(first.writeScopes).toEqual(['src']) + await expect(ctx.teams.updateTask(beta, { + taskId: second.id, + expectedRevision: second.revision, + action: 'claim', + })).rejects.toMatchObject({ code: 'TEAM_TASK_BLOCKED' }) + + const claimed = await ctx.teams.updateTask(alpha, { + taskId: first.id, + expectedRevision: first.revision, + action: 'claim', + }) + await expect(ctx.teams.updateTask(beta, { + taskId: first.id, + expectedRevision: claimed.revision, + action: 'claim', + })).rejects.toMatchObject({ code: 'TEAM_TASK_ALREADY_CLAIMED' }) + expect(ctx.teams.getTask(beta, second.id)).toMatchObject({ + ready: false, + writeScopeWarnings: [`write scopes overlap with ${first.id}`], + }) + await expect(ctx.teams.updateTask(beta, { + taskId: first.id, + expectedRevision: claimed.revision, + action: 'edit', + subject: 'stolen', + })).rejects.toMatchObject({ code: 'TEAM_TASK_UNAUTHORIZED' }) + await expect(ctx.teams.updateTask(alpha, { + taskId: first.id, + expectedRevision: first.revision, + action: 'complete', + })).rejects.toMatchObject({ code: 'TEAM_TASK_STALE_REVISION' }) + + const completed = await ctx.teams.updateTask(alpha, { + taskId: first.id, + expectedRevision: claimed.revision, + action: 'complete', + }) + expect(completed.status).toBe('completed') + expect(ctx.teams.getTask(beta, second.id).ready).toBe(true) + const secondClaim = await ctx.teams.updateTask(beta, { + taskId: second.id, + expectedRevision: second.revision, + action: 'claim', + }) + const released = await ctx.teams.updateTask(beta, { + taskId: second.id, + expectedRevision: secondClaim.revision, + action: 'release', + }) + expect(released).toMatchObject({ status: 'pending', ready: true }) + expect('ownerId' in released).toBe(false) + + ctx.teams.interrupt(lead, 'alpha') + ctx.teams.interrupt(lead, 'beta') + await Promise.all([waitNoAgent(ctx, alpha.id), waitNoAgent(ctx, beta.id)]) + }) + + it('rejects malformed scopes and every invalid dependency relation', async () => { + const { ctx, lead } = await setup([]) + const first = await ctx.teams.createTask(lead, { subject: 'one', description: 'one' }) + const second = await ctx.teams.createTask(lead, { + subject: 'two', description: 'two', blockedBy: [first.id], + }) + await expect(ctx.teams.createTask(lead, { + subject: 'bad', description: 'bad', blockedBy: [TeamTaskId('missing')], + })).rejects.toMatchObject({ code: 'TEAM_TASK_NOT_FOUND' }) + await expect(ctx.teams.updateTask(lead, { + taskId: first.id, + expectedRevision: first.revision, + action: 'set_dependencies', + blockedBy: [second.id], + })).rejects.toMatchObject({ code: 'TEAM_TASK_DEPENDENCY_CYCLE' }) + await expect(ctx.teams.updateTask(lead, { + taskId: first.id, + expectedRevision: first.revision, + action: 'set_dependencies', + blockedBy: [first.id], + })).rejects.toMatchObject({ code: 'TEAM_TASK_DEPENDENCY_CYCLE' }) + await expect(ctx.teams.updateTask(lead, { + taskId: first.id, + expectedRevision: first.revision, + action: 'set_dependencies', + blockedBy: [second.id, second.id], + })).rejects.toMatchObject({ code: 'TEAM_INVALID_ARGUMENT' }) + for (const scope of ['', '.', '..', '/root', 'C:\\root', 'C:root', 'a//b', 'a/../b']) { + await expect(ctx.teams.createTask(lead, { + subject: 'scope', description: 'scope', writeScopes: [scope], + })).rejects.toMatchObject({ code: 'TEAM_INVALID_WRITE_SCOPE' }) + } + }) + + it('rejects incomplete mutations, invalid transitions, and deletion of a live blocker', async () => { + const { ctx, lead } = await setup([]) + await expect(ctx.teams.createTask(lead, { subject: ' ', description: 'invalid' })) + .rejects.toMatchObject({ code: 'TEAM_INVALID_ARGUMENT' }) + await expect(ctx.teams.createTask(lead, { subject: 'invalid', description: '' })) + .rejects.toMatchObject({ code: 'TEAM_INVALID_ARGUMENT' }) + await expect(ctx.teams.createTask(lead, { subject: 'x'.repeat(201), description: 'too long' })) + .rejects.toMatchObject({ code: 'TEAM_INVALID_ARGUMENT' }) + const blocker = await ctx.teams.createTask(lead, { subject: 'blocker', description: 'blocker' }) + await ctx.teams.createTask(lead, { + subject: 'dependent', description: 'dependent', blockedBy: [blocker.id], + }) + expect(() => ctx.teams.getTask(lead, TeamTaskId('missing'))) + .toThrow(expect.objectContaining({ code: 'TEAM_TASK_NOT_FOUND' })) + for (const action of ['release', 'complete', 'reopen'] as const) { + await expect(ctx.teams.updateTask(lead, { + taskId: blocker.id, + expectedRevision: blocker.revision, + action, + })).rejects.toMatchObject({ code: 'TEAM_TASK_INVALID_TRANSITION' }) + } + await expect(ctx.teams.updateTask(lead, { + taskId: blocker.id, + expectedRevision: blocker.revision, + action: 'edit', + })).rejects.toMatchObject({ code: 'TEAM_INVALID_ARGUMENT' }) + await expect(ctx.teams.updateTask(lead, { + taskId: blocker.id, + expectedRevision: blocker.revision, + action: 'set_dependencies', + })).rejects.toMatchObject({ code: 'TEAM_INVALID_ARGUMENT' }) + await expect(ctx.teams.updateTask(lead, { + taskId: blocker.id, + expectedRevision: blocker.revision, + action: 'delete', + })).rejects.toMatchObject({ code: 'TEAM_TASK_HAS_DEPENDENTS' }) + }) + + it('supports Lead reassignment, completion, reopen, and deletion permissions', async () => { + const { ctx, lead } = await setup(['hang']) + const started = await spawn(ctx, lead, 'owner') + const owner = await waitRunning(ctx, started.member.id) + const task = await ctx.teams.createTask(owner, { subject: 'lifecycle', description: 'lifecycle' }) + const assigned = await ctx.teams.updateTask(lead, { + taskId: task.id, + expectedRevision: task.revision, + action: 'reassign', + owner: 'owner', + }) + await expect(ctx.teams.updateTask(owner, { + taskId: task.id, + expectedRevision: assigned.revision, + action: 'reassign', + owner: 'lead', + })).rejects.toMatchObject({ code: 'TEAM_LEAD_REQUIRED' }) + const complete = await ctx.teams.updateTask(owner, { + taskId: task.id, + expectedRevision: assigned.revision, + action: 'complete', + }) + await expect(ctx.teams.updateTask(lead, { + taskId: task.id, + expectedRevision: complete.revision, + action: 'reassign', + owner: 'lead', + })).rejects.toMatchObject({ code: 'TEAM_TASK_INVALID_TRANSITION' }) + const reopened = await ctx.teams.updateTask(owner, { + taskId: task.id, + expectedRevision: complete.revision, + action: 'reopen', + }) + const claimed = await ctx.teams.updateTask(owner, { + taskId: task.id, + expectedRevision: reopened.revision, + action: 'claim', + }) + const deleted = await ctx.teams.updateTask(owner, { + taskId: task.id, + expectedRevision: claimed.revision, + action: 'delete', + }) + expect(deleted.status).toBe('deleted') + expect(ctx.teams.listTasks(lead)).toEqual([]) + await expect(ctx.teams.updateTask(owner, { + taskId: task.id, + expectedRevision: deleted.revision, + action: 'edit', + subject: 'late', + })).rejects.toMatchObject({ code: 'TEAM_TASK_DELETED' }) + ctx.teams.interrupt(lead, 'owner') + await waitNoAgent(ctx, owner.id) + }) + + it('covers partial edits, Lead ownership, unassignment, and blocked reassignment', async () => { + const { ctx, lead } = await setup(['hang']) + const started = await spawn(ctx, lead, 'editor') + const editor = await waitRunning(ctx, started.member.id) + const blocker = await ctx.teams.createTask(lead, { subject: 'blocker', description: 'blocker' }) + const task = await ctx.teams.createTask(lead, { + subject: 'draft', + description: 'draft description', + blockedBy: [blocker.id], + }) + await expect(ctx.teams.updateTask(lead, { + taskId: TeamTaskId('missing-update'), expectedRevision: 1, action: 'delete', + })).rejects.toMatchObject({ code: 'TEAM_TASK_NOT_FOUND' }) + await expect(ctx.teams.updateTask(lead, { + taskId: task.id, expectedRevision: task.revision, action: 'reassign', owner: 'editor', + })).rejects.toMatchObject({ code: 'TEAM_TASK_BLOCKED' }) + + const leadClaim = await ctx.teams.updateTask(lead, { + taskId: blocker.id, expectedRevision: blocker.revision, action: 'claim', + }) + expect(leadClaim.ownerName).toBe('lead') + const completedBlocker = await ctx.teams.updateTask(lead, { + taskId: blocker.id, expectedRevision: leadClaim.revision, action: 'complete', + }) + expect(completedBlocker.status).toBe('completed') + const assigned = await ctx.teams.updateTask(lead, { + taskId: task.id, expectedRevision: task.revision, action: 'reassign', owner: 'editor', + }) + const subject = await ctx.teams.updateTask(editor, { + taskId: task.id, expectedRevision: assigned.revision, action: 'edit', subject: 'edited subject', + }) + const description = await ctx.teams.updateTask(editor, { + taskId: task.id, + expectedRevision: subject.revision, + action: 'edit', + description: 'edited description', + }) + const scopes = await ctx.teams.updateTask(editor, { + taskId: task.id, + expectedRevision: description.revision, + action: 'edit', + writeScopes: ['src/nested'], + }) + expect(scopes).toMatchObject({ + subject: 'edited subject', + description: 'edited description', + writeScopes: ['src/nested'], + }) + const unassigned = await ctx.teams.updateTask(lead, { + taskId: task.id, expectedRevision: scopes.revision, action: 'reassign', owner: ' ', + }) + expect(unassigned).toMatchObject({ status: 'pending' }) + expect('ownerId' in unassigned).toBe(false) + + const broad = await ctx.teams.createTask(lead, { + subject: 'broad scope', description: 'broad scope', writeScopes: ['src'], + }) + const narrow = await ctx.teams.createTask(lead, { + subject: 'narrow scope', description: 'narrow scope', writeScopes: ['src/nested'], + }) + const disjoint = await ctx.teams.createTask(lead, { + subject: 'disjoint scope', description: 'disjoint scope', writeScopes: ['docs'], + }) + await ctx.teams.updateTask(lead, { + taskId: broad.id, expectedRevision: broad.revision, action: 'claim', + }) + await ctx.teams.updateTask(lead, { + taskId: narrow.id, expectedRevision: narrow.revision, action: 'claim', + }) + await ctx.teams.updateTask(lead, { + taskId: disjoint.id, expectedRevision: disjoint.revision, action: 'claim', + }) + expect(ctx.teams.getTask(lead, broad.id).writeScopeWarnings) + .toEqual([`write scopes overlap with ${narrow.id}`]) + + ctx.teams.interrupt(lead, 'editor') + await waitNoAgent(ctx, editor.id) + }) +}) + +describe('Team mailbox and waiting', () => { + it('acknowledges waking messages persisted by a busy Lead before model claim', async () => { + const { ctx, lead, teamFiber } = await setup(['hang', 'hang'], { maxPendingMessagesPerMember: 1 }) + const started = await spawn(ctx, lead, 'lead-reporter') + const reporter = await waitRunning(ctx, started.member.id) + lead.followup(createUserMessage({ content: content('keep the Lead busy'), source: { kind: 'user' } })) + await waitRunning(ctx, lead.id) + + const first = await ctx.teams.sendMessage(reporter, { + target: 'lead', content: content('first wakeup report'), delivery: 'wakeup', signal: SIGNAL, + }) + const second = await ctx.teams.sendMessage(reporter, { + target: 'lead', content: content('second wakeup report'), delivery: 'wakeup', signal: SIGNAL, + }) + expect([first.status, second.status]).toEqual(['accepted', 'accepted']) + expect(lead.status).toBe('running') + expect(durable(lead).pendingMessages).toEqual([]) + + const messageIds = new Set([first.messageId, second.messageId]) + const persisted = await ctx.sessionPersistence.inspect(lead.id) + const receiptOrder = persisted.events.flatMap((event) => { + if (event.type === 'agent/inbox/spliced' && event.data.inserted.some(message => + message.source.kind === 'team-message' && messageIds.has(message.source.messageId))) { + return ['agent/inbox/spliced'] + } + if (event.type === 'team/message/delivered' && messageIds.has(event.data.messageId)) { + return ['team/message/delivered'] + } + return [] + }) + expect(receiptOrder).toEqual([ + 'agent/inbox/spliced', + 'team/message/delivered', + 'agent/inbox/spliced', + 'team/message/delivered', + ]) + + const receiptCount = lead.session.events.filter(event => event.type === 'agent/inbox/spliced' + && event.data.inserted.some(message => message.source.kind === 'team-message' + && messageIds.has(message.source.messageId))).length + await teamFiber.dispose() + await ctx.plugin(TeamService, { maxPendingMessagesPerMember: 1 }) + await vi.waitFor(() => { expect(durable(lead).pendingMessages).toEqual([]) }) + expect(lead.session.events.filter(event => event.type === 'agent/inbox/spliced' + && event.data.inserted.some(message => message.source.kind === 'team-message' + && messageIds.has(message.source.messageId)))).toHaveLength(receiptCount) + + lead.cancel({ kind: 'parent' }) + await lead.whenIdle() + }) + + it('flushes a live pending receipt before acknowledgement without inserting a duplicate', async () => { + const { ctx, lead } = await setup(['hang']) + const started = await spawn(ctx, lead, 'pending-target') + const target = await waitRunning(ctx, started.member.id) + const immediate = await ctx.teams.sendMessage(lead, { + target: 'pending-target', + content: content('live quiet receipt'), + delivery: 'quiet', + signal: SIGNAL, + }) + expect(immediate.status).toBe('accepted') + expect(durable(lead).pendingMessages).toEqual([]) + expect(target.inbox.nextStep.some(item => item.source.kind === 'team-message' + && item.source.messageId === immediate.messageId)).toBe(true) + + const message: TeamMessageSnapshot = { + id: TeamMessageId('live-pending-message'), + senderId: lead.id, + senderName: 'lead', + targetId: target.id, + delivery: 'quiet', + content: content('durable pending receipt'), + } + lead.session.append('team/message/queued', { + version: 1, + teamId: TeamId(lead.id), + message, + }) + await ctx.sessions.flush(lead.session) + target.inject(createUserMessage({ + content: content('durable pending receipt'), + source: { + kind: 'team-message', + teamId: TeamId(lead.id), + messageId: message.id, + senderId: lead.id, + senderName: 'lead', + }, + })) + + const flush = ctx.sessions.flush.bind(ctx.sessions) + const flushed: SessionId[] = [] + const flushSpy = vi.spyOn(ctx.sessions, 'flush').mockImplementation(async (session) => { + flushed.push(session.id) + return flush(session) + }) + const delivered = await teamInternals(ctx).mailbox.tryDispatch(lead, message, SIGNAL) + + expect(delivered).toBe(true) + expect(flushed.slice(0, 2)).toEqual([target.id, lead.id]) + expect(target.inbox.nextStep.filter(item => item.source.kind === 'team-message' + && item.source.messageId === message.id)).toHaveLength(1) + expect(durable(lead).pendingMessages).toEqual([]) + + const disappearing: TeamMessageSnapshot = { + ...message, + id: TeamMessageId('disappearing-pending-message'), + content: content('canceled before checkpoint'), + } + lead.session.append('team/message/queued', { + version: 1, + teamId: TeamId(lead.id), + message: disappearing, + }) + await flush(lead.session) + const disappearingInput = createUserMessage({ + content: content('canceled before checkpoint'), + source: { + kind: 'team-message', + teamId: TeamId(lead.id), + messageId: disappearing.id, + senderId: lead.id, + senderName: 'lead', + }, + }) + target.inject(disappearingInput) + flushSpy.mockImplementationOnce(async (session) => { + target.inbox.remove(disappearingInput.id) + return flush(session) + }) + await expect(teamInternals(ctx).mailbox.tryDispatch(lead, disappearing, SIGNAL)).resolves.toBe(false) + expect(durable(lead).pendingMessages.map(pending => pending.id)).toEqual([disappearing.id]) + + ctx.teams.interrupt(lead, 'pending-target') + target.cancel({ kind: 'parent' }) + await waitNoAgent(ctx, target.id) + }) + + it('acknowledges waking messages accepted by a busy target inbox', async () => { + const { ctx, lead } = await setup(['hang'], { maxPendingMessagesPerMember: 1 }) + const started = await spawn(ctx, lead, 'busy-target') + const target = await waitRunning(ctx, started.member.id) + const flush = ctx.sessions.flush.bind(ctx.sessions) + const flushed: SessionId[] = [] + vi.spyOn(ctx.sessions, 'flush').mockImplementation(async (session) => { + flushed.push(session.id) + return flush(session) + }) + + const first = await ctx.teams.sendMessage(lead, { + target: 'busy-target', content: content('first waking message'), delivery: 'wakeup', signal: SIGNAL, + }) + + expect(first.status).toBe('accepted') + expect(flushed).toEqual([lead.id, target.id, lead.id]) + expect(durable(lead).pendingMessages).toEqual([]) + expect(target.inbox.nextTurn.some(message => message.source.kind === 'team-message' + && message.source.messageId === first.messageId)).toBe(true) + + flushed.length = 0 + const second = await ctx.teams.sendMessage(lead, { + target: 'busy-target', content: content('second waking message'), delivery: 'wakeup', signal: SIGNAL, + }) + + expect(second.status).toBe('accepted') + expect(flushed).toEqual([lead.id, target.id, lead.id]) + expect(durable(lead).pendingMessages).toEqual([]) + expect(target.inbox.nextTurn.filter(message => message.source.kind === 'team-message' + && (message.source.messageId === first.messageId || message.source.messageId === second.messageId))) + .toHaveLength(2) + + ctx.teams.interrupt(lead, 'busy-target') + target.cancel({ kind: 'parent' }) + await waitNoAgent(ctx, target.id) + }) + + it('serializes concurrent waking delivery admission for one target', async () => { + const { ctx, lead } = await setup([textResponse('target initial')]) + const target = await spawn(ctx, lead, 'ordered-target') + await waitNoAgent(ctx, target.member.id) + const entered = Promise.withResolvers() + const release = Promise.withResolvers() + const admitted: string[] = [] + vi.spyOn(ctx.subagents, 'followup').mockImplementation(async (_parent, _childId, blocks) => { + const last = blocks.at(-1) + const text = last?.type === 'text' ? last.text : '' + admitted.push(text) + if (text === 'first waking') { + entered.resolve(undefined) + await release.promise + } + return createUserMessage({ content: blocks, source: { kind: 'user' } }).id + }) + + const first = ctx.teams.sendMessage(lead, { + target: 'ordered-target', content: content('first waking'), delivery: 'wakeup', signal: SIGNAL, + }) + await entered.promise + let secondSettled = false + const second = ctx.teams.sendMessage(lead, { + target: 'ordered-target', content: content('second waking'), delivery: 'wakeup', signal: SIGNAL, + }).finally(() => { secondSettled = true }) + await new Promise((resolve) => { setTimeout(resolve, 0) }) + expect(admitted).toEqual(['first waking']) + expect(secondSettled).toBe(false) + + release.resolve(undefined) + await expect(Promise.all([first, second])).resolves.toMatchObject([ + { status: 'accepted' }, + { status: 'accepted' }, + ]) + expect(admitted).toEqual(['first waking', 'second waking']) + }) + + it('deduplicates live target history and contains inspection and delivery failures', async () => { + const { ctx, lead } = await setup(['hang', textResponse('inactive target initial')]) + const liveStarted = await spawn(ctx, lead, 'live-target') + const live = await waitRunning(ctx, liveStarted.member.id) + const internal = teamInternals(ctx).mailbox + const message: TeamMessageSnapshot = { + id: TeamMessageId('live-recorded-message'), + senderId: lead.id, + senderName: 'lead', + targetId: live.id, + delivery: 'wakeup', + content: content('already in live history'), + } + lead.session.append('team/message/queued', { + version: 1, teamId: TeamId(lead.id), message, + }) + await ctx.sessions.flush(lead.session) + live.session.append('user/message', createUserMessage({ + content: content('different Team message first'), + source: { + kind: 'team-message', + teamId: TeamId(lead.id), + messageId: TeamMessageId('other-message'), + senderId: lead.id, + senderName: 'lead', + }, + }), { surfaceOp: 'append' }) + live.session.append('user/message', createUserMessage({ + content: content('already in live history'), + source: { + kind: 'team-message', + teamId: TeamId(lead.id), + messageId: message.id, + senderId: lead.id, + senderName: 'lead', + }, + }), { surfaceOp: 'append' }) + await expect(internal.tryDispatch(lead, message, SIGNAL)).resolves.toBe(true) + await internal.markDelivered(lead, message.id, live.id) + + const wrongTarget: TeamMessageSnapshot = { + ...message, + id: TeamMessageId('wrong-target-message'), + } + lead.session.append('team/message/queued', { + version: 1, teamId: TeamId(lead.id), message: wrongTarget, + }) + await ctx.sessions.flush(lead.session) + await internal.markDelivered(lead, wrongTarget.id, SessionId('wrong-target')) + await expect(internal.serializeDispatch(wrongTarget, async () => true)).resolves.toBe(true) + const serialEntered = Promise.withResolvers() + const releaseSerial = Promise.withResolvers() + const serialFirst = internal.serializeDispatch(wrongTarget, async () => { + serialEntered.resolve(undefined) + await releaseSerial.promise + return true + }) + await serialEntered.promise + const serialSecond = internal.serializeDispatch({ + ...wrongTarget, id: TeamMessageId('second-serialized-message'), + }, async () => true) + releaseSerial.resolve(undefined) + await expect(Promise.all([serialFirst, serialSecond])).resolves.toEqual([true, true]) + + const warnings: string[] = [] + ctx.logger.warn = ((value: unknown) => { warnings.push(String(value)) }) as typeof ctx.logger.warn + const failedAck = vi.spyOn(ctx.sessions, 'flush').mockRejectedValueOnce(new Error('acknowledgement flush failed')) + live.session.append('user/message', createUserMessage({ + content: content('acknowledgement failure'), + source: { + kind: 'team-message', + teamId: TeamId(lead.id), + messageId: wrongTarget.id, + senderId: lead.id, + senderName: 'lead', + }, + }), { surfaceOp: 'append' }) + await vi.waitFor(() => { + expect(warnings.some(warning => warning.includes('acknowledgement flush failed'))).toBe(true) + }) + failedAck.mockRestore() + + const inactiveStarted = await spawn(ctx, lead, 'inactive-target') + await waitNoAgent(ctx, inactiveStarted.member.id) + const inspect = vi.spyOn(ctx.sessionPersistence, 'inspect').mockRejectedValueOnce(new Error('inspect unavailable')) + const uncertain = await ctx.teams.sendMessage(lead, { + target: 'inactive-target', content: content('inspection failure'), delivery: 'wakeup', signal: SIGNAL, + }) + expect(uncertain.status).toBe('queued') + inspect.mockRestore() + + vi.spyOn(ctx.subagents, 'followup').mockRejectedValueOnce(new Error('delivery unavailable')) + const failed = await ctx.teams.sendMessage(lead, { + target: 'inactive-target', content: content('delivery failure'), delivery: 'wakeup', signal: SIGNAL, + }) + expect(failed.status).toBe('queued') + expect(warnings.some(warning => warning.includes('inspect unavailable'))).toBe(true) + expect(warnings.some(warning => warning.includes('delivery unavailable'))).toBe(true) + + ctx.teams.interrupt(lead, 'live-target') + await waitNoAgent(ctx, live.id) + }) + + it('keeps quiet mail dormant, wakes on follow-up, preserves FIFO, and de-duplicates delivery', async () => { + const { ctx, lead } = await setup(['hang', textResponse('beta first'), textResponse('beta resumed')]) + const alphaStarted = await spawn(ctx, lead, 'alpha') + const alpha = await waitRunning(ctx, alphaStarted.member.id) + const betaStarted = await spawn(ctx, lead, 'beta') + await waitNoAgent(ctx, betaStarted.member.id) + + const quiet = await ctx.teams.sendMessage(alpha, { + target: 'beta', content: content('quiet info'), delivery: 'quiet', signal: SIGNAL, + }) + expect(quiet.status).toBe('queued') + expect(ctx.agents.get(betaStarted.member.id)).toBeUndefined() + const waking = await ctx.teams.sendMessage(alpha, { + target: 'beta', content: content('do another turn'), delivery: 'wakeup', signal: SIGNAL, + }) + expect(waking.status).toBe('accepted') + await waitNoAgent(ctx, betaStarted.member.id) + await vi.waitFor(() => { expect(durable(lead).pendingMessages).toEqual([]) }) + + const stored = await ctx.sessionPersistence.inspect(betaStarted.member.id) + const peerMessages = stored.events.filter(event => event.type === 'user/message' + && event.data.source.kind === 'team-message') + expect(peerMessages.map((event) => { + if (event.type !== 'user/message') return undefined + const block = event.data.content.at(-1) + return block?.type === 'text' ? block.text : undefined + })).toEqual(['quiet info', 'do another turn']) + expect(peerMessages.map(event => event.type === 'user/message' + ? event.data.content[0]?.type === 'text' && event.data.content[0].text + : undefined)).toEqual([ + expect.stringMatching(/^Team message .* from alpha:$/u), + expect.stringMatching(/^Team message .* from alpha:$/u), + ]) + expect(peerMessages.map(event => event.type === 'user/message' && event.data.source.kind === 'team-message' + ? [event.data.source.messageId, event.data.source.senderName] + : undefined)).toEqual([ + [quiet.messageId, 'alpha'], + [waking.messageId, 'alpha'], + ]) + + ctx.teams.interrupt(lead, 'alpha') + await waitNoAgent(ctx, alpha.id) + }) + + it('enforces message byte and pending-count limits without encouraging retry after enqueue', async () => { + const { ctx, lead } = await setup([textResponse('idle')], { + maxMessageBytes: 256, + maxPendingMessagesPerMember: 1, + }) + const target = await spawn(ctx, lead, 'target') + await waitNoAgent(ctx, target.member.id) + await expect(ctx.teams.sendMessage(lead, { + target: 'target', content: content('x'.repeat(300)), delivery: 'quiet', signal: SIGNAL, + })).rejects.toMatchObject({ code: 'TEAM_MESSAGE_TOO_LARGE' }) + const queued = await ctx.teams.sendMessage(lead, { + target: 'target', content: content('one'), delivery: 'quiet', signal: SIGNAL, + }) + expect(queued.status).toBe('queued') + await expect(ctx.teams.sendMessage(lead, { + target: 'target', content: content('two'), delivery: 'quiet', signal: SIGNAL, + })).rejects.toMatchObject({ code: 'TEAM_MAILBOX_FULL' }) + await expect(ctx.teams.sendMessage(lead, { + target: 'lead', content: content('self'), delivery: 'quiet', signal: SIGNAL, + })).rejects.toMatchObject({ code: 'TEAM_SELF_MESSAGE' }) + await expect(ctx.teams.sendMessage(lead, { + target: 'missing', content: content('unknown target'), delivery: 'quiet', signal: SIGNAL, + })).rejects.toMatchObject({ code: 'TEAM_MEMBER_NOT_FOUND' }) + const controller = new AbortController() + controller.abort(new TeamError('cancelled before queue', 'TEST_CANCELLED')) + await expect(ctx.teams.sendMessage(lead, { + target: 'target', content: content('cancelled'), delivery: 'quiet', signal: controller.signal, + })).rejects.toMatchObject({ code: 'TEST_CANCELLED' }) + }) + + it('interrupts only the current turn and retains an already accepted follow-up', async () => { + const { ctx, lead } = await setup(['hang', textResponse('after interrupt')]) + const started = await spawn(ctx, lead, 'worker') + const worker = await waitRunning(ctx, started.member.id) + const followup = await ctx.teams.sendMessage(lead, { + target: 'worker', content: content('retained follow-up'), delivery: 'wakeup', signal: SIGNAL, + }) + expect(followup.status).toBe('accepted') + expect(ctx.teams.interrupt(lead, 'worker')).toEqual({ previousStatus: 'running' }) + await vi.waitFor(() => { expect(worker.status).toBe('idle') }) + expect(worker.inbox.nextTurn.some(message => message.source.kind === 'team-message' + && message.source.messageId === followup.messageId)).toBe(true) + worker.cancel({ kind: 'parent' }) + await waitNoAgent(ctx, worker.id) + }) + + it('waits for one change, supports cancellation, times out, and releases waiters on HMR disposal', async () => { + const ctx = new Context() + await mountAgentLoopTestDependencies(ctx) + const storageRoot = mkdtempSync(join(tmpdir(), 'dsh-team-wait-')) + roots.push(storageRoot) + await ctx.plugin(JsonlSessionPersistence, { root: storageRoot }) + await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(SubagentService) + const fiber = await ctx.plugin(TeamService) + const service = ctx.teams + const lead = ctx.agentLoop.create(SessionId('wait-lead'), {}) + + await expect(service.waitForChange(lead, 9_999, SIGNAL)) + .rejects.toMatchObject({ code: 'TEAM_INVALID_TIMEOUT' }) + const alreadyAborted = new AbortController() + alreadyAborted.abort(new TeamError('cancelled before wait', 'TEST_CANCELLED')) + await expect(service.waitForChange(lead, 10_000, alreadyAborted.signal)) + .rejects.toMatchObject({ code: 'TEST_CANCELLED' }) + + const changed = service.waitForChange(lead, 10_000, SIGNAL) + const flush = ctx.sessions.flush.bind(ctx.sessions) + const flushEntered = Promise.withResolvers() + const releaseFlush = Promise.withResolvers() + vi.spyOn(ctx.sessions, 'flush').mockImplementationOnce(async (session) => { + flushEntered.resolve(undefined) + await releaseFlush.promise + return await flush(session) + }) + let waitSettled = false + void changed.finally(() => { waitSettled = true }) + const creating = service.createTask(lead, { subject: 'wake', description: 'wake waiter' }) + await flushEntered.promise + expect(waitSettled).toBe(false) + releaseFlush.resolve(undefined) + await creating + await expect(changed).resolves.toEqual({ timedOut: false }) + + const controller = new AbortController() + const cancelled = service.waitForChange(lead, 10_000, controller.signal) + controller.abort(new TeamError('cancelled', 'TEST_CANCELLED')) + await expect(cancelled).rejects.toMatchObject({ code: 'TEST_CANCELLED' }) + + const stringAbort = new AbortController() + const firstWaiter = service.waitForChange(lead, 10_000, stringAbort.signal) + const secondWaiter = service.waitForChange(lead, 10_000, SIGNAL) + stringAbort.abort('string cancellation') + await expect(firstWaiter).rejects.toMatchObject({ + code: 'TEAM_WAIT_ABORTED', + message: 'wait_agent aborted: string cancellation', + }) + await service.createTask(lead, { subject: 'second waiter', description: 'second waiter remains registered' }) + await expect(secondWaiter).resolves.toEqual({ timedOut: false }) + + const objectAbort = new AbortController() + const objectCancelled = service.waitForChange(lead, 10_000, objectAbort.signal) + objectAbort.abort({ kind: 'user' }) + await expect(objectCancelled).rejects.toMatchObject({ + code: 'TEAM_WAIT_ABORTED', + message: "wait_agent aborted: { kind: 'user' }", + }) + + await service.createTask(lead, { subject: 'already changed', description: 'edge-triggered wait' }) + vi.useFakeTimers() + const timeout = service.waitForChange(lead, 10_000, SIGNAL) + await vi.advanceTimersByTimeAsync(10_000) + await expect(timeout).resolves.toEqual({ timedOut: true }) + vi.useRealTimers() + + const disposed = service.waitForChange(lead, 10_000, SIGNAL) + await fiber.dispose() + await expect(disposed).resolves.toEqual({ timedOut: false }) + expect(ctx.get('teams')).toBeUndefined() + }) + + it('disposes live teammate Activations and their waits when the Team service unloads', async () => { + const { ctx, lead, teamFiber } = await setup(['hang']) + const started = await spawn(ctx, lead, 'dispose-worker') + await waitRunning(ctx, started.member.id) + const waiting = ctx.teams.waitForChange(lead, 10_000, SIGNAL) + + await teamFiber.dispose() + + await expect(waiting).resolves.toEqual({ timedOut: false }) + expect(ctx.agents.get(started.member.id)).toBeUndefined() + expect(ctx.get('teams')).toBeUndefined() + }) + + it('closes creation admission and drains an in-flight spawn before unload completes', async () => { + const { ctx, lead, teamFiber } = await setup(['hang']) + const service = ctx.teams + const start = ctx.subagents.startContinuable.bind(ctx.subagents) + const entered = Promise.withResolvers() + const release = Promise.withResolvers() + let childId: SessionId | undefined + vi.spyOn(ctx.subagents, 'startContinuable').mockImplementation(async (spec) => { + childId = spec.childId + entered.resolve(undefined) + await release.promise + return start(spec) + }) + const spawning = spawn(ctx, lead, 'disposing-worker') + const rejected = expect(spawning).rejects.toMatchObject({ code: 'TEAM_DISPOSED' }) + await entered.promise + + const disposal = teamFiber.dispose() + await Promise.resolve() + await expect(service.waitForChange(lead, 3_600_000, SIGNAL)).resolves.toEqual({ timedOut: false }) + await expect(service.spawnTeammate(lead, { + name: 'late-worker', + description: 'must not enter after disposal', + prompt: content('late task'), + context: 'fresh', + provider: 'spawn', + signal: SIGNAL, + })).rejects.toMatchObject({ code: 'TEAM_DISPOSED' }) + release.resolve(undefined) + + await rejected + await disposal + if (childId !== undefined) expect(ctx.agents.get(childId)).toBeUndefined() + expect(ctx.get('teams')).toBeUndefined() + }) + + it('retains an in-flight creation cleanup failure during disposal', async () => { + const { ctx } = await setup([]) + const internal = teamInternals(ctx) + const cleanupFailure = new Error('creation cleanup failed') + const rejected = Promise.reject(cleanupFailure) + void rejected.catch(() => undefined) + internal.roster.inFlightCreations.add(rejected) + + await expect(internal.disposeRuntime()).rejects.toMatchObject({ errors: [cleanupFailure] }) + }) + + it('recognizes wrapped and coded runtime cancellation during disposal settlement', async () => { + const open = new TeamRuntimeLifecycle(100) + const ordinaryFailure = new Error('ordinary failure before disposal') + const openFailures: unknown[] = [] + await open.settle([Promise.reject(ordinaryFailure)], openFailures) + expect(openFailures).toEqual([ordinaryFailure]) + + const lifecycle = new TeamRuntimeLifecycle(100) + lifecycle.close() + const failures: unknown[] = [] + await lifecycle.settle([ + Promise.reject(new Error('wrapped cancellation', { cause: lifecycle.reason })), + Promise.reject(new TeamError('translated cancellation', 'TEAM_DISPOSED')), + ], failures) + expect(failures).toEqual([]) + + const cyclic = new Error('unrelated cyclic failure') + cyclic.cause = cyclic + await lifecycle.settle([Promise.reject(cyclic)], failures) + expect(failures).toEqual([cyclic]) + }) + + it('disposes a live child even after its durable member edge becomes failed', async () => { + const { ctx, lead } = await setup(['hang']) + const childId = SessionId('failed-live-child') + const member = { + id: childId, + name: 'failed-live-worker', + description: 'failed-live-worker responsibility', + provider: 'spawn', + context: 'fresh' as const, + phase: 'provisioning' as const, + } + lead.session.append('team/member', { + version: 1, + teamId: TeamId(lead.id), + member, + }) + await ctx.subagents.startContinuable({ + childId, + provider: 'spawn', + label: member.description, + request: { prompt: content('failed child task'), parent: lead }, + signal: SIGNAL, + }) + await waitRunning(ctx, childId) + lead.session.append('team/member', { + version: 1, + teamId: TeamId(lead.id), + member: { + ...member, + phase: 'failed', + error: 'creation cleanup is pending', + }, + }) + await ctx.sessions.flush(lead.session) + expect(ctx.teams.listMembers(lead)[1]?.status).toBe('failed') + + const internal = ctx.teams as unknown as { disposeRuntime(): Promise } + await internal.disposeRuntime() + expect(ctx.agents.get(childId)).toBeUndefined() + }) + + it('aborts and awaits an admitted cold mailbox dispatch during disposal', async () => { + const { ctx, lead } = await setup([textResponse('worker done')]) + const started = await spawn(ctx, lead, 'mailbox-worker') + await waitNoAgent(ctx, started.member.id) + const entered = Promise.withResolvers() + const aborted = Promise.withResolvers() + const release = Promise.withResolvers() + vi.spyOn(ctx.subagents, 'followup').mockImplementation(async (_parent, _childId, _content, options) => { + entered.resolve(undefined) + return await new Promise((_resolve, reject) => { + options.signal.addEventListener('abort', () => { + aborted.resolve(undefined) + void release.promise.then(() => { + const reason: unknown = options.signal.reason + reject(reason instanceof Error ? reason : new Error(String(reason))) + }) + }, { once: true }) + }) + }) + + const sending = ctx.teams.sendMessage(lead, { + target: 'mailbox-worker', + content: content('resume during disposal'), + delivery: 'wakeup', + signal: SIGNAL, + }) + await entered.promise + const internal = ctx.teams as unknown as { disposeRuntime(): Promise } + let disposed = false + const disposal = internal.disposeRuntime().then(() => { disposed = true }) + await aborted.promise + await Promise.resolve() + expect(disposed).toBe(false) + release.resolve(undefined) + + await expect(sending).resolves.toMatchObject({ status: 'queued' }) + await disposal + expect(disposed).toBe(true) + expect(ctx.agents.get(started.member.id)).toBeUndefined() + }) + + it('awaits an admitted asynchronous acknowledgement before disposal completes', async () => { + const { ctx, lead } = await setup([]) + const message: TeamMessageSnapshot = { + id: TeamMessageId('dispose-ack-message'), + senderId: SessionId('sender'), + senderName: 'sender', + targetId: lead.id, + delivery: 'wakeup', + content: content('acknowledge before disposal'), + } + lead.session.append('team/message/queued', { + version: 1, + teamId: TeamId(lead.id), + message, + }) + await ctx.sessions.flush(lead.session) + + const entered = Promise.withResolvers() + const release = Promise.withResolvers() + const flush = ctx.sessions.flush.bind(ctx.sessions) + let blockReceipt = true + const flushSpy = vi.spyOn(ctx.sessions, 'flush').mockImplementation(async (session) => { + if (blockReceipt && session === lead.session) { + blockReceipt = false + entered.resolve(undefined) + await release.promise + } + return flush(session) + }) + lead.session.append('user/message', createUserMessage({ + content: content('acknowledge before disposal'), + source: { + kind: 'team-message', + teamId: TeamId(lead.id), + messageId: message.id, + senderId: message.senderId, + senderName: message.senderName, + }, + }), { surfaceOp: 'append' }) + + const internal = ctx.teams as unknown as { disposeRuntime(): Promise } + let disposed = false + const disposal = internal.disposeRuntime().then(() => { disposed = true }) + await entered.promise + await Promise.resolve() + const disposedBeforeRelease = disposed + release.resolve(undefined) + await disposal + + expect(disposedBeforeRelease).toBe(false) + expect(disposed).toBe(true) + expect(durable(lead).pendingMessages).toEqual([]) + flushSpy.mockRestore() + }) + + it('bounds Team runtime disposal when a continuation drain never settles', async () => { + const { ctx, lead, teamFiber } = await setup(['hang'], { disposalTimeoutMs: 25 }) + const started = await spawn(ctx, lead, 'stuck-worker') + await waitRunning(ctx, started.member.id) + const drain = vi.spyOn(ctx.subagents, 'drainContinuableChildren') + .mockImplementation(() => new Promise(() => {})) + + const outcome = await Promise.race([ + teamFiber.dispose().then(() => 'disposed'), + new Promise<'hung'>((resolve) => { setTimeout(() => { resolve('hung') }, 1_000) }), + ]) + expect(outcome).toBe('disposed') + expect(drain).toHaveBeenCalledWith(lead, [started.member.id]) + expect(ctx.get('teams')).toBeUndefined() + }) + + it('bounds disposal while an admitted creation ignores cancellation', async () => { + const { ctx, lead } = await setup([], { disposalTimeoutMs: 25 }) + const internal = teamInternals(ctx) + internal.roster.inFlightCreations.add(new Promise(() => {})) + + await expect(internal.disposeRuntime()).rejects.toBeInstanceOf(AggregateError) + await expect(ctx.teams.spawnTeammate(lead, { + name: 'after-timeout', + description: 'admission remains closed', + prompt: content('must reject'), + context: 'fresh', + provider: 'spawn', + signal: SIGNAL, + })).rejects.toMatchObject({ code: 'TEAM_DISPOSED' }) + await expect(ctx.teams.sendMessage(lead, { + target: 'nobody', content: content('must reject'), delivery: 'quiet', signal: SIGNAL, + })).rejects.toMatchObject({ code: 'TEAM_DISPOSED' }) + await expect(internal.mailbox.tryDispatch(lead, { + id: TeamMessageId('post-disposal-message'), + senderId: lead.id, + senderName: 'lead', + targetId: lead.id, + delivery: 'quiet', + content: content('must not dispatch'), + }, SIGNAL)).resolves.toBe(false) + }) + + it('contains recovery callback failures and ignores work scheduled after disposal', async () => { + const { ctx, lead, teamFiber } = await setup([]) + const warnings: string[] = [] + ctx.logger.warn = ((value: unknown) => { warnings.push(String(value)) }) as typeof ctx.logger.warn + const internal = teamInternals(ctx) + internal.recoverFor = async () => { throw new Error('forced recovery failure') } + internal.scheduleRecovery(lead) + await Promise.resolve() + await Promise.resolve() + expect(warnings.some(warning => warning.includes('forced recovery failure'))).toBe(true) + + lead.session.append('user/message', createUserMessage({ + content: content('orphan Team source'), + source: { + kind: 'team-message', + teamId: TeamId('absent-team'), + messageId: TeamMessageId('absent-team-message'), + senderId: SessionId('absent-sender'), + senderName: 'absent', + }, + }), { surfaceOp: 'append' }) + await Promise.resolve() + + const entered = Promise.withResolvers() + const release = Promise.withResolvers() + internal.recoverFor = async () => { + entered.resolve(undefined) + await release.promise + throw new Error('failure after disposal') + } + internal.scheduleRecovery(lead) + await entered.promise + await teamFiber.dispose() + release.resolve(undefined) + await Promise.resolve() + await Promise.resolve() + internal.scheduleRecovery(lead) + await Promise.resolve() + }) + + it('reports contained teardown failures without retaining the Team service', async () => { + const { ctx, lead, teamFiber } = await setup(['hang']) + const started = await spawn(ctx, lead, 'failing-drain') + await waitRunning(ctx, started.member.id) + vi.spyOn(ctx.subagents, 'drainContinuableDescendants').mockRejectedValueOnce(new Error('drain failure')) + + await teamFiber.dispose() + expect(ctx.get('teams')).toBeUndefined() + }) + + it('reconciles mismatched persisted children and ignores a concurrently settled member', async () => { + const first = await setup([]) + const liveId = SessionId('live-provisioning-child') + const live = await first.ctx.agents.create({ + sessionId: liveId, + meta: { parentSession: first.lead.id }, + agentOptions: { provider: 'mock', model: 'mock' }, + }) + const provisioning = { + id: liveId, + name: 'mismatched-child', + description: 'mismatched persisted child', + provider: 'spawn', + context: 'fresh' as const, + phase: 'provisioning' as const, + } + first.lead.session.append('team/member', { + version: 1, teamId: TeamId(first.lead.id), member: provisioning, + }) + const reconcileFirst = teamInternals(first.ctx).roster + await reconcileFirst.reconcileProvisioning(first.lead, SIGNAL) + expect(durable(first.lead).members[0]?.phase).toBe('provisioning') + live.agent.session.append('user/message', createUserMessage({ + content: content('persist mismatched child'), source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + await first.ctx.sessions.flush(live.agent.session) + await live.dispose() + await reconcileFirst.reconcileProvisioning(first.lead, SIGNAL) + expect(durable(first.lead).members[0]).toMatchObject({ + phase: 'failed', + error: 'persisted child Session does not match the provisioned continuation', + }) + + const second = await setup([]) + const childId = SessionId('concurrently-settled-child') + const member = { ...provisioning, id: childId, name: 'concurrent-child' } + second.lead.session.append('team/member', { + version: 1, teamId: TeamId(second.lead.id), member, + }) + const entered = Promise.withResolvers() + const release = Promise.withResolvers() + vi.spyOn(second.ctx.sessionPersistence, 'inspect').mockImplementationOnce(async () => { + entered.resolve(undefined) + await release.promise + throw new Error('late inspection failure') + }) + const reconcileSecond = teamInternals(second.ctx).roster + const reconciling = reconcileSecond.reconcileProvisioning(second.lead, SIGNAL) + await entered.promise + second.lead.session.append('team/member', { + version: 1, + teamId: TeamId(second.lead.id), + member: { ...member, phase: 'failed', error: 'settled elsewhere' }, + }) + release.resolve(undefined) + await reconciling + expect(durable(second.lead).members[0]).toMatchObject({ + phase: 'failed', error: 'settled elsewhere', + }) + }) +}) diff --git a/packages/experimental/team/tsconfig.json b/packages/experimental/team/tsconfig.json new file mode 100644 index 0000000000..d0defa8242 --- /dev/null +++ b/packages/experimental/team/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": ["src"], + "references": [ + { "path": "../../../vendor/cosmokit" }, + { "path": "../../../vendor/schemastery" }, + { "path": "../../../vendor/cordis" }, + { "path": "../../util/brand" }, + { "path": "../../llm/llm" }, + { "path": "../../core/session" }, + { "path": "../../core/agent" }, + { "path": "../../subagent/subagent" }, + { "path": "../../session/session-persistence" }, + { "path": "../../runtime-diagnostics/invariants" } + ] +} diff --git a/packages/experimental/team/tsdown.config.ts b/packages/experimental/team/tsdown.config.ts new file mode 100644 index 0000000000..e630b728b4 --- /dev/null +++ b/packages/experimental/team/tsdown.config.ts @@ -0,0 +1,25 @@ +import { defineConfig } from 'tsdown' + +/** Build the runtime and invariant as independent bundles so shared fold code stays package-local. */ +export default defineConfig([ + { + entry: ['lib/types/index.js'], + outDir: 'lib', + format: ['esm'], + platform: 'node', + target: 'es2024', + fixedExtension: false, + dts: false, + clean: false, + }, + { + entry: ['lib/types/invariant.js'], + outDir: 'lib', + format: ['esm'], + platform: 'node', + target: 'es2024', + fixedExtension: false, + dts: false, + clean: false, + }, +]) diff --git a/packages/experimental/tool-team/README.i18n.yaml b/packages/experimental/tool-team/README.i18n.yaml new file mode 100644 index 0000000000..9a4a8a211e --- /dev/null +++ b/packages/experimental/tool-team/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/experimental/tool-team/README.md +README.md: 1c0b2b4393be25f00af2f6b179ab4f925f9a1763 +README.zh.md: 8ddfebd17eb8f1a7a5cebeebab5ad4a47e091a41 diff --git a/packages/experimental/tool-team/README.md b/packages/experimental/tool-team/README.md new file mode 100644 index 0000000000..1c0b2b4393 --- /dev/null +++ b/packages/experimental/tool-team/README.md @@ -0,0 +1,49 @@ +# @deepseek-ai/dsh-tool-team + +English | [中文](README.zh.md) + +Scoped model-facing adapter for [`ctx.teams`](../team/README.md). It installs the Agent Teams policy and collaboration tools in each implicit Lead and durable teammate scope. Scoped Team definitions shadow same-named legacy global continuable-subagent controls, so a composition that mounts both must disable the legacy definitions. + +## Config + +```yaml +- id: tool-team + name: '@deepseek-ai/dsh-tool-team' + config: + freshProvider: spawn + forkProvider: fork +``` + +`freshProvider` and `forkProvider` select registered continuable-subagent providers. The fixed model policy creates teammates only when the user explicitly asks for Agent Teams or teammates. + +## Tools and authority + +The generated [tool catalog](../../../docs/tool-catalog.md#deepseek-aidsh-tool-team) owns exact schemas. The adapter supplies teammate creation; quiet and waking peer delivery; roster listing, waiting, and Lead-only interruption; and task create/list/get/compare-and-set update operations. + +Every tool requires the exact calling `Agent`. `spawn_teammate` and `interrupt_agent` enforce Lead authority inside `ctx.teams`, not only in their descriptions. All members can communicate with any peer and use the task board. Task mutations retain the domain's owner/Lead and revision checks. + +`send_message` succeeds once mail is durable and never wakes an inactive target. `followup_task` also makes the message the target's next turn and can cold-resume it. A `queued` result is accepted durable work and must not be retried. Task readiness does not start an owner. Before arming its 10,000-through-3,600,000-millisecond edge wait, `wait_agent` checks for another member that is running or provisioning; without one it returns `noProgress` immediately with instructions to re-list and use `followup_task`. Otherwise it waits for one post-call Team edge, defaulting to 30,000 milliseconds, and callers re-list after wakeup or timeout because earlier changes are not replayed. + +The plugin listens to Agent publication and installs its registrations through that Agent's scope. Fresh creation and cold resume therefore receive the same tool/prompt set before the first model request. Agent disposal and plugin HMR remove every scoped registration; reloading the plugin installs one fresh set in each still-live member without changing its continuation Activation. + +## Model Experience + +### Team policy and tools + +#### What the model sees + +One stable policy section states the exact Team role/name/id, explicit-delegation requirement, shared-cwd behavior, filesystem stale-version recovery, Bash/formatter/codegen risk, task/write-scope coordination, quiet versus waking delivery, no-retry mailbox rule, and the Lead's duty to wait before answering. The ten Team schemas from `spawn_teammate` through `team_task_update` appear only in Team member scopes. + +#### Token effect + +Fixed policy and schema cost on every Team member request. Tool calls add compact JSON roster, task, wait, or receipt results. Peer content is retained by the Team domain in the target's history. + +#### KV Cache effect + +Prefix-stable while the Team plugin generation, configuration, member role/name, and schemas remain unchanged. The per-member identity line differs across Agents. Tool results and peer messages append after the reusable request prefix. + +## Known Limitations and Deferred Work + +- **Prompt policy is coordination, not confinement** — it cannot stop Bash or external processes from writing overlapping files. +- **No autonomous team creation** — ordinary tasks do not trigger delegation unless the user explicitly requests it. +- **No Web controls** — browser roster and task-board presentation is outside this runtime package. diff --git a/packages/experimental/tool-team/README.zh.md b/packages/experimental/tool-team/README.zh.md new file mode 100644 index 0000000000..8ddfebd17e --- /dev/null +++ b/packages/experimental/tool-team/README.zh.md @@ -0,0 +1,49 @@ +# @deepseek-ai/dsh-tool-team + +[English](README.md) | 中文 + +[`ctx.teams`](../team/README.md) 的 scoped 模型适配器。它会在每个隐式 Lead 与持久 teammate scope 中安装 Agent Teams 策略和协作工具。scoped Team 定义会覆盖同名的旧全局 continuable-subagent control,因此同时挂载两者的组合必须禁用旧定义。 + +## 配置 + +```yaml +- id: tool-team + name: '@deepseek-ai/dsh-tool-team' + config: + freshProvider: spawn + forkProvider: fork +``` + +`freshProvider` 与 `forkProvider` 选择已注册的 continuable-subagent provider。固定模型策略仅在用户明确要求 Agent Teams 或 teammate 时创建 teammate。 + +## 工具与权限 + +生成的[工具目录](../../../docs/tool-catalog.md#deepseek-aidsh-tool-team)负责精确 schema。该适配器提供 teammate 创建;quiet 与 waking peer 投递;roster 列表、等待和仅限 Lead 的 interrupt;以及任务 create/list/get/CAS update 操作。 + +每个工具都要求完全相同的调用 `Agent`。`spawn_teammate` 与 `interrupt_agent` 在 `ctx.teams` 内部强制执行 Lead 权限,而不只依赖描述。所有成员都可以与任意 peer 通讯并使用任务板。任务变更保留领域层的 Owner/Lead 与 revision 校验。 + +`send_message` 在 mail 持久化后即成功,并且绝不会唤醒 inactive target。`followup_task` 还会让该消息成为 target 的下一个 turn,并可冷恢复 target。`queued` 结果表示持久工作已经接受,不能重试。任务 ready 不会启动 owner。`wait_agent` 在注册 10,000 到 3,600,000 毫秒的边等待前,会检查是否有另一个 running 或 provisioning member;如果没有,它会立即返回 `noProgress`,提示重新 list 并使用 `followup_task`。否则它会等待调用后发生的一条 Team 边,默认 30,000 毫秒;由于不会回放更早的变化,调用方需要在唤醒或超时后重新 list。 + +插件监听 Agent publication,并通过对应 Agent scope 安装注册。因此,fresh 创建与 cold resume 都会在第一次模型请求前获得相同工具/提示词集合。Agent dispose 和插件 HMR 会移除全部 scoped 注册;重新加载插件会为仍 live 的每个成员安装一套新注册,而不改变 continuation Activation。 + +## 模型体验 + +### Team 策略与工具 + +#### 模型看到的内容 + +一段稳定策略会说明确切 Team role/name/id、显式 delegation 要求、共享 cwd 行为、文件 stale-version 恢复、Bash/formatter/codegen 风险、task/write-scope 协调、quiet 与 waking 投递区别、mailbox 不重试规则,以及 Lead 必须在回答前等待。`spawn_teammate` 到 `team_task_update` 的 10 个 Team schema 只出现在 Team member scope。 + +#### Token 影响 + +每次 Team member 请求都有固定策略与 schema 成本。工具调用会增加紧凑 JSON roster、task、wait 或 receipt 结果。Peer 内容由 Team 领域保留在 target 历史中。 + +#### KV Cache 影响 + +Team 插件 generation、配置、member role/name 与 schema 不变时,前缀保持稳定。每个成员的身份行不同。工具结果与 peer 消息追加在可复用请求前缀之后。 + +## 已知限制与暂缓事项 + +- **提示词策略只负责协调,不负责 confinement**:它无法阻止 Bash 或外部进程写入重叠文件。 +- **不会自主创建 Team**:除非用户明确要求 delegation,普通任务不会触发组队。 +- **没有 Web 控制功能**:浏览器 roster 与任务板呈现不属于该 runtime 包。 diff --git a/packages/experimental/tool-team/package.json b/packages/experimental/tool-team/package.json new file mode 100644 index 0000000000..3aa6c766e2 --- /dev/null +++ b/packages/experimental/tool-team/package.json @@ -0,0 +1,61 @@ +{ + "name": "@deepseek-ai/dsh-tool-team", + "description": "Scoped model-facing Agent Teams tools over ctx.teams", + "version": "0.1.0-rc.7", + "private": true, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/experimental/tool-team" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.d.ts" + ], + "license": "MIT", + "dependencies": { + "@deepseek-ai/schemastery": "workspace:^" + }, + "peerDependencies": { + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-team": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" + }, + "devDependencies": { + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-agent-loop": "workspace:^", + "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", + "@deepseek-ai/dsh-scope": "workspace:^", + "@deepseek-ai/dsh-subagent": "workspace:^", + "@deepseek-ai/dsh-subagent-fork-in-process": "workspace:^", + "@deepseek-ai/dsh-subagent-spawn-in-process": "workspace:^", + "@deepseek-ai/dsh-tool-subagent-control": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-team": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" + } +} diff --git a/packages/experimental/tool-team/src/index.ts b/packages/experimental/tool-team/src/index.ts new file mode 100644 index 0000000000..0e35b85337 --- /dev/null +++ b/packages/experimental/tool-team/src/index.ts @@ -0,0 +1,418 @@ +/** Scoped model-facing tools for the opt-in Agent Teams runtime. */ + +import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' +import type { Agent } from '@deepseek-ai/dsh-agent' +import { TeamTaskId } from '@deepseek-ai/dsh-team' +import type { TeamMemberView } from '@deepseek-ai/dsh-team' +import { defineTool } from '@deepseek-ai/dsh-tools' +import type { InferValue, ValueSchemaSpec } from '@deepseek-ai/dsh-tools' + +/** Cordis plugin name. */ +export const name = 'tool-team' +/** Services required by the Team tool plugin. */ +export const inject = ['agents', 'teams', 'tools', 'systemPrompt'] + +/** Tool routing configuration. */ +export interface Config { + /** Continuable-subagent provider used for fresh teammates. */ + readonly freshProvider?: string + /** Continuable-subagent provider used for completed-prefix fork teammates. */ + readonly forkProvider?: string +} + +/** Loader schema for the opt-in Team tool plugin. */ +export const Config: z = z.object({ + freshProvider: z.string().default('spawn'), + forkProvider: z.string().default('fork'), +}) + +/** Model-facing collaboration guidance shared by Lead and teammates. */ +const POLICY = `Agent Teams is available in this session, but create teammates only when the user explicitly asks to use Agent Teams or teammates. + +The Team Lead and all teammates share the same working directory and filesystem. Edits are immediately visible to every member. Split write work into disjoint scopes, record expected write scopes on shared tasks, and use task dependencies when work must be ordered. Write-scope overlap is advisory, not a lock. + +Prefer read/edit/write for file changes. If a file operation returns FS_STALE_VERSION, read the current file, rebase your intended change onto the new content, and retry. Bash, formatters, code generators, and scripts are not fully protected by the filesystem version guard; coordinate them explicitly and have the Lead review the final diff and run tests. + +Use send_message for quiet information that must not start an idle teammate. Use followup_task when the target should run another turn. A delivered peer item starts with its stable message id and sender name. A successful send is already durable even when its result says queued; do not resend it. Shared-task workflow is list, get, claim with the current revision, perform the work, then complete. Task readiness never starts an owner. Before wait_agent, use list_agents and make sure another required member is running or provisioning; use followup_task first when the required member is inactive. wait_agent observes only changes after that call starts, never wakes a member, and returns noProgress immediately when no other member can produce a change. Re-list after wakeup or timeout. The Lead must wait for required teammates before giving the final answer.` + +const ACTIVE_WAIT_STATUSES: ReadonlySet = new Set(['running', 'provisioning']) +const NO_ACTIVE_PEER_MESSAGE = 'No other Team member is running or provisioning. wait_agent cannot make progress or wake inactive teammates. Re-list with list_agents and team_task_list, then use followup_task to wake each required inactive teammate before waiting again.' + +/** + * One roster row, matching `TeamMemberView`. The Lead pseudo-row omits the + * teammate-only provisioning fields, so only identity, role, status, and + * diagnostics are required. + */ +const MEMBER_VIEW_SCHEMA = { + type: 'object', + additionalProperties: false, + properties: { + id: { type: 'string', required: true }, + name: { type: 'string', required: true }, + role: { type: 'string', required: true, enum: ['lead', 'teammate'] }, + status: { type: 'string', required: true, enum: ['running', 'idle', 'inactive', 'provisioning', 'failed'] }, + description: { type: 'string' }, + provider: { type: 'string' }, + context: { type: 'string', enum: ['fresh', 'fork'] }, + model: { type: 'string' }, + diagnostics: { type: 'array', required: true, items: { type: 'string' } }, + }, +} as const + +/** One shared task, matching the public `TeamTaskView`. */ +const TASK_VIEW_SCHEMA = { + type: 'object', + additionalProperties: false, + properties: { + id: { type: 'string', required: true }, + revision: { type: 'integer', required: true }, + subject: { type: 'string', required: true }, + description: { type: 'string', required: true }, + status: { type: 'string', required: true, enum: ['pending', 'in_progress', 'completed', 'deleted'] }, + ownerName: { type: 'string' }, + blockedBy: { type: 'array', required: true, items: { type: 'string' } }, + writeScopes: { type: 'array', required: true, items: { type: 'string' } }, + ready: { type: 'boolean', required: true }, + writeScopeWarnings: { type: 'array', required: true, items: { type: 'string' } }, + }, +} as const + +const SPAWN_VALUE_SCHEMA = { + type: 'object', + additionalProperties: false, + properties: { + member: { ...MEMBER_VIEW_SCHEMA, required: true }, + }, +} as const + +const MEMBER_LIST_VALUE_SCHEMA = { type: 'array', items: MEMBER_VIEW_SCHEMA } as const + +const SEND_VALUE_SCHEMA = { + type: 'object', + additionalProperties: false, + properties: { + messageId: { type: 'string', required: true }, + status: { type: 'string', required: true, enum: ['accepted', 'queued'] }, + }, +} as const + +/** `noProgress` is present only on the model-only shortcut that skips the wait. */ +const WAIT_VALUE_SCHEMA = { + type: 'object', + additionalProperties: false, + properties: { + timedOut: { type: 'boolean', required: true }, + noProgress: { + type: 'object', + additionalProperties: false, + properties: { + reason: { type: 'string', required: true, const: 'no-active-peer' }, + message: { type: 'string', required: true }, + }, + }, + }, +} as const + +const INTERRUPT_VALUE_SCHEMA = { + type: 'object', + additionalProperties: false, + properties: { + previousStatus: { type: 'string', required: true, enum: ['running', 'idle', 'inactive'] }, + }, +} as const + +const TASK_LIST_VALUE_SCHEMA = { + type: 'object', + additionalProperties: false, + properties: { + tasks: { type: 'array', required: true, items: TASK_VIEW_SCHEMA }, + nextCursor: { type: 'integer' }, + }, +} as const + +/** + * Declare one canonical output schema with compact model-facing JSON. Every + * Team result is a fixed record, so the declared schema is what makes the + * compiler check `execute` against the value the model is promised. + * @param schema - canonical value schema for one tool. + * @returns the `output` declaration accepted by {@link defineTool}. + */ +function jsonOutput(schema: S): { + schema: S + render: (args: unknown, value: InferValue) => [{ type: 'text'; text: string }] +} { + return { + schema, + render: (_args: unknown, value: InferValue) => [{ type: 'text', text: JSON.stringify(value) }], + } +} + +/** Recover the exact caller guaranteed by Agent-scoped tool discovery. */ +function callingAgent(agent: Agent | undefined, toolName: string): Agent { + /* v8 ignore next 2 -- Team tools are registered only in an exact Agent scope, so discovery supplies this carrier. */ + if (agent === undefined) throw new Error(`${toolName} requires a calling Agent`) + return agent +} + +/** Register the complete Team tool set in one exact Agent scope. */ +function install(agent: Agent, ctx: Context, config: Required): () => void { + const scoped = agent.ctx + const disposers: Array<() => unknown> = [] + const register = (disposer: () => unknown): void => { disposers.push(disposer) } + try { + register(scoped.systemPrompt.section({ + name: 'team:policy', + order: 60, + text: () => { + const membership = ctx.teams.membership(agent) + return `${POLICY}\n\nYour Team role is ${membership.role}; your Team name is ${membership.name}; Team id is ${membership.id}.` + }, + })) + + register(scoped.tools.register(defineTool({ + name: 'spawn_teammate', + description: 'Create one named, durable teammate. Only the Team Lead may call this tool.', + parameters: { + name: { type: 'string', required: true, description: 'Unique lower-kebab-case teammate name.' }, + description: { type: 'string', required: true, description: 'Short description of the delegated responsibility.' }, + prompt: { type: 'string', required: true, description: 'Complete initial task for the teammate.' }, + context: { + type: 'string', + enum: ['fresh', 'fork'], + description: 'fresh starts without Lead history; fork inherits completed Lead turns. Defaults to fresh.', + }, + }, + output: jsonOutput(SPAWN_VALUE_SCHEMA), + async execute(args, exec) { + const agent = callingAgent(exec.agent, 'spawn_teammate') + const context = args.context ?? 'fresh' + return await ctx.teams.spawnTeammate(agent, { + name: args.name, + description: args.description, + prompt: [{ type: 'text', text: args.prompt }], + context, + provider: context === 'fork' ? config.forkProvider : config.freshProvider, + signal: exec.signal, + }) + }, + }))) + + const messageTool = (toolName: 'send_message' | 'followup_task', delivery: 'quiet' | 'wakeup'): void => { + register(scoped.tools.register(defineTool({ + name: toolName, + description: delivery === 'quiet' + ? 'Send durable information to another Team member without starting an idle member.' + : 'Send a durable follow-up task to another Team member and start a turn when needed.', + parameters: { + target: { type: 'string', required: true, description: 'Team member name, or lead.' }, + message: { type: 'string', required: true, description: 'Self-contained message for the target.' }, + }, + output: jsonOutput(SEND_VALUE_SCHEMA), + execute(args, exec) { + return ctx.teams.sendMessage(callingAgent(exec.agent, toolName), { + target: args.target, + content: [{ type: 'text', text: args.message }], + delivery, + signal: exec.signal, + }) + }, + }))) + } + messageTool('send_message', 'quiet') + messageTool('followup_task', 'wakeup') + + register(scoped.tools.register(defineTool({ + name: 'list_agents', + description: 'List the Lead and every durable teammate with current runtime status.', + parameters: {}, + output: jsonOutput(MEMBER_LIST_VALUE_SCHEMA), + async execute(_args, exec) { + return Promise.resolve(ctx.teams.listMembers(callingAgent(exec.agent, 'list_agents'))) + }, + }))) + + register(scoped.tools.register(defineTool({ + name: 'wait_agent', + description: 'Wait for the next teammate status, mailbox, or shared-task change after this call starts. This never wakes inactive members and returns noProgress immediately when no other member is running or provisioning. Re-list after wakeup or timeout instead of polling.', + parameters: { + timeout_ms: { + type: 'integer', + description: 'Wait duration in milliseconds, from 10000 through 3600000. Defaults to 30000.', + }, + }, + output: jsonOutput(WAIT_VALUE_SCHEMA), + async execute(args, exec) { + const caller = callingAgent(exec.agent, 'wait_agent') + const timeoutMs = args.timeout_ms ?? 30_000 + // Preserve TeamService's authoritative timeout validation before the + // model-only no-progress shortcut. + if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 10_000 || timeoutMs > 3_600_000) { + return await ctx.teams.waitForChange(caller, timeoutMs, exec.signal) + } + // The active-peer read and waiter registration must remain one synchronous + // span; awaiting between them can lose the only peer-status edge. + const hasActivePeer = ctx.teams.listMembers(caller).some(member => + member.id !== caller.id && ACTIVE_WAIT_STATUSES.has(member.status)) + if (!hasActivePeer) { + return { + timedOut: false, + noProgress: { + reason: 'no-active-peer' as const, + message: NO_ACTIVE_PEER_MESSAGE, + }, + } + } + return await ctx.teams.waitForChange(caller, timeoutMs, exec.signal) + }, + }))) + + register(scoped.tools.register(defineTool({ + name: 'interrupt_agent', + description: 'Interrupt one teammate\'s current turn while preserving its pending inbox. Team Lead only.', + parameters: { + target: { type: 'string', required: true, description: 'Teammate name.' }, + }, + output: jsonOutput(INTERRUPT_VALUE_SCHEMA), + async execute(args, exec) { + return Promise.resolve(ctx.teams.interrupt( + callingAgent(exec.agent, 'interrupt_agent'), + args.target, + )) + }, + }))) + + register(scoped.tools.register(defineTool({ + name: 'team_task_create', + description: 'Create one unowned pending task on the shared Team task board.', + parameters: { + subject: { type: 'string', required: true, description: 'Concise task title.' }, + description: { type: 'string', required: true, description: 'Complete task details and acceptance criteria.' }, + blocked_by: { type: 'array', items: { type: 'string' }, description: 'Task ids that must complete first.' }, + write_scopes: { + type: 'array', + items: { type: 'string' }, + description: 'Advisory workspace-relative file or directory prefixes this task expects to modify.', + }, + }, + output: jsonOutput(TASK_VIEW_SCHEMA), + async execute(args, exec) { + return await ctx.teams.createTask(callingAgent(exec.agent, 'team_task_create'), { + subject: args.subject, + description: args.description, + ...args.blocked_by === undefined ? {} : { blockedBy: args.blocked_by.map(TeamTaskId) }, + ...args.write_scopes === undefined ? {} : { writeScopes: args.write_scopes }, + }) + }, + }))) + + register(scoped.tools.register(defineTool({ + name: 'team_task_list', + description: 'List shared tasks, including readiness, owner, revision, blockers, and write-scope warnings.', + parameters: { + status: { + type: 'string', + enum: ['pending', 'in_progress', 'completed'], + description: 'Optional exact status filter.', + }, + owner: { type: 'string', description: 'Optional member-name filter; use unowned for tasks without an owner.' }, + ready: { type: 'boolean', description: 'Optional readiness filter.' }, + cursor: { type: 'integer', description: 'Zero-based result offset. Defaults to 0.' }, + limit: { type: 'integer', description: 'Number of rows, 1 through 100. Defaults to 50.' }, + }, + output: jsonOutput(TASK_LIST_VALUE_SCHEMA), + execute(args, exec) { + const status = args.status + const filtered = ctx.teams.listTasks(callingAgent(exec.agent, 'team_task_list')).filter(task => + (status === undefined || task.status === status) + && (args.owner === undefined || (args.owner === 'unowned' ? task.ownerName === undefined : task.ownerName === args.owner)) + && (args.ready === undefined || task.ready === args.ready)) + const cursor = args.cursor ?? 0 + const limit = args.limit ?? 50 + if (!Number.isSafeInteger(cursor) || cursor < 0) throw new Error('cursor must be a non-negative safe integer') + if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100) throw new Error('limit must be an integer from 1 through 100') + return Promise.resolve({ + tasks: filtered.slice(cursor, cursor + limit), + ...(cursor + limit < filtered.length ? { nextCursor: cursor + limit } : {}), + }) + }, + }))) + + register(scoped.tools.register(defineTool({ + name: 'team_task_get', + description: 'Read the complete latest value of one shared task before changing or executing it.', + parameters: { + task_id: { type: 'string', required: true, description: 'Shared task id.' }, + }, + output: jsonOutput(TASK_VIEW_SCHEMA), + async execute(args, exec) { + return Promise.resolve(ctx.teams.getTask( + callingAgent(exec.agent, 'team_task_get'), + TeamTaskId(args.task_id), + )) + }, + }))) + + register(scoped.tools.register(defineTool({ + name: 'team_task_update', + description: 'Compare-and-set a shared task action using the latest revision from team_task_get or team_task_list.', + parameters: { + task_id: { type: 'string', required: true, description: 'Shared task id.' }, + expected_revision: { type: 'integer', required: true, description: 'Current task revision used as the CAS precondition.' }, + action: { + type: 'string', + required: true, + enum: ['claim', 'release', 'edit', 'set_dependencies', 'complete', 'reopen', 'reassign', 'delete'], + description: 'Task transition to apply.', + }, + subject: { type: 'string', description: 'Replacement title for edit.' }, + description: { type: 'string', description: 'Replacement details for edit.' }, + blocked_by: { type: 'array', items: { type: 'string' }, description: 'Complete blocker list for set_dependencies.' }, + write_scopes: { type: 'array', items: { type: 'string' }, description: 'Replacement advisory write scopes for edit.' }, + owner: { type: 'string', description: 'Member name for Lead-only reassign; omit to unassign.' }, + }, + output: jsonOutput(TASK_VIEW_SCHEMA), + async execute(args, exec) { + return await ctx.teams.updateTask(callingAgent(exec.agent, 'team_task_update'), { + taskId: TeamTaskId(args.task_id), + expectedRevision: args.expected_revision, + action: args.action, + ...args.subject === undefined ? {} : { subject: args.subject }, + ...args.description === undefined ? {} : { description: args.description }, + ...args.blocked_by === undefined ? {} : { blockedBy: args.blocked_by.map(TeamTaskId) }, + ...args.write_scopes === undefined ? {} : { writeScopes: args.write_scopes }, + ...args.owner === undefined ? {} : { owner: args.owner }, + }) + }, + }))) + } catch (error: unknown) { + for (const dispose of disposers.reverse()) void dispose() + throw error + } + return () => { + for (const dispose of disposers.reverse()) void dispose() + } +} + +/** Install Team tools in every live or subsequently published Team member scope. */ +export function apply(ctx: Context, config: Config = {}): void { + const resolved: Required = { + freshProvider: config.freshProvider ?? 'spawn', + forkProvider: config.forkProvider ?? 'fork', + } + const installed = new Map void>() + const maybeInstall = (agent: Agent): void => { + if (installed.has(agent) || ctx.teams.tryMembership(agent) === undefined) return + installed.set(agent, install(agent, ctx, resolved)) + } + for (const agent of ctx.agents.list()) maybeInstall(agent) + ctx.on('agent/created', ({ agent }) => { maybeInstall(agent) }) + ctx.on('agent/disposed', ({ agent }) => { + installed.get(agent)?.() + installed.delete(agent) + }) + ctx.effect(() => () => { + for (const dispose of installed.values()) dispose() + installed.clear() + }, 'tool-team.scopedTools()') +} diff --git a/packages/experimental/tool-team/src/invariant.ts b/packages/experimental/tool-team/src/invariant.ts new file mode 100644 index 0000000000..9018b62f7b --- /dev/null +++ b/packages/experimental/tool-team/src/invariant.ts @@ -0,0 +1,18 @@ +/** Package-owned invariant companion for the Team tool adapter. */ + +import type { Context } from '@deepseek-ai/cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-tool-team' + +/** Cordis companion plugin name. */ +export const name = 'tool-team-invariant' +/** Invariant registry dependency. */ +export const inject = ['invariants'] + +/** No runtime invariant: the Team service owns durable and authorization relations. */ +const install: InvariantInstaller = () => {} + +/** Register this package's invariant ownership. */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) diff --git a/packages/experimental/tool-team/tests/tool-team.spec.ts b/packages/experimental/tool-team/tests/tool-team.spec.ts new file mode 100644 index 0000000000..f950af4aad --- /dev/null +++ b/packages/experimental/tool-team/tests/tool-team.spec.ts @@ -0,0 +1,442 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Context } from '@deepseek-ai/cordis' +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' +import { CallId } from '@deepseek-ai/dsh-llm' +import { scopeOf } from '@deepseek-ai/dsh-scope' +import { SessionId } from '@deepseek-ai/dsh-session' +import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' +import SubagentService from '@deepseek-ai/dsh-subagent' +import * as SubagentFork from '@deepseek-ai/dsh-subagent-fork-in-process' +import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn-in-process' +import { renderPrompt } from '@deepseek-ai/dsh-system-prompt' +import * as ToolSubagentControl from '@deepseek-ai/dsh-tool-subagent-control' +import { defineContentToolFixture } from '@deepseek-ai/dsh-tools' +import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' +import TeamService from '../../team/src/index.ts' +import * as toolTeam from '../src/index.ts' + +const SIGNAL = new AbortController().signal +const TOOL_NAMES = [ + 'spawn_teammate', + 'send_message', + 'followup_task', + 'list_agents', + 'wait_agent', + 'interrupt_agent', + 'team_task_create', + 'team_task_list', + 'team_task_get', + 'team_task_update', +].sort() + +const roots: string[] = [] +let callNumber = 0 + +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) +}) + +async function setup(script: ConstructorParameters[0], legacyControl = false) { + const ctx = new Context() + await mountAgentLoopTestDependencies(ctx) + const storageRoot = mkdtempSync(join(tmpdir(), 'dsh-tool-team-')) + roots.push(storageRoot) + await ctx.plugin(JsonlSessionPersistence, { root: storageRoot }) + await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(SubagentService) + if (legacyControl) await ctx.plugin(ToolSubagentControl) + await ctx.plugin(SubagentSpawn, { providerName: 'spawn' }) + await ctx.plugin(SubagentFork, { providerName: 'fork' }) + await ctx.plugin(TeamService) + const fiber = await ctx.plugin(toolTeam) + const adapter = new MockAdapter(script) + ctx.llm.registerAdapter(['mock'], adapter) + const lead = ctx.agentLoop.create(SessionId('tool-team-lead'), { provider: 'mock', model: 'mock' }) + return { ctx, lead, fiber } +} + +function execute( + ctx: Context, + agent: Agent | undefined, + name: string, + args: unknown, + signal: AbortSignal = SIGNAL, +) { + return ctx.tools.execute({ + callId: CallId(`team-call-${++callNumber}`), + name, + arguments: args, + signal, + ...agent === undefined ? {} : { agent }, + }) +} + +function text(result: Awaited>): string { + return result.content.flatMap(block => block.type === 'text' ? [block.text] : []).join('') +} + +function spawnedChildId(result: Awaited>): SessionId { + const parsed: unknown = JSON.parse(text(result)) + if (typeof parsed !== 'object' || parsed === null || !('member' in parsed)) { + throw new Error('spawn_teammate result has no member') + } + const member = parsed.member + if (typeof member !== 'object' || member === null || !('id' in member) || typeof member.id !== 'string') { + throw new Error('spawn_teammate result has no member id') + } + return SessionId(member.id) +} + +async function assembly(ctx: Context, agent: Agent) { + const scope = scopeOf(agent.ctx) + if (scope === undefined) throw new Error('expected Agent scope') + return ctx.systemPrompt.assemble({ scope }) +} + +async function waitRunning(ctx: Context, id: SessionId): Promise { + return vi.waitFor(() => { + const child = ctx.agents.get(id) + expect(child?.status).toBe('running') + return child! + }, { timeout: 5_000 }) +} + +async function waitNoAgent(ctx: Context, id: SessionId): Promise { + await vi.waitFor(() => { expect(ctx.agents.get(id)).toBeUndefined() }, { timeout: 5_000 }) +} + +describe('dsh-tool-team', () => { + it('installs the complete scoped schema and shared-checkout policy for roots and teammates', async () => { + const { ctx, lead } = await setup(['hang']) + const leadAssembly = await assembly(ctx, lead) + expect(leadAssembly.tools.map(schema => schema.name).filter(name => TOOL_NAMES.includes(name)).sort()) + .toEqual(TOOL_NAMES) + const leadPrompt = renderPrompt(leadAssembly) + expect(leadPrompt).toContain('create teammates only when the user explicitly asks') + expect(leadPrompt).toContain('FS_STALE_VERSION') + expect(leadPrompt).toContain('Bash, formatters, code generators, and scripts are not fully protected') + expect(leadPrompt).toContain('Task readiness never starts an owner') + expect(leadPrompt).toContain('returns noProgress immediately') + expect(leadPrompt).toContain('Your Team role is lead') + + const spawned = await execute(ctx, lead, 'spawn_teammate', { + name: 'tool-worker', + description: 'exercise scoped tools', + prompt: 'stay available', + }) + expect(spawned.isError).toBe(false) + const childId = spawnedChildId(spawned) + const child = await waitRunning(ctx, childId) + const childAssembly = await assembly(ctx, child) + expect(childAssembly.tools.map(schema => schema.name).filter(name => TOOL_NAMES.includes(name)).sort()) + .toEqual(TOOL_NAMES) + expect(renderPrompt(childAssembly)).toContain('Your Team role is teammate; your Team name is tool-worker') + + const denied = await execute(ctx, child, 'spawn_teammate', { + name: 'nested', description: 'not allowed', prompt: 'no', + }) + expect(denied.isError).toBe(true) + expect(text(denied)).toContain('only the Team Lead') + await execute(ctx, lead, 'interrupt_agent', { target: 'tool-worker' }) + await vi.waitFor(() => { expect(ctx.agents.get(childId)).toBeUndefined() }, { timeout: 5_000 }) + }) + + it('returns actionable no-progress output and renders structured wait cancellation', async () => { + const inactiveSetup = await setup([textResponse('worker done')]) + const inactiveSpawn = await execute(inactiveSetup.ctx, inactiveSetup.lead, 'spawn_teammate', { + name: 'inactive-worker', description: 'finish immediately', prompt: 'finish', + }) + const inactiveId = spawnedChildId(inactiveSpawn) + await waitNoAgent(inactiveSetup.ctx, inactiveId) + const noProgress = await execute(inactiveSetup.ctx, inactiveSetup.lead, 'wait_agent', { timeout_ms: 3_600_000 }) + expect(noProgress.isError).toBe(false) + expect(JSON.parse(text(noProgress))).toEqual({ + timedOut: false, + noProgress: { + reason: 'no-active-peer', + message: 'No other Team member is running or provisioning. wait_agent cannot make progress or wake inactive teammates. Re-list with list_agents and team_task_list, then use followup_task to wake each required inactive teammate before waiting again.', + }, + }) + for (const timeout_ms of [9_999, 3_600_001, Number.MAX_SAFE_INTEGER + 1]) { + const invalid = await execute(inactiveSetup.ctx, inactiveSetup.lead, 'wait_agent', { timeout_ms }) + expect(invalid.isError).toBe(true) + expect(text(invalid)).toContain('timeoutMs must be an integer from 10000 through 3600000') + } + + const activeSetup = await setup(['hang']) + const activeSpawn = await execute(activeSetup.ctx, activeSetup.lead, 'spawn_teammate', { + name: 'active-worker', description: 'stay active', prompt: 'wait', + }) + const activeId = spawnedChildId(activeSpawn) + await waitRunning(activeSetup.ctx, activeId) + const controller = new AbortController() + const waiting = execute(activeSetup.ctx, activeSetup.lead, 'wait_agent', { timeout_ms: 10_000 }, controller.signal) + await new Promise(resolve => setTimeout(resolve, 0)) + controller.abort({ kind: 'user' }) + const aborted = await waiting + expect(aborted.isError).toBe(true) + expect(text(aborted)).toBe("Error: wait_agent aborted: { kind: 'user' }") + await execute(activeSetup.ctx, activeSetup.lead, 'interrupt_agent', { target: 'active-worker' }) + await waitNoAgent(activeSetup.ctx, activeId) + }) + + it('adapts roster, mailbox, wait, and task CAS operations to canonical JSON', async () => { + const { ctx, lead } = await setup(['hang', textResponse('lead received wakeup')]) + const spawned = await execute(ctx, lead, 'spawn_teammate', { + name: 'json-worker', description: 'json worker', prompt: 'wait', context: 'fresh', + }) + const childId = spawnedChildId(spawned) + const child = await waitRunning(ctx, childId) + + const roster = await execute(ctx, child, 'list_agents', {}) + expect(JSON.parse(text(roster))).toMatchObject([ + { name: 'lead', role: 'lead' }, + { name: 'json-worker', role: 'teammate' }, + ]) + // Every Team result reaches the model as compact JSON: indentation would + // spend tokens on every roster, task, and receipt without adding meaning. + expect(text(roster)).toBe(JSON.stringify(JSON.parse(text(roster)))) + const peer = await execute(ctx, child, 'send_message', { target: 'lead', message: 'quiet report' }) + expect(peer.isError).toBe(false) + expect(JSON.parse(text(peer))).toMatchObject({ status: 'accepted' }) + const waking = await execute(ctx, child, 'followup_task', { target: 'lead', message: 'review the report' }) + expect(waking.isError).toBe(false) + expect(JSON.parse(text(waking))).toMatchObject({ status: 'accepted' }) + await lead.whenIdle() + + const created = await execute(ctx, lead, 'team_task_create', { + subject: 'tool task', + description: 'created through tool', + blocked_by: [], + write_scopes: ['src/team'], + }) + const task = JSON.parse(text(created)) as { id: string; revision: number } + const listed = await execute(ctx, child, 'team_task_list', { ready: true, limit: 1 }) + expect(JSON.parse(text(listed))).toMatchObject({ tasks: [{ id: task.id, ready: true }] }) + const read = await execute(ctx, child, 'team_task_get', { task_id: task.id }) + expect(JSON.parse(text(read))).toMatchObject({ id: task.id, revision: 1 }) + const claimed = await execute(ctx, child, 'team_task_update', { + task_id: task.id, + expected_revision: task.revision, + action: 'claim', + }) + expect(JSON.parse(text(claimed))).toMatchObject({ status: 'in_progress', ownerName: 'json-worker' }) + const stale = await execute(ctx, lead, 'team_task_update', { + task_id: task.id, + expected_revision: task.revision, + action: 'delete', + }) + expect(stale.isError).toBe(true) + expect(text(stale)).toContain('stale team task') + + const wait = execute(ctx, lead, 'wait_agent', { timeout_ms: 10_000 }) + const completedCall = new Promise>>((resolve, reject) => { + setTimeout(() => { + void execute(ctx, child, 'team_task_update', { + task_id: task.id, + expected_revision: 2, + action: 'complete', + }).then(resolve, reject) + }, 0) + }) + await expect(wait).resolves.toMatchObject({ isError: false }) + expect((await completedCall).isError).toBe(false) + + const childInterrupt = await execute(ctx, child, 'interrupt_agent', { target: 'json-worker' }) + expect(childInterrupt.isError).toBe(true) + await execute(ctx, lead, 'interrupt_agent', { target: 'json-worker' }) + await vi.waitFor(() => { expect(ctx.agents.get(childId)).toBeUndefined() }, { timeout: 5_000 }) + }) + + it('adapts optional task filters, mutations, pagination, and default waiting', async () => { + const { ctx, lead } = await setup(['hang']) + const spawned = await execute(ctx, lead, 'spawn_teammate', { + name: 'fork-worker', description: 'fork worker', prompt: 'stay active', context: 'fork', + }) + const childId = spawnedChildId(spawned) + await waitRunning(ctx, childId) + + const firstResult = await execute(ctx, lead, 'team_task_create', { + subject: 'first', description: 'first task', + }) + const secondResult = await execute(ctx, lead, 'team_task_create', { + subject: 'second', description: 'second task', + }) + const first = JSON.parse(text(firstResult)) as { id: string; revision: number } + const second = JSON.parse(text(secondResult)) as { id: string; revision: number } + const claimed = await execute(ctx, lead, 'team_task_update', { + task_id: first.id, expected_revision: first.revision, action: 'claim', + }) + const claim = JSON.parse(text(claimed)) as { revision: number } + + expect(JSON.parse(text(await execute(ctx, lead, 'team_task_list', { + status: 'in_progress', owner: 'lead', cursor: 0, limit: 1, + })))).toMatchObject({ tasks: [{ id: first.id }] }) + expect(JSON.parse(text(await execute(ctx, lead, 'team_task_list', { + owner: 'unowned', limit: 1, + })))).toMatchObject({ tasks: [{ id: second.id }] }) + expect(JSON.parse(text(await execute(ctx, lead, 'team_task_list', { + cursor: 0, limit: 1, + })))).toMatchObject({ nextCursor: 1 }) + expect(JSON.parse(text(await execute(ctx, lead, 'team_task_list', { + cursor: 1, + })))).not.toHaveProperty('nextCursor') + expect((await execute(ctx, lead, 'team_task_list', { cursor: -1 })).isError).toBe(true) + expect((await execute(ctx, lead, 'team_task_list', { limit: 101 })).isError).toBe(true) + + const edited = await execute(ctx, lead, 'team_task_update', { + task_id: first.id, + expected_revision: claim.revision, + action: 'edit', + subject: 'edited', + description: 'edited description', + write_scopes: ['src/team'], + }) + const edit = JSON.parse(text(edited)) as { revision: number } + const dependencies = await execute(ctx, lead, 'team_task_update', { + task_id: first.id, + expected_revision: edit.revision, + action: 'set_dependencies', + blocked_by: [second.id], + }) + expect(dependencies.isError).toBe(false) + const dependency = JSON.parse(text(dependencies)) as { revision: number } + expect((await execute(ctx, lead, 'team_task_update', { + task_id: first.id, + expected_revision: dependency.revision, + action: 'reassign', + owner: 'fork-worker', + })).isError).toBe(true) + + const wait = execute(ctx, lead, 'wait_agent', {}) + const wake = new Promise>>((resolve, reject) => { + setTimeout(() => { + void execute(ctx, lead, 'team_task_create', { + subject: 'wake', description: 'wake default wait', + }).then(resolve, reject) + }, 0) + }) + expect((await wait).isError).toBe(false) + expect((await wake).isError).toBe(false) + + await execute(ctx, lead, 'interrupt_agent', { target: 'fork-worker' }) + await vi.waitFor(() => { expect(ctx.agents.get(childId)).toBeUndefined() }, { timeout: 5_000 }) + }) + + it('removes and reinstalls every scoped registration across plugin HMR without stopping the child', async () => { + const { ctx, lead, fiber } = await setup(['hang']) + const spawned = await execute(ctx, lead, 'spawn_teammate', { + name: 'hmr-worker', description: 'hmr worker', prompt: 'wait', + }) + const childId = spawnedChildId(spawned) + const child = await waitRunning(ctx, childId) + + await fiber.dispose() + expect((await assembly(ctx, lead)).tools.map(schema => schema.name).some(name => TOOL_NAMES.includes(name))).toBe(false) + expect((await assembly(ctx, child)).tools.map(schema => schema.name).some(name => TOOL_NAMES.includes(name))).toBe(false) + expect(ctx.agents.get(childId)).toBe(child) + + const replacement = await ctx.plugin(toolTeam) + expect((await assembly(ctx, lead)).tools.map(schema => schema.name).filter(name => TOOL_NAMES.includes(name)).sort()) + .toEqual(TOOL_NAMES) + expect((await assembly(ctx, child)).tools.map(schema => schema.name).filter(name => TOOL_NAMES.includes(name)).sort()) + .toEqual(TOOL_NAMES) + await execute(ctx, lead, 'interrupt_agent', { target: 'hmr-worker' }) + await vi.waitFor(() => { expect(ctx.agents.get(childId)).toBeUndefined() }, { timeout: 5_000 }) + await replacement.dispose() + }) + + it('shadows legacy global control names only inside Team member scopes', async () => { + const { ctx, lead, fiber } = await setup([], true) + const teamSchema = (await assembly(ctx, lead)).tools.find(schema => schema.name === 'send_message') + expect(JSON.stringify(teamSchema)).toContain('target') + expect(JSON.stringify(teamSchema)).not.toContain('subagent_id') + + await fiber.dispose() + const legacySchema = (await assembly(ctx, lead)).tools.find(schema => schema.name === 'send_message') + expect(JSON.stringify(legacySchema)).toContain('subagent_id') + }) + + it('rolls back partial scoped installation after a same-scope collision', async () => { + const { ctx, lead, fiber } = await setup([]) + await fiber.dispose() + lead.ctx.tools.register(defineContentToolFixture({ + name: 'spawn_teammate', + description: 'intentional collision', + parameters: {}, + async execute() { return [{ type: 'text', text: 'collision' }] }, + })) + + await expect(ctx.plugin(toolTeam)).rejects.toThrow(/already registered/u) + const assembled = await assembly(ctx, lead) + expect(assembled.tools.filter(schema => TOOL_NAMES.includes(schema.name)).map(schema => schema.name)) + .toEqual(['spawn_teammate']) + expect(renderPrompt(assembled)).not.toContain('Your Team role is lead') + }) + + it('resolves direct-apply defaults without Loader schema normalization', async () => { + const { ctx, lead, fiber } = await setup([textResponse('ordinary child')]) + await fiber.dispose() + toolTeam.apply(ctx, {}) + expect((await assembly(ctx, lead)).tools.map(schema => schema.name).filter(name => TOOL_NAMES.includes(name)).sort()) + .toEqual(TOOL_NAMES) + const ordinary = await ctx.subagents.startContinuable({ + provider: 'spawn', + label: 'ordinary child', + request: { prompt: [{ type: 'text', text: 'finish' }], parent: lead }, + signal: SIGNAL, + }) + await vi.waitFor(() => { expect(ctx.agents.get(ordinary.childId)).toBeUndefined() }, { timeout: 5_000 }) + }) + + it('reinstalls Team scope before a cold-resumed teammate request', async () => { + const { ctx, lead } = await setup([textResponse('first'), 'hang']) + const spawned = await execute(ctx, lead, 'spawn_teammate', { + name: 'cold-worker', description: 'cold worker', prompt: 'finish once', + }) + const childId = spawnedChildId(spawned) + await vi.waitFor(() => { expect(ctx.agents.get(childId)).toBeUndefined() }, { timeout: 5_000 }) + + await ctx.teams.sendMessage(lead, { + target: 'cold-worker', + content: [{ type: 'text', text: 'resume with Team scope' }], + delivery: 'wakeup', + signal: SIGNAL, + }) + const resumed = await waitRunning(ctx, childId) + expect((await assembly(ctx, resumed)).tools.map(schema => schema.name) + .filter(name => TOOL_NAMES.includes(name)).sort()).toEqual(TOOL_NAMES) + expect(renderPrompt(await assembly(ctx, resumed))).toContain('Your Team role is teammate; your Team name is cold-worker') + await execute(ctx, lead, 'interrupt_agent', { target: 'cold-worker' }) + await vi.waitFor(() => { expect(ctx.agents.get(childId)).toBeUndefined() }, { timeout: 5_000 }) + }) + + it('fails safely without a calling Agent and has the function-plugin export shape', async () => { + const { ctx } = await setup([]) + const result = await execute(ctx, undefined, 'list_agents', {}) + expect(result.isError).toBe(true) + expect(text(result)).toContain('unknown tool "list_agents"') + expect('default' in toolTeam).toBe(false) + expect(toolTeam.name).toBe('tool-team') + expect(toolTeam.inject).toEqual(['agents', 'teams', 'tools', 'systemPrompt']) + }) + + it('uses configured fresh and fork provider names', async () => { + const { ctx, lead, fiber } = await setup([textResponse('custom')]) + await fiber.dispose() + await ctx.plugin(SubagentSpawn, { providerName: 'team-fresh' }) + await ctx.plugin(toolTeam, { freshProvider: 'team-fresh', forkProvider: 'fork' }) + const result = await execute(ctx, lead, 'spawn_teammate', { + name: 'custom-provider', description: 'custom provider', prompt: 'go', + }) + expect(result.isError).toBe(false) + const childId = spawnedChildId(result) + await vi.waitFor(() => { expect(ctx.agents.get(childId)).toBeUndefined() }, { timeout: 5_000 }) + expect(ctx.teams.listMembers(lead)[1]).toMatchObject({ provider: 'team-fresh' }) + }) +}) diff --git a/packages/experimental/tool-team/tsconfig.json b/packages/experimental/tool-team/tsconfig.json new file mode 100644 index 0000000000..f528b2280c --- /dev/null +++ b/packages/experimental/tool-team/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": ["src"], + "references": [ + { "path": "../../../vendor/cosmokit" }, + { "path": "../../../vendor/schemastery" }, + { "path": "../../../vendor/cordis" }, + { "path": "../../core/agent" }, + { "path": "../../core/scope" }, + { "path": "../../core/session" }, + { "path": "../../core/system-prompt" }, + { "path": "../../core/tools" }, + { "path": "../team" }, + { "path": "../../runtime-diagnostics/invariants" } + ] +} diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 8c70bf6424..78e948d3f9 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -1634,6 +1634,13 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ returns: 'once every retained descendant Activation released its `AgentHandle`.', throws: ['an aggregate error after all branches settle when any failed.'], }, + { + signature: 'async drainContinuableChildren(parent: Agent, childIds: readonly SessionId[]): Promise', + description: 'Release selected resident continuable direct children of one exact live parent. Other children of the same parent remain admitted and resident. Absent targets and a manager-less composition are accepted no-ops.', + parameters: [{ name: 'parent', description: 'exact live direct parent authorizing the selected release.' }, { name: 'childIds', description: 'durable direct-child ids to release when resident.' }], + returns: 'once every selected Activation released its `AgentHandle`.', + throws: ['{SubagentError} `UNAUTHORIZED` when a resident target belongs to a different parent or the supplied parent identity is stale.'], + }, { signature: 'listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise', description: 'Enumerate the parent\'s direct session-backed subagents without loading or resuming an Agent and without any query service: the listing merges the live session store with optional session persistence (live-preferred) and serves each child\'s durable mode/label from the registered `subagent` projection unit down a three-rung ladder — the registry\'s watermark snapshot for a live child; for a cold one, a durable projection-cache row when the optional cache serves an own-suffix identity (its `seq` gate proves the value postdates the fork seed, where a child\'s own descriptor is immutable once appended), else one persistence inspection folded through the registry. The projection fold is the single classification authority; per-child diagnostics relay a fold that served no identity or a failed inspection, never a list-time descriptor parse. Absent persistence, enumeration is live-only (a cold child cannot be resumed then either, so its absence is capability absence, not an error). This service consults no Agent registrations, Activations, or providers.\n\nEvery persistence read receives `signal`, and the listing rechecks cancellation around each of those awaits. Read rejections that settle after an abort become a stable `SubagentError` with code `CANCELLED`.', @@ -1742,6 +1749,79 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, ], }, + { + key: 'teams', + summary: 'Agent Teams service backed by the exact live Lead Session log.', + description: 'Agent Teams service backed by the exact live Lead Session log.', + methods: [ + { + signature: 'membership(agent: Agent): TeamMembership', + description: 'Resolve one exact live Agent\'s Team role.', + parameters: [{ name: 'agent', description: 'exact live Agent used as the authority credential.' }], + returns: 'its root, Team identity, role, and model-facing name.', + }, + { + signature: 'listMembers(agent: Agent): TeamMemberView[]', + description: 'List the runtime-enriched roster visible to one Team member.', + parameters: [{ name: 'agent', description: 'exact live Team member.' }], + returns: 'Lead and teammate rows in creation order.', + }, + { + signature: 'async spawnTeammate(caller: Agent, request: SpawnTeammateRequest): Promise', + description: 'Create one named, continuable direct child of the Team Lead.', + parameters: [{ name: 'caller', description: 'exact live Lead Agent.' }, { name: 'request', description: 'immutable name, description, prompt, context mode, provider, and cancellation.' }], + returns: 'the active roster row.', + }, + { + signature: 'async sendMessage(caller: Agent, request: SendTeamMessageRequest): Promise', + description: 'Queue one durable peer message, then attempt immediate delivery.', + parameters: [{ name: 'caller', description: 'exact live sending Team member.' }, { name: 'request', description: 'target name, content, scheduling mode, and pre-queue cancellation.' }], + returns: 'durable message identity and immediate-delivery observation.', + }, + { + signature: 'async createTask(caller: Agent, request: CreateTeamTaskRequest): Promise', + description: 'Create one unowned pending task in the Team Lead log.', + parameters: [{ name: 'caller', description: 'exact live Team member creating the task.' }, { name: 'request', description: 'task text, blockers, and advisory write scopes.' }], + returns: 'the revision-one task view.', + }, + { + signature: 'getTask(caller: Agent, id: TeamTaskId): TeamTaskView', + description: 'Return one task, including a deleted tombstone.', + parameters: [{ name: 'caller', description: 'exact live Team member reading the task.' }, { name: 'id', description: 'Team-local task identity.' }], + returns: 'the latest task value and derived readiness diagnostics.', + }, + { + signature: 'listTasks(caller: Agent): TeamTaskView[]', + description: 'List current non-deleted tasks in numeric creation order.', + parameters: [{ name: 'caller', description: 'exact live Team member reading the board.' }], + returns: 'detached current task views.', + }, + { + signature: 'async updateTask(caller: Agent, request: UpdateTeamTaskRequest): Promise', + description: 'Compare-and-set one authorized task transition.', + parameters: [{ name: 'caller', description: 'exact live Team member authorizing the mutation.' }, { name: 'request', description: 'task identity, expected revision, action, and action fields.' }], + returns: 'the committed next task revision.', + }, + { + signature: 'async waitForChange(caller: Agent, timeoutMs: number, signal: AbortSignal): Promise', + description: 'Wait for the next Team-domain or member-status change.', + parameters: [{ name: 'caller', description: 'exact live Team member waiting for activity.' }, { name: 'timeoutMs', description: 'bounded wait duration from ten seconds through one hour.' }, { name: 'signal', description: 'caller cancellation for the wait only.' }], + returns: 'one observed change or a timeout result.', + }, + { + signature: 'interrupt(caller: Agent, targetName: string): { previousStatus: \'running\' | \'idle\' | \'inactive\' }', + description: 'Interrupt one live teammate turn without clearing its pending inbox.', + parameters: [{ name: 'caller', description: 'exact live Lead Agent.' }, { name: 'targetName', description: 'durable teammate name.' }], + returns: 'the target status sampled before cancellation.', + }, + { + signature: 'tryMembership(agent: Agent): TeamMembership | undefined', + description: 'Resolve a caller without throwing, used by scoped-tool installation and observers.', + parameters: [{ name: 'agent', description: 'candidate exact live Agent.' }], + returns: 'Team membership, or undefined for non-Team subagents and stale identities.', + }, + ], + }, { key: 'terminals', summary: 'In-process registry for replaceable PTY backends and exact-Agent sessions.', @@ -2869,7 +2949,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ContinuableStartSpec', - declaration: 'export interface ContinuableStartSpec {\n readonly provider: string;\n readonly label: string;\n readonly request: Omit;\n readonly signal: AbortSignal;\n}', + declaration: 'export interface ContinuableStartSpec {\n readonly provider: string;\n readonly label: string;\n readonly childId?: SessionId;\n readonly request: Omit;\n readonly signal: AbortSignal;\n}', }, { name: 'ContinuableSubagentDescriptorData', @@ -2919,6 +2999,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'CreateSessionOptions', declaration: 'export interface CreateSessionOptions {\n readonly seed?: readonly SessionEvent[];\n readonly meta?: {\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly createdAt?: number;\n readonly seedLength?: number;\n readonly origin?: \'subagent\';\n readonly delegationDepth?: number;\n readonly agentPreset?: string;\n };\n}', }, + { + name: 'CreateTeamTaskRequest', + declaration: 'export interface CreateTeamTaskRequest {\n readonly subject: string;\n readonly description: string;\n readonly blockedBy?: readonly TeamTaskId[];\n readonly writeScopes?: readonly string[];\n}', + }, { name: 'CredentialInfo', declaration: 'export interface CredentialInfo {\n configured: boolean;\n source?: string;\n writable: boolean;\n}', @@ -3137,7 +3221,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ImageAttachmentLimits', - declaration: 'export interface ImageAttachmentLimits {\n maxImageBytes: number;\n maxImagesPerMessage: number;\n maxMessageImageBytes: number;\n maxImagePixels: number;\n mediaTypes: readonly ImageMediaType[];\n}', + declaration: 'export interface ImageAttachmentLimits {\n maxImageBytes: number;\n maxImagesPerMessage: number;\n maxMessageImageBytes: number;\n maxImagePixels: number;\n maxImageDimension: number;\n mediaTypes: readonly ImageMediaType[];\n}', }, { name: 'ImageAttachmentRef', @@ -3727,6 +3811,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SearchResultView', declaration: 'export type SearchResultView = SearchMatchesResultView | SearchPathsResultView;', }, + { + name: 'SendTeamMessageRequest', + declaration: 'export interface SendTeamMessageRequest {\n readonly target: string;\n readonly content: ContentBlock[];\n readonly delivery: \'quiet\' | \'wakeup\';\n readonly signal: AbortSignal;\n}', + }, + { + name: 'SendTeamMessageResult', + declaration: 'export interface SendTeamMessageResult {\n readonly messageId: TeamMessageId;\n readonly status: \'accepted\' | \'queued\';\n}', + }, { name: 'ServerResponse', declaration: 'export interface ServerResponse {\n type: \'server-response\';\n rpcId: RpcId;\n result: RpcResult;\n}', @@ -4071,6 +4163,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SkillViewOptions', declaration: 'export interface SkillViewOptions extends SkillLookupOptions {\n readonly scope?: ScopeKey | undefined;\n}', }, + { + name: 'SpawnTeammateRequest', + declaration: 'export interface SpawnTeammateRequest {\n readonly name: string;\n readonly description: string;\n readonly prompt: ContentBlock[];\n readonly context: \'fresh\' | \'fork\';\n readonly provider: string;\n readonly signal: AbortSignal;\n}', + }, + { + name: 'SpawnTeammateResult', + declaration: 'export interface SpawnTeammateResult {\n readonly member: TeamMemberView;\n}', + }, { name: 'SpillLocator', declaration: 'export type SpillLocator = Branded<\'SpillLocator\'>;', @@ -4129,7 +4229,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SubagentReportDelivery', - declaration: 'export type SubagentReportDelivery = \'quiet\' | \'wakeup\';', + declaration: 'export type SubagentReportDelivery = \'quiet\' | \'next-step\';', }, { name: 'SubagentReportOptions', @@ -4157,7 +4257,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SubagentRuntime', - declaration: 'export class SubagentRuntime extends Service {\n constructor(ctx: Context);\n async startContinuable(spec: ContinuableStartSpec): Promise;\n async followup(parent: Agent, childId: SessionId, content: ContentBlock[], options: SubagentFollowupOptions): Promise;\n interrupt(targetSessionId: SessionId, authority: SubagentInterruptAuthority): void;\n async reportFrom(child: Agent, content: ContentBlock[], options: SubagentReportOptions): Promise;\n registerContinuableSetup(contribution: ContinuableSetupContribution): () => void;\n async drainContinuableDescendants(parents: readonly Agent[]): Promise;\n listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise;\n listDescendants(rootSessionId: SessionId, signal?: AbortSignal): Promise;\n registerProvider(provider: SubagentProvider): () => void;\n getProvider(name: string): SubagentProvider | undefined;\n list(): string[];\n async start(name: string, request: SubagentStartRequest): Promise;\n}', + declaration: 'export class SubagentRuntime extends Service {\n constructor(ctx: Context);\n async startContinuable(spec: ContinuableStartSpec): Promise;\n async followup(parent: Agent, childId: SessionId, content: ContentBlock[], options: SubagentFollowupOptions): Promise;\n interrupt(targetSessionId: SessionId, authority: SubagentInterruptAuthority): void;\n async reportFrom(child: Agent, content: ContentBlock[], options: SubagentReportOptions): Promise;\n registerContinuableSetup(contribution: ContinuableSetupContribution): () => void;\n async drainContinuableDescendants(parents: readonly Agent[]): Promise;\n async drainContinuableChildren(parent: Agent, childIds: readonly SessionId[]): Promise;\n listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise;\n listDescendants(rootSessionId: SessionId, signal?: AbortSignal): Promise;\n registerProvider(provider: SubagentProvider): () => void;\n getProvider(name: string): SubagentProvider | undefined;\n list(): string[];\n async start(name: string, request: SubagentStartRequest): Promise;\n}', }, { name: 'SubagentStartRequest', @@ -4251,6 +4351,42 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'TableValueOf', declaration: 'export type TableValueOf = S[\'tables\'][N] extends DomainTableSpec ? V : never;', }, + { + name: 'TeamId', + declaration: 'export type TeamId = Branded<\'TeamId\'>;', + }, + { + name: 'TeamMembership', + declaration: 'export interface TeamMembership {\n readonly root: Agent;\n readonly id: TeamId;\n readonly role: \'lead\' | \'teammate\';\n readonly name: string;\n}', + }, + { + name: 'TeamMemberView', + declaration: 'export interface TeamMemberView {\n readonly id: SessionId;\n readonly name: string;\n readonly role: \'lead\' | \'teammate\';\n readonly status: \'running\' | \'idle\' | \'inactive\' | \'provisioning\' | \'failed\';\n readonly description?: string;\n readonly provider?: string;\n readonly context?: \'fresh\' | \'fork\';\n readonly model?: string;\n readonly diagnostics: string[];\n}', + }, + { + name: 'TeamMessageId', + declaration: 'export type TeamMessageId = Branded<\'TeamMessageId\'>;', + }, + { + name: 'TeamTaskAction', + declaration: 'export type TeamTaskAction = \'claim\' | \'release\' | \'edit\' | \'set_dependencies\' | \'complete\' | \'reopen\' | \'reassign\' | \'delete\';', + }, + { + name: 'TeamTaskId', + declaration: 'export type TeamTaskId = Branded<\'TeamTaskId\'>;', + }, + { + name: 'TeamTaskStatus', + declaration: 'export type TeamTaskStatus = \'pending\' | \'in_progress\' | \'completed\' | \'deleted\';', + }, + { + name: 'TeamTaskView', + declaration: 'export interface TeamTaskView {\n readonly id: TeamTaskId;\n readonly revision: number;\n readonly subject: string;\n readonly description: string;\n readonly status: TeamTaskStatus;\n readonly blockedBy: TeamTaskId[];\n readonly writeScopes: string[];\n readonly ownerName?: string;\n readonly ready: boolean;\n readonly writeScopeWarnings: string[];\n}', + }, + { + name: 'TeamWaitResult', + declaration: 'export interface TeamWaitResult {\n readonly timedOut: boolean;\n}', + }, { name: 'TerminalBackend', declaration: 'export interface TerminalBackend {\n readonly type: string;\n spawn(spec: TerminalBackendSpawnSpec): Promise;\n}', @@ -4531,6 +4667,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'TypertTypeModel', declaration: 'export interface TypertTypeModel {\n readonly name: string;\n readonly declaration: string;\n}', }, + { + name: 'UpdateTeamTaskRequest', + declaration: 'export interface UpdateTeamTaskRequest {\n readonly taskId: TeamTaskId;\n readonly expectedRevision: number;\n readonly action: TeamTaskAction;\n readonly subject?: string;\n readonly description?: string;\n readonly blockedBy?: readonly TeamTaskId[];\n readonly writeScopes?: readonly string[];\n readonly owner?: string;\n}', + }, { name: 'UserMessage', declaration: 'export interface UserMessage extends Message {\n readonly role: \'user\';\n}', diff --git a/packages/fs/tool-fs/README.i18n.yaml b/packages/fs/tool-fs/README.i18n.yaml index 88f33c5c3b..b8cbc626de 100644 --- a/packages/fs/tool-fs/README.i18n.yaml +++ b/packages/fs/tool-fs/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/fs/tool-fs/README.md -README.md: 71dc4134feee6bf1b5d17eef5f62e587653adf6d -README.zh.md: dff4b838aea4c6fa50a92ce2102bfdd2a96150a5 +README.md: ce7c0ea9070e30c1e6b538933ff5c4605b8d59cc +README.zh.md: 28360fbe6d0ab0f1a2b5ff11e44155ad6468274c diff --git a/packages/fs/tool-fs/README.md b/packages/fs/tool-fs/README.md index 71dc4134fe..ce7c0ea907 100644 --- a/packages/fs/tool-fs/README.md +++ b/packages/fs/tool-fs/README.md @@ -131,7 +131,7 @@ A successful `read_image` returns ``, `image { maxImagesPerMessage: 1, maxMessageImageBytes: 1024, maxImagePixels: 100, + maxImageDimension: 2000, mediaTypes: Object.freeze(['image/jpeg'] as const), }) @@ -390,6 +391,57 @@ describe('image admission failures', () => { const ctx = await setup({ storeConfig: { maxImagePixels: 4 } }) const result = await readImage(ctx, { file_path: 'big.png' }, agentOn('vision-model')) expect(result.isError).toBe(true) + expect(text(result)).toContain('exceeds the 4-pixel decoded-size limit') + expect(text(result)).toContain('downscale the image and read the smaller copy') + }) + + it('surfaces the per-side limit from attachment admission', async () => { + await writeFile(join(dir, 'wide.png'), PNG_3X3) + const ctx = await setup({ storeConfig: { maxImageDimension: 2 } }) + const result = await readImage(ctx, { file_path: 'wide.png' }, agentOn('vision-model')) + expect(result.isError).toBe(true) + expect(text(result)).toContain('at least one image side exceeds the 2px limit') + expect(text(result)).toContain('downscale the image and read the smaller copy') + }) + + it('passes storage faults and non-attachment failures through unchanged', async () => { + /** Store whose commit fails with a configurable error; admission itself passes. */ + class FailingStore extends AttachmentStore { + static failure: unknown + readonly imageLimits: ImageAttachmentLimits = Object.freeze({ + maxImageBytes: 1024, + maxImagesPerMessage: 1, + maxMessageImageBytes: 1024, + maxImagePixels: 100, + maxImageDimension: 2000, + mediaTypes: Object.freeze(['image/png'] as const), + }) + + validateImage(_input: SaveImageAttachment): Promise { + return Promise.resolve() + } + + async saveImage(_input: SaveImageAttachment): Promise { + throw FailingStore.failure + } + + readImage(_ref: ImageAttachmentRef): Promise { + throw new Error('unreachable in this test') + } + } + await writeFile(join(dir, 'red.png'), PNG_1X1) + const ctx = await setup({ attachments: false }) + await ctx.plugin(FailingStore) + + FailingStore.failure = new AttachmentError('Unable to persist image attachment.', 'ATTACHMENT_WRITE_FAILED') + const storageFault = await readImage(ctx, { file_path: 'red.png' }, agentOn('vision-model')) + expect(storageFault.isError).toBe(true) + expect(text(storageFault)).toContain('Unable to persist image attachment.') + + FailingStore.failure = new Error('unrelated infrastructure failure') + const unrelated = await readImage(ctx, { file_path: 'red.png' }, agentOn('vision-model')) + expect(unrelated.isError).toBe(true) + expect(text(unrelated)).toContain('unrelated infrastructure failure') }) it('reports a missing image file and a directory target through the fs vocabulary', async () => { @@ -415,6 +467,7 @@ describe('image admission failures', () => { maxImagesPerMessage: 1, maxMessageImageBytes: 1024, maxImagePixels: 100, + maxImageDimension: 2000, mediaTypes: Object.freeze(['image/png'] as const), }) diff --git a/packages/host/apiproxy/src/api/sessions.schema.ts b/packages/host/apiproxy/src/api/sessions.schema.ts index 0fbe06bf11..c415015776 100644 --- a/packages/host/apiproxy/src/api/sessions.schema.ts +++ b/packages/host/apiproxy/src/api/sessions.schema.ts @@ -231,6 +231,7 @@ export const imageLimitsProjectionSchema = z.object({ maxImagesPerMessage: z.number().int().positive(), maxMessageImageBytes: z.number().int().positive(), maxImagePixels: z.number().int().positive(), + maxImageDimension: z.number().int().positive(), mediaTypes: z.array(z.string()), }) as unknown as z.ZodType diff --git a/packages/host/apiproxy/tests/api-proxy-models.spec.ts b/packages/host/apiproxy/tests/api-proxy-models.spec.ts index 94f02fcdfb..d353ad0e62 100644 --- a/packages/host/apiproxy/tests/api-proxy-models.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-models.spec.ts @@ -147,6 +147,7 @@ describe('Web session model selection', () => { maxImagesPerMessage: 2, maxMessageImageBytes: 4, maxImagePixels: 4, + maxImageDimension: 2000, mediaTypes: ['image/png'], }, validateImage, diff --git a/packages/host/apiproxy/tests/api-proxy-projections.spec.ts b/packages/host/apiproxy/tests/api-proxy-projections.spec.ts index bef2dba6a2..0fb88c766d 100644 --- a/packages/host/apiproxy/tests/api-proxy-projections.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-projections.spec.ts @@ -94,6 +94,7 @@ describe('session.history projections block', () => { maxImagesPerMessage: 20, maxMessageImageBytes: 100 * 1024 * 1024, maxImagePixels: 40_000_000, + maxImageDimension: 2000, mediaTypes: ['image/png'] as const, } await ctx.plugin(class extends AttachmentStore { diff --git a/packages/llm/llm-pi-ai/README.i18n.yaml b/packages/llm/llm-pi-ai/README.i18n.yaml index c4f1dd2fa0..39451c8f11 100644 --- a/packages/llm/llm-pi-ai/README.i18n.yaml +++ b/packages/llm/llm-pi-ai/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/llm/llm-pi-ai/README.md -README.md: 3251cc7e71e343fa82ab76f95fe341949a0836c6 -README.zh.md: b62a4069e9f5c57533054d439fd63b9cbd6f44cd +README.md: 8d4e52f739fc767608eed2fdd1c91bfb09a99023 +README.zh.md: a547047802080c5ec03d606e3b6841302cbc78fc diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index 3251cc7e71..8d4e52f739 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -118,7 +118,7 @@ A model that carries reasoning metadata — from the installed catalog or from i A model **without** that metadata — a hand-declared one whose entry declares no `reasoningEfforts`, and a catalog model pi-ai marks as non-reasoning — exposes no `reasoning` at all. pi-ai reports such a model as supporting the single level `off`, but `off` is translated to *omitting* the reasoning option, which is byte-for-byte the request that naming no effort already produces: selecting it could not disable anything, so a provider whose own default is to think would keep thinking with `off` shown as selected. Reporting the capability as unavailable leaves a surface offering the provider's default and nothing that misrepresents it. The profile `reasoning` value, including `off`, is the deployment default when configured; omitting it preserves the provider default. Per-request `GenerateOptions.reasoningEffort` takes precedence, and a level absent from the exact model capability fails the REQUEST with `UNSUPPORTED_REASONING_EFFORT` before network I/O instead of being clamped. Describing a model never fails that way: the models under one provider disagree about which levels they accept, so `resolveModel` reports a profile level the exact model cannot take as no default at all rather than throwing. A throw there would take the whole provider out of every model catalog built over it — one mis-set profile field hiding even the models that do support the level — so a bad configuration surfaces where it is acted on, not where it is described. pi-ai's common stream options represent `off` by omitting `reasoning`. -Supported profile fields are `apiKeyEnv`, `displayName`, `api`, `baseURL`, `models`, `modelOverrides`, `compat`, `defaultContextWindow`, `defaultMaxTokens`, `defaultInput`, `headers`, `reasoning`, `thinkingBudgets`, `cacheRetention`, `transport`, `timeoutMs`, `websocketConnectTimeoutMs`, `streamIdleTimeoutMs`, and `retryPolicy`. Each profile's optional retry policy is captured with that provider route; omission uses bounded normal defaults. The stream-idle interval is a positive finite Node timer delay, defaults to five minutes, and covers only an outstanding provider read, not consumer think time. Harness app attribution wins a conflicting configured header name. +Supported profile fields are `apiKeyEnv`, `displayName`, `api`, `baseURL`, `models`, `modelOverrides`, `compat`, `defaultContextWindow`, `defaultMaxTokens`, `defaultInput`, `headers`, `reasoning`, `thinkingBudgets`, `cacheRetention`, `transport`, `timeoutMs`, `websocketConnectTimeoutMs`, `streamIdleTimeoutMs`, `maxRequestImageBytes`, and `retryPolicy`. Each profile's optional retry policy is captured with that provider route; omission uses bounded normal defaults. The stream-idle interval is a positive finite Node timer delay, defaults to five minutes, and covers only an outstanding provider read, not consumer think time. `maxRequestImageBytes` bounds one request's base64-encoded image payload (default 20MiB, a positive integer): every image in history is re-encoded into every request, so when the accumulated payload exceeds the bound, the oldest images are replaced by a fixed text placeholder until the request fits, keeping an image-heavy session serviceable instead of permanently rejected by a gateway request-size cap. The default leaves capacity for system prompts, history, tools, and JSON; deployments behind stricter gateways lower it per route. Harness app attribution wins a conflicting configured header name. The adapter forces pi-ai's SDK `maxRetries` to zero so one `stream()` call makes one provider request. The removed profile fields `maxRetries` and `maxRetryDelayMs` fail load instead of silently multiplying or hiding the separately composed agent-level retry budget. Idle expiry aborts the SDK's stable request signal and surfaces `TIMEOUT`; an earlier caller abort remains `ABORTED`. @@ -168,15 +168,15 @@ pi-ai installs several provider SDKs and lazy-loads the one selected by the cata #### What the model sees -The selected catalog model receives `GenerateOptions.system`, history, tools, and sampling fields supported by pi-ai's common streaming API. This package adds no prompt prose. Provider-native replay metadata is restored only when the adapter validates it for the historical content. +The selected catalog model receives `GenerateOptions.system`, history, tools, and sampling fields supported by pi-ai's common streaming API. This package adds no prompt prose, with one exception: when a request's accumulated base64 image payload exceeds the route's `maxRequestImageBytes`, each offloaded image (oldest first) is replaced by fixed text. The text tells the model to read the file again when a path is available or ask the user to attach the image again. Provider-native replay metadata is restored only when the adapter validates it for the historical content. #### Token effect -Provider tokenization governs exact input. Conversion adds no model-visible text; replay metadata may let a native API reuse provider-side state. +Provider tokenization governs exact input. Conversion adds no model-visible text beyond the image-offload placeholder, which replaces the offloaded image's visual tokens with a short fixed sentence; replay metadata may let a native API reuse provider-side state. #### KV Cache effect -Conversion preserves logical request order without adding text, while the selected provider's serialization and replay state determine reuse. Changing adapter instance, provider, model, or any upstream request token may prevent reuse from the first difference. +Conversion preserves logical request order without adding text, while the selected provider's serialization and replay state determine reuse. Changing adapter instance, provider, model, or any upstream request token may prevent reuse from the first difference. Crossing the image bound rewrites an early message (the newly offloaded image becomes placeholder text), so reuse ends at that message until the offloaded prefix stabilizes. ### Provider response @@ -194,6 +194,7 @@ Recorded response content appends to the next request and does not invalidate it ## Known Limitations and Deferred Work +- **`maxRequestImageBytes` counts base64 image payload only** — text, tools, and JSON structure ride outside the bound, so it must sit below the gateway's request-body cap with headroom. Offload is decided at request conversion as a pure function of history and configuration and is not recorded as a session event; per-route capability metadata (image count, per-image size, total request size) driving admission and assembly together is deferred design work. - **A provider that authenticates through OAuth alone is not offered** — pi-ai resolves OAuth from a *stored* OAuth credential, and this adapter builds its `Models` collection with no credential store and runs no login flow, so every request on such a route fails `Provider is not configured` before it goes out. The configurable-provider directory withholds them; `openai-codex` is the only one the installed catalog ships. A route a settings document already names keeps its entry so a configuration surface can edit or delete it, and `apiKeyEnv` still authenticates it with that key — which for Codex is a token that expires with nothing here to refresh it. - **Provider-native discovery reads the process environment only** — a route naming no credential defers to the catalog provider's own resolution, which interrogates environment variables (`AZURE_OPENAI_API_KEY`, `AWS_PROFILE`, `AWS_ACCESS_KEY_ID`, and each provider's own set). It reads no local credential directory, so `~/.aws/credentials` without an exported `AWS_PROFILE` resolves as unconfigured, and a value held by the harness credential seam is invisible to it unless the process environment carries it too. - **Settings can add or override routes, not remove composition routes** — the user layer merges over the composition `base`, so deleting a `cordis.yml`-provided provider is a composition change; `replace` on the namespace only resets the user layer. diff --git a/packages/llm/llm-pi-ai/README.zh.md b/packages/llm/llm-pi-ai/README.zh.md index b62a4069e9..a547047802 100644 --- a/packages/llm/llm-pi-ai/README.zh.md +++ b/packages/llm/llm-pi-ai/README.zh.md @@ -119,7 +119,7 @@ pi-ai 依据提供方 id 与 baseURL 决定每个请求的形状:系统提示 **没有**这份元数据的模型——条目未声明 `reasoningEfforts` 的手工声明模型,以及 pi-ai 标记为不具备推理能力的 catalog 模型——完全不公开 `reasoning`。pi-ai 会把这类模型报告为只支持 `off` 一档,但 `off` 会被翻译成*省略* reasoning 选项,而那与「不点名任何档位」产出的请求逐字节相同:选它关不掉任何东西,于是自身默认就在思考的提供方,会在界面显示 `off` 被选中的同时继续思考。把该能力报告为不可用,界面就只剩提供方默认这一项,不会再出现自相矛盾的控件。配置 profile 的 `reasoning` 值(包括 `off`)在存在时是部署默认值;省略它会保留提供方默认值。每次请求的 `GenerateOptions.reasoningEffort` 优先;未出现在确切模型能力中的档位会让**请求**在网络 I/O 前以 `UNSUPPORTED_REASONING_EFFORT` 失败,而不会被自动调整。**描述**一个模型则从不这样失败:同一提供方下各模型接受的档位并不一致,因此 `resolveModel` 对该模型拿不下的 profile 档位报告为「没有默认值」,而不是抛错。在那里抛错会让整个提供方从任何基于它构建的模型目录中消失——一个配错的 profile 字段连支持该档位的模型也一并藏起来——所以坏配置暴露在被执行处,而不是被描述处。pi-ai 的通用流选项通过省略 `reasoning` 表示 `off`。 -受支持的 profile 字段是 `apiKeyEnv`、`displayName`、`api`、`baseURL`、`models`、`modelOverrides`、`compat`、`defaultContextWindow`、`defaultMaxTokens`、`defaultInput`、`headers`、`reasoning`、`thinkingBudgets`、`cacheRetention`、`transport`、`timeoutMs`、`websocketConnectTimeoutMs`、`streamIdleTimeoutMs` 和 `retryPolicy`。每个 profile 的可选重试策略都会与该提供方路由一同捕获;省略时使用有界的常规默认值。流空闲间隔必须是正的有限 Node 定时器延迟,默认为五分钟,且只覆盖未完成提供方读取,不包括消费方思考时间。若已配置标头中有同名项,则以 Harness 应用归因为准。 +受支持的 profile 字段是 `apiKeyEnv`、`displayName`、`api`、`baseURL`、`models`、`modelOverrides`、`compat`、`defaultContextWindow`、`defaultMaxTokens`、`defaultInput`、`headers`、`reasoning`、`thinkingBudgets`、`cacheRetention`、`transport`、`timeoutMs`、`websocketConnectTimeoutMs`、`streamIdleTimeoutMs`、`maxRequestImageBytes` 和 `retryPolicy`。每个 profile 的可选重试策略都会与该提供方路由一同捕获;省略时使用有界的常规默认值。流空闲间隔必须是正的有限 Node 定时器延迟,默认为五分钟,且只覆盖未完成提供方读取,不包括消费方思考时间。`maxRequestImageBytes` 约束单个请求的 base64 编码图片载荷(默认 20MiB,正整数):历史中的每张图片都会重新编码进每个请求,累积载荷超过上限时,从最老的图片开始替换为固定文本占位,直到请求装得下,使图片较多的会话保持可用,而不是被网关请求体上限永久拒绝。默认值为系统提示词、历史、工具与 JSON 保留请求容量;网关更严格的部署按路由调低该值。若已配置标头中有同名项,则以 Harness 应用归因为准。 适配器强制 pi-ai SDK `maxRetries` 为零,因此一次 `stream()` 调用只会发起一次提供方请求。已移除 profile 字段 `maxRetries` 和 `maxRetryDelayMs` 会使加载失败,而不是静默倍增或隐藏单独组合的 agent(智能体)级重试预算。空闲超时会 abort SDK 的稳定请求信号,并以 `TIMEOUT` 呈现;较早的调用方 abort 仍为 `ABORTED`。 @@ -169,15 +169,15 @@ pi-ai 会安装多个提供方 SDK,并延迟加载 catalog 模型所选的 SDK #### 模型看到的内容 -所选 catalog 模型会收到 `GenerateOptions.system`、历史、工具,以及 pi-ai 通用流式 API 支持的采样字段。本包不添加提示词文本。只有当适配器验证提供方原生回放元数据与历史内容匹配时,才会恢复这些元数据。 +所选 catalog 模型会收到 `GenerateOptions.system`、历史、工具,以及 pi-ai 通用流式 API 支持的采样字段。本包不添加提示词文本,仅有一个例外:请求累积的 base64 图片载荷超过路由的 `maxRequestImageBytes` 时,被 offload 的图片(从最老开始)会被替换为一段固定文本。该文本要求模型在有路径时重新读取文件,否则请用户重新附上图片。只有当适配器验证提供方原生回放元数据与历史内容匹配时,才会恢复这些元数据。 #### Token 影响 -精确输入取决于提供方 tokenization。转换不添加模型可见文本;回放元数据可能让原生 API 复用提供方侧状态。 +精确输入取决于提供方 tokenization。除图片 offload 占位文本外,转换不添加模型可见文本;占位文本用一句固定短句替代被省略图片的视觉 token。回放元数据可能让原生 API 复用提供方侧状态。 #### KV Cache 影响 -转换保留逻辑请求顺序,不添加文本;复用取决于所选提供方的序列化与回放状态。更改适配器实例、提供方、模型或任何上游请求 token,都可能使复用从首个出现差异的 token 起失效。 +转换保留逻辑请求顺序,不添加文本;复用取决于所选提供方的序列化与回放状态。更改适配器实例、提供方、模型或任何上游请求 token,都可能使复用从首个出现差异的 token 起失效。跨过图片上限会改写较早的一条消息(新被 offload 的图片变为占位文本),复用在该消息处截止,直到被 offload 的前缀稳定。 ### 提供方响应 @@ -195,6 +195,7 @@ pi-ai 事件会变为 harness 推理、文本、工具调用、usage 与 finish ## 已知限制与暂缓事项 +- **`maxRequestImageBytes` 只统计 base64 图片载荷**:文本、工具与 JSON 结构不计入上限,因此该值必须低于网关请求体上限并留出余量。offload 在请求转换时决定,是历史与配置的纯函数,不记录为会话事件;由按路由能力元数据(图片数量、单图大小、请求总大小)同时驱动准入与组装的完整设计属于暂缓工作。 - **仅以 OAuth 认证的提供方不予提供**:pi-ai 的 OAuth 只从*已存储*的 OAuth 凭据解析,而本适配器构造 `Models` 集合时不注入凭据存储、也不运行登录流程,因此这类路由的每个请求都会在发出之前以 `Provider is not configured` 失败。可配置提供方目录因此不列出它们;已安装 catalog 中只有 `openai-codex` 属于此类。settings 文档已经写过的路由仍保留目录条目,配置界面据此可以编辑或删除;`apiKeyEnv` 也仍能用该密钥完成认证——对 Codex 而言那是一个会过期、且这里没有任何环节会去刷新的 token。 - **提供方自带的凭据发现只读进程环境**:不指定凭据的路由交由 catalog 提供方自行解析,而它探测的是环境变量(`AZURE_OPENAI_API_KEY`、`AWS_PROFILE`、`AWS_ACCESS_KEY_ID` 以及各提供方自己的那一组)。它不读任何本地凭据目录,因此只有 `~/.aws/credentials` 而未导出 `AWS_PROFILE` 会被解析为未配置;由 harness 凭据 seam 保管的值,除非进程环境里也有,否则对它不可见。 - **settings 能新增或覆盖路由,但不能移除组合路由**:用户层合并在组合 `base` 之上,因此删除 `cordis.yml` 提供的提供方属于组合变更;对该 namespace 执行 `replace` 只会重置用户层。 diff --git a/packages/llm/llm-pi-ai/src/adapter.ts b/packages/llm/llm-pi-ai/src/adapter.ts index ab1c784351..5ecec593da 100644 --- a/packages/llm/llm-pi-ai/src/adapter.ts +++ b/packages/llm/llm-pi-ai/src/adapter.ts @@ -317,7 +317,7 @@ export class PiAiAdapter extends LlmAdapter { } const context = attachments === undefined ? toPiContext(options, undefined, onReplayDegrade) - : await toPiContext(options, attachments, onReplayDegrade) + : await toPiContext(options, attachments, onReplayDegrade, profile.maxRequestImageBytes) const events = snapshot.models.streamSimple(model, context, { ...profileOptions(profile, reasoning, apiKey), ...options.temperature === undefined ? {} : { temperature: options.temperature }, diff --git a/packages/llm/llm-pi-ai/src/config.ts b/packages/llm/llm-pi-ai/src/config.ts index e5443b6a8f..b4bd37ad9c 100644 --- a/packages/llm/llm-pi-ai/src/config.ts +++ b/packages/llm/llm-pi-ai/src/config.ts @@ -34,6 +34,17 @@ import { buildProvider, supportedProtocols } from './provider.ts' /** Default maximum idle interval while an adapter stream read is outstanding. */ export const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 300_000 +/** + * Default request-level bound on base64-encoded image payload. Every image in + * history is re-encoded into every request body, so an unbounded conversation + * eventually exceeds a provider or gateway request-size cap and the session + * can never complete another request. The 20MiB default admits four images at + * the attachment store's 3.5MiB raw-image default after base64 expansion and + * reserves request capacity for system prompts, history, tools, and JSON. + * Deployments behind stricter gateways lower it per route. + */ +export const DEFAULT_MAX_REQUEST_IMAGE_BYTES = 20 * 1024 * 1024 + /** Context capacity assumed for a model neither configuration nor the catalog sizes. */ export const DEFAULT_CONTEXT_WINDOW = 262_144 @@ -137,6 +148,13 @@ export interface PiAiProviderProfile { websocketConnectTimeoutMs?: number /** Maximum provider idle time while one stream read is outstanding. */ streamIdleTimeoutMs?: number + /** + * Maximum base64-encoded image payload per request. When a request's + * accumulated images exceed it, the oldest images are replaced by text + * placeholders until the request fits, so a long session keeps completing + * requests instead of being rejected by a request-size cap. + */ + maxRequestImageBytes?: number /** Provider-owned model-request retry policy; omission uses normal defaults. */ retryPolicy?: RetryPolicyConfig } @@ -152,6 +170,8 @@ export interface ResolvedPiAiProviderProfile apiKeyEnv?: CredentialRef /** Positive finite provider-idle interval after defaulting. */ streamIdleTimeoutMs: number + /** Positive request-level base64 image payload bound after defaulting. */ + maxRequestImageBytes: number /** Immutable retry policy captured with this provider route. */ retryPolicy: ResolvedRetryPolicy /** @@ -283,6 +303,7 @@ const profile = z.object({ timeoutMs: z.natural(), websocketConnectTimeoutMs: z.natural(), streamIdleTimeoutMs: z.number().min(Number.MIN_VALUE).max(MAX_TIMER_DELAY_MS).default(DEFAULT_STREAM_IDLE_TIMEOUT_MS), + maxRequestImageBytes: z.number().step(1).min(1).default(DEFAULT_MAX_REQUEST_IMAGE_BYTES), retryPolicy: RetryPolicySchema, }) @@ -358,6 +379,10 @@ export function resolveProfiles( `llm-pi-ai: provider "${provider}" streamIdleTimeoutMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`, ) } + const maxRequestImageBytes = source.maxRequestImageBytes ?? DEFAULT_MAX_REQUEST_IMAGE_BYTES + if (!Number.isInteger(maxRequestImageBytes) || maxRequestImageBytes <= 0) { + throw new Error(`llm-pi-ai: provider "${provider}" maxRequestImageBytes must be a positive integer`) + } // Detached from the configuration object because pi-ai types `Model.input` // mutable. The schema's explicit default covers an absent key, so an empty // list here is always one someone typed — and unlike an entry's, nothing @@ -389,6 +414,7 @@ export function resolveProfiles( displayName, ...apiKeyEnv === undefined ? {} : { apiKeyEnv: credentialRef(apiKeyEnv) }, streamIdleTimeoutMs, + maxRequestImageBytes, retryPolicy: resolveRetryPolicy(retryPolicy, `llm-pi-ai: provider "${provider}" retryPolicy`), ...rest.headers === undefined ? {} : { headers: { ...rest.headers } }, ...rest.thinkingBudgets === undefined ? {} : { thinkingBudgets: { ...rest.thinkingBudgets } }, diff --git a/packages/llm/llm-pi-ai/src/context.ts b/packages/llm/llm-pi-ai/src/context.ts index dcbaabc815..5a2d330b7d 100644 --- a/packages/llm/llm-pi-ai/src/context.ts +++ b/packages/llm/llm-pi-ai/src/context.ts @@ -26,17 +26,82 @@ function toolResultText(blocks: readonly ContentBlock[]): string { : block.type === 'tool-result' ? toolResultText(block.content) : '').join('') } +/** Model-facing stand-in for an image dropped to fit the request bound. */ +export const OFFLOADED_IMAGE_TEXT + = '[image omitted to keep the request within its image limit; older images are omitted first. If this image is still needed, read its file again when a path is available; otherwise ask the user to attach it again.]' + +/** Base64 length of `bytes` raw bytes (4 output characters per 3 input bytes, padded). */ +function base64Length(bytes: number): number { + return Math.ceil(bytes / 3) * 4 +} + +/** + * Select the images a request must drop to fit the per-request payload bound. + * History order is oldest-first, so the most recent images are omitted last. + * A single image larger than the bound is itself omitted. Locations use + * message and nested block indexes so JSON replay cannot change the result by + * splitting or preserving shared object identities. + * @param messages - complete request history, oldest first. + * @param maxRequestImageBytes - bound on total base64-encoded image payload; undefined leaves every image in place. + * @returns the image locations the conversion replaces with {@link OFFLOADED_IMAGE_TEXT}. + */ +function offloadedImages( + messages: readonly Message[], + maxRequestImageBytes: number | undefined, +): ReadonlySet { + const offloaded = new Set() + if (maxRequestImageBytes === undefined) return offloaded + const images: { location: string; base64Bytes: number }[] = [] + const collect = (messageIndex: number, blocks: readonly ContentBlock[], prefix: readonly number[] = []): void => { + for (const [blockIndex, block] of blocks.entries()) { + const path = [...prefix, blockIndex] + if (block.type === 'image') { + images.push({ + location: `${messageIndex}:${path.join('.')}`, + base64Bytes: base64Length(block.attachment.bytes), + }) + } else if (block.type === 'tool-result') { + collect(messageIndex, block.content, path) + } + } + } + for (const [messageIndex, message] of messages.entries()) collect(messageIndex, message.content) + let total = images.reduce((sum, image) => sum + image.base64Bytes, 0) + for (const image of images) { + if (total <= maxRequestImageBytes) break + offloaded.add(image.location) + total -= image.base64Bytes + } + return offloaded +} + +interface LocatedContentBlock { + readonly block: ContentBlock + readonly path: readonly number[] +} + +/** Attach stable nested indexes to blocks from one message. */ +function locatedBlocks(blocks: readonly ContentBlock[], prefix: readonly number[] = []): LocatedContentBlock[] { + return blocks.map((block, index) => ({ block, path: [...prefix, index] })) +} + async function userContent( - blocks: readonly ContentBlock[], + blocks: readonly LocatedContentBlock[], attachments: AttachmentStore, + offloaded: ReadonlySet, + messageIndex: number, ): Promise { const content: (TextContent | ImageContent)[] = [] - for (const block of blocks) { + for (const { block, path } of blocks) { switch (block.type) { case 'text': if (block.text.length > 0) content.push({ type: 'text', text: block.text }) break case 'image': { + if (offloaded.has(`${messageIndex}:${path.join('.')}`)) { + content.push({ type: 'text', text: OFFLOADED_IMAGE_TEXT }) + break + } const stored = await attachments.readImage(block.attachment) content.push({ type: 'image', @@ -47,7 +112,7 @@ async function userContent( } case 'tool-result': { - const nested = await userContent(block.content, attachments) + const nested = await userContent(locatedBlocks(block.content, path), attachments, offloaded, messageIndex) if (typeof nested === 'string') { if (nested.length > 0) content.push({ type: 'text', text: nested }) } else { @@ -136,36 +201,44 @@ export function toPiContext( ): PiContext /** * Convert harness history to a pi-ai Context while resolving durable images. - * Tool result names are recovered from preceding assistant tool calls. + * Tool result names are recovered from preceding assistant tool calls. When + * the accumulated base64 image payload exceeds `maxRequestImageBytes`, the + * oldest images are replaced by text placeholders until the request fits, so + * an image-heavy session keeps clearing gateway request-size caps. * @param options - the harness request; `options.system` maps to pi-ai's single `systemPrompt` slot. * @param attachments - durable byte resolver for image references. * @param onReplayDegrade - forwarded to {@link toPiAssistant} for each assistant message. + * @param maxRequestImageBytes - request-level bound on base64-encoded image payload; omission leaves every image in place. * @returns the asynchronously resolved pi-ai context. */ export function toPiContext( options: GenerateOptions, attachments: AttachmentStore, onReplayDegrade?: (reason: string) => void, + maxRequestImageBytes?: number, ): Promise export function toPiContext( options: GenerateOptions, attachments?: AttachmentStore, onReplayDegrade?: (reason: string) => void, + maxRequestImageBytes?: number, ): PiContext | Promise { return attachments === undefined ? textOnlyContext(options, onReplayDegrade) - : toPiContextWithImages(options, attachments, onReplayDegrade) + : toPiContextWithImages(options, attachments, onReplayDegrade, maxRequestImageBytes) } async function toPiContextWithImages( options: GenerateOptions, attachments: AttachmentStore, onReplayDegrade?: (reason: string) => void, + maxRequestImageBytes?: number, ): Promise { + const offloaded = offloadedImages(options.messages, maxRequestImageBytes) const toolNames = new Map() const messages: PiMessage[] = [] - for (const message of options.messages) { + for (const [messageIndex, message] of options.messages.entries()) { if (message.role === 'system') { if (contentHasImage(message.content)) { throw new LlmError('pi-ai cannot represent an image in an in-history system message', 'UNSUPPORTED_CONTENT') @@ -185,14 +258,17 @@ async function toPiContextWithImages( continue } // user role: text + tool results (each result becomes its own message). - const regular = message.content.filter(block => block.type !== 'tool-result') - const content = await userContent(regular, attachments) - const results = message.content.filter(block => block.type === 'tool-result') + const located = locatedBlocks(message.content) + const regular = located.filter(({ block }) => block.type !== 'tool-result') + const content = await userContent(regular, attachments, offloaded, messageIndex) + const results = located.filter((entry): entry is LocatedContentBlock & { block: Extract } => ( + entry.block.type === 'tool-result' + )) if (content.length > 0 || results.length === 0) { messages.push({ role: 'user', content, timestamp: 0 }) } - for (const result of results) { - const resultContent = await userContent(result.content, attachments) + for (const { block: result, path } of results) { + const resultContent = await userContent(locatedBlocks(result.content, path), attachments, offloaded, messageIndex) messages.push({ role: 'toolResult', toolCallId: result.toolCallId, diff --git a/packages/llm/llm-pi-ai/src/stream.ts b/packages/llm/llm-pi-ai/src/stream.ts index 22aa7cb579..d45df45a39 100644 --- a/packages/llm/llm-pi-ai/src/stream.ts +++ b/packages/llm/llm-pi-ai/src/stream.ts @@ -40,6 +40,9 @@ function classifyPiAiError(message: string): string { if (/\b(?:401|403)\b/.test(message)) return 'AUTH' if (isQuotaExceededError(message)) return QUOTA_EXCEEDED_CODE if (/\b429\b|rate.?limit/i.test(message)) return 'RATE_LIMIT' + // A rejected request body (gateway or provider size cap): resending the + // same request cannot succeed, so it is invalid, not transient. + if (/\b413\b|failed to buffer the request body:\s*length limit exceeded|payload too large|request body too large/i.test(message)) return 'INVALID_REQUEST' if (/\b400\b|invalid.?request/i.test(message)) return 'INVALID_REQUEST' if (/\b5\d\d\b/.test(message)) return 'SERVER' if (/\btime(?:d)?\s*out\b|timeout/i.test(message)) return 'TIMEOUT' diff --git a/packages/llm/llm-pi-ai/tests/adapter.spec.ts b/packages/llm/llm-pi-ai/tests/adapter.spec.ts index 5e02646f64..fff75c57f0 100644 --- a/packages/llm/llm-pi-ai/tests/adapter.spec.ts +++ b/packages/llm/llm-pi-ai/tests/adapter.spec.ts @@ -12,7 +12,7 @@ import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai' import { PiAiAdapter } from '@deepseek-ai/dsh-llm-pi-ai' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import { getBuiltinModels } from '@earendil-works/pi-ai/providers/all' -import { resolveProfiles } from '../src/config.ts' +import { DEFAULT_MAX_REQUEST_IMAGE_BYTES, resolveProfiles } from '../src/config.ts' import { assemble } from './assemble.ts' import { closeMockServers, mockServer, textEvents } from './mock-server.ts' @@ -217,6 +217,7 @@ describe('PiAiAdapter provider routing', () => { maxImagesPerMessage: 1, maxMessageImageBytes: 1, maxImagePixels: 1, + maxImageDimension: 2000, mediaTypes: ['image/png'], } @@ -736,6 +737,7 @@ describe('provider profile lifecycle', () => { }) it('validates empty, underspecified, legacy-shaped, and explicitly blank profiles', () => { + expect(DEFAULT_MAX_REQUEST_IMAGE_BYTES).toBe(20 * 1024 * 1024) // Empty and omitted dicts are the dormant zero-route posture, not errors. expect(resolveProfiles({}).size).toBe(0) expect(resolveProfiles(undefined).size).toBe(0) @@ -749,6 +751,11 @@ describe('provider profile lifecycle', () => { expect(() => resolveProfiles({ openai: { provider: 'openai' } as never })).toThrow(/moved to the providers dict key/) expect(() => resolveProfiles({ openai: { baseURL: '' } })).toThrow(/empty baseURL/) expect(() => resolveProfiles({ openai: { apiKeyEnv: 'not-a-var!' } })).toThrow(/must match/) + expect(() => resolveProfiles({ openai: { maxRequestImageBytes: 0 } })).toThrow(/maxRequestImageBytes/) + expect(resolveProfiles({ openai: {} }).get('openai')?.maxRequestImageBytes) + .toBe(DEFAULT_MAX_REQUEST_IMAGE_BYTES) + expect(resolveProfiles({ openai: { maxRequestImageBytes: 1024 } }).get('openai')?.maxRequestImageBytes) + .toBe(1024) }) it.each(['maxRetries', 'maxRetryDelayMs'] as const)( @@ -770,6 +777,9 @@ describe('provider profile lifecycle', () => { { streamIdleTimeoutMs: 0 }, { streamIdleTimeoutMs: Number.NaN }, { streamIdleTimeoutMs: MAX_TIMER_DELAY_MS + 1 }, + { maxRequestImageBytes: 0 }, + { maxRequestImageBytes: 1.5 }, + { maxRequestImageBytes: Number.NaN }, ] for (const entry of invalid) { const ctx = new Context() diff --git a/packages/llm/llm-pi-ai/tests/context.spec.ts b/packages/llm/llm-pi-ai/tests/context.spec.ts index 01ed3d080c..be41a12b07 100644 --- a/packages/llm/llm-pi-ai/tests/context.spec.ts +++ b/packages/llm/llm-pi-ai/tests/context.spec.ts @@ -3,7 +3,7 @@ import { AttachmentId } from '@deepseek-ai/dsh-attachment' import type { AttachmentStore, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import { CallId, createMessage, createUserMessage } from '@deepseek-ai/dsh-llm' import type { ContentBlock, GenerateOptions, Message } from '@deepseek-ai/dsh-llm' -import { toPiContext } from '../src/context.ts' +import { OFFLOADED_IMAGE_TEXT, toPiContext } from '../src/context.ts' import { toPiAssistant } from '../src/replay.ts' const ref: ImageAttachmentRef = { @@ -140,6 +140,92 @@ describe('pi-ai request context conversion', () => { ]) }) + it('replaces the oldest images with placeholders once the request payload bound is exceeded', async () => { + const readImage = vi.fn(() => Promise.resolve({ ref: { ...ref, bytes: 3 }, data: Uint8Array.of(1, 2, 3) })) + const store = { readImage } as unknown as AttachmentStore + const sized: ImageAttachmentRef = { ...ref, bytes: 3 } + const callId = CallId('shot-call') + // Three 3-byte images cost 4 base64 characters each (12 total); a bound of + // 8 forces exactly the oldest one out, including one nested in a tool result. + const context = await toPiContext(request([ + user([{ + type: 'tool-result', + toolCallId: callId, + content: [{ type: 'image', attachment: sized }], + }]), + user([{ type: 'image', attachment: sized }, { type: 'text', text: 'newer' }]), + user([{ type: 'image', attachment: sized }]), + ]), store, undefined, 8) + + expect(context.messages).toEqual([ + { + role: 'toolResult', + toolCallId: 'shot-call', + toolName: 'unknown', + content: [{ type: 'text', text: OFFLOADED_IMAGE_TEXT }], + isError: false, + timestamp: 0, + }, + { + role: 'user', + content: [ + { type: 'image', data: 'AQID', mimeType: 'image/png' }, + { type: 'text', text: 'newer' }, + ], + timestamp: 0, + }, + { role: 'user', content: [{ type: 'image', data: 'AQID', mimeType: 'image/png' }], timestamp: 0 }, + ]) + expect(readImage).toHaveBeenCalledTimes(2) + }) + + it('keeps every image at exactly the payload bound and drops all of them when even the newest cannot fit', async () => { + const sized: ImageAttachmentRef = { ...ref, bytes: 3 } + const exact = await toPiContext(request([ + user([{ type: 'image', attachment: sized }]), + user([{ type: 'image', attachment: sized }]), + ]), attachments, undefined, 8) + expect(exact.messages).toEqual([ + { role: 'user', content: [expect.objectContaining({ type: 'image' })], timestamp: 0 }, + { role: 'user', content: [expect.objectContaining({ type: 'image' })], timestamp: 0 }, + ]) + + const readImage = vi.fn() + const store = { readImage } as unknown as AttachmentStore + const oversized = await toPiContext(request([ + user([{ type: 'image', attachment: { ...ref, bytes: 300 } }]), + ]), store, undefined, 8) + // All-text content collapses to the string form; the placeholder still reaches the model. + expect(oversized.messages).toEqual([ + { role: 'user', content: OFFLOADED_IMAGE_TEXT, timestamp: 0 }, + ]) + expect(readImage).not.toHaveBeenCalled() + }) + + it('offloads repeated image-block occurrences by position rather than shared object identity', async () => { + const sized: ImageAttachmentRef = { ...ref, bytes: 3 } + const shared: ContentBlock = { type: 'image', attachment: sized } + const readImage = vi.fn(() => Promise.resolve({ ref: sized, data: Uint8Array.of(1, 2, 3) })) + const store = { readImage } as unknown as AttachmentStore + const aliased = await toPiContext(request([user([shared, shared])]), store, undefined, 4) + const replayed = await toPiContext(request([user([ + { type: 'image', attachment: { ...sized } }, + { type: 'image', attachment: { ...sized } }, + ])]), store, undefined, 4) + + const expected = [{ + role: 'user', + content: [ + { type: 'text', text: OFFLOADED_IMAGE_TEXT }, + { type: 'image', data: 'AQID', mimeType: 'image/png' }, + ], + timestamp: 0, + }] + expect(aliased.messages).toEqual(expected) + expect(replayed.messages).toEqual(expected) + expect(readImage).toHaveBeenCalledTimes(2) + }) + it('keeps empty text-only users while separating result-only messages', () => { const callId = CallId('unknown-call') expect(toPiContext(request([ diff --git a/packages/llm/llm-pi-ai/tests/convert.spec.ts b/packages/llm/llm-pi-ai/tests/convert.spec.ts index 1a42e4b085..2a3b41b0c4 100644 --- a/packages/llm/llm-pi-ai/tests/convert.spec.ts +++ b/packages/llm/llm-pi-ai/tests/convert.spec.ts @@ -779,6 +779,16 @@ describe('mapStopReason / mapUsage', () => { stopReason: 'error', errorMessage: 'HTTP 400: invalid input: temperature exceeds maximum allowed value', }))).toMatchObject({ kind: 'error', failure: { code: 'INVALID_REQUEST' } }) + expect(mapStopReason(assistant({ stopReason: 'error', errorMessage: 'HTTP 413: Payload Too Large' }))) + .toMatchObject({ kind: 'error', failure: { code: 'INVALID_REQUEST' } }) + expect(mapStopReason(assistant({ + stopReason: 'error', + errorMessage: 'Failed to buffer the request body: length limit exceeded', + }))).toMatchObject({ kind: 'error', failure: { code: 'INVALID_REQUEST' } }) + expect(mapStopReason(assistant({ + stopReason: 'error', + errorMessage: 'vector length limit exceeded', + }))).toMatchObject({ kind: 'error', failure: { code: 'PI_AI_ERROR' } }) }) it.each([ diff --git a/assets/community-wecom-survey.png b/packages/llm/llm-pi-ai/tests/fixtures/qr-code.png similarity index 100% rename from assets/community-wecom-survey.png rename to packages/llm/llm-pi-ai/tests/fixtures/qr-code.png diff --git a/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts b/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts index a2a583abd8..1fe529336f 100644 --- a/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts +++ b/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts @@ -70,6 +70,7 @@ async function harness(image?: StoredImageAttachment): Promise { maxImagesPerMessage: 1, maxMessageImageBytes: fixture.data.byteLength, maxImagePixels: fixture.ref.width * fixture.ref.height, + maxImageDimension: Math.max(fixture.ref.width, fixture.ref.height), mediaTypes: [fixture.ref.mediaType], } @@ -209,7 +210,7 @@ for (const profile of providerCases) { if (profile.provider === 'anthropic') { it('sends a real image through the authenticated Anthropic visual path', async () => { const data = new Uint8Array(await readFile( - new URL('../../../../assets/community-wecom-survey.png', import.meta.url), + new URL('./fixtures/qr-code.png', import.meta.url), )) const ref: ImageAttachmentRef = { attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`), diff --git a/packages/mcp/mcp-client/tests/mcp-client.spec.ts b/packages/mcp/mcp-client/tests/mcp-client.spec.ts index 8a155b2f54..164c230c56 100644 --- a/packages/mcp/mcp-client/tests/mcp-client.spec.ts +++ b/packages/mcp/mcp-client/tests/mcp-client.spec.ts @@ -73,6 +73,7 @@ const IMAGE_LIMITS: ImageAttachmentLimits = { maxImagesPerMessage: 4, maxMessageImageBytes: 2048, maxImagePixels: 1024, + maxImageDimension: 2000, mediaTypes: ['image/png', 'image/jpeg', 'image/webp', 'image/gif'], } diff --git a/packages/subagent/subagent/README.i18n.yaml b/packages/subagent/subagent/README.i18n.yaml index 6d443fc9ba..fde3009342 100644 --- a/packages/subagent/subagent/README.i18n.yaml +++ b/packages/subagent/subagent/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subagent/subagent/README.md -README.md: ed4a9123a2dfa5b2fa5abc67f4513547feb3d140 -README.zh.md: 3ad2ee5738a210a776d1f0b2746dcbd21d46144c +README.md: 25657a6a7dd6727bb5177c828241ec82192f20d8 +README.zh.md: 2f36f950e0a4b0bb86d97633dd0e841fd331ad50 diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index ed4a9123a2..25657a6a7d 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -16,12 +16,13 @@ The [subagent family overview](../README.md) maps implementations and model-faci | `getProvider(name)` | Return the provider, or `undefined` when absent. | | `list()` | Return provider names in insertion order. | | `start(name, request)` | Validate an ordinary caller request, resolve its detached `one-shot` descriptor, then await the provider until a real one-shot child is published. Fulfillment returns a holder-owned `SubagentRun`; rejection means the provider has already cleaned every unpublished startup resource, while post-publication turn or infrastructure faults settle through the run. Continuable children never enter through this operation. | -| `startContinuable(spec)` | Establish one durable continuable child and deliver its initial prompt. Resolves with `{ childId, messageId }` when the child's inbox accepts that prompt, without waiting for the turn to start or for the message to reach the Session log; any earlier failure rejects with no ids and rolls the child back entirely. Requires `ctx.agents`, session persistence, and a provider with the `prepareContinuable` capability. | +| `startContinuable(spec)` | Establish one durable continuable child and deliver its initial prompt. Resolves with `{ childId, messageId }` when the child's inbox accepts that prompt, without waiting for the turn to start or for the message to reach the Session log; any earlier failure rejects with no ids and rolls the child back entirely. A caller-reserved `childId` is rejected when the live registries or configured persistence already own it. Requires `ctx.agents`, session persistence, and a provider with the `prepareContinuable` capability. | | `followup(parent, childId, content, { source, signal })` | Deliver one later message from the exact live direct parent as the child's next FIFO turn, matching `Agent.followup()` terminology, and return the accepted `MessageId`. A resident child's inbox accepts it directly (waking a waiting Activation); an absent one cold-resumes from its persisted Session. Requires `ctx.agents`; cold resume also requires session persistence. | | `interrupt(targetSessionId, authority)` | Interrupt one live continuable child's current turn under a human durable parent address (`{ kind: 'user', parentSessionId }`) or an exact live ancestor Agent (`{ kind: 'ancestor', agent }`). Admission is synchronous and the effect asynchronous: it issues `Agent.cancel(cause, { keepInbox: true })` and returns without waiting for the target to observe the signal. Unclaimed pending inbox work, the Activation, and published descendants are preserved; work already claimed into the interrupted turn is not requeued. An absent target is an accepted no-op; a wrong parent address or a stale, self-targeting, or non-ancestor caller rejects with `UNAUTHORIZED`. | -| `reportFrom(child, content, { delivery, signal })` | Deliver one selected message from the exact live continuable child to its exact live direct parent and return the accepted stable `MessageId`. Quiet delivery injects context; waking delivery submits one later parent turn. | +| `reportFrom(child, content, { delivery, signal })` | Deliver one selected message from the exact live continuable child to its exact live direct parent and return the accepted stable `MessageId`. Quiet delivery injects next-step context without waking; next-step delivery steers and wakes the parent. | | `registerContinuableSetup(contribution)` | Compose an optional deployment capability into each continuable child's unpublished scope, with immediate revocation from resident children. | | `drainContinuableDescendants(parents)` | Close admission below exact live host-owned parent Agents, stop only their visible continuable descendants, await materializations admitted below those roots through publication or rollback, then release the selected forests child-first. The cutoff lasts until each exact parent leaves the registry; unrelated parent forests and manager-wide admission remain live. | +| `drainContinuableChildren(parent, childIds)` | Release only the named resident continuable direct children of one exact live parent, recursively and child-first. It does not close admission or touch siblings, accepts absent ids as no-ops, and rejects a resident child owned by another parent. This is teardown, so unlike `interrupt()` it does not preserve pending inbox work. | | `listChildren(parentSessionId, signal?)` | List direct session-backed subagents with their `one-shot`/`continuable` mode, `running`/`inactive` activity, origin-classified one-level `hasChildren` hint, and per-child diagnostics, ordered by `createdAt` then id, without loading or resuming them. Reads the live session store and optional session persistence directly (live-only enumeration when persistence is absent) and requires the mounted `sessionProjections` registry; it does not require `ctx.agents`, the continuation manager, or any query service. | | `listDescendants(rootSessionId, signal?)` | Flatten the root's complete session tree in stable pre-order from the same live-preferred corpus, adding each subagent entry's durable `parentId` and root-relative `depth`. Ordinary sessions and one-shot children remain traversal nodes so continuable descendants below them are discovered. Identity, diagnostics, dependencies, and cancellation follow `listChildren()`. | @@ -146,7 +147,7 @@ Prefix-stable within a child: the statement never changes during the child's lif - **ACP children remain one-shot and are not trace-enumerable** — an ACP run has no local child session in the parent's session corpus. An ACP `prepareContinuable` requires persisting the remote session id in provider-specific descriptor data and a per-child continuation advertisement, since ACP `loadSession` support is negotiated per child rather than established by the method's presence. Remote providers also require a separate Activation ownership contract with equivalent authenticated control and child-first quiescence before they support continuable children. - **No host-user continuation** — `followup()` requires the exact live direct parent. Only `interrupt()` accepts a durable parent-address user authority, because stopping a turn is idempotent and delivers no content; a future host adapter needs a concrete authenticated interaction before the seam gains a user delivery capability. -- **No current-turn steering** — continuable messages and waking reports enqueue later turns; neither redirects an open turn. +- **Continuation messages never steer** — parent-to-child continuation messages enqueue later child turns. Child-to-parent reports are independent next-step input and may extend the parent's open turn. - **Wake gap during cancellation convergence** — a waking follow-up accepted after the interrupt signal is issued but before the active driver becomes idle remains queued until another waking send. Issue #1838 owns the agent-loop wake latch, which also affects ordinary session cancellation. - **Process-local residency** — the Activation inbox and ownership graph do not coordinate two harness processes; concurrent access to one persistence store still requires a durable mailbox and cross-process lease protocol. - **No replay of accepted-but-unlogged messages** — only messages written to the child Session log are reconstructable with the source that supplied them. A crash may lose an accepted initial prompt or follow-up that never reached the log; a later authorized message can cold-resume the child, but the lost message is not replayed automatically. diff --git a/packages/subagent/subagent/README.zh.md b/packages/subagent/subagent/README.zh.md index 3ad2ee5738..2f36f950e0 100644 --- a/packages/subagent/subagent/README.zh.md +++ b/packages/subagent/subagent/README.zh.md @@ -16,12 +16,13 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委 | `getProvider(name)` | 返回提供方;不存在时返回 `undefined`。 | | `list()` | 按插入顺序返回提供方名称。 | | `start(name, request)` | 校验普通调用方请求,解析其已分离的 `one-shot` 描述符,然后等待提供方发布真正的一次性子 agent。兑现时返回由持有方拥有的 `SubagentRun`;如果调用被拒绝,提供方已经清理所有尚未发布的启动资源。发布后的轮次故障或基础设施故障则通过该 run 结算。可继续子 agent 绝不通过此操作进入。 | -| `startContinuable(spec)` | 建立一个持久化的可继续子 agent,并投递其初始提示词。子 agent 的 inbox 一接受该提示词,调用就会兑现为 `{ childId, messageId }`,无需等待轮次开始,也无需等待消息写入会话日志。在此之前发生的任何失败都会使调用被拒绝,不返回任何 id,并完全回滚该子 agent。要求 `ctx.agents`、会话持久化以及具备 `prepareContinuable` 能力的提供方。 | +| `startContinuable(spec)` | 建立一个持久化的可继续子 agent,并投递其初始提示词。子 agent 的 inbox 一接受该提示词,调用就会兑现为 `{ childId, messageId }`,无需等待轮次开始,也无需等待消息写入会话日志。在此之前发生的任何失败都会使调用被拒绝,不返回任何 id,并完全回滚该子 agent。如果在线注册表或已配置的持久化已经占用调用方预留的 `childId`,则拒绝该身份。要求 `ctx.agents`、会话持久化以及具备 `prepareContinuable` 能力的提供方。 | | `followup(parent, childId, content, { source, signal })` | 将来自确切在线直接父级的一条后续消息作为子 agent 的下一个 FIFO 轮次投递,术语与 `Agent.followup()` 一致,并返回被接受的 `MessageId`。驻留中的子 agent 由其 inbox 直接接受(唤醒处于 waiting 的 Activation);不驻留的则从其持久化会话冷恢复。要求 `ctx.agents`;冷恢复还要求会话持久化。 | | `interrupt(targetSessionId, authority)` | 凭人类出示的持久化父级地址 `{ kind: 'user', parentSessionId }`,或确切在线的祖先 Agent `{ kind: 'ancestor', agent }` 进行授权,中断一个在线可继续子级的当前轮次。准入判定同步完成,但取消异步生效:该操作发出 `Agent.cancel(cause, { keepInbox: true })` 后立即返回,不等待目标观察到信号。尚未领取的待处理 inbox 工作、Activation 和已发布的后代均会保留;已经领取到被中断轮次中的工作不会重新入队。目标不存在时视为已接受的空操作;错误的父级地址,或陈旧、指向自身、并非祖先的调用方,会以 `UNAUTHORIZED` 被拒绝。 | -| `reportFrom(child, content, { delivery, signal })` | 从确切在线可继续 child 向其确切在线直接 parent 投递一条选中消息,并返回已接受的稳定 `MessageId`。静默投递会注入上下文;唤醒投递会提交一个后续 parent 轮次。 | +| `reportFrom(child, content, { delivery, signal })` | 从确切在线可继续 child 向其确切在线直接 parent 投递一条选中消息,并返回已接受的稳定 `MessageId`。静默投递会注入不唤醒的 next-step 上下文;next-step 投递会 steering 并唤醒 parent。 | | `registerContinuableSetup(contribution)` | 把一项可选部署能力组合到每个可继续 child 尚未发布的作用域中,并支持从驻留 child 立即撤销。 | | `drainContinuableDescendants(parents)` | 在由 host 拥有的确切在线父级 Agent 之下关闭准入,只停止这些父级可见的可继续后代;等待已在这些根节点下获准的物化过程完成发布或回滚后,再按子级优先顺序释放所选的各棵树。该截止状态会持续到每个确切父级离开注册表;无关的父级树仍在线,管理器全局准入仍保持开放。 | +| `drainContinuableChildren(parent, childIds)` | 只释放一个确切在线父级的具名驻留可继续直接子级,并递归保持子级优先顺序。它不关闭准入、不影响同级子级,缺失的 id 视为空操作;若驻留子级属于其他父级,则拒绝。这是拆卸操作,因此与 `interrupt()` 不同,它不会保留待处理的 inbox 工作。 | | `listChildren(parentSessionId, signal?)` | 按 `createdAt`、再按 id 的顺序列出由会话支撑的直接 subagent,包括其 `one-shot`/`continuable` 模式、`running`/`inactive` 活动状态、根据 origin 分类得出的一层 `hasChildren` 提示,以及每个子级的诊断信息,且不会加载或恢复它们。该操作直接读取在线会话存储和可选的会话持久化(没有持久化时只枚举在线子级),并要求已挂载 `sessionProjections` 注册表;不要求 `ctx.agents`、继续执行管理器或任何查询服务。 | | `listDescendants(rootSessionId, signal?)` | 从同一份在线优先语料按稳定 pre-order 展平根的完整会话树,并为每个 subagent 条目附加持久 `parentId` 与相对根的 `depth`。普通会话与一次性 child 仍作为遍历节点,因此其下的可继续后代仍可发现。身份、diagnostic、依赖与取消约定均沿用 `listChildren()`。 | @@ -146,7 +147,7 @@ You are a delegated subagent: your permission scope was fixed when you were star - **ACP 子 agent 仍为一次性,且无法通过追踪枚举**:ACP 运行在 parent 会话语料中没有本地 child 会话。ACP 的 `prepareContinuable` 需要在提供方专用描述符数据中持久化远端会话 id,以及逐子 agent 的继续执行能力声明,因为 ACP 的 `loadSession` 支持按子 agent 协商,而不是通过方法是否存在来确定。远程提供方还需要一份独立的 Activation 所有权约定,具备等效的经认证控制和子先于父的完全停稳保证,才能支持可继续子 agent。 - **无 host-user 继续执行**:`followup()` 要求确切在线直接父级。只有 `interrupt()` 接受持久化 parent 地址形式的用户授权,因为停止一个轮次是幂等的且不投递任何内容;未来 host 适配器需要具体的经认证交互,才能让该 seam 获得用户投递能力。 -- **不对当前轮次进行 steering**:可继续消息和唤醒式 report 会排入后续轮次,均不会重定向正在进行的轮次。 +- **继续执行消息绝不 steering**:parent 到 child 的继续执行消息会排入后续 child 轮次。child 到 parent 的 report 是独立的 next-step 输入,可能延长 parent 已打开的轮次。 - **取消收敛期间存在唤醒缺口**:中断信号发出后、活动 driver 进入 idle 前被接受的唤醒型 follow-up 会保持排队,直到另一条唤醒发送到达。Issue #1838 负责 agent-loop 的唤醒锁存;普通会话取消也受此影响。 - **驻留仅限进程内**:Activation inbox 与所有权图不会在两个 harness 进程之间协调;对单个持久化存储的并发访问仍然需要持久化邮箱和跨进程租约协议。 - **不回放已接受但未记录的消息**:只有写入子 agent 会话日志的消息才能连同提供该消息的来源一起重建。崩溃可能丢失从未写入日志、已被接受的初始提示词或后续消息;此后一条经授权的消息可以冷恢复该子 agent,但丢失的消息不会自动回放。 diff --git a/packages/subagent/subagent/src/continuation.ts b/packages/subagent/subagent/src/continuation.ts index d826efbfdb..652a3ba6c8 100644 --- a/packages/subagent/subagent/src/continuation.ts +++ b/packages/subagent/subagent/src/continuation.ts @@ -98,7 +98,7 @@ declare module '@deepseek-ai/dsh-llm' { } /** Deployment scheduling policy for accepted child reports. */ -export type SubagentReportDelivery = 'quiet' | 'wakeup' +export type SubagentReportDelivery = 'quiet' | 'next-step' /** Options for one continuable child's report to its direct parent. */ export interface SubagentReportOptions { @@ -114,6 +114,12 @@ export interface ContinuableStartSpec { readonly provider: string /** The initial delegation's short `description`, persisted as the child's creation label. */ readonly label: string + /** + * Optional caller-reserved child identity. Omission preserves the manager's + * UUID allocation; supplying one lets a durable parent record provisioning + * before child materialization without a second identity handshake. + */ + readonly childId?: SessionId /** * The delegation request. The manager reserves the stable child id, resolves * the durable descriptor, and composes the child itself. @@ -404,9 +410,10 @@ export class SubagentContinuationManager { const request = spec.request const parent = request.parent this.assertAdmitting(parent) - this.requirePersistence() + const persistence = this.requirePersistence() assertSubagentMaxDepth(request.maxDepth) - const childId = SessionId(randomUUID()) + const childId = spec.childId ?? SessionId(randomUUID()) + this.assertChildIdAvailable(childId) const childDepth = resolveChildDepth(parent, request.maxDepth) // Snapshot before any await: invalid descriptor JSON rejects the call // before a child exists, and the detached value is what reaches the log. @@ -436,6 +443,18 @@ export class SubagentContinuationManager { const lineageSeedLength = prepared.seed?.length ?? 0 const seed = seedDescriptorTurn(childId, prepared.seed, descriptor) const messageId = await this.locks.run(childId, async () => { + spec.signal.throwIfAborted() + this.assertAdmitting(parent) + this.assertChildIdAvailable(childId) + if (spec.childId !== undefined) { + const persisted = await persistence.listSnapshots(spec.signal) + spec.signal.throwIfAborted() + this.assertAdmitting(parent) + this.assertChildIdAvailable(childId) + if (persisted.some(snapshot => snapshot.header.id === childId)) { + throw new SubagentError(`subagent "${childId}" already exists`, 'DUPLICATE_CHILD') + } + } const activation = await this.materialize({ childId, provider: spec.provider, @@ -456,6 +475,13 @@ export class SubagentContinuationManager { return { childId, messageId } } + /** Reject one child identity already owned by a live Agent or Session. */ + private assertChildIdAvailable(childId: SessionId): void { + if (this.ctx.agents.get(childId) !== undefined || this.ctx.get('sessions')?.get(childId) !== undefined) { + throw new SubagentError(`subagent "${childId}" already exists`, 'DUPLICATE_CHILD') + } + } + /** * Deliver one later message to a known continuable child as its next FIFO * turn. Routing depends only on Activation residency: a `running` Activation @@ -644,7 +670,7 @@ export class SubagentContinuationManager { senderSessionId: activation.childId, }, }) - if (delivery === 'wakeup') { + if (delivery === 'next-step') { this.sendWaking(parent, message, () => { this.sendReport(parent, message, delivery) }) } else { this.sendReport(parent, message, delivery) @@ -656,7 +682,7 @@ export class SubagentContinuationManager { * Perform one waking send to a parent, accounted against that parent's own * Activation when it has one. Registering the id before the send is what * keeps a continuation-managed parent from being judged quiescent in the - * window between `followup()` and the microtask that admits it. + * window between a waking send and the microtask that admits it. * @param parent - the exact live parent receiving the waking message. * @param message - the message whose id is accounted. * @param send - the synchronous waking send to perform. @@ -681,7 +707,7 @@ export class SubagentContinuationManager { delivery: SubagentReportDelivery, ): void { try { - if (delivery === 'wakeup') parent.followup(message) + if (delivery === 'next-step') parent.steer(message) else parent.inject(message) } catch (error: unknown) { throw new SubagentError( @@ -779,10 +805,46 @@ export class SubagentContinuationManager { await this.disposeRoots(targetRoots, 'scoped activation(s)') } + /** + * Release selected resident direct children of one exact live parent without + * closing admission for the parent's other continuable children. Owned + * descendants are released recursively through the same lifecycle. + * @param parent - exact live direct parent authorizing the selected release. + * @param childIds - durable direct-child ids to release when resident. + * @returns once every selected Activation released its handle. + * @throws {SubagentError} `UNAUTHORIZED` when a resident target is not the + * parent's direct continuable child or the parent identity is stale. + */ + async drainChildren(parent: Agent, childIds: readonly SessionId[]): Promise { + if (this.ctx.agents.get(parent.id) !== parent) { + throw new SubagentError('selected child teardown requires the exact live parent agent', 'UNAUTHORIZED') + } + const targets: Activation[] = [] + for (const childId of new Set(childIds)) { + const activation = this.activations.get(childId) + if (activation === undefined) continue + if (activation.parentSession !== parent.id || !activation.ancestry.has(parent)) { + throw new SubagentError( + `subagent "${childId}" is not a direct child of agent "${parent.id}"`, + 'UNAUTHORIZED', + ) + } + targets.push(activation) + } + + // Open every transaction before the first await so cancellation propagates + // across the selected roots in one synchronous span. + for (const activation of targets) { + const disposal = this.dispose(activation) + void disposal.catch(() => undefined) + } + await this.disposeRoots(targets, 'selected activation(s)') + } + /** Dispose independent roots and report every branch failure after all settle. */ private async disposeRoots( roots: readonly Activation[], - failureSubject: 'activation(s)' | 'scoped activation(s)', + failureSubject: 'activation(s)' | 'scoped activation(s)' | 'selected activation(s)', ): Promise { const failures = await Promise.all(roots.map(async (activation) => { try { @@ -1158,7 +1220,7 @@ export class SubagentContinuationManager { messageId: MessageId, send: () => void, ): MessageId { - // `Agent.followup()` publishes inbox events synchronously, so observers must + // Waking Agent sends publish inbox events synchronously, so observers must // see this Activation as busy before the call begins. activation.accepted.add(messageId) try { diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index fe270f1b4c..45b96a2311 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -308,6 +308,22 @@ export class SubagentRuntime extends Service { await manager.drainDescendants(parents) } + /** + * Release selected resident continuable direct children of one exact live + * parent. Other children of the same parent remain admitted and resident. + * Absent targets and a manager-less composition are accepted no-ops. + * @param parent - exact live direct parent authorizing the selected release. + * @param childIds - durable direct-child ids to release when resident. + * @returns once every selected Activation released its `AgentHandle`. + * @throws {SubagentError} `UNAUTHORIZED` when a resident target belongs to a + * different parent or the supplied parent identity is stale. + */ + async drainContinuableChildren(parent: Agent, childIds: readonly SessionId[]): Promise { + const manager = this.continuations + if (manager === undefined) return + await manager.drainChildren(parent, childIds) + } + /** * Enumerate the parent's direct session-backed subagents without loading or * resuming an Agent and without any query service: the listing merges the live diff --git a/packages/subagent/subagent/tests/continuation.spec.ts b/packages/subagent/subagent/tests/continuation.spec.ts index 07f497628d..d7ef69a593 100644 --- a/packages/subagent/subagent/tests/continuation.spec.ts +++ b/packages/subagent/subagent/tests/continuation.spec.ts @@ -190,6 +190,38 @@ describe('SubagentRuntime.startContinuable', () => { expect(hasUserText(loaded.events, 'child task')).toBe(true) }) + it('uses a caller-reserved child identity and rejects a duplicate reservation', async () => { + const release = Promise.withResolvers() + const adapter = new GatedAdapter([ + { chunks: textResponse('reserved answer'), gate: release.promise }, + ]) + const { ctx, parent } = await setupWith(adapter) + const reservedId = SessionId('00000000-0000-4000-8000-000000000123') + + const started = await ctx.subagents.startContinuable({ + ...startSpec(parent), + childId: reservedId, + }) + expect(started.childId).toBe(reservedId) + await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) + + await expect(ctx.subagents.startContinuable({ + ...startSpec(parent), + childId: reservedId, + })).rejects.toMatchObject({ code: 'DUPLICATE_CHILD' }) + + release.resolve(undefined) + await waitNoActivation(ctx, reservedId) + const loaded = await ctx.sessionPersistence.load(reservedId) + expect(loaded.meta.id).toBe(reservedId) + + await expect(ctx.subagents.startContinuable({ + ...startSpec(parent), + childId: reservedId, + })).rejects.toMatchObject({ code: 'DUPLICATE_CHILD' }) + expect(ctx.agents.get(reservedId)).toBeUndefined() + }) + it('rejects without ids when the provider has no prepareContinuable capability', async () => { const { ctx, parent } = await setup([]) const start = vi.fn(async () => { throw new Error('must not dispatch') }) @@ -745,10 +777,10 @@ describe('continuable durability and teardown', () => { hold.resolve(undefined) + await waitNoActivation(ctx, started.childId) await vi.waitFor(() => { expect(warnings.some(warning => warning.includes('normal settlement cleanup failed'))).toBe(true) - }) - expect(ctx.agents.get(started.childId)).toBeUndefined() + }, { timeout: 5_000 }) }) it('disposes every live Activation forest child-first on manager teardown', async () => { @@ -873,6 +905,86 @@ describe('continuable durability and teardown', () => { await waitNoActivation(ctx, started.childId) }) + it('releases only selected direct children', async () => { + const releaseTarget = Promise.withResolvers() + const releaseSibling = Promise.withResolvers() + const releaseGrandchild = Promise.withResolvers() + const adapter = new GatedAdapter([ + { chunks: textResponse('target'), gate: releaseTarget.promise }, + { chunks: textResponse('sibling'), gate: releaseSibling.promise }, + { chunks: textResponse('grandchild'), gate: releaseGrandchild.promise }, + ]) + const { ctx, parent } = await setupWith(adapter) + const target = await ctx.subagents.startContinuable(startSpec(parent)) + const sibling = await ctx.subagents.startContinuable(startSpec(parent)) + await vi.waitFor(() => { expect(adapter.requests).toHaveLength(2) }) + const targetAgent = ctx.agents.get(target.childId)! + const siblingAgent = ctx.agents.get(sibling.childId)! + const grandchild = await ctx.subagents.startContinuable(startSpec(targetAgent)) + await vi.waitFor(() => { expect(adapter.requests).toHaveLength(3) }) + const cancel = vi.spyOn(targetAgent, 'cancel') + + const drained = ctx.subagents.drainContinuableChildren(parent, [target.childId, target.childId]) + + expect(cancel).toHaveBeenCalledWith({ kind: 'parent' }) + expect(ctx.agents.get(sibling.childId)).toBe(siblingAgent) + releaseTarget.resolve(undefined) + releaseGrandchild.resolve(undefined) + await drained + expect(ctx.agents.get(target.childId)).toBeUndefined() + expect(ctx.agents.get(grandchild.childId)).toBeUndefined() + expect(ctx.agents.get(sibling.childId)).toBe(siblingAgent) + releaseSibling.resolve(undefined) + await waitNoActivation(ctx, sibling.childId) + }) + + it('reports selected-child disposal failures after releasing the child', async () => { + const hold = Promise.withResolvers() + const adapter = new GatedAdapter([{ chunks: textResponse('target'), gate: hold.promise }]) + const { ctx, parent } = await setupWith(adapter) + const target = await ctx.subagents.startContinuable(startSpec(parent)) + await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) + const manager = (ctx.subagents as unknown as { + continuations: { activations: Map Promise } }> } + }).continuations + const activation = manager.activations.get(target.childId)! + const realDispose = activation.handle.dispose.bind(activation.handle) + activation.handle.dispose = async () => { + await realDispose() + throw new Error('selected cleanup failed') + } + + const drained = ctx.subagents.drainContinuableChildren(parent, [target.childId]) + hold.resolve(undefined) + + await expect(drained).rejects.toMatchObject({ code: 'ACTIVATION_TEARDOWN_FAILED' }) + expect(ctx.agents.get(target.childId)).toBeUndefined() + }) + + it('rejects selected-child teardown through another live parent', async () => { + const release = Promise.withResolvers() + const adapter = new GatedAdapter([{ chunks: textResponse('target'), gate: release.promise }]) + const { ctx, parent } = await setupWith(adapter) + const other = ctx.agentLoop.create(SessionId('other-parent'), { provider: 'mock', model: 'mock' }) + const target = await ctx.subagents.startContinuable(startSpec(parent)) + await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) + + await expect(ctx.subagents.drainContinuableChildren(other, [target.childId])) + .rejects.toMatchObject({ code: 'UNAUTHORIZED' }) + expect(ctx.agents.get(target.childId)).toBeDefined() + + release.resolve(undefined) + await waitNoActivation(ctx, target.childId) + }) + + it('rejects selected-child teardown through a stale parent identity', async () => { + const { ctx, parent } = await setup([]) + const stale = { ...parent, id: parent.id } as unknown as Agent + + await expect(ctx.subagents.drainContinuableChildren(stale, [])) + .rejects.toMatchObject({ code: 'UNAUTHORIZED' }) + }) + it('finds scoped descendants after an intermediate one-shot Agent leaves the registry', async () => { const releaseIntermediate = Promise.withResolvers() const releaseDescendant = Promise.withResolvers() @@ -1524,6 +1636,42 @@ function settlementNotices(agent: Agent): { sender: string; text: string; summar }) } +describe('continuable report delivery', () => { + it('wakes an idle parent for a next-step report', async () => { + const releaseChild = Promise.withResolvers() + const adapter = new GatedAdapter([ + { chunks: textResponse('child answer'), gate: releaseChild.promise }, + { chunks: textResponse('parent report ack') }, + { chunks: textResponse('parent settlement ack') }, + ]) + const { ctx, parent } = await setupWith(adapter) + const started = await ctx.subagents.startContinuable(startSpec(parent)) + await vi.waitFor(() => { + expect(adapter.requests.filter(request => request.sessionId === started.childId)).toHaveLength(1) + }) + const child = ctx.agents.get(started.childId) + expect(child).toBeDefined() + + const messageId = await ctx.subagents.reportFrom(child!, message('an explicit report'), { + delivery: 'next-step', + signal: testSignal, + }) + + await vi.waitFor(() => { + expect(adapter.requests.filter(request => request.sessionId === parent.id)).toHaveLength(1) + }) + const report = parent.session.events.flatMap(event => event.type === 'user/message' + && event.data.source.kind === 'subagent-report' ? [event.data] : [])[0] + expect(report?.id).toBe(messageId) + + releaseChild.resolve(undefined) + await waitNoActivation(ctx, started.childId) + await vi.waitFor(() => { + expect(adapter.requests.filter(request => request.sessionId === parent.id)).toHaveLength(2) + }) + }) +}) + describe('continuable settlement delivery', () => { it('tells the parent what the child finished with, without being asked', async () => { const { ctx, parent } = await setup([textResponse('the answer'), textResponse('parent ack')]) diff --git a/packages/subagent/subagent/tests/service.spec.ts b/packages/subagent/subagent/tests/service.spec.ts index e387a758e8..05e9785611 100644 --- a/packages/subagent/subagent/tests/service.spec.ts +++ b/packages/subagent/subagent/tests/service.spec.ts @@ -127,11 +127,12 @@ describe('SubagentRuntime', () => { expect('resume' in provider).toBe(false) }) - it('does not expose manager teardown and treats a scoped drain as a no-op when no manager was bound', async () => { + it('does not expose manager teardown and treats public drains as no-ops when no manager was bound', async () => { const { subagents } = await service() // Without `ctx.agents` no manager exists, so nothing was ever materialized. expect('drainContinuable' in subagents).toBe(false) await expect(subagents.drainContinuableDescendants([])).resolves.toBeUndefined() + await expect(subagents.drainContinuableChildren(fakeParent(), [SessionId('child')])).resolves.toBeUndefined() }) it('treats interrupt as an accepted no-op when no manager was bound', async () => { diff --git a/packages/subagent/tool-subagent-report/README.i18n.yaml b/packages/subagent/tool-subagent-report/README.i18n.yaml index f96d1d5031..129ba8f92f 100644 --- a/packages/subagent/tool-subagent-report/README.i18n.yaml +++ b/packages/subagent/tool-subagent-report/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subagent/tool-subagent-report/README.md -README.md: b38b6b541d9c03174de1515df7a0babe5da055b4 -README.zh.md: d8557a43bca4b9a403aac31b24812425e0586fcf +README.md: ab2bcc5e4b829b66810da4089edb7146ebc1c791 +README.zh.md: c9ae647a44cad8c04603877dddd81d3fbe5be0e3 diff --git a/packages/subagent/tool-subagent-report/README.md b/packages/subagent/tool-subagent-report/README.md index b38b6b541d..ab2bcc5e4b 100644 --- a/packages/subagent/tool-subagent-report/README.md +++ b/packages/subagent/tool-subagent-report/README.md @@ -6,7 +6,7 @@ The optional child-scoped `report` tool is a thin adapter over `ctx.subagents.re The child-scoped `tool:report` prompt section instructs the child to call `report` once before finishing, with a self-contained answer, and earlier whenever a partial finding changes what the parent should do next. The instruction is guidance, not enforcement: the mechanism still accepts zero or many calls in one turn, and no runtime path rejects a child that never reports. A successful call neither concludes the turn, settles the Activation, nor prevents later parent follow-ups, and finishing a turn never reports automatically. The tool accepts no recipient: `exec.agent` is the sender's exact live Agent and the authority credential, and the service derives the sole recipient from that child's durable `parentSession`. Success returns the stable `MessageId` of the parent-accepted message, not a read receipt, an inbox-occurrence id, a parent-log acknowledgement, a turn-completion receipt, or a persistence flush. A parent absent from the registry fails the call with `direct parent is not live; report was not delivered` — registry presence governs parent resolution, and a registered parent already in host-owned disposal still accepts while its log admits appends. The service performs no injection, parent cold resume, or offline mailbox write; the durable child transcript remains the recovery source, and a failed tool call does not prove non-delivery (a later `tools/post-execute` veto can fail a call whose report was already accepted). -`reportDelivery` selects parent scheduling for every accepted report. `wakeup` (the default) uses `parent.followup()`, creating exactly one ordinary later parent turn and waking a parked parent driver; it never steers an open turn. It is the default because a parent that already parked has no other reason to look, so quiet delivery would leave an accepted report unread until something unrelated woke it. `quiet` uses `parent.inject()`, adding model-facing context without starting a parent model request: an idle parent's append completes before the call returns, while a report reaching an admitting or running parent stages for the next safe log position. This is deployment scheduling policy, so the model-facing schema cannot select or override it per call. +`reportDelivery` selects parent scheduling for every accepted report. `next-step` (the default) uses `parent.steer()`: a running parent receives the report at its nearest safe step boundary, while an idle parent starts a turn. Reports accepted in sequence share the next-step FIFO, including the later manager-authored settlement notice, so the parent cannot observe settlement before an earlier report; reports waiting together enter one claimed batch. `quiet` uses `parent.inject()`, adding the same next-step context without waking a parked parent. This is deployment scheduling policy, so the model-facing schema cannot select or override it per call. Scope-local registration deliberately survives the child's global `toolFilter`, so a delegation allow-list cannot remove the only return channel. A deployment that requires a child with no return channel omits this package. @@ -36,7 +36,7 @@ Prefix-stable within a child; neither the schema nor the section changes at runt #### Token effect -One short acknowledgement per call in the reporting child. The reported content is additionally billed to the parent: waking delivery makes it the sole ordinary message of one new parent turn, while quiet delivery adds it to the parent's next request. +One short acknowledgement per call in the reporting child. The reported content is additionally billed to the parent: next-step delivery joins the next request in an open parent turn or starts a turn for an idle parent, while quiet delivery waits for another input to wake the parent. #### KV Cache effect @@ -54,7 +54,7 @@ The child's complete `output` plus the one-line frame, uncapped by this package. #### KV Cache effect -Append-only; the report follows the parent's reusable request prefix. Waking delivery starts an independent parent model request, while quiet delivery does not. +Append-only; the report follows the parent's reusable request prefix. Next-step delivery wakes the parent and may extend its open turn, while quiet delivery does not wake it. ## Known Limitations and Deferred Work @@ -63,4 +63,4 @@ Append-only; the report follows the parent's reusable request prefix. Waking del - **A staged quiet report is not immediately reconstructable** — acceptance returns its stable `MessageId`, but the parent Session reconstructs the framed content only after pending context reaches its ordinary log boundary. - **Granting waits for the next Activation; revocation is immediate** — installing this package after a child becomes resident grants `report` and its guidance only on that child's next Activation, while removing the package revokes both from resident children immediately. - **Nested reporting reaches exactly one edge upward** — a grandchild reports to its direct child parent, never to the top-level coordinator, which must explicitly report a derived update later. -- **No rate limiting** — the default `wakeup` mode can amplify model work when nested children report frequently; a deployment that accepts unread reports over that amplification selects `quiet`. +- **No rate limiting** — the default `next-step` mode can amplify model work when nested children report frequently, although reports waiting together share one step; a deployment that accepts unread reports over that amplification selects `quiet`. diff --git a/packages/subagent/tool-subagent-report/README.zh.md b/packages/subagent/tool-subagent-report/README.zh.md index d8557a43bc..c9ae647a44 100644 --- a/packages/subagent/tool-subagent-report/README.zh.md +++ b/packages/subagent/tool-subagent-report/README.zh.md @@ -6,7 +6,7 @@ 子级作用域的 `tool:report` 提示词 section 要求子级在结束前调用一次 `report` 并给出自足的答案,并在部分发现会改变父级下一步动作时提前上报。该指令是引导而非强制:机制本身仍接受一个轮次中调用零次或多次,也没有任何运行时路径会拒绝从不上报的子级。调用成功既不会结束轮次或结算 Activation,也不会阻止父级后续消息;轮次结束也绝不会自动上报。该工具不接受接收方参数:`exec.agent` 是发送方确切在线的 Agent,也是权限凭据;服务根据该子级持久化的 `parentSession` 推导唯一接收方。成功时返回父级已接受消息的稳定 `MessageId`,不表示已读回执、inbox 中该次出现的 id、父级日志确认、轮次完成回执或持久化刷盘。父级解析由注册表中的存在性决定:父级不在注册表时,调用失败并返回 `direct parent is not live; report was not delivered`;已开始由宿主管理的 dispose(资源释放)但仍在注册表中的父级在其日志仍接受追加时仍会接受。服务不会执行注入、父级冷恢复或离线 mailbox 写入;持久化子级 transcript(文本记录)仍是恢复依据,且工具调用失败不能证明未送达(后续 `tools/post-execute` 否决可能让报告已被接受的调用以失败结束)。 -`reportDelivery` 为每条已接受的报告选择父级调度方式。`wakeup`(默认值)使用 `parent.followup()`,恰好创建一个普通的后续父级轮次,并唤醒停驻的父级驱动;它绝不会对正在运行的轮次进行 steering(中途引导)。之所以作为默认值:已经停驻的父级没有别的理由再去查看,静默投递会让一条已被接受的报告一直无人阅读,直到别的事件把父级唤醒。`quiet` 使用 `parent.inject()`,在不启动父级模型请求的情况下添加面向模型的上下文:父级空闲时,追加操作会在调用返回前完成;报告到达正在准入或运行的父级时,则会暂存到下一个安全日志位置。这是部署调度策略,因此面向模型的 schema 不能在单次调用中选择或覆盖该策略。 +`reportDelivery` 为每条已接受的报告选择父级调度方式。`next-step`(默认值)使用 `parent.steer()`:运行中的父级会在最近的安全 step 边界收到报告,空闲父级则会启动一个轮次。按顺序接受的报告会共享 next-step FIFO,稍后由管理器撰写的结算通知也进入该队列,因此父级不会在更早的报告之前看到结算;一起等待的报告会进入同一个领取批次。`quiet` 使用 `parent.inject()`,添加相同的 next-step 上下文,但不会唤醒停驻的父级。这是部署调度策略,因此面向模型的 schema 不能在单次调用中选择或覆盖该策略。 作用域局部注册有意不受子级全局 `toolFilter` 影响,因此委派允许列表无法移除唯一的返回通道。需要子级不具备返回通道的部署应省略本包。 @@ -36,7 +36,7 @@ #### Token 影响 -每次调用都会在执行上报的子级中产生一条简短确认消息。父级还会为上报内容支付 token 成本:唤醒投递会使该内容成为一个新父级轮次中唯一的普通消息,静默投递则把内容加入父级的下一次请求。 +每次调用都会在执行上报的子级中产生一条简短确认消息。父级还会为上报内容支付 token 成本:next-step 投递会加入父级已打开轮次的下一次请求,或为空闲父级启动一个轮次;静默投递则等待其他输入唤醒父级。 #### KV Cache 影响 @@ -54,7 +54,7 @@ #### KV Cache 影响 -仅追加;报告位于父级可复用请求前缀之后。唤醒投递会启动一次独立的父级模型请求,静默投递则不会。 +仅追加;报告位于父级可复用请求前缀之后。next-step 投递会唤醒父级,并可能延长其已打开的轮次;静默投递则不会唤醒父级。 ## 已知限制与暂缓事项 @@ -63,4 +63,4 @@ - **暂存的静默报告无法立即重建**:接受时会返回其稳定 `MessageId`,但只有当待处理上下文到达普通日志边界后,父级会话才能重建带前缀的内容。 - **授权须等到下一个 Activation,撤销则立即生效**:子级驻留后再安装本包,只会在该子级的下一个 Activation 中授予 `report` 及其指引;移除本包则会立即从驻留子级撤销两者。 - **嵌套上报只向上到达一条直接边**:孙级只向作为其直接父级的子级上报,不会直接到达顶层协调器;该直接父级必须随后显式发出一条衍生更新。 -- **没有速率限制**:嵌套子级频繁上报时,默认的 `wakeup` 模式会放大模型工作量;宁可接受报告无人阅读也要避免这种放大的部署应选择 `quiet`。 +- **没有速率限制**:嵌套子级频繁上报时,默认的 `next-step` 模式会放大模型工作量,但一起等待的报告会共享一个 step;宁可接受报告无人阅读也要避免这种放大的部署应选择 `quiet`。 diff --git a/packages/subagent/tool-subagent-report/src/index.ts b/packages/subagent/tool-subagent-report/src/index.ts index 6f2a6b3c6a..85b139e641 100644 --- a/packages/subagent/tool-subagent-report/src/index.ts +++ b/packages/subagent/tool-subagent-report/src/index.ts @@ -26,15 +26,15 @@ const REPORT_SECTION_ORDER = 117 /** Config: how accepted reports are scheduled on the parent. */ export interface Config { /** - * Parent scheduling (default `wakeup`). `wakeup` creates one ordinary later - * parent turn; `quiet` adds context without waking, so a parked parent learns - * of the report only when something else wakes it. + * Parent scheduling (default `next-step`). `next-step` wakes the parent and + * enters at its nearest step boundary; `quiet` adds the same context without + * waking, so a parked parent waits for another waking input. */ reportDelivery?: SubagentReportDelivery } export const Config: z = z.object({ - reportDelivery: z.union(['quiet', 'wakeup'] as const).default('wakeup'), + reportDelivery: z.union(['quiet', 'next-step'] as const).default('next-step'), }) /** diff --git a/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts b/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts index b71e8365db..28d6068757 100644 --- a/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts +++ b/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts @@ -7,7 +7,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import { assembleContextFor } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' -import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm' +import { CallId, LlmAdapter, createUserMessage } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent } from '@deepseek-ai/dsh-session' @@ -20,22 +20,39 @@ import * as tool from '../src/index.ts' const testSignal = new AbortController().signal -/** Adapter that keeps child Activations resident until released. */ +/** Adapter that keeps selected Agent requests open until released. */ class HeldAdapter extends LlmAdapter { readonly requests: GenerateOptions[] = [] - private readonly gate = Promise.withResolvers() + private readonly gates = new Map>() + private readonly releasedSessions = new Set() + private released = false async * stream(options: GenerateOptions): AsyncIterable { this.requests.push(options) - await this.gate.promise + if (!this.released && !this.releasedSessions.has(options.sessionId)) { + let gate = this.gates.get(options.sessionId) + if (gate === undefined) { + gate = Promise.withResolvers() + this.gates.set(options.sessionId, gate) + } + await gate.promise + } for (const chunk of textResponse('held answer')) { if (options.signal?.aborted) throw new Error('aborted') yield chunk } } - release(): void { - this.gate.resolve(undefined) + release(sessionId?: SessionId): void { + if (sessionId !== undefined) { + this.releasedSessions.add(sessionId) + this.gates.get(sessionId)?.resolve(undefined) + this.gates.delete(sessionId) + return + } + this.released = true + for (const gate of this.gates.values()) gate.resolve(undefined) + this.gates.clear() } } @@ -86,6 +103,17 @@ async function startChild(ctx: Context, parent: Agent, prompt = 'child task') { return { started, child } } +/** Start one parent request that remains open in the held adapter. */ +async function startHeldParentTurn(parent: Agent, adapter: HeldAdapter): Promise { + parent.followup(createUserMessage({ + content: [{ type: 'text', text: 'parent work' }], + source: { kind: 'user' }, + })) + await vi.waitFor(() => { + expect(adapter.requests.some(request => request.sessionId === parent.id)).toBe(true) + }) +} + let calls = 0 function callReport(ctx: Context, child: Agent, output: string, signal = testSignal) { return ctx.tools.execute({ @@ -204,8 +232,8 @@ describe('dsh-tool-subagent-report', () => { expect(adapter.requests.filter(request => request.sessionId === parent.id)).toHaveLength(parentRequests) }) - it('queues wakeup reports as one later parent turn', async () => { - const { ctx, parent, adapter } = await setup({ config: { reportDelivery: 'wakeup' } }) + it('delivers next-step reports through waking steering', async () => { + const { ctx, parent, adapter } = await setup({ config: { reportDelivery: 'next-step' } }) const { child } = await startChild(ctx, parent) const enqueues: string[] = [] ctx.on('agent/inbox/inserted', ({ agent, message }) => { @@ -216,19 +244,37 @@ describe('dsh-tool-subagent-report', () => { const result = await callReport(ctx, child, 'WAKE_UP') expect(result.isError).toBe(false) - expect(enqueues).toEqual(['queued']) + expect(enqueues).toEqual(['steering']) await vi.waitFor(() => { expect(adapter.requests.some(request => request.sessionId === parent.id)).toBe(true) }) }) - it('preserves accepted order across repeated reports', async () => { - const { ctx, parent } = await setup() + it('batches repeated next-step reports in accepted order', async () => { + const { ctx, parent, adapter } = await setup({ config: { reportDelivery: 'next-step' } }) + await startHeldParentTurn(parent, adapter) const { child } = await startChild(ctx, parent) expect((await callReport(ctx, child, 'FIRST')).isError).toBe(false) expect((await callReport(ctx, child, 'SECOND')).isError).toBe(false) expect(reports(parent).map(report => report.text.split('\n').at(-1))).toEqual(['FIRST', 'SECOND']) + expect(parent.inbox.nextStep).toHaveLength(2) + }) + + it('keeps a report before the child settlement in one busy-parent batch', async () => { + const { ctx, parent, adapter } = await setup({ config: { reportDelivery: 'next-step' } }) + await startHeldParentTurn(parent, adapter) + const { started, child } = await startChild(ctx, parent) + + expect((await callReport(ctx, child, 'ORDERED_REPORT')).isError).toBe(false) + adapter.release(started.childId) + await vi.waitFor(() => { expect(ctx.agents.get(started.childId)).toBeUndefined() }) + + expect(parent.inbox.nextStep.map(message => message.source.kind)).toEqual([ + 'subagent-report', + 'subagent-settled', + ]) + expect(parent.inbox.nextTurn).toHaveLength(0) }) it('keeps an accepted report after the child settles', async () => { @@ -261,8 +307,8 @@ describe('dsh-tool-subagent-report', () => { expect(reports(child)[0]?.text).toContain('FROM_GRANDCHILD') }) - it('accounts wakeup reports delivered to a resident continuable parent', async () => { - const { ctx, parent, adapter } = await setup({ config: { reportDelivery: 'wakeup' } }) + it('accounts next-step reports delivered to a resident continuable parent', async () => { + const { ctx, parent, adapter } = await setup({ config: { reportDelivery: 'next-step' } }) const { child } = await startChild(ctx, parent, 'outer task') const { started: grandchildStart, child: grandchild } = await startChild(ctx, child, 'inner task') @@ -509,9 +555,10 @@ describe('dsh-tool-subagent-report', () => { expect('default' in tool).toBe(false) expect(tool.name).toBe('tool-subagent-report') expect(tool.inject).toEqual(['subagents', 'tools', 'systemPrompt']) - // Waking is the default because a report that never wakes its parent - // cannot deliver a result to an agent that already parked. - expect(tool.Config({}).reportDelivery).toBe('wakeup') + // Next-step delivery wakes a parked parent and lets a running parent act at + // its nearest safe boundary. + expect(tool.Config({}).reportDelivery).toBe('next-step') + expect(() => tool.Config({ reportDelivery: 'wakeup' } as never)).toThrow() expect(() => tool.Config({ reportDelivery: 'shout' } as never)).toThrow() }) @@ -526,7 +573,7 @@ describe('dsh-tool-subagent-report', () => { }) expect((await callReport(ctx, child, 'DEFAULT_WAKES')).isError).toBe(false) - expect(enqueues).toEqual(['queued']) + expect(enqueues).toEqual(['steering']) await vi.waitFor(() => { expect(adapter.requests.some(request => request.sessionId === parent.id)).toBe(true) }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ce67604757..6123468bef 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -640,6 +640,9 @@ importers: '@deepseek-ai/dsh-system-prompt': specifier: workspace:* version: link:../packages/core/system-prompt + '@deepseek-ai/dsh-team': + specifier: workspace:* + version: link:../packages/experimental/team '@deepseek-ai/dsh-terminal': specifier: workspace:* version: link:../packages/terminal/terminal @@ -706,6 +709,9 @@ importers: '@deepseek-ai/dsh-tool-subagent-report': specifier: workspace:* version: link:../packages/subagent/tool-subagent-report + '@deepseek-ai/dsh-tool-team': + specifier: workspace:* + version: link:../packages/experimental/tool-team '@deepseek-ai/dsh-tool-terminal': specifier: workspace:* version: link:../packages/terminal/tool-terminal @@ -3914,6 +3920,110 @@ importers: specifier: workspace:^ version: link:../../runtime-diagnostics/invariants + packages/experimental/team: + dependencies: + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery + zod: + specifier: ^4.4.3 + version: 4.4.3 + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-agent-loop': + specifier: workspace:^ + version: link:../../core/agent-loop + '@deepseek-ai/dsh-agent-loop-testkit': + specifier: workspace:^ + version: link:../../test-support/agent-loop-testkit + '@deepseek-ai/dsh-brand': + specifier: workspace:^ + version: link:../../util/brand + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-session-persistence': + specifier: workspace:^ + version: link:../../session/session-persistence + '@deepseek-ai/dsh-session-persistence-jsonl': + specifier: workspace:^ + version: link:../../session/session-persistence-jsonl + '@deepseek-ai/dsh-session-persistence-sqlite': + specifier: workspace:^ + version: link:../../session/session-persistence-sqlite + '@deepseek-ai/dsh-subagent': + specifier: workspace:^ + version: link:../../subagent/subagent + '@deepseek-ai/dsh-subagent-fork-in-process': + specifier: workspace:^ + version: link:../../subagent/subagent-fork-in-process + '@deepseek-ai/dsh-subagent-spawn-in-process': + specifier: workspace:^ + version: link:../../subagent/subagent-spawn-in-process + + packages/experimental/tool-team: + dependencies: + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-agent-loop': + specifier: workspace:^ + version: link:../../core/agent-loop + '@deepseek-ai/dsh-agent-loop-testkit': + specifier: workspace:^ + version: link:../../test-support/agent-loop-testkit + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-scope': + specifier: workspace:^ + version: link:../../core/scope + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-session-persistence-jsonl': + specifier: workspace:^ + version: link:../../session/session-persistence-jsonl + '@deepseek-ai/dsh-subagent': + specifier: workspace:^ + version: link:../../subagent/subagent + '@deepseek-ai/dsh-subagent-fork-in-process': + specifier: workspace:^ + version: link:../../subagent/subagent-fork-in-process + '@deepseek-ai/dsh-subagent-spawn-in-process': + specifier: workspace:^ + version: link:../../subagent/subagent-spawn-in-process + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../../core/system-prompt + '@deepseek-ai/dsh-team': + specifier: workspace:^ + version: link:../team + '@deepseek-ai/dsh-tool-subagent-control': + specifier: workspace:^ + version: link:../../subagent/tool-subagent-control + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../core/tools + packages/extensions/cordis-client-runner: devDependencies: '@deepseek-ai/cordis': diff --git a/scripts/check-workspace-constraints.spec.ts b/scripts/check-workspace-constraints.spec.ts new file mode 100644 index 0000000000..755af09093 --- /dev/null +++ b/scripts/check-workspace-constraints.spec.ts @@ -0,0 +1,67 @@ +/** Experimental-package publication and dependency constraints. */ + +import { describe, expect, it } from 'vitest' +import { + checkExperimentalDependencyIsolation, + checkExperimentalManifest, + type WorkspaceManifest, +} from './check-workspace-constraints.ts' + +const experimental: WorkspaceManifest = { + dir: 'packages/experimental/prototype', + manifest: { name: '@deepseek-ai/dsh-prototype', private: true }, +} + +describe('experimental workspace constraints', () => { + it('requires private manifests without publication metadata', () => { + expect(checkExperimentalManifest(experimental)).toEqual([]) + expect(checkExperimentalManifest({ + ...experimental, + manifest: { ...experimental.manifest, private: false, publishConfig: { access: 'public' } }, + })).toEqual([ + '@deepseek-ai/dsh-prototype: experimental package must set "private": true', + '@deepseek-ai/dsh-prototype: experimental package must omit publishConfig', + ]) + }) + + it.each(['dependencies', 'optionalDependencies', 'peerDependencies'] as const)( + 'rejects release %s on an experimental package', + (section) => { + expect(checkExperimentalDependencyIsolation([experimental, { + dir: 'packages/core/consumer', + manifest: { + name: '@deepseek-ai/dsh-consumer', + [section]: { '@deepseek-ai/dsh-prototype': 'workspace:^' }, + }, + }])).toEqual([ + `@deepseek-ai/dsh-consumer: ${section}.@deepseek-ai/dsh-prototype must not reference an experimental package`, + ]) + }, + ) + + it('allows development and experimental consumers but rejects the Python release runtime', () => { + const manifests: WorkspaceManifest[] = [experimental, { + dir: 'packages/core/test-only', + manifest: { + name: '@deepseek-ai/dsh-test-only', + devDependencies: { '@deepseek-ai/dsh-prototype': 'workspace:^' }, + }, + }, { + dir: 'packages/experimental/consumer', + manifest: { + name: '@deepseek-ai/dsh-experimental-consumer', + dependencies: { '@deepseek-ai/dsh-prototype': 'workspace:^' }, + }, + }, { + dir: 'python/sdk-runtime', + manifest: { + name: '@deepseek-ai/dsh-python-runtime', + dependencies: { '@deepseek-ai/dsh-prototype': 'workspace:^' }, + }, + }] + + expect(checkExperimentalDependencyIsolation(manifests)).toEqual([ + '@deepseek-ai/dsh-python-runtime: dependencies.@deepseek-ai/dsh-prototype must not reference an experimental package', + ]) + }) +}) diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index c8dab964e1..5852839961 100644 --- a/scripts/check-workspace-constraints.ts +++ b/scripts/check-workspace-constraints.ts @@ -7,6 +7,7 @@ import { existsSync, readdirSync, readFileSync } from 'node:fs' import { join, relative, resolve } from 'node:path' +import { pathToFileURL } from 'node:url' import { hasTypertRemoteNavigation, isForbiddenPublicationFile } from './publication-payload.ts' import { collectProjectReferenceFaceViolations } from './project-reference-faces.ts' @@ -47,8 +48,10 @@ const repositoryUrl = 'git+https://github.com/deepseek-harness/deepseek-harness. * their trusted publishing against the repository that runs the workflow. */ const publishedRepositoryUrl = 'git+https://github.com/deepseek-ai/deepseek-harness.git' +/** Private packages that participate in workspace checks but not releases. */ +const experimentalPackageDirectory = /^packages\/experimental\/[^/]+$/ /** Directories whose packages this repository publishes: one release member each. */ -const releaseMemberDirectory = /^(?:packages\/[^/]+\/[^/]+|apps\/[^/]+|vendor\/[^/]+)$/ +const releaseMemberDirectory = /^(?:packages\/(?!experimental\/)[^/]+\/[^/]+|apps\/[^/]+|vendor\/[^/]+)$/ const localArtifactDirs = new Set(['node_modules']) const appPackageFiles: Readonly> = { @@ -59,7 +62,7 @@ const appPackageFiles: Readonly> = { } /** The subset of package.json fields this constraint check cares about. */ -interface PackageManifest { +export interface PackageManifest { name?: string version?: string private?: boolean @@ -87,7 +90,7 @@ interface PackageManifest { } /** One workspace manifest and its repo-relative path. */ -interface WorkspaceManifest { +export interface WorkspaceManifest { dir: string manifest: PackageManifest } @@ -228,8 +231,18 @@ function usesEmittedTreeDefaults(manifest: PackageManifest): boolean { exportDefault(manifest, subpath)?.startsWith('./lib/types/') === true) } -function checkWorkspace({ dir, manifest }: WorkspaceManifest): string[] { +/** Experimental manifest requirements enforced independently from release metadata. */ +export function checkExperimentalManifest({ dir, manifest }: WorkspaceManifest): string[] { + if (!experimentalPackageDirectory.test(dir)) return [] + const label = manifest.name ?? dir const errors: string[] = [] + if (manifest.private !== true) errors.push(`${label}: experimental package must set "private": true`) + if (manifest.publishConfig !== undefined) errors.push(`${label}: experimental package must omit publishConfig`) + return errors +} + +function checkWorkspace({ dir, manifest }: WorkspaceManifest): string[] { + const errors = checkExperimentalManifest({ dir, manifest }) const label = manifest.name ?? dir const isLandlockPackageDir = dir.startsWith('native/landlock-run/packages/') const isPublicLandlockPackage = isLandlockPackageDir @@ -271,7 +284,7 @@ function checkWorkspace({ dir, manifest }: WorkspaceManifest): string[] { || manifest.repository.directory !== dir) { errors.push(`${label}: release member repository must use ${publishedRepositoryUrl} with directory ${dir}`) } - } else if (manifest.private !== true) { + } else if (!experimentalPackageDirectory.test(dir) && manifest.private !== true) { errors.push(`${label}: package.json must set "private": true`) } @@ -390,6 +403,31 @@ function checkRepositoryVersion(): string[] { /** Dependency sections whose ranges reach a published tarball or a local install. */ const dependencySections = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'] as const +/** Dependency sections present in an installed runtime. */ +const runtimeDependencySections = ['dependencies', 'optionalDependencies', 'peerDependencies'] as const + +/** + * Prevent an official runtime from requiring a package its release omits. + * @param manifests - release, private experimental, and deployment-root manifests. + * @returns One error for each forbidden runtime dependency. + */ +export function checkExperimentalDependencyIsolation(manifests: readonly WorkspaceManifest[]): string[] { + const experimentalNames = new Set(manifests + .filter(entry => experimentalPackageDirectory.test(entry.dir)) + .map(entry => entry.manifest.name) + .filter(name => name !== undefined)) + const errors: string[] = [] + for (const { dir, manifest } of manifests) { + if (!releaseMemberDirectory.test(dir) && dir !== 'python/sdk-runtime') continue + for (const section of runtimeDependencySections) { + for (const name of Object.keys(manifest[section] ?? {})) { + if (!experimentalNames.has(name)) continue + errors.push(`${manifest.name ?? dir}: ${section}.${name} must not reference an experimental package`) + } + } + } + return errors +} /** * Require the `workspace:` protocol for every reference to a workspace member. @@ -415,15 +453,25 @@ function checkWorkspaceProtocol(manifests: readonly WorkspaceManifest[]): string return errors } -const manifests = workspaceManifests() -const errors = [ - ...checkRepositoryVersion(), - ...manifests.flatMap(checkWorkspace), - ...checkWorkspaceProtocol(manifests), - ...checkHierarchyShape(), - ...collectProjectReferenceFaceViolations(root), -] -if (errors.length > 0) { - console.error(errors.join('\n')) - process.exitCode = 1 +/** Run the repository constraint gate. */ +export function main(): void { + const manifests = workspaceManifests() + const dependencyManifests = [ + ...manifests, + { dir: 'python/sdk-runtime', manifest: readJson(join(root, 'python/sdk-runtime/package.json')) }, + ] + const errors = [ + ...checkRepositoryVersion(), + ...manifests.flatMap(checkWorkspace), + ...checkWorkspaceProtocol(manifests), + ...checkExperimentalDependencyIsolation(dependencyManifests), + ...checkHierarchyShape(), + ...collectProjectReferenceFaceViolations(root), + ] + if (errors.length > 0) { + console.error(errors.join('\n')) + process.exitCode = 1 + } } + +if (process.argv[1] !== undefined && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) main() diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 2e5a193bd1..60acbf17dd 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -97,6 +97,7 @@ export const SERVICE_PAGE: Record = { systemPrompt: 'system-prompt.md', jobs: 'jobs.md', sessionTelemetry: 'session-telemetry.md', + teams: 'team.md', tokenMeter: 'token-meter.md', toolResultPruner: 'compaction.md', tools: 'tools.md', @@ -423,6 +424,18 @@ export const LINK_MAP: Readonly> = { JobSnapshot: 'jobs.md', JobStart: 'jobs.md', JobsChangedListener: 'jobs.md', + CreateTeamTaskRequest: 'team.md', + SendTeamMessageRequest: 'team.md', + SendTeamMessageResult: 'team.md', + SpawnTeammateRequest: 'team.md', + SpawnTeammateResult: 'team.md', + TeamId: 'team.md', + TeamMemberView: 'team.md', + TeamMembership: 'team.md', + TeamTaskId: 'team.md', + TeamTaskView: 'team.md', + TeamWaitResult: 'team.md', + UpdateTeamTaskRequest: 'team.md', TokenMeasurement: 'token-meter.md', CodeDispatchLog: 'tools.md', PostToolDecision: 'tools.md', diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 0ee6ae5a13..1cd41fe3de 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -67,6 +67,7 @@ const GROUP_ORDER = [ 'core', 'typert', 'goal', + 'experimental', 'process', 'bash', 'pty', @@ -469,6 +470,14 @@ const SERVICE_ROLES: ServiceRole[] = [ consumers: ['tool-subagent', 'tool-subagent-control', 'tool-ralph'], note: 'Providers implement transports; the service also owns optional Activation-based continuation orchestration, tool-subagent selects one-shot or continuable delegation, tool-subagent-control delivers follow-ups, and tool-ralph requires one fresh structured-output route.', }, + { + key: 'teams', + pkg: 'team', + title: 'Agent Teams coordination domain', + mode: 'core', + consumers: ['tool-team'], + note: 'Owns the implicit-root roster, durable peer mailbox, shared task DAG, and continuable-child lifecycle; tool-team contributes the scoped model policy and controls.', + }, { key: 'jobs', pkg: 'jobs', diff --git a/scripts/gen-persistence-catalog.ts b/scripts/gen-persistence-catalog.ts index 38100fd531..4166d1b07d 100644 --- a/scripts/gen-persistence-catalog.ts +++ b/scripts/gen-persistence-catalog.ts @@ -52,6 +52,11 @@ const LINK_MAP: Record = { SessionTitleModelProvenance: 'session-title.md', SessionTitleProviderId: 'session-title.md', SessionTitleSource: 'session-title.md', + TeamId: 'team.md', + TeamMemberSnapshot: 'team.md', + TeamMessageId: 'team.md', + TeamMessageSnapshot: 'team.md', + TeamTaskSnapshot: 'team.md', } /** One log event, extracted from a `SessionEventMap` declaration. */ diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index 005eb4aef9..2f1abb810d 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -57,6 +57,8 @@ import * as ToolLsp from '@deepseek-ai/dsh-tool-lsp' import * as ToolSkill from '@deepseek-ai/dsh-tool-skill' import * as ToolSessionQuery from '@deepseek-ai/dsh-tool-session-query' import * as ToolTasks from '@deepseek-ai/dsh-tool-jobs' +import type TeamService from '@deepseek-ai/dsh-team' +import * as ToolTeam from '@deepseek-ai/dsh-tool-team' import * as ToolTodo from '@deepseek-ai/dsh-tool-todo' import * as ToolSubagent from '@deepseek-ai/dsh-tool-subagent' import * as ToolWeb from '@deepseek-ai/dsh-tool-web' @@ -72,6 +74,7 @@ class CatalogAttachmentStore extends AttachmentStore { maxImagesPerMessage: 1, maxMessageImageBytes: 1, maxImagePixels: 1, + maxImageDimension: 1, mediaTypes: Object.freeze(['image/png'] as const), }) @@ -506,6 +509,44 @@ const TOOL_PACKAGES: ToolPackage[] = [ note: 'The kind-agnostic background-job controller: background bash commands, PTY sends, and subagents are read, listed, and killed through the same three tools. Loading the plugin attaches the controller that arms producers\' `ctx.jobs.start()`.', }, + { + pkg: '@deepseek-ai/dsh-tool-team', + dir: 'tool-team', + source: 'packages/experimental/tool-team/src/index.ts', + requires: ['ctx.tools', 'ctx.systemPrompt', 'ctx.teams', 'an exact live Team member Agent'], + writes: ['tool/call', 'team/member', 'team/message/queued', 'team/message/delivered', 'team/task', 'tool/result'], + async mount(ctx) { + await ctx.plugin(AgentRegistry) + await ctx.plugin(SessionStore) + const session = ctx.sessions.create(SessionId('tool-catalog-team-lead')) + let agent!: Agent + const membership = { + get root() { return agent }, + id: session.id, + role: 'lead' as const, + name: 'lead', + } + ctx.provide('teams', { + tryMembership: (candidate: Agent) => candidate === agent ? membership : undefined, + membership: () => membership, + } as unknown as TeamService) + await ctx.plugin(Object.assign((inner: Context) => { + agent = { + id: session.id, + session, + options: {}, + status: 'idle', + } as unknown as Agent + Object.assign(agent, { ctx: createScope(inner, agent).ctx }) + inner.agents.register(agent) + }, { inject: ['tools', 'systemPrompt', 'agents', 'teams'] })) + await ctx.plugin(ToolTeam) + catalogChildScopes.set(ctx, agent) + }, + scope: ctx => catalogChildScopes.get(ctx) as Agent, + note: + 'All ten tools are scoped to implicit Team Leads and durable teammates. The shipped dsh-base bundle keeps the package disabled; the documented Agent Teams profile patch enables it while disabling the legacy continuable-child control names.', + }, { pkg: '@deepseek-ai/dsh-tool-todo', dir: 'tool-todo', diff --git a/scripts/publish-npm-baseline.ts b/scripts/publish-npm-baseline.ts index d69923910f..2d5d391fe9 100644 --- a/scripts/publish-npm-baseline.ts +++ b/scripts/publish-npm-baseline.ts @@ -24,7 +24,7 @@ const DEFAULT_REGISTRY = 'https://registry.npm.harnessment.com' const DEFAULT_OUTPUT_DIRECTORY = '.artifacts/npm-baseline' const PACKAGE_PATTERNS = [ 'vendor/*/package.json', - 'packages/*/*/package.json', + 'packages/!(experimental)/*/package.json', 'apps/*/package.json', ] as const const DEPENDENCY_SECTIONS = [ diff --git a/scripts/release/families.spec.ts b/scripts/release/families.spec.ts index 66c3daf83f..22eb0b064a 100644 --- a/scripts/release/families.spec.ts +++ b/scripts/release/families.spec.ts @@ -1,5 +1,6 @@ /** Release family discovery, publish order, tag naming, and the bump judgements. */ +import { resolve } from 'node:path' import { describe, expect, it } from 'vitest' import { releaseFamily, type ReleaseMember } from './families.ts' import { compareVersions, nextVendorVersion, reachesPayload } from './bump.ts' @@ -16,6 +17,13 @@ function member(directory: string, name: string, manifest: Record { + it('excludes private experimental packages from the dsh release', () => { + const members = releaseFamily('dsh').members(resolve(import.meta.dirname, '../..')) + + expect(members.some(member => member.directory.startsWith('packages/experimental/'))).toBe(false) + expect(members.map(member => member.name)).not.toContain('@deepseek-ai/dsh-team') + }) + it('names one tag for the whole dsh family and one per vendored package', () => { const dsh = releaseFamily('dsh') const vendor = releaseFamily('vendor') diff --git a/scripts/release/families.ts b/scripts/release/families.ts index f43939f17d..7ce5566831 100644 --- a/scripts/release/families.ts +++ b/scripts/release/families.ts @@ -305,10 +305,10 @@ export abstract class ReleaseFamily { abstract readonly installedEntry: InstalledEntry | undefined } -/** `packages/*` and `apps/*`: one shared version across the whole family. */ +/** Release packages and apps: one shared version across the whole family. */ class DshFamily extends ReleaseFamily { readonly id = 'dsh' - readonly patterns = ['packages/*/*/package.json', 'apps/*/package.json'] as const + readonly patterns = ['packages/!(experimental)/*/package.json', 'apps/*/package.json'] as const readonly tagPrefix = 'dsh-v' /** diff --git a/scripts/snapshots/translation-prompt-v4/request-response.expected.json b/scripts/snapshots/translation-prompt-v4/request-response.expected.json index 286c07b62a..ddffa0c246 100644 --- a/scripts/snapshots/translation-prompt-v4/request-response.expected.json +++ b/scripts/snapshots/translation-prompt-v4/request-response.expected.json @@ -12,7 +12,7 @@ }, { "role": "assistant", - "content": "# DeepSeek Harness\n\n[English](README.md) | 中文\n\nDeepSeek Harness(`dsh`)是由 [DeepSeek AI](https://deepseek.com) 开发的开源 agent harness(智能体框架)。\n\n它采用**一切皆插件**的架构,并由 [Cordis](https://github.com/cordiverse/cordis) 驱动,其设计参见论文 [_A Programming Paradigm for Spatiotemporal Composability_](https://github.com/cordiverse/paper)。\n\n## 开发者预览\n\nDeepSeek Harness 目前处于 _开发者预览_ 阶段,正在快速迭代。**未来将出现破坏兼容性的变更。**\n\n## 运行\n\n### 通过 `npm` 运行\n\n安装 `Node.js`,然后运行:\n\n```sh\nnpx @deepseek-ai/dsh web\n```\n\n该命令会启动 Web UI,默认地址为 `http://127.0.0.1:3080`。详见 [Web UI 指南](docs/user/guide/index.md)。\n\n### 从源码运行\n\n如需从仓库源码运行:\n\n```sh\ngit clone https://github.com/deepseek-ai/deepseek-harness.git\ncd deepseek-harness\npnpm install\npnpm run build\npnpm dsh web\n```\n\n## 社区与支持\n\n- 欢迎通过 [GitHub Discussions](https://github.com/deepseek-ai/deepseek-harness/discussions) 提交反馈或 bug 报告。\n- 为你的插件仓库添加 [`dsh-plugin`](https://github.com/topics/dsh-plugin) 话题,便于被发现。\n- 欢迎加入 DeepSeek Harness 企微群:扫码添加企微小助手并填写入群问卷,完成后小助手会邀请你入群。\n\n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
    企微小助手入群问卷微信公众号
    \"DeepSeek\"DeepSeek\"DeepSeek
    \n\n## 参与贡献\n\n参见 [CONTRIBUTING.md](CONTRIBUTING.md)。\n\n## 开发\n\n请先阅读[开发指南](docs/development.md)与[架构文档](docs/architecture.md)。\n\n面向 agent:请遵循 [AGENTS.md](AGENTS.md)。\n\n## 许可证\n\n[MIT](LICENSE)\n\n第三方依赖及其许可证见 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md)。\n" + "content": "# DeepSeek Harness\n\n[English](README.md) | 中文\n\nDeepSeek Harness(`dsh`)是由 [DeepSeek AI](https://deepseek.com) 开发的开源 agent harness(智能体框架)。\n\n它采用**一切皆插件**的架构,并由 [Cordis](https://github.com/cordiverse/cordis) 驱动,其设计参见论文 [_A Programming Paradigm for Spatiotemporal Composability_](https://github.com/cordiverse/paper)。\n\n## 开发者预览\n\nDeepSeek Harness 目前处于 _开发者预览_ 阶段,正在快速迭代。**未来将出现破坏兼容性的变更。**\n\n## 运行\n\n### 通过 `npm` 运行\n\n安装 `Node.js`,然后运行:\n\n```sh\nnpx @deepseek-ai/dsh web\n```\n\n该命令会启动 Web UI,默认地址为 `http://127.0.0.1:3080`。详见 [Web UI 指南](docs/user/guide/index.md)。\n\n### 从源码运行\n\n如需从仓库源码运行:\n\n```sh\ngit clone https://github.com/deepseek-ai/deepseek-harness.git\ncd deepseek-harness\npnpm install\npnpm run build\npnpm dsh web\n```\n\n## 社区与支持\n\n- 欢迎通过 [GitHub Discussions](https://github.com/deepseek-ai/deepseek-harness/discussions) 提交反馈或 bug 报告。\n- 为你的插件仓库添加 [`dsh-plugin`](https://github.com/topics/dsh-plugin) 话题,便于被发现。\n- 欢迎加入 DeepSeek Harness 企微群:扫码添加企微小助手并填写入群问卷,完成后小助手会邀请你入群。\n\n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
    企微小助手入群问卷微信公众号
    \"DeepSeek\"DeepSeek\"DeepSeek
    \n\n## 参与贡献\n\n参见 [CONTRIBUTING.md](CONTRIBUTING.md)。\n\n## 开发\n\n请先阅读[开发指南](docs/development.md)与[架构文档](docs/architecture.md)。\n\n面向 agent:请遵循 [AGENTS.md](AGENTS.md)。\n\n## 许可证\n\n[MIT](LICENSE)\n\n第三方依赖及其许可证见 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md)。\n" }, { "role": "user", diff --git a/scripts/test-invariants.ts b/scripts/test-invariants.ts index a9f4cf335b..a3b96a90a3 100644 --- a/scripts/test-invariants.ts +++ b/scripts/test-invariants.ts @@ -117,6 +117,7 @@ class TestAttachmentStore extends AttachmentStore { maxImagesPerMessage: 1, maxMessageImageBytes: 1, maxImagePixels: 1, + maxImageDimension: 1, mediaTypes: ['image/png'], } diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 95a573541c..f603244a6f 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -301,6 +301,26 @@ "symbol": "ScheduleView", "source": "packages/schedule/schedule/src/types.ts" }, + { + "doc": "docs/subsystems/team.md", + "symbol": "TeamMemberSnapshot", + "source": "packages/experimental/team/src/types.ts" + }, + { + "doc": "docs/subsystems/team.md", + "symbol": "TeamMessageSnapshot", + "source": "packages/experimental/team/src/types.ts" + }, + { + "doc": "docs/subsystems/team.md", + "symbol": "TeamMessageSource", + "source": "packages/experimental/team/src/types.ts" + }, + { + "doc": "docs/subsystems/team.md", + "symbol": "TeamTaskSnapshot", + "source": "packages/experimental/team/src/types.ts" + }, { "doc": "docs/subsystems/commands.md", "symbol": "CommandInputDescriptor", diff --git a/tsconfig.base.json b/tsconfig.base.json index 1dd8dab3ad..189c38bb4c 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -117,6 +117,7 @@ "./packages/preset/*/src/invariant.ts", "./packages/subagent/*/src/invariant.ts", "./packages/jobs/*/src/invariant.ts", + "./packages/experimental/*/src/invariant.ts", "./packages/workflow/*/src/invariant.ts", "./packages/web/*/src/invariant.ts", "./packages/attachment/*/src/invariant.ts", @@ -247,6 +248,7 @@ "./packages/preset/*/src", "./packages/subagent/*/src", "./packages/jobs/*/src", + "./packages/experimental/*/src", "./packages/workflow/*/src", "./packages/web/*/src", "./packages/attachment/*/src", diff --git a/tsconfig.host.json b/tsconfig.host.json index a132525afc..0986b4df59 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -266,6 +266,8 @@ { "path": "./packages/jobs/jobs" }, { "path": "./packages/jobs/jobs-local" }, { "path": "./packages/jobs/tool-jobs" }, + { "path": "./packages/experimental/team" }, + { "path": "./packages/experimental/tool-team" }, { "path": "./packages/workflow/workflow" }, { "path": "./packages/workflow/workflow-worker-thread" }, { "path": "./packages/workflow/tool-workflow" },