import { action, type ActionContext } from "@cloudflare/think"; import * as Effect from "effect/Effect"; import { z } from "zod"; import { MAX_MEMORY_LENGTH } from "../shared/memory"; import { AgentFailure, agentCall, runAgent } from "./agent-io"; import { operationCall } from "./operation-result"; import type { PersonalAgent } from "./personal-agent"; import { scheduleActionInput, scheduleActionResult } from "./schedule-action"; type ActionParent = Pick< DurableObjectStub, | "createTaskForConversation" | "rememberForConversation" | "updateMemoryForConversation" | "deleteMemoryForConversation" >; function actionId(parts: string[]) { return agentCall(() => crypto.subtle.digest( "SHA-256", new TextEncoder().encode(JSON.stringify(parts)), ), ).pipe( Effect.map((digest) => { const hex = Array.from(new Uint8Array(digest)) .slice(0, 16) .map((byte) => byte.toString(16).padStart(2, "0")) .join(""); return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; }), ); } export function conversationActions( name: () => string, generation: () => number, getParent: () => Effect.Effect, ) { function checkedParent(ctx: ActionContext) { return Effect.gen(function* () { const started = generation(); ctx.signal.throwIfAborted(); const client = yield* getParent(); ctx.signal.throwIfAborted(); if (started !== generation()) return yield* Effect.fail( new AgentFailure({ message: "Memory operation interrupted" }), ); return client; }); } const content = z.string().min(1).max(MAX_MEMORY_LENGTH); const id = z .string() .regex(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/); const version = z.number().int().positive().max(Number.MAX_SAFE_INTEGER); return { createSchedule: action({ description: "Create a durable task in this conversation only at the user's explicit request. Clarify an exact time and timezone first: recurring schedules are UTC only; one-off instants require an explicit offset. Returns the saved task, actual next run, status and editable task URL.", inputSchema: scheduleActionInput, idempotencyKey: ({ ctx }) => ctx.toolCallId, execute: (input, ctx) => runAgent( Effect.gen(function* () { const started = generation(); ctx.signal.throwIfAborted(); // Native action settlement and parent persistence are separate commits. // Replays of the same tool call must reach the same task even after a // lost parent reply; prompt text is intentionally not the identity. const taskId = yield* actionId([ "schedule", name(), ctx.toolCallId, ]); ctx.signal.throwIfAborted(); if (started !== generation()) return yield* Effect.fail( new AgentFailure({ message: "Schedule operation interrupted" }), ); const parent = yield* getParent(); ctx.signal.throwIfAborted(); if (started !== generation()) return yield* Effect.fail( new AgentFailure({ message: "Schedule operation interrupted" }), ); return scheduleActionResult( yield* operationCall(() => parent.createTaskForConversation(name(), taskId, input), ), ); }), ctx.signal, ), }), remember: action({ description: "Save one fact only when the user explicitly asks you to remember it. Never automatically harvest conversation history.", inputSchema: z.object({ content }).strict(), idempotencyKey: ({ ctx }) => ctx.toolCallId, execute: ({ content }, ctx) => runAgent( Effect.gen(function* () { const started = generation(); // The parent write and native action ledger are separate commits. A // deterministic server-derived ID closes the lost-reply duplicate gap. const factId = yield* actionId([name(), ctx.toolCallId]); const parent = yield* checkedParent(ctx); ctx.signal.throwIfAborted(); if (started !== generation()) return yield* Effect.fail( new AgentFailure({ message: "Memory operation interrupted" }), ); return yield* operationCall(() => parent.rememberForConversation(name(), factId, content), ); }), ctx.signal, ), }), updateMemory: action({ description: "Update a saved fact at the user's request. Recall the current fact ID and version first; stale edits fail.", inputSchema: z.object({ id, content, version }).strict(), execute: ({ id, content, version }, ctx) => runAgent( Effect.gen(function* () { const parent = yield* checkedParent(ctx); ctx.signal.throwIfAborted(); return yield* operationCall(() => parent.updateMemoryForConversation(name(), id, content, version), ); }), ctx.signal, ), }), forget: action({ description: "Delete a saved fact at the user's request using its current ID and version. Does not erase historical messages.", inputSchema: z.object({ id, version }).strict(), execute: ({ id, version }, ctx) => runAgent( Effect.gen(function* () { const parent = yield* checkedParent(ctx); ctx.signal.throwIfAborted(); return yield* operationCall(() => parent.deleteMemoryForConversation(name(), id, version), ); }), ctx.signal, ), }), }; }