refactor: remove per-followup result attribution

This commit is contained in:
_Kerman
2026-07-30 16:48:28 +08:00
parent f6db60b52c
commit a6baddaaac
72 changed files with 586 additions and 1059 deletions
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/acp/acp/README.md
README.md: 1b188b994d17ce56e8d5df019ddef755338fcc88
README.zh.md: c1e7d045b55119b62ad44d81071188e1ed6110d5
README.md: 162e88265762652b5629c0e04786311c5b62583f
README.zh.md: 3fd914ecc5bf7b0e7069ba933b09db0a4a3cdfcd
+3 -1
View File
@@ -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.
+3 -1
View File
@@ -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 客户端只需上述核心方法。
+1 -24
View File
@@ -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
+26 -95
View File
@@ -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