Packages

Elixir/OTP runtime for supervised coding-agent sessions with ACP, Subagents, Workflows, and replayable evidence.

Current section

Files

Jump to
pixir lib pixir session_diagnostics.ex
Raw

lib/pixir/session_diagnostics.ex

defmodule Pixir.SessionDiagnostics do
@moduledoc """
Read-only diagnostics for one Pixir Session.
`Pixir.Doctor` answers "can this local Pixir install run?". This module answers
"is this Session replayable and internally coherent?" by combining Log facts,
Provider replay inspection, Session tree projection, and Provider usage metadata
without calling auth, the network, or the model.
"""
alias Pixir.{Log, ReplayInspector, SessionTree, Tool}
@doc "Run local, no-network diagnostics for `session_id`."
@spec run(String.t(), keyword()) :: {:ok, map()} | {:error, map()}
def run(session_id, opts \\ [])
def run(session_id, opts) when is_binary(session_id) do
workspace = opts |> Keyword.get(:workspace, File.cwd!()) |> Path.expand()
if Log.exists?(session_id, workspace: workspace) do
with {:ok, history} <- Log.fold(session_id, workspace: workspace),
{:ok, replay} <- ReplayInspector.inspect(session_id, workspace: workspace),
{:ok, tree} <- SessionTree.project(session_id, workspace: workspace) do
{:ok, report(session_id, workspace, history, replay, tree)}
end
else
{:error,
Tool.error(:not_found, "session log not found", %{
session_id: session_id,
workspace: workspace,
log_path: Log.path(session_id, workspace: workspace),
next_actions: [
"check the session id",
"run pixir diagnose session from the workspace that owns the session log"
]
})}
end
end
def run(_session_id, _opts),
do: {:error, Tool.error(:invalid_args, "session id must be a string", %{})}
defp report(session_id, workspace, history, replay, tree) do
checks = checks(history, replay, tree)
%{
"ok" => Enum.all?(checks, &(&1["status"] != "failed")),
"status" => status(checks),
"session_id" => session_id,
"workspace" => workspace,
"log_path" => Log.path(session_id, workspace: workspace),
"checks" => checks,
"events" => event_summary(history),
"replay" => replay["provider_input"],
"continuation" => replay["continuation"],
"tree" => tree_summary(tree),
"provider_usage" => provider_usage_summary(history),
"next_actions" => next_actions(checks)
}
end
defp checks(history, replay, tree) do
[
log_check(history),
tool_pairing_check(history),
turn_completion_check(history),
partial_assistant_check(history),
turn_failure_check(history),
subagent_timeout_check(history),
replay_check(replay),
tree_check(tree),
continuation_check(replay["continuation"])
]
end
defp log_check(history) do
if history == [] do
failed("log", "Session Log is empty.", %{})
else
passed("log", "Session Log is readable.", %{"event_count" => length(history)})
end
end
defp turn_completion_check(history) do
incomplete = incomplete_tool_turns(history)
if incomplete == [] do
passed(
"turn_completion",
"Every user turn with tool/provider activity has assistant or failure evidence.",
%{}
)
else
warning(
"turn_completion",
"Some user turns have tool/provider activity but no following assistant_message.",
%{
"classification" => "missing_canonical_assistant_after_provider_activity",
"turns" => incomplete,
"next_actions" => [
"inspect the session log around each user_seq",
"check whether the Provider stream ended before a final assistant response",
"rerun or resume the session after deciding whether the missing final answer is acceptable"
]
}
)
end
end
defp partial_assistant_check(history) do
partials = partial_assistant_messages(history)
if partials == [] do
passed(
"assistant_canonicalization",
"No partial assistant_message recovery markers found.",
%{}
)
else
warning(
"assistant_canonicalization",
"Pixir preserved partial assistant text after a Provider stream error.",
%{
"classification" => "partial_assistant_preserved_after_provider_error",
"messages" => partials,
"next_actions" => [
"inspect the provider log for stream termination",
"treat the assistant_message as partial evidence, not a clean final answer",
"rerun the turn if a complete final answer is required"
]
}
)
end
end
defp turn_failure_check(history) do
failures =
history
|> by_type(:turn_failed)
|> Enum.map(fn event ->
%{
"seq" => event.seq,
"terminal_status" => event.data["terminal_status"],
"error_kind" => event.data["error_kind"],
"has_details" => event.data["details"] not in [nil, %{}]
}
end)
if failures == [] do
passed("turn_failure_evidence", "No durable turn_failed events found.", %{})
else
warning(
"turn_failure_evidence",
"Pixir recorded durable audit evidence for failed Turns.",
%{
"classification" => "durable_turn_failure_evidence",
"failures" => failures,
"next_actions" => [
"inspect each turn_failed event and its surrounding log entries",
"treat turn_failed as audit evidence, not Provider replay context",
"rerun or resume the session if a complete assistant answer is required"
]
}
)
end
end
defp subagent_timeout_check(history) do
timeouts =
history
|> by_type(:subagent_event)
|> Enum.filter(fn event ->
event.data["event"] == "timed_out" or event.data["status"] == "timed_out"
end)
|> Enum.map(fn event ->
missing =
[
"subagent_id",
"child_session_id",
"agent",
"status",
"timeout_ms",
"elapsed_ms",
"reason",
"next_actions"
]
|> Enum.reject(fn key -> present?(event.data[key]) end)
%{
"seq" => event.seq,
"subagent_id" => event.data["subagent_id"],
"child_session_id" => event.data["child_session_id"],
"agent" => event.data["agent"],
"status" => event.data["status"],
"reason" => event.data["reason"],
"timeout_ms" => event.data["timeout_ms"],
"elapsed_ms" => event.data["elapsed_ms"],
"next_actions" => event.data["next_actions"] || [],
"missing_fields" => missing
}
end)
cond do
timeouts == [] ->
passed("subagent_timeouts", "No Subagent timeout events found.", %{})
Enum.any?(timeouts, &(not Enum.empty?(&1["missing_fields"]))) ->
warning(
"subagent_timeouts",
"Pixir found Subagent timeout events with incomplete diagnostic fields.",
%{
"classification" => "subagent_timeout_incomplete_evidence",
"timeouts" => timeouts,
"next_actions" => [
"rerun with a Pixir version that records enriched timeout evidence"
]
}
)
true ->
warning(
"subagent_timeouts",
"Pixir found explicit Subagent timeout evidence.",
%{
"classification" => "subagent_timeout_evidence",
"timeouts" => timeouts,
"next_actions" => [
"inspect the child_session_id log",
"retry the Subagent with a larger timeout or reduced task scope"
]
}
)
end
end
defp tool_pairing_check(history) do
call_ids = history |> by_type(:tool_call) |> Enum.map(& &1.data["call_id"])
result_ids = history |> by_type(:tool_result) |> Enum.map(& &1.data["call_id"])
missing = call_ids -- result_ids
extra = result_ids -- call_ids
cond do
missing != [] ->
failed("tool_pairing", "Some tool_call events have no matching tool_result.", %{
"missing_result_ids" => missing,
"extra_result_ids" => extra
})
extra != [] ->
warning("tool_pairing", "Some tool_result events have no matching tool_call.", %{
"missing_result_ids" => missing,
"extra_result_ids" => extra
})
true ->
passed("tool_pairing", "All tool_call events have matching tool_result events.", %{
"tool_calls" => length(call_ids),
"tool_results" => length(result_ids)
})
end
end
defp replay_check(%{"provider_input" => input}) do
cond do
input["missing_output_ids"] != [] ->
failed(
"provider_replay",
"Provider replay input has function calls without outputs.",
input
)
input["extra_output_ids"] != [] ->
warning(
"provider_replay",
"Provider replay input has outputs without function calls.",
input
)
input["synthetic_orphan_closures"] != [] ->
warning(
"provider_replay",
"Provider replay is balanced using synthetic orphan closures.",
input
)
true ->
passed(
"provider_replay",
"Provider replay input is balanced without synthetic closures.",
input
)
end
end
defp tree_check(tree) do
missing_children =
tree
|> child_nodes()
|> Enum.reject(& &1["log_exists"])
|> Enum.map(& &1["session_id"])
if missing_children == [] do
passed("session_tree", "Session tree child Logs are present or no children exist.", %{
"subagents" => length(tree["subagents"] || []),
"forks" => length(tree["forks"] || [])
})
else
warning("session_tree", "Some referenced child Session Logs are missing.", %{
"missing_child_session_ids" => missing_children
})
end
end
defp continuation_check(%{"present" => false}) do
warning("continuation", "No provider_usage event found for continuation metadata.", %{})
end
defp continuation_check(%{"continuation_reset_reason" => reason} = details)
when reason not in [nil, ""] do
warning("continuation", "Latest provider usage reset continuation.", details)
end
defp continuation_check(details) do
passed("continuation", "Latest provider usage has no continuation reset.", details)
end
defp event_summary(history) do
seqs = history |> Enum.map(& &1.seq) |> Enum.filter(&is_integer/1)
%{
"count" => length(history),
"from_seq" => Enum.min(seqs, fn -> nil end),
"to_seq" => Enum.max(seqs, fn -> nil end),
"counts_by_type" =>
history
|> Enum.frequencies_by(&Atom.to_string(&1.type))
|> Enum.into(%{})
}
end
defp tree_summary(tree) do
%{
"session_id" => tree["session_id"],
"log_exists" => tree["log_exists"],
"event_count" => tree["event_count"],
"subagents" => Enum.map(tree["subagents"] || [], &subagent_summary/1),
"fork_count" => length(tree["forks"] || [])
}
end
defp subagent_summary(record) do
session = record["session"] || %{}
%{
"subagent_id" => record["subagent_id"],
"child_session_id" => record["child_session_id"],
"status" => record["status"],
"events" => record["events"] || [],
"log_exists" => session["log_exists"] == true,
"event_count" => session["event_count"]
}
end
defp provider_usage_summary(history) do
usage = by_type(history, :provider_usage)
latest = List.last(usage)
%{
"count" => length(usage),
"latest" => provider_usage_item(latest)
}
end
defp provider_usage_item(nil), do: nil
defp provider_usage_item(%{seq: seq, data: data}) do
%{
"seq" => seq,
"model" => data["model"],
"active_transport" => data["active_transport"],
"continuation_attempted" => data["continuation_attempted"],
"continuation_reset_reason" => data["continuation_reset_reason"],
"used_previous_response_id" => data["used_previous_response_id"],
"usage_summary" => data["usage_summary"]
}
end
defp child_nodes(tree) do
subagent_children =
tree
|> Map.get("subagents", [])
|> Enum.map(& &1["session"])
|> Enum.reject(&is_nil/1)
fork_children =
tree
|> Map.get("forks", [])
|> Enum.map(& &1["session"])
|> Enum.reject(&is_nil/1)
subagent_children ++ fork_children
end
defp by_type(history, type), do: Enum.filter(history, &(&1.type == type))
defp present?(value), do: value not in [nil, "", []]
defp partial_assistant_messages(history) do
history
|> by_type(:assistant_message)
|> Enum.filter(&(get_in(&1.data, ["metadata", "partial"]) == true))
|> Enum.map(fn event ->
metadata = Map.get(event.data, "metadata", %{})
%{
"seq" => event.seq,
"error_kind" => metadata["error_kind"],
"terminal_status" => metadata["terminal_status"],
"text_length" => event.data |> Map.get("text", "") |> String.length()
}
end)
end
defp incomplete_tool_turns(history) do
history
|> Enum.reduce({nil, []}, fn event, {current, acc} ->
case {event.type, current} do
{:user_message, nil} ->
{new_turn(event), acc}
{:user_message, turn} ->
acc = maybe_record_incomplete(turn, event.seq, acc)
{new_turn(event), acc}
{:assistant_message, nil} ->
{nil, acc}
{:assistant_message, turn} ->
{%{turn | answered?: true, last_seq: event.seq}, acc}
{:turn_failed, nil} ->
{nil, acc}
{:turn_failed, turn} ->
{%{turn | failed?: true, last_seq: event.seq}, acc}
{type, turn}
when type in [:tool_call, :tool_result, :provider_usage] and not is_nil(turn) ->
{
%{
turn
| activity?: true,
tool_calls: turn.tool_calls + if(type == :tool_call, do: 1, else: 0),
tool_results: turn.tool_results + if(type == :tool_result, do: 1, else: 0),
provider_calls: turn.provider_calls + if(type == :provider_usage, do: 1, else: 0),
last_seq: event.seq
},
acc
}
{_type, turn} when not is_nil(turn) ->
{%{turn | last_seq: event.seq}, acc}
{_type, nil} ->
{nil, acc}
end
end)
|> then(fn {turn, acc} -> maybe_record_incomplete(turn, nil, acc) end)
|> Enum.reverse()
end
defp new_turn(event) do
%{
user_seq: event.seq,
next_user_seq: nil,
last_seq: event.seq,
answered?: false,
failed?: false,
activity?: false,
tool_calls: 0,
tool_results: 0,
provider_calls: 0
}
end
defp maybe_record_incomplete(nil, _next_user_seq, acc), do: acc
defp maybe_record_incomplete(%{answered?: true}, _next_user_seq, acc), do: acc
defp maybe_record_incomplete(%{failed?: true}, _next_user_seq, acc), do: acc
defp maybe_record_incomplete(%{activity?: false}, _next_user_seq, acc), do: acc
defp maybe_record_incomplete(turn, next_user_seq, acc) do
[
%{
"user_seq" => turn.user_seq,
"next_user_seq" => next_user_seq,
"last_seq" => turn.last_seq,
"tool_calls" => turn.tool_calls,
"tool_results" => turn.tool_results,
"provider_calls" => turn.provider_calls
}
| acc
]
end
defp status(checks) do
cond do
Enum.any?(checks, &(&1["status"] == "failed")) -> "blocked"
Enum.any?(checks, &(&1["status"] == "warning")) -> "ready_with_warnings"
true -> "ready"
end
end
defp next_actions(checks) do
checks
|> Enum.flat_map(&(get_in(&1, ["details", "next_actions"]) || []))
|> Enum.uniq()
end
defp passed(id, message, details), do: check(id, "passed", message, details)
defp warning(id, message, details), do: check(id, "warning", message, details)
defp failed(id, message, details), do: check(id, "failed", message, details)
defp check(id, status, message, details) do
%{
"id" => id,
"status" => status,
"message" => message,
"details" => details
}
end
end