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: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 OpenAItools 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 malformedtool_calls entry raises rather than
falling back to prose parsing).
Local LLMs often return imperfect JSON. The parser applies six extraction strategies in sequence:
- Direct JSON parse
- Extract from markdown code blocks (
```json ... ```) - Bracket-matching — find first complete
{...}in mixed text - Fix common syntax errors (trailing commas, single quotes, missing brackets)
- Regex extraction of individual fields (
"thought","tool","answer") - Treat entire response as a plain-text conversational answer
Tool Execution Errors
When a tool callback throws an exception or returns{"status": "error", ...}, the agent enters error recovery mode:
- The error is captured (exceptions are caught, not propagated)
- The error context is sent back to the LLM: “Tool execution failed. Please try an alternative approach.”
- The LLM reasons about the error and may try a different tool or strategy
- If the LLM cannot recover within
maxSteps, the agent returns the last error as the result
MCP Auto-Reconnect
If an MCP server disconnects mid-session (process crash, timeout), the agent reconnects automatically: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
DifferentAgent 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 callprocessQuery() 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).
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 viaregisterTool() 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
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 betweenprocessQuery() 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:
GAIA_API macro automatically switches from __declspec(dllexport) to __declspec(dllimport).
Install Targets
The CMake install target produces a complete SDK package: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: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:
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.
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.ToolRegistry
OutputHandler
Subclass to integrate agent output with your own UI. All methods are virtual:MCPClient
MCPRegistry
Turns a configured server id into a launchable config, so an agent can connect togithub without hardcoding how github is started.
gaia connectors keys
entries by their catalog id (mcp-github, mcp-tavily, mcp-memory, mcp-git):
$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.
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.jsonand does not honorGAIA_CONFIG_DIR. Pointing it elsewhere makes the C++ registry read a file Python won’t. - Python does not read
mcp.jsonfrom the config directory, so an id that lives only there is invisible to it. Put anything both runtimes need inmcp_servers.json.
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 intogaia_core, so
including gaia/http_client.h does not pull a 10k-line header into your build.
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.
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 overfloat32 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.
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.
.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.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.
Transaction while one is already open produces a savepoint
instead of a second BEGIN, so nesting works.
Schema migrations
Migrations mirror the PythonMemoryStore 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.
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 raisesgaia::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
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:
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
Seecpp/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 foreignmetadata.<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: yesis a bool andmode: 0755is 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, anddisallowed-toolsare parsed, preserved, and ignored. They overlapmetadata.gaiaand are never a permission mechanism — permissions come only frommetadata.gaia.permissions.- Failures are loud. Every violation throws
gaia::SkillValidationErrornaming 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