feat(backends,jobs,telegram,conversations): mcp calls carry the conversation origin, quiet branches close asleep, fresh topics get named
This commit is contained in:
1 parent
7d812c7962
commit
944b68e275
9 files changed
+276
-15
No files matched your search
@@ -111,6 +111,7 @@ warnings.filterwarnings("ignore", category=CanUseToolShadowedWarning)
|
||||
ASK_TOOL = "AskUserQuestion"
|
||||
|
||||
__all__ = [
|
||||
"ORIGIN_HEADER",
|
||||
"AuditSink",
|
||||
"ClaudeSdkBackend",
|
||||
"RunnerConfig",
|
||||
@@ -120,6 +121,9 @@ __all__ = [
|
||||
"fingerprint",
|
||||
]
|
||||
|
||||
ORIGIN_HEADER = "X-Conversation-Origin"
|
||||
"""Rides on every exposed MCP request; the gateway proxy passes it upstream."""
|
||||
|
||||
ENV_KEEP: tuple[str, ...] = (
|
||||
"PATH",
|
||||
"HOME",
|
||||
@@ -724,7 +728,14 @@ class ClaudeSdkBackend:
|
||||
system_prompt = (
|
||||
prompt_assembly.assemble(sources) if sources else agent.system_prompt
|
||||
)
|
||||
servers: dict[str, Any] = dict(self._servers) if spec.tools else {}
|
||||
servers: dict[str, Any] = (
|
||||
{
|
||||
name: {**server, "headers": {ORIGIN_HEADER: key}}
|
||||
for name, server in self._servers.items()
|
||||
}
|
||||
if spec.tools
|
||||
else {}
|
||||
)
|
||||
gateway = (
|
||||
self._tool_server(key, spec.kind)
|
||||
if spec.tools and self._tool_server is not None and agent.gateway_tools
|
||||
|
||||
@@ -148,8 +148,7 @@ class Spawning(Messaging):
|
||||
)
|
||||
if result.text.strip():
|
||||
await self.inject(parent, result.text, urgency="normal", origin="merge")
|
||||
await self.set_status(conv, "merged")
|
||||
await self.mark_closed(conv)
|
||||
await self.mark_closed(await self.set_status(conv, "merged"))
|
||||
self._bus.publish(
|
||||
"conversation.merged",
|
||||
conversation_id=conv.external_id,
|
||||
|
||||
@@ -268,7 +268,10 @@ class TelegramFrontend(Frontend):
|
||||
)
|
||||
|
||||
async def mark_closed(self, conv: Conversation) -> bool:
|
||||
return await self.mark_topic(conv)
|
||||
merged = conv.status == "merged"
|
||||
return await self.mark_topic(
|
||||
conv, self.texts.closed_prefix if merged else self.texts.idle_prefix
|
||||
)
|
||||
|
||||
async def mark_topic(self, conv: Conversation, prefix: str | None = None) -> bool:
|
||||
"""Rename the topic; closeForumTopic does not exist in private chats."""
|
||||
@@ -279,10 +282,7 @@ class TelegramFrontend(Frontend):
|
||||
name = self._topic_names.get(target[1]) or conv.title or self.texts.branch
|
||||
if name.startswith(prefix):
|
||||
return True
|
||||
await self.bot.edit_forum_topic(
|
||||
target[0], target[1], name=f"{prefix}{name}"[:128]
|
||||
)
|
||||
self._topic_names[target[1]] = f"{prefix}{name}"
|
||||
await self._rename(target[1], f"{prefix}{self._unmarked(name)}")
|
||||
return True
|
||||
|
||||
def file_note(self) -> str:
|
||||
@@ -462,6 +462,36 @@ class TelegramFrontend(Frontend):
|
||||
attachments=attachments,
|
||||
)
|
||||
|
||||
async def _branch_in_topic(
|
||||
self, topic: int, *, text: str, attachments: list[dict[str, Any]] | None
|
||||
) -> None:
|
||||
"""A fresh topic is named by the first line, a closed one loses its mark."""
|
||||
current = self._topic_names.get(topic)
|
||||
previous = None
|
||||
if current is None:
|
||||
previous = await self.conversations.find_bound(
|
||||
frontend=FRONTEND, external_id=self._ext(topic)
|
||||
)
|
||||
known = current or (previous.title if previous is not None else None)
|
||||
title = (self._unmarked(known) if known else "") or self._title_from(text)
|
||||
await self._branch(topic, title=title, text=text, attachments=attachments)
|
||||
if current == title:
|
||||
return
|
||||
try:
|
||||
await self._rename(topic, title)
|
||||
except TelegramAPIError as exc:
|
||||
_log.warning("topic %s not renamed to %r: %s", topic, title, exc)
|
||||
|
||||
async def _rename(self, topic: int, name: str) -> None:
|
||||
name = name[:128]
|
||||
await self.bot.edit_forum_topic(self.chat_id, topic, name=name)
|
||||
self._topic_names[topic] = name
|
||||
|
||||
def _unmarked(self, name: str) -> str:
|
||||
for prefix in (self.texts.closed_prefix, self.texts.idle_prefix):
|
||||
name = name.removeprefix(prefix)
|
||||
return name
|
||||
|
||||
async def _handle(self, update: Update) -> None:
|
||||
if update.message is not None:
|
||||
await self._on_message(update.message)
|
||||
@@ -592,9 +622,8 @@ class TelegramFrontend(Frontend):
|
||||
topic = cast("int", thread_id)
|
||||
conv = await self._live(topic)
|
||||
if conv is None:
|
||||
title = self._topic_names.get(topic) or self._title_from(text)
|
||||
await self._branch(
|
||||
topic, title=title, text=text, attachments=attachments or None
|
||||
await self._branch_in_topic(
|
||||
topic, text=text, attachments=attachments or None
|
||||
)
|
||||
return
|
||||
pending = self.conversations.pending_question(conv.external_id)
|
||||
|
||||
@@ -42,6 +42,7 @@ class TelegramTexts:
|
||||
help_head: str = "General is the master, any other topic is a branch."
|
||||
branch: str = "branch"
|
||||
closed_prefix: str = "✅ "
|
||||
idle_prefix: str = "💤 "
|
||||
waiting: str = "⏳ thinking"
|
||||
thinking: str = "🤔 thinking"
|
||||
writing: str = "✍️ writing"
|
||||
|
||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Awaitable, Callable, Coroutine
|
||||
@@ -73,6 +73,23 @@ class JobRun:
|
||||
)
|
||||
return True
|
||||
|
||||
async def inject_into(
|
||||
self,
|
||||
public_id: str | None,
|
||||
text: str,
|
||||
*,
|
||||
urgency: Priority = "normal",
|
||||
origin: str | None = None,
|
||||
) -> bool:
|
||||
"""Inject into that conversation, or the master once it is gone or over."""
|
||||
conv = await self.conversations.get(public_id) if public_id else None
|
||||
if conv is None or conv.status != "open":
|
||||
return await self.inject_master(text, urgency=urgency, origin=origin)
|
||||
await self.conversations.inject(
|
||||
conv, text, urgency=urgency, origin=origin or self.job.name
|
||||
)
|
||||
return True
|
||||
|
||||
async def spawn_job(
|
||||
self,
|
||||
*,
|
||||
@@ -113,6 +130,21 @@ class JobRun:
|
||||
_log.exception("closing idle %s failed", conv.external_id)
|
||||
return out
|
||||
|
||||
async def close_quiet(
|
||||
self, *, kind: str = "branch", days: int = 3, limit: int | None = None
|
||||
) -> list[Conversation]:
|
||||
"""Close chats quiet for ``days`` without a digest, their windows marked."""
|
||||
out: list[Conversation] = []
|
||||
for conv in await self.conversations.idle(kind=kind, days=days, limit=limit):
|
||||
if await self.conversations.busy(
|
||||
conv
|
||||
) or await self.conversations.queue.pending(cast("int", conv.id)):
|
||||
continue
|
||||
row = await self.conversations.close(conv)
|
||||
await self.conversations.mark_closed(row)
|
||||
out.append(row)
|
||||
return out
|
||||
|
||||
async def retry_in(self, delay: timedelta) -> None:
|
||||
await self.scheduler.trigger(
|
||||
self.job, self.payload, delay=delay, trigger=self.trigger
|
||||
|
||||
Reference in new issue
Block a user