单轮 attempt:模型调用与工具配对
职责
如果 embedded-runner 的 while (true) 是"今天要做几次",那么 runEmbeddedAttempt 就是"这一次具体怎么做"。它位于 attempt.ts:837,职责是把一次 attempt 内的所有动作打包:解析 sandbox、加载工具目录、组装 system prompt、把模型 stream 函数挂到 agent 上、提交 prompt、收尾持久化。
它本身不决定要不要重试——重试是外层循环的事。它只负责把"这一次"完整跑下来,并在内部处理三类细节:tool_use / tool_result 的配对修复、上下文溢出的就地压缩、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 就解决"和"外层重试"的分界。
tool 执行包装(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。