やりたいこと
AgentCore Memory に保存したセッションに、AI が自動生成したタイトルを付けたい。
セッション一覧を表示したときに、UUID ではなく内容で識別できるようにしたい、という話です。
AI エージェントは AI SDK の ToolLoopAgent を使ってます。
なぜ ToolLoopAgent? 🤔 AgentCore SDK のサンプルにあって使ってみたくなったから、というだけです。
といっても AgentCore SDK の BedrockAgentCoreApp の中から呼び出してるだけなので、他のエージェントフレームワークでも今回の実装は適用できます。
課題
AgentCore Memory の Session オブジェクトには タイトル属性が無い。
ListSessions で返ってくる SessionSummary はこれだけです。
type SessionSummary = {
sessionId?: string
actorId?: string
createdAt?: Date
}
UpdateSession や TagSession 相当の API もありません。
セッション自身にメタ情報を持たせる手段が用意されていないのが出発点です。
解決方針
会話の 1 ターン目(初回 AIエージェントの 応答)の event に、{ title: "..." } を metadata として乗せる、というのが今回の方針です。
AgentCore Memory にイベントを作成するには、AWS SDK の CreateEventCommand を使います。
イベントとは、セッション内で発生する個々のインタラクション(ユーザー発言や AI 応答)のことで、
CreateEventCommand には metadata フィールド (Map<string, AttributeValue>) があり、任意のキーと値を保存できます。
ここに title を入れれば、Session そのものに属性を足せない問題を回避できます。
ファイル構成
実装が散らばるので、先にどこに何があるかを並べておきます。Amplify Gen 2 + Vite + React のプロジェクトです。
amplify/
agent/
app.ts ← Runtime 本体
generateTitle 関数 / SSE event:title yield
src/
lib/
agentcoreMemory.ts ← Memory クライアント層
saveMessage / encode|decodeMetadataValue
listSessions / fetchSessionTitleMetadata
App.tsx ← SSE 受信ループ
event:title を onTitle に流す
初回ターン完了時に saveMessage を呼ぶ
components/
HistorySidebar.tsx ← タイトルを表示する側
前提: コンポーネントと保存形式
実装に入る前に、登場人物と保存形式を整理しておきます。
ここを共有しておくと、後段のコードで mode === 'initial' や「初回 assistant event」がスッと読めるはずです。
登場人物と役割
登場するコンポーネントは 3 つです。
-
Runtime (
amplify/agent/app.ts): AI 生成側。Claude Sonnet で本文応答、Claude Haiku で短いタイトルを生成 -
フロント (
src/App.tsxほか): UI とブラウザから直接 AgentCore Memory に書き込み / 読み出し - AgentCore Memory: 会話イベントの永続化先。Session 自体にはタイトル属性がない、というのが今回の出発点
Runtime からは CreateEvent を呼びません。タイトル文字列だけを SSE で流して、保存はフロント側で行います。
会話の保存形式
1 ターンの会話は user event と assistant event のペアとして Memory に保存します。
- ユーザーの発言 → user event
- AI の応答 → assistant event
セッションのタイトルは、この 初回ターンの assistant event の metadata に格納します。
(後の §6 で一覧から取り出すときに「最古から 2 番目の event を見る」という話が出ますが、user event が先にあるからです)
Runtime の 2 モード
ハンドラは「初回ターン」と「2 ターン目以降のフォローアップ」を、同じ Runtime エンドポイントで処理します。
両者を区別するために、リクエスト body に自前で mode フィールドを足して、zod の discriminated union で型を分けています。
-
mode: 'initial'初回ターン。タイトル生成はここでだけ走る -
mode: 'followup'2 ターン目以降。タイトルは既に付いているので生成しない
// amplify/agent/app.ts
const initialRequestSchema = z.object({
mode: z.literal('initial'),
// ... 初回ターン特有の入力
})
const followupRequestSchema = z.object({
mode: z.literal('followup'),
message: z.string(),
history: z.array(conversationTurnSchema).default([]),
})
const requestSchema = z.discriminatedUnion('mode', [
initialRequestSchema,
followupRequestSchema,
])
mode は BedrockAgentCoreApp が用意したフィールドではなく、こちらで決めたアプリ層の規約です。
エンドポイントを 2 つに分ける代わりに、1 つのハンドラの中で分岐させるための仕掛けと考えてください。
1. Runtime でタイトルを生成する
本文応答は Claude Sonnet 4.6 で生成していますが、20 文字の要約にこのクラスを使うのはオーバーです。
タイトル生成だけ Claude Haiku 4.5 に分けます。
// amplify/agent/app.ts
import { generateText } from 'ai'
import { createAmazonBedrock } from '@ai-sdk/amazon-bedrock'
const bedrock = createAmazonBedrock({ region: 'ap-northeast-1' })
const TITLE_MODEL_ID = 'jp.anthropic.claude-haiku-4-5-20251001-v1:0'
async function generateTitle(reviewText: string): Promise<string> {
try {
const { text } = await generateText({
model: bedrock(TITLE_MODEL_ID),
prompt:
`次のレビュー結果の冒頭部分から、20文字以内の日本語タイトルのみを返してください。` +
`装飾(括弧、引用符、「〜について」などの定型)は不要です。\n\n---\n` +
reviewText.slice(0, 800),
})
const cleaned = text.trim().replace(/^[「『"'\s]+|[」』"'\s]+$/g, '').slice(0, 40)
return cleaned || fallbackTitle()
} catch {
return fallbackTitle()
}
}
function fallbackTitle(): string {
const now = new Date()
const pad = (n: number) => String(n).padStart(2, '0')
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())} のレビュー`
}
関数の中身はこの 3 点だけです。
- 本文全部を渡すとトークンの無駄なので、冒頭 800 文字だけを渡す
- Claude が
「」で囲んで返すことがあるので、両端の引用符・空白を剥がす - 失敗・タイムアウト時は
2026-05-19 14:22 のレビュー形式のフォールバックに落とす
呼び出すタイミングは BedrockAgentCoreApp の invocationHandler.process の中で、次の 4 条件を全部満たしたときだけです。
(コード中の reviewAgent は冒頭で触れた ToolLoopAgent のインスタンスです。本文はこの agent に流させ、要約だけ別途 generateTitle を呼ぶ、という構成)
// amplify/agent/app.ts
const reviewAgent = new ToolLoopAgent({
model: bedrock('jp.anthropic.claude-sonnet-4-6'),
tools: { /* ... */ },
})
// 以下、process ジェネレータ本体
const app = new BedrockAgentCoreApp({
invocationHandler: {
requestSchema,
process: async function* (request, context) {
// ...
if (request.mode === 'initial') { // 条件 1
// ...
const stream = await reviewAgent.stream({ /* ... */ })
let assistantText = ''
for await (const chunk of stream.fullStream) {
if (chunk.type === 'text-delta') {
assistantText += chunk.text
yield { event: 'message', data: { text: chunk.text } }
}
} // 条件 2: ループを抜けた後
if (assistantText) { // 条件 3
const title = await generateTitle(assistantText) // 条件 4: await で待つ
yield { event: 'title', data: { title } }
}
return
}
// followup: ...
},
},
})
- 条件 1: タイトルは初回に 1 度付ければ十分なので、followup 分岐では呼ばない。
-
条件 2: 途中で要約を走らせると、本文が途切れた状態のものを Haiku に渡してしまうため、
for awaitを抜けてから呼ぶ。 -
条件 3: 途中切断などで本文が無いケースを弾くため、
assistantTextが空でないことを確認する。 - 条件 4: fire-and-forget にしないので、本文の最後の delta が届いてから title イベントが届くまでに Haiku 1 回ぶんの間が空く。
2. SSE で event: title を流す
章 1 のコードに yield { event: 'title', data: { title } } の 1 行が増えただけですが、SSE 視点で何が起きているかを補足します。
BedrockAgentCoreApp の process は async generator で書きます。
そこで yield した値は、event と data の組み合わせがそのまま 1 つの SSE フレームとしてフロントに届きます。
つまり event フィールドを 'message' と 'title' で切り替えるだけで、フロント側で別イベントとして拾える形になります。
章 1 のスニペットの中で yield { event: 'message', ... } と yield { event: 'title', ... } の 2 種類が並んでいたのが、まさにこの仕組みです。
3. フロントで event: title を拾う
SSE 受信ループで event: 行を見て分岐します。
App.tsx の streamInvoke (Runtime を SSE で叩くラッパ関数) の中に置いています。
// src/App.tsx (streamInvoke の中)
const reader = res.body?.getReader()
const decoder = new TextDecoder()
let lineBuf = ''
let currentEvent = 'message'
while (true) {
const { done, value } = await reader.read()
if (done) break
lineBuf += decoder.decode(value, { stream: true })
const lines = lineBuf.split('\n')
lineBuf = lines.pop() ?? ''
for (const line of lines) {
if (line.startsWith('event: ')) {
currentEvent = line.slice(7).trim() || 'message'
continue
}
if (!line.startsWith('data: ')) continue
const data = line.slice(6)
const parsed = JSON.parse(data) as { text?: string; title?: string }
if (currentEvent === 'title' && parsed.title) {
onTitle(parsed.title)
} else if (parsed.text) {
onDelta(parsed.text)
}
}
}
これでフロントの onTitle コールバックにタイトル文字列が届きます。
あとは assistant event の metadata に乗せて CreateEvent で保存するだけ、と言いたいところですが、ここに 1 つ大きな落とし穴があります。
4. metadata の ASCII 制約を base64 で逃がす
ここが一番の落とし穴です。届いたタイトルを素朴に CreateEvent の metadata に乗せると InvalidInputException で event ごと拒否されます。本文も一緒に保存できません。
理由は metadata.stringValue の ASCII 制約です。
[a-zA-Z0-9\s._:/=+@-]*
日本語タイトルもファイル名も、このパターンに全くマッチしません。
対策として、ASCII セーフな値はそのまま、それ以外は UTF-8 → base64 化して b64: プレフィックスを付けます。
base64 のアルファベット A-Za-z0-9+/= は許可パターンの部分集合なのでそのまま通り、プレフィックスの : も許可文字に含まれます。
// src/lib/agentcoreMemory.ts
const METADATA_ASCII_SAFE = /^[a-zA-Z0-9\s._:/=+@-]*$/
function encodeMetadataValue(v: string): string {
if (METADATA_ASCII_SAFE.test(v)) return v
// btoa は Latin-1 限定なので、UTF-8 バイト列を一旦 Latin-1 文字列化してから base64 化する
const utf8 = new TextEncoder().encode(v)
let binary = ''
for (let i = 0; i < utf8.length; i++) {
binary += String.fromCharCode(utf8[i]!)
}
return `b64:${btoa(binary)}`
}
function decodeMetadataValue(v: string): string {
if (!v.startsWith('b64:')) return v
const binary = atob(v.slice(4))
const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)
return new TextDecoder().decode(bytes)
}
btoa がそのままだと日本語を受け付けない(Latin-1 限定)ので、TextEncoder で UTF-8 バイト列にしてから 1 バイトずつ詰め直す、というブラウザ環境のイディオムを踏みます。
5. CreateEvent に metadata.title を乗せて保存する
エンコードを saveMessage ラッパに組み込んでおきます。呼び出し側は普通の日本語を渡せば OK。
引数の型 (MemoryContext / ConversationRole) は自前定義です。
// src/lib/agentcoreMemory.ts
import { CreateEventCommand } from '@aws-sdk/client-bedrock-agentcore'
type MemoryContext = {
client: BedrockAgentCoreClient // AWS SDK のクライアント
actorId: string // ログイン中ユーザーの Cognito sub
memoryId: string // CDK で作った Memory リソース ID
}
type ConversationRole = 'user' | 'assistant'
export async function saveMessage(
ctx: MemoryContext,
sessionId: string,
role: ConversationRole,
text: string,
metadata?: Record<string, string>,
): Promise<void> {
if (!text) return
await ctx.client.send(
new CreateEventCommand({
memoryId: ctx.memoryId,
actorId: ctx.actorId,
sessionId,
eventTimestamp: new Date(),
payload: [buildPayload(role, text)],
...(metadata
? {
metadata: Object.fromEntries(
Object.entries(metadata).map(([k, v]) => [
k,
{ stringValue: encodeMetadataValue(v) },
]),
),
}
: {}),
}),
)
}
呼び出しは初回 assistant の保存時にタイトルを混ぜるだけです。
ストリームから受け取った assistantText と、SSE から受け取った assistantTitle を一緒に保存します。
await saveMessage(ctx, sessionId, 'assistant', assistantText, {
title: assistantTitle, // 例: '営業資料の構成改善'
})
Memory には b64:5Za25qWt6LOH5paZ44Gu... のような形で保存されます。
AWS コンソールから直接覗いても読めませんが、アプリ経由ならデコードして元に戻ります。
6. 一覧表示でタイトルを取り出す
ListSessions でセッション一覧を取り、各セッションについて ListEvents で最古の metadata.title を引き出します。
読み出し側は listSessions がエントリポイントで、その中から各セッションごとに下記の fetchSessionTitleMetadata を呼んでタイトルを補完する、という構造です。
// src/lib/agentcoreMemory.ts
async function fetchSessionTitleMetadata(
ctx: MemoryContext,
sessionId: string,
): Promise<{ title?: string; fileName?: string }> {
const events: Event[] = []
let nextToken: string | undefined
do {
const resp = await ctx.client.send(
new ListEventsCommand({
memoryId: ctx.memoryId,
actorId: ctx.actorId,
sessionId,
includePayloads: false, // payload は要らないので false で軽くする
maxResults: 100,
nextToken,
}),
)
events.push(...(resp.events ?? []))
nextToken = resp.nextToken
} while (nextToken)
// ListEvents は newest-first で返るので、昇順ソートしてから最古の metadata を探す
events.sort(
(a, b) =>
(a.eventTimestamp?.getTime() ?? 0) - (b.eventTimestamp?.getTime() ?? 0),
)
for (const ev of events) {
const title = stringMetadata(ev.metadata?.['title'])
if (title) {
return { title, fileName: stringMetadata(ev.metadata?.['fileName']) }
}
}
return {}
}
function stringMetadata(value: unknown): string | undefined {
if (!value || typeof value !== 'object') return undefined
const v = (value as { stringValue?: string }).stringValue
if (typeof v !== 'string' || v.length === 0) return undefined
return decodeMetadataValue(v) // b64: をデコードして元に戻す
}
地味に注意したいのは maxResults: 1 ではダメな点です。
タイトルは初回 assistant event に入りますが、user event がその前に保存されているので、最古から 2 番目の event を見ないと拾えません。
安全のため全件ページングして昇順ソートしています。
タイトルが見つからない場合は createdAt ベースの日時タイトルにフォールバックします。
まとめ
セッションタイトル付与を成立させるための要点はこれだけです。
- AgentCore Memory の Session 自体にはタイトル属性が無いので、初回 assistant event の metadata に格納する
- タイトル生成は軽量モデルに分ける(本文は Sonnet、要約は Haiku のように)
- Runtime から SSE
event: titleでフロントに通知し、保存はフロントのCreateEventで行う -
metadata.stringValueは ASCII 制約があるので、非 ASCII は base64 化してb64:プレフィックスで区別する - 一覧時の
ListEventsは newest-first なので、全件ページング + 昇順ソートで最古の metadata を拾う
最小構成は「Haiku でタイトル生成 → SSE で送る → metadata に base64 で保存 → ListEvents で取り出す」の 4 ステップです。
参考資料
