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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 99 additions & 0 deletions .changeset/20274-agent-memory-contract.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
---
'@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)

**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 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.

Clause-②: yes (narrowing)

<!-- adr-0087: registered agent-memory-long-term-store-removed, agent-memory-store-retired-and-limits-required -->
6 changes: 3 additions & 3 deletions content/docs/references/ai/agent.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, any>; 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. |
Expand Down Expand Up @@ -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`

Expand Down
23 changes: 12 additions & 11 deletions packages/lint/src/lint-liveness-properties.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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'");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2394,11 +2394,11 @@ export const enMetadataForms: NonNullable<TranslationData['metadataForms']> = {
},
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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2394,11 +2394,11 @@ export const esESMetadataForms: NonNullable<TranslationData['metadataForms']> =
},
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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2394,11 +2394,11 @@ export const jaJPMetadataForms: NonNullable<TranslationData['metadataForms']> =
},
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: "ライフサイクル",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2394,11 +2394,11 @@ export const zhCNMetadataForms: NonNullable<TranslationData['metadataForms']> =
},
planning: {
label: "规划",
helpText: "自主推理配置(策略、最大迭代、是否重规划)"
helpText: "自主推理配置:代理停止前的最大推理迭代次数(1–100,默认 10)。"
},
memory: {
label: "记忆",
helpText: "记忆管理(短期、长期、反思)"
helpText: "长期记忆:按用户保存的提炼笔记,在每次会话前召回,并每隔 reflectionInterval 次已送达的交互由一次反思写入。启用长期记忆时,maxEntries 与 reflectionInterval 为必填。由云端 AI 运行时强制执行。"
},
lifecycle: {
label: "生命周期",
Expand Down
7 changes: 6 additions & 1 deletion packages/spec/dropped-refinements.baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
]
},
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
Loading
Loading