defmodule AI.Agent.Coordinator do
@moduledoc """
This agent applies a multi-step reasoning process to research, debug, and
code in response to the user's prompt.
"""
defstruct [
:agent,
# User opts
:edit?,
:replay,
:question,
:conversation_pid,
:followup?,
:project,
# ...controlled by setting option smart:true
:model,
# ...afikoman persona flag (Fonzie mode)
:fonz,
# State
:last_response,
:steps,
:usage,
:context,
:intuition,
:editing_tools_used,
:last_validation_fingerprint,
:interrupts
]
@type t :: %__MODULE__{
# Agent
agent: AI.Agent.t(),
# User opts
edit?: boolean,
replay: boolean,
question: binary,
conversation_pid: pid,
followup?: boolean,
project: binary,
model: AI.Model.t(),
fonz: boolean,
# State
last_response: binary | nil,
steps: list(atom),
usage: non_neg_integer,
context: non_neg_integer,
intuition: binary | nil,
editing_tools_used: boolean,
last_validation_fingerprint: String.t() | nil,
# Interrupt handling: set by AI.Agent.Coordinator.Interrupts.init
interrupts: AI.Agent.Coordinator.Interrupts.t()
}
@type input_opts :: %{
required(:agent) => AI.Agent.t(),
required(:conversation_pid) => pid,
required(:edit) => boolean,
required(:question) => binary,
required(:replay) => boolean,
required(:smart) => binary,
optional(:reasoning) => AI.Model.reasoning_level(),
optional(:verbosity) => AI.Model.verbosity_level(),
optional(:fonz) => boolean
}
@type error :: {:error, binary | atom | :testing}
@type state :: t | error
@default_model AI.Model.smart()
@smarter_model AI.Model.smarter()
@behaviour AI.Agent
@impl AI.Agent
def get_response(opts) do
opts
|> new()
|> select_steps()
|> consider()
|> case do
{:error, reason} -> {:error, reason}
state -> {:ok, state}
end
end
@spec new(input_opts) :: t
defp new(opts) do
with {:ok, agent} <- Map.fetch(opts, :agent),
{:ok, conversation_pid} <- Map.fetch(opts, :conversation_pid),
{:ok, edit?} <- Map.fetch(opts, :edit),
{:ok, question} <- Map.fetch(opts, :question),
{:ok, replay} <- Map.fetch(opts, :replay),
{:ok, project} <- Store.get_project() do
followup? =
conversation_pid
|> Services.Conversation.get_conversation()
|> Store.Project.Conversation.exists?()
model =
cond do
Map.get(opts, :smart, false) -> @smarter_model
System.get_env("FNORD_SMART_MODEL") in ["true", "1"] -> @smarter_model
true -> @default_model
end
|> AI.Model.with_reasoning(Map.get(opts, :reasoning))
|> AI.Model.with_verbosity(Map.get(opts, :verbosity))
Settings.set_edit_mode(edit?)
# Entering/leaving edit mode is a session boundary for approvals:
# discard session-scoped grants. (Approvals reads config at
# confirm-time, so no restart is needed for the mode flag itself.)
:ok = Services.Approvals.reset_session()
%__MODULE__{
# Agent
agent: agent,
# User opts
edit?: edit?,
replay: replay,
question: question,
conversation_pid: conversation_pid,
followup?: followup?,
project: project.name,
model: model,
fonz: Map.get(opts, :fonz, false),
# State
last_response: nil,
steps: [],
usage: 0,
context: model.context,
intuition: nil,
editing_tools_used: false,
last_validation_fingerprint: nil,
interrupts: AI.Agent.Coordinator.Interrupts.new()
}
end
end
@spec consider(t) :: state
defp consider(state) do
AI.Agent.Coordinator.Frippery.log_available_frobs()
AI.Agent.Coordinator.Frippery.log_available_mcp_tools()
AI.Agent.Coordinator.Frippery.log_available_skills()
AI.Agent.Coordinator.Frippery.hint_disabled_external_configs()
if AI.Agent.Coordinator.Test.is_testing?(state) do
state
|> AI.Agent.Coordinator.Frippery.greet()
|> AI.Agent.Coordinator.Test.get_response()
else
state
|> AI.Agent.Coordinator.Notes.init()
|> AI.Agent.Coordinator.Frippery.greet()
|> bootstrap()
|> perform_step()
end
end
@spec bootstrap(t) :: t
defp bootstrap(state) do
state
# All sessions begin with these bootstrap messages. Saved conversations do
# not rely on prior bootstrap ordering, so follow-up sessions re-inject the
# current instruction and context messages here. That keeps the active
# session grounded in fresh identity, memory, project, and worktree state.
|> new_session_msg()
|> initial_msg()
|> AI.Agent.Coordinator.Memory.identity_msg()
|> user_msg()
|> AI.Agent.Coordinator.Intuition.automatic_thoughts_msg()
|> with_memories()
|> project_prompt_msg()
|> external_configs_msg()
|> worktree_context_msg()
|> AI.Agent.Coordinator.Tasks.research_msg()
|> AI.Agent.Coordinator.Tasks.list_msg()
|> AI.Agent.Coordinator.Interrupts.init()
end
# ----------------------------------------------------------------------------
# Research steps
# ----------------------------------------------------------------------------
@spec select_steps(t) :: t
defp select_steps(%{edit?: true, followup?: false} = state) do
%{state | steps: [:initial, :coding, :check_tasks, :commit_worktree, :finalize]}
end
defp select_steps(%{edit?: true, followup?: true} = state) do
%{state | steps: [:followup, :coding, :check_tasks, :commit_worktree, :finalize]}
end
defp select_steps(%{edit?: false, followup?: true} = state) do
%{state | steps: [:followup, :check_tasks, :finalize]}
end
defp select_steps(%{edit?: false} = state) do
%{state | steps: [:initial, :check_tasks, :finalize]}
end
@spec perform_step(state) :: state
# ----------------------------------------------------------------------------
# If this is a follow-up question, we skip the initial response and jump
# straight to follow-up research to update and refine our understanding based
# on the new prompt and any changes in the conversation. This allows us to
# maintain continuity and build on our prior research without starting from
# scratch.
# ----------------------------------------------------------------------------
defp perform_step(%{replay: replay, steps: [:followup | steps]} = state) do
UI.begin_step("Bootstrapping")
state
|> Map.put(:steps, steps)
|> followup_msg()
|> AI.Agent.Coordinator.Glue.get_completion(replay)
|> perform_step()
end
# ----------------------------------------------------------------------------
# Trigger the initial response.
# ----------------------------------------------------------------------------
defp perform_step(%{replay: replay, steps: [:initial | steps]} = state) do
UI.begin_step("Bootstrapping")
state
|> Map.put(:steps, steps)
|> begin_msg()
|> AI.Agent.Coordinator.Glue.get_completion(replay)
|> perform_step()
end
# ----------------------------------------------------------------------------
# Coding steps. If edit mode is enabled, we will have planned out a coding
# phase in our initial response. During the coding phase, we will delegate to
# the coder_tool to implement the changes, but we will also stay actively
# involved in the process to verify the changes, run tests, and ensure the
# coder_tool is doing what we asked. This is a critical phase where we are
# most at risk of the AI going off the rails or failing to double check for
# slop, so we maintain a tight feedback loop and keep the user informed with
# notify_tool updates.
# ----------------------------------------------------------------------------
defp perform_step(%{steps: [:coding | steps]} = state) do
state = Map.put(state, :steps, steps)
# Gate the drainage loop reactively. Applies to both fresh and follow-up
# sessions: if the coordinator's first response didn't touch edit tools,
# didn't leave any in-progress task lists, and didn't produce uncommitted
# worktree changes, there is nothing to drain and we skip the phase
# entirely - no banner, no prompt injection, no extra completion.
if coding_work_pending?(state) do
UI.begin_step("Draining coding tasks")
state
|> AI.Agent.Coordinator.Tasks.research_msg()
|> reminder_msg()
|> AI.Agent.Coordinator.Tasks.list_msg()
|> AI.Agent.Coordinator.Coding.milestone_msg()
|> AI.Agent.Coordinator.Glue.get_completion()
|> perform_step()
else
perform_step(state)
end
end
# ----------------------------------------------------------------------------
# Check for remaining tasks in task lists. Task lists are persisted with the
# conversation, so it is OK to carry tasks forward across multiple sessions.
#
# Only pester (penultimate check) for lists that are explicitly "in-progress"
# and have at least one open task. Ignore lists that are still in "planning"
# or are already "done".
# ----------------------------------------------------------------------------
defp perform_step(%{steps: [:check_tasks | steps]} = state) do
state = Map.put(state, :steps, steps)
pending = AI.Agent.Coordinator.Tasks.pending_lists(state)
case pending do
[] ->
UI.info("All pending work complete!")
state
list_ids ->
UI.begin_step("Reviewing pending tasks")
state
|> AI.Agent.Coordinator.Tasks.list_msg()
|> AI.Agent.Coordinator.Tasks.penultimate_check_msg(list_ids)
|> AI.Agent.Coordinator.Glue.get_completion()
end
|> AI.Agent.Coordinator.Tasks.log_summary()
|> perform_step()
end
# ----------------------------------------------------------------------------
# Commit worktree: if working in a fnord-managed worktree with uncommitted
# changes, pester the coordinator until it commits. It can use the
# git_worktree_tool commit action with wip: true if stopping due to blockers.
# ----------------------------------------------------------------------------
defp perform_step(%{steps: [:commit_worktree | steps]} = state) do
state = Map.put(state, :steps, steps)
case worktree_needs_commit?() do
false ->
perform_step(state)
true ->
UI.begin_step("Committing worktree changes")
case state |> commit_worktree_msg() |> AI.Agent.Coordinator.Glue.get_completion() do
{:error, _reason} ->
UI.warn("Worktree commit skipped due to completion failure")
perform_step(state)
updated ->
updated
|> commit_worktree_loop()
|> perform_step()
end
end
end
# ----------------------------------------------------------------------------
# Finalization: get the final answer, and unblock interrupts so any pending
# interrupts can be displayed to the user after we have the final answer
# ready. We block interrupts during finalization to avoid interjecting them
# into the middle of our final answer or notes.
# ----------------------------------------------------------------------------
defp perform_step(%{steps: [:finalize]} = state) do
# Block interrupts during finalization to avoid mid-output interjections
Services.Conversation.Interrupts.block(state.conversation_pid)
try do
# Spawn the memory reflection agent in parallel with finalize. It runs
# a dedicated completion with only memory_tool in its toolbox, writing
# session memories as side effects. We await it before returning to
# ensure all memories are saved before the process exits.
reflect_task =
Services.Globals.Spawn.async(fn ->
AI.Agent.Coordinator.Memory.reflect(state)
end)
finalize_state =
state
|> Map.put(:steps, [])
|> reminder_msg()
|> AI.Agent.Coordinator.Tasks.list_msg()
|> unused_edit_tools_nudge_msg()
|> finalize_msg()
|> template_msg()
|> AI.Agent.Coordinator.Glue.get_completion()
UI.begin_step("Joining")
Task.await(reflect_task, :infinity)
finalize_state
|> AI.Agent.Coordinator.Frippery.get_motd()
after
# Always unblock, even if completion fails
Services.Conversation.Interrupts.unblock(state.conversation_pid)
end
end
defp perform_step(state), do: state
# ----------------------------------------------------------------------------
# Worktree commit helpers
# ----------------------------------------------------------------------------
# Repeats until the worktree is clean or gives up after max attempts.
@commit_worktree_max_attempts 3
defp commit_worktree_loop(state, attempt \\ 1) do
if worktree_needs_commit?() and attempt < @commit_worktree_max_attempts do
case state |> commit_worktree_nag_msg() |> AI.Agent.Coordinator.Glue.get_completion() do
{:error, _reason} ->
UI.warn("Worktree commit nag skipped due to completion failure")
state
updated ->
commit_worktree_loop(updated, attempt + 1)
end
else
state
end
end
defp worktree_needs_commit? do
case Settings.get_project_root_override() do
nil ->
false
path ->
with {:ok, project} <- Store.get_project(),
true <- GitCli.Worktree.fnord_managed?(project.name, path) do
GitCli.Worktree.has_uncommitted_changes?(path)
else
_ -> false
end
end
end
# Any signal that coding work is actually in flight. The :coding step uses
# this to decide whether to run the milestone/drainage loop at all. It is
# intentionally independent of fresh-vs-follow-up - coding is either
# happening (tools used, tasks open, worktree dirty) or it isn't.
@spec coding_work_pending?(t) :: boolean
defp coding_work_pending?(state) do
state.editing_tools_used or
AI.Agent.Coordinator.Tasks.pending_lists(state) != [] or
worktree_needs_commit?()
end
@spec commit_worktree_msg(t) :: t
defp commit_worktree_msg(%{conversation_pid: conversation_pid} = state) do
"""
# Commit your worktree changes
You have uncommitted changes in the active worktree. Before finishing, commit
them using the `git_worktree_tool` with action `commit`.
- If your work is complete: use a clear, descriptive commit message.
- If you are stopping due to problems or blockers: set `wip` to `true` and
describe what was accomplished and what issues remain in the message body.
Either way, commit now. Do not leave uncommitted changes in the worktree.
"""
|> AI.Util.system_msg()
|> Services.Conversation.append_msg(conversation_pid)
state
end
@spec commit_worktree_nag_msg(t) :: t
defp commit_worktree_nag_msg(%{conversation_pid: conversation_pid} = state) do
"""
The worktree still has uncommitted changes. Use `git_worktree_tool` with
action `commit` to commit them now.
"""
|> AI.Util.system_msg()
|> Services.Conversation.append_msg(conversation_pid)
state
end
# ----------------------------------------------------------------------------
# Message shortcuts
# ----------------------------------------------------------------------------
@spec user_msg(t) :: t
defp user_msg(%{conversation_pid: conversation_pid, question: question} = state) do
question
|> AI.Util.user_msg()
|> Services.Conversation.append_msg(conversation_pid)
UI.feedback_user(question)
state
end
@spec reminder_msg(t) :: t
defp reminder_msg(%{conversation_pid: conversation_pid, question: question} = state) do
"Remember the user's question: #{question}"
|> AI.Util.system_msg()
|> Services.Conversation.append_msg(conversation_pid)
state
end
# Fires when the user enabled edit mode but no coding activity occurred this
# session - no edit tools touched, no open task lists, no dirty worktree. In
# that case, nudge the LLM to double-check whether the prompt actually asked
# for changes. No-op when edit mode is off or when coding work is in flight
# (the drainage loop handles those cases).
@spec unused_edit_tools_nudge_msg(t) :: t
defp unused_edit_tools_nudge_msg(%{conversation_pid: conversation_pid, edit?: true} = state) do
if coding_work_pending?(state) do
state
else
"""
NOTE: The user explicitly enabled your coding tools, but you didn't use them on this session.
Sometimes users enable edit mode preemptively, but **double-check whether they asked for any changes** - if so, make them now before finalizing.
"""
|> AI.Util.system_msg()
|> Services.Conversation.append_msg(conversation_pid)
state
end
end
defp unused_edit_tools_nudge_msg(state), do: state
@common """
You are an AI assistant that coordinates research into the user's code base to answer their questions.
You are logical with prolog-like reasoning: step-by-step, establishing facts, relationships, and rules, to draw conclusions.
Prefer a polite but informal tone.
You are working in the project, "$$PROJECT$$".
You are operating within a terminal-based UI, and can interact with the user's project using your tools.
$$GIT_INFO$$
Confirm if prior research you found is still relevant and factual.
Proactively use your tools to research the user's question.
Where a tool is not available, use the cmd_tool to improvise a solution.
## User feedback
Use the `notify_tool` **extensively** to report what you are doing through the UI.
That will improve the user experience and help them follow your thought process.
Note relevant findings and interesting details you discover along the way.
Analyze the user prompt and plan steps to answer/execute it.
Use the `notify_tool` to inform the user of your plan, your progress, and any changes to your plan as you work.
The user may leave task-specific comments in the code base prefixed with `fnord:` for you.
Check for the presence of these instructions when prompted by the user or performing researching.
Treat these as scoped to the section of code to which they are attached (unless explicitly directed otherwise by the user or the comment).
These are high-priority, contextual bread crumbs to:
- guide your research
- identify friction or confusion
- provide contextual instructions for interacting with a specific section of code
- provide additional context related to your task
#{AI.Agent.Coordinator.Memory.recall_prompt()}
## Reasoning and research
Maintain a critical stance:
- Restate ambiguous asks in your own words; if ≥2 plausible readings exist, ask a brief clarifying question.
- Challenge weak premises or missing data early; avoid guessing when the risk is high.
Interactive interrupts:
- If the user interrupts with guidance, treat it as a constraint update; update your plan and ack
Effort scaling:
- Lean brief for straightforward tasks
- Escalate to deeper reasoning for multi-step deduction or troubleshooting
Reviewing code changes:
- **IMPORTANT**: ALWAYS delegate review of code changes to the reviewer_tool.
- Always name an explicit target: pass `branch:` (branch name), `pr:` (GitHub PR number), or `range:` (explicit git range) alongside the `scope` (design context and concerns).
- Do NOT rely on the reviewer to infer the target from free-text scope. The `scope` field is for design context only - the reviewer will not use it to pick a branch or commit range.
- The reviewer reads the target via git directly and fetches refs as needed; it does NOT need the target checked out in your working tree.
- If the user's request is ambiguous ("review my work", "take a look at this"), ask them to clarify which branch, PR, or commit range to review before calling the tool.
Debugging and troubleshooting:
- Form hypotheses based on evidence from the code base
- Confirm or refute hypotheses through targeted investigation:
- using the cmd_tool
- running or writing tests
- printf debugging
- writing a temporary script in the project root to explore behavior in isolation
- ALWAYS use either the current shell language or elixir for these scripts
- Those are the only languages you *know* are available, since your tui wrapper is written in elixir and is running in the user's shell
- If you need additional functionality, you can use Mix.install in elixir escripts to install dependencies on the fly!
- After testing hypotheses, use the notify_tool to inform the user of the findings and how it affects your understanding of the problem
Reachability and Preconditions:
- Before flagging an issue, confirm it is reachable in current control flow
- Identify real callers using your tools and identify their entry points
- Classification:
- Concrete: provide the exact path (entry -> caller -> callee), show preconditions, and how it can occur
- Potential: report when immediately relevant or likely
- When investigation is scoped to a branch or PR, ONLY report on newly introduced problems or interactions;
DO NOT REPORT ON PRE-EXISTING CONDITIONS unless they are directly relevant to the changes within scope!
- Cite evidence: file paths, symbols, and the shortest proof chain.
Conflicts in user instructions:
- If the user asks you to perform a task and you are incapable, request corrected instructions
- NEVER proceed with the task if you unable to complete it as requested.
The goal isn't to make the user feel validated.
Hallucinating a response out of a desire to please the user erodes trust.
## CLI help guidance
You communicate with the user via your command line interface, a command named `fnord`.
You have two self-help tools:
- `fnord_help_cli_tool`: returns the CLI spec (command tree, flags, subcommands). Use this for structural questions about what commands exist, what flags they accept, and how they parse.
- `fnord_help_docs_tool`: searches fnord's published documentation. Use this for questions about features, configuration, usage patterns, and how things work conceptually.
Always prefer these tools over referencing `fnord --help` output in your response.
If neither self-help tool covers the question, say so plainly. Use generic web search only for published fnord information beyond the indexed docs.
When your intuition classifies the prompt as "interface", respond using ONLY your self-help tools.
Do not delegate to research agents or search the project codebase - the user is asking about fnord's interface, not the code that implements it.
The self-help tools are your authoritative source for interface questions.
If the classification is "ambiguous", prefer self-help tools first; only fall back to codebase research if the self-help tools do not cover the question.
"""
@spec common_prompt() :: binary
def common_prompt, do: @common
@doc """
Returns the coordinator bootstrap messages that enable the Fonz persona.
This keeps the persona note at the coordinator integration point so fresh,
follow-up, and test-mode sessions can all inject the same instruction.
"""
@spec fonz_messages(t) :: [AI.Util.msg()]
def fonz_messages(%{fonz: true}) do
[AI.Util.system_msg(fonz_prompt())]
end
def fonz_messages(_state) do
case Settings.get_yes_count() do
count when count > 1 -> [AI.Util.system_msg(fonz_prompt())]
_ -> []
end
end
defp fonz_prompt do
"""
Fonz mode is enabled. Speak as though you were The Fonz: cool, confident, and lightly in-character.
Keep the answer useful and technically accurate, and do not let the persona crowd out the substance.
"""
end
@initial """
#{@common}
If asked to make changes and your coding tools are not enabled, notify the user that they must enable with --edit.
If asked to troubleshoot a bug, delegate to the troubleshooter_tool.
Instructions:
- Say hi to the user (notify_tool)
- Briefly summarize your understanding of the task
- Create a step-by-step plan that you can delegate to other agents through your tools (preserving your context window as the orchestrator)
- The research_tool has access to the same tools and capabilities as you do; delegate it research tasks
- Delegate multiple parallel research tasks to gain holistic understanding of the problem space
- Delegate follow-up research tasks as necessary to resolve uncertainties
- Once all results are in, compare, synthesize, and integrate findings
**Tool orchestration:**
- Parallelize research; serialize only when outputs feed inputs.
- Prefer agentic tools to preserve context window (eg file_info_tool over file_contents_tool)
- At the start of planning, quickly review the enabled skills list (via the `run_skill` tool spec)
and select a matching skill when one exists.
- Prefer invoking `run_skill` for matching tasks over inventing ad-hoc workflows; skills are
purpose-built with specialized prompts and protect your context window.
- If you choose not to use an obviously relevant skill, state briefly why (e.g., missing RW gating,
mismatch in scope), then proceed with the safest available alternative using existing tools.
**DO NOT FINALIZE YOUR RESPONSE UNTIL INSTRUCTED.**
"""
# Follow-up mode system prompt. On -f/-F, the conversation's earlier turns
# already carry the prescriptive workflow context; re-injecting @initial or
# Coding.@prompt primes the model to re-plan from scratch. Instead, give it
# @common (identity, tools, reasoning, CLI help, project/git info) plus a
# short continuation note telling it to respond to the follow-up directly.
@followup_system """
#{@common}
## Continuation mode
You are continuing an existing conversation. The earlier turns carry the full context from your prior research and work.
- Do NOT re-plan or re-research what has already been established. Trust the prior turns.
- Answer the user's new message directly, using the tools and context you already have.
- Before acting on prior findings, confirm they are still factual - files can move and code can change between turns.
- Use tools only for work the current reply actually requires (new research, new edits, or verifying that prior results have not drifted).
- If the follow-up asks for new code changes, prior discipline still applies (task lists, worktree rules, reviewer_tool), but do not rebuild a full STORY/EPIC workflow for a small follow-up edit.
**DO NOT FINALIZE YOUR RESPONSE UNTIL INSTRUCTED.**
"""
@spec initial_msg(t) :: t
# Follow-up sessions (-f/-F) get the slim @followup_system prompt regardless
# of edit mode. This clause must be listed first so pattern matching selects
# it before the edit-mode / read-mode fresh clauses below.
defp initial_msg(
%{followup?: true, conversation_pid: conversation_pid, project: project} = state
) do
@followup_system
|> String.replace("$$PROJECT$$", project)
|> String.replace("$$GIT_INFO$$", GitCli.git_info())
|> AI.Util.system_msg()
|> Services.Conversation.append_msg(conversation_pid)
state
|> fonz_messages()
|> Enum.each(&Services.Conversation.append_msg(&1, conversation_pid))
state
end
defp initial_msg(%{conversation_pid: conversation_pid, project: project, edit?: false} = state) do
@initial
|> String.replace("$$PROJECT$$", project)
|> String.replace("$$GIT_INFO$$", GitCli.git_info())
|> AI.Util.system_msg()
|> Services.Conversation.append_msg(conversation_pid)
state
|> fonz_messages()
|> Enum.each(&Services.Conversation.append_msg(&1, conversation_pid))
state
end
defp initial_msg(%{edit?: true} = state) do
state
|> AI.Agent.Coordinator.Coding.base_prompt_msg()
end
@followup """