[アップデート] AgentCore Memoryの名前空間に独自変数を使えるようになったので試してみた

[アップデート] AgentCore Memoryの名前空間に独自変数を使えるようになったので試してみた

AgentCore Memoryが独自の名前空間変数に対応しました!!試してみました!!! 長期記憶を任意の分類で論理的に分けるのによさそうですね!
2026.09.17

はじめに

こんにちは、ラーメンが好きなコンサルティング部の神野です。

2026年8月28日のアップデートで、AgentCore Memoryの名前空間に独自の変数を組み込めるようになりました!ユーザー属性であるactorId などの組み込み変数に加えて、テナントや環境を表すキーを自分で宣言し、会話を保存するタイミングで値を渡せます。

https://aws.amazon.com/jp/about-aws/whats-new/2026/08/agentcorememory-flexible-namespaces/

アップデートの名前に「柔軟な名前空間変数」とありますが、今までと何が変わったんでしょうね?
変更点を整理しながら試していきます!

何が変わったのか

前提として、AgentCore Memoryは、会話をイベントとして短期記憶に保存し、設定した戦略に基づいて必要な情報を長期記憶へ抽出します。

アップデートで触れた名前空間は、この長期記憶の保存先を整理するパスで、/actor/staff001/ のような形式です。たとえばSemantic戦略は、会話に出てくる事実を抜き出す仕組みです。「閉店作業は22時からで、食券機の片付けが必要」という会話があれば、閉店作業の開始時刻と片付ける機材が事実として名前空間へ保存されます。

「閉店作業は22時から。食券機の片付けが必要」という会話を短期記憶に保存し、Semantic戦略が開始時刻と機材の事実を長期記憶に抽出する。後から名前空間を指定して検索すると、閉店作業についての事実が返る

https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/memory-types.html

今回のアップデートで名前空間がどう変わるのかを比較してみます!

Before

  • 名前空間に動的に埋め込める変数は actorIdsessionIdmemoryStrategyId の3つだけ
  • テナントを分けるには、/tenant/ramen/actor/{actorId}/ のような固定パスの戦略をテナントの数だけ量産する(テナントが増えるたびにMemoryの設定変更が必要)
  • もしくは actorIdramen-staff001 のような複合キーにする(認証基盤のユーザーIDをそのまま使えず、保存と検索のたびにIDを組み立てる)

After

  • tenantenv といった独自のキーを、Memoryごとに最大5個宣言できる
  • 戦略は /tenant/{tenant}/env/{env}/actor/{actorId}/ の1つだけでよい
  • イベント送信時に tenant="ramen" のような値を渡すと保存先が分かれ、actorId は認証基盤のIDのまま使える

ちょっとわかりづらいですね。簡単に絵にしてみましょうか。

Beforeはテナントごとに固定パスの戦略を量産するか、actorIdをramen-staff001のような複合キーにする必要があった。Afterは戦略1つのまま、イベント送信時にtenantとenvの値を渡すと保存先の名前空間が分かれ、actorIdは認証基盤のIDのまま使える

なるほど!テナントのようなカテゴリが増えても戦略を作り直す必要がないのはよさそうです!
名前空間に独自の変数を差し込めるようになった感じですね!

ちなみにAPIリファレンスでは従来の namespaces は非推奨とされており、今後は namespaceTemplates を使うよう案内されています。
今回の検証時点では namespaces の指定でも動作しましたが、これから書くなら namespaceTemplates に合わせておくのが良さそうです。

https://docs.aws.amazon.com/bedrock-agentcore-control/latest/APIReference/API_SemanticMemoryStrategyInput.html

どんな場面で使えるか

代表例として考えられるのはマルチテナントSaaSですが、テナント分離ならMemoryリソースごと分ける物理分離も選べるため、1つのMemoryを共有して論理分離する構成の場合に活用できます。
テナントが増えてもMemory側の設定はそのままで、アプリが新しい値を渡すだけで済みますね!

本番と検証のように、同じアカウント内で環境を分ける用途にも使えます。
{env} で区切っておけば、検証用の会話が本番の長期記憶に混ざりません。

もう一つが、1人のユーザーが複数のカテゴリを行き来するケースです。例えば、複数ブランドを統括するマネージャーのように、いくつもの現場を担当している人ですね。ユーザー単位でまとめてしまうと別ブランドの情報まで検索に引っかかるので、現場ごとに記憶を論理的に分けられます。今回はこのパターンを試してみます!

試すシナリオ

ラーメンチェーン(ramen)とカフェチェーン(cafe)の2ブランドを運営する会社で、店舗スタッフ向けのサポートAgentを使っている場面を考えます。

マネージャーの staff001 は両ブランドの店舗を巡回していて、さらにラーメンチェーンには実店舗向け(prod)と研修センター向け(training)の環境がある、という設定で、複数チェーンの情報を1つのMemoryで管理している形ですね。

同じマネージャーが同じ質問をしても、ラーメンチェーンの実店舗では22時と食券機、カフェチェーンの実店舗では21時とエスプレッソマシン、ラーメンチェーンの研修センターでは20時とレジ練習機の記憶を検索する利用例

覚えさせる会話は次の3パターンです。どれも同じ staff001 の発言ですが、チェーンのルールが混ざると困りますよね。

テナント 環境 セッション 会話で伝える閉店作業のルール
ramen prod ramen-prod 22時から始め、食券機を片付ける
cafe prod cafe-prod 21時から始め、エスプレッソマシンを片付ける
ramen training ramen-training 20時から始め、レジ練習機を片付ける

この3つの会話を「ユーザー単位の名前空間(従来形式)」と「テナント・環境・ユーザー単位の名前空間(新形式)」の両方に保存し、閉店作業は何時から始めますか?とシンプルな質問をしたときの結果を比べてみます!

前提

今回の検証環境は以下のとおりです。

項目 使用したもの
AWSリージョン us-east-1
Python 3.14.6
実行ツール uv 0.9.26
boto3 / botocore どちらも1.43.89
Strands Agents 1.55.1(ツール例のみ使用)
使用モデル Claude Haiku 4.5(ツール例のみ使用)
長期記憶の戦略 組み込みのSemantic戦略
会話イベントの保持日数 eventExpiryDuration=3

今回追加された柔軟な名前空間変数の機能自体に追加料金はなく、通常のAgentCore Memory利用料の範囲で使えます。

https://aws.amazon.com/bedrock/agentcore/pricing/

実装する

検証の構成はこちらです。ローカルのPythonスクリプトから会話と名前空間変数を送り、抽出後に展開済みの名前空間を指定して検索します!

ローカルのPythonスクリプトが会話と名前空間変数を送り、AgentCore Memoryが長期記憶をテナント・環境ごとに保存する構成。検索では展開済みの名前空間を指定する

検証コードは1ファイルにまとめました。
まずはuvで作業用のプロジェクトを作り、依存パッケージを追加しておきます。

環境のセットアップ
mkdir memory-namespace-demo
cd memory-namespace-demo
uv init --bare --python 3.14
uv add boto3==1.43.89 botocore==1.43.89

以下のコードを scripts/memory_namespace_lab.py として保存すれば、uv run コマンドで実行できます。
環境変数 MEMORY_LAB_VARIANT=legacy を渡すと従来形式、未指定なら新形式のMemoryを作成します。

scripts/memory_namespace_lab.py(完全版)
scripts/memory_namespace_lab.py
"""Compare legacy and flexible namespaces on a disposable AgentCore Memory."""
import argparse
from datetime import datetime, timezone
import json
import os
from pathlib import Path
import time
import uuid

import boto3
from botocore.exceptions import ClientError

ROOT = Path(__file__).resolve().parents[1]
VARIANT = os.environ.get('MEMORY_LAB_VARIANT', 'flexible')
if VARIANT not in ('legacy', 'flexible'):
    raise ValueError('MEMORY_LAB_VARIANT must be legacy or flexible')
STATE_PATH = ROOT / f'.local/memory-namespace-{VARIANT}-state.json'
LOG_PATH = ROOT / '.local/memory-namespace-events.jsonl'
REGION = 'us-east-1'
ACTOR = 'staff001'
TEMPLATE = '/tenant/{tenant}/env/{env}/actor/{actorId}/'
CASES = [
    ('ramen', 'prod', '22時', '食券機'),
    ('cafe', 'prod', '21時', 'エスプレッソマシン'),
    ('ramen', 'training', '20時', 'レジ練習機'),
]
session = boto3.Session(region_name=REGION)
control = session.client('bedrock-agentcore-control')
data = session.client('bedrock-agentcore')
state = json.loads(STATE_PATH.read_text()) if STATE_PATH.exists() else {}

def save():
    STATE_PATH.parent.mkdir(exist_ok=True, mode=0o700)
    fd = os.open(STATE_PATH, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
    with os.fdopen(fd, 'w') as f:
        json.dump(state, f, indent=2, default=str)

def record(action, **values):
    item = {'time': datetime.now(timezone.utc).isoformat(), 'variant': VARIANT, 'action': action, **values}
    LOG_PATH.parent.mkdir(exist_ok=True, mode=0o700)
    with LOG_PATH.open('a') as f:
        f.write(json.dumps(item, ensure_ascii=False, default=str) + '\n')
    print(json.dumps(item, ensure_ascii=False, default=str), flush=True)

def setup():
    if state.get('deleted'):
        raise RuntimeError('Previous lab was deleted; archive its state before a new run.')
    if 'memory_id' not in state:
        options = ({
            'namespaceKeys': [
                {'key': 'tenant', 'validation': {'allowedValues': ['ramen', 'cafe']}},
                {'key': 'env', 'validation': {'allowedValues': ['prod', 'training']}},
            ],
            'memoryStrategies': [{'semanticMemoryStrategy': {
                'name': 'FlexibleByTenantEnv', 'namespaceTemplates': [TEMPLATE],
            }}],
        } if VARIANT == 'flexible' else {
            'memoryStrategies': [{'semanticMemoryStrategy': {
                'name': 'LegacyByActor', 'namespaces': ['/legacy/actor/{actorId}/'],
            }}],
        })
        memory = control.create_memory(
            name='NamespaceBlog_' + uuid.uuid4().hex[:12],
            description='Disposable flexible namespace comparison',
            eventExpiryDuration=3,
            tags={'Project': 'memory-namespace-blog', 'Purpose': 'disposable-test'},
            **options,
        )['memory']
        state['memory_id'] = memory['id']
        save()
        record('created', status=memory['status'])
    status()

def status():
    memory = control.get_memory(memoryId=state['memory_id'])['memory']
    state['strategies'] = {s['name']: s['strategyId'] for s in memory['strategies']}
    save()
    record('status', status=memory['status'], namespaceKeys=memory.get('namespaceKeys'),
           strategies=[{k: s.get(k) for k in ('name', 'status', 'namespaces', 'namespaceTemplates')}
                       for s in memory['strategies']])
    return memory

def create_event(session_id, text, variables):
    kwargs = dict(memoryId=state['memory_id'], actorId=ACTOR, sessionId=session_id,
                  eventTimestamp=datetime.now(timezone.utc), clientToken=str(uuid.uuid4()),
                  payload=[{'conversational': {'role': 'USER', 'content': {'text': text}}},
                           {'conversational': {'role': 'ASSISTANT', 'content': {
                               'text': '承知しました。閉店作業の手順を覚えておきます。'}}}])
    if variables is not None and VARIANT == 'flexible':
        kwargs['extractionConfig'] = {'namespaceVariables': variables}
    return data.create_event(**kwargs)

def seed():
    memory = status()
    if memory['status'] != 'ACTIVE' or any(s['status'] != 'ACTIVE' for s in memory['strategies']):
        raise RuntimeError('Wait until memory and strategies are ACTIVE.')
    for tenant, env, closing_time, item in CASES:
        key = f'{tenant}-{env}'
        if key in state.get('events', {}):
            continue
        text = (f'閉店作業は{closing_time}から始めます。'
                f'閉店作業では{item}の片付けが必要です。'
                '今後、閉店作業について質問したときはこの手順を思い出してください。')
        result = create_event(key, text, {'tenant': tenant, 'env': env})
        state.setdefault('events', {})[key] = result['event']['eventId']
        save()
        record('event_created', case=key, closing_time=closing_time, item=item,
               http_status=result['ResponseMetadata']['HTTPStatusCode'])

def wait_ready():
    deadline = time.monotonic() + 600
    while time.monotonic() < deadline:
        memory = status()
        if memory['status'] == 'FAILED' or any(s['status'] == 'FAILED' for s in memory['strategies']):
            raise RuntimeError(memory.get('failureReason', 'Memory strategy failed'))
        if memory['status'] == 'ACTIVE' and all(s['status'] == 'ACTIVE' for s in memory['strategies']):
            return
        time.sleep(15)
    raise TimeoutError('Memory did not become ACTIVE within 10 minutes.')

def list_records(namespace, strategy_id=None):
    args = dict(memoryId=state['memory_id'], namespace=namespace, maxResults=100)
    if strategy_id:
        args['memoryStrategyId'] = strategy_id
    records = []
    while True:
        response = data.list_memory_records(**args)
        records.extend(response.get('memoryRecordSummaries', []))
        if not response.get('nextToken'):
            break
        args['nextToken'] = response['nextToken']
    return records

def check():
    paths = (['/legacy/actor/staff001/'] if VARIANT == 'legacy' else [
        f'/tenant/{t}/env/{e}/actor/staff001/' for t, e, _, _ in CASES])
    results = {}
    for path in paths:
        records = list_records(path)
        results[path] = [{k: r.get(k) for k in ('content', 'namespaces', 'memoryStrategyId')}
                         for r in records]
        record('records', namespace=path, count=len(records), records=results[path])
    state['last_records'] = results
    save()
    return results

def retrieve():
    query = '閉店作業は何時から始めますか?'
    if VARIANT == 'legacy':
        namespace = '/legacy/actor/staff001/'
        response = data.retrieve_memory_records(memoryId=state['memory_id'], namespace=namespace,
            searchCriteria={'searchQuery': query, 'topK': 10})
        records = response.get('memoryRecordSummaries', [])
        text = json.dumps([r['content'] for r in records], ensure_ascii=False)
        assert all(t in text and item in text for _, _, t, item in CASES), records
        record('retrieved', namespace=namespace, records=[
            {k: r.get(k) for k in ('content', 'namespaces', 'score')} for r in records],
            all_cases_present=True, assertions_passed=True)
        return
    for tenant, env, closing_time, item in CASES:
        namespace = f'/tenant/{tenant}/env/{env}/actor/staff001/'
        response = data.retrieve_memory_records(memoryId=state['memory_id'], namespace=namespace,
            searchCriteria={'searchQuery': query, 'topK': 10})
        records = response.get('memoryRecordSummaries', [])
        text = json.dumps([r['content'] for r in records], ensure_ascii=False)
        assert closing_time in text and item in text, (namespace, records)
        assert all(other not in text for _, _, _, other in CASES if other != item), (namespace, records)
        assert all(t not in text for _, _, t, _ in CASES if t != closing_time), (namespace, records)
        assert all(r['namespaces'] == [namespace] for r in records), records
        record('retrieved', namespace=namespace, records=[
            {k: r.get(k) for k in ('content', 'namespaces', 'score')} for r in records],
            expected_case=f'{closing_time}{item}', assertions_passed=True)

def probes():
    if 'missing_env' not in state:
        result = create_event('missing-env',
            '閉店作業のあとに日報を提出します。日報の提出期限は23時です。'
            '今後、日報について質問したときはこの期限を思い出してください。', {'tenant': 'ramen'})
        state['missing_env'] = result['event']['eventId']
        save()
        stored = data.get_event(memoryId=state['memory_id'], actorId=ACTOR,
                                sessionId='missing-env', eventId=state['missing_env'])
        record('missing_env', http_status=result['ResponseMetadata']['HTTPStatusCode'],
               persisted_in_short_term=bool(stored['event']['payload']))
    for label, variables in [
        ('invalid_allowed_value', {'tenant': 'ramen', 'env': 'staging'}),
        ('uppercase_value', {'tenant': 'Ramen', 'env': 'prod'}),
    ]:
        try:
            result = create_event(label.replace('_', '-'), '新メニューの試食は毎週金曜です。', variables)
        except ClientError as e:
            record(label, code=e.response['Error']['Code'], message=e.response['Error']['Message'])
        else:
            record(label, unexpected_success=True, http_status=result['ResponseMetadata']['HTTPStatusCode'])

def jobs():
    args = dict(memoryId=state['memory_id'], maxResults=50)
    result = []
    while True:
        response = data.list_memory_extraction_jobs(**args)
        result.extend(response.get('jobs', []))
        if not response.get('nextToken'):
            break
        args['nextToken'] = response['nextToken']
    record('extraction_jobs', response=result)

def cleanup():
    try:
        response = control.delete_memory(memoryId=state['memory_id'])
        record('delete_requested', status=response.get('status'))
    except control.exceptions.ResourceNotFoundException:
        pass
    for _ in range(60):
        try:
            control.get_memory(memoryId=state['memory_id'])
        except control.exceptions.ResourceNotFoundException:
            state['deleted'] = True
            save()
            record('deleted', confirmed=True)
            return
        time.sleep(5)
    raise TimeoutError('Deletion still pending; rerun cleanup.')

if __name__ == '__main__':
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('command', choices=['setup', 'status', 'wait_ready', 'seed', 'check', 'retrieve', 'probes', 'jobs', 'cleanup'])
    globals()[parser.parse_args().command]()

ここからは新形式にかかわる主要な処理を順に見ていきます。

Memoryでキーを宣言し、戦略のテンプレートから参照する

Memoryの namespaceKeystenantenv を定義し、戦略側の namespaceTemplates から {tenant} のように波括弧で参照します。

scripts/memory_namespace_lab.py(抜粋)
memory = control.create_memory(
    name='NamespaceBlog_' + uuid.uuid4().hex[:12],
    eventExpiryDuration=3,
    namespaceKeys=[
        {'key': 'tenant', 'validation': {'allowedValues': ['ramen', 'cafe']}},
        {'key': 'env', 'validation': {'allowedValues': ['prod', 'training']}},
    ],
    memoryStrategies=[{'semanticMemoryStrategy': {
        'name': 'FlexibleByTenantEnv',
        'namespaceTemplates': ['/tenant/{tenant}/env/{env}/actor/{actorId}/'],
    }}],
)['memory']

namespaceKeys がMemory全体で使える変数の宣言、namespaceTemplates がそれらで組み立てる戦略ごとの保存先パスというイメージですね。allowedValues による値のバリデーションは任意ですが、今回はテナントを2ブランド、環境を prodtraining に限定しています。

キーと値には文字種の制約があります。

  • キー名
    • 小文字の英字で始まる英数字で、最大32文字
  • 設定する値
    • 小文字の英数字で始まり、小文字の英数字・ハイフン・アンダースコアが使用可能(最大64文字)

https://docs.aws.amazon.com/bedrock-agentcore/latest/APIReference/API_ExtractionConfig.html

会話と一緒に変数の値を渡す

イベントを登録する際に、extractionConfig.namespaceVariables で変数の値を渡します。以下はラーメンチェーンの実店舗向けとして会話を記録する例です。

scripts/memory_namespace_lab.py(抜粋)
data.create_event(
    memoryId=state['memory_id'],
    actorId='staff001',
    sessionId='ramen-prod',
    eventTimestamp=datetime.now(timezone.utc),
    payload=[
        {'conversational': {'role': 'USER', 'content': {'text': text}}},
        {'conversational': {'role': 'ASSISTANT', 'content': {
            'text': '承知しました。閉店作業の手順を覚えておきます。'}}},
    ],
    extractionConfig={'namespaceVariables': {'tenant': 'ramen', 'env': 'prod'}},
)

保存先は、アプリケーション側が明示的に渡した値で決まります。

検索では展開後の名前空間を渡す

抽出されたレコードを検索するときは、パスを直接指定します。

scripts/memory_namespace_lab.py(抜粋)
response = data.retrieve_memory_records(
    memoryId=state['memory_id'],
    namespace='/tenant/ramen/env/prod/actor/staff001/',
    searchCriteria={'searchQuery': '閉店作業は何時から始めますか?', 'topK': 10},
)
for record in response['memoryRecordSummaries']:
    print(record['namespaces'], record['content']['text'])

こちらは{tenant} のようなプレースホルダーや namespaceVariables の指定には対応していないので注意が必要です。

動作確認

新形式と従来形式のMemoryを両方作成し、それぞれに3つの会話を投入します。

実行コマンド
uv run scripts/memory_namespace_lab.py setup
uv run scripts/memory_namespace_lab.py wait_ready
uv run scripts/memory_namespace_lab.py seed

MEMORY_LAB_VARIANT=legacy uv run scripts/memory_namespace_lab.py setup
MEMORY_LAB_VARIANT=legacy uv run scripts/memory_namespace_lab.py wait_ready
MEMORY_LAB_VARIANT=legacy uv run scripts/memory_namespace_lab.py seed

長期記憶の抽出は非同期で走るため、少し時間を置いてから名前空間ごとの保存内容を取り出し、検索クエリを投げて結果を確認します。

確認コマンド
MEMORY_LAB_VARIANT=legacy uv run scripts/memory_namespace_lab.py check
uv run scripts/memory_namespace_lab.py check
MEMORY_LAB_VARIANT=legacy uv run scripts/memory_namespace_lab.py retrieve
uv run scripts/memory_namespace_lab.py retrieve

保存先ごとの内訳は以下のようになりました。

設定 名前空間 レコード数 保存されていた記憶
ユーザー単位 /legacy/actor/staff001/ 6 3現場すべての開始時刻と機材
テナント・環境・ユーザー単位 /tenant/ramen/env/prod/actor/staff001/ 2 22時・食券機
テナント・環境・ユーザー単位 /tenant/cafe/env/prod/actor/staff001/ 2 21時・エスプレッソマシン
テナント・環境・ユーザー単位 /tenant/ramen/env/training/actor/staff001/ 2 20時・レジ練習機
ListMemoryRecordsの結果(全4名前空間・全12件)
ListMemoryRecordsの結果
// namespace=/legacy/actor/staff001/(6件)
[
  {
    "content": {
      "text": "閉店作業は21時から始まる。"
    },
    "namespaces": [
      "/legacy/actor/staff001/"
    ]
  },
  {
    "content": {
      "text": "閉店作業では食券機の片付けが必要である。"
    },
    "namespaces": [
      "/legacy/actor/staff001/"
    ]
  },
  {
    "content": {
      "text": "閉店作業は20時から始まる。"
    },
    "namespaces": [
      "/legacy/actor/staff001/"
    ]
  },
  {
    "content": {
      "text": "閉店作業は22時から始まる。"
    },
    "namespaces": [
      "/legacy/actor/staff001/"
    ]
  },
  {
    "content": {
      "text": "閉店作業ではエスプレッソマシンの片付けが必要である。"
    },
    "namespaces": [
      "/legacy/actor/staff001/"
    ]
  },
  {
    "content": {
      "text": "閉店作業ではレジ練習機の片付けが必要である。"
    },
    "namespaces": [
      "/legacy/actor/staff001/"
    ]
  }
]

// namespace=/tenant/ramen/env/prod/actor/staff001/(2件)
[
  {
    "content": {
      "text": "閉店作業は22時から始まる。"
    },
    "namespaces": [
      "/tenant/ramen/env/prod/actor/staff001/"
    ]
  },
  {
    "content": {
      "text": "閉店作業では食券機の片付けが必要である。"
    },
    "namespaces": [
      "/tenant/ramen/env/prod/actor/staff001/"
    ]
  }
]

// namespace=/tenant/cafe/env/prod/actor/staff001/(2件)
[
  {
    "content": {
      "text": "閉店作業は21時から始まる。"
    },
    "namespaces": [
      "/tenant/cafe/env/prod/actor/staff001/"
    ]
  },
  {
    "content": {
      "text": "閉店作業ではエスプレッソマシンの片付けが必要である。"
    },
    "namespaces": [
      "/tenant/cafe/env/prod/actor/staff001/"
    ]
  }
]

// namespace=/tenant/ramen/env/training/actor/staff001/(2件)
[
  {
    "content": {
      "text": "閉店作業ではレジ練習機の片付けが必要である。"
    },
    "namespaces": [
      "/tenant/ramen/env/training/actor/staff001/"
    ]
  },
  {
    "content": {
      "text": "閉店作業は20時から始まる。"
    },
    "namespaces": [
      "/tenant/ramen/env/training/actor/staff001/"
    ]
  }
]

各会話から「開始時刻」と「片付ける機材」の2件ずつが抽出され、新形式では1つの戦略から3つの名前空間へきれいに分かれて保存されていますね!

同じstaff001の記憶が、従来設定では1つの名前空間に6件、新しい設定ではテナントと環境ごとに2件ずつ保存された実測結果

検索クエリは一律で「閉店作業は何時から始めますか?」を使いました。まず従来形式の /legacy/actor/staff001/ を検索した結果がこちらです。

従来形式の検索結果(スコア上位3件を抜粋)
[
  {"content": {"text": "閉店作業は22時から始まる。"}, "score": 0.7048244},
  {"content": {"text": "閉店作業は20時から始まる。"}, "score": 0.67026436},
  {"content": {"text": "閉店作業は21時から始まる。"}, "score": 0.6684311}
]
従来形式の検索結果(全6件)
RetrieveMemoryRecordsの結果(従来形式・全件)
[
  {
    "content": {
      "text": "閉店作業は22時から始まる。"
    },
    "namespaces": [
      "/legacy/actor/staff001/"
    ],
    "score": 0.7048244
  },
  {
    "content": {
      "text": "閉店作業は20時から始まる。"
    },
    "namespaces": [
      "/legacy/actor/staff001/"
    ],
    "score": 0.67026436
  },
  {
    "content": {
      "text": "閉店作業は21時から始まる。"
    },
    "namespaces": [
      "/legacy/actor/staff001/"
    ],
    "score": 0.6684311
  },
  {
    "content": {
      "text": "閉店作業ではエスプレッソマシンの片付けが必要である。"
    },
    "namespaces": [
      "/legacy/actor/staff001/"
    ],
    "score": 0.47130024
  },
  {
    "content": {
      "text": "閉店作業ではレジ練習機の片付けが必要である。"
    },
    "namespaces": [
      "/legacy/actor/staff001/"
    ],
    "score": 0.46928594
  },
  {
    "content": {
      "text": "閉店作業では食券機の片付けが必要である。"
    },
    "namespaces": [
      "/legacy/actor/staff001/"
    ],
    "score": 0.4545679
  }
]

22時・20時・21時が全部返ってきてしまいました。ユーザー単位で集約する設定通りの挙動ではあるものの、いまどの現場にいるかをアプリ側で判断できないと、どの時刻を答えればいいのか分かりません。

続いて新形式で、ラーメンチェーンの実店舗(ramenprod)を指定した結果です。

新形式の検索結果(抜粋)
{
  "content": {"text": "閉店作業は22時から始まる。"},
  "namespaces": ["/tenant/ramen/env/prod/actor/staff001/"],
  "score": 0.7048244
}
新形式の検索結果(3つの名前空間・全6件)
RetrieveMemoryRecordsの結果(新形式・全件)
// namespace=/tenant/ramen/env/prod/actor/staff001/ を指定した結果
[
  {
    "content": {
      "text": "閉店作業は22時から始まる。"
    },
    "namespaces": [
      "/tenant/ramen/env/prod/actor/staff001/"
    ],
    "score": 0.7048244
  },
  {
    "content": {
      "text": "閉店作業では食券機の片付けが必要である。"
    },
    "namespaces": [
      "/tenant/ramen/env/prod/actor/staff001/"
    ],
    "score": 0.45456797
  }
]

// namespace=/tenant/cafe/env/prod/actor/staff001/ を指定した結果
[
  {
    "content": {
      "text": "閉店作業は21時から始まる。"
    },
    "namespaces": [
      "/tenant/cafe/env/prod/actor/staff001/"
    ],
    "score": 0.66843104
  },
  {
    "content": {
      "text": "閉店作業ではエスプレッソマシンの片付けが必要である。"
    },
    "namespaces": [
      "/tenant/cafe/env/prod/actor/staff001/"
    ],
    "score": 0.4713003
  }
]

// namespace=/tenant/ramen/env/training/actor/staff001/ を指定した結果
[
  {
    "content": {
      "text": "閉店作業は20時から始まる。"
    },
    "namespaces": [
      "/tenant/ramen/env/training/actor/staff001/"
    ],
    "score": 0.67026436
  },
  {
    "content": {
      "text": "閉店作業ではレジ練習機の片付けが必要である。"
    },
    "namespaces": [
      "/tenant/ramen/env/training/actor/staff001/"
    ],
    "score": 0.46928594
  }
]

狙い通り、他の現場の情報は混ざらず、ラーメンチェーン実店舗の22時と食券機だけが返ってきていますね!同じ検索文でも、cafeprod なら21時とエスプレッソマシン、ramentraining なら20時とレジ情報だけが返るようになりました。

独自変数を設定しない場合はどうなる?

env を渡し忘れた場合や、定義で許可していない値を渡したときの挙動も試してみました。

検証コマンド
uv run scripts/memory_namespace_lab.py probes
入力 実際の応答
tenant="ramen" だけを指定し、env を省略 HTTP 201で成功。GetEvent でも短期記憶への保存を確認
env="staging" ValidationExceptionallowedValues の検証に失敗
tenant="Ramen" ValidationException。値の文字種の制約に違反
実行記録(全件)
検証スクリプトの実行記録(時刻の項目は省略)
{"variant": "flexible", "action": "missing_env", "http_status": 201, "persisted_in_short_term": true}
{"variant": "flexible", "action": "invalid_allowed_value", "code": "ValidationException", "message": "Value 'staging' for namespace variable 'env' failed validation"}
{"variant": "flexible", "action": "uppercase_value", "code": "ValidationException", "message": "1 validation error detected: Value at 'extractionConfig.namespaceVariables' failed to satisfy constraint: Map value must satisfy constraint: [Member must have length less than or equal to 64, Member must have length greater than or equal to 1, Member must satisfy regular expression pattern: [a-z0-9][a-z0-9-_]*]"}

env が抜けていてもリクエスト自体はHTTP 201で通ってしまうんですね。

公式ガイドによると、戦略が参照している変数が欠けていると名前空間が解決できず、長期記憶の抽出が始まらない仕様です(イベント自体は短期記憶に残ります)。

成功レスポンスが返ってきても抽出ができるとは限らないので、送信前に必要な変数がそろっているかをアプリケーション側でバリデーションしておきたいところですね。

https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/specify-long-term-memory-organization.html#specify-supply-namespace-values-at-runtime

Strands AgentsのSession Managerからはまだ使えない

Strands Agentsには、AgentCore Memoryをセッション永続化と長期記憶の注入に使うSession Managerがあります。今回の機能と組み合わせられるか気になってソースを確認したところ、2026年9月17日時点の最新版では名前空間変数に未対応でした・・・

  • イベント保存時に extractionConfig.namespaceVariables を渡す手段がなく、独自変数を参照する戦略では長期記憶の抽出が動かない
  • 検索設定の名前空間テンプレートは組み込みの3変数しか解決できず、{tenant} を含むテンプレートではその名前空間の検索に失敗する

執筆時点では、関連するIssueやPRもまだ見当たりませんでした。今後の対応に期待ですね。

https://github.com/aws/bedrock-agentcore-sdk-python/tree/main/src/bedrock_agentcore/memory/integrations/strands

ただ、それでもこの名前空間を使いたい・・・!と思った時にツールとして検索する作りを考えてみます。

記憶の検索をツールとして持たせる

記憶の検索を自前のツールとしてAgentへ持たせる方法なら今すぐ使えます!

ここで考えどころなのが、テナントと環境をどう決めるかです。サーバー側で固定する方法と、ツールの引数にしてLLMに選ばせる方法の2つを試してみました。

まずはサーバー側で固定する形です。クロージャで名前空間を閉じ込めてしまい、LLMには検索クエリだけを考えさせます。

scripts/memory_tool_agent.py(抜粋)
def make_memory_tools(memory_id: str, tenant: str, env: str, actor_id: str):
    # JWTなどのユーザー属性から判別したテナント・環境をクロージャで固定する
    namespace = f'/tenant/{tenant}/env/{env}/actor/{actor_id}/'

    @tool
    def search_store_memory(query: str) -> str:
        """いま担当している現場の記憶(閉店作業のルールなど)を検索する"""
        response = data.retrieve_memory_records(
            memoryId=memory_id,
            namespace=namespace,
            searchCriteria={'searchQuery': query, 'topK': 5},
        )
        records = [r['content']['text'] for r in response['memoryRecordSummaries']]
        return '\n'.join(records) if records else '該当する記憶はありません'

    return [search_store_memory]
scripts/memory_tool_agent.py(完全版)
scripts/memory_tool_agent.py
"""Search tenant-scoped AgentCore Memory records from a Strands agent tool."""
import json
import os
from pathlib import Path

import boto3
from strands import Agent, tool
from strands.models import BedrockModel

REGION = 'us-east-1'
data = boto3.client('bedrock-agentcore', region_name=REGION)

state_path = Path(__file__).resolve().parents[1] / '.local/memory-namespace-flexible-state.json'
MEMORY_ID = os.environ.get('MEMORY_ID') or json.loads(state_path.read_text())['memory_id']

def make_memory_tools(memory_id: str, tenant: str, env: str, actor_id: str):
    # JWTなどのユーザー属性から判別したテナント・環境をクロージャで固定する
    namespace = f'/tenant/{tenant}/env/{env}/actor/{actor_id}/'

    @tool
    def search_store_memory(query: str) -> str:
        """いま担当している現場の記憶(閉店作業のルールなど)を検索する"""
        response = data.retrieve_memory_records(
            memoryId=memory_id,
            namespace=namespace,
            searchCriteria={'searchQuery': query, 'topK': 5},
        )
        records = [r['content']['text'] for r in response['memoryRecordSummaries']]
        return '\n'.join(records) if records else '該当する記憶はありません'

    return [search_store_memory]

if __name__ == '__main__':
    agent = Agent(
        model=BedrockModel(model_id='us.anthropic.claude-haiku-4-5-20251001-v1:0', region_name=REGION),
        tools=make_memory_tools(MEMORY_ID, tenant='ramen', env='prod', actor_id='staff001'),
    )
    agent('閉店作業は何時から始めますか?')

ラーメンチェーンの実店舗として組み立てたAgentに質問すると、その現場の記憶だけを見て答えてくれました。

実行結果(抜粋)
Tool #1: search_store_memory
閉店作業は**22時から始まります**。
実行結果(全文)
uv run scripts/memory_tool_agent.py
閉店作業の開始時間についての情報を検索させていただきます。
Tool #1: search_store_memory
閉店作業は**22時から始まります**。

閉店作業では食券機の片付けなどの業務も含まれています。

次に、tenantenv をツールの引数にしてLLMに選ばせる形も試してみます。引数の説明にどの値が使えるかを書いておくと、LLMが会話から判断して渡してくれます。

scripts/memory_tool_agent_dynamic.py(抜粋)
@tool
def search_store_memory(query: str, tenant: str, env: str) -> str:
    """現場の記憶を検索する。tenantはramenまたはcafe、envはprodまたはtraining"""
    namespace = f'/tenant/{tenant}/env/{env}/actor/staff001/'
    print(f'[tool called with tenant={tenant} env={env}]')
    ...
scripts/memory_tool_agent_dynamic.py(完全版)
scripts/memory_tool_agent_dynamic.py
"""Let the LLM choose the tenant and env as tool arguments (compare with memory_tool_agent.py)."""
import json
import os
from pathlib import Path

import boto3
from strands import Agent, tool
from strands.models import BedrockModel

REGION = 'us-east-1'
MODEL_ID = 'us.anthropic.claude-haiku-4-5-20251001-v1:0'
data = boto3.client('bedrock-agentcore', region_name=REGION)

state_path = Path(__file__).resolve().parents[1] / '.local/memory-namespace-flexible-state.json'
MEMORY_ID = os.environ.get('MEMORY_ID') or json.loads(state_path.read_text())['memory_id']

@tool
def search_store_memory(query: str, tenant: str, env: str) -> str:
    """現場の記憶を検索する。tenantはramenまたはcafe、envはprodまたはtraining"""
    namespace = f'/tenant/{tenant}/env/{env}/actor/staff001/'
    print(f'[tool called with tenant={tenant} env={env}]')
    response = data.retrieve_memory_records(
        memoryId=MEMORY_ID,
        namespace=namespace,
        searchCriteria={'searchQuery': query, 'topK': 5},
    )
    records = [r['content']['text'] for r in response['memoryRecordSummaries']]
    return '\n'.join(records) if records else '該当する記憶はありません'

def build_agent() -> Agent:
    return Agent(
        model=BedrockModel(model_id=MODEL_ID, region_name=REGION),
        tools=[search_store_memory],
    )

if __name__ == '__main__':
    build_agent()('カフェの閉店作業は何時から始めますか?')
    print('=== 次: あいまいな聞き方 ===')
    build_agent()('閉店作業は何時から始めますか?')

「カフェの閉店作業は?」と聞くと tenant=cafe を選んで21時と答えてくれるので、1つのAgentで複数の現場を扱えて便利そうに見えます。ただ、現場を明示せずに聞くとこうなりました。

実行結果(抜粋)
ただし、どの店舗の情報を探すのか、また本番環境(prod)と訓練環境(training)のどちらを確認するのか教えていただけますか?
実行結果(全文)
テナントを引数にした場合
カフェの閉店作業の時間を検索します。
Tool #1: search_store_memory
[tool called with tenant=cafe env=prod]
カフェの閉店作業は**21時から始まります**。

閉店作業ではエスプレッソマシンの片付けなどが必要とのことです。
=== 次: あいまいな聞き方 ===
閉店作業の開始時刻を確認するために、現場の記憶を検索させていただきます。

ただし、どの店舗の情報を探すのか、また本番環境(prod)と訓練環境(training)のどちらを確認するのか教えていただけますか?

- **テナント**: ramen(ラーメン店)または cafe(カフェ)のどちらですか?
- **環境**: prod(本番)または training(訓練)のどちらですか?

どの現場を見るかが利用者の申告次第になってしまっていますね。会話の流れ次第では別テナントの名前空間を検索できてしまうので、属性で必ず限定したい場合は、テナントはJWTなどのユーザー属性から決めて、LLMには検索クエリだけを任せる前者の形が安全だと思います。

認可制御する場合の注意点

注意点として、名前空間を作成する時に指定した allowedValues は値の形式を検証する仕組みで、呼び出し元の所属を確認するような認可ではありません。テナントの判別は、外部から送られた値を信用せず、ログイン中のユーザーのJWTをサーバー側で検証する必要があります。

MemoryのAPI呼び出しはIAM(SigV4)認証のため、JWTがそのままMemoryへ届くことはわけではなく、Memoryから見える呼び出し元はサーバーのIAMロールを使用します。

このIAMロールに、今回のアップデートで追加された条件キー bedrock-agentcore:namespaceVariable/<キー名> で制限可能で、テナントごとにワークロードのロールを分けていて同一Memoryを使用する構成なら、ラーメン側のロールに tenant="ramen" のリクエストだけを許可しておくことで、サーバーの実装ミスで別テナントの値を渡してもAPIの時点で拒否できます。

書き込み側のポリシー例(ラーメンチェーン向けロール)
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AllowCreateEventForRamenOnly",
      "Effect": "Allow",
      "Action": "bedrock-agentcore:CreateEvent",
      "Resource": "arn:aws:bedrock-agentcore:us-east-1:123456789012:memory/<Memory ID>",
      "Condition": {
        "StringEquals": {
          "bedrock-agentcore:namespaceVariable/tenant": "ramen"
        }
      }
    }
  ]
}

公式ドキュメントの評価表によると、条件キーを付けたポリシーでは、対象の変数を渡さないリクエストも「条件を満たせない」ため拒否されます。先ほど確認した「env を渡し忘れてもHTTP 201で通ってしまう」動きも、条件キーを設定しておけばAPIの時点で拒否できるわけですね。渡し忘れ対策としても役に立ちそうです。

逆に、1つのロールで全テナントを処理する構成では呼び出し元を区別できないため、この条件キーでは制御できません。テナントごとのロール分離とセットで使う機能と考えておくのが良さそうです。

とはいえ、テナントの数だけロールを作るのは大変ですよね。その場合はSTSのセッションタグを使う手もあります。アプリが AssumeRole するときにテナントをタグとして付けておき、ポリシー側は ${aws:PrincipalTag/tenant} と突き合わせる形です。

利用者のJWTをアプリのサーバーが検証してテナントを判別し、AssumeRole時にセッションタグとしてtenant=ramenを付ける。ポリシーの条件を${aws:PrincipalTag/tenant}にしておくと、ロールもポリシーも1本のままでタグと一致するテナントの書き込みだけが許可される

タグの値と違うテナントを渡すと拒否されるので、ロールを増やさずにテナントを分離できます。

一方で、読み取り側の検索APIは、変数が展開されたあとの完全なパスを受け取ります。そのため既存の条件キーである bedrock-agentcore:namespace(完全一致)や bedrock-agentcore:namespacePath(階層プレフィックス)がそのまま使えます。

読み取り側のポリシー例(ラーメンチェーン向けロール)
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AllowRetrieveRamenNamespacesOnly",
      "Effect": "Allow",
      "Action": "bedrock-agentcore:RetrieveMemoryRecords",
      "Resource": "arn:aws:bedrock-agentcore:us-east-1:123456789012:memory/<Memory ID>",
      "Condition": {
        "StringLike": {
          "bedrock-agentcore:namespacePath": "/tenant/ramen/*"
        }
      }
    }
  ]
}

書き込みと読み取りの制限を図にするとこうなります。

ラーメンチェーン向けロールの例。書き込み側はnamespaceVariable/tenantの条件キーでtenant=ramenのCreateEventだけを許可し、cafeの値や渡し忘れは拒否される。読み取り側は展開後のパスへのnamespacePath条件で、ラーメンの名前空間だけ検索できる

JWTのクレームで直接制御したい場合は、記憶の検索をLambdaなどでツール化してGatewayに載せ、AgentCore Policy(プレビュー)のCedarポリシーで「JWTのテナントとツール引数のテナントが一致するときだけ許可」と書く構成も考えられます。ただしこの方法で制御できるのはGatewayを通るツール呼び出しだけで、今回のようなAPIの直接呼び出しはIAMで守る形になります。

https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/policy-understanding-cedar.html

今回はさらっと紹介していますが、このあたりはややこしいので今度どう設計するのが良いかじっくり考えてみたいですね。

後片付け

検証用に作成した2つのMemoryを削除します。

削除コマンド
uv run scripts/memory_namespace_lab.py cleanup
MEMORY_LAB_VARIANT=legacy uv run scripts/memory_namespace_lab.py cleanup

おわりに

ユーザーIDにテナントや環境の名前を無理やり連結せず、それぞれを独立したパラメータとして渡せるようになったのは、設計面で扱いやすくなったのではと感じました!同一ユーザーでも、関わっている組織や環境ごとに参照する記憶を切り替えたいユースケースによさそうですね。

本記事が少しでも参考になりましたら幸いです。最後までご覧いただきありがとうございました!

この記事をシェアする

AWSのお困り事はクラスメソッドへ

関連記事