Carry tools through the responses surface

bc572f0852d3 · AtlantisPleb · · parent 34cc32225094

Carry tools through the responses surface

The dev lane's model could not reach the capability catalog: the
responses surface accepted no tools, so a session there answered "I
cannot access your filesystem" to work its own plugins do. Function
tools now pass through — declared flat in the OpenResponses shape,
handed to the provider as tool definitions — and a call the model asks
for comes back as a function_call output item, with its own events on
the stream. Replayed function_call and function_call_output items map
onto the provider's replay contract, so the client-held loop closes:
ask, run, replay, answer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E1mRkPGYmTVvMKqAzmQvy5
Co-Authored-By
Claude Fable 5 <noreply@anthropic.com>

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

pushed
by user · WAL seq 403 · 2026-08-25T15:35:55.461700Z

Changed files

  • modified lib/openagents_web/controllers/responses_controller.ex
  • modified test/openagents_web/controllers/responses_controller_test.exs
  • added test/support/providers/tool_calling_test_provider.ex

Diff

3 files changed, +226 -6

lib/openagents_web/controllers/responses_controller.ex modified +126 -6

@@ -31,7 +31,7 @@ defmodule OpenAgentsWeb.ResponsesController do

31 31
  use OpenAgentsWeb, :controller
32 32
33 33
  alias OpenAgents.Inference.Models
34
  alias OpenAgents.Providers.Request
34
  alias OpenAgents.Providers.{Request, ToolDefinition, ToolOutput}
35 35
  alias OpenAgentsWeb.ApiError
36 36
37 37
  @default_model "gemini-3.7-flash"

@@ -65,10 +65,10 @@ defmodule OpenAgentsWeb.ResponsesController do

65 65
  defp input_of(params) do
66 66
    case params["input"] do
67 67
      input when is_binary(input) and input != "" ->
68
        {:ok, [%{role: "user", content: input}]}
68
        {:ok, {[%{role: "user", content: input}], []}}
69 69
70 70
      [_ | _] = items ->
71
        {:ok, Enum.flat_map(items, &item_message/1)}
71
        {:ok, {Enum.flat_map(items, &item_message/1), Enum.flat_map(items, &item_output/1)}}
72 72
73 73
      _missing ->
74 74
        {:error, :input_missing}

@@ -76,8 +76,31 @@ defmodule OpenAgentsWeb.ResponsesController do

76 76
  end
77 77
78 78
  # One OpenResponses input item as a provider message. Text rides in
79
  # `content` as a string or as `input_text`/`output_text` blocks; anything
79
  # `content` as a string or as `input_text`/`output_text` blocks; a replayed
80
  # `function_call` item becomes the assistant turn that asked for it, its
81
  # arguments the raw string the model produced, never interpreted. Anything
80 82
  # else contributes nothing rather than failing the request.
83
  defp item_message(%{"type" => "function_call"} = item) do
84
    call_id = string_or(item["call_id"], "")
85
    name = string_or(item["name"], "")
86
87
    if call_id == "" or name == "" do
88
      []
89
    else
90
      [
91
        %{
92
          role: "assistant",
93
          content: item_text(item["content"]),
94
          tool_calls: [
95
            %{call_id: call_id, name: name, arguments: string_or(item["arguments"], "{}")}
96
          ]
97
        }
98
      ]
99
    end
100
  end
101
102
  defp item_message(%{"type" => "function_call_output"}), do: []
103
81 104
  defp item_message(%{"role" => role} = item) when role in ["user", "assistant", "system"] do
82 105
    case item_text(item["content"]) do
83 106
      "" -> []

@@ -87,6 +110,55 @@ defmodule OpenAgentsWeb.ResponsesController do

87 110
88 111
  defp item_message(_item), do: []
89 112
113
  # A `function_call_output` item answers a replayed call; the provider takes
114
  # it as a tool output keyed by the call id.
115
  defp item_output(%{"type" => "function_call_output"} = item) do
116
    call_id = string_or(item["call_id"], "")
117
118
    if call_id == "" do
119
      []
120
    else
121
      [%ToolOutput{call_id: call_id, output: %{"content" => item_text(item["output"])}}]
122
    end
123
  end
124
125
  defp item_output(_item), do: []
126
127
  defp string_or(value, _fallback) when is_binary(value) and value != "", do: value
128
  defp string_or(_value, fallback), do: fallback
129
130
  # OpenResponses function tools are flat (`{type, name, description,
131
  # parameters}`); the chat-completions nesting is accepted too, because the
132
  # first client of this surface converted from that shape.
133
  defp declared_tools(tools) when is_list(tools) do
134
    Enum.flat_map(tools, fn
135
      %{"name" => name} = tool when is_binary(name) and name != "" ->
136
        [
137
          %ToolDefinition{
138
            name: name,
139
            description: string_or(tool["description"], ""),
140
            input_schema: Map.get(tool, "parameters") || %{},
141
            strict: false
142
          }
143
        ]
144
145
      %{"function" => %{"name" => name} = function} when is_binary(name) ->
146
        [
147
          %ToolDefinition{
148
            name: name,
149
            description: string_or(function["description"], ""),
150
            input_schema: Map.get(function, "parameters") || %{},
151
            strict: false
152
          }
153
        ]
154
155
      _other ->
156
        []
157
    end)
158
  end
159
160
  defp declared_tools(_tools), do: []
161
90 162
  defp item_text(content) when is_binary(content), do: content
91 163
92 164
  defp item_text(blocks) when is_list(blocks) do

@@ -124,8 +196,8 @@ defmodule OpenAgentsWeb.ResponsesController do

124 196
    if Models.available?(model), do: :ok, else: {:error, :model_unavailable}
125 197
  end
126 198
127
  defp build_request(model, input, params) do
128
    {system, turns} = Enum.split_with(input, &(&1.role == "system"))
199
  defp build_request(model, {messages, tool_outputs}, params) do
200
    {system, turns} = Enum.split_with(messages, &(&1.role == "system"))
129 201
130 202
    instructions =
131 203
      case params["instructions"] do

@@ -143,6 +215,8 @@ defmodule OpenAgentsWeb.ResponsesController do

143 215
      model_id: model.provider_model,
144 216
      instructions: instructions,
145 217
      input: turns,
218
      tool_definitions: declared_tools(params["tools"]),
219
      tool_outputs: tool_outputs,
146 220
      max_output: max_output
147 221
    }
148 222
  end

@@ -176,6 +250,7 @@ defmodule OpenAgentsWeb.ResponsesController do

176 250
    Process.put(:responses_seq, 0)
177 251
    Process.put(:responses_text, [])
178 252
    Process.put(:responses_usage, %{})
253
    Process.put(:responses_calls, [])
179 254
    # The conn rides the process dictionary too: `chunk/2` returns the conn
180 255
    # that carries what has been sent — on the test adapter, literally the
181 256
    # accumulated body — and a closure cannot rebind the outer variable.

@@ -221,18 +296,42 @@ defmodule OpenAgentsWeb.ResponsesController do

221 296
          Process.put(:responses_usage, usage)
222 297
          :ok
223 298
299
        # A tool call the model asked for: one function_call item, whole,
300
        # because the provider hands the call assembled rather than in
301
        # fragments. The item's own done-events follow immediately.
302
        {:tool_call, call} ->
303
          calls = Process.get(:responses_calls)
304
          Process.put(:responses_calls, calls ++ [call])
305
          index = length(calls) + 1
306
          item = function_call_item(call, "completed")
307
308
          emit.("response.output_item.added", %{
309
            "output_index" => index,
310
            "item" => %{item | "status" => "in_progress"}
311
          })
312
313
          emit.("response.function_call_arguments.done", %{
314
            "item_id" => item["id"],
315
            "output_index" => index,
316
            "arguments" => item["arguments"]
317
          })
318
319
          emit.("response.output_item.done", %{"output_index" => index, "item" => item})
320
224 321
        _other ->
225 322
          :ok
226 323
      end)
227 324
228 325
    text = IO.iodata_to_binary(Process.get(:responses_text))
229 326
    usage = Process.get(:responses_usage)
327
    calls = Process.get(:responses_calls)
230 328
231 329
    case result do
232 330
      :ok ->
233 331
        completed =
234 332
          shell(response_id, model_name(model), "completed", [
235 333
            message(message_id, "completed", [text_part(text)])
334
            | Enum.map(calls, &function_call_item(&1, "completed"))
236 335
          ])
237 336
          |> Map.put("usage", usage_view(usage))
238 337

@@ -285,6 +384,12 @@ defmodule OpenAgentsWeb.ResponsesController do

285 384
        _other -> nil
286 385
      end)
287 386
387
    calls =
388
      Enum.flat_map(events, fn
389
        {:tool_call, call} -> [call]
390
        _other -> []
391
      end)
392
288 393
    case result do
289 394
      :ok ->
290 395
        response_id = "resp_" <> identifier()

@@ -294,6 +399,7 @@ defmodule OpenAgentsWeb.ResponsesController do

294 399
          conn,
295 400
          shell(response_id, model_name(model), "completed", [
296 401
            message(message_id, "completed", [text_part(text)])
402
            | Enum.map(calls, &function_call_item(&1, "completed"))
297 403
          ])
298 404
          |> Map.put("usage", usage_view(usage))
299 405
        )

@@ -343,6 +449,20 @@ defmodule OpenAgentsWeb.ResponsesController do

343 449
    }
344 450
  end
345 451
452
  # One function_call output item, in the specification's shape. The
453
  # arguments are the raw JSON string the model produced; this surface
454
  # replays, never interprets.
455
  defp function_call_item(call, status) do
456
    %{
457
      "type" => "function_call",
458
      "id" => "fc_" <> identifier(),
459
      "call_id" => call.call_id,
460
      "name" => call.name,
461
      "arguments" => call.raw_arguments,
462
      "status" => status
463
    }
464
  end
465
346 466
  defp usage_view(usage) do
347 467
    input = whole(usage["input_tokens"])
348 468
    output = whole(usage["output_tokens"])
test/openagents_web/controllers/responses_controller_test.exs modified +61

@@ -4,6 +4,7 @@ defmodule OpenAgentsWeb.ResponsesControllerTest do

4 4
  alias OpenAgents.Providers.{
5 5
    FailingTestProvider,
6 6
    RecordingTestProvider,
7
    ToolCallingTestProvider,
7 8
    UnconfiguredTestProvider
8 9
  }
9 10

@@ -149,4 +150,64 @@ defmodule OpenAgentsWeb.ResponsesControllerTest do

149 150
      assert body["code"] == "model_unavailable"
150 151
    end
151 152
  end
153
154
  describe "tools through the surface" do
155
    setup do
156
      swap_lane(ToolCallingTestProvider)
157
      :ok
158
    end
159
160
    @tools [
161
      %{
162
        type: "function",
163
        name: "read_conversation",
164
        description: "Read a conversation back.",
165
        parameters: %{type: "object", properties: %{}}
166
      }
167
    ]
168
169
    test "a declared tool comes back as a function_call output item", %{conn: conn} do
170
      conn = post(conn, ~p"/api/v1/responses", %{input: "read it", tools: @tools})
171
172
      assert %{"output" => [_message, call]} = json_response(conn, 200)
173
174
      assert %{
175
               "type" => "function_call",
176
               "call_id" => "call_1",
177
               "name" => "read_conversation",
178
               "arguments" => ~s({"max_turns":4}),
179
               "status" => "completed"
180
             } = call
181
    end
182
183
    test "streams the function_call item with its own events", %{conn: conn} do
184
      conn = post(conn, ~p"/api/v1/responses", %{input: "read it", tools: @tools, stream: true})
185
      body = response(conn, 200)
186
187
      assert body =~ "event: response.function_call_arguments.done"
188
      assert body =~ ~s("name":"read_conversation")
189
      assert body =~ ~s("output_index":1)
190
      assert body =~ "event: response.completed"
191
    end
192
193
    test "replayed calls and outputs reach the provider, and it answers from them", %{conn: conn} do
194
      conn =
195
        post(conn, ~p"/api/v1/responses", %{
196
          input: [
197
            %{role: "user", content: "read it"},
198
            %{
199
              type: "function_call",
200
              call_id: "call_1",
201
              name: "read_conversation",
202
              arguments: ~s({"max_turns":4})
203
            },
204
            %{type: "function_call_output", call_id: "call_1", output: "four turns of text"}
205
          ],
206
          tools: @tools
207
        })
208
209
      assert %{"output" => [message]} = json_response(conn, 200)
210
      assert %{"content" => [%{"text" => "The tool said: four turns of text"}]} = message
211
    end
212
  end
152 213
end
test/support/providers/tool_calling_test_provider.ex added +39

@@ -0,0 +1,39 @@

1
defmodule OpenAgents.Providers.ToolCallingTestProvider do
2
  @moduledoc false
3
4
  @behaviour OpenAgents.Providers.Provider
5
6
  alias OpenAgents.Providers.Request
7
8
  @impl true
9
  def id, do: "test.tool_calling_provider"
10
11
  @impl true
12
  def capabilities, do: [:text, :tools, :usage]
13
14
  @impl true
15
  def configured?, do: true
16
17
  # First call: ask for a tool. Second call — recognizable by the outputs the
18
  # caller replays — answer from them. The two-step is the whole agentic loop
19
  # in miniature, which is what a surface carrying tools must survive.
20
  @impl true
21
  def stream(%Request{} = request, on_event) when is_function(on_event, 1) do
22
    on_event.({:response_started, "tool-calling-response"})
23
24
    if request.tool_outputs == [] do
25
      on_event.(
26
        {:tool_call,
27
         %{call_id: "call_1", name: "read_conversation", raw_arguments: ~s({"max_turns":4})}}
28
      )
29
    else
30
      [output | _rest] = request.tool_outputs
31
      on_event.({:text_delta, "The tool said: "})
32
      on_event.({:text_delta, output.output["content"]})
33
    end
34
35
    on_event.({:usage, %{"input_tokens" => 6, "output_tokens" => 3}})
36
    on_event.({:response_completed, "tool-calling-response"})
37
    :ok
38
  end
39
end

This page updates live while a promote is in flight · changelog