Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/memory-schema-guard.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions packages/cli/src/runtime-exports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
16 changes: 14 additions & 2 deletions packages/core/src/capabilities/built-in/memory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
29 changes: 29 additions & 0 deletions packages/core/test/capabilities/memory.test.ts
Original file line number Diff line number Diff line change
@@ -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() {
Expand Down Expand Up @@ -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: {} }
Comment thread
blove marked this conversation as resolved.
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"))
Expand Down