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/loaders/case.py
ADDED
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import mimetypes
|
|
4
|
+
from copy import deepcopy
|
|
5
|
+
from html import escape
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import yaml
|
|
10
|
+
|
|
11
|
+
from ..workspace import WorkspaceConfig
|
|
12
|
+
from ..models import LoadedSkill, ResolvedCase
|
|
13
|
+
from .common import CollectionError, load_yaml, sha256_file
|
|
14
|
+
from .tool_loader import load_tool
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _load_skill(
|
|
18
|
+
plugin_root: Path, skills_dir: str, entry: str, item: Any
|
|
19
|
+
) -> LoadedSkill:
|
|
20
|
+
if isinstance(item, str):
|
|
21
|
+
requested_name = item
|
|
22
|
+
elif isinstance(item, dict) and isinstance(item.get("name"), str):
|
|
23
|
+
if set(item) != {"name"}:
|
|
24
|
+
raise CollectionError(
|
|
25
|
+
"context.skills entries cannot inject Skill bodies; only {name} is allowed"
|
|
26
|
+
)
|
|
27
|
+
requested_name = item["name"]
|
|
28
|
+
else:
|
|
29
|
+
raise CollectionError("context.skills entries must be names or {name} objects")
|
|
30
|
+
path = (plugin_root / skills_dir / requested_name / entry).resolve()
|
|
31
|
+
if not path.is_file():
|
|
32
|
+
raise CollectionError(f"skill does not exist: {path}")
|
|
33
|
+
text = path.read_text(encoding="utf-8")
|
|
34
|
+
if not text.startswith("---\n"):
|
|
35
|
+
raise CollectionError(f"skill frontmatter is missing: {path}")
|
|
36
|
+
try:
|
|
37
|
+
_, raw_frontmatter, body = text.split("---", 2)
|
|
38
|
+
frontmatter = yaml.safe_load(raw_frontmatter)
|
|
39
|
+
except (ValueError, yaml.YAMLError) as exc:
|
|
40
|
+
raise CollectionError(f"invalid skill frontmatter: {path}") from exc
|
|
41
|
+
if not isinstance(frontmatter, dict):
|
|
42
|
+
raise CollectionError(f"invalid skill frontmatter: {path}")
|
|
43
|
+
name = frontmatter.get("name")
|
|
44
|
+
description = frontmatter.get("description")
|
|
45
|
+
if name != requested_name:
|
|
46
|
+
raise CollectionError(
|
|
47
|
+
f"skill name mismatch: requested {requested_name!r}, frontmatter has {name!r}"
|
|
48
|
+
)
|
|
49
|
+
if not isinstance(description, str) or not description.strip():
|
|
50
|
+
raise CollectionError(f"skill description is missing: {path}")
|
|
51
|
+
return LoadedSkill(
|
|
52
|
+
requested_name=requested_name,
|
|
53
|
+
name=name,
|
|
54
|
+
description=description.strip(),
|
|
55
|
+
body=body.lstrip("\n"),
|
|
56
|
+
path=path,
|
|
57
|
+
content_hash=sha256_file(path),
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _render_system_prompt(base: str, skills: list[LoadedSkill]) -> str:
|
|
62
|
+
rendered: list[str] = []
|
|
63
|
+
for skill in skills:
|
|
64
|
+
block = (
|
|
65
|
+
"<skill>\n<name>\n"
|
|
66
|
+
f"{escape(skill.name, quote=True)}\n"
|
|
67
|
+
"</name>\n<description>\n"
|
|
68
|
+
f"{escape(skill.description, quote=True)}\n"
|
|
69
|
+
"</description>"
|
|
70
|
+
)
|
|
71
|
+
block += "\n</skill>"
|
|
72
|
+
rendered.append(block)
|
|
73
|
+
catalog = "\n".join(rendered)
|
|
74
|
+
return f"{base.rstrip()}\n<available_skills>\n{catalog}\n</available_skills>"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
_INJECT_POSITIONS = {
|
|
78
|
+
"before_user_message",
|
|
79
|
+
"after_user_message",
|
|
80
|
+
"after_all_tool_calls",
|
|
81
|
+
"after_tool_call",
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _resolve_message_inject(
|
|
86
|
+
evals_root: Path,
|
|
87
|
+
entries: Any,
|
|
88
|
+
fixture_manifest: list[dict[str, Any]],
|
|
89
|
+
step_id: str,
|
|
90
|
+
) -> list[dict[str, Any]]:
|
|
91
|
+
if not isinstance(entries, list):
|
|
92
|
+
raise CollectionError(f"step {step_id} message_inject must be a list")
|
|
93
|
+
resolved: list[dict[str, Any]] = []
|
|
94
|
+
for entry in entries:
|
|
95
|
+
if not isinstance(entry, dict):
|
|
96
|
+
raise CollectionError(
|
|
97
|
+
f"step {step_id} message_inject entry must be an object"
|
|
98
|
+
)
|
|
99
|
+
unknown = set(entry) - {"position", "message", "match"}
|
|
100
|
+
if unknown:
|
|
101
|
+
raise CollectionError(
|
|
102
|
+
f"step {step_id} message_inject has unknown fields: {sorted(unknown)}"
|
|
103
|
+
)
|
|
104
|
+
position = entry.get("position")
|
|
105
|
+
if position not in _INJECT_POSITIONS:
|
|
106
|
+
raise CollectionError(
|
|
107
|
+
f"step {step_id} message_inject.position must be one of "
|
|
108
|
+
f"{sorted(_INJECT_POSITIONS)}, got {position!r}"
|
|
109
|
+
)
|
|
110
|
+
message = entry.get("message")
|
|
111
|
+
if not isinstance(message, dict) or message.get("role") not in {
|
|
112
|
+
"user",
|
|
113
|
+
"assistant",
|
|
114
|
+
"tool",
|
|
115
|
+
}:
|
|
116
|
+
raise CollectionError(
|
|
117
|
+
f"step {step_id} message_inject.message must have a valid role"
|
|
118
|
+
)
|
|
119
|
+
resolved_message = deepcopy(message)
|
|
120
|
+
resolved_message["content"] = _resolve_message_parts(
|
|
121
|
+
evals_root, message.get("content"), fixture_manifest
|
|
122
|
+
)
|
|
123
|
+
match = entry.get("match")
|
|
124
|
+
if position == "after_tool_call":
|
|
125
|
+
if not isinstance(match, dict) or not isinstance(match.get("name"), str):
|
|
126
|
+
raise CollectionError(
|
|
127
|
+
f"step {step_id} message_inject.after_tool_call requires "
|
|
128
|
+
"match.name"
|
|
129
|
+
)
|
|
130
|
+
match_unknown = set(match) - {"name", "arguments", "call"}
|
|
131
|
+
if match_unknown:
|
|
132
|
+
raise CollectionError(
|
|
133
|
+
f"step {step_id} message_inject.match has unknown fields: "
|
|
134
|
+
f"{sorted(match_unknown)}"
|
|
135
|
+
)
|
|
136
|
+
if "arguments" in match and not isinstance(match["arguments"], dict):
|
|
137
|
+
raise CollectionError(
|
|
138
|
+
f"step {step_id} message_inject.match.arguments must be an object"
|
|
139
|
+
)
|
|
140
|
+
if "call" in match and (
|
|
141
|
+
not isinstance(match["call"], int) or match["call"] < 1
|
|
142
|
+
):
|
|
143
|
+
raise CollectionError(
|
|
144
|
+
f"step {step_id} message_inject.match.call must be a "
|
|
145
|
+
"positive integer"
|
|
146
|
+
)
|
|
147
|
+
elif match is not None:
|
|
148
|
+
raise CollectionError(
|
|
149
|
+
f"step {step_id} message_inject.match is only valid for "
|
|
150
|
+
"position: after_tool_call"
|
|
151
|
+
)
|
|
152
|
+
resolved.append(
|
|
153
|
+
{
|
|
154
|
+
"position": position,
|
|
155
|
+
"message": resolved_message,
|
|
156
|
+
**({"match": deepcopy(match)} if match is not None else {}),
|
|
157
|
+
}
|
|
158
|
+
)
|
|
159
|
+
return resolved
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _resolve_message_parts(
|
|
163
|
+
evals_root: Path,
|
|
164
|
+
content: Any,
|
|
165
|
+
fixture_manifest: list[dict[str, Any]],
|
|
166
|
+
) -> Any:
|
|
167
|
+
if isinstance(content, str):
|
|
168
|
+
return content
|
|
169
|
+
if not isinstance(content, list) or not content:
|
|
170
|
+
raise CollectionError("message content must be a string or non-empty part list")
|
|
171
|
+
resolved: list[dict[str, Any]] = []
|
|
172
|
+
for part in content:
|
|
173
|
+
if not isinstance(part, dict) or part.get("type") not in {
|
|
174
|
+
"text",
|
|
175
|
+
"input_image",
|
|
176
|
+
"input_file",
|
|
177
|
+
}:
|
|
178
|
+
raise CollectionError(f"unsupported message part: {part!r}")
|
|
179
|
+
allowed_fields = (
|
|
180
|
+
{"type", "text"}
|
|
181
|
+
if part["type"] == "text"
|
|
182
|
+
else {"type", "fixture", "filename", "media_type", "delivery"}
|
|
183
|
+
)
|
|
184
|
+
unsupported_fields = set(part) - allowed_fields
|
|
185
|
+
if unsupported_fields:
|
|
186
|
+
names = ", ".join(sorted(unsupported_fields))
|
|
187
|
+
raise CollectionError(
|
|
188
|
+
f"message part contains provider-specific or unknown fields: {names}"
|
|
189
|
+
)
|
|
190
|
+
if part["type"] == "text":
|
|
191
|
+
if not isinstance(part.get("text"), str):
|
|
192
|
+
raise CollectionError("text part requires text")
|
|
193
|
+
resolved.append({"type": "text", "text": part["text"]})
|
|
194
|
+
continue
|
|
195
|
+
fixture = (evals_root / str(part.get("fixture", ""))).resolve()
|
|
196
|
+
if not fixture.is_file():
|
|
197
|
+
raise CollectionError(f"fixture does not exist: {fixture}")
|
|
198
|
+
media_type = part.get("media_type") or mimetypes.guess_type(fixture.name)[0]
|
|
199
|
+
if not media_type:
|
|
200
|
+
raise CollectionError(f"cannot infer fixture media type: {fixture}")
|
|
201
|
+
entry = {
|
|
202
|
+
"type": part["type"],
|
|
203
|
+
"fixture": str(fixture),
|
|
204
|
+
"filename": part.get("filename") or fixture.name,
|
|
205
|
+
"media_type": media_type,
|
|
206
|
+
"bytes": fixture.stat().st_size,
|
|
207
|
+
"sha256": sha256_file(fixture),
|
|
208
|
+
}
|
|
209
|
+
delivery = part.get("delivery", "file_path_inline")
|
|
210
|
+
legacy_deliveries = {
|
|
211
|
+
"metadata": "file_path_inline",
|
|
212
|
+
"content": "file_content",
|
|
213
|
+
}
|
|
214
|
+
delivery = legacy_deliveries.get(delivery, delivery)
|
|
215
|
+
if delivery not in {"file_path_inline", "file_content"}:
|
|
216
|
+
raise CollectionError(
|
|
217
|
+
"file delivery must be file_path_inline or file_content"
|
|
218
|
+
)
|
|
219
|
+
entry["delivery"] = delivery
|
|
220
|
+
resolved.append(entry)
|
|
221
|
+
if not any(
|
|
222
|
+
existing["sha256"] == entry["sha256"] for existing in fixture_manifest
|
|
223
|
+
):
|
|
224
|
+
fixture_manifest.append(deepcopy(entry))
|
|
225
|
+
return resolved
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def resolve_case(
|
|
229
|
+
path: Path,
|
|
230
|
+
plugin_root: Path,
|
|
231
|
+
workspace: WorkspaceConfig,
|
|
232
|
+
plugin_config: dict[str, Any],
|
|
233
|
+
) -> ResolvedCase:
|
|
234
|
+
raw = load_yaml(path)
|
|
235
|
+
evals_root = (plugin_root / "__evals__").resolve()
|
|
236
|
+
context = raw.get("context")
|
|
237
|
+
steps = raw.get("steps")
|
|
238
|
+
if not isinstance(context, dict):
|
|
239
|
+
raise CollectionError(f"context is required: {path}")
|
|
240
|
+
if "skill_tool" in context:
|
|
241
|
+
raise CollectionError(
|
|
242
|
+
"context.skill_tool has been removed; declare {builtin: skill} "
|
|
243
|
+
"in context.tools"
|
|
244
|
+
)
|
|
245
|
+
if not isinstance(steps, list) or not steps:
|
|
246
|
+
raise CollectionError(f"steps must be a non-empty list: {path}")
|
|
247
|
+
|
|
248
|
+
conventions = {
|
|
249
|
+
"system_prompt": plugin_config.get("system_prompt", "SYSTEM.md"),
|
|
250
|
+
"skills_dir": plugin_config.get("skills_dir", "skills"),
|
|
251
|
+
"skill_entry": plugin_config.get("skill_entry", "SKILL.md"),
|
|
252
|
+
}
|
|
253
|
+
system_config = context.get("system_prompt", {"default": True})
|
|
254
|
+
if system_config is None or system_config == {"default": True}:
|
|
255
|
+
system_path = plugin_root / conventions["system_prompt"]
|
|
256
|
+
elif isinstance(system_config, dict) and isinstance(system_config.get("file"), str):
|
|
257
|
+
system_path = evals_root / system_config["file"]
|
|
258
|
+
else:
|
|
259
|
+
raise CollectionError(
|
|
260
|
+
"context.system_prompt must use {default: true} or {file: ...}"
|
|
261
|
+
)
|
|
262
|
+
if not system_path.is_file():
|
|
263
|
+
raise CollectionError(f"system prompt does not exist: {system_path}")
|
|
264
|
+
|
|
265
|
+
skills = [
|
|
266
|
+
_load_skill(
|
|
267
|
+
plugin_root, conventions["skills_dir"], conventions["skill_entry"], item
|
|
268
|
+
)
|
|
269
|
+
for item in context.get("skills", [])
|
|
270
|
+
]
|
|
271
|
+
names = [skill.name for skill in skills]
|
|
272
|
+
if len(names) != len(set(names)):
|
|
273
|
+
raise CollectionError("context.skills contains duplicate names")
|
|
274
|
+
rendered_system = _render_system_prompt(
|
|
275
|
+
system_path.read_text(encoding="utf-8"), skills
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
mocks = deepcopy(raw.get("mocks", {}))
|
|
279
|
+
if not isinstance(mocks, dict):
|
|
280
|
+
raise CollectionError("mocks must be an object")
|
|
281
|
+
tool_items = context.get("tools", [])
|
|
282
|
+
if not isinstance(tool_items, list):
|
|
283
|
+
raise CollectionError("context.tools must be a list")
|
|
284
|
+
tools = [
|
|
285
|
+
load_tool(
|
|
286
|
+
evals_root,
|
|
287
|
+
item,
|
|
288
|
+
skills=skills,
|
|
289
|
+
conventions=conventions,
|
|
290
|
+
mocks=mocks,
|
|
291
|
+
)
|
|
292
|
+
for item in tool_items
|
|
293
|
+
]
|
|
294
|
+
if skills and not any(
|
|
295
|
+
isinstance(item, dict) and item.get("builtin") == "skill"
|
|
296
|
+
for item in tool_items
|
|
297
|
+
):
|
|
298
|
+
raise CollectionError(
|
|
299
|
+
"context.tools must include {builtin: skill} when context.skills "
|
|
300
|
+
"is non-empty"
|
|
301
|
+
)
|
|
302
|
+
tool_names = [tool.name for tool in tools]
|
|
303
|
+
if len(tool_names) != len(set(tool_names)):
|
|
304
|
+
raise CollectionError("context.tools contains duplicate names")
|
|
305
|
+
if "cli" in mocks:
|
|
306
|
+
raise CollectionError(
|
|
307
|
+
"mocks.cli requires context.tools to include {builtin: bash}"
|
|
308
|
+
)
|
|
309
|
+
if "fs" in mocks:
|
|
310
|
+
raise CollectionError(
|
|
311
|
+
"mocks.fs requires context.tools to include {builtin: bash}"
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
fixture_manifest: list[dict[str, Any]] = []
|
|
315
|
+
initial_messages: list[dict[str, Any]] = [
|
|
316
|
+
{"role": "system", "content": rendered_system}
|
|
317
|
+
]
|
|
318
|
+
for message in context.get("messages", []):
|
|
319
|
+
if not isinstance(message, dict) or message.get("role") not in {
|
|
320
|
+
"user",
|
|
321
|
+
"assistant",
|
|
322
|
+
"tool",
|
|
323
|
+
}:
|
|
324
|
+
raise CollectionError(f"invalid context message: {message!r}")
|
|
325
|
+
resolved = deepcopy(message)
|
|
326
|
+
resolved["content"] = _resolve_message_parts(
|
|
327
|
+
evals_root, message.get("content"), fixture_manifest
|
|
328
|
+
)
|
|
329
|
+
initial_messages.append(resolved)
|
|
330
|
+
|
|
331
|
+
resolved_steps: list[dict[str, Any]] = []
|
|
332
|
+
step_ids: set[str] = set()
|
|
333
|
+
for index, step in enumerate(steps):
|
|
334
|
+
if not isinstance(step, dict) or not isinstance(step.get("id"), str):
|
|
335
|
+
raise CollectionError(f"step {index + 1} requires an id")
|
|
336
|
+
if step["id"] in step_ids:
|
|
337
|
+
raise CollectionError(f"duplicate step id: {step['id']}")
|
|
338
|
+
step_ids.add(step["id"])
|
|
339
|
+
if "user" not in step:
|
|
340
|
+
raise CollectionError(f"step requires user: {step['id']}")
|
|
341
|
+
resolved = deepcopy(step)
|
|
342
|
+
resolved["user"] = _resolve_message_parts(
|
|
343
|
+
evals_root, step["user"], fixture_manifest
|
|
344
|
+
)
|
|
345
|
+
if "message_inject" in step:
|
|
346
|
+
resolved["message_inject"] = _resolve_message_inject(
|
|
347
|
+
evals_root, step["message_inject"], fixture_manifest, step["id"]
|
|
348
|
+
)
|
|
349
|
+
resolved["index"] = index
|
|
350
|
+
resolved_steps.append(resolved)
|
|
351
|
+
|
|
352
|
+
model = deepcopy(workspace.defaults["model"])
|
|
353
|
+
model.update(plugin_config.get("model", {}))
|
|
354
|
+
model.update(raw.get("model", {}))
|
|
355
|
+
execution = deepcopy(workspace.defaults["execution"])
|
|
356
|
+
execution.update(plugin_config.get("execution", {}))
|
|
357
|
+
execution.update(raw.get("execution", {}))
|
|
358
|
+
try:
|
|
359
|
+
case_key = path.resolve().relative_to(plugin_root.resolve()).as_posix()
|
|
360
|
+
except ValueError:
|
|
361
|
+
case_key = path.name
|
|
362
|
+
return ResolvedCase(
|
|
363
|
+
path=path.resolve(),
|
|
364
|
+
case_key=case_key,
|
|
365
|
+
plugin_root=plugin_root.resolve(),
|
|
366
|
+
evals_root=evals_root,
|
|
367
|
+
model=model,
|
|
368
|
+
context=context,
|
|
369
|
+
mocks=mocks,
|
|
370
|
+
execution=execution,
|
|
371
|
+
steps=resolved_steps,
|
|
372
|
+
outcome=deepcopy(raw.get("outcome", {})),
|
|
373
|
+
system_prompt=rendered_system,
|
|
374
|
+
skills=skills,
|
|
375
|
+
tools=tools,
|
|
376
|
+
initial_messages=initial_messages,
|
|
377
|
+
fixture_manifest=fixture_manifest,
|
|
378
|
+
)
|
yama/loaders/common.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import yaml
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class CollectionError(ValueError):
|
|
11
|
+
pass
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def load_yaml(path: Path) -> dict[str, Any]:
|
|
15
|
+
try:
|
|
16
|
+
value = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
17
|
+
except (OSError, yaml.YAMLError) as exc:
|
|
18
|
+
raise CollectionError(f"cannot load YAML {path}: {exc}") from exc
|
|
19
|
+
if not isinstance(value, dict):
|
|
20
|
+
raise CollectionError(f"YAML root must be an object: {path}")
|
|
21
|
+
return value
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def sha256_file(path: Path) -> str:
|
|
25
|
+
digest = hashlib.sha256()
|
|
26
|
+
with path.open("rb") as handle:
|
|
27
|
+
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
|
28
|
+
digest.update(chunk)
|
|
29
|
+
return digest.hexdigest()
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from ..mocks import MockConfigurationError
|
|
7
|
+
from ..models import LoadedSkill, ToolSchema
|
|
8
|
+
from ..tools.bash.bash import normalize_cli_config
|
|
9
|
+
from ..tools.bash.fs import normalize_fs_config
|
|
10
|
+
from .common import CollectionError, load_yaml
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def load_tool(
|
|
14
|
+
evals_root: Path,
|
|
15
|
+
item: Any,
|
|
16
|
+
*,
|
|
17
|
+
skills: list[LoadedSkill],
|
|
18
|
+
conventions: dict[str, str],
|
|
19
|
+
mocks: dict[str, Any],
|
|
20
|
+
) -> ToolSchema:
|
|
21
|
+
if not isinstance(item, dict) or sum(
|
|
22
|
+
key in item for key in ("builtin", "file", "inline")
|
|
23
|
+
) != 1:
|
|
24
|
+
raise CollectionError(
|
|
25
|
+
"each context.tools entry must choose exactly one of builtin, file or inline"
|
|
26
|
+
)
|
|
27
|
+
if "builtin" in item:
|
|
28
|
+
if set(item) != {"builtin"}:
|
|
29
|
+
raise CollectionError("builtin tool entries cannot contain other fields")
|
|
30
|
+
if item["builtin"] == "skill":
|
|
31
|
+
return _resolve_builtin_skill_tool(skills, conventions, mocks)
|
|
32
|
+
if item["builtin"] == "bash":
|
|
33
|
+
return _resolve_builtin_bash_tool(mocks, evals_root)
|
|
34
|
+
raise CollectionError(f"unknown builtin tool: {item['builtin']!r}")
|
|
35
|
+
source_path: Path | None = None
|
|
36
|
+
if "file" in item:
|
|
37
|
+
source_path = (evals_root / str(item["file"])).resolve()
|
|
38
|
+
if source_path.suffix not in {".yaml", ".yml"}:
|
|
39
|
+
raise CollectionError(f"tool schema must be YAML: {source_path}")
|
|
40
|
+
raw = load_yaml(source_path)
|
|
41
|
+
else:
|
|
42
|
+
raw = item["inline"]
|
|
43
|
+
if not isinstance(raw, dict):
|
|
44
|
+
raise CollectionError("inline tool schema must be an object")
|
|
45
|
+
name = raw.get("name")
|
|
46
|
+
description = raw.get("description")
|
|
47
|
+
parameters = raw.get("parameters")
|
|
48
|
+
if not isinstance(name, str) or not name:
|
|
49
|
+
raise CollectionError("tool name is required")
|
|
50
|
+
if not isinstance(description, str) or not description:
|
|
51
|
+
raise CollectionError(f"tool description is required: {name}")
|
|
52
|
+
if not isinstance(parameters, dict) or parameters.get("type") != "object":
|
|
53
|
+
raise CollectionError(f"tool parameters root must have type=object: {name}")
|
|
54
|
+
return ToolSchema(name, description, parameters, source_path)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _skill_tool_schema(skills: list[LoadedSkill]) -> ToolSchema:
|
|
58
|
+
names = [skill.name for skill in skills]
|
|
59
|
+
return ToolSchema(
|
|
60
|
+
name="skill",
|
|
61
|
+
description="Read an enabled Skill entry or one file inside that Skill directory.",
|
|
62
|
+
parameters={
|
|
63
|
+
"type": "object",
|
|
64
|
+
"properties": {
|
|
65
|
+
"name": {
|
|
66
|
+
"type": "string",
|
|
67
|
+
"enum": names,
|
|
68
|
+
"description": "Enabled Skill name.",
|
|
69
|
+
},
|
|
70
|
+
"file": {
|
|
71
|
+
"type": "string",
|
|
72
|
+
"description": "Optional path relative to the selected Skill directory.",
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
"required": ["name"],
|
|
76
|
+
"additionalProperties": False,
|
|
77
|
+
},
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _resolve_builtin_skill_tool(
|
|
82
|
+
skills: list[LoadedSkill],
|
|
83
|
+
conventions: dict[str, str],
|
|
84
|
+
mocks: dict[str, Any],
|
|
85
|
+
) -> ToolSchema:
|
|
86
|
+
if not skills:
|
|
87
|
+
raise CollectionError(
|
|
88
|
+
"context.tools builtin skill requires at least one context.skills entry"
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
mock_tools = mocks.setdefault("tools", {})
|
|
92
|
+
if not isinstance(mock_tools, dict):
|
|
93
|
+
raise CollectionError("mocks.tools must be an object")
|
|
94
|
+
if "skill" not in mock_tools:
|
|
95
|
+
mock_tools["skill"] = {
|
|
96
|
+
"match": {
|
|
97
|
+
"arguments": {
|
|
98
|
+
"name": {"one_of": [skill.name for skill in skills]},
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
"handler": {
|
|
102
|
+
"python": "yama.tools.skill.skill:read_skill",
|
|
103
|
+
"with": {
|
|
104
|
+
"enabled": [skill.name for skill in skills],
|
|
105
|
+
"skills_dir": conventions["skills_dir"],
|
|
106
|
+
"skill_entry": conventions["skill_entry"],
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
}
|
|
110
|
+
return _skill_tool_schema(skills)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _bash_tool_schema() -> ToolSchema:
|
|
114
|
+
return ToolSchema(
|
|
115
|
+
name="bash",
|
|
116
|
+
description=(
|
|
117
|
+
"Execute a bash command and return its stdout, stderr and exit code."
|
|
118
|
+
),
|
|
119
|
+
parameters={
|
|
120
|
+
"type": "object",
|
|
121
|
+
"properties": {
|
|
122
|
+
"command": {
|
|
123
|
+
"type": "string",
|
|
124
|
+
"description": "The bash command to execute.",
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
"required": ["command"],
|
|
128
|
+
"additionalProperties": False,
|
|
129
|
+
},
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _resolve_builtin_bash_tool(mocks: dict[str, Any], evals_root: Path) -> ToolSchema:
|
|
134
|
+
mock_tools = mocks.setdefault("tools", {})
|
|
135
|
+
if not isinstance(mock_tools, dict):
|
|
136
|
+
raise CollectionError("mocks.tools must be an object")
|
|
137
|
+
cli_raw = mocks.pop("cli", {})
|
|
138
|
+
fs_raw = mocks.pop("fs", None)
|
|
139
|
+
if cli_raw and fs_raw is not None:
|
|
140
|
+
raise CollectionError("mocks.cli cannot be combined with mocks.fs")
|
|
141
|
+
try:
|
|
142
|
+
cli = normalize_cli_config(cli_raw, base_dir=evals_root, root=evals_root.parent)
|
|
143
|
+
except MockConfigurationError as exc:
|
|
144
|
+
raise CollectionError(str(exc)) from exc
|
|
145
|
+
fs_normalized: dict[str, Any] | None = None
|
|
146
|
+
if fs_raw is not None:
|
|
147
|
+
try:
|
|
148
|
+
fs_normalized = normalize_fs_config(
|
|
149
|
+
fs_raw, base_dir=evals_root, root=evals_root.parent
|
|
150
|
+
)
|
|
151
|
+
except MockConfigurationError as exc:
|
|
152
|
+
raise CollectionError(str(exc)) from exc
|
|
153
|
+
if "bash" in mock_tools:
|
|
154
|
+
# Check the raw pre-normalization values: normalize_cli_config always
|
|
155
|
+
# returns a non-empty dict (passthrough defaults), so testing the
|
|
156
|
+
# normalized `cli` here would flag every custom handler as conflicting
|
|
157
|
+
# even when the case never declared mocks.cli/mocks.fs at all.
|
|
158
|
+
if cli_raw or fs_raw is not None:
|
|
159
|
+
raise CollectionError(
|
|
160
|
+
"mocks.cli/mocks.fs cannot be combined with a custom "
|
|
161
|
+
"mocks.tools.bash handler"
|
|
162
|
+
)
|
|
163
|
+
else:
|
|
164
|
+
handler_with: dict[str, Any] = (
|
|
165
|
+
{"fs": fs_normalized} if fs_normalized is not None else {"cli": cli}
|
|
166
|
+
)
|
|
167
|
+
mock_tools["bash"] = {
|
|
168
|
+
"handler": {
|
|
169
|
+
"python": "yama.tools.bash.bash:run_bash",
|
|
170
|
+
"with": handler_with,
|
|
171
|
+
},
|
|
172
|
+
}
|
|
173
|
+
return _bash_tool_schema()
|