lib/openagents/tools/computer_probe.ex

58e6347eeb72 · 7 KB

defmodule OpenAgents.Tools.ComputerProbe do
  @moduledoc """
  Asks a paired computer what it has: host facts, coding agents, and toolchains.

  Read-only. The controller runs a fixed discovery set locally and refuses
  anything else; if the computer is offline the outcome is typed rather than
  imagined.
  """

  @behaviour OpenAgents.Tools.Tool

  alias OpenAgents.{Computer, Machines}
  alias OpenAgents.Modules.Metadata
  alias OpenAgents.Tools.{ExecutionResult, OwnerContext, Tool}

  @maximum_entries 40

  @impl true
  def specification do
    %Tool{
      module_id: "sarah.tool.computer_probe.v1",
      name: "computer_probe",
      version: 1,
      description:
        "Discovers what is installed on one of the user's paired computers: operating system, " <>
          "coding agents (claude, codex, cursor-agent, aider and similar), and toolchains " <>
          "(git, node, python, cargo and similar), including each ACP agent's configured " <>
          "model, reasoning effort, and mode when declared. Pass machine_id from computer_list. " <>
          "Discovery is read-only; it never changes the computer.",
      input_schema: input_schema(),
      output_schema: output_schema(),
      side_effect: :read_only,
      required_scope: "browser_conversation",
      required_authority: "computer.control",
      executor: %{
        id: "sarah.computer.controller",
        disclosure: "Read-only discovery on the user's paired computer"
      },
      maintainer: "OpenAgents",
      attribution: ["OpenAgentsInc/openagents.com", "OpenAgentsInc/sarah-computer-controller"],
      policy_facets: %{
        "privacy" => "signed_browser_owner",
        "residency" => "operator_machine",
        "consent" => "computer_pairing"
      },
      module_metadata:
        Metadata.first_party("computer.control", "browser_conversation",
          effect: :read_only,
          privacy: "signed_browser_owner",
          residency: "operator_machine"
        ),
      timeout_ms: 20_000,
      maximum_input_bytes: 512,
      maximum_output_bytes: 32_768,
      implementation: __MODULE__,
      reach: [:signed_in_owner, :paired_computer]
    }
  end

  @impl true
  def execute(%{"machine_id" => machine_id}, context) when is_binary(machine_id) do
    with {:ok, user} <- OwnerContext.resolve(context),
         {:ok, machine} <- Machines.get_machine(user.id, machine_id) do
      case probe(machine) do
        {:ok, report, freshness} ->
          {:ok,
           %ExecutionResult{
             result: %{
               "schema" => "sarah.computer_probe_result.v1",
               "status" => "ok",
               "machine_id" => machine.id,
               "machine_name" => machine.name,
               "freshness" => freshness,
               "platform" => text(report["platform"], 60),
               "release" => text(report["release"], 80),
               "architecture" => text(report["architecture"], 30),
               "shell" => text(report["shell"], 80),
               "acp_agents" => acp_agents(report["acp_agents"]),
               "coding_agents" => present(report["coding_agents"]),
               "toolchains" => present(report["toolchains"])
             },
             target_receipt_refs: ["machine:#{machine.id}"]
           }}

        {:error, reason} ->
          {:ok,
           %ExecutionResult{
             result: %{
               "schema" => "sarah.computer_probe_result.v1",
               "status" => Atom.to_string(reason),
               "machine_id" => machine.id,
               "machine_name" => machine.name,
               "freshness" => "unavailable",
               "platform" => "unknown",
               "release" => "unknown",
               "architecture" => "unknown",
               "shell" => "unknown",
               "acp_agents" => [],
               "coding_agents" => [],
               "toolchains" => []
             },
             target_receipt_refs: ["machine:#{machine.id}"]
           }}
      end
    end
  end

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

  defp probe(machine) do
    case Computer.request_probe(machine.id) do
      {:ok, report} ->
        {:ok, report, "live"}

      {:error, reason} ->
        case machine.last_probe do
          report when is_map(report) -> {:ok, report, "last_known"}
          _absent -> {:error, reason}
        end
    end
  end

  defp present(entries) when is_list(entries) do
    entries
    |> Enum.filter(&(is_map(&1) and &1["present"] == true))
    |> Enum.take(@maximum_entries)
    |> Enum.map(
      &%{
        "name" => text(&1["name"], 40),
        "path" => text(&1["path"], 200),
        "version" => text(&1["version"], 60)
      }
    )
  end

  defp present(_entries), do: []

  defp acp_agents(entries) when is_list(entries) do
    entries
    |> Enum.take(@maximum_entries)
    |> Enum.flat_map(fn
      %{"id" => id} = agent when is_binary(id) and id != "" ->
        [
          %{
            "id" => text(id, 64),
            "source" => text(agent["source"], 40),
            "version" => text(agent["version"], 80),
            "model" => optional_text(agent["model"], 128, "default"),
            "reasoning_effort" => optional_text(agent["reasoning_effort"], 32, "default"),
            "mode" => optional_text(agent["mode"], 32, "default")
          }
        ]

      _invalid ->
        []
    end)
  end

  defp acp_agents(_entries), do: []

  defp optional_text(value, maximum, _fallback) when is_binary(value) and value != "",
    do: String.slice(value, 0, maximum)

  defp optional_text(_value, _maximum, fallback), do: fallback

  defp text(value, maximum) when is_binary(value), do: String.slice(value, 0, maximum)
  defp text(_value, _maximum), do: "unknown"

  defp input_schema do
    %{
      "type" => "object",
      "properties" => %{"machine_id" => %{"type" => "string", "maxLength" => 64}},
      "required" => ["machine_id"],
      "additionalProperties" => false
    }
  end

  defp output_schema do
    entry_schema = %{
      "type" => "object",
      "properties" => %{
        "name" => string(40),
        "path" => string(200),
        "version" => string(60)
      },
      "required" => ["name", "path", "version"],
      "additionalProperties" => false
    }

    entries = %{"type" => "array", "maxItems" => @maximum_entries, "items" => entry_schema}

    acp_entry_schema = %{
      "type" => "object",
      "properties" => %{
        "id" => string(64),
        "source" => string(40),
        "version" => string(80),
        "model" => string(128),
        "reasoning_effort" => string(32),
        "mode" => string(32)
      },
      "required" => ["id", "source", "version", "model", "reasoning_effort", "mode"],
      "additionalProperties" => false
    }

    acp_entries = %{
      "type" => "array",
      "maxItems" => @maximum_entries,
      "items" => acp_entry_schema
    }

    %{
      "type" => "object",
      "properties" => %{
        "schema" => string(64),
        "status" => string(32),
        "machine_id" => string(64),
        "machine_name" => string(80),
        "freshness" => string(16),
        "platform" => string(60),
        "release" => string(80),
        "architecture" => string(30),
        "shell" => string(80),
        "acp_agents" => acp_entries,
        "coding_agents" => entries,
        "toolchains" => entries
      },
      "required" => [
        "schema",
        "status",
        "machine_id",
        "machine_name",
        "freshness",
        "platform",
        "release",
        "architecture",
        "shell",
        "acp_agents",
        "coding_agents",
        "toolchains"
      ],
      "additionalProperties" => false
    }
  end

  defp string(maximum), do: %{"type" => "string", "maxLength" => maximum}
end