agentfault 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. agentfault/__init__.py +54 -0
  2. agentfault/agent/__init__.py +5 -0
  3. agentfault/agent/base.py +11 -0
  4. agentfault/agent/deterministic.py +80 -0
  5. agentfault/agent/naive.py +55 -0
  6. agentfault/benchmarks/__init__.py +17 -0
  7. agentfault/benchmarks/models.py +141 -0
  8. agentfault/benchmarks/packs/__init__.py +3 -0
  9. agentfault/benchmarks/packs/buying.py +237 -0
  10. agentfault/benchmarks/runner.py +83 -0
  11. agentfault/cli.py +112 -0
  12. agentfault/commerce/__init__.py +23 -0
  13. agentfault/commerce/environment.py +119 -0
  14. agentfault/commerce/models.py +45 -0
  15. agentfault/commerce/tools.py +44 -0
  16. agentfault/integrations/langgraph.py +227 -0
  17. agentfault/integrations/openai/__init__.py +9 -0
  18. agentfault/integrations/openai/agent.py +156 -0
  19. agentfault/integrations/openai/tools.py +17 -0
  20. agentfault/pytest_plugin.py +68 -0
  21. agentfault/testing/__init__.py +19 -0
  22. agentfault/testing/assertion.py +27 -0
  23. agentfault/testing/assertions/__init__.py +7 -0
  24. agentfault/testing/assertions/budget.py +39 -0
  25. agentfault/testing/assertions/prohibited_action.py +21 -0
  26. agentfault/testing/perturbations/__init__.py +12 -0
  27. agentfault/testing/perturbations/base.py +8 -0
  28. agentfault/testing/perturbations/change_inventory.py +14 -0
  29. agentfault/testing/perturbations/change_price.py +14 -0
  30. agentfault/testing/perturbations/fail_tool.py +22 -0
  31. agentfault/testing/runner.py +107 -0
  32. agentfault/testing/scenario.py +19 -0
  33. agentfault/testing/scenario_loader.py +42 -0
  34. agentfault/testing/scenario_result.py +14 -0
  35. agentfault/testing/scenario_runner.py +99 -0
  36. agentfault/testing/triggers/__init__.py +8 -0
  37. agentfault/testing/triggers/base.py +9 -0
  38. agentfault/testing/triggers/tool.py +20 -0
  39. agentfault/tools/__init__.py +13 -0
  40. agentfault/tools/errors.py +9 -0
  41. agentfault/tools/executor.py +19 -0
  42. agentfault/tools/registry.py +24 -0
  43. agentfault/tools/tool.py +69 -0
  44. agentfault/trajectory/__init__.py +5 -0
  45. agentfault/trajectory/models.py +16 -0
  46. agentfault-0.1.0.dist-info/METADATA +146 -0
  47. agentfault-0.1.0.dist-info/RECORD +50 -0
  48. agentfault-0.1.0.dist-info/WHEEL +4 -0
  49. agentfault-0.1.0.dist-info/entry_points.txt +5 -0
  50. agentfault-0.1.0.dist-info/licenses/LICENSE +21 -0
agentfault/__init__.py ADDED
@@ -0,0 +1,54 @@
1
+ from agentfault.agent import Agent, DeterministicCommerceAgent
2
+ from agentfault.commerce import (
3
+ Cart,
4
+ CartItem,
5
+ CommerceEnvironment,
6
+ Inventory,
7
+ Merchant,
8
+ Offer,
9
+ Order,
10
+ Product,
11
+ create_commerce_tools,
12
+ )
13
+ from agentfault.testing import (
14
+ AgentRunner,
15
+ Assertion,
16
+ AssertionResult,
17
+ EvaluationContext,
18
+ Perturbation,
19
+ Scenario,
20
+ ScenarioRunner,
21
+ ScenarioResult,
22
+ Trigger,
23
+ )
24
+ from agentfault.tools import RegistryToolExecutor, Tool, ToolCall, ToolExecutor, ToolRegistry
25
+ from agentfault.trajectory import TrajectoryStep
26
+
27
+ __all__ = [
28
+ "Agent",
29
+ "AgentRunner",
30
+ "Assertion",
31
+ "AssertionResult",
32
+ "EvaluationContext",
33
+ "Cart",
34
+ "CartItem",
35
+ "CommerceEnvironment",
36
+ "DeterministicCommerceAgent",
37
+ "Inventory",
38
+ "Merchant",
39
+ "Offer",
40
+ "Order",
41
+ "Product",
42
+ "Perturbation",
43
+ "RegistryToolExecutor",
44
+ "Scenario",
45
+ "ScenarioRunner",
46
+ "ScenarioResult",
47
+ "Tool",
48
+ "ToolCall",
49
+ "ToolExecutor",
50
+ "ToolRegistry",
51
+ "TrajectoryStep",
52
+ "Trigger",
53
+ "create_commerce_tools",
54
+ ]
@@ -0,0 +1,5 @@
1
+ from agentfault.agent.base import Agent
2
+ from agentfault.agent.deterministic import DeterministicCommerceAgent
3
+ from agentfault.agent.naive import NaiveCommerceAgent
4
+
5
+ __all__ = ["Agent", "DeterministicCommerceAgent", "NaiveCommerceAgent"]
@@ -0,0 +1,11 @@
1
+ from abc import ABC, abstractmethod
2
+ from typing import Any
3
+
4
+ from agentfault.tools.tool import ToolCall
5
+
6
+
7
+ class Agent(ABC):
8
+ @abstractmethod
9
+ def next_action(self, observation: Any) -> ToolCall | None:
10
+ """Return the next tool call based on the current observation."""
11
+ raise NotImplementedError
@@ -0,0 +1,80 @@
1
+ from typing import Any
2
+
3
+ from agentfault.agent import Agent
4
+ from agentfault.tools.tool import ToolCall
5
+
6
+
7
+ class DeterministicCommerceAgent(Agent):
8
+ """Reference buyer that rechecks offer price and stock before purchase."""
9
+
10
+ def __init__(self, query: str, max_price: float) -> None:
11
+ self.query = query
12
+ self.max_price = max_price
13
+ self.step = 0
14
+ self.offer_id: str | None = None
15
+ self.cart_id: str | None = None
16
+
17
+ def next_action(self, observation: Any) -> ToolCall | None:
18
+ if self.step == 0:
19
+ self.step = 1
20
+ return ToolCall("search_products", {"query": self.query})
21
+
22
+ if self.step == 1:
23
+ self.step = 2
24
+ if not observation:
25
+ return None
26
+ return ToolCall(
27
+ "get_offers_for_product",
28
+ {"product_id": observation[0].id},
29
+ )
30
+
31
+ if self.step == 2:
32
+ self.step = 3
33
+ if not observation:
34
+ return None
35
+ self.offer_id = observation[0].id
36
+ return ToolCall("get_offer", {"offer_id": self.offer_id})
37
+
38
+ if self.step == 3:
39
+ self.step = 4
40
+ if observation.price > self.max_price:
41
+ return None
42
+ return ToolCall("check_inventory", {"offer_id": self.offer_id})
43
+
44
+ if self.step == 4:
45
+ self.step = 5
46
+ if observation.quantity < 1:
47
+ return None
48
+ return ToolCall("create_cart", {})
49
+
50
+ if self.step == 5:
51
+ self.step = 6
52
+ self.cart_id = observation.id
53
+ return ToolCall(
54
+ "add_to_cart",
55
+ {
56
+ "cart_id": self.cart_id,
57
+ "offer_id": self.offer_id,
58
+ "quantity": 1,
59
+ },
60
+ )
61
+
62
+ # Cart creation is not the last safety boundary: offer and stock may
63
+ # have changed after the item was added.
64
+ if self.step == 6:
65
+ self.step = 7
66
+ return ToolCall("get_offer", {"offer_id": self.offer_id})
67
+
68
+ if self.step == 7:
69
+ self.step = 8
70
+ if observation.price > self.max_price:
71
+ return None
72
+ return ToolCall("check_inventory", {"offer_id": self.offer_id})
73
+
74
+ if self.step == 8:
75
+ self.step = 9
76
+ if observation.quantity < 1:
77
+ return None
78
+ return ToolCall("purchase", {"cart_id": self.cart_id})
79
+
80
+ return None
@@ -0,0 +1,55 @@
1
+ from typing import Any
2
+
3
+ from agentfault.agent.base import Agent
4
+ from agentfault.tools.tool import ToolCall
5
+
6
+
7
+ class NaiveCommerceAgent(Agent):
8
+ def __init__(self, query: str) -> None:
9
+ self.query = query
10
+ self.step = 0
11
+ self.offer_id: str | None = None
12
+ self.cart_id: str | None = None
13
+
14
+ def next_action(self, observation: Any) -> ToolCall | None:
15
+ if self.step == 0:
16
+ self.step += 1
17
+ return ToolCall(
18
+ name="search_products",
19
+ arguments={"query": self.query},
20
+ )
21
+
22
+ if self.step == 1:
23
+ product = observation[0]
24
+ self.step += 1
25
+ return ToolCall(
26
+ name="get_offers_for_product",
27
+ arguments={"product_id": product.id},
28
+ )
29
+
30
+ if self.step == 2:
31
+ offer = observation[0]
32
+ self.offer_id = offer.id
33
+ self.step += 1
34
+ return ToolCall(name="create_cart", arguments={})
35
+
36
+ if self.step == 3:
37
+ self.cart_id = observation.id
38
+ self.step += 1
39
+ return ToolCall(
40
+ name="add_to_cart",
41
+ arguments={
42
+ "cart_id": self.cart_id,
43
+ "offer_id": self.offer_id,
44
+ "quantity": 1,
45
+ },
46
+ )
47
+
48
+ if self.step == 4:
49
+ self.step += 1
50
+ return ToolCall(
51
+ name="purchase",
52
+ arguments={"cart_id": self.cart_id},
53
+ )
54
+
55
+ return None
@@ -0,0 +1,17 @@
1
+ from agentfault.benchmarks.models import (
2
+ AgentFactory,
3
+ BenchmarkCase,
4
+ BenchmarkCaseResult,
5
+ BenchmarkResult,
6
+ BenchmarkSuite,
7
+ )
8
+ from agentfault.benchmarks.runner import BenchmarkRunner
9
+
10
+ __all__ = [
11
+ "AgentFactory",
12
+ "BenchmarkCase",
13
+ "BenchmarkCaseResult",
14
+ "BenchmarkResult",
15
+ "BenchmarkRunner",
16
+ "BenchmarkSuite",
17
+ ]
@@ -0,0 +1,141 @@
1
+ from dataclasses import dataclass
2
+ from typing import Callable, Literal, TypeAlias
3
+
4
+ from agentfault.agent import Agent
5
+ from agentfault.testing import Scenario, ScenarioResult
6
+ from agentfault.tools import Tool, ToolRegistry
7
+
8
+ AgentFactory: TypeAlias = Callable[[str, list[Tool]], Agent]
9
+ CaseFactory: TypeAlias = Callable[[AgentFactory], tuple[Scenario, ToolRegistry]]
10
+ CaseStatus: TypeAlias = Literal["passed", "failed", "error"]
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class BenchmarkCase:
15
+ """One reproducible behavioral test in a benchmark suite."""
16
+
17
+ id: str
18
+ title: str
19
+ build: CaseFactory
20
+
21
+ def __post_init__(self) -> None:
22
+ if not self.id.strip():
23
+ raise ValueError("Benchmark case id must not be empty.")
24
+ if not self.title.strip():
25
+ raise ValueError("Benchmark case title must not be empty.")
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class BenchmarkSuite:
30
+ """An immutable, versioned collection of benchmark cases."""
31
+
32
+ id: str
33
+ version: str
34
+ category: str
35
+ description: str
36
+ cases: tuple[BenchmarkCase, ...]
37
+
38
+ def __post_init__(self) -> None:
39
+ for name in ("id", "version", "category", "description"):
40
+ if not getattr(self, name).strip():
41
+ raise ValueError(f"Benchmark suite {name} must not be empty.")
42
+ if not self.cases:
43
+ raise ValueError("Benchmark suite must contain at least one case.")
44
+ case_ids = [case.id for case in self.cases]
45
+ if len(case_ids) != len(set(case_ids)):
46
+ raise ValueError("Benchmark case ids must be unique within a suite.")
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class BenchmarkCaseResult:
51
+ """Outcome and evidence for one benchmark case."""
52
+
53
+ case_id: str
54
+ title: str
55
+ status: CaseStatus
56
+ scenario_result: ScenarioResult | None = None
57
+ error: str | None = None
58
+
59
+ @property
60
+ def passed(self) -> bool:
61
+ return self.status == "passed"
62
+
63
+
64
+ @dataclass(frozen=True)
65
+ class BenchmarkResult:
66
+ """Aggregate results for one suite run."""
67
+
68
+ suite_id: str
69
+ suite_version: str
70
+ category: str
71
+ agent_name: str
72
+ cases: tuple[BenchmarkCaseResult, ...]
73
+
74
+ @property
75
+ def complete(self) -> bool:
76
+ return all(case.status in {"passed", "failed"} for case in self.cases)
77
+
78
+ @property
79
+ def score(self) -> float | None:
80
+ """Return percentage passed, or None when any case was invalid."""
81
+ if not self.complete or not self.cases:
82
+ return None
83
+ return round(100.0 * self.passed_count / len(self.cases), 2)
84
+
85
+ @property
86
+ def passed_count(self) -> int:
87
+ return sum(case.status == "passed" for case in self.cases)
88
+
89
+ @property
90
+ def failed_count(self) -> int:
91
+ return sum(case.status == "failed" for case in self.cases)
92
+
93
+ @property
94
+ def error_count(self) -> int:
95
+ return sum(case.status == "error" for case in self.cases)
96
+
97
+ def to_dict(self) -> dict[str, object]:
98
+ """Return a JSON-friendly summary without provider-specific object data."""
99
+ return {
100
+ "suite_id": self.suite_id,
101
+ "suite_version": self.suite_version,
102
+ "category": self.category,
103
+ "agent_name": self.agent_name,
104
+ "complete": self.complete,
105
+ "score": self.score,
106
+ "passed": self.passed_count,
107
+ "failed": self.failed_count,
108
+ "errors": self.error_count,
109
+ "cases": [
110
+ {
111
+ "id": case.case_id,
112
+ "title": case.title,
113
+ "status": case.status,
114
+ "error": case.error,
115
+ "assertions": (
116
+ [
117
+ {
118
+ "passed": assertion.passed,
119
+ "message": assertion.message,
120
+ }
121
+ for assertion in case.scenario_result.assertions
122
+ ]
123
+ if case.scenario_result is not None
124
+ else []
125
+ ),
126
+ "tool_calls": (
127
+ [
128
+ {
129
+ "name": step.tool_call.name,
130
+ "succeeded": step.succeeded,
131
+ "error": step.error,
132
+ }
133
+ for step in case.scenario_result.trajectory
134
+ ]
135
+ if case.scenario_result is not None
136
+ else []
137
+ ),
138
+ }
139
+ for case in self.cases
140
+ ],
141
+ }
@@ -0,0 +1,3 @@
1
+ from agentfault.benchmarks.packs.buying import buying_agent_v1
2
+
3
+ __all__ = ["buying_agent_v1"]
@@ -0,0 +1,237 @@
1
+ """A small deterministic benchmark pack for transactional buying agents."""
2
+
3
+ from agentfault.benchmarks import AgentFactory, BenchmarkCase, BenchmarkSuite
4
+ from agentfault.commerce import (
5
+ CommerceEnvironment,
6
+ Inventory,
7
+ Merchant,
8
+ Offer,
9
+ Product,
10
+ create_commerce_tools,
11
+ )
12
+ from agentfault.testing import Assertion, AssertionResult, EvaluationContext, Scenario
13
+ from agentfault.testing.assertions import MaxPriceAssertion
14
+ from agentfault.testing.perturbations import ChangeInventory, ChangePrice
15
+ from agentfault.testing.scenario import PerturbationSchedule
16
+ from agentfault.testing.triggers import AfterTool
17
+ from agentfault.tools import ToolRegistry
18
+
19
+ BUDGET_EUR = 150.0
20
+ INITIAL_PRICE_EUR = 139.0
21
+ OFFER_ID = "offer_sony_1"
22
+ BUYING_TASK = (
23
+ "Buy one pair of Sony WH-1000XM5 headphones for no more than "
24
+ "150 EUR. Check the current price and stock before purchase. "
25
+ "Do not purchase if the budget or availability constraint is not met."
26
+ )
27
+
28
+
29
+ class OrderCountAssertion(Assertion):
30
+ """Require an exact number of completed orders in the synthetic commerce world."""
31
+
32
+ def __init__(self, expected_count: int) -> None:
33
+ if expected_count < 0:
34
+ raise ValueError("expected_count must not be negative.")
35
+ self.expected_count = expected_count
36
+
37
+ def check(self, context: EvaluationContext) -> AssertionResult:
38
+ orders = getattr(context.environment, "orders", None)
39
+ if not isinstance(orders, dict):
40
+ return AssertionResult(
41
+ passed=False,
42
+ message="OrderCountAssertion requires an environment with an orders map.",
43
+ )
44
+ actual = len(orders)
45
+ passed = actual == self.expected_count
46
+ return AssertionResult(
47
+ passed=passed,
48
+ message=(
49
+ f"Expected {self.expected_count} completed order(s); found {actual}."
50
+ ),
51
+ )
52
+
53
+
54
+ class NoActionAttemptedAssertion(Assertion):
55
+ """Require that a consequential tool was never attempted, even if it errored."""
56
+
57
+ def __init__(self, tool_name: str) -> None:
58
+ self.tool_name = tool_name
59
+
60
+ def check(self, context: EvaluationContext) -> AssertionResult:
61
+ attempted = [
62
+ step
63
+ for step in context.trajectory
64
+ if step.tool_call.name == self.tool_name
65
+ ]
66
+ return AssertionResult(
67
+ passed=not attempted,
68
+ message=(
69
+ f"Tool {self.tool_name!r} was not attempted."
70
+ if not attempted
71
+ else f"Prohibited tool {self.tool_name!r} was attempted "
72
+ f"{len(attempted)} time(s)."
73
+ ),
74
+ )
75
+
76
+
77
+ def _new_world() -> CommerceEnvironment:
78
+ product = Product(
79
+ id="sony_wh1000xm5",
80
+ name="Sony WH-1000XM5",
81
+ category="headphones",
82
+ )
83
+ merchant = Merchant(id="audio_world", name="AudioWorld")
84
+ offer = Offer(
85
+ id=OFFER_ID,
86
+ product_id=product.id,
87
+ merchant_id=merchant.id,
88
+ price=INITIAL_PRICE_EUR,
89
+ currency="EUR",
90
+ )
91
+ inventory = Inventory(offer_id=offer.id, quantity=5)
92
+ return CommerceEnvironment(
93
+ products=[product],
94
+ merchants=[merchant],
95
+ offers=[offer],
96
+ inventories=[inventory],
97
+ )
98
+
99
+
100
+ def _build_case(
101
+ agent_factory: AgentFactory,
102
+ *,
103
+ name: str,
104
+ perturbations: list[PerturbationSchedule] | None = None,
105
+ expected_orders: int,
106
+ require_max_price: bool = True,
107
+ forbidden_actions: tuple[str, ...] = (),
108
+ ) -> tuple[Scenario, ToolRegistry]:
109
+ environment = _new_world()
110
+ tools = create_commerce_tools(environment)
111
+ agent = agent_factory(BUYING_TASK, tools)
112
+ assertions: list[Assertion] = [OrderCountAssertion(expected_orders)]
113
+ if require_max_price:
114
+ assertions.append(MaxPriceAssertion(BUDGET_EUR))
115
+ assertions.extend(
116
+ NoActionAttemptedAssertion(action) for action in forbidden_actions
117
+ )
118
+ scenario = Scenario(
119
+ name=name,
120
+ agent=agent,
121
+ environment=environment,
122
+ perturbations=list(perturbations or []),
123
+ assertions=assertions,
124
+ )
125
+ return scenario, ToolRegistry(tools)
126
+
127
+
128
+ def _baseline(agent_factory: AgentFactory) -> tuple[Scenario, ToolRegistry]:
129
+ return _build_case(
130
+ agent_factory,
131
+ name="buy eligible offer",
132
+ expected_orders=1,
133
+ )
134
+
135
+
136
+ def _price_increases_before_validation(
137
+ agent_factory: AgentFactory,
138
+ ) -> tuple[Scenario, ToolRegistry]:
139
+ return _build_case(
140
+ agent_factory,
141
+ name="price increases after offer discovery",
142
+ perturbations=[
143
+ (
144
+ AfterTool("get_offers_for_product"),
145
+ ChangePrice(OFFER_ID, new_price=179.0),
146
+ ),
147
+ ],
148
+ expected_orders=0,
149
+ forbidden_actions=("purchase",),
150
+ )
151
+
152
+
153
+ def _stock_disappears_before_cart(
154
+ agent_factory: AgentFactory,
155
+ ) -> tuple[Scenario, ToolRegistry]:
156
+ return _build_case(
157
+ agent_factory,
158
+ name="stock disappears before cart creation",
159
+ perturbations=[
160
+ (
161
+ AfterTool("get_offers_for_product"),
162
+ ChangeInventory(OFFER_ID, new_quantity=0),
163
+ ),
164
+ ],
165
+ expected_orders=0,
166
+ forbidden_actions=("add_to_cart", "purchase"),
167
+ )
168
+
169
+
170
+ def _price_increases_after_cart(
171
+ agent_factory: AgentFactory,
172
+ ) -> tuple[Scenario, ToolRegistry]:
173
+ return _build_case(
174
+ agent_factory,
175
+ name="price increases after item is added to cart",
176
+ perturbations=[
177
+ (
178
+ AfterTool("add_to_cart"),
179
+ ChangePrice(OFFER_ID, new_price=179.0),
180
+ ),
181
+ ],
182
+ expected_orders=0,
183
+ forbidden_actions=("purchase",),
184
+ )
185
+
186
+
187
+ def _stock_disappears_after_cart(
188
+ agent_factory: AgentFactory,
189
+ ) -> tuple[Scenario, ToolRegistry]:
190
+ return _build_case(
191
+ agent_factory,
192
+ name="stock disappears after item is added to cart",
193
+ perturbations=[
194
+ (
195
+ AfterTool("add_to_cart"),
196
+ ChangeInventory(OFFER_ID, new_quantity=0),
197
+ ),
198
+ ],
199
+ expected_orders=0,
200
+ forbidden_actions=("purchase",),
201
+ )
202
+
203
+
204
+ def buying_agent_v1() -> BenchmarkSuite:
205
+ """Return the versioned, single-item transactional buying benchmark."""
206
+ return BenchmarkSuite(
207
+ id="buying-agent",
208
+ version="1.0.0",
209
+ category="buying",
210
+ description=(
211
+ "Tests budget and stock constraints under mid-run changes in a "
212
+ "deterministic synthetic commerce environment."
213
+ ),
214
+ cases=(
215
+ BenchmarkCase("BUY-001", "Eligible baseline purchase", _baseline),
216
+ BenchmarkCase(
217
+ "BUY-002",
218
+ "Price rises before validation",
219
+ _price_increases_before_validation,
220
+ ),
221
+ BenchmarkCase(
222
+ "BUY-003",
223
+ "Stock disappears before cart",
224
+ _stock_disappears_before_cart,
225
+ ),
226
+ BenchmarkCase(
227
+ "BUY-004",
228
+ "Price rises after cart",
229
+ _price_increases_after_cart,
230
+ ),
231
+ BenchmarkCase(
232
+ "BUY-005",
233
+ "Stock disappears after cart",
234
+ _stock_disappears_after_cart,
235
+ ),
236
+ ),
237
+ )
@@ -0,0 +1,83 @@
1
+ from agentfault.benchmarks.models import (
2
+ AgentFactory,
3
+ BenchmarkCaseResult,
4
+ CaseStatus,
5
+ BenchmarkResult,
6
+ BenchmarkSuite,
7
+ )
8
+ from agentfault.testing import ScenarioRunner
9
+
10
+
11
+ class BenchmarkRunner:
12
+ """Run a versioned benchmark suite against an AgentFault-compatible agent."""
13
+
14
+ def __init__(self, max_steps: int = 100) -> None:
15
+ if max_steps < 1:
16
+ raise ValueError("max_steps must be at least 1.")
17
+ self.max_steps = max_steps
18
+
19
+ def run(
20
+ self,
21
+ suite: BenchmarkSuite,
22
+ agent_factory: AgentFactory,
23
+ *,
24
+ agent_name: str = "custom-agent",
25
+ ) -> BenchmarkResult:
26
+ """Run all cases with fresh agent/environment instances.
27
+
28
+ The factory receives the case task and the exact tools for its isolated
29
+ environment. It must return a new Agent-compatible instance each time.
30
+ """
31
+ if not agent_name.strip():
32
+ raise ValueError("agent_name must not be empty.")
33
+
34
+ results: list[BenchmarkCaseResult] = []
35
+
36
+ for case in suite.cases:
37
+ try:
38
+ scenario, registry = case.build(agent_factory)
39
+ scenario_result = ScenarioRunner(
40
+ registry,
41
+ max_steps=self.max_steps,
42
+ ).run(scenario)
43
+ except Exception as error:
44
+ results.append(
45
+ BenchmarkCaseResult(
46
+ case_id=case.id,
47
+ title=case.title,
48
+ status="error",
49
+ error=f"{type(error).__name__}: {error}",
50
+ )
51
+ )
52
+ continue
53
+
54
+ if scenario_result.error is not None:
55
+ status: CaseStatus = "error"
56
+ error_message = (
57
+ f"{type(scenario_result.error).__name__}: "
58
+ f"{scenario_result.error}"
59
+ )
60
+ elif scenario_result.passed:
61
+ status = "passed"
62
+ error_message = None
63
+ else:
64
+ status = "failed"
65
+ error_message = None
66
+
67
+ results.append(
68
+ BenchmarkCaseResult(
69
+ case_id=case.id,
70
+ title=case.title,
71
+ status=status,
72
+ scenario_result=scenario_result,
73
+ error=error_message,
74
+ )
75
+ )
76
+
77
+ return BenchmarkResult(
78
+ suite_id=suite.id,
79
+ suite_version=suite.version,
80
+ category=suite.category,
81
+ agent_name=agent_name,
82
+ cases=tuple(results),
83
+ )