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.
- checksums.yaml +4 -4
- data/.vimproject +138 -50
- data/README.md +171 -290
- data/Rakefile +17 -1
- data/VERSION +1 -1
- data/doc/Improvements.md +325 -0
- data/doc/StartHere.md +110 -0
- data/doc/developer/Architecture.md +126 -0
- data/doc/developer/Backends.md +199 -0
- data/doc/developer/ChatLifecycle.md +183 -0
- data/doc/developer/DelegationInternals.md +295 -0
- data/doc/developer/DesignPrinciples.md +245 -0
- data/doc/developer/PromptProcessing.md +292 -0
- data/doc/developer/Provenance.md +317 -0
- data/doc/user/BuildingAgents.md +345 -0
- data/doc/user/Cookbook.md +333 -0
- data/doc/user/CoreConcepts.md +181 -0
- data/doc/user/Delegation.md +191 -0
- data/doc/user/GettingStarted.md +159 -0
- data/doc/user/ManagingContext.md +163 -0
- data/doc/user/MultiAgentWorkflows.md +256 -0
- data/doc/user/Python.md +159 -0
- data/doc/user/RunningInference.md +200 -0
- data/doc/user/ToolCalling.md +193 -0
- data/doc/user/WritingChats.md +197 -0
- data/lib/scout/llm/agent/chat.rb +61 -11
- data/lib/scout/llm/agent/delegate.rb +274 -65
- data/lib/scout/llm/agent/iterate.rb +2 -2
- data/lib/scout/llm/agent/save.rb +273 -0
- data/lib/scout/llm/agent/workflow.rb +164 -0
- data/lib/scout/llm/agent.rb +86 -61
- data/lib/scout/llm/ask.rb +62 -17
- data/lib/scout/llm/backends/anthropic.rb +9 -2
- data/lib/scout/llm/backends/bedrock.rb +15 -3
- data/lib/scout/llm/backends/default.rb +183 -99
- data/lib/scout/llm/backends/glm.rb +58 -0
- data/lib/scout/llm/backends/huggingface.rb +196 -26
- data/lib/scout/llm/backends/ollama.rb +13 -1
- data/lib/scout/llm/backends/openai.rb +0 -2
- data/lib/scout/llm/backends/openwebui.rb +20 -13
- data/lib/scout/llm/backends/relay.rb +22 -22
- data/lib/scout/llm/backends/responses.rb +1 -1
- data/lib/scout/llm/chat/agent_meta.rb +264 -0
- data/lib/scout/llm/chat/annotation.rb +39 -10
- data/lib/scout/llm/chat/parse.rb +28 -6
- data/lib/scout/llm/chat/persist.rb +25 -0
- data/lib/scout/llm/chat/process/clear.rb +41 -6
- data/lib/scout/llm/chat/process/files.rb +21 -6
- data/lib/scout/llm/chat/process/meta.rb +421 -34
- data/lib/scout/llm/chat/process/options.rb +21 -1
- data/lib/scout/llm/chat/process/tools.rb +56 -15
- data/lib/scout/llm/chat/process.rb +4 -0
- data/lib/scout/llm/chat/prompt/shorten_tools.rb +125 -0
- data/lib/scout/llm/chat/prompt/shorten_tools_epoch.rb +365 -0
- data/lib/scout/llm/chat/prompt.rb +48 -0
- data/lib/scout/llm/chat/provenance.rb +775 -0
- data/lib/scout/llm/chat/tool_calls.rb +76 -0
- data/lib/scout/llm/chat.rb +18 -2
- data/lib/scout/llm/embed.rb +11 -3
- data/lib/scout/llm/image.rb +86 -0
- data/lib/scout/llm/mcp.rb +10 -2
- data/lib/scout/llm/rag.rb +3 -3
- data/lib/scout/llm/tools/call.rb +160 -11
- data/lib/scout/llm/tools/knowledge_base.rb +1 -1
- data/lib/scout/llm/tools/workflow.rb +32 -16
- data/lib/scout/model/python/huggingface/causal.rb +23 -5
- data/lib/scout/model/python/huggingface.rb +2 -1
- data/lib/scout-ai.rb +1 -0
- data/python/README.md +197 -14
- data/python/scout_ai/huggingface/eval.py +245 -34
- data/python/tests/test_huggingface_eval.py +58 -0
- data/research/ChatAnalyst-required-changes.md +167 -0
- data/research/agent-delegation-analysis.md +810 -0
- data/research/agent-meta-provenance-integration-plan.md +622 -0
- data/research/agent-workflow-analysis.md +1120 -0
- data/research/backends-analysis.md +836 -0
- data/research/chat-core-analysis.md +946 -0
- data/research/chatanalyst-provenance/00-baseline.md +30 -0
- data/research/chatanalyst-provenance/01-repo-map.md +60 -0
- data/research/chatanalyst-provenance/02-event-reconstruction.md +55 -0
- data/research/chatanalyst-provenance/03-duplication-evidence.md +45 -0
- data/research/chatanalyst-provenance/04-tooling-root-cause.md +57 -0
- data/research/chatanalyst-provenance/05-fix-plan.md +46 -0
- data/research/chatanalyst-provenance/07-critic-review.md +25 -0
- data/research/chatanalyst-provenance/final-report.md +45 -0
- data/research/chatanalyst-provenance/resumption.md +37 -0
- data/research/coding-philosophy-analysis.md +928 -0
- data/research/commands-analysis.md +947 -0
- data/research/multi-agent-patterns-analysis.md +853 -0
- data/research/prompt-strategies-analysis.md +630 -0
- data/research/prov-verbosity-fix-notes.md +77 -0
- data/research/provenance-analysis.md +469 -0
- data/research/provenance-navigation-design.md +640 -0
- data/research/synthesis-report.md +487 -0
- data/research/tools-system-analysis.md +779 -0
- data/scout-ai.gemspec +100 -11
- data/scout_commands/agent/ask +13 -3
- data/scout_commands/agent/kb +2 -0
- data/scout_commands/llm/ask +11 -4
- data/scout_commands/llm/md +76 -0
- data/scout_commands/llm/process_queries +48 -0
- data/scout_commands/llm/prov +602 -0
- data/scout_commands/llm/word +71 -0
- data/scout_commands/workflow/mcp +43 -0
- data/share/word/reference.docx +0 -0
- data/test/etc/AI/mock.yaml +11 -0
- data/test/fixtures/backends/anthropic.json +19 -0
- data/test/fixtures/backends/anthropic_tool_use.json +24 -0
- data/test/fixtures/backends/bedrock.json +8 -0
- data/test/fixtures/backends/bedrock_embedding.json +3 -0
- data/test/fixtures/backends/bedrock_tool_use.json +17 -0
- data/test/fixtures/backends/ollama.json +16 -0
- data/test/fixtures/backends/ollama_tool_call.json +27 -0
- data/test/fixtures/backends/openai_chat.json +21 -0
- data/test/fixtures/backends/openai_chat_tool_call.json +31 -0
- data/test/fixtures/backends/responses.json +33 -0
- data/test/fixtures/backends/responses_tool_call.json +28 -0
- data/test/integration/README.md +32 -0
- data/test/integration/scout/llm/backends/test_endpoints.rb +34 -0
- data/test/integration/scout/llm/backends/test_openwebui.rb +61 -0
- data/test/integration/scout/llm/backends/test_relay.rb +52 -0
- data/test/integration/scout/llm/test_infrastructure.rb +74 -0
- data/test/{scout → integration/scout}/llm/test_mcp.rb +1 -1
- data/test/integration/scout/llm/tools/test_mcp.rb +42 -0
- data/test/integration/scout/model/test_base.rb +91 -0
- data/test/scout/llm/agent/test_chat.rb +8 -2
- data/test/scout/llm/agent/test_save.rb +413 -0
- data/test/scout/llm/agent/test_workflow.rb +110 -0
- data/test/scout/llm/backends/test_anthropic.rb +93 -10
- data/test/scout/llm/backends/test_bedrock.rb +118 -2
- data/test/scout/llm/backends/test_huggingface.rb +137 -42
- data/test/scout/llm/backends/test_ollama.rb +70 -20
- data/test/scout/llm/backends/test_openwebui.rb +42 -40
- data/test/scout/llm/backends/test_relay.rb +4 -2
- data/test/scout/llm/chat/agent_meta_fixtures.rb +131 -0
- data/test/scout/llm/chat/process/test_meta.rb +518 -0
- data/test/scout/llm/chat/process/test_normalize_usage.rb +183 -0
- data/test/scout/llm/chat/test_agent_meta.rb +357 -0
- data/test/scout/llm/chat/test_agent_meta_provenance.rb +467 -0
- data/test/scout/llm/chat/test_agent_meta_tokens.rb +594 -0
- data/test/scout/llm/chat/test_parse.rb +70 -15
- data/test/scout/llm/chat/test_prov_cli.rb +274 -0
- data/test/scout/llm/chat/test_provenance.rb +240 -0
- data/test/scout/llm/chat/test_tool_calls.rb +38 -0
- data/test/scout/llm/test_agent.rb +13 -36
- data/test/scout/llm/test_ask.rb +75 -52
- data/test/scout/llm/test_chat.rb +107 -13
- data/test/scout/llm/test_embed.rb +48 -0
- data/test/scout/llm/test_rag.rb +23 -16
- data/test/scout/llm/test_tools.rb +12 -1
- data/test/scout/llm/tools/test_knowledge_base.rb +0 -1
- data/test/scout/llm/tools/test_mcp.rb +5 -3
- data/test/scout/llm/tools/test_workflow.rb +23 -2
- data/test/scout/model/python/huggingface/causal/test_next_token.rb +11 -5
- data/test/scout/model/python/huggingface/test_causal.rb +9 -3
- data/test/scout/model/python/huggingface/test_classification.rb +11 -2
- data/test/scout/model/python/test_torch.rb +2 -0
- data/test/scout/model/python/torch/test_helpers.rb +4 -0
- data/test/scout/model/test_base.rb +4 -2
- data/test/support/availability.rb +231 -0
- data/test/support/fake_clients.rb +138 -0
- data/test/support/fixtures.rb +21 -0
- data/test/support/infrastructure_probes.rb +136 -0
- data/test/support/mock_backend.rb +215 -0
- data/test/test_helper.rb +32 -2
- metadata +99 -10
- data/doc/Agent.md +0 -327
- data/doc/Chat.md +0 -458
- data/doc/LLM.md +0 -340
- data/doc/RAG.md +0 -129
- data/scout_commands/documenter +0 -148
- data/test/scout/llm/backends/test_openai.rb +0 -192
- data/test/scout/llm/backends/test_responses.rb +0 -238
- 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
|
+
```
|