AI Engineering
Five Agents, One Football Team: Per-Role Model Selection and Deterministic Guardrails on AgentCore
I entered an agent football tournament: five LLM-driven players on Bedrock AgentCore, one command per game tick, every tick latency-sensitive. Per-role Nova model selection, memory switched off, and deterministic rules applied after the LLM made the team work — and then command fingerprinting in CloudWatch proved my perfectly healthy runtimes had been playing for the wrong team all along.
· 9 MIN READ
I entered an agent football tournament recently: 5v5 simulated football where every player is an autonomous agent. Each game tick, the simulator sends your agent the game state — positions, possession, stamina, the coach’s live instructions — and your agent answers with exactly one command: PASS, SHOOT, MARK, INTERCEPT, FOLLOW_PLAYER. Miss the latency window and your player stands still while the opposition does not.
By every signal I had, my five agents were a success. Five Amazon Bedrock AgentCore runtimes, all READY. 100% command response success across every match. Disciplined, balanced play from kickoff to final whistle.
The uncomfortable part: they were playing for the wrong team. The match reports I was reading to tune my tactics described a different set of runtimes entirely, and nothing — not health checks, not latency, not the team label printed on the report — could tell me that. The only thing that could was counting commands in CloudWatch.
This post covers the three decisions that made the team work — one runtime per player with per-role model selection, memory deliberately switched off, and deterministic rules applied after the LLM — and the observability lesson that turned out to matter more than all three.
One runtime per player, no orchestrator
The obvious architecture for a five-player team is a single orchestrating agent that reasons about the whole pitch and dispatches commands to five puppets. I deliberately did not build that.
The tournament scored rapid deployment and match participation, and every tick was latency-sensitive. An orchestrator is a serialization point: five players’ decisions queue behind one model call, and one slow response stalls the entire team for that tick. It is also a single blast radius — one bad deploy and you forfeit five positions at once.
Instead I deployed five independent Strands agents, each on its own AgentCore runtime, each owning exactly one player in a 1-2-1 outfield formation: a goalkeeper, a sole defender, a creative midfielder, a striker, and a box-to-box second midfielder. Five small blast radii. A failure or a slow tick affects one player, not the team — and five runtimes deploy in parallel, which mattered under a deployment clock.
One model cannot play every position
The first thing that fell out of load-testing the tick loop: there is no single model that fits all five roles. A model with enough reasoning depth for the creative midfielder is too slow for a goalkeeper who must react inside the tick window. A model fast enough for the goalkeeper produces embarrassing tactical reads in midfield.
So each role got the smallest model that could play it:
| Position | Decision profile | Model |
|---|---|---|
| GK | reflex decisions, tiny action space | Amazon Nova Micro |
| DEF (sole defender) | positional discipline, marking | Amazon Nova Lite |
| MID (creative) | through balls, tempo, hardest tactical reads | Amazon Nova Pro |
| FWD | finishing runs, latency-critical | Amazon Nova Micro |
| MID2 (box-to-box) | pressing decisions, support play | Amazon Nova Lite |
The logic is boring and that is the point. The goalkeeper and the striker live or die on reaction time and choose from a handful of plausible actions, so they run Nova Micro — the fastest, cheapest option. The defender and the box-to-box midfielder need somewhat better judgment about when to hold a line or press, so they run Nova Lite. Exactly one player on the pitch is paid to be clever — the creative midfielder — and only that runtime carries Nova Pro and its latency cost.
Three model tiers, one fleet, and the expensive model runs on 20% of the team instead of 100%. If you multiply a per-tick model call by five players by every tick of every match, the difference between “Pro everywhere” and “Pro where creativity lives” is most of your latency budget and most of your bill.
Memory: off, deliberately
AgentCore ships a memory subsystem, and for most agents it is the right default. I turned it off for the competition.
Every tick is latency-sensitive, and the stock memory path persists state on each interaction — write amplification for context the next tick does not need. Football state is fully observable: the simulator resends positions, possession, and stamina every tick anyway. Persisted memory would have added latency in the hot loop to store information that arrives fresh 100 milliseconds later.
The tactical identity — role, formation, the manager’s philosophy — lives in the system prompt. Everything else is stateless. During a timed competition, that is a feature, not a compromise.
The LLM proposes, physics disposes
The most important line in the architecture sits after the model call. Every command an LLM produced went through a deterministic rules layer before reaching the simulator — because prompt instructions alone demonstrably did not hold.
Fatigue was the clearest case. Every system prompt said, in several phrasings, “conserve energy when stamina is low.” The models sprinted anyway. The fix was not a better prompt; it was a hard rule: sprints are stripped from any command when the player’s stamina drops below 35%, and low-stamina pressing is rewritten into an interception attempt. Predictable energy management, guaranteed by code, whatever the model proposes. (The same layer normalizes stamina itself, because the field arrived sometimes as 0..1 and sometimes as 0..100 — a classic simulator quirk that a prompt cannot see and a rule handles in one line.)
The rest of the deterministic envelope, in the same spirit:
- Possession truth. The game state could report
hasBall=truefor my player while a legacy possession field still named someone else. Explicit per-player possession takes precedence, deterministically — an agent that reasons from the wrong possession bit plays an entirely different match. - Marking conversions. A dangerous opposition run converts the sole defender’s command into
MARKtight, and the box-to-box midfielder’s loose pressing converts intoFOLLOW_PLAYER. With only one defender in a 1-2-1, you do not let a language model freelance on marking assignments. - Command hygiene. The parser filters out commands the simulator does not support (
RESET,CLEAR_OVERRIDE) that models occasionally hallucinate, and maps tactical stances onto the simulator’s integer enum (0=Balanced,1=Defensive,2=Attacking) rather than trusting the model to emit the right integer. - Forced shooting zones. Inside defined zones, the midfielders’ and striker’s commands are rewritten to
SHOOT. LLMs, it turns out, love one more pass.
The generalization: put the boundary between stochastic and deterministic exactly where correctness stops being negotiable. The model chooses among safe actions; the rules layer guarantees the envelope. Latency helps too — a post-processing rule costs microseconds, while pushing the same constraint into the prompt costs tokens on every single tick.
Healthy runtimes, wrong team
Now the part that rearranged my priorities.
All five runtimes were READY. Logs showed correct prompts, correct role behavior, 100% response success. And the match reports for my team described a pressing-heavy playstyle I had explicitly engineered against. Either my guardrails were broken, or something stranger was going on.
Nothing in the runtime’s own telemetry could answer that question, because runtime health proves the plumbing works — it proves nothing about where the output lands. So I stopped reading labels and started fingerprinting behavior: aggregate the per-command counts from my runtimes’ CloudWatch logs for one match, and compare them against the per-team command breakdowns in the tournament’s match reports.
The final match settled it. My runtime logs against one team’s report — not the team carrying my label:
| Command | My runtime logs | That team’s match report |
|---|---|---|
| INTERCEPT | 100 | 100 |
| FOLLOW_PLAYER | 84 | 84 |
| PASS | 40 | 40 |
| GK_DISTRIBUTE | 21 | 21 |
| SHOOT | 16 | 16 |
| MARK | 107 | 106 |
| PRESS_BALL | 72 | 73 |
Seven command types, near-exact across the board. That distribution is a fingerprint — the odds of another runtime set independently producing it are nil. My agents had played that team’s disciplined 0-0 draw. The team registered under my label was driven by someone else’s runtimes, and I had spent the evening reading its report as feedback on my prompts.
The registration mix-up itself is a tournament artifact. The lesson is not. In any multi-runtime system, attribution is a claim to verify, not a label to trust. Average latency could not distinguish the two teams. Team names actively misled. The only evidence that held up was a distinctive behavioral fingerprint correlated across two independent data sources. If I ran the tournament again, match one would be sacrificed to ownership validation — emit a deliberately skewed command distribution, confirm it shows up under my name, then start tuning.
The general pattern
Strip away the football and three transferable rules remain:
- Right-size models per role, not per system. Latency budget and reasoning depth are properties of each role’s decision profile. Buy intelligence only where the decisions are hard, and let the reflex roles run the smallest model that survives.
- The LLM proposes, code disposes. Every constraint that must always hold — safety, fatigue, possession truth, protocol validity — belongs in a deterministic layer after the model, not in the prompt. Prompts shape tendencies; they do not enforce invariants.
- Verify attribution behaviorally before tuning from downstream reports. Health checks tell you a component runs. Only a fingerprint — a distinctive output distribution traced from your own logs to the downstream system — tells you which component’s output you are looking at.
Honest recommendation
If you are building a real-time multi-agent system on AgentCore, resist the reflex to give every agent the best model and a memory. Start every role on the smallest model available and promote only the roles that show a measurable reasoning deficit — in my team that was one player out of five. Disable memory in any hot loop where the environment resends full state; you are paying write latency to remember what you are about to be told. Write your invariants as post-processing rules from day one, because the prompt version of “never sprint below 35% stamina” is a suggestion and the code version is a guarantee. And before you tune anything based on downstream reports, prove the pipeline’s attribution with a behavioral fingerprint from your own logs. My runtimes were healthy, correct, and playing for the wrong team — and every dashboard I had called it a success.
ABOUT THE AUTHOR
ONE LETTER A MONTH · NO TRACKER · UNSUBSCRIBE ANYTIME
CONTINUE READING
Related dispatches
One 'AI Gateway', Two Different Jobs: the LLM Gateway and the Tool Gateway
9 MIN READ
When Your AI Agent Runs Away: 204 PRs, $900 Wasted, and the 3-Layer Fix
13 MIN READ
AWS DevOps Agent: Build vs Buy for Enterprise AIOps
13 MIN READ
Comments
Sign in to leave a comment
