refactor: remove per-followup result attribution
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/acp/acp/README.md
|
||||
README.md: 1b188b994d17ce56e8d5df019ddef755338fcc88
|
||||
README.zh.md: c1e7d045b55119b62ad44d81071188e1ed6110d5
|
||||
README.md: 162e88265762652b5629c0e04786311c5b62583f
|
||||
README.zh.md: 3fd914ecc5bf7b0e7069ba933b09db0a4a3cdfcd
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 客户端只需上述核心方法。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<void>
|
||||
/** 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<TurnEndReason, { kind: 'error' }> | 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<SessionRecord['inflight']>,
|
||||
reason: Extract<TurnEndReason, { kind: 'error' }>,
|
||||
): 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<StopReason>((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<SessionRecord['inflight']> = {
|
||||
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<SessionRecord['inflight']> = { 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<void> {
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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))。
|
||||
|
||||
### 扩展点
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
|
||||
@@ -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` 都有两个可选顶层字段(结构元数据):
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 上下文或并发顶层会话。
|
||||
- **没有交互式问题或批准提供方**:需要人工回答的工具无法完成,除非其他叶节点按显式策略组合一个非交互式提供方。
|
||||
- **流式输出仅限顶层会话**:子会话不会平铺到流中,聚合用量只涵盖父任务轮次记录的模型步骤。
|
||||
- **流式输出仅限顶层会话**:子会话不会平铺到流中,聚合用量只涵盖父活动区间记录的模型步骤。
|
||||
|
||||
@@ -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<v
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one message-triggered turn on the configured top-level agent, aggregate its
|
||||
* final text and model usage, wait for idle plus an explicit persistence flush,
|
||||
* and return its durable ending. Only the selected agent's task turn reaches
|
||||
* `onEvent`; startup injections and unrelated sessions are ignored. The context
|
||||
* must contain exactly one top-level agent. Signal abort cancels that agent; an
|
||||
* abort before the correlated task turn rejects. An observer throw cancels the
|
||||
* turn and is rethrown after the agent reaches idle and the session flushes.
|
||||
* Run one owned activity interval on the configured top-level agent, from the
|
||||
* task's durable enqueue receipt through whole-agent idle.
|
||||
* @param ctx - settled Loader root containing one agent plus `ctx.sessions`.
|
||||
* @param options - task, optional cancellation, and optional stream observer.
|
||||
* @returns the DSH-native result envelope after durable quiescence.
|
||||
@@ -222,33 +214,12 @@ export async function runOneShot(ctx: Context, options: OneShotOptions): Promise
|
||||
}
|
||||
await waitForStartupIdle(agent, options.signal)
|
||||
|
||||
let targetTurn: number | undefined
|
||||
let reason: TurnEndReason | undefined
|
||||
let result = ''
|
||||
const message = createUserMessage({ content: [{ type: 'text', text: options.task }], source: { kind: 'user' } })
|
||||
let received = false
|
||||
let output = ''
|
||||
const usageByStep = new Map<string, TokenUsage>()
|
||||
let outputError: Error | undefined
|
||||
let resolveTurn!: () => void
|
||||
let rejectTurn!: (error: Error) => void
|
||||
let firstTurnEnded = false
|
||||
const turnEnded = new Promise<void>((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<TokenUsage | undefined>(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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 `<goal_round>` 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.
|
||||
|
||||
@@ -23,30 +23,21 @@
|
||||
|
||||
当对应的活跃 agent 实例处于 idle 状态,且目标 phase 为 active、已启用续行并有剩余容量时,驱动器先为待处理 goal 变更创建检查点,再预留 `roundsStarted + 1`,对应当前 `{ goalId, revision }`。它会排入一条 `<goal_round>` 提示词,并携带 `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,而不会采用隐式重试策略。
|
||||
|
||||
@@ -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<void> | undefined
|
||||
stopping: boolean
|
||||
readonly flushFailedTurns: Set<number>
|
||||
}
|
||||
|
||||
/** 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<void> {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,放弃该过程意味着关闭运行时。
|
||||
- **客户端→服务端通知与服务端→客户端请求**在协议两端都未实现;传输层为未来审批流保留了承载能力。
|
||||
|
||||
@@ -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<TurnResult> {
|
||||
run(input: string | ContentBlock[], options?: RunOptions): Promise<RunResult> {
|
||||
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<RunOptions, 'onNotification'>): Promise<TurnResult> {
|
||||
async run(input: string | ContentBlock[], options?: Pick<RunOptions, 'onNotification'>): Promise<RunResult> {
|
||||
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 {
|
||||
|
||||
@@ -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<void> {
|
||||
async prompt(sessionId: string, contentBlocks: ContentBlock[]): Promise<string> {
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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<string, unknown>
|
||||
@@ -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[]
|
||||
|
||||
@@ -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<string, unknown> | 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':
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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`。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ export type {
|
||||
InitializeResult,
|
||||
SdkRunStatus,
|
||||
SessionEventNotification,
|
||||
SessionFinishedNotification,
|
||||
SessionStatusNotification,
|
||||
SessionPromptParams,
|
||||
SessionPromptResult,
|
||||
SubagentFinishedNotification,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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: {} },
|
||||
])
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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` 被拒绝。
|
||||
|
||||
## 能力与上下文
|
||||
|
||||
|
||||
@@ -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<SessionEvent, { type: 'turn/end' }> => event.type === 'turn/end',
|
||||
)
|
||||
return { output: collectOutput(), stopReason: sdkStopReason(lastEnd?.data.reason) }
|
||||
},
|
||||
collectOutput,
|
||||
cancelled: () => flags.cancelled,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)。
|
||||
|
||||
## 取消与所有权
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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`。
|
||||
|
||||
@@ -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<SessionPromptResult> {
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user