<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Nguyễn Ngọc Long</title>
    <description>The latest articles on DEV Community by Nguyễn Ngọc Long (@znlong2203).</description>
    <link>https://dev.to/znlong2203</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F2145567%2F217a75cd-8497-41e0-9fac-4ed9ee27c9d0.png</url>
      <title>DEV Community: Nguyễn Ngọc Long</title>
      <link>https://dev.to/znlong2203</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/znlong2203"/>
    <language>en</language>
    <item>
      <title>A good fallback hides the failure it was built for</title>
      <dc:creator>Nguyễn Ngọc Long</dc:creator>
      <pubDate>Sat, 29 Aug 2026 19:22:30 +0000</pubDate>
      <link>https://dev.to/znlong2203/a-good-fallback-hides-the-failure-it-was-built-for-1cgm</link>
      <guid>https://dev.to/znlong2203/a-good-fallback-hides-the-failure-it-was-built-for-1cgm</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Built for Google's All Things Agentic Hackathon (Devpost), August 2026.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I spent three weeks building a fleet of five agents that coordinate home care&lt;br&gt;
admin — reading photographs of pill bottles, voice memos from carers, insurance&lt;br&gt;
letters — and stopping to ask a human before anything irreversible.&lt;/p&gt;

&lt;p&gt;The tests passed. The health endpoint was green. Every screen rendered. And for&lt;br&gt;
most of those three weeks, three separate things were broken in ways that&lt;br&gt;
produced no error, no warning, and no visible symptom at all.&lt;/p&gt;

&lt;p&gt;They were all hidden by the same thing: a fallback doing exactly what I designed&lt;br&gt;
it to do.&lt;/p&gt;

&lt;p&gt;The UI had never once been live&lt;/p&gt;

&lt;p&gt;The web interface reads from the deployed API and falls back to a committed&lt;br&gt;
fixture corpus when a request fails. It labels itself honestly — a small badge in&lt;br&gt;
the corner reads sample data when it is showing fixtures, and live when it&lt;br&gt;
is not. I was proud of that badge. A demo that silently shows canned data while&lt;br&gt;
implying it is live is a lie the viewer cannot detect.&lt;/p&gt;

&lt;p&gt;The badge worked perfectly. It said "sample data" for the entire life of the&lt;br&gt;
deployment, and I never noticed, because I was testing the API with curl and it&lt;br&gt;
answered every time.&lt;/p&gt;

&lt;p&gt;The cause was four characters of shell. gcloud run deploy --source passes&lt;br&gt;
--set-build-env-vars to the builder. A Dockerfile build does not receive them&lt;br&gt;
as build args. So NEXT_PUBLIC_VIGIL_KEY was empty in every image ever shipped,&lt;br&gt;
every browser request was unauthenticated, every request failed, and every screen&lt;br&gt;
fell back — correctly, quietly, exactly as specified.&lt;/p&gt;

&lt;p&gt;Then there was a second layer. The UI and the API are served from the same&lt;br&gt;
container, so the deploy script deliberately leaves the API base URL empty:&lt;br&gt;
requests are same-origin and need no host. But the code read an empty base URL as&lt;br&gt;
"no API configured" and short-circuited to the fixture before attempting a&lt;br&gt;
single request. The one deployment shape the system was designed for was the one&lt;br&gt;
shape it refused to try.&lt;/p&gt;

&lt;p&gt;An agent's proposals never reached the approvals queue&lt;/p&gt;

&lt;p&gt;The medication agent can propose a schedule change. Every clinical change goes to&lt;br&gt;
a human — that is the entire safety argument of the project. The tool wrote a&lt;br&gt;
document into a proposals collection.&lt;/p&gt;

&lt;p&gt;The API served the approvals screen from a collection called approvals.&lt;/p&gt;

&lt;p&gt;Two mechanisms for one idea, running past each other, and nothing in between to&lt;br&gt;
notice. The Approvals screen looked populated and correct the whole time —&lt;br&gt;
because it had sample cards to render.&lt;/p&gt;

&lt;p&gt;Voice notes never reached the agent&lt;/p&gt;

&lt;p&gt;Uploaded .wav files were stored as application/octet-stream. The upload&lt;br&gt;
handler validated the file extension against an allowlist and then preferred the&lt;br&gt;
content type the client declared over the allowlist it had just consulted. Both&lt;br&gt;
curl and browsers send application/octet-stream for .wav.&lt;/p&gt;

&lt;p&gt;Downstream, the pipeline decides whether to attach a binary to the agent's message&lt;br&gt;
by looking at that content type. It saw neither image/ nor audio/, so it&lt;br&gt;
attached nothing. The agent received a prompt with no recording in it and reported&lt;br&gt;
that the audio format was unsupported — which was true of what it had been given,&lt;br&gt;
and false of the file. The screen faithfully reported the agent. Every component&lt;br&gt;
did what it was told.&lt;/p&gt;

&lt;p&gt;What exposed all three&lt;/p&gt;

&lt;p&gt;One instruction from the person I was building for: delete the sample data.&lt;br&gt;
Every screen shows the deployed system, or says plainly that it cannot reach it.&lt;/p&gt;

&lt;p&gt;All three surfaced within an hour.&lt;/p&gt;

&lt;p&gt;That is not a coincidence, and it is the part worth keeping. A fallback is a&lt;br&gt;
second, quieter implementation of your feature. When the first one breaks, the&lt;br&gt;
second one carries on and the system looks healthy — which is precisely what you&lt;br&gt;
asked it to do. The more graceful the degradation, the longer the outage hides.&lt;/p&gt;

&lt;p&gt;I still think the fallback was a good design. What I would change is that a&lt;br&gt;
fallback should be loud in the environment where it should never happen. In&lt;br&gt;
development, fall back silently. In the deployment where a backend is definitionally&lt;br&gt;
present, falling back is not degradation — it is an incident, and it should say so.&lt;/p&gt;

&lt;p&gt;The one that had nothing to do with fallbacks&lt;/p&gt;

&lt;p&gt;While fixing those, I found a fourth, and it is my favourite.&lt;/p&gt;

&lt;p&gt;Five tool signatures took a run_id: str parameter. The agent framework builds a&lt;br&gt;
tool's declaration from its signature, which means every parameter is a field the&lt;br&gt;
model fills in. So the model filled it in — with "run-001", a plausible,&lt;br&gt;
well-formatted identifier belonging to no run that has ever existed.&lt;/p&gt;

&lt;p&gt;Every approval created that way was untraceable. The card in the queue named a run&lt;br&gt;
that does not exist. The audit trail could not be correlated. And the idempotency&lt;br&gt;
key was namespaced under the hallucinated id, so two proposals from genuinely&lt;br&gt;
different runs could collide and one would be silently suppressed as a duplicate.&lt;/p&gt;

&lt;p&gt;A tool signature should carry what the model needs to decide, and nothing else.&lt;br&gt;
Bookkeeping the model cannot know is bookkeeping the model will invent. The run id&lt;br&gt;
now comes from a context variable, and a test asserts that no tool signature&lt;br&gt;
contains a runtime-only field:&lt;/p&gt;

&lt;p&gt;RUNTIME_ONLY = {"run_id", "trace_id", "actor", "agent", "step_id"}&lt;/p&gt;

&lt;p&gt;def test_no_tool_asks_the_model_for_runtime_bookkeeping():&lt;br&gt;
    offenders = {&lt;br&gt;
        f.&lt;strong&gt;name&lt;/strong&gt;: sorted(RUNTIME_ONLY &amp;amp; set(signature(f).parameters))&lt;br&gt;
        for f in ALL_TOOLS&lt;br&gt;
        if RUNTIME_ONLY &amp;amp; set(signature(f).parameters)&lt;br&gt;
    }&lt;br&gt;
    assert not offenders&lt;/p&gt;

&lt;p&gt;And the one where I destroyed the evidence myself&lt;/p&gt;

&lt;p&gt;The project's centrepiece is an eval gate with an anti-gaming judge: an agent&lt;br&gt;
proposes a rewrite of its own instruction, the proposal is scored against a fixed&lt;br&gt;
suite, and a second model argues that the improvement is not real. The stored&lt;br&gt;
record of one agent gaming its own test and being caught is the most valuable&lt;br&gt;
artefact the system produces.&lt;/p&gt;

&lt;p&gt;I ran a second, honest improvement round — no gamed candidate — because a gate&lt;br&gt;
that has only ever said no is hard to distinguish from a gate wired to say no.&lt;br&gt;
It was rejected for an ordinary reason: the proposer truncated the instruction&lt;br&gt;
mid-sentence and the score fell.&lt;/p&gt;

&lt;p&gt;And it overwrote the first record.&lt;/p&gt;

&lt;p&gt;Version records were keyed on the proposed version number. The bump function is&lt;br&gt;
deterministic, so every rejected proposal from 1.4.2 is named 1.5.0-rc. The&lt;br&gt;
second set() replaced the first. Nothing reported a loss, because overwriting is&lt;br&gt;
what set() is for.&lt;/p&gt;

&lt;p&gt;The docstring on that function reads: "Rejections are kept deliberately… a gate&lt;br&gt;
whose refusals are not retained cannot be audited." It was true of the intent and&lt;br&gt;
false of the storage. Records are now one document per attempt, and the registry&lt;br&gt;
holds both verdicts — one rejection for gaming, one for quality. The pair is&lt;br&gt;
better evidence than either alone.&lt;/p&gt;

&lt;p&gt;The last one: a refusal that was really a crash&lt;/p&gt;

&lt;p&gt;The newest feature in the system is the one I was most nervous about. A discharge&lt;br&gt;
letter says to rest the leg on a cushion so the ankle sits higher than the hip,&lt;br&gt;
knee slightly bent. That is correct, a clinician wrote it, and it is delivered on&lt;br&gt;
the worst possible medium — a sheet of A4 handed over once at a hospital desk to&lt;br&gt;
a relative too worried to take it in. A week later it is being done slightly&lt;br&gt;
wrong and nobody knows.&lt;/p&gt;

&lt;p&gt;Video is the only format that shows a movement, so the fleet proposes filming&lt;br&gt;
it, a human approves, and Veo renders the action. The clinician's sentence is&lt;br&gt;
quoted above the clip; the numbers stay in the text, because generated lettering&lt;br&gt;
is unreliable and a wrong figure on a care video is worse than no video.&lt;/p&gt;

&lt;p&gt;The card can also say the instruction was not filmed — some instructions have&lt;br&gt;
no picture in them. "The district nurse visits on Tuesdays" is a fact, and a&lt;br&gt;
pleasant generated shot of a calendar attached to medical paperwork reads as&lt;br&gt;
evidence. Refusing is the correct behaviour and the card shows the reason.&lt;/p&gt;

&lt;p&gt;The first live run rendered nothing, and the reason shown to the user was:&lt;/p&gt;

&lt;p&gt;Expecting ',' delimiter: line 6 column 16 (char 583)&lt;/p&gt;

&lt;p&gt;I had asked the model for JSON in prose and parsed the reply. It wrote a scene&lt;br&gt;
containing a quotation mark, and the response stopped being JSON. Fine — except&lt;br&gt;
for where that string went. The record is keyed by the approval id so the render&lt;br&gt;
never runs twice, which meant a transient fault had been written into a&lt;br&gt;
permanent "not filmed" card, with a parser error where a carer would look for the&lt;br&gt;
reason, and nothing left in the system that would ever try again.&lt;/p&gt;

&lt;p&gt;Two fixes, and the second is the one I keep thinking about. The schema is now&lt;br&gt;
declared to the serving layer instead of described in a prompt — the same lesson&lt;br&gt;
every agent in this codebase was already built on, which I had failed to apply to&lt;br&gt;
the one call that was not an agent. And a fault and a refusal are now different&lt;br&gt;
things in the type: a refusal is a decision and it is final; an error means try&lt;br&gt;
again later, and it deliberately leaves nothing behind.&lt;/p&gt;

&lt;p&gt;They look identical on screen. That is exactly why they must not be stored alike.&lt;/p&gt;

&lt;p&gt;Refusing to help is not the same as being safe&lt;/p&gt;

&lt;p&gt;The shared rule every agent in this fleet reads used to say "you do not give&lt;br&gt;
medical advice". It was tidy, it was easy to defend, and it meant the part of&lt;br&gt;
the folder the family most struggled with was the part the system would not&lt;br&gt;
touch — while it went on cheerfully filing their insurance paperwork.&lt;/p&gt;

&lt;p&gt;The rule is now precise instead of broad: carrying a clinician's words is&lt;br&gt;
allowed, authoring is not. The safety did not come from the silence. It comes&lt;br&gt;
from the quotation, and from a scope owned by the clinical department, which the&lt;br&gt;
policy engine sends to a named human at any confidence — including a confidence&lt;br&gt;
of 1.0. Every clip on screen carries who approved it.&lt;/p&gt;

&lt;p&gt;Broad rules feel safer to write. They are mostly a way of moving the risk&lt;br&gt;
somewhere you cannot see it.&lt;/p&gt;

&lt;p&gt;What I would tell myself three weeks ago&lt;/p&gt;

&lt;p&gt;Delete the safety net once, on purpose, in the environment that matters. Not&lt;br&gt;
permanently — just long enough to find out what it has been carrying.&lt;/p&gt;

&lt;p&gt;A component that "did what it was told" is not exonerated. All three of the&lt;br&gt;
first bugs had a blameless component chain. The failure was in the seams, and the&lt;br&gt;
seams are where the tests were not.&lt;/p&gt;

&lt;p&gt;Numbers in your README expire. I quoted an eval score of 0.67 → 0.92 in mine.&lt;br&gt;
By the time I checked, the deployed record said 0.75 → 0.83. A judge with two&lt;br&gt;
tabs open would have caught it. I now regenerate the numbers from the running&lt;br&gt;
system before I claim them.&lt;/p&gt;

&lt;p&gt;Built for Google's All Things Agentic Hackathon, August 2026. Stack: Google ADK,&lt;br&gt;
Gemini 3.5 and 3.6 on Vertex AI, Gemma for redaction, Gemini TTS and Veo, Cloud&lt;br&gt;
Run, Firestore, Pub/Sub, Cloud Trace. All data synthetic. The system writes no&lt;br&gt;
care advice of its own — it carries a clinician's, quoted, and every clinical&lt;br&gt;
action waits for a person.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>googlecloud</category>
      <category>agents</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
