@miller-tech/uap 2.21.3 → 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.
- package/README.md +22 -10
- package/config/model-profiles/qwen38-coder390.json +46 -0
- package/config/model-profiles/qwen38.json +3 -2
- package/config/model-registry.json +110 -0
- package/dist/.tsbuildinfo +1 -1
- package/dist/benchmarks/paired/types.d.ts +2 -2
- package/dist/bin/cli.js +315 -20
- package/dist/bin/cli.js.map +1 -1
- package/dist/cli/attention.d.ts +6 -0
- package/dist/cli/attention.d.ts.map +1 -0
- package/dist/cli/attention.js +115 -0
- package/dist/cli/attention.js.map +1 -0
- package/dist/cli/bench-report.d.ts +82 -0
- package/dist/cli/bench-report.d.ts.map +1 -0
- package/dist/cli/bench-report.js +237 -0
- package/dist/cli/bench-report.js.map +1 -0
- package/dist/cli/clients.d.ts +6 -0
- package/dist/cli/clients.d.ts.map +1 -0
- package/dist/cli/clients.js +142 -0
- package/dist/cli/clients.js.map +1 -0
- package/dist/cli/dashboard-grants.d.ts +26 -0
- package/dist/cli/dashboard-grants.d.ts.map +1 -0
- package/dist/cli/dashboard-grants.js +69 -0
- package/dist/cli/dashboard-grants.js.map +1 -0
- package/dist/cli/dashboard-pace.d.ts +31 -0
- package/dist/cli/dashboard-pace.d.ts.map +1 -0
- package/dist/cli/dashboard-pace.js +42 -0
- package/dist/cli/dashboard-pace.js.map +1 -0
- package/dist/cli/dashboard-session.d.ts +25 -0
- package/dist/cli/dashboard-session.d.ts.map +1 -0
- package/dist/cli/dashboard-session.js +814 -0
- package/dist/cli/dashboard-session.js.map +1 -0
- package/dist/cli/dashboard-types.d.ts +18 -0
- package/dist/cli/dashboard-types.d.ts.map +1 -0
- package/dist/cli/dashboard-types.js +9 -0
- package/dist/cli/dashboard-types.js.map +1 -0
- package/dist/cli/dashboard.d.ts +2 -18
- package/dist/cli/dashboard.d.ts.map +1 -1
- package/dist/cli/dashboard.js +43 -793
- package/dist/cli/dashboard.js.map +1 -1
- package/dist/cli/deliver.d.ts +35 -0
- package/dist/cli/deliver.d.ts.map +1 -1
- package/dist/cli/deliver.js +93 -9
- package/dist/cli/deliver.js.map +1 -1
- package/dist/cli/diff.d.ts +85 -0
- package/dist/cli/diff.d.ts.map +1 -0
- package/dist/cli/diff.js +293 -0
- package/dist/cli/diff.js.map +1 -0
- package/dist/cli/eta.d.ts +35 -0
- package/dist/cli/eta.d.ts.map +1 -0
- package/dist/cli/eta.js +101 -0
- package/dist/cli/eta.js.map +1 -0
- package/dist/cli/handsfree.d.ts +7 -0
- package/dist/cli/handsfree.d.ts.map +1 -1
- package/dist/cli/handsfree.js +27 -0
- package/dist/cli/handsfree.js.map +1 -1
- package/dist/cli/hooks.d.ts.map +1 -1
- package/dist/cli/hooks.js +126 -5
- package/dist/cli/hooks.js.map +1 -1
- package/dist/cli/loops.d.ts +24 -5
- package/dist/cli/loops.d.ts.map +1 -1
- package/dist/cli/loops.js +92 -12
- package/dist/cli/loops.js.map +1 -1
- package/dist/cli/models.d.ts +62 -0
- package/dist/cli/models.d.ts.map +1 -0
- package/dist/cli/models.js +567 -0
- package/dist/cli/models.js.map +1 -0
- package/dist/cli/plan.d.ts +9 -0
- package/dist/cli/plan.d.ts.map +1 -1
- package/dist/cli/plan.js +57 -4
- package/dist/cli/plan.js.map +1 -1
- package/dist/cli/proxy-relay.d.ts +55 -0
- package/dist/cli/proxy-relay.d.ts.map +1 -0
- package/dist/cli/proxy-relay.js +116 -0
- package/dist/cli/proxy-relay.js.map +1 -0
- package/dist/cli/proxy.d.ts +4 -0
- package/dist/cli/proxy.d.ts.map +1 -1
- package/dist/cli/proxy.js +26 -1
- package/dist/cli/proxy.js.map +1 -1
- package/dist/cli/sandbox.d.ts +101 -4
- package/dist/cli/sandbox.d.ts.map +1 -1
- package/dist/cli/sandbox.js +393 -44
- package/dist/cli/sandbox.js.map +1 -1
- package/dist/cli/self.d.ts +13 -0
- package/dist/cli/self.d.ts.map +1 -0
- package/dist/cli/self.js +94 -0
- package/dist/cli/self.js.map +1 -0
- package/dist/cli/systemd-services.d.ts +2 -8
- package/dist/cli/systemd-services.d.ts.map +1 -1
- package/dist/cli/systemd-services.js +5 -20
- package/dist/cli/systemd-services.js.map +1 -1
- package/dist/cli/task.js +1 -1
- package/dist/cli/task.js.map +1 -1
- package/dist/cli/wizard-config.d.ts.map +1 -1
- package/dist/cli/wizard-config.js +31 -2
- package/dist/cli/wizard-config.js.map +1 -1
- package/dist/config/settings-registry.d.ts +1 -1
- package/dist/config/settings-registry.d.ts.map +1 -1
- package/dist/config/settings-registry.js +108 -0
- package/dist/config/settings-registry.js.map +1 -1
- package/dist/coordination/capability-router.js +2 -2
- package/dist/coordination/capability-router.js.map +1 -1
- package/dist/coordination/database.d.ts +13 -0
- package/dist/coordination/database.d.ts.map +1 -1
- package/dist/coordination/database.js +63 -7
- package/dist/coordination/database.js.map +1 -1
- package/dist/coordination/eta-inject.d.ts +32 -0
- package/dist/coordination/eta-inject.d.ts.map +1 -0
- package/dist/coordination/eta-inject.js +64 -0
- package/dist/coordination/eta-inject.js.map +1 -0
- package/dist/coordination/reactor.d.ts.map +1 -1
- package/dist/coordination/reactor.js +14 -0
- package/dist/coordination/reactor.js.map +1 -1
- package/dist/coordination/service.d.ts +18 -8
- package/dist/coordination/service.d.ts.map +1 -1
- package/dist/coordination/service.js +38 -28
- package/dist/coordination/service.js.map +1 -1
- package/dist/dashboard/client-registry.d.ts +163 -0
- package/dist/dashboard/client-registry.d.ts.map +1 -0
- package/dist/dashboard/client-registry.js +549 -0
- package/dist/dashboard/client-registry.js.map +1 -0
- package/dist/dashboard/controls.d.ts.map +1 -1
- package/dist/dashboard/controls.js +33 -4
- package/dist/dashboard/controls.js.map +1 -1
- package/dist/dashboard/data-seeder.d.ts +10 -7
- package/dist/dashboard/data-seeder.d.ts.map +1 -1
- package/dist/dashboard/data-seeder.js +31 -75
- package/dist/dashboard/data-seeder.js.map +1 -1
- package/dist/dashboard/data-service.d.ts +9 -0
- package/dist/dashboard/data-service.d.ts.map +1 -1
- package/dist/dashboard/data-service.js +32 -3
- package/dist/dashboard/data-service.js.map +1 -1
- package/dist/dashboard/placement-routes.d.ts +119 -0
- package/dist/dashboard/placement-routes.d.ts.map +1 -0
- package/dist/dashboard/placement-routes.js +331 -0
- package/dist/dashboard/placement-routes.js.map +1 -0
- package/dist/dashboard/server.d.ts.map +1 -1
- package/dist/dashboard/server.js +408 -2
- package/dist/dashboard/server.js.map +1 -1
- package/dist/delivery/attention.d.ts +130 -0
- package/dist/delivery/attention.d.ts.map +1 -0
- package/dist/delivery/attention.js +341 -0
- package/dist/delivery/attention.js.map +1 -0
- package/dist/delivery/decompose.d.ts +18 -0
- package/dist/delivery/decompose.d.ts.map +1 -1
- package/dist/delivery/decompose.js +22 -1
- package/dist/delivery/decompose.js.map +1 -1
- package/dist/delivery/eta-estimate.d.ts +111 -0
- package/dist/delivery/eta-estimate.d.ts.map +1 -0
- package/dist/delivery/eta-estimate.js +171 -0
- package/dist/delivery/eta-estimate.js.map +1 -0
- package/dist/delivery/eta.d.ts +23 -0
- package/dist/delivery/eta.d.ts.map +1 -0
- package/dist/delivery/eta.js +59 -0
- package/dist/delivery/eta.js.map +1 -0
- package/dist/delivery/orchestrated-mission.d.ts +10 -0
- package/dist/delivery/orchestrated-mission.d.ts.map +1 -1
- package/dist/delivery/orchestrated-mission.js +15 -1
- package/dist/delivery/orchestrated-mission.js.map +1 -1
- package/dist/delivery/plan-check.d.ts +7 -1
- package/dist/delivery/plan-check.d.ts.map +1 -1
- package/dist/delivery/plan-check.js +23 -2
- package/dist/delivery/plan-check.js.map +1 -1
- package/dist/delivery/run-state.d.ts +1 -1
- package/dist/delivery/run-state.d.ts.map +1 -1
- package/dist/delivery/run-state.js +8 -1
- package/dist/delivery/run-state.js.map +1 -1
- package/dist/delivery/task-orchestrator.d.ts +8 -3
- package/dist/delivery/task-orchestrator.d.ts.map +1 -1
- package/dist/delivery/task-orchestrator.js +75 -43
- package/dist/delivery/task-orchestrator.js.map +1 -1
- package/dist/delivery/task-sync.d.ts +21 -0
- package/dist/delivery/task-sync.d.ts.map +1 -1
- package/dist/delivery/task-sync.js +44 -8
- package/dist/delivery/task-sync.js.map +1 -1
- package/dist/delivery/task-workspace.d.ts +3 -3
- package/dist/delivery/task-workspace.js +3 -3
- package/dist/generators/self-card.d.ts +65 -0
- package/dist/generators/self-card.d.ts.map +1 -0
- package/dist/generators/self-card.js +256 -0
- package/dist/generators/self-card.js.map +1 -0
- package/dist/inference/probe.js +1 -1
- package/dist/inference/probe.js.map +1 -1
- package/dist/memory/prepopulate.d.ts.map +1 -1
- package/dist/memory/prepopulate.js +2 -0
- package/dist/memory/prepopulate.js.map +1 -1
- package/dist/models/analytics.d.ts +32 -0
- package/dist/models/analytics.d.ts.map +1 -1
- package/dist/models/analytics.js +68 -5
- package/dist/models/analytics.js.map +1 -1
- package/dist/models/plan-readability.d.ts +55 -0
- package/dist/models/plan-readability.d.ts.map +1 -0
- package/dist/models/plan-readability.js +339 -0
- package/dist/models/plan-readability.js.map +1 -0
- package/dist/placement/admission.d.ts +59 -0
- package/dist/placement/admission.d.ts.map +1 -0
- package/dist/placement/admission.js +197 -0
- package/dist/placement/admission.js.map +1 -0
- package/dist/placement/auto.d.ts +44 -0
- package/dist/placement/auto.d.ts.map +1 -0
- package/dist/placement/auto.js +212 -0
- package/dist/placement/auto.js.map +1 -0
- package/dist/placement/enforce.d.ts +128 -0
- package/dist/placement/enforce.d.ts.map +1 -0
- package/dist/placement/enforce.js +655 -0
- package/dist/placement/enforce.js.map +1 -0
- package/dist/placement/idle.d.ts +50 -0
- package/dist/placement/idle.d.ts.map +1 -0
- package/dist/placement/idle.js +213 -0
- package/dist/placement/idle.js.map +1 -0
- package/dist/placement/ledger.d.ts +323 -0
- package/dist/placement/ledger.d.ts.map +1 -0
- package/dist/placement/ledger.js +263 -0
- package/dist/placement/ledger.js.map +1 -0
- package/dist/placement/preview.d.ts +51 -0
- package/dist/placement/preview.d.ts.map +1 -0
- package/dist/placement/preview.js +61 -0
- package/dist/placement/preview.js.map +1 -0
- package/dist/placement/probes.d.ts +47 -0
- package/dist/placement/probes.d.ts.map +1 -0
- package/dist/placement/probes.js +207 -0
- package/dist/placement/probes.js.map +1 -0
- package/dist/placement/proxy-env.d.ts +21 -0
- package/dist/placement/proxy-env.d.ts.map +1 -0
- package/dist/placement/proxy-env.js +42 -0
- package/dist/placement/proxy-env.js.map +1 -0
- package/dist/placement/registry.d.ts +1038 -0
- package/dist/placement/registry.d.ts.map +1 -0
- package/dist/placement/registry.js +274 -0
- package/dist/placement/registry.js.map +1 -0
- package/dist/policies/liveness.d.ts +6 -8
- package/dist/policies/liveness.d.ts.map +1 -1
- package/dist/policies/liveness.js +6 -25
- package/dist/policies/liveness.js.map +1 -1
- package/dist/policies/sandbox-anchor.d.ts +91 -0
- package/dist/policies/sandbox-anchor.d.ts.map +1 -0
- package/dist/policies/sandbox-anchor.js +187 -0
- package/dist/policies/sandbox-anchor.js.map +1 -0
- package/dist/policies/sandbox-grants.d.ts +131 -0
- package/dist/policies/sandbox-grants.d.ts.map +1 -0
- package/dist/policies/sandbox-grants.js +562 -0
- package/dist/policies/sandbox-grants.js.map +1 -0
- package/dist/state/manifest.d.ts +4 -2
- package/dist/state/manifest.d.ts.map +1 -1
- package/dist/state/manifest.js +32 -16
- package/dist/state/manifest.js.map +1 -1
- package/dist/utils/checkout-root.d.ts +2 -0
- package/dist/utils/checkout-root.d.ts.map +1 -0
- package/dist/utils/checkout-root.js +43 -0
- package/dist/utils/checkout-root.js.map +1 -0
- package/dist/utils/resolve-uap-bin.d.ts +5 -0
- package/dist/utils/resolve-uap-bin.d.ts.map +1 -0
- package/dist/utils/resolve-uap-bin.js +33 -0
- package/dist/utils/resolve-uap-bin.js.map +1 -0
- package/dist/utils/terminal-safe.d.ts +12 -0
- package/dist/utils/terminal-safe.d.ts.map +1 -0
- package/dist/utils/terminal-safe.js +53 -0
- package/dist/utils/terminal-safe.js.map +1 -0
- package/docs/ATTRIBUTION.md +1 -0
- package/docs/DASHBOARD_UPLIFT_SPEC.md +8 -8
- package/docs/INDEX.md +6 -5
- package/docs/architecture/OVERVIEW.md +15 -3
- package/docs/architecture/PROTOCOL.md +7 -1
- package/docs/architecture/adr/0006-operator-directed-model-placement.md +86 -0
- package/docs/architecture/adr/0007-auto-displacement-allowlist-as-standing-consent.md +79 -0
- package/docs/architecture/adr/0008-fleet-clients-link-out.md +92 -0
- package/docs/architecture/adr/0009-completion-triggered-dispatch-and-per-leaf-model-dials.md +71 -0
- package/docs/architecture/adr/0010-attention-queue-and-afk-auto-decider.md +159 -0
- package/docs/architecture/adr/0011-sandbox-grants-and-network-egress-boundary.md +219 -0
- package/docs/architecture/adr/0012-operator-legibility-surfaces.md +175 -0
- package/docs/getting-started/INSTALLATION.md +1 -1
- package/docs/guides/AUTOMATIC_FEATURES.md +8 -0
- package/docs/guides/LOCAL_MODELS.md +1 -1
- package/docs/guides/PROXY.md +2 -1
- package/docs/guides/SANDBOX.md +91 -4
- package/docs/integrations/MCP_ROUTER.md +1 -1
- package/docs/plans/model-performance-proof-options.md +176 -0
- package/docs/reference/API.md +1 -1
- package/docs/reference/CLI.md +169 -4
- package/docs/reference/COMPARISON.md +1 -1
- package/docs/reference/CONFIGURATION.md +12 -3
- package/docs/reference/CONFIGURATION_REFERENCE.md +269 -14
- package/docs/reference/DATABASE_SCHEMA.md +1 -1
- package/docs/reference/FEATURES.md +15 -3
- package/docs/reference/PATTERNS.md +1 -1
- package/docs/reference/PLATFORMS.md +14 -1
- package/docs/specs/operator-model-placement.md +1314 -0
- package/docs/specs/operator-model-placement.review.md +339 -0
- package/package.json +2 -2
- package/scripts/maintenance/publish-npm.sh +23 -11
- package/scripts/version-bump.sh +6 -51
- package/src/policies/enforcers/__pycache__/_common.cpython-311.pyc +0 -0
- package/src/policies/enforcers/__pycache__/expert_review_required.cpython-311.pyc +0 -0
- package/src/policies/enforcers/__pycache__/quality_metrics_gate.cpython-311.pyc +0 -0
- package/src/policies/schemas/policies/mandatory-testing-deployment.md +2 -5
- package/templates/hooks/__pycache__/deliver_autoroute.cpython-311.pyc +0 -0
- package/templates/hooks/session-start.sh +44 -0
- package/tools/agents/UAP/cli.py +14 -3
- package/tools/agents/UAP/paths.py +50 -0
- package/tools/agents/scripts/__pycache__/toolcall_path_normalizer.cpython-311.pyc +0 -0
- package/tools/agents/scripts/anthropic_proxy.py +1386 -99
- package/tools/agents/scripts/toolcall_path_normalizer.py +46 -1
- package/tools/agents/tests/test_anthropic_proxy_streaming.py +29 -6
- package/tools/agents/tests/test_doubling_break.py +64 -5
- package/tools/agents/tests/test_error_loop_break.py +10 -4
- package/tools/agents/tests/test_error_loop_ignores_correctives.py +13 -2
- package/tools/agents/tests/test_lockstep_escalation.py +280 -9
- package/tools/agents/tests/test_path_containment.py +94 -0
- package/tools/agents/tests/test_proxy_unix_socket.py +162 -0
- package/tools/agents/tests/test_signature_quality.py +349 -0
- package/tools/agents/tests/test_uap_compliance.py +11 -1
- package/web/dash/core.js +1 -0
- package/web/dash/tab-clients.js +196 -0
- package/web/dash/tab-models.js +387 -8
- package/web/dash/tab-policies.js +87 -4
- package/web/dash/tabs.js +77 -73
- package/web/dashboard.html +6 -0
- package/src/policies/enforcers/__pycache__/_common.cpython-312.pyc +0 -0
- package/src/policies/enforcers/__pycache__/expert_review_required.cpython-312.pyc +0 -0
- package/src/policies/enforcers/__pycache__/quality_metrics_gate.cpython-312.pyc +0 -0
- package/templates/hooks/__pycache__/deliver_autoroute.cpython-312.pyc +0 -0
- package/tools/agents/scripts/__pycache__/toolcall_path_normalizer.cpython-312.pyc +0 -0
- package/web/dash/tab-agents.js +0 -21
- package/web/dash/tab-deliver.js +0 -12
- package/web/dash/tab-memory.js +0 -12
- package/web/dash/tab-orchestration.js +0 -21
- package/web/dash/tab-overview.js +0 -21
- 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
|
-
"""
|
|
4310
|
-
|
|
4311
|
-
|
|
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("
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 = {
|
|
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
|
-
|
|
189
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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(
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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"][
|
|
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"][
|
|
235
|
-
self.assertNotIn("FINAL WARNING", body["messages"][
|
|
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
|
|
285
|
-
|
|
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 = ""
|