Python That Agent Frameworks Assume

Decorators

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 Priya

say = 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 Arun

run_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_loud receives greet and calls it func
  • inside, it defines a brand new function called wrapper
  • wrapper calls the original, then shouts the result
  • make_loud returns wrapper — 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 ready

One 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
None

The 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 wrapper
fetch_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 readsIt produces
the function namethe tool's name
the docstringthe description the model uses to choose the tool
city: stra 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 city is
  • 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 decorator

Three levels: takes the setting, takes the function, does the work.

The ones you will meet

DecoratorWhereWhat it does
@toolLangChainturns a function into a tool the model can call
@agent, @taskCrewAIregisters a method as an agent or a task
@propertyPythonmakes a method usable like an attribute
@staticmethodPythona method that ignores self
@dataclassPythongenerates __init__ and friends
@pytest.fixturepytestsupplies test data to a test
@app.get("/path")FastAPIbinds 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# annotationPython decorator
What it ismetadata, read later by a frameworka function that runs immediately
When it actscompile time, or via reflectionthe moment the file is imported
Can it change behaviour?not by itselfyes — it replaces the function
Can you write one?rarely, and painfullyyes, 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_loud and check you get the same result.
  • Write timed and 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.

Try It Yourself

Copy this into a new Colab notebook and run it. Nothing to install.

example.py
1# A decorator is a function that wraps your function and returns the wrapper.
2# Build one, then see why frameworks use them.
3
4import time
5from functools import wraps
6
7
8def timed(func):
9 @wraps(func) # keeps the name and docstring
10 def wrapper(*args, **kwargs): # accept whatever the original accepts
11 start = time.time()
12 result = func(*args, **kwargs)
13 print(f"[{func.__name__} took {time.time() - start:.2f}s]")
14 return result
15 return wrapper
16
17
18@timed
19def fetch_report(name):
20 """Pretend to fetch a report."""
21 time.sleep(0.5)
22 return f"{name} ready"
23
24
25print(fetch_report("July close"))
26print()
27
28# @timed is EXACTLY the same as writing this:
29def plain(name):
30 """A plain function."""
31 return f"{name} ready"
32
33plain = timed(plain)
34print(plain("August close"))
35print()
36
37# Why @wraps matters - without it these would say "wrapper"
38print("name :", fetch_report.__name__)
39print("docstring :", fetch_report.__doc__)
40print()
41
42
43# A decorator that takes arguments is one more layer:
44def retry(attempts):
45 def decorator(func):
46 @wraps(func)
47 def wrapper(*args, **kwargs):
48 for i in range(attempts):
49 try:
50 return func(*args, **kwargs)
51 except Exception as e:
52 print(f" attempt {i + 1} failed: {e}")
53 if i == attempts - 1:
54 raise
55 return wrapper
56 return decorator
57
58
59calls = {"n": 0}
60
61@retry(attempts=3)
62def flaky():
63 calls["n"] += 1
64 if calls["n"] < 3:
65 raise ConnectionError("network blip")
66 return "succeeded on attempt 3"
67
68
69print(flaky())
70print()
71
72# What a framework's @tool reads from your function:
73def get_weather(city: str, unit: str = "celsius") -> str:
74 """Get the current weather for a city."""
75 return f"28 degrees {unit} in {city}"
76
77print("tool name :", get_weather.__name__)
78print("tool description :", get_weather.__doc__)
79print("tool parameters :", get_weather.__annotations__)
80print("required (no default): city")