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

152 lines
4.8 KiB
TypeScript

import type { AgentTool } from '@earendil-works/pi-agent-core';
import { Type, type Static } from 'typebox';
import { resolveSceneOutline } from '@/lib/agent/client/resolve-scene-outline';
import { buildStateContext } from '@/lib/orchestration/summarizers/state-context';
import type { StatelessChatRequest } from '@/lib/types/chat';
const ReadSceneParams = Type.Object({
sceneId: Type.String({
minLength: 1,
description: 'Exact sceneId from the course outline in the Director system prompt.',
}),
});
type ReadSceneParams = Static<typeof ReadSceneParams>;
export interface ReadSceneDetails {
status: 'ok' | 'not_found' | 'too_large';
sceneId: string;
title?: string;
sceneType?: string;
order?: number;
revision?: string;
source: 'request_start_snapshot';
truncated: false;
}
export interface DirectorSceneEvidencePacket {
content: string;
details: ReadSceneDetails & { status: 'ok' };
}
export type DirectorSceneEvidenceMetadata = Pick<
ReadSceneDetails,
'sceneId' | 'title' | 'sceneType' | 'order' | 'revision' | 'source'
>;
const MAX_SCENE_EVIDENCE_CHARS = 24_000;
function buildSceneEvidence(body: StatelessChatRequest, sceneId: string): string | null {
const scene = body.storeState.scenes.find((candidate) => candidate.id === sceneId);
if (!scene) return null;
const outline = resolveSceneOutline(scene, body.storeState.outlines ?? []);
const stageWithoutWhiteboard = body.storeState.stage
? { ...body.storeState.stage, whiteboard: [] }
: null;
const sceneContext = buildStateContext({
...body.storeState,
stage: stageWithoutWhiteboard,
scenes: [scene],
currentSceneId: scene.id,
outlines: [outline],
quizResults:
body.storeState.quizResults?.sceneId === scene.id ? body.storeState.quizResults : undefined,
});
const outlineContext = [
`Outline description: ${outline.description || '(none)'}`,
`Outline key points: ${outline.keyPoints?.join('; ') || '(none)'}`,
].join('\n');
const featureBoundary =
scene.type === 'interactive' || scene.type === 'pbl'
? `\nContent boundary: ${scene.type} payload is not exposed by read_scene v1; only its visible outline and scene metadata are available.`
: '';
return `${outlineContext}\n${sceneContext}${featureBoundary}`;
}
export function buildReadSceneTool(opts: {
body: StatelessChatRequest;
onEvidence?: (evidence: DirectorSceneEvidencePacket) => void;
}): AgentTool<typeof ReadSceneParams, ReadSceneDetails> {
return {
name: 'read_scene',
label: 'Read course scene',
description:
'Read one course scene by its exact sceneId before delegating a scene-dependent task. ' +
'Returns visible scene evidence and quiz-safe context from the request-start course snapshot. ' +
'Use the course outline to select the id; do not guess ids.',
parameters: ReadSceneParams,
executionMode: 'sequential',
execute: async (_toolCallId, params) => {
const scene = opts.body.storeState.scenes.find(
(candidate) => candidate.id === params.sceneId,
);
if (!scene) {
return {
content: [
{
type: 'text',
text: `Scene ${JSON.stringify(params.sceneId)} was not found in the request-start course snapshot. Select an exact sceneId from the outline.`,
},
],
details: {
status: 'not_found',
sceneId: params.sceneId,
source: 'request_start_snapshot',
truncated: false,
},
isError: true,
};
}
const evidence = buildSceneEvidence(opts.body, scene.id) ?? '';
const revision = String(
scene.updatedAt ?? opts.body.storeState.stage?.updatedAt ?? 'request-start',
);
const details: ReadSceneDetails = {
status: evidence.length > MAX_SCENE_EVIDENCE_CHARS ? 'too_large' : 'ok',
sceneId: scene.id,
title: scene.title,
sceneType: scene.type,
order: scene.order,
revision,
source: 'request_start_snapshot',
truncated: false,
};
if (evidence.length > MAX_SCENE_EVIDENCE_CHARS) {
return {
content: [
{
type: 'text',
text: `Scene ${JSON.stringify(scene.id)} is too large for read_scene v1 (${evidence.length} characters). The result was not silently truncated.`,
},
],
details,
isError: true,
};
}
const content = [
`Scene evidence (sceneId=${scene.id}, revision=${revision}, source=request_start_snapshot):`,
evidence,
].join('\n');
opts.onEvidence?.({
content,
details: { ...details, status: 'ok' },
});
return {
content: [
{
type: 'text',
text: content,
},
],
details,
};
},
};
}