- 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
190 lines
7.3 KiB
TypeScript
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)
|
|
}
|