Skip to content

Agent

An Agent is a finite executable whose steps are decided at run time rather than written down in advance — typically by a language model working towards a goal it is handed when called. It is wired through dependency injection the same way as a Component, with a single invoke() contract that, unlike a Task's execute(), takes arguments.

The framework ships no model client and no agent loop. @Agent marks the place where one — an agentic library, a provider SDK, or hand-written logic — meets the rest of the project: its components, its configuration, its logging.

Agents answer one question:

What should work towards this goal, given what the project has?


When to use Agent

Use @Agent when a class needs to:

  • be handed a goal at call time — a question, a ticket, a document
  • decide its own steps to reach it, usually by driving a model
  • act on the project's components, repositories, or services along the way
  • run to completion and return a result

Typical examples:

  • answering questions over a repository
  • triaging or classifying incoming records
  • drafting a report from several data sources
  • reviewing the output of a pipeline

Decorator

The @Agent decorator registers the class into the Agent namespace. The Context reads from this namespace at startup. The class is returned unchanged.

Without arguments

@Agent
class Librarian:
    def invoke(self, question: str) -> str: ...

With a name

@Agent(name="librarian")
class CatalogueAssistant:
    def invoke(self, question: str) -> str: ...

Combined with a Layer

@Analytics
@Agent
class Librarian:
    def invoke(self, question: str) -> str: ...

invoke() is required

Applying @Agent to a class that does not define invoke() raises TypeError at decoration time, as does applying it to anything that is not a class.


Interface

An Agent implements a single method — invoke(). Its signature is the author's to choose: any number of parameters, of any type, with or without defaults. It may return a value or None.

@Agent
class Librarian:
    def invoke(self, question: str) -> str: ...


@Agent
class Triage:
    def invoke(self, ticket: Ticket, *, urgent: bool = False) -> Verdict: ...


@Agent
class NightlyReview:
    def invoke(self) -> Report: ...

No parameter name is reserved, and nothing in invoke() is injected — every parameter is supplied by the caller.


Dependency Injection

Like all components, an Agent declares its dependencies through the constructor. For an agent this doubles as a statement of reach: what it can act on is exactly what its constructor asks for, and it shows up in the dependency graph like any other edge.

@Agent
class Librarian:
    def __init__(
        self,
        books: Books,
        model: Model,
        logger: Logger,
    ):
        self.books = books
        self.model = model
        self.logger = logger

    def invoke(self, question: str) -> str: ...

The model is usually one of those dependencies — a Component or Service of your own that holds the client and its configuration — so it can be shared between agents and replaced in tests.


Using an Agent

An Agent is invoked through the Context by its key, agent.<name>, with keyword arguments passed on to invoke():

answer = project.context.call("agent.librarian", question="Anything by Herbert?")

Or by class, typed:

answer = project.context.get(Librarian).invoke("Anything by Herbert?")

project.context.parameters(Librarian) returns the parameters of invoke() — what a caller building a form or a schema needs to ask for.

Every invocation is logged to the agent's own log file — start, duration, and any exception — whichever way it was made. See Logger.

As a step in a Pipeline

An agent is injected like any other dependency, so a Pipeline can make it one of its steps:

@Pipeline
class WeeklyDigest:
    def __init__(self, fetcher: NewBooksFetcher, librarian: Librarian):
        self.fetcher = fetcher
        self.librarian = librarian

    def execute(self):
        self.fetcher.execute()
        return self.librarian.invoke("Summarise this week's additions.")

Functions as dependencies

What an agent may act on needn't be a class. A registered Function is declared in the constructor too, and arrives with its own dependencies filled in:

@Agent
class Librarian:
    def __init__(self, find: Annotated[Callable[..., list[str]], find_titles], model: Model):
        self.find = find
        self.model = model

See Injecting a Function.

Long-running agents

An agent is finite: one goal in, one result out. For an agent that works continuously — draining a queue, watching a stream — keep the loop in a Service and inject the agent into it:

@Service
class TicketWorker:
    def __init__(self, tickets: Tickets, triage: Triage):
        self.tickets = tickets
        self.triage = triage
        self._stopped = threading.Event()

    def setup(self): ...

    def run(self):
        while not self._stopped.is_set():
            for ticket in self.tickets.unprocessed():
                self.triage.invoke(ticket)
            self._stopped.wait(5)

    def stop(self):
        self._stopped.set()

One agent using another

Agents can depend on each other through their constructors, with the same rule as everywhere else: no circular dependencies.

@Agent
class Editor:
    def __init__(self, librarian: Librarian): ...

Tools

An agent usually works by asking a language model, inside invoke(), what to do next. That makes "tool" mean two different things around an agent, declared in different places: the tools the agent uses while it works, and the tools it offers to a model somewhere else that wants to call it.

Tools the agent uses

These are the agent's dependencies. Whatever it takes through its constructor — a component's methods, a registered function — is what it can hand to the model it runs inside invoke(). Nothing else is in reach, and the dependency graph shows it.

@Agent
class Librarian:
    def __init__(self, find: Annotated[Callable[..., list[str]], find_titles], model: Model):
        self.model = model
        self.tools = {"find-titles": find}

    def invoke(self, question: str) -> str: ...

How those callables are described to the model is up to whatever runs the loop; an injected function keeps its name and docstring, and its signature lists only the arguments a caller supplies.

This half needs nothing beyond this package.

Tools the agent offers

A model outside the agent — a chat assistant, an MCP client — can call it only if something serves the project's tools to that model. This package doesn't: it has no MCP server, and here an agent is invoked by project code alone, through a class that injects it or through project.context.call().

The agent itself needs no declaration to be offered. A server built on the framework can find every resolved agent through the Context and offer its invoke(), described by the class docstring, with project.context.parameters(Librarian) as the arguments to ask for. So the docstring and the parameters of invoke() are what the calling model sees. The parameters should be types a schema can express, like a str, a number or a dataclass, not an arbitrary object.

@Agent
class Librarian:
    """Answers questions about the book catalogue."""

    def invoke(self, question: str) -> str: ...

To offer more than invoke(), for example a narrower, cheaper method beside it, the agent implements mcp_tools() like any other class (see Component → MCP Tools). Don't name one of those tools invoke, because a server that offers the agent's invoke() already uses that name:

@Agent
class Librarian:
    """Answers questions about the book catalogue."""

    def invoke(self, question: str) -> str: ...

    def titles(self, author: str) -> list[str]: ...

    def mcp_tools(self) -> list[McpTool]:
        return [
            McpTool(
                name="titles",
                description="List the catalogue's titles by an author.",
                handler=self.titles,
            )
        ]

Seeing it in practice

The sibling odf package's McpServer offers every resolved agent as <key>.invoke and every mcp_tools() entry as <key>.<name>, so the model on the other side sees agent.librarian.invoke and agent.librarian.titles. When it calls invoke, the librarian uses the tools from its constructor to work out the answer.

The two are independent. An agent that uses tools needn't offer any beyond invoke(), and one that offers several needn't use any.


Wrapping an agentic library

invoke() is where a library's own agent runs. The class holds it, hands it the project's components as tools, and exposes one method to the framework. The example below uses Pydantic AI, which is not a dependency of this package — any library, or none, fits the same shape.

from pydantic_ai import Agent as ModelAgent


@Agent
class Librarian:
    """Answers questions about the book catalogue."""

    def __init__(self, books: Books, config: Config):
        self.books = books
        self.agent = ModelAgent(
            config.agent.librarian.model, instructions="Answer from the catalogue."
        )
        self.agent.tool_plain(self.find)

    def find(self, title: str) -> list[str]:
        """Find books whose title contains the given text."""
        return [book.title for book in self.books.search(title)]

    def invoke(self, question: str) -> str:
        return self.agent.run_sync(question).output

For a version with no third-party dependency at all — a rule-based stand-in for the model, so the loop itself is visible — see examples/09-agent/.


Protocol

AgentProtocol documents the expected interface and is available for type checking and introspection:

from opendataframework import AgentProtocol

isinstance(instance, AgentProtocol)  # True if invoke() is present

AgentProtocol is @runtime_checkable, so the check confirms only that invoke is present, not its signature.

class AgentProtocol(Protocol):
    def invoke(self, *args, **kwargs): ...

What Agent is Not

  • Not a model client. The framework has no third-party dependencies and talks to no provider. The model is a dependency you supply.

  • Not an agent loop. Planning, tool calling, retries, and memory belong to the library or code inside invoke().

  • Not a long-running process. invoke() runs to completion. For continuous work, inject the agent into a Service.

  • Not async, and not streamed for you. The Context calls and never awaits, so an async def invoke() makes project.start() raise TypeError. An invoke() that returns an iterator hands it back as is; the log then records when the iterator was created, not how long it took to finish.

  • Not a base class. Librarian does not inherit from Agent. The decorator registers it; inheritance is not involved.


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
Coordinate multiple tasks into a workflow Pipeline
Work towards a goal handed over at call time Agent
Manage persistent data Repository