feat(injects,conversations): configurable inject header, urgent inject marks the turn it cut

This commit is contained in:
hh
2026-08-29 17:07:43 +02:00
parent 8d252f9867
commit a989d2909d
4 changed files with 66 additions and 5 deletions
+9 -2
View File
@@ -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}"
+39 -3
View File
@@ -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(
+3
View File
@@ -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)
+15
View File
@@ -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(