Tentative attempt: appel modèle et appariement d'outils
Responsabilités
Si le while (true) de embedded-runner répond à « combien de fois aujourd'hui », runEmbeddedAttempt répond à « comment on fait cette fois ». Elle se trouve dans attempt.ts:837, et son rôle est d'empaqueter toutes les actions d'un attempt: résoudre le sandbox, charger le répertoire d'outils, assembler le system prompt, attacher la fonction de stream du modèle à l'agent, soumettre le prompt, persister à la fin.
Elle ne décide pas elle-même de retry — c'est l'affaire de la boucle supérieure. Elle ne fait que mener « cette fois » à son terme, en gérant trois catégories de détails: la réparation de l'appariement tool_use / tool_result, la compaction sur place en cas de débordement de contexte, et le backfill de l'exécution d'outils.
Motivation de conception
Pourquoi extraire une fonction runEmbeddedAttempt dédiée? Parce qu'elle fait bien plus que « appeler le modèle une fois ». Un attempt implique:
- Résolution du sandbox:
resolveSandboxContextdétermine leeffectiveWorkspaceet leeffectiveCwdde cet attempt; si le sandbox est activé, l'override de cwd lève directement une erreur (voirattempt.ts:904). - Empilement de wrappers sur la fonction de stream:
activeSession.agent.streamFnest wrappée à plusieurs reprises dans attempt — transformations de texte, tracking de cache, restauration Anthropic, wrapping de recherche web, nettoyage de malformed tool calls, etc. L'appel réel au modèle passe par cette chaîne multi-décorée. - Pré-check du budget de contexte: avant de soumettre le prompt, on fait un
shouldPreemptivelyCompactBeforePrompt; si on découvre que le token dépasse déjà le budget, on compacte d'abord ou on tronque le tool_result, pour éviter d'envoyer au modèle un prompt qui va inévitablement déborder. - Réparation de l'appariement tool_use / tool_result: la séquence de messages renvoyée par le provider en streaming n'est pas toujours propre — parfois il y a des tool_result orphelins (le message assistant correspondant a été coupé par limitHistoryTurns).
repairAttemptToolUseResultPairingest responsable de réaligner aux points clés. - Persistance et abonnement d'événements: pendant l'attempt, tous les appels d'outils, compaction, persistance de messages passent par le SessionManager, en respectant la sémantique owned-transcript-write-lock pour sérialiser.
Entasser tout cela dans le while (true) de run.ts rendrait la boucle ingérable; d'où l'extraction de attempt.ts pour porter seule la complexité d'« une fois ».
Fichiers clés
attempt.ts— 5800+ lignes, corps principal de l'attempt.attempt.ts runEmbeddedAttempt:837-847— Entrée de fonction; crée abortController, configure HTTP runtime.attempt.ts repairAttemptToolUseResultPairing:644-652— Réparation d'appariement tool_use / tool_result.attempt.ts registerProviderStreamForModel:2844-2866— Résout la fonction provider stream et l'attache à l'agent.attempt.ts promptActiveSession:3421-3427— Fonction utilitaire qui soumet réellement le prompt.attempt.ts preemptiveCompaction:4599-4697— Pré-check de token avant soumission + troncature sur place.attempt.ts toolSearchCatalogExecutor:3658-3706— Wrapper d'exécution d'outil qui trace la transcript projection.attempt.ts runContextEngineMaintenance:5143-5162— Maintenance du context engine à la fin de l'attempt.backend.ts— Implémentation réelle derunEmbeddedAttemptWithBackend(relie l'attempt au backend).
Flux de données
L'entrée de runEmbeddedAttempt (attempt.ts:837) ne fait que l'initialisation minimale:
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"}`,
);Notez configureEmbeddedAttemptHttpRuntime({ timeoutMs: params.timeoutMs }) — cela bind le timeout de cet attempt au HTTP runtime, pour que l'appel HTTP du provider et l'attempt lui-même partagent la même horloge de timeout; on évite la déconnexion « attempt aborté mais HTTP toujours en cours ».
Ensuite la résolution du 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",
);
}Un détail souvent oublié: quand le sandbox est activé, l'override de cwd est refusé en dur. C'est pour éviter la confusion « l'agent pense être dans workspace A, mais les outils s'exécutent dans le sandbox B » — en mode sandbox, cwd doit coïncider avec le workspace.
L'assemblage de la fonction de stream (attempt.ts:2844) attache la fonction native de stream du provider à l'agent, puis l'enveloppe plusieurs fois:
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,
});Vient ensuite une série de wrappers: wrapStreamFnTextTransforms (transformations de texte provider), wrapStreamFnSanitizeMalformedToolCalls (nettoyage de tool calls illégaux), wrapStreamFnPromoteStandaloneTextToolCalls (promotion de texte isolé en tool call), wrapStreamFnTrimToolCallNames (normalisation des noms d'outils) (voir attempt.ts:3067-3143). Chaque wrapper résout un problème de compatibilité provider — par exemple le tool call de xAI a besoin d'un decode spécifique, le cache prompt de Google doit être préchauffé, le stream d'Anthropic nécessite une logique de restauration.
La soumission du prompt est le cœur de l'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 sérialise toutes les écritures de transcript dans le contexte owned courant, pour éviter que des écritures concurrentes ne s'entrelacent; abortable bind la promise au runAbortController; trackPromptSettlePromise attend que le prompt soit totalement settled (y compris tous les appels d'outils terminés) avant de retourner. Ces trois couches superposées rendent la « soumission de prompt » sûre vis-à-vis de la concurrence et de l'interruption.
Avant la soumission réelle du prompt, l'attempt fait un pré-check de 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,
},
});Le preemptiveCompaction retourné porte un champ route qui décide quel chemin économique prendre: truncate_tool_results_only ne tronque que les tool results oversized (le moins cher), compact_then_truncate compacte d'abord puis tronque (coût moyen); le chemin lourd qui appelle réellement le modèle pour compacter n'est pris que si nécessaire. Ce « pré-check + traitement gradué » est ce qui distingue l'attempt d'un prompt envoyé aveuglément.
Si le pré-check révèle que tronquer le tool_result suffit, l'attempt prend le 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;
}Notez skipPromptSubmission = true — après troncature, cet attempt ne soumet pas de prompt; il rend le contrôle à la boucle supérieure pour qu'elle démarre un nouvel attempt à partir du transcript tronqué. C'est la frontière entre « résoudre en un seul attempt sur place » et « retry par la boucle supérieure ».
Le wrapper d'exécution d'outil (attempt.ts:3658) est un maillon clé de l'appariement 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;
}
};Chaque exécution d'outil passe par runToolLifecycle — qui enveloppe uniformément les hooks before_tool_call / after_tool_call, le flag replay-safe, et le formatage d'erreur. Le résultat n'est pas seulement retourné au modèle; il est aussi push dans toolSearchTargetTranscriptProjections, qui sera persisté dans le transcript à la fin de l'attempt, devenant l'historique contextuel du prochain attempt.
La réparation de l'appariement tool_use / tool_result (attempt.ts:644) est une fonction pure appelée à plusieurs points clés:
function repairAttemptToolUseResultPairing(
messages: AgentMessage[],
isOpenAIResponsesApi: boolean,
): AgentMessage[] {
return sanitizeToolUseResultPairing(messages, {
erroredAssistantResultPolicy: "drop",
...(isOpenAIResponsesApi ? { missingToolResultText: "aborted" } : {}),
});
}Son rôle est de traiter les tool_use ou tool_result « orphelins » dans la séquence de messages: les tool result d'assistant en erreur sont droppés; sous OpenAI Responses API, les tool result manquants sont remplacés par un texte « aborted ». Cette réparation est rejouée après troncature d'historique (attempt.ts:3287), car limitHistoryTurns peut couper un message assistant et laisser son tool_result correspondant orphelin.
Limites et modes d'échec
- Sandbox et override cwd mutuellement exclusifs (
attempt.ts:904): en mode sandbox, passer un override de cwd lève une erreur plutôt que d'être ignoré silencieusement. C'est pour éviter une désynchronisation entre la perception de l'agent et le répertoire d'exécution réel. - Le pré-check peut skiper la soumission du prompt: la branche
skipPromptSubmission = true(attempt.ts:4683) signifie que cet attempt n'envoie pas de prompt; il n'a fait que la maintenance du transcript. La boucle supérieure, en voyant cet état, démarre un nouvel attempt basé sur l'historique tronqué, plutôt que de le traiter comme un échec à réessayer. - Transcript projection en cas d'erreur d'outil:
toolSearchTargetTranscriptProjections.pushécrit aussi une entréeisError: truedans la branche catch (attempt.ts:3692), pour que le transcript reflète fidèlement l'historique des appels d'outils, même en cas d'échec, sans perdre le contexte. - Ordre des wrappers streamFn sensible:
wrapStreamFnSanitizeMalformedToolCallsdoit précéderwrapStreamFnPromoteStandaloneTextToolCalls(voirattempt.ts:3067) — d'abord nettoyer l'illégal, puis promouvoir; l'inverse promouvrait les tool calls illégaux en légaux. Cet ordre de wrappers est un contrat implicite; vérifier les tests concernés avant de changer l'ordre. - Le hook before_agent_finalize peut déclencher une revision:
runAgentHarnessBeforeAgentFinalizeHook(attempt.ts:3499) s'exécute juste avant que l'attempt ne donne une réponse terminale; le hook peut demander une revision (au plusMAX_BEFORE_AGENT_FINALIZE_REVISIONSfois), chaque revision faisant refaire un tour de boucle d'outils à l'attempt. - Le owned transcript write lock couvre tout l'attempt:
promptActiveSessionest enveloppé danswithOwnedSessionTranscriptWrites, et toutes les exécutions d'outils passent par le contexte owned. Cela garantit que pendant l'attempt, même en cas de compaction concurrente ou d'écriture externe de session, l'ordre d'écriture du transcript reste piloté par cet attempt, sans entrelacement. - Priorité de cleanup en cas d'échec d'attempt: les erreurs de cleanup (
EmbeddedAttemptSessionTakeoverError) sont préservées avant les erreurs de prompt (attempt.ts:654), car le takeover est un état plus grave — à la prochaine ouverture de session, il faut savoir que la précédente a été reprise de l'extérieur et non terminée naturellement.
Résumé
runEmbeddedAttempt est l'empaquetage complet d'« un appel modèle + une boucle d'outils ». Elle encapsule dans une seule fonction: résolution sandbox, multi-wrap de streamFn, pré-check du prompt, transcript projection des exécutions d'outils, réparation de l'appariement tool_use / tool_result. La boucle supérieure ne voit que la classification du résultat de l'attempt (réponse terminale / à réessayer / à compacter / à failover), sans se soucier des détails internes.
Comment le while (true) supérieur ordonnance cet attempt, voir Boucle principale de l'agent: embedded-runner; définition et politique des outils, voir Système d'outils; le branchement de la fonction stream de chaque provider, voir Branchement Provider; mécanismes plus profonds de compaction et de maintenance du transcript, voir Context Engine.
Pour comparer avec la documentation officielle: Attempt docs · README.