core/agent+tools: apply a batch's same-file writes in call order

This commit is contained in:
2026-10-16 19:37:29 +08:00
parent ab6b5d590c
commit 85b0318b77
7 changed files with 208 additions and 17 deletions
+100 -1
View File
@@ -22,7 +22,7 @@ from plyngent.lmproto.openai_compatible.model import (
)
if TYPE_CHECKING:
from collections.abc import AsyncIterator
from collections.abc import AsyncIterator, Mapping
def _response(message: AssistantChatMessage) -> ChatCompletionResponse:
@@ -156,3 +156,102 @@ async def test_parallel_tool_calls_run_concurrently() -> None:
tool_msgs = [e for e in agent.messages if isinstance(e, ToolChatMessage)]
assert {m.content for m in tool_msgs} == {"a", "b"}
assert any(getattr(e, "content", None) == "done" for e in events)
async def test_same_file_writes_apply_in_call_order() -> None:
"""A batch's writes to one file run one at a time, in call order."""
order: list[str] = []
@tool(register=False, writes=("path",))
async def write_a(path: str) -> str:
order.append(f"a start {path}")
await asyncio.sleep(0) # an unserialized sibling would interleave here
order.append(f"a end {path}")
return "a"
@tool(register=False, writes=("path",))
async def write_b(path: str) -> str:
order.append(f"b start {path}")
await asyncio.sleep(0)
order.append(f"b end {path}")
return "b"
registry = ToolRegistry([write_a, write_b])
results = await asyncio.gather(
registry.execute("write_a", '{"path": "a.txt"}'),
registry.execute("write_b", '{"path": "./a.txt"}'),
)
assert results == ["a", "b"]
assert order == ["a start a.txt", "a end a.txt", "b start ./a.txt", "b end ./a.txt"]
async def test_different_files_still_write_concurrently() -> None:
"""Serialization is per file: two files of one batch still overlap."""
overlaps = 0
gate = asyncio.Event()
async def _rendezvous() -> None:
nonlocal overlaps
overlaps += 1
if overlaps == 2:
_ = gate.set()
_ = await asyncio.wait_for(gate.wait(), timeout=2.0)
@tool(register=False, writes=("path",))
async def write_a(path: str) -> str:
del path
await _rendezvous()
return "a"
@tool(register=False, writes=("path",))
async def write_b(path: str) -> str:
del path
await _rendezvous()
return "b"
registry = ToolRegistry([write_a, write_b])
results = await asyncio.gather(
registry.execute("write_a", '{"path": "a.txt"}'),
registry.execute("write_b", '{"path": "b.txt"}'),
)
assert results == ["a", "b"]
async def test_a_write_holds_its_file_across_the_confirm() -> None:
"""The file is locked before the prompt, so a sibling write cannot overtake it."""
order: list[str] = []
prompt_shown = asyncio.Event()
answered = asyncio.Event()
@tool(register=False, writes=("path",))
async def write_a(path: str) -> str:
del path
order.append("a")
return "a"
@tool(register=False, writes=("path",))
async def write_b(path: str) -> str:
del path
order.append("b")
return "b"
async def on_confirm(name: str, args: Mapping[str, object], reason: str) -> bool:
del args, reason
order.append(f"confirm {name}")
prompt_shown.set()
await answered.wait()
return True
def danger(name: str, args: Mapping[str, object]) -> str | None:
del args
return "overwrite" if name == "write_a" else None
registry = ToolRegistry([write_a, write_b], danger=danger, on_confirm=on_confirm)
first = asyncio.create_task(registry.execute("write_a", '{"path": "a.txt"}'))
await prompt_shown.wait()
second = asyncio.create_task(registry.execute("write_b", '{"path": "a.txt"}'))
await asyncio.sleep(0) # the second write waits for the file, not for the human
assert order == ["confirm write_a"]
answered.set()
assert await asyncio.gather(first, second) == ["a", "b"]
assert order == ["confirm write_a", "a", "b"]