Files
Aqr-K 7584d4b9fc fix: isolate agent sessions and retain runtime resource ownership (#7149)
* fix: arbitrate subscription identity and restore workflow state

* fix: isolate agent sessions and retain runtime resource ownership

* test: preserve legacy empty-owner subscription transactions

* test: handle reaped terminal descendants during shutdown polling

* fix: align shared atomic configuration method with lifecycle prerequisite

* test: cover subagent cancellation and completion lifecycle boundaries

---------

Co-authored-by: jxxghp <jxxghp@gmail.com>
2026-10-23 08:20:59 +08:00

498 lines
19 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
WebAgent 主运行流和运行中补充消息的展示桥接。
"""
from __future__ import annotations
import asyncio
from dataclasses import dataclass
from functools import partial
from typing import Any, AsyncIterator, Awaitable, Callable, Coroutine, Optional, cast
from app.agent.steering import SteeringMessage
from app.application import agent as agent_application
from app.runtime.log import logger
from app.runtime.stop import runtime_stop_state
from app.schemas.message import Message
from app.schemas.types import NotificationChannel, ReplyMode
@dataclass(frozen=True, slots=True)
class WebAgentStreamDependencies:
"""
注入 WebAgent 流所需的展示、事件和后台任务能力,避免模块相互导入。
:param event_publisher_factory: 创建当前事件流使用的发布器
:param bind_user_session: 接收用户标识和会话标识,维护用户的活动会话绑定
:param apply_display_event: 把一个流事件合并到可持久化的展示消息对象
:param build_display_message: 按角色、正文和附件创建展示消息
:param build_input_attachments: 把图片、音频和文件引用转换为展示附件
:param build_message_events_async: 把渠道消息异步转换为 WebAgent 展示事件列表
:param save_display_snapshot: 异步保存当前用户会话的展示消息快照
:param split_output: 把助手输出文本拆为按序展示的消息片段
:param create_background_task: 将协程交给宿主管理并返回后台任务
:param source: 本次会话使用的 WebAgent 来源标识
:param heartbeat_seconds: 事件流空闲时发送心跳的间隔,单位为秒
"""
event_publisher_factory: Callable[[], Any]
bind_user_session: Callable[[str, str], None]
apply_display_event: Callable[[dict[str, Any], dict[str, Any]], None]
build_display_message: Callable[..., dict[str, Any]]
build_input_attachments: Callable[..., list[dict[str, Any]]]
build_message_events_async: Callable[[Message], Awaitable[list[dict[str, Any]]]]
save_display_snapshot: Callable[..., Awaitable[None]]
split_output: Callable[[str], list[dict[str, Any]]]
create_background_task: Callable[
[Coroutine[Any, Any, object]], asyncio.Task[object]
]
source: str
heartbeat_seconds: float
async def submit_web_agent_steering(
*,
manager: Any,
session_id: str,
user_id: str,
prompt: str,
images: list[str],
files: list[dict[str, Any]],
audio_refs: list[str],
) -> Optional[SteeringMessage]:
"""
把活动 WebAgent 的补充输入提交到会话 inbox;无活动运行时返回 None。
"""
steering_files = [file for file in files if str(file.get("ref") or "") not in set(audio_refs)]
steering_files.extend({"ref": audio_ref, "mime_type": "audio/*"} for audio_ref in audio_refs)
submit = getattr(manager, "submit_steering_message", None)
if not callable(submit):
return None
return cast(
Optional[SteeringMessage],
await submit(
session_id=session_id,
user_id=user_id,
message=prompt,
images=images,
files=steering_files or None,
),
)
def _build_steering_ack_stream(
*,
session_id: str,
message: SteeringMessage,
) -> AsyncIterator[dict[str, Any]]:
"""
为运行中补充消息返回短 ACK 流,不创建第二个助手展示气泡。
"""
async def event_generator() -> AsyncIterator[dict[str, Any]]:
"""
按统一 WebAgent SSE 协议报告已排队的消息 ID。
"""
yield {"type": "start", "session_id": session_id}
yield {
"type": "steering",
"status": "queued",
"message_id": message.message_id,
"content": message.text,
}
yield {"type": "done"}
return event_generator()
def _publish_web_agent_protected_output(
event_publisher: Any,
content: str,
) -> bool:
"""
发布不进入普通展示快照的敏感交互结果。
"""
return bool(event_publisher.publish({"type": "interaction-protected", "content": content}))
def _attach_assistant_message_id(
event: dict[str, Any],
assistant_message_ref: dict[str, dict[str, Any]],
) -> dict[str, Any]:
"""
为主流展示事件附加产生它的助手消息身份。
"""
if event.get("assistant_message_id"):
return event
assistant_message_id = assistant_message_ref["message"].get("id")
if not assistant_message_id:
return event
return {**event, "assistant_message_id": assistant_message_id}
def _build_web_agent_steering_callback(
*,
display_messages: list[dict[str, Any]],
assistant_message_ref: dict[str, dict[str, Any]],
event_publisher: Any,
build_display_message: Callable[..., dict[str, Any]],
build_input_attachments: Callable[..., list[dict[str, Any]]],
) -> Callable[[SteeringMessage, str], None]:
"""
构造运行中补充消息的应用回调,并同步展示快照与 SSE 时间线。
"""
def steering_status_callback(message: SteeringMessage, status: str) -> None:
"""
把补充消息应用状态投影到当前助手流的真实边界。
"""
if status != "applied":
return
assistant_message = assistant_message_ref["message"]
assistant_message["status"] = "done"
display_message = build_display_message(
role="user",
content=message.text,
attachments=build_input_attachments(
images=list(message.images),
files=list(message.files),
audio_refs=[],
),
)
display_message["steering_message_id"] = message.message_id
# 应用点是消息流中的真实边界:先收口前一段助手输出,再插入用户消息,
# 最后创建承接后续工具和文本的新助手气泡。
# 用对象身份定位当前助手段;字典内容可能暂时相同,不能用值相等误选历史段。
assistant_index = next(
(index for index, item in enumerate(display_messages) if item is assistant_message),
-1,
)
if assistant_index < 0:
logger.warning("WebAgent steering 应用时未找到当前助手展示段: %s", message.message_id)
return
display_messages.insert(assistant_index + 1, display_message)
continuation = build_display_message(role="assistant", status="streaming")
display_messages.insert(assistant_index + 2, continuation)
assistant_message_ref["message"] = continuation
event_publisher.publish(
{
"type": "steering",
"status": "applied",
"message_id": message.message_id,
"content": message.text,
"display_message": display_message,
"assistant_message_id": assistant_message.get("id"),
"continuation_message_id": continuation.get("id"),
}
)
return steering_status_callback
def _build_web_agent_message_callback(
*,
assistant_message_ref: dict[str, dict[str, Any]],
event_publisher: Any,
build_message_events_async: Callable[[Message], Awaitable[list[dict[str, Any]]]],
apply_display_event: Callable[[dict[str, Any], dict[str, Any]], None],
) -> Callable[[Message], Awaitable[None]]:
"""
构造 Agent 主动消息的 Web 展示回调。
"""
async def message_callback(message: Message) -> None:
"""
接收 Agent 工具主动发送的 Web 通知。
"""
for item in await build_message_events_async(message):
display_event = _attach_assistant_message_id(item, assistant_message_ref)
apply_display_event(display_event, assistant_message_ref["message"])
event_publisher.publish(display_event)
return message_callback
def _build_web_agent_output_callback(
*,
assistant_message_ref: dict[str, dict[str, Any]],
event_publisher: Any,
split_output: Callable[[str], list[dict[str, Any]]],
apply_display_event: Callable[[dict[str, Any], dict[str, Any]], None],
) -> Callable[[str], None]:
"""
构造 Agent 文本增量的 Web 展示回调。
"""
def output_callback(delta: str) -> None:
"""
接收 Agent 文本增量并投影为展示事件。
"""
for item in split_output(delta):
display_event = _attach_assistant_message_id(item, assistant_message_ref)
apply_display_event(display_event, assistant_message_ref["message"])
event_publisher.publish(display_event)
return output_callback
def _build_web_agent_tool_event_callback(
*,
assistant_message_ref: dict[str, dict[str, Any]],
event_publisher: Any,
apply_display_event: Callable[[dict[str, Any], dict[str, Any]], None],
) -> Callable[[dict[str, Any]], None]:
"""
构造结构化工具生命周期回调,确保快照与 SSE 使用同一事件顺序。
"""
def tool_event_callback(event: dict[str, Any]) -> None:
"""
投影工具开始、完成和失败事件。
"""
display_event = _attach_assistant_message_id(event, assistant_message_ref)
apply_display_event(display_event, assistant_message_ref["message"])
event_publisher.publish(display_event)
return tool_event_callback
def _build_web_agent_files(command: Any) -> list[dict[str, Any]]:
"""
把 WebAgent 命令中的普通文件和音频引用整理成 Agent 输入。
"""
audio_refs = {str(audio_ref) for audio_ref in command.audio_refs}
files = [file for file in command.files if str(file.get("ref") or "") not in audio_refs]
files.extend({"ref": audio_ref, "mime_type": "audio/*"} for audio_ref in command.audio_refs)
return files
def _build_web_agent_event_generator(
*,
command: Any,
current_user: Any,
session_id: str,
prompt: str,
display_messages: list[dict[str, Any]],
assistant_message_ref: dict[str, dict[str, Any]],
has_audio_input: bool,
is_secret_confirmation_control: bool,
protected_transport_supported: bool,
service: Any,
persistence: Any,
is_disconnected: Callable[[], Awaitable[bool]],
dependencies: WebAgentStreamDependencies,
event_publisher: Any,
output_callback: Callable[[str], None],
tool_event_callback: Callable[[dict[str, Any]], None],
message_callback: Callable[[Message], Awaitable[None]],
protected_output_callback: Callable[[str], bool],
steering_status_callback: Callable[[SteeringMessage, str], None],
) -> AsyncIterator[dict[str, Any]]:
"""
构造后台 Agent 任务并按断线与终态语义消费展示事件。
"""
files = _build_web_agent_files(command)
async def run_agent() -> None:
"""
后台执行 Agent,并在完成后持久化展示快照。
"""
try:
runtime_manager = agent_application.get_running_agent_manager()
if runtime_manager is None:
raise RuntimeError("智能助手服务尚未就绪,请稍后重试。")
await runtime_manager.process_message(
session_id=session_id,
user_id=str(current_user.id),
message=prompt,
images=command.images,
files=files or None,
has_audio_input=has_audio_input,
channel=NotificationChannel.WebAgent.value,
source=dependencies.source,
username=current_user.name,
reply_mode=ReplyMode.CAPTURE_ONLY,
allow_message_tools=True,
output_callback=output_callback,
tool_event_callback=tool_event_callback,
protected_output_callback=(protected_output_callback if protected_transport_supported else None),
message_callback=message_callback,
steering_status_callback=steering_status_callback,
agent_factory=agent_application.get_web_agent_type(),
wait_for_completion=True,
)
except asyncio.CancelledError:
# 显式停止会话沿用正常终止语义;服务关闭由 manager 的稳定异常分支处理。
pass
except Exception as err:
logger.error(f"Web智能助手执行失败: {str(err)}")
error_event = _attach_assistant_message_id({
"type": "error",
"message": "智能助手执行失败,请稍后重试",
}, assistant_message_ref)
dependencies.apply_display_event(error_event, assistant_message_ref["message"])
event_publisher.publish(error_event)
finally:
done_event = _attach_assistant_message_id({"type": "done"}, assistant_message_ref)
dependencies.apply_display_event(done_event, assistant_message_ref["message"])
# 终态先进入事件队列,避免展示快照落库延迟前端结束动画。
event_publisher.publish(done_event)
if not is_secret_confirmation_control:
try:
await dependencies.save_display_snapshot(
session_id=session_id,
current_user=current_user,
messages=display_messages,
client_session_id=command.session_id or session_id,
service=service,
persistence=persistence,
append=True,
)
except Exception as err:
logger.error(f"保存WebAgent展示历史失败:{err}")
async def event_generator() -> AsyncIterator[dict[str, Any]]:
"""
执行后台 Agent 并在客户端断线时保留运行任务。
"""
task = dependencies.create_background_task(run_agent())
disconnected = False
terminal_sent = False
try:
yield {
"type": "start",
"session_id": session_id,
# 让前端把本地占位助手段绑定到服务端展示段,后续 steering
# 应用事件即可按稳定 ID 定位真实消息边界。
"assistant_message_id": assistant_message_ref["message"].get("id"),
}
while not runtime_stop_state.is_system_stopped:
if await is_disconnected():
disconnected = True
break
try:
event = await asyncio.wait_for(
event_publisher.get(),
timeout=dependencies.heartbeat_seconds,
)
except asyncio.TimeoutError:
yield {"type": "heartbeat"}
continue
if event.get("type") == "done":
terminal_sent = True
yield event
if event.get("type") == "done":
break
except asyncio.CancelledError:
disconnected = True
return
finally:
if not task.done() and not disconnected and not terminal_sent:
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
await event_publisher.aclose()
# 客户端断线后保留 Agent 继续执行;发布器关闭后拒绝受保护结果。
return event_generator()
def build_agent_web_agent_stream(
*,
command: Any,
current_user: Any,
session_id: str,
prompt: str,
display_prompt: str,
has_audio_input: bool,
is_secret_confirmation_control: bool,
protected_transport_supported: bool,
service: Any,
persistence: Any,
is_disconnected: Callable[[], Awaitable[bool]],
dependencies: WebAgentStreamDependencies,
) -> AsyncIterator[dict[str, Any]]:
"""
构造标准 Agent 执行链路的 WebAgent 事件流。
"""
dependencies.bind_user_session(str(current_user.id), session_id)
event_publisher = dependencies.event_publisher_factory()
user_attachments = dependencies.build_input_attachments(
images=command.images,
files=command.files,
audio_refs=command.audio_refs,
)
display_messages = []
if command.echo_user and not is_secret_confirmation_control:
user_display_message = dependencies.build_display_message(
role="user",
content=display_prompt or prompt,
attachments=user_attachments,
)
if command.choice_selection:
user_display_message["choice_selection"] = command.choice_selection
display_messages.append(user_display_message)
assistant_display_message = dependencies.build_display_message(
role="assistant",
status="streaming",
)
display_messages.append(assistant_display_message)
assistant_message_ref = {"message": assistant_display_message}
output_callback = _build_web_agent_output_callback(
assistant_message_ref=assistant_message_ref,
event_publisher=event_publisher,
split_output=dependencies.split_output,
apply_display_event=dependencies.apply_display_event,
)
message_callback = _build_web_agent_message_callback(
assistant_message_ref=assistant_message_ref,
event_publisher=event_publisher,
build_message_events_async=dependencies.build_message_events_async,
apply_display_event=dependencies.apply_display_event,
)
tool_event_callback = _build_web_agent_tool_event_callback(
assistant_message_ref=assistant_message_ref,
event_publisher=event_publisher,
apply_display_event=dependencies.apply_display_event,
)
protected_output_callback = partial(_publish_web_agent_protected_output, event_publisher)
steering_status_callback = _build_web_agent_steering_callback(
display_messages=display_messages,
assistant_message_ref=assistant_message_ref,
event_publisher=event_publisher,
build_display_message=dependencies.build_display_message,
build_input_attachments=dependencies.build_input_attachments,
)
return _build_web_agent_event_generator(
command=command,
current_user=current_user,
session_id=session_id,
prompt=prompt,
display_messages=display_messages,
assistant_message_ref=assistant_message_ref,
has_audio_input=has_audio_input,
is_secret_confirmation_control=is_secret_confirmation_control,
protected_transport_supported=protected_transport_supported,
service=service,
persistence=persistence,
is_disconnected=is_disconnected,
dependencies=dependencies,
event_publisher=event_publisher,
output_callback=output_callback,
tool_event_callback=tool_event_callback,
message_callback=message_callback,
protected_output_callback=protected_output_callback,
steering_status_callback=steering_status_callback,
)
__all__ = [
"WebAgentStreamDependencies",
"_build_steering_ack_stream",
"build_agent_web_agent_stream",
"submit_web_agent_steering",
]