omarcevi.dev / workshops / match-day-agents / 1 · Scout

Step 1 · The Scout

The pattern: a single LlmAgent is an instruction (who it is, how it works) plus tools (plain Python functions it can call).

Step 1: the scout LlmAgent sends its instruction and tool schemas to Gemini, calls Python tools that read data, and answers with their numbers
One agent, five tools. Gemini decides which tool to call; Python does the work. Tap to open full size.

Here's the whole agent:

steps/step1_scout/agent.py
def build_scout(
    name: str = "scout",
    description: str = "Looks up players and team form: stats, availability, top performers, recent results.",
    tools: list | None = None,
) -> LlmAgent:
    return LlmAgent(
        name=name,
        model=MODEL,
        description=description,
        instruction="""
You are the scout on Trabzonspor's analysis staff. "We" and "us" always mean Trabzonspor.
Answer questions about players and teams.

How you work:
1. Look everything up with your tools before you answer. Never answer from memory.
2. "In form" means recent output and ratings: check top players by goals, assists and avg_rating.
3. "Who can't play" means the availability list: out and doubtful players with the reason.
4. Name the player, then the numbers that back up your point.
""" + HOUSE_RULES,
        tools=tools or SCOUT_TOOLS,
    )


root_agent = build_scout()

HOUSE_RULES comes from config.py and goes into every agent: answer in the user's language, and only use numbers from a tool. Type in Turkish and you get Turkish back. The agent is built by a function, build_scout(), which pays off in step 5.

ADK turns each tool's type hints and docstring into the schema the model sees, so the docstring is written for the model: it's all it knows about the tool. Inside, the tools do the counting, like the share of goals per 15-minute window:

matchday/tools.py
    t["goals_by_15min"] = {
        window: {
            **g,
            "pct_of_goals_scored": round(100 * g["scored"] / total_for) if total_for else NA,
            "pct_of_goals_conceded": round(100 * g["conceded"] / total_against) if total_against else NA,
        }
        for window, g in t["goals_by_15min"].items()
    }

Name matching ignores Turkish and Polish letters (simsir finds Aral Şimşir), and bad input returns an error with suggestions instead of raising.

I asked: "Who's in form for us, and who can't play Sunday?"

The scout called get_availability once and get_top_players three times (goals, assists, avg_rating), then answered in 12 seconds. Trimmed:

In Form (Last 10 Matches)

  • Mohamed Salah (Available): 8 goals, 5 assists, and an 8.03 average rating in 10 matches (802 minutes).
  • Ernest Muçi (Available): 2 goals, 5 assists, and a 7.14 average rating in 9 matches (549 minutes).
  • (Note: Franculino has also been in top form with 7 goals, 2 assists, and a 7.75 average rating in 8 matches / 545 minutes, but he is currently doubtful). …

Use it when one job needs a handful of tools and some judgement: a support bot, a data assistant, a code helper.

Avoid it when the instruction grows into a long list of "first do this, then that". That's a workflow, and step 2 is for that.

The gotcha: tools do the maths. Every number an agent states should come out of a tool. Give the model computed answers (pct_of_goals_conceded: 33), not raw rows to add up. My smoke script flags any number in an answer that appears in no tool result; step 5 shows what happens when you skip this.