「GmailとGoogle Calendarを連携させて、今日の予定を要約してくれるAIが欲しい」——そう思い立ってコードを書き始めたものの、認証の設定でつまずき、APIの制限に引っかかり、コストが想定外に膨らんで諦めた、という経験はありませんか。
私自身、個人プロジェクトでこのパターンを何度か繰り返しました。Google Workspace APIは豊富な機能を持つ反面、認証フロー・スコープ管理・クォータ制限といった「周辺の複雑さ」が思った以上に重く、AIの実装に入る前に力尽きることが多いのです。
ここではGemini APIとGoogle Workspace(Gmail・Calendar・Drive)を統合したパーソナル秘書AIエージェントを本番環境で動かすための設計と実装を、詰まりやすいポイントも含めてすべて解説します。単なるチュートリアルではなく、「これで本番に出せる」と自信を持てるレベルの実装を目指しました。
なぜ「秘書AI」プロジェクトは失敗しやすいのか
最初に、よく見るアンチパターンを3つ挙げます。これを先に知っておくことで、実装の優先順位が変わります。
アンチパターン1:ツールを1つずつ繋げていく設計
Gmail→AIで処理→Calendarに転記、というシリアルな連携を最初から設計してしまうケースです。後からDriveやDocsを追加しようとすると、コードが複雑に絡み合って保守困難になります。最初からGemini Function Callingを使い、AIがツール選択を担う設計にするほうが長期的に正解です。
アンチパターン2:ユーザー認証とサービスアカウントを混在させる
「個人用だからOAuth2、チーム用だからサービスアカウント」という単純な使い分けが崩れ、両方の認証情報が混在するコードになりがちです。スコープ管理が破綻します。パーソナル用途はOAuth2一本で統一することをおすすめします。
アンチパターン3:コスト設計なしに長文コンテキストを渡す
Gmail全受信トレイをGeminiに渡して要約させようとすると、1回のAPI呼び出しで数百円になることがあります。フィルタリングとキャッシング設計がなければ、個人用途でも月数万円の請求が来ます。
これら3つを踏まえた設計を、以下で順を追って解説します。
Google Workspace API の認証設計 — OAuth2 vs サービスアカウントの選択基準
Google Workspace APIへのアクセスは、大きく2つの認証方式があります。
OAuth2(認証コードフロー) : ユーザー自身のデータにアクセスする場合に使います。Gmailの受信トレイ、自分のカレンダー、自分のDriveファイルを操作するパーソナル用途はこちらです。アクセストークンは1時間で失効するため、リフレッシュトークンの管理が必要になります。
サービスアカウント : Google Workspace管理者が一括管理するドメイン全体のデータにアクセスする場合、またはCI/CDなどのヘッドレス環境での利用に向いています。個人用秘書AIには通常不要です。
パーソナル秘書AIの認証フローを実装します。以下はトークンのファイル保存と自動リフレッシュを含む、実用的な認証クラスです:
# workspace_auth.py
import logging
from pathlib import Path
from google.auth.transport.requests import Request
from google.oauth2.credentials import Credentials
from google_auth_oauthlib.flow import InstalledAppFlow
from googleapiclient.discovery import build
logger = logging.getLogger( __name__ )
# 必要なスコープを明示的に定義する
# 最小権限の原則:必要なスコープだけ要求すること
SCOPES = [
"https://www.googleapis.com/auth/gmail.readonly" ,
"https://www.googleapis.com/auth/gmail.compose" ,
"https://www.googleapis.com/auth/calendar.readonly" ,
"https://www.googleapis.com/auth/drive.readonly" ,
]
TOKEN_PATH = Path( "~/.config/secretary-ai/token.json" ).expanduser()
CREDENTIALS_PATH = Path( "~/.config/secretary-ai/credentials.json" ).expanduser()
def get_credentials () -> Credentials:
"""
OAuth2認証情報を取得または更新する。
初回実行時はブラウザでの認可フローが起動する。
2回目以降はトークンファイルから読み込み、期限切れなら自動リフレッシュする。
"""
creds = None
# 既存トークンの読み込み
if TOKEN_PATH .exists():
creds = Credentials.from_authorized_user_file( str ( TOKEN_PATH ), SCOPES )
# トークンが無効または期限切れの場合
if not creds or not creds.valid:
if creds and creds.expired and creds.refresh_token:
try :
creds.refresh(Request())
logger.info( "アクセストークンを更新しました" )
except Exception as e:
logger.warning( f "トークン更新失敗。再認証が必要です: { e } " )
creds = None
if not creds:
if not CREDENTIALS_PATH .exists():
raise FileNotFoundError (
f "credentials.json が見つかりません: { CREDENTIALS_PATH }\n "
"Google Cloud Console でOAuth2クライアントIDを作成してダウンロードしてください"
)
flow = InstalledAppFlow.from_client_secrets_file(
str ( CREDENTIALS_PATH ), SCOPES
)
creds = flow.run_local_server( port = 0 )
# トークンをファイルに保存(次回以降に再利用)
TOKEN_PATH .parent.mkdir( parents = True , exist_ok = True )
TOKEN_PATH .write_text(creds.to_json())
logger.info( "トークンを保存しました" )
return creds
def build_service (service_name: str , version: str ):
"""Google APIサービスクライアントを生成する"""
creds = get_credentials()
return build(service_name, version, credentials = creds)
ここで重要なのはスコープの設計です。gmail.readonly と gmail.compose を分けているのは意図的です。読み取りだけで良い処理に gmail.modify のような強いスコープを渡すと、バグ時のリスクが大きくなります。最小権限の原則をAPIスコープにも適用することをおすすめします。
Gmail エージェントの実装 — メール分類・優先度スコアリングと本文取得の2段階設計
認証が整ったら、Gmailとの統合に入ります。よくある実装の失敗は「未読メールを全件取得してGeminiに渡す」パターンです。未読100件を全文取得すると、スニペットだけでも数万トークンになります。
実用的なアプローチは2段階フィルタリング です:
Gmail APIの q パラメーターでサーバーサイドフィルタリング(日付・ラベル・送信者)
取得したメタデータだけをGeminiに渡して優先度スコアリング
高優先度と判定されたメールのみ本文を取得する
# gmail_agent.py
import base64
from datetime import datetime, timedelta
from workspace_auth import build_service
def fetch_recent_emails (max_results: int = 20 , days_back: int = 1 ) -> list[ dict ]:
"""
直近N日の重要メールを取得する。
本文は取得せず、メタデータとスニペットのみ返す(コスト削減)。
"""
service = build_service( "gmail" , "v1" )
# 日付フィルター(サーバーサイドで絞る)
after_date = (datetime.now() - timedelta( days = days_back)).strftime( "%Y/%m/ %d " )
query = f "is:unread after: { after_date } -category:promotions -category:social"
result = service.users().messages().list(
userId = "me" ,
q = query,
maxResults = max_results
).execute()
messages = result.get( "messages" , [])
email_data = []
for msg in messages:
# metadataフォーマットで取得(本文は含まない)
detail = service.users().messages().get(
userId = "me" ,
id = msg[ "id" ],
format = "metadata" ,
metadataHeaders = [ "Subject" , "From" , "Date" ]
).execute()
headers = {h[ "name" ]: h[ "value" ] for h in detail.get( "payload" , {}).get( "headers" , [])}
email_data.append({
"id" : msg[ "id" ],
"subject" : headers.get( "Subject" , "(件名なし)" ),
"from" : headers.get( "From" , "" ),
"date" : headers.get( "Date" , "" ),
"snippet" : detail.get( "snippet" , "" ),
})
return email_data
def get_email_body (message_id: str ) -> str :
"""メール本文を取得する(優先度の高いメールのみ呼ぶ)"""
service = build_service( "gmail" , "v1" )
detail = service.users().messages().get(
userId = "me" ,
id = message_id,
format = "full"
).execute()
def extract_text (payload: dict ) -> str :
"""マルチパートメールから平文テキストを再帰的に取得"""
if payload.get( "mimeType" ) == "text/plain" :
data = payload.get( "body" , {}).get( "data" , "" )
if data:
return base64.urlsafe_b64decode(data).decode( "utf-8" , errors = "replace" )
for part in payload.get( "parts" , []):
text = extract_text(part)
if text:
return text
return ""
body = extract_text(detail.get( "payload" , {}))
# 長すぎる場合は最初の2000文字に制限(トークンコスト管理)
return body[: 2000 ] if len (body) > 2000 else body
このコードのポイントは format="metadata" の活用です。本文を取得しないことでGmail APIの割り当て消費を抑えつつ、Geminiへ送るデータ量も最小化しています。get_email_body() は、後のFunction Callingから優先度の高いメールに対してのみ呼び出します。
Calendar エージェントの実装 — タイムゾーン処理と会議準備サマリー
Google Calendar APIはGmail APIよりシンプルですが、タイムゾーン処理に罠があります。APIが返す時刻はUTCまたは任意のタイムゾーンで、日本語での出力を期待すると時刻がずれることがあります。
# calendar_agent.py
from datetime import datetime
from zoneinfo import ZoneInfo
from workspace_auth import build_service
JST = ZoneInfo( "Asia/Tokyo" )
def fetch_todays_events () -> list[ dict ]:
"""
今日の予定を取得する。
タイムゾーンをJSTに揃えて返す。
"""
service = build_service( "calendar" , "v3" )
# JST基準で今日の開始・終了時刻を計算
now_jst = datetime.now( JST )
day_start = now_jst.replace( hour = 0 , minute = 0 , second = 0 , microsecond = 0 )
day_end = now_jst.replace( hour = 23 , minute = 59 , second = 59 , microsecond = 0 )
events_result = service.events().list(
calendarId = "primary" ,
timeMin = day_start.isoformat(),
timeMax = day_end.isoformat(),
singleEvents = True ,
orderBy = "startTime" ,
maxResults = 20 ,
).execute()
events = events_result.get( "items" , [])
formatted = []
for event in events:
start_raw = event.get( "start" , {})
end_raw = event.get( "end" , {})
# 終日イベントとtimeイベントを区別する
if "dateTime" in start_raw:
start_dt = datetime.fromisoformat(start_raw[ "dateTime" ]).astimezone( JST )
end_dt = datetime.fromisoformat(end_raw[ "dateTime" ]).astimezone( JST )
time_str = f " { start_dt.strftime( '%H:%M' ) } 〜 { end_dt.strftime( '%H:%M' ) } "
else :
time_str = "終日"
formatted.append({
"title" : event.get( "summary" , "(タイトルなし)" ),
"time" : time_str,
"location" : event.get( "location" , "" ),
"description" : event.get( "description" , "" )[: 300 ],
"attendees" : [a.get( "email" , "" ) for a in event.get( "attendees" , [])],
})
return formatted
タイムゾーンの扱いで pytz ではなく zoneinfo(Python 3.9以降の標準ライブラリ)を使っているのは、pytz の localize() メソッドの直感に反する挙動を避けるためです。datetime.astimezone(ZoneInfo("Asia/Tokyo")) のほうが安全です。
全ツールを束ねる Gemini Function Calling — マルチツールオーケストレーションの設計
ここからが本記事のコアです。Gemini APIのFunction Callingを使い、AIが状況に応じてGmail・Calendar・Driveのどのツールを呼ぶかを自律的に判断するオーケストレーター層を構築します。
# secretary_agent.py
import json
import google.generativeai as genai
from gmail_agent import fetch_recent_emails, get_email_body
from calendar_agent import fetch_todays_events
# ツール定義(Geminiに渡すFunction Schema)
WORKSPACE_TOOLS = [
genai.protos.Tool(
function_declarations = [
genai.protos.FunctionDeclaration(
name = "get_todays_schedule" ,
description = "今日のGoogle Calendarの予定を取得する。日程確認・会議準備に使う。" ,
parameters = genai.protos.Schema(
type = genai.protos.Type. OBJECT ,
properties = {},
required = [],
),
),
genai.protos.FunctionDeclaration(
name = "get_unread_emails" ,
description = "未読メールのリストをメタデータ(件名・送信者・スニペット)付きで取得する。優先度判断に使う。" ,
parameters = genai.protos.Schema(
type = genai.protos.Type. OBJECT ,
properties = {
"max_results" : genai.protos.Schema(
type = genai.protos.Type. INTEGER ,
description = "取得するメール数。デフォルト15。" ,
),
},
required = [],
),
),
genai.protos.FunctionDeclaration(
name = "read_email_body" ,
description = "特定のメールの本文を取得する。優先度の高いメールの詳細確認に使う。" ,
parameters = genai.protos.Schema(
type = genai.protos.Type. OBJECT ,
properties = {
"message_id" : genai.protos.Schema(
type = genai.protos.Type. STRING ,
description = "取得するメールのID(get_unread_emailsで取得したid)" ,
),
},
required = [ "message_id" ],
),
),
]
)
]
def handle_function_call (function_name: str , function_args: dict ) -> str :
"""
Geminiが呼び出したFunctionを実行して結果を返す。
エラーが発生してもJSON形式で返すことでGeminiが適切に処理できる。
"""
try :
if function_name == "get_todays_schedule" :
events = fetch_todays_events()
return json.dumps({ "events" : events, "count" : len (events)}, ensure_ascii = False )
elif function_name == "get_unread_emails" :
max_results = function_args.get( "max_results" , 15 )
emails = fetch_recent_emails( max_results = max_results)
return json.dumps({ "emails" : emails, "count" : len (emails)}, ensure_ascii = False )
elif function_name == "read_email_body" :
message_id = function_args[ "message_id" ]
body = get_email_body(message_id)
return json.dumps({ "body" : body}, ensure_ascii = False )
else :
return json.dumps({ "error" : f "未知の関数: { function_name } " }, ensure_ascii = False )
except Exception as e:
# エラーをGeminiに伝えることで、エラーを踏まえた回答が返ってくる
return json.dumps({ "error" : str (e), "function" : function_name}, ensure_ascii = False )
def run_secretary_agent (user_query: str ) -> str :
"""
パーソナル秘書AIエージェントのメインループ。
Geminiが必要なツールを自律的に呼び出してユーザーの質問に答える。
"""
genai.configure( api_key = "YOUR_GEMINI_API_KEY" )
model = genai.GenerativeModel(
model_name = "gemini-2.5-pro" ,
tools = WORKSPACE_TOOLS ,
system_instruction = """あなたはユーザーの個人秘書AIです。
Gmail・Google Calendar・Google Driveのツールを使って、
ユーザーの質問に正確かつ簡潔に答えてください。
重要な指示:
- 複数のツールが必要な場合は順番に呼び出す
- メールの優先度は「返信が必要か」「締め切りがあるか」「重要人物からか」で判断する
- 個人情報に配慮した適切な要約を心がける
- 時刻は日本時間(JST)で表示する""" ,
)
chat = model.start_chat()
response = chat.send_message(user_query)
# Function Callingのループ処理
max_iterations = 5 # 無限ループ防止
iteration = 0
while response.candidates[ 0 ].content.parts and iteration < max_iterations:
# Function Callが含まれているか確認
function_calls = [
part for part in response.candidates[ 0 ].content.parts
if hasattr (part, "function_call" ) and part.function_call.name
]
if not function_calls:
# テキスト応答のみ → 最終回答
break
# 各Function Callを実行して結果を返す
function_responses = []
for part in function_calls:
fc = part.function_call
result = handle_function_call(fc.name, dict (fc.args))
function_responses.append(
genai.protos.Part(
function_response = genai.protos.FunctionResponse(
name = fc.name,
response = { "result" : result},
)
)
)
response = chat.send_message(function_responses)
iteration += 1
# 最終テキストを抽出
final_text = ""
for part in response.candidates[ 0 ].content.parts:
if hasattr (part, "text" ):
final_text += part.text
return final_text
この実装で特に注目してほしいのは max_iterations = 5 の設定です。Function Callingのループには必ず上限を設けてください。Geminiが何らかの理由でツール呼び出しを繰り返す状況(ツール結果がエラーのまま回復しないケースなど)でも、無限課金を防げます。
本番運用の現実 — コスト・認証更新・エラー回復の設計パターン
ここは実装記事でよく省略される部分ですが、本番で最もよく問題になる箇所でもあります。
コスト管理
パーソナル秘書AIを毎朝実行する場合、1回あたりのコストを試算しておく必要があります。メタデータのみで未読15件を処理するケースでは、入力トークンは通常2,000〜5,000程度に収まります。Gemini 2.5 Proの場合、このレンジであれば1回あたり0.5〜1.5円程度です。
ただし、「全メール本文を読む」「長いカレンダー説明を全文渡す」操作を追加すると、一気に10〜20倍になります。コード内で [:2000] のように文字数を制限しているのは、この理由からです。
コスト追跡の最小実装としては、Geminiのレスポンスに含まれる usage_metadata を記録することをおすすめします:
# コスト追跡(secretary_agent.pyに追記)
import datetime
import json
from pathlib import Path
def log_usage (response, query: str ):
"""
API使用量をJSONLファイルに記録する。
月次でコストを集計できるようにする。
期待する出力例:
{"timestamp": "2026-05-03T04:45:00", "query_preview": "今日の予定をまとめて",
"prompt_tokens": 3240, "response_tokens": 512, "total_tokens": 3752}
"""
usage = response.usage_metadata
log_entry = {
"timestamp" : datetime.datetime.now().isoformat(),
"query_preview" : query[: 50 ],
"prompt_tokens" : usage.prompt_token_count,
"response_tokens" : usage.candidates_token_count,
"total_tokens" : usage.total_token_count,
}
log_path = Path( "~/.config/secretary-ai/usage.jsonl" ).expanduser()
log_path.parent.mkdir( parents = True , exist_ok = True )
with log_path.open( "a" ) as f:
f.write(json.dumps(log_entry, ensure_ascii = False ) + " \n " )
def estimate_monthly_cost (log_path: Path, price_per_1m_input: float = 1.25 ) -> dict :
"""
usageログから今月の推定コストを計算する。
price_per_1m_input: 入力トークン100万個あたりのUSD単価
"""
current_month = datetime.datetime.now().strftime( "%Y-%m" )
total_input = 0
total_output = 0
count = 0
if log_path.exists():
for line in log_path.read_text().splitlines():
entry = json.loads(line)
if entry[ "timestamp" ].startswith(current_month):
total_input += entry.get( "prompt_tokens" , 0 )
total_output += entry.get( "response_tokens" , 0 )
count += 1
# 簡易計算(実際の料金はGemini API pricing pageを参照)
estimated_usd = (total_input / 1_000_000 ) * price_per_1m_input
return {
"count" : count,
"total_input_tokens" : total_input,
"total_output_tokens" : total_output,
"estimated_usd" : round (estimated_usd, 4 ),
"estimated_jpy" : round (estimated_usd * 150 , 1 ), # 概算
}
指数バックオフによるエラー回復
Google Workspace APIのレート制限は、Gmail APIとCalendar APIでクォータの種類が異なります。単純なリトライではなく、指数バックオフが必要です:
import time
import random
from googleapiclient.errors import HttpError
def with_retry (func, max_retries: int = 3 , base_delay: float = 1.0 ):
"""
Google API呼び出しに指数バックオフリトライを適用する。
429(レート制限)と503(一時的なサーバーエラー)のみリトライ対象とする。
404(リソース不存在)や403(権限なし)はリトライしても無意味なので即エラーにする。
"""
for attempt in range (max_retries):
try :
return func()
except HttpError as e:
if e.resp.status in ( 429 , 503 ) and attempt < max_retries - 1 :
delay = base_delay * ( 2 ** attempt) + random.uniform( 0 , 1 )
print ( f "レート制限 — { delay :.1f } 秒後にリトライ ( { attempt + 1 } / { max_retries } )" )
time.sleep(delay)
else :
raise
# 使用例(fetch_todays_events内で活用する)
events = with_retry( lambda : service.events().list(
calendarId = "primary" ,
timeMin = day_start.isoformat(),
timeMax = day_end.isoformat(),
singleEvents = True ,
orderBy = "startTime" ,
maxResults = 20 ,
).execute())
認証トークンの自動更新
OAuth2トークンは1時間で失効します。長時間稼働するプロセスやcronジョブで動かす場合、トークン更新失敗が静かにエラーを引き起こします。workspace_auth.py の get_credentials() はトークン更新ロジックを含んでいますが、リフレッシュトークン自体が失効することもあります(長期間未使用・パスワード変更・セキュリティイベント発生時など)。
この場合は適切な例外を上流に伝播させ、Slackや通知サービスでアラートを受け取るようにしてください。「なぜか3日間サマリーが届いていなかった」という事態を防げます。
よくある落とし穴と回避策 — 実装中に詰まった3つの問題
実装中に自分が実際に引っかかった問題を共有します。
落とし穴1:credentials.json の再利用によるスコープ不足エラー
開発初期に作成した credentials.json で認証し、後からスコープを追加した場合、既存の token.json には新スコープが含まれていないため insufficient authentication scopes エラーになります。解決策は token.json を削除して再認証するだけですが、「コードは正しいのになぜ権限エラー?」という状況に1〜2時間取られることがあります。スコープを追加・変更した場合は必ず token.json を削除してください。
落とし穴2:Function Declaration の required フィールドを省略すると引数が空になる
Function Declaration のパラメーター定義で required フィールドを省略すると、Geminiがツール呼び出し時に引数を渡さないことがあります。特に read_email_body のような必須引数があるツールでは、required=["message_id"] を明示する点が肝心です。上記のコードに含めてある理由がここにあります。これはGemini APIに特有の挙動で、OpenAI APIのFunction Callingとは動作が異なります。
落とし穴3:並列Function Callingの結果処理での name ベースマッピング
Gemini APIは複数のFunction Callを同時に返すことがあります(例:「今日の予定とメールを同時に確認して」というリクエスト)。この場合、function_responses の作成時は各 part.function_call.name を使ってレスポンスを作成してください。上記の実装がそうなっています。インデックスでマッピングしようとすると、Geminiの返却順が変わった場合に結果が正しくマッピングされません。
モデルを固定するか、gemini-flash-latest に委ねるか
上のコードでは model_name="gemini-2.5-pro" と固定バージョンを書いています。ここは意図的です。
エイリアス指定(gemini-flash-latest や gemini-pro-latest)は書き味が軽く、常に新しいモデルへ追従できます。ただしその追従は、こちらの都合とは無関係に起こります。2026年7月に Gemini 3.5 Flash が一般提供となり、gemini-flash-latest が指す実体が入れ替わりました。エイリアスで夜間処理を回していた場合、ある朝から別のモデルが応答していることになります。
秘書AIのように「毎朝同じ形式のサマリーが届くこと」を前提にした処理では、この静かな入れ替わりが効きます。個人開発で複数の処理を夜間に委ねている都合上、実体が変わったあとはまず出力を並べて見比べました。要約の情報量は落ちていないものの、優先度の並び順の判断がやや変わっていました。壊れてはいない。けれど同じではない。
指定方法 向く場面 注意点
gemini-3.5-flash のような固定指定毎日同じ形式の出力を期待する定期処理 自分で移行時期を決める必要がある。廃止予定を追う手間が残る
gemini-flash-latest のようなエイリアス対話的な用途・手元で結果を確認する処理 実体の入れ替わりが予告なく反映される。出力形式に依存した後段があると壊れうる
私の推奨は、定期実行の秘書AIには固定指定、手元で試す実験にはエイリアス という分け方です。そのうえで、モデル名を1箇所に集約しておくと移行が軽くなります。
# config.py — モデル指定を1箇所に集約する
from dataclasses import dataclass
@dataclass ( frozen = True )
class ModelConfig :
"""
秘書AIが使うモデルの指定。
定期実行では固定バージョンを使い、移行はこのファイルの1行変更で行う。
"""
# 分類・優先度スコアリング(呼び出し回数が多い=コストが効く)
triage: str = "gemini-3.5-flash"
# 本文要約・返信下書き(品質が効く場面のみ Pro を使う)
compose: str = "gemini-3.5-pro"
# 移行検証用。切り替え前にここだけ latest にして出力を並べて比較する
canary: str = "gemini-flash-latest"
MODELS = ModelConfig()
分類には Flash、下書きには Pro という振り分けは、コストと品質の両方に効きます。未読メールの優先度判断は件名・送信者・スニペットだけを見る単純なタスクで、ここに Pro を使う理由はほとんどありません。一方、返信の下書きは文面の質がそのまま自分の時間の節約になるため、Pro の価値が出ます。
Flash と Pro の損益分岐についてはGemini 3.5 Flash のリトライ増幅を実測した記録 に、単価表だけでは見えない部分をまとめています。
書き込み系ツールに承認ゲートを挟む
ここが、秘書AIを本番で動かすかどうかの分かれ目でした。
スコープ定義に gmail.compose を入れた時点で、このエージェントは下書きを作れます。Function Calling は AI がツールを選ぶ設計ですから、理屈のうえでは「AI が自分の判断でメールの下書きを作る」ことになります。読み取りだけなら最悪でも情報が読まれるだけですが、書き込みは違います。
私自身、最初はこの違いを軽く見ていました。テストで「返信が必要なメールに下書きを用意して」と頼んだところ、エージェントは丁寧に5通の下書きを作りました。うち2通は、私が返すつもりのない相手への返信でした。送信はしていないので実害はありません。それでも、下書きフォルダを開いたときに手が止まりました。
対策は、ツールを危険度で分け、書き込み側にだけ人の確認を挟むことです。
# approval.py
import json
from enum import Enum
class RiskTier ( str , Enum ):
"""ツールの危険度。READ は自動実行、WRITE は確認を挟む。"""
READ = "read" # 読むだけ。取り消し不要
WRITE = "write" # 状態を変える。取り消しできる(下書き作成など)
SEND = "send" # 外部に出る。取り消せない(送信・共有など)
TOOL_RISK = {
"get_todays_schedule" : RiskTier. READ ,
"get_unread_emails" : RiskTier. READ ,
"read_email_body" : RiskTier. READ ,
"create_draft_reply" : RiskTier. WRITE ,
}
def requires_approval (function_name: str ) -> bool :
"""READ 以外は確認を必要とする。未知のツールは安全側に倒す。"""
return TOOL_RISK .get(function_name, RiskTier. SEND ) != RiskTier. READ
def request_approval (function_name: str , function_args: dict ) -> bool :
"""
書き込み系ツールの実行前に確認を取る。
cron 実行では標準入力がないため、承認待ちキューへ積む実装に差し替える。
"""
print ( f " \n [確認] { function_name } を実行しようとしています" )
print (json.dumps(function_args, ensure_ascii = False , indent = 2 ))
answer = input ( "実行しますか? [y/N]: " ).strip().lower()
return answer == "y"
handle_function_call() の冒頭にゲートを差し込みます。拒否した場合もエラーにせず、その事実を JSON で Gemini に返すのが要点です。こうするとエージェントは「承認されなかった」と理解し、代わりに要約だけを返してきます。例外を投げると会話ごと落ちてしまいます。
def handle_function_call (function_name: str , function_args: dict ) -> str :
if requires_approval(function_name):
if not request_approval(function_name, function_args):
# 拒否も「結果」として返す。例外にしない
return json.dumps(
{ "status" : "declined" , "reason" : "ユーザーが実行を承認しませんでした" },
ensure_ascii = False ,
)
# 以降は既存の分岐(get_todays_schedule / get_unread_emails / ...)
cron で無人実行する場合、input() は使えません。私は承認待ちの内容を JSONL に積んで、朝の通知にまとめて出す形にしています。「AI が5通の下書きを作った」ではなく「AI が5通の下書きを作りたがっている」まで戻す。この一段があるだけで、安心して寝られるようになりました。
危険度別のゲートをもう少し踏み込んで設計したい方はGemini の Function Calling に危険度別の承認ゲートを挟む実装 も参考になります。
Managed Agents が出た今、自前のオーケストレータを続けるか
2026年7月、Gemini API の Managed Agents が公開プレビューに入りました。Google がホストする隔離 Linux サンドボックスの中で、状態を保持する自律エージェントを動かせます。計画・推論・コード実行・ファイル操作・ウェブ閲覧まで、サンドボックス側が引き受けてくれます。
この記事で組んできた while ループのオーケストレータは、これで不要になるのでしょうか。
私の結論は「秘書AIでは、まだ自前を続ける」です。理由は3つあります。
認証情報の置き場所 。秘書AIの核心は、自分の Gmail と Calendar にアクセスするリフレッシュトークンです。これを手元に置いたまま動かせることは、この用途では機能ではなく前提に近いものでした
承認ゲートを挟む余地 。前節のゲートは、ツール実行の直前に自分のコードが挟まっているからこそ書けます。実行を委ねると、この一段を差し込む場所を探すことになります
ループの上限が見える 。max_iterations = 5 のような制御が自分のコードにあることは、コスト面の安心につながります
逆に、Managed Agents に寄せたほうが素直な場面もはっきりしています。手元の環境に依存しない使い捨ての処理、コード実行やファイル操作を伴う調査タスク、複数の環境から同じエージェントを叩きたい場合です。自前のサンドボックスを用意する手間がまるごと消えるのは大きい。
観点 自前オーケストレータ Managed Agents(公開プレビュー)
個人の OAuth トークン 手元に置ける サンドボックスへの受け渡し設計が要る
実行前の承認 ツール呼び出しの直前に挟める 挟み込む場所を別途設計する
環境構築 自分で用意する 不要
コード実行・ファイル操作 自分で隔離環境を作る サンドボックスに含まれる
向く用途 個人データを扱う常設の処理 使い捨ての調査・自動化タスク
サンドボックスから成果物を持ち出す部分の設計はManaged Agents の隔離サンドボックスから成果物を安全に持ち出す設計 にまとめています。公開プレビューの機能ですので、本番投入の判断は正式提供を待ってからでも遅くないと考えています。
90日回してみたコストと、想定と違ったところ
毎朝6時に1回実行する構成で、3ヶ月ほど回した記録です。処理内容は「今日の予定の取得 → 未読メールのメタデータ取得 → 優先度スコアリング → 上位3件の本文取得 → サマリー生成」で、log_usage() が積んだ JSONL から集計しました。
項目 実測
1回あたりの入力トークン(中央値) 約 4,100
1回あたりの出力トークン(中央値) 約 480
1回あたりの Function Call 回数 3〜5回(上位3件の本文取得を含む)
月あたりの実行回数 30回(毎朝1回)
分類を Flash に寄せた後の月額 数十円台に収束
想定と違ったのは、コストの絶対額ではなく分布 でした。中央値は 4,100 トークン程度で安定していたのに、月に2〜3回、20,000 トークンを超える日がありました。原因を追うと、長い引用が連なったメールスレッドの本文が [:2000] の制限に達し、それが上位3件すべてで起きた日でした。
つまり、平均で設計すると裾を見落とします。私は上限アラートを平均の3倍ではなく、実測の最大値を基準に置き直しました。
もうひとつ、with_retry() を入れる前は月に1回ほど 429 で無言のまま止まっていました。cron は失敗しても静かです。サマリーが届かない朝があっても、忙しいと気づかない。バックオフを入れたのは、コストのためではなく「気づけない失敗」をなくすためでした。
ツール結果が会話を膨らませていく現象そのものはツール結果の巨大レスポンスをハンドル渡しで軽くする設計 で詳しく扱っています。
全体を振り返って:まず「朝のルーティン」から始めてみてください
秘書AIの全機能を一度に実装しようとすると、認証・ツール定義・オーケストレーションと複雑さが重なって途中で止まりやすくなります。
最初のステップとして、「毎朝8時に今日の予定と未読メールの優先度をSlackに投稿する」という単機能から始めることをおすすめします。この記事のコードはそのまま動くはずです。一度稼働したら、そこにDrive検索やDocs要約を追加していくのが現実的なアプローチです。コスト・認証・エラーの3つの設計を最初から組み込んでおくことで、「動いたけど怖くて長期運用できない」という状況を避けられます。
Gemini APIのFunction Callingの基本から学びたい方にはGemini API Function Calling:ツール統合と実践的な活用法 が参考になります。マルチターンのツール呼び出しを壊さない設計についてはGemini 3 の thought signatures を保持する本番設計 も合わせてご覧ください。