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,836 @@
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
+ # 05 — Backend Abstraction, Inference Loop, chain_tools, Error Handling, and Provider Differences
10
+
11
+ This document describes the LLM backend layer in `lib/scout/llm/backends/`,
12
+ the top-level entry point in `lib/scout/llm/ask.rb`, the `chain_tools`
13
+ recursive tool-calling loop, error handling and retries, and the differences
14
+ between every provider backend.
15
+
16
+ All paths are relative to the repository root (`/bulk/mvazque2/git/scout-ai`).
17
+
18
+ ---
19
+
20
+ ## Table of Contents
21
+
22
+ 1. [Backend Class Hierarchy](#1-backend-class-hierarchy)
23
+ 2. [The `ask` Method](#2-the-ask-method)
24
+ 3. [The `chain_tools` Loop (CRITICAL)](#3-the-chain_tools-loop-critical)
25
+ 4. [Error Handling and Retries](#4-error-handling-and-retries)
26
+ 5. [Provider-Specific Differences](#5-provider-specific-differences)
27
+ 6. [Integration Points](#6-integration-points)
28
+ 7. [Key Design Patterns](#7-key-design-patterns)
29
+
30
+ ---
31
+
32
+ ## 1. Backend Class Hierarchy
33
+
34
+ ### 1.1 The `Backend` module (default.rb)
35
+
36
+ There is **no abstract base class** in the traditional OOP sense. Instead, the
37
+ shared implementation lives in a Ruby **module** called `LLM::Backend`,
38
+ defined in `lib/scout/llm/backends/default.rb` (≈610 lines).
39
+
40
+ ```
41
+ LLM::Backend # module
42
+ ├── BackendException # a class used to tag exceptions with a `.chat` accessor
43
+ └── ClassMethods # inner module holding ALL shared methods
44
+ ├── client, client_options, prepare_client, extra_options
45
+ ├── query
46
+ ├── process_format, encode_image, encode_pdf, format_other
47
+ ├── format_tool_definitions, format_tool_call, format_tool_output
48
+ ├── format_messages, extract_tools, tools
49
+ ├── messages
50
+ ├── chain_tools # recursive tool loop
51
+ ├── parse_tool_call
52
+ ├── process_response # parse API response → internal message list
53
+ ├── update_meta, reasoning
54
+ ├── upload_messages, gather_response # relay helpers
55
+ ├── ask # main entry point (template method)
56
+ ├── image
57
+ └── embed_query, embed
58
+ ```
59
+
60
+ ### 1.2 How providers compose it
61
+
62
+ Every backend is a **module** (not a class) that exposes singleton methods.
63
+ The composition idiom is:
64
+
65
+ ```ruby
66
+ module LLM
67
+ module FooMethods # provider-specific overrides
68
+ def query(...)
69
+ ...
70
+ end
71
+ # ... other overrides
72
+ end
73
+
74
+ module Foo
75
+ TAG = 'foo'
76
+ DEFAULT_MODEL = 'foo-model'
77
+
78
+ class << self
79
+ prepend FooMethods # overrides take priority
80
+ include Backend::ClassMethods # shared implementation
81
+ end
82
+ end
83
+ end
84
+ ```
85
+
86
+ Key mechanics:
87
+
88
+ | Mechanism | Purpose |
89
+ |---|---|
90
+ | `class << self; prepend FooMethods; end` | Provider-specific overrides (e.g. `query`, `process_response`) are dispatched **before** the shared methods. |
91
+ | `class << self; include Backend::ClassMethods; end` | Shared methods (`ask`, `chain_tools`, `tools`, `embed`, etc.) come from the included module. |
92
+ | `prepend` (not `include`) for overrides | Ruby MRO ensures `FooMethods#query` shadows `Backend::ClassMethods#query`. |
93
+
94
+ This allows the shared `ask` method to call `self.query(...)`, `self.format_tool_call(...)`, etc., and Ruby dispatches to the **provider-specific** version when one exists.
95
+
96
+ ### 1.3 The Backend Registry / How a Backend Is Selected
97
+
98
+ The top-level `LLM.ask` (in `ask.rb`) selects a backend in two ways:
99
+
100
+ **A. Hard-coded `case` dispatch** (for built-in backends):
101
+
102
+ ```ruby
103
+ case backend
104
+ when :openai, "openai" then LLM::OpenAI.ask(...)
105
+ when :anthropic, "anthropic" then LLM::Anthropic.ask(...)
106
+ when :responses, "responses" then LLM::Responses.ask(...)
107
+ when :ollama, "ollama" then LLM::OLlama.ask(...)
108
+ when :vllm, "vllm" then LLM::VLLM.ask(...)
109
+ when :openwebui, "openwebui" then LLM::OpenWebUI.ask(...)
110
+ when :huggingface, "huggingface" then LLM::Huggingface.ask(...)
111
+ when :relay, "relay" then LLM::Relay.ask(...)
112
+ when :bedrock, "bedrock" then LLM::Bedrock.ask(...)
113
+ else
114
+ mod = BACKENDS[backend]
115
+ raise "Unknown backend: #{backend}" if mod.nil?
116
+ mod.ask(...)
117
+ end
118
+ ```
119
+
120
+ **B. Dynamic registry** (for plugins / user-registered backends):
121
+
122
+ ```ruby
123
+ BACKENDS = IndiferentHash.setup({})
124
+
125
+ def self.register_backend(name, mod)
126
+ BACKENDS[name] = mod
127
+ end
128
+ ```
129
+
130
+ Third-party code can call `LLM.register_backend(:my_backend, MyModule)` and
131
+ then pass `backend: :my_backend` in options.
132
+
133
+ **Default backend**: `Scout::Config.get(:backend, :ask, :llm, env:
134
+ 'ASK_BACKEND,LLM_BACKEND', default: :responses)` — the **Responses API**
135
+ backend is the default.
136
+
137
+ ### 1.4 TAG and DEFAULT_MODEL constants
138
+
139
+ Every backend module defines two constants used by `client_options`:
140
+
141
+ ```ruby
142
+ TAG = 'openai' # used in config key resolution: TAG + '_ask', TAG.upcase + '_URL'
143
+ DEFAULT_MODEL = 'gpt-5-nano' # fallback model if none configured
144
+ ```
145
+
146
+ These are referenced via `self::TAG` and `self::DEFAULT_MODEL` in the shared
147
+ `client_options` method.
148
+
149
+ ---
150
+
151
+ ## 2. The `ask` Method
152
+
153
+ ### 2.1 Full Signature
154
+
155
+ ```ruby
156
+ def ask(question, options = {}, &block)
157
+ ```
158
+
159
+ **Location**: `LLM::Backend::ClassMethods#ask` in `default.rb`, line ~476.
160
+
161
+ ### 2.2 Parameters
162
+
163
+ | Parameter | Type | Description |
164
+ |---|---|---|
165
+ | `question` | String, Array (chat messages), or Chat object | The user's input. Converted to messages via `self.messages(question, options)`. |
166
+ | `options` | Hash (IndiferentHash) | Options hash. Key entries: |
167
+ | `options[:backend]` | Symbol/String | Backend selector (e.g. `:responses`, `:openai`). |
168
+ | `options[:tools]` | Array of tool definitions | Tools made available to the model. |
169
+ | `options[:model]` | String | Model identifier. |
170
+ | `options[:url]` | String | API endpoint URL. |
171
+ | `options[:key]` | String | API key. |
172
+ | `options[:return_messages]` | Boolean | If `true`, returns the full message array (including meta, tool calls); if `false`, returns just the final text content. Default: `false`. |
173
+ | `options[:log_response]` | Boolean | Whether to compute/store metadata (token counts). Default: `true`. |
174
+ | `options[:current_meta]` | Hash | Accumulated metadata from prior calls (token counts). |
175
+ | `options[:relay]` | String | Server hostname for relay mode. If set, the query is dispatched via SCP to a remote server. |
176
+ | `options[:process]` | String | If set, the raw response JSON is written to `Scout.var.query.response[process]` and returned immediately (no further processing). |
177
+ | `options[:prompt_strategies]` | String/Proc/Array | Controls how messages are prepared before inference (see Integration Points). |
178
+ | `options[:previous_response_id]` | String | OpenAI Responses API conversation threading ID. |
179
+ | `options[:previous_response]` | String/Boolean | If `'false'`, disables `previous_response_id` propagation. |
180
+ | `options[:format]` | Symbol/String/Hash | Output format control (JSON, json_schema, etc.). |
181
+ | `options[:websearch]` | Boolean | If truthy, appends a `{role: 'websearch', content: true}` message. |
182
+ | `options[:tool_choice]` | Various | Passed through to provider. Removed during `chain_tools` recursion. |
183
+ | `&block` | Block | If provided, called as `block.call(function_name, function_arguments)` for each tool call when no matching tool object is found. Used by `LLM.workflow_ask` and `LLM.knowledge_base_ask`. |
184
+
185
+ ### 2.3 Return Value
186
+
187
+ | Mode | Return type |
188
+ |---|---|
189
+ | `return_messages: true` | `Chat` (Array of message hashes), including `{role: :meta}`, `{role: 'function_call'}`, `{role: 'function_call_output'}`, `{role: 'assistant'}`, etc. |
190
+ | `return_messages: false` (default) | `String` — the last assistant message's `content`, after purging non-content messages. Returns `''` if no content. |
191
+ | `process:` set | Raw response Hash (JSON), written to disk and returned. |
192
+ | `relay:` set | Hash (parsed JSON) from the remote server. |
193
+
194
+ ### 2.4 High-Level Flow of `Backend#ask`
195
+
196
+ ```
197
+ ask(question, options, &block)
198
+ │
199
+ ├─ 1. options setup: extract return_messages, log_response, current_meta, relay, process, prompt_strategies
200
+ │
201
+ ├─ 2. messages = self.messages(question, options) # normalize question → message array
202
+ │
203
+ ├─ 3a. IF relay:
204
+ │ upload_messages(server, messages, options) → id
205
+ │ response = gather_response(server, id)
206
+ │
207
+ ├─ 3b. ELSE (normal path):
208
+ │ client = prepare_client(options, messages)
209
+ │ prompt = Chat.prepare_prompt(messages, prompt_strategies)
210
+ │ formatted_prompt = format_messages(prompt)
211
+ │ tools = self.tools(formatted_prompt, options)
212
+ │ response = query(client, formatted_prompt, tools, options)
213
+ │
214
+ ├─ 4. IF process: write response to disk, return response
215
+ │
216
+ ├─ 5. reasoning = self.reasoning(response)
217
+ │
218
+ ├─ 6. output = process_response(messages, response, tools, options, &block)
219
+ │ → parses API response into internal message format
220
+ │ → executes tool calls via LLM.process_calls
221
+ │
222
+ ├─ 7. IF log_response: meta = update_meta(response, current_meta); meta['reas'] = reasoning
223
+ │
224
+ ├─ 8. output = chain_tools(messages, output, tools, options, &block) ← RECURSIVE
225
+ │
226
+ ├─ 9. Prepend meta message to output
227
+ │
228
+ ├─ 10. Append previous_response_id message if applicable
229
+ │
230
+ └─ 11. Return: Chat (if return_messages) or String (if not)
231
+ ```
232
+
233
+ ---
234
+
235
+ ## 3. The `chain_tools` Loop (CRITICAL)
236
+
237
+ `chain_tools` is the **recursive tool-calling loop** that allows the model to
238
+ make multiple rounds of tool calls. It is the heart of Scout-AI's agentic
239
+ capability.
240
+
241
+ ### 3.1 Location and Signature
242
+
243
+ ```ruby
244
+ def chain_tools(messages, output, tools, options = {}, &block)
245
+ ```
246
+
247
+ `default.rb`, line ~342.
248
+
249
+ ### 3.2 How It Works — Step by Step
250
+
251
+ ```ruby
252
+ def chain_tools(messages, output, tools, options = {}, &block)
253
+ previous_response_id = options[:previous_response_id]
254
+
255
+ return output if output === [] # guard: empty output
256
+
257
+ raise "Output format unknown" unless # guard: last message must be a hash with :role
258
+ Hash === output.last && output.last.include?(:role)
259
+
260
+ if output.last[:role] == 'function_call_output'
261
+ # The last message is a tool result → feed it back to the model
262
+ case previous_response_id
263
+ when String
264
+ # OpenAI Responses API: can use previous_response_id for threading
265
+ output + ask(output, options.except(:tool_choice)
266
+ .merge(return_messages: true,
267
+ previous_response_id: previous_response_id), &block)
268
+ else
269
+ # Generic path: re-send full conversation including tool outputs
270
+ output + ask(messages + output, options.except(:tool_choice)
271
+ .merge(return_messages: true), &block)
272
+ end
273
+ else
274
+ # Last message is NOT a function_call_output → loop terminates
275
+ output
276
+ end
277
+ end
278
+ ```
279
+
280
+ ### 3.3 The Recursive Loop in Detail
281
+
282
+ ```
283
+ ┌──────────────────────────────────────────────────────────────┐
284
+ │ Backend#ask (iteration 1) │
285
+ │ query(client, ...) → response │
286
+ │ process_response(messages, response, tools, ...) │
287
+ │ → extracts tool_calls from response │
288
+ │ → LLM.process_calls(tools, tool_calls, &block) │
289
+ │ → returns [function_call_msg, function_call_output_msg] │
290
+ │ output.last[:role] == 'function_call_output' → YES │
291
+ │ chain_tools(messages, output, tools, options) │
292
+ │ ┌────────────────────────────────────────────────────┐ │
293
+ │ │ Recursive call to ask (iteration 2) │ │
294
+ │ │ messages + output → query → response │ │
295
+ │ │ process_response → more tool calls? │ │
296
+ │ │ YES → recurse again │ │
297
+ │ │ NO → output.last is 'assistant' → return │ │
298
+ │ └────────────────────────────────────────────────────┘ │
299
+ │ output = chain_tools result │
300
+ │ return final output │
301
+ └──────────────────────────────────────────────────────────────┘
302
+ ```
303
+
304
+ **Termination condition**: The recursion stops when `output.last[:role]` is
305
+ **not** `'function_call_output'`. This happens when:
306
+
307
+ 1. The model returns a text/assistant response **without** any tool calls.
308
+ 2. `process_response` produces only assistant messages (no tool calls were
309
+ detected in the API response).
310
+
311
+ **Each iteration of the loop:**
312
+
313
+ 1. **Ask** the model with the current messages (including previous tool call results).
314
+ 2. **Process** the response: `process_response` extracts any tool calls, executes them via `LLM.process_calls`, and produces function_call / function_call_output messages.
315
+ 3. **Check**: if the last message is `function_call_output` (i.e., a tool was just called), the loop continues.
316
+ 4. **Recurse**: call `ask` again with the accumulated messages + tool outputs.
317
+
318
+ ### 3.4 How Tool Results Are Appended to the Chat
319
+
320
+ Tool results are produced by `LLM.process_calls` (in
321
+ `lib/scout/llm/tools/call.rb`). For each tool call, it returns a **pair** of
322
+ messages:
323
+
324
+ ```ruby
325
+ [
326
+ { role: "function_call", content: tool_call.to_json }, # the call itself
327
+ { role: "function_call_output", content: result.to_json } # the result
328
+ ]
329
+ ```
330
+
331
+ These pairs are concatenated into the `output` array. When the loop recurses,
332
+ `messages + output` is passed as the new question to `ask`, which feeds the
333
+ full conversation (including tool calls and results) back to the model.
334
+
335
+ The `format_messages` method in `Backend::ClassMethods` then transforms these
336
+ internal roles into provider-specific formats:
337
+
338
+ | Internal role | Default (Responses API) | OpenAI (Chat Completions) | Anthropic |
339
+ |---|---|---|---|
340
+ | `function_call` | `{type: 'function_call', name:..., arguments:..., call_id:...}` | `{role: 'assistant', tool_calls: [{type:'function', function:{name:..., arguments:...}}]}` | `{role: 'assistant', content: [{type:'tool_use', id:..., name:..., input:...}]}` |
341
+ | `function_call_output` | `{type: 'function_call_output', output:..., call_id:...}` | `{role: 'tool', tool_call_id:..., content:...}` | `{role: 'user', content: [{type:'tool_result', tool_use_id:..., content:...}]}` |
342
+
343
+ ### 3.5 Maximum Iterations / Safeguards
344
+
345
+ **There is no explicit maximum iteration count.** The `chain_tools` method
346
+ recurses indefinitely until the model stops requesting tool calls.
347
+
348
+ However, several implicit safeguards exist:
349
+
350
+ 1. **Model behavior**: The model is expected to eventually stop calling tools and produce a final answer.
351
+ 2. **`tool_choice` is stripped**: Each recursive call uses `options.except(:tool_choice)`, preventing forced tool selection from causing infinite loops.
352
+ 3. **Token limits / API limits**: Provider API limits (max tokens, rate limits) will eventually cause errors.
353
+ 4. **`max_content_length`**: In `LLM.process_calls`, if a tool result exceeds
354
+ `LLM.max_content_length` (default: 100,000 characters, configurable via
355
+ `Scout::Config.get(:max_content_length, ...)`), the result is replaced with
356
+ an exception message instead of being fed back to the model.
357
+
358
+ > **⚠ Design note**: The lack of an explicit recursion limit is a potential
359
+ > concern for robustness. If a model continuously calls tools without
360
+ > producing a final answer, the system will recurse until a stack overflow or
361
+ > API error occurs.
362
+
363
+ ---
364
+
365
+ ## 4. Error Handling and Retries
366
+
367
+ ### 4.1 Error Handling in `Backend#ask`
368
+
369
+ The `ask` method has two major `rescue` blocks, both in the non-relay path:
370
+
371
+ **A. Query errors** (calling `query(client, ...)`):
372
+
373
+ ```ruby
374
+ response = begin
375
+ query(client, formatted_prompt, tools, options)
376
+ rescue Exception => e
377
+ # Save diagnostic information
378
+ tmpfile = TmpFile.tmp_file
379
+ Open.write tmpfile + ".chat", Chat.print(messages)
380
+ Open.write tmpfile + ".options", options.except(:messages, :tools).to_json
381
+ Open.write tmpfile + ".meta", current_meta.to_json
382
+ Log.warn "Messages and options saved in #{tmpfile}"
383
+
384
+ e.extend LLM::Backend::BackendException # tag the exception
385
+ e.chat tmpfile # attach the tmpfile path
386
+
387
+ raise e # re-raise
388
+ end
389
+ ```
390
+
391
+ **B. `process_response` errors**:
392
+
393
+ ```ruby
394
+ output = begin
395
+ process_response(messages, response, tools, options, &block)
396
+ rescue Exception => e
397
+ # Same pattern: save diagnostics, tag exception, re-raise
398
+ ...
399
+ raise e
400
+ end
401
+ ```
402
+
403
+ In both cases:
404
+ - Messages and options are saved to temporary files for debugging.
405
+ - The exception is tagged with `BackendException` and given a `.chat` accessor pointing to the diagnostic files.
406
+ - The exception is **re-raised** — it is NOT silently swallowed.
407
+
408
+ ### 4.2 Relay Retry Logic
409
+
410
+ The relay backend (`gather_response` and `Relay.gather`) uses a **retry loop**:
411
+
412
+ ```ruby
413
+ def gather_response(server, id)
414
+ TmpFile.with_file do |file|
415
+ begin
416
+ CMD.cmd("scp #{server}:.../response/#{id}.json #{file}")
417
+ JSON.parse(Open.read(file))
418
+ rescue
419
+ sleep 1
420
+ retry # infinite retry with 1-second sleep
421
+ end
422
+ end
423
+ end
424
+ ```
425
+
426
+ This retries indefinitely until the response file appears on the remote server.
427
+
428
+ ### 4.3 `chain_tools` Error Handling
429
+
430
+ ```ruby
431
+ begin
432
+ ask(output, options.except(:tool_choice).merge(...), &block)
433
+ rescue Exception
434
+ raise $! # re-raise immediately
435
+ end
436
+ ```
437
+
438
+ No retry logic inside `chain_tools`. If a recursive call fails, the error
439
+ propagates up.
440
+
441
+ ### 4.4 No Explicit Rate-Limiting / Backoff
442
+
443
+ There is **no built-in rate-limit handling** (429 retries, exponential
444
+ backoff, etc.) in the Scout-AI backend layer. The `request_timeout` option
445
+ (default: 12000 for client creation, 1200 in `client_options`) is passed to
446
+ the underlying HTTP client (e.g., `OpenAI::Client`), but Scout-AI itself does
447
+ not catch rate-limit errors and retry them.
448
+
449
+ ### 4.5 Bedrock Inline Tool Loop (Separate Implementation)
450
+
451
+ The Bedrock backend (`lib/scout/llm/backends/bedrock.rb`) implements its own
452
+ **`while` loop** for tool calling, bypassing `chain_tools` entirely:
453
+
454
+ ```ruby
455
+ tool_calls = message.dig('content').select{|m| m['tool_calls']}
456
+ while tool_calls && tool_calls.any?
457
+ messages << message
458
+ tool_calls.each do |tool_call|
459
+ response_message = LLM.tool_response(tool_call, &block)
460
+ messages << response_message
461
+ end
462
+ # re-invoke model
463
+ response = client.invoke_model(...)
464
+ result = JSON.parse(response.body.string)
465
+ message = result
466
+ tool_calls = message.dig('content').select{|m| m['tool_calls']}
467
+ end
468
+ ```
469
+
470
+ This is an iterative (not recursive) loop, but like `chain_tools`, it has no
471
+ explicit iteration limit.
472
+
473
+ ---
474
+
475
+ ## 5. Provider-Specific Differences
476
+
477
+ ### 5.1 Summary Table
478
+
479
+ | Backend | File | API Style | Tool Format | Embeddings | Notes |
480
+ |---|---|---|---|---|---|
481
+ | **Responses** (default) | `responses.rb` | OpenAI Responses API (`client.responses.create`) | `function_call` / `function_call_output` types | ✅ | Uses all default `Backend::ClassMethods`. No overrides. |
482
+ | **OpenAI** | `openai.rb` | OpenAI Chat Completions API (`client.chat`) | `{type:'function', function:{...}}` | ✅ | Overrides `query`, `format_tool_definitions`, `format_tool_call`, `format_tool_output`, `parse_tool_call`, `process_response`. |
483
+ | **Anthropic** | `anthropic.rb` | Anthropic Messages API (`client.messages`) | `{type:'custom', input_schema:...}` / `tool_use` / `tool_result` | ❌ (`raise 'Anthropic does not offer embeddings'`) | Overrides `client`, `query`, `format_tool_definitions`, `format_tool_call`, `format_tool_output`, `parse_tool_call`, `process_response`, `extra_options`, `embed_query`. |
484
+ | **vLLM** | `vllm.rb` | OpenAI-compatible Responses API | Same as Responses | ✅ | Includes `ResponsesMethods`. Only overrides `parse_tool_call` (strips channel artifacts from tool names). |
485
+ | **Ollama** | `ollama.rb` | Ollama native API (`Ollama.new(...)`) | `{type:'function', function:{...}}` (like OpenAI) | ✅ (via `/api/embed`) | Overrides `client`, `query`, `embed_query`, `parse_tool_call`, `process_response`, `format_tool_definitions`, `format_tool_call`, `format_tool_output`, `extra_options`. Stubs out `update_meta` and `reasoning` (return `nil`). |
486
+ | **OpenWebUI** | `openwebui.rb` | REST API (RestClient, not OpenAI gem) | Same as OpenAI | ✅ | Includes `OpenAIMethods`. Overrides `client` (returns a config hash, not a client object), `query` (manual HTTP POST), `parse_tool_call`. |
487
+ | **HuggingFace** | `huggingface.rb` | Local Python model via ScoutPython (`CausalModel`) | `{type:'function', function:{...}}` | ✅ (via local model) | Overrides nearly everything: `prepare_client`, `query`, `format_tool_definitions`, tool formatting, `parse_tool_call`, `process_response`, `tools`, `reasoning`, `embed`. Uses a Python subprocess for inference. |
488
+ | **Relay** | `relay.rb` | SCP-based delegation to remote server | N/A (delegated) | N/A | Standalone module, does NOT include `Backend::ClassMethods`. Uploads serialized messages/options via SCP, polls for response. |
489
+ | **Bedrock** | `bedrock.rb` | AWS Bedrock Runtime (`Aws::BedrockRuntime::Client`) | Limited (inline `while` loop) | ✅ | Standalone module, does NOT include `Backend::ClassMethods`. Has its own `ask` with iterative tool loop. Supports `:messages` and `:prompt` types. |
490
+
491
+ ### 5.2 Detailed Provider Notes
492
+
493
+ #### Responses (Default Backend)
494
+
495
+ - **Module**: `LLM::Responses`
496
+ - **TAG**: `'responses'`, **DEFAULT_MODEL**: `'gpt-5-nano'`
497
+ - **API**: OpenAI Responses API via `client.responses.create(parameters:)`.
498
+ - **Methods module**: `ResponsesMethods` — empty, no overrides.
499
+ - Uses **all** shared `Backend::ClassMethods` unchanged.
500
+ - The default `query` method passes `parameters[:input] = messages` and `parameters[:tools] = formatted_tools`.
501
+ - Supports `previous_response_id` for conversation threading (OpenAI server-side state).
502
+ - Parses responses with `response['output']` — a flat array of typed items (`message`, `function_call`, `mcp_call`, `image_generation_call`, `web_search_call`, `reasoning`).
503
+
504
+ #### OpenAI (Chat Completions)
505
+
506
+ - **Module**: `LLM::OpenAI`
507
+ - **TAG**: `'openai'`, **DEFAULT_MODEL**: `'gpt-5-nano'`
508
+ - **API**: `client.chat(parameters:)` — the classic Chat Completions endpoint.
509
+ - Key differences from Responses:
510
+ - `query`: passes `parameters[:messages]` instead of `parameters[:input]`.
511
+ - `format_tool_definitions`: wraps definitions in `{type: :function, function: {...}}` (nested structure).
512
+ - `format_tool_call`: produces `{role: 'assistant', tool_calls: [...]}`.
513
+ - `format_tool_output`: produces `{role: 'tool', tool_call_id: ..., content: ...}`.
514
+ - `parse_tool_call`: extracts `function.name`, `function.arguments`, `id`.
515
+ - `process_response`: reads `response['choices'][0]['message']` and `response['choices'][0]['tool_calls']`.
516
+
517
+ #### Anthropic
518
+
519
+ - **Module**: `LLM::Anthropic`
520
+ - **TAG**: `'anthropic'`, **DEFAULT_MODEL**: `'claude-sonnet-4-5'`
521
+ - **API**: `client.messages(parameters:)`.
522
+ - **No embeddings** — `embed_query` raises an exception.
523
+ - `extra_options` adds `max_tokens` (default 1000) and maps `format` to `response_format`.
524
+ - `client` method creates an `Anthropic::Client` directly (ignores URL from options).
525
+ - Tool definitions use `type: 'custom'` (not `'function'`) and `input_schema` (not `parameters`).
526
+ - `format_tool_call`: produces `{role: 'assistant', content: [{type:'tool_use', ...}]}`.
527
+ - `format_tool_output`: produces `{role: 'user', content: [{type:'tool_result', tool_use_id:..., content:...}]}`.
528
+ - `process_response`: iterates `response[:content]`, handling `text`, `reasoning`, `tool_use`, and `web_search_call` types. Tool calls are processed **one at a time** per content block (not batched).
529
+
530
+ #### vLLM
531
+
532
+ - **Module**: `LLM::VLLM`
533
+ - **TAG**: `'vllm'`, **DEFAULT_MODEL**: `'vllm'`
534
+ - Includes `ResponsesMethods` (same as the Responses backend).
535
+ - Only override: `parse_tool_call` — calls `super` then strips a regex pattern (`/[^a-zA-Z_]+channel[^a-zA-Z_]+[a-zA-Z_]+/`) from the tool name to clean up artifacts from vLLM's tool name generation.
536
+
537
+ #### Ollama
538
+
539
+ - **Module**: `LLM::OLlama`
540
+ - **TAG**: `'ollama'`, **DEFAULT_MODEL**: `'llama3.1'`
541
+ - **API**: `Ollama.new(credentials: {address:, bearer_token:})` → `client.chat(parameters)`.
542
+ - Sets `parameters[:stream] = false` explicitly.
543
+ - Tool definitions use the OpenAI-like `{type: :function, function: {...}}` format.
544
+ - `update_meta`: **returns nil** (Ollama doesn't provide token usage in a standard way).
545
+ - `reasoning`: **returns nil** (no reasoning extraction).
546
+ - `parse_tool_call`: generates ID from `name + '_' + Misc.digest(arguments)` if no ID provided.
547
+ - `process_response`: iterates over `responses` (array of response chunks), handles both `response['tool_calls']` and `response['message']['tool_calls']`.
548
+ - Embeddings: calls `client.request('api/embed', parameters)`.
549
+
550
+ #### OpenWebUI
551
+
552
+ - **Module**: `LLM::OpenWebUI`
553
+ - **TAG**: `'openwebui'`, **DEFAULT_MODEL**: `'llama3.1'`
554
+ - Includes `OpenAIMethods` (reuses all OpenAI overrides).
555
+ - `client` method: returns a **plain Hash** (not a client object) with `base_url`, `key`, `model`, `method`, `action`.
556
+ - `query` method: uses `RestClient.post` directly (not the OpenAI gem). Sets `verify_ssl: false`, configures timeouts.
557
+ - `parse_tool_call`: similar to OpenAI but generates ID from `name + '_' + Misc.digest(arguments)`.
558
+
559
+ #### HuggingFace
560
+
561
+ - **Module**: `LLM::Huggingface`
562
+ - **TAG**: `'huggingface'`, **DEFAULT_MODEL**: `nil`
563
+ - **API**: Local Python inference via `ScoutPython` → `CausalModel`.
564
+ - `prepare_client`: loads a Python causal language model. Supports extensive model options (`checkpoint`, `dir`, `chat_template`, `generation_kwargs`, `torch_dtype`, `device_map`, etc.).
565
+ - `query`: calls `client.chat(messages, formatted_tools, parameters)` and converts Python dict result to Ruby hash.
566
+ - `reasoning`: extracts `message[:thinking]` (thinking/reasoning traces from models like QwQ).
567
+ - `tools` method override: filters out messages with `role == 'tool'` that have `tool_call_id` or `name` before collecting tool definitions from chat.
568
+ - Embeddings: loads model with `task: 'Embedding'` and calls `model.eval(text)` or `model.eval_list(text)`.
569
+
570
+ #### Relay
571
+
572
+ - **Module**: `LLM::Relay`
573
+ - **Does NOT include `Backend::ClassMethods`** — fully standalone.
574
+ - `ask` method: serializes question + options to a temp JSON file, SCPs it to a remote server, polls for the response.
575
+ - No tool calling, no formatting, no embeddings — all delegated to the remote server.
576
+
577
+ #### Bedrock
578
+
579
+ - **Module**: `LLM::Bedrock`
580
+ - **Does NOT include `Backend::ClassMethods`** — fully standalone.
581
+ - Uses `Aws::BedrockRuntime::Client`.
582
+ - Supports two invocation types: `:messages` (structured messages) and `:prompt` (plain text prompt).
583
+ - Tool calling: inline `while` loop (not recursive `chain_tools`).
584
+ - Embeddings: uses `amazon.titan-embed-text-v1` by default.
585
+
586
+ ---
587
+
588
+ ## 6. Integration Points
589
+
590
+ ### 6.1 How `prepare_prompt` Is Called Before Inference
591
+
592
+ In `Backend#ask` (the shared template method), after preparing the client:
593
+
594
+ ```ruby
595
+ prompt = Chat.prepare_prompt(messages, prompt_strategies)
596
+ ```
597
+
598
+ `Chat.prepare_prompt` is defined in `lib/scout/llm/chat/prompt.rb`:
599
+
600
+ ```ruby
601
+ def self.prepare_prompt(prompt, prompt_strategies = nil)
602
+ return prompt_strategies.call(prompt) if Proc === prompt_strategies
603
+ prompt_strategies = DEFAULT_CONTEXT_STRATEGY if prompt_strategies.nil?
604
+ prompt_strategies = prompt_strategies.split(',') if String === prompt_strategies
605
+ prompt_strategies.each do |strategy|
606
+ prompt = case strategy
607
+ when 'shorten_tools' then Chat.shorten_tools(prompt)
608
+ when 'none' then prompt
609
+ else strategy_proc = REGISTERED_STRATEGIES[strategy]
610
+ strategy_proc.call(prompt)
611
+ end
612
+ end
613
+ prompt
614
+ end
615
+ ```
616
+
617
+ - `prompt_strategies` can be: a `Proc` (called directly), a comma-separated string of named strategies, or an array.
618
+ - If `nil`, uses `DEFAULT_CONTEXT_STRATEGY`.
619
+ - Registered strategies can be added to `REGISTERED_STRATEGIES`.
620
+ - `'shorten_tools'` is a built-in strategy that condenses tool definitions.
621
+ - `'none'` is a pass-through.
622
+
623
+ ### 6.2 How Tools Are Passed to the Backend
624
+
625
+ Tools enter the system through multiple channels and are merged in `Backend#tools`:
626
+
627
+ ```ruby
628
+ def tools(messages, options)
629
+ tools = options.delete :tools # 1. Explicit tools from options
630
+
631
+ # Normalize Array → Hash keyed by name
632
+ case tools
633
+ when Array then tools = tools.inject({}){|acc, d| ... }
634
+ when nil then tools = {}
635
+ end
636
+
637
+ tools.merge!(LLM.tools messages) # 2. Tools from chat 'tool'/'mcp' messages
638
+ tools.merge!(LLM.associations messages) # 3. Associations from chat 'associate' messages
639
+
640
+ tools
641
+ end
642
+ ```
643
+
644
+ **Sources of tools:**
645
+
646
+ | Source | How |
647
+ |---|---|
648
+ | `options[:tools]` | Explicitly passed as an Array of tool definitions. |
649
+ | Chat messages with `role: 'tool'` | `LLM.tools(messages)` extracts workflow/task tool definitions from messages. Supports local workflows, remote workflows, and specific tasks. |
650
+ | Chat messages with `role: 'mcp'` | `LLM.tools(messages)` connects to MCP (Model Context Protocol) servers and loads their tool definitions. Supports both HTTP and stdio transports. |
651
+ | Chat messages with `role: 'introduce'` | `LLM.tools(messages)` loads all tasks from a workflow as tool definitions. |
652
+ | Chat messages with `role: 'associate'` | `LLM.associations(messages)` extracts knowledge-base association tools. |
653
+
654
+ The resulting `tools` is a Hash: `{ tool_name => [obj, definition] }` where
655
+ `obj` is a `Proc`, `Workflow`, `KnowledgeBase`, `String` (workflow name), or
656
+ `nil` (use block).
657
+
658
+ ### 6.3 How Responses Are Parsed
659
+
660
+ Each backend overrides `process_response(messages, response, tools, options, &block)`:
661
+
662
+ **Default (Responses API)**:
663
+ 1. Extract `function_call` / `mcp_call` items from `response['output']`.
664
+ 2. Parse them into `{name:, arguments:, id:}` via `parse_tool_call`.
665
+ 3. Execute all tool calls via `LLM.process_calls(tools, tool_calls, &block)`.
666
+ 4. Build output array: for each `response['output']` item, produce the appropriate message(s):
667
+ - `message` → `{role: 'assistant', content: text}`
668
+ - `function_call` / `mcp_call` → the tool call + tool output pair
669
+ - `image_generation_call` → `{role: 'image', content: output}`
670
+ - `reasoning` → skipped
671
+ - `web_search_call` → skipped
672
+ 5. Set `options[:previous_response_id]` from `response['id']` for threading.
673
+
674
+ **OpenAI (Chat Completions)**:
675
+ 1. Check for `response['error']` → raise if present.
676
+ 2. Extract `response['choices'][0]['message']`.
677
+ 3. Check for `response['choices'][0]['tool_calls']` or nested.
678
+ 4. If tool calls exist: parse and execute via `LLM.process_calls`.
679
+ 5. If no tool calls: return `[{role: 'assistant', content: ...}]`.
680
+
681
+ **Anthropic**:
682
+ 1. Iterate `response[:content]` array.
683
+ 2. For each block: `text` → assistant message; `tool_use` → parse + execute; `reasoning` / `web_search_call` → skip.
684
+ 3. Tool calls processed individually (not batched).
685
+
686
+ **Ollama**:
687
+ 1. Iterate response chunks.
688
+ 2. Skip empty assistant messages with no tool calls.
689
+ 3. If `tool_calls` present: parse and execute.
690
+ 4. Otherwise return the message.
691
+
692
+ **HuggingFace**:
693
+ 1. Extract `response['message']['content']`.
694
+ 2. Check `response['message']['tool_calls']`.
695
+ 3. Build output: assistant text (if content), then tool call results (if any).
696
+
697
+ ### 6.4 `LLM.process_calls` — The Tool Execution Engine
698
+
699
+ Located in `lib/scout/llm/tools/call.rb`. For each tool call:
700
+
701
+ 1. **Look up** the tool by name in the `tools` hash → `[obj, definition]`.
702
+ 2. **Merge defaults** from `definition[:parameters][:defaults]` into arguments.
703
+ 3. **Execute** the tool:
704
+ - `Proc` → `obj.call(function_name, function_arguments)`
705
+ - `String` (workflow name) → load workflow and call task
706
+ - `Workflow` → `call_workflow(obj, function_name, arguments)`
707
+ - `KnowledgeBase` → `call_knowledge_base(obj, function_name, arguments)`
708
+ - `else` → `block.call(function_name, function_arguments)` if block given
709
+ 4. **Process results**: handle `Step` (run/produce job), `LLM::Agent` (ask agent), `String`, `IO`, `TSV::Dumper`, `Exception`, `nil`.
710
+ 5. **Content length guard**: if result > `max_content_length` (100k chars), replace with exception message (optionally referencing persisted Step path).
711
+ 6. **Return**: pairs of `[function_call_message, function_call_output_message]`.
712
+ 7. **Agent parallelism**: agents returned by tools are asked in parallel using `Open.traverse` with configurable CPU count (`Scout::Config.get(:cpus, :agent_ask, default: 3)`).
713
+
714
+ ---
715
+
716
+ ## 7. Key Design Patterns
717
+
718
+ ### 7.1 Strategy Pattern for Providers
719
+
720
+ Each backend module is a **strategy** for LLM inference. The `LLM.ask` entry
721
+ point selects which strategy to use via the `backend` option. All strategies
722
+ share the same interface: `ask(question, options, &block)`.
723
+
724
+ ### 7.2 Template Method for `ask`
725
+
726
+ `Backend::ClassMethods#ask` is a **template method**: it defines the skeleton
727
+ of the algorithm (prepare → query → process → chain → return), while
728
+ delegating specific steps to overridable methods:
729
+
730
+ | Step | Overridable Method | Default (Responses) |
731
+ |---|---|---|
732
+ | Create client | `client(options)` | `OpenAI::Client.new(...)` |
733
+ | Prepare client | `prepare_client(options, messages)` | Standard flow |
734
+ | Prepare prompt | `Chat.prepare_prompt(...)` | External (shared) |
735
+ | Format messages | `format_messages(messages)` | Responses API format |
736
+ | Collect tools | `tools(messages, options)` | Standard flow |
737
+ | Execute query | `query(client, messages, tools, params)` | `client.responses.create(...)` |
738
+ | Parse response | `process_response(...)` | Responses API parsing |
739
+ | Tool loop | `chain_tools(...)` | Shared (recursive) |
740
+ | Update metadata | `update_meta(response, meta)` | Token counting |
741
+ | Extract reasoning | `reasoning(response)` | From `choices[0].message.reasoning_content` |
742
+
743
+ ### 7.3 Module Composition via `prepend` + `include`
744
+
745
+ The backend composition pattern is the core architectural choice:
746
+
747
+ ```ruby
748
+ class << self
749
+ prepend ProviderMethods # overrides (higher priority)
750
+ include Backend::ClassMethods # shared implementation (lower priority)
751
+ end
752
+ ```
753
+
754
+ This is Ruby's MRO (Method Resolution Order) used as a dependency injection
755
+ mechanism. The comment block at the top of `default.rb` explains why:
756
+
757
+ > *This file used to expose its API by doing `extend Backend` in each backend
758
+ > module. That worked for simple overrides, but other backends (notably
759
+ > OpenWebUI) started **copying** singleton methods from another backend.
760
+ > Copying/binding singleton methods breaks Ruby's internal dispatch because
761
+ > internal calls stay bound to the original receiver.*
762
+
763
+ The `prepend` approach ensures that when the shared `ask` method calls
764
+ `self.query(...)`, Ruby dispatches to the provider's override, not the default.
765
+
766
+ ### 7.4 IndiferentHash — Pervasive Option Normalization
767
+
768
+ Throughout the codebase, `IndiferentHash.process_options` is used to extract
769
+ and set defaults for option keys in a symbol/string-agnostic way. This is a
770
+ convention that makes the API tolerant of different key types (`:model` vs
771
+ `'model'`).
772
+
773
+ ### 7.5 Chat as a Universal Message Format
774
+
775
+ Internally, all backends use a common message format with roles like
776
+ `system`, `user`, `assistant`, `tool`, `function_call`,
777
+ `function_call_output`, `meta`, `websearch`, `image`, `pdf`,
778
+ `previous_response_id`. The `format_messages` method (overridden per provider)
779
+ transforms these into API-specific formats. This decoupling allows the same
780
+ chat history to be sent to any provider.
781
+
782
+ ### 7.6 Relay Pattern — Remote Delegation
783
+
784
+ The `relay` option in `ask` and the standalone `LLM::Relay` module implement a
785
+ **remote delegation pattern**: serialize the full conversation + options, send
786
+ them to a remote server via SCP, and poll for the response. This allows running
787
+ LLM queries on a machine with different credentials, models, or network
788
+ access.
789
+
790
+ ### 7.7 Persistence / Caching Layer
791
+
792
+ `LLM.ask` wraps the backend call in `Persist.persist(...)`:
793
+
794
+ ```ruby
795
+ res = Persist.persist(endpoint, :json, prefix: "LLM ask",
796
+ other: options.merge(messages: messages),
797
+ persist: persist, dir: Scout.var.cache.ask) do
798
+ # backend dispatch happens here
799
+ end
800
+ ```
801
+
802
+ This caches responses based on the endpoint + options + messages, avoiding
803
+ redundant API calls. Can be disabled with `persist: false`.
804
+
805
+ ### 7.8 Endpoint Configuration Files
806
+
807
+ ```ruby
808
+ endpoint ||= Scout::Config.get(:endpoint, :ask, :llm, env: 'ASK_ENDPOINT,...')
809
+ if endpoint && Scout.etc.AI[endpoint].find_with_extension(:yaml).exists?
810
+ options = IndiferentHash.add_defaults options, Scout.etc.AI[endpoint].yaml
811
+ end
812
+ ```
813
+
814
+ Named endpoints can be defined as YAML files in `~/.scout/etc/AI/<name>.yaml`,
815
+ providing preset configurations (model, URL, key, backend, etc.).
816
+
817
+ ---
818
+
819
+ ## Appendix A: File Reference
820
+
821
+ | File | Lines | Description |
822
+ |---|---|---|
823
+ | `lib/scout/llm/ask.rb` | ~126 | Top-level `LLM.ask` entry point, backend dispatch, persistence. |
824
+ | `lib/scout/llm/backends/default.rb` | ~610 | `Backend` module with `ClassMethods` — shared implementation. |
825
+ | `lib/scout/llm/backends/responses.rb` | ~26 | Responses API backend (default, no overrides). |
826
+ | `lib/scout/llm/backends/openai.rb` | ~110 | OpenAI Chat Completions backend. |
827
+ | `lib/scout/llm/backends/anthropic.rb` | ~107 | Anthropic Messages API backend. |
828
+ | `lib/scout/llm/backends/vllm.rb` | ~28 | vLLM backend (Responses + tool name cleanup). |
829
+ | `lib/scout/llm/backends/ollama.rb` | ~130 | Ollama native API backend. |
830
+ | `lib/scout/llm/backends/openwebui.rb` | ~85 | OpenWebUI REST backend (OpenAI-compatible). |
831
+ | `lib/scout/llm/backends/huggingface.rb` | ~190 | HuggingFace local model backend (Python subprocess). |
832
+ | `lib/scout/llm/backends/relay.rb` | ~38 | Remote relay backend (SCP-based). |
833
+ | `lib/scout/llm/backends/bedrock.rb` | ~110 | AWS Bedrock backend (standalone). |
834
+ | `lib/scout/llm/tools/call.rb` | ~150 | `LLM.process_calls` — tool execution engine. |
835
+ | `lib/scout/llm/chat/prompt.rb` | ~140 | `Chat.prepare_prompt` — prompt strategy pipeline. |
836
+ | `lib/scout/llm/chat/process/tools.rb` | ~250+ | `LLM.tools` / `LLM.associations` — tool extraction from chat. |