mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-11-02 05:45:20 +08:00
Fix DingTalk A2UI form submissions and agent replies
This commit is contained in:
@@ -257,11 +257,17 @@ class AgentRunOrchestrator:
|
||||
auto_reply_enabled = (
|
||||
binding.processor_type == 'agent'
|
||||
and binding.delivery_policy.enable_reply
|
||||
and event.event_type == 'message.received'
|
||||
and event.event_type in {'message.received', 'interaction.submitted'}
|
||||
and reply_streams.adapter is not None
|
||||
)
|
||||
auto_reply_stream_id = uuid4()
|
||||
explicit_delivery_completed = False
|
||||
auto_reply_stream_text = ''
|
||||
auto_reply_stream_open = False
|
||||
auto_reply_cumulative_text = ''
|
||||
auto_reply_prefix = ''
|
||||
auto_reply_deferred = False
|
||||
explicit_reply_text: str | None = None
|
||||
pending_explicit_replies: dict[str, str] = {}
|
||||
# A run reached without a platform ingress (WebUI debug, service API)
|
||||
# owns its own chain; a run inside an ingress reuses that chain.
|
||||
trace_binding: trace_mod.TraceBinding | None = None
|
||||
@@ -370,14 +376,38 @@ class AgentRunOrchestrator:
|
||||
):
|
||||
continue
|
||||
|
||||
if result_type == 'tool.call.completed':
|
||||
if result_type == 'tool.call.started':
|
||||
result_data = result_dict.get('data') or {}
|
||||
tool_definition = PLATFORM_TOOLS_BY_NAME.get(result_data.get('tool_name'))
|
||||
if tool_definition is not None and tool_definition.api in {
|
||||
'send_message',
|
||||
'interaction.request',
|
||||
}:
|
||||
explicit_delivery_completed = True
|
||||
parameters = result_data.get('parameters') or {}
|
||||
if tool_definition is not None and tool_definition.api == 'send_message':
|
||||
text = parameters.get('text') if isinstance(parameters, dict) else None
|
||||
if isinstance(text, str):
|
||||
pending_explicit_replies[str(result_data.get('tool_call_id') or '')] = text
|
||||
|
||||
if result_type == 'tool.call.completed' and auto_reply_enabled:
|
||||
if auto_reply_stream_open:
|
||||
await reply_streams.apply(
|
||||
ReplyStreamRequest(
|
||||
stream_id=auto_reply_stream_id,
|
||||
operation='finish',
|
||||
text=auto_reply_stream_text,
|
||||
)
|
||||
)
|
||||
auto_reply_prefix = auto_reply_cumulative_text
|
||||
auto_reply_stream_id = uuid4()
|
||||
auto_reply_stream_text = ''
|
||||
auto_reply_stream_open = False
|
||||
result_data = result_dict.get('data') or {}
|
||||
tool_definition = PLATFORM_TOOLS_BY_NAME.get(result_data.get('tool_name'))
|
||||
auto_reply_deferred = (
|
||||
tool_definition is not None
|
||||
and tool_definition.api == 'send_message'
|
||||
and not result_data.get('error')
|
||||
)
|
||||
explicit_reply_text = pending_explicit_replies.pop(
|
||||
str(result_data.get('tool_call_id') or ''), None
|
||||
)
|
||||
|
||||
journal_started = time.monotonic()
|
||||
await self.journal.append_run_result(
|
||||
@@ -465,23 +495,49 @@ class AgentRunOrchestrator:
|
||||
async with binding_for(execution_query).lock:
|
||||
result.attachments = exported_message(execution_query, file_ids, consume=True)
|
||||
|
||||
if auto_reply_enabled and not explicit_delivery_completed and result is not None:
|
||||
reply_text = None
|
||||
operation = None
|
||||
if auto_reply_enabled and result is not None:
|
||||
if result_type == 'message.delta' and isinstance(result, provider_message.MessageChunk):
|
||||
reply_text = result.all_content or result.content
|
||||
operation = 'update'
|
||||
cumulative_text = result.all_content or result.content
|
||||
if isinstance(cumulative_text, str):
|
||||
if auto_reply_prefix and cumulative_text.startswith(auto_reply_prefix):
|
||||
reply_text = cumulative_text[len(auto_reply_prefix) :]
|
||||
else:
|
||||
auto_reply_prefix = ''
|
||||
reply_text = cumulative_text
|
||||
auto_reply_cumulative_text = cumulative_text
|
||||
auto_reply_stream_text = reply_text
|
||||
if reply_text.strip() and not auto_reply_deferred:
|
||||
await reply_streams.apply(
|
||||
ReplyStreamRequest(
|
||||
stream_id=auto_reply_stream_id,
|
||||
operation='update',
|
||||
text=reply_text,
|
||||
)
|
||||
)
|
||||
auto_reply_stream_open = True
|
||||
elif has_completed_message:
|
||||
reply_text = result.content
|
||||
operation = 'finish'
|
||||
if isinstance(reply_text, str) and reply_text.strip() and operation is not None:
|
||||
await reply_streams.apply(
|
||||
ReplyStreamRequest(
|
||||
stream_id=auto_reply_stream_id,
|
||||
operation=operation,
|
||||
text=reply_text,
|
||||
)
|
||||
)
|
||||
if isinstance(reply_text, str):
|
||||
if auto_reply_prefix and reply_text.startswith(auto_reply_prefix):
|
||||
reply_text = reply_text[len(auto_reply_prefix) :]
|
||||
if reply_text.strip() and (
|
||||
not auto_reply_deferred
|
||||
or explicit_reply_text is None
|
||||
or reply_text.strip() != explicit_reply_text.strip()
|
||||
):
|
||||
await reply_streams.apply(
|
||||
ReplyStreamRequest(
|
||||
stream_id=auto_reply_stream_id,
|
||||
operation='finish',
|
||||
text=reply_text,
|
||||
)
|
||||
)
|
||||
auto_reply_stream_id = uuid4()
|
||||
auto_reply_stream_text = ''
|
||||
auto_reply_stream_open = False
|
||||
auto_reply_prefix = auto_reply_cumulative_text
|
||||
auto_reply_deferred = False
|
||||
explicit_reply_text = None
|
||||
|
||||
if result is not None:
|
||||
yield result
|
||||
|
||||
@@ -126,9 +126,9 @@ PLATFORM_TOOL_DEFINITIONS: tuple[PlatformToolDefinition, ...] = (
|
||||
{'zh_Hans': '回复当前会话', 'en_US': 'Reply to current conversation'},
|
||||
{
|
||||
'zh_Hans': '向触发当前事件的会话发送回复或任务进度。目标由 LangBot 固定,Agent 无法改写。'
|
||||
'需要发送消息时请调用此工具,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.',
|
||||
'Call this tool to send an explicit message. Direct Agent text output may also be delivered automatically.',
|
||||
},
|
||||
_object_schema({'text': {**_TEXT, 'description': 'Reply text'}}, ['text']),
|
||||
('message.*', 'friend.*', 'group.*', 'feedback.*', 'interaction.submitted'),
|
||||
@@ -282,9 +282,9 @@ PLATFORM_TOOL_DEFINITIONS: tuple[PlatformToolDefinition, ...] = (
|
||||
'write',
|
||||
{'zh_Hans': '发送消息', 'en_US': 'Send message'},
|
||||
{
|
||||
'zh_Hans': '使用当前机器人向指定用户或群组发送回复或任务进度。Agent 的输出文本不会自动发送。',
|
||||
'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.',
|
||||
'Direct Agent text output may also be delivered automatically.',
|
||||
},
|
||||
_object_schema(
|
||||
{'target_type': _TARGET_TYPE, 'target_id': _ID, 'text': _TEXT}, ['target_type', 'target_id', 'text']
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
import traceback
|
||||
import typing
|
||||
@@ -34,27 +35,59 @@ class DingTalkCardCallbackHandler(dingtalk_stream.CallbackHandler):
|
||||
self.adapter = adapter
|
||||
|
||||
async def process(self, message: dingtalk_stream.CallbackMessage):
|
||||
callback = dingtalk_stream.CardCallbackMessage.from_dict(message.data or {})
|
||||
content = dict(callback.content) if isinstance(callback.content, dict) else {}
|
||||
if isinstance(message.data, dict):
|
||||
content['_raw_callback'] = message.data
|
||||
raw = message.data if isinstance(message.data, dict) else {}
|
||||
content = raw.get('content')
|
||||
if isinstance(content, str):
|
||||
try:
|
||||
content = json.loads(content)
|
||||
except json.JSONDecodeError:
|
||||
content = {}
|
||||
content = dict(content) if isinstance(content, dict) else {}
|
||||
content['_raw_callback'] = raw
|
||||
extension = raw.get('extension')
|
||||
if isinstance(extension, str):
|
||||
try:
|
||||
extension = json.loads(extension)
|
||||
except json.JSONDecodeError:
|
||||
extension = {}
|
||||
track_id = str(raw.get('outTrackId') or '')
|
||||
matched = track_id in self.adapter.interaction_callback_contexts
|
||||
self.logger.info(
|
||||
'DingTalk card callback: topic=%s track=%s keys=%s content_keys=%s context_matched=%s',
|
||||
getattr(message.headers, 'topic', None),
|
||||
track_id,
|
||||
sorted(raw),
|
||||
sorted(content),
|
||||
matched,
|
||||
)
|
||||
event = DingTalkEvent.from_payload(
|
||||
{
|
||||
'conversation_type': 'CardCallback',
|
||||
'Type': 'card_callback',
|
||||
'timestamp': float(message.headers.time or 0) / 1000,
|
||||
'CardCallback': {
|
||||
'extension': callback.extension,
|
||||
'corp_id': callback.corp_id,
|
||||
'user_id': callback.user_id,
|
||||
'extension': extension if isinstance(extension, dict) else {},
|
||||
'corp_id': raw.get('corpId'),
|
||||
'user_id': raw.get('userId'),
|
||||
'content': content,
|
||||
'space_id': callback.space_id,
|
||||
'card_instance_id': callback.card_instance_id,
|
||||
'space_id': raw.get('openSpaceId') or raw.get('spaceId'),
|
||||
'card_instance_id': track_id,
|
||||
},
|
||||
}
|
||||
)
|
||||
if event is not None:
|
||||
await self.adapter._handle_native_event(event)
|
||||
interaction = interaction_event_from_native(event, self.adapter.interaction_callback_contexts)
|
||||
if interaction is not None:
|
||||
try:
|
||||
await self.adapter._dispatch_eba_event(interaction)
|
||||
except Exception:
|
||||
self.logger.exception('DingTalk card callback dispatch failed: track=%s', track_id)
|
||||
return 500, 'Card callback dispatch failed'
|
||||
elif matched:
|
||||
self.logger.warning('DingTalk card callback could not be parsed: track=%s', track_id)
|
||||
return 500, 'Card callback could not be parsed'
|
||||
else:
|
||||
self.logger.info('DingTalk card callback has no active interaction: track=%s', track_id)
|
||||
return dingtalk_stream.AckMessage.STATUS_OK, 'OK'
|
||||
|
||||
|
||||
|
||||
@@ -216,6 +216,8 @@ async def send_interaction(adapter: typing.Any, params: dict[str, typing.Any]) -
|
||||
adapter.interaction_callback_contexts[out_track_id] = {
|
||||
'callback_token': callback_token,
|
||||
'field_types': field_types,
|
||||
'target_type': target_type,
|
||||
'target_id': target_id,
|
||||
'expires_at': time.monotonic() + 30 * 60,
|
||||
}
|
||||
return {
|
||||
@@ -340,6 +342,12 @@ def interaction_event_from_native(
|
||||
if callback_data:
|
||||
parsed = parse_callback_data(callback_data)
|
||||
raw_values = _mapping(_find_named_value(callback, {'values'}))
|
||||
if 'field_ref' in parsed and 'option_ref' not in parsed:
|
||||
# The A2UI form submit button uses f:0, but its values already
|
||||
# contain the selected option; option refs belong to native pickers.
|
||||
if not raw_values:
|
||||
return None
|
||||
parsed.pop('field_ref')
|
||||
context: dict[str, typing.Any] = {}
|
||||
if callback_contexts is not None:
|
||||
track_id = _callback_track_id(callback)
|
||||
@@ -348,7 +356,6 @@ def interaction_event_from_native(
|
||||
return None
|
||||
if parsed.get('callback_token') != context.get('callback_token'):
|
||||
return None
|
||||
callback_contexts.pop(track_id, None)
|
||||
if raw_values:
|
||||
field_types = context.get('field_types') if isinstance(context.get('field_types'), dict) else {}
|
||||
values = {}
|
||||
@@ -370,10 +377,12 @@ def interaction_event_from_native(
|
||||
'callback_token': str(context.get('callback_token') or ''),
|
||||
'values': {str(context.get('field_id') or ''): value},
|
||||
}
|
||||
contexts.pop(track_id, None)
|
||||
space_id = str(callback.get('space_id') or '')
|
||||
normalized_space_id = space_id.lower()
|
||||
if 'im_group.' in normalized_space_id:
|
||||
if context.get('target_type') in {'group', 'person'} and context.get('target_id'):
|
||||
target_type = str(context['target_type'])
|
||||
target_id = str(context['target_id'])
|
||||
elif 'im_group.' in normalized_space_id:
|
||||
target_type = 'group'
|
||||
target_id = space_id[normalized_space_id.index('im_group.') + len('im_group.') :]
|
||||
elif 'im_robot.' in normalized_space_id:
|
||||
|
||||
@@ -899,7 +899,8 @@ class RuntimeBot:
|
||||
state_policy=StatePolicy(state_scopes=['conversation', 'actor', 'subject', 'runner']),
|
||||
delivery_policy=DeliveryPolicy(
|
||||
enable_streaming=agent.get('kind') == 'agent' and event_type == 'message.received',
|
||||
enable_reply=agent.get('kind') == 'agent' and event_type == 'message.received',
|
||||
enable_reply=agent.get('kind') == 'agent'
|
||||
and event_type in {'message.received', 'interaction.submitted'},
|
||||
enable_interactions=agent.get('kind') != 'event_processor',
|
||||
),
|
||||
agent_id=agent.get('uuid'),
|
||||
@@ -1700,7 +1701,7 @@ class RuntimeBot:
|
||||
'_execution_context': self.execution_context,
|
||||
},
|
||||
):
|
||||
# Resuming an interaction follows the same explicit-action delivery policy.
|
||||
# The orchestrator delivers Direct Agent output unless a platform action already did.
|
||||
pass
|
||||
|
||||
async def _dispatch_eba_message_to_pipeline(
|
||||
|
||||
Reference in New Issue
Block a user