DEV Community

Build a Memory-Powered Multi-Agent Financial Advisor with Strands SDK & Amazon Bedrock

Why Agents — Not Just Chatbots

A chatbot answers your question and forgets you. An AI agent takes actions, uses tools, and loops until the job is actually done. The difference isn't the model, it's the architecture.

The agent loop has four steps:

  1. Perceive- read the user's request plus any injected context (memory, RAG results, past turns)
  2. Plan- reason about what to do next; choose which tool(s) to call
  3. Act- call the chosen tool(s), APIs, or sub-agents
  4. Reflect- evaluate the results; decide whether the task is done or the loop should continue

Unlike a simple LLM call, the agent decides autonomously when it has gathered enough information to answer and it keeps going until it has.

The Project: A Multi-Agent Financial Advisor

To make this concrete, we'll build a memory-powered multi-agent financial advisor. It:

  • Accepts natural-language questions ("Should I rebalance my portfolio?")
  • Delegates to specialist sub-agents for portfolio data, market prices, and financial news
  • Uses Amazon Bedrock Guardrails to enforce compliance rules at every step
  • Stores client preferences and past conversations using Amazon Bedrock AgentCore Memory
  • Deploys as a serverless endpoint with a single agentcore deploy command

The full stack:

Layer Technology
Agent framework Strands Agents SDK (open-source)
Foundation model Claude 3.5 Sonnet via Amazon Bedrock
Safety layer Amazon Bedrock Guardrails
Memory layer Amazon Bedrock AgentCore Memory
Runtime Amazon Bedrock AgentCore Runtime
Observability AWS CloudWatch + OpenTelemetry

The orchestrator agent receives every user request and intelligently routes sub-tasks to the right specialist. Results are synthesized into a single, coherent response.

Meet the Strands Agents SDK

Strands is an open-source Python framework from AWS Labs that makes building production agents dramatically simpler. You define tools, wrap them in an Agent, and Strands handles the loop.

pip install strands-agents

The core idea is minimal boilerplate. Three imports and a decorator are enough to expose any Python function to an agent as a callable tool.

Step 1 — Define Tools with @tool

The @tool decorator does the heavy lifting automatically:

  • Parses Python type hints into a JSON schema the model can read
  • Converts the docstring into the tool's description (what the agent uses to decide when to call it)
  • Handles invocation so the agent decides when, and with what arguments, to call the function
# tools/financial_tools.py
from strands import tool
from typing import Optional

@tool
def get_portfolio_value(
    client_id: str,
    include_unrealized_gains: bool = True
) -> dict:
    """Retrieve the current portfolio value and positions for a client."""
    return fetch_portfolio_data(client_id, include_unrealized_gains)


@tool
def get_market_data(tickers: list[str], period: str = "1d") -> dict:
    """Retrieve current market prices and key metrics for a list of tickers."""
    return fetch_market_prices(tickers, period)


@tool
def get_financial_news(query: str, max_results: int = 5) -> dict:
    """Retrieve recent financial news articles relevant to a query."""
    return fetch_news_feed(query, max_results)


@tool
def get_risk_analysis(
    client_id: str,
    risk_tolerance: Optional[str] = None
) -> dict:
    """Perform a risk analysis on a client's portfolio."""
    return compute_risk_metrics(client_id, risk_tolerance)


@tool
def get_investment_recommendations(
    client_id: str,
    goal: str = "growth",
    time_horizon_years: int = 10
) -> dict:
    """Generate personalised investment recommendations for a client."""
    return generate_recommendations(client_id, goal, time_horizon_years)
Enter fullscreen mode Exit fullscreen mode

Each function becomes a first-class tool — no YAML, no schema files, no extra config.


Step 2- Build Specialist Sub-Agents

Rather than one monolithic agent with 15 tools, we split responsibilities. Each specialist agent gets only the tools it needs, which keeps the system prompt focused and improves tool selection accuracy.

# agents/specialists.py
from strands import Agent
from strands.models import BedrockModel
from tools.financial_tools import (
    get_portfolio_value, get_risk_analysis,
    get_market_data, get_financial_news,
)

MODEL_ID = "us.anthropic.claude-3-5-sonnet-20241022-v2:0"

def _make_model(guardrail_id: str | None) -> BedrockModel:
    kwargs = {"model_id": MODEL_ID, "region_name": "us-east-1"}
    if guardrail_id:
        kwargs["guardrail_id"] = guardrail_id
        kwargs["guardrail_version"] = "DRAFT"
    return BedrockModel(**kwargs)


def create_portfolio_agent(guardrail_id: str | None = None) -> Agent:
    return Agent(
        model=_make_model(guardrail_id),
        system_prompt=(
            "You are a Portfolio Specialist. Analyse holdings, risk metrics, "
            "and P&L. Be precise and cite specific numbers."
        ),
        tools=[get_portfolio_value, get_risk_analysis],
    )


def create_market_data_agent(guardrail_id: str | None = None) -> Agent:
    return Agent(
        model=_make_model(guardrail_id),
        system_prompt=(
            "You are a Market Data Specialist. Provide current prices, "
            "52-week ranges, and key indices. Stick to facts."
        ),
        tools=[get_market_data],
    )


def create_news_agent(guardrail_id: str | None = None) -> Agent:
    return Agent(
        model=_make_model(guardrail_id),
        system_prompt=(
            "You are a Financial News Analyst. Summarise recent news and "
            "provide sentiment analysis. Be objective."
        ),
        tools=[get_financial_news],
    )
Enter fullscreen mode Exit fullscreen mode

Step 3- The Orchestrator & Agent-as-Tool Pattern

The orchestrator doesn't call specialist agents directly, it treats them as tools. This is the agent-as-tool pattern: each sub-agent is wrapped in a @tool function, so the orchestrator's model reasons about which specialist to consult, exactly like it would choose any other tool.

# agents/orchestrator.py
from strands import Agent, tool
from strands.models import BedrockModel
from agents.specialists import (
    create_portfolio_agent,
    create_market_data_agent,
    create_news_agent,
)
from tools.financial_tools import get_investment_recommendations


def _build_specialist_tools(guardrail_id: str | None):
    portfolio_agent = create_portfolio_agent(guardrail_id)
    market_agent   = create_market_data_agent(guardrail_id)
    news_agent     = create_news_agent(guardrail_id)

    @tool
    def ask_portfolio_agent(query: str) -> str:
        """Delegate a portfolio analysis or risk question to the Portfolio Specialist Agent."""
        return str(portfolio_agent(query))

    @tool
    def ask_market_data_agent(query: str) -> str:
        """Delegate a market data or pricing question to the Market Data Specialist Agent."""
        return str(market_agent(query))

    @tool
    def ask_news_agent(query: str) -> str:
        """Delegate a news or sentiment analysis question to the News Analyst Agent."""
        return str(news_agent(query))

    return [ask_portfolio_agent, ask_market_data_agent, ask_news_agent]


ORCHESTRATOR_PROMPT = """
You are a Senior Financial Advisor AI. Your role is to provide comprehensive,
personalised financial guidance. You have three specialist agents available:

- Portfolio Specialist: portfolio values, positions, risk metrics
- Market Data Specialist: prices, indices, volatility
- News Analyst: financial news and sentiment

Always delegate to the right specialist(s), then synthesise their findings
into a clear, actionable response for the client.
"""


def create_financial_advisor(
    guardrail_id: str | None = None,
    memory_context: str = "",
    model_id: str = "us.anthropic.claude-3-5-sonnet-20241022-v2:0",
) -> Agent:
    specialist_tools = _build_specialist_tools(guardrail_id)

    system_prompt = ORCHESTRATOR_PROMPT
    if memory_context:
        system_prompt += f"\n\n## Client Memory\n{memory_context}"

    model_kwargs = {"model_id": model_id, "region_name": "us-east-1"}
    if guardrail_id:
        model_kwargs.update({"guardrail_id": guardrail_id, "guardrail_version": "DRAFT"})

    return Agent(
        model=BedrockModel(**model_kwargs),
        system_prompt=system_prompt,
        tools=specialist_tools + [get_investment_recommendations],
    )
Enter fullscreen mode Exit fullscreen mode

When the user asks "Should I rebalance my portfolio given today's market?", the orchestrator:

  1. Calls ask_portfolio_agent → gets current holdings & risk score
  2. Calls ask_market_data_agent → gets today's index moves
  3. Calls ask_news_agent → gets relevant headlines
  4. Synthesizes everything into a single coherent recommendation

Step 4- Persistent Memory Across Sessions

Without memory, every conversation starts cold. AgentCore Memory gives the agent a persistent, semantic store, the agent remembers past interactions and client preferences across sessions.

# memory/memory_manager.py
import boto3
from dataclasses import dataclass, field


class MemoryManager:
    """
    Wraps Amazon Bedrock AgentCore Memory with a local in-process fallback
    so the demo runs without any AWS credentials.
    """

    def __init__(
        self,
        memory_id: str | None = None,
        session_id: str | None = None,
        region: str = "us-east-1",
        use_local_fallback: bool = False,
    ):
        self.memory_id = memory_id
        self.session_id = session_id or "default"
        self.use_local = use_local_fallback or (memory_id is None)
        self._local_memory: list[dict] = []

        if not self.use_local:
            self._client = boto3.client("bedrock-agentcore", region_name=region)

    def save_turn(self, user_input: str, agent_response: str) -> None:
        """Persist a conversation turn."""
        if self.use_local:
            self._local_memory.append(
                {"user": user_input, "assistant": agent_response}
            )
            return

        self._client.save_memory(
            memoryId=self.memory_id,
            sessionId=self.session_id,
            messages=[
                {"role": "user",      "content": user_input},
                {"role": "assistant", "content": agent_response},
            ],
        )

    def get_recent_context(self, max_turns: int = 5, query: str | None = None) -> str:
        """Return a formatted string of recent memory to inject into the system prompt."""
        if self.use_local:
            recent = self._local_memory[-max_turns:]
            if not recent:
                return ""
            lines = ["Previous conversation context:"]
            for turn in recent:
                lines.append(f"User: {turn['user']}")
                lines.append(f"Assistant: {turn['assistant'][:200]}...")
            return "\n".join(lines)

        response = self._client.retrieve_memory(
            memoryId=self.memory_id,
            sessionId=self.session_id,
            query=query or "recent client interactions",
            maxResults=max_turns,
        )
        return "\n".join(r["content"] for r in response.get("results", []))
Enter fullscreen mode Exit fullscreen mode

Memory context is injected into the orchestrator's system prompt on every turn:

memory = MemoryManager(use_local_fallback=True)  # swap to AWS for prod
context = memory.get_recent_context(query=user_input)

advisor = create_financial_advisor(memory_context=context)
response = advisor(user_input)

memory.save_turn(user_input, str(response))
Enter fullscreen mode Exit fullscreen mode

Why this matters: "Persistent memory lets the agent remember past conversations and client preferences, enabling truly personalized financial guidance at scale."


Step 5- Compliance with Bedrock Guardrails

Financial applications have strict compliance requirements: no guaranteed return promises, no off-topic advice, PII must be redacted. Bedrock Guardrails handles all of this at the infrastructure level, zero code changes in your agent logic.

Guardrails are applied at both the input and output layers:

  • Input filter: blocks prompt injection, off-topic requests (medical, legal), and PII before the LLM ever sees them
  • Output filter: checks grounding, redacts any leaked PII, blocks harmful or hallucinated content

Here's how we configure it:

# guardrails/config.py
import boto3
from dataclasses import dataclass, field
from typing import Optional


@dataclass
class GuardrailsConfig:
    name: str = "financial-advisor-guardrail"
    content_filter_strength: str = "HIGH"

    denied_topics: list[dict] = field(default_factory=lambda: [
        {"name": "Non-Financial Advice",
         "definition": "Any advice outside personal finance, investments, or markets.",
         "examples": ["medical diagnosis", "legal advice", "relationship counselling"]},
        {"name": "Guaranteed Returns",
         "definition": "Claims that any investment guarantees specific returns.",
         "examples": ["guaranteed 20% return", "risk-free investment"]},
    ])

    pii_redaction_types: list[str] = field(default_factory=lambda: [
        "US_SOCIAL_SECURITY_NUMBER",
        "CREDIT_DEBIT_CARD_NUMBER",
        "US_BANK_ACCOUNT_NUMBER",
        "EMAIL",
        "PHONE",
        "NAME",
    ])

    blocked_words: list[str] = field(default_factory=lambda: [
        "guaranteed returns",
        "risk-free investment",
        "100% safe",
        "cannot lose",
    ])

    enable_grounding_check: bool = True
    grounding_threshold: float = 0.7


def get_or_create_guardrail(
    config: GuardrailsConfig | None = None,
    region: str = "us-east-1",
) -> Optional[str]:
    """Create (or retrieve existing) guardrail and return its ID."""
    config = config or GuardrailsConfig()
    client = boto3.client("bedrock", region_name=region)

    # Check if it already exists
    for g in client.list_guardrails().get("guardrails", []):
        if g["name"] == config.name:
            return g["id"]

    response = client.create_guardrail(
        name=config.name,
        contentPolicyConfig={
            "filtersConfig": [
                {"type": "SEXUAL",   "inputStrength": config.content_filter_strength,
                 "outputStrength": config.content_filter_strength},
                {"type": "VIOLENCE", "inputStrength": config.content_filter_strength,
                 "outputStrength": config.content_filter_strength},
                {"type": "HATE",     "inputStrength": config.content_filter_strength,
                 "outputStrength": config.content_filter_strength},
            ]
        },
        topicPolicyConfig={"topicsConfig": config.denied_topics},
        sensitiveInformationPolicyConfig={
            "piiEntitiesConfig": [
                {"type": t, "action": "ANONYMIZE"}
                for t in config.pii_redaction_types
            ]
        },
        wordPolicyConfig={
            "wordsConfig": [{"text": w} for w in config.blocked_words]
        },
        groundingPolicyConfig={
            "filtersConfig": [
                {"type": "GROUNDING",    "threshold": config.grounding_threshold},
                {"type": "RELEVANCE",    "threshold": config.grounding_threshold},
            ]
        } if config.enable_grounding_check else {},
    )
    return response["guardrailId"]
Enter fullscreen mode Exit fullscreen mode

Attach the guardrail ID to the BedrockModel and every request, input AND output, is automatically screened.


Step 6- Deploy to Production with AgentCore Runtime

AgentCore Runtime is a serverless execution environment for agents. It handles auto-scaling, session management, health checks, and observability out of the box.


The deployment config lives in agentcore.json:

{
  "name": "financial-advisor-agent",
  "runtime": "python3.12",
  "handler": "main.handler",
  "model": {
    "provider": "bedrock",
    "model_id": "us.anthropic.claude-3-5-sonnet-20241022-v2:0"
  },
  "memory": {
    "enabled": true,
    "type": "agentcore",
    "session_ttl_days": 90
  },
  "guardrails": {
    "enabled": true,
    "guardrail_name": "financial-advisor-guardrail",
    "apply_to": ["input", "output"]
  },
  "scaling": {
    "min_instances": 1,
    "max_instances": 10,
    "target_concurrency": 5
  },
  "observability": {
    "tracing": "xray",
    "metrics": "cloudwatch",
    "log_level": "INFO"
  }
}
Enter fullscreen mode Exit fullscreen mode

Three CLI commands take you from code to production:

# Develop locally with hot-reload
agentcore dev
# → Local agent running at http://localhost:8080

# Deploy to Amazon Bedrock AgentCore Runtime
agentcore deploy
# → Packaging agent... done
# → Provisioning CDK stack... done
# → Attaching guardrails... done
# → Configuring AgentCore Memory... done
# → Deployed! Endpoint: https://bedrock-agentcore.us-east-1.amazonaws.com/agents/fa-demo

# Invoke the deployed agent
agentcore invoke "What is the portfolio value for client ABC123?" --stream
Enter fullscreen mode Exit fullscreen mode

Putting It All Together- Running the Demo

Clone the repo and run the demo locally (no AWS credentials required):

git clone https://github.com/awslabs/agentcore-samples
cd agentcore-samples/financial-advisor

pip install -r requirements.txt

# Local demo with mock data
python main.py

# Full AWS mode with real Bedrock, Memory, and Guardrails
python main.py --use-aws --enable-guardrails

# Interactive REPL
python main.py --interactive
Enter fullscreen mode Exit fullscreen mode

The scripted demo walks through six representative turns:

[1/6] What is the total portfolio value for client ABC123?
  → Portfolio Specialist retrieves holdings + P&L

[2/6] What are the current prices for AAPL, GOOGL, and MSFT?
  → Market Data Specialist fetches live prices + 52w range

[3/6] What's the latest news about Apple and Microsoft?
  → News Analyst returns headlines + sentiment scores

[4/6] What's the risk level of my current portfolio?
  → Portfolio Specialist runs risk decomposition

[5/6] Based on everything, should I rebalance?
  → Orchestrator calls all three specialists + synthesises

[6/6] Given what I told you about my risk preference earlier, any concerns?
  → Memory-powered: agent recalls previous turns and personalises
Enter fullscreen mode Exit fullscreen mode

Key Takeaways

The financial advisor demo shows how modern agent patterns come together in a coherent production architecture:

Strands SDK makes agents simple. The @tool decorator, Agent class, and BedrockModel eliminate most boilerplate. You describe your tools in plain Python; the SDK handles the rest.

Specialisation beats monoliths. Breaking the system into focused sub-agents — each with its own system prompt and tool set — produces sharper, more reliable answers than a single agent trying to do everything.

The agent-as-tool pattern is powerful. Wrapping sub-agents as @tool functions lets the orchestrator's model reason about delegation using the same mechanism it uses for everything else. No custom routing logic required.

Guardrails are infrastructure, not code. Attaching a Bedrock Guardrail to BedrockModel protects every single request- input AND output — without any changes to your agent logic. Compliance becomes a deployment concern, not a development one.

Memory makes agents personal. Injecting past conversations into the system prompt at runtime is simple but transformative. The agent genuinely remembers- and users notice immediately.

agentcore deploy is production-ready. Serverless execution, auto-scaling, integrated tracing, and health management are all included. Going from local dev to a live endpoint is three CLI commands.


Resources


Built with ❤️ using open-source tools and AWS. Questions or feedback? Drop them in the comments below.

Top comments (0)