/** * `regenerate_scene_actions` agent tool * * Re-generates a scene's playback `actions` to match its (edited) content by * reusing the same server-side pipeline as `app/api/generate/scene-actions/route.ts`. * * The tool's `execute` runs inside the agent loop and has no access to the * request's resolved model, so the LLM call capability is injected via a * factory (`makeRegenerateSceneActionsTool`) — the route will supply `deps.aiCall` * built from the already-resolved model. * * Scene/stage context (outline, allOutlines, content, stageId) is injected via * `deps.getSceneContext` — sourced from the client's `useStageStore` and sent in * the POST body — so the model never needs to fabricate these large structures. * The model only needs to supply the `sceneId` (or rely on the active scene). * * The tool returns the regenerated actions in `details`; a later client task * reads `tool_execution_end` and applies the actions to the scene in the store. */ import { Type, type Static } from 'typebox'; import type { AgentTool } from '@earendil-works/pi-agent-core'; import { generateSceneActions, type SceneGenerationContext, type AgentInfo, } from '@openmaic/generation'; import type { Action } from '@/lib/types/action'; import type { SceneOutline, GeneratedSlideContent, GeneratedQuizContent, GeneratedInteractiveContent, GeneratedPBLContent, } from '@/lib/types/generation'; import type { SceneContent } from '@/lib/types/stage'; import type { LlmStage } from '@/lib/server/model-routes'; import { normalizeLegacyPBLContent } from '@/lib/pbl/legacy/read'; // ── Scene context shape (client-sourced, injected via deps) ────────────────── export interface SceneContext { /** The SceneOutline for the target scene. */ outline: SceneOutline; /** All scene outlines in the stage, in order (for cross-scene context). */ allOutlines: SceneOutline[]; /** The current scene content. */ content: SceneContent; /** The stage id that owns this scene. */ stageId: string; /** Optional agent info for multi-agent stages. */ agents?: AgentInfo[]; /** Optional language directive forwarded to the generator. */ languageDirective?: string; /** * Runtime errors the interactive iframe reported for this scene (captured by * the error shim, see lib/utils/iframe.ts). Surfaced to the model by * read_scene_content so it can diagnose a blank/broken page from the real * error instead of guessing. */ runtimeErrors?: string[]; } // ── Deps injection interface ───────────────────────────────────────────────── export interface RegenerateActionsDeps { /** * Server-side LLM text call, resolving the model PER GENERATION STAGE. * * Each tool is a self-contained generation black box: it names the stage it is * generating for (e.g. `scene-content:interactive`, `scene-content:slide`, * `scene-actions`) and the route resolves that stage's model via MODEL_ROUTES * — independent of the `maic-agent` model driving the agent conversation. The * agent only decides WHICH tool to call; the tool owns its model. * * `signal` is the tool's abort signal (from `execute`): when the user cancels * the turn, the in-flight generation call is aborted so it stops promptly * instead of running to completion in the background. */ aiCall: ( stage: LlmStage, systemPrompt: string, userPrompt: string, signal?: AbortSignal, ) => Promise; /** * Returns the trusted scene/stage context for a given scene id. * This is populated from the client POST body (useStageStore state) so the * model never has to fabricate outlines or content. */ getSceneContext: (sceneId: string) => SceneContext | undefined; } // ── Content shape conversion ───────────────────────────────────────────────── // // The client sends `scene.content` (runtime `SceneContent` DSL) but // `generateSceneActions` expects the generation-time types: // GeneratedSlideContent { elements, background?, remark? } // GeneratedQuizContent { questions } // GeneratedInteractiveContent { html, ... } // GeneratedPBLContent { projectV2 } // // The ONLY mismatch is `SlideContent` (runtime) vs `GeneratedSlideContent`: // SlideContent = { type: 'slide', canvas: Slide } — elements at canvas.elements // GeneratedSlide = { elements, background?, remark? } — elements at top level // // `generateSceneActions` checks `'elements' in content` for the slide branch. // If we pass SlideContent directly, that check is FALSE → falls through → returns []. // // For quiz/interactive/pbl the runtime shapes already have the discriminant field // at the top level ('questions', 'html', 'projectV2'), so they pass through as-is. function toGenerationContent( content: SceneContent, ): | GeneratedSlideContent | GeneratedQuizContent | GeneratedInteractiveContent | GeneratedPBLContent { if (content.type === 'slide') { // Convert SlideContent → GeneratedSlideContent return { elements: content.canvas.elements ?? [], background: content.canvas.background, // remark is not stored in the runtime canvas; omit it } satisfies GeneratedSlideContent; } if (content.type === 'pbl') { return normalizeLegacyPBLContent(content) as GeneratedPBLContent; } // quiz, interactive, pbl runtime shapes already satisfy the generation type return content as GeneratedQuizContent | GeneratedInteractiveContent | GeneratedPBLContent; } // ── Typebox parameter schema ───────────────────────────────────────────────── // Minimal: the model only needs to identify WHICH scene to regenerate. // All heavy context (outline, allOutlines, content, stageId) comes from deps. export const RegenerateSceneActionsParams = Type.Object({ sceneId: Type.String({ description: 'The id of the scene whose actions should be regenerated. ' + 'Use the id of the current scene shown in the system prompt.', }), previousSpeeches: Type.Optional( Type.Array(Type.String(), { description: 'Speech texts from the previous scene for cross-scene coherence.', }), ), userProfile: Type.Optional( Type.String({ description: 'Free-text user profile for personalised narration.' }), ), }); export type RegenerateSceneActionsParams = Static; // ── Details shape returned to the client ──────────────────────────────────── export interface RegenerateSceneActionsDetails { sceneId: string; actions: Action[]; } // ── Factory ────────────────────────────────────────────────────────────────── export function makeRegenerateSceneActionsTool( deps: RegenerateActionsDeps, ): AgentTool { return { name: 'regenerate_scene_actions', label: 'Regenerate scene actions', description: 'Re-generates the narration/playback actions for a scene to match its (edited) content. ' + 'Use this after the scene content has been modified (e.g. slide elements changed, quiz questions updated) ' + 'so that the actions stay in sync with what is actually on screen. ' + 'Only supply the sceneId — the scene data is loaded automatically.', parameters: RegenerateSceneActionsParams, execute: async (_toolCallId, params, signal) => { const { sceneId, previousSpeeches, userProfile } = params; // ── Resolve trusted scene context from deps (not from model args) ── const ctxData = deps.getSceneContext(sceneId); if (!ctxData) { return { content: [ { type: 'text', text: `Error: scene context not found for sceneId ${JSON.stringify(String(sceneId).slice(0, 200))}. Cannot regenerate actions.`, }, ], details: { sceneId, actions: [] }, isError: true, }; } const { outline, allOutlines, content, stageId, agents, languageDirective } = ctxData; // Suppress unused variable — stageId is part of the context contract and // may be needed by future tool logic (e.g. quota checks, audit logging). void stageId; // ── Build cross-scene context (mirrors route.ts logic) ───────────── const allTitles: string[] = allOutlines.map((o) => o.title); const pageIndex = allOutlines.findIndex((o) => o.id === outline.id); const ctx: SceneGenerationContext = { pageIndex: (pageIndex >= 0 ? pageIndex : 0) + 1, totalPages: allOutlines.length, allTitles, previousSpeeches: previousSpeeches ?? [], }; // Wrap deps.aiCall to match AICallFn (adds optional images param). Actions // generation resolves the `scene-actions` stage model — the same route the // course-generation actions path uses — not the agent conversation model. const aiCallFn = ( systemPrompt: string, userPrompt: string, _images?: Array<{ id: string; src: string }>, ): Promise => deps.aiCall('scene-actions', systemPrompt, userPrompt, signal); // ── Generate actions ─────────────────────────────────────────────── // Convert the runtime SceneContent shape to the generation-time shape that // generateSceneActions expects. The critical case is SlideContent: // runtime: { type: 'slide', canvas: Slide } → elements at canvas.elements // generation: { elements, background?, remark? } → elements at top level // Without this conversion the 'elements' in content check is FALSE and the // function returns [] immediately. const generationContent = toGenerationContent(content); const actions = await generateSceneActions(outline, generationContent, aiCallFn, { ctx, agents, userProfile, languageDirective, }); if (actions.length === 0) { return { content: [ { type: 'text', text: `Warning: action generation produced no actions for scene "${outline.title}". ` + `The scene content may be empty or in an unexpected format. ` + `The existing actions have NOT been changed.`, }, ], details: { sceneId, actions: [] }, isError: true, }; } return { content: [ { type: 'text', text: `Regenerated ${actions.length} actions for the scene.`, }, ], details: { sceneId, actions }, }; }, }; }