Core¶
Agent loop¶
helioai.core.agent_loop ¶
The agent decision loop.
Given a user message and a session id, run the LLM in a tool-using loop: the LLM may emit tool calls, execute them via the ToolRegistry, feed the results back, and iterate until the LLM produces a final text reply (or we hit the safety cap).
Two consumption modes share the same generator core (stream_chat): - chat() → collects all events, returns a single ChatResult - stream_chat() → async generator, yields one event dict per step
Event shapes
tool_call {turn, name, arguments} tool_result {turn, name, summary} sub_agent_start {task_id, role, description} sub_agent_end {task_id, role, summary, n_iterations, error} plan {title, steps} skill_loaded {name} reply {text} provenance {matched, contradicted, derived, unsourced, details} done {n_iterations} error {message}
ChatResult
dataclass
¶
Final outcome of a non-streaming chat() call.
Source code in helioai/core/agent_loop.py
build_lead_system_prompt ¶
Return the lead agent system prompt.
restricted=True (default / public): appends the scope guardrail so the LLM auto-refuses off-topic requests. restricted=False (dev token supplied): base prompt only, full access.
Source code in helioai/core/agent_loop.py
stream_chat
async
¶
stream_chat(llm_client: LLMClient, user_id: str, session_id: str, user_text: str, *, restricted: bool = True) -> AsyncIterator[dict]
Run one conversational turn of the agent and stream its progress as events.
This is the package's central API: every interface (CLI, web SSE, Jupyter, MCP) is a consumer of this generator. History is loaded from and persisted to the session store keyed by (user_id, session_id), so consecutive calls with the same ids continue the same conversation.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
llm_client
|
LLMClient
|
Provider client from |
required |
user_id
|
str
|
Storage namespace — workspaces and profiles live under it. |
required |
session_id
|
str
|
Conversation id; reuse it to continue, mint one to start fresh. |
required |
user_text
|
str
|
The user's message for this turn. |
required |
restricted
|
bool
|
True (default) appends the heliophysics scope guardrail; False (dev token) exposes the base prompt only. |
True
|
Yields:
| Type | Description |
|---|---|
AsyncIterator[dict]
|
Dicts with an |
AsyncIterator[dict]
|
|
AsyncIterator[dict]
|
|
AsyncIterator[dict]
|
|
AsyncIterator[dict]
|
|
Example
llm = build_llm_client() async for ev in stream_chat(llm, "cli", "my-session", "IMF Bz at L1 today?"): ... if ev["event"] == "reply": ... print(ev["text"], end="")
Source code in helioai/core/agent_loop.py
298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 | |
chat
async
¶
chat(llm_client: LLMClient, user_id: str, session_id: str, user_text: str, *, restricted: bool = True) -> ChatResult
Run one agent turn to completion and return the final result.
Non-streaming wrapper over stream_chat — same arguments, same session
semantics — for callers that want the answer, not the progress feed
(Jupyter magic, scripts, tests).
Returns:
| Type | Description |
|---|---|
ChatResult
|
ChatResult with |
ChatResult
|
|
Example
result = await chat(build_llm_client(), "cli", "my-session", ... "Plasma beta for B=5 nT, n=10 cm^-3, T=20 eV?") print(result.reply) # final assistant text (model-dependent) len(result.artifacts) # figures / parameter cards / code produced
Source code in helioai/core/agent_loop.py
Sub-agents¶
helioai.core.sub_agents ¶
Sub-agents for delegating focused heliophysics subtasks.
Each role declares a tool whitelist, a system addon, and an optional set of skills auto-loaded into the sub's system prompt. Sub-agents run in isolation with a fresh context — the lead's history is invisible to them.
SubAgentRole
dataclass
¶
A specialised agent: its prompt, its tool whitelist and its turn budget.
allowed_tools is enforced, not advisory — a role calling outside its set
gets an error naming what it may use, and the tool is never dispatched.
Source code in helioai/core/sub_agents.py
SubAgentResult
dataclass
¶
What a finished sub-agent hands back to the lead agent.
Source code in helioai/core/sub_agents.py
task_tool_def ¶
Build the task tool definition offered to the lead agent.
Deliberately not registered in the ToolRegistry: the agent loop intercepts
task and spawns a sub-agent instead of dispatching a function.
Returns:
| Type | Description |
|---|---|
ToolDef
|
A ToolDef whose |
Source code in helioai/core/sub_agents.py
stream_subagent
async
¶
stream_subagent(role: str, description: str, *, parent_session_id: str, user_id: str, llm_client: LLMClient, task_id: str | None = None) -> AsyncIterator[dict]
Async generator that runs a sub-agent and yields progress events.
Yields the same event types as stream_chat (tool_call, tool_result, skill_loaded, artifact) enriched with sub_agent_ctx={role, task_id}, then a final sub_agent_end event carrying summary/artifacts/n_iterations/error.
summary is the sub-agent's whole deliverable and is emitted in full: it becomes
the lead's tool result, so anything cut here is a measurement the lead can no
longer report and will be tempted to invent. Callers that display it truncate on
their own side. Reaching the turn cap is reported as an error, not as a result,
for the same reason — a lead handed a capped run has nothing to summarise.
Source code in helioai/core/sub_agents.py
301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 | |
run_subagent
async
¶
run_subagent(role: str, description: str, *, parent_session_id: str, user_id: str, llm_client: LLMClient, task_id: str | None = None) -> SubAgentResult
Run a sub-agent to completion and return only its outcome.
Non-streaming wrapper over stream_subagent — same arguments.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
role
|
str
|
One of the whitelisted roles (parameter_hunter, data_analyst, plasma_physicist, librarian). |
required |
description
|
str
|
The task handed to the sub-agent, in natural language. |
required |
parent_session_id
|
str
|
The lead conversation this run belongs to. |
required |
user_id
|
str
|
Storage namespace of that conversation. |
required |
llm_client
|
LLMClient
|
Provider client shared with the lead. |
required |
task_id
|
str | None
|
Optional id echoed in events, for UI correlation. |
None
|
Returns:
| Type | Description |
|---|---|
SubAgentResult
|
SubAgentResult — |
SubAgentResult
|
|
Source code in helioai/core/sub_agents.py
Tool execution¶
Shared between the lead loop and the sub-agent loop.
helioai.core.tool_exec ¶
Shared tool-execution helpers for the agent loops.
Both the lead loop (agent_loop.stream_chat) and the sub-agent loop (sub_agents.stream_subagent) run the same tool-call mechanics: inject the sandbox run dir for run_python, summarise the result, detect skill loads, and extract renderable artifacts. Keeping that logic here — imported by both loops — prevents the two copies from drifting apart (a real bug source, see the session-13 _extract_artifact list/dict regression).
This module imports neither agent_loop nor sub_agents, so there is no cycle.
compact_history ¶
Return a copy of messages where tool-result messages older than the last
keep_full are summarized. The most recent results stay verbatim (the next LLM
call usually needs them); older ones — already consumed — are trimmed so context
does not grow unbounded over a long session. Persisted history is left untouched;
only the per-call payload shrinks.
ponytail: fixed window N=2; widen keep_full if a case regresses on stale results.
Source code in helioai/core/tool_exec.py
inject_run_python_args ¶
Trusted per-run sandbox args (_plot_dir/_run_idx) for run_python.
Passed via call_tool(..., trusted=...) so they bypass the private-arg
guard that rejects LLM/MCP-supplied _* overrides. Empty for any other tool.
Source code in helioai/core/tool_exec.py
emit_post_tool_events ¶
emit_post_tool_events(name: str, result: str, *, tool_result_extra: dict | None = None, common_extra: dict | None = None) -> Iterator[dict]
Yield the events that follow a completed tool call.
Order is tool_result → (skill_loaded if load_skill) → artifact(s),
matching what both loops emitted before this was factored out.
tool_result_extrais merged into the tool_result event data (e.g. {turn}).common_extrais merged into skill_loaded and artifact event data (e.g. {sub_agent_ctx} for sub-agents).
Source code in helioai/core/tool_exec.py
check_answer ¶
Confront a finished answer with the catalogue and the recipe shelf.
Both loops call this, which is the whole point of it living here. Both checks were
written inside the sub-agent loop and stayed there, so a lead agent that did the
physics itself — Acts III and IV of the showcase notebook, on a run where
load_recipe was called zero times all session — was never checked at all. The
detectors were not silent because the run was clean; they were silent because
nothing called them.
Returns:
| Type | Description |
|---|---|
tuple[str, list[str], list[dict]]
|
The text (annotated when a check fires), the unknown ids, and the recipe flags. |
Source code in helioai/core/tool_exec.py
Sessions¶
helioai.core.session ¶
Conversation history keyed by (user_id, session_id), persisted to SQLite.
SessionStore ¶
Conversation history keyed by (user_id, session_id), persisted to SQLite.
Histories are cached in memory per key and written back whole on save.
Tests use a real database on tmp_path rather than a mock: a mocked store
passed happily through a schema migration that broke production.
Example
store = SessionStore(tmp_path / "sessions.db") history = store.get_or_create("cli", "sess-1") # [] on first call history.append(Message(role="user", content="hello")) store.save("cli", "sess-1", history) store.get_or_create("cli", "sess-1")[0].role 'user'
Source code in helioai/core/session.py
65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 | |
get_or_create ¶
Return the cached history for a session, loading it from disk if needed.
Source code in helioai/core/session.py
save ¶
Replace a session's stored history.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
user_id
|
str
|
Owner of the session. |
required |
session_id
|
str
|
Session identifier. |
required |
history
|
list[Message]
|
Full message list; it replaces whatever was stored. |
required |
Source code in helioai/core/session.py
reset ¶
Delete a session and its messages, and drop it from the cache.
Source code in helioai/core/session.py
set_workspace_dir ¶
Record which workspace directory a session's artifacts live in.
Source code in helioai/core/session.py
get_workspace_dir ¶
Return a session's workspace directory label, or None.
Source code in helioai/core/session.py
workspace_dirs ¶
All workspace dir labels owned by a user (for path-ownership checks).
Source code in helioai/core/session.py
all_sessions ¶
Return a user's session ids, most recently updated first.
Source code in helioai/core/session.py
list_summaries ¶
Summarise a user's recent sessions for the history view.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
user_id
|
str
|
Owner of the sessions. |
required |
limit
|
int
|
Maximum number of sessions to return. |
50
|
Returns:
| Type | Description |
|---|---|
list[dict]
|
Dicts with session_id, updated_at, first_message, n_messages and |
list[dict]
|
workspace_dir, most recent first. |
Source code in helioai/core/session.py
strip_orphan_tool_calls ¶
Remove assistant tool_calls that have no matching tool response.
An interrupted generation (e.g. client disconnect mid-tool) can leave an assistant message with tool_calls but no corresponding tool messages in the history. Sending such a sequence to the LLM API causes a 400 error.
For each orphaned tool_call id: - If the assistant message has content too, keep the message but drop the orphaned tool_calls list entry (or clear it entirely if all are orphaned). - If the assistant message has no content and all its tool_calls are orphaned, drop the message entirely.
Source code in helioai/core/session.py
Skills¶
helioai.core.skills_loader ¶
Discover and serve markdown-defined skills to the agent.
Each skill lives in skills/
SkillError ¶
SkillMeta
dataclass
¶
Header of a skill, as listed to the agent before it loads the body.
Source code in helioai/core/skills_loader.py
load_index ¶
Return the markdown index of available skills, for the agent to browse.
Source code in helioai/core/skills_loader.py
load_skill ¶
Return a skill's full markdown body.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
name
|
str
|
Skill name as listed by |
required |
Returns:
| Type | Description |
|---|---|
str
|
The skill body, ready to append to a system prompt. |
Raises:
| Type | Description |
|---|---|
SkillError
|
If the skill does not exist or the name escapes the skills directory. |
Source code in helioai/core/skills_loader.py
Figure review¶
helioai.core.vision ¶
Stateless vision side-call: review sandbox figures after run_python.
The image is sent once, outside the conversation; only the short text verdict enters the tool result (and thus the history), so the cost stays a few hundred tokens per figure instead of re-sending images every turn. Never blocks the loop: any failure logs a warning and returns the result unchanged.
maybe_review
async
¶
Attach a vision verdict to a run_python result carrying figures.
No-op unless HELIOAI_VISION_ENABLED is set and the tool is run_python.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
tool_name
|
str
|
Name of the tool that just ran. |
required |
result
|
str
|
Its JSON result string (read for |
required |
Returns:
| Type | Description |
|---|---|
str
|
(possibly augmented result, verdict text or None) — the verdict is a |
str | None
|
stateless side-call; only its text enters the history, never the image. |
Source code in helioai/core/vision.py
LLM clients¶
helioai.core.llm.base ¶
Provider-neutral message model and LLMClient interface.
ToolCall
dataclass
¶
A tool invocation requested by the model.
Attributes:
| Name | Type | Description |
|---|---|---|
id |
str
|
Provider-assigned identifier, echoed back on the matching tool
result. Gemini has no native ids, so its client synthesises
|
name |
str
|
Registered tool name. |
arguments |
dict
|
Decoded JSON arguments. Empty when the model emitted malformed JSON — a bad tool call must not kill the loop. |
Source code in helioai/core/llm/base.py
Message
dataclass
¶
One turn of conversation, in a provider-neutral form.
Every client converts to and from this shape, so the agent loop, the session store and the interfaces never see a provider's wire format.
Attributes:
| Name | Type | Description |
|---|---|---|
role |
Literal['system', 'user', 'assistant', 'tool']
|
Who produced the turn. |
content |
str
|
Text content. Present alongside |
tool_calls |
list[ToolCall] | None
|
Tools the assistant wants invoked, when it requested any. |
tool_call_id |
str | None
|
For |
Source code in helioai/core/llm/base.py
ToolDef
dataclass
¶
A tool as advertised to the model.
Attributes:
| Name | Type | Description |
|---|---|---|
name |
str
|
Tool name the model will call. |
description |
str
|
What the tool does and when to reach for it — the model's only clue about applicability. |
parameters |
dict
|
JSON Schema object describing the accepted arguments. |
Source code in helioai/core/llm/base.py
LLMClient ¶
Bases: ABC
Interface every provider client implements.
One method is required, deliberately: the agent loop only ever needs a single completion with optional tool calling. Streaming happens at the loop level.
Source code in helioai/core/llm/base.py
aclose
async
¶
Release the underlying HTTP connection pool.
Callers that build a client per request — the CLI, the Jupyter magic and
the web endpoints all do — must await this before their event loop ends.
An async pool binds to the loop that used it, so a client left to the
garbage collector schedules its own teardown after asyncio.run has
closed that loop, and asyncio reports an unretrieved
RuntimeError: Event loop is closed while the sockets stay open.
The default is a no-op so a client without a pool needs no override.
Source code in helioai/core/llm/base.py
chat
abstractmethod
async
¶
chat(messages: list[Message], tools: list[ToolDef], system_prompt: str | None = None, tool_choice: str = 'auto') -> Message
Send one turn and return the assistant's reply.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
messages
|
list[Message]
|
Conversation history. |
required |
tools
|
list[ToolDef]
|
Tools the model may call this turn. |
required |
system_prompt
|
str | None
|
Instructions placed before the history. |
None
|
tool_choice
|
str
|
|
'auto'
|
Returns:
| Type | Description |
|---|---|
Message
|
The assistant reply, carrying |
Source code in helioai/core/llm/base.py
call_with_retry
async
¶
Call async fn with backoff on retryable HTTP errors.
A server-supplied Retry-After wins over the exponential backoff: waiting the window a rate limiter asks for costs less than losing a session that is already several minutes and several downloads deep.
But only when the wait is one we would actually sit through. A per-minute limiter
asks for seconds; an exhausted daily quota answers Retry-After: 35464 — nearly ten
hours. Capping that to max_delay and retrying anyway just buys four minutes of
silence before the same failure, so a window longer than max_delay fails at once
and says how long it really is.
Non-retryable errors and exhausted attempts are re-raised immediately.
Source code in helioai/core/llm/base.py
close_sdk_client
async
¶
Close an SDK client's connection pool, whether its close() is sync or async.
openai.AsyncOpenAI.close is a coroutine; google.genai.Client.close is not.
Failures are swallowed: this only ever runs while tearing down, and a pool
that will not close is not worth crashing a finished analysis over.
Source code in helioai/core/llm/base.py
helioai.core.llm.openai_compat ¶
Single client for every provider that speaks the OpenAI chat-completions wire format.
Groq, Ollama, Azure OpenAI and OpenAI itself all accept the same request shape, so
they share one implementation here instead of one near-identical class each. A
provider is a base_url plus a couple of dialect flags, not a subclass.
Azure is the one exception that still needs its own SDK client object (deployment
routing and api-version), so it subclasses this to swap the constructor only.
OpenAICompatClient ¶
Bases: LLMClient
Chat client for any OpenAI-compatible endpoint.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
model
|
str
|
Model name sent in the request (the deployment name on Azure). |
required |
api_key
|
str
|
Provider API key. Local endpoints such as Ollama ignore it, but the SDK requires a non-empty value. |
''
|
base_url
|
str | None
|
Endpoint root. |
None
|
system_role
|
str
|
Role used for the system prompt — |
'system'
|
max_output_tokens
|
int
|
Cap on generated tokens. |
4096
|
temperature
|
float | None
|
Sampling temperature. |
0.2
|
provider
|
str
|
Name used to label log messages. |
'openai'
|
client
|
Any
|
Pre-built SDK client. Injected by tests and by subclasses. |
None
|
Example
client = OpenAICompatClient( ... model="llama-3.3-70b-versatile", ... api_key="gsk_...", ... base_url="https://api.groq.com/openai/v1", ... ) reply = await client.chat([Message(role="user", content="hi")], tools=[])
Source code in helioai/core/llm/openai_compat.py
178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 | |
aclose
async
¶
chat
async
¶
chat(messages: list[Message], tools: list[ToolDef], system_prompt: str | None = None, tool_choice: str = 'auto') -> Message
Send one chat turn and return the assistant's reply.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
messages
|
list[Message]
|
Conversation history. |
required |
tools
|
list[ToolDef]
|
Tools the model may call. |
required |
system_prompt
|
str | None
|
Instructions prepended as the first message. |
None
|
tool_choice
|
str
|
|
'auto'
|
Returns:
| Type | Description |
|---|---|
Message
|
The assistant reply, carrying |
Source code in helioai/core/llm/openai_compat.py
to_openai_messages ¶
Convert neutral messages to the OpenAI wire format.
System messages already in the history are dropped: the system prompt is
passed separately by chat() so it always lands first.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
messages
|
list[Message]
|
Conversation history in HelioAI's provider-neutral form. |
required |
Returns:
| Type | Description |
|---|---|
list[dict]
|
Message dicts ready to send as the |
Source code in helioai/core/llm/openai_compat.py
to_openai_tools ¶
Convert tool definitions to OpenAI function-calling schemas.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
tools
|
list[ToolDef]
|
Tools the agent may call this turn. |
required |
Returns:
| Type | Description |
|---|---|
list[dict]
|
Function schemas ready to send as the |
Source code in helioai/core/llm/openai_compat.py
from_openai_response ¶
Convert an OpenAI chat-completions response to a neutral message.
Text content is preserved even when tool calls are present, and a tool call
whose arguments are not valid JSON degrades to {} with a warning rather
than raising — a malformed model output must not kill the agent loop. An
inline <think>...</think> reasoning block, when a provider emits one, is
stripped from the content before it reaches the agent loop or the user.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
response
|
Any
|
The SDK response object. |
required |
provider
|
str
|
Provider name, used only to label log messages. |
'openai'
|
Returns:
| Type | Description |
|---|---|
Message
|
The assistant's reply, with |
Source code in helioai/core/llm/openai_compat.py
helioai.core.llm.azure_openai ¶
Azure-hosted OpenAI.
Same wire format as every other OpenAI-compatible provider, so the conversion
logic lives in openai_compat. Azure only differs in how the client is built —
the endpoint embeds the deployment name and an api-version — plus two dialect
details the base class already exposes as parameters: the system prompt is sent
with the developer role, and temperature is omitted when unset because GPT-5
and the o-series reject it.
AzureOpenAIClient ¶
Bases: OpenAICompatClient
Chat client for an Azure OpenAI deployment.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
api_key
|
str
|
Azure OpenAI API key. |
required |
endpoint
|
str
|
Resource root, e.g. |
required |
api_version
|
str
|
Azure API version, e.g. |
required |
deployment
|
str
|
Deployment name, sent as the request's |
required |
max_output_tokens
|
int
|
Cap on generated tokens. |
4096
|
temperature
|
float | None
|
Sampling temperature; |
None
|
Source code in helioai/core/llm/azure_openai.py
helioai.core.llm.gemini ¶
GeminiClient: Google genai SDK → neutral Message model.
GeminiClient ¶
Bases: LLMClient
Chat client for Google Gemini, using the native google-genai SDK.
Kept separate from OpenAICompatClient because the wire format genuinely
differs: turns are Content objects with typed parts, the assistant role is
called model, and tool results are matched by function name rather than
by id. Since Gemini issues no call ids, this client synthesises name::hex
and parses the name back out on the way in — a format that is persisted in
existing sessions, so it must stay readable.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
api_key
|
str
|
Gemini API key. |
required |
model
|
str
|
Model name, e.g. |
required |
max_output_tokens
|
int
|
Cap on generated tokens. |
4096
|
temperature
|
float
|
Sampling temperature. |
0.2
|
Source code in helioai/core/llm/gemini.py
17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 | |
aclose
async
¶
chat
async
¶
chat(messages: list[Message], tools: list[ToolDef], system_prompt: str | None = None, tool_choice: str = 'auto') -> Message
Send one turn to Gemini and return the assistant's reply.
When system_prompt is not given, it is recovered from any system message
left in the history. tool_choice="required" maps to Gemini's ANY mode.
Source code in helioai/core/llm/gemini.py
helioai.core.llm.factory ¶
Build the configured LLM client.
Providers that speak the OpenAI wire format are table entries, not classes — see
OPENAI_COMPAT. Azure and Gemini need their own SDK client objects and stay
explicit below.
build_llm_client ¶
Return a client for the requested provider.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
provider
|
str | None
|
Provider name. Defaults to |
None
|
Returns:
| Type | Description |
|---|---|
LLMClient
|
A ready-to-use client. |
Raises:
| Type | Description |
|---|---|
RuntimeError
|
If the provider is unknown or its API key is missing. |
Example
llm = build_llm_client("groq") type(llm).name 'OpenAICompatClient'
Source code in helioai/core/llm/factory.py
Storage¶
helioai.datastore ¶
Session datastore — persist downloaded timeseries as .npz for reuse in run_python.
/data/.npz — compressed numpy archive
Datasets are accessible in the sandbox via load_data("name"). The persisted data is the full-resolution download (before any downsampling). All I/O errors are silently swallowed — persistence must never break a tool call.
fill_mask ¶
Boolean mask of samples that carry no measurement.
Three conventions have to be caught at once, which is why every caller shares this one function instead of applying its own threshold:
- non-finite (NaN/inf) — already unusable;
- the ~1e31 magnitude convention, used by ACE among others;
- the value the dataset declares in its CDF FILLVAL, which is the only way to catch Wind/SWE's 99999.9. A blanket "reject >= 99999" rule is wrong: OMNI carries a real proton temperature of 99093 K in the 2003 Halloween window.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
fillval
|
float | list | None
|
the declared FILLVAL, when the provider exposes it. A list as often
as a scalar — Wind/SWE declares |
None
|
Example
fill_mask(np.array([1.2, -1e31, 3.4]), [-1e31]).tolist() [False, True, False]
Source code in helioai/datastore.py
blank_fill ¶
Return (values with fill blanked to NaN, mask), or (values, None) if not numeric.
Applied at every point where downloaded data is persisted, so that anything reading it back — the sandbox, the exported notebook — sees NaN for "no measurement" rather than a sentinel that looks like a plausible reading. Leaving it to the reader meant remembering to call clean(), which cannot see FILLVAL, so one forgotten call put a 99999.9 "speed" into a plot and a mean.
Non-numeric parameters (string labels, epochs) have no fill convention and are passed through untouched.
Example
vals, mask = blank_fill(np.array([1.2, -1e31, 3.4]), [-1e31]) vals.tolist(), mask.tolist() ([1.2, nan, 3.4], [False, True, False])
Source code in helioai/datastore.py
read_manifest ¶
Return the manifest dict for a given session directory.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
session_dir
|
Path
|
A session workspace directory (contains |
required |
Returns:
| Type | Description |
|---|---|
dict
|
{"datasets": {name: {kind, param_id, start, stop, ...}}} — empty |
dict
|
datasets dict when no manifest exists. |
Source code in helioai/datastore.py
find_existing ¶
Name of an already-persisted dataset for this exact param and window, or None.
The same matching _unique_name uses to reuse a slot — but consulted before the
download rather than after, so a repeat request costs a dict lookup instead of a
network round-trip. The prompt has always said "download each parameter ONCE"; a
real run still re-fetched the same Wind field three times across three turns, with
the dataset name sitting in plain sight in its own history. Discipline the model
does not reliably apply belongs in the tool.
Source code in helioai/datastore.py
save_timeseries ¶
save_timeseries(name_hint: str, *, time, values, param_id: str, units: str, start: str, stop: str, columns, source: str) -> dict | None
Persist a timeseries download as npz + a manifest entry.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
name_hint
|
str
|
Basis for the dataset name (slugged, collision-suffixed). |
required |
time
|
array - like
|
Time axis as returned by speasy. |
required |
values
|
ndarray
|
Data array (fill already blanked). |
required |
param_id
|
str
|
Speasy id, recorded in the manifest for the export rewrite. |
required |
units
|
str
|
Physical units string. |
required |
start
|
str
|
ISO window start, recorded for the export rewrite. |
required |
stop
|
str
|
ISO window stop. |
required |
columns
|
list[str]
|
Component names. |
required |
source
|
str
|
Which tool produced the download. |
required |
Returns:
| Type | Description |
|---|---|
dict | None
|
{"dataset": |
dict | None
|
persisting failed (the download result is still usable in-memory). |
Source code in helioai/datastore.py
191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 | |
save_event_collection ¶
save_event_collection(name_hint: str, *, series, param_id: str, units: str, source: str) -> dict | None
Persist a batch of per-event timeseries. series = [(ev_start, ev_stop, ts|None), ...]. Returns {"dataset": name} or None on failure.
Source code in helioai/datastore.py
272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 | |
helioai.workspace ¶
Workspace — stable output directory for sandbox figures and data.
Figures go to workspace/
The session label is a human-readable slug derived from the first user message, propagated via a contextvar set by stream_chat at the start of each request.
current_user ¶
set_user ¶
reset_user ¶
user_home ¶
A user's private storage home: /users/
Example
user_home("cli") PosixPath('.../data/users/cli')
set_session ¶
reset_session ¶
set_label ¶
reset_label ¶
safe_id ¶
Reduce an identifier to something that cannot escape its parent directory.
Session ids are caller-supplied — a web request body, an MCP client, a CLI
flag — and end up as path components here, in the export filename, and in the
rmtree behind DELETE /api/sessions/{id}. Everything the project mints is a
uuid4, so stripping to [A-Za-z0-9_-] is lossless in practice and turns
../.. into the fallback rather than a parent directory.
Example
safe_id("../../etc/passwd"), safe_id("sess-abc-123456") ('etcpasswd', 'sess-abc-123456')
Source code in helioai/workspace.py
make_session_label ¶
Build a human-readable slug for the session workspace folder.
Example: "Plot IMF Bz from ACE" + session abc123... → "plot-imf-bz-from_abc123"
Source code in helioai/workspace.py
get_session_dir ¶
Return the workspace directory for the current session.
Uses _current_label if set, falls back to _current_session UUID, then tmpdir. Creates the directory if it does not exist.
Source code in helioai/workspace.py
get_next_run_idx ¶
Return the next available run index for a session directory.
Scans for code_N.py files and returns max(N)+1, or 0 if none exist.
Source code in helioai/workspace.py
get_run_dir_for_sandbox ¶
is_under_workspace ¶
True if path is safely under the per-user storage root (no traversal).
is_relative_to rather than a string prefix: comparing against str(root) + "/"
hard-coded the POSIX separator, so on Windows the check never matched and /figure
and /code returned 404 for every legitimate path. Fail-closed, so it was a dead
web UI rather than a hole — but dead all the same.
Source code in helioai/workspace.py
cleanup_old_runs ¶
Purge session dirs older than ttl_seconds across all users. Returns count removed.
Source code in helioai/workspace.py
Export¶
helioai.export ¶
Export a session as a reproducible Jupyter notebook.
A research result that cannot be re-run is worthless. The agent already saves
every sandbox run as code_N.py in the session workspace; this module bundles
those runs plus the conversation into a self-contained, re-executable .ipynb
with a provenance header (parameter ids, time, library versions).
The saved runs are rewritten to standalone code (to_standalone): load_data()
becomes a direct spz.get_data(...), the agent-only param_card()/
document_method() calls are dropped, and a minimal header supplies the
imports plus real clean()/export() helpers — so each cell runs in a plain
Jupyter kernel with no HelioAI sandbox around it.
export_session_notebook ¶
Write the session as a .ipynb and return its path.
Default location:
Example
export_session_notebook("cli", "8f3aa012-...") PosixPath('.../data/users/cli/workspace/plot-imf-bz_8f3aa0/plot-imf-bz_8f3aa0.ipynb')
The notebook opens with a provenance header (parameter ids, library versions), then one runnable cell per saved sandbox run, rewritten to standalone speasy calls.
Source code in helioai/export.py
to_standalone ¶
Turn a saved sandbox run into standalone, re-executable code.
Strips agent-only calls, rewrites load_data() → spz.get_data(), and (unless embedded in a notebook that already has a setup cell) prepends the imports plus real clean()/export() helpers it needs.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
code_src
|
str
|
A |
required |
manifest
|
dict
|
The session manifest from |
required |
with_header
|
bool
|
Prepend the standalone imports/helpers header. |
True
|
Example
manifest = {"datasets": {"imf_gsm": {"kind": "timeseries", ... "param_id": "amda/imf_gsm", "start": "2005-01-17", "stop": "2005-01-18"}}} to_standalone('d = load_data("imf_gsm")\nparam_card(d, "amda/imf_gsm")\n', ... manifest, with_header=False) 'd = spz.get_data("amda/imf_gsm", "2005-01-17", "2005-01-18")'
Source code in helioai/export.py
Configuration¶
helioai.config ¶
Centralized configuration — loads .env once at startup.
settings is a module-level singleton imported everywhere.
Fails fast at import time if the configured LLM provider is missing an API key.
AzureOpenAIConfig
dataclass
¶
Azure OpenAI deployment settings.
Azure routes by deployment name rather than model name, and reasoning models
(GPT-5, o-series) reject an explicit temperature — hence temperature=None
by default, which omits the field entirely.
Source code in helioai/config.py
GeminiConfig
dataclass
¶
Google Gemini settings, used with the native google-genai client.
Source code in helioai/config.py
GroqConfig
dataclass
¶
Groq settings. Reached through the shared OpenAI-compatible client.
Source code in helioai/config.py
OpenCodeConfig
dataclass
¶
OpenCode's Zen gateway — OpenAI-compatible, whichever way you reach it: the flat-rate Go subscription, a BYOK-routed key, or any other model Zen hosts.
base_url defaults to the Go-plan endpoint, since that flat-rate tier is what
most accounts actually have. It is a DIFFERENT catalogue from the general Zen
endpoint (.../zen/v1, no /go/) — that one serves premium/BYOK-only models
(Claude, ...) a Go subscription cannot reach, confirmed by querying both
/models endpoints directly. Override HELIOAI_OPENCODE_URL to the plain Zen
path if your access is not the Go plan.
No default model: what is reachable depends on your plan/BYOK setup and Zen's
rotating catalogue (GLM, Kimi, DeepSeek, Qwen, MiniMax...). Set
HELIOAI_OPENCODE_MODEL to the exact id from your dashboard — an empty string
fails at the API with a clear "unknown model" rather than silently routing to a
guessed default that may not exist on your plan.
16384 output tokens because everything this gateway serves is a reasoning model, and reasoning, prose AND tool-call arguments all draw on the same budget. At 4096, DeepSeek v4's run_python calls (~12k chars of JSON once a plot script is in them) were cut mid-string: the model saw "missing 1 required positional argument: 'code'", could not know why, and burned five turns re-sending the same truncated call. Same lesson as Azure's 2048→8192, one provider later.
Source code in helioai/config.py
OllamaConfig
dataclass
¶
Local Ollama settings.
Ollama serves an OpenAI-compatible API on /v1, so it needs no client of its
own and no API key. Point base_url elsewhere for any other local endpoint.
Source code in helioai/config.py
LLMConfig
dataclass
¶
Which provider to use, and the settings for each one.
Source code in helioai/config.py
AgentConfig
dataclass
¶
Agent loop limits.
max_iterations caps how many tool-calling rounds one question may take
before the loop gives up, bounding both runtime and token spend.
Source code in helioai/config.py
RAGConfig
dataclass
¶
Parameter search settings.
Retrieval is hybrid: dense embeddings for descriptions, BM25 for exact tokens
like BGSEc, fused by Reciprocal Rank Fusion with parameter rrf_k.
rerank_enabled stays False on purpose. A generic MS MARCO cross-encoder was
measured to degrade results here: trained on web prose, it discards the
dense+sparse consensus that makes exact-code matching work. Only a
domain-tuned reranker would help.
Source code in helioai/config.py
WorkspaceConfig
dataclass
¶
Per-session working directories, cleaned up after ttl_seconds.
Source code in helioai/config.py
ProfileConfig
dataclass
¶
RecipesConfig
dataclass
¶
Where scientific recipes are loaded from.
Defaults to the copy shipped inside the package so pip install works;
override with HELIOAI_RECIPES_DIR to use your own set.
Source code in helioai/config.py
CatalogsConfig
dataclass
¶
LiteratureConfig
dataclass
¶
MCPConfig
dataclass
¶
VisionConfig
dataclass
¶
Multimodal review of generated figures.
A stateless side-call outside the agent loop: the image is downscaled, sent once, and only the text verdict enters the history — never the image, which would otherwise be resent on every subsequent turn. Off by default.
Source code in helioai/config.py
DevConfig
dataclass
¶
Shared secret unlocking unrestricted mode past the heliophysics guardrail.
Empty by default, which means no token is valid and every request stays scoped. Compared in constant time.
Source code in helioai/config.py
WebAuthConfig
dataclass
¶
Nominative tokens for the web UI, parsed from HELIOAI_USERS.
Empty means no authentication and a single local user, which is the intended behaviour for local development only.
Source code in helioai/config.py
Settings
dataclass
¶
Root settings object.
Imported as the module-level settings singleton and read everywhere; built
once at import by _load(), which fails fast when the selected provider has
no API key.
Source code in helioai/config.py
dev_unlock ¶
True iff the supplied token matches the configured dev secret.
Returns False when the server-side token is empty (guards against accidentally unlocking an unconfigured instance).
Source code in helioai/config.py
Logging¶
helioai.logging_config ¶
Structured logging via structlog.
Output format is selected by HELIOAI_LOG_FORMAT
console(default): human-friendly, colourised.json: one JSON object per line.
setup_logging ¶
Configure structlog and the root logger.
Output format follows HELIOAI_LOG_FORMAT: console (default) or json.
Safe to call more than once — every entry point calls it, and repeated calls
replace the handler rather than stacking duplicates.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
level
|
str | int
|
Log level name or numeric value. Unknown names fall back to INFO. |
'INFO'
|