Packages

fnord

0.9.24

AI code archaeology

Current section

Files

Jump to
fnord lib ai completion output.ex
Raw

lib/ai/completion/output.ex

defmodule AI.Completion.Output do
@max_tool_lines 10
# -----------------------------------------------------------------------------
# UI integration
# -----------------------------------------------------------------------------
def log_user_msg(state, msg) do
if state.log_msgs do
UI.feedback_user(msg)
end
end
def log_assistant_msg(%{name: nil} = state, msg) do
if state.log_msgs do
UI.feedback_assistant(Services.NamePool.default_name(), msg)
end
end
def log_assistant_msg(%{name: name} = state, msg) do
if state.log_msgs do
UI.feedback_assistant(name, msg)
end
end
def log_tool_call(state, step) do
if state.log_tool_calls do
UI.report_from(state.name, step)
end
end
def log_tool_call(state, step, msg) do
if state.log_tool_calls do
UI.report_from(state.name, step, Util.truncate(msg, @max_tool_lines))
end
end
def log_tool_call_result(state, step) do
if state.log_tool_calls do
UI.report_from(state.name, step)
end
end
def log_tool_call_result(state, step, msg) do
if state.log_tool_calls do
UI.report_from(state.name, step, Util.truncate(msg, @max_tool_lines))
end
end
# Log a tool call error. The reason is always emitted at debug level. The
# raw args JSON is only dumped to stderr when FNORD_DEBUG_TOOL_CALLS is set,
# bypassing Logger entirely so large payloads aren't truncated.
def log_tool_call_error(state, tool, args_json, reason) do
name = state.name || "Assistant"
UI.debug(name, """
Tool call failed: #{tool}
#{reason}
""")
maybe_dump_tool_call_args(tool, args_json)
end
# When FNORD_DEBUG_TOOL_CALLS is set, pretty-print the full args JSON to
# stderr, bypassing Logger and the formatter so nothing gets truncated.
defp maybe_dump_tool_call_args(tool, args_json) do
if System.get_env("FNORD_DEBUG_TOOL_CALLS") do
pretty =
with true <- is_binary(args_json),
{:ok, decoded} <- SafeJson.decode(args_json),
{:ok, json} <- SafeJson.encode(decoded, pretty: true) do
json
else
_ when is_binary(args_json) -> args_json
_ -> inspect(args_json, pretty: true)
end
border = "# " <> String.duplicate("-", 77)
dump = """
#{border}
# Tool call args (#{tool})
#{border}
#{pretty}
#{border}
"""
IO.puts(:stderr, dump)
UI.Tee.write([dump, "\n"])
end
end
# -----------------------------------------------------------------------------
# Tool call logging
# -----------------------------------------------------------------------------
def on_event(state, :tool_call, {tool, args}) do
AI.Tools.on_tool_request(tool, args, state.toolbox)
|> case do
nil -> state
{step, msg} -> log_tool_call(state, step, msg)
step -> log_tool_call(state, step)
end
end
def on_event(state, :tool_call_result, {tool, args, {:ok, result}}) do
AI.Tools.on_tool_result(tool, args, result, state.toolbox)
|> case do
nil -> state
{step, msg} -> log_tool_call_result(state, step, msg)
step -> log_tool_call_result(state, step)
end
end
def on_event(state, :tool_call_error, {tool, args_json, {:error, reason}}) do
# Decode the arguments JSON, falling back to raw JSON on failure
args =
case SafeJson.decode(args_json) do
{:ok, decoded} -> decoded
_ -> args_json
end
case AI.Tools.on_tool_error(tool, args, reason, state.toolbox) do
:ignore ->
state
:default ->
reason_str =
if is_binary(reason) do
reason
else
inspect(reason, pretty: true)
end
log_tool_call_error(state, tool, args_json, reason_str)
msg when is_binary(msg) ->
log_tool_call(state, tool, msg)
{title, detail} when is_binary(title) and is_binary(detail) ->
log_tool_call(state, title, Util.truncate(detail, @max_tool_lines))
other ->
log_tool_call_error(state, tool, args_json, inspect(other, pretty: true))
end
end
def on_event(_state, _, _), do: :ok
# ----------------------------------------------------------------------------
# Continuing a conversation
# ----------------------------------------------------------------------------
@doc """
Replays an earlier conversation identically to the original interaction,
except that the final response is logged as a typical assistant message, so
that the conversation may be continued.
"""
def replay_conversation(%{replay_conversation: false} = state), do: state
def replay_conversation(state) do
messages = Util.string_keys_to_atoms(state.messages)
agent_name = state.name || extract_agent_name(messages)
state = Map.put(state, :name, agent_name)
# Make a lookup for tool call args by id
tool_call_args = build_tool_call_args(messages)
# The toolbox isn't stored in the saved conversation, so we pass the
# complete toolbox while replaying.
all_tools =
AI.Tools.all_tools()
# Mark toolbox as replay to bypass validation in AI.Tools.on_tool_request
replay_tools = Map.put(all_tools, "__replay__", true)
messages
# Skip the first message, which is the system prompt for the agent
|> Enum.drop(1)
|> Enum.each(fn msg ->
state
|> Map.put(:toolbox, replay_tools)
|> replay_msg(msg, tool_call_args)
end)
state
end
@doc """
Replays the entire conversation, similarly to `replay_conversation/1`, while
rendering the final assistant response to STDOUT in the same shape the user
would receive from the CLI.
Piped output stays plain for copy/paste safety. Interactive terminal output
adds the same kind of framed emphasis used by other prominent terminal
sections so the replay ends with a clear handoff from the agent to the user.
"""
def replay_conversation_as_output(state) do
# Retrieve messages and convert to an atom-keyed map, extracting the final
# message, which is the final response from the assistant.
all = Util.string_keys_to_atoms(state.messages)
case Enum.split(all, -1) do
{messages, [response]} ->
do_replay(state, messages, response)
# An empty or malformed saved conversation (e.g. truncated write, manual
# edit) has nothing to replay. Surface the empty case rather than
# crashing with MatchError on the split.
_ ->
UI.warn("[replay] conversation has no messages to replay")
state
end
end
defp do_replay(state, messages, response) do
agent_name = state.name || extract_agent_name(messages)
state = Map.put(state, :name, agent_name)
# Make a lookup for tool call args by id
tool_call_args = build_tool_call_args(messages)
# The toolbox isn't stored in the saved conversation, so we pass the
# complete toolbox while replaying.
all_tools =
AI.Tools.all_tools()
# Mark toolbox as replay to bypass validation in AI.Tools.on_tool_request
replay_tools = Map.put(all_tools, "__replay__", true)
messages
# Skip the first message, which is the system prompt for the agent
|> Enum.drop(1)
|> Enum.each(fn msg ->
state
|> Map.put(:toolbox, replay_tools)
|> replay_msg(msg, tool_call_args)
end)
UI.flush()
# UI outputs to STDERR and the next message is going to STDOUT, so we need
# to pause to allow the terminal to catch up before printing the final
# message. This is a shitty, shitty hack, but I haven't found a way to
# coordinate the two streams.
Process.sleep(100)
output = format_final_response_output(response.content, agent_name, UI.stdout_tty?())
IO.write(:stdio, output)
state
end
defp replay_msg(state, message, tool_call_args) do
case message do
%{role: "assistant", content: nil, tool_calls: tool_calls} ->
tool_calls
|> Enum.each(fn %{function: %{name: func, arguments: args_json}} ->
with {:ok, args} <- SafeJson.decode(args_json) do
on_event(state, :tool_call, {func, args})
end
end)
%{role: "tool", name: func, tool_call_id: id, content: content} ->
on_event(state, :tool_call_result, {func, tool_call_args[id], content})
%{role: "assistant", content: content} ->
log_assistant_msg(state, content)
%{role: "user", content: content} ->
log_user_msg(state, content)
_ ->
:ok
end
end
# Formats the final assistant response for the terminal. Piped output stays
# plain so saved or redirected text remains clean, while interactive terminal
# output gets a framed banner naming the agent before the response body.
defp format_final_response_output(content, agent_name, true) do
content
|> final_response_with_banner(agent_name)
|> UI.format()
end
defp format_final_response_output(content, _agent_name, false) do
"\n#{content}\n"
end
# Wraps the final assistant response in the same visual framing used by other
# prominent terminal sections so the end of the replay reads like a deliberate
# handoff from the agent to the user.
defp final_response_with_banner(content, agent_name) do
[
"\n",
response_banner_separator(),
"\n",
response_banner_title(agent_name),
"\n",
response_banner_separator(),
"\n\n",
content,
"\n"
]
|> IO.iodata_to_binary()
end
# Builds the separator line used around the final response banner.
defp response_banner_separator do
IO.ANSI.format([:cyan, String.duplicate("─", 60), :reset], true)
end
# Builds the banner title for the final response using the responding agent's
# name.
defp response_banner_title(agent_name) do
title = " ◆ #{agent_name}'s Response ◆ "
IO.ANSI.format([:cyan_background, :black, :bright, title, :reset], true)
end
defp build_tool_call_args(messages) do
messages
|> Enum.reduce(%{}, fn msg, acc ->
case msg do
%{role: "assistant", content: nil, tool_calls: tool_calls} ->
tool_calls
|> Enum.map(fn %{id: id, function: %{arguments: args}} -> {id, args} end)
|> Enum.into(acc)
_ ->
acc
end
end)
end
defp extract_agent_name(messages) do
regex = ~r/^Your name is (.*)\.$/
default_name = Services.NamePool.default_name()
# Prefer developer messages
developer_name =
messages
|> Enum.find(fn
%{role: "developer", content: content} -> Regex.match?(regex, content)
_ -> false
end)
|> case do
%{content: content} ->
case Regex.run(regex, content) do
[_, name] -> name
_ -> nil
end
_ ->
nil
end
if developer_name && developer_name != default_name do
developer_name
else
# Fallback to system messages
system_name =
messages
|> Enum.find(fn
%{role: "system", content: content} -> Regex.match?(regex, content)
_ -> false
end)
|> case do
%{content: content} ->
case Regex.run(regex, content) do
[_, name] -> name
_ -> nil
end
_ ->
nil
end
if system_name && system_name != default_name do
system_name
else
default_name
end
end
end
end