You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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)constagent=newAgent({
model,memoryManager: {stores: [myStore],injection: true},})// Customizedconstagent=newAgent({
model,memoryManager: {stores: [myStore],injection: {trigger: 'everyTurn',// inject before every model call, not just fresh user asksmaxEntries: 2,// retrieve + inject up to 2 entries (default 5)query: ({ messages })=>myDerive(messages),// override the adaptive default queryformat: ({ entries })=>// override the default <memory> XML renderingentries.map((e)=>e.content).join('\n'),},},})
The generic engine is also vended as a plugin for non-memory consumers:
// Generic injection contract (reusable by any consumer)exporttypeInjectionTrigger='userTurn'|'everyTurn'exportinterfaceInjectionConfig{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.exportinterfaceInjectionContext{messages: MessageData[]// the current conversation, as dataappState: StateStore// durable cross-call app state — read what a tool stashed last turnsignal: 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 contractexportinterfaceMemoryInjectionConfigextendsInjectionConfig{maxEntries?: numberquery?: (context: {messages: MessageData[]})=>string|undefinedformat?: (context: {entries: MemoryEntry[]})=>string}// MemoryManagerConfig now carries:
injection?: boolean|MemoryInjectionConfig// Generic vended plugin (from '@strands-agents/sdk/vended-plugins/context-injector')exportinterfaceContextInjectorConfig{name?: stringtrigger?: InjectionTrigger|((context: InjectionContext)=>boolean)renderContent: (context: InjectionContext)=>Promise<string|undefined>}exportclassContextInjectorimplementsPlugin{ ... }exportfunctionescapeXml(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 askmaxEntries: 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 entries5: 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,typeInjectionContext}from'@strands-agents/sdk'// Built-in: inject before every model call (autonomous agents that should consult memory each step)newMemoryManager({stores: [myStore],injection: {trigger: 'everyTurn'}})// Predicate: only inject once the conversation is long enough to benefitnewMemoryManager({stores: [myStore],injection: {trigger: ({ messages }: InjectionContext)=>messages.length>=4},})// Predicate: gate on something a tool stashed in appState last turnnewMemoryManager({stores: [myStore],injection: {trigger: ({ appState }: InjectionContext)=>appState.get('recallEnabled')===true},})
Customization examples for query
import{MemoryManager,typeMessageData}from'@strands-agents/sdk'// Example 1: only inject when the user explicitly asks to recall somethingnewMemoryManager({stores: [myStore],injection: {query: ({ messages })=>{consttext=lastUserText(messages)??''return/\b(remember|recall|lasttime|previously)\b/i.test(text) ? text : undefined// ^ undefined => skip},},})// Example 2: combine the last user ask + the last assistant turn for richer retrievalnewMemoryManager({stores: [myStore],injection: {query: ({ messages })=>{constuser=lastTextByRole(messages,'user')constassistant=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'constagent=newAgent({
model,plugins: [// a live clock, injected on every turnnewContextInjector({name: 'now',trigger: 'everyTurn',renderContent: async()=>`<now>${nowIso()}</now>`}),// a sandbox descriptor read from durable app state, cancellable on run-abortnewContextInjector({name: 'sandbox',renderContent: async({ appState, signal })=>`<sandbox>${escapeXml(awaitdescribeSandbox(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.
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 maxInjectedSearchResults → 5 (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 1 → 5
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).
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.tsexport 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.
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 createInjectionMiddlewareinternal 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.
exportinterfaceContextProviderConfig{name?: stringtrigger?: InjectionTrigger|((ctx: ProvideContext)=>boolean)// default 'userTurn'provide: (ctx: ProvideContext)=>Promise<string|undefined>// ''/undefined => skip; throws => fail openmaxTokens?: number// shared-budget hint (see coalescing)}
class ContextProvider implements Plugin — name + 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:
exportinterfaceProvideContext{messages: MessageData[]appState: StateStore// durable cross-call store on LocalAgentsignal?: AbortSignal// so async provide() I/O (sandbox RPC, RAG) is cancellable on abortagent: 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.
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 createInjectionMiddlewareun-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.
@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 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?
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 callsearch_memoryitself.Delivery is live: it registers an
InvokeModelStageinput 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 aprovide(context) => Promise<string | undefined>callback and reuses the same delivery (a clock, a sandbox descriptor, a RAG lookup).MemoryManager'sinjectionconfig 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 throughContextInjectororMemoryManager, not by wiring middleware themselves.Public API Changes
MemoryManagerConfiggains an opt-ininjectionfield (defaultfalse):The generic engine is also vended as a plugin for non-memory consumers:
New / changed exported types:
MemoryInjectionConfig,InjectionConfig,InjectionTrigger, andInjectionContextare exported from the package root (@strands-agents/sdk);ContextInjector,ContextInjectorConfig, andescapeXmlfrom the./vended-plugins/context-injectorsubpath.Defaults, when
injection: true:'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 theInjectionContextis the escape hatch.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.<memory>block with one<entry source="…">per result, attributing the originating store. The default escapes entry content and thesourceattribute, so user-derived memory text cannot break the block or inject markup. A customformatthat emits markup is responsible for its own escaping (use the exportedescapeXmlhelper).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:
Customization examples for
triggerCustomization examples for
queryCustomization examples for
formatCustomization examples for the generic
ContextInjectorRelated 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 (
foldIntoLastUserMessageblock-prepend ordering and no-op when no user message exists,isUserTurn,resolveTriggeracross modes incl. fail-open), the livecreateInjectionMiddlewarehandler (folds on a user turn, skips on a non-user turn,everyTurn, empty/whitespace text, fail-open on a throwingprovide, no durable mutation, and thatappState/signalare exposed on theProvideContext), theContextInjectorplugin (name defaulting/override,InvokeModelStage.Inputregistration, end-to-end fold through the registered handler, fail-open), and the full memory pipeline (_provideMemoryContext: adaptive query on user vs. autonomous turns, customquery/format, fail-open on throwing callbacks, empty-search short-circuit,maxInjectedSearchResults, default-format XML escaping incl. an adversarial</entry>/quote breakout, config resolution forfalse/true/object).Verified locally from
strands-ts/:tsc --noEmit --project src/tsconfig.json,npm run lint,npm run format:checkall clean; full unit suite green (3268 passed, 0 todo). This is a TypeScript SDK change.hatch run prepareChecklist
By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.