Giving LLMs Hands and Feet: Agent Architecture and Code Practice
Agents combine a large language model that decides the next action, tools that fetch data or perform work, and a control loop that handles iteration and errors. This article explains why tools matter, how the reason-act-observe loop works, how to define tools and implement them in Python with the OpenAI-compatible API, when to use a framework versus a custom loop, how to debug failed tool calls, and what to watch in cloud deployments.
Start with the conclusion
An agent has three parts: the large language model decides what to do next, tools retrieve information or execute actions, and the program owns the loop and error handling. All three are required. The rest of this article follows that order and includes a complete code example.
Why models need tools
A model’s knowledge stops at the cutoff of its training data. It cannot read today’s news or query an internal database. With tools, it can look up external information before answering, or run an action after confirmation. Tool calling turns the model from “answering questions” into “completing tasks.”
The basic agent loop
Agents typically follow reason, call, observe:
- The model reads the user question and conversation history and decides whether a tool is needed.
- If so, it emits a tool name and arguments; the program runs the matching function.
- The program appends the result to the conversation; the model judges again.
- When the model no longer requests tools, the loop ends and the final answer is returned.
Always set a maximum number of steps. Models sometimes call the same tool repeatedly; an unbounded loop burns API cost.
Defining tool descriptions
The model only sees the description when deciding whether to call a tool, so purpose and parameters must be explicit. Vague descriptions lead to wrong calls or bad arguments. OpenAI-compatible APIs use a schema like this:
[{"type": "function","function": {"name": "fetch_latest_news","description": "Get latest news titles and summaries for a tech category. Use when the user asks about recent technology developments.","parameters": {"type": "object","properties": {"category": {"type": "string","description": "Tech category, e.g. artificial intelligence, cloud computing, chips"}},"required": ["category"]}}}]
name must match the registered function. description states when to use the tool. parameters is JSON Schema; required lists mandatory fields.
Implementing tools and the agent loop
Tool functions should handle success, network timeouts, and invalid arguments. On failure, return structured error text instead of raising and aborting the loop, so the model can read the cause and adapt.
import jsonimport osimport requestsfrom openai import OpenAINEWS_API_URL = os.environ.get("NEWS_API_URL", "https://api.example.com/news")MAX_STEPS = 8 # max reasoning rounds per conversationdef fetch_latest_news(category: str) -> str:"""GET is idempotent, so retry on transient failures."""last_error = Nonefor _ in range(3):try:resp = requests.get(NEWS_API_URL, params={"category": category}, timeout=5)resp.raise_for_status()return resp.text[:4000] # truncate to avoid filling the contextexcept requests.RequestException as e:last_error = ereturn json.dumps({"error": f"tool execution failed: {last_error}"}, ensure_ascii=False)TOOL_REGISTRY = {"fetch_latest_news": fetch_latest_news}TOOL_DEFINITIONS = [{"type": "function","function": {"name": "fetch_latest_news","description": "Get latest news titles and summaries for a tech category. Use when the user asks about recent technology developments.","parameters": {"type": "object","properties": {"category": {"type": "string","description": "Tech category, e.g. artificial intelligence, cloud computing, chips"}},"required": ["category"]}}}]def agent_run(user_query: str, llm_client, model_name: str, tool_definitions: list) -> str:messages = [{"role": "system", "content": "You are an assistant that can use tools."},{"role": "user", "content": user_query},]for _ in range(MAX_STEPS):response = llm_client.chat.completions.create(model=model_name,messages=messages,tools=tool_definitions,)message = response.choices[0].message# No tool call: return the final answerif not message.tool_calls:return message.content# Convert SDK objects to dicts before appendingmessages.append({"role": "assistant","content": message.content or "","tool_calls": [{"id": tc.id,"type": "function","function": {"name": tc.function.name, "arguments": tc.function.arguments},}for tc in message.tool_calls],})for tc in message.tool_calls:name = tc.function.nametry:args = json.loads(tc.function.arguments)except json.JSONDecodeError:result = json.dumps({"error": "arguments are not valid JSON"}, ensure_ascii=False)else:func = TOOL_REGISTRY.get(name)if func is None:result = json.dumps({"error": f"unknown tool: {name}"}, ensure_ascii=False)else:try:result = func(**args)except TypeError as e:result = json.dumps({"error": f"argument error: {e}"}, ensure_ascii=False)messages.append({"role": "tool","tool_call_id": tc.id,"content": str(result),})return "Reached the maximum number of reasoning steps; stopped calling tools."if __name__ == "__main__":client = OpenAI(api_key=os.environ["LLM_API_KEY"], base_url=os.environ.get("LLM_BASE_URL"))print(agent_run("What is the news in AI today?", client, "your-model-name", TOOL_DEFINITIONS))
Install with pip install openai requests. Read API keys from environment variables; do not hard-code them. TOOL_DEFINITIONS is the tool list from the previous section.
Three loop details matter. First, MAX_STEPS caps rounds. Second, assistant messages are converted to dicts so SDK objects do not break serialization. Third, JSON parse, tool lookup, and argument checks each have failure paths; errors go back to the model as text.
Framework or a custom loop
LangChain, LlamaIndex, and similar libraries wrap this loop. They help for prototypes or multi-vendor models. A custom loop is transparent, has fewer dependencies, and is easier to debug. Both still need step limits, argument validation, and timeouts. If you need an auditable call path, get a native SDK flow working first, then decide whether to add a framework.
Debugging failed tool calls
- Model never calls tools: Check that
descriptionstates when to use the tool and thattoolsis actually sent. - Bad argument format: Print the raw
tc.function.argumentsstring. Often field names do not match the schema, or the model emitted invalid JSON. - Tool succeeds but the result is useless: Check length and structure. Long pages or logs fill the context; truncate or summarize first.
- Loop never stops: See whether
MAX_STEPSwas hit, and log each round’s tool name and arguments to find repeated calls.
Cloud deployment notes
When you run an agent in containers or other cloud services:
- Least privilege: Grant the service account only what the task needs. Prefer read-only. Writes and deletes should require human confirmation.
- Secrets: Store model API keys and tool credentials in the platform’s secret manager and inject them via environment variables or mounts.
- Log redaction: Logging model I/O helps debugging. Redact phone numbers, IDs, and similar PII before writing logs.
- Cost alerts: Alert on usage and spend; cap rounds and output length per request.
- Rollback: Keep the previous image so you can revert quickly.
Object storage can hold long-term memory and user uploads; logging services collect reasoning and tool traces. The platform provides these; the agent code does not need to reinvent them.
Summary
Giving a model “hands” is not about how many tools you wire up. Clear tool descriptions, errors the model can read, a bounded loop, and tight permissions determine whether a prototype can run in production.
