Files
deepseek-harness/packages/llm/llm-pi-ai/src/context.ts
creatixchu 0d1250f743 fix: address ds-review-bot v7 findings on the merged image-input head
- gate model selection on steering-placement image carriers from enqueue
  until their steering/message event publishes; release the gate when an
  admission ends idle without publication (both behaviorally asserted)
- reject session.updateQueue edits carrying non-text blocks at the RPC
  boundary (queue edits cannot bypass image admission)
- extend the durable-directory walk past a first-created DSH_HOME to the
  deepest pre-existing ancestor
- strip Windows-style separators from attachment display names on POSIX
- verify attachment reads with a header-only probe (digest already proves
  the bytes decoded fully at admission); document the read path
- make SessionInputShell.addImages refusal observable and keep workspace
  transfers/composer intake from leaking refused drafts
- own ONE recursive image walk (dsh-llm contentHasImage) across apiproxy,
  pi-ai, compact-basic, and the DeepSeek text-only assertion
- drop the redundant canonical-base64 regex and the no-op role read
- move AttachmentId/AttachmentError out of types.ts (brand.ts/error.ts);
  document why AttachmentError does not extend HarnessError
- document the hard attachments inject in both consumer READMEs
2026-07-30 14:34:08 +08:00

190 lines
7.3 KiB
TypeScript

/**
* Harness request-history conversion into pi-ai's Context vocabulary.
*
* @module dsh-llm-pi-ai/context
*/
import { CallId, contentHasImage, LlmError } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, GenerateOptions, Message } from '@deepseek-ai/dsh-llm'
import type { AttachmentStore } from '@deepseek-ai/dsh-attachment'
import type { Context as PiContext, ImageContent, Message as PiMessage, TextContent, Tool as PiTool } from '@earendil-works/pi-ai'
import { toPiAssistant } from './replay.ts'
/** Join the text blocks of a harness message. */
function flattenText(message: Message): string {
return message.content
.filter(block => block.type === 'text')
.map(block => block.text)
.join('')
}
/** Flatten text recursively inside one tool result. */
function toolResultText(blocks: readonly ContentBlock[]): string {
return blocks.map(block => block.type === 'text'
? block.text
: block.type === 'tool-result' ? toolResultText(block.content) : '').join('')
}
async function userContent(
blocks: readonly ContentBlock[],
attachments: AttachmentStore,
): Promise<string | (TextContent | ImageContent)[]> {
const content: (TextContent | ImageContent)[] = []
for (const block of blocks) {
switch (block.type) {
case 'text':
if (block.text.length > 0) content.push({ type: 'text', text: block.text })
break
case 'image': {
const stored = await attachments.readImage(block.attachment)
content.push({
type: 'image',
data: Buffer.from(stored.data).toString('base64'),
mimeType: stored.ref.mediaType,
})
break
}
case 'tool-result':
{
const nested = await userContent(block.content, attachments)
if (typeof nested === 'string') {
if (nested.length > 0) content.push({ type: 'text', text: nested })
} else {
content.push(...nested)
}
}
break
default:
// Other merge-extensible blocks are not user-input vocabulary for pi-ai.
break
}
}
if (content.every(block => block.type === 'text')) return content.map(block => block.text).join('')
return content
}
function toolsOf(options: GenerateOptions): PiTool[] | undefined {
return options.tools?.map(tool => ({
name: tool.name,
description: tool.description,
// ToolSchema.parameters is a JSON Schema object; pi-ai's TSchema
// (TypeBox) is structurally JSON Schema, so it assigns directly.
parameters: tool.parameters,
}))
}
/** Assemble the request-level pi-ai context envelope shared by both conversion paths. */
function piContext(options: GenerateOptions, messages: PiMessage[]): PiContext {
const tools = toolsOf(options)
return {
...options.system !== undefined ? { systemPrompt: options.system } : {},
messages,
...tools !== undefined && tools.length > 0 ? { tools } : {},
}
}
function textOnlyContext(options: GenerateOptions): PiContext {
const toolNames = new Map<CallId, string>()
const messages: PiMessage[] = []
for (const message of options.messages) {
if (contentHasImage(message.content)) {
throw new LlmError('pi-ai image conversion requires the durable attachment service', 'UNSUPPORTED_CONTENT')
}
if (message.role === 'system') {
messages.push({ role: 'user', content: flattenText(message), timestamp: 0 })
continue
}
if (message.role === 'assistant') {
const assistant = toPiAssistant(message)
for (const block of assistant.content) if (block.type === 'toolCall') toolNames.set(CallId(block.id), block.name)
messages.push(assistant)
continue
}
const text = flattenText(message)
const results = message.content.filter(block => block.type === 'tool-result')
if (text.length > 0 || results.length === 0) messages.push({ role: 'user', content: text, timestamp: 0 })
for (const result of results) {
messages.push({
role: 'toolResult',
toolCallId: result.toolCallId,
toolName: toolNames.get(result.toolCallId) ?? 'unknown',
content: [{
type: 'text',
text: toolResultText(result.content) || '(no output)',
}],
isError: result.isError ?? false,
timestamp: 0,
})
}
}
return piContext(options, messages)
}
/**
* Convert text-only harness history to a synchronous pi-ai Context. Tool
* result names are recovered from preceding assistant tool calls.
* @param options - the harness request; `options.system` maps to pi-ai's single `systemPrompt` slot.
* @returns the pi-ai context; `tools` is omitted when the request declares none.
*/
export function toPiContext(options: GenerateOptions): PiContext
/**
* Convert harness history to a pi-ai Context while resolving durable images.
* Tool result names are recovered from preceding assistant tool calls.
* @param options - the harness request; `options.system` maps to pi-ai's single `systemPrompt` slot.
* @param attachments - durable byte resolver for image references.
* @returns the asynchronously resolved pi-ai context.
*/
export function toPiContext(options: GenerateOptions, attachments: AttachmentStore): Promise<PiContext>
export function toPiContext(options: GenerateOptions, attachments?: AttachmentStore): PiContext | Promise<PiContext> {
return attachments === undefined ? textOnlyContext(options) : toPiContextWithImages(options, attachments)
}
async function toPiContextWithImages(options: GenerateOptions, attachments: AttachmentStore): Promise<PiContext> {
const toolNames = new Map<CallId, string>()
const messages: PiMessage[] = []
for (const message of options.messages) {
if (message.role === 'system') {
if (contentHasImage(message.content)) {
throw new LlmError('pi-ai cannot represent an image in an in-history system message', 'UNSUPPORTED_CONTENT')
}
// pi-ai has a single systemPrompt slot; in-history system messages are
// folded into user messages to preserve order (rare in practice — the
// harness sends the system prompt via options.system).
messages.push({ role: 'user', content: flattenText(message), timestamp: 0 })
continue
}
if (message.role === 'assistant') {
const assistant = toPiAssistant(message)
for (const block of assistant.content) {
if (block.type === 'toolCall') toolNames.set(CallId(block.id), block.name)
}
messages.push(assistant)
continue
}
// user role: text + tool results (each result becomes its own message).
const regular = message.content.filter(block => block.type !== 'tool-result')
const content = await userContent(regular, attachments)
const results = message.content.filter(block => block.type === 'tool-result')
if (content.length > 0 || results.length === 0) {
messages.push({ role: 'user', content, timestamp: 0 })
}
for (const result of results) {
const resultContent = await userContent(result.content, attachments)
messages.push({
role: 'toolResult',
toolCallId: result.toolCallId,
toolName: toolNames.get(result.toolCallId) ?? 'unknown',
content: typeof resultContent === 'string'
? [{ type: 'text', text: resultContent || '(no output)' }]
: resultContent,
isError: result.isError ?? false,
timestamp: 0,
})
}
}
return piContext(options, messages)
}