Gemini Nano でエージェント ワークフローを構築する

ML Kit のオンデバイス Agentic Workflows(com.google.mlkit:genai-agent、AICore デベロッパー プレビュー プログラムで利用可能)を使用すると、Gemini Nano と Android AICore システム サービスを活用した自律型のマルチターン会話エージェントを構築できます。

直接モデルアクセスを提供する GenerativeModel クラスをベースに構築された AgentRunner クラスは、完全なエージェント ループを自動化します。マルチステップ アクションの計画、型付きツールの呼び出し(アプリ リポジトリのクエリ、バックエンド API の呼び出し、ローカル データベースの読み取りなど)、ツールの結果の評価、会話コンテキストのローカル デバイスでの管理を、ネットワーク レイテンシなしで、ユーザーのプライバシーを完全に保護しながら行います。GenerativeModel クラスは、List<Content> オブジェクト(USER ロールと MODEL ロールを使用)と sessionId 値を使用して、クライアント管理のマルチターン履歴をサポートします。一方、AgentRunner クラスは、AgentSession インターフェースを使用して、マネージド マルチターン セッションを提供します。このセッションでは、ターン間でメモリ内会話履歴が自動的に記録されるため、文字起こしを手動で管理する必要はありません。静的なツール宣言とシステム指示もデバイスに自動的にキャッシュされるため、追加の構成なしで最初のトークンまでのレイテンシを短縮できます。

コア コンポーネント

  • Agent クラス: Agent.builder() メソッドを使用して、指示、使用可能なツール、実行ガードレールを宣言します。デフォルトは自動的に maxTurns = 10 に設定されます。
  • AgentRunner クラス: GenerativeModel クラスをベースに構築され、オンデバイス モデルを使用して自律的な推論ループとマネージド セッションをオーケストレートします。Dispatchers.IO ではデフォルトでメインセーフ。temperature のデフォルトは null(モデルのデフォルトに従う。貪欲デコードの場合は temperature = 0.0f を渡す)。
  • AgentSession インターフェース: runner.getOrCreateSession(sessionId) メソッドを使用して取得された、スコープ付きのマルチターン会話セッション ハンドル。runAsync(content) メソッドと close() メソッドを使用して、ターン間でメモリ内の会話履歴を自動的に記録します。
  • AgentEvent インターフェース: テキスト トークンとツールの更新を生成するリアクティブな Kotlin Flow ストリーム。
  • AgentTool クラス: Android AppFunctions(@AppFunctionDeclaration)を使用して定義された、モデルから呼び出し可能な型付き関数。
  • GenAiException クラス: MAX_TURNS_EXCEEDED、NOT_AVAILABLE、BUSY などの障害状態を通知する、型付き例外階層(com.google.mlkit.genai.common.GenAiException)。

前提条件と依存関係

Agent API を統合する前に、開発環境と Gradle 構成が次の要件を満たしていることを確認してください。

プラットフォームとハードウェアの要件

Gradle 構成

モジュールの build.gradle.kts ファイルに genai-agent 依存関係を追加します。

dependencies {
    // Gemini Nano agentic workflows
    implementation("com.google.mlkit:genai-agent:1.0.0-alpha1")

    // Optional: Only required when defining tools using Android AppFunctions
    implementation("androidx.appfunctions:appfunctions:1.0.0-alpha13")
    ksp("androidx.appfunctions:appfunctions-compiler:1.0.0-alpha13")
}

genai-agent 自体はアノテーション プロセッサやコンパイラ プラグインを必要としませんが、androidx.appfunctions を使用してツールを定義するには、appfunctions-compiler KSP プラグインが必要です。

クイックスタート: エージェントを初期化する

次のコードサンプルは、オンデバイス エージェントとランナーをインスタンス化する方法を示しています。

import com.google.mlkit.genai.agent.Agent
import com.google.mlkit.genai.agent.AgentRunner

// Configure the agent with system instructions
val agent = Agent.builder()
    .setInstructions("You are a helpful on-device assistant.")
    .build()

// Create the runner to execute prompts
val runner = AgentRunner(agent)

ツールを定義する

モデルは、型付きツールを呼び出して、アプリデータのクエリ、バックエンド サービスとの通信、アプリ内コンバージョンのトリガーを行うことができます。ツールは、Android AppFunctions(androidx.appfunctions)を使用して宣言的に定義されます。

package com.example.ai.agent.tools

import androidx.appfunctions.AppFunctionDeclaration
import androidx.appfunctions.AppFunctionSerializable
import androidx.appfunctions.AppFunctionService
import androidx.appfunctions.AppFunctionServiceEntryPoint
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext

@AppFunctionSerializable
data class OrderDetails(
    val orderId: String,
    val status: String,
    val eta: String,
)

@AppFunctionServiceEntryPoint(
    serviceName = "OrderAppFunctionService",
    appFunctionXmlFileName = "order_app_functions",
)
abstract class OrderTools : AppFunctionService() {

    /**
     * Fetches details and delivery status for an in-app order.
     *
     * @param orderId The alphanumeric order identifier
     */
    @AppFunctionDeclaration(isDescribedByKDoc = true)
    suspend fun fetch_order_details(
        orderId: String
    ): OrderDetails = withContext(Dispatchers.IO) {
        // Query local database or call your backend API
        OrderDetails(
            orderId = orderId,
            status = "Out for delivery",
            eta = "2:30 PM",
        )
    }
}

生成されたサービスをアプリの AndroidManifest.xml ファイルに登録します。

<service
    android:name=".OrderAppFunctionService"
    android:permission="android.permission.BIND_APP_FUNCTION_SERVICE"
    android:exported="true">
    <property
        android:name="android.app.appfunctions.schema"
        android:value="app_functions_schema.xsd" />
    <property
        android:name="android.app.appfunctions.v2"
        android:value="order_app_functions.xml" />
    <intent-filter>
        <action android:name="android.app.appfunctions.AppFunctionService" />
    </intent-filter>
</service>

ACCESS_LEVEL_SELF 定数を使用して呼び出し元のアクセスを制限する

AppFunctions は、システムや外部の呼び出し元に公開する必要はありません。Android では、アクセス制御は呼び出し元の ID を検査するのではなく、呼び出し元のアクセスレベルによって処理されます。

デフォルトでは(ACCESS_LEVEL_ANDROID_TRUSTED 定数を使用)、AppFunctions は、EXECUTE_APP_FUNCTIONS システム権限を持つプラットフォーム認定のオンデバイス アシスタントから呼び出すことができます。ツールがアプリの内部エージェント専用(ユーザーカートやプライベート データベース レコードの管理など)の場合は、ACCESS_LEVEL_SELF 定数を使用して呼び出し元のアクセスを制限します。

import androidx.appfunctions.AppFunctionAccessLevel
import androidx.appfunctions.AppFunctionDeclaration
import androidx.appfunctions.AppFunctionService
import androidx.appfunctions.AppFunctionServiceEntryPoint
import androidx.appfunctions.ExperimentalAppFunctionsApi
import androidx.appfunctions.metadata.AppFunctionMetadata

@AppFunctionServiceEntryPoint(
    serviceName = "OrderAppFunctionService",
    appFunctionXmlFileName = "order_app_functions",
)
abstract class OrderTools : AppFunctionService() {

    /**
     * Cancels an order. Restricted strictly to this app's internal agent.
     */
    @OptIn(ExperimentalAppFunctionsApi::class)
    @AppFunctionDeclaration(isDescribedByKDoc = true)
    @AppFunctionAccessLevel(
        level = AppFunctionMetadata.ACCESS_LEVEL_SELF,
        isCompatEnforcementEnabled = true,
    )
    fun cancel_order(
        orderId: String
    ): String {
        // Only your app's internal agent can invoke this function
        return "Order $orderId cancelled successfully."
    }
}

ACCESS_LEVEL_SELF 定数で制限されている場合、プラットフォームは、外部サーフェスとシステム アシスタントが IPC 経由で関数を検出または実行するのをブロックします。

エージェントを構成する

システム指示を使用して Agent インスタンスを構成し、AppFunctions を有効にしてから、AgentRunner インスタンスを初期化します。

import com.google.mlkit.genai.agent.Agent
import com.google.mlkit.genai.agent.AgentRunner

// Configure the agent with system instructions and enable AppFunctions
val agent = Agent.builder()
    .setName("OrderAssistant")
    .setInstructions(
        "You are a helpful assistant for looking up store orders. " +
        "Use available tools to fetch order information."
    )
    .enableAppFunctions(true) // Automatically discovers all registered AppFunctions in your app
    .build()

// Initialize the runner
val runner = AgentRunner(agent)

必要に応じて、addAppFunctionIds("fetch_order_details", "cancel_order") メソッドを呼び出して、すべてのアプリ内機能を公開するのではなく、特定の AppFunction ID に厳密に検出範囲を限定します。

エージェントのデフォルト

  • ターン数の上限: Agent.Builder クラスは、無限のツール呼び出しループを防ぐために、終了条件として maxTurns = 10 を自動的にデフォルト設定します。標準の会話型ワークフローまたはツール呼び出しワークフローでは、setMaxTurns(10) メソッドを手動で呼び出す必要はありません。
  • 決定的貪欲デコード: AgentRunner クラスは、デフォルトの temperature パラメータを null に設定し、基盤となるモデルのデフォルトに委ねます。デバイスで決定論的なツール選択と JSON スキーマの準拠を実現するには、temperature = 0.0f(temperature)を AgentRunner コンストラクタに渡します。クラウドスタイルの温度(> 0.4f)を設定しないでください。ハルシネーションのリスクが高まり、モバイルモデルのスキーマが劣化します。

マルチターン実行とストリーミング イベント

Android ViewModel クラス内の runner.getOrCreateSession(sessionId) メソッドを使用して、AgentSession インスタンスを取得します。セッションは、リアルタイムのテキスト トークンとツールの実行の更新を送信する Flow<AgentEvent> ストリームを返します。

package com.example.ai.agent

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.google.mlkit.genai.agent.AgentEvent
import com.google.mlkit.genai.agent.AgentRunner
import com.google.mlkit.genai.agent.AgentSession
import com.google.mlkit.genai.common.GenAiException
import com.google.mlkit.genai.prompt.Content
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch

data class AgentUiState(
    val messages: List<String> = emptyList(),
    val currentResponse: String = "",
    val activeToolStatus: String? = null,
    val isLoading: Boolean = false,
    val errorMessage: String? = null
)

class ChatViewModel(
    private val runner: AgentRunner,
    private val sessionId: String = "chat_session"
) : ViewModel() {

    // Scoped session preserves conversation history across configuration changes (screen rotations)
    private val session: AgentSession = runner.getOrCreateSession(sessionId)

    private val _uiState = MutableStateFlow(AgentUiState())
    val uiState: StateFlow<AgentUiState> = _uiState.asStateFlow()

    fun sendMessage(userText: String) {
        _uiState.update {
            it.copy(
                messages = it.messages + userText,
                currentResponse = "",
                activeToolStatus = null,
                isLoading = true,
                errorMessage = null
            )
        }

        viewModelScope.launch {
            val content = Content.builder().text(userText).build()
            session.runAsync(content)
                .catch { e ->
                    val message = when {
                        e is GenAiException && e.errorCode == GenAiException.ErrorCode.MAX_TURNS_EXCEEDED ->
                            "Agent exceeded the maximum reasoning turns allowed."
                        e is GenAiException && e.errorCode == GenAiException.ErrorCode.NOT_AVAILABLE ->
                            "On-device AI is currently unavailable."
                        e is GenAiException && e.errorCode == GenAiException.ErrorCode.BUSY ->
                            "On-device AI is busy. Please try again."
                        else -> e.localizedMessage ?: "Agent error"
                    }
                    _uiState.update {
                        it.copy(
                            isLoading = false,
                            activeToolStatus = null,
                            errorMessage = message
                        )
                    }
                }
                .collect { event ->
                    when (event) {
                        is AgentEvent.TextMessage -> {
                            _uiState.update {
                                it.copy(
                                    currentResponse = if (event.isPartial) {
                                        it.currentResponse + event.text
                                    } else {
                                        event.text.ifEmpty { it.currentResponse }
                                    },
                                    activeToolStatus = null
                                )
                            }
                        }
                        is AgentEvent.FunctionCall -> {
                            _uiState.update {
                                it.copy(activeToolStatus = "Executing ${event.name}...")
                            }
                        }
                        is AgentEvent.FunctionResponse -> {
                            _uiState.update {
                                it.copy(activeToolStatus = "Received data from ${event.name}")
                            }
                        }
                        else -> {}
                    }
                }

            // Finalize message once stream completes
            _uiState.update {
                it.copy(
                    messages = it.messages + it.currentResponse,
                    currentResponse = "",
                    activeToolStatus = null,
                    isLoading = false
                )
            }
        }
    }

    override fun onCleared() {
        super.onCleared()
        // Cleanly evicts conversation memory from RAM when ViewModel is destroyed
        session.close()
    }
}

テキスト トークンを直接ストリーミングする

ツールの実行の詳細を UI に個別に表示する必要がない会話型インターフェースの場合は、Flow<AgentEvent> インターフェースで textTokens() 拡張関数を使用します。

import com.google.mlkit.genai.agent.textTokens
import com.google.mlkit.genai.prompt.Content

val content = Content.builder().text("What is my order status?").build()
session.runAsync(content)
    .textTokens()
    .collect { token ->
        print(token)
    }

シングルターン実行

会話履歴を RAM に保持する必要がない 1 回限りのタスク(写真のタグ付けや一時的なテキストの要約など)の場合は、runner.runSingleTurnAsync(content) メソッドを直接呼び出します。

// Ephemeral single-turn: cleans up session state from RAM automatically upon completion
val content = Content.builder()
    .text("Summarize the order history for customer #1042: ...")
    .build()

runner.runSingleTurnAsync(content)
    .textTokens()
    .collect { token ->
        displaySummary(token)
    }

runner.runSingleTurnAsync(content) メソッドを直接呼び出すと、AgentRunner インスタンスは内部でエフェメラル セッション ID を割り当て、完了時にメモリから会話の状態を自動的に削除して、メモリリークを防ぎます。

セッションのライフサイクルとメモリ管理

マルチターンの会話履歴はメモリに保存され、sessionId 値でインデックスに登録されます。

  • 構成の変更: Android の ViewModel クラス内に AgentSession インスタンスを保持すると、画面の回転時に会話履歴が自動的に保持されます。ターンごとに文字列を渡す必要はありません。
  • セッションの強制排除: 会話スレッドが閉じられたときに session.delete() メソッドを呼び出してセッションを同期的に強制排除するか、ViewModel.onCleared() メソッドで非サスペンド session.close() メソッドを呼び出して RAM を解放します。
  • 一括削除: runner.deleteSession(sessionId) メソッドを呼び出して特定のセッションを解放するか、runner.clearAllSessions() メソッドを呼び出してキャッシュに保存されているすべての会話を削除します。
  • ランナーのライフサイクル: AgentRunner クラスは AutoCloseable インターフェースを実装します。複数の画面でランナーを共有する場合は、各画面の ViewModel.onCleared() メソッドで個々の AgentSession ハンドルを閉じ、ランナーを完全にシャットダウンするときに runner.close() メソッドを呼び出します。
  • 同時セッション: 1 つの AgentRunner インスタンスで、複数の異なるセッションを同時にホストできます。各 sessionId 値は、独立した会話履歴を保持します。

GenAiException クラスを使用したエラー処理

エージェントの実行中のランタイム エラー(エージェントがレスポンスを生成せずにツールの最大ターン数に達した場合や、オンデバイス AI が一時的に利用できない場合など)は、GenAiException 例外(com.google.mlkit.genai.common.GenAiException)として出力またはスローされます。Kotlin コルーチン フローの catch 演算子を使用して、エラーに事後対応します。

session.runAsync(content)
    .catch { e ->
        if (e is GenAiException) {
            when (e.errorCode) {
                GenAiException.ErrorCode.MAX_TURNS_EXCEEDED -> {
                    // Turn limit exceeded
                }
                GenAiException.ErrorCode.NOT_AVAILABLE -> {
                    // Gemini Nano or AICore unavailable
                }
                GenAiException.ErrorCode.BUSY -> {
                    // System busy or rate limit reached
                }
                else -> {
                    // Other GenAiException errors
                }
            }
        }
    }
    .collect { event ->
        // Process response events
    }

主なエラーコード

  • GenAiException.ErrorCode.MAX_TURNS_EXCEEDED(-112): エージェントが最終レスポンスを生成せずに、最大ターン数(maxTurns の上限)を超えました。
  • GenAiException.ErrorCode.NOT_AVAILABLE(8): Gemini Nano モデルまたは AICore システム サービスが利用できないか、準備ができていません。
  • GenAiException.ErrorCode.BUSY(9): システムがビジー状態であるか、呼び出し元のアプリが割り当てを超過しています。