0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

AIエージェントUIは「失敗」より最初のズレを見る。3つのfixtureでイベントをリプレイする

0
Posted at

AIエージェントが呼んだtoolは失敗しているのに、ボタンはずっと「実行中」のまま。チャット末尾にはエラーメッセージもある。原因を探そうとして履歴を上から読み直すが、UIがどのイベントを受け損ねたのかは分からない。

この手の不具合を最後のerrorから逆算すると、見る範囲が広がりすぎる。

自分なら、ログを増やす前に再現専用routeを1つ作る。そこへ期待するイベント列を置き、実際の列が最初に外れたindexだけを出す。対象はまず次の3ケースで十分だ。

tool failure
retry after failure
approval required

CopilotKit Inspectorは、agentとfrontendの間を流れるイベントをアプリ内で観測し、failureを原因側のイベントへ結び付けて追える。保存した会話をPlaygroundへforkして別経路も試せる。ReactだけでなくVueとAngularにも対応し、接続面にはAG-UIを使う。

一方、長時間動くagentではmodel responseだけを見ても足りない。OpenAIのAgents APIも、sessionに加えてtoolやenvironmentをharness側で扱う設計になっている。実行が長くなるほど、frontendが最後のメッセージだけで状態を説明するのは無理が出てくる。

この記事ではInspectorの内部形式を再現しない。SDK固有のイベントを、アプリ内で比較するための小さなTraceEventへ寄せる。そのうえで3つのfixtureをどう切るか考える。

再現専用routeへ隔離する

本番画面へdebug用の分岐を足すと、再現条件まで既存stateに引っ張られる。削除しやすいrouteへ隔離したほうが扱いやすい。

route: /agent-ui-debug-fixture

cases:
  - tool-failure
  - retry-after-failure
  - approval-required

fixture側で固定するのは次の項目だ。

  • user input
  • 疑似toolの応答
  • 応答までの遅延
  • approvalの有無
  • 期待するイベント列

production APIへ接続したままでは、同じ失敗を何度も作れない。fixtureではtool adapterを差し替え、「必ず失敗する」「2回目だけ成功する」「承認を返すまで実行しない」を選べるようにする。

たとえばインターフェースはこのくらいでいい。

type FixtureMode =
  | "tool-failure"
  | "retry-after-failure"
  | "approval-required"

type FixtureTool = {
  execute(input: unknown, context: { attempt: number }): Promise<unknown>
}

function createFixtureTool(mode: FixtureMode): FixtureTool {
  return {
    async execute(_input, { attempt }) {
      if (mode === "approval-required") {
        throw new Error("fixture must stop before tool execution")
      }

      if (mode === "tool-failure" || attempt === 1) {
        throw new Error("fixture tool failure")
      }

      return { ok: true }
    },
  }
}

approval-requiredでexecute()へ到達したら、fixture自体を失敗させる。これなら「承認待ちを表示した」という見た目だけで、裏ではtoolが動いていたケースを見逃しにくい。

SDKのeventをそのままfixtureへ固定しない

Inspectorが表示する内部event名や保存形式を、こちらで推測して型に写すべきではない。SDKの更新で名前が変わると、製品コードとは無関係なfixtureまで壊れる。

比較用には、アプリが必要とする情報だけを残す。

type TraceKind =
  | "request_received"
  | "tool_started"
  | "tool_failed"
  | "approval_required"
  | "tool_completed"
  | "ui_rendered"

type TraceEvent = {
  runId: string
  caseId:
    | "tool-failure"
    | "retry-after-failure"
    | "approval-required"
  attempt: number
  correlationId: string
  kind: TraceKind
}

これはCopilotKit、AG-UI、OpenAIの公式schemaではない。各SDKから届いた情報をadapterで正規化するためのapp-levelな型だ。

各fieldの役割も絞っておく。

  • runId: originalとforkを分ける
  • attempt: retry前後を分ける
  • correlationId: 同じ論理操作を追う
  • kind: UIが判断に使う最小イベント

traceへpromptやtoken、tool payloadの全文を複製する必要はない。認証情報やユーザーデータを保存してしまうくらいなら、比較に使うIDとkindだけに絞るほうがいい。

最初の不一致だけを返す

イベント列を丸ごとdiff表示すると、結局また長いログを読むことになる。入口として欲しいのは最初のズレだ。

runIdとcaseIdは実行を識別するmetadataなので、列の比較からは外す。比較するのはattempt、correlationId、kindにする。

type ComparableTraceEvent = Pick<
  TraceEvent,
  "attempt" | "correlationId" | "kind"
>

function isSameEvent(
  expected: ComparableTraceEvent | undefined,
  observed: ComparableTraceEvent | undefined,
) {
  return (
    expected?.attempt === observed?.attempt &&
    expected?.correlationId === observed?.correlationId &&
    expected?.kind === observed?.kind
  )
}

function firstDivergence(
  expected: readonly ComparableTraceEvent[],
  observed: readonly ComparableTraceEvent[],
) {
  const length = Math.max(expected.length, observed.length)

  for (let index = 0; index < length; index += 1) {
    if (!isSameEvent(expected[index], observed[index])) {
      return {
        index,
        expected: expected[index] ?? null,
        observed: observed[index] ?? null,
      }
    }
  }

  return null
}

片方のevent数が少ない場合も、nullを含む不一致として返る。timestampは比較しない。固定したいのは時刻ではなく、単一操作内の順序と相関だ。

複数toolを並行実行する画面なら、全イベントを一列に押し込まない。先にcorrelationIdで分け、操作ごとに比較する。今回のfixtureは1ケース1操作に限定する。

Case 1: toolが失敗したあともrunningに残る

最初の期待列はこうする。

const correlationId = "profile-update"

const toolFailureExpected = [
  { attempt: 1, correlationId, kind: "request_received" },
  { attempt: 1, correlationId, kind: "tool_started" },
  { attempt: 1, correlationId, kind: "tool_failed" },
  { attempt: 1, correlationId, kind: "ui_rendered" },
] satisfies ComparableTraceEvent[]

疑似toolを必ず失敗させ、次の2項目を別々に見る。

  1. observed eventsは期待列と一致したか
  2. 最終UIはerrorとretry操作を表示し、runningを解除したか

たとえばobservedの3件目がui_renderedなら、最初のズレはindex 2になる。画面にエラー文が出ていても、tool_failedを処理する経路が抜けた可能性が残る。

index: 2
expected.kind: tool_failed
observed.kind: ui_rendered

これは実行結果ではなく、不一致の読み方を示す例だ。実際の値はfixtureを走らせて埋める。

Inspectorでは同じrunのfailureを開き、そこへ至るagentとfrontendのイベントを確認する。Inspector上の名前をTraceKindと同一視せず、adapterの入力とapp側traceを見比べる。

Case 2: retryを同じ実行の二重完了にしない

retryで混ざりやすいのは「同じ操作」と「同じ試行」だ。

ユーザーから見れば同じprofile更新なのでcorrelationIdは維持する。ただし実行単位は別なので、最初をattempt: 1、retryをattempt: 2にする。

const retryExpected = [
  { attempt: 1, correlationId, kind: "request_received" },
  { attempt: 1, correlationId, kind: "tool_started" },
  { attempt: 1, correlationId, kind: "tool_failed" },
  { attempt: 1, correlationId, kind: "ui_rendered" },
  { attempt: 2, correlationId, kind: "request_received" },
  { attempt: 2, correlationId, kind: "tool_started" },
  { attempt: 2, correlationId, kind: "tool_completed" },
  { attempt: 2, correlationId, kind: "ui_rendered" },
] satisfies ComparableTraceEvent[]

このcaseでは、attempt 1の失敗を遅延させてからattempt 2を成功させる。確認したいのは、遅れて届いたattempt 1のeventがattempt 2の完了表示を上書きしないことだ。

UI更新側には少なくともactive attemptの判定が要る。

function canUpdateCurrentUi(event: TraceEvent, activeAttempt: number) {
  return event.attempt === activeAttempt
}

もちろん実装ではcancelや並行toolも考える必要がある。ただ、このfixtureへ全部を持ち込むと失敗理由が増える。まずlate eventとretry成功だけをぶつけ、次の条件を確認する。

active attempt: 2
final UI: completed
attempt 1 late event: ignored for current UI
attempt 2 completion count: 1

attemptを持たずにretryボタンだけ追加すると、遅延eventが届いたときに「どちらの実行結果か」を判定できない。ここはチャット表示の工夫では直らない。

Case 3: approval前にtoolを起動しない

approval fixtureはもっと短い。

request_received
  -> approval_required
  -> stop

期待列も2件だけにする。

const approvalExpected = [
  { attempt: 1, correlationId, kind: "request_received" },
  { attempt: 1, correlationId, kind: "approval_required" },
] satisfies ComparableTraceEvent[]

UIは承認待ちを表示する。tool_started、tool_completed、成功表示が出たらfailだ。approve後やreject後の完全なstate machineは別fixtureへ分ける。今回は「承認前に止まったか」だけを見る。

この境界は、画面上のapproval panelだけ眺めても確認できない。疑似toolのexecute()へ到達していないことと、event列がapproval_requiredで止まったことの両方が必要になる。

originalとforkはrunIdを分けて比較する

再現できる失敗ができたら、保存会話をInspectorのPlaygroundへforkする。入力または疑似tool応答のどちらか1点だけを変える。

original runId: run-original
fork runId: run-fork-01
correlationId: profile-update
changed condition: attempt 1 tool response only

originalを上書きしない。forkをproduction replayやrollbackとも呼ばない。保存した会話から別経路を試すための分岐として扱う。

比較時にはrunIdを落とし、firstDivergence()へ渡す。

function toComparable(event: TraceEvent): ComparableTraceEvent {
  const { attempt, correlationId, kind } = event
  return { attempt, correlationId, kind }
}

const divergence = firstDivergence(
  originalEvents.map(toComparable),
  forkEvents.map(toComparable),
)

runIdまで比較するとindex 0で必ずズレる。それでは分岐条件の確認にならない。runの識別とevent内容の比較は分ける。

fork側で最終結果が成功へ変わっても、見るのは成功メッセージではない。最初に変わったevent、そのeventが変わった入力条件、以降のUI状態をセットで残す。

fixtureは実際のGenerative UIから1件選ぶ

空のチャット画面だけで試すと、agentとfrontendの境界が見えにくい。tool result card、approval panel、dynamic formのどれか1件をfixtureにする。

実装例やSDKを探す入口としては、生成AI UIデザインのリソース集のような分類済み一覧も使える。見つけたdemoのevent設計をそのまま正解にはせず、自分のアプリへ持ち込む時点でTraceEventへ変換する。

今回はprofile更新用のdynamic formを選んだ想定にする。それなら3ケースを同じUIで作れる。

  • 保存toolが失敗し、formを再編集できる
  • retry後に新しいattemptだけが完了する
  • 更新内容の送信前にapprovalで止まる

fixtureごとに別の派手なUIを作るより、同じcomponentへ異なるevent列を流したほうが差を追いやすい。

結果表にnot-runとnot-observedを残す

まだ走らせていないcaseを空欄にすると、後から成功なのか未確認なのか分からなくなる。結果表には未実行と未観測を明示する。

| case | expected | observed | first divergent index | UI after failure | replay |
|---|---|---|---:|---|---|
| tool-failure | 4 events | not-observed | not-observed | not-observed | not-run |
| retry | 8 events | not-observed | not-observed | not-observed | not-run |
| approval | 2 events | not-observed | not-observed | not-observed | not-run |

使い分けはこうする。

  • not-run: fixture自体を実行していない
  • not-observed: 実行したが、その観測点を取得できなかった
  • none: expectedとobservedに不一致がなかった
  • 数値: 最初に不一致が出たindex

passは、event列とUI状態の両方を確認したときだけ付ける。Inspectorでfailureを見つけたことと、アプリ側の表示が正しいことも別判定にする。

Inspectorは答えではなく、adapterを照合する場所にする

Inspectorで見えるevent、アプリが保存するTraceEvent、画面の最終状態。この3つが一致して初めて、失敗経路を説明できる。

ただし、全部を永久保存する必要はない。fixture runの保持期間を短くし、payloadは最小化する。prompt、token、secret、tool response本文を無条件にtraceへ入れない。観測性を足した結果、機密情報の複製先を増やしたら本末転倒だ。

Automatic Learningのように会話からSKILL.md候補を作る機能もあるが、今回のdebug loopとは分ける。失敗経路を観測できたことと、その結果を次のagent動作へ採用することは別のレビューだ。

最後のerrorより、最初に外れたeventを見る

agent UIの不具合は、最後だけ見るとだいたい「失敗しました」に潰れる。その手前で起きているのは別々の問題だ。tool failureをadapterが落としたのか。古いattemptをreducerが採用したのか。approval前にtoolを起動したのか。

最初の不一致が取れると、次に読む場所も狭くなる。

  • Inspectorにはeventがあるのにapp側traceにないなら、adapterを見る
  • tool_failedがtraceにあるのにUIがrunningなら、UI更新処理を見る
  • retry成功後にattempt 1へ戻るなら、active attemptの判定を見る

自分が先に実装するなら、全量ログの保存機能ではなく、1 routeとこの3つのfixtureだ。expectedとobservedを比べ、forkでもoriginalを残す。agentやSDKを替えたあとも、どこから挙動が変わったかを同じ方法で確認できる。

参考資料

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?