Merge remote-tracking branch 'origin/master' into feature/workspace-picker-composer
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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 依赖 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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` 不含 ACP(Agent Client Protocol)命名的 `refusal`/`max_turn_requests` 变体**:受生产方约束;只有当适配器或循环首次产生这些变体时才加入。
|
||||
|
||||
@@ -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) {
|
||||
|
||||
59
packages/core/session/src/known-event-types.ts
Normal file
59
packages/core/session/src/known-event-types.ts
Normal 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',
|
||||
])
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -1,7 +0,0 @@
|
||||
# experimental/:实验性与内部专用包
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
该分组容纳工程人员与产品经理在团队内共享的原型,以及内部专用 Cordis 插件。该分组不纳入官方发布版本;包在发布前移入对应的产品角色分组。
|
||||
|
||||
该分组尚未包含任何包。[子树规则](AGENTS.md)界定不作保证、依赖关系和提升机制的边界。
|
||||
@@ -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). */
|
||||
|
||||
@@ -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' },
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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`。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -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[]
|
||||
}
|
||||
|
||||
|
||||
@@ -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: [],
|
||||
},
|
||||
}])
|
||||
})
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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. |
|
||||
|
||||
|
||||
@@ -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 修复后会改变;不会仅因两个存储使用相同本地计数器而冲突。可选信号请求取消后端发现工作;第一方后端会先等待所有已启动的列出工作结束,再予以拒绝,因此调用返回拒绝时,相关工作已完全停稳。 |
|
||||
|
||||
|
||||
@@ -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)`)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 阶梯使进程实际退出。
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 的工具限制,也不会建立权限子集。
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 会话,其一次性运行不会进入基于追踪的枚举结果。
|
||||
|
||||
|
||||
74
packages/subagent/subagent/src/assistant-output.ts
Normal file
74
packages/subagent/subagent/src/assistant-output.ts
Normal 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()
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
92
packages/subagent/subagent/tests/assistant-output.spec.ts
Normal file
92
packages/subagent/subagent/tests/assistant-output.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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))
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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)。
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user