
A Developer’s Guide to Building RAG Systems: Lessons from the Trenches
June 27, 2026Partner Blog | From planning to execution: FY27 priorities for cloud and AI partners
June 28, 2026Part 4 of 5.
If you publish an agent project today, you are caught between two forces. The interesting behaviour lives in the cloud – Foundry models, Foundry IQ retrieval, the Agent Service memory store. But the audience that decides whether your project matters – judges, students, engineers skimming GitHub at midnight – will give it exactly one git clone and five minutes. If the first run dies on a missing credential, the project is dead to them.
This is the same tension Lee Stott opens his Hybrid AI Agents in Python post with – privacy, latency, and frontier capability pulling in different directions – and his answer is the one we adopted: route between tiers behind one contract. Every cloud arrow gets a keyless fallback with the same schema and code path.
This post covers both halves of making that real and credible: the routing that lets the game run on your own machine, and the trace that lets you see exactly which path served every request.
Forkability is reliability, demoed on someone else’s machine. Observability is what lets them believe what they just saw.
The contract: one result, many paths
The caller never knows which path served a request. run_maf_agent tries the Foundry path and degrades inside the same function; the caller can only tell the difference by reading a logged field, never by branching on an API.
FoundryChatClient, gpt-5 family”]
MAF –>|tier 2: local| OLL[“Local model
Ollama / Foundry Local, OpenAI-compatible /v1″]
MAF -.->|tier 3: floor| SIM[“Deterministic simulation”]
RET[“Recall tool”] ==>|live| IQ[“Foundry IQ – cited”]
RET -.->|no creds| KB[“knowledge/*.md”]
MEM[“Memory”] ==>|live| MS[“Agent Service store”]
MEM -.->|no creds| JSON[“state/memory.json”]
–>
| Capability | Tier 1 (cloud) | Tier 2 (local) | Tier 3 (floor) |
|---|---|---|---|
| Reasoning | FoundryChatClient, gpt-5 family |
Ollama / Foundry Local, OpenAI-compatible | Deterministic simulation |
| Retrieval | Foundry IQ, citations | local markdown | local markdown |
| Memory | Agent Service store | state/memory.json |
state/memory.json |
| Auth | DefaultAzureCredential (keyless) |
none | none |
The last column is the design insight: the truth layer – validators, the rubric floor, the human gate (Part 2) – never lived in the cloud. It is local code in every tier. Fallbacks only have to cover creativity, never correctness.
Where the tiers are decided
The whole routing policy lives in one file, submission/agents/model_config.py, and resolves to a single label through runtime_mode(). Two environment variables decide everything: DEMO_MODE (simulation / local / live) and AGENT_ROUTING (local_first / cloud_first / local_only / cloud_only). From those, runtime_mode() returns one of four words the rest of the app reads:
# submission/agents/model_config.py
def runtime_mode():
local_ready = _local_runtime_enabled()
cloud_ready = _cloud_runtime_enabled()
if local_ready and cloud_ready and AGENT_ROUTING != “local_only”:
return “hybrid”
if local_ready:
return “local”
if cloud_ready:
return “live”
return “simulation”
The agents never check DEMO_MODE themselves. They call get_foundry_client(), and in any routing mode that is not cloud_first that helper hands back the local OpenAI-compatible client when one is configured, falling through to the cloud Foundry client otherwise. One function owns the decision; everything downstream just asks for “the client” and gets the right one. That is the difference between a routing policy and routing littered through call sites.
create_chat_completion: resilience the caller never sees
The direct (non-Agent-Framework) path has its own contract, create_chat_completion in model_config.py, and it absorbs four separate failure modes so callers do not have to. Its docstring is the spec:
# submission/agents/model_config.py
def create_chat_completion(deployment, messages, *, max_completion_tokens=8000,
response_format=None, temperature=None):
“””Run a chat completion with automatic resilience, returning the response.
1. Local-first routing – try the configured local model before cloud.
2. Cloud fallback – if local errors, retry on the role’s cloud
deployment, then FOUNDRY_FALLBACK_MODEL.
3. Temperature retry – gpt-5.x deployments reject a non-default
temperature; drop it and retry the same model.
4. JSON-mode retry – some local runtimes do not support
response_format; drop it and retry.
“””
Each of those is a real bruise from running a hybrid stack. Local model down? Cloud takes over. Primary cloud deployment throttled? FOUNDRY_FALLBACK_MODEL catches it. Asked a gpt-5.x deployment for a custom temperature and it refused? Retry without it instead of failing the turn. A local runtime that has never heard of response_format? Drop the JSON-mode flag and retry. The caller writes one line and the four contingencies are handled beneath it – with the original exception re-raised only if every candidate fails, so the existing mock fallback is still the last net.
The fallback is a receipt, not a silence
Tier 1 to tier 2 is not a branch the caller takes; it is a degradation that happens inside run_maf_agent and leaves a paper trail. The function tries the Foundry path, and on any failure – an RBAC gap, a region without the model, preview flux – it records why before dropping to the compatibility client:
# submission/agents/maf_runtime.py
try:
text = loop.run_until_complete(_run(True)) # tier 1: FoundryChatClient
_FOUNDRY_PATH_OK = True
return text, meta
except Exception as e:
_FOUNDRY_PATH_OK = False
_FOUNDRY_FALLBACK_REASON = f”FoundryChatClient {type(e).__name__}: {str(e)[:200]}”
meta[“maf_fallback_reason”] = _FOUNDRY_FALLBACK_REASON # rides into the replay log
meta[“maf_memory”].clear(); meta[“maf_tools_called”].clear()
text = loop.run_until_complete(_run(False)) # tier 2: OpenAIChatClient
return text, meta
Two details make this trustworthy. First, maf_fallback_reason is a field on the result, so the exact reason the run left Foundry shows up in the receipts panel and the replay log – never a silent downgrade. Second, the module remembers the failure in _FOUNDRY_PATH_OK so a whole demo does not re-pay a 10-second RBAC timeout on every single invocation; the first failure routes the rest of the session straight to tier 2.
[DIAGRAM – render this mermaid to PNG and upload here]
Upload: submission/private/blogs/images/series_04_fallback_ladder.png
T1{“Foundry endpoint
configured & OK?”}
T1 –>|yes| RUN1[“_run(True)
FoundryChatClient”]
RUN1 –>|success| OK[“return text + meta”]
RUN1 –>|exception| REC[“record maf_fallback_reason
set _FOUNDRY_PATH_OK=False”]
T1 –>|no| REC
REC –> RUN2[“_run(False)
OpenAIChatClient (local/compat)”]
RUN2 –> OK
OK –> LOG[“replay log shows
which tier served the run”]
–>
The clients, built per tier
What actually swaps when the tier changes is the client object. The Foundry tier builds a FoundryChatClient authenticated with DefaultAzureCredential – keyless, the recommended posture – pointed at the project endpoint; the compatibility tier builds an OpenAIChatClient against whatever OpenAI-compatible base URL is configured (the cloud resource’s /openai/v1, or a local Ollama / Foundry Local server):
# submission/agents/maf_runtime.py – inside _run()
if use_foundry:
from agent_framework.foundry import FoundryChatClient
client = FoundryChatClient(project_endpoint=foundry_project_endpoint(),
model=deployment, credential=_aad_credential())
meta[“maf_client”] = “FoundryChatClient”
else:
client = OpenAIChatClient(model=deployment, api_key=api_key, base_url=base_url)
meta[“maf_client”] = “OpenAIChatClient”
The maf_client field is set right here, which is why the receipts panel (Part 2) can name the exact client that served a run. Two clients, one Agent built around whichever one you got – the tier is visible in the receipt and nowhere else in the code.
The settings console: routing you can change at runtime
You do not have to edit .env and restart to change tiers. The game ships a settings console, reachable from any run, whose first tab is the local-model runtime.
The Local LLM settings tab: base URL, model, a routing dropdown with four modes, and a per-role runtime map
Four status pills tell you where you stand at a glance – runtime, routing, whether a local model is connected, and whether foundry is on standby. Below that:
- OpenAI-compatible base URL –
http://localhost:11434/v1for Ollama out of the box. - Model – whatever
ollama listshows; we test with small local models. - Routing – a dropdown with all four modes:
local_first,cloud_first,local_only,cloud_only. - Runtime map – each role (narrator, orgdesigner, antagonist, strategist, designer, marketer, ops, npc) bound to a model, “inherits default” until you override it.
- Copy env / Copy test – generate the exact
.envblock and a one-line smoke-test command.
The default posture is local-first: a local agent handles normal gameplay, cloud Foundry is the escalation layer for failed local turns, integration evidence, and demo-quality reasoning. That keeps the game cheap to play repeatedly while preserving the Foundry path the rubric requires.
<PRE class="language-env" tabindex="0" contenteditable="false" data-lia-code-value="# Local play profile – normal gameplay on your own model
DEMO_MODE=local
AGENT_ROUTING=local_first
LOCAL_AGENT_BASE_URL=http://localhost:11434/v1
LOCAL_AGENT_API_KEY=ollama
LOCAL_AGENT_MODEL=
“>
# Local play profile – normal gameplay on your own model
DEMO_MODE=local
AGENT_ROUTING=local_first
LOCAL_AGENT_BASE_URL=http://localhost:11434/v1
LOCAL_AGENT_API_KEY=ollama
LOCAL_AGENT_MODEL=
Ollama is the lowest-friction local runtime because it is OpenAI-compatible. The app never gets an Ollama-specific branch; it points at LOCAL_AGENT_BASE_URL. That keeps Foundry Local and a llama.cpp server swappable as long as they expose an OpenAI-compatible /v1 endpoint – the planned upgrade from a generic local model to a pinned Foundry Local deployment is one more entry in the routing table, not a rewrite.
A gotcha we inherit straight from Lee Stott’s post: gpt-5 and o-series deployments reject max_tokens and require max_completion_tokens, and reasoning-tuned local models wrap output in blocks that break a naive JSON parser – so the lightweight local tier wants a non-reasoning model, and everything that must emit JSON goes through a tolerant extractor.
Per-role routing: not one model, a fleet
Routing is not a single global switch; it is per role. model_config.py carries two parallel tables – _CLOUD_AGENT_MODELS and _LOCAL_AGENT_MODELS – keyed by the eight agent roles (narrator, orgdesigner, antagonist, strategist, designer, marketer, ops, npc), each reading its own environment variable with a sensible default chain:
# submission/agents/model_config.py
_CLOUD_AGENT_MODELS = {
“narrator”: AgentModel(“narrator”, os.getenv(“NARRATOR_MODEL”, “”)),
“orgdesigner”: AgentModel(“orgdesigner”, os.getenv(“ORG_DESIGNER_MODEL”, os.getenv(“NARRATOR_MODEL”, “”))),
“antagonist”: AgentModel(“antagonist”, os.getenv(“ANTAGONIST_MODEL”, os.getenv(“NARRATOR_MODEL”, “”))),
“strategist”: AgentModel(“strategist”, os.getenv(“STRATEGIST_MODEL”, “”)),
# … designer, marketer, ops, npc …
}
model_for(role) resolves the right deployment for whichever tier is active. This is why the settings console can show a per-role runtime map: the narrator can run on a frontier reasoning deployment in the cloud while the fast NPC chatter runs on a small local model, all in the same session. The default chains (ORG_DESIGNER_MODEL falls back to NARRATOR_MODEL) mean you can wire up one strong deployment and have the whole reasoning core inherit it, then peel off individual roles onto cheaper models as you tune cost.
flowchart LR
ROLE["role: narrator / orgdesigner /
strategist / … / npc”] –> MF[“model_for(role)”]
MF –> RT{“active runtime?”}
RT –>|local / hybrid| LM[“_LOCAL_AGENT_MODELS[role]
OpenAI-compatible deployment”]
RT –>|live| CM[“_CLOUD_AGENT_MODELS[role]
Foundry deployment”]
RT –>|simulation| SIM[“deterministic output”]
–>
Bounded by default, so a hang is a fast error
A hybrid system has more ways to stall than a single-model one, so every model call is bounded. AGENT_REQUEST_TIMEOUT (45s) and AGENT_MAX_RETRIES (1) cap the OpenAI client instead of inheriting the SDK default of 600 seconds plus two retries. The reason is concrete and was learned the hard way: a blocked /api/founder/analyze is exactly what leaves the onboarding console stuck on “Reasoning” with no way forward. A slow upstream should surface as a quick, recoverable error, not a frozen page. There is also a FOUNDRY_FALLBACK_MODEL for the narrower case of a primary deployment returning HTTP 429 mid-demo – a second deployment with quota headroom to retry on, set by env, no code change.
One agent, three clients
Here is the payoff of routing behind one contract: the agent is identical in every tier. Whether run_maf_agent ends up on FoundryChatClient or the compatibility OpenAIChatClient, it builds the same Agent with the same instructions, the same capped FunctionTools (Part 2), and the same CampaignMemory ContextProvider (Part 3):
# submission/agents/maf_runtime.py – inside _run()
agent = Agent(
client=client, # the ONLY thing the tier swaps
name=name,
instructions=instructions,
tools=[_wrap(n, f) for n, f in (tool_fns or {}).items()] or None,
context_providers=[CampaignMemory()],
)
resp = await agent.run(prompt)
The tier is a single constructor argument. Tools, memory injection, the prompt, the proof-point capture – none of it knows or cares which model is behind the client. That is what makes the fallback safe: dropping from Foundry to a local model cannot silently change what the agent is allowed to do, only which brain answers. Capability is constant; only creativity varies by tier.
The replay log: every action is a traced event
Routing is only trustworthy if you can see which path ran. That is what the rest of the console is for. The Backend replay tab is the system’s flight recorder: every action emits a typed, timestamped, actor-attributed event with an inspectable payload.
The Backend replay tab: a scroll of typed events – CEO_DECISION, CONSEQUENCE_APPLIED, ANTAGONIST_MOVE, WORLD_ADAPTED, WORLD_COUNCIL, KNOWLEDGE_STRUCTURED – each with a payload you can open
One CEO decision produces an entire causal chain you can read end to end:
| Event | Actor | What it proves |
|---|---|---|
CEO_DECISION |
founder | The human chose “Depth” at the NEED gate |
CONSEQUENCE_APPLIED |
system | That choice deterministically changed company state |
MEMORY_WRITTEN |
memory | The decision was stored as binding memory (Part 3) |
WORLD_ADAPTED |
world_designer | The designer bent 6 pending stages to the choice |
ANTAGONIST_MOVE |
antagonist | The rival reacted to the same signal |
WORLD_COUNCIL |
world_council | The Game Masters ratified the next stage |
KNOWLEDGE_STRUCTURED |
iq_sync | Search documents were re-structured after the choice |
CON –> MEM[“MEMORY_WRITTEN (memory)”]
CON –> ADA[“WORLD_ADAPTED (world_designer)”]
CON –> ANT[“ANTAGONIST_MOVE (antagonist)”]
ADA –> COU[“WORLD_COUNCIL (world_council)”]
COU –> KNO[“KNOWLEDGE_STRUCTURED (iq_sync)”]
KNO –> READ[“one decision, one readable causal chain”]
–>
Each row carries a payload – some hundreds of characters, some thousands – that you click to expand into the raw data. Nothing happens off-book. When someone asks “did the agents actually do anything?”, the answer is this scroll, not a claim.
Every one of those rows comes from a single call – store.log_event(event_type, actor, message, payload) – so adding a new traced action is one line, and the shape is always the same: a type, who did it, a human-readable message, and a structured payload you can expand. Uniform events are what make the log greppable and filterable instead of a wall of free text, and they are why a fork running with zero credentials still produces a study-able trace of its own behaviour.
Three tabs, one per proof point
The four proof points from Part 2 each get a dedicated, auditable surface so you can isolate any single claim.
The MAF & tools tab lists every Microsoft Agent Framework worker invocation with its runtime label – simulation · direct here, foundry · direct when live – and a payload with the full call:
The MAF and tools tab: each worker invocation logged with its runtime and an inspectable payload
The Foundry IQ tab logs every retrieval call and, crucially, labels the fallback honestly – 2 IQ sources · local-knowledge (fallback) when cloud IQ is absent:
The Foundry IQ tab: retrieval calls logged with their source count and a clear fallback label
This honest labelling is the whole discipline. Lee Stott’s hybrid post labels every fallback (LOCAL_FALLBACK, CLOUD_FALLBACK) and carries a correlation ID everywhere. Silent fallback is how you end up demoing simulation while believing you are demoing Foundry. Here, the runtime label is on every event and the proof points are emitted in every tier – so a fork running with zero credentials produces a study-able replay log that says exactly what it did.
Retrieval has the same two-tier shape
Routing is not only about which model answers – retrieval follows the identical pattern, in submission/agents/retrieval.py. The preferred path queries the Foundry IQ knowledge base on the project endpoint and returns cited hits; with no credentials it falls back to a keyword scan over submission/knowledge/*.md. Every hit carries an origin so the source is never ambiguous:
# submission/agents/retrieval.py – a Foundry IQ hit
hits.append({
“content”: str(item.get(“content”) …)[:800],
“source”: str(item.get(“source”) or kb),
“score”: float(item.get(“score”) or 0.0),
“origin”: “foundry-iq”, # vs “local-knowledge” on the fallback
“citation”: str(item.get(“url”) or item.get(“id”) or “”),
})
That origin is what lets the Foundry IQ tab label a recall 2 IQ sources – local-knowledge (fallback) instead of pretending it hit the cloud. The same _IQ_AVAILABLE cache trick from the memory store applies: one failed probe routes the rest of the session to the local scan, so a missing knowledge base costs one timeout, not one per stage.
Best practice: make degradation visible, never silent
- Label every path on the result, never branch the API.
run_maf_agentreturns the same shape whether it hit Foundry, a local model, or simulation; the path is a field you log, not a fork callers handle. - Emit identical proof points in every tier.
iq_hits,memory_injected,tools_called,inference_usage– so a no-credentials fork is debuggable from its own logs. - Keep the truth layer local. Validators and the human gate run the same in every tier; only creativity degrades.
- Give every dependency a same-schema fallback. IQ -> local markdown, memory store -> local JSON, cloud model -> local model -> simulation.
Responsible AI
Local-first is also a privacy posture: with local_only routing and the local memory fallback, a full game loop runs without a single byte leaving the machine – no endpoint, no key, no telemetry. And because every fallback is labelled in the replay log, a user always knows whether their data touched the cloud on a given run. Observability is not just for debugging; it is how you keep an honest promise about where computation happened.
Where this lives in the repo
| Concern | File | Key symbol |
|---|---|---|
| Tier resolution + per-role map | submission/agents/model_config.py |
runtime_mode, get_foundry_client, model_for, _CLOUD_AGENT_MODELS, _LOCAL_AGENT_MODELS |
| Foundry-to-compat fallback receipt | submission/agents/maf_runtime.py |
run_maf_agent, maf_fallback_reason, _FOUNDRY_PATH_OK |
| Typed replay events | submission/tools/server.py |
CEO_DECISION, CONSEQUENCE_APPLIED, WORLD_ADAPTED, KNOWLEDGE_STRUCTURED |
| Bounded client + 429 retry | submission/agents/model_config.py |
AGENT_REQUEST_TIMEOUT, AGENT_MAX_RETRIES, FOUNDRY_FALLBACK_MODEL |
Try it
Point the game at a local model and watch the routing pills flip to “local: connected”:
submission/.env <<'ENV'
DEMO_MODE=local
AGENT_ROUTING=local_first
LOCAL_AGENT_BASE_URL=http://localhost:11434/v1
LOCAL_AGENT_API_KEY=ollama
LOCAL_AGENT_MODEL=
ENVpython3 submission/tools/run_quest_simulation.py –pitch “Your idea here”
“># with Ollama running locally
git clone https://github.com/princepspolycap/agentsleague-afterbuild
cd agentsleague-afterbuild && python3 -m venv .venv && source .venv/bin/activate
pip install -r submission/requirements.txtcat > submission/.env <<'ENV'
DEMO_MODE=local
AGENT_ROUTING=local_first
LOCAL_AGENT_BASE_URL=http://localhost:11434/v1
LOCAL_AGENT_API_KEY=ollama
LOCAL_AGENT_MODEL=
ENVpython3 submission/tools/run_quest_simulation.py –pitch “Your idea here”
Then open the live app, hit Settings, and read the Backend replay tab as you play.
Key takeaways
- Give every cloud dependency a keyless fallback with the same schema and code path; the path is a label on the result, not a fork in the API.
- Ship a runtime settings console so routing (
local_first/cloud_first/local_only/cloud_only) is a dropdown, not a redeploy. - Keep Ollama / Foundry Local swappable behind one OpenAI-compatible base URL.
- Make the system fully traced: typed, timestamped, actor-attributed events with inspectable payloads, plus a dedicated tab per proof point.
- Label every fallback honestly – silent degradation is how you demo simulation while believing it is Foundry.
Hybrid is not a compromise between local and cloud. It is the supervisor pattern applied to inference – and a forkable, fully-traced agent repo is the proof that your architecture was real.
Part 4 of 5. Next: the Org Designer bridge – exporting the game’s designed workforce as a portable bundle a real digital-worker platform can provision.