mirror of
https://github.com/NCBM/plyngent.git
synced 2026-11-07 23:47:54 +08:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4676b9af32 | |||
| 865d314234 | |||
| 9139655e54 | |||
| 741d1a8526 | |||
| 44b6ab90ed | |||
| fb77fd676b | |||
| a79039f06f | |||
| ff656b800a | |||
| 2039d4f31f |
@@ -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
@@ -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" },
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user