py-harness-cli 0.3.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 (92) hide show
  1. finetune/__init__.py +1 -0
  2. finetune/agent_system.py +41 -0
  3. finetune/agent_traces.py +157 -0
  4. finetune/everyday.py +30 -0
  5. finetune/hf_ollama.py +158 -0
  6. finetune/huggingface_store.py +144 -0
  7. finetune/models.py +74 -0
  8. finetune/paths.py +11 -0
  9. finetune/python_vibe.py +788 -0
  10. finetune/splits.py +54 -0
  11. finetune/systems.py +9 -0
  12. harness/__init__.py +42 -0
  13. harness/__main__.py +8 -0
  14. harness/act/__init__.py +6 -0
  15. harness/act/autofix/__init__.py +110 -0
  16. harness/act/autofix/additions.py +217 -0
  17. harness/act/autofix/conflicts.py +124 -0
  18. harness/act/autofix/cover.py +419 -0
  19. harness/act/autofix/mechanical.py +151 -0
  20. harness/act/autofix/missing_imports.py +50 -0
  21. harness/act/autofix/moves.py +439 -0
  22. harness/act/autofix/names.py +339 -0
  23. harness/act/autofix/scaffold.py +224 -0
  24. harness/act/code.py +157 -0
  25. harness/act/gate.py +229 -0
  26. harness/act/parse.py +247 -0
  27. harness/act/patch_fix.py +138 -0
  28. harness/act/tools.py +244 -0
  29. harness/agent/__init__.py +11 -0
  30. harness/agent/dispatch.py +235 -0
  31. harness/agent/loop.py +699 -0
  32. harness/agent/options.py +144 -0
  33. harness/agent/policy.py +856 -0
  34. harness/agent/prompt.py +170 -0
  35. harness/cli.py +393 -0
  36. harness/editor_kit.py +265 -0
  37. harness/guard/__init__.py +6 -0
  38. harness/guard/fallbacks.py +6 -0
  39. harness/guard/loop_guard.py +57 -0
  40. harness/guard/python_vibe.py +68 -0
  41. harness/guard/run.py +41 -0
  42. harness/guard/types.py +19 -0
  43. harness/locate.py +767 -0
  44. harness/mcp_stdio.py +306 -0
  45. harness/memory/__init__.py +5 -0
  46. harness/memory/conversation.py +104 -0
  47. harness/model/__init__.py +6 -0
  48. harness/model/chat_backend.py +100 -0
  49. harness/model/engine.py +165 -0
  50. harness/model/ollama_generate.py +60 -0
  51. harness/model/openai_generate.py +156 -0
  52. harness/model/outbound.py +83 -0
  53. harness/model/route.py +90 -0
  54. harness/observe/__init__.py +6 -0
  55. harness/observe/eval_gate.py +80 -0
  56. harness/observe/eval_loop.py +185 -0
  57. harness/observe/eval_tasks.py +399 -0
  58. harness/observe/report_md.py +102 -0
  59. harness/observe/trace_record.py +79 -0
  60. harness/openai_api.py +81 -0
  61. harness/paths.py +88 -0
  62. harness/py.typed +0 -0
  63. harness/scan/__init__.py +6 -0
  64. harness/scan/app_spec.py +338 -0
  65. harness/scan/design.py +112 -0
  66. harness/scan/existing.py +131 -0
  67. harness/scan/layout.py +254 -0
  68. harness/scan/names.py +308 -0
  69. harness/scan/project_brief.py +287 -0
  70. harness/scan/project_docs.py +42 -0
  71. harness/scan/project_scan.py +49 -0
  72. harness/scan/repo_map.py +101 -0
  73. harness/secrets.py +39 -0
  74. harness/server.py +199 -0
  75. harness/ship/__init__.py +1 -0
  76. harness/ship/bot_pr.py +221 -0
  77. harness/ship/git_ship.py +262 -0
  78. harness/ship/identity.py +62 -0
  79. harness/ship/ticket.py +251 -0
  80. harness/skillkit/__init__.py +6 -0
  81. harness/skillkit/catalog.py +241 -0
  82. harness/skillkit/refuse_change.py +640 -0
  83. harness/skillkit/refuse_finish.py +295 -0
  84. harness/skillkit/target.py +238 -0
  85. harness/task.py +717 -0
  86. py_harness_cli-0.3.0.dist-info/METADATA +177 -0
  87. py_harness_cli-0.3.0.dist-info/RECORD +92 -0
  88. py_harness_cli-0.3.0.dist-info/WHEEL +5 -0
  89. py_harness_cli-0.3.0.dist-info/entry_points.txt +3 -0
  90. py_harness_cli-0.3.0.dist-info/licenses/LICENSE +202 -0
  91. py_harness_cli-0.3.0.dist-info/licenses/NOTICE +6 -0
  92. py_harness_cli-0.3.0.dist-info/top_level.txt +2 -0
harness/mcp_stdio.py ADDED
@@ -0,0 +1,306 @@
1
+ """Local MCP over stdio so an editor can apply the write limit without a tunnel.
2
+
3
+ This is the editor calling py-harness. It is not an Action the 8B may emit.
4
+ Stdout is JSON-RPC only. Logs go to stderr.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import sys
11
+ from pathlib import Path
12
+ from typing import Any, TextIO
13
+
14
+ from harness.agent import Agent, AgentOptions
15
+
16
+ PROTOCOL = "2024-11-05"
17
+ PROMPTS = (
18
+ {
19
+ "name": "ask",
20
+ "description": "Read-only question. py-harness does not change files.",
21
+ "arguments": [
22
+ {"name": "task", "description": "The question", "required": True}
23
+ ],
24
+ },
25
+ {
26
+ "name": "run",
27
+ "description": "Explore, edit and run inside the project folder.",
28
+ "arguments": [
29
+ {"name": "task", "description": "What to do", "required": True}
30
+ ],
31
+ },
32
+ )
33
+
34
+ TOOLS = (
35
+ {
36
+ "name": "ask",
37
+ "description": (
38
+ "Read-only question about the project. Uses the py-harness write limit. "
39
+ "Does not change files."
40
+ ),
41
+ "inputSchema": {
42
+ "type": "object",
43
+ "properties": {"task": {"type": "string"}},
44
+ "required": ["task"],
45
+ },
46
+ },
47
+ {
48
+ "name": "run",
49
+ "description": (
50
+ "Explore, edit and run inside the project folder. Writes only when "
51
+ "the server was started with --allow-writes."
52
+ ),
53
+ "inputSchema": {
54
+ "type": "object",
55
+ "properties": {
56
+ "task": {"type": "string"},
57
+ "scope": {"type": "string"},
58
+ },
59
+ "required": ["task"],
60
+ },
61
+ },
62
+ # These two need no model and take under a second, which makes them
63
+ # the ones worth reaching for in an editor. Only ask and run were
64
+ # offered, so the only tools an editor had were the two slowest and
65
+ # least certain.
66
+ {
67
+ "name": "brief",
68
+ "description": (
69
+ "Summarise this project: how large it is, what is in it, and "
70
+ "whether it is small enough to read whole. Needs no model."
71
+ ),
72
+ "inputSchema": {
73
+ "type": "object",
74
+ "properties": {"scope": {"type": "string"}},
75
+ },
76
+ },
77
+ {
78
+ "name": "layout",
79
+ "description": (
80
+ "Report what makes this project hard to read: import cycles, "
81
+ "a folder with no grouping, an oversized module. Needs no model."
82
+ ),
83
+ "inputSchema": {
84
+ "type": "object",
85
+ "properties": {"scope": {"type": "string"}},
86
+ },
87
+ },
88
+ )
89
+
90
+
91
+ def _text(rpc_id, body: str) -> dict[str, Any]:
92
+ """One plain answer, in the shape an editor expects."""
93
+ return {
94
+ "jsonrpc": "2.0",
95
+ "id": rpc_id,
96
+ "result": {"content": [{"type": "text", "text": body}]},
97
+ }
98
+
99
+
100
+ def _describe(name: str, project: Path, scope: str) -> str:
101
+ """Answer brief or layout. Neither loads a model."""
102
+ from harness.scan.layout import render_layout
103
+ from harness.scan.project_brief import (
104
+ classify_project,
105
+ render_brief_for_person,
106
+ resolve_scope,
107
+ )
108
+
109
+ if name == "brief":
110
+ return render_brief_for_person(
111
+ classify_project(project, scope), scope=scope
112
+ )
113
+ base = resolve_scope(project, scope) if scope else project
114
+ return render_layout(base)
115
+
116
+
117
+ def handle_rpc(
118
+ message: dict[str, Any],
119
+ *,
120
+ project: Path,
121
+ allow_writes: bool,
122
+ model: str,
123
+ ) -> dict[str, Any] | None:
124
+ """One JSON-RPC message. Notifications return None."""
125
+ method = str(message.get("method") or "")
126
+ rpc_id = message.get("id")
127
+ if method == "initialize":
128
+ return {
129
+ "jsonrpc": "2.0",
130
+ "id": rpc_id,
131
+ "result": {
132
+ "protocolVersion": PROTOCOL,
133
+ "capabilities": {"tools": {}, "prompts": {}},
134
+ "serverInfo": {"name": "py-harness", "version": "0.1.0"},
135
+ },
136
+ }
137
+ if method == "notifications/initialized":
138
+ return None
139
+ if method == "tools/list":
140
+ return {"jsonrpc": "2.0", "id": rpc_id, "result": {"tools": list(TOOLS)}}
141
+ if method == "prompts/list":
142
+ return {"jsonrpc": "2.0", "id": rpc_id, "result": {"prompts": list(PROMPTS)}}
143
+ if method == "prompts/get":
144
+ params = message.get("params") or {}
145
+ name = str(params.get("name") or "")
146
+ args = params.get("arguments") or {}
147
+ task = str(args.get("task") or "").strip() or "what does this project do?"
148
+ if name not in {"ask", "run"}:
149
+ return _error(rpc_id, f"unknown prompt {name}")
150
+ verb = "ask" if name == "ask" else "run"
151
+ return {
152
+ "jsonrpc": "2.0",
153
+ "id": rpc_id,
154
+ "result": {
155
+ "description": f"Call the py-harness {verb} tool",
156
+ "messages": [
157
+ {
158
+ "role": "user",
159
+ "content": {
160
+ "type": "text",
161
+ "text": f"Use the py-harness {verb} tool with task: {task}",
162
+ },
163
+ }
164
+ ],
165
+ },
166
+ }
167
+ if method == "tools/call":
168
+ params = message.get("params") or {}
169
+ name = str(params.get("name") or "")
170
+ args = params.get("arguments") or {}
171
+ scope = str(args.get("scope") or "").strip()
172
+ if name in {"brief", "layout"}:
173
+ return _text(rpc_id, _describe(name, project, scope))
174
+ task = str(args.get("task") or "").strip()
175
+ if not task:
176
+ return _error(rpc_id, "task required")
177
+ if name == "run" and not allow_writes:
178
+ return {
179
+ "jsonrpc": "2.0",
180
+ "id": rpc_id,
181
+ "result": {
182
+ "content": [
183
+ {
184
+ "type": "text",
185
+ "text": (
186
+ "this server is read-only. Restart it with "
187
+ "--allow-writes, or run py-harness run in the terminal."
188
+ ),
189
+ }
190
+ ],
191
+ "isError": True,
192
+ },
193
+ }
194
+ if name not in {"ask", "run"}:
195
+ return _error(rpc_id, f"unknown tool {name}")
196
+ options = AgentOptions(
197
+ project=project,
198
+ task=task,
199
+ model=model,
200
+ scope=str(args.get("scope") or ""),
201
+ allow_writes=allow_writes and name == "run",
202
+ )
203
+ try:
204
+ result = Agent(options).run()
205
+ except (ValueError, OSError) as exc:
206
+ return _error(rpc_id, str(exc))
207
+ return {
208
+ "jsonrpc": "2.0",
209
+ "id": rpc_id,
210
+ "result": {
211
+ "content": [{"type": "text", "text": describe(result)}],
212
+ "isError": not result.ok,
213
+ },
214
+ }
215
+ if rpc_id is None:
216
+ return None
217
+ return _error(rpc_id, f"unknown method {method}")
218
+
219
+
220
+ def describe(result) -> str:
221
+ """What to show in the editor: the answer, and what it left behind.
222
+
223
+ The summary alone is not enough when a run stops early. A person who
224
+ asked for a feature and got a question back also needs to know that
225
+ files were changed and that the tests no longer pass, or they will
226
+ find out later and blame the wrong thing.
227
+ """
228
+ lines = [result.summary or "done"]
229
+ if result.writes:
230
+ changed = ", ".join(sorted(set(result.writes)))
231
+ lines.append(f"\nChanged: {changed}")
232
+ if not result.ok:
233
+ if result.stopped == "question":
234
+ lines.append(
235
+ "This run stopped to ask. Nothing further was done, so the "
236
+ "change above may be half finished — run the tests before "
237
+ "relying on it."
238
+ )
239
+ elif result.stopped == "steps":
240
+ lines.append(
241
+ "This run ran out of steps before it finished. Check what "
242
+ "changed above."
243
+ )
244
+ return "\n".join(lines)
245
+
246
+
247
+ def _error(rpc_id: Any, text: str) -> dict[str, Any]:
248
+ return {"jsonrpc": "2.0", "id": rpc_id, "error": {"code": -32600, "message": text}}
249
+
250
+
251
+ def _read_message(stdin: TextIO) -> dict[str, Any] | None:
252
+ """Read one message. Lines are the transport; headers are tolerated."""
253
+ first = stdin.readline()
254
+ if first == "":
255
+ return None
256
+ if first.lstrip().startswith("{"):
257
+ return json.loads(first)
258
+ headers: dict[str, str] = {}
259
+ line = first
260
+ while line not in ("", "\r\n", "\n"):
261
+ key, _, value = line.partition(":")
262
+ headers[key.strip().lower()] = value.strip()
263
+ line = stdin.readline()
264
+ if line == "" and "content-length" not in headers:
265
+ return None
266
+ length = int(headers.get("content-length") or "0")
267
+ raw = stdin.read(length) if length else ""
268
+ return json.loads(raw) if raw else None
269
+
270
+
271
+ def _write_message(stdout: TextIO, payload: dict[str, Any]) -> None:
272
+ """One message per line, as the stdio transport requires.
273
+
274
+ The specification says messages are delimited by newlines and must not
275
+ contain embedded newlines. Content-Length headers are the convention in
276
+ the Language Server Protocol, not this one, and a client that expects
277
+ lines cannot read them.
278
+ """
279
+ # json.dumps escapes a newline inside a value, so the serialized form is
280
+ # already a single line.
281
+ stdout.write(json.dumps(payload, separators=(",", ":")) + "\n")
282
+ stdout.flush()
283
+
284
+
285
+ def serve_stdio(
286
+ project: Path, *, allow_writes: bool = False, model: str = ""
287
+ ) -> int:
288
+ print(
289
+ f"py-harness mcp project {project} "
290
+ f"{'read-write' if allow_writes else 'read-only'}",
291
+ file=sys.stderr,
292
+ flush=True,
293
+ )
294
+ while True:
295
+ try:
296
+ message = _read_message(sys.stdin)
297
+ except (json.JSONDecodeError, ValueError) as exc:
298
+ print(f"bad mcp message: {exc}", file=sys.stderr)
299
+ continue
300
+ if message is None:
301
+ return 0
302
+ reply = handle_rpc(
303
+ message, project=project, allow_writes=allow_writes, model=model
304
+ )
305
+ if reply is not None:
306
+ _write_message(sys.stdout, reply)
@@ -0,0 +1,5 @@
1
+ """What a run remembers between turns."""
2
+
3
+ from harness.memory.conversation import Conversation
4
+
5
+ __all__ = ["Conversation"]
@@ -0,0 +1,104 @@
1
+ """What a run remembers between turns, and what it is allowed to forget.
2
+
3
+ Memory used to be a bare list on the generate function. Every turn
4
+ appended the prompt and the reply, nothing was ever removed, and the
5
+ request grew about 130 tokens a turn on top of an opening that is
6
+ usually over a thousand.
7
+
8
+ Nobody decided where that stopped. The harness sent no context size, so
9
+ Ollama applied its own default — 4096 tokens for a model whose weights
10
+ accept 131072 — and when a run crossed it the oldest messages were
11
+ dropped by the server. The oldest message is the opening: the file the
12
+ harness located and the instruction saying what to do with it. The run
13
+ lost exactly the part it had done work to assemble, and said nothing.
14
+
15
+ This decides instead. The opening is kept whatever else goes, because it
16
+ is the only turn that carries the code. Recent turns are kept because
17
+ that is where the run is. What goes is the middle, which is where a
18
+ model has already been told it used the wrong verb four times.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from dataclasses import dataclass, field
24
+
25
+ # Roughly four characters to a token. Only used to decide what to drop,
26
+ # so it does not need to match a tokeniser.
27
+ CHARS_PER_TOKEN = 4
28
+
29
+
30
+ @dataclass
31
+ class Conversation:
32
+ """The messages one run sends, and the budget they have to fit.
33
+
34
+ Fields:
35
+ budget_tokens: how much the model will read. Everything above
36
+ this is dropped here, in the open, rather than by the server.
37
+ system: the instruction that opens every request.
38
+ opening: the first prompt, which carries the located file. Kept
39
+ for as long as the run lasts.
40
+ turns: prompt and reply for every turn after the first.
41
+ dropped: how many middle turns have been let go, so a caller can
42
+ say so rather than guess.
43
+ """
44
+
45
+ budget_tokens: int = 8192
46
+ system: str = ""
47
+ opening: str = ""
48
+ turns: list[tuple[str, str]] = field(default_factory=list)
49
+ dropped: int = 0
50
+
51
+ @property
52
+ def budget_chars(self) -> int:
53
+ return self.budget_tokens * CHARS_PER_TOKEN
54
+
55
+ def remember(self, prompt: str, reply: str) -> None:
56
+ """Record one exchange. The first prompt becomes the opening."""
57
+ if not self.opening:
58
+ self.opening = prompt
59
+ self.turns.append(("", reply))
60
+ return
61
+ self.turns.append((prompt, reply))
62
+
63
+ def clear(self) -> None:
64
+ """Forget the run, keep the budget and the system line."""
65
+ self.opening = ""
66
+ self.turns.clear()
67
+ self.dropped = 0
68
+
69
+ def messages(self, prompt: str) -> list[dict[str, str]]:
70
+ """What to send for `prompt`, trimmed from the middle to fit."""
71
+ head: list[dict[str, str]] = []
72
+ if self.system:
73
+ head.append({"role": "system", "content": self.system})
74
+ if self.opening:
75
+ head.append({"role": "user", "content": self.opening})
76
+
77
+ body: list[dict[str, str]] = []
78
+ for asked, answered in self.turns:
79
+ if asked:
80
+ body.append({"role": "user", "content": asked})
81
+ if answered:
82
+ body.append({"role": "assistant", "content": answered})
83
+ tail = [{"role": "user", "content": prompt}]
84
+
85
+ fixed = _size(head) + _size(tail)
86
+ room = self.budget_chars - fixed
87
+ if room <= 0:
88
+ # Even the opening does not fit. Send the instruction and the
89
+ # question: a truncated file is worse than none, because the
90
+ # model reads half a function and answers about it.
91
+ self.dropped = len(self.turns)
92
+ return ([head[0]] if self.system else []) + tail
93
+
94
+ kept: list[dict[str, str]] = []
95
+ for message in reversed(body):
96
+ if _size(kept) + len(message["content"]) > room:
97
+ break
98
+ kept.insert(0, message)
99
+ self.dropped = max(0, (len(body) - len(kept) + 1) // 2)
100
+ return head + kept + tail
101
+
102
+
103
+ def _size(messages: list[dict[str, str]]) -> int:
104
+ return sum(len(message["content"]) for message in messages)
@@ -0,0 +1,6 @@
1
+ """Load and call a language model.
2
+
3
+ Supports a LoRA adapter through MLX and a local model through Ollama. This
4
+ is the only package whose output is not deterministic. It imports no other
5
+ package in the harness, so everything else can be tested without a model.
6
+ """
@@ -0,0 +1,100 @@
1
+ """What every model this project talks to has in common.
2
+
3
+ Both hosts speak the same shape: post a list of messages, read one reply
4
+ out of what comes back. Sixty-four per cent of the two files was the
5
+ same lines — building the request, opening it, turning a failure into a
6
+ message, pulling the text out.
7
+
8
+ What actually differs is small and named here: where to post, what the
9
+ body looks like, what headers it needs, where the reply sits in the
10
+ answer, and what a status code means. A new host is those five things,
11
+ not another copy of the transport.
12
+
13
+ This is a base class rather than a function because the pieces vary
14
+ together and are named by what they are. Most of this project is plain
15
+ functions, which is right when a thing takes values and returns one; a
16
+ model backend holds a host, a model name and a timeout, and answers
17
+ several questions about them.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import urllib.error
24
+ import urllib.request
25
+ from collections.abc import Sequence
26
+ from typing import Any
27
+
28
+
29
+ class ChatBackend:
30
+ """One model, reachable over HTTP, that answers a list of messages."""
31
+
32
+ def __init__(self, model: str, system: str, *, timeout: float) -> None:
33
+ self.model = model
34
+ self.system = system
35
+ self.timeout = timeout
36
+
37
+ # -- what a host has to say for itself -----------------------------
38
+
39
+ def url(self) -> str:
40
+ raise NotImplementedError
41
+
42
+ def body(self, messages: list[dict[str, str]]) -> dict[str, Any]:
43
+ raise NotImplementedError
44
+
45
+ def headers(self) -> dict[str, str]:
46
+ return {"Content-Type": "application/json"}
47
+
48
+ def reply_from(self, payload: dict[str, Any]) -> str:
49
+ raise NotImplementedError
50
+
51
+ def unreachable(self, exc: Exception) -> str:
52
+ return f"{self.model} unreachable"
53
+
54
+ def refused(self, code: int) -> str:
55
+ return f"remote model HTTP {code}"
56
+
57
+ def before_send(self, messages: list[dict[str, str]]) -> None:
58
+ """Last look at what is about to leave. Raise to stop it.
59
+
60
+ Local hosts have nothing to check, so this does nothing by
61
+ default. A backend that posts somewhere else overrides it.
62
+ """
63
+
64
+ # -- what they all do the same way ---------------------------------
65
+
66
+ def __call__(
67
+ self, prompt: str, history: Sequence[dict[str, str]] | None = None
68
+ ) -> str:
69
+ """Answer one prompt, with earlier turns in front of it."""
70
+ messages: list[dict[str, str]] = [
71
+ {"role": "system", "content": self.system}
72
+ ]
73
+ if history:
74
+ messages.extend(history)
75
+ messages.append({"role": "user", "content": prompt})
76
+ return self.send(messages)
77
+
78
+ def send(self, messages: list[dict[str, str]]) -> str:
79
+ """Post exactly these messages. The caller decides what they are.
80
+
81
+ The conversation is assembled by `harness.memory`, which knows
82
+ what to keep and what to let go. This only sends it.
83
+ """
84
+ self.before_send(messages)
85
+ request = urllib.request.Request(
86
+ self.url(),
87
+ data=json.dumps(self.body(messages)).encode("utf-8"),
88
+ headers=self.headers(),
89
+ method="POST",
90
+ )
91
+ try:
92
+ with urllib.request.urlopen(request, timeout=self.timeout) as answer:
93
+ payload = json.loads(answer.read().decode("utf-8"))
94
+ except urllib.error.HTTPError as exc:
95
+ # `from None` on purpose: the original carries the full URL,
96
+ # and a token can be in it.
97
+ raise RuntimeError(self.refused(exc.code)) from None
98
+ except urllib.error.URLError as exc:
99
+ raise RuntimeError(self.unreachable(exc)) from exc
100
+ return self.reply_from(payload)
@@ -0,0 +1,165 @@
1
+ """Load MLX LoRA or Ollama once; reuse for a batch of prompts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import subprocess
7
+ import sys
8
+ from collections.abc import Callable
9
+ from pathlib import Path
10
+
11
+ from finetune.huggingface_store import BEST_ADAPTER, ensure_adapters
12
+ from finetune.models import SPECS
13
+ from finetune.paths import PROJECT_ROOT
14
+ from harness.paths import venv_python
15
+
16
+
17
+ def mlx_pythons() -> list[str]:
18
+ extra = os.environ.get("MLX_PYTHON", "")
19
+ return [
20
+ sys.executable,
21
+ str(venv_python(PROJECT_ROOT / ".venv")),
22
+ *([extra] if extra else []),
23
+ ]
24
+
25
+
26
+ def has_mlx(exe: str) -> bool:
27
+ if not Path(exe).is_file():
28
+ return False
29
+ probe = subprocess.run([exe, "-c", "import mlx_lm"], capture_output=True)
30
+ return probe.returncode == 0
31
+
32
+
33
+ def reexec_for_mlx() -> None:
34
+ try:
35
+ import mlx_lm # noqa: F401
36
+ return
37
+ except ImportError:
38
+ pass
39
+ for exe in mlx_pythons():
40
+ if exe == sys.executable:
41
+ continue
42
+ if has_mlx(exe):
43
+ os.execv(exe, [exe, *sys.argv])
44
+ sys.exit("mlx-lm missing. Use the Homebrew 3.13 venv or pass --engine ollama")
45
+
46
+
47
+ def _stage_best(adapter_dir: Path) -> Path:
48
+ ckpt = adapter_dir / BEST_ADAPTER
49
+ if not ckpt.is_file():
50
+ sys.exit(f"no best checkpoint: {ckpt}")
51
+ staging = adapter_dir.parent / "python-vibe-100"
52
+ staging.mkdir(parents=True, exist_ok=True)
53
+ dest = staging / "adapters.safetensors"
54
+ cfg = staging / "adapter_config.json"
55
+ src_cfg = adapter_dir / "adapter_config.json"
56
+ if dest.exists() or dest.is_symlink():
57
+ dest.unlink()
58
+ dest.symlink_to(ckpt.resolve())
59
+ if src_cfg.is_file():
60
+ if cfg.exists() or cfg.is_symlink():
61
+ cfg.unlink()
62
+ cfg.symlink_to(src_cfg.resolve())
63
+ return staging
64
+
65
+
66
+ def make_generate(
67
+ engine: str,
68
+ max_tokens: int,
69
+ *,
70
+ model: str | None = None,
71
+ system: str | None = None,
72
+ memory=None,
73
+ adapters: bool = True,
74
+ ) -> tuple[str, Callable[[str], str]]:
75
+ """A label and a function that answers one prompt.
76
+
77
+ `memory` is whatever the harness is using to remember the run. It
78
+ only has to answer `messages(prompt)`. Nothing here decides what is
79
+ remembered or forgotten: that belongs to the harness, and this
80
+ package does not import it.
81
+ """
82
+ if engine == "auto":
83
+ engine = "mlx" if any(has_mlx(p) for p in mlx_pythons()) else "ollama"
84
+ if engine == "mlx":
85
+ return _mlx_generate(
86
+ max_tokens, system=system, memory=memory, adapters=adapters
87
+ )
88
+ if engine == "openai":
89
+ return _openai_generate(
90
+ max_tokens, model=model, system=system, memory=memory
91
+ )
92
+ if engine != "ollama":
93
+ sys.exit(f"unknown engine {engine!r}: use ollama, mlx, or openai")
94
+ return _ollama_generate(model=model, system=system, memory=memory)
95
+
96
+
97
+ def _mlx_generate(
98
+ max_tokens: int,
99
+ *,
100
+ system: str | None = None,
101
+ memory=None,
102
+ adapters: bool = True,
103
+ ) -> tuple[str, Callable[[str], str]]:
104
+ reexec_for_mlx()
105
+ from mlx_lm import generate, load
106
+
107
+ spec = SPECS["python-vibe"]
108
+ adapter_path: str | None = None
109
+ label = f"mlx-base:{spec.mlx_base}"
110
+ if adapters:
111
+ local = ensure_adapters(spec)
112
+ adapter = _stage_best(local) if (local / BEST_ADAPTER).is_file() else local
113
+ adapter_path = str(adapter)
114
+ label = f"mlx-lora:{adapter.name}"
115
+ model, tokenizer = load(spec.mlx_base, adapter_path=adapter_path)
116
+
117
+ def generate_once(prompt: str) -> str:
118
+ text = tokenizer.apply_chat_template(
119
+ memory.messages(prompt), tokenize=False, add_generation_prompt=True
120
+ )
121
+ return generate(model, tokenizer, prompt=text, max_tokens=max_tokens)
122
+
123
+ generate_once.memory = memory # type: ignore[attr-defined]
124
+ return label, generate_once
125
+
126
+
127
+ def _openai_generate(
128
+ max_tokens: int,
129
+ *,
130
+ model: str | None = None,
131
+ system: str | None = None,
132
+ memory=None,
133
+ ) -> tuple[str, Callable[[str], str]]:
134
+ from harness.model.openai_generate import OpenAIGenerate
135
+
136
+ spec = SPECS["python-vibe"]
137
+ name = model or spec.ollama_base
138
+ try:
139
+ backend = OpenAIGenerate(
140
+ name, system or spec.system, max_tokens=max_tokens
141
+ )
142
+ except ValueError as exc:
143
+ sys.exit(str(exc))
144
+ def generate_once(prompt: str) -> str:
145
+ return backend.send(memory.messages(prompt))
146
+
147
+ generate_once.memory = memory # type: ignore[attr-defined]
148
+ return f"openai:{name}", generate_once
149
+
150
+
151
+ def _ollama_generate(
152
+ *, model: str | None = None, system: str | None = None, memory=None
153
+ ) -> tuple[str, Callable[[str], str]]:
154
+ from harness.model.ollama_generate import OllamaGenerate
155
+
156
+ spec = SPECS["python-vibe"]
157
+ name = model or spec.ollama_base
158
+ backend = OllamaGenerate(name, system or spec.system)
159
+ if not backend.healthy():
160
+ sys.exit(f"ollama {backend.host} is down")
161
+ def generate_once(prompt: str) -> str:
162
+ return backend.send(memory.messages(prompt))
163
+
164
+ generate_once.memory = memory # type: ignore[attr-defined]
165
+ return f"ollama:{name}", generate_once