Decorators, And The @ Symbol
Open any agent code and you will meet a line starting with @:
@tool
def get_weather(city: str) -> str:
"""Get the current weather for a city."""
return f"It is 28 degrees in {city}."By the end of this page you will know exactly what that @tool does. It is simpler than it looks.
The idea in one picture
Think of a parcel.
You have a gift. Someone wraps paper around it. It is still the same gift — but now it has a label, a ribbon, and instructions on the outside.
A decorator wraps your function. Your function still does its job. The wrapper adds something around it.
That is the whole concept. The rest is syntax.
Build it up in four steps
We will get to @tool gradually. Do not skip ahead; each step is three lines.
Step 1: a function is a value
def greet(name):
return f"Hello {name}"
say = greet
print(say("Priya"))Hello Priyasay = greet did not call anything. It gave the same function a second name. No brackets means the function itself.
Step 2: a function can take a function
def run_twice(func):
return func("Priya") + " / " + func("Arun")
def greet(name):
return f"Hello {name}"
print(run_twice(greet))Hello Priya / Hello Arunrun_twice has no idea what greet does. It only knows it can be called.
Step 3: a function can return a new function
This is the step that feels odd. Read it slowly.
def make_loud(func):
def wrapper(name):
result = func(name)
return result.upper() + "!"
return wrapper
def greet(name):
return f"Hello {name}"
loud_greet = make_loud(greet)
print(loud_greet("Priya"))HELLO PRIYA!Read it out loud:
make_loudreceivesgreetand calls itfunc- inside, it defines a brand new function called
wrapper wrappercalls the original, then shouts the resultmake_loudreturnswrapper— it does not call it
So loud_greet is wrapper. When you call loud_greet("Priya"), you are running wrapper, which runs greet inside itself.
That is a decorator. You have already written one.
Step 4: the @ is shorthand
greet = make_loud(greet)That line is a mouthful, and you have to remember to write it. Python gives you a shortcut:
@make_loud
def greet(name):
return f"Hello {name}"
print(greet("Priya"))HELLO PRIYA!@make_loud above a function means exactly greet = make_loud(greet). Nothing more.
If you remember one sentence from this page, that is the one.
A useful one: timing
import time
def timed(func):
def wrapper(*args, **kwargs):
start = time.time()
result = func(*args, **kwargs)
print(f"[{func.__name__} took {time.time() - start:.2f}s]")
return result
return wrapper
@timed
def fetch_report():
time.sleep(1)
return "report ready"
print(fetch_report())[fetch_report took 1.00s]
report readyOne new thing: *args, **kwargs.
It means "accept whatever arguments the original accepted, and pass them straight through". Without it, your decorator only works on functions that take no arguments. Treat it as boilerplate you always include.
Keep the name and the docstring
There is one wrinkle, and in agent code it genuinely breaks things.
@timed
def fetch_report():
"""Fetch the monthly report."""
...
print(fetch_report.__name__)
print(fetch_report.__doc__)wrapper
NoneThe name and docstring are gone, because fetch_report is now wrapper. And in agent code the docstring is the tool description the model reads. Losing it means the model no longer knows what your tool does.
functools.wraps copies them across:
from functools import wraps
def timed(func):
@wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapperfetch_report
Fetch the monthly report.Put @wraps(func) in every decorator you write. Always.
Now: what does @tool actually do?
Back to where we started.
from langchain_core.tools import tool
@tool
def get_weather(city: str, unit: str = "celsius") -> str:
"""Get the current weather for a city."""
return f"28 degrees {unit} in {city}"tool is an ordinary function. It receives get_weather and returns a wrapped version. While doing so, it reads your function and builds a description the model can understand:
| It reads | It produces |
|---|---|
| the function name | the tool's name |
| the docstring | the description the model uses to choose the tool |
city: str | a required string parameter |
unit: str = "celsius" | an optional string parameter, default celsius |
You can see the raw material yourself:
def get_weather(city: str, unit: str = "celsius") -> str:
"""Get the current weather for a city."""
return "28 degrees"
print(get_weather.__name__)
print(get_weather.__doc__)
print(get_weather.__annotations__)get_weather
Get the current weather for a city.
{'city': <class 'str'>, 'unit': <class 'str'>, 'return': <class 'str'>}Everything the framework needs is already sitting on your function.
Two rules follow, and both fail silently:
- no type hints — the framework cannot say what
cityis - no docstring — the model has nothing to read, so it never picks the tool
Neither raises an error. Your agent just behaves badly, which is much harder to debug than a crash.
Decorators with brackets
You will also see this shape:
@retry(attempts=3)
def call_api():
...One extra layer. retry(attempts=3) runs first and returns a decorator, which is then applied.
You rarely write these. You need to recognise them:
def retry(attempts):
def decorator(func):
def wrapper(*args, **kwargs):
for i in range(attempts):
try:
return func(*args, **kwargs)
except Exception:
if i == attempts - 1:
raise
return wrapper
return decoratorThree levels: takes the setting, takes the function, does the work.
The ones you will meet
| Decorator | Where | What it does |
|---|---|---|
@tool | LangChain | turns a function into a tool the model can call |
@agent, @task | CrewAI | registers a method as an agent or a task |
@property | Python | makes a method usable like an attribute |
@staticmethod | Python | a method that ignores self |
@dataclass | Python | generates __init__ and friends |
@pytest.fixture | pytest | supplies test data to a test |
@app.get("/path") | FastAPI | binds a function to a URL |
Coming from Java or C#
The closest thing you know is an annotation — @Override, @Autowired, [Serializable].
The resemblance is only skin deep:
| Java / C# annotation | Python decorator | |
|---|---|---|
| What it is | metadata, read later by a framework | a function that runs immediately |
| When it acts | compile time, or via reflection | the moment the file is imported |
| Can it change behaviour? | not by itself | yes — it replaces the function |
| Can you write one? | rarely, and painfully | yes, in ten lines |
The practical difference: a Python decorator is ordinary code you can open and read. If you want to know what @tool does, go and read tool. There is no reflection magic underneath.
Common mistakes
- No docstring on a tool. The model reads it. No docstring, no idea when to call it.
- No type hints. The schema is built from them.
- Forgetting
@wraps. Silently destroys the name and docstring the framework needs. - Forgetting `*args, kwargs`** in the wrapper, so it only works on no-argument functions.
- Expecting the decorator to run at call time. It runs at import time. The wrapper runs at call time.
Practise this
- Write Step 3 from memory —
make_loud— and confirm the output is shouted. - Convert it to use
@make_loudand check you get the same result. - Write
timedand apply it to a function that sleeps for one second. - Print
__name__before and after adding@wraps, and see the difference. - Write a function with type hints and a docstring, then print
__annotations__and__doc__and say aloud what a framework could build from them.