Every claim below is read directly from the code

How Team 1's Code Actually Fits Together

Not the architecture diagram again — this is a walk through the real function calls, in the order they actually execute, with short excerpts from the real files in sinder38/Team-1-Prac-A-Project. Anyone who can read GitHub should be able to open the same files and follow along.

The Call Chain, End to End

One weekly run is a single call to run_pipeline.py. Here's what actually happens, in order:

  1. main() reads a TOML config file (which stages are on, which LLM models to run)
  2. It creates one empty PipelineContext object for this week
  3. It calls run_almanac, run_technical, run_macro, run_evidence in sequence — each one fills in one field of that same context object
  4. It calls run_llm once per configured model — each call reads the same shared context and builds a fresh prompt from whatever's in it so far
  5. It writes a comparison table markdown file summarizing all models side by side
The key design fact that makes this work: agents never talk to each other directly. They only ever read and write the shared PipelineContext object.

The Shared Contract — base.py + schemas.py

Every agent (technical, macro, almanac, evidence, and every LLM) inherits from one abstract base class:

backend/agents/base.pyclass BaseAgent(ABC, Generic[T]):
    agent_type: str

    @abstractmethod
    def run(self, prediction_date: date, **kwargs) -> T: ...

    @abstractmethod
    def render_md(self, output: T, prediction_date: date) -> str: ...

That's the entire contract: every agent must implement run() (do the work, return typed output) and render_md() (turn that output into a human-readable report). Nothing else is required, which is why five very different agents — a yfinance technical analyzer, a seasonal almanac lookup, and four different LLM wrappers — can all be called the exact same way by the pipeline.

Every agent's output is a typed Python dataclass, not a loose dictionary:

backend/agents/schemas.py@dataclass
class TechnicalOutput:
    prediction_date: date
    instruments: dict[str, InstrumentTechnical]
    agent_type: str = "technical"
Why this matters: if a downstream stage tries to read a field that doesn't exist on TechnicalOutput, that's a Python error caught immediately — not a silent KeyError or a missing value discovered three steps later.

The Pipeline Context — the entire object is 6 lines

This is the object every agent reads from and writes to. It's deliberately small:

backend/agents/pipeline/context.py@dataclass
class PipelineContext:
    prediction_date: date
    almanac: AlmanacOutput | None = None
    technical: TechnicalOutput | None = None
    macro: MacroOutput | None = None
    evidence: EvidenceOutput | None = None
    llm_outputs: list[LLMOutput] = field(default_factory=list)

Every field starts as None. As each stage function runs, it fills in exactly one field. By the time the LLM stage runs, whatever fields are filled in get assembled into its prompt — and whatever's still None just gets skipped, no special-casing required.

This is the piece MPE doesn't have. Right now MPE's run_pipeline.py passes agent outputs directly as function arguments. It works at 3 tickers and 3 agents, but it's exactly the kind of thing that gets messy fast if a 4th or 5th agent gets added later.

Stage Functions — one per agent, all the same shape

Every data agent's stage function follows an identical 4-line pattern:

backend/agents/pipeline/stages.pydef run_technical(ctx: PipelineContext, config: dict) -> None:
    agent = TechnicalAgent()
    output = agent.run(ctx.prediction_date)
    ctx.technical = output          # ← writes into the shared context
    _save_artifacts(agent, output, ctx.prediction_date, config)

almanac, macro, and evidence are the exact same four lines with different class names. The LLM stage is slightly different because it needs to know which model to run:

backend/agents/pipeline/stages.pyLLM_REGISTRY: dict[str, Callable[[], BaseLLMAgent]] = {
    "nemotron": lambda: _make_openrouter("NVIDIA Nemotron 3 Super", "nvidia/nemotron-3-super-120b-a12b:free"),
    "gptoss":   lambda: _make_openrouter("OpenAI gpt-oss-120b",     "openai/gpt-oss-120b:free"),
    "gemma":    lambda: _make_openrouter("Google Gemma 4 31B",       "google/gemma-4-31b-it:free"),
    "laguna":   lambda: _make_openrouter("Poolside Laguna M.1",      "poolside/laguna-m.1:free"),
}

Each value is a zero-argument function that builds an agent instance when called — not the agent itself. That means listing all four models here costs nothing until run_llm actually calls one of these lambdas, which is also why adding a fifth model later is a one-line addition, not a refactor.

The Entry Point — config-driven, and it stops on the first real error

run_pipeline.py reads which stages are enabled from a TOML file, then runs them in a fixed order:

backend/run_pipeline.pyfor name, fn in stage_map.items():
    if not stages.get(name, False):
        print(f"[pipeline] skipping {name} (disabled in config)")
        continue
    try:
        fn(ctx, config)
    except Exception as e:
        print(f"[pipeline] ERROR in {name}: {e}", file=sys.stderr)
        sys.exit(1)          # ← one broken agent stops the whole run
Notice this is stricter than the LLM loop later in the same file. A broken data agent (technical/macro/almanac/evidence) kills the entire run immediately — there's no "skip and continue" here, because every later stage depends on this data being real.

The LLM Layer — Deliberately Split in Two Files

base_llm.py and multi_model_runner.py have a strict division of labor, and it's written explicitly as a code comment, not just implied:

backend/agents/llm/multi_model_runner.py"""
CONTRACT: we own ONLY query() on the BaseLLMAgent subclass. We do NOT modify any
other base_llm method (build_prompt / parse_response / run / render_md).
"""

base_llm.py owns: assembling the prompt from the shared context, and parsing whatever text comes back into a typed LLMOutput. This code is identical no matter which of the four models is being called.

multi_model_runner.py owns: only the actual network call — OpenRouterAgent.query() — sending the prompt to OpenRouter and returning raw text. It does no parsing at all; that's explicitly not its job.

Why split it this way: this was written by two different people on the team (per their sprint docs, a "Dev Lead" role and an "R8 Synthesis" role). The contract comment isn't decoration — it's how two people can work on the LLM layer simultaneously without their changes colliding.

The Prompt Format Decision — and why it matters for MPE specifically

This is the single most useful thing in the whole codebase for MPE right now. Team 1's system prompt to every model is explicit:

backend/agents/llm/multi_model_runner.pysystem_instruction = (
    "You are a strict financial data formatter. "
    "You MUST output exactly the requested keys in PLAIN TEXT format, separated by colons. "
    "DO NOT OUTPUT JSON FORMAT. "
    "For index ranges, you MUST strictly use the word 'to' (e.g., -1.5 to 2.0). "
    "Do NOT wrap your response in markdown code blocks."
)

And the parser on the receiving end is deliberately forgiving — it scans every line for a KEY: value pattern anywhere in the text, tolerating whatever reasoning or extra prose surrounds it:

backend/agents/llm/base_llm.pylines = {
    line.split(":", 1)[0].strip(): line.split(":", 1)[1].strip()
    for line in raw.strip().splitlines()
    if ":" in line
}
This directly explains bugs we spent hours debugging in MPE today. MPE's prompt asks every model to respond with ONLY a JSON object. Reasoning models like Nemotron and Laguna write out their thinking first, and by the time they get to the actual answer, either the token budget is gone or the JSON is buried inside prose that a strict JSON parser rejects outright. Team 1 hit this exact failure mode too, which is presumably why they explicitly banned JSON in the system prompt — a line-scanning parser degrades gracefully around reasoning text; a JSON parser doesn't.

Failure Handling Philosophy — honest failure over silent failure

Three separate defensive layers in OpenRouterAgent.query():

backend/agents/llm/multi_model_runner.pyfor attempt in range(max_retries):
    try:
        response = self.client.chat.completions.create(...)
        if not response.choices or not response.choices[0].message.content:
            raise RuntimeError(f"[{self.model_name}] Empty response from provider.")
        return response.choices[0].message.content
    except Exception as e:
        if attempt == max_retries - 1:
            raise RuntimeError(f"...Exhausted all {max_retries} API retries.") from e
        time.sleep(2 ** attempt)   # 1s, 2s, 4s

Every exception gets retried with exponential backoff — not just rate-limit errors specifically. And when a model fails for good, it isn't dropped quietly. It gets a visible marker in the final comparison table:

backend/agents/llm/multi_model_runner.pydef _failed_row(exc: Exception) -> dict:
    """Every data cell reads FAILED so a reviewer can't mistake it for 'no data'."""
    marker = f"❌ FAIL ({type(exc).__name__})"
    row = {key: marker for _, key in COMPARISON_DIMENSIONS}
    return row

And the whole run exits with a non-zero code if any model failed — which is what makes their CI turn red on a bad run, rather than a red flag getting buried in a log nobody reads.

A comment left in their own __main__ block admits an unresolved bug in their own system: a path-resolution issue in base_llm.py that the pre-flight check doesn't actually catch. Worth knowing this isn't a polished, bug-free reference — it's a real, evolving student codebase with its own open issues, same as MPE.

The Validation Layer

scripts/rules/ defines format rules per agent type, and validate_output.py runs them with three severity levels — PASS, WARN, FAIL — against any saved report file:

scripts/validate_output.pyExit codes:
  0  all required checks pass (warnings may be present)
  1  one or more required checks failed
  2  usage error

This is a separate, standalone script from the pipeline itself — it's meant to be run in CI against whatever markdown report an agent just wrote, checking things like "does this file actually contain the required fields in the required format," independent of whether the pipeline run itself succeeded.

What MPE Should Actually Borrow

PatternWhy it's worth taking
Plain-text KEY: value prompt format instead of JSON Directly fixes the exact Nemotron/Laguna parsing failures MPE hit today — this is the highest-value, lowest-effort change available
Exponential backoff on any exception, not just 429 MPE currently only retries on rate-limit errors specifically; other transient failures get zero retries
Honest failure markers instead of silent skip MPE currently drops a failed model with no visible trace in the output; a reviewer can't tell "1 model failed" from "we only ever use 1 model"
Shared PipelineContext object Lower priority at 3 tickers/3 agents, but worth adopting before adding a 4th agent
The prompt-format change is the one worth doing first — it's a rewrite of one function (build_prompt) and one system instruction string, and it targets the exact failure mode already confirmed in MPE's own committed logs.
Listen to This Page
Ready.