Mentor Session

Tools & State

The two things that make or break your agent

Derrick Bozkurt  •  Prompt Engineers

What makes it an agent?

An LLM in a loop that decides which tools to call and when to stop.

Messages LLM Done Execute tool text tool call append result, loop

Two things determine whether this loop works well:
how you design the tools, and how you manage state.

Tools

The agent's hands.
Bad tools = bad agent, no matter how clever the prompt.

What the model actually sees

Every provider needs a JSON schema for each tool. Same concept, slightly different shapes.

OpenAI

{ "type": "function",
  "function": {
    "name": "complete_todo",
    "description": "Mark done.\n
      USE WHEN: ...\n
      DO NOT USE WHEN: ...",
    "parameters": {
      "type": "object",
      "properties": {
        "todo_id": {"type": "string"}
      }
    }
  }
}

Anthropic

{
  "name": "complete_todo",
  "description": "Mark done.\n
    USE WHEN: ...\n
    DO NOT USE WHEN: ...",
  "input_schema": {
    "type": "object",
    "properties": {
      "todo_id": {"type": "string"}
    }
  }
}

LangChain (generates it for you)

# Write Python, get the JSON for free

@tool
def complete_todo(
    todo_id: str
) -> str:
    """Mark done.

    USE WHEN: ...
    DO NOT USE WHEN: ...
    """
    ...

# Type hints → parameters
# Docstring → description
# Works with OpenAI or Anthropic
The description field is universal — it's what the model reads to decide which tool to call. LangChain auto-generates the right JSON shape for whichever provider you're using. Without it, you write the JSON yourself.
worked-example.md → tools.py: show how @tool + docstring becomes provider JSON; Ch 3 has both raw formats

A bad tool

@tool  # ← LangChain decorator: registers this function as a tool
def complete_todo(todo_id: str) -> str:
    """Mark a todo as completed."""  # ← becomes the tool's "description" in the schema
    todo = db.get(todo_id)
    todo.completed = True
    db.commit()
    return f"Done: {todo.title}"

This looks fine. Here's what goes wrong:

1 Model makes up "uuid-of-the-dishes" — no validation

2 Exception crashes the agent — user sees a stack trace

3 Model doesn't know when to call it — description says nothing useful

4 Retry creates a duplicate — calling it twice isn't safe

A good tool

@tool
def complete_todo(todo_id: str) -> str:
    """Mark a todo as completed.

    USE WHEN: "I finished it", "took care of that"
    DO NOT USE WHEN: observing or planning.
    Args:
        todo_id: Full UUID from list_todos. DO NOT make up UUIDs.
    """
    if not valid_uuid(todo_id):
        return "ERROR: not a UUID. Call list_todos first."
    todo = db.get(todo_id)
    if todo.user_id != config.user_id:  # config is passed via code, not the prompt
        return "ERROR: not your todo."
    if todo.completed:
        return "Already done."  # safe to call twice
    todo.completed = True
    db.commit()
    return f"Done: {todo.title}"

What changed — and why

DESCRIPTION The tool's description tells the model when to call and when not to. Negative examples ("DO NOT USE WHEN") are weighted heavily by modern models.

VALIDATION Returns an error string, not an exception. The model reads it and self-corrects on the next loop. Exceptions crash the agent.

OWNERSHIP The user's identity (user_id) is passed into the tool through code — never mentioned in the prompt. The model can't see it, leak it, or change it.

SAFE RETRIES Calling twice returns "Already done" — not a crash, not a duplicate. Network retries and model re-attempts are safe.

worked-example.md → tools.py: walk through complete_todo step by step — validation, ownership, safe retry

Bind the minimum tool set

A tool you don't give the agent cannot be called. This is the strongest safety constraint — enforced by your code, not the model's good behavior.

# When creating an agent, you choose which tools to include
# (syntax varies by framework — concept is the same)
query_agent  = Agent(tools=[get_context, search])
task_agent   = Agent(tools=[get_context, create, complete])
chat_agent   = Agent(tools=[])  # no tools = just text responses
Don't think "read tools vs. write tools." Think: what does this agent's job require? A task agent needs both reads and writes. A query agent doesn't need writes. Scope by job, not by category.
worked-example.md → tools.py: show the return [...] at the bottom of make_tools — that list IS the tool binding

State

What does the system remember — and for how long?

What is state?

In web apps, "state" is React state or a session cookie. In agents, it's simpler:

State is what the system remembers — and for how long.

Every piece of information your agent works with has a lifetime. Some things live for one turn, some for one session, some forever. The LLM itself stores nothing — it starts from zero on every call. You decide what to send it each time.

"State management" sounds complex but it's really just answering: where does this data live, how long does it last, and who can see it?

The LLM is stateless

It has no memory between calls. Every call sends the entire conversation as a list of messages. Same concept across all providers.

OpenAI

client.chat.completions.create(
  model="gpt-4o",
  messages=[
    {"role": "system",
     "content": "You are..."},
    {"role": "user",
     "content": "Add a todo"},
    {"role": "assistant",
     "tool_calls": [...]},
    {"role": "tool",
     "content": "Created!"},
  ],
  tools=[...]
)

Anthropic

client.messages.create(
  model="claude-sonnet-4-20250514",
  system="You are...",
  messages=[
    {"role": "user",
     "content": "Add a todo"},
    {"role": "assistant",
     "content": [tool_use...]},
    {"role": "user",
     "content": [tool_result...]},
  ],
  tools=[...]
)

LangChain (wraps both)

# Same message types, any provider
messages = [
  SystemMessage("You are..."),
  HumanMessage("Add a todo"),
  AIMessage(tool_calls=[...]),
  ToolMessage("Created!"),
]

# model is defined elsewhere, e.g.:
# ChatOpenAI(model="gpt-4o")
# ChatAnthropic(model="claude...")
model.invoke(messages)
Four message roles everywhere: system (who you are), user (what they said), assistant (what the model said/called), tool (what the tool returned). Every message costs tokens — a long conversation adds up fast.
worked-example.md → agent.py: show messages = [SystemMessage, HumanMessage] in run_turn — this is the list

Three lifetimes

Conversation The message list The LLM sees this directly lifetime: session Session User ID, caches, config Flows through code, LLM can't see it lifetime: one turn Database Todos, users, real data Tools read/write it, LLM doesn't touch it lifetime: forever
The database is the source of truth. The conversation is a window into it. The session carries auth and caches. Each has a different lifetime — mix them up and things break.
worked-example.md → run with --debug to see all three: STATE:CONVERSATION, STATE:SESSION, STATE:PERSISTENT

The trap: state in the prompt

Naive — inject state at turn start

system_prompt = f"""You are Todo.

Current todos:
{format_todos(get_all_todos())}

Help the user manage their list."""

Looks reasonable. But the todo list is stale the instant you send it. The model completes a todo → the prompt still shows it as pending. Hallucinated actions follow.

Better — tools fetch fresh state

system_prompt = """You are Todo.

Use list_todos to see current items.
Use create_todo, complete_todo to act.
Do NOT rely on prior context for IDs."""

The prompt holds rules. The tools fetch current state. Every call gets fresh data. No stale context, no hallucinated IDs.

worked-example.md → prompts.py: show RULES vs db.py — rules live in the prompt, data lives in the database, tools bridge them

What should go in the prompt

Not dynamic state — but context that helps the model do its job. Helper functions gather the values, the builder injects them.

Helper functions (gather context)

# These run BEFORE the LLM call to collect
# context the model will need this turn

def get_today() -> str:
    """So the model can resolve 'tomorrow'."""
    return date.today().isoformat()

def get_user_name(user_id: str) -> str:
    """Personalize the response."""
    user = db.get_user(user_id)
    return user.display_name

def get_page_context(request) -> str | None:
    """Bias the model toward relevant intents.
    e.g. user is on the 'My Home' page."""
    return request.headers.get("X-Page-Context")

Prompt builder (assembles it)

# Static sections — identical every call (cached)
ROLE = """You are Todo, a task assistant."""
RULES = """Do EXACTLY what the user asked..."""
EXAMPLES = """..."""

# Build: static first, dynamic last
def build_prompt(today, user_name,
                 page_context=None):
    parts = [ROLE, RULES, EXAMPLES]
    parts.append(f"Today is {today}")
    parts.append(f"User: {user_name}")
    if page_context:
        parts.append(
            f"User is on: {page_context}")
    return "\n\n".join(parts)

# Called at the start of each turn:
# prompt = build_prompt(
#     get_today(), get_user_name(uid),
#     get_page_context(req))
Static sections first, dynamic last. LLM providers can cache the beginning of your prompt if it stays the same across calls. Put rules and examples first (they never change), injected variables last. This can cut your costs significantly.
worked-example.md → prompts.py: show build_system_prompt() and how agent.py calls it in run_turn()

Four memory strategies — simple

Use the simplest that works. Here's what each one looks like when retrieved.

Raw History

Full message list • SQL

-- what you get back --

user:      "remind me to fix faucet"
assistant: [calls create_todo]
tool:      "Created: Fix faucet"
assistant: "Added it!"
user:      "what's on my list?"
assistant: [calls list_todos]
tool:      "- Fix faucet (due Thu)"
assistant: "You have one todo."
user:      "I fixed the faucet"
assistant: [calls complete_todo]
...

Every message, verbatim. Simple. Gets big fast.

Compaction

LLM summarizes old turns • SQL

-- what you get back --

Summary of earlier turns:
User created a "Fix faucet" todo
due Thursday. Checked their list
and saw one pending item.

--- recent turns (verbatim) ---
user:      "I fixed the faucet"
assistant: [calls complete_todo]
tool:      "Completed: Fix faucet"
assistant: "Marked it done!"

Old turns condensed. Recent turns kept. Lossy but fits.

Four memory strategies — advanced

For when you need facts across sessions or fuzzy recall.

Structured Recall

Extracted facts • SQL

-- what you get back --

{
  "user_id": "a1b2c3",
  "facts": [
    { "type": "preference",
      "value": "prefers morning reminders" },
    { "type": "pattern",
      "value": "batches chores on Saturdays" },
    { "type": "context",
      "value": "homeowner, two kids" }
  ]
}

Key facts extracted from conversation. Queryable by type.

Vector Search

"Find similar" • Vector DB

-- query: "kitchen preferences" --
-- returns top matches + scores --

[0.92] "User said they prefer
       modern kitchen appliances"

[0.87] "User wants to renovate
       the kitchen before winter"

[0.81] "Asked about energy-efficient
       dishwashers last session"

Fuzzy match with similarity scores. Works across sessions.

Memory scoping — the pitfall

If you add vector memory, always scope your queries.

Unscoped — data leak

# Searches ALL memories in the database
results = vector_db.search(
    query="kitchen preferences",
    limit=3,
)  # ← no user filter!

User A asks about kitchens. Gets User B's private preferences.

Scoped — always filter

# Only searches THIS user's memories
results = vector_db.search(
    query="kitchen preferences",
    limit=3,
    filter={"user_id": current_user_id}
)

Same principle as SQL: WHERE user_id = ?. Always filter.

Five rules to build by

1 Write tool descriptions for the LLM, not for you
2 Return error strings, never throw exceptions
3 Rules in the prompt, dynamic data in tools
4 Inject context variables — don't hardcode state
5 Scope everything by user — memory, tools, queries

Derrick Bozkurt  •  Prompt Engineers