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/runner.py
ADDED
|
@@ -0,0 +1,531 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import inspect
|
|
5
|
+
import json
|
|
6
|
+
import shutil
|
|
7
|
+
import traceback
|
|
8
|
+
from copy import deepcopy
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import yaml
|
|
15
|
+
|
|
16
|
+
from .assertions import run_hard_assertions, run_judge
|
|
17
|
+
from .tools.bash.fs import cleanup_session
|
|
18
|
+
from .llm import LLMClient
|
|
19
|
+
from .mocks import MockToolRegistry, ToolMockContext, _matches, load_python_callable
|
|
20
|
+
from .models import ResolvedCase, StepResult, ToolCallRecord, sanitize_case_key
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _tool_result_content(result: Any) -> str:
|
|
24
|
+
"""Render a mock tool result as the `content` of a `role: tool` message.
|
|
25
|
+
|
|
26
|
+
The `bash` tool's handlers (run_bash_fs/run_bash_cli) return the raw
|
|
27
|
+
`{stdout, stderr, exit_code}` envelope so the framework can inspect it
|
|
28
|
+
for records/assertions -- but the model should see the command's actual
|
|
29
|
+
output text, the same way a real shell tool would return it, not our
|
|
30
|
+
internal JSON envelope. The `skill` tool returns the skill file's text
|
|
31
|
+
verbatim (see `tools/skill/skill.py`), which should also reach the
|
|
32
|
+
model as-is rather than as a JSON-quoted string. Every other tool
|
|
33
|
+
result (including bash error envelopes) keeps the previous JSON
|
|
34
|
+
encoding.
|
|
35
|
+
"""
|
|
36
|
+
if (
|
|
37
|
+
isinstance(result, dict)
|
|
38
|
+
and isinstance(result.get("stdout"), str)
|
|
39
|
+
and isinstance(result.get("stderr"), str)
|
|
40
|
+
and "exit_code" in result
|
|
41
|
+
):
|
|
42
|
+
return result["stdout"] + result["stderr"]
|
|
43
|
+
if isinstance(result, str):
|
|
44
|
+
return result
|
|
45
|
+
return json.dumps(result, ensure_ascii=False)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class MaxToolRoundsExceeded(RuntimeError):
|
|
49
|
+
def __init__(self, step_id: str, tool_calls: list[ToolCallRecord]) -> None:
|
|
50
|
+
super().__init__(f"max tool rounds exceeded for step {step_id}")
|
|
51
|
+
self.step_id = step_id
|
|
52
|
+
self.tool_calls = tool_calls
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass
|
|
56
|
+
class RunResult:
|
|
57
|
+
run_index: int
|
|
58
|
+
passed: bool
|
|
59
|
+
steps: list[StepResult] = field(default_factory=list)
|
|
60
|
+
error: str | None = None
|
|
61
|
+
traceback: str | None = None
|
|
62
|
+
mock_events: list[dict[str, Any]] = field(default_factory=list)
|
|
63
|
+
mock_state: dict[str, Any] = field(default_factory=dict)
|
|
64
|
+
requests: list[dict[str, Any]] = field(default_factory=list)
|
|
65
|
+
artifact_dir: Path | None = None
|
|
66
|
+
|
|
67
|
+
def to_dict(self) -> dict[str, Any]:
|
|
68
|
+
return {
|
|
69
|
+
"run_index": self.run_index,
|
|
70
|
+
"passed": self.passed,
|
|
71
|
+
"steps": [step.to_dict() for step in self.steps],
|
|
72
|
+
"error": self.error,
|
|
73
|
+
"mock_events": self.mock_events,
|
|
74
|
+
"mock_state": self.mock_state,
|
|
75
|
+
"requests": self.requests,
|
|
76
|
+
"artifact_dir": str(self.artifact_dir) if self.artifact_dir else None,
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass
|
|
81
|
+
class CaseResult:
|
|
82
|
+
case_key: str
|
|
83
|
+
passed: bool
|
|
84
|
+
runs: list[RunResult]
|
|
85
|
+
pass_rate: float
|
|
86
|
+
|
|
87
|
+
def to_dict(self) -> dict[str, Any]:
|
|
88
|
+
return {
|
|
89
|
+
"case_key": self.case_key,
|
|
90
|
+
"passed": self.passed,
|
|
91
|
+
"pass_rate": self.pass_rate,
|
|
92
|
+
"runs": [run.to_dict() for run in self.runs],
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class YamaRunner:
|
|
97
|
+
def __init__(
|
|
98
|
+
self,
|
|
99
|
+
llm: LLMClient,
|
|
100
|
+
*,
|
|
101
|
+
result_root: Path | None = None,
|
|
102
|
+
write_artifacts: bool = True,
|
|
103
|
+
) -> None:
|
|
104
|
+
self.llm = llm
|
|
105
|
+
self.result_root = result_root
|
|
106
|
+
self.write_artifacts = write_artifacts
|
|
107
|
+
|
|
108
|
+
async def run_case(self, case: ResolvedCase) -> CaseResult:
|
|
109
|
+
repeat = int(case.execution.get("repeat", 1))
|
|
110
|
+
if repeat < 1:
|
|
111
|
+
raise ValueError("execution.repeat must be at least 1")
|
|
112
|
+
runs = [
|
|
113
|
+
await self._run_once(case, run_index) for run_index in range(1, repeat + 1)
|
|
114
|
+
]
|
|
115
|
+
passed_count = sum(run.passed for run in runs)
|
|
116
|
+
result = CaseResult(
|
|
117
|
+
case_key=case.case_key,
|
|
118
|
+
passed=passed_count == repeat,
|
|
119
|
+
runs=runs,
|
|
120
|
+
pass_rate=passed_count / repeat,
|
|
121
|
+
)
|
|
122
|
+
return result
|
|
123
|
+
|
|
124
|
+
async def _run_once(self, case: ResolvedCase, run_index: int) -> RunResult:
|
|
125
|
+
artifact_dir = self._artifact_dir(case, run_index)
|
|
126
|
+
registry: MockToolRegistry | None = None
|
|
127
|
+
steps: list[StepResult] = []
|
|
128
|
+
requests: list[dict[str, Any]] = []
|
|
129
|
+
try:
|
|
130
|
+
registry = MockToolRegistry(
|
|
131
|
+
case.mocks,
|
|
132
|
+
plugin_root=case.plugin_root,
|
|
133
|
+
relative_root=case.evals_root,
|
|
134
|
+
exposed_tools={tool.name for tool in case.tools},
|
|
135
|
+
)
|
|
136
|
+
messages = await self._initial_messages(case, registry, run_index)
|
|
137
|
+
for step in case.steps:
|
|
138
|
+
transcript_before = deepcopy(messages)
|
|
139
|
+
timeout = float(case.execution.get("timeout_seconds_per_step", 120))
|
|
140
|
+
try:
|
|
141
|
+
step_result = await asyncio.wait_for(
|
|
142
|
+
self._run_step(
|
|
143
|
+
case,
|
|
144
|
+
step,
|
|
145
|
+
messages,
|
|
146
|
+
registry,
|
|
147
|
+
run_index,
|
|
148
|
+
requests,
|
|
149
|
+
),
|
|
150
|
+
timeout=timeout,
|
|
151
|
+
)
|
|
152
|
+
except MaxToolRoundsExceeded as exc:
|
|
153
|
+
messages[:] = transcript_before
|
|
154
|
+
steps.append(
|
|
155
|
+
self._max_tool_rounds_step_result(case, step, exc)
|
|
156
|
+
)
|
|
157
|
+
continue
|
|
158
|
+
assertion_config = step.get("assert", {})
|
|
159
|
+
hard = assertion_config.get("hard", [])
|
|
160
|
+
step_result.hard_checks = await run_hard_assertions(
|
|
161
|
+
hard,
|
|
162
|
+
result=step_result,
|
|
163
|
+
transcript=messages,
|
|
164
|
+
state=registry.state,
|
|
165
|
+
plugin_root=case.plugin_root,
|
|
166
|
+
relative_root=case.evals_root,
|
|
167
|
+
)
|
|
168
|
+
step_result.hard_pass = all(
|
|
169
|
+
check["passed"] for check in step_result.hard_checks
|
|
170
|
+
)
|
|
171
|
+
judge_config = assertion_config.get("judge")
|
|
172
|
+
if judge_config and (
|
|
173
|
+
step_result.hard_pass
|
|
174
|
+
or judge_config.get("run_if_hard_failed", False)
|
|
175
|
+
):
|
|
176
|
+
step_result.judge_result = await run_judge(
|
|
177
|
+
judge_config,
|
|
178
|
+
client=self.llm,
|
|
179
|
+
step_result=step_result,
|
|
180
|
+
transcript_before_step=transcript_before,
|
|
181
|
+
default_model=case.model,
|
|
182
|
+
)
|
|
183
|
+
steps.append(step_result)
|
|
184
|
+
passed = self._case_passed(case, steps)
|
|
185
|
+
run = RunResult(
|
|
186
|
+
run_index=run_index,
|
|
187
|
+
passed=passed,
|
|
188
|
+
steps=steps,
|
|
189
|
+
mock_events=deepcopy(registry.events),
|
|
190
|
+
mock_state=deepcopy(registry.state),
|
|
191
|
+
requests=requests,
|
|
192
|
+
artifact_dir=artifact_dir,
|
|
193
|
+
)
|
|
194
|
+
except Exception as exc:
|
|
195
|
+
run = RunResult(
|
|
196
|
+
run_index=run_index,
|
|
197
|
+
passed=False,
|
|
198
|
+
steps=steps,
|
|
199
|
+
error=f"{type(exc).__name__}: {exc}",
|
|
200
|
+
traceback=traceback.format_exc(),
|
|
201
|
+
mock_events=deepcopy(registry.events) if registry else [],
|
|
202
|
+
mock_state=deepcopy(registry.state) if registry else {},
|
|
203
|
+
requests=requests,
|
|
204
|
+
artifact_dir=artifact_dir,
|
|
205
|
+
)
|
|
206
|
+
finally:
|
|
207
|
+
if registry is not None:
|
|
208
|
+
await cleanup_session(registry.state)
|
|
209
|
+
if self.write_artifacts:
|
|
210
|
+
self._write_artifacts(case, run)
|
|
211
|
+
return run
|
|
212
|
+
|
|
213
|
+
def _max_tool_rounds_step_result(
|
|
214
|
+
self,
|
|
215
|
+
case: ResolvedCase,
|
|
216
|
+
step: dict[str, Any],
|
|
217
|
+
exc: MaxToolRoundsExceeded,
|
|
218
|
+
) -> StepResult:
|
|
219
|
+
maximum = int(case.execution.get("max_tool_rounds_per_step", 10))
|
|
220
|
+
user_message = {"role": "user", "content": deepcopy(step["user"])}
|
|
221
|
+
return StepResult(
|
|
222
|
+
step_id=step["id"],
|
|
223
|
+
user_message=user_message,
|
|
224
|
+
assistant_message={"role": "assistant", "content": ""},
|
|
225
|
+
tool_calls=exc.tool_calls,
|
|
226
|
+
messages=[],
|
|
227
|
+
hard_checks=[
|
|
228
|
+
{
|
|
229
|
+
"type": "max_tool_rounds_exceeded",
|
|
230
|
+
"passed": False,
|
|
231
|
+
"label": "tool call loop limit",
|
|
232
|
+
"expected": f"step completes within {maximum} tool round(s)",
|
|
233
|
+
"actual": f"{len(exc.tool_calls)} tool call(s) made before limit",
|
|
234
|
+
"reason": str(exc),
|
|
235
|
+
"detail": "step was reset; transcript rolled back and run continued",
|
|
236
|
+
}
|
|
237
|
+
],
|
|
238
|
+
hard_pass=False,
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
async def _initial_messages(
|
|
242
|
+
self,
|
|
243
|
+
case: ResolvedCase,
|
|
244
|
+
registry: MockToolRegistry,
|
|
245
|
+
run_index: int,
|
|
246
|
+
) -> list[dict[str, Any]]:
|
|
247
|
+
messages = deepcopy(case.initial_messages)
|
|
248
|
+
builders = case.context.get("builders", [])
|
|
249
|
+
case_messages: dict[str, list[dict[str, Any]]] = {
|
|
250
|
+
"after_system": [],
|
|
251
|
+
"before_history": [],
|
|
252
|
+
"after_history": [],
|
|
253
|
+
}
|
|
254
|
+
for position in case_messages:
|
|
255
|
+
case_messages[position] = await self._run_builders(
|
|
256
|
+
builders,
|
|
257
|
+
position=position,
|
|
258
|
+
case=case,
|
|
259
|
+
transcript=messages,
|
|
260
|
+
state=registry.state,
|
|
261
|
+
run_index=run_index,
|
|
262
|
+
)
|
|
263
|
+
history = messages[1:]
|
|
264
|
+
return [
|
|
265
|
+
messages[0],
|
|
266
|
+
*case_messages["after_system"],
|
|
267
|
+
*case_messages["before_history"],
|
|
268
|
+
*history,
|
|
269
|
+
*case_messages["after_history"],
|
|
270
|
+
]
|
|
271
|
+
|
|
272
|
+
async def _run_step(
|
|
273
|
+
self,
|
|
274
|
+
case: ResolvedCase,
|
|
275
|
+
step: dict[str, Any],
|
|
276
|
+
messages: list[dict[str, Any]],
|
|
277
|
+
registry: MockToolRegistry,
|
|
278
|
+
run_index: int,
|
|
279
|
+
requests: list[dict[str, Any]],
|
|
280
|
+
) -> StepResult:
|
|
281
|
+
injections = step.get("message_inject", [])
|
|
282
|
+
messages.extend(
|
|
283
|
+
deepcopy(item["message"])
|
|
284
|
+
for item in injections
|
|
285
|
+
if item["position"] == "before_user_message"
|
|
286
|
+
)
|
|
287
|
+
user_message = {"role": "user", "content": deepcopy(step["user"])}
|
|
288
|
+
messages.append(user_message)
|
|
289
|
+
messages.extend(
|
|
290
|
+
deepcopy(item["message"])
|
|
291
|
+
for item in injections
|
|
292
|
+
if item["position"] == "after_user_message"
|
|
293
|
+
)
|
|
294
|
+
records: list[ToolCallRecord] = []
|
|
295
|
+
step_call_counts: dict[str, int] = {}
|
|
296
|
+
maximum = int(case.execution.get("max_tool_rounds_per_step", 10))
|
|
297
|
+
for request_index in range(maximum + 1):
|
|
298
|
+
request_messages = deepcopy(messages)
|
|
299
|
+
request_record = {
|
|
300
|
+
"step_id": step["id"],
|
|
301
|
+
"request_index": request_index,
|
|
302
|
+
}
|
|
303
|
+
requests.append(request_record)
|
|
304
|
+
try:
|
|
305
|
+
response = await self.llm.complete(
|
|
306
|
+
messages=request_messages,
|
|
307
|
+
tools=case.tools,
|
|
308
|
+
model=case.model,
|
|
309
|
+
)
|
|
310
|
+
except Exception as exc:
|
|
311
|
+
request_record["error"] = f"{type(exc).__name__}: {exc}"
|
|
312
|
+
raise
|
|
313
|
+
finally:
|
|
314
|
+
invocation = getattr(self.llm, "last_request", None)
|
|
315
|
+
if invocation is not None:
|
|
316
|
+
request_record["invocation"] = deepcopy(invocation)
|
|
317
|
+
resolved_provider = getattr(self.llm, "last_provider", None)
|
|
318
|
+
if resolved_provider is not None:
|
|
319
|
+
request_record["resolved_provider"] = deepcopy(resolved_provider)
|
|
320
|
+
assistant = response.assistant_message()
|
|
321
|
+
request_record["response"] = deepcopy(assistant)
|
|
322
|
+
messages.append(assistant)
|
|
323
|
+
if not response.tool_calls:
|
|
324
|
+
return StepResult(
|
|
325
|
+
step_id=step["id"],
|
|
326
|
+
user_message=user_message,
|
|
327
|
+
assistant_message=assistant,
|
|
328
|
+
tool_calls=records,
|
|
329
|
+
messages=deepcopy(messages),
|
|
330
|
+
)
|
|
331
|
+
for call in response.tool_calls:
|
|
332
|
+
call_index = registry.next_call_index(call.name)
|
|
333
|
+
step_call_counts[call.name] = step_call_counts.get(call.name, 0) + 1
|
|
334
|
+
step_call_index = step_call_counts[call.name]
|
|
335
|
+
context = ToolMockContext(
|
|
336
|
+
plugin_root=case.plugin_root,
|
|
337
|
+
case_key=case.case_key,
|
|
338
|
+
run_index=run_index,
|
|
339
|
+
step_id=step["id"],
|
|
340
|
+
step_index=step["index"],
|
|
341
|
+
request_index=request_index,
|
|
342
|
+
tool_call_id=call.id,
|
|
343
|
+
call_index=call_index,
|
|
344
|
+
transcript=tuple(deepcopy(messages)),
|
|
345
|
+
state=registry.state,
|
|
346
|
+
)
|
|
347
|
+
result = await registry.call(call.name, call.arguments, context)
|
|
348
|
+
record = ToolCallRecord(
|
|
349
|
+
step_id=step["id"],
|
|
350
|
+
request_index=request_index,
|
|
351
|
+
call_index=call_index,
|
|
352
|
+
call=call,
|
|
353
|
+
result=result,
|
|
354
|
+
is_error=isinstance(result, dict) and "error" in result,
|
|
355
|
+
)
|
|
356
|
+
records.append(record)
|
|
357
|
+
messages.append(
|
|
358
|
+
{
|
|
359
|
+
"role": "tool",
|
|
360
|
+
"tool_call_id": call.id,
|
|
361
|
+
"content": _tool_result_content(result),
|
|
362
|
+
}
|
|
363
|
+
)
|
|
364
|
+
for item in injections:
|
|
365
|
+
if item["position"] != "after_tool_call":
|
|
366
|
+
continue
|
|
367
|
+
match = item["match"]
|
|
368
|
+
if match["name"] != call.name:
|
|
369
|
+
continue
|
|
370
|
+
if "call" in match and match["call"] != step_call_index:
|
|
371
|
+
continue
|
|
372
|
+
if "arguments" in match and not _matches(
|
|
373
|
+
call.arguments, match["arguments"]
|
|
374
|
+
):
|
|
375
|
+
continue
|
|
376
|
+
messages.append(deepcopy(item["message"]))
|
|
377
|
+
messages.extend(
|
|
378
|
+
deepcopy(item["message"])
|
|
379
|
+
for item in injections
|
|
380
|
+
if item["position"] == "after_all_tool_calls"
|
|
381
|
+
)
|
|
382
|
+
raise MaxToolRoundsExceeded(step["id"], records)
|
|
383
|
+
|
|
384
|
+
async def _run_builders(
|
|
385
|
+
self,
|
|
386
|
+
builders: Any,
|
|
387
|
+
*,
|
|
388
|
+
position: str,
|
|
389
|
+
case: ResolvedCase,
|
|
390
|
+
transcript: list[dict[str, Any]],
|
|
391
|
+
state: dict[str, Any],
|
|
392
|
+
run_index: int,
|
|
393
|
+
) -> list[dict[str, Any]]:
|
|
394
|
+
output: list[dict[str, Any]] = []
|
|
395
|
+
for builder in builders or []:
|
|
396
|
+
if builder.get("position") != position:
|
|
397
|
+
continue
|
|
398
|
+
use = builder.get("use")
|
|
399
|
+
if use == "message":
|
|
400
|
+
produced = [deepcopy(builder["message"])]
|
|
401
|
+
elif use == "python":
|
|
402
|
+
target = load_python_callable(
|
|
403
|
+
builder["callable"],
|
|
404
|
+
case.plugin_root,
|
|
405
|
+
relative_root=case.evals_root,
|
|
406
|
+
)
|
|
407
|
+
context = {
|
|
408
|
+
"plugin_root": case.plugin_root,
|
|
409
|
+
"case_key": case.case_key,
|
|
410
|
+
"run_index": run_index,
|
|
411
|
+
"step_id": None,
|
|
412
|
+
"step_index": None,
|
|
413
|
+
"request_index": 0,
|
|
414
|
+
"transcript": tuple(deepcopy(transcript)),
|
|
415
|
+
"tool_state": state,
|
|
416
|
+
}
|
|
417
|
+
value = target(
|
|
418
|
+
context=context, config=deepcopy(builder.get("with", {}))
|
|
419
|
+
)
|
|
420
|
+
produced = await value if inspect.isawaitable(value) else value
|
|
421
|
+
if not isinstance(produced, list):
|
|
422
|
+
raise TypeError("Python builder must return a message list")
|
|
423
|
+
else:
|
|
424
|
+
raise ValueError(f"unsupported builder type: {use}")
|
|
425
|
+
output.extend(produced)
|
|
426
|
+
return output
|
|
427
|
+
|
|
428
|
+
def _case_passed(self, case: ResolvedCase, steps: list[StepResult]) -> bool:
|
|
429
|
+
if not all(step.hard_pass for step in steps):
|
|
430
|
+
return False
|
|
431
|
+
judge_results = [
|
|
432
|
+
step.judge_result for step in steps if step.judge_result is not None
|
|
433
|
+
]
|
|
434
|
+
if any(not result.get("passed", False) for result in judge_results):
|
|
435
|
+
return False
|
|
436
|
+
requirement = case.outcome.get("require", {})
|
|
437
|
+
judge_requirement = requirement.get("judge", {})
|
|
438
|
+
if judge_requirement and not judge_results:
|
|
439
|
+
return False
|
|
440
|
+
if "overall_gte" in judge_requirement:
|
|
441
|
+
overall = sum(result["overall"] for result in judge_results) / len(
|
|
442
|
+
judge_results
|
|
443
|
+
)
|
|
444
|
+
if overall < judge_requirement["overall_gte"]:
|
|
445
|
+
return False
|
|
446
|
+
if "each_dimension_gte" in judge_requirement:
|
|
447
|
+
threshold = judge_requirement["each_dimension_gte"]
|
|
448
|
+
for result in judge_results:
|
|
449
|
+
if any(
|
|
450
|
+
(item["score"] - 1) / 4 < threshold for item in result["dimensions"]
|
|
451
|
+
):
|
|
452
|
+
return False
|
|
453
|
+
return True
|
|
454
|
+
|
|
455
|
+
def _artifact_dir(self, case: ResolvedCase, run_index: int) -> Path | None:
|
|
456
|
+
if not self.write_artifacts:
|
|
457
|
+
return None
|
|
458
|
+
root = self.result_root or case.plugin_root / ".yama" / "runs"
|
|
459
|
+
key = sanitize_case_key(case.case_key)
|
|
460
|
+
return root / key / f"run-{run_index:03d}"
|
|
461
|
+
|
|
462
|
+
def _write_artifacts(self, case: ResolvedCase, run: RunResult) -> None:
|
|
463
|
+
assert run.artifact_dir is not None
|
|
464
|
+
directory = run.artifact_dir
|
|
465
|
+
if directory.exists():
|
|
466
|
+
shutil.rmtree(directory)
|
|
467
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
468
|
+
fixture_entries = deepcopy(case.fixture_manifest)
|
|
469
|
+
fixture_dir = directory / "fixtures"
|
|
470
|
+
for entry in fixture_entries:
|
|
471
|
+
source = Path(entry["fixture"])
|
|
472
|
+
fixture_dir.mkdir(exist_ok=True)
|
|
473
|
+
target = fixture_dir / f"{entry['sha256'][:12]}-{entry['filename']}"
|
|
474
|
+
shutil.copy2(source, target)
|
|
475
|
+
entry["materialized_path"] = str(target)
|
|
476
|
+
resolved = {
|
|
477
|
+
"case_key": case.case_key,
|
|
478
|
+
"path": str(case.path),
|
|
479
|
+
"plugin_root": str(case.plugin_root),
|
|
480
|
+
"evals_root": str(case.evals_root),
|
|
481
|
+
"model": case.model,
|
|
482
|
+
"execution": case.execution,
|
|
483
|
+
"context": case.context,
|
|
484
|
+
"mocks": case.mocks,
|
|
485
|
+
"steps": case.steps,
|
|
486
|
+
"outcome": case.outcome,
|
|
487
|
+
}
|
|
488
|
+
(directory / "case.resolved.yaml").write_text(
|
|
489
|
+
yaml.safe_dump(resolved, allow_unicode=True, sort_keys=False),
|
|
490
|
+
encoding="utf-8",
|
|
491
|
+
)
|
|
492
|
+
(directory / "rendered-system-prompt.txt").write_text(
|
|
493
|
+
case.system_prompt, encoding="utf-8"
|
|
494
|
+
)
|
|
495
|
+
self._write_json(
|
|
496
|
+
directory / "loaded-skills.json", [s.to_dict() for s in case.skills]
|
|
497
|
+
)
|
|
498
|
+
self._write_json(directory / "fixtures.json", fixture_entries)
|
|
499
|
+
request_dir = directory / "requests"
|
|
500
|
+
request_dir.mkdir(exist_ok=True)
|
|
501
|
+
for index, request in enumerate(run.requests, 1):
|
|
502
|
+
self._write_json(request_dir / f"request-{index:03d}.json", request)
|
|
503
|
+
self._write_json(
|
|
504
|
+
directory / "transcript.json", [step.to_dict() for step in run.steps]
|
|
505
|
+
)
|
|
506
|
+
self._write_json(directory / "mock-events.json", run.mock_events)
|
|
507
|
+
self._write_json(directory / "mock-state.json", run.mock_state)
|
|
508
|
+
self._write_json(
|
|
509
|
+
directory / "hard-checks.json",
|
|
510
|
+
{step.step_id: step.hard_checks for step in run.steps},
|
|
511
|
+
)
|
|
512
|
+
self._write_json(
|
|
513
|
+
directory / "judge-result.json",
|
|
514
|
+
{
|
|
515
|
+
step.step_id: step.judge_result
|
|
516
|
+
for step in run.steps
|
|
517
|
+
if step.judge_result
|
|
518
|
+
},
|
|
519
|
+
)
|
|
520
|
+
result = run.to_dict()
|
|
521
|
+
result["generated_at"] = datetime.now(timezone.utc).isoformat()
|
|
522
|
+
if run.traceback:
|
|
523
|
+
result["traceback"] = run.traceback
|
|
524
|
+
self._write_json(directory / "result.json", result)
|
|
525
|
+
|
|
526
|
+
@staticmethod
|
|
527
|
+
def _write_json(path: Path, value: Any) -> None:
|
|
528
|
+
path.write_text(
|
|
529
|
+
json.dumps(value, ensure_ascii=False, indent=2, default=str) + "\n",
|
|
530
|
+
encoding="utf-8",
|
|
531
|
+
)
|
yama/tools/__init__.py
ADDED
|
File without changes
|
yama/tools/base.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from typing import Any, Mapping
|
|
5
|
+
|
|
6
|
+
from ..mocks import ToolMockContext
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Tool(ABC):
|
|
10
|
+
"""Common interface implemented by every built-in Tool Mock handler."""
|
|
11
|
+
|
|
12
|
+
@abstractmethod
|
|
13
|
+
def run(
|
|
14
|
+
self,
|
|
15
|
+
arguments: Mapping[str, Any],
|
|
16
|
+
context: ToolMockContext | None,
|
|
17
|
+
config: Mapping[str, Any],
|
|
18
|
+
) -> Any:
|
|
19
|
+
raise NotImplementedError
|
|
File without changes
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Standalone resolver invoked by compiled mocks.cli shell scripts.
|
|
2
|
+
|
|
3
|
+
Not imported by the framework at runtime: fs.compile_cli_config writes
|
|
4
|
+
a tiny shell wrapper into the sandbox's shadow PATH for every mocked command,
|
|
5
|
+
and that wrapper runs `python -m yama.tools.bash._cli_shim <rules.json> "$@"`. This
|
|
6
|
+
module reuses the exact rule-tree walk and _matches() semantics the old
|
|
7
|
+
in-process bash interpreter used, so mocks.cli's matching behavior (including
|
|
8
|
+
regex fidelity) is unchanged even though a real shell now owns command-line
|
|
9
|
+
parsing instead of a hand-rolled tokenizer.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import sys
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from ...mocks import _matches
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _resolve(node: dict[str, Any], argv: list[str]) -> dict[str, Any] | None:
|
|
23
|
+
remaining = list(argv)
|
|
24
|
+
fallback = node.get("default")
|
|
25
|
+
while True:
|
|
26
|
+
if node.get("default") is not None:
|
|
27
|
+
fallback = node["default"]
|
|
28
|
+
commands = node.get("commands") or {}
|
|
29
|
+
if remaining and remaining[0] in commands:
|
|
30
|
+
node = commands[remaining[0]]
|
|
31
|
+
remaining = remaining[1:]
|
|
32
|
+
continue
|
|
33
|
+
break
|
|
34
|
+
view = {"argv": remaining, "argline": " ".join(remaining)}
|
|
35
|
+
for rule in node.get("rules") or []:
|
|
36
|
+
match = rule.get("match") or {}
|
|
37
|
+
if all(_matches(view.get(key), expected) for key, expected in match.items()):
|
|
38
|
+
return rule
|
|
39
|
+
return fallback
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def main(argv: list[str]) -> int:
|
|
43
|
+
if not argv:
|
|
44
|
+
sys.stderr.write("yama-cli-shim: missing rules file argument\n")
|
|
45
|
+
return 70
|
|
46
|
+
rules_path, *rest = argv
|
|
47
|
+
with open(rules_path, "r", encoding="utf-8") as handle:
|
|
48
|
+
spec = json.load(handle)
|
|
49
|
+
name = spec["name"]
|
|
50
|
+
rule = _resolve(spec["node"], rest)
|
|
51
|
+
if rule is None:
|
|
52
|
+
sys.stderr.write(f"{name}: unknown error")
|
|
53
|
+
return 1
|
|
54
|
+
if rule.get("passthrough"):
|
|
55
|
+
env = dict(os.environ)
|
|
56
|
+
env["PATH"] = os.environ.get("YAMA_REAL_PATH", env.get("PATH", ""))
|
|
57
|
+
try:
|
|
58
|
+
os.execvpe(name, [name, *rest], env)
|
|
59
|
+
except FileNotFoundError:
|
|
60
|
+
sys.stderr.write(f"bash: {name}: command not found\n")
|
|
61
|
+
return 127
|
|
62
|
+
except PermissionError:
|
|
63
|
+
sys.stderr.write(f"bash: {name}: permission denied\n")
|
|
64
|
+
return 126
|
|
65
|
+
sys.stdout.write(rule.get("stdout", ""))
|
|
66
|
+
sys.stderr.write(rule.get("stderr", ""))
|
|
67
|
+
return int(rule.get("exit_code", 0))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
if __name__ == "__main__":
|
|
71
|
+
raise SystemExit(main(sys.argv[1:]))
|