feat(session): add cross-session references
This commit is contained in:
@@ -5,6 +5,12 @@
|
||||
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import type { TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
import {
|
||||
SESSION_REFERENCE_SCHEME,
|
||||
decodeSessionReferenceUri,
|
||||
parseSessionReferenceText,
|
||||
type SessionReferenceInput,
|
||||
} from '@deepseek-ai/dsh-session-reference'
|
||||
import type { ContentBlock as AcpContentBlock, StopReason } from '@agentclientprotocol/sdk'
|
||||
|
||||
/**
|
||||
@@ -81,6 +87,45 @@ export function acpPromptToText(prompt: readonly AcpContentBlock[]): string {
|
||||
.join('')
|
||||
}
|
||||
|
||||
/** ACP prompt text plus structured session references extracted from text and resource links. */
|
||||
export interface AcpReferencedPrompt {
|
||||
/** Readable prompt text with opaque session URIs removed. */
|
||||
text: string
|
||||
/** Structured session references in ACP block and inline appearance order. */
|
||||
references: SessionReferenceInput[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract canonical session references while preserving ordinary ACP resource links.
|
||||
* @param prompt - already-supported ACP prompt blocks.
|
||||
* @returns readable text and structured references.
|
||||
* @throws when any observed `dsh-session:` URI is malformed.
|
||||
*/
|
||||
export function acpPromptToReferencedPrompt(prompt: readonly AcpContentBlock[]): AcpReferencedPrompt {
|
||||
const references: SessionReferenceInput[] = []
|
||||
const text = prompt.flatMap((block): string[] => {
|
||||
switch (block.type) {
|
||||
case 'text': {
|
||||
const parsed = parseSessionReferenceText(block.text)
|
||||
references.push(...parsed.references)
|
||||
return [parsed.text]
|
||||
}
|
||||
case 'resource_link': {
|
||||
if (!block.uri.startsWith(SESSION_REFERENCE_SCHEME)) {
|
||||
return [`\n[resource_link name=${JSON.stringify(block.name)} uri=${JSON.stringify(block.uri)}]\n`]
|
||||
}
|
||||
const sessionId = decodeSessionReferenceUri(block.uri)
|
||||
const label = block.name === '' ? sessionId : block.name
|
||||
references.push({ sessionId, label })
|
||||
return [`@${label}`]
|
||||
}
|
||||
default:
|
||||
return []
|
||||
}
|
||||
}).join('')
|
||||
return { text, references }
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an ACP prompt contains content the bridge cannot accept. Baseline ACP
|
||||
* requires `text` and `resource_link`; richer inline payloads (`resource`,
|
||||
|
||||
@@ -49,6 +49,7 @@ import { assertNever, CallId } from '@deepseek-ai/dsh-llm'
|
||||
import type {} from '@deepseek-ai/dsh-llm-retry'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type {} from '@deepseek-ai/dsh-commands'
|
||||
import type {} from '@deepseek-ai/dsh-session-reference'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
// Side-effect type import: resolves `ctx.get('permission')` to the service.
|
||||
import type {} from '@deepseek-ai/dsh-permission'
|
||||
@@ -72,7 +73,7 @@ import {
|
||||
type AskUserQuestionRequest,
|
||||
} from '@deepseek-ai/dsh-user-interaction'
|
||||
import {
|
||||
acpPromptToText,
|
||||
acpPromptToReferencedPrompt,
|
||||
harnessBlockToAcpContent,
|
||||
promptHasUnsupportedContent,
|
||||
turnEndToStopReason,
|
||||
@@ -302,6 +303,8 @@ interface SessionRecord {
|
||||
} | undefined
|
||||
/** Abort owner for a direct slash-command request, mutually exclusive with `inflight`. */
|
||||
commandAbort: AbortController | undefined
|
||||
/** Abort owner while referenced sessions are snapshotted before enqueue. */
|
||||
promptPreparation: AbortController | undefined
|
||||
/** Last idle switch per knob, anchored before the next prompt assembles. */
|
||||
pendingSwitches: { preset?: string }
|
||||
}
|
||||
@@ -765,6 +768,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
target,
|
||||
inflight: undefined,
|
||||
commandAbort: undefined,
|
||||
promptPreparation: undefined,
|
||||
pendingSwitches: {},
|
||||
}
|
||||
sessions.set(sessionId, record)
|
||||
@@ -850,6 +854,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
target,
|
||||
inflight: undefined,
|
||||
commandAbort: undefined,
|
||||
promptPreparation: undefined,
|
||||
pendingSwitches: {},
|
||||
}
|
||||
sessions.set(sessionId, record)
|
||||
@@ -885,13 +890,19 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
async prompt(params: PromptRequest): Promise<PromptResponse> {
|
||||
assertOpen()
|
||||
const rec = requireSession(SessionId(params.sessionId))
|
||||
if (rec.inflight !== undefined || rec.commandAbort !== undefined) {
|
||||
if (rec.inflight !== undefined || rec.commandAbort !== undefined || rec.promptPreparation !== 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; image/audio/embedded resource blocks are rejected rather than silently dropped')
|
||||
}
|
||||
const text = acpPromptToText(params.prompt)
|
||||
let referencedPrompt: ReturnType<typeof acpPromptToReferencedPrompt>
|
||||
try {
|
||||
referencedPrompt = acpPromptToReferencedPrompt(params.prompt)
|
||||
} catch (error: unknown) {
|
||||
throw invalidParams(`invalid session reference: ${renderThrown(error)}`)
|
||||
}
|
||||
const { text } = referencedPrompt
|
||||
if (text.trim().length === 0) {
|
||||
// Reject up front rather than calling send(): an empty prompt would
|
||||
// queue no work, no turn would start, and the RPC would hang forever
|
||||
@@ -944,6 +955,32 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
rec.commandAbort = undefined
|
||||
}
|
||||
}
|
||||
let preparedContent: ContentBlock[] = [{ type: 'text', text }]
|
||||
let preparedContexts: NonNullable<Parameters<Agent['send']>[1]>['contexts'] = []
|
||||
if (referencedPrompt.references.length > 0) {
|
||||
const sessionReferences = ctx.get('sessionReferences')
|
||||
if (sessionReferences === undefined) {
|
||||
throw invalidParams('session reference capability unavailable')
|
||||
}
|
||||
const controller = new AbortController()
|
||||
rec.promptPreparation = controller
|
||||
try {
|
||||
const prepared = await sessionReferences.prepare(
|
||||
rec.agent,
|
||||
preparedContent,
|
||||
referencedPrompt.references,
|
||||
controller.signal,
|
||||
)
|
||||
preparedContent = prepared.content
|
||||
preparedContexts = prepared.contexts
|
||||
} catch (error: unknown) {
|
||||
if (controller.signal.aborted) return { stopReason: 'cancelled' }
|
||||
throw invalidParams(`session reference preparation failed: ${renderThrown(error)}`)
|
||||
} finally {
|
||||
rec.promptPreparation = undefined
|
||||
}
|
||||
assertOpen()
|
||||
}
|
||||
// Install the in-flight slot BEFORE send() (send does not synchronously
|
||||
// flip status to running; the session/event listener records the turn
|
||||
// number and settle/rejects it). Capture the log length now as the
|
||||
@@ -951,7 +988,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
// produces an error stop reason).
|
||||
const stopReason = await new Promise<StopReason>((resolve, reject) => {
|
||||
rec.inflight = { resolve, reject, turn: undefined }
|
||||
rec.agent.send([{ type: 'text', text }])
|
||||
rec.agent.send(preparedContent, { contexts: preparedContexts })
|
||||
})
|
||||
return { stopReason }
|
||||
},
|
||||
@@ -971,7 +1008,9 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
// settle it, because cancel() may drop the turn before any turn/end is
|
||||
// emitted, and removing this direct settle would move the RPC's
|
||||
// resolution onto a later observer path, changing its timing.
|
||||
if (rec.commandAbort !== undefined) {
|
||||
if (rec.promptPreparation !== undefined) {
|
||||
rec.promptPreparation.abort(new Error('session/cancel'))
|
||||
} else if (rec.commandAbort !== undefined) {
|
||||
rec.commandAbort.abort(new Error('session/cancel'))
|
||||
} else {
|
||||
rec.agent.cancel('session/cancel')
|
||||
@@ -1092,6 +1131,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
await Promise.all(recs.map(async (rec) => {
|
||||
settlePrompt(rec, 'cancelled')
|
||||
rec.commandAbort?.abort(new Error('ACP connection closed'))
|
||||
rec.promptPreparation?.abort(new Error('ACP connection closed'))
|
||||
// Per-agent dispose (the AgentHandle disposer): unregister this agent,
|
||||
// stop its loop (sets disposed + aborts the in-flight step), await
|
||||
// quiescence (the loop exit + final flush), and remove its session — so
|
||||
|
||||
Reference in New Issue
Block a user