ADR-030: symfony/ai Adoption — Platform Yes, Tool-Calling No (on llama3.2:3b)
Withdrawn (2026-08-11) — AI support has been removed from the product. See ADR-052. This record is kept for historical context only.
- Status: Withdrawn by ADR-052 (AI support removed)
- Date: 2026-05-22
- Depends on: ADR-028 (Ollama/LLaMA Integration Architecture)
- Supersedes / refines: none — operational evolution of the LLM transport layer
Superseded by ADR-042 (2026-06-19): the self-hosted Ollama/LLaMA tier was replaced by an optional, per-user, multi-provider bring-your-own-token model. The Ollama service, OLLAMA_* env and the bundled LLM resource have been removed. The
symfony/ai-platformtransport layer described below still holds, but thesymfony/ai-ollama-platformbridge is dropped in favour of the Anthropic, Gemini and OpenAI bridges, and the client is built per user with the user's own key.In-ride superseded by ADR-048 (2026-08-06): the in-ride assistant described here is now AI-free.
Context and Problem Statement
ADR-028 chose a custom thin wrapper around Ollama's HTTP API (App\Llm\OllamaClient) plus a JSON envelope strategy on top of the format: "json" decoding mode: the model is asked to emit { "action", "params", "response" } and a tolerant ChatActionInterpreter parses the resulting blob, falling back to an info action on malformed output.
Since ADR-028 was written, the Symfony team has published symfony/ai (v0.9.0 at the time of this ADR), a set of decoupled PHP components that abstract LLM platforms (symfony/ai-platform + bridges such as symfony/ai-ollama-platform), declarative tools and agents (symfony/ai-agent with #[AsTool] attributes), conversation persistence (symfony/ai-chat), and vector stores (symfony/ai-store).
The question for this ADR is which parts of symfony/ai we adopt and which we deliberately leave on the custom path, given:
- We use exclusively a local Ollama backend with two small LLaMA models (
llama3.2:3bfor dialogue and in-ride narrative;llama3.1:8bfor the trip overview). These are constrained by CPU inference on the operator's hardware. - We have a dual-storage chat history (Redis hot 5-turn window in
ChatHistoryStore+ PostgreSQL audit log viaTripChatMessage) that carries domain semantics (action executed, GPS position at turn time, POI suggestions) beyond a flatMessageBag. symfony/aiis explicitly experimental: every component README states it is "experimental" and not covered by Symfony's backward-compatibility promise.
The hypothesis to validate was: does it pay off to migrate the App\Llm layer to symfony/ai? Specifically, two distinct opportunities:
- Phase 1 — Transport. Replace the custom
OllamaClientHTTP wrapper withsymfony/ai-platform+ the Ollama bridge. Same model, same Ollama daemon, but typedMessageBag/Message, streaming-ready, multi-provider abstraction "for free". - Phase 2 — Tool calling. Replace the JSON-envelope strategy with
symfony/ai-agentand native tool calling: declare each action (split_stage,merge_stages,add_waypoint,change_accommodation,adjust_distance,change_route) as a#[AsTool]class, let the LLM emittool_callsdirectly through Ollama's tool-calling API.
symfony/ai-chat and symfony/ai-store are out of scope for this ADR — see the rejected options.
Decision Drivers
- Reliability on small local models. Phase 1 is mechanical; Phase 2 depends entirely on whether
llama3.2:3bcan reliably emit tool calls with the JSON-Schema-based protocol. ADR-028 deliberately picked a 3B model for CPU latency; this ADR must not silently regress accuracy. - Operational risk.
symfony/aiis at v0.9.0 with no backward-compatibility guarantee. Adopting it for a critical path requires a clear value/risk story. - Cost of the migration. The current
App\Llmlayer is 6 files / ~600 lines. The transition has to be cheaper than the value it delivers. - Future optionality. Even if we do not need streaming or multi-provider today, the option of getting them without a rewrite is genuinely useful.
- No regression to the dual-storage chat model.
TripChatMessagecarries action, position, and POI suggestion semantics that a singleMessageStoreInterface::save(MessageBag)cannot express.
Considered Options
Option A — Wholesale adoption (Platform + Agent + Chat + Store)
Replace OllamaClient, ChatActionInterpreter, ChatHistoryStore, and TripChatMessageRepository with the corresponding symfony/ai components.
Rejected.
- The custom dual-storage pattern (Redis 5-turn hot window + PostgreSQL audit) carries domain-specific data that does not fit
MessageStoreInterface. - Phase 2 (tool calling) does not work on
llama3.2:3b— see the POC results below. ReplacingChatActionInterpreterwith the Agent would actively regress dialogue accuracy. - The migration cost would be high and the rollback path narrow if
symfony/aiships a breaking change pre-1.0.
Option B — Phase 1 only (transport)
Install symfony/ai-platform + symfony/ai-ollama-platform + symfony/ai-bundle. Make OllamaClient a façade that delegates to PlatformInterface while preserving the LlmClientInterface contract (isEnabled, generate, chat with the same legacy return shapes). Keep everything else untouched.
Chosen. Trade-offs:
- Pros: typed
MessageBag/Messageat the boundary, streaming available without a rewrite, broader provider catalogue ready when needed, transport now maintained by the Symfony team rather than us. - Cons: each
Platform::invoke()makes an extraPOST /api/showround-trip via the defaultModelCatalog(typed capability discovery). On a local Ollama this is single-digit milliseconds, but it doubles the HTTP traffic. - Risk surface: small. The contract preserved by the façade means every caller (analyze handlers, intent detector, in-ride assistant, chat processor) keeps its existing call shape, and the existing graceful-fallback /
OllamaUnavailableExceptionflow remains in place.
Option C — Phase 1 + Phase 2 (Platform + Agent tool calling)
Same as Option B, plus migration of the dialogue action interpretation from the JSON envelope to native tool calling via #[AsTool] classes.
Rejected based on POC measurement (see "POC results" below). Tool-calling accuracy on llama3.2:3b is less than half of the JSON-envelope baseline on the same model and the same Ollama daemon. The failure mode is not a flaky edge case — it is reproducible and structural.
Option D — Status quo (no adoption)
Stay on the custom OllamaClient end-to-end. Re-evaluate symfony/ai after it reaches a stable release.
Rejected. Phase 1 is low-risk and unlocks streaming / multi-provider optionality at a marginal maintenance cost. The composer dependency is a small price for a layer that we no longer have to maintain ourselves.
POC Results — Tool calling on llama3.2:3b
The findings below come from a throwaway benchmark that ran 20 representative French chat prompts (3 per action + 3 "info"/no-tool prompts) through both pipelines back-to-back, against the same Ollama daemon with the same model. Pre-warming and keep_alive: 30m were applied to both passes so the comparison was not skewed by model load. The benchmark code was deliberately not kept in the repository — this ADR captures the substance.
The six tools exposed to symfony/ai-agent mirrored the six actions of the JSON envelope (split_stage, merge_stages, add_waypoint, change_accommodation, adjust_distance, change_route) as #[AsTool] classes, with the same parameter shapes (integer stage indices, string accommodation enums, etc.). The toolbox-generated JSON Schema matched what the bridge sends on the wire (verified by Symfony\AI\Platform\Bridge\Ollama\Contract\OllamaContract::createToolOption()).
Headline numbers (single run, 20 prompts, llama3.2:3b)
| Strategy | Match rate | Median latency |
|---|---|---|
symfony/ai-agent tool calling |
7 / 20 (35 %) | ~5 s |
Legacy JSON envelope (ChatActionInterpreter) |
15 / 20 (75 %) | ~15 s |
Tool calling is consistently ~3× faster (less output to generate — just the tool name and arguments, no conversational response field). It is also less than half as accurate.
Reproducible failure modes — tool calling
-
JSON-Schema leakage into argument values (5 / 20 cases). The model emits the schema fragment in the place of the value:
{"name":"split_stage","args":{"stage":{"description":"The 1-based index of the stage to split","type":"integer"}}}Same prompt, run through the JSON envelope, gives the correct
{"action":"split_stage","params":{"stage":2}}. -
Wrong tool selection on conversational prompts (2 / 3 "info" cases).
Bonjour !triggerssplit_stage(stage=1);Quelle est la différence entre gravel et bikepacking ?triggerssplit_stage(stage=2). The JSON envelope correctly returnsinfoin all three "info" cases. -
String-coerced arguments. Even when the value is correct, integer fields come back as strings (
{"stage":"3"}instead of{"stage":3}). Recoverable by callers, but indicative of weak type adherence. -
Hallucinated arguments on parameterless tools.
change_route(zero-argument tool) was called with{"stages":"{}"}and{"_":"[[1]]"}— the model fabricates content rather than emitting an emptyargumentsobject. -
Accommodation type drift.
Étape 1 : je préfère dormir en gîteproduces{"stage":"null","type":"gète"}(typo plus invalid stage), versus the JSON envelope'sinfofallback which at least keeps the conversation usable.
Why the JSON envelope wins on this size of model
- The schema is communicated in natural language inside the system prompt, not as a formal JSON Schema attached to the request. Small models tolerate this much better than the structured tool-protocol metadata.
- The envelope forces a single, coherent output (
action+params+ conversationalresponsein one JSON object). Tool calling separates the action from the user-facing reply, which would require a second model turn to generate the French response and recomposes the latency advantage away. ChatActionInterpreteris a tolerant parser: it strips Markdown fences, smart quotes, leading prose, and whitelists the action vocabulary. This forgiveness is exactly the safety net a small model needs.
These observations are specific to ~3B-parameter local models. On a 70B+ model or GPT-4o/Claude-class providers, tool calling would almost certainly win. The decision below is therefore conditional on continuing to run small local models — see "When to revisit" below.
Decision Outcome
Chosen: Option B — Phase 1 only.
App\Llm\OllamaClientis rewritten as a thin façade overSymfony\AI\Platform\PlatformInterface(Ollama bridge wired through the bundle, pointing to the existing scopedollama.clientHTTP client). TheLlmClientInterfacecontract is preserved; every caller is untouched. Return shapes remain['response' => string]forgenerate()and['message' => ['content' => string]]forchat()so the existing JSON-envelope flow keeps working.- The dialogue layer keeps the JSON envelope (
SystemPrompt/dialogue.txt+ChatActionInterpreter). No#[AsTool]classes in production. - Not adopted (for now):
symfony/ai-chat—ChatHistoryStore(Redis hot window) andTripChatMessage(PostgreSQL audit with action / GPS / POIs) carry domain semantics thatMessageStoreInterface::save(MessageBag)cannot express.symfony/ai-store— no current product need for embeddings / vector search; Overpass + regex-basedPoiIntentDetectorcovers the in-ride flow.symfony/ai-mcp— no need yet to expose this app as an MCP server.
Implementation notes
- The Ollama bridge issues a
POST /api/showperPlatform::invoke()to discover model capabilities. For our two-model fixed setup this overhead is ~2 ms locally. Acceptable; if it ever shows up in latency budgets, the fix is a staticModelCatalogInterfacereturning hard-codedOllamainstances. - Cold model loads on CPU can exceed the default 30 s
ollama.clienttimeout. The existingOllamaUnavailableException+ graceful-fallback chain already covers the user-visible behaviour; bump the scoped timeout if this becomes a recurring complaint in production. - The POC harness used to produce the numbers above is not retained in the repository. If the trigger conditions below fire and we need to re-evaluate, rebuild the benchmark from the methodology described in the previous section.
When to revisit
The "stay on JSON envelope" decision is conditional on continuing to run small local models. Rebuild the benchmark and reopen this ADR when any of these hold:
- We upgrade the dialogue model to ≥ 8B parameters (e.g. switching
OLLAMA_DIALOGUE_MODELtollama3.1:8bor a tool-call-tuned variant such ashermes-3:8b). - Ollama ships a deterministic fix for the schema-leakage failure mode on 3B models.
- A future
symfony/ai-platformrelease introduces an Ollama contract that constrains arguments more strictly (e.g. by post-validating tool_call arguments against the schema). - Product evolution requires multi-step agentic flows (chain of tool calls, RAG over a vector store) that the JSON envelope cannot express in a single shot.
Until one of these triggers fires: the dialogue stays on the envelope, this ADR stays accepted.