@miller-tech/uap 1.218.0 → 1.220.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/dist/.tsbuildinfo +1 -1
- package/dist/bin/cli.js +1 -0
- package/dist/bin/cli.js.map +1 -1
- package/dist/cli/deliver.d.ts +2 -0
- package/dist/cli/deliver.d.ts.map +1 -1
- package/dist/cli/deliver.js +47 -0
- package/dist/cli/deliver.js.map +1 -1
- package/dist/cli/guided-setup.d.ts.map +1 -1
- package/dist/cli/guided-setup.js +8 -6
- package/dist/cli/guided-setup.js.map +1 -1
- package/dist/cli/hooks.d.ts.map +1 -1
- package/dist/cli/hooks.js +32 -0
- package/dist/cli/hooks.js.map +1 -1
- package/dist/cli/wizard-config.d.ts +2 -2
- package/dist/cli/wizard-config.d.ts.map +1 -1
- package/dist/config/settings-registry.d.ts.map +1 -1
- package/dist/config/settings-registry.js +22 -7
- package/dist/config/settings-registry.js.map +1 -1
- package/dist/coordination/reactor.d.ts +8 -0
- package/dist/coordination/reactor.d.ts.map +1 -1
- package/dist/coordination/reactor.js +56 -13
- package/dist/coordination/reactor.js.map +1 -1
- package/dist/delivery/trivial-mission.d.ts +30 -0
- package/dist/delivery/trivial-mission.d.ts.map +1 -0
- package/dist/delivery/trivial-mission.js +118 -0
- package/dist/delivery/trivial-mission.js.map +1 -0
- package/dist/mcp-router/tools/deliver.d.ts +6 -0
- package/dist/mcp-router/tools/deliver.d.ts.map +1 -1
- package/dist/mcp-router/tools/deliver.js +20 -1
- package/dist/mcp-router/tools/deliver.js.map +1 -1
- package/dist/types/config.d.ts +40 -16
- package/dist/types/config.d.ts.map +1 -1
- package/dist/types/config.js +10 -2
- package/dist/types/config.js.map +1 -1
- package/docs/reference/CONFIGURATION_REFERENCE.md +44 -8
- package/package.json +1 -1
- package/src/policies/enforcers/__pycache__/_common.cpython-312.pyc +0 -0
- package/src/policies/enforcers/delivery_enforcement.py +226 -8
- package/templates/hooks/__pycache__/deliver_autoroute.cpython-312.pyc +0 -0
- package/templates/hooks/deliver_autoroute.py +2 -2
- package/templates/hooks/escalation_tracker.py +252 -0
- package/templates/hooks/stop.sh +10 -0
- package/templates/hooks/uap-policy-gate.sh +5 -5
- package/tools/agents/scripts/__pycache__/toolcall_path_normalizer.cpython-312.pyc +0 -0
- package/tools/agents/scripts/anthropic_proxy.py +112 -4
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""escalation-tracker: the evidence store behind the `escalate` delivery mode.
|
|
3
|
+
|
|
4
|
+
Under `delivery.enforcement: escalate` (or `delivery.localMode: escalate`) a
|
|
5
|
+
coding agent makes ordinary source edits DIRECTLY and `uap deliver` becomes an
|
|
6
|
+
ESCALATION POINT instead of the path every edit is forced through. The gate
|
|
7
|
+
(delivery_enforcement.py) decides *when* to escalate from the state kept here:
|
|
8
|
+
|
|
9
|
+
.uap/escalation-state.json
|
|
10
|
+
failures consecutive verification failures since the last green
|
|
11
|
+
last_failure {ts, source, detail} — what failed, shown to the agent
|
|
12
|
+
last_green ts of the last passing verification
|
|
13
|
+
edits_since_green {relpath: n} — per-file churn with no green in between
|
|
14
|
+
updated ts of the last write
|
|
15
|
+
|
|
16
|
+
Writers (all best-effort, never block):
|
|
17
|
+
escalation_tracker.py fail --source verify|bash|stop --detail "<text>"
|
|
18
|
+
escalation_tracker.py pass --source verify|bash|stop
|
|
19
|
+
escalation_tracker.py edit --file <path> (the gate records this itself)
|
|
20
|
+
escalation_tracker.py reset (after a deliver run lands)
|
|
21
|
+
escalation_tracker.py status [--json]
|
|
22
|
+
escalation_tracker.py classify-bash --command "<cmd>" --output "<text>"
|
|
23
|
+
-> prints pass|fail|none for a build/test command's output, and records it
|
|
24
|
+
|
|
25
|
+
The project root is UAP_MAIN_ROOT (the checkout holding .uap/), else cwd.
|
|
26
|
+
"""
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import argparse
|
|
30
|
+
import json
|
|
31
|
+
import os
|
|
32
|
+
import re
|
|
33
|
+
import sys
|
|
34
|
+
import time
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
|
|
37
|
+
STATE_FILE = "escalation-state.json"
|
|
38
|
+
DETAIL_MAX = 1200
|
|
39
|
+
|
|
40
|
+
# A shell command that exercises the project's real gates. Only these commands'
|
|
41
|
+
# outcomes count as verification evidence — `ls` failing is not a gate failure.
|
|
42
|
+
_BUILD_TEST_RE = re.compile(
|
|
43
|
+
r"(?:^|[;&|]\s*)(?:"
|
|
44
|
+
r"cargo\s+(?:build|check|test|clippy)"
|
|
45
|
+
r"|npm\s+(?:run\s+)?(?:build|test|lint|typecheck|check)"
|
|
46
|
+
r"|(?:pnpm|yarn|bun)\s+(?:run\s+)?(?:build|test|lint|typecheck|check)"
|
|
47
|
+
r"|(?:npx\s+)?(?:tsc|vitest|jest|mocha|eslint)\b"
|
|
48
|
+
r"|(?:python3?\s+-m\s+)?pytest\b"
|
|
49
|
+
r"|go\s+(?:build|test|vet)"
|
|
50
|
+
r"|make(?:\s+(?:test|build|check|all))?\s*(?:$|[;&|])"
|
|
51
|
+
r"|mvn\s+(?:test|package|verify|compile)"
|
|
52
|
+
r"|gradle(?:w)?\s+(?:test|build|check)"
|
|
53
|
+
r"|dotnet\s+(?:build|test)"
|
|
54
|
+
r"|uap\s+verify\b"
|
|
55
|
+
r")",
|
|
56
|
+
re.IGNORECASE,
|
|
57
|
+
)
|
|
58
|
+
_FAIL_MARKERS = (
|
|
59
|
+
"error[e", # rustc
|
|
60
|
+
"could not compile",
|
|
61
|
+
"test result: failed",
|
|
62
|
+
"failed to compile",
|
|
63
|
+
"npm err!",
|
|
64
|
+
"error ts", # tsc: error TS2345
|
|
65
|
+
"assertionerror",
|
|
66
|
+
"traceback (most recent call last)",
|
|
67
|
+
"build failed",
|
|
68
|
+
"fatal error",
|
|
69
|
+
"compilation failed",
|
|
70
|
+
"panicked at",
|
|
71
|
+
"exit status 1",
|
|
72
|
+
"exit code 1",
|
|
73
|
+
)
|
|
74
|
+
_PASS_MARKERS = (
|
|
75
|
+
"test result: ok",
|
|
76
|
+
"finished `dev`", "finished `release`", "finished dev", "finished release",
|
|
77
|
+
"tests passed", "passed", "all tests passed",
|
|
78
|
+
"compiled successfully", "build succeeded", "build successful",
|
|
79
|
+
"0 errors", "no errors", "ok\n", " ok ",
|
|
80
|
+
"runtime gate: pass", "all gates pass", "verify: pass",
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def root_dir() -> Path:
|
|
85
|
+
"""The MAIN checkout's root — the same anchor the policy gate uses.
|
|
86
|
+
|
|
87
|
+
The gate resolves MAIN_ROOT by stripping `/.worktrees/NNN-slug` and points
|
|
88
|
+
the enforcer there, so evidence written from a session that runs INSIDE a
|
|
89
|
+
worktree must land in the same place or the trigger never fires."""
|
|
90
|
+
p = str(Path(os.environ.get("UAP_MAIN_ROOT") or ".").resolve())
|
|
91
|
+
if "/.worktrees/" in p:
|
|
92
|
+
p = p.split("/.worktrees/", 1)[0]
|
|
93
|
+
return Path(p)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def state_path(root: Path | None = None) -> Path:
|
|
97
|
+
return (root or root_dir()) / ".uap" / STATE_FILE
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def load_state(root: Path | None = None) -> dict:
|
|
101
|
+
try:
|
|
102
|
+
data = json.loads(state_path(root).read_text(encoding="utf-8"))
|
|
103
|
+
if isinstance(data, dict):
|
|
104
|
+
data.setdefault("failures", 0)
|
|
105
|
+
data.setdefault("edits_since_green", {})
|
|
106
|
+
return data
|
|
107
|
+
except Exception:
|
|
108
|
+
pass
|
|
109
|
+
return {"failures": 0, "edits_since_green": {}, "last_failure": None,
|
|
110
|
+
"last_green": None, "updated": 0}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def save_state(state: dict, root: Path | None = None) -> None:
|
|
114
|
+
p = state_path(root)
|
|
115
|
+
try:
|
|
116
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
117
|
+
state["updated"] = int(time.time())
|
|
118
|
+
tmp = p.with_suffix(".tmp")
|
|
119
|
+
tmp.write_text(json.dumps(state, indent=1), encoding="utf-8")
|
|
120
|
+
os.replace(tmp, p)
|
|
121
|
+
except Exception:
|
|
122
|
+
pass # evidence is best-effort; never wedge the hook that records it
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def record_fail(source: str, detail: str, root: Path | None = None) -> dict:
|
|
126
|
+
st = load_state(root)
|
|
127
|
+
st["failures"] = int(st.get("failures") or 0) + 1
|
|
128
|
+
st["last_failure"] = {
|
|
129
|
+
"ts": int(time.time()),
|
|
130
|
+
"source": source or "unknown",
|
|
131
|
+
"detail": (detail or "")[-DETAIL_MAX:],
|
|
132
|
+
}
|
|
133
|
+
save_state(st, root)
|
|
134
|
+
return st
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def record_pass(source: str, root: Path | None = None) -> dict:
|
|
138
|
+
st = load_state(root)
|
|
139
|
+
st["failures"] = 0
|
|
140
|
+
st["edits_since_green"] = {}
|
|
141
|
+
st["last_green"] = {"ts": int(time.time()), "source": source or "unknown"}
|
|
142
|
+
st["last_failure"] = None
|
|
143
|
+
save_state(st, root)
|
|
144
|
+
return st
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def record_edit(rel: str, root: Path | None = None) -> dict:
|
|
148
|
+
st = load_state(root)
|
|
149
|
+
edits = st.get("edits_since_green") or {}
|
|
150
|
+
edits[rel] = int(edits.get(rel, 0)) + 1
|
|
151
|
+
st["edits_since_green"] = edits
|
|
152
|
+
save_state(st, root)
|
|
153
|
+
return st
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def reset(root: Path | None = None) -> dict:
|
|
157
|
+
st = {"failures": 0, "edits_since_green": {}, "last_failure": None,
|
|
158
|
+
"last_green": {"ts": int(time.time()), "source": "reset"}, "updated": 0}
|
|
159
|
+
save_state(st, root)
|
|
160
|
+
return st
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def is_gate_command(command: str) -> bool:
|
|
164
|
+
return bool(command) and bool(_BUILD_TEST_RE.search(command))
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def classify_output(command: str, output: str, exit_code: int | None = None) -> str:
|
|
168
|
+
"""pass | fail | none for a shell command's outcome.
|
|
169
|
+
|
|
170
|
+
The exit code is authoritative when the harness supplies one. Without it
|
|
171
|
+
(opencode's after-hook exposes text only) fall back to markers, and to
|
|
172
|
+
`none` when the text says nothing either way — silence is not evidence.
|
|
173
|
+
"""
|
|
174
|
+
if not is_gate_command(command):
|
|
175
|
+
return "none"
|
|
176
|
+
if exit_code is not None:
|
|
177
|
+
return "pass" if exit_code == 0 else "fail"
|
|
178
|
+
low = (output or "").lower()
|
|
179
|
+
if not low.strip():
|
|
180
|
+
return "none"
|
|
181
|
+
# A non-zero failure COUNT ("2 failed", "1 failures", "3 errors") or a
|
|
182
|
+
# specific failure marker. A bare "failed" substring is deliberately NOT a
|
|
183
|
+
# marker: `test_failed_login PASSED` and `--- PASS: TestFailedAuth` are green.
|
|
184
|
+
if any(m in low for m in _FAIL_MARKERS) or re.search(r"\b[1-9]\d* (?:failed|failures?|errors?)\b", low):
|
|
185
|
+
return "fail"
|
|
186
|
+
if any(m in low for m in _PASS_MARKERS) or re.search(r"(?m)^ok\b|--- pass:|\b\d+ passed\b", low):
|
|
187
|
+
return "pass"
|
|
188
|
+
return "none"
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def main(argv: list[str] | None = None) -> int:
|
|
192
|
+
ap = argparse.ArgumentParser(prog="escalation_tracker.py")
|
|
193
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
194
|
+
f = sub.add_parser("fail"); f.add_argument("--source", default="verify"); f.add_argument("--detail", default="")
|
|
195
|
+
p = sub.add_parser("pass"); p.add_argument("--source", default="verify"); p.add_argument("--detail", default="")
|
|
196
|
+
e = sub.add_parser("edit"); e.add_argument("--file", required=True)
|
|
197
|
+
sub.add_parser("reset")
|
|
198
|
+
s = sub.add_parser("status"); s.add_argument("--json", action="store_true")
|
|
199
|
+
c = sub.add_parser("classify-bash")
|
|
200
|
+
c.add_argument("--command", required=True); c.add_argument("--output", default="")
|
|
201
|
+
c.add_argument("--exit-code", type=int, default=None); c.add_argument("--dry-run", action="store_true")
|
|
202
|
+
raw = list(sys.argv[1:] if argv is None else argv)
|
|
203
|
+
folded: list[str] = []
|
|
204
|
+
k = 0
|
|
205
|
+
while k < len(raw):
|
|
206
|
+
tok = raw[k]
|
|
207
|
+
if tok in ("--detail", "--output", "--command") and k + 1 < len(raw):
|
|
208
|
+
folded.append(f"{tok}={raw[k + 1]}")
|
|
209
|
+
k += 2
|
|
210
|
+
continue
|
|
211
|
+
folded.append(tok)
|
|
212
|
+
k += 1
|
|
213
|
+
a = ap.parse_args(folded)
|
|
214
|
+
|
|
215
|
+
if a.cmd == "fail":
|
|
216
|
+
detail = a.detail
|
|
217
|
+
if detail == "-":
|
|
218
|
+
detail = sys.stdin.read()
|
|
219
|
+
st = record_fail(a.source, detail)
|
|
220
|
+
print(f"escalation: {st['failures']} consecutive failure(s) recorded ({a.source})")
|
|
221
|
+
elif a.cmd == "pass":
|
|
222
|
+
record_pass(a.source)
|
|
223
|
+
print(f"escalation: green ({a.source}) — counters reset")
|
|
224
|
+
elif a.cmd == "edit":
|
|
225
|
+
record_edit(a.file)
|
|
226
|
+
elif a.cmd == "reset":
|
|
227
|
+
reset()
|
|
228
|
+
print("escalation: state reset")
|
|
229
|
+
elif a.cmd == "status":
|
|
230
|
+
st = load_state()
|
|
231
|
+
if a.json:
|
|
232
|
+
print(json.dumps(st))
|
|
233
|
+
else:
|
|
234
|
+
lf = st.get("last_failure") or {}
|
|
235
|
+
print(f"failures={st.get('failures', 0)} files_churning={len(st.get('edits_since_green') or {})}"
|
|
236
|
+
+ (f" last_failure=({lf.get('source')}) {str(lf.get('detail',''))[:120]!r}" if lf else ""))
|
|
237
|
+
elif a.cmd == "classify-bash":
|
|
238
|
+
output = a.output
|
|
239
|
+
if output == "-":
|
|
240
|
+
output = sys.stdin.read()
|
|
241
|
+
verdict = classify_output(a.command, output, a.exit_code)
|
|
242
|
+
if not a.dry_run:
|
|
243
|
+
if verdict == "fail":
|
|
244
|
+
record_fail("bash", f"$ {a.command[:200]}\n{output[-DETAIL_MAX:]}")
|
|
245
|
+
elif verdict == "pass":
|
|
246
|
+
record_pass("bash")
|
|
247
|
+
print(verdict)
|
|
248
|
+
return 0
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
if __name__ == "__main__":
|
|
252
|
+
sys.exit(main())
|
package/templates/hooks/stop.sh
CHANGED
|
@@ -221,6 +221,16 @@ if [ "$CODE_CHANGED" = "true" ] && [ "${UAP_VERIFY_ON_STOP:-1}" != "0" ] && comm
|
|
|
221
221
|
fi
|
|
222
222
|
VERIFY_OUT="$(cd "$PROJECT_DIR" && $TIMEOUT_WRAP uap verify --runtime-only $UV_AUTO $ACC_AUTO --dir "$PROJECT_DIR" 2>&1)"
|
|
223
223
|
VERIFY_RC=$?
|
|
224
|
+
# Evidence for the `escalate` delivery mode: a red verify counts toward the
|
|
225
|
+
# "escalate to deliver" threshold, a green one clears it. Best-effort.
|
|
226
|
+
TRACKER="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/escalation_tracker.py"
|
|
227
|
+
if [ -f "$TRACKER" ]; then
|
|
228
|
+
if [ "$VERIFY_RC" = "1" ]; then
|
|
229
|
+
(cd "$PROJECT_DIR" && UAP_MAIN_ROOT="$PROJECT_DIR" python3 "$TRACKER" fail --source stop --detail "$(printf '%s' "$VERIFY_OUT" | tail -c 1200)" >/dev/null 2>&1) || true
|
|
230
|
+
elif [ "$VERIFY_RC" = "0" ]; then
|
|
231
|
+
(cd "$PROJECT_DIR" && UAP_MAIN_ROOT="$PROJECT_DIR" python3 "$TRACKER" pass --source stop >/dev/null 2>&1) || true
|
|
232
|
+
fi
|
|
233
|
+
fi
|
|
224
234
|
fi
|
|
225
235
|
set -e
|
|
226
236
|
# RC 1 = a REAL gate failure (code is broken) → block. Everything else fails
|
|
@@ -71,7 +71,7 @@ export UAP_WORKTREE_ROOT="$CHECKOUT_ROOT"
|
|
|
71
71
|
# in .bashrc) can NOT silently downgrade a project configured to `block`
|
|
72
72
|
# and let the agent bypass deliver. `delivery.localMode` is read the same
|
|
73
73
|
# authoritative way and selects how a local-model session resolves block
|
|
74
|
-
# (advisory|deliver|block — see delivery_enforcement.py).
|
|
74
|
+
# (advisory|deliver|block|escalate — see delivery_enforcement.py).
|
|
75
75
|
# 2. ambient UAP_ENFORCE_DELIVERY (operator/CI per-run override) when the
|
|
76
76
|
# project declares nothing.
|
|
77
77
|
# 3. default: block.
|
|
@@ -83,7 +83,7 @@ import json, sys
|
|
|
83
83
|
try:
|
|
84
84
|
d = (json.load(open(sys.argv[1])).get("delivery") or {})
|
|
85
85
|
e = str(d.get("enforcement", "")).lower()
|
|
86
|
-
print("%s|%s" % (e if e in ("block", "advisory", "off") else "", str(d.get("localMode", "")).lower()))
|
|
86
|
+
print("%s|%s" % (e if e in ("block", "advisory", "off", "escalate") else "", str(d.get("localMode", "")).lower()))
|
|
87
87
|
except Exception:
|
|
88
88
|
print("|")
|
|
89
89
|
' "$MAIN_ROOT/.uap.json" 2>/dev/null || echo "|")"
|
|
@@ -168,7 +168,7 @@ if not hit and cmd:
|
|
|
168
168
|
if hit:
|
|
169
169
|
break
|
|
170
170
|
bypass = re.search(
|
|
171
|
-
r"UAP_DELIVER_BYPASS\s*=\s*[\x27\"]?1|UAP_ENFORCE_DELIVERY\s*=\s*[\x27\"]?(advisory|off|0|false|no)"
|
|
171
|
+
r"UAP_DELIVER_BYPASS\s*=\s*[\x27\"]?1|UAP_ENFORCE_DELIVERY\s*=\s*[\x27\"]?(advisory|off|escalate|0|false|no)"
|
|
172
172
|
r"|UAP_SELF_PROTECT_OFF\s*=\s*[\x27\"]?1|UAP_NO_WORKTREE\s*=\s*[\x27\"]?1"
|
|
173
173
|
r"|UAP_WORKDIR_SCOPE_OFF\s*=\s*[\x27\"]?1|UAP_USER_VALIDATION\s*=\s*[\x27\"]?0"
|
|
174
174
|
r"|UAP_DELIVER_NO_LOCK\s*=\s*[\x27\"]?1|UAP_NO_REVIEW\s*=\s*[\x27\"]?1"
|
|
@@ -293,7 +293,7 @@ record_execution() {
|
|
|
293
293
|
local esc_args esc_reason
|
|
294
294
|
esc_args="$(printf '%s' "$ARGS" | sed "s/'/''/g")"
|
|
295
295
|
esc_reason="$(printf '%s' "$reason" | sed "s/'/''/g" | cut -c1-500)"
|
|
296
|
-
sqlite3 "$DB" "INSERT INTO policy_executions
|
|
296
|
+
sqlite3 -cmd ".timeout 5000" "$DB" "INSERT INTO policy_executions
|
|
297
297
|
(policyId, toolName, operation, args, result, allowed, reason, executedAt)
|
|
298
298
|
VALUES ('$pid', '${TOOL//\'/\'\'}', '${TOOL//\'/\'\'}', '$esc_args',
|
|
299
299
|
'{}', $allowed, '$esc_reason', strftime('%Y-%m-%dT%H:%M:%fZ','now'));
|
|
@@ -463,7 +463,7 @@ except: print("")' 2>/dev/null || echo "")"
|
|
|
463
463
|
# first, and the agent saw the wrong routing message. Honor priority now so
|
|
464
464
|
# the intended policy fires first. (Blocking short-circuits at exit 2, so the
|
|
465
465
|
# highest-priority applicable block wins.) Name is the stable tiebreak.
|
|
466
|
-
done < <(sqlite3 "$DB" "SELECT p.id, p.name, t.toolName FROM policies p JOIN executable_tools t ON t.policyId=p.id WHERE p.isActive=1 ORDER BY p.priority DESC, p.name ASC;")
|
|
466
|
+
done < <(sqlite3 -cmd ".timeout 5000" "$DB" "SELECT p.id, p.name, t.toolName FROM policies p JOIN executable_tools t ON t.policyId=p.id WHERE p.isActive=1 ORDER BY p.priority DESC, p.name ASC;")
|
|
467
467
|
|
|
468
468
|
# A sensitive op that no self-protect enforcer ever evaluated = the control
|
|
469
469
|
# surface is unguarded (self-protect not registered/active). Fail closed.
|
|
Binary file
|
|
@@ -1945,6 +1945,96 @@ def _apply_profile_overrides(
|
|
|
1945
1945
|
return updated, prompt_suffix, grammar_text
|
|
1946
1946
|
|
|
1947
1947
|
|
|
1948
|
+
# ── Upstream-safe tool names ────────────────────────────────────────────────
|
|
1949
|
+
# llama.cpp's grammar builder cannot parse a tool NAME containing '-' or '.'
|
|
1950
|
+
# under tool_choice=required: the request fails with HTTP 400 "Failed to
|
|
1951
|
+
# initialize samplers: failed to parse grammar" (measured 2026-08-21 against
|
|
1952
|
+
# the live server: `uap-router_deliver` -> 400, the same schema as
|
|
1953
|
+
# `uap_router_deliver` -> 200; `auto` passes either way because the lazy
|
|
1954
|
+
# grammar path never compiles the name). OpenCode prefixes every MCP tool
|
|
1955
|
+
# with "<server>_" and servers are named with hyphens, so the moment a rail
|
|
1956
|
+
# forces `required` with such a tool present, the turn dies and the client
|
|
1957
|
+
# session wedges (observed: RECON hard tier restored `uap-router_deliver`,
|
|
1958
|
+
# forced required, 400, stream error, 10h idle).
|
|
1959
|
+
#
|
|
1960
|
+
# Names are rewritten to [A-Za-z0-9_] on the way UP (tool definitions, the
|
|
1961
|
+
# assistant history's tool_calls, a function-pinned tool_choice) and restored
|
|
1962
|
+
# on the way DOWN, so the client never sees the sanitized form. The map is
|
|
1963
|
+
# module-level: sanitization is a pure function of the name, so one map serves
|
|
1964
|
+
# every session and every response path (stream, strict, non-stream).
|
|
1965
|
+
_UPSTREAM_TOOL_NAME_BAD_RE = re.compile(r"[^A-Za-z0-9_]")
|
|
1966
|
+
_UPSTREAM_TOOL_NAME_MAP: dict[str, str] = {} # sanitized -> original
|
|
1967
|
+
|
|
1968
|
+
|
|
1969
|
+
def _sanitize_tool_name(name: str) -> str:
|
|
1970
|
+
return _UPSTREAM_TOOL_NAME_BAD_RE.sub("_", name or "")
|
|
1971
|
+
|
|
1972
|
+
|
|
1973
|
+
def _sanitize_tool_names_for_upstream(openai_body: dict) -> int:
|
|
1974
|
+
"""Rewrite unsafe tool names in-place; returns how many were renamed.
|
|
1975
|
+
|
|
1976
|
+
Idempotent: already-safe names are untouched, so calling it on a retry is
|
|
1977
|
+
harmless. A rename that would collide with another tool's (native or
|
|
1978
|
+
sanitized) name gets a numeric suffix.
|
|
1979
|
+
"""
|
|
1980
|
+
tools = openai_body.get("tools") or []
|
|
1981
|
+
originals = {
|
|
1982
|
+
((t.get("function") or {}).get("name") or "")
|
|
1983
|
+
for t in tools
|
|
1984
|
+
if isinstance(t, dict) and isinstance(t.get("function"), dict)
|
|
1985
|
+
}
|
|
1986
|
+
renamed: dict[str, str] = {} # original -> safe
|
|
1987
|
+
taken: set[str] = set()
|
|
1988
|
+
for t in tools:
|
|
1989
|
+
fn = t.get("function") if isinstance(t, dict) else None
|
|
1990
|
+
if not isinstance(fn, dict):
|
|
1991
|
+
continue
|
|
1992
|
+
name = fn.get("name") or ""
|
|
1993
|
+
safe = _sanitize_tool_name(name)
|
|
1994
|
+
if safe == name:
|
|
1995
|
+
taken.add(name)
|
|
1996
|
+
# A tool that NATIVELY owns this spelling must never be restored
|
|
1997
|
+
# to some other client's hyphenated name left in the global map.
|
|
1998
|
+
_UPSTREAM_TOOL_NAME_MAP.pop(name, None)
|
|
1999
|
+
continue
|
|
2000
|
+
base, i = safe, 2
|
|
2001
|
+
while safe in taken or (safe in originals and safe != name):
|
|
2002
|
+
safe = f"{base}_{i}"
|
|
2003
|
+
i += 1
|
|
2004
|
+
fn["name"] = safe
|
|
2005
|
+
taken.add(safe)
|
|
2006
|
+
renamed[name] = safe
|
|
2007
|
+
_UPSTREAM_TOOL_NAME_MAP[safe] = name
|
|
2008
|
+
if not renamed:
|
|
2009
|
+
return 0
|
|
2010
|
+
# The model must see ONE name per tool: history tool_calls that carry the
|
|
2011
|
+
# original spelling are rewritten too, as is a function-pinned tool_choice.
|
|
2012
|
+
for m in openai_body.get("messages") or []:
|
|
2013
|
+
if not isinstance(m, dict):
|
|
2014
|
+
continue
|
|
2015
|
+
for tc in m.get("tool_calls") or []:
|
|
2016
|
+
fn = tc.get("function") if isinstance(tc, dict) else None
|
|
2017
|
+
if isinstance(fn, dict) and fn.get("name") in renamed:
|
|
2018
|
+
fn["name"] = renamed[fn["name"]]
|
|
2019
|
+
choice = openai_body.get("tool_choice")
|
|
2020
|
+
if isinstance(choice, dict):
|
|
2021
|
+
fn = choice.get("function")
|
|
2022
|
+
if isinstance(fn, dict) and fn.get("name") in renamed:
|
|
2023
|
+
fn["name"] = renamed[fn["name"]]
|
|
2024
|
+
logger.info(
|
|
2025
|
+
"TOOL NAMES: sanitized %d upstream-unsafe tool name(s) for llama.cpp grammar: %s",
|
|
2026
|
+
len(renamed), ", ".join(f"{k}->{v}" for k, v in list(renamed.items())[:6]),
|
|
2027
|
+
)
|
|
2028
|
+
return len(renamed)
|
|
2029
|
+
|
|
2030
|
+
|
|
2031
|
+
def _restore_tool_name(name: str) -> str:
|
|
2032
|
+
"""The client-facing name for a tool the upstream called by sanitized name."""
|
|
2033
|
+
if not name or not _UPSTREAM_TOOL_NAME_MAP:
|
|
2034
|
+
return name
|
|
2035
|
+
return _UPSTREAM_TOOL_NAME_MAP.get(name, name)
|
|
2036
|
+
|
|
2037
|
+
|
|
1948
2038
|
def _is_grammar_tools_incompatibility(status_code: int, error_text: str) -> bool:
|
|
1949
2039
|
if status_code != 400:
|
|
1950
2040
|
return False
|
|
@@ -1963,9 +2053,15 @@ def _is_gemma4_peg_parse_failure(status_code: int, error_text: str) -> bool:
|
|
|
1963
2053
|
tool_choice='auto' so the model can emit prose or a complete call
|
|
1964
2054
|
without grammar enforcement triggering this failure mode.
|
|
1965
2055
|
"""
|
|
2056
|
+
text = error_text or ""
|
|
2057
|
+
# Same remedy for the other grammar-compile failure: HTTP 400 "Failed to
|
|
2058
|
+
# initialize samplers: failed to parse grammar" (a tool schema/name the
|
|
2059
|
+
# builder cannot express under `required`). Relaxing to `auto` lets the
|
|
2060
|
+
# turn proceed on the lazy grammar path instead of killing the stream.
|
|
2061
|
+
if status_code == 400 and "failed to parse grammar" in text.lower():
|
|
2062
|
+
return True
|
|
1966
2063
|
if status_code != 500:
|
|
1967
2064
|
return False
|
|
1968
|
-
text = error_text or ""
|
|
1969
2065
|
return (
|
|
1970
2066
|
"Failed to parse input at pos" in text
|
|
1971
2067
|
or "<|tool_call>call:" in text
|
|
@@ -8844,6 +8940,12 @@ def _tool_schema_map_from_anthropic_body(anthropic_body: dict) -> dict[str, dict
|
|
|
8844
8940
|
if isinstance(name, str) and name:
|
|
8845
8941
|
schema = tool.get("input_schema")
|
|
8846
8942
|
schema_map[name] = schema if isinstance(schema, dict) else {}
|
|
8943
|
+
# The upstream calls tools by their SANITIZED name (see
|
|
8944
|
+
# _sanitize_tool_names_for_upstream); a guardrail that only knew the
|
|
8945
|
+
# client's spelling would reject every such call as "unknown tool".
|
|
8946
|
+
safe = _sanitize_tool_name(name)
|
|
8947
|
+
if safe != name and safe not in schema_map:
|
|
8948
|
+
schema_map[safe] = schema_map[name]
|
|
8847
8949
|
return schema_map
|
|
8848
8950
|
|
|
8849
8951
|
|
|
@@ -9317,6 +9419,9 @@ def _anthropic_tools_by_name(anthropic_body: dict) -> dict[str, dict]:
|
|
|
9317
9419
|
else {}
|
|
9318
9420
|
)
|
|
9319
9421
|
tool_map[name] = schema or {}
|
|
9422
|
+
safe = _sanitize_tool_name(name) # upstream spelling, see schema map above
|
|
9423
|
+
if safe != name and safe not in tool_map:
|
|
9424
|
+
tool_map[safe] = tool_map[name]
|
|
9320
9425
|
return tool_map
|
|
9321
9426
|
|
|
9322
9427
|
|
|
@@ -11202,7 +11307,7 @@ def openai_to_anthropic_response(
|
|
|
11202
11307
|
{
|
|
11203
11308
|
"type": "tool_use",
|
|
11204
11309
|
"id": tool_use_id,
|
|
11205
|
-
"name": fn.get("name", ""),
|
|
11310
|
+
"name": _restore_tool_name(fn.get("name", "")),
|
|
11206
11311
|
"input": args,
|
|
11207
11312
|
}
|
|
11208
11313
|
)
|
|
@@ -11561,7 +11666,7 @@ async def stream_anthropic_response(
|
|
|
11561
11666
|
initial_args = fn.get("arguments", "")
|
|
11562
11667
|
tool_calls_by_index[tc_idx] = {
|
|
11563
11668
|
"id": tc_id,
|
|
11564
|
-
"name": fn.get("name", ""),
|
|
11669
|
+
"name": _restore_tool_name(fn.get("name", "")),
|
|
11565
11670
|
"arguments": initial_args,
|
|
11566
11671
|
"block_index": tool_block_index,
|
|
11567
11672
|
}
|
|
@@ -11749,7 +11854,7 @@ async def stream_anthropic_response(
|
|
|
11749
11854
|
for idx, xtc in enumerate(xml_extracted, start=1):
|
|
11750
11855
|
fn = xtc.get("function", {})
|
|
11751
11856
|
tc_id = xtc.get("id", f"toolu_{uuid.uuid4().hex[:12]}")
|
|
11752
|
-
tc_name = fn.get("name", "")
|
|
11857
|
+
tc_name = _restore_tool_name(fn.get("name", ""))
|
|
11753
11858
|
tc_args = fn.get("arguments", "{}")
|
|
11754
11859
|
tool_calls_by_index[idx] = {
|
|
11755
11860
|
"id": tc_id,
|
|
@@ -12380,6 +12485,9 @@ async def messages(request: Request):
|
|
|
12380
12485
|
media_type="application/json",
|
|
12381
12486
|
)
|
|
12382
12487
|
|
|
12488
|
+
# Last step before the wire: names llama.cpp's grammar builder can parse.
|
|
12489
|
+
_sanitize_tool_names_for_upstream(openai_body)
|
|
12490
|
+
|
|
12383
12491
|
use_guarded_non_stream = _should_use_guarded_non_stream(
|
|
12384
12492
|
is_stream,
|
|
12385
12493
|
body,
|