opencodeがあるのに、C 1ファイルのエージェントを育てている

本格的なコーディングはopencodeでやっている。それでも cagent という C 1ファイルのエージェントを自作して、ここ数日ずっと育てている。理由は単純で、opencodeにはできないことを2つやりたかったからだ。

1つは声。KOE(koe.live)の音声入出力をネイティブに組み込んで、マイクに話しかけるだけで動くようにしたかった。もう1つはサイズ。ソース全体が1コンテキストに収まる大きさを保ったまま、grep・サブエージェント・MCPクライアント・セッション継続を足していく、という制約付きの遊びをしたかった。

今日の時点で agent.c は430行から1591行になった。今日1日で足したのは grep / todo管理 / サブエージェント(task) / MCPクライアント(stdio) / セッション継続(-c) / 音声マクロ(合言葉) / 常駐モード(--daemon) の7つ。全部、実サーバー・実プロセス・実マイクでテストした。

そして常駐モードのテストで、2つの発見があった。1つはcagent自身のバグ、もう1つはcagentの外、koe.live本体の実障害だった。

バグ1: 常駐モードが、一度も声を検知していなかった

常駐モード(--daemon)は、話しかけるまで待機して、話し終わったら自動でSTTに投げる仕組みにしたかった。soxrecコマンドにはsilenceという便利なフィルタがあって、「一定の音量を超えたら録音開始、また一定時間下回ったら録音終了」を勝手にやってくれる。これを使えば、常時マイクを開けっぱなしにしても課金もCPUも食わない待機ができる。

実装してビルドして、sayコマンドでテスト音声を作り、スピーカー→マイクのループバックで実機テストした。

まったく反応しなかった。 recプロセスは起動したまま、何を再生しても録音が始まらない。

最初はマイクの権限を疑った。次に音量を疑って、出力85・入力90まで上げても変わらなかった。手動でrec単体を動かしても同じで、ここで初めて「サンプルレートを明示指定しているせいでは」と当たりを付けた。

# 動かない
rec -q -c 1 -r 16000 out.wav silence 1 0.1 2% 1 2.5 2%
# rec WARN formats: can't set sample rate 16000; using 48000
# → silenceトリガーが一度も発火しない

# 動く
rec -q out.wav silence 1 0.1 2% 1 2.5 2%

-r 16000を渡すと、デバイスのネイティブレート(このMacでは48kHz)からのリサンプルが挟まる。その状態だとsilenceエフェクトのリアルタイム閾値監視が機能しなくなる、というのが実測での結論だった。理由の内部実装までは追っていないが、-rを外して分かった。

直してもう一度ループバックテストをしたら、一発で音声を検知してSTTが「今の時刻を教えて」を拾い、モデルがpwdを実行して答えを返してきた。SIGTERMでの正常終了も確認できた。

コードの中身より、原因が見えなかった時間の方が長かった。ここも前回の記事(AIコーディングは「動くはず」で動かない)と同じ形をしている。推測で直そうとした2回は外れて、パラメータを1つずつ削って初めて分かった。

バグ2: 声は認識していたのに、返事だけがずっと来なかった

--daemonが動くようになったので、次はkoe-tsuneji(かけ流しラジオ)に「きた声」コーナーを足す作業に進んだ。koe.liveの受信箱に届いた本物の声を選んで、無音トリム・ノイズ除去・ラウドネス正規化をかけてスタジオクオリティに仕上げ、番組に差し込む機能だ。ここまでは順調にできた。

問題は、番組の本編ナレーションを合成する段階で起きた。koe.liveの/api/speakにテキストを送っても、90秒待っても、10分待っても返ってこない。

koe-cuda(TTSのGPUサーバー)に直接入ってログを見ると、原因はすぐに分かった。

[koe-cuda] batch n=8 gen=36.48s uids=['demo1', 'demo1', 'default', 'nojima', 'nojima', ...]

リクエストはFIFOの単純キューに積まれていて、公開デモ(demo1等)の連投がキューの前を埋め尽くすと、実サービスからのリクエストがその後ろに並ぶしかない。デモ側の負荷が高い時間帯は、実リクエストが数分単位で待たされる。

これは自分のツールのバグではなく、koe.live本体の設計の隙だった。直すことにした。

キューをPriorityQueueに変えて、demo1/demo2/demo3だけ優先度を下げるパッチを書いた。

_REQ_Q = _queue.PriorityQueue()
_DEMO_UIDS = {"demo1", "demo2", "demo3"}
def _req_priority(uid):
    return 1 if uid in _DEMO_UIDS else 0  # 0=先(実リクエスト)、1=後(公開デモ)

本番に適用して、実際にデモを16件連投した状態で実リクエストを投げたら、実測でこうなった。

応答時間
修正前(デモ連投時)最大90秒でもタイムアウト
修正後(デモ連投時)39.6秒(デモの半数以上より速い)
修正後(平常時)1.4〜3.1秒

ここで1つ、正直に書いておきたい失敗がある。効果を確かめるために本番のGPUポッドへ直接デモリクエストを連投したのだが、これは後で読んだそのリポジトリのCLAUDE.mdに「本番podで重い検証作業をしない」と明記されていたルールに違反していた。過去に同じことで本番のTTSを落とした実障害があったらしい。今回はクラッシュはせず、デモ側の応答が一時的に遅くなった程度で収まったが、ルールを先に読んでいなかったのは事実で、そこは反省点として残しておく。

真似しないと決めたもの

cagentに機能を足すたびに、「これはopencodeの土俵か」を判定基準にしている。TUI・LSP補完・シンタックスハイライト・プラグインシステム・多プロバイダ対応のUI — このあたりは全部見送った。判断基準は一言で、「追加後もソース全体が1コンテキストに入るか、声で使えるか」。どちらもNoなら断る。

次: まだ実装していない話

「きた声」コーナーの延長で、家族がKOEに話しかけると、それがそのままissue化される、という構想がある。「お父さんのブログ、写真が出てないよ」と話しかけたら、該当リポジトリを調べて修正案を作り、音声で承認を求める、というものだ。声を出す人とコードを書く人が別でいい、という発想で、まだコードは1行もない。今日みたいに、作ってみて初めて分かるバグがまだいくつも隠れていると思う。