From d857a8ae4a16b155d4694f8f556dca55b124c7ce Mon Sep 17 00:00:00 2001 From: David Khourshid Date: Wed, 5 Aug 2026 15:24:09 -0400 Subject: [PATCH] Validate machine input against its schema; add chat-with-pdf example MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit XState's `schemas` are types only — it never validates, and it resolves `schemas.input` to one type shared by `createActor`'s `input` option and the `context: ({ input })` factory. A field declared with a default was therefore both absent at runtime and required at the call site. runAgent/createAgentActor now validate `options.input` against the machine's registered input schema before the actor starts. The resolved value (defaults filled, transforms applied) is what reaches the actor, the replayable init entry, and the `run.start` trace, so a replay reproduces the run even if a default is computed. Invalid input throws an AgentError with code `invalid-machine-input`, matching how a mismatched resume snapshot behaves; omitted input still skips validation. Types: `setupAgent` brands the machine's input type with its own schema, and runAgent's `input` is now `AgentInputFrom`, which reads the schema's pre-validation side while the context factory keeps the validated one. The brand is a `~`-prefixed phantom property rather than a unique symbol — a symbol has to be exported as a runtime value for declaration emit to name it. Only object-shaped input is branded, since intersecting a brand into xstate's NonReducibleUnknown collapses members to never. Standard Schema throughout; no validation library referenced. Also ports agentcn's chat-with-PDF quiz recipe, where the quiz loop lives in instructions.md as prose. The sequencing moves into the machine: one question per entry into `asking`, a refreshEvery guard instead of "after 3-4 questions", covered pages fed back to retrieval as excludePages, documentId threaded from context, page hints read off the chunk, and an idle choosingDocument state when the library is ambiguous. Voice and question formatting stay in the prompt. --- .changeset/validate-machine-input.md | 31 ++ examples/README.md | 1 + examples/chat-with-pdf/index.test.ts | 251 ++++++++++ examples/chat-with-pdf/index.ts | 694 +++++++++++++++++++++++++++ examples/chat-with-pdf/metadata.json | 35 ++ src/events.ts | 4 +- src/index.ts | 2 + src/run-agent.test.ts | 118 +++++ src/run-agent.ts | 79 ++- src/setup-agent.ts | 28 +- src/types.ts | 29 ++ 11 files changed, 1264 insertions(+), 8 deletions(-) create mode 100644 .changeset/validate-machine-input.md create mode 100644 examples/chat-with-pdf/index.test.ts create mode 100644 examples/chat-with-pdf/index.ts create mode 100644 examples/chat-with-pdf/metadata.json diff --git a/.changeset/validate-machine-input.md b/.changeset/validate-machine-input.md new file mode 100644 index 00000000..55d061a4 --- /dev/null +++ b/.changeset/validate-machine-input.md @@ -0,0 +1,31 @@ +--- +"@statelyai/agent": minor +--- + +**Machine input is validated against its schema, and defaulted fields are optional at the call site.** + +XState's `schemas` are types only — it never validates, and it resolves `schemas.input` to one type shared by `createActor`'s `input` option and the `context: ({ input })` factory. A field declared with a default was therefore both absent at runtime and required at the call site. + +`runAgent`/`createAgentActor` now validate `options.input` against the machine's declared input schema before the actor starts: defaults are filled and transforms applied, and the resolved value is what reaches the actor, the replayable event log, and the `run.start` trace — so a replay reproduces the run even if a default is computed. Invalid input throws an `AgentError` with code `invalid-machine-input` (like a mismatched resume snapshot; the actor never starts). Omitting `input` entirely still skips validation. + +```ts +const agent = setupAgent({ + schemas: createAgentSchemas({ + context: z.object({ topic: z.string(), rounds: z.number() }), + input: z.object({ topic: z.string(), rounds: z.number().default(3) }), + }), +}); + +const machine = agent.createMachine({ + // `rounds` arrives filled in — no `?? 3` restating the default here + context: ({ input }) => ({ topic: input.topic, rounds: input.rounds }), + // ... +}); + +// `rounds` is optional at the call site; `topic` (no default) is not +await runAgent(machine, { input: { topic: "otters" } }); +``` + +Standard Schema throughout — no validation library is referenced, so this works with whatever the machine was declared with. + +Types: `runAgent`'s `input` is now `AgentInputFrom`, which reads the schema's pre-validation side (`~standard.types.input`) while the context factory keeps the validated side. Machines reached through `.provide(...)` lose the brand and fall back to xstate's `InputFrom`. New exported type helpers: `AgentInputFrom`, `InferInput`. diff --git a/examples/README.md b/examples/README.md index 812147bc..b26b1794 100644 --- a/examples/README.md +++ b/examples/README.md @@ -66,6 +66,7 @@ These use `setupAgent(...)` (or plain XState `setup(...)` plus `createTextLogic( - [`file-snapshot-store/index.ts`](file-snapshot-store/index.ts): durable HITL checkpoints in a file-backed snapshot store: each idle settle writes JSON to disk and a fresh `runAgent` call resumes across turns - [`machine-as-tool/index.ts`](machine-as-tool/index.ts): a whole agent machine embedded inside one tool call of a host harness: start/resume tools bridge a JSON-safe snapshot handle and read the typed interaction meta - [`rag/index.ts`](rag/index.ts): retrieve (typed plain actor, keyword scoring over a sample corpus) → grounded answer, with conversational memory in context +- [`chat-with-pdf/index.ts`](chat-with-pdf/index.ts): agentcn's chat-with-PDF quiz recipe with the sequencing lifted out of `instructions.md` and into the machine — one question per entry into `asking`, a `refreshEvery` guard instead of "after 3-4 questions", `pagesCovered` fed back to retrieval as `excludePages`, `documentId` threaded from context, page hints read off the chunk, and an idle `choosingDocument` state when the library is ambiguous; voice and question formatting stay in the prompt - [`corrective-rag/index.ts`](corrective-rag/index.ts): LangGraph's CRAG tutorial as explicit states: retrieve → grade documents → conditional correction branch (rewrite query → web-search fallback) → grounded generate, every model call with a degrading `onError` - [`adaptive-rag/index.ts`](adaptive-rag/index.ts): LangGraph adaptive RAG as route → retrieve/search → grade → bounded rewrite → generate → groundedness/usefulness verification - [`deep-research/index.ts`](deep-research/index.ts): plan 2-4 complementary searches → one dynamically spawned researcher per query → coverage reflection → optional targeted follow-up → sourced report; search remains host-owned diff --git a/examples/chat-with-pdf/index.test.ts b/examples/chat-with-pdf/index.test.ts new file mode 100644 index 00000000..ae8877b1 --- /dev/null +++ b/examples/chat-with-pdf/index.test.ts @@ -0,0 +1,251 @@ +import { describe, expect, test } from "vitest"; +import { runAgent } from "@statelyai/agent"; +import type { AgentRequestExecutor } from "@statelyai/agent"; +import { + chatWithPdfMachine, + idlePrompt, + queryPdfContent, + SAMPLE_LIBRARY, + type LearnerEvent, +} from "./index.js"; + +type MachineInput = { + documentId?: string | null; + topic?: string; + pageStart?: number | null; + pageEnd?: number | null; + maxQuestions?: number; + refreshEvery?: number; +}; + +interface PlayOptions { + input?: MachineInput; + /** Consumed in order on each idle settle. */ + learnerEvents: LearnerEvent[]; + /** Grade every answer as correct unless this says otherwise. */ + grade?: (answer: string) => boolean; +} + +interface PlayResult { + status: string; + output?: { + documentId: string; + correct: number; + answered: number; + pagesCovered: number[]; + results: Array<{ pageNumber: number; prompt: string; correct: boolean }>; + exhausted: boolean; + }; + /** Passages `writeQuestion` was given, in order. */ + passages: string[]; + /** Prompts `gradeAnswer` was given, in order. */ + gradePrompts: string[]; + /** Every idle label the run settled on. */ + idleLabels: string[]; +} + +async function play(options: PlayOptions): Promise { + const passages: string[] = []; + const gradePrompts: string[] = []; + const idleLabels: string[] = []; + let questionNumber = 0; + + const generateText: AgentRequestExecutor = async (request) => { + if (request.system?.includes("Grade a quiz answer")) { + gradePrompts.push(request.prompt ?? ""); + const answer = (request.prompt ?? "").match(/Learner's answer: (.*)/)?.[1] ?? ""; + return { + output: { + correct: options.grade ? options.grade(answer) : true, + explanation: `graded "${answer}"`, + }, + }; + } + // writeQuestion + const passage = (request.prompt ?? "").match(/Passage:\n(.*)/)?.[1] ?? ""; + passages.push(passage); + questionNumber += 1; + return { + output: { + type: "short-answer", + question: `Q${questionNumber} about: ${passage.slice(0, 24)}`, + choices: [], + }, + }; + }; + + const queue = [...options.learnerEvents]; + let result = await runAgent(chatWithPdfMachine, { + input: options.input ?? {}, + executors: { generateText }, + }); + + while (result.status === "idle") { + idleLabels.push(idlePrompt(result.snapshot)); + const event = queue.shift(); + if (!event) break; + result = await runAgent(chatWithPdfMachine, { + snapshot: result.persistedSnapshot, + event, + executors: { generateText }, + }); + } + + return { + status: result.status, + output: result.status === "done" ? (result.output as PlayResult["output"]) : undefined, + passages, + gradePrompts, + idleLabels, + }; +} + +const answers = (count: number): LearnerEvent[] => + Array.from({ length: count }, (_, i) => ({ type: "ANSWER", text: `answer ${i + 1}` })); + +describe("chat-with-pdf quiz mode", () => { + test("one question per idle settle: the machine cannot pose two at once", async () => { + const result = await play({ + input: { documentId: "statecharts", maxQuestions: 4, refreshEvery: 2 }, + learnerEvents: answers(4), + }); + + expect(result.status).toBe("done"); + // Four questions asked, four idle settles, four gradings. No batching. + expect(result.passages).toHaveLength(4); + expect(result.idleLabels).toHaveLength(4); + expect(result.output?.answered).toBe(4); + // Each idle label is exactly the one pending question. + for (const label of result.idleLabels) { + expect(label.match(/^Q\d+ about:/m)).not.toBeNull(); + expect(label.match(/Hint: see page \d+/g)).toHaveLength(1); + } + }); + + test("covered pages are excluded by the query, so refreshed batches never repeat a page", async () => { + // 6 questions with refreshEvery 2 forces three retrievals. + const result = await play({ + input: { documentId: "statecharts", maxQuestions: 6, refreshEvery: 2 }, + learnerEvents: answers(6), + }); + + expect(result.status).toBe("done"); + const pages = result.output!.pagesCovered; + expect(pages).toHaveLength(6); + expect(new Set(pages).size).toBe(6); + // Passages came from distinct pages too, not the same top-scoring chunk. + expect(new Set(result.passages).size).toBe(6); + }); + + test("documentId is threaded by the machine: every passage comes from the chosen document", async () => { + const result = await play({ + input: { documentId: "retrieval", maxQuestions: 4, refreshEvery: 2 }, + learnerEvents: answers(4), + }); + + const chosen = SAMPLE_LIBRARY.find((entry) => entry.id === "retrieval")!; + const owned = new Set(chosen.pages.map((page) => page.content)); + expect(result.passages.length).toBeGreaterThan(0); + for (const passage of result.passages) { + expect(owned.has(passage)).toBe(true); + } + expect(result.output?.documentId).toBe("retrieval"); + }); + + test("grading is grounded on the exact passage the question came from", async () => { + const result = await play({ + input: { documentId: "statecharts", maxQuestions: 3, refreshEvery: 3 }, + learnerEvents: answers(3), + }); + + expect(result.gradePrompts).toHaveLength(3); + result.gradePrompts.forEach((prompt, index) => { + expect(prompt).toContain(result.passages[index]!); + expect(prompt).toMatch(/Source passage \(page \d+\)/); + }); + }); + + test("an ambiguous library stops at the document picker, and an unknown choice does not advance", async () => { + const result = await play({ + input: { maxQuestions: 2, refreshEvery: 2 }, + learnerEvents: [ + { type: "SELECT_DOCUMENT", documentId: "not-a-document" }, + { type: "SELECT_DOCUMENT", documentId: "Retrieval Systems" }, + ...answers(2), + ], + }); + + expect(result.status).toBe("done"); + // First settle is the picker; the rejected choice leaves the machine there. + expect(result.idleLabels[0]).toContain("Which document"); + expect(result.idleLabels[1]).toContain("Which document"); + // No retrieval ran against a guessed id. + expect(result.passages.length).toBe(2); + expect(result.output?.documentId).toBe("retrieval"); + }); + + test("running out of fresh pages ends the session instead of repeating", async () => { + // The retrieval doc has 6 pages; ask for 8. + const result = await play({ + input: { documentId: "retrieval", maxQuestions: 8, refreshEvery: 2 }, + learnerEvents: answers(8), + }); + + expect(result.status).toBe("done"); + expect(result.output?.exhausted).toBe(true); + expect(result.output?.answered).toBe(6); + expect(new Set(result.output!.pagesCovered).size).toBe(6); + }); + + test("SKIP advances the loop without scoring", async () => { + const result = await play({ + input: { documentId: "statecharts", maxQuestions: 3, refreshEvery: 3 }, + learnerEvents: [{ type: "SKIP" }, ...answers(2)], + }); + + expect(result.status).toBe("done"); + expect(result.passages).toHaveLength(3); + // Three questions posed, only two graded. + expect(result.output?.answered).toBe(2); + expect(result.output?.pagesCovered).toHaveLength(3); + }); + + test("STOP ends with the score so far", async () => { + const result = await play({ + input: { documentId: "statecharts", maxQuestions: 6, refreshEvery: 3 }, + learnerEvents: [{ type: "ANSWER", text: "yes" }, { type: "STOP" }], + }); + + expect(result.status).toBe("done"); + expect(result.output?.answered).toBe(1); + expect(result.output?.correct).toBe(1); + }); + + test("retrieval stratifies across the document instead of clustering", () => { + const chunks = queryPdfContent({ + documentId: "statecharts", + topic: "", + pageStart: null, + pageEnd: null, + excludePages: [], + limit: 3, + }); + + expect(chunks).toHaveLength(3); + // Nine pages, thirds are 1-3 / 4-6 / 7-9: one page from each band. + expect(chunks.map((chunk) => chunk.pageNumber)).toEqual([1, 4, 7]); + }); + + test("page range is honored by the query", () => { + const chunks = queryPdfContent({ + documentId: "statecharts", + topic: "", + pageStart: 4, + pageEnd: 6, + excludePages: [5], + limit: 5, + }); + + expect(chunks.map((chunk) => chunk.pageNumber)).toEqual([4, 6]); + }); +}); diff --git a/examples/chat-with-pdf/index.ts b/examples/chat-with-pdf/index.ts new file mode 100644 index 00000000..8fd8874b --- /dev/null +++ b/examples/chat-with-pdf/index.ts @@ -0,0 +1,694 @@ +/** + * Chat-with-PDF quiz mode — the same recipe agentcn ships, with the sequencing + * lifted out of the prompt and into the machine. + * + * The agentcn recipe (`registry/*​/chat-with-pdf/instructions.md`) writes the + * quiz loop as prose the model is asked to obey: + * + * - "Ask ONE question at a time" + * - "After 3-4 questions, call the tool AGAIN to get fresh content from + * different pages" + * - "ALWAYS include the documentId parameter to query the correct document" + * - "Every question MUST include a hint telling the user which page has the + * answer" + * - "ONLY create questions from retrieved content" + * - "If multiple documents exist, ask the user which one they want" + * + * Those are a counter, a coverage set, a piece of session state, a formatting + * invariant, a grounding invariant, and a branch. Written as instructions they + * hold for a few turns and then drift, because the *rules* live in the system + * prompt but the *state they talk about* lives only in conversation history. + * + * Here each one is structure instead: + * - one question per entry into `asking`, so "one at a time" is not a request + * - `sinceRefresh` / `refreshEvery` guard, so the refresh is a transition + * - `pagesCovered` passed to retrieval as `excludePages`, so "different pages" + * is enforced by the query, not remembered by the model + * - `documentId` in context, threaded into every retrieval by the machine + * - the page hint is read off the retrieved chunk, never generated + * - grading is grounded on the exact chunk that produced the question + * - `choosingDocument` is a real idle state, so an ambiguous corpus cannot be + * silently guessed past + * + * What stays prose: voice and question formatting (`QUIZ_VOICE`). Models follow + * that well, and encoding it as states would be ceremony. + * + * Retrieval is honest keyword scoring over an in-file corpus (same approach as + * `examples/rag`). A real build swaps `queryPdfContent` for a vector store; the + * machine shape is unchanged. + * + * Run: OPENAI_API_KEY=... npx tsx examples/chat-with-pdf/index.ts + */ +import { z } from "zod"; +import type { SnapshotFrom } from "xstate"; +import { createAsyncLogic } from "xstate"; +import { openai } from "@ai-sdk/openai"; +import { createAiSdkExecutors, defineModels } from "@statelyai/agent/ai-sdk"; +import { createAgentSchemas, getStateMeta, runAgent, setupAgent } from "@statelyai/agent"; + +/** + * The part of the original instructions.md that is genuinely prose: tone and + * question shape. Everything the original said about *sequencing* is gone from + * this string — it moved into the machine below. + */ +const QUIZ_VOICE = [ + "You write quiz questions from a passage of a document.", + "Write exactly one question from the passage you are given.", + "Mix question types across a session: multiple choice, short answer, true/false.", + "For multiple choice, give four plausible options; exactly one is correct.", + "If the passage contains code, include the code in the question so it can be", + "answered without opening the document.", + "Be encouraging — learning is the goal.", +].join("\n"); + +/** A page of an indexed document. Stands in for a chunk in a vector store. */ +const chunkSchema = z.object({ + documentId: z.string(), + pageNumber: z.number(), + content: z.string(), +}); + +type Chunk = z.infer; + +const questionSchema = z.object({ + type: z.enum(["multiple-choice", "short-answer", "true-false"]), + question: z.string(), + /** Empty for short-answer and true/false. */ + choices: z.array(z.string()), +}); + +const gradeSchema = z.object({ + correct: z.boolean(), + explanation: z.string(), +}); + +/** + * Sample data: two indexed "documents". Two, not one, so the ambiguous-corpus + * branch is real rather than hypothetical. + */ +export const SAMPLE_LIBRARY: Array<{ id: string; title: string; pages: Chunk[] }> = [ + { + id: "statecharts", + title: "Statecharts in Practice", + pages: [ + "A state machine is in exactly one of a finite set of states at a time. Events cause transitions between those states.", + "Context is the extended state of a machine: arbitrary data stored alongside the finite state and updated during transitions.", + "A guard is a condition that must hold for a transition to be taken. Guards are how illegal transitions stay impossible.", + "Invoking an actor starts it when a state is entered and stops it when the state is exited. onDone and onError handle its result.", + "Hierarchical states nest: a parent state's transitions apply to every child, so shared handling is written once.", + "Parallel states run several regions at the same time. The machine is in one state per region.", + "A final state signals that a machine or region is done. A top-level final state produces the machine's output.", + "History states remember which child was active when a parent was last exited, so re-entry resumes where it left off.", + "Snapshots serialize the whole running state, which is what makes a paused machine resumable in a different process.", + ].map((content, index) => ({ documentId: "statecharts", pageNumber: index + 1, content })), + }, + { + id: "retrieval", + title: "Retrieval Systems", + pages: [ + "Chunking splits a document into passages small enough to embed but large enough to stand alone when read.", + "An embedding maps text to a vector so that semantically similar passages land near each other.", + "Top-k retrieval returns the k nearest chunks to a query vector. Larger k trades precision for recall.", + "Stratified sampling draws from early, middle, and late sections instead of clustering on whichever section scores highest.", + "Grounding means answering only from retrieved text. An answer with no supporting chunk is a hallucination regardless of how plausible it reads.", + "Citations tie each claim back to the passage it came from, which is what makes a grounded answer checkable.", + ].map((content, index) => ({ documentId: "retrieval", pageNumber: index + 1, content })), + }, +]; + +const STOP_WORDS = new Set([ + "a", + "an", + "the", + "is", + "are", + "of", + "to", + "in", + "and", + "what", + "how", + "why", + "do", + "does", + "about", + "for", + "with", + "that", + "this", + "it", +]); + +/** Shared content words between a topic and a page. Not embeddings. */ +function scorePage(topic: string, text: string): number { + const terms = new Set( + topic + .toLowerCase() + .split(/[^a-z]+/) + .filter((word) => word.length > 2 && !STOP_WORDS.has(word)), + ); + const haystack = text.toLowerCase(); + let score = 0; + for (const term of terms) { + if (haystack.includes(term)) score += 1; + } + return score; +} + +/** + * Take from early, middle, and late thirds in turn. + * + * The original instructions asked the model to notice that "the tool returns a + * stratified sample". Here the tool actually returns one. + */ +function stratify(pages: Chunk[], limit: number): Chunk[] { + if (pages.length <= limit) return pages; + const third = Math.ceil(pages.length / 3); + const bands = [pages.slice(0, third), pages.slice(third, third * 2), pages.slice(third * 2)]; + const out: Chunk[] = []; + for (let round = 0; out.length < limit; round += 1) { + const before = out.length; + for (const band of bands) { + const page = band[round]; + if (page && out.length < limit) out.push(page); + } + // Every band is exhausted — stop rather than spin. + if (out.length === before) break; + } + return out; +} + +export interface QueryPdfInput { + documentId: string; + topic: string; + pageStart: number | null; + pageEnd: number | null; + /** Pages already quizzed on. The query never returns these. */ + excludePages: number[]; + limit: number; +} + +/** Page-range filter, keyword score, exclusion, then stratified sample. */ +export function queryPdfContent(input: QueryPdfInput): Chunk[] { + const document = SAMPLE_LIBRARY.find((entry) => entry.id === input.documentId); + if (!document) return []; + const excluded = new Set(input.excludePages); + const inRange = document.pages.filter( + (page) => + !excluded.has(page.pageNumber) && + (input.pageStart === null || page.pageNumber >= input.pageStart) && + (input.pageEnd === null || page.pageNumber <= input.pageEnd), + ); + // A blank topic means "anywhere in range" — keep page order and stratify. + const candidates = input.topic.trim() + ? inRange + .map((page) => ({ page, score: scorePage(input.topic, page.content) })) + .filter((scored) => scored.score > 0) + .sort( + (left, right) => right.score - left.score || left.page.pageNumber - right.page.pageNumber, + ) + .map((scored) => scored.page) + : inRange; + return stratify(candidates, input.limit); +} + +const askedQuestionSchema = z.object({ + pageNumber: z.number(), + prompt: z.string(), + /** The exact passage the question came from — grading is grounded on it. */ + sourceText: z.string(), +}); + +const resultSchema = z.object({ + pageNumber: z.number(), + prompt: z.string(), + answer: z.string(), + correct: z.boolean(), + explanation: z.string(), +}); + +const models = defineModels({ + quiz: openai("gpt-5.4-mini"), +}); + +/** Typed `meta.interaction` hints a host reads off an idle snapshot. */ +const metaSchema = z.object({ + interaction: z + .object({ + label: z.string(), + events: z + .record( + z.string(), + z.object({ + label: z.string().optional(), + style: z.enum(["primary", "danger", "default"]).optional(), + }), + ) + .optional(), + textEvent: z.string().optional(), + }) + .optional(), +}); + +export const chatWithPdfSchemas = createAgentSchemas({ + meta: metaSchema, + context: z.object({ + /** Session state, not a prompt reminder: every retrieval reads it. */ + documentId: z.string().nullable(), + documentTitle: z.string(), + topic: z.string(), + pageStart: z.number().nullable(), + pageEnd: z.number().nullable(), + maxQuestions: z.number(), + /** Refresh retrieval after this many questions. Was "after 3-4 questions". */ + refreshEvery: z.number(), + chunks: z.array(chunkSchema), + chunkCursor: z.number(), + /** Was "get fresh content from different pages". Now a query parameter. */ + pagesCovered: z.array(z.number()), + questionsAsked: z.number(), + sinceRefresh: z.number(), + pending: askedQuestionSchema.nullable(), + results: z.array(resultSchema), + /** Rendered label for whatever the idle state is waiting on. */ + prompt: z.string(), + /** Set when retrieval comes back empty; explains an early summary. */ + exhausted: z.boolean(), + }), + // Defaults are declared once, here: `runAgent` validates input against this + // schema before the actor starts, so the context factory below receives them + // already filled. A run can start with nothing but a question budget — the + // machine resolves the rest (document choice included) as states. + input: z.object({ + documentId: z.string().nullable().default(null), + topic: z.string().default(""), + pageStart: z.number().nullable().default(null), + pageEnd: z.number().nullable().default(null), + maxQuestions: z.number().default(6), + refreshEvery: z.number().default(3), + }), + output: z.object({ + documentId: z.string(), + correct: z.number(), + answered: z.number(), + pagesCovered: z.array(z.number()), + results: z.array(resultSchema), + exhausted: z.boolean(), + }), + events: { + /** Free text at the document picker; matched against id or title. */ + SELECT_DOCUMENT: z.object({ documentId: z.string() }), + ANSWER: z.object({ text: z.string() }), + SKIP: z.object({}), + STOP: z.object({}), + }, + emitted: { + QUESTION: z.object({ prompt: z.string(), pageNumber: z.number() }), + GRADED: z.object({ correct: z.boolean(), explanation: z.string() }), + }, +}); + +const agentSetup = setupAgent({ + schemas: chatWithPdfSchemas, + models, + // Deterministic idle detection: the states waiting on the human are exactly + // the ones tagged `waiting`. + isSuspended: (snapshot) => snapshot.hasTag("waiting"), + actors: { + // Plain typed actor — no model in the retrieval path. + retrieve: createAsyncLogic({ + run: async ({ input }) => queryPdfContent(input), + }), + }, + requests: { + writeQuestion: { + schemas: { + input: z.object({ + passage: z.string(), + questionNumber: z.number(), + askedSoFar: z.array(z.string()), + }), + output: questionSchema, + }, + model: "quiz", + system: QUIZ_VOICE, + prompt: ({ input }) => + [ + `Passage:\n${input.passage}`, + input.askedSoFar.length + ? `\nAlready asked (vary the type and angle):\n${input.askedSoFar.join("\n")}` + : "", + `\nWrite question ${input.questionNumber} from this passage only.`, + ].join("\n"), + }, + gradeAnswer: { + schemas: { + input: z.object({ + prompt: z.string(), + answer: z.string(), + // Grading sees the source passage, so it cannot grade from memory. + sourceText: z.string(), + pageNumber: z.number(), + }), + output: gradeSchema, + }, + model: "quiz", + system: + "Grade a quiz answer against the source passage ONLY. Be encouraging. " + + "Accept answers that are right in substance even if worded differently. " + + "In the explanation, quote or paraphrase the passage and name the page.", + prompt: ({ input }) => + [ + `Source passage (page ${input.pageNumber}):\n${input.sourceText}`, + `\nQuestion: ${input.prompt}`, + `Learner's answer: ${input.answer}`, + ].join("\n"), + }, + }, + states: { + // `asking` always sets `pending` before `awaitingAnswer` / `grading` read + // it, so those two states can be narrowed non-null. + awaitingAnswer: { context: { pending: askedQuestionSchema } }, + grading: { context: { pending: askedQuestionSchema } }, + }, +}); + +type QuizContext = { + chunks: Chunk[]; + chunkCursor: number; + questionsAsked: number; + maxQuestions: number; + sinceRefresh: number; + refreshEvery: number; +}; + +/** + * The whole quiz loop, in one place instead of four prose bullets: + * budget spent → stop; refresh due or batch drained → retrieve fresh pages; + * otherwise → next question from the current batch. + */ +function nextStep(context: QuizContext): "summary" | "retrieving" | "asking" { + if (context.questionsAsked >= context.maxQuestions) return "summary"; + if (context.sinceRefresh >= context.refreshEvery) return "retrieving"; + if (context.chunkCursor >= context.chunks.length) return "retrieving"; + return "asking"; +} + +/** Assemble the display text. The page hint comes from the chunk, not the model. */ +function renderQuestion(question: z.infer, pageNumber: number): string { + const choices = question.choices.length + ? "\n" + question.choices.map((choice, i) => `${"ABCD"[i]}) ${choice}`).join("\n") + : ""; + return `${question.question}${choices}\n(Hint: see page ${pageNumber})`; +} + +const PICKER_PROMPT = + "Which document do you want to be quizzed on? " + + SAMPLE_LIBRARY.map((entry) => `${entry.title} (${entry.id})`).join(", "); + +export const chatWithPdfMachine = agentSetup.createMachine({ + id: "chat-with-pdf-quiz", + context: ({ input }) => ({ + documentId: input.documentId, + documentTitle: SAMPLE_LIBRARY.find((entry) => entry.id === input.documentId)?.title ?? "", + topic: input.topic, + pageStart: input.pageStart, + pageEnd: input.pageEnd, + maxQuestions: input.maxQuestions, + refreshEvery: input.refreshEvery, + chunks: [], + chunkCursor: 0, + pagesCovered: [], + questionsAsked: 0, + sinceRefresh: 0, + pending: null, + results: [], + prompt: "", + exhausted: false, + }), + initial: "selectingDocument", + states: { + /** + * "If multiple documents exist, ask the user which one" as a branch, not a + * request. A known id goes straight through; a single-document library is + * chosen automatically; anything else has to be resolved before retrieval + * can run at all. + */ + selectingDocument: { + always: ({ context }) => { + const named = SAMPLE_LIBRARY.find((entry) => entry.id === context.documentId); + if (named) { + return { target: "retrieving", context: { documentTitle: named.title } }; + } + if (SAMPLE_LIBRARY.length === 1) { + const only = SAMPLE_LIBRARY[0]!; + return { + target: "retrieving", + context: { documentId: only.id, documentTitle: only.title }, + }; + } + return { target: "choosingDocument", context: { prompt: PICKER_PROMPT } }; + }, + }, + + // Idle: no invoke, so the run settles here for a host to resume. + choosingDocument: { + tags: ["waiting"], + meta: { + interaction: { + label: "{prompt}", + events: { SELECT_DOCUMENT: { label: "Choose" } }, + textEvent: "SELECT_DOCUMENT", + }, + }, + on: { + // An unrecognized choice returns `undefined`: the transition is illegal, + // the machine stays put, and no retrieval runs against a guessed id. + SELECT_DOCUMENT: ({ event }) => { + const wanted = event.documentId.trim().toLowerCase(); + const match = SAMPLE_LIBRARY.find( + (entry) => entry.id.toLowerCase() === wanted || entry.title.toLowerCase() === wanted, + ); + return match + ? { + target: "retrieving", + context: { documentId: match.id, documentTitle: match.title }, + } + : undefined; + }, + }, + }, + + retrieving: { + invoke: { + src: "retrieve", + // `documentId` and `excludePages` are threaded by the machine. The model + // is never asked to remember either. + input: ({ context }) => ({ + documentId: context.documentId ?? "", + topic: context.topic, + pageStart: context.pageStart, + pageEnd: context.pageEnd, + excludePages: context.pagesCovered, + limit: context.refreshEvery, + }), + onDone: ({ output }) => + output.length === 0 + ? // Nothing fresh left in range. Prose has no answer for this case; + // a machine ends the session and says why. + { target: "summary", context: { exhausted: true } } + : { target: "asking", context: { chunks: output, chunkCursor: 0, sinceRefresh: 0 } }, + onError: { target: "summary", context: { exhausted: true } }, + }, + }, + + /** + * One entry, one question. "Ask ONE question at a time" is not an + * instruction here — there is no state in which two can be posed. + */ + asking: { + invoke: { + src: "writeQuestion", + input: ({ context }) => ({ + passage: context.chunks[context.chunkCursor]?.content ?? "", + questionNumber: context.questionsAsked + 1, + askedSoFar: context.results.map((result) => result.prompt), + }), + onDone: ({ context, output }, enq) => { + const chunk = context.chunks[context.chunkCursor]!; + const prompt = renderQuestion(output, chunk.pageNumber); + enq.emit({ type: "QUESTION", prompt, pageNumber: chunk.pageNumber }); + return { + target: "awaitingAnswer", + context: { + pending: { pageNumber: chunk.pageNumber, prompt, sourceText: chunk.content }, + prompt, + chunkCursor: context.chunkCursor + 1, + questionsAsked: context.questionsAsked + 1, + sinceRefresh: context.sinceRefresh + 1, + pagesCovered: [...context.pagesCovered, chunk.pageNumber], + }, + }; + }, + onError: { target: "summary" }, + }, + }, + + awaitingAnswer: { + tags: ["waiting"], + meta: { + interaction: { + label: "{prompt}", + events: { + ANSWER: { label: "Answer", style: "primary" }, + SKIP: { label: "Skip" }, + STOP: { label: "End quiz", style: "danger" }, + }, + textEvent: "ANSWER", + }, + }, + on: { + ANSWER: ({ event }) => ({ + target: "grading", + context: { prompt: event.text }, + }), + // Skipping still advances the loop through the same function grading + // uses, so the two paths cannot drift apart. + SKIP: ({ context }) => ({ target: nextStep(context) }), + STOP: { target: "summary" }, + }, + }, + + grading: { + invoke: { + src: "gradeAnswer", + input: ({ context }) => ({ + prompt: context.pending.prompt, + answer: context.prompt, + sourceText: context.pending.sourceText, + pageNumber: context.pending.pageNumber, + }), + onDone: ({ context, output }, enq) => { + enq.emit({ type: "GRADED", correct: output.correct, explanation: output.explanation }); + const results = [ + ...context.results, + { + pageNumber: context.pending.pageNumber, + prompt: context.pending.prompt, + answer: context.prompt, + correct: output.correct, + explanation: output.explanation, + }, + ]; + return { target: nextStep(context), context: { results, pending: null } }; + }, + onError: ({ context }) => ({ target: nextStep(context), context: { pending: null } }), + }, + }, + + summary: { + type: "final", + output: ({ context }) => ({ + documentId: context.documentId ?? "", + correct: context.results.filter((result) => result.correct).length, + answered: context.results.length, + pagesCovered: context.pagesCovered, + results: context.results, + exhausted: context.exhausted, + }), + }, + }, +}); + +type QuizSnapshot = SnapshotFrom; + +/** What a host sends to unblock an idle machine. */ +export type LearnerEvent = + | { type: "SELECT_DOCUMENT"; documentId: string } + | { type: "ANSWER"; text: string } + | { type: "SKIP" } + | { type: "STOP" }; + +/** `{key}` placeholders in interaction labels resolve against context. */ +export function resolveInteractionLabel(label: string, context: Record): string { + return label.replace(/\{(\w+)\}/g, (_, key: string) => { + const value = context[key]; + return typeof value === "string" || typeof value === "number" ? String(value) : ""; + }); +} + +/** Prompt for whatever the idle state is waiting on, from its meta hint. */ +export function idlePrompt(snapshot: QuizSnapshot): string { + const interaction = getStateMeta(snapshot).interaction; + return resolveInteractionLabel(interaction?.label ?? "?", snapshot.context); +} + +/** Route free text to the idle state's `textEvent`. */ +export function toLearnerEvent(snapshot: QuizSnapshot, text: string): LearnerEvent { + if (text.toLowerCase() === "stop") return { type: "STOP" }; + if (text.toLowerCase() === "skip") return { type: "SKIP" }; + const textEvent = getStateMeta(snapshot).interaction?.textEvent ?? "ANSWER"; + return textEvent === "SELECT_DOCUMENT" + ? { type: "SELECT_DOCUMENT", documentId: text } + : { type: "ANSWER", text }; +} + +export async function main() { + const shared = { + executors: createAiSdkExecutors({ models }), + on: { + QUESTION: ({ prompt }: { prompt: string }) => console.log(`\n${prompt}`), + GRADED: ({ correct, explanation }: { correct: boolean; explanation: string }) => + console.log(`${correct ? "✓" : "✗"} ${explanation}`), + }, + onTransition: (snapshot: QuizSnapshot) => + console.log("[state]", JSON.stringify(snapshot.value)), + }; + + let result = await runAgent(chatWithPdfMachine, { + input: { maxQuestions: 6, refreshEvery: 3 }, + ...shared, + }); + + while (result.status === "idle") { + const text = await promptLine(`${idlePrompt(result.snapshot)}\n> `); + result = await runAgent(chatWithPdfMachine, { + snapshot: result.persistedSnapshot, + event: toLearnerEvent(result.snapshot, text), + ...shared, + }); + } + + if (result.status !== "done") { + throw new Error(`Quiz did not complete: ${result.status}`); + } + + console.log( + `\nScore: ${result.output.correct}/${result.output.answered} — pages covered: ` + + result.output.pagesCovered.join(", ") + + (result.output.exhausted ? " (ran out of fresh pages)" : ""), + ); +} + +/** Prompt once on stdin and resolve the trimmed reply. */ +async function promptLine(query: string): Promise { + const { createInterface } = await import("node:readline/promises"); + const rl = createInterface({ input: process.stdin, output: process.stdout }); + try { + return (await rl.question(query)).trim(); + } finally { + rl.close(); + } +} + +// Run directly (`tsx index.ts`); skipped when a test imports this module. +if (import.meta.url === new URL(process.argv[1]!, "file:").href) { + if (!process.env.OPENAI_API_KEY) { + console.error("Set OPENAI_API_KEY to run this example."); + process.exit(1); + } + main().catch((error) => { + console.error(error); + process.exitCode = 1; + }); +} diff --git a/examples/chat-with-pdf/metadata.json b/examples/chat-with-pdf/metadata.json new file mode 100644 index 00000000..2426e876 --- /dev/null +++ b/examples/chat-with-pdf/metadata.json @@ -0,0 +1,35 @@ +{ + "name": "chat-with-pdf", + "title": "Chat with PDF (quiz mode)", + "kind": "agent-workflow", + "machine": "chatWithPdfMachine", + "origin": { + "type": "port", + "source": "agentcn chat-with-pdf recipe, quiz mode (registry/*/chat-with-pdf/instructions.md)", + "url": "https://github.com/shadcn-labs/agentcn/tree/main/registry/langgraph/chat-with-pdf" + }, + "comparison": { + "targets": ["agentcn", "LangGraph", "Mastra"], + "purpose": "The same quiz recipe agentcn ships as prose instructions, with the sequencing moved into the machine: one question per state entry, a refresh guard instead of \"after 3-4 questions\", covered pages excluded by the query instead of remembered, documentId in context, and page hints read off the retrieved chunk. Voice and question formatting stay in the prompt." + }, + "starters": [ + { + "label": "Statecharts, 6 questions", + "input": { "documentId": "statecharts", "maxQuestions": 6, "refreshEvery": 3 } + }, + { + "label": "Retrieval, pages 1-4", + "input": { + "documentId": "retrieval", + "pageStart": 1, + "pageEnd": 4, + "maxQuestions": 4, + "refreshEvery": 2 + } + }, + { + "label": "Pick a document first", + "input": { "maxQuestions": 4 } + } + ] +} diff --git a/src/events.ts b/src/events.ts index 0e99e53b..5b524f30 100644 --- a/src/events.ts +++ b/src/events.ts @@ -102,9 +102,11 @@ export interface AgentEventDescriptor { inputSchema?: StandardSchemaV1; } -/** Registered event payload schemas, as attached to a machine by `setupAgent`/`createAgentSchemas`. */ +/** Registered schemas, as attached to a machine by `setupAgent`/`createAgentSchemas`. */ export interface AgentSchemas { events?: Record; + /** Machine input schema; `runAgent` validates `options.input` against it. */ + input?: StandardSchemaV1; } /** Shared options threaded through step discovery ({@link getAgentRequests}/{@link getAcceptedEvents}) — snapshot for event legality, event schemas for payload validation/tool schemas, and registered actor source logics. */ diff --git a/src/index.ts b/src/index.ts index 63051821..fdf52900 100644 --- a/src/index.ts +++ b/src/index.ts @@ -144,6 +144,7 @@ export type { AgentRequestSource, } from "./events.js"; export type { + AgentInputFrom, AgentMessageInfo, AgentRunMeta, AgentStateRequest, @@ -206,6 +207,7 @@ export type { ChosenEvent, FilePart, ImagePart, + InferInput, InferOutput, StandardSchemaV1, SystemMessage, diff --git a/src/run-agent.test.ts b/src/run-agent.test.ts index e04cdbd6..92c4fc69 100644 --- a/src/run-agent.test.ts +++ b/src/run-agent.test.ts @@ -4,6 +4,7 @@ import { createActor, createAsyncLogic, setup, toPromise } from "xstate"; import { createDecisionLogic } from "./decision.js"; import { AGENT_TRACE_SCHEMA_VERSION, + AgentError, AgentIdleError, createAgentSchemas, createTextLogic, @@ -3706,3 +3707,120 @@ describe("runAgent usage aggregation", () => { expect(result.usage).toEqual({ inputTokens: 8, outputTokens: 2, modelCalls: 1 }); }); }); + +describe("machine input validation", () => { + // A machine whose input schema defaults every field except `topic`, and whose + // context simply mirrors what the factory was handed. + const buildMachine = () => + setupAgent({ + schemas: createAgentSchemas({ + context: z.object({ topic: z.string(), rounds: z.number(), tone: z.string() }), + input: z.object({ + topic: z.string(), + rounds: z.number().default(3), + tone: z.string().default("neutral"), + }), + output: z.object({ topic: z.string(), rounds: z.number(), tone: z.string() }), + }), + }).createMachine({ + id: "input-defaults", + context: ({ input }) => ({ topic: input.topic, rounds: input.rounds, tone: input.tone }), + initial: "done", + states: { + done: { + type: "final", + output: ({ context }) => context, + }, + }, + }); + + test("fills schema defaults before the context factory runs", async () => { + const result = await runAgent(buildMachine(), { input: { topic: "otters" } }); + + expect(result.status).toBe("done"); + if (result.status !== "done") throw new Error("expected done"); + // `rounds`/`tone` were never passed; the schema supplied them. + expect(result.output).toEqual({ topic: "otters", rounds: 3, tone: "neutral" }); + }); + + test("defaulted fields are optional at the call site, required ones are not", async () => { + // Compile-time half: `{ topic }` alone type-checks above because `rounds` + // and `tone` are defaulted, while `topic` (no default) stays required. + await expect( + runAgent(buildMachine(), { + // @ts-expect-error `topic` has no default, so it cannot be omitted + input: { rounds: 1 }, + }), + ).rejects.toThrow(AgentError); + }); + + test("explicit values win over defaults", async () => { + const result = await runAgent(buildMachine(), { + input: { topic: "otters", rounds: 9 }, + }); + + if (result.status !== "done") throw new Error("expected done"); + expect(result.output).toEqual({ topic: "otters", rounds: 9, tone: "neutral" }); + }); + + test("the replayable init entry carries post-default input, so a replay reproduces the run", async () => { + const result = await runAgent(buildMachine(), { input: { topic: "otters" } }); + + const init = result.events[0] as AgentLogEntry & { event?: { input?: unknown } }; + expect(init.event?.input).toEqual({ topic: "otters", rounds: 3, tone: "neutral" }); + }); + + test("invalid input rejects with an AgentError, like a bad resume snapshot", async () => { + // Thrown, not settled: the actor never starts, so this is a bad call rather + // than a machine failure (same shape as AgentSnapshotVersionMismatchError). + await expect( + runAgent(buildMachine(), { + // `rounds` is not a number. + input: { topic: "otters", rounds: "nine" } as never, + }), + ).rejects.toThrow(AgentError); + + const error: unknown = await runAgent(buildMachine(), { + input: { topic: "otters", rounds: "nine" } as never, + }).catch((caught: unknown) => caught); + expect((error as AgentError).code).toBe("invalid-machine-input"); + }); + + test("omitted input stays omitted rather than being validated as {}", async () => { + // A machine that tolerates no input at all: omitting it must not start + // failing just because a schema with required fields is declared. + const machine = setupAgent({ + schemas: createAgentSchemas({ + context: z.object({ topic: z.string() }), + input: z.object({ topic: z.string() }), + output: z.object({ topic: z.string() }), + }), + }).createMachine({ + id: "no-input", + context: ({ input }) => ({ topic: input?.topic ?? "(none)" }), + initial: "done", + states: { done: { type: "final", output: ({ context }) => context } }, + }); + + const result = await runAgent(machine, {}); + + if (result.status !== "done") throw new Error("expected done"); + expect(result.output).toEqual({ topic: "(none)" }); + }); + + test("a machine with no declared input schema passes input through untouched", async () => { + const machine = setup({ + schemas: { context: z.object({ seen: z.unknown() }) }, + }).createMachine({ + id: "plain", + context: ({ input }) => ({ seen: input }), + initial: "done", + states: { done: { type: "final", output: ({ context }) => context.seen } }, + }); + + const result = await runAgent(machine, { input: { anything: 1 } as never }); + + if (result.status !== "done") throw new Error("expected done"); + expect(result.output).toEqual({ anything: 1 }); + }); +}); diff --git a/src/run-agent.ts b/src/run-agent.ts index 0a28b410..5de0cd73 100644 --- a/src/run-agent.ts +++ b/src/run-agent.ts @@ -16,12 +16,21 @@ import { type Snapshot, type SnapshotFrom, } from "xstate"; -import type { AgentMessage, AgentTools, ChosenEvent } from "./types.js"; +import type { + AgentMessage, + AgentTools, + ChosenEvent, + InferInput, + StandardSchemaV1, + WithAgentInputSchema, +} from "./types.js"; import { AgentError } from "./errors.js"; import { findNonSerializableContextPaths, getAgentMessages, getMachineStructuralHash, + isStandardSchema, + validateSchemaSync, } from "./utils.js"; import { runStateRequestPass, @@ -480,8 +489,15 @@ export interface RunAgentOptions { */ executors?: Partial; - /** Machine input, passed straight to `createActor(machine, { input })`. Omit when resuming via `snapshot`. */ - input?: InputFrom; + /** + * Machine input. Validated against the machine's declared input schema — + * defaults filled, transforms applied — before it reaches + * `createActor(machine, { input })` and the replayable event log; invalid + * input throws an {@link AgentError} with code `invalid-machine-input`. + * Typed as {@link AgentInputFrom}, so fields the schema defaults are optional + * here. Omit when resuming via `snapshot`. + */ + input?: AgentInputFrom; // resume /** A previously-settled run's `result.snapshot`, to resume from instead of starting fresh. Pair with `event` to deliver the event that unblocks the resumed idle state. */ @@ -1594,6 +1610,52 @@ export function bindDecisionForProvide( return createRunAgentDecisionLogic(logic, provideBindContext(machine, executors, options)); } +/** + * The machine input a run accepts, which is the schema's *pre*-validation side. + * + * XState's `schemas` are types only — it never validates, and it resolves + * `schemas.input` to one type shared by `createActor`'s `input` option and the + * `context: ({ input })` factory. A schema field declared with a default + * therefore reads as required at the call site even though the caller is meant + * to omit it. `setupAgent` brands the machine's input type with its own schema + * ({@link WithAgentInputSchema}), so this recovers the looser caller-facing + * side while the factory keeps seeing the validated one. Machines with no + * declared input schema — and machines reached through `.provide(...)`, which + * drops the brand — fall back to xstate's `InputFrom`. + */ +export type AgentInputFrom = + InputFrom extends WithAgentInputSchema + ? [TInputSchema] extends [StandardSchemaV1] + ? InferInput + : InputFrom + : InputFrom; + +/** + * Validates `input` against the machine's registered input schema, returning + * the schema's output — so defaults are filled and transforms applied before + * the value reaches `createActor` or the replayable event log. + * + * Standard Schema only (no validation library is referenced), so this works for + * whatever the machine was declared with. Omitted input stays omitted rather + * than being validated as `{}`: "started with no input" keeps meaning what it + * has always meant, instead of newly failing schemas with required fields. + */ +function resolveMachineInput(machine: AnyStateMachine, input: unknown): unknown { + if (input === undefined) return input; + const schema = getRegisteredAgentExecutionOptions(machine).schemas?.input; + if (!isStandardSchema(schema)) return input; + try { + return validateSchemaSync(schema, input); + } catch (error) { + throw new AgentError( + "invalid-machine-input", + `runAgent: machine input failed validation against the declared input ` + + `schema: ${error instanceof Error ? error.message : String(error)}`, + { cause: error }, + ); + } +} + /** * Recursively rebinds an invoked child machine's own agent sources with the * SAME host-backed wrappers runAgent applies to the top-level machine, so a @@ -1763,6 +1825,11 @@ function createAgentSession( let warnedHeuristicIdle = false; const runId = `run_${nextRunAgentTraceId++}`; let traceSeq = 0; + // Validated once, then used everywhere `options.input` would have been: the + // actor, the replayable init entry, and the `run.start` trace all see the + // same post-defaults value, so a replay reproduces this run exactly even if a + // schema default is computed rather than constant. + const resolvedInput = resolveMachineInput(machine, options.input); // Version stamping (§ item 2): every settled snapshot carries a plain, // enumerable `agentMeta` field so it survives JSON persist/resume, and an @@ -2174,7 +2241,7 @@ function createAgentSession( return entry; }; if (replayEvents.length === 0 && effectiveSnapshot === undefined) { - const entry = initEntry(machine, options.input, { machineVersion }); + const entry = initEntry(machine, resolvedInput, { machineVersion }); replayEventIds.add(entry.id); replayEvents.push(entry); options.onEvent?.(entry); @@ -2409,7 +2476,7 @@ function createAgentSession( }; actor = createActor(boundMachine, { - input: options.input as never, + input: resolvedInput as never, snapshot: effectiveSnapshot, inspect: (event: InspectionEvent) => { // System-wide passthrough (children included) before runAgent's own @@ -2593,7 +2660,7 @@ function createAgentSession( onTrace({ type: "run.start", - ...(options.input !== undefined ? { input: options.input } : {}), + ...(resolvedInput !== undefined ? { input: resolvedInput as InputFrom } : {}), ...(effectiveSnapshot !== undefined ? { snapshot: effectiveSnapshot } : {}), ...(options.event !== undefined ? { event: options.event } : {}), }); diff --git a/src/setup-agent.ts b/src/setup-agent.ts index 16084807..8f8d95f1 100644 --- a/src/setup-agent.ts +++ b/src/setup-agent.ts @@ -15,9 +15,11 @@ import type { AgentEventSchemaInputMap, AgentMessage, EventUnion, + InferInput, InferOutput, NormalizedEventSchemas, StandardSchemaV1, + WithAgentInputSchema, } from "./types.js"; import { builtinTextActors, @@ -392,6 +394,30 @@ type AgentSetupEmittedSchema event.n` lost `n` under the old alias.) +/** + * The input schema as handed to xstate's `setup(...)`, with the machine's input + * type branded by the schema it came from. + * + * XState resolves `schemas.input` to a single type used both by + * `createActor`'s `input` option and by the `context: ({ input })` factory — + * and it never validates, so a schema default reads as a required field at the + * call site while being absent at runtime. `runAgent` validates the input + * (filling defaults) and reads this brand back through `AgentInputFrom` to + * accept the schema's looser *input* side, while the factory keeps seeing the + * validated *output* side. + * + * Only object-shaped input is branded: with no declared input schema the + * resolved type is xstate's `NonReducibleUnknown` (a union including `null`), + * and intersecting a brand into that collapses members to `never`. + */ +type BrandedInputSchema = + InferOutput extends Record + ? StandardSchemaV1< + InferInput, + InferOutput & WithAgentInputSchema + > + : TInputSchema; + type AgentSetupXStateConfig< TContextSchema extends StandardSchemaV1>, TEventSchemas extends AgentEventSchemaInputMap, @@ -406,7 +432,7 @@ type AgentSetupXStateConfig< > = { schemas: { context: TContextSchema; - input: TInputSchema; + input: BrandedInputSchema; output: TOutputSchema; meta: TMetaSchema; } & AgentSetupEventsSchema & diff --git a/src/types.ts b/src/types.ts index c292ac1d..c552ada8 100644 --- a/src/types.ts +++ b/src/types.ts @@ -35,6 +35,35 @@ export interface StandardSchemaV1 { /** The validated output type of a {@link StandardSchemaV1}. */ export type InferOutput = T extends StandardSchemaV1 ? O : never; +/** + * The *pre*-validation input type of a {@link StandardSchemaV1}: what a caller + * passes in, before defaults are filled and transforms applied. Standard Schema + * carries both sides (`~standard.types.input` / `.output`), so a schema + * declaring a defaulted field makes that field optional here and required in + * {@link InferOutput} — which is exactly the split between what `runAgent` + * accepts as machine `input` and what the `context` factory then sees. + */ +export type InferInput = T extends StandardSchemaV1 ? I : never; + +/** + * Phantom brand carrying a machine's declared input schema on the machine type. + * + * XState resolves `schemas.input` to a single type and uses it for both + * `createActor`'s `input` option and the `context: ({ input })` factory, so the + * caller-facing and factory-facing sides cannot differ there. `setupAgent`'s + * `createMachine` brands the machine's input type with the schema itself, which + * lets `AgentInputFrom` recover the looser input side for `runAgent` while the + * `context` factory keeps the strict validated side. + * + * The key is a `~`-prefixed phantom property (the same convention Standard + * Schema uses for `~standard`) rather than a `unique symbol`: a symbol would + * have to be exported as a runtime value for declaration emit to name it in + * every machine type it touches. + */ +export type WithAgentInputSchema = { + readonly "~agent.inputSchema"?: TInputSchema; +}; + /** An event schema's output, widened to `unknown` when it validates an empty object (no payload fields). */ export type EventPayload = T extends Record ? unknown : T;