From c07ab05aea0f3a15acba2c49cc5adb9166262f89 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 10:49:13 +0000 Subject: [PATCH 1/9] =?UTF-8?q?wip(spec):=20agent.memory=20contract=20?= =?UTF-8?q?=E2=80=94=20refinement,=20store=20tombstone,=20D2/D3,=20ledger?= =?UTF-8?q?=20flip?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01YDt3PzwfrkuFzUBF89WPmM Co-authored-by: Claude --- packages/spec/liveness/agent.json | 8 +- packages/spec/src/ai/agent.test.ts | 463 +++++++++++++++++- packages/spec/src/ai/agent.zod.ts | 154 +++++- packages/spec/src/conversions/registry.ts | 110 +++++ .../18.ai__Agent__memory.longTerm.store.ts | 27 + ...emory-store-retired-and-limits-required.ts | 48 ++ packages/spec/src/migrations/registry.ts | 84 ++++ 7 files changed, 864 insertions(+), 30 deletions(-) create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.ai__Agent__memory.longTerm.store.ts create mode 100644 packages/spec/src/migrations/entries/semantic/18.agent-memory-store-retired-and-limits-required.ts diff --git a/packages/spec/liveness/agent.json b/packages/spec/liveness/agent.json index 6c731a4cf42..febb7851fcf 100644 --- a/packages/spec/liveness/agent.json +++ b/packages/spec/liveness/agent.json @@ -90,9 +90,11 @@ "note": "aspirational autonomy." }, "memory": { - "status": "experimental", - "evidence": "no runtime reader", - "note": "aspirational autonomy." + "status": "live", + "verifiedAt": "2026-10-02", + "evidenceScope": "cross-repo", + "evidence": "cloud @ef5a4344: packages/service-ai/src/agent-runtime.ts#compileAgentMemory — the reader: it re-parses the agent's memory with this spec's own AgentSchema.shape.memory, adding no default of its own, and compiles it into the turn's memory policy, reached through AgentRuntime.resolveTurnGuardrails; cloud @ef5a4344: packages/service-ai/src/ai-service.ts — the enforcement: the newest maxEntries distilled notes for the user and agent are recalled before the first round as an unstored system block, every reflectionInterval delivered interactions one guarded, metered, answer-only call writes one note, notes beyond maxEntries are evicted, and a reflection refused by a limit is deferred, never dropped; cloud @ef5a4344: packages/service-ai/src/agent-memory.ts#AgentMemoryStore — the store, over the object ai_agent_memories (access private, no API).", + "note": "Flipped from experimental on the cloud seat's reading, not on a reading taken from this repo — no seat in the flipping session could read cloud. The reading: the repo:cloud seat repo:cloud#1 (session_01Wxo1xhh2bU66T73q23jzE4), comment 5946891697 on objectstack-ai/objectstack#20274, of cloud @ef5a4344 (objectstack-ai/cloud#2594, merged for objectstack-ai/cloud#2568), read 2026-10-02; verifiedAt is that reading's date, and every evidence pointer above is quoted from it. The blanket verdict covers every child: longTerm.enabled, longTerm.maxEntries and reflectionInterval. The same change made the contract state exactly what that reader honours (ruling record 5950198150, letter A′): once longTerm.enabled is true, maxEntries and reflectionInterval are required, and reflectionInterval is refused without it — each refused at parse with a prescription (checkAgentMemoryContract in packages/spec/src/ai/agent.zod.ts), no default declared — and longTerm.store is retired as a whole key (retiredKey; the ADR-0087 conversion agent-memory-long-term-store-removed repairs stored and existing sources). ⚠ THE WINDOW, stated rather than smoothed: at ef5a4344 the reader still READS longTerm.store — it honours database only, and refuses vector (what an omitted store parsed to under the old default) and redis with AI_AGENT_MEMORY_UNSUPPORTED. Per the ruling, cloud drops store in that one reader — using its database store and dropping the vector / redis refusals — once the spec release carrying this retirement reaches cloud's pin, and no earlier; until then cloud parses with its pinned spec, where an omitted store still defaults to vector. So this row is live for enabled, maxEntries and reflectionInterval today, and the store half of the reader is a declared follow-up in cloud, not a reading of it. Producer: none outside packages/spec in this repo (measured); none among cloud's built-in agents, per the ruling record's own census; tenant-authored agents are NOT MEASURED. These keys are live because the cloud runtime consumes them; the OPEN framework edition does not run agents (this file's _note). ⛔ Re-attest by re-reading cloud, never by re-stamping the date." }, "guardrails": { "status": "live", diff --git a/packages/spec/src/ai/agent.test.ts b/packages/spec/src/ai/agent.test.ts index 1d8cb79fccd..61ae84f8a8f 100644 --- a/packages/spec/src/ai/agent.test.ts +++ b/packages/spec/src/ai/agent.test.ts @@ -1,4 +1,14 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + import { describe, it, expect } from 'vitest'; + +import { applyConversions, collectConversionNotices } from '../conversions/apply'; +import { ALL_CONVERSIONS } from '../conversions/registry'; +import { applyConversionsToStoredItem } from '../conversions/stored'; +import { MIGRATIONS_BY_MAJOR, RETIRED_KEYS_BY_MAJOR } from '../migrations/registry'; +import { defineStack, ObjectStackDefinitionSchema } from '../stack.zod'; import { AgentSchema, AIModelConfigSchema, @@ -465,7 +475,10 @@ Be precise, data-driven, and clear in your explanations.`, }); describe('Memory Management', () => { - it('should accept agent with memory configuration', () => { + // Flipped from the old acceptance: `store` is retired and the two numbers + // are required once long-term memory is enabled (ADR-0049, #20274). The + // refusals themselves are pinned in "agent memory contract" below. + it('should accept agent with memory configuration, and materialize no store', () => { const agent = AgentSchema.parse({ name: 'memory_agent', label: 'Memory Agent', @@ -474,7 +487,6 @@ Be precise, data-driven, and clear in your explanations.`, memory: { longTerm: { enabled: true, - store: 'vector', maxEntries: 10000, }, reflectionInterval: 5, @@ -482,23 +494,23 @@ Be precise, data-driven, and clear in your explanations.`, }); expect(agent.memory?.longTerm?.enabled).toBe(true); - expect(agent.memory?.longTerm?.store).toBe('vector'); + expect(agent.memory?.longTerm?.maxEntries).toBe(10000); expect(agent.memory?.reflectionInterval).toBe(5); + // The retired key's old default no longer appears: absence stays absence. + expect(agent.memory?.longTerm).not.toHaveProperty('store'); }); - it('should accept all memory store backends', () => { - const stores = ['vector', 'database', 'redis'] as const; - - stores.forEach(store => { + it('should accept memory without enabled long-term memory', () => { + for (const memory of [{}, { longTerm: {} }, { longTerm: { enabled: false } }, { longTerm: { enabled: false, maxEntries: 5 } }]) { const agent = AgentSchema.parse({ name: 'test_agent', label: 'Test', role: 'Test', instructions: 'Test', - memory: { longTerm: { enabled: true, store } }, + memory, }); - expect(agent.memory?.longTerm?.store).toBe(store); - }); + expect(agent.memory?.reflectionInterval).toBeUndefined(); + } }); }); @@ -770,3 +782,434 @@ describe('defineAgent', () => { })).toThrow(); }); }); + +// ─── The agent memory contract (ADR-0049, ruling record 5950198150, letter A′) ── +// +// The cloud AI runtime, the one runtime that executes agents, enforces +// long-term memory from `enabled`, `maxEntries` and `reflectionInterval`, and +// refused before an agent's first turn exactly the declarations the spec still +// accepted. Authoring now refuses them, by name, with a prescription; and the +// storage backend left the spec as a whole key. On the assertion set: a schema +// refusal is a ZodError whose issues carry `code` and `path` but no ADR-0112 +// `status` — so the schema pins assert refusal, the issue `code`, the `path` +// naming the key and the prescription text, and the authoring door +// (`defineStack`) is pinned with its envelope's `code` and `status`. + +const MEMORY_AGENT = { + name: 'memory_agent', + label: 'Memory Agent', + role: 'Assistant', + instructions: 'Remember what the user told you.', +} as const; + +/** The store tombstone's prescription: key first, why, the fix, the migrate sentence. */ +const STORE_PRESCRIPTION = + /^`agent\.memory\.longTerm\.store` was removed in @objectstack\/spec 17\.7\.0 \(ADR-0049 enforce-or-remove\) — the memory store is platform infrastructure, not agent metadata: .*Delete the key; long-term memory is configured by `enabled`, `maxEntries` and `agent\.memory\.reflectionInterval`/s; + +/** A stack an authoring door accepts, carrying one agent. */ +const stackWith = (agent: Record) => ({ + manifest: { id: 'com.example.agent-memory', name: 'agent_memory', version: '1.0.0', type: 'app' }, + agents: [agent], +}); + +/** The one refusal `defineStack` raised, as its ADR-0112 envelope. */ +const stackRefusal = (agent: Record) => { + let thrown: unknown; + try { + defineStack(stackWith(agent) as never); + } catch (e) { + thrown = e; + } + return thrown as { code?: string; status?: number; issues?: Array<{ path: PropertyKey[]; message: string }> }; +}; + +describe('agent memory contract — what the runtime honours is what authoring accepts', () => { + it('accepts an enabled long-term memory carrying both numbers', () => { + const r = AgentSchema.safeParse({ ...MEMORY_AGENT, memory: { longTerm: { enabled: true, maxEntries: 20 }, reflectionInterval: 5 } }); + expect(r.success, JSON.stringify(r.error?.issues ?? [])).toBe(true); + }); + + it('refuses an enabled longTerm without `maxEntries`, at its path, naming the key and the fix', () => { + const r = AgentSchema.safeParse({ ...MEMORY_AGENT, memory: { longTerm: { enabled: true }, reflectionInterval: 5 } }); + expect(r.success).toBe(false); + const issues = r.error!.issues; + expect(issues).toHaveLength(1); + expect(issues[0].code).toBe('custom'); + expect(issues[0].path).toEqual(['memory', 'longTerm', 'maxEntries']); + expect(issues[0].message.split(' — ')[0]).toBe( + '`agent.memory.longTerm.maxEntries` is required when `agent.memory.longTerm.enabled` is true', + ); + expect(issues[0].message).toContain('the spec declares no default for it'); + expect(issues[0].message).toContain('Declare it as an integer of at least 1'); + }); + + it('refuses an enabled longTerm without `reflectionInterval` — the declaration cloud refused at turn time', () => { + // `{ enabled: true, maxEntries: 5 }` is the most natural declaration, and the + // one the runtime refused before the first round: no reflection, no note. + const r = AgentSchema.safeParse({ ...MEMORY_AGENT, memory: { longTerm: { enabled: true, maxEntries: 5 } } }); + expect(r.success).toBe(false); + const issues = r.error!.issues; + expect(issues).toHaveLength(1); + expect(issues[0].code).toBe('custom'); + expect(issues[0].path).toEqual(['memory', 'reflectionInterval']); + expect(issues[0].message.split(' — ')[0]).toBe( + '`agent.memory.reflectionInterval` is required when `agent.memory.longTerm.enabled` is true', + ); + expect(issues[0].message).toContain('the spec declares no default for it'); + }); + + it('names BOTH missing numbers in one pass, each at its own path', () => { + const r = AgentSchema.safeParse({ ...MEMORY_AGENT, memory: { longTerm: { enabled: true } } }); + expect(r.success).toBe(false); + expect(r.error!.issues.map((i) => [i.code, i.path])).toEqual([ + ['custom', ['memory', 'longTerm', 'maxEntries']], + ['custom', ['memory', 'reflectionInterval']], + ]); + }); + + it('refuses `reflectionInterval` without an enabled longTerm — absent, empty or disabled', () => { + for (const longTerm of [undefined, {}, { enabled: false }, { enabled: false, maxEntries: 5 }]) { + const memory = longTerm === undefined ? { reflectionInterval: 5 } : { longTerm, reflectionInterval: 5 }; + const r = AgentSchema.safeParse({ ...MEMORY_AGENT, memory }); + expect(r.success, `longTerm: ${JSON.stringify(longTerm)}`).toBe(false); + const issues = r.error!.issues; + expect(issues).toHaveLength(1); + expect(issues[0].code).toBe('custom'); + expect(issues[0].path).toEqual(['memory', 'reflectionInterval']); + expect(issues[0].message.split(' — ')[0]).toBe( + '`agent.memory.reflectionInterval` requires `agent.memory.longTerm.enabled: true`', + ); + expect(issues[0].message).toContain('or delete `reflectionInterval`'); + } + }); + + it('declares no default for either number: an omitted one is never filled in', () => { + const parsed = AgentSchema.parse({ ...MEMORY_AGENT, memory: { longTerm: { enabled: false } } }); + expect(parsed.memory?.longTerm).toEqual({ enabled: false }); + expect(parsed.memory).not.toHaveProperty('reflectionInterval'); + }); + + it('a malformed number keeps its own type complaint — the contract check adds none on top', () => { + const r = AgentSchema.safeParse({ ...MEMORY_AGENT, memory: { longTerm: { enabled: true, maxEntries: 0 }, reflectionInterval: 5 } }); + expect(r.success).toBe(false); + expect(r.error!.issues).toHaveLength(1); + expect(r.error!.issues[0].code).toBe('too_small'); + expect(r.error!.issues[0].path).toEqual(['memory', 'longTerm', 'maxEntries']); + }); + + it('the authoring door, defineStack, refuses a missing number with the STACK_SCHEMA_INVALID envelope', () => { + const refusal = stackRefusal({ ...MEMORY_AGENT, memory: { longTerm: { enabled: true, maxEntries: 5 } } }); + expect(refusal?.code).toBe('STACK_SCHEMA_INVALID'); + expect(refusal?.status).toBe(422); + expect(refusal.issues).toHaveLength(1); + expect(refusal.issues?.[0]?.path).toEqual(['agents', 0, 'memory', 'reflectionInterval']); + expect(refusal.issues?.[0]?.message).toContain('`agent.memory.reflectionInterval` is required'); + // CONTROL: the same agent with both numbers is accepted by the same door. + expect(() => + defineStack(stackWith({ ...MEMORY_AGENT, memory: { longTerm: { enabled: true, maxEntries: 5 }, reflectionInterval: 3 } }) as never), + ).not.toThrow(); + }); +}); + +describe('agent memory contract — `longTerm.store` is retired as a whole key', () => { + it('refuses EVERY value at its path with the prescription — the honoured one, the refused ones, and non-strings', () => { + for (const store of ['database', 'vector', 'redis', {}, null, 0]) { + const r = AgentSchema.safeParse({ + ...MEMORY_AGENT, + memory: { longTerm: { enabled: true, store, maxEntries: 5 }, reflectionInterval: 3 }, + }); + expect(r.success, `store: ${JSON.stringify(store)}`).toBe(false); + const issues = r.error!.issues; + expect(issues).toHaveLength(1); + expect(issues[0].code).toBe('invalid_type'); + expect(issues[0].path).toEqual(['memory', 'longTerm', 'store']); + expect(issues[0].message).toMatch(STORE_PRESCRIPTION); + expect(issues[0].message.endsWith(MIGRATE_SENTENCE)).toBe(true); + } + }); + + it('the old store spellings `backend` / `storage` / `provider` are answered with the same fact, never steered onto `store`', () => { + for (const spelling of ['backend', 'storage', 'provider']) { + const r = AgentSchema.safeParse({ + ...MEMORY_AGENT, + memory: { longTerm: { enabled: true, [spelling]: 'database', maxEntries: 5 }, reflectionInterval: 3 }, + }); + expect(r.success, spelling).toBe(false); + const issues = r.error!.issues; + expect(issues).toHaveLength(1); + expect(issues[0].code).toBe('unrecognized_keys'); + expect(issues[0].path).toEqual(['memory', 'longTerm']); + expect(issues[0].message).toContain('there is no storage-backend key on long-term memory'); + expect(issues[0].message).not.toMatch(/→ `store`/); + } + }); + + it('the did-you-mean never offers the tombstone: a near-miss `stor` is not steered onto `store`', () => { + const r = AgentSchema.safeParse({ + ...MEMORY_AGENT, + memory: { longTerm: { enabled: true, stor: 'database', maxEntries: 5 }, reflectionInterval: 3 }, + }); + expect(r.success).toBe(false); + const issue = r.error!.issues[0]; + expect(issue.code).toBe('unrecognized_keys'); + expect(issue.message).toContain('`stor`'); + expect(issue.message).not.toMatch(/`stor` → `store`/); + }); + + it('the walked shape keeps `store` as a key — the tombstone stays reachable for its refusal', () => { + // `AgentSchema.shape.memory` is the very handle the cloud reader re-parses with. + const longTerm = (AgentSchema.shape.memory as any).unwrap().shape.longTerm.unwrap(); + expect(Object.keys(longTerm.shape)).toContain('store'); + expect(Object.keys(longTerm.shape), 'CONTROL: its live neighbour').toContain('maxEntries'); + }); + + it('fails tsc at the authoring site: the input type of `store` is `never`', () => { + const agent: Agent = { + ...MEMORY_AGENT, + memory: { + longTerm: { + enabled: true, + // @ts-expect-error — `store` is a retiredKey() tombstone: its input type is `never`. + store: 'database', + maxEntries: 5, + }, + reflectionInterval: 3, + }, + }; + // The parse channel agrees with the type channel on the same literal. + expect(() => AgentSchema.parse(agent)).toThrow(STORE_PRESCRIPTION); + }); + + it('the authoring door, defineStack, refuses it with the STACK_SCHEMA_INVALID envelope — never rewrites it', () => { + const refusal = stackRefusal({ + ...MEMORY_AGENT, + memory: { longTerm: { enabled: true, store: 'vector', maxEntries: 5 }, reflectionInterval: 3 }, + }); + expect(refusal?.code).toBe('STACK_SCHEMA_INVALID'); + expect(refusal?.status).toBe(422); + expect(refusal.issues).toHaveLength(1); + expect(refusal.issues?.[0]?.path).toEqual(['agents', 0, 'memory', 'longTerm', 'store']); + expect(refusal.issues?.[0]?.message).toMatch(STORE_PRESCRIPTION); + }); +}); + +describe('agent memory contract — the D2 conversion deletes `store`', () => { + const CONVERSION_ID = 'agent-memory-long-term-store-removed'; + const persisted = (store: unknown) => ({ + ...MEMORY_AGENT, + memory: { longTerm: { enabled: true, store, maxEntries: 20 }, reflectionInterval: 5 }, + }); + + it('a STORED agent row carrying the key replays clean through the rehydration seam', () => { + const notices: Array<{ conversionId?: string; path?: string; from?: string; to?: string }> = []; + const rehydrated = applyConversionsToStoredItem('agent', persisted('vector'), { + onNotice: (n) => notices.push(n as { conversionId?: string; path?: string; from?: string; to?: string }), + }) as Record; + expect(notices.map((n) => [n.conversionId, n.path, n.from, n.to])).toEqual([ + [CONVERSION_ID, 'agents[0](memory_agent).memory.longTerm.store', 'store', '(removed)'], + ]); + // CONTROL: every live key on the same row survives byte-for-byte. + expect(rehydrated).toEqual({ + ...MEMORY_AGENT, + memory: { longTerm: { enabled: true, maxEntries: 20 }, reflectionInterval: 5 }, + }); + // And the rehydrated row is exactly what the write door accepts now. + expect(AgentSchema.safeParse(rehydrated).success).toBe(true); + }); + + it('a persisted artifact is REFUSED at the boot door before the conversion and ACCEPTED after it', () => { + const artifact = () => ({ agents: [persisted('database')] }); + const before = ObjectStackDefinitionSchema.safeParse(artifact()); + expect(before.success).toBe(false); + expect(JSON.stringify(before.error?.issues ?? [])).toContain('`agent.memory.longTerm.store` was removed'); + const after = ObjectStackDefinitionSchema.safeParse(applyConversions(artifact(), { includeRetired: true })); + expect(after.success, JSON.stringify(after.error?.issues ?? [])).toBe(true); + }); + + it('LIT CONTROL — an agent missing a required number is refused on BOTH sides: the conversion supplies no number', () => { + const artifact = () => ({ + agents: [{ ...MEMORY_AGENT, memory: { longTerm: { enabled: true, store: 'vector', maxEntries: 20 } } }], + }); + expect(ObjectStackDefinitionSchema.safeParse(artifact()).success).toBe(false); + const healed = applyConversions(artifact(), { includeRetired: true }); + const after = ObjectStackDefinitionSchema.safeParse(healed); + expect(after.success).toBe(false); + expect(JSON.stringify(after.error?.issues ?? [])).toContain('`agent.memory.reflectionInterval` is required'); + }); + + it('is idempotent by construction, and leaves an agent without the key untouched by reference', () => { + const input = { agents: [persisted('redis'), { ...MEMORY_AGENT, name: 'plain_agent' }] }; + const { stack, notices } = collectConversionNotices(input, { includeRetired: true }); + expect(notices.filter((n) => n.conversionId === CONVERSION_ID)).toHaveLength(1); + expect((stack.agents as unknown[])[1]).toBe(input.agents[1]); + const replay = collectConversionNotices(stack, { includeRetired: true }); + expect(replay.notices).toHaveLength(0); + expect(replay.stack).toBe(stack); + }); + + it('is retired from the load path — a live author is refused at parse, never silently rewritten', () => { + const input = { agents: [persisted('database')] }; + const { stack, notices } = collectConversionNotices(input); + expect(notices.filter((n) => n.conversionId === CONVERSION_ID)).toHaveLength(0); + expect(stack).toEqual(input); + }); + + it('is registered under major 18: the exact nested key, the chain step, and one D3 entry for the contract', () => { + expect(RETIRED_KEYS_BY_MAJOR[18]).toContain('ai/Agent:memory.longTerm.store'); + expect(MIGRATIONS_BY_MAJOR[18]!.conversionIds).toContain(CONVERSION_ID); + const conversion = ALL_CONVERSIONS.find((c) => c.id === CONVERSION_ID); + expect(conversion, 'the D2 conversion must be registered').toBeDefined(); + expect(conversion!.toMajor).toBe(18); + expect(conversion!.retiredFromLoadPath).toBe(true); + expect(conversion!.surface).toBe('agent.memory.longTerm.store'); + const d3 = MIGRATIONS_BY_MAJOR[18]!.semantic.filter((s) => s.id === 'agent-memory-store-retired-and-limits-required'); + expect(d3).toHaveLength(1); + expect(d3[0]!.conversionIds).toEqual([CONVERSION_ID]); + expect(d3[0]!.reason).toContain(`\`${CONVERSION_ID}\``); + }); +}); + +// ─── Tree-scoped absence, with a DECLARED radius ───────────────────────────── +// +// `tsc` is the primary sweeper — `retiredKey()` types `store` `never` on +// `Agent`, so every TYPED authoring site fails to compile. The residue is what +// `tsc` never judges: JSON, YAML, MD/MDX code fences, untyped `.js`, and TS +// literals typed `any` / `unknown`. This walk covers that residue across the +// five repo roots `scripts/cross-package-test-inputs.mjs` already declares for +// `@objectstack/spec#test` (mirrored in `turbo.json`), plus the example apps' +// own `src/` trees, declared there as `examples/*/src/**/*.ts`. +// +// The matcher judges the AUTHORING SHAPE, never a mention: a `longTerm` key +// whose object value holds a `store` key — or one of its old spellings +// `backend` / `storage` / `provider` — before any nested brace (TS / JS / JSON, +// and YAML block and flow form). `store` alone is far too common a word to +// judge. Inline code is prose and is stripped before judging. The bound, +// stated: a block assembled by spread or under computed keys, and `docs/**`, +// `.claude/**`, `.github/**` and the repo-root files are outside what this +// walk sees. +describe('tree-scoped absence: nothing inside the declared radius still authors a long-term memory store', () => { + const SPEC_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); + const REPO_ROOT = path.resolve(SPEC_ROOT, '../..'); + const THIS_FILE = path.relative(REPO_ROOT, fileURLToPath(import.meta.url)).split(path.sep).join('/'); + + /** The walked roots — declared in `scripts/cross-package-test-inputs.mjs` under `@objectstack/spec`. */ + const WALK_ROOTS = ['packages', 'examples', 'skills', 'content', 'scripts']; + const SCANNED_EXT = new Set(['.ts', '.mts', '.cts', '.js', '.mjs', '.cjs', '.json', '.md', '.mdx', '.yaml', '.yml']); + /** Under `examples/` the non-code extensions, plus `.ts` inside an app's own `src/` tree. */ + const EXAMPLES_EXT = new Set(['.json', '.md', '.mdx', '.yaml', '.yml']); + const EXAMPLE_APP_SRC_TS = /^examples\/[^/]+\/src\/.+\.ts$/; + const SKIPPED_DIRS = new Set(['node_modules', 'dist', '.git', '.turbo', '.cache', '.objectstack', 'coverage', '.next', '.source']); + + const STORE_KEYS = '(store|backend|storage|provider)'; + const AUTHORING = [ + // TS / JS / JSON, block or inline: the key, then an object whose own keys + // (before any nested brace) include the store or one of its spellings. + new RegExp(`(^|[^\\w.$])["']?longTerm["']?\\s*:\\s*\\{[^{}]*?(^|[^\\w.$])["']?${STORE_KEYS}["']?\\s*:`, 'm'), + // YAML block form: the key on its own line, the store key indented below it + // among its siblings. + new RegExp(`^[ \\t]*(-[ \\t]+)?longTerm[ \\t]*:[ \\t]*\\r?\\n(?:[ \\t]+[^\\s][^\\n]*\\r?\\n)*?[ \\t]+${STORE_KEYS}[ \\t]*:`, 'm'), + ]; + + /** + * Inline code spans are prose — the house style `check:doc-authoring` enforces + * — so stripping single-backtick spans separates "the retirement kit + * describing what it removed" from "a source still writing it". + * Newline-bounded: a fenced block's content is NOT stripped. + */ + const stripInlineCode = (text: string): string => text.replace(/`[^`\n]*`/g, ''); + const judge = (text: string): RegExpExecArray | null => { + const stripped = stripInlineCode(text); + for (const re of AUTHORING) { + const m = re.exec(stripped); + if (m) return m; + } + return null; + }; + + /** + * Structural exclusions — the retirement kit, each with its reason. ⛔ NOT an + * allowlist file (`spec-property-retirement` §4): every entry's JOB is to + * spell the retired key. + */ + const EXCLUDED = new Set([ + // This pin authors the key to assert its refusal and its conversion. + THIS_FILE, + ]); + const EXCLUDED_PREFIXES = [ + // The D2 conversion's fixture authors the pre-retirement agent on purpose. + 'packages/spec/src/conversions/', + // Release-owned prose records the removal; never edited by a code PR. + 'content/docs/releases/', + // GITIGNORED build output (`packages/spec/json-schema/`), reached only + // because this is a FILESYSTEM walk. Its source is `agent.zod.ts`. + 'packages/spec/json-schema/', + ]; + /** tsup's own bundle of `tsup.config.ts`, written and deleted mid-build. */ + const TSUP_BUNDLED_CONFIG = /\.bundled_[^./]+\.mjs$/; + + /** Tolerates ONLY a path that vanished mid-walk; every other read fault is re-raised. */ + const readIfPresent = (full: string): string | undefined => { + try { + return fs.readFileSync(full, 'utf-8'); + } catch (err) { + if ((err as NodeJS.ErrnoException)?.code !== 'ENOENT') throw err; + return undefined; + } + }; + + it('the matcher recognises an authoring and ignores a prose mention and a neighbour (anti-vacuity)', () => { + // Offenders — the retired shape, in each syntax the walk reads. + expect(judge("defineAgent({ memory: { longTerm: { enabled: true, store: 'vector' } } })")).not.toBeNull(); + expect(judge(" longTerm: {\n enabled: true,\n store: 'database',\n },")).not.toBeNull(); + expect(judge('{ "longTerm": { "enabled": true, "store": "redis" } }')).not.toBeNull(); + expect(judge("longTerm: { backend: 'database' }")).not.toBeNull(); + expect(judge('memory:\n longTerm:\n enabled: true\n storage: vector\n')).not.toBeNull(); + expect(judge('longTerm: { enabled: true, provider: redis }\n')).not.toBeNull(); + expect(judge("Prose.\n\n```ts\ndefineAgent({ memory: { longTerm: { store: 'vector' } } });\n```\n")).not.toBeNull(); + // Neighbours that must NOT match. + expect(judge("the `longTerm: { enabled: true, store: 'vector' }` block loses its store")).toBeNull(); + expect(judge("memory: { longTerm: { enabled: true, maxEntries: 5 }, reflectionInterval: 3 }")).toBeNull(); + expect(judge("store: retiredKey(LONG_TERM_STORE_RETIRED),")).toBeNull(); + expect(judge("'long.store': (memory.longTerm as any)?.store,")).toBeNull(); + expect(judge("const s = { longTerm: 1, nested: { store: 'x' } };")).toBeNull(); + expect(judge('longTerm:\n enabled: true\nstore:\n kind: x\n')).toBeNull(); + }); + + it('no long-term memory store authoring survives inside the declared radius outside the retirement kit', () => { + const offenders: string[] = []; + let visited = 0; + let exampleSources = 0; + const walk = (dir: string) => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + const rel = path.relative(REPO_ROOT, full).split(path.sep).join('/'); + if (entry.isDirectory()) { + if (SKIPPED_DIRS.has(entry.name) || entry.name.startsWith('.')) continue; + walk(full); + continue; + } + if (!entry.isFile()) continue; + const ext = path.extname(entry.name); + const scanned = rel.startsWith('examples/') + ? EXAMPLES_EXT.has(ext) || EXAMPLE_APP_SRC_TS.test(rel) + : SCANNED_EXT.has(ext); + if (!scanned) continue; + if (entry.name === 'CHANGELOG.md') continue; // release prose records the removal + if (EXCLUDED.has(rel) || EXCLUDED_PREFIXES.some((p) => rel.startsWith(p))) continue; + if (TSUP_BUNDLED_CONFIG.test(entry.name)) continue; + visited += 1; + if (EXAMPLE_APP_SRC_TS.test(rel)) exampleSources += 1; + const text = readIfPresent(full); + if (text === undefined) continue; + const m = judge(text); + if (m) offenders.push(`${rel} authors \`${m[0].trim().replace(/\s+/g, ' ')}\``); + } + }; + for (const root of WALK_ROOTS) walk(path.join(REPO_ROOT, root)); + // Anti-vacuity: the walk really covered the tree, and the example apps' + // sources were really read. + expect(visited).toBeGreaterThan(1000); + expect(exampleSources).toBeGreaterThan(50); + expect(offenders, 'a long-term memory store authoring means the retirement is being undone').toEqual([]); + }); +}); diff --git a/packages/spec/src/ai/agent.zod.ts b/packages/spec/src/ai/agent.zod.ts index 6435277ab4a..99be77b247a 100644 --- a/packages/spec/src/ai/agent.zod.ts +++ b/packages/spec/src/ai/agent.zod.ts @@ -181,6 +181,84 @@ export type StructuredOutputConfig = z.input; +// ── The agent memory contract (ADR-0049 enforce-or-remove) ────────────────── +// +// Ruling record 5950198150 (letter A′, #20274): the `agent.memory` contract +// states exactly what the runtime honours. The one runtime that executes +// agents, cloud's AI service, enforces long-term memory from `enabled`, +// `maxEntries` and `reflectionInterval`, and refused before an agent's first +// turn (`AI_AGENT_MEMORY_UNSUPPORTED`) the declarations this spec still +// accepted: an enabled `longTerm` missing either number, a `reflectionInterval` +// without an enabled `longTerm`, and a `store` other than its own database +// store — `vector`, the old default, included. Authoring now refuses the same +// declarations, by name, with these prescriptions. Module-private and written +// with `//`, never `/** */`: prose the schema's refusals consume, not +// documented surface. +const LONG_TERM_STORE_RETIRED = + '`agent.memory.longTerm.store` was removed in @objectstack/spec 17.7.0 (ADR-0049 ' + + 'enforce-or-remove) — the memory store is platform infrastructure, not agent metadata: the ' + + 'cloud AI runtime keeps long-term memory notes in its own database store, and refused the ' + + '`vector` store (the old default) and `redis` before an agent\'s first turn. Delete the key; ' + + 'long-term memory is configured by `enabled`, `maxEntries` and ' + + '`agent.memory.reflectionInterval`, and where the notes are kept is the platform\'s choice. ' + + 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand.'; + +const LONG_TERM_STORE_SPELLING_RETIRED = + 'there is no storage-backend key on long-term memory — where the notes are kept is the ' + + 'platform\'s choice, not agent metadata (`agent.memory.longTerm.store` was removed in ' + + '@objectstack/spec 17.7.0, ADR-0049 enforce-or-remove). Delete the key.'; + +const MAX_ENTRIES_REQUIRED = + '`agent.memory.longTerm.maxEntries` is required when `agent.memory.longTerm.enabled` is ' + + 'true — it is how many distilled notes are kept for each user (the newest are recalled before ' + + 'the first round, and older ones are evicted), and the spec declares no default for it. Declare ' + + 'it as an integer of at least 1, or delete `longTerm` and `reflectionInterval` if the agent ' + + 'needs no long-term memory.'; + +const REFLECTION_INTERVAL_REQUIRED = + '`agent.memory.reflectionInterval` is required when `agent.memory.longTerm.enabled` is true — ' + + 'it is how many delivered interactions pass between reflections, and a reflection is what ' + + 'writes a note to long-term memory, so without it nothing is ever remembered; the spec ' + + 'declares no default for it. Declare it as an integer of at least 1, or delete `longTerm` if ' + + 'the agent needs no long-term memory.'; + +const REFLECTION_INTERVAL_WITHOUT_LONG_TERM = + '`agent.memory.reflectionInterval` requires `agent.memory.longTerm.enabled: true` — a ' + + 'reflection writes a note to long-term memory, and this agent has none enabled, so the ' + + 'interval would do nothing. Enable long-term memory (`longTerm: { enabled: true, maxEntries: N }`) ' + + 'or delete `reflectionInterval`.'; + +/** + * The three refusals of the agent memory contract, each a `custom` issue at + * the path of the key it names (the house refinement shape — + * `ai/skill.zod.ts`'s `checkSkillTriggerConditionValueShape`). + * + * A refinement on `memory`, not on `longTerm`, because `reflectionInterval` is + * `longTerm`'s sibling. No default is declared for either number: the runtime + * adds none, and a spec default would be a number with no measured basis that + * a later release could only remove by breaking it. It runs only on a body the + * shape already accepted — an unknown key, a wrong type or the `store` + * tombstone aborts first — so an author meets one complaint at a time. + */ +function checkAgentMemoryContract( + memory: { longTerm?: { enabled?: boolean; maxEntries?: number }; reflectionInterval?: number }, + ctx: z.RefinementCtx, +): void { + const enabled = memory.longTerm?.enabled === true; + if (enabled) { + if (memory.longTerm?.maxEntries === undefined) { + ctx.addIssue({ code: 'custom', path: ['longTerm', 'maxEntries'], message: MAX_ENTRIES_REQUIRED }); + } + if (memory.reflectionInterval === undefined) { + ctx.addIssue({ code: 'custom', path: ['reflectionInterval'], message: REFLECTION_INTERVAL_REQUIRED }); + } + return; + } + if (memory.reflectionInterval !== undefined) { + ctx.addIssue({ code: 'custom', path: ['reflectionInterval'], message: REFLECTION_INTERVAL_WITHOUT_LONG_TERM }); + } +} + /** * AI Agent Schema * Definition of an autonomous agent specialized for a domain. @@ -379,28 +457,70 @@ export const AgentSchema = lazySchema(() => strictObject({ // cloud#339). It declared a working-memory window that NOTHING in the // runtime consumed — a config that lies. Cross-turn grounding is done by // tools reading live state, and the context budget is governed elsewhere - // (the per-request token guardrail), not by this field. `longTerm` / - // `reflectionInterval` are kept as forward-looking, off-by-default config. - - /** Long-term (persistent) memory configuration */ + // (the per-request token guardrail), not by this field. + // + // `longTerm` / `reflectionInterval` are ENFORCED, by the cloud AI runtime + // (its `compileAgentMemory` reader, #20274): the newest `maxEntries` + // distilled notes for the user and agent are recalled before the first + // round, every `reflectionInterval` delivered interactions one reflection + // writes a note, and notes beyond `maxEntries` are evicted. The contract + // states exactly that and nothing more (ADR-0049, ruling record + // 5950198150, letter A′): both numbers are REQUIRED once `longTerm.enabled` + // is true, with no default declared — see `checkAgentMemoryContract` — and + // the storage backend is not agent metadata: `longTerm.store` is + // tombstoned below. + + /** Long-term memory: distilled notes kept per user and agent. */ longTerm: strictObject({ surface: 'this long-term memory configuration', history: AGENT_HISTORY, - aliases: { backend: 'store', storage: 'store', provider: 'store', limit: 'maxEntries', maxItems: 'maxEntries', active: 'enabled' }, + aliases: { limit: 'maxEntries', maxItems: 'maxEntries', active: 'enabled' }, + // `backend` / `storage` / `provider` used to be aliases steering onto + // `store`. An alias may not target a tombstone (an author told to write + // the key guaranteed to be refused next), so the three spellings carry + // the same answer as the tombstone instead. + guidance: { + backend: LONG_TERM_STORE_SPELLING_RETIRED, + storage: LONG_TERM_STORE_SPELLING_RETIRED, + provider: LONG_TERM_STORE_SPELLING_RETIRED, + }, }, { - /** Whether long-term memory is enabled */ - enabled: z.boolean().default(false).describe('Enable long-term memory persistence'), - - /** Storage backend for long-term memory */ - store: z.enum(['vector', 'database', 'redis']).default('vector').describe('Long-term memory storage backend'), - - /** Maximum number of persisted memory entries */ - maxEntries: z.number().int().min(1).optional().describe('Max entries in long-term memory'), - }).optional().describe('Long-term / persistent memory'), + /** Whether long-term memory is enabled. When true, `maxEntries` and `memory.reflectionInterval` are required. */ + enabled: z.boolean().default(false).describe( + 'Enable long-term memory. When true, maxEntries and memory.reflectionInterval are required', + ), + + /** + * REMOVED — the storage backend. The memory store is platform + * infrastructure, not agent metadata: the cloud AI runtime keeps the + * notes in its own database store and refused the `vector` default and + * `redis` before an agent's first turn. The ADR-0087 conversion + * `agent-memory-long-term-store-removed` deletes the key from existing + * sources and stored rows. + */ + store: retiredKey(LONG_TERM_STORE_RETIRED), + + /** + * How many distilled notes are kept and recalled for each user: the + * newest `maxEntries` are recalled before the first round, and notes + * beyond it are evicted. Required when `enabled` is true. + */ + maxEntries: z.number().int().min(1).optional().describe( + 'How many distilled notes are kept per user: the newest N are recalled before the first round, and notes beyond N are evicted. Required when enabled is true', + ), + }).optional().describe('Long-term memory: distilled notes kept per user and agent and recalled before each conversation'), - /** Reflection interval — how often the agent reflects on past actions */ - reflectionInterval: z.number().int().min(1).optional().describe('Reflect every N interactions to improve behavior'), - }).optional().describe('[EXPERIMENTAL — not enforced] Agent memory management. Parsed but no runtime consumer yet.'), + /** + * How many delivered interactions pass between reflections. Each + * reflection writes one distilled note to long-term memory. Required when + * `longTerm.enabled` is true, and refused without it. + */ + reflectionInterval: z.number().int().min(1).optional().describe( + 'Reflect every N delivered interactions: each reflection writes one distilled note to long-term memory. Required when longTerm.enabled is true, and refused without it', + ), + }).superRefine(checkAgentMemoryContract).optional().describe( + 'Agent memory (long-term notes recalled before each conversation and written by periodic reflection), enforced by the cloud AI runtime; the open framework edition does not run agents.', + ), /** Guardrails */ guardrails: strictObject({ diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index f4f1b6fb54c..9d37792a272 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -10396,6 +10396,115 @@ const actionBlockEndpointToTarget: MetadataConversion = { }, }; +/** + * An agent's long-term memory store — `agent.memory.longTerm.store` — leaves + * the spec (protocol 18, #20274; ADR-0049 enforce-or-remove, ruling record + * 5950198150, letter A′: the `agent.memory` contract states exactly what the + * runtime honours). + * + * The memory store is platform infrastructure, not agent metadata. Cloud's AI + * service, the one runtime that executes agents, keeps long-term memory notes + * in its own database store; it refused `vector` — the key's old DEFAULT, so + * what an omitted `store` parsed to — and `redis` before an agent's first + * turn, and honoured `database` only because that is the store it uses anyway. + * Before that reader landed nothing read the block at all. So no authored + * value ever chose a backend, and the delete is LOSSLESS: authoring now + * refuses the key by name (`retiredKey`, ai/agent.zod.ts). + * + * One edit: the key is deleted from `memory.longTerm`, whatever it holds — an + * explicit `database`, a refused `vector` / `redis`, or the `vector` default a + * released toolchain materialized into a parsed agent. Every other key of the + * block stays. One notice per agent that carried it. + * + * ⛔ What it does NOT do: supply `maxEntries` or `reflectionInterval`. The same + * ruling made both REQUIRED once `longTerm.enabled` is true, with no default, + * so no mechanical rewrite can choose them; the D3 entry + * `agent-memory-store-retired-and-limits-required` carries that judgement. + * + * Idempotent by construction: `stripKeys` skips an absent key and hands the + * input back by reference. Retired from the load path: an author is refused at + * parse with the prescription; data at rest (`applyConversionsToStoredItem`), + * built artifacts and `os migrate meta` replay it. + */ +const agentMemoryLongTermStoreRemoved: MetadataConversion = { + id: 'agent-memory-long-term-store-removed', + toMajor: 18, + retiredFromLoadPath: true, + retiredAfter: '17.6.0', + surface: 'agent.memory.longTerm.store', + summary: + "agent memory key 'longTerm.store' removed: the memory store is platform infrastructure, not agent " + + "metadata — the AI runtime keeps long-term memory notes in its own database store and refused the " + + "'vector' default and 'redis' before the first turn. The key is deleted; every other memory key stays", + apply(stack, emit) { + return mapCollection(stack, 'agents', (agent, path) => { + const memory = agent.memory; + if (!isDict(memory)) return agent; + const longTerm = memory.longTerm; + if (!isDict(longTerm)) return agent; + const nextLongTerm = stripKeys(longTerm, ['store'], emit, `${path}.memory.longTerm`); + return nextLongTerm === longTerm ? agent : { ...agent, memory: { ...memory, longTerm: nextLongTerm } }; + }); + }, + fixture: { + before: { + agents: [ + { + // The one value the runtime honoured: deleted all the same — the + // store is the platform's, whatever the agent says. + name: 'recall_agent', + label: 'Recall', + memory: { longTerm: { enabled: true, store: 'database', maxEntries: 20 }, reflectionInterval: 5 }, + }, + { + // The old default, as a released toolchain materialized it into a + // parsed agent whose author never wrote `store`. + name: 'paused_agent', + label: 'Paused', + memory: { longTerm: { enabled: false, store: 'vector' } }, + }, + { + // A refused backend. + name: 'cache_agent', + label: 'Cache', + memory: { longTerm: { enabled: true, store: 'redis', maxEntries: 50 }, reflectionInterval: 10 }, + }, + { + // Already canonical, and an agent with no memory at all: both ride + // through untouched. + name: 'notes_agent', + label: 'Notes', + memory: { longTerm: { enabled: true, maxEntries: 5 }, reflectionInterval: 3 }, + }, + { name: 'plain_agent', label: 'Plain' }, + ], + }, + after: { + agents: [ + { + name: 'recall_agent', + label: 'Recall', + memory: { longTerm: { enabled: true, maxEntries: 20 }, reflectionInterval: 5 }, + }, + { name: 'paused_agent', label: 'Paused', memory: { longTerm: { enabled: false } } }, + { + name: 'cache_agent', + label: 'Cache', + memory: { longTerm: { enabled: true, maxEntries: 50 }, reflectionInterval: 10 }, + }, + { + name: 'notes_agent', + label: 'Notes', + memory: { longTerm: { enabled: true, maxEntries: 5 }, reflectionInterval: 3 }, + }, + { name: 'plain_agent', label: 'Plain' }, + ], + }, + // Three: one per agent whose `longTerm` carried the key. + expectedNotices: 3, + }, +}; + /** * The members of an agent's `structuredOutput` the runtime refused — the * `regex`, `grammar` and `xml` formats and the `coerce_types` transform step — @@ -13954,6 +14063,7 @@ function inApplicationOrder(entries: readonly OrderedConversion[]): readonly Met const MAJOR_18_CONVERSIONS: readonly OrderedConversion[] = [ { conversion: actionAriaRemoved, order: 43 }, { conversion: actionBlockEndpointToTarget, order: 52 }, + { conversion: agentMemoryLongTermStoreRemoved, order: 56 }, { conversion: agentStructuredOutputRefusedMembersRemoved, order: 55 }, { conversion: apiEndpointCacheTtlToCacheTtlSeconds, order: 23 }, { conversion: chartConfigAriaRemoved, order: 32 }, diff --git a/packages/spec/src/migrations/entries/retired-keys/18.ai__Agent__memory.longTerm.store.ts b/packages/spec/src/migrations/entries/retired-keys/18.ai__Agent__memory.longTerm.store.ts new file mode 100644 index 00000000000..a55db236f13 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.ai__Agent__memory.longTerm.store.ts @@ -0,0 +1,27 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #20274 — ADR-0049 enforce-or-remove, ruling record 5950198150 (letter A′, +// maintainer 「同意」): the `agent.memory` contract states exactly what the +// runtime honours, and the memory store is platform infrastructure, not agent +// metadata. Retired as a WHOLE key, ⛔ not narrowed to a one-value enum: the +// cloud AI runtime keeps the notes in its own database store and refused the +// `vector` default and `redis` before an agent's first turn. Tombstoned with +// `retiredKey()` inside the live `longTerm` block; its old aliases (`backend` / +// `storage` / `provider`) became `guidance` entries carrying the same answer. +// D2 conversion `agent-memory-long-term-store-removed` (lossless delete, +// retired from the load path); D3 semantic entry +// `agent-memory-store-retired-and-limits-required`. Registered under 18 for the +// launch-window reason its neighbours state. +// +// ⚠️ The key was DEFAULTED (`'vector'`), so a released toolchain materialized +// it into every parsed agent that declared `longTerm`. The +// `acceptRetiredDefaultResidue` stage is deliberately NOT adopted: the producer +// census is zero (no agent outside `packages/spec` declares `longTerm`, measured +// in this repository; none of cloud's built-in agents does, per the cloud +// seat's reading), and the ruling names the tombstone's prescription as the +// backstop for the unmeasured tenant population. Stored rows and built +// artifacts are healed by the replayed D2 conversion; a pre-retirement compiled +// definition fed back through the authoring funnel meets the prescription. +// +// Nested key of an inline block — no `authorable-surface/` line of its own. +export const entry = 'ai/Agent:memory.longTerm.store'; diff --git a/packages/spec/src/migrations/entries/semantic/18.agent-memory-store-retired-and-limits-required.ts b/packages/spec/src/migrations/entries/semantic/18.agent-memory-store-retired-and-limits-required.ts new file mode 100644 index 00000000000..24276c9a9bc --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.agent-memory-store-retired-and-limits-required.ts @@ -0,0 +1,48 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// #20274 — ADR-0049 enforce-or-remove (ruling record 5950198150, letter A′) — +// the D3 entry of the `agent.memory` contract: one entry for the one decision, +// because its two halves leave an upgrading author ONE job between them. The +// `store` half is mechanical (the D2 conversion +// `agent-memory-long-term-store-removed` deletes it, losslessly); the two +// required numbers are not — no default is declared, so only the author can +// choose them, which is the judgement this entry exists to carry. +export const entry: SemanticMigration = { + id: 'agent-memory-store-retired-and-limits-required', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a code span. + surface: + 'agent.memory — longTerm.store left the shape (the memory store is the platform\'s); ' + + 'longTerm.maxEntries and reflectionInterval are required when longTerm.enabled is true, and ' + + 'reflectionInterval is refused without an enabled longTerm; longTerm.enabled is unchanged', + replacement: + 'no storage key: delete `longTerm.store`, whatever it held — where long-term memory notes are ' + + 'kept is the platform\'s choice. An agent whose `longTerm.enabled` is true declares ' + + '`longTerm.maxEntries` (how many distilled notes are kept for each user; the newest are recalled ' + + 'before the first round and older ones evicted) and `memory.reflectionInterval` (how many ' + + 'delivered interactions pass between the reflections that write a note). An agent without ' + + 'enabled long-term memory declares no `reflectionInterval`', + reason: + 'ADR-0049 enforce-or-remove: the `agent.memory` contract states exactly what the runtime honours. ' + + 'The cloud AI runtime, the one runtime that executes agents, enforces long-term memory from ' + + '`enabled`, `maxEntries` and `reflectionInterval`: it recalls the newest `maxEntries` notes ' + + 'before the first round, writes one note every `reflectionInterval` delivered interactions, and ' + + 'evicts notes beyond `maxEntries`. It keeps the notes in its own database store, and before an ' + + 'agent\'s first turn it refused the `vector` store (the old default, so what an omitted `store` ' + + 'parsed to), `redis`, an enabled `longTerm` missing either number, and a `reflectionInterval` ' + + 'without an enabled `longTerm`. Authoring now refuses the same declarations, each with a ' + + 'prescription. The D2 conversion `agent-memory-long-term-store-removed` deletes `store` from ' + + 'existing sources and stored rows, losslessly: no value of it ever chose a backend. No default ' + + 'is declared for either number, because none has a measured basis — so an agent with long-term ' + + 'memory enabled and either number missing no longer parses, and only its author can choose the ' + + 'numbers it needs', + acceptanceCriteria: + 'No agent declares `memory.longTerm.store`, or a `backend`, `storage` or `provider` key under ' + + '`longTerm`; each is refused at parse with its prescription, and TypeScript rejects `store`. ' + + 'Every agent whose `longTerm.enabled` is true declares both `longTerm.maxEntries` and ' + + '`memory.reflectionInterval`, each an integer of at least 1 chosen for that agent, and no agent ' + + 'declares `reflectionInterval` without an enabled `longTerm`. Every agent parses under the new ' + + 'schema.', + conversionIds: ['agent-memory-long-term-store-removed'], +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 0eae471050e..240bf6734ad 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -5074,6 +5074,21 @@ const STEP18_RATIONALE: readonly RationaleFragment[] = [ + 'to be already precise. Unlike everything else in this step it changes no schema, so ' + 'nothing refuses at publish: the upgrade signal is behavioural and belongs here.', }, + { + id: 'agent-memory-store-retired-and-limits-required', + order: 61, + text: + 'It also makes an agent\'s memory contract state exactly what the runtime honours (ADR-0049 ' + + 'enforce-or-remove). The cloud AI runtime, which executes agents, recalls the newest ' + + '`maxEntries` long-term notes before the first round, writes one every `reflectionInterval` ' + + 'delivered interactions, and keeps them in its own database store; before an agent\'s first ' + + 'turn it refused the `vector` store (the old default) and `redis`, an enabled `longTerm` ' + + 'missing either number, and a `reflectionInterval` without one. So `longTerm.store` is ' + + 'retired as a whole key — the memory store is platform infrastructure, not agent metadata — ' + + 'and the D2 conversion `agent-memory-long-term-store-removed` deletes it, losslessly, retired ' + + 'from the load path; and with long-term memory enabled both numbers are required at ' + + 'authoring, with no default declared, so an upgrading author chooses them.', + }, { id: 'agent-structured-output-refused-members-retired', order: 60, @@ -6570,6 +6585,50 @@ const step18: MigrationStep = { + 'tests green. ⚠️ Runtime behaviour is deliberately UNCHANGED: nothing ' + 'ever read the container, so removing it removes no behaviour.', }, + // #20274 — ADR-0049 enforce-or-remove (ruling record 5950198150, letter A′) — + // the D3 entry of the `agent.memory` contract: one entry for the one decision, + // because its two halves leave an upgrading author ONE job between them. The + // `store` half is mechanical (the D2 conversion + // `agent-memory-long-term-store-removed` deletes it, losslessly); the two + // required numbers are not — no default is declared, so only the author can + // choose them, which is the judgement this entry exists to carry. + { + id: 'agent-memory-store-retired-and-limits-required', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a code span. + surface: + 'agent.memory — longTerm.store left the shape (the memory store is the platform\'s); ' + + 'longTerm.maxEntries and reflectionInterval are required when longTerm.enabled is true, and ' + + 'reflectionInterval is refused without an enabled longTerm; longTerm.enabled is unchanged', + replacement: + 'no storage key: delete `longTerm.store`, whatever it held — where long-term memory notes are ' + + 'kept is the platform\'s choice. An agent whose `longTerm.enabled` is true declares ' + + '`longTerm.maxEntries` (how many distilled notes are kept for each user; the newest are recalled ' + + 'before the first round and older ones evicted) and `memory.reflectionInterval` (how many ' + + 'delivered interactions pass between the reflections that write a note). An agent without ' + + 'enabled long-term memory declares no `reflectionInterval`', + reason: + 'ADR-0049 enforce-or-remove: the `agent.memory` contract states exactly what the runtime honours. ' + + 'The cloud AI runtime, the one runtime that executes agents, enforces long-term memory from ' + + '`enabled`, `maxEntries` and `reflectionInterval`: it recalls the newest `maxEntries` notes ' + + 'before the first round, writes one note every `reflectionInterval` delivered interactions, and ' + + 'evicts notes beyond `maxEntries`. It keeps the notes in its own database store, and before an ' + + 'agent\'s first turn it refused the `vector` store (the old default, so what an omitted `store` ' + + 'parsed to), `redis`, an enabled `longTerm` missing either number, and a `reflectionInterval` ' + + 'without an enabled `longTerm`. Authoring now refuses the same declarations, each with a ' + + 'prescription. The D2 conversion `agent-memory-long-term-store-removed` deletes `store` from ' + + 'existing sources and stored rows, losslessly: no value of it ever chose a backend. No default ' + + 'is declared for either number, because none has a measured basis — so an agent with long-term ' + + 'memory enabled and either number missing no longer parses, and only its author can choose the ' + + 'numbers it needs', + acceptanceCriteria: + 'No agent declares `memory.longTerm.store`, or a `backend`, `storage` or `provider` key under ' + + '`longTerm`; each is refused at parse with its prescription, and TypeScript rejects `store`. ' + + 'Every agent whose `longTerm.enabled` is true declares both `longTerm.maxEntries` and ' + + '`memory.reflectionInterval`, each an integer of at least 1 chosen for that agent, and no agent ' + + 'declares `reflectionInterval` without an enabled `longTerm`. Every agent parses under the new ' + + 'schema.', + conversionIds: ['agent-memory-long-term-store-removed'], + }, // #21277 — ADR-0049 enforce-or-remove (ruling record 5945617233, letter A) — // the D3 entry of the `agent-structured-output-refused-members-removed` // family: one entry for the four members, because they leave for one reason @@ -20216,6 +20275,31 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // entry id by `gen:migration-registry` (#7297). Add an entry by adding a // FILE — never by editing between the markers, which is generated. // + // #20274 — ADR-0049 enforce-or-remove, ruling record 5950198150 (letter A′, + // maintainer 「同意」): the `agent.memory` contract states exactly what the + // runtime honours, and the memory store is platform infrastructure, not agent + // metadata. Retired as a WHOLE key, ⛔ not narrowed to a one-value enum: the + // cloud AI runtime keeps the notes in its own database store and refused the + // `vector` default and `redis` before an agent's first turn. Tombstoned with + // `retiredKey()` inside the live `longTerm` block; its old aliases (`backend` / + // `storage` / `provider`) became `guidance` entries carrying the same answer. + // D2 conversion `agent-memory-long-term-store-removed` (lossless delete, + // retired from the load path); D3 semantic entry + // `agent-memory-store-retired-and-limits-required`. Registered under 18 for the + // launch-window reason its neighbours state. + // + // ⚠️ The key was DEFAULTED (`'vector'`), so a released toolchain materialized + // it into every parsed agent that declared `longTerm`. The + // `acceptRetiredDefaultResidue` stage is deliberately NOT adopted: the producer + // census is zero (no agent outside `packages/spec` declares `longTerm`, measured + // in this repository; none of cloud's built-in agents does, per the cloud + // seat's reading), and the ruling names the tombstone's prescription as the + // backstop for the unmeasured tenant population. Stored rows and built + // artifacts are healed by the replayed D2 conversion; a pre-retirement compiled + // definition fed back through the authoring funnel meets the prescription. + // + // Nested key of an inline block — no `authorable-surface/` line of its own. + 'ai/Agent:memory.longTerm.store', // #15680 (stack card 5/6 of #14478) — maintainer ruling 2026-09-02 ("ruled B"): // a duration-shaped `z.number()` key carries its unit in its NAME, and no // existing offender is grandfathered. `ConversationAnalytics.duration` said From d313c6d93966048465dd76ab7b9d6e57ad5a6c40 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 11:08:34 +0000 Subject: [PATCH 2/9] wip(spec,lint): dropped-refinement sites, lint witnesses repointed, changeset Claude-Session: https://claude.ai/code/session_01YDt3PzwfrkuFzUBF89WPmM Co-authored-by: Claude --- .changeset/20274-agent-memory-contract.md | 88 +++++++++++++++++++ .../lint/src/lint-liveness-properties.test.ts | 23 ++--- .../spec/dropped-refinements.baseline.json | 7 +- 3 files changed, 106 insertions(+), 12 deletions(-) create mode 100644 .changeset/20274-agent-memory-contract.md diff --git a/.changeset/20274-agent-memory-contract.md b/.changeset/20274-agent-memory-contract.md new file mode 100644 index 00000000000..19917562ad3 --- /dev/null +++ b/.changeset/20274-agent-memory-contract.md @@ -0,0 +1,88 @@ +--- +'@objectstack/spec': minor +--- + +feat(spec)!: an agent's `memory` contract states exactly what the runtime honours — `maxEntries` and `reflectionInterval` are required once long-term memory is enabled, `longTerm.store` is retired, and the block is `live`, enforced by the cloud AI runtime (#20274) + +**BREAKING** — `agent.memory` narrows to what the cloud AI runtime, the one runtime +that executes agents, actually does with it. That runtime recalls the newest +`maxEntries` distilled notes for the user before the first round, writes one note +every `reflectionInterval` delivered interactions, evicts notes beyond `maxEntries`, +and keeps them in its own database store. Before an agent's first turn it refused +exactly the declarations this spec still accepted, so authoring now refuses them, +by name, with a prescription (ADR-0049 enforce-or-remove): + +- **`longTerm.maxEntries` and `reflectionInterval` are required when + `longTerm.enabled` is true.** No default is declared for either: none has a + measured basis, and the runtime adds none. +- **`reflectionInterval` is refused without an enabled `longTerm`** — a reflection + writes a long-term note, so with none enabled it would do nothing. +- **`longTerm.store` is retired as a whole key.** The memory store is platform + infrastructure, not agent metadata: the runtime keeps the notes in its own + database store, and refused `vector` (the key's default, so what an omitted + `store` parsed to) and `redis`. Its old spellings `backend`, `storage` and + `provider` under `longTerm` are answered with the same prescription instead of + being steered onto `store`. + +`longTerm.enabled` is unchanged. + +### FROM → TO + +| before | what to write instead | +| --- | --- | +| `memory.longTerm.store` — any value, `database` included | delete the key; where the notes are kept is the platform's choice. | +| `longTerm: { enabled: true, … }` without `maxEntries` | add `maxEntries`: how many distilled notes are kept for each user (an integer of at least 1). | +| `longTerm: { enabled: true, … }` without `memory.reflectionInterval` | add `reflectionInterval`: how many delivered interactions pass between the reflections that write a note (an integer of at least 1). | +| `memory.reflectionInterval` without `longTerm.enabled: true` | enable long-term memory with both numbers, or delete `reflectionInterval`. | + +**The one-line fix: declare `maxEntries` and `reflectionInterval` when `longTerm.enabled`; delete `store`.** +`os migrate meta --from 17` lists the mechanical edits for existing sources (the +`store` deletion); the two numbers are the author's to choose. + +Each refusal is a parse error at the key's own path, naming the key and the fix, and +`store` also fails `tsc` (its input type is `never`). + +### The retirement kit + +- **Tombstone.** `longTerm.store` is a `retiredKey()` carrying the prescription; the + three old alias spellings moved from `aliases` to `guidance`, because an alias may + not steer an author onto a tombstone. +- **The contract check** is a refinement on `memory` (`reflectionInterval` is + `longTerm`'s sibling), one `custom` issue per missing or misplaced key. A JSON + Schema cannot state a value-conditioned requirement in the closed projection list, + so the published `ai/Agent` schema (and the four installed-package schemas that + embed agents) names the site in `x-dropped-refinements`, recorded in + `dropped-refinements.baseline.json`. +- **D2 conversion `agent-memory-long-term-store-removed`** (step 18, retired from the + load path): it deletes `store` from `memory.longTerm`, whatever it holds — the + delete is lossless, because no value of it ever chose a backend. Stored + `sys_metadata` agent rows and built artifacts replay it; one notice per agent. It + supplies neither number. +- **D3 entry `agent-memory-store-retired-and-limits-required`** carries the judgement + the conversion cannot make: the two numbers an enabled `longTerm` now requires. +- **`RETIRED_KEYS_BY_MAJOR[18]`** registers `ai/Agent:memory.longTerm.store`. +- **No deprecation window**, per the project's startup-stage posture. + +### Describes and the liveness ledger + +- `agent.memory` drops `[EXPERIMENTAL — not enforced]`: it states that the cloud AI + runtime enforces it and that the open framework edition does not run agents. + `longTerm`, `enabled`, `maxEntries` and `reflectionInterval` each state what the + runtime does with them. +- The ledger row moves `experimental` → `live`, citing the cloud reader + `agent-runtime.ts#compileAgentMemory` (via `AgentRuntime.resolveTurnGuardrails`), + the enforcement in `ai-service.ts` and the store `agent-memory.ts#AgentMemoryStore`, + as attested by the cloud seat's reading at cloud `ef5a4344`, `verifiedAt` + 2026-10-02. `os lint` / `os validate` no longer warn + `liveness-experimental-property` on an agent that sets `memory`. +- ⚠️ **The window, stated.** At `ef5a4344` the cloud reader still reads `store`: it + honours `database` only and refuses `vector` and `redis`. Cloud drops `store` in + that one reader once this release reaches its pin, and no earlier. + +⚠️ **The out-of-repo consumer population is NOT MEASURED.** `@objectstack/spec` is +published, and tenant-authored agents were not measured. This repo authors no +`longTerm` outside `packages/spec`, and no cloud built-in agent declares one. + +Clause-②: yes (narrowing) + + diff --git a/packages/lint/src/lint-liveness-properties.test.ts b/packages/lint/src/lint-liveness-properties.test.ts index d97fd9f6c0f..4f8e844d175 100644 --- a/packages/lint/src/lint-liveness-properties.test.ts +++ b/packages/lint/src/lint-liveness-properties.test.ts @@ -119,12 +119,13 @@ describe('lintLivenessProperties', () => { expect(ruleOf(findings, 'nodes.outputSchema')).toBe(LIVENESS_DEAD_PROPERTY); }); - it('warns on an experimental prop with no authorWarn of its own (agent.memory)', () => { + it('warns on an experimental prop with no authorWarn of its own (tool.outputSchema)', () => { // `experimental` warns implicitly — shouldWarn() treats a declared-but- // unenforced guarantee like an opted-in dead prop. Repointed from - // action.undoable in #3714, which turned out to have two objectui readers. - const findings = lintLivenessProperties({ agents: [{ name: 'ag1', memory: { kind: 'buffer' } }] }); - const f = findings.find((x) => x.message.includes('`memory`')); + // action.undoable in #3714, which turned out to have two objectui readers, + // and from agent.memory in #20274, which the cloud AI runtime enforces. + const findings = lintLivenessProperties({ tools: [{ name: 't1', outputSchema: { type: 'object' } }] }); + const f = findings.find((x) => x.message.includes('`outputSchema`')); expect(f).toBeDefined(); expect(f!.rule).toBe('liveness-experimental-property'); }); @@ -930,12 +931,12 @@ describe('lintLivenessProperties', () => { describe('never throws on a malformed collection item (#11385)', () => { it('flat TYPE_COLLECTIONS loop: skips a null item and keeps walking past it', () => { const findings = lintLivenessProperties({ - // agent.memory is a real, currently-`experimental` ledger row (see + // tool.outputSchema is a real, currently-`experimental` ledger row (see // "warns on an experimental prop" above) — a real ledger witness, // not a synthetic one. - agents: [null, { name: 'ag1', memory: { kind: 'buffer' } }], + tools: [null, { name: 't1', outputSchema: { type: 'object' } }], }); - expect(paths(findings).some((m) => m.includes('`memory`'))).toBe(true); + expect(paths(findings).some((m) => m.includes('`outputSchema`'))).toBe(true); }); it('object walk: skips a null item and keeps walking past it', () => { @@ -1596,12 +1597,12 @@ describe('a per-type ledger that could not be READ is reported once (#19276)', ( }); it('keeps walking the types whose ledgers ARE readable, and puts the fault first', () => { - // agent.memory is a real `experimental` row, so this proves the fault does - // not abort the pass: one type is dark, the rest still enforce, and the - // line that explains the darkness is the one a reader meets first. + // tool.outputSchema is a real `experimental` row, so this proves the fault + // does not abort the pass: one type is dark, the rest still enforce, and + // the line that explains the darkness is the one a reader meets first. const findings = lintLivenessPropertiesFromLedgerDir( ledgerDirWith((dir) => rmSync(join(dir, 'object.json'))), - { agents: [{ name: 'ag1', memory: { kind: 'buffer' } }] }, + { tools: [{ name: 't1', outputSchema: { type: 'object' } }] }, ); expect(findings.map((f) => f.rule)).toEqual([LIVENESS_LEDGER_UNREADABLE, LIVENESS_EXPERIMENTAL_PROPERTY]); expect(findings[0].where).toBe("liveness ledger 'object'"); diff --git a/packages/spec/dropped-refinements.baseline.json b/packages/spec/dropped-refinements.baseline.json index a2ab87151b8..1f849493c81 100644 --- a/packages/spec/dropped-refinements.baseline.json +++ b/packages/spec/dropped-refinements.baseline.json @@ -3,13 +3,14 @@ "measured": { "zod": "4.4.3", "publishedSchemasWithDroppedRefinements": 215, - "droppedRefinementSites": 635, + "droppedRefinementSites": 640, "refinementSitesThatDidProject": 369, "refinementSitesWithNoJsonFormToCompare": 0 }, "entries": { "ai/Agent": { "sites": [ + "memory", "structuredOutput.schema" ] }, @@ -63,6 +64,7 @@ "manifest.actions.element.in", "manifest.actions.element.in.ai.outputSchema", "manifest.actions.element.in.params.element.in", + "manifest.agents.element.memory", "manifest.agents.element.structuredOutput.schema", "manifest.connectors.element.out", "manifest.dashboards.element.globalFilters.element", @@ -168,6 +170,7 @@ "data.options[1].manifest.actions.element.in", "data.options[1].manifest.actions.element.in.ai.outputSchema", "data.options[1].manifest.actions.element.in.params.element.in", + "data.options[1].manifest.agents.element.memory", "data.options[1].manifest.agents.element.structuredOutput.schema", "data.options[1].manifest.connectors.element.out", "data.options[1].manifest.dashboards.element.globalFilters.element", @@ -258,6 +261,7 @@ "options[1].manifest.actions.element.in", "options[1].manifest.actions.element.in.ai.outputSchema", "options[1].manifest.actions.element.in.params.element.in", + "options[1].manifest.agents.element.memory", "options[1].manifest.agents.element.structuredOutput.schema", "options[1].manifest.connectors.element.out", "options[1].manifest.dashboards.element.globalFilters.element", @@ -308,6 +312,7 @@ "data.packages.element.options[1].manifest.actions.element.in", "data.packages.element.options[1].manifest.actions.element.in.ai.outputSchema", "data.packages.element.options[1].manifest.actions.element.in.params.element.in", + "data.packages.element.options[1].manifest.agents.element.memory", "data.packages.element.options[1].manifest.agents.element.structuredOutput.schema", "data.packages.element.options[1].manifest.connectors.element.out", "data.packages.element.options[1].manifest.dashboards.element.globalFilters.element", From 5348f6afa0c1baef1cc2b13c07514519f92e524b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 11:19:21 +0000 Subject: [PATCH 3/9] wip(spec): regenerate agent liveness counts Claude-Session: https://claude.ai/code/session_01YDt3PzwfrkuFzUBF89WPmM Co-authored-by: Claude --- packages/spec/liveness/state-counts/agent.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/spec/liveness/state-counts/agent.md b/packages/spec/liveness/state-counts/agent.md index ffbc1f79fc3..924db90445a 100644 --- a/packages/spec/liveness/state-counts/agent.md +++ b/packages/spec/liveness/state-counts/agent.md @@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them. | Type | live | exp | elsewhere | dead | planned | classified | |---|---|---|---|---|---|---| -| `agent` | 22 | 2 | 0 | 2 | 0 | 26 | +| `agent` | 23 | 1 | 0 | 2 | 0 | 26 | From 1e51cd956690e97b41590ac19252add99a8637b5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 11:25:21 +0000 Subject: [PATCH 4/9] wip(spec): regenerate agent reference page Claude-Session: https://claude.ai/code/session_01YDt3PzwfrkuFzUBF89WPmM Co-authored-by: Claude --- content/docs/references/ai/agent.mdx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/content/docs/references/ai/agent.mdx b/content/docs/references/ai/agent.mdx index 476609a68cd..3cf2c258c65 100644 --- a/content/docs/references/ai/agent.mdx +++ b/content/docs/references/ai/agent.mdx @@ -58,7 +58,7 @@ const result = AIModelConfigSchema.parse(data); | **access** | `string[]` | optional | Who can chat with this agent | | **permissions** | `string[]` | optional | Required permission-set capabilities | | **planning** | `{ maxIterations: integer }` | optional | Autonomous reasoning and planning configuration | -| **memory** | `{ longTerm?: object; reflectionInterval?: integer }` | optional | [EXPERIMENTAL — not enforced] Agent memory management. Parsed but no runtime consumer yet. | +| **memory** | `{ longTerm?: object; reflectionInterval?: integer }` | optional | Agent memory (long-term notes recalled before each conversation and written by periodic reflection), enforced by the cloud AI runtime; the open framework edition does not run agents. | | **guardrails** | `{ maxTokensPerInvocation?: integer; maxExecutionTimeSec?: integer; blockedTopics?: string[] }` | optional | Safety guardrails for the agent (token budget, time limit, blocked topics), enforced per user turn by the cloud AI runtime; the open framework edition does not run agents. | | **structuredOutput** | `{ format: Enum<'json_object' \| 'json_schema'>; schema?: Record; strict: boolean; retryOnValidationFailure: boolean; … }` | optional | Structured output contract for the agent's final answer (JSON format, schema, retries, fallback format, transform steps), enforced on every final answer by the cloud AI runtime; the open framework edition does not run agents. | | **protection** | `{ lock: Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>; reason: string; docsUrl?: string }` | optional | Package author protection block — lock policy for this agent. | @@ -101,8 +101,8 @@ const result = AIModelConfigSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **longTerm** | `{ enabled: boolean; store: Enum<'vector' \| 'database' \| 'redis'>; maxEntries?: integer }` | optional | Long-term / persistent memory | -| **reflectionInterval** | `integer` | optional | Reflect every N interactions to improve behavior | +| **longTerm** | `{ enabled: boolean; maxEntries?: integer }` | optional | Long-term memory: distilled notes kept per user and agent and recalled before each conversation | +| **reflectionInterval** | `integer` | optional | Reflect every N delivered interactions: each reflection writes one distilled note to long-term memory. Required when longTerm.enabled is true, and refused without it | ### Nested Shape: `Agent.guardrails` From 61fbcabbfa2c012f02aa64f582ecea22aa322569 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 11:26:25 +0000 Subject: [PATCH 5/9] wip(spec): store retirement pins in their own repo-project file Claude-Session: https://claude.ai/code/session_01YDt3PzwfrkuFzUBF89WPmM Co-authored-by: Claude --- .../ai/agent-memory-store-retirement.test.ts | 377 ++++++++++++++++++ packages/spec/src/ai/agent.test.ts | 321 +-------------- packages/spec/vitest.repo-tests.json | 1 + 3 files changed, 382 insertions(+), 317 deletions(-) create mode 100644 packages/spec/src/ai/agent-memory-store-retirement.test.ts diff --git a/packages/spec/src/ai/agent-memory-store-retirement.test.ts b/packages/spec/src/ai/agent-memory-store-retirement.test.ts new file mode 100644 index 00000000000..c90c4e33d37 --- /dev/null +++ b/packages/spec/src/ai/agent-memory-store-retirement.test.ts @@ -0,0 +1,377 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * An agent's long-term memory store — `agent.memory.longTerm.store` — RETIRED + * as a whole key (ADR-0049 enforce-or-remove, ruling record 5950198150, letter + * A′, #20274): the memory store is platform infrastructure, not agent metadata. + * The cloud AI runtime, the one runtime that executes agents, keeps long-term + * memory notes in its own database store and refused the `vector` default and + * `redis` before an agent's first turn. The contract half of the same ruling — + * `maxEntries` and `reflectionInterval` required once long-term memory is + * enabled — is pinned in `agent.test.ts`. + * + * Bookkeeping shapes, pinned below: + * 1. A `retiredKey()` tombstone inside the `strictObject` `longTerm` block, so + * the refusal carries the prescription and the key's input type is `never` + * for `tsc`. Its three old alias spellings (`backend` / `storage` / + * `provider`) are `guidance` entries carrying the same answer, because an + * alias may not steer an author onto a tombstone. + * 2. D2 conversion `agent-memory-long-term-store-removed` (step 18), a lossless + * delete of the key from every `agents[]` entry, retired from the load + * path: a live author is refused, a stored or built agent replays clean. + * 3. `RETIRED_KEYS_BY_MAJOR[18]` carries the nested `ai/Agent:memory.longTerm.store`, + * and the D3 entry `agent-memory-store-retired-and-limits-required` carries + * the judgement the conversion cannot make. + * 4. The liveness row is the `memory` container's, `live`: a nested key has no + * row of its own (`check:liveness` is the judge of that half). + * + * On the assertion set: a schema refusal raises a `ZodError` whose issues carry + * `code` and `path` but no ADR-0112 `status` — that envelope belongs to the + * authoring door, `defineStack`, which is pinned with its `code` and `status` + * below. Everywhere else: refusal, the issue `code`, the `path` naming the key, + * and the prescription text. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { describe, expect, it } from 'vitest'; + +import { applyConversions, collectConversionNotices } from '../conversions/apply'; +import { ALL_CONVERSIONS } from '../conversions/registry'; +import { applyConversionsToStoredItem } from '../conversions/stored'; +import { MIGRATIONS_BY_MAJOR, RETIRED_KEYS_BY_MAJOR } from '../migrations/registry'; +import { defineStack, ObjectStackDefinitionSchema } from '../stack.zod'; +import { AgentSchema, type Agent } from './agent.zod'; + +const MIGRATE_SENTENCE = + 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand.'; + +const MEMORY_AGENT = { + name: 'memory_agent', + label: 'Memory Agent', + role: 'Assistant', + instructions: 'Remember what the user told you.', +} as const; + +/** The store tombstone's prescription: key first, why, the fix, the migrate sentence. */ +const STORE_PRESCRIPTION = + /^`agent\.memory\.longTerm\.store` was removed in @objectstack\/spec 17\.7\.0 \(ADR-0049 enforce-or-remove\) — the memory store is platform infrastructure, not agent metadata: .*Delete the key; long-term memory is configured by `enabled`, `maxEntries` and `agent\.memory\.reflectionInterval`/s; + +/** The one refusal `defineStack` raised, as its ADR-0112 envelope. */ +const stackRefusal = (agent: Record) => { + let thrown: unknown; + try { + defineStack({ + manifest: { id: 'com.example.agent-memory', name: 'agent_memory', version: '1.0.0', type: 'app' }, + agents: [agent], + } as never); + } catch (e) { + thrown = e; + } + return thrown as { code?: string; status?: number; issues?: Array<{ path: PropertyKey[]; message: string }> }; +}; + +describe('agent memory store retirement — the tombstone, at every door that carries an agent', () => { + it('refuses EVERY value at its path with the prescription — the honoured one, the refused ones, and non-strings', () => { + for (const store of ['database', 'vector', 'redis', {}, null, 0]) { + const r = AgentSchema.safeParse({ + ...MEMORY_AGENT, + memory: { longTerm: { enabled: true, store, maxEntries: 5 }, reflectionInterval: 3 }, + }); + expect(r.success, `store: ${JSON.stringify(store)}`).toBe(false); + const issues = r.error!.issues; + expect(issues).toHaveLength(1); + expect(issues[0].code).toBe('invalid_type'); + expect(issues[0].path).toEqual(['memory', 'longTerm', 'store']); + expect(issues[0].message).toMatch(STORE_PRESCRIPTION); + expect(issues[0].message.endsWith(MIGRATE_SENTENCE)).toBe(true); + } + }); + + it('the old store spellings `backend` / `storage` / `provider` are answered with the same fact, never steered onto `store`', () => { + for (const spelling of ['backend', 'storage', 'provider']) { + const r = AgentSchema.safeParse({ + ...MEMORY_AGENT, + memory: { longTerm: { enabled: true, [spelling]: 'database', maxEntries: 5 }, reflectionInterval: 3 }, + }); + expect(r.success, spelling).toBe(false); + const issues = r.error!.issues; + expect(issues).toHaveLength(1); + expect(issues[0].code).toBe('unrecognized_keys'); + expect(issues[0].path).toEqual(['memory', 'longTerm']); + expect(issues[0].message).toContain('there is no storage-backend key on long-term memory'); + expect(issues[0].message).not.toMatch(/→ `store`/); + } + }); + + it('the did-you-mean never offers the tombstone: a near-miss `stor` is not steered onto `store`', () => { + const r = AgentSchema.safeParse({ + ...MEMORY_AGENT, + memory: { longTerm: { enabled: true, stor: 'database', maxEntries: 5 }, reflectionInterval: 3 }, + }); + expect(r.success).toBe(false); + const issue = r.error!.issues[0]; + expect(issue.code).toBe('unrecognized_keys'); + expect(issue.message).toContain('`stor`'); + expect(issue.message).not.toMatch(/`stor` → `store`/); + }); + + it('the walked shape keeps `store` as a key — the tombstone stays reachable for its refusal', () => { + // `AgentSchema.shape.memory` is the very handle the cloud reader re-parses with. + const longTerm = (AgentSchema.shape.memory as any).unwrap().shape.longTerm.unwrap(); + expect(Object.keys(longTerm.shape)).toContain('store'); + expect(Object.keys(longTerm.shape), 'CONTROL: its live neighbour').toContain('maxEntries'); + }); + + it('fails tsc at the authoring site: the input type of `store` is `never`', () => { + const agent: Agent = { + ...MEMORY_AGENT, + memory: { + longTerm: { + enabled: true, + // @ts-expect-error — `store` is a retiredKey() tombstone: its input type is `never`. + store: 'database', + maxEntries: 5, + }, + reflectionInterval: 3, + }, + }; + // The parse channel agrees with the type channel on the same literal. + expect(() => AgentSchema.parse(agent)).toThrow(STORE_PRESCRIPTION); + }); + + it('the authoring door, defineStack, refuses it with the STACK_SCHEMA_INVALID envelope — never rewrites it', () => { + const refusal = stackRefusal({ + ...MEMORY_AGENT, + memory: { longTerm: { enabled: true, store: 'vector', maxEntries: 5 }, reflectionInterval: 3 }, + }); + expect(refusal?.code).toBe('STACK_SCHEMA_INVALID'); + expect(refusal?.status).toBe(422); + expect(refusal.issues).toHaveLength(1); + expect(refusal.issues?.[0]?.path).toEqual(['agents', 0, 'memory', 'longTerm', 'store']); + expect(refusal.issues?.[0]?.message).toMatch(STORE_PRESCRIPTION); + }); +}); + +describe('agent memory store retirement — the D2 conversion', () => { + const CONVERSION_ID = 'agent-memory-long-term-store-removed'; + const persisted = (store: unknown) => ({ + ...MEMORY_AGENT, + memory: { longTerm: { enabled: true, store, maxEntries: 20 }, reflectionInterval: 5 }, + }); + + it('a STORED agent row carrying the key replays clean through the rehydration seam', () => { + const notices: Array<{ conversionId?: string; path?: string; from?: string; to?: string }> = []; + const rehydrated = applyConversionsToStoredItem('agent', persisted('vector'), { + onNotice: (n) => notices.push(n as { conversionId?: string; path?: string; from?: string; to?: string }), + }) as Record; + expect(notices.map((n) => [n.conversionId, n.path, n.from, n.to])).toEqual([ + [CONVERSION_ID, 'agents[0](memory_agent).memory.longTerm.store', 'store', '(removed)'], + ]); + // CONTROL: every live key on the same row survives byte-for-byte. + expect(rehydrated).toEqual({ + ...MEMORY_AGENT, + memory: { longTerm: { enabled: true, maxEntries: 20 }, reflectionInterval: 5 }, + }); + // And the rehydrated row is exactly what the write door accepts now. + expect(AgentSchema.safeParse(rehydrated).success).toBe(true); + }); + + it('a persisted artifact is REFUSED at the boot door before the conversion and ACCEPTED after it', () => { + const artifact = () => ({ agents: [persisted('database')] }); + const before = ObjectStackDefinitionSchema.safeParse(artifact()); + expect(before.success).toBe(false); + expect(JSON.stringify(before.error?.issues ?? [])).toContain('`agent.memory.longTerm.store` was removed'); + const after = ObjectStackDefinitionSchema.safeParse(applyConversions(artifact(), { includeRetired: true })); + expect(after.success, JSON.stringify(after.error?.issues ?? [])).toBe(true); + }); + + it('LIT CONTROL — an agent missing a required number is refused on BOTH sides: the conversion supplies no number', () => { + const artifact = () => ({ + agents: [{ ...MEMORY_AGENT, memory: { longTerm: { enabled: true, store: 'vector', maxEntries: 20 } } }], + }); + expect(ObjectStackDefinitionSchema.safeParse(artifact()).success).toBe(false); + const healed = applyConversions(artifact(), { includeRetired: true }); + const after = ObjectStackDefinitionSchema.safeParse(healed); + expect(after.success).toBe(false); + expect(JSON.stringify(after.error?.issues ?? [])).toContain('`agent.memory.reflectionInterval` is required'); + }); + + it('is idempotent by construction, and leaves an agent without the key untouched by reference', () => { + const input = { agents: [persisted('redis'), { ...MEMORY_AGENT, name: 'plain_agent' }] }; + const { stack, notices } = collectConversionNotices(input, { includeRetired: true }); + expect(notices.filter((n) => n.conversionId === CONVERSION_ID)).toHaveLength(1); + expect((stack.agents as unknown[])[1]).toBe(input.agents[1]); + const replay = collectConversionNotices(stack, { includeRetired: true }); + expect(replay.notices).toHaveLength(0); + expect(replay.stack).toBe(stack); + }); + + it('is retired from the load path — a live author is refused at parse, never silently rewritten', () => { + const input = { agents: [persisted('database')] }; + const { stack, notices } = collectConversionNotices(input); + expect(notices.filter((n) => n.conversionId === CONVERSION_ID)).toHaveLength(0); + expect(stack).toEqual(input); + }); + + it('is registered under major 18: the exact nested key, the chain step, and one D3 entry for the contract', () => { + expect(RETIRED_KEYS_BY_MAJOR[18]).toContain('ai/Agent:memory.longTerm.store'); + expect(MIGRATIONS_BY_MAJOR[18]!.conversionIds).toContain(CONVERSION_ID); + const conversion = ALL_CONVERSIONS.find((c) => c.id === CONVERSION_ID); + expect(conversion, 'the D2 conversion must be registered').toBeDefined(); + expect(conversion!.toMajor).toBe(18); + expect(conversion!.retiredFromLoadPath).toBe(true); + expect(conversion!.surface).toBe('agent.memory.longTerm.store'); + const d3 = MIGRATIONS_BY_MAJOR[18]!.semantic.filter((s) => s.id === 'agent-memory-store-retired-and-limits-required'); + expect(d3).toHaveLength(1); + expect(d3[0]!.conversionIds).toEqual([CONVERSION_ID]); + expect(d3[0]!.reason).toContain(`\`${CONVERSION_ID}\``); + }); +}); + +// ─── Tree-scoped absence, with a DECLARED radius ───────────────────────────── +// +// `tsc` is the primary sweeper — `retiredKey()` types `store` `never` on +// `Agent`, so every TYPED authoring site fails to compile. The residue is what +// `tsc` never judges: JSON, YAML, MD/MDX code fences, untyped `.js`, and TS +// literals typed `any` / `unknown`. This walk covers that residue across the +// five repo roots `scripts/cross-package-test-inputs.mjs` already declares for +// `@objectstack/spec#test` (mirrored in `turbo.json`), plus the example apps' +// own `src/` trees, declared there as `examples/*/src/**/*.ts`. +// +// The matcher judges the AUTHORING SHAPE, never a mention: a `longTerm` key +// whose object value holds a `store` key — or one of its old spellings +// `backend` / `storage` / `provider` — before any nested brace (TS / JS / JSON, +// and YAML block and flow form). `store` alone is far too common a word to +// judge. Inline code is prose and is stripped before judging. The bound, +// stated: a block assembled by spread or under computed keys, and `docs/**`, +// `.claude/**`, `.github/**` and the repo-root files are outside what this +// walk sees. +describe('tree-scoped absence: nothing inside the declared radius still authors a long-term memory store', () => { + const SPEC_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); + const REPO_ROOT = path.resolve(SPEC_ROOT, '../..'); + const THIS_FILE = path.relative(REPO_ROOT, fileURLToPath(import.meta.url)).split(path.sep).join('/'); + + /** The walked roots — declared in `scripts/cross-package-test-inputs.mjs` under `@objectstack/spec`. */ + const WALK_ROOTS = ['packages', 'examples', 'skills', 'content', 'scripts']; + const SCANNED_EXT = new Set(['.ts', '.mts', '.cts', '.js', '.mjs', '.cjs', '.json', '.md', '.mdx', '.yaml', '.yml']); + /** Under `examples/` the non-code extensions, plus `.ts` inside an app's own `src/` tree. */ + const EXAMPLES_EXT = new Set(['.json', '.md', '.mdx', '.yaml', '.yml']); + const EXAMPLE_APP_SRC_TS = /^examples\/[^/]+\/src\/.+\.ts$/; + const SKIPPED_DIRS = new Set(['node_modules', 'dist', '.git', '.turbo', '.cache', '.objectstack', 'coverage', '.next', '.source']); + + const STORE_KEYS = '(store|backend|storage|provider)'; + const AUTHORING = [ + // TS / JS / JSON, block or inline: the key, then an object whose own keys + // (before any nested brace) include the store or one of its spellings. + new RegExp(`(^|[^\\w.$])["']?longTerm["']?\\s*:\\s*\\{[^{}]*?(^|[^\\w.$])["']?${STORE_KEYS}["']?\\s*:`, 'm'), + // YAML block form: the key on its own line, the store key indented below it + // among its siblings. + new RegExp(`^[ \\t]*(-[ \\t]+)?longTerm[ \\t]*:[ \\t]*\\r?\\n(?:[ \\t]+[^\\s][^\\n]*\\r?\\n)*?[ \\t]+${STORE_KEYS}[ \\t]*:`, 'm'), + ]; + + /** + * Inline code spans are prose — the house style `check:doc-authoring` enforces + * — so stripping single-backtick spans separates "the retirement kit + * describing what it removed" from "a source still writing it". + * Newline-bounded: a fenced block's content is NOT stripped. + */ + const stripInlineCode = (text: string): string => text.replace(/`[^`\n]*`/g, ''); + const judge = (text: string): RegExpExecArray | null => { + const stripped = stripInlineCode(text); + for (const re of AUTHORING) { + const m = re.exec(stripped); + if (m) return m; + } + return null; + }; + + /** + * Structural exclusions — the retirement kit, each with its reason. ⛔ NOT an + * allowlist file (`spec-property-retirement` §4): every entry's JOB is to + * spell the retired key. + */ + const EXCLUDED = new Set([ + // This pin authors the key to assert its refusal and its conversion. + THIS_FILE, + ]); + const EXCLUDED_PREFIXES = [ + // The D2 conversion's fixture authors the pre-retirement agent on purpose. + 'packages/spec/src/conversions/', + // Release-owned prose records the removal; never edited by a code PR. + 'content/docs/releases/', + // GITIGNORED build output (`packages/spec/json-schema/`), reached only + // because this is a FILESYSTEM walk. Its source is `agent.zod.ts`. + 'packages/spec/json-schema/', + ]; + /** tsup's own bundle of `tsup.config.ts`, written and deleted mid-build. */ + const TSUP_BUNDLED_CONFIG = /\.bundled_[^./]+\.mjs$/; + + /** Tolerates ONLY a path that vanished mid-walk; every other read fault is re-raised. */ + const readIfPresent = (full: string): string | undefined => { + try { + return fs.readFileSync(full, 'utf-8'); + } catch (err) { + if ((err as NodeJS.ErrnoException)?.code !== 'ENOENT') throw err; + return undefined; + } + }; + + it('the matcher recognises an authoring and ignores a prose mention and a neighbour (anti-vacuity)', () => { + // Offenders — the retired shape, in each syntax the walk reads. + expect(judge("defineAgent({ memory: { longTerm: { enabled: true, store: 'vector' } } })")).not.toBeNull(); + expect(judge(" longTerm: {\n enabled: true,\n store: 'database',\n },")).not.toBeNull(); + expect(judge('{ "longTerm": { "enabled": true, "store": "redis" } }')).not.toBeNull(); + expect(judge("longTerm: { backend: 'database' }")).not.toBeNull(); + expect(judge('memory:\n longTerm:\n enabled: true\n storage: vector\n')).not.toBeNull(); + expect(judge('longTerm: { enabled: true, provider: redis }\n')).not.toBeNull(); + expect(judge("Prose.\n\n```ts\ndefineAgent({ memory: { longTerm: { store: 'vector' } } });\n```\n")).not.toBeNull(); + // Neighbours that must NOT match. + expect(judge("the `longTerm: { enabled: true, store: 'vector' }` block loses its store")).toBeNull(); + expect(judge("memory: { longTerm: { enabled: true, maxEntries: 5 }, reflectionInterval: 3 }")).toBeNull(); + expect(judge("store: retiredKey(LONG_TERM_STORE_RETIRED),")).toBeNull(); + expect(judge("'long.store': (memory.longTerm as any)?.store,")).toBeNull(); + expect(judge("const s = { longTerm: 1, nested: { store: 'x' } };")).toBeNull(); + expect(judge('longTerm:\n enabled: true\nstore:\n kind: x\n')).toBeNull(); + }); + + it('no long-term memory store authoring survives inside the declared radius outside the retirement kit', () => { + const offenders: string[] = []; + let visited = 0; + let exampleSources = 0; + const walk = (dir: string) => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + const rel = path.relative(REPO_ROOT, full).split(path.sep).join('/'); + if (entry.isDirectory()) { + if (SKIPPED_DIRS.has(entry.name) || entry.name.startsWith('.')) continue; + walk(full); + continue; + } + if (!entry.isFile()) continue; + const ext = path.extname(entry.name); + const scanned = rel.startsWith('examples/') + ? EXAMPLES_EXT.has(ext) || EXAMPLE_APP_SRC_TS.test(rel) + : SCANNED_EXT.has(ext); + if (!scanned) continue; + if (entry.name === 'CHANGELOG.md') continue; // release prose records the removal + if (EXCLUDED.has(rel) || EXCLUDED_PREFIXES.some((p) => rel.startsWith(p))) continue; + if (TSUP_BUNDLED_CONFIG.test(entry.name)) continue; + visited += 1; + if (EXAMPLE_APP_SRC_TS.test(rel)) exampleSources += 1; + const text = readIfPresent(full); + if (text === undefined) continue; + const m = judge(text); + if (m) offenders.push(`${rel} authors \`${m[0].trim().replace(/\s+/g, ' ')}\``); + } + }; + for (const root of WALK_ROOTS) walk(path.join(REPO_ROOT, root)); + // Anti-vacuity: the walk really covered the tree, and the example apps' + // sources were really read. + expect(visited).toBeGreaterThan(1000); + expect(exampleSources).toBeGreaterThan(50); + expect(offenders, 'a long-term memory store authoring means the retirement is being undone').toEqual([]); + }); +}); diff --git a/packages/spec/src/ai/agent.test.ts b/packages/spec/src/ai/agent.test.ts index 61ae84f8a8f..d2cc2bbd70e 100644 --- a/packages/spec/src/ai/agent.test.ts +++ b/packages/spec/src/ai/agent.test.ts @@ -1,14 +1,6 @@ -import fs from 'node:fs'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; - import { describe, it, expect } from 'vitest'; -import { applyConversions, collectConversionNotices } from '../conversions/apply'; -import { ALL_CONVERSIONS } from '../conversions/registry'; -import { applyConversionsToStoredItem } from '../conversions/stored'; -import { MIGRATIONS_BY_MAJOR, RETIRED_KEYS_BY_MAJOR } from '../migrations/registry'; -import { defineStack, ObjectStackDefinitionSchema } from '../stack.zod'; +import { defineStack } from '../stack.zod'; import { AgentSchema, AIModelConfigSchema, @@ -789,7 +781,9 @@ describe('defineAgent', () => { // long-term memory from `enabled`, `maxEntries` and `reflectionInterval`, and // refused before an agent's first turn exactly the declarations the spec still // accepted. Authoring now refuses them, by name, with a prescription; and the -// storage backend left the spec as a whole key. On the assertion set: a schema +// storage backend left the spec as a whole key — that retirement's pins, its D2 +// conversion and its tree-scoped absence walk live in +// `agent-memory-store-retirement.test.ts`. On the assertion set: a schema // refusal is a ZodError whose issues carry `code` and `path` but no ADR-0112 // `status` — so the schema pins assert refusal, the issue `code`, the `path` // naming the key and the prescription text, and the authoring door @@ -802,10 +796,6 @@ const MEMORY_AGENT = { instructions: 'Remember what the user told you.', } as const; -/** The store tombstone's prescription: key first, why, the fix, the migrate sentence. */ -const STORE_PRESCRIPTION = - /^`agent\.memory\.longTerm\.store` was removed in @objectstack\/spec 17\.7\.0 \(ADR-0049 enforce-or-remove\) — the memory store is platform infrastructure, not agent metadata: .*Delete the key; long-term memory is configured by `enabled`, `maxEntries` and `agent\.memory\.reflectionInterval`/s; - /** A stack an authoring door accepts, carrying one agent. */ const stackWith = (agent: Record) => ({ manifest: { id: 'com.example.agent-memory', name: 'agent_memory', version: '1.0.0', type: 'app' }, @@ -910,306 +900,3 @@ describe('agent memory contract — what the runtime honours is what authoring a ).not.toThrow(); }); }); - -describe('agent memory contract — `longTerm.store` is retired as a whole key', () => { - it('refuses EVERY value at its path with the prescription — the honoured one, the refused ones, and non-strings', () => { - for (const store of ['database', 'vector', 'redis', {}, null, 0]) { - const r = AgentSchema.safeParse({ - ...MEMORY_AGENT, - memory: { longTerm: { enabled: true, store, maxEntries: 5 }, reflectionInterval: 3 }, - }); - expect(r.success, `store: ${JSON.stringify(store)}`).toBe(false); - const issues = r.error!.issues; - expect(issues).toHaveLength(1); - expect(issues[0].code).toBe('invalid_type'); - expect(issues[0].path).toEqual(['memory', 'longTerm', 'store']); - expect(issues[0].message).toMatch(STORE_PRESCRIPTION); - expect(issues[0].message.endsWith(MIGRATE_SENTENCE)).toBe(true); - } - }); - - it('the old store spellings `backend` / `storage` / `provider` are answered with the same fact, never steered onto `store`', () => { - for (const spelling of ['backend', 'storage', 'provider']) { - const r = AgentSchema.safeParse({ - ...MEMORY_AGENT, - memory: { longTerm: { enabled: true, [spelling]: 'database', maxEntries: 5 }, reflectionInterval: 3 }, - }); - expect(r.success, spelling).toBe(false); - const issues = r.error!.issues; - expect(issues).toHaveLength(1); - expect(issues[0].code).toBe('unrecognized_keys'); - expect(issues[0].path).toEqual(['memory', 'longTerm']); - expect(issues[0].message).toContain('there is no storage-backend key on long-term memory'); - expect(issues[0].message).not.toMatch(/→ `store`/); - } - }); - - it('the did-you-mean never offers the tombstone: a near-miss `stor` is not steered onto `store`', () => { - const r = AgentSchema.safeParse({ - ...MEMORY_AGENT, - memory: { longTerm: { enabled: true, stor: 'database', maxEntries: 5 }, reflectionInterval: 3 }, - }); - expect(r.success).toBe(false); - const issue = r.error!.issues[0]; - expect(issue.code).toBe('unrecognized_keys'); - expect(issue.message).toContain('`stor`'); - expect(issue.message).not.toMatch(/`stor` → `store`/); - }); - - it('the walked shape keeps `store` as a key — the tombstone stays reachable for its refusal', () => { - // `AgentSchema.shape.memory` is the very handle the cloud reader re-parses with. - const longTerm = (AgentSchema.shape.memory as any).unwrap().shape.longTerm.unwrap(); - expect(Object.keys(longTerm.shape)).toContain('store'); - expect(Object.keys(longTerm.shape), 'CONTROL: its live neighbour').toContain('maxEntries'); - }); - - it('fails tsc at the authoring site: the input type of `store` is `never`', () => { - const agent: Agent = { - ...MEMORY_AGENT, - memory: { - longTerm: { - enabled: true, - // @ts-expect-error — `store` is a retiredKey() tombstone: its input type is `never`. - store: 'database', - maxEntries: 5, - }, - reflectionInterval: 3, - }, - }; - // The parse channel agrees with the type channel on the same literal. - expect(() => AgentSchema.parse(agent)).toThrow(STORE_PRESCRIPTION); - }); - - it('the authoring door, defineStack, refuses it with the STACK_SCHEMA_INVALID envelope — never rewrites it', () => { - const refusal = stackRefusal({ - ...MEMORY_AGENT, - memory: { longTerm: { enabled: true, store: 'vector', maxEntries: 5 }, reflectionInterval: 3 }, - }); - expect(refusal?.code).toBe('STACK_SCHEMA_INVALID'); - expect(refusal?.status).toBe(422); - expect(refusal.issues).toHaveLength(1); - expect(refusal.issues?.[0]?.path).toEqual(['agents', 0, 'memory', 'longTerm', 'store']); - expect(refusal.issues?.[0]?.message).toMatch(STORE_PRESCRIPTION); - }); -}); - -describe('agent memory contract — the D2 conversion deletes `store`', () => { - const CONVERSION_ID = 'agent-memory-long-term-store-removed'; - const persisted = (store: unknown) => ({ - ...MEMORY_AGENT, - memory: { longTerm: { enabled: true, store, maxEntries: 20 }, reflectionInterval: 5 }, - }); - - it('a STORED agent row carrying the key replays clean through the rehydration seam', () => { - const notices: Array<{ conversionId?: string; path?: string; from?: string; to?: string }> = []; - const rehydrated = applyConversionsToStoredItem('agent', persisted('vector'), { - onNotice: (n) => notices.push(n as { conversionId?: string; path?: string; from?: string; to?: string }), - }) as Record; - expect(notices.map((n) => [n.conversionId, n.path, n.from, n.to])).toEqual([ - [CONVERSION_ID, 'agents[0](memory_agent).memory.longTerm.store', 'store', '(removed)'], - ]); - // CONTROL: every live key on the same row survives byte-for-byte. - expect(rehydrated).toEqual({ - ...MEMORY_AGENT, - memory: { longTerm: { enabled: true, maxEntries: 20 }, reflectionInterval: 5 }, - }); - // And the rehydrated row is exactly what the write door accepts now. - expect(AgentSchema.safeParse(rehydrated).success).toBe(true); - }); - - it('a persisted artifact is REFUSED at the boot door before the conversion and ACCEPTED after it', () => { - const artifact = () => ({ agents: [persisted('database')] }); - const before = ObjectStackDefinitionSchema.safeParse(artifact()); - expect(before.success).toBe(false); - expect(JSON.stringify(before.error?.issues ?? [])).toContain('`agent.memory.longTerm.store` was removed'); - const after = ObjectStackDefinitionSchema.safeParse(applyConversions(artifact(), { includeRetired: true })); - expect(after.success, JSON.stringify(after.error?.issues ?? [])).toBe(true); - }); - - it('LIT CONTROL — an agent missing a required number is refused on BOTH sides: the conversion supplies no number', () => { - const artifact = () => ({ - agents: [{ ...MEMORY_AGENT, memory: { longTerm: { enabled: true, store: 'vector', maxEntries: 20 } } }], - }); - expect(ObjectStackDefinitionSchema.safeParse(artifact()).success).toBe(false); - const healed = applyConversions(artifact(), { includeRetired: true }); - const after = ObjectStackDefinitionSchema.safeParse(healed); - expect(after.success).toBe(false); - expect(JSON.stringify(after.error?.issues ?? [])).toContain('`agent.memory.reflectionInterval` is required'); - }); - - it('is idempotent by construction, and leaves an agent without the key untouched by reference', () => { - const input = { agents: [persisted('redis'), { ...MEMORY_AGENT, name: 'plain_agent' }] }; - const { stack, notices } = collectConversionNotices(input, { includeRetired: true }); - expect(notices.filter((n) => n.conversionId === CONVERSION_ID)).toHaveLength(1); - expect((stack.agents as unknown[])[1]).toBe(input.agents[1]); - const replay = collectConversionNotices(stack, { includeRetired: true }); - expect(replay.notices).toHaveLength(0); - expect(replay.stack).toBe(stack); - }); - - it('is retired from the load path — a live author is refused at parse, never silently rewritten', () => { - const input = { agents: [persisted('database')] }; - const { stack, notices } = collectConversionNotices(input); - expect(notices.filter((n) => n.conversionId === CONVERSION_ID)).toHaveLength(0); - expect(stack).toEqual(input); - }); - - it('is registered under major 18: the exact nested key, the chain step, and one D3 entry for the contract', () => { - expect(RETIRED_KEYS_BY_MAJOR[18]).toContain('ai/Agent:memory.longTerm.store'); - expect(MIGRATIONS_BY_MAJOR[18]!.conversionIds).toContain(CONVERSION_ID); - const conversion = ALL_CONVERSIONS.find((c) => c.id === CONVERSION_ID); - expect(conversion, 'the D2 conversion must be registered').toBeDefined(); - expect(conversion!.toMajor).toBe(18); - expect(conversion!.retiredFromLoadPath).toBe(true); - expect(conversion!.surface).toBe('agent.memory.longTerm.store'); - const d3 = MIGRATIONS_BY_MAJOR[18]!.semantic.filter((s) => s.id === 'agent-memory-store-retired-and-limits-required'); - expect(d3).toHaveLength(1); - expect(d3[0]!.conversionIds).toEqual([CONVERSION_ID]); - expect(d3[0]!.reason).toContain(`\`${CONVERSION_ID}\``); - }); -}); - -// ─── Tree-scoped absence, with a DECLARED radius ───────────────────────────── -// -// `tsc` is the primary sweeper — `retiredKey()` types `store` `never` on -// `Agent`, so every TYPED authoring site fails to compile. The residue is what -// `tsc` never judges: JSON, YAML, MD/MDX code fences, untyped `.js`, and TS -// literals typed `any` / `unknown`. This walk covers that residue across the -// five repo roots `scripts/cross-package-test-inputs.mjs` already declares for -// `@objectstack/spec#test` (mirrored in `turbo.json`), plus the example apps' -// own `src/` trees, declared there as `examples/*/src/**/*.ts`. -// -// The matcher judges the AUTHORING SHAPE, never a mention: a `longTerm` key -// whose object value holds a `store` key — or one of its old spellings -// `backend` / `storage` / `provider` — before any nested brace (TS / JS / JSON, -// and YAML block and flow form). `store` alone is far too common a word to -// judge. Inline code is prose and is stripped before judging. The bound, -// stated: a block assembled by spread or under computed keys, and `docs/**`, -// `.claude/**`, `.github/**` and the repo-root files are outside what this -// walk sees. -describe('tree-scoped absence: nothing inside the declared radius still authors a long-term memory store', () => { - const SPEC_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); - const REPO_ROOT = path.resolve(SPEC_ROOT, '../..'); - const THIS_FILE = path.relative(REPO_ROOT, fileURLToPath(import.meta.url)).split(path.sep).join('/'); - - /** The walked roots — declared in `scripts/cross-package-test-inputs.mjs` under `@objectstack/spec`. */ - const WALK_ROOTS = ['packages', 'examples', 'skills', 'content', 'scripts']; - const SCANNED_EXT = new Set(['.ts', '.mts', '.cts', '.js', '.mjs', '.cjs', '.json', '.md', '.mdx', '.yaml', '.yml']); - /** Under `examples/` the non-code extensions, plus `.ts` inside an app's own `src/` tree. */ - const EXAMPLES_EXT = new Set(['.json', '.md', '.mdx', '.yaml', '.yml']); - const EXAMPLE_APP_SRC_TS = /^examples\/[^/]+\/src\/.+\.ts$/; - const SKIPPED_DIRS = new Set(['node_modules', 'dist', '.git', '.turbo', '.cache', '.objectstack', 'coverage', '.next', '.source']); - - const STORE_KEYS = '(store|backend|storage|provider)'; - const AUTHORING = [ - // TS / JS / JSON, block or inline: the key, then an object whose own keys - // (before any nested brace) include the store or one of its spellings. - new RegExp(`(^|[^\\w.$])["']?longTerm["']?\\s*:\\s*\\{[^{}]*?(^|[^\\w.$])["']?${STORE_KEYS}["']?\\s*:`, 'm'), - // YAML block form: the key on its own line, the store key indented below it - // among its siblings. - new RegExp(`^[ \\t]*(-[ \\t]+)?longTerm[ \\t]*:[ \\t]*\\r?\\n(?:[ \\t]+[^\\s][^\\n]*\\r?\\n)*?[ \\t]+${STORE_KEYS}[ \\t]*:`, 'm'), - ]; - - /** - * Inline code spans are prose — the house style `check:doc-authoring` enforces - * — so stripping single-backtick spans separates "the retirement kit - * describing what it removed" from "a source still writing it". - * Newline-bounded: a fenced block's content is NOT stripped. - */ - const stripInlineCode = (text: string): string => text.replace(/`[^`\n]*`/g, ''); - const judge = (text: string): RegExpExecArray | null => { - const stripped = stripInlineCode(text); - for (const re of AUTHORING) { - const m = re.exec(stripped); - if (m) return m; - } - return null; - }; - - /** - * Structural exclusions — the retirement kit, each with its reason. ⛔ NOT an - * allowlist file (`spec-property-retirement` §4): every entry's JOB is to - * spell the retired key. - */ - const EXCLUDED = new Set([ - // This pin authors the key to assert its refusal and its conversion. - THIS_FILE, - ]); - const EXCLUDED_PREFIXES = [ - // The D2 conversion's fixture authors the pre-retirement agent on purpose. - 'packages/spec/src/conversions/', - // Release-owned prose records the removal; never edited by a code PR. - 'content/docs/releases/', - // GITIGNORED build output (`packages/spec/json-schema/`), reached only - // because this is a FILESYSTEM walk. Its source is `agent.zod.ts`. - 'packages/spec/json-schema/', - ]; - /** tsup's own bundle of `tsup.config.ts`, written and deleted mid-build. */ - const TSUP_BUNDLED_CONFIG = /\.bundled_[^./]+\.mjs$/; - - /** Tolerates ONLY a path that vanished mid-walk; every other read fault is re-raised. */ - const readIfPresent = (full: string): string | undefined => { - try { - return fs.readFileSync(full, 'utf-8'); - } catch (err) { - if ((err as NodeJS.ErrnoException)?.code !== 'ENOENT') throw err; - return undefined; - } - }; - - it('the matcher recognises an authoring and ignores a prose mention and a neighbour (anti-vacuity)', () => { - // Offenders — the retired shape, in each syntax the walk reads. - expect(judge("defineAgent({ memory: { longTerm: { enabled: true, store: 'vector' } } })")).not.toBeNull(); - expect(judge(" longTerm: {\n enabled: true,\n store: 'database',\n },")).not.toBeNull(); - expect(judge('{ "longTerm": { "enabled": true, "store": "redis" } }')).not.toBeNull(); - expect(judge("longTerm: { backend: 'database' }")).not.toBeNull(); - expect(judge('memory:\n longTerm:\n enabled: true\n storage: vector\n')).not.toBeNull(); - expect(judge('longTerm: { enabled: true, provider: redis }\n')).not.toBeNull(); - expect(judge("Prose.\n\n```ts\ndefineAgent({ memory: { longTerm: { store: 'vector' } } });\n```\n")).not.toBeNull(); - // Neighbours that must NOT match. - expect(judge("the `longTerm: { enabled: true, store: 'vector' }` block loses its store")).toBeNull(); - expect(judge("memory: { longTerm: { enabled: true, maxEntries: 5 }, reflectionInterval: 3 }")).toBeNull(); - expect(judge("store: retiredKey(LONG_TERM_STORE_RETIRED),")).toBeNull(); - expect(judge("'long.store': (memory.longTerm as any)?.store,")).toBeNull(); - expect(judge("const s = { longTerm: 1, nested: { store: 'x' } };")).toBeNull(); - expect(judge('longTerm:\n enabled: true\nstore:\n kind: x\n')).toBeNull(); - }); - - it('no long-term memory store authoring survives inside the declared radius outside the retirement kit', () => { - const offenders: string[] = []; - let visited = 0; - let exampleSources = 0; - const walk = (dir: string) => { - for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { - const full = path.join(dir, entry.name); - const rel = path.relative(REPO_ROOT, full).split(path.sep).join('/'); - if (entry.isDirectory()) { - if (SKIPPED_DIRS.has(entry.name) || entry.name.startsWith('.')) continue; - walk(full); - continue; - } - if (!entry.isFile()) continue; - const ext = path.extname(entry.name); - const scanned = rel.startsWith('examples/') - ? EXAMPLES_EXT.has(ext) || EXAMPLE_APP_SRC_TS.test(rel) - : SCANNED_EXT.has(ext); - if (!scanned) continue; - if (entry.name === 'CHANGELOG.md') continue; // release prose records the removal - if (EXCLUDED.has(rel) || EXCLUDED_PREFIXES.some((p) => rel.startsWith(p))) continue; - if (TSUP_BUNDLED_CONFIG.test(entry.name)) continue; - visited += 1; - if (EXAMPLE_APP_SRC_TS.test(rel)) exampleSources += 1; - const text = readIfPresent(full); - if (text === undefined) continue; - const m = judge(text); - if (m) offenders.push(`${rel} authors \`${m[0].trim().replace(/\s+/g, ' ')}\``); - } - }; - for (const root of WALK_ROOTS) walk(path.join(REPO_ROOT, root)); - // Anti-vacuity: the walk really covered the tree, and the example apps' - // sources were really read. - expect(visited).toBeGreaterThan(1000); - expect(exampleSources).toBeGreaterThan(50); - expect(offenders, 'a long-term memory store authoring means the retirement is being undone').toEqual([]); - }); -}); diff --git a/packages/spec/vitest.repo-tests.json b/packages/spec/vitest.repo-tests.json index 8a11cb0dd7c..40a5d0f92eb 100644 --- a/packages/spec/vitest.repo-tests.json +++ b/packages/spec/vitest.repo-tests.json @@ -23,6 +23,7 @@ "scripts/step18-rationale-merge.test.ts", "scripts/strictness-ledger-doc.test.ts", "scripts/strictness-ledger.test.ts", + "src/ai/agent-memory-store-retirement.test.ts", "src/ai/tool-confirmation-prescription-tense.pin.test.ts", "src/api/error-catalog-docs.test.ts", "src/api/export-job-family-retirement.test.ts", From 388d86d4b57463e0421ae77b56858710c42ea2e3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 11:27:50 +0000 Subject: [PATCH 6/9] wip(spec): fix retirement pin assertions Claude-Session: https://claude.ai/code/session_01YDt3PzwfrkuFzUBF89WPmM Co-authored-by: Claude --- .../src/ai/agent-memory-store-retirement.test.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/spec/src/ai/agent-memory-store-retirement.test.ts b/packages/spec/src/ai/agent-memory-store-retirement.test.ts index c90c4e33d37..aa495bcca4c 100644 --- a/packages/spec/src/ai/agent-memory-store-retirement.test.ts +++ b/packages/spec/src/ai/agent-memory-store-retirement.test.ts @@ -55,9 +55,13 @@ const MEMORY_AGENT = { instructions: 'Remember what the user told you.', } as const; -/** The store tombstone's prescription: key first, why, the fix, the migrate sentence. */ +/** + * The store tombstone's prescription: why, the fix. Unanchored, because a thrown + * `ZodError`'s message is the JSON of its issues; the key-first house convention + * is asserted on the issue message itself below. + */ const STORE_PRESCRIPTION = - /^`agent\.memory\.longTerm\.store` was removed in @objectstack\/spec 17\.7\.0 \(ADR-0049 enforce-or-remove\) — the memory store is platform infrastructure, not agent metadata: .*Delete the key; long-term memory is configured by `enabled`, `maxEntries` and `agent\.memory\.reflectionInterval`/s; + /`agent\.memory\.longTerm\.store` was removed in @objectstack\/spec 17\.7\.0 \(ADR-0049 enforce-or-remove\) — the memory store is platform infrastructure, not agent metadata: .*Delete the key; long-term memory is configured by `enabled`, `maxEntries` and `agent\.memory\.reflectionInterval`/s; /** The one refusal `defineStack` raised, as its ADR-0112 envelope. */ const stackRefusal = (agent: Record) => { @@ -86,6 +90,8 @@ describe('agent memory store retirement — the tombstone, at every door that ca expect(issues[0].code).toBe('invalid_type'); expect(issues[0].path).toEqual(['memory', 'longTerm', 'store']); expect(issues[0].message).toMatch(STORE_PRESCRIPTION); + // House convention 1: the fully-qualified key, in backticks, opens it. + expect(issues[0].message.startsWith('`agent.memory.longTerm.store` was removed')).toBe(true); expect(issues[0].message.endsWith(MIGRATE_SENTENCE)).toBe(true); } }); @@ -168,7 +174,7 @@ describe('agent memory store retirement — the D2 conversion', () => { onNotice: (n) => notices.push(n as { conversionId?: string; path?: string; from?: string; to?: string }), }) as Record; expect(notices.map((n) => [n.conversionId, n.path, n.from, n.to])).toEqual([ - [CONVERSION_ID, 'agents[0](memory_agent).memory.longTerm.store', 'store', '(removed)'], + [CONVERSION_ID, 'agents[0].memory.longTerm.store', 'store', '(removed)'], ]); // CONTROL: every live key on the same row survives byte-for-byte. expect(rehydrated).toEqual({ From 4014c7e3d7ecf22dc09490fbb2e5df9ac1ac5883 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 12:20:24 +0000 Subject: [PATCH 7/9] wip(spec): agent form help texts for memory and planning name only declared keys Claude-Session: https://claude.ai/code/session_01YDt3PzwfrkuFzUBF89WPmM Co-authored-by: Claude --- packages/spec/src/ai/agent.form.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/spec/src/ai/agent.form.ts b/packages/spec/src/ai/agent.form.ts index f37667ab25a..a9eb3a520eb 100644 --- a/packages/spec/src/ai/agent.form.ts +++ b/packages/spec/src/ai/agent.form.ts @@ -39,8 +39,8 @@ export const agentForm = defineForm({ fields: [ { field: 'instructions', required: true, widget: 'textarea', helpText: 'System prompt — tell the agent how to behave and what it can do' }, { field: 'model', type: 'composite', helpText: 'AI model configuration (provider, model name, temperature, etc.)' }, - { field: 'planning', type: 'composite', helpText: 'Autonomous reasoning configuration (strategy, max iterations, replan)' }, - { field: 'memory', type: 'composite', helpText: 'Memory management (short-term, long-term, reflection)' }, + { field: 'planning', type: 'composite', helpText: 'Autonomous reasoning configuration: the maximum number of reasoning iterations before the agent stops (1–100, default 10).' }, + { field: 'memory', type: 'composite', helpText: 'Long-term memory: distilled notes kept per user, recalled before each conversation and written by a reflection every reflectionInterval delivered interactions. When long-term memory is enabled, maxEntries and reflectionInterval are required. Enforced by the cloud AI runtime.' }, { field: 'lifecycle', type: 'composite', helpText: 'State machine defining conversation flow' }, { field: 'structuredOutput', type: 'composite', helpText: "Output contract for the agent's final answer: JSON format, the JSON Schema it is checked against, retries, fallback format and transform steps. Enforced by the cloud AI runtime." }, ], From 8965af164dacfa9b5b9b9a68eabc7f999f8a7b7d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 12:23:34 +0000 Subject: [PATCH 8/9] wip(platform-objects): regenerate and translate the agent memory/planning help texts Claude-Session: https://claude.ai/code/session_01YDt3PzwfrkuFzUBF89WPmM Co-authored-by: Claude --- .../src/apps/translations/en.metadata-forms.generated.ts | 4 ++-- .../src/apps/translations/es-ES.metadata-forms.generated.ts | 4 ++-- .../src/apps/translations/ja-JP.metadata-forms.generated.ts | 4 ++-- .../src/apps/translations/zh-CN.metadata-forms.generated.ts | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts index d41e928ca79..ab5a1768898 100644 --- a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts @@ -2394,11 +2394,11 @@ export const enMetadataForms: NonNullable = { }, planning: { label: "Planning", - helpText: "Autonomous reasoning configuration (strategy, max iterations, replan)" + helpText: "Autonomous reasoning configuration: the maximum number of reasoning iterations before the agent stops (1–100, default 10)." }, memory: { label: "Memory", - helpText: "Memory management (short-term, long-term, reflection)" + helpText: "Long-term memory: distilled notes kept per user, recalled before each conversation and written by a reflection every reflectionInterval delivered interactions. When long-term memory is enabled, maxEntries and reflectionInterval are required. Enforced by the cloud AI runtime." }, lifecycle: { label: "Lifecycle", diff --git a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts index 09004e7bc6a..ab3c1896e93 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts @@ -2394,11 +2394,11 @@ export const esESMetadataForms: NonNullable = }, planning: { label: "Planificación", - helpText: "Configuración de razonamiento autónomo (strategy, max iterations, replan)" + helpText: "Configuración de razonamiento autónomo: el número máximo de iteraciones de razonamiento antes de que el agente se detenga (1–100, 10 por defecto)." }, memory: { label: "Memoria", - helpText: "Gestión de memoria (short-term, long-term, reflection)" + helpText: "Memoria a largo plazo: notas destiladas que se guardan por usuario, se recuperan antes de cada conversación y las escribe una reflexión cada reflectionInterval interacciones entregadas. Cuando la memoria a largo plazo está habilitada, maxEntries y reflectionInterval son obligatorios. Lo aplica el runtime de IA en la nube." }, lifecycle: { label: "Ciclo de vida", diff --git a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts index 88fb39707e6..6105de7f91a 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts @@ -2394,11 +2394,11 @@ export const jaJPMetadataForms: NonNullable = }, planning: { label: "計画", - helpText: "自律推論設定(strategy, max iterations, replan)" + helpText: "自律推論設定: エージェントが停止するまでの最大推論反復回数(1〜100、既定値 10)。" }, memory: { label: "メモリ", - helpText: "メモリ管理(short-term, long-term, reflection)" + helpText: "長期メモリ: ユーザーごとに保持される要約ノート。各会話の前に呼び出され、配信済みのやり取り reflectionInterval 回ごとに 1 回のリフレクションで書き込まれます。長期メモリを有効にする場合、maxEntries と reflectionInterval は必須です。クラウド AI ランタイムが適用します。" }, lifecycle: { label: "ライフサイクル", diff --git a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts index 82223c0f1d5..f038cc2b5c7 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts @@ -2394,11 +2394,11 @@ export const zhCNMetadataForms: NonNullable = }, planning: { label: "规划", - helpText: "自主推理配置(策略、最大迭代、是否重规划)" + helpText: "自主推理配置:代理停止前的最大推理迭代次数(1–100,默认 10)。" }, memory: { label: "记忆", - helpText: "记忆管理(短期、长期、反思)" + helpText: "长期记忆:按用户保存的提炼笔记,在每次会话前召回,并每隔 reflectionInterval 次已送达的交互由一次反思写入。启用长期记忆时,maxEntries 与 reflectionInterval 为必填。由云端 AI 运行时强制执行。" }, lifecycle: { label: "生命周期", From 39412feb9e0ee9e899b1c6b27ae2c82b2ff58674 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 12:45:25 +0000 Subject: [PATCH 9/9] wip: changeset covers the platform-objects help-text leaves Claude-Session: https://claude.ai/code/session_01YDt3PzwfrkuFzUBF89WPmM Co-authored-by: Claude --- .changeset/20274-agent-memory-contract.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.changeset/20274-agent-memory-contract.md b/.changeset/20274-agent-memory-contract.md index 19917562ad3..0806f914138 100644 --- a/.changeset/20274-agent-memory-contract.md +++ b/.changeset/20274-agent-memory-contract.md @@ -1,5 +1,6 @@ --- '@objectstack/spec': minor +'@objectstack/platform-objects': patch --- feat(spec)!: an agent's `memory` contract states exactly what the runtime honours — `maxEntries` and `reflectionInterval` are required once long-term memory is enabled, `longTerm.store` is retired, and the block is `live`, enforced by the cloud AI runtime (#20274) @@ -79,6 +80,16 @@ Each refusal is a parse error at the key's own path, naming the key and the fix, honours `database` only and refuses `vector` and `redis`. Cloud drops `store` in that one reader once this release reaches its pin, and no earlier. +### The agent form's help texts + +- The `memory` row's help text on the agent metadata form named short-term memory, + a key the schema refuses. It now states what memory does and that `maxEntries` + and `reflectionInterval` are required once long-term memory is enabled. +- The neighbouring `planning` row named a strategy and a replan switch the schema + does not declare; it now states the one key it has, the iteration cap. +- The `platform-objects` metadata-form catalogs follow: the English leaves are + regenerated, and the `zh-CN`, `ja-JP` and `es-ES` leaves are authored, not copied. + ⚠️ **The out-of-repo consumer population is NOT MEASURED.** `@objectstack/spec` is published, and tenant-authored agents were not measured. This repo authors no `longTerm` outside `packages/spec`, and no cloud built-in agent declares one.