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,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. |
|