kiwimatecoder 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. kiwimatecoder/__init__.py +8 -0
  2. kiwimatecoder/__main__.py +12 -0
  3. kiwimatecoder/acp/__init__.py +13 -0
  4. kiwimatecoder/acp/protocol.py +115 -0
  5. kiwimatecoder/acp/server.py +823 -0
  6. kiwimatecoder/acp/stdio.py +32 -0
  7. kiwimatecoder/agent.py +1162 -0
  8. kiwimatecoder/ai.py +68 -0
  9. kiwimatecoder/audit.py +65 -0
  10. kiwimatecoder/browser.py +261 -0
  11. kiwimatecoder/catalog.py +445 -0
  12. kiwimatecoder/checkpoints.py +103 -0
  13. kiwimatecoder/client.py +820 -0
  14. kiwimatecoder/clipboard.py +99 -0
  15. kiwimatecoder/commands.py +6757 -0
  16. kiwimatecoder/config.py +5335 -0
  17. kiwimatecoder/diagnostics.py +295 -0
  18. kiwimatecoder/documents.py +299 -0
  19. kiwimatecoder/evals/__init__.py +39 -0
  20. kiwimatecoder/evals/cases.py +230 -0
  21. kiwimatecoder/evals/render.py +46 -0
  22. kiwimatecoder/evals/runner.py +319 -0
  23. kiwimatecoder/events.py +95 -0
  24. kiwimatecoder/forge.py +172 -0
  25. kiwimatecoder/git.py +164 -0
  26. kiwimatecoder/headless.py +255 -0
  27. kiwimatecoder/hooks.py +170 -0
  28. kiwimatecoder/hunks.py +260 -0
  29. kiwimatecoder/i18n.py +368 -0
  30. kiwimatecoder/images.py +195 -0
  31. kiwimatecoder/index/__init__.py +14 -0
  32. kiwimatecoder/index/builder.py +376 -0
  33. kiwimatecoder/index/embeddings.py +151 -0
  34. kiwimatecoder/index/search.py +247 -0
  35. kiwimatecoder/index/store.py +251 -0
  36. kiwimatecoder/instructions.py +83 -0
  37. kiwimatecoder/jobs.py +594 -0
  38. kiwimatecoder/lsp/__init__.py +56 -0
  39. kiwimatecoder/lsp/client.py +548 -0
  40. kiwimatecoder/lsp/manager.py +353 -0
  41. kiwimatecoder/lsp/protocol.py +79 -0
  42. kiwimatecoder/main.py +4258 -0
  43. kiwimatecoder/mcp/__init__.py +45 -0
  44. kiwimatecoder/mcp/client.py +385 -0
  45. kiwimatecoder/mcp/errors.py +7 -0
  46. kiwimatecoder/mcp/manager.py +253 -0
  47. kiwimatecoder/mcp/protocol.py +132 -0
  48. kiwimatecoder/media.py +904 -0
  49. kiwimatecoder/memory.py +141 -0
  50. kiwimatecoder/network.py +112 -0
  51. kiwimatecoder/notify.py +139 -0
  52. kiwimatecoder/permissions.py +171 -0
  53. kiwimatecoder/pickers.py +417 -0
  54. kiwimatecoder/plugins.py +245 -0
  55. kiwimatecoder/pricing.py +167 -0
  56. kiwimatecoder/prompts.py +213 -0
  57. kiwimatecoder/providers.py +342 -0
  58. kiwimatecoder/redaction.py +66 -0
  59. kiwimatecoder/remote.py +294 -0
  60. kiwimatecoder/repl.py +1388 -0
  61. kiwimatecoder/routing.py +65 -0
  62. kiwimatecoder/sandbox.py +169 -0
  63. kiwimatecoder/sdk.py +146 -0
  64. kiwimatecoder/session.py +574 -0
  65. kiwimatecoder/share.py +386 -0
  66. kiwimatecoder/shell.py +660 -0
  67. kiwimatecoder/skills.py +138 -0
  68. kiwimatecoder/sync.py +513 -0
  69. kiwimatecoder/team.py +249 -0
  70. kiwimatecoder/telemetry.py +392 -0
  71. kiwimatecoder/templates.py +108 -0
  72. kiwimatecoder/tools/__init__.py +190 -0
  73. kiwimatecoder/tools/ask.py +62 -0
  74. kiwimatecoder/tools/base.py +69 -0
  75. kiwimatecoder/tools/browser.py +261 -0
  76. kiwimatecoder/tools/edit_file.py +143 -0
  77. kiwimatecoder/tools/forge.py +207 -0
  78. kiwimatecoder/tools/git.py +158 -0
  79. kiwimatecoder/tools/image.py +300 -0
  80. kiwimatecoder/tools/io.py +346 -0
  81. kiwimatecoder/tools/list_dir.py +67 -0
  82. kiwimatecoder/tools/lsp.py +254 -0
  83. kiwimatecoder/tools/memory.py +115 -0
  84. kiwimatecoder/tools/paths.py +234 -0
  85. kiwimatecoder/tools/read_file.py +113 -0
  86. kiwimatecoder/tools/registry.py +91 -0
  87. kiwimatecoder/tools/run_bash.py +189 -0
  88. kiwimatecoder/tools/search.py +224 -0
  89. kiwimatecoder/tools/selection.py +108 -0
  90. kiwimatecoder/tools/shell.py +158 -0
  91. kiwimatecoder/tools/skill.py +46 -0
  92. kiwimatecoder/tools/task.py +61 -0
  93. kiwimatecoder/tools/todo.py +77 -0
  94. kiwimatecoder/tools/web.py +146 -0
  95. kiwimatecoder/tools/write_file.py +83 -0
  96. kiwimatecoder/ui.py +189 -0
  97. kiwimatecoder/updater.py +502 -0
  98. kiwimatecoder/web.py +499 -0
  99. kiwimatecoder-0.1.0.dist-info/METADATA +2016 -0
  100. kiwimatecoder-0.1.0.dist-info/RECORD +104 -0
  101. kiwimatecoder-0.1.0.dist-info/WHEEL +5 -0
  102. kiwimatecoder-0.1.0.dist-info/entry_points.txt +2 -0
  103. kiwimatecoder-0.1.0.dist-info/licenses/LICENSE +21 -0
  104. kiwimatecoder-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,230 @@
1
+ """Load and validate declarative eval cases from JSON files.
2
+
3
+ A case is a small unit of expected agent behavior: a prompt, an optional
4
+ starting workspace, and deterministic expectations about the run's text,
5
+ tools, and resulting files. See ``evals/README.md`` for the schema.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from dataclasses import dataclass, field
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from kiwimatecoder.config import has_path_anchor
16
+
17
+ PACKAGE_ROOT = Path(__file__).resolve().parents[2]
18
+ DEFAULT_CASES_DIR = PACKAGE_ROOT / "evals" / "cases"
19
+
20
+ _TOP_LEVEL_KEYS = frozenset({"name", "description", "prompt", "files", "expect"})
21
+ _EXPECT_KEYS = frozenset(
22
+ {
23
+ "text_contains",
24
+ "text_not_contains",
25
+ "tools_called",
26
+ "tools_not_called",
27
+ "files",
28
+ "success",
29
+ }
30
+ )
31
+ _LIST_FIELDS = (
32
+ "text_contains",
33
+ "text_not_contains",
34
+ "tools_called",
35
+ "tools_not_called",
36
+ )
37
+
38
+
39
+ class CaseError(ValueError):
40
+ """Raised when an eval case file is missing, unreadable, or malformed."""
41
+
42
+
43
+ def _fail(source: Path | None, message: str) -> CaseError:
44
+ where = f"{source}: " if source is not None else ""
45
+ return CaseError(f"{where}{message}")
46
+
47
+
48
+ def _non_empty_str(value: Any, *, key: str, source: Path | None) -> str:
49
+ if not isinstance(value, str) or not value.strip():
50
+ raise _fail(source, f"'{key}' must be a non-empty string")
51
+ return value
52
+
53
+
54
+ def _string_list(value: Any, *, key: str, source: Path | None) -> tuple[str, ...]:
55
+ if not isinstance(value, list):
56
+ raise _fail(source, f"'{key}' must be a list of strings")
57
+ for item in value:
58
+ if not isinstance(item, str) or not item:
59
+ raise _fail(source, f"'{key}' entries must be non-empty strings")
60
+ return tuple(value)
61
+
62
+
63
+ def _safe_relpath(value: Any, *, key: str, source: Path | None) -> str:
64
+ if not isinstance(value, str) or not value.strip():
65
+ raise _fail(source, f"'{key}' paths must be non-empty strings")
66
+ if "\\" in value:
67
+ raise _fail(source, f"'{key}' path {value!r} must use forward slashes")
68
+ path = Path(value)
69
+ if has_path_anchor(path) or value.startswith("~"):
70
+ raise _fail(source, f"'{key}' path {value!r} must be relative to the workspace")
71
+ if ".." in path.parts:
72
+ raise _fail(source, f"'{key}' path {value!r} must not contain '..'")
73
+ return value
74
+
75
+
76
+ def _string_map(value: Any, *, key: str, source: Path | None) -> dict[str, str]:
77
+ if not isinstance(value, dict):
78
+ raise _fail(source, f"'{key}' must be an object mapping paths to contents")
79
+ result: dict[str, str] = {}
80
+ for raw_path, content in value.items():
81
+ relpath = _safe_relpath(raw_path, key=key, source=source)
82
+ if not isinstance(content, str):
83
+ raise _fail(source, f"'{key}' value for {relpath!r} must be a string")
84
+ result[relpath] = content
85
+ return result
86
+
87
+
88
+ def _expect_files(value: Any, *, key: str, source: Path | None) -> dict[str, tuple[str, ...]]:
89
+ if not isinstance(value, dict):
90
+ raise _fail(
91
+ source, f"expect.'{key}' must be an object mapping paths to string lists"
92
+ )
93
+ result: dict[str, tuple[str, ...]] = {}
94
+ for raw_path, needles in value.items():
95
+ relpath = _safe_relpath(raw_path, key=f"expect.{key}", source=source)
96
+ result[relpath] = _string_list(needles, key=f"expect.{key}.{relpath}", source=source)
97
+ return result
98
+
99
+
100
+ @dataclass
101
+ class Expectation:
102
+ """Deterministic checks applied to one :class:`~kiwimatecoder.sdk.RunResult`."""
103
+
104
+ text_contains: tuple[str, ...] = ()
105
+ text_not_contains: tuple[str, ...] = ()
106
+ tools_called: tuple[str, ...] = ()
107
+ tools_not_called: tuple[str, ...] = ()
108
+ files: dict[str, tuple[str, ...]] = field(default_factory=dict)
109
+ success: bool | None = None
110
+
111
+
112
+ @dataclass
113
+ class EvalCase:
114
+ """One prompt plus the workspace files and expectations for its run."""
115
+
116
+ name: str
117
+ prompt: str
118
+ expect: Expectation = field(default_factory=Expectation)
119
+ description: str = ""
120
+ files: dict[str, str] = field(default_factory=dict)
121
+ source: Path | None = None
122
+
123
+
124
+ def parse_case(payload: Any, *, source: Path | None = None) -> EvalCase:
125
+ """Validate an already-decoded case payload and return an :class:`EvalCase`."""
126
+ if not isinstance(payload, dict):
127
+ raise _fail(source, "case must be a JSON object")
128
+
129
+ unknown = sorted(set(payload) - _TOP_LEVEL_KEYS)
130
+ if unknown:
131
+ raise _fail(source, f"unknown key(s): {', '.join(unknown)}")
132
+
133
+ name = _non_empty_str(payload.get("name"), key="name", source=source)
134
+ prompt = _non_empty_str(payload.get("prompt"), key="prompt", source=source)
135
+
136
+ description = payload.get("description", "")
137
+ if not isinstance(description, str):
138
+ raise _fail(source, "'description' must be a string")
139
+
140
+ files: dict[str, str] = {}
141
+ if "files" in payload:
142
+ files = _string_map(payload["files"], key="files", source=source)
143
+
144
+ if "expect" not in payload:
145
+ raise _fail(source, "'expect' is required")
146
+ expect_payload = payload["expect"]
147
+ if not isinstance(expect_payload, dict):
148
+ raise _fail(source, "'expect' must be an object")
149
+ unknown_expect = sorted(set(expect_payload) - _EXPECT_KEYS)
150
+ if unknown_expect:
151
+ raise _fail(source, f"unknown expect key(s): {', '.join(unknown_expect)}")
152
+
153
+ success = expect_payload.get("success")
154
+ if success is not None and not isinstance(success, bool):
155
+ raise _fail(source, "'expect.success' must be a boolean")
156
+
157
+ expect = Expectation(
158
+ text_contains=_string_list(
159
+ expect_payload.get("text_contains", []), key="expect.text_contains", source=source
160
+ ),
161
+ text_not_contains=_string_list(
162
+ expect_payload.get("text_not_contains", []),
163
+ key="expect.text_not_contains",
164
+ source=source,
165
+ ),
166
+ tools_called=_string_list(
167
+ expect_payload.get("tools_called", []), key="expect.tools_called", source=source
168
+ ),
169
+ tools_not_called=_string_list(
170
+ expect_payload.get("tools_not_called", []),
171
+ key="expect.tools_not_called",
172
+ source=source,
173
+ ),
174
+ files=_expect_files(expect_payload.get("files", {}), key="files", source=source),
175
+ success=success,
176
+ )
177
+ return EvalCase(
178
+ name=name,
179
+ prompt=prompt,
180
+ expect=expect,
181
+ description=description,
182
+ files=files,
183
+ source=source,
184
+ )
185
+
186
+
187
+ def load_case(path: str | Path) -> EvalCase:
188
+ """Read and validate one ``*.json`` case file."""
189
+ source = Path(path)
190
+ try:
191
+ raw = source.read_text(encoding="utf-8")
192
+ except OSError as exc:
193
+ raise _fail(source, f"could not read case: {exc}") from exc
194
+ try:
195
+ payload = json.loads(raw)
196
+ except json.JSONDecodeError as exc:
197
+ raise _fail(source, f"invalid JSON: {exc}") from exc
198
+ return parse_case(payload, source=source)
199
+
200
+
201
+ def resolve_cases_dir(directory: str | Path | None = None) -> Path:
202
+ """Return the cases directory, falling back to ``./evals/cases`` when installed."""
203
+ if directory is not None:
204
+ return Path(directory).expanduser()
205
+ if DEFAULT_CASES_DIR.is_dir():
206
+ return DEFAULT_CASES_DIR
207
+ return Path.cwd() / "evals" / "cases"
208
+
209
+
210
+ def discover_cases(directory: str | Path | None = None) -> list[EvalCase]:
211
+ """Load every ``*.json`` case under ``directory``, sorted by case name.
212
+
213
+ Raises :class:`CaseError` for a missing directory, a malformed case file,
214
+ or duplicate case names.
215
+ """
216
+ root = resolve_cases_dir(directory)
217
+ if not root.is_dir():
218
+ raise CaseError(f"Eval cases directory not found: {root}")
219
+ cases: list[EvalCase] = []
220
+ seen: dict[str, Path] = {}
221
+ for path in sorted(root.glob("*.json")):
222
+ case = load_case(path)
223
+ if case.name in seen:
224
+ raise CaseError(
225
+ f"Duplicate eval case name {case.name!r} in {path} and {seen[case.name]}"
226
+ )
227
+ seen[case.name] = path
228
+ cases.append(case)
229
+ cases.sort(key=lambda case: case.name.lower())
230
+ return cases
@@ -0,0 +1,46 @@
1
+ """Rich table and JSON rendering for eval reports."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+
7
+ from rich.console import Console
8
+ from rich.markup import escape
9
+ from rich.table import Table
10
+
11
+ from kiwimatecoder.evals.runner import EvalReport
12
+
13
+
14
+ def report_to_json(report: EvalReport) -> str:
15
+ """Serialize a report as pretty-printed JSON."""
16
+ return json.dumps(report.to_dict(), indent=2)
17
+
18
+
19
+ def build_report_table(report: EvalReport) -> Table:
20
+ """Build the per-case result table shown by ``kiwimatecoder eval run``."""
21
+ table = Table(title="Eval results")
22
+ table.add_column("Case", style="cyan", no_wrap=True)
23
+ table.add_column("Result", no_wrap=True)
24
+ table.add_column("Details", overflow="fold")
25
+ table.add_column("Time", justify="right", no_wrap=True)
26
+ for result in report.results:
27
+ label = "PASS" if result.success else "FAIL"
28
+ style = "green" if result.success else "red"
29
+ details = "; ".join(result.reasons)
30
+ table.add_row(
31
+ escape(result.name),
32
+ f"[{style}]{label}[/{style}]",
33
+ escape(details),
34
+ f"{result.duration_s:.1f}s",
35
+ )
36
+ return table
37
+
38
+
39
+ def print_report(report: EvalReport, console: Console | None = None) -> None:
40
+ """Print the result table plus a one-line summary."""
41
+ console = console or Console()
42
+ console.print(build_report_table(report))
43
+ style = "green" if report.success else "red"
44
+ console.print(
45
+ f"[{style}]{report.passed}/{report.total} eval case(s) passed[/{style}]"
46
+ )
@@ -0,0 +1,319 @@
1
+ """Run eval cases against the SDK and evaluate their expectations.
2
+
3
+ ``run_case`` materializes a case into a fresh temp workspace, drives
4
+ :func:`kiwimatecoder.sdk.run_agent_sync`, and turns every expectation into a
5
+ human-readable failure reason. ``run_suite`` runs a directory of cases and can
6
+ write a JSON report. Neither raises for a failing case: only invalid input
7
+ (missing directory, malformed case, no matching cases) raises ``CaseError``.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import tempfile
14
+ import threading
15
+ import time
16
+ from dataclasses import dataclass, field
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ from kiwimatecoder import config, sdk
21
+ from kiwimatecoder.evals.cases import (
22
+ CaseError,
23
+ EvalCase,
24
+ discover_cases,
25
+ resolve_cases_dir,
26
+ )
27
+ from kiwimatecoder.tools.paths import atomic_write_text
28
+
29
+ DEFAULT_TIMEOUT = 300.0
30
+ DEFAULT_MAX_TURNS = 20
31
+
32
+
33
+ class EvalTimeout(TimeoutError):
34
+ """Raised internally when a case exceeds its ``timeout``."""
35
+
36
+
37
+ @dataclass
38
+ class EvalResult:
39
+ """The outcome of one eval case: a pass/fail plus human-readable reasons."""
40
+
41
+ name: str
42
+ success: bool
43
+ reasons: list[str] = field(default_factory=list)
44
+ text: str = ""
45
+ tools_used: list[str] = field(default_factory=list)
46
+ error: str | None = None
47
+ duration_s: float = 0.0
48
+
49
+ def to_dict(self) -> dict[str, Any]:
50
+ return {
51
+ "name": self.name,
52
+ "success": self.success,
53
+ "reasons": list(self.reasons),
54
+ "duration_s": round(self.duration_s, 3),
55
+ "error": self.error,
56
+ "tools_used": list(self.tools_used),
57
+ "text": self.text,
58
+ }
59
+
60
+
61
+ @dataclass
62
+ class EvalReport:
63
+ """The results of running a suite of eval cases."""
64
+
65
+ results: list[EvalResult] = field(default_factory=list)
66
+ provider: str | None = None
67
+ model: str | None = None
68
+ cases_dir: str = ""
69
+
70
+ @property
71
+ def total(self) -> int:
72
+ return len(self.results)
73
+
74
+ @property
75
+ def passed(self) -> int:
76
+ return sum(1 for result in self.results if result.success)
77
+
78
+ @property
79
+ def failed(self) -> int:
80
+ return self.total - self.passed
81
+
82
+ @property
83
+ def success(self) -> bool:
84
+ return self.failed == 0
85
+
86
+ def to_dict(self) -> dict[str, Any]:
87
+ return {
88
+ "success": self.success,
89
+ "total": self.total,
90
+ "passed": self.passed,
91
+ "failed": self.failed,
92
+ "provider": self.provider,
93
+ "model": self.model,
94
+ "cases_dir": self.cases_dir,
95
+ "results": [result.to_dict() for result in self.results],
96
+ }
97
+
98
+
99
+ def materialize_case(case: EvalCase, workspace: str | Path) -> dict[str, Path]:
100
+ """Write the case's starting files into ``workspace`` and return their paths."""
101
+ root = Path(workspace)
102
+ written: dict[str, Path] = {}
103
+ for relpath in sorted(case.files):
104
+ target = root / relpath
105
+ target.parent.mkdir(parents=True, exist_ok=True)
106
+ atomic_write_text(target, case.files[relpath])
107
+ written[relpath] = target
108
+ return written
109
+
110
+
111
+ def _describe_tools(tools: list[str]) -> str:
112
+ return ", ".join(tools) if tools else "none"
113
+
114
+
115
+ def evaluate_case(case: EvalCase, result: sdk.RunResult, workspace: str | Path) -> list[str]:
116
+ """Return human-readable failures for a completed run (empty means passed)."""
117
+ expect = case.expect
118
+ problems: list[str] = []
119
+
120
+ for needle in expect.text_contains:
121
+ if needle not in result.text:
122
+ problems.append(f"text_contains: {needle!r} not found in the reply")
123
+ for needle in expect.text_not_contains:
124
+ if needle in result.text:
125
+ problems.append(f"text_not_contains: {needle!r} found in the reply")
126
+
127
+ used = list(result.tools_used)
128
+ for tool in expect.tools_called:
129
+ if tool not in used:
130
+ problems.append(
131
+ f"tools_called: {tool!r} was not used (used: {_describe_tools(used)})"
132
+ )
133
+ for tool in expect.tools_not_called:
134
+ if tool in used:
135
+ problems.append(f"tools_not_called: {tool!r} was used")
136
+
137
+ root = Path(workspace)
138
+ for relpath in sorted(expect.files):
139
+ target = root / relpath
140
+ if not target.is_file():
141
+ problems.append(f"files: {relpath!r} was not created")
142
+ continue
143
+ content = target.read_text(encoding="utf-8", errors="replace")
144
+ for needle in expect.files[relpath]:
145
+ if needle not in content:
146
+ problems.append(f"files: {relpath!r} does not contain {needle!r}")
147
+
148
+ if expect.success is not None:
149
+ if result.success != expect.success:
150
+ problems.append(f"success: expected {expect.success}, got {result.success}")
151
+ elif result.error:
152
+ problems.append(f"run failed: {result.error}")
153
+ return problems
154
+
155
+
156
+ def _run_with_timeout(
157
+ prompt: str,
158
+ *,
159
+ workspace: Path,
160
+ provider: str | None,
161
+ model: str | None,
162
+ mode: str,
163
+ timeout: float,
164
+ max_turns: int,
165
+ ) -> sdk.RunResult:
166
+ """Run the SDK on a worker thread so ``timeout`` can bound the wait.
167
+
168
+ The worker is a daemon thread, so a timed-out run never blocks process
169
+ exit; its result is discarded.
170
+ """
171
+ box: list[sdk.RunResult] = []
172
+ errors: list[BaseException] = []
173
+
174
+ def target() -> None:
175
+ try:
176
+ box.append(
177
+ sdk.run_agent_sync(
178
+ prompt,
179
+ workspace=workspace,
180
+ provider=provider,
181
+ model=model,
182
+ mode=mode,
183
+ max_turns=max_turns,
184
+ )
185
+ )
186
+ except BaseException as exc: # re-raised on the caller's thread
187
+ errors.append(exc)
188
+
189
+ worker = threading.Thread(target=target, name="kiwimatecoder-eval", daemon=True)
190
+ worker.start()
191
+ worker.join(timeout=timeout)
192
+ if worker.is_alive():
193
+ raise EvalTimeout(f"timed out after {timeout:g}s")
194
+ if errors:
195
+ raise errors[0]
196
+ if not box:
197
+ raise RuntimeError("eval run produced no result")
198
+ return box[0]
199
+
200
+
201
+ def run_case(
202
+ case: EvalCase,
203
+ *,
204
+ provider: str | None = None,
205
+ model: str | None = None,
206
+ mode: str = "auto-accept",
207
+ timeout: float = DEFAULT_TIMEOUT,
208
+ max_turns: int = DEFAULT_MAX_TURNS,
209
+ ) -> EvalResult:
210
+ """Materialize ``case``, run the SDK, and evaluate every expectation.
211
+
212
+ A run error, provider exception, or timeout becomes a failed result with a
213
+ reason; this function does not raise for a failing case.
214
+ """
215
+ started = time.monotonic()
216
+ with tempfile.TemporaryDirectory(prefix="kiwimatecoder-eval-") as tmp:
217
+ workspace = Path(tmp)
218
+ try:
219
+ materialize_case(case, workspace)
220
+ except OSError as exc:
221
+ return EvalResult(
222
+ name=case.name,
223
+ success=False,
224
+ reasons=[f"could not materialize case files: {exc}"],
225
+ duration_s=time.monotonic() - started,
226
+ )
227
+ try:
228
+ result = _run_with_timeout(
229
+ case.prompt,
230
+ workspace=workspace,
231
+ provider=provider,
232
+ model=model,
233
+ mode=mode,
234
+ timeout=timeout,
235
+ max_turns=max_turns,
236
+ )
237
+ except EvalTimeout as exc:
238
+ return EvalResult(
239
+ name=case.name,
240
+ success=False,
241
+ reasons=[str(exc)],
242
+ error="timeout",
243
+ duration_s=time.monotonic() - started,
244
+ )
245
+ except Exception as exc:
246
+ message = f"{type(exc).__name__}: {exc}"
247
+ return EvalResult(
248
+ name=case.name,
249
+ success=False,
250
+ reasons=[f"run failed: {message}"],
251
+ error=message,
252
+ duration_s=time.monotonic() - started,
253
+ )
254
+ reasons = evaluate_case(case, result, workspace)
255
+ return EvalResult(
256
+ name=case.name,
257
+ success=not reasons,
258
+ reasons=reasons,
259
+ text=result.text,
260
+ tools_used=list(result.tools_used),
261
+ error=result.error,
262
+ duration_s=time.monotonic() - started,
263
+ )
264
+
265
+
266
+ def run_suite(
267
+ cases_dir: str | Path | None = None,
268
+ *,
269
+ provider: str | None = None,
270
+ model: str | None = None,
271
+ mode: str = "auto-accept",
272
+ filter: str | None = None,
273
+ report_path: str | Path | None = None,
274
+ timeout: float = DEFAULT_TIMEOUT,
275
+ max_turns: int = DEFAULT_MAX_TURNS,
276
+ ) -> EvalReport:
277
+ """Run every matching case and optionally write a JSON report.
278
+
279
+ Raises :class:`CaseError` for invalid input: a missing directory, no
280
+ matching cases, malformed case files, or an unknown provider / no model
281
+ passed or chosen for it. A case whose expectations fail is reported, never
282
+ raised.
283
+ """
284
+ root = resolve_cases_dir(cases_dir)
285
+ cases = discover_cases(root)
286
+ if filter:
287
+ needle = filter.lower()
288
+ cases = [case for case in cases if needle in case.name.lower()]
289
+ if not cases:
290
+ if filter:
291
+ raise CaseError(f"No eval cases matched {filter!r} in {root}")
292
+ raise CaseError(f"No eval cases found in {root}")
293
+ # One clear error up front instead of every case failing the same way.
294
+ try:
295
+ config.require_model(
296
+ config.get_provider_config(provider or config.get_selected_provider_id()),
297
+ override=model,
298
+ )
299
+ except (KeyError, ValueError) as exc:
300
+ raise CaseError(str(exc)) from exc
301
+
302
+ report = EvalReport(provider=provider, model=model, cases_dir=str(root))
303
+ for case in cases:
304
+ report.results.append(
305
+ run_case(
306
+ case,
307
+ provider=provider,
308
+ model=model,
309
+ mode=mode,
310
+ timeout=timeout,
311
+ max_turns=max_turns,
312
+ )
313
+ )
314
+
315
+ if report_path is not None:
316
+ target = Path(report_path)
317
+ target.parent.mkdir(parents=True, exist_ok=True)
318
+ atomic_write_text(target, json.dumps(report.to_dict(), indent=2) + "\n")
319
+ return report
@@ -0,0 +1,95 @@
1
+ """A tiny synchronous event bus for lifecycle notifications.
2
+
3
+ Dependency-free by design: subscribers receive an :class:`Event` and may return
4
+ any value. :meth:`EventBus.emit` calls them in registration order and collects
5
+ their results. A raising subscriber never breaks the caller -- the exception is
6
+ captured in the results list and dispatch continues to the next subscriber.
7
+
8
+ Module-level helpers proxy to a default :data:`BUS`, which the agent and REPL
9
+ use unless a fresh bus is injected (tests do this for isolation).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from dataclasses import dataclass, field
15
+ from typing import Any, Callable
16
+
17
+ SESSION_START = "session_start"
18
+ SESSION_END = "session_end"
19
+ PRE_TOOL = "pre_tool"
20
+ POST_TOOL = "post_tool"
21
+
22
+ EVENT_NAMES = (SESSION_START, SESSION_END, PRE_TOOL, POST_TOOL)
23
+
24
+ Subscriber = Callable[["Event"], Any]
25
+ Unsubscribe = Callable[[], None]
26
+
27
+
28
+ @dataclass
29
+ class Event:
30
+ """One named lifecycle notification with a free-form payload."""
31
+
32
+ name: str
33
+ payload: dict[str, Any] = field(default_factory=dict)
34
+
35
+
36
+ class EventBus:
37
+ """Synchronous publish/subscribe over named events."""
38
+
39
+ def __init__(self) -> None:
40
+ self._subscribers: dict[str, list[Subscriber]] = {}
41
+
42
+ def subscribe(self, name: str, callback: Subscriber) -> Unsubscribe:
43
+ """Register ``callback`` for ``name``; returns an unsubscribe callable."""
44
+ self._subscribers.setdefault(name, []).append(callback)
45
+
46
+ def unsubscribe() -> None:
47
+ self.unsubscribe(name, callback)
48
+
49
+ return unsubscribe
50
+
51
+ def unsubscribe(self, name: str, callback: Subscriber) -> bool:
52
+ """Remove one subscription; returns whether it was registered."""
53
+ callbacks = self._subscribers.get(name)
54
+ if not callbacks:
55
+ return False
56
+ for index, registered in enumerate(callbacks):
57
+ if registered is callback:
58
+ del callbacks[index]
59
+ if not callbacks:
60
+ self._subscribers.pop(name, None)
61
+ return True
62
+ return False
63
+
64
+ def emit(self, name: str, **payload: Any) -> list[Any]:
65
+ """Call every subscriber for ``name``; collect results and exceptions."""
66
+ event = Event(name=name, payload=payload)
67
+ results: list[Any] = []
68
+ for callback in list(self._subscribers.get(name, [])):
69
+ try:
70
+ results.append(callback(event))
71
+ except Exception as exc: # a bad subscriber must not break emit
72
+ results.append(exc)
73
+ return results
74
+
75
+ def clear(self) -> None:
76
+ """Drop every subscription."""
77
+ self._subscribers.clear()
78
+
79
+
80
+ BUS = EventBus()
81
+
82
+
83
+ def subscribe(name: str, callback: Subscriber) -> Unsubscribe:
84
+ """Subscribe to ``name`` on the default bus."""
85
+ return BUS.subscribe(name, callback)
86
+
87
+
88
+ def unsubscribe(name: str, callback: Subscriber) -> bool:
89
+ """Unsubscribe from ``name`` on the default bus."""
90
+ return BUS.unsubscribe(name, callback)
91
+
92
+
93
+ def emit(name: str, **payload: Any) -> list[Any]:
94
+ """Emit ``name`` on the default bus."""
95
+ return BUS.emit(name, **payload)