Skip to content

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

@Function
def load_data(path: str) -> list[dict]: ...

Registered as load-data.

With a name

@Function(name="loader")
def load_data(path: str) -> list[dict]: ...

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:

@Storage
@Function
def load_data(path: str, db: Postgres) -> int: ...

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 Context manages 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:

@Function
def score(model: SklearnModel, threshold: float, db: Postgres) -> float: ...

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

load_data("data.csv", db=fake_db)

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

project.context.call("function.load-data", path="data.csv")  # db injected

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.

inspect.signature(load)  # (path: str, limit: int = 100) -> int

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_db to call() or to the bound function overrides the injected value.
  • A missing argument fails clearly. If a required parameter is neither managed nor supplied, a TypeError names 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 a Task.

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 it
  • low_stock is which registered function to inject — the part the Context reads

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:

def test_reorder():
    assert Reorder(lambda threshold: ["bolts"]).execute() == ["bolts"]

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]
project.start()
project.context.call("function.restock", item="apple", quantity=10)

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. The Context cannot 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 Context calls and never awaits, so a registered async def function makes project.start() raise TypeError. Keep an async function out of @Function and 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