Claude Codeのダイナミックワークフローを手書きする

Claude Codeのダイナミックワークフローを手書きする

ultracode とダイナミックワークフロー

Claude Codeのeffortultracode(UIではxhighの右、maxのさらに上)にすると、ワークフローのランタイムによる自動オーケストレーションが有効になります。

/effort設定


この仕組みはBunのZigからRustへの大規模なリライトで活用されたことで知られています[1]
Claude Codeのビルトインツールである/deep-researchも、内部で同じワークフローを使っています。

ドキュメントによれば、このとき最大16個のエージェントが並列に起動し、それぞれが独立したセッションを持って動作します。
Claude Codeがユーザーの指示から「ワークフローにする価値がある」と判断した場合に、このランタイムが処理を担います[2]

このultracodeのオーケストレーションを支えているのが、ダイナミックワークフローです。
画面上では/workflowsでバックグラウンドで動作したワークフローの一覧を確認できます。
デスクトップ版とコマンドライン版でUIは微妙に異なりますが、コマンドライン版の方が情報量が多くなっています。
ワークフローとは、サブエージェントの実行順をモデルが推論しながら決めるのではなく、あらかじめ生成したJavaScriptの台本に従って再生します。
類似のケースとして、多段階のツール呼び出しをあらかじめ生成したTypeScriptコードにまとめ、推論の外で実行するCloudflareのCodeModeに近いアプローチですが。ダイナミックワークフローの目的はトークン削減ではありません。

Claude Code登場以前の、今では古い記事ですが。Anthropicは2024年の記事で、ワークフローとエージェントを対比して説明しています[3]
そこではエージェントを、モデルが「自らのプロセスやツールの使用を動的に指示します」と説明していました。
一方ワークフローは「事前に定義されたコードのパスによってオーケストレーションされます」とされ、静的なものとして対置されています。
この対比からすると、「ダイナミックワークフロー」という名称は、コード生成の部分が動的に行われることに由来していると考えられます。
ただし動的であるのは生成の段階までで、生成されたワークフロー自体は従来の定義どおり静的なコードパスとして実行されます。

Anthropicは最上位の機能に「ウルトラ〇〇」という命名を当てる傾向があります。
その中には、従来ローカルで処理していたものをクラウド側へ移譲してスケールさせるものもありました。
ultraplan(削除済み)はリッチなPlanモードをブラウザ上で操作できるものでしたし、ultrareview/code-review ultra)はクラウド上のマルチエージェントでコードレビューを実行します。

当初、ultracodeもクラウドで処理されるものと想像していましたが、実際はそうではありませんでした。
むしろローカルでサブエージェントを並列に実行する仕組みです。
そのため筆者のMac miniのような非力なマシンでは、並列数が上限に達するとメモリを消費し、OSのウィンドウマネージャが応答しなくなります(迷惑)。
また並列セッションが動く分、トークンコストも加速します。

生成されたコードは読み書き自由

ダイナミックワークフローは、内部で生成されたJavaScriptを後から読むことができます。
ultracodeでワークフローを実行すると、ファイルは~/.claude/projects/以下に自動で保存されます。
実体はJavaScriptコードのファイルです。
それをsキーでプロジェクトの.claude/以下に書き出せます。
書き出したものは次回からスラッシュコマンドとして使えるようになり、外部に持ち出して再生可能なワークフローとして利用できます。
プロンプトで「このファイルをワークフローで実行して」と指示するだけで、Claude Codeはその台本どおりになぞってくれます。

つまり自分で書くこともできます。
手書きといっても、昨今はエージェント経由で書いてもらうことが多いでしょう。
ドキュメントにはワークフローで使えるAPIがあまり明記されていませんが、書き出されたファイルを見ていくとどのような機能があるか分かります。

最も重要なのがエージェント関数agent(..., {schema})です。
サブエージェントはここを起点に実行されます。
コード上でエージェント関数を並列で呼び出すのか直列で呼び出すのかを、JavaScriptの構文でそのまま表現できます。
直接実行する場合は単にawait agent()を並べます。

parallel()はエージェントの呼び出しを並列実行し、完了を待って次のフローに進めるための関数です。
待ち合わせて一つの処理にまとめたいときに適しており、すべての結果に依存した次の処理がある場合に使えます。
このあたりはJavaScriptのPromiseや非同期処理を使ったことがあればすぐに想像でき、理解しやすい部分です。

一方pipeline()は、呼び出し結果を段階的にパイプします。
各項目は待たずに独立して流れ、呼び出した後は非同期に進みます。
バラバラに実行して結果を出せばよい場合に、待ち合わせを減らせます。

const results = await parallel([
  () => agent('A を調べる'),
  () => agent('B を調べる'),
  () => agent('C を調べる'),
])
// ここに来た時点で A・B・C は全部終わっている

const results = await pipeline(
  ['x.ts', 'y.ts', 'z.ts'],
  file  => agent(`${file} を調べる`),   // 1段目
  found => agent(`${found} を直す`),    // 2段目
)
// x が2段目にいる間に、y はまだ1段目にいる

スキーマは、エージェントの引数に渡すことでレスポンスを型のある戻り値にする仕組みです。
これを使ってロジックを組みます。
サブエージェントの呼び出し結果を構造化するもので、定義したスキーマがエージェントの戻り値になるため、filterwhileで分岐や繰り返しも実装できます。

制約として、JavaScriptとはいえ自由にAPIを使えるわけではありません。
たとえばfetchrequireprocesscrypto、Bun固有のAPI、WebAssemblyimport()は動作しません、実行前に強制終了します。
つまりホストAPIを呼び出せない、ECMAScript互換のDSLだと捉えることができます。
筆者はゲームエンジンなどに付属するLuaのスクリプトレイヤーを想起しました。
またDate.now()Math.randomなどの非決定的な値も記述できません。

この制約は、プロンプト内の一致をもって結果をキャッシュする、いわゆるメモ化のために設けられています。
Date.now()Math.randomを呼び出すと、次のようなエラーが返ります。

Date.now() / new Date() are unavailable in workflow scripts (breaks resume).
Math.random() is unavailable in workflow scripts (breaks resume). For N independent samples, include the index in the agent label or prompt.

呼び出し結果はプロンプトと引数からハッシュ化してキャッシュキーにします。
中断再開で継続するときにこの値を使います。
実際に次のように検証しました。
まずワークフロー内でagent()を呼び出し、同じrunIdを指定して再開したところ、戻り値は変わるのに新規セッションは作成されませんでした。
次にプロンプトを改変して同じrunIdで呼び出すと、新規セッションが作成されました。
作成されたjournal.jsonlを監査すると、1回目と2回目のキーは一致し、3回目のキーだけが異なることを確認できました。

ファイルの保存先は次のとおりです。

~/.claude/projects/<プロジェクト>/<セッションID>/
├── workflows/
│   └── wf_<runId>.json                     1〜2.5KB  実行1つにつき1ファイル
└── subagents/workflows/wf_<runId>/
    ├── journal.jsonl                       556B      呼び出しごとに2行を追記
    ├── agent-<agentId>.jsonl               約31KB    実行されたエージェントの会話記録
    └── agent-<agentId>.meta.json           48B

ちなみに、このJavaScript(ECMAScript)は、Claude Codeが実装されているBunのJavaScriptCore(JSC)で評価されています。
次のようにプロパティ参照のエラーを起こしてみると、Node.jsとBunの違いが観察できます。

$ node -e 'try{const o={};o.nope()}catch(e){console.log("node:", e.message)}'
node: o.nope is not a function
$ bun  -e 'try{const o={};o.nope()}catch(e){console.log("bun :", e.message)}'
bun : ({}).nope is not a function. (In '({}).nope()', '({}).nope' is undefined)

Codex外部レビューをループするワークフロー

この仕組みを使って、筆者がいつも行っている作業を、プロンプトベースのスキルからJavaScriptの半固定なワークフローに移行しました。
それが/red-pen-loopです[4]
このワークフローはコードレビューを外部のCodexに投げます。

ワークフローで定義するJavaScriptでは、サブエージェント同士が交換する型をスキーマとして宣言できます。
Codex側には、外部コマンド呼び出しで決まった形のJSONを返す仕組みがあります。
これを利用して、サブエージェントがCodexをコマンドラインから呼び出し、その返答をスキーマに沿った形へ変換して呼び出し元のClaude Codeのセッションに返します。
呼び出し元は型どおりの情報を受け取り、それをJavaScript上の処理に落とし込みます。

スキーマの威力

戻ってくるスキーマが保証されているので、ワークフローとしてループや条件分岐が書けます。
たとえば次のように結果を受け取って、エージェント呼び出しをループできます。

const targets = findings.filter(f => f.priority <= THRESHOLD)

これまでプロンプトで「こういう時はこうする」とフローを説明していた部分が宣言的なコードになり、決定論的に実行されます。
ここが面白いところです。
個人的には、LangChainで細かい動作を手書きしていた時代に揺り戻しているように感じました。

テストしてみる

実際に、ここで作成した/red-pen-loopを呼び出し、プルリクエストのレビューをpriority 1がなくなるまで繰り返す実験をしました。
結果として、コミットと修正を繰り返してくれました。
これまで2、3段階の人手の介入で済ませてきた修正作業をワークフロー化できたことになります。

実行して分かったのは、バックグラウンドで起動されるセッションから許可を求められるため、スムーズに動作させるには承認を自動判定するオートモードにしておく必要があるということです。
Haikuは使用不可なので、Sonnet・Opus以上で実行する必要があります。
厳密には--dangerously-skip-permissions(yolo)でもよいですが、セキュリティ上のポリシーで使っていません。

コラム: ultrathink

ウルトラ系の機能で最初に普及したのはultrathinkです。
これは当時、モデルのreasoning budgetを指定する、つまり思考トークンをより多く生成して深く考えるための隠しショートカットとして用意されていました。
その後、Claude Codeは思考レベルの調整を/effortでの切り替えとして明示的に選べるようにしました。
現在のultrathinkは、単に“requesting deeper reasoning on this turn”を1ターンのみ挿入し、あとはモデルの判断に任せる機能に切り替わっています。
実際にはultrathinkは一度削除されたのち、この仕様で復活した経緯があります。
以前存在した、各言語への翻訳ワードへの反応も今は無効です。
古いネット情報やそれを参照したAIの中には、Claude Codeに話しかけるときに「deepthink! 深く考えて」と唱えると頭が良くなるという教えが残っていますが、都市伝説が広がる過程のようで面白いですね。

説明 実行方法
ultrathink プロンプトへ“requesting deeper reasoning on this turn”を追加 ハーネス側の差し込み
ultracode xhigh + 自動オーケストレーション ローカル(CPU数で並列上限が決まる)
ultraplan リッチなPlanモード。削除済み。 Claude Code on the web
ultrareview マルチエージェントのレビュー。従量課金 Claude Code on the webのインフラ

おわりに

Anthropicはワークフローをユーザーが自由に書くものではなく、エージェント経由で自動で書かれるものとして説明しています。
つまり手書きは推奨していないようです。
ワークフローのAPI詳細もドキュメントには書かれていません。
運用時に互換性を維持することや、実験的な機能を入れること。そもそもClaude Codeの更新頻度を考えると妥当な判断です。

単純なワークフローなら、毎回都度生成したコードを通して実行しても問題ないでしょう。
それをプロンプトベースのスキルを使って繰り返し再現することもできます。
そしてワークフローは実行後にローカルにコードを吐き出せます。
つまり実質的な差は、その場で生成するのか、過去に実行した静的なコードを読み込むのかという選択になります。

このJavaScriptのDSLを台本として実行する仕組みは拡張性が高いです。
推論によるブレを最小にして意図どおりコントロールでき、コードを他人と共有できることも利点です。
作業工程が大きくなるほど、コードを通して直接設計する価値があります。
このときに、ワークフローコードを読んで動作を理解し、パッチを当てられる知識は有効です。

ぜひ皆さんもワークフローを手書きしましょう。


  1. https://claude.com/blog/introducing-dynamic-workflows-in-claude-code↩︎
  2. https://code.claude.com/docs/en/workflows#let-claude-decide-with-ultracode↩︎
  3. https://www.anthropic.com/engineering/building-effective-agents↩︎
  4. https://gist.github.com/laiso/236be6cee65c887f7c86b8d01f175911↩︎