// BMO's tools: things the model can do to its own body and world. The server // validates each call and forwards it to the page as an "action" event; the page // carries it out. Results go straight back to the model so it can keep talking. import type Anthropic from "@anthropic-ai/sdk"; import { COLOR_LOOKS, COLOR_NAMES, OUTFIT_LOOKS, OUTFIT_NAMES, PERFORMANCES, SPOTS, TIME_MODES, WEATHER_MODES, type BmoAction, type SceneState, } from "../shared/scene.ts"; function enumTool( name: string, description: string, field: string, values: readonly string[], fieldDescription: string, ): Anthropic.Beta.BetaTool { return { name, description, // Inputs are tiny enums, but stream them as generated anyway; we validate // every call ourselves below. eager_input_streaming: true, input_schema: { type: "object", properties: { [field]: { type: "string", enum: [...values], description: fieldDescription } }, required: [field], additionalProperties: false, }, }; } export const BMO_TOOLS: Anthropic.Beta.BetaTool[] = [ enumTool( "set_color", "Repaint BMO's body. Use when the user asks BMO to change color, or BMO wants a new look.", "color", COLOR_NAMES, `Palette name. ${COLOR_NAMES.map((c) => `${c} = ${COLOR_LOOKS[c]}`).join("; ")}.`, ), enumTool( "set_outfit", "Change what BMO is wearing.", "outfit", OUTFIT_NAMES, `Outfit name. ${OUTFIT_NAMES.map((o) => `${o} = ${OUTFIT_LOOKS[o]}`).join("; ")}.`, ), enumTool( "set_time_of_day", "Change the sky in BMO's garden. 'auto' follows the user's real clock.", "time", TIME_MODES, "auto, day, dusk or night.", ), enumTool( "set_weather", "Change the weather in BMO's garden. 'auto' lets it drift on its own; 'none' means clear skies.", "weather", WEATHER_MODES, "auto, none (clear), rain, snow or falling-leaves.", ), enumTool( "move", "Walk to a spot on BMO's little stage. Use 'center' for 'come here' or 'stand in front of me'.", "to", SPOTS, "left, center or right.", ), enumTool( "perform", "Do something with BMO's body or face: dance, celebrate (hearts), idea (lightbulb), confused (question mark), love (heart eyes), dizzy (spiral eyes), look_around, or nap (fall asleep until the user moves the mouse).", "action", PERFORMANCES, "Which animation to play.", ), ]; const FIELD: Record = { set_color: "color", set_outfit: "outfit", set_time_of_day: "time", set_weather: "weather", move: "to", perform: "action", }; const ALLOWED: Record = { set_color: COLOR_NAMES, set_outfit: OUTFIT_NAMES, set_time_of_day: TIME_MODES, set_weather: WEATHER_MODES, move: SPOTS, perform: PERFORMANCES, }; /** Validates a tool call from the model. Returns the action or an error message. */ export function toAction(name: string, input: unknown): BmoAction | { error: string } { if (!(name in FIELD)) return { error: `Unknown tool ${name}.` }; const type = name as BmoAction["type"]; const value = (input as Record | null)?.[FIELD[type]]; if (typeof value !== "string" || !ALLOWED[type].includes(value)) { return { error: `${FIELD[type]} must be one of: ${ALLOWED[type].join(", ")}.` }; } return { type, [FIELD[type]]: value } as BmoAction; } /** One-line confirmation the model sees as the tool result. */ export function describeAction(action: BmoAction): string { switch (action.type) { case "set_color": return `Done. BMO is now ${COLOR_LOOKS[action.color]} (${action.color}).`; case "set_outfit": return `Done. BMO is now wearing ${OUTFIT_LOOKS[action.outfit]}.`; case "set_time_of_day": return action.time === "auto" ? "Done. The sky follows the real clock again." : `Done. It is now ${action.time} in the garden.`; case "set_weather": return action.weather === "auto" ? "Done. The weather will drift on its own again." : `Done. Weather is now ${action.weather === "none" ? "clear" : action.weather}.`; case "move": return `Done. BMO walked to the ${action.to}.`; case "perform": return `Done. BMO did: ${action.action}.`; } } /** Parses and checks the scene state the page sends; anything odd is dropped. */ export function parseScene(input: unknown): SceneState | null { if (!input || typeof input !== "object") return null; const s = input as Record; const ok = COLOR_NAMES.includes(s.color as never) && OUTFIT_NAMES.includes(s.outfit as never) && ["day", "dusk", "night"].includes(s.timeOfDay as string) && TIME_MODES.includes(s.timeMode as never) && ["none", "rain", "snow", "falling-leaves"].includes(s.weather as string) && WEATHER_MODES.includes(s.weatherMode as never) && SPOTS.includes(s.spot as never) && typeof s.localTime === "string" && s.localTime.length < 40; return ok ? (s as unknown as SceneState) : null; } /** The per-turn system message that tells BMO what it looks like right now. */ export function describeScene(scene: SceneState): string { const weather = scene.weather === "none" ? "clear skies" : scene.weather.replace("-", " "); return [ "Live scene (what the user sees right now; trust this over earlier messages):", `- BMO's color: ${COLOR_LOOKS[scene.color]} (palette "${scene.color}")`, `- BMO's outfit: ${OUTFIT_LOOKS[scene.outfit]} (outfit "${scene.outfit}")`, `- Garden sky: ${scene.timeOfDay}${scene.timeMode === "auto" ? " (following the real clock)" : " (set by hand)"}`, `- Weather: ${weather}${scene.weatherMode === "auto" ? " (drifting on its own)" : " (set by hand)"}`, `- BMO is standing at the ${scene.spot} of the stage`, `- User's local time: ${scene.localTime}`, ].join("\n"); }