defmodule Cyclium.Strategy.Template.Interactive do @moduledoc """ Strategy template for interactive actors. Implements the intent interpretation loop: 1. context_assembly — gather context for the synthesizer 2. interpret — call synthesizer with structured output spec 3. validate — run plan through PlanGate 4. preview (conditional) — block for approval if side effects 5. execute — run the plan 6. converge — standard converge with findings, outputs, summary Uses Option A from the plan: one episode per conversation turn. The `interpret → validate → preview → execute → summarize` phases are shared with the autonomous `Cyclium.Strategy.Template.AgenticTask` template via `Cyclium.Strategy.Template.Agentic.Loop`. This template supplies the conversation-specific ends: seeding intent from a user message, assembling conversation history/findings as context, and converging with conversation resolution semantics. """ @behaviour Cyclium.EpisodeRunner.Strategy alias Cyclium.{ConvergeResult, Conversations} alias Cyclium.Strategy.Template.Agentic.Loop alias Cyclium.Intent.GoalSpec # --- Strategy callbacks --- @impl true def init(episode, %Cyclium.Trigger.Interactive{} = trigger) do strategy_config = Loop.load_strategy_config(episode.actor_id) conversation = load_conversation(episode.conversation_id) goal = if conversation, do: Cyclium.Schemas.Conversation.decode_goal(conversation) {:ok, %{ phase: :context_assembly, strategy_config: strategy_config, message: trigger.message, principal: trigger.principal, history: trigger.history, conversation_id: trigger.conversation_id, conversation: conversation, goal: goal && struct(GoalSpec, atomize_keys(goal)), gathered_context: nil, action_plan: nil, plan_hash: nil, execution_results: [], current_step_index: 0, deny_reason: nil, explanation: nil, budget_exhausted: false }} end def init(_episode, _trigger) do # Fallback for non-interactive triggers (shouldn't happen but be safe) {:ok, %{phase: :done, strategy_config: %{}, explanation: "Non-interactive trigger"}} end @impl true def next_step(%{phase: :context_assembly} = state, episode_ctx) do context = assemble_context(state, episode_ctx) {:observe, context} end def next_step(state, episode_ctx), do: Loop.next_step(state, episode_ctx) @impl true def handle_result(%{phase: :context_assembly} = state, _step, {:ok, context}) do {:ok, %{state | phase: :interpret, gathered_context: context}} end def handle_result(state, step, result), do: Loop.handle_result(state, step, result) @impl true def converge(state, _episode_ctx) do plan = state.action_plan {summary, classification} = cond do state[:budget_exhausted] -> {Loop.budget_exhausted_summary(state), %{"primary" => "incomplete", "reason" => "budget_exhausted"}} state.deny_reason -> {"Plan denied: #{state.deny_reason}", %{"primary" => "denied", "reason" => state.deny_reason}} plan && plan.kind == :explain_only -> {plan.explanation || state.explanation || "Explanation provided", %{"primary" => "explain_only"}} plan -> summary = state.explanation || Loop.build_execution_summary(state) {summary, %{"primary" => to_string(plan.kind), "risk" => to_string(plan.risk)}} true -> {state.explanation || "No action taken", %{"primary" => "no_action"}} end outputs = build_outputs(state) findings = build_findings(state) # Check for conversation resolution signals classification = if plan && plan.meta["resolve_conversation"] do Map.merge(classification, %{ "conversation_resolved" => true, "outcome" => plan.meta["outcome"] || "completed", "result" => plan.meta["result"] || %{} }) else classification end # Merge collected fields if present classification = if plan && plan.meta["collected_fields"] do Map.put(classification, "collected_fields", plan.meta["collected_fields"]) else classification end {:ok, %ConvergeResult{ summary: summary, classification: classification, confidence: (plan && plan.meta["confidence"]) || 0.8, outputs: outputs, findings: findings }} end @impl true def workflow_result(state, converge_result) do %{ summary: converge_result.summary, classification: converge_result.classification, conversation_id: state.conversation_id } end @impl true def handle_budget_exhausted(state, episode_ctx), do: Loop.handle_budget_exhausted(state, episode_ctx) @impl true def resume_from_block(state, episode), do: Loop.resume_from_block(state, episode) # --- Private: Context assembly --- defp assemble_context(state, episode_ctx) do findings = load_relevant_findings(state, episode_ctx) prior_summaries = load_prior_episode_summaries(state) collected = load_collected_fields(state) %{ message: state.message, principal: state.principal, history: state.history, findings: findings, prior_summaries: prior_summaries, goal: state.goal, collected_fields: collected, actor_id: episode_ctx.actor_id, conversation_id: state.conversation_id } end defp load_relevant_findings(_state, episode_ctx) do try do Cyclium.Findings.active_for(actor: episode_ctx.actor_id) rescue _ -> [] end end defp load_prior_episode_summaries(%{conversation_id: nil}), do: [] defp load_prior_episode_summaries(%{conversation_id: conv_id}) do import Ecto.Query from(e in Cyclium.Schemas.Episode, where: e.conversation_id == ^conv_id and e.status == :done, order_by: [asc: e.started_at], select: %{summary: e.summary, classification: e.classification, started_at: e.started_at}, limit: 20 ) |> Cyclium.repo().all() rescue _ -> [] end defp load_collected_fields(%{conversation: nil}), do: %{} defp load_collected_fields(%{conversation: conv}) do Cyclium.Schemas.Conversation.decode_collected_fields(conv) end # --- Private: Converge helpers --- defp build_outputs(state) do plan = state.action_plan case plan && plan.kind do :output_proposal when not is_nil(plan.output) -> [plan.output] _ -> [] end end defp build_findings(state) do plan = state.action_plan Loop.build_findings_list(plan && plan.meta["findings"]) end # --- Private: Config loading --- defp load_conversation(nil), do: nil defp load_conversation(conv_id), do: Conversations.get(conv_id) defp atomize_keys(map) when is_map(map) do Map.new(map, fn {k, v} when is_binary(k) -> {String.to_existing_atom(k), v} {k, v} -> {k, v} end) rescue _ -> map end end