Python That Agent Frameworks Assume

Functions As Values

Functions Are Values

In Python a function is a value, like a number or a string. You can put it in a variable, pass it to another function, store it in a dictionary, or return it.

This sounds like trivia. It is actually the idea the entire agent stack is built on. Every graph node, every router, every callback, every tool is a function handed to a framework as data.

If you come from Java or C#, this is where the biggest habit change happens.

The one thing to get right: brackets

def greet(name):
    return f"Hello {name}"

print(greet)
print(greet("Priya"))
<function greet at 0x000001F3...>
Hello Priya
You writeYou get
greetthe function itself
greet()the result of running it

That single pair of brackets is the whole distinction, and mixing them up is the most common mistake on this page.

say = greet
print(say("Priya"))
Hello Priya

say = greet did not call anything. It gave the same function a second name.

Passing a function to a function

def apply_twice(func, value):
    return func(func(value))

def add_ten(x):
    return x + 10

print(apply_twice(add_ten, 5))
25

apply_twice has no idea what add_ten does. It only knows it can be called with one value.

That is why a framework can accept your code without knowing anything about it.

The pattern behind tool calling

This one is worth reading slowly, because it is how tool calling works underneath.

Start with two ordinary functions:

def get_weather(city):
    return f"28 degrees in {city}"

def lookup_order(order_id):
    return f"order {order_id} shipped"

Put them in a dictionary, by name:

TOOLS = {
    "get_weather": get_weather,
    "lookup_order": lookup_order,
}

Note there are no brackets. The dictionary holds the functions, not their results.

Now — a model replies with a tool name and some arguments. As data:

call = {"name": "get_weather", "args": {"city": "Hyderabad"}}

And here is the whole trick:

name = call["name"]
args = call["args"]

result = TOOLS[name](**args)
print(result)
28 degrees in Hyderabad

Take that line in two halves:

  • TOOLS[name] — looks the function up by its name, and gives you the function
  • (**args) — calls it, spreading the dictionary out into named arguments

So TOOLS["get_weather"](**{"city": "Hyderabad"}) becomes get_weather(city="Hyderabad").

That is how text from a model becomes a real function call. When a framework "runs your tool", this is what it does.

It also replaces a long if/elif chain. Adding a tool becomes adding one dictionary entry.

Handling several calls, and unknown ones

calls = [
    {"name": "get_weather", "args": {"city": "Hyderabad"}},
    {"name": "lookup_order", "args": {"order_id": 88213}},
    {"name": "no_such_tool", "args": {}},
]

for call in calls:
    name = call["name"]
    if name not in TOOLS:
        print(f"Error: no tool called {name}")
        continue
    print(TOOLS[name](**call["args"]))
28 degrees in Hyderabad
order 88213 shipped
Error: no tool called no_such_tool

Four lines, and you have written the core of a tool dispatcher.

Returning a function

def make_multiplier(n):
    def multiply(x):
        return x * n
    return multiply

double = make_multiplier(2)
triple = make_multiplier(3)

print(double(5), triple(5))
10 15

multiply remembers the n it was created with, even though make_multiplier has long finished. A function carrying values from where it was defined is called a closure.

You will not write many by hand. You will read them constantly — it is how decorators and configured callbacks work.

Lambdas: tiny functions with no name

pairs = [("P3", 3), ("P1", 1), ("P2", 2)]
pairs.sort(key=lambda pair: pair[1])
print(pairs)
[('P1', 1), ('P2', 2), ('P3', 3)]

A lambda holds exactly one expression and has no name. lambda pair: pair[1] means "given a pair, give me its second item".

Use it for throwaway things — a sort key, a filter. If it needs a second line, write a proper def. Framework code uses lambdas heavily for wiring, which is another reason to read them comfortably.

Where this shows up in frameworks

Graph nodes are functions

def triage(state):
    return {"severity": "P1"}

graph.add_node("triage", triage)

You pass triage, not triage(). The framework calls it later, with the state, at the right point in the graph.

Routers are functions that return a name

def route(state):
    if state["severity"] == "P1":
        return "notify"
    return "log"

graph.add_conditional_edges("triage", route)

Your function decides where the graph goes next. You have handed over a decision, as code, for later execution.

Callbacks are functions handed over in advance

def on_token(token):
    print(token, end="")

llm.invoke(prompt, callbacks=[on_token])

Once you see it, the pattern is everywhere. A framework is mostly machinery for calling functions you supplied earlier.

Coming from Java or C#

C# has this already — delegates, Func<T>, Action<T>, lambdas. Same idea, less ceremony, nothing to declare.

Java before 8 did not, which is why older Java code wraps a single method in a class. Java 8 lambdas and method references (this::handle) are the same concept.

ConceptJavaC#Python
Function as a valueFunction<A,B>Func<A,B>just the function
Anonymous functionx -> x + 1x => x + 1lambda x: x + 1
Method referenceobj::methodobj.Methodobj.method
Wanting a callbackinterface with one methoddelegateany callable

The habit to unlearn: you do not need an interface, a base class, or a wrapper object. If a framework wants something to call, hand it the function.

Two mistakes worth seeing

Accidental brackets

graph.add_node("triage", triage())

This calls triage immediately and passes its result. The framework later tries to call a dictionary and fails in a confusing way, far from the real mistake.

Late binding in a loop

funcs = [lambda: i for i in range(3)]
print([f() for f in funcs])
[2, 2, 2]

Not [0, 1, 2]. All three lambdas share the same i, and by the time you call them the loop has finished, so i is 2.

Capture the value with a default argument:

funcs = [lambda i=i: i for i in range(3)]
print([f() for f in funcs])
[0, 1, 2]

Common mistakes

  • Adding brackets by accident when passing a function.
  • Late binding in a loop, as above.
  • Reaching for a class when a plain function would do.
  • Long lambdas. If it does not fit comfortably on one line, use def.

Practise this

  • Print greet and greet("x") side by side and say aloud what each is.
  • Put two functions in a dictionary and call one by looking it up from a variable.
  • Write the three-call dispatcher above, including the unknown-tool case.
  • Write make_multiplier from memory and explain what double is holding on to.
  • Reproduce the [2, 2, 2] surprise, then fix it.

Try It Yourself

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

example.py
1# A function is a value. This is the idea the whole agent stack rests on.
2
3def greet(name):
4 return f"Hello {name}"
5
6# Give the same function a second name. Note: NO brackets.
7say = greet
8print(say("Priya"))
9
10print("greet ->", greet) # the function itself
11print("greet() ->", greet("x")) # the RESULT of calling it
12print()
13
14# --- Passing a function to a function ---
15def apply_twice(func, value):
16 return func(func(value))
17
18def add_ten(x):
19 return x + 10
20
21print(apply_twice(add_ten, 5))
22print()
23
24# --- The pattern behind tool calling ---
25def get_weather(city):
26 return f"28 degrees in {city}"
27
28def search_web(query):
29 return f"results for {query}"
30
31def lookup_order(order_id):
32 return f"order {order_id} shipped"
33
34TOOLS = {
35 "get_weather": get_weather,
36 "search_web": search_web,
37 "lookup_order": lookup_order,
38}
39
40# This is what a model returns - a name and some arguments, as data:
41calls = [
42 {"name": "get_weather", "args": {"city": "Hyderabad"}},
43 {"name": "lookup_order", "args": {"order_id": 88213}},
44 {"name": "no_such_tool", "args": {}},
45]
46
47for call in calls:
48 name = call["name"]
49 if name not in TOOLS:
50 print(f"Error: no tool called {name}")
51 continue
52 print(TOOLS[name](**call["args"])) # look it up, then call it
53print()
54
55# --- Returning a function (a closure) ---
56def make_multiplier(n):
57 def multiply(x):
58 return x * n # remembers n
59 return multiply
60
61double = make_multiplier(2)
62triple = make_multiplier(3)
63print(double(5), triple(5))
64print()
65
66# --- Lambdas: small, throwaway ---
67pairs = [("P3", 3), ("P1", 1), ("P2", 2)]
68pairs.sort(key=lambda pair: pair[1])
69print(pairs)
70print()
71
72# --- The late-binding surprise ---
73funcs = [lambda: i for i in range(3)]
74print("surprising:", [f() for f in funcs]) # [2, 2, 2]
75
76fixed = [lambda i=i: i for i in range(3)]
77print("fixed :", [f() for f in fixed]) # [0, 1, 2]
78print()
79
80# --- A router: a function that returns the name of the next step ---
81def route(state):
82 if state["severity"] == "P1":
83 return "notify"
84 return "log"
85
86print(route({"severity": "P1"}))
87print(route({"severity": "P3"}))