Design — services/red-agent/ (Phase 0 weeks 5-7)¶
The last of the Phase 0 weeks-5-7 cells: a bus-symmetric Red service that plays through the UCI v2.5 wire instead of being baked into the world-sim's scenario. This gives Blue a real adversary independent of the scenario YAML's scripted track list and gives reviewers something to point at when they ask "where's the Red model?"
PR #34 shipped the solver-daemon (Blue's blueprint trainer). PR #35 made it tractable on full-depth Tripwire. PR #36 wired the SolverAgent into Blue's decisions. This PR ships the Red service — scripted backend only for Phase 0; the solver-driven backend is stubbed and falls back to scripted until PR #38 extends the daemon to also publish Red's blueprint.
The SBIR proposal's interpretability story relies on showing Blue-vs-Red gameplay through the bus. That requires this PR.
What ships¶
| Surface | File | Notes |
|---|---|---|
services/red-agent/ (new package) |
package.json + tsconfig.json + src/{main,redLoop,scenarios}.ts + src/policies/{scripted,solverDriven,index}.ts |
Bridge-pattern emission, structurally identical to services/adsb-bridge/ |
| Scripted policy | src/policies/scripted.ts |
Heuristic spawn-timing + trajectory generation, deterministic with seed |
| Solver-driven stub | src/policies/solverDriven.ts |
Subscribes uci-demo/solver/red-blueprint (future topic); falls back to scripted with a warn log until PR #38 |
USE_RED_AGENT=1 gate |
root package.json + services/red-agent/src/main.ts |
Opt-in env var; default pnpm run up unchanged |
pnpm run up:red |
root | Existing services + red-agent |
pnpm run up:solver:red |
root | Everything: solver-daemon + red-agent (full adversarial demo) |
Why this isn't an extension of services/world-sim¶
The plan is explicit: services/world-sim and services/red-agent
are separate processes even though both publish track-shaped UCI
emissions. Reasons:
- Bus-symmetric architecture — RFP attribute #1. Red plays
through the same MQTT pipeline as Blue's wire-side data; any
subscriber sees both indistinguishable except by the
senderSystemIdfield and theRED-topic-id prefix. - Policy ladder — scripted Red is the baseline; solver-driven Red comes next. Both live in one place so the difference is a single env flag.
- No coupling between world-sim and red-agent code paths. They
don't share state. Each publishes its own tracks, identified by
distinct
senderSystemId.
The COP renders both kinds of tracks identically because the validator + bus subscriber don't care about provenance. The distinction is only meaningful for diagnostics.
Bridge-pattern emission¶
Identical structural pattern to
services/adsb-bridge/src/bridge.ts:89-275. Per-track lifecycle:
NEW:
- buildEntityNotification(severity) → uci/v2_5/EntityNotificationMT/RED-TRACK-NNN
- buildEntity({ Identity, ThreatType, Confidence })
→ uci/v2_5/EntityMT/RED-TRACK-NNN
- buildPositionReport(lat, lon, alt) → uci/v2_5/PositionReportMT/RED-TRACK-NNN
UPDATE (every 1.5s while alive):
- buildPositionReport(lat, lon, alt) → uci/v2_5/PositionReportMT/RED-TRACK-NNN
LOST (engaged + killed, withdrew, or scenario timeout):
- buildEntityLost({reason}) → uci/v2_5/EntityLostMT/RED-TRACK-NNN
All builders are from @uci-demo/codec. The validator audits every
emission against the v2.5 XSDs — no special treatment for red-agent
payloads. The RED- topic-id prefix is purely informational; UCI
topic-id regex ([A-Za-z0-9_-]+) accepts it.
senderSystemId¶
newSystemId() from @uci-demo/codec returns a fresh UUID per
process. The red-agent calls it once at startup. The validator's
audit logs can then tally per-source emission counts (useful for
demo diagnostics: "1500 EntityNotificationMT from RED-AGENT vs 200
from WORLD-SIM").
Scripted policy¶
Heuristic spawn-timing + trajectory generation. Phase 0 ladder baseline — predictable but non-trivial.
Spawn schedule¶
- First spawn: at
T+RED_AGENT_FIRST_SPAWN_SEC(default 15s after red-agent boots). Gives operators time to settle before threats arrive. - Subsequent spawns: every
RED_AGENT_INTERVAL_SEC(default 45s). Optionally with a jitter window of ±10s for organic feel. - Max concurrent live tracks:
RED_AGENT_MAX_CONCURRENT(default 4). Beyond that, spawns are suppressed until existing tracks die or withdraw. - Lifetime cap:
RED_AGENT_TRACK_LIFETIME_SEC(default 180s). Tracks that haven't been killed by then withdraw on their own.
Trajectory generation¶
Each spawn:
1. Picks an origin on a 5 km perimeter circle around the FOB
centroid (FOB_CENTER from apps/cop-ui/lib/geo.ts). Random
azimuth seeded by the configurable RNG.
2. Picks a closing speed in [15, 35] m/s (typical UAS cruise).
3. Picks a threat type uniformly from
{JAMMER, MANNED_AIRCRAFT, MISSILE, ANTIAIRCRAFT_ARTILLERY}.
4. Updates lat/lon every 1.5s by (velocity / 1.5s) toward FOB
centroid, capped at the minimum-approach distance (no
over-fly).
Threat composition¶
For Phase 0, scripted Red emits all tracks as HOSTILE with high threat confidence (75-95%). No ambiguity (UNKNOWN / SUSPECT). The copilot's belief mirror will compute the correct posterior given the EntityNotificationMT severity, but the truth-on-the-wire is straightforward.
Solver-driven Red (PR #38+) can do more nuanced identity emission (e.g. mask hostile intent with a NEUTRAL identity to bait Blue into withholding).
Lifecycle: when does a track end?¶
- Engaged by Blue + effect succeeds — the world-sim publishes EffectStatusMT/EntityLostMT on the engagement plan. Red-agent observes EntityLostMT for its own topicId and removes the track from internal state. (Doesn't re-emit; world-sim already did.)
- Lifetime cap reached — red-agent emits its own EntityLostMT
with
lostReason: "WITHDREW". - Shutdown — on SIGINT/SIGTERM, red-agent emits EntityLostMT for every live track before disconnecting.
Deterministic seeding¶
RED_AGENT_SEED=<integer> (default 42) seeds the mulberry32 RNG
used for origin azimuth, threat type, and spawn jitter. Demos that
need reproducible outcomes can pin this.
Solver-driven backend (stub)¶
src/policies/solverDriven.ts is a thin stub that:
- Subscribes
uci-demo/solver/red-blueprint(this topic doesn't exist yet — PR #38 will extend the daemon to publish it). - Until a red-blueprint arrives, falls back to the scripted
policy for every decision. Logs ONCE per process startup:
[red-agent] solver-driven backend selected but no red-blueprint yet; running scripted fallback. - When/if a red-blueprint does arrive (future), deserializes it and uses it for action selection. Phase 0 stops here — full integration is PR #38 work.
The stub exists so the backend-selection plumbing + env var + operator workflow lands now. PR #38's daemon changes will Just Work once the topic starts flowing.
Backend selection¶
const POLICY = process.env.RED_AGENT_POLICY ?? "scripted";
// Valid values: "scripted" | "solver-driven"
solver-driven mode degrades to scripted behavior internally
(per above) but tags log lines with [red-agent:solver-driven] so
diagnostics show the intended backend even when fallback fires.
Integration with existing services¶
services/copilot¶
Reads EntityNotificationMT + EntityMT + PositionReportMT for
RED-TRACK-NNN topic ids exactly like any other track. The
worldMirror populates with red-agent's tracks; the SolverAgent /
LLM agent / scriptedAgent evaluate them as hostile-like; proposals
fire as normal. No copilot changes required.
services/world-sim¶
No changes. World-sim's threshold/destruction mechanics (PR #33)
only scan tracks it owns internally. Red-agent's tracks are visible
on the bus but don't trigger world-sim's losability events. That's
correct — Blue's score for engaging red-agent tracks comes through
the normal EntityLostMT → neutralizedHostiles++ pipeline, not via
threshold-crossing. If we want red-agent tracks to also fire
threshold/destruction, that's a separate workstream (extend
world-sim to subscribe to bus tracks beyond its own).
services/validator¶
Audits every red-agent emission against the v2.5 XSDs. Tagged in the
audit feed by the distinct senderSystemId. Target: 100% valid
just like every other publisher.
apps/cop-ui¶
Renders red-agent tracks identically to world-sim tracks. Operators
can engage them via the normal approval flow. The BeliefPanel and
TrackTimeline show belief posteriors and engagement history
without distinction.
TypeScript module layout¶
services/red-agent/
├── package.json # workspace deps on @uci-demo/{bus,codec}
├── tsconfig.json
├── src/
│ ├── main.ts # env parsing, bus connect, kick off redLoop
│ ├── redLoop.ts # main spawn + update + lost loop
│ ├── scenarios.ts # FOB-relative geometry helpers, threat-type pool
│ └── policies/
│ ├── index.ts # backend factory
│ ├── scripted.ts # heuristic spawn-timing implementation
│ └── solverDriven.ts # stub; falls back to scripted
└── test/
├── redLoop.test.ts # mock-bus spawn + lifecycle assertion
├── policies/
│ ├── scripted.test.ts # deterministic seed produces deterministic schedule
│ └── solverDriven.test.ts # stub falls back; log fires once
└── emission.test.ts # spawned tracks emit schema-valid XML
Total: 1 new workspace package, 8 source files, 4 test files.
Environment variables¶
| Var | Default | Purpose |
|---|---|---|
USE_RED_AGENT |
unset (off) | Gate flag in pnpm run up:red. Default off |
UCI_MQTT_URL |
mqtt://127.0.0.1:1883 |
Broker URL (shared with other services) |
RED_AGENT_POLICY |
scripted |
scripted or solver-driven |
RED_AGENT_SEED |
42 |
mulberry32 seed for spawn randomness |
RED_AGENT_FIRST_SPAWN_SEC |
15 |
Delay after boot before first spawn |
RED_AGENT_INTERVAL_SEC |
45 |
Spawn cadence |
RED_AGENT_MAX_CONCURRENT |
4 |
Live-track cap |
RED_AGENT_TRACK_LIFETIME_SEC |
180 |
Auto-withdraw timeout |
Defaults chosen so a fresh pnpm run up:red produces visible
threats within ~15 seconds, with a steady pace of 1 spawn / 45s
that's easy for an operator to follow.
Root package.json script additions¶
{
"scripts": {
"up": "...existing list (no red, no solver)",
"up:solver": "...existing list + solver-daemon",
"up:red": "...existing list + red-agent", // NEW
"up:solver:red": "...existing list + solver-daemon + red-agent" // NEW
}
}
Naming: up:<flavor> where <flavor> is the comma-of-feature flags.
Three flavors enable all permutations relevant to demos.
Tests¶
test/policies/scripted.test.ts¶
- Seeded mulberry32 → identical spawn-time + origin + threat-type sequence across runs (determinism check)
- Spawn count over a synthetic 5-minute window matches
interval / lifetime / concurrent-caparithmetic - Trajectory generator produces valid lat/lon (within Earth bounds)
- decreasing range from FOB
test/policies/solverDriven.test.ts¶
- With no red-blueprint published, returns scripted-equivalent decisions
- Logs the fallback warning exactly once per process startup
- Subscribes the right MQTT topic (
uci-demo/solver/red-blueprint)
test/redLoop.test.ts¶
- Mock bus + mock policy. After N ticks: emissions land on the correct topics with the correct topic-id prefix
- Track lifecycle: spawn → update → lost cycles fire the right emission sequence
- Shutdown: every live track gets an EntityLostMT before disconnect
test/emission.test.ts¶
- Every emitted XML payload passes
createValidator()'s XSD check from@uci-demo/codec(same gate the runtime validator uses)
Total ~12 test cases. Workspace target: 440 → ~452 tests.
Acceptance criteria¶
pnpm -r typecheckclean across 14 packages.pnpm -r testpasses including the new red-agent test suite.pnpm run up:redboots a stack where red-agent emits tracks under topic ids prefixed withRED-. Within 15-30s of boot, first red track appears on the COP map.- Validator audit at
/audit?n=100stays at 100% valid — red-agent's XML passes the same XSD checks every other publisher does. RED_AGENT_POLICY=solver-driven pnpm run up:solver:redlogs[red-agent:solver-driven] no red-blueprint yet; running scripted fallbackonce at startup, then behaves identically to scripted.- Default
pnpm run upbehavior unchanged. No red-agent emissions. - Copilot's SolverAgent (PR #36) sees red-agent tracks via the bus and evaluates them like any other; no code change required.
What this memo deliberately does not specify¶
- Daemon-side red-blueprint publish — PR #38 extends
serializeBlueprintor adds a separate topic. Coordinates with red-agent's solver-driven backend. - Multi-scenario red-agent YAML — Phase 0 hardcodes the spawn geometry and threat-type pool. YAML scenarios for Red follow when the eval-harness (weeks 7-9) demands cross-scenario benchmarks.
- Red-agent's own threshold/destruction events — out of scope.
Red-agent tracks are visible + engageable, but their crossing the
FOB threshold does not fire
uci-demo/world/threshold_crossed. World-sim's tracks still drive losability. - Red-agent operator UI — no UI for tuning RED_AGENT_* params at runtime. Env-var gating is enough for Phase 0; operator overlay is a follow-up.
- Red-agent metrics in COP — no dedicated red-emission count or red-agent status pill. The existing track lozenges + wire ticker already show red activity. A SolverPill-equivalent for red-agent is Phase II ergonomics.
Open questions¶
- Identity emission for scripted Red — memo specifies HOSTILE for all tracks. Should we mix in SUSPECT / UNKNOWN to exercise the copilot's belief-update path more thoroughly? Phase 0 recommendation: HOSTILE only (predictable demo); diversification is solver-driven Red's job.
- Spawn-origin azimuth distribution — uniform around the compass means roughly equal chance per direction. Real ops intel would weight by likely approach vectors. Phase 0: uniform; bias as a later config knob.
- Lifetime cap (180s) — how it interacts with operators who want to demo "what if I do nothing for a while?" If 4 tracks accumulate and the operator does nothing, after 180s they all auto-withdraw and the scenario quiets. Acceptable — that's realistic behavior. Operator can extend via env var.
- Solver-driven backend in PR #38 — should it use Red's
regret table from a separate blueprint or share Blue's table
with sign flipping? Zero-sum mathematics says Red's strategy
is the response to Blue's; the same regret table reads "Red's
strategy" via the dual / sign-flipped utilities. Phase 0
recommendation: separate
red-blueprinttopic for clarity even if it's the same table content with appropriate sign treatment.
None block writing the scripted backend.