@miller-tech/uap 1.60.0 → 1.61.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.
Files changed (81) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/benchmarks/paired/adapter.d.ts.map +1 -1
  3. package/dist/benchmarks/paired/adapter.js +14 -1
  4. package/dist/benchmarks/paired/adapter.js.map +1 -1
  5. package/dist/benchmarks/paired/scaffold.d.ts.map +1 -1
  6. package/dist/benchmarks/paired/scaffold.js +6 -1
  7. package/dist/benchmarks/paired/scaffold.js.map +1 -1
  8. package/dist/bin/cli.js +58 -0
  9. package/dist/bin/cli.js.map +1 -1
  10. package/dist/cli/self-harness.d.ts +36 -0
  11. package/dist/cli/self-harness.d.ts.map +1 -0
  12. package/dist/cli/self-harness.js +213 -0
  13. package/dist/cli/self-harness.js.map +1 -0
  14. package/dist/mcp-router/executor/client.d.ts.map +1 -1
  15. package/dist/mcp-router/executor/client.js +16 -2
  16. package/dist/mcp-router/executor/client.js.map +1 -1
  17. package/dist/mcp-router/output-compressor.d.ts.map +1 -1
  18. package/dist/mcp-router/output-compressor.js +51 -0
  19. package/dist/mcp-router/output-compressor.js.map +1 -1
  20. package/dist/self-harness/decide.d.ts +41 -0
  21. package/dist/self-harness/decide.d.ts.map +1 -0
  22. package/dist/self-harness/decide.js +69 -0
  23. package/dist/self-harness/decide.js.map +1 -0
  24. package/dist/self-harness/index.d.ts +23 -0
  25. package/dist/self-harness/index.d.ts.map +1 -0
  26. package/dist/self-harness/index.js +23 -0
  27. package/dist/self-harness/index.js.map +1 -0
  28. package/dist/self-harness/middleware/path-normalizer.d.ts +53 -0
  29. package/dist/self-harness/middleware/path-normalizer.d.ts.map +1 -0
  30. package/dist/self-harness/middleware/path-normalizer.js +116 -0
  31. package/dist/self-harness/middleware/path-normalizer.js.map +1 -0
  32. package/dist/self-harness/mine.d.ts +25 -0
  33. package/dist/self-harness/mine.d.ts.map +1 -0
  34. package/dist/self-harness/mine.js +78 -0
  35. package/dist/self-harness/mine.js.map +1 -0
  36. package/dist/self-harness/mods.d.ts +136 -0
  37. package/dist/self-harness/mods.d.ts.map +1 -0
  38. package/dist/self-harness/mods.js +152 -0
  39. package/dist/self-harness/mods.js.map +1 -0
  40. package/dist/self-harness/orchestrator.d.ts +61 -0
  41. package/dist/self-harness/orchestrator.d.ts.map +1 -0
  42. package/dist/self-harness/orchestrator.js +110 -0
  43. package/dist/self-harness/orchestrator.js.map +1 -0
  44. package/dist/self-harness/pending.d.ts +57 -0
  45. package/dist/self-harness/pending.d.ts.map +1 -0
  46. package/dist/self-harness/pending.js +86 -0
  47. package/dist/self-harness/pending.js.map +1 -0
  48. package/dist/self-harness/profile.d.ts +38 -0
  49. package/dist/self-harness/profile.d.ts.map +1 -0
  50. package/dist/self-harness/profile.js +80 -0
  51. package/dist/self-harness/profile.js.map +1 -0
  52. package/dist/self-harness/propose.d.ts +25 -0
  53. package/dist/self-harness/propose.d.ts.map +1 -0
  54. package/dist/self-harness/propose.js +86 -0
  55. package/dist/self-harness/propose.js.map +1 -0
  56. package/dist/self-harness/trace-mine.d.ts +39 -0
  57. package/dist/self-harness/trace-mine.d.ts.map +1 -0
  58. package/dist/self-harness/trace-mine.js +109 -0
  59. package/dist/self-harness/trace-mine.js.map +1 -0
  60. package/dist/self-harness/transfer.d.ts +99 -0
  61. package/dist/self-harness/transfer.d.ts.map +1 -0
  62. package/dist/self-harness/transfer.js +140 -0
  63. package/dist/self-harness/transfer.js.map +1 -0
  64. package/dist/self-harness/weakness.d.ts +56 -0
  65. package/dist/self-harness/weakness.d.ts.map +1 -0
  66. package/dist/self-harness/weakness.js +0 -0
  67. package/dist/self-harness/weakness.js.map +1 -0
  68. package/dist/tasks/coordination.d.ts.map +1 -1
  69. package/dist/tasks/coordination.js +9 -7
  70. package/dist/tasks/coordination.js.map +1 -1
  71. package/dist/tasks/service.d.ts +10 -0
  72. package/dist/tasks/service.d.ts.map +1 -1
  73. package/dist/tasks/service.js +41 -5
  74. package/dist/tasks/service.js.map +1 -1
  75. package/docs/INDEX.md +1 -0
  76. package/docs/design/SELF_HARNESS.md +206 -0
  77. package/package.json +1 -1
  78. package/src/policies/enforcers/__pycache__/_common.cpython-312.pyc +0 -0
  79. package/tools/agents/scripts/anthropic_proxy.py +160 -18
  80. package/tools/agents/scripts/toolcall_path_normalizer.py +140 -0
  81. package/tools/agents/tests/test_anthropic_proxy_streaming.py +93 -2
@@ -0,0 +1,140 @@
1
+ """Self-Harness middleware (proxy side) — conversation-aware tool-call path normalizer.
2
+
3
+ The mechanical fix for the `toolcall.path.garbled` failure: a small quant
4
+ fat-fingers the file path in Write/Edit/Read tool calls (case, dropped/altered
5
+ extension, stray subdirectory, trailing whitespace), so the edit lands nowhere.
6
+ No prompt/param Mod fixes it. A Claude Code PreToolUse hook can't either — it does
7
+ NOT honor `updatedInput` (verified empirically 2026-06-23). The proxy, however,
8
+ sees the whole conversation: the model almost always Reads the file with the
9
+ CORRECT path before mangling it in a later Edit. So we snap a garbled tool-call
10
+ path to the nearest path the model already used correctly earlier in the
11
+ conversation — filesystem-free, and the proxy is in the request path.
12
+
13
+ Pure functions; gated by PROXY_TOOLCALL_PATH_NORMALIZE in the proxy. Mirrors the
14
+ TS reference at src/self-harness/middleware/path-normalizer.ts.
15
+ See docs/design/SELF_HARNESS.md §4 (P2).
16
+ """
17
+
18
+ import re
19
+
20
+ _PATH_ARG_KEYS = ("file_path", "path", "filePath", "notebook_path")
21
+
22
+
23
+ def _squash(s: str) -> str:
24
+ return re.sub(r"[^a-z0-9]", "", s.lower())
25
+
26
+
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
+
31
+
32
+ def _edit_distance(a: str, b: str) -> int:
33
+ m, n = len(a), len(b)
34
+ if m == 0:
35
+ return n
36
+ if n == 0:
37
+ return m
38
+ prev = list(range(n + 1))
39
+ for i in range(1, m + 1):
40
+ cur = [i] + [0] * n
41
+ for j in range(1, n + 1):
42
+ cost = 0 if a[i - 1] == b[j - 1] else 1
43
+ cur[j] = min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + cost)
44
+ prev = cur
45
+ return prev[n]
46
+
47
+
48
+ def normalize_tool_path(proposed: str, known_paths, max_edit_distance=None):
49
+ """Snap `proposed` to the nearest path in `known_paths` (paths the model used
50
+ correctly earlier). Returns (path, changed, reason). Never invents a target:
51
+ an unmatched path (a legitimate new file) is returned unchanged.
52
+ """
53
+ known = list(dict.fromkeys(known_paths)) # de-dupe, preserve order
54
+ if not proposed or not known:
55
+ return proposed, False, None
56
+
57
+ trimmed = proposed.strip()
58
+ if trimmed in known:
59
+ if trimmed != proposed:
60
+ return trimmed, True, "trimmed surrounding whitespace"
61
+ return proposed, False, None
62
+
63
+ base = _basename(trimmed)
64
+
65
+ def snap(target, reason):
66
+ return (target, True, reason) if target != proposed else (proposed, False, None)
67
+
68
+ def unique(cands):
69
+ return cands[0] if len(cands) == 1 else None
70
+
71
+ # 1) exact basename (stray dirs)
72
+ hit = unique([k for k in known if _basename(k) == base])
73
+ if hit:
74
+ return snap(hit, "stray path components removed")
75
+ # 2) case-insensitive basename
76
+ hit = unique([k for k in known if _basename(k).lower() == base.lower()])
77
+ if hit:
78
+ return snap(hit, "case-normalized to the real filename")
79
+ # 3) punctuation/extension-squashed basename
80
+ sb = _squash(base)
81
+ hit = unique([k for k in known if _squash(_basename(k)) == sb])
82
+ if hit:
83
+ return snap(hit, "extension/punctuation-normalized to the real filename")
84
+ # 4) edit-distance fallback (only when clearly closest)
85
+ max_d = max_edit_distance if max_edit_distance is not None else max(2, len(base) // 3)
86
+ ranked = sorted(
87
+ ((k, _edit_distance(base.lower(), _basename(k).lower())) for k in known),
88
+ key=lambda kv: kv[1],
89
+ )
90
+ if ranked and ranked[0][1] <= max_d and (len(ranked) < 2 or ranked[1][1] > ranked[0][1]):
91
+ return snap(ranked[0][0], f"nearest filename by edit distance ({ranked[0][1]})")
92
+
93
+ return proposed, False, None
94
+
95
+
96
+ def extract_known_paths(anthropic_messages) -> list:
97
+ """Collect file paths the model has already used in the conversation — from
98
+ prior tool_use arguments (file_path/path) and from tool_result text that
99
+ echoes a path. These are the 'real' paths to snap garbled ones toward.
100
+ """
101
+ known: list[str] = []
102
+
103
+ def add(p):
104
+ if isinstance(p, str) and p.strip() and p.strip() not in known:
105
+ known.append(p.strip())
106
+
107
+ for msg in anthropic_messages or []:
108
+ content = msg.get("content")
109
+ if not isinstance(content, list):
110
+ continue
111
+ for block in content:
112
+ if not isinstance(block, dict):
113
+ continue
114
+ if block.get("type") == "tool_use":
115
+ inp = block.get("input") or {}
116
+ for key in _PATH_ARG_KEYS:
117
+ add(inp.get(key))
118
+ return known
119
+
120
+
121
+ def normalize_tool_uses(tool_uses, known_paths):
122
+ """Normalize path args of a list of Anthropic tool_use blocks in place.
123
+ Returns the list of corrections [(tool_use_id, key, from, to, reason)].
124
+ """
125
+ corrections = []
126
+ for tu in tool_uses:
127
+ if not isinstance(tu, dict) or tu.get("type") != "tool_use":
128
+ continue
129
+ inp = tu.get("input")
130
+ if not isinstance(inp, dict):
131
+ continue
132
+ for key in _PATH_ARG_KEYS:
133
+ v = inp.get(key)
134
+ if not isinstance(v, str):
135
+ continue
136
+ new_v, changed, reason = normalize_tool_path(v, known_paths)
137
+ if changed:
138
+ inp[key] = new_v
139
+ corrections.append((tu.get("id", ""), key, v, new_v, reason))
140
+ return corrections
@@ -1809,6 +1809,64 @@ class TestMalformedToolGuardrail(unittest.TestCase):
1809
1809
  self.assertEqual(len(fake_client.requests), 1)
1810
1810
 
1811
1811
 
1812
+ class TestTurnCountFinalizeBreaker(unittest.TestCase):
1813
+ """The mode-B runaway backstop: after PROXY_HARD_FINALIZE_TURNS assistant
1814
+ tool turns, the proxy strips tools so the only possible output is a terminal
1815
+ text summary (end_turn). Inert below the ceiling."""
1816
+
1817
+ @staticmethod
1818
+ def _body(n_tool_turns):
1819
+ msgs = [{"role": "user", "content": "do the task"}]
1820
+ for i in range(n_tool_turns):
1821
+ msgs.append({
1822
+ "role": "assistant",
1823
+ "content": [{"type": "tool_use", "id": f"t{i}", "name": "Bash",
1824
+ "input": {"command": "ls"}}],
1825
+ })
1826
+ msgs.append({
1827
+ "role": "user",
1828
+ "content": [{"type": "tool_result", "tool_use_id": f"t{i}", "content": "ok"}],
1829
+ })
1830
+ return {
1831
+ "model": "x", "messages": msgs,
1832
+ "tools": [{"name": "Bash", "input_schema": {"type": "object"}}],
1833
+ }
1834
+
1835
+ def test_strips_tools_above_ceiling(self):
1836
+ old = getattr(proxy, "PROXY_HARD_FINALIZE_TURNS")
1837
+ try:
1838
+ setattr(proxy, "PROXY_HARD_FINALIZE_TURNS", 40)
1839
+ out = proxy.build_openai_request(
1840
+ self._body(45), proxy.SessionMonitor(context_window=262144)
1841
+ )
1842
+ self.assertFalse(out.get("tools")) # tools stripped
1843
+ self.assertIn("STOP now", out["messages"][-1]["content"])
1844
+ finally:
1845
+ setattr(proxy, "PROXY_HARD_FINALIZE_TURNS", old)
1846
+
1847
+ def test_inert_below_ceiling(self):
1848
+ old = getattr(proxy, "PROXY_HARD_FINALIZE_TURNS")
1849
+ try:
1850
+ setattr(proxy, "PROXY_HARD_FINALIZE_TURNS", 40)
1851
+ out = proxy.build_openai_request(
1852
+ self._body(5), proxy.SessionMonitor(context_window=262144)
1853
+ )
1854
+ self.assertTrue(out.get("tools")) # tools retained
1855
+ finally:
1856
+ setattr(proxy, "PROXY_HARD_FINALIZE_TURNS", old)
1857
+
1858
+ def test_disabled_when_zero(self):
1859
+ old = getattr(proxy, "PROXY_HARD_FINALIZE_TURNS")
1860
+ try:
1861
+ setattr(proxy, "PROXY_HARD_FINALIZE_TURNS", 0)
1862
+ out = proxy.build_openai_request(
1863
+ self._body(60), proxy.SessionMonitor(context_window=262144)
1864
+ )
1865
+ self.assertTrue(out.get("tools")) # disabled → never strips
1866
+ finally:
1867
+ setattr(proxy, "PROXY_HARD_FINALIZE_TURNS", old)
1868
+
1869
+
1812
1870
  class TestToolTurnControls(unittest.TestCase):
1813
1871
  def test_tool_narrowing_reduces_tool_count(self):
1814
1872
  old_narrow = getattr(proxy, "PROXY_TOOL_NARROWING")
@@ -1853,9 +1911,17 @@ class TestToolTurnControls(unittest.TestCase):
1853
1911
  openai = proxy.build_openai_request(
1854
1912
  body, proxy.SessionMonitor(context_window=262144)
1855
1913
  )
1856
- self.assertEqual(len(openai.get("tools", [])), 2)
1857
1914
  names = [t.get("function", {}).get("name") for t in openai.get("tools", [])]
1858
- self.assertIn("RunTests", names)
1915
+ # Narrowing must still REDUCE the toolset by dropping irrelevant tools
1916
+ # (Deploy), while keeping the query-relevant tool (RunTests) AND core
1917
+ # action tools (Read/Edit) — a coding agent that loses Edit cannot
1918
+ # "fix the failing test". Core-tool protection makes `keep` a soft
1919
+ # floor, so the exact count is keep + any core tools not already in
1920
+ # the top-scored set; assert the invariant, not a brittle fixed count.
1921
+ self.assertLess(len(names), 4) # reduced from 4
1922
+ self.assertIn("RunTests", names) # query-relevant kept
1923
+ self.assertIn("Edit", names) # core action tool kept
1924
+ self.assertNotIn("Deploy", names) # irrelevant dropped
1859
1925
  finally:
1860
1926
  setattr(proxy, "PROXY_TOOL_NARROWING", old_narrow)
1861
1927
  setattr(proxy, "PROXY_TOOL_NARROWING_KEEP", old_keep)
@@ -5480,6 +5546,31 @@ class TestReconConvergence(unittest.TestCase):
5480
5546
  proxy._maybe_inject_recon_convergence(body, m)
5481
5547
  self.assertEqual(len(body["messages"]), 1)
5482
5548
 
5549
+ def test_escalate_is_one_shot_not_a_permanent_poison(self):
5550
+ """POISON FIX: the escalate tier strips tools to force a terminal
5551
+ summary, but it must reset the no-write streak so it does NOT re-fire
5552
+ every subsequent request. Without the reset, recon_hard_fires is
5553
+ monotonic (escalate stays true) and stripped tools mean the model can
5554
+ never write (streak never falls), so EVERY request gets tools stripped
5555
+ forever — poisoning the session (and any colliding-fingerprint session)
5556
+ until a proxy restart."""
5557
+ proxy.PROXY_RECON_CONVERGENCE_THRESHOLD = 40
5558
+ m = proxy.SessionMonitor(context_window=131072)
5559
+ tools = [self._tool("Write"), self._tool("Read")]
5560
+ # drive into escalate: streak huge, hard-tier already fired to the cap
5561
+ m.consecutive_no_write_turns = 200
5562
+ m.recon_hard_fires = proxy.PROXY_RECON_SESSION_HARD_CAP
5563
+ b1 = {"messages": [{"role": "user", "content": "go"}],
5564
+ "tools": list(tools), "tool_choice": "required"}
5565
+ proxy._maybe_inject_recon_convergence(b1, m, tools)
5566
+ self.assertNotIn("tools", b1) # escalate stripped tools
5567
+ self.assertEqual(m.consecutive_no_write_turns, 0) # streak reset (the fix)
5568
+ # next request: streak is now below threshold → MUST NOT strip again
5569
+ b2 = {"messages": [{"role": "user", "content": "go"}],
5570
+ "tools": list(tools), "tool_choice": "required"}
5571
+ proxy._maybe_inject_recon_convergence(b2, m, tools)
5572
+ self.assertTrue(b2.get("tools")) # tools restored, not poisoned
5573
+
5483
5574
  @staticmethod
5484
5575
  def _tool(name: str) -> dict:
5485
5576
  return {"type": "function", "function": {"name": name, "description": f"{name} tool"}}