English · 日本語
AI オーサリング(record / Tier 1)¶
Tier 1 = AI ライブ操作です。自然言語のゴールから AI がアプリを探索しながら操作し、決定的シナリオ を書き出します。AI が関与するのはここ(記録時)だけです。生成された YAML は AI 非依存で、以後はユーザーが管理します。
実装:
bajutsu/record.py(ループ)、bajutsu/agents/protocols.py+bajutsu/agents/factory.py(抽象 + 構築)、bajutsu/agents/claude.py(SDK オーサリングエージェント)、bajutsu/agents/alerts.py(システムアラート対処)。 幅優先の探索bajutsu/crawl/も同じエージェントを使います。
関連: concepts の 2 層 · scenarios · run-loop
Agent 抽象¶
ループとモデルを分離するための薄い Protocol です(agents/protocols.py)。テストではスクリプト化した fake を使い、
本番では SDK 経由の ClaudeAgent を使います(下記)。
@dataclass
class Observation:
goal: str # 自然言語ゴール
screen: list[Element] # 現在画面の要素
history: list[Step] # ここまでに記録したステップ
screenshot: bytes | None # 現在画面の PNG(視覚用)
@dataclass
class Proposal:
steps: list[Step] = field(default_factory=list) # 現在の画面から確定できるアクションの順序付きバッチ(通常は 1 手)
done: bool = False # ゴール到達
expect: list[Assertion] = field(default_factory=list) # done 時に、ゴールを検証するアサーション
note: str = ""
needs_human: bool = False # 第三の結果。人に引き渡す(BE-0179)
human_prompt: str = "" # 引き渡しの理由。人に提示する
class Agent(Protocol):
def next_action(self, observation: Observation) -> Proposal: ...
record ループ¶
record(driver, goal, agent, *, name, max_steps=30, alert_guard=None, ...) -> Scenario
(record.py)。observe → 提案 → 実行 を max_steps ターンまで繰り返します。
1. (alert_guard があれば)アプリを覆うもの(システムアラート等)を片付ける
2. elements = driver.query()
- alert_guard 下で id を持つ要素が皆無なら、エージェントに死んだ画面を見せず再ループ
(id を幻覚させないため)
3. スクリーンショットを添付するか判断し(オンデマンドの視覚、BE-0192)、必要なら遅延キャプチャして
Observation を作り、agent.next_action() を呼ぶ。テキストのみのターンでエージェントが画面を要求
(need_screenshot)したら、同じ観測を画像付きで 1 回だけ出し直す
4. proposal.needs_human なら(BE-0179):
- `handoff` 応答者に引き渡す。cancel なら停止する。値の入る先のフィールドを名指しした値応答
(BE-0182)なら、その値を実際のアプリに入力し、決定的なプレースホルダのステップを記録する
(後述)。それ以外の値/「操作した」応答なら観測し直して続行する(人のターンはステップを記録
しません)。応答者が居なければ `HumanHandoffUnavailable` を送出する。ハングも推測もない、
明確でラベル付きの失敗です
5. proposal.steps を順に実行する(1 手以上のバッチ。後述):
- 各手を _execute_with_recovery で実行(失敗かつ alert_guard ありなら片付けて 1 回再試行)
- 成功したら steps に積み、画面の同一性を再観測してバッチ開始時と比較する。手が残ったまま
変化していれば残りを中断し、次ターンで再観測する(実行済みの接頭辞だけが記録される)
- 解決しない手は、そのターンで 1 手も実行していなければ、応答者がいれば引き取りを申し出(BE-0185、
後述)、いなければきれいに停止する。バッチの途中であれば残りを中断する
6. proposal.done なら settle ステップ(後述)を必要なら差し込み、expect を確定して終了
7. Scenario(name, steps, expect) を返す
出力は dump_scenarios を通して YAML 化されます(scenarios)。
human-in-the-loop ハンドオフ(BE-0179)¶
一部の flow は、AI が供給できない何か(ワンタイムパスワード、CAPTCHA、生体認証のプロンプト)で
塞がれます。ターン結果が「人が必要」(proposal.needs_human)のとき、ループは一時停止し、転送方式に
依存しない Handoff 契約(handoff.py)を通して人に制御を引き渡します。要求(なぜ止まったか、現在
画面の要約とスクリーンショット)が出て行き、応答(供給された値、または「デバイスを操作した。観測し
直せ」、または cancel)が返り、ループは実際の画面を観測し直して再開します。人がループに入るのは
オーサリングの最中だけで、記録した scenario は決定的な run の経路に人を置かずに再生されます。
同じ契約に二つのインターフェースがあります。端末からは、record が上限つきで中断可能な対話的な標準
入力プロンプトから応答を読み取ります。serve から駆動する場合は、要求を record の server-sent
events ストリーム上の human-request イベントとしてシリアライズ配信し(一時停止したジョブは、目に
見える再開可能な「人待ち」状態に入ります)、ブラウザが応答を /api/jobs/<id>/respond-human へ
POST すると、serve がそれを spawn した record プロセスの標準入力へ書き込みます。どちらの経路でも
待機は上限つきで中断可能なので、席を外した人を相手にどのインターフェースもハングしません。応答者が
まったく居ない非対話的な実行や CI では、「人が必要」のターンは明確でラベル付きの非ゼロ終了になり、
ハングにも AI の推測にもなりません。この土台は仕組みと境界を担い、「人が必要」を立ち上げる
ヒューリスティックと、ハンドオフが記録する成果物の形は、子項目が受け持ちます。
人による値入力(BE-0182)。値の子パターンは、最も多い詰まりに対してこのハンドオフの上に乗ります。
それは、AI が場所は特定できても値は知りようがないフィールド——ワンタイムパスワード、2 要素認証(2FA)
コード、その場限りのランダムな値です。AI はそのフィールドを(ask_human で id / label により)名指し
し、実際の実行でその値をどう供給するかを classify で提案します。totp または email(実行時の橋渡し
ステップ BE-0046 が生む ${vars.*})か、secret(宣言済みの ${secrets.*})です。人は値を一度だけ
入力します。ループはその値を実際のフィールドに入力して記録を先へ進めますが、記録するのは
プレースホルダの type ステップ——${vars.*} / ${secrets.*} であって、リテラルではありません
(BE-0120 の漏らさない保証を再利用します)。このステップは from: 由来(BE-0044)を帯び、それが人由来
であることと、配線するための TODO の両方を示します。原則 1 のとおり、AI は分類を提案するだけで、作者が
確認して配線します。配線し終えれば、再生は完全に決定的で AI に依存しません。
人による操作の引き取り(BE-0185)。もう一つの子パターンは、フィールドを名指ししないハンドオフを扱います。
詰まりが AI に入力できる値ではなく、AI には実行できない操作そのものである場合です——CAPTCHA、生体認証の
プロンプト、AI が繰り返し解けないジェスチャなどです。人が実際のデバイスを操作して「操作した」と答えると
(acted ハンドオフ、BE-0179)、record は生のジェスチャではなく観測した遷移のマーカーを、manual
ステップとして記録します(scenarios)。AI はこのマーカーを、決定的な迂回路を
提案できるかどうかで分類します。bypass は、そのステップを再生可能にするために作者が配線できる
テストビルド用のフラグ、あるいは device-control / device-state プリミティブ(BE-0035 / BE-0052)を名指し
します。それが無ければ、等価物のない引き取り(本物の CAPTCHA)であることを示します。どの codegen ターゲット
もこのステップをラベル付きの // TODO(BE-0026)として描画します。そして——再生可能な type ステップを
生む値パターンとは違い——manual ステップには決定的な実行時の等価物がないため、実行時には合格を偽装せず
ManualStepRequired で明示的に失敗します(原則 1・2)。このマーカーはハンドオフの既定の種別ではなく
明示的な acted フラグをキーにするので、素の再開が実行時に失敗するステップを捏造することはありません。
引き取りの起点。引き取りは 2 通りで始まります。1 つは、AI がこれ以上進めないと判断したときに自ら
要求する場合です(フィールドを伴わない proposal.needs_human)。もう 1 つは、ループ自身が起こす場合です。
AI の提案したターゲットが、アラートガードが覆いを取り除いたあとでも一意の要素に解決できないとき——以前は
record が「could not resolve that target on the live screen; stopping」と表示して録画を投げ出していた、
まさにその場面です。いまは応答者がいれば、その行き止まりが引き取りの申し出に変わります。ループは一時停止して
ハンドオフし、人はデバイスを操作して再開するか(manual マーカーが記録されます)、取り消して(きれいに停止
します)応えます。これを起こすのは LLM ではなくつねにループであり、どの要素を操作するかを推測することは
ありません。ただ人に操作を引き取るよう頼むだけです。応答者がいなければ、従来どおりラベル付きできれいに停止
します。
リモート serve でのデバイス到達性。引き取りは人に実際のデバイスを操作してもらう手順なので、ブラウザ内で
完結する値入力とは違い、作者の手元にデバイスが届いている必要があります。ローカルの serve(同じマシン上の
シミュレータ)では、作者が直接それを操作し、ハンドオフのペインは一時停止と再開を仲介するだけです。ブラウザが
デバイスを駆動することはありません。一方、ホスト型の serve
(BE-0015、
マルチテナントの server バックエンド)では、デバイスは作者の届かないワーカー上で動くため、デバイス操作の
引き取りは成立しません。respond-human は acted の再開を拒否し、代替手段を名指しするメッセージを返します
——デバイスのある場所で録り直すか、テストビルドの迂回路を配線して、実行時に人の操作を要さないように
する、のいずれかです。値入力と取り消しはリモートでも引き続き機能します。
acted の拒否は、ホスト型デプロイのシグナルをキーにします。これが「デバイスが作者の手元にない」ことを
確実に示す唯一の指標だからです。ネットワーク越しにチームへ公開したセルフホストの serve
(BE-0016)も同じくリモートの
ケースですが、ある接続クライアントがデバイスの手元にいるかを serve が確実に判定する術はありません(loopback
bind は健全な代理になりません——SSH フォワードを見落とし、作者が同席する wildcard bind を誤って弾きます)。
そのため、そこでは同じ代替手段が強制ではなく運用上の取り決めとして適用されます——到達可能なデバイスで
実際に行っていない操作について acted と答えないでください。セルフホストのケースを強制シグナルへ厳密化する
ことと、制約自体を外すブラウザ内のデバイス鏡像表示は、いずれも後続の作業です。
マルチアクションのターン(BE-0178)¶
1 ターンの提案は順序付きのバッチです。前の手の結果を見なくても現在の画面から確定できる一連の
アクション(たとえば複数のフォームフィールドを埋めてから送信する、など)をまとめて提案します。
バッチは構造上つねに画面内で完結します。各手の実行後に crawl.screen_identity(...) を比較するためです。
この同一性シグネチャは要素ごとの状態(フィールドの入力、コントロールの有効・選択フラグ)を無視するので、
バッチ自身が埋めたフィールドを遷移と誤認しません。手が残ったまま同一性が変われば残りを捨てて再観測します
(仕切り直し)。実際に実行した手だけを積むため、記録される YAML は従来と同じ平坦な、1 手ずつ解決された
ステップ列のままです。バッチが変えるのは、それを生むのに要するモデルのターン数(N 回の単発ターンの
代わりに 1 回の observe → model → execute-N)だけで、成果物の形は変わりません。中断の判定は決定的な比較で
あり、LLM の判断ではありません。
settle ステップの自動挿入¶
settle_step: エージェントはターン間に「落ち着いた画面」を見ますが、決定的リプレイは速く、
非同期遷移(シート等)が描画される前に検証してしまうことがあります。そのため expect の最初の
「存在を要する要素」への wait を、アサーションの直前に記録します。これにより run に
暗黙のタイミングを追加せず、記録シナリオが自己完結します。
モバイルターゲットでの動画キャプチャ¶
モバイル(iOS シミュレータ)ターゲットの場合、record は記録シナリオの最初のステップに
capture: [video] を付与します。これにより、リプレイ時にシナリオ全体の画面動画を記録します。
requested_intervals は 1 つのステップの inline capture を見てシナリオ全体のインターバルを
開始するため、1 つのアクションの区間だけでなくリプレイ全体が録画されます。この録画は simctl
インターバル(BE-0028)なので iOS バックエンド固有です。web ターゲットは別の手段で動画を取得し、
記録シナリオに capture は付きません。
ターンのペイロードの軽量化とコストの制御(BE-0194)¶
スクリーンショットに次いで大きいターンごとの入力は、描画したアクセシビリティ要素ツリーです。そこで
_render(agents/claude.py)は、エージェントが操作できる要素を落とさないまま、このツリーを軽く保ちます。
- 要素行のロスレス圧縮。 各要素の行には、情報を持つフィールドだけを出力します。アドレス指定に使う
フィールド(
id、label、空でないvalue、空でないtraits)は必ず残し、空のvalue/traitsはvalue='' traits=[]と印字せずに落とします。要素の集合も各要素をアドレス指定できることも変わらず、 純粋に文字数だけを削減します。 - 病的な画面に対する安全なキャップ。 要素数がグローバルな閾値(
_LARGE_SCREEN_ELEMENTS)を超えると、 アドレス指定できない残り(id・label・value・traitsのいずれも持たない要素)を黙って捨てるのではなく、 末尾の- (+N further non-addressable elements omitted)の 1 行にまとめます。アドレス指定できる要素は すべてそのまま描画するので、キャップが要素を落とすことはありません。この 1 行が画面の切り詰めを エージェントに伝えるため、必要なら swipe でさらに表示させられます。
セッションの消費量を前もって抑えるための CLI のオプションが 2 つあります。どちらも既定は現状の挙動なので、 明示的に指定しない限り何も変わりません。
--max-steps Nはオーサリングのターン数(既定 30)を上限として、最悪ケースのトークン消費を抑えます。--no-screenshotは画像を送らない要素のみのセッションを記録します。アクセシビリティ情報が十分に付与されたアプリに対して、 最も安価に record したいときに使います。
オンデマンドの視覚(BE-0192)¶
スクリーンショットは 1 ターンあたりで最も大きなコストであり、静的なシステムプロンプトやツール定義と
違ってプロンプトキャッシュが効きません。そのため毎ターン、デバイス解像度のまま全額を支払っています。
一方でエージェントは要素の id か label でしか操作せず(id を捏造することはありません)、操作対象が
すべてアドレス指定できる画面では、画像は判断を左右せず確認用にすぎません。そこで record は画像を
オンデマンドで送ります。アクセシビリティ要素は毎ターンのメッセージに必ず載せ、スクリーンショットは
実際に情報を足すときだけ添付します。
添付するかどうかは、要素ツリー上の 2 つの決定的なトリガーでターンごとに決めます。モデルは介在しない ので、この判断は Tier 1 のままです。
- 新しい画面。 現在の画面の
crawl.screen_identity(...)の署名が前のターンと異なる(または最初の ターン)とき。まだ見ていないビューには画像を渡します。これはバッチ中断の判定が使うのと同じ遷移署名で、 要素ごとの操作状態(フィールドの入力内容、コントロールの有効・選択フラグ)を落とします。そのため同じ ビュー上でフィールドに入力したりコントロールを切り替えたりしても再添付は起きず、ビューが実際に切り替 わったときだけ発火します。 - 退化したツリー。 その署名が
screen_identityの構造ベースの経路に落ちたとき(セレクタでアドレス 指定するにはアクセシビリティ識別子が少なすぎる、id を持たないタブバーのような場合で、tap_pointが 唯一の入口になります)。ここでは画像を先回りして添付します。このトリガーは意図的に寛容にしてあり、id の乏しい画面がエスカレーションの往復に頼らずに済むようにしています。
どちらも発火しないとき、つまりすでに見た、アドレス指定できる豊富なツリーを持つ画面では、そのターンは
テキストのみになり、スクリーンショットはそもそもキャプチャしません(遅延キャプチャにより screenshot
のサブプロセスも省けます)。視覚は除去ではなく遅延です。すべての異なる画面は少なくとも一度は画像
付きで見え、退化したツリーには必ず画像が付き、残る必要はエージェント自身の need_screenshot 呼び出し
が拾います。ループはそのターンを、変えないまま画像付きで出し直します(1 ターンにつき最大 1 回)。ですから
エージェントは必要なものを常に見られ、要素リストだけで行動が決まるターンで画像を渡されないだけです。
--no-screenshot は従来どおり全ターンをテキストのみに強制します(渡す画像がないのでエスカレーションも
無効になります)。記録されるシナリオ成果物はバイト単位で不変なので、run・codegen・レポートには影響
しません。
セッション終了時の AI usage: 行に続けて、呼び出し元ごとの内訳(plan / next_action / alert-guard)を
表示します(analytics/usage.py、BE-0194)。これにより、トークン削減の効果を測定できます。各カテゴリは記録した
すべての呼び出しを漏れなく分けるので、合計は running total に一致します。この内訳は報告専用で、analytics/usage.py
の他の部分と同じく pass/fail には一切関与しません。
Claude オーサリングエージェント¶
record / crawl は本番の agents.protocols.Agent 実装として ClaudeAgent を 1 つ構築します
(agents/claude.py、agents/factory.py が組み立てます)。モデルへはベンダー中立の AiBackend
シーム(BE-0104)を通して話すため、プロバイダは設定の細部であって別のエージェントではありません。
解決済みの ai.provider(configuration)が次を選びます。
api-key(既定):Anthropic API。ANTHROPIC_API_KEY(またはai.keyEnvが指す環境変数)で認証します。(旧称anthropicも同じものとして解決されます。)bedrock:Amazon Bedrock。AWS の資格情報と、プロバイダプレフィックス付きのモデル id (BAJUTSU_BEDROCK_MODEL)で認証します。ant:公式の Anthropic CLI(ant auth login。ブラウザ経由の OAuth(SSO)サインイン)。 API キーの代わりに Claude の Pro / Max / Console のシートに課金され、すべての AI 経路で画像も そのまま使えます(BE-0163)。
モデルは claude-opus-4-8 です。anthropic は遅延インポートで(資格情報無しでもモジュールは
読み込めます)、クライアントはテスト用に注入できます。どのプロバイダでもターン契約は同じです。
- ツール強制呼び出し:
tool_choice={"type": "any"}で毎ターン ちょうど 1 つのツールを呼びます。tap(id)/type_text(id, text)/wait_for(id, timeout)/finish(assertions)。finishのassertions(exists/notExists/valueEquals/labelContains)はAssertionに変換されます(_to_assertion)。 - prompt cache(API 経路): 静的なシステムプロンプトとツール定義に
cache_control: ephemeralを付け、ターンごとに変わるのは観測(要素 + スクリーンショット)だけです。 - 視覚 + 要素の併用: スクリーンショットで見た目と状態を読み、操作は必ず要素リストの
idで行います(id を生成させません。リストには id を持つ要素だけを出します)。
ClaudeAgent() # api: 本番(環境の ANTHROPIC_API_KEY を使う)
ClaudeAgent(client=fake_client) # api: テスト
システムアラートの自動対処¶
idb のアクセシビリティクエリは前面アプリにスコープされるため、SpringBoard レベルのプロンプト
(iOS の「パスワードを保存しますか?」等)は見えず、アプリの要素ツリーが 1 つの window ノードに
縮退して run が静かにブロックされます。agents/alerts.py がこれを片付けます。
class AlertLocator(Protocol):
def locate(self, screenshot_png, instruction) -> AlertDecision: ...
class SystemAlertGuard:
def dismiss(self, driver) -> bool: ... # プロンプトがあれば座標 tap して True
SystemAlertGuard.dismiss: スクリーンショットを撮り、ロケータに「プロンプトがあるか、どこを押すか」を 問い合わせ、正規化座標 [0,1] を画面の point サイズ(最大要素 frame = アプリ window)に掛けてdriver.tap_pointでタップします。画面の point サイズは、ツリーが縮退してもアプリ window ノードが 全画面に広がることから求めます。ClaudeAlertLocator: 本番の実装です。Claude vision に PNG を渡し、ツールresolve_alertを強制呼び出しします。 既定は 最も無害な(dismiss 系の)ボタン("Not Now" / "Don't Allow" / "Cancel" 等)を選びます。instructionを与えると代わりにそのボタンをタップします。座標はピクセルで返させ、PNG の IHDR から得た 画像サイズで [0,1] に正規化します。- ロケータは注入可能です。テストやオフライン実行では決定的なロケータを使います。
run / record での使い方¶
run: ガードはシナリオごとに既定 ON です。CLI はdismissAlertsが有効な各シナリオにSystemAlertGuard(...).dismissをon_blockedとして渡します。ステップ失敗時に プロンプトを片付け、そのステップを 1 回だけ再試行します(run-loop)。waitステップ(for/settled/screenChanged)では同じハンドラが wait の途中でも待ち構えており、 すでにポーリング済みの画面のツリーが潰れて見えた時点で発火します(デバウンスとクールダウンを挟み、1 回の wait につき最大 2 回まで)。wait 自体のタイムアウトを待たず、末尾の再試行より前に回復できます(BE-0269)。 シナリオ側でdismissAlerts: falseで無効化、{ instruction: "tap Allow" }でボタンを指定できます。--dismiss-alerts/--no-dismiss-alertsは全シナリオを上書きし、--alert-instruction "..."は既定指示を設定します。record --dismiss-alerts: opt-in です(オーサリング時はまだシナリオが無いため)。割り込むプロンプトを片付け、 エージェントに常にクリーンな画面を見せます。dismissal は環境操作であって記録ステップではありません (リプレイ側は各シナリオのdismissAlertsで対処します)。
ガードは視覚モデルを使うため
ANTHROPIC_API_KEYが必要です(cli の .env)。 無くてもベストエフォートで単に no-op し、run を失敗させません。ガードはブロックしたプロンプトを 片付けるためだけに動作し、合否は機械判定のみで AI 非依存です(concepts)。