---
title: "ADR-054: Typed tool turns on ChatMessage instead of wire arrays"
manual: "TYPO3 LLM Extension"
version: "0.35"
permalink: "https://docs.typo3.org/permalink/netresearch/nr-llm:adr-054@0.35"
source: "Adr/Adr054TypedToolTurnMessages.rst"
modified: "2026-09-16T22:09:16+00:00"
---

# ADR-054: Typed tool turns on ChatMessage instead of wire arrays

-   *Status:* Accepted
-   *Date:* 2026-07-13
-   *Authors:* Netresearch DTT GmbH

## Context

`ChatMessage` modelled only `(role, content)`. Two message shapes
that every tool-calling conversation needs could not be expressed as
value objects:

1.  the **assistant turn** that carries the model's `tool_calls`, and
1.  the **tool turn** that answers one call via its `tool_call_id`.

So every tool loop hand-built raw OpenAI-wire arrays:
`ToolLoopService` assembled both turns as associative arrays (with the
`arguments` JSON-encoding and the empty-`{}`-not-`[]` subtlety
inlined at the call site), and consuming extensions copied the pattern.
The developer documentation taught the raw-array shape and had drifted
further: its example still treated `CompletionResponse::$toolCalls`
elements as nested arrays (`$toolCall['function']['name']`) although
they have been typed `ToolCall` value objects since that migration.

Untyped wire arrays at the public seam mean no validation (a `tool`
message without a `tool_call_id` fails only at the provider, with a
provider-specific 400), duplicated serialisation subtleties, and drift
between code and documentation.

## Decision

`ChatMessage` gains two optional, validated tail fields and two named
constructors — it stays the single value object for all four roles
rather than growing per-shape subclasses:

-   `?array $toolCalls` (`list<ToolCall>`), allowed only on the
    `assistant` role; every element must be a `ToolCall`; an empty
    list is rejected (providers 400 on `tool_calls: []`).
-   `?string $toolCallId`, allowed only on the `tool` role and must be
    non-empty.
-   `ChatMessage::assistantToolCalls(array $toolCalls, ?string
    $content = null)` — `null` content (what providers send alongside
    tool calls) is stored as `''` because the `$content` property
    stays a non-nullable string.
-   `ChatMessage::toolResult(string $toolCallId, string $content)`.

**Wire shape.** `toArray()` (and `jsonSerialize()`) emits the
OpenAI-compatible request form: `tool_calls` entries carry
`function.arguments` as a JSON-encoded **string**, with empty
arguments encoding to `{}` (an object), never `[]`; `tool_call_id`
is emitted when set. This deliberately differs from
`ToolCall::toArray()`, which keeps the legacy decoded-map form for
`CompletionResponse` consumers — `ToolCall::fromArray()` accepts
both variants, so `ChatMessage::fromArray()` round-trips either shape
(and accepts `content: null` alongside `tool_calls`).

**Transport path.** Every provider adapter flattens messages via
`$m instanceof ChatMessage ? $m->toArray() : $m` before building its
payload, and `LlmServiceManager::normaliseMessages()` passes
`ChatMessage` instances through untouched — no layer rebuilds messages
from `role` \+ `content` alone, so the new fields reach the HTTP
payload intact. `ClaudeProvider` / `GeminiProvider` / `Ollama`
already convert *from* that OpenAI wire shape into their native formats.
A unit test pins the path end-to-end at the mocked HTTP boundary.

`ToolLoopService` builds both turns through the new factories; its
private raw-array assembly is deleted.

## Consequences

-   Tool loops — in this extension and in consumers — compose typed,
    validated turns; invalid shapes (a tool result without an id, tool
    calls on a user message) fail fast with nr_llm's
    `InvalidArgumentException` instead of a provider 400.
-   The `arguments`-encoding and `{}`-vs-`[]` subtleties live in
    exactly one place, `ChatMessage::toArray()`.
-   Raw-array messages remain accepted everywhere for back-compat: the
    manager's normalisation still passes richer arrays through unchanged,
    and `fromArray()` now understands the two tool-turn keys.
-   `ChatMessage::toArray()`'s return shape gains two optional keys;
    callers that assumed exactly `{role, content}` for *tool-loop*
    messages must use the documented shape. Plain messages serialise
    byte-identically to before.
-   The developer documentation example is rewritten on top of the value
    objects, closing the `$toolCall['function']['name']` drift.
