こんにちは。ジェダイパンくず🚀 です。

前回の記事 ADK Go 2.0 のグラフワークフローを読んで SRE Agent への応用を考える の最後に、こんな宿題を残していました。

インシデント対応は「調べて、分からなかったらもう少し調べる」という反復が本質なので、完全に静的なグラフでは表現しきれない部分があります。NewDynamicNode で調査ループを書き、確定した対処だけを静的グラフに戻す、というハイブリッドが現実解な気がしていますが、そうすると動的ノードの中身が結局ブラックボックスになるジレンマがあります。

その後、Claude Agent SDK の agent loop や、Claude Code の dynamic workflows を眺めていて、「自律的に、動的に回るループ」を Google の Agent Development Kit(ADK)でやるとどうなるのかが気になりました。結論を先に書くと、ループそのものは ADK でも回せます。ただし止める仕組みは自分で書く必要があります。この記事はその「止める仕組み」、つまりガードレールの話です。

記事中のコードは jedipunkz/adk-playground の investigation-loop にあり、ADK Go v2.5.0(2026-09-30 リリース)で go vet とテストを通しています。

「ループ」は 2 種類ある

最初に整理しておきたいのは、「自律的なループ」と言ったときに 2 つの別のものが混ざりがちだという点です。

Claude 側 ADK Go 側
agent loop(1 エージェントの中で、モデル → ツール → モデルを繰り返す) Claude Agent SDK の query() llmagent
オーケストレーションのループ(エージェントを何度も呼び、結果を見て次を決める) Claude Code の dynamic workflows(loop until done パターン) workflow.NewDynamicNode

Claude Code の dynamic workflows は、Claude 自身が JavaScript のオーケストレーション用スクリプトを書き、ランタイムがそれをバックグラウンドで実行する機能です。スクリプトの中では agent() でサブエージェントを起動し、parallel() / pipeline() で fan-out します。ループ・分岐・途中結果はスクリプトの変数が持つので、Claude のコンテキストには最終結果しか入りません。

ADK の dynamic workflow も「制御フローをコードに置く」という点は同じです。違いはそのコードを書くのが人間(開発者)だという点です。ADK には LLM がオーケストレーションを書く仕組みは組み込まれていません。

今回はこの 2 つを組み合わせます。外側のループ(何ラウンド回すか、いつ止めるか)を Go の dynamic node に置き、内側の agent loop(1 ラウンドの調査)を llmagent に任せます。

ADK の llmagent は止まらない

まず内側の agent loop です。ADK Go の llmagent のループ本体は internal/llminternal/base_flow.go の Flow.Run にあります(v2.5.0 時点、要点だけ抜粋)。

func (f *Flow) Run(ctx agent.InvocationContext) iter.Seq2[*session.Event, error] {
	return func(yield func(*session.Event, error) bool) {
		thoughtOnlyTurns := 0
		for {
			var lastEvent *session.Event
			for ev, err := range f.runOneStep(ctx) {
				// ... イベントを上流に流す
				lastEvent = ev
			}
			if lastEvent == nil {
				return
			}
			if lastEvent.IsFinalResponse() {
				if !isThoughtOnlyTurn(lastEvent) {
					return
				}
				thoughtOnlyTurns++
				if thoughtOnlyTurns >= maxConsecutiveThoughtOnlyTurns {
					return
				}
			}
			// ...
		}
	}
}

runOneStep がモデルを 1 回呼び、ツール呼び出しがあればツールを実行します。ツール呼び出しのない最終応答が返るまで for {} が回り続けます。組み込みの上限は「思考だけのターンが 10 回続いたら諦める」(maxConsecutiveThoughtOnlyTurns = 10)だけです。MaxLLMCalls という設定は存在しますが、v2.5.0 では live(双方向ストリーミング)用の LiveRunConfig のフィールドで、通常の実行では使われていません。

Claude Agent SDK と並べるとこうなります。

Claude Agent SDK ADK Go v2.5.0
max_turns / maxTurns なし
max_budget_usd / maxBudgetUsd なし
allowed_tools / disallowed_tools / permission_mode なし(BeforeToolCallback で自分で書く)
読み取り系ツールだけ並列実行し、書き込み系は順番に実行 1 応答内の複数のツール呼び出しを、既定で goroutine で並列実行(読み書きを区別しない)
自動 compaction runner.Config.Compaction(既定は無効)

ADK が劣っているという話ではありません。Claude Agent SDK は Claude Code という完成した harness をライブラリとして使うもので、ADK は harness を組み立てるための framework です。ただ、上限がない状態で外側にさらにループを重ねると、暴走の上に暴走を重ねることになります。なので今回はガードレールを先に設計しました。

設計

作ったのはインシデント調査のエージェントです。構造は 1 ノードだけのワークフローです。

START ─> investigate (dynamic, Go) ──RunNode × N──> investigator (llmagent + tools)
  • investigate は workflow.NewDynamicNode で書いた Go のオーケストレータです。ラウンドを回し、ラウンド間で何を引き継ぐか、いつ止めるかを決めます
  • investigator は read-only のツール(query_metrics / query_logs / list_deploys)を持つ llmagent です。1 ラウンドの中で agent loop を回し、新しく分かったことを JSON で返します

ポイントは、llmagent をワークフローのノードにすると既定で single-turn になることです。investigator には前のラウンドの会話履歴が見えず、オーケストレータが渡したプロンプトだけを見ます。つまりラウンドをまたいで何を覚えておくかを Go のコードが決めます。今回引き継ぐのは確定した findings のリストだけです。

func roundPrompt(question string, round int, known []string) string {
	var sb strings.Builder
	fmt.Fprintf(&sb, "Incident: %s\nRound: %d\n", question, round)
	if len(known) == 0 {
		sb.WriteString("Known findings: none yet.\n")
		return sb.String()
	}
	sb.WriteString("Known findings:\n")
	for _, f := range known {
		fmt.Fprintf(&sb, "- %s\n", f)
	}
	return sb.String()
}

これは Claude Code の dynamic workflows が「途中結果はスクリプトの変数に置き、Claude のコンテキストには入れない」としているのと同じ考え方です。ラウンドを重ねてもコンテキストが膨らまず、compaction で指示が要約されて消える心配もありません。

ガードレールを 4 層で組む

ガードレールは置き場所ごとに 4 層に分けました。

層 ガードレール 実装 既定値
ループ(Go) 最大ラウンド数 investigate の for 条件 6
ループ(Go) 進捗なし停止 新しい finding が N ラウンド連続で出なければ止める 2
ループ(Go) LLM の done 提案 finding が 1 件以上あるときだけ受け入れる -
ループ(Go) 不正な出力 JSON として読めない応答は finding を増やせない -
両方 トークン予算 AfterModelCallback で集計し、ラウンド開始前と BeforeModelCallback で判定 30,000
agent loop ラウンドあたりのモデル呼び出し上限 BeforeModelCallback 8
ツール allowlist と引数の制限 BeforeToolCallback read-only 3 ツール、時間窓 60 分
スケジューラ ラウンドのタイムアウト NodeConfig.Timeout 2 分

上限値はひとつの struct にまとめました。

type Limits struct {
	MaxRounds             int
	NoProgressRounds      int
	MaxModelCallsPerRound int
	TokenBudget           int
	MaxWindowMinutes      int
}

ループ層: 止める判断は Go が持つ

オーケストレータの本体です。

func investigate(ctx agent.Context, question string, investigator workflow.Node, g *guard) (report, error) {
	id := ctx.InvocationID()
	defer g.finish(id)

	var (
		findings []string
		seen     = map[string]bool{}
		idle     int
		r        = report{Stop: stopMaxRounds}
	)
	for r.Rounds < g.limits.MaxRounds {
		if g.snapshot(id).tokens >= g.limits.TokenBudget {
			r.Stop = stopBudget
			break
		}
		r.Rounds++
		g.beginRound(id)

		out, err := workflow.RunNode[string](ctx, investigator, roundPrompt(question, r.Rounds, findings))
		if err != nil {
			return report{}, fmt.Errorf("round %d: %w", r.Rounds, err)
		}

		res := parseRound(out)
		added := 0
		for _, f := range res.Findings {
			key := strings.ToLower(strings.TrimSpace(f))
			if key == "" || seen[key] {
				continue
			}
			seen[key] = true
			findings = append(findings, strings.TrimSpace(f))
			added++
		}
		if added == 0 {
			idle++
		} else {
			idle = 0
		}

		if res.Done && len(findings) > 0 {
			r.Stop = stopModelDone
			break
		}
		if idle >= g.limits.NoProgressRounds {
			r.Stop = stopNoProgress
			break
		}
	}
	// ...
}

Claude のブログ記事 A harness for every task では loop until done を「作業量が分からないタスクで、固定回数ではなく停止条件(たとえば『新しい発見がない』)が成り立つまでエージェントを起動し続ける」パターンとして紹介しています。ここではその停止条件を Go で書いています。

LLM が "done": true を返すことは許していますが、それだけでは止めません。finding が 1 件もないのに done を返すのは「調べるのを諦めた」可能性があるためです。逆に、LLM が done を返さなくても、新しい発見が 2 ラウンド続けてなければ Go が止めます。前回の記事と同じく「LLM は提案し、判断はコードがする」形です。

LLM の出力のパースも、ガードレールとして扱っています。

func parseRound(out string) roundResult {
	start, end := strings.Index(out, "{"), strings.LastIndex(out, "}")
	if start < 0 || end <= start {
		return roundResult{}
	}
	var res roundResult
	if err := json.Unmarshal([]byte(out[start:end+1]), &res); err != nil {
		return roundResult{}
	}
	return res
}

読めない応答は「新しい発見ゼロ」として扱います。壊れた出力が findings に紛れ込むことはなく、壊れた出力が続けば進捗なし停止でループが終わります。

agent loop 層: エラーではなく「空の答え」で止める

内側の llmagent のループは BeforeModelCallback で止めます。このコールバックが *model.LLMResponse を返すと、ADK は実際のモデル呼び出しをスキップしてその応答を使います。

const cutoffAnswer = `{"findings": [], "done": false}`

func (g *guard) beforeModel(ctx agent.Context, _ *model.LLMRequest) (*model.LLMResponse, error) {
	g.mu.Lock()
	defer g.mu.Unlock()
	s := g.state(ctx.InvocationID())

	var reason string
	switch {
	case s.tokens >= g.limits.TokenBudget:
		reason = fmt.Sprintf("token budget %d exhausted", g.limits.TokenBudget)
	case s.roundCalls >= g.limits.MaxModelCallsPerRound:
		reason = fmt.Sprintf("%d model calls in one round", g.limits.MaxModelCallsPerRound)
	default:
		s.roundCalls++
		return nil, nil
	}
	s.cutoffs = append(s.cutoffs, reason)
	return &model.LLMResponse{
		Content:      genai.NewContentFromText(cutoffAnswer, genai.RoleModel),
		TurnComplete: true,
	}, nil
}

ここで迷ったのは、上限に達したときに error を返すか、応答を返すかです。error を返すとノードが失敗し、RetryConfig を付けていればリトライされて、むしろ呼び出しが増えます。そこでツール呼び出しを含まない応答を返すことにしました。先ほどの Flow.Run は「ツール呼び出しのない最終応答」でループを抜けるので、これで agent loop は正常に終わります。返す内容は「findings が空のラウンド結果」なので、オーケストレータ側では進捗なしのラウンドとして数えられます。

トークンは AfterModelCallback で数えます。

func (g *guard) afterModel(ctx agent.Context, resp *model.LLMResponse, _ error) (*model.LLMResponse, error) {
	if resp == nil || resp.Partial || resp.UsageMetadata == nil {
		return nil, nil
	}
	g.mu.Lock()
	defer g.mu.Unlock()
	g.state(ctx.InvocationID()).tokens += int(resp.UsageMetadata.TotalTokenCount)
	return nil, nil
}

ストリーミング時の部分チャンク(Partial)は数えないようにしています。数えると同じ応答を二重に数えるためです。

ツール層: allowlist と引数の制限

BeforeToolCallback は結果を返すとツールの実行をスキップし、返した結果をツールの結果としてモデルに渡します。また、args を書き換えて nil, nil を返すと、書き換えた引数でツールが実行されます。この 2 つの性質を使います。

func (g *guard) beforeTool(ctx agent.Context, t tool.Tool, args map[string]any) (map[string]any, error) {
	if !readOnlyTools[t.Name()] {
		g.mu.Lock()
		s := g.state(ctx.InvocationID())
		s.denied = append(s.denied, t.Name())
		g.mu.Unlock()
		return map[string]any{"error": fmt.Sprintf("tool %q is not allowed: this agent is read-only", t.Name())}, nil
	}
	switch w := args["window_minutes"].(type) {
	case float64:
		if int(w) > g.limits.MaxWindowMinutes {
			args["window_minutes"] = float64(g.limits.MaxWindowMinutes)
		}
	case int:
		if w > g.limits.MaxWindowMinutes {
			args["window_minutes"] = g.limits.MaxWindowMinutes
		}
	}
	return nil, nil
}

拒否は Go の error ではなくツールの結果として返しています。こうするとモデルは「このツールは使えない」と知った上で、許可されたツールで調査を続けられます。

サンプルではわざと restart_pod(全 Pod を再起動する書き込み系ツール)をツール一覧に入れています。「危険なツールは最初から渡さなければいい」と思うかもしれません。しかし MCP toolset を使うと、サーバが公開しているツールは書き込み系も含めてそのまま入ってきます。渡していないつもりで渡っている状況を前提に、allowlist で止める形にしました。Claude Agent SDK の allowed_tools / disallowed_tools に相当するものです。

スケジューラ層: タイムアウト

最後はノード単位のタイムアウトです。

investigatorNode, err := workflow.NewAgentNode(investigator, workflow.NodeConfig{Timeout: roundTimeout})

これは無限ループではなく、処理が止まったまま返ってこない(ハングする)ケースに備えるものです。モデルやツールが応答しなくなると呼び出し回数もトークンも増えないため、上の 3 層では止められません。

動作確認

API キーがなくても動くように、スクリプト化したスタブモデルを用意しました。INVESTIGATION_SCENARIO で振る舞いを切り替えると、各ガードレールが発動する様子を見られます。

git clone https://github.com/jedipunkz/adk-playground
cd adk-playground/investigation-loop
go run . console

converge: 新しい発見がなくなって止まる

3 ラウンドで 3 件見つけ、その後は既知のことしか返さないシナリオです。途中のラウンドで restart_pod を呼ぼうとし、4 時間分のログも要求します。

Investigation stopped after 5 round(s): no new findings. Tokens used: 13500.

Findings:
- checkout-api error rate rose to 18% at 10:07
- payments-db is refusing connections: too many clients
- v1.42 deployed at 10:00 lowered DB_POOL_SIZE from 50 to 20

Tool calls refused:
- restart_pod

ラウンド 4・5 で新しい発見がなかったので、5 ラウンド目で止まりました。restart_pod は拒否され、一度も実行されていません。ログの時間窓は 240 分から 60 分に丸められています(どちらもテストで確認しています)。

runaway-tools: ツールを呼び続けて答えない

Investigation stopped after 2 round(s): no new findings. Tokens used: 24000.

Findings:
- none

Rounds cut short by guardrails:
- 8 model calls in one round
- 8 model calls in one round

各ラウンドがモデル呼び出し 8 回で打ち切られ、空の結果になります。空のラウンドが 2 回続いたので、オーケストレータが止めました。agent loop 層とループ層が連携して止めている例です。

endless-findings: 毎ラウンド「新しい」ことを言い続ける

Investigation stopped after 5 round(s): token budget exhausted. Tokens used: 30000.

Findings:
- possible anomaly #1 worth a closer look
- possible anomaly #2 worth a closer look
- possible anomaly #3 worth a closer look
- possible anomaly #4 worth a closer look
- possible anomaly #5 worth a closer look

一番厄介なのはこれです。毎回違う文言で「何か見つけた」と返されると、進捗なし停止は効きません。最後に止めるのはトークン予算です。文言だけ変えた同じ指摘を同一とみなす判定は、Go の文字列比較ではできません。ここは予算という、内容を見ないガードレールが必要になるところだと思います。

テストは 3 シナリオを ADK の runner で端から端まで実行し、停止理由、restart_pod が実行されていないこと、引数が丸められていることを確認しています。

func TestConvergeStopsWhenNothingNewTurnsUp(t *testing.T) {
	restartCalls.Store(0)
	got, windows := run(t, scenarioConverge)
	// ...
	if n := restartCalls.Load(); n != 0 {
		t.Errorf("restart_pod ran %d time(s); the allowlist must refuse it", n)
	}
	if len(windows) != 1 || windows[0] != float64(defaultLimits.MaxWindowMinutes) {
		t.Errorf("query_logs saw window_minutes %v, want it clamped to [%d]", windows, defaultLimits.MaxWindowMinutes)
	}
}

前回の記事で「ルーティングのテストは、分岐が LLM の判断だったら書けなかったテスト」と書きました。今回も同じで、停止条件がコードにあるから停止のテストが書けます。

ハマりどころと学び

ガードレールの状態をどこに持つか

コールバックは llmagent に 1 度だけ登録するので、状態は実行ごとに分ける必要があります。今回は ctx.InvocationID() をキーにしました。dynamic node の中から RunNode で呼んだ子の llmagent は、親と同じ InvocationID を持つためです。これにより、オーケストレータが beginRound でリセットしたカウンタを、子のコールバックがそのまま参照できます。

ただし状態はプロセスのメモリにあります。ADK のワークフローはセッションの履歴から再開できますが、別のプロセスで再開するとトークン集計は 0 から始まります。厳密にやるなら、session state に置く必要があります。

オーケストレータは決定的に書く

ADK の dynamic node は再開時に本体を頭から再実行し、完了済みの子ノードはチェックポイントから結果を返します(RerunOnResume の既定値が true)。子の実行 ID は親 ID とカウンタから決まるので、同じ入力から同じ順序で RunNode を呼ぶ必要があります。オーケストレータの中で時刻や乱数を使ってはいけません。

Claude Code の dynamic workflows も、スクリプトの中で Date.now() や Math.random() を呼ぶと例外になります。理由は同じで、再実行したときに同じ agent() 呼び出しを再現するためです。言語もランタイムも違いますが、同じ制約にたどり着いているのが面白いところです。

compaction は root が LLM agent でないと素直に使えない

runner.Config.Compaction は、Summarizer を指定しない場合は root agent のモデルで要約します。そのため root が LLM agent である必要があります。今回のように root が workflow agent だとこの前提が満たされないので、Summarizer を自分で渡す必要があります。今回はラウンドごとに single-turn で文脈がリセットされるので、compaction 自体が不要でした。

ツールの並列実行は読み書きを区別しない

前述のとおり、ADK は 1 応答内の複数のツール呼び出しを並列に実行します。Claude Agent SDK のように「書き込み系は順番に実行する」区別はありません。今回は書き込み系を allowlist で全部止めたので問題になりませんでしたが、書き込み系を許可するなら直列化を自分で考える必要があります。

Claude Code の dynamic workflows と比べて

一番大きな違いは、やはりオーケストレーションを書くのが誰かです。

Claude Code では、Claude がタスクに合わせてスクリプトを書きます。事前に想定していなかった形のタスクにも、その場で専用の harness を作れるのが強みです。その代わり、実行前に人間がスクリプトを承認する(あるいは承認を省略する)運用になります。

ADK では人間が書きます。柔軟性では劣りますが、停止条件・上限・許可されたツールがすべてコードとしてレビューでき、テストで固定できます。本番環境に触れる SRE のエージェントでは、こちらの性質がほしい場面が多いと感じています。

前回の宿題だった「動的ノードの中身がブラックボックスになる」問題について、今回の結論はこうです。ループの中身(LLM が何を調べるか)は動的なままでよく、ループの外枠(何を引き継ぎ、いつ止め、何を許すか)を静的なコードにすればよい。中身が読めなくても、外枠が読めてテストできれば、自律度を上げても怖くありません。

まとめ

  • ADK Go の llmagent も Claude Agent SDK と同じ agent loop(モデル → ツール → モデル)を回すが、ターン上限・予算上限・ツール許可は持たない
  • NewDynamicNode で外側のループを Go で書くと、Claude Code の dynamic workflows の loop until done と同じ形になる。違いはスクリプトを書くのが人間であること
  • ガードレールは置き場所ごとに 4 層に分けた。ループ層(ラウンド上限・進捗なし停止・done 提案の扱い)、agent loop 層(BeforeModelCallback で呼び出し上限と予算)、ツール層(BeforeToolCallback で allowlist と引数制限)、スケジューラ層(Timeout)
  • agent loop を止めるときは error ではなく「ツール呼び出しのない空の答え」を返すと、リトライに化けずに正常に終わる
  • 毎回違う文言で発見を言い続けるモデルは、内容で止めることができない。予算のような内容を見ないガードレールが最後の砦になる

次は、実際の Gemini で動かしたときにモデルがどれくらいの頻度で JSON を壊すのか、進捗なし停止がどのくらい効くのかを見てみたいと思っています。

参考