Communityコーディング&開発github.com

EBIKEBUILDER/ai-skills

このスキルは、Google Gemini(@google/genai)を利用したAIチャット、コーディングアシスタント、ドキュメント/ワークスペースエディタの構築やアップグレード時に、ステートフルなマルチターン会話を実現するためのものです。毎回のプロンプトでコードベース全体やドキュメント、コンテキストを再送信することを防ぎます。

ai-skills とは?

このスキルは、AIアシスタントが会話の状態を維持し、効率的かつ高品質なマルチターン対話を実現するための設計パターンと実装ガイドを提供します。従来のステートレスなアプローチでは、後続の質問や編集のたびに全コードベースやドキュメントを再アップロードする必要がありましたが、このスキルではそれを排除します。具体的には、GoogleのInteractions APIを用いたサーバー管理型の状態維持(Pattern 1)と、アプリケーション側で会話履歴を管理しSHA-256ハッシュによるワークスペースの同期を取る方法(Pattern 2)の2つのパターンを解説しています。後者では、未変更のソースコードの再送信を省略し、古いターンのコードをコンパクトなメタデータに置き換えることで、長いセッションでもトークン使用量を抑制します。これにより、レイテンシの低下、トークン消費の最適化、推論品質の向上が期待できます。主にGoogle Gemini(@google/genai)をターゲットとしており、TypeScriptとPython両方のコード例を提供しています。

対応~Claude Code~Codex CLI~Cursor✓Gemini CLI
npx skills add https://github.com/EBIKEBUILDER/ai-skills/tree/HEAD/gemini-multiturn-conversation

Installed? Explore more コーディング&開発 skills: steipete/bluebubbles, steipete/eightctl, steipete/blucli · View all 6 →

お気に入りのAIに質問する

このエージェントスキルを事前に読み込んだ状態で新しいチャットを開きます。

ドキュメント

ステートフルなマルチターンGemini会話(@google/genai)

このスキルが解決するコアな問題

多くのAIアシスタントは、すべてのプロンプトをステートレスな単一ターンリクエストとして誤って扱い、後続の質問や連続した編集のたびにドキュメント全体、コードベース、ワークスペースの状態を再アップロードしたり、以前のターンを単一のテキストブロックに平坦化したりします。これはトークンの浪費、レイテンシの増加、推論品質の低下を招きます。

代わりに、ステートフルなマルチターン会話を維持します。

  1. 初期コンテキスト(コード、ドキュメント、プロジェクトの状態)はターン1で確立されます。
  2. 後続のターンでは、未変更の状態を再送信せずに、同じ会話を継続します(previous_interaction_id または contents 内の構造化された user/model ターン)。
  3. コードまたはドキュメントの完全な状態は、ユーザーがAIフローの外部でワークスペースを手動で編集した場合(SHA-256ハッシュ比較で検出)にのみ再送信されます。

パターン1: サーバー管理型マルチターン状態(Interactions API)

GoogleのInteractions API(@google/genai >= 2.3.0)を使用する場合、Geminiは会話コンテキストをGoogleのサーバー上で保持します(デフォルトでstore: true)。previous_interaction_id: interaction.id を渡すことでターンを連鎖させます。

TypeScript(@google/genai)

import { GoogleGenAI } from "@google/genai";

const client = new GoogleGenAI({});

// ターン1: 会話と初期のワークスペース/ドキュメント状態を確立
const turn1 = await client.interactions.create({
  model: "gemini-3.7-flash",
  system_instruction: "You are an expert coding assistant. Remember the active code across turns.",
  input: `Here is my current file:\n\`\`\`ts\n${initialCode}\n\`\`\`\nAdd input validation.`,
});

console.log(turn1.output_text);

// ターン2: フォローアップの質問または連続編集 — ファイルが未変更の場合は再送信しません!
// Googleのサーバーは previous_interaction_id を介してターン1の入力とモデル出力を既に保持しています。
const turn2 = await client.interactions.create({
  model: "gemini-3.7-flash",
  system_instruction: "You are an expert coding assistant. Remember the active code across turns.",
  previous_interaction_id: turn1.id,
  input: "Why did you choose that validation pattern, and can we also log rejected inputs?",
});

console.log(turn2.output_text);

Python(google-genai)

from google import genai

client = genai.Client()

# ターン1: 初期コンテキストを確立
turn1 = client.interactions.create(
    model="gemini-3.7-flash",
    system_instruction="You are an expert coding assistant. Remember the active code across turns.",
    input=f"""Here is my current file:
```py
{initial_code}

Refactor this into a class.""", )

ターン2: ファイルを再アップロードせずに previous_interaction_id で連鎖

turn2 = client.interactions.create( model="gemini-3.7-flash", system_instruction="You are an expert coding assistant. Remember the active code across turns.", previous_interaction_id=turn1.id, input="Now add a unit test method for edge cases.", )


> **`interactions.create` の重要なルール**:
> - `system_instruction`、`tools`、`generation_config` はインタラクションスコープです。各ターンで再指定してください。
> - `store: true`(デフォルト)を維持してください。`store: false` に設定すると `previous_interaction_id` が無効になります。

---

## パターン2: アプリケーション管理型マルチターン `contents` とハッシュ同期ワークスペース状態

バックエンドが独自のデータベースで会話ターンを管理する場合(または `ai.models.generateContent` / `ai.chats.create` と構造化JSONスキーマ、diff/applyワークフローを使用する場合)、以下の5つのルールに従うことで、未変更のファイルやワークスペースセクションを冗長に再送信することを防ぎます。

### 1. ネイティブな `role: 'user' | 'model'` ターン配列を使用する
  以前のターンを単一のユーザーメッセージ内の1つの文字列に平坦化しないでください。常に適切なマルチターン `contents` 配列を構築します。
```typescript
type ContentTurn = {
  role: "user" | "model";
  parts: Array<{ text: string }>;
};

2. SHA-256ハッシュでワークスペースの同期を追跡する

クライアント上で、会話ごとの同期レコードを維持します。

interface SyncedConversationState {
  activeDocumentSha256: string | null;
  secondaryContextHash: string | null; // 例: 他の開いているタブ、シーン、プロジェクトツリーの概要
}
  • ask(Q&A)ターンが完了したとき: activeDocumentSha256 は送信されたドキュメントのSHA-256のままです。
  • edit ターンが提案で完了したとき: activeDocumentSha256 は proposal.sha256 に更新されます(会話履歴内の model ターンに提案コードが含まれるため)。
  • ユーザーが「適用」をクリックしたとき: activeDocumentSha256 は適用された proposal.sha256 と一致します。
  • ユーザーが「破棄」または「元に戻す」をクリックしたとき: activeDocumentSha256 は提案前の base.sha256 に戻ります。

3. フォローアップターンで未変更のワークスペースコードを省略する(sourceSynchronized: true)

新しいターンを送信する前に、sha256(currentEditorCode) を syncedState.activeDocumentSha256 と比較します。

  • sha256(currentEditorCode) === syncedState.activeDocumentSha256(sourceSynchronized: true)の場合:
    • Ask(Q&A)モード: ファイルをまったくアップロードしません(source: null)。モデルは会話履歴内に正確なアクティブコードをすでに持っています。
    • Edit モード: Geminiに送信する新しい user ターン内で currentSource を重複させません({ mode: "edit", prompt, sourceSynchronized: true })。ローカルベーススナップショットは、クライアント側の差分/元に戻しとバックエンド検証のためだけに保持します。
  • sha256(currentEditorCode) !== syncedState.activeDocumentSha256(sourceSynchronized: false)の場合:
    • ユーザーがファイルを手動で編集したか、ファイルを切り替えました。更新された currentSource をこのターンで添付して会話を再同期し、activeDocumentSha256 を更新します。

4. 完全なソースは履歴内で一度だけ保持する(古いターンをコンパクト化)

10ターンの編集セッションで、10の履歴ターンすべてに10個の完全なファイルコピーを保持しないでください。

  • 完全なコードは、アクティブコードを導入または変更した最新のターン(最新のアクティブ編集を提案した model ターン、または最後に手動編集コードを添付した user ターン)にのみ保持します。
  • コードが置き換えられた古いターンについては、インラインコード文字列をコンパクトなメタデータ({ supersededSourceSha256: "..." })に置き換えます。これにより、完全な会話推論スレッドを維持しながら、長いセッションでもトークン使用量を一定に保ちます。

5. 読み取り専用の二次コンテキスト(他のファイル/タブ/セクション)を重複排除する

アプリがアクティブでないファイルやセクションの読み取り専用サマリーを含む場合:

  • そのサマリーは会話のターン1で送信します。
  • ターン2以降は、ハッシュが変更された場合(secondaryContextHash !== currentSecondaryContextHash)にのみサマリーを再送信します。

参考実装: マルチターンリクエストビルダー(TypeScript)

interface HistoryTurn {
  prompt: string;
  response: string;
  mode: "ask" | "edit";
  baseSource?: string | null;
  baseSha256?: string | null;
  proposalSource?: string | null;
  proposalSha256?: string | null;
  outcomeStatus?: "none" | "proposed" | "applied" | "discarded" | "undone";
}

export function buildMultiTurnContents(params: {
  history: HistoryTurn[];
  mode: "ask" | "edit";
  prompt: string;
  currentSource: string | null;
  sourceSynchronized: boolean;
}) {
  const { history, mode, prompt, currentSource, sourceSynchronized } = params;

  // 現在アクティブなソースを保持している履歴ターンを特定する
  let latestActiveSourceIndex = -1;
  if (sourceSynchronized || currentSource === null) {
    for (let i = history.length - 1; i >= 0; i--) {
      const item = history[i];
      const hasValidProposal =
        item.mode === "edit" &&
        item.proposalSource &&
        item.outcomeStatus !== "discarded" &&
        item.outcomeStatus !== "undone";
      if (hasValidProposal || item.baseSource) {
        latestActiveSourceIndex = i;
        break;
      }
    }
  }

  const contents: Array<{ role: "user" | "model"; parts: Array<{ text: string }> }> = [];

  for (let i = 0; i < history.length; i++) {
    const item = history[i];
    const isLatestSourceTurn = i === latestActiveSourceIndex;
    const hasActiveProposal =
      item.mode === "edit" &&
      Boolean(item.proposalSource) &&
      item.outcomeStatus !== "discarded" &&
      item.outcomeStatus !== "undone";

    // 履歴内のユーザーターン
    const userPayload: Record<string, unknown> = {
      mode: item.mode ?? "ask",
      prompt: item.prompt,
    };
    if (isLatestSourceTurn && !hasActiveProposal && item.baseSource) {
      userPayload.currentSource = item.baseSource;
    } else if (item.baseSha256) {
      userPayload.supersededSourceSha256 = item.baseSha256;
    }
    contents.push({ role: "user", parts: [{ text: JSON.stringify(userPayload) }] });

    // 履歴内のモデルターン
    const modelPayload: Record<string, unknown> = {
      kind: item.mode === "edit" ? "code_proposal" : "answer",
      explanation: item.response,
    };
    if (isLatestSourceTurn && hasActiveProposal && item.proposalSource) {
      modelPayload.proposedSource = item.proposalSource;
    } else if (item.proposalSha256) {
      modelPayload.supersededProposalSha256 = item.proposalSha256;
    }
    contents.push({ role: "model", parts: [{ text: JSON.stringify(modelPayload) }] });
  }

  // 最終(現在の)ユーザーターン
  const omitDuplicateSource = sourceSynchronized && latestActiveSourceIndex !== -1;
  const currentTurnPayload: Record<string, unknown> = { mode, prompt };
  if (omitDuplicateSource) {
    currentTurnPayload.sourceSynchronized = true;
  } else {
    currentTurnPayload.currentSource = currentSource;
  }

  contents.push({ role: "user", parts: [{ text: JSON.stringify(currentTurnPayload) }] });
  return contents;
}

マルチターンAIをレビューまたは実装する際のチェックリスト

  • @google/genai(GoogleGenAI)を使用しており、非推奨の @google/generative-ai を使用していませんか?
  • ターン2以降では、履歴を単一のプロンプト文字列に平坦化する代わりに、マルチターンの user / model ロール(または previous_interaction_id)を保持していますか?
  • フォローアップの質問(Ask モード)で、エディタのコードが変更されていない場合、クライアントからのコードの再アップロードを省略していますか?
  • 連続したAI編集(Edit モード)で、エディタのコードが最後のターンと一致する場合、contents 内の新しいユーザープロンプトから重複する currentSource を省略していますか?
  • 古い置き換えられたコードバージョンは履歴内でコンパクト化され、アクティブなコードバージョンのみが完全に送信されていますか?
  • ユーザーがドキュメントを手動で編集したり、変更を元に戻したりした場合、SHA-256の不一致が次のターンで自動的に再同期をトリガーしますか?

関連スキル