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;