lib/openagents/tools/conversation_search.ex

main at 58e6347eeb72 · 5 KB

defmodule OpenAgents.Tools.ConversationSearch do
  @moduledoc "First-party read-only recall discovery within one frozen conversation snapshot."

  @behaviour OpenAgents.Tools.Tool

  alias OpenAgents.Modules.Metadata
  alias OpenAgents.Tools.{ExecutionResult, RecallContext, Tool}

  @impl true
  def specification do
    %Tool{
      module_id: "sarah.tool.conversation_search.v1",
      name: "conversation_search",
      version: 1,
      description:
        "Finds candidate messages and durable tool activity (completed tool steps from text " <>
          "and voice, matched on tool name, status, and bounded result text) in the current " <>
          "conversation before relying on older context",
      input_schema: input_schema(),
      output_schema: output_schema(),
      side_effect: :read_only,
      required_scope: "browser_conversation",
      required_authority: "conversation.read",
      executor: %{
        id: "sarah.postgres.recall",
        disclosure: "Sarah conversation recall"
      },
      maintainer: "OpenAgents",
      attribution: ["OpenAgentsInc/openagents.com"],
      policy_facets: %{
        "privacy" => "browser_conversation",
        "residency" => "application_postgres",
        "ranking" => "configured_lexical_or_hybrid"
      },
      module_metadata:
        Metadata.first_party("conversation.read", "browser_conversation",
          effect: :read_only,
          privacy: "browser_conversation",
          residency: "application_postgres"
        ),
      timeout_ms: 5_000,
      maximum_input_bytes: 1_024,
      maximum_output_bytes: 16_384,
      implementation: __MODULE__
    }
  end

  @impl true
  def execute(arguments, context) do
    with {:ok, conversation, snapshot} <- RecallContext.resolve(context),
         {:ok, options} <- search_options(arguments),
         {:ok, page} <-
           recall_backend().search_page(conversation, snapshot, arguments["query"], options) do
      matches = Enum.map(page.matches, &match_output/1)

      {:ok,
       %ExecutionResult{
         result: %{
           "schema" => "sarah.conversation_search_result.v1",
           "scope" => "browser_conversation",
           "snapshot_ref" => context.memory_snapshot_ref,
           "status" => if(matches == [], do: "empty", else: "matches"),
           "ranking" => page.strategy,
           "semantic_degraded" => page.semantic_degraded,
           "semantic_reason" => page.semantic_reason || "none",
           "matches" => matches,
           "truncated" => page.truncated
         },
         target_receipt_refs: Enum.map(matches, & &1["source_ref"])
       }}
    end
  end

  defp recall_backend,
    do: Application.fetch_env!(:openagents, :recall_search_backend)

  defp search_options(arguments) do
    with {:ok, before} <- parse_time(Map.get(arguments, "before")),
         {:ok, after_time} <- parse_time(Map.get(arguments, "after")) do
      {:ok,
       [
         first: Map.get(arguments, "first", 5),
         before: before,
         after: after_time
       ]}
    end
  end

  defp parse_time(nil), do: {:ok, nil}

  defp parse_time(value) when is_binary(value) do
    case DateTime.from_iso8601(value) do
      {:ok, timestamp, 0} -> {:ok, timestamp}
      _invalid -> {:error, :invalid_time_bound}
    end
  end

  defp parse_time(_value), do: {:error, :invalid_time_bound}

  defp match_output(match) do
    %{
      "source_ref" => match.source_ref,
      "role" => match.role,
      "observed_at" => DateTime.to_iso8601(match.observed_at),
      "excerpt" => match.excerpt,
      "score" => match.score,
      "rank" => match.rank,
      "truncated" => match.truncated
    }
  end

  defp input_schema do
    %{
      "type" => "object",
      "properties" => %{
        "query" => %{"type" => "string", "maxLength" => 512},
        "before" => %{"type" => "string", "maxLength" => 64},
        "after" => %{"type" => "string", "maxLength" => 64},
        "first" => %{"type" => "integer"}
      },
      "required" => ["query"],
      "additionalProperties" => false
    }
  end

  defp output_schema do
    %{
      "type" => "object",
      "properties" => %{
        "schema" => %{"type" => "string", "maxLength" => 64},
        "scope" => %{"type" => "string", "maxLength" => 64},
        "snapshot_ref" => %{"type" => "string", "maxLength" => 64},
        "status" => %{"type" => "string", "maxLength" => 16},
        "ranking" => %{"type" => "string", "maxLength" => 32},
        "semantic_degraded" => %{"type" => "boolean"},
        "semantic_reason" => %{"type" => "string", "maxLength" => 64},
        "matches" => %{
          "type" => "array",
          "maxItems" => 10,
          "items" => %{
            "type" => "object",
            "properties" => %{
              "source_ref" => %{"type" => "string", "maxLength" => 64},
              "role" => %{"type" => "string", "maxLength" => 16},
              "observed_at" => %{"type" => "string", "maxLength" => 40},
              "excerpt" => %{"type" => "string", "maxLength" => 800},
              "score" => %{"type" => "number"},
              "rank" => %{"type" => "integer"},
              "truncated" => %{"type" => "boolean"}
            },
            "required" => [
              "source_ref",
              "role",
              "observed_at",
              "excerpt",
              "score",
              "rank",
              "truncated"
            ],
            "additionalProperties" => false
          }
        },
        "truncated" => %{"type" => "boolean"}
      },
      "required" => [
        "schema",
        "scope",
        "snapshot_ref",
        "status",
        "ranking",
        "semantic_degraded",
        "semantic_reason",
        "matches",
        "truncated"
      ],
      "additionalProperties" => false
    }
  end
end