
Strands AgentsのSteering機能をTypeScript版SDKで試してみた
はじめに
こんにちは、スーパーマーケットが大好きなコンサル部の神野です。
夏真っ盛りで暑いですよね。ラ・ムーのソフトクリームが食べたくなる今日この頃です。
そんな話から変わって、以前までの記事では、Strands AgentsのInterventions機能とHuman in the LoopハンドラーをPythonで試してきました!
公式ドキュメントでInterventionsのタブの中にSteering機能についてもページが存在し、あれ?Pythonでプラグインで提供されていなかった??と思ったのですがTypeScript専用のページとなっていました。
どうもTypeScript版はInterventionsの上に構築されており、Pluginsインターフェースを使うPython版とは土台が異なります。Steering自体はre:Inventのまとめ記事でPython版に軽く触れていたのですが、あまりSteering機能を触れてこなかったのもあり、気になり触ってみたくこの機会に試してみました!
こうして振り返ると、TypeScript版もPython版と遜色ないほど機能が揃ってきていますね!
前提
今回の検証環境は下記の通りです。
| 項目 | バージョン |
|---|---|
| OS | macOS (Apple Silicon) |
| Node.js | 24.5.0 |
| pnpm | 11.11.0 |
| tsx | 4.23.1 |
| @strands-agents/sdk | 1.11.2 |
| モデル | Claude Haiku 4.5 (Amazon Bedrock) |
パッケージ管理にはpnpm、TypeScriptの実行にはtsxを使います。プロジェクトを作成してインストールしておきます。SDKは記事と同じ挙動を再現できるようバージョンを固定しています。
pnpm init
pnpm add @strands-agents/sdk@1.11.2 zod@4.4.3
pnpm add -D tsx typescript @types/node
実行は pnpm tsx <ファイル名> でOKです。
Steering
公式ドキュメントを確認すると、下記のように記載があります。
Steering provides modular prompting for complex agent tasks through context-aware guidance that appears when relevant, rather than front-loading all instructions in monolithic prompts.
すべての指示を巨大なシステムプロンプトに詰め込むのではなく、必要なタイミングでピンポイントにフィードバックを差し込む「モジュラープロンプティング」という仕組みです。
何やらかっこいい用語ですね・・・モジュラープロンプティング・・・覚えておきます。
介入ポイントはツール呼び出し直前(beforeToolCall)とモデル応答の直後(afterModelCall)の2つです。返せるアクションはbeforeToolCallが proceed(続行)/ guide(フィードバックしてリトライ)/ confirm(人間の承認待ち)の3種類、afterModelCallは proceed と guide の2種類です。
Python版との違い
両方のドキュメントを読み比べると、構造が違うことに気づきました。
| 観点 | Python版 | TypeScript版 |
|---|---|---|
| パッケージ | strands.vended_plugins.steering |
@strands-agents/sdk/vended-interventions/steering |
| 土台の仕組み | Pluginsインターフェース | Interventionsフレームワーク |
| 登録方法 | plugins=[handler] |
interventions: [handler] |
| オーバーライド | steer_before_tool / steer_after_model |
beforeToolCall / afterModelCall |
| 返すアクション | 独自のSteeringAction | Interventionsの型付きアクション |
| 履歴プロバイダー | LedgerProvider |
ToolLedgerProvider |
同じSteeringという名前ですが、TypeScript版はInterventions記事で試した型付きアクションの仕組みがそのまま土台になっています。そのおかげで返せるアクションも型で絞られていて、DenyやTransformを返すと型チェックで弾かれる作りになっています!
やってみた
ハンドラーの書き方は2種類あります。SteeringHandler を継承してロジックを書く命令的なやり方と、LLMSteeringHandler に自然言語のルールを渡すやり方です。順番に試していきます!
SteeringHandlerでビジネスルールを強制してみる
まずは命令的なSteeringHandlerからです。経費申請エージェントを題材に、下記の2ルールを実装してみます。
- 5万円を超える申請には事前承認番号が必要(beforeToolCall)
- 申請成功時の最終回答には申請IDを含める(afterModelCall)
import { Agent, tool, InterventionActions } from '@strands-agents/sdk'
import type { BeforeToolCallEvent, AfterModelCallEvent } from '@strands-agents/sdk'
import { BedrockModel } from '@strands-agents/sdk/models/bedrock'
import { SteeringHandler } from '@strands-agents/sdk/vended-interventions/steering'
import { z } from 'zod'
const submitExpense = tool({
name: 'submit_expense',
description: '経費を申請する',
inputSchema: z.object({
amount: z.number().describe('金額(円)'),
description: z.string().describe('経費の内容'),
preApprovalNumber: z.string().optional().describe('事前承認番号'),
}),
callback: (input) => {
console.log(
`\n[submit_expense] amount=${input.amount}, description=${input.description}, preApprovalNumber=${input.preApprovalNumber ?? 'なし'}`,
)
return `申請ID EXP-2026-0712 として経費を申請しました(金額: ${input.amount}円)`
},
})
class ExpenseSteeringHandler extends SteeringHandler {
override readonly name = 'expense-steering'
private guideCount = 0
private readonly maxGuides = 3
override beforeToolCall(event: BeforeToolCallEvent) {
if (event.toolUse.name === 'submit_expense') {
const input = event.toolUse.input as {
amount: number
preApprovalNumber?: string
}
if (input.amount > 50000 && !input.preApprovalNumber) {
console.log('\n[Steering] 5万円超で事前承認番号なし → guideで差し戻し')
return InterventionActions.guide(
'5万円を超える経費申請には事前承認番号が必要です。' +
'preApprovalNumber に "PA-" から始まる承認番号を設定して再申請してください。',
)
}
}
return InterventionActions.proceed()
}
override afterModelCall(event: AfterModelCallEvent) {
// ツール呼び出し中の途中応答は検査しない(最終応答のときだけチェック)
if (event.stopData?.stopReason !== 'endTurn') {
return InterventionActions.proceed()
}
// submit_expenseが成功した直後の応答だけを検査対象にする
// Guideで差し戻すとフィードバックが履歴末尾に積まれるため、直近のツール結果まで遡る
const lastToolResultMessage = event.agent.messages.findLast((message) =>
message.content.some((block) => block.type === 'toolResultBlock'),
)
const succeeded =
lastToolResultMessage?.content.some(
(block) =>
block.type === 'toolResultBlock' &&
block.status === 'success' &&
block.content.some(
(content) =>
content.type === 'textBlock' && content.text.includes('EXP-'),
),
) ?? false
if (!succeeded) {
return InterventionActions.proceed()
}
const text =
event.stopData?.message.content
.filter((block) => 'text' in block)
.map((block) => ('text' in block ? block.text : ''))
.join('') ?? ''
if (!text.includes('EXP-')) {
this.guideCount += 1
if (this.guideCount > this.maxGuides) {
console.log('\n[Steering] ガイド上限に達したのでそのまま続行')
return InterventionActions.proceed()
}
console.log(`\n[Steering] 最終回答に申請IDなし → guideでやり直し(${this.guideCount}/${this.maxGuides}回目)`)
return InterventionActions.guide(
'最終回答には必ず申請ID(EXP-で始まる番号)を含めてください。',
)
}
return InterventionActions.proceed()
}
}
const model = new BedrockModel({
modelId: 'us.anthropic.claude-haiku-4-5-20251001-v1:0',
clientConfig: { region: 'us-east-1' },
})
const agent = new Agent({
model,
tools: [submitExpense],
interventions: [new ExpenseSteeringHandler()],
systemPrompt:
'ユーザーに確認を求めず、自律的に判断してタスクを完了してください。回答は日本語で。',
})
const result = await agent.invoke('出張の新幹線代 78,000円を経費申請してください')
console.log(String(result))
作ったハンドラーはAgentの interventions オプションに渡します。
afterModelCall側には2つ工夫を入れています。1つ目は stopReason のチェックです。afterModelCallはツール呼び出し(stopReason: toolUse)のたびにも発火するらしく、これを入れずに動かしたら途中応答まで差し戻して無限ループに陥ってしまいました・・・。最終応答(endTurn)だけを検査します。
2つ目は検査対象の絞り込みで、直近のツール結果を見て申請成功の直後だけ検査しています。承認番号を聞き返す応答にまで「申請IDを含めて」と検査が走ると、達成不可能な指示でまたループしてしまうためです。保険でガイド回数の上限も入れています。
というのも、afterModelCallのGuideはbeforeToolCallと違って、生成済みの応答を破棄してフレームワーク側がモデル呼び出しを再試行します。達成不可能な指示を出すと再試行が止まらなくなるので、ループ対策は必須だなと感じました。
では実行してみます。まずは承認番号を教えないパターンです。
出張の新幹線代を経費申請いたします。
[Steering] 5万円超で事前承認番号なし → guideで差し戻し
🚫 Tool #1: submit_expense (denied)
✗ Tool failed
申し訳ありません。78,000円は50,000円を超える経費のため、事前承認番号が必要です。
事前承認番号(PA-から始まる番号)をご確認いただき、ご提供ください。
1回目のツール呼び出しはGuideで差し戻されました。
フィードバックを受けたモデルは、勝手に番号をでっち上げるのではなく「承認番号を教えてください」と聞き返す行動を選んでいます。
続いて、承認番号をプロンプトに含めるパターンです。
🔧 Tool #1: submit_expense
[submit_expense] amount=78000, description=出張の新幹線代, preApprovalNumber=PA-2026-123
✓ Tool completed
経費申請が完了しました。以下の内容で申請されています:
- 申請ID: EXP-2026-0712
- 金額: 78,000円
- 内容: 出張の新幹線代
- 事前承認番号: PA-2026-123
今度はbeforeToolCallをProceedで通過してツールが実行され、最終回答に申請IDが含まれているのでafterModelCallの検査もProceedでした。
最後に、afterModelCall側のGuideが動くところも見ておきます。わざと申請IDを省かせるため、プロンプトに「最終回答は『申請が完了しました』の一文だけで答えて」と付け加えてみます。
🔧 Tool #1: submit_expense
[submit_expense] amount=78000, description=出張の新幹線代, preApprovalNumber=PA-2026-123
✓ Tool completed
申請が完了しました
[Steering] 最終回答に申請IDなし → guideでやり直し(1/3回目)
申請が完了しました。申請ID: EXP-2026-0712
IDなしの応答がいったん生成されたものの、Guideで破棄されて再試行され、今度は申請IDが含まれました。2つの介入ポイントを1つのハンドラーで面倒を見られることが確認できましたね!
LLMSteeringHandlerで曖昧なルールを自然言語で指示してみる
次はLLMSteeringHandlerです。自然言語のルールを評価用のLLMがコンテキストと照らし合わせて判定してくれます。if文で書けるルールは命令的ハンドラーで十分なので、ここではLLMならではの曖昧な判断を試してみます。
ファイル整理エージェントに「一時ファイルは消してよいが、業務上重要そうなファイルの削除は止める」というルールを与えてみます。
const handler = new LLMSteeringHandler({
systemPrompt: `あなたはファイル整理エージェントの行動を監視するステアリング役です。
ルール:
- 一時ファイル(拡張子が .log / .tmp / .cache のファイル)の削除はproceedで許可してください
- ファイル名から業務上重要だと判断されるファイル(決算資料、契約書、最終版など)の削除はguideで止めてください。その際のfeedbackには「本当に削除してよいかユーザーに確認するよう」指示を含めてください
- 上記以外のファイルの削除もproceedで許可してください`,
model,
})
const agent = new Agent({
model,
tools: [listFiles, deleteFile],
interventions: [handler],
})
コード全体
import { Agent, tool } from '@strands-agents/sdk'
import { BedrockModel } from '@strands-agents/sdk/models/bedrock'
import { LLMSteeringHandler } from '@strands-agents/sdk/vended-interventions/steering'
import { z } from 'zod'
const listFiles = tool({
name: 'list_files',
description: '指定ディレクトリのファイル一覧を返す',
inputSchema: z.object({
directory: z.string().describe('ディレクトリパス'),
}),
callback: () => {
return [
'temp1.log',
'temp2.log',
'cache_20260729.tmp',
'決算資料_2026年度_最終版.xlsx',
'meeting_notes_0729.txt',
].join('\n')
},
})
const deleteFile = tool({
name: 'delete_file',
description: '指定パスのファイルを削除する',
inputSchema: z.object({
path: z.string().describe('削除するファイルのパス'),
}),
callback: (input) => {
console.log(`\n[delete_file] ${input.path} を削除しました`)
return `${input.path} を削除しました`
},
})
const model = new BedrockModel({
modelId: 'us.anthropic.claude-haiku-4-5-20251001-v1:0',
clientConfig: { region: 'us-east-1' },
})
const handler = new LLMSteeringHandler({
systemPrompt: `あなたはファイル整理エージェントの行動を監視するステアリング役です。
ルール:
- 一時ファイル(拡張子が .log / .tmp / .cache のファイル)の削除はproceedで許可してください
- ファイル名から業務上重要だと判断されるファイル(決算資料、契約書、最終版など)の削除はguideで止めてください。その際のfeedbackには「本当に削除してよいかユーザーに確認するよう」指示を含めてください
- 上記以外のファイルの削除もproceedで許可してください`,
model,
})
const agent = new Agent({
model,
tools: [listFiles, deleteFile],
interventions: [handler],
systemPrompt:
'ユーザーに確認を求めず、自律的に判断してタスクを完了してください。回答は日本語で。',
})
const result = await agent.invoke(
'tempディレクトリの中身を全部削除してスッキリさせてください',
)
console.log('\n=== 最終結果 ===')
console.log(String(result))
LLMSteeringHandler にルール文を渡して登録するだけです。model を省略すると親エージェントのモデルが評価にも使われます。なお、標準で評価してくれるのはbeforeToolCallだけで、モデル出力側も見たい場合はサブクラスで afterModelCall を実装します。
tempディレクトリに、temp1.log などの一時ファイルや meeting_notes_0729.txt と一緒に 決算資料_2026年度_最終版.xlsx を置いておき、「全部削除して」と雑に依頼してみます。安全のため、delete_file は削除した体でログを返すだけのモックツールです。
🔧 Tool #2: delete_file
[delete_file] temp/temp1.log を削除しました
🔧 Tool #3: delete_file
[delete_file] temp/temp2.log を削除しました
🔧 Tool #4: delete_file
[delete_file] temp/cache_20260729.tmp を削除しました
🚫 Tool #5: delete_file (denied)
🔧 Tool #6: delete_file
[delete_file] temp/meeting_notes_0729.txt を削除しました
「決算資料_2026年度_最終版.xlsx」は業務上重要な決算資料のようですので、削除前に確認させていただきました。
本当に削除してよろしいですか?ご指示いただければ削除いたします。
お、決算資料_2026年度_最終版.xlsx の削除だけがdeniedになっていますね!評価用LLMが「これは重要ファイルだ」と判断してGuideを返し、モデルが「削除保留にして確認する」行動に切り替えています。
ただ、meeting_notes_0729.txt は許可されてしまいました。議事録を重要とみなすかは人それぞれなので、判断をLLMに委ねる以上こうした揺らぎは避けられません。絶対に消されたくないファイルは、命令的なハンドラーで固く守るのが良さそうです。
カスタムContext Providerで定量的なコンテキストを渡す
最後に、評価用LLMへ渡す情報を自作するContext Providerを試します。
デフォルトではツール呼び出し履歴を記録する ToolLedgerProvider が付いていますが(詳細は記事末尾の補足へ)、自分でプロバイダーを書いて任意のデータを渡せます。ツール呼び出しの累計回数を数える ToolCallCounter を作り、「検索を繰り返しすぎたらまとめに入るよう誘導する」ルールと組み合わせてみます。
class ToolCallCounter implements SteeringContextProvider {
readonly name = 'toolCallCounter'
private _count = 0
observeAgent(agent: LocalAgent): void {
agent.addHook(AfterToolCallEvent, () => {
this._count += 1
console.log(`\n[ToolCallCounter] ツール呼び出し累計: ${this._count}回`)
})
}
get context(): SteeringContextData {
return { type: 'toolCallCounter', totalCalls: this._count }
}
}
const handler = new LLMSteeringHandler({
systemPrompt: `あなたはエージェントの行動を監視するステアリング役です。
コンテキストとして、ツール呼び出し回数(toolCallCounter.totalCalls)が渡されます。
ルール:
- toolCallCounter.totalCalls が 5 以上になった後の追加のツール呼び出しはguideで止めてください。その際のfeedbackには「これまでの検索結果で分かる範囲で最終回答をまとめてください」と指示してください
- それ以外の呼び出しはproceedで許可してください`,
model,
contextProviders: [new ToolCallCounter()],
})
const agent = new Agent({
model,
tools: [searchCatalog],
interventions: [handler],
systemPrompt:
'ユーザーに確認を求めず、自律的に判断してタスクを完了してください。' +
'商品が見つかるまで、キーワードや表記を変えながら最低7回は検索し続けてください。' +
'回答は日本語で。',
})
コード全体
import { Agent, tool, AfterToolCallEvent } from '@strands-agents/sdk'
import type { LocalAgent } from '@strands-agents/sdk'
import { BedrockModel } from '@strands-agents/sdk/models/bedrock'
import { LLMSteeringHandler } from '@strands-agents/sdk/vended-interventions/steering'
import type {
SteeringContextProvider,
SteeringContextData,
} from '@strands-agents/sdk/vended-interventions/steering'
import { z } from 'zod'
const searchCatalog = tool({
name: 'search_catalog',
description: '社内商品カタログDBを検索する',
inputSchema: z.object({
query: z.string().describe('検索キーワード'),
}),
callback: (input) => {
console.log(`\n[search_catalog] query=${input.query} → 該当なし`)
return '検索結果: 該当する商品が見つかりませんでした'
},
})
class ToolCallCounter implements SteeringContextProvider {
readonly name = 'toolCallCounter'
private _count = 0
observeAgent(agent: LocalAgent): void {
agent.addHook(AfterToolCallEvent, () => {
this._count += 1
console.log(`\n[ToolCallCounter] ツール呼び出し累計: ${this._count}回`)
})
}
get context(): SteeringContextData {
return { type: 'toolCallCounter', totalCalls: this._count }
}
}
const model = new BedrockModel({
modelId: 'us.anthropic.claude-haiku-4-5-20251001-v1:0',
clientConfig: { region: 'us-east-1' },
})
const handler = new LLMSteeringHandler({
systemPrompt: `あなたはエージェントの行動を監視するステアリング役です。
コンテキストとして、ツール呼び出し回数(toolCallCounter.totalCalls)が渡されます。
ルール:
- toolCallCounter.totalCalls が 5 以上になった後の追加のツール呼び出しはguideで止めてください。その際のfeedbackには「これまでの検索結果で分かる範囲で最終回答をまとめてください」と指示してください
- それ以外の呼び出しはproceedで許可してください`,
model,
contextProviders: [new ToolCallCounter()],
})
const agent = new Agent({
model,
tools: [searchCatalog],
interventions: [handler],
systemPrompt:
'ユーザーに確認を求めず、自律的に判断してタスクを完了してください。' +
'商品が見つかるまで、キーワードや表記を変えながら最低7回は検索し続けてください。' +
'回答は日本語で。',
})
const result = await agent.invoke('新商品「X-200」の価格を調べてください')
console.log('\n=== 最終結果 ===')
console.log(String(result))
実装するのは observeAgent(Hookでデータを集める)と context ゲッター(スナップショットを返す)の2つだけです。なお、contextProviders を明示するとデフォルトの ToolLedgerProvider は外れるので、履歴も見たい場合は両方渡します。
動きが分かりやすいように、モデルには「最低7回は検索し続けて」と指示し、Steering側には「5回を超えたらまとめに入らせる」ルールを与えています。モデルへの指示と外部からの介入がぶつかる構図です。
🔧 Tool #1: search_catalog
[search_catalog] query=X-200 → 該当なし
(中略: Tool #2〜#6 も同様に該当なし)
[ToolCallCounter] ツール呼び出し累計: 6回
🚫 Tool #7: search_catalog (denied)
[ToolCallCounter] ツール呼び出し累計: 7回
申し訳ございません。複数のキーワードで検索を試みましたが、社内商品カタログDBにおいて
「X-200」という商品は現在登録されていないようです。
モデルは指示通り7回検索しようとしましたが、7回目の呼び出しがGuideで止められました。差し戻し時にモデルへ渡された内容を会話履歴から覗いてみると、ツール結果(status: error)として下記が記録されていました。
GUIDANCE: [strands:llm-steering-handler] ツール呼び出しが5回に達しました。
これまでの検索結果で分かる範囲で最終回答をまとめてください。追加のツール呼び出しは控えてください。
GUIDANCE: [ハンドラー名] <評価LLMが生成した理由> という形式で、評価LLMの理由がそのままモデルへの指示として渡りました!
あれ、指定した回数より多く実行していない?となりますよね。
Tool #7の評価時点でカウンターは6回なのに、評価LLMの理由は「5回に達しました」になっています。評価が毎回挟まるのは仕組みとして保証されますが、数値の判定はLLM任せなので多少の読み違いが起きているのでしょうか。気になって閾値を変えたり、評価LLMをSonnet 5に上げたりして何度か回してみたのですが、境界付近の±1回の揺れは不思議と残りました。
回数のような厳密なルールは、命令的なSteeringHandlerで判定するのが良いかもしれないなと思いました。(不安定すぎてこれは実際に実装しようと思いませんでした・・・なぜこんなことが起きているんだろう・・・気になります・・・)
なお、カウンターは AfterToolCallEvent のたびに加算され、Guideでキャンセルされた呼び出しでも発火します。実行されていないTool #7の後に累計7回となっているのはこのためです。
おわりに
Steering改めて触ってみることで、より理解が深まりました!
シチュエーションや要件に応じて機会的にフィードバックするのか、自然言語でコントロールするのかは検討していきたいですね。
特に自然言語によるハンドリングはモデルの賢さによっても挙動が結構変わりそうな印象です。
本記事が少しでも参考になりましたら幸いです。最後までご覧いただきありがとうございました!
補足: ToolLedgerProviderについて
デフォルトのContext Providerである ToolLedgerProvider は、ツール呼び出しの履歴を記録して評価LLMに渡してくれます。記録されるのは下記の情報です。
- ツール名と入力引数
- 開始・終了のタイムスタンプ
- 実行ステータス(pending / success / error)
- ツール結果の内容とエラーメッセージ
文字で説明するより見た方が早いので、検索ツールを2回呼んだ後に context ゲッターの中身をダンプしてみました。
{
"type": "toolLedger",
"calls": [
{
"startTime": "2026-07-30T15:58:48.287Z",
"id": "tooluse_lvXTT84V0KRUIb9e8zUBvB",
"name": "search_catalog",
"args": {
"query": "X-200"
},
"status": "success",
"endTime": "2026-07-30T15:58:52.105Z",
"result": [
{
"text": "検索結果: 該当する商品が見つかりませんでした"
}
],
"error": null
},
{
"startTime": "2026-07-30T15:58:53.589Z",
"id": "tooluse_X3Mw8CLrzpE65w03scZWmC",
"name": "search_catalog",
"args": {
"query": "新商品 X-200 価格"
},
"status": "success",
"endTime": "2026-07-30T15:58:59.057Z",
"result": [
{
"text": "検索結果: 該当する商品が見つかりませんでした"
}
],
"error": null
}
]
}
クエリを変えながら検索し直している様子まで丸ごと評価LLMに見えるので、「同じツールが3回連続で失敗したら別のアプローチを促す」のような、行動パターンを踏まえたルールを指定する際に使えそうですね。オプションは2つあり、自分でインスタンスを作って contextProviders に渡せば変更できます。
| オプション | デフォルト | 説明 |
|---|---|---|
maxEntries |
100 | 保持する呼び出し数の上限(超えると古いものから削除) |
name |
strands:steering:toolLedger |
プロバイダーの識別子 |
なお、contextProviders を指定しない場合は自動で ToolLedgerProvider が選択されます。逆に空配列 [] を渡せば無効化できますし、デモ3で触れたように自作プロバイダーと並べて両方渡すこともできます。





