Inspiration
A while back I built a supply chain demo with four agents: demand forecasting, inventory, procurement, and order processing. It demoed well. It was also mostly hollow, and I knew exactly where.
The worst part was the forecasting agent. I had the LLM itself predict demand. During the demo I would say "in production we'd use a real model here" and move on. That gap stayed with me long after the demo was over, because it wasn't a missing feature. It was the model doing a job it had no business doing, dressed up as capability.
The order processing agent had the opposite problem. Routing an order by warehouse proximity and stock levels is deterministic, and it was already solved in code years ago. I wrapped an agent around it because I wanted a fourth agent.
So when this hackathon came up, I wanted to build something where I couldn't cheat the same way. Airline irregular operations turned out to fit, because in that domain a bad suggestion and an illegal one are different categories. A crew assignment that breaks a duty-time limit isn't a judgement call you can prompt your way around. Either the crew member is type-rated and inside their hours, or the flight doesn't go.
That constraint is what the whole project is built around.
What it does
AeroFleet OCC recovers a disrupted airline day on its own.
A seeded simulator generates a world: 8 airports, 18 aircraft, 62 flights, and about 400 crew, each with a home base, a set of aircraft they are rated to fly, and a duty clock. At 07:15 a thunderstorm cuts Delhi's runway capacity to 20 percent. Departures back up, aircraft arrive late, rotations break, and crew land in cities they are not supposed to be in. Left alone, that day ends with 7 cancellations and 998 stranded passengers.
Three Gemini agents work the disruption. A commander triages what is breaking and routes it. A crew recovery agent finds legal replacements. A fleet recovery agent re-equips aircraft, holds flights, or cancels them as a last resort.
Every action they propose is checked by a deterministic gate before it touches anything. The gate has no model in it. When it refuses, it returns the reason as a sentence, and the agent re-plans against the actual constraint:
swap_aircraft refused: VT-B3700 is a B737; AI121 is crewed for A320, the roster would be invalid
Then the part I care about most. The same seed and the same storm are replayed with every agent switched off, and the live run is scored against that. On a verified run the fleet avoided 2 cancellations and protected 286 passengers, with 47 actions refused by the guardrail or the gateway along the way. Those numbers are computed on every page load, not typed into a slide.
How we built it
Four layers, and only one of them contains a model:
| Layer | Job | LLM |
|---|---|---|
| Simulation engine | what happens in the world | no |
| Agent fleet | what we should do about it | yes |
| Guardrail gate | whether we are allowed to do it | no |
| Evaluator | whether it actually helped | no |
The simulator is a discrete-event loop over a heap. Events schedule other events, so one weather change turns into a chain: capacity drops, a flight is held, it departs late, arrives late, and the next flight on that same aircraft gets pushed back, which pushes the one after that. A single run produced 15 of those knock-on failures.
For the fleet I used the Google GenAI SDK with Gemini 3.5 Flash and manual function calling. I deliberately avoided automatic tool execution, because the guardrail has to sit between the model and every side effect, and that means owning the loop.
Three GEAP ideas do real work rather than decoration. The registry is a Firestore collection of agent cards, and the commander looks up specialists by declared capability instead of holding references to them. The gateway is a single function every tool call passes through, which is where authorisation, tracing, and identity all live. Identity is a tool allowlist on each card, and it is enforced: the crew agent cannot cancel a flight, because the gateway refuses the call.
It runs on Cloud Run with Firestore behind it. The dashboard is one static HTML file that polls the API every second, because with the time I had, a build step was a liability.
Challenges we ran into
The storm was not doing anything. This is the one I am glad I caught. I gave airports their real-world movement rates, so Delhi was 46 per hour. Cutting that to 25 percent still left 11 slots against demand of about 3, so the storm bound nothing at all. The dashboard turned red, the weather alert fired, and not a single flight was affected. All the damage in those runs was actually coming from the sick calls and the maintenance event. I fixed capacity by sizing it to each airport's own busiest hour, then found the second half of the same bug: the storm landed at 08:30, by which time Delhi's morning bank had already departed. It was hitting an empty airport. Moving it to 07:15 took cascade events from zero to fifteen.
Nothing ever took off. My crew seeding walked each crew member forward through the day to keep the roster consistent, then left them at their final destination. So at 06:00 nobody was where they were rostered, every flight was blocked, and the agents spent the whole run repairing my seeding instead of the storm.
Gemini 3.x and thought signatures. Gemini 3.x attaches a thought_signature to
function-call parts and requires it back on every call you show it again, but it does not
emit one on every turn. Replaying a normal multi-turn tool transcript failed with
400 INVALID_ARGUMENT ... position 2, every time. I first tried keeping only the first
call per turn to preserve the signature, and position 2 still failed. What worked was
making each agent turn a fresh stateless request carrying a plain-text observation log. It
costs a few hundred extra input tokens per turn on a Flash model and it cannot break this
way.
Gemini 3.x models are not in regional Vertex endpoints. gemini-3.5-flash returns 404
on us-central1 while appearing in that region's publisher listing, which is a confusing
place to be at hour four. It is served from global.
Cloud Run. The default compute service account had no permissions, so the first deploy
died on storage.objects.get denied. Google's frontend also swallows /healthz and
returns its own 404, so the health route is served at /api/health too. And a background
simulation loop needs --no-cpu-throttling, otherwise the clock freezes whenever nobody is
polling.
Accomplishments that we're proud of
The counterfactual works, and it is the thing I most wanted to get right. Every agent demo claims it helped. This one replays the identical seed with the agents switched off and shows the difference, so the claim is checkable by anyone who clicks the other button.
The guardrail is not theatre. In a single verified run it refused 47 actions, each with a reason the agent then used to pick differently. Watching a model propose putting a 737 on an A320-crewed flight, get refused, and come back with a legal aircraft is the moment the architecture stops being a diagram.
I caught the storm bug myself, before a judge could. It took writing the documentation and a test that asserted on cascade counts to notice that my most visually dramatic feature was changing nothing. I would rather find that at hour five than be asked about it later.
And the whole thing is reproducible without credentials. python3 test_aerofleet.py runs 8
deterministic checks in about two seconds, offline, and each one guards a failure I
actually hit.
I am also fairly proud of what the project says out loud. Delay minutes finished slightly negative, because the agents spent delay to buy back cancellations. That is a real trade and it is on the scoreboard rather than hidden.
What we learned
Let a guardrail return a reason, not a boolean. This is the single highest-leverage
decision in the codebase. The agent reads "CREW-102 is at BOM, AI103 departs CCU" and picks
someone else. A False would have made it guess.
Read tools should return facts, not verdicts. I had a version where the candidate search pre-filtered to legal crew. The model was never wrong, the guardrail never fired, and I had no evidence the safety layer did anything. Letting the model reason and sometimes fail is how you can see the gate working.
A simulated disruption is only real if it collides with demand. I shipped one twice that looked alarming and constrained nothing. What caught it was asserting on cascade counts in a test, not looking at the screen.
Write the check for the thing you are claiming. The assertion that an undisrupted day must produce zero delays is worth more than it sounds, because it fails the moment the engine drifts.
And the thing that started all of this: keeping the model out of the numeric path made the project stronger, not weaker. Duty limits and type ratings are arithmetic, so they stayed in arithmetic, and the model got to do what it is actually good at.
What's next for AeroFleet
The commander routes by capability string rather than using the model to triage. Its card and tools are already in place for LLM triage, and that is the next thing to land.
After that, passenger re-accommodation modelled per itinerary instead of scored in aggregate, and a replacement for my fixed weighting of a stranded passenger against a delay minute with a curve an actual airline would recognise. Hosted Model Armor is worth adding on the agent's outbound messaging, which is the one place in this system where a second safety layer would do something the deterministic gate does not.
Log in or sign up for Devpost to join the conversation.