Build grounded repository chat tools

0cdcc8365381 · AtlantisPleb · 2026-08-22T16:43:14-05:00 · parent 16136c0330f4

Build grounded repository chat tools

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 102 · 2026-08-22T21:43:44.528852Z
pushed
by user · WAL seq 101 · 2026-08-22T21:43:25.497542Z

Changed files

  • modified assets/css/app.css
  • modified config/dev.exs
  • modified lib/openagents/chat/open_router.ex
  • modified lib/openagents/chat/open_router/responses_stream_decoder.ex
  • deleted lib/openagents/chat/tools/demo.ex
  • added lib/openagents/chat/tools/repository_file.ex
  • modified lib/openagents/markdown.ex
  • modified lib/openagents/repositories/importer.ex
  • modified lib/openagents/repositories/provisioner.ex
  • modified lib/openagents_web/components/ai/conversation.ex
  • modified lib/openagents_web/live/chat_placeholder_live.ex
  • added test/fixtures/openrouter/responses_tool_call.sse
  • added test/openagents/chat/open_router/responses_stream_decoder_test.exs
  • modified test/openagents/chat/open_router_test.exs
  • added test/openagents/chat/tools/repository_file_test.exs
  • modified test/openagents/markdown_test.exs
  • modified test/openagents/repositories/provisioner_test.exs
  • modified test/openagents_web/live/chat_placeholder_test.exs

Diff

18 files changed, +1695 -195

assets/css/app.css modified +2 -10

@@ -724,9 +724,8 @@ body.docs-sidebar-open {

724 724
}
725 725
726 726
.chat-placeholder-composer {
727
  position: fixed;
728
  inset-inline: 0;
729
  inset-block-end: 0;
727
  position: relative;
728
  flex: none;
730 729
  z-index: 30;
731 730
  border-block-start: 1px solid var(--line-faint);
732 731
  background: var(--ink-void);

@@ -781,13 +780,6 @@ body.docs-sidebar-open {

781 780
  }
782 781
}
783 782
784
@media (min-width: 1024px) {
785
  #app-shell:not([data-sidebar-initialized="true"]) .chat-placeholder-composer,
786
  #app-shell[data-sidebar-open="true"] .chat-placeholder-composer {
787
    inset-inline-start: 240px;
788
  }
789
}
790
791 783
/* Chat UI (OpenAgents) */
792 784
793 785
/* The transcript and the composer are AI Elements now — `conversation`,
config/dev.exs modified +2 -1

@@ -12,7 +12,8 @@ config :openagents, :changelog_backfill_on_boot, true

12 12
# page is a 404 locally, so the repository surfaces cannot be reviewed at all.
13 13
config :openagents,
14 14
  forge_enabled: true,
15
  forge_data_dir: Path.expand("../.local/forge", __DIR__)
15
  forge_data_dir: Path.expand("../.local/forge", __DIR__),
16
  forge_wal_dir: Path.expand("../.local/forge-wal", __DIR__)
16 17
17 18
# Configure your database
18 19
config :openagents, OpenAgents.Repo,
lib/openagents/chat/open_router.ex modified +53 -20

@@ -1,20 +1,24 @@

1 1
defmodule OpenAgents.Chat.OpenRouter do
2 2
  @moduledoc """
3
  Server-side OpenRouter chat-completions adapter for the `/chat` preview.
3
  Server-side OpenRouter Responses adapter for the `/chat` preview.
4 4
5 5
  The adapter keeps the OpenRouter credential and HTTP transport on the server.
6 6
  It requests Ox Alpha first, with the Free Models Router as its configured
7
  fallback, and returns normalized failures without provider credentials or
7
  model fallback. It uses chat completions only when a provider does not support
8
  Responses, and returns normalized failures without provider credentials or
8 9
  response bodies.
9 10
  """
10 11
11 12
  alias OpenAgents.Chat.OpenRouter.ResponsesStreamDecoder
12
  alias OpenAgents.Chat.Tools.Demo
13
  alias OpenAgents.Chat.Tools.RepositoryFile
13 14
14 15
  @chat_completions_endpoint "https://openrouter.ai/api/v1/chat/completions"
15 16
  @responses_endpoint "https://openrouter.ai/api/v1/responses"
16 17
  @default_model "stealth/ox-alpha"
17
  @maximum_tool_rounds 3
18
  @maximum_tool_rounds 6
19
  @tool_instructions """
20
  Ground every repository claim in repository tool output. Never claim that a file or directory exists unless a tool result confirms it. Use list_repository_directory before guessing a path, and use the returned paths exactly. Do not retry the same failed repository, path, and ref combination. If a read fails, list its parent directory once or tell the user that the requested content is unavailable.
21
  """
18 22
  @reasoning_efforts ~w(none minimal low medium high max)
19 23
20 24
  @type completion :: map()

@@ -80,7 +84,7 @@ defmodule OpenAgents.Chat.OpenRouter do

80 84
  end
81 85
82 86
  defp stream_with_responses_fallback(api_key, request, on_event, options) do
83
    with {:ok, payload} <- responses_payload(request) do
87
    with {:ok, payload} <- responses_payload(request, options) do
84 88
      case responses_stream_request(api_key, payload, options) do
85 89
        {:ok, response} -> consume_responses_stream(response, on_event, payload["model"])
86 90
        {:fallback, _reason} -> stream_with_chat_completions(api_key, request, on_event, options)

@@ -130,7 +134,7 @@ defmodule OpenAgents.Chat.OpenRouter do

130 134
    end
131 135
  end
132 136
133
  defp responses_payload(%{"model" => model, "messages" => messages} = request)
137
  defp responses_payload(%{"model" => model, "messages" => messages} = request, options)
134 138
       when is_binary(model) and is_list(messages) do
135 139
    with {:ok, input} <- responses_input(messages) do
136 140
      payload = %{"model" => model, "input" => input}

@@ -149,7 +153,8 @@ defmodule OpenAgents.Chat.OpenRouter do

149 153
150 154
      {:ok,
151 155
       Map.merge(payload, %{
152
         "tools" => Demo.definitions(),
156
         "instructions" => @tool_instructions,
157
         "tools" => tool_module(options).definitions(),
153 158
         "tool_choice" => "auto",
154 159
         "reasoning" => reasoning,
155 160
         "include" => ["reasoning.encrypted_content"],

@@ -158,7 +163,7 @@ defmodule OpenAgents.Chat.OpenRouter do

158 163
    end
159 164
  end
160 165
161
  defp responses_payload(_request), do: {:error, :invalid_response}
166
  defp responses_payload(_request, _options), do: {:error, :invalid_response}
162 167
163 168
  defp reasoning_request("none"), do: %{"effort" => "none", "exclude" => false}
164 169

@@ -234,16 +239,16 @@ defmodule OpenAgents.Chat.OpenRouter do

234 239
  defp chat_request(request), do: request
235 240
236 241
  defp continue_responses_tool_calls(
237
         {:ok, %{"tool_calls" => tool_calls}},
242
         {:ok, %{"tool_calls" => tool_calls, "output" => provider_output}},
238 243
         api_key,
239 244
         payload,
240 245
         on_event,
241 246
         options,
242 247
         rounds_remaining
243 248
       )
244
       when is_list(tool_calls) and rounds_remaining > 0 do
245
    with {:ok, tool_outputs} <- execute_tool_calls(tool_calls),
246
         payload <- Map.update!(payload, "input", &(&1 ++ tool_calls ++ tool_outputs)),
249
       when is_list(tool_calls) and is_list(provider_output) and rounds_remaining > 0 do
250
    with {:ok, tool_outputs} <- execute_tool_calls(tool_calls, on_event, options),
251
         payload <- Map.update!(payload, "input", &(&1 ++ provider_output ++ tool_outputs)),
247 252
         {:ok, response} <- responses_stream_request(api_key, payload, options),
248 253
         result <- consume_responses_stream(response, on_event, payload["model"]) do
249 254
      continue_responses_tool_calls(

@@ -277,16 +282,36 @@ defmodule OpenAgents.Chat.OpenRouter do

277 282
       ),
278 283
       do: result
279 284
280
  defp execute_tool_calls(tool_calls) do
285
  defp execute_tool_calls(tool_calls, on_event, options) do
286
    tool_module = tool_module(options)
287
    tool_context = Keyword.get(options, :tool_context, %{})
288
281 289
    tool_outputs =
282 290
      Enum.map(tool_calls, fn %{"call_id" => call_id, "name" => name, "arguments" => arguments} ->
283
        {:ok, output} = Demo.execute(name, arguments)
284
285
        %{
286
          "type" => "function_call_output",
287
          "call_id" => call_id,
288
          "output" => Jason.encode!(output)
289
        }
291
        on_event.(
292
          {:tool_call_started, %{"call_id" => call_id, "name" => name, "arguments" => arguments}}
293
        )
294
295
        case execute_tool(tool_module, name, arguments, tool_context) do
296
          {:ok, output} ->
297
            encoded_output = Jason.encode!(output)
298
            on_event.({:tool_call_completed, %{"call_id" => call_id, "output" => encoded_output}})
299
300
            %{
301
              "type" => "function_call_output",
302
              "call_id" => call_id,
303
              "output" => encoded_output
304
            }
305
306
          {:error, error} ->
307
            on_event.({:tool_call_failed, %{"call_id" => call_id, "error" => error}})
308
309
            %{
310
              "type" => "function_call_output",
311
              "call_id" => call_id,
312
              "output" => Jason.encode!(%{"error" => error})
313
            }
314
        end
290 315
      end)
291 316
292 317
    {:ok, tool_outputs}

@@ -294,6 +319,14 @@ defmodule OpenAgents.Chat.OpenRouter do

294 319
    _exception -> {:error, :invalid_response}
295 320
  end
296 321
322
  defp execute_tool(tool_module, name, arguments, context) do
323
    tool_module.execute(name, arguments, context)
324
  rescue
325
    exception -> {:error, Exception.message(exception)}
326
  end
327
328
  defp tool_module(options), do: Keyword.get(options, :tool_module, RepositoryFile)
329
297 330
  defp chat_stream_request(api_key, request, options) do
298 331
    request_options = Keyword.get(options, :request_options, [])
299 332
lib/openagents/chat/open_router/responses_stream_decoder.ex modified +220 -18

@@ -3,6 +3,8 @@ defmodule OpenAgents.Chat.OpenRouter.ResponsesStreamDecoder do

3 3
4 4
  @maximum_buffer_bytes 262_144
5 5
  @maximum_delta_bytes 65_536
6
  @maximum_arguments_bytes 65_536
7
  @maximum_error_characters 240
6 8
7 9
  defstruct buffer: "",
8 10
            complete?: false,

@@ -12,6 +14,8 @@ defmodule OpenAgents.Chat.OpenRouter.ResponsesStreamDecoder do

12 14
            assistant_content: "",
13 15
            reasoning_summary: nil,
14 16
            reasoning_items: [],
17
            output_items: [],
18
            function_call_arguments: %{},
15 19
            text_event_family: nil,
16 20
            reasoning_event_family: nil
17 21

@@ -64,7 +68,9 @@ defmodule OpenAgents.Chat.OpenRouter.ResponsesStreamDecoder do

64 68
  end
65 69
66 70
  defp decode_event(_state, {:error, _reason}), do: {:error, :invalid_response}
67
  defp decode_event(_state, {:ok, %{"type" => "error"}}), do: {:error, :provider_unavailable}
71
72
  defp decode_event(_state, {:ok, %{"type" => "error"} = event}),
73
    do: {:error, provider_event_error(event)}
68 74
69 75
  defp decode_event(state, {:ok, %{"type" => "response.content_part.delta", "delta" => delta}})
70 76
       when is_binary(delta) and byte_size(delta) <= @maximum_delta_bytes,

@@ -101,34 +107,92 @@ defmodule OpenAgents.Chat.OpenRouter.ResponsesStreamDecoder do

101 107
102 108
  defp decode_event(
103 109
         state,
104
         {:ok, %{"type" => "response.output_item.added", "item" => item}}
110
         {:ok, %{"type" => "response.output_item.added", "item" => item} = event}
105 111
       )
106 112
       when is_map(item),
107
       do: {:ok, capture_output_item(state, item), []}
113
       do: {:ok, capture_output_item(state, item, event["output_index"]), []}
108 114
109 115
  defp decode_event(
110 116
         state,
111
         {:ok, %{"type" => "response.output_item.done", "item" => item}}
117
         {:ok, %{"type" => "response.output_item.done", "item" => item} = event}
112 118
       )
113 119
       when is_map(item),
114
       do: {:ok, capture_output_item(state, item), []}
120
       do: {:ok, capture_output_item(state, item, event["output_index"]), []}
121
122
  defp decode_event(
123
         state,
124
         {:ok,
125
          %{
126
            "type" => "response.function_call_arguments.delta",
127
            "item_id" => item_id,
128
            "delta" => delta
129
          }}
130
       )
131
       when is_binary(item_id) and is_binary(delta) do
132
    append_function_call_arguments(state, item_id, delta)
133
  end
134
135
  defp decode_event(
136
         state,
137
         {:ok,
138
          %{
139
            "type" => "response.function_call_arguments.done",
140
            "item_id" => item_id,
141
            "arguments" => arguments
142
          }}
143
       )
144
       when is_binary(item_id) and is_binary(arguments) and
145
              byte_size(arguments) <= @maximum_arguments_bytes do
146
    {:ok, put_function_call_arguments(state, item_id, arguments), []}
147
  end
115 148
116
  defp decode_event(state, {:ok, %{"type" => "response.done", "response" => response}})
149
  defp decode_event(state, {:ok, %{"type" => type, "response" => response}})
150
       when type in ["response.completed", "response.done"] and is_map(response) do
151
    complete_response(state, response)
152
  end
153
154
  defp decode_event(_state, {:ok, %{"type" => type} = event})
155
       when type in ["response.failed", "response.incomplete"],
156
       do: {:error, provider_event_error(event)}
157
158
  defp decode_event(state, {:ok, %{"type" => _type}}), do: {:ok, state, []}
159
  defp decode_event(_state, {:ok, _event}), do: {:error, :invalid_response}
160
161
  defp complete_response(state, response)
117 162
       when is_map(response) do
118 163
    state = capture_model(state, response["model"])
164
    output = terminal_output(response["output"], state)
165
166
    response =
167
      response
168
      |> Map.put_new("object", "response")
169
      |> maybe_put_model(state.model)
170
      |> maybe_put_output(output)
119 171
120 172
    case completion(response) do
121
      {:ok, completion} -> {:ok, %{state | completion: completion}, []}
122
      {:error, :invalid_response} -> {:ok, state, []}
173
      {:ok, completion} ->
174
        completion = merge_streamed_reasoning(completion, state)
175
        {:ok, %{state | complete?: true, completion: completion}, []}
176
177
      {:error, :invalid_response} ->
178
        {:ok, %{state | complete?: true}, []}
123 179
    end
124 180
  end
125 181
126
  defp decode_event(_state, {:ok, %{"type" => type}})
127
       when type in ["response.failed", "response.incomplete"],
128
       do: {:error, :provider_unavailable}
182
  defp merge_streamed_reasoning(completion, state) do
183
    completion =
184
      if is_binary(state.reasoning_summary) do
185
        Map.put_new(completion, "reasoning_summary", state.reasoning_summary)
186
      else
187
        completion
188
      end
129 189
130
  defp decode_event(state, {:ok, %{"type" => _type}}), do: {:ok, state, []}
131
  defp decode_event(_state, {:ok, _event}), do: {:error, :invalid_response}
190
    if state.reasoning_items == [] do
191
      completion
192
    else
193
      Map.put_new(completion, "reasoning_items", state.reasoning_items)
194
    end
195
  end
132 196
133 197
  defp append_text_delta(%{text_event_family: nil} = state, delta, family) do
134 198
    {:ok,

@@ -164,8 +228,19 @@ defmodule OpenAgents.Chat.OpenRouter.ResponsesStreamDecoder do

164 228
  defp completion(%{"object" => "response", "model" => model, "output" => output})
165 229
       when is_binary(model) and is_list(output) do
166 230
    case tool_calls(output) do
167
      [] -> assistant_completion(model, output)
168
      tool_calls -> {:ok, %{"object" => "response", "model" => model, "tool_calls" => tool_calls}}
231
      [] ->
232
        assistant_completion(model, output)
233
234
      tool_calls ->
235
        %{
236
          "object" => "response",
237
          "model" => model,
238
          "output" => output,
239
          "tool_calls" => tool_calls
240
        }
241
        |> maybe_put_reasoning_summary(output)
242
        |> maybe_put_reasoning_items(output)
243
        |> then(&{:ok, &1})
169 244
    end
170 245
  end
171 246

@@ -185,6 +260,7 @@ defmodule OpenAgents.Chat.OpenRouter.ResponsesStreamDecoder do

185 260
      completion = %{
186 261
        "object" => "response",
187 262
        "model" => model,
263
        "output" => output,
188 264
        "assistant_message_id" => id,
189 265
        "assistant_content" => text
190 266
      }

@@ -263,7 +339,18 @@ defmodule OpenAgents.Chat.OpenRouter.ResponsesStreamDecoder do

263 339
  defp capture_model(state, model) when is_binary(model), do: %{state | model: model}
264 340
  defp capture_model(state, _model), do: state
265 341
266
  defp capture_output_item(
342
  defp capture_output_item(state, item, output_index) do
343
    item = maybe_put_function_call_arguments(item, state.function_call_arguments)
344
345
    state = %{
346
      state
347
      | output_items: upsert_output_item(state.output_items, item, output_index)
348
    }
349
350
    capture_output_item_details(state, item)
351
  end
352
353
  defp capture_output_item_details(
267 354
         state,
268 355
         %{
269 356
           "type" => "message",

@@ -281,14 +368,68 @@ defmodule OpenAgents.Chat.OpenRouter.ResponsesStreamDecoder do

281 368
    }
282 369
  end
283 370
284
  defp capture_output_item(state, %{"type" => "reasoning"} = item) do
371
  defp capture_output_item_details(state, %{"type" => "reasoning"} = item) do
285 372
    state = capture_reasoning_item(state, item)
286 373
    summary = Map.get(item, "summary", [])
287 374
288 375
    capture_reasoning_summary(state, summary)
289 376
  end
290 377
291
  defp capture_output_item(state, _item), do: state
378
  defp capture_output_item_details(state, _item), do: state
379
380
  defp upsert_output_item(output_items, item, output_index) do
381
    key = output_item_key(item, output_index)
382
383
    case Enum.find_index(output_items, fn {existing_key, _item} -> existing_key == key end) do
384
      nil -> output_items ++ [{key, item}]
385
      index -> List.replace_at(output_items, index, {key, item})
386
    end
387
  end
388
389
  defp output_item_key(_item, output_index)
390
       when is_integer(output_index) and output_index >= 0,
391
       do: {:index, output_index}
392
393
  defp output_item_key(%{"id" => id}, _output_index) when is_binary(id), do: {:id, id}
394
  defp output_item_key(_item, _output_index), do: make_ref()
395
396
  defp append_function_call_arguments(state, item_id, delta)
397
       when byte_size(delta) <= @maximum_arguments_bytes do
398
    arguments = Map.get(state.function_call_arguments, item_id, "") <> delta
399
400
    if byte_size(arguments) <= @maximum_arguments_bytes do
401
      {:ok, put_function_call_arguments(state, item_id, arguments), []}
402
    else
403
      {:error, :invalid_response}
404
    end
405
  end
406
407
  defp append_function_call_arguments(_state, _item_id, _delta),
408
    do: {:error, :invalid_response}
409
410
  defp put_function_call_arguments(state, item_id, arguments) do
411
    argument_map = Map.put(state.function_call_arguments, item_id, arguments)
412
413
    output_items =
414
      Enum.map(state.output_items, fn {key, item} ->
415
        {key, maybe_put_function_call_arguments(item, argument_map)}
416
      end)
417
418
    %{state | function_call_arguments: argument_map, output_items: output_items}
419
  end
420
421
  defp maybe_put_function_call_arguments(
422
         %{"type" => "function_call", "id" => item_id} = item,
423
         argument_map
424
       )
425
       when is_binary(item_id) do
426
    case Map.get(argument_map, item_id) do
427
      arguments when is_binary(arguments) -> Map.put(item, "arguments", arguments)
428
      _missing -> item
429
    end
430
  end
431
432
  defp maybe_put_function_call_arguments(item, _argument_map), do: item
292 433
293 434
  defp capture_reasoning_item(
294 435
         state,

@@ -312,6 +453,67 @@ defmodule OpenAgents.Chat.OpenRouter.ResponsesStreamDecoder do

312 453
313 454
  defp capture_reasoning_summary(state, _summary), do: state
314 455
456
  defp terminal_output(output, state) when is_list(output) and output != [] do
457
    Enum.map(output, &maybe_put_function_call_arguments(&1, state.function_call_arguments))
458
  end
459
460
  defp terminal_output(_output, state) do
461
    Enum.map(state.output_items, fn {_key, item} -> item end)
462
  end
463
464
  defp maybe_put_model(response, model) when is_binary(model),
465
    do: Map.put_new(response, "model", model)
466
467
  defp maybe_put_model(response, _model), do: response
468
469
  defp maybe_put_output(response, output) when is_list(output) and output != [],
470
    do: Map.put(response, "output", output)
471
472
  defp maybe_put_output(response, _output), do: response
473
474
  defp provider_event_error(event) do
475
    response = map_or_empty(event["response"])
476
    error = map_or_empty(event["error"] || response["error"])
477
    incomplete_details = map_or_empty(response["incomplete_details"])
478
479
    code =
480
      event["error_type"] ||
481
        error["code"] ||
482
        incomplete_details["reason"] ||
483
        "provider_error"
484
485
    message = error["message"] || incomplete_details["message"]
486
487
    {:provider_error, normalize_error_code(code), normalize_error_message(message)}
488
  end
489
490
  defp map_or_empty(value) when is_map(value), do: value
491
  defp map_or_empty(_value), do: %{}
492
493
  defp normalize_error_code(code) when is_binary(code) do
494
    code
495
    |> String.trim()
496
    |> String.slice(0, 80)
497
    |> case do
498
      "" -> "provider_error"
499
      normalized -> normalized
500
    end
501
  end
502
503
  defp normalize_error_code(_code), do: "provider_error"
504
505
  defp normalize_error_message(message) when is_binary(message) do
506
    message
507
    |> String.trim()
508
    |> String.slice(0, @maximum_error_characters)
509
    |> case do
510
      "" -> nil
511
      normalized -> normalized
512
    end
513
  end
514
515
  defp normalize_error_message(_message), do: nil
516
315 517
  defp streamed_completion(
316 518
         %{
317 519
           model: model,
lib/openagents/chat/tools/demo.ex deleted -44

@@ -1,44 +0,0 @@

1
defmodule OpenAgents.Chat.Tools.Demo do
2
  @moduledoc false
3
4
  @tool_name "get_demo_time"
5
6
  @spec definitions() :: [map()]
7
  def definitions do
8
    [
9
      %{
10
        "type" => "function",
11
        "name" => @tool_name,
12
        "description" =>
13
          "Get the current UTC time from the OpenAgents demo tool. Use this only when the user asks for the current time or asks to demonstrate a tool call.",
14
        "strict" => true,
15
        "parameters" => %{
16
          "type" => "object",
17
          "properties" => %{},
18
          "additionalProperties" => false
19
        }
20
      }
21
    ]
22
  end
23
24
  @spec execute(String.t(), String.t()) :: {:ok, map()}
25
  def execute(@tool_name, arguments) when is_binary(arguments) do
26
    case Jason.decode(arguments) do
27
      {:ok, arguments} when arguments == %{} ->
28
        {:ok,
29
         %{
30
           "time" => DateTime.utc_now() |> DateTime.to_iso8601(),
31
           "timezone" => "UTC",
32
           "source" => "OpenAgents demo tool"
33
         }}
34
35
      {:ok, _arguments} ->
36
        {:ok, %{"error" => "get_demo_time does not accept arguments."}}
37
38
      {:error, _reason} ->
39
        {:ok, %{"error" => "Tool arguments must be a JSON object."}}
40
    end
41
  end
42
43
  def execute(_name, _arguments), do: {:ok, %{"error" => "This tool is not available."}}
44
end
lib/openagents/chat/tools/repository_file.ex added +219

@@ -0,0 +1,219 @@

1
defmodule OpenAgents.Chat.Tools.RepositoryFile do
2
  @moduledoc false
3
4
  alias OpenAgents.Accounts.User
5
  alias OpenAgents.Forge.Browse
6
  alias OpenAgents.Repositories
7
  alias OpenAgents.Repositories.Repository
8
9
  @read_tool_name "read_repository_file"
10
  @list_tool_name "list_repository_directory"
11
12
  @spec definitions() :: [map()]
13
  def definitions do
14
    [
15
      %{
16
        "type" => "function",
17
        "name" => @read_tool_name,
18
        "description" =>
19
          "Read a text file from a repository the signed-in user can access in OpenAgents. The repository can be an owner/name path or an unambiguous repository name. If the user asks for a README without naming a path, set path to null and the tool reads README.md or README from the default branch.",
20
        "strict" => true,
21
        "parameters" => %{
22
          "type" => "object",
23
          "properties" => %{
24
            "repository" => %{
25
              "type" => "string",
26
              "description" =>
27
                "Repository path such as OpenAgentsInc/openagents.com, or an unambiguous name such as openagents.com."
28
            },
29
            "path" => %{
30
              "type" => ["string", "null"],
31
              "description" =>
32
                "Repository-relative file path. Use null to read the repository README."
33
            },
34
            "ref" => %{
35
              "type" => ["string", "null"],
36
              "description" =>
37
                "Branch, tag, or commit. Use null for the repository default branch."
38
            }
39
          },
40
          "required" => ["repository", "path", "ref"],
41
          "additionalProperties" => false
42
        }
43
      },
44
      %{
45
        "type" => "function",
46
        "name" => @list_tool_name,
47
        "description" =>
48
          "List the files and directories at one repository path. Use this tool before guessing a file path. Set path to null to list the repository root, then list child directories as needed before reading a file.",
49
        "strict" => true,
50
        "parameters" => %{
51
          "type" => "object",
52
          "properties" => %{
53
            "repository" => %{
54
              "type" => "string",
55
              "description" =>
56
                "Repository path such as OpenAgentsInc/openagents.com, or an unambiguous name such as openagents.com."
57
            },
58
            "path" => %{
59
              "type" => ["string", "null"],
60
              "description" =>
61
                "Repository-relative directory path. Use null to list the repository root."
62
            },
63
            "ref" => %{
64
              "type" => ["string", "null"],
65
              "description" =>
66
                "Branch, tag, or commit. Use null for the repository default branch."
67
            }
68
          },
69
          "required" => ["repository", "path", "ref"],
70
          "additionalProperties" => false
71
        }
72
      }
73
    ]
74
  end
75
76
  @spec execute(String.t(), String.t(), map()) :: {:ok, map()} | {:error, String.t()}
77
  def execute(@read_tool_name, arguments, %{user: %User{} = user}) when is_binary(arguments) do
78
    with {:ok, params} <- decode_arguments(arguments),
79
         {:ok, repository} <- resolve_repository(params["repository"], user),
80
         {:ok, path, blob} <- read_blob(repository, params["ref"], params["path"]) do
81
      if blob.binary do
82
        {:error, "The requested repository file is binary and cannot be read as text."}
83
      else
84
        {:ok,
85
         %{
86
           "repository" => repository.owner <> "/" <> repository.name,
87
           "ref" => params["ref"] || repository.default_branch,
88
           "path" => path,
89
           "content" => blob.content,
90
           "size_bytes" => blob.size,
91
           "truncated" => blob.truncated
92
         }}
93
      end
94
    end
95
  end
96
97
  def execute(@list_tool_name, arguments, %{user: %User{} = user}) when is_binary(arguments) do
98
    with {:ok, params} <- decode_arguments(arguments),
99
         {:ok, repository} <- resolve_repository(params["repository"], user),
100
         {:ok, path, entries} <- list_directory(repository, params["ref"], params["path"]) do
101
      {:ok,
102
       %{
103
         "repository" => repository.owner <> "/" <> repository.name,
104
         "ref" => params["ref"] || repository.default_branch,
105
         "path" => path,
106
         "entries" => Enum.map(entries, &directory_entry(path, &1)),
107
         "count" => length(entries)
108
       }}
109
    end
110
  end
111
112
  def execute(name, _arguments, _context) when name in [@read_tool_name, @list_tool_name],
113
    do: {:error, "You must sign in before reading a connected repository."}
114
115
  def execute(_name, _arguments, _context), do: {:error, "This tool is not available."}
116
117
  defp decode_arguments(arguments) do
118
    case Jason.decode(arguments) do
119
      {:ok, %{"repository" => repository} = params} when is_binary(repository) ->
120
        {:ok,
121
         params
122
         |> Map.update("path", nil, &normalize_optional_argument/1)
123
         |> Map.update("ref", nil, &normalize_optional_argument/1)}
124
125
      {:ok, _arguments} ->
126
        {:error, "Tool arguments must include a repository string."}
127
128
      {:error, _reason} ->
129
        {:error, "Tool arguments must be a JSON object."}
130
    end
131
  end
132
133
  defp normalize_optional_argument(value) when is_binary(value) do
134
    trimmed = String.trim(value)
135
136
    if String.downcase(trimmed) in ["", "null"], do: nil, else: trimmed
137
  end
138
139
  defp normalize_optional_argument(value), do: value
140
141
  defp resolve_repository(repository_path, user) do
142
    case String.split(repository_path, "/", parts: 2) do
143
      [owner, name] when owner != "" and name != "" ->
144
        get_visible_repository(owner, name, user)
145
146
      [name] when name != "" ->
147
        matches =
148
          user
149
          |> Repositories.list_visible_repositories()
150
          |> Enum.filter(&(String.downcase(&1.name) == String.downcase(name)))
151
152
        case matches do
153
          [repository] ->
154
            {:ok, repository}
155
156
          [] ->
157
            {:error, "No accessible repository matches #{name}."}
158
159
          _matches ->
160
            {:error, "More than one accessible repository matches #{name}. Use owner/name."}
161
        end
162
163
      _invalid ->
164
        {:error, "Repository must be an owner/name path or repository name."}
165
    end
166
  end
167
168
  defp get_visible_repository(owner, name, user) do
169
    {:ok, Repositories.get_visible_by_path!(owner, name, user)}
170
  rescue
171
    Ecto.NoResultsError -> {:error, "The repository does not exist or you cannot access it."}
172
  end
173
174
  defp read_blob(%Repository{} = repository, requested_ref, requested_path) do
175
    ref = requested_ref || repository.default_branch
176
177
    case requested_path do
178
      nil ->
179
        case Browse.readme(repository, ref) do
180
          {:ok, path, blob} -> {:ok, path, blob}
181
          {:error, :not_found} -> {:error, "The repository does not have a README at that ref."}
182
        end
183
184
      path when is_binary(path) ->
185
        case Browse.blob(repository, ref, path) do
186
          {:ok, blob} ->
187
            {:ok, path, blob}
188
189
          {:error, :not_found} ->
190
            {:error,
191
             "The requested file or ref does not exist. List the parent directory before trying another path."}
192
        end
193
194
      _invalid ->
195
        {:error, "Path must be a repository-relative string or null."}
196
    end
197
  end
198
199
  defp list_directory(%Repository{} = repository, requested_ref, requested_path) do
200
    ref = requested_ref || repository.default_branch
201
    path = requested_path || ""
202
203
    case Browse.tree(repository, ref, path) do
204
      {:ok, entries} -> {:ok, path, entries}
205
      {:error, :not_found} -> {:error, "The requested directory or ref does not exist."}
206
    end
207
  end
208
209
  defp directory_entry(parent_path, entry) do
210
    path = if parent_path == "", do: entry.name, else: parent_path <> "/" <> entry.name
211
212
    %{
213
      "name" => entry.name,
214
      "path" => path,
215
      "type" => if(entry.kind == "tree", do: "directory", else: "file"),
216
      "size_bytes" => entry.size
217
    }
218
  end
219
end
lib/openagents/markdown.ex modified +36

@@ -227,6 +227,7 @@ defmodule OpenAgents.Markdown do

227 227
      |> drop_incomplete_html_tag()
228 228
      |> drop_incomplete_block_marker()
229 229
      |> close_inline_code()
230
      |> finish_partial_emphasis_closer()
230 231
      |> close_emphasis()
231 232
    end
232 233
  end

@@ -300,6 +301,41 @@ defmodule OpenAgents.Markdown do

300 301
    |> Enum.count(&(&1 == "`"))
301 302
  end
302 303
304
  # A closing delimiter can arrive one character at a time. At the intermediate
305
  # `**bold*` prefix, appending a full `**` produces `**bold***`, which CommonMark
306
  # renders as bold text followed by a literal asterisk. Add only the missing
307
  # part of the closer. If the shorter trailing delimiter is already balanced,
308
  # it belongs to a nested span and must remain untouched.
309
  defp finish_partial_emphasis_closer(text) do
310
    case Regex.run(~r/(\*+|_+|~+)\z/u, text, capture: :all_but_first) do
311
      [tail] -> finish_partial_emphasis_closer(text, tail)
312
      nil -> text
313
    end
314
  end
315
316
  defp finish_partial_emphasis_closer(text, tail) do
317
    character = String.first(tail)
318
    tail_length = String.length(tail)
319
    shorter_marker = String.duplicate(character, tail_length)
320
321
    shorter_marker_balanced? =
322
      shorter_marker in @emphasis_markers and not unbalanced?(text, shorter_marker)
323
324
    partial_opener =
325
      Enum.find(@emphasis_markers, fn marker ->
326
        String.first(marker) == character and
327
          String.length(marker) > tail_length and
328
          unbalanced?(text, marker) and
329
          content_after_last?(text, marker)
330
      end)
331
332
    if partial_opener && not shorter_marker_balanced? do
333
      text <> String.duplicate(character, String.length(partial_opener) - tail_length)
334
    else
335
      text
336
    end
337
  end
338
303 339
  defp close_emphasis(text) do
304 340
    # Recomputed per marker: dropping a dangling one changes what the next
305 341
    # marker sees.
lib/openagents/repositories/importer.ex modified +33 -8

@@ -59,9 +59,10 @@ defmodule OpenAgents.Repositories.Importer do

59 59
        :ok
60 60
61 61
      {:error, reason} ->
62
        log_stage(repository, running_import, "import", "failed", reason)
63
        mark_failed!(running_import, error_code(reason))
64
        {:error, reason}
62
        normalized_reason = normalize_error(reason)
63
        log_stage(repository, running_import, "import", "failed", normalized_reason)
64
        mark_failed!(running_import, error_code(normalized_reason))
65
        {:error, normalized_reason}
65 66
    end
66 67
  end
67 68

@@ -183,15 +184,33 @@ defmodule OpenAgents.Repositories.Importer do

183 184
        end
184 185
185 186
      nil ->
186
        with true <-
187
               repository.created_by_user.github_token_scopes == GitHubOAuth.required_scopes() or
188
                 {:error, :github_scope_required},
189
             {:ok, credential} <- Accounts.github_token(repository.created_by_user) do
190
          {:ok, "https://github.com/#{repository_import.source_full_name}.git", credential}
187
        source_url = "https://github.com/#{repository_import.source_full_name}.git"
188
189
        case repository.visibility do
190
          "public" ->
191
            {:ok, source_url, optional_github_credential(repository.created_by_user)}
192
193
          "private" ->
194
            with true <-
195
                   repository.created_by_user.github_token_scopes ==
196
                     GitHubOAuth.required_scopes() or
197
                     {:error, :github_scope_required},
198
                 {:ok, credential} <- Accounts.github_token(repository.created_by_user) do
199
              {:ok, source_url, credential}
200
            end
191 201
        end
192 202
    end
193 203
  end
194 204
205
  defp optional_github_credential(user) do
206
    if user.github_token_scopes == GitHubOAuth.required_scopes() do
207
      case Accounts.github_token(user) do
208
        {:ok, credential} -> credential
209
        {:error, _reason} -> nil
210
      end
211
    end
212
  end
213
195 214
  defp initialize_source(temporary_directory) do
196 215
    path = Path.join(temporary_directory, "source.git")
197 216

@@ -543,6 +562,12 @@ defmodule OpenAgents.Repositories.Importer do

543 562
  defp error_code(:temporary_storage_unavailable), do: "temporary_storage_unavailable"
544 563
  defp error_code(_reason), do: "import_failed"
545 564
565
  defp normalize_error(reason) when reason in [:eacces, :enoent, :enotdir, :erofs],
566
    do: :temporary_storage_unavailable
567
568
  defp normalize_error(:enospc), do: :insufficient_storage
569
  defp normalize_error(reason), do: reason
570
546 571
  defp import_stage(repository, repository_import, stage, operation) do
547 572
    log_stage(repository, repository_import, stage, "started")
548 573
lib/openagents/repositories/provisioner.ex modified +21 -6

@@ -30,7 +30,7 @@ defmodule OpenAgents.Repositories.Provisioner do

30 30
      %ProvisioningOutbox{} = work ->
31 31
        case safe_execute(executor, work) do
32 32
          :ok -> complete(work)
33
          {:error, _reason} -> fail(work)
33
          {:error, reason} -> fail(work, reason)
34 34
        end
35 35
36 36
        :processed

@@ -210,9 +210,10 @@ defmodule OpenAgents.Repositories.Provisioner do

210 210
    :ok
211 211
  end
212 212
213
  defp fail(work) do
213
  defp fail(work, reason) do
214 214
    now = DateTime.utc_now()
215 215
    retry_at = DateTime.add(now, retry_delay(work.attempt_count), :second)
216
    error_code = provision_error_code(reason)
216 217
217 218
    Repo.transaction(fn ->
218 219
      repository = lock_repository!(work.repository_id)

@@ -222,7 +223,7 @@ defmodule OpenAgents.Repositories.Provisioner do

222 223
      |> Ecto.Changeset.change(
223 224
        lifecycle_state: "failed",
224 225
        ready_at: nil,
225
        provision_error_code: "provisioning_failed"
226
        provision_error_code: error_code
226 227
      )
227 228
      |> Repo.update!()
228 229

@@ -233,7 +234,7 @@ defmodule OpenAgents.Repositories.Provisioner do

233 234
        retry_at: retry_at,
234 235
        claimed_at: outbox.claimed_at,
235 236
        completed_at: nil,
236
        error_code: "provisioning_failed"
237
        error_code: error_code
237 238
      })
238 239
      |> Repo.update!()
239 240

@@ -241,17 +242,31 @@ defmodule OpenAgents.Repositories.Provisioner do

241 242
        repository_id: repository.id,
242 243
        metadata: %{
243 244
          "attempt_count" => outbox.attempt_count,
244
          "error_code" => "provisioning_failed",
245
          "error_code" => error_code,
245 246
          "operation" => outbox.operation
246 247
        }
247 248
      )
248 249
    end)
249 250
250 251
    OpenAgents.Repositories.broadcast_provisioning(work.repository_id)
251
    Logger.warning("repository_provisioning_failed code=provisioning_failed")
252
    Logger.warning("repository_provisioning_failed code=#{error_code}")
252 253
    :ok
253 254
  end
254 255
256
  defp provision_error_code(reason)
257
       when reason in [
258
              :github_connection_required,
259
              :github_scope_required,
260
              :import_timeout,
261
              :import_too_large,
262
              :insufficient_storage,
263
              :source_changed,
264
              :temporary_storage_unavailable
265
            ],
266
       do: Atom.to_string(reason)
267
268
  defp provision_error_code(_reason), do: "provisioning_failed"
269
255 270
  defp lock_repository!(id) do
256 271
    Repo.one!(from repository in Repository, where: repository.id == ^id, lock: "FOR UPDATE")
257 272
  end
lib/openagents_web/components/ai/conversation.ex modified +124 -7

@@ -92,6 +92,7 @@ defmodule OpenAgentsWeb.AI.Conversation do

92 92
      />
93 93
      <script :type={Phoenix.LiveView.ColocatedHook} name=".StickToBottom">
94 94
        const THRESHOLD = 24
95
        const USER_SCROLL_WINDOW = 400
95 96
96 97
        export default {
97 98
          mounted() {

@@ -100,12 +101,86 @@ defmodule OpenAgentsWeb.AI.Conversation do

100 101
101 102
            this.button = this.el.querySelector("[data-conversation-scroll-button]")
102 103
            this.pinned = true
104
            this.userScrollUntil = 0
105
            this.touchY = null
106
            this.pointerDown = false
107
            this.stickFrame = null
108
109
            this.markUserScroll = () => {
110
              this.userScrollUntil = performance.now() + USER_SCROLL_WINDOW
111
            }
103 112
104 113
            this.onScroll = () => {
105
              this.pinned = this.atBottom()
114
              if (this.atBottom()) {
115
                this.pinned = true
116
              } else if (performance.now() <= this.userScrollUntil) {
117
                this.pinned = false
118
              }
106 119
              this.sync()
107 120
            }
121
122
            this.onWheel = (event) => {
123
              this.markUserScroll()
124
125
              if (event.deltaY < 0) {
126
                this.pinned = false
127
                this.sync()
128
              }
129
            }
130
131
            this.onTouchStart = (event) => {
132
              this.touchY = event.touches[0]?.clientY ?? null
133
              this.markUserScroll()
134
            }
135
136
            this.onTouchMove = (event) => {
137
              const nextY = event.touches[0]?.clientY ?? null
138
              this.markUserScroll()
139
140
              if (nextY !== null && this.touchY !== null && nextY > this.touchY) {
141
                this.pinned = false
142
                this.sync()
143
              }
144
145
              this.touchY = nextY
146
            }
147
148
            this.onPointerDown = () => {
149
              this.pointerDown = true
150
              this.markUserScroll()
151
            }
152
153
            this.onPointerMove = () => {
154
              if (this.pointerDown) this.markUserScroll()
155
            }
156
157
            this.onPointerUp = () => {
158
              this.pointerDown = false
159
              this.markUserScroll()
160
            }
161
162
            this.onKeyDown = (event) => {
163
              const upwardKeys = ["ArrowUp", "PageUp", "Home"]
164
              const scrollingKeys = [...upwardKeys, "ArrowDown", "PageDown", "End", " "]
165
166
              if (!scrollingKeys.includes(event.key)) return
167
168
              this.markUserScroll()
169
170
              if (upwardKeys.includes(event.key)) {
171
                this.pinned = false
172
                this.sync()
173
              }
174
            }
175
108 176
            this.viewport.addEventListener("scroll", this.onScroll, { passive: true })
177
            this.viewport.addEventListener("wheel", this.onWheel, { passive: true })
178
            this.viewport.addEventListener("touchstart", this.onTouchStart, { passive: true })
179
            this.viewport.addEventListener("touchmove", this.onTouchMove, { passive: true })
180
            this.viewport.addEventListener("pointerdown", this.onPointerDown)
181
            this.viewport.addEventListener("pointermove", this.onPointerMove)
182
            window.addEventListener("pointerup", this.onPointerUp)
183
            this.viewport.addEventListener("keydown", this.onKeyDown)
109 184
110 185
            if (this.button) {
111 186
              this.onClick = () => {

@@ -116,22 +191,37 @@ defmodule OpenAgentsWeb.AI.Conversation do

116 191
            }
117 192
118 193
            if (window.ResizeObserver) {
119
              this.observer = new ResizeObserver(() => this.stick("smooth"))
120
              for (const child of this.viewport.children) {
121
                this.observer.observe(child)
122
              }
194
              this.observer = new ResizeObserver(() => this.scheduleStick())
195
              this.observeChildren()
123 196
            }
124 197
198
            this.mutationObserver = new MutationObserver(() => this.scheduleStick())
199
            this.mutationObserver.observe(this.viewport, {
200
              childList: true,
201
              characterData: true,
202
              subtree: true
203
            })
204
125 205
            this.stick("auto")
126 206
          },
127 207
128 208
          updated() {
129
            this.stick("smooth")
209
            this.observeChildren()
210
            this.scheduleStick()
130 211
          },
131 212
132 213
          destroyed() {
133 214
            if (this.viewport && this.onScroll) {
134 215
              this.viewport.removeEventListener("scroll", this.onScroll)
216
              this.viewport.removeEventListener("wheel", this.onWheel)
217
              this.viewport.removeEventListener("touchstart", this.onTouchStart)
218
              this.viewport.removeEventListener("touchmove", this.onTouchMove)
219
              this.viewport.removeEventListener("pointerdown", this.onPointerDown)
220
              this.viewport.removeEventListener("pointermove", this.onPointerMove)
221
              this.viewport.removeEventListener("keydown", this.onKeyDown)
222
            }
223
            if (this.onPointerUp) {
224
              window.removeEventListener("pointerup", this.onPointerUp)
135 225
            }
136 226
            if (this.button && this.onClick) {
137 227
              this.button.removeEventListener("click", this.onClick)

@@ -139,6 +229,12 @@ defmodule OpenAgentsWeb.AI.Conversation do

139 229
            if (this.observer) {
140 230
              this.observer.disconnect()
141 231
            }
232
            if (this.mutationObserver) {
233
              this.mutationObserver.disconnect()
234
            }
235
            if (this.stickFrame) {
236
              cancelAnimationFrame(this.stickFrame)
237
            }
142 238
          },
143 239
144 240
          atBottom() {

@@ -148,11 +244,32 @@ defmodule OpenAgentsWeb.AI.Conversation do

148 244
149 245
          stick(behavior) {
150 246
            if (this.pinned) {
151
              this.viewport.scrollTo({ top: this.viewport.scrollHeight, behavior })
247
              if (behavior === "smooth") {
248
                this.viewport.scrollTo({ top: this.viewport.scrollHeight, behavior })
249
              } else {
250
                this.viewport.scrollTop = this.viewport.scrollHeight
251
              }
152 252
            }
153 253
            this.sync()
154 254
          },
155 255
256
          scheduleStick() {
257
            if (this.stickFrame) return
258
259
            this.stickFrame = requestAnimationFrame(() => {
260
              this.stickFrame = null
261
              this.stick("auto")
262
            })
263
          },
264
265
          observeChildren() {
266
            if (!this.observer) return
267
268
            for (const child of this.viewport.children) {
269
              this.observer.observe(child)
270
            }
271
          },
272
156 273
          sync() {
157 274
            const atBottom = this.atBottom()
158 275
            this.el.dataset.atBottom = String(atBottom)
lib/openagents_web/live/chat_placeholder_live.ex modified +287 -46

@@ -39,7 +39,16 @@ defmodule OpenAgentsWeb.ChatPlaceholderLive do

39 39
    ]
40 40
41 41
  import OpenAgentsWeb.AI.Reasoning,
42
    only: [reasoning: 1, reasoning_trigger: 1, reasoning_content: 1]
42
    only: [
43
      reasoning: 1,
44
      reasoning_trigger: 1,
45
      reasoning_content: 1,
46
      tool: 1,
47
      tool_header: 1,
48
      tool_content: 1,
49
      tool_input: 1,
50
      tool_output: 1
51
    ]
43 52
44 53
  @impl true
45 54
  def mount(_params, _session, socket) do

@@ -51,6 +60,8 @@ defmodule OpenAgentsWeb.ChatPlaceholderLive do

51 60
     |> assign(:messages, [])
52 61
     |> assign(:assistant_response, nil)
53 62
     |> assign(:assistant_reasoning, nil)
63
     |> assign(:assistant_tool_calls, [])
64
     |> assign(:assistant_blocks, [])
54 65
     |> assign(:reasoning_started_at, nil)
55 66
     |> assign(:streaming?, false)
56 67
     |> assign(:stream_task_ref, nil)

@@ -74,7 +85,10 @@ defmodule OpenAgentsWeb.ChatPlaceholderLive do

74 85
  def handle_info({:openrouter_stream_event, stream_id, {:text_delta, delta}}, socket) do
75 86
    case socket.assigns do
76 87
      %{stream_id: ^stream_id, streaming?: true} ->
77
        {:noreply, update(socket, :assistant_response, &(&1 <> delta))}
88
        {:noreply,
89
         socket
90
         |> update(:assistant_response, &(&1 <> delta))
91
         |> append_streaming_delta(:content, delta)}
78 92
79 93
      _stale_stream ->
80 94
        {:noreply, socket}

@@ -84,13 +98,54 @@ defmodule OpenAgentsWeb.ChatPlaceholderLive do

84 98
  def handle_info({:openrouter_stream_event, stream_id, {:reasoning_delta, delta}}, socket) do
85 99
    case socket.assigns do
86 100
      %{stream_id: ^stream_id, streaming?: true} ->
87
        {:noreply, update(socket, :assistant_reasoning, &((&1 || "") <> delta))}
101
        {:noreply,
102
         socket
103
         |> update(:assistant_reasoning, &((&1 || "") <> delta))
104
         |> append_streaming_delta(:reasoning, delta)}
105
106
      _stale_stream ->
107
        {:noreply, socket}
108
    end
109
  end
110
111
  def handle_info(
112
        {:openrouter_stream_event, stream_id, {:tool_call_started, tool_call}},
113
        socket
114
      ) do
115
    case socket.assigns do
116
      %{stream_id: ^stream_id, streaming?: true} ->
117
        {:noreply,
118
         socket
119
         |> update(:assistant_tool_calls, &(&1 ++ [tool_call_view(tool_call)]))
120
         |> append_tool_block(tool_call_view(tool_call))}
88 121
89 122
      _stale_stream ->
90 123
        {:noreply, socket}
91 124
    end
92 125
  end
93 126
127
  def handle_info(
128
        {:openrouter_stream_event, stream_id, {:tool_call_completed, tool_result}},
129
        socket
130
      ) do
131
    update_streaming_tool(socket, stream_id, tool_result["call_id"], fn tool_call ->
132
      %{
133
        tool_call
134
        | output: format_tool_json(tool_result["output"]),
135
          state: "output-available"
136
      }
137
    end)
138
  end
139
140
  def handle_info(
141
        {:openrouter_stream_event, stream_id, {:tool_call_failed, tool_result}},
142
        socket
143
      ) do
144
    update_streaming_tool(socket, stream_id, tool_result["call_id"], fn tool_call ->
145
      %{tool_call | error: tool_result["error"], state: "output-error"}
146
    end)
147
  end
148
94 149
  def handle_info(
95 150
        {task_ref, {:ok, completion}},
96 151
        %{assigns: %{stream_task_ref: task_ref}} = socket

@@ -100,6 +155,9 @@ defmodule OpenAgentsWeb.ChatPlaceholderLive do

100 155
    assistant_content = completion["assistant_content"] || socket.assigns.assistant_response
101 156
    reasoning = completion["reasoning_summary"] || socket.assigns.assistant_reasoning
102 157
158
    assistant_blocks =
159
      reconcile_assistant_blocks(socket.assigns.assistant_blocks, assistant_content, reasoning)
160
103 161
    {:noreply,
104 162
     socket
105 163
     |> append_assistant_message(

@@ -108,10 +166,14 @@ defmodule OpenAgentsWeb.ChatPlaceholderLive do

108 166
       completion,
109 167
       nil,
110 168
       reasoning,
111
       reasoning_duration(socket)
169
       reasoning_duration(socket),
170
       socket.assigns.assistant_tool_calls,
171
       assistant_blocks
112 172
     )
113 173
     |> assign(:assistant_response, nil)
114 174
     |> assign(:assistant_reasoning, nil)
175
     |> assign(:assistant_tool_calls, [])
176
     |> assign(:assistant_blocks, [])
115 177
     |> assign(:reasoning_started_at, nil)
116 178
     |> assign(:streaming?, false)
117 179
     |> assign(:stream_task_ref, nil)

@@ -127,10 +189,16 @@ defmodule OpenAgentsWeb.ChatPlaceholderLive do

127 189
       socket.assigns.stream_id,
128 190
       socket.assigns.assistant_response,
129 191
       nil,
130
       error_message(reason)
192
       error_message(reason),
193
       socket.assigns.assistant_reasoning,
194
       reasoning_duration(socket),
195
       socket.assigns.assistant_tool_calls,
196
       finalize_assistant_blocks(socket.assigns.assistant_blocks)
131 197
     )
132 198
     |> assign(:assistant_response, nil)
133 199
     |> assign(:assistant_reasoning, nil)
200
     |> assign(:assistant_tool_calls, [])
201
     |> assign(:assistant_blocks, [])
134 202
     |> assign(:reasoning_started_at, nil)
135 203
     |> assign(:streaming?, false)
136 204
     |> assign(:stream_task_ref, nil)

@@ -147,10 +215,16 @@ defmodule OpenAgentsWeb.ChatPlaceholderLive do

147 215
       socket.assigns.stream_id,
148 216
       socket.assigns.assistant_response,
149 217
       nil,
150
       error_message(:provider_unavailable)
218
       error_message(:provider_unavailable),
219
       socket.assigns.assistant_reasoning,
220
       reasoning_duration(socket),
221
       socket.assigns.assistant_tool_calls,
222
       finalize_assistant_blocks(socket.assigns.assistant_blocks)
151 223
     )
152 224
     |> assign(:assistant_response, nil)
153 225
     |> assign(:assistant_reasoning, nil)
226
     |> assign(:assistant_tool_calls, [])
227
     |> assign(:assistant_blocks, [])
154 228
     |> assign(:reasoning_started_at, nil)
155 229
     |> assign(:streaming?, false)
156 230
     |> assign(:stream_task_ref, nil)

@@ -170,7 +244,7 @@ defmodule OpenAgentsWeb.ChatPlaceholderLive do

170 244
      flush
171 245
    >
172 246
      <section id="chat-placeholder" class="relative flex min-h-0 flex-1 flex-col bg-background">
173
        <div class="flex min-h-0 flex-1 px-4 pb-44 pt-6">
247
        <div class="flex min-h-0 flex-1 px-4">
174 248
          <.conversation
175 249
            id="chat-placeholder-transcript"
176 250
            class="w-full"

@@ -194,18 +268,17 @@ defmodule OpenAgentsWeb.ChatPlaceholderLive do

194 268
                  from={Atom.to_string(message.role)}
195 269
                  data-message-role={Atom.to_string(message.role)}
196 270
                >
197
                  <.reasoning
198
                    :if={message.role == :assistant and message.reasoning}
199
                    id={"chat-placeholder-reasoning-#{message.id}"}
200
                    open={false}
201
                  >
202
                    <.reasoning_trigger duration={message.reasoning_duration} />
203
                    <.reasoning_content text={message.reasoning} />
204
                  </.reasoning>
205
                  <.message_content text={message.content}>
206
                    <p :if={message.error} id={"chat-placeholder-error-#{message.id}"} role="status">
207
                      {message.error}
208
                    </p>
271
                  <%= if message.role == :assistant do %>
272
                    <.assistant_block
273
                      :for={{block, index} <- Enum.with_index(message.blocks)}
274
                      id={"chat-placeholder-block-#{message.id}-#{index}"}
275
                      block={block}
276
                    />
277
                    <.message_content :if={message.error}>
278
                      <p id={"chat-placeholder-error-#{message.id}"} role="status">
279
                        {message.error}
280
                      </p>
281
                    </.message_content>
209 282
                    <p
210 283
                      :if={message.completion}
211 284
                      id={"chat-placeholder-response-metadata-#{message.id}"}

@@ -213,7 +286,9 @@ defmodule OpenAgentsWeb.ChatPlaceholderLive do

213 286
                    >
214 287
                      OpenRouter · {message.completion["object"]} · {message.completion["model"]}
215 288
                    </p>
216
                  </.message_content>
289
                  <% else %>
290
                    <.message_content text={message.content} />
291
                  <% end %>
217 292
                </.message>
218 293
219 294
                <.message

@@ -221,22 +296,11 @@ defmodule OpenAgentsWeb.ChatPlaceholderLive do

221 296
                  id="chat-placeholder-streaming-assistant-message"
222 297
                  from="assistant"
223 298
                >
224
                  <.reasoning
225
                    :if={@streaming?}
226
                    id="chat-placeholder-streaming-reasoning"
227
                    open={true}
228
                  >
229
                    <.reasoning_trigger streaming={true} duration={0} />
230
                    <.reasoning_content
231
                      :if={@assistant_reasoning}
232
                      text={@assistant_reasoning}
233
                      streaming
234
                    />
235
                  </.reasoning>
236
                  <.message_content
237
                    id="chat-placeholder-response"
238
                    text={@assistant_response}
239
                    streaming={@streaming?}
299
                  <.assistant_block
300
                    :for={{block, index} <- Enum.with_index(@assistant_blocks)}
301
                    id={"chat-placeholder-streaming-block-#{index}"}
302
                    block={block}
303
                    streaming
240 304
                  />
241 305
                </.message>
242 306
              </div>

@@ -316,9 +380,11 @@ defmodule OpenAgentsWeb.ChatPlaceholderLive do

316 380
317 381
    task =
318 382
      Task.Supervisor.async_nolink(OpenAgents.ProviderTaskSupervisor, fn ->
319
        OpenRouter.stream(request, fn event ->
320
          send(owner, {:openrouter_stream_event, stream_id, event})
321
        end)
383
        OpenRouter.stream(
384
          request,
385
          fn event -> send(owner, {:openrouter_stream_event, stream_id, event}) end,
386
          tool_context: %{user: socket.assigns.current_user}
387
        )
322 388
      end)
323 389
324 390
    {:noreply,

@@ -327,6 +393,8 @@ defmodule OpenAgentsWeb.ChatPlaceholderLive do

327 393
     |> update(:messages, &(&1 ++ [user_message(stream_id, message)]))
328 394
     |> assign(:assistant_response, "")
329 395
     |> assign(:assistant_reasoning, nil)
396
     |> assign(:assistant_tool_calls, [])
397
     |> assign(:assistant_blocks, [reasoning_block("")])
330 398
     |> assign(:reasoning_started_at, System.monotonic_time(:second))
331 399
     |> assign(:streaming?, true)
332 400
     |> assign(:stream_task_ref, task.ref)

@@ -335,11 +403,16 @@ defmodule OpenAgentsWeb.ChatPlaceholderLive do

335 403
  end
336 404
337 405
  defp user_message(id, content),
338
    do: %{id: id, role: :user, content: content, completion: nil, error: nil, history?: true}
339
340
  defp append_assistant_message(socket, id, content, completion, error) do
341
    append_assistant_message(socket, id, content, completion, error, nil, nil)
342
  end
406
    do: %{
407
      id: id,
408
      role: :user,
409
      content: content,
410
      completion: nil,
411
      error: nil,
412
      history?: true,
413
      tool_calls: [],
414
      blocks: []
415
    }
343 416
344 417
  defp append_assistant_message(
345 418
         socket,

@@ -348,7 +421,9 @@ defmodule OpenAgentsWeb.ChatPlaceholderLive do

348 421
         completion,
349 422
         error,
350 423
         reasoning,
351
         reasoning_duration
424
         reasoning_duration,
425
         tool_calls,
426
         blocks
352 427
       ) do
353 428
    assistant = %{
354 429
      id: id,

@@ -361,7 +436,9 @@ defmodule OpenAgentsWeb.ChatPlaceholderLive do

361 436
      provider_status: if(completion && completion["assistant_message_id"], do: "completed"),
362 437
      provider_reasoning_items: completion && completion["reasoning_items"],
363 438
      reasoning: reasoning,
364
      reasoning_duration: reasoning_duration
439
      reasoning_duration: reasoning_duration,
440
      tool_calls: tool_calls,
441
      blocks: blocks
365 442
    }
366 443
367 444
    update(socket, :messages, &(&1 ++ [assistant]))

@@ -421,4 +498,168 @@ defmodule OpenAgentsWeb.ChatPlaceholderLive do

421 498
  end
422 499
423 500
  defp reasoning_duration(_socket), do: nil
501
502
  attr :id, :string, required: true
503
  attr :tool_call, :map, required: true
504
  attr :open, :boolean, default: false
505
506
  defp tool_call_component(assigns) do
507
    ~H"""
508
    <.tool id={@id} open={@open || @tool_call.state != "output-available"}>
509
      <.tool_header
510
        type={"tool-#{@tool_call.name}"}
511
        title={@tool_call.name}
512
        state={@tool_call.state}
513
      />
514
      <.tool_content>
515
        <.tool_input input={@tool_call.arguments} />
516
        <.tool_output output={@tool_call.output} error_text={@tool_call.error} />
517
      </.tool_content>
518
    </.tool>
519
    """
520
  end
521
522
  attr :id, :string, required: true
523
  attr :block, :map, required: true
524
  attr :streaming, :boolean, default: false
525
526
  defp assistant_block(assigns) do
527
    ~H"""
528
    <%= case @block.type do %>
529
      <% :reasoning -> %>
530
        <.reasoning id={@id} open={@streaming && is_nil(@block.duration)}>
531
          <.reasoning_trigger
532
            streaming={@streaming && is_nil(@block.duration)}
533
            duration={@block.duration || 0}
534
          />
535
          <.reasoning_content
536
            :if={@block.text != ""}
537
            text={@block.text}
538
            streaming={@streaming && is_nil(@block.duration)}
539
          />
540
        </.reasoning>
541
      <% :tool -> %>
542
        <.tool_call_component id={@id} tool_call={@block.tool_call} open />
543
      <% :content -> %>
544
        <.message_content id={@id} text={@block.text} streaming={@streaming} />
545
    <% end %>
546
    """
547
  end
548
549
  defp update_streaming_tool(socket, stream_id, call_id, update_tool) do
550
    case socket.assigns do
551
      %{stream_id: ^stream_id, streaming?: true} ->
552
        {:noreply,
553
         socket
554
         |> update(:assistant_tool_calls, &update_tool_call(&1, call_id, update_tool))
555
         |> update(:assistant_blocks, fn blocks ->
556
           Enum.map(blocks, fn
557
             %{type: :tool, tool_call: %{call_id: ^call_id} = tool_call} = block ->
558
               %{block | tool_call: update_tool.(tool_call)}
559
560
             block ->
561
               block
562
           end)
563
         end)}
564
565
      _stale_stream ->
566
        {:noreply, socket}
567
    end
568
  end
569
570
  defp format_tool_json(value) when is_binary(value) do
571
    case Jason.decode(value) do
572
      {:ok, decoded} -> Jason.encode!(decoded, pretty: true)
573
      {:error, _reason} -> value
574
    end
575
  end
576
577
  defp format_tool_json(value), do: Jason.encode!(value, pretty: true)
578
579
  defp tool_call_view(tool_call) do
580
    %{
581
      call_id: tool_call["call_id"],
582
      name: tool_call["name"],
583
      arguments: format_tool_json(tool_call["arguments"]),
584
      output: nil,
585
      error: nil,
586
      state: "input-available"
587
    }
588
  end
589
590
  defp update_tool_call(tool_calls, call_id, update_tool) do
591
    Enum.map(tool_calls, fn
592
      %{call_id: ^call_id} = tool_call -> update_tool.(tool_call)
593
      tool_call -> tool_call
594
    end)
595
  end
596
597
  defp append_streaming_delta(socket, type, delta) do
598
    update(socket, :assistant_blocks, fn blocks ->
599
      case List.last(blocks) do
600
        %{type: ^type} = block ->
601
          List.replace_at(blocks, -1, %{block | text: block.text <> delta})
602
603
        _other ->
604
          finalize_assistant_blocks(blocks) ++ [streaming_block(type, delta)]
605
      end
606
    end)
607
  end
608
609
  defp append_tool_block(socket, tool_call) do
610
    update(socket, :assistant_blocks, fn blocks ->
611
      finalize_assistant_blocks(blocks) ++ [%{type: :tool, tool_call: tool_call}]
612
    end)
613
  end
614
615
  defp streaming_block(:reasoning, text), do: reasoning_block(text)
616
  defp streaming_block(:content, text), do: %{type: :content, text: text}
617
618
  defp reasoning_block(text) do
619
    %{
620
      type: :reasoning,
621
      text: text,
622
      started_at: System.monotonic_time(:second),
623
      duration: nil
624
    }
625
  end
626
627
  defp finalize_assistant_blocks(blocks) do
628
    blocks
629
    |> Enum.map(fn
630
      %{type: :reasoning, duration: nil} = block ->
631
        %{block | duration: max(System.monotonic_time(:second) - block.started_at, 1)}
632
633
      block ->
634
        block
635
    end)
636
    |> Enum.reject(&(&1.type == :reasoning and &1.text == ""))
637
  end
638
639
  defp reconcile_assistant_blocks(blocks, content, reasoning) do
640
    blocks = finalize_assistant_blocks(blocks)
641
642
    blocks =
643
      if Enum.any?(blocks, &(&1.type == :reasoning)) or not is_binary(reasoning) or
644
           reasoning == "" do
645
        blocks
646
      else
647
        [%{reasoning_block(reasoning) | duration: 1} | blocks]
648
      end
649
650
    case Enum.find_index(Enum.reverse(blocks), &(&1.type == :content)) do
651
      nil when is_binary(content) and content != "" ->
652
        blocks ++ [%{type: :content, text: content}]
653
654
      nil ->
655
        blocks
656
657
      reversed_index when is_binary(content) ->
658
        index = length(blocks) - reversed_index - 1
659
        List.update_at(blocks, index, &%{&1 | text: content})
660
661
      _index ->
662
        blocks
663
    end
664
  end
424 665
end
test/fixtures/openrouter/responses_tool_call.sse added +21

@@ -0,0 +1,21 @@

1
data: {"type":"response.created","response":{"id":"resp_demo","object":"response","status":"in_progress","model":"stealth/ox-alpha","output":[]}}
2
3
data: {"type":"response.in_progress","response":{"id":"resp_demo","object":"response","status":"in_progress","model":"stealth/ox-alpha","output":[]}}
4
5
data: {"type":"response.output_item.added","response_id":"resp_demo","output_index":0,"item":{"type":"reasoning","id":"rs_demo","status":"in_progress","summary":[]}}
6
7
data: {"type":"response.reasoning_summary_text.delta","response_id":"resp_demo","item_id":"rs_demo","output_index":0,"summary_index":0,"delta":"The user asked to read a repository file."}
8
9
data: {"type":"response.output_item.done","response_id":"resp_demo","output_index":0,"item":{"type":"reasoning","id":"rs_demo","status":"completed","summary":[{"type":"summary_text","text":"The user asked to read a repository file."}],"encrypted_content":"encrypted-demo-reasoning"}}
10
11
data: {"type":"response.output_item.added","response_id":"resp_demo","output_index":1,"item":{"type":"function_call","id":"fc_demo","call_id":"call_demo","name":"read_repository_file","arguments":"","status":"in_progress"}}
12
13
data: {"type":"response.function_call_arguments.delta","response_id":"resp_demo","item_id":"fc_demo","output_index":1,"delta":"{\"repository\":\"OpenAgentsInc/openagents.com\",\"path\":\"README.md\",\"ref\":null}"}
14
15
data: {"type":"response.function_call_arguments.done","response_id":"resp_demo","item_id":"fc_demo","output_index":1,"arguments":"{\"repository\":\"OpenAgentsInc/openagents.com\",\"path\":\"README.md\",\"ref\":null}"}
16
17
data: {"type":"response.output_item.done","response_id":"resp_demo","output_index":1,"item":{"type":"function_call","id":"fc_demo","call_id":"call_demo","name":"read_repository_file","arguments":"{\"repository\":\"OpenAgentsInc/openagents.com\",\"path\":\"README.md\",\"ref\":null}","status":"completed"}}
18
19
data: {"type":"response.completed","response":{"id":"resp_demo","object":"response","status":"completed","model":"stealth/ox-alpha","output":[{"type":"reasoning","id":"rs_demo","status":"completed","summary":[{"type":"summary_text","text":"The user asked to read a repository file."}],"encrypted_content":"encrypted-demo-reasoning"},{"type":"function_call","id":"fc_demo","call_id":"call_demo","name":"read_repository_file","arguments":"{\"repository\":\"OpenAgentsInc/openagents.com\",\"path\":\"README.md\",\"ref\":null}","status":"completed"}],"usage":{"input_tokens":18,"output_tokens":11,"total_tokens":29}}}
20
21
data: [DONE]
test/openagents/chat/open_router/responses_stream_decoder_test.exs added +106

@@ -0,0 +1,106 @@

1
defmodule OpenAgents.Chat.OpenRouter.ResponsesStreamDecoderTest do
2
  use ExUnit.Case, async: true
3
4
  alias OpenAgents.Chat.OpenRouter.ResponsesStreamDecoder
5
6
  test "assembles completed tool output from item events and argument deltas" do
7
    reasoning = %{
8
      "type" => "reasoning",
9
      "id" => "rs_test",
10
      "status" => "completed",
11
      "summary" => [%{"type" => "summary_text", "text" => "Use the demo tool."}],
12
      "encrypted_content" => "encrypted-reasoning"
13
    }
14
15
    function_call = %{
16
      "type" => "function_call",
17
      "id" => "fc_test",
18
      "call_id" => "call_test",
19
      "name" => "get_demo_time",
20
      "status" => "completed"
21
    }
22
23
    stream =
24
      frame(%{
25
        "type" => "response.output_item.done",
26
        "output_index" => 0,
27
        "item" => reasoning
28
      }) <>
29
        frame(%{
30
          "type" => "response.output_item.added",
31
          "output_index" => 1,
32
          "item" => Map.put(function_call, "arguments", "")
33
        }) <>
34
        frame(%{
35
          "type" => "response.function_call_arguments.delta",
36
          "item_id" => "fc_test",
37
          "delta" => "{"
38
        }) <>
39
        frame(%{
40
          "type" => "response.function_call_arguments.delta",
41
          "item_id" => "fc_test",
42
          "delta" => "}"
43
        }) <>
44
        frame(%{
45
          "type" => "response.output_item.done",
46
          "output_index" => 1,
47
          "item" => function_call
48
        }) <>
49
        frame(%{
50
          "type" => "response.completed",
51
          "response" => %{
52
            "id" => "resp_test",
53
            "object" => "response",
54
            "status" => "completed",
55
            "model" => "stealth/ox-alpha"
56
          }
57
        })
58
59
    assert {:ok, state, []} = ResponsesStreamDecoder.feed(ResponsesStreamDecoder.new(), stream)
60
61
    expected_call = Map.put(function_call, "arguments", "{}")
62
63
    assert {:ok,
64
            %{
65
              "output" => [^reasoning, ^expected_call],
66
              "reasoning_items" => [^reasoning],
67
              "tool_calls" => [
68
                %{
69
                  "id" => "fc_test",
70
                  "call_id" => "call_test",
71
                  "name" => "get_demo_time",
72
                  "arguments" => "{}"
73
                }
74
              ]
75
            }} = ResponsesStreamDecoder.finish(state)
76
  end
77
78
  test "returns structured failure and incomplete response details" do
79
    failed =
80
      frame(%{
81
        "type" => "response.failed",
82
        "error_type" => "authentication",
83
        "response" => %{
84
          "id" => "resp_failed",
85
          "error" => %{"code" => "server_error", "message" => "Invalid credentials"}
86
        }
87
      })
88
89
    assert {:error, {:provider_error, "authentication", "Invalid credentials"}} =
90
             ResponsesStreamDecoder.feed(ResponsesStreamDecoder.new(), failed)
91
92
    incomplete =
93
      frame(%{
94
        "type" => "response.incomplete",
95
        "response" => %{
96
          "id" => "resp_incomplete",
97
          "incomplete_details" => %{"reason" => "max_output_tokens"}
98
        }
99
      })
100
101
    assert {:error, {:provider_error, "max_output_tokens", nil}} =
102
             ResponsesStreamDecoder.feed(ResponsesStreamDecoder.new(), incomplete)
103
  end
104
105
  defp frame(value), do: "data: " <> Jason.encode!(value) <> "\n\n"
106
end
test/openagents/chat/open_router_test.exs modified +87 -35

@@ -3,6 +3,28 @@ defmodule OpenAgents.Chat.OpenRouterTest do

3 3
4 4
  alias OpenAgents.Chat.OpenRouter
5 5
6
  defmodule RepositoryFileStub do
7
    @moduledoc false
8
9
    def definitions, do: OpenAgents.Chat.Tools.RepositoryFile.definitions()
10
11
    def execute("read_repository_file", arguments, %{user_id: "user-test"}) do
12
      assert_arguments = Jason.decode!(arguments)
13
      "OpenAgentsInc/openagents.com" = assert_arguments["repository"]
14
      "README.md" = assert_arguments["path"]
15
16
      {:ok,
17
       %{
18
         "repository" => "OpenAgentsInc/openagents.com",
19
         "ref" => "main",
20
         "path" => "README.md",
21
         "content" => "# OpenAgents\n",
22
         "size_bytes" => 13,
23
         "truncated" => false
24
       }}
25
    end
26
  end
27
6 28
  setup {Req.Test, :verify_on_exit!}
7 29
8 30
  test "sends an OpenRouter-compatible Ox Alpha request with a free fallback" do

@@ -475,36 +497,26 @@ defmodule OpenAgents.Chat.OpenRouterTest do

475 497
    assert_receive {:openrouter_event, {:text_delta, " stream"}}
476 498
  end
477 499
478
  test "executes the demo tool and continues the Responses conversation" do
500
  test "reads a repository file and continues the Responses conversation" do
479 501
    Req.Test.expect(__MODULE__, fn conn ->
480 502
      assert conn.request_path == "/api/v1/responses"
481 503
482
      assert [
483
               %{
484
                 "type" => "function",
485
                 "name" => "get_demo_time",
486
                 "strict" => true,
487
                 "parameters" => %{"type" => "object", "additionalProperties" => false}
488
               }
489
             ] = conn.body_params["tools"]
504
      assert Enum.map(conn.body_params["tools"], & &1["name"]) == [
505
               "read_repository_file",
506
               "list_repository_directory"
507
             ]
508
509
      assert conn.body_params["instructions"] =~
510
               "Never claim that a file or directory exists unless a tool result confirms it"
511
512
      assert Enum.all?(conn.body_params["tools"], fn tool ->
513
               tool["type"] == "function" and tool["strict"] == true and
514
                 tool["parameters"]["additionalProperties"] == false
515
             end)
490 516
491 517
      body =
492
        sse(%{
493
          "type" => "response.done",
494
          "response" => %{
495
            "object" => "response",
496
            "model" => "stealth/ox-alpha",
497
            "output" => [
498
              %{
499
                "type" => "function_call",
500
                "id" => "fc_demo",
501
                "call_id" => "call_demo",
502
                "name" => "get_demo_time",
503
                "arguments" => "{}"
504
              }
505
            ]
506
          }
507
        }) <> "data: [DONE]\n\n"
518
        Path.expand("../../fixtures/openrouter/responses_tool_call.sse", __DIR__)
519
        |> File.read!()
508 520
509 521
      conn
510 522
      |> Plug.Conn.put_resp_content_type("text/event-stream")

@@ -516,12 +528,26 @@ defmodule OpenAgents.Chat.OpenRouterTest do

516 528
517 529
      assert [
518 530
               %{"type" => "message", "role" => "user"},
531
               %{
532
                 "type" => "reasoning",
533
                 "id" => "rs_demo",
534
                 "status" => "completed",
535
                 "summary" => [
536
                   %{
537
                     "type" => "summary_text",
538
                     "text" => "The user asked to read a repository file."
539
                   }
540
                 ],
541
                 "encrypted_content" => "encrypted-demo-reasoning"
542
               },
519 543
               %{
520 544
                 "type" => "function_call",
521 545
                 "id" => "fc_demo",
522 546
                 "call_id" => "call_demo",
523
                 "name" => "get_demo_time",
524
                 "arguments" => "{}"
547
                 "name" => "read_repository_file",
548
                 "arguments" =>
549
                   "{\"repository\":\"OpenAgentsInc/openagents.com\",\"path\":\"README.md\",\"ref\":null}",
550
                 "status" => "completed"
525 551
               },
526 552
               %{
527 553
                 "type" => "function_call_output",

@@ -530,13 +556,19 @@ defmodule OpenAgents.Chat.OpenRouterTest do

530 556
               }
531 557
             ] = conn.body_params["input"]
532 558
533
      assert %{"timezone" => "UTC", "source" => "OpenAgents demo tool", "time" => _time} =
534
               Jason.decode!(tool_output)
559
      assert %{
560
               "repository" => "OpenAgentsInc/openagents.com",
561
               "path" => "README.md",
562
               "content" => "# OpenAgents\n"
563
             } = Jason.decode!(tool_output)
535 564
536 565
      body =
537
        sse(%{"type" => "response.content_part.delta", "delta" => "The demo tool ran."}) <>
566
        sse(%{
567
          "type" => "response.content_part.delta",
568
          "delta" => "OpenAgents is an agent platform."
569
        }) <>
538 570
          sse(%{
539
            "type" => "response.done",
571
            "type" => "response.completed",
540 572
            "response" => %{
541 573
              "object" => "response",
542 574
              "model" => "stealth/ox-alpha",

@@ -549,7 +581,7 @@ defmodule OpenAgents.Chat.OpenRouterTest do

549 581
                  "content" => [
550 582
                    %{
551 583
                      "type" => "output_text",
552
                      "text" => "The demo tool ran.",
584
                      "text" => "OpenAgents is an agent platform.",
553 585
                      "annotations" => []
554 586
                    }
555 587
                  ]

@@ -565,18 +597,38 @@ defmodule OpenAgents.Chat.OpenRouterTest do

565 597
566 598
    parent = self()
567 599
568
    assert {:ok, %{"assistant_content" => "The demo tool ran."}} =
600
    assert {:ok, %{"assistant_content" => "OpenAgents is an agent platform."}} =
569 601
             OpenRouter.stream(
570 602
               %{
571 603
                 "model" => "stealth/ox-alpha",
572
                 "messages" => [%{"role" => "user", "content" => "What time is it?"}]
604
                 "messages" => [%{"role" => "user", "content" => "Summarize the README."}]
573 605
               },
574 606
               &send(parent, {:openrouter_event, &1}),
575 607
               api_key: "test-openrouter-key",
608
               tool_module: RepositoryFileStub,
609
               tool_context: %{user_id: "user-test"},
576 610
               request_options: [plug: {Req.Test, __MODULE__}]
577 611
             )
578 612
579
    assert_receive {:openrouter_event, {:text_delta, "The demo tool ran."}}
613
    assert_receive {:openrouter_event, {:text_delta, "OpenAgents is an agent platform."}}
614
615
    assert_receive {:openrouter_event,
616
                    {:tool_call_started,
617
                     %{
618
                       "call_id" => "call_demo",
619
                       "name" => "read_repository_file",
620
                       "arguments" =>
621
                         "{\"repository\":\"OpenAgentsInc/openagents.com\",\"path\":\"README.md\",\"ref\":null}"
622
                     }}}
623
624
    assert_receive {:openrouter_event,
625
                    {:tool_call_completed, %{"call_id" => "call_demo", "output" => tool_output}}}
626
627
    assert %{
628
             "repository" => "OpenAgentsInc/openagents.com",
629
             "path" => "README.md",
630
             "content" => "# OpenAgents\n"
631
           } = Jason.decode!(tool_output)
580 632
  end
581 633
582 634
  defp sse(event), do: "data: " <> Jason.encode!(event) <> "\n\n"
test/openagents/chat/tools/repository_file_test.exs added +313

@@ -0,0 +1,313 @@

1
defmodule OpenAgents.Chat.Tools.RepositoryFileTest do
2
  use OpenAgents.DataCase, async: false
3
4
  import OpenAgents.AccountsFixtures
5
6
  alias OpenAgents.Chat.Tools.RepositoryFile
7
  alias OpenAgents.Forge.{Repos, WAL}
8
  alias OpenAgents.Repositories
9
10
  setup do
11
    base =
12
      Path.join(System.tmp_dir!(), "repository-file-tool-#{System.unique_integer([:positive])}")
13
14
    previous_data = Application.get_env(:openagents, :forge_data_dir)
15
    previous_wal = Application.get_env(:openagents, :forge_wal_dir)
16
    Application.put_env(:openagents, :forge_data_dir, Path.join(base, "data"))
17
    Application.put_env(:openagents, :forge_wal_dir, Path.join(base, "wal"))
18
19
    on_exit(fn ->
20
      restore_env(:forge_data_dir, previous_data)
21
      restore_env(:forge_wal_dir, previous_wal)
22
      File.rm_rf(base)
23
    end)
24
25
    user = repository_user_fixture("repository-reader")
26
    suffix = System.unique_integer([:positive, :monotonic])
27
    owner = "ToolOrg#{suffix}"
28
    name = "tool-repo-#{suffix}"
29
30
    {:ok, repository} =
31
      Repositories.create_repository(%{
32
        owner: owner,
33
        name: name,
34
        visibility: "public",
35
        default_branch: "main"
36
      })
37
38
    seed_repository(repository.storage_key)
39
    %{repository: repository, repository_path: "#{owner}/#{name}", user: user}
40
  end
41
42
  test "reads a named file from an accessible repository", %{
43
    user: user,
44
    repository_path: repository_path
45
  } do
46
    assert {:ok,
47
            %{
48
              "repository" => ^repository_path,
49
              "ref" => "main",
50
              "path" => "README.md",
51
              "content" => "# OpenAgents\n\nConnected repository fixture.\n",
52
              "truncated" => false
53
            }} =
54
             RepositoryFile.execute(
55
               "read_repository_file",
56
               Jason.encode!(%{
57
                 "repository" => repository_path,
58
                 "path" => "README.md",
59
                 "ref" => nil
60
               }),
61
               %{user: user}
62
             )
63
  end
64
65
  test "resolves an unambiguous repository name and its README", %{
66
    repository: repository,
67
    user: user
68
  } do
69
    assert {:ok, %{"path" => "README.md", "content" => "# OpenAgents\n" <> _rest}} =
70
             RepositoryFile.execute(
71
               "read_repository_file",
72
               Jason.encode!(%{"repository" => repository.name, "path" => nil, "ref" => nil}),
73
               %{user: user}
74
             )
75
  end
76
77
  test "normalizes string null values emitted by a model", %{
78
    repository_path: repository_path,
79
    user: user
80
  } do
81
    assert {:ok,
82
            %{
83
              "path" => "README.md",
84
              "ref" => "main",
85
              "content" => "# OpenAgents\n" <> _rest
86
            }} =
87
             RepositoryFile.execute(
88
               "read_repository_file",
89
               Jason.encode!(%{
90
                 "repository" => repository_path,
91
                 "path" => "null",
92
                 "ref" => "null"
93
               }),
94
               %{user: user}
95
             )
96
  end
97
98
  test "does not disclose a private repository without membership", %{user: user} do
99
    {:ok, private_repository} =
100
      Repositories.create_repository(%{
101
        owner: "PrivateOrg",
102
        name: "private-repo",
103
        visibility: "private",
104
        default_branch: "main"
105
      })
106
107
    seed_repository(private_repository.storage_key)
108
109
    assert {:error, "The repository does not exist or you cannot access it."} =
110
             RepositoryFile.execute(
111
               "read_repository_file",
112
               Jason.encode!(%{
113
                 "repository" => "PrivateOrg/private-repo",
114
                 "path" => "README.md",
115
                 "ref" => nil
116
               }),
117
               %{user: user}
118
             )
119
  end
120
121
  test "reads a private repository for a member", %{user: user} do
122
    {:ok, private_repository} =
123
      Repositories.create_repository(%{
124
        owner: "MemberOrg",
125
        name: "member-repo",
126
        visibility: "private",
127
        default_branch: "main"
128
      })
129
130
    :ok = seed_repository(private_repository.storage_key)
131
    assert {:ok, _membership} = Repositories.add_member(private_repository, user, "viewer")
132
133
    assert {:ok, %{"content" => "# OpenAgents\n" <> _rest}} =
134
             RepositoryFile.execute(
135
               "read_repository_file",
136
               Jason.encode!(%{
137
                 "repository" => "MemberOrg/member-repo",
138
                 "path" => "README.md",
139
                 "ref" => nil
140
               }),
141
               %{user: user}
142
             )
143
  end
144
145
  test "rejects repository path traversal", %{user: user, repository_path: repository_path} do
146
    assert {:error,
147
            "The requested file or ref does not exist. List the parent directory before trying another path."} =
148
             RepositoryFile.execute(
149
               "read_repository_file",
150
               Jason.encode!(%{
151
                 "repository" => repository_path,
152
                 "path" => "../secrets",
153
                 "ref" => nil
154
               }),
155
               %{user: user}
156
             )
157
  end
158
159
  test "lists repository directories with exact paths", %{
160
    user: user,
161
    repository_path: repository_path
162
  } do
163
    assert {:ok, %{"path" => "", "entries" => root_entries}} =
164
             RepositoryFile.execute(
165
               "list_repository_directory",
166
               Jason.encode!(%{"repository" => repository_path, "path" => nil, "ref" => nil}),
167
               %{user: user}
168
             )
169
170
    assert Enum.any?(root_entries, &match?(%{"path" => "README.md", "type" => "file"}, &1))
171
    assert Enum.any?(root_entries, &match?(%{"path" => "docs", "type" => "directory"}, &1))
172
173
    assert {:ok,
174
            %{
175
              "path" => "docs/runbooks",
176
              "entries" => [
177
                %{"path" => "docs/runbooks/production.md", "type" => "file"}
178
              ]
179
            }} =
180
             RepositoryFile.execute(
181
               "list_repository_directory",
182
               Jason.encode!(%{
183
                 "repository" => repository_path,
184
                 "path" => "docs/runbooks",
185
                 "ref" => "main"
186
               }),
187
               %{user: user}
188
             )
189
  end
190
191
  test "reports a missing directory without inventing alternatives", %{
192
    user: user,
193
    repository_path: repository_path
194
  } do
195
    assert {:error, "The requested directory or ref does not exist."} =
196
             RepositoryFile.execute(
197
               "list_repository_directory",
198
               Jason.encode!(%{
199
                 "repository" => repository_path,
200
                 "path" => "docs/missing",
201
                 "ref" => nil
202
               }),
203
               %{user: user}
204
             )
205
  end
206
207
  defp seed_repository(storage_key) do
208
    path = Repos.ensure_repo!(storage_key)
209
210
    blob =
211
      git!(
212
        path,
213
        ["hash-object", "-w", "--stdin"],
214
        "# OpenAgents\n\nConnected repository fixture.\n"
215
      )
216
217
    runbook_blob =
218
      git!(
219
        path,
220
        ["hash-object", "-w", "--stdin"],
221
        "# Production runbook\n\nDeploy only verified revisions.\n"
222
      )
223
224
    runbooks_tree = git!(path, ["mktree"], "100644 blob #{runbook_blob}\tproduction.md\n")
225
    docs_tree = git!(path, ["mktree"], "040000 tree #{runbooks_tree}\trunbooks\n")
226
227
    tree =
228
      git!(
229
        path,
230
        ["mktree"],
231
        "100644 blob #{blob}\tREADME.md\n040000 tree #{docs_tree}\tdocs\n"
232
      )
233
234
    commit =
235
      git!(path, ["commit-tree", tree, "-m", "Seed repository"], "",
236
        env: [
237
          {"GIT_AUTHOR_NAME", "Test Author"},
238
          {"GIT_AUTHOR_EMAIL", "author@example.test"},
239
          {"GIT_COMMITTER_NAME", "Test Author"},
240
          {"GIT_COMMITTER_EMAIL", "author@example.test"}
241
        ]
242
      )
243
244
    {_, 0} = Repos.git(path, ["update-ref", "refs/heads/main", commit])
245
    refs = %{"refs/heads/main" => commit}
246
247
    bundle_path =
248
      Path.join(
249
        System.tmp_dir!(),
250
        "repository-tool-bundle-#{System.unique_integer([:positive, :monotonic])}.bundle"
251
      )
252
253
    {_, 0} = Repos.git(path, ["bundle", "create", bundle_path, "--all"])
254
255
    index =
256
      case WAL.read_index(storage_key) do
257
        {:ok, _generation, index} -> index
258
        {:error, :not_found} -> WAL.new_index()
259
      end
260
261
    generation =
262
      case WAL.read_index(storage_key) do
263
        {:ok, generation, _index} -> generation
264
        {:error, :not_found} -> :none
265
      end
266
267
    sequence = WAL.next_seq(index)
268
    {:ok, object} = WAL.put_entry_file(storage_key, sequence, bundle_path)
269
270
    entry = %{
271
      "seq" => sequence,
272
      "object" => object,
273
      "format" => "git_bundle",
274
      "refs" => refs,
275
      "principal" => "repository-file-tool-test",
276
      "pushed_at" => DateTime.to_iso8601(DateTime.utc_now())
277
    }
278
279
    try do
280
      {:ok, _generation} = WAL.cas_index(storage_key, generation, WAL.append_entry(index, entry))
281
      :ok
282
    after
283
      File.rm(bundle_path)
284
    end
285
  end
286
287
  defp git!(git_dir, args, input, options \\ []) do
288
    input_path =
289
      Path.join(System.tmp_dir!(), "repository-tool-input-#{System.unique_integer([:positive])}")
290
291
    File.write!(input_path, input)
292
293
    try do
294
      {output, 0} =
295
        System.cmd(
296
          "sh",
297
          ["-c", ~s(exec git --git-dir "$GIT_DIR" "$@" < "$INPUT_PATH"), "sh"] ++ args,
298
          env:
299
            [
300
              {"GIT_DIR", git_dir},
301
              {"INPUT_PATH", input_path}
302
            ] ++ Keyword.get(options, :env, [])
303
        )
304
305
      String.trim(output)
306
    after
307
      File.rm(input_path)
308
    end
309
  end
310
311
  defp restore_env(key, nil), do: Application.delete_env(:openagents, key)
312
  defp restore_env(key, value), do: Application.put_env(:openagents, key, value)
313
end
test/openagents/markdown_test.exs modified +13

@@ -253,5 +253,18 @@ defmodule OpenAgents.MarkdownTest do

253 253
        refute rendered =~ "](", "raw link syntax leaked at prefix #{length}"
254 254
      end
255 255
    end
256
257
    test "withholds partial closing emphasis markers" do
258
      for {partial, expected} <- [
259
            {"**bold*", "<p><strong>bold</strong></p>"},
260
            {"*italic", "<p><em>italic</em></p>"},
261
            {"***both*", "<p><em><strong>both</strong></em></p>"},
262
            {"***both**", "<p><em><strong>both</strong></em></p>"},
263
            {"~~gone~", "<p><del>gone</del></p>"}
264
          ] do
265
        assert html(partial, streaming: true) == expected,
266
               "rendered a partial closing marker for #{inspect(partial)}"
267
      end
268
    end
256 269
  end
257 270
end
test/openagents/repositories/provisioner_test.exs modified +73

@@ -145,6 +145,26 @@ defmodule OpenAgents.Repositories.ProvisionerTest do

145 145
    assert "repository.provisioning.failed" in audit_types(repository.id)
146 146
  end
147 147
148
  test "an admitted provisioning error remains visible on the repository and outbox" do
149
    user = repository_user_fixture("provisioner-specific-failure")
150
151
    assert {:ok, repository, :created} =
152
             Repositories.create_user_repository(
153
               user,
154
               %{name: "specific-failure"},
155
               "specific-failure-key"
156
             )
157
158
    assert :processed =
159
             Provisioner.run_once(fn _work -> {:error, :temporary_storage_unavailable} end)
160
161
    failed_outbox = OpenAgents.Repo.get_by!(ProvisioningOutbox, repository_id: repository.id)
162
    failed_repository = OpenAgents.Repo.get!(Repository, repository.id)
163
164
    assert failed_outbox.error_code == "temporary_storage_unavailable"
165
    assert failed_repository.provision_error_code == "temporary_storage_unavailable"
166
  end
167
148 168
  test "a one-time import persists a bundle that reconstructs after cache loss", %{test: _test} do
149 169
    root = Application.fetch_env!(:openagents, :forge_data_dir) |> Path.dirname()
150 170
    source = Path.join(root, "github-source")

@@ -316,6 +336,59 @@ defmodule OpenAgents.Repositories.ProvisionerTest do

316 336
    assert "fetch" in arguments
317 337
  end
318 338
339
  test "a public GitHub import falls back to anonymous fetch without a usable token" do
340
    root = Application.fetch_env!(:openagents, :forge_data_dir) |> Path.dirname()
341
    source = Path.join(root, "public-source")
342
    File.mkdir_p!(source)
343
    git!(source, ["init", "--initial-branch=main"])
344
    git!(source, ["config", "user.email", "test@example.com"])
345
    git!(source, ["config", "user.name", "Import test"])
346
    File.write!(Path.join(source, "README.md"), "public repository\n")
347
    git!(source, ["add", "README.md"])
348
    git!(source, ["commit", "-m", "Public fixture"])
349
350
    sha = source |> git!(["rev-parse", "HEAD"]) |> String.trim()
351
    refs = %{"refs/heads/main" => sha}
352
    user = repository_user_fixture("public-import-owner")
353
354
    source_record = %{
355
      source_repository_id: 504,
356
      source_owner_id: user.github_id,
357
      source_full_name: "public-import-owner/source",
358
      source_default_branch: "main",
359
      source_ref_digest: ref_digest(source, refs),
360
      source_head_sha: sha,
361
      source_refs: refs,
362
      source_uses_lfs: false
363
    }
364
365
    assert {:ok, repository, _repository_import, :created} =
366
             Repositories.create_user_import(
367
               user,
368
               source_record,
369
               %{name: "public-import", visibility: "public", default_branch: "main"},
370
               "public-import-key"
371
             )
372
373
    test_process = self()
374
375
    git_runner = fn git_directory, arguments, options ->
376
      environment = Keyword.fetch!(options, :env)
377
      send(test_process, {:public_fetch_environment, environment})
378
379
      local_arguments =
380
        Enum.map(arguments, fn
381
          "https://github.com/public-import-owner/source.git" -> source
382
          argument -> argument
383
        end)
384
385
      Repos.git(git_directory, local_arguments, options)
386
    end
387
388
    assert :ok = Importer.import(repository, git_runner: git_runner)
389
    assert_receive {:public_fetch_environment, [{"GIT_TERMINAL_PROMPT", "0"}]}
390
  end
391
319 392
  test "an import over the configured bundle limit fails without entering the WAL" do
320 393
    root = Application.fetch_env!(:openagents, :forge_data_dir) |> Path.dirname()
321 394
    source = Path.join(root, "oversized-source")
test/openagents_web/live/chat_placeholder_test.exs modified +85

@@ -62,4 +62,89 @@ defmodule OpenAgentsWeb.ChatPlaceholderTest do

62 62
63 63
    assert {:error, {:redirect, %{to: "/"}}} = live(conn, ~p"/chat")
64 64
  end
65
66
  test "reasoning stays interleaved with successive tool attempts" do
67
    socket =
68
      %Phoenix.LiveView.Socket{}
69
      |> Phoenix.Component.assign(:stream_id, 42)
70
      |> Phoenix.Component.assign(:streaming?, true)
71
      |> Phoenix.Component.assign(:assistant_reasoning, nil)
72
      |> Phoenix.Component.assign(:assistant_tool_calls, [])
73
      |> Phoenix.Component.assign(:assistant_blocks, [])
74
75
    {:noreply, socket} =
76
      OpenAgentsWeb.ChatPlaceholderLive.handle_info(
77
        {:openrouter_stream_event, 42, {:reasoning_delta, "First attempt."}},
78
        socket
79
      )
80
81
    {:noreply, socket} =
82
      OpenAgentsWeb.ChatPlaceholderLive.handle_info(
83
        {:openrouter_stream_event, 42,
84
         {:tool_call_started,
85
          %{
86
            "call_id" => "call-1",
87
            "name" => "read_repository_file",
88
            "arguments" => ~s({"path":"null"})
89
          }}},
90
        socket
91
      )
92
93
    {:noreply, socket} =
94
      OpenAgentsWeb.ChatPlaceholderLive.handle_info(
95
        {:openrouter_stream_event, 42,
96
         {:tool_call_failed, %{"call_id" => "call-1", "error" => "Not found"}}},
97
        socket
98
      )
99
100
    {:noreply, socket} =
101
      OpenAgentsWeb.ChatPlaceholderLive.handle_info(
102
        {:openrouter_stream_event, 42, {:reasoning_delta, "Second attempt."}},
103
        socket
104
      )
105
106
    {:noreply, socket} =
107
      OpenAgentsWeb.ChatPlaceholderLive.handle_info(
108
        {:openrouter_stream_event, 42,
109
         {:tool_call_started,
110
          %{
111
            "call_id" => "call-2",
112
            "name" => "read_repository_file",
113
            "arguments" => ~s({"path":"README.md"})
114
          }}},
115
        socket
116
      )
117
118
    {:noreply, socket} =
119
      OpenAgentsWeb.ChatPlaceholderLive.handle_info(
120
        {:openrouter_stream_event, 42,
121
         {:tool_call_failed, %{"call_id" => "call-2", "error" => "Still not found"}}},
122
        socket
123
      )
124
125
    {:noreply, socket} =
126
      OpenAgentsWeb.ChatPlaceholderLive.handle_info(
127
        {:openrouter_stream_event, 42, {:reasoning_delta, "Report the failure."}},
128
        socket
129
      )
130
131
    assert [first_reasoning, first_tool, second_reasoning, second_tool, final_reasoning] =
132
             socket.assigns.assistant_blocks
133
134
    assert first_reasoning.type == :reasoning
135
    assert first_reasoning.text == "First attempt."
136
    assert is_integer(first_reasoning.duration)
137
    assert first_tool.type == :tool
138
    assert first_tool.tool_call.state == "output-error"
139
    assert first_tool.tool_call.error == "Not found"
140
    assert second_reasoning.type == :reasoning
141
    assert second_reasoning.text == "Second attempt."
142
    assert is_integer(second_reasoning.duration)
143
    assert second_tool.type == :tool
144
    assert second_tool.tool_call.state == "output-error"
145
    assert second_tool.tool_call.error == "Still not found"
146
    assert final_reasoning.type == :reasoning
147
    assert final_reasoning.text == "Report the failure."
148
    assert is_nil(final_reasoning.duration)
149
  end
65 150
end

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