defmodule AI.Agent.Coordinator.Glue do @moduledoc """ Integration code for the Coordinator and AI.Tools, AI.Completion, etc. """ @type t :: AI.Agent.Coordinator.t() @type state :: AI.Agent.Coordinator.state() # ---------------------------------------------------------------------------- # Tool box # ---------------------------------------------------------------------------- @spec get_tools(t) :: AI.Tools.toolbox() def get_tools(%{edit?: true}) do AI.Tools.basic_tools() |> AI.Tools.with_mcps() |> AI.Tools.with_frobs() |> AI.Tools.with_task_tools() |> AI.Tools.with_skills() |> AI.Tools.with_rw_tools() |> AI.Tools.with_coding_tools() |> AI.Tools.with_review_tools() |> AI.Tools.with_web_tools() |> AI.Tools.maybe_with_ui() end def get_tools(_) do AI.Tools.basic_tools() |> AI.Tools.with_mcps() |> AI.Tools.with_frobs() |> AI.Tools.with_task_tools() |> AI.Tools.with_skills() |> AI.Tools.with_review_tools() |> AI.Tools.with_web_tools() |> AI.Tools.maybe_with_ui() end # ---------------------------------------------------------------------------- # Completion # ---------------------------------------------------------------------------- @spec get_completion(t, boolean) :: state def get_completion(state, replay \\ false) do msgs = Services.Conversation.get_messages(state.conversation_pid) # Save the current conversation to the store for crash resilience with {:ok, conversation} <- Services.Conversation.save(state.conversation_pid) do UI.report_step("Conversation state saved", conversation.id) else {:error, reason} -> UI.error("Failed to save conversation state", inspect(reason)) end # Invoke completion once, ensuring conversation state is included AI.Agent.get_completion(state.agent, log_msgs: true, log_tool_calls: true, archive_notes: true, compact?: true, replay_conversation: replay, conversation_pid: state.conversation_pid, model: state.model, toolbox: AI.Agent.Coordinator.Glue.get_tools(state), messages: msgs ) |> case do {:ok, %{response: response, messages: new_msgs, usage: usage} = completion} -> # Update conversation state and log usage and response Services.Conversation.replace_msgs(new_msgs, state.conversation_pid) tools_used = AI.Agent.tools_used(completion) tools_used |> Enum.map(fn {tool, count} -> "- #{tool}: #{count} invocation(s)" end) |> Enum.join("\n") |> then(fn "" -> UI.debug("Tools used", "None") some -> UI.debug("Tools used", some) end) editing_tools_used = state.editing_tools_used || code_modifying_tools_used?(tools_used) new_state = state |> Map.put(:usage, usage) |> Map.put(:last_response, response) |> Map.put(:editing_tools_used, editing_tools_used) |> Map.put(:model, state.model) |> maybe_run_validation(tools_used) |> log_usage() |> log_response() |> append_context_remaining() # If more interrupts arrived during completion, process them recursively if Services.Conversation.Interrupts.pending?(state.conversation_pid) do get_completion(new_state, replay) else new_state end {:error, %{response: response}} -> UI.error("Derp. Completion failed.", response) if Services.Conversation.Interrupts.pending?(state.conversation_pid) do get_completion(state, replay) else {:error, response} end {:error, reason} -> UI.error("Derp. Completion failed.", inspect(reason)) if Services.Conversation.Interrupts.pending?(state.conversation_pid) do get_completion(state, replay) else {:error, reason} end end end # ---------------------------------------------------------------------------- # Output # ---------------------------------------------------------------------------- defp log_response(%{steps: []} = state) do UI.debug("Response complete") state end defp log_response(%{last_response: thought} = state) do thought # "reasoning" models often leave the tags in the response |> String.replace(~r/(.*)<\/think>/, "\\1") |> Util.truncate(25) |> UI.italicize() |> then(&UI.debug("Considering", &1)) state end defp log_usage(%{usage: usage, model: model} = response) do UI.log_usage(model, usage) response end # Appends a system message showing the LLM how many context tokens remain # before their conversation history will be compacted and returns the state. @spec append_context_remaining(t) :: t defp append_context_remaining(state) do remaining = max(state.context - state.usage, 0) AI.Util.system_msg("Context tokens remaining before compaction: #{remaining}") |> Services.Conversation.append_msg(state.conversation_pid) state end # Deduplication is keyed only on the changed-file fingerprint, not on rule # configuration or command outcomes. If the dirty file set is unchanged but # rules were edited externally (e.g. via settings.json), the report for the # new rule set will be suppressed until the file set changes. In practice # this is a narrow window since rules are managed via CLI in a separate # process, not mid-conversation. defp maybe_run_validation(state, tools_used) do if code_modifying_tools_used?(tools_used) do Validation.Rules.debug("Running validation after code-modifying tools") result = Validation.Rules.run() case result do {:ok, :no_changes} -> Validation.Rules.debug("Validation returned no fingerprint: no changes") state {:error, :discovery_failed} -> Validation.Rules.debug("Changed file discovery failed") report_validation_result(state, result) _ -> fingerprint = validation_fingerprint(result) if fingerprint == state.last_validation_fingerprint do Validation.Rules.debug("Validation fingerprint unchanged: skipping report") state else Validation.Rules.debug("Validation fingerprint changed: reporting result") report_validation_result(state, result) end end else state end end # These are the tools that produce source code changes in the project tree. # cmd_tool is excluded because it primarily runs read-only commands (git log, # grep, etc.) and any file mutations it causes will be picked up on the next # edit-tool turn when the fingerprint changes. save_skill writes to ~/.fnord/, # not project source files. defp code_modifying_tools_used?(tools_used) do Enum.any?(tools_used, fn {tool, count} when count > 0 -> tool in ["coder_tool", "file_edit_tool", "apply_patch"] _ -> false end) end defp validation_fingerprint({:ok, :no_rules, _, fingerprint}), do: fingerprint defp validation_fingerprint({:ok, :no_matches, _, fingerprint}), do: fingerprint defp validation_fingerprint({:ok, _results, fingerprint}), do: fingerprint defp validation_fingerprint({:error, _result, fingerprint}), do: fingerprint defp report_validation_result(state, {:error, :discovery_failed} = result) do summary = Validation.Rules.summarize(result) UI.error("Validation", "Could not determine changed files (git status failed)") AI.Util.system_msg(summary) |> Services.Conversation.append_msg(state.conversation_pid) state end defp report_validation_result(state, result) do summary = Validation.Rules.summarize(result) case validation_ui_detail(result) do {:info, detail} -> UI.info("Validation", detail) {:error, detail} -> UI.error("Validation", detail) end AI.Util.system_msg(summary) |> Services.Conversation.append_msg(state.conversation_pid) Map.put(state, :last_validation_fingerprint, validation_fingerprint(result)) end defp validation_ui_detail({:ok, :no_rules, _, _}), do: {:info, "No rules found"} defp validation_ui_detail({:ok, :no_matches, _, _}), do: {:info, "No matching rules"} defp validation_ui_detail({:ok, results, _}) when is_list(results), do: {:info, "#{length(results)} commands succeeded"} defp validation_ui_detail({:error, reason, _}), do: {:error, "Run failed: #{inspect(reason)}"} end