Skip to content

Commit 8bde92a

Browse files
committed
docs(spec): memoryProvider auto-wire on direct agent calls (0.2.0)
1 parent d568b62 commit 8bde92a

1 file changed

Lines changed: 366 additions & 0 deletions

File tree

Lines changed: 366 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,366 @@
1+
---
2+
title: Memory Provider Auto-Wire on Direct Calls — Design Spec
3+
date: 2026-04-22
4+
status: draft
5+
owner: agentos
6+
audience: agentos open-source users + wilds-ai
7+
---
8+
9+
# Memory Provider Auto-Wire on Direct `agent.stream()` / `agent.generate()`
10+
11+
## Problem
12+
13+
`agent({ memoryProvider })` stores the provider on the agent config but only invokes it on `agent.session().send()` and `agent.session().stream()`. Direct `agent.stream()` and `agent.generate()` silently ignore `memoryProvider`.
14+
15+
This is a public-API footgun. External agentos users who:
16+
17+
1. Pass `memoryProvider` on the factory call.
18+
2. Invoke the agent through the simpler direct path (`agent.stream(...)` / `agent.generate(...)`).
19+
20+
… get zero memory activation. No error. No warning. No auto-wire. The `memoryProvider.getContext` and `memoryProvider.observe` hooks are never called, memory never reaches the prompt, observations never persist.
21+
22+
The silent-misuse failure mode is proven in wilds-ai, the flagship agentos consumer. Wilds narrator passes `memoryProvider: pipeline.memoryProvider` to `createAgent` and then calls `narratorAgent.stream(...)` direct — memory is computed, stored, but never read on that path. External library users will hit the same pattern.
23+
24+
Additionally, `memoryProvider?: any` at `AgentOptions:134` provides no type-level contract. Callers can pass malformed providers with missing `getContext` / `observe` methods and the silent-no-op surface swallows the mistake.
25+
26+
## Decision
27+
28+
**Auto-wire `memoryProvider` on `agent.stream()` and `agent.generate()` whenever `opts.memoryProvider?.getContext` is defined.** Matches existing `AgentSession.send/.stream` behavior. Makes the public API honest: pass the provider → memory works on every call path.
29+
30+
Introduce a typed `AgentMemoryProvider` interface to replace `memoryProvider?: any`.
31+
32+
Extract the existing session memory wiring into a single internal helper so session and direct paths share one implementation.
33+
34+
## Scope
35+
36+
- Add `AgentMemoryProvider` interface at `packages/agentos/src/api/types.ts`.
37+
- Replace `memoryProvider?: any` with `memoryProvider?: AgentMemoryProvider` in `AgentOptions`.
38+
- Extract `applyMemoryProvider(opts, provider, sessionOpts?)` private helper in `packages/agentos/src/api/runtime/memoryProviderHooks.ts`.
39+
- Refactor `AgentSession.send()` and `AgentSession.stream()` at `agent.ts:440-579` to consume the shared helper.
40+
- Wire `Agent.stream()` and `Agent.generate()` at `agent.ts:404-438` to apply the helper when `opts.memoryProvider?.getContext` is set.
41+
- Update `README.md` memory integration section so users see memory works on direct calls.
42+
- Add CHANGELOG entry for the behavior change.
43+
- Minor version bump: `0.1.255 → 0.2.0`.
44+
45+
## Non-goals
46+
47+
- Changing the `AgentMemoryProvider` interface shape beyond the typing (`getContext` / `observe` semantics stay identical).
48+
- Adding a `useMemory` opt-in flag — auto-wire is the default when the provider is present.
49+
- Touching `CognitiveMemoryManager`, `WildsMemoryFacade`, or any wilds-side code.
50+
- Adding new memory capabilities (no new hooks, no new timeout knobs, no new tokenBudget defaults).
51+
- Refactoring the base system-prompt assembly or the existing `onBeforeGeneration` / `onAfterGeneration` chain semantics.
52+
- Full agentos test suite runs; only targeted `vitest` on touched modules.
53+
54+
## Authoritative references
55+
56+
- `packages/agentos/src/api/agent.ts` — factory + `Agent` + `AgentSession` (current source of truth).
57+
- `packages/agentos/src/api/generateText.ts``GenerateTextOptions`, `onBeforeGeneration`, `onAfterGeneration` types.
58+
- `packages/agentos/src/api/streamText.ts` — streaming entry point.
59+
- `packages/agentos/README.md:150-168` — current memory documentation (the session-only example).
60+
- `packages/agentos/src/api/runtime/__tests__/agentPromptEngine.test.ts:118-183` — existing session-path memory tests (reference shape for the new direct-path tests).
61+
62+
## Current state
63+
64+
```ts
65+
// agent.ts:380-402 — baseOpts fed into direct stream/generate
66+
const baseOpts: Partial<GenerateTextOptions> = {
67+
provider: opts.provider,
68+
model: opts.model,
69+
system: opts.systemBlocks ?? buildSystemPrompt(opts),
70+
tools: opts.tools,
71+
// ... 15 more fields ...
72+
// NO memoryProvider here.
73+
};
74+
75+
// agent.ts:404-438 — direct Agent.stream / Agent.generate
76+
async generate(prompt, extra) {
77+
const genOpts = { ...baseOpts, ...extra, usageLedger: ... };
78+
// ... prompt assembly ...
79+
return generateText(genOpts); // memoryProvider never consulted
80+
}
81+
82+
stream(prompt, extra) {
83+
const streamOpts = { ...baseOpts, ...extra, usageLedger: ... };
84+
// ... prompt assembly ...
85+
return streamText(streamOpts); // memoryProvider never consulted
86+
}
87+
88+
// agent.ts:440-579 — session paths have inline memory wiring
89+
session(id) {
90+
return {
91+
async send(input) {
92+
// ~20 LOC of memory wiring:
93+
// - Race getContext against 5s timeout
94+
// - Prepend contextText to system prompt
95+
// - Call observe('user', ...) + observe('assistant', ...) fire-and-forget
96+
// ...
97+
},
98+
stream(input) {
99+
// ~25 LOC of same wiring via onBeforeGeneration hook
100+
// ...
101+
},
102+
};
103+
}
104+
```
105+
106+
## Target state
107+
108+
```ts
109+
// New interface at types.ts
110+
export interface AgentMemoryProvider {
111+
getContext?: (
112+
text: string,
113+
opts?: { tokenBudget?: number }
114+
) => Promise<{ contextText?: string } | null>;
115+
observe?: (
116+
role: 'user' | 'assistant',
117+
text: string
118+
) => Promise<void>;
119+
}
120+
121+
// AgentOptions uses the interface
122+
export interface AgentOptions extends BaseAgentConfig {
123+
// ... existing fields ...
124+
memoryProvider?: AgentMemoryProvider;
125+
// ... remaining fields ...
126+
}
127+
128+
// New helper at runtime/memoryProviderHooks.ts
129+
export const MEMORY_TIMEOUT_MS = 5000;
130+
export const DEFAULT_MEMORY_TOKEN_BUDGET = 2000;
131+
132+
export function applyMemoryProvider(
133+
baseOpts: Partial<GenerateTextOptions>,
134+
provider: AgentMemoryProvider | undefined,
135+
sessionLog?: { onObserve?: (role: 'user' | 'assistant', text: string) => void }
136+
): Partial<GenerateTextOptions> {
137+
if (!provider?.getContext && !provider?.observe) return baseOpts;
138+
139+
const userOnBefore = baseOpts.onBeforeGeneration;
140+
const userOnAfter = baseOpts.onAfterGeneration;
141+
142+
const wrappedOnBefore: GenerateTextOptions['onBeforeGeneration'] = async (ctx) => {
143+
if (provider.getContext) {
144+
try {
145+
const userText = extractLastUserText(ctx.messages);
146+
const memCtx = await Promise.race([
147+
provider.getContext(userText, { tokenBudget: DEFAULT_MEMORY_TOKEN_BUDGET }),
148+
new Promise<null>((resolve) => setTimeout(() => resolve(null), MEMORY_TIMEOUT_MS)),
149+
]);
150+
if (memCtx?.contextText) {
151+
ctx = {
152+
...ctx,
153+
messages: [
154+
{ role: 'system', content: memCtx.contextText },
155+
...ctx.messages,
156+
],
157+
};
158+
}
159+
} catch {
160+
// Memory recall failure is non-fatal
161+
}
162+
}
163+
if (userOnBefore) {
164+
const userResult = await userOnBefore(ctx);
165+
return userResult ?? ctx;
166+
}
167+
return ctx;
168+
};
169+
170+
const wrappedOnAfter: GenerateTextOptions['onAfterGeneration'] = async (result) => {
171+
if (provider.observe) {
172+
// Fire-and-forget; don't block generation
173+
const userText = extractLastUserText(result.messages);
174+
void provider.observe('user', userText).catch(() => {});
175+
if (result.text) {
176+
void provider.observe('assistant', result.text).catch(() => {});
177+
}
178+
sessionLog?.onObserve?.('user', userText);
179+
if (result.text) sessionLog?.onObserve?.('assistant', result.text);
180+
}
181+
if (userOnAfter) {
182+
const userResult = await userOnAfter(result);
183+
return userResult ?? result;
184+
}
185+
return result;
186+
};
187+
188+
return {
189+
...baseOpts,
190+
onBeforeGeneration: wrappedOnBefore,
191+
onAfterGeneration: wrappedOnAfter,
192+
};
193+
}
194+
195+
// agent.ts Agent.stream/generate applies the helper
196+
async generate(prompt, extra) {
197+
const genOpts = applyMemoryProvider(
198+
{ ...baseOpts, ...extra },
199+
opts.memoryProvider
200+
);
201+
// ... prompt assembly ...
202+
return generateText(genOpts);
203+
}
204+
205+
stream(prompt, extra) {
206+
const streamOpts = applyMemoryProvider(
207+
{ ...baseOpts, ...extra },
208+
opts.memoryProvider
209+
);
210+
// ... prompt assembly ...
211+
return streamText(streamOpts);
212+
}
213+
214+
// AgentSession.send/stream consume the same helper — no inline duplication
215+
session(id) {
216+
return {
217+
async send(input) {
218+
const sessOpts = applyMemoryProvider(
219+
{ ...baseOpts, messages: [...history, userMessage] },
220+
opts.memoryProvider
221+
);
222+
const result = await generateText(sessOpts);
223+
// history bookkeeping unchanged
224+
return result;
225+
},
226+
stream(input) {
227+
const sessOpts = applyMemoryProvider(
228+
{ ...baseOpts, messages: [...history, userMessage] },
229+
opts.memoryProvider
230+
);
231+
const result = streamText(sessOpts);
232+
// history bookkeeping unchanged
233+
return result;
234+
},
235+
};
236+
}
237+
```
238+
239+
`extractLastUserText(messages)` is a small helper that pulls the most recent `role: 'user'` message content (string or multimodal parts → concatenated text). Shared with the existing `extractTextFromContent` utility.
240+
241+
## Architecture
242+
243+
### Helper placement
244+
245+
New file: `packages/agentos/src/api/runtime/memoryProviderHooks.ts`.
246+
247+
Sits next to the existing `usageLedger.ts`, `hostPolicy.ts`, `toolAdapter.ts` helpers under `runtime/`. Private module — not exported from the package barrel. Consumed only by `api/agent.ts`.
248+
249+
### Interface exports
250+
251+
`AgentMemoryProvider` exported from `@framers/agentos/api/types` so external callers can type their providers. Added to the public barrel re-exports.
252+
253+
### Test strategy
254+
255+
New test file: `packages/agentos/src/api/runtime/__tests__/memoryProviderHooks.test.ts` covering:
256+
257+
1. Returns opts unchanged when provider absent.
258+
2. Wraps `onBeforeGeneration` when `provider.getContext` defined; unchanged when only `observe` defined.
259+
3. Wraps `onAfterGeneration` when `provider.observe` defined; unchanged when only `getContext` defined.
260+
4. Prepends contextText as a system message when getContext returns content.
261+
5. Skips prepend when getContext returns null, undefined, or empty contextText.
262+
6. Respects the 5-second timeout (uses fake timers).
263+
7. Observe runs fire-and-forget; rejection doesn't block.
264+
8. Chains user-provided `onBeforeGeneration` after memory wiring.
265+
9. Chains user-provided `onAfterGeneration` after observe.
266+
267+
New direct-path integration tests in `packages/agentos/src/api/runtime/__tests__/agentPromptEngine.test.ts`:
268+
269+
10. `agent({ memoryProvider }).stream(...)` fires `getContext` + `observe`.
270+
11. `agent({ memoryProvider }).generate(...)` fires `getContext` + `observe`.
271+
12. Session path (`agent().session().send()`) continues to fire `getContext` + `observe` (regression test — preserves existing semantics).
272+
273+
### Version bump
274+
275+
`0.1.255 → 0.2.0`. Reasoning:
276+
277+
- Direct-path behavior change: previously memoryProvider was silently ignored; now auto-wires. Affects any external caller who passed memoryProvider expecting no-op — but there's no legitimate reason for that pattern.
278+
- Type change: `any``AgentMemoryProvider`. Callers currently passing malformed providers will see TypeScript errors surface.
279+
- Semver: Minor (0.x.y → 0.2.0) signals additive-plus-behavior-aligned, not a full semver-major rewrite. AgentOS is in 0.x so minor bumps carry behavior changes by convention.
280+
281+
### CHANGELOG entry
282+
283+
```markdown
284+
## 0.2.0
285+
286+
### Changed
287+
- `memoryProvider` now auto-wires on `agent.stream()` and `agent.generate()` direct calls in addition to the existing `agent.session().send()` / `.stream()` paths. Passing `memoryProvider` on `createAgent` now means memory `getContext` fires before every LLM call and `observe` fires after — on every call path.
288+
- `memoryProvider?: any` typed as `AgentMemoryProvider` interface. Callers passing malformed providers will see new TypeScript errors.
289+
290+
### Migration
291+
- Callers using `agent.session()` for memory: no action required. Behavior unchanged.
292+
- Callers using `agent.stream()` / `.generate()` direct: memory now works. If you previously worked around the silent-ignore by wiring memory manually via `onBeforeGeneration`, you can remove the manual wiring.
293+
- Callers who passed `memoryProvider` without intending to use memory: remove the field from your `createAgent` config. Or type-check at compile time via the new `AgentMemoryProvider` interface.
294+
```
295+
296+
### README update
297+
298+
Line 150-168's memory example updates to show direct-call usage:
299+
300+
```markdown
301+
#### Memory on direct calls
302+
303+
Memory auto-wires on `agent.stream()` / `agent.generate()` as well — sessions
304+
are not required to get memory integration.
305+
306+
```typescript
307+
const tutor = agent({
308+
provider: 'anthropic',
309+
instructions: 'You are a patient CS tutor.',
310+
memoryProvider: myProvider,
311+
});
312+
313+
// Direct stream — memory context injected before the call, observations
314+
// recorded after.
315+
const stream = tutor.stream('Explain recursion.');
316+
317+
// Session — same memory wiring, plus per-session conversation history.
318+
const session = tutor.session('student-1');
319+
await session.send('Continue where we left off.');
320+
```
321+
```
322+
323+
## Risks and how they're handled
324+
325+
- **External caller relies on silent-ignore**: theoretical risk. No legitimate use case. Minor-version bump + CHANGELOG warn. If a caller surfaces a complaint post-release, we can add a `disableAutoMemory: true` opt-out in a 0.2.1 patch.
326+
- **Test-mock drift**: wilds and external consumers may have mocks for `memoryProvider` that shape-match `any`. Moving to `AgentMemoryProvider` surfaces those mocks as type errors. Callers fix per the interface.
327+
- **Performance**: direct-path callers now pay one 5s-bounded `getContext` call + fire-and-forget `observe` per invocation. For single-shot classification use cases this is a small overhead; callers concerned about hot-loop cost should not pass memoryProvider (the obvious fix). No real-timer test issues because the existing `AgentSession.stream` already uses this timeout pattern.
328+
- **Hook chain ordering**: user-provided `onBeforeGeneration` runs AFTER memory wiring (to allow user to see memory context). User-provided `onAfterGeneration` runs AFTER observe dispatch. Same chain semantics as existing `AgentSession.stream` at `agent.ts:513-538`. Tests pin the order.
329+
- **Typing breakage on upgrade**: callers with `memoryProvider: any` on their config type won't break. Callers with malformed provider implementations will see TS errors — that's the intended benefit.
330+
- **Session history duplication**: not applicable. Direct-path doesn't hold history; session-path history behavior is unchanged.
331+
- **Test duplication between session and direct paths**: the shared helper tests cover the core wiring; session + direct integration tests cover the call-path glue. No duplicate assertion trees.
332+
333+
## Success criteria
334+
335+
- `AgentMemoryProvider` interface exported from `@framers/agentos/api/types` + package barrel.
336+
- `applyMemoryProvider` helper exists at `packages/agentos/src/api/runtime/memoryProviderHooks.ts`.
337+
- `agent.stream()` + `agent.generate()` auto-wire memoryProvider when present.
338+
- `agent.session().send()` + `.stream()` use the same helper (no inline duplication).
339+
- Behavior on all four call paths proven by targeted `vitest` runs.
340+
- Existing agentos tests (full suite) pass unchanged. Memory-related session tests at `agentPromptEngine.test.ts:118-183` pass as regression.
341+
- `tsc --noEmit` clean on the agentos package.
342+
- `pnpm lint` clean for touched files.
343+
- README memory section updated with the direct-call example.
344+
- CHANGELOG entry published under `0.2.0`.
345+
- Package `version` bumped to `0.2.0` in `package.json`.
346+
- Spec status flipped to `implemented` + commit SHAs annotated once landed.
347+
348+
## Constraints (carried from agentos repo conventions)
349+
350+
- Work on the `packages/agentos/` submodule. `cd` into it before `pnpm` / `vitest`.
351+
- Agentos has its own `vitest` config. Run targeted tests only.
352+
- Agentos has its own commit message + PR conventions (see existing commits).
353+
- Agentos is actively published to npm via CI on master commits (CHANGELOG / version bump triggers release).
354+
- No subagents. No worktrees. No stash/reset. All work directly on master.
355+
- Commit and push inside `packages/agentos/` first, then bump the monorepo submodule pointer.
356+
357+
## Rollout
358+
359+
1. Ship all code changes + tests.
360+
2. Bake in wilds-ai master for at least 3-5 days of narrator + companion traffic via the wilds-side consumer spec (`2026-04-22-wilds-memory-integration-completion-design.md`).
361+
3. If no regression surfaces (monitor `refusal_retry_*` + `usage_event_*` + any narrator latency metrics), publish `0.2.0` to npm.
362+
4. If regression surfaces, roll back the agentos pointer in wilds to `0.1.255` and iterate.
363+
364+
## Immediate next step after approval
365+
366+
Invoke `superpowers:writing-plans` to produce `packages/agentos/docs/superpowers/plans/2026-04-22-memoryprovider-direct-call-autowire.md` — the TDD implementation plan that walks the scope task-by-task.

0 commit comments

Comments
 (0)