OpenClaw Agent 对话调度引擎深度解析:一条消息是如何驱动 LLM 完成一次完整对话的

7 阅读8分钟

前言

用户在 Telegram 里发了一条消息,OpenClaw 的 agent 调用了 3 次工具,最终给出了一段回复——这个过程听起来简单,但藏在背后的是一套超过 1600 行的调度引擎,覆盖了双重任务队列、plugin hook 前置钩子、auth profile 轮转故障转移、思考模式(reasoning)流式输出、工具生命周期事件、压缩重试协调、多 API key 冷却隔离、overflow 截断……

这篇文章把这套引擎从头到尾过一遍,以源码为主,讲清楚每一层的设计决策。


一、整体架构:三层入口

Agent 对话调度引擎的核心由三层模块组成:

runEmbeddedPiAgent()         ← 外层:队列、模型解析、auth、重试主循环
    └─ runEmbeddedAttempt()  ← 内层:单次 LLM 调用
subscribeEmbeddedPiSession() ← 事件层:订阅 LLM session 流式事件

入口函数是 src/agents/pi-embedded-runner/run.ts 里的 runEmbeddedPiAgent。这个文件 1629 行,集中了调度引擎最复杂的逻辑。


二、双重队列:防止并发乱序

export async function runEmbeddedPiAgent(
  params: RunEmbeddedPiAgentParams,
): Promise<EmbeddedPiRunResult> {
  const sessionLane = resolveSessionLane(params.sessionKey?.trim() || params.sessionId);
  const globalLane = resolveGlobalLane(params.lane);
  const enqueueGlobal =
    params.enqueue ?? ((task, opts) => enqueueCommandInLane(globalLane, task, opts));
  const enqueueSession =
    params.enqueue ?? ((task, opts) => enqueueCommandInLane(sessionLane, task, opts));

  return enqueueSession(() =>
    enqueueGlobal(async () => {
      // ... 真正的执行逻辑
    })
  );
}

这是第一个关键设计:双重队列嵌套

每次调用 runEmbeddedPiAgent 时,任务会先被推入 sessionLane(session 级队列),等 session 队列出队后,再被推入 globalLane(全局级队列)。

sessionLane 的作用:同一个 session 的请求串行化。如果用户在上一条消息还没处理完时又发了一条,第二条会等第一条完成后才开始。防止同一 session 的消息并发修改 session 文件,产生竞态。

globalLane 的作用:全局并发限制。不同 session 的请求可以并发,但受全局队列管控,防止过多 LLM 并发请求打爆 provider API 或本地资源。

lane 参数允许外部指定队列类型,比如 subagent 使用 AGENT_LANE_SUBAGENT,在控制面板操控子 agent 时(steer 操作)就会通过 lane: AGENT_LANE_SUBAGENT 走独立的队列通道,不干扰主对话流。


三、运行状态注册表:ACTIVE_EMBEDDED_RUNS

src/agents/pi-embedded-runner/runs.ts 里维护了一个全局单例:

const EMBEDDED_RUN_STATE_KEY = Symbol.for("openclaw.embeddedRunState");

const embeddedRunState = resolveGlobalSingleton(EMBEDDED_RUN_STATE_KEY, () => ({
  activeRuns: new Map<string, EmbeddedPiQueueHandle>(),
  waiters: new Map<string, Set<EmbeddedRunWaiter>>(),
}));
const ACTIVE_EMBEDDED_RUNS = embeddedRunState.activeRuns;

Symbol.for + resolveGlobalSingleton 的原因是:bundler 可能把这个模块打包成多个 chunk,如果每个 chunk 都有自己的 Map,运行状态就会互相不一致。用 Symbol.for 把状态挂在 globalThis 上,确保跨 chunk 共享同一个实例。

每个活跃的 run 对应一个 EmbeddedPiQueueHandle

type EmbeddedPiQueueHandle = {
  queueMessage: (text: string) => Promise<void>;
  isStreaming: () => boolean;
  isCompacting: () => boolean;
  abort: () => void;
};

这四个方法暴露了对一个运行中 run 的完整控制能力:

  • queueMessage:在当前 run 的 agent session 里插入一条新消息(用于 steer 操作)
  • isStreaming:检查 run 是否正在接收 LLM 流式响应
  • isCompacting:检查是否正在做 context 压缩
  • abort:中止当前 run(触发 AbortSignal)

abortEmbeddedPiRun 支持三种调用方式:

// 中止单个 run
abortEmbeddedPiRun(sessionId);

// 中止所有正在压缩的 run(系统重启时用)
abortEmbeddedPiRun(undefined, { mode: "compacting" });

// 中止所有 run
abortEmbeddedPiRun(undefined, { mode: "all" });

还有 waitForEmbeddedPiRunEnd,用于等待某个 run 完成:

export function waitForEmbeddedPiRunEnd(sessionId: string, timeoutMs = 15_000): Promise<boolean> {
  if (!sessionId || !ACTIVE_EMBEDDED_RUNS.has(sessionId)) {
    return Promise.resolve(true);
  }
  return new Promise((resolve) => {
    const waiters = EMBEDDED_RUN_WAITERS.get(sessionId) ?? new Set();
    const waiter: EmbeddedRunWaiter = {
      resolve,
      timer: setTimeout(
        () => {
          waiters.delete(waiter);
          if (waiters.size === 0) {
            EMBEDDED_RUN_WAITERS.delete(sessionId);
          }
          resolve(false); // timeout → false
        },
        Math.max(100, timeoutMs),
      ),
    };
    waiters.add(waiter);
    EMBEDDED_RUN_WAITERS.set(sessionId, waiters);
  });
}

这个 waiter 机制用于 subagent steer 操作:控制端先调用 agent.wait gateway 方法,等待旧的 run 结束,再发起新的 steer 消息,确保新旧 run 之间不重叠。


四、Plugin Hook 前置钩子:模型解析拦截

在真正发起 LLM 调用之前,有两个 plugin hook 点:

const hookRunner = getGlobalHookRunner();
const hookCtx = {
  agentId: workspaceResolution.agentId,
  sessionKey: params.sessionKey,
  sessionId: params.sessionId,
  workspaceDir: resolvedWorkspace,
  messageProvider: params.messageProvider ?? undefined,
  trigger: params.trigger,
  channelId: params.messageChannel ?? params.messageProvider ?? undefined,
};

// 新 hook(优先级更高)
if (hookRunner?.hasHooks("before_model_resolve")) {
  try {
    modelResolveOverride = await hookRunner.runBeforeModelResolve(
      { prompt: params.prompt },
      hookCtx,
    );
  } catch (hookErr) {
    log.warn(`before_model_resolve hook failed: ${String(hookErr)}`);
  }
}

// 兼容旧 hook
if (hookRunner?.hasHooks("before_agent_start")) {
  try {
    legacyBeforeAgentStartResult = await hookRunner.runBeforeAgentStart(
      { prompt: params.prompt },
      hookCtx,
    );
    modelResolveOverride = {
      providerOverride:
        modelResolveOverride?.providerOverride ??
        legacyBeforeAgentStartResult?.providerOverride,
      modelOverride:
        modelResolveOverride?.modelOverride ?? legacyBeforeAgentStartResult?.modelOverride,
    };
  } catch (hookErr) {
    log.warn(`before_agent_start hook (legacy model resolve path) failed: ${String(hookErr)}`);
  }
}

if (modelResolveOverride?.providerOverride) {
  provider = modelResolveOverride.providerOverride;
  log.info(`[hooks] provider overridden to ${provider}`);
}
if (modelResolveOverride?.modelOverride) {
  modelId = modelResolveOverride.modelOverride;
  log.info(`[hooks] model overridden to ${modelId}`);
}

这两个 hook 允许插件在模型解析之前拦截并修改 provider/model。典型用途:根据消息来源(渠道类型、用户角色)动态切换模型,比如来自某个特定 Telegram 群的消息用 Claude,来自内部 Discord 的用 GPT-4。

before_model_resolve 是新版 hook,before_agent_start 是旧版,两者都存在时,新版优先级更高(新版的值不会被旧版覆盖)。


五、模型解析与上下文窗口守卫

const { model, error, authStorage, modelRegistry } = resolveModel(
  provider,
  modelId,
  agentDir,
  params.config,
);
if (!model) {
  throw new FailoverError(error ?? `Unknown model: ${provider}/${modelId}`, {
    reason: "model_not_found",
    provider,
    model: modelId,
  });
}

const ctxInfo = resolveContextWindowInfo({
  cfg: params.config,
  provider,
  modelId,
  modelContextWindow: model.contextWindow,
  defaultTokens: DEFAULT_CONTEXT_TOKENS,
});

// 如果配置了 contextTokens 上限,把它写入模型定义
const effectiveModel =
  ctxInfo.tokens < (model.contextWindow ?? Infinity)
    ? { ...model, contextWindow: ctxInfo.tokens }
    : model;

const ctxGuard = evaluateContextWindowGuard({
  info: ctxInfo,
  warnBelowTokens: CONTEXT_WINDOW_WARN_BELOW_TOKENS,
  hardMinTokens: CONTEXT_WINDOW_HARD_MIN_TOKENS,
});
if (ctxGuard.shouldWarn) {
  log.warn(
    `low context window: ${provider}/${modelId} ctx=${ctxGuard.tokens}...`,
  );
}
if (ctxGuard.shouldBlock) {
  throw new FailoverError(
    `Model context window too small (${ctxGuard.tokens} tokens). Minimum is ${CONTEXT_WINDOW_HARD_MIN_TOKENS}.`,
    { reason: "unknown", provider, model: modelId },
  );
}

这里有个细节:effectiveModelcontextWindow 替换成了配置上限(而非模型原始值)。这确保底层 pi-coding-agent 在决定何时触发 auto-compaction 时,用的是用户配置的上限,而不是模型原生的更大窗口——防止用户明明配置了较小的 context 预算,结果 agent 还是把 context 撑满。


六、Auth Profile 轮转:多 API key 故障转移

这是 runEmbeddedPiAgent 里最复杂的部分之一。OpenClaw 支持配置多个 API key(auth profile),当一个 key 遭遇 rate limit 或 billing 错误时,自动切换到下一个。

const profileOrder = resolveAuthProfileOrder({
  cfg: params.config,
  store: authStore,
  provider,
  preferredProfile: preferredProfileId,
});

const profileCandidates = lockedProfileId
  ? [lockedProfileId]
  : profileOrder.length > 0
    ? profileOrder
    : [undefined];
let profileIndex = 0;

profileCandidates 是有序的候选 profile 列表。lockedProfileId 是用户明确指定的 profile(通过 UI 或 API 选择),一旦锁定就不轮转。

每个 profile 有冷却(cooldown)状态,遇到 rate limit 或 billing 错误时会被标记为冷却,在冷却期内跳过:

while (profileIndex < profileCandidates.length) {
  const candidate = profileCandidates[profileIndex];
  const inCooldown =
    candidate && candidate !== lockedProfileId && isProfileInCooldown(authStore, candidate);
  if (inCooldown) {
    if (allowTransientCooldownProbe && !didTransientCooldownProbe) {
      // 特殊情况:所有 profile 都在冷却,但允许探测一次
      didTransientCooldownProbe = true;
      log.warn(`probing cooldowned auth profile for ${provider}/${modelId}...`);
    } else {
      profileIndex += 1;
      continue;
    }
  }
  await applyApiKeyInfo(profileCandidates[profileIndex]);
  break;
}

allowTransientCooldownProbe 是一个特殊逃生阀:当所有 profile 都在冷却,且冷却原因是 rate_limitoverloadedbilling 时,允许探测一次,避免完全阻塞。

对于 GitHub Copilot,API key 是有时效的 OAuth token,需要定期刷新。调度引擎里有一套完整的 Copilot token 刷新机制:

const COPILOT_REFRESH_MARGIN_MS = 5 * 60 * 1000; // 提前 5 分钟刷新

const scheduleCopilotRefresh = (): void => {
  const now = Date.now();
  const refreshAt = copilotTokenState.expiresAt - COPILOT_REFRESH_MARGIN_MS;
  const delayMs = Math.max(COPILOT_REFRESH_MIN_DELAY_MS, refreshAt - now);
  const timer = setTimeout(() => {
    refreshCopilotToken("scheduled")
      .then(() => scheduleCopilotRefresh()) // 刷新后重新调度
      .catch(() => {
        // 失败后 60s 重试一次
        setTimeout(() => refreshCopilotToken("scheduled-retry")
          .then(() => scheduleCopilotRefresh())
          .catch(() => undefined),
        COPILOT_REFRESH_RETRY_MS);
      });
  }, delayMs);
};

这是一个自我调度的定时器:刷新成功后立刻安排下一次刷新,失败了等 60 秒重试一次。


七、重试主循环:32–160 次的弹性重试

const BASE_RUN_RETRY_ITERATIONS = 24;
const RUN_RETRY_ITERATIONS_PER_PROFILE = 8;
const MIN_RUN_RETRY_ITERATIONS = 32;
const MAX_RUN_RETRY_ITERATIONS = 160;

function resolveMaxRunRetryIterations(profileCandidateCount: number): number {
  const scaled =
    BASE_RUN_RETRY_ITERATIONS +
    Math.max(1, profileCandidateCount) * RUN_RETRY_ITERATIONS_PER_PROFILE;
  return Math.min(MAX_RUN_RETRY_ITERATIONS, Math.max(MIN_RUN_RETRY_ITERATIONS, scaled));
}

最大重试次数根据 profile 数量动态计算:基础 24 次 + 每个 profile 8 次,最少 32 次最多 160 次。profile 越多,允许的重试越多——因为多 profile 的场景意味着轮转本身就消耗迭代次数。

重试主循环里有多个 continue 出口,每种错误类型走不同的分支:

过载 backoff(overload failover):

const OVERLOAD_FAILOVER_BACKOFF_POLICY: BackoffPolicy = {
  initialMs: 250,
  maxMs: 1_500,
  factor: 2,
  jitter: 0.2,
};

const maybeBackoffBeforeOverloadFailover = async (reason: FailoverReason | null) => {
  if (reason !== "overloaded") {
    return;
  }
  overloadFailoverAttempts += 1;
  const delayMs = computeBackoff(OVERLOAD_FAILOVER_BACKOFF_POLICY, overloadFailoverAttempts);
  log.warn(`overload backoff before failover for ${provider}/${modelId}: attempt=${overloadFailoverAttempts} delayMs=${delayMs}`);
  await sleepWithAbort(delayMs, params.abortSignal);
};

遇到 provider 过载时,不是立刻重试,而是指数退避(250ms → 500ms → 1000ms → 1500ms)后再切到下一个 profile,避免打爆 provider。

Context overflow compaction(溢出压缩):

const MAX_OVERFLOW_COMPACTION_ATTEMPTS = 3;

当 context 溢出时(isLikelyContextOverflowError),触发 overflow compaction,最多尝试 3 次。每次 compaction 后重新计算 token 数,再次尝试。


八、事件订阅层:subscribeEmbeddedPiSession

src/agents/pi-embedded-subscribe.ts 是整个流式输出系统的核心,726 行。

它通过 params.session.subscribe(handler) 订阅底层 pi-agent session 的事件流,把原始事件转换成人类可读的回复:

const sessionUnsubscribe = params.session.subscribe(createEmbeddedPiSessionEventHandler(ctx));

事件分派器是 src/agents/pi-embedded-subscribe.handlers.ts

export function createEmbeddedPiSessionEventHandler(ctx: EmbeddedPiSubscribeContext) {
  return (evt: EmbeddedPiSubscribeEvent) => {
    switch (evt.type) {
      case "message_start":
        handleMessageStart(ctx, evt as never);
        return;
      case "message_update":
        handleMessageUpdate(ctx, evt as never);
        return;
      case "message_end":
        handleMessageEnd(ctx, evt as never);
        return;
      case "tool_execution_start":
        // fire-and-forget,不阻塞工具摘要发送
        handleToolExecutionStart(ctx, evt as never).catch((err) => {
          ctx.log.debug(`tool_execution_start handler failed: ${String(err)}`);
        });
        return;
      case "tool_execution_update":
        handleToolExecutionUpdate(ctx, evt as never);
        return;
      case "tool_execution_end":
        // fire-and-forget
        handleToolExecutionEnd(ctx, evt as never).catch((err) => {
          ctx.log.debug(`tool_execution_end handler failed: ${String(err)}`);
        });
        return;
      case "agent_start":
        handleAgentStart(ctx);
        return;
      case "auto_compaction_start":
        handleAutoCompactionStart(ctx);
        return;
      case "auto_compaction_end":
        handleAutoCompactionEnd(ctx, evt as never);
        return;
      case "agent_end":
        handleAgentEnd(ctx);
        return;
    }
  };
}

注意 tool_execution_starttool_execution_end 是 async + fire-and-forget,原因是工具摘要发送是"尽力而为"的,不能因为摘要发送慢而阻塞整个工具调用流程。


九、流式消息处理:三阶段 delta buffer

消息流式处理是 handleMessageUpdate 负责的,在 src/agents/pi-embedded-subscribe.handlers.messages.ts

export function handleMessageUpdate(
  ctx: EmbeddedPiSubscribeContext,
  evt: AgentEvent & { message: AgentMessage; assistantMessageEvent?: unknown },
) {
  // ...
  let chunk = "";
  if (evtType === "text_delta") {
    chunk = delta;
  } else if (evtType === "text_start" || evtType === "text_end") {
    if (delta) {
      chunk = delta;
    } else if (content) {
      // 部分 provider 在 text_end 时重发完整内容
      // 只追加尚未见过的部分,保持输出单调递增
      if (content.startsWith(ctx.state.deltaBuffer)) {
        chunk = content.slice(ctx.state.deltaBuffer.length);
      } else if (ctx.state.deltaBuffer.startsWith(content)) {
        chunk = ""; // 内容是已有内容的前缀,跳过
      } else if (!ctx.state.deltaBuffer.includes(content)) {
        chunk = content; // 全新内容
      }
    }
  }

  if (chunk) {
    ctx.state.deltaBuffer += chunk;
    if (ctx.blockChunker) {
      ctx.blockChunker.append(chunk);
    } else {
      ctx.state.blockBuffer += chunk;
    }
  }
  // ...
}

deltaBuffer 是整个消息生命周期内累积的原始 delta(包括 <think> 标签等)。

不同 provider 的流式事件行为差异很大:Anthropic 只发 text_delta,没有 text_end 的重复完整内容;但某些 provider 会在 text_end 时重新发送完整内容。代码里的三个分支 startsWith/deltaBuffer.startsWith/!deltaBuffer.includes 就是为了统一处理这种差异——始终只追加"新的部分",保证输出内容单调递增,不出现重复段落。

思考模式(reasoning)流式处理

if (evtType === "thinking_start" || evtType === "thinking_delta" || evtType === "thinking_end") {
  ctx.state.reasoningStreamOpen = true;
  const thinkingDelta = typeof assistantRecord?.delta === "string" ? assistantRecord.delta : "";
  const thinkingContent =
    typeof assistantRecord?.content === "string" ? assistantRecord.content : "";
  if (ctx.state.streamReasoning) {
    const partialThinking = extractAssistantThinking(msg);
    ctx.emitReasoningStream(partialThinking || thinkingContent || thinkingDelta);
  }
  if (evtType === "thinking_end") {
    emitReasoningEnd(ctx);
  }
  return;
}

原生思考事件(Anthropic extended thinking)和 <think> 标签都被处理了。streamReasoning 模式下,思考内容会通过 onReasoningStream 回调实时推送,同时通过 emitAgentEvent 广播到 WebSocket 客户端:

const emitReasoningStream = (text: string) => {
  emitAgentEvent({
    runId: params.runId,
    stream: "thinking",
    data: {
      text: formatted,
      delta,
    },
  });
  void params.onReasoningStream({ text: formatted });
};

<think><final> 标签的流式剥离

const stripBlockTags = (
  text: string,
  state: { thinking: boolean; final: boolean; inlineCode?: InlineCodeState },
): string => {
  // 1. 处理 <think> 块(有状态,跨 chunk)
  let processed = "";
  THINKING_TAG_SCAN_RE.lastIndex = 0;
  let inThinking = state.thinking;
  for (const match of text.matchAll(THINKING_TAG_SCAN_RE)) {
    const idx = match.index ?? 0;
    if (codeSpans.isInside(idx)) {
      continue; // 代码块内的 <think> 不处理
    }
    if (!inThinking) {
      processed += text.slice(lastIndex, idx);
    }
    const isClose = match[1] === "/";
    inThinking = !isClose;
    lastIndex = idx + match[0].length;
  }
  if (!inThinking) {
    processed += text.slice(lastIndex);
  }
  state.thinking = inThinking; // 状态跨 chunk 传递

  // 2. 处理 <final> 块(只输出 <final> 内部内容)
  // ...
};

这个函数有状态(stateful):state.thinking 记录当前是否处于 <think> 块内部,跨 chunk 传递。这处理了"标签横跨两个 chunk"的情况——比如第一个 chunk 末尾是 <thi,第二个 chunk 开头是 nk>,两个合并才构成一个完整的 <think> 开始标签。

同时有代码块感知:代码块内的 <think> 标签(比如用户贴了包含 <think> 的代码示例)不会被误处理,通过 buildCodeSpanIndex 维护内联代码状态(```` 两种形式)。


十、消息结束处理:handleMessageEnd 的细节

export function handleMessageEnd(
  ctx: EmbeddedPiSubscribeContext,
  evt: AgentEvent & { message: AgentMessage },
) {
  const msg = evt.message;
  ctx.recordAssistantUsage((assistantMessage as { usage?: unknown }).usage);
  
  const rawText = extractAssistantText(assistantMessage);
  const text = resolveSilentReplyFallbackText({
    text: ctx.stripBlockTags(rawText, { thinking: false, final: false }),
    messagingToolSentTexts: ctx.state.messagingToolSentTexts,
  });
  // ...
}

resolveSilentReplyFallbackText 处理了一个特殊 token:SILENT_REPLY_TOKEN。当 agent 回复内容是这个特殊 token 时,意味着 agent 主动表示"不需要额外回复",但如果此前有 messaging 工具成功发送了消息,就用那条消息的文本作为回复内容——确保用户侧不会收到空响应。

recordAssistantUsage 把本次 API 调用的 token 用量合并进 usageTotals,但有一个重要的设计:

const mergeUsageIntoAccumulator = (
  target: UsageAccumulator,
  usage: ReturnType<typeof normalizeUsage>,
) => {
  // ...
  // 只保存最近一次调用的 cache 相关字段(不累积)
  target.lastCacheRead = usage.cacheRead ?? 0;
  target.lastCacheWrite = usage.cacheWrite ?? 0;
  target.lastInput = usage.input ?? 0;
};

为什么 cacheRead/cacheWrite 不累积?因为每次工具调用后的 LLM 请求,API 返回的 cacheRead ≈ 当前 context 大小(缓存命中的 token 数)。如果累积 N 次调用的 cacheRead,会得到 N × context_size,比真实的 context 大小大 N 倍,导致 session 的 totalTokens 统计严重虚高。正确做法是只用最后一次调用的 cache 字段来计算 context 大小。


十一、工具生命周期事件:handleToolExecutionStart/End

工具执行在 src/agents/pi-embedded-subscribe.handlers.tools.ts

工具开始

export async function handleToolExecutionStart(
  ctx: ToolHandlerContext,
  evt: AgentEvent & { toolName: string; toolCallId: string; args: unknown },
) {
  // 1. 在工具执行前 flush 挂起的 block reply
  ctx.flushBlockReplyBuffer();
  if (ctx.params.onBlockReplyFlush) {
    await ctx.params.onBlockReplyFlush();
  }

  // 2. 记录工具开始时间和参数(用于 after_tool_call hook)
  toolStartData.set(buildToolStartKey(runId, toolCallId), { startTime: Date.now(), args });

  // 3. 广播工具开始事件到 WebSocket
  emitAgentEvent({
    runId: ctx.params.runId,
    stream: "tool",
    data: {
      phase: "start",
      name: toolName,
      toolCallId,
      args: args as Record<string, unknown>,
    },
  });

  // 4. 追踪 messaging 工具的待定消息
  if (isMessagingTool(toolName)) {
    const text = (argsRecord.content as string) ?? (argsRecord.message as string);
    if (text) {
      ctx.state.pendingMessagingTexts.set(toolCallId, text);
    }
    const mediaUrls = collectMessagingMediaUrlsFromRecord(argsRecord);
    if (mediaUrls.length > 0) {
      ctx.state.pendingMessagingMediaUrls.set(toolCallId, mediaUrls);
    }
  }
}

flush block reply 是为了确保工具执行前,assistant 已经把前置文字发送出去了。否则用户可能先看到工具结果,再看到导引文字,顺序颠倒。

Messaging 工具的两阶段提交

这是一个很精妙的设计。当 agent 通过 messaging 工具(比如 telegramdiscord)发送消息时:

工具开始时:把消息文本放入 pendingMessagingTexts(pending 状态)

工具结束时

const pendingText = ctx.state.pendingMessagingTexts.get(toolCallId);
if (pendingText) {
  ctx.state.pendingMessagingTexts.delete(toolCallId);
  if (!isToolError) {
    ctx.state.messagingToolSentTexts.push(pendingText);
    ctx.state.messagingToolSentTextsNormalized.push(normalizeTextForComparison(pendingText));
    ctx.trimMessagingToolSent();
  }
  // 工具失败:丢弃,不提交
}

如果工具执行成功,文本从 pending 提升为 committed(messagingToolSentTexts)。如果工具失败,pending 被丢弃。

这个机制的用途:防止重复回复。当 agent 已经通过 messaging 工具发了一条消息,如果 handleMessageEnd 里的 assistant 文本内容和这条消息内容相同(正常情况,agent 发完消息后会回复一句确认),就静默跳过,不再重复发送:

const normalizedText = normalizeTextForComparison(text);
if (isMessagingToolDuplicateNormalized(normalizedText, ctx.state.messagingToolSentTextsNormalized)) {
  log.debug(`Skipping message_end block reply - already sent via messaging tool: ${text.slice(0, 50)}...`);
}

after_tool_call Plugin Hook

const hookRunnerAfter = ctx.hookRunner ?? getGlobalHookRunner();
if (hookRunnerAfter?.hasHooks("after_tool_call")) {
  const durationMs = startData?.startTime != null ? Date.now() - startData.startTime : undefined;
  const hookEvent: PluginHookAfterToolCallEvent = {
    toolName,
    params: afterToolCallArgs,
    runId,
    toolCallId,
    result: sanitizedResult,
    error: isToolError ? extractToolErrorMessage(sanitizedResult) : undefined,
    durationMs,
  };
  void hookRunnerAfter.runAfterToolCall(hookEvent, {
    toolName,
    agentId: ctx.params.agentId,
    sessionKey: ctx.params.sessionKey,
    sessionId: ctx.params.sessionId,
    runId,
    toolCallId,
  }).catch((err) => {
    ctx.log.warn(`after_tool_call hook failed: tool=${toolName} error=${String(err)}`);
  });
}

after_tool_call hook 是完全 fire-and-forget 的。插件可以通过这个 hook 做审计、统计、二次处理(比如把工具结果同步到外部系统)。durationMs 记录了工具执行耗时,方便插件做性能监控。


十二、Agent 生命周期事件:handleAgentStart / handleAgentEnd

export function handleAgentStart(ctx: EmbeddedPiSubscribeContext) {
  ctx.log.debug(`embedded run agent start: runId=${ctx.params.runId}`);
  emitAgentEvent({
    runId: ctx.params.runId,
    stream: "lifecycle",
    data: {
      phase: "start",
      startedAt: Date.now(),
    },
  });
  void ctx.params.onAgentEvent?.({
    stream: "lifecycle",
    data: { phase: "start" },
  });
}

handleAgentEnd 更复杂,需要判断是否以错误结束:

export function handleAgentEnd(ctx: EmbeddedPiSubscribeContext) {
  const lastAssistant = ctx.state.lastAssistant;
  const isError = isAssistantMessage(lastAssistant) && lastAssistant.stopReason === "error";

  if (isError && lastAssistant) {
    const errorText = (friendlyError || lastAssistant.errorMessage || "LLM request failed.").trim();
    emitAgentEvent({
      runId: ctx.params.runId,
      stream: "lifecycle",
      data: {
        phase: "error",
        error: safeErrorText,
        endedAt: Date.now(),
      },
    });
  } else {
    emitAgentEvent({
      runId: ctx.params.runId,
      stream: "lifecycle",
      data: { phase: "end", endedAt: Date.now() },
    });
  }

  ctx.flushBlockReplyBuffer();
  void ctx.params.onBlockReplyFlush?.();

  // 触发压缩重试 resolve
  if (ctx.state.pendingCompactionRetry > 0) {
    ctx.resolveCompactionRetry();
  } else {
    ctx.maybeResolveCompactionWait();
  }
}

agent_end 时触发 resolveCompactionRetry,这是压缩重试协调的核心:如果上一次尝试触发了 overflow compaction,并且这次 agent run 结束了,就把挂起的 compaction promise resolve 掉,让外层的重试循环知道可以继续了。


十三、压缩重试协调机制

压缩(compaction)是 context overflow 时的自动救援机制。subscribeEmbeddedPiSession 里维护了一套精心设计的 Promise 协调机制:

const ensureCompactionPromise = () => {
  if (!state.compactionRetryPromise) {
    state.compactionRetryPromise = new Promise((resolve, reject) => {
      state.compactionRetryResolve = resolve;
      state.compactionRetryReject = reject;
    });
    // 防止无 awaiter 时的 UnhandledRejection
    state.compactionRetryPromise.catch((err) => {
      log.debug(`compaction promise rejected (no waiter): ${String(err)}`);
    });
  }
};

const resolveCompactionRetry = () => {
  if (state.pendingCompactionRetry <= 0) {
    return;
  }
  state.pendingCompactionRetry -= 1;
  if (state.pendingCompactionRetry === 0 && !state.compactionInFlight) {
    state.compactionRetryResolve?.();
    state.compactionRetryPromise = null;
  }
};

pendingCompactionRetry 是计数器,记录有多少次压缩正在等待 resolve。每当 auto-compaction 完成时,通过 resolveCompactionRetry 递减计数器,当计数器归零且没有压缩 in-flight 时,resolve promise,通知外层可以继续。

外层通过 waitForCompactionRetry() 等待:

waitForCompactionRetry: () => {
  if (state.unsubscribed) {
    const err = new Error("Unsubscribed during compaction wait");
    err.name = "AbortError";
    return Promise.reject(err);
  }
  if (state.compactionInFlight || state.pendingCompactionRetry > 0) {
    ensureCompactionPromise();
    return state.compactionRetryPromise ?? Promise.resolve();
  }
  return new Promise<void>((resolve, reject) => {
    queueMicrotask(() => {
      if (state.unsubscribed) {
        reject(new AbortError());
        return;
      }
      if (state.compactionInFlight || state.pendingCompactionRetry > 0) {
        void (state.compactionRetryPromise ?? Promise.resolve()).then(resolve, reject);
      } else {
        resolve();
      }
    });
  });
},

queueMicrotask 里的二次检查处理了竞态:在 waitForCompactionRetry 调用和内部 Promise 创建之间,如果状态已经变化了(比如压缩刚好在这个 tick 完成),就直接 resolve,而不是挂起等待。

取消订阅时需要特别处理:

const unsubscribe = () => {
  if (state.unsubscribed) {
    return;
  }
  state.unsubscribed = true;
  if (state.compactionRetryPromise) {
    // 用 AbortError reject,让调用方知道是取消而不是成功
    const abortErr = new Error("Unsubscribed during compaction");
    abortErr.name = "AbortError";
    reject?.(abortErr);
  }
  if (params.session.isCompacting) {
    params.session.abortCompaction();
  }
  sessionUnsubscribe();
};

取消时 reject(而非 resolve),防止调用方误以为压缩成功完成后继续执行。


十四、Subagent 调度控制:steer 机制

当用户想改变正在运行的 subagent 的方向时,触发 steer 操作,实现在 src/agents/subagent-control.ts

export async function steerControlledSubagentRun(params: {
  cfg: OpenClawConfig;
  controller: ResolvedSubagentController;
  entry: SubagentRunRecord;
  message: string;
}) {
  // 1. 标记为待 steer restart
  markSubagentRunForSteerRestart(params.entry.runId);

  // 2. 中止当前 run 的嵌入式执行
  if (sessionId) {
    abortEmbeddedPiRun(sessionId);
  }

  // 3. 清理 session 队列里的待处理消息
  const cleared = clearSessionQueues([params.entry.childSessionKey, sessionId]);

  // 4. 等待当前 run 完全停止
  try {
    await callGateway({
      method: "agent.wait",
      params: {
        runId: params.entry.runId,
        timeoutMs: STEER_ABORT_SETTLE_TIMEOUT_MS, // 5s
      },
      timeoutMs: STEER_ABORT_SETTLE_TIMEOUT_MS + 2_000,
    });
  } catch {
    // 等待失败也继续
  }

  // 5. 发送新的 steer 消息
  const idempotencyKey = crypto.randomUUID();
  const response = await callGateway<{ runId: string }>({
    method: "agent",
    params: {
      message: params.message,
      sessionKey: params.entry.childSessionKey,
      sessionId,
      idempotencyKey,
      deliver: false,
      channel: INTERNAL_MESSAGE_CHANNEL,
      lane: AGENT_LANE_SUBAGENT,
      timeout: 0,
    },
    timeoutMs: 10_000,
  });

  // 6. 替换 registry 里的 run 记录
  replaceSubagentRunAfterSteer({
    previousRunId: params.entry.runId,
    nextRunId: runId,
    fallback: params.entry,
    runTimeoutSeconds: params.entry.runTimeoutSeconds ?? 0,
  });

  return {
    status: "accepted",
    mode: "restart",
    label: resolveSubagentLabel(params.entry),
    text: `steered ${resolveSubagentLabel(params.entry)}.`,
  };
}

steer 的完整流程:标记 → 中止 → 清队列 → 等待停止 → 发新消息 → 更新 registry。每一步都是必要的——如果跳过"等待停止",新消息可能和旧 run 的尾部处理并发,产生竞态;如果不清队列,旧消息的回调还会触发,导致状态混乱。

steer 有速率限制:

const STEER_RATE_LIMIT_MS = 2_000;

const rateKey = `${params.controller.callerSessionKey}:${params.entry.childSessionKey}`;
const now = Date.now();
const lastSentAt = steerRateLimit.get(rateKey) ?? 0;
if (now - lastSentAt < STEER_RATE_LIMIT_MS) {
  return {
    status: "rate_limited",
    error: "Steer rate limit exceeded. Wait a moment before sending another steer.",
  };
}
steerRateLimit.set(rateKey, now);

2 秒内只能 steer 同一个 subagent 一次,防止快速连续 steer 产生 run 替换竞态。


十五、Token 用量统计的正确姿势

UsageAccumulator 是一个需要特别说明的设计:

type UsageAccumulator = {
  input: number;
  output: number;
  cacheRead: number;
  cacheWrite: number;
  total: number;
  lastCacheRead: number;  // 最近一次 API 调用的 cache 字段
  lastCacheWrite: number;
  lastInput: number;
};

最终的 total 计算用的是"最后一次 API 调用"的 prompt tokens:

const toNormalizedUsage = (usage: UsageAccumulator) => {
  const lastPromptTokens = usage.lastInput + usage.lastCacheRead + usage.lastCacheWrite;
  return {
    input: usage.lastInput || undefined,
    output: usage.output || undefined,     // output 是累积的
    cacheRead: usage.lastCacheRead || undefined,
    cacheWrite: usage.lastCacheWrite || undefined,
    total: lastPromptTokens + usage.output || undefined, // prompt 用最后一次,output 用累积
  };
};

为什么 output 累积而 input/cache 不累积?

  • output:每次 LLM round-trip 生成的新 token 是独立的,应该累积(一次对话里总共生成了多少 token)
  • input/cacheRead/cacheWrite:每次调用的 input ≈ 当前 context 大小,多次累积会错误地放大 context size 的统计。最后一次调用的 input 才是对话当前的实际 context 大小

这个细节在代码注释里有完整说明,解决了真实 issue #13698。


小结

OpenClaw 的 agent 对话调度引擎是一套精心设计的异步状态机,可以用几条核心原则概括:

1. 双重队列保证顺序
session 级队列 + 全局级队列,既防止同一 session 并发,又全局限速。

2. 主循环驱动所有重试
一个 while(true) 主循环统一处理:auth profile 轮转、context overflow compaction、overload 退避、工具截断……每种失败都在同一个循环里用不同的 continue 分支处理,逻辑集中。

3. 事件驱动的流式处理
底层 session 发出的原始事件(message_start/update/end、tool_*、agent_start/end)经过 handlers 层翻译成用户侧的回复流,关注点分离。

4. Promise 协调压缩重试
compaction 和主 run 之间通过 Promise + 计数器协调,避免了轮询,也正确处理了取消和竞态。

5. Two-phase commit 防止消息重复
messaging 工具发送的消息通过 pending → committed 两阶段提交,只在工具成功后才对去重逻辑可见,失败时丢弃 pending 状态,不会误抑制正常回复。


本文涉及的主要源文件: