Ir para o conteúdo

Testar e validar um agente

O comportamento de um agente depende do que o modelo decide, o que dá a impressão de que não dá para testar: você não consegue afirmar nada sobre o humor de um modelo de 0.5B.

Mas quase todo bug que importa está no seu código, não no do modelo — uma ferramenta que trata mal um argumento, um orçamento que nunca dispara, uma skill cujas ferramentas não destravam, uma delegação que perde os artefatos. Tudo isso é testável: você escreve o que o modelo decidiria e afirma sobre o que o seu agente fez com aquilo.

São os mesmos helpers que a suíte do próprio SDK usa nos 200+ testes de agentes.

from tempest_fastapi_sdk.agents.testing import ScriptedBackend, replies

O mesmo backend serve para estudar o laço

ScriptedBackend guarda o que o agente mandou em cada chamada (system_prompts, prompts, specs_seen), então ele não serve só para afirmar: serve para ver. É com ele que Agentes: como funcionam por dentro mostra a transcrição turno a turno.

Sem modelo, sem rede, sem extra

tempest_fastapi_sdk.agents.testing importa sem nenhuma dependência opcional. Um teste que sobe um modelo de verdade é lento, instável e testa a coisa errada.

O teste mínimo

test_agent.py
import pytest

from tempest_fastapi_sdk.agents import Agent, AgentContext, tool
from tempest_fastapi_sdk.agents.testing import (
    ScriptedBackend,
    assert_completed,
    assert_used_tools,
    replies,
    replies_with_tool,
)
from tempest_fastapi_sdk.schemas import BaseSchema


class WeatherArgs(BaseSchema):
    """Arguments for the weather tool."""

    city: str


@tool("get_weather", "Get the current weather for a city.")
async def get_weather(args: WeatherArgs, context: AgentContext) -> str:
    """Return a canned forecast."""
    return f"{args.city}: 22 graus"


@pytest.mark.asyncio
async def test_the_agent_uses_the_weather_tool() -> None:
    """The agent should call the tool and answer from its result."""
    backend = ScriptedBackend(
        [
            replies_with_tool("get_weather", {"city": "Recife"}),
            replies("Está 22 graus no Recife."),
        ],
    )

    run = await Agent(backend, tools=[get_weather]).run("Qual o tempo no Recife?")

    assert_completed(run)
    assert_used_tools(run, "get_weather")
    assert run.output == "Está 22 graus no Recife."

Você declarou o plano do modelo — "chame get_weather com Recife, depois responda" — e afirmou sobre o que o agente fez. Nenhum modelo carregado, e o teste roda em milissegundos.

O que afirmar

Helper Responde
assert_completed(run) O modelo terminou por conta própria (não foi cortado por orçamento).
assert_used_tools(run, "a", "b") Exatamente essas ferramentas, nessa ordem.
assert_artifact(run, "chart.png", media_type="image/png") O artefato existe e é do tipo certo.
tool_steps(run) Só os passos de ferramenta, para inspecionar argumentos.
failed_steps(run) Os passos que erraram — inclusive numa execução bem-sucedida.

O erro que quase todo teste comete

Afirmar só sobre run.output. Uma execução cortada por orçamento também traz texto — é a última coisa que o modelo disse. Um teste que checa só o texto passa com trabalho pela metade.

Por isso assert_completed existe, e por isso ele nomeia o stop_reason quando falha:

AssertionError: run did not complete: stop_reason=max_steps, output='working on it'

Testar a recuperação de erro

Uma ferramenta que levanta não deve derrubar a execução — o erro vira observação e o modelo tenta outro caminho. Teste isso:

import pytest

from tempest_fastapi_sdk.agents import Agent, AgentContext, AgentToolError, text_tool
from tempest_fastapi_sdk.agents.testing import (
    ScriptedBackend,
    failed_steps,
    replies,
    replies_with_tool,
)


@pytest.mark.asyncio
async def test_the_agent_recovers_from_a_failing_tool() -> None:
    """A raising tool becomes an observation, not a crashed run."""

    async def save(arguments: dict[str, str], context: AgentContext) -> str:
        """Always fail, to exercise the recovery path."""
        raise AgentToolError("disco cheio")

    backend = ScriptedBackend(
        [
            replies_with_tool("save", {"text": "x"}),
            replies("Não consegui salvar, mas segue o conteúdo."),
        ],
    )

    run = await Agent(backend, tools=[text_tool("save", "Save it.", save)]).run("salve")

    assert run.succeeded is True
    assert "disco cheio" in failed_steps(run)[0].error

Testar o orçamento

repeat_last=True faz o modelo scriptado nunca parar de pedir ferramenta — que é exatamente o cenário para o qual o teto existe:

import pytest

from tempest_fastapi_sdk.agents import Agent, AgentBudget, AgentContext, StopReason, text_tool
from tempest_fastapi_sdk.agents.testing import ScriptedBackend, replies_with_tool


@pytest.mark.asyncio
async def test_the_step_budget_stops_a_runaway_agent() -> None:
    """A model that never stops asking is what the ceiling is for."""

    async def noop(arguments: dict[str, str], context: AgentContext) -> str:
        """Do nothing, successfully."""
        return "ok"

    backend = ScriptedBackend(
        [replies_with_tool("t", {"text": "x"})],
        repeat_last=True,
    )

    run = await Agent(
        backend,
        tools=[text_tool("t", "T.", noop)],
        budget=AgentBudget(max_steps=4, max_seconds=None),
    ).run("faça para sempre")

    assert run.stop_reason == StopReason.MAX_STEPS
    assert run.succeeded is False

Testar que a skill escondeu suas ferramentas

backend.specs_seen guarda os nomes oferecidos em cada turno, que é como se prova o carregamento sob demanda:

test_skills.py
import pytest

from tempest_fastapi_sdk.agents import Agent, Skill
from tempest_fastapi_sdk.agents.testing import (
    ScriptedBackend,
    replies,
    replies_with_tool,
)
from test_agent import get_weather

invoicing = Skill(
    name="invoicing",
    description="Ler e validar notas fiscais brasileiras (NF-e).",
    instructions="O guia completo da NF-e vai aqui.",
    tools=[get_weather],
)


@pytest.mark.asyncio
async def test_skill_tools_are_hidden_until_loaded() -> None:
    """The skill's tools must not exist before load_skill runs."""
    backend = ScriptedBackend(
        [
            replies_with_tool("load_skill", {"name": "invoicing"}),
            replies_with_tool("get_weather", {"city": "Recife"}),
            replies("Pronto."),
        ],
    )

    run = await Agent(backend, skills=[invoicing]).run("leia a nota")

    assert run.succeeded
    assert "get_weather" not in backend.specs_seen[0]
    assert "get_weather" in backend.specs_seen[1]

Testar que a memória chegou ao modelo

backend.system_prompts guarda o prompt de sistema de cada turno:

test_memory.py
import pytest

from tempest_fastapi_sdk.agents import Agent, InMemoryFactStore, facts_prompt
from tempest_fastapi_sdk.agents.testing import ScriptedBackend, replies


@pytest.mark.asyncio
async def test_facts_reach_the_model() -> None:
    """Stored facts must be injected, not merely available."""
    store = InMemoryFactStore()
    await store.put("timezone", "America/Recife", subject="u1")

    backend = ScriptedBackend([replies("ok")])
    agent = Agent(
        backend,
        system_prompt="Base." + await facts_prompt(store, subject="u1"),
    )
    await agent.run("que horas são?")

    assert "timezone: America/Recife" in backend.system_prompts[0]

Testar a queda do backend

test_outage.py
import pytest

from tempest_fastapi_sdk.agents import Agent, StopReason
from tempest_fastapi_sdk.agents.testing import FailingBackend


@pytest.mark.asyncio
async def test_a_backend_outage_does_not_escape() -> None:
    """A dead model must become an ERROR stop, not an exception."""
    run = await Agent(FailingBackend("ollama fora do ar")).run("oi")

    assert run.stop_reason == StopReason.ERROR
    assert "ollama fora do ar" in run.output

Isso importa porque um agente costuma estar atrás de um endpoint: uma exceção escapando vira 500, enquanto um StopReason.ERROR vira uma resposta que você controla.

O script sobrou?

test_script_exhausted.py
import pytest

from tempest_fastapi_sdk.agents import Agent
from tempest_fastapi_sdk.agents.testing import ScriptedBackend, replies


@pytest.mark.asyncio
async def test_the_whole_script_was_used() -> None:
    """Every scripted turn must actually run."""
    backend = ScriptedBackend([replies("pronto")])

    await Agent(backend).run("oi")

    assert backend.exhausted is True

Um teste que escreve cinco turnos e usa dois normalmente afirma menos do que o autor pensa — o agente parou antes e o resto do script nunca rodou.

E o modelo de verdade?

Scripting não cobre uma pergunta: o modelo escolhe a ferramenta certa? Isso só um modelo responde. Mantenha esses testes separados e marcados, fora da suíte rápida:

test_model_layer.py
import pytest

from tempest_fastapi_sdk.agents import Agent, AgentBudget
from tempest_fastapi_sdk.genai import TextGenerator, TextModel
from test_agent import get_weather


@pytest.mark.model
@pytest.mark.asyncio
async def test_a_real_model_picks_the_weather_tool() -> None:
    """The model must reach for the tool when the goal calls for it."""
    generator = TextGenerator(
        TextModel.QWEN2_5_0_5B_INSTRUCT,
        device="cpu",
        local_files_only=True,
    )
    agent = Agent(
        generator,
        tools=[get_weather],
        budget=AgentBudget(max_steps=4, max_seconds=300),
    )

    run = await agent.run("Qual o tempo no Recife? Use a ferramenta.")

    assert "get_weather" in run.tool_calls

Registre o marcador no pyproject.toml e exclua por padrão:

[tool.pytest.ini_options]
markers = ["model: needs a real local model (slow)"]
addopts = ["-m", "not model"]

Rode a camada do modelo antes de subir para produção

Não em cada commit, mas antes de cada release. Foi rodando contra o Qwen2.5-0.5B que descobrimos que modelos pequenos resolvem a tarefa e respondem em prosa mesmo assim — o que motivou a passada de extração do run_structured. Nenhum teste com fake acharia isso: um from_pretrained falso aceita qualquer coisa.

Recapitulando

  • Script as decisões do modelo com ScriptedBackend — o resto do agente é código comum e testa-se como código comum.
  • assert_completed antes de run.output: execução truncada também traz texto.
  • specs_seen / system_prompts provam o que chegou ao modelo em cada turno — é assim que se testa skill e memória.
  • FailingBackend garante que a queda do modelo vire resposta, não 500.
  • Uma camada @model separada cobre a única coisa que o script não cobre: se o modelo escolhe certo.

Veja também: Agentes de IA para a trilha básica e Agentes de IA (avançado) para memória, skills, delegação e laços.