lib/openagents/computer_agent_jobs.ex

main at 58e6347eeb72 · 6 KB

defmodule OpenAgents.ComputerAgentJobs do
  @moduledoc """
  Owner-scoped entry point for durable ACP coding-agent delegations.

  Both the `computer_agent` model tool and the signed-in Computers API enter
  here so ownership, presence, probe evidence, cwd scope, and durable job
  construction cannot drift between surfaces.
  """

  alias OpenAgents.Accounts.User
  alias OpenAgents.Computer
  alias OpenAgents.Conversations.{Conversation, Visitor}
  alias OpenAgents.Machines.Machine
  alias OpenAgents.{Repo, Work}

  @delegation_timeout_ms 3_600_000

  def start(user, machine, conversation, params, opts \\ [])

  def start(
        %User{} = user,
        %Machine{} = machine,
        %Conversation{} = conversation,
        params,
        opts
      )
      when is_map(params) do
    agent_id = params["agent_id"]
    prompt = params["prompt"]
    cwd = normalize_path(params["cwd"])
    resume_session_id = bounded_optional(params["resume_session_id"], 128)
    surface = if Keyword.get(opts, :surface) == "voice", do: "voice", else: "text"
    timeout_ms = bounded_timeout(params["timeout_ms"])

    with :ok <- verify_enabled(),
         :ok <- validate_owner(user, machine, conversation),
         :ok <- validate_machine(machine),
         :ok <- validate_agent(machine, agent_id),
         :ok <- validate_prompt(prompt),
         :ok <- validate_cwd(machine.roots, cwd) do
      owner = Repo.get!(Visitor, conversation.visitor_id)

      delegation =
        %{
          "agent_id" => agent_id,
          "machine_id" => machine.id,
          "machine_name" => machine.name,
          "prompt" => prompt,
          "timeout_ms" => timeout_ms,
          "cwd" => cwd
        }
        |> put_optional("resume_session_id", resume_session_id)
        |> put_optional("assignment_id", bounded_optional(params["assignment_id"], 64))
        |> put_optional("assignment_branch", bounded_optional(params["assignment_branch"], 256))
        |> put_optional("assignment_repository_id", params["assignment_repository_id"])

      authority_snapshot = %{
        "machine_tier" => machine.tier,
        "roots" => machine.roots,
        "cwd" => cwd,
        "agent_id" => agent_id,
        "machine_name" => machine.name
      }

      budget_snapshot = %{
        "wall_clock_ms" => timeout_ms,
        "maximum_prompt_bytes" => 8_000,
        "maximum_report_bytes" => 8_000
      }

      attributes = %{
        conversation_id: conversation.id,
        owner_visitor_id: owner.id,
        machine_id: machine.id,
        surface: surface,
        goal: String.slice("Delegate to #{agent_id} on #{machine.name}: #{prompt}", 0, 2_000),
        delegation: delegation,
        authority_snapshot: authority_snapshot,
        budget_snapshot: budget_snapshot
      }

      Work.start_delegation(attributes)
    else
      {:error, reason} -> {:error, reason}
    end
  end

  def start(%User{}, %Machine{}, %Conversation{}, _params, _opts),
    do: {:error, :invalid_delegation_request}

  defp verify_enabled do
    if Computer.enabled?(), do: :ok, else: {:error, :computer_controller_disabled}
  end

  defp validate_owner(
         %User{id: user_id},
         %Machine{user_id: user_id},
         %Conversation{visitor_id: visitor_id}
       ) do
    case Repo.get(Visitor, visitor_id) do
      %Visitor{user_id: ^user_id} -> :ok
      _other -> {:error, :conversation_not_found}
    end
  end

  defp validate_owner(_user, _machine, _conversation), do: {:error, :machine_not_found}

  defp validate_machine(%Machine{status: "active", id: machine_id}) do
    if Computer.online?(machine_id), do: :ok, else: {:error, :machine_offline}
  end

  defp validate_machine(%Machine{}), do: {:error, :machine_revoked}

  defp validate_agent(%Machine{last_probe: %{"acp_agents" => agents}}, agent_id)
       when is_list(agents) and is_binary(agent_id) and byte_size(agent_id) <= 64 do
    if String.trim(agent_id) != "" and
         Enum.any?(agents, &(is_map(&1) and &1["id"] == agent_id)) do
      :ok
    else
      {:error, :agent_not_available}
    end
  end

  defp validate_agent(%Machine{}, agent_id) when is_binary(agent_id),
    do: {:error, :agent_not_available}

  defp validate_agent(%Machine{}, _agent_id), do: {:error, :invalid_delegation_request}

  defp validate_prompt(prompt) when is_binary(prompt) do
    if String.trim(prompt) != "" and byte_size(prompt) <= 8_000,
      do: :ok,
      else: {:error, :invalid_delegation_request}
  end

  defp validate_prompt(_prompt), do: {:error, :invalid_delegation_request}

  defp validate_cwd(roots, cwd) when is_list(roots) and is_binary(cwd) and cwd != "" do
    if path_without_parent?(cwd) and Enum.any?(roots, &inside_root?(cwd, &1)),
      do: :ok,
      else: {:error, :cwd_not_allowed}
  end

  defp validate_cwd(_roots, _cwd), do: {:error, :cwd_not_allowed}

  defp bounded_timeout(value) when is_integer(value) do
    max(min(value, @delegation_timeout_ms), 1_000)
  end

  defp bounded_timeout(_value), do: @delegation_timeout_ms

  defp inside_root?(cwd, root) when is_binary(root) do
    normalized_root = normalize_path(root)

    normalized_root != "" and path_without_parent?(normalized_root) and
      (path_equal?(cwd, normalized_root) or
         String.starts_with?(path_compare(cwd), path_compare(normalized_root) <> "/"))
  end

  defp inside_root?(_cwd, _root), do: false

  defp path_without_parent?(path) do
    path
    |> String.split("/", trim: true)
    |> Enum.all?(&(&1 not in [".", ".."]))
  end

  defp path_equal?(left, right), do: path_compare(left) == path_compare(right)

  defp path_compare(<<drive, ?:, _rest::binary>> = path)
       when drive in ?A..?Z or drive in ?a..?z,
       do: String.downcase(path)

  defp path_compare(path), do: path

  defp normalize_path(path) when is_binary(path) do
    path
    |> String.trim()
    |> String.replace("\\", "/")
    |> String.trim_trailing("/")
    |> String.slice(0, 500)
  end

  defp normalize_path(_path), do: ""

  defp bounded_optional(value, maximum) when is_binary(value) do
    case String.trim(value) do
      "" -> nil
      trimmed -> String.slice(trimmed, 0, maximum)
    end
  end

  defp bounded_optional(_value, _maximum), do: nil

  defp put_optional(payload, _key, nil), do: payload
  defp put_optional(payload, key, value), do: Map.put(payload, key, value)
end