コンテンツにスキップ

Skill 非同期実行 (chat-mode async)

chat router が長時間実行 Skill を起動してもプロンプトが固まらない仕組みと、 完了 narration が会話 thread に LLM context を blocking せずに戻る仕組み。

なぜ非同期か

Skill 実行は秒〜分単位かかることがあります(skill_builder / mcp_search / eval round / 実 workload skill)。 FP-0012 までは invoke_skill が chat session の main loop を blocking していました:

Session.run() main loop — sequential, single consumer
─────────────────────────────────────────────────────
kind, payload = await _consume_inbox()         ← skill 実行中はここで止まる
await _handle_user_message()
  └─ RouterLoop.run()
       └─ await invoke_skill tool
            └─ await _run_skill_awaitable()
                 └─ await agent.run()           ← 数分経過
                                                   user が 3 件入力
                                                   → 全部 inbox queue
                                                   → 何も処理されない

User が入力した内容は inbox に黙って溜まるだけで、 ack も progress feedback も 途中の clarifying question も不可能でした。

Chat-mode の invoke_skill は非 blocking 化されました。 Plan-mode は意図的に blocking のままです (= 各 step の LLM が次 step の入力に nested skill 結果を inline で必要)。

chat-mode invoke_skill の現挙動

FP-0034 Phase 6 (2026-05-16) routing note: wrapper-only path が 既定。 LLM は invoke_action(action_name="skill__<name>", args={"input": ...}) で skill を呼ぶ (= legacy invoke_skill(name, input) 直接ではない)。 universal_dispatch.py が wrapper call を 同 invoke_skill handler に route — spawn-ack 機構は不変。 下図は 可読性のため legacy tool 名表記、 production LLM-visible surface は invoke_action

User: 「skill_builder で string_length を作って」
  └─ RouterLoop: invoke_skill(name="skill_builder", input={...})
       └─ _handle: spawn_skill_fn → ChatSession._spawn_skill_for_router
            └─ asyncio.create_task(_run_one_skill(...))
            └─ 即時 return:
                 {status: "spawned", run_id, chain_id, skill, note}
       └─ Router LLM が spawn ack を見て 1 文の reply を生成
  └─ Router LLM → user: 「skill_builder を起動しました — 完了したら
                         お知らせします。 進捗は /tasks で確認できます。」
  └─ Session loop: 即座に次の inbox message 処理に戻る

User: 「ちなみに recall の設定どうなってた?」
  └─ RouterLoop: recall(...) → router LLM が inline で回答
  └─ User は回答を見る — skill_builder はバックグラウンドで実行継続

[2 分後] skill_builder 完了
  └─ _run_one_skill → _enqueue_skill_completed → _put_inbox("skill_completed", {...})
  └─ Session.run() が inbox kind を pickup して
     _handle_skill_completed(payload) を呼ぶ:
        ├─ history に user-role ChatMessage を append:
        │     "[task_completed] chain_id=... run_id=...
        │      skill: skill_builder  status: finished
        │      result: {skill_name: string_length, path: ...}
        │      Please summarize for the user in 1-2 sentences."
        └─ router LLM turn を 1 回実行 (LLM は thread 全 context を持つ)
  └─ Router LLM → user: 「skill_builder が完了しました。
                          reyn/project/string_length/ に作成されました。」

LLM が動く 2 つのタイミング:

  1. Spawn ack — invoke_skill が {status: "spawned", ...} を返したとき、 router LLM は 1 文の ack を生成し、 進捗確認のため /tasks を案内します。 同 request に対して invoke_skill を再度呼んではならず、 in-flight task に 対する follow-up 質問もしてはいけません。
  2. 完了 narration — 会話 thread に [task_completed] user-role message が 到着すると、 LLM は result から user-relevant fields を extract して 1〜2 文で narrate します。

両ルールは router system prompt の Behaviour section で pin されています。

なぜ fresh tool_result でなく user-role message か

OpenAI Chat Completions と Anthropic Messages API はどちらも厳格な ペアリング制約を持っています: tool_result / role: "tool" message は 直前に対応する tool_use / tool_calls を含む assistant message が 必要です。 非同期 task が完了する頃には、 元の invoke_skill tool_use には 既に対応する tool_result (= spawn ack) が紐付いています。 同じ呼び出しに 2 つ目の result を送ると API は 400 を返します。

そのため完了は、 既存の会話 thread に挿入される合成 user-role message (meta.source = "skill_completion") として配送されます。 Router LLM は thread 全 context (= 元の spawn ack + 中間の user とのやり取り + 完了通知) を 持っているので、 chain_idrun_id が specific invocation との correlation を提供します。 並行 skill は各々の chain_id を持ち、 LLM はどの task が完了 したかを区別できます。

エラー時の anti-optimism

Skill の terminal status は finished / loop_limit_exceeded / error です。 Router system prompt の narration ルール:

  • finished: 完了確認、 必要なら次 step を hint。
  • loop_limit_exceeded: phase budget 切れと伝え、 safety.loop.max_phase_visits を上げて再実行を提案。
  • error / 任意の非 finished status / result.error が存在する場合: reply は specific error を verbatim で surface しなければならない。 成功と narrate してはいけない。 user 向けに quote (output_language が設定 されていれば翻訳、 ただし failure signal は明示維持) し、 最も可能性の高い fix を提案。

2026-05-10 G4 spike で、 strong tier (gemini-2.5-flash) router が status="error" + data.error populated でも success と narrate する事例が 観測されました。 MUST-surface rule は FP-0011 Component B の強化と同時に land し、 この flash tier optimism bias に対応します。

A2A / MCP bypass path

reyn mcp serve と FastAPI A2A endpoint (reyn web) は両方とも mcp_server.send_to_agent_impl 経由で agent に到達し、 ChatSession._handle_user_messageinline で駆動します (= session.run() を通らない)。 MCP SDK の stdio transport 下で asyncio.create_task で spawn した session.run() coroutine は request handler が await している間 starved になり、 内部の LLM call が進まず handler が empty timeout します。 そのため bypass は 全てを SDK が scheduling している単一 event loop task 上に保ちます。

trade-off: bypass path には skill_completed inbox kind (= FP-0012 で導入) を consume するものが居ません。 明示 drain なしだと 非 blocking invoke_skill は spawn ack しか返さず、 A2A 駆動の agent では完了 narration が fire しません。

send_to_agent_impl_handle_user_message 完了後に残 timeout 予算内で 3 step でこの gap を埋めます:

  1. await asyncio.gather(*running_plans) — plan-mode async task (= ADR-0023 §2.1.1) が完了し、 terminal text を history に append。
  2. await asyncio.gather(*running_skills) — spawn 済 skill が terminal status に到達し、 _enqueue_skill_completed 経由で skill_completed を enqueue。
  3. session.drain_skill_completed_inbox(deadline_monotonic=...)skill_completed item を非 blocking で pop、 WAL consume entry を記録、 各々を _handle_skill_completed (= router LLM を回して narration 生成) に dispatch。 skill_completed 以外の kind は FIFO で re-queue され、 次の consumer に渡る。

deadline が drain 中に fire したら partial=True で返り、 残 item は 次 call の pickup を待って inbox に残ります。

Plan-mode は blocking のまま

Plan-mode RouterLoop は run_skill_fn のみ bind し (= 旧 blocking path)、 spawn_skill_fn は None のまま。 そのため plan step の LLM が invoke_skill を呼んでも完了まで blocking し、 結果が次 step に inline で feed されます。 これは意図的で — plan step は sequential 実行で、 次 step の prompt は前 step の結果を含むことが頻繁にあるため。 spawn-and-return semantics だと planner が 独自の完了追跡層を構築する必要があり、 複雑性に見合いません。

切り分けは RouterLoop._build_router_caller_state で wiring されています:

_spawn_skill_bound = None
if hasattr(self.host, "spawn_skill") and callable(...):
    _spawn_skill_bound = ...

return RouterCallerState(
    run_skill_fn=_run_skill_bound,        # 常に present
    spawn_skill_fn=_spawn_skill_bound,    # chat-mode のみ
    ...
)

Plan-mode の _PlanStepHostspawn_skill を実装しないので hasattr check が 失敗し、 binding は None のまま維持されます。

Slash commands

/tasks は Skill 実行と Plan task を横断する統合 entry point:

/tasks                          → 実行中の全 task (skills + plans) を一覧
/tasks list                     → /tasks と同じ
/tasks status <run_id_prefix>   → current phase + 経過時間 + chain_id
/tasks kill <run_id_prefix>     → 特定 task を中止

旧 command も alias として継続:

  • /skill list / /skill discard <run_id> — Skill のみ (PR-resume-ux U2)
  • /plan list / /plan discard <plan_id> — Plan のみ (ADR-0023)

Crash 越しに保持されるもの

State 配置先 Crash 耐性
inbox queue (skill_completed 含む) agents/<name>/state/inbox.snapshot.json yes (PR21)
spawned task (asyncio.Task in memory) session 内のみ no — process 終了で task も死ぬ
running_skills_* dicts session 内のみ no
実行中の Skill state per-skill snapshot + WAL yes (PR-resume-auto / ADR-0023)

Crash 前に spawn 済で完了していなかった Skill は、 標準の skill-resume 仕組み (= per-skill snapshot + WAL replay) で resume 可能です。 chat session の running_skills dict や skill_completed inbox には依存しません。 restore 後、 auto-resume coordinator が active な Skill を再起動し、 完了時には新規 run と 同様に restored inbox に対して skill_completed を enqueue します。

関連項目

  • コンセプト: plan-mode — sequential step 実行 (= 明示的に blocking、 chat-mode async と対比)
  • コンセプト: skill-resume — in-flight skill state の crash recovery
  • リファレンス: chat CLI/tasks / /skill / /plan slash command
  • FP-0011 (docs/deep-dives/proposals/0011-remove-narrator.md) — 専用 narrator skill 削除、 router LLM が inline で narrate
  • FP-0012 (docs/deep-dives/proposals/0012-async-skill-execution.md) — 本 design の完全な proposal