From b4973eedbaf1a3e4915dcff9d993c47d9fcd636e Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 27 Jun 2026 15:16:48 -0700 Subject: [PATCH 1/2] fix(core): guard non-Zod memory schema instead of silently casting it The remember tool's `data` shape comes from `context.memory.schema`, which arrives as `unknown` (loaded via dynamic import, validated structurally). The previous `as z.ZodTypeAny` cast was silent: a non-Zod value (absent, or a plain JSON-Schema object) was handed to `z.object()` and blew up with an opaque "expected a Zod schema" error at tool-schema use time. Guard it with an `isZodSchema` check and fall back to a permissive record instead. Also documents `runMemoryCommand`'s `@dawn-ai/cli/runtime` export as a test-only surface, not a stable public API. Addresses the advisory review feedback carried over from #257. Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/memory-schema-guard.md | 5 ++++ packages/cli/src/runtime-exports.ts | 2 ++ .../core/src/capabilities/built-in/memory.ts | 14 +++++++-- .../core/test/capabilities/memory.test.ts | 29 +++++++++++++++++++ 4 files changed, 48 insertions(+), 2 deletions(-) create mode 100644 .changeset/memory-schema-guard.md 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..164b80ce 100644 --- a/packages/core/src/capabilities/built-in/memory.ts +++ b/packages/core/src/capabilities/built-in/memory.ts @@ -27,8 +27,18 @@ 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(). `mem.schema` arrives as `unknown` (loaded via + // dynamic import, validated structurally), so guard it rather than casting + // blindly: a non-Zod value (absent, or a plain JSON-Schema object) would + // otherwise be handed to z.object() below and blow up opaquely at use time. + // Falls back to a permissive map in that case. + const isZodSchema = (s: unknown): s is z.ZodTypeAny => + typeof s === "object" && + s !== null && + typeof (s as { safeParse?: unknown }).safeParse === "function" + 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")) From e4282d47976fffa5f974cbed81faaebf9ee13f67 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 27 Jun 2026 15:24:00 -0700 Subject: [PATCH 2/2] refactor(core): hoist isZodSchema to module scope The guard helper has no closure dependencies and load() runs per-request, so keep it module-scoped rather than recreating it each call. Addresses review feedback on #279. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../core/src/capabilities/built-in/memory.ts | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/packages/core/src/capabilities/built-in/memory.ts b/packages/core/src/capabilities/built-in/memory.ts index 164b80ce..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,15 +36,8 @@ 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(). `mem.schema` arrives as `unknown` (loaded via - // dynamic import, validated structurally), so guard it rather than casting - // blindly: a non-Zod value (absent, or a plain JSON-Schema object) would - // otherwise be handed to z.object() below and blow up opaquely at use time. - // Falls back to a permissive map in that case. - const isZodSchema = (s: unknown): s is z.ZodTypeAny => - typeof s === "object" && - s !== null && - typeof (s as { safeParse?: unknown }).safeParse === "function" + // 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())