mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-09-16 14:57:15 +00:00
fix(runtime): preserve explicit replies and report bot configuration errors
This commit is contained in:
@@ -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': {
|
||||
|
||||
@@ -3,11 +3,20 @@ from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from ....authz import Permission, has_permission
|
||||
from ....context import RequestContext
|
||||
from ....service.bot_errors import BotApplyError, bot_error_message
|
||||
from ... import group
|
||||
|
||||
|
||||
@group.group_class('bots', '/api/v1/platform/bots')
|
||||
class BotsRouterGroup(group.RouterGroup):
|
||||
def _apply_error_response(self, exc: BotApplyError):
|
||||
request_id = self.request_id()
|
||||
logger = getattr(self.ap, 'logger', self.quart_app.logger)
|
||||
logger.error(f'Bot configuration apply failed request_id={request_id} bot_uuid={exc.bot_uuid}', exc_info=True)
|
||||
return quart.jsonify(
|
||||
code='bot_apply_failed', msg=str(exc), data={'uuid': exc.bot_uuid}, request_id=request_id
|
||||
), 400
|
||||
|
||||
async def initialize(self) -> None:
|
||||
@self.route(
|
||||
'',
|
||||
@@ -34,7 +43,14 @@ class BotsRouterGroup(group.RouterGroup):
|
||||
)
|
||||
async def _(request_context: RequestContext) -> str:
|
||||
json_data = await quart.request.json
|
||||
bot_uuid = await self.ap.bot_service.create_bot(request_context, json_data)
|
||||
if not isinstance(json_data, dict):
|
||||
return self.http_status(400, 'invalid_bot_config', 'Bot configuration must be an object')
|
||||
try:
|
||||
bot_uuid = await self.ap.bot_service.create_bot(request_context, json_data)
|
||||
except BotApplyError as exc:
|
||||
return self._apply_error_response(exc)
|
||||
except ValueError as exc:
|
||||
return self.http_status(400, 'invalid_bot_config', bot_error_message(exc, json_data))
|
||||
return self.success(data={'uuid': bot_uuid})
|
||||
|
||||
@self.route(
|
||||
@@ -63,7 +79,14 @@ class BotsRouterGroup(group.RouterGroup):
|
||||
async def _(bot_uuid: str, request_context: RequestContext) -> str:
|
||||
if quart.request.method == 'PUT':
|
||||
json_data = await quart.request.json
|
||||
await self.ap.bot_service.update_bot(request_context, bot_uuid, json_data)
|
||||
if not isinstance(json_data, dict):
|
||||
return self.http_status(400, 'invalid_bot_config', 'Bot configuration must be an object')
|
||||
try:
|
||||
await self.ap.bot_service.update_bot(request_context, bot_uuid, json_data)
|
||||
except BotApplyError as exc:
|
||||
return self._apply_error_response(exc)
|
||||
except ValueError as exc:
|
||||
return self.http_status(400, 'invalid_bot_config', bot_error_message(exc, json_data))
|
||||
else:
|
||||
await self.ap.bot_service.delete_bot(request_context, bot_uuid)
|
||||
return self.success()
|
||||
|
||||
@@ -11,6 +11,7 @@ from ....entity.persistence import agent as persistence_agent
|
||||
from ....entity.persistence import bot as persistence_bot
|
||||
from ....entity.persistence import pipeline as persistence_pipeline
|
||||
from ....workspace.errors import WorkspaceNotFoundError
|
||||
from .bot_errors import BotApplyError, bot_error_message
|
||||
from .tenant import TenantContext, require_workspace_uuid, scope_statement
|
||||
from ....utils import httpclient
|
||||
from ....platform.sources import http_bot_signing
|
||||
@@ -664,7 +665,10 @@ class BotService:
|
||||
|
||||
bot = await self.get_bot(context, bot_data['uuid'], include_secret=True)
|
||||
|
||||
await self.ap.platform_mgr.load_bot(context, bot)
|
||||
try:
|
||||
await self.ap.platform_mgr.load_bot(context, bot)
|
||||
except Exception as exc:
|
||||
raise BotApplyError(bot_error_message(exc, bot), bot['uuid']) from exc
|
||||
|
||||
return bot_data['uuid']
|
||||
|
||||
@@ -694,17 +698,17 @@ class BotService:
|
||||
runtime_bot.bot_entity.description = update_data['description']
|
||||
return
|
||||
|
||||
await self.ap.platform_mgr.remove_bot(context, bot_uuid)
|
||||
|
||||
# select from db
|
||||
# Persisted configuration is distinct from applying it to the running adapter.
|
||||
bot = await self.get_bot(context, bot_uuid, include_secret=True)
|
||||
try:
|
||||
await self.ap.platform_mgr.remove_bot(context, bot_uuid)
|
||||
runtime_bot = await self.ap.platform_mgr.load_bot(context, bot)
|
||||
if runtime_bot.enable:
|
||||
await runtime_bot.run()
|
||||
except Exception as exc:
|
||||
raise BotApplyError(bot_error_message(exc, bot), bot['uuid']) from exc
|
||||
|
||||
runtime_bot = await self.ap.platform_mgr.load_bot(context, bot)
|
||||
|
||||
if runtime_bot.enable:
|
||||
await runtime_bot.run()
|
||||
|
||||
# update all conversation that use this bot
|
||||
# Reset conversations using this bot after its configuration is applied.
|
||||
for session in self.ap.sess_mgr.session_list:
|
||||
if (
|
||||
session.using_conversation is not None
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
"""User-facing bot configuration errors without credentials or validation inputs."""
|
||||
|
||||
import json
|
||||
|
||||
import pydantic
|
||||
|
||||
from .secrets import redact_secrets
|
||||
|
||||
|
||||
class BotApplyError(Exception):
|
||||
"""Configuration was persisted, but the runtime could not apply it."""
|
||||
|
||||
def __init__(self, message: str, bot_uuid: str | None = None):
|
||||
super().__init__(message)
|
||||
self.bot_uuid = bot_uuid
|
||||
|
||||
|
||||
def bot_error_message(error: Exception, configuration: dict) -> str:
|
||||
if isinstance(error, pydantic.ValidationError):
|
||||
text = '; '.join(
|
||||
f'{".".join(map(str, item["loc"]))}: {item["msg"]}'
|
||||
for item in error.errors(include_input=False, include_context=False, include_url=False)
|
||||
)
|
||||
else:
|
||||
text = str(error).strip() or type(error).__name__
|
||||
|
||||
replacements = []
|
||||
|
||||
def collect(original, masked):
|
||||
if isinstance(original, dict) and isinstance(masked, dict):
|
||||
for key, value in original.items():
|
||||
collect(value, masked.get(key))
|
||||
elif isinstance(original, (list, tuple)) and isinstance(masked, (list, tuple)):
|
||||
for value, replacement in zip(original, masked):
|
||||
collect(value, replacement)
|
||||
elif isinstance(original, str) and original and original != masked:
|
||||
for representation in {original, repr(original)[1:-1], json.dumps(original, ensure_ascii=False)[1:-1]}:
|
||||
replacements.append((representation, str(masked)))
|
||||
|
||||
collect(configuration, redact_secrets(configuration))
|
||||
for original, masked in sorted(replacements, key=lambda item: len(item[0]), reverse=True):
|
||||
text = text.replace(original, masked)
|
||||
return text[:2000]
|
||||
@@ -103,9 +103,9 @@ spec:
|
||||
zh_Hans: 启用钉钉卡片流式回复模式
|
||||
zh_Hant: 啟用釘釘卡片串流回覆模式
|
||||
description:
|
||||
en_US: If enabled, the bot will use DingTalk card streaming replies.
|
||||
zh_Hans: 如果启用,将使用钉钉卡片流式方式来回复内容
|
||||
zh_Hant: 如果啟用,將使用釘釘卡片串流方式來回覆內容
|
||||
en_US: Used for streaming replies from pipelines or plugins. If enabled, the bot will use DingTalk card streaming replies.
|
||||
zh_Hans: 用于流水线和插件主动发起的流式回复。如果启用,将使用钉钉卡片流式方式来回复内容
|
||||
zh_Hant: 用於流水線和插件主動發起的串流回覆。如果啟用,將使用釘釘卡片串流方式來回覆內容
|
||||
type: boolean
|
||||
required: true
|
||||
default: false
|
||||
|
||||
@@ -38,6 +38,10 @@ spec:
|
||||
en_US: Enable stream reply
|
||||
zh_Hans: 启用流式回复
|
||||
zh_Hant: 啟用串流回覆
|
||||
description:
|
||||
en_US: Used for streaming replies from pipelines or plugins.
|
||||
zh_Hans: 用于流水线和插件主动发起的流式回复。
|
||||
zh_Hant: 用於流水線和插件主動發起的串流回覆。
|
||||
type: boolean
|
||||
required: true
|
||||
default: false
|
||||
|
||||
@@ -174,10 +174,10 @@ spec:
|
||||
zh_Hant: 啟用飛書串流回覆模式
|
||||
ja_JP: ストリーミング返信モードを有効化
|
||||
description:
|
||||
en_US: If enabled, replies are rendered through an updating Lark card.
|
||||
zh_Hans: 如果启用,将使用可更新的飞书卡片进行流式回复。
|
||||
zh_Hant: 如果啟用,將使用可更新的飛書卡片進行串流回覆。
|
||||
ja_JP: 有効にすると、更新可能な Lark カードでストリーミング返信します。
|
||||
en_US: Used for streaming replies from pipelines or plugins. If enabled, replies are rendered through an updating Lark card.
|
||||
zh_Hans: 用于流水线和插件主动发起的流式回复。如果启用,将使用可更新的飞书卡片进行流式回复。
|
||||
zh_Hant: 用於流水線和插件主動發起的串流回覆。如果啟用,將使用可更新的飛書卡片進行串流回覆。
|
||||
ja_JP: パイプラインやプラグインのストリーミング返信に適用されます。有効にすると、更新可能な Lark カードでストリーミング返信します。
|
||||
type: boolean
|
||||
required: true
|
||||
default: false
|
||||
|
||||
@@ -100,9 +100,9 @@ spec:
|
||||
zh_Hans: 启用流式回复模式
|
||||
zh_Hant: 啟用串流回覆模式
|
||||
description:
|
||||
en_US: If enabled, the adapter uses QQ Official streaming replies for C2C private messages.
|
||||
zh_Hans: 启用后,适配器会对 C2C 私聊使用 QQ 官方流式回复。
|
||||
zh_Hant: 啟用後,適配器會對 C2C 私聊使用 QQ 官方串流回覆。
|
||||
en_US: Used for streaming replies from pipelines or plugins. If enabled, the adapter uses QQ Official streaming replies for C2C private messages.
|
||||
zh_Hans: 用于流水线和插件主动发起的流式回复。启用后,适配器会对 C2C 私聊使用 QQ 官方流式回复。
|
||||
zh_Hant: 用於流水線和插件主動發起的串流回覆。啟用後,適配器會對 C2C 私聊使用 QQ 官方串流回覆。
|
||||
type: boolean
|
||||
required: true
|
||||
default: false
|
||||
|
||||
@@ -39,8 +39,8 @@ spec:
|
||||
en_US: Enable Stream Reply Mode
|
||||
zh_Hans: 启用电报流式回复模式
|
||||
description:
|
||||
en_US: If enabled, the bot will use the stream of telegram reply mode
|
||||
zh_Hans: 如果启用,将使用电报流式方式来回复内容
|
||||
en_US: Used for streaming replies from pipelines or plugins. If enabled, the bot will use the stream of telegram reply mode
|
||||
zh_Hans: 用于流水线和插件主动发起的流式回复。如果启用,将使用电报流式方式来回复内容
|
||||
type: boolean
|
||||
required: true
|
||||
default: false
|
||||
|
||||
@@ -145,9 +145,9 @@ spec:
|
||||
zh_Hans: 启用流式回复
|
||||
zh_Hant: 啟用串流回覆
|
||||
description:
|
||||
en_US: If enabled, the bot will use WeComBot streaming replies.
|
||||
zh_Hans: 如果启用,机器人将使用企业微信智能机器人流式回复。
|
||||
zh_Hant: 如果啟用,機器人將使用企業微信智慧機器人串流回覆。
|
||||
en_US: Used for streaming replies from pipelines or plugins. If enabled, the bot will use WeComBot streaming replies.
|
||||
zh_Hans: 用于流水线和插件主动发起的流式回复。如果启用,机器人将使用企业微信智能机器人流式回复。
|
||||
zh_Hant: 用於流水線和插件主動發起的串流回覆。如果啟用,機器人將使用企業微信智慧機器人串流回覆。
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
|
||||
@@ -38,7 +38,6 @@ from .logger import EventLogger
|
||||
from .adapter_names import canonical_adapter_name
|
||||
|
||||
import langbot_plugin.api.entities.builtin.provider.session as provider_session
|
||||
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
||||
import langbot_plugin.api.entities.events as plugin_events
|
||||
import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
||||
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
|
||||
@@ -826,7 +825,7 @@ class RuntimeBot:
|
||||
state_policy=StatePolicy(state_scopes=['conversation', 'actor', 'subject', 'runner']),
|
||||
delivery_policy=DeliveryPolicy(
|
||||
enable_streaming=False,
|
||||
enable_reply=True,
|
||||
enable_reply=False,
|
||||
enable_interactions=agent.get('kind') != 'event_processor',
|
||||
),
|
||||
agent_id=agent.get('uuid'),
|
||||
@@ -834,65 +833,6 @@ class RuntimeBot:
|
||||
processor_id=agent.get('uuid'),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _provider_content_to_text(content: typing.Any) -> str:
|
||||
if content is None:
|
||||
return ''
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
if isinstance(content, list):
|
||||
parts: list[str] = []
|
||||
for item in content:
|
||||
item_data = item.model_dump(mode='json') if hasattr(item, 'model_dump') else item
|
||||
if isinstance(item_data, dict):
|
||||
if item_data.get('type') == 'text' and item_data.get('text') is not None:
|
||||
parts.append(str(item_data.get('text')))
|
||||
elif item_data.get('text') is not None:
|
||||
parts.append(str(item_data.get('text')))
|
||||
elif item_data is not None:
|
||||
parts.append(str(item_data))
|
||||
return ''.join(parts)
|
||||
return str(content)
|
||||
|
||||
@classmethod
|
||||
def _provider_output_to_text(cls, result: provider_message.Message | provider_message.MessageChunk) -> str:
|
||||
if getattr(result, 'all_content', None):
|
||||
return str(getattr(result, 'all_content'))
|
||||
return cls._provider_content_to_text(getattr(result, 'content', None))
|
||||
|
||||
async def _deliver_agent_outputs(
|
||||
self,
|
||||
envelope: AgentEventEnvelope,
|
||||
outputs: list[provider_message.Message | provider_message.MessageChunk],
|
||||
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter | None = None,
|
||||
) -> None:
|
||||
if not outputs or not envelope.delivery.reply_target:
|
||||
return
|
||||
|
||||
reply_target = envelope.delivery.reply_target
|
||||
target_type = reply_target.get('target_type')
|
||||
target_id = reply_target.get('target_id')
|
||||
if not target_type or not target_id:
|
||||
return
|
||||
|
||||
final_text = ''
|
||||
for output in outputs:
|
||||
output_text = self._provider_output_to_text(output)
|
||||
if isinstance(output, provider_message.Message):
|
||||
final_text = output_text or final_text
|
||||
elif output_text:
|
||||
final_text = output_text
|
||||
|
||||
if not final_text:
|
||||
return
|
||||
|
||||
delivery_adapter = adapter or self.adapter
|
||||
await delivery_adapter.send_message(
|
||||
str(target_type),
|
||||
str(target_id),
|
||||
platform_message.MessageChain([platform_message.Plain(text=final_text)]),
|
||||
)
|
||||
|
||||
async def _handle_platform_event(
|
||||
self,
|
||||
event: platform_events.EBAEvent,
|
||||
@@ -1053,17 +993,18 @@ class RuntimeBot:
|
||||
envelope = self._eba_event_to_agent_envelope(event, adapter)
|
||||
if target_type == 'event_processor':
|
||||
envelope.data = event.model_dump(mode='json', exclude={'source_platform_object', 'legacy_event'})
|
||||
outputs: list[provider_message.Message | provider_message.MessageChunk] = []
|
||||
try:
|
||||
async for output in self.ap.agent_run_orchestrator.run(
|
||||
async for _ in self.ap.agent_run_orchestrator.run(
|
||||
envelope,
|
||||
binding,
|
||||
adapter_context={
|
||||
'_delivery_adapter': adapter,
|
||||
'_platform_event': event,
|
||||
'_execution_context': self.execution_context,
|
||||
},
|
||||
):
|
||||
outputs.append(output)
|
||||
# Results are journaled by the orchestrator; platform sends require explicit actions.
|
||||
pass
|
||||
except Exception:
|
||||
return await self._record_event_route_trace(
|
||||
event_type=event_type,
|
||||
@@ -1076,21 +1017,6 @@ class RuntimeBot:
|
||||
reason='Agent runner failed',
|
||||
text=f'Failed to run Agent for EBA event {event_type}: {traceback.format_exc()}',
|
||||
)
|
||||
|
||||
try:
|
||||
await self._deliver_agent_outputs(envelope, outputs, adapter=adapter)
|
||||
except Exception:
|
||||
return await self._record_event_route_trace(
|
||||
event_type=event_type,
|
||||
status='failed',
|
||||
level='error',
|
||||
binding=event_binding,
|
||||
target_type=target_type,
|
||||
target_uuid=target_uuid,
|
||||
failure_code='delivery_failed',
|
||||
reason='Agent output delivery failed',
|
||||
text=f'Failed to deliver Agent output for EBA event {event_type}: {traceback.format_exc()}',
|
||||
)
|
||||
return await self._record_event_route_trace(
|
||||
event_type=event_type,
|
||||
status='delivered',
|
||||
@@ -1458,14 +1384,13 @@ class RuntimeBot:
|
||||
data={'interaction': submission},
|
||||
)
|
||||
|
||||
outputs: list[provider_message.Message | provider_message.MessageChunk] = []
|
||||
async for output in self.ap.agent_run_orchestrator.run(
|
||||
async for _ in self.ap.agent_run_orchestrator.run(
|
||||
envelope,
|
||||
binding,
|
||||
adapter_context={'_delivery_adapter': adapter},
|
||||
):
|
||||
outputs.append(output)
|
||||
await self._deliver_agent_outputs(envelope, outputs, adapter=adapter)
|
||||
# Resuming an interaction follows the same explicit-action delivery policy.
|
||||
pass
|
||||
|
||||
async def _dispatch_eba_message_to_pipeline(
|
||||
self,
|
||||
|
||||
@@ -82,9 +82,9 @@ spec:
|
||||
zh_Hans: 启用钉钉卡片流式回复模式
|
||||
zh_Hant: 啟用釘釘卡片串流回覆模式
|
||||
description:
|
||||
en_US: If enabled, the bot will use the stream of lark reply mode
|
||||
zh_Hans: 如果启用,将使用钉钉卡片流式方式来回复内容
|
||||
zh_Hant: 如果啟用,將使用釘釘卡片串流方式來回覆內容
|
||||
en_US: Used for streaming replies from pipelines or plugins. If enabled, the bot will use the stream of lark reply mode
|
||||
zh_Hans: 用于流水线和插件主动发起的流式回复。如果启用,将使用钉钉卡片流式方式来回复内容
|
||||
zh_Hant: 用於流水線和插件主動發起的串流回覆。如果啟用,將使用釘釘卡片串流方式來回覆內容
|
||||
type: boolean
|
||||
required: true
|
||||
default: false
|
||||
|
||||
@@ -178,10 +178,10 @@ spec:
|
||||
zh_Hant: 啟用飛書串流回覆模式
|
||||
ja_JP: ストリーミング返信モードを有効化
|
||||
description:
|
||||
en_US: If enabled, the bot will use the stream of lark reply mode
|
||||
zh_Hans: 如果启用,将使用飞书流式方式来回复内容
|
||||
zh_Hant: 如果啟用,將使用飛書串流方式來回覆內容
|
||||
ja_JP: 有効にすると、ボットはストリーミングモードでメッセージに返信します
|
||||
en_US: Used for streaming replies from pipelines or plugins. If enabled, the bot will use the stream of lark reply mode
|
||||
zh_Hans: 用于流水线和插件主动发起的流式回复。如果启用,将使用飞书流式方式来回复内容
|
||||
zh_Hant: 用於流水線和插件主動發起的串流回覆。如果啟用,將使用飛書串流方式來回覆內容
|
||||
ja_JP: パイプラインやプラグインのストリーミング返信に適用されます。有効にすると、ボットはストリーミングモードでメッセージに返信します
|
||||
type: boolean
|
||||
required: true
|
||||
default: false
|
||||
|
||||
@@ -64,8 +64,8 @@ spec:
|
||||
vi_VN: Bật phản hồi luồng
|
||||
es_ES: Activar respuesta en streaming
|
||||
description:
|
||||
en_US: Update a Mattermost post while LangBot generates a response
|
||||
zh_Hans: 在 LangBot 生成回复时持续更新同一条 Mattermost 消息
|
||||
en_US: Used for streaming replies from pipelines or plugins. Update a Mattermost post while LangBot generates a response
|
||||
zh_Hans: 用于流水线和插件主动发起的流式回复。在 LangBot 生成回复时持续更新同一条 Mattermost 消息
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
|
||||
@@ -90,9 +90,9 @@ spec:
|
||||
zh_Hans: 启用流式回复模式
|
||||
zh_Hant: 啟用串流回覆模式
|
||||
description:
|
||||
en_US: If enabled, the bot will use streaming mode to reply messages (C2C only)
|
||||
zh_Hans: 如果启用,机器人将使用流式方式回复消息(仅私聊)
|
||||
zh_Hant: 如果啟用,機器人將使用串流方式回覆訊息(僅私聊)
|
||||
en_US: Used for streaming replies from pipelines or plugins. If enabled, the bot will use streaming mode to reply messages (C2C only)
|
||||
zh_Hans: 用于流水线和插件主动发起的流式回复。如果启用,机器人将使用流式方式回复消息(仅私聊)
|
||||
zh_Hant: 用於流水線和插件主動發起的串流回覆。如果啟用,機器人將使用串流方式回覆訊息(僅私聊)
|
||||
type: boolean
|
||||
required: true
|
||||
default: false
|
||||
|
||||
@@ -63,13 +63,13 @@ spec:
|
||||
vi_VN: Bật chế độ trả lời trực tuyến
|
||||
es_ES: Habilitar modo de respuesta en streaming
|
||||
description:
|
||||
en_US: If enabled, the bot will use the stream of telegram reply mode
|
||||
zh_Hans: 如果启用,将使用电报流式方式来回复内容
|
||||
zh_Hant: 如果啟用,將使用 Telegram 串流方式來回覆內容
|
||||
ja_JP: 有効にすると、ボットはストリーミングモードでメッセージに返信します
|
||||
th_TH: หากเปิดใช้งาน บอทจะใช้โหมดสตรีมของ Telegram ในการตอบกลับ
|
||||
vi_VN: Nếu bật, bot sẽ sử dụng chế độ trả lời trực tuyến của Telegram
|
||||
es_ES: Si está habilitado, el bot usará el modo de respuesta en streaming de Telegram
|
||||
en_US: Used for streaming replies from pipelines or plugins. If enabled, the bot will use the stream of telegram reply mode
|
||||
zh_Hans: 用于流水线和插件主动发起的流式回复。如果启用,将使用电报流式方式来回复内容
|
||||
zh_Hant: 用於流水線和插件主動發起的串流回覆。如果啟用,將使用 Telegram 串流方式來回覆內容
|
||||
ja_JP: パイプラインやプラグインのストリーミング返信に適用されます。有効にすると、ボットはストリーミングモードでメッセージに返信します
|
||||
th_TH: ใช้กับการตอบกลับแบบสตรีมจากไปป์ไลน์หรือปลั๊กอิน หากเปิดใช้งาน บอทจะใช้โหมดสตรีมของ Telegram ในการตอบกลับ
|
||||
vi_VN: Áp dụng cho phản hồi streaming từ pipeline hoặc plugin. Nếu bật, bot sẽ sử dụng chế độ trả lời trực tuyến của Telegram
|
||||
es_ES: Se aplica a respuestas en streaming de pipelines o plugins. Si está habilitado, el bot usará el modo de respuesta en streaming de Telegram
|
||||
type: boolean
|
||||
required: true
|
||||
default: false
|
||||
|
||||
@@ -142,9 +142,9 @@ spec:
|
||||
zh_Hans: 启用流式回复
|
||||
zh_Hant: 啟用串流回覆
|
||||
description:
|
||||
en_US: If enabled, the bot will use streaming mode to reply messages
|
||||
zh_Hans: 如果启用,机器人将使用流式模式回复消息
|
||||
zh_Hant: 如果啟用,機器人將使用串流模式回覆訊息
|
||||
en_US: Used for streaming replies from pipelines or plugins. If enabled, the bot will use streaming mode to reply messages
|
||||
zh_Hans: 用于流水线和插件主动发起的流式回复。如果启用,机器人将使用流式模式回复消息
|
||||
zh_Hant: 用於流水線和插件主動發起的串流回覆。如果啟用,機器人將使用串流模式回覆訊息
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
|
||||
@@ -1511,6 +1511,56 @@ class RuntimeConnectionHandler(handler.Handler):
|
||||
},
|
||||
)
|
||||
|
||||
async def reply_stream(data: dict[str, Any]) -> handler.ActionResponse:
|
||||
"""Explicit reply delivery authorized by the frozen event_reply permission."""
|
||||
from ..agent.runner.reply_stream import ReplyStreamRequest
|
||||
|
||||
action_context = self._require_runtime_action_context()
|
||||
session, error = await _validate_run_authorization(
|
||||
data.get('run_id'),
|
||||
'tool',
|
||||
'event_reply',
|
||||
self.ap,
|
||||
data.get('caller_plugin_identity'),
|
||||
operation='call',
|
||||
)
|
||||
if error:
|
||||
return error
|
||||
source_ref, error = _validate_frozen_tool_source_identity(session, 'event_reply', self.ap)
|
||||
if error:
|
||||
return error
|
||||
if source_ref is None or source_ref['source'] != 'platform':
|
||||
return handler.ActionResponse.error('event_reply must be a Host platform tool')
|
||||
query = session.get('execution_query')
|
||||
context = self._execution_context(action_context)
|
||||
if query is None or any(
|
||||
getattr(query, field, None) != getattr(context, field, None)
|
||||
for field in ('instance_uuid', 'workspace_uuid', 'placement_generation')
|
||||
):
|
||||
return handler.ActionResponse.error('Reply stream run belongs to another execution scope')
|
||||
streams = session.get('reply_streams')
|
||||
if streams is None:
|
||||
return handler.ActionResponse.error('Streaming replies are unavailable for this run')
|
||||
try:
|
||||
if not streams.mock:
|
||||
bot_id = session['authorization'].get('bot_id')
|
||||
bot = await self.ap.platform_mgr.get_bot_by_uuid(context, bot_id)
|
||||
if bot is None or bot.adapter is not streams.adapter:
|
||||
return handler.ActionResponse.error('The reply adapter is no longer active')
|
||||
if 'send_message' not in bot.adapter.get_supported_apis():
|
||||
return handler.ActionResponse.error('The reply adapter no longer supports sending messages')
|
||||
request = ReplyStreamRequest.model_validate(
|
||||
{key: data[key] for key in ('stream_id', 'operation', 'text') if key in data}
|
||||
)
|
||||
result = await streams.apply(request)
|
||||
return handler.ActionResponse.success(data={'result': result})
|
||||
except Exception as exc:
|
||||
return handler.ActionResponse.error(f'Streaming reply failed: {exc}')
|
||||
|
||||
reply_stream_action = getattr(PluginToRuntimeAction, 'REPLY_STREAM', None)
|
||||
if reply_stream_action is not None:
|
||||
self.action(reply_stream_action)(reply_stream)
|
||||
|
||||
@self.action(PluginToRuntimeAction.CALL_TOOL)
|
||||
async def call_tool(data: dict[str, Any]) -> handler.ActionResponse:
|
||||
"""Call a tool
|
||||
|
||||
Reference in New Issue
Block a user