Files
openmaic/OpenMAIC/lib/orchestration/summarizers/whiteboard-ledger.ts
2026-08-16 14:58:47 +08:00

317 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import type { StatelessChatRequest } from '@/lib/types/chat';
import type { WhiteboardActionRecord } from '../types';
import { createCodeRenderBudget, renderCodeLines } from './code-line-budget';
interface VirtualWhiteboardElement {
agentName: string;
summary: string;
elementId?: string;
codeLines?: Array<{ id: string; content: string }>;
codeLanguage?: string;
codeFileName?: string;
// The mutation sequence number of the last wb_draw_code / wb_edit_code that
// touched this element during the current round; null for elements seeded
// from the request-start snapshot and for non-code draws (which do not
// compete for the code-line budget). Budget is allocated most-recently-
// touched first, so when several code blocks are drawn this round the one a
// later child agent is most likely to edit keeps its line ids even if an
// earlier, larger block would otherwise exhaust the shared budget.
lastTouchedSequence: number | null;
}
function getRecordElementId(record: WhiteboardActionRecord): string | undefined {
const elementId = record.params.elementId;
return typeof elementId === 'string' && elementId ? elementId : undefined;
}
function summarizeCodeElement(element: VirtualWhiteboardElement): string {
const lines = element.codeLines ?? [];
const fileName = element.codeFileName ? ` "${element.codeFileName}"` : '';
return `code block${fileName} (${element.codeLanguage || 'text'}, ${lines.length} lines)`;
}
function getInitialWhiteboardElements(
storeState: StatelessChatRequest['storeState'],
): VirtualWhiteboardElement[] {
const whiteboards = storeState.stage?.whiteboard;
const latestWhiteboard = Array.isArray(whiteboards) ? whiteboards.at(-1) : null;
const source = latestWhiteboard?.elements;
if (!Array.isArray(source)) return [];
return source.flatMap((element) => {
if (!element || typeof element !== 'object') return [];
const candidate = element as {
id?: unknown;
type?: unknown;
language?: unknown;
fileName?: unknown;
lines?: unknown;
};
if (typeof candidate.id !== 'string' || !candidate.id) return [];
if (candidate.type !== 'code') {
return [
{
agentName: 'Before this round',
elementId: candidate.id,
summary: `existing ${String(candidate.type || 'element')} [id:${candidate.id}]`,
lastTouchedSequence: null,
},
];
}
const virtual: VirtualWhiteboardElement = {
agentName: 'Before this round',
elementId: candidate.id,
summary: '',
lastTouchedSequence: null,
codeLines: Array.isArray(candidate.lines)
? candidate.lines.flatMap((line) => {
if (!line || typeof line !== 'object') return [];
const value = line as { id?: unknown; content?: unknown };
return typeof value.id === 'string'
? [{ id: value.id, content: String(value.content ?? '') }]
: [];
})
: [],
codeLanguage: typeof candidate.language === 'string' ? candidate.language : 'text',
codeFileName: typeof candidate.fileName === 'string' ? candidate.fileName : undefined,
};
virtual.summary = summarizeCodeElement(virtual);
return [virtual];
});
}
function applyCodeEdit(
target: VirtualWhiteboardElement,
record: WhiteboardActionRecord,
sequence: number,
): void {
if (!target.codeLines) return;
const operation = record.params.operation;
const contentLines = String(record.params.content ?? '').split('\n');
const targetIds = Array.isArray(record.params.lineIds) ? record.params.lineIds.map(String) : [];
const newLineIds = Array.isArray(record.params.newLineIds)
? record.params.newLineIds.map(String)
: [];
if (operation === 'insert_after' || operation === 'insert_before') {
const lineId = String(record.params.lineId || '');
const index = target.codeLines.findIndex((line) => line.id === lineId);
if (index >= 0) {
target.codeLines.splice(
operation === 'insert_after' ? index + 1 : index,
0,
...contentLines.map((content, offset) => ({
id: newLineIds[offset] ?? `${lineId}-${operation}-${offset + 1}`,
content,
})),
);
}
} else if (operation === 'delete_lines') {
const deleteIds = new Set(targetIds);
target.codeLines = target.codeLines.filter((line) => !deleteIds.has(line.id));
} else if (operation === 'replace_lines') {
const firstIndex = target.codeLines.findIndex((line) => line.id === targetIds[0]);
if (firstIndex >= 0) {
const replaceIds = new Set(targetIds);
target.codeLines = target.codeLines.filter((line) => !replaceIds.has(line.id));
target.codeLines.splice(
firstIndex,
0,
...contentLines.map((content, index) => ({
id: newLineIds[index] ?? targetIds[index] ?? `${targetIds[0]}-replacement-${index + 1}`,
content,
})),
);
}
}
target.agentName = record.agentName;
target.lastTouchedSequence = sequence;
target.summary = `${summarizeCodeElement(target)}; edited (${String(operation || 'edit')})`;
}
/** Replay this round's ledger on top of the request-start whiteboard snapshot. */
export function buildVirtualWhiteboardContext(
storeState: StatelessChatRequest['storeState'],
ledger?: WhiteboardActionRecord[],
): string {
if (!ledger || ledger.length === 0) return '';
const elements = getInitialWhiteboardElements(storeState);
let hasContentMutation = false;
// Monotonic counter incremented on each wb_draw_code / successful
// wb_edit_code, recorded on the target element so budget allocation can
// prefer the most recently touched code block this round.
let touchSequence = 0;
for (const record of ledger) {
const elementId = getRecordElementId(record);
switch (record.actionName) {
case 'wb_clear':
hasContentMutation = true;
elements.length = 0;
break;
case 'wb_delete':
hasContentMutation = true;
for (let index = elements.length - 1; index >= 0; index -= 1) {
if (elements[index].elementId === elementId) elements.splice(index, 1);
}
break;
case 'wb_draw_text': {
hasContentMutation = true;
const content = String(record.params.content || '').slice(0, 40);
elements.push({
agentName: record.agentName,
elementId,
lastTouchedSequence: null,
summary: `text: "${content}${content.length >= 40 ? '...' : ''}" at (${record.params.x ?? '?'},${record.params.y ?? '?'}), size ~${record.params.width ?? 400}x${record.params.height ?? 100}`,
});
break;
}
case 'wb_draw_shape':
hasContentMutation = true;
elements.push({
agentName: record.agentName,
elementId,
lastTouchedSequence: null,
summary: `shape(${record.params.type || record.params.shape || 'rectangle'}) at (${record.params.x ?? '?'},${record.params.y ?? '?'}), size ${record.params.width ?? 100}x${record.params.height ?? 100}`,
});
break;
case 'wb_draw_chart': {
hasContentMutation = true;
const labels = Array.isArray(record.params.labels)
? record.params.labels
: (record.params.data as Record<string, unknown>)?.labels;
elements.push({
agentName: record.agentName,
elementId,
lastTouchedSequence: null,
summary: `chart(${record.params.chartType || record.params.type || 'bar'})${labels ? `: labels=[${(labels as string[]).slice(0, 4).join(',')}]` : ''} at (${record.params.x ?? '?'},${record.params.y ?? '?'}), size ${record.params.width ?? 350}x${record.params.height ?? 250}`,
});
break;
}
case 'wb_draw_latex': {
hasContentMutation = true;
const latex = String(record.params.latex || '').slice(0, 40);
elements.push({
agentName: record.agentName,
elementId,
lastTouchedSequence: null,
summary: `latex: "${latex}${latex.length >= 40 ? '...' : ''}" at (${record.params.x ?? '?'},${record.params.y ?? '?'}), size ~${record.params.width ?? 400}x${record.params.height ?? 80}`,
});
break;
}
case 'wb_draw_table': {
hasContentMutation = true;
const data = record.params.data as unknown[][] | undefined;
const rows = data?.length || 0;
const cols = data?.[0]?.length || 0;
elements.push({
agentName: record.agentName,
elementId,
lastTouchedSequence: null,
summary: `table(${rows}×${cols}) at (${record.params.x ?? '?'},${record.params.y ?? '?'}), size ${record.params.width ?? 400}x${record.params.height ?? rows * 40 + 20}`,
});
break;
}
case 'wb_draw_line':
hasContentMutation = true;
elements.push({
agentName: record.agentName,
elementId,
lastTouchedSequence: null,
summary: `line${(record.params.points as string[] | undefined)?.includes('arrow') ? ' (arrow)' : ''}: (${record.params.startX ?? '?'},${record.params.startY ?? '?'}) → (${record.params.endX ?? '?'},${record.params.endY ?? '?'})`,
});
break;
case 'wb_draw_code': {
hasContentMutation = true;
const code = String(record.params.code || '');
const suppliedIds = Array.isArray(record.params.lineIds)
? record.params.lineIds.map(String)
: [];
const virtual: VirtualWhiteboardElement = {
agentName: record.agentName,
elementId,
summary: '',
lastTouchedSequence: (touchSequence += 1),
codeLines: code
.split('\n')
.map((content, index) => ({ id: suppliedIds[index] ?? `L${index + 1}`, content })),
codeLanguage: String(record.params.language || 'text'),
codeFileName: record.params.fileName ? String(record.params.fileName) : undefined,
};
virtual.summary = `${summarizeCodeElement(virtual)} at (${record.params.x ?? '?'},${record.params.y ?? '?'}), size ${record.params.width ?? 500}x${record.params.height ?? 300}`;
elements.push(virtual);
break;
}
case 'wb_edit_code': {
hasContentMutation = true;
const target = elements.find((element) => element.elementId === elementId);
if (target) applyCodeEdit(target, record, (touchSequence += 1));
break;
}
default:
break;
}
}
if (!hasContentMutation) return '';
if (elements.length === 0) {
return `
## Whiteboard Changes This Round (IMPORTANT)
The whiteboard is now empty after changes made during this discussion round.
`;
}
// Expose each element's id (and, for code, its line ids) so a later child
// agent can target them with wb_delete / wb_edit_code — the runtime validators
// require exact elementId/lineId, which were previously invisible in-prompt.
//
// The code-line budget is shared across all elements so the whole section is
// bounded. It is spent in PRIORITY order — this round's code blocks first
// (most recently drawn/edited first), pre-existing snapshot blocks last — so
// neither a large stale block nor a large earlier block drawn this round can
// exhaust the budget before the block a later child is most likely to edit
// gets its line ids rendered (a later child needs those ids to edit it this
// loop). The displayed list stays in board order; only budget allocation is
// reordered.
let codeBudget = createCodeRenderBudget();
const renderedCodeByIndex = new Map<number, string>();
const budgetOrder = elements
.map((_, index) => index)
.sort((a, b) => {
const sa = elements[a].lastTouchedSequence;
const sb = elements[b].lastTouchedSequence;
// Snapshot elements (null) sort last; among this-round code, higher
// sequence (more recent) sorts first.
if (sa === null && sb === null) return 0;
if (sa === null) return 1;
if (sb === null) return -1;
return sb - sa;
});
for (const index of budgetOrder) {
const codeLines = elements[index].codeLines ?? [];
if (codeLines.length === 0) continue;
const { text, budget } = renderCodeLines(codeLines, codeBudget);
codeBudget = budget;
renderedCodeByIndex.set(index, text);
}
const elementLines = elements
.map((element, index) => {
const idTag = element.elementId ? ` (id: ${element.elementId})` : '';
const header = ` ${index + 1}. [by ${element.agentName}]${idTag} ${element.summary}`;
const codeText = renderedCodeByIndex.get(index);
return codeText ? `${header}\n${codeText}` : header;
})
.join('\n');
return `
## Whiteboard Changes This Round (IMPORTANT)
Other agents have modified the whiteboard during this discussion round.
Current whiteboard elements (${elements.length}):
${elementLines}
DO NOT redraw content that already exists. Check positions above before adding new elements.
`;
}