diff --git a/.agents/notes/implemented/architecture/2026-08-22-projection-fold-context-seed-boundary.md b/.agents/notes/implemented/architecture/2026-08-22-projection-fold-context-seed-boundary.md new file mode 100644 index 0000000000..dda4a47cbe --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-22-projection-fold-context-seed-boundary.md @@ -0,0 +1,52 @@ +# Agent Note: Projection units fold against a header-derived context + +Status: implemented + +## Problem + +A forked subagent's session log opens with a verbatim copy of its parent's log. `SessionHeader.seedLength` records that inherited prefix, and consumers that must attribute work to the session that actually did it already slice past it — `dsh-agent`'s inbox and `dsh-schedule`'s domain both do. + +Projection units could not. `ProjectionDefinition.apply(state, event)` received only the event stream, so every unit folded the inherited prefix as if the child had produced it. For `openRouterCost` that made a child's value report its parent's spend: measured on a real fork with `seedLength` 109342, the child folded to $2.0401 over 575 priced steps while its own work was $0.1073 over 25. Summing a parent with its subagents — what the composer dock does to show delegated spend — therefore counted the parent once per forked child, inflating an $8.59 session to $12.79. + +The log alone cannot supply the boundary. `session/end-seed` marks a constructor seed, but a resumed ordinary session appends one too, so the marker cannot distinguish a fork boundary from a resume boundary: the parent session in the measurement above carries four of them. Only the durable header separates the two. + +## Decision + +`ProjectionDefinition.apply` takes a third parameter, `ProjectionFoldContext` — per-session facts derived from the session's durable header, currently just `seedLength`. The registry supplies it on every call, so a unit reads session facts without ever touching a `Session`: + +- `snapshot`, the eager drive, and the lazy cell build derive it from `session.header` through the exported `foldContextOf`. +- `restore` takes it as a required fourth parameter, because a detached fold has no Session to derive it from. Its four callers each already hold the stored header: the projection cache's cold read (`tail.meta`, `whole.meta`), api-proxy's detached history baseline, and the subagent catalog's identity probe. + +The parameter is required in the interface and unused by 13 of the 14 shipped units: a two-parameter implementation satisfies a three-parameter signature, so no other unit changed. + +`openRouterCost` skips events below the boundary and bumps `stateVersion` to 4. It still reads `request/context` from the inherited prefix — those records carry no cost, and the route they establish is what attributes the child's first chunk-only step, which would otherwise fold unpriced. + +`CostDock` sums the session plus its subagent subtree over the session list's `parentId`/`origin` rows. That sum is only sound because each value now describes its own session's work. + +## What the context does not decide + +`ProjectionFoldContext` is not a general escape hatch to session state. It carries header facts a pure fold legitimately needs and nothing derived from mutable session state, which would break the synchronous-fold and plain-JSON-state guarantees the persisted cache depends on. A unit describing the whole conversation rather than the session's own work — context pressure, the visible transcript — correctly ignores `seedLength` and folds the inherited prefix like any other event. + +## Alternatives considered + +**Reset the accumulated state on `session/end-seed`.** No framework change, and the marker is already durable. Rejected because a resumed ordinary session appends the same marker: resetting on it would zero a session's spend on every reopen. The marker cannot name which boundary it is. + +**Track a second accumulator for "spend since the last marker" and let the client pick per `origin`.** Contained entirely in this plugin. Rejected because it is wrong for a resumed continuable subagent, whose own earlier segments fall before the last marker and would silently vanish from its total — trading a 20× over-count for a quiet under-count. + +**Deduplicate by the `steps` map's `${turn}:${step}` keys across the lineage.** The inherited steps carry the parent's own keys, so a union would drop them. Rejected because sibling forks both continue from the same fork point and mint colliding keys for their own first steps, and a child seeded with nothing numbers from 0 exactly like its parent. + +**Have the client stop adding forked children entirely.** Never inflated. Rejected because delegated spend is the figure the dock exists to show; hiding it answers the wrong question. + +**Give `apply` the whole `Session`.** Simpler signature change. Rejected because it hands every unit a mutable, non-JSON object and an appendable log, inviting folds that read state outside the event they were given — the exact discipline the unit contract exists to enforce. + +## Consequences + +Every unit's value now means "this session's own work" or "the whole conversation" by explicit choice rather than by accident. The cost is one more parameter on the seam's central function and a required argument on `restore`, which is what makes the omission impossible to reintroduce silently: a caller with no header cannot compile. + +The `stateVersion` bump discards persisted `openRouterCost` rows. Sessions whose rows are dropped and which are never reopened read as absent rather than refolded, because the cold read serves the cache and never folds a cold log — so historical subagent spend stays missing until something opens those sessions. `dsh-client-ui-openrouter-usage` records that gap under its known limitations; a cold-fold aggregate is a separate decision. + +`tokenUsage` and `sessionStats` still fold a forked child's inherited prefix. That is now a deliberate, visible choice rather than an invisible one: whether those figures should describe own work or inherited history is a question for their owners, and the context they need is already in hand. + +## Testing + +`packages/session/session-projection/tests/registry.spec.ts` proves the context reaches `apply` on all three drive paths — eager drive, lazy cell build after events flowed, and `restore` with a caller-supplied context — through a unit that counts only its own events. `packages/llm/openrouter-usage/tests/projection.spec.ts` builds a real fork through `ctx.sessions.create(undefined, { seed, meta: { seedLength } })` and asserts the child excludes the inherited prefix while the parent's figure is untouched, plus that a chunk-only child step still prices from a route recorded in that prefix. `packages/client/ui-openrouter-usage/tests/cost-dock.client.spec.tsx` covers the subtree walk: nested chains, ordinary forks excluded, a descendant with no projection value skipped, and `parentId` cycle termination. diff --git a/.agents/notes/implemented/feature/2026-08-22-cursor-external-agent-provider.i18n.yaml b/.agents/notes/implemented/feature/2026-08-22-cursor-external-agent-provider.i18n.yaml new file mode 100644 index 0000000000..e6e66e832c --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-22-cursor-external-agent-provider.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-22-cursor-external-agent-provider.md +2026-08-22-cursor-external-agent-provider.md: 1126ee883fecf56081ac44b00cb70a54f7a8cbe0 +2026-08-22-cursor-external-agent-provider.zh.md: 0565f29236c4319857444081d6e6f132f93064ee diff --git a/.agents/notes/implemented/feature/2026-08-22-cursor-external-agent-provider.md b/.agents/notes/implemented/feature/2026-08-22-cursor-external-agent-provider.md new file mode 100644 index 0000000000..1126ee883f --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-22-cursor-external-agent-provider.md @@ -0,0 +1,57 @@ +# Agent Note: Cursor joins the external-agent providers, and all three become visible + +Status: implemented + +English | [中文](2026-08-22-cursor-external-agent-provider.zh.md) + +## Problem + +The subagent seam already had two external product providers — `codex` over the Codex app-server protocol and `claude-code` over the official Claude Agent SDK — but Cursor had none, and neither existing provider was reachable by a model in a shipped composition: every Agent Preset carried its tool row with `disabled: true`, and the committed base bundle mounted neither provider. The capability existed and nothing could use it. + +Cursor is also the first of the three whose only headless prompt channel is a command-line argument. `cursor-agent --print` takes the task as a POSITIONAL argument, documents no `--` end-of-options separator, and reads no prompt from stdin. Codex sidesteps this entirely — its task travels inside JSON-RPC — and the Claude Code provider hands its prompt to an SDK. A Cursor provider has to decide what happens when model-authored text becomes argv. + +## Decision + +`@deepseek-ai/dsh-subagent-cursor` registers the fixed `cursor` provider over `cursor-agent --print --output-format stream-json`. It reuses the seam's out-of-process vocabulary (`NO_START_CAPABILITIES`, `resolveChildCwd`, `settleRunResult`, `subprocessRunHandle`) and owns only three product-specific things: the print-mode event decoder, the publication gate, and the argv admission rules. + +**Publication gates on `system`/`init`.** Print mode has no handshake, so there is no "remote session exists" moment to wait for. The CLI's first event announces its own chat id after it has resolved credentials and model, which is the earliest point that proves a usable child exists — the structural equivalent of Codex returning an ephemeral thread. A successful `result` arriving with no prior `init` fails startup rather than resolving: a run the caller was never handed has nowhere to put an answer. + +**Argv admission fails loud twice.** A task whose first character is `-` is rejected at admission, because the CLI would parse it as an option and nothing in the seam can escape it. A resolved Windows `.cmd` or `.bat` shim is rejected too: only `cmd.exe` can run one, and its command tail reparses — model-authored task text would become shell syntax. PATHEXT resolution prefers the `cursor-agent.exe` the native Windows installer provides, so the rejection names a fixable installation rather than blocking the platform. + +**Cancellation settles the result, the seam stops the process.** There is no reply channel, so no protocol interrupt exists. The run's abort signal goes to the spawn spec, where the subprocess seam owns the termination escalation, and the same abort is raced into both the startup and result awaits so a cancelled run settles as `aborted` immediately instead of waiting out the grace period. Stdin is closed right after spawn, so a prompt the CLI still tries to read fails fast rather than stalling an unattended child forever. + +**Only `result` completes a run.** The provider accepts `subtype: "success"` with `is_error: false` and a nonblank `result`. Print mode carries no machine-readable failure taxonomy, so every other ending is `error` and the provider produces neither `max-tokens` nor `refusal`. A malformed stdout line is a protocol failure, not something to skip — skipping it would hide a CLI version whose stream this contract cannot read. Unknown event kinds are ignored, because a newer CLI adding events must not break a contract that needs only three of them. + +**All three providers move onto the shipped host plane, and their tool rows turn on.** The base bundle mounts `codex`, `claude-code`, and `cursor` once each, so a Profile enables one by removing `disabled` from its preset tool row instead of mounting a duplicate provider. The `code`, `cordis`, and `standard` presets carry enabled rows. The `economy` preset keeps all three `disabled: true`: its stated purpose is not to reach for external paid agents by default, and that reason survives this change. + +Loading a provider starts no product process, so a deployment without a given CLI keeps an inert tool row whose call fails at call time. That is deliberate: the alternative — gating the row on a PATH probe at load — makes the model's roster depend on host state it cannot see, and turns a missing CLI into a silent absence instead of an answerable error. + +## Alternatives considered + +**Drive Cursor through the existing ACP provider.** `cursor-agent acp` speaks the Agent Client Protocol natively, so `dsh-subagent-acp` can drive it with configuration alone and no new package. Rejected as the primary path because it produces no `subagent_cursor` tool row, no product-specific config, and no product-specific stop-reason or failure mapping — Cursor would be reachable only by a deployment that hand-wrote an ACP row, which is the same invisibility this change exists to remove. The ACP path remains valid and is documented in the package README for a deployment that wants ACP's permission auto-answer policy or a longer-lived remote session. + +**Pass the task through `cmd.exe` on Windows, as the Claude Code provider does for its `.cmd` shim.** That provider quotes the resolved executable into an environment variable that cmd expands once, and its remaining arguments are fixed SDK flags with no cmd metacharacters. Rejected here because the trick does not generalize to the payload: a quoted value containing `"` breaks out of its quoting, and an executable path cannot contain `"` while a model-authored task certainly can. + +**Default `force` to true so a delegated child can actually edit.** Rejected: Cursor's own print-mode default only proposes changes, and the sibling providers are unattended-safe by the same logic — the Codex provider declines approvals and the Claude Code provider disables `AskUserQuestion`. A deployment that wants edits sets `force` explicitly, which is also where the decision is auditable. + +**Pass `CURSOR_API_KEY` as `--api-key`.** The CLI accepts it. Rejected because argv is world-readable in a process listing; the credential goes through the provider's `env` config, which the subprocess seam already treats as a deliberate opt-in past its credential scrub. + +**Add a `--stream-partial-output` delta path.** Rejected as unused surface: without it each `assistant` event is one complete message, which is exactly the seam's "last non-empty assistant message" selection rule with no accumulation state to own. + +**Probe the CLI at load and skip the tool row when absent.** Rejected as above — it makes the model-visible roster a function of invisible host state, and a missing CLI is better reported as a failed call than as a tool that was never there. + +## Consequences + +Three external product agents are now visible to the model in three of the four shipped presets, which is the point and also the cost: each is a tool row the model can choose, and a deployment without the corresponding CLI pays one failed call to learn that. The `economy` preset is unchanged in behavior. + +The committed base-bundle guard inverted. `packages/bundle/base/tests/base.spec.ts` previously asserted that the base layer mounts none of these providers and depends on none of them; it now asserts each is mounted exactly once and declared as a dependency. The old assertion encoded the opt-in-only policy this change replaces. + +Cursor delegation has no continuation. The CLI supports resuming a chat by id, and the provider deliberately does not: the seam's one-shot contract is one process, one run, one result, and a resumable Cursor chat would need durable descriptor fields no consumer asks for yet. + +## Testing + +`packages/subagent/subagent-cursor/tests/subagent-cursor.spec.ts` drives the real event stream through a fake subprocess handle: the init gate, chunk-split and blank-line framing, last-non-empty-message selection with non-text blocks dropped, every unusable terminal result, a result without an announced session, malformed stdout, stream error, end of stream, and close. Its lifecycle cases cover the fixed argv with and without `force`/`trust`, stdin closure, immediate cancellation settling as `aborted` with partial output, post-publication exit and protocol failures flattened through the diagnostic sink, pre-spawn abort, the four startup rollback paths, an aggregate when rollback itself fails, run isolation, and the registered plugin's config, resolved executable, and warning text. The argv admission rules are tested per platform so the suite pins both outcomes on every host. + +`packages/subagent/subagent-cursor/tests/loader-composition.e2e.ts` boots the public opt-in composition with an empty `PATH` and asserts the provider, the `subagent_cursor` schema, and the generic Job controls register while no run starts — loading must not probe or launch a CLI. + +`packages/bundle/base/tests/base.spec.ts` pins the inverted policy: each of the three providers is mounted once and declared once. diff --git a/.agents/notes/implemented/feature/2026-08-22-cursor-external-agent-provider.zh.md b/.agents/notes/implemented/feature/2026-08-22-cursor-external-agent-provider.zh.md new file mode 100644 index 0000000000..0565f29236 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-22-cursor-external-agent-provider.zh.md @@ -0,0 +1,57 @@ +# Agent Note:Cursor 加入外部 agent 提供方,并让三者同时可见 + +Status: implemented + +[English](2026-08-22-cursor-external-agent-provider.md) | 中文 + +## 问题 + +subagent seam 早已拥有两个外部产品提供方——基于 Codex app-server 协议的 `codex`,以及基于官方 Claude Agent SDK 的 `claude-code`——但 Cursor 一个都没有,而且这两个既有提供方在任何已发布的组装中都无法被模型触及:每个 Agent Preset 都把其工具行设为 `disabled: true`,已提交的 base bundle 也没有挂载任何一个。能力存在,却没有任何东西能用它。 + +Cursor 还是三者中唯一一个仅以命令行参数作为无头提示词通道的产品。`cursor-agent --print` 按**位置**接收任务,未记载 `--` 选项终止符,也不从标准输入读取提示词。Codex 完全绕开了这个问题——它的任务在 JSON-RPC 内部传输;Claude Code 提供方则把提示词交给 SDK。而 Cursor 提供方必须决定:当模型撰写的文本变成 argv 时会发生什么。 + +## 决策 + +`@deepseek-ai/dsh-subagent-cursor` 基于 `cursor-agent --print --output-format stream-json` 注册固定的 `cursor` 提供方。它复用 seam 的进程外词汇(`NO_START_CAPABILITIES`、`resolveChildCwd`、`settleRunResult`、`subprocessRunHandle`),只拥有三件与产品相关的事:print 模式事件解码器、发布闸门,以及 argv 准入规则。 + +**发布以 `system`/`init` 为闸门。** print 模式没有握手,因此不存在可等待的“远端会话已就绪”时刻。该 CLI 的首个事件会在它解析出凭证与模型之后公布其自有会话 ID,这是能证明存在可用子级的最早时点——在结构上等同于 Codex 返回临时线程。若成功的 `result` 在没有先行 `init` 的情况下到达,启动会失败而非返回结果:调用方从未拿到的运行没有地方安放答案。 + +**argv 准入两处显式失败。** 首字符为 `-` 的任务在准入阶段被拒绝,因为该 CLI 会将其解析为选项,而 seam 中没有任何手段可以转义它。解析到 Windows `.cmd` 或 `.bat` 包装脚本同样被拒绝:只有 `cmd.exe` 能运行它,而其命令尾部会被重新解析——模型撰写的任务文本会变成 shell 语法。PATHEXT 解析会优先选择原生 Windows 安装程序提供的 `cursor-agent.exe`,因此这项拒绝指向的是一个可修复的安装问题,而不是封禁该平台。 + +**取消负责结束结果,seam 负责停止进程。** 由于没有回复通道,也就不存在协议层中断。本次运行的中止信号被交给 spawn 规格,由子进程 seam 拥有逐级终止机制;同一个中止信号同时被并入启动等待与结果等待的竞态,因此被取消的运行会立即判为 `aborted`,而不必等完宽限期。标准输入在 spawn 后立即关闭,因此该 CLI 若仍尝试读取提示词,会快速失败,而不是让无人值守的子级永远停滞。 + +**只有 `result` 能让运行完成。** 提供方只接受 `subtype: "success"` 且 `is_error: false` 并带非空白 `result` 的事件。print 模式不携带可供程序判读的失败分类,因此其他任何结束都是 `error`,且该提供方既不产生 `max-tokens` 也不产生 `refusal`。格式错误的标准输出行属于协议失败,而不是可以跳过的东西——跳过它会掩盖某个本约定无法读取其事件流的 CLI 版本。未知事件类别会被忽略,因为更新版 CLI 新增事件不应破坏一个只需要其中三种事件的约定。 + +**三个提供方一并进入已发布的宿主平面,其工具行随之开启。** base bundle 各挂载 `codex`、`claude-code`、`cursor` 一次,因此 Profile 启用其中之一的方式是从其 preset 工具行中删除 `disabled`,而不是挂载重复的提供方。`code`、`cordis` 与 `standard` preset 携带已启用的行。`economy` preset 三者均保持 `disabled: true`:它被明确设定为默认不动用外部付费 agent,而这条理由在本次变更后依然成立。 + +加载提供方不会启动任何产品进程,因此缺少某个 CLI 的部署会保留一个惰性工具行,其调用会在调用时失败。这是有意为之:另一种做法——在加载时以 PATH 探测为工具行设闸——会让模型的工具清单取决于它看不见的宿主状态,并把“缺少 CLI”从一个可回答的错误变成一次无声的缺席。 + +## 考虑过的替代方案 + +**通过既有 ACP 提供方驱动 Cursor。** `cursor-agent acp` 原生讲 Agent Client Protocol,因此 `dsh-subagent-acp` 仅凭配置即可驱动它,无需新包。作为主路径被否决,因为它不产生 `subagent_cursor` 工具行、没有产品专属配置,也没有产品专属的停止原因与失败映射——Cursor 将只能被手写 ACP 行的部署触及,而这正是本次变更要消除的那种不可见性。ACP 路径依然有效,并已在包 README 中记录,供需要 ACP 权限自动应答策略或更长生命周期远端会话的部署使用。 + +**在 Windows 上让任务穿过 `cmd.exe`,如 Claude Code 提供方处理其 `.cmd` 包装脚本那样。** 那个提供方把解析出的可执行文件加引号放进环境变量,由 cmd 展开一次,其余参数是不含 cmd 元字符的固定 SDK 标志。此处被否决,因为该技巧无法推广到载荷本身:含 `"` 的加引号值会突破引号,而可执行文件路径不可能含 `"`,模型撰写的任务却完全可能含有它。 + +**把 `force` 默认设为 true,好让被委托的子级真能编辑。** 被否决:Cursor 自身的 print 模式默认只提出改动,而同族提供方按同一逻辑保持无人值守安全——Codex 提供方拒绝审批,Claude Code 提供方禁用 `AskUserQuestion`。需要编辑的部署显式设置 `force`,那也正是该决策可被审计的位置。 + +**把 `CURSOR_API_KEY` 作为 `--api-key` 传入。** 该 CLI 接受它。被否决,因为 argv 在进程列表中对所有人可读;凭证走提供方的 `env` 配置,子进程 seam 已把该配置视为越过其凭证清除的一次有意选择。 + +**新增 `--stream-partial-output` 增量路径。** 作为无人使用的surface被否决:不启用它时,每个 `assistant` 事件就是一条完整消息,这恰好就是 seam 的“最后一条非空助手消息”选择规则,且无需拥有任何累积状态。 + +**在加载时探测 CLI,缺失则跳过工具行。** 同上被否决——那会让模型可见的工具清单成为不可见宿主状态的函数,而缺少 CLI 更适合报告为一次失败的调用,而非一个从未存在过的工具。 + +## 后果 + +三个外部产品 agent 现在在四个已发布 preset 中的三个里对模型可见,这既是目的也是代价:每一个都是模型可以选择的工具行,而缺少对应 CLI 的部署要用一次失败调用来得知这一点。`economy` preset 的行为未变。 + +已提交的 base bundle 闸门发生反转。`packages/bundle/base/tests/base.spec.ts` 此前断言 base 层不挂载这些提供方、也不依赖它们;现在它断言每一个都被挂载恰好一次并被声明为依赖。旧断言编码的正是本次变更所替换的“仅可选启用”政策。 + +Cursor 委托没有续接。该 CLI 支持按 ID 恢复对话,而提供方有意不做:seam 的 one-shot 约定是一个进程、一次运行、一个结果,可恢复的 Cursor 对话需要目前没有任何消费方要求的持久化描述符字段。 + +## 测试 + +`packages/subagent/subagent-cursor/tests/subagent-cursor.spec.ts` 通过伪造的子进程句柄驱动真实事件流:init 闸门、分块与空行的帧解析、丢弃非文本块的最后一条非空消息选择、每一种不可用的终止结果、没有公布会话的结果、格式错误的标准输出、流失败、流结束以及关闭。其生命周期用例覆盖带与不带 `force`/`trust` 的固定 argv、标准输入关闭、立即取消并携带部分输出判为 `aborted`、发布后退出与协议失败经诊断出口摊平、spawn 前中止、四条启动回滚路径、回滚自身失败时的聚合错误、运行间隔离,以及已注册插件的配置、解析出的可执行文件与告警文本。argv 准入规则按平台分别测试,因此该套件在任何宿主上都能钉住两种结果。 + +`packages/subagent/subagent-cursor/tests/loader-composition.e2e.ts` 以空 `PATH` 启动公开的可选组装,断言提供方、`subagent_cursor` schema 与通用 Job 控制工具均已注册且没有任何运行启动——加载不得探测或启动 CLI。 + +`packages/bundle/base/tests/base.spec.ts` 钉住反转后的政策:三个提供方各被挂载一次、各被声明一次。 diff --git a/.agents/notes/implemented/feature/2026-08-22-openrouter-balance-on-demand-refresh.md b/.agents/notes/implemented/feature/2026-08-22-openrouter-balance-on-demand-refresh.md new file mode 100644 index 0000000000..2043cac846 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-22-openrouter-balance-on-demand-refresh.md @@ -0,0 +1,35 @@ +# Agent Note: OpenRouter balance refreshes on click + +Status: implemented + +## Problem + +The account balance badge polled `openRouterUsage.snapshot()` on a 60s interval and rendered whatever the host had last fetched. A user who had just topped up, or who had just watched a long run spend credits, had no way to ask for the current figure: the badge looked like a button, was not one, and the only way to move the number was to wait out the interval. + +Widening the poll interval trades staleness for request volume in both directions and never answers "what is it right now". + +## Decision + +The gateway exposes a second Remote, `refresh()`, which fetches the account immediately and resolves with the resulting snapshot. Concurrent callers share one in-flight fetch, so repeated clicks cost one request, and the shared promise is released once it settles so a later click fetches again. A failed fetch resolves with the last-known snapshot rather than rejecting — the caller compares `updatedAt` if it wants to know whether the figure moved. + +`refresh()` re-reads only the account, not the model pricing table. Pricing is a six-hour table whose staleness the badge does not express, and folding it in would make a click cost a second request for a figure the click is not about. + +`BalanceBadge` calls `refresh` on click, marks itself `aria-busy` for the duration, and ignores further clicks until it settles. The poll continues underneath, so the badge stays current without clicks and a failed refresh is retried by the next tick. + +## Alternatives considered + +**Shorten the poll interval.** No new surface. Rejected because it multiplies requests for every user to serve the moment one user cares, and still cannot answer "right now". + +**Have the click re-read the cached snapshot.** A one-line client change. Rejected because the cached value is exactly what the badge already shows; the click would appear to do something and do nothing. + +**Push balance changes as a forwarded Remote event.** Instant and click-free. Rejected because the host learns of a change only by polling OpenRouter itself, so the event would carry the same staleness with more machinery. The badge's known limitations record that the figure is polled rather than pushed. + +**Refresh pricing alongside the balance.** Rejected as above: a click about the balance should not pay for a table the user cannot see. + +## Consequences + +The badge is now honestly interactive — it looks like a button and behaves like one — at the cost of one more method on the gateway's Remote surface and a user-triggerable outbound request. The in-flight share bounds that: a held-down click is one fetch, not a stream of them. + +## Testing + +`packages/llm/openrouter-usage/tests/loader-composition.spec.ts` moves the mocked `/credits` figures between calls and asserts `refresh()` serves the moved balance, that two concurrent refreshes issue one fetch, and that a later refresh fetches again. `packages/client/ui-openrouter-usage/tests/balance-badge.client.spec.tsx` covers the click path: the fetched figure renders, a click while outstanding is ignored, and a failed refresh keeps the last-known figure and clears the busy state. diff --git a/.agents/notes/implemented/feature/2026-08-23-maximum-preset-three-role-pipeline.i18n.yaml b/.agents/notes/implemented/feature/2026-08-23-maximum-preset-three-role-pipeline.i18n.yaml new file mode 100644 index 0000000000..d3032d9ad2 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-23-maximum-preset-three-role-pipeline.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-23-maximum-preset-three-role-pipeline.md +2026-08-23-maximum-preset-three-role-pipeline.md: 1ce18e6c9dc68cea0d6bd0f60a5f579466ffc4a2 +2026-08-23-maximum-preset-three-role-pipeline.zh.md: 8aa375c60a104b5184dbafe4fc330edb793b6a6c diff --git a/.agents/notes/implemented/feature/2026-08-23-maximum-preset-three-role-pipeline.md b/.agents/notes/implemented/feature/2026-08-23-maximum-preset-three-role-pipeline.md new file mode 100644 index 0000000000..1ce18e6c9d --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-23-maximum-preset-three-role-pipeline.md @@ -0,0 +1,65 @@ +# Agent Note: the `maximum` agent preset and its three-role delivery pipeline + +Status: implemented + +English | [中文](2026-08-23-maximum-preset-three-role-pipeline.zh.md) + +## Problem + +The shipped preset roster covered a capability RANGE — `minimal` two tools, `standard` the usual coding agent, `code` the same catalog as one TypeScript program, `cordis` plus self-modification, `economy` the same catalog spent carefully — and its upper end was still short of what the deployment can compose. Persistent terminals, language-server queries, session history, durable time and tmux context, scheduled reminders, MCP servers, and the standalone editor each ship as a plugin that no shipped preset mounts, so the only way to get them was to author a preset by hand and rediscover which plane each row belongs to. + +The roster also had no opinion about HOW a change gets made. Every preset offers `subagent` and `subagent_fork` as unshaped delegation: the model decides per call whether to delegate, what to say, and whether anyone checks the result. Specify, build, and verify are three different jobs with three different failure modes, and one agent doing all three in one context reviews its own work with its own assumptions in scope. + +## Decision + +`apps/cli/config/agent-presets/maximum/` is a sixth shipped preset (`order: 6`) that mounts every model-facing row this deployment can compose for one agent, and structures delegation as a fixed three-role pipeline. + +Its catalog is `standard`'s plus: the six `terminal_*` tools over an entry-local PTY realm, `lsp` over an entry-local `lsp` realm, the five `session_*` read tools, `schedule_*`, `str_replace_editor`, the seven `cordis_*` tools, and `run_code` beside the native schemas (`tool-presentation` at `mode: both`). `time-context` and `tmux-context` inject per-step durable context. The three external product agents stay enabled, as they now are in `standard`. + +The pipeline is three `tool-subagent` instances over the one `spawn` provider, distinguished only by `toolName` and the child `persona` the provider installs as a scoped section shadowing `deployment:persona`: + +| Tool | Role | Reply sections | +|---|---|---| +| `subagent_architect` | reads the repository and specifies the change | GOAL / CONTEXT / PLAN / ACCEPTANCE / RISKS | +| `subagent_implementer` | makes the repository satisfy that specification | CHANGES / VERIFICATION / DEVIATIONS | +| `subagent_reviewer` | verifies the result against it and returns a verdict | VERDICT / EVIDENCE / DEFECTS | + +All three are `backgroundMode: one-shot` — a stage's reply is the next stage's input, so the default must be the foreground call that returns it — and `maxDepth: 1`, which makes the roles leaves and keeps one pass's agent count equal to the stages run. The preset persona owns sequencing: architect, implementer, reviewer, with a FAIL verdict returning to the implementer and a third failed review going to the user instead of a fourth round. A `three-role-delivery-pipeline` skill ships in the preset's own `skills/` directory with the handoff rules, since a role child cannot see the parent's conversation and everything it needs must be pasted into its prompt. + +The preset's `skill-filesystem` scans two custom roots: its own `skills/`, and the `cordis` preset's, so composition authoring is documented for the `cordis_*` tools it also carries. A copy landing beside no `cordis` directory scans a missing path, which is valid empty state for that provider. + +### Rows that stay off, and why + +Two rows ship `disabled` because what they need is a machine fact, not a deployment fact, and each carries the worked configuration a copy fills in; a third is absent because its name is already taken: + +- **`lsp-stdio`** resolves every configured executable AT LOAD and rolls back every provider when one is missing, so enabling it in a shipped preset would fail the mount on any machine without that language server. The `lsp` service and `tool-lsp` are unconditional instead; without a provider the tool stays in the catalog and answers the structured `LSP_UNAVAILABLE`. +- **`mcp-client`** binds one instance to one server — a command to spawn or a URL to reach. +- **`tool-bash-persistent`** is absent rather than disabled: it registers under the name `bash`, which this preset's `tool-bash` already owns. `tool-terminal` supplies long-lived sessions under names that do not collide. + +`web_fetch` stays off because the shipped host mounts no fetch provider; that provider defers SSRF protection and the model would choose the request target. Full-text session search stays off because the host mounts `session-query-sqlite` with `openAt: never` — `session_search` and `session_event_search` answer `SESSION_QUERY_SEARCH_DISABLED` while the three read and trace tools work, and changing that is a host patch, not a preset row. + +## Alternatives considered + +**Enforce the roles with `toolFilter` instead of personas.** A read-only architect and reviewer are exactly what `toolFilter` is for, and a persona cannot enforce anything. It is not usable from a shipped preset: the filter names GLOBAL tool names and `tools.restrict()` throws on an unknown one, while the shell tool is `bash` off Windows and `pwsh` on it — one list would be a startup failure on one platform. A deny list over the write tools would also be theater while the shell remains, since a child can write through it. The preset states the restriction in each role persona and the composition comment names the filter as the enforcement a deployment adds for its own platform. + +**Drive the pipeline from a fixed `workflow` script.** `tool-workflow` runs deterministic orchestration, which is what a three-stage pipeline is. Its script comes from the model per call, not from the composition, and its own prompt guidance reserves it for explicit user requests for orchestration; `tool-ralph` takes a build-time script but fans out fresh identical rounds rather than distinct roles. Encoding the order in the persona keeps the stages in the transcript as ordinary tool calls the user can watch, interrupt, and read. + +**One role tool with a `role` argument.** Child policy is fixed per `tool-subagent` instance — another persona means another instance — so a single tool could only pass the role in its prompt, leaving the role's rules as text the parent must remember to repeat rather than a section the provider installs. + +**Add the capabilities to `standard` instead of a sixth preset.** `standard` is the default every new session gets, and this catalog is 53 tools plus a generated Code Mode SDK on every request. Keeping the maximal point separate leaves the default affordable and gives the roster an explicit capability-versus-cost axis, with `economy` at the other end. + +**Mount `schedule` on the host plane, as the Web overlay does.** Its `agent/created` listener is scope-filtered, so a preset-mounted instance installs only on root agents joined to that preset — the property that makes it a legal preset row. Host mounting would hand the tools to every session including `minimal`, which is what the preset boundary exists to prevent. + +## Consequences + +The roster now spans from two tools to every tool, and the preset file is the readable inventory of what this deployment can compose for one agent — including the three rows that need machine-local configuration, each with the worked example a copy edits. + +The cost is real and deliberate: every request carries the full native catalog and the Code Mode SDK, and one pipeline pass is three child agents with their own contexts. The composition header says so and names `standard` and `economy` as the cheaper points. + +The three roles are advisory, not enforced. A reviewer child can write files; only its persona tells it not to. Enforcement requires the platform-specific `toolFilter` a deployment adds to its own copy. + +`apps/cli` gains `dsh-tool-terminal` and `dsh-tool-session-query`: a preset's bare specifiers resolve from the host install's dependency surface, so a row no shipped composition mounted before needs the dependency added there. + +## Testing + +`apps/cli/tests/web-agent-presets.e2e.ts` mounts the preset on the real shipped Web composition and asserts the EXACT tool catalog — the assertion that catches a row registering into the wrong layer, which otherwise mounts cleanly and contributes nothing — plus that each role tool registers under its own name with foreground-by-default semantics. The Web authoring and selection goldens carry the new roster row. diff --git a/.agents/notes/implemented/feature/2026-08-23-maximum-preset-three-role-pipeline.zh.md b/.agents/notes/implemented/feature/2026-08-23-maximum-preset-three-role-pipeline.zh.md new file mode 100644 index 0000000000..8aa375c60a --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-23-maximum-preset-three-role-pipeline.zh.md @@ -0,0 +1,65 @@ +# Agent Note:`maximum` agent preset 及其三角色交付流水线 + +Status: implemented + +[English](2026-08-23-maximum-preset-three-role-pipeline.md) | 中文 + +## 问题 + +随附的 preset 名册已经覆盖了一条能力**区间**——`minimal` 两个工具、`standard` 常规编码 agent、`code` 把同一份目录变成一个 TypeScript 程序、`cordis` 再加自我修改、`economy` 精打细算地花同一份目录——但它的上限仍然够不到本部署真正能组合出的东西。持久终端、语言服务器查询、会话历史、durable 时间与 tmux 上下文、定时提醒、MCP 服务器、独立编辑器,各自都以插件形式随附,却没有任何随附 preset 挂载它们;要用上就只能手写一个 preset,并重新弄清每一行属于哪个平面。 + +名册对**改动如何发生**也没有主张。每个 preset 都提供 `subagent` 和 `subagent_fork` 作为无形状的委派:是否委派、说什么、有没有人复核结果,都由模型逐次决定。规格、实现、验证是三件不同的工作,各有不同的失败方式;一个 agent 在同一个上下文里全做完,就是带着自己的假设复核自己的产出。 + +## 决定 + +`apps/cli/config/agent-presets/maximum/` 是第六个随附 preset(`order: 6`):它为一个 agent 挂载本部署能组合的全部面向模型的行,并把委派固定成三角色流水线。 + +它的目录是 `standard` 再加上:entry 本地 PTY realm 上的六个 `terminal_*` 工具、entry 本地 `lsp` realm 上的 `lsp`、五个 `session_*` 读取工具、`schedule_*`、`str_replace_editor`、七个 `cordis_*` 工具,以及与原生 schema 并存的 `run_code`(`tool-presentation` 取 `mode: both`)。`time-context` 与 `tmux-context` 逐步注入 durable 上下文。三个外部产品 agent 保持启用,与 `standard` 现在的状态一致。 + +流水线是同一个 `spawn` 提供方上的三个 `tool-subagent` 实例,彼此只由 `toolName` 和子 `persona` 区分——提供方会把该 persona 作为遮蔽 `deployment:persona` 的 scoped 段落安装到子 agent 上: + +| 工具 | 角色 | 回复段落 | +|---|---|---| +| `subagent_architect` | 阅读仓库并写出改动规格 | GOAL / CONTEXT / PLAN / ACCEPTANCE / RISKS | +| `subagent_implementer` | 让仓库满足该规格 | CHANGES / VERIFICATION / DEVIATIONS | +| `subagent_reviewer` | 对照规格验证结果并给出结论 | VERDICT / EVIDENCE / DEFECTS | + +三者都是 `backgroundMode: one-shot`——上一阶段的回复就是下一阶段的输入,所以默认必须是能把结果带回来的前台调用——并且 `maxDepth: 1`,这让角色成为叶子,一次流水线的 agent 数量恰好等于跑过的阶段数。排序由 preset persona 负责:架构师、实现者、审查者;FAIL 结论退回实现者,第三次审查失败则交给用户,而不是开始第四轮。preset 自带的 `skills/` 目录里随附一个 `three-role-delivery-pipeline` skill 说明交接规则,因为角色子 agent 看不到父会话的对话,它需要的一切都必须粘贴进它的提示词。 + +preset 的 `skill-filesystem` 扫描两个自定义根目录:它自己的 `skills/`,以及 `cordis` preset 的——这样它同时携带的 `cordis_*` 工具就有了组合编写文档。若某份拷贝落在没有 `cordis` 目录的位置,扫描到的就是一个不存在的路径,对该提供方而言是合法的空状态。 + +### 哪些行保持关闭,以及为什么 + +有两行以 `disabled` 随附,因为它们需要的是机器事实而非部署事实,并且各自带着拷贝时填写的示例配置;还有一行则因为名字已被占用而根本没有出现: + +- **`lsp-stdio`** 会在**加载时**解析每个已配置的可执行文件,缺一个就回滚全部提供方;因此在随附 preset 里启用它,会让任何没装该语言服务器的机器挂载失败。作为替代,`lsp` 服务与 `tool-lsp` 无条件挂载;没有提供方时工具仍在目录中,并返回结构化的 `LSP_UNAVAILABLE`。 +- **`mcp-client`** 一个实例绑定一台服务器——要么是待启动的命令,要么是待访问的 URL。 +- **`tool-bash-persistent`** 是缺席而非禁用:它以 `bash` 之名注册,而这个名字已被本 preset 的 `tool-bash` 占用。长期存活的会话由 `tool-terminal` 以不冲突的名字提供。 + +`web_fetch` 保持关闭,因为随附宿主没有挂载 fetch 提供方:该提供方把 SSRF 防护留给别人,而请求目标将由模型选择。会话全文检索保持关闭,因为宿主挂载 `session-query-sqlite` 时取 `openAt: never`——`session_search` 与 `session_event_search` 会返回 `SESSION_QUERY_SEARCH_DISABLED`,三个读取与追踪工具照常可用;改变这一点是宿主 patch,不是 preset 里的一行。 + +## 备选方案 + +**用 `toolFilter` 而不是 persona 来强制角色。** 只读的架构师与审查者正是 `toolFilter` 的用途,而 persona 强制不了任何事。它在随附 preset 里不可用:过滤器写的是**全局**工具名,`tools.restrict()` 遇到未知名字会抛错,而 shell 工具在非 Windows 上是 `bash`、在 Windows 上是 `pwsh`——同一份名单必然在某个平台上启动失败。而且只要 shell 还在,针对写工具的 deny 名单也只是摆设,子 agent 照样能通过 shell 写入。preset 把限制写进各角色 persona,并在组合注释里指明:过滤器是部署方按自己平台补上的强制手段。 + +**用固定的 `workflow` 脚本驱动流水线。** `tool-workflow` 运行确定性编排,而三阶段流水线正是编排。但它的脚本由模型逐次撰写,而非来自组合;它自己的提示词指引也把它限定在用户明确要求编排时使用。`tool-ralph` 接受构建期固定脚本,但它扇出的是一轮轮相同的全新 agent,而不是彼此不同的角色。把顺序写进 persona,则让各阶段以普通工具调用的形式留在 transcript 里,用户可以看、可以打断、可以读。 + +**一个带 `role` 参数的角色工具。** 子 agent 策略是每个 `tool-subagent` 实例固定的——换一个 persona 就得换一个实例——所以单一工具只能把角色写在提示词里,角色规则于是退化成父 agent 必须记得每次重复的文本,而不是提供方安装的段落。 + +**把这些能力加进 `standard`,而不是新增第六个 preset。** `standard` 是每个新会话拿到的默认值,而这份目录是 53 个工具外加每次请求都要带上的 Code Mode SDK。把最大点单独放置,既让默认值保持可负担,也给名册一条明确的能力—成本轴,`economy` 在另一端。 + +**像 Web overlay 那样把 `schedule` 挂在宿主平面。** 它的 `agent/created` 监听是按 scope 过滤的,所以 preset 挂载的实例只会安装到加入该 preset 的根 agent 上——正是这一性质让它成为合法的 preset 行。挂在宿主上则会把这些工具发给包括 `minimal` 在内的每个会话,而这恰恰是 preset 边界要阻止的事。 + +## 影响 + +名册现在从两个工具一直延伸到全部工具,而这个 preset 文件本身就是一份可读的清单:本部署能为一个 agent 组合出什么,包括那三行需要机器本地配置的行,每行都带着拷贝后可直接编辑的示例。 + +代价真实且有意为之:每次请求都携带完整原生目录与 Code Mode SDK,一次流水线是三个各有上下文的子 agent。组合文件的头部注释说明了这一点,并指出 `standard` 与 `economy` 是更便宜的选择。 + +三个角色是约定而非强制。审查者子 agent 能写文件,只有它的 persona 让它别写。要强制,就需要部署方在自己的拷贝里加上与平台相符的 `toolFilter`。 + +`apps/cli` 新增了 `dsh-tool-terminal` 与 `dsh-tool-session-query` 依赖:preset 的裸 specifier 从宿主安装的依赖面解析,因此此前没有任何随附组合挂载过的行,需要在那里补上依赖。 + +## 测试 + +`apps/cli/tests/web-agent-presets.e2e.ts` 在真实的随附 Web 组合上挂载该 preset,断言**精确**的工具目录——这正是能抓住「某一行注册到了错误层级」的断言,否则它会干净地挂载却什么也不贡献——并断言每个角色工具以自己的名字注册、默认前台执行。Web 的编写与选择 golden 也带上了新的名册行。 diff --git a/.bench-searxng-50.mjs b/.bench-searxng-50.mjs new file mode 100644 index 0000000000..f1c1e2d2fe --- /dev/null +++ b/.bench-searxng-50.mjs @@ -0,0 +1,84 @@ +// 50 live searches directly through SearXngSearchProvider (bypassing the tool). +// Companion to .tool-searxng.mjs: that one goes through ctx.tools.execute(); this +// one calls provider.search() directly so the two surfaces can be compared. +import { SearXngSearchProvider } from '@deepseek-ai/dsh-web-search-searxng' + +const BASE = process.env.SEARXNG_BASE ?? 'http://192.168.31.240:8066' + +// Same 50 queries as the web_search exercise, in the same order. +const QUERIES = [ + 'DeepSeek R1 reasoning model', + 'latest SpaceX starship launch news', + 'производительность видеокарт RTX 5090 обзор', + 'Python async programming best practices 2025', + 'Olympic games 2028 Los Angeles schedule', + 'how does search engine ranking algorithm work', + 'текущая погода в Москве сегодня', + 'Node.js v24 release notes', + 'best programming languages to learn 2026', + 'A.I. regulation EU AI Act summary', + 'Elon Musk Tesla news today', + 'заголовок новости главные события недели', + 'Docker compose tutorial beginners', + 'climate change global warming impact report', + 'как приготовить борщ рецепт пошагово', + 'SQL injection prevention cheat sheet OWASP', + 'самый быстрый способ выучить английский язык', + 'React 19 features new hooks', + 'gold price today per ounce forecast', + 'рецепт шарлотки с яблоками в духовке', + 'nuclear fusion breakthrough energy company', + 'как похудеть без диет советы диетолога', + 'TypeScript 5 generics tutorial', + 'sustainable fashion eco brands list', + 'история создания интернета кратко кто придумал', + 'machine learning vs deep learning difference explained', + 'лучшие фильмы 2025 года рейтинг топ', + 'home solar panels cost savings 2026', + 'как настроить VPN на Windows 11 инструкция', + 'Quantum computing stocks investment 2026', + 'how to write a resume for software engineer tips', + 'лучшие книги по программированию читать', + 'electric car battery recycling process technology', + 'что такое криптовалюта простыми словами', + 'web development full stack roadmap 2026', + 'space tourism first commercial passengers mission', + 'польза и вред кофе для здоровья исследования', + 'Rust vs Go backend performance comparison', + 'как выбрать ноутбук для работы критерии', + 'artificial intelligence in healthcare applications examples', + 'pluto dwarf planet facts discovery', + 'банановые оладьи рецепт просто и быстро', + 'GitHub copilot pricing plans comparison', + 'великие открытия российской науки список', + 'open source LLM models to run locally 2026', + 'microservices architecture patterns distributed systems', + 'как экономить электроэнергию дома советы', + 'sleep quality improvement tips science backed', + 'космос последние новости запуски ракет', + 'postgresql vs mysql performance use cases', +] + +const provider = new SearXngSearchProvider({ baseURL: BASE }) + +let ok = 0 +let errors = 0 +let totalSources = 0 +const out = [] +for (let i = 0; i < QUERIES.length; i++) { + const q = QUERIES[i] + try { + const res = await provider.search({ query: q, maxResults: 10 }) + const n = res.sources.length + totalSources += n + ok++ + out.push({ i: i + 1, q, ok: true, sources: n, firstUrl: res.sources[0]?.url ?? null }) + } catch (e) { + errors++ + const code = e?.info?.code ?? null + const msg = String(e?.message ?? e) + out.push({ i: i + 1, q, ok: false, errorCode: code, error: msg.slice(0, 120) }) + } +} + +console.log(JSON.stringify({ ok, errors, totalSources, queries: QUERIES.length, results: out }, null, 2)) \ No newline at end of file diff --git a/.tool-searxng.mjs b/.tool-searxng.mjs new file mode 100644 index 0000000000..7051af2b8b --- /dev/null +++ b/.tool-searxng.mjs @@ -0,0 +1,45 @@ +// Native web_search tool run over the real SearXNG provider. +// Mirrors packages/web/tool-web/tests/integration.spec.ts: nothing bypasses +// ctx.tools.execute(). Boots the real seam (web-search-searxng), the model +// tool (tool-web), and the tool registry from the BUILT bundles. +import { Context } from '@deepseek-ai/cordis' +import { CallId } from '@deepseek-ai/dsh-llm' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRuntime from '@deepseek-ai/dsh-tools' +import WebRuntime from '@deepseek-ai/dsh-web' +import * as SearXng from '@deepseek-ai/dsh-web-search-searxng' +import * as ToolWeb from '@deepseek-ai/dsh-tool-web' + +const BASE = process.env.SEARXNG_BASE ?? 'http://192.168.31.240:8066' +const QUERY = process.env.SEARCH_QUERY ?? 'deepseek harness' + +const ctx = new Context() +await ctx.plugin(SystemPrompt) +await ctx.plugin(ToolRuntime) +await ctx.plugin(WebRuntime, { searchProvider: SearXng.SEARXNG_PROVIDER_ID }) +await ctx.plugin(SearXng, { baseURL: BASE }) +const fiber = await ctx.plugin(ToolWeb, { search: true, fetch: false }) + +const byName = new Map(ctx.tools.schemas().map((s) => [s.name, s])) +if (!byName.has('web_search')) { + console.error('web_search tool NOT registered') + process.exit(1) +} +console.error(`tool web_search registered; params=${JSON.stringify(byName.get('web_search').parameters)}`) + +const out = await ctx.tools.execute({ + callId: CallId('searxng-native-1'), + name: 'web_search', + arguments: { query: QUERY }, + signal: new AbortController().signal, +}) + +const text = out.content.map((b) => b.type === 'text' ? b.text : '').join('') +console.log(JSON.stringify({ + isError: out.isError, + errorCode: out.error?.info?.code ?? null, + contentLength: text.length, + preview: text.slice(0, 400), +}, null, 2)) + +await fiber.dispose() \ No newline at end of file diff --git a/apps/cli/config/agent-presets/code/agent.cordis.yml b/apps/cli/config/agent-presets/code/agent.cordis.yml index 407938e5c8..631d321a0d 100644 --- a/apps/cli/config/agent-presets/code/agent.cordis.yml +++ b/apps/cli/config/agent-presets/code/agent.cordis.yml @@ -198,12 +198,14 @@ toolName: subagent_fork backgroundMode: continuable - # Production dsh does not install these optional providers. An opting-in - # Profile mounts each provider once on the host plane; copy this preset, - # then remove `disabled` from the matching tool row. + # External product agents. The base host plane mounts each provider, and + # each run needs that product's own CLI on PATH plus its own account: + # `codex`, `claude`, and `cursor-agent`. A missing CLI fails the call it is + # asked for rather than the composition, so a deployment without one keeps + # an inert tool row; copy this preset and add `disabled: true` to drop it + # from the model's roster entirely. - id: tool-subagent-codex name: '@deepseek-ai/dsh-tool-subagent' - disabled: true config: provider: codex toolName: subagent_codex @@ -212,13 +214,20 @@ - id: tool-subagent-claude-code name: '@deepseek-ai/dsh-tool-subagent' - disabled: true config: provider: claude-code toolName: subagent_claude_code backgroundMode: one-shot maxDepth: provider-managed + - id: tool-subagent-cursor + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: cursor + toolName: subagent_cursor + backgroundMode: one-shot + maxDepth: provider-managed + - id: workflow-worker-thread name: '@deepseek-ai/dsh-workflow-worker-thread' config: diff --git a/apps/cli/config/agent-presets/cordis/agent.cordis.yml b/apps/cli/config/agent-presets/cordis/agent.cordis.yml index e808250c00..7dfdf58301 100644 --- a/apps/cli/config/agent-presets/cordis/agent.cordis.yml +++ b/apps/cli/config/agent-presets/cordis/agent.cordis.yml @@ -185,12 +185,14 @@ toolName: subagent_fork backgroundMode: continuable - # Production dsh does not install these optional providers. An opting-in - # Profile mounts each provider once on the host plane; copy this preset, - # then remove `disabled` from the matching tool row. + # External product agents. The base host plane mounts each provider, and + # each run needs that product's own CLI on PATH plus its own account: + # `codex`, `claude`, and `cursor-agent`. A missing CLI fails the call it is + # asked for rather than the composition, so a deployment without one keeps + # an inert tool row; copy this preset and add `disabled: true` to drop it + # from the model's roster entirely. - id: tool-subagent-codex name: '@deepseek-ai/dsh-tool-subagent' - disabled: true config: provider: codex toolName: subagent_codex @@ -199,13 +201,20 @@ - id: tool-subagent-claude-code name: '@deepseek-ai/dsh-tool-subagent' - disabled: true config: provider: claude-code toolName: subagent_claude_code backgroundMode: one-shot maxDepth: provider-managed + - id: tool-subagent-cursor + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: cursor + toolName: subagent_cursor + backgroundMode: one-shot + maxDepth: provider-managed + - id: workflow-worker-thread name: '@deepseek-ai/dsh-workflow-worker-thread' config: diff --git a/apps/cli/config/agent-presets/cordis/skills/editing-cordis-compositions/SKILL.md b/apps/cli/config/agent-presets/cordis/skills/editing-cordis-compositions/SKILL.md index 8214e88654..99516a37d6 100644 --- a/apps/cli/config/agent-presets/cordis/skills/editing-cordis-compositions/SKILL.md +++ b/apps/cli/config/agent-presets/cordis/skills/editing-cordis-compositions/SKILL.md @@ -9,7 +9,7 @@ Every capability in this harness is a plugin row in a `cordis.yml`. There is no ## Off-limits -**Never edit, delete, or overwrite a preset that ships with the deployment** — the `agent-presets` directory beside the deployment's own config, which supplies `standard`, `code`, `minimal`, and `cordis`. Never escalate the sandbox to reach it, even when a change there looks quicker. An upgrade overwrites that install, and corrupting `cordis` disables preset authoring itself. Reading a shipped composition is the intended way to start; writing to one is not, and neither is editing the host composition to work around a preset limitation. +**Never edit, delete, or overwrite a preset that ships with the deployment** — the `agent-presets` directory beside the deployment's own config, whose ids the roster reports. Never escalate the sandbox to reach it, even when a change there looks quicker. An upgrade overwrites that install, and corrupting `cordis` disables preset authoring itself. Reading a shipped composition is the intended way to start; writing to one is not, and neither is editing the host composition to work around a preset limitation. To change what a shipped preset does, copy it and edit the copy. Locally authored presets under the user root are yours to create, edit, and delete. diff --git a/apps/cli/config/agent-presets/economy/agent.cordis.yml b/apps/cli/config/agent-presets/economy/agent.cordis.yml new file mode 100644 index 0000000000..19f9e142dd --- /dev/null +++ b/apps/cli/config/agent-presets/economy/agent.cordis.yml @@ -0,0 +1,211 @@ +# The `economy` agent preset: token-efficient agent with cost-aware delegation. + +# ── identity ──────────────────────────────────────────────────────────────── + +- id: persona + name: '@deepseek-ai/dsh-persona' + config: + text: >- + You are an economy-focused coding agent powered by the {{model}} model. Your working directory is {{cwd}}. + + TOKEN & COST EFFICIENCY PRINCIPLES (MANDATORY OPERATING RULES): + 1. Do cheap work directly in the main thread: + - A direct read/grep/glob call is almost always cheaper and faster than a subagent, which pays its own system prompt and tool catalog. Use read/grep/glob/pwsh directly for lookups, small edits, and tasks with a small working set. + 2. Delegate only when delegation clearly saves tokens: + - Delegate large, self-contained subtasks that would otherwise add many tool-call rounds to the main context: whole-codebase exploration, running and analyzing a test suite, drafting an isolated implementation, independent research. + - Use `subagent` for self-contained work; use `subagent_fork` only when the child must build on this conversation. + - Group related questions into one delegation rather than one subagent per small question. + 3. Bound the delegation cost: + - Prefer one subagent per work item; never spawn a subagent for a lookup a main-thread tool can answer. + - Children are leaves: a delegated child works directly with its own tools and reports back; it must not delegate further. + - Ignore or cancel a subagent whose result no longer matters; collect only the results you need. + 4. Context protection: + - Keep the main conversation lean: paste conclusions, not raw file dumps. + 5. Communication: + - Keep assistant responses concise, structured, and actionable. + +- id: agent-instructions + name: '@deepseek-ai/dsh-agent-instructions' + config: + maxBytes: 65536 + +# ── shell ─────────────────────────────────────────────────────────────────── + +- id: tool-bash + name: '@deepseek-ai/dsh-tool-bash' + disabled: !!js process.platform === 'win32' + +- id: tool-pwsh + name: '@deepseek-ai/dsh-tool-pwsh' + disabled: !!js process.platform !== 'win32' + +# ── filesystem ────────────────────────────────────────────────────────────── + +- id: tool-fs + name: '@deepseek-ai/dsh-tool-fs' + +- id: tool-fs-search + name: '@deepseek-ai/dsh-tool-fs-search' + config: + sampleOverCapGlobResults: false + +# ── background jobs ──────────────────────────────────────────────────────── + +- id: tool-jobs + name: '@deepseek-ai/dsh-tool-jobs' + +# ── skills ────────────────────────────────────────────────────────────────── + +- id: skill-filesystem + name: '@deepseek-ai/dsh-skill-filesystem' + +- id: tool-skill + name: '@deepseek-ai/dsh-tool-skill' + +# ── goals ─────────────────────────────────────────────────────────────────── + +- id: tool-goal + name: '@deepseek-ai/dsh-tool-goal' + +# ── plan mode ─────────────────────────────────────────────────────────────── + +- id: planning + name: cordis:group + group: true + isolate: + planMode: true + config: + - id: plan-mode + name: '@deepseek-ai/dsh-plan-mode' + config: + section: | + You are in plan mode. Stay in plan mode until exit_plan_mode succeeds or the user switches the session mode. Imperative language to implement changes means plan the implementation, not execute it. A user's conversational agreement — including an answer confirming something you asked — approves nothing and does not end plan mode; fold the confirmed decision into the plan and submit it through exit_plan_mode. + + Explore first. Use non-mutating reads, searches, static analysis, and checks to ground the plan in the actual repository. Do not edit or write files, change configuration, run formatters or code generation that rewrites tracked files, commit, or otherwise carry out the plan. Prefer existing functions and patterns over new machinery. + + The tool catalog stays the same across modes for request-cache stability. These plan-mode rules override any later tool description or guidance that suggests using mutation tools; those tools remain listed to keep the tool catalog unchanged. Do not use todo_write to track this planning phase: it tracks implementation after an approved plan, while the plan itself belongs in exit_plan_mode. + + Resolve discoverable facts by inspection. Use ask_user_question only for user-owned choices or material ambiguity that inspection cannot answer. Do not ask the user where code lives or how current behavior works when you can find out. + + Make the plan decision-complete: state the goal and success criteria; group implementation changes by subsystem; identify public API, schema, and data-flow changes; cover edge cases, failure modes, tests, acceptance criteria, and explicit assumptions. Keep it concise enough to review but detailed enough that another engineer can implement it without making design decisions. + + When ready, call exit_plan_mode with the complete plan markdown, starting with a # title. Make exit_plan_mode the only and final tool call in that assistant response: it presents the plan for approval, and implementation begins only in a later step after approval. Do not paste the final plan as a plain reply or ask "should I proceed?" through prose or ask_user_question. If review rejects it, incorporate the feedback and present again. If the review channel is unavailable or aborted, stay in plan mode and ask the user to switch modes manually; do not proceed with implementation. + +# ── compaction ────────────────────────────────────────────────────────────── + +- id: compaction + name: cordis:group + group: true + isolate: + compaction: true + toolResultPruner: true + config: + - id: compaction-basic + name: '@deepseek-ai/dsh-compaction-basic' + + - id: command-compact + name: '@deepseek-ai/dsh-command-compact' + + - id: tool-result-pruner + name: '@deepseek-ai/dsh-compaction-tool-result-pruner' + config: + thresholdChars: 4096 + headChars: 2048 + tailChars: 512 + +# ── delegation and workflows ──────────────────────────────────────────────── + +- id: delegation + name: cordis:group + group: true + isolate: + workflowEngine: true + config: + - id: tool-subagent-control + name: '@deepseek-ai/dsh-tool-subagent-control' + + - id: tool-subagent-list-agents + name: '@deepseek-ai/dsh-tool-subagent-control/list-agents' + + - id: tool-subagent + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: spawn + toolName: subagent + backgroundMode: one-shot + # Children are leaves: the top-level agent may delegate, a child may + # not, so the number of spawned agents is bounded by the top-level + # delegations instead of cascading. + maxDepth: 1 + + - id: tool-subagent-fork + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: fork + toolName: subagent_fork + backgroundMode: one-shot + maxDepth: 1 + + # An economy mode should not reach for external paid agents by default, so + # these rows stay off even though the base host plane mounts each provider. + # A deployment that wants them copies this preset and removes `disabled` + # from the matching row; each run then needs that product's own CLI on PATH + # (`codex`, `claude`, `cursor-agent`) plus its own account. + - id: tool-subagent-codex + name: '@deepseek-ai/dsh-tool-subagent' + disabled: true + config: + provider: codex + toolName: subagent_codex + backgroundMode: one-shot + maxDepth: provider-managed + + - id: tool-subagent-claude-code + name: '@deepseek-ai/dsh-tool-subagent' + disabled: true + config: + provider: claude-code + toolName: subagent_claude_code + backgroundMode: one-shot + maxDepth: provider-managed + + - id: tool-subagent-cursor + name: '@deepseek-ai/dsh-tool-subagent' + disabled: true + config: + provider: cursor + toolName: subagent_cursor + backgroundMode: one-shot + maxDepth: provider-managed + + - id: workflow-worker-thread + name: '@deepseek-ai/dsh-workflow-worker-thread' + config: + provider: spawn + + - id: tool-workflow + name: '@deepseek-ai/dsh-tool-workflow' + + - id: tool-ralph + name: '@deepseek-ai/dsh-tool-ralph' + config: + # Fresh-agent rounds are the most expensive delegation pattern: every + # round is a new child with no conversation seed. Keep the budget tight. + subagentProvider: spawn + maxRounds: 8 + +# ── remaining model-facing rows ───────────────────────────────────────────── + +- id: tool-ask-user + name: '@deepseek-ai/dsh-tool-ask-user' + +- id: tool-todo + name: '@deepseek-ai/dsh-tool-todo' + config: + allowParallelInProgress: true + +- id: tool-web + name: '@deepseek-ai/dsh-tool-web' + config: + fetch: false + searchTimeoutMs: 60000 diff --git a/apps/cli/config/agent-presets/economy/preset.yml b/apps/cli/config/agent-presets/economy/preset.yml new file mode 100644 index 0000000000..6a31288a3a --- /dev/null +++ b/apps/cli/config/agent-presets/economy/preset.yml @@ -0,0 +1,3 @@ +name: Экономный режим +description: Оптимизированный режим для экономии токенов и затрат на API: дешёвые операции выполняются в основном контексте, делегирование субагентам — только когда оно окупается, результаты инструментов активно сжимаются. +order: 5 \ No newline at end of file diff --git a/apps/cli/config/agent-presets/maximum/agent.cordis.yml b/apps/cli/config/agent-presets/maximum/agent.cordis.yml new file mode 100644 index 0000000000..6332832397 --- /dev/null +++ b/apps/cli/config/agent-presets/maximum/agent.cordis.yml @@ -0,0 +1,539 @@ +# The `maximum` agent preset: every model-facing capability this deployment +# can compose, driven by a three-role delivery pipeline. +# +# Two things distinguish it from `standard`. It adds the capabilities the other +# shipped presets leave out — persistent terminals, language-server queries, +# session history, durable time and tmux context, reminders, self-modification, +# the extra editor, and Code Mode beside the native schemas. And it adds three +# ROLE delegation tools over the same `spawn` backend, each carrying its own +# child persona: `subagent_architect` writes the specification, +# `subagent_implementer` builds it, `subagent_reviewer` verifies it. The +# orchestrating persona below owns the order they run in; the child personas +# own what each role must produce. +# +# COST: this preset is deliberately the expensive one. Every row here adds tool +# schemas and prompt sections to every request, `mode: both` sends the native +# catalog AND a generated Code Mode SDK, and one pipeline pass is three child +# agents with their own contexts. Copy it and delete rows to trade capability +# for tokens; `standard` and `economy` are the smaller shipped points. +# +# TRUST: `cordis_mount` evaluates model-written JavaScript against the live +# runtime, and the external product agents below run whatever their own CLI and +# account allow. Treat a session on this preset as shell access. +# +# This file is an AGENT-PLANE composition. The roster mounts it ONCE under a +# standing scope; every session naming it joins by scope parentage, so the tools +# and prompt sections registered here cover each joined agent while a session's +# own state stays keyed per Session/Agent inside the plugins. The host +# composition (`base.cordis.yml` + `web.cordis.yml`) keeps everything a preset +# must not own: the registries themselves, the sandbox and approval stack, +# persistence, and the model route. +# +# A service row here MUST sit inside a group carrying an `isolate` realm. +# Without one it publishes into the root realm, where it is process-global — +# another preset publishing the same name collides, and a host reader would +# resolve one preset's instance for every session; `dsh-agent-presets` rejects +# that at mount. `true` means an entry-local realm: this standing mount's own +# private instance, apart from every other preset's. + +# ── identity ──────────────────────────────────────────────────────────────── + +# The preset's own persona, shadowing the deployment default for this agent. +# `{{model}}` and `{{cwd}}` resolve from the agent's own route and workspace. +# It states the pipeline the three role tools below exist for; each role's own +# instructions live on that role's tool row, not here, because a child never +# reads this section — the delegation replaces it with the role persona. +- id: persona + name: '@deepseek-ai/dsh-persona' + config: + text: |- + You are the lead agent of a three-role delivery pipeline, powered by the {{model}} model. Your working directory is {{cwd}}. + + Every change to the repository passes through three delegated roles, in this order: + + 1. `subagent_architect` turns the request into a specification: the goal, the files and subsystems involved, the ordered change plan, the acceptance checks, and the risks. It reads and never writes. + 2. `subagent_implementer` receives that specification and makes the repository satisfy it, running the checks the specification names. + 3. `subagent_reviewer` receives the specification and the implementer's report, inspects the working tree itself, and returns PASS with its evidence or FAIL with a numbered defect list. + + On FAIL, hand the defect list plus the original specification back to `subagent_implementer` and review again. Stop after the third failed review and bring the disagreement to the user rather than starting a fourth round. + + Each role runs as a fresh agent that cannot see this conversation or the other roles' sessions. Everything a role needs goes into its prompt as complete text — paste the specification and the verdict, never "as discussed above" or a session id. + + Skip the pipeline for work that is not a change: questions, read-only investigation, running a command the user asked for, or a fix the user dictated line by line. Use it for anything that changes behavior, and say which stage you are in as you go. + + Do your own reading, searching, and answering. Delegate the three roles, not your judgment. + +- id: agent-instructions + name: '@deepseek-ai/dsh-agent-instructions' + config: + maxBytes: 65536 + +# ── shell ─────────────────────────────────────────────────────────────────── + +# `shell-env` stays in the HOST composition: `apps/cli/src/web.ts` injects it to +# publish `DSH_WEB_URL`/`DSH_WEB_MODE`, and a host row that injects a service is +# the criterion for host-plane ownership — injection resolves before any session +# exists, so there is no agent to key by. Both shell tools consume the host +# registry from here; their executors (`bash-sandbox`/`pwsh-sandbox`) are +# host-plane too. +- id: tool-bash + name: '@deepseek-ai/dsh-tool-bash' + disabled: !!js process.platform === 'win32' + +- id: tool-pwsh + name: '@deepseek-ai/dsh-tool-pwsh' + disabled: !!js process.platform !== 'win32' + +# ── persistent terminals ──────────────────────────────────────────────────── + +# The PTY registry is an agent-owned service, so it lives in an entry-local +# realm; the backend still consumes the host sandbox policy and subprocess +# implementation. `tool-terminal` rather than `tool-bash-persistent` is what +# joins the one-shot shell above: the persistent-bash tool registers under the +# name `bash`, which this preset's `tool-bash` already owns, while the six +# `terminal_*` tools add long-lived sessions under their own names. +# +# The backend starts an interactive `bash`. A machine without one fails +# `terminal_open`, not the composition, exactly like the external agents below. +- id: persistent-terminals + name: cordis:group + group: true + isolate: + terminals: true + config: + - id: pty + name: '@deepseek-ai/dsh-terminal' + + - id: terminal-bash + name: '@deepseek-ai/dsh-terminal-bash' + config: + timeoutMs: 300000 + + - id: tool-terminal + name: '@deepseek-ai/dsh-tool-terminal' + config: + enableRunInBackground: true + maxResultBytes: 262144 + +# ── filesystem ────────────────────────────────────────────────────────────── + +# All three register into the host `tools` registry and provide nothing, so +# they need no realm. The `fs` service and its policy stay in the host. +# `str_replace_editor` is the standalone view/create/replace/insert editor; it +# overlaps `edit` deliberately, since this preset's contract is that every +# shipped model-facing tool is present. +- id: tool-fs + name: '@deepseek-ai/dsh-tool-fs' + +- id: tool-fs-search + name: '@deepseek-ai/dsh-tool-fs-search' + config: + sampleOverCapGlobResults: false + +- id: tool-str-replace-editor + name: '@deepseek-ai/dsh-tool-str-replace-editor' + config: + maxOutputChars: 16000 + +# ── language servers ──────────────────────────────────────────────────────── + +# The `lsp` registry has no consumer outside an agent, so it lives in an +# entry-local realm with the tool that reads it. Without a provider the tool +# stays in the catalog and every query answers the structured `LSP_UNAVAILABLE` +# error, which is why the tool row is unconditional and the provider row is not. +# +# `lsp-stdio` resolves every configured executable AT LOAD and fails the mount +# when one is missing, so a shipped preset cannot enable it: the language server +# is a machine-local install, not a deployment fact. The row below is the +# worked example — copy this preset, drop `disabled`, and name the servers this +# machine actually has. +- id: language-servers + name: cordis:group + group: true + isolate: + lsp: true + config: + - id: lsp + name: '@deepseek-ai/dsh-lsp' + + - id: lsp-stdio + name: '@deepseek-ai/dsh-lsp-stdio' + disabled: true + config: + servers: + typescript: + command: typescript-language-server + args: ['--stdio'] + extensionToLanguage: + .ts: typescript + .tsx: typescriptreact + .mts: typescript + .cts: typescript + .js: javascript + .jsx: javascriptreact + + - id: tool-lsp + name: '@deepseek-ai/dsh-tool-lsp' + +# ── background jobs ──────────────────────────────────────────────────────── + +# Only the model-facing controls. The task REGISTRY stays on the host plane: +# its producers sit outside any realm this file could put it in — `tool-bash` +# and `tool-terminal` above resolve it with `ctx.get`, and an entry-local realm +# here is invisible to every sibling row, so `run_in_background` would answer +# "background jobs unavailable" while these controls sat in the catalog. The +# registry is keyed by owning agent anyway, so one host instance serves every +# session. +- id: tool-jobs + name: '@deepseek-ai/dsh-tool-jobs' + +# ── skills ────────────────────────────────────────────────────────────────── + +# The skill REGISTRY lives in the host composition and is layered per scope: +# these rows register into THIS preset's layer of it, so they need no realm. +# The merged catalog carries the project and user roots this provider always +# scans, whatever the deployment registered globally, and the two custom roots +# below. +# +# The first custom root is this preset's own `skills/`, which travels with it. +# The second is the `cordis` preset's, so composition authoring is documented +# for the `cordis_*` tools this preset also carries; a copy of this preset that +# lands beside no `cordis` directory simply scans a missing path, which is +# valid empty state for this provider. +- id: skill-filesystem + name: '@deepseek-ai/dsh-skill-filesystem' + config: + customSkillDirs: + - !!js "process.getBuiltinModule('node:url').fileURLToPath(new URL('skills/', baseUrl))" + - !!js "process.getBuiltinModule('node:url').fileURLToPath(new URL('../cordis/skills/', baseUrl))" + +- id: tool-skill + name: '@deepseek-ai/dsh-tool-skill' + +# ── goals ─────────────────────────────────────────────────────────────────── + +# Only the model-facing tool. The goal SERVICE, its session driver, and the +# `/goal` command stay on the host plane: the Gateway serves the goal domain as +# Remote endpoints whose receiver comes from a generated descriptor, so it +# resolves `goals` on the host and an entry-local realm here would hide it. +- id: tool-goal + name: '@deepseek-ai/dsh-tool-goal' + +# ── session history ───────────────────────────────────────────────────────── + +# Five read-only tools over the host `sessionQuery` service, each result +# authorized from the calling agent's own session. The shipped host mounts that +# service with `openAt: never`, so `session_search` and `session_event_search` +# answer `SESSION_QUERY_SEARCH_DISABLED` while the three read and trace tools +# work; full-text search is a host decision (`openAt: first-search` in a +# profile patch), not one a preset can make. +- id: tool-session-query + name: '@deepseek-ai/dsh-tool-session-query' + +# ── durable per-step context ──────────────────────────────────────────────── + +# Both inject their snapshot at `agent/pre-step`, which is scope-filtered, so +# they reach only agents joined to this preset. Time context lets the model +# read relative dates in the request's own zone; tmux context names the pane +# this process runs in, and reads as "not in tmux" everywhere else. +- id: time-context + name: '@deepseek-ai/dsh-time-context' + config: + refreshIntervalMs: 60000 + +- id: tmux-context + name: '@deepseek-ai/dsh-tmux-context' + config: + refreshIntervalMs: 60000 + +# ── scheduled reminders ───────────────────────────────────────────────────── + +# Session-scoped durable reminders (`schedule_create`/`_list`/`_delete`). The +# plugin installs on root agents created after it loads and takes its state +# from the session log through the host persistence barrier, so one instance +# per preset mount serves every session that joins — `agent/created` is +# scope-filtered, and an agent on another preset never reaches this one. +- id: schedule + name: '@deepseek-ai/dsh-schedule' + +# ── plan mode ─────────────────────────────────────────────────────────────── + +# Plan state is per-agent by nature, so an entry-local realm is not a +# workaround here — it is the correct lifetime. +- id: planning + name: cordis:group + group: true + isolate: + planMode: true + config: + - id: plan-mode + name: '@deepseek-ai/dsh-plan-mode' + config: + section: | + You are in plan mode. Stay in plan mode until exit_plan_mode succeeds or the user switches the session mode. Imperative language to implement changes means plan the implementation, not execute it. A user's conversational agreement — including an answer confirming something you asked — approves nothing and does not end plan mode; fold the confirmed decision into the plan and submit it through exit_plan_mode. + + Explore first. Use non-mutating reads, searches, static analysis, and checks to ground the plan in the actual repository. Do not edit or write files, change configuration, run formatters or code generation that rewrites tracked files, commit, or otherwise carry out the plan. Prefer existing functions and patterns over new machinery. Delegation does not lift this: a delegated role may read and report, and none of them may write while plan mode is active. + + The tool catalog stays the same across modes for request-cache stability. These plan-mode rules override any later tool description or guidance that suggests using mutation tools; those tools remain listed to keep the tool catalog unchanged. Do not use todo_write to track this planning phase: it tracks implementation after an approved plan, while the plan itself belongs in exit_plan_mode. + + Resolve discoverable facts by inspection. Use ask_user_question only for user-owned choices or material ambiguity that inspection cannot answer. Do not ask the user where code lives or how current behavior works when you can find out. + + Make the plan decision-complete: state the goal and success criteria; group implementation changes by subsystem; identify public API, schema, and data-flow changes; cover edge cases, failure modes, tests, acceptance criteria, and explicit assumptions. Keep it concise enough to review but detailed enough that another engineer can implement it without making design decisions. + + When ready, call exit_plan_mode with the complete plan markdown, starting with a # title. Make exit_plan_mode the only and final tool call in that assistant response: it presents the plan for approval, and implementation begins only in a later step after approval. Do not paste the final plan as a plain reply or ask "should I proceed?" through prose or ask_user_question. If review rejects it, incorporate the feedback and present again. If the review channel is unavailable or aborted, stay in plan mode and ask the user to switch modes manually; do not proceed with implementation. + +# ── compaction ────────────────────────────────────────────────────────────── + +# `compaction-basic` reads `toolResultPrune` through `ctx.get`, so the pruner +# must share this realm rather than sit outside it. +# +# `tokenMeter` is deliberately NOT in this realm: the meter stays on the HOST +# plane, and the rows here resolve that one instance. It keys every fold by +# Session and owns the context-meter projection units the browser reads for +# every session — behind a realm those units would come and go with whichever +# presets happen to be mounted. +- id: compaction + name: cordis:group + group: true + isolate: + compaction: true + toolResultPruner: true + config: + - id: compaction-basic + name: '@deepseek-ai/dsh-compaction-basic' + + - id: command-compact + name: '@deepseek-ai/dsh-command-compact' + + - id: tool-result-pruner + name: '@deepseek-ai/dsh-compaction-tool-result-pruner' + config: + thresholdChars: 8192 + headChars: 4096 + tailChars: 1024 + +# ── delegation, roles, and workflows ──────────────────────────────────────── + +# The `subagents` registry and its spawn/fork backends live in the HOST +# composition: the registry is a process singleton whose cross-session queries +# the api-proxy serves to the browser, and a provider name may only be +# registered once. This preset contributes the delegation TOOLS, which resolve +# that host registry. +# +# `workflows` is different — nothing outside an agent reads it — so every row +# that reaches it shares one entry-local realm here, and a consumer left +# outside would resolve a host registry this preset does not populate. +- id: delegation + name: cordis:group + group: true + isolate: + workflowEngine: true + config: + - id: tool-subagent-control + name: '@deepseek-ai/dsh-tool-subagent-control' + + - id: tool-subagent-list-agents + name: '@deepseek-ai/dsh-tool-subagent-control/list-agents' + + # The unshaped delegations, kept beside the roles: `subagent` for work that + # is not a pipeline stage, `subagent_fork` for work that needs this + # conversation's history. + - id: tool-subagent + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: spawn + toolName: subagent + backgroundMode: continuable + + - id: tool-subagent-fork + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: fork + toolName: subagent_fork + backgroundMode: continuable + + # ── the three pipeline roles ──────────────────────────────────────────── + # + # One `spawn` instance per role, distinguished only by its `toolName` and + # its child `persona` — the provider applies that persona as a scoped + # section shadowing `deployment:persona`, so a role child never reads the + # orchestrating persona at the top of this file. + # + # All three are `one-shot`: a stage's result is the next stage's input, so + # the default must be the foreground call that returns it. `maxDepth: 1` + # keeps the pipeline flat — the roles are leaves and the lead agent owns + # sequencing, so the agent count of one pass is exactly the stages run. + # + # A role's restrictions are stated in its persona rather than as a + # `toolFilter`: the filter names GLOBAL tool names and fails the start when + # one is unknown, and the shell tool's name differs by platform, so a + # read-only filter here would be a Windows-versus-POSIX startup failure. A + # persona cannot enforce a restriction; deployments that need enforcement + # copy this preset and add the filter their platform actually registers. + - id: tool-subagent-architect + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: spawn + toolName: subagent_architect + backgroundMode: one-shot + maxDepth: 1 + persona: |- + You are the ARCHITECT of a three-role delivery pipeline. You produce one specification and nothing else. An implementer who cannot see this session receives your reply verbatim and builds from it. + + Ground the specification in the repository as it actually is. Read the files the change touches, the tests around them, and the existing patterns it must follow; prefer an existing function, module, or convention over new machinery. Resolve by inspection anything you could otherwise guess at. + + Change nothing. No edits, no writes, no commits, no formatters, no code generation, no dependency installs. Running read-only commands and tests to learn how the code behaves today is expected. + + Reply with exactly these sections: + + GOAL — one paragraph: what will be true when this is done, and what is explicitly out of scope. + CONTEXT — the files, symbols, and patterns the change must fit, each with a path, and what each one contributes. + PLAN — ordered steps, each naming the file it changes and the change it makes, in enough detail that the implementer makes no design decisions. + ACCEPTANCE — the checks that decide done: exact commands to run, and the observable behavior to confirm. + RISKS — what could break elsewhere, the edge cases to cover, and every assumption you could not verify. + + When the request is ambiguous in a way that changes the design, state the interpretation you chose and why in GOAL rather than asking; you cannot see the user. + + - id: tool-subagent-implementer + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: spawn + toolName: subagent_implementer + backgroundMode: one-shot + maxDepth: 1 + persona: |- + You are the IMPLEMENTER of a three-role delivery pipeline. You receive a specification and make the repository satisfy it. A reviewer who cannot see this session checks your work against that same specification. + + Follow the plan you were given. Match the surrounding code: its naming, its idioms, its comment density, its error handling. Change what the specification calls for and leave the rest alone. + + Run the checks the specification names, plus whatever narrower test covers the code you touched. A check you did not run is not a check you may report. + + When a step is wrong or impossible, do every step that is not blocked, then say exactly what you skipped and why. Never substitute a different design in silence, and never widen the scope past the specification. + + Reply with exactly these sections: + + CHANGES — one line per file: the path and what changed in it. + VERIFICATION — each command you ran and its outcome, quoting the failing output where it failed. + DEVIATIONS — everything you did differently from the plan, or did not do, each with its reason. Write "none" when there is nothing. + + - id: tool-subagent-reviewer + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: spawn + toolName: subagent_reviewer + backgroundMode: one-shot + maxDepth: 1 + persona: |- + You are the REVIEWER of a three-role delivery pipeline. You receive a specification and an implementer's report, and you return a verdict on the work as it exists in the repository. + + Verify, do not trust. Read the changed files yourself, re-run the acceptance checks the specification names, and confirm each claim in the report against what you observe. An unrunnable or skipped check is a defect, not a pass. + + Judge the implementation against the specification: unmet acceptance criteria, missed edge cases and failure modes, behavior changed outside the plan, tests that assert nothing the change could break, and anything that contradicts the conventions of the surrounding code. + + Fix nothing. Do not edit files, do not commit, do not run formatters or code generation. Reporting the defect is your whole job. + + Reply with exactly these sections: + + VERDICT — the first line, either PASS or FAIL and nothing else. + EVIDENCE — the commands you ran and the files you read, each with its outcome. + DEFECTS — numbered, each naming the file, what the specification requires, what the code does instead, and a severity of blocking or minor. PASS requires this list to be empty; a blocking defect requires FAIL. + + # External product agents. The base host plane mounts each provider, and + # each run needs that product's own CLI on PATH plus its own account: + # `codex`, `claude`, and `cursor-agent`. A missing CLI fails the call it is + # asked for rather than the composition, so a deployment without one keeps + # an inert tool row; copy this preset and add `disabled: true` to drop it + # from the model's roster entirely. + - id: tool-subagent-codex + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: codex + toolName: subagent_codex + backgroundMode: one-shot + maxDepth: provider-managed + + - id: tool-subagent-claude-code + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: claude-code + toolName: subagent_claude_code + backgroundMode: one-shot + maxDepth: provider-managed + + - id: tool-subagent-cursor + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: cursor + toolName: subagent_cursor + backgroundMode: one-shot + maxDepth: provider-managed + + - id: workflow-worker-thread + name: '@deepseek-ai/dsh-workflow-worker-thread' + config: + provider: spawn + + - id: tool-workflow + name: '@deepseek-ai/dsh-tool-workflow' + + - id: tool-ralph + name: '@deepseek-ai/dsh-tool-ralph' + config: + subagentProvider: spawn + maxRounds: 64 + +# ── MCP servers ───────────────────────────────────────────────────────────── + +# One row per external MCP server, each registering its tools on the host +# registry as `mcp____`. Which servers exist is a machine +# fact — every one names a command to spawn or a URL to reach — so this preset +# ships the worked example disabled. Copy the preset, drop `disabled`, and add +# one row per server. +- id: mcp-example + name: '@deepseek-ai/dsh-mcp-client' + disabled: true + config: + serverName: example + transport: stdio + command: npx + args: ['-y', '@modelcontextprotocol/server-everything'] + +# ── remaining model-facing rows ───────────────────────────────────────────── + +- id: tool-ask-user + name: '@deepseek-ai/dsh-tool-ask-user' + +- id: tool-todo + name: '@deepseek-ai/dsh-tool-todo' + config: + allowParallelInProgress: true + +# The `web` service and its search provider stay in the host composition; only +# the model-facing tool is per-session. `fetch` stays off because the shipped +# host mounts no fetch provider: that provider defers SSRF protection and the +# model would choose the request target. +- id: tool-web + name: '@deepseek-ai/dsh-tool-web' + config: + fetch: false + searchTimeoutMs: 60000 + +# ── self-modification ─────────────────────────────────────────────────────── + +# Read the live runtime, mount a temporary plugin, unmount it. The toolset is a +# trust boundary, not a sandbox — see this file's header. The composition- +# authoring skill reaches this agent through the second custom skill root above. +- id: tool-cordis + name: '@deepseek-ai/dsh-tool-cordis' + +# ── presentation ──────────────────────────────────────────────────────────── + +# `both` sends the native tool schemas AND the generated Code Mode SDK, so the +# model may call a tool directly or write one TypeScript program that combines +# several. It waits for the host's `codeRuntime` rather than assuming it: a +# deployment composing no TypeScript runtime fails this preset at mount, naming +# this id, instead of at the first request. +- id: tool-presentation + name: '@deepseek-ai/dsh-agent-tool-presentation' + config: + mode: both diff --git a/apps/cli/config/agent-presets/maximum/preset.yml b/apps/cli/config/agent-presets/maximum/preset.yml new file mode 100644 index 0000000000..c8da2ab5ac --- /dev/null +++ b/apps/cli/config/agent-presets/maximum/preset.yml @@ -0,0 +1,3 @@ +name: 全能模式 +description: 组合全部可用插件、技能与外部 Agent,并以「架构师 → 实现者 → 审查者」三角色流水线规划、实现并验证每一次改动。 +order: 6 diff --git a/apps/cli/config/agent-presets/maximum/skills/three-role-delivery-pipeline/SKILL.md b/apps/cli/config/agent-presets/maximum/skills/three-role-delivery-pipeline/SKILL.md new file mode 100644 index 0000000000..db6199e9ab --- /dev/null +++ b/apps/cli/config/agent-presets/maximum/skills/three-role-delivery-pipeline/SKILL.md @@ -0,0 +1,48 @@ +--- +name: three-role-delivery-pipeline +description: Use when running a change through this preset's architect → implementer → reviewer roles — writing the prompt for subagent_architect, subagent_implementer, or subagent_reviewer, deciding whether a task needs the pipeline at all, handing a FAIL verdict back for a second round, or splitting work that is too large for one pass. +--- + +# The three-role delivery pipeline + +This preset carries three delegation tools that are one tool each, distinguished by the persona their children run under: + +| Tool | Role | Produces | +|---|---|---| +| `subagent_architect` | specify | GOAL / CONTEXT / PLAN / ACCEPTANCE / RISKS | +| `subagent_implementer` | build | CHANGES / VERIFICATION / DEVIATIONS | +| `subagent_reviewer` | verify | VERDICT / EVIDENCE / DEFECTS | + +All three are foreground calls that return their reply as the tool result, and none of them can delegate further. You are the only agent that sees the whole pipeline. + +## When it applies + +Run the pipeline for anything that changes behavior: a feature, a bug fix, a refactor, a migration, a configuration change that alters what the system does. + +Skip it for a question, a read-only investigation, a command the user asked you to run, or an edit the user dictated exactly. Answer those yourself. A pipeline pass costs three child agents; using it to rename one variable is waste, and the user notices. + +Split before you delegate when the request holds several independent changes. One pipeline pass carries one specification; two unrelated changes in one specification produce a review that cannot say PASS or FAIL about either. + +## The handoff is the whole design + +Each child is a fresh agent. It cannot see this conversation, the user's message, the earlier stages, or the other children's sessions. Whatever you leave out of the prompt does not exist for that role. + +**To the architect**, pass the user's request in full, the constraints the user stated, and anything you already learned that narrows the work — a file you found, a decision the user made mid-conversation, a check that already fails. + +**To the implementer**, pass the architect's reply verbatim. Add nothing and remove nothing; if you disagree with the plan, say so to the user or re-run the architect, but do not edit a specification into the implementer's prompt. + +**To the reviewer**, pass the same specification verbatim plus the implementer's complete report. The reviewer compares two texts against the repository; withholding either leaves it guessing. + +Never write "as described above", "the plan from the previous step", or a session id in a role prompt. There is no above. + +## The FAIL loop + +A FAIL verdict comes back as a numbered defect list. Send the implementer the original specification, its own previous report, and that defect list, and say that this round fixes the listed defects and nothing else. Then review again, with the same specification and the new report. + +Stop after the third failed review. Three rounds against one specification means the specification and the implementation disagree about something the reviewer cannot resolve — take it to the user with the specification, the last report, and the surviving defects, rather than starting a fourth round. + +A `minor` defect does not have to block. When the reviewer returns PASS with minor defects, or FAIL where every defect is minor and unrelated to the acceptance criteria, say so to the user and let them decide whether to spend another round. + +## Reporting + +Say which stage you are in as you go: the user is waiting through three model calls and a silent gap reads as a hang. When the pipeline finishes, report what changed, what the reviewer verified, and any deviation or surviving defect. Never report the pipeline as complete when the reviewer never ran or returned FAIL. diff --git a/apps/cli/config/agent-presets/standard/agent.cordis.yml b/apps/cli/config/agent-presets/standard/agent.cordis.yml index b8559cfd74..310c149568 100644 --- a/apps/cli/config/agent-presets/standard/agent.cordis.yml +++ b/apps/cli/config/agent-presets/standard/agent.cordis.yml @@ -197,12 +197,14 @@ toolName: subagent_fork backgroundMode: continuable - # Production dsh does not install these optional providers. An opting-in - # Profile mounts each provider once on the host plane; copy this preset, - # then remove `disabled` from the matching tool row. + # External product agents. The base host plane mounts each provider, and + # each run needs that product's own CLI on PATH plus its own account: + # `codex`, `claude`, and `cursor-agent`. A missing CLI fails the call it is + # asked for rather than the composition, so a deployment without one keeps + # an inert tool row; copy this preset and add `disabled: true` to drop it + # from the model's roster entirely. - id: tool-subagent-codex name: '@deepseek-ai/dsh-tool-subagent' - disabled: true config: provider: codex toolName: subagent_codex @@ -211,13 +213,20 @@ - id: tool-subagent-claude-code name: '@deepseek-ai/dsh-tool-subagent' - disabled: true config: provider: claude-code toolName: subagent_claude_code backgroundMode: one-shot maxDepth: provider-managed + - id: tool-subagent-cursor + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: cursor + toolName: subagent_cursor + backgroundMode: one-shot + maxDepth: provider-managed + - id: workflow-worker-thread name: '@deepseek-ai/dsh-workflow-worker-thread' config: diff --git a/apps/cli/package.json b/apps/cli/package.json index 1323329b2b..18b07c7023 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -38,6 +38,8 @@ "@deepseek-ai/dsh-goal-round-driver": "workspace:^", "@deepseek-ai/dsh-cmdline": "workspace:^", "@deepseek-ai/dsh-launch-environment": "workspace:^", + "@deepseek-ai/dsh-lsp": "workspace:^", + "@deepseek-ai/dsh-lsp-stdio": "workspace:^", "@deepseek-ai/dsh-fs-local": "workspace:^", "@deepseek-ai/dsh-headless": "workspace:^", "@deepseek-ai/dsh-mcp-client": "workspace:^", @@ -56,6 +58,9 @@ "@deepseek-ai/dsh-jobs-local": "workspace:^", "@deepseek-ai/dsh-tmux-context": "workspace:^", "@deepseek-ai/dsh-token-meter": "workspace:^", + "@deepseek-ai/dsh-subagent-claude-code": "workspace:^", + "@deepseek-ai/dsh-subagent-codex": "workspace:^", + "@deepseek-ai/dsh-subagent-cursor": "workspace:^", "@deepseek-ai/dsh-tool-ask-user": "workspace:^", "@deepseek-ai/dsh-tool-bash": "workspace:^", "@deepseek-ai/dsh-tool-bash-persistent": "workspace:^", @@ -70,7 +75,10 @@ "@deepseek-ai/dsh-tool-str-replace-editor": "workspace:^", "@deepseek-ai/dsh-tool-subagent": "workspace:^", "@deepseek-ai/dsh-tool-subagent-control": "workspace:^", + "@deepseek-ai/dsh-tool-session-query": "workspace:^", + "@deepseek-ai/dsh-tool-terminal": "workspace:^", "@deepseek-ai/dsh-tool-jobs": "workspace:^", + "@deepseek-ai/dsh-tool-lsp": "workspace:^", "@deepseek-ai/dsh-tool-todo": "workspace:^", "@deepseek-ai/dsh-tool-web": "workspace:^", "@deepseek-ai/dsh-tool-workflow": "workspace:^", diff --git a/apps/cli/tests/web-agent-presets.e2e.ts b/apps/cli/tests/web-agent-presets.e2e.ts index 7b347353fd..c3dc397798 100644 --- a/apps/cli/tests/web-agent-presets.e2e.ts +++ b/apps/cli/tests/web-agent-presets.e2e.ts @@ -150,6 +150,21 @@ function enablePresetTool(composition: string, id: string): string { return composition.slice(0, disabled) + composition.slice(disabled + ' disabled: true\n'.length) } +function disablePresetTool(composition: string, id: string): string { + const row = ` - id: ${id}\n` + const start = composition.indexOf(row) + if (start < 0) throw new Error(`missing preset row ${id}`) + const end = composition.indexOf('\n - id:', start + row.length) + const disabled = composition.indexOf(' disabled: true\n', start) + if (disabled >= 0 && (end < 0 || disabled < end)) { + throw new Error(`preset row ${id} is already disabled`) + } + // `disabled` is entry metadata, so it goes beside `name` rather than inside + // the row's `config`. + const nameLine = composition.indexOf('\n', start + row.length) + 1 + return `${composition.slice(0, nameLine)} disabled: true\n${composition.slice(nameLine)}` +} + let ctx: Context beforeAll(async () => { const settingsFile = join(await mkdtemp(join(tmpdir(), 'dsh-web-presets-')), 'settings.yaml') @@ -197,7 +212,7 @@ describe('the shipped Web composition', () => { it('supplies both shipped presets, and only those, from the system root', async () => { const listed = await ctx.agentPresets.list() - expect(listed.map(preset => preset.id).sort()).toEqual(['code', 'cordis', 'minimal', 'standard']) + expect(listed.map(preset => preset.id).sort()).toEqual(['code', 'cordis', 'economy', 'maximum', 'minimal', 'standard']) expect(listed.every(preset => preset.trust === 'system')).toBe(true) expect(ctx.agentPresets.defaultId).toBe('standard') }) @@ -216,7 +231,8 @@ describe('the shipped Web composition', () => { expect(toolNames(ctx, handle.agent).filter(name => name !== 'glob' && name !== 'grep')).toEqual([ 'ask_user_question', 'bash', 'create_goal', 'edit', 'exit_plan_mode', 'get_goal', 'interrupt_agent', 'job_kill', 'job_list', 'job_output', 'list_agents', 'ralph', 'read', 'read_image', 'send_message', 'skill', - 'subagent', 'subagent_fork', 'todo_write', 'update_goal', 'web_search', + 'subagent', 'subagent_claude_code', 'subagent_codex', 'subagent_cursor', + 'subagent_fork', 'todo_write', 'update_goal', 'web_search', 'workflow', 'write', ]) } finally { @@ -224,6 +240,75 @@ describe('the shipped Web composition', () => { } }) + it('composes the economy agent without external subagent providers', async () => { + const handle = await ctx.agents.create({ + sessionId: SessionId('preset-economy'), + setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'economy').then(() => undefined), + }) + try { + const toolList = toolNames(ctx, handle.agent).filter(name => name !== 'glob' && name !== 'grep') + // The shell row is platform-gated like `standard`'s: bash off Windows, + // pwsh on it. Everything else is the standard catalog. + const shell = process.platform === 'win32' ? 'pwsh' : 'bash' + // `economy` keeps every standard tool except the external product + // agents, which it disables to avoid paid delegations by default. + expect(toolList).toEqual(expect.arrayContaining([ + 'ask_user_question', shell, 'create_goal', 'edit', 'exit_plan_mode', + 'get_goal', 'interrupt_agent', 'job_kill', 'job_list', 'job_output', 'list_agents', 'ralph', 'read', 'read_image', 'send_message', 'skill', + 'subagent', 'subagent_fork', 'todo_write', 'update_goal', 'web_search', + 'workflow', 'write', + ])) + expect(toolList).not.toEqual(expect.arrayContaining([ + 'subagent_codex', 'subagent_claude_code', 'subagent_cursor', + ])) + // The delegation tools default to foreground: one-shot, not the + // continuable background scheduling `standard` uses. + const subagent = ctx.tools.schemas(handle.agent).find(schema => schema.name === 'subagent') + expect(subagent?.description).toContain('This call waits for the result by default.') + } finally { + await handle.dispose() + } + }) + + it('composes every shipped capability and the three pipeline roles from `maximum`', async () => { + const handle = await ctx.agents.create({ + sessionId: SessionId('preset-maximum'), + setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'maximum').then(() => undefined), + }) + try { + // The shell row is platform-gated like `standard`'s: bash off Windows, + // pwsh on it. The registry answers in name order, so the gated name is + // sorted in rather than written at a fixed position. + const shell = process.platform === 'win32' ? 'pwsh' : 'bash' + // The EXACT catalog, for the reason `standard`'s assertion states: a row + // that registers into the wrong layer mounts cleanly and contributes + // nothing, and this preset exists to carry every row at once. + expect(toolNames(ctx, handle.agent).filter(name => name !== 'glob' && name !== 'grep')).toEqual([ + 'ask_user_question', shell, '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', 'interrupt_agent', 'job_kill', 'job_list', 'job_output', + 'list_agents', 'lsp', 'ralph', 'read', 'read_image', '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', 'subagent_architect', 'subagent_claude_code', + 'subagent_codex', 'subagent_cursor', 'subagent_fork', 'subagent_implementer', + 'subagent_reviewer', 'terminal_close', 'terminal_list', 'terminal_open', 'terminal_read', + 'terminal_send', 'terminal_signal', 'todo_write', 'update_goal', 'web_search', 'workflow', + 'write', + ].sort()) + // Each role is one `spawn` instance carrying its own child persona, so + // the roles are distinguishable only by what their descriptions and + // personas say — the wire schemas are otherwise identical. + const schemas = ctx.tools.schemas(handle.agent) + for (const role of ['subagent_architect', 'subagent_implementer', 'subagent_reviewer']) { + expect(schemas.find(schema => schema.name === role)?.description) + .toContain('This call waits for the result by default.') + } + } finally { + await handle.dispose() + } + }) + it('composes the exact RL prompt and two tools from `minimal`', async () => { const handle = await ctx.agents.create({ sessionId: SessionId('preset-minimal'), @@ -436,7 +521,7 @@ describe('the shipped Web composition', () => { describe('product subagent rows in user presets', () => { let productCtx: Context - const ids = ['products-none', 'products-codex', 'products-claude', 'products-both'] as const + const ids = ['products-none', 'products-codex', 'products-claude', 'products-all'] as const beforeAll(async () => { const root = await mkdtemp(join(tmpdir(), 'dsh-product-presets-')) @@ -444,23 +529,26 @@ describe('product subagent rows in user presets', () => { const settingsFile = join(root, 'settings.yaml') const standard = await readFile(join(CONFIG_DIR, 'agent-presets', 'standard', 'agent.cordis.yml'), 'utf8') await writeFile(settingsFile, '{}\n') + // `standard` ships every product row enabled, so each variant is built by + // REMOVING the rows it must not carry. for (const id of ids) { let composition = standard - if (id === 'products-codex' || id === 'products-both') { - composition = enablePresetTool(composition, 'tool-subagent-codex') + if (id !== 'products-all' && id !== 'products-codex') { + composition = disablePresetTool(composition, 'tool-subagent-codex') } - if (id === 'products-claude' || id === 'products-both') { - composition = enablePresetTool(composition, 'tool-subagent-claude-code') + if (id !== 'products-all' && id !== 'products-claude') { + composition = disablePresetTool(composition, 'tool-subagent-claude-code') + } + if (id !== 'products-all') { + composition = disablePresetTool(composition, 'tool-subagent-cursor') } const directory = join(userRoot, id) await mkdir(directory, { recursive: true }) await writeFile(join(directory, 'agent.cordis.yml'), composition) } + // The base patch already mounts every product provider on the host plane, + // so this boot adds only the preset roster. productCtx = await bootWeb(settingsFile, [ - { insert: [ - { id: 'subagent-codex', name: '@deepseek-ai/dsh-subagent-codex' }, - { id: 'subagent-claude-code', name: '@deepseek-ai/dsh-subagent-claude-code' }, - ] }, { id: 'agent-presets', config: { @@ -479,15 +567,15 @@ describe('product subagent rows in user presets', () => { await productCtx.fiber.dispose() }) - it('composes none, either product, or both without changing the shared host registry', async () => { + it('composes none, one product, or every product without changing the shared host registry', async () => { const expected = new Map([ ['products-none', []], ['products-codex', ['subagent_codex']], ['products-claude', ['subagent_claude_code']], - ['products-both', ['subagent_claude_code', 'subagent_codex']], + ['products-all', ['subagent_claude_code', 'subagent_codex', 'subagent_cursor']], ]) expect(productCtx.subagents.list()).toEqual(expect.arrayContaining([ - 'spawn', 'fork', 'codex', 'claude-code', + 'spawn', 'fork', 'codex', 'claude-code', 'cursor', ])) for (const [id, productTools] of expected) { @@ -497,7 +585,7 @@ describe('product subagent rows in user presets', () => { }) try { const tools = toolNames(productCtx, handle.agent) - expect(tools.filter(name => name === 'subagent_codex' || name === 'subagent_claude_code')) + expect(tools.filter(name => name.startsWith('subagent_') && name !== 'subagent_fork')) .toEqual(productTools) expect(tools).toEqual(expect.arrayContaining(['job_kill', 'job_list', 'job_output'])) for (const productTool of productTools) { diff --git a/apps/web/tests/snapshots/agent-preset-authoring/created.expected.md b/apps/web/tests/snapshots/agent-preset-authoring/created.expected.md index d82f48d8c1..a799499c6f 100644 --- a/apps/web/tests/snapshots/agent-preset-authoring/created.expected.md +++ b/apps/web/tests/snapshots/agent-preset-authoring/created.expected.md @@ -61,6 +61,26 @@ - 'button "复制: 创造模式"': - img - text: 复制 + - listitem: + - 'button "设为默认: Экономный режим"': + - text: Экономный режим 内置 Оптимизированный режим для экономии токенов и затрат на API: дешёвые операции выполняются в основном контексте, делегирование субагентам — только когда оно окупается, результаты инструментов активно сжимаются. + - code: economy + - 'button "查看: Экономный режим"': + - img + - text: 查看 + - 'button "复制: Экономный режим"': + - img + - text: 复制 + - listitem: + - 'button "设为默认: 全能模式"': + - text: 全能模式 内置 组合本部署能提供的全部能力 —— 终端、语言服务器、会话历史、MCP、外部 Agent,以及与原生工具并存的 Code Mode —— 并由架构师、实现者、审查者三个角色接力交付每一次改动。 + - code: maximum + - 'button "查看: 全能模式"': + - img + - text: 查看 + - 'button "复制: 全能模式"': + - img + - text: 复制 - heading "自定义" [level=3] - list: - listitem: diff --git a/apps/web/tests/snapshots/agent-preset-authoring/damaged.expected.md b/apps/web/tests/snapshots/agent-preset-authoring/damaged.expected.md index 0869bd7f3f..3fdf3257ae 100644 --- a/apps/web/tests/snapshots/agent-preset-authoring/damaged.expected.md +++ b/apps/web/tests/snapshots/agent-preset-authoring/damaged.expected.md @@ -61,6 +61,26 @@ - 'button "复制: 创造模式"': - img - text: 复制 + - listitem: + - 'button "设为默认: Экономный режим"': + - text: Экономный режим 内置 Оптимизированный режим для экономии токенов и затрат на API: дешёвые операции выполняются в основном контексте, делегирование субагентам — только когда оно окупается, результаты инструментов активно сжимаются. + - code: economy + - 'button "查看: Экономный режим"': + - img + - text: 查看 + - 'button "复制: Экономный режим"': + - img + - text: 复制 + - listitem: + - 'button "设为默认: 全能模式"': + - text: 全能模式 内置 组合本部署能提供的全部能力 —— 终端、语言服务器、会话历史、MCP、外部 Agent,以及与原生工具并存的 Code Mode —— 并由架构师、实现者、审查者三个角色接力交付每一次改动。 + - code: maximum + - 'button "查看: 全能模式"': + - img + - text: 查看 + - 'button "复制: 全能模式"': + - img + - text: 复制 - heading "自定义" [level=3] - list: - listitem: diff --git a/apps/web/tests/snapshots/agent-preset-authoring/section.expected.md b/apps/web/tests/snapshots/agent-preset-authoring/section.expected.md index c8d983cb34..0fce5e9e58 100644 --- a/apps/web/tests/snapshots/agent-preset-authoring/section.expected.md +++ b/apps/web/tests/snapshots/agent-preset-authoring/section.expected.md @@ -61,6 +61,26 @@ - 'button "复制: 创造模式"': - img - text: 复制 + - listitem: + - 'button "设为默认: Экономный режим"': + - text: Экономный режим 内置 Оптимизированный режим для экономии токенов и затрат на API: дешёвые операции выполняются в основном контексте, делегирование субагентам — только когда оно окупается, результаты инструментов активно сжимаются. + - code: economy + - 'button "查看: Экономный режим"': + - img + - text: 查看 + - 'button "复制: Экономный режим"': + - img + - text: 复制 + - listitem: + - 'button "设为默认: 全能模式"': + - text: 全能模式 内置 组合本部署能提供的全部能力 —— 终端、语言服务器、会话历史、MCP、外部 Agent,以及与原生工具并存的 Code Mode —— 并由架构师、实现者、审查者三个角色接力交付每一次改动。 + - code: maximum + - 'button "查看: 全能模式"': + - img + - text: 查看 + - 'button "复制: 全能模式"': + - img + - text: 复制 - heading "自定义" [level=3] - button "用「创造模式」创作自定义预设": - img diff --git a/apps/web/tests/snapshots/agent-preset-selection/menu.expected.md b/apps/web/tests/snapshots/agent-preset-selection/menu.expected.md index 9e3116d353..d6be748d2b 100644 --- a/apps/web/tests/snapshots/agent-preset-selection/menu.expected.md +++ b/apps/web/tests/snapshots/agent-preset-selection/menu.expected.md @@ -5,3 +5,5 @@ - menuitem "PTC mode All Standard mode capabilities, with tools exposed through the Code Mode SDK so the model can combine multi-step operations in one TypeScript program." - menuitem "Minimal mode Two-tool coding agent with persistent bash and str_replace_editor." - menuitem "Creator mode Built for creating custom agent presets, with all Standard mode capabilities plus runtime inspection, plugin experiments, and preset-authoring guidance." + - menuitem "Экономный режим Оптимизированный режим для экономии токенов и затрат на API: дешёвые операции выполняются в основном контексте, делегирование субагентам — только когда оно окупается, результаты инструментов активно сжимаются." + - menuitem "Maximum mode Every capability this deployment can compose — terminals, language servers, session history, MCP, external agents, and Code Mode beside the native tools — delivering each change through an architect, an implementer, and a reviewer." diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 695afa5151..01fe1a5799 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: 040deb80fe232dc61bba1b2041a541d33516973f -config-catalog.zh.md: e0166072b66d4a940c5242f23974a602a5658f1a +config-catalog.md: 2c450518537d9cb318efa449fabbfba82eeda1cd +config-catalog.zh.md: 365c443a9f36a45309e0196e821ecb8e5bea974d diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 040deb80fe..2c45051853 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1671,7 +1671,7 @@ export interface Config { } ``` -Source: [`packages/session/session-projection-cache/src/index.ts:42`](../packages/session/session-projection-cache/src/index.ts) +Source: [`packages/session/session-projection-cache/src/index.ts:43`](../packages/session/session-projection-cache/src/index.ts) @@ -2138,6 +2138,39 @@ export interface Config { Source: [`packages/subagent/subagent-codex/src/index.ts:30`](../packages/subagent/subagent-codex/src/index.ts) + + +## `@deepseek-ai/dsh-subagent-cursor` + +Requires: `subagents` · `subprocess` + +```ts config-catalog +/** Deployment-owned environment, permissions, and process-release bound. */ +export interface Config { + /** + * Explicit environment entries layered over the subprocess seam's + * credential-scrubbed parent environment. `CURSOR_API_KEY` belongs here + * rather than on the command line, where a process listing would expose it. + */ + env?: Record + /** Grace in milliseconds for `cursor-agent` process-tree termination. */ + disposeGraceMs?: number + /** + * Whether the child may apply file changes and run commands (`--force`). + * Cursor's own print-mode default only PROPOSES changes, so a delegation + * expected to edit the workspace needs this on. + */ + force?: boolean + /** + * Whether the child may act in the workspace without Cursor's interactive + * trust prompt (`--trust`). An unattended child cannot answer that prompt. + */ + trust?: boolean +} +``` + +Source: [`packages/subagent/subagent-cursor/src/index.ts:35`](../packages/subagent/subagent-cursor/src/index.ts) + ## `@deepseek-ai/dsh-subagent-dsh-sdk` diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index e0166072b6..365c443a9f 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -2140,6 +2140,39 @@ export interface Config { 来源:[`packages/subagent/subagent-codex/src/index.ts:30`](../packages/subagent/subagent-codex/src/index.ts) + + +## `@deepseek-ai/dsh-subagent-cursor` + +需要:`subagents` · `subprocess` + +```ts config-catalog +/** Deployment-owned environment, permissions, and process-release bound. */ +export interface Config { + /** + * Explicit environment entries layered over the subprocess seam's + * credential-scrubbed parent environment. `CURSOR_API_KEY` belongs here + * rather than on the command line, where a process listing would expose it. + */ + env?: Record + /** Grace in milliseconds for `cursor-agent` process-tree termination. */ + disposeGraceMs?: number + /** + * Whether the child may apply file changes and run commands (`--force`). + * Cursor's own print-mode default only PROPOSES changes, so a delegation + * expected to edit the workspace needs this on. + */ + force?: boolean + /** + * Whether the child may act in the workspace without Cursor's interactive + * trust prompt (`--trust`). An unattended child cannot answer that prompt. + */ + trust?: boolean +} +``` + +来源:[`packages/subagent/subagent-cursor/src/index.ts:35`](../packages/subagent/subagent-cursor/src/index.ts) + ## `@deepseek-ai/dsh-subagent-dsh-sdk` diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index e6a315823c..90471f60b0 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: 1a09c6c39dd0710b490ec23ae76b928cd6edf8d6 -module-graph.zh.md: 811ee0899068ce475df8b6b168513f60a492d56f +module-graph.md: 690c680fd3dc64f3313274c27bbb622fe4ab4fa6 +module-graph.zh.md: fbb3363d753c1c1419ad120f053c77c94cc64cc1 diff --git a/docs/module-graph.md b/docs/module-graph.md index 1a09c6c39d..690c680fd3 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -60,6 +60,7 @@ flowchart TD pkg_subagent_acp["subagent-acp"] pkg_subagent_claude_code["subagent-claude-code"] pkg_subagent_codex["subagent-codex"] + pkg_subagent_cursor["subagent-cursor"] pkg_subagent_dsh_sdk["subagent-dsh-sdk"] pkg_subagent_fork_in_process["subagent-fork-in-process"] pkg_subagent_in_process_driver["subagent-in-process-driver"] @@ -978,6 +979,12 @@ flowchart TD pkg_subagent_claude_code --> pkg_subagent pkg_subagent_claude_code --> pkg_subprocess pkg_subagent_claude_code --> pkg_timeout + pkg_subagent_cursor --> pkg_invariants + pkg_subagent_cursor --> pkg_llm + pkg_subagent_cursor --> pkg_session + pkg_subagent_cursor --> pkg_subagent + pkg_subagent_cursor --> pkg_subprocess + pkg_subagent_cursor --> pkg_timeout pkg_subagent_in_process_driver --> pkg_agent pkg_subagent_in_process_driver --> pkg_invariants pkg_subagent_in_process_driver --> pkg_llm @@ -1232,6 +1239,7 @@ flowchart TD pkg_client_ui_trajectory --> pkg_client_ui_primitives pkg_client_ui_trajectory --> pkg_compaction pkg_client_ui_trajectory --> pkg_invariants + pkg_client_ui_trajectory --> pkg_openrouter_usage pkg_client_ui_trajectory --> pkg_tools pkg_client_ui_user_questions --> pkg_api_remotes pkg_client_ui_user_questions --> pkg_client_locale @@ -1606,6 +1614,7 @@ flowchart TD | [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`subagent-claude-code`](../packages/subagent/subagent-claude-code) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | +| [`subagent-cursor`](../packages/subagent/subagent-cursor) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | @@ -1640,7 +1649,7 @@ flowchart TD | [`client-ui-settings-plugins`](../packages/client/ui-settings-plugins) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`agent`](../packages/core/agent), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`tools`](../packages/core/tools) | +| [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`agent`](../packages/core/agent), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`openrouter-usage`](../packages/llm/openrouter-usage), [`tools`](../packages/core/tools) | | [`client-ui-user-questions`](../packages/client/ui-user-questions) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`agent`](../packages/core/agent), [`api-remotes`](../packages/api/remotes), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-attachment`](../packages/client/ui-attachment), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm-retry`](../packages/llm/llm-retry), [`session-stats`](../packages/session/session-stats), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 811ee08990..fbb3363d75 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -62,6 +62,7 @@ flowchart TD pkg_subagent_acp["subagent-acp"] pkg_subagent_claude_code["subagent-claude-code"] pkg_subagent_codex["subagent-codex"] + pkg_subagent_cursor["subagent-cursor"] pkg_subagent_dsh_sdk["subagent-dsh-sdk"] pkg_subagent_fork_in_process["subagent-fork-in-process"] pkg_subagent_in_process_driver["subagent-in-process-driver"] @@ -980,6 +981,12 @@ flowchart TD pkg_subagent_claude_code --> pkg_subagent pkg_subagent_claude_code --> pkg_subprocess pkg_subagent_claude_code --> pkg_timeout + pkg_subagent_cursor --> pkg_invariants + pkg_subagent_cursor --> pkg_llm + pkg_subagent_cursor --> pkg_session + pkg_subagent_cursor --> pkg_subagent + pkg_subagent_cursor --> pkg_subprocess + pkg_subagent_cursor --> pkg_timeout pkg_subagent_in_process_driver --> pkg_agent pkg_subagent_in_process_driver --> pkg_invariants pkg_subagent_in_process_driver --> pkg_llm @@ -1234,6 +1241,7 @@ flowchart TD pkg_client_ui_trajectory --> pkg_client_ui_primitives pkg_client_ui_trajectory --> pkg_compaction pkg_client_ui_trajectory --> pkg_invariants + pkg_client_ui_trajectory --> pkg_openrouter_usage pkg_client_ui_trajectory --> pkg_tools pkg_client_ui_user_questions --> pkg_api_remotes pkg_client_ui_user_questions --> pkg_client_locale @@ -1608,6 +1616,7 @@ flowchart TD | [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`subagent-claude-code`](../packages/subagent/subagent-claude-code) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | +| [`subagent-cursor`](../packages/subagent/subagent-cursor) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | @@ -1642,7 +1651,7 @@ flowchart TD | [`client-ui-settings-plugins`](../packages/client/ui-settings-plugins) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`agent`](../packages/core/agent), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`tools`](../packages/core/tools) | +| [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`agent`](../packages/core/agent), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`openrouter-usage`](../packages/llm/openrouter-usage), [`tools`](../packages/core/tools) | | [`client-ui-user-questions`](../packages/client/ui-user-questions) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`agent`](../packages/core/agent), [`api-remotes`](../packages/api/remotes), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-attachment`](../packages/client/ui-attachment), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm-retry`](../packages/llm/llm-retry), [`session-stats`](../packages/session/session-stats), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools) | diff --git a/docs/subsystems/subagent.i18n.yaml b/docs/subsystems/subagent.i18n.yaml index a86dc8de4a..892aa5e6e5 100644 --- a/docs/subsystems/subagent.i18n.yaml +++ b/docs/subsystems/subagent.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/subagent.md -subagent.md: a683a679e6017351540ee4b73adc74375ef0a1d6 -subagent.zh.md: 61391cd297c0eb14f4c0d8eac4539b551cb60bda +subagent.md: 2e504fbeccb917328a7675e13c205fca4c54f3d0 +subagent.zh.md: e4ed14ce53f22824740c3457e5f84045dfc92050 diff --git a/docs/subsystems/subagent.md b/docs/subsystems/subagent.md index a683a679e6..2e504fbecc 100644 --- a/docs/subsystems/subagent.md +++ b/docs/subsystems/subagent.md @@ -4,7 +4,7 @@ English | [中文](subagent.zh.md) The subagent seam lets an agent delegate work to a child agent. Like [bash](shell.md), it is **one optional capability**, not part of the agent loop, so its types live here rather than in [core.md](core.md). It differs from the other capability seams because **multiple provider implementations coexist** in one context, registered by name (`ctx.subagents`), while bash allows only one executor. Its registry follows the [LLM adapter registry](llm-streaming.md), not the single-service bash executor. -Service Definition: [dsh-subagent](../../packages/subagent/subagent) (`ctx.subagents` + the vocabulary below). Service Providers are sibling packages (`dsh-subagent-spawn-in-process`, `-fork`, `-acp`, `-codex`, `-claude-code`, `-dsh-sdk`); the model-facing Consumers are [dsh-tool-subagent](../../packages/subagent/tool-subagent) (per-provider delegation), [dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control) (the optional global `send_message`, `interrupt_agent`, and `list_agents` controls), and [dsh-tool-subagent-report](../../packages/subagent/tool-subagent-report) (the optional child-scoped `report` return channel). The same `ctx.subagents` service owns continuable-child orchestration through an internal activation manager and read-only child and descendant discovery straight from the session store and optional session persistence. Product-provider rationale lives in [the Codex and Claude Code Agent Note](../../.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md); common-seam rationale lives in [the subagent Agent Note](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md), [the continuable subagents Agent Note](../../.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md), [the report-tool Agent Note](../../.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.md), [the durable catalog Agent Note](../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md), [the list-identity-projection Agent Note](../../.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.md), and [the merged-service Agent Note](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md). +Service Definition: [dsh-subagent](../../packages/subagent/subagent) (`ctx.subagents` + the vocabulary below). Service Providers are sibling packages (`dsh-subagent-spawn-in-process`, `-fork`, `-acp`, `-codex`, `-claude-code`, `-cursor`, `-dsh-sdk`); the model-facing Consumers are [dsh-tool-subagent](../../packages/subagent/tool-subagent) (per-provider delegation), [dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control) (the optional global `send_message`, `interrupt_agent`, and `list_agents` controls), and [dsh-tool-subagent-report](../../packages/subagent/tool-subagent-report) (the optional child-scoped `report` return channel). The same `ctx.subagents` service owns continuable-child orchestration through an internal activation manager and read-only child and descendant discovery straight from the session store and optional session persistence. Product-provider rationale lives in [the Codex and Claude Code Agent Note](../../.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md) and [the Cursor Agent Note](../../.agents/notes/implemented/feature/2026-08-22-cursor-external-agent-provider.md); common-seam rationale lives in [the subagent Agent Note](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md), [the continuable subagents Agent Note](../../.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md), [the report-tool Agent Note](../../.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.md), [the durable catalog Agent Note](../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md), [the list-identity-projection Agent Note](../../.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.md), and [the merged-service Agent Note](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md). Sources: [`packages/subagent/subagent/src/types.ts`](../../packages/subagent/subagent/src/types.ts), [`packages/subagent/subagent/src/index.ts`](../../packages/subagent/subagent/src/index.ts), and [`packages/subagent/subagent/src/continuation.ts`](../../packages/subagent/subagent/src/continuation.ts) diff --git a/docs/subsystems/subagent.zh.md b/docs/subsystems/subagent.zh.md index 61391cd297..e4ed14ce53 100644 --- a/docs/subsystems/subagent.zh.md +++ b/docs/subsystems/subagent.zh.md @@ -4,7 +4,7 @@ subagent seam 让一个 agent(智能体)将工作委派给子 agent。与 [bash](shell.md) 一样,它是**一项可选能力**,不属于 agent loop(智能体循环),因此其类型定义在此而非 [core.md](core.md) 中。它不同于其他能力 seam,因为**同一上下文中可共存多个提供方实现**,并按名称注册(`ctx.subagents`),而 bash 只允许一个执行器。该注册表遵循 [LLM(大语言模型)适配器注册表](llm-streaming.md),而非单服务的 bash 执行器。 -Service Definition:[dsh-subagent](../../packages/subagent/subagent)(`ctx.subagents` + 下文词汇)。Service Provider 是六个兄弟包:`dsh-subagent-spawn-in-process`、`-fork`、`-acp`、`-codex`、`-claude-code`、`-dsh-sdk`;面向模型的 Consumer 包括 [dsh-tool-subagent](../../packages/subagent/tool-subagent)(按提供方委派)、[dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control)(可选的全局 `send_message`、`interrupt_agent` 与 `list_agents` 控制工具)和 [dsh-tool-subagent-report](../../packages/subagent/tool-subagent-report)(可选的 child 作用域 `report` 返回通道)。同一个 `ctx.subagents` 服务通过内部激活管理器负责可继续子 agent 编排,并直接基于会话存储和可选的会话持久化提供只读的 child 与后代发现。产品提供方设计理由见 [Codex 与 Claude Code Agent Note](../../.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md);通用 seam 的设计理由见 [subagent Agent Note](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)、[可继续 subagent Agent Note](../../.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md)、[report 工具 Agent Note](../../.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.md)、[持久化目录 Agent Note](../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md)、[列表身份投影 Agent Note](../../.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.md)和[服务合并 Agent Note](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)。 +Service Definition:[dsh-subagent](../../packages/subagent/subagent)(`ctx.subagents` + 下文词汇)。Service Provider 是七个兄弟包:`dsh-subagent-spawn-in-process`、`-fork`、`-acp`、`-codex`、`-claude-code`、`-cursor`、`-dsh-sdk`;面向模型的 Consumer 包括 [dsh-tool-subagent](../../packages/subagent/tool-subagent)(按提供方委派)、[dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control)(可选的全局 `send_message`、`interrupt_agent` 与 `list_agents` 控制工具)和 [dsh-tool-subagent-report](../../packages/subagent/tool-subagent-report)(可选的 child 作用域 `report` 返回通道)。同一个 `ctx.subagents` 服务通过内部激活管理器负责可继续子 agent 编排,并直接基于会话存储和可选的会话持久化提供只读的 child 与后代发现。产品提供方设计理由见 [Codex 与 Claude Code Agent Note](../../.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md) 与 [Cursor Agent Note](../../.agents/notes/implemented/feature/2026-08-22-cursor-external-agent-provider.md);通用 seam 的设计理由见 [subagent Agent Note](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)、[可继续 subagent Agent Note](../../.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md)、[report 工具 Agent Note](../../.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.md)、[持久化目录 Agent Note](../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md)、[列表身份投影 Agent Note](../../.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.md)和[服务合并 Agent Note](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)。 源码:[`packages/subagent/subagent/src/types.ts`](../../packages/subagent/subagent/src/types.ts)、[`packages/subagent/subagent/src/index.ts`](../../packages/subagent/subagent/src/index.ts)和 [`packages/subagent/subagent/src/continuation.ts`](../../packages/subagent/subagent/src/continuation.ts) diff --git a/examples/acp-agent/product-subagent-cursor.cordis.snapshot.yml b/examples/acp-agent/product-subagent-cursor.cordis.snapshot.yml new file mode 100644 index 0000000000..020e86d652 --- /dev/null +++ b/examples/acp-agent/product-subagent-cursor.cordis.snapshot.yml @@ -0,0 +1,29 @@ +# Keyless twin of product-subagent-cursor.cordis.yml: keep the same product +# provider/tool composition and replace only the external model 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 + - insert: + - id: llm-replay + name: '@deepseek-ai/dsh-llm-replay' + config: + providers: + - id: deepseek-official + name: DeepSeek + models: + - id: deepseek-v4-flash + - id: deepseek-v4-pro + - id: subagent-cursor + name: '@deepseek-ai/dsh-subagent-cursor' + - id: tool-subagent-cursor + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: cursor + toolName: subagent_cursor + backgroundMode: one-shot + maxDepth: provider-managed diff --git a/examples/acp-agent/product-subagent-cursor.cordis.yml b/examples/acp-agent/product-subagent-cursor.cordis.yml new file mode 100644 index 0000000000..a4a25a6976 --- /dev/null +++ b/examples/acp-agent/product-subagent-cursor.cordis.yml @@ -0,0 +1,18 @@ +# Add the native Cursor product provider and its preset-shaped one-shot tool to +# the real ACP composition. The model is told not to call it; the scenario pins +# the assembled request schema without starting cursor-agent. +- id: base + name: '@deepseek-ai/cordis-plugin-include' + config: + path: ./cordis.yml + patches: + - insert: + - id: subagent-cursor + name: '@deepseek-ai/dsh-subagent-cursor' + - id: tool-subagent-cursor + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: cursor + toolName: subagent_cursor + backgroundMode: one-shot + maxDepth: provider-managed diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index 173a903e1d..2a3eaaa196 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -65,6 +65,7 @@ const BACKGROUND_TASK_ADMISSION_CONFIG = fileURLToPath( new URL('../background-job-admission.cordis.yml', import.meta.url), ) const PRODUCT_SUBAGENT_CODEX_CONFIG = fileURLToPath(new URL('../product-subagent-codex.cordis.yml', import.meta.url)) +const PRODUCT_SUBAGENT_CURSOR_CONFIG = fileURLToPath(new URL('../product-subagent-cursor.cordis.yml', import.meta.url)) const PRODUCT_SUBAGENT_BOTH_CONFIG = fileURLToPath(new URL('../product-subagent-both.cordis.yml', import.meta.url)) const FS_DIFF_BOUND_CONFIG = fileURLToPath(new URL('./fs-diff-bound.cordis.yml', import.meta.url)) const SNAPSHOTS_DIR = join(dirname(fileURLToPath(import.meta.url)), 'snapshots') @@ -149,6 +150,15 @@ const SCENARIOS: Scenario[] = [ headerClass: 'product-subagent-codex', configPath: PRODUCT_SUBAGENT_CODEX_CONFIG, }, + { + name: 'product-subagent-cursor', + hasModelTurn: true, + recorded: false, + pinsHeader: true, + headerClass: 'product-subagent-cursor', + systemPromptSource: 'product-subagent-codex', + configPath: PRODUCT_SUBAGENT_CURSOR_CONFIG, + }, { name: 'product-subagent-both', hasModelTurn: true, diff --git a/examples/acp-agent/tests/fixtures/subagent/subagent-cursor/cordis.yml b/examples/acp-agent/tests/fixtures/subagent/subagent-cursor/cordis.yml new file mode 100644 index 0000000000..be2f899078 --- /dev/null +++ b/examples/acp-agent/tests/fixtures/subagent/subagent-cursor/cordis.yml @@ -0,0 +1,40 @@ +# Test-only composition of the public opt-in provider and one-shot task tool. +# The owning e2e boots this tree but never invokes the model or Cursor. +- id: fixture + name: './fixture.ts' + +- id: subagent + name: '@deepseek-ai/dsh-subagent' + +- id: subprocess + name: '@deepseek-ai/dsh-subprocess-local' + +- id: subagent-cursor + name: '@deepseek-ai/dsh-subagent-cursor' + +- id: tool-subagent-cursor + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: cursor + toolName: subagent_cursor + backgroundMode: one-shot + maxDepth: 'provider-managed' + +- id: agent-spine + name: '@deepseek-ai/dsh-agent-spine-demo' + config: + agents: + - id: main + provider: mock + model: mock-delegate + cwd: !!js process.cwd() + persona: 'This composition test must not start a model turn.' + workspaceContext: false + +- id: persistence + name: '@deepseek-ai/dsh-session-persistence-jsonl' + config: + root: './.sessions' + +- id: checkpoint-policy + name: '@deepseek-ai/dsh-session-checkpoint-policy' diff --git a/examples/acp-agent/tests/fixtures/subagent/subagent-cursor/driver.ts b/examples/acp-agent/tests/fixtures/subagent/subagent-cursor/driver.ts new file mode 100644 index 0000000000..9d570514b8 --- /dev/null +++ b/examples/acp-agent/tests/fixtures/subagent/subagent-cursor/driver.ts @@ -0,0 +1,56 @@ +#!/usr/bin/env node +/** Inspect the public Cursor provider composition without invoking the product. */ + +import { boot, resolveConfigPath } from '@deepseek-ai/dsh-app-boot' +import type {} from '@deepseek-ai/dsh-subagent' +import type {} from '@deepseek-ai/dsh-tools' + +const configPath = process.argv[2] +if (configPath === undefined) { + throw new Error('subagent-cursor Loader composition driver requires a config path') +} + +let starts = 0 +const ctx = await boot( + 'subagent-cursor-loader-composition', + resolveConfigPath(configPath, undefined), + undefined, + (hostCtx) => { + hostCtx.on('subagent/start', () => { + starts += 1 + }) + }, +) + +try { + const provider = ctx.subagents.getProvider('cursor') + if (provider === undefined) throw new Error('Cursor provider was not registered') + const tool = ctx.tools.schemas().find(schema => schema.name === 'subagent_cursor') + if (tool === undefined) throw new Error('subagent_cursor tool was not registered') + const properties = tool.parameters.properties + if (typeof properties !== 'object' || properties === null || Array.isArray(properties)) { + throw new Error('subagent_cursor tool has invalid parameter properties') + } + const jobTools = ctx.tools.schemas() + .map(schema => schema.name) + .filter(name => name === 'job_kill' || name === 'job_list' || name === 'job_output') + .sort() + + process.stdout.write(`${JSON.stringify({ + providers: ctx.subagents.list(), + provider: { + name: provider.name, + capabilities: provider.capabilities, + inheritsParentContext: provider.inheritsParentContext, + }, + tool: { + name: tool.name, + parameterNames: Object.keys(properties).sort(), + required: tool.parameters.required, + }, + jobTools, + starts, + })}\n`) +} finally { + await ctx.fiber.dispose() +} diff --git a/examples/acp-agent/tests/fixtures/subagent/subagent-cursor/fixture.ts b/examples/acp-agent/tests/fixtures/subagent/subagent-cursor/fixture.ts new file mode 100644 index 0000000000..07758df6ee --- /dev/null +++ b/examples/acp-agent/tests/fixtures/subagent/subagent-cursor/fixture.ts @@ -0,0 +1,22 @@ +/** Parent adapter that fails if the composition-only Loader test starts a turn. */ + +import type { Context } from '@deepseek-ai/cordis' +import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' +import { LlmAdapter } from '@deepseek-ai/dsh-llm' + +class CompositionOnlyAdapter extends LlmAdapter { + async * stream(_options: GenerateOptions): AsyncIterable { + throw new Error('subagent-cursor Loader composition must not invoke a model') + } +} + +export const name = 'cursor-loader-composition-fixture' +export const inject = ['llm'] + +/** + * Register a parent adapter solely so the host composition is complete. + * @param ctx - Loader context supplying the LLM seam. + */ +export function apply(ctx: Context): void { + ctx.llm.registerAdapter(['mock'], new CompositionOnlyAdapter()) +} diff --git a/examples/acp-agent/tests/snapshots/product-subagent-cursor/input.json b/examples/acp-agent/tests/snapshots/product-subagent-cursor/input.json new file mode 100644 index 0000000000..5fe0259a4e --- /dev/null +++ b/examples/acp-agent/tests/snapshots/product-subagent-cursor/input.json @@ -0,0 +1,7 @@ +{ + "steps": [ + { "op": "initialize" }, + { "op": "newSession" }, + { "op": "prompt", "text": "Reply with exactly the word: PONG. Do not use any tools." } + ] +} diff --git a/examples/acp-agent/tests/snapshots/product-subagent-cursor/session.jsonl b/examples/acp-agent/tests/snapshots/product-subagent-cursor/session.jsonl new file mode 100644 index 0000000000..bd47dfa24f --- /dev/null +++ b/examples/acp-agent/tests/snapshots/product-subagent-cursor/session.jsonl @@ -0,0 +1,22 @@ +{"type":"session","version":0,"id":"539aa64c-7f37-40ff-abd8-ed45b717be1b","createdAt":1783600629539,"cwd":"{{cwd}}","delegationDepth":0} +{"type":"agent/inbox/spliced","seq":0,"time":1785498761270,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word: PONG. Do not use any tools."}],"source":{"kind":"user"},"role":"user","id":"3e25dc34-48e0-4738-8401-1a8d181d37e5"}]}} +{"type":"turn/start","seq":1,"time":1785821359466,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":1785821359466,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":3,"time":1783600629542,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":4,"time":1785498761313,"data":{"content":[{"type":"text","text":"Reply with exactly the word: PONG. Do not use any tools."}],"source":{"kind":"user"},"role":"user","id":"3e25dc34-48e0-4738-8401-1a8d181d37e5"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1785730415287,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"4b8d9730-0b7b-4e14-8a30-3d852f808f0e"},"surfaceOp":"append"} +{"type":"session/title","seq":6,"time":1785730415287,"data":{"title":"Reply with exactly the word:","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":7,"time":1785498761318,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-pro"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":8,"time":1785730415288,"data":{"provider":"deepseek-official","model":"deepseek-v4-pro"}} +{"type":"assistant/chunk","seq":9,"time":1783600630822,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":10,"time0":1783600630852,"data":{"turn":1,"step":1,"index":0,"dt":[0,0,0,33,1,40,0,0,0,0,0,18,0,36,0,0,0,0,0],"texts":["The"," user"," wants"," me"," to"," reply"," with"," exactly"," the"," word"," \"","P","ONG","\""," and"," not"," use"," any"," tools","."]}} +{"type":"assistant/chunk","seq":30,"time":1783600631006,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":31,"time":1783600631008,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"P"}}} +{"type":"assistant/chunk","seq":32,"time":1783600631009,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"ONG"}}} +{"type":"assistant/chunk","seq":33,"time":1783600631009,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly the word \"PONG\" and not use any tools."}}}} +{"type":"assistant/chunk","seq":34,"time":1783600631009,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"PONG"}}}} +{"type":"assistant/chunk","seq":35,"time":1785498761338,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3091,"outputTokens":23,"cacheReadTokens":0,"reasoningTokens":20}}}} +{"type":"assistant/chunk","seq":36,"time":1785730415297,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":37,"time":1785730415298,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to reply with exactly the word \"PONG\" and not use any tools."},{"type":"text","text":"PONG"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"c883cf16-01fe-4afc-b37c-d255bb450d21"},"usage":{"inputTokens":3091,"outputTokens":23,"cacheReadTokens":0,"reasoningTokens":20}},"sourceEventSeqs":[9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36],"surfaceOp":"append"} +{"type":"step/end","seq":38,"time":1785730415298,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":39,"time":1785730415298,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/product-subagent-cursor/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/product-subagent-cursor/stdout.expected.jsonl new file mode 100644 index 0000000000..acfccdd778 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/product-subagent-cursor/stdout.expected.jsonl @@ -0,0 +1,4 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"PONG"}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/product-subagent-cursor/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/product-subagent-cursor/tool-schemas.expected.json new file mode 100644 index 0000000000..6acb394fc7 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/product-subagent-cursor/tool-schemas.expected.json @@ -0,0 +1,548 @@ +{ + "initial": [ + { + "name": "bash", + "description": "Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a job id immediately; read its output with `job_output` and stop it with `job_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.", + "parameters": { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The bash command to execute." + }, + "description": { + "type": "string", + "description": "Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"." + }, + "timeoutMs": { + "type": "number", + "description": "Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry." + }, + "workdir": { + "type": "string", + "description": "Working directory for this command. Defaults to the session workspace; a relative path is resolved against it." + }, + "run_in_background": { + "type": "boolean", + "description": "Run in the background and return a job id immediately (collect with job_output, stop with job_kill). No timeout applies." + }, + "sandbox_permissions": { + "type": "string", + "description": "The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.", + "enum": [ + "workspace-write", + "danger-full-access" + ] + }, + "justification": { + "type": "string", + "description": "Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access." + } + }, + "required": [ + "command", + "description" + ] + } + }, + { + "name": "create_goal", + "description": "Create one persisted same-session completion goal when the current direct human request is a long-running objective that should continue across autonomous goal rounds. You may infer that intent without requiring the user to say \"create a goal\". Do not use this for trivial single-turn work. Execution rejects non-human and subagent authority.", + "parameters": { + "type": "object", + "properties": { + "objective": { + "type": "string", + "description": "The concrete completion objective inferred from the direct human request." + }, + "max_goal_rounds": { + "type": "number", + "description": "Optional positive safe-integer limit on automatic continuation rounds." + } + }, + "required": [ + "objective" + ] + } + }, + { + "name": "edit", + "description": "Edit an existing UTF-8 text file by replacing literal text.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to edit, resolved by the filesystem backend." + }, + "old_string": { + "type": "string", + "description": "Literal text to replace. Must match exactly." + }, + "new_string": { + "type": "string", + "description": "Literal replacement text. Use an empty string to delete the match." + }, + "replace_all": { + "type": "boolean", + "description": "Replace all matches. Defaults to false; when false, old_string must appear exactly once." + }, + "sandbox_permissions": { + "type": "string", + "description": "The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation the sandbox just denied; requires justification and user approval.", + "enum": [ + "workspace-write", + "danger-full-access" + ] + }, + "justification": { + "type": "string", + "description": "Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access." + } + }, + "required": [ + "file_path", + "old_string", + "new_string" + ] + } + }, + { + "name": "get_goal", + "description": "Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, blocker reason when present, and whether another continuation is armed. Call this before updating a goal.", + "parameters": { + "type": "object", + "properties": {} + } + }, + { + "name": "interrupt_agent", + "description": "Request cancellation of a background agent's current turn by its agent id. The target may be your direct child or a deeper agent created under you. Only the current turn stops: messages already queued for the agent stay parked until a later send_message, agents it started keep running, and the agent itself stays available for follow-ups. This call returns as soon as the stop request is accepted, so the target may keep running briefly; interrupting an agent that already finished is an accepted no-op.", + "parameters": { + "type": "object", + "properties": { + "agent_id": { + "type": "string", + "description": "The agent id of the running agent to interrupt." + } + }, + "required": [ + "agent_id" + ] + } + }, + { + "name": "job_kill", + "description": "Request cancellation of a running background job by job id. Returns immediately; the job settles as killed once its work actually stops.", + "parameters": { + "type": "object", + "properties": { + "job_id": { + "type": "string", + "description": "Job id returned by the tool that started the background work." + }, + "reason": { + "type": "string", + "description": "Optional short reason, recorded in the log and forwarded to the job." + } + }, + "required": [ + "job_id" + ] + } + }, + { + "name": "job_list", + "description": "List your background jobs (running and finished) with their ids, kinds, and statuses.", + "parameters": { + "type": "object", + "properties": {} + } + }, + { + "name": "job_output", + "description": "Read a background job. Stream jobs return only output since the previous read; final-output jobs return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.", + "parameters": { + "type": "object", + "properties": { + "job_id": { + "type": "string", + "description": "Job id returned by the tool that started the background work." + }, + "wait": { + "type": "boolean", + "description": "Block until the job reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the job alive." + }, + "timeout_ms": { + "type": "number", + "description": "Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum." + } + }, + "required": [ + "job_id" + ] + } + }, + { + "name": "list_agents", + "description": "List your continuable background subagents by durable id and label. Use it to recall which ones you started, not to poll for completion — you are told when one finishes. Status comes from the live registry: running means the agent is working right now, idle means it is loaded but between turns (it may be waiting on agents it started), and ready means it exists only in storage — resumable, not terminal, and not a result waiting to be collected; a `send_message` starts a new turn on the same conversation, and a direct child remains a `send_message` candidate in every status. The snapshot is not a delivery promise — `send_message` performs the authoritative check and may still fail. Children that could not be read are reported as diagnostics instead of being silently dropped. Scope `descendants` walks the whole tree below you in stable pre-order, annotating each entry with its durable direct-parent session id and depth. You may use `send_message` only for depth-1 entries; deeper entries are candidates for `interrupt_agent` only.", + "parameters": { + "type": "object", + "properties": { + "scope": { + "type": "string", + "description": "children (default) lists direct children only; descendants walks the complete tree below you.", + "enum": [ + "children", + "descendants" + ] + } + } + } + }, + { + "name": "ralph", + "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", + "parameters": { + "type": "object", + "properties": { + "objective": { + "type": "string", + "description": "The immutable completion objective for every fresh Ralph round." + }, + "maxRounds": { + "type": "number", + "description": "Optional positive safe-integer round cap, bounded by the deployment ceiling." + } + }, + "required": [ + "objective" + ] + } + }, + { + "name": "read", + "description": "Read a UTF-8 text file and return line-numbered content.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to read, resolved by the filesystem backend." + }, + "offset": { + "type": "number", + "description": "1-based first line to return. Defaults to 1." + }, + "limit": { + "type": "number", + "description": "Maximum number of lines to return. Defaults to 2000." + } + }, + "required": [ + "file_path" + ] + } + }, + { + "name": "send_message", + "description": "Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered.", + "parameters": { + "type": "object", + "properties": { + "subagent_id": { + "type": "string", + "description": "The subagent id returned when the background subagent was started." + }, + "message": { + "type": "string", + "description": "The message to deliver to the subagent." + } + }, + "required": [ + "subagent_id", + "message" + ] + } + }, + { + "name": "skill", + "description": "Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.", + "parameters": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The exact skill name from the available skills list." + } + }, + "required": [ + "name" + ] + } + }, + { + "name": "subagent", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result.", + "parameters": { + "type": "object", + "properties": { + "description": { + "type": "string", + "description": "A short (3-5 word) description of the delegated task, for display." + }, + "prompt": { + "type": "string", + "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." + }, + "run_in_background": { + "type": "boolean", + "description": "Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it." + } + }, + "required": [ + "description", + "prompt" + ] + } + }, + { + "name": "subagent_cursor", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This call waits for the result by default. Set `run_in_background: true` to return a job id; collect with `job_output` and stop with `job_kill`.", + "parameters": { + "type": "object", + "properties": { + "description": { + "type": "string", + "description": "A short (3-5 word) description of the delegated task, for display." + }, + "prompt": { + "type": "string", + "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." + }, + "run_in_background": { + "type": "boolean", + "description": "Whether to run as a background job and return its id. Defaults to false; collect with job_output or stop with job_kill." + } + }, + "required": [ + "description", + "prompt" + ] + } + }, + { + "name": "subagent_fork", + "description": "Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn). Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive its result, not its intermediate steps. This call waits for the subagent and returns its result.", + "parameters": { + "type": "object", + "properties": { + "description": { + "type": "string", + "description": "A short (3-5 word) description of the delegated task, for display." + }, + "prompt": { + "type": "string", + "description": "The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new." + } + }, + "required": [ + "description", + "prompt" + ] + } + }, + { + "name": "todo_write", + "description": "Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).", + "parameters": { + "type": "object", + "properties": { + "todos": { + "type": "array", + "description": "The COMPLETE task list, replacing any previous list.", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "content": { + "type": "string", + "description": "What the task is — a short imperative line." + }, + "status": { + "type": "string", + "description": "pending (not started) | in_progress (now) | completed (done).", + "enum": [ + "pending", + "in_progress", + "completed" + ] + } + }, + "required": [ + "content", + "status" + ] + } + } + }, + "required": [ + "todos" + ] + } + }, + { + "name": "update_goal", + "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", + "parameters": { + "type": "object", + "properties": { + "goal_id": { + "type": "string", + "description": "Exact id returned by get_goal." + }, + "revision": { + "type": "number", + "description": "Exact positive revision returned by get_goal." + }, + "action": { + "type": "string", + "description": "edit | pause | resume | complete | blocked", + "enum": [ + "edit", + "pause", + "resume", + "complete", + "blocked" + ] + }, + "objective": { + "type": "string", + "description": "Replacement objective; valid only with action edit." + }, + "max_goal_rounds": { + "type": "number", + "description": "Replacement cap; valid only with action edit." + }, + "blocked_reason": { + "type": "string", + "description": "Concrete blocking condition; required only with action blocked." + } + }, + "required": [ + "goal_id", + "revision", + "action" + ] + } + }, + { + "name": "workflow", + "description": "Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.", + "parameters": { + "type": "object", + "properties": { + "script": { + "type": "string", + "description": "The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)." + }, + "meta": { + "type": "object", + "description": "The workflow identity block (plain JSON — never code).", + "additionalProperties": true, + "properties": { + "name": { + "type": "string", + "description": "Short kebab-case workflow name." + }, + "description": { + "type": "string", + "description": "One-line description of what the workflow does." + }, + "whenToUse": { + "type": "string", + "description": "Optional guidance on when this workflow applies." + }, + "phases": { + "type": "array", + "description": "Optional phase declarations matched by phase() calls.", + "items": { + "type": "object", + "additionalProperties": true, + "properties": { + "title": { + "type": "string", + "description": "The phase title phase() calls match by exact string." + }, + "detail": { + "type": "string", + "description": "Optional one-line description of the phase." + }, + "provider": { + "type": "string", + "description": "Optional provider override this phase is expected to use." + }, + "model": { + "type": "string", + "description": "Optional model override this phase is expected to use." + } + }, + "required": [ + "title" + ] + } + } + }, + "required": [ + "name", + "description" + ] + }, + "args": { + "type": "object", + "description": "Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).", + "additionalProperties": true + } + }, + "required": [ + "script", + "meta" + ] + } + }, + { + "name": "write", + "description": "Create or fully replace a UTF-8 text file.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to write, resolved by the filesystem backend." + }, + "content": { + "type": "string", + "description": "Full UTF-8 text content to write." + }, + "sandbox_permissions": { + "type": "string", + "description": "The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation the sandbox just denied; requires justification and user approval.", + "enum": [ + "workspace-write", + "danger-full-access" + ] + }, + "justification": { + "type": "string", + "description": "Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access." + } + }, + "required": [ + "file_path", + "content" + ] + } + } + ], + "changes": [] +} diff --git a/examples/package.json b/examples/package.json index cd348d5fe2..1b9257a63a 100644 --- a/examples/package.json +++ b/examples/package.json @@ -75,6 +75,7 @@ "@deepseek-ai/dsh-subagent-acp": "workspace:*", "@deepseek-ai/dsh-subagent-claude-code": "workspace:*", "@deepseek-ai/dsh-subagent-codex": "workspace:*", + "@deepseek-ai/dsh-subagent-cursor": "workspace:*", "@deepseek-ai/dsh-subagent-dsh-sdk": "workspace:*", "@deepseek-ai/dsh-subagent-fork-in-process": "workspace:*", "@deepseek-ai/dsh-subagent-spawn-in-process": "workspace:*", diff --git a/knip.json b/knip.json index ed90c16ff2..51fc71584e 100644 --- a/knip.json +++ b/knip.json @@ -61,6 +61,8 @@ "acp-agent/tests/fixtures/subagent/subagent-claude-code/driver.ts", "acp-agent/tests/fixtures/subagent/subagent-codex/fixture.ts", "acp-agent/tests/fixtures/subagent/subagent-codex/driver.ts", + "acp-agent/tests/fixtures/subagent/subagent-cursor/fixture.ts", + "acp-agent/tests/fixtures/subagent/subagent-cursor/driver.ts", "jsonrpc-agent/tests/fixtures/subagent/subagent-dsh-sdk/driver.ts", "jsonrpc-agent/tests/fixtures/subagent/subagent-dsh-sdk/child-mock-llm.ts", "jsonrpc-agent/tests/fixtures/subagent/subagent-dsh-sdk/mock-delegating-llm.ts", @@ -768,6 +770,10 @@ "tests/**/*.ts" ] }, + "packages/subagent/subagent-cursor": { + "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], + "project": ["src/**/*.ts", "tests/**/*.ts"] + }, "packages/subagent/subagent-dsh-sdk": { "entry": [ "tests/**/*.spec.ts", diff --git a/packages/bundle/base/cordis.patch.yml b/packages/bundle/base/cordis.patch.yml index e9567d9206..e5d55a0a8b 100644 --- a/packages/bundle/base/cordis.patch.yml +++ b/packages/bundle/base/cordis.patch.yml @@ -302,6 +302,15 @@ config: providerName: fork + - id: subagent-codex + name: '@deepseek-ai/dsh-subagent-codex' + + - id: subagent-claude-code + name: '@deepseek-ai/dsh-subagent-claude-code' + + - id: subagent-cursor + name: '@deepseek-ai/dsh-subagent-cursor' + # Continuable background children are selected per delegation tool. The # separately loaded follow-up tool registers the one global `send_message`. - id: tool-subagent-control diff --git a/packages/bundle/base/package.json b/packages/bundle/base/package.json index 62350bbc11..033f60ff5f 100644 --- a/packages/bundle/base/package.json +++ b/packages/bundle/base/package.json @@ -64,6 +64,8 @@ "@deepseek-ai/dsh-llm-deepseek": "workspace:^", "@deepseek-ai/dsh-llm-pi-ai": "workspace:^", "@deepseek-ai/dsh-llm-retry": "workspace:^", + "@deepseek-ai/dsh-lsp": "workspace:^", + "@deepseek-ai/dsh-lsp-stdio": "workspace:^", "@deepseek-ai/dsh-permission-presets": "workspace:^", "@deepseek-ai/dsh-plan-mode": "workspace:^", "@deepseek-ai/dsh-pwsh-sandbox": "workspace:^", @@ -85,6 +87,9 @@ "@deepseek-ai/dsh-spill-local": "workspace:^", "@deepseek-ai/dsh-spill-policy": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", + "@deepseek-ai/dsh-subagent-claude-code": "workspace:^", + "@deepseek-ai/dsh-subagent-codex": "workspace:^", + "@deepseek-ai/dsh-subagent-cursor": "workspace:^", "@deepseek-ai/dsh-subagent-fork-in-process": "workspace:^", "@deepseek-ai/dsh-subagent-spawn-in-process": "workspace:^", "@deepseek-ai/dsh-subprocess-local": "workspace:^", @@ -104,6 +109,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-lsp": "workspace:^", "@deepseek-ai/dsh-tool-todo": "workspace:^", "@deepseek-ai/dsh-tool-web": "workspace:^", "@deepseek-ai/dsh-tool-workflow": "workspace:^", diff --git a/packages/bundle/base/tests/base.spec.ts b/packages/bundle/base/tests/base.spec.ts index e70bc0ff74..dee51217e1 100644 --- a/packages/bundle/base/tests/base.spec.ts +++ b/packages/bundle/base/tests/base.spec.ts @@ -35,10 +35,18 @@ describe('dsh-base bundle', () => { expect(rows.find(row => row.id === 'session-telemetry-otel')?.config?.['mode']).toEqual({ __jsExpr: "process.env.DSH_TELEMETRY_MODE || 'DISABLED'", }) - expect(rows.filter(row => row.id === 'subagent-codex')).toHaveLength(0) - expect(rows.filter(row => row.id === 'subagent-claude-code')).toHaveLength(0) - expect(manifest.dependencies).not.toHaveProperty('@deepseek-ai/dsh-subagent-codex') - expect(manifest.dependencies).not.toHaveProperty('@deepseek-ai/dsh-subagent-claude-code') + // The base layer mounts each external product agent exactly once, so a + // Profile enables one by removing `disabled` from its Agent Preset tool + // row rather than mounting a duplicate provider. Loading a provider starts + // no product process; a run still needs that product's own CLI on PATH. + for (const [id, dependency] of [ + ['subagent-codex', '@deepseek-ai/dsh-subagent-codex'], + ['subagent-claude-code', '@deepseek-ai/dsh-subagent-claude-code'], + ['subagent-cursor', '@deepseek-ai/dsh-subagent-cursor'], + ] as const) { + expect(rows.filter(row => row.id === id)).toHaveLength(1) + expect(manifest.dependencies).toHaveProperty(dependency) + } }) it('gates each shell stack by platform with a symmetric disabled expression', () => { diff --git a/packages/client/ui-agent-preset/src/client/locales.ts b/packages/client/ui-agent-preset/src/client/locales.ts index de19803a29..21a55e720d 100644 --- a/packages/client/ui-agent-preset/src/client/locales.ts +++ b/packages/client/ui-agent-preset/src/client/locales.ts @@ -8,6 +8,7 @@ export type AgentPresetSettingsKey = | 'presetCodeName' | 'presetCodeDescription' | 'presetMinimalName' | 'presetMinimalDescription' | 'presetCordisName' | 'presetCordisDescription' + | 'presetMaximumName' | 'presetMaximumDescription' | 'duplicate' | 'duplicateUnavailable' | 'delete' | 'presetId' | 'presetIdPlaceholder' | 'copyOf' | 'displayName' | 'displayNamePlaceholder' | 'inUse' | 'noDescription' | 'builtInGroup' | 'customGroup' @@ -46,6 +47,9 @@ export const en: Record = { presetCordisName: 'Creator mode', presetCordisDescription: 'Built for creating custom agent presets, with all Standard mode capabilities plus runtime inspection, plugin experiments, and preset-authoring guidance.', + presetMaximumName: 'Maximum mode', + presetMaximumDescription: + 'Every capability this deployment can compose — terminals, language servers, session history, MCP, external agents, and Code Mode beside the native tools — delivering each change through an architect, an implementer, and a reviewer.', duplicate: 'Duplicate', duplicateUnavailable: 'This deployment has no writable preset directory', delete: 'Delete', @@ -106,6 +110,8 @@ export const zh: Record = { presetMinimalDescription: '仅提供持久 bash 与 str_replace_editor 的双工具编码 Agent。', presetCordisName: '创造模式', presetCordisDescription: '用于创建自定义 Agent preset:具备标准模式的全部能力,并提供运行时检查、插件实验和 preset 创作指导。', + presetMaximumName: '全能模式', + presetMaximumDescription: '组合本部署能提供的全部能力 —— 终端、语言服务器、会话历史、MCP、外部 Agent,以及与原生工具并存的 Code Mode —— 并由架构师、实现者、审查者三个角色接力交付每一次改动。', duplicate: '复制', duplicateUnavailable: '此部署未配置可写的预设目录', delete: '删除', @@ -171,6 +177,7 @@ const BUILT_IN_PRESET_KEYS: Readonly>> code: { name: 'presetCodeName', description: 'presetCodeDescription' }, minimal: { name: 'presetMinimalName', description: 'presetMinimalDescription' }, cordis: { name: 'presetCordisName', description: 'presetCordisDescription' }, + maximum: { name: 'presetMaximumName', description: 'presetMaximumDescription' }, } /** diff --git a/packages/client/ui-openrouter-usage/README.md b/packages/client/ui-openrouter-usage/README.md index 88a0d1eb88..b270f78eea 100644 --- a/packages/client/ui-openrouter-usage/README.md +++ b/packages/client/ui-openrouter-usage/README.md @@ -13,13 +13,21 @@ only once a step actually priced — no zero-cost group. An unknown-pricing step count surfaces in a tooltip so the figure's coverage stays visible. `CostDock` therefore owns no store, refresh chain, or event listener. +The figure covers the session plus its subagent subtree, walked over the +session list's `parentId`/`origin` rows; a subtree share names itself in the +label. Each projection value describes only its own session's work — the host +fold excludes a forked child's inherited prefix — so the walk sums rather than +double counts. Ordinary forks are not delegated work and stay out of it. + `BalanceBadge` is account-global, so it is not a session projection: the slot inject face carries a `snapshot()` callback that polls the host `ctx.remote.openRouterUsage.snapshot()` Remote on a 60s interval and drives -local state. In the collapsed rail it renders only the balance marker; in the -wide column a labeled pill. Unknown or absent figures render the empty -placeholder. Both entries render nothing before real data exists, so an -assembly without the OpenRouter gateway or key costs no layout. +local state. Clicking the badge calls the `refresh()` callback instead, which +makes the host fetch the account immediately; the badge marks itself +`aria-busy` for the duration and ignores further clicks until it settles, and +a failed refresh keeps the last-known figure. In the collapsed rail it renders +only the balance marker; in the wide column a labeled pill. Unknown or absent +figures render the empty placeholder. The `/client` exports are the plugin body (`apply`/`inject`), the `CostDock`/`BalanceBadge` components, and the injected face types. @@ -42,5 +50,10 @@ account figure; neither is a new model-visible input. projection holds; figures for already-folded history reflect the pricing table at fold time (see `dsh-openrouter-usage`). - **Polled, not pushed** — `BalanceBadge` polls the host snapshot; there is - no `openRouterUsage` forwarded-event channel, so the figure updates on the - poll interval rather than instantly. + no `openRouterUsage` forwarded-event channel, so an account change the user + did not click for surfaces on the poll interval rather than instantly. +- **A cold subagent contributes nothing** — the subtree walk reads the + descendants' projection values the client holds. A subagent session never + opened, and with no version-matching row in the host's cold projection + cache, carries no value and is indistinguishable from a zero-cost child, so + its spend is missing from the total rather than marked absent. diff --git a/packages/client/ui-openrouter-usage/src/client/BalanceBadge.module.css b/packages/client/ui-openrouter-usage/src/client/BalanceBadge.module.css index d774a75362..458bf9faa9 100644 --- a/packages/client/ui-openrouter-usage/src/client/BalanceBadge.module.css +++ b/packages/client/ui-openrouter-usage/src/client/BalanceBadge.module.css @@ -14,7 +14,12 @@ color: var(--dsw-alias-label-tertiary); font-size: 12px; line-height: 28px; - cursor: default; + cursor: pointer; +} + +.badge[aria-busy='true'] { + cursor: progress; + opacity: 0.6; } .badge:hover { diff --git a/packages/client/ui-openrouter-usage/src/client/BalanceBadge.tsx b/packages/client/ui-openrouter-usage/src/client/BalanceBadge.tsx index fe07256a2b..d6da696118 100644 --- a/packages/client/ui-openrouter-usage/src/client/BalanceBadge.tsx +++ b/packages/client/ui-openrouter-usage/src/client/BalanceBadge.tsx @@ -2,10 +2,11 @@ // plus the latest snapshot's USD figure in both widths (rail = marker-only, // wide = label + figure). The value is account-global, so it is not a session // projection: an injected `snapshot` callback polls the host Remote gateway on -// an interval and drives local state. `wide` arrives from the sidebar's owner -// share; `snapshot` from the register's inject face. +// an interval, a click runs `refresh` for an on-demand read, and both drive +// local state. `wide` arrives from the sidebar's owner share; the two +// callbacks from the register's inject face. -import { useEffect, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import type { OpenRouterBalance } from '@deepseek-ai/dsh-openrouter-usage/client' import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import { formatUsd } from './money.ts' @@ -14,25 +15,34 @@ import css from './BalanceBadge.module.css' /** Poll interval for the balance figure, ms. */ export const BALANCE_POLL_MS = 60_000 -/** Injected face: the balance read callback the badge polls. */ +/** Injected face: the balance reads the badge polls and re-runs on demand. */ export interface BalanceBadgeActions { - /** Fetch the latest account snapshot from the host gateway. */ + /** Read the host gateway's cached account snapshot. */ snapshot: () => Promise + /** Make the host fetch the account NOW and serve the result. */ + refresh: () => Promise } -/** Composed props: the sidebar footer-action owner share plus the injected read. */ +/** Composed props: the sidebar footer-action owner share plus the injected reads. */ export type BalanceBadgeProps = PropsRuntime<'sidebar.footer.action'> & BalanceBadgeActions & PropsLocale<'openRouterUsage'> /** - * Poll `snapshot` on an interval and render the account balance. Unknown or - * absent figures render the empty placeholder so the seat costs no confusion. - * A transient failure keeps the last-known figure and retries next tick. - * @param props - owner state plus the injected snapshot callback. + * Poll `snapshot` on an interval, re-read through `refresh` when the user + * clicks, and render the account balance. Unknown or absent figures render the + * empty placeholder so the seat costs no confusion. A transient failure keeps + * the last-known figure and retries next tick; a click while one refresh is + * still outstanding is ignored rather than queued. + * @param props - owner state plus the injected snapshot/refresh callbacks. * @returns the balance badge. */ -export function BalanceBadge({ wide, snapshot, t }: BalanceBadgeProps) { +export function BalanceBadge({ wide, snapshot, refresh, t }: BalanceBadgeProps) { const [balance, setBalance] = useState(undefined) + const [refreshing, setRefreshing] = useState(false) + // Survives the unmount a disposed registration causes mid-fetch: the + // resolving callback must not set state on a gone component. + const mounted = useRef(true) + useEffect(() => () => { mounted.current = false }, []) useEffect(() => { let disposed = false @@ -53,14 +63,38 @@ export function BalanceBadge({ wide, snapshot, t }: BalanceBadgeProps) { } }, [snapshot]) + const onClick = useCallback(() => { + if (refreshing) return + setRefreshing(true) + void (async () => { + try { + const next = await refresh() + if (mounted.current) setBalance(next) + } catch (_failedRefresh) { + // The click keeps the last-known figure; the interval poll retries. + } finally { + if (mounted.current) setRefreshing(false) + } + })() + }, [refresh, refreshing]) + const usd = balance?.balanceUsd ?? null const amount = usd === null ? t('balance.empty') : formatUsd(usd) const label = t('balance.label', { amount }) - const tooltip = balance?.label == null ? undefined : t('balance.tooltip', { label: balance.label }) + const tooltip = refreshing + ? t('balance.refreshing') + : balance?.label == null ? undefined : t('balance.tooltip', { label: balance.label }) return ( - diff --git a/packages/client/ui-openrouter-usage/src/client/CostDock.tsx b/packages/client/ui-openrouter-usage/src/client/CostDock.tsx index c33ff554c5..61d7d837f7 100644 --- a/packages/client/ui-openrouter-usage/src/client/CostDock.tsx +++ b/packages/client/ui-openrouter-usage/src/client/CostDock.tsx @@ -2,9 +2,15 @@ // Reads the durable openRouterCost projection, so paging and compaction // cannot change the figure; the entry renders nothing until at least one // step priced (no zero-cost group, mirroring the stats line's billing gate). +// +// The figure covers the session plus its subagent subtree. Each projection +// value describes only its own session's work — the host fold excludes a +// forked child's inherited prefix — so summing the lineage is a sum, not a +// double count. -import { memo } from 'react' +import { memo, useMemo } from 'react' import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' // Type-only: merges openRouterCost into SessionProjectionMap for useProjection. import type {} from '@deepseek-ai/dsh-openrouter-usage/client' import type { OpenRouterCost } from '@deepseek-ai/dsh-openrouter-usage/client' @@ -15,22 +21,75 @@ import css from './CostDock.module.css' export type CostDockProps = PropsRuntime<'conversation.composer.dock'> & PropsLocale<'openRouterUsage'> +/** Summed spend over one session's subagent subtree. */ +interface SubtreeCost { + totalUsd: number + pricedSteps: number + unknownModelSteps: number +} + /** * Render the running session cost as `会话费用 $X.XX`, shown only when the - * session actually priced steps. An unknown-pricing step count feeds a - * tooltip so the figure's coverage stays visible without polluting the row. + * session or any of its subagents actually priced steps. An unknown-pricing + * step count feeds a tooltip so the figure's coverage stays visible without + * polluting the row. * @param props - framework/runtime props. * @returns the cost group, or null on a zero/absent projection. */ -export const CostDock = memo(function CostDock({ useProjection, t }: CostDockProps) { - const cost = useProjection('openRouterCost') as OpenRouterCost | undefined - if (cost === undefined || cost.pricedSteps === 0) return null - const label = t('cost.label', { amount: formatUsd(cost.totalUsd) }) +export const CostDock = memo(function CostDock({ sessionId, useProjection, useSessions, t }: CostDockProps) { + const cost: OpenRouterCost | undefined = useProjection('openRouterCost') + const sessionById = useSessions(s => s.byId) + + const subagentCost = useMemo((): SubtreeCost => { + let totalUsd = 0 + let pricedSteps = 0 + let unknownModelSteps = 0 + const rows = Object.values(sessionById) + // Breadth-first over subagent-origin descendants, one frontier at a time. + // `visited` also closes a parentId cycle a corrupt lineage could present. + const visited = new Set([sessionId]) + let frontier: SessionId[] = [sessionId] + while (frontier.length > 0) { + const next: SessionId[] = [] + for (const parent of frontier) { + for (const row of rows) { + if (row.parentId !== parent || row.origin !== 'subagent' || visited.has(row.id)) continue + visited.add(row.id) + next.push(row.id) + // A descendant the client holds no projection value for (never + // opened, and no version-matching cold cache row) contributes + // nothing; it cannot be told apart from a zero-cost child here. + const childCost = row.projectionValues?.openRouterCost + if (childCost === undefined) continue + totalUsd += childCost.totalUsd + pricedSteps += childCost.pricedSteps + unknownModelSteps += childCost.unknownModelSteps + } + } + frontier = next + } + return { totalUsd, pricedSteps, unknownModelSteps } + }, [sessionById, sessionId]) + + const selfPricedSteps = cost?.pricedSteps ?? 0 + if (selfPricedSteps === 0 && subagentCost.pricedSteps === 0) return null + + const selfUsd = cost?.totalUsd ?? 0 + const totalUsd = selfUsd + subagentCost.totalUsd + const unknownModelSteps = (cost?.unknownModelSteps ?? 0) + subagentCost.unknownModelSteps + + const label = subagentCost.pricedSteps > 0 + ? t('cost.labelWithSubagents', { + amount: formatUsd(totalUsd), + subagentAmount: formatUsd(subagentCost.totalUsd), + }) + : t('cost.label', { amount: formatUsd(totalUsd) }) + return ( 0 - ? t('cost.tooltipUnknown', { count: String(cost.unknownModelSteps) }) + title={unknownModelSteps > 0 + ? t('cost.tooltipUnknown', { count: String(unknownModelSteps) }) : undefined} > {label} diff --git a/packages/client/ui-openrouter-usage/src/client/index.ts b/packages/client/ui-openrouter-usage/src/client/index.ts index d8fa7f66f2..ad54744ed1 100644 --- a/packages/client/ui-openrouter-usage/src/client/index.ts +++ b/packages/client/ui-openrouter-usage/src/client/index.ts @@ -63,6 +63,14 @@ export function apply(ctx: ClientContext): void { return result.value } + const refresh: BalanceBadgeActions['refresh'] = async () => { + const result = await ctx.remote.openRouterUsage.refresh() + if (!result.ok) { + throw new Error(`openRouterUsage.refresh failed: ${result.error.code}: ${result.error.message}`) + } + return result.value + } + // Account balance is account-global (root scope): the injected face carries // the snapshot read the badge polls. The seat is declared but unhoused by // default; this registration names it, so an assembly without ui-sidebar @@ -72,6 +80,6 @@ export function apply(ctx: ClientContext): void { id: 'openrouter-balance', order: 10, locale: NS, - inject: (): BalanceBadgeActions => ({ snapshot }), + inject: (): BalanceBadgeActions => ({ snapshot, refresh }), }, BalanceBadge)) } diff --git a/packages/client/ui-openrouter-usage/src/client/locales.ts b/packages/client/ui-openrouter-usage/src/client/locales.ts index 79284d9b0f..a0a15a1323 100644 --- a/packages/client/ui-openrouter-usage/src/client/locales.ts +++ b/packages/client/ui-openrouter-usage/src/client/locales.ts @@ -3,10 +3,12 @@ /** Simplified Chinese dictionary (the key-set source of truth). */ export const zh = { 'cost.label': '会话费用 {amount}', + 'cost.labelWithSubagents': '会话费用 {amount}(含子代理 {subagentAmount})', 'cost.tooltipUnknown': '包含 {count} 个未知定价步骤', 'balance.label': '余额 {amount}', - 'balance.tooltip': 'OpenRouter 账户余额({label})', + 'balance.tooltip': 'OpenRouter 账户余额({label})— 点击刷新', 'balance.empty': '——', + 'balance.refreshing': '正在刷新 OpenRouter 账户余额', } satisfies Record /** The openrouterUsage namespace key union. */ @@ -15,8 +17,10 @@ export type OpenRouterUsageKey = keyof typeof zh /** English dictionary, checked complete against the zh key set. */ export const en = { 'cost.label': 'Session cost {amount}', + 'cost.labelWithSubagents': 'Session cost {amount} (subagents {subagentAmount})', 'cost.tooltipUnknown': 'Includes {count} unpriced steps', 'balance.label': 'Balance {amount}', - 'balance.tooltip': 'OpenRouter account balance ({label})', + 'balance.tooltip': 'OpenRouter account balance ({label}) — click to refresh', 'balance.empty': '——', + 'balance.refreshing': 'Refreshing the OpenRouter account balance', } satisfies Record diff --git a/packages/client/ui-openrouter-usage/tests/balance-badge.client.spec.tsx b/packages/client/ui-openrouter-usage/tests/balance-badge.client.spec.tsx index ac5a609fc6..a7def7cbe3 100644 --- a/packages/client/ui-openrouter-usage/tests/balance-badge.client.spec.tsx +++ b/packages/client/ui-openrouter-usage/tests/balance-badge.client.spec.tsx @@ -1,8 +1,9 @@ // @vitest-environment jsdom // BalanceBadge presentation: polls an injected snapshot callback and renders -// 余额 in both widths, hiding unknown figures behind the empty placeholder. +// 余额 in both widths, hiding unknown figures behind the empty placeholder, +// and re-reads through the injected refresh callback when the user clicks it. -import { act, cleanup, render, screen } from '@testing-library/react' +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' @@ -34,6 +35,7 @@ function makeProps(over: Partial = {}) { return { wide: true, snapshot: vi.fn(async () => makeBalance()), + refresh: vi.fn(async () => makeBalance()), t, ...over, } as unknown as Parameters[0] @@ -67,4 +69,37 @@ describe('BalanceBadge', () => { render( makeBalance({ balanceUsd: null })) })} />) expect(await screen.findByLabelText('余额 ——')).toBeTruthy() }) + + it('re-reads through refresh on click and renders the fetched figure', async () => { + const refresh = vi.fn(async () => makeBalance({ balanceUsd: 42.25 })) + render() + const badge = await screen.findByLabelText('余额 $10.50') + await act(async () => { fireEvent.click(badge) }) + expect(refresh).toHaveBeenCalledOnce() + expect(await screen.findByLabelText('余额 $42.25')).toBeTruthy() + }) + + it('ignores a click while a refresh is still outstanding', async () => { + let release: (value: OpenRouterBalance) => void = () => {} + const refresh = vi.fn(() => new Promise((resolve) => { release = resolve })) + render() + const badge = await screen.findByLabelText('余额 $10.50') + await act(async () => { fireEvent.click(badge) }) + expect(badge.getAttribute('aria-busy')).toBe('true') + await act(async () => { fireEvent.click(badge) }) + expect(refresh).toHaveBeenCalledOnce() + await act(async () => { release(makeBalance({ balanceUsd: 7 })) }) + expect(badge.getAttribute('aria-busy')).toBe('false') + await act(async () => { fireEvent.click(badge) }) + expect(refresh).toHaveBeenCalledTimes(2) + }) + + it('keeps the last-known figure when a refresh fails', async () => { + const refresh = vi.fn(async () => { throw new Error('boom') }) + render() + const badge = await screen.findByLabelText('余额 $10.50') + await act(async () => { fireEvent.click(badge) }) + expect(await screen.findByLabelText('余额 $10.50')).toBeTruthy() + expect(badge.getAttribute('aria-busy')).toBe('false') + }) }) diff --git a/packages/client/ui-openrouter-usage/tests/browser-plugin.client.spec.tsx b/packages/client/ui-openrouter-usage/tests/browser-plugin.client.spec.tsx index dedd3ab010..302a76a473 100644 --- a/packages/client/ui-openrouter-usage/tests/browser-plugin.client.spec.tsx +++ b/packages/client/ui-openrouter-usage/tests/browser-plugin.client.spec.tsx @@ -37,8 +37,10 @@ async function bench() { new RemoteService(ctx) const snapshot = vi.fn<() => Promise>>() .mockResolvedValue({ ok: true, value: balance() }) - ctx.provide('remote.openRouterUsage', { snapshot }) - return { ctx, slots: ctx.get('slots') as SlotRegistry, locale, snapshot } + const refresh = vi.fn<() => Promise>>() + .mockResolvedValue({ ok: true, value: balance({ balanceUsd: 9 }) }) + ctx.provide('remote.openRouterUsage', { snapshot, refresh }) + return { ctx, slots: ctx.get('slots') as SlotRegistry, locale, snapshot, refresh } } function declare(slots: SlotRegistry): () => void { @@ -84,11 +86,14 @@ describe('ui-openrouter-usage browser plugin', () => { expect(badge.options).toMatchObject({ id: 'openrouter-balance', order: 10 }) expect(badge.locale).toBe(NS) - // Registration reads nothing live; the snapshot rolls at call time. + // Registration reads nothing live; both reads roll at call time. expect(b.snapshot).not.toHaveBeenCalled() + expect(b.refresh).not.toHaveBeenCalled() const injected = (badge.inject as unknown as () => BalanceBadgeActions)() await expect(injected.snapshot()).resolves.toEqual(balance()) expect(b.snapshot).toHaveBeenCalledOnce() + await expect(injected.refresh()).resolves.toEqual(balance({ balanceUsd: 9 })) + expect(b.refresh).toHaveBeenCalledOnce() await b.ctx.fiber.dispose() }) @@ -102,6 +107,16 @@ describe('ui-openrouter-usage browser plugin', () => { await b.ctx.fiber.dispose() }) + it('forwards a Remote failure out of the injected refresh verbatim', async () => { + const b = await bench() + declare(b.slots) + await b.ctx.plugin({ inject: [...inject], apply }).await() + b.refresh.mockResolvedValueOnce({ ok: false, error: { code: 'REMOTE_ERROR', message: 'nope', details: {} } }) + const injected = (b.slots.entries('sidebar.footer.action')[0]!.inject as unknown as () => BalanceBadgeActions)() + await expect(injected.refresh()).rejects.toThrow('openRouterUsage.refresh failed: REMOTE_ERROR: nope') + await b.ctx.fiber.dispose() + }) + it('follows locale and recovers across late declaration and declarer reload', async () => { const b = await bench() const fiber = b.ctx.plugin({ inject: [...inject], apply }) diff --git a/packages/client/ui-openrouter-usage/tests/cost-dock.client.spec.tsx b/packages/client/ui-openrouter-usage/tests/cost-dock.client.spec.tsx index d890ae8967..ab0ef82a49 100644 --- a/packages/client/ui-openrouter-usage/tests/cost-dock.client.spec.tsx +++ b/packages/client/ui-openrouter-usage/tests/cost-dock.client.spec.tsx @@ -1,6 +1,7 @@ // @vitest-environment jsdom // CostDock presentation: renders the running session cost from the -// openRouterCost projection, and nothing at all until a step was priced. +// openRouterCost projection plus the subagent subtree read off the session +// list, and nothing at all until a step was priced. import { act, cleanup, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' @@ -13,9 +14,37 @@ afterEach(cleanup) const t = makeTranslate(zh, commonZh) -function makeProps(over: Partial[0]> = {}) { +const PARENT = 'session-parent' + +/** One session-list row as much of it as the dock reads. */ +interface Row { + id: string + parentId?: string + origin?: 'subagent' + cost?: { totalUsd: number; pricedSteps: number; unknownModelSteps: number } +} + +/** A `useSessions` stub serving `byId` built from the given rows. */ +function sessionsHook(rows: readonly Row[]) { + const byId: Record = {} + for (const row of rows) { + byId[row.id] = { + id: row.id, + ...row.parentId === undefined ? {} : { parentId: row.parentId }, + ...row.origin === undefined ? {} : { origin: row.origin }, + ...row.cost === undefined + ? {} + : { projectionValues: { openRouterCost: { ...row.cost, currency: 'USD' } } }, + } + } + return (selector: (state: { byId: Record }) => unknown) => selector({ byId }) +} + +function makeProps(over: Partial[0]> = {}, rows: readonly Row[] = []) { return { + sessionId: PARENT, useProjection: vi.fn(() => undefined), + useSessions: sessionsHook(rows), t, ...over, } as unknown as Parameters[0] @@ -57,4 +86,53 @@ describe('CostDock', () => { act(() => {}) expect(useProjection).toHaveBeenCalledWith('openRouterCost') }) + + it('adds the subagent subtree to the figure and names its share', () => { + render( ({ totalUsd: 1, pricedSteps: 1, unknownModelSteps: 0, currency: 'USD' }) }, + [{ id: 'child', parentId: PARENT, origin: 'subagent', cost: { totalUsd: 0.5, pricedSteps: 1, unknownModelSteps: 1 } }], + )} />) + const row = screen.getByText('会话费用 $1.50(含子代理 $0.50)') + expect(row.getAttribute('title')).toBe('包含 1 个未知定价步骤') + }) + + it('follows a nested subagent chain and ignores non-subagent children', () => { + render( ({ totalUsd: 1, pricedSteps: 1, unknownModelSteps: 0, currency: 'USD' }) }, + [ + { id: 'child', parentId: PARENT, origin: 'subagent', cost: { totalUsd: 0.25, pricedSteps: 1, unknownModelSteps: 0 } }, + { id: 'grandchild', parentId: 'child', origin: 'subagent', cost: { totalUsd: 0.125, pricedSteps: 1, unknownModelSteps: 0 } }, + // An ordinary fork of the same parent is not delegated work. + { id: 'sibling', parentId: PARENT, cost: { totalUsd: 9, pricedSteps: 1, unknownModelSteps: 0 } }, + ], + )} />) + expect(screen.getByText('会话费用 $1.38(含子代理 $0.38)')).toBeTruthy() + }) + + it('shows the subagent subtree alone when the session itself priced nothing', () => { + render( undefined }, + [{ id: 'child', parentId: PARENT, origin: 'subagent', cost: { totalUsd: 0.75, pricedSteps: 2, unknownModelSteps: 0 } }], + )} />) + expect(screen.getByText('会话费用 $0.75(含子代理 $0.75)')).toBeTruthy() + }) + + it('skips a descendant the client holds no projection value for', () => { + render( ({ totalUsd: 2, pricedSteps: 1, unknownModelSteps: 0, currency: 'USD' }) }, + [{ id: 'cold-child', parentId: PARENT, origin: 'subagent' }], + )} />) + expect(screen.getByText('会话费用 $2.00')).toBeTruthy() + }) + + it('terminates on a parentId cycle', () => { + render( ({ totalUsd: 1, pricedSteps: 1, unknownModelSteps: 0, currency: 'USD' }) }, + [ + { id: 'a', parentId: PARENT, origin: 'subagent', cost: { totalUsd: 0.5, pricedSteps: 1, unknownModelSteps: 0 } }, + { id: PARENT, parentId: 'a', origin: 'subagent', cost: { totalUsd: 99, pricedSteps: 1, unknownModelSteps: 0 } }, + ], + )} />) + expect(screen.getByText('会话费用 $1.50(含子代理 $0.50)')).toBeTruthy() + }) }) diff --git a/packages/client/ui-subagent/src/client/SubagentCatalogAction.tsx b/packages/client/ui-subagent/src/client/SubagentCatalogAction.tsx index 2a730245e7..0f33da67d5 100644 --- a/packages/client/ui-subagent/src/client/SubagentCatalogAction.tsx +++ b/packages/client/ui-subagent/src/client/SubagentCatalogAction.tsx @@ -282,6 +282,10 @@ function CatalogRows({ entry.activity, now, ) + const costUsd = summary?.projectionValues?.openRouterCost?.totalUsd + const costMetric = (costUsd !== undefined && costUsd > 0) + ? `$${costUsd < 0.01 ? '<0.01' : costUsd.toFixed(2)}` + : undefined const tokenMetric = totalTokens === undefined ? undefined : `${formatTokens(totalTokens)} tok` @@ -291,7 +295,7 @@ function CatalogRows({ compact: formatDuration(durationMs, t), exact: formatExactDuration(durationMs, t), } - const metrics = [tokenMetric, durationMetric?.exact] + const metrics = [costMetric, tokenMetric, durationMetric?.exact] .filter(value => value !== undefined) .join(' · ') diff --git a/packages/client/ui-trajectory/src/client/TrajectoryView.tsx b/packages/client/ui-trajectory/src/client/TrajectoryView.tsx index 42d6c53e93..d5fa66e157 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryView.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryView.tsx @@ -84,6 +84,7 @@ interface UsageLike { cacheWriteTokens?: number outputTokens?: number reasoningTokens?: number + costUsd?: number } function requestUsage(value: unknown): TrajectoryUsage | undefined { @@ -205,7 +206,9 @@ export function TrajectoryView({ const provider = request?.provenance?.provider ?? node?.provenance?.provider const model = request?.provenance?.model ?? node?.provenance?.model const requestConfig = request?.requestConfig ?? node?.requestConfig - const stepCostUsd = stepCosts?.[`${turn}:${step}`] + const directCostUsd = (entry.request?.usage as UsageLike | undefined)?.costUsd + ?? (entry.node?.usage as UsageLike | undefined)?.costUsd + const stepCostUsd = directCostUsd ?? stepCosts?.[`${turn}:${step}`] numbered.push({ seq: entry.seq, turn, @@ -232,6 +235,7 @@ export function TrajectoryView({ continue } const request = entry.request + const directCostUsd = (request.usage as UsageLike | undefined)?.costUsd numbered.push({ seq: request.startSeq, turn: request.turn, @@ -253,6 +257,7 @@ export function TrajectoryView({ ...(request.requestConfig === undefined ? {} : { requestConfig: request.requestConfig }), ...(usage === undefined ? {} : { usage }), ...(cumulativeUsage === undefined ? {} : { cumulativeUsage }), + ...(directCostUsd === undefined ? {} : { stepCostUsd: directCostUsd }), }) } diff --git a/packages/client/ui-trajectory/src/client/layout.ts b/packages/client/ui-trajectory/src/client/layout.ts index 265d5ba034..2b4690cce5 100644 --- a/packages/client/ui-trajectory/src/client/layout.ts +++ b/packages/client/ui-trajectory/src/client/layout.ts @@ -48,6 +48,7 @@ interface UsageLike { cacheWriteTokens?: number outputTokens?: number reasoningTokens?: number + costUsd?: number } /** Cell plus absolute ms for group wall-span descriptions. */ diff --git a/packages/client/ui-trajectory/src/client/trajectory-assistant-definition.ts b/packages/client/ui-trajectory/src/client/trajectory-assistant-definition.ts index 3614d4e0d7..cd11fa54ae 100644 --- a/packages/client/ui-trajectory/src/client/trajectory-assistant-definition.ts +++ b/packages/client/ui-trajectory/src/client/trajectory-assistant-definition.ts @@ -18,6 +18,7 @@ interface UsageValue { readonly cacheReadTokens?: number readonly cacheWriteTokens?: number readonly reasoningTokens?: number + readonly costUsd?: number } interface RetryValue { @@ -101,6 +102,9 @@ function addUsage(current: UsageValue | undefined, next: UsageValue): UsageValue ...(current?.reasoningTokens === undefined && next.reasoningTokens === undefined ? {} : { reasoningTokens: (current?.reasoningTokens ?? 0) + (next.reasoningTokens ?? 0) }), + ...(current?.costUsd === undefined && next.costUsd === undefined + ? {} + : { costUsd: (current?.costUsd ?? 0) + (next.costUsd ?? 0) }), } } diff --git a/packages/client/ui-trajectory/tests/views.client.spec.tsx b/packages/client/ui-trajectory/tests/views.client.spec.tsx index 6ae7f33f5c..0938694050 100644 --- a/packages/client/ui-trajectory/tests/views.client.spec.tsx +++ b/packages/client/ui-trajectory/tests/views.client.spec.tsx @@ -1226,6 +1226,27 @@ describe('TrajectoryView state', () => { expect(screen.getByText('$0.0028')).toBeTruthy() }) + it('attaches the per-step cost directly from node usage even when projection is undefined', () => { + const nodesWithCost = [ + NODES[0]!, + { + ...NODES[1]!, + usage: { inputTokens: 100, outputTokens: 50, costUsd: 0.0045 }, + }, + NODES[2]!, + NODES[3]!, + ] + render( + undefined) as never} + />, + ) + expect(screen.getByRole('button', { name: 'Request #1 · $0.0045' })).toBeTruthy() + }) + }) describe('node half', () => { diff --git a/packages/extensions/tool-lab/package.json b/packages/extensions/tool-lab/package.json new file mode 100644 index 0000000000..f1b708a841 --- /dev/null +++ b/packages/extensions/tool-lab/package.json @@ -0,0 +1,51 @@ +{ + "name": "@deepseek-ai/dsh-tool-lab", + "description": "Model-facing home-lab AI tools (lab_generate_image via ComfyUI, lab_ocr_pdf via Docling, lab_transcribe_audio via Whishper) over the lab's LAN services", + "version": "0.1.0-rc.7", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/extensions/tool-lab" + }, + "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", + "peerDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/dsh-fs": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^" + }, + "dependencies": { + "@deepseek-ai/schemastery": "workspace:^" + }, + "devDependencies": { + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/dsh-fs": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" + } +} \ No newline at end of file diff --git a/packages/extensions/tool-lab/src/comfy.ts b/packages/extensions/tool-lab/src/comfy.ts new file mode 100644 index 0000000000..6d9d6761c3 --- /dev/null +++ b/packages/extensions/tool-lab/src/comfy.ts @@ -0,0 +1,129 @@ +/** + * ComfyUI image generation tool. Talks to the lab's ComfyUI server (default + * http://192.168.31.240:8188) with a Juggernaut-XL workflow and returns the + * served image URL. @module @deepseek-ai/dsh-tool-lab + */ + +import { Deadline, sleep } from './helpers.ts' + +/** Root of the resolved lab config. */ +export interface ResolvedLabConfig { + comfyBaseUrl: string + doclingBaseUrl: string + whishBaseUrl: string + timeoutMs: number + maxUploadBytes: number + maxOutputChars: number +} + +/** Model-backed fields ComfyUI exposes on the workflow we build. */ +interface ComfyApiResult { + prompt_id?: string + node_errors?: Record +} + +interface ComfyHistoryEntry { + outputs?: Record + status?: { completed?: boolean; error?: unknown } +} + +/** A single ComfyUI generation request. */ +export interface GenerateImageArgs { + prompt: string + negative?: string + width?: number + height?: number + steps?: number + seed?: number + model?: string + upscale?: boolean + upscale_model?: string + filename_prefix?: string +} + +/** Compose the fixed 28-step Juggernaut-XL workflow from user-facing arguments. */ +export function buildWorkflow(args: GenerateImageArgs): Record { + const seed = args.seed ?? Math.floor(Math.random() * 1_000_000) + const width = args.width ?? 512 + const height = args.height ?? 512 + const steps = args.steps ?? 28 + const model = args.model ?? 'Juggernaut-XL_v9_RunDiffusionPhoto_v2.safetensors' + const negative = args.negative ?? 'blurry, low quality, distorted, watermark, text, extra limbs' + const upscale = args.upscale !== false + const upscaleModel = args.upscale_model ?? 'RealESRGAN_x4plus.safetensors' + const prefix = args.filename_prefix ?? 'dsh' + const workflow: Record = { + 3: { + class_type: 'KSampler', + inputs: { + seed, steps, cfg: 4.0, sampler_name: 'dpmpp_2m_sde', scheduler: 'karras', denoise: 1.0, + model: ['4', 0], positive: ['6', 0], negative: ['7', 0], latent_image: ['5', 0], + }, + }, + 4: { class_type: 'CheckpointLoaderSimple', inputs: { ckpt_name: model } }, + 5: { class_type: 'EmptyLatentImage', inputs: { width, height, batch_size: 1 } }, + 6: { class_type: 'CLIPTextEncode', inputs: { text: args.prompt, clip: ['4', 1] } }, + 7: { class_type: 'CLIPTextEncode', inputs: { text: negative, clip: ['4', 1] } }, + 8: { class_type: 'VAEDecode', inputs: { samples: ['3', 0], vae: ['4', 2] } }, + 9: { class_type: 'SaveImage', inputs: { filename_prefix: prefix, images: upscale ? ['10', 0] : ['8', 0] } }, + } + if (upscale) { + workflow['10'] = { class_type: 'ImageUpscaleWithModel', inputs: { upscale_model: ['11', 0], image: ['8', 0] } } + workflow['11'] = { class_type: 'UpscaleModelLoader', inputs: { model_name: upscaleModel } } + } + return workflow +} + +/** + * Poll ComfyUI `/history/{prompt_id}` until the image appears or the timeout + * budget elapses. Returns the served image URL. + */ +async function pollImage( + baseUrl: string, + promptId: string, + deadline: Deadline, + timeoutMs: number, +): Promise { + const started = Date.now() + for (;;) { + deadline.check() + const res = await fetch(`${baseUrl}/history/${promptId}`) + if (!res.ok) throw new Error(`ComfyUI history ${res.status}: ${await res.text()}`) + const history = (await res.json()) as Record + const entry = history[promptId] + if (entry?.outputs?.['9']?.images?.length) { + const img = entry.outputs['9'].images[0] + if (!img) throw new Error('ComfyUI history entry missing image 0') + return `${baseUrl}/view?filename=${encodeURIComponent(img.filename ?? '')}&subfolder=${encodeURIComponent(img.subfolder ?? '')}&type=${encodeURIComponent(img.type ?? 'output')}` + } + if (entry?.status?.error) throw new Error(`ComfyUI generation failed: ${JSON.stringify(entry.status.error)}`) + if (Date.now() - started >= timeoutMs) throw new Error(`ComfyUI generation timed out after ${timeoutMs}ms`) + await sleep(1500, deadline.signal) + } +} + +/** Run the ComfyUI tool. Reads no files; returns a served image URL string. */ +export async function runGenerateImage( + config: ResolvedLabConfig, + args: GenerateImageArgs, + signal: AbortSignal | undefined, +): Promise { + const deadline = new Deadline('generate_image', config.timeoutMs, signal) + try { + const clientId = `dsh-${Math.random().toString(36).slice(2)}` + const workflow = buildWorkflow(args) + const submit = await fetch(`${config.comfyBaseUrl}/prompt`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ prompt: workflow, client_id: clientId }), + }) + if (!submit.ok) throw new Error(`ComfyUI /prompt ${submit.status}: ${await submit.text()}`) + const result = (await submit.json()) as ComfyApiResult + if (!result.prompt_id) throw new Error(`ComfyUI /prompt no prompt_id: ${JSON.stringify(result)}`) + const errors = result.node_errors + if (errors && Object.keys(errors).length > 0) throw new Error(`ComfyUI node_errors: ${JSON.stringify(errors)}`) + return await pollImage(config.comfyBaseUrl, result.prompt_id, deadline, config.timeoutMs) + } finally { + deadline.dispose() + } +} diff --git a/packages/extensions/tool-lab/src/docling.ts b/packages/extensions/tool-lab/src/docling.ts new file mode 100644 index 0000000000..b8c1cdf699 --- /dev/null +++ b/packages/extensions/tool-lab/src/docling.ts @@ -0,0 +1,85 @@ +/** + * Docling OCR tool. Reads a local PDF via `ctx.fs`, uploads it to the lab's + * Docling server, polls the async task, and returns the extracted markdown. + * @module @deepseek-ai/dsh-tool-lab + */ + +import type { FileSystem } from '@deepseek-ai/dsh-fs' +import { Deadline, sleep } from './helpers.ts' +import type { ResolvedLabConfig } from './comfy.ts' + +interface DoclingPoll { + task_id?: string + task_status?: string +} + +interface DoclingResult { + document?: { filename?: string; md_content?: string | null } + status?: string + errors?: unknown[] +} + +/** Build FormData upload of a PDF from bytes. */ +export function buildPdfUpload(data: Uint8Array, filename: string): FormData { + const fd = new FormData() + fd.append('files', new Blob([data as unknown as BlobPart], { type: 'application/pdf' }), filename) + fd.append('options', JSON.stringify({})) + return fd +} + +/** Poll Docling `/v1/status/poll/{task_id}` until success. */ +async function pollTask( + baseUrl: string, + taskId: string, + deadline: Deadline, + timeoutMs: number, +): Promise { + const started = Date.now() + for (;;) { + deadline.check() + const res = await fetch(`${baseUrl}/v1/status/poll/${taskId}?wait=2`) + if (!res.ok) throw new Error(`Docling poll ${res.status}: ${await res.text()}`) + const poll = (await res.json()) as DoclingPoll + if (poll.task_status === 'success') return + if (poll.task_status === 'failed' || poll.task_status === 'error') { + throw new Error(`Docling task failed: ${await (await fetch(`${baseUrl}/v1/result/${taskId}`)).text()}`) + } + if (Date.now() - started >= timeoutMs) throw new Error(`Docling OCR timed out after ${timeoutMs}ms`) + await sleep(1200, deadline.signal) + } +} + +/** Run the Docling OCR tool: read file, upload, poll, return markdown text. */ +export async function runOcrPdf( + fs: FileSystem, + config: ResolvedLabConfig, + filePath: string, + signal: AbortSignal | undefined, +): Promise { + const deadline = new Deadline('ocr_pdf', config.timeoutMs, signal) + try { + const target = await fs.resolve(filePath, signal === undefined ? undefined : { signal }) + const stat = await fs.stat(target, signal) + if (!stat) throw new Error(`ocr_pdf: file not found: ${filePath}`) + const data = await fs.readBytes(target, signal, config.maxUploadBytes) + const filename = filePath.split(/[\\/]/).pop() ?? 'document.pdf' + const form = buildPdfUpload(data, filename) + const submit = await fetch(`${config.doclingBaseUrl}/v1/convert/file/async`, { + method: 'POST', + body: form, + }) + if (!submit.ok) throw new Error(`Docling /convert ${submit.status}: ${await submit.text()}`) + const poll = (await submit.json()) as DoclingPoll + if (!poll.task_id) throw new Error(`Docling no task_id: ${JSON.stringify(poll)}`) + await pollTask(config.doclingBaseUrl, poll.task_id, deadline, config.timeoutMs) + const res = await fetch(`${config.doclingBaseUrl}/v1/result/${poll.task_id}`) + if (!res.ok) throw new Error(`Docling result ${res.status}: ${await res.text()}`) + const result = (await res.json()) as DoclingResult + const md = result.document?.md_content ?? '' + if (md.length === 0) throw new Error('Docling returned empty markdown content') + if (md.length > config.maxOutputChars) return md.slice(0, config.maxOutputChars) + return md + } finally { + deadline.dispose() + } +} diff --git a/packages/extensions/tool-lab/src/helpers.ts b/packages/extensions/tool-lab/src/helpers.ts new file mode 100644 index 0000000000..d1567bf718 --- /dev/null +++ b/packages/extensions/tool-lab/src/helpers.ts @@ -0,0 +1,62 @@ +/** + * Shared helpers for the home-lab tool package: cooperative deadline enforcement + * and aborted sleep. @module @deepseek-ai/dsh-tool-lab + */ + +/** Error thrown when a lab tool exceeds its cooperative timeout budget. */ +export class LabToolTimeoutError extends Error { + constructor(tool: string, ms: number) { + super(`lab tool ${tool} timed out after ${ms}ms`) + this.name = 'LabToolTimeoutError' + } +} + +/** A deadline that aborts when the caller cancels or the budget expires. */ +export class Deadline { + readonly signal: AbortSignal | undefined + private readonly timer: ReturnType | undefined + private cancelled = false + private elapsed = false + + constructor( + private readonly tool: string, + private readonly timeoutMs: number, + signal: AbortSignal | undefined, + ) { + this.signal = signal + if (Number.isFinite(timeoutMs) && timeoutMs > 0) { + this.timer = setTimeout(() => this.expire(), timeoutMs) + } + signal?.addEventListener('abort', () => this.expire(), { once: true }) + } + + private expire(): void { + if (this.cancelled || this.elapsed) return + this.elapsed = true + } + + /** Throw if the budget has elapsed or the caller aborted. Cheap in loops. */ + check(): void { + if (this.signal?.aborted || this.elapsed) { + throw new LabToolTimeoutError(this.tool, this.timeoutMs) + } + } + + /** Cancel the timer when the tool finishes normally. */ + dispose(): void { + this.cancelled = true + if (this.timer !== undefined) clearTimeout(this.timer) + } +} + +/** Abortable sleep; resolves early or throws on cancellation/expiry. */ +export async function sleep(ms: number, signal: AbortSignal | undefined): Promise { + if (signal?.aborted) throw new Error('aborted') + await new Promise((resolve) => { + const t = setTimeout(resolve, ms) + signal?.addEventListener('abort', () => { + clearTimeout(t) + resolve() + }, { once: true }) + }) +} diff --git a/packages/extensions/tool-lab/src/index.ts b/packages/extensions/tool-lab/src/index.ts new file mode 100644 index 0000000000..46db541430 --- /dev/null +++ b/packages/extensions/tool-lab/src/index.ts @@ -0,0 +1,189 @@ +/** + * Model-facing home-lab AI tools. Registers `lab_generate_image` (ComfyUI), + * `lab_ocr_pdf` (Docling), and `lab_transcribe_audio` (Whishper) over the + * lab's LAN services. Tools read local files only via `ctx.fs`; uploads and + * polls happen over HTTP. Image results stay server-hosted as URLs. + * @module @deepseek-ai/dsh-tool-lab + */ + +import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' +import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import { defineTool } from '@deepseek-ai/dsh-tools' +import { runGenerateImage, type ResolvedLabConfig } from './comfy.ts' +import { runOcrPdf } from './docling.ts' +import { runTranscribeAudio } from './whish.ts' + +export { runGenerateImage, buildWorkflow, type GenerateImageArgs } from './comfy.ts' +export type { ResolvedLabConfig } from './comfy.ts' +export { runOcrPdf, buildPdfUpload } from './docling.ts' +export { runTranscribeAudio, buildAudioUpload } from './whish.ts' + +/** Cordis plugin name used by loader diagnostics. */ +export const name = 'tool-lab' + +/** Services required by the lab tool suite. */ +export const inject = ['tools', 'fs'] + +/** Default cooperative tool-call timeout budget (ms). */ +export const DEFAULT_LAB_TIMEOUT_MS = 120_000 + +/** Default cap on one upload in bytes and one output in characters. */ +export const DEFAULT_MAX_UPLOAD_BYTES = 20 * 1024 * 1024 +export const DEFAULT_MAX_OUTPUT_CHARS = 200_000 + +/** Plugin config: which lab tools to register, the base URLs, and limits. */ +export interface Config { + /** Register `lab_generate_image` (ComfyUI). Defaults to true. */ + generateImage?: boolean + /** Register `lab_ocr_pdf` (Docling). Defaults to true. */ + ocrPdf?: boolean + /** Register `lab_transcribe_audio` (Whishper). Defaults to true. */ + transcribeAudio?: boolean + /** ComfyUI base URL. Defaults to http://192.168.31.240:8188 */ + comfyBaseUrl?: string + /** Docling base URL. Defaults to http://192.168.31.159:5001 */ + doclingBaseUrl?: string + /** Whishper base URL. Defaults to http://192.168.31.159:8082 */ + whishBaseUrl?: string + /** Cooperative timeout budget (ms). Defaults to 120000. */ + timeoutMs?: number + /** Cap on upload bytes. Defaults to 20971520. */ + maxUploadBytes?: number + /** Cap on output characters. Defaults to 200000. */ + maxOutputChars?: number +} + +export const Config: z = z.object({ + generateImage: z.boolean().default(true), + ocrPdf: z.boolean().default(true), + transcribeAudio: z.boolean().default(true), + comfyBaseUrl: z.string().default('http://192.168.31.240:8188'), + doclingBaseUrl: z.string().default('http://192.168.31.159:5001'), + whishBaseUrl: z.string().default('http://192.168.31.159:8082'), + timeoutMs: z.number().default(DEFAULT_LAB_TIMEOUT_MS), + maxUploadBytes: z.number().default(DEFAULT_MAX_UPLOAD_BYTES), + maxOutputChars: z.number().default(DEFAULT_MAX_OUTPUT_CHARS), +}) + +/** Complete config after schemastery applies every field default. */ +type ResolvedConfig = Required + +/** Configured limits must be positive integers. */ +function assertPositiveInteger(field: string, value: number): void { + if (!Number.isInteger(value) || value < 1) { + throw new Error(`tool-lab: ${field} must be a positive integer`) + } +} + +/** Render one string value as a single text content block. */ +function renderText(_args: unknown, value: string): ContentBlock[] { + return [{ type: 'text', text: String(value) }] +} + +/** + * Register the enabled lab tools. Each tool's cooperative timeout budget is + * resolved here and attached as `ToolDefinition.timeoutMs`. Disposers are + * fiber-scoped (effect-based registries clean up on dispose), so no manual + * teardown is needed. + */ +export function apply(ctx: Context, config: Config): void { + const resolved = config as ResolvedConfig + assertPositiveInteger('timeoutMs', resolved.timeoutMs) + assertPositiveInteger('maxUploadBytes', resolved.maxUploadBytes) + assertPositiveInteger('maxOutputChars', resolved.maxOutputChars) + + const labConfig: ResolvedLabConfig = { + comfyBaseUrl: resolved.comfyBaseUrl, + doclingBaseUrl: resolved.doclingBaseUrl, + whishBaseUrl: resolved.whishBaseUrl, + timeoutMs: resolved.timeoutMs, + maxUploadBytes: resolved.maxUploadBytes, + maxOutputChars: resolved.maxOutputChars, + } + + if (resolved.generateImage) { + ctx.tools.register(defineTool({ + name: 'lab_generate_image', + description: + 'Generate an image on the home-lab ComfyUI server (Juggernaut-XL V9, 512x512 base, upscaled 4x via RealESRGAN by default). Returns a URL to the generated PNG hosted on that server. Give a descriptive prompt; optionally adjust model, width, height, steps, seed, negative, upscale, upscale_model, or filename_prefix.', + parameters: { + prompt: { type: 'string', description: 'Positive prompt describing the desired image.', required: true }, + negative: { type: 'string', description: 'Negative prompt.' }, + width: { type: 'integer', description: 'Image width in pixels. Default 512.' }, + height: { type: 'integer', description: 'Image height in pixels. Default 512.' }, + steps: { type: 'integer', description: 'Sampling steps. Default 28 (Juggernaut).' }, + seed: { type: 'integer', description: 'Random seed. Defaults to a random value.' }, + model: { type: 'string', description: 'Checkpoint model filename. Default Juggernaut-XL_v9_RunDiffusionPhoto_v2.safetensors.' }, + upscale: { type: 'boolean', description: 'Upscale the result 4x with RealESRGAN_x4plus. Default true.' }, + upscale_model: { type: 'string', description: 'Upscale model filename. Default RealESRGAN_x4plus.safetensors.' }, + filename_prefix: { type: 'string', description: 'Filename prefix for the saved image. Default dsh.' }, + }, + output: { + schema: { type: 'string' }, + render: renderText, + }, + timeoutMs: resolved.timeoutMs, + isConcurrencySafe: () => true, + async execute(args, exec) { + const imageArgs = args as { + prompt: string + negative?: string + width?: number + height?: number + steps?: number + seed?: number + model?: string + upscale?: boolean + upscale_model?: string + filename_prefix?: string + } + return runGenerateImage(labConfig, imageArgs, exec.signal) + }, + })) + } + + if (resolved.ocrPdf) { + ctx.tools.register(defineTool({ + name: 'lab_ocr_pdf', + description: + 'Extract text from a PDF using the home-lab Docling OCR server. Reads the local PDF and returns the recognized markdown text. Provide the local path to the PDF.', + parameters: { + file_path: { type: 'string', description: 'Local path to the PDF file to OCR.', required: true }, + }, + output: { + schema: { type: 'string' }, + render: renderText, + }, + timeoutMs: resolved.timeoutMs, + isConcurrencySafe: () => true, + execute(args, exec) { + const fs = ctx.fs + return runOcrPdf(fs, labConfig, args.file_path, exec.signal) + }, + })) + } + + if (resolved.transcribeAudio) { + ctx.tools.register(defineTool({ + name: 'lab_transcribe_audio', + description: + 'Transcribe speech from an audio file using the home-lab Whishper server. Returns the recognized text. Provide a local path to an audio file and optionally a language hint.', + parameters: { + file_path: { type: 'string', description: 'Local path to the audio file (wav, mp3, ogg, or m4a).', required: true }, + language: { type: 'string', description: 'Optional language code hint (e.g. ru, en).' }, + model_size: { type: 'string', description: 'Optional model size (e.g. small, base).' }, + }, + output: { + schema: { type: 'string' }, + render: renderText, + }, + timeoutMs: resolved.timeoutMs, + isConcurrencySafe: () => true, + execute(args, exec) { + const fs = ctx.fs + return runTranscribeAudio(fs, labConfig, args.file_path, args.language, args.model_size, exec.signal) + }, + })) + } +} diff --git a/packages/extensions/tool-lab/src/invariant.ts b/packages/extensions/tool-lab/src/invariant.ts new file mode 100644 index 0000000000..2c55bd8ae3 --- /dev/null +++ b/packages/extensions/tool-lab/src/invariant.ts @@ -0,0 +1,29 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-tool-lab`. + * @module @deepseek-ai/dsh-tool-lab/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from '@deepseek-ai/cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-tool-lab' + +/** Cordis companion plugin name. */ +export const name = 'tool-lab-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: this model-facing adapter has no independent lifecycle + * stream; execution relations are owned by the capability seam it calls. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) diff --git a/packages/extensions/tool-lab/src/whish.ts b/packages/extensions/tool-lab/src/whish.ts new file mode 100644 index 0000000000..55b7a23594 --- /dev/null +++ b/packages/extensions/tool-lab/src/whish.ts @@ -0,0 +1,84 @@ +/** + * Whishper speech-to-text tool. Reads local audio via `ctx.fs`, uploads it to + * the lab's Whishper server, polls the transcription, and returns the text. + * @module @deepseek-ai/dsh-tool-lab + */ + +import type { FileSystem } from '@deepseek-ai/dsh-fs' +import { Deadline, sleep } from './helpers.ts' +import type { ResolvedLabConfig } from './comfy.ts' + +interface WhishperTranscription { + id?: string + status?: number + result?: { text?: string | null; language?: string | null; duration?: number | null } +} + +/** Build multipart upload of an audio file. */ +export function buildAudioUpload(data: Uint8Array, filename: string, language?: string): FormData { + const fd = new FormData() + const ext = filename.split('.').pop()?.toLowerCase() ?? 'wav' + const type = ext === 'mp3' ? 'audio/mpeg' : ext === 'ogg' ? 'audio/ogg' : ext === 'm4a' ? 'audio/mp4' : 'audio/wav' + fd.append('files', new Blob([data as unknown as BlobPart], { type }), filename) + if (language) fd.append('language', language) + return fd +} + +/** Poll Whishper `/api/transcriptions/{id}` until text is ready or done. */ +async function pollTranscription( + baseUrl: string, + id: string, + deadline: Deadline, + timeoutMs: number, +): Promise { + const started = Date.now() + for (;;) { + deadline.check() + const res = await fetch(`${baseUrl}/api/transcriptions/${id}`) + if (!res.ok) throw new Error(`Whishper status ${res.status}: ${await res.text()}`) + const row = (await res.json()) as WhishperTranscription + const text = row.result?.text + if (typeof text === 'string' && text.length > 0 && row.status !== -1) return row + if (row.status !== -1 && (row.status === 1 || row.status === 2)) return row + if (Date.now() - started >= timeoutMs) throw new Error(`Whishper transcription timed out after ${timeoutMs}ms`) + await sleep(1500, deadline.signal) + } +} + +/** Run the Whishper tool: read audio, upload, poll, return transcription text. */ +export async function runTranscribeAudio( + fs: FileSystem, + config: ResolvedLabConfig, + filePath: string, + language: string | undefined, + modelSize: string | undefined, + signal: AbortSignal | undefined, +): Promise { + const deadline = new Deadline('transcribe_audio', config.timeoutMs, signal) + try { + const target = await fs.resolve(filePath, signal === undefined ? undefined : { signal }) + const statInfo = await fs.stat(target, signal) + if (!statInfo) throw new Error(`transcribe_audio: file not found: ${filePath}`) + const data = await fs.readBytes(target, signal, config.maxUploadBytes) + const filename = filePath.split(/[\\/]/).pop() ?? 'audio.wav' + const form = buildAudioUpload(data, filename, language) + if (modelSize) { + // Whishper server config may restrict model size; pass it as a field if supported. + form.append('modelSize', modelSize) + } + const submit = await fetch(`${config.whishBaseUrl}/api/transcriptions`, { + method: 'POST', + body: form, + }) + if (!submit.ok) throw new Error(`Whishper upload ${submit.status}: ${await submit.text()}`) + const row = (await submit.json()) as WhishperTranscription + if (!row.id) throw new Error(`Whishper no id: ${JSON.stringify(row)}`) + const done = await pollTranscription(config.whishBaseUrl, row.id, deadline, config.timeoutMs) + const text = done.result?.text ?? '' + if (text.length === 0) return '(transcription completed with no text)' + if (text.length > config.maxOutputChars) return text.slice(0, config.maxOutputChars) + return text + } finally { + deadline.dispose() + } +} diff --git a/packages/extensions/tool-lab/tests/tool-lab.spec.ts b/packages/extensions/tool-lab/tests/tool-lab.spec.ts new file mode 100644 index 0000000000..538bc04260 --- /dev/null +++ b/packages/extensions/tool-lab/tests/tool-lab.spec.ts @@ -0,0 +1,60 @@ +/** + * Unit tests for the pure helpers of `@deepseek-ai/dsh-tool-lab`. These + * assertions do not touch the network or the runtime; they check the workflow + * and upload builders only. + * @module @deepseek-ai/dsh-tool-lab/tests + */ + +import { describe, expect, it } from 'vitest' +import { buildWorkflow } from '../src/comfy.ts' +import { buildPdfUpload } from '../src/docling.ts' +import { buildAudioUpload } from '../src/whish.ts' + +/** One ComfyUI workflow node as seen by the assertions. */ +interface WorkflowNode { + class_type: string + inputs: Record +} + +/** Reinterpret the workflow builder's JSON-serializable output for assertions. */ +function wf(prompt: string, overrides: Record = {}): Record { + return buildWorkflow({ prompt, ...(overrides as object) }) as Record +} + +describe('buildWorkflow', () => { + it('renders a 28-step Juggernaut workflow with defaults', () => { + const flow = wf('a red fox') + expect(flow['4'].inputs.ckpt_name).toBe('Juggernaut-XL_v9_RunDiffusionPhoto_v2.safetensors') + expect(flow['9'].class_type).toBe('SaveImage') + expect(flow['9'].inputs.images).toEqual(['10', 0]) + expect(flow['10'].class_type).toBe('ImageUpscaleWithModel') + expect(flow['11'].inputs.model_name).toBe('RealESRGAN_x4plus.safetensors') + expect(flow['3'].inputs).toMatchObject({ steps: 28, cfg: 4.0, sampler_name: 'dpmpp_2m_sde', scheduler: 'karras' }) + }) + + it('skips upscaling when upscale is false', () => { + const flow = wf('p', { upscale: false }) + expect(flow['9'].inputs.images).toEqual(['8', 0]) + expect(flow['10']).toBeUndefined() + expect(flow['11']).toBeUndefined() + }) + + it('honors overrides and seed', () => { + const flow = wf('p', { width: 768, height: 640, steps: 8, seed: 99 }) + expect(flow['5'].inputs).toMatchObject({ width: 768, height: 640 }) + expect(flow['3'].inputs).toMatchObject({ seed: 99, steps: 8 }) + expect(flow['6'].inputs.text).toBe('p') + }) +}) + +describe('upload builders', () => { + it('builds a PDF upload carrying files and options parts', () => { + const fd = buildPdfUpload(new Uint8Array([1, 2, 3]), 'scan.pdf') + expect(fd).toBeDefined() + }) + + it('builds an audio upload', () => { + const fd = buildAudioUpload(new Uint8Array([1, 2]), 'clip.mp3') + expect(fd).toBeDefined() + }) +}) diff --git a/packages/extensions/tool-lab/tsconfig.json b/packages/extensions/tool-lab/tsconfig.json new file mode 100644 index 0000000000..7387f69e1c --- /dev/null +++ b/packages/extensions/tool-lab/tsconfig.json @@ -0,0 +1,28 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": ["src"], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../core/tools" + }, + { + "path": "../../fs/fs" + }, + { + "path": "../../runtime-diagnostics/invariants" + } + ] +} \ No newline at end of file diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index c675b60907..bf40d27d74 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -57,7 +57,7 @@ import { truncateUnicodeCodePoints, } from './api/session-search.ts' // Type-only: resolves `ctx.get('sessionProjections')` to the projection registry. -import type {} from '@deepseek-ai/dsh-session-projection' +import { foldContextOf } from '@deepseek-ai/dsh-session-projection' // Type-only: resolves `ctx.get('tasks')` to the background job registry. import type {} from '@deepseek-ai/dsh-jobs' import type { JobSnapshot } from '@deepseek-ai/dsh-jobs' @@ -838,11 +838,12 @@ function listProjectionsFor(ctx: Context, meta: SessionHeader, session: Session /** Projection baseline for a detached history tail without Agent activation. */ function detachedProjectionsFor( ctx: Context, + header: SessionHeader, events: readonly SessionEvent[], ): SessionProjectionsBlock | undefined { const registry = ctx.get('sessionProjections') if (registry === undefined) return undefined - return registry.restore({}, events, 0).snapshot + return registry.restore({}, events, 0, foldContextOf(header)).snapshot } /** @@ -1534,7 +1535,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro includeProjections: boolean, ): { events: SessionEvent[]; projections?: SessionProjectionsBlock } { if (source.kind === 'detached') { - const projections = includeProjections ? detachedProjectionsFor(ctx, source.events) : undefined + const projections = includeProjections ? detachedProjectionsFor(ctx, source.header, source.events) : undefined return { events: source.events, ...projections === undefined ? {} : { projections } } } const events = [...source.session.events] @@ -2632,7 +2633,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro header = inspected.meta events = inspected.events projections = beforeSeq === undefined - ? subagentHistoryProjections(ctx, childSessionId, () => detachedProjectionsFor(ctx, inspected.events)) + ? subagentHistoryProjections(ctx, childSessionId, () => detachedProjectionsFor(ctx, inspected.meta, inspected.events)) : undefined } catch (error: unknown) { if (signal?.aborted) { diff --git a/packages/llm/llm-pi-ai/src/stream.ts b/packages/llm/llm-pi-ai/src/stream.ts index 22aa7cb579..1e0da4321a 100644 --- a/packages/llm/llm-pi-ai/src/stream.ts +++ b/packages/llm/llm-pi-ai/src/stream.ts @@ -20,11 +20,17 @@ import { toPiReplayState } from './replay.ts' * @returns harness counts; cache fields appear only when non-zero (pi-ai reports zeros, not absence). */ export function mapUsage(usage: PiUsage): TokenUsage { + const usageRecord = usage as unknown as Record + const rawCost = usageRecord['cost'] ?? usageRecord['total_cost'] + const costUsd = typeof rawCost === 'number' && Number.isFinite(rawCost) && rawCost >= 0 + ? rawCost + : undefined return { inputTokens: usage.input, outputTokens: usage.output, ...usage.cacheRead > 0 ? { cacheReadTokens: usage.cacheRead } : {}, ...usage.cacheWrite > 0 ? { cacheWriteTokens: usage.cacheWrite } : {}, + ...costUsd !== undefined ? { costUsd } : {}, } } diff --git a/packages/llm/llm/src/types.ts b/packages/llm/llm/src/types.ts index 8c5be187dd..de5e22adbf 100644 --- a/packages/llm/llm/src/types.ts +++ b/packages/llm/llm/src/types.ts @@ -138,6 +138,7 @@ export interface TokenUsage { cacheReadTokens?: number cacheWriteTokens?: number reasoningTokens?: number + costUsd?: number } /** Display metadata for one registered provider route. */ diff --git a/packages/llm/openrouter-usage/README.md b/packages/llm/openrouter-usage/README.md index eca28c70b5..b6e11b3f47 100644 --- a/packages/llm/openrouter-usage/README.md +++ b/packages/llm/openrouter-usage/README.md @@ -43,7 +43,11 @@ is the available balance from `GET /credits` (`total_credits` minus the spent the monthly `usageTokens`/`limitTokens` budget from `GET /auth/key`, `isFreeTier`, and an `updatedAt` epoch. Before any successful fetch it serves an all-`null` -record; a failed refresh keeps the last-known snapshot and logs. The same key +record; a failed refresh keeps the last-known snapshot and logs. The +`refresh()` method fetches the account NOW and resolves with the resulting +snapshot, for a user-initiated read that must not wait for the next scheduled +tick; concurrent callers share one in-flight fetch, and a failed fetch +resolves with the last-known snapshot rather than rejecting. The same key also refreshes the model pricing table from `GET /models` (`pricing.prompt`/`completion` USD per token, plus a flat `request` fee and optional `input_cache_read`/`input_cache_write` when disclosed). @@ -54,7 +58,14 @@ step) against the pricing table. Attribution prefers the assembled message's own `provider`/`model`; a chunk-only (failed) step prices from the newest `request/context` route. A step on a non-`openrouter` provider is outside the domain and changes nothing; an OpenRouter step whose model has no pricing -entry counts as an unknown (unpriced) step. The projection's `steps` view +entry counts as an unknown (unpriced) step. + +Every value describes only its own session's work. A forked child session's +log opens with a verbatim copy of its parent's up to the header's +`seedLength`, and the fold skips that inherited prefix (reading only its +`request/context` route records, which carry no cost but attribute the +child's first chunk-only step). A session total plus its subagents' totals is +therefore a sum, never a double count. The projection's `steps` view field maps each priced step to its cost in USD under `${turn}:${step}` keys, so per-step surfaces can render spend without re-pricing; unpriced steps are absent from the map. @@ -95,7 +106,10 @@ usage events, never a new model-visible input. promotional pricing may differ from the model table. - **Pricing as of the fold** — the projection prices a cell with the model table current when that cell folds. Refreshing pricing only affects cells - folded afterward; already-folded history keeps its prior figures. + folded afterward; already-folded history keeps its prior figures. A session + folded while the table was empty (no key yet, or a failed first fetch) + keeps its steps recorded as unpriced until a `stateVersion` bump refolds + them. - **Per-token approximation** — cache-read/cache-write fall back to the prompt rate when the API does not disclose separate cache rates, and the flat request fee is charged once per step. Bills may differ by fractions diff --git a/packages/llm/openrouter-usage/src/index.ts b/packages/llm/openrouter-usage/src/index.ts index e7f7bedc08..cd6e978aba 100644 --- a/packages/llm/openrouter-usage/src/index.ts +++ b/packages/llm/openrouter-usage/src/index.ts @@ -103,6 +103,8 @@ export class OpenRouterUsageGateway extends TypertRemoteService { private readonly abortController = new AbortController() /** Authoritative settings thunk; re-pointed when the section (re)mounts. */ private currentSource: () => Config + /** In-flight on-demand balance refresh, shared by concurrent callers. */ + private pendingRefresh: Promise | undefined constructor(ctx: Context, config: Config = {}) { super(ctx, 'openRouterUsage') @@ -124,7 +126,7 @@ export class OpenRouterUsageGateway extends TypertRemoteService { // live pricing thunk. The unit child activates only when a projection // registry is composed (headless assemblies stay unaffected). ctx.inject(['sessionProjections'], (projectionCtx) => { - projectionCtx.sessionProjections.register(createOpenRouterCostProjection((model) => this.pricing.get(model))) + projectionCtx.sessionProjections.register(createOpenRouterCostProjection(model => this.lookupPricing(model))) }) ctx.effect(() => () => { @@ -158,6 +160,44 @@ export class OpenRouterUsageGateway extends TypertRemoteService { return { ...this.balance } } + /** + * Fetch the account snapshot NOW and serve the result, for a user-initiated + * read that must not wait for the next scheduled tick. Concurrent callers + * share one in-flight fetch, so repeated clicks cost one request. A failed + * fetch resolves with the last-known snapshot rather than rejecting — the + * caller's own `updatedAt` comparison tells it whether the figure moved. + * @returns the snapshot after the refresh attempt settled. + */ + @Remote('refresh') + async refresh(): Promise { + this.pendingRefresh ??= (async () => { + try { + await this.refreshBalance(this.resolve(this.currentSource())) + return { ...this.balance } + } finally { + this.pendingRefresh = undefined + } + })() + return await this.pendingRefresh + } + + /** + * Look up pricing for a model ID, with fallback for tagged variants (`:free`, etc.). + * @param model - model identifier. + * @returns matched rate, or undefined if unknown. + */ + lookupPricing(model: string): ModelPricing | undefined { + const direct = this.pricing.get(model) + if (direct !== undefined) return direct + if (model.includes(':')) { + const tagIndex = model.indexOf(':') + const base = model.slice(0, tagIndex) + const basePricing = this.pricing.get(base) + if (basePricing !== undefined) return basePricing + } + return undefined + } + /** Materialize plugin defaults against the validated section. */ private resolve(config: Config): Required { return { diff --git a/packages/llm/openrouter-usage/src/projection.ts b/packages/llm/openrouter-usage/src/projection.ts index 460f32f06f..3630b2e497 100644 --- a/packages/llm/openrouter-usage/src/projection.ts +++ b/packages/llm/openrouter-usage/src/projection.ts @@ -11,6 +11,14 @@ * Refreshing pricing only affects cells folded afterward — the documented * "as of fold" limitation. * + * Inherited history: a forked child session opens with a verbatim copy of its + * parent's log up to `header.seedLength`. Those steps are the parent's spend, + * which the parent's own value already reports, so the fold skips them and + * every value describes only the session's OWN work — a parent total plus its + * children's totals is a sum, never a double count. `request/context` records + * are still read from the inherited prefix: they carry no cost and the route + * they establish is what attributes the child's first chunk-only step. + * * Model attribution: an `assistant/message` carries its own provider/model in * `message.source`; a chunk-only (failed) step has none, so the fold prices it * from the newest `request/context` last-wins record. A step on a provider that @@ -24,7 +32,7 @@ import { z } from 'zod' import type { TokenUsage } from '@deepseek-ai/dsh-llm' import type { SessionEvent } from '@deepseek-ai/dsh-session' -import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' +import type { ProjectionDefinition, ProjectionFoldContext } from '@deepseek-ai/dsh-session-projection' import type { ModelPricing, OpenRouterCost } from './types.ts' /** The provider route this projection prices; LLM routing must land here. */ @@ -84,7 +92,13 @@ export function createOpenRouterCostProjection( key: 'openRouterCost', schema: costSchema as unknown as z.ZodType, init: () => ({ totalUsd: 0, pricedSteps: 0, unknownModelSteps: 0, steps: {}, last: null, lastModel: null }), - apply: (state, event: SessionEvent) => { + apply: (state, event: SessionEvent, context: ProjectionFoldContext) => { + // A forked child's log opens with a verbatim copy of its parent's log. + // That spend belongs to the parent — which already reports it — so + // pricing it here would report it twice, once per forked child. The + // route records still apply: attribution carries across the boundary, + // so `request/context` below is read from the inherited prefix too. + if (event.seq < context.seedLength && event.type !== 'request/context') return state if (event.type === 'request/context') { const nextModel = { provider: event.data.provider, model: event.data.model } if (state.lastModel?.provider === nextModel.provider && state.lastModel?.model === nextModel.model) return state @@ -106,14 +120,21 @@ export function createOpenRouterCostProjection( } else { return state } - // A non-openrouter step is outside this plugin's domain: never counted, - // never recorded (a later, correctly-attributed message for the same - // step must still land fresh). - if (attribution === undefined || attribution.provider !== OPENROUTER_PROVIDER) return state + const directCost = usage.costUsd + const pricing = attribution !== undefined ? pricingOf(attribution.model) : undefined + const isOpenRouterProvider = attribution !== undefined && ( + attribution.provider === OPENROUTER_PROVIDER + || attribution.provider.toLowerCase().includes('openrouter') + ) - const pricing = pricingOf(attribution.model) - const priced = pricing !== undefined - const costUsd = priced ? stepCostUsd(usage, pricing) : 0 + if (directCost === undefined && !isOpenRouterProvider && pricing === undefined) return state + + const costUsd = directCost !== undefined + ? directCost + : pricing !== undefined + ? stepCostUsd(usage, pricing) + : 0 + const priced = directCost !== undefined || pricing !== undefined const previous = state.last !== null && state.last.turn === turn && state.last.step === step ? state.last : null @@ -144,6 +165,6 @@ export function createOpenRouterCostProjection( steps: state.steps, currency: 'USD', }), - stateVersion: 2, + stateVersion: 4, } } diff --git a/packages/llm/openrouter-usage/src/types.ts b/packages/llm/openrouter-usage/src/types.ts index e51c1df2aa..8969ba8df8 100644 --- a/packages/llm/openrouter-usage/src/types.ts +++ b/packages/llm/openrouter-usage/src/types.ts @@ -33,11 +33,13 @@ export interface ModelPricing { } /** - * Whole-log OpenRouter spend for one session, priced from the logged token - * usage of its steps against the pricing table current at fold time. Every - * field is 0 until its first contributing priced step lands; a session whose - * provider route is not `openrouter`, or whose models have no pricing entry, - * stays all-zero. + * One session's OWN OpenRouter spend, priced from the logged token usage of + * its steps against the pricing table current at fold time. A forked child's + * inherited log prefix (the parent's history up to `header.seedLength`) is + * excluded, so a parent value plus its subagents' values is a sum rather + * than a double count. Every field is 0 until its first contributing priced + * step lands; a session whose provider route is not `openrouter`, or whose + * models have no pricing entry, stays all-zero. */ export interface OpenRouterCost { /** Summed USD over steps priced against a known model entry. */ diff --git a/packages/llm/openrouter-usage/tests/loader-composition.spec.ts b/packages/llm/openrouter-usage/tests/loader-composition.spec.ts index 6f1aaa0cd8..a19ebfd1ce 100644 --- a/packages/llm/openrouter-usage/tests/loader-composition.spec.ts +++ b/packages/llm/openrouter-usage/tests/loader-composition.spec.ts @@ -38,6 +38,7 @@ function stubOpenRouter() { const pricing = [ { id: 'deepseek/deepseek-chat', pricing: { prompt: '0.0000014', completion: '0.0000028', request: '0' } }, ] + const credits = { total_credits: 42, total_usage: 1, is_free_tier: false } const calls: string[] = [] vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => { const url = String(input) @@ -46,9 +47,7 @@ function stubOpenRouter() { return new Response(JSON.stringify({ data: pricing }), { status: 200 }) } if (url.endsWith('/credits')) { - return new Response(JSON.stringify({ - data: { total_credits: 42, total_usage: 1, is_free_tier: false }, - }), { status: 200 }) + return new Response(JSON.stringify({ data: { ...credits } }), { status: 200 }) } if (url.endsWith('/auth/key')) { return new Response(JSON.stringify({ @@ -57,7 +56,7 @@ function stubOpenRouter() { } return new Response('not found', { status: 404 }) })) - return { calls } + return { calls, credits } } async function loadComposition(): Promise { @@ -170,4 +169,39 @@ describe('openrouter-usage real composition', () => { expect(balance.currency).toBe('USD') expect(balance.updatedAt).not.toBeNull() }) + + it('re-fetches the account on demand and serves the moved figure', async () => { + const openRouter = stubOpenRouter() + const loaded = await loadComposition() + + await vi.waitFor(() => { + expect(openRouter.calls.some(url => url.endsWith('/credits'))).toBe(true) + }, { timeout: 5000 }) + expect(loaded.openRouterUsage.snapshot().balanceUsd).toBe(41) + + openRouter.credits.total_usage = 12 + await expect(loaded.openRouterUsage.refresh()).resolves.toMatchObject({ balanceUsd: 30 }) + expect(loaded.openRouterUsage.snapshot().balanceUsd).toBe(30) + }) + + it('shares one in-flight fetch across concurrent on-demand refreshes', async () => { + const openRouter = stubOpenRouter() + const loaded = await loadComposition() + + await vi.waitFor(() => { + expect(openRouter.calls.some(url => url.endsWith('/credits'))).toBe(true) + }, { timeout: 5000 }) + const before = openRouter.calls.filter(url => url.endsWith('/credits')).length + + const [first, second] = await Promise.all([ + loaded.openRouterUsage.refresh(), + loaded.openRouterUsage.refresh(), + ]) + expect(first).toEqual(second) + expect(openRouter.calls.filter(url => url.endsWith('/credits')).length).toBe(before + 1) + + // The shared promise is released once it settles, so a later click fetches again. + await loaded.openRouterUsage.refresh() + expect(openRouter.calls.filter(url => url.endsWith('/credits')).length).toBe(before + 2) + }) }) diff --git a/packages/llm/openrouter-usage/tests/projection.spec.ts b/packages/llm/openrouter-usage/tests/projection.spec.ts index d1697effb1..e5cf1bd6c8 100644 --- a/packages/llm/openrouter-usage/tests/projection.spec.ts +++ b/packages/llm/openrouter-usage/tests/projection.spec.ts @@ -224,6 +224,28 @@ describe('openRouterCost session projection', () => { expect(projected(ctx, session)).toEqual({ totalUsd: 0, pricedSteps: 0, unknownModelSteps: 1, steps: {}, currency: 'USD' }) }) + it('uses explicitly logged costUsd on usage directly even without pricing table entry', async () => { + const { ctx, session } = await harness() + session.append('request/context', { provider: 'openrouter', model: 'unlisted/custom-model' }) + startStep(session, 1, 1) + session.append('assistant/chunk', { + turn: 1, + step: 1, + chunk: { + type: 'usage', + usage: { inputTokens: 500, outputTokens: 200, costUsd: 0.0042 }, + }, + }) + session.append('step/end', { turn: 1, step: 1 }) + expect(projected(ctx, session)).toEqual({ + totalUsd: 0.0042, + pricedSteps: 1, + unknownModelSteps: 0, + steps: { '1:1': 0.0042 }, + currency: 'USD', + }) + }) + it('pushes no change for unrelated events', async () => { const { ctx, session } = await harness() recordContext(session) @@ -235,6 +257,47 @@ describe('openRouterCost session projection', () => { expect(changed).not.toContain('openRouterCost') }) + it('excludes the inherited prefix of a forked child, so parent + child is a sum', async () => { + const { ctx, session: parent } = await harness() + recordContext(parent) + startStep(parent, 1, 1) + const parentSource = usageChunk(parent, { inputTokens: 1_000, outputTokens: 500 }, 1, 1) + finalUsage(parent, { inputTokens: 1_000, outputTokens: 500 }, 1, 1, [parentSource]) + const parentCost = 1_000 * 1.4e-6 + 500 * 2.8e-6 + expect(projected(ctx, parent).totalUsd).toBe(parentCost) + + // The fork: the child's log opens with a verbatim copy of the parent's. + const seed = [...parent.events] + const child = ctx.sessions.create(undefined, { seed, meta: { seedLength: seed.length } }) + startStep(child, 2, 1) + const childSource = usageChunk(child, { inputTokens: 10, outputTokens: 4 }, 2, 1) + finalUsage(child, { inputTokens: 10, outputTokens: 4 }, 2, 1, [childSource]) + const childCost = 10 * 1.4e-6 + 4 * 2.8e-6 + + const childValue = projected(ctx, child) + expect(childValue.totalUsd).toBe(childCost) + expect(childValue.pricedSteps).toBe(1) + expect(childValue.steps).toEqual({ '2:1': childCost }) + // The parent's own figure is untouched by the fork. + expect(projected(ctx, parent).totalUsd).toBe(parentCost) + }) + + it('attributes a chunk-only step of a forked child from a route in the inherited prefix', async () => { + const { ctx, session: parent } = await harness() + // The route record is the LAST thing the parent logs, so only the + // inherited prefix can supply the child's attribution. + recordContext(parent) + const seed = [...parent.events] + const child = ctx.sessions.create(undefined, { seed, meta: { seedLength: seed.length } }) + startStep(child, 1, 1) + usageChunk(child, { inputTokens: 100, outputTokens: 20 }, 1, 1) + expect(projected(ctx, child)).toMatchObject({ + totalUsd: 100 * 1.4e-6 + 20 * 2.8e-6, + pricedSteps: 1, + unknownModelSteps: 0, + }) + }) + it('restores from a JSON checkpoint', async () => { const { ctx, session } = await harness() recordContext(session) diff --git a/packages/llm/token-meter/tests/context-breakdown-projection.spec.ts b/packages/llm/token-meter/tests/context-breakdown-projection.spec.ts index 350ed335c9..e9d75e0ddc 100644 --- a/packages/llm/token-meter/tests/context-breakdown-projection.spec.ts +++ b/packages/llm/token-meter/tests/context-breakdown-projection.spec.ts @@ -7,7 +7,7 @@ import { createMessage, createUserMessage } from '@deepseek-ai/dsh-llm' import type { ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm' import SessionStore from '@deepseek-ai/dsh-session' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' -import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' +import SessionProjectionRegistry, { foldContextOf } from '@deepseek-ai/dsh-session-projection' import TokenMeter from '@deepseek-ai/dsh-token-meter' import type { ContextBreakdownProjection } from '@deepseek-ai/dsh-token-meter/client' import { CompactionId } from '@deepseek-ai/dsh-compaction' @@ -184,6 +184,9 @@ describe('contextBreakdown session projection', () => { it('folds a replacement without a claim at zero and fails on a mismatched claim', () => { const definition = contextBreakdownProjectionDefinition + // The unit ignores the fold context; this session inherits no fork prefix. + const applyBreakdown = (state: Parameters[0], event: SessionEvent) => + definition.apply(state, event, foldContextOf({})) const replace = (start: number, end: number): SessionEvent => ({ type: 'user/message', seq: 9, @@ -206,22 +209,22 @@ describe('contextBreakdown session projection', () => { data: { shadowedRange: { start, end }, shadowedSeqs: [start, end], shadowedTokenCount: 5 }, } as unknown as SessionEvent) let state = definition.init() - state = definition.apply(state, append(1)) - state = definition.apply(state, append(3)) + state = applyBreakdown(state, append(1)) + state = applyBreakdown(state, append(3)) // No metering event: the replacement contributes zero instead of throwing. - expect(definition.view(definition.apply(state, replace(1, 3))).messageTokens) + expect(definition.view(applyBreakdown(state, replace(1, 3))).messageTokens) .toBe(definition.view(state).messageTokens) // An adjacent claim for another range contradicts the replacement. - const mismatched = definition.apply(state, meter(1, 1, 8)) - expect(() => definition.apply(mismatched, replace(1, 3))).toThrow('no adjacent shadow price') + const mismatched = applyBreakdown(state, meter(1, 1, 8)) + expect(() => applyBreakdown(mismatched, replace(1, 3))).toThrow('no adjacent shadow price') // A claim expires after one intervening event, so replacement delta is zero. - let expired = definition.apply(state, meter(1, 3, 8)) - expired = definition.apply(expired, { type: 'todo/write', seq: 9, time: 0, data: { todos: [] } } as unknown as SessionEvent) - expect(definition.view(definition.apply(expired, replace(1, 3))).messageTokens) + let expired = applyBreakdown(state, meter(1, 3, 8)) + expired = applyBreakdown(expired, { type: 'todo/write', seq: 9, time: 0, data: { todos: [] } } as unknown as SessionEvent) + expect(definition.view(applyBreakdown(expired, replace(1, 3))).messageTokens) .toBe(definition.view(state).messageTokens) // The armed claim prices exactly the next event's matching replacement. - const armed = definition.apply(state, meter(1, 3, 8)) - expect(definition.view(definition.apply(armed, replace(1, 3))).messageTokens) + const armed = applyBreakdown(state, meter(1, 3, 8)) + expect(definition.view(applyBreakdown(armed, replace(1, 3))).messageTokens) .toBe(definition.view(state).messageTokens - 5 + estimateMessage( createUserMessage({ content: [{ type: 'text', text: 'x' }], source: { kind: 'user' } }), )) diff --git a/packages/session/session-projection-cache/src/index.ts b/packages/session/session-projection-cache/src/index.ts index 66ad637402..0c96e5471a 100644 --- a/packages/session/session-projection-cache/src/index.ts +++ b/packages/session/session-projection-cache/src/index.ts @@ -19,6 +19,7 @@ import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek- // Empty type import: applies the package's cordis Context merge // (`ctx.sessionPersistence`), which this service reads on the cold path. import type {} from '@deepseek-ai/dsh-session-persistence' +import { foldContextOf } from '@deepseek-ai/dsh-session-projection' import type { ProjectionCheckpoint, ProjectionSnapshot } from '@deepseek-ai/dsh-session-projection' import type { KvTable } from '@deepseek-ai/dsh-storage-domain' import { projectionCacheDomainSpec } from './spec.ts' @@ -183,14 +184,14 @@ export class SessionProjectionCache extends Service { const related = record === undefined || identityMatches(record.identity, identityOf(tail.meta)) try { if (!related) throw new Error('unrelated log identity') - restored = this.ctx.sessionProjections.restore(cached, tail.events, floor) + restored = this.ctx.sessionProjections.restore(cached, tail.events, floor, foldContextOf(tail.meta)) } catch { // The recoverable restore failures: an unrelated record, or a row // overreaching the stored log end (or predating the floor). Both imply // floor > 0 (baseSeq-0 restores never throw and an unrelated record // still carried a usable watermark), so the full log is a fresh read. const whole = await persistence.readFrom(id, 0, signal) - restored = this.ctx.sessionProjections.restore({}, whole.events, 0) + restored = this.ctx.sessionProjections.restore({}, whole.events, 0, foldContextOf(whole.meta)) } await this.putSoft(id, identityOf(tail.meta), restored.checkpoint, 'cold-read write-back') return restored.snapshot diff --git a/packages/session/session-projection/README.md b/packages/session/session-projection/README.md index 9018b133bb..8aa1ad7c30 100644 --- a/packages/session/session-projection/README.md +++ b/packages/session/session-projection/README.md @@ -11,15 +11,18 @@ Session-projection Service Definition and drive registry. It owns `ctx.sessionPr - `ctx.sessionProjections.register(definition): () => void` Register one domain's unit. Duplicate keys and invalid `stateVersion` throw; the registration is an effect on the calling fiber, so an unloaded domain plugin's key (with its cached cells) disappears from subsequent drives and snapshots — clients read that as capability absence. - `ctx.sessionProjections.onChanged(listener): () => void` Subscribe to the change feed: one call per unit whose state reference changed, per committed event, carrying the schema-validated view and the causing seq. Effect-tied like `register`. - `ctx.sessionProjections.snapshot(session): ProjectionSnapshot` One consistent synchronous cut over every registered unit — `{ asOfSeq, values }` with `asOfSeq` = the seq of the last event every value reflects (`-1` for an empty log). +- `foldContextOf(header): ProjectionFoldContext` Derive the per-session fold context from a session header. Every `apply` call receives one; a caller folding a detached log (`restore`) supplies it explicitly. ### Key Types - `SessionProjectionMap` — the single merge-extensible type table for the whole chain (host unit, wire block, React hook). Values are wire-JSON whole values; rendering belongs to the slot system, never this layer. -- `ProjectionDefinition` — `{ key, schema, init(), apply(state, event), view(state), stateVersion }`: a state-driven computation unit of three pure synchronous functions plus declarations, never an opaque getter. +- `ProjectionDefinition` — `{ key, schema, init(), apply(state, event, context), view(state), stateVersion }`: a state-driven computation unit of three pure synchronous functions plus declarations, never an opaque getter. +- `ProjectionFoldContext` — per-session header facts a unit folds against beyond the event stream: `seedLength`, the durable fork-lineage boundary. ## Contract - **The framework drives, the domain computes.** The registry subscribes to `session/event` once; every committed event passes every unit's `apply` eagerly. Domains hold no subscriptions. Cells (`{state, observedSeq}` per unit per session, WeakMap-keyed) build lazily — a unit registered after events flowed, or a read of a session predating the registration, folds `init` over the in-memory log on first touch. +- **A unit reads session facts through the fold context, never a Session.** Every `apply` call carries `ProjectionFoldContext`, derived from the session's durable header. Its `seedLength` is the fork-lineage boundary: events with `seq < seedLength` were inherited from the forked parent rather than produced by this session. A unit whose value must describe only this session's OWN work (spend, step counts) skips them, so a parent value plus its children's values is a sum rather than a double count; a unit describing the whole conversation (context pressure, the visible transcript) folds them like any other event. A unit needing neither declares two parameters and ignores the third. - **Same-reference means no work.** `apply` MUST return the same state reference for events that do not concern the unit; the drive gates the change feed on `Object.is`, so non-matching events cost one call and nothing downstream. - **Whole-value event rule (load-bearing).** A state-carrying log event MUST carry the complete post-change state, never a bare delta — it keeps every transition trivially cheap and every served value self-describing (last-wins for consumers). - **Synchronous unit discipline.** `init`/`apply`/`view` MUST be synchronous; carriers read `snapshot()` in the same tick as their page slice, which is what makes `asOfSeq` one consistent cut. An accidentally-async `view` returns a Promise, which fails the boundary `schema.parse` loudly. diff --git a/packages/session/session-projection/src/index.ts b/packages/session/session-projection/src/index.ts index 9f0c24e72e..1f7d16aba5 100644 --- a/packages/session/session-projection/src/index.ts +++ b/packages/session/session-projection/src/index.ts @@ -19,7 +19,7 @@ import { Context, Service } from '@deepseek-ai/cordis' import type { ZodType } from 'zod' -import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' +import type { Session, SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' declare module '@deepseek-ai/cordis' { interface Context { @@ -31,6 +31,32 @@ import type { SessionProjectionMap } from './types.ts' export type { SessionProjectionMap } from './types.ts' +/** + * Per-session facts a unit folds against beyond the event stream itself. + * Supplied by the framework on every `apply` call and derived from the + * session's durable header, so a unit never reads a Session. + */ +export interface ProjectionFoldContext { + /** + * The session header's durable fork-lineage boundary: events with + * `seq < seedLength` were inherited from the forked parent rather than + * produced by this session. A unit whose value must describe only this + * session's OWN work skips them; a unit describing the whole conversation + * (context pressure, the visible transcript) folds them like any other + * event. 0 for a session with no inherited prefix. + */ + readonly seedLength: number +} + +/** + * Derive the fold context of one session's header. + * @param header - the session's durable header. + * @returns the context every `apply` call for that session receives. + */ +export function foldContextOf(header: Pick): ProjectionFoldContext { + return { seedLength: header.seedLength ?? 0 } +} + /** * One domain's state-driven computation unit: three pure synchronous * functions plus declarations — never an opaque getter. The framework drives @@ -55,9 +81,10 @@ export interface ProjectionDefinition { * unchanged reference (`Object.is`) produces zero downstream work. * @param state - the state covering all prior events. * @param event - the next committed session event. + * @param context - per-session header facts; a unit that needs none may declare two parameters. * @returns the next state (same reference when the event is not the unit's). */ - apply(state: S, event: SessionEvent): S + apply(state: S, event: SessionEvent, context: ProjectionFoldContext): S /** * State → wire payload (the read-side projection). * @param state - the current state. @@ -122,7 +149,7 @@ interface ErasedDefinition { key: string schema: { parse(value: unknown): unknown } init(): unknown - apply(state: unknown, event: SessionEvent): unknown + apply(state: unknown, event: SessionEvent, context: ProjectionFoldContext): unknown view(state: unknown): unknown stateVersion: number } @@ -348,12 +375,17 @@ export class SessionProjectionRegistry extends Service { * @param checkpoint - persisted rows for one session (possibly stale or empty). * @param events - the stored events with `seq >= baseSeq`, in seq order. * @param baseSeq - the seq `events` starts at (its first event's seq when non-empty). + * @param context - the stored header's fold context (see {@link foldContextOf}). * @returns the snapshot cut at the supplied log end (`asOfSeq` is the last * supplied event's seq, `baseSeq - 1` for an empty tail) plus the * refreshed checkpoint rows at that cut, ready for a durable write-back. */ - restore(checkpoint: ProjectionCheckpoint, events: readonly SessionEvent[], baseSeq: number): - { snapshot: ProjectionSnapshot; checkpoint: ProjectionCheckpoint } { + restore( + checkpoint: ProjectionCheckpoint, + events: readonly SessionEvent[], + baseSeq: number, + context: ProjectionFoldContext, + ): { snapshot: ProjectionSnapshot; checkpoint: ProjectionCheckpoint } { const endSeq = events.at(-1)?.seq ?? baseSeq - 1 const values: Record = {} const refreshed: ProjectionCheckpoint = {} @@ -373,7 +405,7 @@ export class SessionProjectionRegistry extends Service { let state = usable ? row.val : def.init() const from = usable ? row.seq : baseSeq - 1 for (const event of events) { - if (event.seq > from) state = def.apply(state, event) + if (event.seq > from) state = def.apply(state, event, context) } values[def.key] = def.schema.parse(def.view(state)) refreshed[def.key] = { ver: def.stateVersion, seq: endSeq, val: state } @@ -385,9 +417,13 @@ export class SessionProjectionRegistry extends Service { } /** Fold one unit from init over `events`, producing a cell watermarked at the last folded event. */ - private buildCell(def: ErasedDefinition, events: readonly SessionEvent[]): UnitCell { + private buildCell( + def: ErasedDefinition, + events: readonly SessionEvent[], + context: ProjectionFoldContext, + ): UnitCell { let state = def.init() - for (const event of events) state = def.apply(state, event) + for (const event of events) state = def.apply(state, event, context) return { state, observedSeq: (events.at(-1)?.seq ?? -1) } } @@ -395,7 +431,7 @@ export class SessionProjectionRegistry extends Service { private cellFor(registration: Registration, session: Session): UnitCell { let cell = registration.cells.get(session) if (cell === undefined) { - cell = this.buildCell(registration.def, session.events) + cell = this.buildCell(registration.def, session.events, foldContextOf(session.header)) registration.cells.set(session, cell) } return cell @@ -403,15 +439,16 @@ export class SessionProjectionRegistry extends Service { /** Eager drive: pass one committed event through every registered unit; notify on changed references. */ private drive(session: Session, event: SessionEvent): void { + const context = foldContextOf(session.header) for (const registration of this.registrations.values()) { let cell = registration.cells.get(session) if (cell === undefined) { // Late build mid-stream: fold history before this event (seq = log // index, so the prefix slice is exact), then take the normal gate. - cell = this.buildCell(registration.def, session.events.slice(0, event.seq)) + cell = this.buildCell(registration.def, session.events.slice(0, event.seq), context) registration.cells.set(session, cell) } - const next = registration.def.apply(cell.state, event) + const next = registration.def.apply(cell.state, event, context) const changed = !Object.is(next, cell.state) cell.state = next cell.observedSeq = event.seq diff --git a/packages/session/session-projection/tests/registry.spec.ts b/packages/session/session-projection/tests/registry.spec.ts index 4ae4a3face..95f52c9f5e 100644 --- a/packages/session/session-projection/tests/registry.spec.ts +++ b/packages/session/session-projection/tests/registry.spec.ts @@ -12,9 +12,12 @@ import { Context } from '@deepseek-ai/cordis' import { z } from 'zod' import SessionStore from '@deepseek-ai/dsh-session' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' -import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' +import SessionProjectionRegistry, { foldContextOf } from '@deepseek-ai/dsh-session-projection' import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' +/** The fold context of a session with no inherited fork prefix. */ +const NO_SEED = foldContextOf({}) + declare module '@deepseek-ai/dsh-session-projection/types' { interface SessionProjectionMap { 'test/marks': { marks: string[] } @@ -49,6 +52,19 @@ const countUnit = (): ProjectionDefinition<'test/count', number> => ({ stateVersion: 1, }) +/** + * A unit counting only events the fold context marks as this session's OWN + * work, so its value proves the context reached `apply`. + */ +const ownCountUnit = (): ProjectionDefinition<'test/count', number> => ({ + key: 'test/count', + schema: z.number().int().nonnegative(), + init: () => 0, + apply: (state, event, context) => (event.seq < context.seedLength ? state : state + 1), + view: state => state, + stateVersion: 1, +}) + async function harness(): Promise<{ ctx: Context; session: Session }> { const ctx = new Context() await ctx.plugin(SessionStore) @@ -263,7 +279,7 @@ describe('SessionProjectionRegistry drive', () => { expect(() => ctx.sessionProjections.restore({ 'test/marks': { ver: 1, seq: 2, val: { marks: ['old'] } }, 'test/count': { ver: 99, seq: 2, val: 3 }, - }, tail, 3)).toThrow(/re-read from seq 0/) + }, tail, 3, NO_SEED)).toThrow(/re-read from seq 0/) // The full-log re-read (baseSeq 0) refolds the mismatched key from init. const full: SessionEvent[] = [ { type: 'turn/start', seq: 0, time: 0, data: { turn: 1 } }, @@ -274,7 +290,7 @@ describe('SessionProjectionRegistry drive', () => { const { snapshot, checkpoint } = ctx.sessionProjections.restore({ 'test/marks': { ver: 1, seq: 2, val: { marks: ['old', '2'] } }, 'test/count': { ver: 99, seq: 2, val: 3 }, - }, full, 0) + }, full, 0, NO_SEED) expect(snapshot.asOfSeq).toBe(4) expect(snapshot.values['test/marks']).toEqual({ marks: ['new'] }) expect(snapshot.values['test/count']).toBe(5) // refolded from init over all 5 events @@ -295,7 +311,7 @@ describe('SessionProjectionRegistry drive', () => { { type: 'turn/start', seq: 3, time: 3, data: { turn: 2 } }, { type: 'turn/end', seq: 4, time: 4, data: { turn: 2, reason: { kind: 'completed' } } }, ] - const { snapshot } = ctx.sessionProjections.restore(rows, tail, 3) + const { snapshot } = ctx.sessionProjections.restore(rows, tail, 3, NO_SEED) expect(snapshot.asOfSeq).toBe(4) // marks already covers the tail (watermark 4): nothing re-applied. expect(snapshot.values['test/marks']).toEqual({ marks: ['done'] }) @@ -306,7 +322,7 @@ describe('SessionProjectionRegistry drive', () => { const { snapshot: current } = ctx.sessionProjections.restore({ 'test/marks': { ver: 1, seq: 4, val: { marks: ['done'] } }, 'test/count': { ver: 1, seq: 4, val: 5 }, - }, [], 5) + }, [], 5, NO_SEED) expect(current.asOfSeq).toBe(4) expect(current.values['test/count']).toBe(5) }) @@ -324,6 +340,48 @@ describe('SessionProjectionRegistry drive', () => { expect(ctx.sessionProjections.viewCheckpoint({})).toEqual({}) }) + it('folds every unit against the fold context of the session header', async () => { + const { ctx, session: parent } = await harness() + ctx.sessionProjections.register(ownCountUnit()) + mark(parent, ['a']) + mark(parent, ['b']) + const inherited = parent.seq + expect(ctx.sessionProjections.snapshot(parent).values['test/count']).toBe(inherited) + + // The forked child inherits the parent's log; only its own appends count. + const seed = [...parent.events] + const child = ctx.sessions.create(undefined, { seed, meta: { seedLength: seed.length } }) + mark(child, ['own']) + expect(child.header.seedLength).toBe(seed.length) + expect(ctx.sessionProjections.snapshot(child).values['test/count']) + .toBe(child.seq - seed.length) + }) + + it('passes the fold context to a cell built lazily after events flowed', async () => { + const { ctx, session: parent } = await harness() + mark(parent, ['a']) + const seed = [...parent.events] + const child = ctx.sessions.create(undefined, { seed, meta: { seedLength: seed.length } }) + mark(child, ['own']) + // Registered only now: the lazy full-log build must see the same context. + ctx.sessionProjections.register(ownCountUnit()) + expect(ctx.sessionProjections.snapshot(child).values['test/count']) + .toBe(child.seq - seed.length) + }) + + it('restore folds against the caller-supplied fold context', async () => { + const { ctx } = await harness() + ctx.sessionProjections.register(ownCountUnit()) + const events: SessionEvent[] = [ + { type: 'turn/start', seq: 0, time: 0, data: { turn: 1 } }, + { type: 'turn/end', seq: 1, time: 1, data: { turn: 1, reason: { kind: 'completed' } } }, + { type: 'turn/start', seq: 2, time: 2, data: { turn: 2 } }, + ] + expect(ctx.sessionProjections.restore({}, events, 0, NO_SEED).snapshot.values['test/count']).toBe(3) + expect(ctx.sessionProjections.restore({}, events, 0, foldContextOf({ seedLength: 2 })) + .snapshot.values['test/count']).toBe(1) + }) + it('restore rejects a row claiming events past the supplied log end (shrunk log ⇒ re-read)', async () => { const { ctx } = await harness() ctx.sessionProjections.register(countUnit()) @@ -334,16 +392,16 @@ describe('SessionProjectionRegistry drive', () => { expect(floor).toBe(9) // …an intact log serves the anchor event and the checkpoint stands as-is. const anchor: SessionEvent = { type: 'turn/end', seq: 9, time: 9, data: { turn: 2, reason: { kind: 'completed' } } } - expect(ctx.sessionProjections.restore(rows, [anchor], 9).snapshot.values['test/count']).toBe(10) + expect(ctx.sessionProjections.restore(rows, [anchor], 9, NO_SEED).snapshot.values['test/count']).toBe(10) // …while a log crash-repaired down to fewer events returns an empty tail: // the row overreaches the proven end and a tail read cannot fix this key. - expect(() => ctx.sessionProjections.restore(rows, [], 9)).toThrow(/re-read from seq 0/) + expect(() => ctx.sessionProjections.restore(rows, [], 9, NO_SEED)).toThrow(/re-read from seq 0/) // The full re-read discards the overreaching row and refolds from init. const events: SessionEvent[] = [ { type: 'turn/start', seq: 0, time: 0, data: { turn: 1 } }, { type: 'turn/end', seq: 1, time: 1, data: { turn: 1, reason: { kind: 'completed' } } }, ] - const { snapshot } = ctx.sessionProjections.restore(rows, events, 0) + const { snapshot } = ctx.sessionProjections.restore(rows, events, 0, NO_SEED) expect(snapshot.asOfSeq).toBe(1) expect(snapshot.values['test/count']).toBe(2) }) diff --git a/packages/session/session-stats/tests/projection.spec.ts b/packages/session/session-stats/tests/projection.spec.ts index ebe728181b..da8fddd44f 100644 --- a/packages/session/session-stats/tests/projection.spec.ts +++ b/packages/session/session-stats/tests/projection.spec.ts @@ -15,10 +15,14 @@ import { Context } from '@deepseek-ai/cordis' import { createMessage } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' -import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' +import SessionProjectionRegistry, { foldContextOf } from '@deepseek-ai/dsh-session-projection' import * as SessionStatsPlugin from '@deepseek-ai/dsh-session-stats' import { sessionStatsProjectionDefinition } from '@deepseek-ai/dsh-session-stats/src/projection.ts' import type { SessionStatsProjection } from '@deepseek-ai/dsh-session-stats/types' +/** The unit under test ignores the fold context; this session inherits no fork prefix. */ +const applyStats = (state: Parameters[0], event: SessionEvent) => + sessionStatsProjectionDefinition.apply(state, event, foldContextOf({})) + async function harness(withStatsPlugin: boolean): Promise<{ ctx: Context; session: Session }> { const ctx = new Context() @@ -155,7 +159,7 @@ function at(time: number, type: string, data: unknown): SessionEvent { /** Fold a synthetic event list through the definition and view the result. */ function fold(events: readonly SessionEvent[]): SessionStatsProjection { const state = events.reduce( - (folded, event) => sessionStatsProjectionDefinition.apply(folded, event), + (folded, event) => applyStats(folded, event), sessionStatsProjectionDefinition.init(), ) return sessionStatsProjectionDefinition.view(state) @@ -270,10 +274,10 @@ describe('sessionStats wall-time fold (controlled timestamps)', () => { // The first message closed the step boundary; a defensive duplicate finds // no open step and folds to the same reference. const state = events.reduce( - (folded, event) => sessionStatsProjectionDefinition.apply(folded, event), + (folded, event) => applyStats(folded, event), sessionStatsProjectionDefinition.init(), ) - expect(sessionStatsProjectionDefinition.apply( + expect(applyStats( state, at(2_050, 'assistant/message', { turn: 1, step: 1, message }), )).toBe(state) @@ -281,7 +285,7 @@ describe('sessionStats wall-time fold (controlled timestamps)', () => { it('accrues nothing for unrelated events and clamps negative clock skew to zero', () => { const state = sessionStatsProjectionDefinition.init() - const untouched = sessionStatsProjectionDefinition.apply(state, at(1, 'user/message', { content: [] })) + const untouched = applyStats(state, at(1, 'user/message', { content: [] })) expect(untouched).toBe(state) expect(fold([ at(2_000, 'step/start', { turn: 1, step: 1 }), diff --git a/packages/subagent/README.i18n.yaml b/packages/subagent/README.i18n.yaml index 5a5dc2c181..ff89ea48dd 100644 --- a/packages/subagent/README.i18n.yaml +++ b/packages/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/README.md -README.md: a863ed3f5ef864b6eb6eb9a7a0c1ee2f40f247d6 -README.zh.md: 1c9bf8ba0814a74c5774d81a34a3266daa9c375b +README.md: a3bbfdf415006e6321c7f60f6678a828e8295013 +README.zh.md: accc3a2216b9b0d27bb8e23c391d0206f813ff5f diff --git a/packages/subagent/README.md b/packages/subagent/README.md index a863ed3f5e..a3bbfdf415 100644 --- a/packages/subagent/README.md +++ b/packages/subagent/README.md @@ -13,6 +13,7 @@ This family lets an agent delegate work to child agents. Multiple named provider | [`subagent-acp/`](subagent-acp/README.md) | Starts an out-of-process child over ACP | registers on `ctx.subagents` | | [`subagent-codex/`](subagent-codex/README.md) | Starts a real Codex app-server child | registers on `ctx.subagents` | | [`subagent-claude-code/`](subagent-claude-code/README.md) | Starts a real Claude Code child through the official Claude Agent SDK | registers on `ctx.subagents` | +| [`subagent-cursor/`](subagent-cursor/README.md) | Starts a real Cursor child through the `cursor-agent` print-mode CLI | registers on `ctx.subagents` | | [`subagent-dsh-sdk/`](subagent-dsh-sdk/README.md) | Starts an out-of-process Harness child through the TypeScript SDK | registers on `ctx.subagents` | | [`tool-subagent/`](tool-subagent/README.md) | Exposes delegation to the model | registers on `ctx.tools` | | [`tool-subagent-control/`](tool-subagent-control/README.md) | Exposes child messaging and listing to the model | registers on `ctx.tools` | diff --git a/packages/subagent/README.zh.md b/packages/subagent/README.zh.md index 1c9bf8ba08..accc3a2216 100644 --- a/packages/subagent/README.zh.md +++ b/packages/subagent/README.zh.md @@ -13,6 +13,7 @@ | [`subagent-acp/`](subagent-acp/README.md) | 通过 ACP(Agent Client Protocol)启动进程外子 agent | 注册到 `ctx.subagents` | | [`subagent-codex/`](subagent-codex/README.md) | 启动真实的 Codex app-server 子 agent | 注册到 `ctx.subagents` | | [`subagent-claude-code/`](subagent-claude-code/README.md) | 通过官方 Claude Agent SDK 启动真实的 Claude Code 子 agent | 注册到 `ctx.subagents` | +| [`subagent-cursor/`](subagent-cursor/README.md) | 通过 `cursor-agent` print 模式 CLI 启动真实的 Cursor 子 agent | 注册到 `ctx.subagents` | | [`subagent-dsh-sdk/`](subagent-dsh-sdk/README.md) | 通过 TypeScript SDK 启动进程外 Harness 子 agent | 注册到 `ctx.subagents` | | [`tool-subagent/`](tool-subagent/README.md) | 向模型公开委派操作 | 注册到 `ctx.tools` | | [`tool-subagent-control/`](tool-subagent-control/README.md) | 向模型公开子级消息发送和列举操作 | 注册到 `ctx.tools` | diff --git a/packages/subagent/subagent-claude-code/README.i18n.yaml b/packages/subagent/subagent-claude-code/README.i18n.yaml index a165540575..6995f145a1 100644 --- a/packages/subagent/subagent-claude-code/README.i18n.yaml +++ b/packages/subagent/subagent-claude-code/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-claude-code/README.md -README.md: 1a0d6e32b8610769dcc5d8342a4fe88d0c884085 -README.zh.md: 78dab14e5eaddc06ccd07b69dc952a09380e0428 +README.md: 9ded255eddefbed524a94216df8142411c91f547 +README.zh.md: e24d32578b462946dea27e2b13bdd1341efd52bb diff --git a/packages/subagent/subagent-claude-code/README.md b/packages/subagent/subagent-claude-code/README.md index 1a0d6e32b8..9ded255edd 100644 --- a/packages/subagent/subagent-claude-code/README.md +++ b/packages/subagent/subagent-claude-code/README.md @@ -31,7 +31,7 @@ The provider advertises no optional start-time capabilities and reports `inherit Production resolves `claude` from the subprocess execution world's credential-scrubbed `PATH`, with explicit `env` entries applied, and passes the resulting path to the SDK as `pathToClaudeCodeExecutable`. On Windows, a resolved `.cmd` or `.bat` path is carried as a quoted, per-spawn environment value that `cmd.exe /v:off` expands once, so valid path metacharacters remain data. The pinned SDK's fixed flags then occupy cmd's command tail and contain no cmd metacharacters; they are not ordinary Windows argv. Native settings and authentication remain authoritative. The plugin does not install another CLI, select a model, create a product home, log in, or probe an account. Credential-shaped ambient variables are removed before the explicit `env` overlay is applied, so an API key or token intended for the child must be supplied there. Non-credential endpoint variables such as `ANTHROPIC_BASE_URL`, along with ordinary ambient values such as `PATH` and `HOME`, remain inherited unless overridden. -Production `dsh` does not install or mount this optional provider. A Profile that opts in must install `@deepseek-ai/dsh-subagent-claude-code` and mount it once on the host plane; loading the provider starts no Claude process until a tool call. Full Agent Presets carry a matching product tool row with `disabled: true`; copy a preset and remove that field to expose `subagent_claude_code` only to agents composed from the copy. Its `one-shot` policy keeps omitted or `false` `run_in_background` calls in the foreground, while explicit `true` returns a parent-owned Job id for `job_output` or `job_kill`. The base host and full presets already provide the generic Job registry and controls. +The `dsh-base` host plane mounts this provider, and the `code`, `cordis`, and `standard` Agent Presets carry an enabled `subagent_claude_code` tool row; the `economy` preset keeps it `disabled: true` because an economy composition should not reach for external paid agents by default. Loading the provider starts no Claude process until a tool call. Its `one-shot` policy keeps omitted or `false` `run_in_background` calls in the foreground, while explicit `true` returns a parent-owned Job id for `job_output` or `job_kill`. The base host and full presets already provide the generic Job registry and controls. The standalone composition below shows the complete explicit capability. A Profile based on `@deepseek-ai/dsh-base` keeps its existing Job rows, adds the product provider row, and enables the preset tool row instead of mounting duplicate Job services. diff --git a/packages/subagent/subagent-claude-code/README.zh.md b/packages/subagent/subagent-claude-code/README.zh.md index 78dab14e5e..e24d32578b 100644 --- a/packages/subagent/subagent-claude-code/README.zh.md +++ b/packages/subagent/subagent-claude-code/README.zh.md @@ -31,7 +31,7 @@ SDK 接收由文本块原样拼接成的任务。提供方会完整迭代 SDK 生产环境从子进程执行世界清除凭证后的 `PATH` 解析 `claude`,再应用显式 `env` 条目,并把所得路径作为 `pathToClaudeCodeExecutable` 交给 SDK。在 Windows 上,解析到的 `.cmd` 或 `.bat` 路径会作为带引号、仅供本次 spawn 使用的环境值交给 `cmd.exe /v:off` 展开一次,因此合法路径中的元字符仍只是数据。锁定版本的 SDK 随后把固定命令行选项放在 cmd 的命令尾部;这些选项不含 cmd 元字符,也并不是普通的 Windows argv。原生设置与身份验证继续是权威来源。本插件不安装另一份 CLI、不选择模型、不创建产品主目录、不执行登录,也不探测账户。具有凭证特征的环境变量会在显式 `env` 覆盖生效前被清除,因此供子进程使用的 API 密钥或 token 必须在该配置中显式提供。除非被覆盖,`ANTHROPIC_BASE_URL` 等非凭证端点变量以及 `PATH` 和 `HOME` 等普通环境变量仍会被继承。 -生产 `dsh` 不会安装或挂载这个可选提供方。选择启用它的 Profile 必须安装 `@deepseek-ai/dsh-subagent-claude-code`,并在 host plane(宿主平面)挂载一次;加载提供方本身不会在工具调用前启动 Claude 进程。完整 Agent Preset 携带对应的产品工具行并设置 `disabled: true`;复制一个 preset 后删除该字段,即可只向由该副本组装的 agent 暴露 `subagent_claude_code`。其 `one-shot` 策略会让省略 `run_in_background` 或传入 `false` 的调用继续在前台等待,而显式传入 `true` 会返回由父 agent 拥有的 Job ID,供 `job_output` 或 `job_kill` 使用。base host(基础宿主)与完整 preset 已提供通用作业注册表和控制工具。 +`dsh-base` 宿主平面会挂载本提供方,`code`、`cordis` 与 `standard` Agent Preset 均携带已启用的 `subagent_claude_code` 工具行;`economy` preset 保留 `disabled: true`,因为经济模式的组装不应默认动用外部付费 agent。加载提供方本身不会在工具调用前启动 Claude 进程。其 `one-shot` 策略会让省略 `run_in_background` 或传入 `false` 的调用继续在前台等待,而显式传入 `true` 会返回由父 agent 拥有的 Job ID,供 `job_output` 或 `job_kill` 使用。base host(基础宿主)与完整 preset 已提供通用作业注册表和控制工具。 下列独立组装展示完整的显式能力。基于 `@deepseek-ai/dsh-base` 的 Profile 保留已有 Job 行,只新增产品提供方行并启用 preset 工具行,禁止重复挂载 Job 服务。 diff --git a/packages/subagent/subagent-codex/README.i18n.yaml b/packages/subagent/subagent-codex/README.i18n.yaml index da14b8ff30..b83a84c5e0 100644 --- a/packages/subagent/subagent-codex/README.i18n.yaml +++ b/packages/subagent/subagent-codex/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-codex/README.md -README.md: 848d170585710b682fa4ce331010fce7080de673 -README.zh.md: 34e9105e6a78bc16f16997c7df89d4f6412eb50c +README.md: de64a9136c3f1a5573cc169bcd3bd2ea11cd15e8 +README.zh.md: 9490e1084def998be9352111dbc8137642c92263 diff --git a/packages/subagent/subagent-codex/README.md b/packages/subagent/subagent-codex/README.md index 848d170585..de64a9136c 100644 --- a/packages/subagent/subagent-codex/README.md +++ b/packages/subagent/subagent-codex/README.md @@ -27,7 +27,7 @@ The provider advertises no optional start-time capabilities and reports `inherit Production resolves `codex` from `PATH` and uses the host's native Codex configuration and authentication. The plugin does not install Codex, select a model, create `CODEX_HOME`, log in, or probe a version. Credential-shaped ambient variables are removed by the subprocess seam, so an API key intended for the child must be supplied explicitly in `env`; ordinary ambient values such as `PATH` and `HOME` remain available unless overridden. -Production `dsh` does not install or mount this optional provider. A Profile that opts in must install `@deepseek-ai/dsh-subagent-codex` and mount it once on the host plane; loading the provider starts no Codex process until a tool call. Full Agent Presets carry a matching product tool row with `disabled: true`; copy a preset and remove that field to expose `subagent_codex` only to agents composed from the copy. Its `one-shot` policy keeps omitted or `false` `run_in_background` calls in the foreground, while explicit `true` returns a parent-owned Job id for `job_output` or `job_kill`. The base host and full presets already provide the generic Job registry and controls. +The `dsh-base` host plane mounts this provider, and the `code`, `cordis`, and `standard` Agent Presets carry an enabled `subagent_codex` tool row; the `economy` preset keeps it `disabled: true` because an economy composition should not reach for external paid agents by default. Loading the provider starts no Codex process until a tool call. Its `one-shot` policy keeps omitted or `false` `run_in_background` calls in the foreground, while explicit `true` returns a parent-owned Job id for `job_output` or `job_kill`. The base host and full presets already provide the generic Job registry and controls. The standalone composition below shows the complete explicit capability. A Profile based on `@deepseek-ai/dsh-base` keeps its existing Job rows, adds the product provider row, and enables the preset tool row instead of mounting duplicate Job services. diff --git a/packages/subagent/subagent-codex/README.zh.md b/packages/subagent/subagent-codex/README.zh.md index 34e9105e6a..9490e1084d 100644 --- a/packages/subagent/subagent-codex/README.zh.md +++ b/packages/subagent/subagent-codex/README.zh.md @@ -27,7 +27,7 @@ 生产环境会从 `PATH` 中解析 `codex`,并使用宿主机原生的 Codex 配置与身份验证。本插件不安装 Codex、不选择模型、不创建 `CODEX_HOME`、不执行登录,也不探测版本。子进程 seam 会移除具有凭证特征的环境变量,因此供子进程使用的 API 密钥必须在 `env` 中显式提供;除非被覆盖,`PATH` 和 `HOME` 等普通环境变量值仍然可用。 -生产 `dsh` 不会安装或挂载这个可选提供方。选择启用它的 Profile 必须安装 `@deepseek-ai/dsh-subagent-codex`,并在 host plane(宿主平面)挂载一次;加载提供方本身不会在工具调用前启动 Codex 进程。完整 Agent Preset 携带对应的产品工具行并设置 `disabled: true`;复制一个 preset 后删除该字段,即可只向由该副本组装的 agent 暴露 `subagent_codex`。其 `one-shot` 策略会让省略 `run_in_background` 或传入 `false` 的调用继续在前台等待,而显式传入 `true` 会返回由父 agent 拥有的 Job ID,供 `job_output` 或 `job_kill` 使用。base host(基础宿主)与完整 preset 已提供通用作业注册表和控制工具。 +`dsh-base` 宿主平面会挂载本提供方,`code`、`cordis` 与 `standard` Agent Preset 均携带已启用的 `subagent_codex` 工具行;`economy` preset 保留 `disabled: true`,因为经济模式的组装不应默认动用外部付费 agent。加载提供方本身不会在工具调用前启动 Codex 进程。其 `one-shot` 策略会让省略 `run_in_background` 或传入 `false` 的调用继续在前台等待,而显式传入 `true` 会返回由父 agent 拥有的 Job ID,供 `job_output` 或 `job_kill` 使用。base host(基础宿主)与完整 preset 已提供通用作业注册表和控制工具。 下列独立组装展示完整的显式能力。基于 `@deepseek-ai/dsh-base` 的 Profile 保留已有 Job 行,只新增产品提供方行并启用 preset 工具行,禁止重复挂载 Job 服务。 diff --git a/packages/subagent/subagent-cursor/README.i18n.yaml b/packages/subagent/subagent-cursor/README.i18n.yaml new file mode 100644 index 0000000000..402be8a2d1 --- /dev/null +++ b/packages/subagent/subagent-cursor/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/subagent/subagent-cursor/README.md +README.md: bbf6a4bd763e24c09a34fdc77fecc6f2c66a1ade +README.zh.md: db15b551cd95a763a3768b09ab4a9527c72afa10 diff --git a/packages/subagent/subagent-cursor/README.md b/packages/subagent/subagent-cursor/README.md new file mode 100644 index 0000000000..bbf6a4bd76 --- /dev/null +++ b/packages/subagent/subagent-cursor/README.md @@ -0,0 +1,106 @@ +# @deepseek-ai/dsh-subagent-cursor + +English | [中文](README.zh.md) + +This package registers the fixed `cursor` subagent provider. Each accepted run starts the official `cursor-agent` CLI in non-interactive print mode in the delegating Session's workspace, submits one self-contained text task, reads the CLI's `stream-json` event stream, and returns only the final answer through the shared [`dsh-subagent`](../subagent/README.md) result contract. + +## Start and ownership + +`start(request)` accepts only a non-empty sequence of text blocks and derives the child cwd from the parent Session. It resolves `cursor-agent` through [`dsh-subprocess`](../../subprocess/subprocess/README.md), spawns the fixed command, and publishes the run only after the CLI's `system`/`init` event announces its own chat id — the point at which the CLI has started and resolved its credentials and model. A failure or cancellation before that announcement terminates the managed process tree, waits for it to exit, and rejects `start()`. + +The task crosses the boundary as a positional command-line argument, because that is the only prompt channel print mode offers: the CLI documents no `--` end-of-options separator and reads no prompt from stdin. Two consequences are enforced rather than papered over. A task whose first character is `-` is rejected at admission, since the CLI would parse it as an option. A resolved Windows `.cmd` or `.bat` shim is rejected as well, because only `cmd.exe` can run it and its command tail would reparse model-authored text as shell syntax; PATHEXT resolution prefers the `cursor-agent.exe` that the native Windows installer provides. Stdin is closed immediately after spawn, so a prompt the CLI still tries to read fails fast instead of stalling an unattended child on an answer nobody can give. + +The published `run.result` waits for the authoritative terminal `result` event and accepts only `subtype: "success"` with `is_error: false` and a nonblank `result`. Every other terminal event, malformed stdout line, stream failure, or end of stream without a result maps to `error`; print mode carries no machine-readable failure taxonomy, so the provider produces neither `max-tokens` nor `refusal`. `user`, `tool_call`, and event kinds a newer CLI adds contribute nothing to this contract. + +Print mode has no reply channel, so there is no protocol interrupt: cancellation is the run's abort signal, which the subprocess seam turns into its termination escalation while the result settles immediately as `aborted` with the last non-empty assistant message collected so far. `dispose()` is idempotent: it detaches the event stream, invokes the shared process-tree termination escalation, and waits for whole-tree exit. Result failure and independent teardown failure remain separate. + +## Capabilities and context + +The provider advertises no optional start-time capabilities and reports `inheritsParentContext: false`. Cursor receives the standalone text task and the parent Session cwd, but not the parent conversation, persona, tool filter, depth policy, or structured-output contract. The CLI's own chat id and model stay private to the run and are never persisted in the parent Session. + +## Configuration + +| Key | Default | Meaning | +|---|---|---| +| `env` | `{}` | Explicit child environment layered over the subprocess seam's credential-scrubbed parent environment. | +| `disposeGraceMs` | `3000` | Positive finite grace in milliseconds, no greater than [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md), between the shared process-tree owner's termination tiers; disposal then waits for whole-tree exit. | +| `force` | `false` | Pass `--force`, letting the child apply file changes and run commands. Cursor's own print-mode default only PROPOSES changes, so a delegation expected to edit the workspace needs this on. | +| `trust` | `false` | Pass `--trust`, letting the child act in the workspace without Cursor's interactive trust prompt an unattended child cannot answer. | + +Production resolves `cursor-agent` from the subprocess execution world's credential-scrubbed `PATH`, with explicit `env` entries applied, and uses the host's native Cursor configuration and authentication. The plugin does not install the CLI, select a model, create a Cursor home, log in, or probe a version. Credential-shaped ambient variables are removed by the subprocess seam, so a `CURSOR_API_KEY` intended for the child must be supplied in `env`; it is never passed as `--api-key`, where a process listing would expose it. Ordinary ambient values such as `PATH` and `HOME` remain available unless overridden. + +The `dsh-base` host plane mounts this provider, and the `code`, `cordis`, and `standard` Agent Presets carry an enabled `subagent_cursor` tool row; the `economy` preset keeps it `disabled: true` because an economy composition should not reach for external paid agents by default. Loading the provider starts no Cursor process until a tool call. Its `one-shot` policy keeps omitted or `false` `run_in_background` calls in the foreground, while explicit `true` returns a parent-owned Job id for `job_output` or `job_kill`. + +The standalone composition below shows the complete explicit capability. A Profile based on `@deepseek-ai/dsh-base` keeps its existing Job rows and provider row and enables only the preset tool row instead of mounting duplicates. + +```yaml +- id: subagent-cursor + name: '@deepseek-ai/dsh-subagent-cursor' + config: + force: true + trust: true + env: + CURSOR_API_KEY: !!js process.env.CURSOR_API_KEY + +- id: jobs + name: '@deepseek-ai/dsh-jobs-local' + +- id: tool-jobs + name: '@deepseek-ai/dsh-tool-jobs' + +- id: tool-subagent-cursor + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: cursor + toolName: subagent_cursor + backgroundMode: one-shot + maxDepth: provider-managed +``` + +## Product compatibility and evidence + +The provider reads only the print-mode events this one-shot contract needs: `system`/`init` to publish, `assistant` to snapshot partial output, and `result` to settle. Deployments supply `cursor-agent` on `PATH`; the CLI is not an npm dependency of this package, so unit evidence drives the real event stream through the subprocess seam rather than a pinned binary. + +The CLI also speaks the Agent Client Protocol as `cursor-agent acp`, which the generic [`dsh-subagent-acp`](../subagent-acp/README.md) provider can drive with configuration alone. That path exists for a deployment that wants ACP's permission auto-answer policy or a long-lived remote session; this package exists for the one-shot delegation contract, its own `subagent_cursor` tool row, and print mode's simpler failure surface. + +## Model Experience + +### Child request + +#### What the model sees + +The Cursor child receives the concatenated text task as one positional prompt in a fresh print-mode run. Its workspace is the parent Session cwd, and its model, system instructions, tools, and authentication come from the native Cursor installation and configuration. + +#### Token effect + +The child pays for an independent Cursor context and turn. Child tokens do not enter the parent's context. + +#### KV Cache effect + +Independent of the parent request cache. Reuse depends only on Cursor's own model, instructions, tools, and per-run request. + +### Parent scheduling and results, indirectly + +#### What the model sees + +Through `dsh-tool-subagent`, a foreground call gives the parent the terminal Cursor answer or the consumer's exact error for a non-completed result. A background call first returns a Job id; the generic job controls later deliver a completion notice, expose the final answer and status through `job_output`, and let `job_kill` request cancellation. Cursor's intermediate messages, tool activity, stderr, workspace diffs, usage, and product ids are not copied into the parent Session. + +#### Token effect + +Foreground input grows by the retained final answer or error. Background input also includes the start acknowledgement, completion notice, and any `job_output`, `job_kill`, or later status results; child tokens still do not enter the parent context. This provider adds no parent tool schema by itself. + +#### KV Cache effect + +Append-only: foreground adds one result after the reusable parent prefix, while background appends the Job acknowledgement, notice, and later control or collection results. Background scheduling can add a notice-driven turn, but none of these messages rewrites the earlier prefix. + +## Known Limitations and Deferred Work + +- **One fresh process and run per delegation** — there is no continuation, `--resume`, pooling, progress stream, or product-session persistence, even though the CLI itself supports resuming a chat by id. +- **The task cannot begin with `-`** — print mode takes the prompt positionally and documents no `--` separator, so such a task is rejected at admission instead of being mis-parsed as an option. +- **Windows needs the native executable** — a resolved `.cmd` or `.bat` shim is rejected rather than run through `cmd.exe`, whose command tail would reparse the task text. +- **Host-managed product installation and account state** — a missing `cursor-agent`, configuration error, or authentication failure is surfaced as a startup or run error; the plugin provides no installer, login flow, or runtime version gate. +- **No graduated approval path** — `force` and `trust` are whole-run deployment switches; there is no per-call policy, allowlist, or human approval channel through this package. +- **No failure taxonomy** — print mode reports only a terminal subtype and error marker, so a context-window ending is indistinguishable from any other failure and never maps to `max-tokens`. +- **Product payload is final text only** — intermediate messages, tool traffic, usage, stderr, and workspace diffs remain product-local; generic Job ids, notices, and status come from the shared job runtime. +- **No optional shared capabilities** — output schemas, child personas, tool filtering, and harness depth enforcement are rejected by the shared service for this provider. +- **No wall-clock timeout or side-effect rollback** — the caller cancels long work, and files or external systems changed before cancellation are not restored. diff --git a/packages/subagent/subagent-cursor/README.zh.md b/packages/subagent/subagent-cursor/README.zh.md new file mode 100644 index 0000000000..db15b551cd --- /dev/null +++ b/packages/subagent/subagent-cursor/README.zh.md @@ -0,0 +1,106 @@ +# @deepseek-ai/dsh-subagent-cursor + +[English](README.md) | 中文 + +本包注册固定的 `cursor` subagent 提供方。每次接受运行请求后,它都会在发起委托的会话工作区中以非交互 print 模式启动官方 `cursor-agent` CLI,提交一个自包含的文本任务,读取该 CLI 的 `stream-json` 事件流,并通过共享的 [`dsh-subagent`](../subagent/README.md) 结果约定仅返回最终答案。 + +## 启动与所有权 + +`start(request)` 只接受非空的文本块序列,并根据父会话确定子级 cwd。随后,它通过 [`dsh-subprocess`](../../subprocess/subprocess/README.md) 解析 `cursor-agent`,spawn 固定命令,且仅在该 CLI 的 `system`/`init` 事件公布其自有会话 ID 之后才发布此次运行——那正是该 CLI 已启动并解析出自身凭证与模型的时刻。若在此公布之前发生失败或取消,它会终止受管进程树并等待其退出,然后拒绝 `start()` 调用。 + +任务以位置命令行参数的形式跨越进程边界,因为这是 print 模式提供的唯一提示词通道:该 CLI 未记载 `--` 选项终止符,也不从标准输入读取提示词。由此产生的两项后果被强制约束,而非含糊掩盖。首字符为 `-` 的任务在准入阶段即被拒绝,因为该 CLI 会将其解析为选项。解析到 Windows `.cmd` 或 `.bat` 包装脚本同样被拒绝,因为只有 `cmd.exe` 能运行它,而其命令尾部会把模型撰写的文本重新解析为 shell 语法;PATHEXT 解析会优先选择原生 Windows 安装程序提供的 `cursor-agent.exe`。标准输入在 spawn 后立即关闭,因此该 CLI 若仍尝试读取提示词,会快速失败,而不是让无人值守的子级停滞在无人能给出的答案上。 + +已发布的 `run.result` 会等待权威的终止 `result` 事件,且只接受 `subtype: "success"` 且 `is_error: false` 并带非空白 `result` 的事件。其他任何终止事件、格式错误的标准输出行、流失败,或流结束时仍无结果,都映射为 `error`;print 模式不携带可供程序判读的失败分类,因此该提供方既不会产生 `max-tokens` 也不会产生 `refusal`。`user`、`tool_call` 以及更新版 CLI 新增的事件类别对本约定没有贡献。 + +print 模式没有回复通道,因此不存在协议层中断:取消即本次运行的中止信号,子进程 seam 会将其转为逐级终止机制,同时结果立即判为 `aborted`,并携带此前收集到的最后一条非空助手消息。`dispose()`(资源释放)具有幂等性:它会摘除事件流监听、调用共享的进程树逐级终止机制,并等待整棵进程树退出。结果失败与独立的清理失败仍彼此分离。 + +## 能力与上下文 + +本提供方不声明任何可选的启动时能力,并报告 `inheritsParentContext: false`。Cursor 会接收独立文本任务和父会话 cwd,但不会接收父会话的对话、角色设定、工具筛选器、深度策略或结构化输出约定。该 CLI 自有的会话 ID 与模型仅在此次运行内部可见,绝不会持久化到父会话。 + +## 配置 + +| 配置键 | 默认值 | 含义 | +|---|---|---| +| `env` | `{}` | 显式指定的子进程环境,叠加在由子进程 seam 清除凭证后的父环境之上。 | +| `disposeGraceMs` | `3000` | 共享进程树责任方各终止层级之间的宽限期,单位为毫秒且须为正有限值,并不得大于 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md);随后资源释放会等待整棵进程树退出。 | +| `force` | `false` | 传入 `--force`,允许子级实际应用文件改动并执行命令。Cursor 自身的 print 模式默认只“提出”改动,因此预期要编辑工作区的委托需要开启此项。 | +| `trust` | `false` | 传入 `--trust`,允许子级在不经过 Cursor 交互式信任提示的情况下在工作区中行动——无人值守的子级无法回答该提示。 | + +生产环境会从子进程执行环境中已清除凭证的 `PATH` 里解析 `cursor-agent`,并叠加显式 `env` 条目,同时使用宿主机原生的 Cursor 配置与身份验证。本插件不安装该 CLI、不选择模型、不创建 Cursor 主目录、不执行登录,也不探测版本。子进程 seam 会移除具有凭证特征的环境变量,因此供子进程使用的 `CURSOR_API_KEY` 必须在 `env` 中提供;它绝不会作为 `--api-key` 传入——那会让进程列表暴露它。除非被覆盖,`PATH` 和 `HOME` 等普通环境变量值仍然可用。 + +`dsh-base` 宿主平面会挂载本提供方,`code`、`cordis` 与 `standard` Agent Preset 均携带已启用的 `subagent_cursor` 工具行;`economy` preset 保留 `disabled: true`,因为经济模式的组装不应默认动用外部付费 agent。加载提供方本身不会在工具调用前启动 Cursor 进程。其 `one-shot` 策略会让省略 `run_in_background` 或传入 `false` 的调用继续在前台等待,而显式传入 `true` 会返回由父 agent 拥有的 Job ID,供 `job_output` 或 `job_kill` 使用。 + +下面的独立组装展示了完整的显式能力。基于 `@deepseek-ai/dsh-base` 的 Profile 会保留其既有的 Job 行与提供方行,只需启用 preset 中的工具行,而不必挂载重复项。 + +```yaml +- id: subagent-cursor + name: '@deepseek-ai/dsh-subagent-cursor' + config: + force: true + trust: true + env: + CURSOR_API_KEY: !!js process.env.CURSOR_API_KEY + +- id: jobs + name: '@deepseek-ai/dsh-jobs-local' + +- id: tool-jobs + name: '@deepseek-ai/dsh-tool-jobs' + +- id: tool-subagent-cursor + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: cursor + toolName: subagent_cursor + backgroundMode: one-shot + maxDepth: provider-managed +``` + +## 产品兼容性与证据 + +本提供方只读取这一 one-shot 约定所需的 print 模式事件:`system`/`init` 用于发布运行,`assistant` 用于快照部分输出,`result` 用于结束运行。部署方需在 `PATH` 上提供 `cursor-agent`;该 CLI 不是本包的 npm 依赖,因此单元证据通过子进程 seam 驱动真实事件流,而不依赖固定版本的二进制文件。 + +该 CLI 还能以 `cursor-agent acp` 讲 Agent Client Protocol,通用的 [`dsh-subagent-acp`](../subagent-acp/README.md) 提供方仅凭配置即可驱动它。那条路径适用于需要 ACP 权限自动应答策略或长期远端会话的部署;本包存在的意义在于这一 one-shot 委托约定、自有的 `subagent_cursor` 工具行,以及 print 模式更简单的失败面。 + +## Model Experience + +### 子级请求 + +#### 模型看到什么 + +Cursor 子级会以一次全新的 print 模式运行,接收拼接后的文本任务作为单个位置提示词。其工作区是父会话 cwd,而模型、系统指令、工具与身份验证均来自宿主机原生的 Cursor 安装与配置。 + +#### Token 影响 + +子级为独立的 Cursor 上下文与轮次付费。子级 token 不会进入父级上下文。 + +#### KV 缓存影响 + +与父级请求缓存互相独立。复用只取决于 Cursor 自身的模型、指令、工具与本次运行的请求。 + +### 父级调度与结果(间接) + +#### 模型看到什么 + +通过 `dsh-tool-subagent`,前台调用会把终止事件中的 Cursor 答案交给父级,若结果并非完成状态,则交给消费方的确切错误。后台调用先返回一个 Job ID;通用作业控制工具随后送达完成通知,通过 `job_output` 暴露最终答案与状态,并允许 `job_kill` 请求取消。Cursor 的中间消息、工具活动、标准错误、工作区差异、用量与产品 ID 都不会复制进父会话。 + +#### Token 影响 + +前台输入会因保留的最终答案或错误而增长。后台输入还包含启动确认、完成通知,以及任何 `job_output`、`job_kill` 或后续状态结果;子级 token 仍不会进入父级上下文。本提供方自身不添加任何父级工具 schema。 + +#### KV 缓存影响 + +仅追加:前台在可复用的父级前缀之后追加一条结果,后台则追加 Job 确认、通知以及后续的控制或收集结果。后台调度可能新增一个由通知驱动的轮次,但上述任何消息都不会改写此前的前缀。 + +## Known Limitations and Deferred Work + +- **每次委托对应一个全新进程与一次运行** —— 没有续接、`--resume`、进程池化、进度流或产品会话持久化,尽管该 CLI 本身支持按 ID 恢复对话。 +- **任务不能以 `-` 开头** —— print 模式按位置接收提示词且未记载 `--` 终止符,因此这类任务在准入阶段即被拒绝,而不是被误解析为选项。 +- **Windows 需要原生可执行文件** —— 解析到 `.cmd` 或 `.bat` 包装脚本会被拒绝,而不会通过 `cmd.exe` 运行——其命令尾部会重新解析任务文本。 +- **产品安装与账户状态由宿主机管理** —— 缺失 `cursor-agent`、配置错误或身份验证失败会作为启动错误或运行错误上报;本插件不提供安装器、登录流程或运行时版本闸门。 +- **没有分级审批路径** —— `force` 与 `trust` 是整次运行级别的部署开关;本包不提供按调用的策略、允许清单或人工审批通道。 +- **没有失败分类** —— print 模式只报告终止子类型与错误标记,因此上下文窗口耗尽与其他任何失败无法区分,且绝不会映射为 `max-tokens`。 +- **产品载荷仅为最终文本** —— 中间消息、工具流量、用量、标准错误与工作区差异仍留在产品本地;通用 Job ID、通知与状态来自共享作业运行时。 +- **没有可选共享能力** —— 输出 schema、子级角色设定、工具筛选与 harness 深度强制均被共享服务针对本提供方拒绝。 +- **没有挂钟超时或副作用回滚** —— 由调用方取消长时间工作,取消前已改动的文件或外部系统不会被还原。 diff --git a/packages/subagent/subagent-cursor/package.json b/packages/subagent/subagent-cursor/package.json new file mode 100644 index 0000000000..5ed2b0b35c --- /dev/null +++ b/packages/subagent/subagent-cursor/package.json @@ -0,0 +1,59 @@ +{ + "name": "@deepseek-ai/dsh-subagent-cursor", + "description": "One-shot Cursor subagent provider over the cursor-agent print-mode stream-json protocol", + "version": "0.1.0-rc.7", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/subagent/subagent-cursor" + }, + "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", + "peerDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-subagent": "workspace:^", + "@deepseek-ai/dsh-subprocess": "workspace:^", + "@deepseek-ai/dsh-timeout": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" + }, + "dependencies": { + "@deepseek-ai/schemastery": "workspace:^" + }, + "devDependencies": { + "@deepseek-ai/cordis-plugin-loader": "workspace:^", + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-loader-smoke": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-subagent": "workspace:^", + "@deepseek-ai/dsh-subprocess": "workspace:^", + "@deepseek-ai/dsh-subprocess-local": "workspace:^", + "@deepseek-ai/dsh-timeout": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" + } +} diff --git a/packages/subagent/subagent-cursor/src/index.ts b/packages/subagent/subagent-cursor/src/index.ts new file mode 100644 index 0000000000..c0ff7d59ec --- /dev/null +++ b/packages/subagent/subagent-cursor/src/index.ts @@ -0,0 +1,130 @@ +/** + * Fixed Cursor one-shot subagent provider. Every accepted run starts a fresh + * non-interactive `cursor-agent --print --output-format stream-json` process + * in the delegating Session's workspace and publishes only after the CLI + * announces its own session. + * + * @module @deepseek-ai/dsh-subagent-cursor + */ + +import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' +import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' +import { + assertPositiveFinite, + NO_START_CAPABILITIES, + resolveChildCwd, + type ResolvedSubagentStartRequest, + type SubagentCapabilities, + type SubagentProvider, +} from '@deepseek-ai/dsh-subagent' +import { + assertDirectlySpawnable, + DEFAULT_DISPOSE_GRACE_MS, + startCursorRun, + type CursorRunSpec, +} from './run.ts' + +export const name = 'subagent-cursor' +export const inject = ['subagents', 'subprocess'] + +/** Fixed native executable; Cursor's own configuration stays authoritative. */ +const CURSOR_EXECUTABLE = 'cursor-agent' + +/** Deployment-owned environment, permissions, and process-release bound. */ +export interface Config { + /** + * Explicit environment entries layered over the subprocess seam's + * credential-scrubbed parent environment. `CURSOR_API_KEY` belongs here + * rather than on the command line, where a process listing would expose it. + */ + env?: Record + /** Grace in milliseconds for `cursor-agent` process-tree termination. */ + disposeGraceMs?: number + /** + * Whether the child may apply file changes and run commands (`--force`). + * Cursor's own print-mode default only PROPOSES changes, so a delegation + * expected to edit the workspace needs this on. + */ + force?: boolean + /** + * Whether the child may act in the workspace without Cursor's interactive + * trust prompt (`--trust`). An unattended child cannot answer that prompt. + */ + trust?: boolean +} + +export const Config: z = z.object({ + env: z.dict(z.string()).default({}), + disposeGraceMs: z.number().default(DEFAULT_DISPOSE_GRACE_MS), + force: z.boolean().default(false), + trust: z.boolean().default(false), +}) + +type ResolvedConfig = Required + +class CursorProvider implements SubagentProvider { + readonly name = 'cursor' + readonly capabilities: SubagentCapabilities = NO_START_CAPABILITIES + readonly inheritsParentContext = false + + constructor( + private readonly ctx: Context, + private readonly config: ResolvedConfig, + ) {} + + async start(request: ResolvedSubagentStartRequest) { + const parentCwd = request.parent.session.header.cwd + if (parentCwd === undefined) { + throw new Error( + 'subagent-cursor: no working directory for the child — delegate from a parent session that has one', + ) + } + const executable = assertDirectlySpawnable( + await this.ctx.subprocess.resolveExecutable( + CURSOR_EXECUTABLE, + this.config.env, + request.signal, + ), + ) + const spec: CursorRunSpec = { + cwd: resolveChildCwd( + 'subagent-cursor', + undefined, + parentCwd, + ), + executable, + env: this.config.env, + disposeGraceMs: this.config.disposeGraceMs, + force: this.config.force, + trust: this.config.trust, + spawn: spawnSpec => this.ctx.subprocess.spawn(spawnSpec), + onError: (error, stopReason) => { + this.ctx.logger.warn( + `subagent-cursor: child run failed (${stopReason}): ${error.message}`, + ) + }, + } + return startCursorRun(request, spec) + } +} + +/** + * Register the fixed `cursor` provider. + * @param ctx - context carrying shared subagent and subprocess services. + * @param config - explicit child environment, permissions, and disposal grace. + */ +export function apply(ctx: Context, config: Config): void { + const resolved = config as ResolvedConfig + assertPositiveFinite( + 'subagent-cursor', + 'disposeGraceMs', + resolved.disposeGraceMs, + ) + if (resolved.disposeGraceMs > MAX_TIMER_DELAY_MS) { + throw new Error( + `subagent-cursor: disposeGraceMs must be no greater than ${MAX_TIMER_DELAY_MS}`, + ) + } + ctx.subagents.registerProvider(new CursorProvider(ctx, resolved)) +} diff --git a/packages/subagent/subagent-cursor/src/invariant.ts b/packages/subagent/subagent-cursor/src/invariant.ts new file mode 100644 index 0000000000..c580e5383c --- /dev/null +++ b/packages/subagent/subagent-cursor/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-subagent-cursor`. + * @module @deepseek-ai/dsh-subagent-cursor/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from '@deepseek-ai/cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-subagent-cursor' + +/** Cordis companion plugin name. */ +export const name = 'subagent-cursor-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: lifecycle pairing belongs to the shared subagent + * service and process-tree ownership belongs to the subprocess service. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - plugin context carrying the invariant registry. + * @returns the installed registration's disposer. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/subagent/subagent-cursor/src/run.ts b/packages/subagent/subagent-cursor/src/run.ts new file mode 100644 index 0000000000..3a36688ec1 --- /dev/null +++ b/packages/subagent/subagent-cursor/src/run.ts @@ -0,0 +1,267 @@ +/** + * One-shot Cursor child lifecycle: spawn the real `cursor-agent` print-mode + * command through the subprocess seam, publish only after the CLI announces + * its session, flatten post-publication failures, and dispose to whole-tree + * quiescence. + * + * @module @deepseek-ai/dsh-subagent-cursor/run + */ + +import { randomUUID } from 'node:crypto' +import { extname } from 'node:path' +import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import { SessionId } from '@deepseek-ai/dsh-session' +import { + settleRunResult, + subprocessRunHandle, + type SubagentResult, + type SubagentRun, + type SubagentStartRequest, + type SubagentStopReason, +} from '@deepseek-ai/dsh-subagent' +import type { SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' +import { CursorStreamWire } from './wire.ts' + +/** Default POSIX grace between subprocess termination tiers. */ +export const DEFAULT_DISPOSE_GRACE_MS = 3_000 + +/* jscpd:ignore-start -- sibling providers intentionally keep product-private + * run inputs and error normalization instead of adding a shared lifecycle owner. */ +/** Fully resolved inputs for one `cursor-agent` print-mode run. */ +export interface CursorRunSpec { + /** Parent Session workspace, also passed as the CLI `--workspace`. */ + readonly cwd: string + /** Exact native `cursor-agent` executable resolved from the host PATH. */ + readonly executable: string + /** Explicit deployment/test environment layered after the shared scrub. */ + readonly env: Record + /** Subprocess termination grace passed to the shared process-tree owner. */ + readonly disposeGraceMs: number + /** Whether the child may apply changes instead of only proposing them. */ + readonly force: boolean + /** Whether the child may act in the workspace without a trust prompt. */ + readonly trust: boolean + /** Shared subprocess service spawn operation. */ + readonly spawn: (spec: SubprocessSpawnSpec) => SubprocessHandle + /** Diagnostic sink for a post-publication error flattened into a result. */ + readonly onError?: (error: Error, stopReason: SubagentStopReason) => void +} + +function thrown(value: unknown): Error { + /* v8 ignore next -- typed subprocess/wire failures reject with Error. */ + return value instanceof Error ? value : new Error(String(value)) +} +/* jscpd:ignore-end */ + +/** + * Validate and preserve the one-shot task before it becomes a command-line + * argument. + * + * `cursor-agent` takes the task as a POSITIONAL argument and documents no + * `--` end-of-options separator, so a task whose first character is `-` would + * be parsed as an option. The seam has no way to escape that, so it is + * rejected here rather than silently mis-parsed by the CLI. + * @param prompt - task content accepted from the shared subagent service. + * @returns the exact concatenated text task. + */ +export function textTask(prompt: readonly ContentBlock[]): string { + if (prompt.length === 0) { + throw new Error('subagent-cursor: the one-shot task must contain only text blocks') + } + const texts: string[] = [] + for (const block of prompt) { + if (block.type !== 'text') { + throw new Error('subagent-cursor: the one-shot task must contain only text blocks') + } + texts.push(block.text) + } + if (texts.every(text => text.trim().length === 0)) { + throw new Error('subagent-cursor: the one-shot task must not be empty') + } + const task = texts.join('') + if (task.startsWith('-')) { + throw new Error( + 'subagent-cursor: the one-shot task must not begin with "-" — cursor-agent takes it as a positional argument and would parse it as an option', + ) + } + return task +} + +/** + * Assert the resolved executable can be started without a shell. + * + * The task text is ordinary argv on every direct spawn, but a Windows `.cmd` + * or `.bat` shim can only run through `cmd.exe`, which reparses its command + * tail — model-authored task text would then be shell syntax. PATHEXT + * resolution prefers `cursor-agent.exe`, which the native Windows installer + * provides, so this fails loud instead of opening that boundary. + * @param executable - the resolved absolute executable path. + * @param platform - host platform selecting the batch-shim rejection. + * @returns the executable, validated. + */ +export function assertDirectlySpawnable( + executable: string, + platform: NodeJS.Platform = process.platform, +): string { + const extension = extname(executable).toLowerCase() + if (platform === 'win32' && (extension === '.cmd' || extension === '.bat')) { + throw new Error( + `subagent-cursor: resolved ${executable} is a batch shim that requires a shell; install the native cursor-agent executable so the task text stays ordinary argv`, + ) + } + return executable +} + +/** + * Build the fixed print-mode command for one run. + * @param spec - resolved executable, workspace, and permission selections. + * @param task - the validated positional task text. + * @returns argv for one non-interactive `cursor-agent` run. + */ +export function cursorAgentArgv( + spec: Pick, + task: string, +): string[] { + return [ + spec.executable, + '--print', + '--output-format', + 'stream-json', + '--workspace', + spec.cwd, + ...(spec.force ? ['--force'] : []), + ...(spec.trust ? ['--trust'] : []), + task, + ] +} + +/** + * Close the event stream, terminate the managed process tree, and wait for the + * subprocess owner to prove it is gone. + * @param wire - the run's private stdout event decoder. + * @param child - shared-service handle that owns the process tree. + */ +export async function disposeCursorChild( + wire: CursorStreamWire, + child: SubprocessHandle, +): Promise { + wire.close() + if (child.pid <= 0) { + await child.done.catch(() => {}) + return + } + child.terminate() + await child.waitForExit() + await child.done +} + +/** + * Start one real `cursor-agent` print-mode child and publish its one-shot run. + * @param request - resolved shared subagent request. + * @param spec - executable, workspace, environment, permissions, process + * service, and diagnostic policy. + * @returns the published run after the CLI announces its session. + */ +export async function startCursorRun( + request: SubagentStartRequest, + spec: CursorRunSpec, +): Promise { + const task = textTask(request.prompt) + if (request.signal.aborted) { + throw new Error('subagent-cursor: request was aborted before cursor-agent startup') + } + + const runAbort = new AbortController() + const child = spec.spawn({ + argv: cursorAgentArgv(spec, task), + cwd: spec.cwd, + stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'inherit' }, + graceMs: spec.disposeGraceMs, + // Print mode offers no reply channel, so there is no protocol interrupt to + // send: stopping the child IS the cancellation, and the subprocess seam + // owns that termination escalation. + signal: runAbort.signal, + env: spec.env, + }) + + const wire = new CursorStreamWire(child.stdout as NonNullable) + const disposeProcess = (): Promise => disposeCursorChild(wire, child) + + const processFailure: Promise = child.done.then( + outcome => Promise.reject(new Error( + 'subagent-cursor: cursor-agent exited before the run settled ' + + `(code ${String(outcome.exitCode)}, signal ${String(outcome.signal)})`, + )), + (error: unknown) => Promise.reject(thrown(error)), + ) + // A normal post-result dispose also closes the process. Keep that expected + // late rejection observed after the result race has already settled. + processFailure.catch(() => {}) + + const requestCancel = (): void => { + if (runAbort.signal.aborted) return + runAbort.abort(new Error('subagent-cursor: run cancelled locally')) + } + const onAbort = (): void => { requestCancel() } + request.signal.addEventListener('abort', onAbort, { once: true }) + + // The spawn signal stops the child; this race settles the run. Waiting for + // the termination ladder instead would leave a cancelled result pending for + // the whole grace period, so cancellation is observed here directly. + const cancellation: Promise = new Promise((_resolve, reject) => { + runAbort.signal.addEventListener( + 'abort', + () => { reject(new Error('subagent-cursor: run cancelled locally')) }, + { once: true }, + ) + }) + // Both races below observe this rejection, but only until one of them + // settles; keep it observed for the window after that. + cancellation.catch(() => {}) + + try { + wire.start() + // The task is already on the command line and print mode reads no input. + // Closing stdin now makes any prompt the CLI still tries to read fail fast + // instead of stalling an unattended child on an answer nobody can give. + child.stdin?.end() + await Promise.race([wire.ready(), processFailure, cancellation]) + } catch (error: unknown) { + request.signal.removeEventListener('abort', onAbort) + try { + await disposeProcess() + } catch (disposeError: unknown) { + throw new AggregateError( + [thrown(error), thrown(disposeError)], + 'subagent-cursor: startup failed and cursor-agent cleanup also failed', + ) + } + if (runAbort.signal.aborted) { + throw new Error('subagent-cursor: request was aborted before run publication') + } + throw thrown(error) + } + + /* jscpd:ignore-start -- the seam documents settlement and publication as two + * primitives, so every out-of-process provider ends with this same literal + * composition; folding them into a third seam function would trade two named + * steps for one eight-parameter call. */ + const result: Promise = settleRunResult({ + attempt: () => Promise.race([wire.awaitResult(), processFailure, cancellation]), + collectOutput: () => wire.collectOutput(), + cancelled: () => runAbort.signal.aborted, + onError: spec.onError, + signal: request.signal, + onAbort, + }) + + return subprocessRunHandle({ + id: SessionId(randomUUID()), + result, + signal: request.signal, + onAbort, + requestCancel, + teardown: disposeProcess, + }) + /* jscpd:ignore-end */ +} diff --git a/packages/subagent/subagent-cursor/src/wire.ts b/packages/subagent/subagent-cursor/src/wire.ts new file mode 100644 index 0000000000..c1bd8deba8 --- /dev/null +++ b/packages/subagent/subagent-cursor/src/wire.ts @@ -0,0 +1,265 @@ +/** + * Decoder for one `cursor-agent --print --output-format stream-json` run. The + * CLI writes newline-delimited JSON events on stdout and never reads a reply, + * so this module owns line framing, event validation at the process boundary, + * the `system`/`init` gate that run publication waits on, assistant-message + * selection, and terminal-answer selection. It sends nothing: cancellation is + * local and process termination belongs to the subprocess seam. + * + * @module @deepseek-ai/dsh-subagent-cursor/wire + */ + +import type { Readable } from 'node:stream' +import { StringDecoder } from 'node:string_decoder' +import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import type { SubagentResult } from '@deepseek-ai/dsh-subagent' + +type JsonObject = Record + +/** + * The `system`/`init` facts observed once before publication. They prove the + * CLI started and resolved its own credentials and model, which is this + * provider's equivalent of a remote session existing; both values stay + * private to the run and are never persisted in the parent Session. + */ +export interface CursorSessionInfo { + /** Cursor's own chat id for this run. */ + readonly sessionId: string + /** Model display name Cursor selected from its native configuration. */ + readonly model: string | undefined +} + +function object(value: unknown, label: string): JsonObject { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + throw new Error(`subagent-cursor: cursor-agent emitted an invalid ${label}`) + } + return value as JsonObject +} + +/** + * Select the text blocks of one Cursor message. Non-text blocks are dropped + * rather than rejected: the CLI may add block kinds this one-shot contract has + * no representation for, and dropping them cannot corrupt the selected answer. + * @param message - the event's `message` value. + * @param label - event name used in the boundary diagnostic. + * @returns the message's text blocks, possibly empty. + */ +function messageText(message: unknown, label: string): ContentBlock[] { + const content = object(message, label).content + if (!Array.isArray(content)) { + throw new Error(`subagent-cursor: cursor-agent emitted an invalid ${label} content`) + } + const blocks: ContentBlock[] = [] + for (const block of content) { + if ( + block !== null + && typeof block === 'object' + && !Array.isArray(block) + && (block as JsonObject).type === 'text' + && typeof (block as JsonObject).text === 'string' + ) { + blocks.push({ type: 'text', text: (block as JsonObject).text as string }) + } + } + return blocks +} + +/** + * Describe why a terminal `result` event cannot complete the run. The CLI + * carries no machine-readable failure taxonomy in print mode, so the reported + * detail is its own `subtype` plus the error marker. + * @param event - the terminal event. + * @returns a stable single-line diagnostic detail. + */ +function resultFailureDetail(event: JsonObject): string { + const subtype = typeof event.subtype === 'string' ? event.subtype : 'unknown' + return subtype === 'success' + ? 'success result was marked as an error or contained no answer' + : `terminal result subtype ${subtype}` +} + +/** + * One `cursor-agent` print-mode event stream. + * + * The class deliberately exposes no generic event surface. Observing another + * event kind must first become part of the provider contract. + */ +export class CursorStreamWire { + private readonly decoder = new StringDecoder('utf8') + private readonly initialization = Promise.withResolvers() + private readonly terminal = Promise.withResolvers() + private buffer = '' + private lastAssistantMessage: ContentBlock[] | undefined + private started = false + private closed = false + private initialized = false + private settled = false + + /** + * Attach to one run's stdout stream. + * @param input - the child's stdout pipe; the wire owns its listeners only + * between {@link start} and {@link close}. + */ + constructor(private readonly input: Readable) { + // Both gates can reject before their awaiter exists: a startup failure + // rejects `initialization` and abandons `terminal`, and a failure racing + // publication rejects `terminal` before `settleRunResult` adopts it. Keep + // both observed so neither becomes an unhandled rejection. + void this.initialization.promise.catch(() => {}) + void this.terminal.promise.catch(() => {}) + } + + /** Begin reading events. Idempotent. */ + start(): void { + if (this.started) return + this.started = true + this.input.on('data', this.onData) + this.input.on('error', this.onError) + this.input.on('end', this.onEnd) + } + + /** + * Detach listeners and fail both gates if they are still open. Idempotent, + * and safe before {@link start}. + */ + close(): void { + if (this.closed) return + this.closed = true + this.input.off('data', this.onData) + this.input.off('error', this.onError) + this.input.off('end', this.onEnd) + this.fail(new Error('subagent-cursor: cursor-agent event stream closed')) + } + + /** + * Await the `system`/`init` event that gates run publication. + * @returns Cursor's own chat id and selected model for this run. + */ + ready(): Promise { + return this.initialization.promise + } + + /** + * Await the terminal `result` event. + * @returns the completed result; rejects for every non-success ending, + * malformed event, stream failure, or end of stream without a result. + */ + awaitResult(): Promise { + return this.terminal.promise + } + + /** + * Snapshot the child's output for a cancelled or failed settlement. + * @returns the last non-empty assistant message, or `[]` when the child + * produced none. Without `--stream-partial-output` each `assistant` event + * is one complete message, so this is the seam's selection rule directly + * rather than a delta accumulation. + */ + collectOutput(): ContentBlock[] { + return this.lastAssistantMessage ?? [] + } + + private readonly onData = (chunk: Buffer): void => { + try { + // The wire owns stdout between `start` and `close` and never sets an + // encoding on it, so every chunk is bytes that may split a code point. + this.buffer += this.decoder.write(chunk) + for (;;) { + const newline = this.buffer.indexOf('\n') + if (newline < 0) break + const line = this.buffer.slice(0, newline) + this.buffer = this.buffer.slice(newline + 1) + if (line.trim().length > 0) this.handleLine(line) + } + } catch (error: unknown) { + /* v8 ignore next -- handleLine and the decoder throw only Error. */ + this.fail(error instanceof Error ? error : new Error(String(error))) + } + } + + private readonly onError = (error: Error): void => { + this.fail(error) + } + + private readonly onEnd = (): void => { + this.fail(new Error('subagent-cursor: cursor-agent ended without a terminal result')) + } + + private handleLine(line: string): void { + let parsed: unknown + try { + parsed = JSON.parse(line) + } catch { + // The CLI's stdout is a process boundary, so a line that is not JSON is + // a protocol failure rather than something to skip: skipping it would + // hide a version whose stream this contract cannot read. + throw new Error('subagent-cursor: cursor-agent emitted a stdout line that is not JSON') + } + const event = object(parsed, 'event') + switch (event.type) { + case 'system': + if (event.subtype === 'init') this.handleInit(event) + return + case 'assistant': { + const blocks = messageText(event.message, 'assistant message') + if (blocks.length > 0) this.lastAssistantMessage = blocks + return + } + case 'result': + this.handleResult(event) + return + default: + // `user`, `tool_call`, and any event kind a newer CLI adds contribute + // nothing to this one-shot contract: publication is gated on `init` + // and the answer comes from `result`. + return + } + } + + private handleInit(event: JsonObject): void { + if (typeof event.session_id !== 'string' || event.session_id.length === 0) { + throw new Error('subagent-cursor: cursor-agent emitted an invalid init session id') + } + this.initialized = true + this.initialization.resolve({ + sessionId: event.session_id, + model: typeof event.model === 'string' ? event.model : undefined, + }) + } + + private handleResult(event: JsonObject): void { + const answer = event.result + if ( + event.subtype !== 'success' + || event.is_error === true + || typeof answer !== 'string' + || answer.trim().length === 0 + ) { + this.fail(new Error(`subagent-cursor: cursor-agent failed: ${resultFailureDetail(event)}`)) + return + } + this.settled = true + if (!this.initialized) { + // A run that never announced itself cannot be published, so its answer + // has nowhere to go: fail startup instead of resolving a result for a + // run the caller was never handed. + const error = new Error('subagent-cursor: cursor-agent produced a result without announcing a session') + this.initialization.reject(error) + this.terminal.reject(error) + return + } + // The terminal event carries the full assistant answer, so it wins over + // the last streamed message for a completed run. + this.terminal.resolve({ + output: [{ type: 'text', text: answer }], + stopReason: 'completed', + }) + } + + private fail(error: Error): void { + if (this.settled) return + this.settled = true + this.initialization.reject(error) + this.terminal.reject(error) + } +} diff --git a/packages/subagent/subagent-cursor/tests/loader-composition.e2e.ts b/packages/subagent/subagent-cursor/tests/loader-composition.e2e.ts new file mode 100644 index 0000000000..26889cd94a --- /dev/null +++ b/packages/subagent/subagent-cursor/tests/loader-composition.e2e.ts @@ -0,0 +1,54 @@ +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' +import { + LOADER_SMOKE_TEST_TIMEOUT_MS, + runLoaderSmoke, +} from '@deepseek-ai/dsh-loader-smoke' + +const fixtureDir = fileURLToPath(new URL( + '../../../../examples/acp-agent/tests/fixtures/subagent/subagent-cursor/', + import.meta.url, +)) +const driver = join(fixtureDir, 'driver.ts') +const configPath = join(fixtureDir, 'cordis.yml') +const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)) + +describe('Cursor provider public Loader composition', () => { + it('loads the opt-in package, one-shot task tool, and job controls without starting Cursor', async () => { + const { stdout, stderr } = await runLoaderSmoke({ + label: 'subagent-cursor Loader composition', + tempDirPrefix: 'dsh-subagent-cursor-loader-', + binScript: driver, + libBinScript: driver, + configPath, + tsconfigPath: repoTsconfig, + env: { + // Loading the optional package must not probe or start a cursor-agent binary. + PATH: '', + }, + }) + + expect(stderr).toBe('') + expect(JSON.parse(stdout)).toEqual({ + providers: ['cursor'], + provider: { + name: 'cursor', + capabilities: { + outputSchema: false, + depthLimit: false, + toolFilter: false, + persona: false, + }, + inheritsParentContext: false, + }, + tool: { + name: 'subagent_cursor', + parameterNames: ['description', 'prompt', 'run_in_background'], + required: ['description', 'prompt'], + }, + jobTools: ['job_kill', 'job_list', 'job_output'], + starts: 0, + }) + }, LOADER_SMOKE_TEST_TIMEOUT_MS) +}) diff --git a/packages/subagent/subagent-cursor/tests/subagent-cursor.spec.ts b/packages/subagent/subagent-cursor/tests/subagent-cursor.spec.ts new file mode 100644 index 0000000000..ff8e897693 --- /dev/null +++ b/packages/subagent/subagent-cursor/tests/subagent-cursor.spec.ts @@ -0,0 +1,704 @@ +import { PassThrough } from 'node:stream' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import { describe, expect, it, vi } from 'vitest' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import SubagentRuntime, { type SubagentStopReason } from '@deepseek-ai/dsh-subagent' +import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' +import type { + SubprocessHandle, + SubprocessOutcome, +} from '@deepseek-ai/dsh-subprocess' +import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local' +import * as cursor from '../src/index.ts' +import * as invariant from '../src/invariant.ts' +import { + assertDirectlySpawnable, + cursorAgentArgv, + DEFAULT_DISPOSE_GRACE_MS, + disposeCursorChild, + startCursorRun, + textTask, + type CursorRunSpec, +} from '../src/run.ts' +import { CursorStreamWire } from '../src/wire.ts' + +type JsonObject = Record + +const EXECUTABLE = '/opt/cursor/cursor-agent' + +const fakeParent = { + id: 'parent', + session: { header: { cwd: process.cwd() } }, +} as unknown as Agent + +function request( + prompt: ContentBlock[] = [{ type: 'text', text: 'do the task' }], + signal = new AbortController().signal, +) { + return { prompt, parent: fakeParent, signal } +} + +async function nextTask(): Promise { + await new Promise((resolve) => { setImmediate(resolve) }) +} + +function initEvent(overrides: JsonObject = {}): JsonObject { + return { + type: 'system', + subtype: 'init', + apiKeySource: 'env', + cwd: process.cwd(), + session_id: 'chat-1', + model: 'cursor-model', + permissionMode: 'default', + ...overrides, + } +} + +function assistantEvent(content: unknown): JsonObject { + return { + type: 'assistant', + message: { role: 'assistant', content }, + session_id: 'chat-1', + } +} + +function resultEvent(overrides: JsonObject = {}): JsonObject { + return { + type: 'result', + subtype: 'success', + duration_ms: 12, + duration_api_ms: 10, + is_error: false, + result: 'the final answer', + session_id: 'chat-1', + ...overrides, + } +} + +/** Writes stream-json events the way the real CLI writes its stdout. */ +class StreamPeer { + constructor(private readonly output: PassThrough) {} + + send(...events: readonly JsonObject[]): void { + this.output.write(`${events.map(event => JSON.stringify(event)).join('\n')}\n`) + } + + raw(text: string): void { + this.output.write(text) + } +} + +interface FakeChildOptions { + readonly pid?: number + readonly exitOnTerminate?: boolean + readonly doneError?: Error +} + +interface FakeChild { + readonly handle: SubprocessHandle + readonly peer: StreamPeer + readonly fromChild: PassThrough + readonly toChild: PassThrough + readonly settle: (outcome?: SubprocessOutcome) => void + readonly fail: (error: Error) => void + readonly terminate: () => void + readonly waitForExit: (signal?: AbortSignal) => Promise +} + +function fakeChild(options: FakeChildOptions = {}): FakeChild { + const fromChild = new PassThrough() + const toChild = new PassThrough() + const peer = new StreamPeer(fromChild) + let exited = false + let resolveDone!: (outcome: SubprocessOutcome) => void + let rejectDone!: (error: Error) => void + const done = new Promise((resolve, reject) => { + resolveDone = resolve + rejectDone = reject + }) + const settle = ( + outcome: SubprocessOutcome = { exitCode: 0, signal: null }, + ): void => { + if (exited) return + exited = true + resolveDone(outcome) + } + const fail = (error: Error): void => { + if (exited) return + exited = true + rejectDone(error) + } + if (options.doneError !== undefined) fail(options.doneError) + const terminate = vi.fn(() => { + if (options.exitOnTerminate !== false) settle() + }) + const waitForExit = vi.fn(async (signal?: AbortSignal) => { + if (exited) return true + if (signal === undefined) { + await done.catch(() => {}) + return true + } + return await new Promise((resolve) => { + const onAbort = (): void => { resolve(false) } + signal.addEventListener('abort', onAbort, { once: true }) + void done.then( + () => { + signal.removeEventListener('abort', onAbort) + resolve(true) + }, + () => { + signal.removeEventListener('abort', onAbort) + resolve(true) + }, + ) + }) + }) + const handle: SubprocessHandle = { + pid: options.pid ?? 4321, + stdin: toChild, + stdout: fromChild, + stderr: undefined, + collected: {}, + done, + terminate, + waitForExit, + } + return { + handle, + peer, + fromChild, + toChild, + settle, + fail, + terminate, + waitForExit, + } +} + +function runSpec( + child: FakeChild, + overrides: Partial = {}, +): CursorRunSpec { + return { + cwd: process.cwd(), + executable: EXECUTABLE, + env: {}, + disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS, + force: false, + trust: false, + spawn: () => child.handle, + ...overrides, + } +} + +async function publishRun( + child = fakeChild(), + signal = new AbortController().signal, + specOverrides: Partial = {}, +) { + const starting = startCursorRun(request(undefined, signal), runSpec(child, specOverrides)) + await nextTask() + child.peer.send(initEvent()) + return { child, run: await starting } +} + +function startedWire(): { readonly child: FakeChild; readonly wire: CursorStreamWire } { + const child = fakeChild() + const wire = new CursorStreamWire(child.handle.stdout!) + wire.start() + return { child, wire } +} + +describe('task admission and command construction', () => { + it('accepts one or more text blocks and rejects empty, non-text, or option-shaped tasks', () => { + expect(textTask([{ type: 'text', text: 'a' }, { type: 'text', text: 'b' }])).toBe('ab') + expect(() => textTask([])).toThrow('must contain only text blocks') + expect(() => textTask([{ type: 'reasoning', text: 'x' }])) + .toThrow('must contain only text blocks') + expect(() => textTask([{ type: 'text', text: ' ' }])).toThrow('must not be empty') + expect(() => textTask([{ type: 'text', text: '--force me' }])) + .toThrow('must not begin with "-"') + }) + + it('refuses a Windows batch shim so task text never reaches a shell', () => { + expect(assertDirectlySpawnable('C:\\bin\\cursor-agent.exe', 'win32')) + .toBe('C:\\bin\\cursor-agent.exe') + expect(assertDirectlySpawnable('/usr/bin/cursor-agent', 'linux')) + .toBe('/usr/bin/cursor-agent') + // A shim is only unusable where it needs cmd.exe. + expect(assertDirectlySpawnable('/usr/bin/cursor-agent.cmd', 'linux')) + .toBe('/usr/bin/cursor-agent.cmd') + for (const shim of ['C:\\bin\\cursor-agent.cmd', 'C:\\bin\\cursor-agent.BAT']) { + expect(() => assertDirectlySpawnable(shim, 'win32')).toThrow('is a batch shim') + } + // The omitted platform reads the host, so the expectation follows it. + const underHostPlatform = (): string => assertDirectlySpawnable('C:\\bin\\cursor-agent.cmd') + if (process.platform === 'win32') { + expect(underHostPlatform).toThrow('is a batch shim') + } else { + expect(underHostPlatform()).toBe('C:\\bin\\cursor-agent.cmd') + } + }) + + it('builds the fixed print-mode argv and adds only selected permissions', () => { + const base = { executable: EXECUTABLE, cwd: '/work', force: false, trust: false } + expect(cursorAgentArgv(base, 'ship it')).toEqual([ + EXECUTABLE, + '--print', + '--output-format', + 'stream-json', + '--workspace', + '/work', + 'ship it', + ]) + expect(cursorAgentArgv({ ...base, force: true, trust: true }, 'ship it')).toEqual([ + EXECUTABLE, + '--print', + '--output-format', + 'stream-json', + '--workspace', + '/work', + '--force', + '--trust', + 'ship it', + ]) + }) +}) + +describe('package contracts', () => { + it('registers one fixed descriptor, validates config, and unregisters on HMR', async () => { + const ctx = new Context() + await ctx.plugin(SubagentRuntime) + await ctx.plugin(LocalSubprocessRuntime) + const fiber = await ctx.plugin(cursor, {}) + expect(ctx.subagents.getProvider('cursor')).toMatchObject({ + name: 'cursor', + capabilities: { + outputSchema: false, + depthLimit: false, + toolFilter: false, + persona: false, + }, + inheritsParentContext: false, + }) + expect(ctx.subagents.list()).toEqual(['cursor']) + await fiber.dispose() + expect(ctx.subagents.list()).toEqual([]) + + for (const disposeGraceMs of [0, -1, Number.NaN, Number.POSITIVE_INFINITY]) { + await expect(ctx.plugin(cursor, { disposeGraceMs })) + .rejects.toThrow('disposeGraceMs must be a positive finite number') + } + await expect(ctx.plugin(cursor, { disposeGraceMs: MAX_TIMER_DELAY_MS + 1 })) + .rejects.toThrow(`disposeGraceMs must be no greater than ${MAX_TIMER_DELAY_MS}`) + await ctx.fiber.dispose() + }) + + it('requires a parent session cwd before resolving or spawning anything', async () => { + const ctx = new Context() + await ctx.plugin(SubagentRuntime) + await ctx.plugin(LocalSubprocessRuntime) + const resolveExecutable = vi.spyOn(ctx.subprocess, 'resolveExecutable') + const spawn = vi.spyOn(ctx.subprocess, 'spawn') + await ctx.plugin(cursor, {}) + + await expect(ctx.subagents.start('cursor', { + prompt: [{ type: 'text', text: 'task' }], + parent: { + id: 'parent-without-cwd', + session: { header: {} }, + } as unknown as Agent, + signal: new AbortController().signal, + })).rejects.toThrow( + 'subagent-cursor: no working directory for the child — delegate from a parent session that has one', + ) + expect(resolveExecutable).not.toHaveBeenCalled() + expect(spawn).not.toHaveBeenCalled() + await ctx.fiber.dispose() + }) + + it('keeps the namespace export shape and package-owned empty invariant', async () => { + expect('default' in cursor).toBe(false) + expect(cursor.name).toBe('subagent-cursor') + expect(cursor.inject).toEqual(['subagents', 'subprocess']) + const loader = Object.create(Loader.prototype) as Loader + expect(loader.unwrapExports(cursor)).toBe(cursor) + + const dispose = vi.fn() + const register = vi.fn(( + _packageName: string, + _installer: InvariantInstaller, + ) => dispose) + const ctx = { invariants: { register } } as unknown as Context + await expect(invariant.apply(ctx)).resolves.toBe(dispose) + expect(register).toHaveBeenCalledWith( + '@deepseek-ai/dsh-subagent-cursor', + expect.any(Function), + ) + const install = register.mock.calls[0]![1] + await install(new Context(), (message) => { throw new Error(message) }) + expect(invariant.name).toBe('subagent-cursor-invariant') + expect(invariant.inject).toEqual(['invariants']) + }) +}) + +describe('CursorStreamWire', () => { + it('gates on init, keeps the terminal answer, and ignores unrelated events', async () => { + const { child, wire } = startedWire() + wire.start() + child.peer.send(initEvent()) + await expect(wire.ready()).resolves.toEqual({ + sessionId: 'chat-1', + model: 'cursor-model', + }) + child.peer.send( + { type: 'user', message: { role: 'user', content: [{ type: 'text', text: 'task' }] } }, + { type: 'tool_call', subtype: 'started', call_id: 'c1', tool_call: {} }, + { type: 'system', subtype: 'usage', tokens: 12 }, + assistantEvent([{ type: 'text', text: 'thinking out loud' }]), + { type: 'newer_cli_event', payload: 1 }, + ) + await nextTask() + expect(wire.collectOutput()).toEqual([{ type: 'text', text: 'thinking out loud' }]) + child.peer.send(resultEvent()) + await expect(wire.awaitResult()).resolves.toEqual({ + output: [{ type: 'text', text: 'the final answer' }], + stopReason: 'completed', + }) + }) + + it('reads events split across chunks and skips blank lines', async () => { + const { child, wire } = startedWire() + const frame = JSON.stringify(initEvent()) + child.peer.raw(`\n \n${frame.slice(0, 10)}`) + await nextTask() + child.peer.raw(`${frame.slice(10)}\n`) + await expect(wire.ready()).resolves.toMatchObject({ sessionId: 'chat-1' }) + expect(wire.collectOutput()).toEqual([]) + }) + + it('reports an absent model rather than inventing one', async () => { + const { child, wire } = startedWire() + child.peer.send(initEvent({ model: 42 })) + await expect(wire.ready()).resolves.toEqual({ + sessionId: 'chat-1', + model: undefined, + }) + }) + + it('keeps the last non-empty assistant message and drops non-text blocks', async () => { + const { child, wire } = startedWire() + child.peer.send( + initEvent(), + assistantEvent([{ type: 'text', text: 'first' }]), + assistantEvent([{ type: 'image', source: {} }]), + assistantEvent([]), + assistantEvent([{ type: 'text', text: 'second' }, { type: 'image', source: {} }]), + ) + await nextTask() + expect(wire.collectOutput()).toEqual([{ type: 'text', text: 'second' }]) + }) + + it('fails closed on every unusable terminal result', async () => { + for (const [overrides, detail] of [ + [{ subtype: 'error' }, 'terminal result subtype error'], + [{ subtype: 7 }, 'terminal result subtype unknown'], + [{ is_error: true }, 'success result was marked as an error'], + [{ result: 12 }, 'success result was marked as an error'], + [{ result: ' ' }, 'success result was marked as an error'], + ] as const) { + const { child, wire } = startedWire() + child.peer.send(initEvent(), resultEvent(overrides)) + await expect(wire.awaitResult()).rejects.toThrow(detail) + } + }) + + it('refuses a result that arrives without an announced session', async () => { + const { child, wire } = startedWire() + child.peer.send(resultEvent()) + await expect(wire.ready()).rejects.toThrow('without announcing a session') + await expect(wire.awaitResult()).rejects.toThrow('without announcing a session') + }) + + it('treats malformed stdout as a protocol failure', async () => { + for (const [line, detail] of [ + ['not json at all', 'a stdout line that is not JSON'], + ['[1,2]', 'an invalid event'], + ['null', 'an invalid event'], + [JSON.stringify({ type: 'system', subtype: 'init', session_id: '' }), 'an invalid init session id'], + [JSON.stringify({ type: 'assistant', message: 'text' }), 'an invalid assistant message'], + [JSON.stringify(assistantEvent('not an array')), 'an invalid assistant message content'], + ] as const) { + const { child, wire } = startedWire() + child.peer.raw(`${line}\n`) + await expect(wire.awaitResult()).rejects.toThrow(detail) + } + }) + + it('fails pending gates on stream error, end of stream, and close', async () => { + const broken = startedWire() + broken.child.fromChild.emit('error', new Error('stdout broke')) + await expect(broken.wire.awaitResult()).rejects.toThrow('stdout broke') + + const ended = startedWire() + ended.child.fromChild.end() + await expect(ended.wire.awaitResult()).rejects.toThrow('ended without a terminal result') + + const closed = startedWire() + closed.wire.close() + closed.wire.close() + await expect(closed.wire.ready()).rejects.toThrow('event stream closed') + + // A completed run keeps its result across teardown and end of stream. + const done = startedWire() + done.child.peer.send(initEvent(), resultEvent()) + await expect(done.wire.awaitResult()).resolves.toMatchObject({ stopReason: 'completed' }) + done.wire.close() + done.child.fromChild.end() + await expect(done.wire.awaitResult()).resolves.toMatchObject({ stopReason: 'completed' }) + }) + + it('is safe to close before it is started', async () => { + const child = fakeChild() + const wire = new CursorStreamWire(child.handle.stdout!) + wire.close() + await expect(wire.ready()).rejects.toThrow('event stream closed') + }) +}) + +describe('run lifecycle and quiescence', () => { + it('spawns the fixed command, publishes after init, and disposes once', async () => { + const child = fakeChild() + const spawn = vi.fn(() => child.handle) + const starting = startCursorRun( + request(), + runSpec(child, { spawn, force: true, trust: true, disposeGraceMs: 40, env: { CURSOR_API_KEY: 'fake' } }), + ) + await nextTask() + expect(spawn).toHaveBeenCalledWith(expect.objectContaining({ + argv: [ + EXECUTABLE, + '--print', + '--output-format', + 'stream-json', + '--workspace', + process.cwd(), + '--force', + '--trust', + 'do the task', + ], + cwd: process.cwd(), + graceMs: 40, + env: { CURSOR_API_KEY: 'fake' }, + })) + child.peer.send(initEvent()) + const run = await starting + expect(run.id).toMatch(/^[0-9a-f-]{36}$/) + expect(run.localAgent).toBeUndefined() + + child.peer.send(resultEvent()) + await expect(run.result).resolves.toEqual({ + output: [{ type: 'text', text: 'the final answer' }], + stopReason: 'completed', + }) + const first = run.dispose() + expect(run.dispose()).toBe(first) + await first + expect(child.terminate).toHaveBeenCalledTimes(1) + }) + + it('closes stdin so an unattended prompt cannot stall the child', async () => { + const { child, run } = await publishRun() + expect(child.toChild.writableEnded).toBe(true) + child.peer.send(resultEvent()) + await run.result + await run.dispose() + }) + + it('settles a cancelled run as aborted with the output collected so far', async () => { + const controller = new AbortController() + const child = fakeChild({ exitOnTerminate: false }) + const { run } = await publishRun(child, controller.signal) + child.peer.send(assistantEvent([{ type: 'text', text: 'partial work' }])) + await nextTask() + controller.abort(new Error('parent stopped waiting')) + await expect(run.result).resolves.toEqual({ + output: [{ type: 'text', text: 'partial work' }], + stopReason: 'aborted', + }) + child.settle({ exitCode: null, signal: 'SIGTERM' }) + await run.dispose() + }) + + it('flattens a child exit and a protocol failure after publication', async () => { + const exited = await publishRun() + exited.child.settle({ exitCode: 2, signal: null }) + await expect(exited.run.result).resolves.toEqual({ + output: [], + stopReason: 'error', + }) + await exited.run.dispose() + + const onError = vi.fn<(error: Error, stopReason: SubagentStopReason) => void>() + const malformed = await publishRun(fakeChild(), new AbortController().signal, { onError }) + malformed.child.peer.raw('garbage\n') + await expect(malformed.run.result).resolves.toMatchObject({ stopReason: 'error' }) + expect(onError).toHaveBeenCalledTimes(1) + const [reported, reportedReason] = onError.mock.calls[0]! + expect(reported.message).toContain('not JSON') + expect(reportedReason).toBe('error') + await malformed.run.dispose() + }) + + it('rejects before spawn when the request is already aborted', async () => { + const controller = new AbortController() + controller.abort() + const child = fakeChild() + const spawn = vi.fn(() => child.handle) + await expect(startCursorRun(request(undefined, controller.signal), runSpec(child, { spawn }))) + .rejects.toThrow('aborted before cursor-agent startup') + expect(spawn).not.toHaveBeenCalled() + }) + + it('rolls the child back when startup fails or is aborted before publication', async () => { + const failed = fakeChild() + const failing = startCursorRun(request(), runSpec(failed)) + await nextTask() + failed.peer.raw('not json\n') + await expect(failing).rejects.toThrow('not JSON') + expect(failed.terminate).toHaveBeenCalledTimes(1) + + const controller = new AbortController() + const aborted = fakeChild() + const aborting = startCursorRun(request(undefined, controller.signal), runSpec(aborted)) + await nextTask() + controller.abort() + await expect(aborting).rejects.toThrow('aborted before run publication') + expect(aborted.terminate).toHaveBeenCalledTimes(1) + + const exited = fakeChild() + const exiting = startCursorRun(request(), runSpec(exited)) + await nextTask() + exited.settle({ exitCode: 3, signal: null }) + await expect(exiting).rejects.toThrow('exited before the run settled') + + // A failed spawn reports pid -1, so rollback has no tree to signal. + const broken = fakeChild({ pid: -1, doneError: new Error('spawn observer failed') }) + await expect(startCursorRun(request(), runSpec(broken))) + .rejects.toThrow('spawn observer failed') + expect(broken.terminate).not.toHaveBeenCalled() + }) + + it('reports a startup failure whose cleanup also failed as an aggregate', async () => { + const child = fakeChild() + child.handle.waitForExit = vi.fn(() => Promise.reject(new Error('tree never exited'))) + const starting = startCursorRun(request(), runSpec(child)) + await nextTask() + child.peer.raw('not json\n') + const failure = await starting.catch((error: unknown) => error) + expect(failure).toBeInstanceOf(AggregateError) + expect((failure as AggregateError).message) + .toContain('startup failed and cursor-agent cleanup also failed') + }) + + it('keeps overlapping runs isolated', async () => { + const first = await publishRun() + const second = await publishRun() + first.child.peer.send(resultEvent({ result: 'first answer' })) + second.child.peer.send(resultEvent({ subtype: 'error' })) + await expect(first.run.result).resolves.toMatchObject({ + output: [{ type: 'text', text: 'first answer' }], + stopReason: 'completed', + }) + await expect(second.run.result).resolves.toMatchObject({ stopReason: 'error' }) + await first.run.dispose() + await second.run.dispose() + }) + + it('uses the registered provider config, the resolved executable, and logs flattened errors', async () => { + const ctx = new Context() + await ctx.plugin(SubagentRuntime) + await ctx.plugin(LocalSubprocessRuntime) + const child = fakeChild() + const resolveExecutable = vi.spyOn(ctx.subprocess, 'resolveExecutable') + .mockResolvedValue(EXECUTABLE) + const spawn = vi.spyOn(ctx.subprocess, 'spawn').mockReturnValue(child.handle) + const warnings: string[] = [] + ctx.logger.warn = ((message: unknown) => { + warnings.push(String(message)) + }) as typeof ctx.logger.warn + await ctx.plugin(cursor, { + env: { CURSOR_API_KEY: 'fake' }, + disposeGraceMs: 25, + force: true, + }) + const starting = ctx.subagents.start('cursor', { + prompt: [{ type: 'text', text: 'task' }], + parent: fakeParent, + signal: new AbortController().signal, + }) + await nextTask() + child.peer.send(initEvent()) + const run = await starting + child.settle({ exitCode: 1, signal: null }) + await expect(run.result).resolves.toMatchObject({ stopReason: 'error' }) + expect(resolveExecutable).toHaveBeenCalledWith( + 'cursor-agent', + { CURSOR_API_KEY: 'fake' }, + expect.any(AbortSignal), + ) + expect(spawn).toHaveBeenCalledTimes(1) + const spawned = spawn.mock.calls[0]![0] + expect(spawned.argv).toContain('--force') + expect(spawned.env).toEqual({ CURSOR_API_KEY: 'fake' }) + expect(spawned.graceMs).toBe(25) + expect(spawned.cwd).toBe(process.cwd()) + expect(warnings).toEqual([ + expect.stringContaining('subagent-cursor: child run failed (error):'), + ]) + await run.dispose().catch(() => {}) + await ctx.fiber.dispose() + }) +}) + +describe('disposeCursorChild', () => { + it('closes the stream, terminates, and waits for the managed tree', async () => { + const child = fakeChild() + const wire = new CursorStreamWire(child.handle.stdout!) + wire.start() + await disposeCursorChild(wire, child.handle) + expect(child.terminate).toHaveBeenCalledTimes(1) + expect(child.waitForExit).toHaveBeenCalledTimes(1) + }) + + it('does not finish disposal before the managed tree exits', async () => { + const child = fakeChild({ exitOnTerminate: false }) + const wire = new CursorStreamWire(child.handle.stdout!) + let settled = false + const disposing = disposeCursorChild(wire, child.handle).then(() => { settled = true }) + await nextTask() + expect(settled).toBe(false) + child.settle() + await disposing + expect(settled).toBe(true) + }) + + it('skips signalling a failed spawn and contains its observer rejection', async () => { + const child = fakeChild({ pid: -1, doneError: new Error('spawn failed') }) + const wire = new CursorStreamWire(child.handle.stdout!) + await expect(disposeCursorChild(wire, child.handle)).resolves.toBeUndefined() + expect(child.terminate).not.toHaveBeenCalled() + }) + + it('reports a direct-child observer failure from a live tree', async () => { + const child = fakeChild({ exitOnTerminate: false }) + const wire = new CursorStreamWire(child.handle.stdout!) + const disposing = disposeCursorChild(wire, child.handle) + child.fail(new Error('observer failed')) + await expect(disposing).rejects.toThrow('observer failed') + }) +}) diff --git a/packages/subagent/subagent-cursor/tsconfig.json b/packages/subagent/subagent-cursor/tsconfig.json new file mode 100644 index 0000000000..cf9ca295eb --- /dev/null +++ b/packages/subagent/subagent-cursor/tsconfig.json @@ -0,0 +1,42 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../core/agent" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../core/session" + }, + { + "path": "../subagent" + }, + { + "path": "../../subprocess/subprocess" + }, + { + "path": "../../util/timeout" + }, + { + "path": "../../runtime-diagnostics/invariants" + } + ] +} diff --git a/packages/subagent/subagent/src/list-children.ts b/packages/subagent/subagent/src/list-children.ts index 95c3be8520..8877055f74 100644 --- a/packages/subagent/subagent/src/list-children.ts +++ b/packages/subagent/subagent/src/list-children.ts @@ -19,6 +19,7 @@ import type { Context } from '@deepseek-ai/cordis' import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence' +import { foldContextOf } from '@deepseek-ai/dsh-session-projection' import type { SessionProjectionRegistry } from '@deepseek-ai/dsh-session-projection' import type { SessionProjectionCache } from '@deepseek-ai/dsh-session-projection-cache' import { SubagentError } from './error.ts' @@ -395,7 +396,7 @@ async function resolveColdIdentity( } let identity: SubagentIdentityProjection | null | undefined try { - identity = projections.restore({}, inspected.events, 0).snapshot.values.subagent + identity = projections.restore({}, inspected.events, 0, foldContextOf(inspected.meta)).snapshot.values.subagent } catch { // The restore folds EVERY registered unit over this child's log, so any // unit's fold or schema can reject damaged payloads — deterministic data diff --git a/packages/subagent/subagent/tests/timing-projection.spec.ts b/packages/subagent/subagent/tests/timing-projection.spec.ts index ab3ffac9a2..559a0659a7 100644 --- a/packages/subagent/subagent/tests/timing-projection.spec.ts +++ b/packages/subagent/subagent/tests/timing-projection.spec.ts @@ -2,9 +2,13 @@ import { describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import SessionStore from '@deepseek-ai/dsh-session' import type { SessionEvent } from '@deepseek-ai/dsh-session' -import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' +import SessionProjectionRegistry, { foldContextOf } from '@deepseek-ai/dsh-session-projection' import SubagentRuntime from '../src/index.ts' import { subagentTimingProjectionDefinition } from '../src/projection.ts' +/** The unit under test ignores the fold context; this session inherits no fork prefix. */ +const applyTiming = (state: Parameters[0], event: SessionEvent) => + subagentTimingProjectionDefinition.apply(state, event, foldContextOf({})) + function event(type: SessionEvent['type'], seq: number, time: number): SessionEvent { return { type, seq, time, data: {} } as SessionEvent @@ -12,7 +16,7 @@ function event(type: SessionEvent['type'], seq: number, time: number): SessionEv function fold(events: SessionEvent[]) { let state = subagentTimingProjectionDefinition.init() - for (const item of events) state = subagentTimingProjectionDefinition.apply(state, item) + for (const item of events) state = applyTiming(state, item) return subagentTimingProjectionDefinition.view(state) } @@ -59,19 +63,19 @@ describe('subagent timing projection', () => { it('ignores completed pre-descriptor turns and unrelated events', () => { const initial = subagentTimingProjectionDefinition.init() - expect(subagentTimingProjectionDefinition.apply( + expect(applyTiming( initial, event('assistant/chunk', 0, 1), )).toBe(initial) - expect(subagentTimingProjectionDefinition.apply( + expect(applyTiming( initial, event('turn/end', 1, 2), )).toBe(initial) - const descriptor = subagentTimingProjectionDefinition.apply( + const descriptor = applyTiming( initial, event('subagent/descriptor', 2, 3), ) - expect(subagentTimingProjectionDefinition.apply( + expect(applyTiming( descriptor, event('turn/end', 3, 4), )).toBe(descriptor) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ca3a96c1a2..19e6dbd196 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -198,6 +198,12 @@ importers: '@deepseek-ai/dsh-launch-environment': specifier: workspace:^ version: link:../../packages/util/launch-environment + '@deepseek-ai/dsh-lsp': + specifier: workspace:^ + version: link:../../packages/lsp/lsp + '@deepseek-ai/dsh-lsp-stdio': + specifier: workspace:^ + version: link:../../packages/lsp/lsp-stdio '@deepseek-ai/dsh-mcp-client': specifier: workspace:^ version: link:../../packages/mcp/mcp-client @@ -228,6 +234,15 @@ importers: '@deepseek-ai/dsh-skill-filesystem': specifier: workspace:^ version: link:../../packages/skill/skill-filesystem + '@deepseek-ai/dsh-subagent-claude-code': + specifier: workspace:^ + version: link:../../packages/subagent/subagent-claude-code + '@deepseek-ai/dsh-subagent-codex': + specifier: workspace:^ + version: link:../../packages/subagent/subagent-codex + '@deepseek-ai/dsh-subagent-cursor': + specifier: workspace:^ + version: link:../../packages/subagent/subagent-cursor '@deepseek-ai/dsh-terminal': specifier: workspace:^ version: link:../../packages/terminal/terminal @@ -267,12 +282,18 @@ importers: '@deepseek-ai/dsh-tool-jobs': specifier: workspace:^ version: link:../../packages/jobs/tool-jobs + '@deepseek-ai/dsh-tool-lsp': + specifier: workspace:^ + version: link:../../packages/lsp/tool-lsp '@deepseek-ai/dsh-tool-pwsh': specifier: workspace:^ version: link:../../packages/shell/tool-pwsh '@deepseek-ai/dsh-tool-ralph': specifier: workspace:^ version: link:../../packages/workflow/tool-ralph + '@deepseek-ai/dsh-tool-session-query': + specifier: workspace:^ + version: link:../../packages/session-query/tool-session-query '@deepseek-ai/dsh-tool-skill': specifier: workspace:^ version: link:../../packages/skill/tool-skill @@ -285,6 +306,9 @@ importers: '@deepseek-ai/dsh-tool-subagent-control': specifier: workspace:^ version: link:../../packages/subagent/tool-subagent-control + '@deepseek-ai/dsh-tool-terminal': + specifier: workspace:^ + version: link:../../packages/terminal/tool-terminal '@deepseek-ai/dsh-tool-todo': specifier: workspace:^ version: link:../../packages/todo/tool-todo @@ -626,6 +650,9 @@ importers: '@deepseek-ai/dsh-subagent-codex': specifier: workspace:* version: link:../packages/subagent/subagent-codex + '@deepseek-ai/dsh-subagent-cursor': + specifier: workspace:* + version: link:../packages/subagent/subagent-cursor '@deepseek-ai/dsh-subagent-dsh-sdk': specifier: workspace:* version: link:../packages/subagent/subagent-dsh-sdk @@ -1064,6 +1091,12 @@ importers: '@deepseek-ai/dsh-llm-retry': specifier: workspace:^ version: link:../../llm/llm-retry + '@deepseek-ai/dsh-lsp': + specifier: workspace:^ + version: link:../../lsp/lsp + '@deepseek-ai/dsh-lsp-stdio': + specifier: workspace:^ + version: link:../../lsp/lsp-stdio '@deepseek-ai/dsh-permission-presets': specifier: workspace:^ version: link:../../interaction/permission-presets @@ -1130,6 +1163,15 @@ importers: '@deepseek-ai/dsh-subagent': specifier: workspace:^ version: link:../../subagent/subagent + '@deepseek-ai/dsh-subagent-claude-code': + specifier: workspace:^ + version: link:../../subagent/subagent-claude-code + '@deepseek-ai/dsh-subagent-codex': + specifier: workspace:^ + version: link:../../subagent/subagent-codex + '@deepseek-ai/dsh-subagent-cursor': + specifier: workspace:^ + version: link:../../subagent/subagent-cursor '@deepseek-ai/dsh-subagent-fork-in-process': specifier: workspace:^ version: link:../../subagent/subagent-fork-in-process @@ -1163,6 +1205,9 @@ importers: '@deepseek-ai/dsh-tool-jobs': specifier: workspace:^ version: link:../../jobs/tool-jobs + '@deepseek-ai/dsh-tool-lsp': + specifier: workspace:^ + version: link:../../lsp/tool-lsp '@deepseek-ai/dsh-tool-pwsh': specifier: workspace:^ version: link:../../shell/tool-pwsh @@ -4104,6 +4149,31 @@ importers: specifier: workspace:^ version: link:../../core/tools + packages/extensions/tool-lab: + 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-fs': + specifier: workspace:^ + version: link:../../fs/fs + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../core/tools + packages/extensions/ui-cordis: devDependencies: '@deepseek-ai/cordis': @@ -7267,6 +7337,46 @@ importers: specifier: 0.147.0 version: 0.147.0 + packages/subagent/subagent-cursor: + dependencies: + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-loader': + specifier: workspace:^ + version: link:../../../vendor/loader + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-loader-smoke': + specifier: workspace:^ + version: link:../../test-support/loader-smoke + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-subagent': + specifier: workspace:^ + version: link:../subagent + '@deepseek-ai/dsh-subprocess': + specifier: workspace:^ + version: link:../../subprocess/subprocess + '@deepseek-ai/dsh-subprocess-local': + specifier: workspace:^ + version: link:../../subprocess/subprocess-local + '@deepseek-ai/dsh-timeout': + specifier: workspace:^ + version: link:../../util/timeout + packages/subagent/subagent-dsh-sdk: dependencies: '@deepseek-ai/schemastery': diff --git a/tsconfig.host.json b/tsconfig.host.json index a2919d4009..05a4f95525 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -263,6 +263,7 @@ { "path": "./packages/subagent/subagent-acp" }, { "path": "./packages/subagent/subagent-claude-code" }, { "path": "./packages/subagent/subagent-codex" }, + { "path": "./packages/subagent/subagent-cursor" }, { "path": "./packages/subagent/subagent-dsh-sdk" }, { "path": "./packages/jobs/jobs" }, { "path": "./packages/jobs/jobs-local" }, @@ -278,6 +279,7 @@ { "path": "./packages/guard/repeat-tool-reminder" }, { "path": "./packages/extensions/cordis-host-runner" }, { "path": "./packages/extensions/tool-cordis" }, + { "path": "./packages/extensions/tool-lab" }, { "path": "./packages/hooks/hook-protocol" }, { "path": "./packages/hooks/hooks-claude-code" }, { "path": "./packages/hooks/hooks-codex" },