Files
openmaic/OpenMAIC/lib/chat/pi/tools/call-agent.ts
2026-08-16 14:58:47 +08:00

809 lines
28 KiB
TypeScript

import type { AgentTool } from '@earendil-works/pi-agent-core';
import { Type, type Static } from 'typebox';
import type { LanguageModel } from 'ai';
import { nanoid } from 'nanoid';
import { buildAgent } from '@/lib/agent/runtime/build-agent';
import { createCallLlmStreamFn } from '@/lib/agent/runtime/stream-fn';
import {
createParserState,
finalizeParser,
looksLikeStructuredFragment,
parseStructuredChunk,
type ParseResult,
} from '@/lib/orchestration/stateless-generate';
import type { AgentConfig } from '@/lib/orchestration/registry/types';
import type { AgentTurnSummary, WhiteboardActionRecord } from '@/lib/orchestration/types';
import type { ThinkingConfig } from '@/lib/types/provider';
import type { DirectorSceneEvidenceMetadata } from './read-scene';
import type { DirectorWebEvidenceMetadata } from './web-search';
import type { ParsedAction, StatelessChatRequest } from '@/lib/types/chat';
import {
buildChildPrompt,
buildChildTurnPrompt,
extractLastAssistantText,
sanitizeVisibleSpeech,
toHistoryMessages,
} from '../prompts';
import type { SendEvent } from '../types';
import { buildChildActionTools, createPiWhiteboardRuntimeState } from './classroom-actions';
const CallAgentParams = Type.Object({
agentId: Type.String({
description: 'ID of the classroom agent that should speak next.',
}),
instruction: Type.String({
description: 'Specific instruction and context for the selected agent response.',
}),
});
type CallAgentParams = Static<typeof CallAgentParams>;
type RuntimeEvidenceAttachment<TMetadata> = {
content: string;
metadata: TMetadata;
};
type ChildActionTool = ReturnType<typeof buildChildActionTools>[number];
type ChildMessageEvent = {
type: string;
assistantMessageEvent?: {
type: string;
delta?: string;
};
};
type ActionWarning = {
actionName?: string;
reason: 'unknown_action' | 'invalid_params' | 'raw_structured_fallback';
message: string;
};
const SHAPE_TYPES = new Set(['rectangle', 'circle', 'triangle']);
const CHART_TYPES = new Set(['bar', 'column', 'line', 'pie', 'ring', 'area', 'radar', 'scatter']);
const LINE_STYLES = new Set(['solid', 'dashed']);
const CODE_EDIT_OPERATIONS = new Set([
'insert_after',
'insert_before',
'delete_lines',
'replace_lines',
]);
function getAssistantTextDelta(event: ChildMessageEvent): string | null {
if (event.type !== 'message_update') return null;
const assistantEvent = event.assistantMessageEvent;
if (!assistantEvent) return null;
if (assistantEvent.type !== 'text_delta') return null;
return assistantEvent.delta ?? '';
}
async function emitTextDelta(opts: {
content: string;
messageId: string;
send: SendEvent;
appendText: (content: string) => void;
}): Promise<void> {
const visibleDelta = sanitizeVisibleSpeech(opts.content);
if (!visibleDelta) return;
opts.appendText(visibleDelta);
await opts.send({
type: 'text_delta',
data: { content: visibleDelta, messageId: opts.messageId },
});
}
function isLikelyRawStructuredFallback(content: string): boolean {
// Backstop only — the structured parser (finalizeParser) is the primary
// defense. Delegate to the shared structural classifier rather than the old
// brittle substring checks so brace-less JSON fragments are caught too.
return looksLikeStructuredFragment(content);
}
function requireString(
params: Record<string, unknown>,
field: string,
actionName: string,
): string | null {
return typeof params[field] === 'string' && params[field].length > 0
? null
: `${actionName} requires params.${field} string`;
}
function requireNumber(
params: Record<string, unknown>,
field: string,
actionName: string,
): string | null {
return typeof params[field] === 'number' ? null : `${actionName} requires params.${field} number`;
}
function optionalNumber(
params: Record<string, unknown>,
field: string,
actionName: string,
): string | null {
return params[field] !== undefined && typeof params[field] !== 'number'
? `${actionName} params.${field} must be a number`
: null;
}
function optionalString(
params: Record<string, unknown>,
field: string,
actionName: string,
): string | null {
return params[field] !== undefined && typeof params[field] !== 'string'
? `${actionName} params.${field} must be a string`
: null;
}
function validateOptionalElementId(
params: Record<string, unknown>,
actionName: string,
): string | null {
return optionalString(params, 'elementId', actionName);
}
function validateRequiredPosition(
params: Record<string, unknown>,
actionName: string,
fields: string[],
): string | null {
for (const field of fields) {
const error = requireNumber(params, field, actionName);
if (error) return error;
}
return null;
}
function validateOptionalBox(
params: Record<string, unknown>,
actionName: string,
fields: string[],
): string | null {
for (const field of fields) {
const error = optionalNumber(params, field, actionName);
if (error) return error;
}
return null;
}
function isStringArray(value: unknown): value is string[] {
return Array.isArray(value) && value.every((item) => typeof item === 'string');
}
function isNumberMatrix(value: unknown): value is number[][] {
return (
Array.isArray(value) &&
value.every((row) => Array.isArray(row) && row.every((item) => typeof item === 'number'))
);
}
function isStringMatrix(value: unknown): value is string[][] {
if (!Array.isArray(value) || value.length === 0) return false;
const columnCount = Array.isArray(value[0]) ? value[0].length : 0;
return (
columnCount > 0 &&
value.every(
(row) =>
Array.isArray(row) &&
row.length === columnCount &&
row.every((item) => typeof item === 'string'),
)
);
}
function validateChartData(params: Record<string, unknown>): string | null {
const data = params.data;
if (!data || typeof data !== 'object' || Array.isArray(data)) {
return 'wb_draw_chart requires params.data object';
}
const chartData = data as Record<string, unknown>;
if (!isStringArray(chartData.labels)) {
return 'wb_draw_chart params.data.labels must be a string array';
}
if (!isStringArray(chartData.legends)) {
return 'wb_draw_chart params.data.legends must be a string array';
}
if (!isNumberMatrix(chartData.series)) {
return 'wb_draw_chart params.data.series must be a number matrix';
}
return null;
}
function validateLinePoints(params: Record<string, unknown>): string | null {
const points = params.points;
if (points === undefined) return null;
if (!Array.isArray(points) || points.length !== 2) {
return 'wb_draw_line params.points must be a two-item marker tuple';
}
if (!points.every((marker) => marker === '' || marker === 'arrow')) {
return 'wb_draw_line params.points markers must be "" or "arrow"';
}
return null;
}
function validateActionParams(action: ParsedAction): string | null {
const params = action.params;
if (!params || typeof params !== 'object' || Array.isArray(params)) {
return 'params must be an object';
}
if (action.actionName === 'spotlight') {
return (
requireString(params, 'elementId', 'spotlight') ??
optionalNumber(params, 'dimOpacity', 'spotlight')
);
}
if (action.actionName === 'laser') {
return requireString(params, 'elementId', 'laser') ?? optionalString(params, 'color', 'laser');
}
if (action.actionName === 'play_video') {
return requireString(params, 'elementId', 'play_video');
}
if (
action.actionName === 'wb_open' ||
action.actionName === 'wb_close' ||
action.actionName === 'wb_clear'
) {
return null;
}
if (action.actionName === 'wb_delete') {
return requireString(params, 'elementId', 'wb_delete');
}
if (action.actionName === 'wb_draw_text') {
return (
requireString(params, 'content', 'wb_draw_text') ??
validateRequiredPosition(params, 'wb_draw_text', ['x', 'y']) ??
validateOptionalBox(params, 'wb_draw_text', ['width', 'height', 'fontSize']) ??
optionalString(params, 'color', 'wb_draw_text') ??
validateOptionalElementId(params, 'wb_draw_text')
);
}
if (action.actionName === 'wb_draw_shape') {
return (
(typeof params.shape === 'string' && SHAPE_TYPES.has(params.shape)
? null
: 'wb_draw_shape requires params.shape rectangle|circle|triangle') ??
validateRequiredPosition(params, 'wb_draw_shape', ['x', 'y', 'width', 'height']) ??
optionalString(params, 'fillColor', 'wb_draw_shape') ??
validateOptionalElementId(params, 'wb_draw_shape')
);
}
if (action.actionName === 'wb_draw_chart') {
return (
(typeof params.chartType === 'string' && CHART_TYPES.has(params.chartType)
? null
: 'wb_draw_chart requires params.chartType bar|column|line|pie|ring|area|radar|scatter') ??
validateRequiredPosition(params, 'wb_draw_chart', ['x', 'y', 'width', 'height']) ??
validateChartData(params) ??
(params.themeColors !== undefined && !isStringArray(params.themeColors)
? 'wb_draw_chart params.themeColors must be a string array'
: null) ??
validateOptionalElementId(params, 'wb_draw_chart')
);
}
if (action.actionName === 'wb_draw_latex') {
return (
requireString(params, 'latex', 'wb_draw_latex') ??
validateRequiredPosition(params, 'wb_draw_latex', ['x', 'y']) ??
validateOptionalBox(params, 'wb_draw_latex', ['width', 'height']) ??
optionalString(params, 'color', 'wb_draw_latex') ??
validateOptionalElementId(params, 'wb_draw_latex')
);
}
if (action.actionName === 'wb_draw_table') {
return (
validateRequiredPosition(params, 'wb_draw_table', ['x', 'y', 'width', 'height']) ??
(isStringMatrix(params.data)
? null
: 'wb_draw_table requires a non-empty rectangular params.data string matrix') ??
validateOptionalElementId(params, 'wb_draw_table')
);
}
if (action.actionName === 'wb_draw_line') {
return (
validateRequiredPosition(params, 'wb_draw_line', ['startX', 'startY', 'endX', 'endY']) ??
optionalString(params, 'color', 'wb_draw_line') ??
optionalNumber(params, 'width', 'wb_draw_line') ??
(params.style !== undefined &&
(typeof params.style !== 'string' || !LINE_STYLES.has(params.style))
? 'wb_draw_line params.style must be solid or dashed'
: null) ??
validateLinePoints(params) ??
validateOptionalElementId(params, 'wb_draw_line')
);
}
if (action.actionName === 'wb_draw_code') {
return (
requireString(params, 'language', 'wb_draw_code') ??
requireString(params, 'code', 'wb_draw_code') ??
validateRequiredPosition(params, 'wb_draw_code', ['x', 'y']) ??
validateOptionalBox(params, 'wb_draw_code', ['width', 'height']) ??
optionalString(params, 'fileName', 'wb_draw_code') ??
validateOptionalElementId(params, 'wb_draw_code')
);
}
if (action.actionName === 'wb_edit_code') {
const commonError =
requireString(params, 'elementId', 'wb_edit_code') ??
(typeof params.operation === 'string' && CODE_EDIT_OPERATIONS.has(params.operation)
? null
: 'wb_edit_code requires params.operation insert_after|insert_before|delete_lines|replace_lines');
if (commonError) return commonError;
if (params.operation === 'insert_after' || params.operation === 'insert_before') {
if (requireString(params, 'lineId', 'wb_edit_code')) {
return `wb_edit_code ${params.operation} requires params.lineId string`;
}
return requireString(params, 'content', 'wb_edit_code')
? `wb_edit_code ${params.operation} requires params.content string`
: null;
}
if (!isStringArray(params.lineIds) || params.lineIds.length === 0) {
return `wb_edit_code ${String(params.operation)} requires non-empty params.lineIds string array`;
}
return params.operation === 'replace_lines' && requireString(params, 'content', 'wb_edit_code')
? 'wb_edit_code replace_lines requires params.content string'
: null;
}
return null;
}
function findCurrentSlideElement(
body: StatelessChatRequest,
elementId: string,
): { type?: string } | null {
const currentScene = body.storeState.currentSceneId
? body.storeState.scenes.find((scene) => scene.id === body.storeState.currentSceneId)
: undefined;
if (currentScene?.content.type !== 'slide') return null;
return currentScene.content.canvas.elements.find((element) => element.id === elementId) ?? null;
}
async function executeParsedAction(
action: ParsedAction,
body: StatelessChatRequest,
toolsByName: Map<string, ChildActionTool>,
warn: (warning: ActionWarning) => void,
): Promise<void> {
const tool = toolsByName.get(action.actionName);
if (!tool) {
warn({
actionName: action.actionName,
reason: 'unknown_action',
message: `Action "${action.actionName}" is not available for this agent/scene.`,
});
return;
}
const validationError = validateActionParams(action);
if (validationError) {
warn({
actionName: action.actionName,
reason: 'invalid_params',
message: validationError,
});
return;
}
if (action.actionName === 'play_video') {
const elementId = action.params.elementId;
const slideElement =
typeof elementId === 'string' ? findCurrentSlideElement(body, elementId) : null;
if (!slideElement) {
warn({
actionName: action.actionName,
reason: 'invalid_params',
message: `play_video params.elementId "${String(elementId)}" was not found on the current slide`,
});
return;
}
if (slideElement.type !== 'video') {
warn({
actionName: action.actionName,
reason: 'invalid_params',
message: `play_video params.elementId "${elementId}" must reference a video element, got ${slideElement.type ?? 'unknown'}`,
});
return;
}
}
const result = await tool.execute(action.actionId, action.params);
if (
(action.actionName === 'wb_edit_code' ||
action.actionName === 'wb_delete' ||
action.actionName.startsWith('wb_draw_')) &&
result &&
typeof result === 'object' &&
'details' in result &&
(result.details as { skipped?: boolean } | undefined)?.skipped
) {
const details = result.details as { skipped?: boolean; reason?: string } | undefined;
const reportSkippedTarget =
action.actionName === 'wb_edit_code' ||
(action.actionName === 'wb_delete' && details?.reason === 'whiteboard_element_not_found') ||
(action.actionName.startsWith('wb_draw_') &&
details?.reason === 'whiteboard_element_id_conflict');
if (!reportSkippedTarget) return;
const content = 'content' in result && Array.isArray(result.content) ? result.content : [];
const message = content.find((item): item is { type: 'text'; text: string } =>
Boolean(
item &&
typeof item === 'object' &&
'type' in item &&
item.type === 'text' &&
'text' in item &&
typeof item.text === 'string',
),
)?.text;
warn({
actionName: action.actionName,
reason: 'invalid_params',
message: message ?? `${action.actionName} was skipped`,
});
}
}
async function processParseResult(opts: {
result: ParseResult;
messageId: string;
send: SendEvent;
toolsByName: Map<string, ChildActionTool>;
body: StatelessChatRequest;
appendText: (content: string) => void;
warn: (warning: ActionWarning) => void;
}): Promise<void> {
let emittedOrderedTextCount = 0;
for (const entry of opts.result.ordered) {
if (entry.type === 'text') {
const content = opts.result.textChunks[entry.index];
if (!content) continue;
// Text here already came out of the structured parser (extracted from a
// `"content"` field) or finalizeParser (which suppresses residue itself),
// so it is trusted speech. Do NOT re-run the residue classifier over it —
// legitimate speech that merely discusses JSON/brackets would be dropped.
await emitTextDelta({
content,
messageId: opts.messageId,
send: opts.send,
appendText: opts.appendText,
});
emittedOrderedTextCount += 1;
continue;
}
const action = opts.result.actions[entry.index];
if (!action) continue;
await executeParsedAction(action, opts.body, opts.toolsByName, opts.warn);
}
for (let i = emittedOrderedTextCount; i < opts.result.textChunks.length; i += 1) {
const content = opts.result.textChunks[i];
if (!content) continue;
await emitTextDelta({
content,
messageId: opts.messageId,
send: opts.send,
appendText: opts.appendText,
});
}
}
export function buildCallAgentTool(opts: {
body: StatelessChatRequest;
agentConfigs: AgentConfig[];
send: SendEvent;
languageModel: LanguageModel;
onAgentDone: (summary: AgentTurnSummary) => void;
onActionDone: (record?: WhiteboardActionRecord) => void;
thinkingConfig: ThinkingConfig;
maxOutputTokens?: number;
abortSignal: AbortSignal;
maxAgentTurns: number;
getAgentTurnCount: () => number;
getAgentResponses: () => AgentTurnSummary[];
getWhiteboardLedger: () => WhiteboardActionRecord[];
maxActionsPerAgent: number;
enableWhiteboardTools: boolean;
isUserCued?: () => boolean;
isSessionClosed?: () => boolean;
takeSceneEvidence?: () => RuntimeEvidenceAttachment<DirectorSceneEvidenceMetadata[]> | undefined;
takeWebEvidence?: () => RuntimeEvidenceAttachment<DirectorWebEvidenceMetadata> | undefined;
}): AgentTool<typeof CallAgentParams> {
// Loop-guard (model-agnostic): an empty/errored child turn used to bypass onAgentDone,
// so the completed-turn count never advanced and the maxAgentTurns guard was defeated — a model
// that returns empty completions (e.g. reasoning eats the output budget) could then trigger
// unbounded call_agent retries. Track attempts + consecutive empties and stop deterministically.
const MAX_CONSECUTIVE_EMPTY_TURNS = 2;
const maxAgentAttempts = Math.max(opts.maxAgentTurns * 3, opts.maxAgentTurns + 3);
let consecutiveEmptyTurns = 0;
let totalAgentAttempts = 0;
const whiteboardState = createPiWhiteboardRuntimeState(opts.body);
return {
name: 'call_agent',
label: 'Call classroom agent',
description: `Ask one classroom agent to produce the next in-class response. Use this before giving your final director decision. Hard limit: at most ${opts.maxAgentTurns} classroom agent turns in this server-side loop. Once the limit is reached, finish with cue_user or close_session.`,
parameters: CallAgentParams,
executionMode: 'sequential',
execute: async (_toolCallId: string, params: CallAgentParams, signal?: AbortSignal) => {
if (totalAgentAttempts >= maxAgentAttempts) {
return {
content: [
{
type: 'text',
text: `Reached the hard call_agent attempt cap (${maxAgentAttempts}). Finish with cue_user or close_session.`,
},
],
details: { skipped: true, reason: 'agent_attempt_cap', totalAgentAttempts },
};
}
totalAgentAttempts += 1;
if (opts.isSessionClosed?.()) {
return {
content: [
{
type: 'text',
text: 'The classroom session is already closed. Finish the director loop without calling another agent.',
},
],
details: { skipped: true, reason: 'session_closed' },
};
}
if (opts.isUserCued?.()) {
return {
content: [
{
type: 'text',
text: 'The user has already been cued. Finish the director loop without calling another agent.',
},
],
details: { skipped: true, reason: 'user_already_cued' },
};
}
const agent = opts.agentConfigs.find((candidate) => candidate.id === params.agentId);
if (!agent) {
const availableAgentIds = opts.agentConfigs.map((candidate) => candidate.id).join(', ');
return {
content: [
{
type: 'text',
text: `Agent "${params.agentId}" is not available. Available agents: ${availableAgentIds || 'none'}.`,
},
],
details: {
skipped: true,
reason: 'invalid_agent_id',
requestedAgentId: params.agentId,
availableAgentIds: opts.agentConfigs.map((candidate) => candidate.id),
},
};
}
if (opts.getAgentTurnCount() >= opts.maxAgentTurns) {
return {
content: [
{
type: 'text',
text: `Agent turn limit (${opts.maxAgentTurns}) reached. Finish the director loop with cue_user or close_session.`,
},
],
details: {
skipped: true,
reason: 'agent_turn_limit',
maxAgentTurns: opts.maxAgentTurns,
},
};
}
if (consecutiveEmptyTurns >= MAX_CONSECUTIVE_EMPTY_TURNS) {
return {
content: [
{
type: 'text',
text: `Classroom agents returned empty responses ${consecutiveEmptyTurns} times in a row. Stop calling agents and finish with cue_user or close_session.`,
},
],
details: { skipped: true, reason: 'consecutive_empty_turns', consecutiveEmptyTurns },
};
}
// Evidence is request-scoped and belongs to exactly one valid child delegation.
// Take it before starting/building the child so any downstream failure cannot
// leak the packet to a later agent.
const sceneEvidence = opts.takeSceneEvidence?.();
const webEvidence = opts.takeWebEvidence?.();
const childAbort = new AbortController();
const abortChild = () => childAbort.abort();
opts.abortSignal.addEventListener('abort', abortChild, { once: true });
signal?.addEventListener('abort', abortChild, { once: true });
const messageId = nanoid();
let text = '';
let actionCount = 0;
let sawStructuredOutput = false;
const whiteboardActions: WhiteboardActionRecord[] = [];
const actionWarnings: ActionWarning[] = [];
const warn = (warning: ActionWarning) => {
if (
actionWarnings.some(
(existing) =>
existing.reason === warning.reason &&
existing.actionName === warning.actionName &&
existing.message === warning.message,
)
) {
return;
}
actionWarnings.push(warning);
};
await opts.send({
type: 'agent_start',
data: {
messageId,
agentId: agent.id,
agentName: agent.name,
agentAvatar: agent.avatar,
agentColor: agent.color,
},
});
const childTools = buildChildActionTools({
body: opts.body,
agent,
messageId,
send: opts.send,
onActionDone: (record) => {
actionCount += 1;
if (record) whiteboardActions.push(record);
opts.onActionDone(record);
},
maxActionsPerAgent: opts.maxActionsPerAgent,
enableWhiteboardTools: opts.enableWhiteboardTools,
whiteboardState,
});
const childToolsByName = new Map(childTools.map((tool) => [tool.name, tool]));
const child = buildAgent({
streamFn: createCallLlmStreamFn({
languageModel: opts.languageModel,
source: 'pi-chat-child',
thinkingConfig: opts.thinkingConfig,
maxOutputTokens: opts.maxOutputTokens,
abortSignal: childAbort.signal,
}),
systemPrompt: buildChildPrompt(
opts.body,
agent,
opts.getAgentResponses(),
opts.getWhiteboardLedger(),
childTools.map((tool) => tool.name),
),
tools: [],
allowedToolNames: new Set(),
history: toHistoryMessages(opts.body.messages),
});
const parserState = createParserState();
const unsubscribe = child.subscribe(async (event) => {
const delta = getAssistantTextDelta(event);
if (!delta) return;
sawStructuredOutput = sawStructuredOutput || delta.includes('[');
const result = parseStructuredChunk(delta, parserState);
await processParseResult({
result,
messageId,
send: opts.send,
toolsByName: childToolsByName,
body: opts.body,
appendText: (content) => {
text += content;
},
warn,
});
});
let childErrored = false;
try {
await child.prompt(
buildChildTurnPrompt(params.instruction, agent.role, {
scene: sceneEvidence?.content,
web: webEvidence?.content,
}),
);
await child.waitForIdle();
} catch (error) {
// Propagate genuine aborts; otherwise treat a failed child run as an empty turn
// so it still records via onAgentDone below (counts toward the turn/retry budget)
// instead of throwing out of execute and skipping the counter.
if (opts.abortSignal.aborted || signal?.aborted) throw error;
childErrored = true;
} finally {
unsubscribe();
opts.abortSignal.removeEventListener('abort', abortChild);
signal?.removeEventListener('abort', abortChild);
}
await processParseResult({
result: finalizeParser(parserState),
messageId,
send: opts.send,
toolsByName: childToolsByName,
body: opts.body,
appendText: (content) => {
text += content;
},
warn,
});
const emittedText = text.trim();
const fallbackText = sawStructuredOutput
? ''
: sanitizeVisibleSpeech(extractLastAssistantText(child.state.messages)).trim();
// Bug 2 guard: only count a turn as real teaching when it produced genuine
// visible speech. Two distinct sources need different trust levels:
// - `emittedText`: already streamed through processParseResult, where every
// chunk was structurally filtered (isLikelyRawStructuredFallback). It is
// trusted speech — re-running the residue classifier over the whole
// accumulated string would misjudge a real turn that merely *discusses*
// JSON/code/brackets as empty, so we do NOT re-classify it.
// - `fallbackText`: the raw last-assistant message, which bypassed the
// parser entirely, so it still needs the structural backstop.
// A turn with no trusted speech falls through to the empty-turn guard so the
// director does not cue_user / switch agents on half a sentence. This does
// NOT touch the drain lifecycle (waitForIdle -> finalizeParser -> agent_end);
// it only classifies the already-drained result.
const safeFallback =
fallbackText && !isLikelyRawStructuredFallback(fallbackText) ? fallbackText : '';
const finalText = emittedText || safeFallback;
const hasVisibleText = finalText.length > 0;
if (finalText && !emittedText) {
await opts.send({ type: 'text_delta', data: { content: finalText, messageId } });
}
const isEmptyTurn = childErrored || (!hasVisibleText && actionCount === 0);
consecutiveEmptyTurns = isEmptyTurn ? consecutiveEmptyTurns + 1 : 0;
await opts.send({ type: 'agent_end', data: { messageId, agentId: agent.id } });
opts.onAgentDone({
agentId: agent.id,
agentName: agent.name,
contentPreview: finalText.slice(0, 300),
actionCount,
whiteboardActions,
actionWarnings,
});
return {
content: [
{
type: 'text',
text: `${agent.name}: ${finalText || '(no visible response)'}`,
},
],
details: {
agentId: agent.id,
agentName: agent.name,
text: finalText,
actionWarnings,
...(sceneEvidence ? { sceneEvidence: sceneEvidence.metadata } : {}),
...(webEvidence ? { webEvidence: webEvidence.metadata } : {}),
},
};
},
};
}