單輪 attempt:模型呼叫與工具配對
職責
如果 embedded-runner 的 while (true) 是「今天要做幾次」,那麼 runEmbeddedAttempt 就是「這一次具體怎麼做」。它位於 attempt.ts:837,職責是把一次 attempt 內的所有動作打包:解析 sandbox、載入工具目錄、組裝 system prompt、把模型 stream 函式掛到 agent 上、提交 prompt、收尾持久化。
它本身不決定要不要重試——重試是外層循環的事。它只負責把「這一次」完整跑下來,並在內部處理三類細節:tool_use / tool_result 的配對修復、上下文溢出的就地壓縮 (compaction)、tool 執行回填。
設計動機
為什麼需要單獨拉一個 runEmbeddedAttempt 函式?因為它做的事比「呼叫一次模型」複雜得多。一次 attempt 涉及:
- 沙箱 (sandbox) 解析:
resolveSandboxContext決定本次的effectiveWorkspace和effectiveCwd,沙箱啟用時 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.ts 的 while (true) 會讓循環體臃腫到不可維護,所以拆出 attempt.ts 單獨承擔「一次」的複雜度。
關鍵檔案
attempt.ts— 5800+ 行,attempt 主體。attempt.ts runEmbeddedAttempt:837-847— 函式入口,建立 abortController、設定 HTTP runtime。attempt.ts repairAttemptToolUseResultPairing:644-652— tool_use / tool_result 配對修復。attempt.ts registerProviderStreamForModel:2844-2866— 解析 provider stream 函式並掛到 agent 上。attempt.ts promptActiveSession:3421-3427— 真正提交 prompt 的輔助函式。attempt.ts preemptiveCompaction:4599-4697— 提交前的 token 預檢 + 就地截斷。attempt.ts toolSearchCatalogExecutor:3658-3706— 工具執行包裝,記錄 transcript projection。attempt.ts runContextEngineMaintenance:5143-5162— attempt 收尾後的上下文引擎維護。backend.ts—runEmbeddedAttemptWithBackend的真正實作(把 attempt 串到 backend)。
資料流
runEmbeddedAttempt(attempt.ts:837)的入口只是做最基礎的初始化:
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):
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:
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):
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):
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 走快速路徑:
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 配對的關鍵一環:
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)是一個純函式,在多個關鍵點被呼叫:
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:
promptActiveSession用withOwnedSessionTranscriptWrites包,所有 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。