Skip to content

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:

  1. 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 senderSystemId field and the RED- topic-id prefix.
  2. 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.
  3. 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:

  1. Subscribes uci-demo/solver/red-blueprint (this topic doesn't exist yet — PR #38 will extend the daemon to publish it).
  2. 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.
  3. 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-cap arithmetic
  • 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

  1. pnpm -r typecheck clean across 14 packages.
  2. pnpm -r test passes including the new red-agent test suite.
  3. pnpm run up:red boots a stack where red-agent emits tracks under topic ids prefixed with RED-. Within 15-30s of boot, first red track appears on the COP map.
  4. Validator audit at /audit?n=100 stays at 100% valid — red-agent's XML passes the same XSD checks every other publisher does.
  5. RED_AGENT_POLICY=solver-driven pnpm run up:solver:red logs [red-agent:solver-driven] no red-blueprint yet; running scripted fallback once at startup, then behaves identically to scripted.
  6. Default pnpm run up behavior unchanged. No red-agent emissions.
  7. 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 serializeBlueprint or 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

  1. 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.
  2. 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.
  3. 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.
  4. 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-blueprint topic for clarity even if it's the same table content with appropriate sign treatment.

None block writing the scripted backend.