Connect chat to the shared tool runtime

17e891e9bf2c · AtlantisPleb · · parent 5cbe98f9fb78

Connect chat to the shared tool runtime

Deploy story

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

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • modified config/config.exs
  • modified config/test.exs
  • modified docs/api-authentication.md
  • modified docs/architecture.md
  • modified lib/openagents/api_tokens.ex
  • added lib/openagents/chat/account_event.ex
  • added lib/openagents/chat/account_run.ex
  • added lib/openagents/chat/account_turns.ex
  • modified lib/openagents/chat/open_router.ex
  • added lib/openagents/chat/open_router/tool_runtime.ex
  • added lib/openagents/tools/admitted_catalog.ex
  • added lib/openagents/tools/connected_repository.ex
  • added lib/openagents/tools/connected_repository_list.ex
  • added lib/openagents/tools/connected_repository_read.ex
  • modified lib/openagents/tools/conversation_execution_context.ex
  • modified lib/openagents/tools/execution_context.ex
  • added lib/openagents/tools/redaction.ex
  • modified lib/openagents/tools/runner.ex
  • modified lib/openagents/voice/context_capture.ex
  • modified lib/openagents_web/api_route_authority.ex
  • added lib/openagents_web/controllers/chat_turn_controller.ex
  • modified lib/openagents_web/live/api_tokens_live.ex
  • modified lib/openagents_web/live/chat_placeholder_live.ex
  • modified lib/openagents_web/route_authority.ex
  • modified lib/openagents_web/router.ex
  • modified priv/migration_lineages/prior-2026-08-19.json
  • added priv/repo/migrations/20260822234211_create_account_chat_runs_and_events.exs
  • added priv/repo/migrations/20260823000143_allow_chat_account_api_token_scope.exs
  • modified test/fixtures/openrouter/responses_tool_call.sse
  • added test/openagents/chat/account_turns_test.exs
  • modified test/openagents/chat/open_router_test.exs
  • added test/openagents/tools/admitted_catalog_test.exs
  • added test/openagents/tools/connected_repository_tools_test.exs
  • modified test/openagents/tools/conversation_execution_context_test.exs
  • added test/openagents/tools/redaction_test.exs
  • added test/openagents_web/controllers/chat_turn_controller_test.exs
  • modified test/openagents_web/route_authority_test.exs
  • modified test/support/conn_case.ex

Diff

38 files changed, +2438 -172

config/config.exs modified +2

@@ -146,6 +146,8 @@ config :openagents,

146 146
    OpenAgents.Tools.ModuleDiscover,
147 147
    OpenAgents.Tools.GitHubRepoList,
148 148
    OpenAgents.Tools.GitHubRepoRead,
149
    OpenAgents.Tools.ConnectedRepositoryRead,
150
    OpenAgents.Tools.ConnectedRepositoryList,
149 151
    OpenAgents.Tools.ConversationSearch,
150 152
    OpenAgents.Tools.ConversationRead,
151 153
    OpenAgents.Tools.MemoryList,
config/test.exs modified +2

@@ -110,6 +110,8 @@ config :openagents, :tools, [

110 110
  OpenAgents.Tools.ModuleDiscover,
111 111
  OpenAgents.Tools.GitHubRepoList,
112 112
  OpenAgents.Tools.GitHubRepoRead,
113
  OpenAgents.Tools.ConnectedRepositoryRead,
114
  OpenAgents.Tools.ConnectedRepositoryList,
113 115
  OpenAgents.Tools.ConversationSearch,
114 116
  OpenAgents.Tools.ConversationRead,
115 117
  OpenAgents.Tools.MemoryList,
docs/api-authentication.md modified +54

@@ -30,6 +30,60 @@ Account export includes the same metadata with `credential_exported: false`.

30 30
Product-data deletion retains API credentials until the person revokes them;
31 31
credential management is independent from conversation deletion.
32 32
33
### Account chat events
34
35
Use `POST /api/v3/chat/turns` to submit an account chat message and
36
`GET /api/v3/chat/events` to list its durable event journal. These routes use
37
the same account-scoped application service and ordered projection as `/chat`.
38
Both routes require a personal API token with the `chat:account` scope. A token
39
with only `forge:write` cannot read or submit account chat data.
40
41
Submit a message with this request:
42
43
```sh
44
curl \
45
  --header "Authorization: Bearer $OPENAGENTS_API_TOKEN" \
46
  --header "Content-Type: application/json" \
47
  --data '{"message":"Summarize my repository README.","reasoning":"high"}' \
48
  https://staging.openagents.com/api/v3/chat/turns
49
```
50
51
The server returns `202 Accepted` after it durably creates the run and its
52
first `user_message` event. Provider work continues asynchronously. The
53
response identifies the run and includes its initial `streaming` status,
54
reasoning effort, and start time.
55
56
List the account's event journal with this request:
57
58
```sh
59
curl \
60
  --header "Authorization: Bearer $OPENAGENTS_API_TOKEN" \
61
  https://staging.openagents.com/api/v3/chat/events
62
```
63
64
Each event contains an event ID, run ID, run-local sequence number, type,
65
payload, and observation time. The API orders runs by creation time and events
66
within each run by sequence number. It returns only the authenticated account's
67
conversation. A caller cannot select another account or gain access by
68
supplying a conversation or run ID.
69
70
The journal records user messages, reasoning deltas, tool calls and outcomes,
71
text deltas, and terminal provider-response events. The browser and API consume
72
this same journal, so they observe the same ordered lifecycle instead of
73
maintaining separate chat histories.
74
75
The terminal `response_completed` event serves as the provider-response
76
receipt. It retains the Responses output list, including reasoning items,
77
function calls, function-call identifiers, and encrypted reasoning state. A
78
later turn replays that output list before its new input and tool results. It
79
does not reconstruct the prior response from normalized assistant text.
80
81
OpenAgents recursively redacts credential-shaped fields before it persists an
82
event payload or terminal provider response. The same boundary applies before
83
a tool outcome reaches the provider or client. This redaction does not make
84
chat content public or nonsensitive: store the bearer token securely and treat
85
the event journal as account data.
86
33 87
## Browser JSON routes
34 88
35 89
`/api/tokens`, `/api/computers`, and `/api/computer-agent-jobs` support the
docs/architecture.md modified +51

@@ -120,6 +120,57 @@ Tests replace network providers with explicit fakes. A provider outage must

120 120
produce a bounded durable failure outcome instead of abandoning an in-flight
121 121
turn, work item, or voice session.
122 122
123
## Durable account chat
124
125
`OpenAgents.Chat.AccountTurns` is the application entry point for account chat
126
submissions from both `/chat` and `POST /api/v3/chat/turns`. It creates the run
127
and first event in one transaction before it starts provider work. The browser
128
and `GET /api/v3/chat/events` then project the same account-scoped journal.
129
LiveView messages and PubSub notifications remain replaceable projections of
130
that durable state.
131
132
Each run has a monotonically increasing event sequence. The journal preserves
133
the order of user input, reasoning deltas, tool-call starts and outcomes, text
134
deltas, and the terminal provider response or failure. This ordering lets an
135
API client observe the same reasoning, tool, and response lifecycle that the
136
browser renders. Conversation ownership comes from the authenticated account;
137
client-supplied resource identifiers do not establish authority.
138
139
The terminal `response_completed` event and run record retain the provider's
140
Responses output list. When the next turn constructs provider history, it
141
replays that list in its original order instead of reducing it to assistant
142
text or reconstructed function calls. This preserves provider item IDs,
143
reasoning items, encrypted reasoning state, and the function-call continuity
144
required by the stateless Responses API.
145
146
OpenAgents applies `OpenAgents.Tools.Redaction` before it stores provider
147
completions or event payloads and before a tool outcome reaches a provider or
148
client. The replay boundary therefore preserves the redacted provider output,
149
not an unfiltered provider payload. Never log the journal or use telemetry as a
150
second copy of message, reasoning, or tool content.
151
152
## Shared text and voice tool admission
153
154
Text and voice are transports over one conversation authority boundary.
155
`OpenAgents.Tools.ConversationExecutionContext` derives the owner, conversation
156
scope, authorities, approval receipts, workspace, and registry snapshot for
157
both surfaces. `OpenAgents.Tools.AdmittedCatalog` applies the shared selector,
158
scope check, authority check, and module surface policy before it produces a
159
provider catalog.
160
161
Text captures the registry and execution context for each Responses turn.
162
Voice captures the same admitted catalog when the voice session starts and
163
keeps that snapshot for the session. A surface can change presentation and the
164
catalog format, but it cannot select a separate tool implementation or bypass
165
the shared runner and policy checks. Add a conversation tool to the registry
166
and its declared surface policy rather than wiring independent text and voice
167
backends.
168
169
Bearer clients use the same account chat entry point through a personal API
170
token with `chat:account` scope. Forge mutations continue to require
171
`forge:write`; one scope does not imply the other. The authenticated account,
172
not the token name or request body, determines conversation ownership.
173
123 174
## Untrusted Markdown boundary
124 175
125 176
Assistant and repository Markdown enters HTML through
lib/openagents/api_tokens.ex modified +1 -1

@@ -9,7 +9,7 @@ defmodule OpenAgents.ApiTokens do

9 9
  alias OpenAgents.Repo
10 10
11 11
  @prefix "oa_pat_"
12
  @allowed_scopes ["forge:write"]
12
  @allowed_scopes ["chat:account", "forge:write"]
13 13
  @maximum_lifetime_days 90
14 14
15 15
  @spec create(User.t(), map()) ::
lib/openagents/chat/account_event.ex added +25

@@ -0,0 +1,25 @@

1
defmodule OpenAgents.Chat.AccountEvent do
2
  @moduledoc false
3
  use Ecto.Schema
4
  import Ecto.Changeset
5
  @primary_key {:id, :binary_id, autogenerate: true}
6
  @foreign_key_type :binary_id
7
  @timestamps_opts [type: :utc_datetime_usec, updated_at: false]
8
  schema "account_chat_events" do
9
    belongs_to :run, OpenAgents.Chat.AccountRun
10
    field :sequence, :integer
11
    field :kind, :string
12
    field :payload, :map, default: %{}
13
    field :observed_at, :utc_datetime_usec
14
    timestamps(updated_at: false)
15
  end
16
17
  def changeset(event, attrs) do
18
    event
19
    |> cast(attrs, [:sequence, :kind, :payload, :observed_at])
20
    |> validate_required([:run_id, :sequence, :kind, :payload, :observed_at])
21
    |> validate_number(:sequence, greater_than: 0)
22
    |> foreign_key_constraint(:run_id)
23
    |> unique_constraint([:run_id, :sequence])
24
  end
25
end
lib/openagents/chat/account_run.ex added +47

@@ -0,0 +1,47 @@

1
defmodule OpenAgents.Chat.AccountRun do
2
  @moduledoc false
3
  use Ecto.Schema
4
  import Ecto.Changeset
5
  @primary_key {:id, :binary_id, autogenerate: true}
6
  @foreign_key_type :binary_id
7
  @timestamps_opts [type: :utc_datetime_usec]
8
  schema "account_chat_runs" do
9
    belongs_to :conversation, OpenAgents.Conversations.Conversation
10
    has_many :events, OpenAgents.Chat.AccountEvent, foreign_key: :run_id
11
    field :status, :string
12
    field :reasoning_effort, :string
13
    field :user_content, :string
14
    field :assistant_content, :string
15
    field :completion, :map
16
    field :error, :string
17
    field :started_at, :utc_datetime_usec
18
    field :completed_at, :utc_datetime_usec
19
    timestamps()
20
  end
21
22
  def changeset(run, attrs) do
23
    run
24
    |> cast(attrs, [
25
      :status,
26
      :reasoning_effort,
27
      :user_content,
28
      :assistant_content,
29
      :completion,
30
      :error,
31
      :started_at,
32
      :completed_at
33
    ])
34
    |> validate_required([
35
      :conversation_id,
36
      :status,
37
      :reasoning_effort,
38
      :user_content,
39
      :started_at
40
    ])
41
    |> validate_inclusion(:status, ["streaming", "completed", "failed"])
42
    |> foreign_key_constraint(:conversation_id)
43
    |> unique_constraint(:conversation_id,
44
      name: :account_chat_runs_one_streaming_per_conversation
45
    )
46
  end
47
end
lib/openagents/chat/account_turns.ex added +422

@@ -0,0 +1,422 @@

1
defmodule OpenAgents.Chat.AccountTurns do
2
  @moduledoc "Runs account-scoped `/chat` requests and journals their ordered events."
3
4
  import Ecto.Query
5
  alias OpenAgents.Accounts.User
6
  alias OpenAgents.Chat.{AccountEvent, AccountRun, OpenRouter}
7
  alias OpenAgents.Conversations
8
  alias OpenAgents.Repo
9
10
  @max_message_bytes 8_000
11
12
  def submit(user, content, options \\ [])
13
14
  def submit(%User{} = user, content, options) when is_binary(content) do
15
    content = String.trim(content)
16
    reasoning = OpenRouter.reasoning_effort(Keyword.get(options, :reasoning, "high"))
17
    subscriber = Keyword.get(options, :subscriber)
18
    streamer = Keyword.get(options, :streamer, &OpenRouter.stream/3)
19
20
    with :ok <- validate_content(content),
21
         {:ok, conversation} <- Conversations.ensure_conversation(user),
22
         {:ok, run} <- create_run(conversation.id, content, reasoning),
23
         {:ok, _pid} <- start_provider(run, user, subscriber, streamer) do
24
      {:ok, run_projection(run)}
25
    else
26
      {:provider_start_failed, run, reason} ->
27
        finish_run(run.id, {:error, :turn_start_failed})
28
        {:error, reason}
29
30
      error ->
31
        error
32
    end
33
  end
34
35
  def submit(%User{}, _content, _options), do: {:error, :invalid_message}
36
37
  def list_events(%User{} = user) do
38
    case Conversations.get_conversation_for_user(user) do
39
      nil -> []
40
      conversation -> events_for_conversation(conversation.id)
41
    end
42
  end
43
44
  def list_messages(%User{} = user) do
45
    case Conversations.get_conversation_for_user(user) do
46
      nil -> []
47
      conversation -> messages_for_conversation(conversation.id)
48
    end
49
  end
50
51
  def active?(%User{} = user) do
52
    case Conversations.get_conversation_for_user(user) do
53
      nil ->
54
        false
55
56
      conversation ->
57
        Repo.exists?(
58
          from r in AccountRun,
59
            where: r.conversation_id == ^conversation.id and r.status == "streaming"
60
        )
61
    end
62
  end
63
64
  defp validate_content(""), do: {:error, :empty_message}
65
66
  defp validate_content(content) when byte_size(content) > @max_message_bytes,
67
    do: {:error, :message_too_long}
68
69
  defp validate_content(_content), do: :ok
70
71
  defp create_run(conversation_id, content, reasoning) do
72
    now = DateTime.utc_now()
73
74
    result =
75
      Repo.transaction(fn ->
76
        run =
77
          %AccountRun{conversation_id: conversation_id}
78
          |> AccountRun.changeset(%{
79
            status: "streaming",
80
            reasoning_effort: reasoning,
81
            user_content: content,
82
            started_at: now
83
          })
84
          |> Repo.insert!()
85
86
        insert_event!(run.id, 1, "user_message", %{"content" => content}, now)
87
        run
88
      end)
89
90
    case result do
91
      {:ok, run} ->
92
        {:ok, run}
93
94
      {:error, %Ecto.Changeset{} = changeset} ->
95
        if Keyword.has_key?(changeset.errors, :conversation_id),
96
          do: {:error, :turn_in_progress},
97
          else: {:error, changeset}
98
99
      {:error, reason} ->
100
        {:error, reason}
101
    end
102
  rescue
103
    Ecto.ConstraintError ->
104
      {:error, :turn_in_progress}
105
106
    Ecto.InvalidChangesetError ->
107
      {:error, :turn_in_progress}
108
  end
109
110
  defp start_provider(run, user, subscriber, streamer) do
111
    request = %{
112
      "model" => OpenRouter.default_model(),
113
      "models" => ["openrouter/free"],
114
      "reasoning" => run.reasoning_effort,
115
      "messages" =>
116
        provider_history(run.conversation_id, run.id) ++
117
          [%{"role" => "user", "content" => run.user_content}]
118
    }
119
120
    case Task.Supervisor.start_child(OpenAgents.ProviderTaskSupervisor, fn ->
121
           result =
122
             try do
123
               streamer.(
124
                 request,
125
                 fn event ->
126
                   persist_provider_event(run.id, event)
127
                   notify(subscriber, {:openrouter_stream_event, run.id, event})
128
                 end,
129
                 tool_context: %{
130
                   surface: "text",
131
                   conversation_id: run.conversation_id,
132
                   owner_visitor_id: user.id,
133
                   owner_user_id: user.id
134
                 }
135
               )
136
             rescue
137
               _error -> {:error, :provider_unavailable}
138
             catch
139
               _kind, _reason -> {:error, :provider_unavailable}
140
             end
141
142
           finish_run(run.id, result)
143
           notify(subscriber, {:account_chat_completed, run.id, result})
144
         end) do
145
      {:ok, pid} -> {:ok, pid}
146
      {:error, reason} -> {:provider_start_failed, run, reason}
147
    end
148
  end
149
150
  defp persist_provider_event(run_id, {kind, payload}),
151
    do: append_event(run_id, Atom.to_string(kind), normalize_payload(payload))
152
153
  defp finish_run(run_id, {:ok, completion}) do
154
    completion = OpenAgents.Tools.Redaction.redact(completion)
155
156
    terminal_update(run_id, "response_completed", completion, %{
157
      status: "completed",
158
      assistant_content: completion["assistant_content"] || "",
159
      completion: completion
160
    })
161
  end
162
163
  defp finish_run(run_id, {:error, reason}) do
164
    error = public_error(reason)
165
166
    terminal_update(run_id, "response_failed", %{"reason" => error}, %{
167
      status: "failed",
168
      error: error
169
    })
170
  end
171
172
  defp terminal_update(run_id, kind, payload, attrs) do
173
    now = DateTime.utc_now()
174
175
    Repo.transaction(fn ->
176
      run = Repo.one!(from r in AccountRun, where: r.id == ^run_id, lock: "FOR UPDATE")
177
      append_event_locked!(run, kind, payload, now)
178
      run |> AccountRun.changeset(Map.put(attrs, :completed_at, now)) |> Repo.update!()
179
    end)
180
  end
181
182
  defp append_event(run_id, kind, payload) do
183
    Repo.transaction(fn ->
184
      run = Repo.one!(from r in AccountRun, where: r.id == ^run_id, lock: "FOR UPDATE")
185
      append_event_locked!(run, kind, payload, DateTime.utc_now())
186
    end)
187
  end
188
189
  defp append_event_locked!(run, kind, payload, observed_at) do
190
    sequence =
191
      Repo.one(from e in AccountEvent, where: e.run_id == ^run.id, select: max(e.sequence)) || 0
192
193
    insert_event!(run.id, sequence + 1, kind, payload, observed_at)
194
  end
195
196
  defp insert_event!(run_id, sequence, kind, payload, observed_at) do
197
    %AccountEvent{run_id: run_id}
198
    |> AccountEvent.changeset(%{
199
      sequence: sequence,
200
      kind: kind,
201
      payload: payload,
202
      observed_at: observed_at
203
    })
204
    |> Repo.insert!()
205
  end
206
207
  defp provider_history(conversation_id, excluded_run_id) do
208
    from(r in AccountRun,
209
      where:
210
        r.conversation_id == ^conversation_id and r.id != ^excluded_run_id and
211
          r.status == "completed",
212
      order_by: [asc: r.inserted_at, asc: r.id]
213
    )
214
    |> Repo.all()
215
    |> Enum.flat_map(fn run ->
216
      [%{"role" => "user", "content" => run.user_content}, provider_assistant(run)]
217
    end)
218
  end
219
220
  defp provider_assistant(run) do
221
    completion = run.completion || %{}
222
223
    case completion["output"] do
224
      output when is_list(output) ->
225
        %{"role" => "assistant", "provider_output" => output}
226
227
      _missing ->
228
        %{"role" => "assistant", "content" => run.assistant_content || ""}
229
        |> maybe_put("id", completion["assistant_message_id"])
230
        |> maybe_put("status", if(completion["assistant_message_id"], do: "completed"))
231
        |> maybe_put("reasoning_items", completion["reasoning_items"])
232
    end
233
  end
234
235
  defp events_for_conversation(conversation_id) do
236
    from(e in AccountEvent,
237
      join: r in assoc(e, :run),
238
      where: r.conversation_id == ^conversation_id,
239
      order_by: [asc: r.inserted_at, asc: r.id, asc: e.sequence]
240
    )
241
    |> Repo.all()
242
    |> Enum.map(&event_projection/1)
243
  end
244
245
  defp messages_for_conversation(conversation_id) do
246
    event_query = from e in AccountEvent, order_by: [asc: e.sequence]
247
248
    from(r in AccountRun,
249
      where: r.conversation_id == ^conversation_id,
250
      order_by: [asc: r.inserted_at, asc: r.id],
251
      preload: [events: ^event_query]
252
    )
253
    |> Repo.all()
254
    |> Enum.flat_map(&run_messages/1)
255
  end
256
257
  defp run_messages(run) do
258
    user = %{
259
      id: run.id,
260
      role: :user,
261
      content: run.user_content,
262
      completion: nil,
263
      error: nil,
264
      history?: true,
265
      tool_calls: [],
266
      blocks: []
267
    }
268
269
    if run.status == "streaming", do: [user], else: [user, assistant_message(run)]
270
  end
271
272
  defp assistant_message(run) do
273
    completion = run.completion
274
    reasoning = completion && completion["reasoning_summary"]
275
    tools = tool_views(run.events)
276
277
    %{
278
      id: run.id,
279
      role: :assistant,
280
      content: run.assistant_content || "",
281
      completion: completion,
282
      error: run.error,
283
      history?: run.status == "completed",
284
      provider_message_id: completion && completion["assistant_message_id"],
285
      provider_status: if(completion && completion["assistant_message_id"], do: "completed"),
286
      provider_reasoning_items: completion && completion["reasoning_items"],
287
      reasoning: reasoning,
288
      reasoning_duration: duration(run),
289
      tool_calls: tools,
290
      blocks: blocks(run.events, run.assistant_content || "", reasoning, tools)
291
    }
292
  end
293
294
  defp blocks(events, content, reasoning, tools) do
295
    events
296
    |> Enum.reduce([], fn
297
      %{kind: "reasoning_delta", payload: %{"value" => delta}}, acc ->
298
        append_delta(acc, :reasoning, delta)
299
300
      %{kind: "text_delta", payload: %{"value" => delta}}, acc ->
301
        append_delta(acc, :content, delta)
302
303
      %{kind: "tool_call_started", payload: payload}, acc ->
304
        acc ++ [%{type: :tool, tool_call: Enum.find(tools, &(&1.call_id == payload["call_id"]))}]
305
306
      _event, acc ->
307
        acc
308
    end)
309
    |> ensure_reasoning(reasoning)
310
    |> ensure_content(content)
311
    |> Enum.map(fn
312
      %{type: :reasoning} = block -> Map.put(block, :duration, 1)
313
      block -> block
314
    end)
315
  end
316
317
  defp append_delta(blocks, type, delta) do
318
    case List.last(blocks) do
319
      %{type: ^type} = block -> List.replace_at(blocks, -1, %{block | text: block.text <> delta})
320
      _ -> blocks ++ [%{type: type, text: delta}]
321
    end
322
  end
323
324
  defp ensure_reasoning(blocks, reasoning) when is_binary(reasoning) and reasoning != "",
325
    do:
326
      if(Enum.any?(blocks, &(&1.type == :reasoning)),
327
        do: blocks,
328
        else: [%{type: :reasoning, text: reasoning} | blocks]
329
      )
330
331
  defp ensure_reasoning(blocks, _reasoning), do: blocks
332
333
  defp ensure_content(blocks, content) when is_binary(content) and content != "",
334
    do:
335
      if(Enum.any?(blocks, &(&1.type == :content)),
336
        do: blocks,
337
        else: blocks ++ [%{type: :content, text: content}]
338
      )
339
340
  defp ensure_content(blocks, _content), do: blocks
341
342
  defp tool_views(events) do
343
    Enum.reduce(events, [], fn
344
      %{kind: "tool_call_started", payload: payload}, acc ->
345
        acc ++
346
          [
347
            %{
348
              call_id: payload["call_id"],
349
              name: payload["name"],
350
              arguments: format_json(payload["arguments"]),
351
              output: nil,
352
              error: nil,
353
              state: "input-available"
354
            }
355
          ]
356
357
      %{kind: "tool_call_completed", payload: payload}, acc ->
358
        update_tool(acc, payload["call_id"], %{
359
          output: format_json(payload["output"]),
360
          state: "output-available"
361
        })
362
363
      %{kind: "tool_call_failed", payload: payload}, acc ->
364
        update_tool(acc, payload["call_id"], %{error: payload["error"], state: "output-error"})
365
366
      _event, acc ->
367
        acc
368
    end)
369
  end
370
371
  defp update_tool(tools, call_id, attrs),
372
    do:
373
      Enum.map(tools, fn tool ->
374
        if tool.call_id == call_id, do: Map.merge(tool, attrs), else: tool
375
      end)
376
377
  defp format_json(value) when is_binary(value) do
378
    case Jason.decode(value) do
379
      {:ok, decoded} -> Jason.encode!(decoded, pretty: true)
380
      _ -> value
381
    end
382
  end
383
384
  defp format_json(value), do: Jason.encode!(value, pretty: true)
385
386
  defp normalize_payload(payload) when is_map(payload),
387
    do: OpenAgents.Tools.Redaction.redact(payload)
388
389
  defp normalize_payload(payload), do: %{"value" => OpenAgents.Tools.Redaction.redact(payload)}
390
  defp public_error(:missing_api_key), do: "OpenRouter is not configured for this environment."
391
  defp public_error(:rate_limited), do: "OpenRouter is rate-limited. Try again later."
392
  defp public_error(:provider_unavailable), do: "OpenRouter could not complete that message."
393
  defp public_error(:turn_start_failed), do: "The chat turn could not start."
394
  defp public_error(_reason), do: "OpenRouter could not complete that message."
395
  defp maybe_put(map, _key, nil), do: map
396
  defp maybe_put(map, key, value), do: Map.put(map, key, value)
397
  defp notify(pid, message) when is_pid(pid), do: send(pid, message)
398
  defp notify(_pid, _message), do: :ok
399
400
  defp duration(%{started_at: %DateTime{} = started, completed_at: %DateTime{} = completed}),
401
    do: max(DateTime.diff(completed, started), 1)
402
403
  defp duration(_run), do: nil
404
405
  defp event_projection(event),
406
    do: %{
407
      "id" => event.id,
408
      "run_id" => event.run_id,
409
      "sequence" => event.sequence,
410
      "type" => event.kind,
411
      "payload" => event.payload,
412
      "observed_at" => DateTime.to_iso8601(event.observed_at)
413
    }
414
415
  defp run_projection(run),
416
    do: %{
417
      "id" => run.id,
418
      "status" => run.status,
419
      "reasoning_effort" => run.reasoning_effort,
420
      "started_at" => DateTime.to_iso8601(run.started_at)
421
    }
422
end
lib/openagents/chat/open_router.ex modified +59 -30

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

9 9
  response bodies.
10 10
  """
11 11
12
  alias OpenAgents.Chat.OpenRouter.ResponsesStreamDecoder
13
  alias OpenAgents.Chat.Tools.RepositoryFile
12
  alias OpenAgents.Chat.OpenRouter.{ResponsesStreamDecoder, ToolRuntime}
14 13
15 14
  @chat_completions_endpoint "https://openrouter.ai/api/v1/chat/completions"
16 15
  @responses_endpoint "https://openrouter.ai/api/v1/responses"

@@ -84,13 +83,21 @@ defmodule OpenAgents.Chat.OpenRouter do

84 83
  end
85 84
86 85
  defp stream_with_responses_fallback(api_key, request, on_event, options) do
87
    with {:ok, payload} <- responses_payload(request, options) do
86
    with {:ok, tool_runtime} <- ToolRuntime.capture(options),
87
         {:ok, payload} <- responses_payload(request, tool_runtime) do
88 88
      case responses_stream_request(api_key, payload, options) do
89 89
        {:ok, response} -> consume_responses_stream(response, on_event, payload["model"])
90 90
        {:fallback, _reason} -> stream_with_chat_completions(api_key, request, on_event, options)
91 91
        {:error, reason} -> {:error, reason}
92 92
      end
93
      |> continue_responses_tool_calls(api_key, payload, on_event, options, @maximum_tool_rounds)
93
      |> continue_responses_tool_calls(
94
        api_key,
95
        payload,
96
        on_event,
97
        options,
98
        tool_runtime,
99
        @maximum_tool_rounds
100
      )
94 101
    else
95 102
      {:error, :responses_history_unavailable} ->
96 103
        stream_with_chat_completions(api_key, request, on_event, options)

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

134 141
    end
135 142
  end
136 143
137
  defp responses_payload(%{"model" => model, "messages" => messages} = request, options)
144
  defp responses_payload(%{"model" => model, "messages" => messages} = request, tool_runtime)
138 145
       when is_binary(model) and is_list(messages) do
139 146
    with {:ok, input} <- responses_input(messages) do
140 147
      payload = %{"model" => model, "input" => input}

@@ -154,7 +161,7 @@ defmodule OpenAgents.Chat.OpenRouter do

154 161
      {:ok,
155 162
       Map.merge(payload, %{
156 163
         "instructions" => @tool_instructions,
157
         "tools" => tool_module(options).definitions(),
164
         "tools" => ToolRuntime.provider_definitions(tool_runtime, latest_user_intent(messages)),
158 165
         "tool_choice" => "auto",
159 166
         "reasoning" => reasoning,
160 167
         "include" => ["reasoning.encrypted_content"],

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

163 170
    end
164 171
  end
165 172
166
  defp responses_payload(_request, _options), do: {:error, :invalid_response}
173
  defp responses_payload(_request, _tool_runtime), do: {:error, :invalid_response}
174
175
  defp latest_user_intent(messages) do
176
    messages
177
    |> Enum.reverse()
178
    |> Enum.find_value("", fn
179
      %{"role" => "user", "content" => content} when is_binary(content) -> content
180
      _message -> nil
181
    end)
182
  end
167 183
168 184
  defp reasoning_request("none"), do: %{"effort" => "none", "exclude" => false}
169 185

@@ -190,6 +206,10 @@ defmodule OpenAgents.Chat.OpenRouter do

190 206
     ]}
191 207
  end
192 208
209
  defp response_input_items(%{"role" => "assistant", "provider_output" => output})
210
       when is_list(output),
211
       do: {:ok, output}
212
193 213
  defp response_input_items(
194 214
         %{
195 215
           "role" => "assistant",

@@ -244,10 +264,11 @@ defmodule OpenAgents.Chat.OpenRouter do

244 264
         payload,
245 265
         on_event,
246 266
         options,
267
         tool_runtime,
247 268
         rounds_remaining
248 269
       )
249 270
       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),
271
    with {:ok, tool_outputs} <- execute_tool_calls(tool_calls, on_event, tool_runtime),
251 272
         payload <- Map.update!(payload, "input", &(&1 ++ provider_output ++ tool_outputs)),
252 273
         {:ok, response} <- responses_stream_request(api_key, payload, options),
253 274
         result <- consume_responses_stream(response, on_event, payload["model"]) do

@@ -257,6 +278,7 @@ defmodule OpenAgents.Chat.OpenRouter do

257 278
        payload,
258 279
        on_event,
259 280
        options,
281
        tool_runtime,
260 282
        rounds_remaining - 1
261 283
      )
262 284
    end

@@ -268,6 +290,7 @@ defmodule OpenAgents.Chat.OpenRouter do

268 290
         _payload,
269 291
         _on_event,
270 292
         _options,
293
         _tool_runtime,
271 294
         0
272 295
       ),
273 296
       do: {:error, :invalid_response}

@@ -278,55 +301,61 @@ defmodule OpenAgents.Chat.OpenRouter do

278 301
         _payload,
279 302
         _on_event,
280 303
         _options,
304
         _tool_runtime,
281 305
         _rounds_remaining
282 306
       ),
283 307
       do: result
284 308
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
289
    tool_outputs =
290
      Enum.map(tool_calls, fn %{"call_id" => call_id, "name" => name, "arguments" => arguments} ->
309
  defp execute_tool_calls(tool_calls, on_event, tool_runtime) do
310
    result =
311
      Enum.reduce_while(tool_calls, [], fn %{
312
                                             "call_id" => call_id,
313
                                             "name" => name,
314
                                             "arguments" => arguments
315
                                           },
316
                                           outputs ->
291 317
        on_event.(
292 318
          {:tool_call_started, %{"call_id" => call_id, "name" => name, "arguments" => arguments}}
293 319
        )
294 320
295
        case execute_tool(tool_module, name, arguments, tool_context) do
296
          {:ok, output} ->
297
            encoded_output = Jason.encode!(output)
321
        case ToolRuntime.run(tool_runtime, call_id, name, arguments) do
322
          {:ok, %{"status" => "succeeded"} = outcome} ->
323
            encoded_output = Jason.encode!(outcome)
298 324
            on_event.({:tool_call_completed, %{"call_id" => call_id, "output" => encoded_output}})
299 325
300
            %{
326
            output = %{
301 327
              "type" => "function_call_output",
302 328
              "call_id" => call_id,
303 329
              "output" => encoded_output
304 330
            }
305 331
306
          {:error, error} ->
332
            {:cont, [output | outputs]}
333
334
          {:ok, outcome} ->
335
            error = get_in(outcome, ["error", "message"]) || "The tool call failed."
307 336
            on_event.({:tool_call_failed, %{"call_id" => call_id, "error" => error}})
308 337
309
            %{
338
            output = %{
310 339
              "type" => "function_call_output",
311 340
              "call_id" => call_id,
312
              "output" => Jason.encode!(%{"error" => error})
341
              "output" => Jason.encode!(outcome)
313 342
            }
343
344
            {:cont, [output | outputs]}
345
346
          {:error, _error} ->
347
            {:halt, {:error, :invalid_response}}
314 348
        end
315 349
      end)
316 350
317
    {:ok, tool_outputs}
351
    case result do
352
      {:error, reason} -> {:error, reason}
353
      outputs -> {:ok, Enum.reverse(outputs)}
354
    end
318 355
  rescue
319 356
    _exception -> {:error, :invalid_response}
320 357
  end
321 358
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
330 359
  defp chat_stream_request(api_key, request, options) do
331 360
    request_options = Keyword.get(options, :request_options, [])
332 361
lib/openagents/chat/open_router/tool_runtime.ex added +131

@@ -0,0 +1,131 @@

1
defmodule OpenAgents.Chat.OpenRouter.ToolRuntime do
2
  @moduledoc """
3
  Captures one tool registry and execution context for an OpenRouter turn.
4
5
  A Responses tool loop must keep using the same registry snapshot across
6
  provider continuations. This adapter also keeps OpenRouter transport details
7
  out of tool implementations: provider definitions come from the registry,
8
  and every call enters the shared `OpenAgents.Tools.Runner` authority boundary.
9
  """
10
11
  alias OpenAgents.Providers.ToolDefinition
12
13
  alias OpenAgents.Tools.{
14
    AdmittedCatalog,
15
    ConversationExecutionContext,
16
    ExecutionContext,
17
    Registry,
18
    Runner,
19
    Snapshot
20
  }
21
22
  @enforce_keys [:snapshot, :execution_context]
23
  defstruct @enforce_keys
24
25
  @type t :: %__MODULE__{
26
          snapshot: Snapshot.t(),
27
          execution_context: ExecutionContext.t()
28
        }
29
30
  @doc "Captures the immutable registry and execution context for one turn."
31
  @spec capture(keyword()) :: {:ok, t()} | {:error, :invalid_tool_runtime}
32
  def capture(options) when is_list(options) do
33
    snapshot = Keyword.get_lazy(options, :tool_registry_snapshot, &Registry.current!/0)
34
35
    with %Snapshot{} <- snapshot,
36
         {:ok, execution_context} <- execution_context(snapshot, options) do
37
      {:ok, %__MODULE__{snapshot: snapshot, execution_context: execution_context}}
38
    else
39
      _invalid -> {:error, :invalid_tool_runtime}
40
    end
41
  rescue
42
    _exception -> {:error, :invalid_tool_runtime}
43
  end
44
45
  @doc "Returns OpenRouter Responses function definitions from the captured registry."
46
  @spec provider_definitions(t(), String.t()) :: [map()]
47
  def provider_definitions(
48
        %__MODULE__{snapshot: snapshot, execution_context: execution_context},
49
        intent \\ ""
50
      ) do
51
    snapshot
52
    |> AdmittedCatalog.provider_definitions(execution_context, intent)
53
    |> Enum.map(&provider_definition/1)
54
  end
55
56
  @doc "Runs a provider function call through the shared tool runner."
57
  @spec run(t(), String.t(), String.t(), String.t()) :: {:ok, map()} | {:error, term()}
58
  def run(%__MODULE__{} = runtime, call_id, name, raw_arguments)
59
      when is_binary(call_id) and is_binary(name) and is_binary(raw_arguments) do
60
    version =
61
      case Map.fetch(runtime.snapshot.tools, name) do
62
        {:ok, tool} -> tool.version
63
        :error -> 1
64
      end
65
66
    case Runner.run(
67
           runtime.snapshot,
68
           %{call_id: call_id, name: name, version: version, raw_arguments: raw_arguments},
69
           runtime.execution_context
70
         ) do
71
      {:ok, outcome} -> {:ok, OpenAgents.Tools.Redaction.redact(outcome)}
72
      {:error, reason} -> {:error, reason}
73
    end
74
  end
75
76
  defp execution_context(snapshot, options) do
77
    case Keyword.get(options, :tool_execution_context) do
78
      %ExecutionContext{} = context ->
79
        {:ok, %{context | module_registry_snapshot: snapshot}}
80
81
      nil ->
82
        context = Keyword.get(options, :tool_context, %{})
83
        user = Map.get(context, :user)
84
        owner_user_id = Map.get(context, :owner_user_id) || user_id(user)
85
86
        conversation_id = Map.get(context, :conversation_id)
87
        owner_visitor_id = Map.get(context, :owner_visitor_id) || owner_user_id
88
89
        attributes = %{
90
          surface: Map.get(context, :surface, "text"),
91
          conversation_id: conversation_id,
92
          current_user_message_id: Map.get(context, :current_user_message_id),
93
          owner_visitor_id: owner_visitor_id,
94
          owner_user_id: owner_user_id,
95
          workspace: Map.get(context, :workspace),
96
          memory_snapshot_ref: Map.get(context, :memory_snapshot_ref),
97
          profile_memory_snapshot_ref: Map.get(context, :profile_memory_snapshot_ref),
98
          module_registry_snapshot: snapshot
99
        }
100
101
        if is_binary(conversation_id) and is_binary(owner_visitor_id) do
102
          {:ok, ConversationExecutionContext.build(attributes)}
103
        else
104
          {:ok,
105
           %ExecutionContext{
106
             scope: "browser_conversation",
107
             scope_ref: "conversation:unbound",
108
             authorities: MapSet.new(),
109
             surface: Map.get(context, :surface, "text"),
110
             module_registry_snapshot: snapshot
111
           }}
112
        end
113
114
      _invalid ->
115
        {:error, :invalid_tool_runtime}
116
    end
117
  end
118
119
  defp user_id(%{id: id}) when is_binary(id), do: id
120
  defp user_id(_user), do: nil
121
122
  defp provider_definition(%ToolDefinition{} = definition) do
123
    %{
124
      "type" => "function",
125
      "name" => definition.name,
126
      "description" => definition.description,
127
      "parameters" => definition.input_schema,
128
      "strict" => definition.strict
129
    }
130
  end
131
end
lib/openagents/tools/admitted_catalog.ex added +58

@@ -0,0 +1,58 @@

1
defmodule OpenAgents.Tools.AdmittedCatalog do
2
  @moduledoc "Builds a provider catalog from tools authorized for one captured execution context."
3
4
  alias OpenAgents.Modules.SurfacePolicy
5
  alias OpenAgents.Providers.ToolDefinition
6
  alias OpenAgents.Tools.{ExecutionContext, Registry, Selector, Snapshot, Tool}
7
8
  @spec provider_definitions(Snapshot.t(), ExecutionContext.t(), String.t() | nil, keyword()) ::
9
          [ToolDefinition.t()]
10
  def provider_definitions(
11
        %Snapshot{} = snapshot,
12
        %ExecutionContext{} = context,
13
        intent,
14
        opts \\ []
15
      ) do
16
    snapshot
17
    |> tools(context, intent, opts)
18
    |> Registry.definitions_for()
19
  end
20
21
  @spec realtime_catalog(Snapshot.t(), ExecutionContext.t(), String.t() | nil, keyword()) :: map()
22
  def realtime_catalog(%Snapshot{} = snapshot, %ExecutionContext{} = context, intent, opts \\ []) do
23
    definitions = provider_definitions(snapshot, context, intent, opts)
24
25
    %{
26
      "schema" => "sarah.realtime_tool_catalog.v1",
27
      "digest" => snapshot.digest,
28
      "mode" => "selected",
29
      "tools" => Enum.map(definitions, &realtime_definition/1)
30
    }
31
  end
32
33
  @spec tools(Snapshot.t(), ExecutionContext.t(), String.t() | nil, keyword()) :: [Tool.t()]
34
  def tools(%Snapshot{} = snapshot, %ExecutionContext{} = context, intent, opts \\ []) do
35
    {selected, _omitted} = Selector.select(snapshot, intent, opts)
36
    Enum.filter(selected, &authorized?(snapshot, &1, context))
37
  end
38
39
  defp authorized?(snapshot, tool, context) do
40
    with true <- tool.required_scope == context.scope,
41
         true <- MapSet.member?(context.authorities, tool.required_authority),
42
         {:ok, artifact} <- Registry.module_for_tool(snapshot, tool.name, tool.version),
43
         :ok <- SurfacePolicy.authorize_execution(artifact, context) do
44
      true
45
    else
46
      _refused -> false
47
    end
48
  end
49
50
  defp realtime_definition(definition) do
51
    %{
52
      "type" => "function",
53
      "name" => definition.name,
54
      "description" => definition.description,
55
      "parameters" => definition.input_schema
56
    }
57
  end
58
end
lib/openagents/tools/connected_repository.ex added +194

@@ -0,0 +1,194 @@

1
defmodule OpenAgents.Tools.ConnectedRepository do
2
  @moduledoc false
3
4
  alias OpenAgents.Accounts.User
5
  alias OpenAgents.Forge.Browse
6
  alias OpenAgents.Repo
7
  alias OpenAgents.Repositories
8
  alias OpenAgents.Repositories.Repository
9
  alias OpenAgents.Tools.ExecutionContext
10
11
  @maximum_content_bytes 180_000
12
  @sensitive_names MapSet.new([
13
                     ".env",
14
                     ".git",
15
                     ".netrc",
16
                     "credentials",
17
                     "credentials.json",
18
                     "id_dsa",
19
                     "id_ed25519",
20
                     "id_rsa"
21
                   ])
22
23
  @spec resolve(ExecutionContext.t(), String.t()) ::
24
          {:ok, Repository.t()} | {:error, atom()}
25
  def resolve(%ExecutionContext{owner_user_id: user_id}, repository)
26
      when is_binary(user_id) and is_binary(repository) do
27
    with %User{} = user <- Repo.get(User, user_id),
28
         {:ok, parsed} <- parse_repository(repository) do
29
      resolve_visible(parsed, user)
30
    else
31
      nil -> {:error, :repository_authentication_required}
32
      {:error, reason} -> {:error, reason}
33
    end
34
  end
35
36
  def resolve(%ExecutionContext{}, _repository), do: {:error, :repository_authentication_required}
37
38
  @spec read(Repository.t(), String.t(), String.t()) :: {:ok, map()} | {:error, atom()}
39
  def read(%Repository{} = repository, path, ref)
40
      when is_binary(path) and is_binary(ref) do
41
    with {:ok, ref} <- normalize_ref(ref, repository),
42
         {:ok, path, blob} <- read_blob(repository, ref, normalize_optional(path)),
43
         :ok <- ensure_text_blob(blob) do
44
      {content, locally_truncated?} = truncate_content(blob.content)
45
46
      {:ok,
47
       %{
48
         "schema" => "openagents.connected_repository_file.v1",
49
         "repository" => repository.owner <> "/" <> repository.name,
50
         "ref" => ref,
51
         "path" => path,
52
         "content" => content,
53
         "size_bytes" => blob.size,
54
         "truncated" => blob.truncated or locally_truncated?
55
       }}
56
    end
57
  end
58
59
  @spec list(Repository.t(), String.t(), String.t()) :: {:ok, map()} | {:error, atom()}
60
  def list(%Repository{} = repository, path, ref)
61
      when is_binary(path) and is_binary(ref) do
62
    path = normalize_optional(path) || ""
63
64
    with {:ok, ref} <- normalize_ref(ref, repository),
65
         :ok <- validate_directory_path(path),
66
         {:ok, entries} <- map_browse_error(Browse.tree(repository, ref, path), :directory) do
67
      entries = Enum.map(entries, &directory_entry(path, &1))
68
69
      {:ok,
70
       %{
71
         "schema" => "openagents.connected_repository_directory.v1",
72
         "repository" => repository.owner <> "/" <> repository.name,
73
         "ref" => ref,
74
         "path" => path,
75
         "entries" => entries,
76
         "count" => length(entries)
77
       }}
78
    end
79
  end
80
81
  defp parse_repository(repository) do
82
    repository = String.trim(repository)
83
84
    case String.split(repository, "/", trim: true) do
85
      [name] when byte_size(name) in 1..100 ->
86
        {:ok, {:name, String.downcase(name)}}
87
88
      [owner, name] when byte_size(owner) in 1..100 and byte_size(name) in 1..100 ->
89
        {:ok, {:path, owner, name}}
90
91
      _invalid ->
92
        {:error, :invalid_repository}
93
    end
94
  end
95
96
  defp resolve_visible({:path, owner, name}, user) do
97
    {:ok, Repositories.get_visible_by_path!(owner, name, user)}
98
  rescue
99
    Ecto.NoResultsError -> {:error, :repository_not_found}
100
  end
101
102
  defp resolve_visible({:name, name_key}, user) do
103
    matches =
104
      user
105
      |> Repositories.list_visible_repositories()
106
      |> Enum.filter(&(&1.name_key == name_key))
107
108
    case matches do
109
      [repository] -> {:ok, repository}
110
      [] -> {:error, :repository_not_found}
111
      _many -> {:error, :ambiguous_repository_name}
112
    end
113
  end
114
115
  defp normalize_ref(ref, repository) do
116
    ref = normalize_optional(ref) || repository.default_branch
117
    if Browse.valid_ref?(ref), do: {:ok, ref}, else: {:error, :invalid_repository_ref}
118
  end
119
120
  defp read_blob(repository, ref, nil) do
121
    case Browse.readme(repository, ref) do
122
      {:ok, path, blob} -> {:ok, path, blob}
123
      {:error, :not_found} -> {:error, :repository_readme_not_found}
124
    end
125
  end
126
127
  defp read_blob(repository, ref, path) do
128
    with :ok <- validate_file_path(path),
129
         {:ok, blob} <- map_browse_error(Browse.blob(repository, ref, path), :file) do
130
      {:ok, path, blob}
131
    end
132
  end
133
134
  defp validate_file_path(path) do
135
    cond do
136
      not Browse.valid_path?(path) -> {:error, :invalid_repository_path}
137
      sensitive_path?(path) -> {:error, :sensitive_repository_path}
138
      true -> :ok
139
    end
140
  end
141
142
  defp validate_directory_path(""), do: :ok
143
  defp validate_directory_path(path), do: validate_file_path(path)
144
145
  defp map_browse_error({:ok, value}, _kind), do: {:ok, value}
146
147
  defp map_browse_error({:error, :not_found}, :file),
148
    do: {:error, :repository_ref_or_file_not_found}
149
150
  defp map_browse_error({:error, :not_found}, :directory),
151
    do: {:error, :repository_ref_or_directory_not_found}
152
153
  defp ensure_text_blob(%{binary: true}), do: {:error, :repository_binary_file}
154
  defp ensure_text_blob(%{binary: false}), do: :ok
155
156
  defp directory_entry(parent, entry) do
157
    %{
158
      "name" => entry.name,
159
      "path" => if(parent == "", do: entry.name, else: parent <> "/" <> entry.name),
160
      "type" => if(entry.kind == "tree", do: "directory", else: "file"),
161
      "size_bytes" => entry.size || 0
162
    }
163
  end
164
165
  defp normalize_optional(value) when value in ["", "null"], do: nil
166
  defp normalize_optional(value), do: value
167
168
  defp sensitive_path?(path) do
169
    path
170
    |> String.split("/", trim: true)
171
    |> Enum.any?(fn segment ->
172
      normalized = String.downcase(segment)
173
174
      MapSet.member?(@sensitive_names, normalized) or String.starts_with?(normalized, ".env.") or
175
        String.ends_with?(normalized, [".pem", ".key", ".p12", ".pfx"])
176
    end)
177
  end
178
179
  defp truncate_content(content) when byte_size(content) <= @maximum_content_bytes,
180
    do: {content, false}
181
182
  defp truncate_content(content) do
183
    truncated = binary_part(content, 0, @maximum_content_bytes)
184
    {trim_invalid_suffix(truncated), true}
185
  end
186
187
  defp trim_invalid_suffix(content) do
188
    if String.valid?(content) do
189
      content
190
    else
191
      trim_invalid_suffix(binary_part(content, 0, byte_size(content) - 1))
192
    end
193
  end
194
end
lib/openagents/tools/connected_repository_list.ex added +104

@@ -0,0 +1,104 @@

1
defmodule OpenAgents.Tools.ConnectedRepositoryList do
2
  @moduledoc "Lists one directory in a connected Forge repository visible to the signed-in user."
3
4
  @behaviour OpenAgents.Tools.Tool
5
6
  alias OpenAgents.Modules.Metadata
7
  alias OpenAgents.Tools.{ConnectedRepository, ExecutionResult, Tool}
8
9
  @impl true
10
  def specification do
11
    %Tool{
12
      module_id: "sarah.tool.connected_repository_list.v1",
13
      name: "list_repository_directory",
14
      version: 1,
15
      description:
16
        "List files and directories at one path in a connected Forge repository the signed-in " <>
17
          "user can access. Pass an empty path for the root. List parent directories before " <>
18
          "choosing a file path.",
19
      input_schema: input_schema(),
20
      output_schema: output_schema(),
21
      side_effect: :read_only,
22
      required_scope: "browser_conversation",
23
      required_authority: "repository.read",
24
      executor: %{
25
        id: "sarah.forge.browse",
26
        disclosure: "OpenAgents Forge with signed-in repository access"
27
      },
28
      maintainer: "OpenAgents",
29
      attribution: ["OpenAgentsInc/openagents.com"],
30
      policy_facets: %{
31
        "privacy" => "signed_browser_owner",
32
        "residency" => "application_process",
33
        "consent" => "not_applicable"
34
      },
35
      module_metadata:
36
        Metadata.first_party("repository.read", "browser_conversation",
37
          effect: :read_only,
38
          privacy: "signed_browser_owner",
39
          residency: "application_process"
40
        ),
41
      timeout_ms: 15_000,
42
      maximum_input_bytes: 2_048,
43
      maximum_output_bytes: 262_144,
44
      implementation: __MODULE__,
45
      tags: ["forge", "repository", "directory", "list"]
46
    }
47
  end
48
49
  @impl true
50
  def execute(%{"repository" => repository, "path" => path, "ref" => ref}, context)
51
      when is_binary(repository) and is_binary(path) and is_binary(ref) do
52
    with {:ok, connected_repository} <- ConnectedRepository.resolve(context, repository),
53
         {:ok, result} <- ConnectedRepository.list(connected_repository, path, ref) do
54
      {:ok,
55
       %ExecutionResult{
56
         result: result,
57
         target_receipt_refs: ["forge-repository:#{connected_repository.id}"]
58
       }}
59
    end
60
  end
61
62
  def execute(_arguments, _context), do: {:error, :invalid_repository}
63
64
  defp input_schema do
65
    %{
66
      "type" => "object",
67
      "properties" => %{
68
        "repository" => %{"type" => "string", "maxLength" => 201},
69
        "path" => %{"type" => "string", "maxLength" => 512},
70
        "ref" => %{"type" => "string", "maxLength" => 128}
71
      },
72
      "required" => ["repository", "path", "ref"],
73
      "additionalProperties" => false
74
    }
75
  end
76
77
  defp output_schema do
78
    entry_schema = %{
79
      "type" => "object",
80
      "properties" => %{
81
        "name" => %{"type" => "string", "maxLength" => 512},
82
        "path" => %{"type" => "string", "maxLength" => 512},
83
        "type" => %{"type" => "string", "maxLength" => 16},
84
        "size_bytes" => %{"type" => "integer"}
85
      },
86
      "required" => ["name", "path", "type", "size_bytes"],
87
      "additionalProperties" => false
88
    }
89
90
    %{
91
      "type" => "object",
92
      "properties" => %{
93
        "schema" => %{"type" => "string", "maxLength" => 64},
94
        "repository" => %{"type" => "string", "maxLength" => 201},
95
        "ref" => %{"type" => "string", "maxLength" => 128},
96
        "path" => %{"type" => "string", "maxLength" => 512},
97
        "entries" => %{"type" => "array", "maxItems" => 400, "items" => entry_schema},
98
        "count" => %{"type" => "integer"}
99
      },
100
      "required" => ["schema", "repository", "ref", "path", "entries", "count"],
101
      "additionalProperties" => false
102
    }
103
  end
104
end
lib/openagents/tools/connected_repository_read.ex added +94

@@ -0,0 +1,94 @@

1
defmodule OpenAgents.Tools.ConnectedRepositoryRead do
2
  @moduledoc "Reads a text file from a connected Forge repository visible to the signed-in user."
3
4
  @behaviour OpenAgents.Tools.Tool
5
6
  alias OpenAgents.Modules.Metadata
7
  alias OpenAgents.Tools.{ConnectedRepository, ExecutionResult, Tool}
8
9
  @impl true
10
  def specification do
11
    %Tool{
12
      module_id: "sarah.tool.connected_repository_read.v1",
13
      name: "read_repository_file",
14
      version: 1,
15
      description:
16
        "Read a text file from a connected Forge repository the signed-in user can access. " <>
17
          "Pass repository as owner/name or an unambiguous name, a repository-relative path " <>
18
          "(empty string for the README), and a branch, tag, or commit (empty string for the " <>
19
          "default branch).",
20
      input_schema: input_schema(),
21
      output_schema: output_schema(),
22
      side_effect: :read_only,
23
      required_scope: "browser_conversation",
24
      required_authority: "repository.read",
25
      executor: %{
26
        id: "sarah.forge.browse",
27
        disclosure: "OpenAgents Forge with signed-in repository access"
28
      },
29
      maintainer: "OpenAgents",
30
      attribution: ["OpenAgentsInc/openagents.com"],
31
      policy_facets: %{
32
        "privacy" => "signed_browser_owner",
33
        "residency" => "application_process",
34
        "consent" => "not_applicable"
35
      },
36
      module_metadata:
37
        Metadata.first_party("repository.read", "browser_conversation",
38
          effect: :read_only,
39
          privacy: "signed_browser_owner",
40
          residency: "application_process"
41
        ),
42
      timeout_ms: 15_000,
43
      maximum_input_bytes: 2_048,
44
      maximum_output_bytes: 200_000,
45
      implementation: __MODULE__,
46
      tags: ["forge", "repository", "file", "read"]
47
    }
48
  end
49
50
  @impl true
51
  def execute(%{"repository" => repository, "path" => path, "ref" => ref}, context)
52
      when is_binary(repository) and is_binary(path) and is_binary(ref) do
53
    with {:ok, connected_repository} <- ConnectedRepository.resolve(context, repository),
54
         {:ok, result} <- ConnectedRepository.read(connected_repository, path, ref) do
55
      {:ok,
56
       %ExecutionResult{
57
         result: result,
58
         target_receipt_refs: ["forge-repository:#{connected_repository.id}"]
59
       }}
60
    end
61
  end
62
63
  def execute(_arguments, _context), do: {:error, :invalid_repository}
64
65
  defp input_schema do
66
    %{
67
      "type" => "object",
68
      "properties" => %{
69
        "repository" => %{"type" => "string", "maxLength" => 201},
70
        "path" => %{"type" => "string", "maxLength" => 512},
71
        "ref" => %{"type" => "string", "maxLength" => 128}
72
      },
73
      "required" => ["repository", "path", "ref"],
74
      "additionalProperties" => false
75
    }
76
  end
77
78
  defp output_schema do
79
    %{
80
      "type" => "object",
81
      "properties" => %{
82
        "schema" => %{"type" => "string", "maxLength" => 64},
83
        "repository" => %{"type" => "string", "maxLength" => 201},
84
        "ref" => %{"type" => "string", "maxLength" => 128},
85
        "path" => %{"type" => "string", "maxLength" => 512},
86
        "content" => %{"type" => "string", "maxLength" => 180_000},
87
        "size_bytes" => %{"type" => "integer"},
88
        "truncated" => %{"type" => "boolean"}
89
      },
90
      "required" => ["schema", "repository", "ref", "path", "content", "size_bytes", "truncated"],
91
      "additionalProperties" => false
92
    }
93
  end
94
end
lib/openagents/tools/conversation_execution_context.ex modified +10

@@ -22,6 +22,7 @@ defmodule OpenAgents.Tools.ConversationExecutionContext do

22 22
                 "memory.read",
23 23
                 "memory.write",
24 24
                 "module.discover",
25
                 "repository.read",
25 26
                 "scv.deploy",
26 27
                 "work.delegate"
27 28
               ])

@@ -66,7 +67,16 @@ defmodule OpenAgents.Tools.ConversationExecutionContext do

66 67
      surface: surface,
67 68
      conversation_id: conversation_id,
68 69
      current_user_message_id: Map.get(attributes, :current_user_message_id),
70
      owner_user_id: owner_user_id,
69 71
      owner_visitor_id: owner_visitor_id,
72
      workspace:
73
        Map.get(attributes, :workspace) ||
74
          %{
75
            "type" => "connected_forge_repository",
76
            "binding" => "user_visible_repository",
77
            "owner_user_id" => owner_user_id,
78
            "read_only" => true
79
          },
70 80
      memory_snapshot_ref: Map.get(attributes, :memory_snapshot_ref),
71 81
      profile_memory_snapshot_ref: Map.get(attributes, :profile_memory_snapshot_ref),
72 82
      module_registry_snapshot: Map.get(attributes, :module_registry_snapshot)
lib/openagents/tools/execution_context.ex modified +4

@@ -9,7 +9,9 @@ defmodule OpenAgents.Tools.ExecutionContext do

9 9
                job_ref: nil,
10 10
                conversation_id: nil,
11 11
                current_user_message_id: nil,
12
                owner_user_id: nil,
12 13
                owner_visitor_id: nil,
14
                workspace: nil,
13 15
                memory_snapshot_ref: nil,
14 16
                profile_memory_snapshot_ref: nil,
15 17
                memory_consent: nil,

@@ -25,7 +27,9 @@ defmodule OpenAgents.Tools.ExecutionContext do

25 27
          job_ref: String.t() | nil,
26 28
          conversation_id: Ecto.UUID.t() | nil,
27 29
          current_user_message_id: Ecto.UUID.t() | nil,
30
          owner_user_id: Ecto.UUID.t() | nil,
28 31
          owner_visitor_id: Ecto.UUID.t() | nil,
32
          workspace: map() | nil,
29 33
          memory_snapshot_ref: String.t() | nil,
30 34
          profile_memory_snapshot_ref: String.t() | nil,
31 35
          memory_consent: map() | nil,
lib/openagents/tools/redaction.ex added +29

@@ -0,0 +1,29 @@

1
defmodule OpenAgents.Tools.Redaction do
2
  @moduledoc "Redacts credential-shaped fields before tool data reaches providers, clients, or storage."
3
4
  @redacted "[REDACTED]"
5
  @sensitive_fragments ~w(api_key authorization cookie credential password private_key secret token)
6
7
  @spec redact(term()) :: term()
8
  def redact(value) when is_map(value) do
9
    Map.new(value, fn {key, nested} ->
10
      if sensitive_key?(key), do: {key, @redacted}, else: {key, redact(nested)}
11
    end)
12
  end
13
14
  def redact(value) when is_list(value), do: Enum.map(value, &redact/1)
15
16
  def redact(value) when is_tuple(value),
17
    do: value |> Tuple.to_list() |> Enum.map(&redact/1) |> List.to_tuple()
18
19
  def redact(value), do: value
20
21
  defp sensitive_key?(key) when is_atom(key), do: key |> Atom.to_string() |> sensitive_key?()
22
23
  defp sensitive_key?(key) when is_binary(key) do
24
    normalized = String.downcase(key)
25
    Enum.any?(@sensitive_fragments, &String.contains?(normalized, &1))
26
  end
27
28
  defp sensitive_key?(_key), do: false
29
end
lib/openagents/tools/runner.ex modified +31

@@ -23,6 +23,7 @@ defmodule OpenAgents.Tools.Runner do

23 23
24 24
    if valid_reference?(call[:call_id]) do
25 25
      result = execute(snapshot, call, context, options, started_at)
26
      result = attach_workspace(result, context.workspace)
26 27
      {:ok, outcome} = result
27 28
      duration_ms = max(DateTime.diff(DateTime.utc_now(), started_at, :millisecond), 0)
28 29
      _telemetry_result = Observability.tool_outcome(outcome, context.surface, duration_ms)

@@ -32,6 +33,11 @@ defmodule OpenAgents.Tools.Runner do

32 33
    end
33 34
  end
34 35
36
  defp attach_workspace({:ok, outcome}, workspace) when is_map(workspace),
37
    do: {:ok, Map.put(outcome, "workspace", workspace)}
38
39
  defp attach_workspace(result, _workspace), do: result
40
35 41
  defp execute(snapshot, call, context, options, started_at) do
36 42
    case Registry.fetch(snapshot, call[:name], call[:version]) do
37 43
      {:ok, tool} -> execute_known(snapshot, tool, call, context, options, started_at)

@@ -371,6 +377,31 @@ defmodule OpenAgents.Tools.Runner do

371 377
    do: "The path is outside the repository or invalid."
372 378
373 379
  defp error_message(:repository_file_not_found), do: "No such file in that repository tree."
380
381
  defp error_message(:repository_authentication_required),
382
    do: "Sign in to read a connected repository."
383
384
  defp error_message(:repository_not_found),
385
    do: "The repository does not exist or you cannot access it."
386
387
  defp error_message(:ambiguous_repository_name),
388
    do: "More than one visible repository has that name. Use owner/name."
389
390
  defp error_message(:invalid_repository),
391
    do: "The repository must be an owner/name path or an unambiguous repository name."
392
393
  defp error_message(:invalid_repository_ref), do: "The repository ref is invalid."
394
  defp error_message(:repository_readme_not_found), do: "The repository has no readable README."
395
396
  defp error_message(:repository_ref_or_file_not_found),
397
    do: "The requested file or ref does not exist."
398
399
  defp error_message(:repository_ref_or_directory_not_found),
400
    do: "The requested directory or ref does not exist."
401
402
  defp error_message(:repository_binary_file),
403
    do: "The requested repository file is binary and cannot be read as text."
404
374 405
  defp error_message(:invalid_search_pattern), do: "The search pattern is not a valid regex."
375 406
  defp error_message(:invalid_code_content), do: "The code content is missing or invalid."
376 407
  defp error_message(:empty_match_string), do: "old_string must not be empty."
lib/openagents/voice/context_capture.ex modified +15 -2

@@ -8,7 +8,7 @@ defmodule OpenAgents.Voice.ContextCapture do

8 8
  alias OpenAgents.Conversations.{Conversation, Message, Turn}
9 9
  alias OpenAgents.Conversations.ToolStep, as: TurnToolStep
10 10
  alias OpenAgents.Memory.LexicalRecall
11
  alias OpenAgents.Tools.{Registry, Snapshot}
11
  alias OpenAgents.Tools.{AdmittedCatalog, ConversationExecutionContext, Registry, Snapshot}
12 12
  alias OpenAgents.Voice.{ResponseContext, Session, TranscriptItem}
13 13
  alias OpenAgents.Voice.ToolStep, as: VoiceToolStep
14 14

@@ -25,6 +25,15 @@ defmodule OpenAgents.Voice.ContextCapture do

25 25
    evidence = conversation_evidence(conversation, nil) ++ tool_activity_evidence(conversation)
26 26
    owner = Conversations.get_conversation_owner!(conversation)
27 27
28
    execution_context =
29
      ConversationExecutionContext.build(%{
30
        surface: "voice",
31
        conversation_id: conversation.id,
32
        owner_visitor_id: owner.id,
33
        owner_user_id: owner.user_id,
34
        module_registry_snapshot: tool_snapshot
35
      })
36
28 37
    with {:ok, blueprint} <- Blueprint.current_projection(),
29 38
         {:ok, context} <-
30 39
           Composer.compose(

@@ -38,8 +47,12 @@ defmodule OpenAgents.Voice.ContextCapture do

38 47
         context: context,
39 48
         blueprint: blueprint,
40 49
         program_snapshot: program_snapshot,
50
         tool_execution_context: execution_context,
41 51
         tool_catalog:
42
           Registry.realtime_catalog(tool_snapshot, voice_intent(conversation),
52
           AdmittedCatalog.realtime_catalog(
53
             tool_snapshot,
54
             execution_context,
55
             voice_intent(conversation),
43 56
             machine_paired?: Machines.active_machine?(owner.user_id)
44 57
           )
45 58
       }}
lib/openagents_web/api_route_authority.ex modified +3 -1

@@ -63,7 +63,9 @@ defmodule OpenAgentsWeb.ApiRouteAuthority do

63 63
      "get /api/v3/repos/:owner/:repo/projectsV2/:project_number" => :optional_bearer,
64 64
      "get /api/v3/repos/:owner/:repo/projectsV2/:project_number/items" => :optional_bearer,
65 65
      "get /api/v3/repos/:owner/:repo/projectsV2/:project_number/fields" => :optional_bearer,
66
      # pipe_through :forge_write_api — scoped bearer required.
66
      # Scoped bearer pipelines require the route-specific token authority.
67
      "get /api/v3/chat/events" => :required_bearer,
68
      "post /api/v3/chat/turns" => :required_bearer,
67 69
      "delete /api/v3/repos/:owner/:repo" => :required_bearer,
68 70
      "delete /api/v3/repos/:owner/:repo/issues/:issue_number/assignees" => :required_bearer,
69 71
      "delete /api/v3/repos/:owner/:repo/issues/:issue_number/labels/:name" => :required_bearer,
lib/openagents_web/controllers/chat_turn_controller.ex added +39

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

1
defmodule OpenAgentsWeb.ChatTurnController do
2
  @moduledoc "Bearer-authenticated access to an account's durable chat turns."
3
4
  use OpenAgentsWeb, :controller
5
6
  alias OpenAgents.Chat.AccountTurns
7
8
  def index(conn, _params) do
9
    json(conn, %{"events" => AccountTurns.list_events(conn.assigns.current_user)})
10
  end
11
12
  def create(conn, %{"message" => message} = params) when is_binary(message) do
13
    case AccountTurns.submit(conn.assigns.current_user, message, reasoning: params["reasoning"]) do
14
      {:ok, turn} -> conn |> put_status(:accepted) |> json(%{"turn" => turn})
15
      {:error, reason} -> submit_error(conn, reason)
16
    end
17
  end
18
19
  def create(conn, _params), do: error(conn, :unprocessable_entity, "invalid_message")
20
21
  defp submit_error(conn, :empty_message),
22
    do: error(conn, :unprocessable_entity, "empty_message")
23
24
  defp submit_error(conn, :message_too_long),
25
    do: error(conn, :unprocessable_entity, "message_too_long")
26
27
  defp submit_error(conn, :invalid_message),
28
    do: error(conn, :unprocessable_entity, "invalid_message")
29
30
  defp submit_error(conn, :rate_limited), do: error(conn, :too_many_requests, "rate_limited")
31
  defp submit_error(conn, :turn_in_progress), do: error(conn, :conflict, "turn_in_progress")
32
33
  defp submit_error(conn, :turn_start_failed),
34
    do: error(conn, :service_unavailable, "turn_start_failed")
35
36
  defp submit_error(conn, _reason), do: error(conn, :unprocessable_entity, "turn_not_created")
37
38
  defp error(conn, status, code), do: conn |> put_status(status) |> json(%{"error" => code})
39
end
lib/openagents_web/live/api_tokens_live.ex modified +4 -2

@@ -21,7 +21,7 @@ defmodule OpenAgentsWeb.ApiTokensLive do

21 21
  def handle_event("create", %{"api_token" => params}, socket) do
22 22
    case ApiTokens.create(socket.assigns.current_user, %{
23 23
           "name" => params["name"],
24
           "scopes" => ["forge:write"],
24
           "scopes" => ["chat:account", "forge:write"],
25 25
           "lifetime_days" => params["lifetime_days"]
26 26
         }) do
27 27
      {:ok, token, plaintext} ->

@@ -55,7 +55,9 @@ defmodule OpenAgentsWeb.ApiTokensLive do

55 55
        <header class="space-y-2">
56 56
          <h1 class="text-3xl font-semibold tracking-tight">API tokens</h1>
57 57
          <p class="text-muted-foreground">
58
            Create an expiring credential for CLI forge writes. Tokens carry only <code>forge:write</code>, are stored as digests, and are shown once.
58
            Create an expiring credential for account chat and Forge operations. Tokens carry
59
            <code>chat:account</code>
60
            and <code>forge:write</code>, are stored as digests, and are shown once.
59 61
          </p>
60 62
        </header>
61 63
lib/openagents_web/live/chat_placeholder_live.ex modified +68 -69

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

9 9
10 10
  use OpenAgentsWeb, :live_view
11 11
12
  alias OpenAgents.Chat.OpenRouter
12
  alias OpenAgents.Chat.{AccountTurns, OpenRouter}
13 13
14 14
  @reasoning_options [
15 15
    {"Reasoning off", "none"},

@@ -52,12 +52,14 @@ defmodule OpenAgentsWeb.ChatPlaceholderLive do

52 52
53 53
  @impl true
54 54
  def mount(_params, _session, socket) do
55
    messages = AccountTurns.list_messages(socket.assigns.current_user)
56
55 57
    {:ok,
56 58
     socket
57 59
     |> assign(:page_title, "Chat")
58 60
     |> assign(:form, composer_form())
59 61
     |> assign(:reasoning_options, @reasoning_options)
60
     |> assign(:messages, [])
62
     |> assign(:messages, messages)
61 63
     |> assign(:assistant_response, nil)
62 64
     |> assign(:assistant_reasoning, nil)
63 65
     |> assign(:assistant_tool_calls, [])

@@ -124,6 +126,26 @@ defmodule OpenAgentsWeb.ChatPlaceholderLive do

124 126
    end
125 127
  end
126 128
129
  def handle_info({:account_chat_completed, stream_id, _result}, socket) do
130
    case socket.assigns do
131
      %{stream_id: ^stream_id} ->
132
        {:noreply,
133
         socket
134
         |> assign(:messages, AccountTurns.list_messages(socket.assigns.current_user))
135
         |> assign(:assistant_response, nil)
136
         |> assign(:assistant_reasoning, nil)
137
         |> assign(:assistant_tool_calls, [])
138
         |> assign(:assistant_blocks, [])
139
         |> assign(:reasoning_started_at, nil)
140
         |> assign(:streaming?, false)
141
         |> assign(:stream_task_ref, nil)
142
         |> assign(:stream_id, nil)}
143
144
      _stale_run ->
145
        {:noreply, socket}
146
    end
147
  end
148
127 149
  def handle_info(
128 150
        {:openrouter_stream_event, stream_id, {:tool_call_completed, tool_result}},
129 151
        socket

@@ -357,49 +379,51 @@ defmodule OpenAgentsWeb.ChatPlaceholderLive do

357 379
    to_form(%{"message" => "", "reasoning" => reasoning}, as: :chat)
358 380
  end
359 381
360
  defp local_chat_request(messages, message, reasoning) do
361
    %{
362
      "model" => OpenRouter.default_model(),
363
      "models" => ["openrouter/free"],
364
      "reasoning" => reasoning,
365
      "messages" =>
366
        Enum.map(messages, &provider_message/1) ++ [%{"role" => "user", "content" => message}]
367
    }
368
  end
369
370 382
  defp submit_message(socket, message, reasoning) do
371
    stream_id = System.unique_integer([:positive, :monotonic])
372
    owner = self()
373
374
    request =
375
      local_chat_request(
376
        Enum.filter(socket.assigns.messages, fn message -> message.history? end),
377
        message,
378
        reasoning
379
      )
380
381
    task =
382
      Task.Supervisor.async_nolink(OpenAgents.ProviderTaskSupervisor, fn ->
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
        )
388
      end)
389
390
    {:noreply,
391
     socket
392
     |> assign(:form, composer_form(reasoning))
393
     |> update(:messages, &(&1 ++ [user_message(stream_id, message)]))
394
     |> assign(:assistant_response, "")
395
     |> assign(:assistant_reasoning, nil)
396
     |> assign(:assistant_tool_calls, [])
397
     |> assign(:assistant_blocks, [reasoning_block("")])
398
     |> assign(:reasoning_started_at, System.monotonic_time(:second))
399
     |> assign(:streaming?, true)
400
     |> assign(:stream_task_ref, task.ref)
401
     |> assign(:stream_id, stream_id)
402
     |> push_event("chat-preview:clear", %{})}
383
    case AccountTurns.submit(socket.assigns.current_user, message,
384
           reasoning: reasoning,
385
           subscriber: self()
386
         ) do
387
      {:ok, run} ->
388
        {:noreply,
389
         socket
390
         |> assign(:form, composer_form(reasoning))
391
         |> assign(:messages, AccountTurns.list_messages(socket.assigns.current_user))
392
         |> assign(:assistant_response, "")
393
         |> assign(:assistant_reasoning, nil)
394
         |> assign(:assistant_tool_calls, [])
395
         |> assign(:assistant_blocks, [reasoning_block("")])
396
         |> assign(:reasoning_started_at, System.monotonic_time(:second))
397
         |> assign(:streaming?, true)
398
         |> assign(:stream_task_ref, nil)
399
         |> assign(:stream_id, run["id"])
400
         |> push_event("chat-preview:clear", %{})}
401
402
      {:error, reason} ->
403
        {:noreply,
404
         socket
405
         |> update(:messages, fn messages ->
406
           messages ++
407
             [
408
               user_message(Ecto.UUID.generate(), message),
409
               %{
410
                 id: Ecto.UUID.generate(),
411
                 role: :assistant,
412
                 content: "",
413
                 completion: nil,
414
                 error: error_message(reason),
415
                 history?: false,
416
                 provider_message_id: nil,
417
                 provider_status: nil,
418
                 provider_reasoning_items: nil,
419
                 reasoning: nil,
420
                 reasoning_duration: nil,
421
                 tool_calls: [],
422
                 blocks: []
423
               }
424
             ]
425
         end)}
426
    end
403 427
  end
404 428
405 429
  defp user_message(id, content),

@@ -444,31 +468,6 @@ defmodule OpenAgentsWeb.ChatPlaceholderLive do

444 468
    update(socket, :messages, &(&1 ++ [assistant]))
445 469
  end
446 470
447
  defp provider_message(%{role: :assistant} = message) do
448
    %{"role" => "assistant", "content" => message.content}
449
    |> maybe_put_provider_message_id(message.provider_message_id)
450
    |> maybe_put_provider_status(message.provider_status)
451
    |> maybe_put_provider_reasoning_items(message.provider_reasoning_items)
452
  end
453
454
  defp provider_message(%{role: role, content: content}),
455
    do: %{"role" => Atom.to_string(role), "content" => content}
456
457
  defp maybe_put_provider_message_id(message, id) when is_binary(id),
458
    do: Map.put(message, "id", id)
459
460
  defp maybe_put_provider_message_id(message, _id), do: message
461
462
  defp maybe_put_provider_status(message, status) when is_binary(status),
463
    do: Map.put(message, "status", status)
464
465
  defp maybe_put_provider_status(message, _status), do: message
466
467
  defp maybe_put_provider_reasoning_items(message, items) when is_list(items) and items != [],
468
    do: Map.put(message, "reasoning_items", items)
469
470
  defp maybe_put_provider_reasoning_items(message, _items), do: message
471
472 471
  defp error_message(:missing_api_key), do: "OpenRouter is not configured for this environment."
473 472
  defp error_message(:rate_limited), do: "OpenRouter is rate-limited. Try again later."
474 473
lib/openagents_web/route_authority.ex modified +6

@@ -210,6 +210,12 @@ defmodule OpenAgentsWeb.RouteAuthority do

210 210
        false
211 211
      )
212 212
213
  defp policy(%{path: "/api/v3/chat/events", verb: verb}) when verb in [:get, :head],
214
    do: declaration(:authenticated_api, "first-party bearer token", "chat:account", false)
215
216
  defp policy(%{path: "/api/v3/chat/turns", verb: :post}),
217
    do: declaration(:authenticated_api, "first-party bearer token", "chat:account", true)
218
213 219
  defp policy(%{path: path, verb: verb})
214 220
       when path in @optional_forge_read_paths and verb in [:get, :head],
215 221
       do:
lib/openagents_web/router.ex modified +13

@@ -42,6 +42,12 @@ defmodule OpenAgentsWeb.Router do

42 42
    plug OpenAgentsWeb.Plugs.ApiTokenAuth, scope: "forge:write"
43 43
  end
44 44
45
  pipeline :chat_account_api do
46
    plug :accepts, ["json"]
47
    plug OpenAgentsWeb.Plugs.RequestOrigin
48
    plug OpenAgentsWeb.Plugs.ApiTokenAuth, scope: "chat:account"
49
  end
50
45 51
  pipeline :optional_forge_api do
46 52
    plug :accepts, ["json"]
47 53
    plug OpenAgentsWeb.Plugs.RequestOrigin

@@ -269,6 +275,13 @@ defmodule OpenAgentsWeb.Router do

269 275
    get "/repos/:owner/:repo/assignees/:assignee", AssigneeController, :show
270 276
  end
271 277
278
  scope "/api/v3", OpenAgentsWeb do
279
    pipe_through :chat_account_api
280
281
    get "/chat/events", ChatTurnController, :index
282
    post "/chat/turns", ChatTurnController, :create
283
  end
284
272 285
  scope "/api/v3", OpenAgentsWeb do
273 286
    pipe_through :forge_write_api
274 287
priv/migration_lineages/prior-2026-08-19.json modified +3 -1

@@ -229,7 +229,9 @@

229 229
    20260822082657,
230 230
    20260822120326,
231 231
    20260822132511,
232
    20260822153929
232
    20260822153929,
233
    20260822234211,
234
    20260823000143
233 235
  ],
234 236
  "required_tables": [
235 237
    "users",
priv/repo/migrations/20260822234211_create_account_chat_runs_and_events.exs added +53

@@ -0,0 +1,53 @@

1
defmodule OpenAgents.Repo.Migrations.CreateAccountChatRunsAndEvents do
2
  use Ecto.Migration
3
4
  def change do
5
    create table(:account_chat_runs, primary_key: false) do
6
      add :id, :binary_id, primary_key: true
7
8
      add :conversation_id, references(:conversations, type: :binary_id, on_delete: :delete_all),
9
        null: false
10
11
      add :status, :string, null: false
12
      add :reasoning_effort, :string, null: false
13
      add :user_content, :text, null: false
14
      add :assistant_content, :text
15
      add :completion, :map
16
      add :error, :text
17
      add :started_at, :utc_datetime_usec, null: false
18
      add :completed_at, :utc_datetime_usec
19
      timestamps(type: :utc_datetime_usec)
20
    end
21
22
    create index(:account_chat_runs, [:conversation_id, :inserted_at])
23
24
    create unique_index(:account_chat_runs, [:conversation_id],
25
             where: "status = 'streaming'",
26
             name: :account_chat_runs_one_streaming_per_conversation
27
           )
28
29
    create constraint(:account_chat_runs, :account_chat_runs_status,
30
             check: "status IN ('streaming', 'completed', 'failed')"
31
           )
32
33
    create table(:account_chat_events, primary_key: false) do
34
      add :id, :binary_id, primary_key: true
35
36
      add :run_id, references(:account_chat_runs, type: :binary_id, on_delete: :delete_all),
37
        null: false
38
39
      add :sequence, :integer, null: false
40
      add :kind, :string, null: false
41
      add :payload, :map, null: false, default: %{}
42
      add :observed_at, :utc_datetime_usec, null: false
43
      timestamps(type: :utc_datetime_usec, updated_at: false)
44
    end
45
46
    create unique_index(:account_chat_events, [:run_id, :sequence])
47
    create index(:account_chat_events, [:run_id, :observed_at])
48
49
    create constraint(:account_chat_events, :account_chat_events_positive_sequence,
50
             check: "sequence > 0"
51
           )
52
  end
53
end
priv/repo/migrations/20260823000143_allow_chat_account_api_token_scope.exs added +19

@@ -0,0 +1,19 @@

1
defmodule OpenAgents.Repo.Migrations.AllowChatAccountApiTokenScope do
2
  use Ecto.Migration
3
4
  def up do
5
    drop constraint(:api_tokens, :api_tokens_scopes_allowed)
6
7
    create constraint(:api_tokens, :api_tokens_scopes_allowed,
8
             check: "scopes <@ ARRAY['chat:account', 'forge:write']::varchar[]"
9
           )
10
  end
11
12
  def down do
13
    drop constraint(:api_tokens, :api_tokens_scopes_allowed)
14
15
    create constraint(:api_tokens, :api_tokens_scopes_allowed,
16
             check: "scopes <@ ARRAY['forge:write']::varchar[]"
17
           )
18
  end
19
end
test/fixtures/openrouter/responses_tool_call.sse modified +9 -5

@@ -8,14 +8,18 @@ data: {"type":"response.reasoning_summary_text.delta","response_id":"resp_demo",

8 8
9 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 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"}}
11
data: {"type":"response.output_item.added","response_id":"resp_demo","output_index":1,"item":{"type":"message","id":"msg_tool_preamble","role":"assistant","status":"in_progress","content":[]}}
12 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}"}
13
data: {"type":"response.output_item.done","response_id":"resp_demo","output_index":1,"item":{"type":"message","id":"msg_tool_preamble","role":"assistant","status":"completed","content":[{"type":"output_text","text":"I will inspect the connected repository.","annotations":[]}]}}
14 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}"}
15
data: {"type":"response.output_item.added","response_id":"resp_demo","output_index":2,"item":{"type":"function_call","id":"fc_demo","call_id":"call_demo","name":"read_repository_file","arguments":"","status":"in_progress"}}
16 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"}}
17
data: {"type":"response.function_call_arguments.delta","response_id":"resp_demo","item_id":"fc_demo","output_index":2,"delta":"{\"repository\":\"OpenAgentsInc/openagents.com\",\"path\":\"README.md\",\"ref\":\"\"}"}
18 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}}}
19
data: {"type":"response.function_call_arguments.done","response_id":"resp_demo","item_id":"fc_demo","output_index":2,"arguments":"{\"repository\":\"OpenAgentsInc/openagents.com\",\"path\":\"README.md\",\"ref\":\"\"}"}
20
21
data: {"type":"response.output_item.done","response_id":"resp_demo","output_index":2,"item":{"type":"function_call","id":"fc_demo","call_id":"call_demo","name":"read_repository_file","arguments":"{\"repository\":\"OpenAgentsInc/openagents.com\",\"path\":\"README.md\",\"ref\":\"\"}","status":"completed"}}
22
23
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":"message","id":"msg_tool_preamble","role":"assistant","status":"completed","content":[{"type":"output_text","text":"I will inspect the connected repository.","annotations":[]}]},{"type":"function_call","id":"fc_demo","call_id":"call_demo","name":"read_repository_file","arguments":"{\"repository\":\"OpenAgentsInc/openagents.com\",\"path\":\"README.md\",\"ref\":\"\"}","status":"completed"}],"usage":{"input_tokens":18,"output_tokens":11,"total_tokens":29}}}
20 24
21 25
data: [DONE]
test/openagents/chat/account_turns_test.exs added +136

@@ -0,0 +1,136 @@

1
defmodule OpenAgents.Chat.AccountTurnsTest do
2
  use OpenAgents.DataCase
3
4
  alias OpenAgents.Chat.AccountTurns
5
6
  test "submit journals the provider lifecycle and projects the same ordered messages" do
7
    user = repository_user_fixture("account-chat-journal")
8
9
    provider_output = [
10
      %{
11
        "type" => "reasoning",
12
        "id" => "reasoning-1",
13
        "encrypted_content" => "opaque",
14
        "summary" => [%{"type" => "summary_text", "text" => "Check the repository."}]
15
      },
16
      %{
17
        "type" => "function_call",
18
        "id" => "function-1",
19
        "call_id" => "call-1",
20
        "name" => "read_repository_file",
21
        "arguments" => ~s({"path":"README.md"}),
22
        "status" => "completed"
23
      },
24
      %{
25
        "type" => "message",
26
        "id" => "message-1",
27
        "role" => "assistant",
28
        "status" => "completed",
29
        "content" => [
30
          %{
31
            "type" => "output_text",
32
            "text" => "The repository is available.",
33
            "annotations" => []
34
          }
35
        ]
36
      }
37
    ]
38
39
    streamer = fn _request, callback, _options ->
40
      callback.({:reasoning_delta, "Check the repository."})
41
42
      callback.(
43
        {:tool_call_started,
44
         %{
45
           "call_id" => "call-1",
46
           "name" => "read_repository_file",
47
           "arguments" => ~s({"path":"README.md"})
48
         }}
49
      )
50
51
      callback.(
52
        {:tool_call_completed, %{"call_id" => "call-1", "output" => ~s({"content":"OpenAgents"})}}
53
      )
54
55
      callback.({:text_delta, "The repository is available."})
56
57
      {:ok,
58
       %{
59
         "assistant_content" => "The repository is available.",
60
         "assistant_message_id" => "response-1",
61
         "reasoning_summary" => "Check the repository.",
62
         "reasoning_items" => [%{"type" => "reasoning", "encrypted_content" => "opaque"}],
63
         "output" => provider_output
64
       }}
65
    end
66
67
    assert {:ok, %{"id" => run_id, "status" => "streaming"}} =
68
             AccountTurns.submit(user, "Read the README.",
69
               subscriber: self(),
70
               streamer: streamer
71
             )
72
73
    assert_receive {:account_chat_completed, ^run_id, {:ok, _completion}}
74
75
    events = AccountTurns.list_events(user)
76
77
    assert Enum.map(events, & &1["type"]) == [
78
             "user_message",
79
             "reasoning_delta",
80
             "tool_call_started",
81
             "tool_call_completed",
82
             "text_delta",
83
             "response_completed"
84
           ]
85
86
    assert Enum.map(events, & &1["sequence"]) == Enum.to_list(1..6)
87
    assert get_in(List.last(events), ["payload", "reasoning_items"]) != nil
88
89
    assert [user_message, assistant_message] = AccountTurns.list_messages(user)
90
    assert user_message.content == "Read the README."
91
    assert assistant_message.content == "The repository is available."
92
    assert assistant_message.history?
93
94
    assert [%{name: "read_repository_file", state: "output-available"}] =
95
             assistant_message.tool_calls
96
97
    test_process = self()
98
99
    follow_up_streamer = fn request, _callback, _options ->
100
      send(test_process, {:provider_request, request})
101
      {:ok, %{"assistant_content" => "Continued."}}
102
    end
103
104
    assert {:ok, %{"id" => follow_up_run_id}} =
105
             AccountTurns.submit(user, "Continue.",
106
               subscriber: self(),
107
               streamer: follow_up_streamer
108
             )
109
110
    assert_receive {:provider_request, request}
111
112
    assert %{"role" => "assistant", "provider_output" => ^provider_output} =
113
             Enum.at(request["messages"], 1)
114
115
    assert_receive {:account_chat_completed, ^follow_up_run_id, {:ok, _completion}}
116
  end
117
118
  test "events are isolated by account" do
119
    user = repository_user_fixture("account-chat-owner")
120
    other_user = repository_user_fixture("account-chat-other")
121
122
    streamer = fn _request, _callback, _options ->
123
      {:ok, %{"assistant_content" => "Done."}}
124
    end
125
126
    assert {:ok, %{"id" => run_id}} =
127
             AccountTurns.submit(user, "Private message.",
128
               subscriber: self(),
129
               streamer: streamer
130
             )
131
132
    assert_receive {:account_chat_completed, ^run_id, {:ok, _completion}}
133
    assert AccountTurns.list_events(user) != []
134
    assert AccountTurns.list_events(other_user) == []
135
  end
136
end
test/openagents/chat/open_router_test.exs modified +172 -59

@@ -2,29 +2,101 @@ defmodule OpenAgents.Chat.OpenRouterTest do

2 2
  use ExUnit.Case, async: true
3 3
4 4
  alias OpenAgents.Chat.OpenRouter
5
  alias OpenAgents.Modules.Metadata
6
  alias OpenAgents.Tools.{ExecutionContext, ExecutionResult, Registry, Tool}
5 7
6
  defmodule RepositoryFileStub do
8
  defmodule RepositoryFileToolStub do
7 9
    @moduledoc false
10
    @behaviour OpenAgents.Tools.Tool
11
12
    @impl true
13
    def specification do
14
      %Tool{
15
        module_id: "sarah.tool.openrouter_repository_file_test",
16
        name: "read_repository_file",
17
        version: 1,
18
        description: "Reads a file from a connected repository for an OpenRouter adapter test.",
19
        input_schema: %{
20
          "type" => "object",
21
          "properties" => %{
22
            "repository" => %{"type" => "string"},
23
            "path" => %{"type" => "string"},
24
            "ref" => %{"type" => "string"}
25
          },
26
          "required" => ["repository", "path", "ref"],
27
          "additionalProperties" => false
28
        },
29
        output_schema: %{
30
          "type" => "object",
31
          "properties" => %{
32
            "repository" => %{"type" => "string"},
33
            "ref" => %{"type" => "string"},
34
            "path" => %{"type" => "string"},
35
            "content" => %{"type" => "string"},
36
            "size_bytes" => %{"type" => "integer"},
37
            "truncated" => %{"type" => "boolean"}
38
          },
39
          "required" => [
40
            "repository",
41
            "ref",
42
            "path",
43
            "content",
44
            "size_bytes",
45
            "truncated"
46
          ],
47
          "additionalProperties" => false
48
        },
49
        side_effect: :read_only,
50
        required_scope: "browser_conversation",
51
        required_authority: "repository.read",
52
        executor: %{id: "sarah.local", disclosure: "OpenRouter adapter test executor"},
53
        maintainer: "OpenAgents",
54
        attribution: ["OpenAgentsInc/openagents.com"],
55
        policy_facets: %{"privacy" => "browser_scoped", "residency" => "host"},
56
        module_metadata:
57
          Metadata.first_party("repository.read", "browser_conversation",
58
            effect: :read_only,
59
            privacy: "browser_scoped",
60
            residency: "host"
61
          ),
62
        timeout_ms: 100,
63
        maximum_input_bytes: 1_024,
64
        maximum_output_bytes: 2_048,
65
        implementation: __MODULE__
66
      }
67
    end
8 68
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
69
    @impl true
70
    def execute(
71
          %{"repository" => "OpenAgentsInc/openagents.com", "path" => "README.md"},
72
          %ExecutionContext{owner_user_id: "user-test"}
73
        ) do
16 74
      {: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
75
       %ExecutionResult{
76
         result: %{
77
           "repository" => "OpenAgentsInc/openagents.com",
78
           "ref" => "main",
79
           "path" => "README.md",
80
           "content" => "# OpenAgents\n",
81
           "size_bytes" => 13,
82
           "truncated" => false
83
         }
24 84
       }}
25 85
    end
26 86
  end
27 87
88
  defp tool_execution_context do
89
    %ExecutionContext{
90
      scope: "browser_conversation",
91
      scope_ref: "conversation:openrouter-test",
92
      authorities: MapSet.new(["repository.read"]),
93
      surface: "text",
94
      conversation_id: "openrouter-test",
95
      owner_user_id: "user-test",
96
      owner_visitor_id: "visitor-test"
97
    }
98
  end
99
28 100
  setup {Req.Test, :verify_on_exit!}
29 101
30 102
  test "sends an OpenRouter-compatible Ox Alpha request with a free fallback" do

@@ -145,7 +217,7 @@ defmodule OpenAgents.Chat.OpenRouterTest do

145 217
            "delta" => " world"
146 218
          }) <>
147 219
          sse(%{
148
            "type" => "response.done",
220
            "type" => "response.completed",
149 221
            "response" => %{
150 222
              "id" => "resp_test",
151 223
              "object" => "response",

@@ -240,7 +312,7 @@ defmodule OpenAgents.Chat.OpenRouterTest do

240 312
241 313
      body =
242 314
        sse(%{
243
          "type" => "response.done",
315
          "type" => "response.completed",
244 316
          "response" => %{
245 317
            "object" => "response",
246 318
            "model" => "stealth/ox-alpha",

@@ -372,7 +444,7 @@ defmodule OpenAgents.Chat.OpenRouterTest do

372 444
             )
373 445
  end
374 446
375
  test "completes a streamed response when response.done contains only metadata" do
447
  test "accepts legacy response.done when the terminal response contains only metadata" do
376 448
    Req.Test.expect(__MODULE__, fn conn ->
377 449
      body =
378 450
        sse(%{

@@ -462,7 +534,7 @@ defmodule OpenAgents.Chat.OpenRouterTest do

462 534
            }
463 535
          }) <>
464 536
          sse(%{
465
            "type" => "response.done",
537
            "type" => "response.completed",
466 538
            "response" => %{
467 539
              "id" => "resp_canonical",
468 540
              "object" => "response",

@@ -501,10 +573,7 @@ defmodule OpenAgents.Chat.OpenRouterTest do

501 573
    Req.Test.expect(__MODULE__, fn conn ->
502 574
      assert conn.request_path == "/api/v1/responses"
503 575
504
      assert Enum.map(conn.body_params["tools"], & &1["name"]) == [
505
               "read_repository_file",
506
               "list_repository_directory"
507
             ]
576
      assert Enum.map(conn.body_params["tools"], & &1["name"]) == ["read_repository_file"]
508 577
509 578
      assert conn.body_params["instructions"] =~
510 579
               "Never claim that a file or directory exists unless a tool result confirms it"

@@ -526,40 +595,40 @@ defmodule OpenAgents.Chat.OpenRouterTest do

526 595
    Req.Test.expect(__MODULE__, fn conn ->
527 596
      assert conn.request_path == "/api/v1/responses"
528 597
529
      assert [
530
               %{"type" => "message", "role" => "user"},
531
               %{
532
                 "type" => "reasoning",
533
                 "id" => "rs_demo",
534
                 "status" => "completed",
535
                 "summary" => [
598
      [user_input | provider_and_tool_output] = conn.body_params["input"]
599
600
      assert user_input == %{
601
               "type" => "message",
602
               "role" => "user",
603
               "content" => [%{"type" => "input_text", "text" => "Summarize the README."}]
604
             }
605
606
      assert Enum.take(provider_and_tool_output, 3) == expected_tool_provider_output()
607
608
      assert %{
609
               "type" => "function_call_output",
610
               "call_id" => "call_demo",
611
               "output" => tool_output
612
             } = List.last(provider_and_tool_output)
613
614
      assert provider_and_tool_output ==
615
               expected_tool_provider_output() ++
616
                 [
536 617
                   %{
537
                     "type" => "summary_text",
538
                     "text" => "The user asked to read a repository file."
618
                     "type" => "function_call_output",
619
                     "call_id" => "call_demo",
620
                     "output" => tool_output
539 621
                   }
540
                 ],
541
                 "encrypted_content" => "encrypted-demo-reasoning"
542
               },
543
               %{
544
                 "type" => "function_call",
545
                 "id" => "fc_demo",
546
                 "call_id" => "call_demo",
547
                 "name" => "read_repository_file",
548
                 "arguments" =>
549
                   "{\"repository\":\"OpenAgentsInc/openagents.com\",\"path\":\"README.md\",\"ref\":null}",
550
                 "status" => "completed"
551
               },
552
               %{
553
                 "type" => "function_call_output",
554
                 "call_id" => "call_demo",
555
                 "output" => tool_output
556
               }
557
             ] = conn.body_params["input"]
622
                 ]
558 623
559 624
      assert %{
560
               "repository" => "OpenAgentsInc/openagents.com",
561
               "path" => "README.md",
562
               "content" => "# OpenAgents\n"
625
               "schema" => "sarah.tool_outcome.v1",
626
               "status" => "succeeded",
627
               "result" => %{
628
                 "repository" => "OpenAgentsInc/openagents.com",
629
                 "path" => "README.md",
630
                 "content" => "# OpenAgents\n"
631
               }
563 632
             } = Jason.decode!(tool_output)
564 633
565 634
      body =

@@ -596,6 +665,7 @@ defmodule OpenAgents.Chat.OpenRouterTest do

596 665
    end)
597 666
598 667
    parent = self()
668
    assert {:ok, tool_registry_snapshot} = Registry.build([RepositoryFileToolStub])
599 669
600 670
    assert {:ok, %{"assistant_content" => "OpenAgents is an agent platform."}} =
601 671
             OpenRouter.stream(

@@ -605,8 +675,8 @@ defmodule OpenAgents.Chat.OpenRouterTest do

605 675
               },
606 676
               &send(parent, {:openrouter_event, &1}),
607 677
               api_key: "test-openrouter-key",
608
               tool_module: RepositoryFileStub,
609
               tool_context: %{user_id: "user-test"},
678
               tool_registry_snapshot: tool_registry_snapshot,
679
               tool_execution_context: tool_execution_context(),
610 680
               request_options: [plug: {Req.Test, __MODULE__}]
611 681
             )
612 682

@@ -618,18 +688,61 @@ defmodule OpenAgents.Chat.OpenRouterTest do

618 688
                       "call_id" => "call_demo",
619 689
                       "name" => "read_repository_file",
620 690
                       "arguments" =>
621
                         "{\"repository\":\"OpenAgentsInc/openagents.com\",\"path\":\"README.md\",\"ref\":null}"
691
                         "{\"repository\":\"OpenAgentsInc/openagents.com\",\"path\":\"README.md\",\"ref\":\"\"}"
622 692
                     }}}
623 693
624 694
    assert_receive {:openrouter_event,
625 695
                    {:tool_call_completed, %{"call_id" => "call_demo", "output" => tool_output}}}
626 696
627 697
    assert %{
628
             "repository" => "OpenAgentsInc/openagents.com",
629
             "path" => "README.md",
630
             "content" => "# OpenAgents\n"
698
             "schema" => "sarah.tool_outcome.v1",
699
             "status" => "succeeded",
700
             "result" => %{
701
               "repository" => "OpenAgentsInc/openagents.com",
702
               "path" => "README.md",
703
               "content" => "# OpenAgents\n"
704
             }
631 705
           } = Jason.decode!(tool_output)
632 706
  end
633 707
708
  defp expected_tool_provider_output do
709
    [
710
      %{
711
        "type" => "reasoning",
712
        "id" => "rs_demo",
713
        "status" => "completed",
714
        "summary" => [
715
          %{
716
            "type" => "summary_text",
717
            "text" => "The user asked to read a repository file."
718
          }
719
        ],
720
        "encrypted_content" => "encrypted-demo-reasoning"
721
      },
722
      %{
723
        "type" => "message",
724
        "id" => "msg_tool_preamble",
725
        "role" => "assistant",
726
        "status" => "completed",
727
        "content" => [
728
          %{
729
            "type" => "output_text",
730
            "text" => "I will inspect the connected repository.",
731
            "annotations" => []
732
          }
733
        ]
734
      },
735
      %{
736
        "type" => "function_call",
737
        "id" => "fc_demo",
738
        "call_id" => "call_demo",
739
        "name" => "read_repository_file",
740
        "arguments" =>
741
          "{\"repository\":\"OpenAgentsInc/openagents.com\",\"path\":\"README.md\",\"ref\":\"\"}",
742
        "status" => "completed"
743
      }
744
    ]
745
  end
746
634 747
  defp sse(event), do: "data: " <> Jason.encode!(event) <> "\n\n"
635 748
end
test/openagents/tools/admitted_catalog_test.exs added +105

@@ -0,0 +1,105 @@

1
defmodule OpenAgents.Tools.AdmittedCatalogTest do
2
  use ExUnit.Case, async: true
3
4
  alias OpenAgents.Tools.{
5
    AdmittedCatalog,
6
    ConnectedRepositoryList,
7
    ConnectedRepositoryRead,
8
    ExecutionContext,
9
    Registry,
10
    RepoWrite
11
  }
12
13
  test "does not advertise tools outside the captured authority and surface" do
14
    assert {:ok, snapshot} = Registry.build([ConnectedRepositoryRead, RepoWrite])
15
16
    context =
17
      execution_context("text", ["repository.read"], [
18
        approval_receipt("sarah.tool.repo_write.v1")
19
      ])
20
21
    names =
22
      snapshot
23
      |> AdmittedCatalog.provider_definitions(context, "read and write repository files",
24
        top_k: 10,
25
        always_include: ["read_repository_file", "repo_write"]
26
      )
27
      |> Enum.map(& &1.name)
28
29
    assert "read_repository_file" in names
30
    refute "repo_write" in names
31
32
    voice_names =
33
      snapshot
34
      |> AdmittedCatalog.realtime_catalog(
35
        execution_context("voice", ["repository.read", "repository.write"], [
36
          approval_receipt("sarah.tool.repo_write.v1")
37
        ]),
38
        "read and write repository files",
39
        top_k: 10,
40
        always_include: ["read_repository_file", "repo_write"]
41
      )
42
      |> Map.fetch!("tools")
43
      |> Enum.map(& &1["name"])
44
45
    assert "read_repository_file" in voice_names
46
    refute "repo_write" in voice_names
47
  end
48
49
  test "equivalent text and voice contexts advertise the same authorized tool names" do
50
    assert {:ok, snapshot} =
51
             Registry.build([ConnectedRepositoryRead, ConnectedRepositoryList])
52
53
    opts = [
54
      top_k: 10,
55
      always_include: ["read_repository_file", "list_repository_directory"]
56
    ]
57
58
    text_names =
59
      snapshot
60
      |> AdmittedCatalog.provider_definitions(
61
        execution_context("text", ["repository.read"]),
62
        "inspect connected repository files",
63
        opts
64
      )
65
      |> Enum.map(& &1.name)
66
      |> Enum.sort()
67
68
    voice_names =
69
      snapshot
70
      |> AdmittedCatalog.realtime_catalog(
71
        execution_context("voice", ["repository.read"]),
72
        "inspect connected repository files",
73
        opts
74
      )
75
      |> Map.fetch!("tools")
76
      |> Enum.map(& &1["name"])
77
      |> Enum.sort()
78
79
    assert text_names == ["list_repository_directory", "read_repository_file"]
80
    assert voice_names == text_names
81
  end
82
83
  defp execution_context(surface, authorities, approval_receipts \\ []) do
84
    %ExecutionContext{
85
      scope: "browser_conversation",
86
      scope_ref: "conversation:test",
87
      surface: surface,
88
      authorities: MapSet.new(authorities),
89
      approval_receipts: approval_receipts
90
    }
91
  end
92
93
  defp approval_receipt(module_id) do
94
    %{
95
      "schema" => "sarah.module_approval.v1",
96
      "approval_class" => "exact_current_user_consent",
97
      "module_id" => module_id,
98
      "version" => 1,
99
      "scope_ref" => "conversation:test",
100
      "explicit" => true,
101
      "actor_type" => "person",
102
      "receipt_ref" => "approval:test"
103
    }
104
  end
105
end
test/openagents/tools/connected_repository_tools_test.exs added +379

@@ -0,0 +1,379 @@

1
defmodule OpenAgents.Tools.ConnectedRepositoryToolsTest do
2
  use OpenAgents.DataCase, async: false
3
4
  import OpenAgents.AccountsFixtures
5
6
  alias OpenAgents.Forge.{Repos, WAL}
7
  alias OpenAgents.Repositories
8
9
  alias OpenAgents.Tools.{
10
    ConnectedRepositoryList,
11
    ConnectedRepositoryRead,
12
    ExecutionContext,
13
    Registry,
14
    Runner
15
  }
16
17
  setup do
18
    base =
19
      Path.join(
20
        System.tmp_dir!(),
21
        "connected-repository-tools-#{System.unique_integer([:positive])}"
22
      )
23
24
    previous_data = Application.get_env(:openagents, :forge_data_dir)
25
    previous_wal = Application.get_env(:openagents, :forge_wal_dir)
26
    Application.put_env(:openagents, :forge_data_dir, Path.join(base, "data"))
27
    Application.put_env(:openagents, :forge_wal_dir, Path.join(base, "wal"))
28
29
    on_exit(fn ->
30
      restore_env(:forge_data_dir, previous_data)
31
      restore_env(:forge_wal_dir, previous_wal)
32
      File.rm_rf(base)
33
    end)
34
35
    user = repository_user_fixture("connected-repository-reader")
36
    suffix = System.unique_integer([:positive, :monotonic])
37
    owner = "RegistryOrg#{suffix}"
38
    name = "registry-repo-#{suffix}"
39
40
    {:ok, repository} =
41
      Repositories.create_repository(%{
42
        owner: owner,
43
        name: name,
44
        visibility: "public",
45
        default_branch: "main"
46
      })
47
48
    seed_repository(repository.storage_key)
49
    {:ok, snapshot} = Registry.build([ConnectedRepositoryRead, ConnectedRepositoryList])
50
51
    %{
52
      context: context(user.id),
53
      repository: repository,
54
      repository_path: "#{owner}/#{name}",
55
      snapshot: snapshot,
56
      user: user
57
    }
58
  end
59
60
  test "reads a connected repository file through the registry runner", %{
61
    context: context,
62
    repository: repository,
63
    repository_path: repository_path,
64
    snapshot: snapshot
65
  } do
66
    assert {:ok, outcome} =
67
             run(snapshot, context, "read_repository_file", %{
68
               "repository" => repository_path,
69
               "path" => "README.md",
70
               "ref" => ""
71
             })
72
73
    assert outcome["status"] == "succeeded"
74
    assert outcome["result"]["repository"] == repository_path
75
    assert outcome["result"]["path"] == "README.md"
76
    assert outcome["result"]["content"] == "# OpenAgents\n\nConnected repository fixture.\n"
77
    assert outcome["target_receipt_refs"] == ["forge-repository:#{repository.id}"]
78
  end
79
80
  test "uses an empty file path to read the default README", %{
81
    context: context,
82
    repository: repository,
83
    snapshot: snapshot
84
  } do
85
    assert {:ok, outcome} =
86
             run(snapshot, context, "read_repository_file", %{
87
               "repository" => repository.name,
88
               "path" => "",
89
               "ref" => ""
90
             })
91
92
    assert outcome["status"] == "succeeded"
93
    assert outcome["result"]["path"] == "README.md"
94
  end
95
96
  test "normalizes string null defaults emitted by a provider", %{
97
    context: context,
98
    repository_path: repository_path,
99
    snapshot: snapshot
100
  } do
101
    assert {:ok, outcome} =
102
             run(snapshot, context, "read_repository_file", %{
103
               "repository" => repository_path,
104
               "path" => "null",
105
               "ref" => "null"
106
             })
107
108
    assert outcome["status"] == "succeeded"
109
    assert outcome["result"]["path"] == "README.md"
110
    assert outcome["result"]["ref"] == "main"
111
  end
112
113
  test "lists exact repository-relative directory paths", %{
114
    context: context,
115
    repository_path: repository_path,
116
    snapshot: snapshot
117
  } do
118
    assert {:ok, root} =
119
             run(snapshot, context, "list_repository_directory", %{
120
               "repository" => repository_path,
121
               "path" => "",
122
               "ref" => "main"
123
             })
124
125
    assert root["status"] == "succeeded"
126
    assert Enum.any?(root["result"]["entries"], &match?(%{"path" => "README.md"}, &1))
127
    assert Enum.any?(root["result"]["entries"], &match?(%{"path" => "docs"}, &1))
128
129
    assert {:ok, nested} =
130
             run(snapshot, context, "list_repository_directory", %{
131
               "repository" => repository_path,
132
               "path" => "docs/runbooks",
133
               "ref" => "main"
134
             })
135
136
    assert nested["result"]["entries"] == [
137
             %{
138
               "name" => "production.md",
139
               "path" => "docs/runbooks/production.md",
140
               "type" => "file",
141
               "size_bytes" => 54
142
             }
143
           ]
144
  end
145
146
  test "rejects traversal before it reaches Forge.Browse", %{
147
    context: context,
148
    repository_path: repository_path,
149
    snapshot: snapshot
150
  } do
151
    assert {:ok, outcome} =
152
             run(snapshot, context, "read_repository_file", %{
153
               "repository" => repository_path,
154
               "path" => "../secrets",
155
               "ref" => "main"
156
             })
157
158
    assert outcome["status"] == "failed"
159
    assert outcome["error"]["code"] == "invalid_repository_path"
160
  end
161
162
  test "rejects sensitive credential paths before they reach Forge.Browse", %{
163
    context: context,
164
    repository_path: repository_path,
165
    snapshot: snapshot
166
  } do
167
    for path <- [".env", "config/credentials.json", "keys/deploy.pem"] do
168
      assert {:ok, outcome} =
169
               run(snapshot, context, "read_repository_file", %{
170
                 "repository" => repository_path,
171
                 "path" => path,
172
                 "ref" => "main"
173
               })
174
175
      assert outcome["status"] == "failed"
176
      assert outcome["error"]["code"] == "sensitive_repository_path"
177
    end
178
  end
179
180
  test "does not disclose a private repository without visible access", %{
181
    context: context,
182
    snapshot: snapshot
183
  } do
184
    {:ok, private_repository} =
185
      Repositories.create_repository(%{
186
        owner: "RegistryPrivateOrg",
187
        name: "private-repo",
188
        visibility: "private",
189
        default_branch: "main"
190
      })
191
192
    seed_repository(private_repository.storage_key)
193
194
    assert {:ok, outcome} =
195
             run(snapshot, context, "read_repository_file", %{
196
               "repository" => "RegistryPrivateOrg/private-repo",
197
               "path" => "README.md",
198
               "ref" => "main"
199
             })
200
201
    assert outcome["status"] == "failed"
202
    assert outcome["error"]["code"] == "repository_not_found"
203
    assert outcome["error"]["message"] == "The repository does not exist or you cannot access it."
204
  end
205
206
  test "reads a private repository that is visible through membership", %{
207
    context: context,
208
    snapshot: snapshot,
209
    user: user
210
  } do
211
    {:ok, private_repository} =
212
      Repositories.create_repository(%{
213
        owner: "RegistryMemberOrg",
214
        name: "member-repo",
215
        visibility: "private",
216
        default_branch: "main"
217
      })
218
219
    seed_repository(private_repository.storage_key)
220
    assert {:ok, _membership} = Repositories.add_member(private_repository, user, "viewer")
221
222
    assert {:ok, outcome} =
223
             run(snapshot, context, "read_repository_file", %{
224
               "repository" => "RegistryMemberOrg/member-repo",
225
               "path" => "README.md",
226
               "ref" => "main"
227
             })
228
229
    assert outcome["status"] == "succeeded"
230
    assert outcome["result"]["content"] == "# OpenAgents\n\nConnected repository fixture.\n"
231
  end
232
233
  test "requires repository authority and an authenticated conversation owner", %{
234
    context: context,
235
    repository_path: repository_path,
236
    snapshot: snapshot
237
  } do
238
    assert {:ok, refused} =
239
             run(snapshot, %{context | authorities: MapSet.new()}, "read_repository_file", %{
240
               "repository" => repository_path,
241
               "path" => "README.md",
242
               "ref" => "main"
243
             })
244
245
    assert refused["status"] == "refused"
246
    assert refused["error"]["code"] == "authority_refused"
247
248
    assert {:ok, unauthenticated} =
249
             run(snapshot, %{context | owner_user_id: nil}, "read_repository_file", %{
250
               "repository" => repository_path,
251
               "path" => "README.md",
252
               "ref" => "main"
253
             })
254
255
    assert unauthenticated["status"] == "failed"
256
    assert unauthenticated["error"]["code"] == "repository_authentication_required"
257
  end
258
259
  defp context(user_id) do
260
    %ExecutionContext{
261
      scope: "browser_conversation",
262
      scope_ref: "conversation:connected-repository-test",
263
      authorities: MapSet.new(["repository.read"]),
264
      owner_user_id: user_id
265
    }
266
  end
267
268
  defp run(snapshot, context, name, arguments) do
269
    Runner.run(
270
      snapshot,
271
      %{
272
        call_id: "call:#{System.unique_integer([:positive])}",
273
        name: name,
274
        version: 1,
275
        raw_arguments: Jason.encode!(arguments)
276
      },
277
      context
278
    )
279
  end
280
281
  defp seed_repository(storage_key) do
282
    path = Repos.ensure_repo!(storage_key)
283
284
    blob =
285
      git!(
286
        path,
287
        ["hash-object", "-w", "--stdin"],
288
        "# OpenAgents\n\nConnected repository fixture.\n"
289
      )
290
291
    runbook_blob =
292
      git!(
293
        path,
294
        ["hash-object", "-w", "--stdin"],
295
        "# Production runbook\n\nDeploy only verified revisions.\n"
296
      )
297
298
    runbooks_tree = git!(path, ["mktree"], "100644 blob #{runbook_blob}\tproduction.md\n")
299
    docs_tree = git!(path, ["mktree"], "040000 tree #{runbooks_tree}\trunbooks\n")
300
301
    tree =
302
      git!(
303
        path,
304
        ["mktree"],
305
        "100644 blob #{blob}\tREADME.md\n040000 tree #{docs_tree}\tdocs\n"
306
      )
307
308
    commit =
309
      git!(path, ["commit-tree", tree, "-m", "Seed repository"], "",
310
        env: [
311
          {"GIT_AUTHOR_NAME", "Test Author"},
312
          {"GIT_AUTHOR_EMAIL", "author@example.test"},
313
          {"GIT_COMMITTER_NAME", "Test Author"},
314
          {"GIT_COMMITTER_EMAIL", "author@example.test"}
315
        ]
316
      )
317
318
    {_, 0} = Repos.git(path, ["update-ref", "refs/heads/main", commit])
319
    persist_wal(storage_key, path, %{"refs/heads/main" => commit})
320
  end
321
322
  defp persist_wal(storage_key, path, refs) do
323
    bundle_path =
324
      Path.join(
325
        System.tmp_dir!(),
326
        "connected-repository-bundle-#{System.unique_integer([:positive, :monotonic])}.bundle"
327
      )
328
329
    {_, 0} = Repos.git(path, ["bundle", "create", bundle_path, "--all"])
330
    index_result = WAL.read_index(storage_key)
331
    index = if match?({:ok, _, _}, index_result), do: elem(index_result, 2), else: WAL.new_index()
332
    generation = if match?({:ok, _, _}, index_result), do: elem(index_result, 1), else: :none
333
    sequence = WAL.next_seq(index)
334
    {:ok, object} = WAL.put_entry_file(storage_key, sequence, bundle_path)
335
336
    entry = %{
337
      "seq" => sequence,
338
      "object" => object,
339
      "format" => "git_bundle",
340
      "refs" => refs,
341
      "principal" => "connected-repository-tools-test",
342
      "pushed_at" => DateTime.to_iso8601(DateTime.utc_now())
343
    }
344
345
    try do
346
      {:ok, _generation} = WAL.cas_index(storage_key, generation, WAL.append_entry(index, entry))
347
      :ok
348
    after
349
      File.rm(bundle_path)
350
    end
351
  end
352
353
  defp git!(git_dir, args, input, options \\ []) do
354
    input_path =
355
      Path.join(
356
        System.tmp_dir!(),
357
        "connected-repository-input-#{System.unique_integer([:positive])}"
358
      )
359
360
    File.write!(input_path, input)
361
362
    try do
363
      {output, 0} =
364
        System.cmd(
365
          "sh",
366
          ["-c", ~s(exec git --git-dir "$GIT_DIR" "$@" < "$INPUT_PATH"), "sh"] ++ args,
367
          env:
368
            [{"GIT_DIR", git_dir}, {"INPUT_PATH", input_path}] ++ Keyword.get(options, :env, [])
369
        )
370
371
      String.trim(output)
372
    after
373
      File.rm(input_path)
374
    end
375
  end
376
377
  defp restore_env(key, nil), do: Application.delete_env(:openagents, key)
378
  defp restore_env(key, value), do: Application.put_env(:openagents, key, value)
379
end
test/openagents/tools/conversation_execution_context_test.exs modified +3

@@ -30,6 +30,9 @@ defmodule OpenAgents.Tools.ConversationExecutionContextTest do

30 30
    assert text.authorities == voice.authorities
31 31
    assert text.approval_receipts == voice.approval_receipts
32 32
    assert "scv.deploy" in text.authorities
33
    assert "repository.read" in text.authorities
34
    assert text.owner_user_id == user.id
35
    assert voice.owner_user_id == user.id
33 36
34 37
    assert Enum.any?(text.approval_receipts, fn receipt ->
35 38
             receipt["module_id"] == "sarah.tool.scv_deploy.v1" and
test/openagents/tools/redaction_test.exs added +17

@@ -0,0 +1,17 @@

1
defmodule OpenAgents.Tools.RedactionTest do
2
  use ExUnit.Case, async: true
3
4
  alias OpenAgents.Tools.Redaction
5
6
  test "redacts nested credentials without changing ordinary tool data" do
7
    assert Redaction.redact(%{
8
             "content" => "safe",
9
             "metadata" => %{"api_key" => "secret", "repository" => "owner/repo"},
10
             token: "secret"
11
           }) == %{
12
             "content" => "safe",
13
             "metadata" => %{"api_key" => "[REDACTED]", "repository" => "owner/repo"},
14
             token: "[REDACTED]"
15
           }
16
  end
17
end
test/openagents_web/controllers/chat_turn_controller_test.exs added +41

@@ -0,0 +1,41 @@

1
defmodule OpenAgentsWeb.ChatTurnControllerTest do
2
  use OpenAgentsWeb.ConnCase
3
4
  test "controller submits and lists the authenticated account's chat events", %{conn: conn} do
5
    key = "chat-turn-api"
6
    user = github_user("api-token-" <> key)
7
8
    response =
9
      conn
10
      |> put_chat_api_token(key)
11
      |> post(~p"/api/v3/chat/turns", %{"message" => "Read the README."})
12
      |> json_response(202)
13
14
    assert %{"id" => run_id, "status" => "streaming"} = response["turn"]
15
16
    response =
17
      conn
18
      |> put_chat_api_token(key)
19
      |> get(~p"/api/v3/chat/events")
20
      |> json_response(200)
21
22
    assert [first | _events] = response["events"]
23
    assert first["run_id"] == run_id
24
    assert first["type"] == "user_message"
25
    assert first["payload"] == %{"content" => "Read the README."}
26
    assert OpenAgents.Chat.AccountTurns.list_events(user) == response["events"]
27
  end
28
29
  test "chat API rejects a valid token with the wrong scope", %{conn: conn} do
30
    assert conn
31
           |> put_forge_api_token("chat-wrong-scope")
32
           |> get(~p"/api/v3/chat/events")
33
           |> json_response(401) == %{"error" => "invalid_api_token"}
34
  end
35
36
  test "chat API requires a bearer token", %{conn: conn} do
37
    assert conn |> get(~p"/api/v3/chat/events") |> json_response(401) == %{
38
             "error" => "invalid_api_token"
39
           }
40
  end
41
end
test/openagents_web/route_authority_test.exs modified +24

@@ -88,6 +88,30 @@ defmodule OpenAgentsWeb.RouteAuthorityTest do

88 88
           ).pipe_through == [:optional_forge_api]
89 89
  end
90 90
91
  test "account chat uses its own scoped bearer pipeline" do
92
    events = route!(:get, "/api/v3/chat/events")
93
    turns = route!(:post, "/api/v3/chat/turns")
94
95
    assert events.scope == "chat:account"
96
    refute events.mutation
97
    assert turns.scope == "chat:account"
98
    assert turns.mutation
99
100
    assert Phoenix.Router.route_info(
101
             OpenAgentsWeb.Router,
102
             "GET",
103
             "/api/v3/chat/events",
104
             "stage.openagents.com"
105
           ).pipe_through == [:chat_account_api]
106
107
    assert Phoenix.Router.route_info(
108
             OpenAgentsWeb.Router,
109
             "POST",
110
             "/api/v3/chat/turns",
111
             "stage.openagents.com"
112
           ).pipe_through == [:chat_account_api]
113
  end
114
91 115
  test "operator and machine surfaces cannot drift into browser or public classes" do
92 116
    assert route!(:get, "/admin").class == :operator
93 117
    assert route!(:get, "/admin/forge").scope == "forge:promote"
test/support/conn_case.ex modified +11 -2

@@ -112,10 +112,19 @@ defmodule OpenAgentsWeb.ConnCase do

112 112
  end
113 113
114 114
  defp put_forge_api_token_for_user(conn, user) do
115
    put_api_token_for_user(conn, user, ["forge:write"])
116
  end
117
118
  def put_chat_api_token(conn, key) when is_binary(key) do
119
    user = github_user("api-token-" <> key)
120
    put_api_token_for_user(conn, user, ["chat:account"])
121
  end
122
123
  defp put_api_token_for_user(conn, user, scopes) do
115 124
    {:ok, _credential, plaintext} =
116 125
      OpenAgents.ApiTokens.create(user, %{
117
        name: "test forge client",
118
        scopes: ["forge:write"],
126
        name: "test API client",
127
        scopes: scopes,
119 128
        lifetime_days: 1
120 129
      })
121 130

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