Files
deepseek-harness/packages/acp/acp/src/index.ts
T
Yichen Jiang 61d5cb9e41 fix(apiproxy): serve history events and projections from one log position
Review found two defects in the previous commit's ordering fix.

@pku-xht: `historyStateFor` copied the attached session's events, the handler
then awaited `presenterScopeFor`, and only then read the projection baseline
off the still-live Session. An append during that await served events cut at N
beside a baseline folded to N+1 — one response describing two moments. The
same restructure had also moved the baseline read outside the `try`, so a
failing snapshot escaped the structured `internal` error.

Both awaits now happen before the cut: `historySourceFor` resolves which
session serves the read, `presenterScopeFor` ensures the recorded composition,
and `historyCutOf` then reads events and baseline adjacently with nothing
between them. The whole sequence is back inside the try.

The invariant judged any scoped assembly with a chain of one as an unjoined
agent, which rejects a legitimate assembly in a standing preset key (that key
has no parent of its own). It now gates on `context.agent` — a scope-only read
is not an agent and is out of range by construction rather than by a premise
about who else calls `assemble` — and asks the roster's own
`composedPreset()` instead of introspecting chain length. The advisory warning
uses the same relation.

Also from review: the `2026-08-05-per-agent-tool-presentation` note still
described `presentAs` as per-agent, which standing mounts made false and this
branch's own rewording contradicts; the duplicated "process-wide unit table"
argument collapses to the Agent Note with pointers from the five copies; a
dead `.sort()` before `arrayContaining`; and change-history narration in the
tool-cordis README.
2026-08-11 10:45:15 +08:00

437 lines
18 KiB
TypeScript

/**
* Automation-only Agent Client Protocol server over JSON-RPC stdio.
*
* The bridge exposes fresh harness sessions to trusted programmatic clients. It
* carries prompt text, committed assistant text, cancellation, and one-shot
* permission decisions; presentation and human-interaction features stay with
* the harness's UI modules.
*
* @module @deepseek-ai/dsh-acp
*/
import type { Context } from '@deepseek-ai/cordis'
import { randomUUID } from 'node:crypto'
import { isAbsolute } from 'node:path'
import { Readable, Writable } from 'node:stream'
import Schema from '@deepseek-ai/schemastery'
import { createUserMessage, errorChain } from '@deepseek-ai/dsh-llm'
import {
AgentSideConnection,
ndJsonStream,
PROTOCOL_VERSION,
RequestError,
type Agent as AcpAgent,
type AuthenticateRequest,
type CancelNotification,
type InitializeRequest,
type InitializeResponse,
type NewSessionRequest,
type NewSessionResponse,
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'
// 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'
export const name = 'acp'
/** The bridge creates and owns agents; every other concern is carried by the agent composition. */
export const inject = ['agents']
/**
* The single continuable-subagent teardown the bridge needs. Declared
* structurally so this package does not depend on the subagent seam for one
* shutdown hook; an absent service means nothing continuable was materialized.
*/
interface ContinuableDrain {
/**
* Close admission below exact host-owned parents, then dispose only their
* continuable descendants child-first.
*/
drainContinuableDescendants(parents: readonly Agent[]): Promise<void>
}
/** Preserve invalid-parameter detail in the SDK wire error message. */
function invalidParams(detail: string): RequestError {
return RequestError.invalidParams(undefined, detail)
}
/** Preserve failed-turn detail; plain handler errors become a generic wire internal error. */
function internalError(detail: string): RequestError {
return RequestError.internalError(undefined, detail)
}
/** Plugin config: the provider/model selection used for each ACP-created agent. */
export interface AcpConfig {
/** Provider route for created agents. */
provider?: string
/** Model name for created agents. */
model?: string
/** Runtime-only transport override; production uses stdio. */
stream?: Stream
}
export const Config: Schema<AcpConfig> = Schema.object({
provider: Schema.string(),
model: Schema.string(),
})
/** Per-session protocol state. */
interface SessionRecord {
agent: Agent
/** Exact owned-agent disposer; resolves after registry, loop, and session teardown. */
dispose: () => Promise<void>
/** In-flight prompt and its captured turn number for exact settlement. */
inflight: {
resolve: (reason: StopReason) => void
reject: (error: Error) => void
messageId: string
turn: number | undefined
/** The correlated turn's ending, set at turn/end and settled at whole-agent idle. */
endReason: TurnEndReason | undefined
} | undefined
}
/**
* Mount the automation-only ACP server.
* @param ctx - Cordis context carrying the agent factory and session events.
* @param config - Initial provider/model selection and optional test transport.
*/
export function apply(ctx: Context, config: AcpConfig): void {
// ACP handlers execute outside this plugin's injection scope, so capture the
// injected service during apply rather than reading it lazily in a callback.
const agents = ctx.agents
const logger = ctx.logger
const sessions = new Map<SessionId, SessionRecord>()
let closed = false
let conn: AgentSideConnection
/** Return the bridge-owned record for an agent, rejecting same-id impostors. */
const ownedRecord = (agent: Agent): SessionRecord | undefined => {
const record = sessions.get(agent.session.id)
return record?.agent === agent ? record : undefined
}
const assertOpen = (): void => {
if (closed) throw internalError('the ACP bridge has been disposed')
}
const requireSession = (sessionId: SessionId): SessionRecord => {
const record = sessions.get(sessionId)
if (record === undefined) throw invalidParams(`unknown session: ${sessionId}`)
return record
}
/** Send a protocol update without letting a disconnected client fail an agent turn. */
const notify = (notification: SessionNotification): void => {
/* v8 ignore next 3 -- only a transport write failure reaches this guard. */
void conn.sessionUpdate(notification).catch((error: unknown) => {
logger.warn(`acp: session/update failed: ${String(error)}`)
})
}
const settlePrompt = (record: SessionRecord, reason: StopReason): 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: ${reason.error.message}`))
}
// Emit only committed assistant text. Raw chunks, reasoning, tools, plans,
// titles, and retry markers are presentation or trace data and stay off the
// automation wire.
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 },
},
})
} else if (block.type === 'image') {
notify({
sessionId: record.agent.session.id,
update: {
sessionUpdate: 'agent_message_chunk',
content: {
type: 'text',
text: `[image attachment ${block.attachment.attachmentId}]`,
},
},
})
}
}
}
} finally {
const inflight = record.inflight
if (inflight !== undefined && event.type === 'turn/end' && inflight.turn === event.data.turn) {
if (event.data.reason.kind === 'error') {
// Model failures surface immediately as prompt errors; ordinary
// endings wait for whole-agent idle below.
record.inflight = undefined
rejectFromError(inflight, event.data.reason)
} else {
inflight.endReason = event.data.reason
}
}
}
})
ctx.on('agent/inbox/claimed', ({ agent, message, turn }) => {
const record = ownedRecord(agent)
const inflight = record?.inflight
if (inflight !== undefined && inflight.messageId === message.id) inflight.turn = turn
})
ctx.on('agent/error', ({ agent, turn, error }) => {
const record = ownedRecord(agent)
const inflight = record?.inflight
if (record === undefined || inflight === undefined || inflight.turn === turn) return
record.inflight = undefined
inflight.reject(internalError(`turn failed: ${errorChain(error)}`))
})
// Permission requests are a machine policy channel for ACP clients such as
// dsh-subagent-acp. The bridge offers one-shot choices only and never infers a
// durable grant from an unknown client response.
ctx.on('approval/request', (request, next) => {
const record = ownedRecord(request.agent)
if (record === undefined || request.callId === undefined) return next()
return conn.requestPermission({
sessionId: record.agent.session.id,
toolCall: { toolCallId: request.callId },
options: [
{ optionId: 'allow-once', name: 'Allow once', kind: 'allow_once' },
{ optionId: 'reject-once', name: 'Reject', kind: 'reject_once' },
],
}).then(({ outcome }) => {
if (outcome.outcome === 'cancelled') return 'cancelled'
return outcome.optionId === 'allow-once' ? 'allowed-once' : 'rejected'
})
})
const makeAgent = (connection: AgentSideConnection): AcpAgent => {
conn = connection
return {
initialize(_params: InitializeRequest): Promise<InitializeResponse> {
// Single-version agent: the spec's "same version if supported, else
// the latest supported" both resolve to this server's one version.
return Promise.resolve({
protocolVersion: PROTOCOL_VERSION,
agentInfo: { name: 'deepseek-harness-acp', version: '0.0.1' },
agentCapabilities: {
promptCapabilities: { image: false, audio: false, embeddedContext: false },
},
authMethods: [],
})
},
authenticate(_params: AuthenticateRequest): Promise<void> {
return Promise.resolve()
},
async newSession(params: NewSessionRequest): Promise<NewSessionResponse> {
assertOpen()
validateSessionParams(params)
const sessionId = SessionId(randomUUID())
// No preset composition: the ACP bundle keeps the model-facing rows in
// the host plane, so this agent reads them from the global layer. A
// deployment that configures a roster has to join one here first
// (@deepseek-ai/dsh-agent-presets README, "Composing a child agent").
const handle = await agents.create({
sessionId,
meta: { cwd: params.cwd },
agentOptions: agentOptions(config),
})
/* v8 ignore next 4 -- a real stdio close can race an in-flight create. */
if (closed) {
await handle.dispose()
throw internalError('connection closed during session/new')
}
sessions.set(sessionId, {
agent: handle.agent,
dispose: () => handle.dispose(),
inflight: undefined,
})
return { sessionId }
},
async prompt(params: PromptRequest): Promise<PromptResponse> {
assertOpen()
const record = requireSession(SessionId(params.sessionId))
if (record.inflight !== undefined) {
throw invalidParams('a prompt is already in flight for this session')
}
if (promptHasUnsupportedContent(params.prompt)) {
throw invalidParams('only text and resource_link prompt content is supported')
}
const text = acpPromptToText(params.prompt)
if (text.trim().length === 0) throw invalidParams('empty prompt')
// Not driving a retired agent is this bridge's contract: an
// agent-loop-only reload disposes the loop's agents while the bridge
// record survives, so validate the record against the live registry
// before sending — a disposed machine would accept the item silently.
if (ctx.agents.get(record.agent.id) !== record.agent) {
throw internalError('prompt was not queued: the agent was disposed outside the bridge')
}
const message = createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'user' } })
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, messageId: message.id, turn: undefined, endReason: undefined,
}
record.inflight = inflight
try {
record.agent.followup(message)
// 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 */
// Settlement waits for whole-agent idle: a correlated turn/end arms
// `endReason`, while a turnless slot (admission discarded the
// prompt) stays cancelled. Other producers may run further turns
// before quiescence; the prompt settles only when the agent stops.
void record.agent.whenIdle().then(() => {
if (record.inflight !== inflight) return
record.inflight = undefined
const end = inflight.endReason
if (end === undefined) {
inflight.resolve('cancelled')
} else {
// Token-limit and other non-terminal endings are not prompt-level
// stop reasons (see README); only normal quiescence reports end_turn.
inflight.resolve(end.kind === 'max-tokens' ? 'end_turn' : turnEndToStopReason(end))
}
})
})
return { stopReason }
},
cancel(params: CancelNotification): Promise<void> {
const record = sessions.get(SessionId(params.sessionId))
if (record === undefined) return Promise.resolve()
record.agent.cancel({ kind: 'user' })
settlePrompt(record, 'cancelled')
return Promise.resolve()
},
}
}
/* v8 ignore next 4 -- production stdio wiring; tests inject config.stream. */
const stream: Stream = config.stream ?? ndJsonStream(
Writable.toWeb(process.stdout) as WritableStream<Uint8Array>,
Readable.toWeb(process.stdin) as ReadableStream<Uint8Array>,
)
conn = new AgentSideConnection(makeAgent, stream)
let quiescing: Promise<void> | undefined
const quiesce = (): Promise<void> => {
if (quiescing !== undefined) return quiescing
closed = true
const records = [...sessions.values()]
sessions.clear()
// Stop the bridge's own work before any await: a descendant drain can block
// on persistence or scoped cleanup, and the top-level agents must not keep
// running model and tool calls for its whole duration.
for (const record of records) {
record.agent.cancel({ kind: 'user' })
settlePrompt(record, 'cancelled')
}
quiescing = (async () => {
// Continuable subagents outlive the turn that started them, and their
// Activations own descendant teardown. Drain only these sessions' forests
// child-first BEFORE disposing the top-level agents, so no descendant is
// left holding a runtime its owner already released and another frontend
// sharing this Context remains live.
// Read the one teardown method structurally: the bridge needs no other
// part of the subagent seam, so it does not depend on that package.
const subagents = ctx.get('subagents') as ContinuableDrain | undefined
if (subagents !== undefined) {
try {
await subagents.drainContinuableDescendants(records.map(record => record.agent))
} catch (error: unknown) {
logger.warn(`acp: continuable subagent teardown failed: ${String(error)}`)
}
}
const disposals = await Promise.allSettled(records.map(record => record.dispose()))
const failures: unknown[] = []
for (const result of disposals) {
if (result.status === 'rejected') failures.push(result.reason as unknown)
}
if (failures.length > 0) {
// The production consumer logs this AggregateError through `String`,
// which renders only its message. Embed every per-session diagnostic,
// including nested causes and aggregate members, in that message.
const detail = failures.map(failure => errorChain(failure)).join('; ')
throw new AggregateError(
failures,
`ACP agent teardown failed for ${failures.length} session(s): ${detail}`,
)
}
})()
return quiescing
}
/* v8 ignore start -- production transport rejection and teardown failure. */
void conn.closed
.catch((error: unknown) => {
logger.warn(`acp: connection closed with an error: ${String(error)}`)
})
.then(quiesce)
.catch((error: unknown) => {
logger.warn(`acp: connection-close teardown failed: ${String(error)}`)
})
/* v8 ignore stop */
ctx.effect(() => quiesce, 'acp.connection')
}
/**
* Build per-agent options from plugin config without assigning absent optional fields.
* @param config - ACP provider/model configuration.
* @returns the configured fields only.
*/
function agentOptions(config: AcpConfig): { provider?: string; model?: string } {
return {
...config.provider !== undefined ? { provider: config.provider } : {},
...config.model !== undefined ? { model: config.model } : {},
}
}
/** Reject session features outside the automation contract. */
function validateSessionParams(params: NewSessionRequest): void {
if (!isAbsolute(params.cwd)) throw invalidParams(`cwd must be an absolute path: ${params.cwd}`)
if (params.additionalDirectories !== undefined && params.additionalDirectories.length > 0) {
throw invalidParams('additionalDirectories is not supported')
}
if (params.mcpServers.length > 0) throw invalidParams('mcpServers is not supported')
}