Function calling using LLMs [Link]
Refactoring Agile Architecture About Thoughtworks
Function calling using LLMs
Building AI Agents that interact with the external world.
While LLMs excel at generating cogent text based on their training data,
they may also need to interact with external systems. Function calling
allows them to construct such calls. The LLM does not execute these calls
directly, instead it creates a data structure that describes the call,
passing that to a separate program for execution and further processing.
The LLM's prompt includes details about possible function calls and when
they should be used.
06 May 2025
CONTENTS
Scaffold of a typical agent
Unit tests
System prompt
Kiran Prakash Restricting the agent's action space
Guardrails against prompt injections
Kiran is a principal engineer at Thoughtworks with a
Action classes
focus on data. An avid practitioner of extreme
Refactoring to reduce boiler plate
programming, he is passionate about microservices,
platform modernization, data engineering, and data Can this pattern replace traditional rules engines?
strategy. As a senior leader in the Data and AI Function calling vs Tool calling
service line, he helps large, strategic clients in How Function calling relates to MCP ( Model Context Protocol )
leveraging data to achieve business success Conclusion
One of the key applications of LLMs is to enable programs (agents) that
can interpret user intent, reason about it, and take relevant actions
accordingly.
Function calling is a capability that enables LLMs to go beyond simple
text generation by interacting with external tools and real-world
applications. With function calling, an LLM can analyze a natural
language input, extract the user’s intent, and generate a structured
output containing the function name and the necessary arguments to invoke
that function.
It’s important to emphasize that when using function calling, the LLM
itself does not execute the function. Instead, it identifies the
appropriate function, gathers all required parameters, and provides the
information in a structured JSON format. This JSON output can then be
easily deserialized into a function call in Python (or any other
programming language) and executed within the program’s runtime
environment.
Table of Contents
1 of 13 07/05/25, 10:17
Function calling using LLMs [Link]
Figure 1: natural langauge request to structured output
To see this in action, we’ll build a Shopping Agent that helps users
discover and shop for fashion products. If the user’s intent is unclear,
the agent will prompt for clarification to better understand their needs.
For example, if a user says “I’m looking for a shirt” or “Show me details
about the blue running shirt,” the shopping agent will invoke the
appropriate API—whether it’s searching for products using keywords or
retrieving specific product details—to fulfill the request.
Scaffold of a typical agent
Let's write a scaffold for building this agent. (All code examples are in
Python.)
class ShoppingAgent:
def run(self, user_message: str, conversation_history: List[dict]) -> str:
if self.is_intent_malicious(user_message):
return "Sorry! I cannot process this request."
action = self.decide_next_action(user_message, conversation_history)
return [Link]()
def decide_next_action(self, user_message: str, conversation_history: List[dict]):
pass
def is_intent_malicious(self, message: str) -> bool:
pass
Based on the user’s input and the conversation history, the shopping
agent selects from a predefined set of possible actions, executes it and
returns the result to the user. It then continues the conversation until
the user’s goal is achieved.
Now, let’s look at the possible actions the agent can take:
class Search():
keywords: List[str]
def execute(self) -> str:
# use SearchClient to fetch search results based on keywords
pass
class GetProductDetails():
product_id: str
def execute(self) -> str:
# use SearchClient to fetch details of a specific product based on product_id
pass
class Clarify():
question: str
def execute(self) -> str:
pass
Unit tests Table of Contents
2 of 13 07/05/25, 10:17
Function calling using LLMs [Link]
Let's start by writing some unit tests to validate this functionality
before implementing the full code. This will help ensure that our agent
behaves as expected while we flesh out its logic.
def test_next_action_is_search():
agent = ShoppingAgent()
action = agent.decide_next_action("I am looking for a laptop.", [])
assert isinstance(action, Search)
assert 'laptop' in [Link]
def test_next_action_is_product_details(search_results):
agent = ShoppingAgent()
conversation_history = [
{"role": "assistant", "content": f"Found: Nike dry fit T Shirt (ID: p1)"}
]
action = agent.decide_next_action("Can you tell me more about the shirt?", conversation_history)
assert isinstance(action, GetProductDetails)
assert action.product_id == "p1"
def test_next_action_is_clarify():
agent = ShoppingAgent()
action = agent.decide_next_action("Something something", [])
assert isinstance(action, Clarify)
Let's implement the decide_next_action function using OpenAI's API and a
GPT model. The function will take user input and conversation history,
send it to the model, and extract the action type along with any
necessary parameters.
def decide_next_action(self, user_message: str, conversation_history: List[dict]):
response = [Link](
model="gpt-4-turbo-preview",
messages=[
{"role": "system", "content": SYSTEM_PROMPT},
*conversation_history,
{"role": "user", "content": user_message}
],
tools=[
{"type": "function", "function": SEARCH_SCHEMA},
{"type": "function", "function": PRODUCT_DETAILS_SCHEMA},
{"type": "function", "function": CLARIFY_SCHEMA}
]
)
tool_call = [Link][0].message.tool_calls[0]
function_args = eval(tool_call.[Link])
if tool_call.[Link] == "search_products":
return Search(**function_args)
elif tool_call.[Link] == "get_product_details":
return GetProductDetails(**function_args)
elif tool_call.[Link] == "clarify_request":
return Clarify(**function_args)
Here, we are calling OpenAI’s chat completion API with a system prompt
that directs the LLM, in this case gpt-4-turbo-preview to determine the
appropriate action and extract the necessary parameters based on the
user’s message and the conversation history. The LLM returns the output
as a structured JSON response, which is then used to instantiate the
corresponding action class. This class executes the action by invoking
the necessary APIs, such as search and get_product_details.
System prompt
Now, let’s take a closer look at the system prompt:
Table of Contents
3 of 13 07/05/25, 10:17
Function calling using LLMs [Link]
SYSTEM_PROMPT = """You are a shopping assistant. Use these functions:
1. search_products: When user wants to find products (e.g., "show me shirts")
2. get_product_details: When user asks about a specific product ID (e.g., "tell me about product p1")
3. clarify_request: When user's request is unclear"""
With the system prompt, we provide the LLM with the necessary context for
our task. We define its role as a shopping assistant, specify the
expected output format (functions), and include constraints and special
instructions, such as asking for clarification when the user's request is
unclear.
This is a basic version of the prompt, sufficient for our example.
However, in real-world applications, you might want to explore more
sophisticated ways of guiding the LLM. Techniques like One-shot prompting
—where a single example pairs a user message with the corresponding
action—or Few-shot prompting—where multiple examples cover different
scenarios—can significantly enhance the accuracy and reliability of the
model’s responses.
This part of the Chat Completions API call defines the available
functions that the LLM can invoke, specifying their structure and
purpose:
tools=[
{"type": "function", "function": SEARCH_SCHEMA},
{"type": "function", "function": PRODUCT_DETAILS_SCHEMA},
{"type": "function", "function": CLARIFY_SCHEMA}
]
Each entry represents a function the LLM can call, detailing its expected
parameters and usage according to the OpenAI API specification.
Now, let’s take a closer look at each of these function schemas.
SEARCH_SCHEMA = {
"name": "search_products",
"description": "Search for products using keywords",
"parameters": {
"type": "object",
"properties": {
"keywords": {
"type": "array",
"items": {"type": "string"},
"description": "Keywords to search for"
}
},
"required": ["keywords"]
}
}
PRODUCT_DETAILS_SCHEMA = {
"name": "get_product_details",
"description": "Get detailed information about a specific product",
"parameters": {
"type": "object",
"properties": {
"product_id": {
"type": "string",
"description": "Product ID to get details for"
}
},
"required": ["product_id"]
}
}
CLARIFY_SCHEMA = {
"name": "clarify_request", Table of Contents
4 of 13 07/05/25, 10:17
Function calling using LLMs [Link]
"description": "Ask user for clarification when request is unclear",
"parameters": {
"type": "object",
"properties": {
"question": {
"type": "string",
"description": "Question to ask user for clarification"
}
},
"required": ["question"]
}
}
With this, we define each function that the LLM can invoke, along with
its parameters—such as keywords for the “search” function and product_id
for get_product_details. We also specify which parameters are mandatory
to ensure proper function execution.
Additionally, the description field provides extra context to help the
LLM understand the function's purpose, especially when the function name
alone isn’t self-explanatory.
With all the key components in place, let's now fully implement the run
function of the ShoppingAgent class. This function will handle the end-
to-end flow—taking user input, deciding the next action using OpenAI’s
function calling, executing the corresponding API calls, and returning
the response to the user.
Here’s the complete implementation of the agent:
class ShoppingAgent:
def __init__(self):
[Link] = OpenAI()
def run(self, user_message: str, conversation_history: List[dict] = None) -> str:
if self.is_intent_malicious(user_message):
return "Sorry! I cannot process this request."
try:
action = self.decide_next_action(user_message, conversation_history or [])
return [Link]()
except Exception as e:
return f"Sorry, I encountered an error: {str(e)}"
def decide_next_action(self, user_message: str, conversation_history: List[dict]):
response = [Link](
model="gpt-4-turbo-preview",
messages=[
{"role": "system", "content": SYSTEM_PROMPT},
*conversation_history,
{"role": "user", "content": user_message}
],
tools=[
{"type": "function", "function": SEARCH_SCHEMA},
{"type": "function", "function": PRODUCT_DETAILS_SCHEMA},
{"type": "function", "function": CLARIFY_SCHEMA}
]
)
tool_call = [Link][0].message.tool_calls[0]
function_args = eval(tool_call.[Link])
if tool_call.[Link] == "search_products":
return Search(**function_args)
elif tool_call.[Link] == "get_product_details":
return GetProductDetails(**function_args)
Table of Contents
5 of 13 07/05/25, 10:17
Function calling using LLMs [Link]
elif tool_call.[Link] == "clarify_request":
return Clarify(**function_args)
def is_intent_malicious(self, message: str) -> bool:
pass
Restricting the agent's action space
It's essential to restrict the agent's action space using explicit
conditional logic, as demonstrated in the above code block. While
dynamically invoking functions using eval might seem convenient, it poses
significant security risks, including prompt injections that could lead
to unauthorized code execution. To safeguard the system from potential
attacks, always enforce strict control over which functions the agent can
invoke.
Guardrails against prompt injections
When building a user-facing agent that communicates in natural language
and performs background actions via function calling, it's critical to
anticipate adversarial behavior. Users may intentionally try to bypass
safeguards and trick the agent into taking unintended actions—like SQL
injection, but through language.
A common attack vector involves prompting the agent to reveal its system
prompt, giving the attacker insight into how the agent is instructed.
With this knowledge, they might manipulate the agent into performing
actions such as issuing unauthorized refunds or exposing sensitive
customer data.
While restricting the agent’s action space is a solid first step, it’s
not sufficient on its own.
To enhance protection, it's essential to sanitize user input to detect
and prevent malicious intent. This can be approached using a combination
of:
▪ Traditional techniques, like regular expressions and input denylisting,
to filter known malicious patterns.
▪ LLM-based validation, where another model screens inputs for signs of
manipulation, injection attempts, or prompt exploitation.
Here’s a simple implementation of a denylist-based guard that flags
potentially malicious input:
def is_intent_malicious(self, message: str) -> bool:
suspicious_patterns = [
"ignore previous instructions",
"ignore above instructions",
"disregard previous",
"forget above",
"system prompt",
"new role",
"act as",
"ignore all previous commands"
]
message_lower = [Link]()
return any(pattern in message_lower for pattern in suspicious_patterns)
This is a basic example, but it can be extended with regex matching, Table of Contents
6 of 13 07/05/25, 10:17