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/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/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/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/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 b55975a825..e93c79123f 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: 09cf0810d6ec46abe3ad5a9bbf1fc60cf28d5be1 -config-catalog.zh.md: f05f54f600c8a75ce0aeed93926129b46396e73b +config-catalog.md: d608b477c6dc67c79141a2c8cb32fec3540e281f +config-catalog.zh.md: 3ee1593ffa27e8f184865a405527798d96e06818 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 09cf0810d6..d608b477c6 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2246,6 +2246,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` @@ -2705,6 +2729,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 f05f54f600..3ee1593ffa 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -2248,6 +2248,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` @@ -2707,6 +2732,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 3726766a4a..2048c0e0ba 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: 566278f666596a897de1cc0b2eb362e0ff67533b -module-graph.zh.md: 6c3e8f89c39dc96a83a19860a32aa14a8e13bc97 +module-graph.md: 5bd6dcad687dfff82c7ac7f3f5774a39171cdec9 +module-graph.zh.md: 5265188a865319dbe3cbb05ca6cee804f63b7650 diff --git a/docs/module-graph.md b/docs/module-graph.md index 566278f666..5bd6dcad68 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"] @@ -1004,6 +1008,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 @@ -1109,6 +1120,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 @@ -1567,6 +1584,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) | @@ -1580,6 +1598,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 6c3e8f89c3..5265188a86 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"] @@ -1006,6 +1010,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 @@ -1111,6 +1122,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 @@ -1569,6 +1586,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) | @@ -1582,6 +1600,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/subagent.i18n.yaml b/docs/subsystems/subagent.i18n.yaml index b99b907ff9..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: 00fa8faf2f6609d469f87f10aa7cbaa6e30b7eee -subagent.zh.md: 8dc578d1a3b4456edac4888bdc6dd670b59f5cb8 +subagent.md: 07ed5ce4a1da9fd68678ec14949d9ba249d7805f +subagent.zh.md: e1b3ab465841ab16d2dc81bb12d8c49663533aa7 diff --git a/docs/subsystems/subagent.md b/docs/subsystems/subagent.md index 00fa8faf2f..07ed5ce4a1 100644 --- a/docs/subsystems/subagent.md +++ b/docs/subsystems/subagent.md @@ -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 8dc578d1a3..e1b3ab4658 100644 --- a/docs/subsystems/subagent.zh.md +++ b/docs/subsystems/subagent.zh.md @@ -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 11958739dc..4d6bb517f6 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: 02a06788aef11af3dc95afa3341b82fad1e5e537 -tool-catalog.zh.md: cde45d76d01160d5975dc7494542de44ed3ce1f4 +tool-catalog.md: d3c87514372eb73f14ea511e854cb673df2ffb56 +tool-catalog.zh.md: 5dd31ff14e55a2bc4d898ddd4defeb0e46fb758d diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 02a06788ae..d3c8751437 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -37,6 +37,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. | @@ -1707,6 +1708,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 cde45d76d0..5dd31ff14e 100644 --- a/docs/tool-catalog.zh.md +++ b/docs/tool-catalog.zh.md @@ -39,6 +39,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 在更换后端时保持稳定。 | @@ -1713,6 +1714,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/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 8b1d8875ba..23f7768686 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:*", @@ -104,6 +105,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/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 b4d5707283..ce2007c34b 100644 --- a/packages/core/tools/tests/gen-tool-catalog.spec.ts +++ b/packages/core/tools/tests/gen-tool-catalog.spec.ts @@ -25,8 +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', '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', '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 5e27a13bdf..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}', @@ -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\'>;', @@ -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/subagent/subagent/README.i18n.yaml b/packages/subagent/subagent/README.i18n.yaml index 7cfdbec38b..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: 9d60a3bd776e867ddf2f8316266970faa6454c9a -README.zh.md: 241e5e8715e7a2e7d4ae8b7385c1ee358a90e2b0 +README.md: 25657a6a7dd6727bb5177c828241ec82192f20d8 +README.zh.md: 2f36f950e0a4b0bb86d97633dd0e841fd331ad50 diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index 9d60a3bd77..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 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()`. | diff --git a/packages/subagent/subagent/README.zh.md b/packages/subagent/subagent/README.zh.md index 241e5e8715..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`。静默投递会注入不唤醒的 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()`。 | diff --git a/packages/subagent/subagent/src/continuation.ts b/packages/subagent/subagent/src/continuation.ts index 74e1bf4b0b..652a3ba6c8 100644 --- a/packages/subagent/subagent/src/continuation.ts +++ b/packages/subagent/subagent/src/continuation.ts @@ -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 @@ -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 { 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 f69c328873..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() 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/pnpm-lock.yaml b/pnpm-lock.yaml index 465df6b10a..0dfdd0e1c7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -643,6 +643,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 @@ -712,6 +715,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 @@ -3920,6 +3926,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 41d0281a7d..4bb6f37b18 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -58,6 +58,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' @@ -521,6 +523,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/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 1e1ccbc29f..b7c1ee5958 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -267,6 +267,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" },