From a6baddaaacb8acd0bfcbd6c28827310393fe8c54 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Thu, 30 Jul 2026 16:48:28 +0800 Subject: [PATCH] refactor: remove per-followup result attribution --- ...-followup-enqueue-and-owned-runs.i18n.yaml | 6 + ...6-07-30-followup-enqueue-and-owned-runs.md | 43 +++++ ...7-30-followup-enqueue-and-owned-runs.zh.md | 43 +++++ docs/architecture.i18n.yaml | 4 +- docs/architecture.md | 2 +- docs/architecture.zh.md | 4 +- docs/cookbook/extension-cookbook.i18n.yaml | 4 +- docs/cookbook/extension-cookbook.md | 5 +- docs/cookbook/extension-cookbook.zh.md | 5 +- docs/core-data-structures/core.i18n.yaml | 4 +- docs/core-data-structures/core.md | 168 ++++-------------- docs/core-data-structures/core.zh.md | 168 ++++-------------- docs/core-data-structures/session.i18n.yaml | 4 +- docs/core-data-structures/session.md | 47 ++--- docs/core-data-structures/session.zh.md | 47 ++--- docs/defensive-patterns.i18n.yaml | 4 +- docs/defensive-patterns.md | 2 +- docs/defensive-patterns.zh.md | 2 +- docs/persistence-catalog.md | 55 ++++-- packages/acp/acp/README.i18n.yaml | 4 +- packages/acp/acp/README.md | 4 +- packages/acp/acp/README.zh.md | 4 +- packages/acp/acp/src/codec.ts | 25 +-- packages/acp/acp/src/index.ts | 121 +++---------- packages/core/agent/README.i18n.yaml | 4 +- packages/core/agent/README.md | 22 ++- packages/core/agent/README.zh.md | 22 ++- packages/core/session/README.i18n.yaml | 4 +- packages/core/session/README.md | 6 +- packages/core/session/README.zh.md | 6 +- packages/examples/cli-demo/README.i18n.yaml | 4 +- packages/examples/cli-demo/README.md | 16 +- packages/examples/cli-demo/README.zh.md | 16 +- packages/examples/cli-demo/src/cli.ts | 110 +++--------- packages/goal/goal-session/README.i18n.yaml | 4 +- packages/goal/goal-session/README.md | 21 +-- packages/goal/goal-session/README.zh.md | 21 +-- packages/goal/goal-session/src/index.ts | 103 +++-------- packages/goal/goal-session/src/outcome.ts | 53 ------ packages/sdk/sdk-client/README.i18n.yaml | 4 +- packages/sdk/sdk-client/README.md | 14 +- packages/sdk/sdk-client/README.zh.md | 14 +- packages/sdk/sdk-client/src/api.ts | 79 ++++---- packages/sdk/sdk-client/src/client.ts | 11 +- packages/sdk/sdk-client/src/index.ts | 4 +- packages/sdk/sdk-client/src/types.ts | 19 +- packages/sdk/sdk-client/tests/fake-runtime.ts | 25 +-- .../sdk/sdk-client/tests/sdk-client.spec.ts | 48 ++--- packages/sdk/sdk-protocol/README.i18n.yaml | 4 +- packages/sdk/sdk-protocol/README.md | 6 +- packages/sdk/sdk-protocol/README.zh.md | 6 +- packages/sdk/sdk-protocol/src/index.ts | 2 +- packages/sdk/sdk-protocol/src/types.ts | 22 ++- .../sdk/sdk-protocol/tests/transport.spec.ts | 4 +- .../subagent-dsh-sdk/README.i18n.yaml | 4 +- packages/subagent/subagent-dsh-sdk/README.md | 4 +- .../subagent/subagent-dsh-sdk/README.zh.md | 4 +- packages/subagent/subagent-dsh-sdk/src/run.ts | 9 +- .../subagent-inprocess/README.i18n.yaml | 4 +- .../subagent/subagent-inprocess/README.md | 4 +- .../subagent/subagent-inprocess/README.zh.md | 4 +- packages/ui/jsonrpc/README.i18n.yaml | 4 +- packages/ui/jsonrpc/README.md | 7 +- packages/ui/jsonrpc/README.zh.md | 7 +- packages/ui/jsonrpc/src/server.ts | 39 ++-- python/sdk/README.i18n.yaml | 4 +- python/sdk/README.md | 4 +- python/sdk/README.zh.md | 4 +- python/sdk/src/deepseek_harness/__init__.py | 4 +- python/sdk/src/deepseek_harness/api.py | 45 +++-- python/sdk/src/deepseek_harness/client.py | 9 +- scripts/type-equiv.manifest.json | 36 +--- 72 files changed, 586 insertions(+), 1059 deletions(-) create mode 100644 .agents/notes/proposed/architecture/2026-07-30-followup-enqueue-and-owned-runs.i18n.yaml create mode 100644 .agents/notes/proposed/architecture/2026-07-30-followup-enqueue-and-owned-runs.md create mode 100644 .agents/notes/proposed/architecture/2026-07-30-followup-enqueue-and-owned-runs.zh.md delete mode 100644 packages/goal/goal-session/src/outcome.ts diff --git a/.agents/notes/proposed/architecture/2026-07-30-followup-enqueue-and-owned-runs.i18n.yaml b/.agents/notes/proposed/architecture/2026-07-30-followup-enqueue-and-owned-runs.i18n.yaml new file mode 100644 index 0000000000..1027cfb0f0 --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-07-30-followup-enqueue-and-owned-runs.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/proposed/architecture/2026-07-30-followup-enqueue-and-owned-runs.md +2026-07-30-followup-enqueue-and-owned-runs.md: 73dfb501cb5c18a7a9219861eba37e73499af5e0 +2026-07-30-followup-enqueue-and-owned-runs.zh.md: 03a321c761eda385acb665d26a33ef618c70dee5 diff --git a/.agents/notes/proposed/architecture/2026-07-30-followup-enqueue-and-owned-runs.md b/.agents/notes/proposed/architecture/2026-07-30-followup-enqueue-and-owned-runs.md new file mode 100644 index 0000000000..73dfb501cb --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-07-30-followup-enqueue-and-owned-runs.md @@ -0,0 +1,43 @@ +# Agent Note: Follow-up enqueue and owned run boundaries + +Status: proposed + +English | [中文](2026-07-30-followup-enqueue-and-owned-runs.zh.md) + +## Problem + +`Agent.followup()` identifies and queues a user message, but one follow-up does not own the activity that follows it. Steering, injected context, tool continuations, recovery, and later queued messages can all contribute before the agent next becomes idle. A `MessageId` can therefore prove inbox admission, but it cannot identify which assistant message or `turn/end` is the result of that input. + +The [one-send-one-turn decision](../../implemented/simplification/2026-07-17-one-send-one-turn.md) already rejects a per-send completion handle at the core seam. Protocol and SDK layers currently manufacture that missing relationship downstream by pairing one prompt request with a turn result. The pairing becomes ambiguous as soon as activity admits more input, and it exposes turn mechanics as if they were a prompt-level outcome. + +## Proposal + +Keep `Agent.followup(message): void` as an enqueue-only operation. `Agent.whenIdle()` and `agent/status` remain whole-agent lifecycle observations; neither settles an individual message. Inbox durability records the identified message and its admission or cancellation, without assigning later output to it. + +The low-level SDK protocol will answer `session/prompt` as soon as enqueue succeeds with `{ messageId }`. It will stream durable facts through `session.event`, publish whole-agent transitions through `session.status`, and remove `session.finished`. A low-level client may observe that receipt and later idleness, but receives no prompt result. + +High-level automation APIs may return a `RunResult` only when they explicitly own an activity interval. The TypeScript and Python SDK `run()` methods will collect from the submitted message's durable inbox receipt through the next whole-agent `idle`; their `finalResponse` is the last committed assistant message in that interval, not a response causally attributed to the submitted prompt. The one-shot CLI owns the analogous idle-to-idle interval. An isolated child-agent run may still report a result because its caller owns the complete child lifecycle and any steering belongs to that run. + +ACP must still return a protocol `stopReason`. Its bridge will serialize one in-flight prompt per ACP session, wait for whole-agent idle, report `cancelled` only for explicit ACP cancellation or disposal, and otherwise report the generic `end_turn`. It will not infer token-limit or error attribution for the prompt. + +Goal continuation will retain `MessageId` only to recognize its durable queued and admitted goal message. It will advance from durable goal state at whole-agent idle, without mapping the message to a turn result. + +## Alternatives considered + +**Map `MessageId` to the turn that admits it.** A turn may consume steering and injected context and may continue through multiple model/tool steps. The mapping identifies admission, not causal ownership of the resulting output or stop reason. + +**Return a per-follow-up completion handle.** A handle would imply a result boundary that the shared agent lifecycle does not have. It would either omit work that influenced the activity or silently absorb unrelated later input. + +**Use the last `turn/end` observed before idle.** This is a useful run-level observation for an explicitly owned interval, but naming it as the submitted message's outcome recreates the false causal claim. + +## Acceptance criteria + +- `Agent.followup()` remains enqueue-only, and its documentation promises no per-message completion or result. +- The SDK wire protocol returns `MessageId` from `session/prompt`, publishes `session.status`, and has no `session.finished`. +- TypeScript and Python high-level SDKs expose `RunResult` without prompt-level `status` or `reason`, and define the receipt-to-idle collection window. +- ACP, the one-shot CLI, goal continuation, and subagent providers document the distinct activity ownership they actually possess. +- No production consumer derives a follow-up result by correlating `MessageId` with `turn/end`. + +## Risks + +An owned activity interval can include steering, injected context, or other work submitted before idleness, so its final response and events are deliberately broader than the initiating message. Prompt-level model error and token-limit classifications disappear from SDK and ACP results; callers that need those facts must inspect the durable event stream without claiming causal attribution. Concurrent automation on one session requires an explicit serialization or ownership policy rather than an implicit per-prompt result. diff --git a/.agents/notes/proposed/architecture/2026-07-30-followup-enqueue-and-owned-runs.zh.md b/.agents/notes/proposed/architecture/2026-07-30-followup-enqueue-and-owned-runs.zh.md new file mode 100644 index 0000000000..03a321c761 --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-07-30-followup-enqueue-and-owned-runs.zh.md @@ -0,0 +1,43 @@ +# Agent Note: follow-up 入队与自有运行边界 + +Status: proposed + +[English](2026-07-30-followup-enqueue-and-owned-runs.md) | 中文 + +## 问题 + +`Agent.followup()` 会标识一条用户消息并将其排入队列,但单次 follow-up 并不拥有随后发生的活动。在 agent(智能体)下一次进入 idle 前,steering(中途引导)、注入的上下文、工具续行、恢复和后续排队消息都可能参与活动。因此,`MessageId` 可以证明 inbox 已准入,但不能标识哪一条 assistant 消息或哪一个 `turn/end` 是该输入的结果。 + +[one-send-one-turn 决策](../../implemented/simplification/2026-07-17-one-send-one-turn.md) 已经在核心 seam 中排除了按 send 返回完成句柄的设计。协议层和 SDK 层仍会在下游配对一项提示词请求与一个轮次结果,人为构造这一缺失的关系。一旦活动准入更多输入,该配对就会产生歧义,还会把轮次机制暴露为提示词级结果。 + +## 提案 + +保留 `Agent.followup(message): void`,使其仅执行入队。`Agent.whenIdle()` 和 `agent/status` 仍用于观察整个 agent 的生命周期;二者都不结算单条消息。Inbox 持久性会记录已标识消息及其准入或取消,但不会把后续输出归属于该消息。 + +底层 SDK 协议在入队成功后立即以 `{ messageId }` 响应 `session/prompt`。它通过 `session.event` 传输持久事实,通过 `session.status` 发布整个 agent 的状态转换,并删除 `session.finished`。底层客户端可以观察该回执和之后的 idle,但不会收到提示词结果。 + +只有明确拥有一个活动区间时,高层自动化 API 才可以返回 `RunResult`。TypeScript 和 Python SDK 的 `run()` 方法会从已提交消息的持久 inbox 回执开始收集,直至整个 agent 下一次进入 `idle`;其 `finalResponse` 是该区间内最后一条已提交的 assistant 消息,而不是按因果关系归属于已提交提示词的响应。单次 CLI(命令行界面)拥有相应的 idle 到 idle 区间。隔离的子 agent 运行仍可报告结果,因为调用方拥有完整的子级生命周期,任何 steering 都属于该运行。 + +ACP(Agent Client Protocol)仍必须返回协议规定的 `stopReason`。其桥接层会串行处理每个 ACP 会话中唯一一个正在处理的提示词,等待整个 agent 进入 idle,仅在显式 ACP 取消或资源释放时报告 `cancelled`,其他情况均报告通用的 `end_turn`。它不会推断 token 上限或错误是否归属于该提示词。 + +Goal 续行只会保留 `MessageId`,用于识别持久排队和已准入的 goal 消息。它会在整个 agent 进入 idle 时根据持久 goal 状态推进,不把消息映射到轮次结果。 + +## 考虑过的替代方案 + +**将 `MessageId` 映射到准入它的轮次。** 一个轮次可能使用 steering 和注入的上下文,还可能经过多个模型/工具步骤继续执行。该映射只能标识准入,不能确立结果输出或停止原因的因果归属。 + +**返回按 follow-up 区分的完成句柄。** 这样的句柄暗示共享 agent 生命周期中存在并不实际成立的结果边界。它要么遗漏影响活动的工作,要么在不作说明的情况下吸收后续无关输入。 + +**使用进入 idle 前观察到的最后一个 `turn/end`。** 对于明确拥有的区间,这是一项有用的运行级观测;但如果将其命名为已提交消息的结果,就会再次作出错误的因果声明。 + +## 验收标准 + +- `Agent.followup()` 仍仅执行入队,其文档不承诺单条消息的完成状态或结果。 +- SDK 协议格式(wire format)由 `session/prompt` 返回 `MessageId`、发布 `session.status`,且不包含 `session.finished`。 +- TypeScript 和 Python 高层 SDK 公开不带提示词级 `status` 或 `reason` 的 `RunResult`,并定义从回执到 idle 的收集窗口。 +- ACP、单次 CLI、goal 续行和 subagent 提供方分别记录自己实际拥有的活动边界。 +- 生产消费方都不会通过关联 `MessageId` 与 `turn/end` 来推导 follow-up 结果。 + +## 风险 + +自有活动区间可以包含进入 idle 前提交的 steering、注入上下文或其他工作,因此其最终响应和事件有意比初始消息涵盖更广。SDK 和 ACP 结果不再包含提示词级模型错误和 token 上限分类;需要这些事实的调用方必须检查持久事件流,但不能声称这些事实具有因果归属。在同一会话上并发执行自动化操作时,必须采用显式串行或所有权策略,不能依赖隐式的按提示词结果。 diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index dfff50708f..c05e2c0bc9 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/architecture.md -architecture.md: 1fd9bd128d1bcc0dd91d46131981ea4fc331bd74 -architecture.zh.md: 8521f09c6e415f9f8d1c0a44f7534b59c876decc +architecture.md: d8e5f8a2e8d36acb7d27ed0571645f6166eff3af +architecture.zh.md: 96948aa5283e0114b1883335b91148ce3b720e06 diff --git a/docs/architecture.md b/docs/architecture.md index 3149b6b0d4..d8e5f8a2e8 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -131,7 +131,7 @@ Turn and step events are turn-enclosed; idle injected `user/message` events may ### Agent Handles -`ctx.agents` returns `AgentHandle { agent, dispose() }`. Plugins drive agents with `followup()`, `steer()`, and `inject()`; `cancel()` stops work, while the awaited disposer owns teardown. +`ctx.agents` returns `AgentHandle { agent, dispose() }`. Plugins drive agents with `followup()`, `steer()`, and `inject()`; `cancel()` stops work, while the awaited disposer owns teardown. `followup()` only queues an identified message: its `MessageId` follows durable inbox admission, not a prompt-specific output or turn ending. `agent/status` and `whenIdle()` describe whole-agent activity; only a caller that explicitly owns an activity interval may summarize that interval as a run result ([proposal](../.agents/notes/proposed/architecture/2026-07-30-followup-enqueue-and-owned-runs.md)). ### Agent Scope diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index dd3b66ee8a..96948aa528 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -123,7 +123,7 @@ idle inject: ### 失败边界 -最终适配器选择、分发与迭代失败会在 loop 处理前成为终止 `finish { kind: 'error' | 'aborted', failure }` chunk。`agent/request-error` 接收请求坐标、标准化 `LlmFailure`、可用时的准备注册重试策略以及信号;middleware 与消费方错误仍在请求恢复之外抛出。失败分片既不提交消息,也不提交工具调用。 +最终适配器选择、分发与迭代失败会在 loop 处理前成为终止 `finish { kind: 'error' | 'aborted', failure }` chunk。`agent/request-error` 接收请求坐标、标准化 `LlmFailure`、可用时已准备注册项的重试策略以及信号;middleware 与消费方错误仍在请求恢复之外抛出。失败分片既不提交消息,也不提交工具调用。 其他故障使用 `agent/error`。取消和资源释放优先于恢复。在提交请求头之前,轮次信号会取消异步模型能力准备;尚未分派的工具会得到合成的 `tool/call`/`ABORTED_BEFORE_DISPATCH` 对。实际生效的 `cancel(cause)` 在清空队列和中止前发出原因;观察方不能否决;空闲调用不发事件。持久化层将用户或父级取消记录为 `aborted`,拆卸记录为 `disposed`;拆卸会等待完全停稳。原因只影响报告方式,不影响延迟完成的结果上下文处理([决策](../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md))。 @@ -131,7 +131,7 @@ idle inject: ### Agent 句柄 -`ctx.agents` 返回 `AgentHandle { agent, dispose() }`。插件用 `followup()`、`steer()` 和 `inject()` 驱动 agent;`cancel()` 停止工作,而拆卸由需等待完成的 disposer 负责。 +`ctx.agents` 返回 `AgentHandle { agent, dispose() }`。插件用 `followup()`、`steer()` 和 `inject()` 驱动 agent;`cancel()` 停止工作,而拆卸由需等待完成的 disposer 负责。`followup()` 只会将一条带标识的消息排队:其 `MessageId` 跟踪持久 inbox 准入,而不标识某个提示词特有的输出或轮次结束。`agent/status` 与 `whenIdle()` 描述整个 agent 的活动;只有显式拥有某个活动区间的调用方才能将该区间概括为一次运行的结果([提案](../.agents/notes/proposed/architecture/2026-07-30-followup-enqueue-and-owned-runs.md))。 ### Agent 作用域 diff --git a/docs/cookbook/extension-cookbook.i18n.yaml b/docs/cookbook/extension-cookbook.i18n.yaml index 9ff53f33c3..e6384af8ca 100644 --- a/docs/cookbook/extension-cookbook.i18n.yaml +++ b/docs/cookbook/extension-cookbook.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/cookbook/extension-cookbook.md -extension-cookbook.md: 36ab56dcdce1166ef69cec7834f6c17be72d89c3 -extension-cookbook.zh.md: 8c8f9486ec592fcc80f1053f54adce2e33798d4b +extension-cookbook.md: 1d2945d132b73e093e313820878ed2fdc0746f8f +extension-cookbook.zh.md: 6fceb8a3110a914fdb1b26146f06f3f20c644b42 diff --git a/docs/cookbook/extension-cookbook.md b/docs/cookbook/extension-cookbook.md index 36ab56dcdc..1d2945d132 100644 --- a/docs/cookbook/extension-cookbook.md +++ b/docs/cookbook/extension-cookbook.md @@ -64,7 +64,7 @@ export function apply(ctx: Context) { ## An external protocol driver -A *protocol driver* adapts a wire peer to `ctx.agents`; it may serve a UI or an automation client. A stdio driver owns stdout, creates or resumes agents through the factory, maps the protocol's requests to `followup()` or `cancel()`, and settles each request exactly once from durable `turn/end`. Tear agents down with `AgentHandle.dispose()` so disposal reaches quiescence. +A *protocol driver* adapts a wire peer to `ctx.agents`; it may serve a UI or an automation client. A stdio driver owns stdout, creates or resumes agents through the factory, and maps protocol requests to `followup()` or `cancel()`. A low-level prompt request returns its durable enqueue receipt; it does not acquire a result by correlating `MessageId` with `turn/end`. Publish whole-agent status separately. An automation method may wait from its receipt through the next idle and summarize that explicitly owned interval, while a UI normally keeps observing the open-ended event stream. Tear agents down with `AgentHandle.dispose()` so disposal reaches quiescence. [`packages/acp/acp`](../../packages/acp/acp) is the automation-only worked example: it exposes fresh text sessions over Agent Client Protocol JSON-RPC stdio, emits committed assistant text, and registers a one-shot machine permission answerer for agents it owns. Its [README](../../packages/acp/acp/README.md) owns the exact method and lifecycle contract. @@ -84,7 +84,8 @@ export function apply(ctx: Context) { } } }) - // Inbound "prompt": create/resume an agent and feed it; settle on turn end. + // Inbound "prompt": create/resume an agent, feed it, and return its enqueue receipt. + // Whole-agent status is a separate notification; no turn end belongs to this prompt. // Teardown reaches quiescence via AgentHandle.dispose() (stop + await exit). } ``` diff --git a/docs/cookbook/extension-cookbook.zh.md b/docs/cookbook/extension-cookbook.zh.md index 8c8f9486ec..6fceb8a311 100644 --- a/docs/cookbook/extension-cookbook.zh.md +++ b/docs/cookbook/extension-cookbook.zh.md @@ -64,7 +64,7 @@ export function apply(ctx: Context) { ## 外部协议驱动 -*协议驱动*将协议对端接入 `ctx.agents`;它可以服务于 UI 或自动化客户端。stdio 驱动拥有 stdout,通过工厂创建或恢复 agent(智能体),将协议请求映射为 `followup()` 或 `cancel()`,并根据持久的 `turn/end` 对每个请求恰好结算一次。通过 `AgentHandle.dispose()` 拆除 agent,以使 dispose(资源释放)达到完全停稳。 +*协议驱动*将协议对端接入 `ctx.agents`;它可以服务于 UI 或自动化客户端。stdio 驱动拥有 stdout,通过工厂创建或恢复 agent(智能体),并将协议请求映射为 `followup()` 或 `cancel()`。底层提示词请求返回其持久入队回执;它不会通过关联 `MessageId` 与 `turn/end` 获得结果。整个 agent 的状态应单独发布。自动化方法可以从回执等待到下一次 idle,并概括这一显式拥有的区间;UI 通常则会持续观察开放式事件流。通过 `AgentHandle.dispose()` 拆除 agent,以使 dispose(资源释放)达到完全停稳。 [`packages/acp/acp`](../../packages/acp/acp) 是仅面向自动化的完整示例:它通过 ACP(Agent Client Protocol)JSON-RPC stdio 提供全新文本会话,发出已提交的助手文本,并为其拥有的 agent 注册一次性机器权限应答器。其 [README](../../packages/acp/acp/README.md) 拥有精确的方法和生命周期契约。 @@ -84,7 +84,8 @@ export function apply(ctx: Context) { } } }) - // Inbound "prompt": create/resume an agent and feed it; settle on turn end. + // Inbound "prompt": create/resume an agent, feed it, and return its enqueue receipt. + // Whole-agent status is a separate notification; no turn end belongs to this prompt. // Teardown reaches quiescence via AgentHandle.dispose() (stop + await exit). } ``` diff --git a/docs/core-data-structures/core.i18n.yaml b/docs/core-data-structures/core.i18n.yaml index 6321c85127..f0ab5d462e 100644 --- a/docs/core-data-structures/core.i18n.yaml +++ b/docs/core-data-structures/core.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/core-data-structures/core.md -core.md: dad533cee00646a40f57bd9097b2cceb8e9de9e2 -core.zh.md: 9e8afac0744fcf0df8c35dad5debce746d6614c6 +core.md: b7e1c11b488dc751f4d50f4616a6bf20186d06d5 +core.zh.md: 0e204ef9ce51db66dac491ffcb2a32682b8f4826 diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index ec2fe0f736..b7e1c11b48 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -67,14 +67,13 @@ declare module '@deepseek-ai/dsh-llm' { } ``` -Six canonical maps use this pattern; a plugin author extends these: +Five canonical maps use this pattern; a plugin author extends these: | Map | Package | Derives | Catalog | |---|---|---|---| | `ContentBlockMap` | dsh-llm | `ContentBlock` | [below](#content-blocks-and-messages) | | `MessageSourceMap` | dsh-llm | `MessageSource` | [below](#content-blocks-and-messages) | | `FinishReasonMap` | dsh-llm | `FinishReason` | [below](#the-model-request-and-result) | -| `TurnTriggerMap` | dsh-session | `TurnTrigger` | [session.md](session.md) | | `TurnEndReasonMap` | dsh-session | `TurnEndReason` | [session.md](session.md) | | `SessionEventMap` | dsh-session | `SessionEvent` | [session.md](session.md) | @@ -406,7 +405,7 @@ type SessionEvent = { }[T] ``` -The twelve event variants (`turn/start`, `turn/end`, `step/start`, `step/end`, `user/message`, `assistant/chunk`, `assistant/message`, `tool/call`, `tool/result`, `steering/message`, `todo/write`, `request/header`), the `deriveMessages()` projection rules, the `TurnTrigger`/`TurnEndReason` reasons, and the execution-enclosure and standalone-event rules are on **[session.md](session.md)**. How the log is made durable — the `SessionPersistence` seam, JSONL/SQLite backends, the `session/flush` checkpoint, crash recovery, and `SessionHeader` — is on **[persistence.md](persistence.md)**. +The session event variants, `deriveMessages()` projection rules, `TurnEndReason` vocabulary, and execution-enclosure and standalone-event rules are on **[session.md](session.md)**. How the log is made durable — the `SessionPersistence` seam, JSONL/SQLite backends, the `session/flush` checkpoint, crash recovery, and `SessionHeader` — is on **[persistence.md](persistence.md)**. ## The agent handle @@ -415,60 +414,11 @@ The twelve event variants (`turn/start`, `turn/end`, `step/start`, `step/end`, ` Source: [`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types.ts) ```ts type-equiv -/** Resolved inbox placement reported when an accepted message is enqueued. */ -type InboxPlacement = 'queued' | 'steering' +/** One of the two ordered pending-message lists owned by an agent. */ +type InboxTarget = 'next-turn' | 'next-step' ``` -`InboxItemId` is a process-local branded string minted for each accepted FIFO occurrence. It is intentionally distinct from `MessageId`: sending the same immutable message twice creates two independently addressable pending items. - -```ts type-equiv -/** One independently addressable accepted occurrence in an agent inbox. */ -interface InboxItem { - /** Agent-loop-minted occurrence identity. */ - readonly id: InboxItemId - /** Identified message delivered by the caller. */ - readonly message: UserMessage - /** Acceptance-time FIFO classification. */ - readonly placement: InboxPlacement -} -``` - -```ts type-equiv -/** A user-requested mutation of one still-pending queued occurrence. */ -type InboxAction = - | { readonly kind: 'edit'; readonly content: ContentBlock[] } - | { readonly kind: 'remove' } -``` - -```ts type-equiv -/** Result of applying an inbox action at the synchronous ownership boundary. */ -type InboxActionResult = 'applied' | 'not-found' -``` - -```ts type-equiv -/** - * Options for the unified {@link Agent.send} primitive over the - * (`target` × `wakeup`) matrix. Named presets: {@link Agent.followup} - * (`next-turn`/wakeup), {@link Agent.steer} (`next-step`/wakeup), and - * {@link Agent.inject} (`next-step`/no-wakeup). - * - * The object is complete so routing policy is explicit. - */ -interface SendOptions { - /** Queue the item joins. */ - target: SendTarget - /** - * Whether this item makes the model run: wake a parked driver (`next-turn`) - * or force a continuation step (`next-step` while running). A `false` - * `next-turn` item queues without waking; a `false` - * `next-step` item attaches durable context without forcing another step - * (the injection preset). - */ - wakeup: boolean -} -``` - -The fixed-preset aliases own `target` and `wakeup`; their already identified `UserMessage` carries role, content, and provenance. Its `MessageId` remains stable when an edit replaces the message content, while the enclosing `InboxItemId` identifies one accepted occurrence across `agent/inbox/enqueue`, `agent/inbox/update`, and its terminal dequeue or discard. Injection bypasses the FIFOs and never appears on those events. +Every pending occurrence is its `UserMessage`; `MessageId` is the sole identity. `Inbox.splice(target, start, deleteCount, inserted, outcome?)` uses standard splice coordinates, rejects duplicate pending message ids, and records the normalized mutation as durable `agent/inbox/spliced`. Replaying those events reconstructs both `nextTurn` and `nextStep`, including edits, insertion, admission, and cancellation. ```ts type-equiv /** Options for {@link Agent.cancel}. */ @@ -476,26 +426,25 @@ interface CancelOptions { /** * Preserve queued and steering inbox items instead of discarding them. The * active turn is still aborted, but un-started and pending work survives for a - * later turn and no `agent/inbox/discard` fires. + * later turn and no canceled inbox splice is logged. */ - keepInbox?: boolean + keepInbox?: boolean | undefined } ``` ```ts type-equiv -/** Stable runtime cause accepted by {@link Agent.cancel}. */ +/** Why an active agent driver was cancelled. */ type AgentCancelCause = | { readonly kind: 'user' } | { readonly kind: 'parent' } + | { readonly kind: 'hook'; readonly reason: string } + | { readonly kind: 'disposed' } ``` `Agent` is an interface over the public live-agent contract. Concrete drivers implement `followup`, `steer`, and `inject`; routing policy remains private to the driver. ```ts type-equiv -/** - * Public live-agent handle with aliases over the unified delivery primitive. - * @typert object - */ +/** Public live-agent handle. */ interface Agent { /** The single identity shared with {@link session}. */ readonly id: SessionId @@ -503,61 +452,28 @@ interface Agent { readonly options: AgentOptions /** The live session this agent drives; its log is the durable source of truth. */ readonly session: Session + /** The agent-owned projection of durable pending work. */ + readonly inbox: Inbox /** The current lifecycle state, mirrored on every `agent/status` transition. */ readonly status: AgentStatus - /** - * Whether a `next-step` send currently stages for prompt admission or the - * open turn. Unlike {@link status}, this excludes admission exit and turn - * settlement, when a waking `next-step` send becomes a queued follow-up. - */ - readonly acceptsNextStep: boolean /** Agent-scoped context; its contributions are agent-local, unwind on disposal, and reject registration afterward. */ readonly ctx: Context - /** - * The unified delivery primitive over the (`target` × `wakeup`) matrix. - * It routes the caller's typed content and source as follows: - * - * - `next-turn` queues an item that becomes the sole ordinary message of its - * own FIFO-ordered turn; `wakeup:true` wakes a - * parked driver, while `wakeup:false` queues without waking. - * - `next-step` with `wakeup:true` stages steering during prompt admission - * or an open turn; outside that window it falls back to a woken - * `next-turn`. - * - `next-step` with `wakeup:false` injects durable model-facing context - * without running the model: admission or an open turn stages it for the - * next safe log position, while an injection outside that window appends - * immediately without opening a turn. If admission closes without a turn, - * a context-only boundary appends immediately; context staged beside - * steering remains pending with it. - * The agent publishes or queues the identified frozen message as-is. - * @param message - identified model-facing content and its producer provenance. - * @param options - target queue and wakeup decision. - */ - send(message: UserMessage, options: SendOptions): void - - /** - * Mutate one still-pending queued occurrence synchronously. Editing preserves - * the message identity and queue position; removal publishes its terminal - * discard. Steering occurrences and driver-claimed items return `not-found`. - * @param id - independently addressable queued occurrence. - * @param action - edit or remove operation. - * @returns whether the pending occurrence was found and updated. - */ - updateInbox(id: InboxItemId, action: InboxAction): InboxActionResult - /** * Clear queued and steering work — unless `keepInbox` — and abort the active - * turn. An effective call first emits `agent/cancel-requested` with the - * resolved typed cause. The first cause wins for the active turn, and - * `whenIdle()` resolves after cancellation reaches quiescence. Idle - * cancellation is a no-op and does not arm later work. + * turn. The first cause wins for the active turn. Idle cancellation is a + * no-op and does not arm later work. * @param cause - the stable caller intent carried by the current turn signal. * @param options - cancellation options; `keepInbox` preserves pending work. */ cancel(cause: AgentCancelCause, options?: CancelOptions): void - /** Resolve at idle quiescence; disposal waits for driver exit rather than only the status transition. */ + /** + * Resolve after the current whole-agent activity reaches quiescence. This + * follows replacement work scheduled before the observed driver retires, + * but does not identify the settlement of any particular message. + * @returns fulfillment after no scheduled or active driver remains. + */ whenIdle(): Promise /** @@ -568,22 +484,18 @@ interface Agent { followup(message: UserMessage): void /** - * Submit steering during prompt admission or an open turn. It stages for the next steering - * checkpoint before a request or stop decision. If the activity fails before - * that boundary, the remainder stays staged without waking the agent; retry - * or a later prompt takes it. Outside that window steering falls back to a - * woken follow-up turn, while cancellation or disposal may discard pending - * steering. + * Submit steering for the nearest step. An idle driver schedules a turn; + * collecting and running drivers consume it at their next step boundary. + * Cancellation or disposal may discard pending steering. * @param message - identified steering content and its producer provenance. */ steer(message: UserMessage): void /** - * Append model-facing context without running the model — the - * `next-step`/no-wakeup preset of {@link send}. Admission or an open turn - * stages it at the next safe log position; outside that window it appends - * immediately without opening a turn. If admission closes without a turn, - * a context-only boundary appends immediately; context staged beside + * Append model-facing context without running the model. Admission or an + * open turn stages it at the next safe log position; outside that window it + * appends immediately without opening a turn. If admission closes without a + * turn, a context-only boundary appends immediately; context staged beside * steering remains pending with it. * @param message - identified injected context and its producer provenance. */ @@ -591,9 +503,9 @@ interface Agent { } ``` -`AgentStatus` is `'idle' | 'running'`, and `SessionId` is branded. Disposal removes the agent from the registry and emits `agent/disposed`; it is not a terminal status value. `running` describes the driver-wide drain interval and may span consecutive queued turns; it does not prove a turn is still open. `acceptsNextStep` is the narrower routing predicate for callers that must choose between steering the current admission/turn and submitting a fresh admitted prompt. `AgentOptions` is merge-extensible: core declares `provider?`, `model?`, and `maxTokens?` (dispatch requires provider and model after `agent/request`). When present, `maxTokens` must be a positive safe integer and caps every conversation-model request; omission leaves the provider default in control. Persona belongs to `dsh-system-prompt`: an agent-scoped `deployment:persona` may shadow the global default. +`AgentStatus` is `'idle' | 'running'`, and `SessionId` is branded. Disposal removes the agent from the registry and emits `agent/disposed`; it is not a terminal status value. `running` describes the driver-wide drain interval and may span consecutive queued turns; it does not prove a turn is still open. `followup()` returns no handle: its `MessageId` identifies durable inbox and admission facts, not a later assistant output or turn ending. `whenIdle()` observes the whole agent, so callers may call a receipt-to-idle interval a run only when they explicitly own that interval ([proposal](../../.agents/notes/proposed/architecture/2026-07-30-followup-enqueue-and-owned-runs.md)). `AgentOptions` is merge-extensible: core declares `provider?`, `model?`, and `maxTokens?` (dispatch requires provider and model after `agent/request`). When present, `maxTokens` must be a positive safe integer and caps every conversation-model request; omission leaves the provider default in control. Persona belongs to `dsh-system-prompt`: an agent-scoped `deployment:persona` may shadow the global default. -The cause is a TypeScript-enforced same-process input. An active `TurnCancellation` holder copies its discriminant into the runtime-only `AbortSignal.reason` and is retired before `turn/end` publication; the frozen `AbortSignal.reason` remains readable after that retirement. Only the loop reads the cause (`user`, `parent`, or lifecycle-only `disposed`) back off its own machine-private signal at settlement — there is no public reader, and a signal grants cooperating listeners no classification authority. Durable `turn/end` retains the coarse `{ kind: 'aborted' }` outcome; request provenance would require a separate durable event rather than overloading the terminal result. +The cause is a TypeScript-enforced same-process input. An active cancellation holder copies it into the runtime-only `AbortSignal.reason`; a signal grants cooperating listeners no classification authority. Durable `turn/end` retains the coarse `{ kind: 'aborted' }` outcome; request provenance would require a separate durable event rather than overloading the terminal result. The [event taxonomy](../architecture.md#event) owns the `agent/*` lifecycle, checkpoint, and waterfall contracts. Turn and step boundaries are durable session events rather than agent emits. @@ -603,22 +515,21 @@ The process-local initiator carried by `ctx.agents` is the exact `Agent` above, ## Interception decisions -Prompt and post-tool decisions use the same identified `UserMessage` shape as durable user-role input. Each `additionalContexts` entry becomes a separate `user/message`, preserving its identity and provenance. Hook bridges map their native decision fields onto these typed results. +Prompt decisions use the same identified `UserMessage` shape as durable user-role input. The allowed batch is authoritative and preserves every message's identity and provenance. Hook bridges map their native decision fields onto this typed result. Source: [`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types.ts) -`agent/prompt-submit` returns a `PromptDecision` before a turn opens. Allow may rewrite the claimed prompt or attach `additionalContexts`; block rejects admission without creating turn events: +`agent/prompt-submit` returns a `PromptDecision` before a turn opens. Allow supplies the complete admitted batch; block rejects admission without creating turn events and may leave the claimed messages pending: ```ts type-equiv /** - * Prompt interception result. `allow.content` replaces the prompt, while - * `additionalContexts` appends model-facing context before the turn starts. - * An `allow` returned by a listener is authoritative: a listener wrapping - * `next()` preserves both fields unless it intentionally replaces them. + * Prompt interception result. An allowed batch replaces the submitted + * messages. A listener wrapping `next()` preserves the returned batch unless + * it intentionally replaces it. */ type PromptDecision = - | { kind: 'allow'; content?: ContentBlock[]; additionalContexts?: UserMessage[] } - | { kind: 'block'; reason: string } + | { kind: 'allow'; messages: UserMessage[] } + | { kind: 'block'; reason: string; keepInbox?: boolean } ``` `agent/request-error` runs after a failed model step closes and before its turn closes. Listeners can repair durable state or await policy work while the failed turn's signal is still live. A handling listener returns `{ kind: 'retry' }` without calling `next()`; the default `undefined` leaves the failure terminal. @@ -628,11 +539,6 @@ type PromptDecision = type RequestErrorAction = { kind: 'retry' } | undefined ``` -```ts type-equiv -/** Model-request failure with an optional machine-routable provider code. */ -type RequestError = Error & { code?: string } -``` - `agent/step` is the single serial boundary before request derivation. `agent/turn-stopping` runs when a turn has no tool or steering continuation, before one final steering drain. `agent/session-start` carries a `SessionStartSource` (why the session lifecycle began; a bridge keys its SessionStart matcher on it): diff --git a/docs/core-data-structures/core.zh.md b/docs/core-data-structures/core.zh.md index fa6d06734c..0e204ef9ce 100644 --- a/docs/core-data-structures/core.zh.md +++ b/docs/core-data-structures/core.zh.md @@ -69,14 +69,13 @@ declare module '@deepseek-ai/dsh-llm' { } ``` -六个规范 map 使用此模式;插件作者扩展它们: +五个规范 map 使用此模式;插件作者扩展它们: | Map | 包 | 派生 | 目录 | |---|---|---|---| | `ContentBlockMap` | dsh-llm | `ContentBlock` | [下文](#content-blocks-and-messages) | | `MessageSourceMap` | dsh-llm | `MessageSource` | [下文](#content-blocks-and-messages) | | `FinishReasonMap` | dsh-llm | `FinishReason` | [下文](#the-model-request-and-result) | -| `TurnTriggerMap` | dsh-session | `TurnTrigger` | [session.md](session.md) | | `TurnEndReasonMap` | dsh-session | `TurnEndReason` | [session.md](session.md) | | `SessionEventMap` | dsh-session | `SessionEvent` | [session.md](session.md) | @@ -412,7 +411,7 @@ type SessionEvent = { }[T] ``` -十二种事件变体(`turn/start`、`turn/end`、`step/start`、`step/end`、`user/message`、`assistant/chunk`、`assistant/message`、`tool/call`、`tool/result`、`steering/message`、`todo/write`、`request/header`)、`deriveMessages()` 投影规则、`TurnTrigger`/`TurnEndReason` 原因以及执行封闭和独立事件规则都在 **[session.md](session.md)** 中。日志如何持久化——`SessionPersistence` seam、JSONL/SQLite 后端、`session/flush` 检查点、崩溃恢复与 `SessionHeader`——则在 **[persistence.md](persistence.md)** 中。 +会话事件变体、`deriveMessages()` 投影规则、`TurnEndReason` 词汇以及执行封闭和独立事件规则都在 **[session.md](session.md)** 中。日志如何持久化——`SessionPersistence` seam、JSONL/SQLite 后端、`session/flush` 检查点、崩溃恢复与 `SessionHeader`——则在 **[persistence.md](persistence.md)** 中。 @@ -423,60 +422,11 @@ type SessionEvent = { 源码:[`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types.ts) ```ts type-equiv -/** Resolved inbox placement reported when an accepted message is enqueued. */ -type InboxPlacement = 'queued' | 'steering' +/** One of the two ordered pending-message lists owned by an agent. */ +type InboxTarget = 'next-turn' | 'next-step' ``` -`InboxItemId` 是为每次获准进入 FIFO 的项铸造的进程本地品牌字符串。它有意区别于 `MessageId`:同一条不可变消息发送两次,会创建两个可独立寻址的待处理项。 - -```ts type-equiv -/** One independently addressable accepted occurrence in an agent inbox. */ -interface InboxItem { - /** Agent-loop-minted occurrence identity. */ - readonly id: InboxItemId - /** Identified message delivered by the caller. */ - readonly message: UserMessage - /** Acceptance-time FIFO classification. */ - readonly placement: InboxPlacement -} -``` - -```ts type-equiv -/** A user-requested mutation of one still-pending queued occurrence. */ -type InboxAction = - | { readonly kind: 'edit'; readonly content: ContentBlock[] } - | { readonly kind: 'remove' } -``` - -```ts type-equiv -/** Result of applying an inbox action at the synchronous ownership boundary. */ -type InboxActionResult = 'applied' | 'not-found' -``` - -```ts type-equiv -/** - * Options for the unified {@link Agent.send} primitive over the - * (`target` × `wakeup`) matrix. Named presets: {@link Agent.followup} - * (`next-turn`/wakeup), {@link Agent.steer} (`next-step`/wakeup), and - * {@link Agent.inject} (`next-step`/no-wakeup). - * - * The object is complete so routing policy is explicit. - */ -interface SendOptions { - /** Queue the item joins. */ - target: SendTarget - /** - * Whether this item makes the model run: wake a parked driver (`next-turn`) - * or force a continuation step (`next-step` while running). A `false` - * `next-turn` item queues without waking; a `false` - * `next-step` item attaches durable context without forcing another step - * (the injection preset). - */ - wakeup: boolean -} -``` - -固定预设的别名方法自带 `target` 与 `wakeup`;其已有标识的 `UserMessage` 会携带角色、内容与 provenance。编辑替换消息内容时,其 `MessageId` 保持稳定;外层 `InboxItemId` 则在 `agent/inbox/enqueue`、`agent/inbox/update` 及终态 dequeue 或 discard 之间标识同一次入队。注入绕过两个 FIFO,从不出现在这些事件中。 +每个待处理入队项就是其 `UserMessage`;`MessageId` 是唯一标识。`Inbox.splice(target, start, deleteCount, inserted, outcome?)` 使用标准 splice 坐标,拒绝重复的待处理消息 id,并将规范化变更记录为持久 `agent/inbox/spliced`。回放这些事件可以重建 `nextTurn` 和 `nextStep`,包括编辑、插入、准入与取消。 ```ts type-equiv /** Options for {@link Agent.cancel}. */ @@ -484,26 +434,25 @@ interface CancelOptions { /** * Preserve queued and steering inbox items instead of discarding them. The * active turn is still aborted, but un-started and pending work survives for a - * later turn and no `agent/inbox/discard` fires. + * later turn and no canceled inbox splice is logged. */ - keepInbox?: boolean + keepInbox?: boolean | undefined } ``` ```ts type-equiv -/** Stable runtime cause accepted by {@link Agent.cancel}. */ +/** Why an active agent driver was cancelled. */ type AgentCancelCause = | { readonly kind: 'user' } | { readonly kind: 'parent' } + | { readonly kind: 'hook'; readonly reason: string } + | { readonly kind: 'disposed' } ``` `Agent` 是覆盖公开活跃 agent 契约的接口。具体驱动器实现 `followup`、`steer` 和 `inject`;路由策略仍为驱动器私有。 ```ts type-equiv -/** - * Public live-agent handle with aliases over the unified delivery primitive. - * @typert object - */ +/** Public live-agent handle. */ interface Agent { /** The single identity shared with {@link session}. */ readonly id: SessionId @@ -511,61 +460,28 @@ interface Agent { readonly options: AgentOptions /** The live session this agent drives; its log is the durable source of truth. */ readonly session: Session + /** The agent-owned projection of durable pending work. */ + readonly inbox: Inbox /** The current lifecycle state, mirrored on every `agent/status` transition. */ readonly status: AgentStatus - /** - * Whether a `next-step` send currently stages for prompt admission or the - * open turn. Unlike {@link status}, this excludes admission exit and turn - * settlement, when a waking `next-step` send becomes a queued follow-up. - */ - readonly acceptsNextStep: boolean /** Agent-scoped context; its contributions are agent-local, unwind on disposal, and reject registration afterward. */ readonly ctx: Context - /** - * The unified delivery primitive over the (`target` × `wakeup`) matrix. - * It routes the caller's typed content and source as follows: - * - * - `next-turn` queues an item that becomes the sole ordinary message of its - * own FIFO-ordered turn; `wakeup:true` wakes a - * parked driver, while `wakeup:false` queues without waking. - * - `next-step` with `wakeup:true` stages steering during prompt admission - * or an open turn; outside that window it falls back to a woken - * `next-turn`. - * - `next-step` with `wakeup:false` injects durable model-facing context - * without running the model: admission or an open turn stages it for the - * next safe log position, while an injection outside that window appends - * immediately without opening a turn. If admission closes without a turn, - * a context-only boundary appends immediately; context staged beside - * steering remains pending with it. - * The agent publishes or queues the identified frozen message as-is. - * @param message - identified model-facing content and its producer provenance. - * @param options - target queue and wakeup decision. - */ - send(message: UserMessage, options: SendOptions): void - - /** - * Mutate one still-pending queued occurrence synchronously. Editing preserves - * the message identity and queue position; removal publishes its terminal - * discard. Steering occurrences and driver-claimed items return `not-found`. - * @param id - independently addressable queued occurrence. - * @param action - edit or remove operation. - * @returns whether the pending occurrence was found and updated. - */ - updateInbox(id: InboxItemId, action: InboxAction): InboxActionResult - /** * Clear queued and steering work — unless `keepInbox` — and abort the active - * turn. An effective call first emits `agent/cancel-requested` with the - * resolved typed cause. The first cause wins for the active turn, and - * `whenIdle()` resolves after cancellation reaches quiescence. Idle - * cancellation is a no-op and does not arm later work. + * turn. The first cause wins for the active turn. Idle cancellation is a + * no-op and does not arm later work. * @param cause - the stable caller intent carried by the current turn signal. * @param options - cancellation options; `keepInbox` preserves pending work. */ cancel(cause: AgentCancelCause, options?: CancelOptions): void - /** Resolve at idle quiescence; disposal waits for driver exit rather than only the status transition. */ + /** + * Resolve after the current whole-agent activity reaches quiescence. This + * follows replacement work scheduled before the observed driver retires, + * but does not identify the settlement of any particular message. + * @returns fulfillment after no scheduled or active driver remains. + */ whenIdle(): Promise /** @@ -576,22 +492,18 @@ interface Agent { followup(message: UserMessage): void /** - * Submit steering during prompt admission or an open turn. It stages for the next steering - * checkpoint before a request or stop decision. If the activity fails before - * that boundary, the remainder stays staged without waking the agent; retry - * or a later prompt takes it. Outside that window steering falls back to a - * woken follow-up turn, while cancellation or disposal may discard pending - * steering. + * Submit steering for the nearest step. An idle driver schedules a turn; + * collecting and running drivers consume it at their next step boundary. + * Cancellation or disposal may discard pending steering. * @param message - identified steering content and its producer provenance. */ steer(message: UserMessage): void /** - * Append model-facing context without running the model — the - * `next-step`/no-wakeup preset of {@link send}. Admission or an open turn - * stages it at the next safe log position; outside that window it appends - * immediately without opening a turn. If admission closes without a turn, - * a context-only boundary appends immediately; context staged beside + * Append model-facing context without running the model. Admission or an + * open turn stages it at the next safe log position; outside that window it + * appends immediately without opening a turn. If admission closes without a + * turn, a context-only boundary appends immediately; context staged beside * steering remains pending with it. * @param message - identified injected context and its producer provenance. */ @@ -599,9 +511,9 @@ interface Agent { } ``` -`AgentStatus` 为 `'idle' | 'running'`,`SessionId` 是品牌类型。dispose(资源释放)会把 agent 从注册表移除并发出 `agent/disposed`;它不是一个终态 status 值。`running` 描述整个驱动器的排空区间,可能跨越连续的排队轮次;它不能证明某个轮次仍然打开。对于需要在把输入作为 steering 加入当前提示词准入/轮次,还是提交为一个新的待准入提示词之间做选择的调用方,`acceptsNextStep` 才是更窄且准确的路由判断条件。`AgentOptions` 可合并扩展:core 声明 `provider?`、`model?` 与 `maxTokens?`(在 `agent/request` 后,分发要求 provider 与 model 都存在)。提供 `maxTokens` 时,它必须是正安全整数,并限制每次对话模型请求的输出;省略时由提供方默认值控制。Persona 归 `dsh-system-prompt` 所有:agent 作用域的 `deployment:persona` 可以遮蔽全局默认值。 +`AgentStatus` 为 `'idle' | 'running'`,`SessionId` 是品牌类型。dispose(资源释放)会把 agent 从注册表移除并发出 `agent/disposed`;它不是一个终态 status 值。`running` 描述整个驱动器的排空区间,可能跨越连续的排队轮次;它不能证明某个轮次仍然打开。`followup()` 不返回 handle:其 `MessageId` 标识持久 inbox 与准入事实,而不标识之后的助手输出或轮次结束。`whenIdle()` 观察整个 agent,因此只有显式拥有从回执到 idle 这一完整区间的调用方才能将其称为一次运行([提案](../../.agents/notes/proposed/architecture/2026-07-30-followup-enqueue-and-owned-runs.md))。`AgentOptions` 可合并扩展:core 声明 `provider?`、`model?` 与 `maxTokens?`(在 `agent/request` 后,分发要求 provider 与 model 都存在)。提供 `maxTokens` 时,它必须是正安全整数,并限制每次对话模型请求的输出;省略时由提供方默认值控制。Persona 归 `dsh-system-prompt` 所有:agent 作用域的 `deployment:persona` 可以遮蔽全局默认值。 -cause 是由 TypeScript 强制约束的同进程输入。活跃的 `TurnCancellation` 持有者会把其判别字段复制到仅运行时的 `AbortSignal.reason`,并在发布 `turn/end` 前退役;冻结后的 `AbortSignal.reason` 仍可读取。只有 loop 会在结算时从自己机器私有的 signal 上读回 cause(`user`、`parent` 或仅用于生命周期的 `disposed`)——不存在公开的读取器,signal 也不授予协作监听器任何分类权限。持久 `turn/end` 保留粗粒度 `{ kind: 'aborted' }` 结果;若需记录请求 provenance,应使用单独的持久事件,而不是让终态结果承担额外含义。 +cause 是由 TypeScript 强制约束的同进程输入。活跃的取消持有者会将它复制到仅运行时的 `AbortSignal.reason`;signal 不授予协作监听器任何分类权限。持久 `turn/end` 保留粗粒度 `{ kind: 'aborted' }` 结果;若需记录请求 provenance,应使用单独的持久事件,而不是让终态结果承担额外含义。 [事件分类](../architecture.md#event)拥有 `agent/*` 生命周期、检查点与 waterfall(瀑布式事件)契约。轮次和步骤边界是持久会话事件,而不是 agent emit。 @@ -611,22 +523,21 @@ cause 是由 TypeScript 强制约束的同进程输入。活跃的 `TurnCancella ## 拦截决策 -提示词决策与工具后决策使用与持久 user-role 输入相同、带标识的 `UserMessage` 形状。每个 `additionalContexts` 条目都会成为一条独立的 `user/message`,保留各自的标识与 provenance。钩子桥接层把其原生决策字段映射到这些类型化结果上。 +提示词决策使用与持久 user-role 输入相同、带标识的 `UserMessage` 形状。获准批次具有权威性,并保留每条消息的标识与 provenance。钩子桥接层把其原生决策字段映射到这一类型化结果上。 源码:[`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types.ts) -`agent/prompt-submit` 在轮次打开前返回 `PromptDecision`。allow 可以改写已领取的提示词或附加 `additionalContexts`;block 拒绝准入且不产生任何轮次事件: +`agent/prompt-submit` 在轮次打开前返回 `PromptDecision`。allow 提供完整的准入批次;block 拒绝准入且不产生任何轮次事件,并可以让已领取的消息保持待处理: ```ts type-equiv /** - * Prompt interception result. `allow.content` replaces the prompt, while - * `additionalContexts` appends model-facing context before the turn starts. - * An `allow` returned by a listener is authoritative: a listener wrapping - * `next()` preserves both fields unless it intentionally replaces them. + * Prompt interception result. An allowed batch replaces the submitted + * messages. A listener wrapping `next()` preserves the returned batch unless + * it intentionally replaces it. */ type PromptDecision = - | { kind: 'allow'; content?: ContentBlock[]; additionalContexts?: UserMessage[] } - | { kind: 'block'; reason: string } + | { kind: 'allow'; messages: UserMessage[] } + | { kind: 'block'; reason: string; keepInbox?: boolean } ``` `agent/request-error` 在失败的模型步骤关闭之后、其轮次关闭之前运行。listener 可以在失败轮次的 signal 仍然存活时修复持久状态或 await 策略工作。处理该错误的 listener 返回 `{ kind: 'retry' }` 且不调用 `next()`;默认的 `undefined` 会让失败保持终态。 @@ -636,11 +547,6 @@ type PromptDecision = type RequestErrorAction = { kind: 'retry' } | undefined ``` -```ts type-equiv -/** Model-request failure with an optional machine-routable provider code. */ -type RequestError = Error & { code?: string } -``` - `agent/step` 是请求推导前唯一的串行边界。`agent/turn-stopping` 在轮次没有工具或 steering(中途引导)后续时运行,先于最后一次 steering 排空。 `agent/session-start` 携带 `SessionStartSource`(会话生命周期为何开始;桥接层据此匹配其 SessionStart): diff --git a/docs/core-data-structures/session.i18n.yaml b/docs/core-data-structures/session.i18n.yaml index 3e35c3a051..75bef3d87f 100644 --- a/docs/core-data-structures/session.i18n.yaml +++ b/docs/core-data-structures/session.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/core-data-structures/session.md -session.md: fd8285eebd76e8bd7723ee86ae15427f4923f4d6 -session.zh.md: 1033bfda117b5693421f0bdf4ec3fc136039f223 +session.md: 85eebf81e07774e4d9095cfbf042330a90d30a9e +session.zh.md: 356a283c4ebe041c3690ae2e3a23ff1be7b4722f diff --git a/docs/core-data-structures/session.md b/docs/core-data-structures/session.md index fd8285eebd..85eebf81e0 100644 --- a/docs/core-data-structures/session.md +++ b/docs/core-data-structures/session.md @@ -26,9 +26,11 @@ interface UserMessage extends Message { */ interface SessionEventMap { /** - * Opens turn `turn`. `trigger` records what started the model loop. + * Opens turn `turn`. Every turn begins when the loop admits queued input; + * the following identified `user/message` event or batch records the + * admitted input. */ - 'turn/start': { turn: number; trigger: TurnTrigger } + 'turn/start': { turn: number } /** * Closes turn `turn` with the {@link TurnEndReason} that ended it. The loop * awaits `session/flush` after an ordinary turn ends before claiming the next @@ -434,7 +436,7 @@ declare class Session { - `user/message` (injected context, i.e. non-`user` source) → a user-role message carrying its `content` verbatim at its chronological position; provenance and domain data live in its typed source. - `steering/message` → a user-role message carrying exact `content` at its chronological position; an optional envelope remains log-only display metadata. -Everything else (`turn/*`, `step/*`, plugin-owned `llm/retry`) is structural and does not project into a message. Token accounting reads per-step `assistant/chunk { type: 'usage' }` records and treats `assistant/message.usage` as the committed-step fallback when no usage chunk exists; failed model-request attempts have no assistant message, so their usage chunk is the durable accounting record. An operational error's step number is on `turn/end.reason` for `kind: 'error'`, with normalized `LlmFailure` facts for a final model-request failure and message/code for other live errors. Because this unreleased format intentionally has no compatibility promise, seed/load validation rejects request headers without provider+model and assistant messages without provider/model provenance instead of guessing a route for historical data. +Everything else (`turn/*`, `step/*`, plugin-owned `llm/retry`) is structural and does not project into a message. Token accounting reads per-step `assistant/chunk { type: 'usage' }` records and treats `assistant/message.usage` as the committed-step fallback when no usage chunk exists; failed model-request attempts have no assistant message, so their usage chunk is the durable accounting record. Because this unreleased format intentionally has no compatibility promise, seed/load validation rejects request headers without provider+model and assistant messages without provider/model provenance instead of guessing a route for historical data. ## Live-session fork API @@ -444,29 +446,9 @@ Everything else (`turn/*`, `step/*`, plugin-owned `llm/retry`) is structural and An explicit `boundary` lets callers fork from any stable between-turn position, including a previous `turn/end` or a later standalone log-only event, even if the source has newer events or an open current turn. The API rejects a prefix that ends inside an open turn instead of clipping silently. Broader execution-relation sanity stays in the existing `dsh-invariants` plugin and persistence repair path rather than being duplicated in `fork()`. `dsh-subagent-fork` keeps its completed-prefix clipping because tool-time delegation usually starts while the parent turn is open; ordinary session branching should make the requested boundary explicit. -## What started a turn: `TurnTriggerMap` - -```ts type-equiv -/** - * What started a turn. - * Merge-extensible sum type (same pattern as MessageSourceMap). - */ -interface TurnTriggerMap { - message: { kind: 'message'; source: MessageSource } - /** Recovery turn reopened over the repaired current session log. */ - retry: { kind: 'retry' } - /** - * An out-of-band producer explicitly enclosed injected context in a one-shot - * turn. `Agent.inject()` appends idle context directly and does not use this - * trigger; the source mirrors the producer of the enclosed `user/message`. - */ - injection: { kind: 'injection'; source: MessageSource } -} -``` - ## Why a turn ended: `TurnEndReasonMap` -`aborted` is intentionally a coarse durable outcome: it records that cancellation interrupted the live turn, not which runtime caller requested it. The runtime-only caller vocabulary belongs to [`AgentCancelCause`](core.md#the-agent-handle); a future audit requirement would use a separate control-request event rather than overloading the terminal result. +`turn/start` has no trigger field. The admitted `user/message` batch records what entered the turn, `llm/retry` records request recovery, and idle injection opens no turn. `aborted.reason` retains the typed [`AgentCancelCause`](core.md#the-agent-handle) that stopped the driver. ```ts type-equiv /** @@ -475,20 +457,11 @@ interface TurnTriggerMap { interface TurnEndReasonMap { completed: { kind: 'completed' } /** A cancellation request interrupted the live turn. */ - aborted: { kind: 'aborted' } + aborted: { kind: 'aborted'; reason: AgentCancelCause } /** - * The turn failed: a step threw or the model reported a failure. `step` is the - * step number the failure occurred on (the operational error's location — the - * single durable record of an in-turn failure; live diagnostics also fire via - * `agent/error`). Final model-request failures retain their normalized facts - * as one `failure`; other thrown values retain their rendered message and a - * real `HarnessError` code when present. + * The turn failed. */ - error: { kind: 'error'; step: number } & ( - | { failure: LlmFailure; message?: never; code?: never } - | { message: string; code?: string; failure?: never } - ) - disposed: { kind: 'disposed' } + error: { kind: 'error'; error: unknown } /** At least one step reached its output-token ceiling, even if a plugin continued the turn. */ 'max-tokens': { kind: 'max-tokens' } /** @@ -499,7 +472,7 @@ interface TurnEndReasonMap { } ``` -`max-tokens` mirrors the model-call `FinishReason` of the same name: any `max-tokens` step in a turn makes the whole turn end `max-tokens` rather than `completed` (the cut-short fact wins over a later continuation), so a consumer can tell a clean stop from a truncated one — but only over `completed`: the `disposed`/`aborted`/`error` outcomes take precedence. `interrupted` is the one reason no loop emits — it is synthesized by crash recovery (see [persistence.md](persistence.md)). Both maps are merge-extensible. +`max-tokens` mirrors the model-call `FinishReason` of the same name: any `max-tokens` step in a turn makes the whole turn end `max-tokens` rather than `completed` (the cut-short fact wins over a later continuation), so a consumer can tell a clean stop from a truncated one. Cancellation and errors remain distinct outcomes. `interrupted` is the one reason no loop emits—it is synthesized by crash recovery (see [persistence.md](persistence.md)). The map is merge-extensible. ## Execution enclosure and standalone events diff --git a/docs/core-data-structures/session.zh.md b/docs/core-data-structures/session.zh.md index 1033bfda11..356a283c4e 100644 --- a/docs/core-data-structures/session.zh.md +++ b/docs/core-data-structures/session.zh.md @@ -26,9 +26,11 @@ interface UserMessage extends Message { */ interface SessionEventMap { /** - * Opens turn `turn`. `trigger` records what started the model loop. + * Opens turn `turn`. Every turn begins when the loop admits queued input; + * the following identified `user/message` event or batch records the + * admitted input. */ - 'turn/start': { turn: number; trigger: TurnTrigger } + 'turn/start': { turn: number } /** * Closes turn `turn` with the {@link TurnEndReason} that ended it. The loop * awaits `session/flush` after an ordinary turn ends before claiming the next @@ -436,7 +438,7 @@ declare class Session { - `user/message`(注入上下文,即非 `user` 来源)→ 按时间顺序在相应位置生成一条 user-role 消息,并原样承载其 `content`;溯源信息与领域数据都在其类型化的 source 中。 - `steering/message` → 按时间顺序在相应位置生成一条携带确切 `content` 的 user-role 消息;可选 envelope 仅作为日志中的展示元数据保留。 -其余所有事件(`turn/*`、`step/*`、插件所有的 `llm/retry`)均为结构信息,不会投影为消息。token 记账读取每个步骤的 `assistant/chunk { type: 'usage' }` 记录;如果没有用量分片,则将 `assistant/message.usage` 作为已提交步骤的后备。失败的模型请求尝试没有 assistant 消息,因此其用量分片是持久化的记账记录。操作错误的步骤号记录在 `turn/end.reason`(`kind: 'error'`)中;如果是最终模型请求失败,其中包含规范化的 `LlmFailure` 事实,其他实时错误则包含消息/代码。由于这一尚未发布的格式有意不提供兼容性承诺,seed/load 校验会拒绝缺少提供方和模型的请求头,以及缺少提供方/模型溯源信息的 assistant 消息,而不会猜测历史数据应走的提供方路由。 +其余所有事件(`turn/*`、`step/*`、插件所有的 `llm/retry`)均为结构信息,不会投影为消息。token 记账读取每个步骤的 `assistant/chunk { type: 'usage' }` 记录;如果没有用量分片,则将 `assistant/message.usage` 作为已提交步骤的后备。失败的模型请求尝试没有 assistant 消息,因此其用量分片是持久化的记账记录。由于这一尚未发布的格式有意不提供兼容性承诺,seed/load 校验会拒绝缺少提供方和模型的请求头,以及缺少提供方/模型溯源信息的 assistant 消息,而不会猜测历史数据应走的提供方路由。 ## 活跃会话 fork API @@ -446,31 +448,11 @@ declare class Session { 显式 `boundary` 允许调用者从任意稳定的轮次间位置 fork,包括之前的 `turn/end` 或更晚的独立纯日志事件,即使源会话有更新的事件或正在进行的轮次。API 拒绝结束于开放轮次内的前缀,而不是静默截断。更广泛的执行关系健全性检查留在既有的 `dsh-invariants` 插件和持久化修复路径中,不在 `fork()` 中重复。`dsh-subagent-fork` 保留其已完成前缀截断逻辑,因为工具时委托通常在父轮次仍然打开时启动;普通的会话分支应显式指定请求的 boundary。 -## 轮次的触发原因:`TurnTriggerMap` - -```ts type-equiv -/** - * What started a turn. - * Merge-extensible sum type (same pattern as MessageSourceMap). - */ -interface TurnTriggerMap { - message: { kind: 'message'; source: MessageSource } - /** Recovery turn reopened over the repaired current session log. */ - retry: { kind: 'retry' } - /** - * An out-of-band producer explicitly enclosed injected context in a one-shot - * turn. `Agent.inject()` appends idle context directly and does not use this - * trigger; the source mirrors the producer of the enclosed `user/message`. - */ - injection: { kind: 'injection'; source: MessageSource } -} -``` - ## 轮次的结束原因:`TurnEndReasonMap` -`aborted` 有意作为一种粗粒度的持久结果:它只记录取消中断了实时轮次,不记录是哪个运行时调用方发起取消。仅属于运行时的调用方词汇由 [`AgentCancelCause`](core.md#the-agent-handle) 定义;未来若有审计需求,应新增独立的控制请求事件,而非让终止结果承载这一信息。 +`turn/start` 没有 trigger 字段。已准入的 `user/message` 批次记录进入轮次的内容,`llm/retry` 记录请求恢复,idle 注入则不会打开轮次。`aborted.reason` 保留停止驱动器的类型化 [`AgentCancelCause`](core.md#the-agent-handle)。 ```ts type-equiv /** @@ -479,20 +461,11 @@ interface TurnTriggerMap { interface TurnEndReasonMap { completed: { kind: 'completed' } /** A cancellation request interrupted the live turn. */ - aborted: { kind: 'aborted' } + aborted: { kind: 'aborted'; reason: AgentCancelCause } /** - * The turn failed: a step threw or the model reported a failure. `step` is the - * step number the failure occurred on (the operational error's location — the - * single durable record of an in-turn failure; live diagnostics also fire via - * `agent/error`). Final model-request failures retain their normalized facts - * as one `failure`; other thrown values retain their rendered message and a - * real `HarnessError` code when present. + * The turn failed. */ - error: { kind: 'error'; step: number } & ( - | { failure: LlmFailure; message?: never; code?: never } - | { message: string; code?: string; failure?: never } - ) - disposed: { kind: 'disposed' } + error: { kind: 'error'; error: unknown } /** At least one step reached its output-token ceiling, even if a plugin continued the turn. */ 'max-tokens': { kind: 'max-tokens' } /** @@ -503,7 +476,7 @@ interface TurnEndReasonMap { } ``` -`max-tokens` 与模型调用中同名的 `FinishReason` 对应:只要轮次内有任何步骤以 `max-tokens` 结束,整个轮次就以 `max-tokens` 而不是 `completed` 结束(即使之后继续执行,截断事实仍优先),让消费方能够区分正常停止和截断停止;但它只优先于 `completed`,`disposed`/`aborted`/`error` 结果的优先级更高。`interrupted` 是唯一不会由任何 loop 发出的原因:它由崩溃恢复合成(见 [persistence.md](persistence.md))。两个 map 均可通过合并扩展。 +`max-tokens` 与模型调用中同名的 `FinishReason` 对应:只要轮次内有任何步骤以 `max-tokens` 结束,整个轮次就以 `max-tokens` 而不是 `completed` 结束(即使之后继续执行,截断事实仍优先),让消费方能够区分正常停止和截断停止。取消和错误仍是不同的结果。`interrupted` 是唯一不会由任何 loop 发出的原因:它由崩溃恢复合成(见 [persistence.md](persistence.md))。该 map 可通过合并扩展。 ## 执行封闭与独立事件 diff --git a/docs/defensive-patterns.i18n.yaml b/docs/defensive-patterns.i18n.yaml index 674cd63efe..8052189a99 100644 --- a/docs/defensive-patterns.i18n.yaml +++ b/docs/defensive-patterns.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/defensive-patterns.md -defensive-patterns.md: cc34877fb0d6a2e1740d8fa138f879363c8e69a3 -defensive-patterns.zh.md: 21b0977d8167ffecc21cdfab3c778efceefd8f03 +defensive-patterns.md: 3754e02d4f0754fdd711310775c0177ee7c313b1 +defensive-patterns.zh.md: 884ed404820d64afdb5237f8121992bcb107cb0f diff --git a/docs/defensive-patterns.md b/docs/defensive-patterns.md index 60b1f67e78..3754e02d4f 100644 --- a/docs/defensive-patterns.md +++ b/docs/defensive-patterns.md @@ -14,7 +14,7 @@ When an implementation boundary receives several representations of one outcome, ## Async state is not synchronous state -`agent.followup()` does not flip status before returning; a background task's completion races turn boundaries; `reader.close()` fires for both EOF and disposal. Never gate control flow on a status you only just requested — drive lifecycle off the events/promises that actually fire (`agent/status`, `task.done`), and observe the transition (saw `running` THEN `idle`) instead of treating status as a per-follow-up result: several queued follow-ups run as consecutive turns under one `running` interval, while cancellation or disposal can discard unstarted items. The guard cuts both ways: if the awaited transition can never occur (EOF with no work submitted → never `running`), the wait hangs — handle the "nothing to wait for" branch explicitly. +`agent.followup()` has no per-message completion or result; a background task's completion races turn boundaries; `reader.close()` fires for both EOF and disposal. Never treat `agent/status` or `whenIdle()` as the result of one follow-up: several queued follow-ups, steering, and injected work may share one `running` interval, while cancellation or disposal can discard unstarted items. An automation caller that truly owns a run must define its interval explicitly—for example, from its message's durable inbox receipt through the next whole-agent `idle`—and describe any selected output as interval-wide rather than causally attributed to that message. The guard cuts both ways: if the awaited transition can never occur, the wait hangs, so handle the "nothing to wait for" branch explicitly. ## Dispose must reach quiescence, not just request it diff --git a/docs/defensive-patterns.zh.md b/docs/defensive-patterns.zh.md index ca6ee16a42..884ed40482 100644 --- a/docs/defensive-patterns.zh.md +++ b/docs/defensive-patterns.zh.md @@ -14,7 +14,7 @@ ## 异步状态不是同步状态 -`agent.followup()` 不会在返回前翻转状态;后台任务的完成与轮次边界存在竞争;`reader.close()` 在 EOF 和 dispose(资源释放)两种情况下都会触发。切勿基于一个刚刚请求的状态来控制流程——应以实际触发的事件/promise(`agent/status`、`task.done`)驱动生命周期,并观察状态转换(先看到 `running` 再看到 `idle`),而不是把状态当作逐次 `followup()` 的结果:多次排队的 `followup()` 会在同一个 `running` 区间内连续运行多个轮次,而取消或资源释放可能丢弃尚未启动的项。这条守则是双向的:如果等待的转换永远不会发生(EOF 时没有提交过任何工作 → 永远不会进入 `running`),等待就会挂起——请显式处理「无需等待」的分支。 +`agent.followup()` 没有逐消息的完成状态或结果;后台任务的完成与轮次边界存在竞争;`reader.close()` 在 EOF 和 dispose(资源释放)两种情况下都会触发。切勿把 `agent/status` 或 `whenIdle()` 当作某次 `followup()` 的结果:多条已排队的后续消息、steering(中途引导)和注入工作可能共用同一个 `running` 区间,而取消或资源释放可能丢弃尚未启动的项。真正拥有一次运行的自动化调用方必须显式定义其区间——例如从消息的持久 inbox 回执到整个 agent 下一次进入 `idle`——并将选取的任何输出描述为整个区间的输出,而不是把因果关系归于该消息。这条守则是双向的:如果等待的转换永远不会发生,等待就会挂起,因此应显式处理「无需等待」的分支。 ## Dispose 必须达到完全停稳,而不仅仅是请求停止 diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index dea3c4816b..42bab06f44 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -78,10 +78,27 @@ export type SessionEvent = { }[T] ``` -Sources: [`packages/core/session/src/types.ts:256`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:263`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:292`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:324`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:235`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:242`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:271`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:303`](../packages/core/session/src/types.ts) ## Events +### `agent/*` + +#### `agent/inbox/spliced` — log-only + +```ts persistence-catalog +/** One normalized mutation of an agent's durable pending-message lists. */ +'agent/inbox/spliced': { + target: InboxTarget + start: number + removedCount?: number + inserted: UserMessage[] + outcome?: 'admitted' | 'canceled' +} +``` + +Source: [`packages/core/agent/src/types.ts:268`](../packages/core/agent/src/types.ts) + ### `approval/*` #### `approval/asked` — log-only @@ -154,7 +171,7 @@ Source: [`packages/ui/user-approval/src/index.ts:67`](../packages/ui/user-approv Types: [StreamChunk](core-data-structures/llm-streaming.md) -Source: [`packages/core/session/src/types.ts:212`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:191`](../packages/core/session/src/types.ts) #### `assistant/message` — surface @@ -170,7 +187,7 @@ Source: [`packages/core/session/src/types.ts:212`](../packages/core/session/src/ Types: [TokenUsage](core-data-structures/llm-streaming.md) -Source: [`packages/core/session/src/types.ts:219`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:198`](../packages/core/session/src/types.ts) ### `command/*` @@ -311,7 +328,7 @@ Source: [`packages/hooks/hook-protocol/src/types.ts:31`](../packages/hooks/hook- #### `llm/retry` — log-only ```ts persistence-catalog -/** Durable, non-surface record of one provider-routed retry scheduled after a closed failed step. */ +/** Durable, non-surface record of one provider-routed retry scheduled after a failed request attempt. */ 'llm/retry': { turn: number step: number @@ -334,7 +351,7 @@ Source: [`packages/hooks/hook-protocol/src/types.ts:31`](../packages/hooks/hook- } ``` -Source: [`packages/llm/llm-retry/src/index.ts:18`](../packages/llm/llm-retry/src/index.ts) +Source: [`packages/llm/llm-retry/src/index.ts:17`](../packages/llm/llm-retry/src/index.ts) ### `permission/*` @@ -379,7 +396,7 @@ Source: [`packages/plan/plan-mode/src/index.ts:51`](../packages/plan/plan-mode/s 'request/header': { header: EpochHeader; reason: RequestHeaderReason } ``` -Source: [`packages/core/session/src/types.ts:252`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:231`](../packages/core/session/src/types.ts) ### `sandbox/*` @@ -438,7 +455,7 @@ Source: [`packages/session-title/session-title-llm/src/index.ts:43`](../packages 'steering/message': { turn: number; message: UserMessage } ``` -Source: [`packages/core/session/src/types.ts:245`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:224`](../packages/core/session/src/types.ts) ### `step/*` @@ -449,7 +466,7 @@ Source: [`packages/core/session/src/types.ts:245`](../packages/core/session/src/ 'step/end': { turn: number; step: number } ``` -Source: [`packages/core/session/src/types.ts:201`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:180`](../packages/core/session/src/types.ts) #### `step/start` — log-only @@ -458,7 +475,7 @@ Source: [`packages/core/session/src/types.ts:201`](../packages/core/session/src/ 'step/start': { turn: number; step: number } ``` -Source: [`packages/core/session/src/types.ts:199`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:178`](../packages/core/session/src/types.ts) ### `todo/*` @@ -471,7 +488,7 @@ Source: [`packages/core/session/src/types.ts:199`](../packages/core/session/src/ Types: [TodoItem](core-data-structures/session.md) -Source: [`packages/core/session/src/types.ts:247`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:226`](../packages/core/session/src/types.ts) ### `tool/*` @@ -488,7 +505,7 @@ Source: [`packages/core/session/src/types.ts:247`](../packages/core/session/src/ Types: [CallId](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:225`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:204`](../packages/core/session/src/types.ts) #### `tool/code-dispatch` — log-only @@ -561,7 +578,7 @@ Source: [`packages/core/tools/src/code-mode.ts:33`](../packages/core/tools/src/c } ``` -Source: [`packages/core/session/src/types.ts:237`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:216`](../packages/core/session/src/types.ts) ### `turn/*` @@ -579,20 +596,20 @@ Source: [`packages/core/session/src/types.ts:237`](../packages/core/session/src/ Types: [TurnEndReason](core-data-structures/session.md) -Source: [`packages/core/session/src/types.ts:197`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:176`](../packages/core/session/src/types.ts) #### `turn/start` — log-only ```ts persistence-catalog /** - * Opens turn `turn`. `trigger` records what started the model loop. + * Opens turn `turn`. Every turn begins when the loop admits queued input; + * the following identified `user/message` event or batch records the + * admitted input. */ -'turn/start': { turn: number; trigger: TurnTrigger } +'turn/start': { turn: number } ``` -Types: [TurnTrigger](core-data-structures/session.md) - -Source: [`packages/core/session/src/types.ts:190`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:169`](../packages/core/session/src/types.ts) ### `user/*` @@ -610,4 +627,4 @@ Source: [`packages/core/session/src/types.ts:190`](../packages/core/session/src/ 'user/message': UserMessage ``` -Source: [`packages/core/session/src/types.ts:210`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:189`](../packages/core/session/src/types.ts) diff --git a/packages/acp/acp/README.i18n.yaml b/packages/acp/acp/README.i18n.yaml index d359613cd6..09636ae7ba 100644 --- a/packages/acp/acp/README.i18n.yaml +++ b/packages/acp/acp/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/acp/acp/README.md -README.md: 1b188b994d17ce56e8d5df019ddef755338fcc88 -README.zh.md: c1e7d045b55119b62ad44d81071188e1ed6110d5 +README.md: 162e88265762652b5629c0e04786311c5b62583f +README.zh.md: 3fd914ecc5bf7b0e7069ba933b09db0a4a3cdfcd diff --git a/packages/acp/acp/README.md b/packages/acp/acp/README.md index 1b188b994d..162e882657 100644 --- a/packages/acp/acp/README.md +++ b/packages/acp/acp/README.md @@ -24,7 +24,7 @@ Both fields are optional so another agent/request listener may supply the target | `initialize` | Negotiates the supported version and advertises baseline-only prompts (no image, audio, or embedded-context capability). No session, editor, terminal, filesystem, or MCP capability is advertised. | | `authenticate` | No-op because the server advertises no authentication methods. | | `session/new` | Creates a fresh agent with an absolute primary `cwd`; empty `additionalDirectories` and `mcpServers` are accepted, non-empty values reject. | -| `session/prompt` | Concatenates text blocks, renders baseline resource links as bracketed textual references, rejects empty or beyond-baseline input, permits one in-flight request per session, and settles from that request's owning durable `turn/end`. | +| `session/prompt` | Concatenates text blocks, renders baseline resource links as bracketed textual references, rejects empty or beyond-baseline input, permits one in-flight request per session, and waits for the whole agent to become idle. Normal quiescence reports `end_turn`; explicit ACP cancellation or disposal reports `cancelled`. | | `session/cancel` | Cancels only the addressed agent and settles its pending prompt as `cancelled`; unknown ids are no-ops. | | `session/update` | Emits one `agent_message_chunk` per non-empty text block in a committed `assistant/message`. Raw deltas and non-message events are omitted. | | `session/request_permission` | Offers one-shot allow/reject choices for bridge-owned approval requests carrying a tool call id. Clients may answer automatically. | @@ -37,6 +37,8 @@ Committed-message output intentionally trades token-by-token latency for a clean Client disconnect and Cordis disposal share one memoized teardown. The bridge first rejects new sessions and prompts, settles pending prompts, then disposes all owned agent handles in parallel and awaits their loop/session cleanup. An ACP-only plugin reload therefore leaves no orphan agent. +ACP requires each prompt response to carry a `stopReason`, but the bridge does not claim a prompt-specific turn outcome. Committed assistant messages stream across the owned activity, and steering or injected work may contribute before idle. Token-limit and model-error turn endings therefore do not become prompt-level ACP stop reasons. + ## Running `pnpm --dir /path/to/deepseek-harness run demo:acp` boots the repository's automation server composition. A parent harness can spawn it through [`@deepseek-ai/dsh-subagent-acp`](../../subagent/subagent-acp/README.md); other ACP clients need only the core methods above. diff --git a/packages/acp/acp/README.zh.md b/packages/acp/acp/README.zh.md index c1e7d045b5..3fd914ecc5 100644 --- a/packages/acp/acp/README.zh.md +++ b/packages/acp/acp/README.zh.md @@ -24,7 +24,7 @@ | `initialize` | 协商受支持的版本,并仅公布基线提示词(无图像、音频或嵌入上下文能力)。不公布会话、编辑器、终端、文件系统或 MCP 能力。 | | `authenticate` | 空操作,因为服务器不公布身份验证方法。 | | `session/new` | 以绝对路径作为主 `cwd` 创建新 agent;接受空的 `additionalDirectories` 和 `mcpServers`,拒绝非空值。 | -| `session/prompt` | 拼接文本块,将基线资源链接渲染为带方括号的文本引用,拒绝空输入或超出基线的输入,每个会话只允许一个正在处理的请求,并根据该请求所属的持久 `turn/end` 结算。 | +| `session/prompt` | 拼接文本块,将基线资源链接渲染为带方括号的文本引用,拒绝空输入或超出基线的输入,每个会话只允许一个正在处理的请求,并等待整个 agent 进入 idle。正常完全停稳时报告 `end_turn`;显式 ACP 取消或资源释放时报告 `cancelled`。 | | `session/cancel` | 仅取消指定的 agent,并将其待处理提示词结算为 `cancelled`;未知 id 为空操作。 | | `session/update` | 为每个非空文本块发出一个 `agent_message_chunk`;这些文本块来自已提交的 `assistant/message`。省略原始增量和非消息事件。 | | `session/request_permission` | 为携带工具调用 id、由桥接层拥有的批准请求提供一次性允许/拒绝选项。客户端可以自动回答。 | @@ -37,6 +37,8 @@ 客户端断开连接与 Cordis 的 dispose(资源释放)共用同一个记忆化清理流程。桥接层先拒绝新会话和提示词,结算待处理提示词,然后并行对其拥有的全部 agent 句柄执行 dispose,并等待它们的循环/会话清理完成。因此,单独重载 ACP 插件不会遗留孤儿 agent。 +ACP 要求每个提示词响应都携带 `stopReason`,但桥接层不声称它表示提示词专属的轮次结果。已提交的 assistant 消息会在整个自有活动期间流式输出,agent 进入 idle 前发生的 steering(中途引导)或注入工作也可能参与其中。因此,因 token 上限或模型错误而结束的轮次不会成为提示词级 ACP 停止原因。 + ## 运行 `pnpm --dir /path/to/deepseek-harness run demo:acp` 启动仓库的自动化服务器组合。父 harness 可以通过 [`@deepseek-ai/dsh-subagent-acp`](../../subagent/subagent-acp/README.md) spawn 它;其他 ACP 客户端只需上述核心方法。 diff --git a/packages/acp/acp/src/codec.ts b/packages/acp/acp/src/codec.ts index bdfa44bba5..68a577db0a 100644 --- a/packages/acp/acp/src/codec.ts +++ b/packages/acp/acp/src/codec.ts @@ -3,30 +3,7 @@ * @module @deepseek-ai/dsh-acp/codec */ -import type { ContentBlock as AcpContentBlock, StopReason } from '@agentclientprotocol/sdk' -import type { TurnEndReason } from '@deepseek-ai/dsh-session' - -/** - * Map a harness turn ending to ACP's terminal reason vocabulary. - * @param reason - harness turn outcome. - * @returns the closest legal ACP stop reason. - */ -export function turnEndToStopReason(reason: TurnEndReason): StopReason { - switch (reason.kind) { - case 'completed': - return 'end_turn' - case 'max-tokens': - return 'max_tokens' - case 'aborted': - case 'interrupted': - return 'cancelled' - case 'error': - return 'end_turn' - // TurnEndReason is merge-extensible; future variants still need a legal wire value. - default: - return 'end_turn' - } -} +import type { ContentBlock as AcpContentBlock } from '@agentclientprotocol/sdk' /** * Flatten an ACP prompt's baseline blocks to text. Text blocks concatenate diff --git a/packages/acp/acp/src/index.ts b/packages/acp/acp/src/index.ts index f3dd59679a..925f675c7a 100644 --- a/packages/acp/acp/src/index.ts +++ b/packages/acp/acp/src/index.ts @@ -30,14 +30,13 @@ import { type PromptRequest, type PromptResponse, type SessionNotification, - type StopReason, type Stream, } from '@agentclientprotocol/sdk' import type { Agent } from '@deepseek-ai/dsh-agent' -import { SessionId, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session' +import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' // Side-effect type import: declaration-merges the approval waterfall answered below. import type {} from '@deepseek-ai/dsh-user-approval' -import { acpPromptToText, promptHasUnsupportedContent, turnEndToStopReason } from './codec.ts' +import { acpPromptToText, promptHasUnsupportedContent } from './codec.ts' export const name = 'acp' /** The bridge creates and owns agents; every other concern is carried by the agent composition. */ @@ -75,15 +74,7 @@ interface SessionRecord { dispose: () => Promise /** In-flight prompt and its captured turn number for exact settlement. */ inflight: { - resolve: (reason: StopReason) => void - reject: (error: Error) => void - turn: number | undefined - /** - * A failed turn's terminal reason, held until quiescence: a retry action - * closes the failed turn and opens a successor that adopts the prompt, so - * rejecting at `turn/end` would race the recovery. - */ - pendingError: Extract | undefined + cancelled: boolean } | undefined } @@ -125,18 +116,10 @@ export function apply(ctx: Context, config: AcpConfig): void { }) } - const settlePrompt = (record: SessionRecord, reason: StopReason): void => { + const cancelPrompt = (record: SessionRecord): void => { const inflight = record.inflight if (inflight === undefined) return - record.inflight = undefined - inflight.resolve(reason) - } - - const rejectFromError = ( - inflight: NonNullable, - reason: Extract, - ): void => { - inflight.reject(internalError(`turn failed: ${'failure' in reason ? reason.failure.message : reason.message}`)) + inflight.cancelled = true } // Emit only committed assistant text. Raw chunks, reasoning, tools, plans, @@ -145,41 +128,16 @@ export function apply(ctx: Context, config: AcpConfig): void { ctx.on('session/event', (session, event: SessionEvent) => { const record = sessions.get(session.header.id) if (record === undefined || record.agent.session !== session) return - try { - if (event.type === 'assistant/message') { - for (const block of event.data.message.content) { - if (block.type === 'text' && block.text.length > 0) { - notify({ - sessionId: record.agent.session.id, - update: { - sessionUpdate: 'agent_message_chunk', - content: { type: 'text', text: block.text }, - }, - }) - } - } - } - } finally { - const inflight = record.inflight - if (inflight !== undefined && event.type === 'turn/start') { - if (inflight.turn === undefined && event.data.trigger.kind === 'message' - && event.data.trigger.source.kind === 'user') { - inflight.turn = event.data.turn - } else if (inflight.pendingError !== undefined && event.data.trigger.kind === 'retry') { - // A recovery policy opened a retry turn on the failed history: the - // prompt rides it instead of rejecting on the failed turn's end. - inflight.turn = event.data.turn - inflight.pendingError = undefined - } - } else if (inflight !== undefined && event.type === 'turn/end' && inflight.turn === event.data.turn) { - if (event.data.reason.kind === 'error') { - // Hold the rejection: request recovery may adopt the prompt with a - // successor turn; quiescence without one delivers this error. - inflight.turn = undefined - inflight.pendingError = event.data.reason - } else { - record.inflight = undefined - inflight.resolve(turnEndToStopReason(event.data.reason)) + if (record.inflight !== undefined && event.type === 'assistant/message') { + for (const block of event.data.message.content) { + if (block.type === 'text' && block.text.length > 0) { + notify({ + sessionId: record.agent.session.id, + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: block.text }, + }, + }) } } } @@ -265,49 +223,22 @@ export function apply(ctx: Context, config: AcpConfig): void { if (ctx.agents.get(record.agent.id) !== record.agent) { throw internalError('prompt was not queued: the agent was disposed outside the bridge') } - const stopReason = await new Promise((resolve, reject) => { - // Arm the slot before followup() so a listener-driven synchronous - // turn cannot slip past correlation; a synchronous followup() - // failure (invalid input) must free the slot again or the session - // would reject every later prompt as already in flight. - const inflight: NonNullable = { - resolve, reject, turn: undefined, pendingError: undefined, - } - record.inflight = inflight - try { - record.agent.followup(createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'user' } })) - // The machine's send() contains listener failures and accepts - // any typed input; this guards a future synchronous throw so the - // slot cannot wedge. - /* v8 ignore start -- future-proofing guard, see above */ - } catch (error: unknown) { - record.inflight = undefined - const detail = error instanceof Error ? error.message : String(error) - throw internalError(`prompt was not queued: ${detail}`) - } - /* v8 ignore stop */ - // Admission is pre-turn and retries outlive their failed turn, so a - // turnless slot settles only at quiescence: a held failure rejects - // (no retry adopted the prompt); no turn at all means admission - // discarded the prompt — report cancelled. - void record.agent.whenIdle().then(() => { - if (record.inflight !== inflight || inflight.turn !== undefined) return - record.inflight = undefined - if (inflight.pendingError !== undefined) { - rejectFromError(inflight, inflight.pendingError) - return - } - inflight.resolve('cancelled') - }) - }) - return { stopReason } + const inflight: NonNullable = { cancelled: false } + record.inflight = inflight + try { + record.agent.followup(createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'user' } })) + await record.agent.whenIdle() + return { stopReason: inflight.cancelled ? 'cancelled' : 'end_turn' } + } finally { + if (record.inflight === inflight) record.inflight = undefined + } }, cancel(params: CancelNotification): Promise { const record = sessions.get(SessionId(params.sessionId)) if (record === undefined) return Promise.resolve() + cancelPrompt(record) record.agent.cancel({ kind: 'user' }) - settlePrompt(record, 'cancelled') return Promise.resolve() }, } @@ -327,7 +258,7 @@ export function apply(ctx: Context, config: AcpConfig): void { const records = [...sessions.values()] sessions.clear() quiescing = Promise.all(records.map(async (record) => { - settlePrompt(record, 'cancelled') + cancelPrompt(record) await record.dispose() })).then(() => {}) return quiescing diff --git a/packages/core/agent/README.i18n.yaml b/packages/core/agent/README.i18n.yaml index 78a933294a..1797e084d2 100644 --- a/packages/core/agent/README.i18n.yaml +++ b/packages/core/agent/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/core/agent/README.md -README.md: 6bd5279ace93b6d2569833be6f102c854105c2eb -README.zh.md: cdbb0c0b70124e0037a0f7a7a03ddedf1e3b78d3 +README.md: 987386c363705ded9ab074ed012dd0219e6c8308 +README.zh.md: 593a079bcf93e4c7a0785fb58c6abcb5be53693c diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index 860a60b96e..987386c363 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -50,9 +50,9 @@ Agent *creation* is provided by the plugin implementing `AgentFactory` (`dsh-age The lifecycle edges have two important local caveats. `agent/created` runs after scoped setup and after both session and agent registry entries exist. Setup is trusted composition-only code; the immediately following non-vetoing `agent/session-start` notification is the first supported startup injection point. `agent/disposed` always means the exact agent has left the registry. AgentLoop emits it after its driver is quiescent, while ordered teardown may still be detaching the session and unwinding the scope; custom agents registered directly own any stronger driver-ordering contract themselves. -Most interception points are cooperative waterfalls. Turn-scoped asynchronous seams receive one explicit `AbortSignal`, with `signal` immediately before a waterfall's final `next`; listeners may cooperate but must not retain it as authority over another turn. `agent/step` is the serial checkpoint before request derivation, while `agent/request-error` is the failed-model-request recovery waterfall: it receives request coordinates, normalized failure facts, the serving registration's retry policy when available, and the signal. A listener returns `{ kind: 'retry' }` without calling `next()` when it owns recovery; the loop closes the failed turn and opens one numbered retry turn. `agent/turn-stopping` runs before an otherwise completed turn closes. Ordinary queued prompts remain intact. Effective broad cancellation first emits the observe-only `agent/cancel-requested` with its resolved typed cause, then clears queues and aborts; notification failures are contained and cannot veto the stop. The [explicit-cancellation decision](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md) owns signal lifetime; the [agent-scope runtime-design Agent Note](../../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way) owns scoped dispatch and terminal settlement. +Most interception points are cooperative waterfalls. Turn-scoped asynchronous seams receive one explicit `AbortSignal`, with `signal` immediately before a waterfall's final `next`; listeners may cooperate but must not retain it as authority over another turn. `agent/step` is the serial checkpoint before request derivation, while `agent/request-error` is the failed-model-request recovery waterfall: it receives request coordinates, normalized failure facts, the serving registration's retry policy when available, and the signal. A listener returns `{ kind: 'retry' }` without calling `next()` when it owns recovery. `agent/turn-stopping` runs before an otherwise completed turn closes. Ordinary queued prompts remain intact. The [explicit-cancellation decision](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md) owns signal lifetime; the [agent-scope runtime-design Agent Note](../../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way) owns scoped dispatch and terminal settlement. -`PromptDecision.additionalContexts` is an array of identified, frozen `UserMessage` values so every context keeps its own identity and source. The admitted prompt and every additional context become separate model-facing `user/message` events before the turn runs. A listener that wraps a downstream allow preserves its `content` and `additionalContexts` unless it intentionally replaces either field; replacing admitted content preserves the prompt's identity. +`PromptDecision.allow.messages` is the complete identified, frozen batch admitted by prompt interception. A listener that wraps a downstream allow preserves that batch unless it intentionally replaces it. Turn and step boundaries and the model token stream are durable `session/event` facts rather than mirrored `agent/*` notifications. Consumers read `turn/*`, `step/*`, and `assistant/chunk` from the session feed; tool policy and outcome observation belong to the complete pipeline documented by [`dsh-tools`](../tools/README.md). @@ -60,17 +60,15 @@ Turn and step boundaries and the model token stream are durable `session/event` The handle every plugin programs against: -- `agent.send(message, options)` — the one delivery primitive over the (`target` × `wakeup`) matrix. `message` is an already identified, frozen `UserMessage`; callers normally create it with `createUserMessage()` before routing begins. `SendOptions` owns only the `target` and `wakeup` policy. Each accepted FIFO occurrence receives its own `InboxItemId`, even when callers reuse a `MessageId`; `agent/inbox/enqueue`/`update` and the terminal `dequeue` or `discard` carry that complete `InboxItem`. `target: 'next-turn'` queues one independent FIFO item that, if admitted, becomes the sole ordinary prompt in its turn. `target: 'next-step'` with `wakeup: true` submits steering, while `target: 'next-step'` with `wakeup: false` injects durable context without running the model. The [one-send-one-turn Agent Note](../../../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md) owns the turn rationale. -- `agent.updateInbox(itemId, action)` — synchronously edits or removes one still-pending queued occurrence. Edit keeps its `MessageId`, `InboxItemId`, source, and FIFO position while replacing frozen content; remove emits the occurrence's terminal discard. Steering and claimed occurrences return `not-found`. -- `agent.followup(input)` — the `next-turn`/wakeup preset of `send()`: queue an ordinary follow-up turn and wake the driver. -- `agent.steer(input)` — the `next-step`/wakeup preset: during prompt admission or an open turn, stage steering for the next safe boundary without dispatching `agent/prompt-submit`; outside that acceptance window, delegate to a woken follow-up. Admission failure leaves staged steering for retry or a later admitted prompt, while cancellation or disposal may discard it. -- `agent.inject(input)` — the `next-step`/no-wakeup preset: append model-facing context without running the model; the next request sees a verbatim user-role message whose provenance is carried by the required `input.source`. During prompt admission or an open turn, injection waits in the outbox for the next safe boundary. Outside that acceptance window, it appends immediately without opening a turn; a context-only admission batch takes this fallback if admission closes without a turn, while context staged beside steering remains pending with it. Persistence reacts to `session/event` independently. Injection emits no `agent/inbox/*` event. -- `agent.acceptsNextStep` — whether steering would currently join prompt admission or the open turn. Use this narrower routing predicate when a caller must choose between steering and a fresh admitted prompt; `status === 'running'` also covers admission exit and turn settlement. -- `agent.cancel(cause, options?)` — cancel the active turn and, unless `options.keepInbox`, ALL pending work. Callers must choose the `user | parent` cause explicitly; an active holder copies its discriminant into a detached frozen signal reason before aborting. An effective call emits `agent/cancel-requested` with the cause before clearing queued and steering work; dropped items are reported on `agent/inbox/discard`, and observers may synchronize state but cannot veto cancellation. `keepInbox: true` aborts the turn but preserves queued and steering items (no discard, and un-started work is not dropped). The same-process typed seam adds no runtime validation or compatibility fallback for untyped callers. Repeated active-turn cancellation is first-wins for the signal, and idle cancellation is a safe no-op with no notification. ACP maps to `user`, while in-process parent propagation maps to `parent`. The cause is runtime-only; durable `turn/end` stays coarse `aborted`. -- `agent.whenIdle()` — resolve once the agent reaches quiescence after settling out of `running` (idle → immediately; disposed → awaits the loop exit). A non-owner's quiescence-observation hook: it observes the work settling WITHOUT tearing the agent down. Teardown is separate — a lifecycle owner stops and unregisters via `AgentHandle.dispose()`, which awaits the loop exit directly. -- `agent.session`, `agent.status`, `agent.options`, `agent.id` +- `agent.inbox` — the agent-owned projection of durable `agent/inbox/spliced` events. `nextTurn` and `nextStep` expose pending `UserMessage` values; `splice(target, start, deleteCount, inserted, outcome?)` uses standard splice coordinates to insert, edit, remove, admit, or cancel them. `MessageId` is the only occurrence identity and must remain unique while pending. +- `agent.followup(message)` — queue an ordinary `next-turn` message and wake the driver. It returns no completion handle; the message id identifies inbox and admission facts, not a later output or `turn/end`. +- `agent.steer(message)` — queue waking `next-step` input. An idle driver schedules a turn; collecting and running drivers consume it at their next step boundary. +- `agent.inject(message)` — queue non-waking `next-step` context. During admission or an open turn it waits for the next safe log position; otherwise it appends immediately without opening a turn. +- `agent.cancel(cause, options?)` — cancel the active driver and, unless `options.keepInbox`, durably cancel all pending inbox work. Idle cancellation is a no-op. +- `agent.whenIdle()` — observe whole-agent quiescence, including replacement work scheduled before the current driver retires. It does not settle any particular message. +- `agent.session`, `agent.status`, `agent.options`, `agent.id`, `agent.ctx` -`running` describes a driver-wide drain interval, not proof that a turn is still open; it can cover turn close, the durability checkpoint, and consecutive queued turns. +`running` describes a driver-wide drain interval, not proof that a turn is still open; it can cover turn close, the durability checkpoint, and consecutive queued turns. Only a caller that owns a complete interval may summarize it as a run result ([proposal](../../../.agents/notes/proposed/architecture/2026-07-30-followup-enqueue-and-owned-runs.md)). ### Extension points diff --git a/packages/core/agent/README.zh.md b/packages/core/agent/README.zh.md index ff76538b9e..593a079bcf 100644 --- a/packages/core/agent/README.zh.md +++ b/packages/core/agent/README.zh.md @@ -50,9 +50,9 @@ Agent *创建* 由实现 `AgentFactory` 的插件(`dsh-agent-loop`)提供, 生命周期边有两个重要的本地注意事项。`agent/created` 在作用域 setup 之后、会话与 agent 注册表条目都存在之后运行。Setup 是受信任、仅用于组合的代码;紧随其后且不可 veto 的 `agent/session-start` 通知是第一个受支持的启动注入点。`agent/disposed` 始终表示确切 agent 已离开注册表。AgentLoop 在其驱动器完全停稳后发出该事件,而有序 teardown 此时可能仍在分离会话并撤销作用域;直接注册的自定义 agent 自行拥有任何更强的驱动器顺序契约。 -大多数拦截点都是协作式 waterfall(瀑布式事件)。轮次作用域的异步 seam 接收一个显式 `AbortSignal`,其中 `signal` 紧邻 waterfall 最终的 `next`;监听器可以配合,但不得将它保留为控制另一轮次的权限。`agent/step` 是派生请求前的串行检查点,而 `agent/request-error` 是失败模型请求的恢复 waterfall:失败步骤关闭后,它接收确切错误、规范化失败事实和信号。拥有恢复权的监听器返回 `{ kind: 'retry' }` 且不调用 `next()`;循环会关闭失败轮次,并打开一个编号重试轮次。`agent/turn-stopping` 在本可完成的轮次关闭前运行。普通排队提示词保持原样。有效的广义取消会先发出只观测的 `agent/cancel-requested` 及其解析后的类型化原因,再清空队列并中止;通知失败会被收容,不能 veto 停止。信号生命周期由[显式取消决策](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)拥有;作用域分发与终止结算由 [agent 作用域 runtime 设计 Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way)拥有。 +大多数拦截点都是协作式 waterfall(瀑布式事件)。轮次作用域的异步 seam 接收一个显式 `AbortSignal`,其中 `signal` 紧邻 waterfall 最终的 `next`;监听器可以配合,但不得将它保留为控制另一轮次的权限。`agent/step` 是派生请求前的串行检查点,而 `agent/request-error` 是失败模型请求的恢复 waterfall:它接收请求坐标、规范化失败事实、可用时提供服务的注册项重试策略以及信号。拥有恢复权的监听器返回 `{ kind: 'retry' }` 且不调用 `next()`。`agent/turn-stopping` 在本可完成的轮次关闭前运行。普通排队提示词保持原样。信号生命周期由[显式取消决策](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)拥有;作用域分发与终止结算由 [agent 作用域 runtime 设计 Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way)拥有。 -`PromptDecision.additionalContexts` 是由带标识且冻结的 `UserMessage` 值组成的数组,因此每个上下文都保留自己的标识和来源。获准的提示词与每个附加上下文都会在轮次运行前成为各自独立、面向模型的 `user/message` 事件。包装下游允许决策的监听器会保留其 `content` 与 `additionalContexts`,除非有意替换任一字段;替换获准内容时仍会保留提示词的标识。 +`PromptDecision.allow.messages` 是提示词拦截所准入的完整、带标识且冻结的批次。包装下游 allow 的监听器会保留该批次,除非有意替换它。 轮次和步骤边界以及模型 token 流是持久 `session/event` 事实,而不是镜像的 `agent/*` 通知。消费方从会话事件流读取 `turn/*`、`step/*` 和 `assistant/chunk`;工具策略与结果观测属于 [`dsh-tools`](../tools/README.md) 记录的完整流水线。 @@ -60,17 +60,15 @@ Agent *创建* 由实现 `AgentFactory` 的插件(`dsh-agent-loop`)提供, 每个插件面向的 handle: -- `agent.send(message, options)`:覆盖(`target` × `wakeup`)矩阵的唯一投递原语。`message` 是已有标识且已冻结的 `UserMessage`;调用方通常会在开始路由前使用 `createUserMessage()` 创建它。`SendOptions` 只持有 `target` 与 `wakeup` 策略。每次获准进入 FIFO 的项都会获得独立的 `InboxItemId`,即使调用方复用了同一个 `MessageId`;`agent/inbox/enqueue`/`update` 及终态 `dequeue` 或 `discard` 都会携带这一完整 `InboxItem`。`target: 'next-turn'` 排队一条独立 FIFO 项,获准后成为其轮次中唯一的普通提示词。`target: 'next-step'` 且 `wakeup: true` 提交 steering(中途引导),而 `target: 'next-step'` 且 `wakeup: false` 注入持久上下文,不运行模型。轮次原理由 [one-send-one-turn Agent Note](../../../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md)拥有。 -- `agent.updateInbox(itemId, action)`:同步编辑或移除一个仍处于待处理状态的 queued 入队项。编辑会替换已冻结的内容,同时保留其 `MessageId`、`InboxItemId`、来源与 FIFO 位置;移除会发出该项的终态 discard。steering 项和已被认领的项会返回 `not-found`。 -- `agent.followup(input)`:`send()` 的 `next-turn`/wakeup 预设:排队一个普通后续轮次并唤醒驱动器。 -- `agent.steer(input)`:`next-step`/wakeup 预设:提示词接纳期间或轮次打开时,为下一个安全边界暂存 steering,且不分发 `agent/prompt-submit`;该接收窗口之外则委托给会唤醒的后续轮次。接纳失败会保留暂存的 steering,以供重试或之后获准的提示词使用,而取消或 dispose 可能丢弃它。 -- `agent.inject(input)`:`next-step`/不唤醒预设:追加面向模型的上下文而不运行模型;下一次请求会看到一条逐字的 user role 消息,其来源由必填的 `input.source` 携带。提示词接纳期间或轮次打开时,注入会在 outbox 中等待下一个安全边界。该接收窗口之外,它会立即追加而不开启轮次;如果接纳结束却未开启轮次,仅含上下文的接纳批次会采用这一回退,而与 steering 一同暂存的上下文则会随其继续待处理。持久化独立地响应 `session/event`。注入不发出 `agent/inbox/*` 事件。 -- `agent.acceptsNextStep`:steering 当前是否会加入提示词接纳或已打开的轮次。当调用方必须在 steering 与新接纳的提示词之间选择时,应使用这一更窄的路由判定;`status === 'running'` 还涵盖接纳收尾与轮次结算阶段。 -- `agent.cancel(cause, options?)`:取消活动轮次,并在未设置 `options.keepInbox` 时取消全部待处理工作。调用方必须显式选择 `user | parent` 原因;活动持有者会在中止前把其判别字段复制为已分离、冻结的信号原因。有效调用会在清除排队与 steering 工作前,随原因发出 `agent/cancel-requested`;丢弃项在 `agent/inbox/discard` 上报告,观察方可以同步状态,但不能 veto 取消。`keepInbox: true` 会中止轮次,但保留排队与 steering 项(不丢弃,且不删除尚未开始的工作)。同进程类型化 seam 不会为无类型调用方添加运行时校验或兼容回退。重复取消活动轮次时,首个信号生效;空闲取消是安全空操作,不发通知。ACP 映射到 `user`,进程内父传播映射到 `parent`。原因只存在于运行时;持久 `turn/end` 保持粗粒度的 `aborted`。 -- `agent.whenIdle()`:agent 从 `running` 结算后达到完全停稳时解析(idle ⇒ 立即;disposed ⇒ 等待循环退出)。这是非拥有者的完全停稳观测钩子:观察工作结算,但不 teardown agent。Teardown 独立存在;生命周期拥有者通过 `AgentHandle.dispose()` 停止并注销,并直接等待循环退出。 -- `agent.session`、`agent.status`、`agent.options`、`agent.id` +- `agent.inbox`:agent 所拥有的持久 `agent/inbox/spliced` 事件投影。`nextTurn` 与 `nextStep` 暴露待处理的 `UserMessage` 值;`splice(target, start, deleteCount, inserted, outcome?)` 使用标准 splice 坐标插入、编辑、移除、准入或取消消息。`MessageId` 是唯一的入队项标识,在消息待处理期间必须保持唯一。 +- `agent.followup(message)`:将一条普通 `next-turn` 消息排队并唤醒驱动器。它不返回完成 handle;消息 id 标识 inbox 和准入事实,而不标识之后的输出或 `turn/end`。 +- `agent.steer(message)`:将会唤醒的 `next-step` 输入排队。空闲驱动器会调度一个轮次;collecting 和 running 驱动器会在各自的下一步骤边界消费该输入。 +- `agent.inject(message)`:将不会唤醒的 `next-step` 上下文排队。在准入期间或轮次打开时,它会等待下一个安全日志位置;否则立即追加,且不打开轮次。 +- `agent.cancel(cause, options?)`:取消活跃驱动器,并在未设置 `options.keepInbox` 时持久取消全部待处理 inbox 工作。空闲取消是空操作。 +- `agent.whenIdle()`:观察整个 agent 达到完全停稳,包括当前驱动器退役前调度的替代工作。它不结算任何特定消息。 +- `agent.session`、`agent.status`、`agent.options`、`agent.id`、`agent.ctx` -`running` 描述驱动器范围的 drain 区间,而不是轮次仍打开的证明;它可以覆盖轮次关闭、持久性检查点和连续的排队轮次。 +`running` 描述驱动器范围的 drain 区间,而不是轮次仍打开的证明;它可以覆盖轮次关闭、持久性检查点和连续的排队轮次。只有拥有完整区间的调用方才能将其概括为一次运行的结果([提案](../../../.agents/notes/proposed/architecture/2026-07-30-followup-enqueue-and-owned-runs.md))。 ### 扩展点 diff --git a/packages/core/session/README.i18n.yaml b/packages/core/session/README.i18n.yaml index 56895397fc..4dd54b4261 100644 --- a/packages/core/session/README.i18n.yaml +++ b/packages/core/session/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/core/session/README.md -README.md: af93791dfc17f66b79b376ba32ec657761ec63bc -README.zh.md: ed9bba76d307a764a20c7cc4a3d2716c55a1acc0 +README.md: 8d65bf85b1b9c36568f9a972afd84695d3301649 +README.zh.md: cf16b01b7855b6cbfe66a83fb9b1183a837b7db8 diff --git a/packages/core/session/README.md b/packages/core/session/README.md index af93791dfc..8d65bf85b1 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -70,13 +70,13 @@ A `user/message` stores the complete `UserMessage` directly, including the ident ### Session event vocabulary (`types.ts`) -The append-only log's event types, enumerated member by member — payloads, surface badges, provenance — in the generated [persistence log event catalog](../../../docs/persistence-catalog.md). Token accounting reads per-step `assistant/chunk { type: 'usage' }` records and treats `assistant/message.usage` as the committed-step fallback when no usage chunk exists; failed model-request attempts have no assistant message. Provider/model/replay provenance rides on `assistant/message`; an operational error's step is on `turn/end.reason` for `kind: 'error'`, with structured provider facts for a final model-request failure. +The append-only log's event types, enumerated member by member — payloads, surface badges, provenance — in the generated [persistence log event catalog](../../../docs/persistence-catalog.md). Token accounting reads per-step `assistant/chunk { type: 'usage' }` records and treats `assistant/message.usage` as the committed-step fallback when no usage chunk exists; failed model-request attempts have no assistant message. Provider/model/replay provenance rides on `assistant/message`. Merge-extensible via `SessionEventMap` — a plugin declaration-merges its own types (the compaction seam's `compact/*`, bounded recovery's non-surface `llm/retry`, the hook bridges' `hook/*`); merged members appear in the same catalog. A plugin owns the relational invariant for its merged events, including whether a log-only event may appear between turns. A producer that requires durability appends through `Session` and then awaits `ctx.sessions.flush(session)` without fabricating an execution turn. -Also defines `TurnTriggerMap` and `TurnEndReasonMap` (merge-extensible sum types for typed turn boundaries — `kind`-tagged instead of strings). A final model-request error retains one structured `LlmFailure`; other turn errors retain message/code, and both identify the failed step. +Also defines `TurnEndReasonMap`, the merge-extensible `kind`-tagged sum type for turn endings. `turn/start` carries only the turn number; the following admitted `user/message` batch records its input, while `llm/retry` records request recovery. -An interrupted live turn ends with the coarse `{ kind: 'aborted' }` outcome. Caller identity belongs to the Agent's runtime cancellation signal rather than the durable transcript; disposal remains the separate `{ kind: 'disposed' }` terminal state. +An interrupted live turn ends with `{ kind: 'aborted', reason: AgentCancelCause }`, preserving the typed cancellation cause in the durable transcript. A turn failure carries `{ kind: 'error', error }`; crash recovery alone synthesizes `{ kind: 'interrupted' }`. Every `SessionEvent` carries two optional top-level fields (structural metadata): diff --git a/packages/core/session/README.zh.md b/packages/core/session/README.zh.md index ed9bba76d3..cf16b01b78 100644 --- a/packages/core/session/README.zh.md +++ b/packages/core/session/README.zh.md @@ -70,13 +70,13 @@ ### 会话事件词汇(`types.ts`) -生成的[持久化日志事件目录](../../../docs/persistence-catalog.md)逐成员列举仅追加日志的事件类型、载荷、surface 标记和溯源信息。Token 记账读取每个步骤的 `assistant/chunk { type: 'usage' }` 记录;如果没有用量分片,则将 `assistant/message.usage` 作为已提交步骤的后备。失败的模型请求尝试没有 assistant 消息。提供方/模型/回放溯源信息随 `assistant/message` 一同保存;运行错误的步骤记录在 `turn/end.reason` 上(此时为 `kind: 'error'`),最终模型请求失败时还包含结构化的提供方事实。 +生成的[持久化日志事件目录](../../../docs/persistence-catalog.md)逐成员列举仅追加日志的事件类型、载荷、surface 标记和溯源信息。Token 记账读取每个步骤的 `assistant/chunk { type: 'usage' }` 记录;如果没有用量分片,则将 `assistant/message.usage` 作为已提交步骤的后备。失败的模型请求尝试没有 assistant 消息。提供方/模型/回放溯源信息随 `assistant/message` 一同保存。 `SessionEventMap` 可通过合并扩展:插件使用声明合并添加自身类型(压缩 seam 的 `compact/*`、有界恢复的非 surface `llm/retry`、hook(钩子)桥接层的 `hook/*`);合并成员会出现在同一目录中。插件拥有其合并事件的关系不变量,包括是否允许纯日志事件出现在轮次之间。需要持久性的生产方通过 `Session` 追加,再等待 `ctx.sessions.flush(session)`,无需虚构一个执行轮次。 -此包还定义 `TurnTriggerMap` 和 `TurnEndReasonMap`(用于类型化轮次边界、可合并扩展的和类型;以 `kind` 为标签而不是字符串)。最终模型请求错误保留一个结构化 `LlmFailure`;其他轮次错误保留消息/代码,两者均标识失败步骤。 +此包还定义 `TurnEndReasonMap`,即用于轮次结束、可合并扩展且以 `kind` 为标签的和类型。`turn/start` 只携带轮次编号;之后已准入的 `user/message` 批次记录其输入,`llm/retry` 则记录请求恢复。 -被中断的实时轮次以粗粒度的 `{ kind: 'aborted' }` 结果结束。调用方身份属于 Agent 的运行时取消信号,不属于持久 transcript(文本记录);资源释放仍是独立的 `{ kind: 'disposed' }` 终态。 +被中断的实时轮次以 `{ kind: 'aborted', reason: AgentCancelCause }` 结束,在持久 transcript(文本记录)中保留类型化取消原因。轮次失败携带 `{ kind: 'error', error }`;只有崩溃恢复会合成 `{ kind: 'interrupted' }`。 每个 `SessionEvent` 都有两个可选顶层字段(结构元数据): diff --git a/packages/examples/cli-demo/README.i18n.yaml b/packages/examples/cli-demo/README.i18n.yaml index f73cad16df..112ca27929 100644 --- a/packages/examples/cli-demo/README.i18n.yaml +++ b/packages/examples/cli-demo/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/examples/cli-demo/README.md -README.md: b8f2bde962738a1a23f0e57218ab0f90e8e0b705 -README.zh.md: 0e03375ced4e087d44eed7ff33666abf1f2cec10 +README.md: 6e46ae81421c23806524b0784a976e9f3c8eeab8 +README.zh.md: 4dc5d482e17e87177e7a1ae3a39435879cdd76ce diff --git a/packages/examples/cli-demo/README.md b/packages/examples/cli-demo/README.md index b8f2bde962..6e46ae8142 100644 --- a/packages/examples/cli-demo/README.md +++ b/packages/examples/cli-demo/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Headless one-shot app and bin for running one agent task without an interactive UI or editor client. It composes [`@deepseek-ai/dsh-agent-spine-demo`](../agent-spine-demo/README.md), JSONL persistence, and exactly one fresh top-level agent. The bin submits the task, waits for its durable turn ending, renders the selected output, disposes to quiescence, and exits. +Headless one-shot app and bin for running one agent task without an interactive UI or editor client. It composes [`@deepseek-ai/dsh-agent-spine-demo`](../agent-spine-demo/README.md), JSONL persistence, and exactly one fresh top-level agent. The bin owns one idle-to-idle activity interval, renders its selected output, disposes to quiescence, and exits. The package mounts no console logger, interactive UI, user-interaction service, or `ask_user_question` tool. Stdout is reserved for the selected output format; diagnostics use stderr. @@ -44,12 +44,12 @@ Loader configs resolve bare package specifiers through the optional native helpe ### Output formats - `text` writes the last assistant message containing text, followed by one newline. -- `json` writes one DSH-native result record: `{ type: "result", success, sessionId, turn, result, reason, usage? }`. `usage` sums each model step in the task turn once, including billed failed retry attempts that produced usage without a committed assistant message. -- `stream-json` writes each canonical event from the top-level session's task turn as `{ type: "session_event", sessionId, event }`, then the same result record. Child-agent activity appears only through the parent tool events and results. +- `json` writes one DSH-native result record: `{ type: "result", sessionId, output, usage? }`. `output` is the last committed assistant text in the activity interval. `usage` sums each model step in that interval once, including billed failed attempts that produced usage without a committed assistant message. +- `stream-json` writes each canonical event from the top-level session's owned activity interval as `{ type: "session_event", sessionId, event }`, then the same result record. Child-agent activity appears only through the parent tool events and results. -Only `reason.kind === "completed"` exits successfully. Other durable turn endings still emit partial text or a result record, add a stderr diagnostic, and exit nonzero. Argument and boot failures leave stdout empty. SIGINT and SIGTERM cancel active work, await disposal, and exit 130 and 143 respectively. +Normal idle completion exits successfully without assigning a turn reason to the task. Argument, boot, observation, and persistence failures leave stdout empty. SIGINT and SIGTERM cancel active work, await disposal, and exit 130 and 143 respectively. -The task turn is explicitly flushed before final output. Session logs remain under `persistenceRoot` after the process exits. +The owned activity is explicitly flushed before final output. Session logs remain under `persistenceRoot` after the process exits. ## Operational safety @@ -57,11 +57,11 @@ The headless-agent leaf supplies local bash, filesystem, skill, subagent, workfl ## Model Experience -### One-shot task turn +### One-shot activity #### What the model sees -The positional task becomes one user message. Through `dsh-agent-spine-demo`, the top-level agent also receives configured workspace instructions and persona, the skill catalog, visible tool schemas, and retained tool results needed for later steps in the same turn. +The positional task becomes one user message. Through `dsh-agent-spine-demo`, the top-level agent also receives configured workspace instructions and persona, the skill catalog, visible tool schemas, and retained tool results needed for later steps in the owned activity. #### Token effect @@ -75,4 +75,4 @@ Tool-round history is append-only while the one-shot agent's prompt, schemas, mo - **One fresh top-level session per process** — its workspace cwd is the launch directory; there is no resume, second prompt, stdin context, or concurrent top-level session in this app. - **No interactive question or approval provider** — tools that require a human answer cannot complete unless a different leaf composes a non-interactive provider with explicit policy. -- **Streaming is top-level-session-only** — child sessions are not flattened into the stream, and aggregate usage covers only model steps recorded on the parent task turn. +- **Streaming is top-level-session-only** — child sessions are not flattened into the stream, and aggregate usage covers only model steps recorded on the parent activity interval. diff --git a/packages/examples/cli-demo/README.zh.md b/packages/examples/cli-demo/README.zh.md index 0e03375ced..4dc5d482e1 100644 --- a/packages/examples/cli-demo/README.zh.md +++ b/packages/examples/cli-demo/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -无头单次应用及 bin,用于在没有交互式 UI 或编辑器客户端的情况下运行一项 agent(智能体)任务。它组合 [`@deepseek-ai/dsh-agent-spine-demo`](../agent-spine-demo/README.md)、JSONL 持久化,以及恰好一个新建顶层 agent。bin 提交任务,等待其已持久化的轮次结束状态,渲染所选输出,执行 dispose(资源释放)直至完全停稳,然后退出。 +无头单次应用及 bin,用于在没有交互式 UI 或编辑器客户端的情况下运行一项 agent(智能体)任务。它组合 [`@deepseek-ai/dsh-agent-spine-demo`](../agent-spine-demo/README.md)、JSONL 持久化,以及恰好一个新建顶层 agent。bin 拥有一个从 idle 到 idle 的活动区间,渲染所选输出,执行 dispose(资源释放)直至完全停稳,然后退出。 该包(package)不挂载 console logger、交互式 UI、用户交互服务或 `ask_user_question` 工具。Stdout 专用于所选输出格式;诊断使用 stderr。 @@ -44,12 +44,12 @@ loader 配置通过仓库安装的可选原生辅助程序解析裸包说明符 ### 输出格式 - `text` 写入最后一条含文本的 assistant 消息,后跟一个换行符。 -- `json` 写入一条 DSH 原生结果记录:`{ type: "result", success, sessionId, turn, result, reason, usage? }`。`usage` 对任务轮次中的每个模型步骤恰好求和一次,包括产生用量但没有提交 assistant 消息的已计费失败重试。 -- `stream-json` 将顶层会话任务轮次中的每个规范事件写成 `{ type: "session_event", sessionId, event }`,然后写入同一结果记录。子 agent 活动只通过父工具事件与结果出现。 +- `json` 写入一条 DSH 原生结果记录:`{ type: "result", sessionId, output, usage? }`。`output` 是活动区间内最后提交的 assistant 文本。`usage` 对该区间中的每个模型步骤恰好求和一次,包括产生用量但没有提交 assistant 消息的已计费失败尝试。 +- `stream-json` 将顶层会话自有活动区间中的每个规范事件写成 `{ type: "session_event", sessionId, event }`,然后写入同一结果记录。子 agent 活动只通过父工具事件与结果出现。 -只有 `reason.kind === "completed"` 会成功退出。其他已持久化的轮次结束状态仍会输出部分文本或结果记录,向 stderr 添加诊断,并以非零状态退出。参数和启动失败会让 stdout 保持为空。SIGINT 与 SIGTERM 会取消正在进行的工作,等待 dispose 完成,并分别以 130 和 143 退出。 +正常进入 idle 会成功退出,不会为该任务指定轮次原因。参数、启动、观测和持久化失败会让 stdout 保持为空。SIGINT 与 SIGTERM 会取消正在进行的工作,等待 dispose 完成,并分别以 130 和 143 退出。 -任务轮次会在最终输出前显式刷新。进程退出后,会话日志仍保留在 `persistenceRoot` 下。 +自有活动会在最终输出前显式刷新。进程退出后,会话日志仍保留在 `persistenceRoot` 下。 ## 操作安全 @@ -57,11 +57,11 @@ headless-agent 叶节点提供本地 bash、文件系统、skill、subagent、 ## 模型体验 -### 单次任务轮次 +### 单次活动 #### 模型看到的内容 -任务位置参数会成为一条用户消息。通过 `dsh-agent-spine-demo`,顶层 agent 还会收到已配置的工作区指令与 persona、skill 目录、可见工具 schema,以及同一轮次后续步骤所需的保留工具结果。 +任务位置参数会成为一条用户消息。通过 `dsh-agent-spine-demo`,顶层 agent 还会收到已配置的工作区指令与 persona、skill 目录、可见工具 schema,以及自有活动后续步骤所需的保留工具结果。 #### Token 影响 @@ -75,4 +75,4 @@ headless-agent 叶节点提供本地 bash、文件系统、skill、subagent、 - **每个进程只创建一个新的顶层会话**:其工作区 cwd 是启动目录;此应用不支持恢复、第二条提示词、stdin 上下文或并发顶层会话。 - **没有交互式问题或批准提供方**:需要人工回答的工具无法完成,除非其他叶节点按显式策略组合一个非交互式提供方。 -- **流式输出仅限顶层会话**:子会话不会平铺到流中,聚合用量只涵盖父任务轮次记录的模型步骤。 +- **流式输出仅限顶层会话**:子会话不会平铺到流中,聚合用量只涵盖父活动区间记录的模型步骤。 diff --git a/packages/examples/cli-demo/src/cli.ts b/packages/examples/cli-demo/src/cli.ts index 7dfb4b82ff..f40cd03f9a 100644 --- a/packages/examples/cli-demo/src/cli.ts +++ b/packages/examples/cli-demo/src/cli.ts @@ -8,7 +8,7 @@ import { parseArgs } from 'node:util' import type { Context } from 'cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import { createUserMessage, type TokenUsage } from '@deepseek-ai/dsh-llm' -import type { SessionEvent, TurnEndReason } from '@deepseek-ai/dsh-session' +import type { SessionEvent } from '@deepseek-ai/dsh-session' import { boot, loadEnv, resolveConfigPath } from '@deepseek-ai/dsh-app-boot' const CLI_NAME = 'dsh-cli-demo' @@ -32,11 +32,8 @@ export type CliCommand = /** DSH-native final record emitted by JSON modes. */ export interface CliResult { readonly type: 'result' - readonly success: boolean readonly sessionId: string - readonly turn: number - readonly result: string - readonly reason: TurnEndReason + readonly output: string readonly usage?: TokenUsage } @@ -203,13 +200,8 @@ async function waitForStartupIdle(agent: Agent, signal?: AbortSignal): Promise() let outputError: Error | undefined - let resolveTurn!: () => void - let rejectTurn!: (error: Error) => void - let firstTurnEnded = false - const turnEnded = new Promise((resolve, reject) => { - resolveTurn = resolve - rejectTurn = reject - }) - - const settleResolved = (): void => { - if (firstTurnEnded) return - firstTurnEnded = true - resolveTurn() - } - const settleRejected = (error: Error): void => { - // The once-registered abort listener is the only rejecter, and a settled - // prompt makes targetTurn defined so onAbort skips rejection entirely; - // kept for symmetry with settleResolved. - /* v8 ignore next -- unreachable second settlement, see above */ - if (firstTurnEnded) return - firstTurnEnded = true - rejectTurn(error) - } + let interrupted: CliInterruptedError | undefined const observe = (sessionId: string, event: SessionEvent): void => { if (outputError !== undefined || options.onEvent === undefined) return try { @@ -261,38 +232,29 @@ export async function runOneShot(ctx: Context, options: OneShotOptions): Promise const disposeListener = ctx.on('session/event', (session, event) => { if (session !== agent.session) return - if (targetTurn === undefined) { - if (event.type !== 'turn/start' || event.data.trigger.kind !== 'message') return - targetTurn = event.data.turn - } else if (event.type === 'turn/start' && event.data.trigger.kind === 'retry' - && reason?.kind === 'error') { - targetTurn = event.data.turn - reason = undefined + if (!received) { + if (event.type !== 'agent/inbox/spliced' + || !event.data.inserted.some(inserted => inserted.id === message.id)) return + received = true } observe(session.id, event) - if (event.type === 'assistant/chunk' - && event.data.turn === targetTurn - && event.data.chunk.type === 'usage') { + if (event.type === 'assistant/chunk' && event.data.chunk.type === 'usage') { usageByStep.set(`${event.data.turn}/${event.data.step}`, event.data.chunk.usage) } - if (event.type === 'assistant/message' && event.data.turn === targetTurn) { - result = assistantText(event) ?? result + if (event.type === 'assistant/message') { + output = assistantText(event) ?? output if (event.data.usage !== undefined) { usageByStep.set(`${event.data.turn}/${event.data.step}`, event.data.usage) } } - if (event.type === 'turn/end' && event.data.turn === targetTurn) { - reason = event.data.reason - settleResolved() - } }) const signal = options.signal let onAbort: (() => void) | undefined if (signal !== undefined) { onAbort = (): void => { + interrupted ??= new CliInterruptedError(interruptionReason(signal)) agent.cancel({ kind: 'user' }) - if (targetTurn === undefined) settleRejected(new CliInterruptedError(interruptionReason(signal))) } signal.addEventListener('abort', onAbort, { once: true }) /* v8 ignore next -- closes the race between startup-idle completion and listener registration */ @@ -300,37 +262,27 @@ export async function runOneShot(ctx: Context, options: OneShotOptions): Promise } try { - /* v8 ignore next -- skips send only when cancellation wins the listener-registration race above */ - if (!firstTurnEnded) { // oxlint-disable-line typescript/no-unnecessary-condition - agent.followup(createUserMessage({ content: [{ type: 'text', text: options.task }], source: { kind: 'user' } })) - } - await turnEnded + if (interrupted === undefined) agent.followup(message) + await agent.whenIdle() } finally { if (onAbort !== undefined) signal?.removeEventListener('abort', onAbort) - await agent.whenIdle() disposeListener() } - /* v8 ignore next 3 -- turnEnded resolves only from the matching branch that assigns both values */ - if (targetTurn === undefined || reason === undefined) { - throw new Error('task ended without a correlated turn/end event') - } await ctx.sessions.flush(agent.session) if (outputError !== undefined) throw outputError + if (interrupted !== undefined) throw interrupted const usage = [...usageByStep.values()].reduce(addUsage, undefined) return { type: 'result', - success: reason.kind === 'completed', sessionId: agent.session.id, - turn: targetTurn, - result, - reason, + output, ...usage === undefined ? {} : { usage }, } } function renderResult(outputFormat: OutputFormat, result: CliResult): string { - return outputFormat === 'text' ? `${result.result}\n` : `${JSON.stringify(result)}\n` + return outputFormat === 'text' ? `${result.output}\n` : `${JSON.stringify(result)}\n` } /** @@ -380,23 +332,6 @@ async function bootInterruptibly( } } -/** - * Render a non-completed turn reason for stderr. - * @param reason - durable turn ending to describe. - * @returns a concise diagnostic fragment. - */ -export function formatTurnFailure(reason: TurnEndReason): string { - switch (reason.kind) { - case 'completed': return 'completed' - case 'aborted': return 'was aborted' - case 'error': return `failed at step ${reason.step}: ${'failure' in reason ? reason.failure.message : reason.message}` - case 'disposed': return 'was disposed' - case 'max-tokens': return 'reached the model output-token limit' - case 'interrupted': return 'was interrupted during persistence recovery' - default: return `ended with ${JSON.stringify(reason)}` - } -} - /** * Execute one CLI invocation. Argument and boot failures never write stdout; * context disposal is awaited before return, and its failure does not replace @@ -451,8 +386,7 @@ export async function executeCli(args: readonly string[], runtime: CliRuntime = : {}, }) writeStdout(renderResult(command.outputFormat, result)) - exitCode = result.success ? 0 : 1 - if (!result.success) diagnostic = `${CLI_NAME}: turn ${result.turn} ${formatTurnFailure(result.reason)}\n` + exitCode = 0 } catch (error: unknown) { diagnostic = `${CLI_NAME}: ${toError(error).message}\n` } finally { diff --git a/packages/goal/goal-session/README.i18n.yaml b/packages/goal/goal-session/README.i18n.yaml index c0c9f24f89..71aa6413f7 100644 --- a/packages/goal/goal-session/README.i18n.yaml +++ b/packages/goal/goal-session/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/goal/goal-session/README.md -README.md: 6a1c3b9455c93762c2458109c753588ce9a08d9a -README.zh.md: 4162411bf2dbeebbec8da6433c71e176057c4277 +README.md: 7c56f295bb7a587913b201db8860d11605886a64 +README.zh.md: e41afbc4142eee6a8a50e43b4fa6ca34ecc28641 diff --git a/packages/goal/goal-session/README.md b/packages/goal/goal-session/README.md index 6a1c3b9455..7c56f295bb 100644 --- a/packages/goal/goal-session/README.md +++ b/packages/goal/goal-session/README.md @@ -23,30 +23,21 @@ The plugin has no tunable configuration. `maxGoalRounds` belongs to the goal def When an exact live agent is idle with an active, armed goal and remaining capacity, the driver first checkpoints pending goal mutations, then reserves `roundsStarted + 1` for the current `{ goalId, revision }`. It queues one `` prompt with `GoalMessageSource`. Admission through `agent/prompt-submit` verifies the complete queued record and current goal both before and after downstream prompt hooks; only the accepted `user/message` increments `roundsStarted`. A reservation rejected as stale does not consume the round number. -One goal round owns one ordinary session turn, and that turn may contain several model/tool steps. The driver pairs a reservation only with a `message` turn carrying its exact `GoalMessageSource`; merge-extensible plugin turn triggers do not admit or replace that reservation. Human messages remain ordinary turns and do not consume the goal cap. If human work enters the inbox before a reservation or joins its pending batch, automatic work yields until that work settles; a pending automatic prompt in a mixed batch is rejected and re-reserved only after the agent becomes idle. +`MessageId` identifies the reserved message through durable inbox insertion and admission; it does not identify a turn result. Human messages do not consume the goal cap. If human work enters the inbox before a reservation or joins its pending batch, automatic work yields until the agent becomes idle; a pending automatic prompt in a mixed batch is rejected and re-reserved only after that checkpoint. The retained prompt names the JSON-quoted objective and `round/maxGoalRounds`, treats the current workspace, tool results, and durable session state as authoritative, requires evidence before completion, and tells the model to leave the goal active when work remains. Quoting preserves multiline or tag-like objective text as data. Goal lifecycle mutations still require the independent authority checks in `dsh-tool-goal`. -## Settlement policy +## Idle checkpoint -| Durable turn outcome | Goal action | Automatic retry | -|---|---|---| -| `completed` with goal still active and armed | admit the next round, or block with code `round-limit` at the cap | yes | -| cancellation of a reserved/admitted goal round, or its `aborted` outcome | `paused` | no | -| cancellation with no goal-round attempt | keep durable phase; disarm activation | no | -| `error` with `RATE_LIMIT` or `QUOTA` | `blocked` with code `usage-limited` | no | -| other `error`, `max-tokens`, or a non-stale prompt rejection | `blocked` with a diagnostic code and message | no | -| durability failure, disposal, interruption, or unknown future outcome | disarm or block for inspection | no | - -A goal mutation made during its round supersedes settlement of the older revision. Completion, pause, blocking, and edits therefore remain authoritative even if the physical turn closes afterward. No abnormal result is retried automatically. +At whole-agent idle, durable goal phase and revision are authoritative. An active, armed goal with capacity reserves its next round; completion, pause, blocking, and edits suppress continuation. The driver does not classify the preceding activity by correlating the goal message with `turn/end`, so provider errors and token limits are not prompt-level goal outcomes. ## Lifecycle and durability -`goal/changed` creates a durability obligation. Before queuing work, the driver awaits `ctx.sessions.flush()` and rechecks both the goal revision and competing input after the await. A closing flush failure arrives through `agent/error`; the driver associates it with the exact closed turn even if a later one-shot injection has appended another turn, then disarms before another round can start. +`goal/changed` creates a durability obligation. Before queuing work, the driver awaits `ctx.sessions.flush()` and rechecks both the goal revision and competing input after the await. A flush failure arriving through `agent/error` disarms continuation before another round can start. Activation is never inherited when this plugin loads over an existing agent. `GoalService.disarm()` removes process-local authority without changing durable phase, revision, or history; explicit human-authorized resume records the later reactivation. The same rule applies after session resume and fork through the goal domain's `agent/session-start` handling. -Cancellation is observe-before-act: the concrete loop emits `agent/cancel-requested` with its typed cause before clearing queues or aborting the turn. The plugin durably pauses an active goal only when the cancellation owns a reserved or admitted goal attempt; cancellation of unrelated human work merely disarms process-local continuation. If the pause mutation fails, the driver falls back to disarming. Plugin teardown closes admission, disarms every live goal, cancels an admitted round with the `parent` cause, and awaits the driver plus agent quiescence while its event fence remains installed. +Cancellation removes pending inbox work or leaves an agent-wide aborted state. At the next idle checkpoint the driver pauses a goal with a reserved or admitted attempt so cancellation cannot auto-restart it; cancellation unrelated to a goal attempt only disarms process-local continuation. If the pause mutation fails, the driver falls back to disarming. Plugin teardown closes admission, disarms every live goal, cancels active work with the `parent` cause, and awaits the driver plus agent quiescence while its event fence remains installed. ## Model Experience @@ -69,5 +60,5 @@ Append-only within an epoch: each admitted round extends the existing conversati - **No independent evaluator** — the model-facing goal policy decides when evidence is sufficient for completion and whether a blocker is semantically unchanged; evaluator-backed certification remains deferred. - **Same-session execution only** — this package deliberately does not spawn a fresh agent, fork a session prefix, or implement Ralph-style independent attempts; that workflow belongs to its own plugin layer. - **Accepted-queue unload race** — Cordis plugin unload is asynchronous. A goal prompt already accepted by the agent inbox can begin and consume its round before unload starts; teardown then cancels the request, disarms the goal, and awaits quiescence. No later round starts. -- **Round cap, not resource budget** — token, currency, time, and provider quota policies remain independent; observed `RATE_LIMIT` and `QUOTA` stops only map into the blocked reason code `usage-limited`. +- **Round cap, not resource budget** — token, currency, time, and provider quota policies remain independent. Their session events are not attributed to the goal message or mapped into goal blocker codes. - **No abnormal auto-retry** — transient provider and persistence failures require a later human-authorized resume rather than an implicit retry policy. diff --git a/packages/goal/goal-session/README.zh.md b/packages/goal/goal-session/README.zh.md index 4162411bf2..e41afbc414 100644 --- a/packages/goal/goal-session/README.zh.md +++ b/packages/goal/goal-session/README.zh.md @@ -23,30 +23,21 @@ 当对应的活跃 agent 实例处于 idle 状态,且目标 phase 为 active、已启用续行并有剩余容量时,驱动器先为待处理 goal 变更创建检查点,再预留 `roundsStarted + 1`,对应当前 `{ goalId, revision }`。它会排入一条 `` 提示词,并携带 `GoalMessageSource`。通过 `agent/prompt-submit` 准入时,会在下游提示词钩子前后验证完整的排队记录与当前 goal;只有被接受的 `user/message` 才会增加 `roundsStarted`。因陈旧而被拒绝的预留不会消耗 Round 编号。 -一个 Goal Round 对应一个普通会话轮次,该轮次可以包含多个模型/工具步骤。驱动器只会把预留与 `message` 轮次配对,且该轮次必须携带完全相同的 `GoalMessageSource`;可通过声明合并扩展的插件轮次触发器不会准入或替换该预留。用户消息仍是普通轮次,不消耗 goal 上限。如果用户工作在预留前进入 inbox,或加入预留的待处理批次,自动工作会让行,直到用户工作结算;混合批次中的待处理自动提示词会被拒绝,只有 agent 再次 idle 后才重新预留。 +`MessageId` 通过持久 inbox 插入和准入来标识预留消息;它不标识轮次结果。用户消息不消耗 goal 上限。如果用户工作在预留前进入 inbox,或加入预留的待处理批次,自动工作会让行,直到 agent 进入 idle;混合批次中的待处理自动提示词会被拒绝,只有完成该检查点后才重新预留。 保留的提示词会点明经过 JSON 引用的目标与 `round/maxGoalRounds`,将当前工作区、工具结果和持久会话状态视为权威信息,要求在完成前提供证据,并要求在工作仍未完成时保持目标 active。引用可将多行或形似标签的目标文本保留为数据。goal 生命周期变更仍必须通过 `dsh-tool-goal` 的独立权限检查。 -## 结算策略 +## Idle 检查点 -| 持久轮次结果 | Goal 操作 | 自动重试 | -|---|---|---| -| goal phase 仍为 active 且已启用续行时的 `completed` | 准入下一 Round;达到上限时以代码 `round-limit` 阻塞 | 是 | -| 已预留/准入 Goal Round 的取消,或其 `aborted` 结果 | `paused` | 否 | -| 未尝试 Goal Round 时取消 | 保留持久 phase;撤销激活 | 否 | -| `error` 且带 `RATE_LIMIT` 或 `QUOTA` | 设为 `blocked`,代码为 `usage-limited` | 否 | -| 其他 `error`、`max-tokens` 或非陈旧提示词拒绝 | 以诊断代码和消息设为 `blocked` | 否 | -| 持久性失败、dispose(资源释放)、中断或未知未来结果 | 撤销激活或阻塞,以便检查 | 否 | - -某个 goal 在自身 Round 中发生的变更,会取代旧 revision 的结算。因此,即使物理轮次随后关闭,完成、暂停、阻塞和编辑仍具有最终决定权。任何异常结果都不会自动重试。 +整个 agent 进入 idle 时,持久 goal phase 和 revision 具有权威性。phase 为 active、已启用续行且仍有容量的 goal 会预留下一 Round;完成、暂停、阻塞和编辑都会阻止续行。驱动器不会通过关联 goal 消息与 `turn/end` 来对前一段活动分类,因此提供方错误和 token 上限不属于提示词级 goal 结果。 ## 生命周期与持久性 -`goal/changed` 会产生持久性义务。排队工作前,驱动器会等待 `ctx.sessions.flush()`,并在等待后重新检查 goal revision 与竞争输入。关闭时的 flush 失败通过 `agent/error` 到达;即使后续一次性注入已经追加另一轮次,驱动器仍会把失败关联到完全相同的已关闭轮次,然后停用续行,避免另一 Round 启动。 +`goal/changed` 会产生持久性义务。排队工作前,驱动器会等待 `ctx.sessions.flush()`,并在等待后重新检查 goal revision 与竞争输入。通过 `agent/error` 到达的 flush 失败会停用续行,避免另一 Round 启动。 此插件加载到现有 agent 上时绝不会继承续行启用状态。`GoalService.disarm()` 会移除进程本地权限,而不改变持久 phase、revision 或历史;之后由用户明确授权的 resume 会记录重新启用续行。会话 resume 和 fork 后,goal 领域通过 `agent/session-start` 处理应用相同规则。 -取消采用先观察、后行动的顺序:具体循环会在清空队列或中止轮次前,发送带类型 cause 的 `agent/cancel-requested`。仅当取消操作所针对的是已预留或已准入的 Goal Round 尝试时,插件才会持久暂停 active goal;取消无关用户工作只会撤销进程本地续行权限。如果 pause 变更失败,驱动器会回退到停用续行。插件 teardown 会关闭准入,停用所有活跃 goal 的续行,以 `parent` cause 取消已经准入的 Round,并在事件隔离仍安装的情况下等待驱动器和 agent 完全停稳。 +取消会移除 inbox 中待处理的工作,或留下 agent 范围的 aborted 状态。在下一次 idle 检查点,驱动器会暂停存在已预留或已准入尝试的 goal,避免取消后自动重启;与 goal 尝试无关的取消只会撤销进程本地续行权限。如果 pause 变更失败,驱动器会回退到停用续行。插件 teardown 会关闭准入,停用所有活跃 goal 的续行,以 `parent` cause 取消正在进行的工作,并在事件隔离仍安装的情况下等待驱动器和 agent 完全停稳。 ## 模型体验 @@ -69,5 +60,5 @@ - **没有独立评估器**:面向模型的 goal 策略会判断证据是否足以完成,以及 blocker 在语义上是否未变;评估器支持的认证仍保持暂缓。 - **只在同一会话执行**:此包(package)有意不 spawn 新 agent、不 fork 会话前缀,也不实现 Ralph 风格的独立尝试;该工作流属于单独的插件层。 - **已接受队列的卸载竞态**:Cordis 插件卸载是异步的。已经被 agent inbox 接受的 goal 提示词可以在卸载开始前启动并消耗其 Round;teardown 随后会取消请求、撤销 goal 激活并等待完全停稳。不会再启动后续 Round。 -- **只有 Round 上限,不是资源预算**:token、货币、时间与提供方配额策略保持独立;观察到 `RATE_LIMIT` 和 `QUOTA` 时,只会映射为阻塞原因代码 `usage-limited`。 +- **只有 Round 上限,不是资源预算**:token、货币、时间与提供方配额策略保持独立。对应的会话事件不会归属于 goal 消息,也不会映射为 goal 阻塞代码。 - **异常情况不自动重试**:暂时性的提供方与持久化失败需要之后由用户授权 resume,而不会采用隐式重试策略。 diff --git a/packages/goal/goal-session/src/index.ts b/packages/goal/goal-session/src/index.ts index 5694da1dae..3789ec5430 100644 --- a/packages/goal/goal-session/src/index.ts +++ b/packages/goal/goal-session/src/index.ts @@ -8,15 +8,11 @@ import { FiberState } from 'cordis' import type { Context } from 'cordis' import type { Agent, PromptDecision } from '@deepseek-ai/dsh-agent' import type { GoalMessageSource, GoalRef, GoalView } from '@deepseek-ai/dsh-goal' -import { createUserMessage, assertNever } from '@deepseek-ai/dsh-llm' +import { createUserMessage } from '@deepseek-ai/dsh-llm' import type { ContentBlock, MessageId, MessageSource } from '@deepseek-ai/dsh-llm' -import type { Session, SessionEvent, TurnEndReason } from '@deepseek-ai/dsh-session' -import { classifyGoalRound } from './outcome.ts' -import type { GoalRoundOutcome } from './outcome.ts' +import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import { renderGoalRoundPrompt } from './prompt.ts' -export { classifyGoalRound } from './outcome.ts' -export type { GoalRoundOutcome } from './outcome.ts' export { renderGoalRoundPrompt } from './prompt.ts' export const name = 'goal-session' @@ -31,13 +27,11 @@ interface RoundIdentity { readonly round: number } -/** One queued or admitted attempt, retained until its physical turn settles. */ +/** One queued or admitted goal message retained until whole-agent quiescence. */ interface RoundAttempt extends RoundIdentity { readonly messageId: MessageId readonly content: ContentBlock[] phase: 'queued' | 'admitted' - turn: number | undefined - reason: TurnEndReason | undefined stale: boolean } @@ -45,13 +39,11 @@ interface RoundAttempt extends RoundIdentity { interface DriverState { readonly agent: Agent attempt: RoundAttempt | undefined - openTurn: number | undefined competingQueued: boolean needsCheckpoint: boolean requested: boolean run: Promise | undefined stopping: boolean - readonly flushFailedTurns: Set } /** Whether a source identifies an automatic, positive-numbered goal round. */ @@ -92,13 +84,11 @@ export function apply(ctx: Context): void { const state: DriverState = { agent, attempt: undefined, - openTurn: undefined, competingQueued: false, needsCheckpoint: false, requested: false, run: undefined, stopping: false, - flushFailedTurns: new Set(), } states.set(agent, state) return state @@ -134,28 +124,7 @@ export function apply(ctx: Context): void { } } - /** Apply one closed-round outcome only to the exact still-current revision. */ - function applyOutcome(state: DriverState, goal: GoalView, outcome: GoalRoundOutcome): void { - const ref = goalRef(goal) - switch (outcome.kind) { - case 'continue': - return - case 'pause': - ctx.goals.pause(state.agent, ref) - return - case 'blocked': - ctx.goals.block(state.agent, ref, { code: outcome.code, message: outcome.message }) - return - case 'disarm': - ctx.goals.disarm(state.agent) - return - /* v8 ignore next 2 -- GoalRoundOutcome is closed and every member is handled above */ - default: - assertNever(outcome, 'goal round outcome') - } - } - - /** Process a settled attempt, then reserve at most one next round. */ + /** Process admitted work at quiescence, then reserve at most one next round. */ async function drive(state: DriverState): Promise { const { agent } = state if (!readyToDrive(state)) return @@ -166,8 +135,7 @@ export function apply(ctx: Context): void { await ctx.sessions.flush(agent.session) } catch (error: unknown) { ctx.logger.warn(`goal-session: durability checkpoint failed for agent "${agent.id}": ${renderThrown(error)}`) - const goal = currentGoal(state) - if (goal !== undefined) applyOutcome(state, goal, { kind: 'disarm', reason: 'durability-failed' }) + disarm(state) return } // A mutation or ordinary prompt may have arrived while the checkpoint @@ -177,27 +145,8 @@ export function apply(ctx: Context): void { const attempt = state.attempt if (attempt !== undefined) { - // Still unsettled: a contained turn-close failure reaches idle with the - // attempt's turn open in the log and no terminal reason recorded, so - // the drive pass must yield rather than misread it as settled. - if (attempt.reason === undefined) return + if (attempt.phase === 'queued') return state.attempt = undefined - const turn = attempt.turn - /* v8 ignore next -- a closed attempt acquired its turn at turn/start */ - if (turn === undefined) throw new Error('settled goal-round attempt lacks a turn') - const durable = !state.flushFailedTurns.delete(turn) - const goal = currentGoal(state) - if (goal !== undefined && goal.id === attempt.goalId && goal.revision === attempt.revision - && goal.phase === 'active' && goal.activation === 'armed') { - const outcome = classifyGoalRound(attempt.reason, durable) - if (!attempt.stale) applyOutcome(state, goal, outcome) - } - if (!readyToDrive(state)) return - // The loop's persistence is eager write-behind with no turn-end flush, - // so this driver owns the round's durability barrier: checkpoint the - // settled round before reserving another (re-entering drive through - // the flush path above), disarming on failure instead of queueing an - // autonomous round on state that was never persisted. state.needsCheckpoint = true state.requested = true return @@ -226,8 +175,6 @@ export function apply(ctx: Context): void { messageId: message.id, content, phase: 'queued', - turn: undefined, - reason: undefined, stale: false, } state.attempt = reservation @@ -286,12 +233,8 @@ export function apply(ctx: Context): void { // One composite effect keeps the admission fence installed until this // plugin's own scheduling tasks settle. ctx.effect(function* () { - /** Mark a post-turn persistence failure before idle scheduling can run. */ - ctx.on('agent/error', (agent, turn) => { + ctx.on('agent/error', (agent) => { const state = stateFor(agent) - const closed = agent.session.events.some(event => event.type === 'turn/end' && event.data.turn === turn) - if (!closed) return - if (state.attempt?.turn === turn) state.flushFailedTurns.add(turn) disarm(state) }) @@ -300,10 +243,8 @@ export function apply(ctx: Context): void { ctx.on('agent/session-start', (agent) => { const state = stateFor(agent) state.attempt = undefined - state.openTurn = undefined state.competingQueued = false state.needsCheckpoint = false - state.flushFailedTurns.clear() }) ctx.on('agent/status', (agent, status) => { const state = stateFor(agent) @@ -311,11 +252,10 @@ export function apply(ctx: Context): void { state.competingQueued = false const attempt = state.attempt const goal = currentGoal(state) - if (attempt !== undefined && attempt.turn === undefined && attempt.reason === undefined - && goal?.phase === 'active' && goal.activation === 'armed') { + if (attempt?.phase === 'queued' && goal?.phase === 'active' && goal.activation === 'armed') { state.attempt = undefined try { - applyOutcome(state, goal, { kind: 'pause', reason: 'cancelled' }) + ctx.goals.pause(agent, goalRef(goal)) } catch (error: unknown) { ctx.logger.warn(`goal-session: could not pause cancelled goal for agent "${agent.id}": ${renderThrown(error)}`) disarm(state) @@ -345,21 +285,23 @@ export function apply(ctx: Context): void { } return } - case 'turn/start': { - state.openTurn = event.data.turn - return - } case 'user/message': if (state.attempt !== undefined && event.data.id === state.attempt.messageId) { state.attempt.phase = 'admitted' - /* v8 ignore next -- the loop logs admitted input inside an open turn */ - if (state.openTurn !== undefined) state.attempt.turn = state.openTurn } return case 'turn/end': - if (state.attempt?.turn === event.data.turn) state.attempt.reason = event.data.reason - /* v8 ignore next -- balanced live turns close the open turn just observed by this listener */ - if (state.openTurn === event.data.turn) state.openTurn = undefined + if (event.data.reason.kind !== 'aborted') return + { + const goal = currentGoal(state) + if (goal?.phase !== 'active' || goal.activation !== 'armed') return + try { + ctx.goals.pause(agent, goalRef(goal)) + } catch (error: unknown) { + ctx.logger.warn(`goal-session: could not pause cancelled goal for agent "${agent.id}": ${renderThrown(error)}`) + disarm(state) + } + } return default: return @@ -413,7 +355,7 @@ export function apply(ctx: Context): void { // starve every later drive pass. Clear it and let the driver // reschedule the round. const attempt = state.attempt - if (attempt !== undefined && sameRound(source, attempt) && attempt.turn === undefined) { + if (attempt !== undefined && sameRound(source, attempt) && attempt.phase === 'queued') { state.attempt = undefined requestDrive(state) } @@ -468,9 +410,6 @@ export function apply(ctx: Context): void { const attempt = state.attempt if (attempt !== undefined) { attempt.stale = true - if (attempt.phase === 'admitted' && state.agent.status === 'running') { - state.agent.cancel({ kind: 'parent' }) - } } if (state.run !== undefined) waits.push(state.run) } diff --git a/packages/goal/goal-session/src/outcome.ts b/packages/goal/goal-session/src/outcome.ts deleted file mode 100644 index 29615231a0..0000000000 --- a/packages/goal/goal-session/src/outcome.ts +++ /dev/null @@ -1,53 +0,0 @@ -/** Typed settlement policy for one admitted same-session goal round. */ - -import type { TurnEndReason } from '@deepseek-ai/dsh-session' - -/** Driver action derived from one closed goal-owned turn. */ -export type GoalRoundOutcome = - | { readonly kind: 'continue' } - | { readonly kind: 'pause'; readonly reason: string } - | { - readonly kind: 'blocked' - readonly code: 'usage-limited' | 'turn-error' | 'max-tokens' | 'unknown-turn-outcome' - readonly message: string - } - | { readonly kind: 'disarm'; readonly reason: 'durability-failed' | 'disposed' | 'interrupted' } - -/** - * Classify one closed goal round without mutating goal state. - * @param reason - durable reason from the round's `turn/end`. - * @param durable - whether the closing flush reached its durability checkpoint. - * @returns the single driver action; no abnormal outcome requests an automatic retry. - */ -export function classifyGoalRound(reason: TurnEndReason, durable: boolean): GoalRoundOutcome { - if (!durable) return { kind: 'disarm', reason: 'durability-failed' } - const extensibleReason: { readonly kind: string } = reason - switch (reason.kind) { - case 'completed': - return { kind: 'continue' } - case 'aborted': - return { kind: 'pause', reason: 'cancelled' } - case 'error': { - const error = reason.error - const code = typeof error === 'object' && error !== null && 'code' in error - ? error.code - : undefined - const message = error instanceof Error ? error.message : String(error) - return code === 'RATE_LIMIT' || code === 'QUOTA' - ? { kind: 'blocked', code: 'usage-limited', message } - : { kind: 'blocked', code: 'turn-error', message } - } - case 'max-tokens': - return { kind: 'blocked', code: 'max-tokens', message: 'model output reached max tokens' } - case 'interrupted': - return { kind: 'disarm', reason: 'interrupted' } - // TurnEndReason is merge-extensible. An unknown producer cannot opt into - // automatic retry merely by adding a tag; stop for inspection instead. - default: - return { - kind: 'blocked', - code: 'unknown-turn-outcome', - message: `unknown turn outcome: ${extensibleReason.kind}`, - } - } -} diff --git a/packages/sdk/sdk-client/README.i18n.yaml b/packages/sdk/sdk-client/README.i18n.yaml index c0bf2706ff..011d9f3bba 100644 --- a/packages/sdk/sdk-client/README.i18n.yaml +++ b/packages/sdk/sdk-client/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/sdk/sdk-client/README.md -README.md: 3ac4de540401f6f40dab3e84f7005f91d024aee8 -README.zh.md: 95fb6b2887a7bc748a610f7fedae4be1aa2af623 +README.md: 9c441e8538a62f7139f789eef78cf70d8008418a +README.zh.md: fbf638c9160d0356da0eae0566a2775146c47750 diff --git a/packages/sdk/sdk-client/README.md b/packages/sdk/sdk-client/README.md index 3ac4de5404..9c441e8538 100644 --- a/packages/sdk/sdk-client/README.md +++ b/packages/sdk/sdk-client/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The TypeScript client SDK for driving a DeepSeek Harness runtime as a subprocess over stdio JSON-RPC — the design twin of the [Python SDK](../../../python/README.md) (`deepseek-harness`), sharing the same runtime peer, protocol, and layering: `DeepSeekHarness` is the high-level turns API, `HarnessClient` the lower-level protocol client. The package root enumerates the consumer interface: the two client layers, caller-facing types, and `JsonRpcResponseError`; source modules, normalization helpers, and subscription-delivery machinery are not consumer imports. A pure library: it registers nothing on a Cordis context; the runtime process it spawns is a complete harness whose composition its own `cordis.yml` decides. +The TypeScript client SDK for driving a DeepSeek Harness runtime as a subprocess over stdio JSON-RPC — the design twin of the [Python SDK](../../../python/README.md) (`deepseek-harness`), sharing the same runtime peer, protocol, and layering: `DeepSeekHarness` is the high-level owned-run API, `HarnessClient` the lower-level protocol client. The package root enumerates the consumer interface: the two client layers, caller-facing types, and `JsonRpcResponseError`; source modules, normalization helpers, and subscription-delivery machinery are not consumer imports. A pure library: it registers nothing on a Cordis context; the runtime process it spawns is a complete harness whose composition its own `cordis.yml` decides. Unlike the Python SDK, the launch spec is fully explicit (`command`/`args`): this package is for repo-adjacent TypeScript consumers — the [`dsh-subagent-dsh-sdk`](../../subagent/subagent-dsh-sdk/README.md) backend, tests, automation — which know which runtime they are launching. Bundled-runtime resolution (finding a packaged executable) remains the Python distribution's concern. @@ -18,14 +18,16 @@ await using harness = new DeepSeekHarness({ maxTokens: 49_152, }) const result = await harness.run('say hi') -console.log(result.status, result.finalResponse) +console.log(result.finalResponse) ``` -The subprocess starts lazily on first use and stays owned by the instance across `run()` calls; `close()` (or `await using`) is required so the child is always reaped. `start()` memoizes the `initialize` handshake (the workspace cwd — resolved absolute before it crosses the wire — plus the provider/model route and optional positive `maxTokens` output cap); a failed handshake reaps the runtime and swaps in a fresh client, so a later call retries with a new subprocess (until `close()`, which is terminal). The cap applies to each root-agent request and is inherited by in-process descendants; compaction plugins own their separate summary limits. `session(id?)` opens a named or fresh session handle; `run(input, { sessionId?, onNotification? })` sends one prompt turn and settles when the paired `session.finished` arrives, returning a `TurnResult`: `status` (`ok`/`error` as the deployment maps it), the structured `reason` (`TurnEndReason`), `finalResponse` (last assistant message text), root-session `events`, and raw `notifications` for that session plus descendants discovered from `subagent.started`, all in wire order. Model-level failure is a `status: 'error'` result, never a rejection; rejections mean transport loss, timeout, or protocol violation. +The subprocess starts lazily on first use and stays owned by the instance across `run()` calls; `close()` (or `await using`) is required so the child is always reaped. `start()` memoizes the `initialize` handshake (the workspace cwd — resolved absolute before it crosses the wire — plus the provider/model route and optional positive `maxTokens` output cap); a failed handshake reaps the runtime and swaps in a fresh client, so a later call retries with a new subprocess (until `close()`, which is terminal). The cap applies to each root-agent request and is inherited by in-process descendants; compaction plugins own their separate summary limits. `session(id?)` opens a named or fresh session handle. + +`run(input, { sessionId?, onNotification? })` owns one activity interval: it queues the prompt, waits until its `MessageId` appears in a durable `agent/inbox/spliced` receipt, then collects through the next whole-agent `idle`. It returns `RunResult { sessionId, finalResponse, events, notifications }`. `finalResponse` is the last committed root-session assistant text in that interval, not a response causally assigned to the prompt; steering, injected context, and other queued work may contribute before idle. `events` contains root-session events, while `notifications` also contains descendants discovered from `subagent.started`, all in wire order. The result carries no prompt-level status or turn reason. Transport loss, timeout, and protocol violations reject; model outcomes remain observable in the event stream without being attributed to one input. ## HarnessClient -The protocol client under the turns API: explicit `start()`/`initialize()`/`prompt()`/`request()`/`close()`, plus notification subscriptions. `subscribe(filter?)` returns a `NotificationSubscription` (awaitable `next()`, non-blocking `tryNext()`, async iteration); `subscribeSessionTree(id)` scopes to one session and the descendants discovered from `subagent.started` lineage edges — the runtime notifies for every session in its context, and scoping is client-side, exactly like the Python SDK. Error surfaces are typed and exported from this package: `JsonRpcResponseError` (wire error response, code/data preserved), `RequestTimeoutError` (a configured bound elapsed; there is no wire-level cancel, so the request keeps running server-side until close), `SdkProtocolError` (a response outside the documented protocol), `TransportClosedError` (the runtime is gone — message carries the exit code and a bounded stderr tail). +The protocol client under the owned-run API: explicit `start()`/`initialize()`/`prompt()`/`request()`/`close()`, plus notification subscriptions. `prompt()` returns the queued message id as soon as the runtime accepts it; it never waits for agent activity. `subscribe(filter?)` returns a `NotificationSubscription` (awaitable `next()`, non-blocking `tryNext()`, async iteration); `subscribeSessionTree(id)` scopes to one session and the descendants discovered from `subagent.started` lineage edges — the runtime notifies for every session in its context, and scoping is client-side, exactly like the Python SDK. Error surfaces are typed and exported from this package: `JsonRpcResponseError` (wire error response, code/data preserved), `RequestTimeoutError` (a configured bound elapsed), `SdkProtocolError` (a response outside the documented protocol), `TransportClosedError` (the runtime is gone — message carries the exit code and a bounded stderr tail). `close()` requests protocol `shutdown` (bounded by `shutdownTimeoutMs`, default 1000 ms), then walks a stdin-EOF → SIGTERM → SIGKILL ladder (`disposeEofGraceMs` default 6000, `disposeGraceMs` default 3000) until the process has actually exited. The ladder is private to this client: it runs outside any harness context, so it cannot ride the [`dsh-subprocess`](../../subprocess/README.md) service — the seam's documented exception for SDK-managed transports. It is idempotent, and a closed client refuses reuse. @@ -33,7 +35,7 @@ The protocol client under the turns API: explicit `start()`/`initialize()`/`prom ## Testing -Keyless unit tests drive a scripted fake runtime subprocess (`tests/fake-runtime.ts`, protocol-only, env-scripted) over real stdio: turn loop, session-tree scoping, timeout/death/malformed-response surfaces, and the dispose ladder. The [SDK snapshot suite](../../../examples/jsonrpc-agent/tests/sdk.snapshot.ts) drives the real `dsh-jsonrpc-agent` runtime through this client keylessly via `llm-replay`, pinning the notification stream, the turn result, and the persisted logs; `DSH_SNAPSHOT=record` re-records against the live API. +Keyless unit tests drive a scripted fake runtime subprocess (`tests/fake-runtime.ts`, protocol-only, env-scripted) over real stdio: activity collection, session-tree scoping, timeout/death/malformed-response surfaces, and the dispose ladder. The [SDK snapshot suite](../../../examples/jsonrpc-agent/tests/sdk.snapshot.ts) drives the real `dsh-jsonrpc-agent` runtime through this client keylessly via `llm-replay`, pinning the notification stream, run result, and persisted logs; `DSH_SNAPSHOT=record` re-records against the live API. ## Model Experience @@ -47,5 +49,5 @@ None; this package neither assembles nor sends a provider request. - **No bundled-runtime resolution** — callers name the runtime executable explicitly; packaged-executable discovery stays Python-side until a TypeScript distribution consumer exists. - **No mid-turn cancel** — the wire has no prompt-cancel method; abandoning a turn means closing the runtime (see the protocol's [Known Limitations](../sdk-protocol/README.md)). -- **One in-flight prompt per session** — a server-side rule this client surfaces as a `JsonRpcResponseError`; independent sessions run concurrently on one runtime. +- **No per-prompt result or cancel** — low-level `prompt()` returns only an enqueue receipt; high-level `run()` owns receipt-to-idle collection, and abandoning it means closing the runtime. - **Client→server notifications and server→client requests are unimplemented** on both wire ends; the transport carries them for future approval flows. diff --git a/packages/sdk/sdk-client/README.zh.md b/packages/sdk/sdk-client/README.zh.md index 95fb6b2887..fbf638c916 100644 --- a/packages/sdk/sdk-client/README.zh.md +++ b/packages/sdk/sdk-client/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -以子进程方式驱动 DeepSeek Harness 运行时、走 stdio JSON-RPC 的 TypeScript 客户端 SDK——[Python SDK](../../../python/README.md)(`deepseek-harness`)的设计孪生,共享同一个运行时对端、协议与分层:`DeepSeekHarness` 是高层轮次 API,`HarnessClient` 是低层协议客户端。包(package)根枚举消费方接口:两层客户端、面向调用方的类型和 `JsonRpcResponseError`;源模块、规范化辅助函数与订阅投递机制不供消费方导入。纯库:不在任何 Cordis 上下文注册;它所 spawn 的运行时进程是一个完整 harness,其组成由自己的 `cordis.yml` 决定。 +以子进程方式驱动 DeepSeek Harness 运行时、走 stdio JSON-RPC 的 TypeScript 客户端 SDK——[Python SDK](../../../python/README.md)(`deepseek-harness`)的设计孪生,共享同一个运行时对端、协议与分层:`DeepSeekHarness` 是高层自有运行 API,`HarnessClient` 是低层协议客户端。包(package)根枚举消费方接口:两层客户端、面向调用方的类型和 `JsonRpcResponseError`;源模块、规范化辅助函数与订阅投递机制不供消费方导入。纯库:不在任何 Cordis 上下文注册;它所 spawn 的运行时进程是一个完整 harness,其组成由自己的 `cordis.yml` 决定。 与 Python SDK 不同,启动规格完全显式(`command`/`args`):本包面向仓库近旁的 TypeScript 消费方——[`dsh-subagent-dsh-sdk`](../../subagent/subagent-dsh-sdk/README.md) 后端、测试、自动化——它们知道自己要启动哪个运行时。捆绑运行时解析(寻找打包可执行文件)仍归 Python 发行版负责。 @@ -18,14 +18,16 @@ await using harness = new DeepSeekHarness({ maxTokens: 49_152, }) const result = await harness.run('say hi') -console.log(result.status, result.finalResponse) +console.log(result.finalResponse) ``` -子进程在首次使用时惰性启动,并在多次 `run()` 之间持续归实例所有;必须 `close()`(或 `await using`),子进程才总能被回收。`start()` 记忆化 `initialize` 握手(工作区 cwd——在通过协议传输之前解析为绝对路径——加 provider/model 路由和可选的正整数 `maxTokens` 输出上限);握手失败会回收运行时并换入全新客户端,后续调用用新子进程重试(直到终结性的 `close()`)。该上限作用于根 agent(智能体)的每次请求,并由进程内后代继承;压缩(compaction)插件单独持有摘要上限。`session(id?)` 打开具名或全新的会话句柄;`run(input, { sessionId?, onNotification? })` 发送一个提示词轮次,在配对的 `session.finished` 到达时完成,并返回 `TurnResult`:`status`(按部署映射的 `ok`/`error`)、结构化 `reason`(`TurnEndReason`)、`finalResponse`(最后一条助手消息文本)、根会话的 `events`,以及该会话和通过 `subagent.started` 发现的后代的原始 `notifications`,均按协议传输顺序排列。模型层失败会返回 `status: 'error'` 的结果,绝不会导致 Promise 被拒绝;Promise 被拒绝意味着传输丢失、超时或协议违例。 +子进程在首次使用时惰性启动,并在多次 `run()` 之间持续归实例所有;必须 `close()`(或 `await using`),子进程才总能被回收。`start()` 记忆化 `initialize` 握手(工作区 cwd——在通过协议传输之前解析为绝对路径——加 provider/model 路由和可选的正整数 `maxTokens` 输出上限);握手失败会回收运行时并换入全新客户端,后续调用用新子进程重试(直到终结性的 `close()`)。该上限作用于根 agent(智能体)的每次请求,并由进程内后代继承;压缩(compaction)插件单独持有摘要上限。`session(id?)` 打开具名或全新的会话句柄。 + +`run(input, { sessionId?, onNotification? })` 拥有一个活动区间:它将提示词排入队列,等待其 `MessageId` 出现在持久的 `agent/inbox/spliced` 回执中,然后持续收集到整个 agent 下一次进入 `idle`。它返回 `RunResult { sessionId, finalResponse, events, notifications }`。`finalResponse` 是该区间内根会话最后提交的助手文本,并非因果上归属于该提示词的响应;steering(中途引导)、注入的上下文和其他排队工作都可能在 idle 前参与其中。`events` 包含根会话事件,`notifications` 还包含通过 `subagent.started` 发现的后代,均按协议传输顺序排列。结果不携带提示词级状态或轮次原因。传输丢失、超时和协议违例会导致 Promise 被拒绝;模型结果仍可在事件流中观察,但不会归属于某一输入。 ## HarnessClient -轮次 API 之下的协议客户端:显式 `start()`/`initialize()`/`prompt()`/`request()`/`close()`,外加通知订阅。`subscribe(filter?)` 返回 `NotificationSubscription`(可等待的 `next()`、非阻塞 `tryNext()`、异步迭代);`subscribeSessionTree(id)` 把范围限定到一个会话及从 `subagent.started` 血缘边发现的后代——运行时对上下文内每个会话都发通知,范围限定在客户端完成,与 Python SDK 完全一致。本包导出有明确类型的错误:`JsonRpcResponseError`(协议错误响应,保留 code/data)、`RequestTimeoutError`(配置的时限已到;协议层没有取消机制,请求在服务端继续运行直到 close)、`SdkProtocolError`(响应超出文档化协议)、`TransportClosedError`(运行时已消失——消息携带退出码与有界 stderr 尾部)。 +自有运行 API 之下的协议客户端:显式 `start()`/`initialize()`/`prompt()`/`request()`/`close()`,外加通知订阅。`prompt()` 在运行时接受排队消息后立即返回该消息的 ID,绝不等待 agent 活动。`subscribe(filter?)` 返回 `NotificationSubscription`(可等待的 `next()`、非阻塞 `tryNext()`、异步迭代);`subscribeSessionTree(id)` 把范围限定到一个会话及从 `subagent.started` 血缘边发现的后代——运行时对上下文内每个会话都发通知,范围限定在客户端完成,与 Python SDK 完全一致。本包导出有明确类型的错误:`JsonRpcResponseError`(协议错误响应,保留 code/data)、`RequestTimeoutError`(配置的时限已到)、`SdkProtocolError`(响应超出文档化协议)、`TransportClosedError`(运行时已消失——消息携带退出码与有界 stderr 尾部)。 `close()` 先请求协议 `shutdown`(受 `shutdownTimeoutMs` 约束,默认 1000 毫秒),然后走 stdin-EOF → SIGTERM → SIGKILL 阶梯(`disposeEofGraceMs` 默认 6000,`disposeGraceMs` 默认 3000)直到进程真正退出。该阶梯为本客户端私有:它运行在任何 harness 上下文之外,无法搭乘 [`dsh-subprocess`](../../subprocess/README.md) 服务——即该 seam 所记录的 SDK 托管传输例外。幂等,已关闭的客户端拒绝复用。 @@ -33,7 +35,7 @@ console.log(result.status, result.finalResponse) ## 测试 -免密钥单元测试通过真实 stdio 驱动一个脚本化伪运行时子进程(`tests/fake-runtime.ts`,纯协议、环境变量脚本化):轮次循环、会话树范围限定、超时、进程死亡和响应畸形场景,以及 dispose(资源释放)阶梯。[SDK 快照套件](../../../examples/jsonrpc-agent/tests/sdk.snapshot.ts) 经由 `llm-replay` 免密钥地通过本客户端驱动真实 `dsh-jsonrpc-agent` 运行时,固定通知流、轮次结果与持久化日志;`DSH_SNAPSHOT=record` 对真实 API 重录。 +免密钥单元测试通过真实 stdio 驱动一个脚本化伪运行时子进程(`tests/fake-runtime.ts`,纯协议、环境变量脚本化):活动收集、会话树范围限定、超时、进程死亡和响应畸形场景,以及 dispose(资源释放)阶梯。[SDK 快照套件](../../../examples/jsonrpc-agent/tests/sdk.snapshot.ts) 经由 `llm-replay` 免密钥地通过本客户端驱动真实 `dsh-jsonrpc-agent` 运行时,固定通知流、运行结果与持久化日志;`DSH_SNAPSHOT=record` 对真实 API 重录。 ## 模型体验 @@ -47,5 +49,5 @@ console.log(result.status, result.finalResponse) - **无捆绑运行时解析**——调用方显式指定运行时可执行文件;打包可执行文件的发现留在 Python 侧,直到出现 TypeScript 发行版消费方。 - **无轮次中取消**——协议层没有提示词取消方法;放弃轮次意味着关闭运行时(见协议的 [已知限制](../sdk-protocol/README.md))。 -- **每会话同时只有一个在途提示词**——服务端规则,本客户端将其呈现为 `JsonRpcResponseError`;相互独立的会话可在同一运行时上并发。 +- **没有逐提示词结果或取消**——低层 `prompt()` 只返回入队回执;高层 `run()` 负责从回执收集到 idle,放弃该过程意味着关闭运行时。 - **客户端→服务端通知与服务端→客户端请求**在协议两端都未实现;传输层为未来审批流保留了承载能力。 diff --git a/packages/sdk/sdk-client/src/api.ts b/packages/sdk/sdk-client/src/api.ts index b5cfd12c6b..2c27251295 100644 --- a/packages/sdk/sdk-client/src/api.ts +++ b/packages/sdk/sdk-client/src/api.ts @@ -1,7 +1,7 @@ /** - * High-level turns API over {@link HarnessClient}: `DeepSeekHarness` owns one + * High-level run API over {@link HarnessClient}: `DeepSeekHarness` owns one * runtime subprocess across many sessions; `HarnessSession.run` sends a - * prompt and settles with the final response once `session.finished` arrives. + * prompt and settles when the whole agent next becomes idle. * Mirrors the Python SDK's `DeepSeekHarness`/`Session` pair. * * @module @deepseek-ai/dsh-sdk-client/api @@ -9,9 +9,9 @@ import { randomUUID } from 'node:crypto' import { resolve } from 'node:path' -import type { SessionEvent, TurnEndReason } from '@deepseek-ai/dsh-session' +import type { SessionEvent } from '@deepseek-ai/dsh-session' import { HarnessClient, isRecord, SdkProtocolError } from './client.ts' -import type { ContentBlock, DeepSeekHarnessOptions, HarnessClientOptions, HarnessNotification, TurnResult } from './types.ts' +import type { ContentBlock, DeepSeekHarnessOptions, HarnessClientOptions, HarnessNotification, RunResult } from './types.ts' /** * Reusable SDK for running DeepSeek Harness agent turns in a runtime @@ -93,9 +93,9 @@ export class DeepSeekHarness implements AsyncDisposable { * Run one prompt on a fresh (or named) session. * @param input - prompt text, or content blocks sent verbatim. * @param options - optional session id and per-notification observer. - * @returns the settled turn result. + * @returns the owned activity interval. */ - run(input: string | ContentBlock[], options?: RunOptions): Promise { + run(input: string | ContentBlock[], options?: RunOptions): Promise { return this.session(options?.sessionId).run(input, options) } @@ -127,8 +127,7 @@ export interface RunOptions { } /** - * One SDK session: a stable id plus the turn loop that pairs a - * `session/prompt` with its `session.finished`. + * One SDK session: a stable id plus owned activity intervals. */ export class HarnessSession { /** @@ -138,27 +137,23 @@ export class HarnessSession { constructor(readonly harness: DeepSeekHarness, readonly id: string) {} /** - * Run one prompt turn to settlement. + * Queue one prompt, then observe the whole session through its next idle. * @param input - prompt text, or content blocks sent verbatim. * @param options - optional per-notification observer. - * @returns the settled turn result; rejects on transport loss, timeout, or - * a protocol error — never on a model-level failure (that is - * `status: 'error'` in the result). + * @returns the owned activity interval; rejects on transport loss, timeout, + * or a protocol error. */ - async run(input: string | ContentBlock[], options?: Pick): Promise { + async run(input: string | ContentBlock[], options?: Pick): Promise { await this.harness.start() const client = this.harness.client const contentBlocks = normalizeInput(input) const events: SessionEvent[] = [] const notifications: HarnessNotification[] = [] - let status: TurnResult['status'] = 'error' - let reason: TurnEndReason | undefined - let finished = false const subscription = client.subscribeSessionTree(this.id) const collect = (notification: HarnessNotification): void => { if (notification.method === 'session.event' && notification.params.sessionId === this.id) { - // Wire boundary: the envelope feeds the typed TurnResult, so a + // Wire boundary: the envelope feeds the typed RunResult, so a // malformed runtime surfaces as a protocol error, not as type-invalid // data (or a TypeError out of finalResponse). const event = validatedSessionEvent(notification.params.event) @@ -167,37 +162,31 @@ export class HarnessSession { events.push(event) return } - if (notification.method === 'session.finished' && notification.params.sessionId === this.id) { - reason = validatedTurnEndReason(notification.params.reason) - notifications.push(notification) - options?.onNotification?.(notification) - status = notification.params.status === 'ok' ? 'ok' : 'error' - finished = true - return - } notifications.push(notification) options?.onNotification?.(notification) } - const accepted = client.prompt(this.id, contentBlocks) - // Drain concurrently so observers see progress while the prompt request - // is still pending (its response arrives only after settlement). - const drain = (async () => { - while (!finished) collect(await subscription.next()) - })() try { - await Promise.all([accepted, drain]) + const messageId = await client.prompt(this.id, contentBlocks) + let received = false + while (true) { + const notification = await subscription.next() + if (!received) { + if (notification.method !== 'session.event' + || notification.params.sessionId !== this.id + || !isInboxReceipt(notification.params.event, messageId)) continue + received = true + } + collect(notification) + if (notification.method === 'session.status' + && notification.params.sessionId === this.id + && notification.params.status === 'idle') break + } } finally { - // On a prompt rejection the drain is still parked on next(); closing the - // subscription settles it, and the swallow keeps that secondary - // TransportClosedError from surfacing as an unhandled rejection. subscription.close() - await drain.catch(() => {}) } return { sessionId: this.id, - status, - reason, finalResponse: finalResponse(events), events, notifications, @@ -232,18 +221,16 @@ function validatedSessionEvent(value: unknown): SessionEvent { return value as unknown as SessionEvent } -/** Validate a wire `session.finished` reason (absent, or a kind-tagged record). */ -function validatedTurnEndReason(value: unknown): TurnEndReason | undefined { - if (value === undefined) return undefined - if (!isRecord(value) || typeof value.kind !== 'string') { - throw new SdkProtocolError(`session.finished carried a malformed reason: ${JSON.stringify(value)}`) - } - return value as unknown as TurnEndReason +/** Whether a raw session event is the durable enqueue receipt for `messageId`. */ +function isInboxReceipt(value: unknown, messageId: string): boolean { + if (!isRecord(value) || value.type !== 'agent/inbox/spliced' || !isRecord(value.data)) return false + const inserted = value.data.inserted + return Array.isArray(inserted) && inserted.some(message => isRecord(message) && message.id === messageId) } /** * Extract the concatenated text of the last assistant message. - * @param events - the turn's `session.event` payloads in wire order. + * @param events - the activity interval's `session.event` payloads in wire order. * @returns the final response text, or `''` when no assistant message exists. */ export function finalResponse(events: SessionEvent[]): string { diff --git a/packages/sdk/sdk-client/src/client.ts b/packages/sdk/sdk-client/src/client.ts index af19f26868..1937f4a1dc 100644 --- a/packages/sdk/sdk-client/src/client.ts +++ b/packages/sdk/sdk-client/src/client.ts @@ -275,17 +275,18 @@ export class HarnessClient { } /** - * Run one prompt turn to settlement (the response arrives only after the - * turn settled; progress streams as notifications meanwhile). + * Queue one prompt and return its durable inbox identity. * @param sessionId - target session; an unknown id creates it. * @param contentBlocks - the user message, sent verbatim. + * @returns the queued message id. */ - async prompt(sessionId: string, contentBlocks: ContentBlock[]): Promise { + async prompt(sessionId: string, contentBlocks: ContentBlock[]): Promise { const params: SessionPromptParams = { sessionId, contentBlocks } const result = await this.request('session/prompt', { ...params }) - if (!isRecord(result) || result.accepted !== true) { - throw new SdkProtocolError(`session/prompt was not accepted: ${JSON.stringify(result)}`) + if (!isRecord(result) || typeof result.messageId !== 'string') { + throw new SdkProtocolError(`session/prompt returned no message id: ${JSON.stringify(result)}`) } + return result.messageId } /** diff --git a/packages/sdk/sdk-client/src/index.ts b/packages/sdk/sdk-client/src/index.ts index 5fd1297a7f..128cfbb898 100644 --- a/packages/sdk/sdk-client/src/index.ts +++ b/packages/sdk/sdk-client/src/index.ts @@ -1,7 +1,7 @@ /** * TypeScript client SDK for the DeepSeek Harness runtime: spawn the * `dsh-jsonrpc-agent` runtime as a subprocess and drive agent turns over - * stdio JSON-RPC. `DeepSeekHarness` is the high-level turns API; + * stdio JSON-RPC. `DeepSeekHarness` is the high-level run API; * `HarnessClient` is the lower-level protocol client. A pure library — it * registers nothing on a Cordis context; the runtime process it spawns is a * complete harness configured by its own `cordis.yml`. @@ -25,5 +25,5 @@ export type { HarnessClientOptions, HarnessNotification, NotificationFilter, - TurnResult, + RunResult, } from './types.ts' diff --git a/packages/sdk/sdk-client/src/types.ts b/packages/sdk/sdk-client/src/types.ts index ad4998ca13..05300d6e36 100644 --- a/packages/sdk/sdk-client/src/types.ts +++ b/packages/sdk/sdk-client/src/types.ts @@ -1,17 +1,16 @@ /** * Types for the TypeScript SDK client: launch options, notification shapes, - * and turn results. + * and owned activity results. * * @module @deepseek-ai/dsh-sdk-client/types */ import type { ContentBlock } from '@deepseek-ai/dsh-llm' -import type { SessionEvent, TurnEndReason } from '@deepseek-ai/dsh-session' -import type { SdkRunStatus } from '@deepseek-ai/dsh-sdk-protocol' +import type { SessionEvent } from '@deepseek-ai/dsh-session' /** One server-to-client notification as received off the wire. */ export interface HarnessNotification { - /** The JSON-RPC method name (`session.event`, `session.finished`, `subagent.started`, `subagent.finished`). */ + /** The JSON-RPC notification method name. */ method: string /** The raw params object; see `HarnessSdkNotificationMap` for the shapes per method. */ params: Record @@ -59,15 +58,11 @@ export interface DeepSeekHarnessOptions { maxTokens?: number } -/** The settled outcome of one {@link HarnessSession.run} turn. */ -export interface TurnResult { - /** The session the turn ran on. */ +/** One owned session activity interval, from enqueue receipt through idle. */ +export interface RunResult { + /** The session the activity ran on. */ sessionId: string - /** Deployment-mapped turn outcome from `session.finished`. */ - status: SdkRunStatus - /** Why the last message-triggered turn ended; `undefined` when no turn ran. */ - reason: TurnEndReason | undefined - /** Concatenated text of the session's last assistant message (empty when none). */ + /** Concatenated text of the interval's last assistant message (empty when none). */ finalResponse: string /** Every `session.event` payload for the root session, in wire order. */ events: SessionEvent[] diff --git a/packages/sdk/sdk-client/tests/fake-runtime.ts b/packages/sdk/sdk-client/tests/fake-runtime.ts index 626ca87bc6..85d5253765 100644 --- a/packages/sdk/sdk-client/tests/fake-runtime.ts +++ b/packages/sdk/sdk-client/tests/fake-runtime.ts @@ -142,13 +142,6 @@ function runTurn(sessionId: string): void { lastAssistantMessage: [{ type: 'text', text: 'child says hi' }], }) } - notify('session.finished', { - sessionId, - status: env.FAKE_STATUS ?? 'ok', - ...(env.FAKE_MALFORMED_REASON !== undefined - ? { reason: 'not-a-record' } - : reasonKind === 'none' ? {} : { reason: { kind: reasonKind } }), - }) } function sessionIdOf(params: Record | undefined): string { @@ -197,8 +190,20 @@ reader.on('line', (line) => { respond({ serverInfo: { name: 'deepseek-harness-sdk-runtime', version: '0.0.1' } }) return case 'session/prompt': { + const sessionId = sessionIdOf(frame.params) + const messageId = `fake-user-${seq}` + event(sessionId, 'agent/inbox/spliced', { + target: 'next-turn', + start: 0, + inserted: [{ + id: messageId, + role: 'user', + content: [], + source: { kind: 'user' }, + }], + }) + notify('session.status', { sessionId, status: 'running' }) if (env.FAKE_STREAM_THEN_MALFORMED !== undefined) { - const sessionId = sessionIdOf(frame.params) event(sessionId, 'assistant/chunk', { turn: 0, step: 0, chunk: { type: 'text-delta', index: 0, text: 'streamed then cut short' } }) respond({}) return @@ -208,9 +213,9 @@ reader.on('line', (line) => { respond({}) return } - const sessionId = sessionIdOf(frame.params) runTurn(sessionId) - respond({ accepted: true }) + notify('session.status', { sessionId, status: 'idle' }) + respond({ messageId }) return } case 'shutdown': diff --git a/packages/sdk/sdk-client/tests/sdk-client.spec.ts b/packages/sdk/sdk-client/tests/sdk-client.spec.ts index 63093e602b..e31490a6cd 100644 --- a/packages/sdk/sdk-client/tests/sdk-client.spec.ts +++ b/packages/sdk/sdk-client/tests/sdk-client.spec.ts @@ -56,14 +56,13 @@ describe('DeepSeekHarness', () => { it('runs a turn end to end and reuses the runtime across sessions', async () => { const harness = harnessWith({ FAKE_TEXT: 'turn answer' }) const first = await harness.run('say hi') - expect(first.status).toBe('ok') - expect(first.reason).toEqual({ kind: 'completed' }) expect(first.finalResponse).toBe('turn answer') - expect(first.events.map(event => event.type)).toEqual(['turn/start', 'assistant/chunk', 'assistant/message', 'turn/end']) + expect(first.events.map(event => event.type)).toEqual([ + 'agent/inbox/spliced', 'turn/start', 'assistant/chunk', 'assistant/message', 'turn/end', + ]) // Same subprocess, second session: ids differ, protocol state is reusable. const second = await harness.run([{ type: 'text', text: 'again' }]) - expect(second.status).toBe('ok') expect(second.sessionId).not.toBe(first.sessionId) await harness.close() }) @@ -76,13 +75,12 @@ describe('DeepSeekHarness', () => { onNotification: (n) => { seen.push(n) }, }) - expect(result.status).toBe('ok') // The child session's events arrive through subagent.started lineage. expect(seen.map(n => n.method)).toContain('subagent.started') expect(seen.map(n => n.method)).toContain('subagent.finished') const childEvents = seen.filter(n => n.method === 'session.event' && n.params.sessionId === 'parent-1-child') expect(childEvents.length).toBeGreaterThan(0) - // TurnResult.events is the root session's typed stream; descendants retain + // RunResult.events is the root session's typed stream; descendants retain // their session ids in the raw notification stream above. expect(result.events.every(event => event.type !== 'assistant/message' || event.data.message.content[0]?.type !== 'text' @@ -90,22 +88,6 @@ describe('DeepSeekHarness', () => { await harness.close() }) - it('reports an error status with the turn-end reason', async () => { - const harness = harnessWith({ FAKE_STATUS: 'error', FAKE_REASON_KIND: 'max-tokens' }) - const result = await harness.run('overflow') - expect(result.status).toBe('error') - expect(result.reason).toEqual({ kind: 'max-tokens' }) - await harness.close() - }) - - it('omits the reason when the runtime settled without one', async () => { - const harness = harnessWith({ FAKE_STATUS: 'error', FAKE_REASON_KIND: 'none' }) - const result = await harness.run('no turn') - expect(result.status).toBe('error') - expect(result.reason).toBeUndefined() - await harness.close() - }) - it('sends the configured cwd/provider/model/maxTokens in the handshake exactly once', async () => { const dir = await tempDir('sdk-client-init-') const recordFile = join(dir, 'init.jsonl') @@ -311,14 +293,14 @@ describe('HarnessClient', () => { await client.initialize({ cwd: process.cwd(), provider: 'p', model: 'm' }) const all = client.subscribe() - const finishedOnly = client.subscribe(n => n.method === 'session.finished') + const idleOnly = client.subscribe(n => n.method === 'session.status' && n.params.status === 'idle') await client.prompt('sub-test', normalizeInput('go')) const first = await all.next() expect(first.method).toBe('session.event') - const finished = await finishedOnly.next() - expect(finished.method).toBe('session.finished') - expect(finishedOnly.tryNext()).toBeUndefined() + const idle = await idleOnly.next() + expect(idle.method).toBe('session.status') + expect(idleOnly.tryNext()).toBeUndefined() // A bare unbounded request with omitted params sends `{}` on the wire. const identity = await client.request('initialize') as { serverInfo: { name: string } } @@ -328,12 +310,12 @@ describe('HarnessClient', () => { const collected: string[] = [] for await (const notification of all) { collected.push(notification.method) - if (notification.method === 'session.finished') break + if (notification.method === 'session.status' && notification.params.status === 'idle') break } - expect(collected.at(-1)).toBe('session.finished') + expect(collected.at(-1)).toBe('session.status') all.close() - finishedOnly.close() + idleOnly.close() await expect(all.next()).rejects.toThrow('notification subscription closed') await client.close() }) @@ -346,11 +328,11 @@ describe('HarnessClient', () => { const broken = client.subscribe(() => { throw new Error('filter exploded') }) // A non-Error throw is normalized rather than crashing dispatch. const brokenNonError = client.subscribe(() => { throw 'string boom' }) - const healthy = client.subscribe(n => n.method === 'session.finished') + const healthy = client.subscribe(n => n.method === 'session.status' && n.params.status === 'idle') await client.prompt('filter-contain', normalizeInput('go')) // The sibling subscription and the read loop are undisturbed. - expect((await healthy.next()).method).toBe('session.finished') + expect((await healthy.next()).method).toBe('session.status') // Each broken subscription failed with ITS OWN error and detached. await expect(broken.next()).rejects.toThrow('filter exploded') await expect(brokenNonError.next()).rejects.toThrow('string boom') @@ -445,10 +427,6 @@ describe('wire payload validation', () => { await expect(harness.run('no-data')).rejects.toThrow(SdkProtocolError) }) - it('rejects a malformed session.finished reason as a protocol error', async () => { - const harness = harnessWith({ FAKE_MALFORMED_REASON: '1' }) - await expect(harness.run('bad-reason')).rejects.toThrow(SdkProtocolError) - }) }) describe('stderr tail bound', () => { diff --git a/packages/sdk/sdk-protocol/README.i18n.yaml b/packages/sdk/sdk-protocol/README.i18n.yaml index 7eec4f64dd..a8ab928a9d 100644 --- a/packages/sdk/sdk-protocol/README.i18n.yaml +++ b/packages/sdk/sdk-protocol/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/sdk/sdk-protocol/README.md -README.md: 62b26d4a82d358fa4efcb7ab84036e5f4848057f -README.zh.md: 11677c6119c7da407d95ee38ad9f8f7a552c15de +README.md: 2120e6090fcc5d5f4a543424e9c5647e6009bad6 +README.zh.md: 70b046cf1dedbf01031e3e0a4441f522d6153fbc diff --git a/packages/sdk/sdk-protocol/README.md b/packages/sdk/sdk-protocol/README.md index 62b26d4a82..2120e6090f 100644 --- a/packages/sdk/sdk-protocol/README.md +++ b/packages/sdk/sdk-protocol/README.md @@ -15,14 +15,14 @@ The shared wire protocol for the DeepSeek Harness SDK runtime: one newline-delim | Direction | Method | Types | |---|---|---| | client→server | `initialize` | `InitializeParams` → `InitializeResult` | -| client→server | `session/prompt` | `SessionPromptParams` → `SessionPromptResult` (answered only after turn settlement) | +| client→server | `session/prompt` | `SessionPromptParams` → `SessionPromptResult` (durable enqueue receipt) | | client→server | `shutdown` | no params → `{}` | | server→client | `session.event` | `SessionEventNotification` (every session in the runtime, unfiltered) | -| server→client | `session.finished` | `SessionFinishedNotification` (one per accepted prompt) | +| server→client | `session.status` | `SessionStatusNotification` (whole-agent `running`/`idle` transition) | | server→client | `subagent.started` | `SubagentStartedNotification` | | server→client | `subagent.finished` | `SubagentFinishedNotification` (in-process runs only) | -`HarnessSdkRequestMap` and `HarnessSdkNotificationMap` index these by method name. `InitializeParams.maxTokens` is an optional positive safe integer that caps each conversation-model output for SDK-created agents and their in-process descendants; omission leaves the provider default in control. 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. `InitializeParams.maxTokens` is an optional positive safe integer that caps each conversation-model output for SDK-created agents and their in-process descendants; omission leaves the provider default in control. 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 diff --git a/packages/sdk/sdk-protocol/README.zh.md b/packages/sdk/sdk-protocol/README.zh.md index 11677c6119..70b046cf1d 100644 --- a/packages/sdk/sdk-protocol/README.zh.md +++ b/packages/sdk/sdk-protocol/README.zh.md @@ -15,14 +15,14 @@ DeepSeek Harness SDK 运行时的共享协议格式(wire format):一个按 | 方向 | 方法 | 类型 | |---|---|---| | client→server | `initialize` | `InitializeParams` → `InitializeResult` | -| client→server | `session/prompt` | `SessionPromptParams` → `SessionPromptResult`(仅在轮次结算完成后应答) | +| client→server | `session/prompt` | `SessionPromptParams` → `SessionPromptResult`(持久入队回执) | | client→server | `shutdown` | 无参数 → `{}` | | server→client | `session.event` | `SessionEventNotification`(运行时内每个会话,不过滤) | -| server→client | `session.finished` | `SessionFinishedNotification`(每个获准的提示词请求一条) | +| server→client | `session.status` | `SessionStatusNotification`(整个 agent(智能体)的 `running`/`idle` 转换) | | server→client | `subagent.started` | `SubagentStartedNotification` | | server→client | `subagent.finished` | `SubagentFinishedNotification`(仅进程内运行) | -`HarnessSdkRequestMap` 与 `HarnessSdkNotificationMap` 按方法名索引这些类型。`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`。`InitializeParams.maxTokens` 是可选的正的安全整数,用于限制 SDK 创建的 agent 及其进程内后代的每次对话模型输出;省略时由提供方默认值控制。通知载荷类型依赖 `SessionEvent`(`dsh-session`)、`ContentBlock`(`dsh-llm`)与 `SubagentStopReason`(`dsh-subagent`)——协议以完整会话日志封套进行流式传输,因此会话词汇是协议格式契约的一部分。`serverInfo.name` 的协议值固定为 `deepseek-harness-sdk-runtime`。 ## 模型体验 diff --git a/packages/sdk/sdk-protocol/src/index.ts b/packages/sdk/sdk-protocol/src/index.ts index c11a270f47..777290c17c 100644 --- a/packages/sdk/sdk-protocol/src/index.ts +++ b/packages/sdk/sdk-protocol/src/index.ts @@ -17,7 +17,7 @@ export type { InitializeResult, SdkRunStatus, SessionEventNotification, - SessionFinishedNotification, + SessionStatusNotification, SessionPromptParams, SessionPromptResult, SubagentFinishedNotification, diff --git a/packages/sdk/sdk-protocol/src/types.ts b/packages/sdk/sdk-protocol/src/types.ts index 1b7a372f66..dc8e11587f 100644 --- a/packages/sdk/sdk-protocol/src/types.ts +++ b/packages/sdk/sdk-protocol/src/types.ts @@ -9,7 +9,7 @@ */ import type { ContentBlock } from '@deepseek-ai/dsh-llm' -import type { SessionEvent, TurnEndReason } from '@deepseek-ai/dsh-session' +import type { SessionEvent } from '@deepseek-ai/dsh-session' import type { SubagentStopReason } from '@deepseek-ai/dsh-subagent' /** Parameters for the process-wide SDK handshake. */ @@ -38,10 +38,10 @@ export interface SessionPromptParams { contentBlocks: ContentBlock[] } -/** Prompt acceptance after turn settlement; outcome rides on `session.finished`. */ +/** Durable enqueue receipt for one prompt. */ export interface SessionPromptResult { - /** Always `true`; the turn outcome is the paired `session.finished` notification. */ - accepted: true + /** Identity of the queued user message. */ + messageId: string } /** Deployment-mapped SDK outcome: `ok` for an accepted result, `error` otherwise. */ @@ -55,14 +55,12 @@ export interface SessionEventNotification { event: SessionEvent } -/** `session.finished` payload: one per accepted prompt, after turn settlement. */ -export interface SessionFinishedNotification { - /** The settled session. */ +/** Whole-agent lifecycle state for one session. */ +export interface SessionStatusNotification { + /** Session whose live agent changed status. */ sessionId: string - /** Deployment-mapped turn outcome (see `maxTokensAsSuccess` on the server). */ - status: SdkRunStatus - /** Why the last message-triggered turn ended; absent when no turn ran. */ - reason: TurnEndReason | undefined + /** The whole-agent state after the transition. */ + status: 'idle' | 'running' } /** `subagent.started` payload: an in-runtime child session was created. */ @@ -94,7 +92,7 @@ export interface SubagentFinishedNotification { /** Server-to-client notifications by JSON-RPC method name. */ export interface HarnessSdkNotificationMap { 'session.event': SessionEventNotification - 'session.finished': SessionFinishedNotification + 'session.status': SessionStatusNotification 'subagent.started': SubagentStartedNotification 'subagent.finished': SubagentFinishedNotification } diff --git a/packages/sdk/sdk-protocol/tests/transport.spec.ts b/packages/sdk/sdk-protocol/tests/transport.spec.ts index a07324a6fc..c14111be22 100644 --- a/packages/sdk/sdk-protocol/tests/transport.spec.ts +++ b/packages/sdk/sdk-protocol/tests/transport.spec.ts @@ -29,11 +29,11 @@ describe('JsonRpcLineTransport', () => { const response = await b.request('echo', { value: 42 }) expect(response).toEqual({ echoed: { value: 42 } }) - a.notify('session.finished', { sessionId: 'main', status: 'ok' }) + a.notify('session.status', { sessionId: 'main', status: 'idle' }) a.notify('heartbeat') await new Promise(resolve => setTimeout(resolve, 10)) expect(notifications).toEqual([ - { method: 'session.finished', params: { sessionId: 'main', status: 'ok' } }, + { method: 'session.status', params: { sessionId: 'main', status: 'idle' } }, { method: 'heartbeat', params: {} }, ]) diff --git a/packages/subagent/subagent-dsh-sdk/README.i18n.yaml b/packages/subagent/subagent-dsh-sdk/README.i18n.yaml index 296617f484..b1bd3ce8e8 100644 --- a/packages/subagent/subagent-dsh-sdk/README.i18n.yaml +++ b/packages/subagent/subagent-dsh-sdk/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subagent/subagent-dsh-sdk/README.md -README.md: e904ce3c09a1b44f8f5a0072b9ca85812898e74f -README.zh.md: cec89ad9a65c68163fc136fe7b04cb135c57fb51 +README.md: 834c967354610e9ecbb76380f8ddbce988b51c8c +README.zh.md: 4af03a6647da38adf8283d567b37a10203ad7c90 diff --git a/packages/subagent/subagent-dsh-sdk/README.md b/packages/subagent/subagent-dsh-sdk/README.md index e904ce3c09..834c967354 100644 --- a/packages/subagent/subagent-dsh-sdk/README.md +++ b/packages/subagent/subagent-dsh-sdk/README.md @@ -10,13 +10,13 @@ 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 runs one SDK turn and reads the child's answer from its session events: the last complete `assistant/message`, or the `text-delta` stream accumulated so far when the turn 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 `assistant/message`, or the `text-delta` stream accumulated before the activity was cut short — a partial answer survives cancel and error paths. `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. ## Stop-reason mapping -The child reports its turn outcome as a structured `TurnEndReason` on `session.finished`; the provider maps it into the seam vocabulary. `completed` → `completed`, `max-tokens` → `max-tokens`, `aborted` → `aborted`; everything else — `error`, `interrupted`, `disposed`, a future variant, or a turn that never ran — maps to `error`, so an unclean stop is never reported as success. Transport-level failures after publication flatten to `stopReason: 'error'` through the `onError` diagnostic sink (wired to `ctx.logger.warn`); the seam contract forbids `result` rejecting. +The SDK client returns an owned child activity rather than a prompt result. The provider reads the last durable `turn/end` inside that activity and maps it into the seam vocabulary: `completed` → `completed`, `max-tokens` → `max-tokens`, `aborted` → `aborted`; everything else — `error`, `interrupted`, `disposed`, a future variant, or an activity with no turn — maps to `error`, so an unclean stop is never reported as success. Transport-level failures after publication flatten to `stopReason: 'error'` through the `onError` diagnostic sink (wired to `ctx.logger.warn`); the seam contract forbids `result` rejecting. ## Capabilities and context diff --git a/packages/subagent/subagent-dsh-sdk/README.zh.md b/packages/subagent/subagent-dsh-sdk/README.zh.md index cec89ad9a6..4af03a6647 100644 --- a/packages/subagent/subagent-dsh-sdk/README.zh.md +++ b/packages/subagent/subagent-dsh-sdk/README.zh.md @@ -10,13 +10,13 @@ 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`,或该活动中断前已经累积的 `text-delta` 流;部分答案在取消和错误路径上都得以保留。 `dispose()`(资源释放)是幂等的:先在本地把结果确定为 `aborted`(协议层面没有提示词取消机制),再关闭运行时,即先发出一次有界的协议 `shutdown` 请求,随后通过共享的 stdin-EOF → SIGTERM → SIGKILL 阶梯使进程实际退出。 ## 停止原因映射 -子进程在 `session.finished` 上以结构化 `TurnEndReason` 报告轮次结果;提供方将其映射为 seam 词汇。`completed` → `completed`,`max-tokens` → `max-tokens`,`aborted` → `aborted`;其余情况,包括 `error`、`interrupted`、`disposed`、未来变体或根本未运行轮次,均映射为 `error`,因此非正常停止绝不会报告为成功。发布后的传输层失败会通过 `onError` 诊断接收器(连接到 `ctx.logger.warn`)压平为 `stopReason: 'error'`;seam 契约禁止 `result` 被拒绝。 +SDK 客户端返回自有子活动,而不是提示词结果。提供方读取该活动内最后一个持久 `turn/end`,并将其映射为 seam 词汇:`completed` → `completed`,`max-tokens` → `max-tokens`,`aborted` → `aborted`;其余情况,包括 `error`、`interrupted`、`disposed`、未来变体或不含轮次的活动,均映射为 `error`,因此非正常停止绝不会报告为成功。发布后的传输层失败会通过 `onError` 诊断接收器(连接到 `ctx.logger.warn`)压平为 `stopReason: 'error'`;seam 契约禁止 `result` 被拒绝。 ## 能力与上下文 diff --git a/packages/subagent/subagent-dsh-sdk/src/run.ts b/packages/subagent/subagent-dsh-sdk/src/run.ts index ec88cc1fb1..b8a01ea383 100644 --- a/packages/subagent/subagent-dsh-sdk/src/run.ts +++ b/packages/subagent/subagent-dsh-sdk/src/run.ts @@ -70,8 +70,8 @@ export const DEFAULT_SHUTDOWN_TIMEOUT_MS = 1_000 /** * Map a child turn-end reason to a harness {@link SubagentStopReason}. - * @param reason - the `session.finished` reason, or `undefined` when the - * child settled without running a turn. + * @param reason - the owned child run's final durable turn reason, or + * `undefined` when it settled without running a turn. * @returns the harness equivalent; an absent or unknown reason maps to * `error`, so an unclean stop is never reported as `completed`. */ @@ -191,7 +191,10 @@ export async function startSdkRun(request: SubagentStartRequest, spec: SdkRunSpe cancelSettled.then(() => 'cancelled' as const), ]) if (turn === 'cancelled') return { output: collectOutput(), stopReason: 'aborted' } - return { output: collectOutput(), stopReason: sdkStopReason(turn.reason) } + const lastEnd = turn.events.findLast( + (event): event is Extract => event.type === 'turn/end', + ) + return { output: collectOutput(), stopReason: sdkStopReason(lastEnd?.data.reason) } }, collectOutput, cancelled: () => flags.cancelled, diff --git a/packages/subagent/subagent-inprocess/README.i18n.yaml b/packages/subagent/subagent-inprocess/README.i18n.yaml index b834818199..071ca3241a 100644 --- a/packages/subagent/subagent-inprocess/README.i18n.yaml +++ b/packages/subagent/subagent-inprocess/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subagent/subagent-inprocess/README.md -README.md: 980bc18de088c41dfe2f57a5ff0882a60892fc9f -README.zh.md: 1ceb628371c3ae9cee6d8afa6bc1d95ba4cda8ae +README.md: 67f0cf5dd1ecb18542af56953a0eaa40988aca0d +README.zh.md: 58998ce06cd724d6a1f96f08ba31c5da30007b1b diff --git a/packages/subagent/subagent-inprocess/README.md b/packages/subagent/subagent-inprocess/README.md index 980bc18de0..67f0cf5dd1 100644 --- a/packages/subagent/subagent-inprocess/README.md +++ b/packages/subagent/subagent-inprocess/README.md @@ -14,10 +14,12 @@ 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 latest message-triggered turn reason, excluding any fork seed and later between-turn records. +5. Read the child's own last assistant message and 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. +This result boundary is valid because the provider owns an isolated child lifecycle from publication through quiescence. Steering submitted during that lifecycle belongs to the child run; the provider does not pretend the initial follow-up alone owns its output. + When the optional sandbox-policy or approval service is composed, the driver snapshots the parent's explicit session override before child creation and appends a source-tagged event during unpublished setup, after any fork history and before session publication. It never copies deployment defaults or one-shot grants; later child switches still win. See the [policy-inheritance decision](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md). ## Cancellation and ownership diff --git a/packages/subagent/subagent-inprocess/README.zh.md b/packages/subagent/subagent-inprocess/README.zh.md index 1ceb628371..58998ce06c 100644 --- a/packages/subagent/subagent-inprocess/README.zh.md +++ b/packages/subagent/subagent-inprocess/README.zh.md @@ -14,10 +14,12 @@ 2. 直接调用 `parent.ctx.agents.create`,把必需的请求信号传入工厂的创建事务。 3. 在该事务未发布的设置窗口中,安装请求的 persona、工具限制和结构化输出运行时。 4. 发布子 agent,保留返回的 `AgentHandle`,并通过先调用 `child.followup(prompt)`、再调用 `child.whenIdle()` 来驱动一项任务。 -5. 读取子 agent 自身最后一条 assistant 消息,以及由消息触发的最新轮次原因;排除任何 fork 初始内容和后续轮次间记录。 +5. 从完整的自有子运行中读取子 agent 自身最后一条 assistant 消息和最终持久轮次原因,并排除任何 fork 初始内容。 子 agent 会获得父 agent 的工作目录/会话谱系;除非 `request.agentOptions` 覆盖,否则还会继承父 agent 的提供方、模型和输出 token 上限。它获得全新的扁平注册作用域:父级所有权不会导入父 agent 的工具限制,也不会建立权限子集。 +该结果边界成立,是因为提供方拥有从发布到完全停稳的隔离子 agent 生命周期。在该生命周期内提交的 steering(中途引导)属于子运行;提供方不会声称输出只归初始 follow-up 所有。 + 当组合中挂载了可选的沙箱策略或审批服务时,驱动器会在创建子 agent 前对父级的显式会话覆盖项获取快照,并在未发布的设置阶段追加一条带来源标记的事件,使其位于所有 fork 历史之后、会话发布之前。它绝不复制部署默认值或一次性授权;子 agent 后续的切换仍然优先。参见[策略继承决策](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)。 ## 取消与所有权 diff --git a/packages/ui/jsonrpc/README.i18n.yaml b/packages/ui/jsonrpc/README.i18n.yaml index 324f8c4d99..c6ffdca04f 100644 --- a/packages/ui/jsonrpc/README.i18n.yaml +++ b/packages/ui/jsonrpc/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/ui/jsonrpc/README.md -README.md: b1219ba10269fc7d046da22c280ff1b91424a5ae -README.zh.md: 1c27f5edf2f1f172aa6303697b17e2e77a65842a +README.md: 976c63ee4f00336cd68e30288e569d514e7ee65b +README.zh.md: e7862aaa335c3277863647e0931eb815ef2ccc1a diff --git a/packages/ui/jsonrpc/README.md b/packages/ui/jsonrpc/README.md index b1219ba102..976c63ee4f 100644 --- a/packages/ui/jsonrpc/README.md +++ b/packages/ui/jsonrpc/README.md @@ -10,7 +10,7 @@ The `jsonrpc` plugin serves newline-delimited JSON-RPC over stdio so out-of-proc ## Config -`maxTokensAsSuccess` defaults to `false`. Set it to `true` for evaluation hosts that distinguish an accepted, token-limited agent result from an infrastructure failure. `JsonRpcConfig.input`, `output`, and `exit` are runtime-only transport seams; production uses process stdio and `process.exit`. +`maxTokensAsSuccess` defaults to `false` and affects only the deployment-mapped status on `subagent.finished`; root-session prompts have no prompt-level status. `JsonRpcConfig.input`, `output`, and `exit` are runtime-only transport seams; production uses process stdio and `process.exit`. ## stdout is the protocol @@ -22,7 +22,7 @@ The plugin answers `shutdown`, disposes SDK-owned agents and subscriptions to qu ## Wire notes -`initialize.serverInfo.name` is the wire-stable `deepseek-harness-sdk-runtime`. An optional positive `initialize.maxTokens` becomes the request output cap of each SDK-created agent and its in-process descendants; invalid values reject initialization, while omission sends no cap and preserves provider defaults. A session accepts one in-flight prompt; overlap fails immediately, other sessions remain independent, and the session is reusable after settlement. `session.finished` reports that prompt's message-triggered turn outcome; later between-turn records still stream as `session.event` notifications but cannot replace the prompt status. Persistence roots and persona come from `cordis.yml`. +`initialize.serverInfo.name` is the wire-stable `deepseek-harness-sdk-runtime`. An optional positive `initialize.maxTokens` becomes the request output cap of each SDK-created agent and its in-process descendants; invalid values reject initialization, while omission sends no cap and preserves provider defaults. `session/prompt` queues one identified user message and immediately returns `{ messageId }`. The server streams every durable fact as `session.event` and every whole-agent lifecycle transition as `session.status`; it does not assign an assistant message or `turn/end` to that prompt. Independent requests may enqueue more work on the same session. Persistence roots and persona come from `cordis.yml`. ## Model Experience @@ -42,6 +42,7 @@ Append-only; newly visible content follows the reusable request prefix and does ## Known Limitations and Deferred Work -- **The wire has no per-session close or prompt-cancel method** — SDK-created agents remain live until process shutdown, and one accepted prompt runs to agent idle before that session accepts another. +- **The wire has no per-session close or prompt-cancel method** — SDK-created agents remain live until process shutdown. +- **There is no per-prompt result** — `MessageId` identifies inbox admission only; clients that own an automation interval must define and observe that interval themselves. - **stdout purity is deployment-enforced** — a surrounding config can still load a stdout logger and corrupt the JSON-RPC channel; this plugin does not inspect or veto sibling loggers. - **Automatic adapter mounting is DeepSeek-specific** — `initialize` can reuse any pre-registered model adapter, but its only fallback mounts `dsh-llm-deepseek`. diff --git a/packages/ui/jsonrpc/README.zh.md b/packages/ui/jsonrpc/README.zh.md index 1c27f5edf2..e7862aaa33 100644 --- a/packages/ui/jsonrpc/README.zh.md +++ b/packages/ui/jsonrpc/README.zh.md @@ -10,7 +10,7 @@ ## 配置 -`maxTokensAsSuccess` 默认为 `false`。对于需要区分「因 token 上限而结束但可接受的 agent 结果」与「基础设施故障」的评测宿主,请将其设为 `true`。`JsonRpcConfig.input`、`output` 和 `exit` 是仅供运行时使用的传输 seam;生产环境使用进程 stdio 和 `process.exit`。 +`maxTokensAsSuccess` 默认为 `false`,且只影响 `subagent.finished` 上由部署映射的状态;根会话提示词没有提示词级状态。`JsonRpcConfig.input`、`output` 和 `exit` 是仅供运行时使用的传输 seam;生产环境使用进程 stdio 和 `process.exit`。 ## stdout 即协议 @@ -22,7 +22,7 @@ Stdout 只承载 JSON-RPC 帧。部署不得组合 stdout logger;诊断应写 ## 协议说明 -`initialize.serverInfo.name` 的协议稳定值为 `deepseek-harness-sdk-runtime`。可选的正整数 `initialize.maxTokens` 会成为每个 SDK 创建的 agent 及其进程内后代的请求输出上限;非法值会使初始化失败,省略时则不发送上限并保留提供方默认值。一个会话只接受一个进行中的提示词;重叠请求会立即失败,其他会话保持独立,当前请求结算后该会话可再次使用。`session.finished` 报告由该提示词消息触发的轮次结果;后续轮次间记录仍会作为 `session.event` 通知流式发出,但不能替换该提示词的状态。持久化根目录和 persona 由 `cordis.yml` 提供。 +`initialize.serverInfo.name` 的协议稳定值为 `deepseek-harness-sdk-runtime`。可选的正整数 `initialize.maxTokens` 会成为每个 SDK 创建的 agent 及其进程内后代的请求输出上限;非法值会使初始化失败,省略时则不发送上限并保留提供方默认值。`session/prompt` 将一条带标识的用户消息排入队列,并立即返回 `{ messageId }`。服务器将每个持久事实作为 `session.event` 流式发出,并将整个 agent 生命周期的每次状态转换作为 `session.status` 发出;它不会把某条助手消息或 `turn/end` 归属于该提示词。同一会话上的独立请求可以继续排入更多工作。持久化根目录和 persona 由 `cordis.yml` 提供。 ## 模型体验 @@ -42,6 +42,7 @@ Stdout 只承载 JSON-RPC 帧。部署不得组合 stdout logger;诊断应写 ## 已知限制与暂缓事项 -- **协议没有逐会话关闭或提示词取消方法**:SDK 创建的 agent 会一直存活到进程关闭;一条已接受的提示词必须运行到 agent 空闲,该会话才能接受下一条。 +- **协议没有逐会话关闭或提示词取消方法**:SDK 创建的 agent 会一直存活到进程关闭。 +- **没有逐提示词结果**:`MessageId` 只标识 inbox 准入;拥有自动化活动区间的客户端必须自行定义并观察该区间。 - **stdout 纯净性由部署保证**:外围配置仍可能加载 stdout logger 并破坏 JSON-RPC 通道;此插件不会检查或否决同级 logger。 - **自动挂载适配器仅支持 DeepSeek**:`initialize` 可以复用任何预先注册的模型适配器,但唯一的回退行为是挂载 `dsh-llm-deepseek`。 diff --git a/packages/ui/jsonrpc/src/server.ts b/packages/ui/jsonrpc/src/server.ts index 7143e4c525..09beabab18 100644 --- a/packages/ui/jsonrpc/src/server.ts +++ b/packages/ui/jsonrpc/src/server.ts @@ -10,7 +10,7 @@ import { resolve } from 'node:path' import type { Agent, AgentHandle } from '@deepseek-ai/dsh-agent' import { createUserMessage } from '@deepseek-ai/dsh-llm' import { carrierKeyOf, type Scoped } from '@deepseek-ai/dsh-scope' -import { SessionId, type TurnEndReason } from '@deepseek-ai/dsh-session' +import { SessionId } from '@deepseek-ai/dsh-session' import type SubagentService from '@deepseek-ai/dsh-subagent' import type { SubagentRunEndInfo } from '@deepseek-ai/dsh-subagent' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' @@ -19,7 +19,6 @@ import type { InitializeResult, JsonRpcTransportPeer, SessionEventNotification, - SessionFinishedNotification, SessionPromptParams, SessionPromptResult, SubagentFinishedNotification, @@ -28,7 +27,6 @@ import type { interface SessionRecord { handle: AgentHandle - activePrompt: boolean } /** Recover the delegating parent from the service-owned scoped carrier. */ @@ -74,6 +72,9 @@ export class HarnessSdkServer { const payload: SessionEventNotification = { sessionId: String(session.id), event } this.transport.notify('session.event', payload) })) + this.disposers.push(ctx.on('agent/status', (agent, status) => { + this.transport.notify('session.status', { sessionId: String(agent.session.id), status }) + })) this.disposers.push(ctx.on('session/created', (session) => { const parentSession = session.header.parentSession if (parentSession === undefined) return @@ -124,36 +125,21 @@ export class HarnessSdkServer { } /** - * Run one prompt to settlement; overlap on the same session fails. + * Queue one identified prompt without assigning later activity to it. * @param params - target session and user content. - * @returns acceptance after the turn settled. + * @returns the durable message identity. */ async prompt(params: SessionPromptParams): Promise { const rec = await this.getOrCreateSession(params.sessionId) - if (rec.activePrompt) throw new Error(`session already has an active prompt: ${params.sessionId}`) // An agent-loop-only reload disposes the loop's agents while this record // survives; a retained agent accepts followup() silently, so validate the // record against the live registry before delivery (as the ACP bridge does). if (this.ctx.agents.get(rec.handle.agent.id) !== rec.handle.agent) { throw new Error(`session agent was disposed outside the server: ${params.sessionId}`) } - rec.activePrompt = true - try { - const message = createUserMessage({ content: params.contentBlocks, source: { kind: 'user' } }) - rec.handle.agent.followup(message) - await rec.handle.agent.whenIdle() - const lastEnd = rec.handle.agent.session.events.findLast(event => event.type === 'turn/end') - const reason = lastEnd?.data.reason - const payload: SessionFinishedNotification = { - sessionId: params.sessionId, - status: this.finishedStatus(reason), - reason, - } - this.transport.notify('session.finished', payload) - return { accepted: true } - } finally { - rec.activePrompt = false - } + const message = createUserMessage({ content: params.contentBlocks, source: { kind: 'user' } }) + rec.handle.agent.followup(message) + return { messageId: message.id } } /** @@ -239,16 +225,11 @@ export class HarnessSdkServer { ...this.maxTokens === undefined ? {} : { maxTokens: this.maxTokens }, }, }) - const rec: SessionRecord = { handle, activePrompt: false } + const rec: SessionRecord = { handle } this.sessions.set(sessionId, rec) return rec } - private finishedStatus(reason: TurnEndReason | undefined): 'ok' | 'error' { - if (!reason) return 'error' - return successStatus(reason.kind, this.options) - } - private hasAdapterFor(provider: string): boolean { return this.ctx.get('llm')?.listProviders().some(entry => entry.id === provider) ?? false } diff --git a/python/sdk/README.i18n.yaml b/python/sdk/README.i18n.yaml index 0115e41ce9..fc03f5b057 100644 --- a/python/sdk/README.i18n.yaml +++ b/python/sdk/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write python/sdk/README.md -README.md: f1e16e724efd6f71f63e475e47d7e4d704b8ceac -README.zh.md: 11ff235a0eea23acab5f1737537c799021c35bea +README.md: 81bac18690665e4f48e6edb0a7053d7979e613df +README.zh.md: 8ce2fe85ec38327ddc9183661c3b26fe97dfa630 diff --git a/python/sdk/README.md b/python/sdk/README.md index f1e16e724e..81bac18690 100644 --- a/python/sdk/README.md +++ b/python/sdk/README.md @@ -35,7 +35,9 @@ with DeepSeekHarness( `provider` selects a provider route registered by the chosen Cordis composition; `model` is the model id resolved by that adapter. `max_tokens` is an optional positive per-request output-token cap for the root agent and its in-process descendants; omission leaves the provider default in control. Compaction summaries keep the separate limit configured by their compaction plugin. The bundled default composition registers `deepseek`. A custom composition can mount `llm-pi-ai`, configure provider-specific credentials/endpoints there, and select any provider/model present in pi-ai's installed catalog. -`HarnessClient` retains discovered subagent ancestry for the lifetime of the runtime process. During each `Session.run()`, `TurnResult.notifications` and `on_notification` receive the root session and all known descendant notifications in wire order, including nested subagent lifecycle and session events. `TurnResult.events` remains the root session's complete event stream, and `TurnResult.final_response` is the text content from its last `assistant/message`; descendant messages therefore cannot replace the root response. +`Session.run()` owns an activity interval from its prompt's durable inbox receipt through the next whole-agent idle and returns `RunResult(session_id, final_response, events, notifications, session_root)`. The result has no prompt-level status or turn reason: `final_response` is the last committed root-session assistant text in the interval, not an output causally assigned to the prompt. Steering, injected context, and other queued work may contribute before idle. + +`HarnessClient` retains discovered subagent ancestry for the lifetime of the runtime process. During each `Session.run()`, `RunResult.notifications` and `on_notification` receive the root session and all known descendant notifications in wire order, including nested subagent lifecycle and session events. `RunResult.events` contains root-session events only, so descendant messages cannot replace the root response. The low-level `session_prompt()` returns the queued `MessageId` immediately; callers that bypass `Session.run()` own any later activity boundary themselves. The same behavior can be selected for the runtime subprocess with `DSH_CORDIS_CONFIG`. The injection lives in `HarnessClient.start()`, so the low-level client's default launch gets it too: when the launch resolves to the bundled runtime and neither `cordis` nor a non-empty `DSH_CORDIS_CONFIG` is set (the runtime treats an empty value as absent, and so does the injection check), the bundled default configuration is used; an explicit `runtime_bin`, `bridge_bin`, or `launch_args_override` disables the injection entirely. See the [sdk-runtime README](../sdk-runtime/README.md) for the runtime carriers (production exe vs dev-only node closure) and how to obtain them. diff --git a/python/sdk/README.zh.md b/python/sdk/README.zh.md index 11ff235a0e..8ce2fe85ec 100644 --- a/python/sdk/README.zh.md +++ b/python/sdk/README.zh.md @@ -31,7 +31,9 @@ with DeepSeekHarness( `provider` 用于选择当前 Cordis 组合已注册的提供方路由;`model` 是该适配器解析的模型 ID。`max_tokens` 是可选的正整数,用于限制根 agent 及其进程内后代每次请求的输出 token;省略时由提供方默认值控制。压缩摘要继续使用压缩插件单独配置的上限。内置默认组合注册 `deepseek`。自定义组合可以挂载 `llm-pi-ai`,在其中配置各提供方的凭据与端点,再选择 pi-ai 已安装目录中的任意提供方/模型组合。 -`HarnessClient` 会在运行时进程的生命周期内保留已发现的 subagent(子 agent)祖先关系。每次执行 `Session.run()` 时,`TurnResult.notifications` 与 `on_notification` 会按协议传输顺序收到根会话及所有已知后代的通知,其中包括嵌套 subagent 的生命周期事件与会话事件。`TurnResult.events` 仍只保存根会话的完整事件流,`TurnResult.final_response` 则取该会话最后一个 `assistant/message` 的文本内容,因此后代消息不会覆盖根会话回复。 +`Session.run()` 拥有一个从提示词的持久 inbox 回执开始、到整个 agent 下一次进入 idle 为止的活动区间,并返回 `RunResult(session_id, final_response, events, notifications, session_root)`。结果不携带提示词级状态或轮次原因:`final_response` 是该区间内根会话最后提交的助手文本,并非因果上归属于该提示词的输出。steering(中途引导)、注入的上下文和其他排队工作都可能在 idle 前参与其中。 + +`HarnessClient` 会在运行时进程的生命周期内保留已发现的 subagent(子 agent)祖先关系。每次执行 `Session.run()` 时,`RunResult.notifications` 与 `on_notification` 会按协议传输顺序收到根会话及所有已知后代的通知,其中包括嵌套 subagent 的生命周期事件与会话事件。`RunResult.events` 只包含根会话事件,因此后代消息不会覆盖根会话回复。底层 `session_prompt()` 会立即返回已排队消息的 `MessageId`;绕过 `Session.run()` 的调用方必须自行负责后续的活动边界。 同样的行为也可以通过 `DSH_CORDIS_CONFIG` 为运行时子进程选定。注入逻辑位于 `HarnessClient.start()`,因此底层客户端的默认启动也具有此行为:当启动解析到内置运行时,且 `cordis` 与非空的 `DSH_CORDIS_CONFIG` 均未设置时(运行时把空值视为缺省,注入检查与之一致),使用内置的默认配置;显式给出 `runtime_bin`、`bridge_bin` 或 `launch_args_override` 则完全禁用注入。运行时载体(生产用 exe 与仅限开发的 `node` 闭包)及其获取方式见 [sdk-runtime README](../sdk-runtime/README.md)。 diff --git a/python/sdk/src/deepseek_harness/__init__.py b/python/sdk/src/deepseek_harness/__init__.py index fab791d4f6..c15a6ed810 100644 --- a/python/sdk/src/deepseek_harness/__init__.py +++ b/python/sdk/src/deepseek_harness/__init__.py @@ -1,4 +1,4 @@ -from .api import DeepSeekHarness, DeepSeekHarnessConfig, Session, TurnResult +from .api import DeepSeekHarness, DeepSeekHarnessConfig, RunResult, Session from .client import HarnessClient, HarnessConfig from .models import IncomingRequest, InitializeResponse, JsonObject, Notification, ServerInfo @@ -6,7 +6,7 @@ __all__ = [ "DeepSeekHarness", "DeepSeekHarnessConfig", "Session", - "TurnResult", + "RunResult", "HarnessClient", "HarnessConfig", "IncomingRequest", diff --git a/python/sdk/src/deepseek_harness/api.py b/python/sdk/src/deepseek_harness/api.py index 29bb4223ac..dc55070c0e 100644 --- a/python/sdk/src/deepseek_harness/api.py +++ b/python/sdk/src/deepseek_harness/api.py @@ -35,9 +35,8 @@ class DeepSeekHarnessConfig: @dataclass(slots=True) -class TurnResult: +class RunResult: session_id: str - status: str final_response: str events: list[JsonObject] notifications: list[Notification] @@ -119,7 +118,7 @@ class DeepSeekHarness: *, session_id: str | None = None, on_notification: Callable[[Notification], None] | None = None, - ) -> TurnResult: + ) -> RunResult: return self.start_session(session_id).run(input, on_notification=on_notification) @@ -133,15 +132,12 @@ class Session: input: str | list[JsonObject], *, on_notification: Callable[[Notification], None] | None = None, - ) -> TurnResult: + ) -> RunResult: content_blocks = normalize_input(input) notifications: list[Notification] = [] events: list[JsonObject] = [] - status = "error" - finished = False def collect(notification: Notification) -> None: - nonlocal finished, status notifications.append(notification) if on_notification is not None: on_notification(notification) @@ -152,25 +148,31 @@ class Session: event = notification.payload.get("event") if isinstance(event, dict): events.append(event) - if notification.method == "session.finished" and notification.payload.get("sessionId") == self.id: - status = str(notification.payload.get("status") or "ok") - finished = True with self.harness.client.subscribe_session_notifications(self.id) as subscription: - self.harness.client.session_prompt( + message_id = self.harness.client.session_prompt( self.id, content_blocks, - on_notification=collect, notification_subscription=subscription, ) - while not finished: + received = False + while True: notification = subscription.next() + if not received: + if not _is_inbox_receipt(notification, self.id, message_id): + continue + received = True collect(notification) + if ( + notification.method == "session.status" + and notification.payload.get("sessionId") == self.id + and notification.payload.get("status") == "idle" + ): + break - return TurnResult( + return RunResult( session_id=self.id, - status=status, final_response=final_response(events), events=events, notifications=notifications, @@ -178,6 +180,19 @@ class Session: ) +def _is_inbox_receipt(notification: Notification, session_id: str, message_id: str) -> bool: + if notification.method != "session.event" or notification.payload.get("sessionId") != session_id: + return False + event = notification.payload.get("event") + if not isinstance(event, dict) or event.get("type") != "agent/inbox/spliced": + return False + data = event.get("data") + inserted = data.get("inserted") if isinstance(data, dict) else None + return isinstance(inserted, list) and any( + isinstance(message, dict) and message.get("id") == message_id for message in inserted + ) + + def normalize_input(input: str | list[JsonObject]) -> list[JsonObject]: if isinstance(input, str): return [{"type": "text", "text": input}] diff --git a/python/sdk/src/deepseek_harness/client.py b/python/sdk/src/deepseek_harness/client.py index 052969a694..629ddf901f 100644 --- a/python/sdk/src/deepseek_harness/client.py +++ b/python/sdk/src/deepseek_harness/client.py @@ -10,7 +10,7 @@ import uuid from collections import deque from dataclasses import dataclass from pathlib import Path -from typing import Callable, Literal, TypeAlias, TypeVar +from typing import Callable, TypeAlias, TypeVar from pydantic import BaseModel @@ -142,9 +142,9 @@ class HarnessClient: *, on_notification: Callable[[Notification], None] | None = None, notification_subscription: "NotificationSubscription | None" = None, - ) -> None: + ) -> str: payload: JsonObject = {"sessionId": session_id, "contentBlocks": content_blocks} - self.request( + response = self.request( "session/prompt", payload, response_model=_SessionPromptResponse, @@ -152,6 +152,7 @@ class HarnessClient: notification_filter=self._notification_belongs_to_session_tree(session_id), notification_subscription=notification_subscription, ) + return response.messageId def request( self, @@ -536,7 +537,7 @@ class NotificationSubscription: class _SessionPromptResponse(BaseModel): - accepted: Literal[True] + messageId: str class _ShutdownResponse(BaseModel): diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 4c148f3287..55743bdc15 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -88,28 +88,8 @@ }, { "doc": "docs/core-data-structures/core.md", - "symbol": "InboxPlacement", - "source": "packages/core/agent/src/types.ts" - }, - { - "doc": "docs/core-data-structures/core.md", - "symbol": "InboxItem", - "source": "packages/core/agent/src/types.ts" - }, - { - "doc": "docs/core-data-structures/core.md", - "symbol": "InboxAction", - "source": "packages/core/agent/src/types.ts" - }, - { - "doc": "docs/core-data-structures/core.md", - "symbol": "InboxActionResult", - "source": "packages/core/agent/src/types.ts" - }, - { - "doc": "docs/core-data-structures/core.md", - "symbol": "SendOptions", - "source": "packages/core/agent/src/types.ts" + "symbol": "InboxTarget", + "source": "packages/core/agent/src/inbox.ts" }, { "doc": "docs/core-data-structures/core.md", @@ -119,7 +99,7 @@ { "doc": "docs/core-data-structures/core.md", "symbol": "AgentCancelCause", - "source": "packages/core/agent/src/types.ts" + "source": "packages/core/session/src/types.ts" }, { "doc": "docs/core-data-structures/core.md", @@ -136,11 +116,6 @@ "symbol": "RequestErrorAction", "source": "packages/core/agent/src/types.ts" }, - { - "doc": "docs/core-data-structures/core.md", - "symbol": "RequestError", - "source": "packages/core/agent/src/types.ts" - }, { "doc": "docs/core-data-structures/core.md", "symbol": "SessionStartSource", @@ -343,11 +318,6 @@ "symbol": "SessionEvent", "source": "packages/core/session/src/types.ts" }, - { - "doc": "docs/core-data-structures/session.md", - "symbol": "TurnTriggerMap", - "source": "packages/core/session/src/types.ts" - }, { "doc": "docs/core-data-structures/session.md", "symbol": "TurnEndReasonMap",