lib/openagents/tools/incident_lookup.ex

58e6347eeb72 · 9 KB

defmodule OpenAgents.Tools.IncidentLookup do
  @moduledoc """
  First-party `incident_lookup.v1`: lets Sarah read durable failure evidence
  so she can answer "why did that fail?" without guessing.

  Read-only and owner-scoped. Returns recent incidents *and* recent work-job
  reports for this conversation. A job that just timed out is the cause of
  "why did that fail?"; an incident hours earlier is previous context.
  """

  @behaviour OpenAgents.Tools.Tool

  alias OpenAgents.Conversations.Conversation
  alias OpenAgents.Incidents
  alias OpenAgents.Modules.Metadata
  alias OpenAgents.Tools.{ExecutionResult, OwnerContext, Tool}
  alias OpenAgents.Work

  @maximum_listed 20
  @maximum_jobs 5
  @maximum_report_excerpt 500

  @impl true
  def specification do
    %Tool{
      module_id: "sarah.tool.incident_lookup.v1",
      name: "incident_lookup",
      version: 1,
      description:
        "Reads durable failure evidence for the signed-in owner so you can " <>
          "explain why something failed instead of guessing. Use it whenever " <>
          "the person asks why a turn, delegation, voice session, or job " <>
          "failed. Returns recent incidents AND recent work-job reports. " <>
          "Prefer jobs[] when it is more recent than incidents[] — a just-" <>
          "failed delegation is the cause; an incident hours earlier is " <>
          "previous context, not the cause. Honor primary. scope " <>
          "'conversation' (default) limits to this conversation; 'owner' " <>
          "spans recent incidents. Optional correlation_ref filters to one " <>
          "job or turn id.",
      input_schema: input_schema(),
      output_schema: output_schema(),
      side_effect: :read_only,
      required_scope: "browser_conversation",
      required_authority: "conversation.read",
      executor: %{
        id: "sarah.incidents",
        disclosure: "Durable failure records for the signed-in owner"
      },
      maintainer: "OpenAgents",
      attribution: ["OpenAgentsInc/openagents.com"],
      policy_facets: %{
        "privacy" => "signed_browser_owner",
        "residency" => "application_process",
        "consent" => "not_applicable"
      },
      module_metadata:
        Metadata.first_party("conversation.read", "browser_conversation",
          effect: :read_only,
          privacy: "signed_browser_owner",
          residency: "application_process"
        ),
      timeout_ms: 10_000,
      maximum_input_bytes: 256,
      maximum_output_bytes: 32_768,
      implementation: __MODULE__,
      tags: ~w(incident error failure diagnosis debug analyze why),
      reach: [:signed_in_owner]
    }
  end

  @impl true
  def execute(arguments, context) do
    with {:ok, user} <- OwnerContext.resolve(context) do
      scope = if arguments["scope"] == "owner", do: :owner, else: :conversation
      limit = clamp(arguments["limit"])
      correlation_ref = normalize_ref(arguments["correlation_ref"])

      opts =
        [limit: limit] ++
          if(scope == :conversation and is_binary(context.conversation_id),
            do: [conversation_id: context.conversation_id],
            else: []
          )

      incidents =
        user.id
        |> Incidents.list_recent(opts)
        |> Enum.filter(&matches_ref?(&1.correlation_ref, correlation_ref))

      listed = Enum.map(incidents, &summarize(&1, user.id))

      jobs =
        context.conversation_id
        |> recent_jobs()
        |> Enum.filter(&matches_ref?(&1.id, correlation_ref))
        |> Enum.map(&summarize_job/1)

      primary = primary_source(listed, jobs)

      {:ok,
       %ExecutionResult{
         result: %{
           "schema" => "sarah.incident_lookup_result.v1",
           "scope" => Atom.to_string(scope),
           "status" => status(listed, jobs),
           "primary" => primary,
           "guidance" => guidance(primary),
           "incidents" => listed,
           "jobs" => jobs
         },
         target_receipt_refs: []
       }}
    end
  end

  defp summarize(incident, owner_user_id) do
    %{
      "id" => incident.id,
      "code" => incident.code,
      "severity" => incident.severity,
      "origin" => incident.origin,
      "surface" => incident.surface,
      "summary" => incident.summary || "",
      "status" => incident.status,
      "correlation_ref" => incident.correlation_ref || "",
      "occurred_at" => DateTime.to_iso8601(incident.inserted_at),
      "recurrence_count" => Incidents.recurrence_count(owner_user_id, incident.code),
      "being_fixed" => not is_nil(incident.fixer_job_id)
    }
  end

  defp recent_jobs(conversation_id) when is_binary(conversation_id) do
    Work.recent_jobs(%Conversation{id: conversation_id}, @maximum_jobs)
    |> Enum.filter(&(&1.status in OpenAgents.Work.Job.terminal_statuses()))
  end

  defp recent_jobs(_conversation_id), do: []

  defp summarize_job(job) do
    %{
      "id" => job.id,
      "kind" => job.kind || "deep_work",
      "status" => job.status,
      "error_code" => job.error_code || "",
      "goal" => job.goal || "",
      "report_excerpt" => excerpt(job.report),
      "completed_at" => iso8601(job.completed_at || job.updated_at)
    }
  end

  defp excerpt(report) when is_binary(report) and report != "",
    do: String.slice(String.trim(report), 0, @maximum_report_excerpt)

  defp excerpt(_report), do: ""

  defp iso8601(%DateTime{} = datetime), do: DateTime.to_iso8601(datetime)
  defp iso8601(_missing), do: ""

  defp primary_source([], []), do: "none"
  defp primary_source([], _jobs), do: "job"
  defp primary_source(_incidents, []), do: "incident"

  defp primary_source([incident | _rest], [job | _jobs]) do
    incident_at = parse_iso(incident["occurred_at"])
    job_at = parse_iso(job["completed_at"])

    cond do
      is_nil(incident_at) -> "job"
      is_nil(job_at) -> "incident"
      DateTime.compare(job_at, incident_at) != :lt -> "job"
      true -> "incident"
    end
  end

  defp parse_iso(value) when is_binary(value) and value != "" do
    case DateTime.from_iso8601(value) do
      {:ok, datetime, _offset} -> datetime
      _invalid -> nil
    end
  end

  defp parse_iso(_value), do: nil

  defp status([], []), do: "none"
  defp status(_incidents, _jobs), do: "matches"

  defp guidance("job"),
    do:
      "Explain the most recent jobs[] entry. Older incidents[] are previous context, not this failure."

  defp guidance("incident"),
    do: "Explain the most recent incidents[] entry. No newer job report is on record."

  defp guidance(_none),
    do: "No incident or job report is on record for this question."

  defp normalize_ref(value) when is_binary(value) do
    trimmed = String.trim(value)
    if trimmed == "", do: nil, else: String.slice(trimmed, 0, 128)
  end

  defp normalize_ref(_value), do: nil

  defp matches_ref?(_value, nil), do: true
  defp matches_ref?(value, ref) when is_binary(value), do: value == ref
  defp matches_ref?(_value, _ref), do: false

  defp clamp(value) when is_integer(value), do: value |> min(@maximum_listed) |> max(1)
  defp clamp(_value), do: @maximum_listed

  defp input_schema do
    %{
      "type" => "object",
      "properties" => %{
        "scope" => %{"type" => "string", "enum" => ["conversation", "owner"]},
        "limit" => %{"type" => "integer", "minimum" => 1, "maximum" => @maximum_listed},
        "correlation_ref" => %{"type" => "string", "maxLength" => 128}
      },
      "required" => [],
      "additionalProperties" => false
    }
  end

  defp output_schema do
    %{
      "type" => "object",
      "properties" => %{
        "schema" => %{"type" => "string", "maxLength" => 64},
        "scope" => %{"type" => "string", "maxLength" => 32},
        "status" => %{"type" => "string", "maxLength" => 32},
        "primary" => %{"type" => "string", "maxLength" => 16},
        "guidance" => %{"type" => "string", "maxLength" => 240},
        "jobs" => %{
          "type" => "array",
          "items" => %{
            "type" => "object",
            "properties" => %{
              "id" => %{"type" => "string"},
              "kind" => %{"type" => "string"},
              "status" => %{"type" => "string"},
              "error_code" => %{"type" => "string"},
              "goal" => %{"type" => "string"},
              "report_excerpt" => %{"type" => "string"},
              "completed_at" => %{"type" => "string"}
            },
            "required" => [
              "id",
              "kind",
              "status",
              "error_code",
              "goal",
              "report_excerpt",
              "completed_at"
            ],
            "additionalProperties" => false
          }
        },
        "incidents" => %{
          "type" => "array",
          "items" => %{
            "type" => "object",
            "properties" => %{
              "id" => %{"type" => "string"},
              "code" => %{"type" => "string"},
              "severity" => %{"type" => "string"},
              "origin" => %{"type" => "string"},
              "surface" => %{"type" => "string"},
              "summary" => %{"type" => "string"},
              "status" => %{"type" => "string"},
              "correlation_ref" => %{"type" => "string"},
              "occurred_at" => %{"type" => "string"},
              "recurrence_count" => %{"type" => "integer"},
              "being_fixed" => %{"type" => "boolean"}
            },
            "required" => [
              "id",
              "code",
              "severity",
              "origin",
              "surface",
              "summary",
              "status",
              "correlation_ref",
              "occurred_at",
              "recurrence_count",
              "being_fixed"
            ],
            "additionalProperties" => false
          }
        }
      },
      "required" => ["schema", "scope", "status", "primary", "guidance", "incidents", "jobs"],
      "additionalProperties" => false
    }
  end
end