ACP 委派与任务账本 深度解析
从真实源码拆解 qwen-audio-agent 的关键子系统。
导入链与契约
后台执行链从前台工具调用开始,但真正的可靠性来自任务账本和 ACP Session 适配。server/src/voice/tools/tool-call-handler.mjs:138 到 server/src/voice/tools/tool-call-handler.mjs:185 创建 work,server/src/task/task-manager.mjs:225 到 server/src/task/task-manager.mjs:288 持久化任务公共快照和调度字段。
138 createWork({ turnId, originalRequest, objective, submissionKey }) { 139 let workId = '' 140 const task = this.taskManager.create({ 141 objective: originalRequest, 142 ownerId: this.ownerId, 143 sessionId: this.sessionId, 144 turnId, 145 submissionKey, 146 laneKey: `coordinator:${this.ownerId}`, 147 laneLimit: 1, 148 runner: async (_ignored, { onEvent, signal }) => this.coordinator.run({ 149 originalRequest, 150 objective, 151 conversationContext: this.getConversationContext(), 152 userMemories: this.memoryStore?.list(this.ownerId) || [], 153 timeZone: this.getClientContext()?.timeZone, 154 workingDirectory: this.getClientContext()?.workingDirectory, 155 }, { 156 ownerId: this.ownerId, 157 sessionId: this.sessionId, 158 turnId, 159 coordinationRunId: workId, 160 signal, 161 onEvent: event => this.forwardCoordinatorEvent(event, onEvent), 162 }), 163 canceler: async ({ previousStatus, abort }) => { 164 if (previousStatus === 'delegated') { 165 const result = await this.coordinator.cancelDelegatedWork( 166 workId, 167 { ownerId: this.ownerId }, 168 ) 169 abort() 170 return result 171 } 172 abort() 173 return { 174 route: 'adapter', 175 layer: previousStatus === 'finalizing' ? 'finalizing' : 'coordinator', 176 } 177 }, 178 }) 179 workId = task.id 180 this.turnTasks.set(turnId, task.id) 181 if (this.turnTasks.size > 100) { 182 this.turnTasks.delete(this.turnTasks.keys().next().value) 183 } 184 return task 185 }
核心实现,逐段拆
▸提交、去重与权限待决
ToolCallHandler 先确认调用属于当前 turn。server/src/voice/tools/tool-call-handler.mjs:187 到 server/src/voice/tools/tool-call-handler.mjs:215 防陈旧调用,server/src/voice/tools/tool-call-handler.mjs:246 到 server/src/voice/tools/tool-call-handler.mjs:278 在权限待决时阻止创建新 work,server/src/voice/tools/tool-call-handler.mjs:376 到 server/src/voice/tools/tool-call-handler.mjs:429 才真正返回 accepted。
246 const pendingPermissionTask = this.taskManager.list({ 247 ownerId: this.ownerId, 248 sessionId: this.sessionId, 249 active: true, 250 }).find(task => task.authorization?.status === 'pending') 251 if (pendingPermissionTask) { 252 await this.sendOutput( 253 callId, 254 { 255 status: 'authorization_pending', 256 error: true, 257 error_code: 'permission_decision_required', 258 authorization_id: pendingPermissionTask.authorization.id, 259 operation: pendingPermissionTask.authorization.summary, 260 user_message: '当前有一项权限请求正在等待用户决定,不能把本轮回答提交成新工作。', 261 retryable: true, 262 }, 263 turnId, 264 pendingPermissionTask.id, 265 { 266 response: { 267 instructions: [ 268 '当前有一项权限请求正在等待决定,本轮不能调用 spawn_thinking。', 269 '重新结合刚才提出的具体权限问题和本轮用户原话判断。', 270 '若用户已自然表达同意或拒绝,立即调用 respond_agent_permission;按语义判断,不要要求固定口令。', 271 '若用户没有作出决定,只用一句自然的话继续确认。', 272 '绝对不要代替用户同意,也不要声称权限已经生效。', 273 ].join(' '), 274 }, 275 }, 276 ) 277 return 278 } 279 280 const resolved = await this.transcripts.resolveDelegation( 281 turnId, 282 args.objective, 283 ) 284 if (this.isStale(turnId, generation)) { 285 await this.closeStaleCall(callId, turnId) 286 return 287 } 288 const originalRequest = String( 289 resolved.originalRequest || resolved.objective || '', 290 ).trim() 291 const objective = String( 292 resolved.objective || resolved.originalRequest || '', 293 ).trim() 294 if (!objective) { 295 await this.sendOutput( 296 callId, 297 failure( 298 'missing_objective', 299 '没有获得完整、可执行的目标,需要用户补充必要信息。', 300 { retryable: true }, 301 ), 302 turnId, 303 ) 304 return 305 } 306 307 let coordinatorReady = false 308 let coordinatorConfigured = true 309 try { 310 const availability = await this.coordinatorAvailable() 311 if (availability && typeof availability === 'object') { 312 coordinatorConfigured = availability.enabled !== false 313 coordinatorReady = ( 314 coordinatorConfigured && availability.ok === true 315 ) 316 } else { 317 coordinatorReady = availability === true 318 } 319 } catch { 320 coordinatorReady = false 321 } 322 if (!coordinatorReady) { 323 const userMessage = coordinatorConfigured 324 ? '后台 Agent 当前未连接。你仍然可以继续普通聊天,后台恢复后再执行这项工作。' 325 : '当前未配置后台 Agent,无法执行需要后台处理的任务。你仍然可以继续普通聊天。' 326 await this.sendOutput( 327 callId, 328 failure( 329 'backend_unavailable',
▸ACP Session 恢复与权限桥接
AcpBackendAdapter 负责让后台 Agent 像长期服务一样运行。server/src/agent/acp-backend-adapter.mjs:280 到 server/src/agent/acp-backend-adapter.mjs:329 先恢复 coordinator session,server/src/agent/acp-backend-adapter.mjs:501 到 server/src/agent/acp-backend-adapter.mjs:604 把 ACP 权限请求转成前台可确认的授权事件。
501 async handlePermission(params, { signal, session } = {}) { 502 const name = clean(params?.toolCall?.name || params?.toolCall?.title) 503 const internal = ACP_SESSION_TOOL_NAMES.some(toolName => ( 504 name === toolName 505 || name.endsWith(`__${toolName}`) 506 || name.startsWith(`${toolName} (`) 507 )) 508 if ( 509 this.permissionMode === 'full' 510 || internal 511 ) { 512 const option = this.optionFor(params, 'always') 513 return option 514 ? { outcome: { outcome: 'selected', optionId: option.optionId } } 515 : { outcome: { outcome: 'cancelled' } } 516 } 517 const id = `auth_${randomUUID().replaceAll('-', '')}` 518 const pending = deferred() 519 const permission = { 520 id, 521 workId: session?.coordinationRunId || null, 522 status: 'pending', 523 category: bounded(name, 80) || 'unknown', 524 summary: [ 525 bounded(name, 80), 526 bounded( 527 params?.toolCall?.rawInput?.description 528 || params?.toolCall?.rawInput?.command 529 || params?.toolCall?.rawInput?.path 530 || '', 531 ), 532 ].filter(Boolean).join(':'), 533 patterns: [], 534 } 535 const record = { 536 ...permission, 537 ownerId: clean(session?.ownerId), 538 sessionId: clean(session?.sessionId), 539 permissionScopeId: clean(session?.permissionScopeId), 540 params, 541 pending, 542 onEvent: session?.onEvent, 543 } 544 this.pendingPermissions.set(id, record) 545 record.onEvent?.({ type: 'backend.permission.requested', permission }) 546 signal?.addEventListener('abort', () => { 547 this.cancelPermission(record) 548 }, { once: true }) 549 return pending.promise 550 } 551 552 cancelPermission(record) { 553 if (!record || !this.pendingPermissions.delete(record.id)) return false 554 record.pending.resolve({ outcome: { outcome: 'cancelled' } }) 555 const permission = { 556 id: record.id, 557 workId: record.workId, 558 status: 'cancelled', 559 category: record.category, 560 summary: record.summary, 561 } 562 record.onEvent?.({ type: 'backend.permission.resolved', permission }) 563 return true 564 } 565 566 async respondPermission(id, decision, { ownerId } = {}) { 567 const record = this.pendingPermissions.get(String(id)) 568 if (!record) { 569 const resolved = this.resolvedPermissions.get(String(id)) 570 if (resolved?.ownerId === clean(ownerId)) return resolved.permission 571 } 572 if (!record || record.ownerId !== clean(ownerId)) { 573 throw new AgentError('权限请求不存在、已经失效或不属于当前用户', { 574 protocol: this.protocol, 575 }) 576 } 577 this.pendingPermissions.delete(record.id) 578 const approved = decision === 'always' 579 const option = this.optionFor( 580 record.params, 581 approved ? 'always' : 'reject', 582 ) 583 record.pending.resolve(option 584 ? { outcome: { outcome: 'selected', optionId: option.optionId } } 585 : { outcome: { outcome: 'cancelled' } }) 586 const permission = { 587 id: record.id, 588 workId: record.workId, 589 status: approved ? 'approved' : 'denied', 590 category: record.category, 591 summary: record.summary, 592 } 593 record.onEvent?.({ type: 'backend.permission.resolved', permission }) 594 this.resolvedPermissions.set(permission.id, { 595 ownerId: record.ownerId, 596 permission, 597 }) 598 while (this.resolvedPermissions.size > 200) { 599 this.resolvedPermissions.delete( 600 this.resolvedPermissions.keys().next().value, 601 ) 602 } 603 return permission 604 }
关键流程
任务生命周期和恢复策略在账本层闭合:server/src/task/task-manager.mjs:93 到 server/src/task/task-manager.mjs:141 处理重启恢复,server/src/task/task-manager.mjs:160 到 server/src/task/task-manager.mjs:188 恢复 delegated work,server/src/task/task-manager.mjs:291 到 server/src/task/task-manager.mjs:317 控制并发启动。
边界 / 失败模式
server/test/acp-backend-adapter.test.mjs:221到server/test/acp-backend-adapter.test.mjs:259验证空 coordinator 响应会开新 Session 重试。server/test/acp-backend-adapter.test.mjs:261到server/test/acp-backend-adapter.test.mjs:307验证 OpenClaw 初始化冲突可在同一 Session 内重试。cli/src/runtime.mjs:351到cli/src/runtime.mjs:389确认已有 Gateway 的 backend 协议、ownership 和凭据状态符合当前 CLI 请求。
委派结果最终仍要回到前端实时协议。src/useRealtimeVoice.js:287 到 src/useRealtimeVoice.js:339 消费 task、timeline、transcript 和音频事件,让后台 work 的完成状态可以同时显示和播报。
权限请求的自然口语确认依赖输入链路可靠。src/useRealtimeVoice.js:414 到 src/useRealtimeVoice.js:448 打开麦克风、重采样并上报 AUDIO_APPEND,保证用户回答“可以”这类短语能进入同一 turn,而不是丢到新的任务里。
后台任务查询和取消不会阻塞语音 UI。src/useRealtimeVoice.js:341 到 src/useRealtimeVoice.js:357 在连接中断时清理播放并重连,src/useRealtimeVoice.js:362 到 src/useRealtimeVoice.js:369 在卸载时关闭资源,防止长任务期间页面切换造成状态悬挂。