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
22 changes: 22 additions & 0 deletions .changeset/honest-llamas-dress.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
'@mastra/memory': minor
'@mastra/core': minor
---

add OM-managed working memory

Adds `observationalMemory.observation.manageWorkingMemory` so the Observer can update working memory automatically instead of requiring the main agent to call the working memory tool.

```ts
new Memory({
options: {
workingMemory: { enabled: true },
observationalMemory: {
enabled: true,
observation: { manageWorkingMemory: true },
},
},
})
```

This option adds `WorkingMemoryExtractor`, defaults `workingMemory.agentManaged` to `false`, and defaults `workingMemory.useStateSignals` to `true` when working memory is enabled. Set `workingMemory.agentManaged: true` to keep the main agent's working memory tool and instructions enabled.
26 changes: 26 additions & 0 deletions docs/src/content/en/docs/memory/observational-memory.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,32 @@ new Extractor({
})
```

### Working memory updates

Use `observationalMemory.observation.manageWorkingMemory` to let the Observer manage working memory automatically. The main agent no longer needs to call the working memory tool while it handles the user request, so working memory updates don't depend on the agent remembering to make them.

This also keeps working memory prompt-cache friendly. Working memory normally lives in the system prompt, so updates can invalidate the prompt cache. OM-managed working memory defaults `workingMemory.useStateSignals` to `true`, which moves working memory into state signals instead.

```typescript title="src/mastra/agents/agent.ts"
import { Memory } from '@mastra/memory'

const memory = new Memory({
options: {
workingMemory: {
enabled: true,
},
observationalMemory: {
enabled: true,
observation: {
manageWorkingMemory: true,
},
},
},
})
```

This setting adds `WorkingMemoryExtractor`, defaults `workingMemory.agentManaged` to `false`, and defaults `workingMemory.useStateSignals` to `true`. Set `workingMemory.agentManaged: true` if the main agent should still receive working memory tool and instruction injection.
Comment thread
TylerBarnes marked this conversation as resolved.

Use `onExtracted` to normalize or react to custom extracted values before they are persisted:

```typescript title="src/mastra/agents/agent.ts"
Expand Down
2 changes: 2 additions & 0 deletions docs/src/content/en/docs/memory/working-memory.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ Think of it as the agent's active thoughts or scratchpad – the key information

This is useful for maintaining ongoing state that's always relevant and should always be available to the agent.

If you use [Observational Memory](/docs/memory/observational-memory), `observationalMemory.observation.manageWorkingMemory` lets OM update working memory for the agent.

Working memory can persist at two different scopes:

- **Resource-scoped** (default): Memory persists across all conversation threads for the same user
Expand Down
26 changes: 26 additions & 0 deletions docs/src/content/en/reference/memory/observational-memory.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -448,10 +448,36 @@ const memory = new Memory({
- Schema-backed extractors add a follow-up structured output request.
- Schema-less extractors are inline string extractors emitted directly in the Observer or Reflector output.
- Dynamic extractor functions receive runtime context, including `source`, `threadId`, `resourceId`, `mainAgent`, `memory`, and `requestContext` when available.
- `WorkingMemoryExtractor` uses the normal extractor pipeline to update working memory through the active `Memory` instance. It uses structured extraction when working memory has a JSON schema and skips OM metadata persistence, so the working memory payload isn't duplicated under OM extracted metadata.
- `observationalMemory.observation.manageWorkingMemory` adds `WorkingMemoryExtractor`, defaults `workingMemory.agentManaged` to `false`, and defaults `workingMemory.useStateSignals` to `true` when working memory is enabled.
- Extraction failures are reported in OM marker data and do not discard other successful extracted values.

## Examples

### Working memory updates

Use `observationalMemory.observation.manageWorkingMemory` when OM should update working memory.

```typescript title="src/mastra/agents/agent.ts"
import { Memory } from '@mastra/memory'

const memory = new Memory({
options: {
workingMemory: {
enabled: true,
},
observationalMemory: {
enabled: true,
observation: {
manageWorkingMemory: true,
},
},
},
})
```

Set `workingMemory.agentManaged: true` if the main agent should still receive working memory tool and instruction injection.

### Resource scope with custom thresholds (experimental)

```typescript title="src/mastra/agents/agent.ts"
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/memory/mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,7 @@ export class MockMemory extends MastraMemory {

public listTools(_config?: MemoryConfigInternal): Record<string, ToolAction<any, any, any>> {
const mergedConfig = this.getMergedThreadConfig(_config);
if (!mergedConfig.workingMemory?.enabled) {
if (!mergedConfig.workingMemory?.enabled || mergedConfig.workingMemory.agentManaged === false) {
return {};
}

Expand Down
21 changes: 21 additions & 0 deletions packages/core/src/memory/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,14 @@ type BaseWorkingMemory = {
* @see docs/src/content/en/docs/agents/signals.mdx
*/
useStateSignals?: boolean;
/**
* Whether the main agent manages working memory directly through tool/instruction injection.
* Set to false when another path, such as Observational Memory extractors,
* owns working memory updates.
*
* @default true
*/
agentManaged?: boolean;
/** @deprecated The `use` option has been removed. Working memory always uses tool-call mode. */
use?: never;
};
Expand Down Expand Up @@ -436,6 +444,16 @@ export interface ObservationalMemoryObservationConfig {
*/
model?: AgentConfig['model'];

/**
* Manage working memory through Observational Memory extraction.
* When enabled alongside `workingMemory.enabled`, Memory supplies defaults that
* disable main-agent working memory management and add the WorkingMemoryExtractor.
* Set `workingMemory.agentManaged: true` to keep main-agent tools/instructions enabled.
*
* @default false
*/
manageWorkingMemory?: boolean;

/**
* Token count of unobserved messages that triggers observation.
* When unobserved message tokens exceed this, the Observer is called.
Expand Down Expand Up @@ -1321,6 +1339,9 @@ export type SerializedObservationalMemoryConfig = {
export type SerializedObservationalMemoryObservationConfig = {
/** Observer model ID */
model?: string;
/** Manage working memory through Observational Memory extraction. */
manageWorkingMemory?: boolean;

/** Token count threshold that triggers observation */
messageTokens?: number;
/** Model settings (temperature, maxOutputTokens, etc.) */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@ import { MockLanguageModelV2, convertArrayToReadableStream } from '@internal/ai-
import type { MastraDBMessage } from '@mastra/core/agent';
import { getThreadOMMetadata, setThreadOMMetadata } from '@mastra/core/memory';
import { LibSQLStore } from '@mastra/libsql';
import { Extractor, Memory } from '@mastra/memory';
import { Extractor, Memory, WorkingMemoryExtractor } from '@mastra/memory';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { z } from 'zod';

const createMessage = (
threadId: string,
Expand Down Expand Up @@ -109,6 +110,157 @@ describe('Observational Memory extracted metadata persistence', () => {
});
});

it('updates markdown working memory from an end-to-end LibSQL observation run', async () => {
const threadId = randomUUID();
const resourceId = randomUUID();
const observerOutput = `<observations>
- User shared durable profile details.
</observations>
<working-memory># User Profile
- Name: Tyler
- Location: Seattle
</working-memory>`;
const model = new MockLanguageModelV2({
doStream: async () => ({
stream: convertArrayToReadableStream([
{ type: 'stream-start', warnings: [] },
{ type: 'response-metadata', id: 'obs-wm-1', modelId: 'mock-observer', timestamp: new Date() },
{ type: 'text-start', id: 'text-wm-1' },
{ type: 'text-delta', id: 'text-wm-1', delta: observerOutput },
{ type: 'text-end', id: 'text-wm-1' },
{ type: 'finish', finishReason: 'stop', usage: { inputTokens: 100, outputTokens: 20, totalTokens: 120 } },
]),
rawCall: { rawPrompt: null, rawSettings: {} },
warnings: [],
}),
});

const workingMemory = new Memory({
storage: memory.storage,
options: {
workingMemory: {
enabled: true,
template: '# User Profile\n- Name:\n- Location:',
agentManaged: false,
},
observationalMemory: {
enabled: true,
observation: {
model,
messageTokens: 1,
bufferTokens: false,
previousObserverTokens: 1000,
extract: [new WorkingMemoryExtractor()],
},
},
},
});

await workingMemory.createThread({ threadId, resourceId, title: 'Working Memory Markdown' });
await workingMemory.saveMessages({
messages: [
createMessage(
threadId,
resourceId,
'user',
'My name is Tyler and I live in Seattle.',
'2026-06-24T18:00:00.000Z',
),
],
});

const omEngine = await workingMemory.omEngine;
const result = await omEngine!.observe({ threadId, resourceId });

expect(result.observed).toBe(true);
await expect(workingMemory.getWorkingMemory({ threadId, resourceId })).resolves.toContain('Name: Tyler');
await expect(workingMemory.getWorkingMemory({ threadId, resourceId })).resolves.toContain('Location: Seattle');
});

it('replaces schema-backed working memory from an end-to-end LibSQL observation run', async () => {
const threadId = randomUUID();
const resourceId = randomUUID();
const observerOutput = `<observations>
- User shared durable schema-backed profile details.
</observations>`;
const model = new MockLanguageModelV2({
doStream: async () => ({
stream: convertArrayToReadableStream([
{ type: 'stream-start', warnings: [] },
{ type: 'response-metadata', id: 'obs-wm-2', modelId: 'mock-observer', timestamp: new Date() },
{ type: 'text-start', id: 'text-wm-2' },
{ type: 'text-delta', id: 'text-wm-2', delta: observerOutput },
{ type: 'text-end', id: 'text-wm-2' },
{ type: 'finish', finishReason: 'stop', usage: { inputTokens: 100, outputTokens: 20, totalTokens: 120 } },
]),
rawCall: { rawPrompt: null, rawSettings: {} },
warnings: [],
}),
doGenerate: async () => ({
rawCall: { rawPrompt: null, rawSettings: {} },
finishReason: 'stop',
usage: { inputTokens: 80, outputTokens: 10, totalTokens: 90 },
warnings: [],
content: [
{
type: 'text',
text: JSON.stringify({ 'working-memory': { profile: { location: 'Seattle' }, preferences: ['weather'] } }),
},
],
}),
});

const workingMemory = new Memory({
storage: memory.storage,
options: {
workingMemory: {
enabled: true,
schema: z.object({
profile: z.object({ name: z.string().optional(), location: z.string().optional() }).optional(),
preferences: z.array(z.string()).optional(),
}),
agentManaged: false,
},
observationalMemory: {
enabled: true,
observation: {
model,
messageTokens: 1,
bufferTokens: false,
previousObserverTokens: 1000,
extract: [new WorkingMemoryExtractor()],
},
},
},
});

await workingMemory.createThread({ threadId, resourceId, title: 'Working Memory Schema' });
await workingMemory.updateWorkingMemory({
threadId,
resourceId,
workingMemory: JSON.stringify({ profile: { name: 'Tyler' } }),
});
await workingMemory.saveMessages({
messages: [
createMessage(
threadId,
resourceId,
'user',
'I live in Seattle and like weather updates.',
'2026-06-24T18:05:00.000Z',
),
],
});

const omEngine = await workingMemory.omEngine;
const result = await omEngine!.observe({ threadId, resourceId });

expect(result.observed).toBe(true);
await expect(workingMemory.getWorkingMemory({ threadId, resourceId })).resolves.toBe(
JSON.stringify({ profile: { location: 'Seattle' }, preferences: ['weather'] }),
);
});

it('persists extracted values from an end-to-end LibSQL observation run', async () => {
const threadId = randomUUID();
const resourceId = randomUUID();
Expand Down
Loading
Loading