fix: load supported document attachments (#205)

This commit is contained in:
Eyejoker
2026-05-31 22:30:49 +09:00
committed by GitHub
parent 778ed9b94a
commit b6e7e060cc
11 changed files with 398 additions and 30 deletions

View File

@@ -5,6 +5,10 @@ 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 MODEL_DOCUMENT_EXT_RE =
/\.(pdf|txt|md|markdown|csv|json|log|xml|yaml|yml|toml|ini|cfg|conf)$/i;
const PROMPT_ATTACHMENT_TAG_SEGMENT_RE =
/\[(Image|File|Document|Attachment):\s*(?:(.*?)\s*→\s*)?(\/[^\]\n]+)\]/g;
const MARKDOWN_IMAGE_ABSOLUTE_LINK_RE = /!\[[^\]\n]*\]\((\/[^)\n]+)\)/g;
const MEDIA_TAG_RE =
/^[ \t]*MEDIA:\s*(?:"([^"\n]+)"|'([^'\n]+)'|`([^`\n]+)`|(\/\S+))[ \t]*$/gm;
@@ -122,10 +126,61 @@ export function expandImagePromptReferences(text: string): string {
);
}
export function isModelDocumentPath(filePath: string): boolean {
return MODEL_DOCUMENT_EXT_RE.test(filePath);
}
function promptReferenceTag(filePath: string): 'Image' | 'File' | null {
if (IMAGE_EXT_RE.test(filePath)) return 'Image';
if (MODEL_DOCUMENT_EXT_RE.test(filePath)) return 'File';
return null;
}
export function expandPromptAttachmentReferences(text: string): string {
const codeSpans = fencedCodeSpans(text);
const withMediaAttachments = text.replace(
MEDIA_TAG_RE,
(full: string, doubleQuoted, singleQuoted, backticked, bare, offset) => {
if (isInsideSpans(offset, codeSpans)) return full;
const filePath = String(
doubleQuoted ?? singleQuoted ?? backticked ?? bare ?? '',
).trim();
if (!filePath.startsWith('/')) return full;
const tag = promptReferenceTag(filePath);
if (!tag) return full;
const name = attachmentName(filePath) ?? filePath;
return `[${tag}: ${name}${filePath}]`;
},
);
const markdownCodeSpans = fencedCodeSpans(withMediaAttachments);
return withMediaAttachments.replace(
MARKDOWN_IMAGE_ABSOLUTE_LINK_RE,
(full: string, rawPath: string, offset: number) => {
if (isInsideSpans(offset, markdownCodeSpans)) return full;
const filePath = rawPath.trim();
if (!IMAGE_EXT_RE.test(filePath)) return full;
const name = attachmentName(filePath) ?? filePath;
return `[Image: ${name}${filePath}]`;
},
);
}
export type ImageTagPromptPart =
| { type: 'text'; text: string }
| { type: 'image'; label: string | null; path: string; raw: string };
export type PromptAttachmentPart =
| { type: 'text'; text: string }
| {
type: 'attachment';
kind: 'image' | 'document' | 'unsupported';
label: string | null;
path: string;
raw: string;
tag: 'Image' | 'File' | 'Document' | 'Attachment';
};
export function splitImageTagPromptParts(text: string): ImageTagPromptPart[] {
const imagePattern = cloneImageTagSegmentPattern();
const parts: ImageTagPromptPart[] = [];
@@ -151,6 +206,49 @@ export function splitImageTagPromptParts(text: string): ImageTagPromptPart[] {
return parts.length > 0 ? parts : [{ type: 'text', text }];
}
export function splitPromptAttachmentParts(
text: string,
): PromptAttachmentPart[] {
const pattern = new RegExp(
PROMPT_ATTACHMENT_TAG_SEGMENT_RE.source,
PROMPT_ATTACHMENT_TAG_SEGMENT_RE.flags,
);
const parts: PromptAttachmentPart[] = [];
let cursor = 0;
for (const match of text.matchAll(pattern)) {
const start = match.index ?? 0;
if (start > cursor) {
parts.push({ type: 'text', text: text.slice(cursor, start) });
}
const raw = match[0];
const tag = match[1] as 'Image' | 'File' | 'Document' | 'Attachment';
const label = match[2]?.trim() || null;
const filePath = match[3].trim();
const kind = IMAGE_EXT_RE.test(filePath)
? 'image'
: MODEL_DOCUMENT_EXT_RE.test(filePath)
? 'document'
: 'unsupported';
parts.push({
type: 'attachment',
kind,
label,
path: filePath,
raw,
tag,
});
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 {
@@ -167,6 +265,34 @@ export function missingImageTagCaption(
return `[Image unavailable: ${label}${part.path}${reason}]`;
}
export function attachmentEvidenceCaption(
part: Extract<PromptAttachmentPart, { type: 'attachment' }>,
): string {
const subject =
part.kind === 'image'
? 'Image'
: part.kind === 'document'
? 'Document'
: 'Attachment';
return part.label
? `${subject} evidence: ${part.label}`
: `${subject} evidence: ${part.path}`;
}
export function missingAttachmentCaption(
part: Extract<PromptAttachmentPart, { type: 'attachment' }>,
reason: string,
): string {
const subject =
part.kind === 'image'
? 'Image'
: part.kind === 'document'
? 'Document'
: 'Attachment';
const label = part.label ? `${part.label}` : '';
return `[${subject} unavailable: ${label}${part.path}${reason}]`;
}
function attachmentName(filePath: string): string | undefined {
return filePath.split(/[\\/]/).at(-1) || undefined;
}

View File

@@ -38,6 +38,8 @@ export {
type TaskContextMode,
} from './task-runtime.js';
export {
attachmentEvidenceCaption,
expandPromptAttachmentReferences,
expandImagePromptReferences,
extractMarkdownImageAttachments,
extractMediaAttachments,
@@ -47,14 +49,18 @@ export {
IPC_CLOSE_SENTINEL,
IPC_INPUT_SUBDIR,
IPC_POLL_MS,
isModelDocumentPath,
missingAttachmentCaption,
missingImageTagCaption,
normalizeAgentOutput,
normalizeEjclawStructuredOutput,
normalizePublicTextOutput,
splitPromptAttachmentParts,
OUTPUT_END_MARKER,
OUTPUT_START_MARKER,
splitImageTagPromptParts,
writeProtocolOutput,
type PromptAttachmentPart,
type NormalizedRunnerOutput,
type NormalizedAgentOutput,
type RunnerOutputPhase,

View File

@@ -1,12 +1,16 @@
import { describe, expect, it } from 'vitest';
import {
attachmentEvidenceCaption,
expandPromptAttachmentReferences,
expandImagePromptReferences,
extractImageTagPaths,
imageTagCaption,
missingAttachmentCaption,
missingImageTagCaption,
normalizeEjclawStructuredOutput,
normalizePublicTextOutput,
splitPromptAttachmentParts,
splitImageTagPromptParts,
writeProtocolOutput,
} from '../src/agent-protocol.js';
@@ -77,6 +81,60 @@ describe('shared agent protocol helpers', () => {
expect(expandImagePromptReferences(text)).toBe(text);
});
it('expands supported MEDIA documents into file prompt tags', () => {
expect(
expandPromptAttachmentReferences('증거\nMEDIA:/tmp/report.pdf'),
).toBe('증거\n[File: report.pdf → /tmp/report.pdf]');
});
it('splits image and document prompt attachments in order', () => {
expect(
splitPromptAttachmentParts(
'before [Image: render.png → /tmp/render.png] middle [File: report.pdf → /tmp/report.pdf] after',
),
).toEqual([
{ type: 'text', text: 'before ' },
{
type: 'attachment',
kind: 'image',
label: 'render.png',
path: '/tmp/render.png',
raw: '[Image: render.png → /tmp/render.png]',
tag: 'Image',
},
{ type: 'text', text: ' middle ' },
{
type: 'attachment',
kind: 'document',
label: 'report.pdf',
path: '/tmp/report.pdf',
raw: '[File: report.pdf → /tmp/report.pdf]',
tag: 'File',
},
{ type: 'text', text: ' after' },
]);
});
it('formats document evidence captions and missing-document warnings', () => {
const part = {
type: 'attachment' as const,
kind: 'document' as const,
label: 'report.pdf',
path: '/tmp/report.pdf',
raw: '[File: report.pdf → /tmp/report.pdf]',
tag: 'File' as const,
};
expect(attachmentEvidenceCaption(part)).toBe(
'Document evidence: report.pdf',
);
expect(missingAttachmentCaption(part, 'file not found')).toBe(
'[Document unavailable: report.pdf → /tmp/report.pdf — file not found]',
);
});
});
describe('shared agent output normalization', () => {
it('normalizes plain text runner output as public text', () => {
expect(normalizePublicTextOutput('DONE')).toEqual({
result: 'DONE',