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 的配對修復、上下文溢出的就地壓縮 (compaction)、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 就解決」和「外層重試」的分界。

工具執行包裝(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