qwen-audio-agentAudio
§ · Nested detail page

ACP 委派与任务账本 深度解析

从真实源码拆解 qwen-audio-agent 的关键子系统。

§ 00 · IMPORT CHAIN

导入链与契约

后台执行链从前台工具调用开始,但真正的可靠性来自任务账本和 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 持久化任务公共快照和调度字段。

src/voice/tools/tool-call-handler.mjs · L138-185JavaScript
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  }
§ 01 · CORE IMPL

核心实现,逐段拆

▸提交、去重与权限待决

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。

src/voice/tools/tool-call-handler.mjs · L246-429JavaScript
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 权限请求转成前台可确认的授权事件。

src/agent/acp-backend-adapter.mjs · L501-604JavaScript
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  }
§ 02 · KEY FLOW

关键流程

spawn_thinking call → createWork → TaskScheduler lane → coordinator.run → ACP coordinator Session → startSession/sendSession project Session → task.delegated → completed/finalizing result → notification claim → voice announcement

任务生命周期和恢复策略在账本层闭合: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 控制并发启动。

§ 03 · EDGE CASES

边界 / 失败模式

  • 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 在卸载时关闭资源,防止长任务期间页面切换造成状态悬挂。