<?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: JWL AI</title>
    <description>The latest articles on DEV Community by JWL AI (@junwei_lai_71641e0a742b33).</description>
    <link>https://dev.to/junwei_lai_71641e0a742b33</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%2F4012513%2Fb55ed78e-d4ed-4b08-9124-3ee49babddd3.jpg</url>
      <title>DEV Community: JWL AI</title>
      <link>https://dev.to/junwei_lai_71641e0a742b33</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/junwei_lai_71641e0a742b33"/>
    <language>en</language>
    <item>
      <title>Agents for Humans: the fixture that hid four bugs from me</title>
      <dc:creator>JWL AI</dc:creator>
      <pubDate>Mon, 14 Sep 2026 07:12:36 +0000</pubDate>
      <link>https://dev.to/junwei_lai_71641e0a742b33/agents-for-humans-the-fixture-that-hid-four-bugs-from-me-1jmd</link>
      <guid>https://dev.to/junwei_lai_71641e0a742b33/agents-for-humans-the-fixture-that-hid-four-bugs-from-me-1jmd</guid>
      <description>&lt;p&gt;I built an enterprise agent for mine operations and gave it a deterministic model provider so the demo would replay identically. That decision was right, and it hid four bugs from me for the entire project.&lt;/p&gt;

&lt;p&gt;This is about what happened the day I first pointed it at a real model.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;CAIRN correlates fragmented signals from a mine shift into one incident, has four specialist agents analyse it in parallel through a bounded &lt;a href="https://strandsagents.com" rel="noopener noreferrer"&gt;Strands Agents&lt;/a&gt; graph, and produces three recovery options. Then it refuses to act until a person signs.&lt;/p&gt;

&lt;p&gt;To make it reproducible I wrote a Strands &lt;code&gt;Model&lt;/code&gt; provider returning deterministic output from fixtures. No credentials, no network, byte-identical replay. CI ran against it. A thirteen-case evaluation suite ran against it. Everything was green.&lt;/p&gt;

&lt;h2&gt;
  
  
  Then Nova Lite ran the same graph
&lt;/h2&gt;

&lt;p&gt;The graph passed. All five nodes satisfied their typed contracts. Every specialist called tools before answering. Thirteen seconds, 30,253 input tokens.&lt;/p&gt;

&lt;p&gt;And the output was unusable.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;key                    option_1             (the UI selects on recover_tonnes)
recommendedScenarioId  scenario_id_1        (matches none of its own options)
requiredApprovals      operations_manager   (not a role the policy table knows)
estimatedTonnesDelta   -1200, -1500, -1000  (every option a loss)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pydantic validated all of it. Every field was present and correctly typed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the fixture hid it
&lt;/h2&gt;

&lt;p&gt;Look at what the contract actually said:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;  &lt;span class="c1"&gt;# protect_safety | recover_tonnes | preserve_equipment
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The legal values were in a &lt;strong&gt;comment&lt;/strong&gt;. The model never sees comments. It sees the JSON schema Strands generates for its forced tool call, and that schema said &lt;code&gt;string&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;requiredApprovals&lt;/code&gt; was &lt;code&gt;list[str]&lt;/code&gt;, so any plausible role name validated. &lt;code&gt;recommendedScenarioId&lt;/code&gt; was &lt;code&gt;str&lt;/code&gt;, so it could point at nothing — and did. The tonnes sign convention was written down nowhere at all, so the model read the delta as a loss against an undisrupted plan while the fixture meant recovery against doing nothing. Both readings are defensible. That is the problem.&lt;/p&gt;

&lt;p&gt;Each of those was latent and green for weeks, because the fixture always wrote the right value. My tests asserted against a provider that happened to behave.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix, and the thing worth generalising
&lt;/h2&gt;

&lt;p&gt;Four constraints moved out of comments and into the schema: two enums, a model validator for referential integrity, and a field description stating the sign convention. Then five tests that assert the &lt;strong&gt;schema&lt;/strong&gt; rather than a provider, so they need no credentials and hold for any model. All five fail against the old contract.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Anything whose correctness depends on a provider must be asserted against the schema, not against the provider.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A comment is not a constraint. It documents intent to humans and enforces nothing on the machine that actually consumes your contract.&lt;/p&gt;

&lt;h2&gt;
  
  
  A second thing the fixture hid
&lt;/h2&gt;

&lt;p&gt;While I was in there, I asked why the risk agent made two tool calls when the others made three. The answer was boring — its plan is two. But checking turned up something else: three of my prompts advertised tools that do not exist.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gu"&gt;## Allowed tools&lt;/span&gt;
&lt;span class="sb"&gt;`get_weather_window`&lt;/span&gt;, &lt;span class="sb"&gt;`get_active_permits`&lt;/span&gt;, &lt;span class="sb"&gt;`get_evidence`&lt;/span&gt;, &lt;span class="sb"&gt;`get_site_context`&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;get_active_permits&lt;/code&gt; exists nowhere in the codebase. The scenario planner's prompt offered three tools while its allow-list is empty by design, and one of those three was fictional too.&lt;/p&gt;

&lt;p&gt;The fixture provider follows a tool plan and never reads the prompt. Only a real model reads the prompt, tries to call a tool that is not there, and gets cancelled by the hook. The guard held — but the attempt is wasted and the trajectory polluted.&lt;/p&gt;

&lt;p&gt;Two tests now assert that a prompt's advertised tools equal its node's allow-list, and that every advertised tool is registered.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would do differently
&lt;/h2&gt;

&lt;p&gt;Keep the deterministic provider. It is the right call: anyone can clone the repository and get the same result with no account, and CI has a hermetic gate. I would not give that up.&lt;/p&gt;

&lt;p&gt;But run a real provider &lt;strong&gt;early&lt;/strong&gt;, and treat the first run as a contract review rather than a smoke test. Every defect it found was a place where I had written the rule down somewhere the machine could not read it.&lt;/p&gt;

&lt;p&gt;Both modes now run the same graph and the same governance. Swap the brain; the safety envelope does not move. That is a stronger claim than either mode alone, and I only earned it by running both.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;CAIRN is built with Python, the AWS Strands Agents SDK, and Amazon Bedrock. Everything in it is synthetic and simulation-only: no mine system, PLC, SCADA, dispatch, ERP or CMMS is connected, and there is no control path to operational technology by design.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Code: &lt;a href="https://github.com/jwlai-cloud/cairn" rel="noopener noreferrer"&gt;github.com/jwlai-cloud/cairn&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>aws</category>
      <category>ai</category>
      <category>python</category>
      <category>architecture</category>
    </item>
    <item>
      <title>The difference between "mentioned" and "answered"</title>
      <dc:creator>JWL AI</dc:creator>
      <pubDate>Mon, 31 Aug 2026 07:58:08 +0000</pubDate>
      <link>https://dev.to/junwei_lai_71641e0a742b33/the-difference-between-mentioned-and-answered-3ekp</link>
      <guid>https://dev.to/junwei_lai_71641e0a742b33/the-difference-between-mentioned-and-answered-3ekp</guid>
      <description>&lt;p&gt;&lt;em&gt;I built this for the All Things Agentic Hackathon (August 2026). Code:&lt;br&gt;
&lt;a href="https://github.com/jwlai-cloud/intake" rel="noopener noreferrer"&gt;github.com/jwlai-cloud/intake&lt;/a&gt;.&lt;br&gt;
Set &lt;code&gt;published: true&lt;/code&gt; in the front matter when you post it.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A community nurse has ninety minutes and a form she is legally required to&lt;br&gt;
complete. She asks about falls in the last twelve months. The answer is:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;"Oh, I've had a couple of wobbles."&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Every AI scribe on the market ticks that item. It was mentioned. It was never&lt;br&gt;
answered. She finds the gap that evening, at her desk — and now it needs a phone&lt;br&gt;
call, a guess, or a second visit. The person who knew the answer was sitting&lt;br&gt;
three feet away an hour ago.&lt;/p&gt;

&lt;p&gt;That gap is the entire product.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why every competitor ticks the box
&lt;/h2&gt;

&lt;p&gt;I looked at what's shipping. Microsoft Teams' Facilitator marks a topic covered&lt;br&gt;
"once the discussion for that topic has started". Balto ticks when an item is&lt;br&gt;
mentioned. Otter's Live Assist checks off objectives. None of them adjudicate&lt;br&gt;
whether a required item actually &lt;em&gt;received a real answer&lt;/em&gt;, and none of them gate&lt;br&gt;
the output on it.&lt;/p&gt;

&lt;p&gt;That's not laziness. Mention-detection is a much easier problem. Deciding&lt;br&gt;
whether &lt;em&gt;"a couple of wobbles"&lt;/em&gt; satisfies a form's requirement for "the number&lt;br&gt;
of falls and the circumstances of the most recent" is a judgement call, and&lt;br&gt;
judgement calls are where LLM products quietly go wrong.&lt;/p&gt;

&lt;p&gt;So the whole project reduces to one question: &lt;strong&gt;can you make that judgement&lt;br&gt;
reliably enough to gate a report on it?&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Build the judge first, and measure it
&lt;/h2&gt;

&lt;p&gt;The first thing I built wasn't the UI. It was an eval harness.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;eval/&lt;/code&gt; holds 47 labelled cases — for each required item, answers that should&lt;br&gt;
count and answers that shouldn't. It scores the adjudicator against the live&lt;br&gt;
service and &lt;strong&gt;exits non-zero if any answer labelled insufficient was marked&lt;br&gt;
sufficient&lt;/strong&gt;. That failure is the one that destroys the product, because it's&lt;br&gt;
indistinguishable from the mention-level behaviour I'm claiming to beat.&lt;/p&gt;

&lt;p&gt;The bar is asymmetric on purpose. A false &lt;em&gt;insufficient&lt;/em&gt; costs one extra&lt;br&gt;
question. A false &lt;em&gt;sufficient&lt;/em&gt; is a silently blank field in a legal document. So&lt;br&gt;
the prompt's first rule is: &lt;strong&gt;default to insufficient — if you're weighing it up,&lt;br&gt;
it's insufficient.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Twelve of those cases are adversarial, and writing them was the single&lt;br&gt;
highest-value hour of the build. They found a real bug immediately:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;"Three falls, and the last was in May on the stairs."&lt;/em&gt;&lt;br&gt;
&lt;em&gt;"No, hang on — I'm thinking of my sister. I've not actually fallen myself,&lt;br&gt;
not that I can bring to mind."&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The adjudicator read the retraction as a clean nil return and &lt;strong&gt;ticked the&lt;br&gt;
item&lt;/strong&gt;. The record now held a contradiction settled only by a hedge, which is a&lt;br&gt;
practitioner's call, not the agent's. The fix was a rule: the later turn governs&lt;br&gt;
only when it is itself a clear answer or a clear refusal.&lt;/p&gt;

&lt;p&gt;I'd never have found that by hand-testing. A set that scores 100% on its first&lt;br&gt;
run can't tell you anything.&lt;/p&gt;
&lt;h2&gt;
  
  
  The architecture, and the two decisions worth defending
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Google ADK 2.6.2 on Cloud Run&lt;/strong&gt;, calling &lt;strong&gt;Gemini 3.6 Flash on Vertex AI&lt;/strong&gt;,&lt;br&gt;
with session state in &lt;strong&gt;Firestore&lt;/strong&gt;. One audio chunk in, one bounded turn out:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;transcribe → route → adjudicate (fanned out) → coach
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The slot state is the state, not the transcript.&lt;/strong&gt; The naive design&lt;br&gt;
accumulates the conversation and re-asks the model "what's still missing?" every&lt;br&gt;
turn. A forty-five minute interview becomes tens of thousands of growing tokens&lt;br&gt;
— expensive, and degrading as it grows. Instead each call gets the open items, a&lt;br&gt;
fixed-size struct of current values, and the new audio. A three-hour interview&lt;br&gt;
costs the same per chunk as a ten-minute one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Adjudication is one isolated call per open item, not one big prompt.&lt;/strong&gt; Three&lt;br&gt;
reasons, in order: a wrong verdict on one item can't corrupt another; each item&lt;br&gt;
is separately scoreable by the eval; and &lt;em&gt;k&lt;/em&gt; concurrent calls cost about one&lt;br&gt;
call's wall time. That last one surprises people — fanning out is usually the&lt;br&gt;
expensive choice, and here it's close to free.&lt;/p&gt;

&lt;p&gt;Between them sits a router. Without it, every open item was adjudicated against&lt;br&gt;
every chunk, and each independently decided a vague remark was relevant to it —&lt;br&gt;
so &lt;em&gt;"a couple of wobbles"&lt;/em&gt; attached itself to mobility, memory and low mood as&lt;br&gt;
well as falls. One cheap classification call first cut items touched from seven&lt;br&gt;
to two, and cut cost with it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rules that are enforced by types, not prompts
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The agent never authors domain content.&lt;/strong&gt; It tracks coverage against a&lt;br&gt;
human-authored form and quotes the span it relied on. It says &lt;em&gt;"item M14 has no&lt;br&gt;
recorded answer"&lt;/em&gt;, never &lt;em&gt;"this may indicate falls risk"&lt;/em&gt;. That's not a prompt&lt;br&gt;
instruction — the coach's output schema has fields for a question and for quotes&lt;br&gt;
and &lt;strong&gt;no field an answer could go into&lt;/strong&gt;. It structurally cannot suggest one.&lt;/p&gt;

&lt;p&gt;Behavioural evaluation found the hole in that anyway. The schema forbids an&lt;br&gt;
answer &lt;em&gt;field&lt;/em&gt;; it doesn't forbid an interpretive &lt;em&gt;label&lt;/em&gt;. The agent had emitted&lt;br&gt;
a highlight titled &lt;em&gt;"Formal decline to answer alcohol question"&lt;/em&gt; — a&lt;br&gt;
characterisation, not a quote. Titles are bare noun phrases now, and that exact&lt;br&gt;
string is a test case.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No interviewee identity, ever.&lt;/strong&gt; Sessions are scoped to a job, not a person.&lt;br&gt;
Persistent memory is scoped to the &lt;em&gt;practitioner&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;The honest version of that claim is narrower than the slogan. Recorded answers&lt;br&gt;
are verbatim quotes, and a real interviewee says &lt;em&gt;"my daughter Sarah drives me on&lt;br&gt;
Mondays"&lt;/em&gt;. Redacting that would break adjudication, so identity is a&lt;br&gt;
&lt;strong&gt;retention&lt;/strong&gt; answer, not a redaction one — quotes live in the session document,&lt;br&gt;
never in logs, and go when the session goes.&lt;/p&gt;

&lt;p&gt;That last part was a real bug. A Vertex error response echoes the offending&lt;br&gt;
request, and the adjudicator's request body &lt;em&gt;is&lt;/em&gt; the transcript — so a malformed&lt;br&gt;
chunk was writing interviewee speech into Cloud Logging, which outlives the&lt;br&gt;
session document. Logs now carry the exception &lt;em&gt;type&lt;/em&gt; only.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the agent learns, and what it refuses to
&lt;/h2&gt;

&lt;p&gt;It gets better at helping one practitioner across her interviews. It learns&lt;br&gt;
nothing about the people she interviews.&lt;/p&gt;

&lt;p&gt;Two things, both about the professional: a question phrasing that closed an item&lt;br&gt;
on the first ask, and the item ids whose highlights she keeps dismissing. From&lt;br&gt;
her second interview the coach offers back a wording that worked, and stops&lt;br&gt;
proposing chips she's binned — while still asking the required question, because&lt;br&gt;
muting a suggestion must never mute an obligation.&lt;/p&gt;

&lt;p&gt;The version that would demo better is the one it refuses to build. &lt;em&gt;"People like&lt;br&gt;
this one usually under-report falls"&lt;/em&gt; would be useful and would permanently&lt;br&gt;
break the privacy property. The line between them is the line between a question&lt;br&gt;
and an answer: the agent's own composition may cross sessions, a person&lt;br&gt;
describing their own health may not. A guard rejects any candidate that's&lt;br&gt;
quoted, isn't a question, or has a first-person subject — and a test feeds it&lt;br&gt;
five samples of real interviewee speech and fails if any survives.&lt;/p&gt;

&lt;h2&gt;
  
  
  Nothing is ever silently blank
&lt;/h2&gt;

&lt;p&gt;Before a report is produced, every required item resolves into exactly one of&lt;br&gt;
three states:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Answered&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;with the transcript span it was drawn from&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Declined&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;formally recorded, and only where the form permits it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Escalated&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;the agent drafts the follow-up itself and routes it&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The gate is a router, not a wall. A copilot that only says &lt;em&gt;no&lt;/em&gt; is one&lt;br&gt;
practitioners switch off. When it refuses, the response carries what's&lt;br&gt;
outstanding, what's missing from each, and whether a decline is even permitted.&lt;/p&gt;

&lt;p&gt;The escalation is the part I'd point at. Given an unresolved item, the agent&lt;br&gt;
writes what's still not recorded in the form's own terms, why it couldn't be&lt;br&gt;
closed, and picks a destination from a closed list. &lt;em&gt;"Home access and hazards ·&lt;br&gt;
not recorded during the visit → Occupational therapy queue."&lt;/em&gt; Unprompted&lt;br&gt;
judgement, a real artifact, filed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Things that cost me time
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;GOOGLE_GENAI_USE_VERTEXAI=TRUE&lt;/code&gt; is mandatory for ADK.&lt;/strong&gt; Without it, ADK
builds its own AI Studio client and dies with "No API key was provided" — even
though a hand-built &lt;code&gt;genai.Client(vertexai=True)&lt;/code&gt; in the same process works.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;SequentialAgent&lt;/code&gt; is deprecated in 2.6.2&lt;/strong&gt;, and it emits a container event
with no &lt;code&gt;content&lt;/code&gt;. &lt;code&gt;agents-cli eval generate&lt;/code&gt; rejects any content-less event,
so ADK's deprecated orchestrator is incompatible with ADK's current eval
tooling. Then I measured the replacement: the graph &lt;code&gt;Workflow&lt;/code&gt; emits
&lt;code&gt;Event(output=…, content=None)&lt;/code&gt; and &lt;strong&gt;fails identically&lt;/strong&gt;. A four-line custom
&lt;code&gt;BaseAgent&lt;/code&gt; is the only one of the three that can be evaluated.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;I had the reason backwards for three days.&lt;/strong&gt; My notes said the graph
migration was blocked because "Workflow cannot take an LlmAgent as a
sub-agent". The warning says the &lt;em&gt;reverse&lt;/em&gt; — a Workflow can't be nested
&lt;em&gt;inside&lt;/em&gt; an LlmAgent. Both are &lt;code&gt;BaseNode&lt;/code&gt; subclasses and compose fine.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The general lesson, for an SDK this young: read signatures off the installed&lt;br&gt;
package with &lt;code&gt;inspect&lt;/code&gt;, not off documentation. Every API claim in this project&lt;br&gt;
was verified that way, and the one time I trusted a note instead, it was wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug I found by finally doing the obvious thing
&lt;/h2&gt;

&lt;p&gt;Two days before the deadline I asked someone to try the deployed app with a real&lt;br&gt;
microphone. Nothing visibly happened.&lt;/p&gt;

&lt;p&gt;Chunks were arriving, HTTP 200, the ADK pipeline was running — and every turn&lt;br&gt;
finished in about a tenth of a second having done nothing. A real turn takes six&lt;br&gt;
seconds.&lt;/p&gt;

&lt;p&gt;The transcriber labels each turn &lt;code&gt;practitioner&lt;/code&gt; or &lt;code&gt;interviewee&lt;/code&gt;, and&lt;br&gt;
adjudication only looks at &lt;code&gt;interviewee&lt;/code&gt; turns. &lt;strong&gt;One person testing alone is a&lt;br&gt;
single voice, and the model reasonably labelled it &lt;code&gt;practitioner&lt;/code&gt;.&lt;/strong&gt; Every chunk&lt;br&gt;
was discarded. The screen sat inert — which is exactly what a judge trying it&lt;br&gt;
alone would have seen.&lt;/p&gt;

&lt;p&gt;I'd tested the API with curl, the text path, and an automated browser capture.&lt;br&gt;
But that capture runs Chromium with &lt;code&gt;--use-fake-device-for-media-stream&lt;/code&gt;. I had&lt;br&gt;
verified a proxy for the product and called it the product.&lt;/p&gt;

&lt;p&gt;The fix is one instruction, and &lt;em&gt;where&lt;/em&gt; it went matters. My first attempt&lt;br&gt;
relaxed the filter in the adjudicator, and it immediately broke a test asserting&lt;br&gt;
that a nurse restating an answer must never close an item. Same input, two&lt;br&gt;
opposite correct answers — the adjudicator can't tell a lone tester from a&lt;br&gt;
professional summarising. The transcriber can: it's the only stage that hears&lt;br&gt;
how many people are in the room. So it labels a lone voice as the interviewee,&lt;br&gt;
and the downstream guarantee is untouched.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it stands
&lt;/h2&gt;

&lt;p&gt;47 labelled cases, &lt;strong&gt;100% precision on &lt;code&gt;sufficient&lt;/code&gt;&lt;/strong&gt; — it has never once ticked&lt;br&gt;
an answer a human labelled insufficient. Accuracy moves between 45 and 47 across&lt;br&gt;
runs because the adjudicator is occasionally &lt;em&gt;stricter&lt;/em&gt; than a labelled case;&lt;br&gt;
every one of those misses is a false insufficient, which costs a question and&lt;br&gt;
never a wrong tick. 133 backend tests. A behavioural eval over the pipeline&lt;br&gt;
scoring 18/18, graded by deterministic code rather than an LLM judge — asking a&lt;br&gt;
model "is this quote verbatim?" is slower, costs money, and is worse than &lt;code&gt;in&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;A second template — insurance loss adjusting — runs on the same engine with no&lt;br&gt;
code change, which is the test of whether the vertical is really just config.&lt;/p&gt;

&lt;p&gt;What I'd build next is per-user identity. Access control today is a capability&lt;br&gt;
model: one shared key, and session ids that are 128 bits of randomness. It&lt;br&gt;
holds, but it stops holding the moment an id reaches a log or a shared screen.&lt;/p&gt;

</description>
      <category>googlecloud</category>
      <category>ai</category>
      <category>python</category>
      <category>showdev</category>
    </item>
    <item>
      <title>How a Negotiating Agent Society Out-Plans a Single Scheduler</title>
      <dc:creator>JWL AI</dc:creator>
      <pubDate>Fri, 03 Jul 2026 08:44:01 +0000</pubDate>
      <link>https://dev.to/junwei_lai_71641e0a742b33/how-a-negotiating-agent-society-out-plans-a-single-scheduler-55ai</link>
      <guid>https://dev.to/junwei_lai_71641e0a742b33/how-a-negotiating-agent-society-out-plans-a-single-scheduler-55ai</guid>
      <description>&lt;p&gt;&lt;em&gt;Turn a five-way scheduling trade-off into a debate you can audit: five advocate agents argue, a charge-nurse referee rules, and the plan beats a single agent on the same scorer.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The scariest scheduling failures are the confident ones.&lt;/p&gt;

&lt;p&gt;You hand one LLM a clinic's week — 56 patients, 43 slots, three nurses — and ask it to book the follow-ups. No errors. No complaints. It returns a clean, plausible schedule in a single pass. But is it a &lt;em&gt;good&lt;/em&gt; plan, or did it quietly sacrifice something that mattered?&lt;/p&gt;

&lt;p&gt;Score it and you find out. High-acuity patients: all seen. Continuity of care: collapsed — patients bounced to whichever nurse had a gap. Overdue follow-ups: slipping. The agent anchored on the one objective that's easy to name and let the messy ones rot — and it never told you it made that trade.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A quiet objective is not a satisfied objective. Usually it's the opposite.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This is a case study in fixing that with a &lt;strong&gt;multi-agent society&lt;/strong&gt; — and it won't stay abstract. Every number comes from one real system: &lt;strong&gt;RehabPanel&lt;/strong&gt;, a rehab-scheduling agent built for the Qwen Cloud hackathon (Track 3), running on real Qwen models and deployed live on Alibaba Cloud.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem: one agent collapses the trade-off
&lt;/h2&gt;

&lt;p&gt;Five objectives fight for the same slots — clinical acuity, overdue windows, continuity with the primary nurse, hard capacity, patient preference. They genuinely conflict: seat the sickest patient and you may break someone's continuity; honor a preference and you may bump an overdue visit.&lt;/p&gt;

&lt;p&gt;A single agent resolves that tension &lt;em&gt;inside one prompt&lt;/em&gt;, invisibly. You get an answer, not an argument. And you can't audit an answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: make the conflict explicit and negotiated
&lt;/h2&gt;

&lt;p&gt;RehabPanel replaces the one planner with &lt;strong&gt;five advocate agents&lt;/strong&gt;, each obsessed with exactly one objective, plus a &lt;strong&gt;charge-nurse referee&lt;/strong&gt; who brokers between them. It's a LangGraph state machine — &lt;code&gt;draft → critique → negotiate → arbitrate&lt;/code&gt;, looping until nobody's still objecting.&lt;/p&gt;

&lt;p&gt;Each round:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;All five advocates object&lt;/strong&gt; to the current plan, in their own words — in parallel.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The loudest objection's advocate proposes&lt;/strong&gt; a concrete swap.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The rest split into FOR / AGAINST coalitions&lt;/strong&gt; by whose objective the swap helps or hurts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The referee rules&lt;/strong&gt; on a fixed priority ranking (capacity ≻ acuity ≻ overdue ≻ continuity ≻ preference) — and explains the ruling in plain language, appending it to a &lt;strong&gt;conflict ledger&lt;/strong&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Nobody scripts that explanation. Mid-run, the referee writes things like: &lt;em&gt;"approved because it improves preference without violating any higher-ranked objective, and no competing acuity, overdue, or continuity claim contests this slot."&lt;/em&gt; That ledger is the whole point — a single agent never shows this work.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fq20cihq7a745y4dvi5zo.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fq20cihq7a745y4dvi5zo.png" alt="A round in the coordinator app: five advocates object with their own reasons, a FOR/AGAINST coalition forms, and the charge-nurse referee explains the ruling in prose" width="799" height="319"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;One round: five objections, the coalition split, and the referee's prose ruling appended to the conflict ledger.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The whole thing is a LangGraph state machine with one explicit exit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;flowchart TB
    DR["node_draft&amp;lt;br/&amp;gt;acuity-first skeleton — no LLM (or a warm seed)"]
    CR["node_critique&amp;lt;br/&amp;gt;all 5 advocates object — in parallel"]
    CD{"hot objection&amp;lt;br/&amp;gt;&amp;amp;amp; round &amp;amp;lt; cap?"}
    NG["node_negotiate&amp;lt;br/&amp;gt;top objector proposes · FOR/AGAINST coalitions"]
    AR["node_arbitrate&amp;lt;br/&amp;gt;referee brokers on priority rank (deterministic)&amp;lt;br/&amp;gt;+ writes the ruling in prose · logs the ledger"]
    EN(["END"])
    DR --&amp;gt; CR --&amp;gt; CD
    CD -- yes --&amp;gt; NG --&amp;gt; AR --&amp;gt; CR
    CD -- "no · stalled" --&amp;gt; EN
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;Draft once, then critique → negotiate → arbitrate until no hot objection remains.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule 1: Parallelize the debate. Serialize the decision.
&lt;/h2&gt;

&lt;p&gt;The expensive, creative part is the critique — five agents reading the whole caseload and reasoning about their objective. So &lt;strong&gt;run the five critiques concurrently&lt;/strong&gt;: one round-trip, not five sequential ones. That single change is the difference between a demo and a coffee break.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# orchestrator.py — the five advocates critique the SAME draft concurrently
&lt;/span&gt;&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nc"&gt;ThreadPoolExecutor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;max_workers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;advocates&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;ex&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;objections&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;group&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;ex&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_one&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;advocates&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;group&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The part that has to be &lt;em&gt;trustworthy&lt;/em&gt; — who wins — is the opposite. The referee's &lt;strong&gt;decision is deterministic Python&lt;/strong&gt;; only its &lt;strong&gt;rationale is the LLM&lt;/strong&gt;. Autonomy lives where it earns its keep (what to flag, how hard, why) and stays out of where it doesn't (the ruling itself). You get a negotiation that's alive but reproducible.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# orchestrator.py — the verdict is a fixed priority ranking, never an LLM
&lt;/span&gt;&lt;span class="n"&gt;_RANK&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;capacity&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;priority&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;40&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;window&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;continuity&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;preference&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_decide&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;forc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;against&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;any&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;agent&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;capacity&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;against&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;                    &lt;span class="c1"&gt;# capacity veto — never break feasibility
&lt;/span&gt;    &lt;span class="n"&gt;fr&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;_RANK&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;agent&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;forc&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;default&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;ar&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;_RANK&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;agent&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;against&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;default&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;fr&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;fr&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;ar&lt;/span&gt;           &lt;span class="c1"&gt;# approve iff FOR outranks AGAINST
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Design heuristic:&lt;/strong&gt; put the LLM on the reasoning, keep a deterministic rule on the verdict. It's testable, it's cheap, and it can't drift.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Rule 2: Keep the judge outside the system
&lt;/h2&gt;

&lt;p&gt;Both planners — the single agent and the society — are scored by the &lt;strong&gt;same pure-Python function&lt;/strong&gt;. No LLM anywhere near it. It's locked in CI.&lt;/p&gt;

&lt;p&gt;That's not decoration. If the thing declaring the winner is an LLM, "the society is better" is a vibe. If it's forty deterministic lines that neither planner controls, it's a claim. On the same scorer, same week, the society reaches a plan the single agent can't.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# scorer.py — one pure-Python function, no LLM anywhere, CI-locked
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;score&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;assignments&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;patients&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;clinicians&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;slots&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;weights&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;meta&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="p"&gt;...&lt;/span&gt;   &lt;span class="c1"&gt;# multi-objective: acuity coverage, overdue days, continuity, capacity, preference
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Rule 3: Show the number you can defend
&lt;/h2&gt;

&lt;p&gt;Here's the honest part. The first time I ran the full protocol on real Qwen, the live society scored &lt;em&gt;lower&lt;/em&gt; than my deterministic reference implementation of the same protocol.&lt;/p&gt;

&lt;p&gt;For a day that looked like a bug. It wasn't. The rule-based reference runs its critique to exhaustion — it finds &lt;em&gt;every&lt;/em&gt; fixable conflict. The live LLM critique is sharper about what it flags but flags &lt;em&gt;fewer&lt;/em&gt; things per round, so it converges earlier. Same scorer, honest gap.&lt;/p&gt;

&lt;p&gt;So I had a choice: quote the big reproducible ceiling, or show the real run for what it is. I showed the real run. On live Qwen the society climbs &lt;strong&gt;160 → 181 (+21)&lt;/strong&gt; over twelve rounds — continuity breaks 30 → 25, preference misses 26 → 17, high-acuity coverage held throughout.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F54tr8y5lc1hre3bs1vqm.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F54tr8y5lc1hre3bs1vqm.png" alt="Same week, same scorer: the society scores 181 versus the single agent's 160 — a +21 multi-objective gain" width="800" height="250"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Same week, same deterministic scorer — the society (+21) recovers the continuity and preference a single agent abandons.&lt;/em&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Show the number you can defend on a machine a stranger controls, not the number you wish you had. A smaller live win beats a bigger offline one.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;
  
  
  Rule 4: If it costs money per click, gate it
&lt;/h2&gt;

&lt;p&gt;I wanted judges to run it live. Which means an open public URL that fires real Qwen — a way to donate your token voucher to the first crawler that finds it.&lt;/p&gt;

&lt;p&gt;So the demo has two doors. &lt;strong&gt;▶ Replay&lt;/strong&gt; plays a recorded real negotiation for anyone, free, no key. &lt;strong&gt;◉ Run live&lt;/strong&gt; and &lt;strong&gt;⟳ Re-plan&lt;/strong&gt; stream a fresh real negotiation over SSE, &lt;strong&gt;token-gated&lt;/strong&gt; — only the judge link fires the models. Same protocol; the cost is contained.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# api.py — the live SSE endpoint bills the voucher, so gate it (constant-time compare)
&lt;/span&gt;&lt;span class="n"&gt;gate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;REHABPANEL_DEMO_TOKEN&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;gate&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;hmac&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compare_digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;gate&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;JSONResponse&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;error&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;live negotiation is token-gated&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="n"&gt;status_code&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One more trick that keeps live runs watchable instead of expensive: an &lt;strong&gt;explicit context cache&lt;/strong&gt; on the invariant caseload. The big patient-and-slot table is identical on every advocate call, every round, so it's cached once (~99% prefix hit) instead of re-billed forty times.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# advocates.py — mark the invariant caseload block so Qwen caches the prefix
&lt;/span&gt;&lt;span class="n"&gt;sysblocks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;_caseload_ref&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
     &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cache_control&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ephemeral&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}},&lt;/span&gt;   &lt;span class="c1"&gt;# DashScope context-cache marker
&lt;/span&gt;    &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;system&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;        &lt;span class="c1"&gt;# + this advocate's role (changes)
&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What you take away
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Make the conflict a first-class object.&lt;/strong&gt; A society that argues produces an auditable ledger; a single agent produces an opaque answer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Parallelize critique, serialize the verdict&lt;/strong&gt; — LLM on the reasoning, deterministic rule on the decision.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep the scorer pure and external&lt;/strong&gt;, or your "win" isn't measurable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deploy it, and gate what costs money.&lt;/strong&gt; A live, clickable demo is worth ten screenshots — as long as a crawler can't run up the bill.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A single agent collapses the trade-off. A society forced to negotiate under a referee reaches what one agent can't — and shows you exactly why each patient sits where they do.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Code (MIT): &lt;a href="https://github.com/jwlai-cloud/rehabpanel" rel="noopener noreferrer"&gt;github.com/jwlai-cloud/rehabpanel&lt;/a&gt;. Live on Alibaba Cloud — ▶ Replay is free; ◉ Run live is token-gated. Built entirely on Qwen Cloud (dashscope-intl), LangGraph, FastAPI. Decision support, fully synthetic data — no real patient records, anywhere.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>agents</category>
      <category>python</category>
      <category>showdev</category>
    </item>
  </channel>
</rss>
