Recipes and the Tool Layer#
The machinery that exposes the package to an LLM agent: the analysis-recipe
framework, the @tool_spec decorator and its registry, the settings
singleton, the subprocess isolation used when a tool call arrives over an
untrusted transport, and the MCP server that publishes the registered tools.
The narrative overview is in Architecture overview.
Analysis recipes#
Kevin Dunn, 2010-2026. MIT License.
Reusable analysis-recipe framework.
An analysis recipe is a predefined, step-by-step workflow an LLM agent can follow when a user’s request matches a known analytical scenario (intake, panel processing, relating to covariates, …). Recipes reference existing agent tools by name so the agent chains calls deterministically instead of improvising the order.
The framework is package-wide and domain-agnostic: any subpackage may define its
own recipes and register them. The sensory subpackage is the first consumer (see
process_improve.sensory.recipes).
Adding recipes for a subpackage#
Create
process_improve/<subpackage>/recipes.py.Build
AnalysisRecipeinstances and pass each toregister_recipe().Add
"process_improve.<subpackage>.recipes"to_RECIPE_MODULESbelow sodiscover_recipes()imports it.
No changes to the agent tool layer are required: the single, general
select_analysis_recipe tool matches across every registered recipe.
- class process_improve.recipes.AnalysisRecipe(key, title, summary, domain, cue_phrases, inputs_needed, stages, status='available')[source]#
Bases:
objectA reusable, multi-step analysis workflow for the agent.
- Parameters:
- cue_phrases#
Lower-case substrings; each one found in a user’s request scores the recipe one point during matching.
- inputs_needed#
What the agent must resolve from the user before running, each with a short example.
- stages#
The ordered steps. Empty for a planned (not yet available) recipe.
- Type:
list of RecipeStep
- class process_improve.recipes.RecipeStep(order, directive, tools=<factory>, arg_hints=<factory>)[source]#
Bases:
objectOne step in an analysis recipe the agent should execute.
- tools#
Names of agent tools this step may call (empty for prose-only steps such as interpretation or data assembly).
- process_improve.recipes.get_recipe(key)[source]#
Return the registered recipe with key, or
None.- Parameters:
key (str)
- Return type:
AnalysisRecipe | None
- process_improve.recipes.list_recipes()[source]#
Return every registered recipe (registration order).
- Return type:
- process_improve.recipes.register_recipe(recipe)[source]#
Register recipe in the global catalog and return it.
- Raises:
ValueError – If a recipe with the same
keyis already registered.- Parameters:
recipe (AnalysisRecipe)
- Return type:
- process_improve.recipes.select_analysis_recipe(spec)[source]#
Return the best-matching recipe payload plus the full catalogue.
- Parameters:
spec (_RecipeQuery)
- Return type:
- process_improve.recipes.select_recipe(query)[source]#
Return the best-matching recipe for query, or
None.Scoring is intentionally simple: each cue phrase that appears as a substring of the canonicalised query contributes one point. The highest-scoring recipe wins (ties broken by registration order); a minimum score of one is required to match. Replace with embedding similarity if the catalog grows beyond a few dozen recipes.
- Parameters:
query (str)
- Return type:
AnalysisRecipe | None
Tool specifications and registry#
Kevin Dunn, 2010-2026. MIT License.
Tool-call-first infrastructure for process-improve.
Provides the @tool_spec decorator, a global registry of all decorated
functions, and helpers for Anthropic-compatible tool-use integrations.
Quick start#
Import the decorated tools and pass the specs to the Anthropic client:
import anthropic
from process_improve.tool_spec import get_tool_specs, execute_tool_call
client = anthropic.Anthropic()
response = client.messages.create(
model="claude-opus-4-6",
max_tokens=1024,
tools=get_tool_specs(),
messages=[{"role": "user", "content": "Are there outliers in [1,2,3,100]?"}],
)
# Dispatch tool calls from the response
for block in response.content:
if block.type == "tool_use":
result = execute_tool_call(block.name, block.input)
- exception process_improve.tool_spec.ToolInputInvalidError(message, *, details=None)[source]#
Bases:
ToolSafetyErrorInput failed structural validation (unexpected types, bad shape).
- exception process_improve.tool_spec.ToolInputTooLargeError(message, *, details=None)[source]#
Bases:
ToolSafetyErrorInput exceeded an allowed size limit (cells, string length, depth).
- exception process_improve.tool_spec.ToolMemoryExceededError(message, *, details=None)[source]#
Bases:
ToolSafetyErrorSubprocess was killed, most likely by the memory limit.
- exception process_improve.tool_spec.ToolSafetyError(message, *, details=None)[source]#
Bases:
ExceptionBase class for safety-related tool-execution failures.
- exception process_improve.tool_spec.ToolTimeoutError(message, *, details=None)[source]#
Bases:
ToolSafetyErrorTool call exceeded the wall-clock timeout.
- process_improve.tool_spec.clean(value)[source]#
Recursively convert numpy scalars / arrays to plain Python types.
All
tools.pymodules should callclean(result)before returning so that every tool output is JSON-serialisable.
- process_improve.tool_spec.discover_tools()[source]#
Import all
tools.pymodules to populate the tool registry.This is called lazily on the first
get_tool_specs()invocation. It is safe to call multiple times (subsequent calls are no-ops).- Return type:
None
- process_improve.tool_spec.execute_tool_call(tool_name, tool_input)[source]#
Dispatch a single tool call from an Anthropic
tool_usecontent block.The input dict is validated via
input_model.model_validate(tool_input). Unknown keys raiseToolInputInvalidError(closes the SEC-15confirmed=Truekwarg-injection at the schema layer). The parsed pydantic model is passed to the tool function as a single positional argument.- Parameters:
- Returns:
Whatever the tool function returns (typically a JSON-serialisable
dict).- Return type:
Any
- Raises:
ValueError – If tool_name is not in the registry.
ToolInputInvalidError – If the input fails pydantic validation.
- process_improve.tool_spec.get_tool_specs(names=None, category=None)[source]#
Return tool specs in the format expected by the Anthropic
tools=parameter.- Parameters:
- Returns:
Each dict has keys
"name","description", and"input_schema"as required by the Anthropic API. Tools that opt in viarng=on the decorator also carry an"rng"key describing their reproducibility contract; seetool_spec().- Return type:
- process_improve.tool_spec.safe_execute_tool_call(tool_name, tool_input, *, timeout=None, max_cells=None, max_string=None, max_depth=None, memory_mb=None, executor=None)[source]#
Execute a tool call with input validation, timeout, and memory cap.
- Parameters:
tool_name (str) – Same meaning as
process_improve.tool_spec.execute_tool_call().tool_input (dict[str, Any]) – Same meaning as
process_improve.tool_spec.execute_tool_call().timeout (float | None) – Wall-clock seconds. On overrun the runaway worker is force-terminated (
terminate()thenkill()) so it cannot keep holding a CPU, andToolTimeoutErroris raised.max_cells (int | None) – Input-size limits. See
validate_input().max_string (int | None) – Input-size limits. See
validate_input().max_depth (int | None) – Input-size limits. See
validate_input().memory_mb (int | None) – RSS cap applied to the worker subprocess via
RLIMIT_AS(POSIX). On overrun the subprocess dies andToolMemoryExceededErroris raised.executor (ProcessPoolExecutor | None) – Optional caller-provided pool. When None (default) a PRIVATE pool is created for this call and torn down afterwards, so each call runs in a fresh worker with isolated process-global state and reclaimed memory, and concurrent calls (e.g. from the threaded MCP server) never share or tear down each other’s workers. A caller-provided executor is never recycled or terminated by this function - the caller owns its lifecycle.
- Raises:
ToolInputInvalidError, ToolInputTooLargeError: – Synchronous rejection before any subprocess work.
ToolInputInvalidErroralso covers JSON-schema violations (wrong type, out-of-bounds value, bad enum, missing required key, or an unknown parameter).ToolTimeoutError: – Wall-clock overrun.
ToolMemoryExceededError: – Worker subprocess died unexpectedly (likely OOM).
ValueError: – Unknown tool name (propagated from
execute_tool_call).
- Return type:
- process_improve.tool_spec.tool_spec(name, description, *, input_model, examples='', category='', rng=None)[source]#
Mark a function as an agent-callable tool.
- Parameters:
name (str) – Unique tool name exposed to the LLM (snake_case).
description (str) – Natural-language description of what the tool does and when to use it.
input_model (type[BaseModel]) –
A
pydantic.BaseModelsubclass describing the tool’s input. The model is the single source of truth for both the function’s Python signature and the MCP JSON Schema (derived viaBaseModel.model_json_schema()). Every input model must setmodel_config = ConfigDict(extra="forbid")so unknown keys are rejected (closes the SEC-15 kwarg-injection vector at the schema layer).The decorated function then receives the parsed model as its single positional argument:
class FooInput(BaseModel): model_config = ConfigDict(extra="forbid") values: list[float] = Field(..., min_length=2) @tool_spec(name="foo", description="...", input_model=FooInput) def foo(spec: FooInput) -> dict: return {"n": len(spec.values)}
examples (str) – Optional natural-language -> tool-call mappings appended to
descriptionso the LLM sees worked examples.category (str) – Optional category string (e.g.
"univariate"). Used for filtering withget_tool_specs().rng (dict[str, Any] | None) – Optional reproducibility metadata; see
_validate_rng_metadata()for the contract.
- Returns:
The original function, with an attached
_tool_specdict and_input_modelattribute.- Return type:
Callable
Configuration#
Kevin Dunn, 2010-2026. MIT License.
Central configuration for process-improve.
Closes ENG-09 (#291) – “configuration sprawl: env vars at import
time, magic numbers, no central config” – and ENG-27 (#309) –
“tool_safety.py reads env vars at import time”.
The settings singleton is the single place every other module
reads configurable knobs from. Each knob:
has a documented default,
can be overridden by the corresponding environment variable (
PROCESS_IMPROVE_*), andis read on first access, not at import time. Tests can call
Settings.reload()to re-read after mutating the environment; callers can override a single knob in code viasettings.tool_timeout = 5.0(the setter writes through and bypasses the cache).
Usage#
>>> from process_improve.config import settings
>>> settings.tool_timeout
10.0
>>> settings.mcp_safe_mode
False
Override via environment:
PROCESS_IMPROVE_TOOL_TIMEOUT=30 python -m process_improve.mcp_server
Override in code (e.g. in a test):
from process_improve.config import settings
monkeypatch.setenv("PROCESS_IMPROVE_TOOL_TIMEOUT", "5")
settings.reload()
assert settings.tool_timeout == 5.0
Why a class rather than module-level globals?#
Module-level globals are read once at import; a test that mutates
os.environ after import has no effect. The Settings class
caches values on first access and exposes reload() for the test
case. That matches what every other module’s behaviour should be.
The class deliberately does not depend on pydantic-settings
(or any new third-party package). pydantic is already a hard dep
(ENG-04 is
the open decision about whether to commit to it everywhere); a plain
class with explicit env-var reads keeps that decision unfettered.
- process_improve.config.DEFAULTS: Final[dict[str, Any]] = {'dataset_fetch_timeout': 30.0, 'max_cells': 1000000, 'max_depth': 10, 'max_factors_combinatorial': 15, 'max_formula_chars': 4096, 'max_formula_terms': 100, 'max_matrix_cols': 500, 'max_matrix_rows': 10000, 'max_memory_mb': 1024, 'max_regression_points': 5000, 'max_string': 100000, 'mcp_safe_mode': False, 'tool_timeout': 10.0}#
Default knob values. Kept here so
Settings.reload()knows what to fall back to when an env var is unset, and soDEFAULTSis the single place to read the canonical defaults from.
- process_improve.config.ENV_VAR_NAMES: Final[dict[str, str]] = {'dataset_fetch_timeout': 'PROCESS_IMPROVE_DATASET_FETCH_TIMEOUT', 'max_cells': 'PROCESS_IMPROVE_MAX_CELLS', 'max_depth': 'PROCESS_IMPROVE_MAX_DEPTH', 'max_factors_combinatorial': 'PROCESS_IMPROVE_MAX_FACTORS_COMBINATORIAL', 'max_formula_chars': 'PROCESS_IMPROVE_MAX_FORMULA_CHARS', 'max_formula_terms': 'PROCESS_IMPROVE_MAX_FORMULA_TERMS', 'max_matrix_cols': 'PROCESS_IMPROVE_MAX_MATRIX_COLS', 'max_matrix_rows': 'PROCESS_IMPROVE_MAX_MATRIX_ROWS', 'max_memory_mb': 'PROCESS_IMPROVE_MAX_MEMORY_MB', 'max_regression_points': 'PROCESS_IMPROVE_MAX_REGRESSION_POINTS', 'max_string': 'PROCESS_IMPROVE_MAX_STRING', 'mcp_safe_mode': 'PROCESS_IMPROVE_MCP_SAFE_MODE', 'tool_timeout': 'PROCESS_IMPROVE_TOOL_TIMEOUT'}#
Mapping from knob name to environment-variable name.
tool_safety’s original env-var contract is preserved verbatim so existing deployments keep working without modification.
- class process_improve.config.Settings[source]#
Bases:
objectSingle-instance configuration store.
Every attribute is a knob; reads are cached after the first access. Call
reload()after mutatingos.environ(typically inside a test fixture); calloverride()to set a single knob from code.- property dataset_fetch_timeout: float#
Wall-clock seconds budget for downloading one remote sample dataset.
Bounds the
urlopencall inprocess_improve._remote_data.fetch_remote_bytes(), so a black-holing host raises the module’s documentedRuntimeErrorinstead of hanging the caller indefinitely (#508).
- property mcp_safe_mode: bool#
Whether the MCP server should treat its transport as untrusted.
When
True, every tool call goes throughprocess_improve.tool_safety.safe_execute_tool_call()(validation, subprocess isolation, memory cap).
- property max_factors_combinatorial: int#
Maximum
kfor combinatorial design generators (ff2n,fullfact, simplex centroid / lattice). Default 15 caps2**krows at ~32 KiB of memory per cell.
- property max_regression_points: int#
Maximum
len(x)/len(y)for the O(N^2) regression kernels (repeated_median_slopeetc.).
- property max_matrix_rows: int#
Maximum row count for
data/x_datamatrix inputs tofit_pca/fit_pls/detect_multivariate_outliers.
Tool-call safety#
Kevin Dunn, 2010-2026. MIT License.
Safe execution wrapper for process-improve tool calls.
Adds the four guard rails needed to expose the tool registry over an untrusted transport (public MCP server, hosted REST API, etc.):
Input-size validation (reject oversize arrays/strings before work).
Wall-clock timeout via subprocess isolation.
Memory cap per subprocess (POSIX; best-effort on Windows).
Structured error types so callers can distinguish failure modes.
The in-process process_improve.tool_spec.execute_tool_call() is
left untouched for callers that trust their input (notebooks, tests,
the stdio MCP server running on the user’s own machine). Hosted
callers should use safe_execute_tool_call() instead.
Configuration#
Every knob is read lazily from process_improve.config.settings,
which in turn picks up the corresponding PROCESS_IMPROVE_*
environment variable on first access (ENG-09 / ENG-27). Tests can
override a knob in-process via settings.tool_timeout = 5.0; CI
deployments can still set env vars at startup. See
process_improve/config.py for the canonical defaults table.
- exception process_improve.tool_safety.ToolSafetyError(message, *, details=None)[source]#
Bases:
ExceptionBase class for safety-related tool-execution failures.
- exception process_improve.tool_safety.ToolInputTooLargeError(message, *, details=None)[source]#
Bases:
ToolSafetyErrorInput exceeded an allowed size limit (cells, string length, depth).
- exception process_improve.tool_safety.ToolInputInvalidError(message, *, details=None)[source]#
Bases:
ToolSafetyErrorInput failed structural validation (unexpected types, bad shape).
- exception process_improve.tool_safety.ToolTimeoutError(message, *, details=None)[source]#
Bases:
ToolSafetyErrorTool call exceeded the wall-clock timeout.
- exception process_improve.tool_safety.ToolMemoryExceededError(message, *, details=None)[source]#
Bases:
ToolSafetyErrorSubprocess was killed, most likely by the memory limit.
- process_improve.tool_safety.validate_input(tool_input, *, max_cells=None, max_string=None, max_depth=None, scalar_caps=None)[source]#
Raise
ToolInputTooLargeErrorif tool_input breaks any limit.- Parameters:
tool_input (dict[str, Any]) – The
inputdict that would be passed as keyword arguments to the tool function.max_cells (int | None) – Maximum number of numeric leaves anywhere in the payload.
max_string (int | None) – Maximum length of any single string value.
max_depth (int | None) – Maximum nesting depth for dicts/lists.
scalar_caps (dict[str, float] | None) – Override the default per-key numeric caps (see
_SCALAR_CAPS).
- Return type:
None
- process_improve.tool_safety.get_pool(memory_mb=None, max_workers=1)[source]#
Return a lazily-initialised module-level
ProcessPoolExecutor.The pool is recreated if
memory_mbchanges (e.g. tests override it). PassingNoneresolves the cap fromsettings.max_memory_mbat call time (ENG-09 / ENG-27). Thread-safe.
- process_improve.tool_safety.shutdown_pool()[source]#
Shut down the module-level pool, if any. Safe to call repeatedly.
Worker processes are force-terminated first so a runaway task cannot keep holding a CPU after the executor is torn down. Thread-safe.
- Return type:
None
- process_improve.tool_safety.safe_execute_tool_call(tool_name, tool_input, *, timeout=None, max_cells=None, max_string=None, max_depth=None, memory_mb=None, executor=None)[source]#
Execute a tool call with input validation, timeout, and memory cap.
- Parameters:
tool_name (str) – Same meaning as
process_improve.tool_spec.execute_tool_call().tool_input (dict[str, Any]) – Same meaning as
process_improve.tool_spec.execute_tool_call().timeout (float | None) – Wall-clock seconds. On overrun the runaway worker is force-terminated (
terminate()thenkill()) so it cannot keep holding a CPU, andToolTimeoutErroris raised.max_cells (int | None) – Input-size limits. See
validate_input().max_string (int | None) – Input-size limits. See
validate_input().max_depth (int | None) – Input-size limits. See
validate_input().memory_mb (int | None) – RSS cap applied to the worker subprocess via
RLIMIT_AS(POSIX). On overrun the subprocess dies andToolMemoryExceededErroris raised.executor (ProcessPoolExecutor | None) – Optional caller-provided pool. When None (default) a PRIVATE pool is created for this call and torn down afterwards, so each call runs in a fresh worker with isolated process-global state and reclaimed memory, and concurrent calls (e.g. from the threaded MCP server) never share or tear down each other’s workers. A caller-provided executor is never recycled or terminated by this function - the caller owns its lifecycle.
- Raises:
ToolInputInvalidError, ToolInputTooLargeError: – Synchronous rejection before any subprocess work.
ToolInputInvalidErroralso covers JSON-schema violations (wrong type, out-of-bounds value, bad enum, missing required key, or an unknown parameter).ToolTimeoutError: – Wall-clock overrun.
ToolMemoryExceededError: – Worker subprocess died unexpectedly (likely OOM).
ValueError: – Unknown tool name (propagated from
execute_tool_call).
- Return type:
MCP server#
Requires the optional mcp extra; the docs environment installs every extra,
so autodoc imports it directly rather than mocking it.
Kevin Dunn, 2010-2026. MIT License.
MCP (Model Context Protocol) server for process-improve.
Exposes all @tool_spec-decorated functions as MCP tools, making
them instantly available to Claude Desktop, Cursor, VS Code Copilot,
and any other MCP-compatible client.
Usage#
Run directly:
python -m process_improve.mcp_server
Or via the installed entry-point:
process-improve-mcp
Configuration for Claude Desktop (claude_desktop_config.json):
{
"mcpServers": {
"process-improve": {
"command": "process-improve-mcp"
}
}
}
- process_improve.mcp_server.create_server()[source]#
Create the MCP server with every
@tool_spectool registered.- Returns:
A server whose
list_tools()publishes, for each registered tool, the sameinput_schemathatprocess_improve.tool_spec.get_tool_specs()reports.- Return type:
MCPServer