DEV Community

Guillaume Marchand
Guillaume Marchand

Posted on Originally published at towardsaws.com on

Editing a Markdown corpus without loss, with an Amazon Bedrock AgentCore agent

Context

A 23 KB document in our corpus came back at 1,364 bytes. Every tool call had returned a success, the file was still valid Markdown, and the agent had reported its work done. Nothing in the session said anything was wrong. What had gone was everything the skeleton the agent had just written did not contain.

That is the failure this article is about: an agent writing into a corpus of linked documents can succeed at every write and damage the corpus, and what it removed is only visible at the scale of the whole. It is not specific to one kind of corpus. A documentary workspace where every document cites its neighbours describes an on-call runbook base, a library of contractual clauses, a set of architecture decisions, a regulatory file and a corpus of research notes equally well. The on-call engineer, the lawyer, the technical writer and the screenwriter have the same exposure here: the value is not in the files taken one by one, it is in what they presuppose of one another.

The corpus lives in Markdown for a simple reason. An agent with text tools can read, write and circulate a Markdown file with the same primitives a human uses in an editor. A .docx document requires a zipped binary format and a dedicated library, which few agent stacks carry natively. The same text therefore becomes the source of truth, the reading rendition and the WYSIWYG editing format. Changes across a set of Markdown documents are traceable in a version control tool like Git.

As our corpus grew, we decided to adopt frontMatter and wikilinks. FrontMatter in a Markdown document is a header section placed at the very top of the file, holding structured metadata meant to be read by processing tools rather than displayed in the final rendition. It carries contextual information about the document — the title, the date, the tags, the author, or custom variables such as status. In practice it is a standardised mechanism that separates control data from narrative content, which is what makes automation, reuse and large-scale management of Markdown documents possible in technical or editorial workflows.

That continuity holds in the browser too. We edit these files with the same libraries the agent reads and writes. @mdxeditor/editor handles WYSIWYG editing. react-markdown renders the reading view, with remark-gfm for tables and checkboxes and remark-frontmatter for the YAML frontMatter. mermaid renders the diagrams. The author sees the same document as the agent, with no intermediate conversion.

The customer stake comes down to this: what an agent degrades silently is authored work already produced and already paid for, along with the confidence that made delegating the writing possible. A regression found three weeks later does not cost what a visible error at call time costs — you first have to establish when it started, then what it has touched since.

The problem

An editorial agent writes into a workspace mounted on AgentCore Runtime, versioned on every turn in git through AWS CodeCommit. Two families of tools reach this corpus with different scopes. The editing tools change one section of one file. The corpus tools read the hundreds of documents and answer questions no file-by-file tool can ask.

We hit two distinct failures, months apart. The second was caused by the fix for the first, and it was quieter than the one it repaired. Both remove information the agent cannot see when it looks at an isolated document, which is why they lasted.

First failure: a whole document travels through a tool argument

Our first write tool was the one everybody writes first: a path, a content, a commit message.

The trap is in the signature, not in the body. content is a tool-call argument, so the model does not forward it. It generates it, token by token, out of the same output budget as its visible answer. A 23 KB document is roughly 7,000 tokens of escaped JSON string, to be produced in one go before the call even exists.

When that budget runs out while the arguments are being generated, the call block stays incomplete. A tool call only executes once its block is finished. With no execution, no result comes back. And the agent loop hands the floor back to the model by re-injecting that result. With no result, the loop does not hand back, and the stream ends with no message and no error.

The tool-call event counters show it in one line. A failing turn records five calls started and only four results.

Our system-prompt workaround made things worse. The rule asked the model to write a skeleton first. Then it asked it to fill that skeleton in section by section, through the same tool that replaced the entire file:

The skeleton is short, so writing it succeeds. The filling is a large call, so it does not get through. And write_text on an existing path replaces the whole file.

We had written a destructive primitive. Neither its name, create_or_update, nor its description "Save a deliverable" announced that it could lose work. The system prompt carries no rule of that kind: writing goes through the per-section tools described in the second failure.

The tool, renamed create_document, refuses the dangerous case in its description by naming the replacement tools. A model only chooses well among descriptions that say what a tool destroys.

Second failure: per-section editing wipes the frontMatter

The fix was to move to per-section editing. A tool that changes one section leaves the rest of the file intact. Each call carries only one section, so the arguments stay small and the output-budget wall disappears. That part worked.

It introduced a data loss quieter than the one it repaired. Our parser splits the document on its headings, and the rebuild reassembles the sections.

Everything before the first heading belongs to no section. The frontMatter is therefore absent from the rebuild. The four write tools — update, insert, delete, move — share this parse-and-rebuild pair, and each one deleted the frontMatter of the file it touched.

The fix captures that preamble and re-emits it. The parameter is required, with no default: a caller that omits it deletes the frontMatter silently, which is exactly the failure the parameter exists to prevent.

The solution: a graph of the corpus

In a linked corpus a document is not self-supporting: a runbook presupposes a topology, a clause presupposes a definition, an analysis presupposes a statement of intent. An agent handed a single document produces an answer that contradicts the others, because it cannot see what that document presupposes.

Two signals already exist in the files to make those links explicit to the agent. The frontMatter gives the agent a document’s type and status without reading it: it knows a sheet is locked or in progress before changing it. The [[target]] wikilinks are relations written by the author, not inferred by a model. That is the correlation signal the agent needs in order to gather the relevant context instead of loading everything.

For the author, the graph answers three questions that neither a read-through nor a per-document test asks. What does this document presuppose, and what therefore has to be re-read before changing it. Which documents are load-bearing, meaning the ones where a change propagates furthest.

For the agent, the graph does two things. It gives it the relevant context to gather instead of loading everything, which is the difference between an answer that accounts for what the document presupposes and an answer that contradicts it. And it acts as a guard rail: it detects damage the agent itself caused, at a scale the agent does not look at.

We built a MultiDiGraph from NetworkX that reads both signals across every document and exposes three tools to the agent, plus an audit tool.

  1. document_links returns a document's neighbourhood at depth N — who cites it and who it cites.
  2. document_path returns the shortest chain of links between two documents.
  3. cluster_report measures betweenness centrality around a topic to find the pivot documents.
  4. check_frontmatter audits frontMatter hygiene and reports broken titles, unknown tags, and outgoing links that point nowhere.

The graph therefore serves two purposes:

  • giving the agent the relevant context during an edit,
  • detecting damage at the scale of the corpus.

This is a neuro-symbolic architecture in the ordinary sense of the term: a deep learning model on one side, a symbolic representation and symbolic reasoning on the other, with the tool call as the interface. The symbolic half owes nothing to a model: the relations are not extracted, they are written by the author. The traversal is not learned, it is a shortest path and a betweenness centrality. No step introduces model error, so when the agent is wrong the graph is not a suspect. Many stacks called neuro-symbolic have a symbolic half built by extraction from a model, which offers no such guarantee.

Figure 1 shows where all of this is deployed. Figure 2 shows what is inside the container.

Figure 1. The deployed architecture. The call to the agent goes through neither CloudFront nor API Gateway: the browser reaches the AgentCore data plane directly. A single execution role carries every access, and the partition between authors is not in IAM — which allows the whole aicd-* prefix — but in the code, from the token's sub_._

Figure 2. The software architecture. The SDK provides the model, the conversation window, the turn replay and context offloading; ours are the turn lifecycle, the deterministic closing frame and the tools.

How all of this is wired to Strands

The agent itself fits in six arguments.

Six arguments, two of them plugins the SDK already offers. The SDK brings the model, the conversation window, the replay of a turn that stopped at an announcement, the offloading of bulky results, the persistence of the conversation, and the @tool decorator that turns a Python function into a tool whose description the model reads.

That brevity is the SDK’s doing and not the sign of a minimal agent: each of these arguments carries a whole mechanism. The two sections that follow cover the two plugins, because they are the ones that decide something about the conversation.

The turn replay belongs to the SDK

The problem: the model announces an action — “Let me check the real state before writing” — and its turn ends there, with no tool call. The user sees one sentence and nothing else. In a real session, the person typed “Retry” three times before giving up.

The mechanism that answers this is in the SDK. AfterInvocationEvent carries a resume field which the hooks documentation describes as making the agent re-invoke itself automatically with the supplied input, and the GoalLoop plugin wraps it: it validates the response after each invocation and, if the validator refuses, re-injects its comment as a user message and re-enters the loop, under a max_attempts and a timeout.

What we supply is the validator, and it is a predicate over the turn that has just finished.

The point of this split is not brevity. The second attempt is part of the same run and the same stream: there are no two START/FINISHED pairs to stitch together, no concatenation of answers, and the attempt counter is the SDK’s. The test that matters drives a real Strands agent with a scripted model and checks that the model is called twice — what is measured is the SDK’s mechanism, not our confidence in it.

Bulky research leaves the window

For example, a real working turn reads nineteen sections, two method chapters and three web search results. Every result stays in the context window until the sliding window pushes it out — and what it pushes out first is what came in first, which is often the instruction.

The SDK provides ContextOffloader for this: a tool result that is too bulky goes off to a storage backend, leaves only a preview and a reference in the context, and the agent pulls the whole thing back with retrieve_offloaded_content when it genuinely needs it. The plugin is wired like the previous one.

The default behaviour is to offload every result above the threshold. Adopted as such, it would have recreated the failure this article opens with. get_document_section feeds update_document_section: give the model a thousand-token preview of a section it is in the middle of rewriting, and it rewrites the preview — it truncates the document. The should_offload parameter exists for this, and our predicate is an allowlist, not a denylist. The direction is the heart of the matter: whatever is not in it stays whole, so a tool added later is on the right side by default.

One last trap, and it holds for any list of this kind. _OFFLOADABLE_RESULTS is written in registered names — the ones the model sees. But the @tool decorator accepts an explicit name, so a tool does not always register under the name of its Python function: read_file_tool registers as read_file. Writing the function name there therefore raises no error: the entry designates no tool, and that tool stops being offloaded without anything reporting it. A test compares the set against the registry of the built agent, which is the only way to make that silence audible.

That registry holds forty-four tools: our forty-three, plus retrieve_offloaded_content, which the plugin adds to pull back what left the window.

What stays ours, and why

The workspace lifecycle, and it is not in the agent loop. A turn registry launches the work in a detached task, so that a client disconnect does not cancel it, and a second call on the same session attaches to the first instead of opening a second workspace on the same repository.

That last point is not theoretical, and it is the sharpest illustration of the whole problem. Two concurrent turns shared a directory. The second did a fetch and a reset --hard underneath the first. Eight sections out of eighteen ended up empty — and both tools reported success. Nothing in either turn had failed.

No hook could have prevented it, because the conflict is not about the conversation but about a working directory we chose to mount and clone. Neither Strands nor AgentCore knows that /mnt/workspace/ is a Git clone whose two simultaneous writers destroy each other.

The HTTP layer, on the other hand, belongs to the SDK. AGUIApp provides the routes, the RunAgentInput validation, an encoder built from the request's accept header, a context carrying the Authorization header we take the identity from, and a WebSocket transport into the bargain.

Two details of that wiring look like obstacles and are not. The heartbeat first: a raw SSE comment frame does not pass through an event encoder, but AG-UI defines a CustomEvent carrying a free name and value, so the heartbeat is an event like any other. Then, AGUIApp's generator is consumed by the HTTP response, so a client disconnect cancels it — with no effect here, because the work does not live in that generator: it lives in a task the turn registry has detached, and the generator only relays.

The structuring point fits in one sentence: the turn carries AG-UI events, not encoded frames. The SDK does the encoding, with the request’s encoder.

One constraint resists, and that one was read in the SDK’s source. The task tracking that computes the busy status automatically — add_async_task,complete_async_task, and a /ping that answers HealthyBusy for as long as a task is registered — belongs to BedrockAgentCoreApp, not to AGUIApp: the latter has no trace of that registry and offers only a ping decorator. And BedrockAgentCoreApp does not speak AG-UI. So the choice is between the protocol our interface speaks and the automatic tracking. We keep the protocol and write the ping handler, which fits in one line since the registry already knows whether a turn is in flight.

On that /ping, the documentation carries a warning. The time_of_last_update field is optional, and filling it with the current time on every call signals a continuous state change: the idle timeout then never fires, sessions live to their maximum lifetime and the quota runs out. We do not emit it, which is the recommended conduct.

Our application-level refusals — repository absent, repository not owned by the caller — are RunErrorEvent events in the stream and not HTTP 400 and 403 codes. That is the AG-UI rule, and the protocol contract states it unambiguously: every error is serialised as a RUN_ERROR event, whether it happens before or during the stream. What changes is not the shape but the HTTP code accompanying it — its real code for a connection-level error, 200 once the stream has begun. The client surfaces them instead of retrying them, which is the right behaviour for an authorisation refusal.

The detached work, on the other hand, is not a mechanism the platform provides. The documented pattern is that the developer launches the background task and the SDK merely tracks its health. Our turn registry is therefore the expected shape, not a reinvention. What it adds — a replay buffer for a client that comes back, and the refusal to open a second workspace on the same repository — has no documented equivalent.

The lesson fits in one sentence: “the platform already does it” is verified mechanism by mechanism, in the source, and never layer by layer.

The conversation is a session, and it lives in a table

The conversation is not an object the runtime holds: it is a Strands session. Session management persists agent.messages as the turn goes and restores it on AgentInitializedEvent.

Strands provides four managers — file, S3, repository, snapshot. None of them suits here, because this conversation is read back by a Lambda that serves it to the interface, and not by the agent alone. But the persistence sits behind an interface: SessionRepository, nine methods, which RepositorySessionManager consumes. We write one over DynamoDB, one partition per session:

The index is zero-padded, so lexicographic order is numeric order and the conversation window is a Query bounded by a key range. That is where the implementation counts: the S3 repository the SDK provides lists every object under a prefix and sorts in memory, which grows with the whole conversation. The reads are strongly consistent — the previous turn's writes are seconds old, and an eventually consistent read that misses the last message loses context without saying anything.

That choice has an effect which is the real benefit. A hand-rolled writer called with a single text block persists only that block: the reasoning and the tool calls are lost, whatever its documentation claims. SessionMessage carries a whole Strands Message, so they are persisted, and the interface displays them.

And the client stops carrying the conversation. The bridge only replays RunAgentInput.messages into an agent without a session manager; with one, it forwards the latest user message and leaves the history to Strands.

Two traps are silent, and we fell into both. The first: StrandsAgent treats the agent it is given as a template and rebuilds one per thread. A session manager set on that template is deliberately discarded — otherwise every thread would share one session id — and the SDK requires StrandsAgentConfig.session_manager_provider instead. Our two hundred and sixty-five offline tests passed with the manager ignored; a real turn is what revealed it.

The second: the rebuild forwards every constructor parameter it finds as an attribute on the template, and Strands does not retain plugins. The tool registry, on the other hand, is forwarded. The rebuilt agent would therefore have kept retrieve_offloaded_content visible to the model while the offloading and the replay stopped firing: a behaviour that disappears leaving its tools on display. No test that looks at the tool list sees that.

That leaves AgentCore Memory, which is not a competitor: it carries the cross-session semantic recall, injected into the system prompt, and not a conversation replay.

What AgentCore brings, declared in CDK

AgentCore is a hosting contract: it validates the token, mounts the workspace, relays the stream. Declaring it is enough to say so.

Two details of this declaration cost a whole turn when they are missing. Without request_header_allowlist, AgentCore does not forward the Authorization header and the container fails closed. And mount_path only mounts the workspace at invocation, never at container initialisation, so nothing may read the corpus at import time.

GET /ping answers HealthyBusy for as long as a turn is in flight. Without that, AgentCore judges the session idle during a long silent generation and recycles it mid-write.

Finally, the workspace root travels to the tools through contextvars, but in a mutable dictionary rather than by assignment. Strands runs each tool in its own task, where a ContextVar.set() stays invisible to the parent.

Why not GraphRAG

The author’s corpus changes on every turn, since the agent writes into it, so an ingestion per section edit would be both permanent and billed. And its symbolic half is already written, as said above, so the extraction would have nothing to produce. The graph is built in memory, with no embedding, and sees an edit immediately. GraphRAG remains the right choice on a purchased, frozen corpus: the extraction is paid for once and the embedding gives a semantic search that wikilinks do not allow.

An explicit link weighs less than a shared tag

An explicit link and a shared tag do not say the same thing. A wikilink is the author asserting that two documents belong together. A shared tag is generic and connects otherwise unrelated documents. The graph keeps two edge kinds, with different weights.

The MAX_TAG_GROUP ceiling comes from a measurement. Without it, a cross-cutting tag such as research connected every document to every other and made any distance unusable.

Those four numbers are the part of this article that does not transfer. The two bounds and the 1.0/3.0 weights were calibrated on one corpus of 90 documents, with its own tagging habits and its own ratio of explicit links to shared tags. A base of two thousand runbooks with a dozen tags in circulation will not have the same ceiling, and a corpus where authors tag more than they cite will not have the same weights. What transfers is the shape — two edge kinds, the weaker one bounded so a generic tag cannot form a clique — and the method for setting them, which is to measure when the distances stop being usable. Adopting the constants themselves is the one thing to avoid.

Self-remediation at the end of a turn

The validator from the previous section rests on a threshold, and that threshold comes from a measurement. It requires three simultaneous conditions: the turn called no tool, it wrote nothing, and its answer is under 400 characters. A day of real traffic gave the number. The three faulty announcements were 87, 99 and 108 characters. The shortest legitimate answer with no tool was 3,080.

Under that threshold and with nothing written, the turn is an announcement; above it, it is an answer and we leave it alone.

A symmetric case exists, and that one stays outside: a turn uses a tool, the tool succeeds, and the model hands back without a closing sentence. The user sees a silent screen. The _closing_summary function composes a deterministic closing message from the files that were written, with no second call to the model. It is free and predictable, and it covers the case where the output budget ran out while the tool was finishing its work.

The final split is therefore the one in the yellow frame of Figure 2, and it has a logic. The replay decides something about the conversation, so it belongs in the agent loop and lives in the plugin. The closing frame decides nothing: it builds an AG-UI frame the client expects, out of files already written. It stays in the HTTP layer, where it costs zero model calls.

Results

Per-section editing removes the output-budget wall. The same merge work that used to lose a document now produces a richer one, with as many results as calls.

The graph gives the state of the corpus after repair. We rewrote 15 links across 9 documents to catch up with an unfinished rename, then restored the 8 frontMatter blocks from the Git history.

Two rows of this table need a clarification, because their units have already produced a false alarm. “Citation relations” counts distinct pairs; “Written links” counts the edges, one per wikilink actually written. Both numbers are right, and their gap measures the corpus’s repetition: every extra link is a second mention of a document already cited. The field was originally called wikilinkEdges, a name that admitted neither reading; comparing the tool with the graph gave 469 against 808 and the reasonable conclusion that one of the two was wrong. Establishing that neither was cost a full investigation, for a defect that was in the counter's name and nowhere else.

Conclusion

You now know how to edit a Markdown corpus without loss, with an agent deployed on Amazon Bedrock AgentCore. Never pass a whole document through a tool argument. Edit per section, preserve the frontMatter at every reassembly, and keep a graph of the corpus to see what per-section editing does not show. None of this is specific to a creative corpus: the same architecture holds for runbooks, clauses or architecture decisions, as soon as the documents presuppose one another.

Two pieces of work remain open. A partial section patch, avoiding the retransmission of a long section’s whole body. And passing documents by reference in the other direction: ContextOffloader handles the bulky results coming back into the context, but the work still travels whole to the sub-agents and the MCP servers. It is the same lesson, on a layer we have not revisited yet.

Two things to take away:

  1. A tool argument is generated by the model. It consumes its output budget. A call whose arguments are truncated is not executed, so it does not fail: it disappears.
  2. A tool that replaces a whole file is a destructive primitive. Its name and its description must say so, and it must refuse the dangerous case by naming the replacement tool.

Top comments (0)