💵 Tip & Split Agent#
💵 Tip & Split Agent lets a Strands agent read a bill question in plain English, pull out the bill, tip % and number of people, and call one tool, calculate_tip(bill_amount, tip_percentage, num_people).
How It Works#
The idea behind the demo, the request it sends and the function calls that answer it.
Concept#
Tip Calculator Agent illustrates two core principles of reliable tool design: semantic parameter extraction from free-form language and offloading exact arithmetic to deterministic code.
Whether a user writes "The bill is $85, 20% tip, split 4 ways", "Dinner was 120
dollars for 3 of us with an 18 percent tip", or simply "Tip on $50", the Bedrock
agent maps those varied phrasings into a single typed call to
calculate_tip(bill_amount: float, tip_percent: float = 15.0, num_people: int = 1).
Python performs the exact floating-point math and returns a structured dictionary of rounded
dollar amounts that the agent summarizes in natural prose.
Theory#
1. Intent and Entity Extraction Without Regex
Before LLMs, parsing a sentence like "We split an $85.50 tab four ways after adding twenty
percent" required brittle regular expressions and number-word parsers. With tool calling,
the LLM acts as a universal semantic parser that extracts entities, converts them to typed
numbers (bill_amount=85.5, tip_percent=20.0,
num_people=4), and invokes the tool.
2. Optional Parameters via Python Default Values
When Strands introspects def calculate_tip(bill_amount: float, tip_percent: float = 15.0,
num_people: int = 1), only bill_amount is marked as required in
the generated JSON Schema. If the user says "What's the tip on a $64 bill?" without
specifying a percentage or party size, the tool safely falls back to a 15% tip for 1 person.
3. Division of Labor: Language vs. Arithmetic
LLMs predict tokens probabilistically and regularly make rounding or division mistakes on
non-trivial numbers (e.g., $137.45 × 1.18 / 7). Offloading the calculation to
Python guarantees cent-level accuracy (round(total / num_people, 2)) while keeping
the user interface conversational.
4. Structured Dict Return Values
Returning a self-describing dictionary ({"bill": ..., "tip_percent": ..., "tip_amount":
..., "total": ..., "per_person": ...}) gives the model labelled fields to read on the
observation step, preventing it from confusing the total bill with the per-person share.
Request flow#
index.html + main.js sends POST /api/tip-calculator with { query }.
app.py)
tip_calc_route() calls run_tip_calculator_agent(query).
04_tip_calculator.py)
Bedrock extracts the numbers and calls calculate_tip; Strands runs it.
toolUse block into tool_calls.
calculate_tip call with its extracted arguments.
Code explanation#
1. Core AI & Tool Components in 04_tip_calculator.py
- Pydantic Contracts (
TipCalculatorRequest&TipCalculatorResponse): Define the inputquery: strand output dictionary fields (query: str,response: str,tool_calls: list[str]). - Step
②Model Identifier (model_id): Resolves the target Bedrock model ID, defaulting to"amazon.nova-lite-v1:0". @tool def calculate_tip(bill_amount: float, tip_percentage: float, num_people: int = 1) -> dict:- Parameters & Schema Generation: Strands inspects the three typed parameters. Because
bill_amount: floatandtip_percentage: floathave no default values, they are markedrequiredin the Bedrock JSON Schema, whilenum_people: int = 1is marked optional with a default of1. - Deterministic Math: Computes
tip = bill_amount * (tip_percentage / 100),total = bill_amount + tip, andper_person = total / num_people. - Return Value: Returns a dictionary with rounded cent values:
{"bill": bill_amount, "tip": round(tip, 2), "total": round(total, 2), "per_person": round(per_person, 2)}.
- Parameters & Schema Generation: Strands inspects the three typed parameters. Because
run_tip_calculator_agent(query: str) -> dict:- Step
③: InstantiatesAgent(model=BedrockModel(model_id=model_id), tools=[calculate_tip]). - Step
④: Callsresponse = agent(query)so the LLM extracts the numeric parameters from plain English and executescalculate_tip. - Step
⑤: Extracts every"toolUse"block fromagent.messagesintotool_callsand returns{"query": query, "response": str(response), "tool_calls": tool_calls}.
- Step
2. End-to-End AI Execution Walkthrough
run_tip_calculator_agent(query)creates a fresh StrandsAgentbound tocalculate_tipand callsagent(query)(e.g., for"The bill is $85. What's a 20% tip, and how much does each person pay if we're splitting it 4 ways?").- On Turn 1, Bedrock Nova Lite parses the natural-language query and emits a
toolUseblock:calculate_tip({"bill_amount": 85.0, "tip_percentage": 20.0, "num_people": 4}). - Strands invokes
calculate_tip(85.0, 20.0, 4)locally in Python, which returns{"bill": 85.0, "tip": 17.0, "total": 102.0, "per_person": 25.5}as atoolResultblock. - On Turn 2, Bedrock formats those exact numbers into a friendly breakdown ($17.00 tip, $102.00 total, $25.50 per person).
- Step
⑤records the tool call intool_callsand returns{"query": query, "response": str(response), "tool_calls": tool_calls}.
Code flow#
POST /api/tip-calculator"]:::entry -->|"query"| R["app.py
tip_calc_route()"]:::fn R -->|"query"| S["04_tip_calculator.py
run_tip_calculator_agent()"]:::fn S -->|"prompt"| A["Strands Agent
agent(prompt)"]:::fn A -->|"messages + tool specs"| B[("Amazon Bedrock
Nova Lite")]:::ext B -->|"toolUse: calculate_tip"| A A -->|"bill, tip %, people"| T0["calculate_tip()"]:::fn T0 -->|"bill, tip, total, per_person"| A A -->|"toolResult"| B B -->|"final text"| A A -->|"reply + agent.messages"| S S -->|"reply + tool_calls"| R R -->|"JSON"| UI classDef entry fill:#1e3a5f,stroke:#90caf9,color:#e0e0e0 classDef fn fill:#1e1e1e,stroke:#4caf50,color:#e0e0e0 classDef ext fill:#2d1f3d,stroke:#ce93d8,color:#e0e0e0 classDef ret fill:#1b3320,stroke:#81c784,color:#e0e0e0
Source Code#
Every Python file this demo runs, complete and unedited: the service implementation first, then the Flask Blueprint route handler (app.py) that exposes it.
04_tip_calculator.py#
Tip Calculator Agent Service.
"""Tip Calculator Agent Service.
Demonstrates intent-driven tool invocation over keyword matching:
1. Agent understands natural language intent regardless of phrasing.
2. Extracts numeric parameters: bill amount, tip percentage, and number of people.
3. Invokes `calculate_tip(bill_amount, tip_percentage, num_people)`.
4. Returns computed totals and split amounts.
"""
import os
from dotenv import find_dotenv, load_dotenv
from pydantic import BaseModel
from strands import Agent, tool
from strands.models.bedrock import BedrockModel
# ① Load AWS credentials and Bedrock configuration from .env
load_dotenv(find_dotenv())
class TipCalculatorRequest(BaseModel):
"""Input payload for Tip Calculator Agent."""
query: str = "The bill is $85. What's a 20% tip, and how much does each person pay if we're splitting it 4 ways?"
class TipCalculatorResponse(BaseModel):
"""Output payload from Tip Calculator Agent."""
query: str
response: str
tool_calls: list[str]
# ② Resolve the target Bedrock model identifier
model_id = os.getenv("BEDROCK_MODEL_ID", "amazon.nova-lite-v1:0")
@tool
def calculate_tip(bill_amount: float, tip_percentage: float, num_people: int = 1) -> dict:
"""Calculate tip and split the bill among people.
Args:
bill_amount: Total bill amount in dollars
tip_percentage: Tip percentage (e.g., 15, 18, 20)
num_people: Number of people splitting the bill (default: 1)
"""
# Compute the tip amount, combined total, and per-person share
tip = bill_amount * (tip_percentage / 100)
total = bill_amount + tip
per_person = total / num_people
# Return a dictionary with rounded monetary values
return {
"bill": bill_amount,
"tip": round(tip, 2),
"total": round(total, 2),
"per_person": round(per_person, 2),
}
def run_tip_calculator_agent(
query: str = "The bill is $85. What's a 20% tip, and how much does each person pay if we're splitting it 4 ways?",
) -> dict:
"""Run the Tip Calculator Agent."""
# ③ Initialize the Strands Agent with BedrockModel and the calculate_tip tool
agent = Agent(model=BedrockModel(model_id=model_id), tools=[calculate_tip])
# ④ The agent extracts bill, tip % and group size from plain English and calls the tool
response = agent(query)
# ⑤ Collect every tool call (toolUse) from the message history
tool_calls = [
f"{block['toolUse']['name']}({block['toolUse']['input']})"
for message in agent.messages
for block in message["content"]
if "toolUse" in block
]
return {"query": query, "response": str(response), "tool_calls": tool_calls}
app.py#
Flask Blueprint server and route handler (tip_calc_route) in src/python/app.py.
"""Flask server for 05-ai-aws-strands."""
import asyncio
import importlib.util
import os
import sys
from pathlib import Path
from dotenv import find_dotenv, load_dotenv
from flask import Blueprint, Flask, jsonify, request
from flask_cors import CORS
from rate_limiter import check_rate_limit
load_dotenv(find_dotenv())
PATH_PREFIX = os.environ.get("PATH_PREFIX", "")
STATIC_DIR = Path(__file__).resolve().parents[1]
app = Flask(__name__, static_folder=str(STATIC_DIR))
CORS(app)
bp = Blueprint("main", __name__)
_CUR_DIR = Path(__file__).resolve().parent
def _load_service(filename: str, alias: str):
# ① Resolve the numbered service file path and create its import spec
file_path = _CUR_DIR / filename
spec = importlib.util.spec_from_file_location(f"services.{alias}", file_path)
if spec is None or spec.loader is None:
raise ImportError(f"Cannot load module from {file_path}")
# ② Register and execute the module so its functions can be called by routes
mod = importlib.util.module_from_spec(spec)
sys.modules[f"services.{alias}"] = mod
spec.loader.exec_module(mod)
return mod
s_hello = _load_service("01_hello_world_agent.py", "s_hello")
s_langgraph = _load_service("02_hello_world_langgraph.py", "s_langgraph")
s_fn_tool = _load_service("03_function_to_tool.py", "s_fn_tool")
s_tip = _load_service("04_tip_calculator.py", "s_tip")
s_sales = _load_service("05_multi_tool_sales.py", "s_sales")
s_inv = _load_service("06_custom_tool_inventory.py", "s_inv")
s_calc = _load_service("07_prebuilt_tools.py", "s_calc")
s_multi_pre = _load_service("08_multi_prebuilt_tools.py", "s_multi_pre")
s_aws = _load_service("09_use_aws.py", "s_aws")
s_cls_tool = _load_service("10_class_based_tools.py", "s_cls_tool")
s_async_tool = _load_service("11_async_tools.py", "s_async_tool")
s_travel = _load_service("12_travel_assistant.py", "s_travel")
@bp.before_request
def enforce_rate_limit():
# ① Only rate-limit mutating POST requests
if request.method == "POST":
# ② Check whether this client has exceeded 30 requests per hour
blocked, msg, retry_after = check_rate_limit(
request, max_requests=30, window_seconds=3600
)
if blocked:
# ③ Return HTTP 429 with Retry-After header when rate-limited
resp = jsonify({"error": msg})
resp.status_code = 429
resp.headers["Retry-After"] = str(retry_after)
return resp
@bp.route("/")
def index():
# ① Read index.html and inject the runtime PATH_PREFIX into data-api-base
with open(os.path.join(app.static_folder, "index.html"), encoding="utf-8") as f:
html = f.read()
html = html.replace('data-api-base=""', f'data-api-base="{PATH_PREFIX}"')
# ② Return the patched HTML page
return app.response_class(html, mimetype="text/html")
@bp.route("/css/<path:filename>")
def css(filename):
return app.send_static_file(os.path.join("css", filename))
@bp.route("/js/<path:filename>")
def js(filename):
return app.send_static_file(os.path.join("js", filename))
@bp.route("/explanation/<path:filename>")
@bp.route("/info/<path:filename>")
def explanation_pages(filename):
return app.send_static_file(os.path.join("explanation", filename))
@bp.route("/theory/<path:filename>")
def theory_pages(filename):
"""Serve the theory documentation pages from src/theory."""
return app.send_static_file(os.path.join("theory", filename))
@bp.route("/api/hello-world-agent", methods=["POST"])
def hello_world_route():
# ① Parse prompt from JSON payload and reject empty values
data = request.get_json(force=True, silent=True) or {}
prompt = (data.get("prompt") or data.get("message") or "").strip()
if not prompt:
return jsonify({"error": "Prompt cannot be empty"}), 400
try:
# ② Invoke the Strands Hello World Agent in 01_hello_world_agent.py
res = s_hello.run_hello_world_agent(prompt)
res["result"] = res.get("response") or res.get("answer") or ""
return jsonify(res)
except Exception as exc:
return jsonify({"error": str(exc)}), 500
@bp.route("/api/langgraph-agent", methods=["POST"])
def langgraph_route():
# ① Parse prompt from JSON payload and reject empty values
data = request.get_json(force=True, silent=True) or {}
prompt = (data.get("prompt") or data.get("message") or "").strip()
if not prompt:
return jsonify({"error": "Prompt cannot be empty"}), 400
try:
# ② Invoke the LangGraph + Bedrock Converse agent in 02_hello_world_langgraph.py
res = s_langgraph.run_langgraph_agent(prompt)
res["result"] = res.get("response") or res.get("answer") or ""
return jsonify(res)
except Exception as exc:
return jsonify({"error": str(exc)}), 500
@bp.route("/api/function-to-tool", methods=["POST"])
def function_to_tool_route():
# ① Parse question from JSON payload and reject empty values
data = request.get_json(force=True, silent=True) or {}
question = (data.get("question") or data.get("message") or "").strip()
if not question:
return jsonify({"error": "Question cannot be empty"}), 400
try:
# ② Run the HTTP server health-check tool agent in 03_function_to_tool.py
res = s_fn_tool.run_function_to_tool_agent(question)
res["result"] = res.get("response") or res.get("answer") or ""
return jsonify(res)
except Exception as exc:
return jsonify({"error": str(exc)}), 500
@bp.route("/api/tip-calculator", methods=["POST"])
def tip_calculator_route():
# ① Parse query from JSON payload and reject empty values
data = request.get_json(force=True, silent=True) or {}
query = (data.get("query") or data.get("message") or "").strip()
if not query:
return jsonify({"error": "Query cannot be empty"}), 400
try:
# ② Run the multi-parameter tip calculator agent in 04_tip_calculator.py
res = s_tip.run_tip_calculator_agent(query)
res["result"] = res.get("response") or res.get("answer") or ""
return jsonify(res)
except Exception as exc:
return jsonify({"error": str(exc)}), 500
@bp.route("/api/multi-tool-sales", methods=["POST"])
def multi_tool_sales_route():
# ① Parse task from JSON payload and reject empty values
data = request.get_json(force=True, silent=True) or {}
task = (data.get("task") or data.get("message") or "").strip()
if not task:
return jsonify({"error": "Task cannot be empty"}), 400
try:
# ② Run the 3-tool sales pipeline agent in 05_multi_tool_sales.py
res = s_sales.run_multi_tool_sales_agent(task)
res["result"] = res.get("response") or res.get("answer") or ""
return jsonify(res)
except Exception as exc:
return jsonify({"error": str(exc)}), 500
@bp.route("/api/custom-inventory", methods=["POST"])
def custom_inventory_route():
# ① Parse question from JSON payload and reject empty values
data = request.get_json(force=True, silent=True) or {}
question = (data.get("question") or data.get("message") or "").strip()
if not question:
return jsonify({"error": "Question cannot be empty"}), 400
try:
# ② Run the custom inventory lookup agent in 06_custom_tool_inventory.py
res = s_inv.run_custom_inventory_agent(question)
res["result"] = res.get("response") or res.get("answer") or ""
return jsonify(res)
except Exception as exc:
return jsonify({"error": str(exc)}), 500
@bp.route("/api/prebuilt-calculator", methods=["POST"])
def prebuilt_calculator_route():
# ① Parse math query from JSON payload and reject empty values
data = request.get_json(force=True, silent=True) or {}
query = (data.get("query") or data.get("message") or "").strip()
if not query:
return jsonify({"error": "Query cannot be empty"}), 400
try:
# ② Run the prebuilt calculator tool agent in 07_prebuilt_tools.py
res = s_calc.run_prebuilt_calculator_agent(query)
res["result"] = res.get("response") or res.get("answer") or ""
return jsonify(res)
except Exception as exc:
return jsonify({"error": str(exc)}), 500
@bp.route("/api/multi-prebuilt-tools", methods=["POST"])
def multi_prebuilt_tools_route():
# ① Parse task from JSON payload and reject empty values
data = request.get_json(force=True, silent=True) or {}
task = (data.get("task") or data.get("message") or "").strip()
if not task:
return jsonify({"error": "Task cannot be empty"}), 400
try:
# ② Run the http_request + calculator agent in 08_multi_prebuilt_tools.py
res = s_multi_pre.run_multi_prebuilt_tools_agent(task)
res["result"] = res.get("response") or res.get("answer") or ""
return jsonify(res)
except Exception as exc:
return jsonify({"error": str(exc)}), 500
@bp.route("/api/use-aws", methods=["POST"])
def use_aws_route():
# ① Parse natural-language AWS prompt from JSON payload and reject empty values
data = request.get_json(force=True, silent=True) or {}
prompt = (data.get("prompt") or data.get("message") or "").strip()
if not prompt:
return jsonify({"error": "Prompt cannot be empty"}), 400
try:
# ② Run the prebuilt use_aws (boto3) agent in 09_use_aws.py
res = s_aws.run_use_aws_agent(prompt)
res["result"] = res.get("response") or res.get("answer") or ""
return jsonify(res)
except Exception as exc:
return jsonify({"error": str(exc)}), 500
@bp.route("/api/class-based-tools", methods=["POST"])
def class_based_tools_route():
# ① Parse query from JSON payload and reject empty values
data = request.get_json(force=True, silent=True) or {}
query = (data.get("query") or data.get("message") or "").strip()
if not query:
return jsonify({"error": "Query cannot be empty"}), 400
try:
# ② Run the stateful InventoryTools class agent in 10_class_based_tools.py
res = s_cls_tool.run_class_based_tools_agent(query)
res["result"] = res.get("response") or res.get("answer") or ""
return jsonify(res)
except Exception as exc:
return jsonify({"error": str(exc)}), 500
@bp.route("/api/async-tools", methods=["POST"])
def async_tools_route():
# ① Parse query from JSON payload and reject empty values
data = request.get_json(force=True, silent=True) or {}
query = (data.get("query") or data.get("message") or "").strip()
if not query:
return jsonify({"error": "Query cannot be empty"}), 400
try:
# ② Run the async parallel warehouse tools agent in 11_async_tools.py
res = asyncio.run(s_async_tool.run_async_tools_agent(query))
res["result"] = res.get("response") or res.get("answer") or ""
return jsonify(res)
except Exception as exc:
return jsonify({"error": str(exc)}), 500
@bp.route("/api/travel-assistant", methods=["POST"])
def travel_assistant_route():
# ① Parse travel query from JSON payload and reject empty values
data = request.get_json(force=True, silent=True) or {}
query = (data.get("query") or data.get("message") or "").strip()
if not query:
return jsonify({"error": "Query cannot be empty"}), 400
try:
# ② Run the 4-tool travel assistant agent in 12_travel_assistant.py
res = s_travel.run_travel_assistant_agent(query)
res["result"] = res.get("response") or res.get("answer") or ""
return jsonify(res)
except Exception as exc:
return jsonify({"error": str(exc)}), 500
app.register_blueprint(bp, url_prefix=PATH_PREFIX)
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000)
Quick Revision#
Key AI concepts, architectural patterns, and practical takeaways from this demo.
Quick revision#
Use these takeaways to review multi-parameter extraction and deterministic computation in Strands tools:
- Semantic Entity Extraction Over Regex: The LLM translates varied natural-language phrasings ("splitting it 4 ways", "for 3 of us", "20% tip") into typed numerical arguments without brittle regular expressions.
- Required vs. Optional Schema Fields: In
calculate_tip(bill_amount: float, tip_percentage: float, num_people: int = 1), parameters without default values becomerequiredin the Bedrock JSON Schema, whilenum_people: int = 1is optional. - Deterministic Arithmetic in Python: Language models predict tokens probabilistically and can miscalculate decimals; delegating floating-point math and
round(..., 2)to Python guarantees cent-level accuracy. - Self-Describing Dictionary Returns: Returning
{"bill": ..., "tip": ..., "total": ..., "per_person": ...}labels every computed number so the LLM never confuses the total bill with the per-person split. - Google-Style
Args:Docstring Parsing: Strands extracts each parameter's description from theArgs:block and embeds it into the JSON Schema property description sent to Bedrock. - Single-Round-Trip Tool Execution: Because all three parameters can be extracted from the initial prompt at once, the agent completes the tool call in a single Reason → Act → Observe cycle.
- Auditing Extracted Arguments: Inspecting
block['toolUse']['input']insideagent.messageslets you verify the exact numeric values the model parsed from the user's sentence.