Tool/function calling 

Tool calling (also known as function calling) allows the LLM to request execution of functions you define. The model decides when to call a tool based on the conversation context.

Sequence: the application sends a chat request with tool definitions, the model answers with tool calls, the tool gate admits or denies each call, the application executes the admitted ones and returns their results, and the model produces the final answer.

One round of the bounded loop. The gate runs before the model is offered anything, and a call whose tool declares a write effect suspends the run for approval before it executes.

Defining tools 

Example: Tool/function calling
$tools = [
    [
        'type' => 'function',
        'function' => [
            'name' => 'get_weather',
            'description' => 'Get current weather for a location',
            'parameters' => [
                'type' => 'object',
                'properties' => [
                    'location' => [
                        'type' => 'string',
                        'description' => 'City name',
                    ],
                    'unit' => [
                        'type' => 'string',
                        'enum' => ['celsius', 'fahrenheit'],
                    ],
                ],
                'required' => ['location'],
            ],
        ],
    ],
];
Copied!

Executing tool calls 

CompletionResponse::$toolCalls is a list of NetresearchNrLlmDomainValueObjectToolCall value objects — $toolCall->arguments is already a JSON-decoded associative array, so no manual json_decode() is needed. The two follow-up turns are built with the ChatMessage factories: ChatMessage::assistantToolCalls() echoes the assistant turn that carries the tool calls, and ChatMessage::toolResult() answers one call by its id.

Example: Handling tool call responses
use Netresearch\NrLlm\Domain\ValueObject\ChatMessage;

$response = $this->llmManager->chatWithTools($messages, $tools);

if ($response->hasToolCalls()) {
    // Echo the assistant turn (with all its tool calls) back first
    $messages[] = ChatMessage::assistantToolCalls(
        $response->toolCalls,
        $response->content,
        $response->metadata['nrllm_provider_items'] ?? null,
    );

    foreach ($response->toolCalls as $toolCall) {
        // Execute your function — $toolCall->arguments is a decoded array
        $result = match ($toolCall->name) {
            'get_weather' => $this->getWeather($toolCall->arguments['location']),
            default => throw new \RuntimeException("Unknown function: {$toolCall->name}"),
        };

        // Answer the call by its id
        $messages[] = ChatMessage::toolResult($toolCall->id, json_encode($result, JSON_THROW_ON_ERROR));
    }

    // Ask the model to answer with the tool results in context
    $response = $this->llmManager->chat($messages);
}
Copied!

Providers that implement ToolCapableInterface support tool calling.

Provider-owned conversation context 

Gemini generateContent and OpenAI Responses need native assistant context between tool steps. The built-in tool loop carries it automatically, including through stored suspension and resume (ADR-222). Manual loops pass the response's nrllm_provider_items metadata to the assistant factory as shown above. Store ChatMessage::toTranscriptArray() and restore it with ChatMessage::fromArray(); the ordinary wire array and JSON serialization omit this internal context.

Populated Gemini native function-call IDs additionally use an internal per-turn binding to the public tool-call ID (ADR-226). Corresponding results echo the native ID; reordered identical parallel calls retain their own association after stored approval resume. ID-less calls keep the existing result shape. Older owned state with native IDs is accepted only when its decoded name and arguments identify a unique association; malformed or ambiguous owned bindings are refused before contact. Integer and decimal representations match only when conversion preserves the numeric value in both directions; strings and booleans remain distinct. These bindings stay inside the opaque carrier and do not become OpenAI payload fields.

Gemini preserves the original native parts and thought signatures. It uses an explicit ownership marker so a fallback to OpenAI Responses reconstructs ordinary visible text and tool calls. Historical OpenAI items keep their own replay semantics. A malformed or ambiguous recognized Gemini capsule is refused before provider contact. Switching providers cannot guarantee native reasoning continuity on a provider or model that cannot consume that context. New streamed-response replay state is outside this contract.

Running the tool loop without an approval step 

ToolLoopServiceInterface::runLoop() suspends a run when the model calls a tool that needs a human approval, and throws ToolApprovalRequiredException. A caller that has no approval step, such as an editor dialog, offers the model only the tools that never suspend: first the tools the policy allows, then those of them that run without an approval and without asking for typed input (ADR-210).

Example: offering only tools that run without approval
use Netresearch\NrLlm\Service\Tool\ToolCallPolicyInterface;
use Netresearch\NrLlm\Service\Tool\UnattendedToolFilterInterface;

$offerable = $this->toolCallPolicy->filterOfferable(null, $configuration, $backendUser);
$tools = $this->unattendedToolFilter->unattended($offerable);

$result = $this->toolLoopService->runLoop($messages, $configuration, $context, $tools);
Copied!

An empty list offers no tools at all; pass it as is rather than null, which would mean "every enabled tool".

The filter applies the same rule as an agent run. A remote (MCP) tool is judged on the operator's declaration on its server record, not on its effect, so an undeclared remote tool counts as running without approval here too.