@miller-tech/uap 2.21.2 → 2.36.1

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 (328) hide show
  1. package/README.md +22 -10
  2. package/config/model-profiles/qwen38-coder390.json +46 -0
  3. package/config/model-profiles/qwen38.json +3 -2
  4. package/config/model-registry.json +110 -0
  5. package/dist/.tsbuildinfo +1 -1
  6. package/dist/benchmarks/paired/types.d.ts +2 -2
  7. package/dist/bin/cli.js +315 -20
  8. package/dist/bin/cli.js.map +1 -1
  9. package/dist/cli/attention.d.ts +6 -0
  10. package/dist/cli/attention.d.ts.map +1 -0
  11. package/dist/cli/attention.js +115 -0
  12. package/dist/cli/attention.js.map +1 -0
  13. package/dist/cli/bench-report.d.ts +82 -0
  14. package/dist/cli/bench-report.d.ts.map +1 -0
  15. package/dist/cli/bench-report.js +237 -0
  16. package/dist/cli/bench-report.js.map +1 -0
  17. package/dist/cli/clients.d.ts +6 -0
  18. package/dist/cli/clients.d.ts.map +1 -0
  19. package/dist/cli/clients.js +142 -0
  20. package/dist/cli/clients.js.map +1 -0
  21. package/dist/cli/dashboard-grants.d.ts +26 -0
  22. package/dist/cli/dashboard-grants.d.ts.map +1 -0
  23. package/dist/cli/dashboard-grants.js +69 -0
  24. package/dist/cli/dashboard-grants.js.map +1 -0
  25. package/dist/cli/dashboard-pace.d.ts +31 -0
  26. package/dist/cli/dashboard-pace.d.ts.map +1 -0
  27. package/dist/cli/dashboard-pace.js +42 -0
  28. package/dist/cli/dashboard-pace.js.map +1 -0
  29. package/dist/cli/dashboard-session.d.ts +25 -0
  30. package/dist/cli/dashboard-session.d.ts.map +1 -0
  31. package/dist/cli/dashboard-session.js +814 -0
  32. package/dist/cli/dashboard-session.js.map +1 -0
  33. package/dist/cli/dashboard-types.d.ts +18 -0
  34. package/dist/cli/dashboard-types.d.ts.map +1 -0
  35. package/dist/cli/dashboard-types.js +9 -0
  36. package/dist/cli/dashboard-types.js.map +1 -0
  37. package/dist/cli/dashboard.d.ts +2 -18
  38. package/dist/cli/dashboard.d.ts.map +1 -1
  39. package/dist/cli/dashboard.js +43 -793
  40. package/dist/cli/dashboard.js.map +1 -1
  41. package/dist/cli/deliver.d.ts +35 -0
  42. package/dist/cli/deliver.d.ts.map +1 -1
  43. package/dist/cli/deliver.js +93 -9
  44. package/dist/cli/deliver.js.map +1 -1
  45. package/dist/cli/diff.d.ts +85 -0
  46. package/dist/cli/diff.d.ts.map +1 -0
  47. package/dist/cli/diff.js +293 -0
  48. package/dist/cli/diff.js.map +1 -0
  49. package/dist/cli/eta.d.ts +35 -0
  50. package/dist/cli/eta.d.ts.map +1 -0
  51. package/dist/cli/eta.js +101 -0
  52. package/dist/cli/eta.js.map +1 -0
  53. package/dist/cli/handsfree.d.ts +7 -0
  54. package/dist/cli/handsfree.d.ts.map +1 -1
  55. package/dist/cli/handsfree.js +27 -0
  56. package/dist/cli/handsfree.js.map +1 -1
  57. package/dist/cli/hooks.d.ts.map +1 -1
  58. package/dist/cli/hooks.js +126 -5
  59. package/dist/cli/hooks.js.map +1 -1
  60. package/dist/cli/loops.d.ts +24 -5
  61. package/dist/cli/loops.d.ts.map +1 -1
  62. package/dist/cli/loops.js +92 -12
  63. package/dist/cli/loops.js.map +1 -1
  64. package/dist/cli/models.d.ts +62 -0
  65. package/dist/cli/models.d.ts.map +1 -0
  66. package/dist/cli/models.js +567 -0
  67. package/dist/cli/models.js.map +1 -0
  68. package/dist/cli/plan.d.ts +9 -0
  69. package/dist/cli/plan.d.ts.map +1 -1
  70. package/dist/cli/plan.js +57 -4
  71. package/dist/cli/plan.js.map +1 -1
  72. package/dist/cli/proxy-relay.d.ts +55 -0
  73. package/dist/cli/proxy-relay.d.ts.map +1 -0
  74. package/dist/cli/proxy-relay.js +116 -0
  75. package/dist/cli/proxy-relay.js.map +1 -0
  76. package/dist/cli/proxy.d.ts +4 -0
  77. package/dist/cli/proxy.d.ts.map +1 -1
  78. package/dist/cli/proxy.js +26 -1
  79. package/dist/cli/proxy.js.map +1 -1
  80. package/dist/cli/sandbox.d.ts +101 -4
  81. package/dist/cli/sandbox.d.ts.map +1 -1
  82. package/dist/cli/sandbox.js +393 -44
  83. package/dist/cli/sandbox.js.map +1 -1
  84. package/dist/cli/self.d.ts +13 -0
  85. package/dist/cli/self.d.ts.map +1 -0
  86. package/dist/cli/self.js +94 -0
  87. package/dist/cli/self.js.map +1 -0
  88. package/dist/cli/systemd-services.d.ts +2 -8
  89. package/dist/cli/systemd-services.d.ts.map +1 -1
  90. package/dist/cli/systemd-services.js +5 -20
  91. package/dist/cli/systemd-services.js.map +1 -1
  92. package/dist/cli/task.js +1 -1
  93. package/dist/cli/task.js.map +1 -1
  94. package/dist/cli/wizard-config.d.ts.map +1 -1
  95. package/dist/cli/wizard-config.js +31 -2
  96. package/dist/cli/wizard-config.js.map +1 -1
  97. package/dist/config/settings-registry.d.ts +1 -1
  98. package/dist/config/settings-registry.d.ts.map +1 -1
  99. package/dist/config/settings-registry.js +108 -0
  100. package/dist/config/settings-registry.js.map +1 -1
  101. package/dist/coordination/capability-router.js +2 -2
  102. package/dist/coordination/capability-router.js.map +1 -1
  103. package/dist/coordination/database.d.ts +13 -0
  104. package/dist/coordination/database.d.ts.map +1 -1
  105. package/dist/coordination/database.js +63 -7
  106. package/dist/coordination/database.js.map +1 -1
  107. package/dist/coordination/eta-inject.d.ts +32 -0
  108. package/dist/coordination/eta-inject.d.ts.map +1 -0
  109. package/dist/coordination/eta-inject.js +64 -0
  110. package/dist/coordination/eta-inject.js.map +1 -0
  111. package/dist/coordination/reactor.d.ts.map +1 -1
  112. package/dist/coordination/reactor.js +14 -0
  113. package/dist/coordination/reactor.js.map +1 -1
  114. package/dist/coordination/service.d.ts +18 -8
  115. package/dist/coordination/service.d.ts.map +1 -1
  116. package/dist/coordination/service.js +38 -28
  117. package/dist/coordination/service.js.map +1 -1
  118. package/dist/dashboard/client-registry.d.ts +163 -0
  119. package/dist/dashboard/client-registry.d.ts.map +1 -0
  120. package/dist/dashboard/client-registry.js +549 -0
  121. package/dist/dashboard/client-registry.js.map +1 -0
  122. package/dist/dashboard/controls.d.ts.map +1 -1
  123. package/dist/dashboard/controls.js +33 -4
  124. package/dist/dashboard/controls.js.map +1 -1
  125. package/dist/dashboard/data-seeder.d.ts +10 -7
  126. package/dist/dashboard/data-seeder.d.ts.map +1 -1
  127. package/dist/dashboard/data-seeder.js +31 -75
  128. package/dist/dashboard/data-seeder.js.map +1 -1
  129. package/dist/dashboard/data-service.d.ts +9 -0
  130. package/dist/dashboard/data-service.d.ts.map +1 -1
  131. package/dist/dashboard/data-service.js +32 -3
  132. package/dist/dashboard/data-service.js.map +1 -1
  133. package/dist/dashboard/placement-routes.d.ts +119 -0
  134. package/dist/dashboard/placement-routes.d.ts.map +1 -0
  135. package/dist/dashboard/placement-routes.js +331 -0
  136. package/dist/dashboard/placement-routes.js.map +1 -0
  137. package/dist/dashboard/server.d.ts.map +1 -1
  138. package/dist/dashboard/server.js +408 -2
  139. package/dist/dashboard/server.js.map +1 -1
  140. package/dist/delivery/attention.d.ts +130 -0
  141. package/dist/delivery/attention.d.ts.map +1 -0
  142. package/dist/delivery/attention.js +341 -0
  143. package/dist/delivery/attention.js.map +1 -0
  144. package/dist/delivery/decompose.d.ts +18 -0
  145. package/dist/delivery/decompose.d.ts.map +1 -1
  146. package/dist/delivery/decompose.js +22 -1
  147. package/dist/delivery/decompose.js.map +1 -1
  148. package/dist/delivery/eta-estimate.d.ts +111 -0
  149. package/dist/delivery/eta-estimate.d.ts.map +1 -0
  150. package/dist/delivery/eta-estimate.js +171 -0
  151. package/dist/delivery/eta-estimate.js.map +1 -0
  152. package/dist/delivery/eta.d.ts +23 -0
  153. package/dist/delivery/eta.d.ts.map +1 -0
  154. package/dist/delivery/eta.js +59 -0
  155. package/dist/delivery/eta.js.map +1 -0
  156. package/dist/delivery/orchestrated-mission.d.ts +10 -0
  157. package/dist/delivery/orchestrated-mission.d.ts.map +1 -1
  158. package/dist/delivery/orchestrated-mission.js +15 -1
  159. package/dist/delivery/orchestrated-mission.js.map +1 -1
  160. package/dist/delivery/plan-check.d.ts +7 -1
  161. package/dist/delivery/plan-check.d.ts.map +1 -1
  162. package/dist/delivery/plan-check.js +23 -2
  163. package/dist/delivery/plan-check.js.map +1 -1
  164. package/dist/delivery/run-state.d.ts +1 -1
  165. package/dist/delivery/run-state.d.ts.map +1 -1
  166. package/dist/delivery/run-state.js +8 -1
  167. package/dist/delivery/run-state.js.map +1 -1
  168. package/dist/delivery/task-orchestrator.d.ts +8 -3
  169. package/dist/delivery/task-orchestrator.d.ts.map +1 -1
  170. package/dist/delivery/task-orchestrator.js +75 -43
  171. package/dist/delivery/task-orchestrator.js.map +1 -1
  172. package/dist/delivery/task-sync.d.ts +21 -0
  173. package/dist/delivery/task-sync.d.ts.map +1 -1
  174. package/dist/delivery/task-sync.js +44 -8
  175. package/dist/delivery/task-sync.js.map +1 -1
  176. package/dist/delivery/task-workspace.d.ts +3 -3
  177. package/dist/delivery/task-workspace.js +3 -3
  178. package/dist/generators/self-card.d.ts +65 -0
  179. package/dist/generators/self-card.d.ts.map +1 -0
  180. package/dist/generators/self-card.js +256 -0
  181. package/dist/generators/self-card.js.map +1 -0
  182. package/dist/inference/probe.js +1 -1
  183. package/dist/inference/probe.js.map +1 -1
  184. package/dist/memory/prepopulate.d.ts.map +1 -1
  185. package/dist/memory/prepopulate.js +2 -0
  186. package/dist/memory/prepopulate.js.map +1 -1
  187. package/dist/models/analytics.d.ts +32 -0
  188. package/dist/models/analytics.d.ts.map +1 -1
  189. package/dist/models/analytics.js +68 -5
  190. package/dist/models/analytics.js.map +1 -1
  191. package/dist/models/plan-readability.d.ts +55 -0
  192. package/dist/models/plan-readability.d.ts.map +1 -0
  193. package/dist/models/plan-readability.js +339 -0
  194. package/dist/models/plan-readability.js.map +1 -0
  195. package/dist/placement/admission.d.ts +59 -0
  196. package/dist/placement/admission.d.ts.map +1 -0
  197. package/dist/placement/admission.js +197 -0
  198. package/dist/placement/admission.js.map +1 -0
  199. package/dist/placement/auto.d.ts +44 -0
  200. package/dist/placement/auto.d.ts.map +1 -0
  201. package/dist/placement/auto.js +212 -0
  202. package/dist/placement/auto.js.map +1 -0
  203. package/dist/placement/enforce.d.ts +128 -0
  204. package/dist/placement/enforce.d.ts.map +1 -0
  205. package/dist/placement/enforce.js +655 -0
  206. package/dist/placement/enforce.js.map +1 -0
  207. package/dist/placement/idle.d.ts +50 -0
  208. package/dist/placement/idle.d.ts.map +1 -0
  209. package/dist/placement/idle.js +213 -0
  210. package/dist/placement/idle.js.map +1 -0
  211. package/dist/placement/ledger.d.ts +323 -0
  212. package/dist/placement/ledger.d.ts.map +1 -0
  213. package/dist/placement/ledger.js +263 -0
  214. package/dist/placement/ledger.js.map +1 -0
  215. package/dist/placement/preview.d.ts +51 -0
  216. package/dist/placement/preview.d.ts.map +1 -0
  217. package/dist/placement/preview.js +61 -0
  218. package/dist/placement/preview.js.map +1 -0
  219. package/dist/placement/probes.d.ts +47 -0
  220. package/dist/placement/probes.d.ts.map +1 -0
  221. package/dist/placement/probes.js +207 -0
  222. package/dist/placement/probes.js.map +1 -0
  223. package/dist/placement/proxy-env.d.ts +21 -0
  224. package/dist/placement/proxy-env.d.ts.map +1 -0
  225. package/dist/placement/proxy-env.js +42 -0
  226. package/dist/placement/proxy-env.js.map +1 -0
  227. package/dist/placement/registry.d.ts +1038 -0
  228. package/dist/placement/registry.d.ts.map +1 -0
  229. package/dist/placement/registry.js +274 -0
  230. package/dist/placement/registry.js.map +1 -0
  231. package/dist/policies/liveness.d.ts +6 -8
  232. package/dist/policies/liveness.d.ts.map +1 -1
  233. package/dist/policies/liveness.js +6 -25
  234. package/dist/policies/liveness.js.map +1 -1
  235. package/dist/policies/sandbox-anchor.d.ts +91 -0
  236. package/dist/policies/sandbox-anchor.d.ts.map +1 -0
  237. package/dist/policies/sandbox-anchor.js +187 -0
  238. package/dist/policies/sandbox-anchor.js.map +1 -0
  239. package/dist/policies/sandbox-grants.d.ts +131 -0
  240. package/dist/policies/sandbox-grants.d.ts.map +1 -0
  241. package/dist/policies/sandbox-grants.js +562 -0
  242. package/dist/policies/sandbox-grants.js.map +1 -0
  243. package/dist/state/manifest.d.ts +4 -2
  244. package/dist/state/manifest.d.ts.map +1 -1
  245. package/dist/state/manifest.js +32 -16
  246. package/dist/state/manifest.js.map +1 -1
  247. package/dist/utils/checkout-root.d.ts +2 -0
  248. package/dist/utils/checkout-root.d.ts.map +1 -0
  249. package/dist/utils/checkout-root.js +43 -0
  250. package/dist/utils/checkout-root.js.map +1 -0
  251. package/dist/utils/resolve-uap-bin.d.ts +5 -0
  252. package/dist/utils/resolve-uap-bin.d.ts.map +1 -0
  253. package/dist/utils/resolve-uap-bin.js +33 -0
  254. package/dist/utils/resolve-uap-bin.js.map +1 -0
  255. package/dist/utils/terminal-safe.d.ts +12 -0
  256. package/dist/utils/terminal-safe.d.ts.map +1 -0
  257. package/dist/utils/terminal-safe.js +53 -0
  258. package/dist/utils/terminal-safe.js.map +1 -0
  259. package/docs/ATTRIBUTION.md +1 -0
  260. package/docs/DASHBOARD_UPLIFT_SPEC.md +8 -8
  261. package/docs/INDEX.md +6 -5
  262. package/docs/architecture/OVERVIEW.md +15 -3
  263. package/docs/architecture/PROTOCOL.md +7 -1
  264. package/docs/architecture/adr/0006-operator-directed-model-placement.md +86 -0
  265. package/docs/architecture/adr/0007-auto-displacement-allowlist-as-standing-consent.md +79 -0
  266. package/docs/architecture/adr/0008-fleet-clients-link-out.md +92 -0
  267. package/docs/architecture/adr/0009-completion-triggered-dispatch-and-per-leaf-model-dials.md +71 -0
  268. package/docs/architecture/adr/0010-attention-queue-and-afk-auto-decider.md +159 -0
  269. package/docs/architecture/adr/0011-sandbox-grants-and-network-egress-boundary.md +219 -0
  270. package/docs/architecture/adr/0012-operator-legibility-surfaces.md +175 -0
  271. package/docs/getting-started/INSTALLATION.md +1 -1
  272. package/docs/guides/AUTOMATIC_FEATURES.md +8 -0
  273. package/docs/guides/LOCAL_MODELS.md +1 -1
  274. package/docs/guides/PROXY.md +2 -1
  275. package/docs/guides/SANDBOX.md +91 -4
  276. package/docs/integrations/MCP_ROUTER.md +1 -1
  277. package/docs/plans/model-performance-proof-options.md +176 -0
  278. package/docs/reference/API.md +1 -1
  279. package/docs/reference/CLI.md +169 -4
  280. package/docs/reference/COMPARISON.md +1 -1
  281. package/docs/reference/CONFIGURATION.md +12 -3
  282. package/docs/reference/CONFIGURATION_REFERENCE.md +269 -14
  283. package/docs/reference/DATABASE_SCHEMA.md +1 -1
  284. package/docs/reference/FEATURES.md +15 -3
  285. package/docs/reference/PATTERNS.md +1 -1
  286. package/docs/reference/PLATFORMS.md +14 -1
  287. package/docs/specs/operator-model-placement.md +1314 -0
  288. package/docs/specs/operator-model-placement.review.md +339 -0
  289. package/package.json +2 -2
  290. package/scripts/maintenance/publish-npm.sh +23 -11
  291. package/scripts/version-bump.sh +6 -51
  292. package/src/policies/enforcers/__pycache__/_common.cpython-311.pyc +0 -0
  293. package/src/policies/enforcers/__pycache__/expert_review_required.cpython-311.pyc +0 -0
  294. package/src/policies/enforcers/__pycache__/quality_metrics_gate.cpython-311.pyc +0 -0
  295. package/src/policies/schemas/policies/mandatory-testing-deployment.md +2 -5
  296. package/templates/hooks/__pycache__/deliver_autoroute.cpython-311.pyc +0 -0
  297. package/templates/hooks/session-start.sh +44 -0
  298. package/tools/agents/UAP/cli.py +14 -3
  299. package/tools/agents/UAP/paths.py +50 -0
  300. package/tools/agents/scripts/__pycache__/toolcall_path_normalizer.cpython-311.pyc +0 -0
  301. package/tools/agents/scripts/anthropic_proxy.py +1386 -99
  302. package/tools/agents/scripts/toolcall_path_normalizer.py +46 -1
  303. package/tools/agents/tests/test_anthropic_proxy_streaming.py +29 -6
  304. package/tools/agents/tests/test_doubling_break.py +64 -5
  305. package/tools/agents/tests/test_error_loop_break.py +10 -4
  306. package/tools/agents/tests/test_error_loop_ignores_correctives.py +13 -2
  307. package/tools/agents/tests/test_lockstep_escalation.py +280 -9
  308. package/tools/agents/tests/test_path_containment.py +94 -0
  309. package/tools/agents/tests/test_proxy_unix_socket.py +162 -0
  310. package/tools/agents/tests/test_signature_quality.py +349 -0
  311. package/tools/agents/tests/test_uap_compliance.py +11 -1
  312. package/web/dash/core.js +1 -0
  313. package/web/dash/tab-clients.js +196 -0
  314. package/web/dash/tab-models.js +387 -8
  315. package/web/dash/tab-policies.js +87 -4
  316. package/web/dash/tabs.js +77 -73
  317. package/web/dashboard.html +6 -0
  318. package/src/policies/enforcers/__pycache__/_common.cpython-312.pyc +0 -0
  319. package/src/policies/enforcers/__pycache__/expert_review_required.cpython-312.pyc +0 -0
  320. package/src/policies/enforcers/__pycache__/quality_metrics_gate.cpython-312.pyc +0 -0
  321. package/templates/hooks/__pycache__/deliver_autoroute.cpython-312.pyc +0 -0
  322. package/tools/agents/scripts/__pycache__/toolcall_path_normalizer.cpython-312.pyc +0 -0
  323. package/web/dash/tab-agents.js +0 -21
  324. package/web/dash/tab-deliver.js +0 -12
  325. package/web/dash/tab-memory.js +0 -12
  326. package/web/dash/tab-orchestration.js +0 -21
  327. package/web/dash/tab-overview.js +0 -21
  328. package/web/dash/tab-tasks.js +0 -21
@@ -12,7 +12,10 @@ path to the nearest path the model already used correctly earlier in the
12
12
  conversation — filesystem-free, and the proxy is in the request path.
13
13
 
14
14
  Pure functions; gated by PROXY_TOOLCALL_PATH_NORMALIZE in the proxy. Mirrors the
15
- TS reference at src/self-harness/middleware/path-normalizer.ts.
15
+ TS reference at src/self-harness/middleware/path-normalizer.ts (the filename-snap
16
+ normalize only). Containment -- contain_tool_uses / contain_to_workdir,
17
+ including the container-exec exemption -- is proxy-side Python ONLY, with no
18
+ TS counterpart; a TS parity sync must not treat containment as mirrored.
16
19
  See docs/design/SELF_HARNESS.md §4 (P2).
17
20
  """
18
21
 
@@ -25,6 +28,39 @@ _PATH_ARG_KEYS = ("file_path", "path", "filePath", "notebook_path")
25
28
  # Absolute paths a tool call might target. Used to scan/rewrite Bash commands.
26
29
  _ABS_PATH_RE = re.compile(r"/(?:home|root|Users|tmp|var|opt|srv|mnt)/[A-Za-z0-9._\-/]+")
27
30
 
31
+ # Container-exec contexts: docker exec (incl. 'docker compose exec' -- the
32
+ # docker word already prefixes it), podman exec, nerdctl exec, kubectl exec.
33
+ # Paths at-or-after this boundary belong to ANOTHER filesystem -- the
34
+ # container's, not the host's -- so the host-workdir heuristic cannot reason
35
+ # about them. Containing them manufactures a guaranteed failure: observed
36
+ # 2026-10-18, an 11-hour loop where every
37
+ # "docker exec p37koral bash -lc 'cat /tmp/e2e/...log'" had its container
38
+ # path snapped onto the host workdir (/tmp/p37/e2e/...), which cannot exist
39
+ # inside the container, so the command failed no matter what the model
40
+ # varied. The exemption is fail-safe: an un-rewritten path that IS garbled
41
+ # errors loudly inside the container, and the loop guards steer on it.
42
+ #
43
+ # Scope decisions, pinned by tests in test_path_containment.py -- do not
44
+ # "tighten" without re-reading the incident:
45
+ # * the boundary is the END of the exec word (engine flags before the
46
+ # subcommand, e.g. kubectl --kubeconfig=/host.conf, are host-side);
47
+ # * the exemption extends to the END OF THE COMMAND, never the next
48
+ # separator -- quoted exec payloads (bash -lc 'cat /a; cat /b') carry
49
+ # separators a regex cannot scope, so per-segment scoping would
50
+ # reintroduce the incident class;
51
+ # * {0,1000} bounds the lazy scan (a capped-out match simply fails and
52
+ # paths stay contained -- fail-safe);
53
+ # * known residual shapes, accepted as fail-loud for now: env-assignment
54
+ # BEFORE the boundary (LOG=/x docker exec ...), backslash
55
+ # line-continuation between engine word and exec, docker cp / docker
56
+ # run dest-side paths, multi-line spellings. Broaden only on incident
57
+ # evidence (candidates: nsenter/chroot/unshare/lxc, docker cp / kubectl
58
+ # cp dest side).
59
+ _CONTAINER_EXEC_RE = re.compile(
60
+ r"\b(?:docker|podman|nerdctl|kubectl)\b[^\n;&|]{0,1000}?\bexec\b",
61
+ re.IGNORECASE,
62
+ )
63
+
28
64
 
29
65
  def _squash(s: str) -> str:
30
66
  return re.sub(r"[^a-z0-9]", "", s.lower())
@@ -276,7 +312,16 @@ def contain_tool_uses(tool_uses, workdir: str):
276
312
  corrections.append((tu_id, key, v, nv, reason))
277
313
  cmd = inp.get("command")
278
314
  if isinstance(cmd, str) and "/" in cmd:
315
+ exec_m = _CONTAINER_EXEC_RE.search(cmd)
316
+ # Boundary = END of the exec word: engine flags BEFORE the
317
+ # subcommand are host-side and stay contained; everything from
318
+ # the subcommand on is container-side (see _CONTAINER_EXEC_RE).
319
+ exec_at = exec_m.end() if exec_m else None
279
320
  def _sub(m):
321
+ # At-or-after a container-exec boundary the token is a
322
+ # container-FS path: exempt (see _CONTAINER_EXEC_RE).
323
+ if exec_at is not None and m.start() >= exec_at:
324
+ return m.group(0)
280
325
  nv, changed, _ = contain_to_workdir(m.group(0), workdir)
281
326
  return nv if changed else m.group(0)
282
327
  new_cmd = _ABS_PATH_RE.sub(_sub, cmd)
@@ -4306,15 +4306,32 @@ class TestGarbledArgsRetry(unittest.TestCase):
4306
4306
  """Tests for garbled tool arguments triggering retry via _validate_tool_call_arguments."""
4307
4307
 
4308
4308
  def test_garbled_runaway_braces_triggers_retry(self):
4309
- """Garbled brace imbalance should return an invalid_tool_args issue."""
4310
- # Valid JSON but with extreme brace imbalance in string value
4311
- garbled_args = '{"todos": "}}}}}}}}}}}}}"}'
4309
+ """Unparseable runaway braces should return an invalid_tool_args issue.
4310
+
4311
+ Valid JSON with braces inside a string VALUE must NOT be flagged: a
4312
+ live payload (2026-10-03) carried a Rust code excerpt with unmatched
4313
+ closing braces in oldString, and the raw balance check burned three
4314
+ session-contamination resets on a perfectly valid payload before the
4315
+ check was gated behind JSON-parse failure.
4316
+ """
4317
+ # Truncated payload: the JSON closes, then runaway closers follow —
4318
+ # unparseable. The JSON step catches it BEFORE the garbled classifier,
4319
+ # so the issue names the syntax error (still invalid_tool_args, still
4320
+ # retried) rather than "garbled".
4321
+ garbled_args = '{"todos": "cut mid-generation"}}}}}}}'
4312
4322
  issue = proxy._validate_tool_call_arguments(
4313
4323
  "TodoWrite", garbled_args, {}, {"TodoWrite"}
4314
4324
  )
4315
4325
  self.assertTrue(issue.has_issue())
4316
4326
  self.assertEqual(issue.kind, "invalid_tool_args")
4317
- self.assertIn("garbled", issue.reason)
4327
+ self.assertIn("invalid JSON arguments", issue.reason)
4328
+ # Valid JSON, runaway braces as payload CONTENT (a code excerpt) —
4329
+ # not an issue.
4330
+ excerpt_args = '{"todos": "}}}}}}}}}}}}}"}'
4331
+ issue = proxy._validate_tool_call_arguments(
4332
+ "TodoWrite", excerpt_args, {}, {"TodoWrite"}
4333
+ )
4334
+ self.assertFalse(issue.has_issue())
4318
4335
 
4319
4336
  def test_garbled_repetitive_digits_triggers_retry(self):
4320
4337
  """Repetitive digit patterns should return an invalid_tool_args issue."""
@@ -4632,9 +4649,15 @@ class TestRetryGarbledImprovements(unittest.TestCase):
4632
4649
 
4633
4650
  def test_garbled_args_excerpt_in_issue(self):
4634
4651
  """_is_garbled_tool_arguments detects garbled content for logging."""
4635
- # Garbled pattern: runaway braces
4636
- garbled = '{"pattern": "test}}}}}}}}}}}}}}"}'
4652
+ # Garbled pattern: runaway braces on an UNPARSEABLE payload
4653
+ # (truncated JSON + trailing closers)
4654
+ garbled = '{"pattern": "test"}}}}}}}}}}}}}}'
4637
4655
  self.assertTrue(proxy._is_garbled_tool_arguments(garbled))
4656
+ # Valid JSON with runaway braces as string CONTENT (a code excerpt) —
4657
+ # must NOT be flagged (live 2026-10-03: valid Rust excerpt, three
4658
+ # wasted contamination resets)
4659
+ excerpt = '{"pattern": "test}}}}}}}}}}}}}}"}'
4660
+ self.assertFalse(proxy._is_garbled_tool_arguments(excerpt))
4638
4661
  # Clean pattern
4639
4662
  clean = '{"pattern": "hello", "path": "/src"}'
4640
4663
  self.assertFalse(proxy._is_garbled_tool_arguments(clean))
@@ -30,6 +30,9 @@ proxy = _load_proxy_module()
30
30
  FAIL = "Error: rate limit exceeded for url"
31
31
  FAIL_OTHER = "TypeError: cannot read properties of undefined"
32
32
  OK = "all files written successfully"
33
+ # Content-free wrapper: names no failure, so no signature can anchor on it --
34
+ # the unnameable identical-call shape _declares_failure_shape now owns here.
35
+ WRAPPER_ONLY = "error: command failed (exit code: 1)"
33
36
 
34
37
 
35
38
  class TestDoublingStreakAccounting(unittest.TestCase):
@@ -116,6 +119,39 @@ class TestDoublingStreakAccounting(unittest.TestCase):
116
119
  mon.note_doubling_signal("Bash:abcd1234", body)
117
120
  self.assertEqual(mon.doubling_streak, 2)
118
121
 
122
+ def test_unnameable_content_free_failure_still_counts(self):
123
+ # Architect review 2026-10-18: an identical call failing with ONLY a
124
+ # content-free wrapper forms NO signature, so before
125
+ # _declares_failure_shape no guard owned this shape at all.
126
+ mon = proxy.SessionMonitor()
127
+ mon.note_doubling_signal("Bash:abcd1234", WRAPPER_ONLY)
128
+ mon.note_doubling_signal("Bash:abcd1234", WRAPPER_ONLY)
129
+ self.assertEqual(mon.doubling_streak, 2)
130
+
131
+ def test_bare_traceback_header_failure_still_counts(self):
132
+ mon = proxy.SessionMonitor()
133
+ mon.note_doubling_signal(
134
+ "Bash:abcd1234", "Traceback (most recent call last):"
135
+ )
136
+ mon.note_doubling_signal(
137
+ "Bash:abcd1234", "Traceback (most recent call last):"
138
+ )
139
+ self.assertEqual(mon.doubling_streak, 2)
140
+
141
+ def test_pure_output_with_no_failure_declaration_does_not_count(self):
142
+ # The declaration shapes are failure-specific: bare output text is
143
+ # not one, so a healthy identical call still resets the streak.
144
+ mon = proxy.SessionMonitor()
145
+ mon.note_doubling_signal("Bash:abcd1234", FAIL)
146
+ mon.note_doubling_signal("Bash:abcd1234", "Compiling main.c")
147
+ self.assertEqual(mon.doubling_streak, 0)
148
+
149
+ def test_zero_exit_code_is_success_not_failure(self):
150
+ mon = proxy.SessionMonitor()
151
+ mon.note_doubling_signal("Bash:abcd1234", "exit code 0")
152
+ mon.note_doubling_signal("Bash:abcd1234", "exit code 0")
153
+ self.assertEqual(mon.doubling_streak, 0)
154
+
119
155
  def test_reset_doubling_clears_state(self):
120
156
  mon = proxy.SessionMonitor()
121
157
  mon.note_doubling_signal("Bash:abcd1234", FAIL)
@@ -145,7 +181,16 @@ class TestDoublingInjection(unittest.TestCase):
145
181
  body = _body()
146
182
  proxy._maybe_inject_doubling_break(body, mon)
147
183
  self.assertEqual(mon.doubling_break_fires, 1)
148
- self.assertIn("PIVOT", body["messages"][0]["content"])
184
+ # Tail placement (the salience position LOCKSTEP/ERROR-LOOP forced):
185
+ # the directive rides the transcript tail as a user-role turn, never a
186
+ # system-top append -- the 2026-10-07 ledger shows system-top
187
+ # directives ignored twice en route to a hard stop.
188
+ tail = body["messages"][-1]
189
+ self.assertEqual(tail["role"], "user")
190
+ self.assertIn("PIVOT", tail["content"])
191
+ # the system prompt is untouched -- no system-0 mangling
192
+ self.assertEqual(body["messages"][0]["role"], "system")
193
+ self.assertEqual(body["messages"][0]["content"], "base")
149
194
  # Advisory: never touches tool_choice (unlike deferral-break).
150
195
  self.assertEqual(body["tool_choice"], "auto")
151
196
 
@@ -181,12 +226,26 @@ class TestDoublingInjection(unittest.TestCase):
181
226
  proxy._maybe_inject_doubling_break(body, mon)
182
227
  self.assertEqual(mon.doubling_break_fires, 0)
183
228
 
184
- def test_inserts_system_message_when_absent(self):
229
+ def test_appends_tail_directive_and_keeps_trailing_prefill_last(self):
230
+ # No system message exists to append to, and a trailing assistant
231
+ # message is a client prefill (a continuation contract): the directive
232
+ # goes in front of it, never after -- same contract as LOCKSTEP.
185
233
  mon = _saturated_monitor()
186
- body = {"tools": [], "messages": [{"role": "user", "content": "hi"}]}
234
+ body = {
235
+ "tools": [],
236
+ "messages": [
237
+ {"role": "user", "content": "hi"},
238
+ {"role": "assistant", "content": "the plan is "},
239
+ ],
240
+ }
187
241
  proxy._maybe_inject_doubling_break(body, mon)
188
- self.assertEqual(body["messages"][0]["role"], "system")
189
- self.assertIn("PIVOT", body["messages"][0]["content"])
242
+ prefill = body["messages"][-1]
243
+ directive = body["messages"][-2]
244
+ self.assertEqual(prefill["role"], "assistant")
245
+ self.assertEqual(prefill["content"], "the plan is ") # untouched
246
+ self.assertEqual(directive["role"], "user")
247
+ self.assertIn("PIVOT", directive["content"])
248
+ self.assertEqual(body["messages"][0]["content"], "hi")
190
249
 
191
250
 
192
251
  class TestRecordingWiring(unittest.TestCase):
@@ -68,10 +68,16 @@ class InjectionTests(unittest.TestCase):
68
68
  self.m.note_tool_result_error("SyntaxError: already declared\n at x:1")
69
69
  body = {"messages": [{"role": "user", "content": "go"}], "tool_choice": "required"}
70
70
  proxy._maybe_inject_error_loop_break(body, self.m)
71
- sysmsg = body["messages"][0]
72
- self.assertEqual(sysmsg["role"], "system")
73
- self.assertIn("SAME failure", sysmsg["content"])
74
- self.assertIn("re-read", sysmsg["content"].lower())
71
+ # Tail user-role turn (the salient placement LOCKSTEP forced; the
72
+ # 2026-09-24 opencode+qwen log shows system-top directives ignored 3x
73
+ # en route to every hard stop), not a system-top append.
74
+ tail = body["messages"][-1]
75
+ self.assertEqual(tail["role"], "user")
76
+ self.assertIn("SAME failure", tail["content"])
77
+ self.assertIn("re-read", tail["content"].lower())
78
+ # the original messages are untouched -- no system-0 mangling
79
+ self.assertEqual(body["messages"][0]["role"], "user")
80
+ self.assertEqual(body["messages"][0]["content"], "go")
75
81
  # advisory: does NOT release tool_choice
76
82
  self.assertEqual(body["tool_choice"], "required")
77
83
  self.assertEqual(self.m.error_loop_fires, 1)
@@ -46,7 +46,14 @@ def _load_signature():
46
46
  _error_signature, rather than cherry-picking names. The previous version
47
47
  exec'd a hardcoded list of regexes, so adding a helper to the proxy left the
48
48
  test raising NameError against its own source -- the loader had to be edited
49
- in lockstep with any new dependency."""
49
+ in lockstep with any new dependency.
50
+
51
+ Placement contract (review condition 2026-10-18): every constant and
52
+ helper _error_signature/_error_signature_for_result touches must live
53
+ INSIDE this slice -- between _ERROR_LINE_RE and the first "# ---" section
54
+ marker after them. A helper that drifts below the marker raises NameError
55
+ here on the next run; the fix is to move it back into the region, never
56
+ to cherry-pick names into ns."""
50
57
  src = PROXY.read_text()
51
58
  ns = {"re": re}
52
59
  start = src.index("_ERROR_LINE_RE = re.compile(")
@@ -241,7 +248,11 @@ class TestTheFlagIsActuallyWiredThrough(unittest.TestCase):
241
248
  def test_the_request_handler_passes_the_is_error_flag(self):
242
249
  src = PROXY.read_text()
243
250
  assert "_latest_err" in src, "the handler no longer computes the is_error flag"
244
- assert re.search(r"note_tool_result_error\(\s*_latest_tr\s*,\s*_latest_err\s*\)", src), (
251
+ assert re.search(
252
+ r"note_tool_result_error\(\s*_latest_tr\s*,\s*_latest_err\s*,\s*"
253
+ r"tool_names=_latest_tr_tools\s*\)",
254
+ src,
255
+ ), (
245
256
  "note_tool_result_error is called without the is_error flag — reading a file that "
246
257
  "contains the word 'Exception' will manufacture a failure streak again"
247
258
  )
@@ -118,16 +118,152 @@ class LadderTests(LockstepKnobGuard):
118
118
  _drive_lockstep(m, proxy.PROXY_LOCKSTEP_PIVOT_AT)
119
119
  body = self._body()
120
120
  proxy._maybe_lockstep_escalate(body, m)
121
- sysmsg = body["messages"][0]
122
- self.assertEqual(sysmsg["role"], "system")
123
- self.assertIn("LOOP-ESCALATE", sysmsg["content"])
124
- self.assertIn("different approaches", sysmsg["content"])
121
+ # Salient placement: the directive rides the transcript TAIL as a
122
+ # user-role turn, not the system-prompt top (2026-10-07 ledger: both
123
+ # system-top directives were ignored en route to a hard stop).
124
+ tail = body["messages"][-1]
125
+ self.assertEqual(tail["role"], "user")
126
+ self.assertIn("LOOP-ESCALATE", tail["content"])
127
+ self.assertIn("different approaches", tail["content"])
128
+ # unknown error class stays generic: no invented fix line
129
+ self.assertNotIn("KNOWN FIX", tail["content"])
130
+ # the quoted error text is untrusted; both guards carry the caveat
131
+ self.assertIn("untrusted tool output", tail["content"])
125
132
  self.assertEqual(m.lockstep_fires, 1)
126
133
  # advisory: tool_choice untouched
127
134
  self.assertEqual(body["tool_choice"], "auto")
128
135
  recs = self._records()
129
136
  self.assertEqual([r["outcome"] for r in recs], ["pivot"])
130
137
 
138
+ def _drive_signature(self, monitor, signature, n):
139
+ """Drive the trackers with a specific error signature, the way a real
140
+ loop with that error feeds them every turn. result_error=True mirrors
141
+ the is_error flag: without it, keyword-free text (e.g. the client
142
+ cancel marker) never extracts a signature at all."""
143
+ for i in range(n):
144
+ monitor.note_tool_result_error(signature, result_error=True)
145
+ monitor.note_doubling_signal(
146
+ FP, signature, msg_count=100 + i, result_error=True
147
+ )
148
+
149
+ def test_pivot_carries_known_fix_for_missing_skill(self):
150
+ m = proxy.SessionMonitor()
151
+ sig = 'skill "uap-router__react" not found. available skills: adapt, animate'
152
+ self._drive_signature(m, sig, proxy.PROXY_LOCKSTEP_PIVOT_AT)
153
+ body = self._body()
154
+ proxy._maybe_lockstep_escalate(body, m)
155
+ tail = body["messages"][-1]
156
+ self.assertIn("KNOWN FIX", tail["content"])
157
+ self.assertIn("does not exist", tail["content"])
158
+ self.assertIn("available-skills list", tail["content"])
159
+
160
+ def test_pivot_carries_known_fix_for_invalid_status(self):
161
+ m = proxy.SessionMonitor()
162
+ sig = "error: todo item #: status must be pending, in_progress, or completed"
163
+ self._drive_signature(m, sig, proxy.PROXY_LOCKSTEP_PIVOT_AT)
164
+ body = self._body()
165
+ proxy._maybe_lockstep_escalate(body, m)
166
+ tail = body["messages"][-1]
167
+ self.assertIn("KNOWN FIX", tail["content"])
168
+ self.assertIn("pending, in_progress, completed", tail["content"])
169
+
170
+ def test_pivot_carries_known_fix_for_interrupted_call(self):
171
+ m = proxy.SessionMonitor()
172
+ sig = "[tool execution was interrupted]"
173
+ self._drive_signature(m, sig, proxy.PROXY_LOCKSTEP_PIVOT_AT)
174
+ body = self._body()
175
+ proxy._maybe_lockstep_escalate(body, m)
176
+ tail = body["messages"][-1]
177
+ self.assertIn("KNOWN FIX", tail["content"])
178
+ self.assertIn("interrupted before it ran", tail["content"])
179
+
180
+ def test_pivot_carries_known_fix_for_edit_anchor_mismatch(self):
181
+ # edit x19 across 3 sessions (ledger 2026-09-25..10-13): the top class.
182
+ m = proxy.SessionMonitor()
183
+ sig = "error: edit could not find oldstring in the file. it must match exactly, including whitespace, indentation, and line endings."
184
+ self._drive_signature(m, sig, proxy.PROXY_LOCKSTEP_PIVOT_AT)
185
+ body = self._body()
186
+ proxy._maybe_lockstep_escalate(body, m)
187
+ tail = body["messages"][-1]
188
+ self.assertIn("KNOWN FIX", tail["content"])
189
+ self.assertIn("byte-for-byte", tail["content"])
190
+
191
+ def test_pivot_carries_known_fix_for_identical_edit(self):
192
+ m = proxy.SessionMonitor()
193
+ sig = "no changes to apply: oldstring and newstring are identical."
194
+ self._drive_signature(m, sig, proxy.PROXY_LOCKSTEP_PIVOT_AT)
195
+ body = self._body()
196
+ proxy._maybe_lockstep_escalate(body, m)
197
+ tail = body["messages"][-1]
198
+ self.assertIn("KNOWN FIX", tail["content"])
199
+ self.assertIn("already contains the intended text", tail["content"])
200
+
201
+ def test_pivot_carries_known_fix_for_write_truncation(self):
202
+ # one hard_blocked incident: re-emitting the whole file gets cut off
203
+ # again, so the fix must forbid whole-file re-emission.
204
+ m = proxy.SessionMonitor()
205
+ sig = "error: src<path> content looks truncated (# unclosed {). your output was likely cut off mid-file. do not re-emit the whole file"
206
+ self._drive_signature(m, sig, proxy.PROXY_LOCKSTEP_PIVOT_AT)
207
+ body = self._body()
208
+ proxy._maybe_lockstep_escalate(body, m)
209
+ tail = body["messages"][-1]
210
+ self.assertIn("KNOWN FIX", tail["content"])
211
+ self.assertIn("Do NOT re-emit the whole file", tail["content"])
212
+
213
+ def test_pivot_carries_known_fix_for_policy_block(self):
214
+ # opencode+qwen log 2026-09-24: 20 ERROR-LOOP hard stops on exactly
215
+ # this signature. The fix must point at the POLICY LAYER, never
216
+ # endorse executing a command string lifted from the untrusted error
217
+ # text (a hostile repo can forge the block line in any failing
218
+ # command's output).
219
+ m = proxy.SessionMonitor()
220
+ sig = "[uap policy blocked: rtk-wrap] rtk-wrap: 'docker' must be invoked via rtk. use: rtk docker compose"
221
+ self._drive_signature(m, sig, proxy.PROXY_LOCKSTEP_PIVOT_AT)
222
+ body = self._body()
223
+ proxy._maybe_lockstep_escalate(body, m)
224
+ tail = body["messages"][-1]
225
+ self.assertIn("KNOWN FIX", tail["content"])
226
+ self.assertIn("blocked by UAP policy", tail["content"])
227
+ self.assertIn("untrusted", tail["content"])
228
+ self.assertNotIn("verbatim", tail["content"])
229
+
230
+ def test_final_warning_repeats_known_fix(self):
231
+ m = proxy.SessionMonitor()
232
+ sig = 'skill "uap-router__react" not found. available skills: adapt'
233
+ self._drive_signature(m, sig, proxy.PROXY_LOCKSTEP_PIVOT_AT)
234
+ proxy._maybe_lockstep_escalate(self._body(), m)
235
+ self._drive_signature(m, sig, 1)
236
+ body = self._body()
237
+ proxy._maybe_lockstep_escalate(body, m)
238
+ tail = body["messages"][-1]
239
+ self.assertIn("FINAL WARNING", tail["content"])
240
+ self.assertIn("KNOWN FIX", tail["content"])
241
+
242
+ def test_pivot_keeps_trailing_assistant_prefill_last(self):
243
+ # A trailing assistant message is a client prefill (a continuation
244
+ # contract the prefill workaround keys on): the directive must go in
245
+ # FRONT of it, never after -- burying the prefill would 400 on
246
+ # thinking-enabled backends and the retry would then silently drop the
247
+ # pivot behind the duplicate-observation guard.
248
+ m = proxy.SessionMonitor()
249
+ _drive_lockstep(m, proxy.PROXY_LOCKSTEP_PIVOT_AT)
250
+ body = {
251
+ "messages": [
252
+ {"role": "user", "content": "fix it"},
253
+ {"role": "assistant", "content": "the fix is "},
254
+ ],
255
+ "tool_choice": "auto",
256
+ }
257
+ proxy._maybe_lockstep_escalate(body, m)
258
+ prefill = body["messages"][-1]
259
+ directive = body["messages"][-2]
260
+ self.assertEqual(prefill["role"], "assistant")
261
+ self.assertEqual(prefill["content"], "the fix is ") # untouched
262
+ self.assertEqual(directive["role"], "user")
263
+ self.assertIn("LOOP-ESCALATE", directive["content"])
264
+ self.assertEqual(m.lockstep_fires, 1)
265
+ self.assertEqual([r["outcome"] for r in self._records()], ["pivot"])
266
+
131
267
  def test_final_warning_on_second_fire(self):
132
268
  m = proxy.SessionMonitor()
133
269
  _drive_lockstep(m, proxy.PROXY_LOCKSTEP_PIVOT_AT)
@@ -135,7 +271,7 @@ class LadderTests(LockstepKnobGuard):
135
271
  _drive_lockstep(m, 1)
136
272
  body = self._body()
137
273
  proxy._maybe_lockstep_escalate(body, m)
138
- self.assertIn("FINAL WARNING", body["messages"][0]["content"])
274
+ self.assertIn("FINAL WARNING", body["messages"][-1]["content"])
139
275
  self.assertEqual(m.lockstep_fires, 2)
140
276
  self.assertEqual(
141
277
  [r["outcome"] for r in self._records()], ["pivot", "final_warning"]
@@ -231,8 +367,8 @@ class LadderTests(LockstepKnobGuard):
231
367
  m.note_doubling_signal(fp_b, "Error: EACCES", msg_count=1000 + i, result_error=True)
232
368
  body = self._body()
233
369
  proxy._maybe_lockstep_escalate(body, m)
234
- self.assertIn("LOOP-ESCALATE", body["messages"][0]["content"])
235
- self.assertNotIn("FINAL WARNING", body["messages"][0]["content"])
370
+ self.assertIn("LOOP-ESCALATE", body["messages"][-1]["content"])
371
+ self.assertNotIn("FINAL WARNING", body["messages"][-1]["content"])
236
372
  self.assertEqual(m.lockstep_fires, 1)
237
373
  self.assertEqual(
238
374
  [r["outcome"] for r in self._records()],
@@ -264,6 +400,138 @@ class YieldTests(LockstepKnobGuard):
264
400
  self.assertEqual(m.error_loop_fires, 1)
265
401
 
266
402
 
403
+ class ErrorLoopDirectiveTests(LockstepKnobGuard):
404
+ """ERROR-LOOP's directive moved from system-top to the tail user turn (the
405
+ same salience fix the 2026-10-07 ledger forced for LOCKSTEP) and carries a
406
+ class-specific KNOWN FIX when the data has one. The opencode+qwen log
407
+ (2026-09-24) shows 20 ERROR-LOOP hard stops where 3 injected system-top
408
+ directives were ignored en route to each stop -- the generic re-read advice
409
+ is not merely useless for those classes, it is wrong."""
410
+
411
+ def _body(self):
412
+ return {
413
+ "messages": [
414
+ {"role": "user", "content": "fix it"},
415
+ {"role": "assistant", "content": "editing"},
416
+ {"role": "tool", "content": "err"},
417
+ ],
418
+ "tool_choice": "auto",
419
+ }
420
+
421
+ def _drive_error_loop(self, monitor, signature):
422
+ # varied edits, same error: error streak builds, doubling streak stays
423
+ # 0, so lockstep never arms and ERROR-LOOP owns the shape.
424
+ for _ in range(proxy.PROXY_ERROR_LOOP_THRESHOLD):
425
+ monitor.note_tool_result_error(signature, result_error=True)
426
+ self.assertFalse(proxy._lockstep_active(monitor))
427
+
428
+ def test_directive_at_tail_not_system_top(self):
429
+ m = proxy.SessionMonitor()
430
+ sig = "error: edit could not find oldstring in the file. it must match exactly"
431
+ self._drive_error_loop(m, sig)
432
+ body = self._body()
433
+ proxy._maybe_inject_error_loop_break(body, m)
434
+ tail = body["messages"][-1]
435
+ self.assertEqual(tail["role"], "user") # tail user turn, not system-top
436
+ self.assertIn("STOP", tail["content"])
437
+ self.assertIn("could not find oldstring", tail["content"])
438
+ # the original messages are untouched -- no system-0 mangling
439
+ self.assertEqual(body["messages"][0]["role"], "user")
440
+ self.assertEqual(body["messages"][0]["content"], "fix it")
441
+ self.assertEqual(m.error_loop_fires, 1)
442
+
443
+ def test_directive_before_trailing_assistant_prefill(self):
444
+ # Same prefill contract as LOCKSTEP: a trailing assistant message is
445
+ # a continuation contract; the directive goes in front of it, never
446
+ # after (burying the prefill would 400 on thinking-enabled backends).
447
+ m = proxy.SessionMonitor()
448
+ sig = "error: edit could not find oldstring in the file. it must match exactly"
449
+ self._drive_error_loop(m, sig)
450
+ body = {
451
+ "messages": [
452
+ {"role": "user", "content": "fix it"},
453
+ {"role": "assistant", "content": "the fix is "},
454
+ ],
455
+ "tool_choice": "auto",
456
+ }
457
+ proxy._maybe_inject_error_loop_break(body, m)
458
+ prefill = body["messages"][-1]
459
+ directive = body["messages"][-2]
460
+ self.assertEqual(prefill["role"], "assistant")
461
+ self.assertEqual(prefill["content"], "the fix is ") # untouched
462
+ self.assertEqual(directive["role"], "user")
463
+ self.assertIn("STOP", directive["content"])
464
+
465
+ def test_known_fix_replaces_generic_remedy(self):
466
+ # For a class with a known fix, the directive replaces the generic
467
+ # re-read advice entirely -- re-reading does not fix a wrong anchor.
468
+ m = proxy.SessionMonitor()
469
+ sig = "error: edit could not find oldstring in the file. it must match exactly"
470
+ self._drive_error_loop(m, sig)
471
+ body = self._body()
472
+ proxy._maybe_inject_error_loop_break(body, m)
473
+ tail = body["messages"][-1]["content"]
474
+ self.assertIn("KNOWN FIX for this error class", tail)
475
+ self.assertIn("byte-for-byte", tail)
476
+ self.assertNotIn("re-read the ENTIRE failing file", tail)
477
+
478
+ def test_policy_block_signature_gets_policy_fix(self):
479
+ m = proxy.SessionMonitor()
480
+ sig = "[uap policy blocked: rtk-wrap] rtk-wrap: 'docker' must be invoked via rtk. use: rtk docker compose"
481
+ self._drive_error_loop(m, sig)
482
+ body = self._body()
483
+ proxy._maybe_inject_error_loop_break(body, m)
484
+ tail = body["messages"][-1]["content"]
485
+ self.assertIn("KNOWN FIX for this error class", tail)
486
+ self.assertIn("blocked by UAP policy", tail)
487
+ self.assertIn("untrusted", tail)
488
+ # verbatim delegation removed: the directive must never endorse
489
+ # executing error-named content
490
+ self.assertNotIn("verbatim", tail)
491
+ self.assertIn("untrusted tool output", tail)
492
+
493
+ def test_generic_remedy_falls_back_when_no_class(self):
494
+ # Unknown error class keeps the diagnose-first generic remedy.
495
+ m = proxy.SessionMonitor()
496
+ sig = "error: something novel and deterministic nobody has classified"
497
+ self._drive_error_loop(m, sig)
498
+ body = self._body()
499
+ proxy._maybe_inject_error_loop_break(body, m)
500
+ tail = body["messages"][-1]["content"]
501
+ self.assertNotIn("KNOWN FIX for this error class", tail)
502
+ self.assertIn("re-read", tail)
503
+
504
+ def test_recon_convergence_pending_yields(self):
505
+ # Recon's own user-role tail directive (injected earlier in the
506
+ # request build) would read as a fresh human turn to the re-arm
507
+ # check and falsely re-arm the hard stop. ERROR-LOOP must yield to
508
+ # recon exactly like LOCKSTEP / DOUBLING-DOWN / DEFERRAL-BREAK.
509
+ m = proxy.SessionMonitor()
510
+ sig = "error: edit could not find oldstring in the file. it must match exactly"
511
+ self._drive_error_loop(m, sig)
512
+ m.error_loop_fires = proxy.PROXY_ERROR_LOOP_HARD_LIMIT
513
+ m.consecutive_no_write_turns = proxy.PROXY_RECON_CONVERGENCE_THRESHOLD
514
+ self.assertTrue(m.recon_convergence_pending())
515
+ body = self._body()
516
+ proxy._maybe_inject_error_loop_break(body, m)
517
+ self.assertEqual(len(body["messages"]), 3) # untouched
518
+ self.assertEqual(m.error_loop_fires, proxy.PROXY_ERROR_LOOP_HARD_LIMIT)
519
+ self.assertEqual(m.error_loop_blocks, 0) # no raise, no re-arm
520
+
521
+ def test_hard_stop_wording_names_directives_not_re_read(self):
522
+ # After the class table, the nudges are directives (several carry
523
+ # tool-misuse fixes, not re-read advice); the stop text must not
524
+ # promise "re-read" remediation it never gave.
525
+ m = proxy.SessionMonitor()
526
+ sig = "error: edit could not find oldstring in the file. it must match exactly"
527
+ self._drive_error_loop(m, sig)
528
+ m.error_loop_fires = proxy.PROXY_ERROR_LOOP_HARD_LIMIT
529
+ with self.assertRaises(proxy.ErrorLoopHardBlock) as ctx:
530
+ proxy._maybe_inject_error_loop_break(self._body(), m)
531
+ self.assertIn("injected directives were ignored", str(ctx.exception))
532
+ self.assertNotIn("re-read directives", str(ctx.exception))
533
+
534
+
267
535
  class IncidentRecordTests(LockstepKnobGuard):
268
536
  def test_record_shape_and_arg_redaction(self):
269
537
  m = proxy.SessionMonitor()
@@ -281,8 +549,11 @@ class IncidentRecordTests(LockstepKnobGuard):
281
549
  self.assertTrue(rec["fingerprint"])
282
550
  self.assertNotIn(FP, json.dumps(rec)) # raw args never persisted
283
551
  self.assertNotIn("ssh host", json.dumps(rec))
284
- # the persisted signature is the normalized one (literals stripped)
285
- self.assertIn("traceback", rec["error_signature"])
552
+ # the persisted signature is the normalized DECISIVE line (literals
553
+ # stripped). The content-free traceback header no longer anchors a
554
+ # streak -- ledger 2026-09-25 -- so the ledger record names the real
555
+ # error the session was hard-stopped over.
556
+ self.assertEqual(rec["error_signature"], "keyerror: 'x'")
286
557
 
287
558
  def test_ledger_disabled_by_empty_path(self):
288
559
  proxy.UAP_LOOP_INCIDENTS = ""