@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.
- package/dist/.tsbuildinfo +1 -1
- package/dist/benchmarks/paired/adapter.d.ts.map +1 -1
- package/dist/benchmarks/paired/adapter.js +14 -1
- package/dist/benchmarks/paired/adapter.js.map +1 -1
- package/dist/benchmarks/paired/scaffold.d.ts.map +1 -1
- package/dist/benchmarks/paired/scaffold.js +6 -1
- package/dist/benchmarks/paired/scaffold.js.map +1 -1
- package/dist/bin/cli.js +58 -0
- package/dist/bin/cli.js.map +1 -1
- package/dist/cli/self-harness.d.ts +36 -0
- package/dist/cli/self-harness.d.ts.map +1 -0
- package/dist/cli/self-harness.js +213 -0
- package/dist/cli/self-harness.js.map +1 -0
- package/dist/mcp-router/executor/client.d.ts.map +1 -1
- package/dist/mcp-router/executor/client.js +16 -2
- package/dist/mcp-router/executor/client.js.map +1 -1
- package/dist/mcp-router/output-compressor.d.ts.map +1 -1
- package/dist/mcp-router/output-compressor.js +51 -0
- package/dist/mcp-router/output-compressor.js.map +1 -1
- package/dist/self-harness/decide.d.ts +41 -0
- package/dist/self-harness/decide.d.ts.map +1 -0
- package/dist/self-harness/decide.js +69 -0
- package/dist/self-harness/decide.js.map +1 -0
- package/dist/self-harness/index.d.ts +23 -0
- package/dist/self-harness/index.d.ts.map +1 -0
- package/dist/self-harness/index.js +23 -0
- package/dist/self-harness/index.js.map +1 -0
- package/dist/self-harness/middleware/path-normalizer.d.ts +53 -0
- package/dist/self-harness/middleware/path-normalizer.d.ts.map +1 -0
- package/dist/self-harness/middleware/path-normalizer.js +116 -0
- package/dist/self-harness/middleware/path-normalizer.js.map +1 -0
- package/dist/self-harness/mine.d.ts +25 -0
- package/dist/self-harness/mine.d.ts.map +1 -0
- package/dist/self-harness/mine.js +78 -0
- package/dist/self-harness/mine.js.map +1 -0
- package/dist/self-harness/mods.d.ts +136 -0
- package/dist/self-harness/mods.d.ts.map +1 -0
- package/dist/self-harness/mods.js +152 -0
- package/dist/self-harness/mods.js.map +1 -0
- package/dist/self-harness/orchestrator.d.ts +61 -0
- package/dist/self-harness/orchestrator.d.ts.map +1 -0
- package/dist/self-harness/orchestrator.js +110 -0
- package/dist/self-harness/orchestrator.js.map +1 -0
- package/dist/self-harness/pending.d.ts +57 -0
- package/dist/self-harness/pending.d.ts.map +1 -0
- package/dist/self-harness/pending.js +86 -0
- package/dist/self-harness/pending.js.map +1 -0
- package/dist/self-harness/profile.d.ts +38 -0
- package/dist/self-harness/profile.d.ts.map +1 -0
- package/dist/self-harness/profile.js +80 -0
- package/dist/self-harness/profile.js.map +1 -0
- package/dist/self-harness/propose.d.ts +25 -0
- package/dist/self-harness/propose.d.ts.map +1 -0
- package/dist/self-harness/propose.js +86 -0
- package/dist/self-harness/propose.js.map +1 -0
- package/dist/self-harness/trace-mine.d.ts +39 -0
- package/dist/self-harness/trace-mine.d.ts.map +1 -0
- package/dist/self-harness/trace-mine.js +109 -0
- package/dist/self-harness/trace-mine.js.map +1 -0
- package/dist/self-harness/transfer.d.ts +99 -0
- package/dist/self-harness/transfer.d.ts.map +1 -0
- package/dist/self-harness/transfer.js +140 -0
- package/dist/self-harness/transfer.js.map +1 -0
- package/dist/self-harness/weakness.d.ts +56 -0
- package/dist/self-harness/weakness.d.ts.map +1 -0
- package/dist/self-harness/weakness.js +0 -0
- package/dist/self-harness/weakness.js.map +1 -0
- package/dist/tasks/coordination.d.ts.map +1 -1
- package/dist/tasks/coordination.js +9 -7
- package/dist/tasks/coordination.js.map +1 -1
- package/dist/tasks/service.d.ts +10 -0
- package/dist/tasks/service.d.ts.map +1 -1
- package/dist/tasks/service.js +41 -5
- package/dist/tasks/service.js.map +1 -1
- package/docs/INDEX.md +1 -0
- package/docs/design/SELF_HARNESS.md +206 -0
- package/package.json +1 -1
- package/src/policies/enforcers/__pycache__/_common.cpython-312.pyc +0 -0
- package/tools/agents/scripts/anthropic_proxy.py +160 -18
- package/tools/agents/scripts/toolcall_path_normalizer.py +140 -0
- 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
|
-
|
|
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"}}
|