Plain chat adapter: instructions plus field-labelled user content.
format/3 renders a signature and its inputs as a system message, one
user/assistant pair per demo, the conversation history, and a final user
message. Fields are labelled with [[ ## name ## ]] markers.
parse/3 accepts an Imp.Prediction, a map of output fields, or completion
text. Text is split on those markers and the first section for each output
field wins. An output with no section takes its declared default, or nil
when optional; a required output with no section is a parse error, and so is
a completion with no section for any output, even when every output is
optional or defaulted, so the JSON fallback or a retry runs.
One signature-declared exception: signature.metadata[:text_field] names an
output field that takes a completion carrying no marker at all. A native tool
loop asks for a thought and tool calls, and a model that answers a step in
plain text with no tool call has said something and called nothing — the
plain reading of that completion, not a format failure worth a second LM call
through Imp.Adapter.JSON. Remaining outputs take their declared defaults, so
the signature says what an unanswered field means. The exception is narrow on
purpose: the completion must carry no [[ ## field ## ]] line anywhere, no
opening tag for an output, and no JSON object with an output's key, and it
must not be blank. A completion in one of those formats tried to answer in
fields, so it is parsed by markers, and one with no output section is a
parse error that the JSON fallback reads. A signature without that metadata parses by markers
alone. A blank completion is a step that said nothing. The text a model writes beside native tool calls is
read the same way. Imp.Predict.ReActV2 sets it on its internal step
signature; see that module.
Options to format/3: :demos, :response_instruction, :guidance,
:agent_line (the guidance's "You are an Agent..." line: a string replaces
it, nil leaves it out, and when absent it is DSPy's),
:omit_empty_request, and the renderer seams :output_renderer,
:input_section_renderer, :system_renderer, :tool_result_renderer and
:history_note_renderer,
which let another adapter reuse this message assembly with its own dialect
and let a host bound what a tool result costs in the prompt without changing
what the loop records. :history_note_renderer is the one seam for saying
something about a stored turn rather than re-rendering it: it is consulted
for every history turn, native tool turns included, after that turn's own
messages, and its text becomes one user message right after them — the next
thing the model reads. A note is data about the turn (the answer was not
delivered, the account's allowance ran out), not a rewrite of what happened,
so the record the loop keeps is unchanged. Options outside that list
are ignored; anything that is not a keyword list raises ArgumentError.
Naming messages
A message can carry a participant name (the OpenAI chat format's optional
name, which providers expect to match ^[a-zA-Z0-9_-]{1,64}$). A history
turn names its own messages with two keys beside its fields: :user_name
for the turn's user message and :assistant_name for its assistant message.
The request inputs name the final user message with :user_name:
history =
Imp.History.new([
%{question: "Who won?", answer: "Blue team.", user_name: "alice"},
%{question: "By how much?", answer: "Two points.", user_name: "bob"}
])
Imp.Predict.call(predict, %{question: "Rematch?", history: history, user_name: "alice"})These keys are message metadata: they are never rendered as fields, a
message is named only when the key holds a non-empty string, and the name is
passed through as given (a provider that rejects a malformed name says so).
Notes and tool results are never named. A signature that declares a field
called user_name or assistant_name keeps it as an ordinary field. The
ReqLLM client copies the name onto the message it sends; a provider whose
format has no such field (Anthropic's Messages API) does not receive it, and
a message without a name goes out exactly as before.
A name that reaches the provider is not always shown to the model. Many upstreams accept the field and drop it, among them Google's, DeepSeek's, Kimi's, Mistral's and Grok's, and open-weight models served through hosts such as DeepInfra, whose chat templates have no place for it. When the model must know who is speaking, say so in the message content as well.
Extending the default rendering
A renderer runs for whichever adapter formats the request, which is not
always the one a program names: a Chat reply that cannot be parsed is asked
again through Imp.Adapter.JSON with the same renderers. So the options a
renderer receives carry that adapter's own default rendering, and a renderer
that adds to the default calls it rather than naming an adapter:
system_renderer = fn signature, opts ->
"Answer tersely." <> opts[:default_system].(signature, opts)
endopts[:default_system] takes (signature, opts). An :output_renderer of
arity 4 receives the options as its fourth argument, with
opts[:default_outputs] taking (signature, outputs, missing_message). The
JSON and XML adapters pass the same two, for their formats. A renderer that
writes a format of its own writes it whatever the adapter.
Summary
Functions
Formats a model-facing tool result, preserving explicit typed image content.
Renders a tool result as the text a reader should see, whether that reader is the model or a person looking at a host's tool card.
The words for a failed tool call, without the Error: that
format_tool_result/1 puts before them: the text an MCP error result carries,
or one sentence for any other reason.
Functions
@spec format_tool_content(term()) :: String.t() | [String.t() | Imp.Adapter.Types.Image.t()]
Formats a model-facing tool result, preserving explicit typed image content.
Lists containing only strings and Imp.Adapter.Types.Image values, with at
least one image, retain their ordered content. All other values use
format_tool_result/1, whose textual contract is unchanged. Custom
:tool_result_renderer functions may bound the strings while retaining the
images. Chat associates those images with the originating tool call and
places them in user messages after all tool responses in the turn.
Renders a tool result as the text a reader should see, whether that reader is the model or a person looking at a host's tool card.
Successful results format like any other value. A failed result renders as
plain text instead of an Elixir term. An MCP error result is the text its
tool wrote, after Error: unless that text already begins with "error",
since the model is given no other sign that the call failed. Every other
failure is one sentence after Error:: a JSON-RPC error is the server's
message; a call that got no answer says why in words, and unless it was
never sent, that it may have been carried out; a denied call says who
declined it; a crashed tool names itself and its message, and one that
exited says it may have been carried out; an unknown tool, a rejected
argument or a rejected submit says what is wrong with the call; an atom
reason is spelled out; and a structured :reason map reads as its reason
and limit. Only the rendering is plain: the result the loop records keeps
the whole structured error. Hosts that relay Imp tool results over a
protocol boundary should use this so the same words reach the person that
reached the model.
iex> Imp.Adapter.Chat.format_tool_result({:error, {:tool_denied, :post, :client_denied}})
"Error: post was not allowed; the person declined it."
The words for a failed tool call, without the Error: that
format_tool_result/1 puts before them: the text an MCP error result carries,
or one sentence for any other reason.
iex> Imp.Adapter.Chat.tool_error_text({:unknown_tool, "frobnicate"})
"there is no tool named frobnicate."