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/act/patch_fix.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Match a `Find:` block against a file, and explain a failed match.
|
|
2
|
+
|
|
3
|
+
`patch` replaces an exact substring. Exact matching is deliberate: it fails
|
|
4
|
+
instead of editing a line the user did not mean. The cost is that a small
|
|
5
|
+
model, which often reproduces the words of a line but not its indentation,
|
|
6
|
+
gets no way forward when the match fails.
|
|
7
|
+
|
|
8
|
+
These functions add two recoveries that do not weaken the guarantee:
|
|
9
|
+
|
|
10
|
+
* Retry the match ignoring differences in whitespace only. If that matches
|
|
11
|
+
in exactly one place, use it. If it matches in more than one place, the
|
|
12
|
+
match is rejected rather than guessed.
|
|
13
|
+
* When there is still no match, return the lines in the file that are most
|
|
14
|
+
similar, so the next attempt can copy a real line.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import difflib
|
|
20
|
+
import re
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
|
|
23
|
+
MAX_SUGGESTIONS = 3
|
|
24
|
+
MIN_RATIO = 0.6
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def normalize(line: str) -> str:
|
|
28
|
+
return re.sub(r"\s+", " ", line).strip()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class Match:
|
|
33
|
+
text: str
|
|
34
|
+
exact: bool
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _windows(lines: list[str], size: int) -> list[tuple[int, int]]:
|
|
38
|
+
return [(i, i + size) for i in range(0, len(lines) - size + 1)]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def find_match(text: str, find: str) -> Match | None:
|
|
42
|
+
"""Find where `find` occurs in `text`.
|
|
43
|
+
|
|
44
|
+
Returns the exact text to replace, and whether it matched exactly.
|
|
45
|
+
Returns None when there is no match, or when a whitespace-insensitive
|
|
46
|
+
match occurs in more than one place.
|
|
47
|
+
"""
|
|
48
|
+
if not find:
|
|
49
|
+
return None
|
|
50
|
+
hits = text.count(find)
|
|
51
|
+
if hits == 1:
|
|
52
|
+
return Match(find, True)
|
|
53
|
+
if hits > 1:
|
|
54
|
+
return None
|
|
55
|
+
wanted = [normalize(line) for line in find.strip("\n").splitlines()]
|
|
56
|
+
if not wanted or not any(wanted):
|
|
57
|
+
return None
|
|
58
|
+
lines = text.splitlines()
|
|
59
|
+
if len(wanted) > len(lines):
|
|
60
|
+
return None
|
|
61
|
+
found: list[str] = []
|
|
62
|
+
for start, end in _windows(lines, len(wanted)):
|
|
63
|
+
if [normalize(line) for line in lines[start:end]] == wanted:
|
|
64
|
+
found.append("\n".join(lines[start:end]))
|
|
65
|
+
if len(found) > 1:
|
|
66
|
+
return None
|
|
67
|
+
if len(found) != 1:
|
|
68
|
+
return None
|
|
69
|
+
return Match(found[0], False)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def suggestions(text: str, find: str, *, limit: int = MAX_SUGGESTIONS) -> list[str]:
|
|
73
|
+
"""Return the lines in `text` most similar to `find`, for an error message.
|
|
74
|
+
|
|
75
|
+
Each entry is formatted as "line number: line content".
|
|
76
|
+
"""
|
|
77
|
+
head = normalize(find.strip("\n").splitlines()[0]) if find.strip() else ""
|
|
78
|
+
if not head:
|
|
79
|
+
return []
|
|
80
|
+
scored: list[tuple[float, int, str]] = []
|
|
81
|
+
for number, line in enumerate(text.splitlines(), 1):
|
|
82
|
+
candidate = normalize(line)
|
|
83
|
+
if not candidate:
|
|
84
|
+
continue
|
|
85
|
+
ratio = difflib.SequenceMatcher(None, head, candidate).ratio()
|
|
86
|
+
if ratio >= MIN_RATIO:
|
|
87
|
+
scored.append((ratio, number, line.rstrip()))
|
|
88
|
+
scored.sort(key=lambda item: (-item[0], item[1]))
|
|
89
|
+
if scored:
|
|
90
|
+
return [f"{number}: {line}" for _ratio, number, line in scored[:limit]]
|
|
91
|
+
return _same_opener(text, head, limit)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _same_opener(text: str, head: str, limit: int) -> list[str]:
|
|
95
|
+
"""Nothing scored. Show lines that at least start the same way."""
|
|
96
|
+
first = head.split(" ", 1)[0]
|
|
97
|
+
if not first:
|
|
98
|
+
return []
|
|
99
|
+
out: list[str] = []
|
|
100
|
+
for number, line in enumerate(text.splitlines(), 1):
|
|
101
|
+
if normalize(line).startswith(first):
|
|
102
|
+
out.append(f"{number}: {line.rstrip()}")
|
|
103
|
+
if len(out) >= limit:
|
|
104
|
+
break
|
|
105
|
+
return out
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def miss_message(text: str, find: str) -> str:
|
|
109
|
+
close = suggestions(text, find)
|
|
110
|
+
if not close:
|
|
111
|
+
return (
|
|
112
|
+
"Find: string not in file. Action: read that Path: first, "
|
|
113
|
+
"then copy one whole line from it."
|
|
114
|
+
)
|
|
115
|
+
listed = "\n".join(f" {item}" for item in close)
|
|
116
|
+
return (
|
|
117
|
+
"Find: string not in file. Closest lines in this file — copy one "
|
|
118
|
+
f"whole line verbatim:\n{listed}"
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def align_indent(matched: str, replace: str) -> str:
|
|
123
|
+
"""Give `replace` the leading whitespace of the line it will replace.
|
|
124
|
+
|
|
125
|
+
A small model often reproduces a line without its indentation. If the
|
|
126
|
+
replacement has no leading whitespace and the matched text does, the
|
|
127
|
+
replacement is indented to match.
|
|
128
|
+
"""
|
|
129
|
+
if not matched or not replace.strip():
|
|
130
|
+
return replace
|
|
131
|
+
first = matched.splitlines()[0]
|
|
132
|
+
indent = first[: len(first) - len(first.lstrip())]
|
|
133
|
+
if not indent:
|
|
134
|
+
return replace
|
|
135
|
+
lines = replace.splitlines()
|
|
136
|
+
if lines[0].startswith((" ", "\t")):
|
|
137
|
+
return replace
|
|
138
|
+
return "\n".join(indent + line if line.strip() else line for line in lines)
|
harness/act/tools.py
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"""The seven things the agent can do to a project.
|
|
2
|
+
|
|
3
|
+
Look for a file, search inside files, read one, run one, change one, and
|
|
4
|
+
draw the shape of the whole. Nothing here decides whether a change is
|
|
5
|
+
allowed — `act.gate` answers that, and `patch_py` and `edit_py` ask it
|
|
6
|
+
before they write.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
|
|
14
|
+
import re
|
|
15
|
+
import subprocess
|
|
16
|
+
import sys
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from harness.act.autofix import (
|
|
20
|
+
append_instead_of_replacing,
|
|
21
|
+
apply_function_rename,
|
|
22
|
+
apply_missing_imports,
|
|
23
|
+
apply_typo_fixes,
|
|
24
|
+
)
|
|
25
|
+
from harness.act.code import apply_source, read_project_file, resolve_project_file
|
|
26
|
+
from harness.act.gate import (
|
|
27
|
+
already_defined,
|
|
28
|
+
refuse_duplicate_module,
|
|
29
|
+
refuse_missing_import_target,
|
|
30
|
+
repair_unittest_append,
|
|
31
|
+
first_refusal,
|
|
32
|
+
)
|
|
33
|
+
from harness.paths import is_secret_name, rel_posix, suffix_globs
|
|
34
|
+
from harness.act.patch_fix import align_indent, find_match, miss_message
|
|
35
|
+
from harness.skillkit.refuse_change import (
|
|
36
|
+
refuse_add_opens_file,
|
|
37
|
+
refuse_layout,
|
|
38
|
+
refuse_ops_draft,
|
|
39
|
+
refuse_platform_draft,
|
|
40
|
+
refuse_rename_incomplete,
|
|
41
|
+
refuse_shell_fetch,
|
|
42
|
+
refuse_stdlib_shadow,
|
|
43
|
+
refuse_stub_body,
|
|
44
|
+
refuse_test_in_impl,
|
|
45
|
+
refuse_undefined_draft,
|
|
46
|
+
refuse_weak_test,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
from harness.task import looks_like_bugfix, looks_like_fix_smell, rename_pair
|
|
50
|
+
from harness.scan.project_brief import render_map, resolve_scope
|
|
51
|
+
from harness.scan.project_scan import SKIP_DIR
|
|
52
|
+
from harness.scan.repo_map import render_outline
|
|
53
|
+
|
|
54
|
+
MAX_HITS = 30
|
|
55
|
+
_TRUNC = "\n# … truncated. Narrow Query or pass --scope"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def glob_py(project: Path, pattern: str, scope: str = "") -> str:
|
|
59
|
+
root = project.resolve()
|
|
60
|
+
base = resolve_scope(project, scope) if scope else root
|
|
61
|
+
hits: list[str] = []
|
|
62
|
+
for path in base.glob(pattern):
|
|
63
|
+
if any(part in SKIP_DIR for part in path.parts):
|
|
64
|
+
continue
|
|
65
|
+
if is_secret_name(path.name):
|
|
66
|
+
continue
|
|
67
|
+
if path.is_file():
|
|
68
|
+
hits.append(rel_posix(path, root))
|
|
69
|
+
if len(hits) >= MAX_HITS:
|
|
70
|
+
return "\n".join(hits) + _TRUNC
|
|
71
|
+
if not hits:
|
|
72
|
+
# rglob if user passed **/...
|
|
73
|
+
for path in base.rglob(pattern.removeprefix("**/")):
|
|
74
|
+
if any(part in SKIP_DIR for part in path.parts):
|
|
75
|
+
continue
|
|
76
|
+
if is_secret_name(path.name):
|
|
77
|
+
continue
|
|
78
|
+
if path.is_file():
|
|
79
|
+
hits.append(rel_posix(path, root))
|
|
80
|
+
if len(hits) >= MAX_HITS:
|
|
81
|
+
return "\n".join(hits) + _TRUNC
|
|
82
|
+
return "\n".join(hits) or "(no files)"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def grep_py(project: Path, query: str, scope: str = "") -> str:
|
|
86
|
+
root = project.resolve()
|
|
87
|
+
base = resolve_scope(project, scope) if scope else root
|
|
88
|
+
try:
|
|
89
|
+
wanted = re.compile(query)
|
|
90
|
+
except re.error as exc:
|
|
91
|
+
return f"bad regex: {exc}"
|
|
92
|
+
lines: list[str] = []
|
|
93
|
+
for pattern in suffix_globs():
|
|
94
|
+
for path in base.rglob(pattern):
|
|
95
|
+
if any(part in SKIP_DIR for part in path.parts):
|
|
96
|
+
continue
|
|
97
|
+
if is_secret_name(path.name):
|
|
98
|
+
continue
|
|
99
|
+
try:
|
|
100
|
+
text = path.read_text(encoding="utf-8")
|
|
101
|
+
except OSError:
|
|
102
|
+
continue
|
|
103
|
+
for i, line in enumerate(text.splitlines(), 1):
|
|
104
|
+
if wanted.search(line):
|
|
105
|
+
rel = rel_posix(path, root)
|
|
106
|
+
lines.append(f"{rel}:{i}:{line.strip()[:160]}")
|
|
107
|
+
if len(lines) >= MAX_HITS:
|
|
108
|
+
return "\n".join(lines) + _TRUNC
|
|
109
|
+
return "\n".join(lines) or "(no hits)"
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def map_py(project: Path, scope: str = "") -> str:
|
|
113
|
+
"""File list plus a signature outline. Sizes do not tell it where to look."""
|
|
114
|
+
return f"{render_map(project, scope)}\n\n{render_outline(project, scope)}"
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def read_py(project: Path, rel: str, about: str = "") -> str:
|
|
118
|
+
"""The file. `about` names what the read is for, so that a file too
|
|
119
|
+
long to send whole keeps the part being asked about."""
|
|
120
|
+
path = resolve_project_file(project, rel)
|
|
121
|
+
return read_project_file(path, about=about)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def patch_py(
|
|
125
|
+
project: Path,
|
|
126
|
+
rel: str,
|
|
127
|
+
find: str,
|
|
128
|
+
replace: str,
|
|
129
|
+
append: str = "",
|
|
130
|
+
task: str = "",
|
|
131
|
+
) -> str:
|
|
132
|
+
path = resolve_project_file(project, rel)
|
|
133
|
+
original = path.read_text(encoding="utf-8") if path.is_file() else ""
|
|
134
|
+
text = original
|
|
135
|
+
note = ""
|
|
136
|
+
if looks_like_fix_smell(task):
|
|
137
|
+
old, new = rename_pair(task)
|
|
138
|
+
renamed = apply_function_rename(original, old, new)
|
|
139
|
+
if renamed != original:
|
|
140
|
+
text = renamed
|
|
141
|
+
note = " (harness renamed the def)"
|
|
142
|
+
find = ""
|
|
143
|
+
if find:
|
|
144
|
+
if len(find) < 8:
|
|
145
|
+
return (
|
|
146
|
+
"Find: must be at least 8 characters. "
|
|
147
|
+
"Use a unique full line such as: Find: return tota"
|
|
148
|
+
)
|
|
149
|
+
hits = text.count(find)
|
|
150
|
+
if hits > 1:
|
|
151
|
+
return f"Find: matches {hits} times — use a longer unique snippet"
|
|
152
|
+
match = find_match(text, find)
|
|
153
|
+
if match is None:
|
|
154
|
+
return miss_message(text, find)
|
|
155
|
+
text = text.replace(
|
|
156
|
+
match.text,
|
|
157
|
+
replace if match.exact else align_indent(match.text, replace),
|
|
158
|
+
1,
|
|
159
|
+
)
|
|
160
|
+
if not match.exact:
|
|
161
|
+
note = " (Find: matched after whitespace normalisation)"
|
|
162
|
+
else:
|
|
163
|
+
note = ""
|
|
164
|
+
elif text == original and not append:
|
|
165
|
+
return "patch needs Find: or Append:"
|
|
166
|
+
if append:
|
|
167
|
+
already = already_defined(original, append, rel)
|
|
168
|
+
if already:
|
|
169
|
+
return already
|
|
170
|
+
repaired = repair_unittest_append(text, append)
|
|
171
|
+
text = (
|
|
172
|
+
repaired
|
|
173
|
+
if repaired is not None
|
|
174
|
+
else text.rstrip() + "\n\n" + append.rstrip() + "\n"
|
|
175
|
+
)
|
|
176
|
+
if looks_like_bugfix(task):
|
|
177
|
+
bound = apply_typo_fixes(text)
|
|
178
|
+
if bound != text:
|
|
179
|
+
text = bound
|
|
180
|
+
note = (note + " (harness bound unique NameError typo)").strip()
|
|
181
|
+
repaired = apply_missing_imports(text)
|
|
182
|
+
if repaired != text:
|
|
183
|
+
text = repaired
|
|
184
|
+
note = (note + " (harness added the missing import)").strip()
|
|
185
|
+
blocked = refuse_duplicate_module(project, rel, original)
|
|
186
|
+
if not blocked:
|
|
187
|
+
blocked = refuse_missing_import_target(project, rel, text)
|
|
188
|
+
if not blocked:
|
|
189
|
+
blocked = first_refusal(task, rel, original, text, fragment=append or replace)
|
|
190
|
+
if blocked:
|
|
191
|
+
return blocked
|
|
192
|
+
apply_source(path, text, original=original)
|
|
193
|
+
return (
|
|
194
|
+
f"patched {rel_posix(path, project.resolve())} "
|
|
195
|
+
f"(backup {path.name}.bak){note}"
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def edit_py(project: Path, rel: str, source: str, task: str = "") -> str:
|
|
200
|
+
path = resolve_project_file(project, rel)
|
|
201
|
+
original = path.read_text(encoding="utf-8") if path.is_file() else ""
|
|
202
|
+
source = apply_missing_imports(source)
|
|
203
|
+
blocked = refuse_duplicate_module(project, rel, original)
|
|
204
|
+
if not blocked:
|
|
205
|
+
blocked = refuse_missing_import_target(project, rel, source)
|
|
206
|
+
if not blocked:
|
|
207
|
+
blocked = first_refusal(task, rel, original, source)
|
|
208
|
+
if blocked:
|
|
209
|
+
return blocked
|
|
210
|
+
# A short draft of only-new definitions is an addition, not a rewrite.
|
|
211
|
+
merged = append_instead_of_replacing(original, source)
|
|
212
|
+
if merged:
|
|
213
|
+
apply_source(path, merged, original=original)
|
|
214
|
+
return (
|
|
215
|
+
f"appended to {rel_posix(path, project.resolve())} "
|
|
216
|
+
f"(backup {path.name}.bak) — the draft added new definitions "
|
|
217
|
+
"rather than replacing the file"
|
|
218
|
+
)
|
|
219
|
+
apply_source(path, source, original=original)
|
|
220
|
+
return f"wrote {rel_posix(path, project.resolve())} (backup {path.name}.bak)"
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def run_python(project: Path, argv: tuple[str, ...]) -> str:
|
|
224
|
+
if not argv:
|
|
225
|
+
return "Argv required, e.g. -m unittest discover -s tests -q"
|
|
226
|
+
blocked = {"-c", "-m pip", "http.server"}
|
|
227
|
+
joined = " ".join(argv)
|
|
228
|
+
if any(tok in joined for tok in blocked) or "|" in joined or ";" in joined:
|
|
229
|
+
return "refusing that argv"
|
|
230
|
+
if "unittest" in joined and "tests" in joined and not (project / "tests").is_dir():
|
|
231
|
+
return "no tests/ directory in this project — add tests/test_*.py first"
|
|
232
|
+
try:
|
|
233
|
+
proc = subprocess.run(
|
|
234
|
+
[sys.executable, *argv],
|
|
235
|
+
cwd=project,
|
|
236
|
+
capture_output=True,
|
|
237
|
+
text=True,
|
|
238
|
+
timeout=60,
|
|
239
|
+
check=False,
|
|
240
|
+
)
|
|
241
|
+
except subprocess.TimeoutExpired:
|
|
242
|
+
return "timed out (60s)"
|
|
243
|
+
out = (proc.stdout or "") + (proc.stderr or "")
|
|
244
|
+
return f"exit {proc.returncode}\n{out[-4000:]}"
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""The agent loop and its public types.
|
|
2
|
+
|
|
3
|
+
`Agent` runs one task. `AgentOptions` describes how to run it and
|
|
4
|
+
`AgentResult` describes what happened. Every layer below this one works
|
|
5
|
+
without a model, which is what allows the harness to be tested offline.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from harness.agent.loop import Agent, Question
|
|
9
|
+
from harness.agent.options import AgentOptions, AgentResult, Step
|
|
10
|
+
|
|
11
|
+
__all__ = ["Agent", "AgentOptions", "AgentResult", "Question", "Step"]
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"""Carry out one action against the project.
|
|
2
|
+
|
|
3
|
+
Each branch returns the text to show the model and the file path the action
|
|
4
|
+
applied to. The path is kept by the loop so that a later action which does
|
|
5
|
+
not name a file still refers to the file most recently used.
|
|
6
|
+
|
|
7
|
+
This module makes no decisions about whether an action should run. Those
|
|
8
|
+
decisions are in `harness.agent.policy`.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from collections.abc import Callable
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from harness.act.tools import (
|
|
19
|
+
edit_py,
|
|
20
|
+
glob_py,
|
|
21
|
+
grep_py,
|
|
22
|
+
map_py,
|
|
23
|
+
patch_py,
|
|
24
|
+
read_py,
|
|
25
|
+
run_python,
|
|
26
|
+
)
|
|
27
|
+
from harness.guard.python_vibe import PythonVibeGuard
|
|
28
|
+
from harness.locate import locate_py
|
|
29
|
+
from harness.scan.layout import render_layout
|
|
30
|
+
from harness.scan.project_brief import resolve_scope
|
|
31
|
+
from harness.ship.git_ship import (
|
|
32
|
+
commit_changes,
|
|
33
|
+
create_pr,
|
|
34
|
+
make_branch,
|
|
35
|
+
merge_pr,
|
|
36
|
+
push_branch,
|
|
37
|
+
read_issue,
|
|
38
|
+
read_pr,
|
|
39
|
+
)
|
|
40
|
+
from harness.skillkit.catalog import (
|
|
41
|
+
list_skills,
|
|
42
|
+
render_catalog,
|
|
43
|
+
render_skill,
|
|
44
|
+
skill_from_action,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
ACTIONS = (
|
|
48
|
+
"glob|grep|read|edit|patch|run|map|plan|skill|locate|layout|ask|done|"
|
|
49
|
+
"issue|branch|commit|push|pr|merge"
|
|
50
|
+
)
|
|
51
|
+
WRITE_ACTIONS = frozenset({"edit", "patch", "run"})
|
|
52
|
+
SHIP_ACTIONS = frozenset({"issue", "branch", "commit", "push", "pr", "merge"})
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class Ask:
|
|
57
|
+
"""One action the model asked for, and everything it may act on.
|
|
58
|
+
|
|
59
|
+
Fields:
|
|
60
|
+
project: the folder the run may read and write inside.
|
|
61
|
+
turn: the parsed action, with whichever fields it carried.
|
|
62
|
+
path: the file this action lands on — the one it named, or the
|
|
63
|
+
last one touched.
|
|
64
|
+
last_path: what to report when the action changes no file.
|
|
65
|
+
scope: the folder to stay inside, if the run was given one.
|
|
66
|
+
target: the skill target, when a skill is being rendered.
|
|
67
|
+
task: what the user asked for, in their own words.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
project: Path
|
|
71
|
+
turn: object
|
|
72
|
+
path: str
|
|
73
|
+
last_path: str
|
|
74
|
+
scope: str
|
|
75
|
+
target: object
|
|
76
|
+
task: str
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _locate(ask: Ask) -> tuple[str, str]:
|
|
80
|
+
return locate_py(ask.project, ask.turn.query or ask.turn.name, ask.scope)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _map(ask: Ask) -> tuple[str, str]:
|
|
84
|
+
return map_py(ask.project, ask.scope), ask.last_path
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _layout(ask: Ask) -> tuple[str, str]:
|
|
88
|
+
base = resolve_scope(ask.project, ask.scope) if ask.scope else ask.project
|
|
89
|
+
return render_layout(base), ask.last_path
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _plan(ask: Ask) -> tuple[str, str]:
|
|
93
|
+
return (
|
|
94
|
+
f"plan noted:\n{ask.turn.summary or '(empty plan)'}\n"
|
|
95
|
+
"Take the first explore action now."
|
|
96
|
+
), ask.last_path
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _glob(ask: Ask) -> tuple[str, str]:
|
|
100
|
+
pattern = ask.turn.pattern or "**/*.py"
|
|
101
|
+
return glob_py(ask.project, pattern, scope=ask.scope), ask.last_path
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _grep(ask: Ask) -> tuple[str, str]:
|
|
105
|
+
return grep_py(ask.project, ask.turn.query, scope=ask.scope), ask.last_path
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _read(ask: Ask) -> tuple[str, str]:
|
|
109
|
+
if not ask.path:
|
|
110
|
+
return "read needs Path:", ask.last_path
|
|
111
|
+
return read_py(ask.project, ask.path), ask.path
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _edit(ask: Ask) -> tuple[str, str]:
|
|
115
|
+
if not ask.path:
|
|
116
|
+
return "edit needs Path:", ask.last_path
|
|
117
|
+
if not ask.turn.source:
|
|
118
|
+
return "edit needs a ```python block", ask.path
|
|
119
|
+
blocked = PythonVibeGuard().check(ask.turn.source)
|
|
120
|
+
if blocked.verdict != "pass":
|
|
121
|
+
return (
|
|
122
|
+
f"harness blocked: {[f.rule_id for f in blocked.findings]}",
|
|
123
|
+
ask.path,
|
|
124
|
+
)
|
|
125
|
+
return edit_py(ask.project, ask.path, ask.turn.source, task=ask.task), ask.path
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _patch(ask: Ask) -> tuple[str, str]:
|
|
129
|
+
if not ask.path:
|
|
130
|
+
return "patch needs Path: (or read that file first)", ask.last_path
|
|
131
|
+
return patch_py(
|
|
132
|
+
ask.project,
|
|
133
|
+
ask.path,
|
|
134
|
+
ask.turn.find,
|
|
135
|
+
ask.turn.replace,
|
|
136
|
+
ask.turn.append,
|
|
137
|
+
task=ask.task,
|
|
138
|
+
), ask.path
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _run(ask: Ask) -> tuple[str, str]:
|
|
142
|
+
return run_python(ask.project, ask.turn.argv), ask.last_path
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _skill(ask: Ask) -> tuple[str, str]:
|
|
146
|
+
return (
|
|
147
|
+
f"skill needs Name:. {render_catalog(list_skills(ask.project))}",
|
|
148
|
+
ask.last_path,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _named(ask: Ask) -> str:
|
|
153
|
+
"""The issue or pull request number an action carried."""
|
|
154
|
+
return (ask.turn.number or ask.turn.name or ask.turn.query or "").strip()
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _issue(ask: Ask) -> tuple[str, str]:
|
|
158
|
+
return read_issue(ask.project, _named(ask)), ask.last_path
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _branch(ask: Ask) -> tuple[str, str]:
|
|
162
|
+
return make_branch(ask.project, ask.turn.name or ask.turn.summary), ask.last_path
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _commit(ask: Ask) -> tuple[str, str]:
|
|
166
|
+
return commit_changes(ask.project, ask.turn.summary), ask.last_path
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _push(ask: Ask) -> tuple[str, str]:
|
|
170
|
+
return push_branch(ask.project), ask.last_path
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _pr(ask: Ask) -> tuple[str, str]:
|
|
174
|
+
"""A number reads that pull request; anything else opens one."""
|
|
175
|
+
number = _named(ask)
|
|
176
|
+
if number.isdigit():
|
|
177
|
+
return read_pr(ask.project, number), ask.last_path
|
|
178
|
+
return create_pr(
|
|
179
|
+
ask.project,
|
|
180
|
+
ask.turn.title or ask.turn.summary,
|
|
181
|
+
ask.turn.body or ask.turn.append,
|
|
182
|
+
), ask.last_path
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _merge(ask: Ask) -> tuple[str, str]:
|
|
186
|
+
return merge_pr(ask.project, _named(ask), allowed=True), ask.last_path
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
# One entry per action the model may take. This was nineteen
|
|
190
|
+
# `if turn.action == ...` tests in a row, which hid both the list and
|
|
191
|
+
# the fact that a new action has to be added to it: forgetting meant a
|
|
192
|
+
# silent "unknown Action" rather than an error anyone would notice.
|
|
193
|
+
# `ask` and `done` are answered by the loop before it gets here.
|
|
194
|
+
HANDLERS: dict[str, Callable[[Ask], tuple[str, str]]] = {
|
|
195
|
+
"locate": _locate,
|
|
196
|
+
"map": _map,
|
|
197
|
+
"layout": _layout,
|
|
198
|
+
"plan": _plan,
|
|
199
|
+
"glob": _glob,
|
|
200
|
+
"grep": _grep,
|
|
201
|
+
"read": _read,
|
|
202
|
+
"edit": _edit,
|
|
203
|
+
"patch": _patch,
|
|
204
|
+
"run": _run,
|
|
205
|
+
"skill": _skill,
|
|
206
|
+
"issue": _issue,
|
|
207
|
+
"branch": _branch,
|
|
208
|
+
"commit": _commit,
|
|
209
|
+
"push": _push,
|
|
210
|
+
"pr": _pr,
|
|
211
|
+
"merge": _merge,
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def run_action(
|
|
216
|
+
project: Path, turn, last_path: str, scope: str, target=None, task: str = ""
|
|
217
|
+
) -> tuple[str, str]:
|
|
218
|
+
"""Carry out one action, and say which file it left the run on."""
|
|
219
|
+
loaded = skill_from_action(turn.action, turn.name, turn.path, project)
|
|
220
|
+
if loaded is not None:
|
|
221
|
+
return render_skill(loaded, target, project), last_path
|
|
222
|
+
handler = HANDLERS.get(turn.action)
|
|
223
|
+
if handler is None:
|
|
224
|
+
return f"unknown Action {turn.action}. Use {ACTIONS}.", last_path
|
|
225
|
+
return handler(
|
|
226
|
+
Ask(
|
|
227
|
+
project=project,
|
|
228
|
+
turn=turn,
|
|
229
|
+
path=turn.path or last_path,
|
|
230
|
+
last_path=last_path,
|
|
231
|
+
scope=turn.scope or scope,
|
|
232
|
+
target=target,
|
|
233
|
+
task=task,
|
|
234
|
+
)
|
|
235
|
+
)
|