Host Functions
Host functions are one of three ways sandboxed code reaches anything outside the sandbox. The others, host objects and filesystem mounts, are specialised forms of the same idea.
When sandboxed code reads a name it never defined, execution suspends. The host resolves the name — usually by running a real function — and execution resumes with the result. The interpreter never calls out; it stops and waits to be told what happened.
from pydantic_monty import Monty
def get_temperature(city: str) -> float:
return 21.5
with Monty() as pool:
with pool.checkout() as session:
result = session.feed_run(
"f'{get_temperature(\"London\")} degrees'",
external_lookup={'get_temperature': get_temperature},
)
print(result)
#> 21.5 degrees
import { Monty } from '@pydantic/monty'
function getTemperature(city: string): number {
return 21.5
}
await using pool = await Monty.create()
await using session = await pool.checkout()
const result = await session.feedRun("f'{get_temperature(\"London\")} degrees'", {
externalLookup: { get_temperature: getTemperature },
})
console.log(result) // 21.5 degrees
external_lookup maps names to host values.
A callable entry becomes a function the sandbox can call.
Any other value is converted and returned when the name is read:
from pydantic_monty import Monty
with Monty() as pool:
with pool.checkout() as session:
cfg = {'retries': 3}
print(session.feed_run('cfg["retries"]', external_lookup={'cfg': cfg}))
#> 3
import { Monty } from '@pydantic/monty'
await using pool = await Monty.create()
await using session = await pool.checkout()
const cfg = { retries: 3 }
console.log(await session.feedRun('cfg["retries"]', { externalLookup: { cfg } })) // 3
A name that is not in external_lookup raises NameError inside the sandbox:
from pydantic_monty import Monty, MontyRuntimeError
with Monty() as pool:
with pool.checkout() as session:
try:
session.feed_run('missing()')
except MontyRuntimeError as exc:
print(exc.display(format='type-msg'))
#> NameError: name 'missing' is not defined
import { Monty, MontyRuntimeError } from '@pydantic/monty'
await using pool = await Monty.create()
await using session = await pool.checkout()
try {
await session.feedRun('missing()')
} catch (err) {
if (!(err instanceof MontyRuntimeError)) throw err
console.log(err.display('type-msg')) // NameError: name 'missing' is not defined
}
Both put host values in front of the sandbox, but they differ in when:
inputs | external_lookup | |
|---|---|---|
| Bound | eagerly, before the snippet runs | lazily, when the name is read |
| Converted | every entry, used or not | only what the code touches |
| Callables | a reference, not a host function | become host functions |
| Missing name | not applicable | NameError in the sandbox |
A name present in both is served by the eager inputs binding.
Lookup only runs for undefined names; it does not override builtins.
See host-function proxies for caching and rebinding rules.
Pass host functions through external_lookup.
A callable in inputs binds only a reference carrying the callable’s __name__, and calling it resolves that name
through external_lookup — so inputs={'f': double} alone raises NameError: name 'double' is not defined on f(2).
Prefer inputs for the small, always-needed values the code was written around, and external_lookup for the tool
surface — a model that writes code calling ten of your tools only pays for the ones it actually calls.
Positional and keyword arguments pass through as written:
from pydantic_monty import Monty
def report(name, *, level='info', **extra):
return f'{name} {level} {extra}'
with Monty() as pool:
with pool.checkout() as session:
code = "report('x', level='warn', code=7)"
print(session.feed_run(code, external_lookup={'report': report}))
#> x warn {'code': 7}
import { Monty } from '@pydantic/monty'
function report(name: string, { level = 'info', ...extra }: { level?: string; [key: string]: unknown } = {}) {
return `${name} ${level} ${JSON.stringify(extra)}`
}
await using pool = await Monty.create()
await using session = await pool.checkout()
const code = "report('x', level='warn', code=7)"
console.log(await session.feedRun(code, { externalLookup: { report } })) // x warn {"code":7}
Return values must be types Monty can represent — the same set inputs and external_lookup accept, listed under
which values cross the boundary.
Arguments come the other way, out of the sandbox.
A sandbox-defined class instance arrives as a read-only MontyClassProxy; see
host objects.
A builtin function or a non-data type object arrives as a MontyStdTypeProxy
naming it, never the host’s own open or exec; type is the one builtin function that arrives as the host class.
Not every value crosses unchanged: unsupported sandbox values become strings, and cycles and deeply nested values
are truncated.
See host-value limitations for conversion failures, identity rules and size caps.
typing.get_type_hints(f) in the sandbox asks a Python host for the function’s type hints, which it evaluates only then.
Builtin types on the data-type allowlist arrive as type objects and
anything else as text; JavaScript functions have none.
See type hints for the conversion rules.
An exception raised by a host function crosses the boundary and behaves like an ordinary Python exception inside the sandbox, so sandboxed code can catch it:
from pydantic_monty import Monty
def fetch(url: str) -> str:
raise ValueError('bad url')
code = """
try:
fetch('nope')
except ValueError as e:
result = f'caught: {e}'
result
"""
with Monty() as pool:
with pool.checkout() as session:
print(session.feed_run(code, external_lookup={'fetch': fetch}))
#> caught: bad url
import { Monty } from '@pydantic/monty'
function fetch(url: string): string {
const err = new Error('bad url')
err.name = 'ValueError' // the sandbox sees the exception type named here
throw err
}
const code = `
try:
fetch('nope')
except ValueError as e:
result = f'caught: {e}'
result
`
await using pool = await Monty.create()
await using session = await pool.checkout()
console.log(await session.feedRun(code, { externalLookup: { fetch } })) // caught: bad url
If the sandbox does not catch it, feed_run raises MontyRuntimeError with the sandbox traceback.
Only the exception types Monty implements can cross; the type name is what carries over, not your
exception class.
JavaScript uses error.name; an unrecognised name becomes RuntimeError.
Host tracebacks are not preserved.
With AsyncMonty, callables in external_lookup may be coroutine functions.
They are awaited on your event loop, so blocking work inside one blocks it exactly as it would anywhere else in your
async code — what AsyncMonty moves off the loop is worker I/O, not your callbacks.
In JavaScript every host function may be async, and there is no separate pool class.
asyncio.gather inside the sandbox lets several run concurrently:
import asyncio
from pydantic_monty import AsyncMonty
async def fetch(url: str) -> str:
await asyncio.sleep(0.01)
return f'contents of {url}'
code = """
import asyncio
results = await asyncio.gather(fetch('a'), fetch('b'))
len(results)
"""
async def main():
async with AsyncMonty() as pool:
async with pool.checkout() as session:
print(await session.feed_run(code, external_lookup={'fetch': fetch}))
#> 2
asyncio.run(main())
import { Monty } from '@pydantic/monty'
async function fetch(url: string): Promise<string> {
await new Promise((resolve) => setTimeout(resolve, 10))
return `contents of ${url}`
}
const code = `
import asyncio
results = await asyncio.gather(fetch('a'), fetch('b'))
len(results)
`
await using pool = await Monty.create()
await using session = await pool.checkout()
console.log(await session.feedRun(code, { externalLookup: { fetch } })) // 2
The sync Monty cannot drive coroutine host functions — use AsyncMonty, or resolve the pending futures by hand with
feed_start.
A call to an ordinary host function suspends the worker until the host answers it.
Against a remote worker each answer is a network round trip, so a gather over 20 calls would wait for 21 of them.
With stream_async_calls (streamAsyncCalls in JavaScript), calls to async host functions (async def in Python,
async function in JavaScript) skip that wait when the sandbox does not await them at once: feed_run / feedRun
lets the worker send the call to the host and keep running, and the sandbox blocks once, when every task is waiting.
The 20-call gather then costs one round trip plus the slowest call.
The host starts each call as it arrives, so the call’s own I/O overlaps whatever the sandbox does before its await.
Calls and print() output reach the host in the order the sandbox made them, so a call made after a failed
print_callback never starts.
A call awaited at once, as in await fetch(url), still suspends, since nothing else could run in the meantime.
The option is set on the pool: it is on by default for AsyncMontyRemote and
MontyRemote, where each round trip crosses the network, and off by default for subprocess pools.
import asyncio
import pydantic_monty
async def fetch(url: str) -> str:
return f'contents of {url}'
code = """
import asyncio
results = await asyncio.gather(*[fetch(str(i)) for i in range(20)])
len(results)
"""
async def main():
async with pydantic_monty.AsyncMonty(stream_async_calls=True) as pool:
async with pool.checkout() as session:
print(await session.feed_run(code, external_lookup={'fetch': fetch}))
#> 20
asyncio.run(main())
import { Monty } from '@pydantic/monty'
async function fetch(url: string): Promise<string> {
return `contents of ${url}`
}
const code = `
import asyncio
results = await asyncio.gather(*[fetch(str(i)) for i in range(20)])
len(results)
`
await using pool = await Monty.create({ streamAsyncCalls: true })
await using session = await pool.checkout()
console.log(await session.feedRun(code, { externalLookup: { fetch } })) // 20
Python detects these functions with inspect.iscoroutinefunction, which also accepts a functools.partial of an
async def and, on Python 3.12+, a function marked with inspect.markcoroutinefunction.
JavaScript reads the function’s AsyncFunction tag, which bound functions keep; an async function transpiled to an
older target is a plain function returning a promise, so its calls suspend.
The cost is control at the call site.
The sandbox has moved on by the time the host sees the call, so the host cannot stop execution there, and an error the
call raises, including a name the host cannot resolve, surfaces at the await instead
(see asyncio limitations).
Each started call counts against max_suspensions like the suspension it replaces.
This applies to external_lookup and to ExternalModule functions, on local and
remote pools.
feed_start() / feedStart() suspend at every call so that each reaches a snapshot, and the sync
Monty pool suspends because it cannot run coroutines.
The WebAssembly worker cannot start calls either: it suspends at each one, with the same results.
Imports of unknown modules ask the host for them.
Pass external_modules to checkout() (externalModules in JavaScript): an imported module stays bound for the
session, and the mapping is read live, so a module added to it later is importable by later feeds.
It maps import names to ExternalModule values holding the module’s attributes and
its optional .pyi type stub:
from pydantic_monty import ExternalModule, Monty
def add(a: int, b: int) -> int:
return a + b
code = """
import tools
from tools import add
tools.add(1, 2) + add(3, 4)
"""
tools = ExternalModule({'add': add})
with Monty() as pool:
with pool.checkout(external_modules={'tools': tools}) as session:
print(session.feed_run(code))
#> 10
import { ExternalModule, Monty } from '@pydantic/monty'
const code = `
import tools
from tools import add
tools.add(1, 2) + add(3, 4)
`
await using pool = await Monty.create()
const tools = new ExternalModule({ add: (a: number, b: number) => a + b })
await using session = await pool.checkout({ externalModules: { tools } })
console.log(await session.feedRun(code)) // 10
An entry’s attrs dict (a plain object in JavaScript) is copied when the ExternalModule is made, and the module
imports as a module object whose attributes are its public items, those whose keys do not start with _.
Python module objects are rejected because they would also expose their imports; a dict explicitly selects attributes.
In JavaScript a plain object is one whose prototype is Object.prototype or null, so a module namespace
(import * as tools) qualifies while an instance of another class does not.
A callable attribute becomes a host function named <module>.<attr>, dispatched like an external_lookup entry: a
JavaScript promise is awaited, and a Python coroutine needs AsyncMonty (the sync Monty raises RuntimeError).
Any other attribute is converted when the module is imported, so a ClassInstance
among them routes its methods back to the wrapped object.
The constructor checks attrs (a dict with identifier keys, nested modules under valid module names) and raises
TypeError or ValueError there.
A mapping entry is checked when a snippet first imports it: a value that is neither an ExternalModule nor a factory
is refused with TypeError, a bad name with ValueError, and an entry under a sandbox module’s name is never reached.
A zero-argument factory returning an ExternalModule runs when a feed first needs the module: for an import,
a stub request, or a function call through a restored binding.
Its result stands for the module for the rest of the session, so a module no feed imports costs nothing to offer.
Under AsyncMonty it may return an awaitable, and in JavaScript a promise; the sync Monty raises RuntimeError for
an awaitable.
Factory exceptions and invalid return values raise at the requesting import, stub request or call, and the next use
runs the factory again.
from tools import add reads the attribute of that object and raises ImportError for a name it does not have.
An import of a module absent from external_modules reads its .py file from the working directory
(see importing modules) and raises ModuleNotFoundError without one; the sandbox’s
own modules, json or math, are never looked up here.
Every import of a host module asks again: the sandbox caches only modules read from files, and a module bound by an
earlier feed is a plain global.
Each import sends the entry the mapping holds at that moment, so replacing an entry changes what the next import
binds, while a module’s own attrs never change after construction.
The bound value is a module object: type(tools) is module, a missing attribute raises
AttributeError: module 'tools' has no attribute 'x', and a module returned to the host arrives as an
ExternalModule with the id it imported with and the values the sandbox holds, a host function among them as
its dotted name.
See modules for the remaining differences.
Module names are identifiers.
An ExternalModule attribute defines a submodule: ExternalModule({'sub': ExternalModule(...)}) in Python or
new ExternalModule({ sub: new ExternalModule(...) }) in JavaScript.
It supports import pkg.sub, import pkg.sub as s, from pkg import sub and from pkg.sub import add.
Its host functions use dotted names such as pkg.sub.add.
Missing submodules raise ModuleNotFoundError: No module named 'pkg.nope'.
Submodules must be values, not factories, because they cross with their parent.
import pkg.x requires x to be a submodule: any other attribute raises ModuleNotFoundError, as in CPython.
The bindings answer two OS calls from external_modules, before mounts or os:
importrequests a module by its dotted name and returns its value. Its second argument is the module’s search path: the working directory for a top-level name, or a file package’s__path__for one of its submodules. Whenexternal_moduleslacks the module, the bindings search each directory on that path for<name>/__init__.py, then<name>.py(see importing modules), and answermodule_filewith the first found, which the sandbox runs as sandbox code, ornot_found, which raises the statement’s miss error:ModuleNotFoundError, orImportErrorforfrom pkg import sub. A read raisingFileNotFoundError,NotADirectoryError,IsADirectoryErrororPermissionErrormoves on to the next candidate; a factory exception or any other read error is raised at the import, and nothing else is tried. A host module’s submodules are its attributes and are never requested.import_stubsrequests stubs for imported host modules the session has not asked about, once before type checking. Return a dict of dotted paths to.pyisources, including submodules, withNonefor missing stubs.
A host driving suspensions itself answers both like any other OS call.
It also answers typing.get_type_hints, whose arguments are ('function', name) or ('class', id), with a dict of
hints; NOT_HANDLED gives {} (see type hints).
In Rust, ImportArgs::candidates lists the files to read and find_module_file runs the search; reply with
ExtFunctionResult::ModuleFile only to an import with a non-empty search path, which a worker from a release before
file imports never sends.
To type-check code that imports a host module, give the entry its stubs; see
type checking.
feed_run answers every suspension for you and returns only the final value.
feed_start hands each suspension back as a snapshot so you can log it, rate-limit it, require approval for it, or
serialize it and continue tomorrow:
from pydantic_monty import FunctionSnapshot, Monty, MontyComplete
with Monty() as pool:
with pool.checkout() as session:
snapshot = session.feed_start('greet(name) + "!"', inputs={'name': 'Ada'})
assert isinstance(snapshot, FunctionSnapshot)
print(snapshot.function_name, snapshot.args)
#> greet ('Ada',)
result = snapshot.resume({'return_value': 'hello Ada'})
assert isinstance(result, MontyComplete)
print(result.output)
#> hello Ada!
import { FunctionSnapshot, Monty, MontyComplete } from '@pydantic/monty'
await using pool = await Monty.create()
await using session = await pool.checkout()
const snapshot = await session.feedStart('greet(name) + "!"', { inputs: { name: 'Ada' } })
if (!(snapshot instanceof FunctionSnapshot)) throw new Error('expected a function call')
console.log(snapshot.functionName, snapshot.args) // greet [ 'Ada' ]
const result = await snapshot.resume('hello Ada')
if (!(result instanceof MontyComplete)) throw new Error('expected completion')
console.log(result.output) // hello Ada!
resume also takes {'exception': SomeError('...')} to raise into the sandbox, or {'exc_type': 'ValueError', 'message': '...'} when you only have the type by name.
In JavaScript resume(value) takes the return value directly and resumeError(err) raises.
Each snapshot’s position locates the suspending expression in the source; see
where execution stopped.
See snapshots for the full set of snapshot kinds.
Monty guarantees the sandbox reaches nothing you did not hand it. It cannot guarantee that what you handed it is safe, because a host function runs in your process with your process’s authority.
- Validate arguments as untrusted input. A host function taking a path, a URL, a SQL fragment or a shell string is a filesystem, network, database or shell primitive that you built. The model writing the code is not adversarial by assumption, but the code it writes is not reviewed.
- Keep the surface narrow.
read_customer(id)is a tool;read_file(path)is a filesystem. - Prefer mounts for file access. Mounts already enforce canonicalization, boundary checks and
symlink rejection.
A hand-rolled
read_filehost function does not. - Make callbacks idempotent if you plan to restore snapshots across a restart. When a suspended session is dumped while the host is answering a call, restoring it re-announces that call and it runs again.