Skip to content

Einzelner Attempt: Modellaufruf und Werkzeugpaarung

源码版本v2026.6.11

Verantwortung

Wenn embedded-runner mit while (true) entscheidet, „wie oft heute", dann ist runEmbeddedAttempt die Antwort auf „wie genau dieses Mal". Es liegt in attempt.ts:837 und packt alle Aktionen innerhalb eines Attempts zusammen: Sandbox parsen, Werkzeugverzeichnis laden, System-Prompt montieren, die Stream-Funktion des Modells an den Agenten hängen, Prompt abschicken, Persistenz abschließen.

Es entscheidet nicht selbst, ob wiederholt wird — Retry ist Sache der äußeren Schleife. Es kümmert sich nur darum, „dieses Mal" vollständig durchzuführen und behandelt intern drei Detailgruppen: tool_use / tool_result-Paarungsreparatur, in-place Komprimierung bei Kontextüberlauf, Tool-Ausführungsnachtrag.

Designmotivation

Warum eine separate runEmbeddedAttempt-Funktion? Weil sie viel mehr macht als „einmal Modell aufrufen". Ein Attempt umfasst:

  • Sandbox (sandbox) parsen: resolveSandboxContext entscheidet über effectiveWorkspace und effectiveCwd für diesen Versuch; bei aktivierter Sandbox wird ein cwd-Override direkt abgelehnt (siehe attempt.ts:904).
  • Mehrschichtige Wrapping der Stream-Funktion: activeSession.agent.streamFn wird im Attempt mehrfach gewrappt — Text-Transformationen, Cache-Tracking, Anthropic-Recovery, Web-Search-Wrapper, Bereinigung fehlerhafter Tool-Aufrufe. Am Ende ruft die Aufrufkette das Modell über diese mehrschichtig dekorierte Kette auf.
  • Kontextbudget-Vorabprüfung: Vor dem tatsächlichen Absenden des Prompts wird shouldPreemptivelyCompactBeforePrompt geprüft; ist das Token bereits über dem Budget, wird vorab komprimiert oder tool_result abgeschnitten, um keinen zum Überlauf verdammten Prompt an das Modell zu schicken.
  • tool_use / tool_result-Paarungsreparatur: Die vom Provider gestreamte Nachrichtenfolge ist nicht immer sauber — manchmal gibt es verwaiste tool_result (deren zugehörige Assistant-Nachricht von limitHistoryTurns abgeschnitten wurde). repairAttemptToolUseResultPairing richtet sie an Schlüsselpunkten neu aus.
  • Persistenz und Ereignisabonnements: Alle Tool-Aufrufe, Komprimierungen und Nachrichtenpersistenzen während eines Attempts laufen über den SessionManager und müssen nach der owned-transcript-write-lock-Semantik serialisiert werden.

All das in run.ts's while (true) zu quetschen, würde den Schleifenkörper bis zur Unwartbarkeit aufblähen; deshalb ist attempt.ts separiert, um die Komplexität des „einen Mals" zu tragen.

Schlüsseldateien

Datenfluss

Der Einstieg von runEmbeddedAttempt (attempt.ts:837) macht nur die rudimentärste Initialisierung:

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

Beachten Sie configureEmbeddedAttemptHttpRuntime({ timeoutMs: params.timeoutMs }) — das bindet das Timeout dieses Attempts an die HTTP-Runtime, sodass HTTP-Aufrufe des Providers und der Attempt selbst dieselbe Timeout-Uhr teilen; „Attempt bereits abgebrochen, aber HTTP läuft noch" kann nicht passieren.

Es folgt das Sandbox-Parsing (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",
  );
}

Ein oft übersehenes Detail: Bei aktivierter Sandbox wird ein cwd-Override hart abgelehnt. Das verhindert die kognitive Verschiebung „Agent glaubt, in Workspace A zu sein, aber Werkzeuge laufen in Sandbox B" — im Sandbox-Modus müssen cwd und Workspace identisch sein.

Die Montage der Stream-Funktion (attempt.ts:2844) hängt die native Stream-Funktion des Providers an den Agenten und wrappt sie mehrschichtig:

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

Anschließend folgt mehrfaches Wrapping durch wrapStreamFnTextTransforms (Provider-Text-Transformationen), wrapStreamFnSanitizeMalformedToolCalls (Bereinigung illegaler Tool-Aufrufe), wrapStreamFnPromoteStandaloneTextToolCalls (isolierten Text als Tool-Aufruf heraufstufen), wrapStreamFnTrimToolCallNames (Werkzeugnamen normalisieren) und andere (siehe attempt.ts:3067-3143). Jede Wrap-Schicht löst ein Provider-Kompatibilitätsproblem — z. B. müssen Tool-Aufruf-Parameter bei xAI separat dekodiert werden, bei Google der Prompt-Cache vorgewärmt werden, bei Anthropic eine Stream-Recovery-Logik existieren.

Prompt-Absendung ist der Kern des Attempts (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 bindet alle Transcript-Schreibvorgänge an den aktuellen owned-Kontext und verhindert verschränkte parallele Schreibvorgänge; abortable koppelt dieses Promise an runAbortController; trackPromptSettlePromise wartet, bis sich der Prompt vollständig settles (inklusive aller Tool-Aufrufe abgeschlossen), bevor zurückzukehren. Diese drei Schichten sorgen dafür, dass „Prompt abschicken" semantisch sicher in Bezug auf Parallelität und Abbruch ist.

Vor dem tatsächlichen Prompt-Absenden macht der Attempt noch eine Token-Vorabprüfung (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,
      },
    });

Das von shouldPreemptivelyCompactBeforePrompt zurückgegebene preemptiveCompaction trägt ein Feld route, das über die sparsame Route entscheidet: truncate_tool_results_only schneidet nur oversized tool_result ab (am billigsten); compact_then_truncate komprimiert zuerst und schneidet dann (mittlere Kosten); erst wenn das Modell wirklich komprimiert werden muss, wird die schwere Route gewählt. Diese „Vorabprüfung + abgestufte Behandlung" ist der Unterschied des Attempts zu „einfach Prompt abschicken".

Stellt die Vorabprüfung fest, dass alleiniges Abschneiden des tool_result ausreicht, nimmt der Attempt den Schnellpfad:

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

Beachten Sie skipPromptSubmission = true — nach dem Abschneiden sendet dieser Attempt keinen Prompt mehr, sondern gibt die Kontrolle an die äußere Schleife zurück, damit diese mit dem gekürzten Transcript einen neuen Attempt startet. Das ist die Grenze zwischen „in-place ein Attempt gelöst" und „äußere Schleife wiederholt".

Der Werkzeugausführungs-Wrapper (attempt.ts:3658) ist ein Schlüsselelement der tool_use / tool_result-Paarung:

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

Jede Werkzeugausführung läuft über runToolLifecycle — es wrappt einheitlich before_tool_call / after_tool_call-Hooks, Replay-Sicherheitsmarken und Fehlerformatierung. Das Ergebnis geht nicht nur an das Modell zurück, sondern wird auch in toolSearchTargetTranscriptProjections gepusht; dieser Teil wird beim Attempt-Abschluss ins Transcript persistiert und wird zum historischen Kontext für den nächsten Attempt.

Die tool_use / tool_result-Paarungsreparatur (attempt.ts:644) ist eine reine Funktion, die an mehreren Schlüsselpunkten aufgerufen wird:

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

Es behandelt „verwaiste" tool_use oder tool_result in der Nachrichtenfolge — errored-assistant tool_result wird direkt gedroppt, bei OpenAI Responses API wird fehlendes tool_result als Text „aborted" platzhalteriert. Diese Reparatur wird nach historischem Abschneiden erneut ausgeführt (attempt.ts:3287), da limitHistoryTurns eine Assistant-Nachricht abschneiden kann und ihr tool_result als Waise zurücklässt.

Grenzen und Fehler

  • Sandbox und cwd-Override schließen sich gegenseitig aus (attempt.ts:904): Im Sandbox-Modus wird ein cwd-Override direkt abgelehnt, nicht stillschweigend ignoriert. Verhindert kognitive Verschiebung zwischen Agent-Wahrnehmung und tatsächlichem Ausführungsverzeichnis.
  • Vorabprüfung kann Prompt-Absendung überspringen: Der skipPromptSubmission = true-Zweig (attempt.ts:4683) bedeutet, dass dieser Attempt keinen Prompt sendet, sondern nur Transcript-Wartung erledigt. Die äußere Schleife startet bei diesem Status einen neuen Attempt mit der gekürzten Historie, anstatt es als Fehlschlag zu werten.
  • Tool-Ausführungsfehler-Transcript-Projektion: toolSearchTargetTranscriptProjections.push schreibt auch im catch-Zweig einen Eintrag mit isError: true (attempt.ts:3692) — das Transcript spiegelt die Werkzeugaufrufhistorie vollständig wider, auch bei Fehlschlägen geht der Kontext nicht verloren.
  • streamFn-Wrap-Reihenfolge ist empfindlich: wrapStreamFnSanitizeMalformedToolCalls muss vor wrapStreamFnPromoteStandaloneTextToolCalls liegen (siehe attempt.ts:3067) — zuerst illegale Bereinigen, dann heraufstufen; umgekehrt würden illegale Tool-Aufrufe als legal heraufgestuft. Diese Wrap-Reihenfolge ist ein impliziter Vertrag; vor einer Umstellung sollten die relevanten Tests geprüft werden.
  • before_agent_finalize-Hook kann Revision auslösen: runAgentHarnessBeforeAgentFinalizeHook (attempt.ts:3499) läuft, bevor der Attempt seine Endantwort liefert. Der Hook kann eine Revision verlangen (maximal MAX_BEFORE_AGENT_FINALIZE_REVISIONS Mal), womit der Attempt noch eine Werkzeug-Schleife mehr durchläuft.
  • Owned-Transcript-Write-Lock durchzieht gesamten Attempt: promptActiveSession wird von withOwnedSessionTranscriptWrites umwickelt, alle Werkzeugausführungen laufen ebenfalls über den owned-Kontext. Damit ist selbst bei paralleler Komprimierung oder externen Session-Schreibvorgängen die Schreibreihenfolge des Transcripts durch diesen Attempt dominiert — keine verschränkte Verschmutzung.
  • Cleanup-Priorität bei Attempt-Fehlschlag: Cleanup-Fehler (EmbeddedAttemptSessionTakeoverError) werden vor Prompt-Fehlern bewahrt (attempt.ts:654), da Takeover ein schwerwiegenderer Zustand ist — beim nächsten Öffnen einer Session muss bekannt sein, dass die vorherige extern übernommen wurde, nicht natürlich endete.

Zusammenfassung

runEmbeddedAttempt ist ein vollständiges Paket aus „einmal Modell aufrufen + Werkzeug-Schleife". Es kapselt Sandbox-Parsing, mehrschichtiges streamFn-Wrapping, Prompt-Vorabprüfung, Werkzeugausführungs-Transcript-Projektion und tool_use / tool_result-Paarungsreparatur in einer Funktion. Die äußere Schleife sieht nur die Ergebnisklassifizierung des Attempts (Endantwort / Retry nötig / Komprimierung nötig / Failover nötig), ohne interne Details.

Wie die äußere while (true) diesen Attempt dispatcht, siehe Agent-Hauptschleife: embedded-runner; Werkzeugdefinitionen und Werkzeugstrategien siehe Werkzeugsystem; wie die Stream-Funktion der Provider angeschlossen wird, siehe Provider-Anbindung; tiefere Mechanismen der Kontextkomprimierung und Transcript-Wartung siehe Context Engine.

Vergleich mit offiziellen Ressourcen: Attempt-Doku · README.