3 Commits

3 changed files with 182 additions and 20 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "plyngent"
version = "0.14.0"
version = "0.14.1"
description = "LLM chat and agent toolkit"
authors = [
{ name = "HivertMoZara", email = "worldmozara@163.com" },
+57 -18
View File
@@ -56,10 +56,20 @@ if TYPE_CHECKING:
# 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
# after external/process mutations are detected). Set to a fresh ``dict`` at the
# start of each user turn via :func:`reset_lineno_tracker`; cleared after the
# assistant finishes. Tool handlers read/write via :func:`get_lineno_read_files` /
# :func:`lineno_read_lines` / :func:`lineno_read_mtime` / :func:`mark_lineno_read`.
# after external/process mutations are detected). The dict itself is owned by
# the :class:`ChatAgent` (a fresh one per user turn) and bound into the
# consuming task's context via :func:`reset_lineno_tracker` /
# :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",
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]:
"""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
``_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({})
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]:
"""Return the current turn's set of line-numbered file paths.
@@ -350,6 +372,9 @@ class ChatAgent:
reminder_last_band: int = 0,
provider_tools: Sequence[dict[str, object]] | 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.model = model
self.tools = tools
@@ -676,14 +701,20 @@ class ChatAgent:
*images* ride along with the text as content parts, which is the shape
the model sees an attachment in (see ``agent.images``).
"""
_ = reset_lineno_tracker()
self._ensure_system_prompt()
user_msg = UserChatMessage(content=user_content(user_text, images))
self.messages.append(user_msg)
await self._persist(user_msg)
self._persist_from = len(self.messages)
async for event in self._run_from_user_message(user_msg):
yield event
# 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()
user_msg = UserChatMessage(content=user_content(user_text, images))
self.messages.append(user_msg)
await self._persist(user_msg)
self._persist_from = len(self.messages)
async for event in self._run_from_user_message(user_msg):
yield event
finally:
_lineno_read_files.reset(token)
def _resolve_aside_tools(
self,
@@ -803,8 +834,16 @@ class ChatAgent:
user_msg = self.messages[user_index]
assert isinstance(user_msg, UserChatMessage)
self._ensure_system_prompt()
# Already-committed rows stay; write any the failed rollback could not.
if 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):
yield event
# 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.
if 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):
yield event
finally:
_lineno_read_files.reset(token)
+124 -1
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Literal, overload
import asyncio
from typing import TYPE_CHECKING, Literal, cast, overload
import pytest
from msgspec import UNSET
@@ -928,6 +929,128 @@ async def test_retry_keeps_committed_tools_after_second_round_fails() -> None:
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:
from plyngent.lmproto.openai_compatible.model import SystemChatMessage