Skip to content

单轮 attempt:模型调用与工具配对

源码版本v2026.6.11

职责

如果 embedded-runnerwhile (true) 是"今天要做几次",那么 runEmbeddedAttempt 就是"这一次具体怎么做"。它位于 attempt.ts:837,职责是把一次 attempt 内的所有动作打包:解析 sandbox、加载工具目录、组装 system prompt、把模型 stream 函数挂到 agent 上、提交 prompt、收尾持久化。

它本身不决定要不要重试——重试是外层循环的事。它只负责把"这一次"完整跑下来,并在内部处理三类细节:tool_use / tool_result 的配对修复、上下文溢出的就地压缩、tool 执行回填。

设计动机

为什么需要单独抽一个 runEmbeddedAttempt 函数?因为它做的事比"调一次模型"复杂得多。一次 attempt 涉及:

  • 沙箱 (sandbox) 解析:resolveSandboxContext 决定本次的 effectiveWorkspaceeffectiveCwd,沙箱启用时 cwd override 直接报错(见 attempt.ts:904)。
  • stream 函数的多层包装:activeSession.agent.streamFn 在 attempt 里被层层 wrap——文本变换、缓存追踪、Anthropic 恢复、web 搜索包装、malformed tool call 清洗等等。最终调用模型的是这条被多层装饰过的链。
  • 上下文预算预检:在真正提交 prompt 之前,先做一次 shouldPreemptivelyCompactBeforePrompt,如果发现 token 已经超预算,先压缩或截断 tool_result,避免把一个注定会溢出的 prompt 发给模型。
  • tool_use / tool_result 配对修复:provider 流式回来的消息序列不一定干净——有时会有 orphan 的 tool_result(对应 assistant 消息被 limitHistoryTurns 切掉了)。repairAttemptToolUseResultPairing 负责在关键点重新对齐。
  • 持久化与事件订阅:attempt 期间所有 tool 调用、compaction、消息持久化都要走 SessionManager,而且要按 owned-transcript-write-lock 的语义串行。

把这些都塞进 run.tswhile (true) 会让循环体臃肿到不可维护,所以拆出 attempt.ts 单独承担"一次"的复杂度。

关键文件

数据流

runEmbeddedAttempt(attempt.ts:837)的入口只是做最基础的初始化:

typescript
export async function runEmbeddedAttempt(
  params: EmbeddedRunAttemptParams,
): Promise<EmbeddedRunAttemptResult> {
  const resolvedWorkspace = resolveUserPath(params.workspaceDir);
  const runAbortController = new AbortController();
  configureEmbeddedAttemptHttpRuntime({ timeoutMs: params.timeoutMs });

  log.debug(
    `embedded run start: runId=${params.runId} sessionId=${params.sessionId} provider=${params.provider} model=${params.modelId} thinking=${params.thinkLevel} messageChannel=${params.messageChannel ?? params.messageProvider ?? "unknown"}`,
  );

注意 configureEmbeddedAttemptHttpRuntime({ timeoutMs: params.timeoutMs })——这把本次 attempt 的超时绑到 HTTP runtime 上,使 provider 的 HTTP 调用和 attempt 本身共用同一个超时时钟,不会出现"attempt 已 abort 但 HTTP 还在跑"的脱节。

接下来是 sandbox 解析(attempt.ts:891):

typescript
const sandboxSessionKey =
  params.sandboxSessionKey?.trim() || params.sessionKey?.trim() || params.sessionId;
const sandbox = await resolveSandboxContext({
  config: params.config,
  sessionKey: sandboxSessionKey,
  workspaceDir: resolvedWorkspace,
});
const effectiveWorkspace = sandbox?.enabled
  ? sandbox.workspaceAccess === "rw"
    ? resolvedWorkspace
    : sandbox.workspaceDir
  : resolvedWorkspace;
const requestedCwd = params.cwd ? resolveUserPath(params.cwd) : undefined;
if (sandbox?.enabled && requestedCwd && requestedCwd !== resolvedWorkspace) {
  throw new Error(
    "cwd override is not supported for sandboxed embedded agent runs; omit cwd or use the agent workspace as cwd",
  );
}

这里有个常被忽略的细节:沙箱启用时 cwd override 会被硬性拒绝。这是为了避免"agent 以为自己在 workspace A,实际工具跑在沙箱 B"的认知错位——沙箱模式下 cwd 必须和 workspace 一致。

stream 函数的组装(attempt.ts:2844)把 provider 的原生 stream 函数挂到 agent 上,然后做多层 wrap:

typescript
const providerStreamFn = registerProviderStreamForModel({
  model: params.model,
  cfg: params.config,
  agentDir,
  workspaceDir: effectiveWorkspace,
});
const streamStrategy = describeEmbeddedAgentStreamStrategy({
  currentStreamFn: defaultSessionStreamFn,
  providerStreamFn,
  model: params.model,
  resolvedApiKey: params.resolvedApiKey,
});
activeSession.agent.streamFn = resolveEmbeddedAgentStreamFn({
  currentStreamFn: defaultSessionStreamFn,
  providerStreamFn,
  sessionId: params.sessionId,
  promptCacheKey: params.promptCacheKey,
  signal: runAbortController.signal,
  model: params.model,
  resolvedApiKey: params.resolvedApiKey,
  authProfileId: resolveAttemptStreamAuthProfileId(params),
  authStorage: params.authStorage,
});

紧接着会被 wrapStreamFnTextTransforms(provider 文本变换)、wrapStreamFnSanitizeMalformedToolCalls(清洗非法 tool call)、wrapStreamFnPromoteStandaloneTextToolCalls(把孤立文本提升成 tool call)、wrapStreamFnTrimToolCallNames(规整工具名)等多次包装(见 attempt.ts:3067-3143)。每层 wrap 都解决一个 provider 兼容性问题——比如 xAI 的 tool call 参数需要单独 decode,Google 的 prompt cache 需要预热,Anthropic 的 stream 需要恢复逻辑。

prompt 提交是 attempt 的核心(attempt.ts:3421):

typescript
const promptActiveSession = (
  prompt: string,
  options?: Parameters<typeof activeSession.prompt>[1],
): Promise<void> =>
  withOwnedSessionTranscriptWrites(ownedTranscriptWriteContext, async () =>
    abortable(trackPromptSettlePromise(activeSession.prompt(prompt, options))),
  );

withOwnedSessionTranscriptWrites 把所有 transcript 写入串到当前 owned context,避免多个并发写入交错;abortable 让这个 promise 跟 runAbortController 绑定;trackPromptSettlePromise 等待 prompt 完全 settle(包括所有 tool 调用完成)再返回。这三层叠加,才让"提交 prompt"在并发和中断语义上是安全的。

在真正提交 prompt 之前,attempt 还要做一次 token 预检(attempt.ts:4599):

typescript
const preemptiveCompaction = skipPromptSubmission
  ? null
  : shouldPreemptivelyCompactBeforePrompt({
      messages: hookMessagesForCurrentPrompt,
      ...(unwindowedLlmBoundaryMessagesForPrecheck
        ? { unwindowedMessages: unwindowedLlmBoundaryMessagesForPrecheck }
        : {}),
      systemPrompt: systemPromptForHook,
      prompt: llmBoundaryPromptForPrecheck,
      contextTokenBudget,
      reserveTokens,
      toolResultMaxChars: promptToolResultMaxChars,
      llmBoundaryTokenPressure: {
        estimatedPromptTokens: llmBoundaryTokenPressure,
        source: "llm_boundary_normalized_prompt",
        renderedChars: llmBoundaryPromptForPrecheck.length,
      },
    });

shouldPreemptivelyCompactBeforePrompt 返回的 preemptiveCompaction 携带 route 字段,决定走哪条省钱路径:truncate_tool_results_only 只截断 oversized tool result(最便宜),compact_then_truncate 是先压缩再截断(中等代价),需要真的调模型压缩时才走重路径。这种"预检 + 分级处理"是 attempt 区别于"无脑发 prompt"的关键。

如果预检发现只截断 tool_result 就够了,attempt 走快速路径:

typescript
if (preemptiveCompaction?.route === "truncate_tool_results_only") {
  const toolResultMaxChars = resolveLiveToolResultMaxChars({
    contextWindowTokens: contextTokenBudget,
    cfg: params.config,
    agentId: sessionAgentId,
  });
  const truncationResult = await withOwnedSessionWriteLock(() =>
    truncateOversizedToolResultsInSessionManager({
      sessionManager: activeSessionManager,
      contextWindowTokens: contextTokenBudget,
      maxCharsOverride: toolResultMaxChars,
      sessionFile: params.sessionFile,
      sessionId: params.sessionId,
      sessionKey: params.sessionKey,
      agentId: sessionAgentId,
    }),
  );
  if (truncationResult.truncated) {
    preflightRecovery = {
      route: "truncate_tool_results_only",
      handled: true,
      truncatedCount: truncationResult.truncatedCount,
    };
    ...
    skipPromptSubmission = true;
  }

注意 skipPromptSubmission = true——截断后本次 attempt 不再发 prompt,而是把控制权还给外层循环,让外层用截断后的 transcript 起一个新的 attempt。这是"原地一次 attempt 就解决"和"外层重试"的分界。

tool 执行包装(attempt.ts:3658)是 tool_use / tool_result 配对的关键一环:

typescript
toolSearchCatalogExecutor = async (toolParams) => {
  try {
    if (toolParams.source === "openclaw" && toolParams.sourceName === "core") {
      recordStructuredReplayTrustForToolCall(
        toolParams.toolCallId,
        toolParams.tool as never,
        params.runId,
      );
    }
    const result = await runToolLifecycle({
      toolName: toolParams.toolName,
      toolCallId: toolParams.toolCallId,
      args: toolParams.input,
      replaySafe: replaySafeTools.has(toolParams.tool as never),
      execute: async () =>
        await toolParams.tool.execute(
          toolParams.toolCallId,
          toolParams.input,
          toolParams.signal ?? runAbortController.signal,
          toolParams.onUpdate,
          undefined as never,
        ),
    });
    toolSearchTargetTranscriptProjections.push({
      parentToolCallId: toolParams.parentToolCallId,
      toolCallId: toolParams.toolCallId,
      toolName: toolParams.toolName,
      input: toolParams.input,
      result,
      timestamp: Date.now(),
    });
    return result;
  } catch (error) {
    ...
    throw error;
  }
};

每次工具执行都走 runToolLifecycle——它统一包了 before_tool_call / after_tool_call 钩子、replay 安全标记、错误格式化。执行结果不只是回给模型,还 push 到 toolSearchTargetTranscriptProjections,这部分会在 attempt 收尾时被持久化到 transcript 里,成为下次 attempt 的历史上下文。

tool_use / tool_result 配对修复(attempt.ts:644)是一个纯函数,在多个关键点被调用:

typescript
function repairAttemptToolUseResultPairing(
  messages: AgentMessage[],
  isOpenAIResponsesApi: boolean,
): AgentMessage[] {
  return sanitizeToolUseResultPairing(messages, {
    erroredAssistantResultPolicy: "drop",
    ...(isOpenAIResponsesApi ? { missingToolResultText: "aborted" } : {}),
  });
}

它的作用是把消息序列里"孤立"的 tool_use 或 tool_result 处理掉——errored assistant 的 tool result 直接 drop,OpenAI Responses API 下缺失的 tool result 用文本 "aborted" 占位。这个修复在历史截断后会重新跑一遍(attempt.ts:3287),因为 limitHistoryTurns 可能切掉某个 assistant 消息,留下它对应的 tool_result 成了无主孤儿。

边界与失败

  • 沙箱与 cwd override 互斥(attempt.ts:904):沙箱模式下传 cwd override 直接抛错,而不是默默忽略。这是为了防止 agent 认知和实际执行目录错位。
  • 预检可以跳过 prompt 提交:skipPromptSubmission = true 的分支(attempt.ts:4683)表示本次 attempt 不发 prompt,只做了 transcript 维护就返回。外层循环看到这个状态会基于截断后的历史起一个新 attempt,而不是当作失败重试。
  • tool 执行错误的 transcript projection:toolSearchTargetTranscriptProjections.push 在 catch 分支里也写一条 isError: true 的记录(attempt.ts:3692),保证 transcript 完整反映工具调用历史,即使失败也不丢上下文。
  • streamFn wrap 的顺序敏感:wrapStreamFnSanitizeMalformedToolCalls 必须在 wrapStreamFnPromoteStandaloneTextToolCalls 之前(见 attempt.ts:3067)——先清洗非法,再做提升,反过来会把非法 tool call 提升成合法的。这种 wrap 顺序是隐式契约,改顺序前要核对相关测试。
  • before_agent_finalize 钩子能触发 revision:runAgentHarnessBeforeAgentFinalizeHook(attempt.ts:3499)在 attempt 即将给出终端回复前跑,钩子可以要求 revision(最多 MAX_BEFORE_AGENT_FINALIZE_REVISIONS 次),每次 revision 都会让 attempt 再多走一圈工具循环。
  • owned transcript write lock 贯穿全 attempt:promptActiveSessionwithOwnedSessionTranscriptWrites 包,所有 tool 执行也走 owned context。这保证 attempt 期间即使有并发的 compaction 或外部 session 写入,transcript 写入顺序仍然由本 attempt 主导,不会交错污染。
  • attempt 失败的 cleanup 优先级:cleanup 错误(EmbeddedAttemptSessionTakeoverError)在 prompt error 之前被保留(attempt.ts:654),因为 takeover 是更严重的状态——下次开 session 时必须知道上一次是被外部接管而非自然结束。

小结

runEmbeddedAttempt 是"一次模型调用 + 工具循环"的完整打包。它把 sandbox 解析、streamFn 多层包装、prompt 预检、tool 执行 transcript projection、tool_use / tool_result 配对修复全部封进一个函数。外层循环只看到 attempt 的结果分类(终端回复 / 需重试 / 需压缩 / 需 failover),不用关心内部细节。

外层 while (true) 怎么调度这个 attempt,见 Agent 主循环:embedded-runner;工具定义和工具策略见 工具系统;各 provider 的 stream 函数接入见 Provider 接入;上下文压缩和 transcript 维护的更深层机制见 上下文引擎

对照官方资料:Attempt 文档 · README