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.
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:]))