scout-ai 1.2.3 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (174) hide show
  1. checksums.yaml +4 -4
  2. data/.vimproject +138 -50
  3. data/README.md +171 -290
  4. data/Rakefile +17 -1
  5. data/VERSION +1 -1
  6. data/doc/Improvements.md +325 -0
  7. data/doc/StartHere.md +110 -0
  8. data/doc/developer/Architecture.md +126 -0
  9. data/doc/developer/Backends.md +199 -0
  10. data/doc/developer/ChatLifecycle.md +183 -0
  11. data/doc/developer/DelegationInternals.md +295 -0
  12. data/doc/developer/DesignPrinciples.md +245 -0
  13. data/doc/developer/PromptProcessing.md +292 -0
  14. data/doc/developer/Provenance.md +317 -0
  15. data/doc/user/BuildingAgents.md +345 -0
  16. data/doc/user/Cookbook.md +333 -0
  17. data/doc/user/CoreConcepts.md +181 -0
  18. data/doc/user/Delegation.md +191 -0
  19. data/doc/user/GettingStarted.md +159 -0
  20. data/doc/user/ManagingContext.md +163 -0
  21. data/doc/user/MultiAgentWorkflows.md +256 -0
  22. data/doc/user/Python.md +159 -0
  23. data/doc/user/RunningInference.md +200 -0
  24. data/doc/user/ToolCalling.md +193 -0
  25. data/doc/user/WritingChats.md +197 -0
  26. data/lib/scout/llm/agent/chat.rb +61 -11
  27. data/lib/scout/llm/agent/delegate.rb +274 -65
  28. data/lib/scout/llm/agent/iterate.rb +2 -2
  29. data/lib/scout/llm/agent/save.rb +273 -0
  30. data/lib/scout/llm/agent/workflow.rb +164 -0
  31. data/lib/scout/llm/agent.rb +86 -61
  32. data/lib/scout/llm/ask.rb +62 -17
  33. data/lib/scout/llm/backends/anthropic.rb +9 -2
  34. data/lib/scout/llm/backends/bedrock.rb +15 -3
  35. data/lib/scout/llm/backends/default.rb +183 -99
  36. data/lib/scout/llm/backends/glm.rb +58 -0
  37. data/lib/scout/llm/backends/huggingface.rb +196 -26
  38. data/lib/scout/llm/backends/ollama.rb +13 -1
  39. data/lib/scout/llm/backends/openai.rb +0 -2
  40. data/lib/scout/llm/backends/openwebui.rb +20 -13
  41. data/lib/scout/llm/backends/relay.rb +22 -22
  42. data/lib/scout/llm/backends/responses.rb +1 -1
  43. data/lib/scout/llm/chat/agent_meta.rb +264 -0
  44. data/lib/scout/llm/chat/annotation.rb +39 -10
  45. data/lib/scout/llm/chat/parse.rb +28 -6
  46. data/lib/scout/llm/chat/persist.rb +25 -0
  47. data/lib/scout/llm/chat/process/clear.rb +41 -6
  48. data/lib/scout/llm/chat/process/files.rb +21 -6
  49. data/lib/scout/llm/chat/process/meta.rb +421 -34
  50. data/lib/scout/llm/chat/process/options.rb +21 -1
  51. data/lib/scout/llm/chat/process/tools.rb +56 -15
  52. data/lib/scout/llm/chat/process.rb +4 -0
  53. data/lib/scout/llm/chat/prompt/shorten_tools.rb +125 -0
  54. data/lib/scout/llm/chat/prompt/shorten_tools_epoch.rb +365 -0
  55. data/lib/scout/llm/chat/prompt.rb +48 -0
  56. data/lib/scout/llm/chat/provenance.rb +775 -0
  57. data/lib/scout/llm/chat/tool_calls.rb +76 -0
  58. data/lib/scout/llm/chat.rb +18 -2
  59. data/lib/scout/llm/embed.rb +11 -3
  60. data/lib/scout/llm/image.rb +86 -0
  61. data/lib/scout/llm/mcp.rb +10 -2
  62. data/lib/scout/llm/rag.rb +3 -3
  63. data/lib/scout/llm/tools/call.rb +160 -11
  64. data/lib/scout/llm/tools/knowledge_base.rb +1 -1
  65. data/lib/scout/llm/tools/workflow.rb +32 -16
  66. data/lib/scout/model/python/huggingface/causal.rb +23 -5
  67. data/lib/scout/model/python/huggingface.rb +2 -1
  68. data/lib/scout-ai.rb +1 -0
  69. data/python/README.md +197 -14
  70. data/python/scout_ai/huggingface/eval.py +245 -34
  71. data/python/tests/test_huggingface_eval.py +58 -0
  72. data/research/ChatAnalyst-required-changes.md +167 -0
  73. data/research/agent-delegation-analysis.md +810 -0
  74. data/research/agent-meta-provenance-integration-plan.md +622 -0
  75. data/research/agent-workflow-analysis.md +1120 -0
  76. data/research/backends-analysis.md +836 -0
  77. data/research/chat-core-analysis.md +946 -0
  78. data/research/chatanalyst-provenance/00-baseline.md +30 -0
  79. data/research/chatanalyst-provenance/01-repo-map.md +60 -0
  80. data/research/chatanalyst-provenance/02-event-reconstruction.md +55 -0
  81. data/research/chatanalyst-provenance/03-duplication-evidence.md +45 -0
  82. data/research/chatanalyst-provenance/04-tooling-root-cause.md +57 -0
  83. data/research/chatanalyst-provenance/05-fix-plan.md +46 -0
  84. data/research/chatanalyst-provenance/07-critic-review.md +25 -0
  85. data/research/chatanalyst-provenance/final-report.md +45 -0
  86. data/research/chatanalyst-provenance/resumption.md +37 -0
  87. data/research/coding-philosophy-analysis.md +928 -0
  88. data/research/commands-analysis.md +947 -0
  89. data/research/multi-agent-patterns-analysis.md +853 -0
  90. data/research/prompt-strategies-analysis.md +630 -0
  91. data/research/prov-verbosity-fix-notes.md +77 -0
  92. data/research/provenance-analysis.md +469 -0
  93. data/research/provenance-navigation-design.md +640 -0
  94. data/research/synthesis-report.md +487 -0
  95. data/research/tools-system-analysis.md +779 -0
  96. data/scout-ai.gemspec +100 -11
  97. data/scout_commands/agent/ask +13 -3
  98. data/scout_commands/agent/kb +2 -0
  99. data/scout_commands/llm/ask +11 -4
  100. data/scout_commands/llm/md +76 -0
  101. data/scout_commands/llm/process_queries +48 -0
  102. data/scout_commands/llm/prov +602 -0
  103. data/scout_commands/llm/word +71 -0
  104. data/scout_commands/workflow/mcp +43 -0
  105. data/share/word/reference.docx +0 -0
  106. data/test/etc/AI/mock.yaml +11 -0
  107. data/test/fixtures/backends/anthropic.json +19 -0
  108. data/test/fixtures/backends/anthropic_tool_use.json +24 -0
  109. data/test/fixtures/backends/bedrock.json +8 -0
  110. data/test/fixtures/backends/bedrock_embedding.json +3 -0
  111. data/test/fixtures/backends/bedrock_tool_use.json +17 -0
  112. data/test/fixtures/backends/ollama.json +16 -0
  113. data/test/fixtures/backends/ollama_tool_call.json +27 -0
  114. data/test/fixtures/backends/openai_chat.json +21 -0
  115. data/test/fixtures/backends/openai_chat_tool_call.json +31 -0
  116. data/test/fixtures/backends/responses.json +33 -0
  117. data/test/fixtures/backends/responses_tool_call.json +28 -0
  118. data/test/integration/README.md +32 -0
  119. data/test/integration/scout/llm/backends/test_endpoints.rb +34 -0
  120. data/test/integration/scout/llm/backends/test_openwebui.rb +61 -0
  121. data/test/integration/scout/llm/backends/test_relay.rb +52 -0
  122. data/test/integration/scout/llm/test_infrastructure.rb +74 -0
  123. data/test/{scout → integration/scout}/llm/test_mcp.rb +1 -1
  124. data/test/integration/scout/llm/tools/test_mcp.rb +42 -0
  125. data/test/integration/scout/model/test_base.rb +91 -0
  126. data/test/scout/llm/agent/test_chat.rb +8 -2
  127. data/test/scout/llm/agent/test_save.rb +413 -0
  128. data/test/scout/llm/agent/test_workflow.rb +110 -0
  129. data/test/scout/llm/backends/test_anthropic.rb +93 -10
  130. data/test/scout/llm/backends/test_bedrock.rb +118 -2
  131. data/test/scout/llm/backends/test_huggingface.rb +137 -42
  132. data/test/scout/llm/backends/test_ollama.rb +70 -20
  133. data/test/scout/llm/backends/test_openwebui.rb +42 -40
  134. data/test/scout/llm/backends/test_relay.rb +4 -2
  135. data/test/scout/llm/chat/agent_meta_fixtures.rb +131 -0
  136. data/test/scout/llm/chat/process/test_meta.rb +518 -0
  137. data/test/scout/llm/chat/process/test_normalize_usage.rb +183 -0
  138. data/test/scout/llm/chat/test_agent_meta.rb +357 -0
  139. data/test/scout/llm/chat/test_agent_meta_provenance.rb +467 -0
  140. data/test/scout/llm/chat/test_agent_meta_tokens.rb +594 -0
  141. data/test/scout/llm/chat/test_parse.rb +70 -15
  142. data/test/scout/llm/chat/test_prov_cli.rb +274 -0
  143. data/test/scout/llm/chat/test_provenance.rb +240 -0
  144. data/test/scout/llm/chat/test_tool_calls.rb +38 -0
  145. data/test/scout/llm/test_agent.rb +13 -36
  146. data/test/scout/llm/test_ask.rb +75 -52
  147. data/test/scout/llm/test_chat.rb +107 -13
  148. data/test/scout/llm/test_embed.rb +48 -0
  149. data/test/scout/llm/test_rag.rb +23 -16
  150. data/test/scout/llm/test_tools.rb +12 -1
  151. data/test/scout/llm/tools/test_knowledge_base.rb +0 -1
  152. data/test/scout/llm/tools/test_mcp.rb +5 -3
  153. data/test/scout/llm/tools/test_workflow.rb +23 -2
  154. data/test/scout/model/python/huggingface/causal/test_next_token.rb +11 -5
  155. data/test/scout/model/python/huggingface/test_causal.rb +9 -3
  156. data/test/scout/model/python/huggingface/test_classification.rb +11 -2
  157. data/test/scout/model/python/test_torch.rb +2 -0
  158. data/test/scout/model/python/torch/test_helpers.rb +4 -0
  159. data/test/scout/model/test_base.rb +4 -2
  160. data/test/support/availability.rb +231 -0
  161. data/test/support/fake_clients.rb +138 -0
  162. data/test/support/fixtures.rb +21 -0
  163. data/test/support/infrastructure_probes.rb +136 -0
  164. data/test/support/mock_backend.rb +215 -0
  165. data/test/test_helper.rb +32 -2
  166. metadata +99 -10
  167. data/doc/Agent.md +0 -327
  168. data/doc/Chat.md +0 -458
  169. data/doc/LLM.md +0 -340
  170. data/doc/RAG.md +0 -129
  171. data/scout_commands/documenter +0 -148
  172. data/test/scout/llm/backends/test_openai.rb +0 -192
  173. data/test/scout/llm/backends/test_responses.rb +0 -238
  174. data/test/scout/llm/test_parse.rb +0 -98
@@ -0,0 +1,125 @@
1
+ module Chat
2
+
3
+ # --- shorten_tools strategy configuration ---
4
+
5
+ DEFAULT_FULL_TOOL_CALLS = 0
6
+ DEFAULT_FULL_TOOL_OUTPUTS = 10
7
+ DEFAULT_MAX_TOOL_CALLS = 40
8
+ DEFAULT_MAX_TOOL_OUTPUTS = DEFAULT_MAX_TOOL_CALLS
9
+ DEFAULT_MAX_TOOL_CHARS = 100_000
10
+
11
+ def self.full_tool_calls
12
+ @@full_tool_calls ||= Scout::Config.get(:full_tool_calls, :prompt, :context, env: 'FULL_TOOL_CALLS')
13
+ end
14
+
15
+ def self.full_tool_outputs
16
+ @@full_tool_outputs ||= Scout::Config.get(:full_tool_outputs, :prompt, :context, env: 'FULL_TOOL_OUTPUTS')
17
+ end
18
+
19
+ def self.max_tool_calls
20
+ @@max_tool_calls ||= Scout::Config.get(:max_tool_calls, :prompt, :context, env: 'MAX_TOOL_CALLS')
21
+ end
22
+
23
+ def self.max_tool_outputs
24
+ @@max_tool_outputs ||= Scout::Config.get(:max_tool_outputs, :prompt, :context, env: 'MAX_TOOL_OUTPUTS', default: max_tool_calls)
25
+ end
26
+
27
+ def self.max_tool_chars
28
+ @@max_tool_chars ||= Scout::Config.get(:max_tool_chars, :prompt, :context, env: 'MAX_TOOL_CHARS')
29
+ end
30
+
31
+ # --- shorten_tools strategy implementation ---
32
+
33
+ #
34
+ # Walks the message array in reverse (newest-first) and applies a tiered
35
+ # truncation/dropping policy to +function_call+ and +function_call_output+
36
+ # messages. The most recent tool interactions get priority for full
37
+ # retention; older ones are progressively degraded.
38
+ #
39
+ # This is the **default strategy**.
40
+ #
41
+ def self.shorten_tools(messages)
42
+ tool_ids = []
43
+ tool_chars = 0
44
+ user_messages = 1
45
+ assistant_messages = 0
46
+
47
+ full_tool_calls = self.full_tool_calls || DEFAULT_FULL_TOOL_CALLS
48
+ full_tool_outputs = self.full_tool_outputs || DEFAULT_FULL_TOOL_OUTPUTS
49
+ max_tool_calls = self.max_tool_calls || DEFAULT_MAX_TOOL_CALLS
50
+ max_tool_outputs = self.max_tool_outputs || DEFAULT_MAX_TOOL_OUTPUTS
51
+ max_tool_chars = self.max_tool_chars || DEFAULT_MAX_TOOL_CHARS
52
+
53
+ full_tool_calls = full_tool_calls.to_i
54
+ full_tool_outputs = full_tool_outputs.to_i
55
+ max_tool_calls = max_tool_calls.to_i
56
+ max_tool_outputs = max_tool_outputs.to_i
57
+ max_tool_chars = max_tool_chars.to_i
58
+
59
+ messages.reverse.collect do |msg|
60
+ case msg[:role].to_sym
61
+ when :function_call
62
+ json = msg[:content]
63
+ next msg unless json
64
+
65
+ tool_call = JSON.parse json
66
+ name, arguments, id = tool_call.values_at 'name', 'arguments', 'id'
67
+
68
+ if tool_ids.length < full_tool_calls || user_messages == 0 || tool_chars < max_tool_chars
69
+ tool_chars += json.length
70
+ msg
71
+ elsif tool_ids.length > max_tool_calls
72
+ Log.medium "Skipped tool call #{id} #{name} #{json.length}"
73
+ next
74
+ else
75
+ new_arguments = {}
76
+ arguments.each do |k,v|
77
+ new_arguments[k] = String === v ? shorten_string(v) : v
78
+ end if arguments
79
+
80
+ next msg if arguments.values == new_arguments.values
81
+
82
+ tool_call['arguments'] = new_arguments
83
+ json = tool_call.to_json
84
+ tool_chars += json.length
85
+ Log.medium "Truncated tool call #{id} #{name} #{msg[:content].length} to #{json.length}"
86
+ msg = msg.dup
87
+ msg[:content] = json
88
+ msg
89
+ end
90
+ when :function_call_output
91
+ json = msg[:content]
92
+ next msg unless json
93
+
94
+ tool_call = JSON.parse json
95
+ name, content, id = tool_call.values_at 'name', 'content', 'id'
96
+ tool_ids << id
97
+
98
+ if tool_ids.length < full_tool_outputs || user_messages == 0 || tool_chars < max_tool_chars
99
+ tool_chars += json.length
100
+ msg
101
+ elsif tool_ids.length > max_tool_outputs
102
+ Log.medium "Skipped tool output #{id} #{name} #{json.length}"
103
+ next
104
+ else
105
+ tool_call['content'] = shorten_string(content, DEFAULT_SHORT_STRING_LENGTH*2)
106
+ next msg if content == tool_call['content']
107
+ json = tool_call.to_json
108
+ tool_chars += json.length
109
+ Log.medium "Truncated tool output #{id} #{name} #{msg[:content].length} to #{json.length}"
110
+ msg = msg.dup
111
+ msg[:content] = json
112
+ msg
113
+ end
114
+ when :user
115
+ user_messages += 1
116
+ msg
117
+ when :assistant
118
+ assistant_messages += 1
119
+ msg
120
+ else
121
+ msg
122
+ end
123
+ end.compact.reverse
124
+ end
125
+ end
@@ -0,0 +1,365 @@
1
+ require 'set'
2
+
3
+ module Chat
4
+
5
+ # --- shorten_tools_epoch strategy configuration ---
6
+
7
+ # Total tool-call count at or below which no compaction happens.
8
+ DEFAULT_EPOCH_TOOL_CALL_THRESHOLD = 50
9
+
10
+ # Number of most-recent tool calls to keep at full fidelity.
11
+ DEFAULT_EPOCH_FULL_TOOL_CALLS = 20
12
+
13
+ # Number of tool calls (before the full window) to compact (truncate).
14
+ DEFAULT_EPOCH_COMPACTED_TOOL_CALLS = 80
15
+
16
+ # How many new tool calls are allowed before the compaction boundary
17
+ # advances. Within a single epoch window the compacted prefix is frozen,
18
+ # maximising KV-cache / prompt-cache hits for consecutive inferences.
19
+ DEFAULT_EPOCH_SIZE = 20
20
+
21
+ # --- shorten_tools_epoch strategy configuration accessors ---
22
+
23
+ def self.epoch_tool_call_threshold
24
+ @@epoch_tool_call_threshold ||= Scout::Config.get(:epoch_tool_call_threshold, :prompt, :context,
25
+ env: 'EPOCH_TOOL_CALL_THRESHOLD', default: DEFAULT_EPOCH_TOOL_CALL_THRESHOLD)
26
+ end
27
+
28
+ def self.epoch_full_tool_calls
29
+ @@epoch_full_tool_calls ||= Scout::Config.get(:epoch_full_tool_calls, :prompt, :context,
30
+ env: 'EPOCH_FULL_TOOL_CALLS', default: DEFAULT_EPOCH_FULL_TOOL_CALLS)
31
+ end
32
+
33
+ def self.epoch_compacted_tool_calls
34
+ @@epoch_compacted_tool_calls ||= Scout::Config.get(:epoch_compacted_tool_calls, :prompt, :context,
35
+ env: 'EPOCH_COMPACTED_TOOL_CALLS', default: DEFAULT_EPOCH_COMPACTED_TOOL_CALLS)
36
+ end
37
+
38
+ def self.epoch_size
39
+ @@epoch_size ||= Scout::Config.get(:epoch_size, :prompt, :context,
40
+ env: 'EPOCH_SIZE', default: DEFAULT_EPOCH_SIZE)
41
+ end
42
+
43
+ # --- deduplication helpers (repeated-call protection) ---
44
+
45
+ # Recursively normalise a value so that hashes with differently-ordered or
46
+ # symbol/string keys produce the same canonical form.
47
+ def self.epoch_sort_value(obj)
48
+ case obj
49
+ when Hash
50
+ obj.transform_keys(&:to_s).sort.to_h.transform_values { |v| epoch_sort_value(v) }
51
+ when Array
52
+ obj.map { |v| epoch_sort_value(v) }
53
+ else
54
+ obj
55
+ end
56
+ end
57
+
58
+ # Build a canonical deduplication key from a tool call's name + arguments.
59
+ # The model-generated +id+ is deliberately excluded because it is not
60
+ # stable across inferences.
61
+ def self.epoch_dedup_key(name, arguments)
62
+ normalized = epoch_sort_value(arguments || {})
63
+ "#{name}\x00#{JSON.generate(normalized)}"
64
+ rescue
65
+ "#{name}\x00#{arguments.inspect}"
66
+ end
67
+
68
+ # --- shorten_tools_epoch strategy implementation ---
69
+
70
+ #
71
+ # Cache-friendly variant of +shorten_tools+. Instead of recomputing the
72
+ # compactation boundary on every single inference (which constantly shifts the
73
+ # prefix and defeats KV-cache / prompt-cache), this strategy divides the
74
+ # conversation into *epochs*.
75
+ #
76
+ # Within an epoch window of +epoch_size+ tool calls the compaction boundary
77
+ # is frozen. This means that the compacted prefix (the messages up to and
78
+ # including the compacted region) is byte-for-byte identical for every
79
+ # inference inside that window.
80
+ #
81
+ # == Layout (newest at the bottom)
82
+ #
83
+ # [ dropped ] tool calls older than (compacted + full) → removed
84
+ # [ compacted ] up to +epoch_compacted_tool_calls+ tool calls, compacted
85
+ # [ full-recent ] +epoch_full_tool_calls+ tool calls at full fidelity
86
+ # [ full-new ] any tool calls that arrived after the epoch boundary
87
+ #
88
+ # The +compacted+ and +full-recent+ regions are pinned relative to the epoch
89
+ # boundary, not the live tool-call count, so their content stays stable
90
+ # until the boundary advances.
91
+ #
92
+ # == Epoch boundary calculation
93
+ #
94
+ # overflow = total_tool_calls - threshold # how many beyond threshold
95
+ # epoch_idx = overflow > 0 ? (overflow - 1) / epoch_size : 0
96
+ # pinned_total = threshold + (epoch_idx * epoch_size)
97
+ #
98
+ # The pinned_total determines where the +full-recent+ region starts. Any
99
+ # tool calls beyond pinned_total are treated as "new" and kept at full
100
+ # fidelity (they are the live portion of the prompt that changes each turn).
101
+ #
102
+ # == Example (threshold=100, full=10, compacted=40, epoch_size=10)
103
+ #
104
+ # 100 calls → compaction starts: keep 10 full, compact 40, drop 50
105
+ # 101 calls → pinned_total = 100, keep 10 full-recent + 1 full-new
106
+ # 105 calls → same pinned_total = 100, keep 10 full-recent + 5 full-new
107
+ # 110 calls → pinned_total = 100, keep 10 full-recent + 10 full-new
108
+ # 111 calls → pinned_total = 110, keep 10 full-recent + 1 full-new
109
+ #
110
+ # == Repeated-call protection
111
+ #
112
+ # When the agent loses visibility of a previous tool call (because it was
113
+ # dropped or heavily compacted) it may re-issue the identical call,
114
+ # creating infinite retry loops. To prevent this the strategy detects
115
+ # repeated calls — matched by (name, arguments) excluding the unstable
116
+ # model-generated +id+ — and ensures the *most recent* instance of every
117
+ # repeated call is never dropped: it is compacted instead. Older duplicate
118
+ # instances are dropped or compacted normally.
119
+ #
120
+ def self.shorten_tools_epoch(messages)
121
+ threshold = (self.epoch_tool_call_threshold || DEFAULT_EPOCH_TOOL_CALL_THRESHOLD).to_i
122
+ full = (self.epoch_full_tool_calls || DEFAULT_EPOCH_FULL_TOOL_CALLS).to_i
123
+ compacted = (self.epoch_compacted_tool_calls || DEFAULT_EPOCH_COMPACTED_TOOL_CALLS).to_i
124
+ epoch_sz = (self.epoch_size || DEFAULT_EPOCH_SIZE).to_i
125
+
126
+ # ---- count tool outputs in the message array ----
127
+ total_tool_outputs = messages.count { |m| m[:role].to_sym == :function_call_output }
128
+
129
+ return messages if total_tool_outputs <= threshold
130
+ return messages if full == 0 && compacted == 0
131
+ return messages if epoch_sz <= 0
132
+
133
+ # ---- compute the pinned epoch boundary ----
134
+ overflow = total_tool_outputs - threshold
135
+ epoch_idx = overflow > 0 ? (overflow - 1) / epoch_sz : 0
136
+ pinned_total = threshold + (epoch_idx * epoch_sz)
137
+
138
+ # Number of tool calls that are "new" (arrived after the epoch boundary)
139
+ new_calls = total_tool_outputs - pinned_total
140
+
141
+ # From the end, the regions are:
142
+ # [1 .. new_calls] → full-new (keep unchanged)
143
+ # [new_calls+1 .. new_calls+full] → full-recent (keep unchanged)
144
+ # [new_calls+full+1 .. new_calls+full+compacted] → compacted
145
+ # everything older → dropped
146
+
147
+ keep_full_count = new_calls + full
148
+ truncate_to = keep_full_count + compacted
149
+
150
+ # ---- detect repeated tool calls to protect from dropping ----
151
+ # Walk forward to identify the most-recent instance of each repeated
152
+ # (name, arguments) pair. Those reverse positions are added to
153
+ # +protected_positions+ so that the main reverse walk compacts them
154
+ # instead of dropping, preventing the agent from re-issuing a call it
155
+ # no longer remembers.
156
+ protected_positions = build_protected_positions(messages, total_tool_outputs)
157
+
158
+ # tool_ids counter mirrors the original shorten_tools: incremented BEFORE
159
+ # the check for function_call_output, so that a function_call and its
160
+ # paired output see the same counter value.
161
+ tool_ids = []
162
+
163
+ kept_messages = []
164
+ dropped_count = 0
165
+ compacted_count = 0
166
+
167
+ # Walk in reverse so we can apply the position-based policy.
168
+ messages.reverse.each do |msg|
169
+ case msg[:role].to_sym
170
+ when :function_call_output
171
+ json = msg[:content]
172
+ if json.nil?
173
+ kept_messages << msg
174
+ next
175
+ end
176
+
177
+ tool_call = JSON.parse json rescue nil
178
+ unless tool_call
179
+ kept_messages << msg
180
+ next
181
+ end
182
+
183
+ name, content, id, step = tool_call.values_at 'name', 'content', 'id', 'step'
184
+ tool_ids << id # increment BEFORE check (mirrors original shorten_tools)
185
+
186
+ if tool_ids.length <= keep_full_count || protected_positions.include?(tool_ids.length)
187
+ # full-new or full-recent (or protected repeated call) → keep unchanged
188
+ kept_messages << msg
189
+ elsif tool_ids.length <= truncate_to
190
+ # compacted region → truncate the content
191
+ new_content = shorten_string(content.to_s, DEFAULT_SHORT_STRING_LENGTH * 2, step: step)
192
+ if new_content != content
193
+ tool_call['content'] = new_content
194
+ new_json = tool_call.to_json
195
+ Log.low "Epoch: truncated tool output #{id} #{name} #{json.length} to #{new_json.length}"
196
+ new_msg = msg.dup
197
+ new_msg[:content] = new_json
198
+ new_msg[:compacted] = true
199
+ kept_messages << new_msg
200
+ compacted_count += 1
201
+ else
202
+ kept_messages << msg
203
+ end
204
+ else
205
+ # beyond compacted → drop
206
+ Log.low "Epoch: dropped tool output #{id} #{name} #{json.length}"
207
+ dropped_count += 1
208
+ end
209
+
210
+ when :function_call
211
+ json = msg[:content]
212
+ if json.nil?
213
+ kept_messages << msg
214
+ next
215
+ end
216
+
217
+ tool_call = JSON.parse json rescue nil
218
+ unless tool_call
219
+ kept_messages << msg
220
+ next
221
+ end
222
+
223
+ name, arguments, id = tool_call.values_at 'name', 'arguments', 'id'
224
+
225
+ # tool_ids already includes the paired output (processed before us
226
+ # in reverse), so we use the same boundary check.
227
+ if tool_ids.length <= keep_full_count || protected_positions.include?(tool_ids.length)
228
+ # full (or protected repeated call) → keep unchanged
229
+ kept_messages << msg
230
+ elsif tool_ids.length <= truncate_to
231
+ # compacted → truncate arguments
232
+ if arguments && arguments.any?
233
+ new_arguments = {}
234
+ arguments.each do |k, v|
235
+ new_arguments[k] = String === v ? shorten_string(v) : v
236
+ end
237
+ if arguments.values != new_arguments.values
238
+ tool_call['arguments'] = new_arguments
239
+ new_json = tool_call.to_json
240
+ Log.low "Epoch: truncated tool call #{id} #{name} #{json.length} to #{new_json.length}"
241
+ new_msg = msg.dup
242
+ new_msg[:content] = new_json
243
+ new_msg[:compacted] = true
244
+ kept_messages << new_msg
245
+ compacted_count += 1
246
+ else
247
+ kept_messages << msg
248
+ end
249
+ else
250
+ kept_messages << msg
251
+ end
252
+ else
253
+ # beyond compacted → drop
254
+ Log.low "Epoch: dropped tool call #{id} #{name} #{json.length}"
255
+ dropped_count += 1
256
+ end
257
+ else
258
+ # assistant, system, meta, etc. → always keep
259
+ kept_messages << msg
260
+ end
261
+ end
262
+
263
+ kept_messages = kept_messages.reverse
264
+
265
+ if dropped_count > 0 || compacted_count > 0
266
+ Log.medium "Epoch strategy: pinned_total=#{pinned_total} new_calls=#{new_calls} " \
267
+ "full=#{full} compacted=#{compacted} truncated=#{compacted_count} dropped=#{dropped_count} " \
268
+ "protected=#{protected_positions.length}"
269
+
270
+ compaction_message = {
271
+ role: :user,
272
+ content: <<~TEXT.chomp
273
+ === Context Management ===
274
+
275
+ To fit within the model context window, this conversation has been
276
+ compacted: some tool calls arguments and tool call outputs are shown with
277
+ compacted content, and some have been hidden entirely. Compacted content
278
+ is show as '[CONTEXT-COMPACTED ...]'. Don't try to reconstruct what
279
+ happened involving tools that have been compacted.
280
+
281
+ Remember that the real tool call did not suffer any compactation at the
282
+ time it was issued, it's only compacted in the current context.
283
+
284
+ Compacted: #{compacted_count}
285
+ Removed: #{dropped_count}
286
+
287
+ Earlier tool results may no longer be present in the visible conversation
288
+ history. If information appears to be missing, it may have been removed
289
+ during context compaction rather than never existing. The absence of an
290
+ earlier tool result in the current conversation does not necessarily mean
291
+ that the tool has not already been executed. And care must be had accessing
292
+ older tool calls which may be compacted.
293
+
294
+ Repeated tool calls with the same arguments will be flagged and the very
295
+ last instance will be protected from removal or compactation.
296
+ TEXT
297
+ }
298
+
299
+ index = kept_messages.index do |msg|
300
+ [:function_call, :function_call_output].include?(msg[:role].to_sym)
301
+ end
302
+
303
+ if index
304
+ kept_messages.insert(index + 1, compaction_message)
305
+ else
306
+ kept_messages << compaction_message
307
+ end
308
+ end
309
+
310
+ Chat.setup(kept_messages)
311
+ end
312
+
313
+ # --- repeated-call detection (forward pre-pass) ---
314
+
315
+ #
316
+ # Walk the message array forward and build a +Set+ of reverse positions
317
+ # (1-based from the end, matching +tool_ids.length+ in the main reverse
318
+ # walk) for the most-recent instance of every repeated (name, arguments)
319
+ # pair. Positions in this set should never be dropped.
320
+ #
321
+ def self.build_protected_positions(messages, total_tool_outputs)
322
+ protected = Set.new
323
+
324
+ fwd_pos = 0
325
+ pending_call_keys = {} # id → dedup_key (set by function_call, consumed by output)
326
+ key_positions = Hash.new { |h, k| h[k] = [] }
327
+
328
+ messages.each do |msg|
329
+ case msg[:role].to_sym
330
+ when :function_call
331
+ json = msg[:content]
332
+ next unless json
333
+ tool_call = JSON.parse json rescue nil
334
+ next unless tool_call
335
+ name = tool_call['name']
336
+ arguments = tool_call['arguments']
337
+ id = tool_call['id']
338
+ pending_call_keys[id] = Chat.epoch_dedup_key(name, arguments)
339
+ when :function_call_output
340
+ json = msg[:content]
341
+ next unless json
342
+ tool_call = JSON.parse json rescue nil
343
+ next unless tool_call
344
+ fwd_pos += 1
345
+ id = tool_call['id']
346
+ key = pending_call_keys.delete(id)
347
+ next unless key
348
+ key_positions[key] << fwd_pos
349
+ end
350
+ end
351
+
352
+ key_positions.each do |key, positions|
353
+ next if positions.length <= 1
354
+ most_recent_fwd = positions.max
355
+ reverse_pos = total_tool_outputs - most_recent_fwd + 1
356
+ protected << reverse_pos
357
+ end
358
+
359
+ unless protected.empty?
360
+ Log.low "Epoch: protecting #{protected.length} repeated call(s) from dropping"
361
+ end
362
+
363
+ protected
364
+ end
365
+ end
@@ -0,0 +1,48 @@
1
+ require_relative 'prompt/shorten_tools'
2
+ require_relative 'prompt/shorten_tools_epoch'
3
+
4
+ module Chat
5
+
6
+ REGISTERED_STRATEGIES = {}
7
+ DEFAULT_CONTEXT_STRATEGY = %w(shorten_tools_epoch)
8
+ DEFAULT_SHORT_STRING_LENGTH = 200
9
+ DEFAULT_SHORT_JSON_LENGTH = 2000
10
+
11
+ # --- Shared utility used by all strategies ---
12
+
13
+ def self.shorten_string(string, size = DEFAULT_SHORT_STRING_LENGTH, step: nil)
14
+ new = Log.truncate_string(string, size)
15
+ if new.length < string.length
16
+ new = ['CONTEXT-COMPACTED', 'Historical account compacted for context efficiency; it does not faithfully represent what happened', "Original content length: #{string.length}"]
17
+ if step
18
+ new << "Full output found in job: #{step}"
19
+ end
20
+ new << 'Do not execute, copy or otherwise use this string as-is.'
21
+ new << "Preview: <<#{new}>>"
22
+ new = "[#{new * ' - '}]"
23
+ end
24
+ new
25
+ end
26
+
27
+ # --- Prompt strategy dispatcher ---
28
+
29
+ def self.prepare_prompt(prompt, prompt_strategies = nil)
30
+ return prompt_strategies.call(prompt) if Proc === prompt_strategies
31
+ prompt_strategies = DEFAULT_CONTEXT_STRATEGY if prompt_strategies.nil?
32
+ prompt_strategies = prompt_strategies.split(',') if String === prompt_strategies
33
+ prompt_strategies.each do |strategy|
34
+ prompt = case strategy
35
+ when 'shorten_tools'
36
+ Chat.shorten_tools(prompt)
37
+ when 'shorten_tools_epoch'
38
+ Chat.shorten_tools_epoch(prompt)
39
+ when 'none'
40
+ prompt
41
+ else
42
+ strategy_proc = REGISTERED_STRATEGIES[strategy]
43
+ strategy_proc.call(prompt)
44
+ end
45
+ end
46
+ return prompt
47
+ end
48
+ end