Function¶
A Function is a plain Python function made known to the framework. It is to a
function what a Component is to a class: registered and wired through
dependency injection, with no execution contract of its own.
It exists for adoption. An existing codebase written as functions can become framework-aware one decorator at a time, without a class refactor first — the function itself, its callers, and its tests stay exactly as they were.
Functions answer one question:
How do I bring the code I already have into the framework?
When to use Function¶
Use @Function when:
- you are adopting the framework in an existing, function-based project
- a piece of logic is stateless and a class would add nothing but ceremony
- you want a function to be callable by name with arguments — by a UI, a CLI, or an MCP server built on top of the framework
Prefer a class — Component, Task, Service —
as soon as the logic needs state or startup and shutdown hooks. @Function is the
on-ramp; classes remain the recommended shape for
code written for the framework from the start.
Decorator¶
The @Function decorator registers the function into the Function namespace under
the kebab-case form of its name. The function is returned unchanged.
Without arguments¶
Registered as load-data.
With a name¶
Only functions can be decorated
Applying @Function to a class raises TypeError at decoration time, as does
a callable with no name of its own (a functools.partial, an instance with
__call__). A function already wrapped by another decorator, such as
functools.cache, is fine. The reverse holds too: @Component, @Task and the
other execution types raise on a function and point here.
Combined with a Layer¶
A function is tagged with a Layer the way a class is — layer outermost:
Dependency Injection¶
A class separates its dependencies (__init__) from its arguments (the method). A
function has one signature for both, so the Context splits it by type — the same
rule it applies to constructors:
- a parameter annotated with a type the
Contextmanages is injected - every other parameter is a call argument, supplied by the caller
@Component
class Postgres: ...
@Function
def load_data(path: str, db: Postgres, limit: int = 100) -> int: ...
Here db is injected and becomes an edge in the dependency graph; path and limit
are what a caller passes.
A parameter whose type the framework doesn't manage is simply an argument, whatever it is:
model and threshold are call arguments; only db is injected.
A parameter annotated Logger receives a Logger bound to the function's
own name, as it would for a class.
String annotations are not injected
As with constructors, only an annotation that is an actual type counts. Under
from __future__ import annotations every annotation is a string, so nothing is
injected and every parameter stays a call argument. The same holds for a class
that asks for a function (see Injecting a Function).
Calling such a function through the Context without the argument raises a
TypeError that names the string annotation as the cause; remove the import (or
the quotes) to have it injected.
Calling a Function¶
There are three ways to call a registered function. Only the first existed before you added the decorator, and it is unaffected by it.
Directly¶
Nothing is injected, wrapped, or logged — every argument is passed by the caller. This is why existing callers and tests keep working unchanged.
By key, through the Context¶
call() takes the function's key, function.<name> (in any casing —
"function.load-data" or "function.load_data"), and its remaining arguments as
keywords. It is the counterpart of
execute() for a Task, with the one difference that a function takes
arguments. This is what a caller holding only a key and a payload uses.
By function, through the Context¶
load = project.context.get(load_data) # db already filled in
load("data.csv")
load("data.csv", limit=10)
get() returns the function with its managed dependencies bound. Positional arguments
fill the remaining parameters in order, so an injected parameter that happens to come
first doesn't shift them.
The bound function describes itself the same way: its signature leaves the injected parameters out, while its name and docstring are the original's.
So it can be handed as is to anything that builds a schema from a callable — an
McpTool handler, or a tool for an agentic library — and
only the parameters a caller can supply are asked for.
Both forms follow the same rules:
- An explicit argument wins. Passing
db=fake_dbtocall()or to the bound function overrides the injected value. - A missing argument fails clearly. If a required parameter is neither managed nor
supplied, a
TypeErrornames it and its type:score() is missing 'model' (SklearnModel) — not managed by the Context and not supplied by the caller. - Calls are logged. Start, completion with duration, and any exception go to the
function's own log, like
execute()on aTask.
Both become usable once project.start() returns, and raise KeyError for a function
the Context hasn't resolved.
Injecting a Function¶
Project code rarely calls a function by name. A class that needs one declares it in
its constructor, like any other dependency, and calls it in its own logic. Since a
function is not a type, the annotation names it through Annotated:
from collections.abc import Callable
from typing import Annotated
@Function
def low_stock(inventory: Inventory, threshold: int = 5) -> list[str]: ...
@Task
class Reorder:
def __init__(self, find_shortages: Annotated[Callable[..., list[str]], low_stock]):
self.find_shortages = find_shortages
def execute(self) -> list[str]:
return self.find_shortages(threshold=3) # inventory is already filled in
The annotation has two halves:
Callable[..., list[str]]is what the class receives, as a type checker sees itlow_stockis which registered function to inject — the part theContextreads
What arrives is the same callable project.context.get(low_stock) returns: the
function with its own managed dependencies bound, every call logged. Reorder never
mentions Inventory. The parameter name is free, as it is for a class dependency, and
any class the Context creates can ask this way — a Component, a
Task or Pipeline, a Service, an
Agent — as can another function.
The function joins the dependency graph: it is bound after the types it takes and before the classes that ask for it, and a cycle through it is reported like any other (see Context).
Typing the callable¶
The first half describes the function minus the parameters the Context injects,
and is written by hand — Python has no way to derive it. Two spellings are useful:
Annotated[Callable[..., list[str]], low_stock] # any arguments, checked return type
Annotated[Callable[[int], list[str]], low_stock] # checked arguments too
Callable[[int], ...] cannot express a default or a keyword, so a type checker would
reject find_shortages() and find_shortages(threshold=3) even though both work.
Use ... when callers rely on either.
To keep constructors short, define the annotation once next to the function:
LowStock = Annotated[Callable[..., list[str]], low_stock]
@Task
class Reorder:
def __init__(self, find_shortages: LowStock): ...
A type LowStock = ... alias works the same way.
Testing¶
The constructor takes any callable, so a test needs no framework:
Full Example¶
A script-style module, before:
def restock(inventory, item, quantity):
inventory.stock[item] = inventory.stock.get(item, 0) + quantity
return inventory.stock[item]
After — one decorator, and a type on the parameter the framework should supply:
@Component
class Inventory:
def __init__(self) -> None:
self.stock: dict[str, int] = {}
@Function
def restock(inventory: Inventory, item: str, quantity: int) -> int:
inventory.stock[item] = inventory.stock.get(item, 0) + quantity
return inventory.stock[item]
Existing calls such as restock(inventory, "apple", 10) still work as written.
Inspecting Functions¶
project.context.functions maps every resolved function to its bound callable. It is
separate from project.context.instances, which holds classes only.
project.context.parameters(func) returns the parameters a caller must supply — the
signature minus whatever the Context injects — as inspect.Parameter objects:
project.context.parameters(load_data)
# → {"path": <Parameter "path: str">, "limit": <Parameter "limit: int = 100">}
This is what tooling uses to build a form or a tool schema for a function without re-implementing the injection rule.
What Function is Not¶
-
Not stateful, and without a lifecycle. There is no instance, and no
on_start()/on_stop(). State belongs in a component the function takes as a parameter. -
Not a Task. A Task has an
execute()contract and takes no arguments. A function has no contract and does take arguments. TheContextcannot run one on its own — it doesn't know the arguments — so a function is not itself a step of a Pipeline; a task that injects it is. -
Not async. The
Contextcalls and never awaits, so a registeredasync deffunction makesproject.start()raiseTypeError. Keep an async function out of@Functionand call it yourself, or make it synchronous.
Decision Guide¶
| I need to... | Use |
|---|---|
| Provide a reusable capability | Component |
| Run a process indefinitely | Service |
| Execute a bounded unit of work once, no arguments | Task |
| Call a unit of work with arguments | Function |
| Make an existing function known to the framework | Function |
| Call a registered function from a class | Inject it with Annotated |
| Coordinate multiple tasks into a workflow | Pipeline |
| Work towards a goal handed over at call time | Agent |
| Manage persistent data | Repository |