Skip to content

Commit 9260e01

Browse files
TylerBarnesMastra Code (openai/gpt-5.5)
andauthored
fix(memory): add per-phase early activation overrides (#16367)
Co-authored-by: Mastra Code (openai/gpt-5.5) <noreply@mastra.ai>
1 parent f0a28e7 commit 9260e01

8 files changed

Lines changed: 411 additions & 38 deletions

File tree

.changeset/lovely-shrimps-call.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@mastra/core': patch
3+
'@mastra/memory': patch
4+
---
5+
6+
Default top-level observational memory early activation settings to observations only, while allowing per-phase overrides under `observation` and `reflection`.

docs/src/content/en/docs/memory/observational-memory.mdx

Lines changed: 49 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,48 @@ The observer also sees these markers when it processes the thread, so the observ
9393

9494
See [the API reference](/reference/memory/observational-memory#configuration) for the full configuration shape.
9595

96+
## Early activation
97+
98+
OM can activate buffered observations before the token threshold is reached. This is useful when a prompt cache is likely to expire, or when the agent changes model providers.
99+
100+
Top-level early activation settings apply to observations by default:
101+
102+
```typescript title="src/mastra/agents/agent.ts"
103+
const memory = new Memory({
104+
options: {
105+
observationalMemory: {
106+
model: 'google/gemini-2.5-flash',
107+
activateAfterIdle: '5m',
108+
activateOnProviderChange: true,
109+
},
110+
},
111+
})
112+
```
113+
114+
Use nested `observation` and `reflection` settings for per-phase control. Reflection early activation is opt-in, so top-level settings affect only observations.
115+
116+
```typescript title="src/mastra/agents/agent.ts"
117+
const memory = new Memory({
118+
options: {
119+
observationalMemory: {
120+
model: 'google/gemini-2.5-flash',
121+
activateAfterIdle: '5m',
122+
observation: {
123+
activateAfterIdle: false,
124+
},
125+
reflection: {
126+
activateAfterIdle: '10m',
127+
activateOnProviderChange: true,
128+
},
129+
},
130+
},
131+
})
132+
```
133+
134+
In this example, the top-level idle setting is disabled for observations, while reflections opt into idle and provider-change activation.
135+
136+
See [the API reference](/reference/memory/observational-memory#configuration) for the full configuration shape.
137+
96138
## Benefits
97139

98140
- **Prompt caching**: OM's context is stable and observations append over time rather than being dynamically retrieved each turn. This keeps the prompt prefix cacheable, which reduces costs.
@@ -397,12 +439,14 @@ Reflection works similarly — the Reflector runs in the background when observa
397439
| `observation.bufferTokens` | `0.2` | How often to buffer. `0.2` means every 20% of `messageTokens` — with the default 30k threshold, that's roughly every 6k tokens. Can also be an absolute token count (e.g. `5000`). |
398440
| `observation.bufferActivation` | `0.8` | How aggressively to clear the message window on activation. `0.8` means remove enough messages to keep only 20% of `messageTokens` remaining. Lower values keep more message history. |
399441
| `observation.blockAfter` | `1.2` | Safety threshold as a multiplier of `messageTokens`. At `1.2`, synchronous observation is forced at 36k tokens (1.2 × 30k). Only matters if buffering can't keep up. |
400-
| `activateAfterIdle` | none | Forces buffered observations and buffered reflections to activate after a period of inactivity, even if their token thresholds have not been reached yet. Accepts milliseconds or duration strings like `300_000`, `"5m"`, or `"1hr"`. Set this to your prompt cache TTL if you want activation to happen before the next cold prompt. |
401-
| `activateOnProviderChange` | `false` | Forces buffered observations and reflections to activate when the next step uses a different `provider/model` than the one that produced the latest assistant step. Use this when switching providers or models would invalidate prompt cache reuse. |
442+
| `activateAfterIdle` | none | Forces buffered observations to activate after a period of inactivity, even before `observation.messageTokens` is reached. Accepts a numeric millisecond value such as `300_000`, or duration strings like `"5m"` or `"1hr"`. Set this to your prompt cache TTL if you want activation to happen before the next cold prompt. |
443+
| `activateOnProviderChange` | `false` | Forces buffered observations to activate when the next step uses a different `provider/model` than the one that produced the latest assistant step. Use this when switching providers or models would invalidate prompt cache reuse. |
402444
| `reflection.bufferActivation` | `0.5` | When to start background reflection. `0.5` means reflection begins when observations reach 50% of the `observationTokens` threshold. |
445+
| `reflection.activateAfterIdle` | none | Opts buffered reflections into idle activation. Reflections don't inherit top-level `activateAfterIdle`. |
446+
| `reflection.activateOnProviderChange` | `false` | Opts buffered reflections into provider-change activation. Reflections don't inherit top-level `activateOnProviderChange`. |
403447
| `reflection.blockAfter` | `1.2` | Safety threshold for reflection, same logic as observation. |
404448

405-
If you're relying on prompt caching, set `activateAfterIdle` to match your cache TTL. That way, once a thread has been idle long enough for the cache to expire, the next request can activate buffered observations or reflections first and send a smaller compressed context window.
449+
If you're relying on prompt caching, set `activateAfterIdle` to match your cache TTL. That way, once a thread has been idle long enough for the cache to expire, the next request can activate buffered observations first and send a smaller compressed context window.
406450

407451
```typescript
408452
const memory = new Memory({
@@ -416,9 +460,9 @@ const memory = new Memory({
416460
})
417461
```
418462

419-
With a 5-minute prompt cache TTL, this activates buffered context after 5 minutes of inactivity so the next uncached prompt uses observations and reflections instead of a larger raw message window. If you prefer, `300_000` works the same way.
463+
With a 5-minute prompt cache TTL, this activates buffered observations after 5 minutes of inactivity so the next uncached prompt uses compressed observations instead of a larger raw message window. If you prefer, `300_000` works the same way.
420464

421-
Changing model or providers mid-thread will invalidate the prompt cache. If your agent can switch between providers or models mid-thread, `activateOnProviderChange: true` forces buffered context to activate before the new provider runs. That avoids sending a large raw window to a provider that cannot reuse the previous prompt cache.
465+
Changing model or providers mid-thread will invalidate the prompt cache. If your agent can switch between providers or models mid-thread, `activateOnProviderChange: true` forces buffered observations to activate before the new provider runs. That avoids sending a large raw window to a provider that can't reuse the previous prompt cache.
422466

423467
### Disabling
424468

docs/src/content/en/reference/memory/observational-memory.mdx

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,11 +66,19 @@ OM performs thresholding with fast local token estimation. Text uses `tokenx`, a
6666
},
6767
{
6868
name: 'activateAfterIdle',
69-
type: 'number | string',
69+
type: 'number | string | false',
7070
description:
71-
'Time before buffered observations or buffered reflections are forced to activate after inactivity, even if their token thresholds have not been reached yet. Accepts milliseconds or duration strings like `300_000`, `"5m"`, or `"1hr"`. When the gap between the current time and the last assistant message part timestamp exceeds this value, buffered observational memory activates before the next prompt. Useful for aligning with prompt cache TTLs.',
71+
'Time before buffered observations are forced to activate after inactivity, even before `observation.messageTokens` is reached. Accepts a numeric millisecond value such as `300_000`, duration strings like `"5m"` or `"1hr"`, or `false` to disable inherited observation idle activation. Reflections do not inherit this setting. Use `reflection.activateAfterIdle` to opt reflections into idle activation.',
7272
isOptional: true,
7373
},
74+
{
75+
name: 'activateOnProviderChange',
76+
type: 'boolean',
77+
description:
78+
'Force buffered observations to activate when the actor provider or model changes. Reflections do not inherit this setting. Use `reflection.activateOnProviderChange` to opt reflections into provider-change activation.',
79+
isOptional: true,
80+
defaultValue: 'false',
81+
},
7482
{
7583
name: 'shareTokenBudget',
7684
type: 'boolean',
@@ -186,6 +194,20 @@ OM performs thresholding with fast local token estimation. Text uses `tokenx`, a
186194
isOptional: true,
187195
defaultValue: '0.8',
188196
},
197+
{
198+
name: 'activateAfterIdle',
199+
type: 'number | string | false',
200+
description:
201+
'Time before buffered observations are forced to activate after inactivity. Accepts milliseconds, a duration string, or `false`. If unset, the top-level `activateAfterIdle` value is used for observations. Set `false` to disable the top-level idle setting for observations.',
202+
isOptional: true,
203+
},
204+
{
205+
name: 'activateOnProviderChange',
206+
type: 'boolean',
207+
description:
208+
'Force buffered observations to activate when the actor provider or model changes. If unset, the top-level `activateOnProviderChange` value is used for observations.',
209+
isOptional: true,
210+
},
189211
{
190212
name: 'blockAfter',
191213
type: 'number',
@@ -273,6 +295,21 @@ OM performs thresholding with fast local token estimation. Text uses `tokenx`, a
273295
isOptional: true,
274296
defaultValue: '0.5',
275297
},
298+
{
299+
name: 'activateAfterIdle',
300+
type: 'number | string | false',
301+
description:
302+
'Time before buffered reflections are forced to activate after inactivity. Accepts milliseconds, a duration string, or `false`. Reflections do not inherit top-level `activateAfterIdle`; set this explicitly to opt reflections into idle activation.',
303+
isOptional: true,
304+
},
305+
{
306+
name: 'activateOnProviderChange',
307+
type: 'boolean',
308+
description:
309+
'Force buffered reflections to activate when the actor provider or model changes. Reflections do not inherit top-level `activateOnProviderChange`; set this explicitly to opt reflections into provider-change activation.',
310+
isOptional: true,
311+
defaultValue: 'false',
312+
},
276313
{
277314
name: 'blockAfter',
278315
type: 'number',

packages/core/src/memory/types.ts

Lines changed: 51 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -397,6 +397,8 @@ export type SemanticRecall = {
397397
*/
398398
export type ObservationalMemoryModelSettings = AgentExecutionOptions['modelSettings'];
399399

400+
export type ObservationalMemoryActivationTTL = number | string | false;
401+
400402
/**
401403
* Configuration for the observation step in Observational Memory.
402404
*/
@@ -506,6 +508,20 @@ export interface ObservationalMemoryObservationConfig {
506508
*/
507509
bufferActivation?: number;
508510

511+
/**
512+
* Time before buffered observations are force-activated after inactivity.
513+
* Accepts milliseconds as a number, a duration string like `"5m"` or `"1hr"`,
514+
* or `false` to disable top-level `activateAfterIdle` for observations.
515+
* If unset, top-level `activateAfterIdle` is used for observations.
516+
*/
517+
activateAfterIdle?: ObservationalMemoryActivationTTL;
518+
519+
/**
520+
* Force-activate buffered observations when the actor provider/model changes.
521+
* If unset, top-level `activateOnProviderChange` is used for observations.
522+
*/
523+
activateOnProviderChange?: boolean;
524+
509525
/**
510526
* Token threshold above which synchronous (blocking) observation is forced.
511527
* When set, the system will never block for observation between `messageTokens`
@@ -640,6 +656,20 @@ export interface ObservationalMemoryReflectionConfig {
640656
*/
641657
blockAfter?: number;
642658

659+
/**
660+
* Time before buffered reflections are force-activated after inactivity.
661+
* Accepts milliseconds as a number, a duration string like `"5m"` or `"1hr"`,
662+
* or `false` to disable idle activation for reflections.
663+
* Reflections do not inherit top-level `activateAfterIdle`; set this explicitly to enable.
664+
*/
665+
activateAfterIdle?: ObservationalMemoryActivationTTL;
666+
667+
/**
668+
* Force-activate buffered reflections when the actor provider/model changes.
669+
* Reflections do not inherit top-level `activateOnProviderChange`; set this explicitly to enable.
670+
*/
671+
activateOnProviderChange?: boolean;
672+
643673
/**
644674
* Ratio (0-1) controlling when async reflection buffering starts.
645675
* When observation tokens reach `observationTokens * bufferActivation`,
@@ -743,21 +773,27 @@ export interface ObservationalMemoryOptions {
743773
scope?: 'resource' | 'thread';
744774

745775
/**
746-
* Time before buffered observations or buffered reflections are force-activated after inactivity.
776+
* Time before buffered observations are force-activated after inactivity.
747777
* Accepts milliseconds as a number or a duration string like `"5m"` or `"1hr"`.
748778
* When the gap between the current time and the last assistant message part's `createdAt`
749-
* exceeds this value, buffered observational memory activates regardless of whether the
779+
* exceeds this value, buffered observations activate regardless of whether the
750780
* token threshold has been reached. Useful to align with prompt cache TTLs.
751781
*
782+
* Reflections do not inherit this setting. Use `reflection.activateAfterIdle` to
783+
* opt reflections into idle activation.
784+
*
752785
* @example 300_000
753786
* @example "5m"
754787
* @example "1hr"
755788
*/
756-
activateAfterIdle?: number | string;
789+
activateAfterIdle?: ObservationalMemoryActivationTTL;
757790

758791
/**
759-
* Force-activate buffered observations and reflections when the actor provider/model changes.
792+
* Force-activate buffered observations when the actor provider/model changes.
760793
* Useful when switching between models that do not share prompt caches.
794+
*
795+
* Reflections do not inherit this setting. Use `reflection.activateOnProviderChange`
796+
* to opt reflections into provider-change activation.
761797
*/
762798
activateOnProviderChange?: boolean;
763799

@@ -1223,10 +1259,10 @@ export type SerializedObservationalMemoryConfig = {
12231259
/** Memory scope: 'resource' or 'thread' */
12241260
scope?: 'resource' | 'thread';
12251261

1226-
/** Inactivity TTL before forcing buffered observation/reflection activation */
1227-
activateAfterIdle?: number | string;
1262+
/** Inactivity TTL before forcing buffered observation activation */
1263+
activateAfterIdle?: ObservationalMemoryActivationTTL;
12281264

1229-
/** Force-activate buffered observation/reflection activation when the actor model changes */
1265+
/** Force-activate buffered observation activation when the actor model changes */
12301266
activateOnProviderChange?: boolean;
12311267

12321268
/** Share the token budget between messages and observations */
@@ -1264,6 +1300,10 @@ export type SerializedObservationalMemoryObservationConfig = {
12641300
bufferTokens?: number | false;
12651301
/** Ratio of buffered observations to activate */
12661302
bufferActivation?: number;
1303+
/** Inactivity TTL before forcing buffered observation activation */
1304+
activateAfterIdle?: ObservationalMemoryActivationTTL;
1305+
/** Force-activate buffered observation activation when the actor model changes */
1306+
activateOnProviderChange?: boolean;
12671307
/** Token threshold for synchronous blocking */
12681308
blockAfter?: number;
12691309
/** Optional token budget for observer context (0 = full truncation, false = disabled) */
@@ -1284,6 +1324,10 @@ export type SerializedObservationalMemoryReflectionConfig = {
12841324
providerOptions?: Record<string, Record<string, unknown> | undefined>;
12851325
/** Token threshold for synchronous blocking */
12861326
blockAfter?: number;
1327+
/** Inactivity TTL before forcing buffered reflection activation */
1328+
activateAfterIdle?: ObservationalMemoryActivationTTL;
1329+
/** Force-activate buffered reflection activation when the actor model changes */
1330+
activateOnProviderChange?: boolean;
12871331
/** Ratio for async reflection buffering */
12881332
bufferActivation?: number;
12891333
};

packages/memory/src/processors/observational-memory/__tests__/observational-memory-api.test.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -723,6 +723,81 @@ describe('activate()', () => {
723723
}
724724
});
725725

726+
it('does not activate buffered observations when observation.activateAfterIdle disables the top-level ttl', async () => {
727+
vi.useFakeTimers();
728+
try {
729+
const now = new Date('2026-04-14T12:00:00.000Z');
730+
vi.setSystemTime(now);
731+
732+
const om = new ObservationalMemory({
733+
storage,
734+
scope: 'thread',
735+
activateAfterIdle: 300_000,
736+
observation: {
737+
model: createMockObserverModel(),
738+
messageTokens: 50_000,
739+
bufferTokens: 5_000,
740+
activateAfterIdle: false,
741+
},
742+
reflection: {
743+
model: createMockReflectorModel(),
744+
observationTokens: 50_000,
745+
},
746+
});
747+
const staleAssistantPartTime = now.getTime() - 301_000;
748+
const messages: MastraDBMessage[] = [
749+
{
750+
...createTestMessage(
751+
'Earlier question',
752+
'user',
753+
'ttl-disabled-user-1',
754+
new Date(staleAssistantPartTime - 1000),
755+
),
756+
threadId,
757+
},
758+
{
759+
...createTestMessage(
760+
'Earlier answer',
761+
'assistant',
762+
'ttl-disabled-assistant-1',
763+
new Date(staleAssistantPartTime),
764+
),
765+
threadId,
766+
content: {
767+
format: 2,
768+
parts: [{ type: 'text', text: 'Earlier answer', createdAt: staleAssistantPartTime }],
769+
} as MastraMessageContentV2,
770+
},
771+
{
772+
...createTestMessage('Latest user follow-up', 'user', 'ttl-disabled-user-2', now),
773+
threadId,
774+
},
775+
];
776+
777+
await storage.saveMessages({ messages });
778+
await om.buffer({ threadId, messages });
779+
780+
const { record } = await om.getStatus({ threadId, messages });
781+
await storage.updateBufferedObservations({
782+
id: record!.id,
783+
chunk: {
784+
observations: '- Buffered observation',
785+
tokenCount: 80,
786+
messageIds: ['ttl-disabled-user-1', 'ttl-disabled-assistant-1'],
787+
cycleId: 'ttl-disabled-cycle-1',
788+
messageTokens: 200,
789+
lastObservedAt: new Date(staleAssistantPartTime),
790+
},
791+
});
792+
793+
const result = await om.activate({ threadId, checkThreshold: true, messages });
794+
795+
expect(result.activated).toBe(false);
796+
} finally {
797+
vi.useRealTimers();
798+
}
799+
});
800+
726801
it('does not activate when ttl has not expired and pending tokens stay below threshold', async () => {
727802
vi.useFakeTimers();
728803
try {

0 commit comments

Comments
 (0)