mirror of
https://github.com/NCBM/plyngent.git
synced 2026-07-23 05:55:16 +08:00
core/agent+cli: defer DB write until turn success; auto/manual retry
Failed turns roll back memory and keep pending_retry_text; persist only after success. CLI auto-retries 10s/20s/30s (cancellable) and /retry.
This commit is contained in:
@@ -2,6 +2,8 @@ from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Literal, overload
|
||||
|
||||
import pytest
|
||||
|
||||
from plyngent.agent import (
|
||||
AssistantMessageEvent,
|
||||
ChatAgent,
|
||||
@@ -225,3 +227,91 @@ async def test_chat_agent_memory_roundtrip() -> None:
|
||||
await agent2.load_history()
|
||||
assert len(agent2.messages) == 2 # noqa: PLR2004
|
||||
await store.close()
|
||||
|
||||
|
||||
async def test_chat_agent_failed_turn_not_persisted() -> None:
|
||||
store = await MemoryStore.open(DatabaseConfig())
|
||||
session = await store.create_session(name="t")
|
||||
|
||||
class BoomClient:
|
||||
@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 param, stream
|
||||
msg = "network down"
|
||||
raise RuntimeError(msg)
|
||||
|
||||
agent = ChatAgent(BoomClient(), model="m", memory=store, session_id=session.sid)
|
||||
with pytest.raises(RuntimeError, match="network down"):
|
||||
_ = [e async for e in agent.run("hello")]
|
||||
|
||||
assert agent.messages == []
|
||||
assert agent.pending_retry_text == "hello"
|
||||
loaded = await store.list_messages(session.sid)
|
||||
assert loaded == []
|
||||
await store.close()
|
||||
|
||||
|
||||
async def test_chat_agent_retry_after_failure() -> None:
|
||||
store = await MemoryStore.open(DatabaseConfig())
|
||||
session = await store.create_session(name="t")
|
||||
|
||||
class FlakyClient:
|
||||
calls: int
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.calls = 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 param
|
||||
self.calls += 1
|
||||
if self.calls == 1:
|
||||
msg = "temporary"
|
||||
raise RuntimeError(msg)
|
||||
if stream:
|
||||
|
||||
async def empty() -> AsyncIterator[ChatCompletionChunk]:
|
||||
empty_chunks: list[ChatCompletionChunk] = []
|
||||
for chunk in empty_chunks:
|
||||
yield chunk
|
||||
|
||||
return empty()
|
||||
return _response(AssistantChatMessage(content="recovered"))
|
||||
|
||||
client = FlakyClient()
|
||||
agent = ChatAgent(client, model="m", memory=store, session_id=session.sid)
|
||||
with pytest.raises(RuntimeError, match="temporary"):
|
||||
_ = [e async for e in agent.run("ping")]
|
||||
assert agent.pending_retry_text == "ping"
|
||||
assert await store.list_messages(session.sid) == []
|
||||
|
||||
events = [e async for e in agent.retry()]
|
||||
assert any(isinstance(e, TextDeltaEvent) and e.content == "recovered" for e in events)
|
||||
assert agent.pending_retry_text is None
|
||||
loaded = await store.list_messages(session.sid)
|
||||
assert len(loaded) == 2 # noqa: PLR2004
|
||||
assert isinstance(loaded[0], UserChatMessage)
|
||||
assert loaded[0].content == "ping"
|
||||
await store.close()
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Literal, overload
|
||||
|
||||
from plyngent.agent import ChatAgent
|
||||
from plyngent.cli.retry import retry_pending_with_retries, run_turn_with_retries, sleep_cancellable
|
||||
from plyngent.config.models import DatabaseConfig
|
||||
from plyngent.lmproto.openai_compatible.model import (
|
||||
AssistantChatMessage,
|
||||
ChatCompletionChoice,
|
||||
ChatCompletionChunk,
|
||||
ChatCompletionResponse,
|
||||
ChatCompletionsParam,
|
||||
UserChatMessage,
|
||||
)
|
||||
from plyngent.memory import MemoryStore
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _response(message: AssistantChatMessage) -> ChatCompletionResponse:
|
||||
return ChatCompletionResponse(
|
||||
id="1",
|
||||
object="chat.completion",
|
||||
created=0,
|
||||
model="test",
|
||||
choices=[
|
||||
ChatCompletionChoice(
|
||||
index=0,
|
||||
message=message,
|
||||
logprobs={},
|
||||
finish_reason="stop",
|
||||
)
|
||||
],
|
||||
system_fingerprint="",
|
||||
usage={},
|
||||
)
|
||||
|
||||
|
||||
class FlakyClient:
|
||||
calls: int
|
||||
fail_times: int
|
||||
|
||||
def __init__(self, fail_times: int) -> None:
|
||||
self.calls = 0
|
||||
self.fail_times = fail_times
|
||||
|
||||
@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 param
|
||||
self.calls += 1
|
||||
if self.calls <= self.fail_times:
|
||||
msg = f"fail-{self.calls}"
|
||||
raise RuntimeError(msg)
|
||||
if stream:
|
||||
|
||||
async def empty() -> AsyncIterator[ChatCompletionChunk]:
|
||||
empty_chunks: list[ChatCompletionChunk] = []
|
||||
for chunk in empty_chunks:
|
||||
yield chunk
|
||||
|
||||
return empty()
|
||||
return _response(AssistantChatMessage(content="ok"))
|
||||
|
||||
|
||||
async def test_sleep_cancellable_completes() -> None:
|
||||
assert await sleep_cancellable(0.01) is True
|
||||
|
||||
|
||||
async def test_auto_retry_eventually_succeeds(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
async def no_wait(_seconds: float) -> bool:
|
||||
return True
|
||||
|
||||
monkeypatch.setattr("plyngent.cli.retry.sleep_cancellable", no_wait)
|
||||
store = await MemoryStore.open(DatabaseConfig())
|
||||
session = await store.create_session(name="t")
|
||||
client = FlakyClient(fail_times=2)
|
||||
agent = ChatAgent(client, model="m", memory=store, session_id=session.sid)
|
||||
|
||||
ok = await run_turn_with_retries(
|
||||
agent,
|
||||
starter=lambda: agent.run("hello"),
|
||||
delays=(0.01, 0.01, 0.01),
|
||||
)
|
||||
assert ok is True
|
||||
assert client.calls == 3 # noqa: PLR2004
|
||||
loaded = await store.list_messages(session.sid)
|
||||
assert len(loaded) == 2 # noqa: PLR2004
|
||||
assert isinstance(loaded[0], UserChatMessage)
|
||||
assert loaded[0].content == "hello"
|
||||
await store.close()
|
||||
|
||||
|
||||
async def test_manual_retry_after_exhausted(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
async def no_wait(_seconds: float) -> bool:
|
||||
return True
|
||||
|
||||
monkeypatch.setattr("plyngent.cli.retry.sleep_cancellable", no_wait)
|
||||
store = await MemoryStore.open(DatabaseConfig())
|
||||
session = await store.create_session(name="t")
|
||||
client = FlakyClient(fail_times=5)
|
||||
agent = ChatAgent(client, model="m", memory=store, session_id=session.sid)
|
||||
|
||||
ok = await run_turn_with_retries(
|
||||
agent,
|
||||
starter=lambda: agent.run("hold-me"),
|
||||
delays=(0.01,), # one auto-retry only → 2 attempts total, both fail
|
||||
)
|
||||
assert ok is False
|
||||
assert agent.pending_retry_text == "hold-me"
|
||||
assert await store.list_messages(session.sid) == []
|
||||
|
||||
# Now succeed on manual /retry path
|
||||
client.fail_times = client.calls # next call succeeds
|
||||
ok2 = await retry_pending_with_retries(agent)
|
||||
# retry_pending uses default delays; force zero-wait already patched
|
||||
# But fail_times set so first attempt of manual retry succeeds immediately
|
||||
assert ok2 is True
|
||||
assert agent.pending_retry_text is None
|
||||
loaded = await store.list_messages(session.sid)
|
||||
assert len(loaded) == 2 # noqa: PLR2004
|
||||
assert isinstance(loaded[0], UserChatMessage)
|
||||
assert loaded[0].content == "hold-me"
|
||||
await store.close()
|
||||
Reference in New Issue
Block a user