Skip to content

Turno único attempt: invocación al modelo y emparejamiento de herramientas

源码版本v2026.6.11

Responsabilidad

Si el while (true) de embedded-runner responde a «cuántas veces hay que hacerlo hoy», runEmbeddedAttempt responde a «cómo se hace esta vez». Vive en attempt.ts:837, y su responsabilidad es empaquetar todas las acciones dentro de un intento (attempt): resolver el sandbox, cargar el catálogo de herramientas, montar el system prompt, enchufar la función de stream del modelo al agent, enviar el prompt y persistir al final.

No decide por sí mismo si hay que reintentar — el reintento es cosa del bucle exterior. Solo se encarga de completar «esta vez», gestionando internamente tres tipos de detalle: el emparejamiento y reparación de tool_use / tool_result, la compactación in situ ante desbordamiento de contexto, y el relleno de la ejecución de tool.

Motivación de diseño

¿Por qué hace falta una función runEmbeddedAttempt aparte? Porque lo que hace es mucho más complejo que «invocar al modelo una vez». Un intento implica:

  • Resolución de sandbox (sandbox): resolveSandboxContext decide el effectiveWorkspace y effectiveCwd del intento; si el sandbox está activo, un override de cwd se rechaza directamente (ver attempt.ts:904).
  • Envolturas múltiples de la función de stream: activeSession.agent.streamFn se envuelve capa tras capa dentro del attempt — transformaciones de texto, seguimiento de cache, recuperación Anthropic, wrapper de web search, limpieza de tool calls malformados, etc. Lo que al final llama al modelo es esta cadena con múltiples capas decoradas.
  • Precontrol del presupuesto de contexto: antes de enviar el prompt, se hace un shouldPreemptivelyCompactBeforePrompt; si los tokens ya superan el presupuesto, primero se compacta o se trunca el tool_result, para no enviar al modelo un prompt destinado a desbordarse.
  • Reparación del emparejamiento tool_use / tool_result: la secuencia de mensajes que devuelve el provider en streaming no siempre es limpia — a veces hay tool_result huérfanos (el assistant correspondiente se cortó por limitHistoryTurns). repairAttemptToolUseResultPairing se encarga de realinear en los puntos clave.
  • Persistencia y suscripción a eventos: todas las llamadas a herramientas, compactación y persistencia de mensajes durante el attempt deben pasar por SessionManager, y además de forma serializada según la semántica del owned-transcript-write-lock.

Meter todo esto en el while (true) de run.ts volvería el cuerpo del bucle inmanejable, así que se extrajo attempt.ts para absorber por su cuenta la complejidad de «un intento».

Archivos clave

Flujo de datos

La entrada de runEmbeddedAttempt (attempt.ts:837) solo hace lo mínimo de inicialización:

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"}`,
  );

Nótese configureEmbeddedAttemptHttpRuntime({ timeoutMs: params.timeoutMs }) — esto vincula el timeout del intento al HTTP runtime, de modo que la llamada HTTP del provider y el propio attempt comparten el mismo reloj de timeout: no puede ocurrir «el attempt ya se abortó pero el HTTP sigue corriendo».

Luego la resolución del 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",
  );
}

Aquí hay un detalle a menudo pasado por alto: si el sandbox está activo, un override de cwd se rechaza de plano. Es para evitar la desincronización «el agent cree estar en el workspace A pero las herramientas corren en el sandbox B» — en modo sandbox, cwd debe coincidir con workspace.

El montaje de la función de stream (attempt.ts:2844) enchufa la función nativa de stream del provider al agent y la envuelve múltiples veces:

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,
});

A continuación se envuelve varias veces más con wrapStreamFnTextTransforms (transformaciones de texto del provider), wrapStreamFnSanitizeMalformedToolCalls (limpieza de tool calls ilegales), wrapStreamFnPromoteStandaloneTextToolCalls (promueve texto aislado a tool call), wrapStreamFnTrimToolCallNames (normaliza nombres de herramientas), etc. (ver attempt.ts:3067-3143). Cada capa resuelve un problema de compatibilidad de provider — por ejemplo, los tool calls de xAI requieren un decode aparte, el prompt cache de Google requiere calentamiento, el stream de Anthropic necesita lógica de recuperación.

El envío del prompt es el núcleo del 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 serializa todas las escrituras del transcript al contexto owned actual, evitando que se intercalen escrituras concurrentes; abortable vincula esta promesa a runAbortController; trackPromptSettlePromise espera a que el prompt se asiente por completo (incluyendo todas las llamadas a herramientas) antes de devolverse. Estas tres capas hacen que «enviar un prompt» sea seguro bajo concurrencia e interrupciones.

Antes de enviar el prompt de verdad, el attempt hace un precontrol de tokens (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,
      },
    });

El preemptiveCompaction devuelto por shouldPreemptivelyCompactBeforePrompt trae un campo route que decide la ruta económica: truncate_tool_results_only solo trunca tool results sobredimensionados (lo más barato), compact_then_truncate primero compacta y luego trunca (coste medio); solo se va a la ruta pesada de llamar al modelo para compactar cuando hace falta. Este «precontrol + procesamiento por niveles» es lo que distingue al attempt de un «enviar prompt a lo bestia».

Si el precontrol detecta que basta con truncar tool_result, el attempt toma la ruta rápida:

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;
  }

Nótese skipPromptSubmission = true — tras truncar, este attempt ya no envía el prompt; devuelve el control al bucle exterior para que, con el transcript truncado, inicie un attempt nuevo. Esta es la frontera entre «resolver in situ en un attempt» y «reintento desde el bucle exterior».

El wrapper de ejecución de herramientas (attempt.ts:3658) es una pieza clave del emparejamiento 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;
  }
};

Cada ejecución de herramienta pasa por runToolLifecycle — envuelve los hooks before_tool_call / after_tool_call, el flag de seguridad replay y el formateo de errores. El resultado no solo vuelve al modelo, también se hace push a toolSearchTargetTranscriptProjections; esa parte se persiste al transcript al cerrar el attempt, convirtiéndose en contexto histórico del siguiente intento.

La reparación del emparejamiento tool_use / tool_result (attempt.ts:644) es una función pura que se invoca en varios puntos clave:

typescript
function repairAttemptToolUseResultPairing(
  messages: AgentMessage[],
  isOpenAIResponsesApi: boolean,
): AgentMessage[] {
  return sanitizeToolUseResultPairing(messages, {
    erroredAssistantResultPolicy: "drop",
    ...(isOpenAIResponsesApi ? { missingToolResultText: "aborted" } : {}),
  });
}

Su trabajo es lidiar con los tool_use o tool_result «huérfanos» de la secuencia de mensajes — el tool result de un assistant con error se dropa, y en la API Responses de OpenAI un tool result faltante se rellena con el texto «aborted». Esta reparación se vuelve a ejecutar tras un truncado del historial (attempt.ts:3287), porque limitHistoryTurns puede cortar un mensaje assistant y dejar su tool result como huérfano sin dueño.

Límites y fallos

  • Sandbox y override de cwd son mutuamente excluyentes (attempt.ts:904): en modo sandbox, pasar un override de cwd lanza error, en lugar de ignorarse silenciosamente. Evita la desincronización entre la percepción del agent y el directorio real de ejecución.
  • El precontrol puede saltar el envío del prompt: la rama skipPromptSubmission = true (attempt.ts:4683) indica que este attempt no envía prompt y solo hace mantenimiento del transcript antes de retornar. El bucle exterior, al ver este estado, arranca un attempt nuevo basado en el historial truncado, sin tratarlo como fallo a reintentar.
  • Transcript projection de errores de tool: toolSearchTargetTranscriptProjections.push también escribe un registro isError: true en la rama catch (attempt.ts:3692), de modo que el transcript refleja íntegramente el historial de llamadas a herramientas aunque fallen.
  • El orden de los wraps de streamFn es sensible: wrapStreamFnSanitizeMalformedToolCalls debe ir antes que wrapStreamFnPromoteStandaloneTextToolCalls (ver attempt.ts:3067) — primero limpiar lo ilegal, luego promover; al revés se promoverían tool calls ilegales a legales. Este orden de wraps es un contrato implícito; antes de cambiarlo, revisar las pruebas correspondientes.
  • El hook before_agent_finalize puede disparar revisiones: runAgentHarnessBeforeAgentFinalizeHook (attempt.ts:3499) corre justo antes de que el attempt dé su respuesta terminal; el hook puede pedir revisiones (máximo MAX_BEFORE_AGENT_FINALIZE_REVISIONS), cada una hace que el attempt dé una vuelta más al bucle de herramientas.
  • Owned transcript write lock atraviesa todo el attempt: promptActiveSession se envuelve con withOwnedSessionTranscriptWrites, y todas las ejecuciones de tool pasan por el contexto owned. Esto garantiza que, aunque haya una compactación concurrente o escrituras externas en la sesión durante el attempt, el orden de escritura del transcript lo sigadirigidoando este attempt, sin intercalarse ni contaminarse.
  • Prioridad de cleanup ante fallo del attempt: los errores de cleanup (EmbeddedAttemptSessionTakeoverError) se conservan por delante del error de prompt (attempt.ts:654), porque takeover es un estado más grave — al abrir la próxima sesión hay que saber que la anterior fuetomada externamente, no terminó naturalmente.

Resumen

runEmbeddedAttempt empaqueta «una llamada al modelo + bucle de herramientas». Encierra en una sola función la resolución de sandbox, los múltiples wraps de streamFn, el precontrol del prompt, el transcript projection de la ejecución de herramientas y la reparación del emparejamiento tool_use / tool_result. El bucle exterior solo ve la clasificación del resultado del attempt (respuesta terminal / necesita reintento / necesita compactación / necesita failover), sin preocuparse de los detalles internos.

Cómo el bucle exterior while (true) programa este attempt se ve en Bucle principal del agent: embedded-runner; la definición y política de herramientas se ve en Sistema de herramientas; la integración de la función de stream de cada provider se ve en Integración de providers; los mecanismos más profundos de compactación de contexto y mantenimiento del transcript se ven en Context Engine.

Referencias oficiales: Documentación del attempt · README.