Merge remote-tracking branch 'origin/master' into feature/workspace-picker-composer

This commit is contained in:
NI0317
2026-08-11 11:43:48 +08:00
120 changed files with 1682 additions and 393 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/README.md
README.md: 19d6e5ba7b554f59bd66e213f8a53389761fc735
README.zh.md: 17f58a2922e9019af054b0dccb6c4d9199fd1a9d
README.md: eb7df95bde10dafd7afcb168d30c9dda90296687
README.zh.md: 03cd02510267d3abdd414bed6ec1f42773d0811a

View File

@@ -52,7 +52,6 @@ Groups hold `packages/<group>/<pkg>/`; names stay `@deepseek-ai/dsh-<pkg>`. **Gr
| [`boot/`](boot/README.md) | Shared app-bin boot glue | Product — stable surface |
| [`host/`](host/README.md) | Web-GUI host half: API gateway + HTTP route server | Product — stable surface |
| [`client/`](client/README.md) | Web-GUI browser half: shell, wire, object services, slots, `ui-*` plugins | Product — stable surface |
| [`experimental/`](experimental/README.md) | Prototypes and internal plugins | Unreleased |
| [`examples/`](examples/README.md) | Demo bundles (agent-spine + CLI/ACP/JSON-RPC bins) leaves load | Support — example infra |
| [`support/`](support/README.md) | Support infrastructure (testkits, invariants, replay, Loader smokes) | Support — lower compatibility expectations |
| [`util/`](util/README.md) | Low-level zero-dependency utilities shared across groups (`Branded<B>`, Harness home/path helpers, timeout, retention) | Support — small, stable, harness-dep-free |

View File

@@ -52,7 +52,6 @@ npm scope 为 `@deepseek-ai/dsh-*`Cordis `Service` 子类和函数插件通
| [`boot/`](boot/README.md) | 共享的 app bin 启动粘合层 | 产品:稳定接口 |
| [`host/`](host/README.md) | web GUI 宿主半侧API 网关 + HTTP 路由服务器 | 产品:稳定接口 |
| [`client/`](client/README.md) | web GUI 浏览器半侧shell、协议层、对象服务、slot、`ui-*` 插件 | 产品:稳定接口 |
| [`experimental/`](experimental/README.md) | 原型和内部插件 | 未发布 |
| [`examples/`](examples/README.md) | 演示组合包agent-spine + CLI/ACP/JSON-RPC bin由叶节点加载 | 支持:示例基础设施 |
| [`support/`](support/README.md) | 支持基础设施testkit、不变式、回放、Loader 冒烟测试) | 支持:兼容性预期较低 |
| [`util/`](util/README.md) | 组间共享的低层零依赖工具(`Branded<B>`、Harness home路径辅助函数、超时、保留策略 | 支持:小型、稳定、无 harness 依赖 |

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/core/session/README.md
README.md: db477d94037d3463870fc8e66ea35d5e607fb6fe
README.zh.md: 1ce1e823a7e0fdbcf7b6898764a89c52b74adf6a
README.md: 57569e9c0dbfa7cb696e3a561a9ff108c2ac981f
README.zh.md: 16629dc70c79ca838ba7088aeafcc5b38b124f87

View File

@@ -76,10 +76,11 @@ Also defines `TurnEndReasonMap`, the merge-extensible `kind`-tagged sum type for
An interrupted live turn ends with `{ kind: 'aborted', reason: AgentCancelCause }`, preserving the typed cancellation cause in the durable transcript. Persistence imports the coarse aborted outcome from the supported older format as `{ kind: 'aborted', reason: { kind: 'legacy' } }`, because that record did not retain its caller. A turn failure carries `{ kind: 'error', error }`; crash recovery alone synthesizes `{ kind: 'interrupted' }`.
Every `SessionEvent` carries two optional top-level fields (structural metadata):
Every `SessionEvent` carries three optional top-level fields (structural metadata):
- `sourceEventSeqs?: number[]` — seq numbers of earlier events cited as sources (e.g., the `assistant/chunk` seqs behind an `assistant/message`, or the shadowed entries behind a compaction replacement entry). On `assistant/message`, a present `[]` records a known empty provider stream, while omission means a legacy or foreign event did not record the source stream; other surface events require a non-empty list when this field is present.
- `surfaceOp?: SurfaceOp` — how this event entered the surface. Absent for non-surface events (boundaries, chunks, usage, errors).
- `ignorable?: true` — marks an event a reader may safely skip when it does not recognize the type; absent means required, so an unknown-type event refuses session reconstruction ([mechanism](../../../.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md)).
### Metadata types (`types.ts`)
@@ -139,5 +140,5 @@ Logging causes no invalidation, and exact reconstruction preserves request-prefi
- **Session branching/tree** (pi-style entry tree) — deferred unless needed beyond boundary-based `fork()`.
- **`fork()` cuts only at stable boundaries of live sessions** — the selected prefix must end outside an open turn and the source must be in the store; forking a persisted-but-unloaded session is excluded from the [fork API](../../../.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md).
- **`SESSION_FORMAT_VERSION` stays pinned at `0`** — pre-release, no broad compatibility implied: `Session` accepts only current seed shapes and a backend rejects any other version. Narrow storage import upgrades belong to the persistence boundary ([policy](../../../AGENTS.md), [pre-identity message recovery](../../../.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md)).
- **`SESSION_FORMAT_VERSION` stays pinned at `0`** — pre-release, no broad compatibility implied: `Session` accepts only current seed shapes, and a backend refuses any other version naming the direction (newer: "written by a newer harness — upgrade"; older: no upgrade path ships yet). Unknown event types refuse the same way unless marked `ignorable` in the envelope; the versioning mechanism is the [session-log-version-mechanism note](../../../.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md). Narrow storage import upgrades belong to the persistence boundary ([policy](../../../AGENTS.md), [pre-identity message recovery](../../../.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md)).
- **`TurnEndReasonMap` omits the ACP-named `refusal` / `max_turn_requests` variants** — producer-gated: they land when an adapter or the loop first emits them.

View File

@@ -76,10 +76,11 @@
被中断的实时轮次以 `{ kind: 'aborted', reason: AgentCancelCause }` 结束,在持久 transcript文本记录中保留类型化取消原因。持久化会将受支持旧格式中的粗粒度中止结果导入为 `{ kind: 'aborted', reason: { kind: 'legacy' } }`,因为该记录没有保留调用方。轮次失败携带 `{ kind: 'error', error }`;只有崩溃恢复会合成 `{ kind: 'interrupted' }`
每个 `SessionEvent` 都有个可选顶层字段(结构元数据):
每个 `SessionEvent` 都有个可选顶层字段(结构元数据):
- `sourceEventSeqs?: number[]`:被引用为来源的较早事件 seq例如 `assistant/message` 引用的 `assistant/chunk` seq或压缩替换条目引用的已遮蔽条目。对于 `assistant/message`,存在的 `[]` 表示已知提供方流为空;省略则表示旧版或外部事件没有记录源流。其他 surface 事件若有此字段,则要求非空列表。
- `surfaceOp?: SurfaceOp`:事件进入 surface 的方式。非 surface 事件(边界、分片、用量、错误)不含该字段。
- `ignorable?: true`:标记读取器在不认识事件类型时可以安全跳过该事件;缺失表示必需,不认识的事件类型会使会话重建被拒绝([机制](../../../.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md))。
### 元数据类型(`types.ts`
@@ -139,5 +140,5 @@
- **会话分支/树**pi 风格条目树):除非需要超越基于边界的 `fork()` 能力,否则暂缓。
- **`fork()` 仅在实时会话的稳定边界处切分**:所选前缀结束时不得有开放轮次,且源会话必须位于存储中;[fork API](../../../.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md) 不支持对已持久化但未加载的会话进行 fork。
- **`SESSION_FORMAT_VERSION` 固定为 `0`**:预发布阶段不承诺广泛兼容性;`Session` 只接受当前 seed 形状,后端拒绝其他任何版本。范围受限的存储导入升级应由持久化边界负责([政策](../../../AGENTS.md)、[消息标识机制引入前的消息恢复](../../../.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md))。
- **`SESSION_FORMAT_VERSION` 固定为 `0`**:预发布阶段不承诺广泛兼容性;`Session` 只接受当前 seed 形状,后端拒绝其他任何版本并说明方向(更新的版本提示"由更新的 harness 写入,请升级";更旧的版本说明尚无升级路径)。不认识的事件类型同样被拒绝,除非信封带 `ignorable` 标记;版本机制见 [session-log 版本机制 Agent Note](../../../.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md)。范围受限的存储导入升级应由持久化边界负责([政策](../../../AGENTS.md)、[消息标识机制引入前的消息恢复](../../../.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md))。
- **`TurnEndReasonMap` 不含 ACPAgent Client Protocol命名的 `refusal``max_turn_requests` 变体**:受生产方约束;只有当适配器或循环首次产生这些变体时才加入。

View File

@@ -32,6 +32,7 @@ export type { ChunkRow, StorageRecord } from './chunk-rows.ts'
export type { SessionSurface, SurfaceFoldReplacement, SurfaceFoldResult } from './surface.ts'
export { deriveEventMessage, foldSurface, isAppendSurfaceEvent, isReplacementSurfaceEvent, isSurfaceEvent, isSurfaceEligibleType } from './surface.ts'
export { canonicalHeader, foldRequestHeader, headerEquals } from './request-header.ts'
export { KNOWN_SESSION_EVENT_TYPES } from './known-event-types.ts'
/**
* Find the latest closed turn that entered at least one model step, ignoring
@@ -243,6 +244,7 @@ function assertSessionEventEnvelope(value: Record<string, unknown>, index: numbe
case 'data':
case 'surfaceOp':
case 'sourceEventSeqs':
case 'ignorable':
break
default:
throw new Error(`seed event at index ${index} has an invalid event envelope`)
@@ -254,7 +256,8 @@ function assertSessionEventEnvelope(value: Record<string, unknown>, index: numbe
if (typeof type !== 'string'
|| typeof seq !== 'number' || !Number.isSafeInteger(seq) || seq < 0
|| typeof time !== 'number' || !Number.isSafeInteger(time)
|| event['data'] === undefined) {
|| event['data'] === undefined
|| (event['ignorable'] !== undefined && event['ignorable'] !== true)) {
throw new Error(`seed event at index ${index} has an invalid event envelope`)
}
switch (type) {

View File

@@ -0,0 +1,59 @@
/**
* GENERATED by `scripts/gen-persistence-catalog.ts` — do not edit by hand; run
* `pnpm run gen-persistence-catalog` to regenerate (verified fresh by
* `pnpm run verify-persistence-catalog`, part of `doc-sync`).
* @module @deepseek-ai/dsh-session/known-event-types
*/
/**
* Every `SessionEventMap` member declared in this repository — the event
* vocabulary this build understands. The persistence read path refuses to
* interpret a log containing a type outside this set unless the event
* carries the envelope's `ignorable` marker (see `SessionEvent.ignorable`
* in `./types.ts`): such a log was likely written by a newer harness, and
* silently skipping a required event would reconstruct a wrong session.
* Downstream (out-of-repo) plugin events are outside this list by
* construction; a registration surface for them is deferred until such a
* consumer exists.
*/
export const KNOWN_SESSION_EVENT_TYPES: ReadonlySet<string> = new Set([
'agent-preset/selected',
'agent/inbox/spliced',
'approval/asked',
'approval/decided',
'approval/policy',
'assistant/chunk',
'assistant/message',
'command/done',
'command/run',
'compact/end',
'compact/prune',
'compact/start',
'compact/summary',
'feedback/record',
'goal/change',
'hook/invoked',
'hook/result',
'llm/retry',
'llm/retry-started',
'permission/preset',
'plan/mode',
'request/context',
'request/header',
'sandbox/mode',
'session/end-seed',
'session/title',
'session/title-llm-request',
'step/end',
'step/start',
'subagent/descriptor',
'todo/write',
'tool/call',
'tool/code-dispatch',
'tool/code-dispatch-start',
'tool/result',
'turn/end',
'turn/start',
'user/message',
'web/deepseek-search-llm-request',
])

View File

@@ -30,8 +30,23 @@ export function SessionId(id: string): SessionId {
* and enforced by every persistence backend on load. The single source of truth for the
* version — write sites and the load-time check all read it.
* While the harness is unreleased it is pinned at `0`: no compatibility is
* implied, incompatible logs are rejected, and no migration is provided. A
* monotonic version policy starts with the first tagged release.
* implied, incompatible logs are rejected, and no migration is provided.
*
* The version is a single monotonic integer with no major/minor split. Whether
* a bump is needed is decided by what the WRITER emits, never by what a newer
* reader can accept: bump exactly when an older runtime could no longer handle
* a new log with full semantic correctness ("parses without error" is not
* correctness — silently skipping content that shapes reconstruction is a
* wrong read). Only structural changes reach that bar: the header shape, the
* {@link SessionEvent} envelope, core event semantics, or the surface
* mechanism (the {@link SurfaceEventType} set and {@link SurfaceOp} variants).
* Adding an ordinary event type does not bump — the per-event
* {@link SessionEvent.ignorable} guard covers vocabulary growth instead. When
* in doubt, bump: a near-identity upgrade step is almost free, a missed bump
* makes older runtimes read new logs wrong silently. The full mechanism
* (upgrade-step chain, in-memory view conversion, migrate-on-continue) is
* recorded in the session-log-version-mechanism Agent Note
* (`.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md`).
*/
export const SESSION_FORMAT_VERSION = 0
@@ -389,6 +404,17 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
/** Unix epoch milliseconds. */
time: number
data: SessionEventMap[K]
/**
* Marks an event a reader may safely skip when it does not recognize
* `type`. Absent means required: a reader meeting an unrecognized type
* without this marker MUST refuse to reconstruct the session instead of
* silently dropping the event, because an unrecognized required event may
* change how the rest of the log is interpreted. A writer sets `true` only
* on purely informational records whose loss cannot affect reconstruction;
* defaulting to required means a forgotten marker over-refuses (an
* inconvenience) rather than silently resuming a gutted session.
*/
ignorable?: true
} & (K extends SurfaceEventType ? {
/**
* Seq numbers of earlier events that this event cites as sources

View File

@@ -1090,12 +1090,20 @@ describe('Session', () => {
{ ...base, time: '1' },
{ ...base, time: 0.5 },
{ type: base.type, seq: base.seq, time: base.time },
{ ...base, ignorable: false },
{ ...base, ignorable: 'yes' },
]
for (const [index, event] of cases.entries()) {
expect(() => Session.create(SessionId(`bad-envelope-${index}`), [event as SessionEvent]))
.toThrow(/invalid event envelope/)
}
// `ignorable: true` is the one accepted marker value (unknown-type skip contract).
const marked = Session.create(SessionId('ignorable-envelope'), [
{ ...base, ignorable: true } as SessionEvent,
])
expect(marked.events[0]?.ignorable).toBe(true)
})
})

View File

@@ -1,11 +0,0 @@
# AGENTS.md — Experimental and internal packages
These rules supplement the [package rules](../AGENTS.md). The [experimental and internal package group decision](../../.agents/notes/implemented/architecture/2026-07-28-experimental-plugin-package-group.md) owns the rationale.
- All Cordis plugin packages whose full public contract is experimental or internal-only belong here. An experimental option inside an otherwise stable package stays in that package's product-role group.
- Use this directory to share engineering and product-manager prototypes across the team so others can discover, run, review, and extend them against the real plugin graph.
- Official releases exclude this directory. A package enters a release only after moving to its product-role group; do not add packages here to release manifests or bundles.
- Experimental packages carry no stability, compatibility, migration, or support promise. Internal-only packages may define contracts for a limited set of internal callers and callees but make no public release promise.
- Experimental or internal-only status never relaxes repository engineering, security, documentation, lifecycle, testing, or snapshot requirements.
- Release packages must not take runtime dependencies on packages here. Examples may; every other runtime dependent is also experimental or internal-only and belongs here. Tests may use them as development dependencies.
- Promotion moves a package to its product-role group without renaming its `@deepseek-ai/dsh-*` package. Require explicit review of its public contract, limitations, test evidence, and a named owner accepting stable-package obligations.

View File

@@ -1,6 +0,0 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/experimental/README.md
README.md: db39af8bb1b1bcfd257e16e4ad1dd112f604ffb1
README.zh.md: fc5942190a354668164b41b99e83b52b14d88f18

View File

@@ -1,7 +0,0 @@
# experimental/ — experimental and internal packages
English | [中文](README.zh.md)
This group hosts team-shared engineering and product-manager prototypes plus internal-only Cordis plugins. It is excluded from official releases; packages move to their product-role group before release.
No packages live here yet. The [subtree rules](AGENTS.md) define the no-warranty, dependency, and promotion boundaries.

View File

@@ -1,7 +0,0 @@
# experimental/:实验性与内部专用包
[English](README.md) | 中文
该分组容纳工程人员与产品经理在团队内共享的原型,以及内部专用 Cordis 插件。该分组不纳入官方发布版本;包在发布前移入对应的产品角色分组。
该分组尚未包含任何包。[子树规则](AGENTS.md)界定不作保证、依赖关系和提升机制的边界。

View File

@@ -45,6 +45,7 @@ export const sessionEventSchema = z.object({
data: z.unknown(),
sourceEventSeqs: z.array(z.number()).optional(),
surfaceOp: z.unknown().optional(),
ignorable: z.literal(true).optional(),
}) as unknown as z.ZodType<SessionEvent>
/** SessionSummary row of session.list (`projections` reuses the history block's shape and schema). */

View File

@@ -26,6 +26,8 @@
* array; `FAKE_MESSAGE_WITHOUT_DATA`: assistant/message with no data
* member; `FAKE_MALFORMED_REASON`: `session.finished` reason is a bare
* string (wire-validation probes).
* - `FAKE_EMPTY_MESSAGE`: the turn streams a text chunk, then records an empty
* assistant/message for a usage-only max-tokens step.
* - `FAKE_HANG_INIT`: never answer `initialize` (mid-handshake cancel probe).
* - `FAKE_INIT_READY` + `FAKE_INIT_GO`: touch the READY file when `initialize`
* arrives, then poll for the GO file before answering (deterministic
@@ -117,7 +119,9 @@ function runTurn(sessionId: string): void {
message: {
id: `fake-assistant-${seq}`,
role: 'assistant',
content: [{ type: 'text', text }],
// Model the usage-only message recorded after a max-tokens step that
// assembled no output blocks.
content: env.FAKE_EMPTY_MESSAGE !== undefined ? [] : [{ type: 'text', text }],
source: { kind: 'model', provider: 'fake', model: 'fake' },
},
})

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/scaffold/protocol/README.md
README.md: 88a48957d0d44cec9f776d31eab7d25bd353de5f
README.zh.md: 6618d8838a00f945c79d7ec24b1e7491df08a3f1
README.md: 082a890454f900aec51df123669f28814d39d601
README.zh.md: d9b8460e51b5313f4c3a8ac66471e8cd39142430

View File

@@ -22,7 +22,7 @@ The shared wire protocol for the DeepSeek Harness SDK runtime: one newline-delim
| server→client | `subagent.started` | `SubagentStartedNotification` |
| server→client | `subagent.finished` | `SubagentFinishedNotification` (in-process runs only) |
`HarnessSdkRequestMap` and `HarnessSdkNotificationMap` index these by method name. `SessionPromptResult.messageId` identifies the queued `UserMessage`; it does not identify a later assistant message, turn ending, or prompt result. Clients combine the open-ended `session.event` stream with agent-wide `session.status` according to their own activity ownership. `InitializeParams.maxTokens` is an optional positive safe integer that caps each conversation-model output for SDK-created agents and their in-process descendants; omission allows the selected adapter's exact-model default to apply, or otherwise preserves provider behavior. The notification payload types depend on `SessionEvent` (`dsh-session`), `ContentBlock` (`dsh-llm`), and `SubagentStopReason` (`dsh-subagent`) — the protocol streams full session-log envelopes, so the session vocabulary is part of the wire contract. `serverInfo.name` stays the wire-stable `deepseek-harness-sdk-runtime`.
`HarnessSdkRequestMap` and `HarnessSdkNotificationMap` index these by method name. `SessionPromptResult.messageId` identifies the queued `UserMessage`; it does not identify a later assistant message, turn ending, or prompt result. Clients combine the open-ended `session.event` stream with agent-wide `session.status` according to their own activity ownership. `SubagentFinishedNotification.lastAssistantMessage` contains the child's last non-empty assistant message or, when no such message exists, its accumulated assistant text; the field is absent when the child produced neither. `InitializeParams.maxTokens` is an optional positive safe integer that caps each conversation-model output for SDK-created agents and their in-process descendants; omission allows the selected adapter's exact-model default to apply, or otherwise preserves provider behavior. The notification payload types depend on `SessionEvent` (`dsh-session`), `ContentBlock` (`dsh-llm`), and `SubagentStopReason` (`dsh-subagent`) — the protocol streams full session-log envelopes, so the session vocabulary is part of the wire contract. `serverInfo.name` stays the wire-stable `deepseek-harness-sdk-runtime`.
## Model Experience

View File

@@ -22,7 +22,7 @@ DeepSeek Harness SDK 运行时的共享协议格式wire format一个按
| server→client | `subagent.started` | `SubagentStartedNotification` |
| server→client | `subagent.finished` | `SubagentFinishedNotification`(仅进程内运行) |
`HarnessSdkRequestMap``HarnessSdkNotificationMap` 按方法名索引这些类型。`SessionPromptResult.messageId` 标识已排队的 `UserMessage`;它不标识后续的助手消息、轮次结束或提示词结果。客户端根据自己对活动区间的所有权,组合持续开放的 `session.event` 流与 agent 级的 `session.status``InitializeParams.maxTokens` 是可选的正的安全整数,用于限制 SDK 创建的 agent 及其进程内后代的每次对话模型输出;省略时会应用所选适配器的确切模型默认值,否则提供方行为保持不变。通知载荷类型依赖 `SessionEvent``dsh-session`)、`ContentBlock``dsh-llm`)与 `SubagentStopReason``dsh-subagent`)——协议以完整会话日志封套进行流式传输,因此会话词汇是协议格式约定的一部分。`serverInfo.name` 的协议值固定为 `deepseek-harness-sdk-runtime`
`HarnessSdkRequestMap``HarnessSdkNotificationMap` 按方法名索引这些类型。`SessionPromptResult.messageId` 标识已排队的 `UserMessage`;它不标识后续的助手消息、轮次结束或提示词结果。客户端根据自己对活动区间的所有权,组合持续开放的 `session.event` 流与 agent 级的 `session.status``SubagentFinishedNotification.lastAssistantMessage` 包含子 agent 最后一条非空 assistant 消息;若不存在这类消息,则包含其累积的 assistant 文本;子 agent 两种输出均未产生时,该字段缺省。`InitializeParams.maxTokens` 是可选的正的安全整数,用于限制 SDK 创建的 agent 及其进程内后代的每次对话模型输出;省略时会应用所选适配器的确切模型默认值,否则提供方行为保持不变。通知载荷类型依赖 `SessionEvent``dsh-session`)、`ContentBlock``dsh-llm`)与 `SubagentStopReason``dsh-subagent`)——协议以完整会话日志封套进行流式传输,因此会话词汇是协议格式约定的一部分。`serverInfo.name` 的协议值固定为 `deepseek-harness-sdk-runtime`
## 模型体验

View File

@@ -85,7 +85,7 @@ export interface SubagentFinishedNotification {
status: SdkRunStatus
/** The provider-reported stop reason. */
stopReason: SubagentStopReason
/** The child's final assistant message, when it produced one. */
/** The child's selected assistant output; absent when the child produced none. */
lastAssistantMessage?: ContentBlock[]
}

View File

@@ -106,6 +106,8 @@ describe.skipIf(!existsSync(jsonrpcBundle))('dsh-jsonrpc BUILT scope carrier', (
})
expect(stderr).not.toContain('listener threw')
// A result without output omits lastAssistantMessage from the wire; it
// never sends `[]`.
expect(JSON.parse(stdout) as unknown).toEqual([{
method: 'subagent.finished',
params: {
@@ -115,7 +117,6 @@ describe.skipIf(!existsSync(jsonrpcBundle))('dsh-jsonrpc BUILT scope carrier', (
childSessionId: 'built-child',
status: 'ok',
stopReason: 'completed',
lastAssistantMessage: [],
},
}])
})

View File

@@ -736,6 +736,8 @@ describe('HarnessSdkServer', () => {
stopReason: 'error',
})
// A result without output omits lastAssistantMessage from the wire; it
// never sends `[]`.
expect(transport.notifications).toContainEqual({
method: 'subagent.finished',
params: {
@@ -745,7 +747,6 @@ describe('HarnessSdkServer', () => {
childSessionId: 'fallback-child-session',
status: 'ok',
stopReason: 'max-tokens',
lastAssistantMessage: [],
},
})
expect(transport.notifications).toContainEqual({

View File

@@ -2633,7 +2633,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'SessionEvent',
declaration: 'export type SessionEvent<T extends SessionEventType = SessionEventType> = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n}[T];',
declaration: 'export type SessionEvent<T extends SessionEventType = SessionEventType> = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n ignorable?: true;\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n}[T];',
},
{
name: 'SessionEventMap',

View File

@@ -9,8 +9,9 @@
*/
import { join } from 'node:path'
import { decodeStorageRecord, packChunkRuns } from '@deepseek-ai/dsh-session'
import { decodeStorageRecord, packChunkRuns, SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session'
import type { SessionEvent, SessionHeader, SessionId, StorageRecord } from '@deepseek-ai/dsh-session'
import { SessionFormatUnsupportedError, sessionFormatVersionRefusal } from '@deepseek-ai/dsh-session-persistence'
/** Physical encoding selected for JSONL session artifacts. */
export type JsonlCompression = 'zstd' | 'none'
@@ -229,6 +230,22 @@ interface SessionLogScan {
}
/** Parse one complete header record supplied independently from event rows. */
/**
* Refuse a header carrying a format version this build does not read BEFORE
* validating the current header shape or decoding any event row: a future
* format need not satisfy today's structural checks at all, and its user must
* see "upgrade the harness", never "corrupt session log".
* @param parsed - the JSON-parsed first line of a session artifact.
*/
function refuseForeignFormatVersion(parsed: unknown): void {
if (typeof parsed !== 'object' || parsed === null) return
const { version, id } = parsed as { version?: unknown; id?: unknown }
if (typeof version !== 'number' || version === SESSION_FORMAT_VERSION) return
throw new SessionFormatUnsupportedError(
sessionFormatVersionRefusal(typeof id === 'string' ? id : String(id), version),
)
}
function parseHeaderRecord(record: Buffer): SessionHeader {
if (record.length === 0 || record.at(-1) !== 0x0A || record.indexOf(0x0A) !== record.length - 1) {
throw new Error('empty or header-less session log')
@@ -239,6 +256,7 @@ function parseHeaderRecord(record: Buffer): SessionHeader {
} catch {
throw new Error('corrupt session log: header line is not valid JSON')
}
refuseForeignFormatVersion(parsed)
if (!isHeaderLine(parsed)) {
throw new Error('corrupt session log: first line is not a session header')
}

View File

@@ -16,7 +16,7 @@ import { scheduler } from 'node:timers/promises'
import { randomBytes } from 'node:crypto'
import {
DEFAULT_PREPARED_SESSION_CACHE_SIZE, DEFAULT_WRITE_BATCH_MAX_DELAY_MS, MAX_WRITE_BATCH_DELAY_MS,
SessionPersistence, SessionPersistenceRevision, PersistenceCoordinator,
SessionPersistence, SessionPersistenceRevision, PersistenceCoordinator, SessionFormatUnsupportedError,
type PersistenceBackend, type SessionLocation, type SessionPersistenceSnapshot,
type SessionInspection, type SessionPersistenceRevision as PersistenceRevision, type StoredPrefix,
} from '@deepseek-ai/dsh-session-persistence'
@@ -256,19 +256,29 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
}
}
let prefix: Omit<StoredPrefix<JsonlTornMarker>, 'revision'>
if (this.compression === 'zstd') {
prefix = await this.readZstdPrefix(buffer, signal)
} else {
signal?.throwIfAborted()
const { meta, events, committedBytes } = scanLog(buffer)
signal?.throwIfAborted()
prefix = {
meta,
events,
...committedBytes < buffer.byteLength
? { tornMarker: { truncateTo: committedBytes, recoveredEvents: [] } }
: {},
try {
if (this.compression === 'zstd') {
prefix = await this.readZstdPrefix(buffer, signal)
} else {
signal?.throwIfAborted()
const { meta, events, committedBytes } = scanLog(buffer)
signal?.throwIfAborted()
prefix = {
meta,
events,
...committedBytes < buffer.byteLength
? { tornMarker: { truncateTo: committedBytes, recoveredEvents: [] } }
: {},
}
}
} catch (error: unknown) {
// A parse-time format refusal predates any SessionHeader, so the
// coordinator's locate-based enrichment cannot run; attach the artifact
// this read actually refused.
if (error instanceof SessionFormatUnsupportedError && error.location === undefined) {
throw new SessionFormatUnsupportedError(`${error.message} (raw log: ${path})`, { kind: 'jsonl', path })
}
throw error
}
signal?.throwIfAborted()
await this.assertStoredIdentity(path, prefix.meta, expectedId, signal)

View File

@@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import { appendFile, mkdtemp, mkdir, rm, readFile, writeFile, readdir, stat, symlink } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { isAbsolute, join, relative, resolve } from 'node:path'
import { dirname, isAbsolute, join, relative, resolve } from 'node:path'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import type { Session, SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
@@ -186,6 +186,76 @@ describe('SessionPersistenceJsonl: format helpers', () => {
})
await fiber.dispose()
})
it('refuses a structurally foreign future header as unsupported, not corrupt', async () => {
const absoluteRoot = await freshRoot()
const ctx = new Context()
await ctx.plugin(SessionStore)
const fiber = await ctx.plugin(SessionPersistenceJsonl, { root: absoluteRoot, compression: 'none' })
// A future format need not satisfy today's header shape at all (no
// createdAt, unknown fields): the version must be refused before shape
// validation, so the user sees the upgrade direction.
const id = SessionId('future-shape')
const path = rawLogPath(resolve(absoluteRoot), '/work', id)
await mkdir(dirname(path), { recursive: true })
await writeFile(path, `${JSON.stringify({ type: 'session', version: 42, id, futureOnly: true })}\n{"future":"row"}\n`)
const failure = await ctx.sessionPersistence.load(id).then(() => undefined, (error: unknown) => error as Error)
expect(failure?.name).toBe('SessionFormatUnsupportedError')
expect(failure?.message).toMatch(/written by a newer harness.*upgrade the harness/)
expect(failure?.message).toContain(`(raw log: ${path})`)
await fiber.dispose()
})
it('keeps a non-object header line a corruption, not a format refusal', async () => {
const absoluteRoot = await freshRoot()
const ctx = new Context()
await ctx.plugin(SessionStore)
const fiber = await ctx.plugin(SessionPersistenceJsonl, { root: absoluteRoot, compression: 'none' })
// Valid JSON that is no object carries no version to compare, so the
// version guard must pass it through to the corruption diagnostics.
const id = SessionId('scalar-header')
const path = rawLogPath(resolve(absoluteRoot), '/work', id)
await mkdir(dirname(path), { recursive: true })
await writeFile(path, '42\n')
const failure = await ctx.sessionPersistence.load(id).then(() => undefined, (error: unknown) => error as Error)
expect(failure?.name).not.toBe('SessionFormatUnsupportedError')
expect(failure?.message).toContain('first line is not a session header')
await fiber.dispose()
})
it('names a foreign-version header by its stringified non-string id', async () => {
const absoluteRoot = await freshRoot()
const ctx = new Context()
await ctx.plugin(SessionStore)
const fiber = await ctx.plugin(SessionPersistenceJsonl, { root: absoluteRoot, compression: 'none' })
// A future header's id field is as untrusted as the rest of its shape:
// the refusal must still name the session it read, not crash on the type.
const id = SessionId('numeric-id')
const path = rawLogPath(resolve(absoluteRoot), '/work', id)
await mkdir(dirname(path), { recursive: true })
await writeFile(path, `${JSON.stringify({ type: 'session', version: 42, id: 123 })}\n`)
const failure = await ctx.sessionPersistence.load(id).then(() => undefined, (error: unknown) => error as Error)
expect(failure?.name).toBe('SessionFormatUnsupportedError')
expect(failure?.message).toContain('session "123" uses log format v42')
await fiber.dispose()
})
it('points a format refusal at the raw log path', async () => {
const absoluteRoot = await freshRoot()
const ctx = new Context()
await ctx.plugin(SessionStore)
const fiber = await ctx.plugin(SessionPersistenceJsonl, { root: absoluteRoot, compression: 'none' })
const m = { ...meta('newer-format', '/work'), version: 7 }
await ctx.sessionPersistence.create(m)
await ctx.sessionPersistence.append(m.id, [
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
{ type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'completed' } } },
])
const failure = await ctx.sessionPersistence.load(m.id).then(() => undefined, (error: unknown) => error as Error)
expect(failure?.name).toBe('SessionFormatUnsupportedError')
expect(failure?.message).toContain(`(raw log: ${rawLogPath(resolve(absoluteRoot), '/work', m.id)})`)
await fiber.dispose()
})
})
describe('SessionPersistenceJsonl: durability and crash semantics', () => {

View File

@@ -28,15 +28,18 @@ import {
export { SCHEMA_VERSION } from './schema.ts'
/**
* Serialize an event's surface-metadata fields for SQL binding. Both fields are
* nullable TEXT columns — null when the event has no surface metadata (non-surface
* events, events written before surface support).
* Serialize an event's optional envelope fields for SQL binding. The surface
* fields are nullable TEXT columns — null when the event has no surface
* metadata (non-surface events, events written before surface support); the
* ignorable marker is a nullable INTEGER column — `1` iff the envelope carries
* `ignorable: true`.
*/
function surfaceBindings(event: SessionEvent): [string | null, string | null] {
function envelopeBindings(event: SessionEvent): [string | null, string | null, number | null] {
const se = event as SessionEvent<SurfaceEventType>
return [
se.sourceEventSeqs ? JSON.stringify(se.sourceEventSeqs) : null,
se.surfaceOp !== undefined ? JSON.stringify(se.surfaceOp) : null,
event.ignorable === true ? 1 : null,
]
}
@@ -225,7 +228,7 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers
if (row === undefined) return undefined
const meta = rowToMeta(row)
const eventRows = this.db
.prepare('SELECT seq, type, time, data, source_event_seqs, surface_op FROM events WHERE session_id = ? AND seq >= ? ORDER BY seq')
.prepare('SELECT seq, type, time, data, source_event_seqs, surface_op, ignorable FROM events WHERE session_id = ? AND seq >= ? ORDER BY seq')
.all(id, fromSeq) as unknown as EventRow[]
signal?.throwIfAborted()
const { preserved } = scanRows(eventRows, fromSeq)
@@ -247,7 +250,7 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers
const row = this.rowFor(id)
if (row !== undefined) {
const eventRows = this.db
.prepare('SELECT seq, type, time, data, source_event_seqs, surface_op FROM events WHERE session_id = ? ORDER BY seq')
.prepare('SELECT seq, type, time, data, source_event_seqs, surface_op, ignorable FROM events WHERE session_id = ? ORDER BY seq')
.all(id) as unknown as EventRow[]
snapshot = { row, eventRows }
}
@@ -279,14 +282,14 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers
async appendBatch(meta: SessionHeader, events: readonly SessionEvent[], isMaterialized: boolean): Promise<void> {
await this.ready
const insertEvent = this.db.prepare(
'INSERT INTO events (session_id, seq, type, time, data, source_event_seqs, surface_op) VALUES (?, ?, ?, ?, ?, ?, ?)',
'INSERT INTO events (session_id, seq, type, time, data, source_event_seqs, surface_op, ignorable) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
)
this.db.exec('BEGIN')
try {
if (!isMaterialized) this.writeRow(meta)
for (const event of events) {
const [surfaceSeqs, surfaceOp] = surfaceBindings(event)
insertEvent.run(meta.id, event.seq, event.type, event.time, JSON.stringify(event.data), surfaceSeqs, surfaceOp)
const [surfaceSeqs, surfaceOp, ignorable] = envelopeBindings(event)
insertEvent.run(meta.id, event.seq, event.type, event.time, JSON.stringify(event.data), surfaceSeqs, surfaceOp, ignorable)
}
this.db.prepare('UPDATE sessions SET revision = revision + 1 WHERE id = ?').run(meta.id)
this.db.exec('COMMIT')
@@ -310,11 +313,11 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers
}
if (closers.length > 0) {
const insertEvent = this.db.prepare(
'INSERT INTO events (session_id, seq, type, time, data, source_event_seqs, surface_op) VALUES (?, ?, ?, ?, ?, ?, ?)',
'INSERT INTO events (session_id, seq, type, time, data, source_event_seqs, surface_op, ignorable) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
)
for (const event of closers) {
const [surfaceSeqs, surfaceOp] = surfaceBindings(event)
insertEvent.run(meta.id, event.seq, event.type, event.time, JSON.stringify(event.data), surfaceSeqs, surfaceOp)
const [surfaceSeqs, surfaceOp, ignorable] = envelopeBindings(event)
insertEvent.run(meta.id, event.seq, event.type, event.time, JSON.stringify(event.data), surfaceSeqs, surfaceOp, ignorable)
}
}
if (tornMarker !== undefined || closers.length > 0) {

View File

@@ -17,7 +17,7 @@ import type { SessionEvent, SessionId, SessionHeader, SurfaceOp } from '@deepsee
* layout; orthogonal to a session's own `version` (which versions the EVENT
* vocabulary, stored per session in the `sessions` row).
*/
export const SCHEMA_VERSION = 14
export const SCHEMA_VERSION = 15
/** SQLite application id protecting unrelated databases from persistence writes. */
export const SESSION_PERSISTENCE_SQLITE_APPLICATION_ID = 0x44534850
@@ -55,6 +55,8 @@ export interface EventRow {
source_event_seqs: string | null
/** JSON-encoded `SurfaceOp` — how the event entered the surface, or null. */
surface_op: string | null
/** `1` iff the event carries the envelope's `ignorable: true` marker, else null. */
ignorable: number | null
}
/**
@@ -139,6 +141,7 @@ function configureDatabase(db: DatabaseSync, path: string, journalMode: JournalM
data TEXT NOT NULL,
source_event_seqs TEXT,
surface_op TEXT,
ignorable INTEGER,
PRIMARY KEY (session_id, seq)
) STRICT
`)
@@ -203,12 +206,14 @@ export function rowToEvent(row: EventRow): SessionEvent {
...row.source_event_seqs !== null ? { sourceEventSeqs: JSON.parse(row.source_event_seqs) as number[] } : {},
...row.surface_op !== null ? { surfaceOp: JSON.parse(row.surface_op) as SurfaceOp } : {},
}
const ignorableField = row.ignorable === 1 ? { ignorable: true as const } : {}
return {
type: row.type as SessionEvent['type'],
seq: row.seq,
time: row.time,
data: JSON.parse(row.data) as SessionEvent['data'],
...surfaceFields,
...ignorableField,
} as SessionEvent
}

View File

@@ -92,6 +92,7 @@ describe('scanRows', () => {
seq: e.seq, type: e.type, time: e.time, data: JSON.stringify(e.data),
source_event_seqs: se.sourceEventSeqs !== undefined ? JSON.stringify(se.sourceEventSeqs) : null,
surface_op: se.surfaceOp !== undefined ? JSON.stringify(se.surfaceOp) : null,
ignorable: e.ignorable === true ? 1 : null,
}
})
@@ -142,8 +143,8 @@ describe('scanRows', () => {
it('throws on an unparsable row inside the committed region', () => {
const withCorruptCommitted: EventRow[] = [
{ seq: 0, type: 'turn/start', time: 1, data: '{not json', source_event_seqs: null, surface_op: null }, // corrupt, sits before a turn/end
{ seq: 1, type: 'turn/end', time: 2, data: JSON.stringify({ turn: 1, reason: { kind: 'completed' } }), source_event_seqs: null, surface_op: null },
{ seq: 0, type: 'turn/start', time: 1, data: '{not json', source_event_seqs: null, surface_op: null, ignorable: null }, // corrupt, sits before a turn/end
{ seq: 1, type: 'turn/end', time: 2, data: JSON.stringify({ turn: 1, reason: { kind: 'completed' } }), source_event_seqs: null, surface_op: null, ignorable: null },
]
expect(() => scanRows(withCorruptCommitted)).toThrow(/unparsable committed event/)
})
@@ -151,7 +152,7 @@ describe('scanRows', () => {
it('tolerates an unparsable torn-tail row after the last turn/end', () => {
const withCorruptTail: EventRow[] = [
...rows(oneTurnLog()),
{ seq: 6, type: 'turn/start', time: 7, data: '{not json', source_event_seqs: null, surface_op: null }, // torn fragment, no committed turn/end after
{ seq: 6, type: 'turn/start', time: 7, data: '{not json', source_event_seqs: null, surface_op: null, ignorable: null }, // torn fragment, no committed turn/end after
]
const { preserved, tornFrom } = scanRows(withCorruptTail)
expect(preserved).toEqual(oneTurnLog())
@@ -658,7 +659,7 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => {
})
it('exposes the schema version constant', () => {
expect(SCHEMA_VERSION).toBe(14)
expect(SCHEMA_VERSION).toBe(15)
})
it('keeps the revision stable for an empty repair hook', async () => {
@@ -857,6 +858,7 @@ describe('surface field round-trip', () => {
data: JSON.stringify({ turn: 1, step: 1, content: [] }),
source_event_seqs: JSON.stringify([3, 5]),
surface_op: JSON.stringify('append'),
ignorable: null,
}
const event = rowToEvent(row)
expect((event as SurfaceEvent).sourceEventSeqs).toEqual([3, 5])
@@ -869,6 +871,7 @@ describe('surface field round-trip', () => {
data: JSON.stringify({ turn: 1, step: 1, content: [] }),
source_event_seqs: JSON.stringify([0, 1]),
surface_op: JSON.stringify({ op: 'replace', start: 0, end: 1 }),
ignorable: null,
}
const event = rowToEvent(row)
expect((event as SurfaceEvent).sourceEventSeqs).toEqual([0, 1])
@@ -879,10 +882,10 @@ describe('surface field round-trip', () => {
const rows: EventRow[] = [
{ seq: 0, type: 'user/message', time: 1,
data: JSON.stringify({ content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }),
source_event_seqs: null, surface_op: '{"op":"replace","start":0,"end":0}' },
source_event_seqs: null, surface_op: '{"op":"replace","start":0,"end":0}', ignorable: null },
{ seq: 1, type: 'turn/end', time: 2,
data: JSON.stringify({ turn: 1, reason: { kind: 'completed' } }),
source_event_seqs: null, surface_op: null },
source_event_seqs: null, surface_op: null, ignorable: 1 },
]
const { preserved } = scanRows(rows)
expect(preserved).toHaveLength(2)

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/session/session-persistence/README.md
README.md: 391548b1b896dca14cbe4f4ae55cf4180c4e0ac2
README.zh.md: 7213e1ee71ba418ffacc3685df371dcba33588a7
README.md: 324c00b3202bd136566137e1bd398b29d2ea4b82
README.zh.md: 2ef5e9a90f0323f8edf8fdc4f936c41ca7e08c70

View File

@@ -14,9 +14,9 @@ The persisted unit IS the existing `SessionEvent` (event-sourced model — the l
| `create(meta): Promise<void>` | Register a new session's metadata. MAY defer the physical write until the first `append` (lazy materialization). |
| `append(id, events): Promise<void>` | Durably persist a batch. Append-only; first event `seq` == stored next-seq after any repair; rejects non-JSON-serializable data naming the offending type. |
| `prepare(id, signal?): Promise<SessionPreparation>` | Reserve the exact unpublished Session used by resume. A coordinator reuses an earlier inspection when available, commits pending recovery, and releases an unpublished reservation back to its bounded cache on disposal. |
| `load(id): Promise<{ meta; events }>` | Return an immutable balanced logical log after converting supported older records from the same format version and committing cold recovery. A live load first flushes its snapshot and rejects while its turn is open; a cold load preserves an interrupted final turn and durably closes it with synthetic `tool/result`/`step/end?`/`turn/end {interrupted}` events. Only a torn tail fragment is dropped; committed corruption, malformed records, and unknown `version` reject. |
| `load(id): Promise<{ meta; events }>` | Return an immutable balanced logical log after converting supported older records from the same format version and committing cold recovery. A live load first flushes its snapshot and rejects while its turn is open; a cold load preserves an interrupted final turn and durably closes it with synthetic `tool/result`/`step/end?`/`turn/end {interrupted}` events. Only a torn tail fragment is dropped; committed corruption and malformed records reject as `SessionPersistenceCorruptionError`, while an unsupported format `version` or an event type unknown to this build (without the envelope's `ignorable` marker) refuses as `SessionFormatUnsupportedError`, naming the refusal direction and the raw log path when the backend keeps one artifact per session. |
| `inspect(id, signal?): Promise<{ meta; events }>` | Return an upgraded, validated, deeply frozen logical view without committing recovery or publishing a Session. A cold view receives in-memory synthetic recovery closers while its physical torn tail remains untouched; an already-live view is its current immutable snapshot and may contain an open turn. Coordinator-backed implementations retain the exact cold unpublished Session in a bounded LRU for later `prepare`, but discard and reload it when the stored revision changes. Same-id inspections share an in-flight read. |
| `readFrom(id, fromSeq, signal?): Promise<{ meta; events }>` | Return valid stored events with `seq >= fromSeq` without preparation caching, truncation, closers, or coordinator state. A `fromSeq` at or past the stored end returns an empty event list; a negative or non-safe-integer `fromSeq` rejects. Seek-capable backends (SQLite) read only the suffix unless converting a supported older record requires earlier records; sequential backends (JSONL) parse the whole artifact and skip forward. Intended for checkpoint consumers that apply only events after a stored sequence number. |
| `readFrom(id, fromSeq, signal?): Promise<{ meta; events }>` | Return valid stored events with `seq >= fromSeq` without preparation caching, truncation, closers, or coordinator state. A `fromSeq` at or past the stored end returns an empty event list; a negative or non-safe-integer `fromSeq` rejects. Seek-capable backends (SQLite) read only the suffix unless converting a supported older record requires earlier records; sequential backends (JSONL) parse the whole artifact and skip forward. Unknown-type refusal follows that access pattern: a seek read checks only the returned suffix, while the sequential fallback also refuses on an unknown required event below the window. Intended for checkpoint consumers that apply only events after a stored sequence number. |
| `list(signal?): Promise<SessionHeader[]>` | Lightweight listing from metadata, no full-log parse. The optional signal cancels backend listing work. A zero-event lazily-materialized session is absent from `list`. |
| `listSnapshots(signal?): Promise<SessionPersistenceSnapshot[]>` | Lightweight metadata plus an opaque branded per-log revision, without loading event logs. A revision stays equal while that log and its backing store are unchanged, changes after append or mutating load repair, and cannot collide solely because two stores use the same local counter. The optional signal requests cancellation of backend discovery work; first-party backends settle any started listing work before rejecting so an awaited call is quiescent. |

View File

@@ -14,9 +14,9 @@
| `create(meta): Promise<void>` | 注册新会话元数据。可以将物理写入延迟到第一次 `append`(延迟实体化)。 |
| `append(id, events): Promise<void>` | 持久保存一个批次。仅追加;任何修复后,第一个事件 `seq` == 已存储 next-seq非 JSON 可序列化数据会被拒绝,并命名违规类型。 |
| `prepare(id, signal?): Promise<SessionPreparation>` | 预留恢复所使用的那个未发布 Session。协调器会尽可能复用之前的检查结果、提交待处理恢复并在 dispose 时将未发布 reservation 释放回有界缓存。 |
| `load(id): Promise<{ meta; events }>` | 转换同一格式版本中受支持的旧记录后,返回不可变、平衡的逻辑日志,并提交冷恢复。实时 load 先 flush 其快照,并在轮次开放时拒绝;冷 load 保留中断的最终轮次,并用合成 `tool/result`/`step/end?`/`turn/end {interrupted}` 事件持久关闭它。只丢弃撕裂尾部碎片;已提交损坏格式错误的记录和未知 `version` 会被拒绝。 |
| `load(id): Promise<{ meta; events }>` | 转换同一格式版本中受支持的旧记录后,返回不可变、平衡的逻辑日志,并提交冷恢复。实时 load 先 flush 其快照,并在轮次开放时拒绝;冷 load 保留中断的最终轮次,并用合成 `tool/result`/`step/end?`/`turn/end {interrupted}` 事件持久关闭它。只丢弃撕裂尾部碎片;已提交损坏格式错误的记录`SessionPersistenceCorruptionError` 拒绝,不支持的格式 `version` 或本构建不认识且信封未带 `ignorable` 标记的事件类型以 `SessionFormatUnsupportedError` 拒绝,消息说明拒绝方向,并在后端为每个会话保留独立文件时给出原始日志路径。 |
| `inspect(id, signal?): Promise<{ meta; events }>` | 返回已经升级、验证和深度冻结的逻辑视图,但不提交恢复或发布 Session。冷视图会获得仅存在于内存的合成恢复 closer物理撕裂尾部保持不变实时状态下的视图则是当前不可变快照可能包含开放的轮次。基于协调器的实现会在有界 LRU 中保留该冷状态下未发布的 Session 本身,供后续 `prepare` 使用,但已存储修订值变化后会丢弃并重新读取。同 id 检查共享进行中的读取。 |
| `readFrom(id, fromSeq, signal?): Promise<{ meta; events }>` | 返回 `seq >= fromSeq` 的有效已存储事件,不进入 preparation 缓存、不截断、不合成 closer也不发布协调器状态。`fromSeq` 达到或超过已存储末尾时返回空事件列表;负数或非安全整数 `fromSeq` 会被拒绝。可寻址后端SQLite只读后缀除非转换受支持的旧记录需要读取更早的记录顺序后端JSONL解析整个产物并向前跳过。供 checkpoint 消费方只应用已存序号之后的事件。 |
| `readFrom(id, fromSeq, signal?): Promise<{ meta; events }>` | 返回 `seq >= fromSeq` 的有效已存储事件,不进入 preparation 缓存、不截断、不合成 closer也不发布协调器状态。`fromSeq` 达到或超过已存储末尾时返回空事件列表;负数或非安全整数 `fromSeq` 会被拒绝。可寻址后端SQLite只读后缀除非转换受支持的旧记录需要读取更早的记录顺序后端JSONL解析整个产物并向前跳过。未知类型拒绝遵循同一读取方式:寻址读取只检查返回的后缀,顺序回退路径还会拒绝窗口以下的未知必需事件。供 checkpoint 消费方只应用已存序号之后的事件。 |
| `list(signal?): Promise<SessionHeader[]>` | 从元数据轻量列出,不解析完整日志。可选信号取消后端列表工作。零事件延迟实体化会话不在 `list` 中。 |
| `listSnapshots(signal?): Promise<SessionPersistenceSnapshot[]>` | 返回轻量元数据和每份日志一个不透明、带品牌类型的修订值不加载事件日志。日志及其后端存储不变时修订保持相等append 或变更性 load 修复后会改变;不会仅因两个存储使用相同本地计数器而冲突。可选信号请求取消后端发现工作;第一方后端会先等待所有已启动的列出工作结束,再予以拒绝,因此调用返回拒绝时,相关工作已完全停稳。 |

View File

@@ -9,6 +9,7 @@ import { Context } from '@deepseek-ai/cordis'
import {
adoptSessionEvent,
interruptedTurnClosers,
KNOWN_SESSION_EVENT_TYPES,
SESSION_FORMAT_VERSION,
SessionPreparation,
snapshotJsonValue,
@@ -16,7 +17,7 @@ import {
} from '@deepseek-ai/dsh-session'
import type { Session, SessionEvent, SessionId, SessionHeader } from '@deepseek-ai/dsh-session'
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
import type { SessionInspection } from './index.ts'
import type { SessionInspection, SessionLocation } from './index.ts'
import type { SessionPersistenceRevision } from './revision.ts'
import { observeQueuedAbort, SessionPreparations } from './preparations.ts'
import type { SessionPreparationReservation } from './preparations.ts'
@@ -43,6 +44,42 @@ export class SessionPersistenceCorruptionError extends Error {
}
}
/**
* The stored log is intact but this runtime cannot faithfully interpret it:
* the header carries an unsupported format version, or an event's type is
* unknown to this build and the event is not marked ignorable. Distinct from
* {@link SessionPersistenceCorruptionError} — nothing is damaged; the raw log
* remains readable at {@link location} when the backend keeps one artifact
* per session.
*/
export class SessionFormatUnsupportedError extends Error {
/**
* @param message - stable reason the log cannot be interpreted, already
* including the raw-log path when one exists.
* @param location - the backend's artifact location, when one exists.
*/
constructor(message: string, readonly location?: SessionLocation) {
super(message)
this.name = 'SessionFormatUnsupportedError'
}
}
/**
* Direction-aware refusal text for a stored session whose format version this
* build does not read. Shared by the coordinator's load-time check and by
* backends that must refuse BEFORE decoding version-dependent structure (a
* future format may not satisfy today's structural checks at all, and the
* user must see "upgrade the harness", never "corrupt").
* @param id - the stored session id, for message context.
* @param version - the stored format version.
* @returns the stable refusal text, without a raw-log path suffix.
*/
export function sessionFormatVersionRefusal(id: string, version: number): string {
return version > SESSION_FORMAT_VERSION
? `session "${id}" uses log format v${version}, but this harness reads only v${SESSION_FORMAT_VERSION}: the log was written by a newer harness — upgrade the harness to open it`
: `session "${id}" uses log format v${version}, older than the supported v${SESSION_FORMAT_VERSION}, and this build ships no upgrade path for it`
}
/** Coordinator policy supplied by a concrete persistence backend. */
export interface PersistenceCoordinatorOptions {
/** Maximum completed unpublished preparations retained for reuse. */
@@ -126,6 +163,11 @@ export interface PersistenceBackend<TornMarker = unknown> {
* contains a supported legacy shape whose normalization needs earlier
* message-identity facts, in which case the coordinator falls back
* to the complete stored prefix.
* Unknown-type refusal follows the same suffix scope: a seek-capable
* backend's `readFrom` checks only the returned suffix, while the
* sequential fallback parses the whole artifact and refuses on an unknown
* required event anywhere in it — over-refusal on the sequential side is
* accepted rather than widening the seek read.
* @param id - persisted session id to resolve.
* @param fromSeq - first event seq to include (non-negative safe integer,
* validated by the coordinator before this hook runs).
@@ -156,6 +198,14 @@ export interface PersistenceBackend<TornMarker = unknown> {
*/
list(signal?: AbortSignal): Promise<SessionHeader[]>
/**
* Optional side-effect-free artifact locator, used to point refusal
* diagnostics ({@link SessionFormatUnsupportedError}) at the raw log.
* Backends without one artifact per session omit it or return `undefined`.
* @param meta - the header whose artifact is requested.
*/
locate?(meta: SessionHeader): SessionLocation | undefined
/**
* Optional lifecycle teardown (e.g. close a database handle). Awaited by the
* coordinator's dispose effect AFTER the quiescence drain. A stateless file
@@ -631,9 +681,13 @@ export class PersistenceCoordinator<TornMarker = unknown> {
private async appendCore(id: SessionId, events: readonly SessionEvent[]): Promise<void> {
// Every append route converges here: the public service, live write-behind
// drains, and HMR seed/suffix adoption. Keep vocabulary rejection at that
// shared boundary so a stale JavaScript plugin cannot persist an event that
// this same backend will refuse to load.
// drains, and HMR seed/suffix adoption. Legacy-shape rejection stays at
// this shared boundary so a stale JavaScript plugin cannot persist a
// retired shape this backend refuses to load. The unknown-type guard is
// deliberately read-side only: an append-time refusal would stall a live
// session's durability mid-flight, which costs more than a loud refusal at
// the log's next load (trade-off owned by the session-log-version-mechanism
// Agent Note).
assertSupportedEvents(events, id)
if (events.length === 0) return
this.preparations.assertWritable(id)
@@ -806,7 +860,9 @@ export class PersistenceCoordinator<TornMarker = unknown> {
const whole = await this.readStoredPrefix(id, signal)
return { meta: whole.meta, events: whole.events.filter(event => event.seq >= fromSeq) }
}
return { meta: structuredClone(suffix.meta), events: snapshotStoredEvents(suffix.events, id) }
const events = snapshotStoredEvents(suffix.events, id)
this.assertEventsSupported(suffix.meta, events)
return { meta: structuredClone(suffix.meta), events }
}
const whole = await this.readStoredPrefix(id, signal)
// Sequential fallback: contiguous seqs from 0 make the suffix an index slice.
@@ -824,9 +880,11 @@ export class PersistenceCoordinator<TornMarker = unknown> {
if (stored === undefined) throw new Error(`session "${id}" not found`)
this.assertStoredId(id, stored.meta)
this.assertVersion(stored.meta)
const events = snapshotStoredEvents(stored.events, id)
this.assertEventsSupported(stored.meta, events)
return {
meta: structuredClone(stored.meta),
events: snapshotStoredEvents(stored.events, id),
events,
}
}
@@ -839,6 +897,7 @@ export class PersistenceCoordinator<TornMarker = unknown> {
this.assertStoredId(id, meta)
this.assertVersion(meta)
const storedEvents = adoptStoredEvents(events, id)
this.assertEventsSupported(meta, storedEvents)
// Preserve complete interrupted events and synthesize only missing closers.
const closers = interruptedTurnClosers(storedEvents).map(adoptSessionEvent)
@@ -861,6 +920,9 @@ export class PersistenceCoordinator<TornMarker = unknown> {
closers,
}
} catch (error: unknown) {
// An unsupported format is a refusal over an intact log, not damage —
// surface it unwrapped so callers can point at the raw artifact.
if (error instanceof SessionFormatUnsupportedError) throw error
throw new SessionPersistenceCorruptionError(
`stored session "${id}" failed validation: ${String(error)}`,
{ cause: error },
@@ -982,11 +1044,36 @@ export class PersistenceCoordinator<TornMarker = unknown> {
}
private assertVersion(meta: SessionHeader): void {
if (meta.version !== SESSION_FORMAT_VERSION) {
throw new Error(`unsupported session format version ${meta.version} for "${meta.id}" (only v${SESSION_FORMAT_VERSION} is supported)`)
if (meta.version === SESSION_FORMAT_VERSION) return
throw this.unsupported(meta, sessionFormatVersionRefusal(meta.id, meta.version))
}
/**
* Refuse a log containing an event type this build does not know, unless the
* writer marked the event ignorable: an unrecognized required event may
* change how the rest of the log must be interpreted, so silently skipping
* it would reconstruct a wrong session (the envelope contract on
* `SessionEvent.ignorable`). Runs on NORMALIZED events — after
* `snapshotStoredEvents`/`adoptStoredEvents` has upgraded the legacy shapes
* this build still reads and rejected the ones it does not, so those keep
* their specific diagnostics.
*/
private assertEventsSupported(meta: SessionHeader, events: readonly SessionEvent[]): void {
for (const event of events) {
if (KNOWN_SESSION_EVENT_TYPES.has(event.type) || event.ignorable === true) continue
throw this.unsupported(meta, `session "${meta.id}" contains event type "${event.type}" (seq ${event.seq}) unknown to this harness and not marked ignorable; refusing to interpret the log — it was likely written by a newer harness`)
}
}
/** Build a format refusal that points at the raw artifact when the backend has one. */
private unsupported(meta: SessionHeader, reason: string): SessionFormatUnsupportedError {
const location = this.backend.locate?.(meta)
return new SessionFormatUnsupportedError(
location === undefined ? reason : `${reason} (raw log: ${location.path})`,
location,
)
}
/** Reject backend metadata that is not bound to the requested session id. */
private assertStoredId(id: SessionId, meta: SessionHeader): void {
if (meta.id !== id) {
@@ -1219,6 +1306,7 @@ export class PersistenceCoordinator<TornMarker = unknown> {
}
this.assertVersion(meta)
const storedEvents = snapshotStoredEvents(events, session.header.id)
this.assertEventsSupported(meta, storedEvents)
if (!seedCoversPrefix(seed, storedEvents)) {
throw new Error(`session "${session.header.id}" already has a persisted log on disk that does not match this live session (id collision)`)
}

View File

@@ -36,7 +36,9 @@ export {
DEFAULT_WRITE_BATCH_MAX_DELAY_MS,
MAX_WRITE_BATCH_DELAY_MS,
PersistenceCoordinator,
SessionFormatUnsupportedError,
SessionPersistenceCorruptionError,
sessionFormatVersionRefusal,
} from './coordinator.ts'
export type {
PersistenceBackend,

View File

@@ -706,6 +706,9 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
.rejects.toThrow('lacks an identified message')
}
// An out-of-repo event type passes only with the envelope's ignorable
// marker (unknown-type refusal otherwise), and its non-object data is
// not message-validated.
const pluginId = SessionId('non-object-plugin-event')
await ctx.sessionPersistence.create(meta(pluginId, WORK))
await ctx.sessionPersistence.append(pluginId, [{
@@ -713,11 +716,12 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
seq: 0,
time: 1,
data: null,
ignorable: true,
} as unknown as SessionEvent])
await expect(ctx.sessionPersistence.inspect(pluginId))
.resolves.toMatchObject({ events: [{ type: 'plugin/test', data: null }] })
.resolves.toMatchObject({ events: [{ type: 'plugin/test', data: null, ignorable: true }] })
await expect(ctx.sessionPersistence.readFrom(pluginId, 0))
.resolves.toMatchObject({ events: [{ type: 'plugin/test', data: null }] })
.resolves.toMatchObject({ events: [{ type: 'plugin/test', data: null, ignorable: true }] })
for (const type of ['user/message', 'assistant/message'] as const) {
const missingContentId = SessionId(`invalid-${type}-without-content`)
@@ -1321,14 +1325,60 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
}
})
it('rejects an unknown format version on load (assertVersion)', async () => {
it('rejects a newer format version on load, naming the upgrade direction', async () => {
const fix = await makeFixture()
const { ctx, fiber } = await freshCtx(fix)
try {
const m = { version: 99, id: SessionId('v99'), createdAt: 1, cwd: WORK }
await ctx.sessionPersistence.create(m)
await ctx.sessionPersistence.append(m.id, oneTurnLog())
await expect(ctx.sessionPersistence.load(m.id)).rejects.toThrow(/version/)
const failure = await ctx.sessionPersistence.load(m.id).then(() => undefined, (error: unknown) => error as Error)
expect(failure?.name).toBe('SessionFormatUnsupportedError')
expect(failure?.message).toMatch(/written by a newer harness.*upgrade the harness/)
} finally {
await fiber.dispose()
await fix.cleanup()
}
})
it('rejects an older format version on load without claiming an upgrade path', async () => {
const fix = await makeFixture()
const { ctx, fiber } = await freshCtx(fix)
try {
const m = { version: -1, id: SessionId('v-older'), createdAt: 1, cwd: WORK }
await ctx.sessionPersistence.create(m)
await ctx.sessionPersistence.append(m.id, oneTurnLog())
const failure = await ctx.sessionPersistence.load(m.id).then(() => undefined, (error: unknown) => error as Error)
expect(failure?.name).toBe('SessionFormatUnsupportedError')
expect(failure?.message).toMatch(/older than the supported v0.*no upgrade path/)
} finally {
await fiber.dispose()
await fix.cleanup()
}
})
it('rejects an unknown event type on load unless the event is marked ignorable', async () => {
const fix = await makeFixture()
const { ctx, fiber } = await freshCtx(fix)
try {
const required = meta('unknown-required', WORK)
await ctx.sessionPersistence.create(required)
await ctx.sessionPersistence.append(required.id, [
...oneTurnLog(),
{ type: 'future/event', seq: oneTurnLog().length, time: 99, data: { payload: 1 } } as unknown as SessionEvent,
])
const failure = await ctx.sessionPersistence.load(required.id).then(() => undefined, (error: unknown) => error as Error)
expect(failure?.name).toBe('SessionFormatUnsupportedError')
expect(failure?.message).toMatch(/event type "future\/event".*not marked ignorable/)
const skippable = meta('unknown-ignorable', WORK)
await ctx.sessionPersistence.create(skippable)
await ctx.sessionPersistence.append(skippable.id, [
...oneTurnLog(),
{ type: 'future/event', seq: oneTurnLog().length, time: 99, data: { payload: 1 }, ignorable: true } as unknown as SessionEvent,
])
const loaded = await ctx.sessionPersistence.load(skippable.id)
expect(loaded.events.some(event => (event.type as string) === 'future/event')).toBe(true)
} finally {
await fiber.dispose()
await fix.cleanup()

View File

@@ -24,6 +24,7 @@ import {
} from '@agentclientprotocol/sdk'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import { SessionId } from '@deepseek-ai/dsh-session'
import { AssistantOutputFold } from '@deepseek-ai/dsh-subagent'
import type { SubagentResult, SubagentRun, SubagentStartRequest, SubagentStopReason } from '@deepseek-ai/dsh-subagent'
import type { SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
@@ -232,8 +233,9 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe
let processDisposal: Promise<void> | undefined
const disposeProcess = (): Promise<void> => (processDisposal ??= disposeAcpChild(child, spec.disposeEofGraceMs))
// Accumulate the child's streamed assistant text — the SubagentResult output.
const output: string[] = []
// ACP exposes no complete assistant messages, so the shared fold selects its
// accumulated assistant text.
const fold = new AssistantOutputFold()
// Shared mutable state keeps cancellation visible across async closures.
const flags = { cancelled: false }
@@ -241,7 +243,7 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe
sessionUpdate(params: SessionNotification): Promise<void> {
const update = params.update
if (update.sessionUpdate === 'agent_message_chunk') {
output.push(acpContentText(update.content))
fold.pushText(acpContentText(update.content))
}
// Other updates (thoughts, tool calls, plans) are consumed but not
// surfaced — the subagent returns only its final answer.
@@ -284,13 +286,8 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe
const onAbort = (): void => { requestCancel() }
request.signal.addEventListener('abort', onAbort, { once: true })
// The accumulated child text as harness ContentBlocks (empty array when the
// child streamed nothing). Read at every return so a partial answer survives
// a later cancel/error.
const collectOutput = (): ContentBlock[] => {
const text = output.join('')
return text.length > 0 ? [{ type: 'text', text }] : []
}
// Read at every return so a partial answer survives a later cancel/error.
const collectOutput = (): ContentBlock[] => fold.collect() ?? []
// Establish the remote session before publishing a handle. Any failure owns
// the still-private process and therefore reaps it before rejecting.

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/subagent/subagent-dsh-sdk/README.md
README.md: 0bbcfa105ecf024a2492d39d3bf8d28956110050
README.zh.md: 8c8551f85951aa8475ab2ce95771e4d54e0ed89a
README.md: 493bb187d45c7654958cfb3dbbe1dee6bb21b368
README.zh.md: 2e1d9b1e602f2180d20d43fe8c358163ec4ec024

View File

@@ -10,7 +10,7 @@ The SDK provider runs each subagent as a complete DeepSeek Harness runtime in a
The working directory resolves exactly like the ACP backend, through the seam's shared out-of-process helpers ([`dsh-subagent`](../subagent/README.md)): the configured `cwd` override when set (validated once at load), else the delegating parent session's cwd — never the server process's own cwd. The resolved path becomes the child process cwd and the workspace cwd of its SDK session.
The returned run id is minted in the parent namespace; the child runtime's session id exists only inside the child process. After publication the provider owns one SDK activity and reads the child's answer from its session events: the last complete `assistant/message`, or the `text-delta` stream accumulated before the activity was cut short — a partial answer survives cancel and error paths.
The returned run id is minted in the parent namespace; the child runtime's session id exists only inside the child process. After publication the provider owns one SDK activity and reads the child's answer from its session events: the last complete non-empty `assistant/message` (an empty-content message that records usage is skipped), or the accumulated `text-delta` stream when no such message exists. Partial output remains available after cancellation or an error.
`dispose()` is idempotent: it settles the result locally as `aborted` (there is no wire-level prompt cancel), then closes the runtime — a bounded protocol `shutdown` request followed by the shared stdin-EOF → SIGTERM → SIGKILL ladder to actual exit.

View File

@@ -10,7 +10,7 @@ SDK 提供方会在全新的子进程中把每个 subagent 作为完整的 DeepS
工作目录的解析与 ACP 后端完全一致,并使用 seam 共享的进程外辅助工具([`dsh-subagent`](../subagent/README.md)):设置了 `cwd` 覆盖值时使用该值(加载时校验一次),否则使用发起委派的父会话 cwd绝不使用服务器进程自身的 cwd。解析出的路径同时成为子进程 cwd 和其 SDK 会话的工作区 cwd。
返回的 run id 在父级命名空间中生成;子运行时的会话 id 只存在于子进程内部。发布后,提供方拥有一段 SDK 活动,并从子会话事件中读取答案:最后一条完整的 `assistant/message`,或该活动中断前已经累积的 `text-delta`;部分答案在取消和错误路径上都得以保留
返回的 run id 在父级命名空间中生成;子运行时的会话 id 只存在于子进程内部。发布后,提供方拥有一段 SDK 活动,并从子会话事件中读取答案:最后一条完整且非空`assistant/message`(记录 usage 的空内容消息会被跳过);若没有这类消息,则取累积的 `text-delta`。取消或发生错误后,部分输出仍然可用
`dispose()`(资源释放)是幂等的:先在本地把结果确定为 `aborted`(协议层面没有提示词取消机制),再关闭运行时,即先发出一次有界的协议 `shutdown` 请求,随后通过共享的 stdin-EOF → SIGTERM → SIGKILL 阶梯使进程实际退出。

View File

@@ -16,7 +16,7 @@ import { DeepSeekHarness, type HarnessNotification } from '@deepseek-ai/dsh-sdk-
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import { SessionId, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session'
import type { SubagentResult, SubagentRun, SubagentStartRequest, SubagentStopReason } from '@deepseek-ai/dsh-subagent'
import { settleRunResult, subprocessRunHandle } from '@deepseek-ai/dsh-subagent'
import { AssistantOutputFold, settleRunResult, subprocessRunHandle } from '@deepseek-ai/dsh-subagent'
import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess'
/** Resolved spawn spec for an SDK runtime child process (no defaults — see Config). */
@@ -163,24 +163,14 @@ export async function startSdkRun(request: SubagentStartRequest, spec: SdkRunSpe
}
const childSessionId = `session-${randomUUID().replaceAll('-', '')}`
// The child's final answer: the last complete assistant message when one
// exists, else the text streamed so far (a partial answer surviving cancel).
let lastMessage: ContentBlock[] | undefined
const partial: string[] = []
// The child's final answer under the seam's canonical selection rule
// (`AssistantOutputFold`); a partial answer survives cancel and error paths.
const fold = new AssistantOutputFold()
const observe = (notification: HarnessNotification): void => {
if (notification.method !== 'session.event' || notification.params.sessionId !== childSessionId) return
const event = notification.params.event as SessionEvent
if (event.type === 'assistant/chunk' && event.data.chunk.type === 'text-delta') {
partial.push(event.data.chunk.text)
} else if (event.type === 'assistant/message') {
lastMessage = event.data.message.content
}
}
const collectOutput = (): ContentBlock[] => {
if (lastMessage !== undefined) return lastMessage
const text = partial.join('')
return text.length > 0 ? [{ type: 'text', text }] : []
fold.push(notification.params.event as SessionEvent)
}
const collectOutput = (): ContentBlock[] => fold.collect() ?? []
// Race the child turn against local cancellation; the shared settlement
// flattens failures under the seam's never-reject contract.

View File

@@ -176,6 +176,20 @@ describe('dsh-subagent-dsh-sdk provider', () => {
await ctx.fiber.dispose()
})
it('keeps streamed text when the terminal message is an empty usage-only step', async () => {
// The child streams its answer, then emits an empty-content
// assistant/message (the harness loop appends one to host usage on a
// max-tokens step that assembled no text blocks). The empty message is
// not assistant output and must not erase the streamed answer.
const ctx = await setup({ FAKE_EMPTY_MESSAGE: '1', FAKE_REASON_KIND: 'max-tokens' })
const run = await ctx.subagents.start('dsh-sdk', request())
const result = await run.result
expect(result.stopReason).toBe('max-tokens')
expect(text(result.output)).toBe('hello from fake runtime')
await run.dispose()
await ctx.fiber.dispose()
})
it('reports a settled-without-turn child as an error', async () => {
const ctx = await setup({ FAKE_REASON_KIND: 'none', FAKE_STATUS: 'error' })
const run = await ctx.subagents.start('dsh-sdk', request())

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/subagent/subagent-inprocess/README.md
README.md: 209f1e9526ff4a01af6f4c96955068de4b2b06c0
README.zh.md: 8623be4bc1ab39aa7718de204dd0507843b0ab14
README.md: 69def8bf8f41e3685d017ac4b003b26a37f064ef
README.zh.md: bf5e7cb5cc8517ee7020695ef10e3b58d613541b

View File

@@ -14,7 +14,7 @@ The driver follows this sequence:
2. Call `parent.ctx.agents.create` directly, passing the required request signal into the factory's creation transaction.
3. During that transaction's unpublished setup window, install the requested persona, tool restriction, and structured-output runtime.
4. Publish the child, retain the returned `AgentHandle`, and drive one task with `child.followup(prompt)` followed by `child.whenIdle()`.
5. Read the child's own last assistant message and final durable turn reason from the complete owned child run, excluding any fork seed.
5. Read the child's own output — its last non-empty assistant message (an empty-content message that records usage is skipped), or its accumulated assistant text when no such message exists — and the final durable turn reason from the complete owned child run, excluding any fork seed.
The child gets the parent's working-directory/session lineage and inherits the parent provider, model, and output-token cap unless `request.agentOptions` overrides them. It gets a fresh flat registration scope: parent ownership does not import parent tool restrictions or establish an authority subset.

View File

@@ -14,7 +14,7 @@
2. 直接调用 `parent.ctx.agents.create`,把必需的请求信号传入工厂的创建事务。
3. 在该事务未发布的设置窗口中,安装请求的 persona、工具限制和结构化输出运行时。
4. 发布子 agent保留返回的 `AgentHandle`,并通过先调用 `child.followup(prompt)`、再调用 `child.whenIdle()` 来驱动一项任务。
5. 从完整的自有子运行中读取子 agent 自身最后一条 assistant 消息最终持久化的轮次原因,并排除任何 fork 初始内容。
5. 从完整的自有子运行中读取子 agent 自身的输出——最后一条非空 assistant 消息(记录 usage 的空内容消息会被跳过),若没有这类消息则取其累积的 assistant 文本——以及最终持久化的轮次原因,并排除任何 fork 初始内容。
子 agent 会获得父 agent 的工作目录/会话谱系;除非 `request.agentOptions` 覆盖,否则还会继承父 agent 的提供方、模型和输出 token 上限。它获得全新的扁平注册作用域:父级所有权不会导入父 agent 的工具限制,也不会建立权限子集。

View File

@@ -22,6 +22,7 @@ import {
assertSubagentMaxDepth,
captureDelegatedPolicyOverrides,
childSessionMeta,
finalAssistantOutput,
resolveChildAgentOptions,
resolveChildDepth,
} from '@deepseek-ai/dsh-subagent'
@@ -206,9 +207,9 @@ function readResult(
structured?: { captured?: { value: unknown } | undefined },
): SubagentResult {
const own = child.session.events.slice(boundary)
const lastMessage = own.findLast((event): event is SessionEvent<'assistant/message'> => event.type === 'assistant/message')
const lastEnd = findLastMessageTurnEnd(own)
const output: ContentBlock[] = lastMessage?.data.message.content ?? []
// The seam's canonical selection rule; a partial answer survives cancel and truncation.
const output: ContentBlock[] = finalAssistantOutput(own) ?? []
const recorded = toStopReason(lastEnd?.data.reason)
// Disposal can tear the owner down before the loop records its ordinary
// `aborted` end, yielding `disposed` instead.

View File

@@ -1,4 +1,4 @@
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import { CallId, createUserMessage } from '@deepseek-ai/dsh-llm'
import { describe, expect, it } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import { type Agent, type AgentOptions } from '@deepseek-ai/dsh-agent'
@@ -10,7 +10,8 @@ import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant'
import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant'
import * as AgentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant'
import SubagentService, { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent'
import { maxTokensResponse, MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
import { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
import { maxTokensResponse, MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
import { startInProcessRun } from '../src/index.ts'
type Script = ConstructorParameters<typeof MockAdapter>[0]
@@ -155,6 +156,31 @@ describe('startInProcessRun', () => {
await run.dispose()
})
it('keeps earlier streamed text when the final step appends an empty usage-only message', async () => {
// A tool-only max-tokens step records an empty assistant/message for
// usage. The result retains the preceding assistant output.
const { ctx, parent } = await setup([
toolCallResponse('t1', 'noop', {}, 'partial one'),
[
{ type: 'block-start', index: 0, blockType: 'tool-call' },
{ type: 'tool-call-delta', index: 0, id: CallId('t2'), name: 'noop', argumentsDelta: '{}' },
{ type: 'block-end', index: 0, block: { type: 'tool-call', id: CallId('t2'), name: 'noop', arguments: '{}' } },
{ type: 'usage', usage: { inputTokens: 20, outputTokens: 5 } },
{ type: 'finish', reason: { kind: 'max-tokens' } },
],
])
const disposeNoop = ctx.tools.register(defineContentToolFixture({
name: 'noop', description: 'probe', parameters: {},
execute() { return Promise.resolve([{ type: 'text', text: 'noop result' }]) },
}))
const run = await startInProcessRun(request(parent), {})
const result = await run.result
expect(result.stopReason).toBe('max-tokens')
expect(text(result.output)).toBe('partial one')
await run.dispose()
disposeNoop()
})
it('seeds a forked child but reads only the child-owned output', async () => {
const { ctx, parent } = await setup([textResponse('parent answer'), textResponse('child answer')])
parent.followup(createUserMessage({ content: [{ type: 'text', text: 'parent question' }], source: { kind: 'user' } }))
@@ -278,7 +304,12 @@ describe('startInProcessRun', () => {
const signalled = await startInProcessRun(request(parent, controller.signal), {})
await new Promise(resolve => setTimeout(resolve, 30))
controller.abort('stop child')
await expect(signalled.result).resolves.toMatchObject({ stopReason: 'aborted' })
// No step completed a message, so the text streamed before the abort is
// the cancelled run's output.
await expect(signalled.result).resolves.toEqual({
output: [{ type: 'text', text: 'partial' }],
stopReason: 'aborted',
})
expect(adapter.requests[0]?.signal?.reason).toEqual({ kind: 'parent' })
const child = parent.ctx.agents.get(signalled.id)
const turnEnd = child?.session.events.findLast(event => event.type === 'turn/end')

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/subagent/subagent/README.md
README.md: 42a10adeccb8e299e25ff0a5e0a918ef09b79617
README.zh.md: 34e2ed6c1ca23df9b3158f3caea10cd19bafa841
README.md: 28f649ef54bbf88feda24a9ce197c2c366f8349b
README.zh.md: 595c5e5e7fffc367f2e3fd8142b779dc22fb3b79

View File

@@ -64,7 +64,7 @@ Both in-process delegation paths fix the child's permission scope at the delegat
`provider.start(request): Promise<SubagentRun>` is the ownership-transfer boundary; the delegation tool also uses it inside its one-shot Task-backed background path. Before fulfillment, the provider owns setup and must cancel, roll back, and quiesce unpublished resources on every failure. After fulfillment, the caller owns the run and must call `dispose()` on every path; remaining prompt and turn work belongs to `SubagentRun.result`.
`SubagentRun.result` resolves to `{ output, structured?, stopReason }`. Child-level failures resolve with a non-`completed` reason; only an infrastructure fault that the seam cannot represent may reject. `dispose()` is idempotent, cancels remaining work, and waits for both result settlement and child-resource quiescence. A result rejection remains on `result`; `dispose()` rejects only for an independent resource-release failure.
`SubagentRun.result` resolves to `{ output, structured?, stopReason }`. Child-level failures resolve with a non-`completed` reason; only an infrastructure fault that the seam cannot represent may reject. `dispose()` is idempotent, cancels remaining work, and waits for both result settlement and child-resource quiescence. A result rejection remains on `result`; `dispose()` rejects only for an independent resource-release failure. `output` and the `subagent/end` event's `lastAssistantMessage` use the exported `AssistantOutputFold`/`finalAssistantOutput` helpers to select the child's last non-empty assistant message, or its accumulated assistant text when no such message exists. `output` is `[]` and the event field is absent when the child produced neither ([`SubagentResult.output`](../../../docs/subsystems/subagent.md#the-terminal-result-subagentresult) owns the result contract).
A local run publishes an ordinary child agent/session before `start()` fulfills, returns that shared session id as `SubagentRun.id`, exposes the exact child as `SubagentRun.localAgent`, records `request.parent.session.id` in the child's `parentSession` header, and appends the resolved descriptor inside its initial turn. Remote providers instead mint a parent-scoped lifecycle id and return `localAgent: undefined`; without a local child session, their one-shot runs are not part of trace-backed enumeration.

View File

@@ -64,7 +64,7 @@ subagent seam 允许一个 agent智能体通过具名提供方把工作委
`provider.start(request): Promise<SubagentRun>` 是所有权转移边界;委派工具也会在其由 Task 支撑的一次性后台路径中使用它。兑现前,提供方拥有设置过程,并且每次失败时都必须取消、回滚并使未发布资源完全停稳。兑现后,调用方拥有该运行,并且必须在每条路径上调用 `dispose()`;剩余提示词和轮次工作属于 `SubagentRun.result`
`SubagentRun.result` 兑现为 `{ output, structured?, stopReason }`。子 agent 级失败会以非 `completed` 原因兑现;只有 seam 无法表示的基础设施故障才可以拒绝。`dispose()` 是幂等的,会取消剩余工作,并等待结果结算以及子 agent 资源完全停稳。`result` 的 rejection 仍归 `result` 通道;只有独立的资源释放失败会使 `dispose()` 拒绝。
`SubagentRun.result` 兑现为 `{ output, structured?, stopReason }`。子 agent 级失败会以非 `completed` 原因兑现;只有 seam 无法表示的基础设施故障才可以拒绝。`dispose()` 是幂等的,会取消剩余工作,并等待结果结算以及子 agent 资源完全停稳。`result` 的 rejection 仍归 `result` 通道;只有独立的资源释放失败会使 `dispose()` 拒绝。`output``subagent/end` 事件的 `lastAssistantMessage` 使用导出的 `AssistantOutputFold``finalAssistantOutput` 辅助函数选取子 agent 最后一条非空 assistant 消息;若没有这类消息,则选取其累积的 assistant 文本。子 agent 两种输出均未产生时,`output``[]`,该事件字段缺省(结果约定归 [`SubagentResult.output`](../../../docs/subsystems/subagent.md#the-terminal-result-subagentresult) 所有)。
本地运行会在 `start()` 兑现前发布普通的子 agent会话把该共享会话 id 作为 `SubagentRun.id` 返回,以 `SubagentRun.localAgent` 公开准确的子 agent`request.parent.session.id` 记录到子 agent 的 `parentSession` header并在其初始轮次内追加已解析的描述符。远程提供方则生成 parent 作用域的生命周期 id并返回 `localAgent: undefined`;由于没有本地 child 会话,其一次性运行不会进入基于追踪的枚举结果。

View File

@@ -0,0 +1,74 @@
/**
* Canonical selection of a child's final assistant output. Backend run results
* and `subagent/end.lastAssistantMessage` apply the same rule: select the last
* non-empty assistant message. An empty-content message records usage only
* when the loop appends it after a max-tokens step with no executable blocks,
* so it does not replace earlier output. If no non-empty message exists,
* select the accumulated assistant text. Selection is independent of the
* run's stop reason.
*
* @module @deepseek-ai/dsh-subagent/assistant-output
*/
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
/**
* Incremental fold of the selection rule, for backends that observe a child's
* output as it streams: session-event backends {@link push} each event, and
* transports without session events (ACP content chunks) {@link pushText} raw
* text into the same streamed fallback.
*/
export class AssistantOutputFold {
private message: ContentBlock[] | undefined
private partial: string[] = []
/**
* Fold one session event: a non-empty assistant message becomes the
* candidate final answer, and a `text-delta` chunk extends the streamed
* fallback; every other event contributes nothing.
* @param event - the next observed session event.
*/
push(event: SessionEvent): void {
if (event.type === 'assistant/message') {
const content = event.data.message.content
if (content.length > 0) this.message = content
} else if (event.type === 'assistant/chunk' && event.data.chunk.type === 'text-delta') {
this.pushText(event.data.chunk.text)
}
}
/**
* Extend the streamed fallback with text observed outside session events.
* @param text - the next streamed text piece (an empty piece is a no-op).
*/
pushText(text: string): void {
if (text.length > 0) this.partial.push(text)
}
/**
* Select the final output folded so far.
* @returns the last non-empty assistant message, else the accumulated
* streamed text, or `undefined` when the child produced neither.
*/
collect(): ContentBlock[] | undefined {
if (this.message !== undefined) return this.message
const text = this.partial.join('')
return text.length > 0 ? [{ type: 'text', text }] : undefined
}
}
/**
* Apply the selection rule to one complete child-owned event suffix.
* @param events - the child-owned events (after any seed or epoch boundary).
* @returns the selected output, or `undefined` when the child produced none.
*/
export function finalAssistantOutput(events: readonly SessionEvent[]): ContentBlock[] | undefined {
// TODO: this folds the complete suffix once per run/epoch settlement. If a
// long continuable epoch ever profiles hot here, scan backward with early
// exit for the last non-empty message and fold text deltas only on the
// no-message fallback.
const fold = new AssistantOutputFold()
for (const event of events) fold.push(event)
return fold.collect()
}

View File

@@ -69,6 +69,7 @@ import { snapshotSubagentDescriptor } from './descriptor.ts'
import { subagentIdentityProjectionDefinition, subagentTimingProjectionDefinition } from './projection.ts'
export * from './out-of-process.ts'
export { AssistantOutputFold, finalAssistantOutput } from './assistant-output.ts'
export { SubagentRunId } from './types.ts'
export type {
ContinuableCreateRequest,

View File

@@ -20,6 +20,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import { findLastMessageTurnEnd } from '@deepseek-ai/dsh-session'
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
import { finalAssistantOutput } from './assistant-output.ts'
import { SubagentRunId } from './types.ts'
import type { SubagentResult, SubagentRun, SubagentRunEndInfo, SubagentRunInfo } from './types.ts'
@@ -128,7 +129,8 @@ export function observeRun(
emit('subagent/end', {
...identity,
stopReason: result.stopReason,
lastAssistantMessage: result.output,
// Omit the field when no output exists, matching continuable epochs.
...result.output.length === 0 ? {} : { lastAssistantMessage: result.output },
}, parent)
},
() => {
@@ -173,7 +175,7 @@ export function createActivationObserver(
},
capture: (child: Agent): void => {
const own = child.session.events.slice(boundary)
const output = lastAssistantOutput(own)
const output = finalAssistantOutput(own)
captured = {
stopReason: epochStopReason(own),
...output === undefined ? {} : { output },
@@ -220,19 +222,6 @@ function epochStopReason(events: readonly SessionEvent[]): SubagentResult['stopR
}
}
/**
* The child's last assistant message content, for one Activation's terminal
* lifecycle edge. Absent when no assistant message reached the log.
* @param events - this epoch's own event suffix.
* @returns its final assistant content, or `undefined` when it produced none.
*/
function lastAssistantOutput(events: readonly SessionEvent[]): ContentBlock[] | undefined {
const message = events.findLast(
(event): event is SessionEvent<'assistant/message'> => event.type === 'assistant/message',
)
return message?.data.message.content
}
/** Render any listener-thrown value without letting coercion escape containment. */
function renderThrown(value: unknown): string {
try {

View File

@@ -64,7 +64,11 @@ export interface SubagentRunEndInfo {
readonly local: boolean
/** The terminal stop reason. */
readonly stopReason: SubagentResult['stopReason']
/** The child's final assistant output, absent on infrastructure rejection. */
/**
* The child's final assistant output, selected by the same rule as
* {@link SubagentResult.output}; absent on infrastructure rejection or when
* the child produced none.
*/
readonly lastAssistantMessage?: ContentBlock[]
}
@@ -213,7 +217,12 @@ export type SubagentStopReason = SubagentStopReasonMap[keyof SubagentStopReasonM
* The terminal outcome of a subagent run, resolved by {@link SubagentRun.result}.
*/
export interface SubagentResult {
/** The child's final assistant output (the last assistant message's content). */
/**
* The child's final assistant output is the content of its last non-empty
* assistant message. Empty-content messages, including usage-only messages,
* are skipped. Without a non-empty message, the output is its accumulated
* assistant text stream, or `[]` when the child produced neither.
*/
readonly output: ContentBlock[]
/**
* The structured result after a requested `outputSchema` was successfully

View File

@@ -0,0 +1,92 @@
import { describe, expect, it } from 'vitest'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import { AssistantOutputFold, finalAssistantOutput } from '../src/assistant-output.ts'
function message(content: ContentBlock[]): SessionEvent {
return { type: 'assistant/message', data: { message: { content } } } as SessionEvent
}
function textDelta(text: string): SessionEvent {
return { type: 'assistant/chunk', data: { chunk: { type: 'text-delta', text } } } as SessionEvent
}
function reasoningDelta(text: string): SessionEvent {
return { type: 'assistant/chunk', data: { chunk: { type: 'reasoning-delta', text } } } as SessionEvent
}
function toolResult(text: string): SessionEvent {
return {
type: 'tool/result',
data: {
message: {
content: [{
type: 'tool-result',
toolCallId: 'call-1',
content: [{ type: 'text', text }],
isError: false,
}],
},
},
} as SessionEvent
}
describe('finalAssistantOutput', () => {
it('selects the last non-empty message past a later empty usage-only message', () => {
const events = [
message([{ type: 'text', text: 'step one' }]),
message([{ type: 'text', text: 'step two' }]),
message([]),
]
expect(finalAssistantOutput(events)).toEqual([{ type: 'text', text: 'step two' }])
})
it('prefers a non-empty message over text streamed before and after it', () => {
const events = [
textDelta('earlier partial'),
message([{ type: 'text', text: 'complete answer' }]),
textDelta('later partial'),
message([]),
]
expect(finalAssistantOutput(events)).toEqual([{ type: 'text', text: 'complete answer' }])
})
it('treats textless assistant content as a non-empty message', () => {
const content: ContentBlock[] = [{ type: 'reasoning', text: 'complete reasoning' }]
expect(finalAssistantOutput([
textDelta('streamed text'),
message(content),
textDelta('later partial'),
])).toEqual(content)
})
it('falls back to text deltas without including reasoning or tool-result content', () => {
const events = [
reasoningDelta('thinking'),
textDelta('partial '),
toolResult('tool output'),
textDelta('answer'),
message([]),
]
expect(finalAssistantOutput(events)).toEqual([{ type: 'text', text: 'partial answer' }])
})
it('returns undefined when the child produced neither messages nor text', () => {
expect(finalAssistantOutput([])).toBeUndefined()
expect(finalAssistantOutput([reasoningDelta('thinking'), message([])])).toBeUndefined()
})
})
describe('AssistantOutputFold', () => {
it('folds raw text pieces into the same streamed fallback (ACP chunk transport)', () => {
const fold = new AssistantOutputFold()
fold.pushText('partial ')
fold.pushText('')
fold.pushText('answer')
expect(fold.collect()).toEqual([{ type: 'text', text: 'partial answer' }])
})
it('collects undefined until any output is folded', () => {
expect(new AssistantOutputFold().collect()).toBeUndefined()
})
})

View File

@@ -12,10 +12,10 @@ import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn'
import * as SubagentFork from '@deepseek-ai/dsh-subagent-fork'
import type { GenerateOptions, MessageId, StreamChunk } from '@deepseek-ai/dsh-llm'
import { createUserMessage, LlmAdapter } from '@deepseek-ai/dsh-llm'
import { CallId, createUserMessage, LlmAdapter } from '@deepseek-ai/dsh-llm'
import { defineTool } from '@deepseek-ai/dsh-tools'
import InvariantService from '@deepseek-ai/dsh-invariants'
import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
import SubagentService, {
SubagentError,
SUBAGENT_DESCRIPTOR_VERSION,
@@ -1200,6 +1200,44 @@ describe('continuable review regressions', () => {
expect(ends[1]!.lastAssistantMessage).toEqual([{ type: 'text', text: 'second answer' }])
})
it('keeps the epoch\'s earlier text past a final empty usage-only message', async () => {
// A tool-only max-tokens step records an empty assistant/message for
// usage. The terminal event retains the previous assistant content,
// including its tool call but not the intervening tool result.
const { ctx, parent } = await setup([
toolCallResponse('t1', 'noop', {}, 'partial one'),
[
{ type: 'block-start', index: 0, blockType: 'tool-call' },
{ type: 'tool-call-delta', index: 0, id: CallId('t2'), name: 'noop', argumentsDelta: '{}' },
{ type: 'block-end', index: 0, block: { type: 'tool-call', id: CallId('t2'), name: 'noop', arguments: '{}' } },
{ type: 'usage', usage: { inputTokens: 20, outputTokens: 5 } },
{ type: 'finish', reason: { kind: 'max-tokens' } },
],
])
ctx.tools.register(defineTool({
name: 'noop',
description: 'does nothing',
parameters: {},
output: {
schema: { type: 'object', additionalProperties: false, properties: {} },
render: () => [{ type: 'text', text: 'noop' }],
},
execute: () => Promise.resolve({}),
}))
const ends: SubagentRunEndInfo[] = []
ctx.on('subagent/end', (info) => { ends.push(info) })
const started = await ctx.subagents.startContinuable(startSpec(parent))
await waitNoActivation(ctx, started.childId)
await vi.waitFor(() => { expect(ends).toHaveLength(1) })
expect(ends[0]!.stopReason).toBe('max-tokens')
expect(ends[0]!.lastAssistantMessage).toEqual([
{ type: 'text', text: 'partial one' },
{ type: 'tool-call', id: 't1', name: 'noop', arguments: '{}' },
])
})
it('reports a resumed epoch that opened no turn without the previous answer', async () => {
const { ctx, parent } = await setup([textResponse('first answer')])
const started = await ctx.subagents.startContinuable(startSpec(parent))

View File

@@ -15,6 +15,7 @@ import SubagentService, {
type SubagentProvider,
type SubagentResult,
type SubagentRun,
type SubagentRunEndInfo,
type SubagentStartRequest,
} from '@deepseek-ai/dsh-subagent'
import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
@@ -263,6 +264,17 @@ describe('SubagentService', () => {
stopReason: 'completed',
}))
// The lifecycle event omits lastAssistantMessage when output is empty,
// matching the continuable epoch event.
const silent = new StubProvider('silent', NO_CAPS, { output: [], stopReason: 'completed' })
subagents.registerProvider(silent)
const silentRun = await subagents.start('silent', baseRequest())
await silentRun.result
await Promise.resolve()
const silentEnd = ended.mock.calls.map(call => call[0] as SubagentRunEndInfo).find(info => info.provider === 'silent')
expect(silentEnd).toBeDefined()
expect('lastAssistantMessage' in silentEnd!).toBe(false)
const failure = Promise.withResolvers<SubagentResult>()
subagents.registerProvider({
name: 'infra',

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/subagent/tool-subagent/README.md
README.md: 6ec313b3b97f0ffa7488025d4314b1c6231a6f6a
README.zh.md: 1fd88363b3ade9d57c194580f81295eed139ac50
README.md: ac3ec0563cce9128608ca31860b034a103dc1a3a
README.zh.md: d64831d7cf64800ad3307ce6cb7f294500a0a6f0

View File

@@ -8,7 +8,7 @@ The model-facing delegation tool over one configured `ctx.subagents` provider. C
Each plugin instance binds one `provider` to one `toolName`; the model receives no provider selector. Load another distinctly named instance to expose another transport. The tool registers only while its provider exists, avoiding sibling load-order and provider-reload dependencies. Its description follows `provider.inheritsParentContext`: fresh children require standalone prompts, while forked children already see completed parent turns.
A foreground call passes the execution signal through startup and execution, awaits `run.result`, and always awaits `run.dispose()` before returning. Only `completed` returns the canonical `{ kind: 'foreground', runId, output: JsonValue[] }`, rendered as the same final text; abort, refusal, token limit, and other failures become errored tool results without partial output. If result collection and disposal both reject, the errored result preserves both diagnostics.
A foreground call passes the execution signal through startup and execution, awaits `run.result`, and always awaits `run.dispose()` before returning. Only `completed` returns the canonical `{ kind: 'foreground', runId, output: JsonValue[] }`, rendered as the same final text; abort, refusal, token limit, and other failures become errored tool results whose message appends the child's preserved partial text (the `SubagentResult.output` selection) after the stop-reason headline, so a truncated answer is never reported as success yet never silently lost. If result collection and disposal both reject, the errored result preserves both diagnostics.
With `run_in_background: true`, `backgroundMode` selects the route. `one-shot` registers a plain parent-owned Task and returns canonical `{ kind: 'background', taskId }`, rendered as `started background subagent task <id>`, even when the provider supports continuable children; generic task tools own its later status, collection, cancellation, and notices. `continuable` requires a provider with the `prepareContinuable` capability, calls `ctx.subagents.startContinuable()`, and returns `{ kind: 'continuable', subagentId }`, rendered as `started subagent <childId>`. The continuable route resolves at inbox acceptance: the child owns its own turns from there, so this call neither waits for nor collects a result, and the child does not report back — its transcript by that id is the source of its output, and the optional global `send_message` tool sends it more work. Starting continuable work does not require `send_message` to be loaded. See the [background subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md), the [continuable subagents Agent Note](../../../.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md), and the [merged-service Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md).

View File

@@ -8,7 +8,7 @@
每个插件实例把一个 `provider` 绑定到一个 `toolName`;模型不会收到提供方选择器。如需公开另一种传输,请加载另一个名称不同的实例。工具只在其提供方存在时注册,从而避免对同级加载顺序和提供方重新加载的依赖。工具描述遵循 `provider.inheritsParentContext`:新建子 agent智能体需要独立提示词而 fork 子 agent 已能看到父级已完成轮次。
前台调用会让执行信号贯穿启动和执行,等待 `run.result`,并且在返回前总会等待 `run.dispose()`。只有 `completed` 会返回规范值 `{ kind: 'foreground', runId, output: JsonValue[] }`并渲染为相同的最终文本中止、拒绝、token 上限和其他失败都会变成出错的工具结果,不包含局部输出。如果结果收集与 dispose资源释放都 reject出错的结果会保留两项诊断信息。
前台调用会让执行信号贯穿启动和执行,等待 `run.result`,并且在返回前总会等待 `run.dispose()`。只有 `completed` 会返回规范值 `{ kind: 'foreground', runId, output: JsonValue[] }`并渲染为相同的最终文本中止、拒绝、token 上限和其他失败都会变成出错的工具结果,其消息在终止原因标题之后附带子代理保留下来的部分文本(即 `SubagentResult.output` 的选取结果)——被截断的回答不会被报告为成功,也绝不会被悄悄丢弃。如果结果收集与 dispose资源释放都 reject出错的结果会保留两项诊断信息。
设置 `run_in_background: true` 后,`backgroundMode` 会选择路由。`one-shot` 会注册一个归父级所有的普通 Task并返回规范值 `{ kind: 'background', taskId }`,渲染为 `started background subagent task <id>`,即使提供方支持可继续子 agent 也不例外;通用 Task 工具负责其后续状态、收集、取消和通知。`continuable` 要求提供方具备 `prepareContinuable` 能力,调用 `ctx.subagents.startContinuable()`,并返回 `{ kind: 'continuable', subagentId }`,渲染为 `started subagent <childId>`。可继续路由在 inbox 接受时结算:子 agent 自此拥有自己的轮次,因此该调用既不等待也不收集结果,而且子 agent 不会回报——通过该 id 查看其 transcript文本记录即是其输出来源可选的全局 `send_message` 工具则向其发送更多工作。启动可继续工作不要求加载 `send_message`。见 [后台 subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md)、[可继续的 subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md)和[服务合并 Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)。

View File

@@ -134,6 +134,21 @@ function stopReasonError(result: SubagentResult): string | undefined {
}
}
/**
* Append the child's preserved partial answer to a stop-reason error so a
* truncated or cancelled child's real text still reaches the parent model.
* @param error - the stop-reason headline.
* @param output - the child's selected output (`SubagentResult.output`).
* @returns the headline, extended with the partial text when any exists.
*/
function withPartialText(error: string, output: ContentBlock[]): string {
const text = output
.filter((block): block is Extract<ContentBlock, { type: 'text' }> => block.type === 'text')
.map(block => block.text)
.join('')
return text.length === 0 ? error : `${error}\nPartial output before the run ended:\n${text}`
}
type ForegroundToolResult = {
readonly kind: 'foreground'
readonly runId: SubagentRun['id']
@@ -149,8 +164,9 @@ async function settleForegroundRun(run: SubagentRun): Promise<ForegroundToolResu
run.result.then((result): ForegroundToolResult => {
const error = stopReasonError(result)
if (error !== undefined) {
// The registry converts this throw to isError; partial output is not success.
throw new Error(error)
// The registry converts this throw to isError; partial output is not
// success, but the preserved partial answer still reaches the parent.
throw new Error(withPartialText(error, result.output))
}
return {
kind: 'foreground',

View File

@@ -154,6 +154,9 @@ describe('dsh-tool-subagent', () => {
const result = await callSubagent(ctx, { description: 'd', prompt: 'p' })
expect(result.isError).toBe(true)
expect(text(result)).toContain(fragment)
// The failure is not partial success, but the child's preserved partial
// answer still reaches the parent model inside the error result.
expect(text(result)).toContain('scripted subagent reply')
})
it('registers under a configurable toolName so multiple providers can coexist', async () => {