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.
- agentfault/__init__.py +54 -0
- agentfault/agent/__init__.py +5 -0
- agentfault/agent/base.py +11 -0
- agentfault/agent/deterministic.py +80 -0
- agentfault/agent/naive.py +55 -0
- agentfault/benchmarks/__init__.py +17 -0
- agentfault/benchmarks/models.py +141 -0
- agentfault/benchmarks/packs/__init__.py +3 -0
- agentfault/benchmarks/packs/buying.py +237 -0
- agentfault/benchmarks/runner.py +83 -0
- agentfault/cli.py +112 -0
- agentfault/commerce/__init__.py +23 -0
- agentfault/commerce/environment.py +119 -0
- agentfault/commerce/models.py +45 -0
- agentfault/commerce/tools.py +44 -0
- agentfault/integrations/langgraph.py +227 -0
- agentfault/integrations/openai/__init__.py +9 -0
- agentfault/integrations/openai/agent.py +156 -0
- agentfault/integrations/openai/tools.py +17 -0
- agentfault/pytest_plugin.py +68 -0
- agentfault/testing/__init__.py +19 -0
- agentfault/testing/assertion.py +27 -0
- agentfault/testing/assertions/__init__.py +7 -0
- agentfault/testing/assertions/budget.py +39 -0
- agentfault/testing/assertions/prohibited_action.py +21 -0
- agentfault/testing/perturbations/__init__.py +12 -0
- agentfault/testing/perturbations/base.py +8 -0
- agentfault/testing/perturbations/change_inventory.py +14 -0
- agentfault/testing/perturbations/change_price.py +14 -0
- agentfault/testing/perturbations/fail_tool.py +22 -0
- agentfault/testing/runner.py +107 -0
- agentfault/testing/scenario.py +19 -0
- agentfault/testing/scenario_loader.py +42 -0
- agentfault/testing/scenario_result.py +14 -0
- agentfault/testing/scenario_runner.py +99 -0
- agentfault/testing/triggers/__init__.py +8 -0
- agentfault/testing/triggers/base.py +9 -0
- agentfault/testing/triggers/tool.py +20 -0
- agentfault/tools/__init__.py +13 -0
- agentfault/tools/errors.py +9 -0
- agentfault/tools/executor.py +19 -0
- agentfault/tools/registry.py +24 -0
- agentfault/tools/tool.py +69 -0
- agentfault/trajectory/__init__.py +5 -0
- agentfault/trajectory/models.py +16 -0
- agentfault-0.1.0.dist-info/METADATA +146 -0
- agentfault-0.1.0.dist-info/RECORD +50 -0
- agentfault-0.1.0.dist-info/WHEEL +4 -0
- agentfault-0.1.0.dist-info/entry_points.txt +5 -0
- 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
|
+
]
|
agentfault/agent/base.py
ADDED
|
@@ -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,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
|
+
)
|