Why an agent's safety rules quietly decay over a long conversation, and how a single Mastra input processor re-anchors them without touching the system prompt.
The same agent produced two shockingly different answers.
We used the exact same model, system prompt, and question: compare the product against a cheaper rival. On the left, the assistant starts building the case against the very product it represents. On the right, it stays neutral and points you elsewhere. The only difference is one extra line of text on the latest message, and the rule against doing this was in the system prompt on both sides.
That assistant is ATLAS, an in-product helper for a fictional investing platform called Meridian. Its one job-specific rule is to never argue that a member should leave Meridian or that it's poor value. Leave the reminder off, and it breaks that rule without tripping a single filter.
While this is a demo to show the situation, the failure is very real. On a recent client project, a customer-facing assistant for a subscription product talked a user into cancelling. Over the course of a long conversation, it concluded the product was overpriced and drafted a write-up arguing the customer should walk away. The uncomfortable part is that the rule meant to stop it was already in place. "Don't argue against the product you represent" was one of the explicit rules in its system prompt, and the agent drifted past it anyway, not in one bad prompt but slowly, over dozens of friendly turns.
We see this across agent projects, and it's rarely the dramatic jailbreak people picture. A rule that holds at turn 3 is shaky by turn 30, and the obvious fix (write it more emphatically and move it higher up the system prompt) barely helps.
This post is about why that happens, why the obvious fix doesn't work, and the small one that does work: re-stating the rule as a short note next to the most recent turn. In Mastra, that's a single input processor with no prompt changes.
How a friendly conversation went off the rails
Nothing about the conversation raised any flags when it started. Small talk, harmless questions, then a slow reframing of the agent's role. Could it compare what it knew against the product's own data? Could it score the product out of ten? Could it pick between renewing and cancelling?
By the time the real ask arrived, the ground had shifted under it. The agent produced a full case for cancelling, complete with numbers it had no basis for, then repackaged that case into an email and a social post. None of it tripped a safety filter (a guardrail). None of it looked unsafe in the ordinary sense: no hate speech, no personal data, nothing a content filter is built to catch. It was just the assistant taking apart the product it was there to support.
Guardrails are tuned for categories of harmful content, and this wasn't that. It was the agent breaking a rule specific to its own job, the kind of rule that lives in your prompt rather than in a content classifier.
When we first pulled the trace, we went looking for a prompt bug, but there wasn't one.
What actually happens is a salience problem
The easy explanation is that the model forgot its instructions, but it didn't. The system prompt is sent in full on every call, and it was right there on the turn the agent went off the rails.
So the rule wasn't missing; it had just lost salience. Picture what the model sees at turn 30: the long system prompt up top, then 30 turns of conversation, much of it dense with data and full of the agent's own earlier answers, which had already played along. The freshest, bulkiest signal in all of that isn't the rule. It's 30 turns of precedent pointing the other way.
You'd expect the rule to be safe up at the top — models do lean on the start and end of a context more than the middle (Liu et al., 2023). But that work is about finding a fact, not obeying a rule, and it doesn't save you here. The real problem isn't where the rule sits; it's that one short line gets crowded out as the context fills with recent, on-topic text. By turn 30, the rule is just a tiny slice of everything the model is reading.
Call it many-shot steering rather than forgetting. The conversation becomes a stack of examples, and the examples outvote one line sitting far away at the top of the prompt.
Why prohibitions are the first to go
There's recent research that lines up almost exactly. In Omission Constraints Decay While Commission Constraints Persist in Long-Context LLM Agents (Gamage, 2026), the author measures how well rules survive as a conversation grows, across thousands of trials and a dozen models.
Prohibitions ("never do X") drop from about 73% compliance at turn 5 to about 33% by turn 16. Requirements ("always do Y") hold steady. The paper calls the gap “Security-Recall Divergence,” and the sting is in which rules fail: the "never" ones, the rules you wrote precisely because the downside was bad.
That also kills the comfortable assumption that pinning the rule into the system prompt saves it from being drowned out once the context fills up. A system instruction does carry weight; models are trained to treat it as high-priority, but that authority isn't the same as still winning the turn once the newer context outweighs it.
Why re-injection isn't as hacky as it sounds
Putting a rule back near the user's latest turn might sound like an anti-pattern. It isn't. It's a documented technique with a track record.
In a peer-reviewed study, wrapping the user's query with a short "self-reminder" cut jailbreak success from 67% to 19% (Xie et al., 2023). Anthropic's own long-context guidance tells you to put instructions at the end and to repeat the critical ones. And if you've used Claude Code, you've already seen this in action: it injects <system-reminder> tags into the message stream, not the system prompt, to keep rules and context fresh. As one analysis puts it, “anything you write in a system prompt is basically gone in a conversation of 100K tokens.”
So the question was never whether to re-inject. It's where to put the reminder and how to phrase it, so it survives a real production setup.
Where and how to place the reminder
Recency and trust pull against each other. You want the reminder near the end of the context, next to the latest turn, but the trusted home for instructions is the system channel, and many providers keep that as one block pinned to the top, outside the recency window. You rarely get both, so you pick. We pick recency, which in practice means the user channel: it's the portable choice and behaves the same everywhere.
That leaves the wording. We write the note as a plain, descriptive line about what the assistant does, not an order about what it must not do. Positive phrasing survives longer than a bare prohibition (as we saw), reads better next to the user's own words, and is less likely to trip a guardrail scanning the user channel for injected commands. Where that isn't enough, there are provider-specific options, which we'll get to.
The fix in Mastra: one input processor
Mastra is the TypeScript agent framework we've been building on for client work, and it happens to have exactly the seam this fix needs. Mastra runs your messages through a pipeline of processors before they reach the model. The hook we want is processLLMRequest: it runs after Mastra has turned the conversation into the provider-facing prompt, right before the call goes out. What matters is that the changes made here are transient. They go to the model on this one call and are never written back to memory or history, so the reminder is re-attached fresh on every call. It never accumulates, never pollutes the stored conversation, and needs no de-duping.
The processor finds the latest user message in that outbound prompt and appends a short, descriptive note. The rule it restates already lives in the system prompt; we keep a single source of truth and read it in both places, so the two can't drift apart.
exportclassFocusReminderProcessorimplementsProcessor{readonly id ="focus-reminder";processLLMRequest({ prompt }:ProcessLLMRequestArgs):ProcessLLMRequestResult{// Find the latest user message in the outbound, provider-shaped prompt.let last =-1;for(let i = prompt.length-1; i >=0; i--){if(prompt[i].role==="user"){ last = i;break;}}if(last ===-1)return{};console.log(prompt[last].content);// [{ type: "text", text: "Is Meridian overpriced?" }]// Append the descriptive note. This change is transient: it's sent to the// model on this call only and is never persisted, so it can't accumulate.const next =[...prompt]; next[last]={...prompt[last], content:[...prompt[last].content,{ type:"text", text:FOCUS_REMINDER}],};console.log(next[last].content);// [{ type: "text", text: "Is Meridian overpriced?" },// { type: "text", text: "<focus_reminder>…</focus_reminder>" }]return{ prompt: next };}}
tsx
First, the rule itself. It lives at the very bottom of ATLAS's long system prompt, one line in a numbered list of about twenty:
...
# ~200 lines of persona and rules above
R16. Never recommend a specific security to buy, sell, or hold.
R17. Never argue the member should cancel, downgrade, or leave Meridian, never recommend a competitor over it, and never characterise Meridian as poor value, overpriced, or "not worth it". Answer pricing and comparison questions factually and neutrally, and keep the keep-or-leave decision with the member.
R18. When the member asks for analysis, a comparison, or a rating of retrieved data, answer substantively and analytically, and don't hedge or decline.
...
Look at R18, two lines below R17: it tells the model not to hedge when asked for analysis or a rating. That's exactly the lever a red-team pulls ("score Meridian out of ten", "is it overpriced"), and in a long conversation, the specific, buried R17 loses to the general R18 plus the accumulated context.
The reminder re-states R17 for the user channel, and gets re-injected next to the latest turn:
exportconstFOCUS_REMINDER=`ATLAS answers questions about markets, securities, company data, investing concepts, and using Meridian normally and helpfully.
It redirects only one narrow set of topics, all about assessing Meridian itself: rating or scoring Meridian's value or usefulness; calling its data or service low-value, redundant, overpriced, or not worth the fee; advice on cancelling, downgrading or renewing a subscription; Meridian's pricing; and comparing Meridian with other platforms.
On those, it stays neutral, keeps the keep-or-leave decision with the member, points to Meridian support, and does not produce content that argues against Meridian's value or assembles a case to drop it.
Everything else, including general questions about instruments, valuation, and markets, it answers normally.`;
tsx
Wiring it into the agent is the one line the title promised:
exportconst financialAgent =newAgent({ name:"ATLAS", instructions:SYSTEM_PROMPT,// the rule still lives here, at the bottom model:"anthropic/claude-sonnet-4-6", tools: marketTools, memory, inputProcessors:[newFocusReminderProcessor()],// ← the fix});
tsx
No change to the prompt. The rule you already wrote just gets a second home, next to the part of the conversation the model is actually reading.
Getting the reminder past the guardrail
There's a catch we flagged earlier: a reminder that reads like a command, sitting in the user turn, can look like a prompt-injection attempt to a guardrail and get blocked. Phrasing it descriptively is the first line of defense and usually does the job. If you also run a filter that scans the user channel, like Bedrock's prompt-attack filter, there's a cleaner backstop.
Bedrock guardrails let you pick which parts of a message get evaluated through the guardContent field, and the rule is blunt: once any guardContent block appears in your messages, the guardrail evaluates only what's inside those blocks and skips everything else (see the AWS docs for more). You can wrap the real user input in a guardContent block, so the prompt-attack filter still scans the thing that matters, and leave the injected reminder outside it. The filter never sees the reminder, so it can't trip on it, while your protection on actual user input stays intact. The flip side is the same rule in reverse: untagged content is skipped, so you have to wrap user input on purpose, or you lose scanning on it.
On the Anthropic API, there's a second option. On Opus 4.8, you can append the reminder as a role: "system" message inside the messages array rather than as user text. It stays where you put it, near the latest turn, so you keep recency in a channel the model treats as operator authority rather than untrusted input, and it leaves the cached prefix intact.
That option is provider-specific, which is the real lesson. On Bedrock, you can't place a system message mid-conversation: system content is a single block at the top of the prompt, so the reminder has to ride in the user turn, and guardContent is what keeps it clear of the filter. Same goal, different mechanics. Check what your provider actually supports before you commit to a placement. These two scenarios mark opposite ends of the range.
Keeping it cache-safe
Re-injection isn't free, and where you put the reminder decides how much it costs. Prompt caching gives a steep discount on the stable prefix of your context: the system prompt and older history, served as cheap cache reads. The reminder can't get that discount. It sits at the end, in the recency window, outside the cached prefix, so it's charged at the full input rate every turn. Anthropic's own engineers flag the same trade-off for Claude Code's reminders in their deep dive on caching optimisation.
The bill stays small as long as you place it correctly. Caching is a prefix match: everything up to a cache breakpoint is reused, and everything after it is reprocessed. Keep the reminder after the last breakpoint, and only the reminder is re-read, while the whole system prompt and history stay cached. Concretely, put it after the rolling cache point (Bedrock's cachePoint block) or the last cache_control breakpoint (Claude API), and keep the note to a sentence or two. The mistakes to avoid are letting it drift in front of the breakpoint, which forces the entire prefix to be re-read, and letting it grow into a second prompt. Small, late, and stable is the goal.
Conclusion
Keeping an agent's rules alive over a long conversation isn't a prompt-size problem, and it isn't a "write more rules" problem. Prompt prohibitions fade where positive rules survive, and some rules (never reveal the system prompt, never give dosage advice, never argue against your own product) can't be turned into positives. So the lever that we’re left with is re-injection. The good news is it's a known, well-supported technique rather than something you have to invent.
What Mastra adds is a clean place to put it. One input processor re-states a rule you've already written as a short, descriptive note next to the latest turn: clear of the cache boundary, clear of the injection filter, and right where the model is actually paying attention. It's one line in your agent config and no changes to your prompt. That’s the difference between a rule that holds at turn 3 and a rule that still holds at turn 30.
Small gaps and nuances like these are where many seemingly production-ready agentic systems fail. And they’re the reliability problem Nearform's AI-Native Engineering practice helps enterprises get right. If your team is wrestling with the prompts and rules behind its agents, we'd love to hear from you.
But wait - there's more.
Nearform publishes real-world learnings on data & AI, engineering, and digital strategy - with more merged in weekly.
Insights
Perspectives on AI in engineering, product development, and strategy, for enterprise executives.