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]
|
||||
Reference in New Issue
Block a user