refactor(agent): complete inbox lifecycle migration
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: 162e88265762652b5629c0e04786311c5b62583f
|
||||
README.zh.md: 3fd914ecc5bf7b0e7069ba933b09db0a4a3cdfcd
|
||||
README.md: 4632c00398d0870d3682d8c6241e26de006ad233
|
||||
README.zh.md: 1bfd16def64f7973ae4e517dfc96e32478b49aa2
|
||||
|
||||
@@ -3,7 +3,31 @@
|
||||
* @module @deepseek-ai/dsh-acp/codec
|
||||
*/
|
||||
|
||||
import type { ContentBlock as AcpContentBlock } from '@agentclientprotocol/sdk'
|
||||
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 'blocked':
|
||||
case 'error':
|
||||
return 'end_turn'
|
||||
/* v8 ignore next 2 -- TurnEndReason is closed and every member is handled above */
|
||||
default:
|
||||
return 'end_turn'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten an ACP prompt's baseline blocks to text. Text blocks concatenate
|
||||
|
||||
@@ -14,7 +14,7 @@ import { randomUUID } from 'node:crypto'
|
||||
import { isAbsolute } from 'node:path'
|
||||
import { Readable, Writable } from 'node:stream'
|
||||
import Schema from 'schemastery'
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import { createUserMessage, errorChain } from '@deepseek-ai/dsh-llm'
|
||||
import {
|
||||
AgentSideConnection,
|
||||
ndJsonStream,
|
||||
@@ -30,18 +30,32 @@ 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 } from '@deepseek-ai/dsh-session'
|
||||
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 } from './codec.ts'
|
||||
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)
|
||||
@@ -74,7 +88,10 @@ interface SessionRecord {
|
||||
dispose: () => Promise<void>
|
||||
/** In-flight prompt and its captured turn number for exact settlement. */
|
||||
inflight: {
|
||||
cancelled: boolean
|
||||
resolve: (reason: StopReason) => void
|
||||
reject: (error: Error) => void
|
||||
messageId: string
|
||||
turn: number | undefined
|
||||
} | undefined
|
||||
}
|
||||
|
||||
@@ -116,10 +133,18 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
})
|
||||
}
|
||||
|
||||
const cancelPrompt = (record: SessionRecord): void => {
|
||||
const settlePrompt = (record: SessionRecord, reason: StopReason): void => {
|
||||
const inflight = record.inflight
|
||||
if (inflight === undefined) return
|
||||
inflight.cancelled = true
|
||||
record.inflight = undefined
|
||||
inflight.resolve(reason)
|
||||
}
|
||||
|
||||
const rejectFromError = (
|
||||
inflight: NonNullable<SessionRecord['inflight']>,
|
||||
reason: Extract<TurnEndReason, { kind: 'error' }>,
|
||||
): void => {
|
||||
inflight.reject(internalError(`turn failed: ${errorChain(reason.error)}`))
|
||||
}
|
||||
|
||||
// Emit only committed assistant text. Raw chunks, reasoning, tools, plans,
|
||||
@@ -128,21 +153,48 @@ 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
|
||||
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 },
|
||||
},
|
||||
})
|
||||
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/end' && inflight.turn === event.data.turn) {
|
||||
if (event.data.reason.kind === 'error') {
|
||||
record.inflight = undefined
|
||||
rejectFromError(inflight, event.data.reason)
|
||||
} else {
|
||||
record.inflight = undefined
|
||||
inflight.resolve(turnEndToStopReason(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, _step, 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.
|
||||
@@ -223,22 +275,44 @@ 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 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 {
|
||||
record.inflight = undefined
|
||||
}
|
||||
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,
|
||||
}
|
||||
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 */
|
||||
// A turnless slot settles only at quiescence: admission discarded
|
||||
// the prompt before it could open a turn.
|
||||
void record.agent.whenIdle().then(() => {
|
||||
if (record.inflight !== inflight) return
|
||||
record.inflight = undefined
|
||||
inflight.resolve('cancelled')
|
||||
})
|
||||
})
|
||||
return { stopReason }
|
||||
},
|
||||
|
||||
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()
|
||||
},
|
||||
}
|
||||
@@ -257,10 +331,45 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
closed = true
|
||||
const records = [...sessions.values()]
|
||||
sessions.clear()
|
||||
quiescing = Promise.all(records.map(async (record) => {
|
||||
cancelPrompt(record)
|
||||
await record.dispose()
|
||||
})).then(() => {})
|
||||
// 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
|
||||
}
|
||||
|
||||
|
||||
@@ -1,20 +1,24 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { acpPromptToText, promptHasUnsupportedContent } from '../src/codec.ts'
|
||||
import type { TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
import { acpPromptToText, turnEndToStopReason } from '../src/codec.ts'
|
||||
|
||||
describe('ACP automation codec', () => {
|
||||
it('flattens baseline blocks and rejects everything richer', () => {
|
||||
expect(acpPromptToText([{ type: 'text', text: 'a' }, { type: 'text', text: 'b' }])).toBe('ab')
|
||||
expect(acpPromptToText([
|
||||
{ type: 'text', text: 'see' },
|
||||
{ type: 'resource_link', name: 'x', uri: 'file:///x' },
|
||||
])).toBe('see\n[resource_link name="x" uri="file:///x"]\n')
|
||||
expect(acpPromptToText([{ type: 'image', data: '', mimeType: 'image/png' }])).toBe('')
|
||||
expect(promptHasUnsupportedContent([
|
||||
{ type: 'text', text: 'ok' },
|
||||
{ type: 'resource_link', name: 'x', uri: 'file:///x' },
|
||||
])).toBe(false)
|
||||
expect(promptHasUnsupportedContent([
|
||||
{ type: 'image', data: '', mimeType: 'image/png' },
|
||||
])).toBe(true)
|
||||
describe('ACP codec', () => {
|
||||
it.each([
|
||||
[{ kind: 'completed' }, 'end_turn'],
|
||||
[{ kind: 'max-tokens' }, 'max_tokens'],
|
||||
[{ kind: 'aborted', reason: { kind: 'user' } }, 'cancelled'],
|
||||
[{ kind: 'interrupted' }, 'cancelled'],
|
||||
[{ kind: 'blocked' }, 'end_turn'],
|
||||
[{ kind: 'error', error: 'failed' }, 'end_turn'],
|
||||
] satisfies Array<[TurnEndReason, string]>)('maps %o to %s', (reason, expected) => {
|
||||
expect(turnEndToStopReason(reason)).toBe(expected)
|
||||
})
|
||||
|
||||
it('drops unsupported blocks from baseline text conversion', () => {
|
||||
expect(acpPromptToText([{
|
||||
type: 'image',
|
||||
data: '',
|
||||
mimeType: 'image/png',
|
||||
}])).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -67,7 +67,15 @@ describe('ACP connection ownership', () => {
|
||||
const agent = harness.ctx.agents.get(SessionId(sessionId))!
|
||||
void harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }).catch(() => {})
|
||||
await vi.waitFor(() => { expect(agent.status).toBe('running') })
|
||||
harness.ctx.on('agent/cancel-requested', () => { order.push('parent cancelled') })
|
||||
const cancel = agent.cancel.bind(agent)
|
||||
let cancelObserved = false
|
||||
vi.spyOn(agent, 'cancel').mockImplementation((...args) => {
|
||||
if (!cancelObserved) {
|
||||
cancelObserved = true
|
||||
order.push('parent cancelled')
|
||||
}
|
||||
cancel(...args)
|
||||
})
|
||||
|
||||
const disposal = harness.acpFiber.dispose()
|
||||
// A drain can block on persistence, so the bridge's own turn must already be
|
||||
|
||||
@@ -31,28 +31,28 @@ describe('ACP prompt lifecycle', () => {
|
||||
harness = undefined
|
||||
})
|
||||
|
||||
it('settles after a max-token turn without losing its committed text', async () => {
|
||||
it('maps a max-token turn without losing its committed text', async () => {
|
||||
harness = await makeBridgeHarness({ script: [maxTokensResponse('cut off')] })
|
||||
const sessionId = await newSession(harness)
|
||||
const result = await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] })
|
||||
expect(result.stopReason).toBe('end_turn')
|
||||
expect(result.stopReason).toBe('max_tokens')
|
||||
await vi.waitFor(() => { expect(messageText(harness!)).toBe('cut off') })
|
||||
})
|
||||
|
||||
it('settles after a failed turn and never publishes its partial chunks', async () => {
|
||||
it('rejects a failed turn and never publishes its partial chunks', async () => {
|
||||
harness = await makeBridgeHarness({ script: [errorResponse('provider boom')] })
|
||||
const sessionId = await newSession(harness)
|
||||
await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }))
|
||||
.resolves.toEqual({ stopReason: 'end_turn' })
|
||||
.rejects.toThrow(/turn failed: provider boom/)
|
||||
expect(messageText(harness)).toBe('')
|
||||
})
|
||||
|
||||
it('settles after an ordinary plugin failure', async () => {
|
||||
it('rejects an ordinary plugin failure through the same prompt boundary', async () => {
|
||||
harness = await makeBridgeHarness({ script: [textResponse('must not run')] })
|
||||
harness.ctx.on('agent/pre-step', () => { throw new Error('plugin pre-step failed') })
|
||||
const sessionId = await newSession(harness)
|
||||
await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }))
|
||||
.resolves.toEqual({ stopReason: 'end_turn' })
|
||||
.rejects.toThrow(/turn failed: plugin pre-step failed/)
|
||||
})
|
||||
|
||||
it('settles even when an earlier turn observer throws', async () => {
|
||||
@@ -221,13 +221,13 @@ describe('ACP prompt lifecycle', () => {
|
||||
await vi.waitFor(() => { expect(messageText(harness!)).toBe('recovered') })
|
||||
})
|
||||
|
||||
it('a failed turn with no retry settles at quiescence', async () => {
|
||||
it('a failed turn with no retry still rejects', async () => {
|
||||
harness = await makeBridgeHarness({ script: [errorResponse('terminal boom')] })
|
||||
let offered = 0
|
||||
harness.ctx.on('agent/request-error', async () => { offered += 1 })
|
||||
const sessionId = await newSession(harness)
|
||||
await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }))
|
||||
.resolves.toEqual({ stopReason: 'end_turn' })
|
||||
.rejects.toThrow(/turn failed: terminal boom/)
|
||||
expect(offered).toBe(1)
|
||||
})
|
||||
|
||||
@@ -238,7 +238,7 @@ describe('ACP prompt lifecycle', () => {
|
||||
}))
|
||||
const sessionId = await newSession(harness)
|
||||
await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }))
|
||||
.resolves.toEqual({ stopReason: 'end_turn' })
|
||||
.resolves.toEqual({ stopReason: 'cancelled' })
|
||||
// The rejected prompt opened no turn and streamed nothing.
|
||||
expect(messageText(harness)).toBe('')
|
||||
})
|
||||
@@ -249,6 +249,6 @@ describe('ACP prompt lifecycle', () => {
|
||||
const sessionId = await newSession(harness)
|
||||
|
||||
await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }))
|
||||
.resolves.toEqual({ stopReason: 'end_turn' })
|
||||
.rejects.toThrow(/turn failed: pre-step exploded/)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user