diff --git a/.changeset/memory-schema-guard.md b/.changeset/memory-schema-guard.md new file mode 100644 index 00000000..41690e97 --- /dev/null +++ b/.changeset/memory-schema-guard.md @@ -0,0 +1,5 @@ +--- +"@dawn-ai/core": patch +--- + +Guard the route memory schema before use: a non-Zod `context.memory.schema` value now falls back to a permissive `data` shape for the `remember` tool instead of being cast and failing opaquely at tool-schema use time. diff --git a/packages/cli/src/runtime-exports.ts b/packages/cli/src/runtime-exports.ts index 845523f1..a39ba285 100644 --- a/packages/cli/src/runtime-exports.ts +++ b/packages/cli/src/runtime-exports.ts @@ -6,6 +6,8 @@ */ export { __resetMaterializedAgentsForTests } from "@dawn-ai/langchain" +// Exported only to support @dawn-ai/testing's live-smoke memory tests; not a +// stable public surface — safe to gate (NODE_ENV) or relocate if it grows. export { runMemoryCommand } from "./commands/memory.js" export { createRuntimeRegistry, type RuntimeRegistry } from "./lib/dev/runtime-registry.js" export { diff --git a/packages/core/src/capabilities/built-in/memory.ts b/packages/core/src/capabilities/built-in/memory.ts index 38289620..598a9336 100644 --- a/packages/core/src/capabilities/built-in/memory.ts +++ b/packages/core/src/capabilities/built-in/memory.ts @@ -4,6 +4,15 @@ import type { CapabilityMarker, PromptFragment } from "../types.js" const DEFAULT_SEMANTIC_IDENTITY = ["subject", "predicate"] as const +// A route's defineMemory() schema arrives as `unknown` (loaded via dynamic +// import, validated structurally). Module-scoped (no closure deps) so it isn't +// recreated on every load(). A non-Zod value must NOT be handed to z.object() +// as the remember tool's `data` shape — it would blow up opaquely at use time. +const isZodSchema = (s: unknown): s is z.ZodTypeAny => + typeof s === "object" && + s !== null && + typeof (s as { safeParse?: unknown }).safeParse === "function" + /** * Long-term memory (L3): contributes `recall` and `remember` tools backed by a * typed, namespaced memory store, plus a memory-index prompt fragment listing @@ -27,8 +36,11 @@ export function createMemoryMarker(): CapabilityMarker { // Tool input schemas exposed to the MODEL (so it knows what to pass). The // `remember.data` shape is the route's own defineMemory() zod schema; without // this the model calls remember/recall with the wrong/empty args and writes - // are rejected by validate(). Falls back to a permissive map if absent. - const routeDataSchema = (mem.schema ?? z.record(z.string(), z.unknown())) as z.ZodTypeAny + // are rejected by validate(). Guarded (see isZodSchema) so a non-Zod value + // falls back to a permissive map instead of failing opaquely. + const routeDataSchema: z.ZodTypeAny = isZodSchema(mem.schema) + ? mem.schema + : z.record(z.string(), z.unknown()) const rememberSchema = z.object({ data: routeDataSchema, content: z diff --git a/packages/core/test/capabilities/memory.test.ts b/packages/core/test/capabilities/memory.test.ts index 6785c754..7296d6ac 100644 --- a/packages/core/test/capabilities/memory.test.ts +++ b/packages/core/test/capabilities/memory.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest" +import { z } from "zod" import { createMemoryMarker } from "../../src/capabilities/built-in/memory.js" function fakeStore() { @@ -97,6 +98,34 @@ describe("memory capability", () => { expect(out).toContain("m1") }) + it("falls back to a permissive data schema when context.memory.schema is not a Zod type", async () => { + const ctx = baseCtx(fakeStore()) + // A non-Zod value (e.g. a plain JSON-Schema object slipping through + // loadRouteMemory) must NOT be handed to z.object() as the remember tool's + // `data` shape — that would blow up opaquely at tool-schema use time. The + // guard falls back to a permissive record instead. + ;(ctx.memory as { schema?: unknown }).schema = { type: "object", properties: {} } + const c = await createMemoryMarker().load("/r", ctx) + const remember = c.tools!.find((t) => t.name === "remember")! + // safeParse must not throw and must accept arbitrary data under the fallback. + const parsed = (remember.schema as z.ZodTypeAny).safeParse({ + data: { anything: "goes" }, + content: "x", + }) + expect(parsed.success).toBe(true) + }) + + it("uses the route's Zod schema as the remember `data` shape when provided", async () => { + const ctx = baseCtx(fakeStore()) + ;(ctx.memory as { schema?: unknown }).schema = z.object({ subject: z.string() }) + const c = await createMemoryMarker().load("/r", ctx) + const remember = c.tools!.find((t) => t.name === "remember")! + const schema = remember.schema as z.ZodTypeAny + expect(schema.safeParse({ data: { subject: "acme" }, content: "x" }).success).toBe(true) + // The real schema is actually exercised — a wrong-typed field is rejected. + expect(schema.safeParse({ data: { subject: 5 }, content: "x" }).success).toBe(false) + }) + it("auto mode ADDs a new active record", async () => { const store = fakeStore() const c = await createMemoryMarker().load("/r", ctxWith(store, "auto"))