Skip to content

単発 attempt:モデル呼び出しとツールペアリング

源码版本v2026.6.11

責務

embedded-runnerwhile (true) が「今日は何回やるか」なら,runEmbeddedAttempt は「この 1 回を具体的にどうやるか」です。それは attempt.ts:837 にあり,1 回の attempt 内のすべての動作をパッケージ化するのが責務です:sandbox の解析、ツールディレクトリの読み込み、system prompt の組み立て、モデル stream 関数の agent への取り付け、prompt の提出、仕上げの永続化。

再試行するかどうかは決定しません——再試行は外層ループの仕事です。ただ「今回」を完走させることと,内部で 3 種の詳細を処理することだけを担当します:tool_use / tool_result のペア修復、コンテキスト溢れのその場圧縮、tool 実行の埋め戻し。

設計動機

なぜ単独の runEmbeddedAttempt 関数を抽出するのか?「1 回モデルを呼ぶ」よりもずっと複雑なことをするからです。1 回の attempt には:

  • サンドボックス (sandbox) 解析:resolveSandboxContext が今回の effectiveWorkspaceeffectiveCwd を決定,サンドボックス有効時に 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.tswhile (true) に詰め込むとループ本体が保守不能なほど膨張するので,attempt.ts を分離して「1 回」の複雑さを単独で担います。

主要ファイル

データフロー

runEmbeddedAttempt(attempt.ts:837)のエントリは最も基礎的な初期化だけを行います:

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

configureEmbeddedAttemptHttpRuntime({ timeoutMs: params.timeoutMs }) に注意——今回の attempt のタイムアウトを HTTP runtime にバインドし,provider の HTTP 呼び出しと attempt 自身が同じタイムアウト時計を共有します。「attempt は abort されたのに HTTP はまだ走っている」という脱節を防ぎます。

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

ここによく見落とされる詳細があります:サンドボックス有効時に cwd override は硬性拒否されます。これは「agent は自分が workspace A にいると思っているが,実際のツールは sandbox B で走る」という認識のずれを避けるためです——サンドボックスモードでは cwd は workspace と一致しなければなりません。

stream 関数の組み立て(attempt.ts:2844)は provider のネイティブ stream 関数を agent に取り付け,その後多層 wrap します:

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

続いて wrapStreamFnTextTransforms(provider テキスト変換)、wrapStreamFnSanitizeMalformedToolCalls(不正 tool call 清洗)、wrapStreamFnPromoteStandaloneTextToolCalls(孤立テキストを tool call に昇格)、wrapStreamFnTrimToolCallNames(ツール名を規整)などで何度も包まれます(attempt.ts:3067-3143 参照)。各層の wrap は provider 互換性問題を 1 つずつ解決します——例えば xAI の tool call パラメータは单独 decode が必要,Google の prompt cache は予熱が必要,Anthropic の stream は復旧ロジックが必要です。

prompt 提出は 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 はすべての transcript 書き込みを現在の owned context に直列化し,複数の並発書き込みが交錯するのを避けます。abortable はこの promise を runAbortController にバインドします。trackPromptSettlePromise は prompt が完全に settle する(すべての tool 呼び出し完了を含む)のを待って返します。この 3 層の重ね合わせで,「prompt 提出」が並発と中断のセマンティクスにおいて安全になります。

本当に prompt を提出する前に,attempt はもう一度 token 事前チェックを行います(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,
      },
    });

shouldPreemptivelyCompactBeforePrompt が返す preemptiveCompactionroute フィールドを持ち,どの省コストパスを走るかを決定します:truncate_tool_results_only は oversized tool result だけ切り詰め(最も安価),compact_then_truncate は先に圧縮してから切り詰め(中コスト),本当にモデル圧縮を呼ぶ必要があるときだけ重いパスに進みます。この「事前チェック + 段階的処理」が attempt を「無思考で prompt を送る」ものと区別します。

事前チェックで tool_result を切り詰めるだけで十分と分かれば,attempt は高速パスに進みます:

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

skipPromptSubmission = true に注意——切り詰め後,今回の attempt は prompt を送らず,制御を外層ループに戻し,外層に切り詰められた transcript で新たな attempt を始めさせます。これが「その場 1 回の attempt で解決」と「外層再試行」の境界です。

tool 実行ラップ(attempt.ts:3658)は 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;
  }
};

毎回のツール実行は runToolLifecycle を経由します——before_tool_call / after_tool_call フック、replay 安全マーク、エラー成形を統一的に包みます。実行結果はモデルに返すだけでなく,toolSearchTargetTranscriptProjections に push され,attempt 仕上げ時に transcript に永続化され,次回 attempt の履歴コンテキストになります。

tool_use / tool_result ペア修復(attempt.ts:644)は純関数で,複数の主要ポイントで呼ばれます:

typescript
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 のレコードを 1 件書き(attempt.ts:3692) transcript がツール呼び出し履歴を完全に反映することを保証し,失敗時もコンテキストを失いません。
  • streamFn wrap の順序は敏感:wrapStreamFnSanitizeMalformedToolCallswrapStreamFnPromoteStandaloneTextToolCalls の前でなければなりません(attempt.ts:3067 参照)——先に不正を清洗してから昇格し,逆順にすると不正 tool call を合法的に昇格してしまいます。この wrap 順序は暗黙の契約で,順序変更前に該当テストを確認してください。
  • before_agent_finalize フックが revision をトリガー可能:runAgentHarnessBeforeAgentFinalizeHook(attempt.ts:3499)は attempt が端末応答を出す直前に走り,フックは revision を要求でき(最大 MAX_BEFORE_AGENT_FINALIZE_REVISIONS 回)各 revision で attempt はもう 1 周ツールループを走ります。
  • owned transcript write lock が attempt 全体に貫く:promptActiveSessionwithOwnedSessionTranscriptWrites で包み,すべての tool 実行も owned context を経由します。これで attempt 中に並発 compaction や外部 session 書き込みがあっても,transcript 書き込み順序は本 attempt が主導し,交錯汚染しません。
  • attempt 失敗の cleanup 優先度:cleanup エラー(EmbeddedAttemptSessionTakeoverError)は prompt error より前に保持されます(attempt.ts:654)。takeover はより深刻な状態だからです——次回 session を開くとき,前回が自然終了ではなく外部接管されたことを知る必要があります。

まとめ

runEmbeddedAttempt は「1 回のモデル呼び出し + ツールループ」の完全パッケージです。sandbox 解析、streamFn 多層ラップ、prompt 事前チェック、tool 実行 transcript projection、tool_use / tool_result ペア修復をすべて 1 つの関数に封じます。外層ループは attempt の結果分類(端末応答 / 再試行必要 / 圧縮必要 / failover 必要)だけを見て,内部の詳細は気にしません。

外層の while (true) がこの attempt をどうスケジュールするかは Agent メインループ:embedded-runner,ツール定義とツールポリシーは ツールシステム,各 provider の stream 関数接入は Provider 接入,コンテキスト圧縮と transcript 保守のより深いメカニズムは コンテキストエンジン を参照。

公式資料:Attempt ドキュメント · README