chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Configure UV for container environment
|
||||
ENV UV_SYSTEM_PYTHON=1 \
|
||||
UV_COMPILE_BYTECODE=1 \
|
||||
DOCKER_CONTAINER=1 \
|
||||
OTEL_PYTHON_LOG_CORRELATION=true \
|
||||
PYTHONUNBUFFERED=1
|
||||
|
||||
# Copy and install agent-specific requirements first
|
||||
COPY agents/langgraph-single-agent/requirements.txt requirements.txt
|
||||
RUN uv pip install --no-cache -r requirements.txt && \
|
||||
uv pip install --no-cache aws-opentelemetry-distro==0.16.0
|
||||
|
||||
# Create non-root user
|
||||
RUN useradd -m -u 1000 bedrock_agentcore
|
||||
USER bedrock_agentcore
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
# Copy agent code, tools, and shared utilities
|
||||
COPY agents/langgraph-single-agent/langgraph_agent.py .
|
||||
COPY agents/langgraph-single-agent/tools/ tools/
|
||||
COPY agents/utils/ utils/
|
||||
|
||||
# Healthcheck using Python (no extra dependencies needed)
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8080/ping', timeout=2)" || exit 1
|
||||
|
||||
# Start agent with OpenTelemetry instrumentation
|
||||
CMD ["opentelemetry-instrument", "python", "-m", "langgraph_agent"]
|
||||
@@ -0,0 +1,159 @@
|
||||
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
from ag_ui.core import RunAgentInput, RunErrorEvent
|
||||
from bedrock_agentcore.identity.auth import requires_access_token
|
||||
from bedrock_agentcore.runtime import BedrockAgentCoreApp, RequestContext
|
||||
from copilotkit import (
|
||||
CopilotKitMiddleware,
|
||||
LangGraphAGUIAgent,
|
||||
StateStreamingMiddleware,
|
||||
StateItem,
|
||||
)
|
||||
from langchain.agents import create_agent
|
||||
from langchain_aws import ChatBedrock
|
||||
from langchain_mcp_adapters.client import MultiServerMCPClient
|
||||
from langgraph_checkpoint_aws import AgentCoreMemorySaver
|
||||
|
||||
from utils.auth import extract_user_id_from_context
|
||||
from utils.ssm import get_ssm_parameter
|
||||
from tools import query_data, AgentState, todo_tools
|
||||
|
||||
app = BedrockAgentCoreApp()
|
||||
|
||||
ACTOR_ID_KEYS = ("actor_id", "actorId", "user_id", "userId", "sub")
|
||||
|
||||
SYSTEM_PROMPT = """You are a helpful assistant with access to tools via the Gateway and built-in data tools.
|
||||
|
||||
When demonstrating charts, always call the query_data tool first to fetch data from the database before calling any chart tool.
|
||||
When managing todos, use manage_todos to update the list and get_todos to read the current list.
|
||||
When asked about your tools, list them and explain what they do."""
|
||||
|
||||
|
||||
@requires_access_token(
|
||||
provider_name=os.environ["GATEWAY_CREDENTIAL_PROVIDER_NAME"],
|
||||
auth_flow="M2M",
|
||||
scopes=[],
|
||||
)
|
||||
async def _fetch_gateway_token(access_token: str) -> str:
|
||||
return access_token
|
||||
|
||||
|
||||
async def create_gateway_mcp_client() -> MultiServerMCPClient:
|
||||
stack_name = os.environ.get("STACK_NAME")
|
||||
if not stack_name:
|
||||
raise ValueError("STACK_NAME environment variable is required")
|
||||
|
||||
if not stack_name.replace("-", "").replace("_", "").isalnum():
|
||||
raise ValueError("Invalid STACK_NAME format")
|
||||
|
||||
gateway_url = get_ssm_parameter(f"/{stack_name}/gateway_url")
|
||||
fresh_token = await _fetch_gateway_token()
|
||||
|
||||
return MultiServerMCPClient(
|
||||
{
|
||||
"gateway": {
|
||||
"transport": "streamable_http",
|
||||
"url": gateway_url,
|
||||
"headers": {
|
||||
"Authorization": f"Bearer {fresh_token}",
|
||||
},
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _build_model(streaming: bool) -> ChatBedrock:
|
||||
return ChatBedrock(
|
||||
model_id="us.anthropic.claude-sonnet-4-5-20250929-v1:0",
|
||||
temperature=0.1,
|
||||
max_tokens=16384,
|
||||
streaming=streaming,
|
||||
beta_use_converse_api=True,
|
||||
)
|
||||
|
||||
|
||||
def _build_checkpointer() -> AgentCoreMemorySaver:
|
||||
memory_id = os.environ.get("MEMORY_ID")
|
||||
if not memory_id:
|
||||
raise ValueError("MEMORY_ID environment variable is required")
|
||||
|
||||
return AgentCoreMemorySaver(
|
||||
memory_id=memory_id,
|
||||
region_name=os.environ.get("AWS_DEFAULT_REGION", "us-east-1"),
|
||||
)
|
||||
|
||||
|
||||
@app.entrypoint
|
||||
async def invocations(payload: dict, context: RequestContext):
|
||||
input_data = RunAgentInput.model_validate(payload)
|
||||
|
||||
# Extract actor identity securely from the validated JWT token.
|
||||
try:
|
||||
actor_id = extract_user_id_from_context(context)
|
||||
except ValueError:
|
||||
# Fall back to forwarded props if JWT extraction fails (e.g. local dev).
|
||||
forwarded = (
|
||||
input_data.forwarded_props
|
||||
if isinstance(input_data.forwarded_props, dict)
|
||||
else {}
|
||||
)
|
||||
actor_id = next(
|
||||
(forwarded[k] for k in ACTOR_ID_KEYS if k in forwarded and forwarded[k]),
|
||||
None,
|
||||
)
|
||||
|
||||
if not actor_id:
|
||||
raise ValueError(
|
||||
"Missing actor identity. Provide forwardedProps.actor_id/user_id "
|
||||
"or include sub claim in the bearer token."
|
||||
)
|
||||
|
||||
try:
|
||||
try:
|
||||
mcp_client = await create_gateway_mcp_client()
|
||||
gateway_tools = await mcp_client.get_tools()
|
||||
except Exception as gw_err:
|
||||
logging.warning("Gateway tools unavailable (running locally?): %s", gw_err)
|
||||
gateway_tools = []
|
||||
|
||||
graph = create_agent(
|
||||
model=_build_model(streaming=True),
|
||||
tools=[*gateway_tools, query_data, *todo_tools],
|
||||
checkpointer=_build_checkpointer(),
|
||||
middleware=[
|
||||
CopilotKitMiddleware(),
|
||||
StateStreamingMiddleware(
|
||||
StateItem(
|
||||
state_key="todos", tool="manage_todos", tool_argument="todos"
|
||||
)
|
||||
),
|
||||
],
|
||||
system_prompt=SYSTEM_PROMPT,
|
||||
state_schema=AgentState,
|
||||
)
|
||||
|
||||
agent = LangGraphAGUIAgent(
|
||||
name="LangGraphSingleAgent",
|
||||
description="LangGraph single agent exposed via AG-UI",
|
||||
graph=graph,
|
||||
config={"configurable": {"actor_id": actor_id}},
|
||||
)
|
||||
async for event in agent.run(input_data):
|
||||
if event is not None:
|
||||
yield event.model_dump(mode="json", by_alias=True, exclude_none=True)
|
||||
except Exception as exc:
|
||||
logging.exception("Agent run failed")
|
||||
yield RunErrorEvent(
|
||||
message=str(exc) or type(exc).__name__,
|
||||
code=type(exc).__name__,
|
||||
).model_dump(mode="json", by_alias=True, exclude_none=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run()
|
||||
@@ -0,0 +1,14 @@
|
||||
# LangGraph agent dependencies with pinned versions
|
||||
fastapi==0.115.12
|
||||
uvicorn==0.34.2
|
||||
ag-ui-protocol>=0.1.15
|
||||
ag-ui-langgraph==0.0.33
|
||||
copilotkit==0.1.87
|
||||
partialjson>=0.0.8
|
||||
langgraph==1.0.10rc1
|
||||
langchain>=0.3.0
|
||||
langchain-aws==1.0.0
|
||||
langchain-mcp-adapters==0.1.13
|
||||
langgraph-checkpoint-aws==1.0.5
|
||||
mcp==1.23.1
|
||||
bedrock-agentcore==1.0.6
|
||||
@@ -0,0 +1,8 @@
|
||||
# patterns/langgraph-single-agent/tools/__init__.py
|
||||
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from .query_data import query_data
|
||||
from .todos import AgentState, todo_tools
|
||||
|
||||
__all__ = ["query_data", "AgentState", "todo_tools"]
|
||||
@@ -0,0 +1,16 @@
|
||||
date,category,amount,type
|
||||
2026-01-05,Food,42.50,expense
|
||||
2026-01-10,Transport,15.00,expense
|
||||
2026-01-15,Salary,3500.00,income
|
||||
2026-01-20,Entertainment,80.00,expense
|
||||
2026-01-25,Utilities,120.00,expense
|
||||
2026-02-03,Food,55.20,expense
|
||||
2026-02-08,Freelance,800.00,income
|
||||
2026-02-14,Dining,65.00,expense
|
||||
2026-02-20,Transport,22.50,expense
|
||||
2026-02-28,Salary,3500.00,income
|
||||
2026-03-05,Groceries,95.40,expense
|
||||
2026-03-10,Gym,40.00,expense
|
||||
2026-03-15,Salary,3500.00,income
|
||||
2026-03-18,Coffee,18.75,expense
|
||||
2026-03-22,Books,35.00,expense
|
||||
|
@@ -0,0 +1,25 @@
|
||||
# patterns/langgraph-single-agent/tools/query_data.py
|
||||
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import csv
|
||||
from pathlib import Path
|
||||
|
||||
from langchain.tools import tool
|
||||
|
||||
# Read at module load time — avoids file I/O on every tool invocation.
|
||||
_csv_path = Path(__file__).parent / "db.csv"
|
||||
try:
|
||||
with open(_csv_path) as _f:
|
||||
_cached_data = list(csv.DictReader(_f))
|
||||
except (FileNotFoundError, OSError) as e:
|
||||
raise RuntimeError(f"query_data: cannot load sample data from {_csv_path}") from e
|
||||
|
||||
|
||||
@tool
|
||||
def query_data(query: str) -> list[dict]:
|
||||
"""
|
||||
Query the database. Accepts natural language.
|
||||
Always call this tool before displaying a chart or graph.
|
||||
"""
|
||||
return _cached_data
|
||||
@@ -0,0 +1,66 @@
|
||||
# patterns/langgraph-single-agent/tools/todos.py
|
||||
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import uuid
|
||||
from typing import Literal, TypedDict
|
||||
|
||||
from langchain.agents import AgentState as BaseAgentState
|
||||
from langchain.tools import ToolRuntime, tool
|
||||
from langchain_core.messages import ToolMessage
|
||||
from langgraph.types import Command
|
||||
|
||||
# ToolRuntime is confirmed available at langchain.tools (langchain >= 1.2).
|
||||
# If you see an ImportError, verify your langchain version is >= 0.3.
|
||||
|
||||
|
||||
class Todo(TypedDict):
|
||||
id: str
|
||||
title: str
|
||||
description: str
|
||||
emoji: str
|
||||
status: Literal["pending", "completed"]
|
||||
|
||||
|
||||
class AgentState(BaseAgentState):
|
||||
todos: list[Todo]
|
||||
|
||||
|
||||
def _assign_ids(todos: list[dict]) -> list[dict]:
|
||||
"""Assign a uuid4 to any todo that has a missing or empty 'id'."""
|
||||
for todo in todos:
|
||||
if not todo.get("id"):
|
||||
todo["id"] = str(uuid.uuid4())
|
||||
return todos
|
||||
|
||||
|
||||
@tool
|
||||
def manage_todos(todos: list[Todo], runtime: ToolRuntime) -> Command:
|
||||
"""
|
||||
Manage the current todos. Replaces the entire todo list.
|
||||
Assigns a unique UUID to any todo that is missing one.
|
||||
"""
|
||||
_assign_ids(todos) # type: ignore[arg-type]
|
||||
|
||||
return Command(
|
||||
update={
|
||||
"todos": todos,
|
||||
"messages": [
|
||||
ToolMessage(
|
||||
content="Successfully updated todos",
|
||||
tool_call_id=runtime.tool_call_id,
|
||||
)
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@tool
|
||||
def get_todos(runtime: ToolRuntime) -> list[Todo]:
|
||||
"""
|
||||
Get the current todo list from agent state.
|
||||
"""
|
||||
return runtime.state.get("todos", [])
|
||||
|
||||
|
||||
todo_tools = [manage_todos, get_todos]
|
||||
@@ -0,0 +1,36 @@
|
||||
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Configure UV for container environment
|
||||
ENV UV_SYSTEM_PYTHON=1 \
|
||||
UV_COMPILE_BYTECODE=1 \
|
||||
DOCKER_CONTAINER=1 \
|
||||
OTEL_PYTHON_LOG_CORRELATION=true \
|
||||
PYTHONUNBUFFERED=1
|
||||
|
||||
# Copy and install agent-specific requirements first
|
||||
COPY agents/strands-single-agent/requirements.txt requirements.txt
|
||||
RUN uv pip install --no-cache -r requirements.txt && \
|
||||
uv pip install --no-cache aws-opentelemetry-distro==0.16.0
|
||||
|
||||
# Create non-root user
|
||||
RUN useradd -m -u 1000 bedrock_agentcore
|
||||
USER bedrock_agentcore
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
# Copy agent code and shared utilities
|
||||
COPY agents/strands-single-agent/strands_agent.py .
|
||||
COPY agents/strands-single-agent/tools/ tools/
|
||||
COPY agents/utils/ utils/
|
||||
|
||||
# Healthcheck using Python (no extra dependencies needed)
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8080/ping', timeout=2)" || exit 1
|
||||
|
||||
# Start agent with OpenTelemetry instrumentation
|
||||
CMD ["opentelemetry-instrument", "python", "-m", "strands_agent"]
|
||||
@@ -0,0 +1,8 @@
|
||||
# Strands agent dependencies with pinned versions
|
||||
strands-agents==1.24.0
|
||||
mcp==1.26.0
|
||||
bedrock-agentcore[strands-agents]==1.2.0
|
||||
PyJWT[crypto]>=2.10.1
|
||||
ag-ui-protocol>=0.1.10
|
||||
ag-ui-strands==0.1.2
|
||||
fastapi>=0.115.12
|
||||
@@ -0,0 +1,229 @@
|
||||
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import traceback
|
||||
|
||||
from ag_ui.core import RunAgentInput, RunErrorEvent
|
||||
from ag_ui_strands import (
|
||||
StrandsAgent,
|
||||
StrandsAgentConfig,
|
||||
ToolBehavior,
|
||||
PredictStateMapping,
|
||||
)
|
||||
from ag_ui_strands.config import ToolCallContext
|
||||
from bedrock_agentcore.memory.integrations.strands.config import AgentCoreMemoryConfig
|
||||
from bedrock_agentcore.memory.integrations.strands.session_manager import (
|
||||
AgentCoreMemorySessionManager,
|
||||
)
|
||||
from bedrock_agentcore.runtime import BedrockAgentCoreApp, RequestContext
|
||||
from mcp.client.streamable_http import streamablehttp_client
|
||||
from strands import Agent
|
||||
from strands.models import BedrockModel
|
||||
from strands.tools.mcp import MCPClient
|
||||
|
||||
from tools.query_data import query_data
|
||||
from tools.todos import manage_todos
|
||||
from utils.auth import extract_user_id_from_context, get_gateway_access_token
|
||||
from utils.ssm import get_ssm_parameter
|
||||
|
||||
app = BedrockAgentCoreApp()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ACTOR_ID_KEYS = ("actor_id", "actorId", "user_id", "userId", "sub")
|
||||
|
||||
SYSTEM_PROMPT = """You are a helpful assistant with access to tools via the Gateway and built-in data tools.
|
||||
|
||||
When demonstrating charts, always call the query_data tool first to fetch data from the database before calling any chart tool.
|
||||
When managing todos, use manage_todos to update the list.
|
||||
When asked about your tools, list them and explain what they do."""
|
||||
|
||||
BEDROCK_MODEL = BedrockModel(
|
||||
model_id="us.anthropic.claude-sonnet-4-5-20250929-v1:0",
|
||||
temperature=0.1,
|
||||
)
|
||||
|
||||
|
||||
def create_gateway_mcp_client() -> MCPClient:
|
||||
"""
|
||||
Create MCP client for AgentCore Gateway with OAuth2 authentication.
|
||||
|
||||
Calls get_gateway_access_token() inside the lambda factory to ensure a fresh
|
||||
token is fetched on every MCP reconnection (avoids the closure trap).
|
||||
"""
|
||||
stack_name = os.environ.get("STACK_NAME")
|
||||
if not stack_name:
|
||||
raise ValueError("STACK_NAME environment variable is required")
|
||||
|
||||
if not stack_name.replace("-", "").replace("_", "").isalnum():
|
||||
raise ValueError("Invalid STACK_NAME format")
|
||||
|
||||
gateway_url = get_ssm_parameter(f"/{stack_name}/gateway_url")
|
||||
|
||||
return MCPClient(
|
||||
lambda: streamablehttp_client(
|
||||
url=gateway_url,
|
||||
headers={"Authorization": f"Bearer {get_gateway_access_token()}"},
|
||||
),
|
||||
prefix="gateway",
|
||||
)
|
||||
|
||||
|
||||
def create_strands_agent(actor_id: str, session_id: str) -> StrandsAgent:
|
||||
"""
|
||||
Create a StrandsAgent wrapping a Strands SDK agent with AgentCore memory,
|
||||
Gateway MCP tools, and CopilotKit-compatible AG-UI configuration.
|
||||
|
||||
Memory: AgentCoreMemorySessionManager provides cloud-persistent conversation
|
||||
history keyed by actor_id, matching the AgentCoreMemorySaver approach used
|
||||
in the LangGraph pattern.
|
||||
"""
|
||||
memory_id = os.environ.get("MEMORY_ID")
|
||||
if not memory_id:
|
||||
raise ValueError("MEMORY_ID environment variable is required")
|
||||
|
||||
agentcore_memory_config = AgentCoreMemoryConfig(
|
||||
memory_id=memory_id, session_id=session_id, actor_id=actor_id
|
||||
)
|
||||
session_manager = AgentCoreMemorySessionManager(
|
||||
agentcore_memory_config=agentcore_memory_config,
|
||||
region_name=os.environ.get("AWS_DEFAULT_REGION", "us-east-1"),
|
||||
)
|
||||
|
||||
gateway_client = create_gateway_mcp_client()
|
||||
|
||||
# Inject current todos into the system prompt so the agent always knows
|
||||
# the latest todo state without needing a separate get_todos tool.
|
||||
def state_context_builder(state: dict) -> str:
|
||||
todos = state.get("todos", [])
|
||||
if todos:
|
||||
return f"\nCurrent todos:\n{json.dumps(todos, indent=2)}"
|
||||
return ""
|
||||
|
||||
# When manage_todos is called, emit a StateSnapshotEvent with the new todos
|
||||
# so the frontend updates immediately (before the tool result arrives).
|
||||
async def todos_state_from_args(ctx: ToolCallContext) -> dict:
|
||||
todos = (ctx.tool_input or {}).get("todos", [])
|
||||
return {"todos": todos}
|
||||
|
||||
# Frontend tools (generative UI / canvas controls): let the agent continue after
|
||||
# calling them so it generates a proper conclusion text. The run then finishes
|
||||
# naturally and ag_ui_strands sends a MessagesSnapshotEvent that preserves the
|
||||
# chat history. Without continue_after_frontend_call the stream halts and
|
||||
# CopilotKit v2 clears the UI because no snapshot was sent.
|
||||
frontend_tool_behavior = ToolBehavior(
|
||||
continue_after_frontend_call=False,
|
||||
skip_messages_snapshot=False,
|
||||
)
|
||||
|
||||
config = StrandsAgentConfig(
|
||||
tool_behaviors={
|
||||
"manage_todos": ToolBehavior(
|
||||
state_from_args=todos_state_from_args,
|
||||
predict_state=[
|
||||
PredictStateMapping(
|
||||
state_key="todos",
|
||||
tool="manage_todos",
|
||||
tool_argument="todos",
|
||||
)
|
||||
],
|
||||
),
|
||||
"pieChart": frontend_tool_behavior,
|
||||
"barChart": frontend_tool_behavior,
|
||||
"toggleTheme": frontend_tool_behavior,
|
||||
"scheduleTime": frontend_tool_behavior,
|
||||
"enableAppMode": frontend_tool_behavior,
|
||||
"enableChatMode": frontend_tool_behavior,
|
||||
},
|
||||
state_context_builder=state_context_builder,
|
||||
)
|
||||
|
||||
# Build the underlying Strands agent with persistent memory and tools.
|
||||
core_agent = Agent(
|
||||
name="FASTAgent",
|
||||
system_prompt=SYSTEM_PROMPT,
|
||||
tools=[gateway_client, query_data, manage_todos],
|
||||
model=BEDROCK_MODEL,
|
||||
session_manager=session_manager,
|
||||
record_direct_tool_call=True,
|
||||
trace_attributes={
|
||||
"user.id": actor_id,
|
||||
"session.id": session_id,
|
||||
},
|
||||
)
|
||||
|
||||
strands_agent = StrandsAgent(
|
||||
agent=core_agent,
|
||||
name="FASTAgent",
|
||||
description="FAST Strands agent with CopilotKit generative UI support",
|
||||
config=config,
|
||||
)
|
||||
|
||||
# Pre-seed the per-thread agent cache so StrandsAgent.run() uses our
|
||||
# core_agent (which has AgentCoreMemorySessionManager) rather than creating
|
||||
# a new instance without it.
|
||||
strands_agent._agents_by_thread[session_id] = core_agent
|
||||
|
||||
return strands_agent
|
||||
|
||||
|
||||
@app.entrypoint
|
||||
async def invocations(payload: dict, context: RequestContext):
|
||||
"""
|
||||
Main entrypoint for the Strands agent using AG-UI protocol.
|
||||
|
||||
Accepts RunAgentInput payloads from the CopilotKit Lambda Runtime,
|
||||
streams AG-UI events back, and supports generative UI, shared state
|
||||
(todos), and human-in-the-loop interactions via CopilotKit.
|
||||
"""
|
||||
input_data = RunAgentInput.model_validate(payload)
|
||||
|
||||
# Extract actor identity securely from the validated JWT token.
|
||||
try:
|
||||
actor_id = extract_user_id_from_context(context)
|
||||
except ValueError:
|
||||
# Fall back to forwarded props if JWT extraction fails (e.g. local dev).
|
||||
forwarded = (
|
||||
input_data.forwarded_props
|
||||
if isinstance(input_data.forwarded_props, dict)
|
||||
else {}
|
||||
)
|
||||
actor_id = next(
|
||||
(forwarded[k] for k in ACTOR_ID_KEYS if k in forwarded and forwarded[k]),
|
||||
None,
|
||||
)
|
||||
|
||||
if not actor_id:
|
||||
raise ValueError(
|
||||
"Missing actor identity. Provide forwardedProps.actor_id/user_id "
|
||||
"or include sub claim in the bearer token."
|
||||
)
|
||||
|
||||
# Use thread_id from the request (set by CopilotKit runtime) or fall back
|
||||
# to actor_id so each user gets their own persistent conversation thread.
|
||||
session_id = input_data.thread_id or actor_id
|
||||
|
||||
# Ensure thread_id in the payload matches so StrandsAgent uses our pre-seeded agent.
|
||||
input_data = input_data.model_copy(update={"thread_id": session_id})
|
||||
|
||||
try:
|
||||
strands_agent = create_strands_agent(actor_id, session_id)
|
||||
|
||||
async for event in strands_agent.run(input_data):
|
||||
if event is not None:
|
||||
yield event.model_dump(mode="json", by_alias=True, exclude_none=True)
|
||||
|
||||
except Exception as exc:
|
||||
logger.exception("Agent run failed")
|
||||
yield RunErrorEvent(
|
||||
message=str(exc) or type(exc).__name__,
|
||||
code=type(exc).__name__,
|
||||
).model_dump(mode="json", by_alias=True, exclude_none=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run()
|
||||
@@ -0,0 +1,4 @@
|
||||
from .query_data import query_data
|
||||
from .todos import manage_todos, Todo
|
||||
|
||||
__all__ = ["query_data", "manage_todos", "Todo"]
|
||||
@@ -0,0 +1,16 @@
|
||||
date,category,amount,type
|
||||
2026-01-05,Food,42.50,expense
|
||||
2026-01-10,Transport,15.00,expense
|
||||
2026-01-15,Salary,3500.00,income
|
||||
2026-01-20,Entertainment,80.00,expense
|
||||
2026-01-25,Utilities,120.00,expense
|
||||
2026-02-03,Food,55.20,expense
|
||||
2026-02-08,Freelance,800.00,income
|
||||
2026-02-14,Dining,65.00,expense
|
||||
2026-02-20,Transport,22.50,expense
|
||||
2026-02-28,Salary,3500.00,income
|
||||
2026-03-05,Groceries,95.40,expense
|
||||
2026-03-10,Gym,40.00,expense
|
||||
2026-03-15,Salary,3500.00,income
|
||||
2026-03-18,Coffee,18.75,expense
|
||||
2026-03-22,Books,35.00,expense
|
||||
|
@@ -0,0 +1,21 @@
|
||||
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import csv
|
||||
import os
|
||||
from strands import tool
|
||||
|
||||
|
||||
@tool
|
||||
def query_data(query: str) -> str:
|
||||
"""
|
||||
Query financial data from the database. Use this tool to fetch data before
|
||||
rendering any charts. Returns CSV-formatted data relevant to the query.
|
||||
"""
|
||||
db_path = os.path.join(os.path.dirname(__file__), "db.csv")
|
||||
try:
|
||||
with open(db_path, "r") as f:
|
||||
content = f.read()
|
||||
return content
|
||||
except FileNotFoundError:
|
||||
return "No data available."
|
||||
@@ -0,0 +1,23 @@
|
||||
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from typing import Literal, TypedDict
|
||||
|
||||
from strands import tool
|
||||
|
||||
|
||||
class Todo(TypedDict):
|
||||
id: str
|
||||
title: str
|
||||
description: str
|
||||
emoji: str
|
||||
status: Literal["pending", "completed"]
|
||||
|
||||
|
||||
@tool
|
||||
def manage_todos(todos: list) -> str:
|
||||
"""
|
||||
Manage the current todos. Replaces the entire todo list.
|
||||
Each todo should have: id (str), title (str), description (str), emoji (str), status ('pending' or 'completed').
|
||||
"""
|
||||
return "Todos updated successfully"
|
||||
@@ -0,0 +1,100 @@
|
||||
"""
|
||||
Authentication utilities for agent patterns.
|
||||
|
||||
Provides secure user identity extraction from JWT tokens in the AgentCore Runtime
|
||||
RequestContext (prevents impersonation via prompt injection).
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
import jwt
|
||||
from bedrock_agentcore.identity.auth import requires_access_token
|
||||
from bedrock_agentcore.runtime import RequestContext
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def extract_user_id_from_context(context: RequestContext) -> str:
|
||||
"""
|
||||
Securely extract the user ID from the JWT token in the request context.
|
||||
|
||||
AgentCore Runtime validates the JWT token before passing it to the agent,
|
||||
so we can safely skip signature verification here. The user ID is taken
|
||||
from the token's 'sub' claim rather than from the request payload, which
|
||||
prevents impersonation via prompt injection.
|
||||
|
||||
Args:
|
||||
context (RequestContext): The request context provided by AgentCore
|
||||
Runtime, containing validated request headers including the
|
||||
Authorization JWT.
|
||||
|
||||
Returns:
|
||||
str: The user ID (sub claim) extracted from the validated JWT token.
|
||||
|
||||
Raises:
|
||||
ValueError: If the Authorization header is missing or the JWT does
|
||||
not contain a 'sub' claim.
|
||||
"""
|
||||
request_headers = context.request_headers
|
||||
if not request_headers:
|
||||
raise ValueError(
|
||||
"No request headers found in context. "
|
||||
"Ensure the AgentCore Runtime is configured with a request header allowlist "
|
||||
"that includes the Authorization header."
|
||||
)
|
||||
|
||||
auth_header = request_headers.get("Authorization")
|
||||
if not auth_header:
|
||||
raise ValueError(
|
||||
"No Authorization header found in request context. "
|
||||
"Ensure the AgentCore Runtime is configured with JWT inbound auth "
|
||||
"and the Authorization header is in the request header allowlist."
|
||||
)
|
||||
|
||||
# Remove "Bearer " prefix to get the raw JWT token
|
||||
token = (
|
||||
auth_header.replace("Bearer ", "")
|
||||
if auth_header.startswith("Bearer ")
|
||||
else auth_header
|
||||
)
|
||||
|
||||
# Decode without signature verification — AgentCore Runtime already validated the token.
|
||||
# We use options to skip all verification since this is a trusted, pre-validated token.
|
||||
claims = jwt.decode(
|
||||
jwt=token,
|
||||
options={"verify_signature": False},
|
||||
algorithms=["RS256"],
|
||||
)
|
||||
|
||||
user_id = claims.get("sub")
|
||||
if not user_id:
|
||||
raise ValueError(
|
||||
"JWT token does not contain a 'sub' claim. Cannot determine user identity."
|
||||
)
|
||||
|
||||
logger.info("Extracted user_id from JWT: %s", user_id)
|
||||
return user_id
|
||||
|
||||
|
||||
@requires_access_token(
|
||||
provider_name=os.environ.get("GATEWAY_CREDENTIAL_PROVIDER_NAME", ""),
|
||||
auth_flow="M2M",
|
||||
scopes=[],
|
||||
)
|
||||
def get_gateway_access_token(access_token: str) -> str:
|
||||
"""
|
||||
Fetch OAuth2 access token for AgentCore Gateway authentication.
|
||||
|
||||
The @requires_access_token decorator handles token retrieval and refresh:
|
||||
1. Token Retrieval: Calls GetResourceOauth2Token API to fetch token from Token Vault
|
||||
2. Automatic Refresh: Uses refresh tokens to renew expired access tokens
|
||||
3. Error Orchestration: Handles missing tokens and OAuth flow management
|
||||
|
||||
For M2M (Machine-to-Machine) flows, the decorator uses Client Credentials grant type.
|
||||
The provider_name must match the Name field in the CDK OAuth2CredentialProvider resource.
|
||||
|
||||
This is synchronous because it's called during agent setup before the async
|
||||
message processing loop.
|
||||
"""
|
||||
return access_token
|
||||
@@ -0,0 +1,45 @@
|
||||
"""
|
||||
SSM Parameter Store utilities for agent patterns.
|
||||
|
||||
Provides a single shared function for fetching parameters from AWS SSM
|
||||
Parameter Store, used by agents to retrieve configuration values like
|
||||
Gateway URLs that are set during deployment.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
import boto3
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_ssm_parameter(parameter_name: str) -> str:
|
||||
"""
|
||||
Fetch a parameter value from AWS SSM Parameter Store.
|
||||
|
||||
SSM Parameter Store is AWS's service for storing configuration values
|
||||
securely. This function retrieves values like Gateway URLs and other
|
||||
stack-specific configuration that are set during CDK deployment.
|
||||
|
||||
Args:
|
||||
parameter_name (str): The full SSM parameter name/path
|
||||
(e.g. '/my-stack/gateway_url').
|
||||
|
||||
Returns:
|
||||
str: The parameter value.
|
||||
|
||||
Raises:
|
||||
ValueError: If the parameter is not found or cannot be retrieved.
|
||||
"""
|
||||
region = os.environ.get(
|
||||
"AWS_REGION", os.environ.get("AWS_DEFAULT_REGION", "us-east-1")
|
||||
)
|
||||
ssm = boto3.client("ssm", region_name=region)
|
||||
try:
|
||||
response = ssm.get_parameter(Name=parameter_name)
|
||||
return response["Parameter"]["Value"]
|
||||
except ssm.exceptions.ParameterNotFound:
|
||||
raise ValueError(f"SSM parameter not found: {parameter_name}")
|
||||
except Exception as e:
|
||||
raise ValueError(f"Failed to retrieve SSM parameter {parameter_name}: {e}")
|
||||
Reference in New Issue
Block a user