mirror of
https://github.com/NCBM/plyngent.git
synced 2026-11-07 07:17:54 +08:00
6d7c239718
Registry cases (none / once / round / grant / YOLO / denial / error / handler record / closest answer wins), the wire cap keeping the line, the three policy paths that record their answer, and a pretty detail that ignores the header. The existing confirm tests now assert the line they gain.
155 lines
5.3 KiB
Python
155 lines
5.3 KiB
Python
"""The ``[approval]`` line: a confirmed call says how it got through."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import TYPE_CHECKING
|
|
|
|
from plyngent.agent import (
|
|
Approval,
|
|
BatchConfirmDecision,
|
|
ToolRegistry,
|
|
ToolTag,
|
|
format_approval,
|
|
most_specific_approval,
|
|
tool,
|
|
)
|
|
from plyngent.agent.budget import cap_tool_message
|
|
from plyngent.lmproto.openai_compatible.model import ToolChatMessage
|
|
from plyngent.tools.context import SessionState, bind_tool_context, record_approval
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Mapping, Sequence
|
|
|
|
from plyngent.agent import ToolConfirmRequest
|
|
|
|
|
|
def _danger(name: str, _args: Mapping[str, object]) -> str | None:
|
|
return f"mutate {name}"
|
|
|
|
|
|
def _allow(_name: str, _args: Mapping[str, object], _reason: str) -> bool:
|
|
return True
|
|
|
|
|
|
@tool(register=False)
|
|
async def quiet() -> str:
|
|
return "quiet"
|
|
|
|
|
|
@tool(register=False)
|
|
async def mutate() -> str:
|
|
return "ran"
|
|
|
|
|
|
async def test_a_call_that_needs_no_confirmation_carries_no_line() -> None:
|
|
"""The common case stays byte-for-byte what the tool returned."""
|
|
|
|
def danger(_name: str, _args: Mapping[str, object]) -> str | None:
|
|
return None
|
|
|
|
def on_confirm(_name: str, _args: Mapping[str, object], _reason: str) -> bool:
|
|
msg = "no prompt expected"
|
|
raise AssertionError(msg)
|
|
|
|
reg = ToolRegistry([quiet], danger=danger, on_confirm=on_confirm)
|
|
assert await reg.execute("quiet", "{}") == "quiet"
|
|
|
|
|
|
async def test_a_single_prompt_leads_the_result() -> None:
|
|
reg = ToolRegistry([mutate], danger=_danger, on_confirm=_allow)
|
|
assert await reg.execute("mutate", "{}") == "[approval] user (once)\nran"
|
|
|
|
|
|
async def test_yolo_mode_says_it_dropped_the_prompt() -> None:
|
|
@tool(tags=ToolTag.LOCAL | ToolTag.YOLO, register=False)
|
|
async def yolo_ok() -> str:
|
|
return "y"
|
|
|
|
reg = ToolRegistry([yolo_ok], danger=_danger, on_confirm=_allow, yolo=True)
|
|
assert await reg.execute("yolo_ok", "{}") == "[approval] yolo (auto)\ny"
|
|
|
|
|
|
async def test_a_batch_answer_covers_its_round() -> None:
|
|
def on_batch(_requests: Sequence[ToolConfirmRequest]) -> BatchConfirmDecision:
|
|
return BatchConfirmDecision(allowed=True)
|
|
|
|
reg = ToolRegistry([mutate], danger=_danger, on_confirm_batch=on_batch)
|
|
calls = [("mutate", "{}"), ("mutate", "{}")]
|
|
decisions = await reg.prepare_batch_confirm(calls)
|
|
results = [
|
|
await reg.execute(name, arguments, decision=decisions[index]) for index, (name, arguments) in enumerate(calls)
|
|
]
|
|
assert results == ["[approval] user (round)\nran", "[approval] user (round)\nran"]
|
|
|
|
|
|
async def test_a_session_grant_says_nobody_was_asked_this_time() -> None:
|
|
@tool(tags=ToolTag.LOCAL | ToolTag.TRUSTABLE, register=False)
|
|
async def trust_me() -> str:
|
|
return "ok"
|
|
|
|
prompts: list[str] = []
|
|
|
|
def on_confirm(name: str, _args: Mapping[str, object], _reason: str) -> bool:
|
|
prompts.append(name)
|
|
return True
|
|
|
|
session = SessionState(session_id=1)
|
|
reg = ToolRegistry([trust_me], danger=_danger, on_confirm=on_confirm, session_state=session)
|
|
with bind_tool_context(session=session):
|
|
assert await reg.execute("trust_me", "{}") == "[approval] user (once)\nok"
|
|
assert await reg.execute("trust_me", "{}") == "[approval] grant (session)\nok"
|
|
assert prompts == ["trust_me"]
|
|
|
|
|
|
async def test_a_denied_call_carries_no_line() -> None:
|
|
reg = ToolRegistry([mutate], danger=_danger, on_confirm=lambda *_args: False)
|
|
out = await reg.execute("mutate", "{}")
|
|
assert out.startswith("error: ")
|
|
assert "[approval]" not in out
|
|
|
|
|
|
async def test_a_failed_call_carries_no_line() -> None:
|
|
"""An approved call that failed is an error first; the answer explains nothing."""
|
|
|
|
@tool(register=False)
|
|
async def boom() -> str:
|
|
return "error: boom"
|
|
|
|
reg = ToolRegistry([boom], danger=_danger, on_confirm=_allow)
|
|
assert await reg.execute("boom", "{}") == "error: boom"
|
|
|
|
|
|
async def test_a_handler_record_leads_the_result() -> None:
|
|
@tool(register=False)
|
|
async def policy_tool() -> str:
|
|
record_approval(Approval("user", "process"))
|
|
return "ran"
|
|
|
|
reg = ToolRegistry([policy_tool])
|
|
assert await reg.execute("policy_tool", "{}") == "[approval] user (process)\nran"
|
|
|
|
|
|
async def test_the_closest_answer_wins() -> None:
|
|
"""YOLO let the call run, but a human answered a policy prompt inside it."""
|
|
|
|
@tool(tags=ToolTag.LOCAL | ToolTag.YOLO, register=False)
|
|
async def yolo_policy() -> str:
|
|
record_approval(Approval("user", "process"))
|
|
return "ran"
|
|
|
|
reg = ToolRegistry([yolo_policy], danger=_danger, on_confirm=_allow, yolo=True)
|
|
assert await reg.execute("yolo_policy", "{}") == "[approval] user (process)\nran"
|
|
|
|
|
|
async def test_the_line_survives_the_wire_cap() -> None:
|
|
approved = f"{format_approval(Approval('user', 'once'))}\n{'x' * 500}"
|
|
capped = cap_tool_message(ToolChatMessage(content=approved, tool_call_id="call-1"), 200)
|
|
assert capped.content.startswith("[approval] user (once)\n")
|
|
assert "document_id=call-1" in capped.content
|
|
|
|
|
|
def test_the_most_specific_record_wins() -> None:
|
|
records = [Approval("yolo", "auto"), Approval("grant", "session"), Approval("user", "once")]
|
|
assert most_specific_approval(records) == Approval("user", "once")
|
|
assert most_specific_approval([]) is None
|