deepseek-harness/packages/acp/acp/src/index.ts
2026-08-22 19:44:00 +08:00

534 lines
22 KiB
TypeScript

/**
* Automation-only Agent Client Protocol server over JSON-RPC stdio.
*
* The bridge exposes persistent harness sessions to trusted programmatic
* clients. It carries standard configuration, MCP mounts, prompt content,
* committed semantic updates, 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 { Buffer } from 'node:buffer'
import { randomUUID } from 'node:crypto'
import { realpath } from 'node:fs/promises'
import { isAbsolute, resolve } from 'node:path'
import { Readable, Writable } from 'node:stream'
import Schema from '@deepseek-ai/schemastery'
import { errorChain } from '@deepseek-ai/dsh-llm'
import {
agent as createAcpAgentApp,
methods,
ndJsonStream,
PROTOCOL_VERSION,
RequestError,
type AgentContext,
type AuthenticateRequest,
type CancelNotification,
type CloseSessionRequest,
type CloseSessionResponse,
type InitializeRequest,
type InitializeResponse,
type ListSessionsRequest,
type ListSessionsResponse,
type NewSessionRequest,
type NewSessionResponse,
type PromptRequest,
type PromptResponse,
type RequestPermissionRequest,
type ResumeSessionRequest,
type ResumeSessionResponse,
type SetSessionConfigOptionRequest,
type SetSessionConfigOptionResponse,
type SessionNotification,
type Stream,
} from '@agentclientprotocol/sdk'
import type { ModelSelection } from '@deepseek-ai/dsh-agent'
import { SessionId } from '@deepseek-ai/dsh-session'
import type {} from '@deepseek-ai/dsh-session-persistence'
// Side-effect type import: declaration-merges the approval waterfall answered below.
import type {} from '@deepseek-ai/dsh-user-approval'
import { supportsAcpImagePrompts } from './content.ts'
import { AcpMcpConfigError } from './mcp.ts'
import { AcpModelConfigError } from './model-control.ts'
import { AcpSession } from './session.ts'
const DEFAULT_SESSION_LIST_PAGE_SIZE = 100
export const name = 'acp'
/** Core services required by the standard automation controls. */
export const inject = ['agents', 'llm', 'sessionPersistence', 'sessions']
/** 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
/** Maximum summaries returned by one session/list page. */
sessionListPageSize?: number
/** Runtime-only transport override; production uses stdio. */
stream?: Stream
}
export const Config: Schema<AcpConfig> = Schema.object({
provider: Schema.string(),
model: Schema.string(),
sessionListPageSize: Schema.natural().min(1).default(DEFAULT_SESSION_LIST_PAGE_SIZE),
})
/**
* 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 persistence = ctx.sessionPersistence
const logger = ctx.logger
const sessionListPageSize = resolveSessionListPageSize(config.sessionListPageSize)
const sessions = new Map<SessionId, AcpSession>()
const activating = new Set<SessionId>()
let closed = false
let imagePromptEnabled = false
/** Return the bridge-owned record for an agent, rejecting same-id impostors. */
const ownedRecord = (agent: Parameters<AcpSession['owns']>[0]): AcpSession | undefined => {
const record = sessions.get(agent.session.id)
return record?.owns(agent) === true ? record : undefined
}
const assertOpen = (): void => {
if (closed) throw internalError('the ACP bridge has been disposed')
}
const requireSession = (sessionId: SessionId): AcpSession => {
const record = sessions.get(sessionId)
if (record === undefined) throw invalidParams(`unknown session: ${sessionId}`)
return record
}
/** Send one ordered protocol update while containing transport-only failure. */
const notify = async (notification: SessionNotification): Promise<void> => {
try {
await conn.notify(methods.client.session.update, notification)
/* v8 ignore start -- the ACP SDK contains notification-handler failures; only a transport write failure reaches this guard. */
} catch (error: unknown) {
logger.warn(`acp: session/update failed: ${String(error)}`)
}
/* v8 ignore stop */
}
ctx.on('session/event', (session, event) => {
const record = sessions.get(session.header.id)
if (record?.ownsSession(session) === true) record.onSessionEvent(session, event)
})
ctx.on('agent/inbox/claimed', ({ agent, message, turn }) => {
ownedRecord(agent)?.onInboxClaimed(message, turn)
})
ctx.on('agent/error', ({ agent, turn, error }) => {
ownedRecord(agent)?.onAgentError(turn, error)
})
ctx.on('llm/adapters-updated', () => {
for (const record of sessions.values()) record.topologyChanged()
})
// 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()
const callId = request.callId
return record.drainUpdates().then(() => {
const params: RequestPermissionRequest = {
sessionId: record.agent.session.id,
toolCall: { toolCallId: callId },
options: [
{ optionId: 'allow-once', name: 'Allow once', kind: 'allow_once' },
{ optionId: 'reject-once', name: 'Reject', kind: 'reject_once' },
],
}
return conn.request(methods.client.session.requestPermission, params)
}).then(({ outcome }) => {
if (outcome.outcome === 'cancelled') return 'cancelled'
return outcome.optionId === 'allow-once' ? 'allowed-once' : 'rejected'
})
})
const implementation = {
async 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.
imagePromptEnabled = await supportsAcpImagePrompts(ctx, config.provider, config.model)
return {
protocolVersion: PROTOCOL_VERSION,
agentInfo: { name: 'deepseek-harness-acp', version: '0.0.1' },
agentCapabilities: {
mcpCapabilities: { http: true },
promptCapabilities: { image: imagePromptEnabled, audio: false, embeddedContext: false },
sessionCapabilities: { close: {}, list: {}, resume: {} },
},
authMethods: [],
}
},
authenticate(_params: AuthenticateRequest): Promise<void> {
return Promise.resolve()
},
async newSession(params: NewSessionRequest, signal: AbortSignal): Promise<NewSessionResponse> {
assertOpen()
validateWorkspaceParams(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").
let record: AcpSession
try {
record = await AcpSession.create(ctx, {
sessionId,
cwd: params.cwd,
mcpServers: params.mcpServers,
agentOptions: agentOptions(config),
fallbackSelection: initialSelection(config),
signal,
notify,
})
} catch (error: unknown) {
if (error instanceof AcpMcpConfigError) throw invalidParams(error.message)
throw error
}
/* v8 ignore next 4 -- a real stdio close can race an in-flight create. */
if (closed) {
await record.close('connection closed during session/new')
throw internalError('connection closed during session/new')
}
sessions.set(sessionId, record)
try {
const configOptions = await record.configOptions(signal)
assertOpen()
await persistence.ensureMaterialized(record.agent.session)
assertOpen()
return { sessionId, configOptions }
} catch (error: unknown) {
sessions.delete(sessionId)
await record.close('session/new activation failed')
throw error
}
},
async resumeSession(params: ResumeSessionRequest, signal: AbortSignal): Promise<ResumeSessionResponse> {
assertOpen()
validateWorkspaceParams(params)
const sessionId = SessionId(params.sessionId)
if (sessions.has(sessionId) || activating.has(sessionId) || ctx.sessions.get(sessionId) !== undefined) {
throw invalidParams(`session is already active: ${sessionId}`)
}
activating.add(sessionId)
return (async (): Promise<ResumeSessionResponse> => {
const persisted = (await persistence.list(signal)).find(header => header.id === sessionId)
if (persisted === undefined || persisted.origin === 'subagent' || persisted.parentSession !== undefined) {
throw invalidParams(`session is not resumable: ${sessionId}`)
}
if (!await sameDirectory(persisted.cwd, params.cwd)) {
throw invalidParams(`session cwd does not match: ${params.cwd}`)
}
let record: AcpSession
try {
record = await AcpSession.resume(ctx, {
sessionId,
cwd: params.cwd,
mcpServers: params.mcpServers ?? [],
agentOptions: agentOptions(config),
fallbackSelection: initialSelection(config),
signal,
notify,
})
} catch (error: unknown) {
if (error instanceof AcpMcpConfigError) throw invalidParams(error.message)
throw error
}
/* v8 ignore start -- the persisted header was checked before resume; the factory restores that exact header. */
if (!await sameDirectory(record.agent.session.header.cwd, params.cwd)) {
await record.close('session/resume cwd mismatch')
throw invalidParams(`session cwd does not match: ${params.cwd}`)
}
/* v8 ignore stop */
/* v8 ignore next 4 -- a real stdio close can race an in-flight resume. */
if (closed) {
await record.close('connection closed during session/resume')
throw internalError('connection closed during session/resume')
}
sessions.set(sessionId, record)
try {
return { configOptions: await record.configOptions(signal) }
} catch (error: unknown) {
sessions.delete(sessionId)
await record.close('session/resume option discovery failed')
throw error
}
})().finally(() => { activating.delete(sessionId) })
},
async listSessions(params: ListSessionsRequest, signal: AbortSignal): Promise<ListSessionsResponse> {
assertOpen()
if (params.cwd !== undefined && params.cwd !== null && !isAbsolute(params.cwd)) {
throw invalidParams(`cwd must be an absolute path: ${params.cwd}`)
}
let cursor: SessionListCursor | undefined
try {
cursor = decodeSessionListCursor(params.cursor)
} catch (error: unknown) {
throw invalidParams((error as Error).message)
}
const listed = await persistence.list(signal)
const filtered = await Promise.all(listed.map(async (header) => {
if (
sessions.has(header.id)
|| activating.has(header.id)
|| ctx.sessions.get(header.id) !== undefined
|| header.origin === 'subagent'
|| header.parentSession !== undefined
|| header.cwd === undefined
|| !isAbsolute(header.cwd)
) return undefined
if (params.cwd !== undefined && params.cwd !== null && !await sameDirectory(header.cwd, params.cwd)) {
return undefined
}
return { sessionId: header.id, cwd: header.cwd, createdAt: header.createdAt }
}))
const entries = filtered
.filter((entry): entry is NonNullable<typeof entry> => entry !== undefined)
.sort((left, right) => right.createdAt - left.createdAt || compareSessionIds(left.sessionId, right.sessionId))
const remaining = cursor === undefined
? entries
: entries.filter(entry => isAfterSessionListCursor(entry, cursor))
const page = remaining.slice(0, sessionListPageSize)
const next = remaining.length > page.length ? page.at(-1) : undefined
return {
sessions: page.map(({ sessionId, cwd }) => ({ sessionId, cwd })),
...next === undefined ? {} : { nextCursor: encodeSessionListCursor(next) },
}
},
async setSessionConfigOption(
params: SetSessionConfigOptionRequest,
signal: AbortSignal,
): Promise<SetSessionConfigOptionResponse> {
assertOpen()
const record = requireSession(SessionId(params.sessionId))
try {
return { configOptions: await record.setConfig(params.configId, params.value, signal) }
} catch (error: unknown) {
if (error instanceof AcpModelConfigError) throw invalidParams(error.message)
throw error
}
},
async closeSession(params: CloseSessionRequest): Promise<CloseSessionResponse> {
assertOpen()
const sessionId = SessionId(params.sessionId)
const record = requireSession(sessionId)
try {
await record.close('ACP session closed')
} catch (error: unknown) {
throw internalError(`session close failed: ${errorChain(error)}`)
} finally {
if (sessions.get(sessionId) === record) sessions.delete(sessionId)
}
return {}
},
async prompt(params: PromptRequest, requestSignal: AbortSignal): Promise<PromptResponse> {
assertOpen()
const record = requireSession(SessionId(params.sessionId))
return record.prompt(params, imagePromptEnabled, requestSignal)
},
cancel(params: CancelNotification): Promise<void> {
sessions.get(SessionId(params.sessionId))?.cancel()
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>,
)
const app = createAcpAgentApp({ name: 'deepseek-harness-acp' })
.onRequest(methods.agent.initialize, ({ params }) => implementation.initialize(params))
.onRequest(methods.agent.authenticate, async ({ params }) => {
await implementation.authenticate(params)
return {}
})
.onRequest(methods.agent.session.new, ({ params, signal }) => implementation.newSession(params, signal))
.onRequest(methods.agent.session.list, ({ params, signal }) => implementation.listSessions(params, signal))
.onRequest(methods.agent.session.resume, ({ params, signal }) => implementation.resumeSession(params, signal))
.onRequest(methods.agent.session.close, ({ params }) => implementation.closeSession(params))
.onRequest(methods.agent.session.setConfigOption, ({ params, signal }) => implementation.setSessionConfigOption(params, signal))
.onRequest(methods.agent.session.prompt, ({ params, signal }) => implementation.prompt(params, signal))
.onNotification(methods.agent.session.cancel, ({ params }) => implementation.cancel(params))
const connection = app.connect(stream)
const conn: AgentContext = connection.client
let quiescing: Promise<void> | undefined
const quiesce = (): Promise<void> => {
if (quiescing !== undefined) return quiescing
closed = true
const records = [...sessions.values()]
// AcpSession.close cancels synchronously before its first await, so every owned
// prompt stops before any descendant or persistence drain can block.
quiescing = (async () => {
const disposals = await Promise.allSettled(records.map(record => record.close('ACP bridge disposed')))
for (const record of records) {
/* v8 ignore next -- closed blocks concurrent handlers; each captured record remains mapped until this loop. */
if (sessions.get(record.agent.session.id) === record) sessions.delete(record.agent.session.id)
}
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 connection.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 } : {},
}
}
/** Initial session selection when both deployment fields are present. */
function initialSelection(config: AcpConfig): ModelSelection | undefined {
return config.provider === undefined || config.model === undefined
? undefined
: { provider: config.provider, model: config.model }
}
interface SessionListCursor {
createdAt: number
sessionId: string
}
/** Resolve and validate the deployment-owned session page limit. */
function resolveSessionListPageSize(value: number | undefined): number {
const resolved = value ?? DEFAULT_SESSION_LIST_PAGE_SIZE
/* v8 ignore start -- Cordis applies the positive-integer Config schema; this protects direct apply callers. */
if (!Number.isSafeInteger(resolved) || resolved < 1) {
throw new Error('acp: sessionListPageSize must be a positive safe integer')
}
/* v8 ignore stop */
return resolved
}
/** Decode an opaque keyset cursor without assigning meaning to client metadata. */
function decodeSessionListCursor(value: string | null | undefined): SessionListCursor | undefined {
if (value === undefined || value === null) return undefined
if (!/^[A-Za-z0-9_-]+$/.test(value)) throw new Error('session/list cursor is invalid')
try {
const decoded = JSON.parse(Buffer.from(value, 'base64url').toString('utf8')) as unknown
const createdAt: unknown = Array.isArray(decoded) ? decoded[0] : undefined
const sessionId: unknown = Array.isArray(decoded) ? decoded[1] : undefined
if (
!Array.isArray(decoded)
|| decoded.length !== 2
|| typeof createdAt !== 'number'
|| !Number.isSafeInteger(createdAt)
|| createdAt < 0
|| typeof sessionId !== 'string'
|| sessionId.length === 0
) throw new Error('invalid cursor fields')
const canonical = Buffer.from(JSON.stringify(decoded), 'utf8').toString('base64url')
if (canonical !== value) throw new Error('non-canonical cursor')
return { createdAt, sessionId }
} catch (_invalidCursor) {
throw new Error('session/list cursor is invalid')
}
}
/** Encode the last returned ordering key as an opaque continuation token. */
function encodeSessionListCursor(entry: SessionListCursor): string {
return Buffer.from(JSON.stringify([entry.createdAt, entry.sessionId]), 'utf8').toString('base64url')
}
/** Test whether an entry follows the cursor in newest-first list order. */
function isAfterSessionListCursor(entry: SessionListCursor, cursor: SessionListCursor): boolean {
return entry.createdAt < cursor.createdAt
|| (entry.createdAt === cursor.createdAt && compareSessionIds(entry.sessionId, cursor.sessionId) > 0)
}
/** Compare opaque session ids by stable UTF-8 bytes, independent of process locale. */
function compareSessionIds(left: string, right: string): number {
return Buffer.compare(Buffer.from(left), Buffer.from(right))
}
/** Reject workspace features outside the automation contract. */
function validateWorkspaceParams(params: { cwd: string; additionalDirectories?: string[] | null }): void {
if (!isAbsolute(params.cwd)) throw invalidParams(`cwd must be an absolute path: ${params.cwd}`)
if (
params.additionalDirectories !== undefined
&& params.additionalDirectories !== null
&& params.additionalDirectories.length > 0
) {
throw invalidParams('additionalDirectories is not supported')
}
}
/** Compare existing directories by physical identity and missing paths lexically. */
async function sameDirectory(left: string | undefined, right: string): Promise<boolean> {
if (left === undefined) return false
try {
const [realLeft, realRight] = await Promise.all([realpath(left), realpath(right)])
return realLeft === realRight
} catch (_unresolvablePath) {
return resolve(left) === resolve(right)
}
}