Skip to content

Architecture

alf edited this page Jul 27, 2026 · 6 revisions

Architecture

Design Principles

FreeCAD AI is built around a few core principles:

  • Zero external dependencies. The entire workbench uses only Python stdlib (urllib, json, threading, ssl, subprocess). No pip install required beyond FreeCAD itself.
  • PySide2/6 compatibility. FreeCAD 1.0 bundles PySide2; newer builds may use PySide6. A shim module (ui/compat.py) handles the import.
  • Safety over convenience. Tool calls wrap operations in undo transactions. Code execution goes through static validation, optional subprocess sandboxing, and undo rollback on failure.
  • Provider-neutral conversation format. Messages are stored in an internal format and converted to OpenAI or Anthropic API format on-the-fly.
  • FreeCAD's exec() loading. FreeCAD loads Init.py and InitGui.py via exec(), meaning __file__ is not defined. Helper functions must live in importable modules, not at the top level of these files.

Project Structure

freecad-ai/
├── Init.py                        # Non-GUI init: adds package to sys.path
├── InitGui.py                     # Workbench registration, FreeCAD commands
├── package.xml                    # FreeCAD addon metadata (v0.1.0)
├── pyproject.toml                 # Project metadata + pytest config
├── mcp_server_entry.py            # MCP server entry point (see MCP Integration)
│
├── freecad_ai/                    # Main package
│   ├── config.py                  # AppConfig dataclass, singleton, JSON persistence
│   ├── i18n.py                    # Translation helpers (PySide2/6 compat)
│   ├── paths.py                   # Workbench root / icon / translations path utils
│   │
│   ├── llm/                       # LLM communication layer
│   │   ├── client.py              # LLMClient: HTTP, SSE streaming, tool calling
│   │   └── providers.py           # Provider registry (6 providers + custom)
│   │
│   ├── tools/                     # Tool calling system
│   │   ├── registry.py            # ToolParam, ToolDefinition (with lazy_params), ToolResult, ToolRegistry
│   │   ├── freecad_tools.py       # 33 tool handlers
│   │   └── setup.py              # Default registry factory (built-in + MCP)
│   │
│   ├── ui/                        # Qt user interface
│   │   ├── compat.py              # PySide2/PySide6 import shim
│   │   ├── chat_widget.py         # Chat dock widget + agentic tool loop
│   │   ├── message_view.py        # HTML rendering for messages, tool calls, thinking
│   │   ├── code_review_dialog.py  # Code review dialog for Plan mode
│   │   └── settings_dialog.py     # Settings UI with provider + MCP config
│   │
│   ├── core/                      # Core logic
│   │   ├── executor.py            # Code execution: validate, sandbox, undo, execute
│   │   ├── context.py             # Document state inspector (objects, properties)
│   │   ├── system_prompt.py       # System prompt builder with context + skills
│   │   └── conversation.py        # Conversation history, API formatting, compacting
│   │
│   ├── extensions/                # Extension points
│   │   ├── agents_md.py           # AGENTS.md loader (search, includes, variables)
│   │   └── skills.py              # Skills registry, slash command matching
│   │
│   └── mcp/                       # Model Context Protocol
│       ├── protocol.py            # JSON-RPC 2.0 helpers
│       ├── transport.py           # STDIO transports (client + server)
│       ├── client.py              # MCPClient: connect, discover, call, deferred schemas, search
│       ├── manager.py             # MCPManager singleton: multi-server, deferred registration, search
│       └── server.py              # MCPServer: expose tools to external clients
│
├── skills/                        # Built-in skill definitions (6 skills)
│   ├── enclosure/SKILL.md         # Electronics enclosure with snap-fit lid
│   ├── gear/SKILL.md              # Involute spur gear
│   ├── fastener-hole/SKILL.md     # Standard fastener holes
│   ├── thread-insert/SKILL.md     # Heat-set thread inserts
│   ├── lattice/SKILL.md           # Lattice/infill patterns
│   └── skill-creator/SKILL.md     # Meta-skill: create new skills
│
├── translations/                  # Qt translation files (.ts, .qm)
│   └── compile_ts.py             # Script to compile .ts to .qm
│
├── resources/icons/               # Workbench icon
│
└── tests/                         # Test suite
    ├── conftest.py                # Shared fixtures
    ├── unit/                      # unit tests (pure Python, no FreeCAD)
    │   ├── test_config.py         # 20 tests
    │   ├── test_conversation.py   # 35 tests
    │   ├── test_executor.py       # 19 tests
    │   ├── test_registry.py       # 26 tests
    │   ├── test_protocol.py       # 25 tests
    │   ├── test_mcp_deferred.py   # 24 tests (deferred loading, search, lazy params)
    │   ├── test_agents_md.py      # 24 tests
    │   ├── test_skills.py         # 19 tests
    │   └── test_i18n.py           # 3 tests
    └── integration/               # 29 integration tests (FreeCAD AppImage)
        ├── test_create_primitive.py         # 7 tests
        ├── test_create_body_sketch_pad.py   # 6 tests
        ├── test_pocket_sketch.py            # 3 tests
        ├── test_boolean_transform.py        # 6 tests
        └── test_enclosure_workflow.py       # 1 test (full end-to-end)

Data Flow

User Message to Model Response

Data flow from user message to model response

User types message
        |
        v
ChatDockWidget._send_message()
        |
        ├── Check for /skill command → SkillsRegistry.match()
        |
        ├── conversation.add_user_message(text)
        |
        ├── Check conversation.needs_compaction()
        │     └── If yes: spawn _CompactionWorker to summarize older messages
        |
        ├── Build system prompt:
        │     system_prompt.build_system_prompt(mode, tools_enabled)
        │       ├── Identity + mode instructions (Plan/Act/Act+Tools)
        │       ├── Code conventions (full API ref without tools, abbreviated with tools)
        │       ├── Document context from context.get_document_context()
        │       └── AGENTS.md from agents_md.load_agents_md()
        |
        ├── Get messages: conversation.get_messages_for_api(api_style)
        │     ├── Truncate older messages to fit max_chars
        │     ├── Never split tool_call/tool_result pairs
        │     └── Convert to OpenAI or Anthropic format
        |
        └── Spawn _LLMWorker thread
              ├── create_client_from_config() → LLMClient
              └── If tools enabled:
              │     _tool_loop(client)     [agentic loop]
              └── Else:
                    _simple_stream(client)  [plain streaming]

Agentic Tool Loop

Agentic tool loop diagram

_tool_loop(client):
    for turn in range(30):           # Max 30 tool turns
        |
        ├── client.stream_with_tools(messages, system, tools)
        │     Yields LLMStreamEvents: text_delta, thinking_delta,
        │     tool_call_start, tool_call_delta, tool_call_end, done
        │
        ├── Emit text deltas to UI via token_received signal
        │
        ├── If no tool_calls → response_finished → return
        │
        ├── Append assistant message (with tool_calls) to messages
        │
        ├── For each tool_call:
        │     ├── Emit tool_call_started signal to UI
        │     ├── _execute_tool_on_main_thread(name, args)
        │     │     ├── Emit tool_exec_requested signal (queued connection)
        │     │     ├── Block on QMutex/QWaitCondition (30s timeout)
        │     │     └── Main thread runs ToolRegistry.execute()
        │     │           └── handler(**params) inside undo transaction
        │     ├── Emit tool_call_finished signal to UI
        │     └── Append tool result message to messages
        │
        └── Loop back to stream next LLM turn

The critical detail here is the main thread dispatch. FreeCAD's Python API is not thread-safe, so all tool executions must happen on the Qt main thread. The worker thread:

  1. Emits tool_exec_requested via a Qt.QueuedConnection signal
  2. Blocks on a QMutex / QWaitCondition
  3. The main thread's slot calls ToolRegistry.execute() and then worker.set_tool_result()
  4. The worker thread unblocks and continues

Tool Execution Safety

Each tool handler in freecad_tools.py wraps its operation using _with_undo():

def _with_undo(label: str, func):
    doc = App.ActiveDocument
    doc.openTransaction(label)
    try:
        result = func(doc)
        doc.recompute()
        doc.commitTransaction()
        return result
    except Exception as e:
        doc.abortTransaction()
        doc.recompute()
        return ToolResult(success=False, output="", error=str(e))

For the execute_code tool (raw Python execution), additional safety layers apply:

  1. Static validation -- blocks os.system(), subprocess, shutil.rmtree(), and checks for known FreeCAD crash patterns (full-circle revolution, etc.)
  2. Subprocess sandbox -- optionally runs the code in a headless FreeCAD subprocess first; catches segfaults (signal -11) before they hit the main process
  3. Undo transaction -- wraps execution in openTransaction() / commitTransaction() with abortTransaction() on failure
  4. Auto-save -- writes a recovery snapshot of the document before execution so crashes do not lose work. Snapshots live in a managed backups directory (<FreeCADAI dir>/backups/, one hash-tagged file per document), not beside your project file; bound their number with the max_backups setting.

Provider Abstraction

The LLMClient class in llm/client.py supports two API styles:

API Style Providers Endpoint
openai OpenAI, Ollama, Gemini, OpenRouter, Custom /chat/completions
anthropic Anthropic /v1/messages

The provider is selected by name, and providers.py maps each name to its API style:

PROVIDERS = {
    "anthropic": {"api_style": "anthropic", "supports_tools": True, ...},
    "openai":    {"api_style": "openai",    "supports_tools": True, ...},
    "ollama":    {"api_style": "openai",    "supports_tools": True, ...},
    "gemini":    {"api_style": "openai",    "supports_tools": True, ...},
    "openrouter":{"api_style": "openai",    "supports_tools": True, ...},
    "custom":    {"api_style": "openai",    "supports_tools": False, ...},
}

All HTTP communication uses urllib.request (no requests library). Streaming uses SSE (Server-Sent Events) parsing. The client handles both simple text streaming and structured tool-call streaming for both API styles.

Thinking Mode

The thinking feature enables LLM reasoning chains:

Provider Mechanism
Anthropic thinking block with budget_tokens (4096 for "on", 16384 for "extended") + beta header
Ollama/qwen3 /think and /no_think tags appended to system prompt
OpenAI (o1/o3) reasoning_effort parameter ("medium" for "on", "high" for "extended")

Thinking content is streamed via thinking_delta events and rendered separately from the main response text.

Conversation Format

Messages are stored in a provider-neutral internal format:

# User message
{"role": "user", "content": "Create a 50mm cube"}

# Assistant message with tool calls
{"role": "assistant", "content": "I'll create that for you.",
 "tool_calls": [{"id": "tc_1", "name": "create_body", "arguments": {"name": "Cube"}}]}

# Tool result
{"role": "tool_result", "tool_call_id": "tc_1", "content": "Created body 'Cube'"}

# System message (stored as user message with prefix)
{"role": "user", "content": "[System] Code executed successfully."}

Conversation.get_messages_for_api() converts to provider-specific format:

  • OpenAI format: tool results become {"role": "tool", "tool_call_id": ...}, tool calls include {"type": "function", "function": {"name": ..., "arguments": json_string}}
  • Anthropic format: tool results become {"role": "user", "content": [{"type": "tool_result", ...}]}, tool calls become content blocks [{"type": "tool_use", ...}]

Context Compaction

When estimated tokens exceed 20,000 and there are more than 6 messages, the conversation auto-compacts:

  1. A _CompactionWorker thread sends older messages to the LLM with a summarization prompt
  2. On completion, Conversation.compact(summary, keep_recent=4) replaces old messages with a summary
  3. The summary is stored as a user message prefixed with [Context Summary ...]
  4. Tool call/result pairs are never split during compaction

Key Patterns

FreeCAD exec() Loading

FreeCAD loads workbench files via exec(), so __file__ is undefined. The pattern used throughout:

# In InitGui.py -- import inside methods, not at module level
class FreeCADAIWorkbench(Gui.Workbench):
    def __init__(self):
        from freecad_ai.paths import get_icon_path  # Import here, not at top
        icon = get_icon_path()
        if icon:
            self.__class__.Icon = icon

Init.py adds the workbench directory to sys.path by deriving it from FreeCAD.getUserAppDataDir().

Lazy-Loaded Singleton Config

_config: AppConfig | None = None

def get_config() -> AppConfig:
    global _config
    if _config is None:
        _config = load_config()
    return _config

Configuration is stored as JSON at <FreeCADAI dir>/config.json. The AppConfig dataclass includes provider settings, mode, max tokens, temperature, thinking mode, and MCP server configs. <FreeCADAI dir> is resolved at module-import time via _resolve_config_dir() in freecad_ai/config.py — see Configuration#configuration-paths for the resolution order and the v0.13.0-alpha migration logic.

Tool Registry Schema Generation

The ToolRegistry can export its tools in three formats:

  • to_openai_schema() -- OpenAI function calling format
  • to_anthropic_schema() -- Anthropic tool_use format
  • to_mcp_schema() -- MCP tools/list format

All three use the same underlying _params_to_json_schema() function that converts ToolParam objects to JSON Schema. All three call ToolDefinition.resolve_params() before serializing, which triggers lazy parameter loading for deferred MCP tools.

The registry also supports search_tools(query) for keyword-based tool discovery across all registered tools.

MCP Tool Integration

MCP tools from external servers are registered as regular ToolDefinition objects with category="mcp" and server__tool namespacing. The agentic loop does not need any special handling -- MCP tools are executed through the same ToolRegistry.execute() path as built-in tools.

When a server is configured with "deferred": true (the default), MCP tools are registered with a lazy_params callable instead of eagerly-loaded parameters. The ToolDefinition.resolve_params() method invokes this callable on first access, fetching the full input schema from the cached tools/list response. This avoids parsing and storing schemas for tools that may never be used.

Configuration

All settings live in <FreeCADAI dir>/:

Path Content
config.json Provider, model, tokens, temperature, thinking mode, MCP servers
conversations/ Saved chat sessions (JSON, one file per conversation)
skills/ User-defined skills (one directory per skill with SKILL.md)
AGENTS.md Global project instructions fallback
logs/ Session debug logs

Testing

The test suite is split into two categories:

Unit Tests

Pure Python tests that do not require FreeCAD. They test the core logic modules using mocks:

pytest tests/unit/ -v

These run by default (integration tests are excluded via addopts = "-m 'not integration'" in pyproject.toml).

Modules tested: config, conversation, executor, registry, protocol, mcp_deferred, agents_md, skills, i18n, select_geometry, new_tools.

Integration Tests (29 tests)

Require the FreeCAD AppImage to be installed. They spawn headless FreeCAD processes that execute tools and verify geometry:

pytest tests/integration/ -v -m integration

Workflows tested: primitive creation, body/sketch/pad, pocket operations, boolean operations, transformations, and a full end-to-end enclosure construction.

Integration tests write temporary Python scripts, run them via FreeCAD.AppImage -c script.py, and verify results from a JSON output file.

Build and Translation

Translation files use Qt's .ts / .qm system:

  1. .ts source files live in translations/
  2. translations/compile_ts.py compiles them to .qm binary files
  3. InitGui.py registers the translation path via Gui.addLanguagePath()
  4. All user-facing strings use translate("Context", "String") from i18n.py

The i18n.py module handles the PySide2/PySide6 difference:

try:
    from PySide6.QtCore import QCoreApplication
except ImportError:
    from PySide2.QtCore import QCoreApplication

def translate(context: str, text: str) -> str:
    return QCoreApplication.translate(context, text)

Clone this wiki locally