agentprobe-testing 1.4.0__tar.gz → 1.5.0__tar.gz
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.
- {agentprobe_testing-1.4.0 → agentprobe_testing-1.5.0}/LICENSE +1 -1
- {agentprobe_testing-1.4.0 → agentprobe_testing-1.5.0}/PKG-INFO +2 -2
- {agentprobe_testing-1.4.0 → agentprobe_testing-1.5.0}/README.md +4 -4
- {agentprobe_testing-1.4.0 → agentprobe_testing-1.5.0}/agentprobe/__init__.py +7 -1
- {agentprobe_testing-1.4.0 → agentprobe_testing-1.5.0}/agentprobe/domain.py +6 -0
- agentprobe_testing-1.5.0/agentprobe/domains/tau_retail/__init__.py +7 -0
- agentprobe_testing-1.5.0/agentprobe/domains/tau_retail/chat_agent.py +38 -0
- agentprobe_testing-1.5.0/agentprobe/domains/tau_retail/domain.py +110 -0
- agentprobe_testing-1.5.0/agentprobe/domains/tau_retail/injector_prompt.py +51 -0
- agentprobe_testing-1.5.0/agentprobe/domains/tau_retail/loader.py +99 -0
- agentprobe_testing-1.5.0/agentprobe/domains/tau_retail/openai_compat_agent.py +85 -0
- agentprobe_testing-1.5.0/agentprobe/domains/tau_retail/oracle.py +75 -0
- agentprobe_testing-1.5.0/agentprobe/domains/tau_retail/tools.py +88 -0
- agentprobe_testing-1.5.0/agentprobe/domains/tau_retail/user_sim.py +79 -0
- agentprobe_testing-1.5.0/agentprobe/domains/tau_retail/world.py +92 -0
- {agentprobe_testing-1.4.0 → agentprobe_testing-1.5.0}/agentprobe/injector.py +6 -0
- {agentprobe_testing-1.4.0 → agentprobe_testing-1.5.0}/agentprobe/quickstart.py +101 -7
- {agentprobe_testing-1.4.0 → agentprobe_testing-1.5.0}/agentprobe/registry.py +1 -1
- {agentprobe_testing-1.4.0 → agentprobe_testing-1.5.0}/agentprobe/runner.py +33 -9
- agentprobe_testing-1.5.0/agentprobe/savings.py +208 -0
- {agentprobe_testing-1.4.0 → agentprobe_testing-1.5.0}/agentprobe_testing.egg-info/PKG-INFO +2 -2
- {agentprobe_testing-1.4.0 → agentprobe_testing-1.5.0}/agentprobe_testing.egg-info/SOURCES.txt +14 -0
- {agentprobe_testing-1.4.0 → agentprobe_testing-1.5.0}/pyproject.toml +1 -1
- {agentprobe_testing-1.4.0 → agentprobe_testing-1.5.0}/tests/test_registry.py +1 -1
- agentprobe_testing-1.5.0/tests/test_runner_timeouts.py +67 -0
- agentprobe_testing-1.5.0/tests/test_savings.py +170 -0
- agentprobe_testing-1.5.0/tests/test_tau_retail.py +157 -0
- {agentprobe_testing-1.4.0 → agentprobe_testing-1.5.0}/agentprobe/agents/__init__.py +0 -0
- {agentprobe_testing-1.4.0 → agentprobe_testing-1.5.0}/agentprobe/agents/base.py +0 -0
- {agentprobe_testing-1.4.0 → agentprobe_testing-1.5.0}/agentprobe/agents/browser_agent.py +0 -0
- {agentprobe_testing-1.4.0 → agentprobe_testing-1.5.0}/agentprobe/agents/rule_based.py +0 -0
- {agentprobe_testing-1.4.0 → agentprobe_testing-1.5.0}/agentprobe/agents/scripted.py +0 -0
- {agentprobe_testing-1.4.0 → agentprobe_testing-1.5.0}/agentprobe/agents/target_agent.py +0 -0
- {agentprobe_testing-1.4.0 → agentprobe_testing-1.5.0}/agentprobe/agreement.py +0 -0
- {agentprobe_testing-1.4.0 → agentprobe_testing-1.5.0}/agentprobe/classifier.py +0 -0
- {agentprobe_testing-1.4.0 → agentprobe_testing-1.5.0}/agentprobe/cli.py +0 -0
- {agentprobe_testing-1.4.0 → agentprobe_testing-1.5.0}/agentprobe/diff.py +0 -0
- {agentprobe_testing-1.4.0 → agentprobe_testing-1.5.0}/agentprobe/domains/__init__.py +0 -0
- {agentprobe_testing-1.4.0 → agentprobe_testing-1.5.0}/agentprobe/domains/access_control/__init__.py +0 -0
- {agentprobe_testing-1.4.0 → agentprobe_testing-1.5.0}/agentprobe/domains/access_control/agent.py +0 -0
- {agentprobe_testing-1.4.0 → agentprobe_testing-1.5.0}/agentprobe/domains/access_control/clean.py +0 -0
- {agentprobe_testing-1.4.0 → agentprobe_testing-1.5.0}/agentprobe/domains/access_control/complex_agent.py +0 -0
- {agentprobe_testing-1.4.0 → agentprobe_testing-1.5.0}/agentprobe/domains/access_control/decoy.py +0 -0
- {agentprobe_testing-1.4.0 → agentprobe_testing-1.5.0}/agentprobe/domains/access_control/domain.py +0 -0
- {agentprobe_testing-1.4.0 → agentprobe_testing-1.5.0}/agentprobe/domains/access_control/entities.py +0 -0
- {agentprobe_testing-1.4.0 → agentprobe_testing-1.5.0}/agentprobe/domains/access_control/injector_prompt.py +0 -0
- {agentprobe_testing-1.4.0 → agentprobe_testing-1.5.0}/agentprobe/domains/access_control/rule_based_agent.py +0 -0
- {agentprobe_testing-1.4.0 → agentprobe_testing-1.5.0}/agentprobe/domains/access_control/scenarios.py +0 -0
- {agentprobe_testing-1.4.0 → agentprobe_testing-1.5.0}/agentprobe/domains/access_control/split.py +0 -0
- {agentprobe_testing-1.4.0 → agentprobe_testing-1.5.0}/agentprobe/domains/access_control/tools.py +0 -0
- {agentprobe_testing-1.4.0 → agentprobe_testing-1.5.0}/agentprobe/domains/access_control/trap.py +0 -0
- {agentprobe_testing-1.4.0 → agentprobe_testing-1.5.0}/agentprobe/domains/web_browsing/__init__.py +0 -0
- {agentprobe_testing-1.4.0 → agentprobe_testing-1.5.0}/agentprobe/domains/web_browsing/agent.py +0 -0
- {agentprobe_testing-1.4.0 → agentprobe_testing-1.5.0}/agentprobe/domains/web_browsing/clean.py +0 -0
- {agentprobe_testing-1.4.0 → agentprobe_testing-1.5.0}/agentprobe/domains/web_browsing/domain.py +0 -0
- {agentprobe_testing-1.4.0 → agentprobe_testing-1.5.0}/agentprobe/domains/web_browsing/entities.py +0 -0
- {agentprobe_testing-1.4.0 → agentprobe_testing-1.5.0}/agentprobe/domains/web_browsing/injector_prompt.py +0 -0
- {agentprobe_testing-1.4.0 → agentprobe_testing-1.5.0}/agentprobe/domains/web_browsing/rule_based_agent.py +0 -0
- {agentprobe_testing-1.4.0 → agentprobe_testing-1.5.0}/agentprobe/domains/web_browsing/scenarios.py +0 -0
- {agentprobe_testing-1.4.0 → agentprobe_testing-1.5.0}/agentprobe/domains/web_browsing/tools.py +0 -0
- {agentprobe_testing-1.4.0 → agentprobe_testing-1.5.0}/agentprobe/domains/web_browsing/trap.py +0 -0
- {agentprobe_testing-1.4.0 → agentprobe_testing-1.5.0}/agentprobe/feedback.py +0 -0
- {agentprobe_testing-1.4.0 → agentprobe_testing-1.5.0}/agentprobe/generic_world.py +0 -0
- {agentprobe_testing-1.4.0 → agentprobe_testing-1.5.0}/agentprobe/injection.py +0 -0
- {agentprobe_testing-1.4.0 → agentprobe_testing-1.5.0}/agentprobe/llm.py +0 -0
- {agentprobe_testing-1.4.0 → agentprobe_testing-1.5.0}/agentprobe/playbook.py +0 -0
- {agentprobe_testing-1.4.0 → agentprobe_testing-1.5.0}/agentprobe/priority.py +0 -0
- {agentprobe_testing-1.4.0 → agentprobe_testing-1.5.0}/agentprobe/reachability.py +0 -0
- {agentprobe_testing-1.4.0 → agentprobe_testing-1.5.0}/agentprobe/report.py +0 -0
- {agentprobe_testing-1.4.0 → agentprobe_testing-1.5.0}/agentprobe/scenario.py +0 -0
- {agentprobe_testing-1.4.0 → agentprobe_testing-1.5.0}/agentprobe/scenarios/__init__.py +0 -0
- {agentprobe_testing-1.4.0 → agentprobe_testing-1.5.0}/agentprobe/scenarios/clean.py +0 -0
- {agentprobe_testing-1.4.0 → agentprobe_testing-1.5.0}/agentprobe/scenarios/decoy.py +0 -0
- {agentprobe_testing-1.4.0 → agentprobe_testing-1.5.0}/agentprobe/scenarios/registry.py +0 -0
- {agentprobe_testing-1.4.0 → agentprobe_testing-1.5.0}/agentprobe/scenarios/split.py +0 -0
- {agentprobe_testing-1.4.0 → agentprobe_testing-1.5.0}/agentprobe/scenarios/trap.py +0 -0
- {agentprobe_testing-1.4.0 → agentprobe_testing-1.5.0}/agentprobe/summarize.py +0 -0
- {agentprobe_testing-1.4.0 → agentprobe_testing-1.5.0}/agentprobe/termui.py +0 -0
- {agentprobe_testing-1.4.0 → agentprobe_testing-1.5.0}/agentprobe/tools.py +0 -0
- {agentprobe_testing-1.4.0 → agentprobe_testing-1.5.0}/agentprobe/trajectory.py +0 -0
- {agentprobe_testing-1.4.0 → agentprobe_testing-1.5.0}/agentprobe/triage.py +0 -0
- {agentprobe_testing-1.4.0 → agentprobe_testing-1.5.0}/agentprobe/validate_scenarios.py +0 -0
- {agentprobe_testing-1.4.0 → agentprobe_testing-1.5.0}/agentprobe/world.py +0 -0
- {agentprobe_testing-1.4.0 → agentprobe_testing-1.5.0}/agentprobe_testing.egg-info/dependency_links.txt +0 -0
- {agentprobe_testing-1.4.0 → agentprobe_testing-1.5.0}/agentprobe_testing.egg-info/entry_points.txt +0 -0
- {agentprobe_testing-1.4.0 → agentprobe_testing-1.5.0}/agentprobe_testing.egg-info/requires.txt +0 -0
- {agentprobe_testing-1.4.0 → agentprobe_testing-1.5.0}/agentprobe_testing.egg-info/top_level.txt +0 -0
- {agentprobe_testing-1.4.0 → agentprobe_testing-1.5.0}/setup.cfg +0 -0
- {agentprobe_testing-1.4.0 → agentprobe_testing-1.5.0}/tests/test_access_control_agent.py +0 -0
- {agentprobe_testing-1.4.0 → agentprobe_testing-1.5.0}/tests/test_access_control_domain.py +0 -0
- {agentprobe_testing-1.4.0 → agentprobe_testing-1.5.0}/tests/test_access_control_rule_based_agent.py +0 -0
- {agentprobe_testing-1.4.0 → agentprobe_testing-1.5.0}/tests/test_access_control_scenarios.py +0 -0
- {agentprobe_testing-1.4.0 → agentprobe_testing-1.5.0}/tests/test_access_control_tools.py +0 -0
- {agentprobe_testing-1.4.0 → agentprobe_testing-1.5.0}/tests/test_agreement.py +0 -0
- {agentprobe_testing-1.4.0 → agentprobe_testing-1.5.0}/tests/test_browser_agent.py +0 -0
- {agentprobe_testing-1.4.0 → agentprobe_testing-1.5.0}/tests/test_classifier.py +0 -0
- {agentprobe_testing-1.4.0 → agentprobe_testing-1.5.0}/tests/test_cli.py +0 -0
- {agentprobe_testing-1.4.0 → agentprobe_testing-1.5.0}/tests/test_complex_access_control_agent.py +0 -0
- {agentprobe_testing-1.4.0 → agentprobe_testing-1.5.0}/tests/test_diff.py +0 -0
- {agentprobe_testing-1.4.0 → agentprobe_testing-1.5.0}/tests/test_domain.py +0 -0
- {agentprobe_testing-1.4.0 → agentprobe_testing-1.5.0}/tests/test_failures.py +0 -0
- {agentprobe_testing-1.4.0 → agentprobe_testing-1.5.0}/tests/test_feedback.py +0 -0
- {agentprobe_testing-1.4.0 → agentprobe_testing-1.5.0}/tests/test_generic_world.py +0 -0
- {agentprobe_testing-1.4.0 → agentprobe_testing-1.5.0}/tests/test_injection.py +0 -0
- {agentprobe_testing-1.4.0 → agentprobe_testing-1.5.0}/tests/test_injection_mode.py +0 -0
- {agentprobe_testing-1.4.0 → agentprobe_testing-1.5.0}/tests/test_injector.py +0 -0
- {agentprobe_testing-1.4.0 → agentprobe_testing-1.5.0}/tests/test_llm.py +0 -0
- {agentprobe_testing-1.4.0 → agentprobe_testing-1.5.0}/tests/test_package_api.py +0 -0
- {agentprobe_testing-1.4.0 → agentprobe_testing-1.5.0}/tests/test_playbook.py +0 -0
- {agentprobe_testing-1.4.0 → agentprobe_testing-1.5.0}/tests/test_priority.py +0 -0
- {agentprobe_testing-1.4.0 → agentprobe_testing-1.5.0}/tests/test_quickstart.py +0 -0
- {agentprobe_testing-1.4.0 → agentprobe_testing-1.5.0}/tests/test_reachability.py +0 -0
- {agentprobe_testing-1.4.0 → agentprobe_testing-1.5.0}/tests/test_report.py +0 -0
- {agentprobe_testing-1.4.0 → agentprobe_testing-1.5.0}/tests/test_rule_based_agent.py +0 -0
- {agentprobe_testing-1.4.0 → agentprobe_testing-1.5.0}/tests/test_runner.py +0 -0
- {agentprobe_testing-1.4.0 → agentprobe_testing-1.5.0}/tests/test_scenarios.py +0 -0
- {agentprobe_testing-1.4.0 → agentprobe_testing-1.5.0}/tests/test_target_agent.py +0 -0
- {agentprobe_testing-1.4.0 → agentprobe_testing-1.5.0}/tests/test_termui.py +0 -0
- {agentprobe_testing-1.4.0 → agentprobe_testing-1.5.0}/tests/test_tools.py +0 -0
- {agentprobe_testing-1.4.0 → agentprobe_testing-1.5.0}/tests/test_trajectory.py +0 -0
- {agentprobe_testing-1.4.0 → agentprobe_testing-1.5.0}/tests/test_triage.py +0 -0
- {agentprobe_testing-1.4.0 → agentprobe_testing-1.5.0}/tests/test_validate_scenarios.py +0 -0
- {agentprobe_testing-1.4.0 → agentprobe_testing-1.5.0}/tests/test_web_browsing_domain.py +0 -0
- {agentprobe_testing-1.4.0 → agentprobe_testing-1.5.0}/tests/test_world.py +0 -0
|
@@ -16,7 +16,7 @@ Additional Use Grant: You may use, copy, modify, and make production use of
|
|
|
16
16
|
limitation, hosted domain generation, hosted
|
|
17
17
|
dashboards, or hosted test-run storage substantially
|
|
18
18
|
similar to those offered at
|
|
19
|
-
https://
|
|
19
|
+
https://sampact.com).
|
|
20
20
|
Change Date: 2030-01-01
|
|
21
21
|
Change License: Apache License, Version 2.0
|
|
22
22
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: agentprobe-testing
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.5.0
|
|
4
4
|
Summary: Adaptive chaos-testing for LLM agents: a live model-driven Injector that reads an agent's real trajectory and decides where to break something, instead of scripting perturbations in advance.
|
|
5
5
|
License: Business Source License 1.1
|
|
6
6
|
|
|
@@ -20,7 +20,7 @@ License: Business Source License 1.1
|
|
|
20
20
|
limitation, hosted domain generation, hosted
|
|
21
21
|
dashboards, or hosted test-run storage substantially
|
|
22
22
|
similar to those offered at
|
|
23
|
-
https://
|
|
23
|
+
https://sampact.com).
|
|
24
24
|
Change Date: 2030-01-01
|
|
25
25
|
Change License: Apache License, Version 2.0
|
|
26
26
|
|
|
@@ -183,10 +183,10 @@ own world — the engine underneath is already generic.
|
|
|
183
183
|
|
|
184
184
|
Writing `agentprobe/domain.py`'s `Domain` bundle by hand (entities, tools,
|
|
185
185
|
business rules) is real, unavoidable work — see "Domains" above. If you'd
|
|
186
|
-
rather not write it yourself, [
|
|
186
|
+
rather not write it yourself, [sampact.com](https://sampact.com)
|
|
187
187
|
can generate one from your existing tool schemas plus a plain-English
|
|
188
188
|
description of your business rules, and hand you back a key — either via
|
|
189
|
-
[/generate](https://
|
|
189
|
+
[/generate](https://sampact.com/generate) on the
|
|
190
190
|
site (sign in, fill in a form, get the key and the generated source back
|
|
191
191
|
right on the page — no `curl` needed) or the raw API:
|
|
192
192
|
|
|
@@ -215,7 +215,7 @@ rule as never installing a package from someone you don't trust.
|
|
|
215
215
|
|
|
216
216
|
## The website: sign in and run a real test in your browser
|
|
217
217
|
|
|
218
|
-
[
|
|
218
|
+
[sampact.com](https://sampact.com)
|
|
219
219
|
is the real site, not just the API — sign in with GitHub and you get:
|
|
220
220
|
|
|
221
221
|
- **`/run`** — one flow, describing to running: describe your business in
|
|
@@ -246,7 +246,7 @@ Attribute a generated domain to your account by passing the key as a
|
|
|
246
246
|
bearer token:
|
|
247
247
|
|
|
248
248
|
```bash
|
|
249
|
-
curl -X POST https://
|
|
249
|
+
curl -X POST https://sampact.com/v1/generate \
|
|
250
250
|
-H "Authorization: Bearer ap_live_..." \
|
|
251
251
|
-H "content-type: application/json" \
|
|
252
252
|
-d '{"name": "...", "tool_schemas": [...], "business_description": "..."}'
|
|
@@ -56,13 +56,19 @@ from agentprobe.registry import (
|
|
|
56
56
|
)
|
|
57
57
|
from agentprobe.report import InjectionRecord, Report
|
|
58
58
|
from agentprobe.runner import run_recovery, run_robustness_pair
|
|
59
|
+
from agentprobe.savings import FixedFaultInjector, estimate_cost, fixed_fault_for, model_injector_for, retest_failures
|
|
59
60
|
from agentprobe.scenario import CommitPattern, FactPattern, GoalSpec, Scenario
|
|
60
61
|
from agentprobe.scenarios.registry import ALL_SCENARIOS as TICKET_SCENARIOS
|
|
61
62
|
from agentprobe.scenarios.registry import BY_ID as TICKET_SCENARIOS_BY_ID
|
|
62
63
|
|
|
63
|
-
__version__ = "1.
|
|
64
|
+
__version__ = "1.5.0"
|
|
64
65
|
|
|
65
66
|
__all__ = [
|
|
67
|
+
"FixedFaultInjector",
|
|
68
|
+
"estimate_cost",
|
|
69
|
+
"fixed_fault_for",
|
|
70
|
+
"model_injector_for",
|
|
71
|
+
"retest_failures",
|
|
66
72
|
"Agent",
|
|
67
73
|
"AgentAction",
|
|
68
74
|
"AppliedInjection",
|
|
@@ -87,6 +87,12 @@ class Domain:
|
|
|
87
87
|
pre_dispatch's ON_TOOL_CALL check, so the injector would silently arm
|
|
88
88
|
on every step and never once fire (0 fired, 100% expired, no error)."""
|
|
89
89
|
|
|
90
|
+
uninjectable_tools: frozenset = frozenset()
|
|
91
|
+
"""Tools that are a communication channel rather than a system call (e.g.
|
|
92
|
+
the tau-bench `respond` tool, which talks to the customer). The runner never
|
|
93
|
+
fires a tool-response injection (error, phantom/false success, unverified
|
|
94
|
+
outcome) on them; triggers stay armed until a different tool is called."""
|
|
95
|
+
|
|
90
96
|
@property
|
|
91
97
|
def all_tools(self) -> frozenset:
|
|
92
98
|
"""Derived from tool_schemas rather than stored separately -- the
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""Sierra's tau-bench retail environment, run unmodified inside AgentProbe.
|
|
2
|
+
|
|
3
|
+
tau-bench (https://github.com/sierra-research/tau-bench, MIT, (c) Sierra) is
|
|
4
|
+
NOT vendored here. Point TAU_BENCH_DIR at a checkout and this package loads
|
|
5
|
+
Sierra's own tool code, database JSON, task list and policy straight from it,
|
|
6
|
+
so every result is measured against their ground truth, not a re-creation.
|
|
7
|
+
"""
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""tau-bench semantics for a plain assistant message: it IS the reply to the
|
|
2
|
+
customer (their `respond` action), not the end of the run. The run ends when the
|
|
3
|
+
customer says ###STOP### or the agent transfers to a human."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from agentprobe.agents.base import AgentAction
|
|
10
|
+
from agentprobe.agents.target_agent import TargetAgent
|
|
11
|
+
from agentprobe.domains.tau_retail.tools import STOP
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TauChatAgent(TargetAgent):
|
|
15
|
+
def start(self, task: str, tool_schemas: list[dict]) -> None:
|
|
16
|
+
super().start(task, tool_schemas)
|
|
17
|
+
self._pending_text_reply = False
|
|
18
|
+
self._over = False
|
|
19
|
+
|
|
20
|
+
def next_action(self) -> AgentAction:
|
|
21
|
+
if self._over:
|
|
22
|
+
return AgentAction(kind="final_answer", text="")
|
|
23
|
+
action = super().next_action()
|
|
24
|
+
if action.kind == "final_answer" and action.text:
|
|
25
|
+
self._pending_text_reply = True
|
|
26
|
+
return AgentAction(kind="tool_call", tool_name="respond", tool_args={"content": action.text},
|
|
27
|
+
latency_s=action.latency_s, cost_usd=action.cost_usd)
|
|
28
|
+
return action
|
|
29
|
+
|
|
30
|
+
def observe(self, tool_name: str, tool_args: dict, result: Any, ok: bool) -> None:
|
|
31
|
+
if STOP in str(result) or tool_name == "transfer_to_human_agents":
|
|
32
|
+
self._over = True
|
|
33
|
+
if self._pending_text_reply:
|
|
34
|
+
# The agent spoke in plain text: the customer's reply is the next user turn.
|
|
35
|
+
self._pending_text_reply = False
|
|
36
|
+
self._messages.append({"role": "user", "content": str(result)})
|
|
37
|
+
return
|
|
38
|
+
super().observe(tool_name, tool_args, result, ok)
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Wires Sierra's retail env into AgentProbe: one Domain + Scenario per run
|
|
2
|
+
(the customer simulator is per-run state, so the Domain closes over it)."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any, Optional
|
|
8
|
+
|
|
9
|
+
from agentprobe.domain import Domain
|
|
10
|
+
from agentprobe.domains.tau_retail import loader, oracle
|
|
11
|
+
from agentprobe.domains.tau_retail.injector_prompt import INJECTION_KINDS, TAU_RETAIL_INJECTOR_SYSTEM_PROMPT
|
|
12
|
+
from agentprobe.domains.tau_retail.tools import TauToolkit, commit_tools, tool_schemas
|
|
13
|
+
from agentprobe.domains.tau_retail.world import TauWorld, TauWorldSpec
|
|
14
|
+
from agentprobe.runner import run_target
|
|
15
|
+
from agentprobe.scenario import CommitPattern, GoalSpec, Scenario
|
|
16
|
+
|
|
17
|
+
BASELINE_STEPS = 2
|
|
18
|
+
"""Most tau tasks have a single commit, so the default "wait for the first commit" would
|
|
19
|
+
wake the injector only after the one thing worth breaking has happened."""
|
|
20
|
+
|
|
21
|
+
MAX_STEPS = 30
|
|
22
|
+
"""tau-bench's default max_num_steps. Here a step is any tool call, including
|
|
23
|
+
`respond`, which matches their count of agent actions."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def build_domain(user: Any, tau: Optional[loader.TauRetail] = None, injector_system_prompt: Optional[str] = None) -> Domain:
|
|
27
|
+
tau = tau or loader.load()
|
|
28
|
+
worlds: list[TauWorld] = []
|
|
29
|
+
|
|
30
|
+
def world_factory(spec: TauWorldSpec) -> TauWorld:
|
|
31
|
+
w = TauWorld(tau.fresh_data(), {"policy": tau.wiki})
|
|
32
|
+
worlds.append(w)
|
|
33
|
+
return w
|
|
34
|
+
|
|
35
|
+
domain = Domain(
|
|
36
|
+
name="tau_retail",
|
|
37
|
+
world_state_factory=world_factory,
|
|
38
|
+
toolkit_factory=lambda w: TauToolkit(w, user, tau),
|
|
39
|
+
tool_schemas=tool_schemas(tau),
|
|
40
|
+
commit_tools=commit_tools(tau),
|
|
41
|
+
precondition_checker=lambda tool, args, world: None,
|
|
42
|
+
entity_id_arg="order_id",
|
|
43
|
+
injector_system_prompt=injector_system_prompt,
|
|
44
|
+
injector_entity_types=("order", "user") if injector_system_prompt else None,
|
|
45
|
+
default_hardcoded_tool="cancel_pending_order",
|
|
46
|
+
uninjectable_tools=frozenset({"respond"}),
|
|
47
|
+
)
|
|
48
|
+
object.__setattr__(domain, "_worlds", worlds)
|
|
49
|
+
return domain
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def build_scenario(task_index: int, opening_message: str, tau: Optional[loader.TauRetail] = None,
|
|
53
|
+
baseline_until=2) -> Scenario:
|
|
54
|
+
tau = tau or loader.load()
|
|
55
|
+
task = tau.tasks[task_index]
|
|
56
|
+
commits = commit_tools(tau)
|
|
57
|
+
required = [CommitPattern(a.name, dict(a.kwargs)) for a in task.actions if a.name in commits]
|
|
58
|
+
return Scenario(
|
|
59
|
+
id=f"tau-retail-{task_index}",
|
|
60
|
+
seed=task_index,
|
|
61
|
+
world=TauWorldSpec(task_index),
|
|
62
|
+
task=opening_message,
|
|
63
|
+
goal=GoalSpec(required_commits=required),
|
|
64
|
+
baseline_until=baseline_until,
|
|
65
|
+
max_steps=MAX_STEPS,
|
|
66
|
+
max_cost_usd=2.0,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def build_model_injector(model: str = "claude-sonnet-5", tau: Optional[loader.TauRetail] = None):
|
|
71
|
+
"""The adaptive Injector, limited to the database-safe kinds."""
|
|
72
|
+
from agentprobe.injector import ModelInjector
|
|
73
|
+
|
|
74
|
+
tau = tau or loader.load()
|
|
75
|
+
return ModelInjector(
|
|
76
|
+
model=model,
|
|
77
|
+
kind_order=list(INJECTION_KINDS),
|
|
78
|
+
system_prompt=TAU_RETAIL_INJECTOR_SYSTEM_PROMPT,
|
|
79
|
+
all_tools=frozenset(s["name"] for s in tool_schemas(tau)),
|
|
80
|
+
commit_tools=commit_tools(tau),
|
|
81
|
+
entity_types=("order", "user"),
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def build_target(model: str = "claude-sonnet-5-5", tau: Optional[loader.TauRetail] = None):
|
|
86
|
+
"""tau-bench's own agent setup: a Claude tool-calling loop whose system prompt is Sierra's policy."""
|
|
87
|
+
from agentprobe.domains.tau_retail.chat_agent import TauChatAgent
|
|
88
|
+
|
|
89
|
+
tau = tau or loader.load()
|
|
90
|
+
return TauChatAgent(model=model, max_tokens=2048, system_prompt=tau.wiki)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass
|
|
94
|
+
class TauRun:
|
|
95
|
+
trajectory: Any
|
|
96
|
+
grade: oracle.Grade
|
|
97
|
+
world: TauWorld
|
|
98
|
+
user_cost_usd: float
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def run_task(task_index: int, agent, injector, user, tau: Optional[loader.TauRetail] = None,
|
|
102
|
+
baseline_until=2, injector_system_prompt: Optional[str] = None, variant: str = "chaos") -> TauRun:
|
|
103
|
+
"""One tau-bench task end to end: customer opens, agent+injector run, Sierra's grader scores."""
|
|
104
|
+
tau = tau or loader.load()
|
|
105
|
+
opening = user.reset()
|
|
106
|
+
domain = build_domain(user, tau, injector_system_prompt)
|
|
107
|
+
scenario = build_scenario(task_index, opening, tau, baseline_until)
|
|
108
|
+
traj = run_target(scenario, agent, injector, variant=variant, domain=domain)
|
|
109
|
+
world = domain._worlds[-1]
|
|
110
|
+
return TauRun(traj, oracle.grade(world, tau.tasks[task_index], tau), world, getattr(user, "cost_usd", 0.0))
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Injector prompt for the tau-bench retail domain.
|
|
2
|
+
|
|
3
|
+
Built from the access-control prompt (the generic trigger semantics, kind
|
|
4
|
+
definitions and rules are identical across domains) with its domain-specific
|
|
5
|
+
opening -- the task description and the tool list -- replaced. The experiment
|
|
6
|
+
offers the Injector only the four tool-response kinds (see INJECTION_KINDS),
|
|
7
|
+
which never touch the database, so Sierra's ground-truth final state stays the
|
|
8
|
+
correct answer under injection and the note/entity kinds' vocabulary is unused.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from agentprobe.domains.access_control.injector_prompt import ACCESS_CONTROL_INJECTOR_SYSTEM_PROMPT as _BASE
|
|
14
|
+
from agentprobe.injection import InjectionKind
|
|
15
|
+
|
|
16
|
+
INJECTION_KINDS = [
|
|
17
|
+
InjectionKind.TOOL_ERROR,
|
|
18
|
+
InjectionKind.PHANTOM_SUCCESS,
|
|
19
|
+
InjectionKind.UNVERIFIED_OUTCOME,
|
|
20
|
+
InjectionKind.FALSE_SUCCESS,
|
|
21
|
+
]
|
|
22
|
+
"""Kinds that change only what a tool call RETURNS (or whether it runs), never
|
|
23
|
+
the stored data. Under these, "database equals ground truth" stays the right
|
|
24
|
+
success test."""
|
|
25
|
+
|
|
26
|
+
_OPEN = _BASE.index("You choose a TRIGGER")
|
|
27
|
+
_HEAD = """You are the Injector in an adversarial testing harness. \
|
|
28
|
+
A Target agent is a retail customer-service agent talking to a customer: it can look up \
|
|
29
|
+
users, orders and products and change orders; you watch its trajectory and, once \
|
|
30
|
+
past the baseline phase, may arm exactly one fragility probe per step (or none). \
|
|
31
|
+
An armed injection does NOT fire immediately -- it fires later, whenever its \
|
|
32
|
+
trigger condition actually occurs. You never talk to the Target and never see \
|
|
33
|
+
its internal reasoning -- only its tool calls and their results.
|
|
34
|
+
|
|
35
|
+
The real tools -- use these exact names, never invent one:
|
|
36
|
+
reads: find_user_id_by_email, find_user_id_by_name_zip, get_user_details, get_order_details, \
|
|
37
|
+
get_product_details, list_all_product_types, calculate, think
|
|
38
|
+
talking to the customer: respond
|
|
39
|
+
commits: cancel_pending_order, modify_pending_order_address, modify_pending_order_items, \
|
|
40
|
+
modify_pending_order_payment, modify_user_address, return_delivered_order_items, \
|
|
41
|
+
exchange_delivered_order_items, transfer_to_human_agents
|
|
42
|
+
Commit tools take an "order_id" (modify_user_address takes "user_id"). A trigger naming a tool \
|
|
43
|
+
that doesn't exist can never fire, silently wasting the injection.
|
|
44
|
+
|
|
45
|
+
You may only use the kinds you are offered. They change what a tool call returns, never the \
|
|
46
|
+
stored data: the correct end state of the database is fixed, so an injection can make the task \
|
|
47
|
+
harder but never change what the right answer is.
|
|
48
|
+
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
TAU_RETAIL_INJECTOR_SYSTEM_PROMPT = _HEAD + _BASE[_OPEN:]
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Loads Sierra's retail env from a tau-bench checkout without importing the
|
|
2
|
+
`tau_bench` package proper (its __init__ pulls litellm/openai/etc.). Only the
|
|
3
|
+
pure-Python tool, data and task modules are used; `tau_bench.types` is a
|
|
4
|
+
dataclass stand-in with the same fields (their original is pydantic)."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import copy
|
|
9
|
+
import importlib
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
import types
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from functools import lru_cache
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class Action:
|
|
21
|
+
name: str
|
|
22
|
+
kwargs: dict[str, Any]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class Task:
|
|
27
|
+
user_id: str
|
|
28
|
+
actions: list[Action]
|
|
29
|
+
instruction: str
|
|
30
|
+
outputs: list[str]
|
|
31
|
+
annotator: str = ""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class TauBenchNotFound(RuntimeError):
|
|
35
|
+
pass
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def tau_dir() -> Path:
|
|
39
|
+
raw = os.environ.get("TAU_BENCH_DIR")
|
|
40
|
+
if not raw:
|
|
41
|
+
raise TauBenchNotFound("set TAU_BENCH_DIR to a checkout of sierra-research/tau-bench")
|
|
42
|
+
root = Path(raw)
|
|
43
|
+
if not (root / "tau_bench" / "envs" / "retail" / "tools").is_dir():
|
|
44
|
+
raise TauBenchNotFound(f"{root} does not look like a tau-bench checkout")
|
|
45
|
+
return root
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _package(name: str, path: Path) -> types.ModuleType:
|
|
49
|
+
mod = types.ModuleType(name)
|
|
50
|
+
mod.__path__ = [str(path)]
|
|
51
|
+
sys.modules[name] = mod
|
|
52
|
+
return mod
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass
|
|
56
|
+
class TauRetail:
|
|
57
|
+
root: Path
|
|
58
|
+
tools: dict[str, Any]
|
|
59
|
+
tasks: list[Task]
|
|
60
|
+
wiki: str
|
|
61
|
+
tool_schemas: list[dict]
|
|
62
|
+
_load_data: Any = field(repr=False, default=None)
|
|
63
|
+
|
|
64
|
+
def fresh_data(self) -> dict:
|
|
65
|
+
return self._load_data()
|
|
66
|
+
|
|
67
|
+
def commit_tool_names(self) -> frozenset:
|
|
68
|
+
return frozenset(
|
|
69
|
+
{
|
|
70
|
+
"cancel_pending_order", "exchange_delivered_order_items", "modify_pending_order_address",
|
|
71
|
+
"modify_pending_order_items", "modify_pending_order_payment", "modify_user_address",
|
|
72
|
+
"return_delivered_order_items", "transfer_to_human_agents",
|
|
73
|
+
}
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@lru_cache(maxsize=1)
|
|
78
|
+
def load(root: str | None = None) -> TauRetail:
|
|
79
|
+
base = Path(root) if root else tau_dir()
|
|
80
|
+
pkg = base / "tau_bench"
|
|
81
|
+
for name in [n for n in sys.modules if n == "tau_bench" or n.startswith("tau_bench.")]:
|
|
82
|
+
del sys.modules[name]
|
|
83
|
+
_package("tau_bench", pkg)
|
|
84
|
+
_package("tau_bench.envs", pkg / "envs")
|
|
85
|
+
_package("tau_bench.envs.retail", pkg / "envs" / "retail")
|
|
86
|
+
types_mod = types.ModuleType("tau_bench.types")
|
|
87
|
+
types_mod.Task, types_mod.Action = Task, Action
|
|
88
|
+
sys.modules["tau_bench.types"] = types_mod
|
|
89
|
+
importlib.invalidate_caches()
|
|
90
|
+
tools_mod = importlib.import_module("tau_bench.envs.retail.tools")
|
|
91
|
+
data_mod = importlib.import_module("tau_bench.envs.retail.data")
|
|
92
|
+
tasks_mod = importlib.import_module("tau_bench.envs.retail.tasks_test")
|
|
93
|
+
wiki = (pkg / "envs" / "retail" / "wiki.md").read_text()
|
|
94
|
+
tools = {t.get_info()["function"]["name"]: t for t in tools_mod.ALL_TOOLS}
|
|
95
|
+
schemas = []
|
|
96
|
+
for t in tools_mod.ALL_TOOLS:
|
|
97
|
+
fn = t.get_info()["function"]
|
|
98
|
+
schemas.append({"name": fn["name"], "description": fn["description"], "input_schema": copy.deepcopy(fn["parameters"])})
|
|
99
|
+
return TauRetail(base, tools, list(tasks_mod.TASKS_TEST), wiki, schemas, data_mod.load_data)
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Tool-calling agent for any OpenAI-compatible chat API (OpenRouter, Moonshot, ...),
|
|
2
|
+
with tau-bench chat semantics: a plain message is a reply to the customer."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import time
|
|
9
|
+
import urllib.request
|
|
10
|
+
from typing import Any, Callable, Optional
|
|
11
|
+
|
|
12
|
+
from agentprobe.agents.base import AgentAction
|
|
13
|
+
from agentprobe.agents.base import Agent
|
|
14
|
+
from agentprobe.domains.tau_retail.tools import STOP
|
|
15
|
+
|
|
16
|
+
OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class OpenAICompatChatAgent(Agent):
|
|
20
|
+
def __init__(self, model: str, system_prompt: str, api_key: Optional[str] = None, url: str = OPENROUTER_URL,
|
|
21
|
+
price_per_mtok: tuple[float, float] = (0.0, 0.0), post: Optional[Callable[[dict], dict]] = None,
|
|
22
|
+
max_tokens: int = 2048):
|
|
23
|
+
self.model, self.system_prompt, self.url = model, system_prompt, url
|
|
24
|
+
self.api_key = api_key or os.environ.get("OPENROUTER_API_KEY", "")
|
|
25
|
+
self.price = price_per_mtok
|
|
26
|
+
self.max_tokens = max_tokens
|
|
27
|
+
self._post = post or self._http_post
|
|
28
|
+
self.messages: list[dict] = []
|
|
29
|
+
self.tools: list[dict] = []
|
|
30
|
+
self._pending_call_id: Optional[str] = None
|
|
31
|
+
self._over = False
|
|
32
|
+
|
|
33
|
+
def _http_post(self, body: dict) -> dict:
|
|
34
|
+
req = urllib.request.Request(self.url, data=json.dumps(body).encode(), method="POST",
|
|
35
|
+
headers={"Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json"})
|
|
36
|
+
with urllib.request.urlopen(req, timeout=180) as r:
|
|
37
|
+
return json.load(r)
|
|
38
|
+
|
|
39
|
+
def start(self, task: str, tool_schemas: list[dict]) -> None:
|
|
40
|
+
self.tools = [{"type": "function", "function": {"name": s["name"], "description": s["description"],
|
|
41
|
+
"parameters": s["input_schema"]}} for s in tool_schemas]
|
|
42
|
+
self.messages = [{"role": "system", "content": self.system_prompt}, {"role": "user", "content": task}]
|
|
43
|
+
self._pending_call_id, self._over = None, False
|
|
44
|
+
|
|
45
|
+
def next_action(self) -> AgentAction:
|
|
46
|
+
if self._over:
|
|
47
|
+
return AgentAction(kind="final_answer", text="")
|
|
48
|
+
t0 = time.monotonic()
|
|
49
|
+
data = self._post({"model": self.model, "messages": self.messages, "tools": self.tools,
|
|
50
|
+
"parallel_tool_calls": False, "temperature": 0, "max_tokens": self.max_tokens})
|
|
51
|
+
msg = data["choices"][0]["message"]
|
|
52
|
+
usage = data.get("usage") or {}
|
|
53
|
+
cost = usage.get("cost")
|
|
54
|
+
if cost is None:
|
|
55
|
+
cost = (usage.get("prompt_tokens", 0) * self.price[0] + usage.get("completion_tokens", 0) * self.price[1]) / 1e6
|
|
56
|
+
lat = time.monotonic() - t0
|
|
57
|
+
calls = msg.get("tool_calls") or []
|
|
58
|
+
if calls:
|
|
59
|
+
call = calls[0]
|
|
60
|
+
self.messages.append({"role": "assistant", "content": msg.get("content"), "tool_calls": [call]})
|
|
61
|
+
self._pending_call_id = call["id"]
|
|
62
|
+
try:
|
|
63
|
+
args = json.loads(call["function"].get("arguments") or "{}")
|
|
64
|
+
except json.JSONDecodeError:
|
|
65
|
+
args = {}
|
|
66
|
+
return AgentAction(kind="tool_call", tool_name=call["function"]["name"], tool_args=args,
|
|
67
|
+
latency_s=lat, cost_usd=cost)
|
|
68
|
+
text = msg.get("content") or ""
|
|
69
|
+
self.messages.append({"role": "assistant", "content": text})
|
|
70
|
+
self._pending_call_id = None
|
|
71
|
+
# tau semantics: plain text is a reply to the customer.
|
|
72
|
+
return AgentAction(kind="tool_call", tool_name="respond", tool_args={"content": text}, latency_s=lat, cost_usd=cost)
|
|
73
|
+
|
|
74
|
+
def observe(self, tool_name: str, tool_args: dict, result: Any, ok: bool) -> None:
|
|
75
|
+
text = str(result) if ok else f"Error: {result.get('error', result)}"
|
|
76
|
+
if STOP in text or tool_name == "transfer_to_human_agents":
|
|
77
|
+
self._over = True
|
|
78
|
+
if self._pending_call_id is None: # plain-text reply -> customer's answer is a user turn
|
|
79
|
+
self.messages.append({"role": "user", "content": text})
|
|
80
|
+
else:
|
|
81
|
+
self.messages.append({"role": "tool", "tool_call_id": self._pending_call_id, "content": text})
|
|
82
|
+
self._pending_call_id = None
|
|
83
|
+
|
|
84
|
+
def receive_message(self, text: str) -> None:
|
|
85
|
+
self.messages.append({"role": "user", "content": text})
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Ground-truth grading, a port of tau-bench Env.calculate_reward: replay the
|
|
2
|
+
task's annotated actions on a fresh DB, compare DB hashes, then check required
|
|
3
|
+
output strings. Plus a row-level diff so a failure says WHAT differed.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
|
|
10
|
+
from agentprobe.domains.tau_retail import loader
|
|
11
|
+
from agentprobe.domains.tau_retail.world import TauWorld, data_hash
|
|
12
|
+
|
|
13
|
+
_TERMINATE = {"transfer_to_human_agents"}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class Grade:
|
|
18
|
+
reward: float
|
|
19
|
+
db_match: bool
|
|
20
|
+
outputs_ok: bool
|
|
21
|
+
missing_outputs: list[str] = field(default_factory=list)
|
|
22
|
+
changed_rows: list[str] = field(default_factory=list)
|
|
23
|
+
"""Rows (table/id) whose final state differs from ground truth, either way."""
|
|
24
|
+
unexpected_writes: list[str] = field(default_factory=list)
|
|
25
|
+
"""Rows the agent changed that ground truth leaves untouched."""
|
|
26
|
+
missing_writes: list[str] = field(default_factory=list)
|
|
27
|
+
"""Rows ground truth changes that the agent left untouched."""
|
|
28
|
+
|
|
29
|
+
def as_dict(self) -> dict:
|
|
30
|
+
return dict(self.__dict__)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def ground_truth_data(task: loader.Task, tau: loader.TauRetail) -> dict:
|
|
34
|
+
data = tau.fresh_data()
|
|
35
|
+
for action in task.actions:
|
|
36
|
+
if action.name == "respond" or action.name in _TERMINATE:
|
|
37
|
+
continue
|
|
38
|
+
try:
|
|
39
|
+
tau.tools[action.name].invoke(data=data, **action.kwargs)
|
|
40
|
+
except Exception:
|
|
41
|
+
pass # tau swallows these into an "Error:" observation too
|
|
42
|
+
return data
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _row_diff(a: dict, b: dict) -> set[str]:
|
|
46
|
+
out = set()
|
|
47
|
+
for table in set(a) | set(b):
|
|
48
|
+
ra, rb = a.get(table, {}), b.get(table, {})
|
|
49
|
+
for k in set(ra) | set(rb):
|
|
50
|
+
if ra.get(k) != rb.get(k):
|
|
51
|
+
out.add(f"{table}/{k}")
|
|
52
|
+
return out
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def grade(world: TauWorld, task: loader.Task, tau: loader.TauRetail) -> Grade:
|
|
56
|
+
gt = ground_truth_data(task, tau)
|
|
57
|
+
db_match = data_hash(world.data) == data_hash(gt)
|
|
58
|
+
outputs_ok, missing = True, []
|
|
59
|
+
said = [m.lower().replace(",", "") for m in world.agent_messages]
|
|
60
|
+
for out in task.outputs:
|
|
61
|
+
if not any(out.lower() in m for m in said):
|
|
62
|
+
outputs_ok = False
|
|
63
|
+
missing.append(out)
|
|
64
|
+
changed = _row_diff(world.data, gt)
|
|
65
|
+
original = tau.fresh_data()
|
|
66
|
+
by_agent, by_gt = _row_diff(world.data, original), _row_diff(gt, original)
|
|
67
|
+
return Grade(
|
|
68
|
+
reward=1.0 if (db_match and outputs_ok) else 0.0,
|
|
69
|
+
db_match=db_match,
|
|
70
|
+
outputs_ok=outputs_ok,
|
|
71
|
+
missing_outputs=missing,
|
|
72
|
+
changed_rows=sorted(changed),
|
|
73
|
+
unexpected_writes=sorted(by_agent - by_gt),
|
|
74
|
+
missing_writes=sorted(by_gt - by_agent),
|
|
75
|
+
)
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""TauToolkit: Sierra's own tool code run against a TauWorld.
|
|
2
|
+
|
|
3
|
+
Differences from tau-bench's agent loop, all deliberate and listed in the
|
|
4
|
+
README: (1) talking to the customer is a `respond` tool (tau uses a plain
|
|
5
|
+
assistant message) so AgentProbe's tool-call engine can drive the whole
|
|
6
|
+
conversation; (2) a tool result that starts with "Error" is raised as ToolError
|
|
7
|
+
so the engine records the call as failed, exactly like tau's own observation.
|
|
8
|
+
The text the agent sees is unchanged.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
from typing import Any, Optional
|
|
15
|
+
|
|
16
|
+
from agentprobe.domains.tau_retail import loader
|
|
17
|
+
from agentprobe.domains.tau_retail.world import TauWorld
|
|
18
|
+
from agentprobe.tools import ToolError
|
|
19
|
+
|
|
20
|
+
STOP = "###STOP###"
|
|
21
|
+
RESPOND_SCHEMA = {
|
|
22
|
+
"name": "respond",
|
|
23
|
+
"description": "Send a message to the customer and wait for their reply. This is the only way to talk to the customer.",
|
|
24
|
+
"input_schema": {
|
|
25
|
+
"type": "object",
|
|
26
|
+
"properties": {"content": {"type": "string", "description": "The message to the customer."}},
|
|
27
|
+
"required": ["content"],
|
|
28
|
+
},
|
|
29
|
+
}
|
|
30
|
+
_TERMINATE = {"transfer_to_human_agents"}
|
|
31
|
+
_NOTE_KEYS = {"get_order_details": ("order", "order_id"), "get_user_details": ("user", "user_id")}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def tool_schemas(tau: Optional[loader.TauRetail] = None) -> list[dict]:
|
|
35
|
+
tau = tau or loader.load()
|
|
36
|
+
return [RESPOND_SCHEMA] + tau.tool_schemas
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class TauToolkit:
|
|
40
|
+
def __init__(self, world: TauWorld, user: Any, tau: Optional[loader.TauRetail] = None):
|
|
41
|
+
self.world = world
|
|
42
|
+
self.user = user
|
|
43
|
+
self.tau = tau or loader.load()
|
|
44
|
+
|
|
45
|
+
def canonicalize_args(self, tool_name: str, args: dict) -> dict:
|
|
46
|
+
return args
|
|
47
|
+
|
|
48
|
+
def call(self, name: str, args: dict) -> Any:
|
|
49
|
+
if name == "respond":
|
|
50
|
+
return self._respond(args)
|
|
51
|
+
tool = self.tau.tools.get(name)
|
|
52
|
+
if tool is None:
|
|
53
|
+
raise ToolError(f"Unknown action {name}")
|
|
54
|
+
try:
|
|
55
|
+
out = tool.invoke(data=self.world.data, **args)
|
|
56
|
+
except Exception as e: # tau's Env.step turns any exception into this string
|
|
57
|
+
out = f"Error: {e}"
|
|
58
|
+
if name in ("find_user_id_by_email", "find_user_id_by_name_zip") and isinstance(out, str) and out in self.world.data["users"]:
|
|
59
|
+
self.world.focus_user = out
|
|
60
|
+
if name in _TERMINATE:
|
|
61
|
+
self.world.conversation_over = True
|
|
62
|
+
if isinstance(out, str) and out.startswith("Error"):
|
|
63
|
+
raise ToolError(out[len("Error"):].lstrip(": ").strip() or "error")
|
|
64
|
+
return self._with_notes(name, args, out)
|
|
65
|
+
|
|
66
|
+
def _respond(self, args: dict) -> str:
|
|
67
|
+
content = args.get("content")
|
|
68
|
+
if not isinstance(content, str):
|
|
69
|
+
raise ToolError("respond requires a string `content`")
|
|
70
|
+
self.world.agent_messages.append(content)
|
|
71
|
+
reply = self.user.step(content)
|
|
72
|
+
if STOP in reply:
|
|
73
|
+
self.world.conversation_over = True
|
|
74
|
+
return reply
|
|
75
|
+
|
|
76
|
+
def _with_notes(self, name: str, args: dict, out: Any) -> Any:
|
|
77
|
+
spec = _NOTE_KEYS.get(name)
|
|
78
|
+
if not spec or not isinstance(out, str):
|
|
79
|
+
return out
|
|
80
|
+
etype, arg = spec
|
|
81
|
+
notes = self.world.notes.get((etype, str(args.get(arg))))
|
|
82
|
+
if not notes:
|
|
83
|
+
return out
|
|
84
|
+
return out + "".join(f"\n[note] {n}" for n in notes)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def commit_tools(tau: Optional[loader.TauRetail] = None) -> frozenset:
|
|
88
|
+
return (tau or loader.load()).commit_tool_names()
|