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
- Tool Types
- System Tools Inventory
- Tool Surface Composition
- Registration and Seeding
- Dispatch Path
- ToolResult Contract
- Uniform Tool Contract
- Effects and the Apply-Effect Chokepoint
- Adding a System Tool
- Custom Tools and the Sandbox
- MCP Tools
- Model Capabilities and Tool Filtering
- Database Schema
- Execution Logging
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
| Type | Source | Execution path | Default status |
|---|---|---|---|
system | engine/tool_definitions.py -> tools/*.py handlers | Registered Python function | approved |
custom | ToolBuilder agent or manual create | Restricted sandbox in ToolRegistry._execute_custom | draft -> testing -> approved |
mcp | External MCP server (shared/mcp_client.py) | Handler proxy bridging sync -> async MCP call | approved 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 module | Notable tools |
|---|---|
engine/schemas/analysis.py | analyze_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.py | spatial_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.py | call_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.py | create_corpus, index_content, refresh_anchor, move_documents, get_document_text, search_documents |
engine/schemas/insight.py | classify_document, extract_document, process_document, create_schema, create_taxonomy |
engine/schemas/entity.py | define_entity_projection, record_fact, get_entity, list_entities, recall_entities, link_entities, ingest_entities |
engine/schemas/predict.py | train_model, get_model_info, explain_prediction, list_trained_models, find_model |
engine/schemas/external.py | register_datasource, register_asset, set_datasource_auth, query_datasource, generate_site_dossier, mirror_source |
engine/schemas/email.py | send_email, search_email, read_email, import_email_attachment, search_directory |
engine/schemas/image.py | extract_from_image, analyze_photo, list_extraction_schemas |
engine/schemas/tracking.py | track_work_items, list_work_items, create_work_item, sync_work_items |
engine/schemas/jira_write.py | create_jira_issue, transition_jira_issue, comment_jira_issue, link_jira_issues |
engine/schemas/infra.py | connect_host, run_host_command, host_processes, host_metrics, host_action |
engine/schemas/terminal.py | terminal_open, terminal_exec, terminal_attach, terminal_close |
engine/schemas/skills.py | use_skill, create_skill, update_skill, run_skill_script, read_skill_asset |
engine/schemas/factory.py | create_primitive, validate_primitive |
engine/schemas/obligations.py | propose_obligation, get_obligation |
engine/schemas/connections.py | connect_system |
engine/schemas/mcp.py | search_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()weightstool_execution_logrows by age bucket (recent< 30 days,mid< 90 days,oldbeyond that; weights 3.0 / 1.0 / 0.2) into a per-tool score.derive_tier0()builds the floor asANCHORSfirst (in order), then the highest-scoring non-anchor tools, untilTARGET_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,
ANCHORSalone already has 44 entries, more thanTARGET_FLOOR. Because the loop that adds usage-ranked tools stops as soon as the floor reachesTARGET_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 pinnedSeedLogrow keyedtool_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 toSTATIC_FLOOR, a frozen copy ofshared/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_FLOORis 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:
| Mode | Composition |
|---|---|
chat | (floor ∪ working_set) − agentic-only tools (currently just plan) |
agentic | a 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:
| Key | Required | Description |
|---|---|---|
name | yes | Tool identifier, used in tool_use blocks and all execution calls |
description | yes | What the tool does, this is what the LLM reads to choose between tools |
input_schema | yes | JSON Schema for input validation |
category | yes | Grouping label. internal excludes the tool from the LLM’s tool list while keeping it callable via execute_tool |
handler_path | yes | Dotted import path to the Python handler (e.g. tools.knowledge._exec_search_documents) |
status_message | no | Human-readable verb shown in the UI while the tool is running (e.g. “Searching documents”) |
large_output | no | Hint 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:
| Function | Purpose |
|---|---|
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:
- Build the synthetic
[CURRENT_STATE]block via_build_current_state_blockand 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. - Estimate token usage with a calibrated heuristic (
_token_caliblearns the actual/estimate ratio from the provider’s returnedinput_tokenseach turn). Compact oversizedtool_resultblocks via_compact_large_tool_resultswhen over the soft threshold; break the loop when over the hard threshold. - Stream the LLM response via
_stream_llm_response(streaming mode) orclient.create(non-streaming). Sentinel markers<!--EUNICE_RESP-->/<!--/EUNICE_RESP-->route text between the thinking pane and the chat response. - Record turn cost into
CostTracker+usage_log+ thellm_requesttelemetry event. - 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, firedone, return. - On tool calls: for each
tool_useblock, emit adecisionevent, build the tool context, run the loop guard, then call_execute_tool_via_async. Recordtool_result, append the assistant + tool messages toloop_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:
| Key | Type | Purpose |
|---|---|---|
tenant_id | str | Tenant identifier |
user_id | str | Caller (from get_request_context()) |
conversation_id | str | Current conversation |
dataset_id | str | None | Active dataset (legacy single-dataset shape) |
catalog | ConversationCatalog | Conversation-scoped catalog handle |
container | Container | DI container (services, factories) |
display | DisplayHandle | Per-tool display channel (streaming only) |
progress_callback | callable | Progress update sink (non-streaming) |
capabilities | frozenset[str] | Caller’s RBAC capabilities |
auth_dev_bypass | bool | Whether the dev bypass is active |
file_context | list[str] | Frontend [Dataset: ...] / [Image uploaded: ...] markers |
focus_context | str | None | Focus-mode anchor |
_user_message | str | The user’s raw message this turn |
_agentic_mode | bool | Agentic loop guardrails on/off |
_recorder | EventRecorder | Telemetry sink for tool-internal events |
_tool_registry | ToolRegistry | For sub-tool composition (e.g. tools.<name>(...) proxy) |
_tool_spec | ToolSpec | None | The Uniform Tool Contract spec for this tool (None for a tool with no registered spec) |
_budget_ref | list[float] | Mutable cost accumulator (delegate budget tracking) |
_budget_limit | float | None | Delegate cost ceiling |
_namespace_id | str | None | Override conversation namespace |
_reasoning | str | None | The stripped reasoning field for handlers that surface it in error feedback |
_advanced | AdvancedSettings | Data isolation + agentic toggles |
_current_tool_use_id | str | The 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:
| Field | Read by | Notes |
|---|---|---|
content | LLM as tool_result.content; UI thinking pane via display | Never 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. |
cards | Frontend (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). |
cost | CostTracker + usage_log + telemetry | USD value of any LLM calls the handler made. Default 0.0. |
datasets | Promoted 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 + notification | Keyed by export filename; value is {path, mime_type, size_bytes, source_tool, original_filename, file_id?}. |
error | Dispatch path treats non-None as failure | Set 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
| Type | Side panel | Inline in chat |
|---|---|---|
data_profile | yes | no |
model_recommendation | yes | no |
training_results | yes | no |
shap_explanation | yes | no |
peer_review | yes | no |
classification_result | yes | no |
extraction_result | yes | no |
file_download | yes | yes (download button) |
chart | yes | yes |
map_display | yes | no (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:
| Pattern | Tools (2026-09) | What it looks like |
|---|---|---|
| Source decoration | lookup_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 import | The 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:
| Field | Type | Notes |
|---|---|---|
rows | int | Row count |
schema | list[ColumnSpec] | Measured from actual data, never declared. Empty schema on a tabular tool (ToolSpec.is_tabular=True) raises a validator warning. |
data | Any | The payload itself |
warnings | list[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. |
effects | Effects | Side-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:
| Field | Type | Notes |
|---|---|---|
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”). |
message | str | Free-text explanation |
alternatives | list[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.
| Field | Type | Notes |
|---|---|---|
message | str | Error text |
err_kind | str | The 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=Trueflags an empty-schemaOkas a smell (validator warning);is_tabular=Falselegitimizes empty schema for render/document/structural-metadata tools.is_cost_rollup=True(set ondelegateanddelegate_batchonly) tells the decorator to skip theusage_logwrite so leaf tool costs are not double-counted.expected_envelopesis 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 inOk.data/Ok.effects.datasets)Hint(schema)→⚠ schema hint: ...+ ranked “did you mean” alternativesHint(fetch)→⚠ fetch hint: ...+ anadd:ornarrow:lineHint(ambiguous)→⚠ ambiguous: ...+ candidates to pick fromErr→✗ error (kind): messageOk.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:
| Effect | Purpose |
|---|---|
PersistAssistantMessage | Write the ChatMessage row at end of turn (message_id, conversation_id, tenant_id, final_text, structured_content, cards, cost_usd). |
TelemetryEvent | Append 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. |
StreamChunks | All 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). Getmax_tokensfromget_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.pyas named constants. Don’t inline them. - No truncation of LLM-bound output: never truncate
contentor card payloads to stay under some imagined limit. The model’s context window is the constraint; compaction happens at the message-list level. internalcategory: set"category": "internal"to keep the tool callable viaexecute_toolbut 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_toolscall), add it toANCHORSinengine/tool_surface_derivation.pywith a comment explaining why; otherwise it’s reachable only viasearch_toolsuntil 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:
| Name | Type | Description |
|---|---|---|
df | pd.DataFrame | The conversation catalog’s default dataset (catalog.default_dataset() -> catalog.load(name)). May be None. |
pd | module | pandas |
np | module | numpy |
inputs | dict | Tool input parameters as passed by the LLM |
result | variable | Assign your output here |
call_tool(name, inputs) | callable | Re-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
| Method | Purpose |
|---|---|
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
| Transport | Setup args | Notes |
|---|---|---|
stdio | command, args, optional env | Spawns a subprocess, communicates over stdin/stdout. |
streamable_http | url, optional headers | Connects 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.capabilitiesJSON AND actually deliverable by the active provider (shared/llm_client.py:provider_native_capabilities(provider); onlyopenai_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_ALLOWEDin agentic mode), a tool the model deliberately paged into its working set viasearch_toolsis 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.
| Column | Type | Description |
|---|---|---|
id | Integer PK | Auto-increment |
name | String (unique) | Tool identifier |
description | Text | What the tool does (shown to the LLM) |
input_schema | JSON | JSON Schema for inputs |
implementation | Text | Python source (custom), system:{name} (system), or mcp:{conn}:{name} (MCP) |
handler_path | String | Dotted Python path for system handler auto-registration |
tool_type | String | system, custom, or mcp |
status | String | draft, testing, approved, rejected |
enabled | Boolean | Disabled tools are completely invisible to the LLM |
category | String | Grouping label; internal hides from LLM but keeps callable |
domain | String | None | Tool 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_capability | String | None | Names the model capability (e.g. web_search) that natively replaces this tool, see Model Capabilities and Tool Filtering. |
status_message | String | Verb shown in the UI while the tool runs |
large_output | Boolean | Hint for display + compaction paths |
parent_tool | String | Name of the tool this extends |
dependencies | JSON | List of tool names this calls |
version | Integer | Bumped on update |
tenant_id | String | "*" = available to all tenants |
created_by | String | system, builder_agent, or mcp:{conn} |
rejection_reason | Text | Set on rejection by auditor |
test_results | JSON | Auditor output (test output, errors) |
deleted_at | DateTime | Soft-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.
| Column | Description |
|---|---|
id (String PK) | Short UUID |
tool_name | Proposed tool name |
job_type | build, audit, or rebuild |
status | queued, building, testing, approved, rejected, failed |
spec | JSON, original build specification |
result | JSON, final result (tool name, description, or error) |
progress_pct | 0-100 |
tenant_id | Owning tenant |
conversation_id | Source conversation (optional) |
tool_execution_log
Immutable audit log of every tool call. Operational record only, full untruncated I/O lives in conversation_events.
| Column | Description |
|---|---|
id | Integer PK |
tool_name | Tool that was called |
tenant_id | Executing tenant |
input_data | JSON, full input parameters |
output_summary | Text, first 500 chars of content |
success | Boolean, whether execution succeeded |
error | Text, exception message on failure |
execution_ms | Wall-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;