All docs

Architecture Overview

How Eunice is put together: the chat-first design, the tool dispatch system at its core, module layout, dependency injection, and the Display Protocol.

This document describes the conceptual architecture of the Eunice platform, how components are organized, how they communicate, and why they are wired this way. Source paths are repo-relative.

Platform Shape

Chat-first by construction

Every capability funnels through the chat interface. A user message lands at /api/v1/assistant/... (router registered in app.py:504 from engine/orchestrator.py), the orchestrator runs an agentic loop, tools execute, and a StreamingResponse of Server-Sent Events flows back. Sidebar pages, Predict, Knowledge, Insight, Fabric, Apps, Skills, Entities, Approvals, Track, Telemetry, FinOps, Admin, are views over the same data, not parallel feature surfaces.

The cardinal three-layer split: the LLM decides what to do, the platform resolves how, the tool executes. The LLM never executes code directly. The platform never picks a tactic. Tools never know which model invoked them.

Everything is a tool

engine/tool_registry.py:ToolRegistry is the only true infrastructure. LLM calls, document generation, embeddings, RAG search, Excel export, corpus management, workflow execution, spatial joins, all are registered tools dispatched through one call site:

result = await registry.execute_tool(name, inputs, context)

New capabilities are tools, not standalone functions bolted onto a router. This is what makes them composable, auditable, and discoverable by the assistant at runtime (search_tools is itself a tool).

Self-extension

Two paths grow the registry without a deploy:

  • workflow/factory.py:PrimitiveFactory, write → test → verify loop. Takes a description and I/O schema, generates Python with the tool_build_* model role, exercises it against real data, registers as a PrimitiveContract if postconditions hold.
  • The build-tool platform skill (skills/platform/build-tool/): engine/tool_builder.py (as the write_tool_code tool) writes a candidate, review_tool_candidate runs two adversarial reviews on the tool_audit model, and tools/register_tool.py is the one approver (engine/tool_approval.py: evidence validator, test adapter, test authorization).

Both are bounded by the same registry contract everything else uses.

Module Map

app.py is the FastAPI entry point: it bootstraps logging, runs production safety checks (app.py:90-115 reject default JWT secret or dev bypass on a non-loopback bind), wires the DI container in lifespan(), registers every module’s router, and exposes liveness/readiness/version probes (/api/v1/health, /ready, /version).

PackageResponsibility
engine/Orchestrator, agentic loop, tool registry, dispatcher, prompt builder, session state, turn types
tools/Tool handler implementations grouped by domain
conversations/Conversation entity CRUD, access control
knowledge/RAG pipeline: extraction, chunking, embedding, vector search
predict/ML training, advisor, SHAP explanations
insight/Document classification, schema-based and schemaless extraction
workflow/Mostly retired (2026-07). Only factory.py (PrimitiveFactory) and seed_primitives.py survive; the assembler, semantic validator, and run engine are gone. Primitive execution moved to skills/primitive_runtime.py.
skills/SKILL.md capability layer + primitive_runtime, the successor to the retired Workflow feature
fabric/Catalog, Kits, temperature, relationship graph, dream cycle, user profile
admin/Model configuration, MCP server management, tenant settings
infrastructure/AsyncExecutionService and executor backends (thread pool today)
shared/Cross-cutting infrastructure (see Shared Layer)
api/Downloads, chart/map snapshots, bookmarks, small REST islands tied to artifacts
frontend/React/TypeScript UI (Vite)

Modules register their routers with app.py at startup. They are not supposed to import each other; cross-module communication goes through the DI container or the tool registry. The actual codebase is mostly faithful to this, engine/orchestrator.py is the largest violator, and its in-progress decomposition (see Engine) is intentionally peeling out the contract surface.

Dependency Injection

Wiring is dependency-injector-based. shared/container.py:CatalystContainer declares every provider as a providers.Singleton placeholder; shared/container_config.py:wire_phase_1a swaps in concrete implementations during lifespan() startup (app.py:126).

Protocols (shared/protocols.py)

Typed interfaces define what a service does, not how. Implementations are swappable per-environment without touching call sites.

ProtocolImplementation wiredNotes
TextExtractorknowledge/extractor.py:PyPDFTextExtractorUsed by RAG ingest
DocumentChunkerknowledge/chunker.py:StructuralChunkerRAG chunking
EmbeddingGeneratorshared/embeddings.py:BGEEmbeddingGeneratorLocal BGE model
VectorSearcherknowledge/search.py:PgVectorSearcher or SQLiteVectorSearcherDispatch on shared.database.is_postgres()
DataAnonymizershared/anonymizer.py:PresidioAnonymizerPII strip before external LLM calls
CostTrackershared/finops.py:SQLCostTrackerWrites usage_log
LLMSynthesizerimplemented by RAG pathSynthesizes answers from retrieved context

Other singletons wired in container_config.py (no PEP 544 protocol but injected by the same machinery): CatalystLLM, KnowledgeService (gatekeeper for all embedding-backed indexing, nothing outside it imports the raw embedder), ModelTrainer, ModelAdvisor, ToolRegistry, ToolBuilder, MCPManager, EmailService, NotificationService, AsyncExecutionService (backed by infrastructure/backends/thread_pool.py:ThreadPoolBackend(max_workers=8)).

Why cost tracking is a first-class protocol. Every LLM operation has a cost, and cost is a constraint the user should be able to see and govern. Making cost a protocol means it is applied consistently across every call and can be swapped for a mock in tests without touching call sites. The platform never silently refuses an operation because of cost, the user always has the final say.

Container injection into tools and routers

Routers and tool modules expose set_container(container); lifespan() calls each one explicitly after wiring (app.py:128-151). Tool modules that need DI services (tools/knowledge.py, tools/utility.py, tools/mcp.py, tools/prediction.py, tools/documents.py, tools/factory_tools.py) follow the same pattern. Direct cross-module imports are avoided.

The Tool Dispatch Boundary

ToolRegistry.execute_tool(name, inputs, context) (engine/tool_registry.py) is the single dispatch point. It looks up the handler (system, custom, or MCP-bridged), invokes it, awaits if the handler is a coroutine, and returns a ToolResult dataclass (engine/tool_result.py) carrying content, cards, cost, datasets, exports, error.

Underneath, handler return shapes still vary, ToolResult for the majority, legacy dict ({"message", "cards", "metadata"}) for a fraction, and a couple of async def outliers, and ToolResult.from_any() (tool_result.py:42) normalizes the inbound shape. What changed is that the Uniform Tool Contract (below) now sits at the dispatch boundary and presents one shape to the model regardless of what the handler returns.

Uniform Tool Contract

The Uniform Tool Contract is the structural rewrite of this boundary, and it has landed on this branch. It is applied at the dispatch boundary (engine/tool_registry.py:_apply_contract wraps every handler that has a registered ToolSpec or is source-decorated; envelope_to_tool_result adapts back to ToolResult), so a migrated tool returns one of three named result variants rendered into a single textual format the model sees identically at every tool boundary. The deferred baseline is empty, but an empty baseline is not full coverage: two newer tools are registered nowhere and bypass the contract at dispatch, and the “only-shrinks” ratchet test is currently failing both for that reason and because its own discovery of migrated tools is hand-maintained rather than derived. Full coverage is the invariant the ratchet exists to enforce; compute the live migrated/deferred split from the registry rather than trusting a count.

Three result variants, engine/contract.py:

VariantWhenCarries
Oksuccessrows, schema: list[ColumnSpec], data, warnings: list[Warning], effects: Effects
Hintrecoverable correction (schema / fetch / ambiguous)message, alternatives: list[dict]
Erractual bug / infra failuremessage, err_kind

Hint is the load-bearing addition. “Did you mean STATE_CODE?” or “this source requires a filter” used to be either a ToolResult.error (looked like a platform bug) or free-text content (looked like success). A Hint is neither, the test harness counts it as a recoverable correction rather than an error, the model can recover by re-calling with the supplied alternatives, and the model never has to learn each tool’s idiosyncratic recovery vocabulary.

Effects are typed side-effect channels, cards, datasets, exports, cost, that ride on Ok.effects and are consumed by the orchestrator, never text-rendered. Hint and Err carry no effects. Whether a tool’s cost is a rollup (delegate / delegate_batch) is a ToolSpec.is_cost_rollup attribute on the registry, not a per-result flag, so leaves can’t be double-counted.

Dispatch path under the contract:

  1. Handler module imports stamp register_tool_spec(ToolSpec(name, handler, is_tabular, is_cost_rollup)) and either source-decorate with @uniform_contract or use the bulk-migration helper migrate_tool(handler, name, ...) which leaves the handler unchanged and lets the registry wrap it at registration.
  2. ToolRegistry.register_system_handler calls _apply_contract(name, handler): if the handler has a spec and isn’t already decorated, wrap it. Idempotent.
  3. execute_tool populates context["_tool_spec"] and context["_current_tool_name"] before invocation so the decorator can read them at call time.
  4. The decorator (sync and async variants chosen by asyncio.iscoroutinefunction) runs the handler in try/except, catches HintException from the resolver layer (engine/contract_resolvers.py) and translates to Hint, catches Exception and translates to Err, runs _validate_envelope (warns if is_tabular and schema is empty), forwards a contract_envelope telemetry event, and persists effects.
  5. A compatibility adapter envelope_to_tool_result translates Ok/Hint/Err back to ToolResult so the rest of the orchestrator path runs unchanged during the migration.

Why bulk migration is safe. Decorating handlers at the source breaks every direct caller (tests, primitives that call other tools, etc.). migrate_tool returns the handler unchanged and wraps at the dispatch boundary, the contract takes effect when the registry invokes the tool, not when the source module imports.

Knowledge-is-routing composition. Catalog/namespace fuzzy resolution inside resolve_dataset / resolve_catalog_entry queries the data_catalog system corpus via the platform’s VectorSearcher, with the local-embedder name cosine as fallback for namespace variables. The contract’s resolvers and search_catalog use the same retrieval path and never disagree about what the catalog contains.

Migration ratchet. tests/contract_deferred_baseline.json is a CI snapshot of the set of unmigrated tools, computed from the registry by absence of the _uniform_contract marker. The baseline can only shrink. New tools must not be added to the deferred set.

Companion contracts. Two adjacent design decisions compose with the Uniform Tool Contract by defining what Ok.data looks like for data-acquisition tools (a {catalog_ref, modality, schema, intelligence, sample, size} envelope with per-column rendering rules, geometry/JSON/vector/binary/long-text become metadata blocks, not cell values) and by treating silent analyze_data save-failures as Hints instead of no-ops.

The Agentic Loop and Turn Lifecycle

A “turn” is one iteration: the assistant reads conversation state, calls the LLM with a streaming request, executes any tool calls the LLM emits, persists results, and decides whether to continue.

Where the code lives

The agentic loop lives in engine/executor.py:_execute_agentic_turns (moved out of engine/orchestrator.py in the orchestrator refactor). That refactor decomposes the former 891-line shell across:

ModuleRole
engine/runner.pyTop-level loop shell; builds TurnState + TurnConfig at request entry; reads cancellation signal each turn; populate_state_from_persistent_store mirrors module-dict state onto typed fields
engine/executor.pyPer-turn execution path; streaming LLM call; tool-gap detection; emits done
engine/dispatcher.pydispatch_tool_call / dispatch_tool_calls; policy-rule checks; failed-approach tracking; tool execution via AsyncExecutionService
engine/persister.pyAssistant message persistence, writes the chat_messages row with structured_content
engine/turn_types.pyTurnState, TurnConfig, TurnResult, TurnError, ToolRuntime, Effect catalog, Clock/IDProvider protocols
engine/state.pyCancellation signals, per-process state container (post-extraction home)
engine/effects.pyapply_effect, the single chokepoint that writes to the real recorder, display, and persister
engine/effect_accumulator.py_RecordingRecorder / _RecordingDisplay proxies that build typed effects and route through apply_effect
engine/session_state.pyEphemeral in-memory per-conversation state: active plan, task stage, failed approaches
engine/prompt_builder.py_build_system_prompt and dynamic state blocks

The orchestrator file remains as a thin shell that re-exports the underscore-prefixed names so legacy import sites (from engine.orchestrator import ...) keep working without churn. New code should import from the focused modules directly.

Typed effects and the apply_effect chokepoint

Pre-refactor, the loop body interleaved recorder.record(...) calls inline with stream handling, DB writes, and tool execution, a single function with five module-level state dicts and effects fired wherever convenient. A streaming bug could break telemetry; a telemetry fix could break persistence.

Today, three closed Effect kinds (engine/turn_types.py:259-313) describe every side-effect a turn produces:

EffectWhat it represents
PersistAssistantMessageWrite the chat_messages row at end of turn
TelemetryEvent(event_type, data, blob)Append to conversation_events; subsumes earlier behavioral-observation effect (event_type="behavioral_observation")
StreamChunks(chunks: list[SSEChunk])All SSE chunks the turn produced, batched

The recording proxies wrap the real EventRecorder and DisplayHandle. Every recorder.record(...) call:

  1. Builds a typed TelemetryEvent / SSEChunk.
  2. Appends it to the shared effects list.
  3. Calls apply_effect(effect, ctx) (engine/effects.py), the single chokepoint that writes to the real backends. EffectContext(recorder, display, persister) carries the live services; _SSE_DISPATCH maps event names to display methods; _apply_telemetry_event handles the text_delta batching path; _apply_persist drives the persister callable.

Net runtime behavior is byte-identical to the pre-refactor path, but tests can mock apply_effect, replay can drive it with synthetic effects to reconstruct a run, and audit can inspect effects independently of when they were created.

Per-turn flow

  1. Runner reads cancellation signal and sets state.cancelled (precondition input, not effect, see TURN_CONTRACT_SPEC.md).
  2. Runner builds TurnState + TurnConfig at request entry; populate_state_from_persistent_store mirrors _conversation_namespaces, _session_states, _conversation_tool_history onto typed fields. Read-through wiring today, the executor still reads and writes the module dicts directly; a follow-on PR migrates consumers field by field.
  3. Executor streams the LLM call, emitting text_delta / thinking chunks via the display protocol.
  4. Dispatcher runs realtime policy checks (_check_policies_realtime), computes args_hash for state.failed_approaches, populates _tool_spec on context, invokes the handler via AsyncExecutionService.
  5. (Post-contract) Decorator catches HintException / Exception, validates the envelope, fires contract_envelope telemetry before _persist_effects. The registry adapter translates the envelope back to ToolResult.
  6. Loop continues until state.end_reason != "in_progress". Termination: end_turn | max_turns | cost_budget | cancelled | error.

Circuit breakers

MechanismDefault
Agentic turn limitmax_agentic_turns=75 (configurable)
Per-delegate turn limitmax_turns=10 (overridable per task)
Per-delegate cost budget$1.00 (overridable per task)
Per-tool execution timeout300s default
Parallel sub-agentsMax 10 per delegate_batch
Cancellation checkEvery turn (engine/state.py:is_conversation_cancelled)
Context size guardEstimated token count before each LLM call

Display Protocol

The display protocol (shared/display.py) is the output layer between tool execution and the SSE stream. It is modeled on Jupyter’s IOPub messages: emitters describe what happened, the frontend decides where and how to render.

ComponentRole
DisplayEventWire format, event_type, data, channel, scope, display_id, verb, priority, persistent, metadata
DisplayHandleScoped, fire-and-forget interface tools call (status, busy, idle, progress, thinking, text, card, error, warning, cost)
DisplayFactoryOwns the event queue; creates handles; drains as SSE; bridges telemetry
ChildDisplayFactoryWrites to a parent factory without closing it, a nested-scope helper left from the retired workflow-node model; no live call sites today

Event types and channels

EventType constants (shared/display.py:102): text_delta, text_complete, reasoning, tool_call, tool_result, busy, idle, status, progress, card, error, warning, cost, done.

Channel (shared/display.py:Channel): response (main chat bubble), thinking (steps tab), status (status bar), artifact (side panel), system (errors/warnings/cost).

Verb (shared/display.py:Verb): append, update, replace, clear, controls how the frontend mutates rendered output for a given display_id.

Priority (shared/display.py:Priority) orders events when multiple compete: IDLE=0, LOW=5, NORMAL=10, IMPORTANT=20, URGENT=30.

Session-scoped ambient context

DisplayFactory is session-scoped via Python contextvars (shared/display.py:52). Tools call get_display_factory() to obtain the ambient factory; they don’t receive it as a parameter. Delegated sub-agents and primitives all produce output, without ambient infrastructure, every producer would have to thread an explicit display parameter through call chains. This is what makes the chat-first design practical at depth: any code path can reach the output channel without knowing anything about the HTTP layer.

Telemetry bridge

DisplayFactory.drain() is the SSE generator, it IS the StreamingResponse body. As events drain, _record_event forwards persistent events to the EventRecorder (shared/telemetry.py). Transient events (progress, busy, idle) are skipped from telemetry, they exist for UX, not audit. text_delta and reasoning are batched via record_text_delta / record_thinking_delta (~500 chars per row) so a 100-chunk stream lands as a handful of rows, not a hundred.

drain() emits both new-format (event: display) and legacy SSE event types (event: text, event: thinking, event: card, event: status, etc.) for backward compatibility during the frontend migration. The legacy emitter is in _format_legacy_sse; it should go once the frontend fully consumes the new protocol.

Telemetry

shared/telemetry.py:EventRecorder accumulates events in memory and flushes in a single batch INSERT at stream end, zero DB writes during streaming. Payloads above BLOB_THRESHOLD = 32 KB (shared/telemetry.py:23) are stored in conversation_event_blobs with a foreign-key reference from conversation_events.

The event log is immutable and never truncated. Display truncation (thinking-stream preview length, tool-input preview in the legacy SSE shape) is a separate, UI-only concern. There is no setting that affects what gets written to conversation_events.

The full replay surface is exposed at GET /conversations/{id}/replay and per-message at /conversations/{id}/messages/{msg_id}/events. The dedicated /telemetry page renders these with type filtering, search, and a full-JSON drill-down.

Persistence & Data Architecture

PostgreSQL is the database in every deployed environment (postgresql+psycopg://localhost/eunice locally, RDS in production), and the platform’s hybrid keyword-plus-vector retrieval runs only there. A development fallback backend still exists for local work, but she refuses to boot on it: the default is PostgreSQL and a startup guard exits on any other backend unless CATALYST_ALLOW_NON_POSTGRES_BACKEND is set deliberately. All schema changes are surgical SQL (UPDATE, targeted INSERT, targeted DELETE). The database is never dropped.

Key table groups

GroupTables
Authusers, tenant_memberships, role_capabilities
Tenantstenant_config, tenant_model_overrides
Conversationsconversations, chat_messages
Telemetryconversation_events, conversation_event_blobs
Catalogcatalog_entries, catalog_entry_types, dataset_activity
Knowledgedocuments, chunks, corpora
Toolstool_definitions, tool_jobs, tool_execution_log
Workflows (retired)workflows, workflow_runs, workflow_queue, workflow_app, legacy rows retained per the no-DB-deletion rule; nothing writes to them
Primitivesprimitives
Insighttaxonomies, extraction_schemas, processing_results, data_domains
Kitskits, kit_memberships, execution_snapshots
Relationshipsrelationship_edges
Behavioralobservations, policy_rules, routing_rules
Externaldatasources
Notificationsnotifications, notification_receipts
Spatialgeojson_blob_refs
Configmodel_config
MCPmcp_connections
FinOpsusage_log, budget_alerts
Evaleval_runs
Filesgenerated_files
Systemseed_log

Tombstone, never delete

Catalog entries are never hard-deleted; they receive a deleted_at timestamp. This preserves conversation replay integrity. All queries filter WHERE deleted_at IS NULL by default.

ConversationCatalog

Each active conversation maintains an in-memory ConversationCatalog, a typed registry of every dataset and document the conversation has access to, backed by catalog_entries. Tools look up datasets by name or ID through the catalog; they do not hit the filesystem directly. Scoping enforces the access boundary: if it is not in the catalog, it does not exist from the tool’s perspective. See Fabric module and Glossary for the catalog model.

Multi-Provider LLM Support

Four providers (anthropic, openai, google, deepseek) sit behind a single canonical-message interface in shared/llm.py. DeepSeek is OpenAI-compatible and reuses the OpenAI SDK with a different base_url.

Role-based selection

The model_config table maps abstract roles to concrete models. Code never references a model ID directly.

RoleUse
chatPrimary assistant conversations
fastClassification, intent, short-form extraction
strongComplex synthesis, code generation, detailed reports
compactionConversation history compression
embeddingVector embedding generation
tool_build_simpleSimple primitive/tool code generation
tool_build_complexMulti-step tool generation
tool_auditValidating generated tool code
peer_reviewIndependent review of assistant answers
kit_curatorKit naming/description during dream cycle

create_llm_client_for_role(role) reads model_config, resolves provider, model ID, and API key environment variable, and returns a provider-specific client.

Choosing a role for call_llm: fast when speed matters more than depth; strong when quality matters more than latency; chat is the default. tool_build_* is reserved for the factory and builder agents.

Token limits and capabilities

Token limits come only from model_config.max_output_tokens via get_model_for_role(). They are never hardcoded. The capabilities JSON column carries feature flags (web_search, vision, structured_output). Capabilities filter tools (native web search suppresses the platform web_search tool). Filtering is DB-driven via ToolDefinition.replaced_by_model_capability, applied to the resident tool surface by engine/tool_surface.py:_filter_resident_by_capabilities, the old hand-maintained _CAPABILITY_TOOL_MAP was deleted, and there is no capability prompt-guidance layer.

Authentication & Authorization

shared/auth.py defines RequestContext and the @requires(capability) decorator. shared/capabilities.py enumerates capability strings and role mappings. shared/auth_router.py exposes register, login, refresh, me, switch-tenant.

app.py:auth_middleware (app.py:320-476) validates JWTs, resolves user/tenant/capabilities, and sets the TenantContext and RequestContext context vars per request. Tenant selection precedence: JWT defines reachable tenants; X-Tenant-ID header selects the active one; a header picking an unowned tenant returns 403; in the absence of a header, the first membership wins.

System roles (on User): system_admin, security_auditor. Tenant roles (on TenantMembership): admin, member, viewer. Endpoints gate on capability strings, not role checks.

Dev bypass. CATALYST_AUTH_DEV_BYPASS=true accepts unauthenticated requests as a seeded dev user with system_admin capabilities and accepts expired tokens to preserve identity across long sessions. Production safety checks in app.py:90-115 refuse to start with the default JWT secret when bypass is disabled, and refuse to start with bypass enabled on a non-loopback bind, a network-exposed system_admin was the audit’s worst-flagged misconfiguration.

System Prompt Architecture

engine/prompt_builder.py:_build_system_prompt() assembles the system prompt dynamically per-conversation in layers:

  1. Base identity (CATALYST_SYSTEM_BASE in shared/prompts.py), persona, output contract, data-first behavior.
  2. Task decomposition, inspection vs retrieval vs analysis vs action.
  3. Resilience rules, diagnose failures before switching tactics; partial results beat no results.
  4. Tool guidance, dynamically generated from the registered tool list filtered by model capabilities.
  5. Model capabilities, injected from model_config.capabilities.
  6. [AVAILABLE_DATA], loaded datasets with variable names, corpus IDs, document IDs, trained models, MCP connections.
  7. [SESSION_STATE], active plan status, task stage, failed approaches, created datasets (engine/session_state.py).
  8. [USER_CONTEXT], compact behavioral observations about how this user works (fabric/user_profile.py).

All multi-line prompt strings live in shared/prompts.py. Inline multi-line prompts in module code are an architecture violation.

Deployment Shape

app.py is a single FastAPI process. lifespan() (app.py:118) drives the order-of-operations every developer should know:

  1. init_db(), create tables idempotently.
  2. wire_phase_1a(container), swap singletons.
  3. set_container(container) on every router and tool module.
  4. Wire AsyncExecutionService into NotificationService for future external delivery.
  5. Reload persisted ML models from disk via the trainer.
  6. seed_tools(), populate tool_definitions from engine/tool_definitions.py:SYSTEM_TOOLS.
  7. Backfill document_id into chunk metadata if needed (idempotent, fast).
  8. Auto-connect enabled MCP servers (stdio or streamable HTTP); persist connected / error status on mcp_connections.

Shutdown: drain AsyncExecutionService (30s timeout) and close MCP connections.

Background work runs out-of-process. Dream cycle and the datasource probe loop run as python scripts/background_jobs.py --loop, kept out of the FastAPI process so embedding-model load (~1 GB) and ~70 HTTP probes don’t sit on the request path. See Deployment.

Operational endpoints. /api/v1/health (liveness), /api/v1/ready (DB connectivity), /api/v1/version (release / SHA / branch / built-at) are intentionally public, canary checks, liveness probes, and on-call dashboards poll them without credentials. Version metadata is resolved in priority order from CATALYST_* env vars, build_info.json, then live git invocation in dev.

Middleware order matters. CORS first, gzip second (minimum_size=1024, large /fabric/datasets/{id}/data payloads can run hundreds of MB; gzip compresses 5-10× on tabular JSON), then auth middleware. The catch-all exception handler pushes a deduped high-priority alert via shared.push before returning a 500.

Known Tensions and Structural Debt

These are real and load-bearing, don’t paper over them when working in the area.

engine/orchestrator.py is still the legacy shell

891 lines today, down from 3,330. The decomposition into runner.py, executor.py, dispatcher.py, persister.py is functionally complete, but the orchestrator file still re-exports underscore-prefixed legacy names for backward compatibility with import sites inside _execute_agentic_turns and external callers. Several module-level state dicts (_conversation_namespaces, _session_states, _conversation_tool_history) remain the canonical storage; runner.populate_state_from_persistent_store is a read-through wiring step that mirrors them onto TurnState. A follow-on PR migrates each consumer field-by-field and drops the dicts. Until then, the typed state is a typed view, not the source of truth.

Two tool-result envelopes coexist during contract migration

The dispatch boundary today is ToolResult (and legacy dict for some handlers). The Uniform Tool Contract introduces Ok | Hint | Err; envelope_to_tool_result adapts back to ToolResult so the rest of the orchestrator runs unchanged. Both shapes live in the tree for the duration of the contract migration. Deletion of engine/tool_result.py and the compatibility shims is conditional on the deferred set going empty.

Tool capability filtering deviates from “LLM decides”

When a model has a native capability (e.g., built-in web search), the platform removes the corresponding tool from the registered list for that conversation. This is a pragmatic call: offering both the native capability and a tool for the same function confuses models. The platform is making a decision the LLM could, in principle, make. The rule lives in the database (ToolDefinition.replaced_by_model_capability) and is applied by engine/tool_surface.py:_filter_resident_by_capabilities; the earlier hardcoded _CAPABILITY_TOOL_MAP was removed.

Catalog is a registry, not a store

ConversationCatalog is an index. Actual data lives in Parquet on disk and chunks rows in PostgreSQL. If backing stores diverge from catalog entries (e.g., a Parquet file deleted without going through the catalog API), tools will fail. New data-producing tools must register catalog entries before returning results, not after.

Legacy SSE shape is still emitted

DisplayFactory.drain() emits both event: display (new protocol) and legacy event types (event: text, event: thinking, event: card, etc.). The legacy emitter exists for frontend backward compatibility during the display-protocol migration and should be removed once the frontend fully consumes the new protocol.

Cross-References

  • Engine, Orchestrator, dispatcher, registry, prompt builder
  • Tools, Tool handler inventory and conventions
  • Shared Layer, DI container, LLM, telemetry, finops, auth
  • Fabric Module, Catalog, Kits, dream cycle, user profile
  • Skills Module, SKILL.md authoring, discovery, and script/primitive execution (successor to the retired Workflow feature)
  • Knowledge Module, RAG pipeline
  • Database Schema
  • Runtime Flow
  • Tool System
  • Deployment
  • Glossary