Packages
fnord
0.8.70
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
221 Versions
Jump to
Current section
221 Versions
Compare versions
10
files changed
+806
additions
-50
deletions
| @@ -1,6 +1,6 @@ | |
| 1 1 | {<<"links">>,[{<<"GitHub">>,<<"https://github.com/sysread/fnord">>}]}. |
| 2 2 | {<<"name">>,<<"fnord">>}. |
| 3 | - {<<"version">>,<<"0.8.69">>}. |
| 3 | + {<<"version">>,<<"0.8.70">>}. |
| 4 4 | {<<"description">>,<<"AI code archaeology">>}. |
| 5 5 | {<<"elixir">>,<<"~> 1.18">>}. |
| 6 6 | {<<"app">>,<<"fnord">>}. |
| @@ -98,10 +98,12 @@ | |
| 98 98 | <<"lib/ai/tools/list_projects.ex">>,<<"lib/ai/notes.ex">>, |
| 99 99 | <<"lib/ai/completion">>,<<"lib/ai/completion/output.ex">>, |
| 100 100 | <<"lib/ai/completion/compaction.ex">>,<<"lib/ai/agent">>, |
| 101 | - <<"lib/ai/agent/code_mapper.ex">>,<<"lib/ai/agent/compactor.ex">>, |
| 102 | - <<"lib/ai/agent/coordinator.ex">>,<<"lib/ai/agent/nomenclater.ex">>, |
| 103 | - <<"lib/ai/agent/spelunker.ex">>,<<"lib/ai/agent/code">>, |
| 104 | - <<"lib/ai/agent/code/task_implementor.ex">>, |
| 101 | + <<"lib/ai/agent/code_mapper.ex">>,<<"lib/ai/agent/compactor.ex.0.3.bak">>, |
| 102 | + <<"lib/ai/agent/compactor.ex.0.2.bak">>,<<"lib/ai/agent/compactor.ex">>, |
| 103 | + <<"lib/ai/agent/compactor.ex.0.0.bak">>, |
| 104 | + <<"lib/ai/agent/compactor.ex.0.1.bak">>,<<"lib/ai/agent/coordinator.ex">>, |
| 105 | + <<"lib/ai/agent/nomenclater.ex">>,<<"lib/ai/agent/spelunker.ex">>, |
| 106 | + <<"lib/ai/agent/code">>,<<"lib/ai/agent/code/task_implementor.ex">>, |
| 105 107 | <<"lib/ai/agent/code/task_planner.ex">>, |
| 106 108 | <<"lib/ai/agent/code/task_validator.ex">>, |
| 107 109 | <<"lib/ai/agent/code/repatcher.ex">>,<<"lib/ai/agent/code/common.ex">>, |
| @@ -7,40 +7,26 @@ defmodule AI.Agent.Compactor do | |
| 7 7 | @target_ratio 0.8 |
| 8 8 | |
| 9 9 | # Minimum acceptable tokens for a compacted summary; prevent trivial context wipes |
| 10 | - # Note: set to >0 in production if you want to reject trivial summaries; tests expect tiny outputs. |
| 11 | - @min_summary_tokens 0 |
| 10 | + @min_summary_tokens 100 |
| 12 11 | |
| 13 12 | @system_prompt """ |
| 14 | - You are an AI Agent in a larger system. |
| 15 | - You will be presented with a transcript of a conversation between a user and an AI assistant, along with any research the assistant has done. |
| 16 | - Your task: Reformat into compact meeting minutes while preserving all content needed for context. |
| 17 | - Do not use smart quotes, smart apostrophes, emojis, or other special characters. |
| 18 | - Read through the messages and reason through a new, more compact narrative that preserves the key points and context. |
| 19 | - Consider the conversation from the user's perspective: what expectations would they have for the assistant's memory and understanding of the conversation? |
| 13 | + Summarize this conversation transcript concisely while preserving essential context. |
| 14 | + You will receive a JSON transcript of messages between a user and an AI assistant, including tool outputs and research. |
| 20 15 | |
| 21 | - Preserve decision points, assumptions, trade-offs, mistakes and corrections, and include rationales leading to any state changes. |
| 16 | + Focus on: what the user asked for, what was learned or discovered, decisions made, and what work is in progress. |
| 17 | + Preserve specific details about files, functions, bugs, and technical decisions. |
| 18 | + Use plain text without special characters. |
| 22 19 | |
| 23 | - Use the following output template: |
| 20 | + Output format: |
| 24 21 | |
| 25 | - # Original User Prompt |
| 26 | - [full text of the original user prompt] |
| 22 | + # User Request |
| 23 | + [What the user is asking for or working on] |
| 27 24 | |
| 28 | - # Research and Responses |
| 29 | - [outline of facts, findings, and conclusions from the research portions of the conversation] |
| 25 | + # Key Findings |
| 26 | + [Important information discovered: file locations, function names, patterns, bugs found, etc.] |
| 30 27 | |
| 31 | - # Conversation Timeline |
| 32 | - [ |
| 33 | - Present a timeline of the conversation, listing each message with its role and a brief summary of its content. |
| 34 | - Messages at the end of the conversation should include WAY more detail than those at the beginning, reflecting the decision cascade and evolving context |
| 35 | - Don't waste space with formatting or JSON; just use plain text. |
| 36 | - ] |
| 37 | - |
| 38 | - # Continuation Context |
| 39 | - [ |
| 40 | - What was the assistant doing before the conversation grew too long? |
| 41 | - Your response will form the complete prompt for the LLM's next response. |
| 42 | - This section MUST guarantee that the LLM continues exactly where it left off. |
| 43 | - ] |
| 28 | + # Current Status |
| 29 | + [What the assistant was doing when context limit approached. Include enough detail that work can resume exactly where it left off.] |
| 44 30 | """ |
| 45 31 | |
| 46 32 | @impl AI.Agent |
| @@ -53,8 +39,11 @@ defmodule AI.Agent.Compactor do | |
| 53 39 | |
| 54 40 | tx_list = transcript(messages, []) |
| 55 41 | |
| 56 | - # Early guard: empty transcript -> skip model call and retries |
| 57 | - if tx_list == [] do |
| 42 | + # Early guard: empty transcript or user-only transcript -> skip model call and retries |
| 43 | + # User messages are preserved separately, so if there's nothing but user messages, there's nothing to summarize |
| 44 | + has_non_user = Enum.any?(tx_list, fn msg -> msg.role != "user" end) |
| 45 | + |
| 46 | + if tx_list == [] or not has_non_user do |
| 58 47 | {:error, :empty_after_filtering} |
| 59 48 | else |
| 60 49 | transcript_json = Jason.encode!(tx_list, pretty: true) |
| @@ -146,6 +135,7 @@ defmodule AI.Agent.Compactor do | |
| 146 135 | defp transcript([], acc), do: Enum.reverse(acc) |
| 147 136 | defp transcript([%{role: "system"} | rest], acc), do: transcript(rest, acc) |
| 148 137 | defp transcript([%{role: "developer"} | rest], acc), do: transcript(rest, acc) |
| 138 | + defp transcript([%{role: "user"} = msg | rest], acc), do: transcript(rest, [msg | acc]) |
| 149 139 | defp transcript([%{role: "tool", name: "notify_tool"} | rest], acc), do: transcript(rest, acc) |
| 150 140 | |
| 151 141 | defp transcript([%{role: "tool", name: name, content: content} | rest], acc) do |
| @@ -0,0 +1,170 @@ | |
| 1 | + defmodule AI.Agent.Compactor do |
| 2 | + @behaviour AI.Agent |
| 3 | + |
| 4 | + @model AI.Model.large_context(:balanced) |
| 5 | + @max_attempts 3 |
| 6 | + @min_length 512 |
| 7 | + @target_ratio 0.8 |
| 8 | + |
| 9 | + # Minimum acceptable tokens for a compacted summary; prevent trivial context wipes |
| 10 | + # Note: set to >0 in production if you want to reject trivial summaries; tests expect tiny outputs. |
| 11 | + @min_summary_tokens 0 |
| 12 | + |
| 13 | + @system_prompt """ |
| 14 | + You are an AI Agent in a larger system. |
| 15 | + You will be presented with a transcript of a conversation between a user and an AI assistant, along with any research the assistant has done. |
| 16 | + Your task: Reformat into compact meeting minutes while preserving all content needed for context. |
| 17 | + Do not use smart quotes, smart apostrophes, emojis, or other special characters. |
| 18 | + Read through the messages and reason through a new, more compact narrative that preserves the key points and context. |
| 19 | + Consider the conversation from the user's perspective: what expectations would they have for the assistant's memory and understanding of the conversation? |
| 20 | + |
| 21 | + Preserve decision points, assumptions, trade-offs, mistakes and corrections, and include rationales leading to any state changes. |
| 22 | + |
| 23 | + Use the following output template: |
| 24 | + |
| 25 | + # Original User Prompt |
| 26 | + [full text of the original user prompt] |
| 27 | + |
| 28 | + # Research and Responses |
| 29 | + [outline of facts, findings, and conclusions from the research portions of the conversation] |
| 30 | + |
| 31 | + # Conversation Timeline |
| 32 | + [ |
| 33 | + Present a timeline of the conversation, listing each message with its role and a brief summary of its content. |
| 34 | + Messages at the end of the conversation should include WAY more detail than those at the beginning, reflecting the decision cascade and evolving context |
| 35 | + Don't waste space with formatting or JSON; just use plain text. |
| 36 | + ] |
| 37 | + |
| 38 | + # Continuation Context |
| 39 | + [ |
| 40 | + What was the assistant doing before the conversation grew too long? |
| 41 | + Your response will form the complete prompt for the LLM's next response. |
| 42 | + This section MUST guarantee that the LLM continues exactly where it left off. |
| 43 | + ] |
| 44 | + """ |
| 45 | + |
| 46 | + @impl AI.Agent |
| 47 | + def get_response(%{messages: [%{role: "developer", content: @system_prompt} | _]}) do |
| 48 | + raise "Refusing to compact a compaction prompt" |
| 49 | + end |
| 50 | + |
| 51 | + def get_response(%{messages: messages} = opts) do |
| 52 | + attempts = Map.get(opts, :attempts, 0) |
| 53 | + |
| 54 | + tx_list = transcript(messages, []) |
| 55 | + |
| 56 | + # Early guard: empty transcript -> skip model call and retries |
| 57 | + if tx_list == [] do |
| 58 | + {:error, :empty_after_filtering} |
| 59 | + else |
| 60 | + transcript_json = Jason.encode!(tx_list, pretty: true) |
| 61 | + original_length = byte_size(transcript_json) |
| 62 | + |
| 63 | + UI.info( |
| 64 | + "Compaction starting", |
| 65 | + "Transcript JSON size: #{original_length} bytes. Recent messages preserved; proceeding with compaction." |
| 66 | + ) |
| 67 | + |
| 68 | + UI.info( |
| 69 | + "Summarizing conversation transcript (expected)", |
| 70 | + "No new user prompt was added for this compaction pass. We are summarizing the transcript for compactness; " <> |
| 71 | + "recent messages (including your latest prompts) remain intact." |
| 72 | + ) |
| 73 | + |
| 74 | + AI.Accumulator.get_response( |
| 75 | + model: @model, |
| 76 | + prompt: @system_prompt, |
| 77 | + input: transcript_json |
| 78 | + ) |
| 79 | + |> case do |
| 80 | + {:ok, %{response: response}} -> |
| 81 | + summary = |
| 82 | + """ |
| 83 | + Summary of conversation and research thus far: |
| 84 | + #{response} |
| 85 | + """ |
| 86 | + |
| 87 | + # Guard against trivial, near-empty summaries that would wipe context |
| 88 | + new_tokens = AI.PretendTokenizer.guesstimate_tokens(summary) |
| 89 | + |
| 90 | + if new_tokens < @min_summary_tokens do |
| 91 | + UI.error( |
| 92 | + "Compaction failed", |
| 93 | + "Summary too small (#{new_tokens} < #{@min_summary_tokens} tokens)" |
| 94 | + ) |
| 95 | + |
| 96 | + {:error, :summary_too_small} |
| 97 | + else |
| 98 | + new_length = byte_size(summary) |
| 99 | + difference = original_length - new_length |
| 100 | + percent = difference / original_length * 100.0 |
| 101 | + |
| 102 | + UI.info(""" |
| 103 | + Compaction results: |
| 104 | + Original: #{Util.format_number(original_length)} bytes |
| 105 | + Compacted: #{Util.format_number(new_length)} bytes |
| 106 | + Savings: #{percent}% (#{Util.format_number(difference)} bytes) |
| 107 | + """) |
| 108 | + |
| 109 | + cond do |
| 110 | + original_length < @min_length -> |
| 111 | + UI.debug("Compaction retries skipped", "Original is too small to justify retries") |
| 112 | + |
| 113 | + if new_length < original_length do |
| 114 | + summary |> AI.Util.system_msg() |> then(&{:ok, [&1]}) |
| 115 | + else |
| 116 | + UI.warn("Compaction failed", "Summary is larger than original; aborting") |
| 117 | + {:error, :compaction_failed} |
| 118 | + end |
| 119 | + |
| 120 | + new_length > original_length * @target_ratio and attempts < @max_attempts -> |
| 121 | + UI.warn( |
| 122 | + "Compaction insufficient", |
| 123 | + "Attempting another pass (#{attempts + 1}/#{@max_attempts})" |
| 124 | + ) |
| 125 | + |
| 126 | + get_response(%{messages: messages, attempts: attempts + 1}) |
| 127 | + |
| 128 | + true -> |
| 129 | + UI.debug("Compacted conversation", summary) |
| 130 | + |
| 131 | + if new_length < original_length do |
| 132 | + summary |> AI.Util.system_msg() |> then(&{:ok, [&1]}) |
| 133 | + else |
| 134 | + UI.warn("Compaction failed", "Summary is larger than original; aborting") |
| 135 | + {:error, :compaction_failed} |
| 136 | + end |
| 137 | + end |
| 138 | + end |
| 139 | + |
| 140 | + {:error, reason} -> |
| 141 | + {:error, reason} |
| 142 | + end |
| 143 | + end |
| 144 | + end |
| 145 | + |
| 146 | + defp transcript([], acc), do: Enum.reverse(acc) |
| 147 | + defp transcript([%{role: "system"} | rest], acc), do: transcript(rest, acc) |
| 148 | + defp transcript([%{role: "developer"} | rest], acc), do: transcript(rest, acc) |
| 149 | + defp transcript([%{role: "user"} | rest], acc), do: transcript(rest, acc) |
| 150 | + defp transcript([%{role: "tool", name: "notify_tool"} | rest], acc), do: transcript(rest, acc) |
| 151 | + |
| 152 | + defp transcript([%{role: "tool", name: name, content: content} | rest], acc) do |
| 153 | + transcript(rest, [%{role: "tool", name: name, content: content} | acc]) |
| 154 | + end |
| 155 | + |
| 156 | + defp transcript([%{role: "assistant", content: nil} | rest], acc), do: transcript(rest, acc) |
| 157 | + |
| 158 | + defp transcript([%{role: "assistant", content: content} = msg | rest], acc) |
| 159 | + when is_binary(content) do |
| 160 | + if String.starts_with?(content, "<think>") do |
| 161 | + # skip internal reasoning |
| 162 | + transcript(rest, acc) |
| 163 | + else |
| 164 | + # include assistant message |
| 165 | + transcript(rest, [msg | acc]) |
| 166 | + end |
| 167 | + end |
| 168 | + |
| 169 | + defp transcript([msg | rest], acc), do: transcript(rest, [msg | acc]) |
| 170 | + end |
| @@ -0,0 +1,182 @@ | |
| 1 | + defmodule AI.Agent.Compactor do |
| 2 | + @behaviour AI.Agent |
| 3 | + |
| 4 | + @model AI.Model.large_context(:balanced) |
| 5 | + @max_attempts 3 |
| 6 | + @min_length 512 |
| 7 | + @target_ratio 0.8 |
| 8 | + |
| 9 | + # Minimum acceptable tokens for a compacted summary; prevent trivial context wipes |
| 10 | + # Note: set to >0 in production if you want to reject trivial summaries; tests expect tiny outputs. |
| 11 | + @min_summary_tokens 0 |
| 12 | + |
| 13 | + @system_prompt """ |
| 14 | + You are an AI Agent in a larger system. |
| 15 | + You will be presented with a transcript of a conversation between a user and an AI assistant, along with any research the assistant has done. |
| 16 | + Your task: Reformat into compact meeting minutes while preserving all content needed for context. |
| 17 | + Do not use smart quotes, smart apostrophes, emojis, or other special characters. |
| 18 | + Read through the messages and reason through a new, more compact narrative that preserves the key points and context. |
| 19 | + Consider the conversation from the user's perspective: what expectations would they have for the assistant's memory and understanding of the conversation? |
| 20 | + |
| 21 | + Preserve decision points, assumptions, trade-offs, mistakes and corrections, and include rationales leading to any state changes. |
| 22 | + |
| 23 | + Use the following output template: |
| 24 | + |
| 25 | + # Original User Prompt |
| 26 | + [full text of the original user prompt] |
| 27 | + |
| 28 | + # Research and Responses |
| 29 | + [outline of facts, findings, and conclusions from the research portions of the conversation] |
| 30 | + |
| 31 | + # Conversation Timeline |
| 32 | + [ |
| 33 | + Present a timeline of the conversation, listing each message with its role and a brief summary of its content. |
| 34 | + Messages at the end of the conversation should include WAY more detail than those at the beginning, reflecting the decision cascade and evolving context |
| 35 | + Don't waste space with formatting or JSON; just use plain text. |
| 36 | + ] |
| 37 | + |
| 38 | + # Continuation Context |
| 39 | + [ |
| 40 | + What was the assistant doing before the conversation grew too long? |
| 41 | + Your response will form the complete prompt for the LLM's next response. |
| 42 | + This section MUST guarantee that the LLM continues exactly where it left off. |
| 43 | + ] |
| 44 | + """ |
| 45 | + |
| 46 | + @impl AI.Agent |
| 47 | + def get_response(%{messages: [%{role: "developer", content: @system_prompt} | _]}) do |
| 48 | + raise "Refusing to compact a compaction prompt" |
| 49 | + end |
| 50 | + |
| 51 | + def get_response(%{messages: messages} = opts) do |
| 52 | + attempts = Map.get(opts, :attempts, 0) |
| 53 | + |
| 54 | + tx_list = transcript(messages, []) |
| 55 | + latest_user_prompt = |
| 56 | + messages |
| 57 | + |> Enum.reverse() |
| 58 | + |> Enum.find_value(fn |
| 59 | + %{role: "user", content: content} when is_binary(content) -> content |
| 60 | + _ -> nil |
| 61 | + end) |
| 62 | + |
| 63 | + prompt_to_use = |
| 64 | + case latest_user_prompt do |
| 65 | + nil -> @system_prompt <> "\nLATEST_USER_PROMPT:\n" |
| 66 | + content -> @system_prompt <> "\nLATEST_USER_PROMPT:\n" <> content |
| 67 | + end |
| 68 | + |
| 69 | + # Early guard: empty transcript -> skip model call and retries |
| 70 | + if tx_list == [] do |
| 71 | + {:error, :empty_after_filtering} |
| 72 | + else |
| 73 | + transcript_json = Jason.encode!(tx_list, pretty: true) |
| 74 | + original_length = byte_size(transcript_json) |
| 75 | + |
| 76 | + UI.info( |
| 77 | + "Compaction starting", |
| 78 | + "Transcript JSON size: #{original_length} bytes. Recent messages preserved; proceeding with compaction." |
| 79 | + ) |
| 80 | + |
| 81 | + UI.info( |
| 82 | + "Summarizing conversation transcript (expected)", |
| 83 | + "We are summarizing assistant/tool transcript for compactness. All user messages are preserved verbatim and excluded from this transcript. The latest user prompt is provided separately." |
| 84 | + ) |
| 85 | + |
| 86 | + AI.Accumulator.get_response( |
| 87 | + model: @model, |
| 88 | + prompt: prompt_to_use, |
| 89 | + input: transcript_json |
| 90 | + ) |
| 91 | + |> case do |
| 92 | + {:ok, %{response: response}} -> |
| 93 | + summary = |
| 94 | + """ |
| 95 | + Summary of conversation and research thus far: |
| 96 | + #{response} |
| 97 | + """ |
| 98 | + |
| 99 | + # Guard against trivial, near-empty summaries that would wipe context |
| 100 | + new_tokens = AI.PretendTokenizer.guesstimate_tokens(summary) |
| 101 | + |
| 102 | + if new_tokens < @min_summary_tokens do |
| 103 | + UI.error( |
| 104 | + "Compaction failed", |
| 105 | + "Summary too small (#{new_tokens} < #{@min_summary_tokens} tokens)" |
| 106 | + ) |
| 107 | + |
| 108 | + {:error, :summary_too_small} |
| 109 | + else |
| 110 | + new_length = byte_size(summary) |
| 111 | + difference = original_length - new_length |
| 112 | + percent = difference / original_length * 100.0 |
| 113 | + |
| 114 | + UI.info(""" |
| 115 | + Compaction results: |
| 116 | + Original: #{Util.format_number(original_length)} bytes |
| 117 | + Compacted: #{Util.format_number(new_length)} bytes |
| 118 | + Savings: #{percent}% (#{Util.format_number(difference)} bytes) |
| 119 | + """) |
| 120 | + |
| 121 | + cond do |
| 122 | + original_length < @min_length -> |
| 123 | + UI.debug("Compaction retries skipped", "Original is too small to justify retries") |
| 124 | + |
| 125 | + if new_length < original_length do |
| 126 | + summary |> AI.Util.system_msg() |> then(&{:ok, [&1]}) |
| 127 | + else |
| 128 | + UI.warn("Compaction failed", "Summary is larger than original; aborting") |
| 129 | + {:error, :compaction_failed} |
| 130 | + end |
| 131 | + |
| 132 | + new_length > original_length * @target_ratio and attempts < @max_attempts -> |
| 133 | + UI.warn( |
| 134 | + "Compaction insufficient", |
| 135 | + "Attempting another pass (#{attempts + 1}/#{@max_attempts})" |
| 136 | + ) |
| 137 | + |
| 138 | + get_response(%{messages: messages, attempts: attempts + 1}) |
| 139 | + |
| 140 | + true -> |
| 141 | + UI.debug("Compacted conversation", summary) |
| 142 | + |
| 143 | + if new_length < original_length do |
| 144 | + summary |> AI.Util.system_msg() |> then(&{:ok, [&1]}) |
| 145 | + else |
| 146 | + UI.warn("Compaction failed", "Summary is larger than original; aborting") |
| 147 | + {:error, :compaction_failed} |
| 148 | + end |
| 149 | + end |
| 150 | + end |
| 151 | + |
| 152 | + {:error, reason} -> |
| 153 | + {:error, reason} |
| 154 | + end |
| 155 | + end |
| 156 | + end |
| 157 | + |
| 158 | + defp transcript([], acc), do: Enum.reverse(acc) |
| 159 | + defp transcript([%{role: "system"} | rest], acc), do: transcript(rest, acc) |
| 160 | + defp transcript([%{role: "developer"} | rest], acc), do: transcript(rest, acc) |
| 161 | + defp transcript([%{role: "user"} | rest], acc), do: transcript(rest, acc) |
| 162 | + defp transcript([%{role: "tool", name: "notify_tool"} | rest], acc), do: transcript(rest, acc) |
| 163 | + |
| 164 | + defp transcript([%{role: "tool", name: name, content: content} | rest], acc) do |
| 165 | + transcript(rest, [%{role: "tool", name: name, content: content} | acc]) |
| 166 | + end |
| 167 | + |
| 168 | + defp transcript([%{role: "assistant", content: nil} | rest], acc), do: transcript(rest, acc) |
| 169 | + |
| 170 | + defp transcript([%{role: "assistant", content: content} = msg | rest], acc) |
| 171 | + when is_binary(content) do |
| 172 | + if String.starts_with?(content, "<think>") do |
| 173 | + # skip internal reasoning |
| 174 | + transcript(rest, acc) |
| 175 | + else |
| 176 | + # include assistant message |
| 177 | + transcript(rest, [msg | acc]) |
| 178 | + end |
| 179 | + end |
| 180 | + |
| 181 | + defp transcript([msg | rest], acc), do: transcript(rest, [msg | acc]) |
| 182 | + end |
| @@ -0,0 +1,190 @@ | |
| 1 | + defmodule AI.Agent.Compactor do |
| 2 | + @behaviour AI.Agent |
| 3 | + |
| 4 | + @model AI.Model.large_context(:balanced) |
| 5 | + @max_attempts 3 |
| 6 | + @min_length 512 |
| 7 | + @target_ratio 0.8 |
| 8 | + |
| 9 | + # Minimum acceptable tokens for a compacted summary; prevent trivial context wipes |
| 10 | + # Note: set to >0 in production if you want to reject trivial summaries; tests expect tiny outputs. |
| 11 | + @min_summary_tokens 0 |
| 12 | + |
| 13 | + @system_prompt """ |
| 14 | + You are an AI Agent in a larger system. |
| 15 | + You will be presented with a transcript of a conversation between a user and an AI assistant, along with any research the assistant has done. |
| 16 | + Your task: Reformat into compact meeting minutes while preserving all content needed for context. |
| 17 | + Do not use smart quotes, smart apostrophes, emojis, or other special characters. |
| 18 | + Read through the messages and reason through a new, more compact narrative that preserves the key points and context. |
| 19 | + Consider the conversation from the user's perspective: what expectations would they have for the assistant's memory and understanding of the conversation? |
| 20 | + |
| 21 | + Important: |
| 22 | + - User messages are preserved verbatim elsewhere and are intentionally excluded from the transcript you are given. |
| 23 | + - Do NOT claim that no user prompt was provided. |
| 24 | + - A section labeled "LATEST_USER_PROMPT:" will be appended to your system prompt. Use it VERBATIM for the "# Original User Prompt" section. |
| 25 | + - If the LATEST_USER_PROMPT section is empty, write: "[user prompt preserved verbatim elsewhere]". |
| 26 | + - Your summary must anchor continuation clearly; do not reset or reintroduce the conversation as if starting from scratch. |
| 27 | + |
| 28 | + Preserve decision points, assumptions, trade-offs, mistakes and corrections, and include rationales leading to any state changes. |
| 29 | + |
| 30 | + Use the following output template: |
| 31 | + |
| 32 | + # Original User Prompt |
| 33 | + [full text of the original user prompt] |
| 34 | + |
| 35 | + # Research and Responses |
| 36 | + [outline of facts, findings, and conclusions from the research portions of the conversation] |
| 37 | + |
| 38 | + # Conversation Timeline |
| 39 | + [ |
| 40 | + Present a timeline of the conversation, listing each message with its role and a brief summary of its content. |
| 41 | + Messages at the end of the conversation should include WAY more detail than those at the beginning, reflecting the decision cascade and evolving context |
| 42 | + Don't waste space with formatting or JSON; just use plain text. |
| 43 | + ] |
| 44 | + |
| 45 | + # Continuation Context |
| 46 | + [ |
| 47 | + What was the assistant doing before the conversation grew too long? |
| 48 | + Your response will form the complete prompt for the LLM's next response. |
| 49 | + This section MUST guarantee that the LLM continues exactly where it left off. |
| 50 | + ] |
| 51 | + """ |
| 52 | + |
| 53 | + @impl AI.Agent |
| 54 | + def get_response(%{messages: [%{role: "developer", content: @system_prompt} | _]}) do |
| 55 | + raise "Refusing to compact a compaction prompt" |
| 56 | + end |
| 57 | + |
| 58 | + def get_response(%{messages: messages} = opts) do |
| 59 | + attempts = Map.get(opts, :attempts, 0) |
| 60 | + |
| 61 | + tx_list = transcript(messages, []) |
| 62 | + |
| 63 | + latest_user_prompt = |
| 64 | + messages |
| 65 | + |> Enum.reverse() |
| 66 | + |> Enum.find_value(fn |
| 67 | + %{role: "user", content: content} when is_binary(content) -> content |
| 68 | + _ -> nil |
| 69 | + end) |
| 70 | + |
| 71 | + prompt_to_use = |
| 72 | + case latest_user_prompt do |
| 73 | + nil -> @system_prompt <> "\nLATEST_USER_PROMPT:\n" |
| 74 | + content -> @system_prompt <> "\nLATEST_USER_PROMPT:\n" <> content |
| 75 | + end |
| 76 | + |
| 77 | + # Early guard: empty transcript -> skip model call and retries |
| 78 | + if tx_list == [] do |
| 79 | + {:error, :empty_after_filtering} |
| 80 | + else |
| 81 | + transcript_json = Jason.encode!(tx_list, pretty: true) |
| 82 | + original_length = byte_size(transcript_json) |
| 83 | + |
| 84 | + UI.info( |
| 85 | + "Compaction starting", |
| 86 | + "Transcript JSON size: #{original_length} bytes. Recent messages preserved; proceeding with compaction." |
| 87 | + ) |
| 88 | + |
| 89 | + UI.info( |
| 90 | + "Summarizing conversation transcript (expected)", |
| 91 | + "We are summarizing assistant/tool transcript for compactness. All user messages are preserved verbatim and excluded from this transcript. The latest user prompt is provided separately." |
| 92 | + ) |
| 93 | + |
| 94 | + AI.Accumulator.get_response( |
| 95 | + model: @model, |
| 96 | + prompt: prompt_to_use, |
| 97 | + input: transcript_json |
| 98 | + ) |
| 99 | + |> case do |
| 100 | + {:ok, %{response: response}} -> |
| 101 | + summary = |
| 102 | + """ |
| 103 | + Summary of conversation and research thus far: |
| 104 | + #{response} |
| 105 | + """ |
| 106 | + |
| 107 | + # Guard against trivial, near-empty summaries that would wipe context |
| 108 | + new_tokens = AI.PretendTokenizer.guesstimate_tokens(summary) |
| 109 | + |
| 110 | + if new_tokens < @min_summary_tokens do |
| 111 | + UI.error( |
| 112 | + "Compaction failed", |
| 113 | + "Summary too small (#{new_tokens} < #{@min_summary_tokens} tokens)" |
| 114 | + ) |
| 115 | + |
| 116 | + {:error, :summary_too_small} |
| 117 | + else |
| 118 | + new_length = byte_size(summary) |
| 119 | + difference = original_length - new_length |
| 120 | + percent = difference / original_length * 100.0 |
| 121 | + |
| 122 | + UI.info(""" |
| 123 | + Compaction results: |
| 124 | + Original: #{Util.format_number(original_length)} bytes |
| 125 | + Compacted: #{Util.format_number(new_length)} bytes |
| 126 | + Savings: #{percent}% (#{Util.format_number(difference)} bytes) |
| 127 | + """) |
| 128 | + |
| 129 | + cond do |
| 130 | + original_length < @min_length -> |
| 131 | + UI.debug("Compaction retries skipped", "Original is too small to justify retries") |
| 132 | + |
| 133 | + if new_length < original_length do |
| 134 | + summary |> AI.Util.system_msg() |> then(&{:ok, [&1]}) |
| 135 | + else |
| 136 | + UI.warn("Compaction failed", "Summary is larger than original; aborting") |
| 137 | + {:error, :compaction_failed} |
| 138 | + end |
| 139 | + |
| 140 | + new_length > original_length * @target_ratio and attempts < @max_attempts -> |
| 141 | + UI.warn( |
| 142 | + "Compaction insufficient", |
| 143 | + "Attempting another pass (#{attempts + 1}/#{@max_attempts})" |
| 144 | + ) |
| 145 | + |
| 146 | + get_response(%{messages: messages, attempts: attempts + 1}) |
| 147 | + |
| 148 | + true -> |
| 149 | + UI.debug("Compacted conversation", summary) |
| 150 | + |
| 151 | + if new_length < original_length do |
| 152 | + summary |> AI.Util.system_msg() |> then(&{:ok, [&1]}) |
| 153 | + else |
| 154 | + UI.warn("Compaction failed", "Summary is larger than original; aborting") |
| 155 | + {:error, :compaction_failed} |
| 156 | + end |
| 157 | + end |
| 158 | + end |
| 159 | + |
| 160 | + {:error, reason} -> |
| 161 | + {:error, reason} |
| 162 | + end |
| 163 | + end |
| 164 | + end |
| 165 | + |
| 166 | + defp transcript([], acc), do: Enum.reverse(acc) |
| 167 | + defp transcript([%{role: "system"} | rest], acc), do: transcript(rest, acc) |
| 168 | + defp transcript([%{role: "developer"} | rest], acc), do: transcript(rest, acc) |
| 169 | + defp transcript([%{role: "user"} | rest], acc), do: transcript(rest, acc) |
| 170 | + defp transcript([%{role: "tool", name: "notify_tool"} | rest], acc), do: transcript(rest, acc) |
| 171 | + |
| 172 | + defp transcript([%{role: "tool", name: name, content: content} | rest], acc) do |
| 173 | + transcript(rest, [%{role: "tool", name: name, content: content} | acc]) |
| 174 | + end |
| 175 | + |
| 176 | + defp transcript([%{role: "assistant", content: nil} | rest], acc), do: transcript(rest, acc) |
| 177 | + |
| 178 | + defp transcript([%{role: "assistant", content: content} = msg | rest], acc) |
| 179 | + when is_binary(content) do |
| 180 | + if String.starts_with?(content, "<think>") do |
| 181 | + # skip internal reasoning |
| 182 | + transcript(rest, acc) |
| 183 | + else |
| 184 | + # include assistant message |
| 185 | + transcript(rest, [msg | acc]) |
| 186 | + end |
| 187 | + end |
| 188 | + |
| 189 | + defp transcript([msg | rest], acc), do: transcript(rest, [msg | acc]) |
| 190 | + end |
Loading more files…