☰
ClawX 子代理会话嵌入父级会话:ACP 血统权威与 Gateway 运行态联合机制解析
2026/10/3 2:28:16 网站建设 项目流程
  • 人工智能
  • AI 应用
  • 桌面应用
  • 交互助手

【免费下载链接】ClawX

ClawX is a desktop app that provides a graphical interface for OpenClaw AI agents. It turns CLI-based AI orchestration into a desktop experience without using the terminal. China website is https://clawx.com.cn.

项目地址:https://gitcode.com/gh_mirrors/cl/ClawX
点击查看免费下载

本篇技术指南以 ClawX(基于 OpenClaw AI Agent 的桌面客户端)中"将原生子代理(subagent)会话嵌入其父级会话"这一运行时桥接任务为核心,讲解 ACPsession/list作为血统与标题唯一权威的架构设计、分页游标边界、Gateway 精确键目录门控规则,以及父子会话导航、删除语义等交互行为。读完本文,你将掌握该特性从 Main 进程 ACP 服务到 Renderer 组件投影的完整调用链,理解"ACP 决定血缘、Gateway 决定运行态与可操作性"的双权威模型,并能在 ClawX 仓库 中对照源码与测试验证每一项验收标准。

特性目标与设计意图

该任务(见 embed-subagent-sessions-in-parent-chat.md)的意图可以概括为三点:

  1. 隐藏:原生agent:<agentId>:subagent:<id>会话不再以独立行出现在左侧边栏,但依然保留在共享的 Gateway 会话目录(session catalog)中,用于状态、路由与既有的删除行为。
  2. 嵌入:在父级会话中直接展示其 ACP 子代理血统与标题——输入区左侧出现本地化的子代理计数控件,展开后列出每个直接子代理。
  3. 保真:始终以 Gateway 作为运行状态(live run status)的唯一权威,子代理行在忙时显示加载图标,空闲时显示机器人图标,与侧边栏使用同一套运行态投影。

该特性属于 gateway-backend-communication 场景下的runtime-bridge任务类型,并遵循 renderer-main-boundary、backend-communication-boundary、acp-chat-state-and-history、sidebar-session-attention-authority 与 ui-i18n-design-tokens 等规则。

权威边界:ACP 决定血缘,Gateway 决定运行态

整个特性的核心是一组精心划分的"权威"边界,理解它们是读懂全部实现的前提:

语义唯一权威说明
血统成员关系与标题ACPsession/listMain 校验SessionInfo._meta,优先parentSessionId,回退spawnedBy,拒绝畸形与自引用
当前子代理可见性与可操作性Gateway 会话目录(最新精确键存在)目录中存在性只"门控"当前操作,不创建血统成员关系或标题
运行状态Gatewaysessions.list/sessions.changed的精确键status与hasActiveRunACP prompt 状态与本地发送状态不驱动子代理加载图标
血统投影内存态、可重载不是第二份 transcript、状态目录或持久化关系数据库

正如任务 Scope 所写:"Latest exact-key Gateway catalog presence gates current child visibility and actionability plus direct-parent return-target availability; presence never creates lineage membership or titles."(最新精确键 Gateway 目录存在性门控当前子代理的可见性、可操作性以及直接父级返回目标的可达性;存在性绝不创建血统成员关系或标题。)

架构参考文档 acp-chat.md 对这一模型的描述是:ACP 是 ClawX 暴露的 Chat 语义的首选权威,Renderer 将每个 ACP 子代理与 Gateway 会话目录中的同一精确键 join 后才呈现操作入口。

Main 进程:新增类型化 ACPsession/list操作

为什么由 Main 持有该操作

血统(lineage)是 ACP 暴露的 Chat 语义,因此该特性在 Main 进程的 ACP 服务中新增了一个类型化操作getSessionFamily。实现位于 electron/services/acp-chat-service.ts,关键流程如下:

async getSessionFamily(payload: unknown): Promise<AcpSessionFamilyResult> { if (!isPlainRecord(payload) || !isValidSessionKey(payload.sessionKey)) { return failSessionFamily('Invalid ACP session family payload'); } try { const connection = await this.ensureConnection(); if (!this.sessionListSupported) { return failSessionFamily('ACP agent does not support session/list'); } const sessions: SessionInfo[] = []; const seenCursors = new Set<string>(); let cursor: string | undefined; for (let page = 0; page < ACP_SESSION_LIST_MAX_PAGES; page += 1) { const result = readAcpSessionListPage(await connection.listSessions(cursor ? { cursor } : {})); if (!result) return failSessionFamily('Invalid ACP session/list response'); sessions.push(...result.sessions); const nextCursor = result.nextCursor; if (nextCursor == null) { return projectAcpSessionFamily(payload.sessionKey, sessions); } if (typeof nextCursor !== 'string' || !nextCursor.trim() || seenCursors.has(nextCursor)) { return failSessionFamily('Invalid ACP session/list cursor'); } seenCursors.add(nextCursor); cursor = nextCursor; } return failSessionFamily(`ACP session/list exceeded ${ACP_SESSION_LIST_MAX_PAGES} pages`); } catch (error) { logger.error(`[acp-chat] getSessionFamily failed: ${String(error)}`); return failSessionFamily(error); } }

实现要点:

  • 能力探测:在 ACP 连接初始化(initializeConnectionOnce)时通过result.agentCapabilities.sessionCapabilities?.list判断是否支持session/list,存入sessionListSupported标志(acp-chat-service.ts);不支持时返回类型化失败而非降级。
  • 不动当前会话:该操作复用既有 ACP 连接(ensureConnection),只做列表读取,不改变已加载的 ACP 会话。
  • 不改变传输层:该调用发生在 Main 持有的 ACP 子进程上,Renderer 只通过hostApi.chat.getAcpSessionFamily发起请求,不直接触碰 ACP 连接。

有界历史(Bounded History)

任务文档对历史范围的约束非常明确:

  • 每页请求100 行,最多跟随128 页(常量ACP_SESSION_LIST_MAX_PAGES = 128,见 acp-chat-service.ts)。
  • 只有当前捆绑的 OpenClaw ACPsession/list实现返回的会话才有资格进入家族投影。
  • 归档(archived)、已删除(deleted)、已清理(cleaned)或未列出的历史子代理一律排除。
  • 家族查询不扫描transcript、公告(announcements)、助手文本、Gateway 血统字段或子代理 UUID 来恢复这些历史记录。

游标与分页校验

Main 对每页响应做严格校验(readAcpSessionListPage):sessions必须是数组且每个元素满足isAcpSessionInfo(sessionId与cwd均为非空字符串)。分页遵循以下规则:

  • nextCursor为空则视为最后一页,立即投影。
  • 游标必须是非空字符串,且不得重复出现(seenCursors去重),否则返回'Invalid ACP session/list cursor'。
  • 超过 128 页返回'ACP session/list exceeded 128 pages'的类型化失败,绝不返回部分家族数据。
  • 重复的会话 ID 保留首次出现的那一条(去重发生在投影层projectAcpSessionFamily)。

血统投影层:subagent-lineage.ts

共享类型契约

投影的核心类型定义在 shared/acp-chat/types.ts:

export type AcpSessionFamilyPayload = AcpSessionKeyPayload; export type AcpSessionFamilyMember = { sessionKey: string; title: string; updatedAt: string | null; parentSessionKey: string | null; }; export type AcpSessionFamilyResult = | { success: true; current: AcpSessionFamilyMember | null; children: AcpSessionFamilyMember[]; error?: never; } | { success: false; current: null; children: []; error: string; };

这是一个可辨识联合(discriminated union):失败分支强制current: null、children: [],保证 Renderer 不必对部分结果做防御式猜测。

父级解析:parseAcpSessionParentId

实现在 shared/acp-chat/subagent-lineage.ts,逻辑为:

  1. 读取SessionInfo._meta,必须是普通对象(isRecord)否则返回null。
  2. 优先取parentSessionId;若缺失、非字符串、空白或等于自身 sessionId(自引用),回退到spawnedBy。
  3. 回退同样拒绝空白与自引用候选。

注意:合法的候选在 trim 后按原样保留(readParentCandidate返回 trim 前还是 trim 后的值?源码中readParentCandidate对非空字符串直接返回原值)。测试 tests/unit/acp-subagent-lineage.test.ts 验证了"保留不透明 ID 的精确性,同时拒绝空白与自引用候选"——例如parentSessionId: ' agent:main:parent '会被原样保留为' agent:main:parent '(含空格),而42、false、数组形态的_meta都会得到null。

原生子代理 ID 判定:isNativeAcpSubagentSessionId

export function isNativeAcpSubagentSessionId(sessionId: string): boolean { const parts = sessionId.split(':'); return parts.length === 4 && parts[0] === 'agent' && Boolean(parts[1]) && parts[2] === 'subagent' && Boolean(parts[3]); }

即只有精确的agent:<agentId>:subagent:<childId>四段结构才算原生子代理。测试用例(acp-subagent-lineage.test.ts)确认:agent:main:acp:child-1、agent:main:subagent:child:extra、agent::subagent:child-1、agent:main:subagent:均为false。

家族投影:projectAcpSessionFamily

投影结果仅包含:

  • 当前会话:sessionKey精确匹配请求的会话,投影为current。
  • 直接原生子代理:同时满足"原生子代理 ID"且"父级解析结果 === 当前会话 key"的会话,投影为children,按updatedAt新到旧排序;时间戳缺失或不可解析的排最后;时间戳相等时按 sessionKey 的 Unicode 码点(code point)字典序打破平局。

嵌套后代(agent:...:subagent:x的子代理)不会被打平进祖先的面板——它们属于各自的直接父级,见测试 acp-subagent-lineage.test.ts。标题以 ACP 提供的title为准,缺失时回退到精确 session key。Renderer 侧通过 src/lib/acp/subagent-lineage.ts 直接再导出这三个函数。

Renderer 侧:会话家族加载与去抖

家族加载的生命周期

Chat 页面(src/pages/Chat/index.tsx)用loadCurrentSessionFamily发起请求,并用familyRequestIdRef请求序号 +selectedFamilySessionKeyRef当前选中会话双重校验拒绝过期完成:

const loadCurrentSessionFamily = useCallback((sessionKey: string) => { const requestId = ++familyRequestIdRef.current; void hostApi.chat.getAcpSessionFamily({ sessionKey }).then((result) => { if ( requestId !== familyRequestIdRef.current || selectedFamilySessionKeyRef.current !== sessionKey || !result.success ) return; setLoadedSessionFamily({ sessionKey, result }); }).catch(() => undefined); }, []); useEffect(() => { familyRequestIdRef.current += 1; setLoadedSessionFamily((current) => current?.sessionKey === currentSessionKey ? current : null); if (currentSessionKey) loadCurrentSessionFamily(currentSessionKey); return () => { familyRequestIdRef.current += 1; }; }, [currentSessionKey, loadCurrentSessionFamily]);

这一设计与验收标准"Lineage requests are scoped to the requested current session and reject stale completion after selection changes"(血统请求限定于当前请求的会话,并在切换选择后拒绝过期完成)一一对应。失败时隐藏或保留既有家族数据,绝不阻塞普通 ACP 会话加载。

sessions_spawn结果:仅作失效信号

验收标准明确指出:一个已完成的结构化sessions_spawn工具结果,只有在status: accepted且runId、childSessionKey均非空时,才作为血统刷新失效信号——真正的成员关系与显示位置仍由 ACPsession/list决定。实现位于 index.tsx:

const successfulSpawnSignature = useMemo(() => { if (visibleAcpTimeline.sessionId !== currentSessionKey) return ''; return visibleAcpTimeline.itemOrder .map((itemId) => visibleAcpTimeline.itemsById[itemId]) .filter((item): item is ToolCallItem => item?.kind === 'tool-call') .map(successfulSessionSpawnIdentity) .filter((identity): identity is string => identity !== null) .join('\n'); }, [currentSessionKey, visibleAcpTimeline]); useEffect(() => { const previous = spawnInvalidationRef.current; if (previous.sessionKey !== currentSessionKey) { spawnInvalidationRef.current = { sessionKey: currentSessionKey, signature: successfulSpawnSignature }; return; } if (previous.signature === successfulSpawnSignature) return; spawnInvalidationRef.current = { sessionKey: currentSessionKey, signature: successfulSpawnSignature }; if (successfulSpawnSignature) loadCurrentSessionFamily(currentSessionKey); }, [currentSessionKey, loadCurrentSessionFamily, successfulSpawnSignature]);

这段代码通过拼接"成功 spawn 的会话标识签名"并与之对比,检测到新 spawn 后触发loadCurrentSessionFamily刷新。子代理会在活跃父会话期间的这次成功sessions_spawn后出现,且会话切换或 Renderer 重载后,同一关系仍能恢复(因为血统来源于 ACP 权威列表而非本地状态)。

子代理会话 UI:AcpSubagentSessions组件

组件职责

src/pages/Chat/AcpSubagentSessions.tsx 是聚合控件与展开面板的实现:

  • 聚合按钮:显示子代理计数(Dispatched {{count}} subagents等本地化文案)与一个图标——任一子代理忙时显示旋转加载图标(Loader2),否则显示机器人图标(Bot)。
  • 展开面板:仅在sessions.length > 0时渲染;每行只出现于"ACP 列出且精确键存在于最新 Gateway 目录"的子代理。
  • 无障碍:聚合状态与每行状态都有role="status"的屏幕阅读器文本(aria-live="polite"),聚合按钮带aria-expanded与aria-controls。

显示标题通过getDisplayTitle处理:仅做展示层去除[Subagent Context]前缀(/^\[Subagent Context\]\s*/),不改变 ACP 提供的标题本体——ACP 依然是标题的唯一权威。

function getDisplayTitle(session: AcpSubagentSession): string { return session.title.replace(/^\[Subagent Context\]\s*/, '') || session.sessionKey; } function SessionIcon({ busy }: { busy: boolean }) { if (busy) { return ( <Loader2 className="h-4 w-4 shrink-0 animate-spin text-blue-700 motion-reduce:animate-none dark:text-blue-400" aria-hidden="true" /> ); } return <Bot className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />; }

与侧边栏相同的运行态投影

子代理的busy标志(index.tsx)使用与当前侧边栏相同的运行态投影projectSessionRunState,数据源是 Gateway 会话目录与sessionAttentionByKey:

const subagentSessions = useMemo<AcpSubagentSession[]>(() => { if (!visibleSessionFamily) return []; return visibleSessionFamily.children.flatMap((child) => { const catalogSession = catalogSessionByKey.get(child.sessionKey); if (!catalogSession) return []; const runState = catalogSession ? projectSessionRunState(catalogSession) : 'unknown'; return [{ sessionKey: child.sessionKey, title: child.title, busy: runState === 'busy' || (runState === 'unknown' && sessionAttentionByKey[child.sessionKey]?.observedBusy === true), }]; }); }, [catalogSessionByKey, sessionAttentionByKey, visibleSessionFamily]);

要点:ACP 子代理必须同时存在于 Gateway 目录(精确键)才显示为行(catalogSession不存在则flatMap返回空数组);busy只来自 Gateway 精确键运行态,投影为unknown时才回退到observedBusy;ACP prompt 状态与本地发送状态完全不参与。

会话切换时的面板行为

运行态变化不会折叠已展开的子代理列表;而切换所选会话会关闭上一个会话的子代理面板(expansion状态以sessionKey为键,切换后自动重置),对应验收标准 "Live status changes do not collapse an open child list. Changing the selected conversation closes the prior conversation's child panel."(运行态变化不折叠已展开的子代理列表;切换所选会话关闭上一会话的子代理面板。)

子代理会话内的"返回父级"导航

当一个会话自身就是原生子代理时(index.tsx):

const isCurrentSessionSubagent = visibleSessionFamily?.current?.sessionKey === currentSessionKey && isNativeSubagentSessionKey(currentSessionKey); const currentSessionTitle = isNativeSubagentSessionKey(currentSessionKey) ? isCurrentSessionSubagent ? formatSubagentSessionTitle(currentSessionKey, visibleSessionFamily.current!.title) : currentSessionKey : catalogSessionTitle; const familyParentSessionKey = isCurrentSessionSubagent ? visibleSessionFamily.current?.parentSessionKey : null; const directParentSessionKey = familyParentSessionKey && catalogSessionByKey.has(familyParentSessionKey) ? familyParentSessionKey : null;
  • 子代理会话显示本地化的"Subagent"标记(chat-subagent-marker)。
  • 明确的"返回父级会话"操作只在ACP 列出的直接父级同时存在于最新 Gateway 目录(精确键)时出现(directParentSessionKey非空)。
  • 返回动作的目标是 ACP 中的直接父级,而非浏览器历史("The explicit return action targets the direct ACP parent rather than browser history")。

formatSubagentSessionTitle的展示层处理位于 src/stores/chat/session-key-utils.ts,同样仅剥离[Subagent Context]前缀。同一文件还定义了shouldIncludeSessionInSidebarList:原生子代理保留在目录(用于状态、注意力对账、工作区清理、导航与既有删除),但不进入侧边栏列表(session-key-utils.ts),这正是"侧边栏过滤仅为展示层"的落点——被隐藏的子代理行对精确键注意力对账、工作区清理、导航及既有非级联删除行为仍然可用。

删除语义:不级联

删除父级会话沿用既有精确会话删除行为,不会级联删除子代理(Out Of Scope 第一条:"Cascading parent deletion to child sessions")。隐藏的子代理行仍保留在共享会话目录中,因此现有的删除路径照常覆盖它们——只是删除必须显式发生,而不是跟随父级自动发生。

本地化与设计令牌

任务要求所有可见与可访问文本均以英文、中文、日文、俄文四语本地化,并复用既有的 composer、面板、选中态与状态设计令牌。英文文案见 shared/i18n/locales/en/chat.json:

"subagentSessions": { "count": "Dispatched {{count}} subagents", "expand": "Expand subagent sessions", "collapse": "Collapse subagent sessions", "toggle": "{{action}}, {{count}}", "panel": "Subagent sessions", "open": "Open subagent {{title}}", "marker": "Subagent", "returnToParent": "Return to parent conversation", "busy": "Running", "settled": "Settled", "aggregateStatus": "Subagents: {{status}}", "rowStatus": "{{title}}: {{status}}" }

对应语言文件位于 zh、ja、ru,并由 i18n-locale-parity.test.ts 保证四语键位一致。

验证:从单测到 E2E

任务在requiredTests中给出了完整的验证命令,可在仓库根目录执行:

# 1. 校验任务规格本身 pnpm harness validate --spec harness/specs/tasks/embed-subagent-sessions-in-parent-chat.md # 2. 单元测试(核心文件) pnpm exec vitest run tests/unit/acp-chat-service.test.ts tests/unit/acp-subagent-lineage.test.ts \ tests/unit/acp-subagent-sessions.test.tsx tests/unit/chat-input.test.tsx \ tests/unit/chat-acp-inline-timeline.test.tsx tests/unit/host-api-facade.test.ts \ tests/unit/session-key-utils.test.ts tests/unit/session-status.test.ts \ tests/unit/session-attention.test.ts tests/unit/harness-specs.test.ts \ tests/unit/i18n-locale-parity.test.ts # 3. E2E pnpm exec playwright test tests/e2e/chat-subagent-sessions.spec.ts \ tests/e2e/chat-sidebar-session-attention.spec.ts # 4. 静态检查与构建 pnpm run typecheck pnpm run lint:check pnpm run build:vite # 5. 通讯回归 pnpm run comms:replay pnpm run comms:compare

关键测试锚点:

  • tests/unit/acp-subagent-lineage.test.ts:覆盖父级解析优先级与回退、畸形/自引用拒绝、精确原生子代理 ID 判定、仅投影当前会话与直接子代理、跨页去重、时间戳排序与平局规则。
  • tests/unit/acp-chat-service.test.ts:验证 Main 侧getSessionFamily的游标跟随、页面上限与类型化失败。
  • tests/e2e/chat-subagent-sessions.spec.ts:验证侧边栏隐藏、父会话聚合控件、子行展开、子会话标记与返回父级交互。

明确不做的事(Out Of Scope)

最后,任务明确列出了不属于本特性的边界,防止实现蔓延:

  1. 级联删除父会话到子代理会话。
  2. 恢复非常古老、归档、已删除或已清理的子代理会话。
  3. 从公告或助手文本推断子代理完成状态。
  4. 用 ACP prompt 状态替换 Gateway 会话目录的运行态投影。

这四条边界与前述"权威模型"互为表里:血统恢复只信任 ACP 当前列表,运行态只信任 Gateway 精确键,其余一切推断路径都不被允许。

小结

ClawX 将原生子代理会话嵌入父级会话的实现,本质是一次严谨的权威分离工程:ACPsession/list通过 Main 进程的类型化操作成为血统成员与标题的唯一权威(有界分页、严格校验、类型化失败);Gateway 会话目录以精确键存在性门控当前可见性、可操作性与返回目标;运行态图标完全由 Gateway 运行态投影驱动。Renderer 只做展示层过滤与过期拒绝,不做任何血统推断。理解这套双权威模型,你就能举一反三地看懂 ClawX 中所有涉及"ACP 语义 + Gateway 状态"协同的模块——这也是该任务被归入 gateway-backend-communication 场景的根本原因。

  • 人工智能
  • AI 应用
  • 桌面应用
  • 交互助手

【免费下载链接】ClawX

ClawX is a desktop app that provides a graphical interface for OpenClaw AI agents. It turns CLI-based AI orchestration into a desktop experience without using the terminal. China website is https://clawx.com.cn.

项目地址:https://gitcode.com/gh_mirrors/cl/ClawX
点击查看免费下载

相关推荐

上一篇:FastMCP v3 Provider 架构解析:FastMCPProvider 与 TransformingProvider 拆分设计
下一篇:Mojo 2023 年 7 月更新解读:循环展开、包模块导入与全局变量等语言特性演进

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询