mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-11 11:43:17 +08:00
fix(im): fall back to workspace-file delivery when non-vision models reject images
This commit is contained in:
parent
e8c9817598
commit
0851e8cf30
6 changed files with 607 additions and 30 deletions
|
|
@ -7,6 +7,12 @@ import {
|
|||
appendInboundFilesToPrompt,
|
||||
InboundFileError,
|
||||
} from './inbound-file.mjs';
|
||||
import {
|
||||
IMAGE_FILE_FALLBACK_PROMPT,
|
||||
contentWithoutImages,
|
||||
imageFileSourcesFromContent,
|
||||
isModelImageRejection,
|
||||
} from './image-prompt.mjs';
|
||||
import { outboundArtifactRegistry } from './semantic/artifact.mjs';
|
||||
import { t } from './i18n.mjs';
|
||||
import { watchHarnessMux } from './harness-mux.mjs';
|
||||
|
|
@ -1236,6 +1242,31 @@ export class HarnessClient {
|
|||
return ownership ? { ownership, recovered: true } : null;
|
||||
}
|
||||
|
||||
/** Stage inbound file sources into the Session workspace via the Host executor. */
|
||||
async #stageWorkspaceFiles(sessionId, files, signal) {
|
||||
if (!this.#fileIngressExecutor) {
|
||||
throw new InboundFileError(
|
||||
'inbound-file-ingress-unavailable',
|
||||
'Harness file ingress is unavailable in this Host process.',
|
||||
);
|
||||
}
|
||||
const sessionList = await this.rpc(
|
||||
'session.list',
|
||||
{},
|
||||
30_000,
|
||||
{ signal },
|
||||
);
|
||||
const sessionWorkspace = sessionList?.items?.find(
|
||||
(item) => item?.sessionId === sessionId,
|
||||
)?.cwd;
|
||||
return this.#fileIngressExecutor({
|
||||
sessionId,
|
||||
workspace: sessionWorkspace,
|
||||
files,
|
||||
signal,
|
||||
});
|
||||
}
|
||||
|
||||
async ask(sessionId, prompt, options = {}) {
|
||||
if (typeof options === 'number') options = { timeoutMs: options };
|
||||
const timeoutMs = options.timeoutMs ?? 600_000;
|
||||
|
|
@ -1292,7 +1323,7 @@ export class HarnessClient {
|
|||
let interactionTask = null;
|
||||
let artifactsDelivered = false;
|
||||
let deliveredArtifactCount = 0;
|
||||
let stagedInboundFiles = null;
|
||||
const stagedBatches = [];
|
||||
let promptAccepted = false;
|
||||
let turnFinished = false;
|
||||
|
||||
|
|
@ -1323,29 +1354,11 @@ export class HarnessClient {
|
|||
const closeArtifactConsumer = outboundArtifactRegistry.openConsumer(sessionId, promptRpcId);
|
||||
|
||||
try {
|
||||
const basePrompt = prompt;
|
||||
if (inboundFiles.length > 0) {
|
||||
if (!this.#fileIngressExecutor) {
|
||||
throw new InboundFileError(
|
||||
'inbound-file-ingress-unavailable',
|
||||
'Harness file ingress is unavailable in this Host process.',
|
||||
);
|
||||
}
|
||||
const sessionList = await this.rpc(
|
||||
'session.list',
|
||||
{},
|
||||
30_000,
|
||||
{ signal },
|
||||
);
|
||||
const sessionWorkspace = sessionList?.items?.find(
|
||||
(item) => item?.sessionId === sessionId,
|
||||
)?.cwd;
|
||||
stagedInboundFiles = await this.#fileIngressExecutor({
|
||||
sessionId,
|
||||
workspace: sessionWorkspace,
|
||||
files: inboundFiles,
|
||||
signal,
|
||||
});
|
||||
prompt = appendInboundFilesToPrompt(prompt, stagedInboundFiles);
|
||||
const staged = await this.#stageWorkspaceFiles(sessionId, inboundFiles, signal);
|
||||
stagedBatches.push(staged);
|
||||
prompt = appendInboundFilesToPrompt(prompt, staged);
|
||||
}
|
||||
if (interactionSignal) {
|
||||
let markOpen;
|
||||
|
|
@ -1372,12 +1385,47 @@ export class HarnessClient {
|
|||
if (!Array.isArray(content) || content.length === 0) {
|
||||
throw new TypeError('Harness prompt content is required');
|
||||
}
|
||||
await this.rpc('session.prompt', {
|
||||
const clientTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
const sendPrompt = (promptContent) => this.rpc('session.prompt', {
|
||||
sessionId,
|
||||
mode: 'queue',
|
||||
content,
|
||||
clientTimeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
content: promptContent,
|
||||
clientTimeZone,
|
||||
}, 30_000, { rpcId: promptRpcId, signal });
|
||||
try {
|
||||
await sendPrompt(content);
|
||||
} catch (error) {
|
||||
// The Host refuses image blocks for a non-vision model before any
|
||||
// durable user message exists. Re-deliver the same bytes the way
|
||||
// ordinary uploads (zip, documents) already travel — staged into the
|
||||
// Session workspace and named in a text manifest — then retry once
|
||||
// with a text-only prompt. The retry reuses promptRpcId so reply
|
||||
// tracking, control and interaction ownership stay bound to this ask.
|
||||
const imageSources = isModelImageRejection(error)
|
||||
? imageFileSourcesFromContent(content)
|
||||
: [];
|
||||
if (imageSources.length === 0) throw error;
|
||||
let stagedImages;
|
||||
try {
|
||||
stagedImages = await this.#stageWorkspaceFiles(sessionId, imageSources, signal);
|
||||
} catch (stagingError) {
|
||||
if (signal?.aborted) throw error;
|
||||
console.warn(
|
||||
`[${this.#logPrefix}] unable to restage rejected images as workspace files:`,
|
||||
stagingError?.message ?? String(stagingError),
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
stagedBatches.push(stagedImages);
|
||||
const baseContent = typeof basePrompt === 'string'
|
||||
? [{ type: 'text', text: basePrompt }]
|
||||
: basePrompt;
|
||||
const fallbackPrompt = appendInboundFilesToPrompt([
|
||||
...contentWithoutImages(baseContent),
|
||||
{ type: 'text', text: t(IMAGE_FILE_FALLBACK_PROMPT) },
|
||||
], { files: stagedBatches.flatMap((batch) => batch?.files ?? []) });
|
||||
await sendPrompt(fallbackPrompt);
|
||||
}
|
||||
promptAccepted = true;
|
||||
|
||||
try {
|
||||
|
|
@ -1435,10 +1483,14 @@ export class HarnessClient {
|
|||
throw turnStoppedError();
|
||||
}
|
||||
} finally {
|
||||
if (stagedInboundFiles && (!promptAccepted || turnFinished)) {
|
||||
await stagedInboundFiles.cleanup().catch((error) => {
|
||||
console.warn(`[${this.#logPrefix}] unable to clean inbound files:`, error.message);
|
||||
});
|
||||
if (!promptAccepted || turnFinished) {
|
||||
for (const staged of stagedBatches) {
|
||||
try {
|
||||
await staged?.cleanup?.();
|
||||
} catch (error) {
|
||||
console.warn(`[${this.#logPrefix}] unable to clean inbound files:`, error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
closeArtifactConsumer();
|
||||
if (ownership) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue