diff --git a/CLAUDE.md b/CLAUDE.md index 3382d7b..9ed949f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -55,7 +55,7 @@ Async SQLAlchemy + aiosqlite. `MemoryStore`: schema init (+ lightweight SQLite ` - **`ChatClient`** Protocol for `chat_completions`. - **`@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`. -- **`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. - 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`. diff --git a/src/plyngent/agent/chat.py b/src/plyngent/agent/chat.py index 21d21cf..4dfa1a4 100644 --- a/src/plyngent/agent/chat.py +++ b/src/plyngent/agent/chat.py @@ -43,7 +43,6 @@ class ChatAgent: parallel_tools: bool max_context_tokens: int messages: list[AnyChatMessage] - pending_retry_text: str | None session_usage: TokenUsage last_turn_usage: TokenUsage last_request_usage: TokenUsage @@ -81,13 +80,22 @@ class ChatAgent: self.parallel_tools = parallel_tools self.max_context_tokens = max_context_tokens self.messages = list(messages) if messages is not None else [] - self.pending_retry_text = None self.session_usage = TokenUsage() self.last_turn_usage = TokenUsage() self.last_request_usage = TokenUsage() self.last_turn_rounds = 0 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 def context_tokens(self) -> int: @@ -116,13 +124,6 @@ class ChatAgent: return 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: """Replace in-memory messages from the bound memory session.""" if self.memory is None or self.session_id is None: @@ -130,7 +131,6 @@ class ChatAgent: raise RuntimeError(msg) self.messages = await self.memory.list_messages(self.session_id) self._ensure_system_prompt() - self._sync_pending_from_orphan_user() async def bind_session(self, session_id: int, *, load: bool = True) -> None: """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): if self.messages[i] is user_msg: return i - # Fallback: last matching content user message for i in range(len(self.messages) - 1, -1, -1): msg = self.messages[i] if isinstance(msg, UserChatMessage) and msg.content == user_msg.content: @@ -157,17 +156,16 @@ class ChatAgent: msg = "user message not found in history" 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.""" del self.messages[user_index + 1 :] - self.pending_retry_text = user_text async def _run_from_user_message(self, user_msg: UserChatMessage) -> AsyncIterator[AgentEvent]: """Run the tool loop for an already-appended user message. On success, persists assistant/tool messages produced after the user. - On failure, keeps the user message (already in memory/DB) and sets - ``pending_retry_text`` so ``retry()`` can re-run without duplicating it. + On failure, keeps the trailing user message so :meth:`retry` can re-run + without duplicating it (see :attr:`pending_retry_text`). """ user_index = self._user_index(user_msg) @@ -190,7 +188,6 @@ class ChatAgent: max_context_tokens=self.max_context_tokens, ): if isinstance(event, UsageEvent): - # Each tool-loop round re-sends history; sum is billing, not context size. turn_rounds += 1 last_request = event.usage turn_usage = turn_usage.add(event.usage) @@ -199,7 +196,7 @@ class ChatAgent: completed = True except BaseException: if not completed: - self._rollback_partial(user_index, user_msg.content) + self._rollback_partial(user_index) raise self.last_turn_usage = turn_usage @@ -207,7 +204,6 @@ class ChatAgent: self.last_turn_rounds = turn_rounds for message in self.messages[user_index + 1 :]: await self._persist(message) - self.pending_retry_text = None async def run(self, user_text: str) -> AsyncIterator[AgentEvent]: """Append a user message (persist immediately), run the tool loop, yield events.""" @@ -221,31 +217,13 @@ class ChatAgent: async def retry(self) -> AsyncIterator[AgentEvent]: """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 - ``load_history`` when the session ends with an orphan user message. + Requires history to end with a :class:`UserChatMessage` (failed/cancelled + turn or resumed orphan user in the session DB). """ - text = self.pending_retry_text - if text is None: - self._sync_pending_from_orphan_user() - text = self.pending_retry_text - if text is None: + if not self.messages or not isinstance(self.messages[-1], UserChatMessage): msg = "nothing to retry" raise RuntimeError(msg) - + user_msg = self.messages[-1] 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): yield event diff --git a/src/plyngent/agent/compact.py b/src/plyngent/agent/compact.py index 4d6efe0..8b1deec 100644 --- a/src/plyngent/agent/compact.py +++ b/src/plyngent/agent/compact.py @@ -129,7 +129,11 @@ def build_compacted_seed_messages( system_prompt: str | None = None, source_session_id: int | None = None, ) -> 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] = [] if system_prompt: out.append(SystemChatMessage(content=system_prompt)) @@ -139,5 +143,5 @@ def build_compacted_seed_messages( f"{summary}\n\n" "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 diff --git a/src/plyngent/cli/repl.py b/src/plyngent/cli/repl.py index e399cd1..760c9f6 100644 --- a/src/plyngent/cli/repl.py +++ b/src/plyngent/cli/repl.py @@ -210,7 +210,6 @@ def _cmd_rounds(state: ReplState, arg: str) -> None: def _cmd_clear(state: ReplState) -> None: state.agent.messages.clear() - state.agent.pending_retry_text = None click.echo("conversation cleared (in-memory only; DB history kept)") diff --git a/src/plyngent/cli/retry.py b/src/plyngent/cli/retry.py index fda309a..96331ec 100644 --- a/src/plyngent/cli/retry.py +++ b/src/plyngent/cli/retry.py @@ -140,9 +140,8 @@ async def run_turn_with_retries( """Run a chat turn with automatic retries on failure. ``starter`` produces the event stream for the first attempt (usually - ``agent.run``). After a failure that sets ``pending_retry_text``, further - attempts use ``agent.retry`` so the user message is not duplicated in - history/DB (it is already persisted). + ``agent.run``). After a failure (history ends with the user message), + further attempts use ``agent.retry`` so the user message is not duplicated. Ctrl+C cancels the in-flight task; user message stays in DB for ``/retry``. """ diff --git a/src/plyngent/cli/state.py b/src/plyngent/cli/state.py index 79d7259..4636cc9 100644 --- a/src/plyngent/cli/state.py +++ b/src/plyngent/cli/state.py @@ -107,7 +107,6 @@ class ReplState: session = await self.memory.create_session(name=name, workspace=self.workspace) self.session_id = session.sid self.agent = self._make_agent() - self.agent.pending_retry_text = None async def resume_session(self, session_id: int) -> None: """Load a session; on workspace mismatch, prompt keep / rebind / abort.""" @@ -202,5 +201,4 @@ class ReplState: self.agent.messages = list(seed) for message in seed: _ = await self.memory.append_message(new_id, message) - self.agent.pending_retry_text = None return old_id, new_id, summary diff --git a/tests/test_agent/test_compact_summarize.py b/tests/test_agent/test_compact_summarize.py index dca01db..ac42923 100644 --- a/tests/test_agent/test_compact_summarize.py +++ b/tests/test_agent/test_compact_summarize.py @@ -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) assert isinstance(seed[0], SystemChatMessage) assert seed[0].content == "sys" - assert isinstance(seed[1], UserChatMessage) + assert isinstance(seed[1], AssistantChatMessage) assert "summary text" in seed[1].content assert "session 3" in seed[1].content