Add chat workspace file tools

72e3e4ab32dc · AtlantisPleb · · parent 23ccf8f5c12d

Add chat workspace file tools

Deploy story

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

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/architecture.md
  • modified lib/openagents/chat/account_turns.ex
  • modified lib/openagents/tools/redaction.ex
  • modified lib/openagents/tools/runner.ex
  • added lib/openagents/tools/workspace_edit.ex
  • added lib/openagents/tools/workspace_files.ex
  • added lib/openagents/tools/workspace_read.ex
  • added lib/openagents/tools/workspace_write.ex
  • modified lib/openagents_web/live/chat_placeholder_live.ex
  • modified ops/ci/allowed-sarah-references.txt
  • modified ops/ci/docs-check.exs
  • added test/fixtures/openrouter/responses_repo_edit_call.sse
  • added test/fixtures/openrouter/responses_repo_read_call.sse
  • added test/fixtures/openrouter/responses_repo_reread_call.sse
  • added test/fixtures/openrouter/responses_repo_write_call.sse
  • modified test/openagents/chat/account_turns_test.exs
  • modified test/openagents/chat/open_router_test.exs
  • added test/openagents/tools/workspace_tools_test.exs
  • modified test/openagents_web/controllers/chat_turn_controller_test.exs
  • modified test/openagents_web/live/chat_placeholder_test.exs

Diff

22 files changed, +1920 -56

config/config.exs modified +3

@@ -148,6 +148,9 @@ config :openagents,

148 148
    OpenAgents.Tools.GitHubRepoRead,
149 149
    OpenAgents.Tools.ConnectedRepositoryRead,
150 150
    OpenAgents.Tools.ConnectedRepositoryList,
151
    OpenAgents.Tools.WorkspaceRead,
152
    OpenAgents.Tools.WorkspaceWrite,
153
    OpenAgents.Tools.WorkspaceEdit,
151 154
    OpenAgents.Tools.ConversationSearch,
152 155
    OpenAgents.Tools.ConversationRead,
153 156
    OpenAgents.Tools.MemoryList,
config/test.exs modified +3

@@ -112,6 +112,9 @@ config :openagents, :tools, [

112 112
  OpenAgents.Tools.GitHubRepoRead,
113 113
  OpenAgents.Tools.ConnectedRepositoryRead,
114 114
  OpenAgents.Tools.ConnectedRepositoryList,
115
  OpenAgents.Tools.WorkspaceRead,
116
  OpenAgents.Tools.WorkspaceWrite,
117
  OpenAgents.Tools.WorkspaceEdit,
115 118
  OpenAgents.Tools.ConversationSearch,
116 119
  OpenAgents.Tools.ConversationRead,
117 120
  OpenAgents.Tools.MemoryList,
docs/architecture.md modified +11

@@ -166,6 +166,17 @@ the shared runner and policy checks. Add a conversation tool to the registry

166 166
and its declared surface policy rather than wiring independent text and voice
167 167
backends.
168 168
169
The shared catalog exposes `read`, `write`, and `edit` only when the host binds
170
the conversation to an explicit, noncanonical repository or computer
171
workspace. `read` limits each result to 2,000 lines or 50 KiB. `write` replaces
172
a complete file, and `edit` applies an exact, nonoverlapping batch against an
173
expected digest. Mutation tools require `repository.write` and an exact
174
approval receipt. They serialize changes to each path, store restorable
175
snapshots outside the workspace, and reject absolute paths, traversal,
176
symbolic links, canonical Forge repositories, and application checkouts. The
177
model and API request can select a relative file path, but they cannot supply
178
the workspace root or mint mutation authority.
179
169 180
Bearer clients use the same account chat entry point through a personal API
170 181
token with `chat:account` scope. Forge mutations continue to require
171 182
`forge:write`; one scope does not imply the other. The authenticated account,
lib/openagents/chat/account_turns.ex modified +206 -20

@@ -61,6 +61,47 @@ defmodule OpenAgents.Chat.AccountTurns do

61 61
    end
62 62
  end
63 63
64
  @doc "Projects the start of a tool call for the browser and account API."
65
  def tool_call_view(payload) when is_map(payload) do
66
    workspace = public_workspace(payload["workspace"])
67
68
    %{
69
      call_id: payload["call_id"],
70
      name: payload["name"] || "tool",
71
      arguments: format_json(payload["arguments"] || %{}),
72
      output: nil,
73
      error: nil,
74
      error_code: nil,
75
      state: "input-available",
76
      status: "running",
77
      workspace: workspace,
78
      workspace_label: workspace_label(workspace),
79
      duration_ms: nil,
80
      receipt_refs: []
81
    }
82
  end
83
84
  @doc "Applies one terminal tool event to the shared browser and account API projection."
85
  def apply_tool_event(tool, kind, payload) when is_map(tool) and is_map(payload) do
86
    outcome = tool_outcome(payload)
87
    error = tool_error(payload, outcome)
88
    status = outcome["status"] || fallback_tool_status(kind)
89
    workspace = public_workspace(outcome["workspace"] || payload["workspace"] || tool.workspace)
90
    result = outcome["result"] || legacy_tool_result(payload, outcome)
91
92
    Map.merge(tool, %{
93
      output: if(is_nil(result), do: nil, else: format_json(result)),
94
      error: error && error.message,
95
      error_code: error && error.code,
96
      state: tool_state(status, kind),
97
      status: status,
98
      workspace: workspace,
99
      workspace_label: workspace_label(workspace),
100
      duration_ms: tool_duration_ms(outcome),
101
      receipt_refs: outcome["target_receipt_refs"] || payload["target_receipt_refs"] || []
102
    })
103
  end
104
64 105
  defp validate_content(""), do: {:error, :empty_message}
65 106
66 107
  defp validate_content(content) when byte_size(content) > @max_message_bytes,

@@ -239,7 +280,7 @@ defmodule OpenAgents.Chat.AccountTurns do

239 280
      order_by: [asc: r.inserted_at, asc: r.id, asc: e.sequence]
240 281
    )
241 282
    |> Repo.all()
242
    |> Enum.map(&event_projection/1)
283
    |> event_projections()
243 284
  end
244 285
245 286
  defp messages_for_conversation(conversation_id) do

@@ -342,36 +383,27 @@ defmodule OpenAgents.Chat.AccountTurns do

342 383
  defp tool_views(events) do
343 384
    Enum.reduce(events, [], fn
344 385
      %{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
          ]
386
        acc ++ [tool_call_view(payload)]
356 387
357 388
      %{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
        })
389
        update_tool(
390
          acc,
391
          payload["call_id"],
392
          &apply_tool_event(&1, "tool_call_completed", payload)
393
        )
362 394
363 395
      %{kind: "tool_call_failed", payload: payload}, acc ->
364
        update_tool(acc, payload["call_id"], %{error: payload["error"], state: "output-error"})
396
        update_tool(acc, payload["call_id"], &apply_tool_event(&1, "tool_call_failed", payload))
365 397
366 398
      _event, acc ->
367 399
        acc
368 400
    end)
369 401
  end
370 402
371
  defp update_tool(tools, call_id, attrs),
403
  defp update_tool(tools, call_id, update),
372 404
    do:
373 405
      Enum.map(tools, fn tool ->
374
        if tool.call_id == call_id, do: Map.merge(tool, attrs), else: tool
406
        if tool.call_id == call_id, do: update.(tool), else: tool
375 407
      end)
376 408
377 409
  defp format_json(value) when is_binary(value) do

@@ -402,16 +434,170 @@ defmodule OpenAgents.Chat.AccountTurns do

402 434
403 435
  defp duration(_run), do: nil
404 436
437
  defp event_projections(events) do
438
    {projections, _tools} =
439
      Enum.reduce(events, {[], %{}}, fn event, {projections, tools} ->
440
        {tool, tools} = project_event_tool(event, tools)
441
        projection = event_projection(event) |> maybe_put("tool_call", tool && public_tool(tool))
442
        {projections ++ [projection], tools}
443
      end)
444
445
    projections
446
  end
447
448
  defp project_event_tool(%{kind: "tool_call_started", payload: payload, run_id: run_id}, tools) do
449
    tool = tool_call_view(payload)
450
    {tool, Map.put(tools, {run_id, tool.call_id}, tool)}
451
  end
452
453
  defp project_event_tool(%{kind: kind, payload: payload, run_id: run_id}, tools)
454
       when kind in ["tool_call_completed", "tool_call_failed"] do
455
    key = {run_id, payload["call_id"]}
456
    tool = tools |> Map.get(key, tool_call_view(payload)) |> apply_tool_event(kind, payload)
457
    {tool, Map.put(tools, key, tool)}
458
  end
459
460
  defp project_event_tool(_event, tools), do: {nil, tools}
461
462
  defp public_tool(tool) do
463
    %{
464
      "call_id" => tool.call_id,
465
      "name" => tool.name,
466
      "arguments" => tool.arguments,
467
      "output" => tool.output,
468
      "error" =>
469
        if(tool.error,
470
          do: %{"code" => tool.error_code, "message" => tool.error},
471
          else: nil
472
        ),
473
      "state" => tool.state,
474
      "status" => tool.status,
475
      "workspace" => tool.workspace,
476
      "duration_ms" => tool.duration_ms,
477
      "receipt_refs" => tool.receipt_refs
478
    }
479
  end
480
481
  defp tool_outcome(payload) do
482
    value = payload["outcome"] || payload["output"]
483
484
    case value do
485
      %{} = outcome ->
486
        outcome
487
488
      value when is_binary(value) ->
489
        case Jason.decode(value) do
490
          {:ok, %{} = outcome} -> outcome
491
          _other -> %{}
492
        end
493
494
      _other ->
495
        %{}
496
    end
497
  end
498
499
  defp legacy_tool_result(payload, outcome) do
500
    cond do
501
      outcome["schema"] == "sarah.tool_outcome.v1" -> nil
502
      map_size(outcome) > 0 -> outcome
503
      Map.has_key?(payload, "output") -> payload["output"]
504
      true -> nil
505
    end
506
  end
507
508
  defp tool_error(payload, outcome) do
509
    error = outcome["error"] || payload["error"]
510
511
    case error do
512
      %{} -> %{code: error["code"], message: error["message"] || "The tool failed."}
513
      value when is_binary(value) -> %{code: payload["error_code"], message: value}
514
      _other -> nil
515
    end
516
  end
517
518
  defp fallback_tool_status("tool_call_completed"), do: "succeeded"
519
  defp fallback_tool_status(_kind), do: "failed"
520
521
  defp tool_state("succeeded", _kind), do: "output-available"
522
  defp tool_state("denied", _kind), do: "output-denied"
523
  defp tool_state(_status, "tool_call_completed"), do: "output-available"
524
  defp tool_state(_status, _kind), do: "output-error"
525
526
  defp tool_duration_ms(outcome) do
527
    with started when is_binary(started) <- outcome["started_at"],
528
         completed when is_binary(completed) <- outcome["completed_at"],
529
         {:ok, started_at, _offset} <- DateTime.from_iso8601(started),
530
         {:ok, completed_at, _offset} <- DateTime.from_iso8601(completed) do
531
      max(DateTime.diff(completed_at, started_at, :millisecond), 0)
532
    else
533
      _other -> outcome["duration_ms"]
534
    end
535
  end
536
537
  defp workspace_label(nil), do: nil
538
539
  defp workspace_label(workspace) when is_map(workspace) do
540
    workspace["path"] || workspace["repository"] || workspace["type"] || format_json(workspace)
541
  end
542
543
  defp workspace_label(workspace), do: to_string(workspace)
544
405 545
  defp event_projection(event),
406 546
    do: %{
407 547
      "id" => event.id,
408 548
      "run_id" => event.run_id,
409 549
      "sequence" => event.sequence,
410 550
      "type" => event.kind,
411
      "payload" => event.payload,
551
      "payload" => public_event_payload(event.payload),
412 552
      "observed_at" => DateTime.to_iso8601(event.observed_at)
413 553
    }
414 554
555
  # Tool outcomes can contain an absolute path to a host worktree. Keep the
556
  # useful workspace identity in account-facing projections without exposing
557
  # the host directory layout.
558
  defp public_event_payload(payload) when is_map(payload) do
559
    Enum.into(payload, %{}, fn
560
      {"workspace", workspace} -> {"workspace", public_workspace(workspace)}
561
      {key, value} -> {key, public_payload_value(value)}
562
    end)
563
  end
564
565
  defp public_event_payload(payload), do: payload
566
567
  defp public_payload_value(value) when is_map(value), do: public_event_payload(value)
568
569
  defp public_payload_value(value) when is_list(value),
570
    do: Enum.map(value, &public_payload_value/1)
571
572
  defp public_payload_value(value) when is_binary(value) do
573
    case Jason.decode(value) do
574
      {:ok, %{} = decoded} -> decoded |> public_event_payload() |> Jason.encode!()
575
      _other -> value
576
    end
577
  end
578
579
  defp public_payload_value(value), do: value
580
581
  defp public_workspace(nil), do: nil
582
583
  defp public_workspace(workspace) when is_map(workspace) do
584
    workspace
585
    |> OpenAgents.Tools.Redaction.redact()
586
    |> Enum.reduce(%{}, fn
587
      {key, value}, acc when key in ["path", "root"] and is_binary(value) ->
588
        Map.put(acc, key, public_workspace_path(value))
589
590
      {key, value}, acc ->
591
        Map.put(acc, key, public_payload_value(value))
592
    end)
593
  end
594
595
  defp public_workspace(workspace), do: workspace
596
597
  defp public_workspace_path(path) do
598
    if Path.type(path) == :absolute, do: Path.basename(path), else: path
599
  end
600
415 601
  defp run_projection(run),
416 602
    do: %{
417 603
      "id" => run.id,
lib/openagents/tools/redaction.ex modified +10

@@ -3,6 +3,11 @@ defmodule OpenAgents.Tools.Redaction do

3 3
4 4
  @redacted "[REDACTED]"
5 5
  @sensitive_fragments ~w(api_key authorization cookie credential password private_key secret token)
6
  @secret_patterns [
7
    ~r/\bsk-(?:or-v1-)?[A-Za-z0-9_-]{16,}\b/,
8
    ~r/\b(?:Bearer|Basic)\s+[A-Za-z0-9._~+\/-]+=*\b/i,
9
    ~r/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/
10
  ]
6 11
7 12
  @spec redact(term()) :: term()
8 13
  def redact(value) when is_map(value) do

@@ -18,6 +23,11 @@ defmodule OpenAgents.Tools.Redaction do

18 23
19 24
  def redact(value), do: value
20 25
26
  @spec redact_text(String.t()) :: String.t()
27
  def redact_text(value) when is_binary(value) do
28
    Enum.reduce(@secret_patterns, value, &Regex.replace(&1, &2, @redacted))
29
  end
30
21 31
  defp sensitive_key?(key) when is_atom(key), do: key |> Atom.to_string() |> sensitive_key?()
22 32
23 33
  defp sensitive_key?(key) when is_binary(key) do
lib/openagents/tools/runner.ex modified +33 -4

@@ -373,6 +373,35 @@ defmodule OpenAgents.Tools.Runner do

373 373
  defp error_message(:repository_workspace_unavailable),
374 374
    do: "Repository mutation is only available inside a coding job's own workspace."
375 375
376
  defp error_message(:workspace_required),
377
    do: "This tool requires an explicit repository or computer workspace."
378
379
  defp error_message(:canonical_workspace_refused),
380
    do: "This tool cannot operate on a canonical repository or application checkout."
381
382
  defp error_message(:workspace_read_only), do: "The assigned workspace is read-only."
383
  defp error_message(:invalid_workspace_path), do: "The workspace path is invalid."
384
  defp error_message(:reserved_workspace_path), do: "That workspace path is host-reserved."
385
  defp error_message(:workspace_path_escape), do: "The path escapes the assigned workspace."
386
  defp error_message(:workspace_symlink_refused), do: "The path crosses a symbolic link."
387
  defp error_message(:workspace_file_not_found), do: "The workspace file does not exist."
388
  defp error_message(:workspace_path_is_directory), do: "The requested path is a directory."
389
  defp error_message(:workspace_path_not_regular), do: "The requested path is not a regular file."
390
  defp error_message(:workspace_invalid_encoding), do: "The file is not valid UTF-8 text."
391
  defp error_message(:invalid_read_range), do: "The requested line range is invalid."
392
393
  defp error_message(:workspace_line_too_large),
394
    do: "A single line exceeds the 50 KiB read limit."
395
396
  defp error_message(:workspace_snapshot_root_invalid),
397
    do: "The host snapshot store must be outside the assigned workspace."
398
399
  defp error_message(:invalid_edits), do: "The edit batch is invalid."
400
  defp error_message(:overlapping_edits), do: "The edit batch contains overlapping matches."
401
402
  defp error_message(:stale_workspace_digest),
403
    do: "The file changed after it was read. Read it again before editing."
404
376 405
  defp error_message(:invalid_repository_path),
377 406
    do: "The path is outside the repository or invalid."
378 407

@@ -404,14 +433,14 @@ defmodule OpenAgents.Tools.Runner do

404 433
405 434
  defp error_message(:invalid_search_pattern), do: "The search pattern is not a valid regex."
406 435
  defp error_message(:invalid_code_content), do: "The code content is missing or invalid."
407
  defp error_message(:empty_match_string), do: "old_string must not be empty."
408
  defp error_message(:edit_is_noop), do: "old_string and new_string are identical."
436
  defp error_message(:empty_match_string), do: "The text to replace must not be empty."
437
  defp error_message(:edit_is_noop), do: "The replacement must change the matched text."
409 438
410 439
  defp error_message(:no_match),
411
    do: "old_string was not found in the file (it may have changed — re-read it)."
440
    do: "The text to replace was not found. Read the file again before editing it."
412 441
413 442
  defp error_message(:ambiguous_match),
414
    do: "old_string matches more than once — include more surrounding context or set replace_all."
443
    do: "The text to replace matches more than once. Include more surrounding context."
415 444
416 445
  defp error_message(:branch_refused),
417 446
    do: "A coding job may push only to its own openagents/job-<id> branch."
lib/openagents/tools/workspace_edit.ex added +96

@@ -0,0 +1,96 @@

1
defmodule OpenAgents.Tools.WorkspaceEdit do
2
  @moduledoc "Applies an atomic batch of exact edits in an explicit agent workspace."
3
4
  @behaviour OpenAgents.Tools.Tool
5
6
  alias OpenAgents.Modules.Metadata
7
  alias OpenAgents.Tools.{ExecutionResult, Tool, WorkspaceFiles}
8
9
  @impl true
10
  def specification do
11
    %Tool{
12
      module_id: "openagents.tool.workspace_edit.v1",
13
      name: "edit",
14
      version: 1,
15
      description:
16
        "Atomically applies exact, nonoverlapping edits to one workspace file. Each old_text " <>
17
          "must occur exactly once in the original content.",
18
      input_schema: %{
19
        "type" => "object",
20
        "properties" => %{
21
          "path" => %{"type" => "string", "maxLength" => 512},
22
          "expected_digest" => %{"type" => "string", "pattern" => "^[0-9a-f]{64}$"},
23
          "edits" => %{
24
            "type" => "array",
25
            "minItems" => 1,
26
            "maxItems" => 100,
27
            "items" => %{
28
              "type" => "object",
29
              "properties" => %{
30
                "old_text" => %{"type" => "string", "maxLength" => 100_000},
31
                "new_text" => %{"type" => "string", "maxLength" => 100_000}
32
              },
33
              "required" => ["old_text", "new_text"],
34
              "additionalProperties" => false
35
            }
36
          }
37
        },
38
        "required" => ["path", "expected_digest", "edits"],
39
        "additionalProperties" => false
40
      },
41
      output_schema: %{"type" => "object", "properties" => %{}, "additionalProperties" => true},
42
      side_effect: :reversible_write,
43
      required_scope: "browser_conversation",
44
      required_authority: "repository.write",
45
      executor: %{id: "openagents.workspace", disclosure: "the assigned agent workspace"},
46
      maintainer: "OpenAgents",
47
      attribution: ["OpenAgentsInc/openagents.com"],
48
      policy_facets: %{"privacy" => "browser_conversation", "residency" => "host"},
49
      module_metadata:
50
        Metadata.first_party("repository.write", "browser_conversation",
51
          effect: :reversible_write,
52
          privacy: "browser_conversation",
53
          residency: "host",
54
          surfaces: ["text", "voice"],
55
          approval_class: "exact_current_user_consent",
56
          approval_enforcement: "host_receipt"
57
        ),
58
      timeout_ms: 10_000,
59
      maximum_input_bytes: 262_144,
60
      maximum_output_bytes: 16_384,
61
      implementation: __MODULE__
62
    }
63
  end
64
65
  @impl true
66
  def execute(
67
        %{"path" => path, "expected_digest" => expected_digest, "edits" => edits},
68
        context
69
      )
70
      when is_binary(path) and is_binary(expected_digest) and is_list(edits) and edits != [] do
71
    with {:ok, target} <- WorkspaceFiles.resolve(context, path, :write) do
72
      WorkspaceFiles.serialize(target, fn -> edit_locked(target, expected_digest, edits) end)
73
    end
74
  end
75
76
  def execute(_arguments, _context), do: {:error, :invalid_edits}
77
78
  defp edit_locked(target, expected_digest, edits) do
79
    with {:ok, original} <- WorkspaceFiles.read_regular(target),
80
         ^expected_digest <- WorkspaceFiles.digest(original),
81
         {:ok, updated, replacements} <- WorkspaceFiles.exact_edits(original, edits),
82
         {:ok, result} <- WorkspaceFiles.atomic_write(target, updated) do
83
      {:ok,
84
       %ExecutionResult{
85
         result:
86
           result
87
           |> Map.put("schema", "openagents.workspace_edit_result.v1")
88
           |> Map.put("replacements", replacements),
89
         target_receipt_refs: [result["effect_receipt"], result["snapshot_ref"]]
90
       }}
91
    else
92
      digest when is_binary(digest) -> {:error, :stale_workspace_digest}
93
      {:error, reason} -> {:error, reason}
94
    end
95
  end
96
end
lib/openagents/tools/workspace_files.ex added +315

@@ -0,0 +1,315 @@

1
defmodule OpenAgents.Tools.WorkspaceFiles do
2
  @moduledoc false
3
4
  alias OpenAgents.Tools.{ExecutionContext, Repository}
5
6
  @workspace_types ~w(repository_workspace computer_workspace)
7
8
  def resolve(%ExecutionContext{workspace: workspace}, path, access)
9
      when is_map(workspace) and access in [:read, :write] do
10
    with {:ok, root} <- workspace_root(workspace, access),
11
         {:ok, resolved} <- safe_path(root, path),
12
         :ok <- reject_symlinks(root, resolved) do
13
      {:ok, %{root: root, path: path, resolved: resolved, ref: workspace_ref(workspace, root)}}
14
    end
15
  end
16
17
  def resolve(_context, _path, _access), do: {:error, :workspace_required}
18
19
  def serialize(%{resolved: resolved}, operation),
20
    do: :global.trans({{__MODULE__, resolved}, self()}, operation)
21
22
  def digest(content) when is_binary(content),
23
    do: :crypto.hash(:sha256, content) |> Base.encode16(case: :lower)
24
25
  def atomic_write(%{resolved: resolved} = target, content) when is_binary(content) do
26
    parent = Path.dirname(resolved)
27
28
    with :ok <- File.mkdir_p(parent),
29
         :ok <- reject_symlinks(target.root, resolved),
30
         {:ok, snapshot} <- snapshot(target) do
31
      case rename_write(resolved, content) do
32
        :ok ->
33
          final_digest = digest(content)
34
35
          {:ok,
36
           %{
37
             "action" => if(snapshot.existed, do: "replaced", else: "created"),
38
             "bytes" => byte_size(content),
39
             "final_digest" => final_digest,
40
             "path" => target.path,
41
             "prior_digest" => snapshot.prior_digest,
42
             "snapshot_ref" => snapshot.ref,
43
             "workspace_ref" => target.ref,
44
             "effect_receipt" => receipt(target, final_digest)
45
           }}
46
47
        {:error, reason} ->
48
          File.rm_rf(snapshot.directory)
49
          {:error, reason}
50
      end
51
    end
52
  end
53
54
  def read_regular(%{resolved: resolved}) do
55
    case File.lstat(resolved) do
56
      {:ok, %File.Stat{type: :regular}} -> File.read(resolved)
57
      {:ok, %File.Stat{type: :directory}} -> {:error, :workspace_path_is_directory}
58
      {:ok, _stat} -> {:error, :workspace_path_not_regular}
59
      {:error, :enoent} -> {:error, :workspace_file_not_found}
60
      {:error, reason} -> {:error, {:workspace_read_failed, reason}}
61
    end
62
  end
63
64
  def exact_edits(original, edits) when is_binary(original) and is_list(edits) do
65
    with :ok <- validate_text(original),
66
         {:ok, ranges} <- edit_ranges(original, edits),
67
         :ok <- reject_overlaps(ranges) do
68
      updated =
69
        ranges
70
        |> Enum.sort_by(& &1.start, :desc)
71
        |> Enum.reduce(original, fn edit, content ->
72
          prefix = binary_part(content, 0, edit.start)
73
          suffix_start = edit.start + edit.length
74
          suffix = binary_part(content, suffix_start, byte_size(content) - suffix_start)
75
          prefix <> edit.new_text <> suffix
76
        end)
77
78
      {:ok, updated, length(ranges)}
79
    end
80
  end
81
82
  def exact_edits(_original, _edits), do: {:error, :invalid_edits}
83
84
  defp workspace_root(workspace, access) do
85
    type = fetch(workspace, "type", :type)
86
    root = fetch(workspace, "root", :root)
87
    canonical = fetch(workspace, "canonical", :canonical)
88
    read_only = fetch(workspace, "read_only", :read_only)
89
90
    cond do
91
      type not in @workspace_types -> {:error, :workspace_required}
92
      canonical != false -> {:error, :canonical_workspace_refused}
93
      access == :write and read_only != false -> {:error, :workspace_read_only}
94
      not is_binary(root) or Path.type(root) != :absolute -> {:error, :workspace_required}
95
      protected_root?(root) -> {:error, :canonical_workspace_refused}
96
      true -> {:ok, Path.expand(root)}
97
    end
98
  end
99
100
  defp protected_root?(root) do
101
    candidate = Path.expand(root)
102
103
    [Repository.source_dir(), Application.get_env(:openagents, :forge_data_dir)]
104
    |> Enum.filter(&is_binary/1)
105
    |> Enum.any?(fn protected ->
106
      protected = Path.expand(protected)
107
108
      candidate == protected or String.starts_with?(candidate, protected <> "/") or
109
        String.starts_with?(protected, candidate <> "/")
110
    end)
111
  end
112
113
  defp safe_path(root, path) when is_binary(path) and byte_size(path) <= 512 do
114
    cond do
115
      path == "" or Path.type(path) == :absolute or String.contains?(path, "\0") ->
116
        {:error, :invalid_workspace_path}
117
118
      path == ".openagents" or String.starts_with?(path, ".openagents/") ->
119
        {:error, :reserved_workspace_path}
120
121
      true ->
122
        expanded = Path.expand(path, root)
123
124
        if String.starts_with?(expanded, root <> "/"),
125
          do: {:ok, expanded},
126
          else: {:error, :workspace_path_escape}
127
    end
128
  end
129
130
  defp safe_path(_root, _path), do: {:error, :invalid_workspace_path}
131
132
  defp reject_symlinks(root, resolved) do
133
    relative = Path.relative_to(resolved, root)
134
135
    relative
136
    |> Path.split()
137
    |> Enum.reduce_while(root, fn segment, current ->
138
      next = Path.join(current, segment)
139
140
      case File.lstat(next) do
141
        {:ok, %File.Stat{type: :symlink}} -> {:halt, {:error, :workspace_symlink_refused}}
142
        {:ok, _stat} -> {:cont, next}
143
        {:error, :enoent} -> {:halt, :ok}
144
        {:error, reason} -> {:halt, {:error, {:workspace_stat_failed, reason}}}
145
      end
146
    end)
147
    |> case do
148
      value when is_binary(value) -> :ok
149
      result -> result
150
    end
151
  end
152
153
  defp snapshot(%{resolved: resolved, root: workspace_root, path: path, ref: workspace_ref}) do
154
    id = Ecto.UUID.generate()
155
156
    with {:ok, root} <- snapshot_root(workspace_root),
157
         directory = Path.join(root, id),
158
         manifest_path = Path.join(directory, "manifest.json"),
159
         {:ok, prior} <- prior_content(resolved),
160
         :ok <- File.mkdir_p(directory),
161
         :ok <- File.chmod(directory, 0o700) do
162
      result =
163
        with :ok <- maybe_write_snapshot(directory, prior),
164
             :ok <-
165
               secure_write(
166
                 manifest_path,
167
                 Jason.encode!(%{
168
                   "existed" => prior != :missing,
169
                   "path" => path,
170
                   "prior_digest" => if(prior == :missing, do: nil, else: digest(prior)),
171
                   "workspace_ref" => workspace_ref
172
                 })
173
               ) do
174
          {:ok,
175
           %{
176
             directory: directory,
177
             existed: prior != :missing,
178
             prior_digest: if(prior == :missing, do: nil, else: digest(prior)),
179
             ref: "workspace-snapshot:" <> id
180
           }}
181
        end
182
183
      if match?({:error, _reason}, result), do: File.rm_rf(directory)
184
      result
185
    end
186
  end
187
188
  defp prior_content(path) do
189
    case File.lstat(path) do
190
      {:ok, %File.Stat{type: :regular}} -> File.read(path)
191
      {:ok, %File.Stat{type: :directory}} -> {:error, :workspace_path_is_directory}
192
      {:ok, _stat} -> {:error, :workspace_path_not_regular}
193
      {:error, :enoent} -> {:ok, :missing}
194
      {:error, reason} -> {:error, {:workspace_read_failed, reason}}
195
    end
196
  end
197
198
  defp maybe_write_snapshot(_directory, :missing), do: :ok
199
200
  defp maybe_write_snapshot(directory, content),
201
    do: secure_write(Path.join(directory, "content"), content)
202
203
  defp secure_write(path, content) do
204
    with :ok <- File.write(path, content, [:binary, :exclusive]),
205
         :ok <- File.chmod(path, 0o600) do
206
      :ok
207
    end
208
  end
209
210
  defp rename_write(path, content) do
211
    temporary = path <> ".openagents-" <> Ecto.UUID.generate() <> ".tmp"
212
213
    try do
214
      with :ok <- File.write(temporary, content, [:binary, :exclusive]),
215
           :ok <- File.rename(temporary, path) do
216
        :ok
217
      end
218
    after
219
      File.rm(temporary)
220
    end
221
  end
222
223
  defp workspace_ref(workspace, root) do
224
    fetch(workspace, "workspace_ref", :workspace_ref) ||
225
      "workspace:" <> (digest(root) |> binary_part(0, 16))
226
  end
227
228
  defp snapshot_root(workspace_root) do
229
    root =
230
      Application.get_env(
231
        :openagents,
232
        :workspace_snapshot_dir,
233
        Path.join(System.tmp_dir!(), "openagents-workspace-snapshots")
234
      )
235
236
    cond do
237
      not is_binary(root) or Path.type(root) != :absolute ->
238
        {:error, :workspace_snapshot_root_invalid}
239
240
      Path.expand(root) == Path.expand(workspace_root) or
241
          String.starts_with?(Path.expand(root), Path.expand(workspace_root) <> "/") ->
242
        {:error, :workspace_snapshot_root_invalid}
243
244
      true ->
245
        {:ok, Path.expand(root)}
246
    end
247
  end
248
249
  defp fetch(map, string_key, atom_key) do
250
    if Map.has_key?(map, string_key), do: Map.get(map, string_key), else: Map.get(map, atom_key)
251
  end
252
253
  defp receipt(target, final_digest) do
254
    identity = digest(target.ref <> "\0" <> target.path) |> binary_part(0, 24)
255
    "workspace-file:#{identity}:#{final_digest}"
256
  end
257
258
  defp validate_text(content) do
259
    if String.valid?(content), do: :ok, else: {:error, :workspace_invalid_encoding}
260
  end
261
262
  defp edit_ranges(original, edits) do
263
    edits
264
    |> Enum.with_index()
265
    |> Enum.reduce_while({:ok, []}, fn
266
      {%{"old_text" => old_text, "new_text" => new_text}, index}, {:ok, ranges}
267
      when is_binary(old_text) and is_binary(new_text) ->
268
        with :ok <- validate_edit_text(old_text, new_text),
269
             {:ok, start} <- unique_match(original, old_text) do
270
          range = %{start: start, length: byte_size(old_text), new_text: new_text, index: index}
271
          {:cont, {:ok, [range | ranges]}}
272
        else
273
          {:error, reason} -> {:halt, {:error, reason}}
274
        end
275
276
      _, _acc ->
277
        {:halt, {:error, :invalid_edits}}
278
    end)
279
  end
280
281
  defp validate_edit_text(old_text, new_text) do
282
    cond do
283
      old_text == "" ->
284
        {:error, :empty_match_string}
285
286
      old_text == new_text ->
287
        {:error, :edit_is_noop}
288
289
      not String.valid?(old_text) or not String.valid?(new_text) ->
290
        {:error, :workspace_invalid_encoding}
291
292
      true ->
293
        :ok
294
    end
295
  end
296
297
  defp unique_match(content, old_text) do
298
    case :binary.matches(content, old_text) do
299
      [] -> {:error, :no_match}
300
      [{start, _length}] -> {:ok, start}
301
      _matches -> {:error, :ambiguous_match}
302
    end
303
  end
304
305
  defp reject_overlaps(ranges) do
306
    ranges
307
    |> Enum.sort_by(& &1.start)
308
    |> Enum.chunk_every(2, 1, :discard)
309
    |> Enum.find(fn [left, right] -> left.start + left.length > right.start end)
310
    |> case do
311
      nil -> :ok
312
      _pair -> {:error, :overlapping_edits}
313
    end
314
  end
315
end
lib/openagents/tools/workspace_read.ex added +150

@@ -0,0 +1,150 @@

1
defmodule OpenAgents.Tools.WorkspaceRead do
2
  @moduledoc "Reads bounded text from an explicit, noncanonical agent workspace."
3
4
  @behaviour OpenAgents.Tools.Tool
5
  @max_lines 2_000
6
  @max_bytes 50 * 1_024
7
8
  alias OpenAgents.Modules.Metadata
9
  alias OpenAgents.Tools.{ExecutionResult, Redaction, Tool, WorkspaceFiles}
10
11
  @impl true
12
  def specification do
13
    %Tool{
14
      module_id: "openagents.tool.workspace_read.v1",
15
      name: "read",
16
      version: 1,
17
      description:
18
        "Reads a UTF-8 file from the explicit repository or computer workspace. " <>
19
          "Offset is one-based; each result is bounded to 2,000 lines and 50 KiB.",
20
      input_schema: %{
21
        "type" => "object",
22
        "properties" => %{
23
          "path" => %{"type" => "string", "maxLength" => 512},
24
          "offset" => %{"type" => "integer", "minimum" => 1},
25
          "limit" => %{"type" => "integer", "minimum" => 1, "maximum" => @max_lines}
26
        },
27
        "required" => ["path"],
28
        "additionalProperties" => false
29
      },
30
      output_schema: %{"type" => "object", "properties" => %{}, "additionalProperties" => true},
31
      side_effect: :read_only,
32
      required_scope: "browser_conversation",
33
      required_authority: "repository.read",
34
      executor: %{id: "openagents.workspace", disclosure: "the assigned agent workspace"},
35
      maintainer: "OpenAgents",
36
      attribution: ["OpenAgentsInc/openagents.com"],
37
      policy_facets: %{"privacy" => "browser_conversation", "residency" => "host"},
38
      module_metadata:
39
        Metadata.first_party("repository.read", "browser_conversation",
40
          effect: :read_only,
41
          privacy: "browser_conversation",
42
          residency: "host",
43
          surfaces: ["text", "voice"]
44
        ),
45
      timeout_ms: 10_000,
46
      maximum_input_bytes: 2_048,
47
      maximum_output_bytes: 64 * 1_024,
48
      implementation: __MODULE__
49
    }
50
  end
51
52
  @impl true
53
  def execute(%{"path" => path} = arguments, context) when is_binary(path) do
54
    offset = Map.get(arguments, "offset", 1)
55
    limit = Map.get(arguments, "limit", @max_lines)
56
57
    with true <- is_integer(offset) and offset > 0,
58
         true <- is_integer(limit) and limit > 0 and limit <= @max_lines,
59
         {:ok, target} <- WorkspaceFiles.resolve(context, path, :read),
60
         {:ok, content} <- WorkspaceFiles.read_regular(target),
61
         :ok <- valid_content(content),
62
         {:ok, result} <- bounded_lines(content, offset, limit) do
63
      {:ok,
64
       %ExecutionResult{
65
         result:
66
           Map.merge(result, %{
67
             "schema" => "openagents.workspace_read_result.v1",
68
             "path" => path,
69
             "digest" => WorkspaceFiles.digest(content),
70
             "workspace_ref" => target.ref
71
           })
72
       }}
73
    else
74
      false -> {:error, :invalid_read_range}
75
      {:error, reason} -> {:error, reason}
76
    end
77
  end
78
79
  def execute(_arguments, _context), do: {:error, :invalid_workspace_path}
80
81
  defp bounded_lines(content, offset, limit) do
82
    lines = lines_with_endings(content)
83
    total = length(lines)
84
85
    cond do
86
      total == 0 and offset == 1 ->
87
        {:ok, %{"content" => "", "line_count" => 0, "next_offset" => nil, "offset" => 1}}
88
89
      offset > total ->
90
        {:error, :invalid_read_range}
91
92
      true ->
93
        selected = lines |> Enum.drop(offset - 1) |> Enum.take(limit)
94
95
        with {:ok, visible, bytes} <- take_bytes(selected, @max_bytes) do
96
          count = length(visible)
97
          more? = offset - 1 + count < total
98
99
          {:ok,
100
           %{
101
             "content" => visible |> IO.iodata_to_binary() |> Redaction.redact_text(),
102
             "line_count" => count,
103
             "next_offset" => if(more?, do: offset + count, else: nil),
104
             "offset" => offset,
105
             "returned_bytes" => bytes,
106
             "total_lines" => total
107
           }}
108
        end
109
    end
110
  end
111
112
  defp lines_with_endings(""), do: []
113
114
  defp lines_with_endings(content) do
115
    parts = :binary.split(content, "\n", [:global])
116
    trailing_newline? = List.last(parts) == ""
117
    content_parts = if trailing_newline?, do: Enum.drop(parts, -1), else: parts
118
    last_index = length(content_parts) - 1
119
120
    content_parts
121
    |> Enum.with_index()
122
    |> Enum.map(fn {line, index} ->
123
      if trailing_newline? or index < last_index, do: line <> "\n", else: line
124
    end)
125
  end
126
127
  defp take_bytes(lines, maximum) do
128
    Enum.reduce_while(lines, {[], 0}, fn line, {accepted, bytes} ->
129
      size = byte_size(line)
130
131
      cond do
132
        bytes + size <= maximum ->
133
          {:cont, {[line | accepted], bytes + size}}
134
135
        accepted == [] ->
136
          {:halt, {:error, :workspace_line_too_large}}
137
138
        true ->
139
          {:halt, {accepted, bytes}}
140
      end
141
    end)
142
    |> case do
143
      {:error, reason} -> {:error, reason}
144
      {accepted, bytes} -> {:ok, Enum.reverse(accepted), bytes}
145
    end
146
  end
147
148
  defp valid_content(content),
149
    do: if(String.valid?(content), do: :ok, else: {:error, :workspace_invalid_encoding})
150
end
lib/openagents/tools/workspace_write.ex added +68

@@ -0,0 +1,68 @@

1
defmodule OpenAgents.Tools.WorkspaceWrite do
2
  @moduledoc "Creates or replaces a file in an explicit, noncanonical agent workspace."
3
4
  @behaviour OpenAgents.Tools.Tool
5
6
  alias OpenAgents.Modules.Metadata
7
  alias OpenAgents.Tools.{ExecutionResult, Tool, WorkspaceFiles}
8
9
  @impl true
10
  def specification do
11
    %Tool{
12
      module_id: "openagents.tool.workspace_write.v1",
13
      name: "write",
14
      version: 1,
15
      description: "Creates or replaces one UTF-8 file in the assigned agent workspace.",
16
      input_schema: %{
17
        "type" => "object",
18
        "properties" => %{
19
          "path" => %{"type" => "string", "maxLength" => 512},
20
          "content" => %{"type" => "string", "maxLength" => 200_000}
21
        },
22
        "required" => ["path", "content"],
23
        "additionalProperties" => false
24
      },
25
      output_schema: %{"type" => "object", "properties" => %{}, "additionalProperties" => true},
26
      side_effect: :reversible_write,
27
      required_scope: "browser_conversation",
28
      required_authority: "repository.write",
29
      executor: %{id: "openagents.workspace", disclosure: "the assigned agent workspace"},
30
      maintainer: "OpenAgents",
31
      attribution: ["OpenAgentsInc/openagents.com"],
32
      policy_facets: %{"privacy" => "browser_conversation", "residency" => "host"},
33
      module_metadata:
34
        Metadata.first_party("repository.write", "browser_conversation",
35
          effect: :reversible_write,
36
          privacy: "browser_conversation",
37
          residency: "host",
38
          surfaces: ["text", "voice"],
39
          approval_class: "exact_current_user_consent",
40
          approval_enforcement: "host_receipt"
41
        ),
42
      timeout_ms: 10_000,
43
      maximum_input_bytes: 262_144,
44
      maximum_output_bytes: 16_384,
45
      implementation: __MODULE__
46
    }
47
  end
48
49
  @impl true
50
  def execute(%{"path" => path, "content" => content}, context)
51
      when is_binary(path) and is_binary(content) do
52
    with true <- String.valid?(content),
53
         {:ok, target} <- WorkspaceFiles.resolve(context, path, :write),
54
         {:ok, result} <-
55
           WorkspaceFiles.serialize(target, fn -> WorkspaceFiles.atomic_write(target, content) end) do
56
      {:ok,
57
       %ExecutionResult{
58
         result: Map.put(result, "schema", "openagents.workspace_write_result.v1"),
59
         target_receipt_refs: [result["effect_receipt"], result["snapshot_ref"]]
60
       }}
61
    else
62
      false -> {:error, :workspace_invalid_encoding}
63
      {:error, reason} -> {:error, reason}
64
    end
65
  end
66
67
  def execute(_arguments, _context), do: {:error, :invalid_workspace_path}
68
end
lib/openagents_web/live/chat_placeholder_live.ex modified +46 -30

@@ -118,8 +118,8 @@ defmodule OpenAgentsWeb.ChatPlaceholderLive do

118 118
      %{stream_id: ^stream_id, streaming?: true} ->
119 119
        {:noreply,
120 120
         socket
121
         |> update(:assistant_tool_calls, &(&1 ++ [tool_call_view(tool_call)]))
122
         |> append_tool_block(tool_call_view(tool_call))}
121
         |> update(:assistant_tool_calls, &(&1 ++ [AccountTurns.tool_call_view(tool_call)]))
122
         |> append_tool_block(AccountTurns.tool_call_view(tool_call))}
123 123
124 124
      _stale_stream ->
125 125
        {:noreply, socket}

@@ -151,11 +151,7 @@ defmodule OpenAgentsWeb.ChatPlaceholderLive do

151 151
        socket
152 152
      ) do
153 153
    update_streaming_tool(socket, stream_id, tool_result["call_id"], fn tool_call ->
154
      %{
155
        tool_call
156
        | output: format_tool_json(tool_result["output"]),
157
          state: "output-available"
158
      }
154
      AccountTurns.apply_tool_event(tool_call, "tool_call_completed", tool_result)
159 155
    end)
160 156
  end
161 157

@@ -164,7 +160,7 @@ defmodule OpenAgentsWeb.ChatPlaceholderLive do

164 160
        socket
165 161
      ) do
166 162
    update_streaming_tool(socket, stream_id, tool_result["call_id"], fn tool_call ->
167
      %{tool_call | error: tool_result["error"], state: "output-error"}
163
      AccountTurns.apply_tool_event(tool_call, "tool_call_failed", tool_result)
168 164
    end)
169 165
  end
170 166

@@ -510,9 +506,44 @@ defmodule OpenAgentsWeb.ChatPlaceholderLive do

510 506
        title={@tool_call.name}
511 507
        state={@tool_call.state}
512 508
      />
513
      <.tool_content>
514
        <.tool_input input={@tool_call.arguments} />
515
        <.tool_output output={@tool_call.output} error_text={@tool_call.error} />
509
      <.tool_content id={"#{@id}-content"}>
510
        <dl
511
          id={"#{@id}-metadata"}
512
          class="grid gap-x-6 gap-y-3 border-b border-border pb-4 sm:grid-cols-3"
513
        >
514
          <div>
515
            <dt class="text-xs uppercase tracking-wide text-muted-foreground">Status</dt>
516
            <dd class="mt-1 font-medium text-sm">{@tool_call.status}</dd>
517
          </div>
518
          <div :if={@tool_call.workspace_label}>
519
            <dt class="text-xs uppercase tracking-wide text-muted-foreground">Workspace</dt>
520
            <dd class="mt-1 truncate font-mono text-sm" title={@tool_call.workspace_label}>
521
              {@tool_call.workspace_label}
522
            </dd>
523
          </div>
524
          <div :if={is_integer(@tool_call.duration_ms)}>
525
            <dt class="text-xs uppercase tracking-wide text-muted-foreground">Duration</dt>
526
            <dd class="mt-1 font-medium text-sm">{format_duration(@tool_call.duration_ms)}</dd>
527
          </div>
528
          <div :if={@tool_call.error_code}>
529
            <dt class="text-xs uppercase tracking-wide text-muted-foreground">Error code</dt>
530
            <dd class="mt-1 font-mono text-sm text-destructive">{@tool_call.error_code}</dd>
531
          </div>
532
        </dl>
533
        <.tool_input id={"#{@id}-input"} input={@tool_call.arguments} />
534
        <.tool_output
535
          id={"#{@id}-output"}
536
          output={@tool_call.output}
537
          error_text={@tool_call.error}
538
        />
539
        <div :if={@tool_call.receipt_refs != []} id={"#{@id}-receipts"} class="space-y-2">
540
          <h4 class="font-medium text-muted-foreground text-xs uppercase tracking-wide">
541
            Receipts
542
          </h4>
543
          <ul class="space-y-1 rounded-md bg-muted/50 p-4 font-mono text-xs">
544
            <li :for={receipt <- @tool_call.receipt_refs}>{format_receipt(receipt)}</li>
545
          </ul>
546
        </div>
516 547
      </.tool_content>
517 548
    </.tool>
518 549
    """

@@ -566,25 +597,10 @@ defmodule OpenAgentsWeb.ChatPlaceholderLive do

566 597
    end
567 598
  end
568 599
569
  defp format_tool_json(value) when is_binary(value) do
570
    case Jason.decode(value) do
571
      {:ok, decoded} -> Jason.encode!(decoded, pretty: true)
572
      {:error, _reason} -> value
573
    end
574
  end
575
576
  defp format_tool_json(value), do: Jason.encode!(value, pretty: true)
577
578
  defp tool_call_view(tool_call) do
579
    %{
580
      call_id: tool_call["call_id"],
581
      name: tool_call["name"],
582
      arguments: format_tool_json(tool_call["arguments"]),
583
      output: nil,
584
      error: nil,
585
      state: "input-available"
586
    }
587
  end
600
  defp format_duration(duration_ms) when duration_ms < 1_000, do: "#{duration_ms} ms"
601
  defp format_duration(duration_ms), do: "#{Float.round(duration_ms / 1_000, 1)} s"
602
  defp format_receipt(receipt) when is_binary(receipt), do: receipt
603
  defp format_receipt(receipt), do: Jason.encode!(receipt)
588 604
589 605
  defp update_tool_call(tool_calls, call_id, update_tool) do
590 606
    Enum.map(tool_calls, fn
ops/ci/allowed-sarah-references.txt modified +1

@@ -13,6 +13,7 @@

13 13
^docs/2026-08-21-sarah-computers-and-scv-architecture-audit\.md:
14 14
^docs/chat-inference-plan\.md:
15 15
^docs/component-library\.md:
16
^docs/episode-triage\.md:
16 17
^docs/github-auth-plan\.md:
17 18
^docs/issues-projects-ui-roadmap\.md:
18 19
^docs/sarah-integration-plan\.md:
ops/ci/docs-check.exs modified +1

@@ -9,6 +9,7 @@ defmodule OpenAgents.DocsCheck do

9 9
  @lexical_exceptions MapSet.new([
10 10
                        "docs/2026-08-20-integration-hardening-and-staging-readiness-recommendations.md",
11 11
                        "docs/2026-08-20-test-coverage-audit.md",
12
                        "docs/episode-triage.md",
12 13
                        "docs/decisions/0005-use-basecoat-and-one-component-system.md"
13 14
                      ])
14 15
test/fixtures/openrouter/responses_repo_edit_call.sse added +15

@@ -0,0 +1,15 @@

1
data: {"type":"response.output_item.added","response_id":"resp_repo_edit","output_index":0,"item":{"type":"reasoning","id":"rs_repo_edit","status":"in_progress","summary":[]}}
2
3
data: {"type":"response.output_item.done","response_id":"resp_repo_edit","output_index":0,"item":{"type":"reasoning","id":"rs_repo_edit","status":"completed","summary":[{"type":"summary_text","text":"Apply the exact replacement to the new draft."}],"encrypted_content":"encrypted-repo-edit"}}
4
5
data: {"type":"response.output_item.added","response_id":"resp_repo_edit","output_index":1,"item":{"type":"function_call","id":"fc_repo_edit","call_id":"call_repo_edit","name":"edit","arguments":"","status":"in_progress"}}
6
7
data: {"type":"response.function_call_arguments.delta","response_id":"resp_repo_edit","item_id":"fc_repo_edit","output_index":1,"delta":"{\"path\":\"notes.txt\",\"old_string\":\"beta\",\"new_string\":\"gamma\"}"}
8
9
data: {"type":"response.function_call_arguments.done","response_id":"resp_repo_edit","item_id":"fc_repo_edit","output_index":1,"arguments":"{\"path\":\"notes.txt\",\"old_string\":\"beta\",\"new_string\":\"gamma\"}"}
10
11
data: {"type":"response.output_item.done","response_id":"resp_repo_edit","output_index":1,"item":{"type":"function_call","id":"fc_repo_edit","call_id":"call_repo_edit","name":"edit","arguments":"{\"path\":\"notes.txt\",\"old_string\":\"beta\",\"new_string\":\"gamma\"}","status":"completed"}}
12
13
data: {"type":"response.completed","response":{"id":"resp_repo_edit","object":"response","status":"completed","model":"stealth/ox-alpha","output":[{"type":"reasoning","id":"rs_repo_edit","status":"completed","summary":[{"type":"summary_text","text":"Apply the exact replacement to the new draft."}],"encrypted_content":"encrypted-repo-edit"},{"type":"function_call","id":"fc_repo_edit","call_id":"call_repo_edit","name":"edit","arguments":"{\"path\":\"notes.txt\",\"old_string\":\"beta\",\"new_string\":\"gamma\"}","status":"completed"}]}}
14
15
data: [DONE]
test/fixtures/openrouter/responses_repo_read_call.sse added +19

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

1
data: {"type":"response.output_item.added","response_id":"resp_repo_read","output_index":0,"item":{"type":"reasoning","id":"rs_repo_read","status":"in_progress","summary":[]}}
2
3
data: {"type":"response.output_item.done","response_id":"resp_repo_read","output_index":0,"item":{"type":"reasoning","id":"rs_repo_read","status":"completed","summary":[{"type":"summary_text","text":"Read the current file before changing it."}],"encrypted_content":"encrypted-repo-read"}}
4
5
data: {"type":"response.output_item.added","response_id":"resp_repo_read","output_index":1,"item":{"type":"message","id":"msg_repo_read_preamble","role":"assistant","status":"completed","content":[{"type":"output_text","text":"I will inspect the current workspace file before changing it.","annotations":[]}]}}
6
7
data: {"type":"response.output_item.done","response_id":"resp_repo_read","output_index":1,"item":{"type":"message","id":"msg_repo_read_preamble","role":"assistant","status":"completed","content":[{"type":"output_text","text":"I will inspect the current workspace file before changing it.","annotations":[]}]}}
8
9
data: {"type":"response.output_item.added","response_id":"resp_repo_read","output_index":2,"item":{"type":"function_call","id":"fc_repo_read","call_id":"call_repo_read","name":"read","arguments":"","status":"in_progress"}}
10
11
data: {"type":"response.function_call_arguments.delta","response_id":"resp_repo_read","item_id":"fc_repo_read","output_index":2,"delta":"{\"path\":\"notes.txt\",\"from\":\"workspace\"}"}
12
13
data: {"type":"response.function_call_arguments.done","response_id":"resp_repo_read","item_id":"fc_repo_read","output_index":2,"arguments":"{\"path\":\"notes.txt\",\"from\":\"workspace\"}"}
14
15
data: {"type":"response.output_item.done","response_id":"resp_repo_read","output_index":2,"item":{"type":"function_call","id":"fc_repo_read","call_id":"call_repo_read","name":"read","arguments":"{\"path\":\"notes.txt\",\"from\":\"workspace\"}","status":"completed"}}
16
17
data: {"type":"response.completed","response":{"id":"resp_repo_read","object":"response","status":"completed","model":"stealth/ox-alpha","output":[{"type":"reasoning","id":"rs_repo_read","status":"completed","summary":[{"type":"summary_text","text":"Read the current file before changing it."}],"encrypted_content":"encrypted-repo-read"},{"type":"message","id":"msg_repo_read_preamble","role":"assistant","status":"completed","content":[{"type":"output_text","text":"I will inspect the current workspace file before changing it.","annotations":[]}]},{"type":"function_call","id":"fc_repo_read","call_id":"call_repo_read","name":"read","arguments":"{\"path\":\"notes.txt\",\"from\":\"workspace\"}","status":"completed"}]}}
18
19
data: [DONE]
test/fixtures/openrouter/responses_repo_reread_call.sse added +15

@@ -0,0 +1,15 @@

1
data: {"type":"response.output_item.added","response_id":"resp_repo_reread","output_index":0,"item":{"type":"reasoning","id":"rs_repo_reread","status":"in_progress","summary":[]}}
2
3
data: {"type":"response.output_item.done","response_id":"resp_repo_reread","output_index":0,"item":{"type":"reasoning","id":"rs_repo_reread","status":"completed","summary":[{"type":"summary_text","text":"Read the file again to verify the edit."}],"encrypted_content":"encrypted-repo-reread"}}
4
5
data: {"type":"response.output_item.added","response_id":"resp_repo_reread","output_index":1,"item":{"type":"function_call","id":"fc_repo_reread","call_id":"call_repo_reread","name":"read","arguments":"","status":"in_progress"}}
6
7
data: {"type":"response.function_call_arguments.delta","response_id":"resp_repo_reread","item_id":"fc_repo_reread","output_index":1,"delta":"{\"path\":\"notes.txt\",\"from\":\"workspace\"}"}
8
9
data: {"type":"response.function_call_arguments.done","response_id":"resp_repo_reread","item_id":"fc_repo_reread","output_index":1,"arguments":"{\"path\":\"notes.txt\",\"from\":\"workspace\"}"}
10
11
data: {"type":"response.output_item.done","response_id":"resp_repo_reread","output_index":1,"item":{"type":"function_call","id":"fc_repo_reread","call_id":"call_repo_reread","name":"read","arguments":"{\"path\":\"notes.txt\",\"from\":\"workspace\"}","status":"completed"}}
12
13
data: {"type":"response.completed","response":{"id":"resp_repo_reread","object":"response","status":"completed","model":"stealth/ox-alpha","output":[{"type":"reasoning","id":"rs_repo_reread","status":"completed","summary":[{"type":"summary_text","text":"Read the file again to verify the edit."}],"encrypted_content":"encrypted-repo-reread"},{"type":"function_call","id":"fc_repo_reread","call_id":"call_repo_reread","name":"read","arguments":"{\"path\":\"notes.txt\",\"from\":\"workspace\"}","status":"completed"}]}}
14
15
data: [DONE]
test/fixtures/openrouter/responses_repo_write_call.sse added +15

@@ -0,0 +1,15 @@

1
data: {"type":"response.output_item.added","response_id":"resp_repo_write","output_index":0,"item":{"type":"reasoning","id":"rs_repo_write","status":"in_progress","summary":[]}}
2
3
data: {"type":"response.output_item.done","response_id":"resp_repo_write","output_index":0,"item":{"type":"reasoning","id":"rs_repo_write","status":"completed","summary":[{"type":"summary_text","text":"Replace the file with the requested draft."}],"encrypted_content":"encrypted-repo-write"}}
4
5
data: {"type":"response.output_item.added","response_id":"resp_repo_write","output_index":1,"item":{"type":"function_call","id":"fc_repo_write","call_id":"call_repo_write","name":"write","arguments":"","status":"in_progress"}}
6
7
data: {"type":"response.function_call_arguments.delta","response_id":"resp_repo_write","item_id":"fc_repo_write","output_index":1,"delta":"{\"path\":\"notes.txt\",\"content\":\"alpha beta\"}"}
8
9
data: {"type":"response.function_call_arguments.done","response_id":"resp_repo_write","item_id":"fc_repo_write","output_index":1,"arguments":"{\"path\":\"notes.txt\",\"content\":\"alpha beta\"}"}
10
11
data: {"type":"response.output_item.done","response_id":"resp_repo_write","output_index":1,"item":{"type":"function_call","id":"fc_repo_write","call_id":"call_repo_write","name":"write","arguments":"{\"path\":\"notes.txt\",\"content\":\"alpha beta\"}","status":"completed"}}
12
13
data: {"type":"response.completed","response":{"id":"resp_repo_write","object":"response","status":"completed","model":"stealth/ox-alpha","output":[{"type":"reasoning","id":"rs_repo_write","status":"completed","summary":[{"type":"summary_text","text":"Replace the file with the requested draft."}],"encrypted_content":"encrypted-repo-write"},{"type":"function_call","id":"fc_repo_write","call_id":"call_repo_write","name":"write","arguments":"{\"path\":\"notes.txt\",\"content\":\"alpha beta\"}","status":"completed"}]}}
14
15
data: [DONE]
test/openagents/chat/account_turns_test.exs modified +105 -2

@@ -49,7 +49,24 @@ defmodule OpenAgents.Chat.AccountTurnsTest do

49 49
      )
50 50
51 51
      callback.(
52
        {:tool_call_completed, %{"call_id" => "call-1", "output" => ~s({"content":"OpenAgents"})}}
52
        {:tool_call_completed,
53
         %{
54
           "call_id" => "call-1",
55
           "output" =>
56
             Jason.encode!(%{
57
               "schema" => "sarah.tool_outcome.v1",
58
               "status" => "succeeded",
59
               "result" => %{"content" => "OpenAgents"},
60
               "error" => nil,
61
               "workspace" => %{
62
                 "type" => "forge_worktree",
63
                 "path" => "/private/var/lib/openagents/workspaces/openagents.com"
64
               },
65
               "target_receipt_refs" => ["receipt:forge:abc123"],
66
               "started_at" => "2026-08-22T19:43:28.000Z",
67
               "completed_at" => "2026-08-22T19:43:28.125Z"
68
             })
69
         }}
53 70
      )
54 71
55 72
      callback.({:text_delta, "The repository is available."})

@@ -86,14 +103,40 @@ defmodule OpenAgents.Chat.AccountTurnsTest do

86 103
    assert Enum.map(events, & &1["sequence"]) == Enum.to_list(1..6)
87 104
    assert get_in(List.last(events), ["payload", "reasoning_items"]) != nil
88 105
106
    assert %{
107
             "status" => "running",
108
             "state" => "input-available"
109
           } = Enum.at(events, 2)["tool_call"]
110
111
    assert %{
112
             "status" => "succeeded",
113
             "state" => "output-available",
114
             "duration_ms" => 125,
115
             "workspace" => %{"path" => "openagents.com"},
116
             "receipt_refs" => ["receipt:forge:abc123"],
117
             "error" => nil
118
           } = Enum.at(events, 3)["tool_call"]
119
89 120
    assert [user_message, assistant_message] = AccountTurns.list_messages(user)
90 121
    assert user_message.content == "Read the README."
91 122
    assert assistant_message.content == "The repository is available."
92 123
    assert assistant_message.history?
93 124
94
    assert [%{name: "read_repository_file", state: "output-available"}] =
125
    assert [
126
             %{
127
               name: "read_repository_file",
128
               state: "output-available",
129
               status: "succeeded",
130
               duration_ms: 125,
131
               workspace_label: "openagents.com",
132
               receipt_refs: ["receipt:forge:abc123"]
133
             }
134
           ] =
95 135
             assistant_message.tool_calls
96 136
137
    refute inspect(events) =~ "/private/var/lib/openagents"
138
    refute inspect(assistant_message) =~ "/private/var/lib/openagents"
139
97 140
    test_process = self()
98 141
99 142
    follow_up_streamer = fn request, _callback, _options ->

@@ -115,6 +158,66 @@ defmodule OpenAgents.Chat.AccountTurnsTest do

115 158
    assert_receive {:account_chat_completed, ^follow_up_run_id, {:ok, _completion}}
116 159
  end
117 160
161
  test "projects typed tool errors without losing lifecycle order" do
162
    user = repository_user_fixture("account-chat-tool-error")
163
164
    streamer = fn _request, callback, _options ->
165
      callback.(
166
        {:tool_call_started,
167
         %{
168
           "call_id" => "call-edit",
169
           "name" => "edit",
170
           "arguments" => ~s({"path":"README.md"})
171
         }}
172
      )
173
174
      callback.(
175
        {:tool_call_failed,
176
         %{
177
           "call_id" => "call-edit",
178
           "output" => %{
179
             "schema" => "sarah.tool_outcome.v1",
180
             "status" => "failed",
181
             "error" => %{
182
               "code" => "workspace_read_only",
183
               "message" => "The workspace is read-only."
184
             },
185
             "workspace" => %{
186
               "type" => "forge_worktree",
187
               "path" => "/private/var/lib/openagents/workspaces/repo"
188
             },
189
             "started_at" => "2026-08-22T19:43:28.000Z",
190
             "completed_at" => "2026-08-22T19:43:28.004Z"
191
           }
192
         }}
193
      )
194
195
      {:ok, %{"assistant_content" => "I could not edit that file."}}
196
    end
197
198
    assert {:ok, %{"id" => run_id}} =
199
             AccountTurns.submit(user, "Edit the README.", subscriber: self(), streamer: streamer)
200
201
    assert_receive {:account_chat_completed, ^run_id, {:ok, _completion}}
202
203
    assert Enum.map(AccountTurns.list_events(user), & &1["type"]) == [
204
             "user_message",
205
             "tool_call_started",
206
             "tool_call_failed",
207
             "response_completed"
208
           ]
209
210
    failed = Enum.at(AccountTurns.list_events(user), 2)["tool_call"]
211
    assert failed["status"] == "failed"
212
    assert failed["state"] == "output-error"
213
    assert failed["duration_ms"] == 4
214
215
    assert failed["error"] == %{
216
             "code" => "workspace_read_only",
217
             "message" => "The workspace is read-only."
218
           }
219
  end
220
118 221
  test "events are isolated by account" do
119 222
    user = repository_user_fixture("account-chat-owner")
120 223
    other_user = repository_user_fixture("account-chat-other")
test/openagents/chat/open_router_test.exs modified +313

@@ -85,6 +85,142 @@ defmodule OpenAgents.Chat.OpenRouterTest do

85 85
    end
86 86
  end
87 87
88
  defmodule RepositorySequenceReadToolStub do
89
    @moduledoc false
90
    @behaviour OpenAgents.Tools.Tool
91
92
    @impl true
93
    def specification,
94
      do:
95
        OpenAgents.Chat.OpenRouterTest.sequence_tool_spec(
96
          "read",
97
          __MODULE__,
98
          "Reads a file from the test repository workspace."
99
        )
100
101
    @impl true
102
    def execute(%{"path" => path}, %ExecutionContext{owner_user_id: agent}) when is_pid(agent) do
103
      content = Agent.get(agent, & &1)
104
      {:ok, %ExecutionResult{result: %{"path" => path, "content" => content}}}
105
    end
106
  end
107
108
  defmodule RepositorySequenceWriteToolStub do
109
    @moduledoc false
110
    @behaviour OpenAgents.Tools.Tool
111
112
    @impl true
113
    def specification,
114
      do:
115
        OpenAgents.Chat.OpenRouterTest.sequence_tool_spec(
116
          "write",
117
          __MODULE__,
118
          "Writes a complete file to the test repository workspace."
119
        )
120
121
    @impl true
122
    def execute(%{"path" => path, "content" => content}, %ExecutionContext{
123
          owner_user_id: agent
124
        })
125
        when is_pid(agent) do
126
      :ok = Agent.update(agent, fn _current -> content end)
127
      {:ok, %ExecutionResult{result: %{"path" => path, "content" => content}}}
128
    end
129
  end
130
131
  defmodule RepositorySequenceEditToolStub do
132
    @moduledoc false
133
    @behaviour OpenAgents.Tools.Tool
134
135
    @impl true
136
    def specification,
137
      do:
138
        OpenAgents.Chat.OpenRouterTest.sequence_tool_spec(
139
          "edit",
140
          __MODULE__,
141
          "Edits an exact string in the test repository workspace."
142
        )
143
144
    @impl true
145
    def execute(
146
          %{"path" => path, "old_string" => old_string, "new_string" => new_string},
147
          %ExecutionContext{owner_user_id: agent}
148
        )
149
        when is_pid(agent) do
150
      case Agent.get_and_update(agent, fn content ->
151
             if String.contains?(content, old_string) do
152
               updated = String.replace(content, old_string, new_string)
153
               {{:ok, updated}, updated}
154
             else
155
               {{:error, :no_match}, content}
156
             end
157
           end) do
158
        {:ok, content} ->
159
          {:ok, %ExecutionResult{result: %{"path" => path, "content" => content}}}
160
161
        {:error, reason} ->
162
          {:error, reason}
163
      end
164
    end
165
  end
166
167
  def sequence_tool_spec(name, implementation, description) do
168
    {properties, required} =
169
      case name do
170
        "read" ->
171
          {%{
172
             "path" => %{"type" => "string"},
173
             "from" => %{"type" => "string"}
174
           }, ["path"]}
175
176
        "write" ->
177
          {%{"path" => %{"type" => "string"}, "content" => %{"type" => "string"}},
178
           ["path", "content"]}
179
180
        "edit" ->
181
          {%{
182
             "path" => %{"type" => "string"},
183
             "old_string" => %{"type" => "string"},
184
             "new_string" => %{"type" => "string"}
185
           }, ["path", "old_string", "new_string"]}
186
      end
187
188
    %Tool{
189
      module_id: "sarah.tool.openrouter_#{name}_sequence_test",
190
      name: name,
191
      version: 1,
192
      description: description,
193
      input_schema: %{
194
        "type" => "object",
195
        "properties" => properties,
196
        "required" => required,
197
        "additionalProperties" => false
198
      },
199
      output_schema: %{
200
        "type" => "object",
201
        "properties" => %{},
202
        "additionalProperties" => true
203
      },
204
      side_effect: :read_only,
205
      required_scope: "browser_conversation",
206
      required_authority: "repository.read",
207
      executor: %{id: "sarah.local", disclosure: "OpenRouter sequence test executor"},
208
      maintainer: "OpenAgents",
209
      attribution: ["OpenAgentsInc/openagents.com"],
210
      policy_facets: %{"privacy" => "browser_scoped", "residency" => "host"},
211
      module_metadata:
212
        Metadata.first_party("repository.read", "browser_conversation",
213
          effect: :read_only,
214
          privacy: "browser_scoped",
215
          residency: "host"
216
        ),
217
      timeout_ms: 100,
218
      maximum_input_bytes: 2_048,
219
      maximum_output_bytes: 2_048,
220
      implementation: implementation
221
    }
222
  end
223
88 224
  defp tool_execution_context do
89 225
    %ExecutionContext{
90 226
      scope: "browser_conversation",

@@ -705,6 +841,183 @@ defmodule OpenAgents.Chat.OpenRouterTest do

705 841
           } = Jason.decode!(tool_output)
706 842
  end
707 843
844
  test "replays provider output around ordered read, write, edit, and reread calls" do
845
    repository_state = start_supervised!({Agent, fn -> "initial" end})
846
847
    fixtures =
848
      Enum.map(
849
        ["repo_read", "repo_write", "repo_edit", "repo_reread"],
850
        &repository_sequence_fixture/1
851
      )
852
853
    Enum.with_index(fixtures)
854
    |> Enum.each(fn {fixture, index} ->
855
      Req.Test.expect(__MODULE__, fn conn ->
856
        assert conn.request_path == "/api/v1/responses"
857
858
        assert_repository_sequence_history(
859
          conn.body_params["input"],
860
          Enum.take(fixtures, index)
861
        )
862
863
        conn
864
        |> Plug.Conn.put_resp_content_type("text/event-stream")
865
        |> Plug.Conn.send_resp(200, fixture.raw)
866
      end)
867
    end)
868
869
    Req.Test.expect(__MODULE__, fn conn ->
870
      assert_repository_sequence_history(conn.body_params["input"], fixtures)
871
872
      body =
873
        sse(%{
874
          "type" => "response.output_text.delta",
875
          "delta" => "The file now contains alpha gamma."
876
        }) <>
877
          sse(%{
878
            "type" => "response.completed",
879
            "response" => %{
880
              "id" => "resp_repo_final",
881
              "object" => "response",
882
              "status" => "completed",
883
              "model" => "stealth/ox-alpha",
884
              "output" => [
885
                %{
886
                  "type" => "message",
887
                  "id" => "msg_repo_final",
888
                  "role" => "assistant",
889
                  "status" => "completed",
890
                  "content" => [
891
                    %{
892
                      "type" => "output_text",
893
                      "text" => "The file now contains alpha gamma.",
894
                      "annotations" => []
895
                    }
896
                  ]
897
                }
898
              ]
899
            }
900
          }) <> "data: [DONE]\n\n"
901
902
      conn
903
      |> Plug.Conn.put_resp_content_type("text/event-stream")
904
      |> Plug.Conn.send_resp(200, body)
905
    end)
906
907
    parent = self()
908
909
    assert {:ok, tool_registry_snapshot} =
910
             Registry.build([
911
               RepositorySequenceReadToolStub,
912
               RepositorySequenceWriteToolStub,
913
               RepositorySequenceEditToolStub
914
             ])
915
916
    execution_context = %{
917
      tool_execution_context()
918
      | owner_user_id: repository_state
919
    }
920
921
    assert {:ok, %{"assistant_content" => "The file now contains alpha gamma."}} =
922
             OpenRouter.stream(
923
               %{
924
                 "model" => "stealth/ox-alpha",
925
                 "messages" => [
926
                   %{
927
                     "role" => "user",
928
                     "content" => "Read notes.txt, rewrite it, edit it, and read it again."
929
                   }
930
                 ]
931
               },
932
               &send(parent, {:openrouter_event, &1}),
933
               api_key: "test-openrouter-key",
934
               tool_registry_snapshot: tool_registry_snapshot,
935
               tool_execution_context: execution_context,
936
               request_options: [plug: {Req.Test, __MODULE__}]
937
             )
938
939
    assert Agent.get(repository_state, & &1) == "alpha gamma"
940
941
    assert_repository_sequence_events([
942
      {"call_repo_read", "read", "initial"},
943
      {"call_repo_write", "write", "alpha beta"},
944
      {"call_repo_edit", "edit", "alpha gamma"},
945
      {"call_repo_reread", "read", "alpha gamma"}
946
    ])
947
  end
948
949
  defp assert_repository_sequence_history([user_input | history], prior_fixtures) do
950
    assert user_input == %{
951
             "type" => "message",
952
             "role" => "user",
953
             "content" => [
954
               %{
955
                 "type" => "input_text",
956
                 "text" => "Read notes.txt, rewrite it, edit it, and read it again."
957
               }
958
             ]
959
           }
960
961
    remaining =
962
      Enum.reduce(prior_fixtures, history, fn fixture, remaining ->
963
        {provider_output, remaining} = Enum.split(remaining, length(fixture.output))
964
        assert provider_output == fixture.output
965
966
        assert [
967
                 %{
968
                   "type" => "function_call_output",
969
                   "call_id" => call_id,
970
                   "output" => encoded_outcome
971
                 }
972
                 | remaining
973
               ] = remaining
974
975
        assert call_id == fixture.call_id
976
        assert %{"schema" => "sarah.tool_outcome.v1"} = Jason.decode!(encoded_outcome)
977
        remaining
978
      end)
979
980
    assert remaining == []
981
  end
982
983
  defp assert_repository_sequence_events(expected) do
984
    Enum.each(expected, fn {call_id, name, expected_content} ->
985
      assert_receive {:openrouter_event,
986
                      {:tool_call_started, %{"call_id" => ^call_id, "name" => ^name}}}
987
988
      assert_receive {:openrouter_event,
989
                      {:tool_call_completed,
990
                       %{"call_id" => ^call_id, "output" => encoded_outcome}}}
991
992
      assert %{
993
               "status" => "succeeded",
994
               "result" => %{"content" => ^expected_content}
995
             } = Jason.decode!(encoded_outcome)
996
    end)
997
  end
998
999
  defp repository_sequence_fixture(name) do
1000
    raw =
1001
      Path.expand("../../fixtures/openrouter/responses_#{name}_call.sse", __DIR__)
1002
      |> File.read!()
1003
1004
    response =
1005
      raw
1006
      |> String.split("\n")
1007
      |> Enum.filter(&String.starts_with?(&1, "data: "))
1008
      |> Enum.map(&String.replace_prefix(&1, "data: ", ""))
1009
      |> Enum.reject(&(&1 == "[DONE]"))
1010
      |> Enum.map(&Jason.decode!/1)
1011
      |> Enum.find_value(fn
1012
        %{"type" => "response.completed", "response" => response} -> response
1013
        _event -> nil
1014
      end)
1015
1016
    function_call = Enum.find(response["output"], &(&1["type"] == "function_call"))
1017
1018
    %{raw: raw, output: response["output"], call_id: function_call["call_id"]}
1019
  end
1020
708 1021
  defp expected_tool_provider_output do
709 1022
    [
710 1023
      %{
test/openagents/tools/workspace_tools_test.exs added +325

@@ -0,0 +1,325 @@

1
defmodule OpenAgents.Tools.WorkspaceToolsTest do
2
  use ExUnit.Case, async: false
3
4
  alias OpenAgents.Tools.{
5
    ExecutionContext,
6
    Registry,
7
    Runner,
8
    WorkspaceEdit,
9
    WorkspaceFiles,
10
    WorkspaceRead,
11
    WorkspaceWrite
12
  }
13
14
  setup do
15
    base = Path.join(System.tmp_dir!(), "workspace-tools-#{System.unique_integer([:positive])}")
16
    root = Path.join(base, "workspace")
17
    snapshots = Path.join(base, "snapshots")
18
    File.mkdir_p!(root)
19
    previous = Application.get_env(:openagents, :workspace_snapshot_dir)
20
    Application.put_env(:openagents, :workspace_snapshot_dir, snapshots)
21
22
    on_exit(fn ->
23
      if previous,
24
        do: Application.put_env(:openagents, :workspace_snapshot_dir, previous),
25
        else: Application.delete_env(:openagents, :workspace_snapshot_dir)
26
27
      File.rm_rf(base)
28
    end)
29
30
    context = %ExecutionContext{
31
      scope: "browser_conversation",
32
      scope_ref: "conversation:test",
33
      authorities: MapSet.new(["repository.read", "repository.write"]),
34
      workspace: %{
35
        "type" => "repository_workspace",
36
        "root" => root,
37
        "canonical" => false,
38
        "read_only" => false,
39
        "workspace_ref" => "workspace:test"
40
      }
41
    }
42
43
    %{context: context, root: root, snapshots: snapshots}
44
  end
45
46
  test "read returns stable line continuation and enforces both bounds", %{
47
    context: context,
48
    root: root
49
  } do
50
    File.write!(Path.join(root, "lines.txt"), Enum.map_join(1..2_005, "", &"line-#{&1}\n"))
51
    assert {:ok, result} = WorkspaceRead.execute(%{"path" => "lines.txt"}, context)
52
    assert result.result["line_count"] == 2_000
53
    assert result.result["next_offset"] == 2_001
54
55
    assert {:ok, continued} =
56
             WorkspaceRead.execute(%{"path" => "lines.txt", "offset" => 2_001}, context)
57
58
    assert continued.result["content"] ==
59
             "line-2001\nline-2002\nline-2003\nline-2004\nline-2005\n"
60
61
    File.write!(
62
      Path.join(root, "bytes.txt"),
63
      String.duplicate("a", 30_000) <> "\n" <> String.duplicate("b", 30_000)
64
    )
65
66
    assert {:ok, bytes} = WorkspaceRead.execute(%{"path" => "bytes.txt"}, context)
67
    assert bytes.result["returned_bytes"] <= 50 * 1_024
68
    assert bytes.result["next_offset"] == 2
69
70
    File.write!(Path.join(root, "oversized-line.txt"), String.duplicate("x", 50 * 1_024 + 1))
71
72
    assert {:error, :workspace_line_too_large} =
73
             WorkspaceRead.execute(%{"path" => "oversized-line.txt"}, context)
74
  end
75
76
  test "read returns typed missing, directory, traversal, encoding, range, and symlink errors", %{
77
    context: context,
78
    root: root
79
  } do
80
    File.mkdir_p!(Path.join(root, "folder"))
81
    File.write!(Path.join(root, "binary"), <<255>>)
82
    outside = Path.join(Path.dirname(root), "outside")
83
    File.write!(outside, "secret")
84
    File.ln_s!(outside, Path.join(root, "link"))
85
86
    assert {:error, :workspace_file_not_found} =
87
             WorkspaceRead.execute(%{"path" => "missing"}, context)
88
89
    assert {:error, :workspace_path_is_directory} =
90
             WorkspaceRead.execute(%{"path" => "folder"}, context)
91
92
    assert {:error, :workspace_path_escape} =
93
             WorkspaceRead.execute(%{"path" => "../outside"}, context)
94
95
    assert {:error, :workspace_invalid_encoding} =
96
             WorkspaceRead.execute(%{"path" => "binary"}, context)
97
98
    assert {:error, :invalid_read_range} =
99
             WorkspaceRead.execute(%{"path" => "binary", "offset" => 0}, context)
100
101
    assert {:error, :workspace_symlink_refused} =
102
             WorkspaceRead.execute(%{"path" => "link"}, context)
103
  end
104
105
  test "read redacts credential-shaped text before returning it", %{
106
    context: context,
107
    root: root
108
  } do
109
    secret = "sk-or-v1-abcdefghijklmnopqrstuvwxyz012345"
110
111
    File.write!(
112
      Path.join(root, "secret.txt"),
113
      "token=#{secret}\nBearer opaque-token-value-123456\n"
114
    )
115
116
    assert {:ok, result} = WorkspaceRead.execute(%{"path" => "secret.txt"}, context)
117
    refute result.result["content"] =~ secret
118
    refute result.result["content"] =~ "opaque-token-value-123456"
119
    assert result.result["content"] =~ "[REDACTED]"
120
  end
121
122
  test "write creates parents, replaces files, and stores restorable snapshots outside the workspace",
123
       %{
124
         context: context,
125
         root: root,
126
         snapshots: snapshots
127
       } do
128
    assert {:ok, created} =
129
             WorkspaceWrite.execute(%{"path" => "deep/file.txt", "content" => "first"}, context)
130
131
    assert created.result["action"] == "created"
132
    assert created.result["prior_digest"] == nil
133
    assert File.read!(Path.join(root, "deep/file.txt")) == "first"
134
135
    assert {:ok, replaced} =
136
             WorkspaceWrite.execute(%{"path" => "deep/file.txt", "content" => "second"}, context)
137
138
    assert replaced.result["action"] == "replaced"
139
    assert replaced.result["prior_digest"] == WorkspaceFiles.digest("first")
140
141
    snapshot_id =
142
      String.replace_prefix(replaced.result["snapshot_ref"], "workspace-snapshot:", "")
143
144
    assert File.read!(Path.join([snapshots, snapshot_id, "content"])) == "first"
145
    refute File.exists?(Path.join(root, ".openagents"))
146
  end
147
148
  test "write refuses a host snapshot store inside the checkout", %{
149
    context: context,
150
    root: root
151
  } do
152
    Application.put_env(:openagents, :workspace_snapshot_dir, Path.join(root, "snapshots"))
153
154
    assert {:error, :workspace_snapshot_root_invalid} =
155
             WorkspaceWrite.execute(%{"path" => "file.txt", "content" => "content"}, context)
156
157
    refute File.exists?(Path.join(root, "file.txt"))
158
    refute File.exists?(Path.join(root, "snapshots"))
159
  end
160
161
  test "edit applies exact batches atomically and preserves BOM and CRLF", %{
162
    context: context,
163
    root: root
164
  } do
165
    original = <<0xEF, 0xBB, 0xBF>> <> "one\r\ntwo\r\nthree\r\n"
166
    File.write!(Path.join(root, "edit.txt"), original)
167
168
    assert {:ok, edited} =
169
             WorkspaceEdit.execute(
170
               %{
171
                 "path" => "edit.txt",
172
                 "expected_digest" => WorkspaceFiles.digest(original),
173
                 "edits" => [
174
                   %{"old_text" => "one", "new_text" => "ONE"},
175
                   %{"old_text" => "three", "new_text" => "THREE"}
176
                 ]
177
               },
178
               context
179
             )
180
181
    assert edited.result["replacements"] == 2
182
183
    assert File.read!(Path.join(root, "edit.txt")) ==
184
             <<0xEF, 0xBB, 0xBF>> <> "ONE\r\ntwo\r\nTHREE\r\n"
185
  end
186
187
  test "edit rejects ambiguous, overlapping, and stale batches without mutation", %{
188
    context: context,
189
    root: root
190
  } do
191
    path = Path.join(root, "edit.txt")
192
    File.write!(path, "aaa bbb aaa")
193
    digest = WorkspaceFiles.digest(File.read!(path))
194
195
    assert {:error, :ambiguous_match} =
196
             edit(context, digest, [%{"old_text" => "aaa", "new_text" => "x"}])
197
198
    assert {:error, :overlapping_edits} =
199
             edit(context, digest, [
200
               %{"old_text" => "aaa bbb", "new_text" => "x"},
201
               %{"old_text" => "bbb aaa", "new_text" => "y"}
202
             ])
203
204
    assert {:error, :stale_workspace_digest} =
205
             edit(context, String.duplicate("0", 64), [%{"old_text" => "bbb", "new_text" => "x"}])
206
207
    assert File.read!(path) == "aaa bbb aaa"
208
  end
209
210
  test "same-path edits serialize so one stale concurrent writer loses", %{
211
    context: context,
212
    root: root
213
  } do
214
    File.write!(Path.join(root, "edit.txt"), "value")
215
    digest = WorkspaceFiles.digest("value")
216
    parent = self()
217
218
    tasks =
219
      for replacement <- ["first", "second"] do
220
        Task.async(fn ->
221
          send(parent, {:ready, self()})
222
223
          receive do: (:go ->
224
                         edit(context, digest, [
225
                           %{"old_text" => "value", "new_text" => replacement}
226
                         ]))
227
        end)
228
      end
229
230
    for _ <- tasks do
231
      assert_receive {:ready, pid}
232
      send(pid, :go)
233
    end
234
235
    results = Enum.map(tasks, &Task.await/1)
236
    assert Enum.count(results, &match?({:ok, _}, &1)) == 1
237
    assert Enum.count(results, &(&1 == {:error, :stale_workspace_digest})) == 1
238
  end
239
240
  test "connected and canonical repository bindings cannot be mutated", %{
241
    context: context,
242
    root: root
243
  } do
244
    connected = %{context | workspace: %{"type" => "connected_forge_repository", "root" => root}}
245
246
    assert {:error, :workspace_required} =
247
             WorkspaceWrite.execute(%{"path" => "x", "content" => "x"}, connected)
248
249
    canonical = put_in(context.workspace["canonical"], true)
250
251
    assert {:error, :canonical_workspace_refused} =
252
             WorkspaceWrite.execute(%{"path" => "x", "content" => "x"}, canonical)
253
254
    read_only = put_in(context.workspace["read_only"], true)
255
256
    assert {:error, :workspace_read_only} =
257
             WorkspaceWrite.execute(%{"path" => "x", "content" => "x"}, read_only)
258
259
    assert {:error, :workspace_required} =
260
             WorkspaceWrite.execute(
261
               %{"path" => "x", "content" => "x", "root" => root},
262
               %{context | workspace: nil}
263
             )
264
  end
265
266
  test "write refuses symlink components", %{context: context, root: root} do
267
    outside = Path.join(Path.dirname(root), "outside-write")
268
    File.mkdir_p!(outside)
269
    File.ln_s!(outside, Path.join(root, "linked"))
270
271
    assert {:error, :workspace_symlink_refused} =
272
             WorkspaceWrite.execute(%{"path" => "linked/file", "content" => "x"}, context)
273
274
    refute File.exists?(Path.join(outside, "file"))
275
  end
276
277
  test "runner requires repository.write authority and an exact approval receipt", %{
278
    context: context
279
  } do
280
    assert {:ok, snapshot} = Registry.build([WorkspaceWrite])
281
282
    call = %{
283
      call_id: "call-workspace-write",
284
      name: "write",
285
      version: 1,
286
      raw_arguments: Jason.encode!(%{"path" => "approved.txt", "content" => "approved"})
287
    }
288
289
    assert {:ok, refused_approval} = Runner.run(snapshot, call, context)
290
    assert refused_approval["error"]["code"] == "module_approval_required"
291
292
    receipt = %{
293
      "schema" => "sarah.module_approval.v1",
294
      "approval_class" => "exact_current_user_consent",
295
      "module_id" => WorkspaceWrite.specification().module_id,
296
      "version" => 1,
297
      "scope_ref" => context.scope_ref,
298
      "explicit" => true,
299
      "actor_type" => "person",
300
      "receipt_ref" => "approval:workspace-write"
301
    }
302
303
    assert {:ok, refused_authority} =
304
             Runner.run(snapshot, call, %{
305
               context
306
               | authorities: MapSet.new(),
307
                 approval_receipts: [receipt]
308
             })
309
310
    assert refused_authority["error"]["code"] == "authority_refused"
311
312
    assert {:ok, approved} =
313
             Runner.run(snapshot, call, %{context | approval_receipts: [receipt]})
314
315
    assert approved["status"] == "succeeded"
316
    assert approved["result"]["final_digest"] == WorkspaceFiles.digest("approved")
317
  end
318
319
  defp edit(context, digest, edits) do
320
    WorkspaceEdit.execute(
321
      %{"path" => "edit.txt", "expected_digest" => digest, "edits" => edits},
322
      context
323
    )
324
  end
325
end
test/openagents_web/controllers/chat_turn_controller_test.exs modified +73

@@ -33,6 +33,79 @@ defmodule OpenAgentsWeb.ChatTurnControllerTest do

33 33
           |> json_response(401) == %{"error" => "invalid_api_token"}
34 34
  end
35 35
36
  test "events expose the same ordered tool lifecycle as the browser projection", %{conn: conn} do
37
    key = "chat-tool-events"
38
    user = github_user("api-token-" <> key)
39
40
    streamer = fn _request, callback, _options ->
41
      callback.(
42
        {:tool_call_started,
43
         %{
44
           "call_id" => "call-write",
45
           "name" => "write",
46
           "arguments" => ~s({"path":"notes.txt","content":"done"})
47
         }}
48
      )
49
50
      callback.(
51
        {:tool_call_completed,
52
         %{
53
           "call_id" => "call-write",
54
           "output" => %{
55
             "schema" => "sarah.tool_outcome.v1",
56
             "status" => "succeeded",
57
             "result" => %{"bytes_written" => 4},
58
             "workspace" => %{
59
               "type" => "forge_worktree",
60
               "path" => "/private/var/lib/openagents/workspaces/repo"
61
             },
62
             "target_receipt_refs" => ["receipt:write:1"],
63
             "started_at" => "2026-08-22T19:43:28.000Z",
64
             "completed_at" => "2026-08-22T19:43:28.010Z"
65
           }
66
         }}
67
      )
68
69
      {:ok, %{"assistant_content" => "Wrote the file."}}
70
    end
71
72
    assert {:ok, %{"id" => run_id}} =
73
             OpenAgents.Chat.AccountTurns.submit(user, "Write the note.",
74
               subscriber: self(),
75
               streamer: streamer
76
             )
77
78
    assert_receive {:account_chat_completed, ^run_id, {:ok, _completion}}
79
80
    events =
81
      conn
82
      |> put_chat_api_token(key)
83
      |> get(~p"/api/v3/chat/events")
84
      |> json_response(200)
85
      |> Map.fetch!("events")
86
87
    assert Enum.map(events, & &1["type"]) == [
88
             "user_message",
89
             "tool_call_started",
90
             "tool_call_completed",
91
             "response_completed"
92
           ]
93
94
    completed = Enum.at(events, 2)["tool_call"]
95
96
    [browser_tool] =
97
      OpenAgents.Chat.AccountTurns.list_messages(user) |> List.last() |> Map.fetch!(:tool_calls)
98
99
    assert completed["status"] == browser_tool.status
100
    assert completed["state"] == browser_tool.state
101
    assert completed["workspace"] == browser_tool.workspace
102
    assert completed["duration_ms"] == browser_tool.duration_ms
103
    assert completed["receipt_refs"] == browser_tool.receipt_refs
104
    assert completed["output"] == browser_tool.output
105
    assert completed["workspace"]["path"] == "repo"
106
    refute inspect(events) =~ "/private/var/lib/openagents"
107
  end
108
36 109
  test "chat API requires a bearer token", %{conn: conn} do
37 110
    assert conn |> get(~p"/api/v3/chat/events") |> json_response(401) == %{
38 111
             "error" => "invalid_api_token"
test/openagents_web/live/chat_placeholder_test.exs modified +97

@@ -147,4 +147,101 @@ defmodule OpenAgentsWeb.ChatPlaceholderTest do

147 147
    assert final_reasoning.text == "Report the failure."
148 148
    assert is_nil(final_reasoning.duration)
149 149
  end
150
151
  test "the browser renders durable tool workspace, duration, output, and receipts", %{conn: conn} do
152
    key = "placeholder-tool-metadata"
153
    user = github_user(key)
154
    conn = log_in_admin_user(conn, key)
155
156
    streamer = fn _request, callback, _options ->
157
      callback.(
158
        {:tool_call_started,
159
         %{
160
           "call_id" => "call-read",
161
           "name" => "read",
162
           "arguments" => ~s({"path":"README.md"})
163
         }}
164
      )
165
166
      callback.(
167
        {:tool_call_completed,
168
         %{
169
           "call_id" => "call-read",
170
           "output" => %{
171
             "schema" => "sarah.tool_outcome.v1",
172
             "status" => "succeeded",
173
             "result" => %{"content" => "OpenAgents"},
174
             "workspace" => %{
175
               "type" => "forge_worktree",
176
               "path" => "/private/var/lib/openagents/workspaces/repo"
177
             },
178
             "target_receipt_refs" => ["receipt:read:1"],
179
             "started_at" => "2026-08-22T19:43:28.000Z",
180
             "completed_at" => "2026-08-22T19:43:28.025Z"
181
           }
182
         }}
183
      )
184
185
      {:ok, %{"assistant_content" => "Read the file."}}
186
    end
187
188
    assert {:ok, %{"id" => run_id}} =
189
             OpenAgents.Chat.AccountTurns.submit(user, "Read the README.",
190
               subscriber: self(),
191
               streamer: streamer
192
             )
193
194
    assert_receive {:account_chat_completed, ^run_id, {:ok, _completion}}
195
    {:ok, view, _html} = live(conn, ~p"/chat")
196
197
    block_id = "chat-placeholder-block-#{run_id}-0"
198
    assert has_element?(view, "##{block_id}-metadata", "succeeded")
199
    assert has_element?(view, "##{block_id}-metadata", "repo")
200
    refute render(view) =~ "/private/var/lib/openagents"
201
    assert has_element?(view, "##{block_id}-metadata", "25 ms")
202
    assert has_element?(view, "##{block_id}-output", "OpenAgents")
203
    assert has_element?(view, "##{block_id}-receipts", "receipt:read:1")
204
  end
205
206
  test "streaming typed tool failures retain status and error code" do
207
    socket =
208
      %Phoenix.LiveView.Socket{}
209
      |> Phoenix.Component.assign(:stream_id, 84)
210
      |> Phoenix.Component.assign(:streaming?, true)
211
      |> Phoenix.Component.assign(:assistant_tool_calls, [])
212
      |> Phoenix.Component.assign(:assistant_blocks, [])
213
214
    {:noreply, socket} =
215
      OpenAgentsWeb.ChatPlaceholderLive.handle_info(
216
        {:openrouter_stream_event, 84,
217
         {:tool_call_started, %{"call_id" => "call-edit", "name" => "edit", "arguments" => "{}"}}},
218
        socket
219
      )
220
221
    {:noreply, socket} =
222
      OpenAgentsWeb.ChatPlaceholderLive.handle_info(
223
        {:openrouter_stream_event, 84,
224
         {:tool_call_failed,
225
          %{
226
            "call_id" => "call-edit",
227
            "output" => %{
228
              "schema" => "sarah.tool_outcome.v1",
229
              "status" => "failed",
230
              "error" => %{
231
                "code" => "workspace_read_only",
232
                "message" => "The workspace is read-only."
233
              },
234
              "workspace" => %{"path" => "/private/var/lib/openagents/workspaces/repo"}
235
            }
236
          }}},
237
        socket
238
      )
239
240
    assert [%{tool_call: tool}] = socket.assigns.assistant_blocks
241
    assert tool.status == "failed"
242
    assert tool.state == "output-error"
243
    assert tool.error_code == "workspace_read_only"
244
    assert tool.error == "The workspace is read-only."
245
    assert tool.workspace_label == "repo"
246
  end
150 247
end

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