@miller-tech/uap 1.61.1 → 1.61.3

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.
@@ -20,20 +20,13 @@ export interface NormalizeResult {
20
20
  /** Why it was changed, or null if unchanged. */
21
21
  reason: string | null;
22
22
  }
23
- export interface NormalizeOptions {
24
- /**
25
- * Max Levenshtein distance (on the basename) for the typo fallback to fire.
26
- * Defaults to max(2, 30% of basename length).
27
- */
28
- maxEditDistance?: number;
29
- }
30
23
  /**
31
24
  * Snap `proposed` to the nearest real file in `workdirFiles` (paths relative to
32
25
  * the workdir). Returns `proposed` unchanged if it already resolves or no
33
26
  * confident match exists — the normalizer never invents a target, so a genuinely
34
27
  * new file (a legitimate create) is left alone.
35
28
  */
36
- export declare function normalizeToolPath(proposed: string, workdirFiles: string[], opts?: NormalizeOptions): NormalizeResult;
29
+ export declare function normalizeToolPath(proposed: string, workdirFiles: string[]): NormalizeResult;
37
30
  /** Tool-call argument keys that carry a file path, by Claude Code tool name. */
38
31
  export declare const PATH_ARG_KEYS: readonly ["file_path", "path", "filePath", "notebook_path"];
39
32
  /**
@@ -41,7 +34,7 @@ export declare const PATH_ARG_KEYS: readonly ["file_path", "path", "filePath", "
41
34
  * Returns the corrections made (empty when nothing changed). Pure w.r.t. the
42
35
  * filesystem — `workdirFiles` is supplied by the caller (the hook).
43
36
  */
44
- export declare function normalizeToolInput(input: Record<string, unknown>, workdirFiles: string[], opts?: NormalizeOptions): {
37
+ export declare function normalizeToolInput(input: Record<string, unknown>, workdirFiles: string[]): {
45
38
  input: Record<string, unknown>;
46
39
  corrections: {
47
40
  key: string;
@@ -1 +1 @@
1
- {"version":3,"file":"path-normalizer.d.ts","sourceRoot":"","sources":["../../../src/self-harness/middleware/path-normalizer.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,MAAM,WAAW,eAAe;IAC9B,8CAA8C;IAC9C,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,OAAO,CAAC;IACjB,gDAAgD;IAChD,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;CACvB;AAED,MAAM,WAAW,gBAAgB;IAC/B;;;OAGG;IACH,eAAe,CAAC,EAAE,MAAM,CAAC;CAC1B;AAkDD;;;;;GAKG;AACH,wBAAgB,iBAAiB,CAC/B,QAAQ,EAAE,MAAM,EAChB,YAAY,EAAE,MAAM,EAAE,EACtB,IAAI,GAAE,gBAAqB,GAC1B,eAAe,CAmDjB;AAED,gFAAgF;AAChF,eAAO,MAAM,aAAa,6DAA8D,CAAC;AAEzF;;;;GAIG;AACH,wBAAgB,kBAAkB,CAChC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC9B,YAAY,EAAE,MAAM,EAAE,EACtB,IAAI,GAAE,gBAAqB,GAC1B;IAAE,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAAC,WAAW,EAAE;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,EAAE,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,EAAE,CAAA;CAAE,CAa9G"}
1
+ {"version":3,"file":"path-normalizer.d.ts","sourceRoot":"","sources":["../../../src/self-harness/middleware/path-normalizer.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,MAAM,WAAW,eAAe;IAC9B,8CAA8C;IAC9C,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,OAAO,CAAC;IACjB,gDAAgD;IAChD,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;CACvB;AAkCD;;;;;GAKG;AACH,wBAAgB,iBAAiB,CAC/B,QAAQ,EAAE,MAAM,EAChB,YAAY,EAAE,MAAM,EAAE,GACrB,eAAe,CAwCjB;AAED,gFAAgF;AAChF,eAAO,MAAM,aAAa,6DAA8D,CAAC;AAEzF;;;;GAIG;AACH,wBAAgB,kBAAkB,CAChC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC9B,YAAY,EAAE,MAAM,EAAE,GACrB;IAAE,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAAC,WAAW,EAAE;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,EAAE,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,EAAE,CAAA;CAAE,CAa9G"}
@@ -36,30 +36,11 @@ function parentDir(p) {
36
36
  function dirCompatible(proposed, candidate) {
37
37
  const cp = parentDir(candidate);
38
38
  if (cp === '')
39
- return true; // bare known path — safe to snap to
40
- return squash(parentDir(proposed)) === squash(cp);
41
- }
42
- function editDistance(a, b) {
43
- const m = a.length;
44
- const n = b.length;
45
- if (m === 0)
46
- return n;
47
- if (n === 0)
48
- return m;
49
- const prev = new Array(n + 1);
50
- const cur = new Array(n + 1);
51
- for (let j = 0; j <= n; j++)
52
- prev[j] = j;
53
- for (let i = 1; i <= m; i++) {
54
- cur[0] = i;
55
- for (let j = 1; j <= n; j++) {
56
- const cost = a[i - 1] === b[j - 1] ? 0 : 1;
57
- cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + cost);
58
- }
59
- for (let j = 0; j <= n; j++)
60
- prev[j] = cur[j];
61
- }
62
- return prev[n];
39
+ return true; // bare workdir-relative file — safe to snap to
40
+ // EXACT parent match only. A previous version compared squash(parent), which
41
+ // collapsed punctuation/case and let writes relocate between genuinely
42
+ // different directories (e.g. `s-space-shooter` vs `s space-shooter`).
43
+ return parentDir(proposed) === cp;
63
44
  }
64
45
  /**
65
46
  * Snap `proposed` to the nearest real file in `workdirFiles` (paths relative to
@@ -67,7 +48,7 @@ function editDistance(a, b) {
67
48
  * confident match exists — the normalizer never invents a target, so a genuinely
68
49
  * new file (a legitimate create) is left alone.
69
50
  */
70
- export function normalizeToolPath(proposed, workdirFiles, opts = {}) {
51
+ export function normalizeToolPath(proposed, workdirFiles) {
71
52
  const unchanged = { path: proposed, changed: false, reason: null };
72
53
  if (!proposed || workdirFiles.length === 0)
73
54
  return unchanged;
@@ -98,17 +79,9 @@ export function normalizeToolPath(proposed, workdirFiles, opts = {}) {
98
79
  hit = unique(safe(workdirFiles.filter((f) => squash(basename(f)) === sb)));
99
80
  if (hit)
100
81
  return snap(hit, 'extension/punctuation-normalized to the real filename');
101
- // 4) Edit-distance fallback for genuine typos. Fire only when the best match
102
- // is meaningfully better than the runner-up (avoids ambiguous snaps).
103
- const maxD = opts.maxEditDistance ?? Math.max(2, Math.floor(base.length * 0.3));
104
- const ranked = safe(workdirFiles)
105
- .map((f) => ({ f, d: editDistance(base.toLowerCase(), basename(f).toLowerCase()) }))
106
- .sort((x, y) => x.d - y.d);
107
- if (ranked.length > 0 &&
108
- ranked[0].d <= maxD &&
109
- (ranked.length < 2 || ranked[1].d > ranked[0].d)) {
110
- return snap(ranked[0].f, `nearest filename by edit distance (${ranked[0].d})`);
111
- }
82
+ // No edit-distance fallback: guessing the "nearest" filename produced wrong
83
+ // snaps (e.g. `oct` -> `octop`). A garble we cannot resolve by exact basename /
84
+ // case / punctuation match is left unchanged so it fails loud and self-corrects.
112
85
  return unchanged;
113
86
  }
114
87
  /** Tool-call argument keys that carry a file path, by Claude Code tool name. */
@@ -118,14 +91,14 @@ export const PATH_ARG_KEYS = ['file_path', 'path', 'filePath', 'notebook_path'];
118
91
  * Returns the corrections made (empty when nothing changed). Pure w.r.t. the
119
92
  * filesystem — `workdirFiles` is supplied by the caller (the hook).
120
93
  */
121
- export function normalizeToolInput(input, workdirFiles, opts = {}) {
94
+ export function normalizeToolInput(input, workdirFiles) {
122
95
  const corrections = [];
123
96
  const out = { ...input };
124
97
  for (const key of PATH_ARG_KEYS) {
125
98
  const v = out[key];
126
99
  if (typeof v !== 'string')
127
100
  continue;
128
- const r = normalizeToolPath(v, workdirFiles, opts);
101
+ const r = normalizeToolPath(v, workdirFiles);
129
102
  if (r.changed) {
130
103
  out[key] = r.path;
131
104
  corrections.push({ key, from: v, to: r.path, reason: r.reason ?? '' });
@@ -1 +1 @@
1
- {"version":3,"file":"path-normalizer.js","sourceRoot":"","sources":["../../../src/self-harness/middleware/path-normalizer.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"path-normalizer.js","sourceRoot":"","sources":["../../../src/self-harness/middleware/path-normalizer.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miller-tech/uap",
3
- "version": "1.61.1",
3
+ "version": "1.61.3",
4
4
  "description": "Autonomous AI agent memory system with CLAUDE.md protocol enforcement",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -945,6 +945,13 @@ class SessionMonitor:
945
945
  tool_cycle_counts: dict = field(default_factory=dict) # {tool_name: cycle_count} across resets
946
946
  last_response_garbled: bool = False # previous turn had garbled/malformed output
947
947
  finalize_turn_active: bool = False
948
+ # Set True for the single turn on which a hard finalize breaker (TURN-COUNT
949
+ # or CONTAMINATION LOOP) strips tools to force a terminal text-only end_turn.
950
+ # Suppresses response-side prose->tool_call resurrection so a contaminated
951
+ # model emitting `<function=...>`/`<tool_call>` prose does not get it promoted
952
+ # back into a structured tool_use, which would continue the very loop the
953
+ # breaker is ending. Reset to False at the start of every request.
954
+ suppress_text_tool_extraction: bool = False
948
955
  finalize_continuation_count: int = 0
949
956
  finalize_hard_stop_count: int = 0 # monotonic, not reset by fresh user text
950
957
  finalize_synthetic_tool_id: str = ""
@@ -4052,6 +4059,10 @@ def build_openai_request(
4052
4059
  })
4053
4060
  openai_body["messages"] = msgs
4054
4061
  monitor.reset_tool_turn_state(reason="turn_count_finalize_breaker")
4062
+ # Tools were stripped to force a terminal text summary; do not let
4063
+ # the response-side extractor resurrect prose tool-calls (which
4064
+ # would defeat the breaker and continue the loop).
4065
+ monitor.suppress_text_tool_extraction = True
4055
4066
  logger.warning(
4056
4067
  "TURN-COUNT FINALIZE BREAKER: %d agent tool turns >= ceiling %d "
4057
4068
  "-- stripped tools to force terminal summary (end_turn).",
@@ -7084,6 +7095,11 @@ def _maybe_apply_session_contamination_breaker(
7084
7095
  "Summarize what you have accomplished and what remains to be done."
7085
7096
  ),
7086
7097
  })
7098
+ # Suppress prose->tool_call resurrection on this turn: the model is
7099
+ # contaminated and will emit `<function=...>`/`<tool_call>` prose even
7100
+ # with tools removed; promoting it back to a structured tool_use would
7101
+ # continue the exact loop this finalize is meant to break.
7102
+ monitor.suppress_text_tool_extraction = True
7087
7103
  return updated
7088
7104
 
7089
7105
  messages = anthropic_body.get("messages", [])
@@ -7210,7 +7226,9 @@ def _maybe_apply_session_contamination_breaker(
7210
7226
 
7211
7227
 
7212
7228
  def _maybe_extract_text_tool_calls(
7213
- openai_resp: dict, anthropic_tools: list[dict] | None = None
7229
+ openai_resp: dict,
7230
+ anthropic_tools: list[dict] | None = None,
7231
+ suppress: bool = False,
7214
7232
  ) -> dict:
7215
7233
  """Mutate *openai_resp* in-place: if the message has no structured
7216
7234
  ``tool_calls`` but contains tool-call markup in text, extract them
@@ -7222,6 +7240,11 @@ def _maybe_extract_text_tool_calls(
7222
7240
  blocks pass through as text.
7223
7241
 
7224
7242
  Returns the (possibly-mutated) response for chaining."""
7243
+ # A hard finalize breaker stripped tools this turn to force a terminal
7244
+ # text-only end_turn; do not resurrect prose tool-calls (that would defeat
7245
+ # the breaker and continue the loop). Carried per-turn on the SessionMonitor.
7246
+ if suppress:
7247
+ return openai_resp
7225
7248
  choice = (openai_resp.get("choices") or [{}])[0]
7226
7249
  message = choice.get("message", {})
7227
7250
 
@@ -7427,7 +7450,10 @@ def _extract_thinking_block(text: str) -> tuple[str | None, str]:
7427
7450
 
7428
7451
 
7429
7452
  def openai_to_anthropic_response(
7430
- openai_resp: dict, model: str, expose_thinking: bool = True
7453
+ openai_resp: dict,
7454
+ model: str,
7455
+ expose_thinking: bool = True,
7456
+ suppress_text_tool_extraction: bool = False,
7431
7457
  ) -> dict:
7432
7458
  """Convert an OpenAI Chat Completions response to Anthropic Messages format.
7433
7459
 
@@ -7441,7 +7467,7 @@ def openai_to_anthropic_response(
7441
7467
  they're surfaced as Anthropic blocks or silently consumed.
7442
7468
  """
7443
7469
  # First: try to recover tool calls trapped in text XML tags
7444
- _maybe_extract_text_tool_calls(openai_resp)
7470
+ _maybe_extract_text_tool_calls(openai_resp, suppress=suppress_text_tool_extraction)
7445
7471
  # Second: strip garbled/degenerate tool call arguments
7446
7472
  _sanitize_garbled_tool_calls(openai_resp)
7447
7473
 
@@ -7837,7 +7863,11 @@ async def stream_anthropic_response(
7837
7863
  # -------------------------------------------------------------------
7838
7864
  # Post-stream: recover <tool_call> XML from accumulated text
7839
7865
  # -------------------------------------------------------------------
7840
- if not tool_calls_by_index and "<tool_call>" in accumulated_text:
7866
+ if (
7867
+ not tool_calls_by_index
7868
+ and "<tool_call>" in accumulated_text
7869
+ and not monitor.suppress_text_tool_extraction
7870
+ ):
7841
7871
  xml_extracted, remaining_text = _extract_tool_calls_from_text(accumulated_text)
7842
7872
  if xml_extracted:
7843
7873
  # We already streamed the text as-is. We cannot un-stream it,
@@ -8045,6 +8075,10 @@ async def messages(request: Request):
8045
8075
  return await _passthrough_anthropic_request(request, body, is_stream)
8046
8076
  session_id = resolve_session_id(request, body)
8047
8077
  monitor = get_session_monitor(session_id)
8078
+ # Per-turn flag: only the turn whose breaker strips tools suppresses the
8079
+ # response-side prose->tool_call resurrection. Clear it at request entry so a
8080
+ # prior finalize turn never bleeds into the next turn's normal extraction.
8081
+ monitor.suppress_text_tool_extraction = False
8048
8082
  last_session_id = session_id
8049
8083
  # Make the session id visible to _ensure_slot_for_session inside
8050
8084
  # _post_with_retry. The /v1/chat/completions handler also reaches this
@@ -8358,7 +8392,11 @@ async def messages(request: Request):
8358
8392
 
8359
8393
  openai_resp = strict_resp.json()
8360
8394
  # Recover tool calls from <tool_call> XML before guardrails run
8361
- _maybe_extract_text_tool_calls(openai_resp, anthropic_tools=body.get("tools"))
8395
+ _maybe_extract_text_tool_calls(
8396
+ openai_resp,
8397
+ anthropic_tools=body.get("tools"),
8398
+ suppress=monitor.suppress_text_tool_extraction,
8399
+ )
8362
8400
  openai_resp = await _apply_unexpected_end_turn_guardrail(
8363
8401
  client,
8364
8402
  openai_resp,
@@ -8417,6 +8455,7 @@ async def messages(request: Request):
8417
8455
  openai_resp, model,
8418
8456
  expose_thinking=isinstance(body.get("thinking"), dict)
8419
8457
  and (body["thinking"].get("type") or "").lower() == "enabled",
8458
+ suppress_text_tool_extraction=monitor.suppress_text_tool_extraction,
8420
8459
  )
8421
8460
  _maybe_normalize_toolcall_paths(anthropic_resp, body)
8422
8461
  # FINALIZE CONTINUATION: inject synthetic tool_use to keep client loop alive
@@ -8758,7 +8797,11 @@ async def messages(request: Request):
8758
8797
 
8759
8798
  openai_resp = resp.json()
8760
8799
  # Recover tool calls from <tool_call> XML before guardrails run
8761
- _maybe_extract_text_tool_calls(openai_resp, anthropic_tools=body.get("tools"))
8800
+ _maybe_extract_text_tool_calls(
8801
+ openai_resp,
8802
+ anthropic_tools=body.get("tools"),
8803
+ suppress=monitor.suppress_text_tool_extraction,
8804
+ )
8762
8805
  openai_resp = await _apply_unexpected_end_turn_guardrail(
8763
8806
  client,
8764
8807
  openai_resp,
@@ -8831,6 +8874,7 @@ async def messages(request: Request):
8831
8874
  openai_resp, model,
8832
8875
  expose_thinking=isinstance(body.get("thinking"), dict)
8833
8876
  and (body["thinking"].get("type") or "").lower() == "enabled",
8877
+ suppress_text_tool_extraction=monitor.suppress_text_tool_extraction,
8834
8878
  )
8835
8879
  _maybe_normalize_toolcall_paths(anthropic_resp, body)
8836
8880
  # FINALIZE CONTINUATION: inject synthetic tool_use (non-guarded stream path)
@@ -15,6 +15,7 @@ TS reference at src/self-harness/middleware/path-normalizer.ts.
15
15
  See docs/design/SELF_HARNESS.md §4 (P2).
16
16
  """
17
17
 
18
+ import os
18
19
  import re
19
20
 
20
21
  _PATH_ARG_KEYS = ("file_path", "path", "filePath", "notebook_path")
@@ -24,92 +25,77 @@ def _squash(s: str) -> str:
24
25
  return re.sub(r"[^a-z0-9]", "", s.lower())
25
26
 
26
27
 
27
- def _basename(p: str) -> str:
28
- parts = [x for x in p.split("/") if x]
29
- return parts[-1] if parts else p
30
-
28
+ def normalize_tool_path(proposed: str, known_paths=None, max_edit_distance=None):
29
+ """Conservatively correct a garbled file path by snapping ONLY the filename to
30
+ a real sibling that already exists in the SAME, on-disk directory. Returns
31
+ ``(path, changed, reason)``.
32
+
33
+ Hardened (2026-06-24) after the heuristic version silently RELOCATED writes
34
+ across projects/worktrees (e.g. ``octopus_invaders/js/config.js`` ->
35
+ ``octopus-invader/space-shooter/js/config.js``), turning a loud,
36
+ self-correcting "no such file" failure into a silent wrong-write that
37
+ clobbered unrelated files. The rules below make a wrong-directory snap
38
+ structurally impossible:
39
+
40
+ * absolute paths only — the proxy cannot verify a relative path's cwd;
41
+ * never touch a path that already exists (it is correct);
42
+ * the directory must already exist on disk and is used VERBATIM — we never
43
+ alter, squash, case-fold or cross a directory segment, so a garbled
44
+ directory simply fails loud (as it did before the normalizer existed);
45
+ * snap only when EXACTLY ONE real file in that directory matches the
46
+ basename case-insensitively or punctuation/extension-squashed;
47
+ * no edit-distance guessing.
48
+
49
+ ``known_paths`` is accepted for signature/back-compat but the on-disk
50
+ directory is the source of truth. Anything that cannot be verified on disk is
51
+ returned unchanged (fail safe to a clean failure).
52
+ """
53
+ if not proposed:
54
+ return proposed, False, None
31
55
 
32
- def _parent_dir(p: str) -> str:
33
- parts = [x for x in p.split("/") if x]
34
- return "/".join(parts[:-1])
56
+ trimmed = proposed.strip()
35
57
 
58
+ # Absolute paths only: the proxy has no reliable cwd for relative paths.
59
+ if not os.path.isabs(trimmed):
60
+ return proposed, False, None
36
61
 
37
- def _dir_compatible(proposed: str, candidate: str) -> bool:
38
- """Only fix the FILENAME / strip a wrong abs prefix — never RELOCATE across
39
- structurally-different directory trees. Bare (no-parent) candidates are safe;
40
- otherwise the parent dirs must match. Blocks the observed harm of snapping
41
- octopus_invaders/js/config.js to a different known dir."""
42
- cp = _parent_dir(candidate)
43
- if cp == "":
44
- return True
45
- return _squash(_parent_dir(proposed)) == _squash(cp)
62
+ # Already real? Nothing to fix (covers files, directories, trailing slashes).
63
+ if os.path.exists(trimmed):
64
+ if trimmed != proposed:
65
+ return trimmed, True, "trimmed surrounding whitespace"
66
+ return proposed, False, None
46
67
 
68
+ parent = os.path.dirname(trimmed)
69
+ base = os.path.basename(trimmed)
70
+ # A wrong/garbled DIRECTORY must never be guessed — only fix the filename
71
+ # within a directory that provably exists.
72
+ if not base or not os.path.isdir(parent):
73
+ return proposed, False, None
47
74
 
48
- def _edit_distance(a: str, b: str) -> int:
49
- m, n = len(a), len(b)
50
- if m == 0:
51
- return n
52
- if n == 0:
53
- return m
54
- prev = list(range(n + 1))
55
- for i in range(1, m + 1):
56
- cur = [i] + [0] * n
57
- for j in range(1, n + 1):
58
- cost = 0 if a[i - 1] == b[j - 1] else 1
59
- cur[j] = min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + cost)
60
- prev = cur
61
- return prev[n]
75
+ try:
76
+ entries = [
77
+ e for e in os.listdir(parent)
78
+ if os.path.isfile(os.path.join(parent, e))
79
+ ]
80
+ except OSError:
81
+ return proposed, False, None
62
82
 
83
+ # Same-directory basename repair only.
84
+ matches = [e for e in entries if e.lower() == base.lower()]
85
+ reason = "case-normalized to the real filename in the same directory"
86
+ if not matches:
87
+ sb = _squash(base)
88
+ matches = [e for e in entries if _squash(e) == sb]
89
+ reason = "extension/punctuation-normalized to the real filename in the same directory"
63
90
 
64
- def normalize_tool_path(proposed: str, known_paths, max_edit_distance=None):
65
- """Snap `proposed` to the nearest path in `known_paths` (paths the model used
66
- correctly earlier). Returns (path, changed, reason). Never invents a target:
67
- an unmatched path (a legitimate new file) is returned unchanged.
68
- """
69
- known = list(dict.fromkeys(known_paths)) # de-dupe, preserve order
70
- if not proposed or not known:
91
+ # 0 matches = nothing to snap to; >1 = ambiguous -> leave alone (fail safe).
92
+ if len(matches) != 1:
71
93
  return proposed, False, None
72
94
 
73
- trimmed = proposed.strip()
74
- if trimmed in known:
75
- if trimmed != proposed:
76
- return trimmed, True, "trimmed surrounding whitespace"
95
+ target = os.path.join(parent, matches[0])
96
+ if target == proposed:
77
97
  return proposed, False, None
78
-
79
- base = _basename(trimmed)
80
-
81
- def snap(target, reason):
82
- return (target, True, reason) if target != proposed else (proposed, False, None)
83
-
84
- def unique(cands):
85
- return cands[0] if len(cands) == 1 else None
86
-
87
- def safe(cands):
88
- return [k for k in cands if _dir_compatible(trimmed, k)]
89
-
90
- # 1) exact basename (stray dirs)
91
- hit = unique(safe([k for k in known if _basename(k) == base]))
92
- if hit:
93
- return snap(hit, "stray path components removed")
94
- # 2) case-insensitive basename
95
- hit = unique(safe([k for k in known if _basename(k).lower() == base.lower()]))
96
- if hit:
97
- return snap(hit, "case-normalized to the real filename")
98
- # 3) punctuation/extension-squashed basename
99
- sb = _squash(base)
100
- hit = unique(safe([k for k in known if _squash(_basename(k)) == sb]))
101
- if hit:
102
- return snap(hit, "extension/punctuation-normalized to the real filename")
103
- # 4) edit-distance fallback (only when clearly closest)
104
- max_d = max_edit_distance if max_edit_distance is not None else max(2, len(base) // 3)
105
- ranked = sorted(
106
- ((k, _edit_distance(base.lower(), _basename(k).lower())) for k in safe(known)),
107
- key=lambda kv: kv[1],
108
- )
109
- if ranked and ranked[0][1] <= max_d and (len(ranked) < 2 or ranked[1][1] > ranked[0][1]):
110
- return snap(ranked[0][0], f"nearest filename by edit distance ({ranked[0][1]})")
111
-
112
- return proposed, False, None
98
+ return target, True, reason
113
99
 
114
100
 
115
101
  def extract_known_paths(anthropic_messages) -> list:
@@ -0,0 +1,178 @@
1
+ #!/usr/bin/env python3
2
+ """Tests for the gated finalize-suppression fix.
3
+
4
+ When a hard finalize breaker (TURN-COUNT FINALIZE BREAKER or SESSION
5
+ CONTAMINATION LOOP) deliberately strips tools to force a terminal text-only
6
+ ``end_turn``, the response-side prose->tool_call resurrection must be suppressed.
7
+ Otherwise a contaminated model that emits ``<function=...>`` / ``<tool_call>``
8
+ prose has it promoted back into a structured ``tool_use``, the client executes
9
+ it, and the very loop the breaker meant to end continues.
10
+
11
+ The suppression is carried per-turn on the SessionMonitor
12
+ (``suppress_text_tool_extraction``): the breakers set it True; the extractor and
13
+ the post-stream recovery honor it; the request handler clears it at the start of
14
+ every turn so a finalize turn never bleeds into the next turn's normal flow.
15
+ """
16
+
17
+ import asyncio
18
+ import importlib.util
19
+ import json
20
+ import unittest
21
+ from pathlib import Path
22
+
23
+
24
+ def _load_proxy_module():
25
+ proxy_path = Path(__file__).resolve().parents[1] / "scripts" / "anthropic_proxy.py"
26
+ spec = importlib.util.spec_from_file_location("anthropic_proxy", proxy_path)
27
+ assert spec is not None and spec.loader is not None
28
+ module = importlib.util.module_from_spec(spec)
29
+ spec.loader.exec_module(module)
30
+ return module
31
+
32
+
33
+ proxy = _load_proxy_module()
34
+
35
+
36
+ def _prose_tool_resp():
37
+ """OpenAI response: message has NO structured tool_calls but DOES contain a
38
+ Hermes ``<function=...>`` prose tool call (the contamination output)."""
39
+ return {
40
+ "choices": [
41
+ {
42
+ "message": {
43
+ "role": "assistant",
44
+ "content": "<function=Bash>\n<parameter=command>ls</parameter>\n</function>",
45
+ },
46
+ "finish_reason": "stop",
47
+ }
48
+ ]
49
+ }
50
+
51
+
52
+ class _FakeOpenAIStream:
53
+ """Minimal stand-in for an httpx streaming response: yields the queued
54
+ ``data: {...}`` SSE lines that stream_anthropic_response consumes."""
55
+
56
+ def __init__(self, lines):
57
+ self._lines = list(lines)
58
+
59
+ async def aiter_lines(self):
60
+ for line in self._lines:
61
+ yield line
62
+
63
+ async def aclose(self):
64
+ pass
65
+
66
+
67
+ def _tool_call_text_stream():
68
+ """An upstream stream whose text delta embeds a ``<tool_call>`` payload and
69
+ that carries NO structured tool_calls — the prose the post-stream recovery
70
+ would otherwise resurrect."""
71
+ text_delta = {
72
+ "choices": [
73
+ {
74
+ "delta": {
75
+ "content": '<tool_call>{"name":"Bash","arguments":{"command":"ls"}}</tool_call>'
76
+ },
77
+ "finish_reason": None,
78
+ }
79
+ ]
80
+ }
81
+ final = {"choices": [{"delta": {}, "finish_reason": "stop"}]}
82
+ return _FakeOpenAIStream(
83
+ [f"data: {json.dumps(text_delta)}", f"data: {json.dumps(final)}", "data: [DONE]"]
84
+ )
85
+
86
+
87
+ async def _drain(agen):
88
+ return [chunk async for chunk in agen]
89
+
90
+
91
+ class TestFinalizeSuppressionExtractor(unittest.TestCase):
92
+ def test_baseline_promotes_prose_tool_call(self):
93
+ """suppress=False: the load-bearing parser still resurrects the prose
94
+ tool call (guards against the fix over-suppressing normal turns)."""
95
+ resp = _prose_tool_resp()
96
+ proxy._maybe_extract_text_tool_calls(resp)
97
+ msg = resp["choices"][0]["message"]
98
+ self.assertTrue(msg.get("tool_calls"), "expected prose call to be promoted")
99
+ self.assertEqual(msg["tool_calls"][0]["function"]["name"], "Bash")
100
+ self.assertEqual(resp["choices"][0]["finish_reason"], "tool_calls")
101
+
102
+ def test_suppressed_leaves_prose_as_text(self):
103
+ """suppress=True: prose stays text -> the client sees a clean end_turn
104
+ with no action, so the agentic loop terminates."""
105
+ resp = _prose_tool_resp()
106
+ proxy._maybe_extract_text_tool_calls(resp, suppress=True)
107
+ msg = resp["choices"][0]["message"]
108
+ self.assertFalse(msg.get("tool_calls"), "must NOT resurrect tool call on finalize")
109
+ self.assertIn("<function=Bash>", msg["content"])
110
+ self.assertEqual(resp["choices"][0]["finish_reason"], "stop")
111
+
112
+ def test_conversion_respects_suppression(self):
113
+ """openai_to_anthropic_response forwards suppression to its internal
114
+ extraction call (the 7444 site that lacks a monitor argument)."""
115
+ anthro = proxy.openai_to_anthropic_response(
116
+ _prose_tool_resp(), "m", suppress_text_tool_extraction=True
117
+ )
118
+ self.assertFalse(
119
+ any(b.get("type") == "tool_use" for b in anthro.get("content", [])),
120
+ "conversion must not resurrect a tool_use when suppressed",
121
+ )
122
+
123
+
124
+ class TestStreamingRecoverySuppression(unittest.TestCase):
125
+ def _run(self, suppress):
126
+ monitor = proxy.SessionMonitor(context_window=100000)
127
+ monitor.suppress_text_tool_extraction = suppress
128
+ agen = proxy.stream_anthropic_response(_tool_call_text_stream(), "test-model", monitor, {})
129
+ return "".join(asyncio.run(_drain(agen)))
130
+
131
+ def test_baseline_streaming_recovers_tool_use(self):
132
+ self.assertIn(
133
+ '"type": "tool_use"', self._run(False),
134
+ "post-stream recovery should fire when not suppressed",
135
+ )
136
+
137
+ def test_suppressed_streaming_does_not_recover_tool_use(self):
138
+ self.assertNotIn(
139
+ '"type": "tool_use"', self._run(True),
140
+ "must NOT recover tool_use on finalize",
141
+ )
142
+
143
+
144
+ class TestContaminationBreakerSetsSuppression(unittest.TestCase):
145
+ def _monitor(self):
146
+ return proxy.SessionMonitor(context_window=100000)
147
+
148
+ def test_forcing_finalize_sets_suppression_and_strips_tools(self):
149
+ """The terminal forcing-finalize branch (resets >= max) strips tools AND
150
+ sets the per-turn suppression flag on the monitor."""
151
+ m = self._monitor()
152
+ m.malformed_tool_streak = proxy.PROXY_SESSION_CONTAMINATION_THRESHOLD
153
+ m.contamination_resets = 3 # >= max (3) -> forcing-finalize branch
154
+ body = {
155
+ "messages": [{"role": "user", "content": "go"}],
156
+ "tools": [{"name": "Bash"}],
157
+ "tool_choice": "auto",
158
+ }
159
+ out = proxy._maybe_apply_session_contamination_breaker(body, m, "sess-finalize")
160
+ self.assertNotIn("tools", out)
161
+ self.assertNotIn("tool_choice", out)
162
+ self.assertTrue(m.suppress_text_tool_extraction, "forcing-finalize must set suppression")
163
+
164
+ def test_standard_reset_does_not_suppress(self):
165
+ """A normal (non-terminal) contamination reset keeps tools and must NOT
166
+ suppress extraction — the model should still be able to recover."""
167
+ m = self._monitor()
168
+ m.malformed_tool_streak = proxy.PROXY_SESSION_CONTAMINATION_THRESHOLD
169
+ m.contamination_resets = 0 # below max -> standard reset path
170
+ msgs = [{"role": "user", "content": "go"}]
171
+ msgs += [{"role": "assistant", "content": f"turn {i}"} for i in range(12)]
172
+ body = {"messages": msgs, "tools": [{"name": "Bash"}]}
173
+ proxy._maybe_apply_session_contamination_breaker(body, m, "sess-standard")
174
+ self.assertFalse(m.suppress_text_tool_extraction, "standard reset must NOT suppress")
175
+
176
+
177
+ if __name__ == "__main__":
178
+ unittest.main()