feat(conversations,api,ui): rewind takes the last turns of a thread back, the cut session stays in the store
This commit is contained in:
1 parent
b0e5aaf599
commit
2a47c6771d
14 files changed
+583
-9
No files matched your search
@@ -20,13 +20,14 @@ except ImportError: # pragma: no cover
|
||||
_cli_version = "2.1.248"
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterable
|
||||
from collections.abc import Iterable, Sequence
|
||||
|
||||
__all__ = [
|
||||
"CLI_VERSION",
|
||||
"build_entries",
|
||||
"close_open_tool_uses",
|
||||
"fingerprint",
|
||||
"last_message_before",
|
||||
"messages_from_entries",
|
||||
"open_tool_uses",
|
||||
"prompt_count",
|
||||
@@ -35,6 +36,7 @@ __all__ = [
|
||||
"spoken_text",
|
||||
"strip_tool_entries",
|
||||
"text_of",
|
||||
"turn_starts",
|
||||
"window_entries",
|
||||
]
|
||||
|
||||
@@ -438,6 +440,33 @@ def prompt_count(entries: Iterable[Mapping[str, Any]]) -> int:
|
||||
return sum(1 for e in entries if _is_prompt_entry(e))
|
||||
|
||||
|
||||
def turn_starts(entries: Iterable[Mapping[str, Any]]) -> list[int]:
|
||||
"""Where each turn opens: the index of every prompt the SDK was handed.
|
||||
|
||||
The CLI stamps only those with ``promptSource``; what it writes as a
|
||||
user on its own (an interruption note, a skill body) stays inside the
|
||||
turn it belongs to.
|
||||
"""
|
||||
return [
|
||||
index
|
||||
for index, entry in enumerate(entries)
|
||||
if entry.get("promptSource") and _is_prompt_entry(entry)
|
||||
]
|
||||
|
||||
|
||||
def last_message_before(entries: Sequence[Mapping[str, Any]], index: int) -> str | None:
|
||||
"""Uuid of the last ``user``/``assistant`` entry ahead of ``index``."""
|
||||
for entry in reversed(entries[:index]):
|
||||
uuid = entry.get("uuid")
|
||||
if (
|
||||
entry.get("type") in _PROMPT_TYPES
|
||||
and isinstance(uuid, str)
|
||||
and not entry.get("isSidechain")
|
||||
):
|
||||
return uuid
|
||||
return None
|
||||
|
||||
|
||||
def spoken_text(block: Any, *, thinking_is_progress: bool) -> str:
|
||||
"""What a content block (dict or model) says to the human.
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import contextlib
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
from uuid import uuid4
|
||||
|
||||
from beaver_gateway.conversations.spawning import Spawning
|
||||
from beaver_gateway.conversations.rewinding import Rewinding
|
||||
from beaver_gateway.conversations.state import Question
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -16,7 +16,7 @@ if TYPE_CHECKING:
|
||||
__all__ = ["Questions"]
|
||||
|
||||
|
||||
class Questions(Spawning):
|
||||
class Questions(Rewinding):
|
||||
async def ask(self, key: str, payload: dict[str, Any]) -> str | None:
|
||||
"""Show the question and wait; ``None`` when nobody answered in time."""
|
||||
conv = await self.get(key)
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
"""Taking turns back: the thread moves to a fork that ends before them."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
from claude_agent_sdk import fork_session_via_store
|
||||
|
||||
from beaver_gateway.backends.transcript import (
|
||||
last_message_before,
|
||||
messages_from_entries,
|
||||
reply_text,
|
||||
text_of,
|
||||
turn_starts,
|
||||
)
|
||||
from beaver_gateway.conversations.spawning import Spawning
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
|
||||
from beaver_gateway.storage.models import Conversation
|
||||
|
||||
__all__ = ["Rewinding", "Rewound"]
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
PREVIEW = 280
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Rewound:
|
||||
conversation: Conversation
|
||||
turns: int
|
||||
previous_session: str
|
||||
|
||||
|
||||
class Rewinding(Spawning):
|
||||
async def turns(
|
||||
self, conv: Conversation, *, limit: int = 10
|
||||
) -> list[dict[str, Any]]:
|
||||
"""The latest turns that can be taken back, oldest first; the first stays."""
|
||||
entries = await self.entries(conv)
|
||||
starts = turn_starts(entries)
|
||||
ends = [*starts[1:], len(entries)]
|
||||
spans = list(zip(starts, ends, strict=True))[1:]
|
||||
agent = self._agents.get(conv.agent_name)
|
||||
progress = agent is not None and agent.thinking_is_progress
|
||||
return [
|
||||
self._turn(entries[start:end], thinking_is_progress=progress)
|
||||
for start, end in spans[-limit:]
|
||||
]
|
||||
|
||||
def _turn(
|
||||
self, entries: Sequence[Any], *, thinking_is_progress: bool
|
||||
) -> dict[str, Any]:
|
||||
prompt, *rest = messages_from_entries(entries)
|
||||
replies = (
|
||||
reply_text(m["content"], thinking_is_progress=thinking_is_progress)
|
||||
for m in reversed(rest)
|
||||
if m["role"] == "assistant"
|
||||
)
|
||||
return {
|
||||
"id": entries[0].get("uuid"),
|
||||
"ts": entries[0].get("timestamp"),
|
||||
"text": text_of(self._unwrapped(prompt)["content"])[:PREVIEW],
|
||||
"reply": next((text for text in replies if text), "")[:PREVIEW],
|
||||
"failed": any(e.get("isApiErrorMessage") for e in entries),
|
||||
}
|
||||
|
||||
async def rewind(self, conv: Conversation, turn: str) -> Rewound:
|
||||
"""Take ``turn`` and every turn after it out of what the model sees.
|
||||
|
||||
Nothing is deleted: the conversation moves to a fork of its session
|
||||
that ends on the message before ``turn``, the cut session stays in
|
||||
the store under its old id.
|
||||
"""
|
||||
row_id = cast("int", conv.id)
|
||||
runner = self._runner(row_id)
|
||||
if runner.lock.locked():
|
||||
msg = f"conversation {conv.external_id} is mid-turn; interrupt it first"
|
||||
raise RuntimeError(msg)
|
||||
async with runner.lock:
|
||||
row = await self.get_row(row_id) or conv
|
||||
entries = await self.entries(row)
|
||||
starts = turn_starts(entries)
|
||||
cut = next((i for i in starts[1:] if entries[i].get("uuid") == turn), None)
|
||||
keep = last_message_before(entries, cut) if cut is not None else None
|
||||
if cut is None or keep is None:
|
||||
msg = f"conversation {row.external_id} has no turn {turn} to take back"
|
||||
raise LookupError(msg)
|
||||
previous = cast("str", row.session_id)
|
||||
forked = await fork_session_via_store(
|
||||
self._store,
|
||||
previous,
|
||||
directory=str(self._claude_agent(row.agent_name).cwd),
|
||||
up_to_message_id=keep,
|
||||
)
|
||||
await self._backend(row.agent_name).close(row.external_id)
|
||||
|
||||
async def apply(moved: Conversation) -> None:
|
||||
moved.session_id = forked.session_id
|
||||
|
||||
row = await self._update(row, apply)
|
||||
turns = sum(1 for start in starts if start >= cut)
|
||||
_log.info(
|
||||
"rewound %s by %d turns: session %s -> %s",
|
||||
row.external_id,
|
||||
turns,
|
||||
previous,
|
||||
forked.session_id,
|
||||
)
|
||||
return Rewound(conversation=row, turns=turns, previous_session=previous)
|
||||
@@ -1,7 +1,7 @@
|
||||
"""``Conversations`` - the service every frontend, job and gateway tool talks to.
|
||||
|
||||
Built as layers, one file each: rows → seeds → turns → messaging →
|
||||
spawning → questions → closing; this file adds start, stop and restart
|
||||
spawning → rewinding → questions → closing; this file adds start, stop and restart
|
||||
recovery. A turn started by a user message streams back to whoever asked;
|
||||
a turn started by an inject streams nowhere and can only speak via ``say``.
|
||||
"""
|
||||
|
||||
@@ -68,14 +68,14 @@ class Turns(Seeds):
|
||||
backend = self._backend(conv.agent_name)
|
||||
turn_id = turn_id or f"turn_{uuid4().hex[:12]}"
|
||||
capture = capture or TurnCapture()
|
||||
resume = session_id if session_id is not None else conv.session_id
|
||||
async with runner.lock:
|
||||
runner.turn_id = turn_id
|
||||
runner.origin = origin
|
||||
runner.text = shown if shown is not None else _prompt_preview(messages)
|
||||
runner.started_at = datetime.now(UTC)
|
||||
runner.tools = {}
|
||||
await self._mark_running(conv, turn_id)
|
||||
row = await self._mark_running(conv, turn_id)
|
||||
resume = session_id if session_id is not None else row.session_id
|
||||
before = await self.before_turn(conv)
|
||||
self._bus.publish(
|
||||
"turn.start",
|
||||
@@ -463,12 +463,12 @@ class Turns(Seeds):
|
||||
except Exception: # noqa: BLE001
|
||||
_log.exception("rate limit write failed")
|
||||
|
||||
async def _mark_running(self, conv: Conversation, turn_id: str) -> None:
|
||||
async def _mark_running(self, conv: Conversation, turn_id: str) -> Conversation:
|
||||
async def apply(row: Conversation) -> None:
|
||||
row.running_turn = turn_id
|
||||
row.last_activity_at = datetime.now(UTC)
|
||||
|
||||
await self._update(conv, apply)
|
||||
return await self._update(conv, apply)
|
||||
|
||||
async def _mark_done(
|
||||
self, conv: Conversation, capture: TurnCapture, *, cut: bool = False
|
||||
|
||||
@@ -79,6 +79,8 @@ MEMORY_MAX_ENTRIES = 5000
|
||||
SEARCH_MIN = 2
|
||||
GRAPH_LIMIT = 120
|
||||
SEARCH_LIMIT = 20
|
||||
TURNS_LIMIT = 10
|
||||
TURNS_MAX = 50
|
||||
SNIPPET_MAX = 160
|
||||
|
||||
|
||||
@@ -578,6 +580,42 @@ def build_app( # noqa: PLR0915
|
||||
"text": result.text,
|
||||
}
|
||||
|
||||
@app.get("/conversations/{public_id}/turns")
|
||||
async def get_turns(public_id: str, request: Request) -> dict[str, Any]:
|
||||
await require_token(request, runtime, scope=SCOPE)
|
||||
conv = await conv_of(public_id)
|
||||
limit = min(query_int(request, "limit", TURNS_LIMIT), TURNS_MAX)
|
||||
return {
|
||||
"id": conv.external_id,
|
||||
"turns": await conversations.turns(conv, limit=limit),
|
||||
}
|
||||
|
||||
@app.post("/conversations/{public_id}/rewind")
|
||||
async def post_rewind(public_id: str, request: Request) -> dict[str, Any]:
|
||||
token = await require_token(request, runtime, scope=ADMIN_SCOPE)
|
||||
conv = await conv_of(public_id)
|
||||
turn = text_of(await body_of(request), "turn")
|
||||
try:
|
||||
result = await conversations.rewind(conv, turn)
|
||||
except LookupError as exc:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, str(exc)) from exc
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(status.HTTP_409_CONFLICT, str(exc)) from exc
|
||||
out = {
|
||||
"turns": result.turns,
|
||||
"session_id": result.conversation.session_id,
|
||||
"previous_session": result.previous_session,
|
||||
}
|
||||
await audit.log(
|
||||
runtime,
|
||||
actor=f"token:{token}",
|
||||
kind="api_rewind",
|
||||
agent_name=conv.agent_name,
|
||||
conversation=conv.external_id,
|
||||
**out,
|
||||
)
|
||||
return {"id": conv.external_id, **out}
|
||||
|
||||
@app.post("/conversations/{public_id}/bind")
|
||||
async def post_bind(public_id: str, request: Request) -> dict[str, Any]:
|
||||
await require_token(request, runtime, scope=SCOPE)
|
||||
|
||||
Reference in new issue
Block a user