Single attempt: model calls and tool pairing
Responsibilities
If embedded-runner's while (true) answers "how many times today", runEmbeddedAttempt answers "how exactly this one time". It lives at attempt.ts:837 and is responsible for packaging every action inside one attempt: resolving the sandbox, loading the tool directory, assembling the system prompt, attaching the model stream function to the agent, submitting the prompt, and finalizing persistence.
It doesn't decide whether to retry — that's the outer loop's job. It only runs "this one" to completion and handles three categories of details internally: tool_use / tool_result pairing repair, in-place compaction on context overflow, and tool execution backfill.
Design motivation
Why extract a separate runEmbeddedAttempt function? Because what it does is far more complex than "call the model once". A single attempt involves:
- Sandbox resolution:
resolveSandboxContextdecides theeffectiveWorkspaceandeffectiveCwdfor this attempt. When sandbox is enabled, cwd override is a hard error (seeattempt.ts:904). - Multi-layer wrapping of the stream function:
activeSession.agent.streamFngets wrapped layer by layer inside the attempt — text transforms, cache tracking, Anthropic recovery, web search wrapping, malformed tool call sanitization, and so on. The function that actually calls the model is this multiply-decorated chain. - Context budget precheck: before actually submitting the prompt, a
shouldPreemptivelyCompactBeforePromptcheck runs; if tokens already exceed budget, compaction or tool_result truncation runs first to avoid sending a prompt that's guaranteed to overflow. - tool_use / tool_result pairing repair: the message sequence streamed back by providers isn't always clean — sometimes there are orphaned tool_results (their corresponding assistant message was cut off by limitHistoryTurns).
repairAttemptToolUseResultPairingrealigns at key points. - Persistence and event subscription: all tool calls, compaction, and message persistence during the attempt go through SessionManager, and must be serialized by owned-transcript-write-lock semantics.
Stuffing all of this into run.ts's while (true) would bloat the loop body beyond maintainability, so attempt.ts is split out to carry the "one time" complexity alone.
Key files
attempt.ts— 5800+ lines, the attempt body.attempt.ts runEmbeddedAttempt:837-847— function entry, creates abortController, configures HTTP runtime.attempt.ts repairAttemptToolUseResultPairing:644-652— tool_use / tool_result pairing repair.attempt.ts registerProviderStreamForModel:2844-2866— resolves the provider stream function and attaches it to the agent.attempt.ts promptActiveSession:3421-3427— helper that actually submits the prompt.attempt.ts preemptiveCompaction:4599-4697— pre-submit token precheck + in-place truncation.attempt.ts toolSearchCatalogExecutor:3658-3706— tool execution wrapper, records transcript projections.attempt.ts runContextEngineMaintenance:5143-5162— context engine maintenance after the attempt finishes.backend.ts— the actual implementation ofrunEmbeddedAttemptWithBackend(threading the attempt onto a backend).
Data flow
The entry of runEmbeddedAttempt (attempt.ts:837) only does the most basic initialization:
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"}`,
);Note configureEmbeddedAttemptHttpRuntime({ timeoutMs: params.timeoutMs }) — this binds this attempt's timeout onto the HTTP runtime, so the provider's HTTP calls share the same timeout clock as the attempt itself, eliminating "attempt already aborted but HTTP still running" drift.
Next is sandbox resolution (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",
);
}There's a frequently-overlooked detail: when sandbox is enabled, cwd override is hard-refused. This avoids the "agent thinks it's in workspace A but tools actually run in sandbox B" cognitive drift — sandbox mode requires cwd to match the workspace.
Stream function assembly (attempt.ts:2844) attaches the provider's native stream function to the agent and then wraps it multiple times:
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,
});Then it gets wrapped multiple times by wrapStreamFnTextTransforms (provider text transforms), wrapStreamFnSanitizeMalformedToolCalls (sanitize illegal tool calls), wrapStreamFnPromoteStandaloneTextToolCalls (promote isolated text into tool calls), wrapStreamFnTrimToolCallNames (normalize tool names), and so on (see attempt.ts:3067-3143). Each wrap layer solves one provider-compatibility problem — for example xAI's tool call arguments need separate decoding, Google's prompt cache needs prewarming, Anthropic's stream needs recovery logic.
Prompt submission is the heart of the 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 serializes all transcript writes onto the current owned context, preventing interleaved concurrent writes; abortable binds this promise to runAbortController; trackPromptSettlePromise waits for the prompt to fully settle (including all tool calls completing) before resolving. These three layers stack to make "submit prompt" safe under concurrency and interruption.
Before actually submitting the prompt, the attempt runs a token precheck (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 returns a preemptiveCompaction carrying a route field that picks the cheapest path: truncate_tool_results_only only truncates oversized tool results (cheapest); compact_then_truncate compacts then truncates (medium cost); only when real model-side compaction is needed does it take the heavy path. This "precheck + tiered handling" is what distinguishes an attempt from a "fire prompt blindly".
If the precheck finds that just truncating tool_results is enough, the attempt takes the fast path:
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;
}Note skipPromptSubmission = true — after truncation, this attempt doesn't send a prompt; it returns control to the outer loop so it can start a new attempt with the truncated transcript. This is the dividing line between "solve in one attempt in place" and "outer-loop retry".
Tool execution wrapping (attempt.ts:3658) is a key piece of tool_use / tool_result pairing:
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;
}
};Every tool execution goes through runToolLifecycle — it uniformly wraps before_tool_call / after_tool_call hooks, replay-safety flags, and error formatting. The result doesn't just go back to the model; it's pushed to toolSearchTargetTranscriptProjections, which gets persisted to the transcript at attempt finalization, becoming historical context for the next attempt.
The tool_use / tool_result pairing repair (attempt.ts:644) is a pure function called at multiple key points:
function repairAttemptToolUseResultPairing(
messages: AgentMessage[],
isOpenAIResponsesApi: boolean,
): AgentMessage[] {
return sanitizeToolUseResultPairing(messages, {
erroredAssistantResultPolicy: "drop",
...(isOpenAIResponsesApi ? { missingToolResultText: "aborted" } : {}),
});
}Its job is to handle "orphaned" tool_use or tool_result in the message sequence — errored assistant's tool result is dropped; under OpenAI Responses API, missing tool results get a placeholder text "aborted". This repair is re-run after history truncation (attempt.ts:3287) because limitHistoryTurns may cut off an assistant message, leaving its tool_result orphaned.
Boundaries and failures
- sandbox and cwd override are mutually exclusive (
attempt.ts:904): under sandbox mode, passing a cwd override throws directly instead of silently ignoring. This prevents a mismatch between agent perception and the actual execution directory. - precheck can skip prompt submission: the
skipPromptSubmission = truebranch (attempt.ts:4683) means this attempt doesn't send a prompt, only does transcript maintenance and returns. The outer loop, seeing this state, starts a new attempt based on the truncated history rather than treating it as a failed retry. - transcript projection of tool execution errors:
toolSearchTargetTranscriptProjections.pushin the catch branch also writes anisError: truerecord (attempt.ts:3692), ensuring the transcript fully reflects tool call history even on failure, so context isn't lost. - streamFn wrap order is sensitive:
wrapStreamFnSanitizeMalformedToolCallsmust come beforewrapStreamFnPromoteStandaloneTextToolCalls(seeattempt.ts:3067) — sanitize first, then promote; reversing would promote illegal tool calls into legal ones. This wrap order is an implicit contract; check the relevant tests before changing order. - before_agent_finalize hook can trigger revisions:
runAgentHarnessBeforeAgentFinalizeHook(attempt.ts:3499) runs before the attempt emits its terminal reply; the hook can request a revision (up toMAX_BEFORE_AGENT_FINALIZE_REVISIONStimes), each forcing the attempt through one more tool loop. - owned transcript write lock spans the whole attempt:
promptActiveSessionis wrapped inwithOwnedSessionTranscriptWrites, and all tool execution also goes through the owned context. This ensures that even with concurrent compaction or external session writes during the attempt, transcript write order is driven by this attempt and not interleaved or polluted. - cleanup priority on attempt failure: cleanup errors (
EmbeddedAttemptSessionTakeoverError) are preserved before prompt errors (attempt.ts:654) because takeover is a more severe state — when opening the next session, it must know the previous one was externally taken over rather than ending naturally.
Summary
runEmbeddedAttempt is the full package of "one model call + tool loop". It encapsulates sandbox resolution, multi-layer streamFn wrapping, prompt precheck, tool execution transcript projection, and tool_use / tool_result pairing repair inside one function. The outer loop only sees the attempt's result classification (terminal reply / needs retry / needs compaction / needs failover), without caring about internal details.
How the outer while (true) schedules this attempt is in Agent main loop: embedded-runner; tool definitions and tool policies are in Tool system; per-provider stream function integration is in Provider integration; deeper mechanisms of context compaction and transcript maintenance are in Context Engine.
Official references: Attempt docs · README.