From 5ad13815f6b15d07526947d3cf6e46749302678f Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Wed, 20 May 2026 03:07:59 +0000 Subject: [PATCH 1/2] feat(templates): add conversation history persistence to HTTP agent templates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #808, #809. All four HTTP agent templates were stateless per-invocation. Each call started with zero conversation context, so multi-turn recall within a session did not work. Strands additionally leaked history across sessions because all invocations shared a single global Agent instance. Each template now uses its framework's idiomatic per-session mechanism: - Strands: replace global _agent with @lru_cache(128) get_or_create_agent(session_id). The Agent accumulates messages internally across stream_async() calls, so reusing the per-session instance gives multi-turn memory and isolates sessions. Bound at 128 entries to cap process memory. Only applied to the no-memory, no-config-bundle branch — hasMemory already keys per (session_id, user_id) and hasConfigBundle intentionally recreates the agent each invoke for hooks. - OpenAI Agents: @lru_cache(128) get_session(session_id) returning SQLiteSession, threaded via session= on Runner.run(). Auto-loads/saves prior history in-process. - Google ADK: module-level _session_service + _runner with a get_or_create_session() helper that calls get_session() then create_session() if missing. Avoids AlreadyExistsError on repeat calls. - LangGraph: module-level _checkpointer = InMemorySaver() passed to create_react_agent(). thread_id mapped to context.session_id via configurable on graph.ainvoke. All state is in-memory and best-effort: persists across invocations within the runtime process, resets on cold starts. Users who need durability can swap to each framework's persistent backend. End-to-end deploy + invoke testing covered in the original PR description. --- .../assets.snapshot.test.ts.snap | 116 ++++++++++++------ src/assets/python/http/googleadk/base/main.py | 34 +++-- .../http/langchain_langgraph/base/main.py | 33 +++-- .../python/http/openaiagents/base/main.py | 25 ++-- src/assets/python/http/strands/base/main.py | 22 ++-- 5 files changed, 159 insertions(+), 71 deletions(-) diff --git a/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap b/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap index 9fad266c2..2ab051d13 100644 --- a/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap +++ b/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap @@ -3417,21 +3417,39 @@ agent = Agent( ) -# Session and Runner -async def setup_session_and_runner(user_id, session_id): - ensure_credentials_loaded() - session_service = InMemorySessionService() - session = await session_service.create_session( +# Module-level session service and runner (preserves history across invocations) +_session_service = InMemorySessionService() +_runner = None + + +def get_or_create_runner(): + global _runner + if _runner is None: + ensure_credentials_loaded() + _runner = Runner( + agent=agent, + app_name=APP_NAME, + session_service=_session_service, + ) + return _runner + + +async def get_or_create_session(user_id, session_id): + session = await _session_service.get_session( app_name=APP_NAME, user_id=user_id, session_id=session_id ) - runner = Runner(agent=agent, app_name=APP_NAME, session_service=session_service) - return session, runner + if session is None: + session = await _session_service.create_session( + app_name=APP_NAME, user_id=user_id, session_id=session_id + ) + return session # Agent Interaction async def call_agent_async(query, user_id, session_id): content = types.Content(role="user", parts=[types.Part(text=query)]) - session, runner = await setup_session_and_runner(user_id, session_id) + runner = get_or_create_runner() + session = await get_or_create_session(user_id, session_id) events = runner.run_async( user_id=user_id, session_id=session.id, new_message=content ) @@ -3718,6 +3736,7 @@ exports[`Assets Directory Snapshots > Python framework assets > python/python/ht from typing import Any from langchain_core.messages import HumanMessage{{#if hasConfigBundle}}, SystemMessage{{/if}} +from langgraph.checkpoint.memory import InMemorySaver from langgraph.prebuilt import create_react_agent from langchain.tools import tool {{#if hasConfigBundle}} @@ -3765,6 +3784,9 @@ def add_numbers(a: int, b: int) -> int: # Define a collection of tools used by the model tools = [add_numbers] +# Module-level checkpointer preserves conversation history across invocations +_checkpointer = InMemorySaver() + {{#if sessionStorageMountPath}} SESSION_STORAGE_PATH = "{{sessionStorageMountPath}}" @@ -3857,29 +3879,44 @@ async def invoke(payload, context): if mcp_client: mcp_tools = await mcp_client.get_tools() - # Define the agent using create_react_agent + # Define the agent using create_react_agent (checkpointer is shared across invocations) {{#if hasConfigBundle}} - graph = create_react_agent(get_or_create_model(), tools=mcp_tools + tools, prompt=DEFAULT_SYSTEM_PROMPT) + graph = create_react_agent( + get_or_create_model(), + tools=mcp_tools + tools, + prompt=DEFAULT_SYSTEM_PROMPT, + checkpointer=_checkpointer, + ) callback = ConfigBundleCallback() # Process the user prompt prompt = payload.get("prompt", "What can you help me with?") + session_id = getattr(context, "session_id", "default-session") log.info(f"Agent input: {prompt}") - # Run the agent with config bundle callback + # Run the agent with config bundle callback (checkpointer auto-loads/saves history per session) result = await graph.ainvoke( {"messages": [HumanMessage(content=prompt)]}, - config={"callbacks": [callback]}, + config={"callbacks": [callback], "configurable": {"thread_id": session_id}}, ) {{else}} - graph = create_react_agent(get_or_create_model(), tools=mcp_tools + tools, prompt=DEFAULT_SYSTEM_PROMPT) + graph = create_react_agent( + get_or_create_model(), + tools=mcp_tools + tools, + prompt=DEFAULT_SYSTEM_PROMPT, + checkpointer=_checkpointer, + ) # Process the user prompt prompt = payload.get("prompt", "What can you help me with?") + session_id = getattr(context, "session_id", "default-session") log.info(f"Agent input: {prompt}") - # Run the agent - result = await graph.ainvoke({"messages": [HumanMessage(content=prompt)]}) + # Run the agent (checkpointer auto-loads/saves history per session) + result = await graph.ainvoke( + {"messages": [HumanMessage(content=prompt)]}, + config={"configurable": {"thread_id": session_id}}, + ) {{/if}} # Return result @@ -4242,7 +4279,8 @@ Thumbs.db exports[`Assets Directory Snapshots > Python framework assets > python/python/http/openaiagents/base/main.py should match snapshot 1`] = ` "import os -from agents import Agent, Runner, function_tool +from functools import lru_cache +from agents import Agent, Runner, SQLiteSession, function_tool from bedrock_agentcore.runtime import BedrockAgentCoreApp from model.load import load_model {{#if hasGateway}} @@ -4340,8 +4378,14 @@ You have persistent storage at {{sessionStorageMountPath}}. Use file tools to re {{/if}} """ + +@lru_cache(maxsize=128) +def get_session(session_id): + return SQLiteSession(session_id) + + # Define the agent execution -async def main(query): +async def main(query, session): ensure_credentials_loaded() try: {{#if hasGateway}} @@ -4353,7 +4397,7 @@ async def main(query): mcp_servers=mcp_servers, tools=tools ) - result = await Runner.run(agent, query) + result = await Runner.run(agent, query, session=session) return result else: agent = Agent( @@ -4363,7 +4407,7 @@ async def main(query): mcp_servers=[], tools=tools ) - result = await Runner.run(agent, query) + result = await Runner.run(agent, query, session=session) return result {{else}} if mcp_servers: @@ -4376,7 +4420,7 @@ async def main(query): mcp_servers=active_servers, tools=tools ) - result = await Runner.run(agent, query) + result = await Runner.run(agent, query, session=session) return result else: agent = Agent( @@ -4386,7 +4430,7 @@ async def main(query): mcp_servers=[], tools=tools ) - result = await Runner.run(agent, query) + result = await Runner.run(agent, query, session=session) return result {{/if}} except Exception as e: @@ -4400,9 +4444,11 @@ async def invoke(payload, context): # Process the user prompt prompt = payload.get("prompt", "What can you help me with?") + session_id = getattr(context, "session_id", "default-session") + session = get_session(session_id) - # Run the agent - result = await main(prompt) + # Run the agent (session automatically loads/saves conversation history) + result = await main(prompt, session) # Return result return {"result": result.final_output} @@ -4655,7 +4701,8 @@ Thumbs.db" `; exports[`Assets Directory Snapshots > Python framework assets > python/python/http/strands/base/main.py should match snapshot 1`] = ` -"from typing import Any +"from functools import lru_cache +from typing import Any from strands import Agent, tool {{#if hasConfigBundle}} @@ -4830,17 +4877,13 @@ def create_agent(): hooks=[ConfigBundleHook()], ) {{else}} -_agent = None - -def get_or_create_agent(): - global _agent - if _agent is None: - _agent = Agent( - model=load_model(), - system_prompt=DEFAULT_SYSTEM_PROMPT, - tools=tools - ) - return _agent +@lru_cache(maxsize=128) +def get_or_create_agent(session_id): + return Agent( + model=load_model(), + system_prompt=DEFAULT_SYSTEM_PROMPT, + tools=tools + ) {{/if}} {{/if}} @@ -4857,7 +4900,8 @@ async def invoke(payload, context): {{#if hasConfigBundle}} agent = create_agent() {{else}} - agent = get_or_create_agent() + session_id = getattr(context, 'session_id', 'default-session') + agent = get_or_create_agent(session_id) {{/if}} {{/if}} diff --git a/src/assets/python/http/googleadk/base/main.py b/src/assets/python/http/googleadk/base/main.py index aa809b686..e2fa2f0fd 100644 --- a/src/assets/python/http/googleadk/base/main.py +++ b/src/assets/python/http/googleadk/base/main.py @@ -113,21 +113,39 @@ def ensure_credentials_loaded(): ) -# Session and Runner -async def setup_session_and_runner(user_id, session_id): - ensure_credentials_loaded() - session_service = InMemorySessionService() - session = await session_service.create_session( +# Module-level session service and runner (preserves history across invocations) +_session_service = InMemorySessionService() +_runner = None + + +def get_or_create_runner(): + global _runner + if _runner is None: + ensure_credentials_loaded() + _runner = Runner( + agent=agent, + app_name=APP_NAME, + session_service=_session_service, + ) + return _runner + + +async def get_or_create_session(user_id, session_id): + session = await _session_service.get_session( app_name=APP_NAME, user_id=user_id, session_id=session_id ) - runner = Runner(agent=agent, app_name=APP_NAME, session_service=session_service) - return session, runner + if session is None: + session = await _session_service.create_session( + app_name=APP_NAME, user_id=user_id, session_id=session_id + ) + return session # Agent Interaction async def call_agent_async(query, user_id, session_id): content = types.Content(role="user", parts=[types.Part(text=query)]) - session, runner = await setup_session_and_runner(user_id, session_id) + runner = get_or_create_runner() + session = await get_or_create_session(user_id, session_id) events = runner.run_async( user_id=user_id, session_id=session.id, new_message=content ) diff --git a/src/assets/python/http/langchain_langgraph/base/main.py b/src/assets/python/http/langchain_langgraph/base/main.py index 773253da0..36d618701 100644 --- a/src/assets/python/http/langchain_langgraph/base/main.py +++ b/src/assets/python/http/langchain_langgraph/base/main.py @@ -2,6 +2,7 @@ from typing import Any from langchain_core.messages import HumanMessage{{#if hasConfigBundle}}, SystemMessage{{/if}} +from langgraph.checkpoint.memory import InMemorySaver from langgraph.prebuilt import create_react_agent from langchain.tools import tool {{#if hasConfigBundle}} @@ -49,6 +50,9 @@ def add_numbers(a: int, b: int) -> int: # Define a collection of tools used by the model tools = [add_numbers] +# Module-level checkpointer preserves conversation history across invocations +_checkpointer = InMemorySaver() + {{#if sessionStorageMountPath}} SESSION_STORAGE_PATH = "{{sessionStorageMountPath}}" @@ -141,29 +145,44 @@ async def invoke(payload, context): if mcp_client: mcp_tools = await mcp_client.get_tools() - # Define the agent using create_react_agent + # Define the agent using create_react_agent (checkpointer is shared across invocations) {{#if hasConfigBundle}} - graph = create_react_agent(get_or_create_model(), tools=mcp_tools + tools, prompt=DEFAULT_SYSTEM_PROMPT) + graph = create_react_agent( + get_or_create_model(), + tools=mcp_tools + tools, + prompt=DEFAULT_SYSTEM_PROMPT, + checkpointer=_checkpointer, + ) callback = ConfigBundleCallback() # Process the user prompt prompt = payload.get("prompt", "What can you help me with?") + session_id = getattr(context, "session_id", "default-session") log.info(f"Agent input: {prompt}") - # Run the agent with config bundle callback + # Run the agent with config bundle callback (checkpointer auto-loads/saves history per session) result = await graph.ainvoke( {"messages": [HumanMessage(content=prompt)]}, - config={"callbacks": [callback]}, + config={"callbacks": [callback], "configurable": {"thread_id": session_id}}, ) {{else}} - graph = create_react_agent(get_or_create_model(), tools=mcp_tools + tools, prompt=DEFAULT_SYSTEM_PROMPT) + graph = create_react_agent( + get_or_create_model(), + tools=mcp_tools + tools, + prompt=DEFAULT_SYSTEM_PROMPT, + checkpointer=_checkpointer, + ) # Process the user prompt prompt = payload.get("prompt", "What can you help me with?") + session_id = getattr(context, "session_id", "default-session") log.info(f"Agent input: {prompt}") - # Run the agent - result = await graph.ainvoke({"messages": [HumanMessage(content=prompt)]}) + # Run the agent (checkpointer auto-loads/saves history per session) + result = await graph.ainvoke( + {"messages": [HumanMessage(content=prompt)]}, + config={"configurable": {"thread_id": session_id}}, + ) {{/if}} # Return result diff --git a/src/assets/python/http/openaiagents/base/main.py b/src/assets/python/http/openaiagents/base/main.py index 3fc704c19..f97a26bc8 100644 --- a/src/assets/python/http/openaiagents/base/main.py +++ b/src/assets/python/http/openaiagents/base/main.py @@ -1,5 +1,6 @@ import os -from agents import Agent, Runner, function_tool +from functools import lru_cache +from agents import Agent, Runner, SQLiteSession, function_tool from bedrock_agentcore.runtime import BedrockAgentCoreApp from model.load import load_model {{#if hasGateway}} @@ -97,8 +98,14 @@ def list_files(directory: str = "") -> str: {{/if}} """ + +@lru_cache(maxsize=128) +def get_session(session_id): + return SQLiteSession(session_id) + + # Define the agent execution -async def main(query): +async def main(query, session): ensure_credentials_loaded() try: {{#if hasGateway}} @@ -110,7 +117,7 @@ async def main(query): mcp_servers=mcp_servers, tools=tools ) - result = await Runner.run(agent, query) + result = await Runner.run(agent, query, session=session) return result else: agent = Agent( @@ -120,7 +127,7 @@ async def main(query): mcp_servers=[], tools=tools ) - result = await Runner.run(agent, query) + result = await Runner.run(agent, query, session=session) return result {{else}} if mcp_servers: @@ -133,7 +140,7 @@ async def main(query): mcp_servers=active_servers, tools=tools ) - result = await Runner.run(agent, query) + result = await Runner.run(agent, query, session=session) return result else: agent = Agent( @@ -143,7 +150,7 @@ async def main(query): mcp_servers=[], tools=tools ) - result = await Runner.run(agent, query) + result = await Runner.run(agent, query, session=session) return result {{/if}} except Exception as e: @@ -157,9 +164,11 @@ async def invoke(payload, context): # Process the user prompt prompt = payload.get("prompt", "What can you help me with?") + session_id = getattr(context, "session_id", "default-session") + session = get_session(session_id) - # Run the agent - result = await main(prompt) + # Run the agent (session automatically loads/saves conversation history) + result = await main(prompt, session) # Return result return {"result": result.final_output} diff --git a/src/assets/python/http/strands/base/main.py b/src/assets/python/http/strands/base/main.py index 0cc8771ad..ea40b50dc 100644 --- a/src/assets/python/http/strands/base/main.py +++ b/src/assets/python/http/strands/base/main.py @@ -1,3 +1,4 @@ +from functools import lru_cache from typing import Any from strands import Agent, tool @@ -173,17 +174,13 @@ def create_agent(): hooks=[ConfigBundleHook()], ) {{else}} -_agent = None - -def get_or_create_agent(): - global _agent - if _agent is None: - _agent = Agent( - model=load_model(), - system_prompt=DEFAULT_SYSTEM_PROMPT, - tools=tools - ) - return _agent +@lru_cache(maxsize=128) +def get_or_create_agent(session_id): + return Agent( + model=load_model(), + system_prompt=DEFAULT_SYSTEM_PROMPT, + tools=tools + ) {{/if}} {{/if}} @@ -200,7 +197,8 @@ async def invoke(payload, context): {{#if hasConfigBundle}} agent = create_agent() {{else}} - agent = get_or_create_agent() + session_id = getattr(context, 'session_id', 'default-session') + agent = get_or_create_agent(session_id) {{/if}} {{/if}} From 05319e9f90372647c0f368986d13a1ccf8cf6ef5 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 21 May 2026 15:23:04 +0000 Subject: [PATCH 2/2] feat(templates): persist conversations in Strands config-bundle path; document LRU cap Addresses follow-up review comments on #794: - Strands hasConfigBundle branch now caches Agent per session_id via the same @lru_cache(128) get_or_create_agent() pattern as the non-bundle branch, with hooks=[ConfigBundleHook()] attached at construction. ConfigBundleHook reads bundle state inside its callbacks (not at registration), so caching the agent is safe and consistent with how the hasMemory branch already attaches the hook. Drops the create_agent() per-invoke pattern, which had no conversation persistence. - Document the LRU cache cap in both Strands and OpenAI Agents templates: callers of an evicted session start fresh on the next invoke. Comment also points to durable session stores for production use. Snapshot regenerated. --- .../__snapshots__/assets.snapshot.test.ts.snap | 13 ++++++++----- src/assets/python/http/openaiagents/base/main.py | 3 +++ src/assets/python/http/strands/base/main.py | 10 +++++----- 3 files changed, 16 insertions(+), 10 deletions(-) diff --git a/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap b/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap index 2ab051d13..dd461e51f 100644 --- a/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap +++ b/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap @@ -4379,6 +4379,9 @@ You have persistent storage at {{sessionStorageMountPath}}. Use file tools to re """ +# Caches up to 128 active sessions; LRU eviction silently resets history for +# the oldest session. For production use, replace with a durable session store +# (e.g. SQLiteSession with a file path). @lru_cache(maxsize=128) def get_session(session_id): return SQLiteSession(session_id) @@ -4868,8 +4871,12 @@ def agent_factory(): return get_or_create_agent get_or_create_agent = agent_factory() {{else}} +# Caches up to 128 active sessions; LRU eviction silently resets history for +# the oldest session. For production use, replace with a durable session store +# (e.g. Strands FileSessionManager). {{#if hasConfigBundle}} -def create_agent(): +@lru_cache(maxsize=128) +def get_or_create_agent(session_id): return Agent( model=load_model(), system_prompt=DEFAULT_SYSTEM_PROMPT, @@ -4896,13 +4903,9 @@ async def invoke(payload, context): session_id = getattr(context, 'session_id', 'default-session') user_id = getattr(context, 'user_id', 'default-user') agent = get_or_create_agent(session_id, user_id) -{{else}} -{{#if hasConfigBundle}} - agent = create_agent() {{else}} session_id = getattr(context, 'session_id', 'default-session') agent = get_or_create_agent(session_id) -{{/if}} {{/if}} # Execute and format response diff --git a/src/assets/python/http/openaiagents/base/main.py b/src/assets/python/http/openaiagents/base/main.py index f97a26bc8..1a33c9587 100644 --- a/src/assets/python/http/openaiagents/base/main.py +++ b/src/assets/python/http/openaiagents/base/main.py @@ -99,6 +99,9 @@ def list_files(directory: str = "") -> str: """ +# Caches up to 128 active sessions; LRU eviction silently resets history for +# the oldest session. For production use, replace with a durable session store +# (e.g. SQLiteSession with a file path). @lru_cache(maxsize=128) def get_session(session_id): return SQLiteSession(session_id) diff --git a/src/assets/python/http/strands/base/main.py b/src/assets/python/http/strands/base/main.py index ea40b50dc..43181ed0f 100644 --- a/src/assets/python/http/strands/base/main.py +++ b/src/assets/python/http/strands/base/main.py @@ -165,8 +165,12 @@ def get_or_create_agent(session_id, user_id): return get_or_create_agent get_or_create_agent = agent_factory() {{else}} +# Caches up to 128 active sessions; LRU eviction silently resets history for +# the oldest session. For production use, replace with a durable session store +# (e.g. Strands FileSessionManager). {{#if hasConfigBundle}} -def create_agent(): +@lru_cache(maxsize=128) +def get_or_create_agent(session_id): return Agent( model=load_model(), system_prompt=DEFAULT_SYSTEM_PROMPT, @@ -193,13 +197,9 @@ async def invoke(payload, context): session_id = getattr(context, 'session_id', 'default-session') user_id = getattr(context, 'user_id', 'default-user') agent = get_or_create_agent(session_id, user_id) -{{else}} -{{#if hasConfigBundle}} - agent = create_agent() {{else}} session_id = getattr(context, 'session_id', 'default-session') agent = get_or_create_agent(session_id) -{{/if}} {{/if}} # Execute and format response