05. Function Calling Integration
Function calling is how the LLM tells your agent to act. Integrating it correctly means handling the protocol precisely—request format, response parsing, error recovery.
Request format (OpenAI-compatible):
def build_function_call_request(
messages: list[dict],
tools: list[dict],
temperature: float = 0.0
) -> dict[str, Any]:
return {
"model": "gpt-4-turbo",
"messages": messages,
"tools": tools,
"tool_choice": "auto", # Let model decide
"temperature": temperature
}
Response parsing:
from dataclasses import dataclass
from typing import Optional
@dataclass
class ToolCall:
id: str
name: str
arguments: dict[str, Any]
@dataclass
class LLMResponse:
content: Optional[str]
finish_reason: str
tool_calls: list[ToolCall]
def parse_response(raw: dict) -> LLMResponse:
raw_message = raw.get("choices", [{}])[0].get("message", {})
tool_calls = []
for tc in raw_message.get("tool_calls", []):
tool_calls.append(ToolCall(
id=tc["id"],
name=tc["function"]["name"],
arguments=json.loads(tc["function"]["arguments"])
))
return LLMResponse(
content=raw_message.get("content"),
finish_reason=raw.get("choices", [{}])[0].get("finish_reason"),
tool_calls=tool_calls
)
Critical detail: tool_choice: "auto" lets the model decide whether to use a tool or respond directly. Using "required" forces tool use, which is sometimes what you want but often causes issues when the model should just answer.
Failure mode: JSON parsing errors. Model-generated JSON arguments sometimes contain trailing commas, missing quotes, or invalid escape sequences. Resilient parsing handles these:
def safe_parse_arguments(arg_str: str) -> dict[str, Any]:
# Attempt JSON parse
try:
return json.loads(arg_str)
except json.JSONDecodeError:
# Try fixing common issues
cleaned = arg_str.replace(",\n}", "\n}").replace(",\n]", "]")
try:
return json.loads(cleaned)
except json.JSONDecodeError as e:
raise ToolArgumentError(f"Failed to parse arguments: {e}\nRaw: {arg_str}")
Failure mode: argument type coercion. The model might pass "42" (string) for an integer parameter. Implement coercion at the registry layer:
def coerce_arguments(tool: Tool, raw_args: dict[str, Any]) -> dict[str, Any]:
coerced = {}
for param_name, param_schema in tool.parameters.get("properties", {}).items():
if param_name in raw_args:
raw_value = raw_args[param_name]
target_type = param_schema.get("type")
if target_type == "integer" and isinstance(raw_value, str):
coerced[param_name] = int(raw_value)
elif target_type == "number" and isinstance(raw_value, str):
coerced[param_name] = float(raw_value)
else:
coerced[param_name] = raw_value
return coerced
Build a mock LLM client that returns function calls with one malformed argument. Write the parsing and coercion logic to handle it gracefully without crashing.