Packages
fnord
0.9.40
0.9.40
0.9.39
0.9.38
0.9.37
0.9.36
0.9.35
0.9.34
0.9.33
0.9.32
0.9.31
0.9.30
0.9.29
0.9.28
0.9.27
0.9.26
0.9.25
0.9.24
0.9.23
0.9.22
0.9.21
0.9.20
0.9.19
0.9.18
0.9.17
0.9.16
0.9.15
0.9.14
0.9.13
0.9.12
0.9.11
0.9.10
0.9.9
0.9.8
0.9.7
0.9.6
0.9.5
0.9.4
0.9.3
0.9.2
0.9.1
0.9.0
0.8.99
0.8.98
0.8.97
0.8.96
0.8.95
0.8.94
0.8.93
0.8.92
0.8.91
0.8.90
0.8.89
0.8.88
0.8.87
0.8.86
0.8.85
0.8.84
0.8.83
0.8.82
0.8.81
0.8.80
0.8.79
0.8.78
0.8.77
0.8.76
0.8.75
0.8.74
0.8.73
0.8.72
0.8.71
0.8.70
0.8.69
0.8.68
0.8.67
0.8.66
0.8.65
0.8.64
0.8.63
0.8.62
0.8.61
0.8.60
0.8.59
0.8.58
0.8.57
0.8.56
0.8.55
0.8.54
0.8.53
0.8.52
0.8.51
0.8.50
0.8.49
0.8.48
0.8.47
0.8.46
0.8.45
0.8.44
0.8.43
0.8.42
0.8.41
0.8.40
0.8.39
0.8.38
0.8.37
0.8.36
0.8.35
0.8.34
0.8.33
0.8.32
0.8.31
0.8.30
0.8.29
0.8.27
0.8.26
0.8.25
0.8.24
0.8.23
0.8.22
0.8.21
0.8.20
0.8.19
0.8.18
0.8.17
0.8.16
0.8.15
0.8.14
0.8.13
0.8.12
0.8.11
0.8.1
0.8.0
0.7.24
0.7.23
0.7.22
0.7.21
0.7.20
0.7.19
0.7.18
0.7.17
0.7.16
0.7.15
0.7.14
0.7.13
0.7.12
0.7.11
0.7.10
0.7.9
0.7.8
0.7.7
0.7.6
0.7.5
0.7.3
0.7.2
0.7.1
0.7.0
0.6.9
0.6.8
0.6.7
0.6.6
0.6.5
0.6.4
0.6.3
0.6.1
0.6.0
0.5.9
0.5.8
0.5.7
0.5.6
0.5.5
0.5.4
0.5.3
0.5.2
0.5.1
0.5.0
0.4.44
0.4.43
0.4.42
0.4.41
0.4.40
0.4.39
0.4.38
0.4.37
0.4.36
0.4.35
0.4.34
0.4.33
0.4.32
0.4.30
0.4.29
0.4.28
0.4.27
0.4.26
0.4.25
0.4.24
0.4.23
0.4.22
0.4.21
0.4.20
0.4.19
0.4.18
0.4.17
0.4.16
0.4.15
0.4.14
0.4.13
0.4.12
0.4.11
0.4.10
0.4.9
0.4.8
0.4.7
0.4.6
0.4.5
0.4.4
0.4.3
0.4.2
0.4.1
0.4.0
0.3.0
0.2.0
0.1.0
AI code archaeology
Current section
Files
Jump to
Current section
Files
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