Chapter 5

Tools and function calling

Add safe, well-scoped tools so an agent can act beyond text generation.

Objective

After this chapter, you will be able to add safe, well-scoped Python tools to a Microsoft Agent Framework agent so it can act beyond text generation.

You will use the verified agent-framework==1.9.0 API: decorate a Python function with @tool, inspect the generated schema, and pass the tool to Agent(..., tools=[...]).

Concept/Theory

In Chapter 1, tool use was the part of the agent loop that lets a model move from responding to acting. Microsoft Learn describes tools as controlled capabilities that let agents interact with external systems, execute code, search data, and more. Source

Function calling is the common mechanism behind many local tools. Instead of asking the model to invent an answer for something your application already knows how to compute, you expose a typed function with a name, description, and JSON-schema parameters. The model can request a call; the framework validates the arguments, invokes your Python function, and returns the result to the model as context for the final response.

This keeps the agent boundary from Chapter 3 intact: instructions describe what the agent should do, while tools define what it is allowed to do. A well-designed tool is a narrow capability with clear inputs, predictable outputs, and explicit safety boundaries.

In MAF

In MAF Python, the standard path is the public agent_framework.tool decorator. Import it with from agent_framework import tool, decorate a plain Python function, and pass the resulting tool to an Agent. The decorator creates a FunctionTool, builds a Pydantic input model from the function signature, validates model-provided arguments, and generates JSON schema. The API confirmed for this chapter is agent-framework==1.9.0.

A minimal function tool attached to an agent.
from typing import Annotated
from agent_framework import Agent, tool

@tool
def add(a: Annotated[int, "left operand"], b: Annotated[int, "right operand"] = 1) -> int:
    """Add two integers."""
    return a + b

agent = Agent(client, instructions="Use tools for arithmetic.", tools=[add])

The schema comes from normal Python parts, so tool authoring feels like writing ordinary application code with better descriptions:

Python function part Tool schema role Why it matters
def add(...) Tool name, unless you override name= The model chooses tools by name, so use a clear verb-noun name.
Docstring Tool description, unless you override description= The model uses the description to decide when the tool is appropriate.
Type hints such as int JSON-schema parameter types Argument validation catches mismatches before your function runs.
Annotated[int, "left operand"] or Pydantic Field Parameter descriptions Descriptions reduce ambiguous or malformed tool calls.
Default values such as b = 1 Optional parameters/defaults Defaults let you keep common cases simple without broadening the tool.
Return value Tool result content returned into the invocation loop The model uses the result to produce the final response.

Stable tools belong on the agent constructor with Agent(..., tools=[...]). Per-request tools can also be supplied to Agent.run(..., tools=[...]). Advanced controls such as FunctionTool, normalize_tools, ToolMode, and function invocation configuration exist for explicit schemas, testing, or policy control, but the decorator is the normal implemented-function path.

When to use / pitfalls

Use a tool when the agent needs a capability that plain text instructions cannot safely or reliably provide.

  • Use tools for grounded facts and actions: calculations, lookups, domain APIs, file/search operations, or controlled business functions.
  • Use plain instructions for behavior: tone, role, response format, escalation rules, and constraints that do not require external action.
  • Use a workflow instead when the path is known: Microsoft Learn distinguishes agent-driven steps from workflows with explicit control over execution order. Source

Common pitfalls

  • Vague descriptions and poor schemas: if names, docstrings, or parameter descriptions are ambiguous, the model may choose the wrong tool or supply weak arguments.
  • Unsafe or over-broad tools: expose the smallest capability that solves the task. Avoid generic tools such as "run arbitrary command" unless you have strong isolation and approval.
  • Hidden side effects: make writes, sends, purchases, deletes, and other irreversible operations explicit. Microsoft documents tool approval as a way to gate tool invocations through human review. Source
  • Tool overload: too many overlapping tools make selection harder. Split capabilities across specialized agents or workflows only when a single agent becomes unreliable.
  • Assuming tools make outputs correct: tools provide controlled data or actions, but your application still needs testing, validation, responsible AI mitigations, and observability. Source

Worked example

The verified example below defines a local arithmetic tool, inspects the generated schema, invokes the tool directly, normalizes it for framework use, and gives it to an Agent. The final live model call is clearly marked because it requires OPENAI_API_KEY.

backend/examples/ch05/tools_function_calling.py demonstrates @tool, generated schema, direct invocation, and Agent(..., tools=[...]).
"""Chapter 5: Function tools and guarded agent tool use."""

from __future__ import annotations

import asyncio
import os
from typing import Annotated

from agent_framework import Agent, FunctionTool, normalize_tools, tool
from agent_framework.openai import OpenAIChatClient


@tool
def add(
    a: Annotated[int, "left operand"],
    b: Annotated[int, "right operand"] = 1,
) -> int:
    """Add two integers."""
    return a + b


async def main() -> None:
    schema = add.to_dict()
    print(schema["name"], schema["input_model"]["properties"]["a"]["description"])
    print(add(2, b=3))

    invoked = await add.invoke(arguments={"a": 10, "b": 5})
    print(invoked)

    normalized = normalize_tools([add])
    assert isinstance(normalized[0], FunctionTool)

    client = OpenAIChatClient(
        model="gpt-4o-mini",
        api_key=os.getenv("OPENAI_API_KEY") or "placeholder",
    )
    agent = Agent(client, instructions="Use tools for arithmetic.", tools=[add])

    if not os.getenv("OPENAI_API_KEY"):
        print(f"Constructed agent {agent.name!r} with tool {add.name!r}; skipping live call.")
        return

    # requires OPENAI_API_KEY
    response = await agent.run("What is 10 + 5?")
    print(response.text)


if __name__ == "__main__":
    asyncio.run(main())

Walk through it in three steps. First, @tool turns add into a FunctionTool; its signature and Annotated descriptions become the input schema. Second, add.to_dict(), add(...), and await add.invoke(...) show that the tool is inspectable and testable without a live model. Third, Agent(client, instructions=..., tools=[add]) makes the tool available to the model; the script skips the live agent.run call unless OPENAI_API_KEY is set.

Recap & next

  • Tools are the controlled action surface of an agent; instructions guide behavior, but tools define available capabilities.
  • In agent-framework==1.9.0, use from agent_framework import tool and @tool for ordinary Python functions.
  • Function names, docstrings, type hints, Annotated descriptions, and defaults become the schema the model sees.
  • Pass stable tools with Agent(..., tools=[...]); keep side-effecting tools narrow, explicit, and reviewable.

Next, Chapter 6 shows when to move from one agent choosing tools dynamically to workflows with sequential and concurrent orchestration.