Packages
nous
0.16.4
0.17.0
0.16.6
0.16.5
0.16.4
0.16.3
0.16.2
0.16.1
0.16.0
0.15.8
0.15.7
0.15.6
0.15.5
0.15.4
0.15.3
0.15.2
0.15.1
0.15.0
0.14.3
0.14.2
0.14.1
0.14.0
0.13.3
0.13.2
0.13.1
0.13.0
0.12.17
0.12.16
0.12.15
0.12.14
0.12.13
0.12.12
0.12.11
0.12.9
0.12.7
0.12.6
0.12.5
0.12.3
0.12.2
0.12.0
0.11.3
0.11.0
0.10.1
0.10.0
0.9.0
0.8.1
0.8.0
0.7.2
0.7.1
0.7.0
0.5.0
AI agent framework for Elixir with multi-provider LLM support
Current section
Files
Jump to
Current section
Files
lib/nous/stream_normalizer/tool_call_accumulator.ex
defmodule Nous.StreamNormalizer.ToolCallAccumulator do
@moduledoc """
Reassembles partial tool-call fragments emitted by `Nous.StreamNormalizer`
into the final list shape that `Nous.Messages.from_provider_response/2`
produces for the non-streaming path.
Used by the `stream: true` branch of `Nous.AgentRunner.run/3` to convert a
sequence of `{:tool_call_delta, fragment}` events into the
`tool_calls` field of an assembled `%Nous.Message{}`.
Polymorphic across the three provider chunk shapes that
`Nous.StreamNormalizer` emits:
## OpenAI-compatible
Fragments arrive as a list of partial calls, each with an `"index"` plus
potentially split `"function"."arguments"` JSON:
[%{"index" => 0, "id" => "call_a", "function" => %{"name" => "search", "arguments" => "{\\"q"}}]
[%{"index" => 0, "function" => %{"arguments" => "uery\\":\\"hi\\"}"}}]
## Anthropic
Fragments are tagged with `_phase` and `_index` (see
`Nous.StreamNormalizer.Anthropic`):
%{"id" => "tu_a", "name" => "search", "_index" => 0, "_phase" => :start}
%{"_index" => 0, "_phase" => :partial, "partial_json" => "{\\"q"}
%{"_index" => 0, "_phase" => :partial, "partial_json" => "uery\\":\\"hi\\"}"}
%{"_index" => 0, "_phase" => :stop}
## Gemini
Fragments arrive already-complete (Gemini does not split tool-call
arguments across chunks):
%{"name" => "search", "arguments" => %{"query" => "hi"}}
## API
acc = ToolCallAccumulator.new()
acc = ToolCallAccumulator.feed(acc, fragment)
tool_calls = ToolCallAccumulator.finalize(acc)
# => [%{"id" => "call_a", "name" => "search", "arguments" => %{"query" => "hi"}}]
"""
alias Nous.Messages.OpenAI, as: OpenAIMessages
@type partial_call :: %{
required(:id) => String.t() | nil,
required(:name) => String.t() | nil,
required(:args_io) => iodata()
}
@type t :: %{
openai: %{integer() => partial_call()},
anthropic: %{integer() => partial_call()},
gemini: [%{required(String.t()) => term()}]
}
@doc "Build an empty accumulator."
@spec new() :: t()
def new do
%{openai: %{}, anthropic: %{}, gemini: []}
end
@doc """
Feed a single `{:tool_call_delta, fragment}` payload into the accumulator.
The `fragment` shape is detected automatically — see the module doc for the
three supported shapes. Unrecognized fragments are silently dropped (the
caller has already filtered `{:unknown, _}` events upstream).
"""
@spec feed(t(), term()) :: t()
def feed(acc, fragment)
# OpenAI: list of partial tool-call objects
def feed(acc, fragments) when is_list(fragments) do
Enum.reduce(fragments, acc, &feed_openai_one/2)
end
# Anthropic: tagged fragments with _phase and _index
def feed(acc, %{"_phase" => :start, "_index" => index} = fragment) do
id = Map.get(fragment, "id")
name = Map.get(fragment, "name")
update_in(acc, [:anthropic, index], fn
nil -> %{id: id, name: name, args_io: []}
existing -> %{existing | id: existing.id || id, name: existing.name || name}
end)
end
def feed(acc, %{"_phase" => :partial, "_index" => index, "partial_json" => json}) do
update_in(acc, [:anthropic, index], fn
nil -> %{id: nil, name: nil, args_io: [json]}
existing -> %{existing | args_io: [existing.args_io, json]}
end)
end
def feed(acc, %{"_phase" => :stop, "_index" => _index}), do: acc
# Gemini: already-complete tool call. Gemini's API does not return tool-call
# IDs, so we synthesize one in the same `"gemini_<base64>"` shape as the
# non-streaming parser (`Nous.Messages.Gemini.generate_tool_call_id/0`) to
# keep stream and non-stream paths consistent for downstream linking.
def feed(acc, %{"name" => name, "arguments" => arguments} = fragment) when is_map(arguments) do
call =
%{
"id" => generate_gemini_id(),
"name" => name,
"arguments" => arguments
}
|> maybe_put_metadata(Map.get(fragment, "metadata"))
%{acc | gemini: [call | acc.gemini]}
end
# Anthropic non-streaming complete-response fallback emits
# `%{"id" => id, "name" => name, "input" => input}` when the response degenerates.
def feed(acc, %{"id" => id, "name" => name, "input" => input}) when is_map(input) do
call = %{"id" => id, "name" => name, "arguments" => input}
%{acc | gemini: [call | acc.gemini]}
end
def feed(acc, _other), do: acc
defp feed_openai_one(fragment, acc) when is_map(fragment) do
index = Map.get(fragment, "index") || Map.get(fragment, :index) || 0
id = Map.get(fragment, "id") || Map.get(fragment, :id)
func = Map.get(fragment, "function") || Map.get(fragment, :function) || %{}
name = Map.get(func, "name") || Map.get(func, :name)
args_chunk = Map.get(func, "arguments") || Map.get(func, :arguments) || ""
update_in(acc, [:openai, index], fn
nil ->
%{id: id, name: name, args_io: [args_chunk]}
existing ->
%{
existing
| id: existing.id || id,
name: existing.name || name,
args_io: [existing.args_io, args_chunk]
}
end)
end
defp feed_openai_one(_, acc), do: acc
defp generate_gemini_id do
"gemini_" <> (:crypto.strong_rand_bytes(8) |> Base.url_encode64(padding: false))
end
# Carry the Gemini/Vertex thought_signature through. Without this, streamed
# tool calls lost the signature the non-streaming path preserves, breaking
# multi-turn thinking parity for 2.5 thinking models.
defp maybe_put_metadata(call, nil), do: call
defp maybe_put_metadata(call, metadata), do: Map.put(call, "metadata", metadata)
@doc """
Finalize the accumulator into a list of tool calls in the unified shape:
[%{"id" => id_or_nil, "name" => name, "arguments" => decoded_map}, ...]
OpenAI and Anthropic argument buffers are JSON-decoded via
`Nous.Messages.OpenAI.decode_arguments/1`. On malformed JSON the tool call
is tagged with `"_invalid_arguments" => raw` so the agent runner can emit
a clean tool-error result instead of invoking the tool with bogus args.
Gemini calls already carry decoded `arguments`.
Order: OpenAI calls sorted by index, then Anthropic calls sorted by
`_index`, then Gemini calls in arrival order. In practice only one of the
three is non-empty per response.
"""
@spec finalize(t()) :: [%{required(String.t()) => term()}]
def finalize(%{openai: openai, anthropic: anthropic, gemini: gemini}) do
finalize_indexed(openai) ++ finalize_indexed(anthropic) ++ Enum.reverse(gemini)
end
defp finalize_indexed(map) when map_size(map) == 0, do: []
defp finalize_indexed(map) do
map
|> Enum.sort_by(fn {index, _} -> index end)
|> Enum.map(fn {_index, %{id: id, name: name, args_io: args_io}} ->
raw = IO.iodata_to_binary(args_io)
case OpenAIMessages.decode_arguments(raw) do
{:ok, decoded} ->
%{"id" => id, "name" => name, "arguments" => decoded}
{:error, {:invalid_json, raw}} ->
%{"id" => id, "name" => name, "arguments" => %{}, "_invalid_arguments" => raw}
end
end)
end
end