Back to projects

Arthur

Personal multi-agent assistant — personal project, in production, not commercialised.

What Arthur does

Arthur was designed as a single-user personal assistant: a secretary and external memory. Concretely, it does three things.

It captures what is said on the fly and files it in the right memory — note, reminder, preference, memory, project decision — without needing to choose the drawer yourself.

It tracks work state: session timer tied to a project, cumulative time, daily and weekly briefing, active projects and their phases.

It retrieves this on demand, in writing or aloud, the target being hands-free use.

The problem is not making an agent talk

An assistant that does nothing is useless, and one that announces an action it didn't take is harmful. Arthur is a personal multi-agent assistant whose design criterion #1 is not performance — it's that it cannot lie about what it just did.

This risk is not theoretical. Over three weeks of real use, four ways of producing it were observed and traced. They don't look alike, and they don't call for the same response.

29 July 2026

The tool fails, the response announces success

A work-time query is made for a future date. The tool refuses and says so: no session can exist in the future, no reading was made. The displayed response announces zero minutes. A refusal to read had just been converted into a measured figure — and zero minutes is perfectly credible data.

20 August 2026

No tool is called, the action is narrated in the past tense

A reminder is dictated about an administrative matter. The agent receiving the request doesn't have the reminder creation tool in its list, and doesn't say so: it replies "got it, noted" without calling any tool. Then it invents a fact to justify its inaction: that the matter is already being handled by a third party, something nobody had said. Reproduced three times in the same session.

4 August 2026

A different action is taken and presented as the one requested

Arthur can store user preferences in memory. Two traces five minutes apart. A preference removal is requested. No removal tool exists at the time. Rather than failing, the agent writes a preference contrary to the one it was meant to erase — a rule nobody had ever stated — and presents the operation as the requested removal.

17 August 2026

The refusal is silenced

A tool refuses. No false statement is made. The response simply moves on to something else, and the refusal is mentioned nowhere. This is the hardest family to spot: there is nothing to contradict, only a false belief transmitted by omission.

The first three are false statements. The fourth is silence. An honesty instruction in the prompt addresses none of the four: it asks the model to watch itself at the exact moment it has already stopped doing so.

Demo

Architecture overview

One call, one classification. The supervisor never gets the floor back after routing: it's a router, not an orchestrator. It classifies the request on two axes — which agent (Intendant or Mentor) and which domain (WORK, DAILY, PERSONAL). The domain labels the conversation in the database on the first exchange; it influences neither the routing nor the response content. Routing is stochastic: the same sentence can go to different agents depending on the turn. This is what requires duplicating tools rather than fixing the prompt.

Own tools: lister_rappels · marquer_rappel_fait · lire_briefing_hebdo.

Own tools: lister_projets · lire_projet · creer_projet · modifier_projet · acter_decision · memoriser_souvenir.

12 shared tools

creer_note · lister_notes · creer_rappel · lire_temps_travail · memoriser_preference · lister_preferences · desactiver_preference · chercher_dans_memoire · demarrer_session · suspendre_session · reprendre_session · arreter_session

Present in both lists because routing is stochastic — a tool missing on one side is a gap where the agent invents.

There is no tool node in the graph: the loop lives entirely inside the specialist node. The model calls, the code executes, the model rereads — up to five times, or until a refusal or briefing cuts the loop. The stop always comes after the current batch, never mid-way: all writes in a turn go through before the loop cuts.

The specialist goes straight to END — never back to the supervisor. The tool loop is not an edge in the graph: it lives inside the node. A refusal or briefing cuts the loop; there is no next turn.

The refusal path

The model can emit several calls in one turn, and Mistral does. They are executed one after the other, in the order the model requested them.

The tool returns a text + artifact pair instead of a plain string. It can only do so if it declares responseFormat: "content_and_artifact" — eleven tools out of twenty-one. Without this line, the artifact is silently discarded: nothing signals it, neither at compile time nor at runtime.

An exception caught in the loop builds a ToolMessage carrying { type: "panne" }. This path depends on no declaration: it covers all twenty-one tools, without exception. It is the second origin of the same marker, and the reason the actual coverage is broader than the eleven tools in the previous block.

Four type guards read the artifact as it arrives. Three feed the incident channel — chrono refusal, business refusal, failure. A fourth recognises the briefing, which cuts for the opposite reason: preventing the model from rephrasing text already composed by code. The first incident in the node wins and is never overwritten by a later one.

Without a marker, nothing cuts. Ten tools have no refusal branch: they always write. Two others, lire_projet and chercher_dans_memoire, do refuse — but in free text, without an artifact. The model rereads this text on the next turn and narrates it itself, with all the risks that implies. The 'Unknown tool' branch, triggered by an invented tool name, is in the same situation.

The stop comes after the batch, never mid-way. All calls in the turn are executed, including those that write, before the loop cuts. This is deliberate: interrupting a batch mid-way would leave the database in a state nobody could describe.

The code produces the text shown on screen via composerRapport and composerRapportParle. No model call occurs on this path: a label table, an exhaustive switch, and the tool name taken from the emitted call rather than from the response. The vocabulary distinguishes three degrees of certainty — a refusal asserts nothing was written, a failure admits the effect is unknown and names the page to check.

The report surfaces through the state channel, read on on_chain_end in chat.ts, and joins the NDJSON stream like any other piece of text.

What this mechanism does not guarantee. The model's stream is consumed entirely before tools execute: by the time the refusal is detected, the turn's tokens have already reached the browser, and the client concatenates them without ever erasing them. The report is appended in the same bubble — it replaces nothing. What the wiring actually guarantees is that the model never gets the floor back after a refusal: it can neither comment on it nor rephrase it. That it said nothing before rests on a measurement — four traces out of four where the message carrying tool calls had empty content — not on a code property.

Four decisions

Decision 1 — Graph architecture

The supervisor classifies in one call, specialists go straight to END

The problem. A standard LangGraph supervisor takes the floor back after each specialist to decide what comes next — more powerful, but more expensive and less predictable. The same sentence can go to different agents depending on the turn: no prompt will route correctly 100% of the time.

My choice. One call, one classification. The supervisor never gets the floor back. Rather than fixing the routing, tools are duplicated in both lists: giving a tool to both agents costs less than winning a routing battle.

The trade-off. If routing makes a mistake, there is no correction possible in the same turn. The graph contains no return edge to the supervisor — this is a state of the code, not a promise of future correction.

When I'd do the opposite. If Arthur had to handle multi-step tasks where the agent must report back before continuing, a true supervisor with control resumption would be necessary.

Decision 2 — Memory system

Four distinct memory types rather than a single store

The problem. Storing everything in a single vector forces the model to distinguish a lasting preference from a one-off note from a project decision. Observation from 31 July: two out of three memories in the database were tasks that should have gone to reminders or notes, and technical choices were consuming slots in the preference limit.

My choice. Four distinct types: preference (modifies behaviour, injected every turn), memory (retrieved by similarity on explicit call), note (memo with no effect on behaviour), decision (tied to a project). Routing no longer relies on the verb used but on a staleness test: is there an action in the world that would make this item done?

The trade-off. The model must choose the right drawer. The taxonomy is written nowhere in the system prompt: Arthur does not know its own classification. A wrong filing is silent — nothing signals it, neither at compile time nor at runtime.

When I'd do the opposite. For a general-purpose assistant, a single store with automatic classification is simpler to explain and maintain. Here the user is unique and control takes precedence over simplicity.

Decision 3 — Long-term memory access

Explicit RAG: memory is only reachable via tool call

The problem. Automatic RAG injects memories before every turn, without the model knowing what it receives or why. Conversations themselves are not automatically vectorised — memorising is an act, not a side effect.

My choice. chercher_dans_memoire is a tool the model decides to call. If the search returns empty, it says so. It never fills the gap. The cosine threshold is set to 0.6: an anti-hallucination safeguard, not an optimisation — without a threshold, a search always returns results even off-topic.

The trade-off. The model may forget to search. The instruction in the tool contract — 'use it before saying you don't know' — partially compensates, but it's a prompt lever: weak by design. The absence of automatic search at the start of each turn is not a separately documented decision — it is a consequence of the architecture.

When I'd do the opposite. If memories were very frequently relevant, automatic RAG with a high cosine threshold would reduce missed searches without polluting the context too much.

Decision 4 — Reliability on tool refusals

When a tool refuses, the code speaks

The problem. Four families of hallucination observed in real use. The most insidious: the silenced refusal — no false statement, but the refusal is mentioned nowhere and a false belief is transmitted by omission. An honesty instruction in the prompt addresses none of the four: it asks the model to watch itself at the exact moment it has stopped doing so.

My choice. A tool that refuses places a marker on the ToolMessage. The graph detects it, cuts the loop after the current batch, and composerRapport writes the response — a label table and an exhaustive switch, without a model call. The vocabulary distinguishes three degrees of certainty: a refusal asserts nothing was written, a failure admits the effect is unknown and names the page to check.

The trade-off. Four write tools — creer_note, creer_rappel, marquer_rappel_fait, memoriser_souvenir — share the same blind spot: a write that succeeds with the wrong content or the wrong target throws no exception and declares no refusal. Circuit-breaking has nothing to intercept. Yet these are the most heavily used tools in hands-free mode. Their reliability rests entirely on their tool contracts.

When I'd do the opposite. If the tools were more precise and error cases better bounded, one could let the model comment on the refusal and suggest an alternative. The cut came first because the edges were not bounded — that's the order that matters.

What it costs

Ten real requests, traced on LangSmith on September 8 and 9, 2026: between 1.5 and 3.6 seconds end to end, between 0.41 and 0.57 second before the first token. Between 7,000 and 16,600 tokens consumed per exchange, for a cost of $0.003 to $0.008. The conversation runs on mistral-large-latest, Mistral's large model; the small model, mistral-small-latest, is reserved for routing.

LangSmith screenshot: ten traced requests with latency, tokens, and cost

What remains open

Circuit-breaking cuts the loop; it doesn't fix anything. A tool that refuses today produces a sentence composed by code — never by the model — and that's deliberate as long as error cases stay coarse. The day the tools get finer, with better-bounded refusal reasons, letting the model comment on the refusal and suggest an alternative becomes worth revisiting. The cut came first because the edges weren't bounded — refinement comes after, if it comes.

The only test that checks tools fire correctly for the model runs in isolation, outside the real graph: it proves the tools work with the LLM, never that they're correctly wired into the two production agents. A tool removed from one side and not the other would go undetected.