Proxy: local abaplint messages on a bare 'save failed' (EPOD syntax check cannot see the rejected source)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Kral
2026-10-05 12:03:54 +02:00
parent 229f862600
commit a4eb567e4c
2 changed files with 105 additions and 1 deletions

86
harness/localcheck.py Normal file
View File

@@ -0,0 +1,86 @@
"""Local syntax check with abaplint (parser errors) for a source that SAP could not save.
SAP answers a failed write with "An error occured during the save operation" and nothing else, and the
EPOD syntax check reads the stored version (the old one or none), so it cannot check the rejected source.
The proxy runs this check on the rejected source and adds the messages (line + text) to the tool result.
"""
import json
import os
import re
import shutil
import subprocess
import tempfile
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
ABAPLINT = os.path.join(ROOT, "node_modules", ".bin", "abaplint")
VERSIONS = {"v702", "v740sp05", "v750", "v758"}
BARE_SAVE_FAILURE = re.compile(r"during the save operation", re.I)
def is_bare_save_failure(text):
"""True when the write result is the detail-free 'save operation' error."""
try:
obj = json.loads(text)
except (TypeError, ValueError):
return bool(BARE_SAVE_FAILURE.search(str(text)))
if not isinstance(obj, dict) or obj.get("success") is not False:
return False
return bool(BARE_SAVE_FAILURE.search(str(obj.get("error") or obj.get("message") or ""))) \
and not (obj.get("activation") or {}).get("messages")
def filename(object_type, name, include_type=None, function_group=None):
n = re.sub(r"\W", "_", str(name).lower())
t = str(object_type).upper()
if t == "CLAS":
return f"{n}.clas.{'testclasses.' if str(include_type).lower() == 'testclasses' else ''}abap"
if t == "INTF":
return f"{n}.intf.abap"
if t == "PROG":
return f"{n}.prog.abap"
if t == "FUNC" and function_group:
fg = re.sub(r"\W", "_", str(function_group).lower())
return f"{fg}.fugr.{n}.abap"
if t == "DDLS":
return f"{n}.ddls.asddls"
return None # TABL, FUGR, ...: no local parser check
def parser_messages(object_type, name, source, include_type=None, function_group=None, release=None):
"""[{"line", "severity", "message"}] from abaplint parser_error; [] when nothing is found or no check."""
fn = filename(object_type, name, include_type, function_group)
if not fn or not source or not os.path.exists(ABAPLINT):
return []
version = release if release in VERSIONS else "v758"
d = tempfile.mkdtemp(prefix="proxylint_")
try:
os.makedirs(os.path.join(d, "src"))
open(os.path.join(d, "src", fn), "w").write(source)
cfg = {"global": {"files": "/src/**/*.*"}, "dependencies": [],
"syntax": {"version": version, "errorNamespace": "^(Z|Y)"}, "rules": {"parser_error": True}}
json.dump(cfg, open(os.path.join(d, "abaplint.json"), "w"))
p = subprocess.run([ABAPLINT, "abaplint.json", "-f", "json"], cwd=d, capture_output=True, text=True,
timeout=60)
issues = json.loads(p.stdout or "[]")
except Exception: # noqa: BLE001 the hint is optional; the original SAP result stays
return []
finally:
shutil.rmtree(d, ignore_errors=True)
return [{"line": i.get("start", {}).get("row"), "severity": "E", "message": i.get("description", "")}
for i in issues if i.get("key") == "parser_error"][:10]
def augment(text, messages):
"""Add the messages to a failed-write result (JSON object keeps valid JSON)."""
if not messages:
return text
try:
obj = json.loads(text)
if isinstance(obj, dict):
obj["syntaxCheck"] = {"source": "local abaplint (the rejected source, not stored in SAP)",
"messages": messages}
return json.dumps(obj)
except (TypeError, ValueError):
pass
return text + "\nSyntax check (local abaplint, rejected source): " + "; ".join(
f"line {m['line']}: {m['message']}" for m in messages)

View File

@@ -4,6 +4,8 @@ import json
import re
import time
from . import localcheck
MODEL_TOOLS = {
"sap_search_object", "sap_pull_source", "sap_object_structure", "sap_object_members",
"sap_usage_references", "sap_element_info", "sap_inactive_objects", "sap_short_dumps",
@@ -56,8 +58,10 @@ class BudgetExceeded(Exception):
class ToolProxy:
def __init__(self, mcp, prefix, budget, log_path, tool_schema=None):
def __init__(self, mcp, prefix, budget, log_path, tool_schema=None, release=None):
self.mcp = mcp
self.release = release # task release target, for the local syntax check
self.syntax_hints = 0 # failed writes that got local syntax messages
self.variant = VARIANTS.get(tool_schema) if tool_schema else None
self.started = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) # dump times are UTC
self.prefix = prefix.upper()
@@ -163,7 +167,12 @@ class ToolProxy:
src = self.last_source.get(str(args.get("objectName", "")).upper())
if src is not None:
err, text = self._activation_fallback(dict(args, source=src), err, text)
raw_text = text
if tool in ("sap_push_source", "sap_push_element") and localcheck.is_bare_save_failure(text):
text = self._add_syntax_messages(args, text)
result = (err, self._filter(tool, text))
if text != raw_text:
entry["raw_result"] = raw_text
if tool in WRITE_TOOLS:
failed = err or '"success":false' in text.replace(" ", "")
self.fail_streak = self.fail_streak + 1 if failed else 0
@@ -183,6 +192,15 @@ class ToolProxy:
self.log.flush()
return result
def _add_syntax_messages(self, args, text):
"""SAP says only "save operation failed". The EPOD syntax check reads the stored version, not the
rejected source, so the proxy checks the rejected source with local abaplint and adds the messages."""
msgs = localcheck.parser_messages(args.get("objectType"), args.get("objectName"), args.get("source"),
args.get("includeType"), args.get("functionGroup"), self.release)
if msgs:
self.syntax_hints += 1
return localcheck.augment(text, msgs)
def _activation_fallback(self, args, err, text):
"""EPOD does not activate a second write of a PROG or FUNC (outcome notExecuted, and sap_activate
cannot help). Write and activate the same source through ADT REST; the agent sees one normal result."""