@miller-tech/uap 2.16.6 → 2.19.5
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 +1 -1
- package/config/capacity-policy.json +33 -0
- package/config/model-profiles/qwen38.json +32 -33
- package/dist/.tsbuildinfo +1 -1
- package/dist/bin/cli.js +57 -0
- package/dist/bin/cli.js.map +1 -1
- package/dist/capacity/policy.d.ts +32 -1
- package/dist/capacity/policy.d.ts.map +1 -1
- package/dist/capacity/policy.js +123 -4
- package/dist/capacity/policy.js.map +1 -1
- package/dist/capacity/probe.d.ts +26 -0
- package/dist/capacity/probe.d.ts.map +1 -1
- package/dist/capacity/probe.js +84 -3
- package/dist/capacity/probe.js.map +1 -1
- package/dist/cli/inference.d.ts +4 -0
- package/dist/cli/inference.d.ts.map +1 -1
- package/dist/cli/inference.js +74 -8
- package/dist/cli/inference.js.map +1 -1
- package/dist/cli/merge-gate.d.ts +59 -0
- package/dist/cli/merge-gate.d.ts.map +1 -0
- package/dist/cli/merge-gate.js +238 -0
- package/dist/cli/merge-gate.js.map +1 -0
- package/dist/cli/merge-queue.d.ts +13 -0
- package/dist/cli/merge-queue.d.ts.map +1 -1
- package/dist/cli/merge-queue.js +48 -2
- package/dist/cli/merge-queue.js.map +1 -1
- package/dist/cli/mission.d.ts +25 -0
- package/dist/cli/mission.d.ts.map +1 -0
- package/dist/cli/mission.js +225 -0
- package/dist/cli/mission.js.map +1 -0
- package/dist/cli/orch.d.ts +27 -0
- package/dist/cli/orch.d.ts.map +1 -0
- package/dist/cli/orch.js +70 -0
- package/dist/cli/orch.js.map +1 -0
- package/dist/cli/systemd-services.d.ts.map +1 -1
- package/dist/cli/systemd-services.js +44 -0
- package/dist/cli/systemd-services.js.map +1 -1
- package/dist/delivery/merge-gate.d.ts +168 -0
- package/dist/delivery/merge-gate.d.ts.map +1 -0
- package/dist/delivery/merge-gate.js +309 -0
- package/dist/delivery/merge-gate.js.map +1 -0
- package/dist/inference/analysis.d.ts +69 -3
- package/dist/inference/analysis.d.ts.map +1 -1
- package/dist/inference/analysis.js +123 -7
- package/dist/inference/analysis.js.map +1 -1
- package/dist/inference/probe.d.ts +38 -1
- package/dist/inference/probe.d.ts.map +1 -1
- package/dist/inference/probe.js +303 -30
- package/dist/inference/probe.js.map +1 -1
- package/dist/inference/strata.d.ts +95 -0
- package/dist/inference/strata.d.ts.map +1 -0
- package/dist/inference/strata.js +167 -0
- package/dist/inference/strata.js.map +1 -0
- package/dist/mission/database.d.ts +29 -0
- package/dist/mission/database.d.ts.map +1 -0
- package/dist/mission/database.js +131 -0
- package/dist/mission/database.js.map +1 -0
- package/dist/mission/ledger.d.ts +109 -0
- package/dist/mission/ledger.d.ts.map +1 -0
- package/dist/mission/ledger.js +294 -0
- package/dist/mission/ledger.js.map +1 -0
- package/dist/mission/poll-cycle.d.ts +73 -0
- package/dist/mission/poll-cycle.d.ts.map +1 -0
- package/dist/mission/poll-cycle.js +100 -0
- package/dist/mission/poll-cycle.js.map +1 -0
- package/dist/mission/poll-executor.d.ts +91 -0
- package/dist/mission/poll-executor.d.ts.map +1 -0
- package/dist/mission/poll-executor.js +328 -0
- package/dist/mission/poll-executor.js.map +1 -0
- package/dist/models/types.d.ts.map +1 -1
- package/dist/models/types.js +33 -27
- package/dist/models/types.js.map +1 -1
- package/dist/self-tuning/profiles/qwen38.d.ts +13 -14
- package/dist/self-tuning/profiles/qwen38.d.ts.map +1 -1
- package/dist/self-tuning/profiles/qwen38.js +17 -18
- package/dist/self-tuning/profiles/qwen38.js.map +1 -1
- package/dist/types/mission.d.ts +80 -0
- package/dist/types/mission.d.ts.map +1 -0
- package/dist/types/mission.js +12 -0
- package/dist/types/mission.js.map +1 -0
- package/docs/architecture/adr/0003-mission-orchestrator.md +99 -0
- package/docs/architecture/adr/0004-deterministic-merge-gate.md +127 -0
- package/docs/guides/CAPACITY_POLICY.md +49 -0
- package/docs/guides/INFERENCE_HEALTH.md +65 -8
- package/docs/guides/MISSIONS.md +170 -0
- package/docs/guides/PROXY.md +2 -0
- package/docs/reports/2026-10-02-plane-admin-fail-closed.md +70 -0
- package/docs/reports/2026-10-02-plane-admin-protect-session.md +116 -0
- package/docs/visual/merge-gate.html +404 -0
- package/docs/visual/mission-orchestrator.html +472 -0
- package/package.json +2 -2
- 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/src/policies/enforcers/plane_admin_protect.py +133 -0
- package/src/policies/schemas/policies/plane-admin-protect.md +55 -0
- package/templates/hooks/__pycache__/deliver_autoroute.cpython-312.pyc +0 -0
- package/templates/hooks/uap-policy-gate.sh +33 -0
- package/tools/agents/scripts/__pycache__/toolcall_path_normalizer.cpython-312.pyc +0 -0
- package/tools/agents/scripts/anthropic_proxy.py +522 -45
- package/tools/agents/tests/test_anthropic_proxy_streaming.py +171 -0
- package/tools/agents/tests/test_gate_failclosed_plane_admin.py +248 -0
- package/tools/agents/tests/test_plane_admin_protect.py +124 -0
- package/tools/agents/tests/test_repeat_call_guard.py +64 -0
- package/tools/agents/tests/test_stream_heartbeat.py +77 -0
|
@@ -3540,6 +3540,177 @@ class TestToolStarvationBreaker(unittest.TestCase):
|
|
|
3540
3540
|
result = proxy.build_openai_request(body, monitor)
|
|
3541
3541
|
self.assertIn("tools", result)
|
|
3542
3542
|
|
|
3543
|
+
def _make_body_with_fresh_user_text(self):
|
|
3544
|
+
"""A COMPLETED turn followed by a new user prompt: the last
|
|
3545
|
+
assistant message is the previous turn's text summary (the honest
|
|
3546
|
+
end-of-work case the 2026-10-04 false fires were made of), and the
|
|
3547
|
+
latest user message is fresh text with n_msgs > 1."""
|
|
3548
|
+
return {
|
|
3549
|
+
"model": "qwen3.5",
|
|
3550
|
+
"messages": [
|
|
3551
|
+
{"role": "user", "content": "first task"},
|
|
3552
|
+
{"role": "assistant", "content": [{"type": "text", "text": "## Summary of progress"}]},
|
|
3553
|
+
{"role": "user", "content": "second task"},
|
|
3554
|
+
],
|
|
3555
|
+
"tools": [{"name": "Bash", "input_schema": {"type": "object", "properties": {"command": {"type": "string"}}}}],
|
|
3556
|
+
}
|
|
3557
|
+
|
|
3558
|
+
def test_fresh_user_text_resets_the_forcing_loop_counter(self):
|
|
3559
|
+
"""The 2026-10-04 live bug: a turn that ended with a legitimate text
|
|
3560
|
+
summary under a forced act-phase left consecutive_forced_count at the
|
|
3561
|
+
threshold, so the FIRST request of the NEXT user turn hit the
|
|
3562
|
+
starvation breaker (count >= threshold AND last-assistant-text-only,
|
|
3563
|
+
i.e. the PREVIOUS turn's summary) and had its tools stripped — firing
|
|
3564
|
+
roughly once per user turn for hours. The forcing loop is per-TURN
|
|
3565
|
+
state; a fresh user text must reset it."""
|
|
3566
|
+
monitor = proxy.SessionMonitor()
|
|
3567
|
+
monitor.consecutive_forced_count = proxy.PROXY_TOOL_STARVATION_THRESHOLD
|
|
3568
|
+
body = self._make_body_with_fresh_user_text()
|
|
3569
|
+
result = proxy.build_openai_request(body, monitor)
|
|
3570
|
+
self.assertIn("tools", result)
|
|
3571
|
+
self.assertEqual(monitor.tool_starvation_streak, 0)
|
|
3572
|
+
# The stale threshold count was reset (the breaker never fired), and
|
|
3573
|
+
# the NEW turn's own act-phase forcing started counting from 1 within
|
|
3574
|
+
# this same request build — 1, not threshold+1, proves the reset.
|
|
3575
|
+
self.assertLessEqual(monitor.consecutive_forced_count, 1)
|
|
3576
|
+
|
|
3577
|
+
def test_fresh_user_text_keeps_output_quality_streaks_sticky(self):
|
|
3578
|
+
"""Only the loop-progress counters reset on fresh user text. A new
|
|
3579
|
+
user message is not evidence about the model's tool-call FORMATTING,
|
|
3580
|
+
so the malformed/invalid/required-miss streaks stay sticky — a user
|
|
3581
|
+
typing "go on" must not clear a model's record of emitting broken
|
|
3582
|
+
tool calls."""
|
|
3583
|
+
monitor = proxy.SessionMonitor()
|
|
3584
|
+
monitor.consecutive_forced_count = proxy.PROXY_TOOL_STARVATION_THRESHOLD
|
|
3585
|
+
monitor.malformed_tool_streak = 3
|
|
3586
|
+
monitor.invalid_tool_call_streak = 2
|
|
3587
|
+
monitor.required_tool_miss_streak = 4
|
|
3588
|
+
body = self._make_body_with_fresh_user_text()
|
|
3589
|
+
proxy.build_openai_request(body, monitor)
|
|
3590
|
+
self.assertEqual(monitor.malformed_tool_streak, 3)
|
|
3591
|
+
self.assertEqual(monitor.invalid_tool_call_streak, 2)
|
|
3592
|
+
self.assertEqual(monitor.required_tool_miss_streak, 4)
|
|
3593
|
+
|
|
3594
|
+
def test_no_tool_results_conversation_resets_the_forcing_loop_counter(self):
|
|
3595
|
+
"""The second reset path of the 2026-10-04 fix: a conversation with
|
|
3596
|
+
no tool results anywhere cannot be in a forcing loop, so the loop
|
|
3597
|
+
counter must not carry in even when there is NO fresh user text to
|
|
3598
|
+
trigger the fresh_user_text branch (here: the last user message is
|
|
3599
|
+
empty, e.g. a pure non-text block). Before the fix the stale count
|
|
3600
|
+
from the previous turn stripped tools on this request too."""
|
|
3601
|
+
monitor = proxy.SessionMonitor()
|
|
3602
|
+
monitor.consecutive_forced_count = proxy.PROXY_TOOL_STARVATION_THRESHOLD
|
|
3603
|
+
body = self._make_body_with_tools()
|
|
3604
|
+
# Overwrite the tail: no tool_result blocks anywhere (that is the
|
|
3605
|
+
# fixture's default) and an EMPTY last user message, so neither
|
|
3606
|
+
# fresh_user_text nor active_loop can fire.
|
|
3607
|
+
body["messages"] = [
|
|
3608
|
+
{"role": "user", "content": "first task"},
|
|
3609
|
+
{"role": "assistant", "content": [{"type": "text", "text": "## Summary of progress"}]},
|
|
3610
|
+
{"role": "user", "content": ""},
|
|
3611
|
+
]
|
|
3612
|
+
result = proxy.build_openai_request(body, monitor)
|
|
3613
|
+
self.assertIn("tools", result)
|
|
3614
|
+
self.assertEqual(monitor.tool_starvation_streak, 0)
|
|
3615
|
+
# Reset proven the same way as the fresh_user_text test: the stale
|
|
3616
|
+
# threshold count is gone, and the at-most-1 remainder is this new
|
|
3617
|
+
# request's own act-phase forcing counted within the same build —
|
|
3618
|
+
# not the previous turn's streak surviving (that would be >= 5).
|
|
3619
|
+
self.assertLessEqual(monitor.consecutive_forced_count, 1)
|
|
3620
|
+
|
|
3621
|
+
def test_fresh_user_text_keeps_an_active_dampener_cooldown(self):
|
|
3622
|
+
"""2026-10-04 follow-up classification: forced_auto_cooldown_turns
|
|
3623
|
+
is deliberately STICKY across fresh user text. The dampener is a
|
|
3624
|
+
remedy for a tool-output QUALITY collapse, and quality is
|
|
3625
|
+
cross-turn evidence -- "keep going" is a mid-episode ack that must
|
|
3626
|
+
not cut the recovery window short, and the dampener cannot re-arm
|
|
3627
|
+
after a reset (activation needs consecutive_forced_count >=
|
|
3628
|
+
min_forced). The cooldown clears only at a brand-new conversation
|
|
3629
|
+
or the contamination hard reset."""
|
|
3630
|
+
monitor = proxy.SessionMonitor()
|
|
3631
|
+
monitor.forced_auto_cooldown_turns = 2
|
|
3632
|
+
body = self._make_body_with_fresh_user_text()
|
|
3633
|
+
proxy.build_openai_request(body, monitor)
|
|
3634
|
+
# One auto turn may be consumed by the release path; what must NOT
|
|
3635
|
+
# happen is the cooldown being zeroed by the fresh_user_text reset.
|
|
3636
|
+
self.assertEqual(monitor.forced_auto_cooldown_turns, 1)
|
|
3637
|
+
|
|
3638
|
+
def test_fresh_user_text_resets_the_recon_exploration_streak(self):
|
|
3639
|
+
"""2026-10-04 follow-up: the recon exploration streak is PER-TASK
|
|
3640
|
+
state. The previous task's read-forever streak is not evidence
|
|
3641
|
+
about the NEW deliverable, and a stale streak fired the recon
|
|
3642
|
+
convergence directive on the new task's first request. Genuine
|
|
3643
|
+
recon loops stay within a turn (their resends carry tool_results
|
|
3644
|
+
and never hit the fresh_user_text branch), so they keep counting."""
|
|
3645
|
+
monitor = proxy.SessionMonitor()
|
|
3646
|
+
monitor.consecutive_no_write_turns = max(1, proxy.PROXY_RECON_CONVERGENCE_THRESHOLD)
|
|
3647
|
+
body = self._make_body_with_fresh_user_text()
|
|
3648
|
+
proxy.build_openai_request(body, monitor)
|
|
3649
|
+
self.assertEqual(monitor.consecutive_no_write_turns, 0)
|
|
3650
|
+
|
|
3651
|
+
def test_fresh_user_text_keeps_the_deferral_streak_sticky(self):
|
|
3652
|
+
"""The deliberate exception (see SessionMonitor.reset_forcing_loop's
|
|
3653
|
+
docstring): the capitulation loop the deferral break exists for
|
|
3654
|
+
SPANS user acks — model asks for more cycles, user says yes, model
|
|
3655
|
+
asks again. A per-turn reset would make that breaker unreachable in
|
|
3656
|
+
exactly its true-positive case, so the deferral streak carries."""
|
|
3657
|
+
monitor = proxy.SessionMonitor()
|
|
3658
|
+
monitor.deferral_streak = 2
|
|
3659
|
+
body = self._make_body_with_fresh_user_text()
|
|
3660
|
+
proxy.build_openai_request(body, monitor)
|
|
3661
|
+
self.assertEqual(monitor.deferral_streak, 2)
|
|
3662
|
+
|
|
3663
|
+
def test_reset_forcing_loop_membership_is_exact(self):
|
|
3664
|
+
"""Pins the family membership at its policy point: the two per-turn
|
|
3665
|
+
members reset, and everything with a different lifecycle (quality
|
|
3666
|
+
streaks, dampener cooldown, deferral streak, session telemetry,
|
|
3667
|
+
and the per-TASK recon streak — which is reset inline at the four
|
|
3668
|
+
boundary sites, ack-gated, never here) is untouched by this method.
|
|
3669
|
+
A future symmetry-cleanup that widens this method must update this
|
|
3670
|
+
test and the docstring together."""
|
|
3671
|
+
monitor = proxy.SessionMonitor()
|
|
3672
|
+
monitor.consecutive_forced_count = 5
|
|
3673
|
+
monitor.no_progress_streak = 3
|
|
3674
|
+
monitor.forced_auto_cooldown_turns = 2
|
|
3675
|
+
monitor.consecutive_no_write_turns = 7
|
|
3676
|
+
monitor.malformed_tool_streak = 1
|
|
3677
|
+
monitor.invalid_tool_call_streak = 1
|
|
3678
|
+
monitor.required_tool_miss_streak = 1
|
|
3679
|
+
monitor.deferral_streak = 4
|
|
3680
|
+
monitor.tool_starvation_streak = 2
|
|
3681
|
+
monitor.reset_forcing_loop()
|
|
3682
|
+
self.assertEqual(monitor.consecutive_forced_count, 0)
|
|
3683
|
+
self.assertEqual(monitor.no_progress_streak, 0)
|
|
3684
|
+
self.assertEqual(monitor.forced_auto_cooldown_turns, 2)
|
|
3685
|
+
self.assertEqual(monitor.consecutive_no_write_turns, 7)
|
|
3686
|
+
self.assertEqual(monitor.malformed_tool_streak, 1)
|
|
3687
|
+
self.assertEqual(monitor.invalid_tool_call_streak, 1)
|
|
3688
|
+
self.assertEqual(monitor.required_tool_miss_streak, 1)
|
|
3689
|
+
self.assertEqual(monitor.deferral_streak, 4)
|
|
3690
|
+
self.assertEqual(monitor.tool_starvation_streak, 2)
|
|
3691
|
+
|
|
3692
|
+
def test_mid_task_ack_does_not_clear_the_recon_exploration_streak(self):
|
|
3693
|
+
"""The ack gate on the per-TASK recon reset (security review,
|
|
3694
|
+
2026-10-04): "ok" is an ack continuing the SAME task, not new work
|
|
3695
|
+
— without the gate, a model could end each threshold-1 turn
|
|
3696
|
+
soliciting an ack and keep the convergence directive unreachable
|
|
3697
|
+
forever. The existing _is_no_task_user_text classifier decides."""
|
|
3698
|
+
monitor = proxy.SessionMonitor()
|
|
3699
|
+
monitor.consecutive_no_write_turns = max(1, proxy.PROXY_RECON_CONVERGENCE_THRESHOLD)
|
|
3700
|
+
body = self._make_body_with_fresh_user_text()
|
|
3701
|
+
body["messages"][-1] = {"role": "user", "content": "ok"}
|
|
3702
|
+
proxy.build_openai_request(body, monitor)
|
|
3703
|
+
# The streak survived the ack (it did not reset to 0) AND kept
|
|
3704
|
+
# counting — the previous assistant prose turn is itself a
|
|
3705
|
+
# no-write turn (Fix B), so the count grows within this build.
|
|
3706
|
+
# Either fact alone would pass; together they pin that the ack gate
|
|
3707
|
+
# kept the counter live for the SAME task.
|
|
3708
|
+
self.assertGreaterEqual(
|
|
3709
|
+
monitor.consecutive_no_write_turns,
|
|
3710
|
+
max(1, proxy.PROXY_RECON_CONVERGENCE_THRESHOLD),
|
|
3711
|
+
)
|
|
3712
|
+
self.assertNotEqual(monitor.consecutive_no_write_turns, 0)
|
|
3713
|
+
|
|
3543
3714
|
|
|
3544
3715
|
class TestPruningImprovements(unittest.TestCase):
|
|
3545
3716
|
"""Tests for pruning death spiral fixes."""
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"""The plane-admin guard's failure must not read as consent.
|
|
2
|
+
|
|
3
|
+
uap-policy-gate.sh is fail-SOFT by design: a broken or absent enforcer must not
|
|
4
|
+
wedge every tool call in the session. That same fail-open makes "break the
|
|
5
|
+
enforcer" a silent bypass of the control that enforcer implements -- the loop
|
|
6
|
+
iterating active policies maps a missing enforcer file, and an enforcer that
|
|
7
|
+
errors or emits unparseable output, to ALLOW for everything except the names
|
|
8
|
+
must_fail_closed() says otherwise about.
|
|
9
|
+
|
|
10
|
+
plane_admin_protect guards the plane-admin control surface: policy registration,
|
|
11
|
+
the plane-admin rows the gate itself reads out of agents/data/memory/policies.db,
|
|
12
|
+
and the .policy-tools/ enforcer copies the gate executes. That surface is mutable
|
|
13
|
+
almost exclusively through the shell (`uap policy install/verify`, `sqlite3
|
|
14
|
+
agents/data/memory/policies.db ...`, `rm -rf .policy-tools/...`), so a missing or
|
|
15
|
+
errored plane-admin enforcer ON A SHELL CALL is a live bypass of the admin
|
|
16
|
+
controls. It therefore arms the fail-closed net -- but only for shell-execution
|
|
17
|
+
tools (SHELL_OP), for the same reason COMMIT_OP scopes schema_diff_gate to
|
|
18
|
+
commit/push: arming it on every Edit would turn one broken enforcer into a
|
|
19
|
+
blanket block on all work in the session.
|
|
20
|
+
|
|
21
|
+
These tests assert the shipped copies of the hook carry that case, and EXECUTE
|
|
22
|
+
the extracted shell logic rather than grep for it -- a comment merely mentioning
|
|
23
|
+
plane_admin_protect must not satisfy them.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import subprocess
|
|
29
|
+
import unittest
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
|
|
32
|
+
REPO = Path(__file__).resolve().parents[3]
|
|
33
|
+
HOOK_NAME = "uap-policy-gate.sh"
|
|
34
|
+
PRIMARY_HOOK = REPO / ".claude" / "hooks" / HOOK_NAME
|
|
35
|
+
TEMPLATE_HOOK = REPO / "templates" / "hooks" / HOOK_NAME
|
|
36
|
+
POLICY_NAME = "plane_admin_protect"
|
|
37
|
+
|
|
38
|
+
SHELL_TOOLS = (
|
|
39
|
+
"Bash", "bash", "run_bash", "BashTool", "Shell", "shell", "Terminal",
|
|
40
|
+
"terminal", "execute_command", "run_command", "exec", "local_shell", "command",
|
|
41
|
+
)
|
|
42
|
+
NON_SHELL_TOOLS = ("Edit", "Write", "MultiEdit", "Read", "Task", "Grep")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def hook_copies() -> list[Path]:
|
|
46
|
+
"""Every copy of the hook this repo SHIPS (git-tracked files only).
|
|
47
|
+
|
|
48
|
+
Matched on the path RELATIVE to REPO, and limited to tracked files: `uap
|
|
49
|
+
hooks install` output (.uap/omp/, .codex/hooks/, .cursor/hooks/, ...) is
|
|
50
|
+
gitignored, so sweeping it fails on files no PR can commit -- and matching
|
|
51
|
+
the ABSOLUTE path excluded the whole tree whenever REPO was itself a
|
|
52
|
+
worktree, making the sweep pass by iterating nothing.
|
|
53
|
+
"""
|
|
54
|
+
tracked = subprocess.run(
|
|
55
|
+
["git", "ls-files", "--full-name", "*" + HOOK_NAME],
|
|
56
|
+
cwd=REPO, capture_output=True, text=True,
|
|
57
|
+
).stdout.split()
|
|
58
|
+
return sorted(REPO / t for t in tracked if (REPO / t).is_file())
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _extract(gate: str, start: str, end: str) -> str:
|
|
62
|
+
"""Pull a verbatim region out of the hook so the test runs the real code."""
|
|
63
|
+
i = gate.index(start)
|
|
64
|
+
return gate[i:gate.index(end, i) + len(end)]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def must_fail_closed_fn(gate: str) -> str:
|
|
68
|
+
return _extract(gate, "must_fail_closed() {", "\n}\n")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def shell_op_block(gate: str) -> str:
|
|
72
|
+
"""The hook's own tool-name -> SHELL_OP decision, verbatim."""
|
|
73
|
+
return _extract(gate, "SHELL_OP=0\n", "esac\n")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def run_bash(script: str) -> subprocess.CompletedProcess:
|
|
77
|
+
return subprocess.run(["bash", "-c", script], capture_output=True,
|
|
78
|
+
text=True, timeout=30)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class GateCarriesThePlaneAdminCase(unittest.TestCase):
|
|
82
|
+
"""The selector must name the enforcer, not just the two it already knew."""
|
|
83
|
+
|
|
84
|
+
def test_the_sweep_actually_finds_the_copies(self):
|
|
85
|
+
"""An assertion about a collection is worthless without this one.
|
|
86
|
+
|
|
87
|
+
hook_copies() once returned [] inside a worktree and both drift sweeps
|
|
88
|
+
went green while checking nothing.
|
|
89
|
+
"""
|
|
90
|
+
found = hook_copies()
|
|
91
|
+
self.assertGreaterEqual(
|
|
92
|
+
len(found), 2,
|
|
93
|
+
f"expected several hook copies under {REPO}, found {[str(p) for p in found]}",
|
|
94
|
+
)
|
|
95
|
+
self.assertIn(PRIMARY_HOOK, found)
|
|
96
|
+
self.assertIn(TEMPLATE_HOOK, found)
|
|
97
|
+
|
|
98
|
+
def test_every_shipped_copy_arms_plane_admin_protect(self):
|
|
99
|
+
for copy in hook_copies():
|
|
100
|
+
with self.subTest(copy=str(copy.relative_to(REPO))):
|
|
101
|
+
fn = must_fail_closed_fn(copy.read_text())
|
|
102
|
+
self.assertIn(
|
|
103
|
+
POLICY_NAME, fn,
|
|
104
|
+
f"{copy.relative_to(REPO)}: must_fail_closed() has no "
|
|
105
|
+
f"{POLICY_NAME} case, so a broken plane-admin enforcer fails open",
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
def test_the_template_copy_is_not_the_stale_one(self):
|
|
109
|
+
"""`uap worktree create` seeds new worktrees from templates/hooks/.
|
|
110
|
+
|
|
111
|
+
A fix applied only to .claude/hooks/ is silently reverted the next time
|
|
112
|
+
anyone starts a branch -- a documented failure mode in this repo.
|
|
113
|
+
"""
|
|
114
|
+
self.assertTrue(TEMPLATE_HOOK.is_file(), "templates/hooks copy is missing")
|
|
115
|
+
self.assertIn(POLICY_NAME, must_fail_closed_fn(TEMPLATE_HOOK.read_text()))
|
|
116
|
+
|
|
117
|
+
def test_the_selector_body_agrees_across_copies(self):
|
|
118
|
+
primary = must_fail_closed_fn(PRIMARY_HOOK.read_text())
|
|
119
|
+
for copy in hook_copies():
|
|
120
|
+
with self.subTest(copy=str(copy.relative_to(REPO))):
|
|
121
|
+
self.assertEqual(
|
|
122
|
+
must_fail_closed_fn(copy.read_text()), primary,
|
|
123
|
+
f"{copy.relative_to(REPO)}: must_fail_closed() has drifted "
|
|
124
|
+
"from .claude/hooks/",
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
def test_every_copy_still_parses(self):
|
|
128
|
+
for copy in hook_copies():
|
|
129
|
+
with self.subTest(copy=str(copy.relative_to(REPO))):
|
|
130
|
+
p = subprocess.run(["bash", "-n", str(copy)],
|
|
131
|
+
capture_output=True, text=True, timeout=30)
|
|
132
|
+
self.assertEqual(p.returncode, 0, p.stderr[:300])
|
|
133
|
+
|
|
134
|
+
def test_the_refusal_names_the_right_enforcer(self):
|
|
135
|
+
"""A refusal that names the wrong guard is how a loop survives it.
|
|
136
|
+
|
|
137
|
+
Routing this through the generic self-protect wording told the operator
|
|
138
|
+
the self-protect enforcer had failed and pointed at a hatch that does
|
|
139
|
+
not re-arm this case.
|
|
140
|
+
"""
|
|
141
|
+
fn = _extract(PRIMARY_HOOK.read_text(), "fail_closed() {", "\n}\n")
|
|
142
|
+
script = (
|
|
143
|
+
"set -euo pipefail\n"
|
|
144
|
+
'record_execution() { echo "RECORDED allowed=$1 policy=$2"; }\n'
|
|
145
|
+
+ fn
|
|
146
|
+
+ f'\nfail_closed "enforcer errored" "{POLICY_NAME}" || true\n'
|
|
147
|
+
)
|
|
148
|
+
p = run_bash(script)
|
|
149
|
+
self.assertIn("FAIL-CLOSED", p.stderr)
|
|
150
|
+
self.assertIn(POLICY_NAME, p.stderr)
|
|
151
|
+
self.assertIn("RECORDED allowed=0", p.stdout, "the block must leave a compliance row")
|
|
152
|
+
self.assertIn(f"policy={POLICY_NAME}", p.stdout, "the row must name the enforcer")
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class PlaneAdminArmsOnlyForShellExecution(unittest.TestCase):
|
|
156
|
+
"""The scoping IS the design: shell tools only, and the hatch clears it."""
|
|
157
|
+
|
|
158
|
+
def verdict(self, policy: str, shell_op: str, sec: str = "0", commit: str = "0") -> bool:
|
|
159
|
+
script = (
|
|
160
|
+
"set -euo pipefail\n"
|
|
161
|
+
f'SEC_SENSITIVE="{sec}"\nCOMMIT_OP="{commit}"\nSHELL_OP="{shell_op}"\n'
|
|
162
|
+
+ must_fail_closed_fn(PRIMARY_HOOK.read_text())
|
|
163
|
+
+ f'\nif must_fail_closed "{policy}"; then echo CLOSED; else echo open; fi\n'
|
|
164
|
+
)
|
|
165
|
+
p = run_bash(script)
|
|
166
|
+
self.assertEqual(p.returncode, 0, f"set -e tripped the helper: {p.stderr[:200]}")
|
|
167
|
+
return p.stdout.strip() == "CLOSED"
|
|
168
|
+
|
|
169
|
+
def test_it_fails_closed_on_a_shell_call(self):
|
|
170
|
+
self.assertTrue(self.verdict(POLICY_NAME, "1"))
|
|
171
|
+
|
|
172
|
+
def test_it_fails_open_on_everything_else(self):
|
|
173
|
+
self.assertFalse(self.verdict(POLICY_NAME, "0"))
|
|
174
|
+
|
|
175
|
+
def test_the_hooks_own_tool_test_classifies_shell_tools(self):
|
|
176
|
+
gate = PRIMARY_HOOK.read_text()
|
|
177
|
+
for tool in SHELL_TOOLS:
|
|
178
|
+
with self.subTest(tool=tool):
|
|
179
|
+
script = ("set -euo pipefail\n" + f'TOOL="{tool}"\n' + shell_op_block(gate)
|
|
180
|
+
+ '\nif [[ "$SHELL_OP" == "1" ]]; then echo shell; else echo not-shell; fi\n')
|
|
181
|
+
p = run_bash(script)
|
|
182
|
+
self.assertEqual(p.returncode, 0, p.stderr[:200])
|
|
183
|
+
self.assertEqual(p.stdout.strip(), "shell",
|
|
184
|
+
f"{tool} must arm the plane-admin fail-closed net")
|
|
185
|
+
|
|
186
|
+
def test_the_hooks_own_tool_test_leaves_edit_and_write_open(self):
|
|
187
|
+
"""Otherwise one broken enforcer blocks every edit in the session."""
|
|
188
|
+
gate = PRIMARY_HOOK.read_text()
|
|
189
|
+
for tool in NON_SHELL_TOOLS:
|
|
190
|
+
with self.subTest(tool=tool):
|
|
191
|
+
script = ("set -euo pipefail\n" + f'TOOL="{tool}"\n' + shell_op_block(gate)
|
|
192
|
+
+ '\nif [[ "$SHELL_OP" == "1" ]]; then echo shell; else echo not-shell; fi\n')
|
|
193
|
+
p = run_bash(script)
|
|
194
|
+
self.assertEqual(p.returncode, 0, p.stderr[:200])
|
|
195
|
+
self.assertEqual(p.stdout.strip(), "not-shell",
|
|
196
|
+
f"{tool} must NOT arm the plane-admin fail-closed net")
|
|
197
|
+
|
|
198
|
+
def test_the_operator_hatch_clears_this_arm_too(self):
|
|
199
|
+
"""UAP_SELF_PROTECT_OFF is documented as clearing the whole net.
|
|
200
|
+
|
|
201
|
+
Leaving SHELL_OP armed made the override named in the refusal a no-op
|
|
202
|
+
for exactly the case that prints it.
|
|
203
|
+
"""
|
|
204
|
+
gate = PRIMARY_HOOK.read_text()
|
|
205
|
+
script = (
|
|
206
|
+
"set -euo pipefail\n"
|
|
207
|
+
'TOOL="Bash"\nSHELL_OP=1\n'
|
|
208
|
+
+ shell_op_block(gate)
|
|
209
|
+
+ '\nexport UAP_SELF_PROTECT_OFF=1\n'
|
|
210
|
+
+ '[[ "${UAP_SELF_PROTECT_OFF:-}" == "1" ]] && SHELL_OP=0\n'
|
|
211
|
+
+ must_fail_closed_fn(gate)
|
|
212
|
+
+ f'\nif must_fail_closed "{POLICY_NAME}"; then echo CLOSED; else echo open; fi\n'
|
|
213
|
+
)
|
|
214
|
+
p = run_bash(script)
|
|
215
|
+
self.assertEqual(p.returncode, 0, p.stderr[:300])
|
|
216
|
+
self.assertEqual(p.stdout.strip(), "open")
|
|
217
|
+
|
|
218
|
+
def test_the_other_two_arms_are_unchanged(self):
|
|
219
|
+
"""Adding this case must not have repurposed the existing selectors."""
|
|
220
|
+
self.assertTrue(self.verdict("enforcement_self_protect", "0", sec="1"))
|
|
221
|
+
self.assertFalse(self.verdict("enforcement_self_protect", "1", sec="0"))
|
|
222
|
+
self.assertTrue(self.verdict("schema_diff_gate", "0", commit="1"))
|
|
223
|
+
self.assertFalse(self.verdict("schema_diff_gate", "1", commit="0"))
|
|
224
|
+
|
|
225
|
+
def test_an_unrelated_enforcer_still_fails_open(self):
|
|
226
|
+
"""Widening this to every enforcer would be a session-wide deadlock."""
|
|
227
|
+
self.assertFalse(self.verdict("worktree_required", "1", sec="1", commit="1"))
|
|
228
|
+
self.assertFalse(self.verdict("delivery_enforcement", "1", sec="1", commit="1"))
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
class MissingEnforcerFileIsCaught(unittest.TestCase):
|
|
232
|
+
"""The call sites, not just the selector: a deleted enforcer must block."""
|
|
233
|
+
|
|
234
|
+
def test_the_loop_asks_the_selector_before_continuing(self):
|
|
235
|
+
gate = PRIMARY_HOOK.read_text()
|
|
236
|
+
i = gate.index("enforcer file missing")
|
|
237
|
+
self.assertIn("must_fail_closed", gate[max(0, i - 400):i],
|
|
238
|
+
"a missing enforcer file must consult the selector, not just skip")
|
|
239
|
+
|
|
240
|
+
def test_an_errored_enforcer_is_caught(self):
|
|
241
|
+
gate = PRIMARY_HOOK.read_text()
|
|
242
|
+
i = gate.index('fail_closed "enforcer errored"')
|
|
243
|
+
self.assertIn("must_fail_closed", gate[max(0, i - 400):i],
|
|
244
|
+
"allowed=2 (errored/unparseable) must consult the selector")
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
if __name__ == "__main__":
|
|
248
|
+
unittest.main()
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Tests for plane-admin-protect: credential-mutation guard on the Plane stack.
|
|
2
|
+
|
|
3
|
+
Covers the three things that make this enforcer trustworthy:
|
|
4
|
+
- it catches the observed incident spelling (docker exec ... changepassword)
|
|
5
|
+
and the bash -c wrapper,
|
|
6
|
+
- it does NOT refuse commands that merely NAME a credential verb in prose
|
|
7
|
+
(commit message, memory store, heredoc body) — the false positives that
|
|
8
|
+
make an agent route around a gate,
|
|
9
|
+
- the owner waiver fails CLOSED (exit 2, allowed=false) on expired / missing
|
|
10
|
+
expiry / unreadable, rather than crashing (exit 1) or silently allowing.
|
|
11
|
+
"""
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import subprocess
|
|
15
|
+
import sys
|
|
16
|
+
import tempfile
|
|
17
|
+
import unittest
|
|
18
|
+
from datetime import date, timedelta
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
ENF = Path(__file__).resolve().parents[3] / "src" / "policies" / "enforcers" / "plane_admin_protect.py"
|
|
22
|
+
|
|
23
|
+
MUTATION = "docker exec plane-app-api-1 python manage.py changepassword admin@pay2u.com.au"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def run(root, command, op="Bash"):
|
|
27
|
+
e = dict(os.environ)
|
|
28
|
+
e["UAP_REPO_ROOT"] = str(root)
|
|
29
|
+
p = subprocess.run(
|
|
30
|
+
[sys.executable, str(ENF), "--operation", op,
|
|
31
|
+
"--args", json.dumps({"command": command})],
|
|
32
|
+
capture_output=True, text=True, env=e,
|
|
33
|
+
)
|
|
34
|
+
out = json.loads(p.stdout) if p.stdout.strip() else {}
|
|
35
|
+
return p.returncode, out
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def write_waiver(root, days):
|
|
39
|
+
w = root / "policies" / "waivers" / "plane-admin-reset"
|
|
40
|
+
w.parent.mkdir(parents=True, exist_ok=True)
|
|
41
|
+
w.write_text(f"owner approved reset\nexpires: {(date.today() + timedelta(days=days)).isoformat()}\n")
|
|
42
|
+
return w
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class PlaneAdminProtectTest(unittest.TestCase):
|
|
46
|
+
def setUp(self):
|
|
47
|
+
self._tmp = tempfile.TemporaryDirectory()
|
|
48
|
+
self.root = Path(self._tmp.name)
|
|
49
|
+
self.addCleanup(self._tmp.cleanup)
|
|
50
|
+
|
|
51
|
+
def test_non_shell_operation_allowed(self):
|
|
52
|
+
rc, out = run(self.root, MUTATION, op="Edit")
|
|
53
|
+
self.assertEqual(rc, 0)
|
|
54
|
+
self.assertTrue(out["allowed"])
|
|
55
|
+
|
|
56
|
+
def test_observed_incident_blocked(self):
|
|
57
|
+
rc, out = run(self.root, MUTATION)
|
|
58
|
+
self.assertEqual(rc, 2)
|
|
59
|
+
self.assertFalse(out["allowed"])
|
|
60
|
+
self.assertIn("changepassword", out["reason"])
|
|
61
|
+
self.assertEqual(out.get("route"), "owner-waiver")
|
|
62
|
+
self.assertIn("waiverHint", out)
|
|
63
|
+
|
|
64
|
+
def test_bash_c_wrapped_mutation_blocked(self):
|
|
65
|
+
rc, out = run(self.root, 'bash -c "docker exec plane-app-api-1 manage.py set_password admin"')
|
|
66
|
+
self.assertEqual(rc, 2)
|
|
67
|
+
self.assertFalse(out["allowed"])
|
|
68
|
+
|
|
69
|
+
def test_read_only_plane_command_allowed(self):
|
|
70
|
+
rc, out = run(self.root, "kubectl logs plane-app-worker")
|
|
71
|
+
self.assertEqual(rc, 0)
|
|
72
|
+
self.assertTrue(out["allowed"])
|
|
73
|
+
|
|
74
|
+
def test_prose_naming_verb_not_blocked(self):
|
|
75
|
+
rc, out = run(self.root, 'git commit -m "refused to passwd /opt/plane admin"')
|
|
76
|
+
self.assertEqual(rc, 0)
|
|
77
|
+
self.assertTrue(out["allowed"])
|
|
78
|
+
rc, out = run(self.root, 'uap memory store "never set_password on plane-db"')
|
|
79
|
+
self.assertEqual(rc, 0)
|
|
80
|
+
self.assertTrue(out["allowed"])
|
|
81
|
+
|
|
82
|
+
def test_heredoc_body_naming_verb_not_blocked(self):
|
|
83
|
+
cmd = "python3 - <<PY\n# would createsuperuser on plane-db\nprint(1)\nPY"
|
|
84
|
+
rc, out = run(self.root, cmd)
|
|
85
|
+
self.assertEqual(rc, 0)
|
|
86
|
+
self.assertTrue(out["allowed"])
|
|
87
|
+
|
|
88
|
+
def test_valid_waiver_allows(self):
|
|
89
|
+
write_waiver(self.root, 3)
|
|
90
|
+
rc, out = run(self.root, MUTATION)
|
|
91
|
+
self.assertEqual(rc, 0)
|
|
92
|
+
self.assertTrue(out["allowed"])
|
|
93
|
+
self.assertIn("waiver valid until", out["reason"])
|
|
94
|
+
|
|
95
|
+
def test_expired_waiver_fails_closed(self):
|
|
96
|
+
write_waiver(self.root, -3)
|
|
97
|
+
rc, out = run(self.root, MUTATION)
|
|
98
|
+
self.assertEqual(rc, 2)
|
|
99
|
+
self.assertFalse(out["allowed"])
|
|
100
|
+
self.assertIn("expired", out["reason"])
|
|
101
|
+
|
|
102
|
+
def test_waiver_missing_expiry_fails_closed(self):
|
|
103
|
+
w = self.root / "policies" / "waivers" / "plane-admin-reset"
|
|
104
|
+
w.parent.mkdir(parents=True, exist_ok=True)
|
|
105
|
+
w.write_text("owner approved reset\n")
|
|
106
|
+
rc, out = run(self.root, MUTATION)
|
|
107
|
+
self.assertEqual(rc, 2)
|
|
108
|
+
self.assertFalse(out["allowed"])
|
|
109
|
+
self.assertIn("expires", out["reason"])
|
|
110
|
+
|
|
111
|
+
def test_unreadable_waiver_fails_closed_not_crash(self):
|
|
112
|
+
w = write_waiver(self.root, 3)
|
|
113
|
+
w.chmod(0)
|
|
114
|
+
try:
|
|
115
|
+
rc, out = run(self.root, MUTATION)
|
|
116
|
+
finally:
|
|
117
|
+
w.chmod(0o644)
|
|
118
|
+
self.assertEqual(rc, 2)
|
|
119
|
+
self.assertFalse(out["allowed"])
|
|
120
|
+
self.assertIn("could not be read", out["reason"])
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
if __name__ == "__main__":
|
|
124
|
+
unittest.main()
|
|
@@ -226,3 +226,67 @@ class TestStreakSurvivesAFreshMonitor(unittest.TestCase):
|
|
|
226
226
|
[{"role": "assistant", "content": "plain text"}]):
|
|
227
227
|
self.proxy._seed_tool_history_from_request(mon, bad)
|
|
228
228
|
self.assertEqual(mon.tool_call_history, [])
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
class TestDirectiveQuotesTheActualOutput(unittest.TestCase):
|
|
232
|
+
"""Live 2026-09-29 (ses_f158cb463, qwen3.8): the advisory directive told the
|
|
233
|
+
model 'you already have that output' on a `uap memory query` returning "No
|
|
234
|
+
results" — and it was ignored 7 times, because a weak model does not
|
|
235
|
+
recognise an EMPTY result as an answer; it believes the missing value must
|
|
236
|
+
exist and keeps re-asking. The directive must SHOW the model the output it
|
|
237
|
+
keeps receiving, and say plainly that 'no results' IS the source's answer.
|
|
238
|
+
"""
|
|
239
|
+
|
|
240
|
+
@classmethod
|
|
241
|
+
def setUpClass(cls):
|
|
242
|
+
cls.proxy = load_proxy()
|
|
243
|
+
|
|
244
|
+
NO_RESULTS = "No results in short-term memory\n\nNo results in long-term memory"
|
|
245
|
+
|
|
246
|
+
@staticmethod
|
|
247
|
+
def looping_monitor(proxy, snippet):
|
|
248
|
+
mon = proxy.SessionMonitor(context_window=100000)
|
|
249
|
+
for _ in range(4):
|
|
250
|
+
mon.record_tool_calls(tool_names=["Bash"], fingerprint="Bash|uap memory query x")
|
|
251
|
+
mon.last_tool_result_snippet = snippet
|
|
252
|
+
return mon
|
|
253
|
+
|
|
254
|
+
def inject(self, mon):
|
|
255
|
+
body = {"messages": [{"role": "system", "content": "base"}], "tool_choice": "required"}
|
|
256
|
+
self.proxy._maybe_inject_stuck_break(body, mon)
|
|
257
|
+
return body["messages"][0]["content"]
|
|
258
|
+
|
|
259
|
+
def test_the_directive_quotes_the_repeated_calls_actual_output(self):
|
|
260
|
+
text = self.inject(self.looping_monitor(self.proxy, self.NO_RESULTS))
|
|
261
|
+
self.assertIn("No results in short-term memory", text)
|
|
262
|
+
self.assertIn("returns EXACTLY this every time", text)
|
|
263
|
+
# The trap named in words, not just implied by the quote.
|
|
264
|
+
self.assertIn("'no results' output IS this source's final answer", text)
|
|
265
|
+
|
|
266
|
+
def test_a_long_result_is_bounded_in_the_directive(self):
|
|
267
|
+
text = self.inject(self.looping_monitor(self.proxy, "x" * 5000))
|
|
268
|
+
self.assertLess(len(text), 2000, "a huge tool result must not bloat the directive")
|
|
269
|
+
|
|
270
|
+
def test_a_missing_snippet_keeps_the_directive_valid(self):
|
|
271
|
+
# No tool_result seen (e.g. the repeat was in the request seeds only):
|
|
272
|
+
# the directive must still fire, unquoted, and not crash.
|
|
273
|
+
text = self.inject(self.looping_monitor(self.proxy, ""))
|
|
274
|
+
self.assertIn("SUCCEEDED each time", text)
|
|
275
|
+
self.assertIn("will not change", text)
|
|
276
|
+
self.assertNotIn("returns EXACTLY this", text)
|
|
277
|
+
|
|
278
|
+
def test_the_snippet_is_captured_from_the_latest_tool_result_of_the_request(self):
|
|
279
|
+
# The capture path: the most recent tool_result text in the
|
|
280
|
+
# conversation becomes the monitor snippet, so the NEXT turn's
|
|
281
|
+
# directive quotes what the repeated call actually returned.
|
|
282
|
+
mon = self.proxy.SessionMonitor(context_window=100000)
|
|
283
|
+
msgs = [{"role": "user", "content": "find the slug"}]
|
|
284
|
+
for i in range(2):
|
|
285
|
+
msgs.append({"role": "assistant", "content": [
|
|
286
|
+
{"type": "tool_use", "id": f"t{i}", "name": "Bash",
|
|
287
|
+
"input": {"command": "uap memory query x"}}]})
|
|
288
|
+
msgs.append({"role": "user", "content": [
|
|
289
|
+
{"type": "tool_result", "tool_use_id": f"t{i}",
|
|
290
|
+
"content": self.NO_RESULTS}]})
|
|
291
|
+
self.proxy._record_last_assistant_tool_calls({"messages": msgs}, mon)
|
|
292
|
+
self.assertIn("No results in short-term memory", mon.last_tool_result_snippet)
|