Skip to content

Architecture

helioai/
├── config.py               settings singleton, loaded once from .env
├── logging_config.py       structlog setup (console or JSON)
├── datastore.py            npz + manifest per session; the key to reproducible export
├── workspace.py            per-user, per-session directories
├── export.py               session → standalone .ipynb
├── indexer.py              speasy catalogue → ChromaDB
├── mcp_server.py           MCP stdio + streamable HTTP
├── core/
│   ├── agent_loop.py       stream_chat — the lead agent
│   ├── sub_agents.py       stream_subagent — delegation with tool whitelists
│   ├── tool_exec.py        shared execution logic between the two loops
│   ├── session.py          SQLite history per (user_id, session_id)
│   ├── skills_loader.py    markdown skills
│   ├── vision.py           stateless figure review side-call
│   ├── skills/             SKILL.md prompt assets
│   └── llm/
│       ├── base.py         Message, ToolCall, ToolDef, LLMClient, call_with_retry
│       ├── openai_compat.py  one client for every OpenAI-wire provider
│       ├── azure_openai.py   thin subclass (deployment routing, developer role)
│       ├── gemini.py         native google-genai client
│       └── factory.py        build_llm_client + the provider table
├── tools/
│   ├── registry.py         ToolRegistry — JSON dispatch to async functions
│   ├── setup.py            registers all 17 tools at import
│   ├── rag.py              hybrid BM25 + dense retrieval, fused by RRF
│   ├── speasy_tools.py     search, download, data-quality scan
│   ├── catalog_tools.py    AMDA catalogs, event timeseries
│   ├── plasmapy_tools.py   formulary wrappers
│   ├── sandbox.py          bubblewrap-isolated Python execution
│   ├── sandbox_helpers.py  coordinate transforms, boundary models
│   ├── recipes.py          recipe loading
│   ├── literature.py       NASA ADS
│   └── mcp_client.py       mounts remote MCP servers into the registry
├── data/recipes/           shipped scientific recipes (inside the package)
└── interfaces/
    ├── cli.py              readline CLI
    ├── jupyter_magic.py    %%helioai
    └── web/                FastAPI + SSE + vanilla JS

The agent loop

stream_chat is an async generator. Each turn: send history plus tool definitions to the model, dispatch any tool calls through the registry, append results, repeat until the model answers with text or the iteration cap is hit. Every step yields an event, which is what lets all four interfaces render progress live from the same source.

sub_agents.stream_subagent runs the same shape with a restricted tool set and its own turn cap. The two loops share tool_exec.py rather than duplicating dispatch — they were duplicated once, and a signature change updated one and not the other.

Registry

Tools are async functions registered with a JSON Schema:

@registry.register(name="plasma_beta", description="...", parameters={...})
async def plasma_beta(B_nT: float, n_cm3: float, T_eV: float) -> dict: ...

call_tool always awaits, so every tool must be async. Arguments starting with _ are rejected from model-supplied input — framework-injected parameters travel through a separate trusted channel so generated code cannot spoof them.

Storage

Everything is namespaced per user, then per session:

<data_dir>/users/<user_id>/workspace/<session>/   figures, scripts, npz, manifest.json
<data_dir>/users/<user_id>/catalogs/              saved catalogs
<data_dir>/chroma/                                the shared parameter index
<data_dir>/sessions.db                            SQLite history

<data_dir> is <repo>/data from a clone and ~/.local/share/helioai when installed — see config._default_data_dir. The agent's hot path resolves paths from a contextvar; every other entry point passes user_id explicitly, because a contextvar set inside stream_chat is not visible to a CLI subcommand.

Sandbox

run_python spawns a subprocess. On Linux with bubblewrap available: read-only root, an environment allowlist rather than inherited os.environ (so API keys are unreadable), dropped privileges, and resource limits. Elsewhere it degrades to a plain subprocess with only the timeout and process-group kill — documented in SECURITY.md.

The subprocess always gets start_new_session=True. Without it, killing the process group on timeout kills the HelioAI server itself.

LLM providers

Groq, Ollama and Azure all speak the OpenAI chat-completions format, so they share OpenAICompatClient; a provider is a base_url entry in factory.OPENAI_COMPAT. Azure subclasses it for AsyncAzureOpenAI and two dialect quirks. Gemini keeps a native client because its wire format genuinely differs — notably it has no tool-call ids, so the client synthesises name::hex and parses the name back out.

Where to look first

To change Start at
how the agent decides core/agent_loop.py + core/skills/
what the agent can do tools/setup.py
how parameters are found tools/rag.py
what a notebook looks like export.py
how a provider is added core/llm/factory.py