mirror of
https://github.com/NCBM/plyngent.git
synced 2026-07-23 05:55:16 +08:00
core/agent: derive pending_retry_text from trailing user message
Drop stored pending field; incomplete turns are just history ending in UserChatMessage. Compact seeds use an assistant summary so they are not mistaken for retryable orphans.
This commit is contained in:
@@ -55,7 +55,7 @@ Async SQLAlchemy + aiosqlite. `MemoryStore`: schema init (+ lightweight SQLite `
|
|||||||
- **`ChatClient`** Protocol for `chat_completions`.
|
- **`ChatClient`** Protocol for `chat_completions`.
|
||||||
- **`@tool` / `ToolRegistry`**: decorator infers JSON Schema from type hints; execute tools by name.
|
- **`@tool` / `ToolRegistry`**: decorator infers JSON Schema from type hints; execute tools by name.
|
||||||
- **`run_chat_loop`**: multi-round tool loop; default **streaming** text deltas + stream tool-call merge; parallel tools; tool-result char budget; soft context compact on request (**API-calibrated** after first usage when available); cooperative cancel points; optional `on_limit`.
|
- **`run_chat_loop`**: multi-round tool loop; default **streaming** text deltas + stream tool-call merge; parallel tools; tool-result char budget; soft context compact on request (**API-calibrated** after first usage when available); cooperative cancel points; optional `on_limit`.
|
||||||
- **`ChatAgent`**: optional `MemoryStore` (user message persisted immediately; assistant/tools on success); `stream`; system prompt; `pending_retry_text` + `retry()` for incomplete turns (including orphan user after resume).
|
- **`ChatAgent`**: optional `MemoryStore` (user message persisted immediately; assistant/tools on success); `stream`; system prompt; `retry()` when history ends with a user message (failed/cancelled turn or orphan after resume).
|
||||||
- **`/compact`**: soft-compact tool dumps → model summary (no tools) → **new** session seeded with summary message.
|
- **`/compact`**: soft-compact tool dumps → model summary (no tools) → **new** session seeded with summary message.
|
||||||
- Events: text_delta, assistant_message, tool_call/result, max_rounds, **error** (`retryable`/`source`), **cancelled** (`reason`), **usage** (`TokenUsage`).
|
- Events: text_delta, assistant_message, tool_call/result, max_rounds, **error** (`retryable`/`source`), **cancelled** (`reason`), **usage** (`TokenUsage`).
|
||||||
- Usage: API `usage` from completions (stream with `include_usage`); **char≈token fallback** (~4 chars/token) when omitted; **context size** = last request ``prompt_tokens`` (API preferred); `last_turn_usage` / `session_usage` are **billed sums** (tool rounds re-send history); CLI end-of-turn + `/status`.
|
- Usage: API `usage` from completions (stream with `include_usage`); **char≈token fallback** (~4 chars/token) when omitted; **context size** = last request ``prompt_tokens`` (API preferred); `last_turn_usage` / `session_usage` are **billed sums** (tool rounds re-send history); CLI end-of-turn + `/status`.
|
||||||
|
|||||||
+19
-41
@@ -43,7 +43,6 @@ class ChatAgent:
|
|||||||
parallel_tools: bool
|
parallel_tools: bool
|
||||||
max_context_tokens: int
|
max_context_tokens: int
|
||||||
messages: list[AnyChatMessage]
|
messages: list[AnyChatMessage]
|
||||||
pending_retry_text: str | None
|
|
||||||
session_usage: TokenUsage
|
session_usage: TokenUsage
|
||||||
last_turn_usage: TokenUsage
|
last_turn_usage: TokenUsage
|
||||||
last_request_usage: TokenUsage
|
last_request_usage: TokenUsage
|
||||||
@@ -81,13 +80,22 @@ class ChatAgent:
|
|||||||
self.parallel_tools = parallel_tools
|
self.parallel_tools = parallel_tools
|
||||||
self.max_context_tokens = max_context_tokens
|
self.max_context_tokens = max_context_tokens
|
||||||
self.messages = list(messages) if messages is not None else []
|
self.messages = list(messages) if messages is not None else []
|
||||||
self.pending_retry_text = None
|
|
||||||
self.session_usage = TokenUsage()
|
self.session_usage = TokenUsage()
|
||||||
self.last_turn_usage = TokenUsage()
|
self.last_turn_usage = TokenUsage()
|
||||||
self.last_request_usage = TokenUsage()
|
self.last_request_usage = TokenUsage()
|
||||||
self.last_turn_rounds = 0
|
self.last_turn_rounds = 0
|
||||||
self._ensure_system_prompt()
|
self._ensure_system_prompt()
|
||||||
self._sync_pending_from_orphan_user()
|
|
||||||
|
@property
|
||||||
|
def pending_retry_text(self) -> str | None:
|
||||||
|
"""Text of an incomplete last user turn, if any.
|
||||||
|
|
||||||
|
Derived only: history ending with a user message means that turn never
|
||||||
|
completed (failure, cancel, or resume of an orphan user in DB).
|
||||||
|
"""
|
||||||
|
if self.messages and isinstance(self.messages[-1], UserChatMessage):
|
||||||
|
return self.messages[-1].content
|
||||||
|
return None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def context_tokens(self) -> int:
|
def context_tokens(self) -> int:
|
||||||
@@ -116,13 +124,6 @@ class ChatAgent:
|
|||||||
return
|
return
|
||||||
self.messages.insert(0, SystemChatMessage(content=self.system_prompt))
|
self.messages.insert(0, SystemChatMessage(content=self.system_prompt))
|
||||||
|
|
||||||
def _sync_pending_from_orphan_user(self) -> None:
|
|
||||||
"""If history ends with a user message, that turn is incomplete → retryable."""
|
|
||||||
if self.messages and isinstance(self.messages[-1], UserChatMessage):
|
|
||||||
self.pending_retry_text = self.messages[-1].content
|
|
||||||
else:
|
|
||||||
self.pending_retry_text = None
|
|
||||||
|
|
||||||
async def load_history(self) -> None:
|
async def load_history(self) -> None:
|
||||||
"""Replace in-memory messages from the bound memory session."""
|
"""Replace in-memory messages from the bound memory session."""
|
||||||
if self.memory is None or self.session_id is None:
|
if self.memory is None or self.session_id is None:
|
||||||
@@ -130,7 +131,6 @@ class ChatAgent:
|
|||||||
raise RuntimeError(msg)
|
raise RuntimeError(msg)
|
||||||
self.messages = await self.memory.list_messages(self.session_id)
|
self.messages = await self.memory.list_messages(self.session_id)
|
||||||
self._ensure_system_prompt()
|
self._ensure_system_prompt()
|
||||||
self._sync_pending_from_orphan_user()
|
|
||||||
|
|
||||||
async def bind_session(self, session_id: int, *, load: bool = True) -> None:
|
async def bind_session(self, session_id: int, *, load: bool = True) -> None:
|
||||||
"""Attach a memory session id; optionally load existing messages."""
|
"""Attach a memory session id; optionally load existing messages."""
|
||||||
@@ -149,7 +149,6 @@ class ChatAgent:
|
|||||||
for i in range(len(self.messages) - 1, -1, -1):
|
for i in range(len(self.messages) - 1, -1, -1):
|
||||||
if self.messages[i] is user_msg:
|
if self.messages[i] is user_msg:
|
||||||
return i
|
return i
|
||||||
# Fallback: last matching content user message
|
|
||||||
for i in range(len(self.messages) - 1, -1, -1):
|
for i in range(len(self.messages) - 1, -1, -1):
|
||||||
msg = self.messages[i]
|
msg = self.messages[i]
|
||||||
if isinstance(msg, UserChatMessage) and msg.content == user_msg.content:
|
if isinstance(msg, UserChatMessage) and msg.content == user_msg.content:
|
||||||
@@ -157,17 +156,16 @@ class ChatAgent:
|
|||||||
msg = "user message not found in history"
|
msg = "user message not found in history"
|
||||||
raise RuntimeError(msg)
|
raise RuntimeError(msg)
|
||||||
|
|
||||||
def _rollback_partial(self, user_index: int, user_text: str) -> None:
|
def _rollback_partial(self, user_index: int) -> None:
|
||||||
"""Drop assistant/tool messages after the user; keep user for retry/DB."""
|
"""Drop assistant/tool messages after the user; keep user for retry/DB."""
|
||||||
del self.messages[user_index + 1 :]
|
del self.messages[user_index + 1 :]
|
||||||
self.pending_retry_text = user_text
|
|
||||||
|
|
||||||
async def _run_from_user_message(self, user_msg: UserChatMessage) -> AsyncIterator[AgentEvent]:
|
async def _run_from_user_message(self, user_msg: UserChatMessage) -> AsyncIterator[AgentEvent]:
|
||||||
"""Run the tool loop for an already-appended user message.
|
"""Run the tool loop for an already-appended user message.
|
||||||
|
|
||||||
On success, persists assistant/tool messages produced after the user.
|
On success, persists assistant/tool messages produced after the user.
|
||||||
On failure, keeps the user message (already in memory/DB) and sets
|
On failure, keeps the trailing user message so :meth:`retry` can re-run
|
||||||
``pending_retry_text`` so ``retry()`` can re-run without duplicating it.
|
without duplicating it (see :attr:`pending_retry_text`).
|
||||||
"""
|
"""
|
||||||
user_index = self._user_index(user_msg)
|
user_index = self._user_index(user_msg)
|
||||||
|
|
||||||
@@ -190,7 +188,6 @@ class ChatAgent:
|
|||||||
max_context_tokens=self.max_context_tokens,
|
max_context_tokens=self.max_context_tokens,
|
||||||
):
|
):
|
||||||
if isinstance(event, UsageEvent):
|
if isinstance(event, UsageEvent):
|
||||||
# Each tool-loop round re-sends history; sum is billing, not context size.
|
|
||||||
turn_rounds += 1
|
turn_rounds += 1
|
||||||
last_request = event.usage
|
last_request = event.usage
|
||||||
turn_usage = turn_usage.add(event.usage)
|
turn_usage = turn_usage.add(event.usage)
|
||||||
@@ -199,7 +196,7 @@ class ChatAgent:
|
|||||||
completed = True
|
completed = True
|
||||||
except BaseException:
|
except BaseException:
|
||||||
if not completed:
|
if not completed:
|
||||||
self._rollback_partial(user_index, user_msg.content)
|
self._rollback_partial(user_index)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
self.last_turn_usage = turn_usage
|
self.last_turn_usage = turn_usage
|
||||||
@@ -207,7 +204,6 @@ class ChatAgent:
|
|||||||
self.last_turn_rounds = turn_rounds
|
self.last_turn_rounds = turn_rounds
|
||||||
for message in self.messages[user_index + 1 :]:
|
for message in self.messages[user_index + 1 :]:
|
||||||
await self._persist(message)
|
await self._persist(message)
|
||||||
self.pending_retry_text = None
|
|
||||||
|
|
||||||
async def run(self, user_text: str) -> AsyncIterator[AgentEvent]:
|
async def run(self, user_text: str) -> AsyncIterator[AgentEvent]:
|
||||||
"""Append a user message (persist immediately), run the tool loop, yield events."""
|
"""Append a user message (persist immediately), run the tool loop, yield events."""
|
||||||
@@ -221,31 +217,13 @@ class ChatAgent:
|
|||||||
async def retry(self) -> AsyncIterator[AgentEvent]:
|
async def retry(self) -> AsyncIterator[AgentEvent]:
|
||||||
"""Re-run the incomplete last user turn without appending a new user message.
|
"""Re-run the incomplete last user turn without appending a new user message.
|
||||||
|
|
||||||
Useful after a failed/cancelled turn (user already in memory/DB) or after
|
Requires history to end with a :class:`UserChatMessage` (failed/cancelled
|
||||||
``load_history`` when the session ends with an orphan user message.
|
turn or resumed orphan user in the session DB).
|
||||||
"""
|
"""
|
||||||
text = self.pending_retry_text
|
if not self.messages or not isinstance(self.messages[-1], UserChatMessage):
|
||||||
if text is None:
|
|
||||||
self._sync_pending_from_orphan_user()
|
|
||||||
text = self.pending_retry_text
|
|
||||||
if text is None:
|
|
||||||
msg = "nothing to retry"
|
msg = "nothing to retry"
|
||||||
raise RuntimeError(msg)
|
raise RuntimeError(msg)
|
||||||
|
user_msg = self.messages[-1]
|
||||||
self._ensure_system_prompt()
|
self._ensure_system_prompt()
|
||||||
if self.messages and isinstance(self.messages[-1], UserChatMessage):
|
|
||||||
user_msg = self.messages[-1]
|
|
||||||
if user_msg.content != text:
|
|
||||||
# Pending text out of sync: replace trailing user
|
|
||||||
user_msg = UserChatMessage(content=text)
|
|
||||||
self.messages[-1] = user_msg
|
|
||||||
else:
|
|
||||||
user_msg = UserChatMessage(content=text)
|
|
||||||
self.messages.append(user_msg)
|
|
||||||
# Already persisted on the original run when memory is bound; only
|
|
||||||
# persist if this is a reconstructed orphan without DB (no memory).
|
|
||||||
if self.memory is None or self.session_id is None:
|
|
||||||
await self._persist(user_msg)
|
|
||||||
|
|
||||||
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
|
||||||
|
|||||||
@@ -129,7 +129,11 @@ def build_compacted_seed_messages(
|
|||||||
system_prompt: str | None = None,
|
system_prompt: str | None = None,
|
||||||
source_session_id: int | None = None,
|
source_session_id: int | None = None,
|
||||||
) -> list[AnyChatMessage]:
|
) -> list[AnyChatMessage]:
|
||||||
"""Messages to seed a new session after compact."""
|
"""Messages to seed a new session after compact.
|
||||||
|
|
||||||
|
Summary is an assistant message so history does not end with a user turn
|
||||||
|
(which would look like an incomplete /retry-able request).
|
||||||
|
"""
|
||||||
out: list[AnyChatMessage] = []
|
out: list[AnyChatMessage] = []
|
||||||
if system_prompt:
|
if system_prompt:
|
||||||
out.append(SystemChatMessage(content=system_prompt))
|
out.append(SystemChatMessage(content=system_prompt))
|
||||||
@@ -139,5 +143,5 @@ def build_compacted_seed_messages(
|
|||||||
f"{summary}\n\n"
|
f"{summary}\n\n"
|
||||||
"Continue from this summary. Prefer not to re-ask for information already covered."
|
"Continue from this summary. Prefer not to re-ask for information already covered."
|
||||||
)
|
)
|
||||||
out.append(UserChatMessage(content=body))
|
out.append(AssistantChatMessage(content=body))
|
||||||
return out
|
return out
|
||||||
|
|||||||
@@ -210,7 +210,6 @@ def _cmd_rounds(state: ReplState, arg: str) -> None:
|
|||||||
|
|
||||||
def _cmd_clear(state: ReplState) -> None:
|
def _cmd_clear(state: ReplState) -> None:
|
||||||
state.agent.messages.clear()
|
state.agent.messages.clear()
|
||||||
state.agent.pending_retry_text = None
|
|
||||||
click.echo("conversation cleared (in-memory only; DB history kept)")
|
click.echo("conversation cleared (in-memory only; DB history kept)")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -140,9 +140,8 @@ async def run_turn_with_retries(
|
|||||||
"""Run a chat turn with automatic retries on failure.
|
"""Run a chat turn with automatic retries on failure.
|
||||||
|
|
||||||
``starter`` produces the event stream for the first attempt (usually
|
``starter`` produces the event stream for the first attempt (usually
|
||||||
``agent.run``). After a failure that sets ``pending_retry_text``, further
|
``agent.run``). After a failure (history ends with the user message),
|
||||||
attempts use ``agent.retry`` so the user message is not duplicated in
|
further attempts use ``agent.retry`` so the user message is not duplicated.
|
||||||
history/DB (it is already persisted).
|
|
||||||
|
|
||||||
Ctrl+C cancels the in-flight task; user message stays in DB for ``/retry``.
|
Ctrl+C cancels the in-flight task; user message stays in DB for ``/retry``.
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -107,7 +107,6 @@ class ReplState:
|
|||||||
session = await self.memory.create_session(name=name, workspace=self.workspace)
|
session = await self.memory.create_session(name=name, workspace=self.workspace)
|
||||||
self.session_id = session.sid
|
self.session_id = session.sid
|
||||||
self.agent = self._make_agent()
|
self.agent = self._make_agent()
|
||||||
self.agent.pending_retry_text = None
|
|
||||||
|
|
||||||
async def resume_session(self, session_id: int) -> None:
|
async def resume_session(self, session_id: int) -> None:
|
||||||
"""Load a session; on workspace mismatch, prompt keep / rebind / abort."""
|
"""Load a session; on workspace mismatch, prompt keep / rebind / abort."""
|
||||||
@@ -202,5 +201,4 @@ class ReplState:
|
|||||||
self.agent.messages = list(seed)
|
self.agent.messages = list(seed)
|
||||||
for message in seed:
|
for message in seed:
|
||||||
_ = await self.memory.append_message(new_id, message)
|
_ = await self.memory.append_message(new_id, message)
|
||||||
self.agent.pending_retry_text = None
|
|
||||||
return old_id, new_id, summary
|
return old_id, new_id, summary
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ def test_build_compacted_seed_messages() -> None:
|
|||||||
seed = build_compacted_seed_messages("summary text", system_prompt="sys", source_session_id=3)
|
seed = build_compacted_seed_messages("summary text", system_prompt="sys", source_session_id=3)
|
||||||
assert isinstance(seed[0], SystemChatMessage)
|
assert isinstance(seed[0], SystemChatMessage)
|
||||||
assert seed[0].content == "sys"
|
assert seed[0].content == "sys"
|
||||||
assert isinstance(seed[1], UserChatMessage)
|
assert isinstance(seed[1], AssistantChatMessage)
|
||||||
assert "summary text" in seed[1].content
|
assert "summary text" in seed[1].content
|
||||||
assert "session 3" in seed[1].content
|
assert "session 3" in seed[1].content
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user