/** * Hydrate Route * * POST /hydrate - Restore agent state from a previous run's action log. * * Loads actions from persistence, optionally slices them to a boundary * (sequence number or turn), replays them through the runtime so reducers * rebuild state, then optionally continues execution from that point. * * Replay uses `dispatchToReducer` — actions go straight to reducers, * bypassing the entire middleware chain. No model calls, no tool execution, * no streaming events. Same approach as Redux DevTools time-travel. */ import { Hono } from "hono"; import type { Runtime, Action } from "@gizmo-ai/runtime"; import { replayActions } from "@gizmo-ai/runtime"; import type { PersistenceStore, ActionRecord } from "../persistence/index.ts"; import type { HydrateRequest, HydrateResponse, ErrorResponse } from "../types.ts"; const EXECUTION_STARTED_TYPE = "RUNTIME_EXECUTION_STARTED"; export interface HydrateRouteConfig< S extends Record = Record, A extends Action = Action > { runtime: Runtime; persistence: PersistenceStore | null; } /** * Slice actions up to a turn boundary. * * Turns are delimited by RUNTIME_EXECUTION_STARTED actions. * "Up to turn N" means: include everything up to (but not including) the * (N+1)th RUNTIME_EXECUTION_STARTED action. */ function sliceToTurn(actions: ActionRecord[], turn: number): ActionRecord[] { let turnsSeen = 0; for (let i = 0; i < actions.length; i++) { if (actions[i].type === EXECUTION_STARTED_TYPE) { turnsSeen++; if (turnsSeen > turn) { return actions.slice(0, i); } } } // Requested turn exceeds available turns — return all actions return actions; } /** * Strip large tool output bodies for slim hydration mode. * * Replaces the output field in toolCallExecuted action payloads with a * placeholder so the conversation structure is preserved but bloat is removed. */ function slimActions(actions: ActionRecord[]): ActionRecord[] { return actions.map((record) => { const payload = record.payload as Record | undefined; if ( record.type === "agent/toolCallExecuted" && payload && typeof payload === "object" && "output" in payload ) { return { ...record, payload: { ...payload, output: "[hydrated — output omitted]", }, }; } return record; }); } /** * Create the hydrate route */ export function createHydrateRoute< S extends Record, A extends Action >(config: HydrateRouteConfig): Hono { const { runtime, persistence } = config; const app = new Hono(); app.post("/", async (c) => { if (!persistence) { return c.json( { error: "Persistence is not enabled — hydration requires persisted action logs" }, 501 ); } let body: HydrateRequest; try { body = await c.req.json(); } catch (error) { return c.json( { error: `Invalid JSON: ${error instanceof Error ? error.message : String(error)}` }, 400 ); } if (!body.executionId || typeof body.executionId !== "string") { return c.json( { error: "executionId is required and must be a string" }, 400 ); } try { // 1. Load actions from persistence let actions = await persistence.getActions(body.executionId); if (actions.length === 0) { return c.json( { error: `No actions found for execution "${body.executionId}"` }, 404 ); } // 2. Slice to boundary if (body.upToTurn !== undefined) { actions = sliceToTurn(actions, body.upToTurn); } if (body.upToSeq !== undefined) { actions = actions.filter((a) => a.seq <= body.upToSeq!); } // 3. Apply slim mode if requested if (body.hydrateMode === "slim") { actions = slimActions(actions); } // 4. Reset state to initial values, then replay from clean slate runtime.resetState(); const replayableActions = actions.map((record) => record.payload as A); replayActions(runtime, replayableActions); // 5. Read restored state summary const state = runtime.getState(); const agentState = state.agent as { conversation?: unknown[]; loopCount?: number } | undefined; const conversationLength = agentState?.conversation?.length ?? 0; const loopCount = agentState?.loopCount ?? 0; // 6. Optionally continue execution let execution: HydrateResponse["execution"]; if (body.thenExecute) { const handle = runtime.execute(body.thenExecute); await handle.included; const execState = runtime.getState().execution as { id: string; turnIds: string[]; }; execution = { executionId: execState.id, turnId: execState.turnIds[execState.turnIds.length - 1], }; } return c.json({ status: "hydrated", actionCount: actions.length, state: { conversationLength, loopCount, }, execution, }); } catch (error) { return c.json( { error: `Hydration failed: ${error instanceof Error ? error.message : String(error)}` }, 500 ); } }); return app; }