05. Function Calling Integration

Chapter 5 of 24 · 20 min

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
EXERCISE

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.