Packages
fnord
0.9.29
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/util.ex
defmodule AI.Util do
# ----------------------------------------------------------------------------
# On average, English words about 4.5 characters long, plus a space or
# punctuation. OpenAI posits that 1 token ~= 4 characters in English text.
#
# We can use these to approximate a reasonable max length for messages to
# mitigate the risk of a single, new message being added to a conversation
# that blows so far past the model's context window that it prevents even
# compaction from working effectively:
# - 5 bytes per word * 10,000 words = 50,000 bytes
#
# The current crop of models have a context window of 400k tokens:
# - 400,000 tokens * 4 bytes per token = 1,600,000 bytes
# - 1,600,000 bytes / 50,000 bytes per message = 32 messages
#
# That seems like a reasonable baseline threshold to start with.
# ----------------------------------------------------------------------------
@max_msg_length 50_000
@doc """
Returns the maximum message length allowed.
"""
@spec max_msg_length() :: non_neg_integer()
def max_msg_length() do
@max_msg_length
end
@role_system "developer"
@role_user "user"
@role_assistant "assistant"
@role_tool "tool"
@type tool_call :: %{
id: binary,
type: binary,
function: %{name: binary, arguments: binary}
}
@type tool_call_parsed :: %{
id: binary,
type: binary,
function: %{name: binary, arguments: map}
}
@type content_msg :: %{role: binary, content: binary}
@type tool_request_msg :: %{
role: binary,
content: nil,
tool_calls: [tool_call_parsed]
}
@type tool_response_msg :: %{
role: binary,
name: binary,
tool_call_id: binary,
content: binary
}
@type msg ::
content_msg
| tool_request_msg
| tool_response_msg
@type msg_list :: [msg]
# Computes the cosine similarity between two vectors
@spec cosine_similarity([float], [float]) :: float
def cosine_similarity(vec1, vec2) do
if length(vec1) != length(vec2) do
raise ArgumentError, """
Vectors must have the same length to compute cosine similarity.
- Left: #{length(vec1)}
- Right: #{length(vec2)}
"""
end
dot_product = Enum.zip(vec1, vec2) |> Enum.reduce(0.0, fn {a, b}, acc -> acc + a * b end)
magnitude1 = :math.sqrt(Enum.reduce(vec1, 0.0, fn x, acc -> acc + x * x end))
magnitude2 = :math.sqrt(Enum.reduce(vec2, 0.0, fn x, acc -> acc + x * x end))
if magnitude1 == 0.0 or magnitude2 == 0.0 do
0.0
else
dot_product / (magnitude1 * magnitude2)
end
end
# -----------------------------------------------------------------------------
# Building transcripts
# -----------------------------------------------------------------------------
@doc """
Builds a "transcript" of the research process by converting the messages into
text. This is most commonly used to generate a transcript of the research
performed in a conversation for various agents and tool calls.
"""
@spec research_transcript([msg]) :: binary
def research_transcript(msgs) do
# Make a lookup for tool call args by id
tool_call_args = build_tool_call_args(msgs)
msgs
# Drop all messages until the first user message
|> Enum.drop_while(&(&1.role != @role_user))
# Convert messages into text
|> Enum.reduce([], fn
%{role: @role_user, content: content}, acc ->
["# USER:\n#{content}" | acc]
%{role: @role_assistant, content: content}, acc when is_binary(content) ->
# Ignore <think> messages, which are used to indicate the assistant is thinking
if String.starts_with?(content, "<think>") do
acc
else
["# ASSISTANT:\n#{content}" | acc]
end
# May be present in older conversations.
%{role: "system", content: _}, acc ->
acc
%{role: @role_system, content: _content}, acc ->
acc
%{role: @role_tool, tool_call_id: id, name: name, content: content}, acc ->
args = tool_call_args[id] |> SafeJson.encode!()
text = """
# TOOL CALL
Performed research using the tool, `#{name}`, with the following arguments:
`#{args}`
Result:
#{content}
"""
[text | acc]
_msg, acc ->
acc
end)
|> Enum.reverse()
|> Enum.join("\n-----\n")
end
defp build_tool_call_args(msgs) do
msgs
|> Enum.reduce(%{}, fn msg, acc ->
case msg do
%{role: @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
@doc """
Extracts the user's *most recent* query from the conversation messages.
"""
@spec user_query([msg]) :: binary | nil
def user_query(messages) do
messages
|> Enum.filter(&(&1.role == @role_user))
|> List.first()
|> then(& &1.content)
end
# -----------------------------------------------------------------------------
# Messages
# -----------------------------------------------------------------------------
@doc """
Creates a system message object, used to define the assistant's behavior for
the conversation.
"""
@spec system_msg(binary) :: content_msg
def system_msg(msg) do
%{role: @role_system, content: msg}
|> validate_msg_length()
end
@doc """
Creates a user message object, representing the user's input prompt.
"""
@spec user_msg(binary) :: content_msg
def user_msg(msg) do
%{role: @role_user, content: msg}
|> validate_msg_length()
end
@doc """
Creates an assistant message object, representing the assistant's response.
"""
@spec assistant_msg(binary) :: content_msg
def assistant_msg(msg) do
%{role: @role_assistant, content: msg}
|> validate_msg_length()
end
@doc """
This is the tool outputs message, which must come immediately after the
`assistant_tool_msg/3` message with the same `tool_call_id` (`id`).
"""
@spec tool_msg(binary, binary, any) :: tool_response_msg
def tool_msg(id, func, output) do
output =
if is_binary(output) do
output
else
inspect(output, pretty: true)
end
output = spill_tool_output_if_needed(id, func, output)
output = """
#{output}
Tool call with ID `#{id}` completed using the function `#{func}`.
"""
%{
role: @role_tool,
name: func,
tool_call_id: id,
content: output
}
|> validate_msg_length()
end
@doc """
A guard to identify system messages.
"""
defguard is_system_msg?(msg)
when is_map(msg) and
msg.role in [@role_system, "system"]
# When a tool produces a very large output, writing the entire contents into the
# conversation can blow past the model's context window. For tool outputs, we
# instead spill the full content to a temporary file and return a preview plus
# explicit instructions for using `cmd_tool` to inspect the file.
defp spill_tool_output_if_needed(_id, _func, output) when is_binary(output) do
if String.length(output) <= @max_msg_length do
output
else
# Use a temp path that the model can reference with cmd_tool. We rely
# on Briefly for atomic, race-safe temp file creation and cleanup when
# the owning process or BEAM exits.
with dir when is_binary(dir) <- System.tmp_dir(),
{:ok, filename} <-
Services.TempFile.mktemp(
directory: dir,
prefix: "fnord-tool-",
extname: ".log"
),
# Best-effort write; if it fails, we fall back to normal truncation.
:ok <- File.write(filename, output) do
bytes = byte_size(output)
lines = output |> String.split("\n") |> length()
header = """
[fnord: tool output truncated]
Full output saved to: #{filename}
Size: #{bytes} bytes (#{lines} lines)
This file will be automatically cleaned up after your next complete response to the user.
To inspect more of this output, use `cmd_tool` with a command like:
- `cat #{filename}`
- `sed -n 'START,ENDp' #{filename}`
--- Begin truncated preview ---
"""
# Reserve room for the header and a closing footer inside @max_msg_length.
# This keeps validate_msg_length/1 as a final safety net rather than the
# primary truncation mechanism for tool outputs.
header_len = String.length(header)
footer = "\n--- End truncated preview ---"
footer_len = String.length(footer)
# Leave a bit of extra slack so that validate_msg_length/1 is less likely
# to trim off the footer we add here.
safety_margin = 200
max_preview_len = max(@max_msg_length - header_len - footer_len - safety_margin, 0)
preview = String.slice(output, 0, max_preview_len)
header <> preview <> footer
else
{:error, _reason} ->
# If we cannot write the tmp file, fall back to the original output and
# let validate_msg_length/1 handle truncation.
output
end
end
end
defp spill_tool_output_if_needed(_id, _func, output), do: output
@doc """
This is the tool call message, which must come immediately before the
`tool_msg/3` message with the same `tool_call_id` (`id`).
"""
@spec assistant_tool_msg(binary, binary, binary) :: tool_request_msg
def assistant_tool_msg(id, func, args) do
%{
role: @role_assistant,
content: nil,
tool_calls: [
%{
id: id,
type: "function",
function: %{
name: func,
arguments: args
}
}
]
}
end
defp validate_msg_length(%{content: content} = msg) when is_binary(content) do
if String.length(content) > @max_msg_length do
warning = "(msg truncated due to size)"
wlen = String.length(warning)
max = @max_msg_length - wlen
%{msg | content: String.slice(content, 0, max) <> warning}
else
msg
end
end
defp validate_msg_length(msg), do: msg
# ---------------------------------------------------------------------------
# Project context - shared preamble for any agent that needs to know where
# files live. The coordinator gets this via $$PROJECT$$ and $$GIT_INFO$$
# substitution; sub-agents (review specialists, skill agents, etc.) should
# prepend this to their system or user prompts so the LLM knows the actual
# filesystem paths and doesn't guess /repo or a CI prefix.
# ---------------------------------------------------------------------------
@doc """
Returns a short context block describing the current project and git state.
Suitable for prepending to any agent's system prompt.
"""
@spec project_context() :: binary
def project_context do
project_info =
case Store.get_project() do
{:ok, project} ->
"""
You are working in the project "#{project.name}".
The project root is `#{project.source_root}`.
All file paths are relative to this root unless absolute.
"""
_ ->
""
end
git_info = GitCli.git_info()
String.trim("#{project_info}#{git_info}")
end
end