# Conflicts: # .agents/notes/implemented/architecture/2026-06-14-session-persistence.md # .agents/notes/implemented/architecture/2026-06-20-package-hierarchy.md # .agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md # .agents/notes/implemented/feature/2026-06-14-acp-agent-client-protocol.md # .agents/notes/implemented/feature/2026-06-14-acp-multi-session.md # .agents/notes/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md # .agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml # .agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.md # .agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.zh.md # .agents/notes/implemented/simplification/2026-07-04-trim-acp-bridge-unreachable-surface.md # docs/architecture.i18n.yaml # docs/cookbook/extension-cookbook.i18n.yaml # docs/cookbook/extension-cookbook.md # docs/cookbook/extension-cookbook.zh.md # docs/core-data-structures/approval.md # docs/core-data-structures/user-interaction.md # docs/event-producer-consumer.md # docs/persistence-catalog.md # docs/testing.md # docs/tool-catalog.md # examples/acp-agent/tests/fixtures/live-mode-switching-2026-07-07.session.jsonl # examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl # examples/acp-agent/tests/snapshots/permission-switching/session.jsonl # packages/goal/tool-goal/README.md # packages/ui/acp/README.md # packages/ui/acp/acp-feature-support.md # packages/ui/acp/src/index.ts # packages/ui/acp/tests/bridge.spec.ts # packages/ui/acp/tests/dispose.spec.ts # packages/ui/acp/tests/edges.spec.ts # packages/ui/acp/tests/turns.spec.ts
331 lines
12 KiB
TypeScript
331 lines
12 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 'cordis'
|
|
import { randomUUID } from 'node:crypto'
|
|
import { isAbsolute } from 'node:path'
|
|
import { Readable, Writable } from 'node:stream'
|
|
import Schema from 'schemastery'
|
|
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']
|
|
|
|
/** 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 target 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
|
|
turn: number | undefined
|
|
} | undefined
|
|
}
|
|
|
|
/**
|
|
* Mount the automation-only ACP server.
|
|
* @param ctx - Cordis context carrying the agent factory and session events.
|
|
* @param config - Initial provider/model target 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 settleFromTurnEnd = (
|
|
inflight: NonNullable<SessionRecord['inflight']>,
|
|
reason: TurnEndReason,
|
|
): void => {
|
|
if (reason.kind === 'error') {
|
|
inflight.reject(internalError(`turn failed: ${'failure' in reason ? reason.failure.message : reason.message}`))
|
|
return
|
|
}
|
|
inflight.resolve(turnEndToStopReason(reason))
|
|
}
|
|
|
|
// 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.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 !== undefined && event.type === 'turn/end' && inflight.turn === event.data.turn) {
|
|
record.inflight = undefined
|
|
settleFromTurnEnd(inflight, event.data.reason)
|
|
}
|
|
}
|
|
})
|
|
|
|
// 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())
|
|
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')
|
|
|
|
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 (an agent disposed outside the bridge, e.g. an
|
|
// agent-loop-only reload) must free the slot again or the session
|
|
// would reject every later prompt as already in flight.
|
|
record.inflight = { resolve, reject, turn: undefined }
|
|
try {
|
|
record.agent.followup([{ type: 'text', text }])
|
|
} catch (error: unknown) {
|
|
record.inflight = undefined
|
|
// followup() throws only Errors (disposed agent / invalid input);
|
|
// the String arm is a defensive fallback for a non-Error throw.
|
|
/* v8 ignore next */
|
|
const detail = error instanceof Error ? error.message : String(error)
|
|
throw internalError(`prompt was not queued: ${detail}`)
|
|
}
|
|
})
|
|
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()
|
|
quiescing = Promise.all(records.map(async (record) => {
|
|
settlePrompt(record, 'cancelled')
|
|
await record.dispose()
|
|
})).then(() => {})
|
|
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')
|
|
}
|