feat(injects,conversations,scheduler,gateway_tools,api): wake priority, master and parent aliases, scheduled injects start a turn

This commit is contained in:
hh
2026-08-30 20:34:12 +02:00
parent 064379ca90
commit a8491330fa
11 changed files with 215 additions and 47 deletions
+51 -7
View File
@@ -103,6 +103,9 @@ __all__ = [
_log = logging.getLogger("beaver_gateway.core.conversations")
MASTER_ALIAS = "master"
PARENT_ALIAS = "parent"
SEEDS = ("clean", "morning", "copy", "brief")
_STATUSES = ("open", "merged", "closed", "archived")
_DEFAULT_MERGE_PROMPT = (
@@ -342,6 +345,23 @@ class Conversations:
async with self._db.session() as session:
return await session.get(Conversation, row_id)
async def resolve(
self, key: str, *, origin: Conversation | None = None
) -> Conversation | None:
"""A conversation by public id or alias.
``master`` is the open master, ``parent`` the parent of ``origin`` -
the names a job or a branch can use without knowing today's ids.
"""
key = key.strip()
if key == MASTER_ALIAS:
return await self._open_master()
if key == PARENT_ALIAS:
if origin is None or origin.parent_id is None:
return None
return await self.get_row(origin.parent_id)
return await self.get(key)
async def find(
self,
*,
@@ -596,7 +616,7 @@ class Conversations:
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)
return any(i.priority in ("user", "urgent", "wake") for i in pending)
# ---- routing -------------------------------------------------------
@@ -841,6 +861,22 @@ class Conversations:
origin: str = "system",
interrupt: bool = True,
) -> InjectQueueItem:
if conv.kind == "master" and conv.status != "open":
live = await self._open_master()
if live is None:
_log.error(
"inject (%s) for closed master %s: no open master, it stays there",
origin,
conv.external_id,
)
else:
_log.info(
"inject (%s) for closed master %s goes to %s",
origin,
conv.external_id,
live.external_id,
)
conv = live
item = await self._queue.push(
conversation_id=cast("int", conv.id),
priority=urgency,
@@ -923,12 +959,20 @@ class Conversations:
return result
async def schedule(
self, conv: Conversation, at: str, text: str, *, dedupe_key: str | None = None
self,
conv: Conversation,
at: str,
text: str,
*,
urgency: Priority = "wake",
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)
return await self.scheduler.schedule(
conv, at, text, urgency=urgency, 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 []
@@ -1632,15 +1676,15 @@ class Conversations:
) -> tuple[list[InjectQueueItem] | None, float | None]:
if not items:
return None, None
normals = [i for i in items if i.priority == "normal"]
head = items[0]
if head.priority == "urgent":
return [head], None
if head.priority == "user":
return [head, *normals], None
tail = [i for i in items if i is not head and i.priority in ("wake", "normal")]
if head.priority in ("user", "wake"):
return [head, *tail], None
age = (datetime.now(UTC) - _aware(head.created_at)).total_seconds()
if age >= self._normal_window:
return normals, None
return [head, *tail], None
return None, max(self._normal_window - age, 1.0)
async def _run_batch(
+45 -13
View File
@@ -12,8 +12,18 @@ from typing import TYPE_CHECKING, Any, cast
from claude_agent_sdk import create_sdk_mcp_server, tool
from beaver_gateway.core.injects import URGENCY
from beaver_gateway.core.kinds import as_kind
URGENCY_HELP = (
"normal waits for the hourly batch or rides with the next turn, wake "
"starts a turn as soon as the target is idle, urgent cuts a running turn"
)
TARGET_HELP = (
"conversation id, or `master` for the open master, `parent` for the "
"parent of this conversation"
)
if TYPE_CHECKING:
from collections.abc import Iterable
@@ -57,6 +67,12 @@ def _tools(conversations: Conversations, key: str) -> list[SdkMcpTool[Any]]:
raise LookupError(msg)
return conv
async def target(raw: Any) -> Any:
conv = await conversations.resolve(str(raw), origin=await current())
if conv is None:
_log.warning("gateway tool in %s: conversation %r not found", key, raw)
return conv
@tool(
"read_conversation",
"Read another conversation (a branch, the master, a deep chat) as plain "
@@ -64,16 +80,16 @@ def _tools(conversations: Conversations, key: str) -> list[SdkMcpTool[Any]]:
{
"type": "object",
"properties": {
"id": {"type": "string", "description": "conversation id"},
"id": {"type": "string", "description": TARGET_HELP},
"window": {"type": "integer", "minimum": 1},
},
"required": ["id"],
},
)
async def read_conversation(args: dict[str, Any]) -> dict[str, Any]:
conv = await conversations.get(str(args["id"]))
conv = await target(args["id"])
if conv is None:
return _error(f"conversation {args['id']} not found")
return _error(f"conversation {args['id']} not found ({TARGET_HELP})")
text = await conversations.read(conv, window=args.get("window"))
return _text(text or "(empty)")
@@ -137,14 +153,26 @@ def _tools(conversations: Conversations, key: str) -> list[SdkMcpTool[Any]]:
@tool(
"schedule",
"Promise yourself an inject later: `at` is `+15m`, `+2h`, `+1d` or an "
"ISO datetime; `text` arrives in this conversation at that time.",
{"at": str, "text": str},
"ISO datetime; `text` arrives in this conversation at that time and "
"starts a turn (urgency wake). " + URGENCY_HELP + ".",
{
"type": "object",
"properties": {
"at": {"type": "string"},
"text": {"type": "string"},
"urgency": {"type": "string", "enum": list(URGENCY), "default": "wake"},
},
"required": ["at", "text"],
},
)
async def schedule(args: dict[str, Any]) -> dict[str, Any]:
conv = await current()
try:
job_id, when = await conversations.schedule(
conv, str(args["at"]), str(args["text"])
conv,
str(args["at"]),
str(args["text"]),
urgency=cast("Any", args.get("urgency") or "wake"),
)
except (RuntimeError, ValueError) as exc:
return _error(str(exc))
@@ -152,15 +180,17 @@ def _tools(conversations: Conversations, key: str) -> list[SdkMcpTool[Any]]:
@tool(
"inject",
"Put a system-origin message into another conversation's queue.",
"Put a system-origin message into another conversation's queue. "
+ URGENCY_HELP
+ ". An error here means nothing was delivered.",
{
"type": "object",
"properties": {
"conversation": {"type": "string"},
"conversation": {"type": "string", "description": TARGET_HELP},
"text": {"type": "string"},
"urgency": {
"type": "string",
"enum": ["normal", "urgent"],
"enum": list(URGENCY),
"default": "normal",
},
},
@@ -168,11 +198,13 @@ def _tools(conversations: Conversations, key: str) -> list[SdkMcpTool[Any]]:
},
)
async def inject(args: dict[str, Any]) -> dict[str, Any]:
target = await conversations.get(str(args["conversation"]))
if target is None:
return _error(f"conversation {args['conversation']} not found")
conv = await target(args["conversation"])
if conv is None:
return _error(
f"conversation {args['conversation']} not found ({TARGET_HELP})"
)
item = await conversations.inject(
target,
conv,
str(args["text"]),
urgency=cast("Any", args.get("urgency") or "normal"),
origin="агент",
+19 -7
View File
@@ -1,10 +1,13 @@
"""Persisted per-conversation queue with priorities ``urgent > user > normal`` (§3.4).
"""Persisted per-conversation queue, ``urgent > user > wake > normal`` (§3.4).
One ``ClaudeSDKClient`` runs one turn at a time, so ordering has to happen
before the client: the rows here are the queue, ``core/conversations``
runs one worker per conversation over them. A row that is still
``running`` when the gateway starts was cut by a restart; it is flagged
``interrupted`` and never re-run.
runs one worker per conversation over them. ``urgent`` cuts a running
turn, ``user`` is the human, ``wake`` starts a turn as soon as the
conversation is idle and takes the queued normals with it, ``normal``
waits for the batching window or rides with the next turn. A row that is
still ``running`` when the gateway starts was cut by a restart; it is
flagged ``interrupted`` and never re-run.
"""
from __future__ import annotations
@@ -24,10 +27,19 @@ if TYPE_CHECKING:
from beaver_gateway.storage.db import Database
from beaver_gateway.storage.models import Conversation
__all__ = ["PRIORITY_RANK", "InjectContext", "InjectQueue", "Priority", "inject_header"]
__all__ = [
"PRIORITY_RANK",
"URGENCY",
"InjectContext",
"InjectQueue",
"Priority",
"inject_header",
]
Priority = Literal["urgent", "user", "normal"]
PRIORITY_RANK: dict[str, int] = {"urgent": 0, "user": 1, "normal": 2}
Priority = Literal["urgent", "user", "wake", "normal"]
PRIORITY_RANK: dict[str, int] = {"urgent": 0, "user": 1, "wake": 2, "normal": 3}
URGENCY: tuple[Priority, ...] = ("normal", "wake", "urgent")
"""What the API and the tools accept for ``urgency``: every priority but ``user``."""
INTERRUPTED_TURN = (
"[этот инжект прервал предыдущий тёрн: «Request interrupted» выше - "
+18 -9
View File
@@ -93,6 +93,9 @@ class JobRun:
) -> bool:
master = await self.master()
if master is None:
_log.error(
"job %s: no open master, inject lost: %s", self.job.name, text[:200]
)
return False
await self.conversations.inject(
master, text, urgency=urgency, origin=origin or self.job.name
@@ -311,9 +314,14 @@ class Scheduler:
at: str,
text: str,
*,
urgency: Priority = "normal",
urgency: Priority = "wake",
dedupe_key: str | None = None,
) -> tuple[int | None, datetime]:
"""A deferred inject.
``wake`` by default: it was promised for a time, so it starts a turn
then instead of waiting for the normal window.
"""
if self._queries is None:
msg = "scheduler needs postgres; `schedule` is unavailable"
raise RuntimeError(msg)
@@ -355,19 +363,20 @@ class Scheduler:
async def _deliver(self, job: PgJob) -> None:
data = _decode(job.payload)
conv = await self.conversations.get(str(data.get("conversation", "")))
if conv is not None and conv.status != "open" and conv.kind == "master":
masters = await self.conversations.find(
kind="master", status="open", limit=1
)
conv = masters[0] if masters else None
key = str(data.get("conversation", ""))
conv = await self.conversations.resolve(key)
if conv is None:
_log.warning("scheduled inject #%s: conversation gone", job.id)
_log.error(
"scheduled inject #%s lost: conversation %r is gone: %s",
job.id,
key,
str(data.get("text", ""))[:200],
)
return
await self.conversations.inject(
conv,
str(data.get("text", "")),
urgency=cast("Priority", data.get("urgency") or "normal"),
urgency=cast("Priority", data.get("urgency") or "wake"),
origin="schedule",
)
+16 -8
View File
@@ -31,6 +31,7 @@ from sqlmodel import col, select
from beaver_gateway.core import audit
from beaver_gateway.core.auth import VALID_SCOPES, hash_token
from beaver_gateway.core.conversations import SEEDS, implied_title
from beaver_gateway.core.injects import URGENCY
from beaver_gateway.core.kinds import Kind, as_kind
from beaver_gateway.frontends._auth import require_token
from beaver_gateway.frontends._sse import (
@@ -145,7 +146,7 @@ def build_app(runtime: GatewayRuntime, *, memory_root: Path | None = None) -> Fa
return data
async def conv_of(public_id: str) -> Conversation:
conv = await conversations.get(public_id)
conv = await conversations.resolve(public_id)
if conv is None:
raise HTTPException(
status.HTTP_404_NOT_FOUND, f"unknown conversation {public_id}"
@@ -158,6 +159,14 @@ def build_app(runtime: GatewayRuntime, *, memory_root: Path | None = None) -> Fa
raise HTTPException(status.HTTP_400_BAD_REQUEST, f"missing `{key}`")
return text
def urgency_of(data: dict[str, Any], default: str) -> Any:
urgency = str(data.get("urgency") or default)
if urgency not in URGENCY:
raise HTTPException(
status.HTTP_400_BAD_REQUEST, f"urgency must be one of {URGENCY}"
)
return urgency
def int_or_none(data: dict[str, Any], key: str) -> int | None:
value = data.get(key)
if value is None:
@@ -343,15 +352,11 @@ def build_app(runtime: GatewayRuntime, *, memory_root: Path | None = None) -> Fa
token = await require_token(request, runtime, scope=SCOPE)
conv = await conv_of(public_id)
data = await body_of(request)
urgency = str(data.get("urgency") or "normal")
if urgency not in ("normal", "urgent"):
raise HTTPException(
status.HTTP_400_BAD_REQUEST, "urgency must be normal|urgent"
)
urgency = urgency_of(data, "normal")
item = await conversations.inject(
conv,
text_of(data),
urgency=cast("Any", urgency),
urgency=urgency,
origin=str(data.get("origin") or "api"),
)
await audit.log(
@@ -599,7 +604,10 @@ def build_app(runtime: GatewayRuntime, *, memory_root: Path | None = None) -> Fa
data = await body_of(request)
try:
job_id, when = await conversations.schedule(
conv, text_of(data, "at"), text_of(data, "text")
conv,
text_of(data, "at"),
text_of(data, "text"),
urgency=urgency_of(data, "wake"),
)
except (RuntimeError, ValueError) as exc:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
+1 -1
View File
@@ -147,7 +147,7 @@ class ConversationBinding(SQLModel, table=True):
class InjectQueueItem(SQLModel, table=True):
"""Persisted per-conversation queue (§3.4), priorities ``urgent > user > normal``.
"""Persisted per-conversation queue (§3.4), ``urgent > user > wake > normal``.
``status`` walks ``queued -> running -> done``; a row still ``running``
at startup was cut by a restart and becomes ``interrupted`` - it is