311 lines
9.3 KiB
TypeScript
311 lines
9.3 KiB
TypeScript
import { isModelDocumentPath } from 'ejclaw-runners-shared';
|
|
|
|
import { buildArbiterContextPrompt } from './arbiter-context.js';
|
|
import { TASK_USER_CONTEXT_START_SKEW_MS } from './message-runtime-task-context.js';
|
|
import { formatMessages } from './router.js';
|
|
import type {
|
|
NewMessage,
|
|
OutboundAttachment,
|
|
PairedTask,
|
|
PairedTurnOutput,
|
|
} from './types.js';
|
|
|
|
const CARRIED_FORWARD_OWNER_FINAL_MARKER =
|
|
'[Carried forward context from the previous task: latest owner final]';
|
|
|
|
const CARRIED_FORWARD_OWNER_FINAL_GUIDANCE = `System note:
|
|
If you see a message beginning with "${CARRIED_FORWARD_OWNER_FINAL_MARKER}", treat it as background only. Do not repeat, continue, or answer that carried-forward final directly. Respond only to the latest human request and the current task.`;
|
|
|
|
const ARBITER_TURN_OUTPUT_CONTEXT_LIMIT = 6;
|
|
|
|
function isImageAttachment(attachment: OutboundAttachment): boolean {
|
|
if (attachment.mime?.toLowerCase().startsWith('image/')) return true;
|
|
return /\.(?:png|jpe?g|gif|webp|bmp)$/i.test(attachment.path);
|
|
}
|
|
|
|
function isDocumentAttachment(attachment: OutboundAttachment): boolean {
|
|
const mime = attachment.mime?.toLowerCase();
|
|
if (mime === 'application/pdf' || mime?.startsWith('text/')) return true;
|
|
return isModelDocumentPath(attachment.path);
|
|
}
|
|
|
|
function attachmentLabel(attachment: OutboundAttachment): string {
|
|
return attachment.name?.trim() || attachment.path.split('/').at(-1) || 'file';
|
|
}
|
|
|
|
function nonImageAttachmentReason(attachment: OutboundAttachment): string {
|
|
const mime = attachment.mime?.trim();
|
|
if (mime) {
|
|
return `${mime} is not loaded as structured model input`;
|
|
}
|
|
return 'non-image attachment is not loaded as structured model input';
|
|
}
|
|
|
|
function formatTurnOutputAttachmentContext(
|
|
attachments: OutboundAttachment[] | undefined,
|
|
): string {
|
|
if (!attachments?.length) return '';
|
|
const lines = attachments.map((attachment) => {
|
|
const label = attachmentLabel(attachment);
|
|
if (isImageAttachment(attachment)) {
|
|
return `[Image: ${label} → ${attachment.path}]`;
|
|
}
|
|
if (isDocumentAttachment(attachment)) {
|
|
return `[File: ${label} → ${attachment.path}]`;
|
|
}
|
|
return `[Attachment unavailable: ${label} → ${attachment.path} — ${nonImageAttachmentReason(
|
|
attachment,
|
|
)}]`;
|
|
});
|
|
return `\n\nAttached evidence from this turn:\n${lines.join('\n')}`;
|
|
}
|
|
|
|
function formatTurnOutputMessageContent(turnOutput: PairedTurnOutput): string {
|
|
return `${turnOutput.output_text}${formatTurnOutputAttachmentContext(
|
|
turnOutput.attachments,
|
|
)}`;
|
|
}
|
|
|
|
function turnOutputsToMessages(
|
|
outputs: PairedTurnOutput[],
|
|
chatJid: string,
|
|
): NewMessage[] {
|
|
return outputs.map((turnOutput) => ({
|
|
id: `turn-${turnOutput.task_id}-${turnOutput.turn_number}`,
|
|
chat_jid: chatJid,
|
|
sender: turnOutput.role,
|
|
sender_name: turnOutput.role,
|
|
content: formatTurnOutputMessageContent(turnOutput),
|
|
timestamp: turnOutput.created_at,
|
|
is_bot_message: true as const,
|
|
is_from_me: false as const,
|
|
}));
|
|
}
|
|
|
|
function omittedTurnOutputsMessage(args: {
|
|
chatJid: string;
|
|
omittedCount: number;
|
|
firstVisibleOutput?: PairedTurnOutput;
|
|
}): NewMessage {
|
|
return {
|
|
id: `turn-output-omitted-${args.firstVisibleOutput?.task_id ?? 'unknown'}-${args.omittedCount}`,
|
|
chat_jid: args.chatJid,
|
|
sender: 'system',
|
|
sender_name: 'system',
|
|
content: `[Earlier paired turn outputs omitted: ${args.omittedCount} older outputs not shown]`,
|
|
timestamp:
|
|
args.firstVisibleOutput?.created_at ?? '1970-01-01T00:00:00.000Z',
|
|
is_bot_message: true as const,
|
|
is_from_me: false as const,
|
|
};
|
|
}
|
|
|
|
function latestTurnOutputMessagesWithOmissionMarker(
|
|
outputs: PairedTurnOutput[],
|
|
chatJid: string,
|
|
limit: number,
|
|
): NewMessage[] {
|
|
const latestOutputs = latestTurnOutputs(outputs, limit);
|
|
const messages = turnOutputsToMessages(latestOutputs, chatJid);
|
|
const omittedCount = outputs.length - latestOutputs.length;
|
|
if (omittedCount <= 0) {
|
|
return messages;
|
|
}
|
|
return [
|
|
omittedTurnOutputsMessage({
|
|
chatJid,
|
|
omittedCount,
|
|
firstVisibleOutput: latestOutputs[0],
|
|
}),
|
|
...messages,
|
|
];
|
|
}
|
|
|
|
function mergeHumanAndTurnOutputMessages(
|
|
chatJid: string,
|
|
humanMessages: NewMessage[],
|
|
turnOutputs: PairedTurnOutput[],
|
|
): NewMessage[] {
|
|
return [
|
|
...humanMessages,
|
|
...turnOutputsToMessages(turnOutputs, chatJid),
|
|
].sort((a, b) => a.timestamp.localeCompare(b.timestamp));
|
|
}
|
|
|
|
function latestTurnOutputs(
|
|
outputs: PairedTurnOutput[],
|
|
limit: number,
|
|
): PairedTurnOutput[] {
|
|
if (outputs.length <= limit) {
|
|
return outputs;
|
|
}
|
|
return outputs.slice(-limit);
|
|
}
|
|
|
|
function currentTaskHumanMessages(
|
|
messages: NewMessage[],
|
|
taskCreatedAt: string | null | undefined,
|
|
): NewMessage[] {
|
|
if (!taskCreatedAt) return [];
|
|
const taskStartMs = Date.parse(taskCreatedAt);
|
|
if (!Number.isFinite(taskStartMs)) return [];
|
|
return messages.filter((message) => {
|
|
if (message.is_bot_message) return false;
|
|
const messageMs = Date.parse(message.timestamp);
|
|
return (
|
|
Number.isFinite(messageMs) &&
|
|
messageMs >= taskStartMs - TASK_USER_CONTEXT_START_SKEW_MS
|
|
);
|
|
});
|
|
}
|
|
|
|
function hasCarriedForwardOwnerFinal(outputs: PairedTurnOutput[]): boolean {
|
|
return outputs.some((output) =>
|
|
output.output_text.startsWith(CARRIED_FORWARD_OWNER_FINAL_MARKER),
|
|
);
|
|
}
|
|
|
|
function prependCarriedForwardGuidance(
|
|
prompt: string,
|
|
turnOutputs: PairedTurnOutput[],
|
|
): string {
|
|
if (!hasCarriedForwardOwnerFinal(turnOutputs)) {
|
|
return prompt;
|
|
}
|
|
return `${CARRIED_FORWARD_OWNER_FINAL_GUIDANCE}\n\n${prompt}`;
|
|
}
|
|
|
|
export function buildPairedTurnPrompt(args: {
|
|
taskId: string;
|
|
chatJid: string;
|
|
timezone: string;
|
|
missedMessages: NewMessage[];
|
|
labeledFallbackMessages: NewMessage[];
|
|
turnOutputs: PairedTurnOutput[];
|
|
}): string {
|
|
if (args.turnOutputs.length === 0) {
|
|
return formatMessages(args.labeledFallbackMessages, args.timezone);
|
|
}
|
|
|
|
const humanMessages = args.missedMessages.filter(
|
|
(message) => !message.is_bot_message,
|
|
);
|
|
return prependCarriedForwardGuidance(
|
|
formatMessages(
|
|
mergeHumanAndTurnOutputMessages(
|
|
args.chatJid,
|
|
humanMessages,
|
|
args.turnOutputs,
|
|
),
|
|
args.timezone,
|
|
),
|
|
args.turnOutputs,
|
|
);
|
|
}
|
|
|
|
function turnOutputsOnlyPrompt(
|
|
chatJid: string,
|
|
timezone: string,
|
|
turnOutputs: PairedTurnOutput[],
|
|
taskHumanMessages: NewMessage[] = [],
|
|
): string {
|
|
return prependCarriedForwardGuidance(
|
|
formatMessages(
|
|
[...taskHumanMessages, ...turnOutputsToMessages(turnOutputs, chatJid)],
|
|
timezone,
|
|
),
|
|
turnOutputs,
|
|
);
|
|
}
|
|
|
|
export function buildReviewerPendingPrompt(args: {
|
|
chatJid: string;
|
|
timezone: string;
|
|
turnOutputs: PairedTurnOutput[];
|
|
recentHumanMessages: NewMessage[];
|
|
lastHumanMessage: string | null | undefined;
|
|
taskCreatedAt?: string | null;
|
|
}): string {
|
|
if (args.turnOutputs.length > 0) {
|
|
return turnOutputsOnlyPrompt(
|
|
args.chatJid,
|
|
args.timezone,
|
|
args.turnOutputs,
|
|
currentTaskHumanMessages(args.recentHumanMessages, args.taskCreatedAt),
|
|
);
|
|
}
|
|
|
|
if (!args.lastHumanMessage) {
|
|
return 'Review the latest owner changes in the workspace.';
|
|
}
|
|
|
|
return `User request:\n---\n${args.lastHumanMessage}\n---\n\nReview the latest owner changes in the workspace.`;
|
|
}
|
|
|
|
export function buildOwnerPendingPrompt(args: {
|
|
chatJid: string;
|
|
timezone: string;
|
|
turnOutputs: PairedTurnOutput[];
|
|
recentHumanMessages: NewMessage[];
|
|
lastHumanMessage: string | null | undefined;
|
|
taskCreatedAt?: string | null;
|
|
}): string {
|
|
if (args.turnOutputs.length > 0) {
|
|
return turnOutputsOnlyPrompt(
|
|
args.chatJid,
|
|
args.timezone,
|
|
args.turnOutputs,
|
|
currentTaskHumanMessages(args.recentHumanMessages, args.taskCreatedAt),
|
|
);
|
|
}
|
|
|
|
if (!args.lastHumanMessage) {
|
|
return 'Continue the owner turn using the latest reviewer or arbiter feedback.';
|
|
}
|
|
|
|
return `User request:\n---\n${args.lastHumanMessage}\n---\n\nContinue the owner turn using the latest reviewer or arbiter feedback.`;
|
|
}
|
|
|
|
export function buildArbiterPromptForTask(args: {
|
|
task: PairedTask;
|
|
chatJid: string;
|
|
timezone: string;
|
|
turnOutputs: PairedTurnOutput[];
|
|
recentMessages: NewMessage[];
|
|
labeledRecentMessages: NewMessage[];
|
|
}): string {
|
|
const taskHumanMessages = currentTaskHumanMessages(
|
|
args.recentMessages,
|
|
args.task.created_at,
|
|
);
|
|
const messages =
|
|
args.turnOutputs.length > 0
|
|
? [
|
|
...taskHumanMessages,
|
|
...latestTurnOutputMessagesWithOmissionMarker(
|
|
args.turnOutputs,
|
|
args.chatJid,
|
|
ARBITER_TURN_OUTPUT_CONTEXT_LIMIT,
|
|
),
|
|
]
|
|
: args.labeledRecentMessages;
|
|
|
|
return buildArbiterContextPrompt({
|
|
chatJid: args.chatJid,
|
|
taskId: args.task.id,
|
|
roundTripCount: args.task.round_trip_count,
|
|
timezone: args.timezone,
|
|
messages,
|
|
});
|
|
}
|
|
|
|
export function buildFinalizePendingPrompt(_args: {
|
|
turnOutputs: PairedTurnOutput[];
|
|
}): string {
|
|
return `The reviewer approved the current task scope (TASK_DONE / legacy DONE). Finalize and report the result.
|
|
If you intend to close this paired turn now, your first line must be TASK_DONE.
|
|
Do not use STEP_DONE only because a broader roadmap still has remaining work; close the approved slice and continue the next slice in a new owner turn.
|
|
Use STEP_DONE only when this same approved scope still needs additional owner changes and another review pass.
|
|
If your first line is DONE_WITH_CONCERNS, the system will reopen review instead of finishing.`;
|
|
}
|