soev for developers
soev-agentsTutorials

Tutorial 1: your first agent

Build a town assistant that answers questions from the town documents, with a search tool and a real model.

In this tutorial, you build an assistant for the town of Springfield. It answers questions from the documents of the town. The assistant is a ReAct agent: the model can call a tool, read the result, and then answer. You connect a real model, give the agent a search tool, and ask two questions. At the end, you look at the trace: the record of everything that happened in the conversation.

The next two tutorials continue with the same documents. Tutorial 2 adds citations, and tutorial 3 gives each user a different set of documents.

Before you start, make sure that you have:

  • Python 3.12 or later and uv.
  • Access to a chat model through an OpenAI-compatible API. The model must support tool calls.

Set up

  1. Get the repository and install the packages:

    git clone https://github.com/Gradient-DS/soev.git
    cd soev
    uv sync --no-dev --group tutorials  # installs soev-agents with its OpenAI adapter, and pytest, into .venv
  2. Make a folder for this tutorial:

    mkdir -p my_tutorials/01-first-agent

    Each tutorial gets its own folder in my_tutorials. Git ignores my_tutorials, so Git never adds your files to a commit.

  3. Tell the agent which model to use. Any server with an OpenAI-compatible API works: give its URL in OPENAI_BASE_URL. For example, for OpenAI:

    export OPENAI_BASE_URL=https://api.openai.com/v1
    export OPENAI_API_KEY=your-key
    export OPENAI_MODEL=gpt-4.1-mini
    export UV_NO_SYNC=1

    The OpenAI client reads OPENAI_BASE_URL and OPENAI_API_KEY itself. The agent reads OPENAI_MODEL. With UV_NO_SYNC=1, uv run uses the environment of step 1 and installs nothing more. Without it, uv run also installs the development tools of the repository.

Step 1: write the document store

The agent answers from the documents of the town. In a real agent, the documents are in a search index or a vector database. Here, they are in a small class in memory. Make my_tutorials/01-first-agent/library.py:

library.py
import re
from collections.abc import Mapping, Sequence
from dataclasses import dataclass


@dataclass(frozen=True)
class Section:
    """One section of a town document."""

    id: str
    document: str
    title: str
    text: str


@dataclass(frozen=True)
class Library:
    """The documents of the town, in sections. In a real agent, this is a search index."""

    sections: tuple[Section, ...]

    @classmethod
    def of(cls, documents: Mapping[str, tuple[str, Sequence[str]]]) -> "Library":
        """A library of `documents`: for each document id, its title and the text of each section."""
        return cls(
            tuple(
                Section(id=f"{document}#{n}", document=document, title=title, text=text)
                for document, (title, texts) in documents.items()
                for n, text in enumerate(texts)
            )
        )

    def search(self, query: str, limit: int = 3) -> tuple[Section, ...]:
        """The sections that share the most words with `query`."""
        words = _words(query)
        scored = [(len(words & _words(section.text)), section) for section in self.sections]
        found = sorted((each for each in scored if each[0]), key=lambda each: -each[0])
        return tuple(section for _, section in found[:limit])

    def document(self, id: str) -> tuple[Section, ...]:
        """All sections of one document."""
        return tuple(section for section in self.sections if section.document == id)

    def section(self, id: str) -> Section:
        return next(section for section in self.sections if section.id == id)


def _words(text: str) -> set[str]:
    return set(re.findall(r"\w+", text.lower()))

The library keeps each document as a list of sections. Each section has an id, such as waste-collection#2, and knows the id and the title of its document. search counts the words that a section shares with the query, and returns the best sections. This tutorial uses only search. Tutorial 2 uses document and section.

Add the documents at the end of the file:

library.py
LIBRARY = Library.of(
    {
        "waste-collection": (
            "Waste collection",
            (
                "Residual waste is collected every Tuesday morning. Put the container on the street before 7:30.",
                "Paper and cardboard are collected on the first Friday of each month.",
                "Bulky waste, such as furniture, is picked up after an appointment. Each household can have it "
                "picked up for free four times a year. Each extra pickup costs 25 euros.",
            ),
        ),
        "parking-permits": (
            "Parking permits",
            (
                "A resident parking permit costs 120 euros a year.",
                "Each household can have at most two resident parking permits.",
                "Apply for a permit on the town website. The permit starts on the day after the payment.",
            ),
        ),
        "dog-tax": (
            "Dog tax",
            (
                "The dog tax is 98 euros a year for the first dog and 130 euros for each other dog.",
                "Report a new dog to the town within 14 days.",
            ),
        ),
    }
)

Step 2: write the prompt and the tool

Make the file my_tutorials/01-first-agent/agent.py. Start with the imports. These are all the imports of this tutorial:

agent.py
import asyncio
import os

from library import LIBRARY
from openai import AsyncOpenAI

from soev_agents.adapters import InMemoryTraceStore, OpenAIChatModel
from soev_agents.domain import Event, Input, ModelOutput, ToolOutput
from soev_agents.runtime import AgentCatalog, tool
from soev_agents.toolkit import ReActAgent, ReActConfig

soev-agents has four parts that you use here:

  • adapters: the connections to the outside world, such as the model and the store that keeps the conversations.
  • domain: the types that a thread stores. These are the events (such as Input, ModelOutput and ToolOutput), the elements that tools show to the model, the citations of an answer, and the Trace that holds the events. The domain has no code that runs a thread.
  • runtime: what runs the agents and their tools. AgentCatalog runs the agents, and tool makes a tool.
  • toolkit: ready-made parts, such as ReActAgent.

Next, write the system prompt. It tells the model what its task is:

agent.py
PROMPT = """You are the assistant of the town of Springfield.
Answer questions from the town documents, in one or two short sentences.
If the documents do not answer the question, say so."""

The prompt does not mention the tool. What a tool does, and when to use it, is in the description of the tool. So the prompt stays correct when you rename a tool or add one.

Now add the tool. A tool is a plain async function, with @tool above it:

agent.py
@tool(
    name="search",
    description="Search the town documents. Returns the sections that share the most words with the query. "
    "Use it for every question about the town, and never answer from memory.",
)
async def search(query: str) -> ToolOutput:
    found = LIBRARY.search(query)
    if not found:
        return ToolOutput(error="Nothing found for these words. Try other words.")
    return ToolOutput(text="\n\n".join(f"{section.title}: {section.text}" for section in found))

@tool turns the function into a tool that the model can call:

  • The model reads the name and the description. Write the description for the model: what the tool does, and when to use it.
  • The model writes the argument query. The framework makes the schema of the argument from the type annotation, and sends it to the model.
  • The tool returns a ToolOutput: the event that the trace records for the result. The model reads its text: here, the title and the text of each section that the search found. If the search finds nothing, the tool returns an error instead. An error is not a crash: the model reads it and can try again with other words.

search is still a function that you can call, for example in a test: await search("dog tax").

Step 3: make the agent

Add a main function that connects the model and makes the agent:

agent.py
async def main() -> None:
    model = OpenAIChatModel(AsyncOpenAI(), model=os.environ["OPENAI_MODEL"])
    assistant = ReActAgent(ReActConfig(model=model, prompt=PROMPT, tools=[search]))
    agents = AgentCatalog(InMemoryTraceStore(), {"assistant": assistant}, version="tutorial-1")

What each line does:

  • OpenAIChatModel connects the agent to the model. AsyncOpenAI() is the client of the openai package. It reads the URL and the key from the environment.
  • ReActConfig holds the settings of the agent: the model, the system prompt and the tools. model and prompt are required. Each other setting has a default, so you give only what you need.
  • ReActAgent is the agent. It runs the ReAct loop with the settings of its ReActConfig.
  • AgentCatalog holds your agents by name. It runs them, and it keeps their conversations in a store. InMemoryTraceStore keeps everything in memory, so it is gone when the script ends. version names your code. Each event records it, so you can always see which code wrote an event.

ReActAgent runs the ReAct loop:

  1. It calls the model with the system prompt, the conversation and the tools.
  2. If the model asks for a tool, it runs the tool and calls the model again with the result.
  3. If the model answers without a tool call, the turn ends.

A turn is one question and everything that the agent does to answer it, up to its answer.

One default to know now: the agent assumes a context window of 32,000 tokens. It refuses a request that does not fit before it sends it. If your model has a larger window, give it in ReActConfig, for example limits=ModelLimits(window=Tokens(128_000)). ModelLimits and Tokens are in soev_agents.toolkit.

Step 4: ask a question

Add these lines at the end of main:

agent.py
    question = Input(payload="When is residual waste collected?", model=model.model)
    thread, _ = await agents.open("assistant", question)
    trace = await thread.resume()
    print("Answer:", trace.by_type(ModelOutput)[-1].content)

Add these lines at the end of the file, so that the script runs main:

agent.py
if __name__ == "__main__":
    asyncio.run(main())

Run the agent:

uv run python my_tutorials/01-first-agent/agent.py

The output is similar to this. Your model uses its own words:

Answer: Residual waste is collected every Tuesday morning.

What happened:

  1. Input is the question. payload is the text. model names the model that answers, and a ReActAgent requires it. The conversation records it, so you can always see which model answered.
  2. agents.open started a thread, a Thread: one conversation with the agent "assistant". It recorded the question.
  3. thread.resume() ran the turn. The model asked for search with a query such as "residual waste collected". The tool returned the sections that it found, and the model answered.
  4. resume returns the trace of the thread: the list of everything that the thread recorded. The answer is the last ModelOutput.

Step 5: ask a follow-up question

A thread remembers the conversation. Add these lines at the end of main:

agent.py
    follow_up = Input(payload="And paper?", model=model.model)
    thread = await agents.thread(thread.id, input=follow_up)
    trace = await thread.send(follow_up)
    print("Answer:", trace.by_type(ModelOutput)[-1].content)

agents.thread gets the thread by its id, and builds the agent for this question. thread.send records the question and runs the turn. Give send the same input: a thread refuses an input that it was not built for. Run the agent again:

Answer: Residual waste is collected every Tuesday morning.
Answer: Paper is collected on the first Friday of each month.

"And paper?" is not a complete question. The model understands it because it gets the whole conversation, the first question included.

Step 6: look at the trace

Add these lines at the end of main:

agent.py
    for event in trace.by_type(Event):
        print(event.seq, type(event).__name__, event.origin or "")

Run the agent again. After the two answers, it prints the trace:

0 Opened
1 Input
2 ModelOutput llm
3 ToolOutput tools
4 ModelOutput llm
5 Finished
6 Input
7 ModelOutput llm
8 ToolOutput tools
9 ModelOutput llm
10 Finished

Each line is one event: its position, its type, and the step of the agent that wrote it.

  • Opened starts the thread.
  • Input is a question.
  • ModelOutput is an answer of the model, written by the step llm. The first one in each turn holds the tool call.
  • ToolOutput is the result of the tool, written by the step tools.
  • Finished ends a turn.

If your model calls the tool more than once, your list has more events.

The trace is the state of the thread. The agent keeps nothing else. For the second question, the agent built the request to the model from the events of the trace. This is why the model saw the first question. It is also why a thread can continue after a crash, or in another process: everything that the agent needs is in the store.

What you built

  • A document store with a simple search.
  • A tool: a plain async function with @tool, which returns a ToolOutput.
  • A ReAct agent with a model, a system prompt and the tool, in one ReActConfig.
  • A thread with two turns, and its trace.

The complete code is in examples/tutorials/01-first-agent.

The answers of this agent name no source, so a reader cannot check them. In tutorial 2, the agent cites the passage behind each fact.

On this page