Skip to main content
Source Code: cpp/ in the GAIA repository.
See also: Overview for architecture, execution flow, and getting started.

Error Handling & Recovery

The framework handles failures at every layer — LLM connection, JSON parsing, and tool execution — so your agent doesn’t crash on transient errors.

LLM Connection Failures

If the LLM server is unreachable or returns an error, the agent retries once automatically, then exits gracefully:
The return value on LLM failure:
Your application should check the result field — there is no exception to catch. HTTP timeouts: 30s connection, 120s read.

Tool Calling Protocols

The agent drives tools two ways. Which one it uses is decided per model.

Native OpenAI tool calling

For models known to support it, the request carries an OpenAI tools array built from the tool registry plus a tool_choice, the response-format template is left out of the system prompt entirely, and the model replies with choices[0].message.tool_calls. Results go back as spec-correct role: tool messages carrying tool_call_id, so the model sees a well-formed tool exchange. Parallel calls — several tool_calls in one response — all execute, each with its own reply. Streaming works too: tool_calls deltas are reassembled across SSE chunks.

Prompt-JSON fallback

For every other model the agent appends a response-format template asking for a JSON envelope ({"thought": ..., "tool": ..., "tool_args": {...}}) and recovers the call from the reply text. Tool results become [Result from <tool>]: user turns. This is the path every C++ agent used before native tool calling landed, and it is unchanged.

Choosing a protocol

Auto consults gaia::isToolCallingModel(modelId), a mirror of the MODELS table in src/gaia/llm/lemonade_client.py. An unrecognised model id resolves to false — the C++ framework targets any OpenAI-compatible server, where an unknown id says nothing about tool-calling support, so it keeps the fallback that works everywhere. Running a tool-calling model this build does not know? Set NativeToolCalls::Always.

Response modes

responseMode picks the template used on the prompt-JSON path. It is ignored under native tool calling, which sends no template at all.

Malformed JSON Recovery

This applies to the prompt-JSON path only — native tool calls arrive as structured JSON and are parsed strictly (a malformed tool_calls entry raises rather than falling back to prose parsing). Local LLMs often return imperfect JSON. The parser applies six extraction strategies in sequence:
  1. Direct JSON parse
  2. Extract from markdown code blocks (```json ... ```)
  3. Bracket-matching — find first complete {...} in mixed text
  4. Fix common syntax errors (trailing commas, single quotes, missing brackets)
  5. Regex extraction of individual fields ("thought", "tool", "answer")
  6. Treat entire response as a plain-text conversational answer
This means the agent recovers from most LLM formatting errors without any intervention.

Tool Execution Errors

When a tool callback throws an exception or returns {"status": "error", ...}, the agent enters error recovery mode:
  1. The error is captured (exceptions are caught, not propagated)
  2. The error context is sent back to the LLM: “Tool execution failed. Please try an alternative approach.”
  3. The LLM reasons about the error and may try a different tool or strategy
  4. If the LLM cannot recover within maxSteps, the agent returns the last error as the result
Tool errors never crash the agent. The error flow:

MCP Auto-Reconnect

If an MCP server disconnects mid-session (process crash, timeout), the agent reconnects automatically:
The subprocess is re-launched and re-initialized. If reconnection fails, the tool call returns an error and the LLM is notified.

Loop Detection

The agent detects infinite tool call loops — when the LLM calls the same tool with the same arguments 4+ times in a row. When detected, the agent stops and returns:

Thread Safety

Blocking Semantics

processQuery() is fully blocking. It runs the complete agent loop (LLM calls, tool executions, history management) on the calling thread and returns only when a final answer is produced or the step limit is reached. This means:
  • Do not call processQuery() from a UI thread — it will freeze the UI for the duration of the agent run
  • Use a background thread or async wrapper for GUI integration

Concurrent Agent Instances

Different Agent instances are fully independent and can run in parallel on separate threads. Each agent owns its own conversation history, tool registry, MCP connections, and output handler.

Single-Agent Rules

Do NOT call processQuery() concurrently on the same agent instance. The Agent enforces this with an atomic in-flight guard: the second concurrent call throws std::runtime_error("Agent::processQuery is not re-entrant"). Callers must serialize access to a single Agent (or create one per thread/session).
Similarly, do not call connectMcpServer() or disconnectMcpServer() while processQuery() is running. The same rule covers the skill-set API: loadSkillSet() and setSkillLoader() mutate agent state and must be serialized by the caller. They are setup-time calls in practice. A lock inside loadSkillSet() would not make them safe — the SkillLoader may call back into the agent to register tools or rebuild the prompt, and the agent’s config mutex is not recursive. Reading activeSkillSet() or skillSetLoaded() from another thread is safe; both return by value under the lock.

Security Model

Tool Registration Is Explicit

Only tools registered via registerTool() or discovered from a connected MCP server are available. There is no reflection, auto-discovery, or dynamic code execution. The LLM can only call tools that your code has explicitly registered.

Tool Callback Responsibility

The framework does not validate tool arguments before passing them to your callback. Each tool is responsible for:
  • Validating its input parameters (types, ranges, formats)
  • Sanitizing paths and shell arguments
  • Rejecting unexpected or dangerous inputs
Example — a safe file-reading tool:

MCP Server Trust

MCP servers are trusted implicitly — all tools they expose are registered without review. Only connect to MCP servers you control. In production, audit the tool list returned by each server before deployment.

Prompt Injection

The LLM decides which tool to call based on user input and conversation history. A malicious user could craft input that causes the LLM to misuse a tool. Mitigations:
  • Validate in the tool callback — don’t trust the LLM’s argument choices blindly
  • Use restrictive tool descriptions — describe exactly what the tool does and what arguments it accepts
  • Limit tool scope — register only the tools needed for your use case
  • Consider confirmation flows — for destructive operations, require user confirmation before executing

Conversation History

Conversation history persists between processQuery() calls on the same agent. Previous queries and tool results are visible to subsequent LLM calls. For multi-user scenarios, create a new Agent instance per user session to prevent data leakage.

Production Deployment

Binary Sizes

Measured with MSVC 2022 Release build (x64): The static library is large because it bundles all dependencies. When building as a shared library (DLL), the binary is significantly smaller since dependencies are linked dynamically.

DLL / Shared Library

The framework supports both static and shared library builds. DLL export macros (GAIA_API) are already applied to all public classes:
When consuming the DLL, the GAIA_API macro automatically switches from __declspec(dllexport) to __declspec(dllimport).

Install Targets

The CMake install target produces a complete SDK package:
This creates:
Consumers use find_package(gaia_core) to link against the installed SDK.

Runtime Configuration

The LLM endpoint can be configured at runtime via environment variable — no recompilation needed:
All other AgentConfig fields are set at construction time. For dynamic configuration, read from a config file or registry in your makeConfig() function.

HTTPS Support

HTTPS is enabled automatically when CMake finds OpenSSL on the system (find_package(OpenSSL QUIET) in cpp/CMakeLists.txt). If OpenSSL is not present, GAIA builds with HTTP-only transport and skips the SSL-specific code paths. There is no GAIA_ENABLE_SSL option — to force-disable OpenSSL, pass the CMake built-in -DCMAKE_DISABLE_FIND_PACKAGE_OpenSSL=ON:

API Quick Reference

Agent

Skill sets (gaia::SkillSets)

An agent can carry more than one interchangeable capability set and activate exactly one per launch. Declare them in the agent’s gaia-agent.yaml:
The active set is chosen in this order: AgentConfig::skillSet (the --skill-set flag’s home) → the agent’s selectSkillSet() hook → the manifest’s default_skill_set. A name the manifest does not declare always throws SkillSetError naming the valid sets — it is never quietly downgraded to the default, because launching with the wrong capability bundle is worse than not launching.
Switching sets mid-session is loadSkillSet("personal"). The new set is loaded before the old one is retired, and only the skills the previous set brought in are unloaded — an always-on skill, and anything loaded outside a set, both survive. A failure part-way through rolls back completely, so the agent is never left reporting one set while carrying another’s.
version: pins are parsed and reported but not enforced — GAIA cannot check one until versioned skill installs land. A declared pin logs a warning naming the pin and the on-disk version rather than being silently accepted.
Skill discovery and registration arrive with the SkillManager work. Until an implementation of SkillLoader is installed via setSkillLoader(), loadSkillSet() resolves the set and warns that nothing was registered. Treat activeSkillSet() as “which set was chosen”, not as proof the skills are loaded.

ToolRegistry

OutputHandler

Subclass to integrate agent output with your own UI. All methods are virtual:
See the Custom Agent guide for full OutputHandler examples including headless/embedded usage.

MCPClient

MCPRegistry

Turns a configured server id into a launchable config, so an agent can connect to github without hardcoding how github is started.
It reads the same file and the same shape as the Python runtime, so a server configured once is reachable from both. Ids are whatever the file uses; gaia connectors keys entries by their catalog id (mcp-github, mcp-tavily, mcp-memory, mcp-git):
Search paths, lowest precedence first, under $GAIA_CONFIG_DIR (default ~/.gaia): mcp.json, then mcp_servers.json — the file gaia connectors maintains and the Python runtime reads, which wins on conflicting ids. Unknown top-level keys are ignored, and servers is accepted as an alias for mcpServers. Everything that cannot produce a launchable config throws MCPRegistryError naming the id, the paths searched, and the ids that are available: a missing file, malformed JSON, an unknown id, an entry marked "disabled": true, or a non-stdio type. An agent silently losing its MCP tools is worse than one that refuses to start.
Servers with secrets are Python-only today. gaia connectors configure <id> stores secret env values in the OS keyring and writes a $keyring reference into the config. The C++ runtime has no keychain support, so those entries (mcp-github, mcp-tavily) throw rather than launching the server with the reference string as the credential. Servers whose env values are literal — or empty — work from both runtimes.
Two behaviours to know about, both of which split the runtimes if you rely on them:
  • The Python MCP path always reads ~/.gaia/mcp_servers.json and does not honor GAIA_CONFIG_DIR. Pointing it elsewhere makes the C++ registry read a file Python won’t.
  • Python does not read mcp.json from the config directory, so an id that lives only there is invisible to it. Put anything both runtimes need in mcp_servers.json.
Unlike the Python runtime, the C++ registry does not read an mcp_servers.json from the current working directory: this config names commands to spawn, and a native binary that trusts whichever directory it was started from is an attack surface.

HttpClient

Blocking HTTP/HTTPS client for tools that need to call a web service. The transport (cpp-httplib) is a private dependency compiled into gaia_core, so including gaia/http_client.h does not pull a 10k-line header into your build.
Every failure — connection refused, timeout, TLS unavailable, or a non-2xx status — throws HttpError (a std::runtime_error) naming the URL and the failure mode; the client never returns an empty response instead. HttpError also exposes status() and body() for programmatic handling.
For streaming responses (SSE), postStreaming hands each chunk to your callback; returning false ends the stream normally — that is how LemonadeClient stops on the [DONE] sentinel. Request path may also be an absolute URL, in which case the configured base URL is ignored. HTTPS requires an OpenSSL-enabled build (see HTTPS Support); an https:// URL on an HTTP-only build raises rather than downgrading.

VectorIndex

Flat (brute-force) vector search over float32 embeddings, with save/load persistence. Exhaustive scan on every query — no approximate index — so it returns exactly what the Python SDK’s faiss.IndexFlatL2 / IndexFlatIP return, with no extra dependency to build.
Scores are the Python convention, higher-is-better, sorted best-first: Ties keep insertion order, so rankings are reproducible across runs and platforms. Mismatches raise instead of returning misleading results: a wrong-sized vector on add()/search() throws std::invalid_argument naming both dimensions, and loading a file built with a different embedding model throws std::runtime_error naming both models.
The .vec file is a documented little-endian binary format (magic, version, metric, dimension, count, then float32 payload) — see cpp/include/gaia/vector_index.h. It is not interchangeable with Python’s index.faiss; the two runtimes use separate cache directories and share only metadata.json.

Structured persistence (gaia::Database)

<gaia/database.h> is a RAII wrapper over SQLite for anything an agent needs to persist as structured data rather than loose JSON files. SQLite is vendored into gaia_core (cpp/third_party/sqlite/) and compiled with SQLITE_ENABLE_FTS5, so full-text search is always available and every platform build behaves identically. You do not need sqlite3.h on your include path or a SQLite package on your system.

Connection

Database::Options controls what is applied at open: Connections are opened in SQLite’s serialized mode, so one Database can be shared across threads without external locking.

Statements

Bind indices are 1-based; column indices are 0-based.
Typed accessors cover every storage class — columnInt64, columnInt, columnBool, columnDouble, columnText, columnBlob — plus isNull() and columnType() to tell a stored empty string apart from NULL.

Transactions

Transaction is a scope guard: it rolls back unless you commit.
Constructing a Transaction while one is already open produces a savepoint instead of a second BEGIN, so nesting works.

Schema migrations

Migrations mirror the Python MemoryStore approach — ordered steps, each advancing a stored version, chaining a database at any older version forward. The version lives in PRAGMA user_version, so no table is imposed on your schema; a fresh database reports 0.
Each step runs inside a transaction that also stamps the new version, so a step that throws rolls back completely and the stored version does not advance — re-running migrate() retries that same step. addColumnIfMissing() is the idempotent ALTER TABLE … ADD COLUMN, which is what makes a step that died half-way safely re-runnable. migrate() refuses to run against a database newer than the last step it knows about rather than operating on a schema it doesn’t understand.

Full-text search (FTS5)

Database::hasFts5() reports availability so you can assert it up front instead of discovering it from a failed query.

Errors

Every failure raises gaia::DatabaseError — nothing is swallowed and no operation degrades to a placeholder value. The message carries the SQLite text plus the context needed to act on it:
code(), dbPath(), and sql() expose the same fields programmatically.
Double-quoted string literals are rejected (SQLITE_DQS=0): WHERE name = "alice" is an error, not a silently-matching string constant. Use single quotes for literals, or a bind parameter.

Vision Language Models (VLM)

The C++ SDK supports vision-language models (VLMs) via the OpenAI-compatible /chat/completions endpoint. Images are sent inline as base64 data URIs.

gaia::Image

Supported formats: image/png, image/jpeg, image/gif, image/webp, image/bmp. Unsupported MIME types and empty buffers throw std::invalid_argument. Size cap. Image::fromFile rejects files larger than GAIA_MAX_IMAGE_BYTES (default 20 MiB, compile-time override). It also rejects non-regular files (directories, symlinks, FIFOs, devices) for safety.

Sending images with processQuery

Two new overloads accept images:
Context size. VLM models require a large context window — 32768 is the recommended minimum. Smaller values (e.g. 2048) will surface a raw server error as std::runtime_error from processQuery. History semantics. Both overloads are stateful and symmetric with the string overload: they read conversationHistory_ as request context, and append the input user messages (with image parts stripped) plus the assistant’s final answer. Image base64 is never retained in history. Thread safety. Agent is not re-entrant — concurrent processQuery calls on the same Agent throw std::runtime_error. See Thread Safety above.

End-to-end example

See cpp/examples/vlm_agent.cpp:

Skills (SKILL.md)

<gaia/skill.h> reads the same SKILL.md files the Python runtime does — same schema, same constants, same refusal messages — so a skill written once loads in either runtime. The C++ side is read-only: it parses, validates, and renders skills; publishing, signing, and installing stay in the gaia skill CLI.

Types

Guarantees

  • Round-trip is identity. parseSkill(toMarkdown(parseSkill(t))) == parseSkill(t), including foreign metadata.<vendor> namespaces, keys GAIA does not model, and the author’s key order. Nothing is lost by passing a third-party skill through GAIA.
  • Scalars resolve exactly as PyYAML does, because the Python runtime reads the same files with yaml.safe_load. flag: yes is a bool and mode: 0755 is 493 in both runtimes. A value PyYAML types as something JSON cannot hold — a timestamp, an infinity, the =/<< control tags — is kept as its literal text and written back unquoted, so Python still reads the value it read before.
  • compatibility, allowed-tools, and disallowed-tools are parsed, preserved, and ignored. They overlap metadata.gaia and are never a permission mechanism — permissions come only from metadata.gaia.permissions.
  • Failures are loud. Every violation throws gaia::SkillValidationError naming the field, the rule, and a doc link, and nothing partial is returned. Name must equal the directory name; a mismatch is refused.
  • BOM and CRLF tolerant, so a skill authored on Windows parses unchanged.

Next Steps

Overview

Architecture, execution flow, and getting started

Custom Agent

Custom prompts, typed tools, MCP servers, and output capture

Integration Guide

Consume gaia_core in your own CMake project

Quickstart

Prerequisites, build steps, and running your first demo