fix: make paired evidence loss visible (#202)

This commit is contained in:
Eyejoker
2026-05-31 16:20:09 +09:00
committed by GitHub
parent 6eca648c47
commit 239c7ff1e6
19 changed files with 644 additions and 67 deletions

View File

@@ -3,6 +3,7 @@ export const OUTPUT_END_MARKER = '---EJCLAW_OUTPUT_END---';
export const IMAGE_TAG_RE =
/\[Image:\s*(?:(?:[^\]\n]*?)\s*→\s*)?(\/[^\]\n]+)\]/g;
const IMAGE_TAG_SEGMENT_RE = /\[Image:\s*(?:(.*?)\s*→\s*)?(\/[^\]\n]+)\]/g;
const IMAGE_EXT_RE = /\.(png|jpe?g|gif|webp|bmp)$/i;
const MARKDOWN_ABSOLUTE_LINK_RE = /!?\[[^\]\n]*\]\((\/[^)\n]+)\)/g;
const MEDIA_TAG_RE =
@@ -63,6 +64,10 @@ function cloneImageTagPattern(): RegExp {
return new RegExp(IMAGE_TAG_RE.source, IMAGE_TAG_RE.flags);
}
function cloneImageTagSegmentPattern(): RegExp {
return new RegExp(IMAGE_TAG_SEGMENT_RE.source, IMAGE_TAG_SEGMENT_RE.flags);
}
export function writeProtocolOutput<T>(
output: T,
writeLine: (line: string) => void = console.log,
@@ -87,6 +92,51 @@ export function extractImageTagPaths(text: string): {
};
}
export type ImageTagPromptPart =
| { type: 'text'; text: string }
| { type: 'image'; label: string | null; path: string; raw: string };
export function splitImageTagPromptParts(text: string): ImageTagPromptPart[] {
const imagePattern = cloneImageTagSegmentPattern();
const parts: ImageTagPromptPart[] = [];
let cursor = 0;
for (const match of text.matchAll(imagePattern)) {
const start = match.index ?? 0;
if (start > cursor) {
parts.push({ type: 'text', text: text.slice(cursor, start) });
}
const raw = match[0];
const label = match[1]?.trim() || null;
const imagePath = match[2].trim();
parts.push({ type: 'image', label, path: imagePath, raw });
cursor = start + raw.length;
}
if (cursor < text.length) {
parts.push({ type: 'text', text: text.slice(cursor) });
}
return parts.length > 0 ? parts : [{ type: 'text', text }];
}
export function imageTagCaption(
part: Extract<ImageTagPromptPart, { type: 'image' }>,
): string {
return part.label
? `Image evidence: ${part.label}`
: `Image evidence: ${part.path}`;
}
export function missingImageTagCaption(
part: Extract<ImageTagPromptPart, { type: 'image' }>,
reason: string,
): string {
const label = part.label ? `${part.label}` : '';
return `[Image unavailable: ${label}${part.path}${reason}]`;
}
function attachmentName(filePath: string): string | undefined {
return filePath.split(/[\\/]/).at(-1) || undefined;
}

View File

@@ -41,15 +41,18 @@ export {
extractMarkdownImageAttachments,
extractMediaAttachments,
extractImageTagPaths,
imageTagCaption,
IMAGE_TAG_RE,
IPC_CLOSE_SENTINEL,
IPC_INPUT_SUBDIR,
IPC_POLL_MS,
missingImageTagCaption,
normalizeAgentOutput,
normalizeEjclawStructuredOutput,
normalizePublicTextOutput,
OUTPUT_END_MARKER,
OUTPUT_START_MARKER,
splitImageTagPromptParts,
writeProtocolOutput,
type NormalizedRunnerOutput,
type NormalizedAgentOutput,

View File

@@ -2,8 +2,11 @@ import { describe, expect, it } from 'vitest';
import {
extractImageTagPaths,
imageTagCaption,
missingImageTagCaption,
normalizeEjclawStructuredOutput,
normalizePublicTextOutput,
splitImageTagPromptParts,
writeProtocolOutput,
} from '../src/agent-protocol.js';
@@ -25,6 +28,37 @@ describe('shared agent protocol helpers', () => {
});
});
it('splits labeled image tags for multimodal prompt builders', () => {
expect(
splitImageTagPromptParts(
'before [Image: screenshot.png → /tmp/a.png] after',
),
).toEqual([
{ type: 'text', text: 'before ' },
{
type: 'image',
label: 'screenshot.png',
path: '/tmp/a.png',
raw: '[Image: screenshot.png → /tmp/a.png]',
},
{ type: 'text', text: ' after' },
]);
});
it('formats image evidence captions and missing-image warnings', () => {
const part = {
type: 'image' as const,
label: 'screenshot.png',
path: '/tmp/a.png',
raw: '[Image: screenshot.png → /tmp/a.png]',
};
expect(imageTagCaption(part)).toBe('Image evidence: screenshot.png');
expect(missingImageTagCaption(part, 'file not found')).toBe(
'[Image unavailable: screenshot.png → /tmp/a.png — file not found]',
);
});
it('normalizes plain text runner output as public text', () => {
expect(normalizePublicTextOutput('DONE')).toEqual({
result: 'DONE',