- Give the model a database and three tools that read it
- Describe those tools so the model knows when to reach for each one
- Run the loop that turns tool calls into tool results
- Watch it request several tools at once
- Hand errors back to the model instead of raising them
- Draw the line between what the model will not do and what it cannot do
Setup
You need Python 3.9 or newer, therequests package, and a Venice API key. See Generating an API Key if you do not have one. Everything else is in the standard library.
agent.py with the imports and the header block every call reuses:
GET /models/traits maps stable trait names onto whatever model currently fills that role. Reading function_calling_default at startup means your agent keeps working when the underlying model is replaced. See Models for the full trait list.1. A database worth asking about
Any SQLite file will do. This one is a small shop with customers, products, and the orders that join them, which is enough that a real question needs a join and an aggregate:2. Three tools the model can reach for
The tools mirror how a person meets an unfamiliar database: find out what is in it, look at one table closely, then query it.description is not a comment. It is the only thing the model reads when deciding which tool to call and what to put in it:
3. The loop
Function calling is a conversation, not a request. The model answers with tool calls, you run them, you append the results, and you ask again. It ends when the model replies with content instead of calls.messages before the results do. It carries the tool_calls the results are answering, and on a reasoning model it also carries a reasoning_content field. Rebuilding the message by hand and dropping fields you did not expect is the most common way to break the second round.
Each result is matched to its call by tool_call_id. Nothing else identifies it.
max_rounds is a real limit, not a formality. A model that keeps querying without concluding will otherwise loop until you run out of patience or credit.
4. What it actually does
Wire up a main block and run it:stderr as they happen, so you can watch it work:
Round 4 is the part a single function call cannot do. The model could not write that query until it had seen the answer to the one before it.
Your run will not match this one call for call. The model sometimes describes all three tables at once and sometimes one at a time, and it occasionally skips
list_tables and guesses a name. The figures are stable because they come from the database; the route to them is not.
Round 2 returned three tool calls in one response, and the loop above runs them one after another. They are independent, so a
ThreadPoolExecutor here is worth having as soon as your tools do real I/O. Keep the tool messages in the same order as the calls that produced them.usage block shows it paying off:
5. Let errors reach the model
The instinct is to raise on a bad query. Resist it. An error is information, and the model can act on it. Ask for a table that does not exist:run_query returned {"error": "OperationalError: no such table: purchases"} as an ordinary tool result rather than raising, the model read it, called list_tables to find out what did exist, and corrected itself. Had the exception propagated, the script would have died on a typo.
This is why every tool returns JSON on the failure path too. The rule is simple: if a human debugging your tool would want to see the message, so does the model.
6. What it will not do, and what it cannot do
Ask the agent to destroy something:SELECT for Spanish customers, found none because the column stores ES rather than Spain, and reported that instead:
run_query is the part that does not depend on a choice:
That second line is why
run_query catches sqlite3.Warning alongside sqlite3.Error. Python’s driver refuses stacked statements, but it raises Warning for them, and Warning is not a subclass of Error. Catching only sqlite3.Error lets a stacked statement escape the handler and kill the loop instead of returning a message the model can read.Controlling when tools get used
tool_choice decides how much say the model has:
"required" is blunter than it looks. Asking this agent What is 2 + 2? with tool_choice set to "required" makes it call list_tables, look at a database it has no use for, and then answer 4 on the next round. With "auto" it answers 4 immediately and calls nothing. Reach for "required" when a tool genuinely must run, such as logging a request, and leave it alone otherwise.
Tuning the agent
Next steps
The loop you now have is the same one behind most agents. Only the tools change.- Swap the SQL tools for HTTP calls and it becomes an API agent.
- Add Web Search and Scraping as a tool and it can check the live web mid-answer.
- Ask for a typed result instead of prose with Structured Responses.
- See a larger version of this pattern in the Private Research Agent.
Function Calling
Reference for the tools array and tool_choice.
Structured Responses
Constrain the final answer to a JSON schema.
Prompt Caching
Keep the growing conversation cheap.
Private Research Agent
The same loop with web tools and a planner.