core/tools: add per-turn guard requiring lineno read before edit_lineno

This commit is contained in:
2026-07-29 01:40:31 +08:00
parent 39ecd55ddd
commit f5b566b371
5 changed files with 71 additions and 2 deletions
+4
View File
@@ -1,4 +1,8 @@
from .chat import ChatAgent as ChatAgent
from .chat import get_lineno_read_files as get_lineno_read_files
from .chat import mark_lineno_read as mark_lineno_read
from .chat import reset_lineno_tracker as reset_lineno_tracker
from .chat import was_lineno_read as was_lineno_read
from .compact import build_compacted_seed_messages as build_compacted_seed_messages
from .compact import summarize_messages as summarize_messages
from .events import AgentEvent as AgentEvent
+40
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
from contextvars import ContextVar, Token
from typing import TYPE_CHECKING
from msgspec import UNSET
@@ -46,6 +47,44 @@ if TYPE_CHECKING:
type LimitContinueHook = Callable[[str], bool | Awaitable[bool]]
# Per-conversation tracking of files read with ``with_lineno=True``.
# Set to a fresh ``set`` 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`.
_lineno_read_files: ContextVar[set[str] | None] = ContextVar("_lineno_read_files", default=None)
def reset_lineno_tracker() -> Token[set[str] | None]:
"""Replace the tracker with a fresh empty set; return the previous token.
Call at the start of a new user turn. Restore with
``_lineno_read_files.reset(token)`` when the turn completes.
"""
return _lineno_read_files.set(set())
def get_lineno_read_files() -> set[str]:
"""Return the current turn's set of line-numbered file paths.
Returns an empty set when no turn is active (tools will reject edits).
"""
val = _lineno_read_files.get()
return val if val is not None else set()
def mark_lineno_read(path: str) -> None:
"""Record that *path* was read with line numbers this turn."""
val = _lineno_read_files.get()
if val is not None:
val.add(path)
def was_lineno_read(path: str) -> bool:
"""Return True if *path* was read with line numbers this turn."""
val = _lineno_read_files.get()
return val is not None and path in val
def incomplete_turn_user_text(messages: Sequence[AnyChatMessage]) -> str | None:
"""User text for an incomplete turn, if history can be retried.
@@ -484,6 +523,7 @@ class ChatAgent:
async def run(self, user_text: str) -> AsyncIterator[AgentEvent]:
"""Append a user message (persist immediately), run the tool loop, yield events."""
_ = reset_lineno_tracker()
self._ensure_system_prompt()
user_msg = UserChatMessage(content=user_text)
self.messages.append(user_msg)
+6 -1
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
from typing import TYPE_CHECKING
from plyngent.agent import ToolTag, tool
from plyngent.agent import ToolTag, tool, was_lineno_read
from plyngent.tools.workspace import resolve_path
if TYPE_CHECKING:
@@ -85,6 +85,11 @@ async def edit_lineno(path: str, start_line: int, end_line: int, new_content: st
target = resolve_path(path)
if not target.is_file():
return f"error: not a file: {path}"
if not was_lineno_read(str(target)):
return (
f"error: read {path!r} first with read_file(path, with_lineno=true) "
"to get correct line numbers"
)
text = target.read_text(encoding="utf-8", errors="replace")
lines = text.splitlines(keepends=True)
+2 -1
View File
@@ -1,6 +1,6 @@
from __future__ import annotations
from plyngent.agent import ToolTag, tool
from plyngent.agent import ToolTag, mark_lineno_read, tool
from plyngent.tools.workspace import resolve_path
_LINENO_WIDTH = 6
@@ -44,5 +44,6 @@ async def read_file(
return ""
slice_lines = lines[start:end]
if with_lineno:
mark_lineno_read(str(target))
return _format_with_lineno(slice_lines, start_lineno=start + 1)
return "".join(slice_lines)