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,
:notes,
:intuition,
:editing_tools_used,
# User interrupts:
# ...interrupt listener
:interrupt_listener,
# ...pending interrupts to display after completion
:pending_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,
notes: binary | nil,
intuition: binary | nil,
editing_tools_used: boolean,
# State: Interrupt handling
interrupt_listener: pid | nil,
pending_interrupts: AI.Util.msg_list()
}
@type input_opts :: %{
required(:agent) => AI.Agent.t(),
required(:conversation_pid) => pid,
required(:edit) => boolean,
required(:question) => binary,
required(:replay) => boolean,
required(:smart) => boolean,
optional(:fonz) => boolean
}
@type error :: {:error, binary | atom | :testing}
@type state :: t | error
@memory_recall_limit 3
@memory_size_limit 1000
@default_model AI.Model.smart()
@smarter_model AI.Model.smart()
@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 =
if Map.get(opts, :smart, false) do
@smarter_model
else
@default_model
end
Settings.set_edit_mode(edit?)
# Restart approvals service to pick up edit mode setting
GenServer.stop(Services.Approvals, :normal)
{:ok, _pid} = Services.Approvals.start_link()
%__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,
notes: nil,
intuition: nil,
editing_tools_used: false,
pending_interrupts: []
}
end
end
@spec consider(t) :: state
defp consider(state) do
log_available_frobs()
log_available_mcp_tools()
if !state.replay do
UI.info("You", state.question)
end
if is_testing?(state) do
UI.debug("Testing mode enabled")
state
|> greet()
|> get_test_response()
else
Services.Notes.ingest_user_msg(state.question)
state
|> greet()
|> bootstrap()
|> perform_step()
end
end
@spec greet(t) :: t
defp greet(%{followup?: true, agent: %{name: name}} = state) do
display_name =
case Services.NamePool.get_name_by_pid(self()) do
{:ok, n} -> n
_ -> name
end
UI.feedback(:info, display_name, "Welcome back, biological.")
UI.feedback(
:info,
display_name,
"""
Your biological distinctiveness has already been added to our training data.
... (mwah) your biological distinctiveness was delicious 🧑🍳
"""
)
state
end
@spec greet(t) :: t
defp greet(%{agent: %{name: name}} = state) do
display_name =
case Services.NamePool.get_name_by_pid(self()) do
{:ok, n} -> n
_ -> name
end
UI.feedback(:info, display_name, "Greetings, human. I am #{display_name}.")
UI.feedback(:info, display_name, "I shall be doing your thinking for you today.")
state
end
@spec bootstrap(t) :: t
defp bootstrap(state) do
state
# All sessions begin with these messages. We strip system/developer
# messages out of the saved conversation in Services.Conversation.save, so
# follow-up sessions re-inject them here. This reduces space on disk and
# ensures the instruction messages don't fall out of focus in long
# conversations.
|> new_session_msg()
|> initial_msg()
|> identity_msg()
|> user_msg()
|> get_intuition()
|> get_notes()
|> recall_memories_msg()
|> project_prompt_msg()
|> research_tasklist_msg()
|> task_list_msg()
|> startinterrupt_listener()
end
# -----------------------------------------------------------------------------
# Research steps
# -----------------------------------------------------------------------------
@spec select_steps(t) :: t
defp select_steps(%{edit?: true, followup?: false} = state) do
%{state | steps: [:initial, :coding, :check_tasks, :finalize]}
end
defp select_steps(%{edit?: true, followup?: true} = state) do
%{state | steps: [:followup, :coding, :check_tasks, :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
defp perform_step(%{replay: replay, steps: [:followup | steps]} = state) do
UI.begin_step("Bootstrapping")
state
|> Map.put(:steps, steps)
|> followup_msg()
|> get_completion(replay)
|> save_notes()
|> perform_step()
end
defp perform_step(%{replay: replay, steps: [:initial | steps]} = state) do
UI.begin_step("Bootstrapping")
state
|> Map.put(:steps, steps)
|> begin_msg()
|> get_completion(replay)
|> save_notes()
|> perform_step()
end
defp perform_step(%{steps: [:coding | steps]} = state) do
UI.begin_step("Draining coding tasks")
state
|> Map.put(:steps, steps)
|> research_tasklist_msg()
|> reminder_msg()
|> task_list_msg()
|> coding_milestone_msg()
|> execute_coding_phase()
|> get_intuition()
|> get_completion()
|> save_notes()
|> perform_step()
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.
defp perform_step(%{steps: [:check_tasks | steps]} = state) do
incomplete_list_ids =
Services.Task.list_ids()
|> Enum.reject(fn list_id ->
list_id
|> Services.Task.all_tasks_complete?()
|> case do
{:ok, true} -> true
_ -> false
end
end)
case incomplete_list_ids do
[] ->
UI.info("All tasks complete!")
state
|> Map.put(:steps, steps)
|> perform_step()
list_ids ->
UI.begin_step("Reviewing task lists")
state
|> Map.put(:steps, steps)
|> task_list_msg()
|> penultimate_tasks_check_msg(list_ids)
|> get_completion()
|> save_notes()
|> perform_step()
end
end
defp perform_step(%{steps: [:finalize]} = state) do
UI.begin_step("Joining")
# Block interrupts during finalization to avoid mid-output interjections
Services.Conversation.Interrupts.block(state.conversation_pid)
try do
state
|> Map.put(:steps, [])
|> reminder_msg()
|> task_list_msg()
|> finalize_msg()
|> template_msg()
|> get_completion()
|> save_notes()
|> get_motd()
after
# Always unblock, even if completion fails
Services.Conversation.Interrupts.unblock(state.conversation_pid)
end
end
defp perform_step(state), do: state
@spec get_completion(t, boolean) :: state
defp get_completion(state, replay \\ false) do
# Pre-apply any pending interrupts to the conversation messages
interrupts = Services.Conversation.Interrupts.take_all(state.conversation_pid)
Enum.each(interrupts, fn msg ->
# Add interrupt to conversation history
Services.Conversation.append_msg(msg, state.conversation_pid)
# Display interrupt in the tui
content = Map.get(msg, :content, "")
display = String.replace_prefix(content, "[User Interjection] ", "")
UI.info("You (rude)", display)
end)
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: 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 ||
Map.has_key?(tools_used, "coder_tool") ||
Map.has_key?(tools_used, "file_edit_tool") ||
Map.has_key?(tools_used, "apply_patch")
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)
|> log_usage()
|> log_response()
# 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
# -----------------------------------------------------------------------------
# Message shortcuts
# -----------------------------------------------------------------------------
@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$$".
$$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 shell_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.
## Memory
You interact with the user in sessions, across multiple conversations and projects.
Your memory is persistent, but you must explicitly choose to remember information.
You have several types of memory you can access via these tools:
- conversation_tool: past conversations with the user
- prior_research: your prior research notes
- memory_tool: memories you chose to record across session, project, and global scopes
## 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
Debugging and troubleshooting:
- Form hypotheses based on evidence from the code base
- Confirm or refute hypotheses through targeted investigation:
- using the shell_tool
- running or writing tests
- printf debugging
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
- 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.
"""
@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)
**DO NOT FINALIZE YOUR RESPONSE UNTIL INSTRUCTED.**
"""
@coding """
**The user enabled your coding tools**
#{@common}
Analyze the prompt and evaluate its complexity.
When in doubt, use the research_tool to figure it out.
If that identified unexpected complexity, pivot to an EPIC and treat the research done as "MILESTONE 0".
## STORIES
Use when the user asks you to make discrete changes to 1-3 files.
- Do research to understand the problem space and dependencies
- Is there an existing test that covers the change you are making?
- Yes: run it before making changes as a baseline
- No: consider writing one to cover the code you are changing
- Use the file_edit_tool
- Check the file after making changes (correctness, formatting, syntax, tool failure)
- Use linters/formatters if available
- ALWAYS run tests if available
## EPICS
Use for complex/open-ended changes.
- REFUSE if there are unstaged changes present that you were not aware of
- It's ok to work on top of your own changes from earlier milestones
- Research affected features and components to map out dependencies and interactions
- Use your task list to plan milestones
- Use the memory_tool to record learnings about the using the coder_tool
- Use those to inform how you structure your milestones
- Delegate milestones to the coder_tool
- It's agentic - include enough context that it can work independently
- The coder_tool will plan, implement, and verify the milestone
- Once the coder_tool has completed its work, you MUST verify the changes
- Did the coder_tool APPLY the changes or just respond with code snippets?
- Manually check syntax, formatting, logic, correctness, and observance of conventions
- Confirm whether there unit tests to update
## POST-CODING CHECKLIST:
1. Syntax/formatting
2. Tests/docs updated
3. Changes visually inspected
4. Correctness manually verified
- Requested changes all present
- NO unintended/unrelated changes/artifacts
- NO existing functionality is broken
- Diff minimized to reduce surface area for bugs/conflicts/review
## DEBUGGING/TROUBLESHOOTING
Use your coding tools and shell_tool to debug.
Propose a theory and test it with a unit test or tmp script.
Rinse and repeat to winnow down to the root cause.
"""
@followup """