FinOS-AgentLab is a self-hosted financial research workspace that pairs a page-aware copilot on every screen with five specialist sub-agents and a multi-agent TradingAgents desk behind it. Every interactive figure below either runs the backend’s own Python routing, prompt-assembly, and scheduler code offline, or replays the 22 desk transcripts and scripted evaluation passes recorded in the repository.

Four processes

Next.js app agent chat, home, dashboards, markets, stock pages, portfolio, watchlists, world, data REST · SSE · WebSocket FastAPI backend 543 routes · 454 service modules · one scheduler copilot: 138 tools, 21 of them core · 5 specialists · 150 shipped commands JSONL, one event per line JSONL, request and reply agents-runtime TradingAgents 0.6.0 on LangGraph, its own venv one subprocess per run, at most five at once quant-runtime vectorbt and numba, its own venv one warm worker, respawned when it stalls On disk SQLite: dashboards, watchlists, runs, chats, specialists, settings · provider caches · the desk’s memory log
The desk and the quant engine run in their own virtual environments and talk to the backend one JSON line at a time.

Splitting a self-hosted workspace across four processes and three Python virtual environments was a truce with dependency management. TradingAgents pulls in the LangChain and LangGraph stack, while the quantitative backtesting engine relies on vectorbt and numba—and both pin transitive packages that clash directly with the FastAPI backend’s own dependencies.

Rather than forcing three incompatible dependency trees into a single environment, the backend isolates both runtimes behind newline-delimited JSON (JSONL) pipes over standard input and output. Each desk run spawns a fresh agents-runtime subprocess and relays its stdout lines to the browser as server-sent events; because each LangGraph process claims a few hundred megabytes of memory, a semaphore caps concurrent runs at five and queues the rest. The quant-runtime takes the opposite lifecycle: importing numba and vectorbt is slow, so the backend starts one worker at boot, keeps it warm across requests, and respawns it automatically if a calculation stalls.

The desk

TradingAgents 0.6.0's graph as the runner builds it, and the 22 desk runs recorded in the project's static export, each event a block sized by its bytes.

Convening the trading desk used to be a tool the chat copilot could invoke on its own, which meant a casual question could accidentally launch a multi-minute, twelve-node LangGraph debate. Now a desk run is always deliberate: typing @TradingAgents NVDA in chat starts the graph directly and streams every analyst report, bull-versus-bear rebuttal, and risk-committee turn into the thread without a model middleman deciding whether to.

Ten of the 22 recorded runs reached a final portfolio verdict (3 BUY, 5 SELL, 2 HOLD), and the runner’s parse_action extracts the exact same call from all ten. Replaying those transcripts surfaced two bugs that only appear when real model routers and LangGraph state dictionaries meet. In one NVDA run and one MU run, a free upstream model router intercepted the Bull Researcher’s turn and returned a bare content-safety classifier label (safety: safe) instead of a bull case; with nobody arguing for the stock, both desks marched straight to SELL. Since September, the runner checks every turn against a classifier pattern and redraws the reply up to three times before any downstream agent reads it:

_CLASSIFIER_REPLY = re.compile(
    r"^\s*(?:(?:user|response|prompt|agent)\s+)?safety\s*:\s*(?:un)?safe\b", re.IGNORECASE
)

A quieter bug hid in the risk committee at the end of the graph. Every finished run in the snapshot streamed only the Neutral risk analyst’s turn. All three risk voices had actually run, but the runner looked up risky and safe in the state dictionary where TradingAgents 0.6.0 writes aggressive and conservative—silently dropping two of the three risk perspectives until a contract test pinned the state keys.

What a turn carries

Prompt and tool schemas routed for one question on each of the app's pages, each tool a block, measured with the backend's tools_for_request against the ceilings its tests pin.

Because an LLM API call pays for every tool schema on every round, shipping the full registry of 138 tools on every turn would burn tens of thousands of prompt characters before the user asked a question. Instead, tools_for_request routes a slice of the catalogue per turn: the 21 core tools are always included, the current page adds its own domain group, and keywords in the user’s question trip any matching intent groups.

To keep schema bloat from creeping back in, a test pins a strict character ceiling for a plain question on each route and requires a written note in the test file whenever a ceiling is raised. The /dashboards page already sits at 28,923 characters against a 28,950 ceiling before any question keywords fire, and asking a question about the book ("how is my book doing?") pulls in enough portfolio and risk tools to push almost every page past its baseline cap.

Ordered by how often it changes

Six turns of one conversation as the loop assembles them, measured with the backend's own prompt, command, policy and context builders, with turn sizes from its scripted scenarios.

Routing fewer tools shrinks each request; ordering the prompt blocks keeps the provider’s prefix cache warm across turns. On every turn, the loop sends up to five system messages arranged strictly by how often each one changes: the base system prompt (4,998 chars), the listing of 150 slash commands (15,962 chars), and the mode policy (1,073 chars) sit at the very top, followed by the growing conversation history.

The page context block (687 chars) carries a wall-clock timestamp that changes every minute, so it is placed last, immediately before the newest user question. It used to sit before the history—which meant that every time the clock ticked over a minute, that single changing line invalidated the provider’s prefix cache for the entire conversation history behind it.

Building an agent

The copilot's 138 tools, one cell each, and what the loop's plan_tools hands the parent turn and each shipped specialist for the same question.

When a question needs several rounds of data gathering, the copilot can delegate a sub-question to a specialist: a smaller child loop with its own instructions and a narrower slice of the tool catalogue, which runs the lookups in isolation and returns one synthesized paragraph. Five specialists ship with the app (general, market, filings, web, and portfolio), and a user can declare up to eight custom specialists in settings—each with a @name handle, up to 4,000 characters of instructions, a tool allowlist, a model choice, and a cap of eight rounds.

What a custom declaration cannot override is fixed in the loop itself: every child runs in ask mode with mutating actions disabled, its tool list can only ever narrow the parent’s read-only tools, and a specialist cannot delegate again. Because the general specialist declares no tool filter of its own, plan_tools hands it the full read-only slice routed by the question—research_subagent included—and the loop refuses the call at depth one if it tries to recurse.

One fire per slot

A week of the ten system jobs on the backend's own scheduler, stepped a minute at a time, with one window of it drawn minute by minute.

A single scheduler drives the backend’s ten recurring system jobs—market snapshots, nightly syncs, thesis scans, and morning or evening briefs—stepping once a minute. Each job is keyed to a slot (a local calendar date or an hour bucket) and may succeed at most once per slot; during an upstream outage, a failing pass retries up to three times in that slot, one minute apart, before waiting for the next window.

Because a self-hosted box can reboot or sleep at any hour, a restart treats any pass left marked running in SQLite as failed and re-fires it immediately, while skipping any morning brief whose catch-up window has already closed rather than delivering a pre-market summary at noon.

Evals without a key

The core suite run scripted on this machine and replayed at its recorded pace: the real loop, catalogue and checks against a model that plays a fixed script, with no network.

Half of the core evaluation suite (17 of 34 scenarios, 144 checks in all) runs in five seconds without an API key and with outbound network connections blocked, because what it guards is the loop rather than the model. A scripted fake model replays predetermined tool calls and failure modes against the real CopilotLoop, tool registry, and permission checks: verifying that an action attempted in ask mode is stopped before touching disk, that a tool call written as prose is handed back for a retry rather than shown to the user, and that the sixteen-lookup turn compacts older tool results out of its context window as it works and still answers using the very first lookup.