stackfix 0.2.0
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.
- package/README.md +111 -0
- package/bin/stackfix +56 -0
- package/package.json +39 -0
- package/pyproject.toml +23 -0
- package/scripts/parse_selftest.py +13 -0
- package/scripts/postinstall.js +130 -0
- package/stackfix/__init__.py +1 -0
- package/stackfix/__main__.py +4 -0
- package/stackfix/__pycache__/__init__.cpython-314.pyc +0 -0
- package/stackfix/__pycache__/__main__.cpython-314.pyc +0 -0
- package/stackfix/__pycache__/agent.cpython-314.pyc +0 -0
- package/stackfix/__pycache__/agents.cpython-314.pyc +0 -0
- package/stackfix/__pycache__/cli.cpython-314.pyc +0 -0
- package/stackfix/__pycache__/context.cpython-314.pyc +0 -0
- package/stackfix/__pycache__/history.cpython-314.pyc +0 -0
- package/stackfix/__pycache__/patching.cpython-314.pyc +0 -0
- package/stackfix/__pycache__/safety.cpython-314.pyc +0 -0
- package/stackfix/__pycache__/session.cpython-314.pyc +0 -0
- package/stackfix/__pycache__/tui.cpython-314.pyc +0 -0
- package/stackfix/__pycache__/util.cpython-314.pyc +0 -0
- package/stackfix/agent.py +298 -0
- package/stackfix/agents.py +29 -0
- package/stackfix/cli.py +169 -0
- package/stackfix/context.py +73 -0
- package/stackfix/history.py +32 -0
- package/stackfix/patching.py +138 -0
- package/stackfix/safety.py +60 -0
- package/stackfix/session.py +40 -0
- package/stackfix/tui.py +591 -0
- package/stackfix/util.py +54 -0
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import shlex
|
|
4
|
+
import sys
|
|
5
|
+
import requests
|
|
6
|
+
from typing import Dict, Any, Optional, Tuple
|
|
7
|
+
|
|
8
|
+
from .util import env_required
|
|
9
|
+
|
|
10
|
+
SYSTEM_PROMPT = (
|
|
11
|
+
"You are StackFix, an agent that proposes minimal safe patches to fix a failing command. "
|
|
12
|
+
"Return ONLY a single JSON object in the assistant message content, with keys: "
|
|
13
|
+
"summary (string), confidence (0-1 number), patch_unified_diff (string), "
|
|
14
|
+
"rerun_command (array of strings). "
|
|
15
|
+
"No markdown, no backticks, no extra text. "
|
|
16
|
+
"The patch must be a valid git unified diff that starts with: "
|
|
17
|
+
"diff --git a/<path> b/<path>, includes --- a/<path>, +++ b/<path>, and hunk headers "
|
|
18
|
+
"with ranges like @@ -1,2 +1,8 @@ (no bare @@ lines)."
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
PROMPT_MODE_SYSTEM_PROMPT = (
|
|
22
|
+
"You are StackFix, a helpful AI coding assistant. "
|
|
23
|
+
"Answer the user's question directly and concisely. "
|
|
24
|
+
"If asked about code, provide clear explanations. "
|
|
25
|
+
"If asked to modify code, explain what changes would be needed. "
|
|
26
|
+
"Keep responses focused and practical."
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
STRICT_DIFF_PROMPT = (
|
|
30
|
+
"Your diff was invalid. Return a valid git unified diff with proper @@ ranges. "
|
|
31
|
+
"Return ONLY a single JSON object in the assistant message content with keys: "
|
|
32
|
+
"summary, confidence, patch_unified_diff, rerun_command. "
|
|
33
|
+
"patch_unified_diff MUST be a valid git unified diff only (no Begin Patch markers), "
|
|
34
|
+
"starting with: diff --git a/<path> b/<path>, including ---/+++ lines, and hunk headers "
|
|
35
|
+
"with ranges like @@ -1,2 +1,8 @@. Do NOT use bare @@. "
|
|
36
|
+
"Example hunk header: @@ -1,2 +1,2 @@. No extra text."
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
_ENDPOINT_LOGGED = False
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _log_endpoint_once(url: str) -> None:
|
|
43
|
+
global _ENDPOINT_LOGGED
|
|
44
|
+
if _ENDPOINT_LOGGED:
|
|
45
|
+
return
|
|
46
|
+
_ENDPOINT_LOGGED = True
|
|
47
|
+
print(f"[stackfix] Using model endpoint: {url}", file=sys.stderr)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _is_debug() -> bool:
|
|
51
|
+
return os.environ.get("STACKFIX_DEBUG") == "1"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _redact_secrets(text: str) -> str:
|
|
55
|
+
api_key = os.environ.get("MODEL_API_KEY")
|
|
56
|
+
if api_key:
|
|
57
|
+
text = text.replace(api_key, "[REDACTED]")
|
|
58
|
+
for token_key in ["api_key", "apikey", "token", "authorization", "bearer"]:
|
|
59
|
+
text = text.replace(token_key, f"{token_key[:2]}***")
|
|
60
|
+
return text
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _debug_log(msg: str) -> None:
|
|
64
|
+
if _is_debug():
|
|
65
|
+
print(f"[stackfix][debug] {msg}", file=sys.stderr)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _model_request_payload(context: Dict[str, Any], system_prompt: str = SYSTEM_PROMPT) -> Dict[str, Any]:
|
|
69
|
+
max_tokens = int(os.environ.get("MODEL_MAX_TOKENS", "2000"))
|
|
70
|
+
|
|
71
|
+
# For prompt mode, use plain text format and simpler prompt
|
|
72
|
+
is_prompt_mode = context.get("mode") == "prompt"
|
|
73
|
+
if is_prompt_mode and system_prompt == SYSTEM_PROMPT:
|
|
74
|
+
system_prompt = PROMPT_MODE_SYSTEM_PROMPT
|
|
75
|
+
|
|
76
|
+
# Build user message - for prompt mode, just send the prompt text
|
|
77
|
+
if is_prompt_mode:
|
|
78
|
+
user_content = context.get("prompt", "")
|
|
79
|
+
if context.get("agent_instructions"):
|
|
80
|
+
user_content = f"Project context:\n{context['agent_instructions']}\n\nUser question: {user_content}"
|
|
81
|
+
else:
|
|
82
|
+
user_content = json.dumps(context)
|
|
83
|
+
|
|
84
|
+
payload = {
|
|
85
|
+
"model": env_required("MODEL_NAME"),
|
|
86
|
+
"temperature": 0.2,
|
|
87
|
+
"max_tokens": max_tokens,
|
|
88
|
+
"messages": [
|
|
89
|
+
{"role": "system", "content": system_prompt},
|
|
90
|
+
{"role": "user", "content": user_content},
|
|
91
|
+
],
|
|
92
|
+
}
|
|
93
|
+
# Only request JSON format for non-prompt mode
|
|
94
|
+
if not is_prompt_mode and os.environ.get("STACKFIX_NO_RESPONSE_FORMAT") != "1":
|
|
95
|
+
payload["response_format"] = {"type": "json_object"}
|
|
96
|
+
return payload
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _call_direct(context: Dict[str, Any], system_prompt: str = SYSTEM_PROMPT) -> Dict[str, Any]:
|
|
100
|
+
base_url = env_required("MODEL_BASE_URL").rstrip("/")
|
|
101
|
+
api_key = env_required("MODEL_API_KEY")
|
|
102
|
+
if base_url.endswith("/v1"):
|
|
103
|
+
url = f"{base_url}/chat/completions"
|
|
104
|
+
else:
|
|
105
|
+
url = f"{base_url}/v1/chat/completions"
|
|
106
|
+
_log_endpoint_once(url)
|
|
107
|
+
payload = _model_request_payload(context, system_prompt=system_prompt)
|
|
108
|
+
headers = {
|
|
109
|
+
"Authorization": f"Bearer {api_key}",
|
|
110
|
+
"Content-Type": "application/json",
|
|
111
|
+
}
|
|
112
|
+
resp = requests.post(url, headers=headers, json=payload, timeout=60)
|
|
113
|
+
_debug_log(f"HTTP status: {resp.status_code}")
|
|
114
|
+
resp.raise_for_status()
|
|
115
|
+
raw_text = resp.text
|
|
116
|
+
_debug_log(f"Raw response (first 500 chars): { _redact_secrets(raw_text[:500]) }")
|
|
117
|
+
data = resp.json()
|
|
118
|
+
content = _extract_content(data)
|
|
119
|
+
return _parse_agent_response(content)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _call_modal(endpoint: str, context: Dict[str, Any], system_prompt: str = SYSTEM_PROMPT) -> Dict[str, Any]:
|
|
123
|
+
payload = _model_request_payload(context, system_prompt=system_prompt)
|
|
124
|
+
resp = requests.post(endpoint, json=payload, timeout=60)
|
|
125
|
+
_debug_log(f"HTTP status: {resp.status_code}")
|
|
126
|
+
resp.raise_for_status()
|
|
127
|
+
raw_text = resp.text
|
|
128
|
+
_debug_log(f"Raw response (first 500 chars): { _redact_secrets(raw_text[:500]) }")
|
|
129
|
+
data = resp.json()
|
|
130
|
+
content = data.get("content") or data.get("response") or data
|
|
131
|
+
if isinstance(content, dict):
|
|
132
|
+
return _validate_agent_json(content)
|
|
133
|
+
return _parse_agent_response(content)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _parse_agent_response(content: str) -> Dict[str, Any]:
|
|
137
|
+
parsed: Optional[Dict[str, Any]] = None
|
|
138
|
+
warning: Optional[str] = None
|
|
139
|
+
if content is None:
|
|
140
|
+
return _fallback_response("", "Agent returned empty content")
|
|
141
|
+
try:
|
|
142
|
+
parsed = json.loads(content)
|
|
143
|
+
except Exception:
|
|
144
|
+
warning = "Agent response was not valid JSON; falling back to raw content"
|
|
145
|
+
return _fallback_response(content, warning)
|
|
146
|
+
return _validate_agent_json(parsed, raw_content=content)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _extract_content(data: Dict[str, Any]) -> str:
|
|
150
|
+
def _get(obj, key, default=None):
|
|
151
|
+
if isinstance(obj, dict):
|
|
152
|
+
return obj.get(key, default)
|
|
153
|
+
return getattr(obj, key, default)
|
|
154
|
+
|
|
155
|
+
choices = _get(data, "choices")
|
|
156
|
+
if isinstance(choices, list) and choices:
|
|
157
|
+
choice = choices[0]
|
|
158
|
+
message = _get(choice, "message")
|
|
159
|
+
if message is not None:
|
|
160
|
+
content = _get(message, "content")
|
|
161
|
+
if isinstance(content, list):
|
|
162
|
+
parts = []
|
|
163
|
+
for part in content:
|
|
164
|
+
if isinstance(part, dict) and "text" in part:
|
|
165
|
+
parts.append(part["text"])
|
|
166
|
+
if parts:
|
|
167
|
+
_debug_log("Parsed content from message.content list")
|
|
168
|
+
return "".join(parts)
|
|
169
|
+
if content is not None and content.strip():
|
|
170
|
+
_debug_log("Parsed content from message.content")
|
|
171
|
+
return content
|
|
172
|
+
# Check for reasoning_content (used by some models like Nebius)
|
|
173
|
+
reasoning_content = _get(message, "reasoning_content")
|
|
174
|
+
if reasoning_content is not None and str(reasoning_content).strip():
|
|
175
|
+
_debug_log("Parsed content from message.reasoning_content")
|
|
176
|
+
return str(reasoning_content)
|
|
177
|
+
tool_calls = _get(message, "tool_calls")
|
|
178
|
+
if isinstance(tool_calls, list) and tool_calls:
|
|
179
|
+
fn = _get(tool_calls[0], "function", {})
|
|
180
|
+
args = _get(fn, "arguments")
|
|
181
|
+
if args:
|
|
182
|
+
_debug_log("Parsed content from message.tool_calls.function.arguments")
|
|
183
|
+
return args
|
|
184
|
+
text = _get(choice, "text")
|
|
185
|
+
if text is not None:
|
|
186
|
+
_debug_log("Parsed content from choice.text")
|
|
187
|
+
return text
|
|
188
|
+
keys = list(data.keys()) if isinstance(data, dict) else dir(data)
|
|
189
|
+
raise RuntimeError(f"Agent returned no content to parse; top-level keys: {keys}")
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _normalize_rerun_command(value: Any) -> list:
|
|
193
|
+
if value is None:
|
|
194
|
+
return []
|
|
195
|
+
if isinstance(value, list):
|
|
196
|
+
return [str(v) for v in value if v is not None]
|
|
197
|
+
if isinstance(value, str):
|
|
198
|
+
try:
|
|
199
|
+
return shlex.split(value)
|
|
200
|
+
except Exception:
|
|
201
|
+
return [value]
|
|
202
|
+
return [str(value)]
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _coerce_confidence(value: Any) -> Optional[float]:
|
|
206
|
+
if value is None:
|
|
207
|
+
return None
|
|
208
|
+
if isinstance(value, (int, float)):
|
|
209
|
+
return float(value)
|
|
210
|
+
if isinstance(value, str):
|
|
211
|
+
try:
|
|
212
|
+
return float(value)
|
|
213
|
+
except Exception:
|
|
214
|
+
return None
|
|
215
|
+
return None
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _fallback_response(content: str, warning: Optional[str]) -> Dict[str, Any]:
|
|
219
|
+
return {
|
|
220
|
+
"summary": content.strip(),
|
|
221
|
+
"confidence": None,
|
|
222
|
+
"patch_unified_diff": "",
|
|
223
|
+
"rerun_command": [],
|
|
224
|
+
"_raw_content": content,
|
|
225
|
+
"_warning": warning,
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _validate_agent_json(parsed: Dict[str, Any], raw_content: Optional[str] = None) -> Dict[str, Any]:
|
|
230
|
+
if not isinstance(parsed, dict):
|
|
231
|
+
return _fallback_response(raw_content or str(parsed), "Agent JSON was not an object")
|
|
232
|
+
|
|
233
|
+
summary = parsed.get("summary")
|
|
234
|
+
if summary is None:
|
|
235
|
+
summary = ""
|
|
236
|
+
summary = str(summary)
|
|
237
|
+
|
|
238
|
+
patch = parsed.get("patch_unified_diff") or ""
|
|
239
|
+
if patch is None:
|
|
240
|
+
patch = ""
|
|
241
|
+
patch = str(patch)
|
|
242
|
+
|
|
243
|
+
rerun = _normalize_rerun_command(parsed.get("rerun_command"))
|
|
244
|
+
confidence = _coerce_confidence(parsed.get("confidence"))
|
|
245
|
+
|
|
246
|
+
return {
|
|
247
|
+
"summary": summary,
|
|
248
|
+
"confidence": confidence,
|
|
249
|
+
"patch_unified_diff": patch,
|
|
250
|
+
"rerun_command": rerun,
|
|
251
|
+
"_raw_content": raw_content,
|
|
252
|
+
"_warning": None,
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _is_valid_unified_diff(diff_text: str) -> bool:
|
|
257
|
+
if not isinstance(diff_text, str):
|
|
258
|
+
return False
|
|
259
|
+
if "diff --git " not in diff_text or "--- " not in diff_text or "+++ " not in diff_text:
|
|
260
|
+
return False
|
|
261
|
+
lines = diff_text.splitlines()
|
|
262
|
+
has_hunk = False
|
|
263
|
+
for line in lines:
|
|
264
|
+
if line.startswith("@@"):
|
|
265
|
+
if not line.startswith("@@ -"):
|
|
266
|
+
return False
|
|
267
|
+
if not _is_valid_hunk_header(line):
|
|
268
|
+
return False
|
|
269
|
+
has_hunk = True
|
|
270
|
+
return has_hunk
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _is_valid_hunk_header(line: str) -> bool:
|
|
274
|
+
import re
|
|
275
|
+
return re.match(r"^@@ -\d+(,\d+)? \+\d+(,\d+)? @@", line) is not None
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def call_agent(context: Dict[str, Any]) -> Dict[str, Any]:
|
|
279
|
+
endpoint = os.environ.get("STACKFIX_ENDPOINT")
|
|
280
|
+
if endpoint:
|
|
281
|
+
result = _call_modal(endpoint, context)
|
|
282
|
+
else:
|
|
283
|
+
result = _call_direct(context)
|
|
284
|
+
|
|
285
|
+
patch = result.get("patch_unified_diff", "")
|
|
286
|
+
if _is_valid_unified_diff(patch):
|
|
287
|
+
return result
|
|
288
|
+
|
|
289
|
+
_debug_log("Invalid patch format; retrying once with strict diff prompt")
|
|
290
|
+
if endpoint:
|
|
291
|
+
result = _call_modal(endpoint, context, system_prompt=STRICT_DIFF_PROMPT)
|
|
292
|
+
else:
|
|
293
|
+
result = _call_direct(context, system_prompt=STRICT_DIFF_PROMPT)
|
|
294
|
+
patch = result.get("patch_unified_diff", "")
|
|
295
|
+
if _is_valid_unified_diff(patch):
|
|
296
|
+
return result
|
|
297
|
+
_debug_log("Agent returned invalid unified diff after retry; passing to fallback applier")
|
|
298
|
+
return result
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
MAX_AGENT_BYTES = 12000
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def find_agents_file(cwd: str) -> Optional[str]:
|
|
8
|
+
current = os.path.abspath(cwd)
|
|
9
|
+
root = os.path.abspath(os.sep)
|
|
10
|
+
while True:
|
|
11
|
+
candidate = os.path.join(current, "AGENTS.md")
|
|
12
|
+
if os.path.isfile(candidate):
|
|
13
|
+
return candidate
|
|
14
|
+
if current == root:
|
|
15
|
+
return None
|
|
16
|
+
current = os.path.dirname(current)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def load_agents_instructions(cwd: str) -> Optional[str]:
|
|
20
|
+
path = find_agents_file(cwd)
|
|
21
|
+
if not path:
|
|
22
|
+
return None
|
|
23
|
+
try:
|
|
24
|
+
if os.path.getsize(path) > MAX_AGENT_BYTES:
|
|
25
|
+
return None
|
|
26
|
+
with open(path, "r", encoding="utf-8", errors="replace") as f:
|
|
27
|
+
return f.read()
|
|
28
|
+
except Exception:
|
|
29
|
+
return None
|
package/stackfix/cli.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
from typing import List
|
|
6
|
+
|
|
7
|
+
from .agent import call_agent
|
|
8
|
+
from .context import collect_context
|
|
9
|
+
from .history import write_history, read_last
|
|
10
|
+
from .patching import apply_patch
|
|
11
|
+
from .util import run_command_stream
|
|
12
|
+
from .agents import load_agents_instructions
|
|
13
|
+
from .tui import run_tui
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _print_last(cwd: str) -> int:
|
|
17
|
+
last = read_last(cwd)
|
|
18
|
+
if not last:
|
|
19
|
+
print("No history found.")
|
|
20
|
+
return 1
|
|
21
|
+
print("Last run summary:")
|
|
22
|
+
print(last.get("summary", ""))
|
|
23
|
+
print("\nPatch:")
|
|
24
|
+
print(last.get("patch", ""))
|
|
25
|
+
return 0
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _normalize_command(cmd: List[str]) -> List[str]:
|
|
29
|
+
if cmd and cmd[0] == "--":
|
|
30
|
+
return cmd[1:]
|
|
31
|
+
return cmd
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def main() -> None:
|
|
35
|
+
parser = argparse.ArgumentParser(description="StackFix command wrapper")
|
|
36
|
+
parser.add_argument("--last", action="store_true", help="Show last run summary and patch")
|
|
37
|
+
parser.add_argument("--prompt", type=str, help="Run a single prompt non-interactively")
|
|
38
|
+
parser.add_argument("command", nargs=argparse.REMAINDER, help="Command to run after --")
|
|
39
|
+
args = parser.parse_args()
|
|
40
|
+
|
|
41
|
+
cwd = os.getcwd()
|
|
42
|
+
|
|
43
|
+
if args.last:
|
|
44
|
+
sys.exit(_print_last(cwd))
|
|
45
|
+
|
|
46
|
+
if args.prompt:
|
|
47
|
+
context = {
|
|
48
|
+
"mode": "prompt",
|
|
49
|
+
"prompt": args.prompt,
|
|
50
|
+
"cwd": cwd,
|
|
51
|
+
}
|
|
52
|
+
agents = load_agents_instructions(cwd)
|
|
53
|
+
if agents:
|
|
54
|
+
context["agent_instructions"] = agents
|
|
55
|
+
try:
|
|
56
|
+
agent_result = call_agent(context)
|
|
57
|
+
except Exception as exc:
|
|
58
|
+
print(f"Agent call failed: {exc}", file=sys.stderr)
|
|
59
|
+
sys.exit(1)
|
|
60
|
+
summary = agent_result.get("summary", "")
|
|
61
|
+
warning = agent_result.get("_warning")
|
|
62
|
+
if warning:
|
|
63
|
+
print(f"Warning: {warning}", file=sys.stderr)
|
|
64
|
+
print(summary)
|
|
65
|
+
record = {
|
|
66
|
+
"command": None,
|
|
67
|
+
"exit_code": 0,
|
|
68
|
+
"summary": summary,
|
|
69
|
+
"patch": agent_result.get("patch_unified_diff", ""),
|
|
70
|
+
"rerun_exit_code": None,
|
|
71
|
+
}
|
|
72
|
+
write_history(cwd, record)
|
|
73
|
+
sys.exit(0)
|
|
74
|
+
|
|
75
|
+
cmd = _normalize_command(args.command)
|
|
76
|
+
if not cmd:
|
|
77
|
+
run_tui()
|
|
78
|
+
return
|
|
79
|
+
|
|
80
|
+
exit_code, stdout, stderr = run_command_stream(cmd, cwd)
|
|
81
|
+
|
|
82
|
+
if exit_code == 0:
|
|
83
|
+
record = {
|
|
84
|
+
"command": cmd,
|
|
85
|
+
"exit_code": exit_code,
|
|
86
|
+
"summary": "Command succeeded; no patch applied.",
|
|
87
|
+
"patch": "",
|
|
88
|
+
"rerun_exit_code": None,
|
|
89
|
+
}
|
|
90
|
+
write_history(cwd, record)
|
|
91
|
+
sys.exit(exit_code)
|
|
92
|
+
|
|
93
|
+
context = collect_context(cwd, cmd, exit_code, stdout, stderr)
|
|
94
|
+
|
|
95
|
+
try:
|
|
96
|
+
agent_result = call_agent(context)
|
|
97
|
+
except Exception as exc:
|
|
98
|
+
print(f"Agent call failed: {exc}", file=sys.stderr)
|
|
99
|
+
sys.exit(exit_code)
|
|
100
|
+
|
|
101
|
+
warning = agent_result.get("_warning")
|
|
102
|
+
if warning:
|
|
103
|
+
print(f"Warning: {warning}", file=sys.stderr)
|
|
104
|
+
|
|
105
|
+
patch = agent_result.get("patch_unified_diff", "")
|
|
106
|
+
summary = agent_result.get("summary", "")
|
|
107
|
+
|
|
108
|
+
print("\nProposed fix:")
|
|
109
|
+
print(summary)
|
|
110
|
+
print("\nPatch preview:\n")
|
|
111
|
+
print(patch)
|
|
112
|
+
|
|
113
|
+
if not patch.strip():
|
|
114
|
+
record = {
|
|
115
|
+
"command": cmd,
|
|
116
|
+
"exit_code": exit_code,
|
|
117
|
+
"summary": summary,
|
|
118
|
+
"patch": patch,
|
|
119
|
+
"rerun_exit_code": None,
|
|
120
|
+
"applied": False,
|
|
121
|
+
}
|
|
122
|
+
write_history(cwd, record)
|
|
123
|
+
print("No patch provided by agent.")
|
|
124
|
+
sys.exit(exit_code)
|
|
125
|
+
|
|
126
|
+
confirm = input("Apply patch? [y/N]: ").strip().lower()
|
|
127
|
+
if confirm != "y":
|
|
128
|
+
record = {
|
|
129
|
+
"command": cmd,
|
|
130
|
+
"exit_code": exit_code,
|
|
131
|
+
"summary": summary,
|
|
132
|
+
"patch": patch,
|
|
133
|
+
"rerun_exit_code": None,
|
|
134
|
+
"applied": False,
|
|
135
|
+
}
|
|
136
|
+
write_history(cwd, record)
|
|
137
|
+
print("Patch not applied.")
|
|
138
|
+
sys.exit(exit_code)
|
|
139
|
+
|
|
140
|
+
try:
|
|
141
|
+
apply_patch(patch, cwd)
|
|
142
|
+
except Exception as exc:
|
|
143
|
+
print(f"Failed to apply patch: {exc}", file=sys.stderr)
|
|
144
|
+
sys.exit(exit_code)
|
|
145
|
+
|
|
146
|
+
rerun_cmd = agent_result.get("rerun_command") or cmd
|
|
147
|
+
print("\nRerunning command...")
|
|
148
|
+
rerun_exit, rerun_stdout, rerun_stderr = run_command_stream(rerun_cmd, cwd)
|
|
149
|
+
|
|
150
|
+
record = {
|
|
151
|
+
"command": cmd,
|
|
152
|
+
"exit_code": exit_code,
|
|
153
|
+
"summary": summary,
|
|
154
|
+
"patch": patch,
|
|
155
|
+
"rerun_command": rerun_cmd,
|
|
156
|
+
"rerun_exit_code": rerun_exit,
|
|
157
|
+
"rerun_stdout": rerun_stdout,
|
|
158
|
+
"rerun_stderr": rerun_stderr,
|
|
159
|
+
"applied": True,
|
|
160
|
+
}
|
|
161
|
+
write_history(cwd, record)
|
|
162
|
+
|
|
163
|
+
print("\nRun summary:")
|
|
164
|
+
print(json.dumps({"before": exit_code, "after": rerun_exit}, indent=2))
|
|
165
|
+
sys.exit(rerun_exit)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
if __name__ == "__main__":
|
|
169
|
+
main()
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import subprocess
|
|
3
|
+
from typing import Dict
|
|
4
|
+
|
|
5
|
+
from .util import truncate_text, is_git_repo
|
|
6
|
+
from .safety import is_forbidden_path
|
|
7
|
+
from .agents import load_agents_instructions
|
|
8
|
+
|
|
9
|
+
MAX_STDIO_CHARS = 20000
|
|
10
|
+
MAX_GIT_CHARS = 20000
|
|
11
|
+
MAX_FILE_CHARS = 12000
|
|
12
|
+
MAX_FILE_BYTES = 200000
|
|
13
|
+
|
|
14
|
+
MANIFESTS = [
|
|
15
|
+
"package.json",
|
|
16
|
+
"pnpm-lock.yaml",
|
|
17
|
+
"yarn.lock",
|
|
18
|
+
"pyproject.toml",
|
|
19
|
+
"requirements.txt",
|
|
20
|
+
"poetry.lock",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _read_file(path: str) -> str:
|
|
25
|
+
with open(path, "r", encoding="utf-8", errors="replace") as f:
|
|
26
|
+
return f.read()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def collect_context(cwd: str, command: list, exit_code: int, stdout: str, stderr: str) -> Dict:
|
|
30
|
+
ctx = {
|
|
31
|
+
"command": command,
|
|
32
|
+
"cwd": cwd,
|
|
33
|
+
"exit_code": exit_code,
|
|
34
|
+
"stdout": truncate_text(stdout, MAX_STDIO_CHARS),
|
|
35
|
+
"stderr": truncate_text(stderr, MAX_STDIO_CHARS),
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
agents = load_agents_instructions(cwd)
|
|
39
|
+
if agents:
|
|
40
|
+
ctx["agent_instructions"] = truncate_text(agents, MAX_FILE_CHARS)
|
|
41
|
+
|
|
42
|
+
if is_git_repo(cwd):
|
|
43
|
+
try:
|
|
44
|
+
status = subprocess.check_output(["git", "status", "--porcelain"], cwd=cwd, text=True)
|
|
45
|
+
except Exception:
|
|
46
|
+
status = ""
|
|
47
|
+
try:
|
|
48
|
+
diff = subprocess.check_output(["git", "diff"], cwd=cwd, text=True)
|
|
49
|
+
except Exception:
|
|
50
|
+
diff = ""
|
|
51
|
+
ctx["git_status"] = truncate_text(status, MAX_GIT_CHARS)
|
|
52
|
+
ctx["git_diff"] = truncate_text(diff, MAX_GIT_CHARS)
|
|
53
|
+
|
|
54
|
+
files = {}
|
|
55
|
+
for name in MANIFESTS:
|
|
56
|
+
path = os.path.join(cwd, name)
|
|
57
|
+
if not os.path.isfile(path):
|
|
58
|
+
continue
|
|
59
|
+
if is_forbidden_path(path, cwd):
|
|
60
|
+
continue
|
|
61
|
+
size = os.path.getsize(path)
|
|
62
|
+
if size > MAX_FILE_BYTES:
|
|
63
|
+
continue
|
|
64
|
+
try:
|
|
65
|
+
content = _read_file(path)
|
|
66
|
+
except Exception:
|
|
67
|
+
continue
|
|
68
|
+
files[name] = truncate_text(content, MAX_FILE_CHARS)
|
|
69
|
+
|
|
70
|
+
if files:
|
|
71
|
+
ctx["manifests"] = files
|
|
72
|
+
|
|
73
|
+
return ctx
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import Dict, Any, Optional
|
|
5
|
+
|
|
6
|
+
HISTORY_DIR = ".stackfix/history"
|
|
7
|
+
LAST_FILE = os.path.join(HISTORY_DIR, "last.json")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def ensure_history_dir(cwd: str) -> str:
|
|
11
|
+
path = os.path.join(cwd, HISTORY_DIR)
|
|
12
|
+
os.makedirs(path, exist_ok=True)
|
|
13
|
+
return path
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def write_history(cwd: str, record: Dict[str, Any]) -> str:
|
|
17
|
+
history_dir = ensure_history_dir(cwd)
|
|
18
|
+
ts = datetime.utcnow().strftime("%Y%m%d_%H%M%S")
|
|
19
|
+
path = os.path.join(history_dir, f"{ts}.json")
|
|
20
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
21
|
+
json.dump(record, f, indent=2)
|
|
22
|
+
with open(os.path.join(cwd, LAST_FILE), "w", encoding="utf-8") as f:
|
|
23
|
+
json.dump(record, f, indent=2)
|
|
24
|
+
return path
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def read_last(cwd: str) -> Optional[Dict[str, Any]]:
|
|
28
|
+
path = os.path.join(cwd, LAST_FILE)
|
|
29
|
+
if not os.path.isfile(path):
|
|
30
|
+
return None
|
|
31
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
32
|
+
return json.load(f)
|