# 外出先からAntigravity IDEをAIに操作させたい──実装編
Introduction: 設計が動く日
前編では「Geminiの正規化座標をOSのピクセルに変換して、Rustでenigo叩けばいけるはず」という設計方針を立てた。本稿はその実装記録である。
実装は一応動いた。「一応」と書いたのは意図的だ。Gemini 2.5 Computer Useは思ったより自由奔放にAPIを叩いてくる。key_combinationの引数がkeyだったりkeysだったりcombinationだったり、その場その場で違う。設計編で「多層エージェント構造」などと格好いいことを書いた反動が、実装で全部返ってきた。
本稿ではコードをそのまま載せる。理解した人が再実装できる粒度を目標にする。
あと外出先で、とは言ったものの、現段階ではまだローカルホスト環境。
Architecture: 3レイヤー + Agentレイヤー
実装は4つのレイヤーに分かれている。
外部クライアント (モバイル / Gemini)
│ HTTP POST /command
▼
┌─────────────────────────────┐
│ Network Layer │ src/network/
│ axum HTTP サーバー │ ・server.rs — Router 組み立て
│ JSON パース・ルーティング │ ・handlers.rs — エンドポイント実装
└────────────┬────────────────┘
│ Command enum
▼
┌─────────────────────────────┐
│ Vision & Math Layer │ src/vision/mod.rs
│ 正規化座標 → ピクセル変換 │ CoordinateConverter
└────────────┬────────────────┘
│ (i32, i32) ピクセル座標
▼
┌─────────────────────────────┐
│ OS Control Layer │ src/os_control/mod.rs
│ enigo ラッパー │ Controller
└─────────────────────────────┘
↑
┌─────────────────────────────┐
│ Agent Layer │ src/agent/
│ Gemini API クライアント │ ・gemini_client.rs
│ マルチターン自律ループ │ ・agent_loop.rs
└─────────────────────────────┘
Network/OS Controlは比較的素直な実装になった。設計上の核心はVisionレイヤーの座標変換と、AgentレイヤーのGeminiとの会話管理の2点だ。
Vision & Math Layer: 座標変換の実装
前編で予告した数理モデルの実コードがこれだ。
/// Gemini 2.5 Computer Use の正規化座標(0.0 〜 1000.0 スケール)を
/// 実際のピクセル座標に変換するコンバーター。
///
/// x_pixel = round( (x_norm / 1000.0) × width_pixel )
/// y_pixel = round( (y_norm / 1000.0) × height_pixel )
#[derive(Clone)]
pub struct CoordinateConverter {
width: u32,
height: u32,
}
impl CoordinateConverter {
pub fn normalize_to_pixel(&self, x_norm: f64, y_norm: f64) -> (i32, i32) {
let x_clamped = x_norm.clamp(0.0, 1000.0);
let y_clamped = y_norm.clamp(0.0, 1000.0);
let x_pixel = (x_clamped / 1000.0) * self.width as f64;
let y_pixel = (y_clamped / 1000.0) * self.height as f64;
(x_pixel.round() as i32, y_pixel.round() as i32)
}
}
シンプルに見えるが、解像度の取得に罠がある。
scrapクレートで自動検出するのが基本だが、WindowsでDPIスケーリングが有効な環境ではscrapが返す解像度とenigoの論理座標系が一致しない。150%スケーリングならscrapは物理解像度(例: 2880×1800)を返すが、enigoが扱うのは論理解像度(1920×1200)だ。そのままでは変換後の座標が約1.5倍ズレる。
対策として環境変数による手動上書きを用意した。
pub fn new() -> Result<Self, VisionError> {
// DISPLAY_WIDTH / DISPLAY_HEIGHT が両方設定されていれば scrap をスキップ
if let Some((w, h)) = Self::try_from_env() {
tracing::info!("Using manual display resolution from env: {}x{}", w, h);
return Ok(Self { width: w, height: h });
}
let display = Display::primary()
.map_err(|e| VisionError::DisplayError(e.to_string()))?;
Ok(Self {
width: display.width() as u32,
height: display.height() as u32,
})
}
fn try_from_env() -> Option<(u32, u32)> {
let w = std::env::var("DISPLAY_WIDTH").ok()?.parse::<u32>().ok()?;
let h = std::env::var("DISPLAY_HEIGHT").ok()?.parse::<u32>().ok()?;
if w == 0 || h == 0 { return None; }
Some((w, h))
}
DPIスケーリングが有効な環境では起動時に論理解像度を手動指定することになる。
DISPLAY_WIDTH=1920 DISPLAY_HEIGHT=1080 ./antigravity_relay
スマートではないが、動く。
OS Control Layer: enigoラッパー
enigoクレートのラッパーとしてControllerを実装した。マウスクリック・ドラッグ・スクロール・テキスト入力・キー操作を提供する。
pub struct Controller {
enigo: Enigo,
}
スレッド安全性のために呼び出し側でtokio::sync::Mutexに包んで使う設計にした。Enigo自体はSendだがSyncではないため、複数の非同期タスクから同時アクセスする可能性があるAgentレイヤーとの境界でMutexが必要になる。
key_press_rawの設計
Geminiは"ctrl+c"や"shift+tab"のような文字列でキー操作を指示してくる。これを受け取って実行するkey_press_rawが地味に面倒だった。
pub fn key_press_raw(&mut self, key_str: &str) -> Result<(), ControlError> {
// "ctrl+c" のようなモディファイア組み合わせを処理
if let Some((modifier, inner)) = key_str.to_lowercase().split_once('+') {
let mod_key = match modifier.trim() {
"ctrl" | "control" => Key::Control,
"alt" => Key::Alt,
"shift" => Key::Shift,
"meta" | "super" | "win" => Key::Meta,
_ => return Err(ControlError::KeyboardError(
format!("Unknown modifier: {}", modifier),
)),
};
self.enigo.key(mod_key, Direction::Press)?;
let k = str_to_key(inner.trim())?;
self.enigo.key(k, Direction::Click)?;
self.enigo.key(mod_key, Direction::Release)?;
return Ok(());
}
let k = str_to_key(key_str)?;
self.enigo.key(k, Direction::Click)?;
Ok(())
}
str_to_keyでGeminiが送ってくる文字列("return" / "enter" / "esc" / "escape" / "uparrow" / "arrowup" など表記揺れが多い)をenigo::Keyにマッピングしている。1文字の場合はKey::Unicode(c)にフォールバックするので、通常の文字キーは網羅できる。
Agent Layer: Geminiとのマルチターンループ
実装の核心はここだ。
会話履歴の構造
Gemini Computer Useはマルチターンで動作する。会話履歴をVec<Value>として保持し、各ステップでスクリーンショットと共に渡す。
pub async fn run_agent_loop(
instruction: String,
controller: Arc<Mutex<Controller>>,
converter: CoordinateConverter,
gemini: Arc<GeminiClient>,
is_running: Arc<AtomicBool>,
) {
let mut contents: Vec<Value> = Vec::new();
let mut prev_fn_name = String::from("unknown");
for step in 1..=MAX_STEPS {
// キルスイッチ監視
if !is_running.load(Ordering::SeqCst) { break; }
let screenshot_b64 = capture_screenshot().await?;
if step == 1 {
// 初回: タスク説明 + スクリーンショット
contents.push(json!({
"role": "user",
"parts": [
{"inlineData": {"mimeType": "image/jpeg", "data": screenshot_b64}},
{"text": format!(
"タスク: 「{instruction}」\n\
現在の画面を確認して次のアクションを1つ実行してください。\n\
タスクが完全に完了したら done 関数を呼び出してください。"
)}
]
}));
} else {
// 2回目以降: 前の関数レスポンス + 新しいスクリーンショット
contents.push(json!({
"role": "user",
"parts": [
{"functionResponse": {
"name": &prev_fn_name,
"response": {"output": "実行完了"}
}},
{"inlineData": {"mimeType": "image/jpeg", "data": screenshot_b64}},
{"text": "次のアクションを実行してください。タスクが完了したら done を呼び出してください。"}
]
}));
}
// ...
}
}
初回と2回目以降でユーザーターンの構造が異なる点に注意。2回目以降は直前に呼ばれた関数名(prev_fn_name)をfunctionResponseとして返す必要がある。このprev_fn_nameを引き回すのがやや冗長だが、Gemini側がこの形式を要求する。
キルスイッチ
エージェントループはバックグラウンドタスクとして起動するため、POST /agent/stopで強制停止できるキルスイッチを実装した。AtomicBoolを使い、各ステップの開始時とAPIリクエスト直前の2か所で監視している。
// ステップ開始時
if !is_running.load(Ordering::SeqCst) {
tracing::warn!("[Agent] キルスイッチが作動しました。");
break;
}
// APIリクエスト直前
if !is_running.load(Ordering::SeqCst) {
tracing::warn!("[Agent] キルスイッチ作動を検知(APIリクエスト前)。");
break;
}
「APIリクエスト直前」に二重チェックする理由は、スクリーンショット取得(数百ms)とAPIリクエスト(数秒)の間に停止命令が来た場合に無駄なAPIコールを避けるためだ。
Gemini関数名マッピングの現実
parse_function_callが実装の中でいちばん泥臭い部分だ。
pub(crate) fn parse_function_call(name: &str, args: &Value) -> Result<ComputerAction, GeminiError> {
let kind = match name {
"click_at" | "hover_at" => {
let x = args["x"].as_f64()
.ok_or_else(|| GeminiError::Parse(format!("{name}: x フィールドがありません")))?;
let y = args["y"].as_f64()
.ok_or_else(|| GeminiError::Parse(format!("{name}: y フィールドがありません")))?;
ActionKind::Click { x, y }
}
"type_text_at" => {
let text = args["text"].as_str()
.ok_or_else(|| GeminiError::Parse("type_text_at: text フィールドがありません".to_string()))?
.to_string();
// Geminiは "enter" と "press_enter" を気分で使い分ける
let press_enter = args["enter"].as_bool()
.or_else(|| args["press_enter"].as_bool())
.unwrap_or(false);
ActionKind::Type { text, press_enter }
}
"key_combination" => {
// "key" / "keys" / "combination" のどれで来るか分からない
let key = args["key"].as_str()
.or_else(|| args["keys"].as_str())
.or_else(|| args["combination"].as_str())
.ok_or_else(|| GeminiError::Parse("key_combination: key フィールドがありません".to_string()))?
.to_string();
ActionKind::Key { key }
}
// ブラウザを開く → Windowsキーにマッピング(後述)
"open_web_browser" => ActionKind::Key { key: "super".to_string() },
// 未知の関数はスクリーンショット再取得で続行
other => {
tracing::warn!("[GeminiClient] 未知の関数呼び出し: {} args={}", other, args);
ActionKind::Screenshot
}
};
Ok(ComputerAction { kind })
}
type_text_atのpress_enterフラグは"enter"と"press_enter"の両方を試している。key_combinationの引数名は"key" / "keys" / "combination"の3択。ドキュメントに書いてあることと実際に返ってくる引数名が一致しないことがあるので、試行錯誤でor_elseチェーンを増やすことになった。
テキスト応答のフォールバック
Geminiが関数呼び出しではなくテキスト応答を返すことがある。その場合はタスク完了と解釈してループを終了する。
// 関数呼び出しが見つからず、テキストだけが存在する場合
if parts.iter().any(|p| p.text.is_some()) {
tracing::debug!("テキスト応答を Done として解釈");
let model_turn = json!({"role": "model", "parts": [{"text": "タスク完了"}]});
return Ok((
ComputerAction { kind: ActionKind::Done },
model_turn,
"done".to_string(),
));
}
これが正しい解釈かどうかは文脈次第だが、エラーで落とすよりはループを終了させる方がトータルで安全だと判断した。
Struggles in Practice: 実際にハマったこと
open_web_browser がChromeを開かない
open_web_browserはWindowsキー("super")にマッピングしている。これはWindowsスタートメニューを開くだけで、Chromeが直接起動するわけではない。
Geminiがopen_web_browserを呼ぶ → スタートメニューが開く → Geminiが"chrome"とタイプする → EnterキーでChromeが起動する、という複数ステップが実際には必要になる。1アクション=1ステップの設計なので、Geminiがこの流れを自分で組み立てられるかどうかはタスクの指示次第だ。
より直接的な解決策として、open_web_browserをtype_text_at("chrome", press_enter=true)として実装するか、あるいはGeminiへのシステムプロンプトで「ブラウザを開く際はスタートメニュー経由の手順を踏め」と明示する方法が考えられる。現状はまだ対処していない。
type_text_atのフォーカス前提問題
現在の実装ではtype_text_atのx, y座標(クリックしてからタイプする意図のフィールド)を無視している。テキストフィールドにフォーカスが当たっている前提でタイプするだけだ。
Geminiが「このフィールドにクリックしてからタイプせよ」という意図でtype_text_atに座標を渡してきた場合、クリックなしでタイプするため意図しない場所に文字が入力される可能性がある。対策はx, yが存在する場合は先にクリックを実行することだが、まだ実装していない。
マルチターン履歴のメモリ肥大化
会話履歴はVec<Value>をメモリ上に保持するだけで永続化していない。スクリーンショット(JPEG base64)を毎ステップ蓄積するため、長時間実行するとコンテキストウィンドウが急速に肥大化する。
Gemini 2.5 Proのコンテキストウィンドウは1Mトークンだが、高解像度スクリーンショットのbase64は1枚あたり数百KBになる。20ステップ走らせると軽く数MB超になり、後半のステップでコスト・レイテンシが跳ね上がる。
将来的には古いターンのスクリーンショットをサマリーテキストに置き換えるか、重要な変化点のみに絞る「ダーティレクタ検知」との組み合わせが必要だ。
Result: 動いたこと・動かなかったこと
動いたこと:
-
POST /agent/startでGeminiが自律的にスクリーンショットを見てクリック・タイプを繰り返すループが機能した - 座標変換は期待通りに動作。DPIスケーリング環境は環境変数で対応できた
-
POST /agent/stopのキルスイッチが正しくループを止めた -
key_combinationの引数名ゆれへの防衛コードが実際に効いた場面があった
動かなかった・不完全なこと:
- ブラウザを直接開く操作(
open_web_browserの限界) -
type_text_atの座標クリック前処理 - 長時間実行時のコンテキスト肥大化(実用上は20ステップ以内に収める運用でカバー)
Next: まだ終わっていない
本稿の実装はMVPとして機能するが、実用的な「外出先からAntigravityを動かす」には以下が残っている。
- Tailscale / WebRTCによる画面転送(Tauriフロントエンド側の実装)
-
open_web_browserの挙動修正 - コンテキスト管理の改善(古いスクリーンショットの要約化)
-
type_text_atのフォーカス前処理
Agentレイヤーを実装してみて分かったのは、Gemini Computer Useは「ドキュメント通りに動くAPIではなく、実際に動かして引数名を確認しながら育てるもの」だということだ。設計編で想定した課題よりも、実際にハマった課題の方が地味で泥臭かった。続きは後編で。