HyphenHyphen
HomeConsole
  • Get Started
  • Connect your coding agent
  • Build with Hyphen
  • Chat UIs & automation
  • Recipes
  • Guides
  • API Reference
Streaming chatTool callingStructured JSONAgent loopHandling 429Long context
powered by Zudoku
Recipes

A small agent loop

Every coding agent is the same loop: send messages, run whatever tools the model asks for, append the results, repeat until it stops asking. Here is that loop with two real tools, in fifty lines. Nothing is elided.

TerminalCode
pip install openai export HYPHEN_API_KEY="sk-..." python agent.py README.md

agent.py

Code
"""Minimal file agent. Reads a file, summarises it, writes the summary out.""" import json import os import sys from openai import OpenAI client = OpenAI( base_url="https://api.hyphen-solution.com/v1", api_key=os.environ["HYPHEN_API_KEY"], ) def read_file(path: str) -> str: with open(path, encoding="utf-8") as f: return f.read()[:200_000] def write_file(path: str, content: str) -> str: with open(path, "w", encoding="utf-8") as f: f.write(content) return f"wrote {len(content)} chars to {path}" IMPL = {"read_file": read_file, "write_file": write_file} TOOLS = [ {"type": "function", "function": { "name": "read_file", "description": "Read a UTF-8 text file from disk.", "parameters": {"type": "object", "properties": {"path": {"type": "string"}}, "required": ["path"]}}}, {"type": "function", "function": { "name": "write_file", "description": "Write UTF-8 text to a file on disk, overwriting it.", "parameters": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"]}}}, ] target = sys.argv[1] if len(sys.argv) > 1 else "README.md" messages = [ {"role": "system", "content": "You are a file assistant. Use the tools. Stop when the job is done."}, {"role": "user", "content": f"Read {target}, then write a five-bullet summary to SUMMARY.md."}, ] for step in range(10): resp = client.chat.completions.create( model="minimax-m3", messages=messages, tools=TOOLS, max_tokens=4000) msg = resp.choices[0].message calls = msg.tool_calls or [] turn = {"role": "assistant", "content": msg.content or ""} if calls: turn["tool_calls"] = [ {"id": c.id, "type": "function", "function": {"name": c.function.name, "arguments": c.function.arguments}} for c in calls ] messages.append(turn) if not calls: print(msg.content) break for c in calls: args = json.loads(c.function.arguments) print(f"[{step}] {c.function.name}({', '.join(args)})") try: result = str(IMPL[c.function.name](**args)) except Exception as err: result = f"error: {err}" messages.append({"role": "tool", "tool_call_id": c.id, "content": result}) else: print("hit the step limit")

What a run looks like

Code
[0] read_file(path) [1] write_file(path, content) I read README.md and wrote a five-bullet summary to SUMMARY.md.

Then SUMMARY.md exists on disk with the bullets in it.

The parts that matter

The step limit is not optional. for step in range(10) is what stops a confused model from looping forever and eating your monthly budget. Python's for/else runs the else branch only if the loop never broke, which is a clean way to detect the runaway case.

Tool errors go back to the model, not up the stack. Catching the exception and returning f"error: {err}" as the tool result lets the model recover. Pass it a bad path and it will notice, apologise, and try the right one. Raise instead and the whole run dies.

The assistant turn is appended before the tool results. Order matters. The API needs the turn that requested the calls to sit immediately before the messages that answer them.

max_tokens=4000. The M-series reason before emitting a tool call. Set this low and you get an empty response with no call in it, which reads as the agent silently doing nothing. See Choosing a model.

Making it real

A few changes turn this from a demo into something you would actually run:

  • Sandbox the paths. read_file and write_file will happily touch anything the process can reach. Resolve every path and reject anything outside a working directory.
  • Cap the context. Every turn re-sends the entire message list, so a long run costs quadratically. Truncate old tool results once the transcript gets long. The [:200_000] slice on read_file is the crude version of this.
  • Stream the final answer. Swap the last call for stream=True so the user sees output while it generates. See Streaming chat.
  • Handle the cap. Wrap the create call in the retry-and-fall-back helper from Handling the 429 cap.
  • Drop to minimax-m2.5 for cheap sub-tasks like classifying a file type. Keep minimax-m3 for the planning turns.

The Anthropic version

If your stack speaks Anthropic Messages, the loop is the same shape with different block names. See Tool calling for the stop_reason == "tool_use" form.

Related

  • Tool calling: the single round trip in detail.
  • Handling the 429 cap: keep long runs alive.
  • Connect your coding agent: or just use an agent someone else wrote.
Last modified on July 28, 2026
Structured JSONHandling 429
On this page
  • agent.py
  • What a run looks like
  • The parts that matter
  • Making it real
  • The Anthropic version
  • Related