Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .dialyzer_ignore.exs
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,7 @@
# Defensive fallbacks and MapSet opacity retained at the 0.3 cut. These are
# individually pinned so a changed success type makes the gate ask again.
{"bench/imp/benchmark_truth/multimodal_runner.ex", :pattern_match_cov, {341, 16}},
{"lib/imp/adapter/chat.ex", :pattern_match_cov, {755, 8}},
{"lib/imp/adapter/chat.ex", :pattern_match_cov, {774, 8}},
{"lib/imp/adapter/xml.ex", :pattern_match_cov, {675, 8}},
{"lib/imp/mcp.ex", :pattern_match_cov, {372, 8}},
{"lib/imp/optimizer/artifact.ex", :call_without_opaque, {745, 52}},
Expand Down
48 changes: 45 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,48 @@ User-visible changes to Imp are recorded here.

## Unreleased

- A `ReActV2` turn now ends when the model stops calling tools. A step that
comes back as prose with no tool call, for a task signature with exactly one
output of type `:string`, finishes the run with that prose as the output and
`termination_reason: :answered`, in that one request. It used to cost one more
request with `tool_choice` naming `submit`, which both spent a call and, when
the model had already acted with a tool, came back with a summary of what it
did rather than what it said. Every other mainstream loop — Anthropic's tool
runner, the OpenAI Agents SDK, LangGraph's ReAct, Pydantic AI — ends the turn
this way, so it is the default. A signature with several outputs, or one
non-text output, still takes the forced submit, because prose cannot fill
those fields, and so does a step that says nothing at all. The new option
`prose: :forced_submit` keeps the old behaviour for a single-output
signature.
- `ReActV2` gains `finish_on`, a map from tool name to
`fn arguments, result, inputs -> {:finish, outputs} | :continue end`. A tool
named there ends the turn with the outputs the function returns, which are
validated against the signature exactly as a `submit`'s are, with
`termination_reason: :finished_by_tool` and `finished_by_tool` naming the
tool. This is the shape Pydantic AI calls an output tool: one call both does
the work and carries the answer. `:continue` leaves the loop running. When a
step calls several terminal tools, the first in call order finishes the run
and the rest still execute and are recorded; a `submit` in the same step
still wins. The functions persist by registry name, like a tool runner.
- A `:model_request` event now records the whole request, not only its
messages. Its metadata carries `:options`, the request options with the tool
definitions removed, and `:tools_hash`, a SHA-256 of the canonical JSON of
those definitions or `nil` when the request offered none. The definitions
themselves are emitted once per run per distinct hash, as a new
`:tools_offered` event whose input is the tool list as sent. A recorded run
can now be reproduced call for call, without repeating an unchanging roster
on every one. Both payloads are redacted like every other event.

- The chat adapter's format options gain the `:history_note_renderer` seam,
`fn signature, turn -> nil | String.t()`. It is consulted for every stored
history turn, native tool turns included, after that turn's own messages, and
its text becomes one user message immediately behind them. Before it, a host
had no way to say anything *about* a turn that carried tool calls: those
turns route through the native replay path, which consults neither
`:output_renderer` nor `:input_section_renderer`. A note is data about the
turn — the answer was never delivered, the account's allowance ran out — so
the model reads it as the next thing after the turn, and the record the loop
keeps is untouched.
- A signature field's description now reaches the provider in the JSON schema
Imp builds for it (`Imp.Schema.json_schema/1`), so `ReActV2`'s `submit` tool
declares each output field's own words about itself in its parameter schema.
Expand All @@ -14,10 +56,10 @@ User-visible changes to Imp are recorded here.
that called nothing, not a parse failure. It used to fail the chat parse and
re-ask the whole prompt through `Imp.Adapter.JSON`, which doubled the cost of
the step and broke the provider's prefix cache; the prose is now
`next_thought`, `tool_calls` is empty, and the loop ends the step at the
forced `submit` as it already did for an empty tool-call list. The prose is
`next_thought` and `tool_calls` is empty, which is what the turn-ending rule
above then reads. The prose is
recorded as that turn's thought in the history and shown back to the model as
a plain assistant turn in the next request. `Imp.Adapter.Chat` reads a
a plain assistant turn in any next request. `Imp.Adapter.Chat` reads a
marker-free completion this way only for a signature that declares
`metadata[:prose_step]`; every other signature parses exactly as before, JSON
fallback included.
Expand Down
3 changes: 2 additions & 1 deletion docs/differentials/REACT_V2_FIDELITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,8 @@ existing fail-fast `Imp.Predict.ReAct`.
| Parallel tool calls preserve IDs and execute all calls | Every missing ID receives `call_<turn>_<index>`; results retain the corresponding ID | parallel call test |
| Unknown tools and execution failures become observations | ReActV2 records error results and continues; existing ReAct remains fail-fast | recovery test |
| `submit` is reserved and validates final outputs | Constructor rejects user `submit`; the generated submit tool uses the task JSON schema | reserved-submit and missing-output tests |
| Empty calls, parse failure, context exhaustion, or budget exhaustion force one submit call | The final predictor call pins provider `tool_choice` to `submit` and clears `reasoning_effort`, matching the pinned call configuration | forced-submit test |
| Empty calls, parse failure, context exhaustion, or budget exhaustion force one submit call | Parse failure, context exhaustion and budget exhaustion still do: the final predictor call pins provider `tool_choice` to `submit` and clears `reasoning_effort`, matching the pinned call configuration. A step of prose with no tool call does not, when the task declares exactly one text output: that prose is the output and the turn is over, as it is in Anthropic's tool runner, the OpenAI Agents SDK, LangGraph's ReAct and Pydantic AI. `prose: :forced_submit` restores the upstream shape | forced-submit test, prose-answer test |
| No upstream equivalent | `finish_on` names tools that end the turn with the outputs they carry, the shape Pydantic AI calls an output tool | `finish_on` tests |
| Prior calls replay as native assistant/tool messages | Chat adapter emits assistant `tool_calls` and matching tool-result messages by call ID | native history adapter test and ReqLLM tests |

Imp additionally applies its existing explicit tool policy to every call and
Expand Down
99 changes: 66 additions & 33 deletions lib/imp/adapter/chat.ex
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,17 @@ defmodule Imp.Adapter.Chat do

Options to `format/3`: `:demos`, `:response_instruction`, `:guidance`,
`:omit_empty_request`, and the renderer seams `:output_renderer`,
`:input_section_renderer`, `:system_renderer` and `:tool_result_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. Options outside that list
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`.
"""

Expand Down Expand Up @@ -61,6 +68,12 @@ defmodule Imp.Adapter.Chat do
# in run events, and only the prompt carries the bounded view. Errors reach
# it too, so a host decides how a failure reads.
tool_result_renderer: [type: {:fun, 2}],
# Renderer for a NOTE about one stored history turn: (signature, turn),
# returning nil or text. Text becomes one user message immediately after
# that turn's own messages, for both native tool turns and plain ones. This
# is how a host tells the model something that became true after the turn
# ended without editing the turn.
history_note_renderer: [type: {:fun, 2}],
# Loop guidance a program passes as data rather than writing into
# `signature.instructions`: `%{finish_tool:, input_names:, output_names:,
# tool_names:}`.
Expand All @@ -85,8 +98,14 @@ defmodule Imp.Adapter.Chat do
tool_result_renderer =
Keyword.get(opts, :tool_result_renderer) || (&default_tool_result_renderer/2)

{history_messages, history_fields} =
extract_history(signature, inputs, output_renderer, input_renderer, tool_result_renderer)
renderers = %{
output: output_renderer,
input_section: input_renderer,
tool_result: tool_result_renderer,
history_note: Keyword.get(opts, :history_note_renderer) || (&no_history_note/2)
}

{history_messages, history_fields} = extract_history(signature, inputs, renderers)

request = %{
role: :user,
Expand Down Expand Up @@ -1010,54 +1029,68 @@ defmodule Imp.Adapter.Chat do

defp default_tool_result_renderer(result, _call), do: format_tool_result(result)

defp extract_history(signature, inputs, renderer, input_renderer, tool_result_renderer) do
defp no_history_note(_signature, _turn), do: nil

defp extract_history(signature, inputs, renderers) do
signature.inputs
|> Enum.reduce({[], MapSet.new()}, fn field, {messages, fields} ->
case fetch_field(inputs, field.name) do
%Imp.History{} = history ->
{messages ++
render_history_turns(
signature,
Imp.History.messages(history),
renderer,
input_renderer,
tool_result_renderer
), MapSet.put(fields, field.name)}
render_history_turns(signature, Imp.History.messages(history), renderers),
MapSet.put(fields, field.name)}

_other ->
{messages, fields}
end
end)
end

defp render_history_turns(signature, turns, renderer, input_renderer, tool_result_renderer) do
defp render_history_turns(signature, turns, renderers) do
turns
|> Enum.flat_map(fn turn ->
turn = Imp.Example.new(turn) |> Imp.Example.to_map()

if native_tool_history_turn?(turn) do
render_native_tool_history_turn(signature, turn, tool_result_renderer)
else
[
%{
role: :user,
content:
render_inputs(signature, turn,
skip: history_input_fields(signature),
section_renderer: input_renderer
)
},
%{
role: :assistant,
content:
renderer.(signature, turn, "Not supplied for this conversation history message. ")
}
]
|> Enum.reject(&blank_message?/1)
end
messages =
if native_tool_history_turn?(turn) do
render_native_tool_history_turn(signature, turn, renderers.tool_result)
else
[
%{
role: :user,
content:
render_inputs(signature, turn,
skip: history_input_fields(signature),
section_renderer: renderers.input_section
)
},
%{
role: :assistant,
content:
renderers.output.(
signature,
turn,
"Not supplied for this conversation history message. "
)
}
]
|> Enum.reject(&blank_message?/1)
end

messages ++ history_note_messages(signature, turn, renderers.history_note)
end)
end

# The note is what the model reads next after the turn it is about, so it is
# a user message directly behind that turn's own messages. A renderer that
# returns nothing adds nothing.
defp history_note_messages(signature, turn, note_renderer) do
case note_renderer.(signature, turn) do
note when is_binary(note) and note != "" -> [%{role: :user, content: note}]
_no_note -> []
end
end

defp native_tool_history_turn?(turn), do: not is_nil(fetch_field(turn, :tool_calls))

defp render_native_tool_history_turn(signature, turn, tool_result_renderer) do
Expand Down
61 changes: 58 additions & 3 deletions lib/imp/lm.ex
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,16 @@ defmodule Imp.LM do
Behaviour for language model clients.

Inside an `Imp.Run` context, `request/2` emits one `:model_request` and one
`:model_response` event per call. The response event's metadata carries the
`:model_response` event per call. The request event carries the messages as
its input and the rest of the request in its metadata: `:options`, the
request options with the tool definitions removed, and `:tools_hash`, the
SHA-256 of the canonical JSON of those definitions, or `nil` when the request
offered no tools. The definitions themselves are emitted once per run per
distinct hash, as a `:tools_offered` event whose input is the tool list as
sent. Between the two, a recorded request can be reproduced without repeating
a roster on every call. Both are redacted like every other event.

The response event's metadata carries the
money for that call in `:cost`: the provider's reported total in USD as a
non-negative float, or `nil` when the provider reported nothing Imp can read
as a number. A host summing spend reads that number and nothing else.
Expand Down Expand Up @@ -87,11 +96,30 @@ defmodule Imp.LM do
def request(lm, %Imp.Core.LMRequest{} = request) do
if Imp.Run.context() do
call_id = Imp.Run.new_event_id("model")
{messages, options} = Imp.Core.request_parts(request)
tools = List.wrap(Keyword.get(options, :tools, []))
hash = tools_hash(tools)

# The definitions are the largest and least variable part of a request, so
# they are recorded once per roster rather than once per call, and every
# request names the roster it was sent by its hash.
if hash && Imp.Run.first_seen?({:tools_offered, hash}) do
Imp.Run.emit(:tools_offered,
component: lm_name(lm),
input: tools,
metadata: %{tools_hash: hash}
)
end

Imp.Run.emit(:model_request,
component: lm_name(lm),
input: elem(Imp.Core.request_parts(request), 0),
metadata: %{model_call_id: call_id, model: request.config.model}
input: messages,
metadata: %{
model_call_id: call_id,
model: request.config.model,
options: Keyword.delete(options, :tools),
tools_hash: hash
}
)

result = perform_request(lm, request)
Expand Down Expand Up @@ -127,6 +155,33 @@ defmodule Imp.LM do
{:error, {:invalid_lm_request, request}}
end

# A stable name for one tool roster: the SHA-256 of its canonical JSON, with
# object keys sorted, so two requests offering the same definitions hash the
# same however the terms were built. A request offering no tools has no hash.
defp tools_hash([]), do: nil

defp tools_hash(tools) do
:sha256
|> :crypto.hash(canonical_json(Imp.Observability.Inspection.json_safe(tools)))
|> Base.encode16(case: :lower)
end

defp canonical_json(value) when is_map(value) do
entries =
value
|> Enum.sort_by(fn {key, _value} -> key end)
|> Enum.map_join(",", fn {key, nested} ->
Jason.encode!(to_string(key)) <> ":" <> canonical_json(nested)
end)

"{" <> entries <> "}"
end

defp canonical_json(value) when is_list(value),
do: "[" <> Enum.map_join(value, ",", &canonical_json/1) <> "]"

defp canonical_json(value), do: Jason.encode!(value)

defp maybe_put_billing(metadata, nil), do: metadata
defp maybe_put_billing(metadata, billing), do: Map.put(metadata, :billing, billing)

Expand Down
Loading
Loading