Spring AI with Llama · Chapter 16

AI Agents: Autonomous Workflows and Tool Chaining

What you will build: A monthly HR report agent — you give it a goal, and it decides for itself which data to gather, calls five different tools in an order it chooses, and writes the finished report.


The Problem We Are Solving

Every month Sarah spends a day compiling the HR report: headcount changes, open roles, new hires, attrition, policy updates. The data lives in five different places.

"Can the AI gather all this and write the report? I just want to trigger it and come back to something finished."

That is the jump from an assistant to an agent.


What You Will Learn


A Single Call vs an Agent

Chapter 10 gave the bot one tool and told it when to use it. An agent is different: it receives a goal and works out the steps.

Goal: "Produce the monthly HR report for 2025-05"
        │
        ▼
The model decides:
  → getHeadcount("2025-05")
  → getOpenPositions("2025-05")
  → getAttrition("2025-05")
  → getPolicyUpdates("2025-05")
  → getRecentHires("2025-05")
  → I have enough. Write the report.
        │
        ▼
Finished report

You provide the tools. The model provides the plan.


The Reason → Act → Observe Loop

REASON:  "I need headcount first"
ACT:     getHeadcount("2025-05")
OBSERVE: { total: 342, changeFromLastMonth: 12 }

REASON:  "Now the open roles"
ACT:     getOpenPositions("2025-05")
OBSERVE: [ "Staff Engineer (critical)", ... ]

… repeat …

REASON:  "That's enough to write the report"
DONE

The important detail: Spring AI runs this loop for you. When the model asks for a tool, Spring executes it, feeds the result back, and lets the model decide what to do next. You do not write the loop.


The Toolbox

Five independent @Tool methods — in a real system, five different services:

@Service
public class HrDataService {

    @Tool(description = "Get total employee headcount and the change from last month for a given month")
    public HrSnapshot.Headcount getHeadcount(
            @ToolParam(description = "Month in yyyy-MM format, e.g. 2025-05") String month) {
        record("getHeadcount");
        return snapshot(month).headcount();
    }

    @Tool(description = "Get all currently open job positions for a given month")
    public List<String> getOpenPositions(
            @ToolParam(description = "Month in yyyy-MM format, e.g. 2025-05") String month) { ... }

    // getRecentHires, getAttrition, getPolicyUpdates — same shape
}

Registering them is a single line — note that nothing here says when to call them:

this.chatClient = builder
        .defaultSystem(SYSTEM_PROMPT)
        .defaultTools(hrData)     // all five become available
        .build();

Give It a Goal, Not Instructions

The system prompt sets the role and output format. Deciding the steps is the agent's job:

private static final String SYSTEM_PROMPT = """
        You are TechCorp's HR reporting agent. You are given a goal, not instructions.

        You have tools that return HR data for a month. Decide for yourself which
        tools you need, call them, and use ONLY the values they return — never
        invent numbers. When you have gathered enough data, write the report.
        ...
        """;
String report = chatClient
        .prompt()
        .options(ChatOptions.builder().temperature(0.0))   // repeatable planning
        .user("""
              Produce the complete monthly HR report for %s.
              Gather whatever data you need using your tools first.
              """.formatted(month))
        .call()
        .content();

That is the whole agent. No orchestration code, no step list.


Make the Agent Observable

Here is the part most agent tutorials skip. An agent that works is not the same as an agent you can trust — because you cannot see what it did.

So each tool records its own invocation, and the endpoint returns the trace next to the report:

public record ReportResponse(
        String month,
        String report,
        List<String> toolsInvoked,   // ← what the agent actually chose to do
        Integer toolCallCount,
        Long tookMillis
) {}

A real run:

{
  "toolsInvoked": ["getHeadcount","getOpenPositions","getAttrition","getPolicyUpdates","getRecentHires"],
  "toolCallCount": 5,
  "tookMillis": 13937
}

Look at that order. It is neither the order the tools are declared in nor the order they appear in the finished report. That is the model's own plan — and without the trace you would never know it existed.

This also solves a testing problem. The report is LLM prose, so you can only assert it is non-empty. But the trace is structured data, so you can assert the thing that actually matters: that the agent chained multiple tools without being told which.


What Actually Controls an Agent

There is no maxToolCalls setting in Spring AI 2.0. The loop ends when the model stops asking for tools. You bound an agent by design instead:

Lever Effect
Tool descriptions The main steering wheel — vague descriptions cause wrong or missed calls
System prompt Role and output format; "use only values the tools return" curbs invention
temperature(0.0) Makes planning repeatable
The set of tools you register An agent can only do what you give it — your real safety boundary
A recorded trace Not control, but you cannot debug what you cannot see

That last row is the honest lesson of this chapter: with agents, observability is the safety feature.


Try It

cd code/chapter-16-ai-agents
mvn spring-boot:run
curl -s -X POST http://localhost:8080/hr/report/generate \
  -H "Content-Type: application/json" \
  -d '{"month": "2025-05"}'

Real output (trimmed):

toolsInvoked : getHeadcount, getOpenPositions, getAttrition, getPolicyUpdates, getRecentHires
toolCallCount: 5

**2025-05 HR Report**
**Headcount**   Total employees: 342   Change from last month: +12
**New Hires**   Priya Sharma - Senior Engineer; Tom Baker - Data Scientist

342 and +12 came from the tools — the agent did not invent them.

Run the tests:

cd code/tests
./run-tests.sh chapter-16

Summary

In this chapter you:


What's Next

In Chapter 17, we tackle evaluation — how do you know if your AI is actually giving good answers? We build an automated QA pipeline that uses a second AI call to judge the quality of the SmartHR bot's responses.

Code for this chapter: code/chapter-16-ai-agents/