fix(runtime): preserve explicit replies and report bot configuration errors

This commit is contained in:
RockChinQ
2026-09-10 17:21:22 +08:00
parent b7a04f7a24
commit 8903a40c41
41 changed files with 1108 additions and 155 deletions
@@ -8,6 +8,9 @@ import typing
from langbot_plugin.api.entities.builtin.provider import message as provider_message
from langbot_plugin.api.entities.builtin.pipeline import query as pipeline_query
from langbot_plugin.entities.io.actions.enums import PluginToRuntimeAction
from .reply_stream import ReplyStreamSession
from ...core import app
from ...api.http.context import ExecutionContext
from ...pipeline.pool import get_query_execution_context
@@ -155,6 +158,16 @@ class AgentRunOrchestrator:
state_context = build_state_context(event, binding, descriptor)
run_id = context['run_id']
context['context']['available_apis']['reply_stream'] = hasattr(PluginToRuntimeAction, 'REPLY_STREAM') and any(
tool.get('tool_name') == 'event_reply' and tool.get('tool_type') == 'platform'
for tool in resources.get('tools', [])
)
reply_streams = ReplyStreamSession(
event,
adapter=(adapter_context or {}).get('_delivery_adapter') or getattr(execution_query, 'adapter', None),
source=(adapter_context or {}).get('_platform_event')
or getattr((adapter_context or {}).get('_query'), 'message_event', None),
)
available_apis = context.get('context', {}).get('available_apis')
run_authorization = {
'runner_id': descriptor.id,
@@ -208,6 +221,7 @@ class AgentRunOrchestrator:
state_context=state_context,
execution_query=execution_query,
platform_context=freeze_platform_context(event),
reply_streams=reply_streams,
)
event_log_id = await self.journal.write_event_log(
@@ -358,6 +372,7 @@ class AgentRunOrchestrator:
raise
finally:
session = await self._session_registry.unregister(run_id)
await reply_streams.close()
pending_steering = session.get('steering_queue', []) if session else []
if pending_steering:
try:
@@ -54,8 +54,10 @@ PLATFORM_TOOL_DEFINITIONS: tuple[PlatformToolDefinition, ...] = (
'write',
{'zh_Hans': '回复当前会话', 'en_US': 'Reply to current conversation'},
{
'zh_Hans': '向触发当前事件的会话发送文本消息。目标由 LangBot 固定,Agent 无法改写。',
'en_US': 'Send text to the conversation that triggered this run. LangBot fixes the target.',
'zh_Hans': '向触发当前事件的会话发送回复或任务进度。目标由 LangBot 固定,Agent 无法改写。'
'需要发送消息时请调用此工具,Agent 的输出文本不会自动发送。',
'en_US': 'Send a reply or task progress to the conversation that triggered this run. LangBot fixes the target. '
'Call this tool to send a message; Agent output text is not sent automatically.',
},
_object_schema({'text': {**_TEXT, 'description': 'Reply text'}}, ['text']),
('message.*', 'friend.*', 'group.*', 'feedback.*'),
@@ -209,8 +211,9 @@ PLATFORM_TOOL_DEFINITIONS: tuple[PlatformToolDefinition, ...] = (
'write',
{'zh_Hans': '发送消息', 'en_US': 'Send message'},
{
'zh_Hans': '使用当前机器人向指定用户或群组发送文本消息。',
'en_US': 'Send text to a specified person or group using the current bot.',
'zh_Hans': '使用当前机器人向指定用户或群组发送回复或任务进度。Agent 的输出文本不会自动发送。',
'en_US': 'Send a reply or task progress to a specified person or group using the current bot. '
'Agent output text is not sent automatically.',
},
_object_schema(
{'target_type': _TARGET_TYPE, 'target_id': _ID, 'text': _TEXT}, ['target_type', 'target_id', 'text']
@@ -0,0 +1,162 @@
"""Host-owned explicit replies using the existing adapter streaming lifecycle."""
from __future__ import annotations
import asyncio
from dataclasses import dataclass, field
from typing import Literal
from uuid import UUID, uuid4
import pydantic
from langbot_plugin.api.entities.builtin.platform import events, message
from langbot_plugin.api.entities.builtin.provider import message as provider_message
class ReplyStreamRequest(pydantic.BaseModel):
model_config = pydantic.ConfigDict(extra='forbid')
stream_id: UUID
operation: Literal['update', 'finish', 'abort']
text: str = pydantic.Field(max_length=200_000, strict=True)
@dataclass
class _Reply:
delivery_id: str = field(default_factory=lambda: str(uuid4()))
text: str = ''
started: bool = False
native: bool = False
final_attempted: bool = False
sequence: int = 0
status: str = 'open'
result: dict | None = None
class ReplyStreamSession:
"""Ephemeral delivery state owned by one authorized run, never by plugin input."""
def __init__(self, event, adapter=None, source=None):
self.adapter = adapter
self.source = source.to_legacy_event() if isinstance(source, events.MessageReceivedEvent) else source
if not isinstance(self.source, events.MessageEvent):
self.source = None
self.target = dict(event.delivery.reply_target or {})
self.mock = (
event.delivery.surface == 'webui' and (event.delivery.platform_capabilities or {}).get('debug_mock') is True
)
self.mock_error = (
((event.delivery.platform_capabilities or {}).get('mock_options') or {})
.get('errors', {})
.get('event_reply')
)
self._replies: dict[str, _Reply] = {}
self._lock = asyncio.Lock()
self._closed = False
self._active: set[asyncio.Task] = set()
async def apply(self, request: ReplyStreamRequest) -> dict:
task = asyncio.create_task(self._apply(request))
self._active.add(task)
try:
return await task
finally:
self._active.discard(task)
async def _apply(self, request: ReplyStreamRequest) -> dict:
async with self._lock:
if self._closed:
raise ValueError('Reply stream run has ended')
key = str(request.stream_id)
reply = self._replies.get(key)
if reply is None:
if len(self._replies) >= 16:
raise ValueError('A run may create at most 16 reply streams')
reply = self._replies[key] = _Reply()
if reply.status != 'open':
if request.operation == 'update' or reply.status == 'failed':
raise ValueError('Reply stream is closed')
return reply.result
try:
if request.operation != 'abort':
reply.text = request.text
if self.mock_error:
raise ValueError(str(self.mock_error))
if not self.mock and reply.text and not reply.started and request.operation != 'abort':
if self.adapter is None:
raise ValueError('This run has no platform delivery adapter')
# Set before I/O: an uncertain create must never be retried as another send.
reply.started = True
reply.native = self.source is not None and await self.adapter.is_stream_output_supported()
if reply.native:
await self.adapter.create_message_card(reply.delivery_id, self.source)
if reply.started and reply.native:
await self._update_native(key, reply, final=request.operation != 'update')
elif request.operation == 'finish' and reply.text and not self.mock:
if self.source is not None:
await self.adapter.reply_message(
message_source=self.source, message=self._message(reply.text), quote_origin=False
)
else:
target_type, target_id = self.target.get('target_type'), self.target.get('target_id')
if not target_type or not target_id:
raise ValueError('This event has no reply target')
await self.adapter.send_message(str(target_type), str(target_id), self._message(reply.text))
if request.operation != 'update':
reply.status = 'completed' if request.operation == 'finish' else 'cancelled'
reply.result = {
'stream_id': key,
'status': reply.status,
'delivery': 'simulated' if self.mock else 'streaming' if reply.native else 'buffered',
'mock': self.mock,
**({'text': reply.text} if request.operation == 'finish' else {}),
}
return reply.result
except BaseException:
reply.status = 'failed'
raise
@staticmethod
def _message(text):
return message.MessageChain([message.Plain(text=text)])
async def _update_native(self, key, reply, *, final):
if final:
reply.final_attempted = True
reply.sequence += 1
chunk = provider_message.MessageChunk(
role='assistant',
content=reply.text,
all_content=reply.text,
resp_message_id=reply.delivery_id,
msg_sequence=reply.sequence,
is_final=final,
)
await self.adapter.reply_message_chunk(
message_source=self.source,
bot_message=chunk,
message=self._message(reply.text),
quote_origin=False,
is_final=final,
)
async def close(self):
"""Close visible streams on cancellation; never send a buffered partial reply."""
self._closed = True
pending_requests = list(self._active)
for task in pending_requests:
task.cancel()
if pending_requests:
await asyncio.gather(*pending_requests, return_exceptions=True)
async with self._lock:
pending = [
self._update_native(key, reply, final=True)
for key, reply in self._replies.items()
if reply.started and reply.native and not reply.final_attempted
]
try:
if pending:
await asyncio.wait_for(asyncio.gather(*pending, return_exceptions=True), timeout=5)
except TimeoutError:
pass
finally:
self._replies.clear()
@@ -77,6 +77,7 @@ class AgentRunSession(typing.TypedDict):
plugin_identity: str # author/name
authorization: RunAuthorizationSnapshot
status: AgentRunSessionStatus
reply_streams: typing.Any
steering_queue: list[SteeringQueueItem]
@@ -115,6 +116,7 @@ class AgentRunSessionRegistry:
state_context: dict[str, typing.Any] | None = None,
execution_query: pipeline_query.Query | None = None,
platform_context: dict[str, typing.Any] | None = None,
reply_streams: typing.Any = None,
) -> None:
"""Register a new agent run session.
@@ -167,6 +169,7 @@ class AgentRunSessionRegistry:
'runner_id': runner_id,
'query_id': query_id,
'execution_query': execution_query,
'reply_streams': reply_streams,
'plugin_identity': plugin_identity,
'authorization': authorization,
'status': {