defmodule ASM.Run.EventReducer do @moduledoc """ Deterministic reducer from run-scoped events to result projections. """ alias ASM.{Error, Event, Metadata, Result, Run} alias CliSubprocessCore.Payload @normalized_error_domains %{ "approval" => :approval, "config" => :config, "guardrail" => :guardrail, "parser" => :parser, "provider" => :provider, "runtime" => :runtime, "tool" => :tool, "transport" => :transport } @conversation_kinds [ :assistant_message, :assistant_delta, :user_message, :tool_use, :tool_result, :thinking, :result, :stderr, :raw ] @spec apply_event!(Run.State.t(), Event.t()) :: Run.State.t() def apply_event!(%Run.State{} = state, %Event{} = event) do next_sequence = max(state.sequence + 1, event.sequence || state.sequence + 1) event = %{event | sequence: next_sequence} state = %{state | metadata: Metadata.merge_run_metadata(state.metadata, event.metadata)} state |> append_event(event) |> apply_semantics(event) end @spec final?(Run.State.t()) :: boolean() def final?(%Run.State{status: status}) do status in [:completed, :failed, :interrupted] end @spec to_result(Run.State.t()) :: Result.t() def to_result(%Run.State{} = state) do state = Run.State.materialize(state) duration_ms = case state.finished_at do %DateTime{} = finished_at -> finished_at |> DateTime.diff(state.started_at, :millisecond) |> max(0) nil -> nil end {stop_reason, object} = case state.result do %Result{stop_reason: reason, object: object} -> {reason, object} _ -> {nil, nil} end %Result{ run_id: state.run_id, session_id: state.session_id, text: state.text_acc, object: object, messages: state.messages_acc, cost: state.cost, error: state.error, duration_ms: duration_ms, stop_reason: stop_reason, session_id_from_cli: state.metadata[:provider_session_id] || state.metadata["provider_session_id"], metadata: state.metadata } end defp append_event(state, event) do %{state | sequence: event.sequence, events_rev: [event | state.events_rev]} end defp append_message(state, nil), do: state defp append_message(state, payload) do %{state | messages_rev: [payload | state.messages_rev]} end defp append_text(state, ""), do: state defp append_text(state, nil), do: state defp append_text(state, text) when is_binary(text) do %{state | text_chunks_rev: [text | state.text_chunks_rev]} end defp apply_semantics(state, %Event{kind: :run_started, provider_session_id: provider_session_id}) do metadata = put_if_present(state.metadata, :provider_session_id, provider_session_id) %{state | status: :running, metadata: metadata} end defp apply_semantics(state, %Event{kind: :assistant_delta} = event) do legacy = Event.legacy_payload(event) state |> append_message(legacy) |> append_text(Event.assistant_text(event)) end defp apply_semantics( state, %Event{kind: :assistant_message, payload: %Payload.AssistantMessage{} = payload} = event ) do legacy = Event.legacy_payload(event) state = append_message(state, legacy) if final_snapshot?(payload.metadata) do state else append_text(state, Event.assistant_text(event)) end end defp apply_semantics(state, %Event{kind: :result, timestamp: finished_at} = event) do legacy = Event.legacy_payload(event) next_state = state |> append_message(legacy) |> Map.put(:status, :completed) |> Map.put(:finished_at, finished_at) materialized = Run.State.materialize(next_state) result = %Result{ run_id: materialized.run_id, session_id: materialized.session_id, text: materialized.text_acc, object: legacy.object, messages: materialized.messages_acc, cost: materialized.cost, error: materialized.error, duration_ms: legacy.duration_ms, stop_reason: legacy.stop_reason, metadata: materialized.metadata } %{next_state | result: result} end defp apply_semantics( state, %Event{kind: :error, payload: %Payload.Error{} = payload, timestamp: finished_at} = event ) do legacy = Event.legacy_payload(event) state |> append_message(legacy) |> Map.put(:status, :failed) |> Map.put(:finished_at, finished_at) |> Map.put(:error, error_from_payload(legacy, payload)) end defp apply_semantics(state, %Event{kind: :error, timestamp: finished_at} = event) do legacy = Event.legacy_payload(event) state |> append_message(legacy) |> Map.put(:status, :failed) |> Map.put(:finished_at, finished_at) |> Map.put(:error, error_from_message(legacy)) end defp apply_semantics(state, %Event{kind: :run_completed, timestamp: finished_at}) do %{state | status: :completed, finished_at: finished_at} end defp apply_semantics(state, %Event{kind: :approval_requested} = event) do payload = Event.legacy_payload(event) %{state | pending_approvals: Map.put(state.pending_approvals, payload.approval_id, payload)} end defp apply_semantics(state, %Event{kind: :approval_resolved} = event) do payload = Event.legacy_payload(event) %{state | pending_approvals: Map.delete(state.pending_approvals, payload.approval_id)} end defp apply_semantics(state, %Event{kind: :cost_update} = event) do payload = Event.legacy_payload(event) totals = add_cost(state.cost, payload) %{state | cost: totals, metadata: Map.put(state.metadata, :cost, totals)} end defp apply_semantics(state, %Event{kind: kind} = event) when kind in @conversation_kinds do append_message(state, Event.legacy_payload(event)) end defp apply_semantics(state, _event), do: state defp add_cost(current, payload) do %{ input_tokens: default_zero(current[:input_tokens]) + payload.input_tokens, output_tokens: default_zero(current[:output_tokens]) + payload.output_tokens, cost_usd: default_zero(current[:cost_usd]) + payload.cost_usd } end defp default_zero(nil), do: 0 defp default_zero(value), do: value defp error_from_message(%ASM.Message.Error{} = payload) do Error.new(payload.kind, :runtime, payload.message) end defp error_from_message(_payload) do Error.new(:unknown, :runtime, "Unknown runtime error") end defp error_from_payload(%ASM.Message.Error{} = payload, %Payload.Error{} = raw_payload) do recovery = error_recovery(raw_payload.metadata) Error.new(payload.kind, error_domain(raw_payload.metadata), payload.message, retryable: recovery_retryable?(recovery), recovery: recovery ) end defp error_from_payload(payload, _raw_payload) do error_from_message(payload) end defp final_snapshot?(metadata) when is_map(metadata) do Map.get(metadata, :source) == :cursor_final_snapshot or Map.get(metadata, "source") in [:cursor_final_snapshot, "cursor_final_snapshot"] end defp final_snapshot?(_metadata), do: false defp error_domain(metadata) when is_map(metadata) do metadata |> Map.get(:asm_error_domain, Map.get(metadata, "asm_error_domain")) |> normalize_error_domain() end defp error_domain(_metadata), do: :runtime defp error_recovery(metadata) when is_map(metadata) do metadata[:recovery] || metadata["recovery"] || metadata |> Map.get(:runtime_failure, Map.get(metadata, "runtime_failure")) |> case do runtime_failure when is_map(runtime_failure) -> runtime_failure[:recovery] || runtime_failure["recovery"] _other -> nil end end defp error_recovery(_metadata), do: nil defp recovery_retryable?(recovery) when is_map(recovery) do value = recovery[:retryable?] || recovery["retryable?"] value in [true, "true", "TRUE", 1, "1"] end defp recovery_retryable?(_recovery), do: nil defp normalize_error_domain(domain) when domain in [ :transport, :parser, :provider, :approval, :guardrail, :tool, :runtime, :config ], do: domain defp normalize_error_domain(domain) when is_binary(domain) do normalized = domain |> String.trim() |> String.downcase() |> String.replace("-", "_") Map.get(@normalized_error_domains, normalized, :runtime) end defp normalize_error_domain(_domain), do: :runtime defp put_if_present(map, _key, nil), do: map defp put_if_present(map, key, value), do: Map.put(map, key, value) end