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.
- finetune/__init__.py +1 -0
- finetune/agent_system.py +41 -0
- finetune/agent_traces.py +157 -0
- finetune/everyday.py +30 -0
- finetune/hf_ollama.py +158 -0
- finetune/huggingface_store.py +144 -0
- finetune/models.py +74 -0
- finetune/paths.py +11 -0
- finetune/python_vibe.py +788 -0
- finetune/splits.py +54 -0
- finetune/systems.py +9 -0
- harness/__init__.py +42 -0
- harness/__main__.py +8 -0
- harness/act/__init__.py +6 -0
- harness/act/autofix/__init__.py +110 -0
- harness/act/autofix/additions.py +217 -0
- harness/act/autofix/conflicts.py +124 -0
- harness/act/autofix/cover.py +419 -0
- harness/act/autofix/mechanical.py +151 -0
- harness/act/autofix/missing_imports.py +50 -0
- harness/act/autofix/moves.py +439 -0
- harness/act/autofix/names.py +339 -0
- harness/act/autofix/scaffold.py +224 -0
- harness/act/code.py +157 -0
- harness/act/gate.py +229 -0
- harness/act/parse.py +247 -0
- harness/act/patch_fix.py +138 -0
- harness/act/tools.py +244 -0
- harness/agent/__init__.py +11 -0
- harness/agent/dispatch.py +235 -0
- harness/agent/loop.py +699 -0
- harness/agent/options.py +144 -0
- harness/agent/policy.py +856 -0
- harness/agent/prompt.py +170 -0
- harness/cli.py +393 -0
- harness/editor_kit.py +265 -0
- harness/guard/__init__.py +6 -0
- harness/guard/fallbacks.py +6 -0
- harness/guard/loop_guard.py +57 -0
- harness/guard/python_vibe.py +68 -0
- harness/guard/run.py +41 -0
- harness/guard/types.py +19 -0
- harness/locate.py +767 -0
- harness/mcp_stdio.py +306 -0
- harness/memory/__init__.py +5 -0
- harness/memory/conversation.py +104 -0
- harness/model/__init__.py +6 -0
- harness/model/chat_backend.py +100 -0
- harness/model/engine.py +165 -0
- harness/model/ollama_generate.py +60 -0
- harness/model/openai_generate.py +156 -0
- harness/model/outbound.py +83 -0
- harness/model/route.py +90 -0
- harness/observe/__init__.py +6 -0
- harness/observe/eval_gate.py +80 -0
- harness/observe/eval_loop.py +185 -0
- harness/observe/eval_tasks.py +399 -0
- harness/observe/report_md.py +102 -0
- harness/observe/trace_record.py +79 -0
- harness/openai_api.py +81 -0
- harness/paths.py +88 -0
- harness/py.typed +0 -0
- harness/scan/__init__.py +6 -0
- harness/scan/app_spec.py +338 -0
- harness/scan/design.py +112 -0
- harness/scan/existing.py +131 -0
- harness/scan/layout.py +254 -0
- harness/scan/names.py +308 -0
- harness/scan/project_brief.py +287 -0
- harness/scan/project_docs.py +42 -0
- harness/scan/project_scan.py +49 -0
- harness/scan/repo_map.py +101 -0
- harness/secrets.py +39 -0
- harness/server.py +199 -0
- harness/ship/__init__.py +1 -0
- harness/ship/bot_pr.py +221 -0
- harness/ship/git_ship.py +262 -0
- harness/ship/identity.py +62 -0
- harness/ship/ticket.py +251 -0
- harness/skillkit/__init__.py +6 -0
- harness/skillkit/catalog.py +241 -0
- harness/skillkit/refuse_change.py +640 -0
- harness/skillkit/refuse_finish.py +295 -0
- harness/skillkit/target.py +238 -0
- harness/task.py +717 -0
- py_harness_cli-0.3.0.dist-info/METADATA +177 -0
- py_harness_cli-0.3.0.dist-info/RECORD +92 -0
- py_harness_cli-0.3.0.dist-info/WHEEL +5 -0
- py_harness_cli-0.3.0.dist-info/entry_points.txt +3 -0
- py_harness_cli-0.3.0.dist-info/licenses/LICENSE +202 -0
- py_harness_cli-0.3.0.dist-info/licenses/NOTICE +6 -0
- 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,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)
|
harness/model/engine.py
ADDED
|
@@ -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
|