9 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
NCBM a79039f06f release: plyngent 0.14.1 2026-10-25 00:21:04 +08:00
NCBM ff656b800a test: cover a numbered read surviving a retry into a fresh attempt task 2026-10-25 00:19:14 +08:00
NCBM 2039d4f31f core: rebind the lineno tracker when retry continues a turn in a new task 2026-10-25 00:19:01 +08:00
8 changed files with 222 additions and 21 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 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 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 `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. (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" A prompt that *follows* an answer given on the notification — the "why denied"
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "plyngent" name = "plyngent"
version = "0.14.0" version = "0.14.2"
description = "LLM chat and agent toolkit" description = "LLM chat and agent toolkit"
authors = [ authors = [
{ name = "HivertMoZara", email = "worldmozara@163.com" }, { name = "HivertMoZara", email = "worldmozara@163.com" },
+45 -6
View File
@@ -56,10 +56,20 @@ if TYPE_CHECKING:
# Per-conversation tracking of lines read with ``with_lineno=True``, keyed by # Per-conversation tracking of lines read with ``with_lineno=True``, keyed by
# resolved file path, with the file's mtime at read time (so stale line numbers # resolved file path, with the file's mtime at read time (so stale line numbers
# after external/process mutations are detected). Set to a fresh ``dict`` at the # after external/process mutations are detected). The dict itself is owned by
# start of each user turn via :func:`reset_lineno_tracker`; cleared after the # the :class:`ChatAgent` (a fresh one per user turn) and bound into the
# assistant finishes. Tool handlers read/write via :func:`get_lineno_read_files` / # consuming task's context via :func:`reset_lineno_tracker` /
# :func:`lineno_read_lines` / :func:`lineno_read_mtime` / :func:`mark_lineno_read`. # :func:`bind_lineno_tracker`; tool handlers read/write it via
# :func:`get_lineno_read_files` / :func:`lineno_read_lines` /
# :func:`lineno_read_mtime` / :func:`mark_lineno_read`.
#
# The binding lives in the *consuming task's* context: the host wraps each turn
# attempt in its own task (auto-retry, Ctrl+C then ``/retry``), and
# ``asyncio.create_task`` copies the context of the caller — not of the previous
# attempt. Retrying therefore re-binds the *same* turn-scoped dict; without
# that, ``mark_lineno_read`` would silently drop every numbered read (the
# variable is unset in the new task) and ``edit_lineno`` would reject all edits
# even after the model re-reads the file as its own error message instructs.
_lineno_read_files: ContextVar[dict[str, tuple[set[int], int]] | None] = ContextVar( _lineno_read_files: ContextVar[dict[str, tuple[set[int], int]] | None] = ContextVar(
"_lineno_read_files", "_lineno_read_files",
default=None, default=None,
@@ -67,7 +77,7 @@ _lineno_read_files: ContextVar[dict[str, tuple[set[int], int]] | None] = Context
def reset_lineno_tracker() -> Token[dict[str, tuple[set[int], int]] | None]: def reset_lineno_tracker() -> Token[dict[str, tuple[set[int], int]] | None]:
"""Replace the tracker with a fresh empty mapping; return the previous token. """Bind a fresh empty tracker; return the previous token.
Call at the start of a new user turn. Restore with Call at the start of a new user turn. Restore with
``_lineno_read_files.reset(token)`` when the turn completes. ``_lineno_read_files.reset(token)`` when the turn completes.
@@ -75,6 +85,18 @@ def reset_lineno_tracker() -> Token[dict[str, tuple[set[int], int]] | None]:
return _lineno_read_files.set({}) return _lineno_read_files.set({})
def bind_lineno_tracker(
tracker: dict[str, tuple[set[int], int]],
) -> Token[dict[str, tuple[set[int], int]] | None]:
"""Bind an agent-owned tracker dict; return the previous token.
``retry`` uses this to re-bind the current turn's dict in a new attempt
task, so numbered reads from before the failure stay valid for edits after
it (the turn did not end, so its reads should not either).
"""
return _lineno_read_files.set(tracker)
def get_lineno_read_files() -> set[str]: def get_lineno_read_files() -> set[str]:
"""Return the current turn's set of line-numbered file paths. """Return the current turn's set of line-numbered file paths.
@@ -350,6 +372,9 @@ class ChatAgent:
reminder_last_band: int = 0, reminder_last_band: int = 0,
provider_tools: Sequence[dict[str, object]] | None = None, provider_tools: Sequence[dict[str, object]] | None = None,
) -> None: ) -> None:
# Lines read with_lineno this turn (shared dict bound into each attempt
# task's context; see the module note above _lineno_read_files).
self._lineno_tracker: dict[str, tuple[set[int], int]] = {}
self.client = client self.client = client
self.model = model self.model = model
self.tools = tools self.tools = tools
@@ -676,7 +701,11 @@ class ChatAgent:
*images* ride along with the text as content parts, which is the shape *images* ride along with the text as content parts, which is the shape
the model sees an attachment in (see ``agent.images``). the model sees an attachment in (see ``agent.images``).
""" """
_ = reset_lineno_tracker() # A new user turn gets a fresh tracker; the dict is owned by the agent
# so a retry (new task, fresh context copy) can re-bind the same dict.
self._lineno_tracker = {}
token = bind_lineno_tracker(self._lineno_tracker)
try:
self._ensure_system_prompt() self._ensure_system_prompt()
user_msg = UserChatMessage(content=user_content(user_text, images)) user_msg = UserChatMessage(content=user_content(user_text, images))
self.messages.append(user_msg) self.messages.append(user_msg)
@@ -684,6 +713,8 @@ class ChatAgent:
self._persist_from = len(self.messages) self._persist_from = len(self.messages)
async for event in self._run_from_user_message(user_msg): async for event in self._run_from_user_message(user_msg):
yield event yield event
finally:
_lineno_read_files.reset(token)
def _resolve_aside_tools( def _resolve_aside_tools(
self, self,
@@ -803,8 +834,16 @@ class ChatAgent:
user_msg = self.messages[user_index] user_msg = self.messages[user_index]
assert isinstance(user_msg, UserChatMessage) assert isinstance(user_msg, UserChatMessage)
self._ensure_system_prompt() self._ensure_system_prompt()
# This attempt runs in a new task whose context copy has no tracker
# bound; re-bind the turn's dict so numbered reads before the failure
# stay valid for edits after it, and reads during the retry land in the
# same dict the next tool call sees.
token = bind_lineno_tracker(self._lineno_tracker)
try:
# Already-committed rows stay; write any the failed rollback could not. # Already-committed rows stay; write any the failed rollback could not.
if self._persist_from < len(self.messages): if self._persist_from < len(self.messages):
await self._persist_range(self._persist_from, len(self.messages)) await self._persist_range(self._persist_from, len(self.messages))
async for event in self._run_from_user_message(user_msg): async for event in self._run_from_user_message(user_msg):
yield event yield event
finally:
_lineno_read_files.reset(token)
+5
View File
@@ -232,6 +232,11 @@ def _decide_continue_limit(reason: str) -> bool:
else: else:
# Same read as the timed confirms: stdin or the notification, # Same read as the timed confirms: stdin or the notification,
# whichever answers first, with the terminal's default on silence. # 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() token = (_read_answer(None, wait) or "").strip().lower()
if not token: if not token:
token = "y" token = "y"
+8
View File
@@ -445,9 +445,17 @@ def resolve_path(path: str | Path, *, required: AccessMode = AccessMode.WRITE) -
candidate = root / candidate candidate = root / candidate
resolved = candidate.expanduser().resolve() resolved = candidate.expanduser().resolve()
if not _under_any_root(resolved, instance, policy) and not _path_grant_covers(resolved, required): 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 = ( msg = (
f"path escapes workspace root ({root}): {path}; " 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"call request_directory_access with mode={required.name.lower()!r} to request access outside the workspace"
f"{scratch}"
) )
raise WorkspaceError(msg) raise WorkspaceError(msg)
# Normalize separators so denylist entries like ``/secrets/`` match on Windows. # Normalize separators so denylist entries like ``/secrets/`` match on Windows.
+124 -1
View File
@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING, Literal, overload import asyncio
from typing import TYPE_CHECKING, Literal, cast, overload
import pytest import pytest
from msgspec import UNSET from msgspec import UNSET
@@ -928,6 +929,128 @@ async def test_retry_keeps_committed_tools_after_second_round_fails() -> None:
await store.close() await store.close()
async def test_retry_keeps_lineno_reads_across_attempt_tasks(tmp_path: object) -> None:
"""A numbered read before a failed round still enables edits after retry.
Each turn attempt runs in its own task (the host wraps turns in
``asyncio.create_task``), so the retry attempt's context does not carry the
first attempt's ContextVar binding. Without ``retry`` re-binding the lineno
tracker, ``read_file(with_lineno=true)`` silently records nothing and
``edit_lineno`` rejects every line edit — even after re-reading the file as
its own error message instructs.
"""
from pathlib import Path
from plyngent.tools.context import InstanceState
from plyngent.tools.file import edit_lineno, read_file
from plyngent.tools.workspace import (
clear_workspace_allowlist,
clear_workspace_root,
set_workspace_root,
)
root = cast("Path", tmp_path).resolve()
(root / "f.txt").write_text("alpha\nbravo\ncharlie\n", encoding="utf-8")
instance = InstanceState(workspace_root=root)
from plyngent.tools.context import bind_instance
with bind_instance(instance):
clear_workspace_allowlist()
_ = set_workspace_root(root)
try:
registry = ToolRegistry([read_file, edit_lineno], instance_state=instance)
class ReadThenBoom:
"""Round 1 reads with line numbers; round 2's model call fails."""
n: int
def __init__(self) -> None:
self.n = 0
@overload
async def chat_completions(
self, param: ChatCompletionsParam, *, stream: Literal[False] = False
) -> ChatCompletionResponse: ...
@overload
async def chat_completions(
self, param: ChatCompletionsParam, *, stream: Literal[True]
) -> AsyncIterator[ChatCompletionChunk]: ...
async def chat_completions(
self, param: ChatCompletionsParam, *, stream: bool = False
) -> ChatCompletionResponse | AsyncIterator[ChatCompletionChunk]:
del stream
self.n += 1
if self.n == 1:
return _response(
AssistantChatMessage(
content="",
tool_calls=[
AssistantFunctionToolCall(
id="c1",
function=AssistantFunctionTool(
name="read_file",
arguments='{"path": "f.txt", "with_lineno": true}',
),
)
],
)
)
if self.n == 2:
msg = "api down after read"
raise RuntimeError(msg)
if self.n == 3:
edit_args = '{"path": "f.txt", "start_line": 2, "end_line": 2, "new_content": "BRAVO\\n"}'
return _response(
AssistantChatMessage(
content="",
tool_calls=[
AssistantFunctionToolCall(
id="c2",
function=AssistantFunctionTool(
name="edit_lineno",
arguments=edit_args,
),
)
],
)
)
return _response(AssistantChatMessage(content="done"))
client = ReadThenBoom()
agent = ChatAgent(
client,
model="m",
tools=registry,
stream=False,
)
async def run_attempt() -> list[object]:
return [e async for e in agent.run("edit line 2")]
# First attempt in its own task (mirrors run_cancellable): commits
# the read, then the next model round fails.
with pytest.raises(RuntimeError, match="api down after read"):
await asyncio.create_task(run_attempt())
assert isinstance(agent.messages[-1], ToolChatMessage)
# Retry in a *fresh* task: its context copy has no tracker bound.
async def run_retry() -> list[object]:
return [e async for e in agent.retry()]
events = await asyncio.create_task(run_retry())
results = [e for e in events if isinstance(e, ToolResultEvent)]
assert results, "retry must run the edit_lineno call"
assert "replaced lines 2-2" in results[0].message.content
assert (root / "f.txt").read_text(encoding="utf-8") == "alpha\nBRAVO\ncharlie\n"
assert agent.pending_retry_text is None
finally:
clear_workspace_allowlist()
clear_workspace_root()
async def test_chat_agent_system_prompt_prepended() -> None: async def test_chat_agent_system_prompt_prepended() -> None:
from plyngent.lmproto.openai_compatible.model import SystemChatMessage from plyngent.lmproto.openai_compatible.model import SystemChatMessage
+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 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: 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.""" """``s`` (this chat) is a button, not only a terminal token."""
from pathlib import Path 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) _ = 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: def test_access_mode_parse_and_covers() -> None:
assert parse_access_mode(" Read ") is AccessMode.READ assert parse_access_mode(" Read ") is AccessMode.READ
assert parse_access_mode("EXEC") is AccessMode.EXEC assert parse_access_mode("EXEC") is AccessMode.EXEC