feat(injects,conversations): configurable inject header, urgent inject marks the turn it cut
This commit is contained in:
@@ -39,6 +39,7 @@ from claude_agent_sdk import (
|
|||||||
)
|
)
|
||||||
from sqlmodel import col, select
|
from sqlmodel import col, select
|
||||||
|
|
||||||
|
from beaver_gateway.core import injects
|
||||||
from beaver_gateway.core.conversation_store import load_messages
|
from beaver_gateway.core.conversation_store import load_messages
|
||||||
from beaver_gateway.core.distill import (
|
from beaver_gateway.core.distill import (
|
||||||
Digest,
|
Digest,
|
||||||
@@ -52,7 +53,7 @@ from beaver_gateway.core.distill import (
|
|||||||
trim_summary,
|
trim_summary,
|
||||||
written_paths,
|
written_paths,
|
||||||
)
|
)
|
||||||
from beaver_gateway.core.injects import InjectQueue, inject_header
|
from beaver_gateway.core.injects import InjectQueue
|
||||||
from beaver_gateway.core.kinds import KINDS, Kind, as_kind
|
from beaver_gateway.core.kinds import KINDS, Kind, as_kind
|
||||||
from beaver_gateway.core.transcript import (
|
from beaver_gateway.core.transcript import (
|
||||||
messages_from_entries,
|
messages_from_entries,
|
||||||
@@ -157,6 +158,8 @@ class ConversationTexts:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
merge_prompt: str = _DEFAULT_MERGE_PROMPT
|
merge_prompt: str = _DEFAULT_MERGE_PROMPT
|
||||||
|
inject_header: Callable[[injects.InjectContext], str] = injects.inject_header
|
||||||
|
"""Framing line(s) above each inject: who it is from, whether it cut a turn."""
|
||||||
interrupted: str = "прервано"
|
interrupted: str = "прервано"
|
||||||
answered: str = "Пользователь ответил: {answer}"
|
answered: str = "Пользователь ответил: {answer}"
|
||||||
unanswered: str = (
|
unanswered: str = (
|
||||||
@@ -857,6 +860,7 @@ class Conversations:
|
|||||||
_log.info(
|
_log.info(
|
||||||
"conversation %s: interrupted for urgent inject", conv.external_id
|
"conversation %s: interrupted for urgent inject", conv.external_id
|
||||||
)
|
)
|
||||||
|
await self._queue.mark_interrupting(item)
|
||||||
self._ensure_worker(cast("int", conv.id))
|
self._ensure_worker(cast("int", conv.id))
|
||||||
return item
|
return item
|
||||||
|
|
||||||
@@ -1650,7 +1654,10 @@ class Conversations:
|
|||||||
prompt += "\n\n" + _bundle(batch[1:])
|
prompt += "\n\n" + _bundle(batch[1:])
|
||||||
else:
|
else:
|
||||||
origin = "inject"
|
origin = "inject"
|
||||||
prompt = "\n\n".join(f"{inject_header(i.origin)}\n{i.text}" for i in batch)
|
prompt = "\n\n".join(
|
||||||
|
f"{self._texts.inject_header(injects.context_of(i))}\n{i.text}"
|
||||||
|
for i in batch
|
||||||
|
)
|
||||||
seed = await self._pending_seed(conv)
|
seed = await self._pending_seed(conv)
|
||||||
if seed:
|
if seed:
|
||||||
prompt = f"{seed}\n\n{prompt}"
|
prompt = f"{seed}\n\n{prompt}"
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ runs one worker per conversation over them. A row that is still
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from typing import TYPE_CHECKING, Literal, cast
|
from typing import TYPE_CHECKING, Literal, cast
|
||||||
|
|
||||||
@@ -23,14 +24,40 @@ if TYPE_CHECKING:
|
|||||||
from beaver_gateway.storage.db import Database
|
from beaver_gateway.storage.db import Database
|
||||||
from beaver_gateway.storage.models import Conversation
|
from beaver_gateway.storage.models import Conversation
|
||||||
|
|
||||||
__all__ = ["PRIORITY_RANK", "InjectQueue", "Priority", "inject_header"]
|
__all__ = ["PRIORITY_RANK", "InjectContext", "InjectQueue", "Priority", "inject_header"]
|
||||||
|
|
||||||
Priority = Literal["urgent", "user", "normal"]
|
Priority = Literal["urgent", "user", "normal"]
|
||||||
PRIORITY_RANK: dict[str, int] = {"urgent": 0, "user": 1, "normal": 2}
|
PRIORITY_RANK: dict[str, int] = {"urgent": 0, "user": 1, "normal": 2}
|
||||||
|
|
||||||
|
INTERRUPTED_TURN = (
|
||||||
|
"[этот инжект прервал предыдущий тёрн: «Request interrupted» выше - "
|
||||||
|
"прерывание, не отказ от тулзы]"
|
||||||
|
)
|
||||||
|
|
||||||
def inject_header(origin: str) -> str:
|
|
||||||
return f"[инжект: {origin} - это не Бобёр, отвечать не нужно, голос не обязателен]"
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class InjectContext:
|
||||||
|
"""What a header renderer knows about one queued inject."""
|
||||||
|
|
||||||
|
origin: str
|
||||||
|
priority: str
|
||||||
|
interrupted_turn: bool
|
||||||
|
|
||||||
|
|
||||||
|
def inject_header(ctx: InjectContext) -> str:
|
||||||
|
"""Default framing; a setup overrides it via ``ConversationTexts.inject_header``."""
|
||||||
|
head = (
|
||||||
|
f"[инжект: {ctx.origin} - это не Бобёр, отвечать не нужно, голос не обязателен]"
|
||||||
|
)
|
||||||
|
return f"{head}\n{INTERRUPTED_TURN}" if ctx.interrupted_turn else head
|
||||||
|
|
||||||
|
|
||||||
|
def context_of(item: InjectQueueItem) -> InjectContext:
|
||||||
|
return InjectContext(
|
||||||
|
origin=item.origin,
|
||||||
|
priority=item.priority,
|
||||||
|
interrupted_turn=bool(item.interrupted_turn),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class InjectQueue:
|
class InjectQueue:
|
||||||
@@ -83,6 +110,15 @@ class InjectQueue:
|
|||||||
) -> None:
|
) -> None:
|
||||||
await self._mark(items, status=status)
|
await self._mark(items, status=status)
|
||||||
|
|
||||||
|
async def mark_interrupting(self, item: InjectQueueItem) -> None:
|
||||||
|
item.interrupted_turn = True
|
||||||
|
async with self._db.session() as session:
|
||||||
|
row = await session.get(InjectQueueItem, item.id)
|
||||||
|
if row is not None:
|
||||||
|
row.interrupted_turn = True
|
||||||
|
session.add(row)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
async def interrupted(self) -> Sequence[InjectQueueItem]:
|
async def interrupted(self) -> Sequence[InjectQueueItem]:
|
||||||
async with self._db.session() as session:
|
async with self._db.session() as session:
|
||||||
result = await session.exec(
|
result = await session.exec(
|
||||||
|
|||||||
@@ -163,6 +163,9 @@ class InjectQueueItem(SQLModel, table=True):
|
|||||||
text: str
|
text: str
|
||||||
status: str = Field(default="queued", index=True)
|
status: str = Field(default="queued", index=True)
|
||||||
turn_id: str | None = Field(default=None)
|
turn_id: str | None = Field(default=None)
|
||||||
|
interrupted_turn: bool | None = Field(default=None)
|
||||||
|
"""An urgent inject that cut a running turn; the header tells the model
|
||||||
|
the ``Request interrupted`` tool result above was this, not a refusal."""
|
||||||
created_at: datetime = Field(default_factory=_utcnow)
|
created_at: datetime = Field(default_factory=_utcnow)
|
||||||
delivered_at: datetime | None = Field(default=None)
|
delivered_at: datetime | None = Field(default=None)
|
||||||
|
|
||||||
|
|||||||
@@ -274,10 +274,25 @@ async def test_urgent_interrupts_and_goes_first(world: World) -> None:
|
|||||||
]
|
]
|
||||||
assert client.prompts[0] == "first"
|
assert client.prompts[0] == "first"
|
||||||
assert client.prompts[1].startswith("[инжект: крон")
|
assert client.prompts[1].startswith("[инжект: крон")
|
||||||
|
assert "прервал предыдущий тёрн" in client.prompts[1]
|
||||||
assert client.prompts[1].endswith("ALERT")
|
assert client.prompts[1].endswith("ALERT")
|
||||||
assert client.prompts[2] == "second"
|
assert client.prompts[2] == "second"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_inject_header_is_configurable(world: World) -> None:
|
||||||
|
from beaver_gateway.core.conversations import ConversationTexts
|
||||||
|
|
||||||
|
world.conversations._texts = ConversationTexts( # noqa: SLF001
|
||||||
|
inject_header=lambda ctx: (
|
||||||
|
f"[от {ctx.origin}, {ctx.priority}, cut={ctx.interrupted_turn}]"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
conv = await world.conversations.create(kind="master", agent="a", origin="test")
|
||||||
|
await world.conversations.inject(conv, "hi", urgency="urgent", origin="panel")
|
||||||
|
await world.settle(conv, 1)
|
||||||
|
assert ScriptedClient.instances[0].prompts[0] == "[от panel, urgent, cut=False]\nhi"
|
||||||
|
|
||||||
|
|
||||||
async def test_normal_injects_ride_with_the_next_user_message(world: World) -> None:
|
async def test_normal_injects_ride_with_the_next_user_message(world: World) -> None:
|
||||||
conv = await world.conversations.create(kind="master", agent="a", origin="test")
|
conv = await world.conversations.create(kind="master", agent="a", origin="test")
|
||||||
await world.conversations.inject(
|
await world.conversations.inject(
|
||||||
|
|||||||
Reference in New Issue
Block a user