feat(telegram,core,backends,storage): telegram frontend with inbox, outbox, drafts and question buttons

This commit is contained in:
hh
2026-08-28 17:57:09 +02:00
parent 7ae87aeeb8
commit 45a5eb4a94
15 changed files with 2244 additions and 4 deletions
+48 -1
View File
@@ -41,6 +41,7 @@ import sys
import tempfile
import time
import uuid
import warnings
from collections.abc import Mapping
from dataclasses import dataclass, field
from pathlib import Path
@@ -49,9 +50,12 @@ from typing import TYPE_CHECKING, Any, Self, cast
import claude_agent_sdk
from claude_agent_sdk import (
AssistantMessage,
CanUseToolShadowedWarning,
ClaudeAgentOptions,
ClaudeSDKClient,
MirrorErrorMessage,
PermissionResultAllow,
PermissionResultDeny,
ResultMessage,
StreamEvent,
TextBlock,
@@ -91,7 +95,12 @@ if TYPE_CHECKING:
from collections.abc import AsyncIterator, Awaitable, Callable, Iterable, Sequence
from anthropic.types import MessageParam
from claude_agent_sdk import McpSdkServerConfig, SessionStore
from claude_agent_sdk import (
McpSdkServerConfig,
PermissionResult,
SessionStore,
ToolPermissionContext,
)
from beaver_gateway.agents.base import BaseAgent
from beaver_gateway.agents.claude import ClaudeAgent
@@ -100,6 +109,12 @@ if TYPE_CHECKING:
_log = logging.getLogger("beaver_gateway.backends.claude_sdk")
# §3.7: in bypass the callback only ever sees AskUserQuestion, and that is
# exactly the one we want - the SDK's warning about the rest is noise here.
warnings.filterwarnings("ignore", category=CanUseToolShadowedWarning)
ASK_TOOL = "AskUserQuestion"
__all__ = [
"ClaudeSdkBackend",
"RunnerConfig",
@@ -151,6 +166,11 @@ ClientFactory = "Callable[[ClaudeAgentOptions], SessionClient]"
UsageSink = "Callable[[UsageEvent], Awaitable[None]]"
ToolServerFactory = "Callable[[str, str], McpSdkServerConfig | None]"
"""``(conversation_key, kind) -> in-process MCP server config`` or ``None``."""
Asker = "Callable[[str, dict[str, Any]], Awaitable[str]]"
"""``(conversation_key, AskUserQuestion input) -> text the model reads as the
tool result``. The only channel an answer has in bypass mode is
``PermissionResultDeny.message`` (spike S1, s05): ``updated_input`` never
reaches the model."""
@dataclass(frozen=True, slots=True)
@@ -197,6 +217,7 @@ class ClaudeSdkBackend:
work_dir: Path | None = None,
pool: SessionPool | None = None,
tool_server: Callable[[str, str], McpSdkServerConfig | None] | None = None,
asker: Callable[[str, dict[str, Any]], Awaitable[str]] | None = None,
) -> None:
self._agent = agent
self._store = session_store
@@ -208,6 +229,7 @@ class ClaudeSdkBackend:
self._mcp_disallowed = _mcp_disallowed(agent, mcp_tool_names or {})
self._pool = pool if pool is not None else SessionPool()
self._tool_server = tool_server
self._asker = asker if ASK_TOOL not in agent.options.disallowed_tools else None
self._uid, self._gid = _resolve_ids(self._runner.user)
self._wrapper: Path | None = None
@@ -553,6 +575,7 @@ class ClaudeSdkBackend:
env=env,
cli_path=str(self._exec_wrapper(extra_keep=tuple(env))),
include_partial_messages=opt.include_partial_messages,
can_use_tool=self._can_use_tool(key) if self._asker else None,
session_store=self._store,
session_store_flush=cast("Any", opt.session_store_flush),
resume=resume,
@@ -564,6 +587,30 @@ class ClaudeSdkBackend:
),
)
def _can_use_tool(
self, key: str
) -> Callable[
[str, dict[str, Any], ToolPermissionContext], Awaitable[PermissionResult]
]:
asker = self._asker
async def can_use_tool(
name: str, tool_input: dict[str, Any], _ctx: ToolPermissionContext
) -> PermissionResult:
if name != ASK_TOOL or asker is None:
return PermissionResultAllow()
live = self._pool.get(key)
if live is not None:
live.pending_question = True
try:
message = await asker(key, tool_input)
finally:
if live is not None:
live.pending_question = False
return PermissionResultDeny(message=message)
return can_use_tool
def _plugins(self) -> list[dict[str, str]]:
plugins: list[dict[str, str]] = []
root = self._work_dir / "plugins" / self._agent.name