6 Commits

Author SHA1 Message Date
NCBM 4676b9af32 release: plyngent 0.14.2 2026-10-26 00:54:34 +08:00
NCBM 865d314234 docs: say the terminal keeps its own prompt line
A prompt answered from the notification reads both surfaces; the terminal still
shows the keys it takes, which a button cannot say.
2026-10-26 00:53:08 +08:00
NCBM 9139655e54 test: cover the limit prompt line while a notification offers buttons
The answer now comes from the notification hook, and the terminal still has to
spell out the keys it accepts.
2026-10-26 00:52:58 +08:00
NCBM 741d1a8526 core/cli: keep the limit prompt line on the terminal
Once the limit prompt offered its three answers as notification buttons, the
read took the button path and skipped the prompt line the terminal used to
print: the human at the terminal saw the reason and then an indefinite wait,
with nothing saying what to type. It writes the line again, as every other
prompt that reads both surfaces already does.
2026-10-26 00:52:48 +08:00
NCBM 44b6ab90ed test: cover the scratch hint in the escape refusal
One test for the write path that now names `new_temporary_workspace`, one
parametrized over read and exec, which must not.
2026-10-26 00:52:38 +08:00
NCBM fb77fd676b core/tools: point a refused write at a temporary workspace
A path outside the workspace is usually rejected while the model only wants a
scratch file: the refusal named the grant tool, so the model had to ask a human
for access it never needed. A write now also names `new_temporary_workspace`,
whose path needs no grant. Reads and commands keep the grant hint alone.
2026-10-26 00:52:28 +08:00
6 changed files with 41 additions and 2 deletions
+3 -1
View File
@@ -84,7 +84,9 @@ back through a surface that outlives it.
A prompt that can be answered from the notification reads both surfaces while it
blocks, from the first moment — the notification itself only shows up after
`grace_seconds`, so waiting for it to appear before listening would block on the
terminal alone. Typing at the terminal always wins; a readline-only convenience
terminal alone. The terminal keeps its own prompt line with the keys it accepts —
a button elsewhere does not say what typing here would do. Typing at the terminal
always wins; a readline-only convenience
(Tab completion on `ask_user_line`) is what a question gives up in exchange.
A prompt that *follows* an answer given on the notification — the "why denied"
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "plyngent"
version = "0.14.1"
version = "0.14.2"
description = "LLM chat and agent toolkit"
authors = [
{ name = "HivertMoZara", email = "worldmozara@163.com" },
+5
View File
@@ -232,6 +232,11 @@ def _decide_continue_limit(reason: str) -> bool:
else:
# Same read as the timed confirms: stdin or the notification,
# whichever answers first, with the terminal's default on silence.
# The prompt line still goes to the terminal: the buttons elsewhere
# do not say what typing here would do.
with contextlib.suppress(OSError):
_ = sys.stderr.write(_CONTINUE_PROMPT)
_ = sys.stderr.flush()
token = (_read_answer(None, wait) or "").strip().lower()
if not token:
token = "y"
+8
View File
@@ -445,9 +445,17 @@ def resolve_path(path: str | Path, *, required: AccessMode = AccessMode.WRITE) -
candidate = root / candidate
resolved = candidate.expanduser().resolve()
if not _under_any_root(resolved, instance, policy) and not _path_grant_covers(resolved, required):
# A refused *write* is often a scratch file the model meant to keep in
# the workspace: name that way out too, next to asking the human.
scratch = (
"; if the file is only scratch, call new_temporary_workspace and write under the absolute path it returns"
if required is AccessMode.WRITE
else ""
)
msg = (
f"path escapes workspace root ({root}): {path}; "
f"call request_directory_access with mode={required.name.lower()!r} to request access outside the workspace"
f"{scratch}"
)
raise WorkspaceError(msg)
# Normalize separators so denylist entries like ``/secrets/`` match on Windows.
+9
View File
@@ -396,6 +396,15 @@ def test_a_limit_prompt_can_stop_the_turn_from_the_notification() -> None:
assert limits.prompt_continue_limit("hit a wall") is False
def test_a_limit_prompt_still_names_its_answers_at_the_terminal(capsys: pytest.CaptureFixture[str]) -> None:
"""Buttons elsewhere do not say what typing here would do."""
from plyngent.cli import limits
with temporary_backend(ScriptedBackend([])), _answered("y"):
assert limits.prompt_continue_limit("hit a wall") is True
assert "[Y/n/yyy]" in capsys.readouterr().err
def test_a_skill_write_can_be_allowed_for_the_chat_from_the_notification() -> None:
"""``s`` (this chat) is a button, not only a terminal token."""
from pathlib import Path
+15
View File
@@ -50,6 +50,21 @@ def test_escape_error_names_the_mode_to_request(workspace: object, required: Acc
_ = resolve_path("../outside", required=required)
def test_escape_error_offers_a_temporary_workspace_for_a_write(workspace: object) -> None:
"""A refused write is often scratch, which never needs a grant."""
del workspace
with pytest.raises(WorkspaceError, match="new_temporary_workspace"):
_ = resolve_path("../scratch.txt", required=AccessMode.WRITE)
@pytest.mark.parametrize("required", [AccessMode.READ, AccessMode.EXEC])
def test_escape_error_hides_the_scratch_hint_from_other_modes(workspace: object, required: AccessMode) -> None:
del workspace
with pytest.raises(WorkspaceError) as refusal:
_ = resolve_path("../scratch.txt", required=required)
assert "new_temporary_workspace" not in str(refusal.value)
def test_access_mode_parse_and_covers() -> None:
assert parse_access_mode(" Read ") is AccessMode.READ
assert parse_access_mode("EXEC") is AccessMode.EXEC