Normalize agent attachment output (#122)

This commit is contained in:
Eyejoker
2026-05-02 19:50:32 +09:00
committed by GitHub
parent 9e45534de0
commit d3c02265e5
16 changed files with 414 additions and 124 deletions

View File

@@ -1,5 +1,5 @@
import {
normalizeEjclawStructuredOutput,
normalizeAgentOutput,
type RunnerOutputAttachment,
} from 'ejclaw-runners-shared';
@@ -28,7 +28,7 @@ export interface SendMessageIpcPayload {
export function buildSendMessageIpcPayload(
input: SendMessageIpcPayloadInput,
): SendMessageIpcPayload {
const normalized = normalizeEjclawStructuredOutput(input.text);
const normalized = normalizeAgentOutput(input.text);
const output =
normalized.output?.visibility === 'public' ? normalized.output : null;
const text = output?.text ?? normalized.result ?? '';

View File

@@ -3,7 +3,7 @@ import path from 'path';
import {
extractImageTagPaths,
normalizeEjclawStructuredOutput,
normalizeAgentOutput,
type RunnerStructuredOutput,
writeProtocolOutput,
} from 'ejclaw-runners-shared';
@@ -151,7 +151,7 @@ export function normalizeStructuredOutput(result: string | null): {
result: string | null;
output?: RunnerOutput['output'];
} {
return normalizeEjclawStructuredOutput(result);
return normalizeAgentOutput(result);
}
export function extractAssistantText(message: unknown): string | null {

View File

@@ -103,6 +103,32 @@ describe('agent runner IPC message payload', () => {
});
});
it('normalizes markdown image output and preserves attachments', () => {
expect(
buildSendMessageIpcPayload({
chatJid: 'dc:123',
text: `TASK_DONE
스크린샷입니다.
![screenshot](/tmp/ejclaw-markdown.png)`,
groupFolder: 'discord-review',
timestamp: '2026-04-04T13:45:00.000Z',
}),
).toEqual({
type: 'message',
chatJid: 'dc:123',
text: 'TASK_DONE\n\n스크린샷입니다.',
groupFolder: 'discord-review',
timestamp: '2026-04-04T13:45:00.000Z',
attachments: [
{
path: '/tmp/ejclaw-markdown.png',
name: 'ejclaw-markdown.png',
},
],
});
});
it('turns silent EJClaw envelopes into empty no-op messages', () => {
expect(
buildSendMessageIpcPayload({

View File

@@ -20,7 +20,7 @@ import {
IPC_CLOSE_SENTINEL,
IPC_INPUT_SUBDIR,
IPC_POLL_MS,
normalizeEjclawStructuredOutput,
normalizeAgentOutput,
writeProtocolOutput,
type RunnerStructuredOutput,
} from 'ejclaw-runners-shared';
@@ -89,7 +89,7 @@ function normalizeStructuredOutput(result: string | null): {
result: string | null;
output?: RunnerOutput['output'];
} {
return normalizeEjclawStructuredOutput(result);
return normalizeAgentOutput(result);
}
function log(message: string): void {

View File

@@ -3,6 +3,8 @@ export const OUTPUT_END_MARKER = '---EJCLAW_OUTPUT_END---';
export const IMAGE_TAG_RE =
/\[Image:\s*(?:(?:[^\]\n]*?)\s*→\s*)?(\/[^\]\n]+)\]/g;
const IMAGE_EXT_RE = /\.(png|jpe?g|gif|webp|bmp)$/i;
const MARKDOWN_ABSOLUTE_LINK_RE = /!?\[[^\]\n]*\]\((\/[^)\n]+)\)/g;
export const IPC_POLL_MS = 500;
export const IPC_INPUT_SUBDIR = 'input';
@@ -44,8 +46,16 @@ export type RunnerStructuredOutput =
export interface NormalizedRunnerOutput {
result: string | null;
output?: RunnerStructuredOutput;
attachmentSource?:
| 'legacy-ejclaw-json'
| 'markdown-image'
| 'image-tag'
| 'mixed'
| 'none';
}
export type NormalizedAgentOutput = NormalizedRunnerOutput;
function cloneImageTagPattern(): RegExp {
return new RegExp(IMAGE_TAG_RE.source, IMAGE_TAG_RE.flags);
}
@@ -74,6 +84,59 @@ export function extractImageTagPaths(text: string): {
};
}
function attachmentName(filePath: string): string | undefined {
return filePath.split(/[\\/]/).at(-1) || undefined;
}
function uniqueAttachments(
attachments: RunnerOutputAttachment[],
): RunnerOutputAttachment[] {
const seen = new Set<string>();
return attachments.filter((attachment) => {
if (seen.has(attachment.path)) return false;
seen.add(attachment.path);
return true;
});
}
export function extractMarkdownImageAttachments(text: string): {
cleanText: string;
attachments: RunnerOutputAttachment[];
} {
const attachments: RunnerOutputAttachment[] = [];
const cleanText = text.replace(
MARKDOWN_ABSOLUTE_LINK_RE,
(full: string, rawPath: string) => {
const trimmed = rawPath.trim();
if (!IMAGE_EXT_RE.test(trimmed)) return full;
attachments.push({
path: trimmed,
name: attachmentName(trimmed),
});
return '';
},
);
return {
cleanText: cleanText.trim(),
attachments: uniqueAttachments(attachments),
};
}
function imageTagPathsToAttachments(
imagePaths: string[],
): RunnerOutputAttachment[] {
return uniqueAttachments(
imagePaths
.filter((filePath) => IMAGE_EXT_RE.test(filePath))
.map((filePath) => ({
path: filePath,
name: attachmentName(filePath),
})),
);
}
export function normalizePublicTextOutput(
result: string | null,
): NormalizedRunnerOutput {
@@ -236,3 +299,62 @@ export function normalizeEjclawStructuredOutput(
return normalizePublicTextOutput(result);
}
export function normalizeAgentOutput(
result: string | null,
): NormalizedRunnerOutput {
const normalized = normalizeEjclawStructuredOutput(result);
if (
normalized.output?.visibility !== 'public' ||
typeof normalized.output.text !== 'string'
) {
return normalized;
}
const explicitAttachments = normalized.output.attachments ?? [];
if (explicitAttachments.length > 0) {
return {
...normalized,
attachmentSource: 'legacy-ejclaw-json',
};
}
const markdownExtracted = extractMarkdownImageAttachments(
normalized.output.text,
);
const imageTagExtracted = extractImageTagPaths(markdownExtracted.cleanText);
const imageTagAttachments = imageTagPathsToAttachments(
imageTagExtracted.imagePaths,
);
const attachments = uniqueAttachments([
...markdownExtracted.attachments,
...imageTagAttachments,
]);
if (attachments.length === 0) {
return {
...normalized,
attachmentSource: normalized.attachmentSource ?? 'none',
};
}
const attachmentSource =
markdownExtracted.attachments.length > 0 && imageTagAttachments.length > 0
? 'mixed'
: markdownExtracted.attachments.length > 0
? 'markdown-image'
: 'image-tag';
return {
result: imageTagExtracted.cleanText,
output: {
visibility: 'public',
text: imageTagExtracted.cleanText,
...(normalized.output.verdict
? { verdict: normalized.output.verdict }
: {}),
attachments,
},
attachmentSource,
};
}

View File

@@ -3,17 +3,20 @@ export {
type RoomRoleContext,
} from './room-role-context.js';
export {
extractMarkdownImageAttachments,
extractImageTagPaths,
IMAGE_TAG_RE,
IPC_CLOSE_SENTINEL,
IPC_INPUT_SUBDIR,
IPC_POLL_MS,
normalizeAgentOutput,
normalizeEjclawStructuredOutput,
normalizePublicTextOutput,
OUTPUT_END_MARKER,
OUTPUT_START_MARKER,
writeProtocolOutput,
type NormalizedRunnerOutput,
type NormalizedAgentOutput,
type RunnerOutputPhase,
type RunnerOutputAttachment,
type RunnerOutputVerdict,

View File

@@ -0,0 +1,121 @@
import { describe, expect, it } from 'vitest';
import {
extractMarkdownImageAttachments,
normalizeAgentOutput,
} from '../src/agent-protocol.js';
describe('normalizeAgentOutput', () => {
it('extracts markdown image attachments without rewriting normal links', () => {
expect(
extractMarkdownImageAttachments(
'결과입니다.\n![screenshot](/tmp/result.png)\n[code](/tmp/source.ts#L10)',
),
).toEqual({
cleanText: '결과입니다.\n\n[code](/tmp/source.ts#L10)',
attachments: [
{
path: '/tmp/result.png',
name: 'result.png',
},
],
});
});
it('normalizes markdown image output into internal attachments', () => {
expect(
normalizeAgentOutput(
'TASK_DONE\n\n스크린샷입니다.\n![screenshot](/tmp/screenshot.png)',
),
).toEqual({
result: 'TASK_DONE\n\n스크린샷입니다.',
output: {
visibility: 'public',
text: 'TASK_DONE\n\n스크린샷입니다.',
attachments: [
{
path: '/tmp/screenshot.png',
name: 'screenshot.png',
},
],
},
attachmentSource: 'markdown-image',
});
});
it('normalizes legacy image tags into internal attachments', () => {
expect(
normalizeAgentOutput(
'TASK_DONE\n\n스크린샷입니다.\n[Image: screenshot.png → /tmp/legacy.png]',
),
).toEqual({
result: 'TASK_DONE\n\n스크린샷입니다.',
output: {
visibility: 'public',
text: 'TASK_DONE\n\n스크린샷입니다.',
attachments: [
{
path: '/tmp/legacy.png',
name: 'legacy.png',
},
],
},
attachmentSource: 'image-tag',
});
});
it('normalizes short image tags documented in prompts', () => {
expect(
normalizeAgentOutput('TASK_DONE\n\n[Image: /tmp/short-form.png]'),
).toEqual({
result: 'TASK_DONE',
output: {
visibility: 'public',
text: 'TASK_DONE',
attachments: [
{
path: '/tmp/short-form.png',
name: 'short-form.png',
},
],
},
attachmentSource: 'image-tag',
});
});
it('keeps legacy ejclaw JSON as compatibility input', () => {
expect(
normalizeAgentOutput(
JSON.stringify({
ejclaw: {
visibility: 'public',
text: '이미지를 첨부했습니다.',
verdict: 'done',
attachments: [
{
path: '/tmp/compat.png',
name: 'compat.png',
mime: 'image/png',
},
],
},
}),
),
).toEqual({
result: '이미지를 첨부했습니다.',
output: {
visibility: 'public',
text: '이미지를 첨부했습니다.',
verdict: 'done',
attachments: [
{
path: '/tmp/compat.png',
name: 'compat.png',
mime: 'image/png',
},
],
},
attachmentSource: 'legacy-ejclaw-json',
});
});
});