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?

Genkit Agents API、resume()に配列を渡すと落ちる罠

0
Posted at

はじめに

Genkit(Firebaseチーム発、Googleが開発するOSS AIエージェントフレームワーク)に、2026年7月1日、「Agents API」というマルチターン対話APIがプレビュー公開された1。メッセージ履歴・ツールループ・ストリーミング・セッション永続化・人間承認待ち(interrupt)を、chat() という単一インターフェースに統合するのが売りだ。

公式ブログの説明はシンプルで動かせそうに見えたので、実際に genkit@1.39.0 をインストールし、モデル呼び出しなし(フェイクモデル)でローカル完結する最小デモを組んで動かしてみた。すると、ブログの説明どおりに書いたコードが型検証エラーで即死した。原因は型定義ファイルまで潜らないと分からない、ドキュメントには書かれていない挙動だった。

この記事で学べること

  • Genkit Agents API(defineAgent / chat() / interrupt)の実際の使い方
  • 外部APIキーなしでローカル完結するテスト用モデルの組み方
  • chat.resume() に潜む型の罠と、正しい呼び出し方
  • FileSessionStore によるセッション永続化・再開の実際の挙動

対象読者

  • Node.js / TypeScript でAIエージェントを実装しているエンジニア
  • チャットボットの状態管理・割り込み承認フローを自作している方
  • Genkitの導入を検討している方

前提環境

  • Node.js: v22.x
  • genkit: 1.39.0(2026-06-26リリース、npm dist-tag latest

TL;DR

  • Genkit Agents APIは chat() 一つでメッセージ履歴・ストリーミング・interrupt・永続化を扱える。ローカルの defineModel でモック応答を返せば、外部APIキーなしで挙動を検証できる。
  • AgentInterrupt.respond() は単体の ToolResponsePart を返すが、chat.resume() にそのまま(または配列で)渡すと Schema validation failed. Parse Errors: - resume: must be object で落ちる。正しくは { respond: [...] } というオブジェクトで包む必要がある。
  • store を渡さないと snapshotId は常に undefined。永続化・再開を検証するには FileSessionStore 等の実装が必須。

背景・課題

Genkitは2025年2月にNode.js版1.0が正式リリースされたGoogle製のOSS AIアプリケーションフレームワークで、本記事執筆時点でGitHub上に6.2k個のスターが付いている(Apache-2.0ライセンス)2。npmパッケージ genkit は直近1週間(2026-06-29〜07-05)で209,856ダウンロードを記録しており3、Node/TypeScriptのAIエージェント開発で一定のシェアを持つ。

7月1日に公開されたAgents APIは、チャット型エージェントアプリでほぼ毎回必要になる「配管部分」(メッセージ履歴・ツールループ・ストリーミング・セッション永続化・クライアント通信プロトコル)を chat() という単一インターフェースにまとめたものだ。目玉機能は以下の3つ。

  1. interruptible tools: モデルが「人間の承認が必要」と判断するとツール呼び出しが一時停止し、クライアントが承認・却下・値の補完を行ってから続行できる(決済・デプロイ等の危険操作向け)
  2. detachable long-running turns: snapshotId で長時間ターンに後から再接続できる
  3. @genkit-ai/vercel-aiuseChat アダプタ: Vercel AI SDKのフロントエンドコンポーネントと組み合わせられる

公式ブログのコード例は次のように簡潔だ。

const agent = remoteAgent({ url: '/api/weatherAgent' });
const chat = agent.chat();
const res = await chat.send('Weather in Tokyo?');

見た目はシンプルだが、interruptresume の実際の呼び出し方は、ブログにもプレビュー版の型定義にも明記されておらず、実装まで潜って初めて分かった。以下、実際に手を動かして検証した内容を示す。

やったこと

ステップ1: 環境構築

外部のGoogle AI APIキーは使わず、defineModel でローカル完結するフェイクモデルを定義して検証する(ネットワーク・課金なしで挙動を再現できる)。

mkdir genkit-test && cd genkit-test
npm init -y
npm install genkit@1.39.0

ステップ2: interrupt付きエージェントを動かす

defineInterrupt で人間承認が必要なツールを定義し、フェイクモデルの1ターン目でそのツールを呼び出させ、interruptさせる。

// test-agent.mjs
import { genkit } from 'genkit/beta';
import { z } from 'genkit';

const ai = genkit({});

let turn = 0;
ai.defineModel(
  { name: 'test/fake-model', label: 'Fake test model' },
  async (request) => {
    turn += 1;
    if (turn === 1) {
      // 1ターン目: ツール呼び出しでinterruptさせる
      return {
        finishReason: 'stop',
        message: {
          role: 'model',
          content: [
            { toolRequest: { ref: 'call-1', name: 'confirmDeploy', input: { target: 'prod' } } },
          ],
        },
      };
    }
    // 2ターン目(resume後)
    return { finishReason: 'stop', message: { role: 'model', content: [{ text: 'resume受信' }] } };
  }
);

const confirmDeploy = ai.defineInterrupt({
  name: 'confirmDeploy',
  description: 'デプロイ前に人間の承認を求める',
  inputSchema: z.object({ target: z.string() }),
});

const agent = ai.defineAgent({ name: 'deployAgent', model: 'test/fake-model', tools: [confirmDeploy] });

const chat = agent.chat();
const res1 = await chat.send('prodにデプロイして');
console.log('finishReason:', res1.finishReason);   // "interrupted"
console.log('interrupts:', res1.interrupts);

ここまでは公式ブログの説明どおりに動く。res1.finishReason"interrupted"res1.interruptsconfirmDeploy の呼び出し内容が入る。

ステップ3: resumeで再開する(ここで落ちた)

ブログの説明は「クライアントが承認・却下・値の補完を行ってから続行できる」というものだったので、AgentInterrupt.respond() が返す値をそのまま渡してみた。

const interrupt = res1.interrupts[0];
// ❌ これは落ちる
const res2 = await chat.resume([interrupt.respond({ approved: true })]);

結果は次のエラーで即死した。

AgentError: INVALID_ARGUMENT: Schema validation failed. Parse Errors:
- resume: must be object

Provided data:
{
  "resume": [
    { "toolResponse": { "name": "confirmDeploy", "ref": "call-1", "output": { "approved": true } } }
  ]
}

ハマりポイント

ポイント1: resume() は配列ではなくオブジェクトで包む

型定義(@genkit-ai/ai/agent.d.ts)を確認すると、AgentChat.resume() のシグネチャは resume(resume: AgentInput['resume'], ...) となっており、resume の実体は次の形をしている(validateResumeAgainstHistory の引数型より)。

resume: {
  restart?: Array<{ toolRequest: { name: string; ref?: string; input?: unknown } }>;
  respond?: Array<{ toolResponse: { name: string; ref?: string; output?: unknown } }>;
}

つまり respond / restart という キーを持つオブジェクト が要求されており、AgentInterrupt.respond() が返す ToolResponsePart を配列にしただけでは足りない。正しくは次のように { respond: [...] } で包む。

// ✅ これで通る
const res2 = await chat.resume({ respond: [interrupt.respond({ approved: true })] });
console.log(res2.text);          // "了解、resume受信"
console.log(res2.finishReason);  // "stop"

resume() のJSDocコメントには「Sugar for send({ resume })」としか書かれておらず、respond/restart という具体的なキー名は型定義を辿らないと分からない。「builders: they return the part to put into a resume payload」という注釈はあるが、payload全体の形(オブジェクトかどうか)までは説明されていなかった。

interrupt.respond() は単体の ToolResponsePart を返す「部品」であり、それ自体が resume の全体ではない。複数の interrupt に同時に答える場合も { respond: [part1, part2] } のように1つのオブジェクトにまとめる。

ポイント2: snapshotIdはstoreを渡さないと常にundefined

上記のデモでは res1.snapshotId が常に undefined だった。永続化用の storedefineAgent に渡していないためで、これは正しい挙動だ。FileSessionStore を渡すと snapshotId / sessionId が発行され、loadChat({ sessionId }) で会話を再開できる。

import { FileSessionStore } from 'genkit/beta';

const agent = ai.defineAgent({
  name: 'storedAgent',
  model: 'test/fake-model2',
  store: new FileSessionStore('./.snapshots'),
});

const chat = agent.chat();
const res1 = await chat.send('こんにちは');
console.log(res1.snapshotId, res1.sessionId);
// => 73e0a08c-... d1399b4a-...

const reloaded = await agent.loadChat({ sessionId: res1.sessionId });
console.log(reloaded.messages.length); // => 2(履歴が復元されている)

実際に生成されたファイルを見ると、.snapshots/global/{snapshotId}.json にターンごとのスナップショットが、.snapshots/global/.pointers/{sessionId}.json に「そのセッションの最新スナップショットID」への参照が保存されていた。sessionId は全ターンで固定、snapshotId はターンごとに新しいUUIDが発行される設計だ。

著者視点の発見ポイント

公式ブログとGitHubリリースノートは「interruptible toolsで人間承認を挟める」という機能の存在は明確に説明していたが、実装者が実際にコードを書くときに必要な resume の正確なペイロード形状({ respond: [...] } というオブジェクトである点)には触れていなかった。プレビュー版APIらしく、ハイレベルな機能説明と実装者向けの正確な型仕様の間にギャップがある状態だった。同様にAnthropicのClaude Agent SDKでも、CHANGELOGの説明が簡略化されすぎていて実際の型シグネチャと差分があるケースに遭遇したことがある。プレビュー/ベータ版のAPIは、ブログの説明だけで実装せず、型定義ファイル(.d.ts)まで確認してから書き始めた方が手戻りが少ない というのが今回の教訓だ。

まとめ

  • Genkit Agents APIは chat() 一つでメッセージ履歴・ストリーミング・interrupt・永続化をまとめて扱える、実用的なAPIだった
  • defineModel でモック応答を返せば、外部APIキーなしでinterrupt/resumeの挙動をローカル検証できる
  • chat.resume() には { respond: [...] } / { restart: [...] } というオブジェクトを渡す必要があり、配列を直接渡すと型検証エラーになる
  • store を渡さない限り snapshotId は発行されない。永続化を試すなら FileSessionStore / InMemorySessionStore を明示的に渡す

現状はプレビュー版でAPIが変わる可能性がある点には注意しつつ、チャット型エージェントの状態管理を自前実装している場合は移行の価値がありそうだ。

参考リンク

  1. Build agentic full-stack apps with Genkit(2026-07-01公開)

  2. firebase/genkit(2026-07-07時点でStar 6.2k・Apache-2.0ライセンス)

  3. npm downloads API(api.npmjs.org/downloads/point/last-week/genkit)実測値。2026-06-29〜2026-07-05の週間ダウンロード数209,856件

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?