feat(scheduler,rotation,envelope,api,ui): pgqueuer jobs and deferred injects, master rotation with handout, vault envelope, jobs page
This commit is contained in:
@@ -57,7 +57,7 @@ from beaver_gateway.storage.models import (
|
||||
ConversationMessage,
|
||||
InjectQueueItem,
|
||||
RateLimit,
|
||||
Schedule,
|
||||
Usage,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -68,9 +68,12 @@ if TYPE_CHECKING:
|
||||
from beaver_gateway.agents.claude import ClaudeAgent
|
||||
from beaver_gateway.backends.claude_sdk import ClaudeSdkBackend
|
||||
from beaver_gateway.core.bus import EventBus
|
||||
from beaver_gateway.core.envelope import Envelope
|
||||
from beaver_gateway.core.events import MessageStreamEvent
|
||||
from beaver_gateway.core.injects import Priority
|
||||
from beaver_gateway.core.registry import AgentRegistry
|
||||
from beaver_gateway.core.rotation import HandoutContext
|
||||
from beaver_gateway.core.scheduler import Scheduler
|
||||
from beaver_gateway.core.sessions import SessionPool
|
||||
from beaver_gateway.frontends.base import Frontend
|
||||
from beaver_gateway.storage.db import Database
|
||||
@@ -124,6 +127,11 @@ class ConversationTexts:
|
||||
"заверши тёрн сейчас, ответ придёт следующим сообщением."
|
||||
)
|
||||
seed: Callable[[SeedContext], Awaitable[str | None] | str | None] | None = None
|
||||
handout: Callable[[HandoutContext], Awaitable[str] | str] | str = (
|
||||
"Этот мастер закрывается ({reason}). Напиши хендаут за {day}: справку "
|
||||
"на утро, не задание - прошедшее время, без повелительного наклонения."
|
||||
)
|
||||
new_day: str = "Новый день: мастер сменился, хендаут за {day} записан."
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -182,6 +190,7 @@ class Conversations:
|
||||
idle_days: Sequence[int] = (2,),
|
||||
idle_interval: float = 3600.0,
|
||||
question_timeout: float = 600.0,
|
||||
envelope: Envelope | None = None,
|
||||
) -> None:
|
||||
self._db = db
|
||||
self._agents = agents
|
||||
@@ -195,6 +204,8 @@ class Conversations:
|
||||
self._idle_days = tuple(sorted(idle_days))
|
||||
self._idle_interval = idle_interval
|
||||
self._question_timeout = question_timeout
|
||||
self._envelope = envelope
|
||||
self.scheduler: Scheduler | None = None
|
||||
self._questions: dict[str, _Question] = {}
|
||||
self._queue = InjectQueue(db)
|
||||
self._runners: dict[int, _Runner] = {}
|
||||
@@ -481,6 +492,45 @@ class Conversations:
|
||||
)
|
||||
return list(result.all())
|
||||
|
||||
async def context_tokens(self, conv: Conversation) -> int:
|
||||
"""Size of the context the last turn ran with, from its usage row."""
|
||||
async with self._db.session() as session:
|
||||
row = (
|
||||
await session.exec(
|
||||
select(Usage)
|
||||
.where(Usage.conversation_id == conv.external_id)
|
||||
.order_by(col(Usage.id).desc())
|
||||
.limit(1)
|
||||
)
|
||||
).first()
|
||||
if row is None:
|
||||
return 0
|
||||
return row.input_tokens + row.cache_read_tokens + row.cache_creation_tokens
|
||||
|
||||
async def usage_tokens(self, since: datetime) -> int:
|
||||
async with self._db.session() as session:
|
||||
rows = (
|
||||
await session.exec(
|
||||
select(Usage).where(
|
||||
col(Usage.ts) >= since.astimezone(UTC).replace(tzinfo=None)
|
||||
)
|
||||
)
|
||||
).all()
|
||||
return sum(
|
||||
r.input_tokens + r.output_tokens + r.cache_creation_tokens for r in rows
|
||||
)
|
||||
|
||||
async def busy(self, conv: Conversation) -> bool:
|
||||
"""A turn is running, a question is open or a message waits to run."""
|
||||
row = await self.get_row(cast("int", conv.id)) or conv
|
||||
if row.running_turn or row.pending_question:
|
||||
return True
|
||||
live = self._pool.get(row.external_id)
|
||||
if live is not None and live.busy:
|
||||
return True
|
||||
pending = await self._queue.pending(cast("int", row.id))
|
||||
return any(i.priority in ("user", "urgent") for i in pending)
|
||||
|
||||
# ---- routing -------------------------------------------------------
|
||||
|
||||
@property
|
||||
@@ -541,6 +591,8 @@ class Conversations:
|
||||
raise ValueError(msg)
|
||||
if agent is None and kind == "branch" and parent is not None:
|
||||
agent = parent.agent_name
|
||||
if kind == "branch" and parent is not None and parent.kind == "master":
|
||||
await self.set_flags(parent, {"streak": 0})
|
||||
agent = agent or self.default_agent(kind)
|
||||
if agent is None:
|
||||
msg = f"no default agent for kind {kind!r}; pass `agent`"
|
||||
@@ -712,6 +764,7 @@ class Conversations:
|
||||
*,
|
||||
urgency: Priority = "normal",
|
||||
origin: str = "system",
|
||||
interrupt: bool = True,
|
||||
) -> InjectQueueItem:
|
||||
item = await self._queue.push(
|
||||
conversation_id=cast("int", conv.id),
|
||||
@@ -726,7 +779,7 @@ class Conversations:
|
||||
priority=urgency,
|
||||
origin=origin,
|
||||
)
|
||||
if urgency == "urgent":
|
||||
if urgency == "urgent" and interrupt:
|
||||
backend = self._backend(conv.agent_name)
|
||||
if await backend.interrupt(conv.external_id):
|
||||
_log.info(
|
||||
@@ -788,22 +841,74 @@ class Conversations:
|
||||
)
|
||||
return result
|
||||
|
||||
async def schedule(self, conv: Conversation, at: str, text: str) -> Schedule:
|
||||
row = Schedule(
|
||||
conversation_id=cast("int", conv.id), execute_at=parse_at(at), text=text
|
||||
)
|
||||
async with self._db.session() as session:
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
async def schedule(
|
||||
self, conv: Conversation, at: str, text: str, *, dedupe_key: str | None = None
|
||||
) -> tuple[int | None, datetime]:
|
||||
if self.scheduler is None:
|
||||
msg = "no scheduler; `schedule` is unavailable"
|
||||
raise RuntimeError(msg)
|
||||
return await self.scheduler.schedule(conv, at, text, dedupe_key=dedupe_key)
|
||||
|
||||
async def schedules(self, conv: Conversation | None = None) -> list[dict[str, Any]]:
|
||||
return await self.scheduler.scheduled(conv) if self.scheduler else []
|
||||
|
||||
# ---- §4.5 rotation -------------------------------------------------
|
||||
|
||||
async def handout(self, conv: Conversation, ctx: HandoutContext) -> str:
|
||||
"""The closing master's last turn: the handout prompt from the config."""
|
||||
source = self._texts.handout
|
||||
if isinstance(source, str):
|
||||
prompt = source.format(day=ctx.day.isoformat(), reason=ctx.reason)
|
||||
else:
|
||||
produced: Any = source(ctx)
|
||||
prompt = await produced if inspect.isawaitable(produced) else produced
|
||||
self._bus.publish(
|
||||
"schedule.created",
|
||||
conversation_id=conv.external_id,
|
||||
schedule=row.id,
|
||||
execute_at=_iso(row.execute_at),
|
||||
"handout.start", conversation_id=conv.external_id, day=ctx.day.isoformat()
|
||||
)
|
||||
try:
|
||||
text, _ = await self.run_text_turn(conv, prompt, origin="handout")
|
||||
except Exception: # noqa: BLE001
|
||||
_log.exception("handout turn on %s failed", conv.external_id)
|
||||
text = ""
|
||||
self._bus.publish(
|
||||
"handout.end",
|
||||
conversation_id=conv.external_id,
|
||||
day=ctx.day.isoformat(),
|
||||
text=text[:2000],
|
||||
)
|
||||
return text
|
||||
|
||||
async def close(self, conv: Conversation) -> Conversation:
|
||||
row = await self.set_status(conv, "closed")
|
||||
with contextlib.suppress(LookupError):
|
||||
await self._backend(conv.agent_name).close(conv.external_id)
|
||||
return row
|
||||
|
||||
async def reparent(self, conv: Conversation, parent: Conversation) -> Conversation:
|
||||
async def apply(row: Conversation) -> None:
|
||||
row.parent_id = parent.id
|
||||
|
||||
return await self._update(conv, apply)
|
||||
|
||||
async def mark_closed(self, conv: Conversation) -> bool:
|
||||
marked = False
|
||||
for fe in self._frontends:
|
||||
if conv.kind in fe.kinds:
|
||||
try:
|
||||
marked = await fe.mark_closed(conv) or marked
|
||||
except Exception: # noqa: BLE001
|
||||
_log.exception("%s could not mark %s", fe.name, conv.external_id)
|
||||
return marked
|
||||
|
||||
async def new_day(self, conv: Conversation, *, moved: int = 0) -> InjectQueueItem:
|
||||
day = datetime.now(UTC).astimezone().date().isoformat()
|
||||
text = self._texts.new_day.format(day=day)
|
||||
if moved:
|
||||
text += f" Инжектов переехало из старого мастера: {moved}."
|
||||
return await self.inject(
|
||||
conv, text, urgency="urgent", origin="ротация", interrupt=False
|
||||
)
|
||||
|
||||
# ---- §3.7 questions ------------------------------------------------
|
||||
|
||||
async def ask(self, key: str, payload: dict[str, Any]) -> str | None:
|
||||
@@ -884,13 +989,6 @@ class Conversations:
|
||||
with contextlib.suppress(LookupError):
|
||||
await self._update(conv, apply)
|
||||
|
||||
async def schedules(self, conv: Conversation | None = None) -> list[Schedule]:
|
||||
stmt = select(Schedule).order_by(col(Schedule.execute_at))
|
||||
if conv is not None:
|
||||
stmt = stmt.where(Schedule.conversation_id == conv.id)
|
||||
async with self._db.session() as session:
|
||||
return list((await session.exec(stmt)).all())
|
||||
|
||||
# ---- turns ---------------------------------------------------------
|
||||
|
||||
async def turn(
|
||||
@@ -1193,6 +1291,9 @@ class Conversations:
|
||||
if head.priority == "user":
|
||||
origin = "user"
|
||||
prompt = head.text
|
||||
envelope = await self._envelope_for(conv, injects=len(batch) - 1)
|
||||
if envelope:
|
||||
prompt += "\n\n" + envelope
|
||||
if len(batch) > 1:
|
||||
prompt += "\n\n" + _bundle(batch[1:])
|
||||
else:
|
||||
@@ -1225,6 +1326,15 @@ class Conversations:
|
||||
text=text,
|
||||
)
|
||||
|
||||
async def _envelope_for(self, conv: Conversation, *, injects: int) -> str | None:
|
||||
if conv.kind != "master":
|
||||
return None
|
||||
streak = int(conv.flags.get("streak", 0) or 0)
|
||||
await self.set_flags(conv, {"streak": streak + 1})
|
||||
if self._envelope is None:
|
||||
return None
|
||||
return self._envelope.build(streak=streak, injects=injects)
|
||||
|
||||
def _observer(
|
||||
self, conv: Conversation, runner: _Runner, turn_id: str, origin: str
|
||||
) -> Callable[[Any], None]:
|
||||
|
||||
Reference in New Issue
Block a user