🤖lauren-ai
← Home
Export this page

Agents

lauren-ai agents are classes decorated with @agent(). The agentic loop — call the LLM, execute tools, call the LLM again — is managed automatically by AgentRunner.


@agent() — defining an agent

python
from lauren_ai import agent, AgentContext, AgentResponse

@agent(
    model="claude-opus-4-6",
    system="You are a helpful research assistant.",
    max_turns=10,
    temperature=0.7,
)
class ResearchAgent:
    """Fallback system prompt if system= is omitted."""
ParameterTypeDefaultPurpose
modelstr \| Noneinherited from LLMConfigLLM model identifier
systemstr \| Noneclass docstring or AgentConfig.system_promptSystem prompt
max_turnsint \| None10Maximum agentic loop iterations
temperaturefloat \| None1.0Sampling temperature
memoryShortTermMemory \| NoneNonePer-agent memory instance; reused across every run() call when set. Fresh memory is built per turn when None.
conversation_storeConversationStore \| NoneNonePer-agent conversation store. AgentModule.for_root() auto-creates InMemoryConversationStore for agents that omit it.
thinkingboolFalseEnable Anthropic extended thinking
thinking_budget_tokensint8_000Token ceiling for the thinking phase
reasoning_effortstr \| NoneNoneOpenAI o-series effort: "low" / "medium" / "high"
include_reasoning_in_responseboolFalseExpose OpenAI reasoning text in AgentResponse

@agent() must be called with parentheses. Bare @agent raises DecoratorUsageError.

For a full treatment of extended thinking — including Completion.thinking_blocks, streaming thinking deltas, lifecycle hook access, and testing — see the extended thinking guide.

@agent() automatically applies @injectable(scope=Scope.SINGLETON) unless the class is already @injectable.


@use_tools() — attaching tools

@use_tools() is the innermost decorator (closest to the class). @agent() must be the outermost (farthest from the class):

python
from lauren_ai import agent, use_tools
from lauren_ai.skills import WebSearchTool, HttpFetchTool

@agent(model="claude-opus-4-6", system="You are a research assistant.")
@use_tools(WebSearchTool, HttpFetchTool)
class ResearchAgent: ...

None entries are silently dropped, which is useful for conditional tools:

python
@agent(model="claude-opus-4-6")
@use_tools(
    WebSearchTool,
    CodeExecutionTool if settings.code_tools_enabled else None,
)
class FlexibleAgent: ...

Decorator ordering — mandatory

Decorators are applied bottom-up in Python. The required order is:

python
@agent(...)            ← outermost: reads USE_TOOLS_META, sets AGENT_META
@remember()            ← optional
@use_guardrails(...)   ← optional
@use_tools(...)        ← innermost: sets USE_TOOLS_META on class
class MyAgent: ...

Swapping the order causes metadata written by inner decorators to be read before it exists, resulting in silently missing tools or guardrails.


Lifecycle hooks

Define any of these methods on the agent class. They are called by AgentRunner at the matching point in the loop. All hooks may be sync or async and are optional.

on_start

Called once before the first LLM turn.

python
from lauren_ai import AgentContext

async def on_start(self, ctx: AgentContext) -> None:
    print(f"Run {ctx.agent_run_id} started")

on_turn_complete

Called after each LLM response, before tool execution.

python
from lauren_ai._transport import Completion

async def on_turn_complete(self, completion: Completion, ctx: AgentContext) -> None:
    print(f"Turn {ctx.turn}: {completion.stop_reason}, tokens={completion.usage.total_tokens}")

on_tool_result

Called after each tool executes. Return a modified ToolResult to override what the model sees, or return None to use the original.

python
from lauren_ai._tools import ToolResult

async def on_tool_result(self, result: ToolResult, ctx: AgentContext) -> ToolResult | None:
    if result.is_error:
        print(f"Tool error: {result.content}")
    return result

on_finish

Called once after the agentic loop terminates.

python
from lauren_ai import AgentResponse

async def on_finish(self, response: AgentResponse, ctx: AgentContext) -> None:
    print(f"Done in {response.turns} turns, stop_reason={response.stop_reason}")

AgentContext

AgentContext is passed to every lifecycle hook:

FieldTypeDescription
agent_idstrAgent instance identifier (random hex)
agent_run_idstrUnique identifier for this specific run
agent_classtypeThe @agent()-decorated class
configAgentConfigEffective configuration for this run
memoryShortTermMemorySliding-window conversation buffer
turnintCurrent loop iteration (0-based)
metadatadict[str, Any]Caller-supplied or decorator-set metadata
requestAny \| NoneOriginating HTTP request, if any
python
# Read metadata set by the caller
user_id = ctx.get_metadata("user_id", default="anonymous")

AgentResponse

AgentRunner.run() returns an AgentResponse:

FieldTypeDescription
contentstrFinal text output
turnsintNumber of loop iterations executed
total_usageTokenUsageCumulative token usage across all turns
tool_calls_madelist[ToolCall]All tool calls executed during the run
stop_reasonstr"end_turn", "max_turns", "budget_exceeded", or "error"
reasoning_traceslist[str]Extended-thinking blocks (Anthropic only)

Running an agent — AgentRunner.run()

AgentRunner is a @runtime_checkable Protocol; AgentRunnerBase is the concrete implementation. AgentModule.for_root() generates a unique AgentRunnerBase subclass and registers it as the module's runner. Inject it via runner: AgentRunner when only one AgentModule is in scope, or runner: AgentRunner[MyAgent] when you need a specific module's runner across module boundaries:

python
from lauren_ai import AgentRunner  # @runtime_checkable Protocol

class ChatController:
    # runner: AgentRunner works when exactly one AgentModule is in scope.
    def __init__(self, runner: AgentRunner, agent: ResearchAgent) -> None:
        self._runner = runner
        self._agent = agent

    @post("/chat")
    async def chat(self, body: ChatBody) -> dict:
        response = await self._runner.run(
            self._agent,
            body.message,
            conversation_id=body.session_id,
            metadata={"user_id": body.user_id},
        )
        return {"content": response.content, "turns": response.turns}

run() keyword arguments:

ArgumentTypeDescription
conversation_idstr \| NoneLinks to a ConversationStore for history persistence
metadatadict[str, Any] \| NoneInjected into AgentContext.metadata
run_idstr \| NoneOptional explicit run identifier

Streaming — AgentRunner.run_stream()

run_stream() returns an async iterator of CompletionChunk objects. Tool calls execute silently between turns; only text deltas are yielded.

python
async for chunk in await runner.run_stream(agent, "Tell me a story"):
    print(chunk.delta, end="", flush=True)

See the streaming guide for SSE controller examples.


Delegation

Multi-agent handoff is always tool-based: give the coordinator a @tool() that calls another agent's runner inside its body. Every handoff appears in the tool-call log, is visible to the model, and composes cleanly with run_stream(). See multi-agent for full examples.

For deterministic routing where your code decides which agent runs (rather than the LLM), do the dispatch in your controller before calling runner.run() / runner.run_stream().


Guardrails on agents

Use @use_guardrails() to attach input and output safety checks. It sits between @agent() and @use_tools() in the stack:

python
from lauren_ai import agent, use_guardrails, use_tools, TopicFilter, PIIRedactor

@agent(model="claude-opus-4-6")
@use_guardrails(
    input=[TopicFilter(allowed_topics=["cooking"])],
    output=[PIIRedactor(entities=["EMAIL"])],
)
@use_tools(WebSearchTool)
class CookingAgent: ...

See the guardrails guide for details.


Module wiring

Register agents with AgentModule.for_root():

python
import os
from lauren_ai import LLMConfig
from lauren_ai._module import LLMModule, AgentModule

LLMProvider = LLMModule.for_root(
    LLMConfig.for_anthropic(
        model="claude-opus-4-6",
        api_key=os.environ["ANTHROPIC_API_KEY"],
    )
)

AIModule = AgentModule.for_root(
    agents=[ResearchAgent],
    tools=[WebSearchTool],
    imports=LLMProvider,   # required — makes Transport and LLMConfig visible
)

@module(
    controllers=[ChatController],
    imports=[LLMProvider, AIModule],
)
class AppModule: ...

The imports=LLMProvider argument is required so the generated agent module can see the Transport and LLMConfig tokens exported by LLMModule. Without it, AgentRunner cannot be wired and startup raises MissingProviderError.