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,630 @@
1
+ > **Disclaimer:** This is an architectural investigation, not normative
2
+ > documentation. It was produced during a documentation-revamp effort and may
3
+ > be outdated relative to the current codebase. Treat it as supporting
4
+ > reference material. For maintained documentation, see
5
+ > [../../doc/](../../doc/).
6
+ >
7
+
8
+
9
+ # Prompt Strategies System
10
+
11
+ > Source file: `lib/scout/llm/chat/prompt.rb` (143 lines)
12
+ > Analysis date: 2025-07-30
13
+
14
+ ---
15
+
16
+ ## Overview
17
+
18
+ ### What prompt strategies are and why they exist
19
+
20
+ The **prompt strategies system** is a pre-inference transformation layer that
21
+ modifies the chat message list *just before* it is sent to the LLM backend.
22
+ Its primary motivation is **context-window management**: in long agent
23
+ conversations with many tool calls, the accumulated tool-call arguments and
24
+ tool-return values can consume enormous amounts of tokens. Without some form
25
+ of summarization or truncation, the prompt rapidly exceeds the model's context
26
+ limit or becomes prohibitively expensive.
27
+
28
+ The system works by applying one or more named "strategies" to the message
29
+ array. Each strategy is a function that takes an array of message hashes and
30
+ returns a (possibly shorter or modified) array of message hashes.
31
+
32
+ ### The ephemeral (non-persisted) nature
33
+
34
+ **Critically, prompt strategies operate on a copy of the messages — they never
35
+ mutate the stored chat.** The transformation happens entirely inside the
36
+ backend's `ask` method:
37
+
38
+ ```ruby
39
+ # lib/scout/llm/backends/default.rb, line 494
40
+ prompt = Chat.prepare_prompt(messages, prompt_strategies)
41
+ formatted_prompt = format_messages(prompt)
42
+ ```
43
+
44
+ The variable `prompt` is a *local* derived from `messages` (which itself comes
45
+ from `self.messages(question, options)`). The original `messages` and the
46
+ underlying `Chat` object are untouched. Whatever truncation, shortening, or
47
+ dropping of tool-call/output content occurs is visible **only to the LLM API
48
+ call** — it is never written back to the chat log, the job, or any persisted
49
+ artifact. This means:
50
+
51
+ - The chat history retains full-fidelity tool outputs for later inspection.
52
+ - The agent's "memory" (in persisted chats) is not degraded by truncation.
53
+ - Only the *next inference* sees the shortened prompt.
54
+
55
+ This is a deliberate design choice: it decouples *what the model sees* from
56
+ *what the system remembers*.
57
+
58
+ ---
59
+
60
+ ## The `prepare_prompt` Entry Point
61
+
62
+ ### Method signature
63
+
64
+ ```ruby
65
+ def self.prepare_prompt(prompt, prompt_strategies = nil)
66
+ ```
67
+
68
+ | Parameter | Type | Description |
69
+ |---------------------|-------------------------------------|------------------------------------------|
70
+ | `prompt` | `Array<Hash>` | Array of message hashes (the chat) |
71
+ | `prompt_strategies` | `nil`, `String`, `Array<String>`, or `Proc` | Which strategy/strategies to apply |
72
+
73
+ ### How it selects and applies strategies
74
+
75
+ The method supports four input forms for `prompt_strategies`:
76
+
77
+ ```ruby
78
+ def self.prepare_prompt(prompt, prompt_strategies = nil)
79
+ # 1. If a Proc is given, call it directly with the prompt — full custom hook
80
+ return prompt_strategies.call(prompt) if Proc === prompt_strategies
81
+
82
+ # 2. If nil, fall back to the default strategy list
83
+ prompt_strategies = DEFAULT_CONTEXT_STRATEGY if prompt_strategies.nil?
84
+ # → DEFAULT_CONTEXT_STRATEGY = %w(shorten_tools)
85
+
86
+ # 3. If a comma-separated String, split into an array
87
+ prompt_strategies = prompt_strategies.split(',') if String === prompt_strategies
88
+
89
+ # 4. Apply each named strategy in sequence
90
+ prompt_strategies.each do |strategy|
91
+ prompt = case strategy
92
+ when 'shorten_tools'
93
+ Chat.shorten_tools(prompt)
94
+ when 'none'
95
+ prompt # no-op / opt-out
96
+ else
97
+ strategy_proc = REGISTERED_STRATEGIES[strategy]
98
+ strategy_proc.call(prompt)
99
+ end
100
+ end
101
+ return prompt
102
+ end
103
+ ```
104
+
105
+ **Key behaviors:**
106
+
107
+ 1. **Proc bypass:** If `prompt_strategies` is a `Proc`, it is called directly
108
+ with the full prompt array and its return value is used as-is. This is an
109
+ escape hatch for fully custom, programmatic transformations that don't fit
110
+ the named-strategy model.
111
+
112
+ 2. **Default:** When `prompt_strategies` is `nil` (the common case, since the
113
+ backend passes the value from `options` which defaults to `nil`), the
114
+ constant `DEFAULT_CONTEXT_STRATEGY = %w(shorten_tools)` is used. This means
115
+ **`shorten_tools` is always active unless explicitly disabled**.
116
+
117
+ 3. **String shorthand:** A comma-separated string like `"shorten_tools,other"`
118
+ is split into individual strategy names.
119
+
120
+ 4. **Chaining:** Multiple strategies are applied **in sequence** — each
121
+ strategy receives the output of the previous one.
122
+
123
+ 5. **Opt-out with `'none'`:** The string `"none"` is a recognized no-op that
124
+ returns the prompt unchanged. To completely disable all strategies, pass
125
+ `prompt_strategies: 'none'` (or `["none"]`).
126
+
127
+ ### Strategy registration mechanism
128
+
129
+ Two mechanisms coexist:
130
+
131
+ | Mechanism | Where | Purpose |
132
+ |------------------------------|-----------------------------------------|-------------------------------------------------------------|
133
+ | Hard-coded `case` dispatch | Inside `prepare_prompt` | Built-in strategies (`shorten_tools`, `none`) |
134
+ | `REGISTERED_STRATEGIES` hash | Referenced at line 137 | Extension point for user/plugin-registered strategies |
135
+
136
+ The `REGISTERED_STRATEGIES` constant is **referenced but not defined in this
137
+ file**. A grep across the entire codebase finds no definition:
138
+
139
+ ```
140
+ $ grep -rn "REGISTERED_STRATEGIES" lib/
141
+ lib/scout/llm/chat/prompt.rb:137: strategy_proc = REGISTERED_STRATEGIES[strategy]
142
+ ```
143
+
144
+ This means `REGISTERED_STRATEGIES` is either:
145
+ - Defined in another file that `require`s or reopens the `Chat` module (not
146
+ found in the current codebase), or
147
+ - A forward-looking extension point that is **not yet wired up** — if an
148
+ unknown strategy name is passed and `REGISTERED_STRATEGIES` is undefined, it
149
+ will raise a `NameError`.
150
+
151
+ In practice, only `shorten_tools` and `none` are usable today.
152
+
153
+ ---
154
+
155
+ ## Each Strategy
156
+
157
+ ### `shorten_tools`
158
+
159
+ **Name:** `'shorten_tools'`
160
+
161
+ **What it does:** Walks the message array **in reverse** (newest-first) and
162
+ applies a tiered truncation/dropping policy to `function_call` and
163
+ `function_call_output` messages. The goal is to keep the most recent tool
164
+ interactions at full fidelity while progressively shortening or removing older
165
+ ones.
166
+
167
+ **Algorithm (reverse traversal):**
168
+
169
+ The method iterates messages from the end (most recent) to the beginning
170
+ (oldest), tracking three counters:
171
+
172
+ | Counter | Initial | Increment |
173
+ |--------------------|---------|----------------------------------------------------------|
174
+ | `tool_ids` | `[]` | One entry per `function_call_output` encountered |
175
+ | `tool_chars` | `0` | Cumulative characters of *retained* tool message content |
176
+ | `user_messages` | `1` | +1 for each `:user` message |
177
+
178
+ For each message, depending on its role:
179
+
180
+ #### `:function_call` (tool invocation request)
181
+
182
+ 1. If `tool_ids.length < full_tool_calls` **OR** `user_messages == 0` **OR**
183
+ `tool_chars < max_tool_chars` → **keep unchanged** (add to `tool_chars`).
184
+ 2. Else if `tool_ids.length > max_tool_calls` → **drop entirely** (`next`
185
+ returns `nil`, later `compact`ed out).
186
+ 3. Else → **truncate string arguments**: iterate over the `arguments` hash;
187
+ for each string value, replace with `shorten_string(v)`. If nothing
188
+ actually changed, keep original. Otherwise, rebuild JSON and update
189
+ `tool_chars`.
190
+
191
+ #### `:function_call_output` (tool return value)
192
+
193
+ 1. Append `id` to `tool_ids`.
194
+ 2. If `tool_ids.length < full_tool_outputs` **OR** `user_messages == 0` **OR**
195
+ `tool_chars < max_tool_chars` → **keep unchanged** (add to `tool_chars`).
196
+ 3. Else if `tool_ids.length > max_tool_outputs` → **drop entirely**.
197
+ 4. Else → **truncate content**: call
198
+ `shorten_string(content, DEFAULT_SHORT_STRING_LENGTH * 2)` (i.e., target
199
+ length 400). If unchanged, keep original; otherwise rebuild JSON.
200
+
201
+ #### `:user`, `:assistant`, other → **pass through unchanged**
202
+
203
+ (`user_messages` is incremented for `:user` messages only.)
204
+
205
+ After the reverse pass, the result is `compact`ed (removing `nil`s from
206
+ dropped messages) and then `reverse`d back to chronological order.
207
+
208
+ **When it activates:** It is the **default strategy** — it runs on every
209
+ backend `ask` call unless `prompt_strategies` is set to `'none'`, a custom
210
+ `Proc`, or an empty array.
211
+
212
+ **The `shorten_string` helper:**
213
+
214
+ ```ruby
215
+ DEFAULT_SHORT_STRING_LENGTH = 200
216
+
217
+ def self.shorten_string(string, size = DEFAULT_SHORT_STRING_LENGTH, warning = 'Truncated')
218
+ new = Log.truncate_string(string, DEFAULT_SHORT_STRING_LENGTH)
219
+ if new.length < string.length
220
+ new = "#{warning} (#{string.length}): " + new
221
+ end
222
+ new
223
+ end
224
+ ```
225
+
226
+ > **Note:** The `size` parameter is accepted but **not actually used** — the
227
+ > method always passes `DEFAULT_SHORT_STRING_LENGTH` (200) to
228
+ > `Log.truncate_string`. This appears to be a minor bug or oversight; the
229
+ > `size` argument is effectively dead code.
230
+
231
+ `Log.truncate_string` (from `scout-essentials`) replaces the middle of the
232
+ string with an MD5-based marker:
233
+
234
+ ```ruby
235
+ FP_MAX_STRING = 150
236
+
237
+ def self.truncate_string(string, max = FP_MAX_STRING)
238
+ if string.length > max
239
+ digest = Digest::MD5.hexdigest(string)
240
+ middle = "<...#{string.length} - #{digest[0..4]}...>"
241
+ s = (max - middle.length) / 2
242
+ string.slice(0, s-1) + middle + string.slice(-s, string.length)
243
+ else
244
+ string
245
+ end
246
+ end
247
+ ```
248
+
249
+ So a truncated value looks like:
250
+ `Truncated (15432): The first ~70 chars...<...15432 - a1b2c...>...last ~70 chars`
251
+
252
+ This preserves the beginning and end of the value and embeds a content hash
253
+ for debugging/identification.
254
+
255
+ ### `'none'`
256
+
257
+ A simple no-op — returns the prompt unchanged. Used to explicitly opt out of
258
+ all strategies.
259
+
260
+ ---
261
+
262
+ ## Thresholds and Configuration
263
+
264
+ All five thresholds are read via `Scout::Config.get` with the config path
265
+ `:prompt, :context` and a corresponding uppercase ENV var fallback. They are
266
+ **memoized** in class variables (`@@foo ||= ...`) on first access.
267
+
268
+ ### Summary table
269
+
270
+ | Config key | ENV var | Default | Memoized in | Description |
271
+ |----------------------|-----------------------|---------------------------------|----------------------|--------------------------------------------------------------------------|
272
+ | `full_tool_calls` | `FULL_TOOL_CALLS` | `0` | `@@full_tool_calls` | Number of most-recent tool *call* messages kept at full fidelity |
273
+ | `full_tool_outputs` | `FULL_TOOL_OUTPUTS` | `10` | `@@full_tool_outputs`| Number of most-recent tool *output* messages kept at full fidelity |
274
+ | `max_tool_calls` | `MAX_TOOL_CALLS` | `40` | `@@max_tool_calls` | Hard limit; tool *call* messages beyond this count are dropped entirely |
275
+ | `max_tool_outputs` | `MAX_TOOL_OUTPUTS` | defaults to `max_tool_calls` (→ `40`) | `@@max_tool_outputs` | Hard limit; tool *output* messages beyond this count are dropped entirely |
276
+ | `max_tool_chars` | `MAX_TOOL_CHARS` | `100_000` | `@@max_tool_chars` | Cumulative character budget for retained tool content |
277
+
278
+ ### Config accessors (all follow the same pattern)
279
+
280
+ ```ruby
281
+ def self.full_tool_calls
282
+ @@full_tool_calls ||= Scout::Config.get(:full_tool_calls, :prompt, :context, env: 'FULL_TOOL_CALLS')
283
+ end
284
+
285
+ def self.full_tool_outputs
286
+ @@full_tool_outputs ||= Scout::Config.get(:full_tool_outputs, :prompt, :context, env: 'FULL_TOOL_OUTPUTS')
287
+ end
288
+
289
+ def self.max_tool_calls
290
+ @@max_tool_calls ||= Scout::Config.get(:max_tool_calls, :prompt, :context, env: 'MAX_TOOL_CALLS')
291
+ end
292
+
293
+ def self.max_tool_outputs
294
+ @@max_tool_outputs ||= Scout::Config.get(:max_tool_outputs, :prompt, :context, env: 'MAX_TOOL_OUTPUTS', default: max_tool_calls)
295
+ end
296
+
297
+ def self.max_tool_chars
298
+ @@max_tool_chars ||= Scout::Config.get(:max_tool_chars, :prompt, :context, env: 'MAX_TOOL_CHARS')
299
+ end
300
+ ```
301
+
302
+ ### How they can be overridden
303
+
304
+ 1. **Scout config files** — under the `[prompt]` / `[context]` hierarchy
305
+ (e.g., `scout@config` entries or config YAML).
306
+ 2. **Environment variables** — uppercase names listed above (e.g.,
307
+ `export FULL_TOOL_OUTPUTS=20`).
308
+ 3. **Direct Ruby assignment** — since the accessors use `||=` memoization,
309
+ assigning to the class variable before first access overrides the config
310
+ lookup: `Chat.class_variable_set(:@@full_tool_calls, 5)`. (Not a public
311
+ API but technically possible.)
312
+ 4. **`options` hash at call time** — The backend's `ask` method extracts
313
+ `prompt_strategies` from `options`, but the *threshold values themselves*
314
+ are **not** passed per-call; they are always read from the global config
315
+ accessors. You cannot override thresholds for a single API call — only
316
+ globally.
317
+
318
+ ### Three-tier policy in action
319
+
320
+ For tool outputs (the same logic applies to tool calls with their respective
321
+ thresholds):
322
+
323
+ | Position (from end) | Condition | Action |
324
+ |---------------------|----------------------------------------|-------------------|
325
+ | Most recent N | `tool_ids.length < full_tool_outputs` | **Full fidelity** |
326
+ | Middle band | Between `full_tool_outputs` and `max_tool_outputs` | **Truncated** (content shortened to ~400 chars) |
327
+ | Beyond max | `tool_ids.length > max_tool_outputs` | **Dropped** |
328
+
329
+ There is also a character-budget override: if `tool_chars < max_tool_chars`,
330
+ the message is kept at full fidelity regardless of position. This means for
331
+ short conversations, nothing is ever truncated — the thresholds only bite
332
+ once cumulative tool content exceeds 100 KB by default.
333
+
334
+ ---
335
+
336
+ ## Integration with Backend
337
+
338
+ ### Where and when `prepare_prompt` is called
339
+
340
+ In `lib/scout/llm/backends/default.rb`, inside the `ask` method (line ~476–500):
341
+
342
+ ```ruby
343
+ def ask(question, options = {}, &block)
344
+ # ...
345
+ return_messages, log_response, current_meta, relay, process, prompt_strategies =
346
+ IndiferentHash.process_options options,
347
+ :return_messages, :log_response, :current_meta, :relay, :process, :prompt_strategies,
348
+ return_messages: false, log_response: true
349
+
350
+ messages = self.messages(question, options)
351
+
352
+ if relay
353
+ # Relay mode: messages sent to a remote relay server — NO prompt strategies applied
354
+ id = upload_messages(relay, messages, options)
355
+ response = gather_response(relay, id)
356
+ formatted_prompt = format_messages(messages) # ← uses raw messages
357
+ tools = tools(formatted_prompt, options)
358
+ else
359
+ # Normal mode: prompt strategies applied here
360
+ client = prepare_client(options, messages)
361
+ prompt = Chat.prepare_prompt(messages, prompt_strategies) # ← LINE 494
362
+ formatted_prompt = format_messages(prompt)
363
+ tools = tools(formatted_prompt, options)
364
+
365
+ response = query(client, formatted_prompt, tools, options)
366
+ # ...
367
+ end
368
+ end
369
+ ```
370
+
371
+ **Key integration points:**
372
+
373
+ 1. **Called on every `ask` invocation** (i.e., every API call in the normal
374
+ path). There is no conditional gating beyond the `relay` vs. normal-path
375
+ split.
376
+
377
+ 2. **Not applied in relay mode.** When `relay` is set, the raw `messages` are
378
+ uploaded to a remote server and strategies are skipped. Presumably the
379
+ relay server handles its own context management, or the expectation is that
380
+ relayed conversations are shorter.
381
+
382
+ 3. **Applied before `format_messages`.** The shortened `prompt` is what gets
383
+ formatted into the final API payload. This means strategy output directly
384
+ determines token consumption.
385
+
386
+ 4. **Applied after `prepare_client`.** The client is prepared with the original
387
+ `messages`, but the query uses the shortened `prompt`. (This may matter if
388
+ `prepare_client` does any token estimation — it would overestimate.)
389
+
390
+ 5. **`prompt_strategies` comes from `options`.** Callers can pass
391
+ `prompt_strategies: 'none'` or `prompt_strategies: ['custom_strategy']` or
392
+ `prompt_strategies: ->(msgs) { ... }` in the options hash to override the
393
+ default.
394
+
395
+ ### Where options originate
396
+
397
+ The `options` hash passed to `ask` typically comes from the agent or workflow
398
+ layer. In the agent workflow helper (`lib/scout/llm/agent/workflow.rb`), the
399
+ system prompt even informs the agent about the current truncation thresholds:
400
+
401
+ ```ruby
402
+ agent.start_chat.system <<-EOF
403
+ Tool call content may be truncated after #{Chat.full_tool_calls}, and forgoten after #{Chat.max_tool_outputs}.
404
+ EOF
405
+ ```
406
+
407
+ This tells the LLM that old tool content may not be available, encouraging it
408
+ to rely on more recent context.
409
+
410
+ ---
411
+
412
+ ## Key Design Notes
413
+
414
+ ### 1. Ephemeral transformation — decouples memory from inference
415
+
416
+ The most important design decision is that strategies **never mutate the
417
+ stored chat**. The `prepare_prompt` method receives the messages array, but
418
+ it returns a *new* array (via `reverse` + `collect` + `compact` + `reverse`),
419
+ and the backend assigns it to a local variable (`prompt`), not back to
420
+ `messages` or the chat object. The chat retains full-fidelity data; only the
421
+ API call sees the shortened version.
422
+
423
+ ### 2. Reverse traversal for recency bias
424
+
425
+ `shorten_tools` iterates in reverse so that the **most recent** tool
426
+ interactions are counted first and thus get priority for full retention.
427
+ Older messages are the ones truncated or dropped. This mirrors how human
428
+ context windows work: recent context is most relevant.
429
+
430
+ ### 3. Three-tier degradation (keep → truncate → drop)
431
+
432
+ Instead of a binary keep/drop, the system offers graceful degradation:
433
+ - Recent: full content
434
+ - Middle: truncated strings (hash-stamped for traceability)
435
+ - Old: completely removed
436
+
437
+ This avoids abrupt context loss where the model suddenly can't see a tool
438
+ result it was just referencing.
439
+
440
+ ### 4. Character budget as a global guard
441
+
442
+ The `max_tool_chars` threshold (default 100,000) acts as a total budget. If
443
+ the conversation is small enough that all tool content fits within the budget,
444
+ **no truncation happens at all** — regardless of the count thresholds. This
445
+ means the system is effectively a no-op for short conversations and only
446
+ activates when context pressure is real.
447
+
448
+ ### 5. Proc escape hatch
449
+
450
+ Supporting `Proc` as a strategy type allows fully custom, programmatic
451
+ transformations without modifying the framework. This is an extensibility
452
+ point for advanced users who want, e.g., LLM-based summarization of old tool
453
+ outputs.
454
+
455
+ ### 6. Content-hash in truncated strings
456
+
457
+ `Log.truncate_string` embeds an MD5 hash prefix (`digest[0..4]`) in the
458
+ truncation marker. This means a truncated value like
459
+ `<...15432 - a1b2c...>` can be matched against logs or the original chat to
460
+ identify what was removed — useful for debugging "why did the model forget
461
+ this?" issues.
462
+
463
+ ### 7. Config memoization trade-off
464
+
465
+ Thresholds are memoized in class variables (`@@foo ||= ...`). This means:
466
+ - **Pro:** No repeated config lookups during a session; consistent values.
467
+ - **Con:** Values are **frozen for the process lifetime** after first access.
468
+ Changing the config file or ENV var mid-process has no effect. This is
469
+ fine for typical CLI/agent usage but could be surprising in long-running
470
+ daemons.
471
+
472
+ ### 8. Minor issues observed
473
+
474
+ - **`shorten_string` ignores its `size` parameter** — always uses
475
+ `DEFAULT_SHORT_STRING_LENGTH` (200). The call at line ~115 passes
476
+ `DEFAULT_SHORT_STRING_LENGTH * 2` (400) as the size, but it has no effect.
477
+ - **`REGISTERED_STRATEGIES` is undefined** — referenced at line 137 but never
478
+ defined anywhere in the codebase. Passing an unknown strategy name will
479
+ raise `NameError` rather than a helpful error. This is likely a planned
480
+ but unimplemented extension point.
481
+
482
+ ---
483
+
484
+ ## Appendix: Full source listing of `lib/scout/llm/chat/prompt.rb`
485
+
486
+ ```ruby
487
+ module Chat
488
+
489
+ DEFAULT_CONTEXT_STRATEGY = %w(shorten_tools)
490
+ DEFAULT_SHORT_STRING_LENGTH = 200
491
+ DEFAULT_SHORT_JSON_LENGTH = 2000
492
+
493
+ DEFAULT_FULL_TOOL_CALLS = 0
494
+ DEFAULT_FULL_TOOL_OUTPUTS = 10
495
+ DEFAULT_MAX_TOOL_CALLS = 40
496
+ DEFAULT_MAX_TOOL_OUTPUTS = DEFAULT_MAX_TOOL_CALLS
497
+ DEFAULT_MAX_TOOL_CHARS = 100_000
498
+
499
+ def self.shorten_string(string, size = DEFAULT_SHORT_STRING_LENGTH, warning = 'Truncated')
500
+ new = Log.truncate_string(string, DEFAULT_SHORT_STRING_LENGTH)
501
+ if new.length < string.length
502
+ new = "#{warning} (#{string.length}): " + new
503
+ end
504
+ new
505
+ end
506
+
507
+ def self.full_tool_calls
508
+ @@full_tool_calls ||= Scout::Config.get(:full_tool_calls, :prompt, :context, env: 'FULL_TOOL_CALLS')
509
+ end
510
+
511
+ def self.full_tool_outputs
512
+ @@full_tool_outputs ||= Scout::Config.get(:full_tool_outputs, :prompt, :context, env: 'FULL_TOOL_OUTPUTS')
513
+ end
514
+
515
+ def self.max_tool_calls
516
+ @@max_tool_calls ||= Scout::Config.get(:max_tool_calls, :prompt, :context, env: 'MAX_TOOL_CALLS')
517
+ end
518
+
519
+ def self.max_tool_outputs
520
+ @@max_tool_outputs ||= Scout::Config.get(:max_tool_outputs, :prompt, :context, env: 'MAX_TOOL_OUTPUTS', default: max_tool_calls)
521
+ end
522
+
523
+ def self.max_tool_chars
524
+ @@max_tool_chars ||= Scout::Config.get(:max_tool_chars, :prompt, :context, env: 'MAX_TOOL_CHARS')
525
+ end
526
+
527
+ def self.shorten_tools(messages)
528
+ tool_ids = []
529
+ tool_chars = 0
530
+ user_messages = 1
531
+ assistant_messages = 0
532
+
533
+ full_tool_calls = self.full_tool_calls || DEFAULT_FULL_TOOL_CALLS
534
+ full_tool_outputs = self.full_tool_outputs || DEFAULT_FULL_TOOL_OUTPUTS
535
+ max_tool_calls = self.max_tool_calls || DEFAULT_MAX_TOOL_CALLS
536
+ max_tool_outputs = self.max_tool_outputs || DEFAULT_MAX_TOOL_OUTPUTS
537
+ max_tool_chars = self.max_tool_chars || DEFAULT_MAX_TOOL_CHARS
538
+
539
+ full_tool_calls = full_tool_calls.to_i
540
+ full_tool_outputs = full_tool_outputs.to_i
541
+ max_tool_calls = max_tool_calls.to_i
542
+ max_tool_outputs = max_tool_outputs.to_i
543
+ max_tool_chars = max_tool_chars.to_i
544
+
545
+ messages.reverse.collect do |msg|
546
+ case msg[:role].to_sym
547
+ when :function_call
548
+ json = msg[:content]
549
+ next msg unless json
550
+
551
+ tool_call = JSON.parse json
552
+ name, arguments, id = tool_call.values_at 'name', 'arguments', 'id'
553
+
554
+ if tool_ids.length < full_tool_calls || user_messages == 0 || tool_chars < max_tool_chars
555
+ tool_chars += json.length
556
+ msg
557
+ elsif tool_ids.length > max_tool_calls
558
+ Log.medium "Skipped tool call #{id} #{name} #{json.length}"
559
+ next
560
+ else
561
+ new_arguments = {}
562
+ arguments.each do |k,v|
563
+ new_arguments[k] = String === v ? shorten_string(v) : v
564
+ end if arguments
565
+
566
+ next msg if arguments.values == new_arguments.values
567
+
568
+ tool_call['arguments'] = new_arguments
569
+ json = tool_call.to_json
570
+ tool_chars += json.length
571
+ Log.medium "Truncated tool call #{id} #{name} #{msg[:content].length} to #{json.length}"
572
+ msg = msg.dup
573
+ msg[:content] = json
574
+ msg
575
+ end
576
+ when :function_call_output
577
+ json = msg[:content]
578
+ next msg unless json
579
+
580
+ tool_call = JSON.parse json
581
+ name, content, id = tool_call.values_at 'name', 'content', 'id'
582
+ tool_ids << id
583
+
584
+ if tool_ids.length < full_tool_outputs || user_messages == 0 || tool_chars < max_tool_chars
585
+ tool_chars += json.length
586
+ msg
587
+ elsif tool_ids.length > max_tool_outputs
588
+ Log.medium "Skipped tool output #{id} #{name} #{json.length}"
589
+ next
590
+ else
591
+ tool_call['content'] = shorten_string(content, DEFAULT_SHORT_STRING_LENGTH*2)
592
+ next msg if content == tool_call['content']
593
+ json = tool_call.to_json
594
+ tool_chars += json.length
595
+ Log.medium "Truncated tool output #{id} #{name} #{msg[:content].length} to #{json.length}"
596
+ msg = msg.dup
597
+ msg[:content] = json
598
+ msg
599
+ end
600
+ when :user
601
+ user_messages += 1
602
+ msg
603
+ when :assistant
604
+ assistant_messages += 1
605
+ msg
606
+ else
607
+ msg
608
+ end
609
+ end.compact.reverse
610
+ end
611
+
612
+ def self.prepare_prompt(prompt, prompt_strategies = nil)
613
+ return prompt_strategies.call(prompt) if Proc === prompt_strategies
614
+ prompt_strategies = DEFAULT_CONTEXT_STRATEGY if prompt_strategies.nil?
615
+ prompt_strategies = prompt_strategies.split(',') if String === prompt_strategies
616
+ prompt_strategies.each do |strategy|
617
+ prompt = case strategy
618
+ when 'shorten_tools'
619
+ Chat.shorten_tools(prompt)
620
+ when 'none'
621
+ prompt
622
+ else
623
+ strategy_proc = REGISTERED_STRATEGIES[strategy]
624
+ strategy_proc.call(prompt)
625
+ end
626
+ end
627
+ return prompt
628
+ end
629
+ end
630
+ ```