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
7 changes: 6 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@ The project follows a modular organization separating core functionality, provid
lib
β”œβ”€β”€ llm_composer
β”‚ β”œβ”€β”€ agent
β”‚ β”‚ └── result.ex
β”‚ β”‚ β”œβ”€β”€ result.ex
β”‚ β”‚ └── stream_collector.ex
β”‚ β”œβ”€β”€ agent.ex
β”‚ β”œβ”€β”€ cache
β”‚ β”‚ β”œβ”€β”€ behaviour.ex
Expand All @@ -47,6 +48,8 @@ lib
β”‚ β”‚ β”‚ └── open_router.ex
β”‚ β”‚ └── pricing.ex
β”‚ β”œβ”€β”€ cost_info.ex
β”‚ β”œβ”€β”€ credo_checks
β”‚ β”‚ └── grouped_functions.ex
β”‚ β”œβ”€β”€ errors.ex
β”‚ β”œβ”€β”€ function_call.ex
β”‚ β”œβ”€β”€ function_call_extractors.ex
Expand Down Expand Up @@ -109,6 +112,8 @@ lib
└── llm_composer.ex
test
β”œβ”€β”€ llm_composer
β”‚ β”œβ”€β”€ agent
β”‚ β”‚ └── stream_collector_test.exs
β”‚ β”œβ”€β”€ agent_test.exs # LlmComposer.Agent tool-calling loop tests
β”‚ β”œβ”€β”€ cost/ # cost_assembler, cost_info, pricing tests
β”‚ β”œβ”€β”€ providers/ # per-provider tests (bedrock, google, ollama, open_ai, open_router, utils)
Expand Down
11 changes: 6 additions & 5 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,13 @@ All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]
## [0.20.0] - 2026-06-29

### Added
- Added `LlmComposer.Agent` β€” an agentic tool-calling loop on top of `LlmComposer.run_completion/3` that automates the full `ask β†’ tool calls β†’ execute β†’ feed results back β†’ repeat` cycle until the model returns a final, tool-free answer. Supports `:sequential` (default) and `:parallel` tool execution (`:tool_timeout`), a configurable `:max_iterations` (default `10`), and per-tool error recovery (tool failures are fed back to the model instead of aborting the run). Returns a `LlmComposer.Agent.Result` struct bundling the final response, the full conversation, executed tool calls, and accumulated cost info. Emits its own `[:llm_composer, :agent, :run | :iteration | :tool]` telemetry events.
- Added `:telemetry` spans for observability: `[:llm_composer, :run_completion]` (with `input_tokens`/`output_tokens` measurements and a `status` of `:ok`/`:error`) and `[:llm_composer, :providers_runner, :call]` (with `provider`, `model`, and `status` metadata). The provider call span is emitted for both the single-provider and the multi-provider (fallback) paths. The existing `%{latency_ms, status, provider, model}` metrics map passed to `LlmComposer.ProviderRouter` callbacks is preserved, so custom routers keep working unchanged.
- Added a custom `LlmComposer.CredoChecks.GroupedFunctions` Credo check that enforces grouping of all public functions before private ones within a module, and enabled it across the codebase.
- Added `LlmComposer.Agent` β€” an automatic tool-calling loop that runs the full ask β†’ execute β†’ repeat cycle until the model returns a final answer. Supports sequential and parallel tool execution, configurable iteration limit, and per-tool error recovery.
- Added streaming support to `LlmComposer.Agent` (`stream_response: true`). Returns a lazy stream of `StreamChunk`s with `:text_delta`, `:tool_call` (one per executed tool, result included), and a terminal `:done` chunk carrying cumulative usage and the full `Agent.Result`. Supported providers: `:open_ai`, `:open_router`, `:open_ai_responses`, `:google`, `:bedrock`, `:ollama`.
- Added `:tool_call` chunk type to `LlmComposer.StreamChunk` β€” emitted by the streaming agent after each tool execution, making the stream self-contained without needing a telemetry handler.
- Added telemetry spans for `run_completion` and `providers_runner` calls, and enriched agent events with per-tool arguments, reasoning deltas, and a `:telemetry_metadata` option to scope handlers to a single run.

## [0.19.6] - 2026-06-16

Expand Down Expand Up @@ -326,7 +327,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
- Initial release with support for basic message handling, interaction with OpenAI and Ollama models, and a foundational structure for model settings and function execution.

---
[Unreleased]: https://github.com/doofinder/llm_composer/compare/0.19.6...HEAD
[0.20.0]: https://github.com/doofinder/llm_composer/compare/0.19.6...0.20.0
[0.19.6]: https://github.com/doofinder/llm_composer/compare/0.19.5...0.19.6
[0.19.5]: https://github.com/doofinder/llm_composer/compare/0.19.4...0.19.5
[0.19.4]: https://github.com/doofinder/llm_composer/compare/0.19.3...0.19.4
Expand Down
73 changes: 64 additions & 9 deletions guides/agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,8 @@ ask β†’ model requests tool calls β†’ execute them β†’ feed the results back β†’
β†’ until the model returns a final, tool-free answer
```

The loop is **synchronous** (streaming is not supported in this version) and pure orchestration
over existing building blocks, so it works with any provider that supports function calling
(**OpenAI**, **OpenRouter**, **Google**).
The loop works with any provider that supports function calling (**OpenAI**, **OpenRouter**,
**Google**, **Bedrock**). Both synchronous and [streaming](#streaming) modes are supported.

## Quick start

Expand Down Expand Up @@ -66,7 +65,7 @@ default, so you normally do not pass them again to `run/3`.

## The result

A successful run returns `{:ok, %LlmComposer.Agent.Result{}}`:
A successful synchronous run returns `{:ok, %LlmComposer.Agent.Result{}}`:

| Field | Description |
|---|---|
Expand All @@ -84,6 +83,7 @@ A successful run returns `{:ok, %LlmComposer.Agent.Result{}}`:
| `:functions` | from settings | Tools available to the model. |
| `:tool_execution` | `:sequential` | `:sequential` runs tool calls one by one; `:parallel` runs them concurrently (`Task.async_stream/3`) while preserving result order. |
| `:tool_timeout` | `:infinity` | Per-task timeout (ms or `:infinity`) used in `:parallel` mode. |
| `:telemetry_metadata` | `%{}` | Map merged into the metadata of every agent telemetry event. An auto-generated `:run_id` is always added. Useful for scoping handlers to a single run. |

## Error handling

Expand All @@ -92,17 +92,73 @@ A successful run returns `{:ok, %LlmComposer.Agent.Result{}}`:
result, giving it a chance to recover or explain the failure.
- **Model/network errors** returned by the provider abort the loop and are returned as
`{:error, reason}`.
- **Streaming** settings (`stream_response: true`) return `{:error, :streaming_not_supported}`.

## Streaming

Pass `stream_response: true` in your settings and `run/3` returns `{:ok, stream}` β€” a lazy
`Enumerable` of `LlmComposer.StreamChunk` β€” instead of `{:ok, result}`.

The stream contains:

- **`:text_delta`** chunks β€” the final answer, token by token.
- **`:tool_call`** chunks β€” one per executed tool call (with the result already filled in), emitted
right after execution and before the next LLM turn. Useful for showing progress in a UI without
needing a telemetry handler.
- A terminal **`:done`** chunk whose `:usage` and `:cost_info` hold the run totals, and whose
`metadata.agent_result` holds the full `LlmComposer.Agent.Result`.
- A terminal **`:error`** chunk on hard failures (e.g. `:max_iterations_reached`).

Intermediate tool-calling turns run entirely inside the loop β€” only the final, tool-free answer
reaches the caller as `:text_delta` chunks.

```elixir
settings = %LlmComposer.Settings{
providers: [{LlmComposer.Providers.OpenAI, [model: "gpt-4.1-mini", functions: [calculator]]}],
system_prompt: "You are a helpful assistant.",
stream_response: true,
track_costs: true
}

{:ok, stream} = LlmComposer.Agent.run(settings, "How much is (7 + 3) * 6?")

{result, cost} =
Enum.reduce(stream, {nil, nil}, fn
%LlmComposer.StreamChunk{type: :text_delta, text: t}, acc ->
IO.write(t)
acc

%LlmComposer.StreamChunk{type: :tool_call, tool_calls: [call]}, acc ->
IO.puts("\n[tool] #{call.name}(#{call.arguments}) β†’ #{inspect(call.result)}")
acc

%LlmComposer.StreamChunk{type: :done, metadata: %{agent_result: r}, cost_info: c}, _ ->
{r, c}

_other, acc ->
acc
end)
```

Supported providers: `:open_ai`, `:open_router`, `:open_ai_responses`, `:google`, `:bedrock`,
`:ollama`. Note that `:ollama`'s native streaming format does not include tool-call deltas β€” text
streaming works, but for tool calls use the `:open_ai` provider pointed at Ollama's
OpenAI-compatible endpoint. Other providers yield a terminal `:error` chunk with
`{:streaming_agent_unsupported_provider, provider}`.

See the [Streaming guide](streaming.md#streaming-with-llmcomposeragent) for more details.

## Telemetry

The loop emits `:telemetry` events you can attach handlers to:
The loop emits `:telemetry` events you can attach handlers to. Pass `telemetry_metadata:` to
`run/3` to include extra keys (plus an auto-generated `:run_id`) in every event's metadata,
making it easy to scope a handler to a single run.

| Event | Measurements | Metadata |
|---|---|---|
| `[:llm_composer, :agent, :run, :start \| :stop \| :exception]` | `:iterations` (on stop), `:duration` | `:status` (`:ok`/`:error`), `:reason`, `:max_iterations`, `:tool_count` |
| `[:llm_composer, :agent, :run, :start \| :stop \| :exception]` | `:iterations` (on stop), `:duration` | `:status` (`:ok`/`:error`/`:halted`), `:max_iterations`, `:tool_count` |
| `[:llm_composer, :agent, :iteration, :stop]` | `:tool_call_count` | `:iteration`, `:cost_info`, `:final` |
| `[:llm_composer, :agent, :tool, :start \| :stop \| :exception]` | `:duration` | `:name`, `:status` (`:ok`/`:error`) |
| `[:llm_composer, :agent, :tool, :start \| :stop \| :exception]` | `:duration` | `:name`, `:id`, `:arguments`, `:metadata`, `:status` (`:ok`/`:error` on stop) |
| `[:llm_composer, :agent, :reasoning, :delta]` | β€” | `:iteration`, `:reasoning` (streaming only) |

The per-iteration event carries that turn's `:cost_info`, so you can record costs incrementally as
each completion finishes rather than only at the end of the run.
Expand All @@ -112,7 +168,6 @@ each completion finishes rather than only at the end of the run.
"agent-costs",
[:llm_composer, :agent, :iteration, :stop],
fn _event, _measurements, %{cost_info: cost_info}, _config ->
# persist or report cost_info for this completion
MyApp.Usage.record(cost_info)
end,
nil
Expand Down
50 changes: 50 additions & 0 deletions guides/streaming.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,3 +128,53 @@ full_text =
Streaming is **not** compatible with Tesla's retry middleware. When `stream_response: true`
is set, the retry middleware is removed automatically. See the
[Configuration guide](configuration.html) for retry options.

## Streaming with `LlmComposer.Agent`

`LlmComposer.Agent.run/3` also supports streaming. With `stream_response: true` it returns
`{:ok, stream}` where the stream carries the final answer token-by-token as `:text_delta` chunks,
interspersed with `:tool_call` chunks for each executed tool, and ends with a terminal `:done`
chunk. The terminal chunk's `:usage` and `:cost_info` hold the run totals, and
`metadata.agent_result` holds the full `LlmComposer.Agent.Result`. Intermediate tool-calling turns
run internally (streamed tool-call deltas are reassembled automatically).

Supported providers: `:open_ai`, `:open_router`, `:open_ai_responses`, `:google`, `:bedrock`,
`:ollama`. Note that `:ollama`'s native streaming format does not carry tool-call deltas β€” text
streaming works, but for tool-call streaming point the `:open_ai` provider at Ollama's
OpenAI-compatible endpoint instead.

```elixir
{:ok, stream} = LlmComposer.Agent.run(settings, "What's the weather in Paris?")

stream
|> Enum.reduce(nil, fn
%LlmComposer.StreamChunk{type: :text_delta, text: t}, acc ->
IO.write(t); acc

%LlmComposer.StreamChunk{type: :tool_call, tool_calls: [call]}, acc ->
IO.puts("\n[tool] #{call.name}(#{call.arguments}) β†’ #{inspect(call.result)}"); acc

%LlmComposer.StreamChunk{type: :done, metadata: %{agent_result: result}}, _ ->
result

_other, acc ->
acc
end)
```

### Progress events for UIs

For advanced use cases (e.g. broadcasting via `Phoenix.PubSub`), tool calls and reasoning are also
exposed via `:telemetry`. Pass `telemetry_metadata:` to scope a handler to a single run:

```elixir
{:ok, stream} = LlmComposer.Agent.run(settings, prompt, telemetry_metadata: %{conversation_id: cid})

:telemetry.attach("agent-ui", [:llm_composer, :agent, :tool, :start], fn
_event, _meas, %{conversation_id: ^cid, name: name, arguments: args}, _cfg ->
Phoenix.PubSub.broadcast(MyApp.PubSub, "conv:#{cid}", {:tool_call, name, args})
end, nil)
```

See the [Agent guide](agent.md) for the full streaming API, all chunk types, options, and the
complete telemetry event reference.
4 changes: 2 additions & 2 deletions mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ defmodule LlmComposer.MixProject do
def project do
[
app: :llm_composer,
version: "0.19.6",
version: "0.20.0",
elixir: "~> 1.18",
start_permanent: Mix.env() == :prod,
deps: deps(),
Expand All @@ -14,8 +14,8 @@ defmodule LlmComposer.MixProject do
source_ref: "master",
extras: [
"README.md",
"guides/agent.md",
"guides/providers.md",
"guides/agent.md",
"guides/streaming.md",
"guides/cost_tracking.md",
"guides/function_calls.md",
Expand Down
3 changes: 2 additions & 1 deletion test/llm_composer/providers/bedrock_http_client_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,8 @@ if Code.ensure_loaded?(ExAws) do

Bypass.pass(bypass)

assert {:error, %{reason: :timeout_waiting_for_status}} = result
assert match?({:error, %{reason: :timeout_waiting_for_status}}, result) or
match?({:error, %{reason: {:task_crashed, _}}}, result)
end
end

Expand Down
Loading