Concepts first, then the code
Memory, Context Management, Orchestration
Three terms that get used interchangeably and are not interchangeable. This page builds each one from the problem it solves, then shows exactly where it lives in the Mines Financial codebase — file by file, number by number.
Start here · the two facts that create all three problems
Everything on this page follows from two properties of a language model. Neither is a bug, and neither can be fixed by choosing a better model.
One: the model is stateless. When your program calls a model, it sends some text and gets some text back. The model keeps nothing. The next call knows nothing about the previous one. When ChatGPT appears to remember your earlier message, that is not the model remembering — the application re-sent the whole conversation, invisibly, as part of the new request. Every bit of apparent memory is something the surrounding program built.
Two: the model has a fixed input limit. There is a maximum amount of text it can read in one call, called the context window. And because the model keeps nothing, the entire content you want it to consider must fit inside that limit on every call, and you pay for all of it every time.
Put those together and three separate engineering problems fall out, which is exactly the three topics of this page:
Memory systems
The model keeps nothing, so what do we store outside it, and where? A storage and lifetime problem.
Context management
The window is finite and costly, so of everything we have stored, what goes into this one call? A selection and assembly problem.
Orchestration
One call cannot do multi-step work reliably, so what sequence of calls runs, and who decides? A control-flow problem.
Telling the three apart · the distinction most explanations skip
These three overlap in practice, which is why people blur them. The cleanest way to keep them separate is to ask what each one owns.
| Owns | Time scale | Fails by | |
|---|---|---|---|
| Memory | What we keep, and for how long | Across calls, sessions, weeks | Remembering something wrong, or forever |
| Context management | What we send in one specific call | One call | Sending too much, too little, or in the wrong order |
| Orchestration | Which calls happen, in what order | One request, start to finish | Wrong branch, infinite loop, unobservable failure |
A concrete example using all three at once. A user asks "what about their dividends?".
- Memory supplies the fact that the previous question was about BHP and FY2024. That fact was stored earlier and is being read now.
- Context management decides that the routing model gets only the enriched question and its instructions — not the conversation history, not the retrieved documents, because that model's only job is to classify.
- Orchestration decides that because this question needs report data, the retrieval subgraph runs before the answering step, and that the answering step is skipped entirely if a cached answer already matched.
1 · Memory systems
Storage outside the model, with an explicit lifetime.
The concept · starting from the dumbest version
The naive solution to statelessness is obvious and it is where nearly every project starts: keep the entire conversation in a list, and send the whole list every time. That genuinely works, for a while. It is the right first version.
It breaks in three stages as the conversation grows.
- Cost, first. If turn twenty re-sends all nineteen previous turns, you are paying for the same text twenty times. Cost grows with the square of conversation length, not linearly.
- Quality, second — and this surprises people. Models attend most reliably to the start and end of their input. A crucial figure buried in the middle of a very long input is measurably less likely to be used than the same figure in a short one. So a longer context can produce a worse answer.
- Hard failure, last. Eventually the input exceeds the window and the call errors outright.
So a memory system is what you build once "send everything" stops working. And the central insight is that "memory" is not one thing. It is four different requirements that share a name, with different storage, different lifetimes, and different consequences when they go wrong. Designing them as one component is the most common mistake.
The four kinds · and what each one is for
Working state — memory within a single request
While one request is being processed, the steps need to pass things to each other: the rewritten query, the retrieved documents, the draft answer, the verdicts. This is memory in the same sense a local variable is memory. It exists for one request and then it should be gone.
It gets confused with the other kinds constantly, and the confusion goes both ways: people store long-lived facts here and lose them when the request ends, or push per-request scratch data into a database and never clean it up.
MainState typed dictionary in state.py —
around twenty fields including query,
aggregated_context, needs_retrieval,
guardrails_passed, confidence. The retrieval
subgraph has its own separate RetrievalState, so its internal
working fields never leak into the parent.
Session persistence — surviving a restart
A conversation has to outlive the process handling it. The server restarts, a deployment happens, the user comes back tomorrow — the thread must still be there. So working state has to be written somewhere durable.
The pattern that solves this is checkpointing. It buys something beyond durability, which is often the more valuable half: because every intermediate state was written down, you can reconstruct exactly what the system saw at each step, long after the fact.
graph.py wires a PostgreSQL checkpointer over a connection pool
(max 10, autocommit on). The full state is written
after every node, keyed by thread id. This is also what makes the clarification
pause possible — see orchestration below.
Long-term memory — facts that outlive the session
Things that should be known next week: user preferences, entities under discussion, conclusions previously reached. The agent literature usually splits this three ways — episodic, semantic and procedural — but the engineering question is simpler: what is the key, and what invalidates the entry?
This is the kind with the worst failure mode, because a wrong long-term memory is stated with total confidence and never re-examined.
nodes/memory.py — plus
the answer cache below, which is long-lived but keyed by question rather than
by user.
Cache — not repeating work already done
Strictly this is an optimisation rather than memory, but it is stored, it is read back later, and it can be wrong — so it behaves like memory and needs the same discipline.
An ordinary cache matches exact text, which is useless for questions: "BHP revenue FY2024" and "How much did BHP earn in FY2024?" are different strings asking one thing. A semantic cache compares meaning instead.
memory/semantic.py holds a separate vector collection called
conclusions, distinct from the reports collection of
source documents — so the system can never cite its own past answer as if it
were an annual report. Two tiers: at or above
0.95 similarity return the stored answer and skip
retrieval; between 0.80 and 0.95 run the full
pipeline but pass the old answer in as reference material.
Why the write path matters more than the read path — cache poisoning
A cache of retrieved documents can only be stale. A cache of the system's own answers can be wrong — and once written, it is served confidently to everyone who asks a similar question, faster than a correct answer would be, because it skips the work that would have caught the error.
There is no error and nothing in the logs. The system looks like it is performing unusually well.
So in this project the write is gated: an answer is stored only if the quality grader passed it and the groundedness check did not fail. A low-confidence answer still reaches the user, with its warning, and is never written to memory. The write also happens before the warning text is appended, so what gets cached is the clean answer rather than a stale caveat about a verification run that has nothing to do with the next user's question.
Stated as a rule: the read path is optimistic, the write path is pessimistic. A cache miss costs seconds. A poisoned cache costs a wrong figure about a listed company, repeatedly, until somebody notices.
In this project · four layers, in a fixed order
Every request passes through all four before any retrieval happens, and the order is load-bearing rather than incidental.
| Order | Layer | Where | Key numbers |
|---|---|---|---|
| always on | Durable session state — full state written after every step, keyed by thread | graph.py · PostgreSQL |
pool max 10 |
| 1st | History compression — summarise older messages, keep recent ones verbatim | nodes/compress_context.py |
trigger at 10 msgs, keep 4 |
| 2nd | Entity carry-over — fill a missing company or year from recent messages | nodes/memory.py |
scan 6 msgs |
| 3rd | Semantic answer cache — two-tier lookup against past questions | memory/semantic.py |
0.95 hit · 0.80 seed |
Why that order
Compress first so the entity scan reads a clean, bounded history rather than fifty messages. Enrich second so the cache lookup uses the completed question — looking up "what about their dividends?" would match nothing useful, whereas "dividends [BHP, FY2024]" might. Cache last so that a hit can skip everything downstream, which is the entire point of having it.
One detail worth understanding properly
The cache stores the question as the searchable text, and keeps the answer, the companies, the year and the source list alongside it as metadata. That sounds backwards — the answer is the valuable part — but at lookup time all you have is a question, so storing the answer as the search key would mean comparing a question against an answer.
Those are different shapes of text. A question is short and interrogative; an answer is long, full of figures and qualifications. Their embeddings sit in different regions even when they belong together, so similarity scores become noisy and any threshold you pick becomes meaningless. Storing the question makes it a question-to-question comparison, which is the comparison you actually want.
2 · Context management
Deciding what goes into one specific model call. Often called context engineering.
The concept · the part people assume is trivial
Suppose memory is solved: you have the conversation history, a set of retrieved documents, a cached answer, and a summary of earlier turns. Now you have to make one model call. What do you put in it?
The naive answer is all of it, and it is naive for a reason worth stating precisely. It is not just that you might exceed the limit. It is that irrelevant content actively degrades the answer. Give a model eight documents where two are relevant, and it has to work out which two. Give it the same two alone, and it does not. Extra context is not free even when it fits.
So context management is assembly, not concatenation. And the thing that makes it a real discipline is that it is a decision per call, not per application — different steps in the same pipeline want completely different context.
Context as concatenation
Build one context blob and pass it to every step. Simple, and every step pays for text it does not need while the important part gets diluted. A routing decision does not improve for having seen eight annual-report chunks.
Context as assembly
Each call receives a purpose-built input: only the fields that step needs, at the fidelity that step needs, in a deliberate order, with untrusted content clearly fenced. More code, and every call gets sharper.
How this differs from retrieval — they are neighbours, not the same job
Retrieval finds candidate documents. Context management decides which candidates make it into the call, in what form, and where in the input they sit.
The distinction becomes obvious at the point of failure. If the correct document was never found, that is a retrieval problem — no amount of clever assembly helps. If the correct document was found, ranked fourth, and then truncated away or buried in the middle of a long input where the model under-weighted it, retrieval did its job and context assembly lost the answer.
They also need separate measurement, which is why this project measures retrieval on its own — did a document containing the known figure arrive, and at what rank — rather than inferring retrieval quality from the final answer.
The five jobs · what context management actually consists of
Selection — what is relevant to this step
The narrowest useful input is usually right. A classifier needs the question and its rules. A verifier needs the draft and the evidence. Giving either one the other's context makes it worse, not more informed.
Ordering — position changes behaviour
Attention is not uniform across a long input; the beginning and end are used more reliably than the middle. So ordering is a real decision: instructions and the current question at the edges, bulk evidence in between.
Compression — deliberately losing detail
Summarising ten messages into two sentences is lossy on purpose. Which means the summarising instruction must name what to preserve, or the model decides for you and drops the part you needed.
Isolation — fencing content by trust level
Your instructions and text extracted from a third-party PDF end up in the same input. The model has no built-in notion of which is authoritative. Labelling and fencing the untrusted part is what stops a document being read as an instruction — the risk called prompt injection.
Budgeting — an explicit priority order
When space runs short, something must go. Deciding that order in advance — and writing it down — is the difference between a designed system and one where whatever the code appended last happens to win.
In this project · nine calls, nine different contexts
This is the part of the project I would point at if someone asked what context engineering looks like in practice. A single request can involve up to nine model calls, and no two receive the same context. Each one gets the narrowest input that lets it do its job.
Three cross-cutting decisions first, because they explain the table below:
- Two model clients, deliberately.
utils/llm.pyis capped at 1,000 output tokens and used for routing, grading and rewriting.utils/llm_large.pyallows 5,000 and is used only for synthesis and the final answer. A routing decision cannot accidentally be budgeted like a synthesis call. Both run at temperature 0. - Different fidelity for different jobs. Relevance grading sees each document truncated to 1,200 characters — enough to judge topic, cheap across twenty candidates. Synthesis sees full content, because a figure could be anywhere in a table. Same documents, two fidelities, chosen per purpose.
- Structure is added, not hoped for. Retrieved documents are converted to
plain dictionaries with company, year, page and source type promoted to top-level
keys, so every step formats headers like
[3] FMG FY2024, p.39 [table]consistently — and the model can attribute a claim to a company without inferring it.
Every model call, and what it is given
| Call | Receives | Deliberately does not receive | Why |
|---|---|---|---|
| compress_context | Older messages, formatted by role, plus an instruction naming what to keep: companies, metrics, fiscal years, conclusions | Documents, retrieval state | Compression is lossy, so the instruction has to name the survivors |
| retrieve_decision | Routing rules, seven worked examples, and the enriched question alone | The conversation history; any documents | It is a classifier. History would add cost and drift without improving the four booleans it returns |
| query_rewrite | Rewriting rules, five examples, the question | History; documents | Turning a question into search keywords needs the question, not the past |
| grade_docs | The rewritten query plus every candidate, each truncated to 1,200 chars and labelled with index, company, year, page and a table marker | Full document text; the draft answer | Judging relevance needs breadth across candidates, not depth in each |
| synthesize | Graded documents at full length with numbered company headers, the question, and any cached answer explicitly labelled "for reference only" | Rejected candidates; news; conversation history | Drafting needs complete evidence, and the label stops reference material being treated as a source |
| grade_answer | Question, graded documents with an injected unit annotation for tables, and the draft | History; news; rejected candidates | Verification must compare the draft against exactly the evidence it was written from — no more, no less |
| aggregate → calc? | Question and the merged context | Raw documents | Whether arithmetic is needed depends on the numbers actually retrieved, not on how the question was worded |
| calculator agent | Question, merged context, and its own growing tool-call transcript | Raw documents; history | It works only on numbers already present in the context, by instruction |
| answer | Section-tagged merged context, plus a system prompt assembled at runtime: base rules, then a citation block containing the numbered source list, then a chart block — each included only if it applies | Raw documents; the rejected candidates | The instruction set matches what is actually available, so the model is never told to cite sources that do not exist |
Isolation, concretely
Content of different trust levels is fenced with visible markers rather than merged.
The merged context uses bracket headers — 【Annual Report Data】,
【Recent News】, 【Calculation Result】 — and the final
prompt instructs the model to prefer report data and to note the discrepancy if the
sections disagree. Cached answers carry "for reference only". Each document carries its
own company header so a figure cannot drift between companies.
3 · Orchestration
Deciding which steps run, in what order, and who makes that decision.
The concept · two naive versions, failing in opposite directions
A question like "compare BHP and Rio on FY2024 revenue growth" cannot be one model call. It needs: work out what is being asked, turn it into searches, search for two companies, judge what came back, draft, verify, compute a growth rate, format with citations. Multi-step work needs something coordinating the steps — that is orchestration.
There are two obvious ways to do it, and they fail in opposite directions.
Naive version A — one giant prompt
Put all the instructions in one call and hope. No branching, so a vague question and a precise one take the same path. No retry, because there is no step to retry. And when the answer is wrong you cannot tell which part of the reasoning failed — there are no parts.
Naive version B — a fully autonomous loop
Give the model tools and let it decide everything, repeatedly, until it stops. Maximum flexibility, and: unbounded cost, a different path every run so nothing is reproducible, no way to unit-test a decision, and an execution trace that is different every time you look at an incident.
Real systems sit between these, and the choice is per component, not per application. That is the single most useful idea in this section. Steps whose order you know go in a fixed graph; steps whose order genuinely depends on intermediate results get a bounded loop.
The spectrum · three levels of who decides
Fixed chain — the code decides everything
Step A, then B, then C, always. Completely predictable, trivial to test, and incapable of adapting. Correct whenever you actually do know the order.
Graph with conditional edges — the code decides, using model output
Steps are nodes; the connections between them are chosen at runtime by plain functions reading state. A model may have produced the value being read, but the branch is taken by ordinary code.
This is the sweet spot for most production work, and the reason is worth being explicit about: the model contributes a judgement, the code retains control of the flow. You get adaptivity, and the routing stays readable, testable and identical every time for the same state.
Agent loop — the model decides, within limits
The model repeatedly chooses a tool, sees the result, and chooses again until done — commonly a ReAct loop. Right when the number and order of steps genuinely cannot be known in advance. Always needs an iteration bound.
The building blocks · vocabulary you need to read the code
- State. One typed object every node reads from and writes to. Nodes return only the fields they changed, and the framework merges them in — so a node cannot accidentally clobber a field it never touched.
- Node. One step. A plain function: state in, partial update out.
- Edge and conditional edge. A fixed connection, or a function that reads state and returns which node comes next.
- Reducer. The rule for combining writes to the same field from parallel branches. The default is overwrite — which silently loses data when branches run concurrently, so lists usually need to append and dictionaries to merge.
- Fan-out and fan-in. Split one request into parallel branches, then merge. The same shape as map-reduce.
- Subgraph. A graph used as a single node inside a bigger one, with its own private state — so its internals stay out of the parent.
- Checkpoint and interrupt. Persisting state after each step, which is what makes it possible to stop mid-run, wait for a human, and resume in place.
- Supervisor pattern. A top-level graph that dispatches to specialist sub-agents and merges their results, rather than one agent doing everything.
In this project · all three levels at once
The architecture is a supervisor graph of 11 nodes, containing a retrieval subgraph of 5 nodes, containing a ReAct agent for arithmetic. So all three points on the spectrum appear in one system, each chosen for a different reason.
| Level | Where | Why there |
|---|---|---|
| Fixed chain | compress_context → memory; and every path into
guardrails before answer |
The order is genuinely known and must not vary. Putting the guard on the boundary means a future branch inherits it automatically |
| Conditional graph | Four routing functions in graph.py — after memory, after the
routing decision, after aggregation, after guardrails |
The path depends on the question, but the decision must be reproducible and testable. Every one is plain Python reading state |
| Agent loop | agents/calculator_agent.py |
How many arithmetic steps a question needs — normalise units, then subtract, then divide — genuinely cannot be known in advance |
The separation that makes it work
The routing model does not choose the next node. It returns four booleans —
is_out_of_scope, needs_retrieval, needs_news,
needs_clarification — into state. A plain function then reads those and
decides the branch.
That separation is the whole design. The model contributes the judgement it is good
at, which is understanding what a question is asking. The code keeps control of the
flow, which means the routing is a function you can unit-test with a dictionary and no
network — and there are tests in tests/test_graph_routing.py doing exactly
that.
Parallel fan-out, and why it needs explicit merge rules
A comparison question is decomposed into one search per company, and the graph dispatches one retrieval branch per company to run concurrently. Two things then have to be stated explicitly, because the defaults are wrong for concurrency:
- Retrieved documents accumulate by list concatenation rather than overwrite, so five branches produce a combined pool instead of only the last one's results.
- The per-company found flags merge as dictionaries via a small custom reducer, so branches write their own key without erasing the others. With the default overwrite, the last branch to finish would delete every other company's status — a bug that would appear only under concurrency and only sometimes.
On retry, the fan-out naturally targets only the still-missing companies, because the rewrite step has already narrowed the list of queries.
Bounded loops
The retrieval subgraph can loop: if graded documents are unusable, or the draft fails, or two or more claims came back unverified, it goes back to query rewriting. Three constraints keep that safe. A counter in state caps it at 2 attempts. Every retry must change its input — the failed claim labels become additional search terms — because a retry with identical input only spends money. And exhausting the retries is a defined outcome routing to the fallback response, not an error.
Two smaller decisions that are easy to miss
- The subgraph declares an output schema, so only the finished
retrieval_resultcrosses back into the parent state. Its dozen internal working fields — retry counts, ungraded candidates, per-company queries — stay private. That is state isolation as a design choice rather than a naming convention. - The clarification pause is durable. The clarify node interrupts execution and returns the question to the caller; the API resumes with the user's reply against the same thread, and the node merges the reply into the original question before routing again. It works only because state was already checkpointed after every step — the pause is simply the absence of a next step. Without persistence, "ask the user something" is a dead end whenever the process restarts.
One request, end to end
The same question traced through all eleven steps, marking which of the three topics is doing the work at each point. This is the section to re-read if the three still feel blurred.
| Step | What happens | Doing the work |
|---|---|---|
| 0 · load | Thread state is read back from PostgreSQL, including turn one's messages | Memory — session persistence |
| 1 · compress_context | Four messages, under the threshold of ten, so nothing is compressed | Context — compression, declining to act |
| 2 · memory | No company or year in the question. Scanning back six messages finds BHP and
FY2024, so the query becomes What about their dividends? [BHP, FY2024] |
Memory — entity carry-over |
| 3 · memory (cache) | The enriched query is embedded and compared with past questions. Best match scores 0.71 — below both tiers, so no hit and no seed | Memory — semantic cache |
| 4 · route | A plain function reads cache_hit. False, so continue to the
routing decision rather than jumping to the answer |
Orchestration — conditional edge |
| 5 · retrieve_decision | The model receives the rules, seven examples, and the enriched question only. Returns needs_retrieval true, everything else false | Context — selection · Orchestration — the model judges, not routes |
| 6 · route | Code reads the four booleans and dispatches one parallel branch to the retrieval subgraph | Orchestration — fan-out |
| 7 · query_rewrite | Becomes BHP FY2024 dividends, companies ["BHP"],
and the per-company found flag is initialised to false |
Context — reshaping input for retrieval |
| 8 · retrieve_company | Keyword and vector search each return 20 candidates from BHP's own indexes; fused, then reranked by a model that reads the text, down to 8 | Retrieval — with orchestration owning the per-company branch |
| 9 · grade_docs | Each candidate truncated to 1,200 characters for judging. Irrelevant ones dropped; the found flag is corrected to true because graded documents exist | Context — fidelity choice · Guardrail — relevance filter |
| 10 · synthesize | Surviving documents at full length with numbered company headers. A draft is
written with [N] markers |
Context — assembly at full fidelity |
| 11 · grade_answer | Draft plus the same documents, now with unit annotations injected for tables. Zero unsupported claims, so grounded = yes and confidence = high | Context — injected annotation · Guardrail — groundedness |
| 12 · aggregate | Draft wrapped under 【Annual Report Data】. A narrow model call
decides no arithmetic is needed |
Context — fencing · Orchestration — branch data |
| 13 · guardrails → answer | Deterministic length check passes. The final prompt is assembled with the citation block and its numbered source list; uncited sources are then removed and markers renumbered | Orchestration — boundary gate · Context — runtime prompt assembly |
| 14 · save | Grade passed and groundedness is not "no", so the clean answer is written to the cache — before the confidence note would have been appended. State is checkpointed | Memory — gated write, decided by the evaluator |
Glossary
Every term used above, in plain words.
Unfold all definitions — 26 terms
- Stateless
- The model keeps nothing between calls. Every appearance of memory is the surrounding program re-sending earlier text.
- Token
- The unit models read and are billed in — very roughly three-quarters of a word. Limits and prices are expressed in tokens, not characters.
- Context window
- The maximum tokens a model can read in one call. A hard ceiling, and everything inside it is re-charged on every call.
- Prompt
- The text sent to the model. Usually split into a system prompt (instructions and rules) and a user message (the actual input).
- Working state
- Data passed between steps of a single request. Memory in the sense a local variable is memory — it should not outlive the request.
- Checkpointing
- Writing the full state to durable storage after every step, so a run survives a restart and can be replayed exactly afterwards.
- Thread
- One conversation, identified by an id. All of its checkpoints share that id.
- Episodic / semantic / procedural memory
- Past events, general facts, and learned procedures. A useful taxonomy borrowed from cognitive science; most systems implement only one or two.
- Embedding
- A list of numbers representing the meaning of a piece of text. Similar meaning gives numerically nearby lists, which is what makes meaning-based search possible.
- Vector store
- Storage that quickly finds the embeddings nearest to a query embedding.
- Semantic cache
- Reusing a past answer when the new question means the same thing, judged by embedding similarity rather than exact text match.
- TTL — time to live
- How long a stored item stays valid before it must be discarded or recomputed. Missing from this project's cache, which is a real gap.
- Cache poisoning
- A wrong entry being written, then served confidently and repeatedly. The reason a cache of your own output needs a gated write path.
- Summary buffer
- Keeping recent messages verbatim and replacing older ones with a generated summary. Preserves follow-up references that a plain sliding window destroys.
- Coreference
- A phrase pointing at something named earlier — "their dividends", "that one". Search cannot resolve it, because the entity is absent from the text.
- Context engineering
- Deliberately choosing what goes into each model call, in what order and at what fidelity. Prompt engineering is one part of it.
- Lost in the middle
- The observed effect that models use the beginning and end of a long input more reliably than the middle. The reason ordering is a design decision.
- Prompt injection
- Text inside retrieved content that the model reads as an instruction. Matters because retrieved documents sit next to your own instructions in one input.
- Orchestration
- Coordinating multiple model calls and tools into one flow: which step runs, in what order, with what retries.
- Node / edge / conditional edge
- A step; a fixed connection between steps; and a function that reads state to decide which step comes next.
- Reducer
- The rule for combining writes to the same state field from parallel branches. The default overwrite silently loses data under concurrency.
- Fan-out / fan-in
- Splitting a request into parallel branches and merging the results. Same shape as map-reduce.
- Subgraph
- A graph used as one node inside a larger graph, with its own private state.
- Interrupt / resume
- Pausing execution to wait for a human, then continuing from the same point. Durable only because state was already checkpointed.
- ReAct loop
- The model alternates between reasoning and calling a tool, feeding each result back, until it has an answer. Needs an iteration bound.
- Supervisor pattern
- A top-level graph that dispatches to specialist sub-agents and merges their output, instead of one agent doing everything.