Agents API Reference
Module location
All agents live in src/agents/. Each file is extracted from its corresponding notebook (N25–N31).
Entry points
Every agent has two entry points:
| Agent | FastF1 Entry | RSM Adapter (no FastF1 session) |
|---|---|---|
| N25 Pace | run_pace_agent(**kwargs) |
run_pace_agent_from_state(lap_state) |
| N26 Tire | run_tire_agent(stint_state) |
run_tire_agent_from_state(lap_state, laps_df) |
| N27 Situation | run_race_situation_agent(lap_state) |
run_race_situation_agent_from_state(lap_state, laps_df) |
| N28 Pit | run_pit_strategy_agent(lap_state) |
run_pit_strategy_agent_from_state(lap_state, laps_df) |
| N29 Radio | run_radio_agent(lap_state, persist) |
run_radio_agent_from_state(lap_state, laps_df, persist) |
| N30 RAG | run_rag_agent(question) |
run_rag_agent_from_state(lap_state, laps_df) |
| N31 Orchestrator | run_strategy_orchestrator(race_state, lap_state) |
run_strategy_orchestrator_from_state(race_state, laps_df, lap_state) |
N25 is the odd one out: it takes no laps_df. Everything it needs must already be in the
lap_state, which is why the stint fuel baseline is carried there rather than derived from a
frame. The adapters are not uniform, so check the signature before assuming.
Only the RAG agent (N30) exposes a module-level get_rag_react_agent() factory returning a compiled LangGraph CompiledGraph, for callers that want to drive the graph directly — it is genuinely invoked (the chat backend calls it). N25-N28 (pace, tire, race situation, pit strategy) used to have an equivalent free function each; all four were confirmed dead (zero callers anywhere in the repo) and removed in the 2026-08-01 cleanup pass. Tire, race situation and pit strategy still have their own get_react_agent() instance method, called internally from each agent's own _run_core — only the redundant module-level wrapper was removed for those three. The radio agent (N29) never had one: its pipeline is a fixed sequence of model calls rather than a ReAct loop, so there is no graph to compile.
N25 (pace) is deliberately deterministic — no ReAct loop at all. Unlike its three siblings above, pace's run()/run_from_state() call the N06 XGBoost model directly; there is no LLM step to invoke and no category field (warning_level/action/threat_level) for an LLM to decide. pace_agent.py used to carry a complete but never-wired LangGraph ReAct scaffold (tools, system prompt, get_react_agent()) from the moment the module was first extracted — confirmed by archaeology to have been a deliberate per-agent choice made in the same commit that wired tire/pit/race_situation, not a wiring gap. It was formally retired (deleted, not left dead) in #781; see #778 for the full decision record.
Output dataclasses
PaceOutput (N25)
| Field | Type | Description |
|---|---|---|
lap_time_pred |
float | Predicted lap time in seconds |
delta_vs_prev |
float | Delta vs previous lap (negative = faster) |
delta_vs_median |
float | Delta vs session median |
ci_p10 |
float | 10th percentile bootstrap CI |
ci_p90 |
float | 90th percentile bootstrap CI |
reasoning |
str | Deterministic summary string (no LLM call — see the note above) |
TireOutput (N26)
| Field | Type | Description |
|---|---|---|
compound |
str | Current compound name (SOFT, MEDIUM, HARD) |
current_tyre_life |
int | Current tire age in laps |
deg_rate |
float | Degradation rate (seconds lost per lap) |
laps_to_cliff_p10 |
float | 10th percentile laps until cliff |
laps_to_cliff_p50 |
float | 50th percentile laps until cliff |
laps_to_cliff_p90 |
float | 90th percentile laps until cliff |
warning_level |
str | OK, MONITOR, or PIT_SOON (derived from laps_to_cliff_p10 against circuit-cluster-aware thresholds; there is no CRITICAL value) |
reasoning |
str | LLM-generated reasoning text |
RaceSituationOutput (N27)
| Field | Type | Description |
|---|---|---|
overtake_prob |
float | None | Probability that our car passes the car ahead (0–1). None when that car is farther away than the 2.5 s gap N11 was trained on — the model has no labelled example out there, so it declines rather than extrapolating. None is not 0.0: zero is what the regulation asserts under a Safety Car (Art. 55.8). |
sc_prob_3lap |
float | Safety car probability within 3 laps (0–1) |
sc_currently_active |
bool | Any neutralisation (full Safety Car or Virtual Safety Car) is deployed right now. Not a prediction: it is read from the lap's RCM events, because N14 was trained to forecast a future SC and cannot recognise one already out. When true it forces the regulatory facts (sc_prob_3lap = 1.0, overtake_prob = 0 per Art. 55.8/56.6, drs_window = 0 per Art. 22.1(c)) and activates N28. It does not force the action: whether to pit under a neutralisation is race state, not a rule. See Multi-agent system. |
vsc_active |
bool | The active neutralisation is specifically a Virtual Safety Car (Art. 56), only meaningful when sc_currently_active is true. Split out (#471) because a VSC and a full SC differ in pit-time saving, and the Monte Carlo / N28 prompt need to tell them apart, the single sc_currently_active flag could not. sc_active (a derived property, not a stored field) is true only under a full SC: sc_currently_active and not vsc_active. |
threat_level |
str | LOW, MEDIUM, HIGH |
gap_ahead_s |
float | Gap to car ahead in seconds |
pace_delta_s |
float | Pace difference vs car ahead |
reasoning |
str | LLM-generated reasoning text |
PitStrategyOutput (N28)
| Field | Type | Description |
|---|---|---|
action |
str | STAY_OUT, PIT_NOW, UNDERCUT, OVERCUT, or REACTIVE_SC (box for an elevated-but-not-yet-confirmed SC probability; when an SC is already confirmed deployed, N28 prefers PIT_NOW directly) |
recommended_lap |
int or None | Suggested pit lap |
compound_recommendation |
str | Suggested next compound |
stop_duration_p05 |
float | 5th percentile stop duration (s) |
stop_duration_p50 |
float | Median stop duration (s) |
stop_duration_p95 |
float | 95th percentile stop duration (s) |
undercut_prob |
float or None | Undercut success probability (0–1) |
undercut_target |
str or None | Target driver for undercut |
sc_reactive |
bool | Whether recommendation is SC-reactive |
reasoning |
str | LLM-generated reasoning text |
RadioOutput (N29)
| Field | Type | Description |
|---|---|---|
radio_events |
list | Processed radio messages with sentiment, intent, NER |
rcm_events |
list | Processed Race Control Messages |
alerts |
list | Deterministic alert flags from NLP pipeline |
reasoning |
str | LLM-generated reasoning text |
corrections |
list | Driver-reported corrections (damage, handling issues) |
RegulationContext (N30)
| Field | Type | Description |
|---|---|---|
answer |
str | Synthesized answer from regulation passages |
articles |
list[str] | Referenced FIA article numbers |
chunks |
list | Raw retrieved text chunks |
reasoning |
str | Alias for answer |
StrategyRecommendation (N31)
The v2 schema (frozen at 14 fields) surrounds the primary action with execution detail, driver-side instructions and a contingency list, see Multi-agent system for the full rationale.
| Field | Type | Description |
|---|---|---|
action |
str | STAY_OUT, PIT_NOW, UNDERCUT, OVERCUT, ALERT, the primary decision |
reasoning |
str | Multi-sentence LLM synthesis of all sub-agent inputs, MC scores and regulation constraints |
confidence |
float | 0–1 LLM self-assessed certainty; treat as qualitative, not calibrated |
pit_lap_target |
int or None | Absolute lap of the planned stop. Populated for PIT_NOW/UNDERCUT/OVERCUT, optionally for a forward-looking STAY_OUT plan |
compound_next |
str or None | Compound (SOFT/MEDIUM/HARD) chosen for the next stint; None for STAY_OUT |
undercut_target |
str or None | Rival code targeted by an UNDERCUT/OVERCUT (e.g. "SAI") |
pace_mode |
str | PUSH, NEUTRAL, MANAGE, LIFT_AND_COAST, driving instruction for the next laps (default NEUTRAL) |
target_lap_time_s |
float or None | Target lap time, grounded in N25's CI bounds so the LLM cannot invent a value far outside the model's prediction. Forced to None under an active SC/VSC (Art. 55.7, see Multi-agent system) |
risk_posture |
str | AGGRESSIVE, BALANCED, DEFENSIVE, the championship stance the LLM reasons under (default BALANCED) |
contingencies |
list[Contingency] | Conditional branches for upcoming laps, capped at four. Each has trigger (plain-language event), switch_to (replacement action), priority (HIGH/MEDIUM/LOW), rationale (short justification) |
key_risks |
list[str] | Up to five short bullets flagging risks the LLM wants to surface outside the narrative |
expected_stint_end |
int or None | Lap the current stint is planned to end. Clamped against a physical anchor: pit_lap_target plus the shorter of the N26 cliff P50 and the next compound's Pirelli stint capacity, bounded by total race laps, accepting the LLM's value only within ±3 laps of that anchor (#433); falls through unclamped when no anchor (missing pit_lap_target/compound_next/cliff) is available |
scenario_scores |
dict | Full MC output per candidate: {"STAY_OUT": {"E", "P10", "P90", "score"}, ...}. Attached in code after the LLM call, not filled by the LLM. On the projection path each candidate also carries eligible (bool) and target (str or null), and an ineligible candidate has score: null, an undercut with no reachable rival, or an overcut with nobody in the pit lane, is reported as not offered rather than given a number it did not earn. Consumers must skip nulls; coercing one to 0.0 draws a real-looking score for a strategy that was never on the table |
regulation_context |
str | N30 RAG answer when activated, empty string otherwise. Attached in code after the LLM call |
RaceState input (N31)
The orchestrator accepts a RaceState Pydantic model:
class RaceState(BaseModel):
driver: str # Three-letter driver code
lap: int # Current lap number
total_laps: int # Total race laps
position: int # Current race position
compound: str # Current tire compound
tyre_life: int # Current tire age (laps)
gap_ahead_s: float # Gap to car ahead (seconds)
pace_delta_s: float # Pace delta vs car ahead
air_temp: float # Air temperature (C)
track_temp: float # Track temperature (C)
rainfall: bool = False
radio_msgs: list = [] # RadioMessage dicts for current lap window
rcm_events: list = [] # RCMEvent dicts for current lap window
risk_tolerance: float = 0.5 # 0=conservative, 1=aggressive
Model artifacts
| Agent | Model directory | Files |
|---|---|---|
| N25 | data/models/lap_time/ |
XGBoost model |
| N26 | data/models/tire_degradation/ |
TireDegTCN .pt files + calibration JSON |
| N27 | data/models/overtake_probability/ |
LightGBM + calibrator + config |
| N27 | data/models/safety_car_probability/ |
LightGBM + calibrator + feature list |
| N28 | data/models/pit_prediction/ |
HistGBT P05/P50/P95 + undercut LightGBM |
| N29 | data/models/nlp/ |
pipeline_config_v1.json + .pt state dicts |
| N30 | data/rag/ |
Qdrant index (built by scripts/build_rag_index.py) |
LLM tool-level input validation
Every LangChain tool the LLM can call takes free-text arguments (driver, lap_number, gp_name, year, ...) with no server-side roster or range check other than what the tool itself enforces. A hardening pass (#476) closed the gap where a hallucinated or long-retired driver code, or a lap outside the loaded session, produced a confident but meaningless prediction instead of a visible failure:
| Agent | Tools guarded | Refuses when |
|---|---|---|
| N26 Tire | predict_tire_deg_tool, estimate_laps_to_cliff_tool |
driver is not on track at the currently loaded lap, or the loaded current_lap is outside [1, total_laps] |
| N27 Situation | predict_overtake_tool, predict_sc_tool |
lap_number is out of range, or (for predict_overtake_tool) either driver is unknown for that lap |
| N28 Pit | predict_pit_duration_tool, score_undercut_tool |
the named driver (driver/driver_y) is not present in the live roster for the current lap |
N25 Pace originally had an equivalent guard on its own predict_pace_tool (#476), but that tool was deleted along with the rest of pace's unreachable LangGraph scaffold in #781 — pace has no LLM-facing tool left to guard, so the class of bug #476 fixed cannot occur for it anymore.
The refusal is a plain string return (e.g. "error: 'HAM' is not on track at lap 12; valid: [...]" or "... REFUSED — {driver} is not on track ..."), not an exception, the LLM sees a normal-looking tool result it can react to, rather than a traceback. predict_pit_duration_tool additionally cross-checks the LLM-supplied under_sc flag against the RCM-confirmed sc_currently_active ground truth when a real orchestrator run has set it, and trusts the confirmed value over the guess (logging a warning) rather than the other way round.
"On track" is a presence check (the driver has a row in the live roster for this lap), the same convention RaceStateManager uses for rivals, see Race replay engine → who counts as a rival. An age/lap-count cutoff cannot substitute for it: a finisher can go 20 laps without a row, and a retirement can surface as few as 9 laps in, so the ranges overlap.
The MCP-facing tools one layer up (src/telemetry/backend/mcp_tools.py, consumed by chat) apply an equivalent guard on gp/driver/lap/year before they even reach these agent-level tools, see Backend API reference → Tool risk tiers and the chat allowlist.
Testing examples
Tool-level (no LLM needed):
from src.agents.radio_agent import process_radio_tool
result = process_radio_tool.invoke({
"driver": "NOR", "lap": 18, "text": "Box this lap."
})
Agent-level (no LLM needed):
Whatever else a *_from_state entry point takes, its lap_state must be the real
nested dict (driver / rivals / weather / session_meta), not a flat one. Most of
them take a laps_df alongside it; the table above has the exact shapes, and they are
not uniform. Build it
with RaceStateManager rather than by hand: it is the component that owns the contract,
and hand-rolling one is how the second, buggy implementation of this got written.
from pathlib import Path
from itertools import islice
from src.simulation.replay_engine import RaceReplayEngine
from src.agents.pace_agent import run_pace_agent_from_state
replay = RaceReplayEngine(Path("data/raw/2025/Lusail"), driver_code="PIA", team="McLaren")
lap_state = next(islice(replay.replay(), 19, 20)) # lap 20
output = run_pace_agent_from_state(lap_state)
print(output.lap_time_pred, output.ci_p10, output.ci_p90)
Take the lap_state from replay(), not from rsm.get_lap_state(lap) directly.
get_lap_state's weather_df argument is optional, and replay() is what passes it. Call
the RSM bare and the weather dict comes back with only track_status, so consumers fall
through to their hardcoded defaults (the arcade's are 25.0 C air / 35.0 C track) and never
say so. At Lusail the real values are 23.7 C and 29.8 C, and track temperature is a live
input to tire degradation.
See Race replay engine for the full lap_state schema.
Full orchestrator (requires LM Studio or OpenAI):
from pathlib import Path
from itertools import islice
import pandas as pd
from src.simulation.replay_engine import RaceReplayEngine
from src.agents.strategy_orchestrator import RaceState
from src.strategy.inference.engine import run_lap
race_dir = Path("data/raw/2025/Lusail")
laps_df = pd.read_parquet(race_dir / "laps.parquet")
replay = RaceReplayEngine(race_dir, driver_code="PIA", team="McLaren")
lap_state = next(islice(replay.replay(), 19, 20)) # lap 20
driver = lap_state["driver"]
ahead = [r for r in lap_state["rivals"] if r["position"] == driver["position"] - 1]
race_state = RaceState(
driver=driver["driver"], lap=20, total_laps=replay.total_laps,
position=driver["position"], compound=driver["compound"],
tyre_life=driver["tyre_life"],
# rivals ahead report a NEGATIVE interval; RaceState wants a magnitude
gap_ahead_s=abs(ahead[0]["interval_to_driver_s"]) if ahead else 0.0,
pace_delta_s=0.0,
air_temp=lap_state["weather"]["air_temp"],
track_temp=lap_state["weather"]["track_temp"],
)
rec, agent_outputs, timings = run_lap(race_state, laps_df, lap_state, profile="no-llm")
print(rec.action, rec.confidence, rec.reasoning)
Prefer run_lap over calling run_strategy_orchestrator_from_state directly: it is the
same pipeline (parity-tested), and it also hands back the per-agent outputs and stage
timings. Pass profile="no-llm" to run the deterministic path with no provider at all.
Pass the lap_state, and make sure it carries session_meta. The laps_df is scoped
to the Grand Prix named there. Without it, the engine falls back to the whole frame and
logs a warning, and the agents then look up rivals by driver and lap number across the
entire season: the Zandvoort grid can end up deciding a race at Lusail. The fallback is
loud on purpose, but the fix is to supply the session_meta, not to ignore the warning.