Skip to content

feat: memory injection - #2631

Merged
opieter-aws merged 4 commits into
strands-agents:mainfrom
opieter-aws:opieter-aws/memory-injection
Jun 12, 2026
Merged

opieter-aws merged 4 commits into
strands-agents:mainfrom
opieter-aws:opieter-aws/memory-injection

Conversation

@opieter-aws

@opieter-aws opieter-aws commented Jun 4, 2026

Copy link
Copy Markdown
Contributor

Description

Adds memory context-injection to MemoryManager: configuration that lets the manager search memory before a model call and fold the top results into the model input, so relevant knowledge is present without the model having to call search_memory itself.

Delivery is live: it registers an InvokeModelStage input middleware (the middleware system from #2681) that folds retrieved memory into the per-call model input. The injected text is ephemeral by design — it augments the model input for a single call and never persists into durable history or the session, because the input phase rewrites the per-call context only, not the agent's stored messages.

The injection mechanism is generic, not memory-specific. The shared engine lives in src/injection/ and is surfaced publicly as a vended plugin, ContextInjector: any consumer supplies a provide(context) => Promise<string | undefined> callback and reuses the same delivery (a clock, a sandbox descriptor, a RAG lookup). MemoryManager's injection config is that same engine specialized to memory search. It is the first consumer. The engine itself is text-in: it knows nothing about queries, search, or result counts; those are consumer concerns (here, owned by memory). The delivery primitives (createInjectionMiddleware, foldIntoLastUserMessage) are kept internal — consumers reach injection through ContextInjector or MemoryManager, not by wiring middleware themselves.

Public API Changes

MemoryManagerConfig gains an opt-in injection field (default false):

import { Agent, MemoryManager } from '@strands-agents/sdk'

// Simplest: enable with defaults (search the latest user ask, inject up to 5 results on a user turn)
const agent = new Agent({
  model,
  memoryManager: { stores: [myStore], injection: true },
})

// Customized
const agent = new Agent({
  model,
  memoryManager: {
    stores: [myStore],
    injection: {
      trigger: 'everyTurn',           // inject before every model call, not just fresh user asks
      maxEntries: 2,                  // retrieve + inject up to 2 entries (default 5)
      query: ({ messages }) => myDerive(messages),  // override the adaptive default query
      format: ({ entries }) =>        // override the default <memory> XML rendering
        entries.map((e) => e.content).join('\n'),
    },
  },
})

The generic engine is also vended as a plugin for non-memory consumers:

import { Agent } from '@strands-agents/sdk'
import { ContextInjector } from '@strands-agents/sdk/vended-plugins/context-injector'

const agent = new Agent({
  model,
  plugins: [new ContextInjector({ renderContent: async () => `<now>${new Date().toISOString()}</now>` })],
})

New / changed exported types:

// Generic injection contract (reusable by any consumer)
export type InjectionTrigger = 'userTurn' | 'everyTurn'

export interface InjectionConfig {
  trigger?: InjectionTrigger | ((context: InjectionContext) => boolean)
}

// The context every injection callback receives. A bag (not positional args) so fields can be added
// later without breaking callbacks.
export interface InjectionContext {
  messages: MessageData[]   // the current conversation, as data
  appState: StateStore      // durable cross-call app state — read what a tool stashed last turn
  signal: AbortSignal       // the run's cancel signal; forward it to async I/O in renderContent()
  agent: LocalAgent         // the agent (escape hatch for advanced consumers)
}

// Memory-owned extension of the generic contract
export interface MemoryInjectionConfig extends InjectionConfig {
  maxEntries?: number
  query?: (context: { messages: MessageData[] }) => string | undefined
  format?: (context: { entries: MemoryEntry[] }) => string
}

// MemoryManagerConfig now carries:
injection?: boolean | MemoryInjectionConfig

// Generic vended plugin (from '@strands-agents/sdk/vended-plugins/context-injector')
export interface ContextInjectorConfig {
  name?: string
  trigger?: InjectionTrigger | ((context: InjectionContext) => boolean)
  renderContent: (context: InjectionContext) => Promise<string | undefined>
}
export class ContextInjector implements Plugin { ... }
export function escapeXml(value: string): string   // opt-in helper for providers that emit XML

MemoryInjectionConfig, InjectionConfig, InjectionTrigger, and InjectionContext are exported from the package root (@strands-agents/sdk); ContextInjector, ContextInjectorConfig, and escapeXml from the ./vended-plugins/context-injector subpath.

Defaults, when injection: true:

{
  trigger: 'userTurn',           // inject only on a fresh user ask
  maxEntries: 5,                 // retrieve and inject up to 5 entries
  // query:  the latest user text on a user turn, else the most recent assistant text
  // format: a <memory> block with one <entry source="STORE_NAME"> per result (content escaped)
}
  • Trigger 'userTurn': inject only when the latest message is a fresh user ask (keeps the user's ask in the recency slot; valid role alternation in both chat and the tool loop). 'everyTurn' injects before every call; a predicate over the InjectionContext is the escape hatch.
  • Query (adaptive): the latest user message's text on a user turn, otherwise the most recent assistant text (the previous autonomous step).
  • Max entries 5: a store ranks by semantic (embedding) similarity, which is not the same as contextual usefulness — the top hit is not reliably the most useful entry for the turn. Injecting the top 5 gives the model a small candidate set to pick from rather than betting on the store's first result. Raising it improves recall at the cost of a larger prepend (context bloat); lower it for a tighter injection.
  • Format: a <memory> block with one <entry source="…"> per result, attributing the originating store. The default escapes entry content and the source attribute, so user-derived memory text cannot break the block or inject markup. A custom format that emits markup is responsible for its own escaping (use the exported escapeXml helper).

All consumer callbacks (trigger, query, format, renderContent) fail open — a throw logs and skips injection, and the model call proceeds.

The default rendered block looks like:

<memory>
<entry source="preferences">User prefers dark mode</entry>
<entry source="facts">Lives in Seattle</entry>
</memory>

Customization examples for trigger

import { MemoryManager, type InjectionContext } from '@strands-agents/sdk'

// Built-in: inject before every model call (autonomous agents that should consult memory each step)
new MemoryManager({ stores: [myStore], injection: { trigger: 'everyTurn' } })

// Predicate: only inject once the conversation is long enough to benefit
new MemoryManager({
  stores: [myStore],
  injection: { trigger: ({ messages }: InjectionContext) => messages.length >= 4 },
})

// Predicate: gate on something a tool stashed in appState last turn
new MemoryManager({
  stores: [myStore],
  injection: { trigger: ({ appState }: InjectionContext) => appState.get('recallEnabled') === true },
})

Customization examples for query

import { MemoryManager, type MessageData } from '@strands-agents/sdk'

// Example 1: only inject when the user explicitly asks to recall something
new MemoryManager({
  stores: [myStore],
  injection: {
    query: ({ messages }) => {
      const text = lastUserText(messages) ?? ''
      return /\b(remember|recall|last time|previously)\b/i.test(text) ? text : undefined
      //                                                                    ^ undefined => skip
    },
  },
})

// Example 2: combine the last user ask + the last assistant turn for richer retrieval
new MemoryManager({
  stores: [myStore],
  injection: {
    query: ({ messages }) => {
      const user = lastTextByRole(messages, 'user')
      const assistant = lastTextByRole(messages, 'assistant')
      return [assistant, user].filter(Boolean).join('\n') || undefined
    },
  },
})

Customization examples for format

import { escapeXml } from '@strands-agents/sdk/vended-plugins/context-injector'

// bullets instead of XML
format: ({ entries }) => entries.map((e) => `- ${e.content}`).join('\n')

// natural-language preamble — frame the memories as soft context
format: ({ entries }) =>
  `Here's what you know about this user from past conversations:\n` +
  entries.map((e) => `- ${e.content}`).join('\n')

// group by source store
format: ({ entries }) => entries.map((e) => `[${e.storeName}] ${e.content}`).join('\n')

// surface a relevance score from metadata
format: ({ entries }) => entries.map((e) => `- ${e.content} (${e.metadata?.score})`).join('\n')

// custom XML — escape your own content (the default escapes; custom formatters own their escaping)
format: ({ entries }) =>
  `<context>${entries.map((e) => `<item>${escapeXml(e.content)}</item>`).join('')}</context>`

Customization examples for the generic ContextInjector

import { Agent } from '@strands-agents/sdk'
import { ContextInjector, escapeXml } from '@strands-agents/sdk/vended-plugins/context-injector'

const agent = new Agent({
  model,
  plugins: [
    // a live clock, injected on every turn
    new ContextInjector({ name: 'now', trigger: 'everyTurn', renderContent: async () => `<now>${nowIso()}</now>` }),

    // a sandbox descriptor read from durable app state, cancellable on run-abort
    new ContextInjector({
      name: 'sandbox',
      renderContent: async ({ appState, signal }) =>
        `<sandbox>${escapeXml(await describeSandbox(appState, { signal }))}</sandbox>`,
    }),
  ],
})

Related Issues

Builds on: #2544 (MemoryManager), #2681 (middleware system — provides the delivery seam)

Documentation PR

todo

Type of Change

New feature

Testing

Unit tests cover the generic delivery primitives (foldIntoLastUserMessage block-prepend ordering and no-op when no user message exists, isUserTurn, resolveTrigger across modes incl. fail-open), the live createInjectionMiddleware handler (folds on a user turn, skips on a non-user turn, everyTurn, empty/whitespace text, fail-open on a throwing provide, no durable mutation, and that appState/signal are exposed on the ProvideContext), the ContextInjector plugin (name defaulting/override, InvokeModelStage.Input registration, end-to-end fold through the registered handler, fail-open), and the full memory pipeline (_provideMemoryContext: adaptive query on user vs. autonomous turns, custom query/format, fail-open on throwing callbacks, empty-search short-circuit, maxInjectedSearchResults, default-format XML escaping incl. an adversarial </entry>/quote breakout, config resolution for false/true/object).

Verified locally from strands-ts/: tsc --noEmit --project src/tsconfig.json, npm run lint, npm run format:check all clean; full unit suite green (3268 passed, 0 todo). This is a TypeScript SDK change.

  • I ran hatch run prepare

Checklist

  • I have read the CONTRIBUTING document
  • I have added any necessary tests that prove my fix is effective or my feature works
  • I have updated the documentation accordingly
  • I have added an appropriate example to the documentation to outline the feature, or no new docs are needed
  • My changes generate no new warnings
  • Any dependent changes have been merged and published

By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.

Comment thread strands-ts/src/memory/types.ts Outdated
Comment thread strands-ts/src/memory/types.ts
Comment thread strands-ts/src/injection/message-injection.ts Outdated
Comment thread strands-ts/src/memory/types.ts Outdated
@opieter-aws
opieter-aws requested a review from zastrowm June 4, 2026 20:48
@agent-of-mkmeral

Copy link
Copy Markdown
Contributor

Maintainer decisions on the API BR conditions

Thanks for the BR pass. We've made calls on the open conditions — here's where we land, with rationale. Net: 4 changes requested before merge (3 were already flagged as blocking; #4 is a promotion of a recommended item to required).

1. Default maxInjectedSearchResults5 (not 1)

Decision: change the default to 5. (Resolves the doc/code mismatch in favor of the PR text, not the code.)

The reasoning is about what the KB actually gives us. A memory store does semantic vector search — it ranks by embedding similarity, i.e. semantic relevance. But semantically relevant ≠ contextually relevant. The top-1 hit is the single most embedding-similar entry, which is not reliably the most useful entry for the current turn. Defaulting to 1 makes the whole feature lean entirely on the store's ranking being right on the first try, which is a fragile bet for a vector index.

Returning the top 5 gives the model a materially better shot at having the contextually-right memory somewhere in the injected set, and the model is good at picking the relevant one out of a small candidate list. The tradeoff is acknowledged: more entries = more context bloat. 5 is the balance point — enough recall to not depend on perfect ranking, small enough to keep the prepend cheap.

Action items:

  • DEFAULT_INJECTED_SEARCH_RESULTS = 5
  • Update the tests that assert 15
  • Make TSDoc + PR body + code all agree on 5
  • Note in the TSDoc the bloat tradeoff so users who want a tighter prepend know to lower it

This also softens condition #4 from the BR (multi-store store-order bias): with 5 you're far less likely to get a single store[0]-biased result. Still worth fixing the global ranking eventually, but it's no longer as acute at the default.

2. Escape XML by default in _defaultInjectionFormat

Decision: yes — the default formatter must escape. This was blocking and stays blocking.

The default renders <entry source="${storeName}">${content}</entry> with raw interpolation. Memory content is frequently user-derived, so:

  • Structural break: content containing </entry>/</memory>/" breaks the block the model sees.
  • Stored-prompt-injection surface: raw user-derived text folded straight into model input is an injection vector.

Escape &, <, > in text content and additionally " (and ideally ') inside attribute values (source). Order matters — escape & first. A custom format remains the user's responsibility, but the shipped default must be safe.

3. Fix the export gap

Decision: fix it. The config types must be importable by name (Tenet 5).

Right now:

import type { MemoryInjectionConfig } from '@strands-agents/sdk'        // ❌
import type { MemoryInjectionConfig } from '@strands-agents/sdk/memory' // ❌

MemoryInjectionConfig / InjectionConfig / InjectionTrigger are re-exported from memory/index.ts, but the root index.ts type re-export block doesn't list them, and there's no ./memory (or ./injection) subpath in package.json#exports. A customer can't write a typed const injection: MemoryInjectionConfig = {…} — defeating the point of the public config surface for both humans and agents/IDEs.

Action: add MemoryInjectionConfig, InjectionConfig, InjectionTrigger to the root index.ts export type { … } from './memory/index.js' block. (MessageData, which the query/format signatures need, is already at root — good.)

4. Callback forward-compat — promote to required

Decision: +1, and we want this done now, not deferred. Moving it from "recommended" to a merge condition.

query, format, and provide currently take a single positional messages arg. The first time we need to pass anything else (resolved trigger, agent ref, store list, abort signal) we break every customer callback — and that's a major-version break for a feature that's brand new. Cheap to get right now:

query?: (ctx: { messages: MessageData[] }) => string | undefined
format?: (ctx: { entries: MemoryEntry[] }) => string
// provide likewise takes a single context object

Wrap the args in a context object now so we can grow it additively later. Please also keep this shape in mind for the Python memory-injection equivalent so the callback contracts don't diverge across SDKs.


Not in scope for this round (tracked, not blocking): the silent no-op warning until #1068 lands, and the maxInjectedSearchResults naming nit. We can revisit naming before the runtime delivery PR.

cc @opieter-aws — once 1–4 are in we should be good to merge.

Comment thread strands-ts/src/memory/types.ts Outdated
Comment thread strands-ts/src/memory/memory-manager.ts Outdated
Comment thread strands-ts/src/memory/types.ts
@agent-of-mkmeral

agent-of-mkmeral commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

Proposal: ContextProvider is the public shape — land it in this PR

TL;DR: mechanism is good. But createInjectionMiddleware as an exported free function is a third extension idiom next to plugins + hooks, with zero non-memory consumers — a "random helper" we shouldn't ship if we won't keep it. So: commit to ContextProvider as a vended plugin now, make createInjectionMiddleware internal from day one, and let memory's injection: true be its first consumer. This also resolves the open provide/escape/appState/stacking questions in one place. Details below.

Why a plugin, and why now (not a follow-up)
  • Every other extension ships via plugins: […] (ContextOffloader, AgentSkills, memory). A free createInjectionMiddleware + addMiddleware + InvokeModelStage is a third idiom customers must learn for no reason.
  • If we export createInjectionMiddleware publicly now, we either keep it forever or break people later. Don't release a helper we won't stand behind. Make it internal plumbing behind ContextProvider from the start.
  • The plugin is the natural owner of coalescing/ordering/budget across stacked providers — a per-middleware design can't be, since each middleware is blind to the others.
  • Memory doesn't regress: injection: true keeps working, just implemented as a ContextProvider under the hood.
Proposed API
import { ContextProvider } from '@strands-agents/sdk/vended-plugins/context-provider'

new Agent({ model, plugins: [
  new ContextProvider({ name: 'now', provide: async () => `<now>${new Date().toISOString()}</now>` }),
  new ContextProvider({ name: 'sandbox', trigger: 'everyTurn', provide: async (ctx) => describeSandbox(ctx.appState) }),
  new ContextProvider({ name: 'editor', provide: async (ctx) => renderEditor(ctx) }),
]})
export interface ContextProviderConfig {
  name?: string
  trigger?: InjectionTrigger | ((ctx: ProvideContext) => boolean)   // default 'userTurn'
  provide: (ctx: ProvideContext) => Promise<string | undefined>      // ''/undefined => skip; throws => fail open
  maxTokens?: number                                                 // shared-budget hint (see coalescing)
}

class ContextProvider implements Pluginname + initAgent(agent) registers the internal injection middleware. Same lifecycle as ContextOffloader.

ctx-bag: pass appState + signal + agent (not just messages)

The single biggest forward-compat lever. provide/trigger get a context object, not a positional messages:

export interface ProvideContext {
  messages: MessageData[]
  appState: StateStore   // durable cross-call store on LocalAgent
  signal?: AbortSignal   // so async provide() I/O (sandbox RPC, RAG) is cancellable on abort
  agent: LocalAgent
}
  • appState lets an injector read what a tool stashed last turn without a closure — makes providers composable with the rest of the agent, not just self-contained closures. (This is the +100 ask.)
  • signal matters because non-memory providers (sandbox/identity/RAG) do real network I/O that should cancel on run-abort. Memory doesn't feel this; sandbox does.
  • Wrapping in a bag now means we grow it additively later — no major-version break. This is the same condition as ci: update pre-commit requirement from <4.2.0,>=3.2.0 to >=3.2.0,<4.3.0 #4 in the conditions comment, unified here.
Escaping & format: consumer contract, not engine behavior

Agreed: the engine does NOT escape. provide returns opaque text — the engine can't know if it's XML, Markdown, JSON, or prose, so it has no basis to escape. Escaping only made sense in memory because memory owns its <memory> format (that's why memory's default formatter still escapes — condition #2).

So at the ContextProvider/engine layer:

  • No auto-escaping. Escaping is the provider author's responsibility.
  • Ship an optional escapeXml helper next to the plugin for the consumers who do choose XML — convenience, never auto-applied.
  • Loud TSDoc warning: provider output is folded raw into model input = stored-prompt-injection surface; escape attacker-influenced fields yourself.

The split: memory has a format (knows its structure → escapes); generic providers return raw text (own their structure → own their escaping).

Stacking: how multiple providers compose (the gap the plugin closes)

Today N injectors stack natively but only via middleware order — each calls foldIntoLastUserMessage independently, so you get N separate prepends ahead of the user ask, order = registration order, no dedup, no shared budget. That's "works but undefined."

ContextProvider owns this explicitly:

  • Ordering: a documented priority/registration order, not implicit plugin-array luck.
  • Coalescing: merge all providers' output into ONE context region instead of N scattered TextBlocks shoved ahead of the ask.
  • Budget: maxTokens hint so the plugin can enforce a shared cap across providers — no single injector can be blind to the total.
Scope / sequencing

The 4 merge conditions still stand (export gap, default 1→5, escape memory's formatter, ctx-bag). On top of those, this PR (or a tightly-coupled companion before release) should: vend ContextProvider, keep createInjectionMiddleware un-exported/internal, and define the coalescing/ordering/budget contract — so we don't ship a public helper we'd have to deprecate. Memory ships as consumer #1; a tiny now/sandbox provider validates the abstraction (Tenet 2).

@opieter-aws — proposing we make ContextProvider the committed public shape in this release rather than a follow-up. Can sketch the full plugin + coalescing impl if we're aligned.

@mkmeral

mkmeral commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

@opieter-aws what do you think about context provider as a plugin? I am hesitant to add a random method. I'm not a fan of the current single method tbh

@opieter-aws

Copy link
Copy Markdown
Contributor Author

@opieter-aws what do you think about context provider as a plugin? I am hesitant to add a random method. I'm not a fan of the current single method tbh

That makes sense. I'm happy to provide as a plugin, but IMO ContextProvider is semantically too close to our ContextManager primitive. What about MessageInjector?

@yonib05 yonib05 added enhancement New feature or request area-context Session or context related area-agent Related to the agent class or general agent questions labels Jun 9, 2026

This branch had an error being deployed

1 failed (outdated) and 1 inactive deployments
auto-approve e29d6d4e Deployed Jun 12, 2026 by opieter-aws via Trigger Strands Review #1884
manual-approval 569c7d53 Deployed Jun 11, 2026 by opieter-aws via Run integration tests #336
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

api/review-complete An API Bar-raiser reviewed and accepted the APIs area-agent Related to the agent class or general agent questions area-context Session or context related enhancement New feature or request size/xl

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants