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