A complete agent loop. It is about sixty lines, and every one of them is doing something that prevents a specific failure.
The loop#
import jsonfrom openai import OpenAIclient = OpenAI(base_url="https://ai.upfyn.com/v1", api_key=KEY, max_retries=0)MAX_STEPS = 8 # a runaway agent is the most expensive bug you can shipdef run_agent(task: str, tools: list, executor, run_id: str) -> str:messages = [{"role": "system", "content": "Use the tools available. Stop when the task is done."},{"role": "user", "content": task},]for step in range(MAX_STEPS):response = client.chat.completions.create(model="upfyn-yuva",messages=messages,tools=tools,extra_body={"chat_ref": run_id}, # ties every turn together in Logs)message = response.choices[0].message# No tool calls means the model is answering, not working.if not message.tool_calls:return message.contentmessages.append(message)for call in message.tool_calls:messages.append({"role": "tool","tool_call_id": call.id,"content": execute(executor, call),})return "Stopped: the task did not finish within the step limit."def execute(executor, call) -> str:"""Always returns a string. A tool that raises must not end the run."""try:args = json.loads(call.function.arguments)except json.JSONDecodeError:# The model generated the arguments, so malformed JSON is a normal event.return json.dumps({"error": "arguments were not valid JSON"})try:return json.dumps(executor(call.function.name, args))except PermissionError as err:return json.dumps({"error": f"not permitted: {err}"})except Exception as err:# Feed the failure back. The model will usually correct itself.return json.dumps({"error": str(err)[:500]})
Why each part is there#
The step cap
Without it, a model that keeps calling tools keeps billing you, and nothing looks wrong — the request is simply still running. Return a plain message when the cap is hit rather than raising, so the caller gets something usable.
Tool errors are results
execute never raises. A tool that fails returns {"error": …} as its result and the loop carries on — the model reads the error, fixes the argument, and tries again. Aborting instead hands the user nothing and wastes the turns you already paid for.
The arguments parse can fail
arguments is a string the model generated. Occasionally it is not valid JSON. That is a normal event to handle, not an exception to propagate.
The allowlist is in the executor
ALLOWED = {"search_orders", "get_order", "send_receipt"}def executor(name: str, args: dict):# The allowlist lives HERE, not in the tool description. A description is a# suggestion to the model; this is the boundary that actually holds.if name not in ALLOWED:raise PermissionError(f"unknown tool {name}")if name == "send_receipt":if not args.get("order_id"):raise ValueError("order_id is required")if not user_owns_order(current_user, args["order_id"]):raise PermissionError("that order belongs to someone else")return send_receipt(args["order_id"])...
Never trust the tool name or the arguments
The model can ask for a tool you never defined, and can pass an id belonging to a different user. Check both in your code. A tool description is a hint to the model; it is not a security control.The run id
chat_ref groups every turn of one run. When an agent does something strange, you can pull the whole run back in Logs instead of hunting through unrelated traffic.
Streaming an agent#
You can stream each turn, but tool-call fragments arrive across chunks: take function.name once and accumulate function.arguments per index.
The classic streaming-tools bug
Appending the name on every chunk gives youget_weatherget_weatherget_weather and a tool that does not exist. Accumulate arguments; do not accumulate the name.Beyond one loop#
- Give the model a
finishtool with a schema when you need a structured final answer — it makes termination explicit rather than inferred. - Use a session when a run must survive a restart, or span more than one process.
- Set
parallel_tool_calls: falsewhen your tools have side effects that must not interleave.
