lib/openagents/tools/issue_capture.ex

main at 58e6347eeb72 · 8 KB

defmodule OpenAgents.Tools.IssueCapture do
  @moduledoc """
  Files one unmet request from a conversation as a scoped forge issue.

  The behavior lives in `OpenAgents.Issues.Capture`, which
  `POST /api/v1/repos/:owner/:repo/issues/capture` also calls, so the tool and
  the API operation cannot drift apart.

  ## Why this one is `:external_effect`

  A filed issue is public, has a number other people cite, and notifies
  everyone watching the repository. Nothing on disk changed, which is exactly
  why the honest classification is easy to get wrong; the coder specification
  settles it directly — "'open an issue' is an external effect even though
  nothing on your disk changed" (`docs/2026-08-23-openagents-coder-cli-spec.md`,
  section 7.2). Its ladder puts tracker writes at **ask every time**, so this
  tool declares `approval_class: "external_confirmation"` — the class
  `OpenAgents.Modules.SurfacePolicy` requires of an external effect, and the
  one `open_pull_request` already uses for the same reason — with
  `approval_enforcement: "host_receipt"`.

  `OpenAgents.Modules.SurfacePolicy.approval/2` then demands a current,
  explicit, person-signed receipt bound to this exact module, version, and
  conversation before the tool runs — and `OpenAgents.Tools.AdmittedCatalog`
  applies the same check when it builds the catalog, so on a turn where the
  person has not consented the tool is not offered rather than offered and
  refused. There is no standing grant and no "always" to record: the receipt is
  scoped to one conversation and checked again on every call.

  ## Authority is the caller's

  The requesting account's own membership decides where an issue can be filed.
  `Capture.authorize/2` reads `OpenAgents.Repositories.writable?/2` for that
  account and nothing else, so the agent cannot file somewhere the person could
  not have filed by hand, and a caller without a writing role gets
  `repository_write_access_required` naming what is missing. The repository is
  always an argument, never a default: a request that names no repository is
  refused rather than routed to a guess.
  """

  @behaviour OpenAgents.Tools.Tool

  alias OpenAgents.Accounts.User
  alias OpenAgents.Issues.Capture
  alias OpenAgents.Modules.Metadata
  alias OpenAgents.Repo
  alias OpenAgents.Tools.{ExecutionContext, ExecutionResult, Tool}

  @impl true
  def specification do
    %Tool{
      module_id: "sarah.tool.issue_capture.v1",
      name: "capture_issue",
      version: 1,
      description:
        "File the person's feature request or bug report as an issue in a Forge repository " <>
          "they can write to, drafted as outcome, current behavior, and acceptance criteria. " <>
          "Returns the issue number and URL, and returns the existing issue instead of a " <>
          "second one when an open issue already has the same title. " <>
          "Do not call this to answer a question, to look something up, or to record a " <>
          "preference or a note — filing a public issue is not a way to remember something. " <>
          "Do not call it without the person asking for the request to be tracked, and do not " <>
          "call it a second time for a request you already filed in this conversation. " <>
          "Do not guess the repository: ask which one if the person has not named it.",
      input_schema: input_schema(),
      output_schema: output_schema(),
      side_effect: :external_effect,
      required_scope: "browser_conversation",
      required_authority: "repository.write",
      executor: %{
        id: "sarah.forge.issues",
        disclosure: "OpenAgents Forge, filing as the signed-in person"
      },
      maintainer: "OpenAgents",
      attribution: ["OpenAgentsInc/openagents.com"],
      policy_facets: %{
        "privacy" => "signed_browser_owner",
        "residency" => "application_process",
        "consent" => "external_confirmation"
      },
      module_metadata:
        Metadata.first_party("repository.write", "browser_conversation",
          effect: :external_effect,
          privacy: "signed_browser_owner",
          residency: "application_process",
          approval_class: "external_confirmation",
          approval_enforcement: "host_receipt"
        ),
      timeout_ms: 15_000,
      maximum_input_bytes: 16_384,
      maximum_output_bytes: 8_192,
      implementation: __MODULE__,
      tags: ["forge", "issue", "tracker", "request", "file"],
      # Filing runs under the person's own membership, so an unresolved owner
      # can never succeed. The repository itself carries no reach requirement:
      # its gate is per-repository write membership, which depends on an
      # argument the catalog has not seen (TOOL-005).
      reach: [:signed_in_owner]
    }
  end

  @impl true
  def execute(%{"repository" => repository} = arguments, %ExecutionContext{} = context)
      when is_binary(repository) do
    with {:ok, actor} <- actor(context),
         {:ok, captured} <- Capture.capture(actor, repository, arguments) do
      {:ok,
       %ExecutionResult{
         result: result(captured),
         target_receipt_refs: ["forge-issue:#{captured.issue.id}"]
       }}
    else
      {:error, {:invalid_issue, _errors}} -> {:error, :invalid_issue}
      {:error, reason} -> {:error, reason}
    end
  end

  def execute(_arguments, _context), do: {:error, :invalid_repository}

  defp actor(%ExecutionContext{owner_user_id: user_id}) when is_binary(user_id) do
    case Repo.get(User, user_id) do
      %User{status: "active"} = user -> {:ok, user}
      _absent -> {:error, :repository_authentication_required}
    end
  end

  defp actor(%ExecutionContext{}), do: {:error, :repository_authentication_required}

  defp result(%{issue: issue, repository: repository, outcome: outcome}) do
    path = repository.owner <> "/" <> repository.name

    %{
      "schema" => "openagents.captured_issue.v1",
      "repository" => path,
      "number" => issue.number,
      "title" => issue.title,
      "state" => issue.state,
      "url" => url(path, issue.number),
      "outcome" => Atom.to_string(outcome)
    }
  end

  defp url(path, number) do
    String.trim_trailing(OpenAgentsWeb.Endpoint.url(), "/") <> "/#{path}/issues/#{number}"
  end

  defp input_schema do
    %{
      "type" => "object",
      "additionalProperties" => false,
      "required" => ["repository", "problem"],
      "properties" => %{
        "repository" => %{
          "type" => "string",
          "description" =>
            "The repository to file in, as owner/name. Required, and never guessed: " <>
              "ask the person which repository if they have not said."
        },
        "problem" => %{
          "type" => "string",
          "description" =>
            "The outcome the person wants, in their own words. Do not add anything they " <>
              "did not say, and do not include conversation, prompt, or credential text."
        },
        "current_behavior" => %{
          "type" => "string",
          "description" =>
            "What happens today, if the person described it. Leave this out rather than " <>
              "inventing behavior nobody observed."
        },
        "acceptance_criteria" => %{
          "type" => "array",
          "items" => %{"type" => "string"},
          "description" =>
            "What must be true for the request to be done, if the person said. " <>
              "Leave this out rather than inventing criteria."
        }
      }
    }
  end

  defp output_schema do
    %{
      "type" => "object",
      "required" => ["schema", "repository", "number", "url", "outcome"],
      "properties" => %{
        "schema" => %{"type" => "string"},
        "repository" => %{"type" => "string"},
        "number" => %{"type" => "integer"},
        "title" => %{"type" => "string"},
        "state" => %{"type" => "string"},
        "url" => %{"type" => "string"},
        "outcome" => %{
          "type" => "string",
          "enum" => ["created", "existing"],
          "description" =>
            "`created` for a new issue, `existing` when an open issue already matched."
        }
      }
    }
  end
end