python-yama 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.
- python_yama-0.1.0.dist-info/METADATA +98 -0
- python_yama-0.1.0.dist-info/RECORD +27 -0
- python_yama-0.1.0.dist-info/WHEEL +4 -0
- python_yama-0.1.0.dist-info/entry_points.txt +2 -0
- python_yama-0.1.0.dist-info/licenses/LICENSE +21 -0
- yama/__init__.py +31 -0
- yama/__main__.py +3 -0
- yama/assertions.py +577 -0
- yama/cli.py +318 -0
- yama/llm.py +365 -0
- yama/loaders/__init__.py +6 -0
- yama/loaders/case.py +378 -0
- yama/loaders/common.py +29 -0
- yama/loaders/tool_loader.py +173 -0
- yama/mocks.py +383 -0
- yama/models.py +159 -0
- yama/report.py +854 -0
- yama/runner.py +531 -0
- yama/tools/__init__.py +0 -0
- yama/tools/base.py +19 -0
- yama/tools/bash/__init__.py +0 -0
- yama/tools/bash/_cli_shim.py +71 -0
- yama/tools/bash/bash.py +285 -0
- yama/tools/bash/fs.py +530 -0
- yama/tools/skill/__init__.py +0 -0
- yama/tools/skill/skill.py +57 -0
- yama/workspace.py +115 -0
yama/mocks.py
ADDED
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import importlib
|
|
4
|
+
import importlib.util
|
|
5
|
+
import inspect
|
|
6
|
+
import json
|
|
7
|
+
import time
|
|
8
|
+
from copy import deepcopy
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from types import ModuleType
|
|
12
|
+
from typing import Any, Callable, Sequence
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ToolMockError(Exception):
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
code: str,
|
|
19
|
+
message: str,
|
|
20
|
+
details: Any = None,
|
|
21
|
+
retryable: bool = False,
|
|
22
|
+
) -> None:
|
|
23
|
+
super().__init__(message)
|
|
24
|
+
self.code = code
|
|
25
|
+
self.message = message
|
|
26
|
+
self.details = details
|
|
27
|
+
self.retryable = retryable
|
|
28
|
+
|
|
29
|
+
def as_result(self) -> dict[str, Any]:
|
|
30
|
+
return {
|
|
31
|
+
"error": {
|
|
32
|
+
"code": self.code,
|
|
33
|
+
"message": self.message,
|
|
34
|
+
"details": self.details,
|
|
35
|
+
"retryable": self.retryable,
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class MockConfigurationError(ValueError):
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class MockInvocationError(RuntimeError):
|
|
45
|
+
pass
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class MockImplementationError(RuntimeError):
|
|
49
|
+
pass
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class ToolMockContext:
|
|
54
|
+
plugin_root: Path
|
|
55
|
+
case_key: str
|
|
56
|
+
run_index: int
|
|
57
|
+
step_id: str
|
|
58
|
+
step_index: int
|
|
59
|
+
request_index: int
|
|
60
|
+
tool_call_id: str
|
|
61
|
+
call_index: int
|
|
62
|
+
transcript: Sequence[dict[str, Any]]
|
|
63
|
+
state: dict[str, Any]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def load_python_callable(
|
|
67
|
+
reference: str,
|
|
68
|
+
plugin_root: Path,
|
|
69
|
+
*,
|
|
70
|
+
relative_root: Path | None = None,
|
|
71
|
+
) -> Callable[..., Any]:
|
|
72
|
+
if ":" not in reference:
|
|
73
|
+
raise MockConfigurationError(f"Python callable must contain ':': {reference}")
|
|
74
|
+
location, attribute = reference.rsplit(":", 1)
|
|
75
|
+
if not attribute:
|
|
76
|
+
raise MockConfigurationError(f"Python callable name is missing: {reference}")
|
|
77
|
+
module: ModuleType
|
|
78
|
+
if location.endswith(".py") or "/" in location or "\\" in location:
|
|
79
|
+
path = ((relative_root or plugin_root) / location).resolve()
|
|
80
|
+
if not path.is_relative_to(plugin_root.resolve()):
|
|
81
|
+
raise MockConfigurationError(
|
|
82
|
+
f"Python mock must be inside plugin root: {path}"
|
|
83
|
+
)
|
|
84
|
+
if not path.is_file() or path.suffix != ".py":
|
|
85
|
+
raise MockConfigurationError(f"Python mock file does not exist: {path}")
|
|
86
|
+
module_name = f"yama_plugin_mock_{abs(hash(path))}"
|
|
87
|
+
spec = importlib.util.spec_from_file_location(module_name, path)
|
|
88
|
+
if spec is None or spec.loader is None:
|
|
89
|
+
raise MockConfigurationError(f"cannot load Python mock: {path}")
|
|
90
|
+
module = importlib.util.module_from_spec(spec)
|
|
91
|
+
spec.loader.exec_module(module)
|
|
92
|
+
else:
|
|
93
|
+
try:
|
|
94
|
+
module = importlib.import_module(location)
|
|
95
|
+
except ImportError as exc:
|
|
96
|
+
raise MockConfigurationError(
|
|
97
|
+
f"cannot import Python mock module: {location}"
|
|
98
|
+
) from exc
|
|
99
|
+
target = getattr(module, attribute, None)
|
|
100
|
+
if not callable(target):
|
|
101
|
+
raise MockConfigurationError(f"Python mock target is not callable: {reference}")
|
|
102
|
+
return target
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _validate_handler_signature(target: Callable[..., Any], reference: str) -> None:
|
|
106
|
+
signature = inspect.signature(target)
|
|
107
|
+
try:
|
|
108
|
+
signature.bind(arguments={}, context=None, config={})
|
|
109
|
+
except TypeError as exc:
|
|
110
|
+
raise MockConfigurationError(
|
|
111
|
+
f"Python mock {reference} must accept keyword arguments, context and config"
|
|
112
|
+
) from exc
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _json_clone(value: Any, label: str) -> Any:
|
|
116
|
+
try:
|
|
117
|
+
return json.loads(json.dumps(value, ensure_ascii=False))
|
|
118
|
+
except (TypeError, ValueError) as exc:
|
|
119
|
+
raise MockImplementationError(
|
|
120
|
+
f"{label} must be JSON serializable: {exc}"
|
|
121
|
+
) from exc
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _matches(actual: Any, expected: Any) -> bool:
|
|
125
|
+
if isinstance(expected, dict):
|
|
126
|
+
matcher_keys = {
|
|
127
|
+
"equals",
|
|
128
|
+
"contains",
|
|
129
|
+
"matches",
|
|
130
|
+
"is_instance",
|
|
131
|
+
"exists",
|
|
132
|
+
"one_of",
|
|
133
|
+
}
|
|
134
|
+
selected = matcher_keys.intersection(expected)
|
|
135
|
+
if selected:
|
|
136
|
+
if len(selected) != 1:
|
|
137
|
+
raise MockConfigurationError("a matcher must use exactly one operation")
|
|
138
|
+
operation = next(iter(selected))
|
|
139
|
+
wanted = expected[operation]
|
|
140
|
+
if operation == "equals":
|
|
141
|
+
return actual == wanted
|
|
142
|
+
if operation == "contains":
|
|
143
|
+
return (
|
|
144
|
+
wanted in actual if isinstance(actual, (str, list, dict)) else False
|
|
145
|
+
)
|
|
146
|
+
if operation == "one_of":
|
|
147
|
+
return isinstance(wanted, list) and actual in wanted
|
|
148
|
+
if operation == "matches":
|
|
149
|
+
import re
|
|
150
|
+
|
|
151
|
+
return (
|
|
152
|
+
isinstance(actual, str)
|
|
153
|
+
and re.search(str(wanted), actual) is not None
|
|
154
|
+
)
|
|
155
|
+
if operation == "exists":
|
|
156
|
+
return (actual is not _MISSING) is bool(wanted)
|
|
157
|
+
types = {
|
|
158
|
+
"array": list,
|
|
159
|
+
"object": dict,
|
|
160
|
+
"string": str,
|
|
161
|
+
"number": (int, float),
|
|
162
|
+
"integer": int,
|
|
163
|
+
"boolean": bool,
|
|
164
|
+
"null": type(None),
|
|
165
|
+
}
|
|
166
|
+
return wanted in types and isinstance(actual, types[wanted])
|
|
167
|
+
if not isinstance(actual, dict):
|
|
168
|
+
return False
|
|
169
|
+
return all(
|
|
170
|
+
_matches(actual.get(key, _MISSING), value)
|
|
171
|
+
for key, value in expected.items()
|
|
172
|
+
)
|
|
173
|
+
if isinstance(expected, list):
|
|
174
|
+
return actual == expected
|
|
175
|
+
return actual == expected
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
_MISSING = object()
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _state_diff(before: Any, after: Any, path: str = "$") -> list[dict[str, Any]]:
|
|
182
|
+
if before == after:
|
|
183
|
+
return []
|
|
184
|
+
if isinstance(before, dict) and isinstance(after, dict):
|
|
185
|
+
changes: list[dict[str, Any]] = []
|
|
186
|
+
for key in sorted(set(before) | set(after)):
|
|
187
|
+
child_path = f"{path}.{key}"
|
|
188
|
+
if key not in before:
|
|
189
|
+
changes.append({"op": "add", "path": child_path, "value": after[key]})
|
|
190
|
+
elif key not in after:
|
|
191
|
+
changes.append({"op": "remove", "path": child_path, "old": before[key]})
|
|
192
|
+
else:
|
|
193
|
+
changes.extend(_state_diff(before[key], after[key], child_path))
|
|
194
|
+
return changes
|
|
195
|
+
return [{"op": "replace", "path": path, "old": before, "value": after}]
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
class MockToolRegistry:
|
|
199
|
+
def __init__(
|
|
200
|
+
self,
|
|
201
|
+
config: dict[str, Any] | None,
|
|
202
|
+
*,
|
|
203
|
+
plugin_root: Path,
|
|
204
|
+
relative_root: Path | None = None,
|
|
205
|
+
exposed_tools: set[str],
|
|
206
|
+
) -> None:
|
|
207
|
+
config = config or {}
|
|
208
|
+
if not isinstance(config, dict):
|
|
209
|
+
raise MockConfigurationError("mocks must be an object")
|
|
210
|
+
self.plugin_root = plugin_root
|
|
211
|
+
self.relative_root = relative_root or plugin_root
|
|
212
|
+
unknown = set(config) - {"state", "tools"}
|
|
213
|
+
if unknown:
|
|
214
|
+
hint = (
|
|
215
|
+
"; undeclared tools and unmatched arguments always fail"
|
|
216
|
+
if "strict" in unknown
|
|
217
|
+
else ""
|
|
218
|
+
)
|
|
219
|
+
raise MockConfigurationError(
|
|
220
|
+
f"unknown mock fields: {sorted(unknown)}{hint}"
|
|
221
|
+
)
|
|
222
|
+
initial_state = config.get("state", {})
|
|
223
|
+
if not isinstance(initial_state, dict):
|
|
224
|
+
raise MockConfigurationError("mocks.state must be an object")
|
|
225
|
+
self.state: dict[str, Any] = _json_clone(initial_state, "mocks.state")
|
|
226
|
+
tools = config.get("tools", {})
|
|
227
|
+
if not isinstance(tools, dict):
|
|
228
|
+
raise MockConfigurationError("mocks.tools must be an object")
|
|
229
|
+
extra = set(tools) - exposed_tools
|
|
230
|
+
missing = exposed_tools - set(tools)
|
|
231
|
+
if extra:
|
|
232
|
+
raise MockConfigurationError(
|
|
233
|
+
f"mock handlers are not exposed in context.tools: {sorted(extra)}"
|
|
234
|
+
)
|
|
235
|
+
if missing:
|
|
236
|
+
raise MockConfigurationError(
|
|
237
|
+
f"context tools have no mock handlers: {sorted(missing)}"
|
|
238
|
+
)
|
|
239
|
+
self.tools: dict[str, dict[str, Any]] = deepcopy(tools)
|
|
240
|
+
self.call_counts = {name: 0 for name in tools}
|
|
241
|
+
self.sequence_indexes = {name: 0 for name in tools}
|
|
242
|
+
self.events: list[dict[str, Any]] = []
|
|
243
|
+
self.handlers: dict[str, Callable[..., Any]] = {}
|
|
244
|
+
for name, tool_config in self.tools.items():
|
|
245
|
+
self._validate_tool_config(name, tool_config)
|
|
246
|
+
|
|
247
|
+
def _validate_tool_config(self, name: str, config: Any) -> None:
|
|
248
|
+
if not isinstance(config, dict):
|
|
249
|
+
raise MockConfigurationError(f"mock config must be an object: {name}")
|
|
250
|
+
unknown = set(config) - {"match", "respond", "handler"}
|
|
251
|
+
if unknown:
|
|
252
|
+
hint = (
|
|
253
|
+
"; tool-call expectations belong in assert.hard (tool_called)"
|
|
254
|
+
if "required" in unknown
|
|
255
|
+
else ""
|
|
256
|
+
)
|
|
257
|
+
raise MockConfigurationError(
|
|
258
|
+
f"unknown mock fields for {name}: {sorted(unknown)}{hint}"
|
|
259
|
+
)
|
|
260
|
+
if ("respond" in config) == ("handler" in config):
|
|
261
|
+
raise MockConfigurationError(
|
|
262
|
+
f"mock {name} must choose exactly one of respond or handler"
|
|
263
|
+
)
|
|
264
|
+
if "handler" in config:
|
|
265
|
+
handler = config["handler"]
|
|
266
|
+
if not isinstance(handler, dict) or not isinstance(
|
|
267
|
+
handler.get("python"), str
|
|
268
|
+
):
|
|
269
|
+
raise MockConfigurationError(f"mock {name} handler.python is required")
|
|
270
|
+
unknown = set(handler) - {"python", "with"}
|
|
271
|
+
if unknown:
|
|
272
|
+
raise MockConfigurationError(
|
|
273
|
+
f"unknown handler fields for {name}: {sorted(unknown)}"
|
|
274
|
+
)
|
|
275
|
+
target = load_python_callable(
|
|
276
|
+
handler["python"],
|
|
277
|
+
self.plugin_root,
|
|
278
|
+
relative_root=self.relative_root,
|
|
279
|
+
)
|
|
280
|
+
_validate_handler_signature(target, handler["python"])
|
|
281
|
+
self.handlers[name] = target
|
|
282
|
+
else:
|
|
283
|
+
respond = config["respond"]
|
|
284
|
+
if not isinstance(respond, dict):
|
|
285
|
+
raise MockConfigurationError(f"mock {name} respond must be an object")
|
|
286
|
+
modes = set(respond).intersection({"result", "error", "sequence"})
|
|
287
|
+
if len(modes) != 1:
|
|
288
|
+
raise MockConfigurationError(
|
|
289
|
+
f"mock {name} respond must choose exactly one of result, error or sequence"
|
|
290
|
+
)
|
|
291
|
+
if "sequence" in respond and (
|
|
292
|
+
not isinstance(respond["sequence"], list) or not respond["sequence"]
|
|
293
|
+
):
|
|
294
|
+
raise MockConfigurationError(f"mock {name} sequence must be non-empty")
|
|
295
|
+
|
|
296
|
+
def next_call_index(self, name: str) -> int:
|
|
297
|
+
return self.call_counts.get(name, 0) + 1
|
|
298
|
+
|
|
299
|
+
async def call(
|
|
300
|
+
self,
|
|
301
|
+
name: str,
|
|
302
|
+
arguments: dict[str, Any],
|
|
303
|
+
context: ToolMockContext,
|
|
304
|
+
) -> Any:
|
|
305
|
+
if name not in self.tools:
|
|
306
|
+
raise MockInvocationError(f"tool has no mock handler: {name}")
|
|
307
|
+
config = self.tools[name]
|
|
308
|
+
expected = config.get("match", {}).get("arguments", {})
|
|
309
|
+
match_failed = bool(expected) and not _matches(arguments, expected)
|
|
310
|
+
self.call_counts[name] += 1
|
|
311
|
+
before = _json_clone(self.state, "mock state")
|
|
312
|
+
started = time.perf_counter()
|
|
313
|
+
is_error = False
|
|
314
|
+
try:
|
|
315
|
+
if match_failed:
|
|
316
|
+
result = {
|
|
317
|
+
"error": {
|
|
318
|
+
"code": "UNKNOWN_ERROR",
|
|
319
|
+
"message": "unknown error",
|
|
320
|
+
"details": None,
|
|
321
|
+
"retryable": False,
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
is_error = True
|
|
325
|
+
elif "handler" in config:
|
|
326
|
+
handler = config["handler"]
|
|
327
|
+
target = self.handlers[name]
|
|
328
|
+
value = target(
|
|
329
|
+
arguments=deepcopy(arguments),
|
|
330
|
+
context=context,
|
|
331
|
+
config=deepcopy(handler.get("with", {})),
|
|
332
|
+
)
|
|
333
|
+
result = await value if inspect.isawaitable(value) else value
|
|
334
|
+
else:
|
|
335
|
+
result = self._declarative_result(name, config["respond"])
|
|
336
|
+
except ToolMockError as exc:
|
|
337
|
+
result = exc.as_result()
|
|
338
|
+
is_error = True
|
|
339
|
+
except (MockConfigurationError, MockInvocationError, MockImplementationError):
|
|
340
|
+
raise
|
|
341
|
+
except Exception as exc:
|
|
342
|
+
raise MockImplementationError(f"Python mock {name} failed: {exc}") from exc
|
|
343
|
+
result = _json_clone(result, f"mock result for {name}")
|
|
344
|
+
after = _json_clone(self.state, "mock state")
|
|
345
|
+
event = {
|
|
346
|
+
"tool": name,
|
|
347
|
+
"tool_call_id": context.tool_call_id,
|
|
348
|
+
"step_id": context.step_id,
|
|
349
|
+
"request_index": context.request_index,
|
|
350
|
+
"call_index": context.call_index,
|
|
351
|
+
"handler_kind": "python" if "handler" in config else "declarative",
|
|
352
|
+
"callable": config.get("handler", {}).get("python"),
|
|
353
|
+
"match_failed": match_failed,
|
|
354
|
+
"arguments": arguments,
|
|
355
|
+
"result": result,
|
|
356
|
+
"is_error": is_error or (isinstance(result, dict) and "error" in result),
|
|
357
|
+
"duration_ms": round((time.perf_counter() - started) * 1000, 3),
|
|
358
|
+
"state_diff": _state_diff(before, after),
|
|
359
|
+
}
|
|
360
|
+
self.events.append(event)
|
|
361
|
+
return result
|
|
362
|
+
|
|
363
|
+
def _declarative_result(self, name: str, respond: dict[str, Any]) -> Any:
|
|
364
|
+
item = respond
|
|
365
|
+
if "sequence" in respond:
|
|
366
|
+
index = self.sequence_indexes[name]
|
|
367
|
+
if index >= len(respond["sequence"]):
|
|
368
|
+
raise MockInvocationError(f"mock response sequence exhausted: {name}")
|
|
369
|
+
item = respond["sequence"][index]
|
|
370
|
+
self.sequence_indexes[name] += 1
|
|
371
|
+
if "result" in item:
|
|
372
|
+
return deepcopy(item["result"])
|
|
373
|
+
error = item.get("error")
|
|
374
|
+
if not isinstance(error, dict):
|
|
375
|
+
raise MockConfigurationError(f"mock error must be an object: {name}")
|
|
376
|
+
return {
|
|
377
|
+
"error": {
|
|
378
|
+
"code": error.get("code", "TOOL_ERROR"),
|
|
379
|
+
"message": error.get("message", "tool failed"),
|
|
380
|
+
"details": error.get("details"),
|
|
381
|
+
"retryable": bool(error.get("retryable", False)),
|
|
382
|
+
}
|
|
383
|
+
}
|
yama/models.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import asdict, dataclass, field
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any, NotRequired, TypedDict
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
Json = dict[str, Any]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class CheckerResult(TypedDict):
|
|
12
|
+
"""Structured return value of a `python` hard-assertion checker.
|
|
13
|
+
|
|
14
|
+
Only `passed` is required. The optional fields refine the diagnostic
|
|
15
|
+
shown in reports; omitted ones fall back as implemented in
|
|
16
|
+
`assertions.py` (`detail` doubles as the failure reason by default).
|
|
17
|
+
Checkers may also return a bare bool or a `(passed, detail)` tuple.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
passed: bool
|
|
21
|
+
detail: NotRequired[str]
|
|
22
|
+
label: NotRequired[str]
|
|
23
|
+
expected: NotRequired[str]
|
|
24
|
+
actual: NotRequired[str]
|
|
25
|
+
reason: NotRequired[str]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def sanitize_case_key(case_key: str) -> str:
|
|
29
|
+
key = case_key.replace("/", "-").replace("\\", "-")
|
|
30
|
+
if key.endswith(".yaml"):
|
|
31
|
+
key = key[:-5]
|
|
32
|
+
elif key.endswith(".yml"):
|
|
33
|
+
key = key[:-4]
|
|
34
|
+
return key
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class ToolSchema:
|
|
39
|
+
name: str
|
|
40
|
+
description: str
|
|
41
|
+
parameters: Json
|
|
42
|
+
source_path: Path | None = None
|
|
43
|
+
|
|
44
|
+
def for_provider(self) -> Json:
|
|
45
|
+
return {
|
|
46
|
+
"type": "function",
|
|
47
|
+
"function": {
|
|
48
|
+
"name": self.name,
|
|
49
|
+
"description": self.description,
|
|
50
|
+
"parameters": self.parameters,
|
|
51
|
+
},
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True)
|
|
55
|
+
class ToolCall:
|
|
56
|
+
id: str
|
|
57
|
+
name: str
|
|
58
|
+
arguments: Json
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class LLMResponse:
|
|
63
|
+
content: str = ""
|
|
64
|
+
tool_calls: list[ToolCall] = field(default_factory=list)
|
|
65
|
+
raw: Any = None
|
|
66
|
+
|
|
67
|
+
def assistant_message(self) -> Json:
|
|
68
|
+
message: Json = {"role": "assistant", "content": self.content or ""}
|
|
69
|
+
if self.tool_calls:
|
|
70
|
+
message["tool_calls"] = [
|
|
71
|
+
{
|
|
72
|
+
"id": call.id,
|
|
73
|
+
"type": "function",
|
|
74
|
+
"function": {
|
|
75
|
+
"name": call.name,
|
|
76
|
+
"arguments": call.arguments,
|
|
77
|
+
},
|
|
78
|
+
}
|
|
79
|
+
for call in self.tool_calls
|
|
80
|
+
]
|
|
81
|
+
return message
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass
|
|
85
|
+
class ToolCallRecord:
|
|
86
|
+
step_id: str
|
|
87
|
+
request_index: int
|
|
88
|
+
call_index: int
|
|
89
|
+
call: ToolCall
|
|
90
|
+
result: Any
|
|
91
|
+
is_error: bool = False
|
|
92
|
+
|
|
93
|
+
def to_dict(self) -> Json:
|
|
94
|
+
return {
|
|
95
|
+
"step_id": self.step_id,
|
|
96
|
+
"request_index": self.request_index,
|
|
97
|
+
"call_index": self.call_index,
|
|
98
|
+
"call": asdict(self.call),
|
|
99
|
+
"result": self.result,
|
|
100
|
+
"is_error": self.is_error,
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@dataclass
|
|
105
|
+
class StepResult:
|
|
106
|
+
step_id: str
|
|
107
|
+
user_message: Json
|
|
108
|
+
assistant_message: Json
|
|
109
|
+
tool_calls: list[ToolCallRecord]
|
|
110
|
+
messages: list[Json]
|
|
111
|
+
hard_checks: list[Json] = field(default_factory=list)
|
|
112
|
+
hard_pass: bool = True
|
|
113
|
+
judge_result: Json | None = None
|
|
114
|
+
|
|
115
|
+
def to_dict(self) -> Json:
|
|
116
|
+
return {
|
|
117
|
+
"step_id": self.step_id,
|
|
118
|
+
"user_message": self.user_message,
|
|
119
|
+
"assistant_message": self.assistant_message,
|
|
120
|
+
"tool_calls": [record.to_dict() for record in self.tool_calls],
|
|
121
|
+
"messages": self.messages,
|
|
122
|
+
"hard_checks": self.hard_checks,
|
|
123
|
+
"hard_pass": self.hard_pass,
|
|
124
|
+
"judge_result": self.judge_result,
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@dataclass
|
|
129
|
+
class LoadedSkill:
|
|
130
|
+
requested_name: str
|
|
131
|
+
name: str
|
|
132
|
+
description: str
|
|
133
|
+
body: str
|
|
134
|
+
path: Path
|
|
135
|
+
content_hash: str
|
|
136
|
+
|
|
137
|
+
def to_dict(self) -> Json:
|
|
138
|
+
data = asdict(self)
|
|
139
|
+
data["path"] = str(self.path)
|
|
140
|
+
return data
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@dataclass
|
|
144
|
+
class ResolvedCase:
|
|
145
|
+
path: Path
|
|
146
|
+
case_key: str
|
|
147
|
+
plugin_root: Path
|
|
148
|
+
evals_root: Path
|
|
149
|
+
model: Json
|
|
150
|
+
context: Json
|
|
151
|
+
mocks: Json
|
|
152
|
+
execution: Json
|
|
153
|
+
steps: list[Json]
|
|
154
|
+
outcome: Json
|
|
155
|
+
system_prompt: str
|
|
156
|
+
skills: list[LoadedSkill]
|
|
157
|
+
tools: list[ToolSchema]
|
|
158
|
+
initial_messages: list[Json]
|
|
159
|
+
fixture_manifest: list[Json] = field(default_factory=list)
|