はじめに
毎月のクレジットカード明細を一行ずつ読んでいる人はほとんどいないと思います。
ジムの会費はお試し期間が終わるとこっそり値上がりするし、無料トライアルはいつのまにか有料プランに切り替わって身に覚えのない会社名で請求が来たりします。
また、請求システムの不具合で同じ月に二重に引き落とされることもあります。どれもその場では気づくことはあまりなく、気づいたときには結構な金額になっている、というものです。
AWSの「Agents for Humans Hackathon」(Everyday Agentsトラック)に、この問題を解決するエージェント「SubSentry」を作って応募しました。今回はその制作過程と、途中でハマった2つの不具合について書きます。
作ったもの
SubSentryは、1請求サイクル分のサブスクを次の4つのルールでチェックするエージェントです。
- 価格が15%以上値上がりしていないか
- 同じサイクルで2回以上請求されていないか
- 台帳に存在しなかった新規の請求元でないか
- 重複が単なる無駄になるカテゴリ(ジム2件、クラウドストレージ2件など)で他の契約と被っていないか
どれにも引っかからなければ黙って自動更新します。
1つでも引っかかれば、理由を明記したカードがWeb UIに出て、人間の承認・却下を待ちます。
却下すれば「何もしない」で保留され、エージェントが勝手に解約や異議申し立てを進めることはありません。
エンジン部分はStrands Agents SDKで書いた4つのツールを持つエージェントで、Amazon Bedrock AgentCore Runtime上で動いています。モデルにはClaude Sonnet 4.6を使いました。
一番時間をかけた部分
今回、推論の精度チューニングにはほとんど時間を使っていません。
時間を使ったのは「エージェントが人間の判断を待つ」という部分の実装です。
エージェントのrequest_reviewツールは、DynamoDBの1行をブロックしながらポーリングします。
一方でNext.js製のWeb UI(Vercel)も、その同じ行を反対側からポーリングしています。
両者は直接通信しません。
エージェントがPENDINGを書き込み、ブラウザがカードを表示し、人間がApprove/Rejectをクリック(あるいはCLIで承認)すると、その決定が同じ行に書き込まれ、エージェント側のポーリングがそれを拾って処理を再開します。
これによって、AgentCore Runtimeの長時間ブロッキング呼び出しと、ブラウザでの非同期なクリックが、WebSocketも共有プロセスも使わずに噛み合います。
CLIからでも同じテーブルに書き込むだけなので、Web UIと全く同じように承認できます。
ハマったところ1: 並列書き込みでファイルが壊れた
ローカルで「問題なし6件・要確認4件」の一連の流れを初めて動かしたとき、出力がおかしいことに気づくのに1分くらいかかりました。
承認レコードがいくつか消えていて、ローカルの.approvals.jsonを開いてみると、2つの書き込みが途中で絡まったような壊れ方をしていました。
Strandsは同じターンの複数のツール呼び出しを並列に実行することがあり、当時のローカルストレージ実装は単純な「JSON読み込み→メモリ上で変更→書き戻し」でロックを持っていませんでした。
2つの書き込みがほぼ同時に走ると、片方がもう片方を上書きして消してしまいます。
ロックを1つかけるだけで修正できました。
with _LOCK:
data = _local_load()
data[approval_id] = entry
_local_save(data)
たった5行ですが、原因にたどり着けたのは「プロンプトのどこかが悪いはず」と決めつけず、実際に壊れたファイルの中身をバイト単位で見たからです。
DynamoDBのUpdateItemは1アイテムごとにアトミックなので、本番構成ではこの問題自体が起きません。ローカル用の「とりあえずJSONファイルに書いておく」という妥協が
DynamoDBがタダでくれるものをわざわざ壊れた形で再発明していた
というオチでした。
ハマったところ2: OIDC FederationがVercel上で動かなかった
VercelとAWSの接続には、事前にOIDC Federationを設定していました。
IAMロールの信頼関係も一見正しく見えたのですが、実際にデプロイしたアプリはランタイムで認証情報を読み込めずエラーになりました。
時間の制約もあり、OIDCの深追いはやめて、DynamoDBの2テーブルと該当エージェントのInvokeAgentRuntimeだけに絞ったIAMユーザーのアクセスキーに切り替えました。
エレガントな解決ではありませんが、権限を絞った形で確実に動く方を選びました。
デモ動画(英語)
おわりに
デモは実際のインフラ上で動きます。
ライブURLをクリックすると本物のInvokeAgentRuntimeが呼ばれ、Claude Sonnet 4.6が推論し、DynamoDBに実データが書き込まれ、ブラウザ(またはターミナル)での承認がブロックされていたバックエンド呼び出しを本当に再開させます。
請求データは今のところ固定のモック台帳なので、デモは何度でも再現できます。
次にやるなら、銀行やカード会社のAPI連携をscan_subscriptionsツールに差し込むこと、そしてメールやSMSなどの通知チャネルを足して、アプリを開かなくてもエージェントの方からピンを送れるようにすることを考えています。
それがこのハッカソンのテーマである「裏で静かに動く」というアイデアに一番近づく方向のはずです。
・ライブデモ: https://sub-sentry-xi.vercel.app
・リポジトリ: https://github.com/yama3133/sub-sentry
最後まで読んでいただきありがとうございました。
