Compare commits
5 Commits
ed152416d5
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| f27a0dcfb5 | |||
| 8719fa84f0 | |||
| e818e37f0a | |||
| 99b63abde6 | |||
| d2ad46b8aa |
@@ -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.
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||||
|
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||||
|
# after editing either side, bring the other along and re-record with:
|
||||||
|
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-20-web-session-projection-store-survives-revival.md
|
||||||
|
2026-08-20-web-session-projection-store-survives-revival.md: 27340e87e9fd2dfbfd4bf1868f4aacc2d4af5fdb
|
||||||
|
2026-08-20-web-session-projection-store-survives-revival.zh.md: 63799da02266e1b2a724bb00659ea0fb5717d93d
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# Agent Note: Web session projection stores survive removal and revival
|
||||||
|
|
||||||
|
Status: implemented
|
||||||
|
|
||||||
|
English | [中文](2026-08-20-web-session-projection-store-survives-revival.zh.md)
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
The client runtime's `ProjectionValueStore` — the per-session store backing
|
||||||
|
`useProjection('openRouterCost')`, `useProjection('title')`, and every other
|
||||||
|
projection face — was deleted on every `host/session-removed`. The session
|
||||||
|
instance itself is resident: `open()` is idempotent, `drop()` only removes
|
||||||
|
the `sessions` map entry, and removal is modeled as a `removed` flag on the
|
||||||
|
surviving instance. Deleting the projection store disagreed with that model:
|
||||||
|
when the session was re-added (`host/session-added`) or re-listed by a
|
||||||
|
`session.list` refresh, the same instance resumed without its projection
|
||||||
|
store, so projection-backed UI (notably the OpenRouter cost readout in the
|
||||||
|
composer dock) reverted to blank until the host re-served a projection
|
||||||
|
commit.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
The projection store follows the resident instance: `SessionManager` no
|
||||||
|
longer deletes it on `host/session-removed`. The `removed` tombstone is
|
||||||
|
lifted by a new `Session.handleRevived()` — called from the `host/session-added`
|
||||||
|
path and from the list-refresh summary push — which clears the flag and
|
||||||
|
records the store as dirty so subscribers re-read the retained values. A
|
||||||
|
re-added session therefore resumes its previous projection values through the
|
||||||
|
same instance; the host re-serves the projection rows on the next `session.list`
|
||||||
|
from its cached row set, and the store needs no re-seed.
|
||||||
|
|
||||||
|
## Alternatives considered
|
||||||
|
|
||||||
|
**Delete the store on removal and re-seed on re-add.** Rejected: the session
|
||||||
|
instance is not re-seeded by `open()` (idempotent), so a deletion would leave
|
||||||
|
the resumed session projection-blank until a fresh projection frame arrives.
|
||||||
|
Retention matches the resident-instance contract already in place.
|
||||||
|
|
||||||
|
**Drop the store only when the session is dropped from the list.** The removal
|
||||||
|
frame is the only cleanup signal the manager receives; there is no separate
|
||||||
|
lifecycle step distinguishing "tombstoned for revival" from "gone", so keeping
|
||||||
|
the store until `drop()` is the model-consistent rule.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
A removed-but-resident session keeps its projection values for as long as the
|
||||||
|
instance exists, and re-listing restores them without a host re-seed. The
|
||||||
|
store retains memory for removed sessions until the manager drops the map
|
||||||
|
entry, which is the same tenure the session instance already held. `test:gui`
|
||||||
|
pins both revival paths — `host/session-added` and list-refresh re-listing —
|
||||||
|
plus the plain-switch retention case, and the projection-store spec pins that
|
||||||
|
a removed session's store keeps its folded values.
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Agent Note: Web 会话投影存储在移除与复活后继续存活
|
||||||
|
|
||||||
|
Status: implemented
|
||||||
|
|
||||||
|
[English](2026-08-20-web-session-projection-store-survives-revival.md) | 中文
|
||||||
|
|
||||||
|
## 问题
|
||||||
|
|
||||||
|
客户端运行时的 `ProjectionValueStore` —— 支撑 `useProjection('openRouterCost')`、`useProjection('title')` 以及其他所有投影面(face)的按会话存储 —— 会在每次 `host/session-removed` 时被删除。会话实例本身是常驻的:`open()` 幂等,`drop()` 只删除 `sessions` 映射项,移除被建模为幸存实例上的 `removed` 标志。删除投影存储与该模型不一致:当会话被重新添加(`host/session-added`)或由 `session.list` 刷新重新列出时,同一实例继续运行却没有其投影存储,于是投影驱动的 UI(尤其是 composer dock 中的 OpenRouter 费用读数)会回退为空白,直到宿主重新下发投影提交。
|
||||||
|
|
||||||
|
## 决策
|
||||||
|
|
||||||
|
投影存储随常驻实例存活:`SessionManager` 不再在 `host/session-removed` 时删除它。`removed` 墓碑由新增的 `Session.handleRevived()` 解除——它从 `host/session-added` 路径与列表刷新摘要推送两处调用——清除标志并将存储标记为脏,使订阅者重新读取保留的值。因此被重新添加的会话通过同一个实例恢复之前的投影值;宿主在下一次 `session.list` 时从缓存的投影行集合重新下发这些行,存储无需重新播种。
|
||||||
|
|
||||||
|
## 考虑过的替代方案
|
||||||
|
|
||||||
|
**移除时删除存储,重新添加时重新播种。** 否决:`open()` 幂等,不会为会话重新播种,因此删除会让恢复的会话在收到新投影帧之前一直投影空白。保留与既有的常驻实例契约一致。
|
||||||
|
|
||||||
|
**仅当会话从列表移除时才丢弃存储。** 移除帧是管理器收到的唯一清理信号;没有单独的「为复活而墓碑化」与「真正消失」生命周期步骤,因此在 `drop()` 之前保留存储是模型一致的规则。
|
||||||
|
|
||||||
|
## 结论
|
||||||
|
|
||||||
|
被移除但常驻的会话在其实例存活期间保留投影值,重新列出时无需宿主重新播种即可恢复。在管理器删除映射项之前,存储会为已移除会话保留内存——这与会话实例本身已有的任期相同。`test:gui` 钉定了两条复活路径(`host/session-added` 与列表刷新重新列出),外加普通切换保留场景;投影存储规范钉定被移除会话的存储会保留其折叠后的值。
|
||||||
@@ -2,5 +2,5 @@
|
|||||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
# 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:
|
# after editing either side, bring the other along and re-record with:
|
||||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-20-openrouter-cost-balance-readouts.md
|
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-20-openrouter-cost-balance-readouts.md
|
||||||
2026-08-20-openrouter-cost-balance-readouts.md: 339acc4140eb6c34d1c3910a93babbeda9a28cac
|
2026-08-20-openrouter-cost-balance-readouts.md: 471ca8f3d6552a0fdba680580908b3d7b268b115
|
||||||
2026-08-20-openrouter-cost-balance-readouts.zh.md: 16606ae58b346a1eeb10f703eae748665f64ea83
|
2026-08-20-openrouter-cost-balance-readouts.zh.md: 5a3a09453bb26ebcc82887bda4209c281f8476bd
|
||||||
|
|||||||
@@ -42,7 +42,14 @@ into `conversation.composer.dock` (the shipped stats line's dock), reading the
|
|||||||
balance badge registers into `sidebar.footer.action`, polling the balance
|
balance badge registers into `sidebar.footer.action`, polling the balance
|
||||||
Remote on a 60s client-side interval. Both hide themselves when their data is
|
Remote on a 60s client-side interval. Both hide themselves when their data is
|
||||||
absent: cost hides before any priced step, balance renders an empty dashes
|
absent: cost hides before any priced step, balance renders an empty dashes
|
||||||
marker when the snapshot has no value yet.
|
marker when the snapshot has no value yet. The projection's `steps` view field
|
||||||
|
also maps every priced step to its USD cost under `${turn}:${step}` keys, and
|
||||||
|
`dsh-client-ui-trajectory` reads it from the resident projection store to show
|
||||||
|
each priced assistant step's cost on the Trajectory request boundary and in
|
||||||
|
its inspector summary. The projection store follows the session instance's
|
||||||
|
residency ([retention note](../bug-fix/2026-08-20-web-session-projection-store-survives-revival.md)),
|
||||||
|
so the readouts survive switching between chats instead of reverting to
|
||||||
|
blank.
|
||||||
|
|
||||||
**The API key is a credential reference, seeded identically to routing.**
|
**The API key is a credential reference, seeded identically to routing.**
|
||||||
`apiKeyEnv` (default `OPENROUTER_API_KEY`) resolves through the credentials
|
`apiKeyEnv` (default `OPENROUTER_API_KEY`) resolves through the credentials
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ Status: implemented
|
|||||||
|
|
||||||
**费用是面向客户端的只读模型投影,绝不是被记录或模型可见的事实。** 网关拉取 OpenRouter 的模型定价表(`GET /models`,`pricing.prompt`/`completion` USD 每 token、flat 的 `request` 费用、披露的缓存费率),并在 `openRouterCost` 的 `SessionProjectionMap` 条目中按各会话已记录的用量折叠它。余额来自 OpenRouter 权威余额端点 `GET /credits`(可用余额 = `total_credits` 减去已花费的 `total_usage`,即仪表盘展示的数字),并与 `GET /auth/key` 合并以获取 key 标签与月度 token 预算(`usage`/`limit`);标准 `sk-or-v1` key 在 `/auth/key` 上**不**返回 `credits` 字段,因此仅靠该端点总会读到空余额。两个请求都使用 `redirect: 'error'` 的 `global.fetch` —— 携带凭证的出站调用的主机标准。
|
**费用是面向客户端的只读模型投影,绝不是被记录或模型可见的事实。** 网关拉取 OpenRouter 的模型定价表(`GET /models`,`pricing.prompt`/`completion` USD 每 token、flat 的 `request` 费用、披露的缓存费率),并在 `openRouterCost` 的 `SessionProjectionMap` 条目中按各会话已记录的用量折叠它。余额来自 OpenRouter 权威余额端点 `GET /credits`(可用余额 = `total_credits` 减去已花费的 `total_usage`,即仪表盘展示的数字),并与 `GET /auth/key` 合并以获取 key 标签与月度 token 预算(`usage`/`limit`);标准 `sk-or-v1` key 在 `/auth/key` 上**不**返回 `credits` 字段,因此仅靠该端点总会读到空余额。两个请求都使用 `redirect: 'error'` 的 `global.fetch` —— 携带凭证的出站调用的主机标准。
|
||||||
|
|
||||||
**Web 表面是两个 slot 条目。**每会话费用读数注册进 `conversation.composer.dock`(随附统计行的 dock),通过 `useProjection` 框架座位读取 `openRouterCost` 投影;实时余额徽章注册进 `sidebar.footer.action`,以客户端 60s 间隔轮询余额 Remote。两者在数据缺失时自我隐藏:费用在任何已计价步骤前隐藏,余额在快照尚无值时渲染空的破折号标记。
|
**Web 表面是两个 slot 条目。**每会话费用读数注册进 `conversation.composer.dock`(随附统计行的 dock),通过 `useProjection` 框架座位读取 `openRouterCost` 投影;实时余额徽章注册进 `sidebar.footer.action`,以客户端 60s 间隔轮询余额 Remote。两者在数据缺失时自我隐藏:费用在任何已计价步骤前隐藏,余额在快照尚无值时渲染空的破折号标记。投影的 `steps` 视图字段还以 `${turn}:${step}` 为键映射每个已计价 step 的美元成本,`dsh-client-ui-trajectory` 从常驻投影存储读取它,在 Trajectory 请求边界与其检查器摘要中显示每个已计价助手 step 的费用。投影存储跟随会话实例的常驻性([保留笔记](../bug-fix/2026-08-20-web-session-projection-store-survives-revival.md)),因此读数在切换聊天时持续存在,不会回退为空白。
|
||||||
|
|
||||||
**API key 是凭证引用,与路由的初始化方式一致。**`apiKeyEnv`(默认 `OPENROUTER_API_KEY`)与 `dsh-web-search-deepseek` 完全一样,通过凭证接缝加环境回退解析,按刷新在 thunk 中捕获,使凭证或设置变更无需重建即可在下一周期生效。
|
**API key 是凭证引用,与路由的初始化方式一致。**`apiKeyEnv`(默认 `OPENROUTER_API_KEY`)与 `dsh-web-search-deepseek` 完全一样,通过凭证接缝加环境回退解析,按刷新在 thunk 中捕获,使凭证或设置变更无需重建即可在下一周期生效。
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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` 钉住反转后的政策:三个提供方各被挂载一次、各被声明一次。
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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 也带上了新的名册行。
|
||||||
84
.bench-searxng-50.mjs
Normal file
84
.bench-searxng-50.mjs
Normal file
@@ -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))
|
||||||
45
.tool-searxng.mjs
Normal file
45
.tool-searxng.mjs
Normal file
@@ -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()
|
||||||
@@ -198,12 +198,14 @@
|
|||||||
toolName: subagent_fork
|
toolName: subagent_fork
|
||||||
backgroundMode: continuable
|
backgroundMode: continuable
|
||||||
|
|
||||||
# Production dsh does not install these optional providers. An opting-in
|
# External product agents. The base host plane mounts each provider, and
|
||||||
# Profile mounts each provider once on the host plane; copy this preset,
|
# each run needs that product's own CLI on PATH plus its own account:
|
||||||
# then remove `disabled` from the matching tool row.
|
# `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
|
- id: tool-subagent-codex
|
||||||
name: '@deepseek-ai/dsh-tool-subagent'
|
name: '@deepseek-ai/dsh-tool-subagent'
|
||||||
disabled: true
|
|
||||||
config:
|
config:
|
||||||
provider: codex
|
provider: codex
|
||||||
toolName: subagent_codex
|
toolName: subagent_codex
|
||||||
@@ -212,13 +214,20 @@
|
|||||||
|
|
||||||
- id: tool-subagent-claude-code
|
- id: tool-subagent-claude-code
|
||||||
name: '@deepseek-ai/dsh-tool-subagent'
|
name: '@deepseek-ai/dsh-tool-subagent'
|
||||||
disabled: true
|
|
||||||
config:
|
config:
|
||||||
provider: claude-code
|
provider: claude-code
|
||||||
toolName: subagent_claude_code
|
toolName: subagent_claude_code
|
||||||
backgroundMode: one-shot
|
backgroundMode: one-shot
|
||||||
maxDepth: provider-managed
|
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
|
- id: workflow-worker-thread
|
||||||
name: '@deepseek-ai/dsh-workflow-worker-thread'
|
name: '@deepseek-ai/dsh-workflow-worker-thread'
|
||||||
config:
|
config:
|
||||||
|
|||||||
@@ -185,12 +185,14 @@
|
|||||||
toolName: subagent_fork
|
toolName: subagent_fork
|
||||||
backgroundMode: continuable
|
backgroundMode: continuable
|
||||||
|
|
||||||
# Production dsh does not install these optional providers. An opting-in
|
# External product agents. The base host plane mounts each provider, and
|
||||||
# Profile mounts each provider once on the host plane; copy this preset,
|
# each run needs that product's own CLI on PATH plus its own account:
|
||||||
# then remove `disabled` from the matching tool row.
|
# `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
|
- id: tool-subagent-codex
|
||||||
name: '@deepseek-ai/dsh-tool-subagent'
|
name: '@deepseek-ai/dsh-tool-subagent'
|
||||||
disabled: true
|
|
||||||
config:
|
config:
|
||||||
provider: codex
|
provider: codex
|
||||||
toolName: subagent_codex
|
toolName: subagent_codex
|
||||||
@@ -199,13 +201,20 @@
|
|||||||
|
|
||||||
- id: tool-subagent-claude-code
|
- id: tool-subagent-claude-code
|
||||||
name: '@deepseek-ai/dsh-tool-subagent'
|
name: '@deepseek-ai/dsh-tool-subagent'
|
||||||
disabled: true
|
|
||||||
config:
|
config:
|
||||||
provider: claude-code
|
provider: claude-code
|
||||||
toolName: subagent_claude_code
|
toolName: subagent_claude_code
|
||||||
backgroundMode: one-shot
|
backgroundMode: one-shot
|
||||||
maxDepth: provider-managed
|
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
|
- id: workflow-worker-thread
|
||||||
name: '@deepseek-ai/dsh-workflow-worker-thread'
|
name: '@deepseek-ai/dsh-workflow-worker-thread'
|
||||||
config:
|
config:
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ Every capability in this harness is a plugin row in a `cordis.yml`. There is no
|
|||||||
|
|
||||||
## Off-limits
|
## 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.
|
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.
|
||||||
|
|
||||||
|
|||||||
211
apps/cli/config/agent-presets/economy/agent.cordis.yml
Normal file
211
apps/cli/config/agent-presets/economy/agent.cordis.yml
Normal file
@@ -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
|
||||||
3
apps/cli/config/agent-presets/economy/preset.yml
Normal file
3
apps/cli/config/agent-presets/economy/preset.yml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
name: Экономный режим
|
||||||
|
description: Оптимизированный режим для экономии токенов и затрат на API: дешёвые операции выполняются в основном контексте, делегирование субагентам — только когда оно окупается, результаты инструментов активно сжимаются.
|
||||||
|
order: 5
|
||||||
539
apps/cli/config/agent-presets/maximum/agent.cordis.yml
Normal file
539
apps/cli/config/agent-presets/maximum/agent.cordis.yml
Normal file
@@ -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__<serverName>__<rawName>`. 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
|
||||||
3
apps/cli/config/agent-presets/maximum/preset.yml
Normal file
3
apps/cli/config/agent-presets/maximum/preset.yml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
name: 全能模式
|
||||||
|
description: 组合全部可用插件、技能与外部 Agent,并以「架构师 → 实现者 → 审查者」三角色流水线规划、实现并验证每一次改动。
|
||||||
|
order: 6
|
||||||
@@ -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.
|
||||||
@@ -197,12 +197,14 @@
|
|||||||
toolName: subagent_fork
|
toolName: subagent_fork
|
||||||
backgroundMode: continuable
|
backgroundMode: continuable
|
||||||
|
|
||||||
# Production dsh does not install these optional providers. An opting-in
|
# External product agents. The base host plane mounts each provider, and
|
||||||
# Profile mounts each provider once on the host plane; copy this preset,
|
# each run needs that product's own CLI on PATH plus its own account:
|
||||||
# then remove `disabled` from the matching tool row.
|
# `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
|
- id: tool-subagent-codex
|
||||||
name: '@deepseek-ai/dsh-tool-subagent'
|
name: '@deepseek-ai/dsh-tool-subagent'
|
||||||
disabled: true
|
|
||||||
config:
|
config:
|
||||||
provider: codex
|
provider: codex
|
||||||
toolName: subagent_codex
|
toolName: subagent_codex
|
||||||
@@ -211,13 +213,20 @@
|
|||||||
|
|
||||||
- id: tool-subagent-claude-code
|
- id: tool-subagent-claude-code
|
||||||
name: '@deepseek-ai/dsh-tool-subagent'
|
name: '@deepseek-ai/dsh-tool-subagent'
|
||||||
disabled: true
|
|
||||||
config:
|
config:
|
||||||
provider: claude-code
|
provider: claude-code
|
||||||
toolName: subagent_claude_code
|
toolName: subagent_claude_code
|
||||||
backgroundMode: one-shot
|
backgroundMode: one-shot
|
||||||
maxDepth: provider-managed
|
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
|
- id: workflow-worker-thread
|
||||||
name: '@deepseek-ai/dsh-workflow-worker-thread'
|
name: '@deepseek-ai/dsh-workflow-worker-thread'
|
||||||
config:
|
config:
|
||||||
|
|||||||
@@ -38,6 +38,8 @@
|
|||||||
"@deepseek-ai/dsh-goal-round-driver": "workspace:^",
|
"@deepseek-ai/dsh-goal-round-driver": "workspace:^",
|
||||||
"@deepseek-ai/dsh-cmdline": "workspace:^",
|
"@deepseek-ai/dsh-cmdline": "workspace:^",
|
||||||
"@deepseek-ai/dsh-launch-environment": "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-fs-local": "workspace:^",
|
||||||
"@deepseek-ai/dsh-headless": "workspace:^",
|
"@deepseek-ai/dsh-headless": "workspace:^",
|
||||||
"@deepseek-ai/dsh-mcp-client": "workspace:^",
|
"@deepseek-ai/dsh-mcp-client": "workspace:^",
|
||||||
@@ -56,6 +58,9 @@
|
|||||||
"@deepseek-ai/dsh-jobs-local": "workspace:^",
|
"@deepseek-ai/dsh-jobs-local": "workspace:^",
|
||||||
"@deepseek-ai/dsh-tmux-context": "workspace:^",
|
"@deepseek-ai/dsh-tmux-context": "workspace:^",
|
||||||
"@deepseek-ai/dsh-token-meter": "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-ask-user": "workspace:^",
|
||||||
"@deepseek-ai/dsh-tool-bash": "workspace:^",
|
"@deepseek-ai/dsh-tool-bash": "workspace:^",
|
||||||
"@deepseek-ai/dsh-tool-bash-persistent": "workspace:^",
|
"@deepseek-ai/dsh-tool-bash-persistent": "workspace:^",
|
||||||
@@ -70,7 +75,10 @@
|
|||||||
"@deepseek-ai/dsh-tool-str-replace-editor": "workspace:^",
|
"@deepseek-ai/dsh-tool-str-replace-editor": "workspace:^",
|
||||||
"@deepseek-ai/dsh-tool-subagent": "workspace:^",
|
"@deepseek-ai/dsh-tool-subagent": "workspace:^",
|
||||||
"@deepseek-ai/dsh-tool-subagent-control": "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-jobs": "workspace:^",
|
||||||
|
"@deepseek-ai/dsh-tool-lsp": "workspace:^",
|
||||||
"@deepseek-ai/dsh-tool-todo": "workspace:^",
|
"@deepseek-ai/dsh-tool-todo": "workspace:^",
|
||||||
"@deepseek-ai/dsh-tool-web": "workspace:^",
|
"@deepseek-ai/dsh-tool-web": "workspace:^",
|
||||||
"@deepseek-ai/dsh-tool-workflow": "workspace:^",
|
"@deepseek-ai/dsh-tool-workflow": "workspace:^",
|
||||||
|
|||||||
@@ -150,6 +150,21 @@ function enablePresetTool(composition: string, id: string): string {
|
|||||||
return composition.slice(0, disabled) + composition.slice(disabled + ' disabled: true\n'.length)
|
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
|
let ctx: Context
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
const settingsFile = join(await mkdtemp(join(tmpdir(), 'dsh-web-presets-')), 'settings.yaml')
|
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 () => {
|
it('supplies both shipped presets, and only those, from the system root', async () => {
|
||||||
const listed = await ctx.agentPresets.list()
|
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(listed.every(preset => preset.trust === 'system')).toBe(true)
|
||||||
expect(ctx.agentPresets.defaultId).toBe('standard')
|
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([
|
expect(toolNames(ctx, handle.agent).filter(name => name !== 'glob' && name !== 'grep')).toEqual([
|
||||||
'ask_user_question', 'bash', 'create_goal', 'edit', 'exit_plan_mode',
|
'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',
|
'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',
|
'workflow', 'write',
|
||||||
])
|
])
|
||||||
} finally {
|
} 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 () => {
|
it('composes the exact RL prompt and two tools from `minimal`', async () => {
|
||||||
const handle = await ctx.agents.create({
|
const handle = await ctx.agents.create({
|
||||||
sessionId: SessionId('preset-minimal'),
|
sessionId: SessionId('preset-minimal'),
|
||||||
@@ -436,7 +521,7 @@ describe('the shipped Web composition', () => {
|
|||||||
|
|
||||||
describe('product subagent rows in user presets', () => {
|
describe('product subagent rows in user presets', () => {
|
||||||
let productCtx: Context
|
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 () => {
|
beforeAll(async () => {
|
||||||
const root = await mkdtemp(join(tmpdir(), 'dsh-product-presets-'))
|
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 settingsFile = join(root, 'settings.yaml')
|
||||||
const standard = await readFile(join(CONFIG_DIR, 'agent-presets', 'standard', 'agent.cordis.yml'), 'utf8')
|
const standard = await readFile(join(CONFIG_DIR, 'agent-presets', 'standard', 'agent.cordis.yml'), 'utf8')
|
||||||
await writeFile(settingsFile, '{}\n')
|
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) {
|
for (const id of ids) {
|
||||||
let composition = standard
|
let composition = standard
|
||||||
if (id === 'products-codex' || id === 'products-both') {
|
if (id !== 'products-all' && id !== 'products-codex') {
|
||||||
composition = enablePresetTool(composition, 'tool-subagent-codex')
|
composition = disablePresetTool(composition, 'tool-subagent-codex')
|
||||||
}
|
}
|
||||||
if (id === 'products-claude' || id === 'products-both') {
|
if (id !== 'products-all' && id !== 'products-claude') {
|
||||||
composition = enablePresetTool(composition, 'tool-subagent-claude-code')
|
composition = disablePresetTool(composition, 'tool-subagent-claude-code')
|
||||||
|
}
|
||||||
|
if (id !== 'products-all') {
|
||||||
|
composition = disablePresetTool(composition, 'tool-subagent-cursor')
|
||||||
}
|
}
|
||||||
const directory = join(userRoot, id)
|
const directory = join(userRoot, id)
|
||||||
await mkdir(directory, { recursive: true })
|
await mkdir(directory, { recursive: true })
|
||||||
await writeFile(join(directory, 'agent.cordis.yml'), composition)
|
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, [
|
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',
|
id: 'agent-presets',
|
||||||
config: {
|
config: {
|
||||||
@@ -479,15 +567,15 @@ describe('product subagent rows in user presets', () => {
|
|||||||
await productCtx.fiber.dispose()
|
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<string, string[]>([
|
const expected = new Map<string, string[]>([
|
||||||
['products-none', []],
|
['products-none', []],
|
||||||
['products-codex', ['subagent_codex']],
|
['products-codex', ['subagent_codex']],
|
||||||
['products-claude', ['subagent_claude_code']],
|
['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([
|
expect(productCtx.subagents.list()).toEqual(expect.arrayContaining([
|
||||||
'spawn', 'fork', 'codex', 'claude-code',
|
'spawn', 'fork', 'codex', 'claude-code', 'cursor',
|
||||||
]))
|
]))
|
||||||
|
|
||||||
for (const [id, productTools] of expected) {
|
for (const [id, productTools] of expected) {
|
||||||
@@ -497,7 +585,7 @@ describe('product subagent rows in user presets', () => {
|
|||||||
})
|
})
|
||||||
try {
|
try {
|
||||||
const tools = toolNames(productCtx, handle.agent)
|
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)
|
.toEqual(productTools)
|
||||||
expect(tools).toEqual(expect.arrayContaining(['job_kill', 'job_list', 'job_output']))
|
expect(tools).toEqual(expect.arrayContaining(['job_kill', 'job_list', 'job_output']))
|
||||||
for (const productTool of productTools) {
|
for (const productTool of productTools) {
|
||||||
|
|||||||
@@ -61,6 +61,26 @@
|
|||||||
- 'button "复制: 创造模式"':
|
- 'button "复制: 创造模式"':
|
||||||
- img
|
- img
|
||||||
- text: 复制
|
- 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]
|
- heading "自定义" [level=3]
|
||||||
- list:
|
- list:
|
||||||
- listitem:
|
- listitem:
|
||||||
|
|||||||
@@ -61,6 +61,26 @@
|
|||||||
- 'button "复制: 创造模式"':
|
- 'button "复制: 创造模式"':
|
||||||
- img
|
- img
|
||||||
- text: 复制
|
- 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]
|
- heading "自定义" [level=3]
|
||||||
- list:
|
- list:
|
||||||
- listitem:
|
- listitem:
|
||||||
|
|||||||
@@ -61,6 +61,26 @@
|
|||||||
- 'button "复制: 创造模式"':
|
- 'button "复制: 创造模式"':
|
||||||
- img
|
- img
|
||||||
- text: 复制
|
- 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]
|
- heading "自定义" [level=3]
|
||||||
- button "用「创造模式」创作自定义预设":
|
- button "用「创造模式」创作自定义预设":
|
||||||
- img
|
- img
|
||||||
|
|||||||
@@ -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 "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 "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 "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."
|
||||||
|
|||||||
@@ -2,5 +2,5 @@
|
|||||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
# 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:
|
# after editing either side, bring the other along and re-record with:
|
||||||
# pnpm run verify-translation-pairing --write docs/config-catalog.md
|
# pnpm run verify-translation-pairing --write docs/config-catalog.md
|
||||||
config-catalog.md: 040deb80fe232dc61bba1b2041a541d33516973f
|
config-catalog.md: 2c450518537d9cb318efa449fabbfba82eeda1cd
|
||||||
config-catalog.zh.md: e0166072b66d4a940c5242f23974a602a5658f1a
|
config-catalog.zh.md: 365c443a9f36a45309e0196e821ecb8e5bea974d
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
<a id="deepseek-aidsh-session-query-sqlite"></a>
|
<a id="deepseek-aidsh-session-query-sqlite"></a>
|
||||||
|
|
||||||
@@ -2138,6 +2138,39 @@ export interface Config {
|
|||||||
|
|
||||||
Source: [`packages/subagent/subagent-codex/src/index.ts:30`](../packages/subagent/subagent-codex/src/index.ts)
|
Source: [`packages/subagent/subagent-codex/src/index.ts:30`](../packages/subagent/subagent-codex/src/index.ts)
|
||||||
|
|
||||||
|
<a id="deepseek-aidsh-subagent-cursor"></a>
|
||||||
|
|
||||||
|
## `@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<string, string>
|
||||||
|
/** 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)
|
||||||
|
|
||||||
<a id="deepseek-aidsh-subagent-dsh-sdk"></a>
|
<a id="deepseek-aidsh-subagent-dsh-sdk"></a>
|
||||||
|
|
||||||
## `@deepseek-ai/dsh-subagent-dsh-sdk`
|
## `@deepseek-ai/dsh-subagent-dsh-sdk`
|
||||||
|
|||||||
@@ -2140,6 +2140,39 @@ export interface Config {
|
|||||||
|
|
||||||
来源:[`packages/subagent/subagent-codex/src/index.ts:30`](../packages/subagent/subagent-codex/src/index.ts)
|
来源:[`packages/subagent/subagent-codex/src/index.ts:30`](../packages/subagent/subagent-codex/src/index.ts)
|
||||||
|
|
||||||
|
<a id="deepseek-aidsh-subagent-cursor"></a>
|
||||||
|
|
||||||
|
## `@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<string, string>
|
||||||
|
/** 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)
|
||||||
|
|
||||||
<a id="deepseek-aidsh-subagent-dsh-sdk"></a>
|
<a id="deepseek-aidsh-subagent-dsh-sdk"></a>
|
||||||
|
|
||||||
## `@deepseek-ai/dsh-subagent-dsh-sdk`
|
## `@deepseek-ai/dsh-subagent-dsh-sdk`
|
||||||
|
|||||||
@@ -2,5 +2,5 @@
|
|||||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
# 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:
|
# after editing either side, bring the other along and re-record with:
|
||||||
# pnpm run verify-translation-pairing --write docs/module-graph.md
|
# pnpm run verify-translation-pairing --write docs/module-graph.md
|
||||||
module-graph.md: 1a09c6c39dd0710b490ec23ae76b928cd6edf8d6
|
module-graph.md: 690c680fd3dc64f3313274c27bbb622fe4ab4fa6
|
||||||
module-graph.zh.md: 811ee0899068ce475df8b6b168513f60a492d56f
|
module-graph.zh.md: fbb3363d753c1c1419ad120f053c77c94cc64cc1
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ flowchart TD
|
|||||||
pkg_subagent_acp["subagent-acp"]
|
pkg_subagent_acp["subagent-acp"]
|
||||||
pkg_subagent_claude_code["subagent-claude-code"]
|
pkg_subagent_claude_code["subagent-claude-code"]
|
||||||
pkg_subagent_codex["subagent-codex"]
|
pkg_subagent_codex["subagent-codex"]
|
||||||
|
pkg_subagent_cursor["subagent-cursor"]
|
||||||
pkg_subagent_dsh_sdk["subagent-dsh-sdk"]
|
pkg_subagent_dsh_sdk["subagent-dsh-sdk"]
|
||||||
pkg_subagent_fork_in_process["subagent-fork-in-process"]
|
pkg_subagent_fork_in_process["subagent-fork-in-process"]
|
||||||
pkg_subagent_in_process_driver["subagent-in-process-driver"]
|
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_subagent
|
||||||
pkg_subagent_claude_code --> pkg_subprocess
|
pkg_subagent_claude_code --> pkg_subprocess
|
||||||
pkg_subagent_claude_code --> pkg_timeout
|
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_agent
|
||||||
pkg_subagent_in_process_driver --> pkg_invariants
|
pkg_subagent_in_process_driver --> pkg_invariants
|
||||||
pkg_subagent_in_process_driver --> pkg_llm
|
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_client_ui_primitives
|
||||||
pkg_client_ui_trajectory --> pkg_compaction
|
pkg_client_ui_trajectory --> pkg_compaction
|
||||||
pkg_client_ui_trajectory --> pkg_invariants
|
pkg_client_ui_trajectory --> pkg_invariants
|
||||||
|
pkg_client_ui_trajectory --> pkg_openrouter_usage
|
||||||
pkg_client_ui_trajectory --> pkg_tools
|
pkg_client_ui_trajectory --> pkg_tools
|
||||||
pkg_client_ui_user_questions --> pkg_api_remotes
|
pkg_client_ui_user_questions --> pkg_api_remotes
|
||||||
pkg_client_ui_user_questions --> pkg_client_locale
|
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) |
|
| [`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-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-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) |
|
| [`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`](../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) |
|
| [`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-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-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-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-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-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) |
|
| [`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) |
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ flowchart TD
|
|||||||
pkg_subagent_acp["subagent-acp"]
|
pkg_subagent_acp["subagent-acp"]
|
||||||
pkg_subagent_claude_code["subagent-claude-code"]
|
pkg_subagent_claude_code["subagent-claude-code"]
|
||||||
pkg_subagent_codex["subagent-codex"]
|
pkg_subagent_codex["subagent-codex"]
|
||||||
|
pkg_subagent_cursor["subagent-cursor"]
|
||||||
pkg_subagent_dsh_sdk["subagent-dsh-sdk"]
|
pkg_subagent_dsh_sdk["subagent-dsh-sdk"]
|
||||||
pkg_subagent_fork_in_process["subagent-fork-in-process"]
|
pkg_subagent_fork_in_process["subagent-fork-in-process"]
|
||||||
pkg_subagent_in_process_driver["subagent-in-process-driver"]
|
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_subagent
|
||||||
pkg_subagent_claude_code --> pkg_subprocess
|
pkg_subagent_claude_code --> pkg_subprocess
|
||||||
pkg_subagent_claude_code --> pkg_timeout
|
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_agent
|
||||||
pkg_subagent_in_process_driver --> pkg_invariants
|
pkg_subagent_in_process_driver --> pkg_invariants
|
||||||
pkg_subagent_in_process_driver --> pkg_llm
|
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_client_ui_primitives
|
||||||
pkg_client_ui_trajectory --> pkg_compaction
|
pkg_client_ui_trajectory --> pkg_compaction
|
||||||
pkg_client_ui_trajectory --> pkg_invariants
|
pkg_client_ui_trajectory --> pkg_invariants
|
||||||
|
pkg_client_ui_trajectory --> pkg_openrouter_usage
|
||||||
pkg_client_ui_trajectory --> pkg_tools
|
pkg_client_ui_trajectory --> pkg_tools
|
||||||
pkg_client_ui_user_questions --> pkg_api_remotes
|
pkg_client_ui_user_questions --> pkg_api_remotes
|
||||||
pkg_client_ui_user_questions --> pkg_client_locale
|
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) |
|
| [`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-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-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) |
|
| [`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`](../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) |
|
| [`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-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-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-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-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-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) |
|
| [`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) |
|
||||||
|
|||||||
@@ -2,5 +2,5 @@
|
|||||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
# 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:
|
# after editing either side, bring the other along and re-record with:
|
||||||
# pnpm run verify-translation-pairing --write docs/subsystems/llm-streaming.md
|
# pnpm run verify-translation-pairing --write docs/subsystems/llm-streaming.md
|
||||||
llm-streaming.md: 7c0e0865f8dcc0e7722bb2205d0129d9e0ca3086
|
llm-streaming.md: 6adaf86016a6bcab757c86d47c10518f41539fe7
|
||||||
llm-streaming.zh.md: 5c31909ee79137c6c5eef101235b43a2419b1339
|
llm-streaming.zh.md: 20349f426b4976904941b4069dfe51e988a8b142
|
||||||
|
|||||||
@@ -866,6 +866,22 @@ stream(options: GenerateOptions): AsyncIterable<StreamChunk>
|
|||||||
|
|
||||||
Source: [`packages/llm/llm/src/index.ts:284`](../../packages/llm/llm/src/index.ts)
|
Source: [`packages/llm/llm/src/index.ts:284`](../../packages/llm/llm/src/index.ts)
|
||||||
|
|
||||||
|
<a id="ctxopenrouterusage--openrouterusagegateway"></a>
|
||||||
|
|
||||||
|
### `ctx.openRouterUsage` — `OpenRouterUsageGateway`
|
||||||
|
|
||||||
|
OpenRouter usage + balance gateway (`ctx.openRouterUsage`).
|
||||||
|
|
||||||
|
```ts cordis-catalog
|
||||||
|
/**
|
||||||
|
* The latest known account snapshot.
|
||||||
|
* @returns a fresh copy of the cached balance.
|
||||||
|
*/
|
||||||
|
@Remote('snapshot') snapshot(): OpenRouterBalance
|
||||||
|
```
|
||||||
|
|
||||||
|
Source: [`packages/llm/openrouter-usage/src/index.ts:91`](../../packages/llm/openrouter-usage/src/index.ts)
|
||||||
|
|
||||||
<a id="llm-events"></a>
|
<a id="llm-events"></a>
|
||||||
|
|
||||||
### `llm/*` events
|
### `llm/*` events
|
||||||
|
|||||||
@@ -872,6 +872,22 @@ stream(options: GenerateOptions): AsyncIterable<StreamChunk>
|
|||||||
|
|
||||||
Source: [`packages/llm/llm/src/index.ts:284`](../../packages/llm/llm/src/index.ts)
|
Source: [`packages/llm/llm/src/index.ts:284`](../../packages/llm/llm/src/index.ts)
|
||||||
|
|
||||||
|
<a id="ctxopenrouterusage--openrouterusagegateway"></a>
|
||||||
|
|
||||||
|
### `ctx.openRouterUsage` — `OpenRouterUsageGateway`
|
||||||
|
|
||||||
|
OpenRouter usage + balance gateway (`ctx.openRouterUsage`).
|
||||||
|
|
||||||
|
```ts cordis-catalog
|
||||||
|
/**
|
||||||
|
* The latest known account snapshot.
|
||||||
|
* @returns a fresh copy of the cached balance.
|
||||||
|
*/
|
||||||
|
@Remote('snapshot') snapshot(): OpenRouterBalance
|
||||||
|
```
|
||||||
|
|
||||||
|
Source: [`packages/llm/openrouter-usage/src/index.ts:91`](../../packages/llm/openrouter-usage/src/index.ts)
|
||||||
|
|
||||||
<a id="llm-events"></a>
|
<a id="llm-events"></a>
|
||||||
|
|
||||||
### `llm/*` events
|
### `llm/*` events
|
||||||
|
|||||||
@@ -2,5 +2,5 @@
|
|||||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
# 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:
|
# after editing either side, bring the other along and re-record with:
|
||||||
# pnpm run verify-translation-pairing --write docs/subsystems/subagent.md
|
# pnpm run verify-translation-pairing --write docs/subsystems/subagent.md
|
||||||
subagent.md: a683a679e6017351540ee4b73adc74375ef0a1d6
|
subagent.md: 2e504fbeccb917328a7675e13c205fca4c54f3d0
|
||||||
subagent.zh.md: 61391cd297c0eb14f4c0d8eac4539b551cb60bda
|
subagent.zh.md: e4ed14ce53f22824740c3457e5f84045dfc92050
|
||||||
|
|||||||
@@ -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.
|
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)
|
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)
|
||||||
|
|
||||||
|
|||||||
@@ -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 执行器。
|
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)
|
源码:[`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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
18
examples/acp-agent/product-subagent-cursor.cordis.yml
Normal file
18
examples/acp-agent/product-subagent-cursor.cordis.yml
Normal file
@@ -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
|
||||||
@@ -65,6 +65,7 @@ const BACKGROUND_TASK_ADMISSION_CONFIG = fileURLToPath(
|
|||||||
new URL('../background-job-admission.cordis.yml', import.meta.url),
|
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_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 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 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')
|
const SNAPSHOTS_DIR = join(dirname(fileURLToPath(import.meta.url)), 'snapshots')
|
||||||
@@ -149,6 +150,15 @@ const SCENARIOS: Scenario[] = [
|
|||||||
headerClass: 'product-subagent-codex',
|
headerClass: 'product-subagent-codex',
|
||||||
configPath: PRODUCT_SUBAGENT_CODEX_CONFIG,
|
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',
|
name: 'product-subagent-both',
|
||||||
hasModelTurn: true,
|
hasModelTurn: true,
|
||||||
|
|||||||
40
examples/acp-agent/tests/fixtures/subagent/subagent-cursor/cordis.yml
vendored
Normal file
40
examples/acp-agent/tests/fixtures/subagent/subagent-cursor/cordis.yml
vendored
Normal file
@@ -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'
|
||||||
56
examples/acp-agent/tests/fixtures/subagent/subagent-cursor/driver.ts
vendored
Normal file
56
examples/acp-agent/tests/fixtures/subagent/subagent-cursor/driver.ts
vendored
Normal file
@@ -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()
|
||||||
|
}
|
||||||
22
examples/acp-agent/tests/fixtures/subagent/subagent-cursor/fixture.ts
vendored
Normal file
22
examples/acp-agent/tests/fixtures/subagent/subagent-cursor/fixture.ts
vendored
Normal file
@@ -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<StreamChunk> {
|
||||||
|
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())
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"steps": [
|
||||||
|
{ "op": "initialize" },
|
||||||
|
{ "op": "newSession" },
|
||||||
|
{ "op": "prompt", "text": "Reply with exactly the word: PONG. Do not use any tools." }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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"}}}
|
||||||
@@ -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"}}
|
||||||
@@ -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> 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 <value>` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise<any>` — 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<any[]>` — 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<any[]>` — 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 <json-value>`)."
|
||||||
|
},
|
||||||
|
"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": []
|
||||||
|
}
|
||||||
@@ -75,6 +75,7 @@
|
|||||||
"@deepseek-ai/dsh-subagent-acp": "workspace:*",
|
"@deepseek-ai/dsh-subagent-acp": "workspace:*",
|
||||||
"@deepseek-ai/dsh-subagent-claude-code": "workspace:*",
|
"@deepseek-ai/dsh-subagent-claude-code": "workspace:*",
|
||||||
"@deepseek-ai/dsh-subagent-codex": "workspace:*",
|
"@deepseek-ai/dsh-subagent-codex": "workspace:*",
|
||||||
|
"@deepseek-ai/dsh-subagent-cursor": "workspace:*",
|
||||||
"@deepseek-ai/dsh-subagent-dsh-sdk": "workspace:*",
|
"@deepseek-ai/dsh-subagent-dsh-sdk": "workspace:*",
|
||||||
"@deepseek-ai/dsh-subagent-fork-in-process": "workspace:*",
|
"@deepseek-ai/dsh-subagent-fork-in-process": "workspace:*",
|
||||||
"@deepseek-ai/dsh-subagent-spawn-in-process": "workspace:*",
|
"@deepseek-ai/dsh-subagent-spawn-in-process": "workspace:*",
|
||||||
|
|||||||
@@ -61,6 +61,8 @@
|
|||||||
"acp-agent/tests/fixtures/subagent/subagent-claude-code/driver.ts",
|
"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/fixture.ts",
|
||||||
"acp-agent/tests/fixtures/subagent/subagent-codex/driver.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/driver.ts",
|
||||||
"jsonrpc-agent/tests/fixtures/subagent/subagent-dsh-sdk/child-mock-llm.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",
|
"jsonrpc-agent/tests/fixtures/subagent/subagent-dsh-sdk/mock-delegating-llm.ts",
|
||||||
@@ -768,6 +770,10 @@
|
|||||||
"tests/**/*.ts"
|
"tests/**/*.ts"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"packages/subagent/subagent-cursor": {
|
||||||
|
"entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"],
|
||||||
|
"project": ["src/**/*.ts", "tests/**/*.ts"]
|
||||||
|
},
|
||||||
"packages/subagent/subagent-dsh-sdk": {
|
"packages/subagent/subagent-dsh-sdk": {
|
||||||
"entry": [
|
"entry": [
|
||||||
"tests/**/*.spec.ts",
|
"tests/**/*.spec.ts",
|
||||||
|
|||||||
@@ -302,6 +302,15 @@
|
|||||||
config:
|
config:
|
||||||
providerName: fork
|
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
|
# Continuable background children are selected per delegation tool. The
|
||||||
# separately loaded follow-up tool registers the one global `send_message`.
|
# separately loaded follow-up tool registers the one global `send_message`.
|
||||||
- id: tool-subagent-control
|
- id: tool-subagent-control
|
||||||
|
|||||||
@@ -64,6 +64,8 @@
|
|||||||
"@deepseek-ai/dsh-llm-deepseek": "workspace:^",
|
"@deepseek-ai/dsh-llm-deepseek": "workspace:^",
|
||||||
"@deepseek-ai/dsh-llm-pi-ai": "workspace:^",
|
"@deepseek-ai/dsh-llm-pi-ai": "workspace:^",
|
||||||
"@deepseek-ai/dsh-llm-retry": "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-permission-presets": "workspace:^",
|
||||||
"@deepseek-ai/dsh-plan-mode": "workspace:^",
|
"@deepseek-ai/dsh-plan-mode": "workspace:^",
|
||||||
"@deepseek-ai/dsh-pwsh-sandbox": "workspace:^",
|
"@deepseek-ai/dsh-pwsh-sandbox": "workspace:^",
|
||||||
@@ -85,6 +87,9 @@
|
|||||||
"@deepseek-ai/dsh-spill-local": "workspace:^",
|
"@deepseek-ai/dsh-spill-local": "workspace:^",
|
||||||
"@deepseek-ai/dsh-spill-policy": "workspace:^",
|
"@deepseek-ai/dsh-spill-policy": "workspace:^",
|
||||||
"@deepseek-ai/dsh-subagent": "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-fork-in-process": "workspace:^",
|
||||||
"@deepseek-ai/dsh-subagent-spawn-in-process": "workspace:^",
|
"@deepseek-ai/dsh-subagent-spawn-in-process": "workspace:^",
|
||||||
"@deepseek-ai/dsh-subprocess-local": "workspace:^",
|
"@deepseek-ai/dsh-subprocess-local": "workspace:^",
|
||||||
@@ -104,6 +109,7 @@
|
|||||||
"@deepseek-ai/dsh-tool-subagent-control": "workspace:^",
|
"@deepseek-ai/dsh-tool-subagent-control": "workspace:^",
|
||||||
"@deepseek-ai/dsh-tool-subagent-report": "workspace:^",
|
"@deepseek-ai/dsh-tool-subagent-report": "workspace:^",
|
||||||
"@deepseek-ai/dsh-tool-jobs": "workspace:^",
|
"@deepseek-ai/dsh-tool-jobs": "workspace:^",
|
||||||
|
"@deepseek-ai/dsh-tool-lsp": "workspace:^",
|
||||||
"@deepseek-ai/dsh-tool-todo": "workspace:^",
|
"@deepseek-ai/dsh-tool-todo": "workspace:^",
|
||||||
"@deepseek-ai/dsh-tool-web": "workspace:^",
|
"@deepseek-ai/dsh-tool-web": "workspace:^",
|
||||||
"@deepseek-ai/dsh-tool-workflow": "workspace:^",
|
"@deepseek-ai/dsh-tool-workflow": "workspace:^",
|
||||||
|
|||||||
@@ -35,10 +35,18 @@ describe('dsh-base bundle', () => {
|
|||||||
expect(rows.find(row => row.id === 'session-telemetry-otel')?.config?.['mode']).toEqual({
|
expect(rows.find(row => row.id === 'session-telemetry-otel')?.config?.['mode']).toEqual({
|
||||||
__jsExpr: "process.env.DSH_TELEMETRY_MODE || 'DISABLED'",
|
__jsExpr: "process.env.DSH_TELEMETRY_MODE || 'DISABLED'",
|
||||||
})
|
})
|
||||||
expect(rows.filter(row => row.id === 'subagent-codex')).toHaveLength(0)
|
// The base layer mounts each external product agent exactly once, so a
|
||||||
expect(rows.filter(row => row.id === 'subagent-claude-code')).toHaveLength(0)
|
// Profile enables one by removing `disabled` from its Agent Preset tool
|
||||||
expect(manifest.dependencies).not.toHaveProperty('@deepseek-ai/dsh-subagent-codex')
|
// row rather than mounting a duplicate provider. Loading a provider starts
|
||||||
expect(manifest.dependencies).not.toHaveProperty('@deepseek-ai/dsh-subagent-claude-code')
|
// 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', () => {
|
it('gates each shell stack by platform with a symmetric disabled expression', () => {
|
||||||
|
|||||||
@@ -476,6 +476,11 @@ export class SessionManager {
|
|||||||
if (session === undefined) continue
|
if (session === undefined) continue
|
||||||
session.handleBlank(s.blank)
|
session.handleBlank(s.blank)
|
||||||
session.handleRunning(s.running)
|
session.handleRunning(s.running)
|
||||||
|
// A durable session the host disposed earlier being re-listed is
|
||||||
|
// alive again: lift the removed tombstone so the surviving
|
||||||
|
// instance renders normally (mutation replay already excluded a
|
||||||
|
// genuinely-removed session from this.summaries).
|
||||||
|
session.handleRevived()
|
||||||
}
|
}
|
||||||
// Seed each row's projection baseline into the per-session value
|
// Seed each row's projection baseline into the per-session value
|
||||||
// store (cold titles surface without opening the session). Per-key
|
// store (cold titles surface without opening the session). Per-key
|
||||||
@@ -804,6 +809,10 @@ export class SessionManager {
|
|||||||
...(frame.agentPreset !== undefined ? { agentPreset: frame.agentPreset } : {}),
|
...(frame.agentPreset !== undefined ? { agentPreset: frame.agentPreset } : {}),
|
||||||
})
|
})
|
||||||
this.sessions.get(frame.sessionId)?.handleBlank(frame.blank)
|
this.sessions.get(frame.sessionId)?.handleBlank(frame.blank)
|
||||||
|
// A re-emitted id (host re-engaging a previously disposed session)
|
||||||
|
// revives the surviving instance: the removed tombstone must not keep
|
||||||
|
// the conversation (and its projection readouts) in the removed state.
|
||||||
|
this.sessions.get(frame.sessionId)?.handleRevived()
|
||||||
if (frame.origin === 'subagent' && frame.parentSessionId !== undefined) {
|
if (frame.origin === 'subagent' && frame.parentSessionId !== undefined) {
|
||||||
this.markCatalogParentExpandable(frame.parentSessionId)
|
this.markCatalogParentExpandable(frame.parentSessionId)
|
||||||
}
|
}
|
||||||
@@ -834,7 +843,12 @@ export class SessionManager {
|
|||||||
// no relative order. Clearing here makes a detached Activation's rows
|
// no relative order. Clearing here makes a detached Activation's rows
|
||||||
// disappear whichever arrives first.
|
// disappear whichever arrives first.
|
||||||
this.jobsBySession.delete(frame.sessionId)
|
this.jobsBySession.delete(frame.sessionId)
|
||||||
if (!durableSubagent) this.projectionStores.delete(frame.sessionId)
|
// The projection store is retained: the Session instance survives
|
||||||
|
// removal (resident-instance rule — handleRemoved above only flags the
|
||||||
|
// snapshot), and a session the host re-lists resumes its projections
|
||||||
|
// through the same instance. Deleting the store here would leave that
|
||||||
|
// surviving instance with an empty projection face until a fresh
|
||||||
|
// baseline lands — and open() is idempotent, so no re-open re-pulls it.
|
||||||
// A pull already in flight was requested before this removal and can
|
// A pull already in flight was requested before this removal and can
|
||||||
// carry the pre-removal parentAvailable:true, which would resurrect
|
// carry the pre-removal parentAvailable:true, which would resurrect
|
||||||
// the writable editor this invalidation just closed. Replay false over
|
// the writable editor this invalidation just closed. Replay false over
|
||||||
|
|||||||
@@ -577,6 +577,18 @@ export class Session implements SessionFace {
|
|||||||
this.notifier.markDirty()
|
this.notifier.markDirty()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* host/session-added or host re-listing relay: the session is alive again,
|
||||||
|
* so lift the removed tombstone. The instance survives removal, and a
|
||||||
|
* removed-then-re-listed session must render its conversation again (the
|
||||||
|
* composer and its projection readouts) rather than the removed state.
|
||||||
|
*/
|
||||||
|
handleRevived(): void {
|
||||||
|
if (!this.removed) return
|
||||||
|
this.removed = false
|
||||||
|
this.notifier.markDirty()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* host/agent-error relay: the only outlet for live failures with no turn position.
|
* host/agent-error relay: the only outlet for live failures with no turn position.
|
||||||
* @param message - the stringified error.
|
* @param message - the stringified error.
|
||||||
|
|||||||
@@ -190,7 +190,7 @@ describe('list lifecycle', () => {
|
|||||||
expect(manager.getListSnapshot().items.map(i => i.sessionId)).toEqual([S2])
|
expect(manager.getListSnapshot().items.map(i => i.sessionId)).toEqual([S2])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('retains title projections before list arrival, keeps last-wins by seq, and clears them on removal', async () => {
|
it('retains title projections before list arrival, keeps last-wins by seq, and survives removal', async () => {
|
||||||
const api = new FakeApiClient()
|
const api = new FakeApiClient()
|
||||||
const manager = new SessionManager(api, fakeRemote())
|
const manager = new SessionManager(api, fakeRemote())
|
||||||
const titleFrame = (rpcId: string, title: string, seq: number) => {
|
const titleFrame = (rpcId: string, title: string, seq: number) => {
|
||||||
@@ -212,9 +212,13 @@ describe('list lifecycle', () => {
|
|||||||
expect(titled.items[0]?.title).toBe('Newest')
|
expect(titled.items[0]?.title).toBe('Newest')
|
||||||
expect(titled.items[1]?.title).toBeUndefined()
|
expect(titled.items[1]?.title).toBeUndefined()
|
||||||
|
|
||||||
|
// A removed-then-re-added session is the same durable lifecycle under the
|
||||||
|
// resident-instance rule: its projection face (here the title) survives
|
||||||
|
// the round-trip, so the row resurfacing shows the retained value rather
|
||||||
|
// than re-seeding from an empty store.
|
||||||
manager.handleHostEnvelope({ rpcId: 'removed' as never, payload: { type: 'host/session-removed', sessionId: S1 } })
|
manager.handleHostEnvelope({ rpcId: 'removed' as never, payload: { type: 'host/session-removed', sessionId: S1 } })
|
||||||
manager.handleHostEnvelope({ rpcId: 'readded' as never, payload: { type: 'host/session-added', blank: true, sessionId: S1 } })
|
manager.handleHostEnvelope({ rpcId: 'readded' as never, payload: { type: 'host/session-added', blank: true, sessionId: S1 } })
|
||||||
expect(manager.getListSnapshot().items.find(item => item.sessionId === S1)?.title).toBeUndefined()
|
expect(manager.getListSnapshot().items.find(item => item.sessionId === S1)?.title).toBe('Newest')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('seeds cold titles from the list rows\' projections block under higher-seq-wins', async () => {
|
it('seeds cold titles from the list rows\' projections block under higher-seq-wins', async () => {
|
||||||
|
|||||||
@@ -209,7 +209,7 @@ describe('manager frame routing', () => {
|
|||||||
expect(manager.getListSnapshot().items[0]?.projectionValues).not.toBe(baseline)
|
expect(manager.getListSnapshot().items[0]?.projectionValues).not.toBe(baseline)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('drops the projection store with the removed session', async () => {
|
it('retains the projection store with the removed session (the instance survives removal)', async () => {
|
||||||
const api = new FakeApiClient()
|
const api = new FakeApiClient()
|
||||||
const manager = new SessionManager(api, fakeRemote())
|
const manager = new SessionManager(api, fakeRemote())
|
||||||
api.onList = () => Promise.resolve(ok({
|
api.onList = () => Promise.resolve(ok({
|
||||||
@@ -224,6 +224,9 @@ describe('manager frame routing', () => {
|
|||||||
rpcId: 'rm' as never,
|
rpcId: 'rm' as never,
|
||||||
payload: { type: 'host/session-removed', sessionId: sid('s1') } as never,
|
payload: { type: 'host/session-removed', sessionId: sid('s1') } as never,
|
||||||
})
|
})
|
||||||
expect(manager.get(sid('s1')).projections.get('title')).toBeUndefined()
|
// The Session instance survives removal (resident-instance rule), so its
|
||||||
|
// projection face survives with it: a re-listed session resumes its
|
||||||
|
// values instead of re-seeding from an empty face.
|
||||||
|
expect(manager.get(sid('s1')).projections.get('title')).toBe('Doomed')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
147
packages/client/runtime/tests/switch-retention.client.spec.ts
Normal file
147
packages/client/runtime/tests/switch-retention.client.spec.ts
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
/**
|
||||||
|
* Repro + behavioral pin for the chat-switch projection retention contract:
|
||||||
|
* a session's projection store (openRouterCost is the canonical example) must
|
||||||
|
* survive a selection change and remain readable through the same value face
|
||||||
|
* when the session becomes current again.
|
||||||
|
*
|
||||||
|
* Server frames exercised:
|
||||||
|
* - session.list rows carrying a projections block (the cold seed path)
|
||||||
|
* - session/projection push frames to a live session
|
||||||
|
* - host/session-removed (transient host teardown) + host/session-added
|
||||||
|
* - session/subscribed (durable-replay baseline) + a re-pulled history tail
|
||||||
|
*
|
||||||
|
* The manager's per-session ProjectionValueStore is retained across select();
|
||||||
|
* this suite pins the retention contract so a regression in the
|
||||||
|
* retention/seeding discipline shows up as a red test here.
|
||||||
|
*/
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client'
|
||||||
|
import { SessionManager } from '../src/client/sessions/manager.ts'
|
||||||
|
import { FakeApiClient, fakeRemote, ok } from './fake-api.client.ts'
|
||||||
|
|
||||||
|
const S1 = 'fk-s1' as SessionId
|
||||||
|
const S2 = 'fk-s2' as SessionId
|
||||||
|
|
||||||
|
const COST = { totalUsd: 1.23, pricedSteps: 1, unknownModelSteps: 0, currency: 'USD' as const }
|
||||||
|
|
||||||
|
function summary(sessionId: SessionId, projections?: { asOfSeq: number; values: Record<string, unknown> }) {
|
||||||
|
return {
|
||||||
|
sessionId,
|
||||||
|
updatedAt: 100,
|
||||||
|
running: false,
|
||||||
|
blank: false,
|
||||||
|
...(projections === undefined ? {} : { projections }),
|
||||||
|
} as never
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('projection retention across chat switches (repro)', () => {
|
||||||
|
it('retains the projection store for a session that was opened, seeded, and switched away from and back', async () => {
|
||||||
|
const api = new FakeApiClient()
|
||||||
|
api.onList = () => Promise.resolve(ok({ items: [summary(S1), summary(S2)] }))
|
||||||
|
const manager = new SessionManager(api, fakeRemote())
|
||||||
|
await manager.refreshList()
|
||||||
|
|
||||||
|
// A live push frame lands the cost in S1's resident store.
|
||||||
|
manager.handleMuxEnvelope({
|
||||||
|
rpcId: 'seed' as never,
|
||||||
|
payload: { type: 'session/projection', sessionId: S1, key: 'openRouterCost', value: COST, seq: 5 } as never,
|
||||||
|
})
|
||||||
|
expect(manager.getListSnapshot().items[0]?.projectionValues?.openRouterCost).toEqual(COST)
|
||||||
|
|
||||||
|
// The conversation switches to another chat and back; nothing removes S1.
|
||||||
|
manager.select(S2)
|
||||||
|
manager.select(S1)
|
||||||
|
expect(manager.getListSnapshot().items[0]?.projectionValues?.openRouterCost).toEqual(COST)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('a host/session-removed disposal followed by a re-add re-seeds the cost from the list row block', async () => {
|
||||||
|
const api = new FakeApiClient()
|
||||||
|
api.onList = () => Promise.resolve(ok({
|
||||||
|
items: [
|
||||||
|
summary(S1, { asOfSeq: 6, values: { openRouterCost: COST } }),
|
||||||
|
summary(S2),
|
||||||
|
],
|
||||||
|
}))
|
||||||
|
const manager = new SessionManager(api, fakeRemote())
|
||||||
|
await manager.refreshList()
|
||||||
|
expect(manager.getListSnapshot().items.find(item => item.sessionId === S1)?.projectionValues?.openRouterCost)
|
||||||
|
.toEqual(COST)
|
||||||
|
|
||||||
|
// Transient host teardown: the chat leaves the list; its projection store
|
||||||
|
// is retained (the Session instance survives removal), and the durable
|
||||||
|
// chat still exists host-side, so a list refresh re-adds it.
|
||||||
|
manager.handleHostEnvelope({
|
||||||
|
rpcId: 'rm' as never,
|
||||||
|
payload: { type: 'host/session-removed', sessionId: S1 } as never,
|
||||||
|
})
|
||||||
|
expect(manager.getListSnapshot().items.find(item => item.sessionId === S1)).toBeUndefined()
|
||||||
|
|
||||||
|
await manager.refreshList()
|
||||||
|
expect(manager.getListSnapshot().items.find(item => item.sessionId === S1)?.projectionValues?.openRouterCost)
|
||||||
|
.toEqual(COST)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('lifts the removed tombstone when a disposed session is re-listed by the host', async () => {
|
||||||
|
const api = new FakeApiClient()
|
||||||
|
api.onList = () => Promise.resolve(ok({ items: [summary(S1), summary(S2)] }))
|
||||||
|
const manager = new SessionManager(api, fakeRemote())
|
||||||
|
await manager.refreshList()
|
||||||
|
|
||||||
|
const session = manager.get(S1)
|
||||||
|
manager.handleHostEnvelope({
|
||||||
|
rpcId: 'rm' as never,
|
||||||
|
payload: { type: 'host/session-removed', sessionId: S1 } as never,
|
||||||
|
})
|
||||||
|
expect(session.getSnapshot().removed).toBe(true)
|
||||||
|
// The same instance survives (resident-instance rule).
|
||||||
|
expect(manager.get(S1)).toBe(session)
|
||||||
|
|
||||||
|
// The durable chat comes back: the re-listing revives the surviving
|
||||||
|
// instance rather than leaving the conversation tombstoned forever.
|
||||||
|
manager.handleHostEnvelope({
|
||||||
|
rpcId: 'add' as never,
|
||||||
|
payload: { type: 'host/session-added', blank: false, sessionId: S1 } as never,
|
||||||
|
})
|
||||||
|
expect(session.getSnapshot().removed).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('revives through a list refresh when the host re-lists a previously disposed session', async () => {
|
||||||
|
const api = new FakeApiClient()
|
||||||
|
const listed = [summary(S1), summary(S2)]
|
||||||
|
api.onList = () => Promise.resolve(ok({ items: listed }))
|
||||||
|
const manager = new SessionManager(api, fakeRemote())
|
||||||
|
await manager.refreshList()
|
||||||
|
|
||||||
|
const session = manager.get(S1)
|
||||||
|
manager.handleHostEnvelope({
|
||||||
|
rpcId: 'rm' as never,
|
||||||
|
payload: { type: 'host/session-removed', sessionId: S1 } as never,
|
||||||
|
})
|
||||||
|
expect(session.getSnapshot().removed).toBe(true)
|
||||||
|
|
||||||
|
// The host never re-emits host/session-added for an existing id; the
|
||||||
|
// durable session simply appears again on the next list pull.
|
||||||
|
await manager.refreshList()
|
||||||
|
expect(session.getSnapshot().removed).toBe(false)
|
||||||
|
expect(manager.getListSnapshot().items.find(item => item.sessionId === S1)).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('survives a subscribed durable-replay baseline when the value is at or before the baseline', async () => {
|
||||||
|
const api = new FakeApiClient()
|
||||||
|
api.onList = () => Promise.resolve(ok({ items: [summary(S1)] }))
|
||||||
|
const manager = new SessionManager(api, fakeRemote())
|
||||||
|
await manager.refreshList()
|
||||||
|
|
||||||
|
manager.handleMuxEnvelope({
|
||||||
|
rpcId: 'push' as never,
|
||||||
|
payload: { type: 'session/projection', sessionId: S1, key: 'openRouterCost', value: COST, seq: 3 } as never,
|
||||||
|
})
|
||||||
|
|
||||||
|
// The durable baseline is at seq 3, so the row is not phantom: it survives.
|
||||||
|
manager.handleMuxEnvelope({
|
||||||
|
rpcId: 'sub' as never,
|
||||||
|
payload: { type: 'session/subscribed', sessionId: S1, lastSeq: 3 } as never,
|
||||||
|
})
|
||||||
|
expect(manager.getListSnapshot().items[0]?.projectionValues?.openRouterCost).toEqual(COST)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -8,6 +8,7 @@ export type AgentPresetSettingsKey =
|
|||||||
| 'presetCodeName' | 'presetCodeDescription'
|
| 'presetCodeName' | 'presetCodeDescription'
|
||||||
| 'presetMinimalName' | 'presetMinimalDescription'
|
| 'presetMinimalName' | 'presetMinimalDescription'
|
||||||
| 'presetCordisName' | 'presetCordisDescription'
|
| 'presetCordisName' | 'presetCordisDescription'
|
||||||
|
| 'presetMaximumName' | 'presetMaximumDescription'
|
||||||
| 'duplicate' | 'duplicateUnavailable' | 'delete' | 'presetId' | 'presetIdPlaceholder' | 'copyOf'
|
| 'duplicate' | 'duplicateUnavailable' | 'delete' | 'presetId' | 'presetIdPlaceholder' | 'copyOf'
|
||||||
| 'displayName' | 'displayNamePlaceholder'
|
| 'displayName' | 'displayNamePlaceholder'
|
||||||
| 'inUse' | 'noDescription' | 'builtInGroup' | 'customGroup'
|
| 'inUse' | 'noDescription' | 'builtInGroup' | 'customGroup'
|
||||||
@@ -46,6 +47,9 @@ export const en: Record<AgentPresetSettingsKey, string> = {
|
|||||||
presetCordisName: 'Creator mode',
|
presetCordisName: 'Creator mode',
|
||||||
presetCordisDescription:
|
presetCordisDescription:
|
||||||
'Built for creating custom agent presets, with all Standard mode capabilities plus runtime inspection, plugin experiments, and preset-authoring guidance.',
|
'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',
|
duplicate: 'Duplicate',
|
||||||
duplicateUnavailable: 'This deployment has no writable preset directory',
|
duplicateUnavailable: 'This deployment has no writable preset directory',
|
||||||
delete: 'Delete',
|
delete: 'Delete',
|
||||||
@@ -106,6 +110,8 @@ export const zh: Record<AgentPresetSettingsKey, string> = {
|
|||||||
presetMinimalDescription: '仅提供持久 bash 与 str_replace_editor 的双工具编码 Agent。',
|
presetMinimalDescription: '仅提供持久 bash 与 str_replace_editor 的双工具编码 Agent。',
|
||||||
presetCordisName: '创造模式',
|
presetCordisName: '创造模式',
|
||||||
presetCordisDescription: '用于创建自定义 Agent preset:具备标准模式的全部能力,并提供运行时检查、插件实验和 preset 创作指导。',
|
presetCordisDescription: '用于创建自定义 Agent preset:具备标准模式的全部能力,并提供运行时检查、插件实验和 preset 创作指导。',
|
||||||
|
presetMaximumName: '全能模式',
|
||||||
|
presetMaximumDescription: '组合本部署能提供的全部能力 —— 终端、语言服务器、会话历史、MCP、外部 Agent,以及与原生工具并存的 Code Mode —— 并由架构师、实现者、审查者三个角色接力交付每一次改动。',
|
||||||
duplicate: '复制',
|
duplicate: '复制',
|
||||||
duplicateUnavailable: '此部署未配置可写的预设目录',
|
duplicateUnavailable: '此部署未配置可写的预设目录',
|
||||||
delete: '删除',
|
delete: '删除',
|
||||||
@@ -171,6 +177,7 @@ const BUILT_IN_PRESET_KEYS: Readonly<Partial<Record<string, PresetLocaleKeys>>>
|
|||||||
code: { name: 'presetCodeName', description: 'presetCodeDescription' },
|
code: { name: 'presetCodeName', description: 'presetCodeDescription' },
|
||||||
minimal: { name: 'presetMinimalName', description: 'presetMinimalDescription' },
|
minimal: { name: 'presetMinimalName', description: 'presetMinimalDescription' },
|
||||||
cordis: { name: 'presetCordisName', description: 'presetCordisDescription' },
|
cordis: { name: 'presetCordisName', description: 'presetCordisDescription' },
|
||||||
|
maximum: { name: 'presetMaximumName', description: 'presetMaximumDescription' },
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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`
|
count surfaces in a tooltip so the figure's coverage stays visible. `CostDock`
|
||||||
therefore owns no store, refresh chain, or event listener.
|
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
|
`BalanceBadge` is account-global, so it is not a session projection: the slot
|
||||||
inject face carries a `snapshot()` callback that polls the host
|
inject face carries a `snapshot()` callback that polls the host
|
||||||
`ctx.remote.openRouterUsage.snapshot()` Remote on a 60s interval and drives
|
`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
|
local state. Clicking the badge calls the `refresh()` callback instead, which
|
||||||
wide column a labeled pill. Unknown or absent figures render the empty
|
makes the host fetch the account immediately; the badge marks itself
|
||||||
placeholder. Both entries render nothing before real data exists, so an
|
`aria-busy` for the duration and ignores further clicks until it settles, and
|
||||||
assembly without the OpenRouter gateway or key costs no layout.
|
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
|
The `/client` exports are the plugin body (`apply`/`inject`), the
|
||||||
`CostDock`/`BalanceBadge` components, and the injected face types.
|
`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
|
projection holds; figures for already-folded history reflect the pricing
|
||||||
table at fold time (see `dsh-openrouter-usage`).
|
table at fold time (see `dsh-openrouter-usage`).
|
||||||
- **Polled, not pushed** — `BalanceBadge` polls the host snapshot; there is
|
- **Polled, not pushed** — `BalanceBadge` polls the host snapshot; there is
|
||||||
no `openRouterUsage` forwarded-event channel, so the figure updates on the
|
no `openRouterUsage` forwarded-event channel, so an account change the user
|
||||||
poll interval rather than instantly.
|
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.
|
||||||
|
|||||||
@@ -14,7 +14,12 @@
|
|||||||
color: var(--dsw-alias-label-tertiary);
|
color: var(--dsw-alias-label-tertiary);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
line-height: 28px;
|
line-height: 28px;
|
||||||
cursor: default;
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge[aria-busy='true'] {
|
||||||
|
cursor: progress;
|
||||||
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge:hover {
|
.badge:hover {
|
||||||
|
|||||||
@@ -2,10 +2,11 @@
|
|||||||
// plus the latest snapshot's USD figure in both widths (rail = marker-only,
|
// 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
|
// 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
|
// projection: an injected `snapshot` callback polls the host Remote gateway on
|
||||||
// an interval and drives local state. `wide` arrives from the sidebar's owner
|
// an interval, a click runs `refresh` for an on-demand read, and both drive
|
||||||
// share; `snapshot` from the register's inject face.
|
// 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 { OpenRouterBalance } from '@deepseek-ai/dsh-openrouter-usage/client'
|
||||||
import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||||
import { formatUsd } from './money.ts'
|
import { formatUsd } from './money.ts'
|
||||||
@@ -14,25 +15,34 @@ import css from './BalanceBadge.module.css'
|
|||||||
/** Poll interval for the balance figure, ms. */
|
/** Poll interval for the balance figure, ms. */
|
||||||
export const BALANCE_POLL_MS = 60_000
|
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 {
|
export interface BalanceBadgeActions {
|
||||||
/** Fetch the latest account snapshot from the host gateway. */
|
/** Read the host gateway's cached account snapshot. */
|
||||||
snapshot: () => Promise<OpenRouterBalance>
|
snapshot: () => Promise<OpenRouterBalance>
|
||||||
|
/** Make the host fetch the account NOW and serve the result. */
|
||||||
|
refresh: () => Promise<OpenRouterBalance>
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 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 =
|
export type BalanceBadgeProps =
|
||||||
PropsRuntime<'sidebar.footer.action'> & BalanceBadgeActions & PropsLocale<'openRouterUsage'>
|
PropsRuntime<'sidebar.footer.action'> & BalanceBadgeActions & PropsLocale<'openRouterUsage'>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Poll `snapshot` on an interval and render the account balance. Unknown or
|
* Poll `snapshot` on an interval, re-read through `refresh` when the user
|
||||||
* absent figures render the empty placeholder so the seat costs no confusion.
|
* clicks, and render the account balance. Unknown or absent figures render the
|
||||||
* A transient failure keeps the last-known figure and retries next tick.
|
* empty placeholder so the seat costs no confusion. A transient failure keeps
|
||||||
* @param props - owner state plus the injected snapshot callback.
|
* 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.
|
* @returns the balance badge.
|
||||||
*/
|
*/
|
||||||
export function BalanceBadge({ wide, snapshot, t }: BalanceBadgeProps) {
|
export function BalanceBadge({ wide, snapshot, refresh, t }: BalanceBadgeProps) {
|
||||||
const [balance, setBalance] = useState<OpenRouterBalance | undefined>(undefined)
|
const [balance, setBalance] = useState<OpenRouterBalance | undefined>(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(() => {
|
useEffect(() => {
|
||||||
let disposed = false
|
let disposed = false
|
||||||
@@ -53,14 +63,38 @@ export function BalanceBadge({ wide, snapshot, t }: BalanceBadgeProps) {
|
|||||||
}
|
}
|
||||||
}, [snapshot])
|
}, [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 usd = balance?.balanceUsd ?? null
|
||||||
const amount = usd === null
|
const amount = usd === null
|
||||||
? t('balance.empty')
|
? t('balance.empty')
|
||||||
: formatUsd(usd)
|
: formatUsd(usd)
|
||||||
const label = t('balance.label', { amount })
|
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 (
|
return (
|
||||||
<button type="button" className={css.badge} title={tooltip} aria-label={label}>
|
<button
|
||||||
|
type="button"
|
||||||
|
className={css.badge}
|
||||||
|
title={tooltip}
|
||||||
|
aria-label={label}
|
||||||
|
aria-busy={refreshing}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
<span className={css.marker} aria-hidden>◈</span>
|
<span className={css.marker} aria-hidden>◈</span>
|
||||||
{wide && <span className={css.text}>{label}</span>}
|
{wide && <span className={css.text}>{label}</span>}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -2,9 +2,15 @@
|
|||||||
// Reads the durable openRouterCost projection, so paging and compaction
|
// Reads the durable openRouterCost projection, so paging and compaction
|
||||||
// cannot change the figure; the entry renders nothing until at least one
|
// 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).
|
// 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 { 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.
|
// Type-only: merges openRouterCost into SessionProjectionMap for useProjection.
|
||||||
import type {} from '@deepseek-ai/dsh-openrouter-usage/client'
|
import type {} from '@deepseek-ai/dsh-openrouter-usage/client'
|
||||||
import type { OpenRouterCost } 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 =
|
export type CostDockProps =
|
||||||
PropsRuntime<'conversation.composer.dock'> & PropsLocale<'openRouterUsage'>
|
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
|
* Render the running session cost as `会话费用 $X.XX`, shown only when the
|
||||||
* session actually priced steps. An unknown-pricing step count feeds a
|
* session or any of its subagents actually priced steps. An unknown-pricing
|
||||||
* tooltip so the figure's coverage stays visible without polluting the row.
|
* step count feeds a tooltip so the figure's coverage stays visible without
|
||||||
|
* polluting the row.
|
||||||
* @param props - framework/runtime props.
|
* @param props - framework/runtime props.
|
||||||
* @returns the cost group, or null on a zero/absent projection.
|
* @returns the cost group, or null on a zero/absent projection.
|
||||||
*/
|
*/
|
||||||
export const CostDock = memo(function CostDock({ useProjection, t }: CostDockProps) {
|
export const CostDock = memo(function CostDock({ sessionId, useProjection, useSessions, t }: CostDockProps) {
|
||||||
const cost = useProjection('openRouterCost') as OpenRouterCost | undefined
|
const cost: OpenRouterCost | undefined = useProjection('openRouterCost')
|
||||||
if (cost === undefined || cost.pricedSteps === 0) return null
|
const sessionById = useSessions(s => s.byId)
|
||||||
const label = t('cost.label', { amount: formatUsd(cost.totalUsd) })
|
|
||||||
|
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>([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 (
|
return (
|
||||||
<span
|
<span
|
||||||
className={css.cost}
|
className={css.cost}
|
||||||
title={cost.unknownModelSteps > 0
|
title={unknownModelSteps > 0
|
||||||
? t('cost.tooltipUnknown', { count: String(cost.unknownModelSteps) })
|
? t('cost.tooltipUnknown', { count: String(unknownModelSteps) })
|
||||||
: undefined}
|
: undefined}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
|
|||||||
@@ -63,6 +63,14 @@ export function apply(ctx: ClientContext): void {
|
|||||||
return result.value
|
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
|
// Account balance is account-global (root scope): the injected face carries
|
||||||
// the snapshot read the badge polls. The seat is declared but unhoused by
|
// the snapshot read the badge polls. The seat is declared but unhoused by
|
||||||
// default; this registration names it, so an assembly without ui-sidebar
|
// default; this registration names it, so an assembly without ui-sidebar
|
||||||
@@ -72,6 +80,6 @@ export function apply(ctx: ClientContext): void {
|
|||||||
id: 'openrouter-balance',
|
id: 'openrouter-balance',
|
||||||
order: 10,
|
order: 10,
|
||||||
locale: NS,
|
locale: NS,
|
||||||
inject: (): BalanceBadgeActions => ({ snapshot }),
|
inject: (): BalanceBadgeActions => ({ snapshot, refresh }),
|
||||||
}, BalanceBadge))
|
}, BalanceBadge))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,12 @@
|
|||||||
/** Simplified Chinese dictionary (the key-set source of truth). */
|
/** Simplified Chinese dictionary (the key-set source of truth). */
|
||||||
export const zh = {
|
export const zh = {
|
||||||
'cost.label': '会话费用 {amount}',
|
'cost.label': '会话费用 {amount}',
|
||||||
|
'cost.labelWithSubagents': '会话费用 {amount}(含子代理 {subagentAmount})',
|
||||||
'cost.tooltipUnknown': '包含 {count} 个未知定价步骤',
|
'cost.tooltipUnknown': '包含 {count} 个未知定价步骤',
|
||||||
'balance.label': '余额 {amount}',
|
'balance.label': '余额 {amount}',
|
||||||
'balance.tooltip': 'OpenRouter 账户余额({label})',
|
'balance.tooltip': 'OpenRouter 账户余额({label})— 点击刷新',
|
||||||
'balance.empty': '——',
|
'balance.empty': '——',
|
||||||
|
'balance.refreshing': '正在刷新 OpenRouter 账户余额',
|
||||||
} satisfies Record<string, string>
|
} satisfies Record<string, string>
|
||||||
|
|
||||||
/** The openrouterUsage namespace key union. */
|
/** The openrouterUsage namespace key union. */
|
||||||
@@ -15,8 +17,10 @@ export type OpenRouterUsageKey = keyof typeof zh
|
|||||||
/** English dictionary, checked complete against the zh key set. */
|
/** English dictionary, checked complete against the zh key set. */
|
||||||
export const en = {
|
export const en = {
|
||||||
'cost.label': 'Session cost {amount}',
|
'cost.label': 'Session cost {amount}',
|
||||||
|
'cost.labelWithSubagents': 'Session cost {amount} (subagents {subagentAmount})',
|
||||||
'cost.tooltipUnknown': 'Includes {count} unpriced steps',
|
'cost.tooltipUnknown': 'Includes {count} unpriced steps',
|
||||||
'balance.label': 'Balance {amount}',
|
'balance.label': 'Balance {amount}',
|
||||||
'balance.tooltip': 'OpenRouter account balance ({label})',
|
'balance.tooltip': 'OpenRouter account balance ({label}) — click to refresh',
|
||||||
'balance.empty': '——',
|
'balance.empty': '——',
|
||||||
|
'balance.refreshing': 'Refreshing the OpenRouter account balance',
|
||||||
} satisfies Record<OpenRouterUsageKey, string>
|
} satisfies Record<OpenRouterUsageKey, string>
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
// BalanceBadge presentation: polls an injected snapshot callback and renders
|
// 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 { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||||
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
|
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
|
||||||
@@ -34,6 +35,7 @@ function makeProps(over: Partial<BalanceBadgeProps> = {}) {
|
|||||||
return {
|
return {
|
||||||
wide: true,
|
wide: true,
|
||||||
snapshot: vi.fn(async () => makeBalance()),
|
snapshot: vi.fn(async () => makeBalance()),
|
||||||
|
refresh: vi.fn(async () => makeBalance()),
|
||||||
t,
|
t,
|
||||||
...over,
|
...over,
|
||||||
} as unknown as Parameters<typeof BalanceBadge>[0]
|
} as unknown as Parameters<typeof BalanceBadge>[0]
|
||||||
@@ -67,4 +69,37 @@ describe('BalanceBadge', () => {
|
|||||||
render(<BalanceBadge {...makeProps({ snapshot: vi.fn(async () => makeBalance({ balanceUsd: null })) })} />)
|
render(<BalanceBadge {...makeProps({ snapshot: vi.fn(async () => makeBalance({ balanceUsd: null })) })} />)
|
||||||
expect(await screen.findByLabelText('余额 ——')).toBeTruthy()
|
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(<BalanceBadge {...makeProps({ refresh })} />)
|
||||||
|
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<OpenRouterBalance>((resolve) => { release = resolve }))
|
||||||
|
render(<BalanceBadge {...makeProps({ refresh })} />)
|
||||||
|
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(<BalanceBadge {...makeProps({ refresh })} />)
|
||||||
|
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')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -37,8 +37,10 @@ async function bench() {
|
|||||||
new RemoteService(ctx)
|
new RemoteService(ctx)
|
||||||
const snapshot = vi.fn<() => Promise<RemoteResult<OpenRouterBalance>>>()
|
const snapshot = vi.fn<() => Promise<RemoteResult<OpenRouterBalance>>>()
|
||||||
.mockResolvedValue({ ok: true, value: balance() })
|
.mockResolvedValue({ ok: true, value: balance() })
|
||||||
ctx.provide('remote.openRouterUsage', { snapshot })
|
const refresh = vi.fn<() => Promise<RemoteResult<OpenRouterBalance>>>()
|
||||||
return { ctx, slots: ctx.get('slots') as SlotRegistry, locale, snapshot }
|
.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 {
|
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.options).toMatchObject({ id: 'openrouter-balance', order: 10 })
|
||||||
expect(badge.locale).toBe(NS)
|
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.snapshot).not.toHaveBeenCalled()
|
||||||
|
expect(b.refresh).not.toHaveBeenCalled()
|
||||||
const injected = (badge.inject as unknown as () => BalanceBadgeActions)()
|
const injected = (badge.inject as unknown as () => BalanceBadgeActions)()
|
||||||
await expect(injected.snapshot()).resolves.toEqual(balance())
|
await expect(injected.snapshot()).resolves.toEqual(balance())
|
||||||
expect(b.snapshot).toHaveBeenCalledOnce()
|
expect(b.snapshot).toHaveBeenCalledOnce()
|
||||||
|
await expect(injected.refresh()).resolves.toEqual(balance({ balanceUsd: 9 }))
|
||||||
|
expect(b.refresh).toHaveBeenCalledOnce()
|
||||||
await b.ctx.fiber.dispose()
|
await b.ctx.fiber.dispose()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -102,6 +107,16 @@ describe('ui-openrouter-usage browser plugin', () => {
|
|||||||
await b.ctx.fiber.dispose()
|
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 () => {
|
it('follows locale and recovers across late declaration and declarer reload', async () => {
|
||||||
const b = await bench()
|
const b = await bench()
|
||||||
const fiber = b.ctx.plugin({ inject: [...inject], apply })
|
const fiber = b.ctx.plugin({ inject: [...inject], apply })
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
// CostDock presentation: renders the running session cost from the
|
// 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 { act, cleanup, render, screen } from '@testing-library/react'
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
@@ -13,9 +14,37 @@ afterEach(cleanup)
|
|||||||
|
|
||||||
const t = makeTranslate(zh, commonZh)
|
const t = makeTranslate(zh, commonZh)
|
||||||
|
|
||||||
function makeProps(over: Partial<Parameters<typeof CostDock>[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<string, unknown> = {}
|
||||||
|
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<string, unknown> }) => unknown) => selector({ byId })
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeProps(over: Partial<Parameters<typeof CostDock>[0]> = {}, rows: readonly Row[] = []) {
|
||||||
return {
|
return {
|
||||||
|
sessionId: PARENT,
|
||||||
useProjection: vi.fn(() => undefined),
|
useProjection: vi.fn(() => undefined),
|
||||||
|
useSessions: sessionsHook(rows),
|
||||||
t,
|
t,
|
||||||
...over,
|
...over,
|
||||||
} as unknown as Parameters<typeof CostDock>[0]
|
} as unknown as Parameters<typeof CostDock>[0]
|
||||||
@@ -57,4 +86,53 @@ describe('CostDock', () => {
|
|||||||
act(() => {})
|
act(() => {})
|
||||||
expect(useProjection).toHaveBeenCalledWith('openRouterCost')
|
expect(useProjection).toHaveBeenCalledWith('openRouterCost')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('adds the subagent subtree to the figure and names its share', () => {
|
||||||
|
render(<CostDock {...makeProps(
|
||||||
|
{ useProjection: () => ({ 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(<CostDock {...makeProps(
|
||||||
|
{ useProjection: () => ({ 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(<CostDock {...makeProps(
|
||||||
|
{ useProjection: () => 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(<CostDock {...makeProps(
|
||||||
|
{ useProjection: () => ({ 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(<CostDock {...makeProps(
|
||||||
|
{ useProjection: () => ({ 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()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -282,6 +282,10 @@ function CatalogRows({
|
|||||||
entry.activity,
|
entry.activity,
|
||||||
now,
|
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
|
const tokenMetric = totalTokens === undefined
|
||||||
? undefined
|
? undefined
|
||||||
: `${formatTokens(totalTokens)} tok`
|
: `${formatTokens(totalTokens)} tok`
|
||||||
@@ -291,7 +295,7 @@ function CatalogRows({
|
|||||||
compact: formatDuration(durationMs, t),
|
compact: formatDuration(durationMs, t),
|
||||||
exact: formatExactDuration(durationMs, t),
|
exact: formatExactDuration(durationMs, t),
|
||||||
}
|
}
|
||||||
const metrics = [tokenMetric, durationMetric?.exact]
|
const metrics = [costMetric, tokenMetric, durationMetric?.exact]
|
||||||
.filter(value => value !== undefined)
|
.filter(value => value !== undefined)
|
||||||
.join(' · ')
|
.join(' · ')
|
||||||
|
|
||||||
|
|||||||
@@ -2,5 +2,5 @@
|
|||||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
# 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:
|
# after editing either side, bring the other along and re-record with:
|
||||||
# pnpm run verify-translation-pairing --write packages/client/ui-trajectory/README.md
|
# pnpm run verify-translation-pairing --write packages/client/ui-trajectory/README.md
|
||||||
README.md: 9ad42a3d3bcfb8fbf4842a66577e65794a716dfd
|
README.md: e409485e23dc41fccbe38e39d5984783e4ae21ff
|
||||||
README.zh.md: df7661ef7d4861dbc6c8e365a18b3e3252d4d4af
|
README.zh.md: f4163d8849241b6775e03ea343709a6a3e304407
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
English | [中文](README.zh.md)
|
English | [中文](README.zh.md)
|
||||||
|
|
||||||
Trajectory renders a turn-aware event ledger with selectable User, Assistant, Tool, and nested Subtool records. Thick rules mark Turn boundaries, compact inline markers identify Steps, and the main ledger keeps only index, event, and content; selection opens a local inspector for token usage, duration, Input, Output, and Timing. Scrollable Summary regions keep their scrollbar thumbs transparent until the region is hovered or contains keyboard focus, without changing the reserved scroll geometry. A standalone compaction request appears chronologically in its own `Between turns` section, while a numbered compaction remains inside its owning turn. Long ledgers open at the current tail, load one older page when the user reaches the loaded range's top, and mount only the visible row window plus a small overscan; request-only separators share the next measurable virtual item, while semantic row keys and ARIA indexes survive prepends. Selection, timeline navigation, folding, search, and Request totals cover the currently loaded window. The ledger covers records with an explicit loading row until the initial tail is positioned. While an older prefix remains unloaded, a first-row control precedes the loaded records, loads one earlier page on click, and changes in place to a disabled loading status while that page is pending. A fixed Overview above the ledger projects real record start/duration timing from left to right; when earlier records remain unloaded and the viewport includes the loaded domain's start, a neutral ellipsis control identifies the omitted prefix and loads one earlier page without assigning unknown history fabricated duration. Assistant spans divide recorded TTFT from decoding, and a 500 ms hover reveals exact clock and duration details. Dragging an interval focuses the ledger on every record active at any point in that inclusive range, while clearing the selection restores the full loaded ledger. Wheel gestures zoom the time domain. A right-button click clears the selected interval, while a right-button drag pans an already zoomed viewport without changing it. The initial view and streaming updates stay at the tail; scrolling upward suspends following so new records do not interrupt inspection of earlier rows. Content-only stream frames preserve virtual row keys and heights, reuse measurements, and do not issue repeated tail-scroll writes. Completed replies retain assembled blocks, timing, and usage in Trajectory target State, while the shared Session window keeps the raw Events. Trajectory asks the conversation shell to float the composer over the full-height ledger, while its responsive vertical scrollers reserve the composer's live height so final rows remain reachable. Trajectory-owned Definitions assemble business records, including cancellation-frozen Assistant and Tool records, from the shared Session window, so Trajectory neither reads nor changes the Chat conversation snapshot. The package provides no service and declares no Context merge; it registers target-specific Event Definitions, a Trajectory view builder, and one tab in the conversation's `'conversation.view'` slot ring. Contract: api-contracts v3 §8.
|
Trajectory renders a turn-aware event ledger with selectable User, Assistant, Tool, and nested Subtool records. Thick rules mark Turn boundaries, compact inline markers identify Steps, and the main ledger keeps only index, event, and content; selection opens a local inspector for token usage, duration, Input, Output, and Timing. When the `openRouterCost` session projection is composed, each priced assistant step shows its host-priced USD cost on the request boundary chip and in the inspector summary. Scrollable Summary regions keep their scrollbar thumbs transparent until the region is hovered or contains keyboard focus, without changing the reserved scroll geometry. A standalone compaction request appears chronologically in its own `Between turns` section, while a numbered compaction remains inside its owning turn. Long ledgers open at the current tail, load one older page when the user reaches the loaded range's top, and mount only the visible row window plus a small overscan; request-only separators share the next measurable virtual item, while semantic row keys and ARIA indexes survive prepends. Selection, timeline navigation, folding, search, and Request totals cover the currently loaded window. The ledger covers records with an explicit loading row until the initial tail is positioned. While an older prefix remains unloaded, a first-row control precedes the loaded records, loads one earlier page on click, and changes in place to a disabled loading status while that page is pending. A fixed Overview above the ledger projects real record start/duration timing from left to right; when earlier records remain unloaded and the viewport includes the loaded domain's start, a neutral ellipsis control identifies the omitted prefix and loads one earlier page without assigning unknown history fabricated duration. Assistant spans divide recorded TTFT from decoding, and a 500 ms hover reveals exact clock and duration details. Dragging an interval focuses the ledger on every record active at any point in that inclusive range, while clearing the selection restores the full loaded ledger. Wheel gestures zoom the time domain. A right-button click clears the selected interval, while a right-button drag pans an already zoomed viewport without changing it. The initial view and streaming updates stay at the tail; scrolling upward suspends following so new records do not interrupt inspection of earlier rows. Content-only stream frames preserve virtual row keys and heights, reuse measurements, and do not issue repeated tail-scroll writes. Completed replies retain assembled blocks, timing, and usage in Trajectory target State, while the shared Session window keeps the raw Events. Trajectory asks the conversation shell to float the composer over the full-height ledger, while its responsive vertical scrollers reserve the composer's live height so final rows remain reachable. Trajectory-owned Definitions assemble business records, including cancellation-frozen Assistant and Tool records, from the shared Session window, so Trajectory neither reads nor changes the Chat conversation snapshot. The package provides no service and declares no Context merge; it registers target-specific Event Definitions, a Trajectory view builder, and one tab in the conversation's `'conversation.view'` slot ring. Contract: api-contracts v3 §8.
|
||||||
|
|
||||||
## Model Experience
|
## Model Experience
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
[English](README.md) | 中文
|
[English](README.md) | 中文
|
||||||
|
|
||||||
Trajectory 渲染按轮次组织的事件记录表,其中可选择用户、助手、工具和嵌套子工具记录。较粗的分割线标示轮次边界,紧凑的行内标记标识步骤,主记录表仅保留索引、事件和内容;选择记录则会打开局部检查器,查看 token 用量、耗时、输入、输出和计时。可滚动的概述区域默认保持滚动条滑块透明,直到鼠标悬停该区域或其中包含键盘焦点时才显示,同时不改变滚动条预留的几何空间。独立运行的压缩(compaction)请求会按时间顺序显示在自己的 `Between turns` 区段中,而带编号的压缩仍位于其所属轮次内。长记录表打开时定位于当前尾部,用户到达已加载范围顶部时加载一页更早的历史,并且只挂载可见行窗口和少量额外缓冲行;仅含请求的分隔行并入下一个具备可测高度的虚拟项,语义行键和 ARIA 索引在向前补页后保持不变。选择、时间线导航、折叠、搜索和请求汇总只覆盖当前已加载的窗口。初始尾部完成定位前,记录表会用明确的加载行遮住真实记录。更早的前缀仍未加载时,已加载记录前会始终保留首行控件;单击它会加载一页更早的历史,页面加载期间则会原地变为禁用的加载状态。固定在记录表上方的 Overview 区域从左到右投影记录的真实开始时间与耗时;仍有更早记录未加载且 viewport 包含已加载时间域起点时,中性的省略号控件会标识被省略的前缀,并可加载一页更早历史,而不会为未知部分虚构耗时。助手时间条会区分记录到的 TTFT 与解码时间,悬停 500 ms 后可查看精确时刻和耗时详情。拖选一个区间会将记录表聚焦到活动区间与该闭区间有重叠的所有记录,清除选择则恢复完整的已加载记录表。滚轮手势用于缩放时间域。右键单击会清除所选区间;在已放大的 viewport 上按住右键拖动则只会平移视图,不会改变该区间。初始视图和流式更新都会停留在尾部;向上滚动会暂停跟随,因此新记录不会打断对旧记录的检查。仅含内容更新的流式帧会保持虚拟行的键和高度不变、复用测量结果,并且不会重复写入末尾滚动位置。已完成的回复会在 Trajectory target State 中保留组装后的 blocks、计时与用量,共享 Session 窗口则保留原始 Event。Trajectory 要求会话壳将 composer 作为浮层置于全高记录表上方;其响应式纵向滚动容器会预留 composer 的实时高度,确保仍可滚动到最后几行。Trajectory 自有的 Definition 从共享 Session 窗口组装业务记录,其中包括因取消而冻结的助手和工具记录,因此 Trajectory 既不读取也不改变 Chat 会话快照。该包不提供 service,也不声明 Context 合并;它会注册 target 专属 Event Definition、Trajectory view builder,以及会话 `'conversation.view'` slot 环中的一个视图标签页。约定:api-contracts v3 §8。
|
Trajectory 渲染按轮次组织的事件记录表,其中可选择用户、助手、工具和嵌套子工具记录。较粗的分割线标示轮次边界,紧凑的行内标记标识步骤,主记录表仅保留索引、事件和内容;选择记录则会打开局部检查器,查看 token 用量、耗时、输入、输出和计时。当组合了 `openRouterCost` 会话投影时,每个已计价的助手 step 会在请求边界标记与检查器摘要中显示其宿主计价的美元成本。可滚动的概述区域默认保持滚动条滑块透明,直到鼠标悬停该区域或其中包含键盘焦点时才显示,同时不改变滚动条预留的几何空间。独立运行的压缩(compaction)请求会按时间顺序显示在自己的 `Between turns` 区段中,而带编号的压缩仍位于其所属轮次内。长记录表打开时定位于当前尾部,用户到达已加载范围顶部时加载一页更早的历史,并且只挂载可见行窗口和少量额外缓冲行;仅含请求的分隔行并入下一个具备可测高度的虚拟项,语义行键和 ARIA 索引在向前补页后保持不变。选择、时间线导航、折叠、搜索和请求汇总只覆盖当前已加载的窗口。初始尾部完成定位前,记录表会用明确的加载行遮住真实记录。更早的前缀仍未加载时,已加载记录前会始终保留首行控件;单击它会加载一页更早的历史,页面加载期间则会原地变为禁用的加载状态。固定在记录表上方的 Overview 区域从左到右投影记录的真实开始时间与耗时;仍有更早记录未加载且 viewport 包含已加载时间域起点时,中性的省略号控件会标识被省略的前缀,并可加载一页更早历史,而不会为未知部分虚构耗时。助手时间条会区分记录到的 TTFT 与解码时间,悬停 500 ms 后可查看精确时刻和耗时详情。拖选一个区间会将记录表聚焦到活动区间与该闭区间有重叠的所有记录,清除选择则恢复完整的已加载记录表。滚轮手势用于缩放时间域。右键单击会清除所选区间;在已放大的 viewport 上按住右键拖动则只会平移视图,不会改变该区间。初始视图和流式更新都会停留在尾部;向上滚动会暂停跟随,因此新记录不会打断对旧记录的检查。仅含内容更新的流式帧会保持虚拟行的键和高度不变、复用测量结果,并且不会重复写入末尾滚动位置。已完成的回复会在 Trajectory target State 中保留组装后的 blocks、计时与用量,共享 Session 窗口则保留原始 Event。Trajectory 要求会话壳将 composer 作为浮层置于全高记录表上方;其响应式纵向滚动容器会预留 composer 的实时高度,确保仍可滚动到最后几行。Trajectory 自有的 Definition 从共享 Session 窗口组装业务记录,其中包括因取消而冻结的助手和工具记录,因此 Trajectory 既不读取也不改变 Chat 会话快照。该包不提供 service,也不声明 Context 合并;它会注册 target 专属 Event Definition、Trajectory view builder,以及会话 `'conversation.view'` slot 环中的一个视图标签页。约定:api-contracts v3 §8。
|
||||||
|
|
||||||
## 模型体验
|
## 模型体验
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,8 @@
|
|||||||
"inject": [
|
"inject": [
|
||||||
"@deepseek-ai/dsh-client-locale",
|
"@deepseek-ai/dsh-client-locale",
|
||||||
"@deepseek-ai/dsh-client-runtime",
|
"@deepseek-ai/dsh-client-runtime",
|
||||||
"@deepseek-ai/dsh-client-ui-conversation"
|
"@deepseek-ai/dsh-client-ui-conversation",
|
||||||
|
"@deepseek-ai/dsh-openrouter-usage"
|
||||||
],
|
],
|
||||||
"platform": "web"
|
"platform": "web"
|
||||||
}
|
}
|
||||||
@@ -54,6 +55,7 @@
|
|||||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
||||||
|
"@deepseek-ai/dsh-openrouter-usage": "workspace:^",
|
||||||
"@deepseek-ai/cordis": "workspace:^",
|
"@deepseek-ai/cordis": "workspace:^",
|
||||||
"@deepseek-ai/dsh-compaction": "workspace:^",
|
"@deepseek-ai/dsh-compaction": "workspace:^",
|
||||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||||
@@ -68,6 +70,7 @@
|
|||||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
||||||
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
|
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
|
||||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||||
|
"@deepseek-ai/dsh-openrouter-usage": "workspace:^",
|
||||||
"@deepseek-ai/dsh-compaction": "workspace:^",
|
"@deepseek-ai/dsh-compaction": "workspace:^",
|
||||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||||
|
|||||||
@@ -408,6 +408,8 @@ interface TrajectoryRequestNumberBase {
|
|||||||
requestConfig?: AssistantRequestConfig
|
requestConfig?: AssistantRequestConfig
|
||||||
usage?: TrajectoryUsage
|
usage?: TrajectoryUsage
|
||||||
cumulativeUsage?: TrajectoryUsage
|
cumulativeUsage?: TrajectoryUsage
|
||||||
|
/** Host-priced OpenRouter spend for this step in USD, when priced. */
|
||||||
|
stepCostUsd?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One purpose-discriminated request identity paired with its session-global number. */
|
/** One purpose-discriminated request identity paired with its session-global number. */
|
||||||
@@ -491,6 +493,25 @@ function requestStep(group: string): number | undefined {
|
|||||||
return Number.isInteger(value) && value > 0 ? value : undefined
|
return Number.isInteger(value) && value > 0 ? value : undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a per-step USD price: cents-style above a cent, three significant
|
||||||
|
* digits below it so sub-cent figures like $0.0028 stay readable instead of
|
||||||
|
* collapsing to $0.00.
|
||||||
|
* @param usd - non-negative USD amount.
|
||||||
|
* @returns display string.
|
||||||
|
*/
|
||||||
|
function formatStepUsd(usd: number): string {
|
||||||
|
if (usd >= 0.01 || usd === 0) {
|
||||||
|
return usd.toLocaleString('en-US', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'USD',
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return `$${usd.toPrecision(3).replace(/0+$/, '').replace(/\.$/, '')}`
|
||||||
|
}
|
||||||
|
|
||||||
function requestKey(turn: number | null, group: string): string {
|
function requestKey(turn: number | null, group: string): string {
|
||||||
return `${turn}\u0000${group}`
|
return `${turn}\u0000${group}`
|
||||||
}
|
}
|
||||||
@@ -2296,7 +2317,7 @@ export function TrajectoryTable({
|
|||||||
}
|
}
|
||||||
const requestLabel = request === undefined
|
const requestLabel = request === undefined
|
||||||
? undefined
|
? undefined
|
||||||
: `Request #${request}${requestInfo?.purpose === 'compaction' ? ' · Compaction' : ''}`
|
: `Request #${request}${requestInfo?.purpose === 'compaction' ? ' · Compaction' : ''}${requestInfo?.stepCostUsd === undefined ? '' : ` · ${formatStepUsd(requestInfo.stepCostUsd)}`}`
|
||||||
const requestSelected = request !== undefined
|
const requestSelected = request !== undefined
|
||||||
&& selectedRequest?.turn === record.turn
|
&& selectedRequest?.turn === record.turn
|
||||||
&& selectedRequest.group === record.group
|
&& selectedRequest.group === record.group
|
||||||
@@ -2733,6 +2754,12 @@ export function TrajectoryTable({
|
|||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{selectedRequestInfo?.stepCostUsd !== undefined && (
|
||||||
|
<div>
|
||||||
|
<dt>Cost</dt>
|
||||||
|
<dd>{formatStepUsd(selectedRequestInfo.stepCostUsd)}</dd>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div>
|
<div>
|
||||||
<dt>Tool calls</dt>
|
<dt>Tool calls</dt>
|
||||||
<dd>{selectedRequestToolCalls}</dd>
|
<dd>{selectedRequestToolCalls}</dd>
|
||||||
|
|||||||
@@ -3,6 +3,11 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import type { ConvViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
import type { ConvViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||||
import type { InjectFace, PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
|
import type { InjectFace, PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
|
||||||
|
// Type-only: merges openRouterCost into SessionProjectionMap so the
|
||||||
|
// framework useProjection seat types the step-cost lookup below. The value
|
||||||
|
// itself comes from the runtime's per-session projection store, absent when
|
||||||
|
// the OpenRouter usage plugin is not composed.
|
||||||
|
import type {} from '@deepseek-ai/dsh-openrouter-usage/client'
|
||||||
import type {
|
import type {
|
||||||
AssistantBlock, AssistantMessageNode, ConversationSnapshot,
|
AssistantBlock, AssistantMessageNode, ConversationSnapshot,
|
||||||
SnapshotStore,
|
SnapshotStore,
|
||||||
@@ -79,6 +84,7 @@ interface UsageLike {
|
|||||||
cacheWriteTokens?: number
|
cacheWriteTokens?: number
|
||||||
outputTokens?: number
|
outputTokens?: number
|
||||||
reasoningTokens?: number
|
reasoningTokens?: number
|
||||||
|
costUsd?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
function requestUsage(value: unknown): TrajectoryUsage | undefined {
|
function requestUsage(value: unknown): TrajectoryUsage | undefined {
|
||||||
@@ -118,7 +124,7 @@ function addUsage(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function TrajectoryView({
|
export function TrajectoryView({
|
||||||
useSession, useDuration, loadOlder, setActualDuration,
|
useProjection, useSession, useDuration, loadOlder, setActualDuration,
|
||||||
inspect, onInspectDone, t,
|
inspect, onInspectDone, t,
|
||||||
}: ConvViewProps & InjectFace<TrajectoryViewInjected> & PropsLocale<'trajectory'>) {
|
}: ConvViewProps & InjectFace<TrajectoryViewInjected> & PropsLocale<'trajectory'>) {
|
||||||
const [collapsedTurns, setCollapsedTurns] = useState<ReadonlySet<number>>(EMPTY_TURN_IDS)
|
const [collapsedTurns, setCollapsedTurns] = useState<ReadonlySet<number>>(EMPTY_TURN_IDS)
|
||||||
@@ -151,6 +157,9 @@ export function TrajectoryView({
|
|||||||
const runningCalls = inspection.runningCalls
|
const runningCalls = inspection.runningCalls
|
||||||
const requests = inspection.requests
|
const requests = inspection.requests
|
||||||
const callSchemas = inspection.callSchemas
|
const callSchemas = inspection.callSchemas
|
||||||
|
// Per-step OpenRouter spend (host-priced): keyed `${turn}:${step}`, only
|
||||||
|
// priced steps present; undefined when the projection is not composed.
|
||||||
|
const stepCosts = useProjection('openRouterCost')?.steps
|
||||||
const requestNumbers = useMemo<readonly TrajectoryRequestNumber[]>(() => {
|
const requestNumbers = useMemo<readonly TrajectoryRequestNumber[]>(() => {
|
||||||
const assistantsByStep = new Map<string, AssistantMessageNode>()
|
const assistantsByStep = new Map<string, AssistantMessageNode>()
|
||||||
for (const node of nodes) {
|
for (const node of nodes) {
|
||||||
@@ -197,6 +206,9 @@ export function TrajectoryView({
|
|||||||
const provider = request?.provenance?.provider ?? node?.provenance?.provider
|
const provider = request?.provenance?.provider ?? node?.provenance?.provider
|
||||||
const model = request?.provenance?.model ?? node?.provenance?.model
|
const model = request?.provenance?.model ?? node?.provenance?.model
|
||||||
const requestConfig = request?.requestConfig ?? node?.requestConfig
|
const requestConfig = request?.requestConfig ?? node?.requestConfig
|
||||||
|
const directCostUsd = (entry.request?.usage as UsageLike | undefined)?.costUsd
|
||||||
|
?? (entry.node?.usage as UsageLike | undefined)?.costUsd
|
||||||
|
const stepCostUsd = directCostUsd ?? stepCosts?.[`${turn}:${step}`]
|
||||||
numbered.push({
|
numbered.push({
|
||||||
seq: entry.seq,
|
seq: entry.seq,
|
||||||
turn,
|
turn,
|
||||||
@@ -218,10 +230,12 @@ export function TrajectoryView({
|
|||||||
...(requestConfig === undefined ? {} : { requestConfig }),
|
...(requestConfig === undefined ? {} : { requestConfig }),
|
||||||
...(usage === undefined ? {} : { usage }),
|
...(usage === undefined ? {} : { usage }),
|
||||||
...(cumulativeUsage === undefined ? {} : { cumulativeUsage }),
|
...(cumulativeUsage === undefined ? {} : { cumulativeUsage }),
|
||||||
|
...(stepCostUsd === undefined ? {} : { stepCostUsd }),
|
||||||
})
|
})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
const request = entry.request
|
const request = entry.request
|
||||||
|
const directCostUsd = (request.usage as UsageLike | undefined)?.costUsd
|
||||||
numbered.push({
|
numbered.push({
|
||||||
seq: request.startSeq,
|
seq: request.startSeq,
|
||||||
turn: request.turn,
|
turn: request.turn,
|
||||||
@@ -243,12 +257,13 @@ export function TrajectoryView({
|
|||||||
...(request.requestConfig === undefined ? {} : { requestConfig: request.requestConfig }),
|
...(request.requestConfig === undefined ? {} : { requestConfig: request.requestConfig }),
|
||||||
...(usage === undefined ? {} : { usage }),
|
...(usage === undefined ? {} : { usage }),
|
||||||
...(cumulativeUsage === undefined ? {} : { cumulativeUsage }),
|
...(cumulativeUsage === undefined ? {} : { cumulativeUsage }),
|
||||||
|
...(directCostUsd === undefined ? {} : { stepCostUsd: directCostUsd }),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return numbered
|
return numbered
|
||||||
}, [
|
}, [
|
||||||
nodes, requests,
|
nodes, requests, stepCosts,
|
||||||
])
|
])
|
||||||
const partialTurn = partial?.turn ?? null
|
const partialTurn = partial?.turn ?? null
|
||||||
const partialStep = partial?.step ?? null
|
const partialStep = partial?.step ?? null
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ interface UsageLike {
|
|||||||
cacheWriteTokens?: number
|
cacheWriteTokens?: number
|
||||||
outputTokens?: number
|
outputTokens?: number
|
||||||
reasoningTokens?: number
|
reasoningTokens?: number
|
||||||
|
costUsd?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Cell plus absolute ms for group wall-span descriptions. */
|
/** Cell plus absolute ms for group wall-span descriptions. */
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ interface UsageValue {
|
|||||||
readonly cacheReadTokens?: number
|
readonly cacheReadTokens?: number
|
||||||
readonly cacheWriteTokens?: number
|
readonly cacheWriteTokens?: number
|
||||||
readonly reasoningTokens?: number
|
readonly reasoningTokens?: number
|
||||||
|
readonly costUsd?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RetryValue {
|
interface RetryValue {
|
||||||
@@ -101,6 +102,9 @@ function addUsage(current: UsageValue | undefined, next: UsageValue): UsageValue
|
|||||||
...(current?.reasoningTokens === undefined && next.reasoningTokens === undefined
|
...(current?.reasoningTokens === undefined && next.reasoningTokens === undefined
|
||||||
? {}
|
? {}
|
||||||
: { reasoningTokens: (current?.reasoningTokens ?? 0) + (next.reasoningTokens ?? 0) }),
|
: { reasoningTokens: (current?.reasoningTokens ?? 0) + (next.reasoningTokens ?? 0) }),
|
||||||
|
...(current?.costUsd === undefined && next.costUsd === undefined
|
||||||
|
? {}
|
||||||
|
: { costUsd: (current?.costUsd ?? 0) + (next.costUsd ?? 0) }),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -871,4 +871,46 @@ describe('TrajectoryTable', () => {
|
|||||||
expect(screen.getByRole('row', { name: /TOOL/ }).getAttribute('aria-selected')).toBe('false')
|
expect(screen.getByRole('row', { name: /TOOL/ }).getAttribute('aria-selected')).toBe('false')
|
||||||
expect(onInspectApplied).not.toHaveBeenCalled()
|
expect(onInspectApplied).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('shows a host-priced step cost on the request boundary and in the summary', () => {
|
||||||
|
render(
|
||||||
|
<TrajectoryTable
|
||||||
|
turns={TURNS}
|
||||||
|
requestNumbers={[{
|
||||||
|
turn: 1, step: 1, group: 'Step 1', number: 1, seq: 1,
|
||||||
|
stepCostUsd: 0.0028,
|
||||||
|
}]}
|
||||||
|
{...FOLD_PROPS}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
const chip = screen.getByRole('button', { name: 'Request #1 · $0.0028' })
|
||||||
|
expect(chip).toBeTruthy()
|
||||||
|
fireEvent.click(chip)
|
||||||
|
expect(screen.getByText('Cost')).toBeTruthy()
|
||||||
|
expect(screen.getByText('$0.0028')).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('formats step costs above a cent with the currency format', () => {
|
||||||
|
render(
|
||||||
|
<TrajectoryTable
|
||||||
|
turns={TURNS}
|
||||||
|
requestNumbers={[{
|
||||||
|
turn: 1, step: 1, group: 'Step 1', number: 1, seq: 1,
|
||||||
|
stepCostUsd: 1.234,
|
||||||
|
}]}
|
||||||
|
{...FOLD_PROPS}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
const chip = screen.getByRole('button', { name: 'Request #1 · $1.23' })
|
||||||
|
expect(chip).toBeTruthy()
|
||||||
|
fireEvent.click(chip)
|
||||||
|
expect(screen.getByText('Cost')).toBeTruthy()
|
||||||
|
expect(screen.getByText('$1.23')).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('omits the cost row when the step was not priced', () => {
|
||||||
|
render(<TrajectoryTable turns={TURNS} {...FOLD_PROPS} />)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Request #1' }))
|
||||||
|
expect(screen.queryByText('Cost')).toBeNull()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -255,7 +255,8 @@ function mount(slots: SlotRegistry, nodes: ConversationSnapshot['nodes'] = NODES
|
|||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
{...injectedProps}
|
{...injectedProps}
|
||||||
{...({ sessionId: SID, useSession, useSessions: emptySessions(), useWorkspaces: emptyWorkspaces() } as unknown as ConvViewProps)}
|
{...({ sessionId: SID, useSession, useProjection: () => undefined,
|
||||||
|
useSessions: emptySessions(), useWorkspaces: emptyWorkspaces() } as unknown as ConvViewProps)}
|
||||||
key={key}
|
key={key}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@@ -1199,6 +1200,53 @@ describe('TrajectoryView state', () => {
|
|||||||
)).toBeTruthy()
|
)).toBeTruthy()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('attaches the per-step OpenRouter cost from the openRouterCost projection', () => {
|
||||||
|
render(
|
||||||
|
<TrajectoryView
|
||||||
|
{...standaloneProps(NODES)}
|
||||||
|
{...standaloneHistory(historySnapshot(NODES))}
|
||||||
|
{...standaloneDuration()}
|
||||||
|
useProjection={((key: string) => (key === 'openRouterCost'
|
||||||
|
? {
|
||||||
|
totalUsd: 0.00336,
|
||||||
|
pricedSteps: 2,
|
||||||
|
unknownModelSteps: 0,
|
||||||
|
steps: { '1:1': 0.0028 },
|
||||||
|
currency: 'USD',
|
||||||
|
}
|
||||||
|
: undefined)) as never}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
// The turn-1/step-1 request is priced; the turn-2/step-1 one is not.
|
||||||
|
expect(screen.getByRole('button', { name: 'Request #1 · $0.0028' })).toBeTruthy()
|
||||||
|
expect(screen.queryByRole('button', { name: 'Request #2 · $0.0028' })).toBeNull()
|
||||||
|
// The summary of the priced step shows its cost.
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Request #1 · $0.0028' }))
|
||||||
|
expect(screen.getByText('Cost')).toBeTruthy()
|
||||||
|
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(
|
||||||
|
<TrajectoryView
|
||||||
|
{...standaloneProps(nodesWithCost)}
|
||||||
|
{...standaloneHistory(historySnapshot(nodesWithCost))}
|
||||||
|
{...standaloneDuration()}
|
||||||
|
useProjection={(() => undefined) as never}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
expect(screen.getByRole('button', { name: 'Request #1 · $0.0045' })).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('node half', () => {
|
describe('node half', () => {
|
||||||
|
|||||||
@@ -32,6 +32,9 @@
|
|||||||
{
|
{
|
||||||
"path": "../../compaction/compaction"
|
"path": "../../compaction/compaction"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "../../llm/openrouter-usage"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "../../runtime-diagnostics/invariants"
|
"path": "../../runtime-diagnostics/invariants"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -421,6 +421,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
|
|||||||
declaredBy: 'an entry in \'conversation\' (client-ui-conversation), so it exists while that entry is mounted',
|
declaredBy: 'an entry in \'conversation\' (client-ui-conversation), so it exists while that entry is mounted',
|
||||||
occupants: [
|
occupants: [
|
||||||
'client-ui-conversation StatsLine id \'stats\'',
|
'client-ui-conversation StatsLine id \'stats\'',
|
||||||
|
'client-ui-openrouter-usage CostDock id \'openrouter-cost\'',
|
||||||
],
|
],
|
||||||
replaceRisk: 'none',
|
replaceRisk: 'none',
|
||||||
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.composer.dock\', () => ctx.slots.register(\n { name: \'conversation.composer.dock\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
|
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.composer.dock\', () => ctx.slots.register(\n { name: \'conversation.composer.dock\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
|
||||||
@@ -1527,6 +1528,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
|
|||||||
slotInject: '',
|
slotInject: '',
|
||||||
declaredBy: 'an entry in \'sidebar\' (client-ui-sidebar), so it exists while that entry is mounted',
|
declaredBy: 'an entry in \'sidebar\' (client-ui-sidebar), so it exists while that entry is mounted',
|
||||||
occupants: [
|
occupants: [
|
||||||
|
'client-ui-openrouter-usage BalanceBadge id \'openrouter-balance\'',
|
||||||
'client-ui-cordis CordisPanel id \'cordis-panel\'',
|
'client-ui-cordis CordisPanel id \'cordis-panel\'',
|
||||||
],
|
],
|
||||||
replaceRisk: 'none',
|
replaceRisk: 'none',
|
||||||
|
|||||||
@@ -56,12 +56,21 @@ export class CordisInspectRegistryService extends Service {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Register one Host provider.
|
* Register one Host provider.
|
||||||
|
*
|
||||||
|
* Throws only when a different manifest claims the same provider id.
|
||||||
|
* An identical re-registration (same id, description, and every method)
|
||||||
|
* replaces the stored entry so the newest registrant's query closure is
|
||||||
|
* active. This allows multiple plugin mounts (e.g. two agent presets) that
|
||||||
|
* register the same capability to coexist without collision.
|
||||||
* @param registration - manifest and local query handler.
|
* @param registration - manifest and local query handler.
|
||||||
* @returns idempotent disposer.
|
* @returns idempotent disposer.
|
||||||
*/
|
*/
|
||||||
register(registration: HostCordisInspectProviderRegistration): () => void {
|
register(registration: HostCordisInspectProviderRegistration): () => void {
|
||||||
const manifest = validateManifest(registration.manifest)
|
const manifest = validateManifest(registration.manifest)
|
||||||
if (this.providers.has(manifest.id)) throw new Error(`Host Cordis inspect provider "${manifest.id}" is already registered`)
|
const existing = this.providers.get(manifest.id)
|
||||||
|
if (existing !== undefined && !sameManifest(existing.manifest, manifest)) {
|
||||||
|
throw new Error(`Host Cordis inspect provider "${manifest.id}" is already registered`)
|
||||||
|
}
|
||||||
const stored = { ...registration, manifest }
|
const stored = { ...registration, manifest }
|
||||||
this.providers.set(manifest.id, stored)
|
this.providers.set(manifest.id, stored)
|
||||||
return () => {
|
return () => {
|
||||||
@@ -198,6 +207,35 @@ export class CordisInspectRegistryService extends Service {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Deep equality for JSON-compatible values (objects, arrays, primitives). */
|
||||||
|
function jsonEqual(a: unknown, b: unknown): boolean {
|
||||||
|
if (a === b) return true
|
||||||
|
if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) return false
|
||||||
|
if (Array.isArray(a) || Array.isArray(b)) {
|
||||||
|
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false
|
||||||
|
return a.every((entry, index) => jsonEqual(entry, b[index]))
|
||||||
|
}
|
||||||
|
const left = a as Record<string, unknown>
|
||||||
|
const right = b as Record<string, unknown>
|
||||||
|
const keys = Object.keys(left)
|
||||||
|
if (keys.length !== Object.keys(right).length) return false
|
||||||
|
return keys.every(key => key in right && jsonEqual(left[key], right[key]))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether two manifests describe the same provider capability. */
|
||||||
|
function sameManifest(a: CordisInspectProviderManifest, b: CordisInspectProviderManifest): boolean {
|
||||||
|
if (a.id !== b.id || a.description !== b.description) return false
|
||||||
|
if (a.methods.length !== b.methods.length) return false
|
||||||
|
return a.methods.every((method, index) => {
|
||||||
|
const other = b.methods[index]
|
||||||
|
return other !== undefined
|
||||||
|
&& method.name === other.name
|
||||||
|
&& method.description === other.description
|
||||||
|
&& jsonEqual(method.inputSchema, other.inputSchema)
|
||||||
|
&& jsonEqual(method.outputSchema, other.outputSchema)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function view(platform: CordisInspectPlatform, manifest: CordisInspectProviderManifest): CordisInspectProviderView {
|
function view(platform: CordisInspectPlatform, manifest: CordisInspectProviderManifest): CordisInspectProviderView {
|
||||||
return { platform, ...manifest, methods: [...manifest.methods] }
|
return { platform, ...manifest, methods: [...manifest.methods] }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { Context } from '@deepseek-ai/cordis'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { CordisInspectRegistryService, type HostCordisInspectProviderRegistration } from '../src/inspect-registry.ts'
|
||||||
|
|
||||||
|
function makeProvider(id: string, desc: string): HostCordisInspectProviderRegistration {
|
||||||
|
return {
|
||||||
|
manifest: {
|
||||||
|
id,
|
||||||
|
description: desc,
|
||||||
|
methods: [{
|
||||||
|
name: 'list',
|
||||||
|
description: 'List things.',
|
||||||
|
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
|
||||||
|
outputSchema: { description: 'anything' },
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
async query() { return {} },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('CordisInspectRegistryService', () => {
|
||||||
|
it('accepts the first registration of a provider id', () => {
|
||||||
|
const ctx = new Context()
|
||||||
|
const svc = new CordisInspectRegistryService(ctx)
|
||||||
|
const p = makeProvider('Service', 'desc')
|
||||||
|
const dispose = svc.register(p)
|
||||||
|
expect(svc.list()).toContainEqual(expect.objectContaining({ id: 'Service', platform: 'host' }))
|
||||||
|
dispose()
|
||||||
|
expect(svc.list()).not.toContainEqual(expect.objectContaining({ id: 'Service' }))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('replaces on identical re-registration without throwing', () => {
|
||||||
|
const ctx = new Context()
|
||||||
|
const svc = new CordisInspectRegistryService(ctx)
|
||||||
|
const p = makeProvider('Service', 'desc')
|
||||||
|
const disposeA = svc.register(p)
|
||||||
|
const disposeB = svc.register(p) // same object — identical manifest
|
||||||
|
// Still in the directory (replaced, not removed)
|
||||||
|
expect(svc.list()).toContainEqual(expect.objectContaining({ id: 'Service', platform: 'host' }))
|
||||||
|
// disposeA should be a no-op because the map entry was replaced
|
||||||
|
disposeA()
|
||||||
|
expect(svc.list()).toContainEqual(expect.objectContaining({ id: 'Service', platform: 'host' }))
|
||||||
|
// disposeB removes the current registration
|
||||||
|
disposeB()
|
||||||
|
expect(svc.list()).not.toContainEqual(expect.objectContaining({ id: 'Service' }))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects a different manifest claiming the same id', () => {
|
||||||
|
const ctx = new Context()
|
||||||
|
const svc = new CordisInspectRegistryService(ctx)
|
||||||
|
svc.register(makeProvider('Service', 'original'))
|
||||||
|
expect(() => svc.register(makeProvider('Service', 'different')))
|
||||||
|
.toThrow('Host Cordis inspect provider "Service" is already registered')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects empty provider id', () => {
|
||||||
|
const ctx = new Context()
|
||||||
|
const svc = new CordisInspectRegistryService(ctx)
|
||||||
|
expect(() => svc.register(makeProvider('', 'empty')))
|
||||||
|
.toThrow('Cordis inspect provider id must not be empty')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('detects method-array length mismatch as unequal', () => {
|
||||||
|
const ctx = new Context()
|
||||||
|
const svc = new CordisInspectRegistryService(ctx)
|
||||||
|
const a: HostCordisInspectProviderRegistration = {
|
||||||
|
manifest: {
|
||||||
|
id: 'X', description: 'd',
|
||||||
|
methods: [{ name: 'a', description: 'a', inputSchema: {}, outputSchema: {} }],
|
||||||
|
},
|
||||||
|
async query() { return {} },
|
||||||
|
}
|
||||||
|
const b: HostCordisInspectProviderRegistration = {
|
||||||
|
manifest: {
|
||||||
|
id: 'X', description: 'd',
|
||||||
|
methods: [
|
||||||
|
{ name: 'a', description: 'a', inputSchema: {}, outputSchema: {} },
|
||||||
|
{ name: 'b', description: 'b', inputSchema: {}, outputSchema: {} },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
async query() { return {} },
|
||||||
|
}
|
||||||
|
svc.register(a)
|
||||||
|
expect(() => svc.register(b)).toThrow('already registered')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -912,6 +912,19 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'openRouterUsage',
|
||||||
|
summary: 'OpenRouter usage + balance gateway (`ctx.openRouterUsage`).',
|
||||||
|
description: 'OpenRouter usage + balance gateway (`ctx.openRouterUsage`).',
|
||||||
|
methods: [
|
||||||
|
{
|
||||||
|
signature: '@Remote(\'snapshot\') snapshot(): OpenRouterBalance',
|
||||||
|
description: 'The latest known account snapshot.',
|
||||||
|
parameters: [],
|
||||||
|
returns: 'a fresh copy of the cached balance.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'permissionPresets',
|
key: 'permissionPresets',
|
||||||
summary: 'Owns the deployment\'s permission presets and their write path.',
|
summary: 'Owns the deployment\'s permission presets and their write path.',
|
||||||
@@ -3479,6 +3492,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
|||||||
name: 'OneShotSubagentDescriptorData',
|
name: 'OneShotSubagentDescriptorData',
|
||||||
declaration: 'export interface OneShotSubagentDescriptorData extends SubagentDescriptorBase {\n readonly mode: \'one-shot\';\n readonly label?: string;\n}',
|
declaration: 'export interface OneShotSubagentDescriptorData extends SubagentDescriptorBase {\n readonly mode: \'one-shot\';\n readonly label?: string;\n}',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'OpenRouterBalance',
|
||||||
|
declaration: 'export interface OpenRouterBalance {\n balanceUsd: number | null;\n label: string | null;\n usageTokens: number | null;\n limitTokens: number | null;\n isFreeTier: boolean | null;\n updatedAt: number | null;\n currency: \'USD\';\n}',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'PermissionSelect',
|
name: 'PermissionSelect',
|
||||||
declaration: 'export interface PermissionSelect {\n options: PresetOption[];\n currentValue: string;\n}',
|
declaration: 'export interface PermissionSelect {\n options: PresetOption[];\n currentValue: string;\n}',
|
||||||
|
|||||||
51
packages/extensions/tool-lab/package.json
Normal file
51
packages/extensions/tool-lab/package.json
Normal file
@@ -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:^"
|
||||||
|
}
|
||||||
|
}
|
||||||
129
packages/extensions/tool-lab/src/comfy.ts
Normal file
129
packages/extensions/tool-lab/src/comfy.ts
Normal file
@@ -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<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ComfyHistoryEntry {
|
||||||
|
outputs?: Record<string, { images?: { filename?: string; subfolder?: string; type?: string }[] }>
|
||||||
|
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<string, unknown> {
|
||||||
|
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<string, unknown> = {
|
||||||
|
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<string> {
|
||||||
|
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<string, ComfyHistoryEntry>
|
||||||
|
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<string> {
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
85
packages/extensions/tool-lab/src/docling.ts
Normal file
85
packages/extensions/tool-lab/src/docling.ts
Normal file
@@ -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<void> {
|
||||||
|
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<string> {
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
62
packages/extensions/tool-lab/src/helpers.ts
Normal file
62
packages/extensions/tool-lab/src/helpers.ts
Normal file
@@ -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<typeof setTimeout> | 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<void> {
|
||||||
|
if (signal?.aborted) throw new Error('aborted')
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
const t = setTimeout(resolve, ms)
|
||||||
|
signal?.addEventListener('abort', () => {
|
||||||
|
clearTimeout(t)
|
||||||
|
resolve()
|
||||||
|
}, { once: true })
|
||||||
|
})
|
||||||
|
}
|
||||||
189
packages/extensions/tool-lab/src/index.ts
Normal file
189
packages/extensions/tool-lab/src/index.ts
Normal file
@@ -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<Config> = 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<Config>
|
||||||
|
|
||||||
|
/** 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)
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
29
packages/extensions/tool-lab/src/invariant.ts
Normal file
29
packages/extensions/tool-lab/src/invariant.ts
Normal file
@@ -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))
|
||||||
84
packages/extensions/tool-lab/src/whish.ts
Normal file
84
packages/extensions/tool-lab/src/whish.ts
Normal file
@@ -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<WhishperTranscription> {
|
||||||
|
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<string> {
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
60
packages/extensions/tool-lab/tests/tool-lab.spec.ts
Normal file
60
packages/extensions/tool-lab/tests/tool-lab.spec.ts
Normal file
@@ -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<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reinterpret the workflow builder's JSON-serializable output for assertions. */
|
||||||
|
function wf(prompt: string, overrides: Record<string, unknown> = {}): Record<string, WorkflowNode> {
|
||||||
|
return buildWorkflow({ prompt, ...(overrides as object) }) as Record<string, WorkflowNode>
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
28
packages/extensions/tool-lab/tsconfig.json
Normal file
28
packages/extensions/tool-lab/tsconfig.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -57,7 +57,7 @@ import {
|
|||||||
truncateUnicodeCodePoints,
|
truncateUnicodeCodePoints,
|
||||||
} from './api/session-search.ts'
|
} from './api/session-search.ts'
|
||||||
// Type-only: resolves `ctx.get('sessionProjections')` to the projection registry.
|
// 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.
|
// Type-only: resolves `ctx.get('tasks')` to the background job registry.
|
||||||
import type {} from '@deepseek-ai/dsh-jobs'
|
import type {} from '@deepseek-ai/dsh-jobs'
|
||||||
import type { JobSnapshot } 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. */
|
/** Projection baseline for a detached history tail without Agent activation. */
|
||||||
function detachedProjectionsFor(
|
function detachedProjectionsFor(
|
||||||
ctx: Context,
|
ctx: Context,
|
||||||
|
header: SessionHeader,
|
||||||
events: readonly SessionEvent[],
|
events: readonly SessionEvent[],
|
||||||
): SessionProjectionsBlock | undefined {
|
): SessionProjectionsBlock | undefined {
|
||||||
const registry = ctx.get('sessionProjections')
|
const registry = ctx.get('sessionProjections')
|
||||||
if (registry === undefined) return undefined
|
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,
|
includeProjections: boolean,
|
||||||
): { events: SessionEvent[]; projections?: SessionProjectionsBlock } {
|
): { events: SessionEvent[]; projections?: SessionProjectionsBlock } {
|
||||||
if (source.kind === 'detached') {
|
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 } }
|
return { events: source.events, ...projections === undefined ? {} : { projections } }
|
||||||
}
|
}
|
||||||
const events = [...source.session.events]
|
const events = [...source.session.events]
|
||||||
@@ -2632,7 +2633,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
|||||||
header = inspected.meta
|
header = inspected.meta
|
||||||
events = inspected.events
|
events = inspected.events
|
||||||
projections = beforeSeq === undefined
|
projections = beforeSeq === undefined
|
||||||
? subagentHistoryProjections(ctx, childSessionId, () => detachedProjectionsFor(ctx, inspected.events))
|
? subagentHistoryProjections(ctx, childSessionId, () => detachedProjectionsFor(ctx, inspected.meta, inspected.events))
|
||||||
: undefined
|
: undefined
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
if (signal?.aborted) {
|
if (signal?.aborted) {
|
||||||
|
|||||||
@@ -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).
|
* @returns harness counts; cache fields appear only when non-zero (pi-ai reports zeros, not absence).
|
||||||
*/
|
*/
|
||||||
export function mapUsage(usage: PiUsage): TokenUsage {
|
export function mapUsage(usage: PiUsage): TokenUsage {
|
||||||
|
const usageRecord = usage as unknown as Record<string, unknown>
|
||||||
|
const rawCost = usageRecord['cost'] ?? usageRecord['total_cost']
|
||||||
|
const costUsd = typeof rawCost === 'number' && Number.isFinite(rawCost) && rawCost >= 0
|
||||||
|
? rawCost
|
||||||
|
: undefined
|
||||||
return {
|
return {
|
||||||
inputTokens: usage.input,
|
inputTokens: usage.input,
|
||||||
outputTokens: usage.output,
|
outputTokens: usage.output,
|
||||||
...usage.cacheRead > 0 ? { cacheReadTokens: usage.cacheRead } : {},
|
...usage.cacheRead > 0 ? { cacheReadTokens: usage.cacheRead } : {},
|
||||||
...usage.cacheWrite > 0 ? { cacheWriteTokens: usage.cacheWrite } : {},
|
...usage.cacheWrite > 0 ? { cacheWriteTokens: usage.cacheWrite } : {},
|
||||||
|
...costUsd !== undefined ? { costUsd } : {},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user