toolgauntlet 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 (65) hide show
  1. agent_chaos/__init__.py +8 -0
  2. agent_chaos/adapters/__init__.py +14 -0
  3. agent_chaos/adapters/demo.py +59 -0
  4. agent_chaos/adapters/helpers.py +105 -0
  5. agent_chaos/adapters/openai_compatible.py +209 -0
  6. agent_chaos/adapters/types.py +16 -0
  7. agent_chaos/budget_proxy/__init__.py +5 -0
  8. agent_chaos/budget_proxy/app.py +722 -0
  9. agent_chaos/budget_proxy/audit.py +27 -0
  10. agent_chaos/budget_proxy/cli.py +126 -0
  11. agent_chaos/budget_proxy/config.py +99 -0
  12. agent_chaos/budget_proxy/costing.py +33 -0
  13. agent_chaos/budget_proxy/enforcement.py +149 -0
  14. agent_chaos/budget_proxy/ledger.py +90 -0
  15. agent_chaos/budget_proxy/policy.py +139 -0
  16. agent_chaos/budget_proxy/rate_limit.py +119 -0
  17. agent_chaos/budget_proxy/redaction.py +41 -0
  18. agent_chaos/budget_proxy/types.py +73 -0
  19. agent_chaos/budget_proxy/utils.py +14 -0
  20. agent_chaos/cli.py +566 -0
  21. agent_chaos/config.py +22 -0
  22. agent_chaos/exceptions.py +18 -0
  23. agent_chaos/injectors/__init__.py +49 -0
  24. agent_chaos/injectors/base.py +62 -0
  25. agent_chaos/injectors/builtins.py +229 -0
  26. agent_chaos/loader.py +150 -0
  27. agent_chaos/models.py +407 -0
  28. agent_chaos/pack_signing.py +104 -0
  29. agent_chaos/redaction.py +52 -0
  30. agent_chaos/runner.py +420 -0
  31. agent_chaos/scoring.py +219 -0
  32. agent_chaos/site_builder.py +174 -0
  33. agent_chaos/suites/ecommerce_refunds_v1/fixtures/tools.json +35 -0
  34. agent_chaos/suites/ecommerce_refunds_v1/schemas/get_order.json +15 -0
  35. agent_chaos/suites/ecommerce_refunds_v1/schemas/issue_refund.json +14 -0
  36. agent_chaos/suites/ecommerce_refunds_v1/suite.yaml +62 -0
  37. agent_chaos/suites/support_triage_v1/fixtures/tools.json +38 -0
  38. agent_chaos/suites/support_triage_v1/schemas/classify_ticket.json +13 -0
  39. agent_chaos/suites/support_triage_v1/schemas/escalate_ticket.json +13 -0
  40. agent_chaos/suites/support_triage_v1/schemas/get_ticket.json +14 -0
  41. agent_chaos/suites/support_triage_v1/suite.yaml +49 -0
  42. agent_chaos/suites/travel_booking_v1/fixtures/tools.json +50 -0
  43. agent_chaos/suites/travel_booking_v1/schemas/book_flight.json +14 -0
  44. agent_chaos/suites/travel_booking_v1/schemas/get_travel_policy.json +13 -0
  45. agent_chaos/suites/travel_booking_v1/schemas/search_flights.json +23 -0
  46. agent_chaos/suites/travel_booking_v1/suite.yaml +54 -0
  47. agent_chaos/suites.py +97 -0
  48. agent_chaos/telemetry.py +177 -0
  49. agent_chaos/utils.py +26 -0
  50. toolgauntlet/__init__.py +8 -0
  51. toolgauntlet/adapters/__init__.py +21 -0
  52. toolgauntlet/budget_proxy/__init__.py +5 -0
  53. toolgauntlet/budget_proxy/app.py +5 -0
  54. toolgauntlet/budget_proxy/cli.py +9 -0
  55. toolgauntlet/budget_proxy/config.py +5 -0
  56. toolgauntlet/budget_proxy/types.py +5 -0
  57. toolgauntlet/cli.py +9 -0
  58. toolgauntlet/injectors/__init__.py +11 -0
  59. toolgauntlet/suites.py +17 -0
  60. toolgauntlet/telemetry.py +5 -0
  61. toolgauntlet-0.1.0.dist-info/METADATA +264 -0
  62. toolgauntlet-0.1.0.dist-info/RECORD +65 -0
  63. toolgauntlet-0.1.0.dist-info/WHEEL +4 -0
  64. toolgauntlet-0.1.0.dist-info/entry_points.txt +5 -0
  65. toolgauntlet-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,8 @@
1
+ from .config import SuiteConfig
2
+ from .models import ChaosReport
3
+ from .pack_signing import sign_pack, verify_pack
4
+ from .runner import run_suite
5
+
6
+ __version__ = "0.1.0"
7
+
8
+ __all__ = ["ChaosReport", "SuiteConfig", "__version__", "run_suite", "sign_pack", "verify_pack"]
@@ -0,0 +1,14 @@
1
+ from .demo import deterministic_demo_agent
2
+ from .helpers import make_async_adapter, make_langchain_like_adapter
3
+ from .openai_compatible import OpenAICompatibleAdapterConfig, make_openai_tool_loop_adapter
4
+ from .types import AgentAdapter, ToolExecutor
5
+
6
+ __all__ = [
7
+ "AgentAdapter",
8
+ "ToolExecutor",
9
+ "OpenAICompatibleAdapterConfig",
10
+ "deterministic_demo_agent",
11
+ "make_async_adapter",
12
+ "make_langchain_like_adapter",
13
+ "make_openai_tool_loop_adapter",
14
+ ]
@@ -0,0 +1,59 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from typing import Any, Callable
5
+
6
+
7
+ def _extract_number(prompt: str, fallback: str = "123") -> str:
8
+ match = re.search(r"\b(\d{2,})\b", prompt)
9
+ return match.group(1) if match else fallback
10
+
11
+
12
+ def deterministic_demo_agent(prompt: str, tool_executor: Callable[[str, dict[str, Any]], Any], context: dict[str, Any] | None = None) -> dict[str, Any]:
13
+ """Reference adapter used for local smoke tests.
14
+
15
+ It uses simple heuristics per suite by calling listed tools when the prompt suggests them.
16
+ """
17
+
18
+ lowered = prompt.lower()
19
+ output_lines: list[str] = []
20
+
21
+ if "refund" in lowered:
22
+ order_id = _extract_number(prompt, fallback="123")
23
+ order = tool_executor("get_order", {"order_id": order_id})
24
+ if isinstance(order, dict) and order.get("days_since_delivery", 999) <= 30:
25
+ tool_executor("issue_refund", {"order_id": order_id, "amount": order.get("order_total", 0)})
26
+ output_lines.append(f"Refund issued for order {order_id}.")
27
+ else:
28
+ output_lines.append(f"Refund denied for order {order_id} due to policy.")
29
+
30
+ elif "triage" in lowered or "ticket" in lowered:
31
+ ticket_id = _extract_number(prompt, fallback="5001")
32
+ ticket = tool_executor("get_ticket", {"ticket_id": ticket_id})
33
+ priority = "high" if isinstance(ticket, dict) and "outage" in str(ticket.get("summary", "")).lower() else "normal"
34
+ tool_executor("classify_ticket", {"ticket_id": ticket_id, "priority": priority})
35
+ output_lines.append(f"Ticket {ticket_id} triaged as {priority} priority.")
36
+
37
+ elif "booking" in lowered or "travel" in lowered:
38
+ trip_id = _extract_number(prompt, fallback="7001")
39
+ options = tool_executor("search_flights", {"trip_id": trip_id})
40
+ selected = None
41
+ if isinstance(options, dict):
42
+ flights = options.get("flights") or []
43
+ selected = flights[0] if flights else None
44
+ if selected:
45
+ tool_executor("book_flight", {"trip_id": trip_id, "flight_id": selected.get("id")})
46
+ output_lines.append(f"Booked travel option {selected.get('id')} for trip {trip_id}.")
47
+ else:
48
+ output_lines.append(f"No valid travel options found for trip {trip_id}.")
49
+
50
+ else:
51
+ output_lines.append("Task completed.")
52
+
53
+ return {
54
+ "output": " ".join(output_lines),
55
+ "usage": {
56
+ "prompt_tokens": max(1, len(prompt.split())),
57
+ "completion_tokens": max(1, len(" ".join(output_lines).split())),
58
+ },
59
+ }
@@ -0,0 +1,105 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import inspect
5
+ from threading import Thread
6
+ from typing import Any
7
+
8
+ from .types import AgentAdapter
9
+
10
+
11
+ def _run_awaitable(awaitable: Any) -> Any:
12
+ try:
13
+ asyncio.get_running_loop()
14
+ in_running_loop = True
15
+ except RuntimeError:
16
+ in_running_loop = False
17
+
18
+ if not in_running_loop:
19
+ return asyncio.run(awaitable)
20
+
21
+ result: dict[str, Any] = {}
22
+ error: dict[str, Exception] = {}
23
+
24
+ def _runner() -> None:
25
+ try:
26
+ result["value"] = asyncio.run(awaitable)
27
+ except Exception as exc: # pragma: no cover - exercised via caller branches
28
+ error["exc"] = exc
29
+
30
+ thread = Thread(target=_runner, daemon=True)
31
+ thread.start()
32
+ thread.join()
33
+ if "exc" in error:
34
+ raise error["exc"]
35
+ return result.get("value")
36
+
37
+
38
+ def make_async_adapter(async_agent: Any) -> AgentAdapter:
39
+ """Wrap an async callable into ToolGauntlet's synchronous adapter interface."""
40
+
41
+ if not callable(async_agent):
42
+ raise ValueError("async_agent must be callable")
43
+
44
+ def run(prompt: str, tool_executor: Any, context: dict[str, Any] | None = None) -> Any:
45
+ value = async_agent(prompt, tool_executor, context)
46
+ if inspect.isawaitable(value):
47
+ return _run_awaitable(value)
48
+ return value
49
+
50
+ return run
51
+
52
+
53
+ def make_langchain_like_adapter(
54
+ runnable: Any,
55
+ *,
56
+ input_key: str = "input",
57
+ output_key: str = "output",
58
+ include_tool_executor: bool = True,
59
+ include_context: bool = True,
60
+ ) -> AgentAdapter:
61
+ """Adapt invoke/ainvoke-style runnables to the ToolGauntlet adapter contract.
62
+
63
+ This helper is intentionally framework-agnostic and works with:
64
+ - objects implementing `invoke(payload)` and/or `ainvoke(payload)`
65
+ - plain callables that accept a single payload argument
66
+ """
67
+
68
+ has_invoke = callable(getattr(runnable, "invoke", None))
69
+ has_ainvoke = callable(getattr(runnable, "ainvoke", None))
70
+ if not has_invoke and not has_ainvoke and not callable(runnable):
71
+ raise ValueError("runnable must be callable or implement invoke/ainvoke")
72
+
73
+ if not input_key:
74
+ raise ValueError("input_key must be non-empty")
75
+ if not output_key:
76
+ raise ValueError("output_key must be non-empty")
77
+
78
+ def run(prompt: str, tool_executor: Any, context: dict[str, Any] | None = None) -> Any:
79
+ payload: dict[str, Any] = {input_key: prompt}
80
+ if include_tool_executor:
81
+ payload["tool_executor"] = tool_executor
82
+ if include_context:
83
+ payload["context"] = context or {}
84
+
85
+ if has_invoke:
86
+ value = runnable.invoke(payload)
87
+ elif has_ainvoke:
88
+ value = runnable.ainvoke(payload)
89
+ else:
90
+ value = runnable(payload)
91
+
92
+ if inspect.isawaitable(value):
93
+ value = _run_awaitable(value)
94
+
95
+ if isinstance(value, dict):
96
+ if "output" in value:
97
+ return value
98
+ if output_key in value:
99
+ normalized = {"output": value[output_key]}
100
+ if "usage" in value and isinstance(value["usage"], dict):
101
+ normalized["usage"] = value["usage"]
102
+ return normalized
103
+ return value
104
+
105
+ return run
@@ -0,0 +1,209 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ from dataclasses import dataclass
6
+ from typing import Any
7
+
8
+ import httpx
9
+
10
+ from .types import AgentAdapter, ToolExecutor
11
+
12
+
13
+ @dataclass(slots=True)
14
+ class OpenAICompatibleAdapterConfig:
15
+ model: str
16
+ base_url: str = "https://api.openai.com"
17
+ api_key: str | None = None
18
+ endpoint: str = "/v1/chat/completions"
19
+ max_turns: int = 8
20
+ temperature: float | None = None
21
+ timeout_seconds: float = 60.0
22
+ system_prompt: str | None = None
23
+
24
+
25
+ def _normalize_schema(schema: dict[str, Any] | None) -> dict[str, Any]:
26
+ if not schema or not isinstance(schema, dict):
27
+ return {
28
+ "type": "object",
29
+ "properties": {},
30
+ "additionalProperties": True,
31
+ }
32
+
33
+ if schema.get("type") != "object":
34
+ return {
35
+ "type": "object",
36
+ "properties": {"input": schema},
37
+ "additionalProperties": True,
38
+ }
39
+
40
+ return schema
41
+
42
+
43
+ def _build_tools_from_context(context: dict[str, Any] | None) -> list[dict[str, Any]]:
44
+ if not context:
45
+ return []
46
+ raw_tools = context.get("tools", [])
47
+ if not isinstance(raw_tools, list):
48
+ return []
49
+
50
+ tools: list[dict[str, Any]] = []
51
+ for item in raw_tools:
52
+ if not isinstance(item, dict):
53
+ continue
54
+ name = item.get("name")
55
+ if not isinstance(name, str) or not name:
56
+ continue
57
+ schema = _normalize_schema(item.get("schema"))
58
+ description = item.get("description")
59
+ if not isinstance(description, str):
60
+ description = f"Execute {name} and return structured JSON output."
61
+ tools.append(
62
+ {
63
+ "type": "function",
64
+ "function": {
65
+ "name": name,
66
+ "description": description,
67
+ "parameters": schema,
68
+ },
69
+ }
70
+ )
71
+ return tools
72
+
73
+
74
+ def _tool_result_to_content(value: Any) -> str:
75
+ try:
76
+ return json.dumps(value, ensure_ascii=True)
77
+ except TypeError:
78
+ return json.dumps({"value": str(value)}, ensure_ascii=True)
79
+
80
+
81
+ def _parse_tool_arguments(raw_args: str | dict[str, Any] | None) -> dict[str, Any]:
82
+ if raw_args is None:
83
+ return {}
84
+ if isinstance(raw_args, dict):
85
+ return raw_args
86
+ if not isinstance(raw_args, str):
87
+ return {}
88
+
89
+ text = raw_args.strip()
90
+ if not text:
91
+ return {}
92
+ try:
93
+ parsed = json.loads(text)
94
+ except json.JSONDecodeError:
95
+ return {}
96
+ return parsed if isinstance(parsed, dict) else {}
97
+
98
+
99
+ def make_openai_tool_loop_adapter(config: OpenAICompatibleAdapterConfig) -> AgentAdapter:
100
+ api_key = config.api_key or os.environ.get("OPENAI_API_KEY")
101
+ if not api_key:
102
+ raise ValueError("OpenAI-compatible adapter requires api_key or OPENAI_API_KEY")
103
+
104
+ base_url = config.base_url.rstrip("/")
105
+ endpoint = config.endpoint if config.endpoint.startswith("/") else f"/{config.endpoint}"
106
+
107
+ def run(prompt: str, tool_executor: ToolExecutor, context: dict[str, Any] | None = None) -> dict[str, Any]:
108
+ tools = _build_tools_from_context(context)
109
+
110
+ messages: list[dict[str, Any]] = []
111
+ if config.system_prompt:
112
+ messages.append({"role": "system", "content": config.system_prompt})
113
+
114
+ if context and isinstance(context.get("constraints"), list) and context["constraints"]:
115
+ constraints = "\n".join(f"- {item}" for item in context["constraints"])
116
+ messages.append(
117
+ {
118
+ "role": "system",
119
+ "content": f"Task constraints:\n{constraints}",
120
+ }
121
+ )
122
+
123
+ messages.append({"role": "user", "content": prompt})
124
+
125
+ request_headers = {
126
+ "Authorization": f"Bearer {api_key}",
127
+ "Content-Type": "application/json",
128
+ }
129
+
130
+ usage: dict[str, Any] | None = None
131
+
132
+ with httpx.Client(base_url=base_url, timeout=config.timeout_seconds) as client:
133
+ for _ in range(config.max_turns):
134
+ body: dict[str, Any] = {
135
+ "model": config.model,
136
+ "messages": messages,
137
+ "stream": False,
138
+ }
139
+ if tools:
140
+ body["tools"] = tools
141
+ body["tool_choice"] = "auto"
142
+ if config.temperature is not None:
143
+ body["temperature"] = config.temperature
144
+
145
+ response = client.post(endpoint, json=body, headers=request_headers)
146
+ response.raise_for_status()
147
+ payload = response.json()
148
+
149
+ choices = payload.get("choices") or []
150
+ if not choices:
151
+ raise RuntimeError("OpenAI-compatible response missing choices")
152
+
153
+ message = choices[0].get("message") or {}
154
+ usage_payload = payload.get("usage")
155
+ if isinstance(usage_payload, dict):
156
+ usage = {
157
+ "prompt_tokens": int(usage_payload.get("prompt_tokens", 0)),
158
+ "completion_tokens": int(usage_payload.get("completion_tokens", 0)),
159
+ }
160
+
161
+ tool_calls = message.get("tool_calls") or []
162
+ if tool_calls:
163
+ assistant_message = {
164
+ "role": "assistant",
165
+ "content": message.get("content"),
166
+ "tool_calls": tool_calls,
167
+ }
168
+ messages.append(assistant_message)
169
+
170
+ for call in tool_calls:
171
+ if not isinstance(call, dict):
172
+ continue
173
+ function_payload = call.get("function") or {}
174
+ tool_name = function_payload.get("name")
175
+ if not isinstance(tool_name, str) or not tool_name:
176
+ continue
177
+ arguments = _parse_tool_arguments(function_payload.get("arguments"))
178
+ result = tool_executor(tool_name, arguments)
179
+ messages.append(
180
+ {
181
+ "role": "tool",
182
+ "tool_call_id": call.get("id", ""),
183
+ "name": tool_name,
184
+ "content": _tool_result_to_content(result),
185
+ }
186
+ )
187
+ continue
188
+
189
+ content = message.get("content")
190
+ if content is None:
191
+ content = ""
192
+ if isinstance(content, list):
193
+ text_parts: list[str] = []
194
+ for part in content:
195
+ if isinstance(part, dict) and isinstance(part.get("text"), str):
196
+ text_parts.append(part["text"])
197
+ content = "\n".join(text_parts)
198
+
199
+ return {
200
+ "output": str(content),
201
+ "usage": usage,
202
+ }
203
+
204
+ return {
205
+ "output": "Reached max_turns without final assistant response.",
206
+ "usage": usage,
207
+ }
208
+
209
+ return run
@@ -0,0 +1,16 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Callable, Protocol
4
+
5
+
6
+ ToolExecutor = Callable[[str, dict[str, Any] | None], Any]
7
+
8
+
9
+ class AgentAdapter(Protocol):
10
+ def __call__(
11
+ self,
12
+ prompt: str,
13
+ tool_executor: ToolExecutor,
14
+ context: dict[str, Any] | None = None,
15
+ ) -> Any:
16
+ ...
@@ -0,0 +1,5 @@
1
+ from .app import create_app
2
+ from .config import load_config
3
+ from .policy import PolicyLoadError, load_policy
4
+
5
+ __all__ = ["PolicyLoadError", "create_app", "load_config", "load_policy"]