package tech.easyflow.agent.runtime;

import com.easyagents.agent.runtime.AgentInitRequest;
import com.easyagents.agent.runtime.AgentRuntime;
import com.easyagents.agent.runtime.AgentRuntimeContext;
import com.easyagents.agent.runtime.event.AgentRuntimeEvent;
import com.easyagents.agent.runtime.event.AgentRuntimeEventType;
import com.easyagents.agent.runtime.message.AgentKnowledgeReference;
import com.easyagents.agent.runtime.message.AgentMessage;
import com.easyagents.agent.runtime.message.AgentMessageRole;
import com.easyagents.agent.runtime.message.AgentMediaBlock;
import com.easyagents.agent.runtime.message.AgentTextBlock;
import com.easyagents.agent.runtime.persistence.session.AgentSessionStore;
import io.agentscope.core.agui.model.RunAgentInput;
import com.mybatisflex.core.keygen.impl.SnowFlakeIDKeyGenerator;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.support.TransactionTemplate;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import tech.easyflow.agent.entity.Agent;
import tech.easyflow.agent.config.AgentBuiltinToolsConfigResolver;
import tech.easyflow.agent.entity.AgentDocumentAttachment;
import tech.easyflow.agent.entity.AgentKnowledgeBinding;
import tech.easyflow.agent.entity.AgentToolBinding;
import tech.easyflow.agent.entity.AgentSkillBinding;
import tech.easyflow.agent.enums.AgentToolType;
import tech.easyflow.agent.distributed.AgentRuntimeCommandAction;
import tech.easyflow.agent.distributed.AgentRuntimeCommandProducer;
import tech.easyflow.agent.distributed.AgentRuntimeRoute;
import tech.easyflow.agent.distributed.AgentRuntimeRouteRegistry;
import tech.easyflow.agent.runtime.event.AgentRunEventRecorder;
import tech.easyflow.agent.runtime.agui.AgentAguiRunInputMapper;
import tech.easyflow.agent.runtime.agui.AgentAguiRunDescriptor;
import tech.easyflow.agent.runtime.agui.AgentAguiRunJournal;
import tech.easyflow.agent.runtime.agui.AgentAguiRunStatus;
import tech.easyflow.agent.runtime.agui.AgentAguiRunStatusView;
import tech.easyflow.agent.runtime.agui.AgentAguiRunStore;
import tech.easyflow.agent.runtime.agui.AgentAguiRunSubscriptionService;
import tech.easyflow.agent.runtime.agui.AgentAguiWireContext;
import tech.easyflow.agent.runtime.agui.AgentAguiHitlResolveRequest;
import tech.easyflow.agent.runtime.agui.ResumableAguiSseEmitter;
import tech.easyflow.agent.runtime.artifact.AgentArtifactService;
import tech.easyflow.agent.runtime.hitl.AgentHitlPendingService;
import tech.easyflow.agent.runtime.hitl.ToolApprovalInputProjection;
import tech.easyflow.agent.runtime.lock.AgentRunLock;
import tech.easyflow.agent.runtime.output.AgentRunOutput;
import tech.easyflow.agent.runtime.output.AguiAgentRunOutput;
import tech.easyflow.agent.runtime.output.LegacyAgentRunOutput;
import tech.easyflow.agent.runtime.session.EasyFlowAgentSessionStore;
import tech.easyflow.agent.runtime.skill.AgentSkillRuntimeProjector;
import tech.easyflow.agent.runtime.media.AgentBoundMedia;
import tech.easyflow.agent.runtime.media.AgentMediaService;
import tech.easyflow.agent.runtime.media.AgentMediaUploadRecord;
import tech.easyflow.agent.runtime.document.AgentBoundDocument;
import tech.easyflow.agent.runtime.document.AgentDocumentContext;
import tech.easyflow.agent.runtime.document.AgentDocumentContextSelector;
import tech.easyflow.agent.runtime.document.AgentDocumentService;
import tech.easyflow.agent.service.AgentService;
import tech.easyflow.agent.support.AgentBindingLockExecutor;
import tech.easyflow.ai.entity.DocumentCollection;
import tech.easyflow.ai.entity.Mcp;
import tech.easyflow.ai.entity.PluginItem;
import tech.easyflow.ai.entity.Workflow;
import tech.easyflow.ai.enums.PublishStatus;
import tech.easyflow.ai.rag.KnowledgeRetrievalModes;
import tech.easyflow.ai.service.DocumentCollectionService;
import tech.easyflow.ai.service.McpService;
import tech.easyflow.ai.service.ModelService;
import tech.easyflow.ai.service.PluginItemService;
import tech.easyflow.ai.service.WorkflowService;
import tech.easyflow.chatlog.domain.dto.ChatSessionSummary;
import tech.easyflow.chatlog.service.ChatSessionQueryService;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.core.chat.protocol.ChatDomain;
import tech.easyflow.core.chat.protocol.ChatType;
import tech.easyflow.core.runtime.*;
import tech.easyflow.system.enums.CategoryResourceType;
import tech.easyflow.system.enums.ResourceAction;
import tech.easyflow.system.service.ResourceAccessService;

import javax.annotation.Resource;
import java.math.BigInteger;
import java.util.*;
import java.util.concurrent.atomic.AtomicBoolean;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;

/**
 * Agent 管理端运行服务。
 */
@Service
public class AgentRunService {

    private static final Logger LOG = LoggerFactory.getLogger(AgentRunService.class);
    private static final String ASSISTANT_CODE = "AGENT";
    private static final String DRAFT_ASSISTANT_CODE = "AGENT_DRAFT";
    private static final String HITL_APPROVAL_EXPIRED_REASON = "审批超时，已自动拒绝";
    private static final String DOCUMENT_CITATIONS_EXT_KEY = "documentCitations";
    private static final String DOCUMENT_CONTEXT_TOKEN_ESTIMATE_EXT_KEY = "documentContextTokenEstimate";

    @Resource
    private AgentService agentService;
    @Resource
    private AgentRuntimeCompiler agentRuntimeCompiler;
    @Resource
    private AgentSkillRuntimeProjector agentSkillRuntimeProjector;
    @Resource
    private AgentRuntimeFactory agentRuntimeFactory;
    @Resource
    private AgentChatCapabilityService agentChatCapabilityService;
    @Resource
    private EasyFlowAgentSessionStore easyFlowAgentSessionStore;
    @Resource
    private AgentSessionStore draftAgentSessionStore;
    @Resource
    private AgentRunRegistry agentRunRegistry;
    @Resource
    private AgentBindingLockExecutor agentBindingLockExecutor;
    @Resource
    private AgentRunStartGuard agentRunStartGuard;
    @Resource
    private AgentRuntimeRouteRegistry agentRuntimeRouteRegistry;
    @Resource
    private AgentRuntimeCommandProducer agentRuntimeCommandProducer;
    @Resource
    private AgentRunLock agentRunLock;
    @Resource
    private AgentHitlPendingService agentHitlPendingService;
    @Resource
    private AgentRunEventRecorder agentRunEventRecorder;
    @Resource
    private ChatRuntimeManager chatRuntimeManager;
    @Resource
    private ChatSessionQueryService chatSessionQueryService;
    @Resource(name = "sseThreadPool")
    private ThreadPoolTaskExecutor threadPoolTaskExecutor;
    @Resource
    private ResourceAccessService resourceAccessService;
    @Resource
    private WorkflowService workflowService;
    @Resource
    private PluginItemService pluginItemService;
    @Resource
    private McpService mcpService;
    @Resource
    private DocumentCollectionService documentCollectionService;
    @Resource
    private ModelService modelService;
    @Resource
    private AgentMediaService agentMediaService;
    @Resource
    private AgentDocumentService agentDocumentService;
    @Resource
    private AgentDocumentContextSelector agentDocumentContextSelector;
    @Resource
    private TransactionTemplate transactionTemplate;
    @Resource
    private AgentAguiRunInputMapper agentAguiRunInputMapper;
    @Resource
    private AgentAguiRunStore agentAguiRunStore;
    @Resource
    private AgentAguiRunJournal agentAguiRunJournal;
    @Resource
    private AgentAguiRunSubscriptionService agentAguiRunSubscriptionService;
    @Resource
    private AgentBuiltinToolsConfigResolver agentBuiltinToolsConfigResolver;
    @Resource
    private AgentArtifactService agentArtifactService;

    /**
     * 启动 Agent 聊天。
     *
     * @param chatRequest 聊天请求
     * @return SSE Emitter
     */
    public SseEmitter chat(AgentChatRequest chatRequest) {
        return chat(chatRequest, null);
    }

    /**
     * 通过受控 AG-UI 输入启动正式 Agent 聊天。
     *
     * @param agentId URL 中的可信 Agent ID
     * @param input AG-UI 运行输入
     * @return SSE Emitter
     */
    public SseEmitter chatAgui(BigInteger agentId, RunAgentInput input) {
        AgentChatRequest request = agentAguiRunInputMapper.toFormalRequest(agentId, input);
        return chat(request, agentAguiRunInputMapper.wireContext(input));
    }

    private SseEmitter chat(AgentChatRequest chatRequest, AgentAguiWireContext wireContext) {
        // 判定Agent是否对当前用户可用
        validateChatRequest(chatRequest);
        LoginAccount account = requireCurrentLoginAccount();
        Agent liveAgent = agentService.getById(chatRequest.getAgentId());
        if (liveAgent == null) {
            throw new BusinessException("Agent 不存在");
        }
        resourceAccessService.assertAccess(CategoryResourceType.AGENT, liveAgent, ResourceAction.USE, "无权限运行该 Agent");
        assertAgentRunnable(liveAgent);
        // 创建会话 ID
        BigInteger sessionId = chatRequest.getSessionId() == null
                ? BigInteger.valueOf(new SnowFlakeIDKeyGenerator().nextId())
                : chatRequest.getSessionId();
        ChatSessionSummary existingSession = resolveExistingSession(account, sessionId, chatRequest.getAgentId());
        // 获取 Agent 发布快照
        Agent agent = agentService.getPublishedView(chatRequest.getAgentId());
        AgentChatCapabilityService.AgentChatCapabilityResolution capabilityResolution =
                agentChatCapabilityService.apply(agent, chatRequest.getCapabilities(), account);
        agent = capabilityResolution.agent();
        assertImageCapability(agent, chatRequest.getImageUploadIds());
        List<AgentMediaUploadRecord> mediaUploads = agentMediaService.requireUploads(
                chatRequest.getImageUploadIds(), AgentMediaService.MODE_FORMAL,
                chatRequest.getAgentId().toString(), sessionId.toString(), account);
        List<AgentDocumentAttachment> documentUploads = requireDocumentUploads(
                chatRequest.getDocumentUploadIds(), AgentMediaService.MODE_FORMAL,
                chatRequest.getAgentId().toString(), sessionId.toString(), account);
        String requestId = UUID.randomUUID().toString();
        String traceId = UUID.randomUUID().toString();
        String titlePrompt = effectivePrompt(chatRequest.getPrompt(), !documentUploads.isEmpty(), !mediaUploads.isEmpty());
        // 组建会话上下文必要信息
        ChatRuntimeContext chatContext = buildChatRuntimeContext(agent, sessionId, titlePrompt, account);
        if (capabilityResolution.knowledgeCapabilityProvided()) {
            chatContext.getExt().put(ChatRuntimeExtKeys.EXTRA_KNOWLEDGE_IDS, capabilityResolution.extraKnowledgeIds());
        }
        applyFormalSessionTitle(chatContext, titlePrompt, existingSession);
        // 执行对话
        AgentRunOutput runOutput = createRunOutput(
                wireContext, requestId, account, agent, sessionId.toString(), false);
        return runAguiAware(agent, chatRequest.getPrompt(), mediaUploads, documentUploads,
                account, requestId, traceId, sessionId.toString(),
                ASSISTANT_CODE, chatContext, true, easyFlowAgentSessionStore, runOutput);
    }

    /**
     * 通过公共 API 启动已发布 Agent 的纯文本聊天。
     *
     * @param chatRequest 聊天请求
     * @param apiAccount API Key 对应的隔离调用身份
     * @return SSE Emitter
     */
    public SseEmitter chatPublic(AgentChatRequest chatRequest, LoginAccount apiAccount) {
        validateChatRequest(chatRequest);
        if (apiAccount == null || apiAccount.getId() == null) {
            throw new BusinessException("API 调用身份不能为空");
        }
        if (chatRequest.getImageUploadIds() != null && !chatRequest.getImageUploadIds().isEmpty()) {
            throw new BusinessException("公共 Agent API 暂不支持图片附件");
        }
        if (chatRequest.getDocumentUploadIds() != null && !chatRequest.getDocumentUploadIds().isEmpty()) {
            throw new BusinessException("公共 Agent API 暂不支持文档附件");
        }
        if (chatRequest.getCapabilities() != null && !chatRequest.getCapabilities().isEmpty()) {
            throw new BusinessException("公共 Agent API 暂不支持临时能力");
        }
        Agent liveAgent = agentService.getById(chatRequest.getAgentId());
        if (liveAgent == null || !Objects.equals(liveAgent.getTenantId(), apiAccount.getTenantId())) {
            throw new BusinessException("Agent 不存在或不可用");
        }
        assertAgentRunnable(liveAgent);
        BigInteger sessionId = chatRequest.getSessionId() == null
                ? BigInteger.valueOf(new SnowFlakeIDKeyGenerator().nextId())
                : chatRequest.getSessionId();
        ChatSessionSummary existingSession =
                resolveExistingSession(apiAccount, sessionId, chatRequest.getAgentId());
        Agent agent = agentService.getPublishedView(chatRequest.getAgentId());
        assertPublicHitlUnsupported(agent);
        String requestId = UUID.randomUUID().toString();
        String traceId = UUID.randomUUID().toString();
        String titlePrompt = effectivePrompt(chatRequest.getPrompt(), false, false);
        ChatRuntimeContext chatContext = buildChatRuntimeContext(
                agent, sessionId, titlePrompt, apiAccount, ASSISTANT_CODE, ChatChannel.PUBLIC_API, true);
        applyFormalSessionTitle(chatContext, titlePrompt, existingSession);
        return run(agent, chatRequest.getPrompt(), Collections.emptyList(), Collections.emptyList(),
                apiAccount, requestId, traceId, sessionId.toString(),
                ASSISTANT_CODE, chatContext, true, easyFlowAgentSessionStore);
    }

    /**
     * 公共 API 当前没有审批恢复入口，因此拒绝包含 HITL 工具的 Agent。
     *
     * @param agent 已发布 Agent 运行视图
     */
    private void assertPublicHitlUnsupported(Agent agent) {
        if (agent == null || agent.getToolBindings() == null) {
            return;
        }
        for (AgentToolBinding binding : agent.getToolBindings()) {
            if (binding == null || !Boolean.TRUE.equals(binding.getEnabled())) {
                continue;
            }
            if (Boolean.TRUE.equals(binding.getHitlEnabled())) {
                throw new BusinessException("公共 Agent API 暂不支持需要执行确认的工具");
            }
            Object approvalRequired = binding.getResourceSnapshot() == null
                    ? null : binding.getResourceSnapshot().get("approvalRequired");
            if ("MCP".equalsIgnoreCase(binding.getToolType())
                    && Boolean.parseBoolean(String.valueOf(approvalRequired))) {
                throw new BusinessException("公共 Agent API 暂不支持需要执行确认的 MCP");
            }
        }
    }

    /**
     * 启动 Agent 草稿态纯文本试用。
     *
     * @param draftRequest 草稿试用请求
     * @return SSE Emitter
     */
    public SseEmitter chatDraft(AgentDraftChatRequest draftRequest) {
        return chatDraft(draftRequest, null);
    }

    /**
     * 通过受控 AG-UI 输入启动草稿 Agent 试用。
     *
     * @param input AG-UI 运行输入
     * @return SSE Emitter
     */
    public SseEmitter chatDraftAgui(RunAgentInput input) {
        AgentDraftChatRequest request = agentAguiRunInputMapper.toDraftRequest(input);
        return chatDraft(request, agentAguiRunInputMapper.wireContext(input));
    }

    private SseEmitter chatDraft(AgentDraftChatRequest draftRequest, AgentAguiWireContext wireContext) {
        validateDraftChatRequest(draftRequest);
        LoginAccount account = requireCurrentLoginAccount();
        Agent agent = buildDraftAgent(draftRequest, account);
        validateDraftResources(agent);

        String runtimeSessionId = draftRequest.getSessionId();
        if (runtimeSessionId == null || runtimeSessionId.isBlank()) {
            runtimeSessionId = "agent-draft-" + new SnowFlakeIDKeyGenerator().nextId();
        }
        assertImageCapability(agent, draftRequest.getImageUploadIds());
        List<AgentMediaUploadRecord> mediaUploads = agentMediaService.requireUploads(
                draftRequest.getImageUploadIds(), AgentMediaService.MODE_DRAFT,
                agent.getId().toString(), runtimeSessionId, account);
        List<AgentDocumentAttachment> documentUploads = requireDocumentUploads(
                draftRequest.getDocumentUploadIds(), AgentMediaService.MODE_DRAFT,
                agent.getId().toString(), runtimeSessionId, account);
        BigInteger chatSessionId = BigInteger.valueOf(new SnowFlakeIDKeyGenerator().nextId());
        String requestId = UUID.randomUUID().toString();
        String traceId = UUID.randomUUID().toString();
        String titlePrompt = effectivePrompt(draftRequest.getPrompt(), !documentUploads.isEmpty(), !mediaUploads.isEmpty());
        ChatRuntimeContext chatContext = buildChatRuntimeContext(
                agent, chatSessionId, titlePrompt, account, DRAFT_ASSISTANT_CODE);
        AgentRunOutput runOutput = createRunOutput(
                wireContext, requestId, account, agent, runtimeSessionId, true);
        return runAguiAware(agent, draftRequest.getPrompt(), mediaUploads, documentUploads,
                account, requestId, traceId, runtimeSessionId,
                DRAFT_ASSISTANT_CODE, chatContext, false, draftAgentSessionStore, runOutput);
    }

    private SseEmitter run(Agent agent,
                           String prompt,
                           List<AgentMediaUploadRecord> mediaUploads,
                           List<AgentDocumentAttachment> documentUploads,
                           LoginAccount account,
                           String requestId,
                           String traceId,
                           String runtimeSessionId,
                           String assistantCode,
                           ChatRuntimeContext chatContext,
                           boolean persistChatlog,
                           AgentSessionStore runtimeSessionStore) {
        return run(agent, prompt, mediaUploads, documentUploads, account, requestId, traceId,
                runtimeSessionId, assistantCode, chatContext, persistChatlog, runtimeSessionStore,
                new LegacyAgentRunOutput());
    }

    private SseEmitter run(Agent agent,
                           String prompt,
                           List<AgentMediaUploadRecord> mediaUploads,
                           List<AgentDocumentAttachment> documentUploads,
                           LoginAccount account,
                           String requestId,
                           String traceId,
                           String runtimeSessionId,
                           String assistantCode,
                           ChatRuntimeContext chatContext,
                           boolean persistChatlog,
                           AgentSessionStore runtimeSessionStore,
                           AgentRunOutput runOutput) {
        // 获取会话锁
        AgentRunLock.Handle lockHandle = acquireRunLock(agent, runtimeSessionId);
        boolean submitted = false;
        try {
            List<AgentBoundMedia> boundMedia;
            List<AgentBoundDocument> describedDocuments = describeDocuments(documentUploads);
            AgentDocumentContext documentContext = selectDocumentContext(agent, describedDocuments, prompt);
            List<AgentBoundDocument> boundDocuments;
            if (persistChatlog) {
                // 持久化会话初始信息
                chatRuntimeManager.prepareSession(chatContext);
                if (!sendSessionCreated(runOutput, chatContext.getSessionId())) {
                    chatRuntimeManager.recordFailure(chatContext, new BusinessException("客户端连接已断开，Agent 运行已取消"));
                    return runOutput.emitter();
                }
                BigInteger messageId = BigInteger.valueOf(new SnowFlakeIDKeyGenerator().nextId());
                PreparedInput preparedInput = bindAndRecordFormalInput(
                        mediaUploads, documentUploads, account, chatContext, messageId, prompt);
                boundMedia = preparedInput.media();
                boundDocuments = preparedInput.documents();
                if (!sendInputAccepted(runOutput, chatContext.getSessionId(), messageId,
                        boundMedia, boundDocuments)) {
                    chatRuntimeManager.recordFailure(chatContext, new BusinessException("客户端连接已断开，Agent 运行已取消"));
                    return runOutput.emitter();
                }
            } else {
                boundMedia = agentMediaService.bindDraft(mediaUploads);
                boundDocuments = bindDraftDocuments(documentUploads);
                if (!sendInputAccepted(runOutput, null, null, boundMedia, boundDocuments)) {
                    return runOutput.emitter();
                }
            }
            chatContext.getExt().put(DOCUMENT_CITATIONS_EXT_KEY, documentContext.citations());
            chatContext.getExt().put(DOCUMENT_CONTEXT_TOKEN_ESTIMATE_EXT_KEY,
                    documentContext.tokenEstimate());
            String runtimePrompt = effectivePrompt(prompt, !boundDocuments.isEmpty(), !boundMedia.isEmpty());
            AgentMessage userMessage = buildAgentMessage(runtimePrompt, boundMedia, documentContext);
            threadPoolTaskExecutor.execute(() -> startRuntime(
                    agent, userMessage, documentContext, account, requestId, traceId, runtimeSessionId,
                    assistantCode, chatContext, runOutput, persistChatlog, runtimeSessionStore, lockHandle));
            submitted = true;
            return runOutput.emitter();
        } finally {
            if (!submitted) {
                try {
                    if (lockHandle != null) {
                        lockHandle.release();
                    }
                } finally {
                    if (!runOutput.cancelRunOnDisconnect()) {
                        // 可重连运行已在编译前注册临时 owner；未提交到后台时必须同步移除。
                        agentRuntimeRouteRegistry.removeRun(requestId);
                    }
                }
            }
        }
    }

    private AgentRunOutput createRunOutput(AgentAguiWireContext wireContext,
                                           String requestId,
                                           LoginAccount account,
                                           Agent agent,
                                           String runtimeSessionId,
                                           boolean draft) {
        if (wireContext == null) {
            return new LegacyAgentRunOutput();
        }
        long now = System.currentTimeMillis();
        AgentAguiRunDescriptor descriptor = new AgentAguiRunDescriptor(
                wireContext.runId(),
                requestId,
                wireContext.threadId(),
                agent == null || agent.getId() == null ? "" : agent.getId().toString(),
                runtimeSessionId,
                account.getId().toString(),
                account.getTenantId().toString(),
                draft,
                AgentAguiRunStatus.RUNNING,
                0L,
                now,
                now);
        boolean storeCreated = false;
        try {
            // 在耗时编译和初始化前注册 owner，刷新恢复可据节点心跳区分“仍在启动”与 owner 丢失。
            agentRuntimeRouteRegistry.registerRun(requestId, descriptor.agentId());
            agentAguiRunStore.create(descriptor);
            storeCreated = true;
            SseEmitter subscriberEmitter = agentAguiRunSubscriptionService.subscribe(descriptor, 0L);
            return new AguiAgentRunOutput(
                    wireContext.threadId(),
                    wireContext.runId(),
                    wireContext.userMessageId(),
                    wireContext.userMessageContent(),
                    new ResumableAguiSseEmitter(
                            wireContext.threadId(), wireContext.runId(),
                            agentAguiRunJournal, subscriberEmitter),
                    false);
        } catch (RuntimeException exception) {
            if (storeCreated) {
                try {
                    agentAguiRunJournal.fail(
                            descriptor.threadId(), descriptor.runId(),
                            "AGENT_RUN_START_FAILED", "Agent 运行启动失败");
                } catch (RuntimeException journalException) {
                    exception.addSuppressed(journalException);
                }
            }
            try {
                agentRuntimeRouteRegistry.removeRun(requestId);
            } catch (RuntimeException routeException) {
                exception.addSuppressed(routeException);
            }
            throw exception;
        }
    }

    private SseEmitter runAguiAware(Agent agent,
                                    String prompt,
                                    List<AgentMediaUploadRecord> mediaUploads,
                                    List<AgentDocumentAttachment> documentUploads,
                                    LoginAccount account,
                                    String requestId,
                                    String traceId,
                                    String runtimeSessionId,
                                    String assistantCode,
                                    ChatRuntimeContext chatContext,
                                    boolean persistChatlog,
                                    AgentSessionStore runtimeSessionStore,
                                    AgentRunOutput runOutput) {
        try {
            return run(agent, prompt, mediaUploads, documentUploads, account, requestId, traceId,
                    runtimeSessionId, assistantCode, chatContext, persistChatlog, runtimeSessionStore, runOutput);
        } catch (RuntimeException exception) {
            if (!runOutput.cancelRunOnDisconnect()) {
                try {
                    runOutput.completeWithError(exception);
                } catch (RuntimeException outputException) {
                    exception.addSuppressed(outputException);
                }
                try {
                    agentRuntimeRouteRegistry.removeRun(requestId);
                } catch (RuntimeException routeException) {
                    exception.addSuppressed(routeException);
                }
            }
            throw exception;
        }
    }

    /**
     * 校验本轮文档上传。无文档时不访问文档服务，兼容纯文本和图片聊天。
     *
     * @param uploadIds 文档上传 ID
     * @param mode 聊天模式
     * @param agentId Agent ID
     * @param sessionId 会话 ID
     * @param account 当前账号
     * @return 已校验文档
     */
    private List<AgentDocumentAttachment> requireDocumentUploads(List<String> uploadIds,
                                                                 String mode,
                                                                 String agentId,
                                                                 String sessionId,
                                                                 LoginAccount account) {
        if (uploadIds == null || uploadIds.isEmpty()) {
            return List.of();
        }
        if (agentDocumentService == null) {
            throw new BusinessException("文档附件服务不可用");
        }
        return agentDocumentService.requireUploads(uploadIds, mode, agentId, sessionId, account);
    }

    /**
     * 获取文档只读运行时描述。
     *
     * @param attachments 已校验文档
     * @return 文档运行时描述
     */
    private List<AgentBoundDocument> describeDocuments(List<AgentDocumentAttachment> attachments) {
        if (attachments == null || attachments.isEmpty()) {
            return List.of();
        }
        return agentDocumentService.describe(attachments);
    }

    /**
     * 按 Agent 预算选择本轮文档上下文。
     *
     * @param agent Agent
     * @param documents 文档描述
     * @param prompt 用户问题
     * @return 受控文档上下文
     */
    private AgentDocumentContext selectDocumentContext(Agent agent,
                                                       List<AgentBoundDocument> documents,
                                                       String prompt) {
        if (documents == null || documents.isEmpty()) {
            return AgentDocumentContext.empty();
        }
        if (agentDocumentContextSelector == null) {
            throw new BusinessException("文档上下文服务不可用");
        }
        return agentDocumentContextSelector.select(agent, documents, prompt);
    }

    /**
     * 在同一数据库事务中绑定正式附件、写入用户消息并完成文档状态提交。
     *
     * @param mediaUploads 图片上传
     * @param documentUploads 文档上传
     * @param account 当前账号
     * @param chatContext 聊天上下文
     * @param messageId 消息 ID
     * @param prompt 原始用户输入
     * @return 已绑定输入
     */
    private PreparedInput bindAndRecordFormalInput(List<AgentMediaUploadRecord> mediaUploads,
                                                   List<AgentDocumentAttachment> documentUploads,
                                                   LoginAccount account,
                                                   ChatRuntimeContext chatContext,
                                                   BigInteger messageId,
                                                   String prompt) {
        java.util.function.Supplier<PreparedInput> action = () -> {
            List<AgentBoundMedia> media = agentMediaService.bindFormal(
                    mediaUploads, chatContext.getSessionId().toString(), messageId.toString(), account);
            List<AgentBoundDocument> documents = documentUploads == null || documentUploads.isEmpty()
                    ? List.of()
                    : agentDocumentService.beginFormalBinding(documentUploads, messageId.toString());
            chatRuntimeManager.recordUserMessage(chatContext,
                    buildUserRuntimeMessage(chatContext, messageId, prompt, media, documents));
            if (documentUploads != null && !documentUploads.isEmpty()) {
                agentDocumentService.completeFormalBinding(documentUploads, messageId.toString());
            }
            return new PreparedInput(media, documents);
        };
        if (transactionTemplate == null) {
            return action.get();
        }
        PreparedInput result = transactionTemplate.execute(status -> action.get());
        if (result == null) {
            throw new BusinessException("Agent 输入绑定失败");
        }
        return result;
    }

    /**
     * 为草稿试运行绑定文档并续期。
     *
     * @param documentUploads 文档上传
     * @return 已绑定文档
     */
    private List<AgentBoundDocument> bindDraftDocuments(List<AgentDocumentAttachment> documentUploads) {
        if (documentUploads == null || documentUploads.isEmpty()) {
            return List.of();
        }
        return agentDocumentService.bindDraft(documentUploads);
    }

    /**
     * 为仅附件输入生成可持久化的最小用户意图。
     *
     * @param prompt 原始用户输入
     * @param hasDocuments 是否包含文档
     * @param hasImages 是否包含图片
     * @return 可发送给运行时的用户输入
     */
    private String effectivePrompt(String prompt, boolean hasDocuments, boolean hasImages) {
        if (prompt != null && !prompt.isBlank()) {
            return prompt;
        }
        if (hasDocuments) {
            return "请阅读并分析本轮上传的文档。";
        }
        if (hasImages) {
            return "请分析本轮上传的图片。";
        }
        return "";
    }

    /**
     * 一次完成绑定的图片和文档。
     *
     * @param media 图片
     * @param documents 文档
     */
    private record PreparedInput(List<AgentBoundMedia> media,
                                 List<AgentBoundDocument> documents) {
    }

    /**
     * 清理草稿试运行会话。
     *
     * @param sessionId 草稿会话 ID
     */
    public void clearDraftSession(String sessionId) {
        if (sessionId == null || sessionId.isBlank()) {
            throw new BusinessException("Agent 草稿会话 ID 不能为空");
        }
        if (!sessionId.startsWith("agent-draft-")) {
            throw new BusinessException("仅允许清理 Agent 草稿试运行会话");
        }
        LoginAccount account = requireCurrentLoginAccount();
        clearDraftSessionInternal(sessionId,
                account.getId() == null ? null : account.getId().toString(),
                account.getTenantId() == null ? null : account.getTenantId().toString());
    }

    /**
     * 查询当前用户可访问的 AG-UI 运行状态。
     *
     * @param runId 客户端运行 ID
     * @return 运行公开状态
     */
    public AgentAguiRunStatusView getAguiRunStatus(String runId) {
        LoginAccount account = requireCurrentLoginAccount();
        return AgentAguiRunStatusView.from(reconcileInterruptedRun(
                agentAguiRunStore.requireOwned(runId, account)));
    }

    /**
     * 从指定游标订阅当前用户可访问的 AG-UI 运行事件。
     *
     * @param runId 客户端运行 ID
     * @param afterCursor 已消费的最后事件游标
     * @return 增量重放 SSE
     */
    public SseEmitter subscribeAguiRun(String runId, long afterCursor) {
        LoginAccount account = requireCurrentLoginAccount();
        AgentAguiRunDescriptor descriptor = reconcileInterruptedRun(
                agentAguiRunStore.requireOwned(runId, account));
        return agentAguiRunSubscriptionService.subscribe(descriptor, afterCursor);
    }

    /**
     * 显式取消当前用户拥有的单次 AG-UI 运行。
     *
     * @param runId 客户端运行 ID
     */
    public void cancelAguiRun(String runId) {
        LoginAccount account = requireCurrentLoginAccount();
        AgentAguiRunDescriptor descriptor = reconcileInterruptedRun(
                agentAguiRunStore.requireOwned(runId, account));
        if (descriptor.status().isTerminal()) {
            return;
        }
        agentAguiRunStore.requestCancellation(runId);
        String userId = account.getId().toString();
        if (agentRunRegistry.get(descriptor.requestId()) != null) {
            cancelRunLocal(descriptor.requestId(), userId, "用户已停止生成");
            return;
        }
        AgentRuntimeRoute route = agentRuntimeRouteRegistry.findOwnerRoute(descriptor.requestId());
        if (route == null || route.getNodeId() == null || route.getNodeId().isBlank()) {
            // Runtime 可能仍在编译或初始化，启动路径会在注册前后消费 cancel intent。
            return;
        }
        agentRuntimeCommandProducer.sendCancelRun(
                route.getNodeId(), descriptor.requestId(), userId, "用户已停止生成");
    }

    /**
     * 在当前节点显式取消指定运行。
     *
     * @param requestId 内部请求 ID
     * @param userId 当前用户 ID
     * @param reason 取消原因
     */
    public void cancelRunLocal(String requestId, String userId, String reason) {
        agentRunRegistry.cancelRun(requestId, userId, reason);
    }

    private AgentAguiRunDescriptor reconcileInterruptedRun(AgentAguiRunDescriptor descriptor) {
        if (descriptor == null || descriptor.status().isTerminal()) {
            return descriptor;
        }
        AgentRuntimeRoute route = agentRuntimeRouteRegistry.findOwnerRoute(descriptor.requestId());
        boolean stale = route == null;
        if (route != null && route.getBootId() != null && route.getNodeId() != null) {
            String currentBootId = agentRuntimeRouteRegistry.currentNodeBootId(route.getNodeId());
            stale = currentBootId == null || !route.getBootId().equals(currentBootId);
        }
        if (!stale || System.currentTimeMillis() - descriptor.updatedAt() < 90_000L) {
            return descriptor;
        }
        agentAguiRunStore.failOwnerLost(descriptor);
        AgentAguiRunDescriptor refreshed = agentAguiRunStore.find(descriptor.runId());
        return refreshed == null ? descriptor : refreshed;
    }

    private void clearDraftSessionInternal(String sessionId, String userId, String tenantId) {
        agentRunRegistry.cancelSession(sessionId, userId);
        draftAgentSessionStore.delete(sessionId);
        if (userId != null && tenantId != null && agentArtifactService != null) {
            agentArtifactService.markDraftSessionDeletePending(
                    sessionId, new BigInteger(tenantId), new BigInteger(userId));
        }
    }

    /**
     * 批准工具执行。
     *
     * @param requestId 请求 ID
     * @param resumeToken 恢复令牌
     */
    public void approve(String requestId, String resumeToken) {
        LoginAccount account = requireCurrentLoginAccount();
        approveRuntime(requestId, resumeToken, account.getId(), account.getId() == null ? null : account.getId().toString());
    }

    private void approveRuntime(String requestId, String resumeToken, BigInteger operatorId, String userId) {
        if (!agentRunRegistry.containsResumeTarget(requestId, resumeToken)) {
            dispatchRemoteRuntimeCommand(
                    requestId, resumeToken, AgentRuntimeCommandAction.APPROVE, null, operatorId, userId, null);
            return;
        }
        approveRuntimeLocal(requestId, resumeToken, operatorId, userId);
    }

    /**
     * 在当前节点批准工具执行。
     *
     * @param requestId 请求 ID
     * @param resumeToken 恢复令牌
     * @param operatorId 操作人 ID
     * @param userId 用户 ID
     */
    public void approveRuntimeLocal(String requestId, String resumeToken, BigInteger operatorId, String userId) {
        if (agentRunRegistry.isDraftResumeTarget(requestId, resumeToken)) {
            agentRunRegistry.approve(requestId, resumeToken, userId);
            return;
        }
        agentRunRegistry.approve(requestId, resumeToken, userId,
                () -> agentHitlPendingService.approve(resumeToken, operatorId));
    }

    /**
     * 拒绝工具执行。
     *
     * @param requestId 请求 ID
     * @param resumeToken 恢复令牌
     * @param reason 拒绝原因
     */
    public void reject(String requestId, String resumeToken, String reason) {
        LoginAccount account = requireCurrentLoginAccount();
        rejectRuntime(requestId, resumeToken, reason, account.getId(), account.getId() == null ? null : account.getId().toString());
    }

    /**
     * 通过不透明审批 ID 处理 AG-UI HITL 决策。
     *
     * @param request 审批请求
     */
    public void resolveAguiApproval(AgentAguiHitlResolveRequest request) {
        if (request == null || request.getApprovalId() == null || request.getApprovalId().isBlank()) {
            throw new BusinessException("Agent 审批 ID 不能为空");
        }
        String decision = request.getDecision() == null ? "" : request.getDecision().trim().toUpperCase();
        if (!"APPROVE".equals(decision) && !"REJECT".equals(decision)) {
            throw new BusinessException("Agent 审批决策不合法");
        }
        LoginAccount account = requireCurrentLoginAccount();
        agentRunRegistry.assertApprovalOwner(request.getApprovalId(), account.getId().toString());
        AgentRunRegistry.ApprovalTarget target = agentRunRegistry.resolveApproval(request.getApprovalId());
        if ("APPROVE".equals(decision)) {
            if (agentRunRegistry.containsResumeTarget(target.requestId(), target.resumeToken())) {
                approveAguiRuntimeLocal(
                        target.requestId(), target.resumeToken(), request.getApprovalId(),
                        account.getId(), account.getId().toString());
            } else {
                dispatchRemoteRuntimeCommand(
                        target.requestId(), target.resumeToken(), AgentRuntimeCommandAction.APPROVE, null,
                        account.getId(), account.getId().toString(), request.getApprovalId());
            }
        } else {
            if (agentRunRegistry.containsResumeTarget(target.requestId(), target.resumeToken())) {
                rejectAguiRuntimeLocal(
                        target.requestId(), target.resumeToken(), request.getApprovalId(), request.getReason(),
                        account.getId(), account.getId().toString());
            } else {
                dispatchRemoteRuntimeCommand(
                        target.requestId(), target.resumeToken(), AgentRuntimeCommandAction.REJECT, request.getReason(),
                        account.getId(), account.getId().toString(), request.getApprovalId());
            }
        }
        agentRunRegistry.removeApproval(request.getApprovalId());
    }

    private void rejectRuntime(String requestId, String resumeToken, String reason, BigInteger operatorId, String userId) {
        if (!agentRunRegistry.containsResumeTarget(requestId, resumeToken)) {
            dispatchRemoteRuntimeCommand(
                    requestId, resumeToken, AgentRuntimeCommandAction.REJECT, reason, operatorId, userId, null);
            return;
        }
        rejectRuntimeLocal(requestId, resumeToken, reason, operatorId, userId);
    }

    /**
     * 在当前节点拒绝工具执行。
     *
     * @param requestId 请求 ID
     * @param resumeToken 恢复令牌
     * @param reason 拒绝原因
     * @param operatorId 操作人 ID
     * @param userId 用户 ID
     */
    public void rejectRuntimeLocal(String requestId, String resumeToken, String reason, BigInteger operatorId, String userId) {
        if (agentRunRegistry.isDraftResumeTarget(requestId, resumeToken)) {
            agentRunRegistry.reject(requestId, resumeToken, userId, reason);
            return;
        }
        agentRunRegistry.reject(requestId, resumeToken, userId, reason,
                () -> agentHitlPendingService.reject(resumeToken, operatorId, reason));
    }

    /**
     * 在当前节点批准 AG-UI 工具执行，并在恢复前发送脱敏决议事件。
     *
     * @param requestId 请求 ID
     * @param resumeToken 恢复令牌
     * @param approvalId 不透明审批 ID
     * @param operatorId 操作人 ID
     * @param userId 用户 ID
     */
    public void approveAguiRuntimeLocal(
            String requestId,
            String resumeToken,
            String approvalId,
            BigInteger operatorId,
            String userId) {
        Runnable emitResolved = () -> agentRunRegistry.emitApprovalResolved(approvalId, "APPROVED", null);
        if (agentRunRegistry.isDraftResumeTarget(requestId, resumeToken)) {
            agentRunRegistry.approve(requestId, resumeToken, userId, emitResolved);
        } else {
            agentRunRegistry.approve(requestId, resumeToken, userId, () -> {
                agentHitlPendingService.approve(resumeToken, operatorId);
                emitResolved.run();
            });
        }
        agentRunRegistry.removeApproval(approvalId);
    }

    /**
     * 在当前节点拒绝 AG-UI 工具执行，并在恢复前发送脱敏决议事件。
     *
     * @param requestId 请求 ID
     * @param resumeToken 恢复令牌
     * @param approvalId 不透明审批 ID
     * @param reason 拒绝原因
     * @param operatorId 操作人 ID
     * @param userId 用户 ID
     */
    public void rejectAguiRuntimeLocal(
            String requestId,
            String resumeToken,
            String approvalId,
            String reason,
            BigInteger operatorId,
            String userId) {
        Runnable emitResolved = () -> agentRunRegistry.emitApprovalResolved(approvalId, "REJECTED", reason);
        if (agentRunRegistry.isDraftResumeTarget(requestId, resumeToken)) {
            agentRunRegistry.reject(requestId, resumeToken, userId, reason, emitResolved);
        } else {
            agentRunRegistry.reject(requestId, resumeToken, userId, reason, () -> {
                agentHitlPendingService.reject(resumeToken, operatorId, reason);
                emitResolved.run();
            });
        }
        agentRunRegistry.removeApproval(approvalId);
    }

    /**
     * 将已经持久化为过期状态的审批同步到运行节点。
     *
     * @param requestId 请求 ID
     * @param resumeToken 恢复令牌
     */
    public void expireApproval(String requestId, String resumeToken) {
        if (agentRunRegistry.containsResumeTarget(requestId, resumeToken)) {
            expireApprovalLocal(requestId, resumeToken, HITL_APPROVAL_EXPIRED_REASON);
            return;
        }
        dispatchRemoteRuntimeCommand(requestId, resumeToken, AgentRuntimeCommandAction.EXPIRE,
                HITL_APPROVAL_EXPIRED_REASON, null, null, null);
    }

    /**
     * 在当前运行节点取消已经过期的审批。
     *
     * <p>数据库记录已由过期任务原子更新，本方法只恢复 runtime 的拒绝分支，
     * 避免再次消费持久化 pending。</p>
     *
     * @param requestId 请求 ID
     * @param resumeToken 恢复令牌
     * @param reason 过期原因
     */
    public void expireApprovalLocal(String requestId, String resumeToken, String reason) {
        String resolvedReason = reason == null || reason.isBlank()
                ? HITL_APPROVAL_EXPIRED_REASON
                : reason;
        String approvalId = agentRunRegistry.findApprovalId(requestId, resumeToken);
        Runnable emitResolved = approvalId == null
                ? null
                : () -> agentRunRegistry.emitApprovalResolved(approvalId, "EXPIRED", resolvedReason);
        agentRunRegistry.reject(requestId, resumeToken, null, resolvedReason, emitResolved);
        if (approvalId != null) {
            agentRunRegistry.removeApproval(approvalId);
        }
    }

    private void dispatchRemoteRuntimeCommand(String requestId,
                                              String resumeToken,
                                              AgentRuntimeCommandAction action,
                                              String reason,
                                              BigInteger operatorId,
                                              String userId,
                                              String approvalId) {
        String resolvedRequestId = resolveRequestIdForRemoteDispatch(requestId, resumeToken);
        AgentRuntimeRoute ownerRoute = agentRuntimeRouteRegistry.findOwnerRoute(resolvedRequestId);
        String ownerNodeId = ownerRoute == null ? null : ownerRoute.getNodeId();
        if (ownerNodeId == null || ownerNodeId.isBlank()) {
            throw new BusinessException("Agent 运行节点不可用，请重新发起对话");
        }
        if (ownerNodeId.equals(agentRuntimeRouteRegistry.currentNodeId())) {
            throw new BusinessException("Agent 运行节点不可用，请重新发起对话");
        }
        if (!agentRuntimeRouteRegistry.isNodeAlive(ownerNodeId)) {
            throw new BusinessException("Agent 运行节点不可用，请重新发起对话");
        }
        String currentOwnerBootId = agentRuntimeRouteRegistry.currentNodeBootId(ownerNodeId);
        if (ownerRoute.getBootId() == null || !ownerRoute.getBootId().equals(currentOwnerBootId)) {
            throw new BusinessException("Agent 运行节点不可用，请重新发起对话");
        }
        if (action == AgentRuntimeCommandAction.APPROVE) {
            if (approvalId == null || approvalId.isBlank()) {
                agentRuntimeCommandProducer.sendApprove(
                        ownerNodeId, resolvedRequestId, resumeToken, operatorId, userId);
            } else {
                agentRuntimeCommandProducer.sendApprove(
                        ownerNodeId, resolvedRequestId, resumeToken, approvalId, operatorId, userId);
            }
            return;
        }
        if (action == AgentRuntimeCommandAction.REJECT) {
            if (approvalId == null || approvalId.isBlank()) {
                agentRuntimeCommandProducer.sendReject(
                        ownerNodeId, resolvedRequestId, resumeToken, reason, operatorId, userId);
            } else {
                agentRuntimeCommandProducer.sendReject(
                        ownerNodeId, resolvedRequestId, resumeToken, approvalId, reason, operatorId, userId);
            }
            return;
        }
        if (action == AgentRuntimeCommandAction.EXPIRE) {
            agentRuntimeCommandProducer.sendExpire(ownerNodeId, resolvedRequestId, resumeToken, reason);
            return;
        }
        throw new BusinessException("不支持的 Agent 运行命令");
    }

    private String resolveRequestIdForRemoteDispatch(String requestId, String resumeToken) {
        if (requestId != null && !requestId.isBlank()) {
            return requestId;
        }
        String resolvedRequestId = agentRuntimeRouteRegistry.findRequestIdByResumeToken(resumeToken);
        if (resolvedRequestId == null || resolvedRequestId.isBlank()) {
            throw new BusinessException("Agent 运行节点不可用，请重新发起对话");
        }
        return resolvedRequestId;
    }

    /**
     * 在 Agent 生命周期锁内启动正式运行，避免与下线或删除并发穿透。
     *
     * @param agent Agent 运行视图
     * @param userMessage 用户消息
     * @param documentContext 文档上下文
     * @param account 当前账号
     * @param requestId 请求 ID
     * @param traceId 链路 ID
     * @param runtimeSessionId 运行会话 ID
     * @param assistantCode 助手类型
     * @param chatContext 聊天上下文
     * @param runOutput SSE 发射器
     * @param persistChatlog 是否持久化聊天日志
     * @param runtimeSessionStore 运行会话存储
     * @param initialLockHandle 会话运行锁
     */
    private void startRuntime(Agent agent,
                              AgentMessage userMessage,
                              AgentDocumentContext documentContext,
                              LoginAccount account,
                              String requestId,
                              String traceId,
                              String runtimeSessionId,
                              String assistantCode,
                              ChatRuntimeContext chatContext,
                              AgentRunOutput runOutput,
                              boolean persistChatlog,
                              AgentSessionStore runtimeSessionStore,
                              AgentRunLock.Handle initialLockHandle) {
        if (!persistChatlog || agent == null || agent.getId() == null) {
            startRuntimeLocked(
                    agent, userMessage, documentContext, account, requestId, traceId,
                    runtimeSessionId, assistantCode, chatContext, runOutput,
                    persistChatlog, runtimeSessionStore, initialLockHandle
            );
            return;
        }
        try {
            agentBindingLockExecutor.execute(agent.getId(), () -> {
                agentRunStartGuard.assertRunnable(agent.getId());
                startRuntimeLocked(
                        agent, userMessage, documentContext, account, requestId, traceId,
                        runtimeSessionId, assistantCode, chatContext, runOutput,
                        true, runtimeSessionStore, initialLockHandle
                );
                return null;
            });
        } catch (Exception exception) {
            AgentRunRegistry.AgentRunContext runContext = agentRunRegistry.get(requestId);
            if (runContext != null) {
                try {
                    runContext.cancel();
                } catch (RuntimeException cancelException) {
                    LOG.warn("Cancel Agent runtime after startup failure failed, requestId={}",
                            requestId, cancelException);
                }
                try {
                    agentRunRegistry.remove(requestId);
                } catch (RuntimeException removeException) {
                    LOG.warn("Remove Agent runtime after startup failure failed, requestId={}",
                            requestId, removeException);
                }
            } else if (initialLockHandle != null) {
                releaseRunLockQuietly(initialLockHandle, requestId);
            }
            handleRuntimeError(
                    exception,
                    requestId,
                    runOutput,
                    chatContext,
                    new AtomicBoolean(false),
                    true
            );
        }
    }

    /**
     * 初始化、注册并订阅单次 Agent 运行。
     *
     * @param agent Agent 运行视图
     * @param userMessage 用户消息
     * @param documentContext 文档上下文
     * @param account 当前账号
     * @param requestId 请求 ID
     * @param traceId 链路 ID
     * @param runtimeSessionId 运行会话 ID
     * @param assistantCode 助手类型
     * @param chatContext 聊天上下文
     * @param runOutput SSE 发射器
     * @param persistChatlog 是否持久化聊天日志
     * @param runtimeSessionStore 运行会话存储
     * @param initialLockHandle 会话运行锁
     */
    private void startRuntimeLocked(Agent agent,
                                    AgentMessage userMessage,
                                    AgentDocumentContext documentContext,
                                    LoginAccount account,
                                    String requestId,
                                    String traceId,
                                    String runtimeSessionId,
                                    String assistantCode,
                                    ChatRuntimeContext chatContext,
                                    AgentRunOutput runOutput,
                                    boolean persistChatlog,
                                    AgentSessionStore runtimeSessionStore,
                                    AgentRunLock.Handle initialLockHandle) {
        AtomicBoolean finished = new AtomicBoolean(false);
        StringBuilder answer = new StringBuilder();
        ChatAssistantAccumulator assistantAccumulator = new ChatAssistantAccumulator();
        LegacyThinkingTagParser legacyThinkingTagParser = new LegacyThinkingTagParser();
        // 注册 emit 服务
        registerEmitterCancellation(requestId, runOutput, chatContext, answer,
                assistantAccumulator, legacyThinkingTagParser, finished, persistChatlog);
        AgentRunLock.Handle lockHandle = initialLockHandle;
        try {
            if (isAguiCancellationRequested(runOutput)) {
                handleRuntimeEvent(cancellationEvent("用户已停止生成"), requestId, runOutput,
                        answer, assistantAccumulator, legacyThinkingTagParser,
                        chatContext, finished, persistChatlog);
                if (lockHandle != null) {
                    releaseRunLockQuietly(lockHandle, requestId);
                }
                return;
            }
            if (persistChatlog) {
                bindAgentSession(agent, runtimeSessionId, chatContext);
            }
            AgentRuntimeContext runtimeContext = buildAgentRuntimeContext(chatContext, traceId, runtimeSessionId);
            AgentRuntimeBundle bundle = agentRuntimeCompiler.compile(agent, runtimeContext, !persistChatlog);
            AgentRuntime runtime = agentRuntimeFactory.create();
            // 会话初始化请求
            AgentInitRequest request = new AgentInitRequest();
            request.setSessionId(runtimeSessionId);
            request.setAgentDefinition(bundle.getDefinition());
            request.setRuntimeContext(runtimeContext);
            request.setToolInvokers(bundle.getToolInvokers());
            request.setKnowledgeRetrievers(bundle.getKnowledgeRetrievers());
            request.setSessionStore(runtimeSessionStore);
            request.setMediaResolver(agentMediaService.runtimeResolver(account));
            request.getMetadata().put("assistantCode", assistantCode);
            runtime.init(request);
            // 注册会话运行时管理
            AgentRunRegistry.RunOwner owner = new AgentRunRegistry.RunOwner(
                    agent.getId() == null ? null : agent.getId().toString(),
                    runtimeSessionId,
                    chatContext.getUserId() == null ? null : chatContext.getUserId().toString()
            );
            Object runtimeEventLock = new Object();
            AgentRunRegistry.AgentRunContext runContext = new AgentRunRegistry.AgentRunContext(
                    requestId,
                    runtimeSessionId,
                    runtime,
                    runOutput,
                    chatContext,
                    answer,
                    assistantAccumulator,
                    finished,
                    persistChatlog,
                    owner,
                    lockHandle,
                    event -> {
                        synchronized (runtimeEventLock) {
                            runRuntimeCallbackSafely(
                                    () -> handleRuntimeEvent(event, requestId, runOutput, answer,
                                            assistantAccumulator, legacyThinkingTagParser,
                                            chatContext, finished, persistChatlog),
                                    requestId, runOutput, chatContext, finished, persistChatlog);
                        }
                    },
                    error -> {
                        synchronized (runtimeEventLock) {
                            runRuntimeCallbackSafely(
                                    () -> handleRuntimeStreamError(error, requestId, runOutput, chatContext, answer,
                                            assistantAccumulator, legacyThinkingTagParser,
                                            finished, persistChatlog),
                                    requestId, runOutput, chatContext, finished, persistChatlog);
                        }
                    },
                    () -> {
                        synchronized (runtimeEventLock) {
                            runRuntimeCallbackSafely(
                                    () -> finishRuntimeStream(requestId, runOutput, chatContext, answer,
                                            assistantAccumulator, legacyThinkingTagParser,
                                            finished, persistChatlog),
                                    requestId, runOutput, chatContext, finished, persistChatlog);
                        }
                    }
            );
            agentRunRegistry.register(runContext);
            lockHandle = null;
            if (isAguiCancellationRequested(runOutput)) {
                runContext.eventConsumer().accept(cancellationEvent("用户已停止生成"));
                return;
            }
            if (finished.get()) {
                runContext.cancel();
                agentRunRegistry.remove(requestId);
                return;
            }
            agentRunRegistry.bindSubscription(requestId,
                    runtime.stream(userMessage).subscribe(
                            runContext.eventConsumer(),
                            runContext.errorConsumer(),
                            runContext.completionHandler()
                    ));
        } catch (Exception e) {
            AgentRunRegistry.AgentRunContext runContext = agentRunRegistry.get(requestId);
            if (runContext != null) {
                try {
                    runContext.cancel();
                } catch (RuntimeException cancelException) {
                    LOG.warn("Cancel Agent runtime after initialization failure failed, requestId={}",
                            requestId, cancelException);
                }
            }
            try {
                agentRunRegistry.remove(requestId);
            } catch (RuntimeException removeException) {
                LOG.warn("Remove Agent runtime after initialization failure failed, requestId={}",
                        requestId, removeException);
            }
            if (lockHandle != null) {
                releaseRunLockQuietly(lockHandle, requestId);
            }
            handleRuntimeError(e, requestId, runOutput, chatContext, finished, persistChatlog);
        }
    }

    /**
     * 尽力释放会话运行锁，避免 Redis 故障遮蔽随后必须执行的终态与资源清理。
     *
     * @param lockHandle 会话锁句柄
     * @param requestId 运行请求 ID
     */
    private void releaseRunLockQuietly(AgentRunLock.Handle lockHandle, String requestId) {
        if (lockHandle == null) {
            return;
        }
        try {
            lockHandle.release();
        } catch (RuntimeException exception) {
            LOG.warn("Release Agent run lock failed, requestId={}", requestId, exception);
        }
    }

    /**
     * 执行序列化后的 Runtime 回调，并保证终态路径自身抛错时仍释放运行资源。
     *
     * @param callback Runtime 事件、错误或完成回调
     * @param requestId 运行请求 ID
     * @param runOutput 运行输出
     * @param chatContext 聊天上下文
     * @param finished 共享终态标记
     * @param persistChatlog 是否持久化聊天日志
     */
    private void runRuntimeCallbackSafely(Runnable callback,
                                          String requestId,
                                          AgentRunOutput runOutput,
                                          ChatRuntimeContext chatContext,
                                          AtomicBoolean finished,
                                          boolean persistChatlog) {
        try {
            callback.run();
        } catch (RuntimeException exception) {
            handleRuntimeCallbackFailure(exception, requestId, runOutput,
                    chatContext, finished, persistChatlog);
        }
    }

    /**
     * 强制收口 Runtime 回调内部故障，不再参与已经完成的终态 CAS。
     *
     * @param error 回调内部故障
     * @param requestId 运行请求 ID
     * @param runOutput 运行输出
     * @param chatContext 聊天上下文
     * @param finished 共享终态标记
     * @param persistChatlog 是否持久化聊天日志
     */
    private void handleRuntimeCallbackFailure(Throwable error,
                                              String requestId,
                                              AgentRunOutput runOutput,
                                              ChatRuntimeContext chatContext,
                                              AtomicBoolean finished,
                                              boolean persistChatlog) {
        finished.set(true);
        Throwable safeError = error == null
                ? new BusinessException("Agent 运行终态处理失败")
                : error;
        LOG.error("Agent runtime callback failed, requestId={}, message={}",
                requestId, safeErrorMessage(safeError), safeError);
        AgentRunRegistry.AgentRunContext runContext = agentRunRegistry.get(requestId);
        if (runContext != null) {
            try {
                runContext.cancel();
            } catch (RuntimeException cancelException) {
                LOG.warn("Cancel Agent runtime after callback failure failed, requestId={}",
                        requestId, cancelException);
            }
        }
        try {
            agentRunRegistry.remove(requestId);
        } catch (RuntimeException removeException) {
            if (runContext != null) {
                runContext.closeRuntime();
            }
            LOG.error("Remove Agent runtime after callback failure failed, requestId={}",
                    requestId, removeException);
        }
        try {
            cancelPending(requestId, safeErrorMessage(safeError), persistChatlog);
        } catch (RuntimeException pendingException) {
            LOG.error("Cancel pending approvals after runtime callback failure failed, requestId={}",
                    requestId, pendingException);
        }
        if (persistChatlog) {
            try {
                chatRuntimeManager.recordFailure(chatContext, safeError);
            } catch (RuntimeException recordException) {
                LOG.error("Record Agent runtime callback failure failed, requestId={}",
                        requestId, recordException);
            }
        }
        try {
            runOutput.completeWithError(safeError);
        } catch (RuntimeException outputException) {
            LOG.error("Complete Agent runtime output after callback failure failed, requestId={}",
                    requestId, outputException);
        }
    }

    private boolean isAguiCancellationRequested(AgentRunOutput runOutput) {
        return runOutput instanceof AguiAgentRunOutput aguiOutput
                && agentAguiRunStore.isCancellationRequested(aguiOutput.runId());
    }

    private AgentRuntimeEvent cancellationEvent(String reason) {
        AgentRuntimeEvent event = AgentRuntimeEvent.of(AgentRuntimeEventType.CANCELLED);
        event.getPayload().put("reason",
                reason == null || reason.isBlank() ? "用户已停止生成" : reason);
        return event;
    }

    /**
     * 取消当前节点上指定 Agent 的全部运行。
     *
     * @param agentId Agent ID
     */
    public void cancelAgentLocal(String agentId) {
        if (agentId == null || agentId.isBlank()) {
            throw new BusinessException("Agent ID 不能为空");
        }
        agentRunRegistry.cancelAgent(agentId);
    }

    private void bindAgentSession(Agent agent, String runtimeSessionId, ChatRuntimeContext chatContext) {
        if (easyFlowAgentSessionStore == null || runtimeSessionId == null || runtimeSessionId.isBlank()) {
            return;
        }
        easyFlowAgentSessionStore.bindSession(new EasyFlowAgentSessionStore.AgentSessionMetadata(
                runtimeSessionId,
                runtimeSessionId,
                agent == null ? null : agent.getId(),
                chatContext == null ? null : chatContext.getSessionId(),
                chatContext == null ? null : chatContext.getTenantId(),
                chatContext == null ? null : chatContext.getUserId()
        ));
    }

    private AgentRunLock.Handle acquireRunLock(Agent agent, String runtimeSessionId) {
        if (agentRunLock == null) {
            return null;
        }
        return agentRunLock.acquire(agent == null ? null : agent.getId(), runtimeSessionId);
    }

    private void recordRuntimeEvent(String requestId, ChatRuntimeContext chatContext, AgentRuntimeEvent event, boolean persistChatlog) {
        if (persistChatlog && agentRunEventRecorder != null) {
            agentRunEventRecorder.record(requestId, chatContext, event);
        }
    }

    private void recordApprovalRequired(String requestId, ChatRuntimeContext chatContext, AgentRuntimeEvent event, boolean persistChatlog) {
        if (persistChatlog && agentHitlPendingService != null) {
            agentHitlPendingService.recordApprovalRequired(requestId, chatContext, event);
        }
    }

    private void cancelPending(String requestId, String reason, boolean persistChatlog) {
        if (persistChatlog && agentHitlPendingService != null) {
            agentHitlPendingService.cancelByRequestId(requestId, reason);
        }
    }

    private void registerEmitterCancellation(String requestId,
                                             AgentRunOutput runOutput,
                                             ChatRuntimeContext chatContext,
                                             StringBuilder answer,
                                             ChatAssistantAccumulator assistantAccumulator,
                                             LegacyThinkingTagParser legacyThinkingTagParser,
                                             AtomicBoolean finished,
                                             boolean persistChatlog) {
        if (!runOutput.cancelRunOnDisconnect()) {
            return;
        }
        Runnable cancelTask = () -> cancelDisconnectedRun(requestId, chatContext, answer,
                assistantAccumulator, legacyThinkingTagParser, finished, persistChatlog);
        SseEmitter emitter = runOutput.emitter();
        emitter.onCompletion(cancelTask);
        emitter.onTimeout(cancelTask);
        emitter.onError(error -> cancelTask.run());
    }

    private void cancelDisconnectedRun(String requestId,
                                       ChatRuntimeContext chatContext,
                                       StringBuilder answer,
                                       ChatAssistantAccumulator assistantAccumulator,
                                       LegacyThinkingTagParser legacyThinkingTagParser,
                                       AtomicBoolean finished,
                                       boolean persistChatlog) {
        if (!finished.compareAndSet(false, true)) {
            return;
        }
        AgentRunRegistry.AgentRunContext runContext = agentRunRegistry.get(requestId);
        if (runContext != null) {
            try {
                runContext.cancel();
            } catch (Exception e) {
                LOG.warn("Cancel disconnected Agent run failed, requestId={}, message={}", requestId, e.getMessage(), e);
            }
        }
        agentRunRegistry.remove(requestId);
        cancelPending(requestId, "客户端连接已断开，Agent 运行已取消", persistChatlog);
        appendAssistantSegments(legacyThinkingTagParser.finish(), answer, assistantAccumulator);
        if (!persistChatlog) {
            return;
        }
        try {
            recordPartialAssistantIfPresent(chatContext, answer, assistantAccumulator, "客户端连接已断开，Agent 运行已取消");
            chatRuntimeManager.recordFailure(chatContext, new BusinessException("客户端连接已断开，Agent 运行已取消"));
        } catch (Exception e) {
            LOG.warn("Record disconnected Agent run failure failed, requestId={}, message={}", requestId, e.getMessage(), e);
        }
    }

    private void handleRuntimeEvent(AgentRuntimeEvent event,
                                    String requestId,
                                    AgentRunOutput runOutput,
                                    StringBuilder answer,
                                    ChatAssistantAccumulator assistantAccumulator,
                                    ChatRuntimeContext chatContext,
                                    AtomicBoolean finished,
                                    boolean persistChatlog) {
        handleRuntimeEvent(event, requestId, runOutput, answer, assistantAccumulator,
                new LegacyThinkingTagParser(), chatContext, finished, persistChatlog);
    }

    private void handleRuntimeEvent(AgentRuntimeEvent event,
                                    String requestId,
                                    AgentRunOutput runOutput,
                                    StringBuilder answer,
                                    ChatAssistantAccumulator assistantAccumulator,
                                    LegacyThinkingTagParser legacyThinkingTagParser,
                                    ChatRuntimeContext chatContext,
                                    AtomicBoolean finished,
                                    boolean persistChatlog) {
        if (event == null || event.getEventType() == null) {
            return;
        }
        boolean terminalEvent = isTerminalRuntimeEvent(event.getEventType());
        if (terminalEvent) {
            // 取消请求来自 HTTP 线程，可能与 Reactor 的完成/失败回调并发；终态必须先统一仲裁再投影。
            if (!finished.compareAndSet(false, true)) {
                return;
            }
        } else if (finished.get()) {
            return;
        }
        Map<String, Object> artifact = buildArtifactPublishedPayload(event);
        if (artifact != null) {
            Map<String, Object> persistedPayload = new LinkedHashMap<>();
            persistedPayload.put("artifactPublished", artifact);
            recordRuntimeEvent(requestId, chatContext, publicRuntimeEvent(event, persistedPayload), persistChatlog);
            assistantAccumulator.appendArtifact(artifact);
            Map<String, Object> statusPayload = new LinkedHashMap<>(artifact);
            statusPayload.put("statusKey", "artifact-published");
            if (!sendEnvelope(runOutput, ChatDomain.BUSINESS, ChatType.STATUS, statusPayload)) {
                cancelDisconnectedRun(requestId, chatContext, answer, assistantAccumulator,
                        legacyThinkingTagParser, finished, persistChatlog);
            }
            return;
        }
        Map<String, Object> skillStatus = isSkillInvocationEvent(event.getEventType())
                ? buildSkillInvocationStatusPayload(event, chatContext, requestId)
                : null;
        Map<String, Object> toolStatus = event.getEventType() == AgentRuntimeEventType.TOOL_CALL
                || event.getEventType() == AgentRuntimeEventType.TOOL_RESULT
                ? buildToolEventPayload(event)
                : null;
        Map<String, Object> asyncToolStatus = isAsyncToolEvent(event.getEventType())
                ? buildAsyncToolEventPayload(event)
                : null;
        AgentRuntimeEvent toolApprovalEvent = event.getEventType() == AgentRuntimeEventType.TOOL_APPROVAL_REQUIRED
                ? buildToolApprovalPublicEvent(event)
                : null;
        Map<String, Object> persistedStatus = skillStatus != null
                ? skillStatus : (toolStatus != null ? toolStatus : asyncToolStatus);
        if (event.getEventType() != AgentRuntimeEventType.SKILL_STEP
                && event.getEventType() != AgentRuntimeEventType.TOOL_APPROVAL_REQUIRED) {
            AgentRuntimeEvent persistedEvent = toolApprovalEvent != null
                    ? toolApprovalEvent
                    : (persistedStatus == null ? event : publicRuntimeEvent(event, persistedStatus));
            recordRuntimeEvent(requestId, chatContext, persistedEvent, persistChatlog);
        }
        if (!deferRuntimeOutput(event.getEventType())
                && !runOutput.emitRuntimeEvent(event)) {
            cancelDisconnectedRun(requestId, chatContext, answer, assistantAccumulator,
                    legacyThinkingTagParser, finished, persistChatlog);
            return;
        }
        if (event.getEventType() == AgentRuntimeEventType.REASONING_STARTED) {
            emitAssistantSegments(legacyThinkingTagParser.finish(), requestId, runOutput, chatContext,
                    answer, assistantAccumulator, legacyThinkingTagParser, finished, persistChatlog);
            legacyThinkingTagParser.reset();
            return;
        }
        if (event.getEventType() == AgentRuntimeEventType.MESSAGE_DELTA) {
            String text = stringPayload(event, "text");
            emitAssistantSegments(legacyThinkingTagParser.acceptContent(text), requestId, runOutput, chatContext,
                    answer, assistantAccumulator, legacyThinkingTagParser, finished, persistChatlog);
            return;
        }
        if (event.getEventType() == AgentRuntimeEventType.REASONING_DELTA) {
            String reasoning = firstText(stringPayload(event, "reasoning"), stringPayload(event, "text"));
            emitAssistantSegments(legacyThinkingTagParser.acceptReasoning(reasoning), requestId, runOutput, chatContext,
                    answer, assistantAccumulator, legacyThinkingTagParser, finished, persistChatlog);
            return;
        }
        if (isSkillInvocationEvent(event.getEventType())) {
            assistantAccumulator.appendSkillInvocationStatus(skillStatus);
            AgentRuntimeEvent publicEvent = publicRuntimeEvent(event, skillStatus);
            if (!runOutput.emitRuntimeEvent(publicEvent)) {
                cancelDisconnectedRun(requestId, chatContext, answer, assistantAccumulator,
                        legacyThinkingTagParser, finished, persistChatlog);
            }
            return;
        }
        if (event.getEventType() == AgentRuntimeEventType.SKILL_STEP) {
            // Skill 内部 Tool 继续走标准 Tool 事件；旁路步骤包含 input/path，不进入响应或持久化。
            return;
        }
        if (event.getEventType() == AgentRuntimeEventType.TOOL_APPROVAL_REQUIRED) {
            String resumeToken = stringPayload(event, "resumeToken");
            agentRunRegistry.registerResumeToken(requestId, resumeToken);
            String approvalId = agentRunRegistry.registerApproval(requestId, resumeToken);
            event.getMetadata().put("approvalId", approvalId);
            toolApprovalEvent.getMetadata().put("approvalId", approvalId);
            recordRuntimeEvent(requestId, chatContext, toolApprovalEvent, persistChatlog);
            recordApprovalRequired(requestId, chatContext, event, persistChatlog);
            if (!runOutput.emitRuntimeEvent(toolApprovalEvent)) {
                cancelDisconnectedRun(requestId, chatContext, answer, assistantAccumulator,
                        legacyThinkingTagParser, finished, persistChatlog);
                return;
            }
            if (!sendEnvelope(runOutput, ChatDomain.TOOL, ChatType.FORM_REQUEST, buildToolHitlPayload(requestId, event))) {
                cancelDisconnectedRun(requestId, chatContext, answer, assistantAccumulator,
                        legacyThinkingTagParser, finished, persistChatlog);
            }
            return;
        }
        if (isAsyncToolEvent(event.getEventType())) {
            if (!sendEnvelope(runOutput, ChatDomain.TOOL, asyncToolChatType(event), asyncToolStatus)) {
                cancelDisconnectedRun(requestId, chatContext, answer, assistantAccumulator,
                        legacyThinkingTagParser, finished, persistChatlog);
            }
            return;
        }
        if (event.getEventType() == AgentRuntimeEventType.TOOL_CALL) {
            if (!emitAssistantSegments(legacyThinkingTagParser.finish(), requestId, runOutput, chatContext,
                    answer, assistantAccumulator, legacyThinkingTagParser, finished, persistChatlog)) {
                return;
            }
            Map<String, Object> toolPayload = toolStatus;
            if (!runOutput.emitRuntimeEvent(publicRuntimeEvent(event, toolPayload))) {
                cancelDisconnectedRun(requestId, chatContext, answer, assistantAccumulator,
                        legacyThinkingTagParser, finished, persistChatlog);
                return;
            }
            LOG.info("Agent runtime tool call, requestId={}, toolCallId={}, toolName={}, status={}",
                    requestId, event.getToolCallId(), stringValue(toolPayload, "toolName"),
                    stringValue(toolPayload, "status"));
            assistantAccumulator.appendToolCall(
                    firstText(stringValue(toolPayload, "toolCallId"), event.getToolCallId()),
                    firstText(stringValue(toolPayload, "toolName"), stringValue(toolPayload, "name")),
                    stringValue(toolPayload, "toolDisplayName"),
                    null
            );
            if (!sendEnvelope(runOutput, ChatDomain.TOOL, ChatType.TOOL_CALL, toolPayload)) {
                cancelDisconnectedRun(requestId, chatContext, answer, assistantAccumulator,
                        legacyThinkingTagParser, finished, persistChatlog);
            }
            return;
        }
        if (event.getEventType() == AgentRuntimeEventType.TOOL_RESULT) {
            Map<String, Object> toolPayload = toolStatus;
            LOG.info("Agent runtime tool result, requestId={}, toolCallId={}, toolName={}, status={}",
                    requestId, event.getToolCallId(), stringValue(toolPayload, "toolName"),
                    stringValue(toolPayload, "status"));
            if (!runOutput.emitRuntimeEvent(publicRuntimeEvent(event, toolPayload))) {
                cancelDisconnectedRun(requestId, chatContext, answer, assistantAccumulator,
                        legacyThinkingTagParser, finished, persistChatlog);
                return;
            }
            assistantAccumulator.appendToolResult(
                    firstText(stringValue(toolPayload, "toolCallId"), event.getToolCallId()),
                    firstText(stringValue(toolPayload, "toolName"), stringValue(toolPayload, "name")),
                    stringValue(toolPayload, "toolDisplayName"),
                    null
            );
            if (!sendEnvelope(runOutput, ChatDomain.TOOL, ChatType.TOOL_RESULT, toolPayload)) {
                cancelDisconnectedRun(requestId, chatContext, answer, assistantAccumulator,
                        legacyThinkingTagParser, finished, persistChatlog);
                return;
            }
            legacyThinkingTagParser.reset();
            return;
        }
        if (event.getEventType() == AgentRuntimeEventType.KNOWLEDGE_RETRIEVAL) {
            LOG.info("Agent runtime knowledge retrieval, requestId={}, payload={}, metadata={}",
                    requestId, event.getPayload(), event.getMetadata());
            if (!sendEnvelope(runOutput, ChatDomain.BUSINESS, ChatType.STATUS, buildKnowledgeRetrievalStatusPayload(event))) {
                cancelDisconnectedRun(requestId, chatContext, answer, assistantAccumulator,
                        legacyThinkingTagParser, finished, persistChatlog);
            }
            return;
        }
        if (event.getEventType() == AgentRuntimeEventType.MEMORY_COMPRESSION_STARTED
                || event.getEventType() == AgentRuntimeEventType.MEMORY_COMPRESSION_COMPLETED) {
            LOG.info("Agent runtime memory compression, requestId={}, eventType={}, payload={}, metadata={}",
                    requestId, event.getEventType(), event.getPayload(), event.getMetadata());
            if (!sendEnvelope(runOutput, ChatDomain.BUSINESS, ChatType.STATUS,
                    buildMemoryCompressionStatusPayload(event))) {
                cancelDisconnectedRun(requestId, chatContext, answer, assistantAccumulator,
                        legacyThinkingTagParser, finished, persistChatlog);
            }
            return;
        }
        if (event.getEventType() == AgentRuntimeEventType.SUSPENDED) {
            LOG.info("Agent runtime suspended, requestId={}, payload={}, metadata={}",
                    requestId, event.getPayload(), event.getMetadata());
            if (!sendEnvelope(runOutput, ChatDomain.BUSINESS, ChatType.STATUS, buildSuspendedStatusPayload(event))) {
                cancelDisconnectedRun(requestId, chatContext, answer, assistantAccumulator,
                        legacyThinkingTagParser, finished, persistChatlog);
                return;
            }
            AgentRunRegistry.AgentRunContext runContext = agentRunRegistry.get(requestId);
            if (runContext != null) {
                runContext.markSuspended();
            }
            return;
        }
        if (event.getEventType() == AgentRuntimeEventType.COMPLETED) {
            emitAssistantSegments(legacyThinkingTagParser.finish(), requestId, runOutput, chatContext,
                    answer, assistantAccumulator, legacyThinkingTagParser, finished, persistChatlog);
            String finalText = stringPayload(event, "text");
            if (!legacyThinkingTagParser.isLegacyFormatDetected() && finalText != null && !finalText.isBlank()) {
                answer.setLength(0);
                answer.append(finalText);
            }
            List<Map<String, Object>> citations = buildKnowledgeCitationPayload(event);
            if (!citations.isEmpty()) {
                if (!sendEnvelope(runOutput, ChatDomain.BUSINESS, ChatType.CITATIONS, Map.of("items", citations))) {
                    LOG.debug("Skip completed-run citations after output closed, requestId={}", requestId);
                }
            }
            runOutput.emitRuntimeEvent(event);
            finishClaimedRun(requestId, runOutput, chatContext, answer,
                    assistantAccumulator, persistChatlog, citations);
            return;
        }
        if (event.getEventType() == AgentRuntimeEventType.CANCELLED) {
            emitAssistantSegments(legacyThinkingTagParser.finish(), requestId, runOutput, chatContext,
                    answer, assistantAccumulator, legacyThinkingTagParser, finished, persistChatlog);
            runOutput.emitRuntimeEvent(event);
            handleClaimedRuntimeCancelled(event, requestId, runOutput, chatContext, answer,
                    assistantAccumulator, persistChatlog);
            return;
        }
        if (event.getEventType() == AgentRuntimeEventType.FAILED) {
            emitAssistantSegments(legacyThinkingTagParser.finish(), requestId, runOutput, chatContext,
                    answer, assistantAccumulator, legacyThinkingTagParser, finished, persistChatlog);
            runOutput.emitRuntimeEvent(event);
            assistantAccumulator.finalizePendingSkillInvocations("FAILED", "技能调用失败");
            if (persistChatlog) {
                recordPartialAssistantIfPresent(chatContext, answer, assistantAccumulator, errorMessage(event));
            }
            handleClaimedRuntimeError(new BusinessException(errorMessage(event)), requestId,
                    runOutput, chatContext, persistChatlog);
        }
    }

    /**
     * 判断事件是否会收口一次运行。
     *
     * @param type 运行时事件类型
     * @return 完成、取消或失败事件返回 {@code true}
     */
    private boolean isTerminalRuntimeEvent(AgentRuntimeEventType type) {
        return type == AgentRuntimeEventType.COMPLETED
                || type == AgentRuntimeEventType.CANCELLED
                || type == AgentRuntimeEventType.FAILED;
    }

    private boolean deferRuntimeOutput(AgentRuntimeEventType type) {
        return type == AgentRuntimeEventType.MESSAGE_DELTA
                || type == AgentRuntimeEventType.REASONING_STARTED
                || type == AgentRuntimeEventType.REASONING_DELTA
                || type == AgentRuntimeEventType.REASONING_COMPLETED
                || type == AgentRuntimeEventType.TOOL_APPROVAL_REQUIRED
                || type == AgentRuntimeEventType.TOOL_CALL
                || type == AgentRuntimeEventType.TOOL_RESULT
                || type == AgentRuntimeEventType.COMPLETED
                || type == AgentRuntimeEventType.CANCELLED
                || type == AgentRuntimeEventType.FAILED
                || type == AgentRuntimeEventType.KNOWLEDGE_RETRIEVAL
                || type == AgentRuntimeEventType.MEMORY_COMPRESSION_STARTED
                || type == AgentRuntimeEventType.MEMORY_COMPRESSION_COMPLETED
                || isSkillInvocationEvent(type)
                || type == AgentRuntimeEventType.SKILL_STEP
                || isAsyncToolEvent(type);
    }

    private boolean isSkillInvocationEvent(AgentRuntimeEventType type) {
        return type == AgentRuntimeEventType.SKILL_CALL
                || type == AgentRuntimeEventType.SKILL_RESULT
                || type == AgentRuntimeEventType.SKILL_FAILED;
    }

    /**
     * 构建可公开、可持久化的 Skill 调用状态白名单载荷。
     *
     * @param event Runtime Skill 事件
     * @param chatContext 当前聊天上下文
     * @return 不含正文、路径和 Tool 输入的状态载荷
     */
    private Map<String, Object> buildSkillInvocationStatusPayload(
            AgentRuntimeEvent event, ChatRuntimeContext chatContext, String requestId) {
        Map<String, Object> source = event == null || event.getPayload() == null
                ? Map.of() : event.getPayload();
        String skillId = firstText(stringValue(source, "skillId"),
                event == null ? null : stringValue(event.getMetadata(), "skillId"));
        String skillName = firstText(stringValue(source, "skillName"),
                event == null ? null : stringValue(event.getMetadata(), "skillName"));
        String displayName = firstText(stringValue(source, "skillDisplayName"),
                event == null ? null : stringValue(event.getMetadata(), "skillDisplayName"),
                skillName, "技能");
        Object roundId = chatContext == null || chatContext.getExt() == null
                ? null : chatContext.getExt().get(ChatRuntimeExtKeys.CURRENT_ROUND_ID);
        String resolvedRoundId = roundId == null ? firstText(requestId, "draft") : String.valueOf(roundId);
        String status = event == null || event.getEventType() == AgentRuntimeEventType.SKILL_CALL
                ? "RUNNING"
                : event.getEventType() == AgentRuntimeEventType.SKILL_RESULT ? "SUCCESS" : "FAILED";
        Map<String, Object> result = new LinkedHashMap<>();
        result.put("statusKey", "skill-invocation:" + resolvedRoundId + ":" + firstText(skillId, skillName, "unknown"));
        result.put("status", status);
        result.put("skillId", skillId);
        result.put("skillName", skillName);
        result.put("skillDisplayName", displayName);
        result.put("toolCallId", event == null ? null : firstText(
                event.getToolCallId(), stringValue(source, "toolCallId")));
        if ("FAILED".equals(status)) {
            result.put("message", "技能调用失败");
        }
        return result;
    }

    /**
     * 将解析后的助手片段累计、持久化并发送到前端。
     *
     * @param segments 解析片段
     * @param requestId 运行请求 ID
     * @param runOutput SSE 发送器
     * @param chatContext 聊天上下文
     * @param answer 最终正文缓冲
     * @param assistantAccumulator 结构化消息缓冲
     * @param legacyThinkingTagParser 旧思考标签解析器
     * @param finished 完成标记
     * @param persistChatlog 是否持久化聊天记录
     * @return 全部片段发送成功时为 {@code true}
     */
    private boolean emitAssistantSegments(List<LegacyThinkingTagParser.Segment> segments,
                                          String requestId,
                                          AgentRunOutput runOutput,
                                          ChatRuntimeContext chatContext,
                                          StringBuilder answer,
                                          ChatAssistantAccumulator assistantAccumulator,
                                          LegacyThinkingTagParser legacyThinkingTagParser,
                                          AtomicBoolean finished,
                                          boolean persistChatlog) {
        for (LegacyThinkingTagParser.Segment segment : segments) {
            String text = segment.getText();
            ChatType chatType;
            Map<String, Object> payload = new LinkedHashMap<>();
            if (segment.getType() == LegacyThinkingTagParser.SegmentType.REASONING) {
                assistantAccumulator.appendReasoning(text);
                payload.put("reasoning", text);
                payload.put("delta", text);
                chatType = ChatType.THINKING;
            } else {
                answer.append(text);
                assistantAccumulator.appendContent(text);
                payload.put("delta", text);
                payload.put("role", "assistant");
                chatType = ChatType.MESSAGE;
                LOG.debug("Agent runtime message delta, requestId={}, deltaLength={}, answerLength={}, delta={}",
                        requestId, text.length(), answer.length(), toVisibleLogText(text));
            }
            if (!sendEnvelope(runOutput, ChatDomain.LLM, chatType, payload)) {
                cancelDisconnectedRun(requestId, chatContext, answer, assistantAccumulator,
                        legacyThinkingTagParser, finished, persistChatlog);
                return false;
            }
        }
        return true;
    }

    /**
     * 仅累计解析片段，用于连接已断开后的部分消息持久化。
     *
     * @param segments 解析片段
     * @param answer 最终正文缓冲
     * @param assistantAccumulator 结构化消息缓冲
     */
    private void appendAssistantSegments(List<LegacyThinkingTagParser.Segment> segments,
                                         StringBuilder answer,
                                         ChatAssistantAccumulator assistantAccumulator) {
        for (LegacyThinkingTagParser.Segment segment : segments) {
            if (segment.getType() == LegacyThinkingTagParser.SegmentType.REASONING) {
                assistantAccumulator.appendReasoning(segment.getText());
            } else {
                answer.append(segment.getText());
                assistantAccumulator.appendContent(segment.getText());
            }
        }
    }

    /**
     * 在运行时自然结束但未显式发出完成事件时收口兼容解析器。
     *
     * @param requestId 运行请求 ID
     * @param runOutput SSE 发送器
     * @param chatContext 聊天上下文
     * @param answer 最终正文缓冲
     * @param assistantAccumulator 结构化消息缓冲
     * @param legacyThinkingTagParser 旧思考标签解析器
     * @param finished 完成标记
     * @param persistChatlog 是否持久化聊天记录
     */
    private void finishRuntimeStream(String requestId,
                                     AgentRunOutput runOutput,
                                     ChatRuntimeContext chatContext,
                                     StringBuilder answer,
                                     ChatAssistantAccumulator assistantAccumulator,
                                     LegacyThinkingTagParser legacyThinkingTagParser,
                                     AtomicBoolean finished,
                                     boolean persistChatlog) {
        AgentRunRegistry.AgentRunContext runContext = agentRunRegistry.get(requestId);
        if (runContext != null && runContext.isSuspended()) {
            LOG.info("Agent runtime stream suspended, keep SSE and runtime active, requestId={}", requestId);
            return;
        }
        if (!runOutput.canFinishSuccessfully()) {
            handleRuntimeError(new BusinessException("Agent 事件流缺少完成事件"),
                    requestId, runOutput, chatContext, finished, persistChatlog);
            return;
        }
        if (!finished.compareAndSet(false, true)) {
            return;
        }
        emitAssistantSegments(legacyThinkingTagParser.finish(), requestId, runOutput, chatContext,
                answer, assistantAccumulator, legacyThinkingTagParser, finished, persistChatlog);
        finishClaimedRun(requestId, runOutput, chatContext, answer,
                assistantAccumulator, persistChatlog, List.of());
    }

    /**
     * 在运行时异常结束前发送兼容解析器中尚未收口的片段。
     *
     * @param error 运行异常
     * @param requestId 运行请求 ID
     * @param runOutput SSE 发送器
     * @param chatContext 聊天上下文
     * @param answer 最终正文缓冲
     * @param assistantAccumulator 结构化消息缓冲
     * @param legacyThinkingTagParser 旧思考标签解析器
     * @param finished 完成标记
     * @param persistChatlog 是否持久化聊天记录
     */
    private void handleRuntimeStreamError(Throwable error,
                                          String requestId,
                                          AgentRunOutput runOutput,
                                          ChatRuntimeContext chatContext,
                                          StringBuilder answer,
                                          ChatAssistantAccumulator assistantAccumulator,
                                          LegacyThinkingTagParser legacyThinkingTagParser,
                                          AtomicBoolean finished,
                                          boolean persistChatlog) {
        if (!finished.compareAndSet(false, true)) {
            return;
        }
        emitAssistantSegments(legacyThinkingTagParser.finish(), requestId, runOutput, chatContext,
                answer, assistantAccumulator, legacyThinkingTagParser, finished, persistChatlog);
        assistantAccumulator.finalizePendingSkillInvocations("FAILED", "技能调用失败");
        if (persistChatlog) {
            recordPartialAssistantIfPresent(chatContext, answer, assistantAccumulator, safeErrorMessage(error));
        }
        handleClaimedRuntimeError(error, requestId, runOutput, chatContext, persistChatlog);
    }

    private void finishIfNeeded(String requestId,
                                AgentRunOutput runOutput,
                                ChatRuntimeContext chatContext,
                                StringBuilder answer,
                                ChatAssistantAccumulator assistantAccumulator,
                                AtomicBoolean finished,
                                boolean persistChatlog) {
        finishIfNeeded(requestId, runOutput, chatContext, answer,
                assistantAccumulator, finished, persistChatlog, List.of());
    }

    private void finishIfNeeded(String requestId,
                                AgentRunOutput runOutput,
                                ChatRuntimeContext chatContext,
                                StringBuilder answer,
                                ChatAssistantAccumulator assistantAccumulator,
                                AtomicBoolean finished,
                                boolean persistChatlog,
                                List<Map<String, Object>> citations) {
        AgentRunRegistry.AgentRunContext runContext = agentRunRegistry.get(requestId);
        if (runContext != null && runContext.isSuspended()) {
            LOG.info("Agent runtime stream suspended, keep SSE and runtime active, requestId={}", requestId);
            return;
        }
        if (!runOutput.canFinishSuccessfully()) {
            handleRuntimeError(new BusinessException("Agent 事件流缺少完成事件"),
                    requestId, runOutput, chatContext, finished, persistChatlog);
            return;
        }
        if (!finished.compareAndSet(false, true)) {
            return;
        }
        finishClaimedRun(requestId, runOutput, chatContext, answer,
                assistantAccumulator, persistChatlog, citations);
    }

    /**
     * 完成已经赢得终态仲裁的运行。
     *
     * @param requestId 运行请求 ID
     * @param runOutput 运行输出
     * @param chatContext 聊天上下文
     * @param answer 最终正文
     * @param assistantAccumulator 助手结构化累计器
     * @param persistChatlog 是否持久化聊天记录
     * @param citations 知识引用
     */
    private void finishClaimedRun(String requestId,
                                  AgentRunOutput runOutput,
                                  ChatRuntimeContext chatContext,
                                  StringBuilder answer,
                                  ChatAssistantAccumulator assistantAccumulator,
                                  boolean persistChatlog,
                                  List<Map<String, Object>> citations) {
        agentRunRegistry.remove(requestId);
        String finalAnswer = answer.toString();
        LOG.info("Agent runtime final answer, requestId={}, sessionId={}, answerLength={}, answer={}",
                requestId, chatContext.getSessionId(), finalAnswer.length(), toVisibleLogText(finalAnswer));
        if (persistChatlog) {
            try {
                chatRuntimeManager.recordAssistantCompleted(chatContext,
                        buildAssistantRuntimeMessage(chatContext, finalAnswer, assistantAccumulator, citations));
            } catch (RuntimeException exception) {
                handleClaimedRuntimeError(exception, requestId, runOutput, chatContext, true);
                return;
            }
        }
        if (!sendDone(runOutput, finalAnswer)) {
            BusinessException outputFailure = new BusinessException("Agent 可恢复输出终态写入失败");
            cancelPending(requestId, outputFailure.getMessage(), persistChatlog);
            LOG.error("Agent run completion output failed, requestId={}, sessionId={}",
                    requestId, chatContext.getSessionId(), outputFailure);
            if (persistChatlog) {
                chatRuntimeManager.recordFailure(chatContext, outputFailure);
            }
            return;
        }
        if (persistChatlog) {
            chatRuntimeManager.recordCompleted(chatContext);
        }
    }

    private void handleRuntimeError(Throwable error,
                                    String requestId,
                                    AgentRunOutput runOutput,
                                    ChatRuntimeContext chatContext,
                                    AtomicBoolean finished,
                                    boolean persistChatlog) {
        if (!finished.compareAndSet(false, true)) {
            return;
        }
        handleClaimedRuntimeError(error, requestId, runOutput, chatContext, persistChatlog);
    }

    /**
     * 失败收口已经赢得终态仲裁的运行。
     *
     * @param error 运行异常
     * @param requestId 运行请求 ID
     * @param runOutput 运行输出
     * @param chatContext 聊天上下文
     * @param persistChatlog 是否持久化聊天记录
     */
    private void handleClaimedRuntimeError(Throwable error,
                                           String requestId,
                                           AgentRunOutput runOutput,
                                           ChatRuntimeContext chatContext,
                                           boolean persistChatlog) {
        agentRunRegistry.remove(requestId);
        cancelPending(requestId, safeErrorMessage(error), persistChatlog);
        Throwable safeError = error == null ? new BusinessException("Agent 运行失败") : error;
        LOG.error("Agent run failed, requestId={}, message={}, exception={}", requestId,
                safeError.getMessage(), safeError.toString(), safeError);
        if (persistChatlog) {
            chatRuntimeManager.recordFailure(chatContext, safeError);
        }
        Map<String, Object> payload = new LinkedHashMap<>();
        payload.put("message", safeError.getMessage() == null ? "Agent 运行失败" : safeError.getMessage());
        payload.put("code", "AGENT_RUN_FAILED");
        sendEnvelope(runOutput, ChatDomain.SYSTEM, ChatType.ERROR, payload);
        runOutput.complete();
    }

    private String safeErrorMessage(Throwable error) {
        if (error == null || error.getMessage() == null || error.getMessage().isBlank()) {
            return "Agent 运行失败";
        }
        return error.getMessage();
    }

    /**
     * 取消收口已经赢得终态仲裁的运行。
     *
     * @param event 取消事件
     * @param requestId 运行请求 ID
     * @param runOutput 运行输出
     * @param chatContext 聊天上下文
     * @param answer 已生成正文
     * @param assistantAccumulator 助手结构化累计器
     * @param persistChatlog 是否持久化聊天记录
     */
    private void handleClaimedRuntimeCancelled(AgentRuntimeEvent event,
                                               String requestId,
                                               AgentRunOutput runOutput,
                                               ChatRuntimeContext chatContext,
                                               StringBuilder answer,
                                               ChatAssistantAccumulator assistantAccumulator,
                                               boolean persistChatlog) {
        agentRunRegistry.remove(requestId);
        String reason = errorMessage(event);
        assistantAccumulator.finalizePendingSkillInvocations("CANCELLED", "技能调用已停止");
        cancelPending(requestId, reason, persistChatlog);
        LOG.info("Agent run cancelled, requestId={}, reason={}", requestId, reason);
        if (persistChatlog) {
            recordPartialAssistantIfPresent(chatContext, answer, assistantAccumulator, reason);
            chatRuntimeManager.recordFailure(chatContext, new BusinessException(reason));
        }
        Map<String, Object> payload = new LinkedHashMap<>();
        payload.put("statusKey", "agent-cancelled");
        payload.put("status", "cancelled");
        payload.put("label", "已取消");
        payload.put("message", reason);
        sendEnvelope(runOutput, ChatDomain.BUSINESS, ChatType.STATUS, payload);
        sendDone(runOutput);
    }

    /**
     * 保存 Agent 取消前已经生成的 assistant 内容。
     *
     * <p>用户主动中止或连接断开时，运行不会进入正常 COMPLETED 分支，但前端历史恢复仍需要看到
     * 中止前已经流出的模型输出。没有正文、reasoning 或工具链内容时不写空 assistant 消息。</p>
     *
     * @param context 聊天运行上下文
     * @param answer 已累计的 assistant 正文
     * @param assistantAccumulator assistant 结构化累计器
     * @param reason 取消原因
     */
    private void recordPartialAssistantIfPresent(ChatRuntimeContext context,
                                                StringBuilder answer,
                                                ChatAssistantAccumulator assistantAccumulator,
                                                String reason) {
        String partialAnswer = answer == null ? "" : answer.toString();
        if (partialAnswer.isBlank() && !hasAssistantPayload(assistantAccumulator)) {
            return;
        }
        ChatRuntimeMessage partialMessage =
                buildAssistantRuntimeMessage(context, partialAnswer, assistantAccumulator, List.of());
        partialMessage.getContentPayload().put("terminalStatus", "CANCELLED");
        partialMessage.getContentPayload().put("terminalMessage", reason);
        chatRuntimeManager.recordAssistantCompleted(context, partialMessage);
        LOG.info("Agent partial answer persisted after cancellation, sessionId={}, answerLength={}, reason={}",
                context == null ? null : context.getSessionId(), partialAnswer.length(), reason);
    }

    /**
     * 判断 assistant 累计器中是否存在可恢复的展示内容。
     *
     * @param assistantAccumulator assistant 结构化累计器
     * @return true 表示存在正文、思考过程或工具链内容
     */
    private boolean hasAssistantPayload(ChatAssistantAccumulator assistantAccumulator) {
        if (assistantAccumulator == null) {
            return false;
        }
        Map<String, Object> payload = assistantAccumulator.buildPayload("");
        if (payload == null || payload.isEmpty()) {
            return false;
        }
        return payload.values().stream().anyMatch(value -> {
            if (value instanceof List<?> list) {
                return !list.isEmpty();
            }
            if (value instanceof Map<?, ?> map) {
                return !map.isEmpty();
            }
            return value != null && !String.valueOf(value).isBlank();
        });
    }

    private ChatRuntimeContext buildChatRuntimeContext(Agent agent, BigInteger sessionId, String prompt, LoginAccount account) {
        return buildChatRuntimeContext(agent, sessionId, prompt, account, ASSISTANT_CODE);
    }

    /**
     * 为正式 Agent 聊天设置会话默认标题。
     *
     * <p>正式聊天只在新会话首轮使用用户输入作为默认标题，后续轮次不再自动覆盖，避免用户已重命名的标题被后续消息改写。</p>
     *
     * @param context 聊天运行上下文
     * @param prompt 当前用户输入
     * @param existingSession 已存在的会话摘要
     */
    private void applyFormalSessionTitle(ChatRuntimeContext context, String prompt, ChatSessionSummary existingSession) {
        if (context == null) {
            return;
        }
        if (existingSession != null) {
            context.setSessionTitle(null);
            return;
        }
        context.setSessionTitle(toSessionTitle(prompt));
    }

    private ChatRuntimeContext buildChatRuntimeContext(Agent agent,
                                                       BigInteger sessionId,
                                                       String prompt,
                                                       LoginAccount account,
                                                       String assistantCode) {
        return buildChatRuntimeContext(
                agent, sessionId, prompt, account, assistantCode, ChatChannel.ADMIN, false);
    }

    /**
     * 构建指定渠道的 Agent 聊天上下文。
     *
     * @param agent Agent 运行视图
     * @param sessionId 会话 ID
     * @param prompt 用户输入
     * @param account 调用身份
     * @param assistantCode 助手类型编码
     * @param channel 调用渠道
     * @param anonymous 是否匿名调用
     * @return 聊天运行上下文
     */
    private ChatRuntimeContext buildChatRuntimeContext(Agent agent,
                                                       BigInteger sessionId,
                                                       String prompt,
                                                       LoginAccount account,
                                                       String assistantCode,
                                                       ChatChannel channel,
                                                       boolean anonymous) {
        ChatRuntimeContext context = new ChatRuntimeContext();
        context.setChannel(channel);
        context.setSessionId(sessionId);
        context.setTenantId(account.getTenantId());
        context.setDeptId(account.getDeptId());
        context.setUserId(account.getId());
        context.setUserAccount(account.getLoginName());
        context.setUserName(account.getNickname() == null || account.getNickname().isBlank() ? account.getLoginName() : account.getNickname());
        context.setAssistantId(agent.getId());
        context.setAssistantCode(assistantCode);
        context.setAssistantName(agent.getName());
        context.setSessionTitle(toSessionTitle(prompt));
        context.setAnonymous(anonymous);
        return context;
    }

    /**
     * 将用户输入裁剪为会话标题。
     *
     * @param prompt 用户输入内容
     * @return 最长 200 字符的会话标题
     */
    private String toSessionTitle(String prompt) {
        if (prompt == null || prompt.isBlank()) {
            return "图片对话";
        }
        return prompt.length() > 200 ? prompt.substring(0, 200) : prompt;
    }

    private AgentRuntimeContext buildAgentRuntimeContext(ChatRuntimeContext chatContext, String traceId, String sessionId) {
        AgentRuntimeContext context = new AgentRuntimeContext();
        context.setTenantId(chatContext.getTenantId() == null ? null : chatContext.getTenantId().toString());
        context.setUserId(chatContext.getUserId() == null ? null : chatContext.getUserId().toString());
        context.setUserName(chatContext.getUserName());
        context.setSessionId(sessionId);
        context.setTraceId(traceId);
        Object roundId = chatContext.getExt().get(ChatRuntimeExtKeys.CURRENT_ROUND_ID);
        if (roundId != null) {
            context.getMetadata().put(ChatRuntimeExtKeys.CURRENT_ROUND_ID, String.valueOf(roundId));
        }
        Object variantIndex = chatContext.getExt().get(ChatRuntimeExtKeys.CURRENT_VARIANT_INDEX);
        if (variantIndex != null) {
            context.getMetadata().put(ChatRuntimeExtKeys.CURRENT_VARIANT_INDEX, variantIndex);
        }
        return context;
    }

    private ChatRuntimeMessage buildUserRuntimeMessage(ChatRuntimeContext context,
                                                       BigInteger messageId,
                                                       String prompt,
                                                       List<AgentBoundMedia> media,
                                                       List<AgentBoundDocument> documents) {
        ChatRuntimeMessage message = new ChatRuntimeMessage();
        message.setMessageId(messageId);
        message.setRole("user");
        boolean hasAttachments = media != null && !media.isEmpty()
                || documents != null && !documents.isEmpty();
        message.setContentType(hasAttachments ? "MULTIMODAL" : "TEXT");
        message.setContentText(prompt);
        if (media != null && !media.isEmpty()) {
            message.getContentPayload().put("images", media.stream().map(AgentBoundMedia::payload).toList());
        }
        if (documents != null && !documents.isEmpty()) {
            message.getContentPayload().put("attachments",
                    documents.stream().map(AgentBoundDocument::payload).toList());
        }
        message.setCreatedAt(new Date());
        message.setSenderId(context.getUserId());
        message.setSenderName(context.getUserName());
        return message;
    }

    /**
     * 构建发送给 AgentScope 的用户消息。
     *
     * <p>文档正文属于用户提供的不可信材料，作为用户内容块进入本轮模型调用和 AgentScope
     * memory。聊天记录仍单独保存原始输入与附件引用，页面不会展示正文内容块。</p>
     *
     * @param prompt 用户输入
     * @param media 图片附件
     * @param documentContext 本轮选中的文档上下文
     * @return 可持久化的运行时用户消息
     */
    private AgentMessage buildAgentMessage(String prompt,
                                           List<AgentBoundMedia> media,
                                           AgentDocumentContext documentContext) {
        AgentMessage message = new AgentMessage();
        message.setRole(AgentMessageRole.USER);
        List<com.easyagents.agent.runtime.message.AgentContentBlock> blocks = new ArrayList<>();
        if (prompt != null && !prompt.isBlank()) {
            blocks.add(new AgentTextBlock(prompt));
        }
        if (documentContext != null && documentContext.text() != null
                && !documentContext.text().isBlank()) {
            blocks.add(new AgentTextBlock(documentContext.text()));
        }
        if (media != null) {
            for (AgentBoundMedia item : media) {
                AgentMediaBlock image = new AgentMediaBlock("image");
                image.setReference(item.reference());
                image.setMimeType(item.mimeType());
                blocks.add(image);
            }
        }
        message.setContentBlocks(blocks);
        return message;
    }

    private ChatRuntimeMessage buildAssistantRuntimeMessage(ChatRuntimeContext context, String content) {
        return buildAssistantRuntimeMessage(context, content, new ChatAssistantAccumulator(), List.of());
    }

    private ChatRuntimeMessage buildAssistantRuntimeMessage(ChatRuntimeContext context,
                                                            String content,
                                                            ChatAssistantAccumulator assistantAccumulator,
                                                            List<Map<String, Object>> citations) {
        ChatRuntimeMessage message = new ChatRuntimeMessage();
        message.setRole("assistant");
        message.setContentType("TEXT");
        message.setContentText(content);
        Map<String, Object> contentPayload = assistantAccumulator == null
                ? new LinkedHashMap<>()
                : assistantAccumulator.buildPayload(content);
        if (citations != null && !citations.isEmpty()) {
            contentPayload.put("knowledgeCitations", citations);
        }
        Object documentCitations = context == null ? null
                : context.getExt().get(DOCUMENT_CITATIONS_EXT_KEY);
        if (documentCitations instanceof List<?> documentCitationList
                && !documentCitationList.isEmpty()) {
            contentPayload.put("documentCitations", documentCitationList);
        }
        Map<String, Object> agentResult = new LinkedHashMap<>();
        agentResult.put("text", content);
        agentResult.put("reasoning", contentPayload.get("reasoningContent"));
        agentResult.put("knowledgeReferences", citations == null ? List.of() : citations);
        agentResult.put("documentReferences",
                documentCitations instanceof List<?> list ? list : List.of());
        contentPayload.put("agentResult", agentResult);
        message.setContentPayload(contentPayload);
        message.setCreatedAt(new Date());
        message.setSenderId(context.getAssistantId());
        message.setSenderName(context.getAssistantName());
        return message;
    }

    private boolean sendEnvelope(AgentRunOutput runOutput, ChatDomain domain, ChatType type, Object payload) {
        return runOutput.emitViewEvent(domain, type, payload);
    }

    /**
     * 发送不携带最终正文的完成事件。
     *
     * @param runOutput SSE 发送器
     * @return 发送成功时为 {@code true}
     */
    private boolean sendDone(AgentRunOutput runOutput) {
        return sendDone(runOutput, null);
    }

    /**
     * 发送完成事件，并提供最终正文供前端校正流式增量。
     *
     * @param runOutput SSE 发送器
     * @param finalText 最终完整正文；取消场景可为空
     * @return 发送成功时为 {@code true}
     */
    private boolean sendDone(AgentRunOutput runOutput, String finalText) {
        return runOutput.finish(finalText);
    }

    private boolean sendSessionCreated(AgentRunOutput runOutput, BigInteger sessionId) {
        if (sessionId == null) {
            return true;
        }
        return sendEnvelope(runOutput, ChatDomain.SYSTEM, ChatType.SESSION_CREATED,
                Map.of("sessionId", sessionId.toString()));
    }

    private boolean sendInputAccepted(AgentRunOutput runOutput,
                                      BigInteger sessionId,
                                      BigInteger messageId,
                                      List<AgentBoundMedia> boundMedia,
                                      List<AgentBoundDocument> boundDocuments) {
        Map<String, Object> payload = new LinkedHashMap<>();
        if (sessionId != null) {
            payload.put("sessionId", sessionId.toString());
        }
        if (messageId != null) {
            payload.put("messageId", messageId.toString());
        }
        if (boundMedia != null && !boundMedia.isEmpty()) {
            payload.put("images", boundMedia.stream().map(AgentBoundMedia::payload).toList());
        }
        if (boundDocuments != null && !boundDocuments.isEmpty()) {
            payload.put("attachments",
                    boundDocuments.stream().map(AgentBoundDocument::payload).toList());
        }
        return sendEnvelope(runOutput, ChatDomain.SYSTEM, ChatType.INPUT_ACCEPTED, payload);
    }

    private void validateChatRequest(AgentChatRequest request) {
        if (request == null || request.getAgentId() == null) {
            throw new BusinessException("Agent ID 不能为空");
        }
        if ((request.getPrompt() == null || request.getPrompt().isBlank())
                && (request.getImageUploadIds() == null || request.getImageUploadIds().isEmpty())
                && (request.getDocumentUploadIds() == null || request.getDocumentUploadIds().isEmpty())) {
            throw new BusinessException("Agent 输入不能为空");
        }
    }

    private void validateDraftChatRequest(AgentDraftChatRequest request) {
        if (request == null || request.getAgent() == null) {
            throw new BusinessException("Agent 草稿不能为空");
        }
        if (request.getAgent().getModelId() == null) {
            throw new BusinessException("Agent 模型不能为空");
        }
        if ((request.getPrompt() == null || request.getPrompt().isBlank())
                && (request.getImageUploadIds() == null || request.getImageUploadIds().isEmpty())
                && (request.getDocumentUploadIds() == null || request.getDocumentUploadIds().isEmpty())) {
            throw new BusinessException("Agent 输入不能为空");
        }
    }

    private void assertImageCapability(Agent agent, List<String> imageUploadIds) {
        if (imageUploadIds == null || imageUploadIds.isEmpty()) {
            return;
        }
        AgentModelCapabilityResolver.Resolution capabilities =
                AgentModelCapabilityResolver.resolve(agent, () ->
                        agent == null || agent.getModelId() == null
                                ? null : modelService.getModelInstance(agent.getModelId()));
        if (!capabilities.supportImage()) {
            throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "当前 Agent 模型未启用多模态图片能力");
        }
    }

    /**
     * 获取当前登录账号。
     *
     * @return 当前登录账号
     * @throws BusinessException 登录信息失效时抛出
     */
    private LoginAccount requireCurrentLoginAccount() {
        try {
            LoginAccount account = SaTokenUtil.getLoginAccount();
            if (account == null || account.getId() == null || account.getTenantId() == null) {
                throw new BusinessException("当前登录状态失效，请重新登录后再试");
            }
            return account;
        } catch (Exception e) {
            if (e instanceof BusinessException businessException) {
                throw businessException;
            }
            throw new BusinessException("当前登录状态失效，请重新登录后再试");
        }
    }

    /**
     * 解析并校验当前用户可继续的 Agent 会话。
     *
     * <p>前端可能先生成稳定 sessionId 再发送首条消息。若该 ID 尚未落库，视为新会话并返回 null；若已落库，则校验归属与 Agent 绑定。</p>
     *
     * @param account 当前登录账号
     * @param sessionId 会话 ID
     * @param agentId Agent ID
     * @return 已存在的会话摘要；不存在时返回 null
     */
    private ChatSessionSummary resolveExistingSession(LoginAccount account, BigInteger sessionId, BigInteger agentId) {
        if (sessionId == null) {
            return null;
        }
        ChatSessionSummary summary = chatSessionQueryService.getSessionSummary(sessionId);
        if (summary == null) {
            return null;
        }
        if (Integer.valueOf(1).equals(summary.getIsDeleted())) {
            throw new BusinessException("Agent 会话不存在或已删除");
        }
        if (!ASSISTANT_CODE.equals(summary.getAssistantCode())) {
            throw new BusinessException("当前会话不是 Agent 会话");
        }
        if (!Objects.equals(summary.getUserId(), account.getId())) {
            throw new BusinessException("无权访问该 Agent 会话");
        }
        if (agentId != null && summary.getAssistantId() != null && !Objects.equals(summary.getAssistantId(), agentId)) {
            throw new BusinessException("当前会话与所选 Agent 不匹配");
        }
        return summary;
    }

    private void assertAgentRunnable(Agent agent) {
        if (!Integer.valueOf(1).equals(agent.getStatus())
                || PublishStatus.from(agent.getPublishStatus()) != PublishStatus.PUBLISHED) {
            throw new BusinessException("当前 Agent 已下线或不可继续会话");
        }
    }

    private Agent buildDraftAgent(AgentDraftChatRequest request, LoginAccount account) {
        Agent incoming = request.getAgent();
        Agent agent = new Agent();
        agent.setId(incoming.getId());
        agent.setTenantId(account.getTenantId());
        agent.setDeptId(account.getDeptId());
        agent.setCreatedBy(account.getId());
        agent.setName(incoming.getName() == null || incoming.getName().isBlank() ? "草稿 Agent" : incoming.getName());
        agent.setDescription(incoming.getDescription());
        agent.setAvatar(incoming.getAvatar());
        agent.setCategoryId(incoming.getCategoryId());
        agent.setModelId(incoming.getModelId());
        agent.setModelConfigJson(incoming.getModelConfigJson());
        agent.setGenerationConfigJson(incoming.getGenerationConfigJson());
        agent.setPromptConfigJson(incoming.getPromptConfigJson());
        agent.setMemoryConfigJson(incoming.getMemoryConfigJson());
        agent.setExecutionConfigJson(incoming.getExecutionConfigJson());
        if (agentBuiltinToolsConfigResolver != null) {
            agent.setExecutionConfigJson(agentBuiltinToolsConfigResolver.normalizeForDraftSave(
                    agent.getExecutionConfigJson(), existingDraftExecutionConfig(incoming), account));
        }
        agent.setStatus(incoming.getStatus() == null ? 1 : incoming.getStatus());
        agent.setVisibilityScope(incoming.getVisibilityScope());
        agent.setPublishStatus(incoming.getPublishStatus());
        if (agent.getId() == null) {
            agent.setId(BigInteger.valueOf(new SnowFlakeIDKeyGenerator().nextId()));
        } else {
            Agent liveAgent = agentService.getById(agent.getId());
            if (liveAgent == null) {
                throw new BusinessException("Agent 不存在");
            }
            resourceAccessService.assertAccess(CategoryResourceType.AGENT, liveAgent, ResourceAction.MANAGE, "无权限试用该 Agent 草稿");
        }
        agent.setToolBindings(copyDraftToolBindings(request.getToolBindings(), agent, account));
        agent.setKnowledgeBindings(copyDraftKnowledgeBindings(request.getKnowledgeBindings(), agent, account));
        List<AgentSkillBinding> skillBindings = copyDraftSkillBindings(request.getSkillBindings(), agent, account);
        agent.setSkillBindings(agentSkillRuntimeProjector.projectCurrentBindings(agent, skillBindings));
        return agent;
    }

    /**
     * 读取已有草稿配置，用于判断本次请求是否真正关闭 Shell 审批。
     *
     * @param incoming 客户端草稿 Agent
     * @return 已有草稿执行配置；新 Agent 返回 null
     */
    private Map<String, Object> existingDraftExecutionConfig(Agent incoming) {
        if (incoming == null || incoming.getId() == null) {
            return null;
        }
        Agent existing = agentService.getById(incoming.getId());
        return existing == null ? null : existing.getExecutionConfigJson();
    }

    /**
     * 复制草稿试用请求中的 Skill 引用，忽略客户端伪造的正文和快照。
     *
     * @param bindings Skill 绑定请求
     * @param agent 草稿 Agent
     * @param account 当前账号
     * @return 安全 Skill 引用
     */
    private List<AgentSkillBinding> copyDraftSkillBindings(List<AgentSkillBinding> bindings,
                                                           Agent agent,
                                                           LoginAccount account) {
        List<AgentSkillBinding> result = new ArrayList<>();
        if (bindings == null || bindings.isEmpty()) {
            return result;
        }
        for (int index = 0; index < bindings.size(); index++) {
            AgentSkillBinding source = bindings.get(index);
            if (source == null || source.getSkillId() == null) {
                throw new BusinessException("Agent Skill 绑定参数不完整");
            }
            AgentSkillBinding binding = new AgentSkillBinding();
            binding.setTenantId(account.getTenantId());
            binding.setAgentId(agent.getId());
            binding.setSkillId(source.getSkillId());
            binding.setSortNo(index);
            binding.setCreatedBy(account.getId());
            binding.setModifiedBy(account.getId());
            result.add(binding);
        }
        return result;
    }

    private List<AgentToolBinding> copyDraftToolBindings(List<AgentToolBinding> bindings, Agent agent, LoginAccount account) {
        List<AgentToolBinding> result = new ArrayList<>();
        if (bindings == null || bindings.isEmpty()) {
            return result;
        }
        for (int i = 0; i < bindings.size(); i++) {
            AgentToolBinding incoming = bindings.get(i);
            if (incoming == null) {
                continue;
            }
            AgentToolBinding binding = new AgentToolBinding();
            binding.setId(incoming.getId());
            binding.setTenantId(account.getTenantId());
            binding.setAgentId(agent.getId());
            binding.setToolType(incoming.getToolType());
            binding.setTargetId(incoming.getTargetId());
            binding.setToolName(incoming.getToolName());
            binding.setEnabled(incoming.getEnabled() == null || incoming.getEnabled());
            binding.setHitlEnabled(Boolean.TRUE.equals(incoming.getHitlEnabled()));
            binding.setHitlConfigJson(incoming.getHitlConfigJson());
            binding.setOptionsJson(incoming.getOptionsJson());
            binding.setResourceSnapshot(new LinkedHashMap<>());
            binding.setResourceSummary(new LinkedHashMap<>());
            binding.setSortNo(incoming.getSortNo() == null ? i : incoming.getSortNo());
            binding.setCreatedBy(account.getId());
            binding.setModifiedBy(account.getId());
            result.add(binding);
        }
        return result;
    }

    private List<AgentKnowledgeBinding> copyDraftKnowledgeBindings(List<AgentKnowledgeBinding> bindings, Agent agent, LoginAccount account) {
        List<AgentKnowledgeBinding> result = new ArrayList<>();
        if (bindings == null || bindings.isEmpty()) {
            return result;
        }
        for (int i = 0; i < bindings.size(); i++) {
            AgentKnowledgeBinding incoming = bindings.get(i);
            if (incoming == null) {
                continue;
            }
            AgentKnowledgeBinding binding = new AgentKnowledgeBinding();
            binding.setId(incoming.getId());
            binding.setTenantId(account.getTenantId());
            binding.setAgentId(agent.getId());
            binding.setKnowledgeId(incoming.getKnowledgeId());
            binding.setRetrievalMode(incoming.getRetrievalMode());
            binding.setEnabled(incoming.getEnabled() == null || incoming.getEnabled());
            binding.setOptionsJson(incoming.getOptionsJson());
            binding.setResourceSnapshot(new LinkedHashMap<>());
            binding.setResourceSummary(new LinkedHashMap<>());
            binding.setSortNo(incoming.getSortNo() == null ? i : incoming.getSortNo());
            binding.setCreatedBy(account.getId());
            binding.setModifiedBy(account.getId());
            result.add(binding);
        }
        return result;
    }

    private void validateDraftResources(Agent agent) {
        if (agent.getToolBindings() != null) {
            for (AgentToolBinding binding : agent.getToolBindings()) {
                if (binding != null && Boolean.TRUE.equals(binding.getEnabled())) {
                    validateDraftToolBinding(binding);
                }
            }
        }
        if (agent.getKnowledgeBindings() != null) {
            for (AgentKnowledgeBinding binding : agent.getKnowledgeBindings()) {
                if (binding != null && Boolean.TRUE.equals(binding.getEnabled())) {
                    validateDraftKnowledgeBinding(binding);
                }
            }
        }
    }

    private void validateDraftToolBinding(AgentToolBinding binding) {
        if (binding.getTargetId() == null || binding.getToolType() == null) {
            throw new BusinessException("工具绑定参数不完整");
        }
        AgentToolType type = parseToolType(binding.getToolType());
        if (type == AgentToolType.WORKFLOW) {
            Workflow workflow = workflowService.getById(binding.getTargetId());
            if (workflow == null || PublishStatus.from(workflow.getPublishStatus()) != PublishStatus.PUBLISHED) {
                throw new BusinessException("绑定工作流不存在或未发布");
            }
            resourceAccessService.assertAccess(CategoryResourceType.WORKFLOW, workflow, ResourceAction.USE, "无权限试用该工作流工具");
            return;
        }
        if (type == AgentToolType.PLUGIN) {
            PluginItem pluginItem = pluginItemService.getById(binding.getTargetId());
            if (pluginItem == null || pluginItem.getStatus() == null || pluginItem.getStatus() != 1) {
                throw new BusinessException("绑定插件不存在或未启用");
            }
            return;
        }
        Mcp mcp = mcpService.getById(binding.getTargetId());
        if (mcp == null || !Boolean.TRUE.equals(mcp.getStatus())) {
            throw new BusinessException("绑定 MCP 不存在或未启用");
        }
    }

    private void validateDraftKnowledgeBinding(AgentKnowledgeBinding binding) {
        if (binding.getKnowledgeId() == null) {
            throw new BusinessException("知识库绑定参数不完整");
        }
        KnowledgeRetrievalModes.parse(binding.getRetrievalMode());
        DocumentCollection knowledge = documentCollectionService.getById(binding.getKnowledgeId());
        if (knowledge == null || PublishStatus.from(knowledge.getPublishStatus()) != PublishStatus.PUBLISHED) {
            throw new BusinessException("绑定知识库不存在或未发布");
        }
        resourceAccessService.assertAccess(CategoryResourceType.KNOWLEDGE, knowledge, ResourceAction.USE, "无权限试用该知识库");
    }

    private AgentToolType parseToolType(String toolType) {
        try {
            return AgentToolType.from(toolType);
        } catch (IllegalArgumentException e) {
            throw new BusinessException("不支持的工具绑定类型：" + (toolType == null ? "" : toolType));
        }
    }

    private AgentToolHitlPayload buildToolHitlPayload(String requestId, AgentRuntimeEvent event) {
        Map<String, Object> rawPayload = event.getPayload() == null ? Map.of() : event.getPayload();
        AgentToolHitlPayload payload = new AgentToolHitlPayload();
        payload.setRequestId(requestId);
        payload.setApprovalId(stringValue(event.getMetadata(), "approvalId"));
        payload.setSessionId(stringValue(rawPayload, "sessionId"));
        payload.setAgentId(stringValue(rawPayload, "agentId"));
        payload.setToolCallId(firstText(stringValue(rawPayload, "toolCallId"), event.getToolCallId()));
        payload.setToolName(stringValue(rawPayload, "toolName"));
        payload.setToolDisplayName(firstText(stringValue(rawPayload, "toolDisplayName"), stringValue(rawPayload, "toolName")));
        payload.setInput(ToolApprovalInputProjection.project(rawPayload.get("toolInput")));
        if (payload.getInput().isEmpty()) {
            payload.setInput(ToolApprovalInputProjection.project(rawPayload.get("input")));
        }
        payload.setExpiresAt(stringValue(rawPayload, "expiresAt"));
        Map<String, Object> metadata = buildHitlMetadata(rawPayload);
        String toolType = firstText(stringValue(rawPayload, "toolType"), stringValue(metadata, "toolType"));
        payload.setToolType(toolType);
        if (toolType != null && !toolType.isBlank()) {
            metadata.put("toolType", toolType);
        }
        payload.setMetadata(metadata);
        return payload;
    }

    /**
     * 构建可发送至 AG-UI 和运行事件存储的工具审批事件。
     *
     * @param event Runtime 原始审批事件
     * @return 仅保留审批身份、期限和脱敏业务参数的事件
     */
    private AgentRuntimeEvent buildToolApprovalPublicEvent(AgentRuntimeEvent event) {
        Map<String, Object> rawPayload = event.getPayload() == null ? Map.of() : event.getPayload();
        Map<String, Object> payload = selectPayload(rawPayload,
                "agentId", "expiresAt", "sessionId", "toolCallId",
                "toolDisplayName", "toolName", "toolType");
        Map<String, Object> input = ToolApprovalInputProjection.project(rawPayload.get("toolInput"));
        if (input.isEmpty()) {
            input = ToolApprovalInputProjection.project(rawPayload.get("input"));
        }
        payload.put("toolInput", input);
        AgentRuntimeEvent projected = publicRuntimeEvent(event, payload);
        putIfPresent(projected.getMetadata(), "approvalId", event.getMetadata().get("approvalId"));
        return projected;
    }

    /**
     * 构建发送给聊天时间线的工具事件载荷。
     *
     * @param event 运行时工具事件
     * @return 包含稳定工具调用 ID 的前端载荷
     */
    private Map<String, Object> buildToolEventPayload(AgentRuntimeEvent event) {
        Map<String, Object> rawPayload = event.getPayload() == null ? Map.of() : event.getPayload();
        Map<String, Object> payload = selectPayload(rawPayload,
                "name", "status", "success", "toolDisplayName", "toolName",
                "skillDisplayName", "skillId");
        String toolCallId = firstText(event.getToolCallId(), stringValue(rawPayload, "toolCallId"));
        if (toolCallId != null && !toolCallId.isBlank()) {
            payload.put("toolCallId", toolCallId);
        }
        if (Boolean.TRUE.equals(event.getMetadata().get("asyncTool"))) {
            putIfPresent(payload, "sourceToolCallId", toolCallId);
            enrichAsyncToolPayload(payload, event.getMetadata(), rawPayload, toolCallId);
            String taskId = stringValue(payload, "taskId");
            if (taskId != null && !taskId.isBlank()) {
                payload.put("toolCallId", taskId);
            }
        }
        return payload;
    }

    /**
     * 从 artifact_publish 的内部投影事件中提取安全产物字段。
     *
     * @param event Runtime 事件
     * @return 安全产物字段；非产物投影事件返回 {@code null}
     */
    private Map<String, Object> buildArtifactPublishedPayload(AgentRuntimeEvent event) {
        if (event.getEventType() != AgentRuntimeEventType.TOOL_RESULT || event.getPayload() == null
                || !Boolean.TRUE.equals(event.getPayload().get("artifactProjectionOnly"))) {
            return null;
        }
        Object rawArtifact = event.getPayload().get("artifactPublished");
        if (!(rawArtifact instanceof Map<?, ?> source)) {
            return null;
        }
        Map<String, Object> artifact = new LinkedHashMap<>();
        for (String field : List.of("schemaVersion", "artifactId", "fileName", "mimeType",
                "size", "sha256", "downloadUrl", "status")) {
            if (source.get(field) != null) {
                artifact.put(field, source.get(field));
            }
        }
        return artifact.get("artifactId") == null ? null : artifact;
    }

    private boolean isAsyncToolEvent(AgentRuntimeEventType type) {
        return type == AgentRuntimeEventType.ASYNC_TOOL_SUBMITTED
                || type == AgentRuntimeEventType.ASYNC_TOOL_OBSERVED
                || type == AgentRuntimeEventType.ASYNC_TOOL_RESULT
                || type == AgentRuntimeEventType.ASYNC_TOOL_CANCELLED
                || type == AgentRuntimeEventType.ASYNC_TOOL_LISTED
                || type == AgentRuntimeEventType.ASYNC_TOOL_FAILED;
    }

    private ChatType asyncToolChatType(AgentRuntimeEvent event) {
        String status = stringPayload(event, "status");
        if ("SUCCEEDED".equalsIgnoreCase(status)
                || "FAILED".equalsIgnoreCase(status)
                || "CANCELLED".equalsIgnoreCase(status)
                || "TIMEOUT".equalsIgnoreCase(status)
                || event.getEventType() == AgentRuntimeEventType.ASYNC_TOOL_RESULT
                || event.getEventType() == AgentRuntimeEventType.ASYNC_TOOL_FAILED
                || event.getEventType() == AgentRuntimeEventType.ASYNC_TOOL_CANCELLED) {
            return ChatType.TOOL_RESULT;
        }
        return ChatType.TOOL_CALL;
    }

    private Map<String, Object> buildAsyncToolEventPayload(AgentRuntimeEvent event) {
        Map<String, Object> rawPayload = event.getPayload() == null ? Map.of() : event.getPayload();
        Map<String, Object> payload = selectPayload(rawPayload,
                "asyncToolName", "name", "phase", "status", "success", "taskId",
                "toolDisplayName", "toolName", "skillDisplayName", "skillId");
        String taskId = stringValue(payload, "taskId");
        String sourceToolCallId = event.getToolCallId();
        String toolCallId = firstText(taskId, sourceToolCallId);
        if (toolCallId != null && !toolCallId.isBlank()) {
            payload.put("toolCallId", toolCallId);
        }
        putIfPresent(payload, "sourceToolCallId", sourceToolCallId);
        enrichAsyncToolPayload(payload, event.getMetadata(), rawPayload, toolCallId);
        return payload;
    }

    private void enrichAsyncToolPayload(Map<String, Object> payload,
                                        Map<String, Object> metadata,
                                        Map<String, Object> runtimePayload,
                                        String fallbackId) {
        Map<String, Object> safeMetadata = metadata == null ? Map.of() : metadata;
        payload.put("asyncTool", true);
        putIfPresent(payload, "asyncToolName", firstText(stringValue(payload, "asyncToolName"), stringValue(safeMetadata, "asyncToolName")));
        putIfPresent(payload, "phase", firstText(stringValue(safeMetadata, "asyncToolPhase"), stringValue(payload, "phase")));
        putIfPresent(payload, "taskId", resolveAsyncTaskId(payload, safeMetadata, runtimePayload));
        putIfPresent(payload, "status", firstText(stringValue(payload, "status"), stringValue(safeMetadata, "status")));
        String displayName = firstText(stringValue(payload, "toolDisplayName"),
                firstText(stringValue(safeMetadata, "toolDisplayName"), stringValue(payload, "asyncToolName")));
        putIfPresent(payload, "toolDisplayName", displayName);
        putIfPresent(payload, "toolName", displayName);
        putIfPresent(payload, "name", displayName);
        String statusKey = "async-tool:" + firstText(stringValue(payload, "taskId"), fallbackId);
        payload.put("statusKey", statusKey);
        payload.put("label", asyncToolLabel(stringValue(payload, "status"), stringValue(payload, "phase"), displayName));
    }

    /**
     * 从异步事件载荷、结果元数据或阶段调用入参中解析稳定任务 ID。
     *
     * @param payload 事件载荷
     * @param metadata 事件元数据
     * @return 异步任务 ID；不存在时返回 null
     */
    private String resolveAsyncTaskId(Map<String, Object> payload,
                                      Map<String, Object> metadata,
                                      Map<String, Object> runtimePayload) {
        String taskId = firstText(stringValue(payload, "taskId"), stringValue(metadata, "taskId"));
        if (taskId != null && !taskId.isBlank()) {
            return taskId;
        }
        Map<String, Object> source = runtimePayload == null ? Map.of() : runtimePayload;
        Map<String, Object> input = mapPayload(firstNonNull(source.get("input"), source.get("toolInput")));
        return firstText(stringValue(input, "taskId"), stringValue(input, "task_id"));
    }

    private String asyncToolLabel(String status, String phase, String displayName) {
        String name = displayName == null || displayName.isBlank() ? "异步工具" : displayName;
        if ("SUCCEEDED".equalsIgnoreCase(status)) {
            return name + "已完成";
        }
        if ("FAILED".equalsIgnoreCase(status) || "TIMEOUT".equalsIgnoreCase(status)) {
            return name + "执行失败";
        }
        if ("PENDING".equalsIgnoreCase(status) || "submit".equalsIgnoreCase(phase)) {
            return name + "已提交";
        }
        return name + "执行中";
    }

    /**
     * 构建知识库检索状态载荷，确保前端可按稳定 key 合并同一轮状态行。
     *
     * @param event 知识库检索运行时事件
     * @return 知识库检索状态载荷
     */
    private Map<String, Object> buildKnowledgeRetrievalStatusPayload(AgentRuntimeEvent event) {
        Map<String, Object> payload = new LinkedHashMap<>();
        payload.put("statusKey", "knowledge-retrieval");
        payload.put("status", "done");
        payload.put("label", "已检索知识库");
        return payload;
    }

    /**
     * 构建不含 Runtime 原始上下文的内存压缩公开状态载荷。
     *
     * @param event 内存压缩事件
     * @return 前端公开状态载荷
     */
    private Map<String, Object> buildMemoryCompressionStatusPayload(AgentRuntimeEvent event) {
        Map<String, Object> rawPayload = event.getPayload() == null ? Map.of() : event.getPayload();
        Map<String, Object> payload = selectPayload(rawPayload, "compressed", "label", "phase", "status");
        payload.put("statusKey", "memory-compression");
        return payload;
    }

    /**
     * 构建挂起状态载荷。
     *
     * @param event 挂起事件
     * @return 前端状态载荷
     */
    private Map<String, Object> buildSuspendedStatusPayload(AgentRuntimeEvent event) {
        Map<String, Object> payload = new LinkedHashMap<>();
        payload.put("statusKey", "agent-suspended");
        payload.put("status", "waiting");
        payload.put("label", "等待人工确认");
        return payload;
    }

    /**
     * 复制标准投影所需字段，并替换为公开白名单载荷。
     *
     * @param source Runtime 原始事件
     * @param payload 公开载荷
     * @return 不携带原始 metadata 和消息对象的投影事件
     */
    private AgentRuntimeEvent publicRuntimeEvent(AgentRuntimeEvent source, Map<String, Object> payload) {
        AgentRuntimeEvent event = AgentRuntimeEvent.of(source.getEventType());
        event.setEventId(source.getEventId());
        event.setTraceId(source.getTraceId());
        event.setSessionId(source.getSessionId());
        event.setAgentId(source.getAgentId());
        event.setMessageId(firstText(
                source.getMessageId(),
                source.getMessage() == null ? null : source.getMessage().getMessageId()));
        event.setToolCallId(source.getToolCallId());
        event.setCreatedAt(source.getCreatedAt());
        event.setPayload(payload == null ? new LinkedHashMap<>() : new LinkedHashMap<>(payload));
        return event;
    }

    /**
     * 构建发送给前端的最终知识库引用载荷。
     *
     * @param event 运行完成事件
     * @return 知识库引用列表
     */
    private List<Map<String, Object>> buildKnowledgeCitationPayload(AgentRuntimeEvent event) {
        AgentMessage message = event == null ? null : event.getMessage();
        if (message == null || message.getKnowledgeReferences() == null || message.getKnowledgeReferences().isEmpty()) {
            return List.of();
        }
        List<Map<String, Object>> citations = new ArrayList<>();
        for (AgentKnowledgeReference reference : message.getKnowledgeReferences()) {
            if (reference == null) {
                continue;
            }
            Map<String, Object> item = new LinkedHashMap<>();
            putIfPresent(item, "knowledgeId", reference.getKnowledgeId());
            putIfPresent(item, "knowledgeName", reference.getKnowledgeName());
            putIfPresent(item, "documentId", reference.getDocumentId());
            putIfPresent(item, "documentName", reference.getDocumentName());
            putIfPresent(item, "chunkId", reference.getChunkId());
            putIfPresent(item, "chunkContent", reference.getChunkContent());
            putIfPresent(item, "sourceUri", reference.getSourceUri());
            putIfPresent(item, "score", reference.getScore());
            if (reference.getMetadata() != null && !reference.getMetadata().isEmpty()) {
                item.put("metadata", new LinkedHashMap<>(reference.getMetadata()));
                putIfPresent(item, "sourceFileName", reference.getMetadata().get("sourceFileName"));
                putIfPresent(item, "knowledgeType", reference.getMetadata().get("knowledgeType"));
                putIfPresent(item, "faqCollection", reference.getMetadata().get("faqCollection"));
            }
            citations.add(item);
        }
        return citations;
    }

    private void putIfPresent(Map<String, Object> target, String key, Object value) {
        if (value != null) {
            target.put(key, value);
        }
    }

    /**
     * 从 Runtime 载荷中选取允许公开的字段。
     *
     * @param source 原始载荷
     * @param allowedKeys 允许字段
     * @return 保持字段顺序的公开载荷
     */
    private Map<String, Object> selectPayload(Map<String, Object> source, String... allowedKeys) {
        Map<String, Object> selected = new LinkedHashMap<>();
        for (String key : allowedKeys) {
            if (source.containsKey(key)) {
                selected.put(key, source.get(key));
            }
        }
        return selected;
    }

    private Map<String, Object> buildHitlMetadata(Map<String, Object> rawPayload) {
        Map<String, Object> metadata = new LinkedHashMap<>();
        mapPayload(rawPayload.get("approvalMetadata")).forEach((key, value) -> {
            if (!isHitlPromptKey(key)) {
                metadata.put(key, value);
            }
        });
        for (String key : List.of("toolType", "skillId", "skillName", "bindingId", "targetId")) {
            Object value = rawPayload.get(key);
            if (value != null) {
                metadata.put(key, value);
            }
        }
        return ToolApprovalInputProjection.project(metadata);
    }

    private boolean isHitlPromptKey(String key) {
        if (key == null) {
            return false;
        }
        String normalized = key.trim();
        return "prompt".equalsIgnoreCase(normalized)
                || "question".equalsIgnoreCase(normalized)
                || "approvalPrompt".equalsIgnoreCase(normalized);
    }

    private Map<String, Object> mapPayload(Object value) {
        if (!(value instanceof Map<?, ?> rawMap)) {
            return new LinkedHashMap<>();
        }
        Map<String, Object> result = new LinkedHashMap<>();
        rawMap.forEach((rawKey, rawValue) -> result.put(String.valueOf(rawKey), rawValue));
        return result;
    }

    private String stringValue(Map<String, Object> payload, String key) {
        Object value = payload == null ? null : payload.get(key);
        return value == null ? null : String.valueOf(value);
    }

    /**
     * 将模型输出转换为适合日志排查的可见文本。
     *
     * @param value 原始模型输出
     * @return 已转义换行、制表符与控制字符的日志文本
     */
    private String toVisibleLogText(String value) {
        if (value == null) {
            return "";
        }
        StringBuilder builder = new StringBuilder(value.length());
        for (int index = 0; index < value.length(); index++) {
            char current = value.charAt(index);
            if (current == '\n') {
                builder.append("\\n");
            } else if (current == '\r') {
                builder.append("\\r");
            } else if (current == '\t') {
                builder.append("\\t");
            } else if (Character.isISOControl(current)) {
                builder.append(String.format("\\u%04x", (int) current));
            } else {
                builder.append(current);
            }
        }
        return builder.toString();
    }

    /**
     * 返回候选值中的首个非空文本。
     *
     * @param candidates 按优先级排列的文本候选
     * @return 首个非空文本；全部为空时返回 {@code null}
     */
    private String firstText(String... candidates) {
        if (candidates == null) {
            return null;
        }
        for (String candidate : candidates) {
            if (candidate != null && !candidate.isBlank()) {
                return candidate;
            }
        }
        return null;
    }

    private Object firstNonNull(Object first, Object second) {
        return first != null ? first : second;
    }

    private String stringPayload(AgentRuntimeEvent event, String key) {
        Object value = event.getPayload() == null ? null : event.getPayload().get(key);
        return value == null ? null : String.valueOf(value);
    }

    private String errorMessage(AgentRuntimeEvent event) {
        String message = stringPayload(event, "message");
        if (message != null && !message.isBlank()) {
            return message;
        }
        String reason = stringPayload(event, "reason");
        return reason == null || reason.isBlank() ? "Agent 运行失败" : reason;
    }
}
