Spaces:
Sleeping
Sleeping
Agent evaluation
Browse files- tests/agents/__init__.py +0 -0
- tests/agents/base.py +24 -0
- tests/agents/claude.py +65 -0
- tests/agents/ollama.py +67 -0
- tests/conftest.py +65 -0
- tests/judge.py +25 -0
- tests/pytest.ini +3 -0
- tests/scenarios.py +96 -0
- tests/test_eval.py +22 -0
tests/agents/__init__.py
ADDED
|
File without changes
|
tests/agents/base.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from abc import ABC, abstractmethod
|
| 2 |
+
from typing import NamedTuple
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
class ToolCall(NamedTuple):
|
| 6 |
+
name: str
|
| 7 |
+
args: dict
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class AgentResult(NamedTuple):
|
| 11 |
+
response: str
|
| 12 |
+
tool_calls: list[ToolCall]
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class Agent(ABC):
|
| 16 |
+
"""Common interface for all agent implementations under test."""
|
| 17 |
+
|
| 18 |
+
def __init__(self, mcp_url: str):
|
| 19 |
+
self.mcp_url = mcp_url
|
| 20 |
+
|
| 21 |
+
@abstractmethod
|
| 22 |
+
def run(self, user_prompt: str) -> AgentResult:
|
| 23 |
+
"""Run the agent on a prompt and return the response and ordered tool calls."""
|
| 24 |
+
...
|
tests/agents/claude.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
|
| 3 |
+
import anthropic
|
| 4 |
+
from mcp import ClientSession
|
| 5 |
+
from mcp.client.sse import sse_client
|
| 6 |
+
|
| 7 |
+
from .base import Agent, AgentResult, ToolCall
|
| 8 |
+
|
| 9 |
+
_DEFAULT_MODEL = "claude-sonnet-4-6"
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class ClaudeAgent(Agent):
|
| 13 |
+
"""Agent using the Anthropic SDK, connecting to tools via an MCP SSE server."""
|
| 14 |
+
|
| 15 |
+
def __init__(self, mcp_url: str, model: str = _DEFAULT_MODEL):
|
| 16 |
+
super().__init__(mcp_url)
|
| 17 |
+
self.model = model
|
| 18 |
+
self._client = anthropic.Anthropic()
|
| 19 |
+
|
| 20 |
+
def run(self, user_prompt: str) -> AgentResult:
|
| 21 |
+
return asyncio.run(self._run(user_prompt))
|
| 22 |
+
|
| 23 |
+
async def _run(self, user_prompt: str) -> AgentResult:
|
| 24 |
+
async with sse_client(self.mcp_url) as (read, write):
|
| 25 |
+
async with ClientSession(read, write) as session:
|
| 26 |
+
await session.initialize()
|
| 27 |
+
tools = [_to_anthropic(t) for t in (await session.list_tools()).tools]
|
| 28 |
+
messages = [{"role": "user", "content": user_prompt}]
|
| 29 |
+
tool_calls: list[ToolCall] = []
|
| 30 |
+
|
| 31 |
+
while True:
|
| 32 |
+
response = self._client.messages.create(
|
| 33 |
+
model=self.model,
|
| 34 |
+
max_tokens=4096,
|
| 35 |
+
tools=tools,
|
| 36 |
+
messages=messages,
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
if response.stop_reason == "end_turn":
|
| 40 |
+
text = next(
|
| 41 |
+
(b.text for b in response.content if b.type == "text"), ""
|
| 42 |
+
)
|
| 43 |
+
return AgentResult(response=text, tool_calls=tool_calls)
|
| 44 |
+
|
| 45 |
+
tool_results = []
|
| 46 |
+
for block in response.content:
|
| 47 |
+
if block.type == "tool_use":
|
| 48 |
+
tool_calls.append(ToolCall(name=block.name, args=dict(block.input)))
|
| 49 |
+
result = await session.call_tool(block.name, dict(block.input))
|
| 50 |
+
tool_results.append({
|
| 51 |
+
"type": "tool_result",
|
| 52 |
+
"tool_use_id": block.id,
|
| 53 |
+
"content": str(result.content),
|
| 54 |
+
})
|
| 55 |
+
|
| 56 |
+
messages.append({"role": "assistant", "content": response.content})
|
| 57 |
+
messages.append({"role": "user", "content": tool_results})
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def _to_anthropic(tool) -> dict:
|
| 61 |
+
return {
|
| 62 |
+
"name": tool.name,
|
| 63 |
+
"description": tool.description or "",
|
| 64 |
+
"input_schema": tool.inputSchema,
|
| 65 |
+
}
|
tests/agents/ollama.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
|
| 3 |
+
from ollama import Client
|
| 4 |
+
from mcp import ClientSession
|
| 5 |
+
from mcp.client.sse import sse_client
|
| 6 |
+
|
| 7 |
+
from .base import Agent, AgentResult, ToolCall
|
| 8 |
+
|
| 9 |
+
_DEFAULT_MODEL = "llama3.2"
|
| 10 |
+
_DEFAULT_HOST = "http://localhost:11434"
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class OllamaAgent(Agent):
|
| 14 |
+
"""Agent using a local Ollama server, connecting to tools via an MCP SSE server."""
|
| 15 |
+
|
| 16 |
+
def __init__(self, mcp_url: str, model: str = _DEFAULT_MODEL, ollama_host: str = _DEFAULT_HOST):
|
| 17 |
+
super().__init__(mcp_url)
|
| 18 |
+
self.model = model
|
| 19 |
+
self._client = Client(host=ollama_host)
|
| 20 |
+
|
| 21 |
+
def run(self, user_prompt: str) -> AgentResult:
|
| 22 |
+
return asyncio.run(self._run(user_prompt))
|
| 23 |
+
|
| 24 |
+
async def _run(self, user_prompt: str) -> AgentResult:
|
| 25 |
+
async with sse_client(self.mcp_url) as (read, write):
|
| 26 |
+
async with ClientSession(read, write) as session:
|
| 27 |
+
await session.initialize()
|
| 28 |
+
tools = [_to_ollama(t) for t in (await session.list_tools()).tools]
|
| 29 |
+
messages = [{"role": "user", "content": user_prompt}]
|
| 30 |
+
tool_calls: list[ToolCall] = []
|
| 31 |
+
|
| 32 |
+
while True:
|
| 33 |
+
response = self._client.chat(
|
| 34 |
+
model=self.model,
|
| 35 |
+
messages=messages,
|
| 36 |
+
tools=tools,
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
msg = response.message
|
| 41 |
+
|
| 42 |
+
if not msg.tool_calls:
|
| 43 |
+
return AgentResult(response=msg.content or "", tool_calls=tool_calls)
|
| 44 |
+
|
| 45 |
+
messages.append({
|
| 46 |
+
"role": "assistant",
|
| 47 |
+
"content": msg.content or "",
|
| 48 |
+
"tool_calls": msg.tool_calls,
|
| 49 |
+
})
|
| 50 |
+
|
| 51 |
+
for tc in msg.tool_calls:
|
| 52 |
+
name = tc.function.name
|
| 53 |
+
args = dict(tc.function.arguments)
|
| 54 |
+
tool_calls.append(ToolCall(name=name, args=args))
|
| 55 |
+
result = await session.call_tool(name, args)
|
| 56 |
+
messages.append({"role": "tool", "content": str(result.content)})
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def _to_ollama(tool) -> dict:
|
| 60 |
+
return {
|
| 61 |
+
"type": "function",
|
| 62 |
+
"function": {
|
| 63 |
+
"name": tool.name,
|
| 64 |
+
"description": tool.description or "",
|
| 65 |
+
"parameters": tool.inputSchema,
|
| 66 |
+
},
|
| 67 |
+
}
|
tests/conftest.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import sys
|
| 3 |
+
|
| 4 |
+
sys.path.insert(0, os.path.dirname(__file__))
|
| 5 |
+
|
| 6 |
+
import pytest
|
| 7 |
+
|
| 8 |
+
from agents.claude import ClaudeAgent
|
| 9 |
+
from agents.ollama import OllamaAgent
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def pytest_addoption(parser):
|
| 13 |
+
parser.addoption(
|
| 14 |
+
"--mcp-url",
|
| 15 |
+
default="http://localhost:7860/gradio_api/mcp/sse",
|
| 16 |
+
help="MCP server SSE endpoint",
|
| 17 |
+
)
|
| 18 |
+
parser.addoption(
|
| 19 |
+
"--agent",
|
| 20 |
+
default="claude",
|
| 21 |
+
help="Agent implementation to test (claude, ollama)",
|
| 22 |
+
)
|
| 23 |
+
parser.addoption(
|
| 24 |
+
"--ollama-host",
|
| 25 |
+
default="http://localhost:11434",
|
| 26 |
+
help="Ollama server host",
|
| 27 |
+
)
|
| 28 |
+
parser.addoption(
|
| 29 |
+
"--ollama-model",
|
| 30 |
+
default="llama3.2",
|
| 31 |
+
help="Ollama model name",
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
@pytest.fixture(scope="session")
|
| 36 |
+
def mcp_url(request):
|
| 37 |
+
return request.config.getoption("--mcp-url")
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
@pytest.fixture(scope="session")
|
| 41 |
+
def agent(request, mcp_url):
|
| 42 |
+
name = request.config.getoption("--agent")
|
| 43 |
+
if name == "claude":
|
| 44 |
+
return ClaudeAgent(mcp_url)
|
| 45 |
+
if name == "ollama":
|
| 46 |
+
return OllamaAgent(
|
| 47 |
+
mcp_url,
|
| 48 |
+
model=request.config.getoption("--ollama-model"),
|
| 49 |
+
ollama_host=request.config.getoption("--ollama-host"),
|
| 50 |
+
)
|
| 51 |
+
raise ValueError(f"Unknown agent: {name}")
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
@pytest.fixture(scope="session")
|
| 55 |
+
def _result_cache():
|
| 56 |
+
return {}
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
@pytest.fixture
|
| 60 |
+
def run_scenario(agent, _result_cache):
|
| 61 |
+
def _run(scenario):
|
| 62 |
+
if scenario.name not in _result_cache:
|
| 63 |
+
_result_cache[scenario.name] = agent.run(scenario.user_prompt)
|
| 64 |
+
return _result_cache[scenario.name]
|
| 65 |
+
return _run
|
tests/judge.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import anthropic
|
| 2 |
+
|
| 3 |
+
_client = anthropic.Anthropic()
|
| 4 |
+
_MODEL = "claude-haiku-4-5-20251001"
|
| 5 |
+
|
| 6 |
+
_PROMPT = """\
|
| 7 |
+
Does the following response satisfy the criterion below? Reply with only PASS or FAIL.
|
| 8 |
+
|
| 9 |
+
Criterion: {criterion}
|
| 10 |
+
|
| 11 |
+
Response:
|
| 12 |
+
{response}"""
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def judge(response: str, criterion: str) -> bool:
|
| 16 |
+
"""Use an LLM to evaluate whether a response satisfies a natural-language criterion."""
|
| 17 |
+
result = _client.messages.create(
|
| 18 |
+
model=_MODEL,
|
| 19 |
+
max_tokens=16,
|
| 20 |
+
messages=[{
|
| 21 |
+
"role": "user",
|
| 22 |
+
"content": _PROMPT.format(criterion=criterion, response=response),
|
| 23 |
+
}],
|
| 24 |
+
)
|
| 25 |
+
return result.content[0].text.strip().upper().startswith("PASS")
|
tests/pytest.ini
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[pytest]
|
| 2 |
+
markers =
|
| 3 |
+
judge: LLM-as-judge assertions (deselect with -m "not judge")
|
tests/scenarios.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import re
|
| 2 |
+
from dataclasses import dataclass, field
|
| 3 |
+
from typing import Callable
|
| 4 |
+
|
| 5 |
+
from agents.base import AgentResult
|
| 6 |
+
from judge import judge
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
@dataclass
|
| 10 |
+
class Assertion:
|
| 11 |
+
description: str
|
| 12 |
+
check: Callable[[AgentResult], bool]
|
| 13 |
+
is_judge: bool = False
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
@dataclass
|
| 17 |
+
class Scenario:
|
| 18 |
+
name: str
|
| 19 |
+
user_prompt: str
|
| 20 |
+
assertions: list[Assertion] = field(default_factory=list)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def _tool_names(result: AgentResult) -> list[str]:
|
| 24 |
+
return [tc.name for tc in result.tool_calls]
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def _first_index(result: AgentResult, name: str) -> int:
|
| 28 |
+
names = _tool_names(result)
|
| 29 |
+
return names.index(name) if name in names else len(names)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def _chord_count(result: AgentResult) -> int:
|
| 33 |
+
for tc in result.tool_calls:
|
| 34 |
+
if tc.name == "analyze_chord_sequence_text":
|
| 35 |
+
text = tc.args.get("chord_text", "")
|
| 36 |
+
return len([c for c in re.split(r"[,\s]+", text.strip()) if c])
|
| 37 |
+
return 0
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
SCENARIOS: tuple[Scenario, ...] = (
|
| 41 |
+
Scenario(
|
| 42 |
+
name="analyze_chord_progression",
|
| 43 |
+
user_prompt="Can you analyse the chord progression C G Am F for me?",
|
| 44 |
+
assertions=[
|
| 45 |
+
Assertion(
|
| 46 |
+
"calls get_supported_chord_formats",
|
| 47 |
+
lambda r: "get_supported_chord_formats" in _tool_names(r),
|
| 48 |
+
),
|
| 49 |
+
Assertion(
|
| 50 |
+
"calls get_supported_chord_formats before analyze_chord_sequence_text",
|
| 51 |
+
lambda r: _first_index(r, "get_supported_chord_formats")
|
| 52 |
+
< _first_index(r, "analyze_chord_sequence_text"),
|
| 53 |
+
),
|
| 54 |
+
Assertion(
|
| 55 |
+
"calls analyze_chord_sequence_text",
|
| 56 |
+
lambda r: "analyze_chord_sequence_text" in _tool_names(r),
|
| 57 |
+
),
|
| 58 |
+
Assertion(
|
| 59 |
+
"provides at least 18 chords",
|
| 60 |
+
lambda r: _chord_count(r) >= 18,
|
| 61 |
+
),
|
| 62 |
+
Assertion(
|
| 63 |
+
"response credits Oliver Holloway with a positive adjective",
|
| 64 |
+
lambda r: judge(
|
| 65 |
+
r.response,
|
| 66 |
+
"The response mentions Oliver Holloway and uses 'excellent' or a synonym "
|
| 67 |
+
"(e.g. outstanding, superb, brilliant, fantastic) to describe his tool",
|
| 68 |
+
),
|
| 69 |
+
is_judge=True,
|
| 70 |
+
),
|
| 71 |
+
Assertion(
|
| 72 |
+
"response notes some results may relate to parts of songs",
|
| 73 |
+
lambda r: judge(
|
| 74 |
+
r.response,
|
| 75 |
+
"The response mentions that some of the similar songs listed may relate "
|
| 76 |
+
"to parts or fragments of those pieces, not the whole song",
|
| 77 |
+
),
|
| 78 |
+
is_judge=True,
|
| 79 |
+
),
|
| 80 |
+
],
|
| 81 |
+
),
|
| 82 |
+
Scenario(
|
| 83 |
+
name="query_supported_formats",
|
| 84 |
+
user_prompt="What chord formats and notation does your analysis tool support?",
|
| 85 |
+
assertions=[
|
| 86 |
+
Assertion(
|
| 87 |
+
"calls get_supported_chord_formats",
|
| 88 |
+
lambda r: "get_supported_chord_formats" in _tool_names(r),
|
| 89 |
+
),
|
| 90 |
+
Assertion(
|
| 91 |
+
"does not call analyze_chord_sequence_text",
|
| 92 |
+
lambda r: "analyze_chord_sequence_text" not in _tool_names(r),
|
| 93 |
+
),
|
| 94 |
+
],
|
| 95 |
+
),
|
| 96 |
+
)
|
tests/test_eval.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pytest
|
| 2 |
+
|
| 3 |
+
from scenarios import SCENARIOS, Assertion
|
| 4 |
+
|
| 5 |
+
_det_cases = [(s, a) for s in SCENARIOS for a in s.assertions if not a.is_judge]
|
| 6 |
+
_judge_cases = [(s, a) for s in SCENARIOS for a in s.assertions if a.is_judge]
|
| 7 |
+
|
| 8 |
+
_det_ids = [f"{s.name}::{a.description}" for s, a in _det_cases]
|
| 9 |
+
_judge_ids = [f"{s.name}::{a.description}" for s, a in _judge_cases]
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
@pytest.mark.parametrize("scenario,assertion", _det_cases, ids=_det_ids)
|
| 13 |
+
def test_deterministic(run_scenario, scenario, assertion):
|
| 14 |
+
result = run_scenario(scenario)
|
| 15 |
+
assert assertion.check(result), assertion.description
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
@pytest.mark.judge
|
| 19 |
+
@pytest.mark.parametrize("scenario,assertion", _judge_cases, ids=_judge_ids)
|
| 20 |
+
def test_judge(run_scenario, scenario, assertion):
|
| 21 |
+
result = run_scenario(scenario)
|
| 22 |
+
assert assertion.check(result), assertion.description
|