All docs

Tool System

The tool registry, the dispatch path, the built-in tool catalog, and the uniform contract every tool speaks.

Every capability the LLM can invoke is a registered tool. There is no special-cased logic for individual features, chat, document generation, RAG, spatial joins, and MCP integrations all flow through one dispatch surface. This page covers the registry, the dispatch path, the result contract, the system tool inventory, the dynamic tool surface the LLM actually sees per call, MCP integration, and the custom-tool sandbox.

Adjacent reading: Engine (orchestrator and prompt assembly), Tools module map (per-domain handler index), Glossary for the vocabulary.

Table of Contents


Architecture Overview

LLM emits tool_use block
        |
        v
engine/dispatcher.py: dispatch_tool_call(tool_use, runtime, state, ...)
        |
        +-- policy checks (_check_policies_realtime)
        +-- args_hash + failed_approaches accounting
        +-- loop guard (3 consecutive identical calls -> refused)
        |
        v
ToolRegistry.execute_tool(name, inputs, context)
        |
        +-- system tool   -> registered Python handler in tools/*.py
        +-- mcp tool      -> handler proxy to MCPManager (shared/mcp_client.py)
        +-- custom tool   -> sandboxed exec namespace
        |
        v
ToolResult(content, cards, cost, datasets, exports, error)
        |
        +-- exports -> catalog.register_export + GeneratedFile row + notification
        +-- datasets -> promoted into _conversation_namespaces (analyze_data scope)
        +-- cards -> singleton dedup, map_display merge, frontend SSE
        +-- cost -> CostTracker + usage_log + telemetry
        |
        v
tool_result block appended to loop_messages; next LLM turn

The ToolRegistry singleton (engine/tool_registry.py:217) is the only entry point. There is no second path for “built-in” capabilities, system, MCP, and custom tools all flow through execute_tool (engine/tool_registry.py:597). That uniformity is what makes the platform composable, auditable, and FinOps-trackable.

The dispatcher (engine/dispatcher.py) sits between the executor loop body and the registry. It owns policy enforcement, repeat-failure tracking, and contextual-signal capture. The _execute_tool_via_async helper (engine/dispatcher.py:376) routes the actual handler invocation through infrastructure/async_service so the user’s cancel button can race a long-running tool to completion.

A real trap: the thread-pool + asyncio.run hop

Inside ToolRegistry.execute_tool, when a handler returns a coroutine and the registry is already running inside an event loop (the normal ASGI case), it cannot await it directly or use asyncio.run on the current loop. Instead it does this (engine/tool_registry.py:660-662):

with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
    result = pool.submit(_asyncio.run, result).result()

asyncio.run creates and owns its own event loop for the duration of that call, on a worker thread, and tears the loop down the moment the coroutine returns. Any asyncio.create_task(...) a handler fires inside that coroutine, meaning to keep running in the background after the handler itself returns, is reaped when the loop closes. The task doesn’t get cancelled cleanly; the loop it belongs to simply stops existing under it. This has bitten background-task patterns inside async handlers (_exec_delegate, _exec_delegate_batch, tools/convene.py:execute_convene) more than once: a “fire and forget” task inside one of these looks correct in isolation and silently never completes when the outer call is dispatched through this hop. If a handler needs genuinely detached background work, it has to hand off to something that outlives this call (a job queue, infrastructure/async_service) rather than an in-process create_task.


Tool Types

TypeSourceExecution pathDefault status
systemengine/tool_definitions.py -> tools/*.py handlersRegistered Python functionapproved
customToolBuilder agent or manual createRestricted sandbox in ToolRegistry._execute_customdraft -> testing -> approved
mcpExternal MCP server (shared/mcp_client.py)Handler proxy bridging sync -> async MCP callapproved on connection

The tool_type column on ToolDefinition controls routing inside execute_tool (engine/tool_registry.py:execute_tool). System and MCP tools both resolve to entries in self._system_handlers; only custom triggers the sandbox (engine/tool_registry.py:725).


System Tools Inventory

System tool schemas live in per-category modules under engine/schemas/, 19 modules as of 2026-09: analysis, connections, email, entity, external, factory, image, infra, insight, jira_write, mcp, obligations, predict, search, skills, spatial, terminal, tracking, utility. engine/tool_definitions.py composes them into a single ordered SYSTEM_TOOLS list. The _TOOL_ORDER tuple (engine/tool_definitions.py:71) is the source of truth for the order tools appear in the prompt and the order the registry iterates them. _assemble() (engine/tool_definitions.py:268) cross-checks each category list against _TOOL_ORDER in both directions, a name in _TOOL_ORDER missing from every category file, or a schema declared in a category file but absent from _TOOL_ORDER, both raise KeyError at import time. This is what protects the prompt from silently losing (or silently never-exposing) a tool when a category file is edited.

A second pass, _apply_feature_flags (engine/tool_definitions.py:316), strips flag-gated tools from the registered list. It currently gates six tier3_model_grid_enabled tools (sample_model_grid, aggregate_model_grid, model_vertical_profile, ensemble_probability, animate_model_field, screen_forecast_arrivals) plus query_frames (frames_sql_enabled) and query (unified_query_enabled). All three flags default to True in shared/config.py as of 2026-09, so in a stock install none of these tools are actually filtered out, the flags exist to allow disabling them per-deployment, not because any of them ships off by default.

Do not rely on a printed tool count, SYSTEM_TOOLS had 172 entries as of 2026-09; count it yourself with len(engine.tool_definitions.SYSTEM_TOOLS) (or _TOOL_ORDER, the two are index-identical) rather than trusting any number here later. The table below groups the current categories with example tools; for the exhaustive, always-current mapping of tool name to schema file and handler, see Tools module map, which is generated from the same source.

Schema moduleNotable tools
engine/schemas/analysis.pyanalyze_data, query, query_frames, preview_data, describe_entry, create_kit, search_kits, query_telemetry, build_co_occurrence, discover_clusters, load_telemetry, create_observation
engine/schemas/spatial.pyspatial_join, spatial_overlay, buffer, spatial_filter, display_map, save_map, geocode, simulate_dispersion, fetch_wind_field, fetch_environmental_layer, waters_trace, elevation_grid, usdm_polygons, nlcd_zonal_stats
engine/schemas/utility.pycall_llm, request_user_input, search_tools, search_self_code, generate_document, document_conversation, web_search, fetch_url, manipulate_excel, create_chart, plan, delegate, delegate_batch, convene
engine/schemas/search.pycreate_corpus, index_content, refresh_anchor, move_documents, get_document_text, search_documents
engine/schemas/insight.pyclassify_document, extract_document, process_document, create_schema, create_taxonomy
engine/schemas/entity.pydefine_entity_projection, record_fact, get_entity, list_entities, recall_entities, link_entities, ingest_entities
engine/schemas/predict.pytrain_model, get_model_info, explain_prediction, list_trained_models, find_model
engine/schemas/external.pyregister_datasource, register_asset, set_datasource_auth, query_datasource, generate_site_dossier, mirror_source
engine/schemas/email.pysend_email, search_email, read_email, import_email_attachment, search_directory
engine/schemas/image.pyextract_from_image, analyze_photo, list_extraction_schemas
engine/schemas/tracking.pytrack_work_items, list_work_items, create_work_item, sync_work_items
engine/schemas/jira_write.pycreate_jira_issue, transition_jira_issue, comment_jira_issue, link_jira_issues
engine/schemas/infra.pyconnect_host, run_host_command, host_processes, host_metrics, host_action
engine/schemas/terminal.pyterminal_open, terminal_exec, terminal_attach, terminal_close
engine/schemas/skills.pyuse_skill, create_skill, update_skill, run_skill_script, read_skill_asset
engine/schemas/factory.pycreate_primitive, validate_primitive
engine/schemas/obligations.pypropose_obligation, get_obligation
engine/schemas/connections.pyconnect_system
engine/schemas/mcp.pysearch_mcp_marketplace, install_mcp_server

Handler implementations live across roughly 53 modules under tools/ (counted by distinct handler_path module, 2026-09), see Tools module map for the full file-to-tool index.

There is no engine/schemas/workflow.py and no tools/workflow.py. The v1/v2 Workflow feature (graph-based workflows and the primitive-workflow engine/assembler/validator) was retired in 2026-07; run_workflow, list_workflows, design_workflow, assemble_workflow, find_primitive, and the rest of the old workflow verb set no longer exist as tools. What survives is create_primitive / validate_primitive (tools/factory_tools.py, backing workflow/factory.py’s PrimitiveFactory) and the skills/ capability layer (use_skill, create_skill, update_skill, run_skill_script, read_skill_asset).


Tool Surface Composition

SYSTEM_TOOLS is the definitional catalog, every tool the platform knows how to run. It is not what any single LLM call sees. The array actually sent to the model on a given turn is composed per-call by assemble_tools() in engine/tool_surface.py, from three independently-varying inputs: a small resident floor, a per-conversation working set, and the current mode. Whatever isn’t in the composed array is still reachable, via search_tools, which pages a tool into the working set, but it is not sitting in the array the provider sees on that call.

The resident floor (Tier 0)

The floor is derived, not hand-listed, by engine/tool_surface_derivation.py:

  • ANCHORS (engine/tool_surface_derivation.py:83), a hand-maintained tuple of tool names (44 as of 2026-09) that are always resident regardless of usage history, each with an inline comment explaining why. These exist because a raw usage-count floor structurally cannot include a brand-new capability (convene, the document/spreadsheet workspace CRUD verbs, the entity-substrate verbs, locate_value, …), it has generated no telemetry yet to earn a slot.
  • compute_scores() weights tool_execution_log rows by age bucket (recent < 30 days, mid < 90 days, old beyond that; weights 3.0 / 1.0 / 0.2) into a per-tool score.
  • derive_tier0() builds the floor as ANCHORS first (in order), then the highest-scoring non-anchor tools, until TARGET_FLOOR (30) names are reached, and logs a warning (non-fatal) if the result falls outside [MIN_FLOOR, MAX_FLOOR] = [25, 35].
  • As of 2026-09, ANCHORS alone already has 44 entries, more than TARGET_FLOOR. Because the loop that adds usage-ranked tools stops as soon as the floor reaches TARGET_FLOOR, and the anchor list already exceeds that on its own, the derived floor today is effectively the anchor list verbatim; the usage-ranking half of the algorithm is currently dormant and only starts contributing names again if the anchor list shrinks below 30.
  • derive_and_store_tier0() writes the result to the pinned SeedLog row keyed tool_surface_tier0_v1; the dream cycle re-derives and rewrites it periodically. resolve_floor_names() (engine/tool_surface.py:202) reads it back, cached for a short TTL, and falls back to STATIC_FLOOR, a frozen copy of shared/prompts.py:CORE_TOOL_NAMES (55 names as of 2026-09), if no config has ever been written or the stored value doesn’t parse. CORE_TOOL_NAMES/STATIC_FLOOR is the bootstrap fallback, not “the 52 (or however many) tools in the prompt”, any doc or comment asserting a fixed prompt tool count is describing this fallback, not steady-state behavior.
  • A small always-unioned set, SYSTEM_TOOL_NAMES (engine/tool_surface.py:86), search_catalog, search_tools, describe_entry, query, query_frames, query_datasource, analyze_data, preview_data, recall_entities, get_entity, present_rows, is unioned into the resolved floor unconditionally. Unlike the derived floor, these can never be derived out by usage; they’re the tools a data platform structurally cannot function without.

The working set

Tools paged in via search_tools during a conversation are tracked per-(conversation_id, namespace_id) in engine/session_state.py and stay resident (sticky) for the rest of that conversation. This is the escape hatch for the other ~140 tools not in the floor.

Mode composition

assemble_tools(tenant_id, capabilities, conversation_id, namespace_id, mode, ...) composes by mode:

ModeComposition
chat(floor ∪ working_set) − agentic-only tools (currently just plan)
agentica fixed project-manager allowlist (_PM_ALLOWED: plan, delegate, delegate_batch, describe_entry, search_catalog, analyze_data, preview_data, display_map, generate_document, document_conversation, export, export_excel, search_tools) ∪ working_set, deliberately not intersected with the floor, so agentic mode doesn’t silently lose ground relative to chat mode
delegate(floor ∪ working_set) − agentic-only − delegate-excluded (the delegate-excluded set is currently empty)

The composed name set is then intersected with the live registry (a renamed or retired floor name is skipped and logged, never emitted as an invalid tool name to the provider), capability-filtered (below), and checked against MAX_TOOL_SURFACE = 120 (engine/tool_surface.py:49, held below OpenAI’s hard cap of 128), a budget violation raises ToolSurfaceBudgetError as an invariant assertion rather than silently truncating an array the model was just told is “available now.” The actual load-time budget enforcement happens where search_tools adds to the working set; the assembly-time check is a backstop.


Registration and Seeding

System tool definition format

Each entry in a category schema list is a dict with these keys:

KeyRequiredDescription
nameyesTool identifier, used in tool_use blocks and all execution calls
descriptionyesWhat the tool does, this is what the LLM reads to choose between tools
input_schemayesJSON Schema for input validation
categoryyesGrouping label. internal excludes the tool from the LLM’s tool list while keeping it callable via execute_tool
handler_pathyesDotted import path to the Python handler (e.g. tools.knowledge._exec_search_documents)
status_messagenoHuman-readable verb shown in the UI while the tool is running (e.g. “Searching documents”)
large_outputnoHint that the tool can return large payloads (used by display + compaction paths)

The description field is the most consequential, it is the only signal the LLM has for tool selection. Write it from the perspective of the model deciding whether to invoke it.

Database seeding

At startup, engine/orchestrator.py calls registry.seed_system_tools(SYSTEM_TOOLS, retired=RETIRED_TOOLS) (engine/orchestrator.py:331; method at engine/tool_registry.py:261), which upserts every entry into the tool_definitions table. Existing rows get their description, input_schema, category, handler_path, status_message, and large_output refreshed; tool_type and status are not overwritten on updates. A changed description also clears the stored embedding so it doesn’t serve stale vectors until the next re-index.

Removal is explicit, not swept. Only tools named in RETIRED_TOOLS (engine/tool_definitions.py:62) are soft-deleted (deleted_at set). A live tool_definitions row that the running code simply doesn’t recognize, because of code/DB skew, a checkout on a different branch, a partial import, is left alone and logged loudly as unknown in the seeding report, never deleted. This replaced an older behavior that soft-deleted anything absent from SYSTEM_TOOLS on every boot; that swept any skew into silent mass-deletion, and a subsequent corpus rebuild would then permanently erase the deleted tools’ discovery embeddings. As of 2026-09, RETIRED_TOOLS has exactly one entry, list_catalog, disabled 2026-07-31 in favor of the embedding-backed search_catalog, with its schema and handler left in place, dormant, for a trivial revert.

Handler auto-registration

ToolRegistry.auto_register_handlers() (engine/tool_registry.py:360) walks every tool_type="system" row with a non-null handler_path, dynamically imports the module, and registers the resolved callable into self._system_handlers. Failures are logged as warnings rather than raising, a single broken handler should not block startup of the rest.

reasoning field injection

get_tools_for_llm(tenant_id) (engine/tool_registry.py:444) returns the LLM-visible schema. For every tool except plan, it injects a required reasoning field into the input schema:

"reasoning": {
    "type": "string",
    "description": "Why you chose this tool and approach. ...",
}

execute_tool strips reasoning from inputs before invoking the handler (engine/tool_registry.py:610-617) and passes it through context["_reasoning"] for handlers that need it for error feedback. It’s recorded as a top-level field on the tool_call telemetry event for audit and behavioral profiling. Handlers should never reach for inputs["reasoning"]; treat it as an auditability surface, not a control field.


Dispatch Path

The public surface in engine/dispatcher.py:

FunctionPurpose
dispatch_tool_call(tool_use, runtime, state, ...)Single-call entry. Computes args_hash, runs realtime policy checks, executes the tool, returns (tool_output, duration_ms, policy_warnings).
dispatch_tool_calls(blocks, runtime, state, ...)List entry. Serial in v1; the API supports parallelization (the provider can emit multiple tool_use blocks per assistant turn) for a follow-up.
args_hash(tool_name, tool_input) (engine/dispatcher.py:514)Stable 16-char SHA-256 prefix of (tool_name, sorted args) for repeat detection.
repeat_failure_hint(tool_name, args_h, state, threshold=3) (engine/dispatcher.py:528)Returns a “consider a different approach” hint string when state.failed_approaches shows the same tool+args has failed threshold times. The dispatcher hints; it does NOT terminate the loop.
record_failed_approach(tool_name, args_h, reason, state) (engine/dispatcher.py:560)Idempotent append to state.failed_approaches; increments attempts on repeats.
_check_policies_realtime(...) (engine/dispatcher.py:100)Runs DB-backed PolicyRule checks. Triggered violations are recorded as policy_violation telemetry events and reinforce per-user Observation rows.
_format_policy_warnings(...) (engine/dispatcher.py:188)Renders triggered violations as text the LLM will see in the tool result. Repeat violations get more emphatic wording, first/second/third+ (“STOP. You’ve skipped this N times.”).
_register_tool_exports(...) (engine/dispatcher.py:276)Routes ToolResult.exports into the catalog, GeneratedFile table, export telemetry event, and an export.ready notification.

Per-turn flow

The agentic loop body lives in engine/executor.py:_execute_agentic_turns (engine/executor.py:1326). For each turn:

  1. Build the synthetic [CURRENT_STATE] block via _build_current_state_block and append as a trailing user message. This is the only per-call-varying portion of the prompt; the system-prompt cache breakpoint sits before it so the cached prefix stays valid.
  2. Estimate token usage with a calibrated heuristic (_token_calib learns the actual/estimate ratio from the provider’s returned input_tokens each turn). Compact oversized tool_result blocks via _compact_large_tool_results when over the soft threshold; break the loop when over the hard threshold.
  3. Stream the LLM response via _stream_llm_response (streaming mode) or client.create (non-streaming). Sentinel markers <!--EUNICE_RESP--> / <!--/EUNICE_RESP--> route text between the thinking pane and the chat response.
  4. Record turn cost into CostTracker + usage_log + the llm_request telemetry event.
  5. On end_turn / max_tokens, run the completion guardrails (failed delegate contracts, incomplete plans) when agentic mode is on; emit cards, persist the assistant message, fire done, return.
  6. On tool calls: for each tool_use block, emit a decision event, build the tool context, run the loop guard, then call _execute_tool_via_async. Record tool_result, append the assistant + tool messages to loop_messages, continue.

The loop guard

The loop guard (engine/executor.py:2525-2578) refuses the 4th consecutive identical call: LOOP_GUARD_THRESHOLD = 3. It does not hash the full input minus only reasoning, it strips a small set of narration-style keys before hashing (_NARRATION_KEYS = {"reasoning", "explanation", "thought", "rationale", "justification"}), because two byte-identical operative calls with different free-text explanations would otherwise hash differently and never trip the counter (this is exactly how an analyze_data loop with identical code but a changed explanation used to survive the guard). Operative arguments (code, sql, query, …) are never stripped, so genuinely distinct work still differs and doesn’t trip the guard.

Cancellation

The runner reads the cancellation flag at the top of every turn (engine/executor.py:1517, state.is_conversation_cancelled). Inside a tool call, _execute_tool_via_async races the tool task against a cancellation watcher polling every 100ms (engine/dispatcher.py:376 onward). On cancel, the in-flight task is cancelled and the orchestrator stops waiting on it. Python’s threading model means the worker thread keeps running until natural completion (or its 300s timeout), the user just stops waiting on it; the platform cannot force-kill a plain Python thread. This is the best the platform can do without forced thread kills, and it gets the user out immediately, which is what they actually need.

context payload

The context dict the dispatcher hands to handlers carries:

KeyTypePurpose
tenant_idstrTenant identifier
user_idstrCaller (from get_request_context())
conversation_idstrCurrent conversation
dataset_idstr | NoneActive dataset (legacy single-dataset shape)
catalogConversationCatalogConversation-scoped catalog handle
containerContainerDI container (services, factories)
displayDisplayHandlePer-tool display channel (streaming only)
progress_callbackcallableProgress update sink (non-streaming)
capabilitiesfrozenset[str]Caller’s RBAC capabilities
auth_dev_bypassboolWhether the dev bypass is active
file_contextlist[str]Frontend [Dataset: ...] / [Image uploaded: ...] markers
focus_contextstr | NoneFocus-mode anchor
_user_messagestrThe user’s raw message this turn
_agentic_modeboolAgentic loop guardrails on/off
_recorderEventRecorderTelemetry sink for tool-internal events
_tool_registryToolRegistryFor sub-tool composition (e.g. tools.<name>(...) proxy)
_tool_specToolSpec | NoneThe Uniform Tool Contract spec for this tool (None for a tool with no registered spec)
_budget_reflist[float]Mutable cost accumulator (delegate budget tracking)
_budget_limitfloat | NoneDelegate cost ceiling
_namespace_idstr | NoneOverride conversation namespace
_reasoningstr | NoneThe stripped reasoning field for handlers that surface it in error feedback
_advancedAdvancedSettingsData isolation + agentic toggles
_current_tool_use_idstrThe provider’s tool_use id (for sub-tool correlation)

Handlers receive (inputs: dict, context: dict) and return a ToolResult (or, for a migrated tool, an envelope the registry translates back to ToolResult, see Uniform Tool Contract). Async handlers (_exec_delegate, _exec_delegate_batch in tools/delegate.py, and execute_convene in tools/convene.py) are awaited inside execute_tool, if there’s a running loop, the coroutine is shipped to a one-shot worker thread running its own loop (engine/tool_registry.py:654-665, see the trap called out above) to avoid run_until_complete deadlocks under ASGI.


ToolResult Contract

Every tool handler returns a ToolResult dataclass (engine/tool_result.py:18):

@dataclass
class ToolResult:
    content: str = ""                          # text the LLM sees
    cards: list[dict] = field(default_factory=list)
    cost: float = 0.0                          # USD cost of LLM calls inside the handler
    datasets: dict[str, pd.DataFrame] = field(default_factory=dict)
    exports: dict[str, dict] = field(default_factory=dict)
    error: str | None = None                   # None = success

Field semantics:

FieldRead byNotes
contentLLM as tool_result.content; UI thinking pane via displayNever truncated. Compaction happens later, at the message-list level. For a migrated tool, this is the rendered text produced by engine/contract_render.py, see below.
cardsFrontend (artifact panel and inline)Singleton types (cited_answer, map_display) dedup at end-of-response; map_display cards with the same display_id are merged via _merge_map_card (sources, layers, bounds, legend).
costCostTracker + usage_log + telemetryUSD value of any LLM calls the handler made. Default 0.0.
datasetsPromoted into _conversation_namespaces so analyze_data and display_map can look them up by name.Keyed by dataset name; values are DataFrame or pass-through payloads (e.g. the vector_field dict from fetch_ocean_currents).
exports_register_tool_exports -> catalog + GeneratedFile + notificationKeyed by export filename; value is {path, mime_type, size_bytes, source_tool, original_filename, file_id?}.
errorDispatch path treats non-None as failureSet this to a string to signal failure without raising. The handler still owns whether to also emit a partial content.

ToolResult.from_any(result) (engine/tool_result.py:42) normalizes legacy dict returns ({"message", "cards", "metadata"}) to the dataclass; the registry’s own normalization inside execute_tool (engine/tool_registry.py:676-682) also catches the legacy shape, so handlers can be migrated incrementally without breaking the loop.

Artifact card types

TypeSide panelInline in chat
data_profileyesno
model_recommendationyesno
training_resultsyesno
shap_explanationyesno
peer_reviewyesno
classification_resultyesno
extraction_resultyesno
file_downloadyesyes (download button)
chartyesyes
map_displayyesno (deferred-emit singleton)

Uniform Tool Contract

Every tool boundary in Eunice speaks one contract as of 2026-09. engine/contract.py defines a typed Ok | Hint | Err envelope; engine/contract_decorator.py’s @uniform_contract decorator wraps handlers to guarantee they emit one. The CI ratchet that enforces this (tests/test_uniform_contract.py + tests/contract_deferred_baseline.json) computes the set of tools with no registered contract spec (“deferred”) and asserts it is a subset of the committed baseline, the baseline is empty as of 2026-09, meaning every one of the 172 tools in SYSTEM_TOOLS has a registered ToolSpec. New tools cannot be added to the deferred list; they ship with the contract from day one.

Read that precisely, though: “has a registered spec” is not the same as “its handler source returns Ok/Hint/Err natively.” There are two distinct adoption paths, and only two tools use the first one as of 2026-09:

PatternTools (2026-09)What it looks like
Source decorationlookup_chemical (tools/chemistry.py), describe_entry (tools/catalog.py)@uniform_contract at the function definition; the handler body itself constructs and returns Ok(...) / raises HintException(...).
Spec-only registration~170 remaining tools, via migrate_tool(handler, name, is_tabular=...) at module importThe handler’s own source is untouched, it still returns ToolResult or a legacy dict. migrate_tool only registers a ToolSpec; ToolRegistry._apply_contract (engine/tool_registry.py:185-203) then wraps the registered callable with @uniform_contract at registration time, so the coercion happens at the dispatch boundary, not in the handler’s own code.

This two-tier design is what made bulk migration safe: decorating ~170 source handlers all at once, changing their return types, would have broken every direct caller and unit test that expected a ToolResult. Wrapping at the registration boundary instead leaves direct callers (other handlers calling each other, tests instantiating a handler directly) untouched, while still giving every dispatched call the envelope’s coercion, validation, and telemetry.

The three result variants

Defined in engine/contract.py.

Ok, successful result with measured data:

FieldTypeNotes
rowsintRow count
schemalist[ColumnSpec]Measured from actual data, never declared. Empty schema on a tabular tool (ToolSpec.is_tabular=True) raises a validator warning.
dataAnyThe payload itself
warningslist[Warning]In-band advisories (no_match, geo_incoherence, data_quality, …). Advisory but not blocking, a coherence issue on an otherwise-good 16k-row result rides here, not on a Hint, because Hint has no data field and would throw the result away.
effectsEffectsSide-effect channels the orchestrator (not the model) consumes: cards, datasets, exports, cost.

Hint, recoverable correction; the model can re-call, and it does NOT count as a platform failure:

FieldTypeNotes
hint_kind"schema" | "fetch" | "ambiguous"schema = unknown table/column/entity (“did you mean”); fetch = filter/cap/pagination issue (“add:” / “narrow:”); ambiguous = two candidates within tolerance (“pick one”).
messagestrFree-text explanation
alternativeslist[dict]Ranked candidates, each at minimum {label, score}.

Err, actual bugs/infra failures the model cannot recover from by re-calling: unhandled exceptions, connection refused, permission denied, OOM, timeouts.

FieldTypeNotes
messagestrError text
err_kindstrThe exception class name, or legacy for a translated ToolResult.error string without a hint prefix.

ColumnSpec carries name, dtype, optional null_count / distinct_count, and a dtype_provenance of "measured" or "inferred" (the latter for single-row dict scalars). Warning is kind + message + details. Effects carries cards, datasets, exports, cost.

ToolSpec and registration

Every migrated tool registers a ToolSpec(name, handler, is_tabular, is_cost_rollup) into the module-level _TOOL_SPECS registry (engine/contract.py):

  • is_tabular=True flags an empty-schema Ok as a smell (validator warning); is_tabular=False legitimizes empty schema for render/document/structural-metadata tools.
  • is_cost_rollup=True (set on delegate and delegate_batch only) tells the decorator to skip the usage_log write so leaf tool costs are not double-counted.
  • expected_envelopes is a set restricting which kinds the tool may return.

HintException

Handlers signal recoverable corrections by raising HintException(hint_kind, message, alternatives) (engine/contract_resolvers.py:60). The decorator catches it BEFORE the generic Exception handler and translates it to a Hint envelope. A “did you mean STATE_CODE?” or “this source requires a filter” was previously either a ToolResult.error (looked like a platform bug) or free-text content (looked like success); under the contract it is a first-class result with ranked alternatives, and the test harness treats it as non-error.

Render and the model-facing contract

engine/contract_render.py:render_envelope(envelope) produces the actual text the model reads, the rendering IS the model-facing contract, not the dataclass shape, and it is locked by golden snapshot tests in tests/test_contract_render.py. Six templates, one per shape:

  • Ok → ✓ ok. N row(s). + a schema line + a bounded, honestly-labeled preview (a window, never the whole table, but it always states the true row count and marks elided rows, e.g. ... (N more rows) ...; the full data lives in Ok.data / Ok.effects.datasets)
  • Hint(schema) → ⚠ schema hint: ... + ranked “did you mean” alternatives
  • Hint(fetch) → ⚠ fetch hint: ... + an add: or narrow: line
  • Hint(ambiguous) → ⚠ ambiguous: ... + candidates to pick from
  • Err → ✗ error (kind): message
  • Ok.warnings → ⚠ <kind>: <message> inline within an otherwise-successful render

engine/tool_result.py:_get_content() strips the leading ✓ ok. status line when another tool’s handler reads a migrated tool’s output as data (RAG synthesis, document structuring, enrichment, the primitive factory) rather than as a model-facing render, Hint/Err text is left intact so a caller can’t misread a failure as content.

Registry adapter (coexistence)

At the dispatch boundary, envelope_to_tool_result(result) (engine/contract_decorator.py:78) translates an envelope back to ToolResult so the orchestrator’s ToolResult-shaped path is unchanged:

  • Ok -> ToolResult(content=rendered, cards/datasets/exports/cost from effects, error=None)
  • Hint -> ToolResult(content=rendered, error=None) (recoverable, non-error)
  • Err -> ToolResult(content=rendered, error=result.message)

In the reverse direction (_translate_tool_result, _translate_dict_result in engine/contract_decorator.py), the decorator coerces a spec-only-migrated handler’s return: error strings prefixed with "schema hint:" or "fetch hint:" become Hints, any other error becomes Err(err_kind="legacy"), otherwise the result becomes Ok with a schema measured from the first dataset. This coexistence is permanent for spec-only tools, not a transitional step toward deleting ToolResult, the orchestrator, engine/effects.py, and every other consumer of tool output are built around ToolResult, and only the two source-decorated handlers ever construct an envelope directly.

Telemetry

The decorator records a contract_envelope telemetry event for every call, after coercion/validation and before effects are persisted. Per-tool Ok/Hint/Err counters with hint_kind / err_kind sub-tallies feed the conversation EventRecorder.


Effects and the Apply-Effect Chokepoint

A “turn”, one iteration of the agentic loop, produces typed effect objects in addition to its message output. The closed effect catalog lives in engine/turn_types.py:261-316:

EffectPurpose
PersistAssistantMessageWrite the ChatMessage row at end of turn (message_id, conversation_id, tenant_id, final_text, structured_content, cards, cost_usd).
TelemetryEventAppend a row to ConversationEvent. Event types include user_message, llm_request, tool_call, tool_result, thinking, status, text_delta, card, progress, error, policy_violation, behavioral_observation, decision, outcome, correction, friction, export, done, text_position_stats. Optional blob: bytes for >32KB payloads -> ConversationEventBlob.
StreamChunksAll SSE chunks the turn produced, batched. Per-chunk effects were rejected for replay-corpus readability, a turn producing 3,000 token-level chunks would generate a 3,000-element list that has to round-trip JSON.

Three closed kinds. Adding a fourth requires updating the closed-catalog lint check.

The recording proxies in engine/effect_accumulator.py wrap the real EventRecorder and DisplayHandle. Every recorder.record(event_type, data) call inside the loop body now (a) builds the typed TelemetryEvent, (b) appends it to the shared effects list, and (c) calls apply_effect(effect, ctx).

apply_effect (engine/effects.py) is the single chokepoint that writes to the real recorder, display, or persister. EffectContext(recorder, display, persister) carries the live services. _SSE_DISPATCH maps event names to display methods. _apply_telemetry_event handles the special-cased text_delta batching path and the optional blob arg. _apply_persist drives the persister callable.

This is a real change from the pre-effects flow. Before, the orchestrator interleaved recorder.record(...) calls inline with stream handling, DB writes, and tool execution, a single function with state in five module-level dicts and effects fired wherever convenient. A streaming bug could break telemetry; a telemetry fix could break persistence. Now the tool boundary emits typed result data only, and typed Effect objects flow through the recording proxies into the apply-effect chokepoint. The accumulator preserves call order; apply_effect is the only code that touches the outside world. Tests mock it; replay drives it with synthetic effects to reconstruct a run byte-for-byte; audit inspects effects independently of when and where they were created.

Streaming UX is preserved because apply_effect is called synchronously from the proxy at the moment of emission, “build effect -> append to list -> call apply_effect (which calls the real backend).” A future change could flip the proxies from “record + apply_effect immediately” to “record-only (defer)” by toggling one flag, at which point apply_effects(effects, ctx) would be called from the runner once per turn instead.


Adding a System Tool

1. Define the tool schema

Drop the schema dict into the right engine/schemas/<category>.py:

{
    "name": "summarize_corpus",
    "description": (
        "Generate a topic summary of all documents in a corpus. "
        "Use when the user asks for an overview, index, or summary "
        "of uploaded documents."
    ),
    "input_schema": {
        "type": "object",
        "properties": {
            "corpus_id": {"type": "integer", "description": "ID of the corpus"},
            "max_topics": {"type": "integer", "description": "Max topics (default 10)"},
        },
        "required": ["corpus_id"],
    },
    "category": "search",
    "handler_path": "tools.knowledge._exec_summarize_corpus",
    "status_message": "Summarizing corpus",
}

Then append "summarize_corpus" to _TOOL_ORDER in engine/tool_definitions.py at the position you want it in the prompt. If you forget either side, _assemble() raises KeyError at import time.

2. Implement the handler

In the matching tools/<domain>.py module:

from engine.tool_result import ToolResult

def _exec_summarize_corpus(inputs: dict, context: dict) -> ToolResult:
    corpus_id = inputs["corpus_id"]
    max_topics = inputs.get("max_topics", 10)
    registry = context["_tool_registry"]

    search_result = registry.execute_tool(
        "search_documents",
        {"query": "main topics overview", "corpus_id": corpus_id},
        context,
    )
    # ... process ...

    return ToolResult(
        content=f"Found {len(topics)} topics in corpus {corpus_id}.",
        cards=[{"type": "data_profile", "data": {"topics": topics}}],
        cost=llm_cost,
    )

3. Register with the Uniform Tool Contract

New tools must ship with a registered spec from day one, they cannot land in tests/contract_deferred_baseline.json. The minimum is a spec-only registration next to the handler:

from engine.contract import migrate_tool

migrate_tool(_exec_summarize_corpus, "summarize_corpus", is_tabular=False)

Prefer raising HintException(...) for recoverable corrections (unknown corpus id, no matching documents) over ToolResult(error=...). Reserve ToolResult.error (which the coercion path turns into Err(err_kind="legacy")) for actual failures.

4. Register (automatic)

handler_path is what ToolRegistry.auto_register_handlers() imports and registers. No manual register_system_handler call is needed unless the handler requires special init.

Guidelines

  • LLM calls inside handlers: use create_llm_client_for_role(role). Get max_tokens from get_model_for_role(role)["max_output_tokens"]. Never hardcode token limits.
  • Composition: handlers can and should call context["_tool_registry"].execute_tool(name, inputs, context) to compose existing tools. Preferred over duplicating logic.
  • Prompts: multi-line LLM prompts belong in shared/prompts.py as named constants. Don’t inline them.
  • No truncation of LLM-bound output: never truncate content or card payloads to stay under some imagined limit. The model’s context window is the constraint; compaction happens at the message-list level.
  • internal category: set "category": "internal" to keep the tool callable via execute_tool but hide it from the LLM’s tool list. Useful for sub-operations the model should not invoke directly.
  • Tool surface: a new tool with no usage history will not appear in the resident floor on its own, see Tool Surface Composition. If it needs to be reachable from a cold conversation (no prior search_tools call), add it to ANCHORS in engine/tool_surface_derivation.py with a comment explaining why; otherwise it’s reachable only via search_tools until it earns usage.

Custom Tools and the Sandbox

Custom tools are Python code strings stored in tool_definitions.implementation and executed inside ToolRegistry._execute_custom (engine/tool_registry.py:725). They can be created by the self-extending tool system (the ToolBuilder agent) or registered manually via register_custom_tool.

Sandbox semantics

Available in the namespace:

NameTypeDescription
dfpd.DataFrameThe conversation catalog’s default dataset (catalog.default_dataset() -> catalog.load(name)). May be None.
pdmodulepandas
npmodulenumpy
inputsdictTool input parameters as passed by the LLM
resultvariableAssign your output here
call_tool(name, inputs)callableRe-enters registry.execute_tool with the same context

SAFE_BUILTINS (engine/tool_registry.py:173) restricts builtins to a vetted set: len, str, int, float, bool, list, dict, tuple, set, range, sorted, reversed, min, max, round, abs, sum, zip, enumerate, map, filter, any, all, isinstance, type, print, repr, format, plus True/False/None and a few common exceptions. Everything else is gone.

BLOCKED_PATTERNS (engine/tool_registry.py:207) is a regex denylist scanned before execution. A match returns a rejection result without running the code:

\bimport\b   __builtins__   __class__   __subclasses__
\bopen\s*\(  \bos\.         \bsubprocess  \bsys\.
\bsocket\b   \beval\s*\(    \bexec\s*\(   \bcompile\s*\(
\bglobals\s*\(  \blocals\s*\(  \bgetattr\s*\(  \bsetattr\s*\(
\bdelattr\s*\(  \b__import__   \brequests\.   \burllib
\bshutil

Output handling: assign the final value to result. If result is a DataFrame or Series it is rendered as a table via to_string(max_rows=30, max_cols=15). If it’s a dict containing a message key, the whole dict is treated as a full response and returned. Otherwise it’s stringified. No arbitrary truncation is applied to the underlying tool result.

Self-extending tool pipeline

The ToolBuilder and ToolAuditor agents handle natural-language tool creation through the ToolJobQueue. Jobs progress through queued -> building -> testing -> approved | rejected | failed, with status and progress persisted to tool_jobs. Once status="approved", the tool is immediately visible to the LLM, get_tools_for_llm() reads from the DB on every request, no restart needed. (Whether it appears in the resident array on a given turn is a separate question, see Tool Surface Composition; a freshly-approved tool is reachable via search_tools from turn one regardless.)

call_tool is invisible to the LLM

Tool-to-tool calls via call_tool bypass the LLM’s decision loop. The model does not see these internal calls or their results. Use this for utility composition (a custom tool calling search_documents internally), not for decision-making that should be visible in the conversation.


MCP Tools

MCP (Model Context Protocol) servers expose external tools registered alongside system tools. MCPManager (shared/mcp_client.py:102) owns the connections.

Connection lifecycle

MethodPurpose
connect(name, transport, **kwargs) (:281)Open the transport, initialize the ClientSession, list available tools, register handlers, seed tool_definitions rows. Returns {"tools_discovered": N, "tool_names": [...]}.
disconnect(name) (:373)Pop the _LiveConnection, soft-delete its tool rows via ToolRegistry.unregister_mcp_tools(name), and close the AsyncExitStack.
reconnect(name, transport, **kwargs) (:388)Disconnect + connect (tool inventory may have changed).
stop() (:393)Disconnect all servers. Called on app shutdown.
call_tool(conn_name, tool_name, arguments) (:533)Proxy a single tool call. Maps the MCP CallToolResult content blocks into the platform’s own dict shape.
set_event_loop(loop) (:128)Capture the main event loop so sync handlers can bridge back into it via asyncio.run_coroutine_threadsafe.

Tool naming

A tool named search from connection my_server is registered as mcp_my_server_search. Description is prefixed with [MCP: my_server] so the LLM can tell native vs external tools apart. The handler is a sync closure (_make_handler, shared/mcp_client.py:639) that submits the async call_tool coroutine to the captured event loop and blocks on future.result(timeout=120).

Transports

TransportSetup argsNotes
stdiocommand, args, optional envSpawns a subprocess, communicates over stdin/stdout.
streamable_httpurl, optional headersConnects to an HTTP MCP endpoint; the stream tuple is (read, write, get_session_id).

Soft import

The MCP SDK is imported lazily. If mcp is not installed, MCPManager still loads, calls to connect() raise RuntimeError("MCP SDK not installed. Run: pip install mcp") but the rest of the app keeps working.

Result shape

call_tool returns the legacy dict shape ({"message", "cards", "metadata"}), which is normalized to ToolResult the same way any legacy dict handler return is. Costs are reported as 0, MCP servers don’t have a unified billing surface, and the platform doesn’t try to invent one.

Admin surface

The Admin page provides My Connections (active MCP servers) and a Marketplace (Smithery.ai search). The search_mcp_marketplace and install_mcp_server system tools expose the same functionality via chat.


Model Capabilities and Tool Filtering

Some models provide native implementations of capabilities the platform also exposes as tools (web search is the current example). When a native capability is available on the active model AND the active provider actually supports delivering it, the corresponding tool is dropped from the resident tool array so the model isn’t offered two ways to do the same thing.

There is no _CAPABILITY_TOOL_MAP in engine/tool_definitions.py anymore, it was deleted. That module-level dict lived (empty, then briefly populated) in engine/prompt_builder.py from 2026-04-09 to 2026-08-06 and was found to be consuming an empty map the whole time: its April-populated version had removed the web_search tool from the prompt while no provider ever actually attached a native web-search payload, which was misdiagnosed at the time as “models don’t use native capabilities” rather than “the request layer never wires them up.” _filter_tools_by_capabilities() in engine/prompt_builder.py is gone with it (the file has a standing comment at engine/prompt_builder.py:645 documenting the erratum so nobody re-adds the same mistake).

Capability-based filtering today lives in engine/tool_surface.py:_filter_resident_by_capabilities() (engine/tool_surface.py:262), and is DB-driven rather than code-driven:

  • ToolDefinition.replaced_by_model_capability (a column, not a Python dict) names the capability that replaces a given tool, e.g. web_search -> "web_search".
  • ToolRegistry.capability_replacements() reads {name: capability} for every non-null value of that column (cached 30s).
  • A resident tool name is dropped only when its replacement capability is BOTH turned on in the active ModelConfig.capabilities JSON AND actually deliverable by the active provider (shared/llm_client.py:provider_native_capabilities(provider); only openai_responses -> {"web_search"} supports anything as of 2026-09). That double guard is exactly what makes bad capability data inert, a model row that (incorrectly) claims a capability no provider can deliver can no longer silently remove a tool with nothing replacing it, which is precisely how the April 2026 incident happened.
  • Filtering applies only to the resident component of the composed array (the floor, or _PM_ALLOWED in agentic mode), a tool the model deliberately paged into its working set via search_tools is never removed by this filter, since it’s a signal the model already decided it needs that specific tool.

There is deliberately no prompt-guidance layer describing capability filtering to the model, a tool present (or absent) from the array is the whole signal; text explaining that absence in the prompt was considered and rejected as a crutch.


Database Schema

tool_definitions

The central record for all tools.

ColumnTypeDescription
idInteger PKAuto-increment
nameString (unique)Tool identifier
descriptionTextWhat the tool does (shown to the LLM)
input_schemaJSONJSON Schema for inputs
implementationTextPython source (custom), system:{name} (system), or mcp:{conn}:{name} (MCP)
handler_pathStringDotted Python path for system handler auto-registration
tool_typeStringsystem, custom, or mcp
statusStringdraft, testing, approved, rejected
enabledBooleanDisabled tools are completely invisible to the LLM
categoryStringGrouping label; internal hides from LLM but keeps callable
domainString | NoneTool facet (general, environmental, …) filled from shared/tool_facets.py:domain_for() when unset; never clobbers an admin edit. Persona substrate, no user-visible filtering on it yet.
replaced_by_model_capabilityString | NoneNames the model capability (e.g. web_search) that natively replaces this tool, see Model Capabilities and Tool Filtering.
status_messageStringVerb shown in the UI while the tool runs
large_outputBooleanHint for display + compaction paths
parent_toolStringName of the tool this extends
dependenciesJSONList of tool names this calls
versionIntegerBumped on update
tenant_idString"*" = available to all tenants
created_byStringsystem, builder_agent, or mcp:{conn}
rejection_reasonTextSet on rejection by auditor
test_resultsJSONAuditor output (test output, errors)
deleted_atDateTimeSoft-delete timestamp

get_tools_for_llm(tenant_id) filters by status="approved", enabled=True, category != "internal", deleted_at IS NULL, and tenant_id IN (tenant_id, "*"). This is the definitional-catalog query, not the per-call resident array, see Tool Surface Composition for what actually reaches a given LLM call.

tool_jobs

Tracks the async build/audit lifecycle for custom tool creation.

ColumnDescription
id (String PK)Short UUID
tool_nameProposed tool name
job_typebuild, audit, or rebuild
statusqueued, building, testing, approved, rejected, failed
specJSON, original build specification
resultJSON, final result (tool name, description, or error)
progress_pct0-100
tenant_idOwning tenant
conversation_idSource conversation (optional)

tool_execution_log

Immutable audit log of every tool call. Operational record only, full untruncated I/O lives in conversation_events.

ColumnDescription
idInteger PK
tool_nameTool that was called
tenant_idExecuting tenant
input_dataJSON, full input parameters
output_summaryText, first 500 chars of content
successBoolean, whether execution succeeded
errorText, exception message on failure
execution_msWall-clock execution time

This table also feeds the Tier-0 tool-surface derivation (engine/tool_surface_derivation.py:compute_scores), see Tool Surface Composition.


Execution Logging

Every tool invocation, success or failure, is written to tool_execution_log (shared/database.py:ToolExecutionLog) after the handler returns, so the row reflects the actual outcome.

output_summary is a 500-character snapshot of the result dict. It is for operational convenience only, the authoritative record is the conversation_events table, which captures full untruncated tool input, tool output, duration, and any policy_violation / decision / outcome events the dispatcher fired. Use query_telemetry from chat or the Telemetry page for full replay; use the SQL log for fast batch queries.

SELECT tool_name, success, error, execution_ms, created_at
FROM tool_execution_log
WHERE tool_name = 'analyze_data'
  AND tenant_id = 'your_tenant'
ORDER BY created_at DESC
LIMIT 50;