ohollo commited on
Commit
c5625fd
·
1 Parent(s): 278cf55

Agent evaluation

Browse files
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