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
|
@@ -1,50 +1,220 @@
|
|
|
1
|
-
|
|
1
|
+
# query() calls ScoutPython.dict2hash, which is defined by the scout/python
|
|
2
|
+
# support (scout-rig), not by this repo: require it up-front so the tool loop
|
|
3
|
+
# path does not hit NameError.
|
|
4
|
+
# ScoutCoder: 'scout/python' alone loads the module but PyCall::List (needed by
|
|
5
|
+
# ScoutPython.py2ruby_a inside dict2hash) is only defined by pycall/list, so
|
|
6
|
+
# require that too, otherwise the offline tool-loop path raises NameError.
|
|
7
|
+
require 'scout/python'
|
|
8
|
+
require 'pycall/list'
|
|
9
|
+
require_relative 'default'
|
|
2
10
|
|
|
3
11
|
module LLM
|
|
4
|
-
module
|
|
12
|
+
module HuggingfaceMethods
|
|
13
|
+
MODEL_OPTION_KEYS = %i[
|
|
14
|
+
task checkpoint dir
|
|
15
|
+
chat_template chat_template_kwargs generation_kwargs
|
|
16
|
+
response_parser tool_argument
|
|
17
|
+
tokenizer_args tokenizer_options
|
|
18
|
+
training_args training_options
|
|
19
|
+
trust_remote_code torch_dtype device_map device
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
def model_options(options = {})
|
|
23
|
+
options = IndiferentHash.setup(options.dup)
|
|
24
|
+
model_options = IndiferentHash.pull_keys(options, :model) || {}
|
|
25
|
+
|
|
26
|
+
MODEL_OPTION_KEYS.each do |key|
|
|
27
|
+
model_options[key] = options[key] if options.include?(key)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
model_options[:model] ||= Scout::Config.get(:model, :huggingface, env: 'HUGGINGFACE_MODEL,HF_MODEL')
|
|
31
|
+
model_options
|
|
32
|
+
end
|
|
5
33
|
|
|
6
|
-
def
|
|
34
|
+
def model(model_options = {})
|
|
7
35
|
require 'scout/model/python/huggingface'
|
|
8
36
|
require 'scout/model/python/huggingface/causal'
|
|
9
37
|
|
|
10
|
-
|
|
11
|
-
|
|
38
|
+
model_options = IndiferentHash.setup(model_options.dup)
|
|
39
|
+
model_options = IndiferentHash.add_defaults(model_options, task: 'CausalLM')
|
|
40
|
+
|
|
41
|
+
model_name = IndiferentHash.process_options(model_options, :model)
|
|
42
|
+
dir = model_options[:dir]
|
|
43
|
+
|
|
44
|
+
CausalModel.new model_name, dir, model_options
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def prepare_client(options, messages = nil)
|
|
48
|
+
client = IndiferentHash.process_options(options, :client)
|
|
49
|
+
|
|
50
|
+
if client.nil?
|
|
51
|
+
model_options = self.model_options(options)
|
|
52
|
+
Log.debug "Client options: #{model_options.inspect}"
|
|
53
|
+
|
|
54
|
+
client = self.model(model_options)
|
|
55
|
+
options[:model] ||= model_options[:model]
|
|
56
|
+
else
|
|
57
|
+
Log.debug "Reusing client: #{Log.fingerprint client}"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
client
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def query(client, messages, tools = [], parameters = {})
|
|
64
|
+
formatted_tools = format_tool_definitions(tools)
|
|
65
|
+
parameters[:generation_kwargs] ||= IndiferentHash.pull_keys parameters, :generation_kwargs
|
|
66
|
+
parameters[:chat_template] ||= IndiferentHash.pull_keys parameters, :chat_template
|
|
67
|
+
parameters = parameters.keys_to_sym
|
|
68
|
+
response = client.chat(messages, formatted_tools, parameters)
|
|
69
|
+
# ScoutCoder: ScoutPython.dict2hash only works on real PyCall dicts; fake
|
|
70
|
+
# clients in tests replay plain Ruby Hashes (which have no #get), so only
|
|
71
|
+
# convert when the object actually is a python wrapper.
|
|
72
|
+
response = ScoutPython.dict2hash(response) if defined?(PyCall) && PyCall === response
|
|
73
|
+
response = response.to_h if defined?(PyCall) && (PyCall::Dict === response rescue false)
|
|
74
|
+
IndiferentHash.setup({message: response})
|
|
75
|
+
rescue
|
|
76
|
+
Log.debug 'Input parameters: ' + "\n" + JSON.pretty_generate(parameters.except(:tools))
|
|
77
|
+
raise $!
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def format_tool_definitions(tools)
|
|
81
|
+
return [] if tools.nil?
|
|
82
|
+
|
|
83
|
+
tools.values.collect do |obj, definition|
|
|
84
|
+
definition = obj if Hash === obj
|
|
85
|
+
definition = IndiferentHash.setup(definition)
|
|
86
|
+
|
|
87
|
+
definition = case definition[:function]
|
|
88
|
+
when Hash
|
|
89
|
+
definition
|
|
90
|
+
else
|
|
91
|
+
{ type: :function, function: definition }
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
definition[:function][:parameters].delete(:defaults) if definition.dig(:function, :parameters)
|
|
95
|
+
|
|
96
|
+
definition
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def format_tool_call(message)
|
|
101
|
+
tool_call = IndiferentHash.setup(JSON.parse(message[:content]))
|
|
102
|
+
arguments = tool_call.delete('arguments') || tool_call.dig('function', 'arguments') || {}
|
|
103
|
+
arguments = JSON.parse(arguments) rescue arguments if String === arguments
|
|
104
|
+
id = tool_call.delete('call_id') || tool_call.delete('id')
|
|
105
|
+
name = tool_call.delete('name') || tool_call.dig('function', 'name')
|
|
106
|
+
|
|
107
|
+
{
|
|
108
|
+
role: 'assistant',
|
|
109
|
+
tool_calls: [IndiferentHash.setup({
|
|
110
|
+
type: 'function',
|
|
111
|
+
id: id,
|
|
112
|
+
function: {
|
|
113
|
+
name: name,
|
|
114
|
+
arguments: arguments
|
|
115
|
+
}
|
|
116
|
+
})]
|
|
117
|
+
}
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def format_tool_output(message, last_id = nil)
|
|
121
|
+
info = IndiferentHash.setup(JSON.parse(message[:content]))
|
|
122
|
+
id = info.delete('call_id') || info.delete('id') || last_id
|
|
123
|
+
name = info.delete('name')
|
|
124
|
+
content = info.delete('content')
|
|
125
|
+
|
|
126
|
+
{
|
|
127
|
+
role: 'tool',
|
|
128
|
+
name: name,
|
|
129
|
+
content: content,
|
|
130
|
+
tool_call_id: id,
|
|
131
|
+
}.compact
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def parse_tool_call(info)
|
|
135
|
+
info = IndiferentHash.setup(info)
|
|
136
|
+
function = IndiferentHash.setup(info[:function] || {})
|
|
137
|
+
|
|
138
|
+
arguments = function[:arguments] || info[:arguments] || info[:parameters] || {}
|
|
139
|
+
arguments = JSON.parse(arguments) rescue arguments if String === arguments
|
|
12
140
|
|
|
13
|
-
|
|
141
|
+
name = function[:name] || info[:name]
|
|
142
|
+
id = info[:id] || info[:call_id] || info[:tool_call_id] || (name.to_s + '_' + Misc.digest(arguments.to_json))
|
|
143
|
+
|
|
144
|
+
{ arguments: arguments, id: id, name: name }
|
|
14
145
|
end
|
|
15
146
|
|
|
16
|
-
def
|
|
17
|
-
|
|
18
|
-
|
|
147
|
+
def process_response(messages, response, tools, options, &block)
|
|
148
|
+
message = response['message']
|
|
149
|
+
content = message['content']
|
|
150
|
+
|
|
151
|
+
output = []
|
|
152
|
+
output << IndiferentHash.setup(role: :assistant, content: content) if String === content && !content.empty?
|
|
19
153
|
|
|
20
|
-
|
|
154
|
+
tool_calls = Array(message[:tool_calls]).collect do |tool_call|
|
|
155
|
+
parse_tool_call(tool_call)
|
|
156
|
+
end.compact
|
|
21
157
|
|
|
22
|
-
|
|
158
|
+
if tool_calls.any?
|
|
159
|
+
output.concat LLM.process_calls(tools, tool_calls, &block)
|
|
160
|
+
elsif output.empty?
|
|
161
|
+
output << IndiferentHash.setup(role: :assistant, content: '') if message.include?(:content)
|
|
162
|
+
end
|
|
23
163
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
164
|
+
output
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def tools(messages, options)
|
|
168
|
+
tools = options.delete :tools
|
|
169
|
+
|
|
170
|
+
case tools
|
|
171
|
+
when Array
|
|
172
|
+
tools = tools.inject({}) do |acc, definition|
|
|
173
|
+
IndiferentHash.setup definition
|
|
174
|
+
name = definition.dig('name') || definition.dig('function', 'name')
|
|
175
|
+
acc.merge(name => definition)
|
|
32
176
|
end
|
|
177
|
+
when nil
|
|
178
|
+
tools = {}
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
chat_messages = messages.reject do |message|
|
|
182
|
+
message[:role].to_s == 'tool' && (message.include?(:tool_call_id) || message.include?(:name))
|
|
33
183
|
end
|
|
34
184
|
|
|
35
|
-
|
|
36
|
-
|
|
185
|
+
tools.merge!(LLM.tools(chat_messages))
|
|
186
|
+
tools.merge!(LLM.associations(chat_messages))
|
|
187
|
+
|
|
188
|
+
Log.high "Tools: #{Log.fingerprint tools.keys}" if tools
|
|
37
189
|
|
|
38
|
-
|
|
190
|
+
tools
|
|
39
191
|
end
|
|
40
192
|
|
|
41
|
-
def
|
|
42
|
-
|
|
43
|
-
|
|
193
|
+
def reasoning(response, current_meta = nil)
|
|
194
|
+
response = IndiferentHash.setup(response)
|
|
195
|
+
message = IndiferentHash.setup(response[:message] || response)
|
|
196
|
+
reasoning_content = message[:thinking]
|
|
197
|
+
reasoning_content = reasoning_content.gsub("\n", ' ') if String === reasoning_content
|
|
198
|
+
Log.medium "Reasoning:\n" + Log.color(:cyan, reasoning_content) if reasoning_content
|
|
199
|
+
reasoning_content
|
|
200
|
+
end
|
|
44
201
|
|
|
45
|
-
|
|
202
|
+
def embed(text, options = {})
|
|
203
|
+
model_options = self.model_options(options)
|
|
204
|
+
model_options[:task] = 'Embedding'
|
|
205
|
+
model = self.model(model_options)
|
|
46
206
|
|
|
47
207
|
(Array === text) ? model.eval_list(text) : model.eval(text)
|
|
48
208
|
end
|
|
49
209
|
end
|
|
210
|
+
|
|
211
|
+
module Huggingface
|
|
212
|
+
TAG = 'huggingface'
|
|
213
|
+
DEFAULT_MODEL = nil
|
|
214
|
+
|
|
215
|
+
class << self
|
|
216
|
+
prepend HuggingfaceMethods
|
|
217
|
+
include Backend::ClassMethods
|
|
218
|
+
end
|
|
219
|
+
end
|
|
50
220
|
end
|
|
@@ -59,7 +59,6 @@ module LLM
|
|
|
59
59
|
end
|
|
60
60
|
|
|
61
61
|
def process_response(messages, responses, tools, options, &block)
|
|
62
|
-
Log.debug "Respose: #{Log.fingerprint responses}"
|
|
63
62
|
output = responses.collect do |response|
|
|
64
63
|
|
|
65
64
|
message = IndiferentHash.setup response['message']
|
|
@@ -115,6 +114,19 @@ module LLM
|
|
|
115
114
|
info['role'] = 'tool'
|
|
116
115
|
info
|
|
117
116
|
end
|
|
117
|
+
|
|
118
|
+
# ScoutCoder: Backend::ClassMethods#ask assigns into the meta hash
|
|
119
|
+
# (meta['timestamp'] = ...) right after update_meta, so a backend override
|
|
120
|
+
# MUST return a Hash; returning nil crashes the request when log_response
|
|
121
|
+
# is on (the default). Ollama does not report OpenAI-style usage here, so an
|
|
122
|
+
# empty hash keeps the request working.
|
|
123
|
+
def update_meta(response, current_meta = nil)
|
|
124
|
+
{}
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def reasoning(response, current_meta = nil)
|
|
128
|
+
end
|
|
129
|
+
|
|
118
130
|
end
|
|
119
131
|
|
|
120
132
|
module OLlama
|
|
@@ -17,20 +17,22 @@ module LLM
|
|
|
17
17
|
include OpenAIMethods
|
|
18
18
|
|
|
19
19
|
def client(options, messages = nil)
|
|
20
|
-
url, key, model = IndiferentHash.process_options options,
|
|
21
|
-
:url, :key, :model
|
|
20
|
+
url, key, model, request_timeout = IndiferentHash.process_options options,
|
|
21
|
+
:url, :key, :model, :request_timeout,
|
|
22
|
+
request_timeout: 12000
|
|
22
23
|
|
|
23
24
|
{
|
|
24
25
|
base_url: url,
|
|
25
26
|
key: key,
|
|
26
27
|
model: model,
|
|
27
28
|
method: :post,
|
|
29
|
+
request_timeout: request_timeout,
|
|
28
30
|
action: 'chat/completions'
|
|
29
31
|
}
|
|
30
32
|
end
|
|
31
33
|
|
|
32
34
|
def query(client, messages, tools = [], parameters = {})
|
|
33
|
-
base_url, key, model, method, action = IndiferentHash.process_options client.dup, :base_url, :key, :model, :method, :action
|
|
35
|
+
base_url, key, model, method, action, timeout = IndiferentHash.process_options client.dup, :base_url, :key, :model, :method, :action, :request_timeout
|
|
34
36
|
url = File.join(base_url, action.to_s)
|
|
35
37
|
|
|
36
38
|
parameters = parameters.dup
|
|
@@ -38,19 +40,24 @@ module LLM
|
|
|
38
40
|
parameters[:tools] = format_tool_definitions(tools) if tools && tools.any?
|
|
39
41
|
parameters[:messages] = messages
|
|
40
42
|
parameters[:verify_ssl] = false
|
|
43
|
+
parameters[:timeout] = timeout.to_i if timeout
|
|
44
|
+
parameters[:read_timeout] = timeout.to_i if timeout
|
|
45
|
+
parameters[:open_timeout] = timeout.to_i if timeout
|
|
41
46
|
|
|
42
47
|
headers = IndiferentHash.setup({ 'Authorization' => "Bearer #{key}", 'Content-Type' => 'application/json' })
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
48
|
+
Misc.insist do
|
|
49
|
+
response = case method.to_sym
|
|
50
|
+
when :post
|
|
51
|
+
RestClient.post(url, parameters.to_json, headers)
|
|
52
|
+
else
|
|
53
|
+
raise 'Get not supported'
|
|
54
|
+
end
|
|
49
55
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
56
|
+
if response.body.nil? || response.body == 'null'
|
|
57
|
+
raise "No response body: #{JSON.pretty_generate(response)}"
|
|
58
|
+
else
|
|
59
|
+
JSON.parse(response.body)
|
|
60
|
+
end
|
|
54
61
|
end
|
|
55
62
|
end
|
|
56
63
|
|
|
@@ -3,33 +3,33 @@ require_relative '../chat'
|
|
|
3
3
|
|
|
4
4
|
module LLM
|
|
5
5
|
module Relay
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
6
|
+
def self.upload(server, file)
|
|
7
|
+
id = Misc.digest(Open.read(file))
|
|
8
|
+
CMD.cmd("scp #{file} #{server}:.scout/var/ask/#{ id }.json")
|
|
9
|
+
id
|
|
10
|
+
end
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
12
|
+
def self.gather(server, id)
|
|
13
|
+
TmpFile.with_file do |file|
|
|
14
|
+
begin
|
|
15
|
+
CMD.cmd("scp #{server}:.scout/var/ask/reply/#{ id }.json #{ file }")
|
|
16
|
+
JSON.parse(Open.read(file))
|
|
17
|
+
rescue
|
|
18
|
+
sleep 1
|
|
19
|
+
retry
|
|
20
|
+
end
|
|
20
21
|
end
|
|
21
22
|
end
|
|
22
|
-
end
|
|
23
23
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
24
|
+
def self.ask(question, options = {}, &block)
|
|
25
|
+
server = IndiferentHash.process_options options, :server
|
|
26
|
+
server ||= Scout::Config.get :server, :ask_relay, :relay, :ask, env: 'ASK_ENDPOINT,LLM_ENDPOINT', default: :openai
|
|
27
27
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
28
|
+
options[:question] = question
|
|
29
|
+
TmpFile.with_file(options.to_json) do |file|
|
|
30
|
+
id = upload(server, file)
|
|
31
|
+
gather(server, id)
|
|
32
|
+
end
|
|
32
33
|
end
|
|
33
34
|
end
|
|
34
|
-
end
|
|
35
35
|
end
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
module Chat
|
|
2
|
+
# Render the human readable message for a malformed or unusable agent_meta
|
|
3
|
+
# receipt. This is a plain module function, not a dedicated error class: the
|
|
4
|
+
# raised exception is ScoutException and the structured facts (chat path,
|
|
5
|
+
# output address, call id, tool name, raw entry, reference) always travel in
|
|
6
|
+
# the on_error callback `reference` Hash built by Chat.agent_meta_error_reference.
|
|
7
|
+
def self.agent_meta_error_message(reason, chat_path: nil, output_address: nil, call_id: nil, tool_name: nil, reference: nil)
|
|
8
|
+
message = +"agent_meta receipt #{reason}"
|
|
9
|
+
message << " in chat #{chat_path}" if chat_path
|
|
10
|
+
message << " at output #{Array(output_address).inspect}" unless output_address.nil?
|
|
11
|
+
details = []
|
|
12
|
+
details << "call #{call_id}" if call_id
|
|
13
|
+
details << "tool #{tool_name}" if tool_name
|
|
14
|
+
message << " (#{details * ', '})" unless details.empty?
|
|
15
|
+
message << " reference #{reference}" if reference
|
|
16
|
+
message
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Delegated-agent inference evidence embedded in function_call_output
|
|
20
|
+
# envelopes.
|
|
21
|
+
#
|
|
22
|
+
# When a tool returns an LLM::Agent, LLM.process_calls embeds the child
|
|
23
|
+
# agent's inference evidence in the parent function_call_output JSON under
|
|
24
|
+
# the `meta` key. These records are portable evidence of child inference
|
|
25
|
+
# events and child producer jobs; they are NOT ordinary parent-chat meta
|
|
26
|
+
# messages and must never be appended to the parent Chat (that would
|
|
27
|
+
# corrupt checkpoint bookkeeping). Everything in this module is read-only
|
|
28
|
+
# provenance analysis: plain Arrays and Hashes in and out, no wrapper
|
|
29
|
+
# classes, no mutation of the inspected chat.
|
|
30
|
+
#
|
|
31
|
+
# Two receipt formats are accepted (the envelope is generic: any tool
|
|
32
|
+
# output may carry one, so nothing here special-cases a tool name such as
|
|
33
|
+
# `ask`):
|
|
34
|
+
#
|
|
35
|
+
# meta (current): {"meta": [{"pt": 10, "inference_id": "w1"}, ...]}
|
|
36
|
+
# Entries are ALREADY-DESERIALIZED field Hashes (no role/content
|
|
37
|
+
# wrapper, no re-parsing). Evidence addresses use the [:meta, index]
|
|
38
|
+
# suffix, mirroring the persisted key. raw_message is nil.
|
|
39
|
+
#
|
|
40
|
+
# agent_meta (legacy): {"agent_meta": [{"role": "meta", "content": "pt=10 ..."}, ...]}
|
|
41
|
+
# Serialized meta messages; each content String is parsed with
|
|
42
|
+
# Chat.parse_meta. Evidence addresses keep the [:agent_meta, index]
|
|
43
|
+
# suffix and raw_message is {role: 'meta', content: <String>} so old
|
|
44
|
+
# data stays addressable exactly as it always was.
|
|
45
|
+
#
|
|
46
|
+
# When both keys are present in one envelope the `meta` key wins (current
|
|
47
|
+
# writer emits exactly one of the two).
|
|
48
|
+
|
|
49
|
+
# One Hash per valid agent_meta entry found across the paired tool outputs
|
|
50
|
+
# of the chat. Pairing is delegated to Chat.tool_calls; raw text is never
|
|
51
|
+
# scanned with regular expressions.
|
|
52
|
+
#
|
|
53
|
+
# Record shape:
|
|
54
|
+
#
|
|
55
|
+
# {
|
|
56
|
+
# origin: :agent_meta,
|
|
57
|
+
# meta: <IndiferentHash of meta fields>,
|
|
58
|
+
# source: <String path or nil>,
|
|
59
|
+
# output_address: <as returned by Chat.tool_calls>,
|
|
60
|
+
# evidence_address: <output_address + [:agent_meta, index]> (legacy
|
|
61
|
+
# entries; current-format entries use [:meta, index]),
|
|
62
|
+
# call_id: ...,
|
|
63
|
+
# tool_name: ...,
|
|
64
|
+
# agent_meta_index: ...,
|
|
65
|
+
# raw_message: {role: "meta", content: "..."} (legacy; nil for
|
|
66
|
+
# current-format entries, which are born deserialized)
|
|
67
|
+
# }
|
|
68
|
+
#
|
|
69
|
+
# Malformed data is skipped. When the caller supplies an Array through the
|
|
70
|
+
# `warnings` keyword, each malformed item appends one warning Hash:
|
|
71
|
+
#
|
|
72
|
+
# {
|
|
73
|
+
# origin: :agent_meta,
|
|
74
|
+
# reason: :not_an_array | :not_a_hash | :invalid_role |
|
|
75
|
+
# :invalid_content | :unparseable_meta | :empty_meta,
|
|
76
|
+
# source:, output_address:, evidence_address:,
|
|
77
|
+
# call_id:, tool_name:,
|
|
78
|
+
# agent_meta_index: (nil when the whole receipt value is malformed),
|
|
79
|
+
# evidence_address: (nil when the index is unknown),
|
|
80
|
+
# raw_entry: <the malformed item as found>
|
|
81
|
+
# }
|
|
82
|
+
#
|
|
83
|
+
# :empty_meta applies to current-format entries (deserialized field Hash
|
|
84
|
+
# with no fields). Malformed receipt data is never silently reinterpreted
|
|
85
|
+
# as provenance.
|
|
86
|
+
def self.agent_meta_evidence(chat, source: nil, warnings: nil)
|
|
87
|
+
tool_calls(chat, source: source).flat_map do |call|
|
|
88
|
+
output_info = call[:output_info]
|
|
89
|
+
next [] unless Hash === output_info
|
|
90
|
+
|
|
91
|
+
# Key presence, not truthiness: an explicit `meta: false` or
|
|
92
|
+
# `agent_meta: nil` is a present-but-malformed receipt and must warn,
|
|
93
|
+
# while an absent key simply has no receipt at all.
|
|
94
|
+
has_meta = output_info.key?('meta') || output_info.key?(:meta)
|
|
95
|
+
has_agent_meta = output_info.key?('agent_meta') || output_info.key?(:agent_meta)
|
|
96
|
+
next [] unless has_meta || has_agent_meta
|
|
97
|
+
|
|
98
|
+
# Current writer emits exactly one of the two; when both are present
|
|
99
|
+
# the current `meta` key wins and the legacy one is ignored.
|
|
100
|
+
format = has_meta ? :meta : :agent_meta
|
|
101
|
+
agent_meta = output_info['meta']
|
|
102
|
+
agent_meta = output_info[:meta] if agent_meta.nil? && output_info.key?(:meta)
|
|
103
|
+
agent_meta = output_info['agent_meta'] if format == :agent_meta && agent_meta.nil?
|
|
104
|
+
agent_meta = output_info[:agent_meta] if format == :agent_meta && agent_meta.nil?
|
|
105
|
+
|
|
106
|
+
add_warning = lambda do |reason, index, raw_entry|
|
|
107
|
+
return unless Array === warnings
|
|
108
|
+
output_address = call[:output_address]
|
|
109
|
+
evidence_address = if index.nil?
|
|
110
|
+
nil
|
|
111
|
+
elsif Array === output_address
|
|
112
|
+
output_address + [format, index]
|
|
113
|
+
else
|
|
114
|
+
[output_address, format, index]
|
|
115
|
+
end
|
|
116
|
+
warnings << {
|
|
117
|
+
origin: :agent_meta,
|
|
118
|
+
reason: reason,
|
|
119
|
+
source: source ? source.to_s : nil,
|
|
120
|
+
output_address: output_address,
|
|
121
|
+
evidence_address: evidence_address,
|
|
122
|
+
call_id: call[:call_id],
|
|
123
|
+
tool_name: call[:name],
|
|
124
|
+
agent_meta_index: index,
|
|
125
|
+
raw_entry: raw_entry
|
|
126
|
+
}
|
|
127
|
+
nil
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
unless Array === agent_meta
|
|
131
|
+
add_warning.call(:not_an_array, nil, agent_meta)
|
|
132
|
+
next []
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
agent_meta.each_with_index.collect do |entry, index|
|
|
136
|
+
unless Hash === entry
|
|
137
|
+
add_warning.call(:not_a_hash, index, entry)
|
|
138
|
+
next nil
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
meta, raw_message =
|
|
142
|
+
if format == :meta
|
|
143
|
+
# Current format: entries are already-deserialized field Hashes.
|
|
144
|
+
# No role/content wrapper and no re-parse; an entry with no
|
|
145
|
+
# fields carries no evidence and is warned about.
|
|
146
|
+
fields = IndiferentHash.setup(entry.dup)
|
|
147
|
+
[fields, nil]
|
|
148
|
+
else
|
|
149
|
+
# Legacy format: serialized {role: 'meta', content: 'k=v ...'}
|
|
150
|
+
# meta messages.
|
|
151
|
+
role = entry['role'] || entry[:role]
|
|
152
|
+
content = entry['content'] || entry[:content]
|
|
153
|
+
|
|
154
|
+
if role.to_s != 'meta'
|
|
155
|
+
add_warning.call(:invalid_role, index, entry)
|
|
156
|
+
next nil
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
unless String === content
|
|
160
|
+
add_warning.call(:invalid_content, index, entry)
|
|
161
|
+
next nil
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
[parse_meta(content), { role: role, content: content }]
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
if meta.empty?
|
|
168
|
+
reason = format == :meta ? :empty_meta : :unparseable_meta
|
|
169
|
+
add_warning.call(reason, index, entry)
|
|
170
|
+
next nil
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
output_address = call[:output_address]
|
|
174
|
+
# Without a source, Chat.tool_calls reports a bare message index; the
|
|
175
|
+
# receipt address keeps the key-mirroring suffix so it stays truthful
|
|
176
|
+
# to the persisted envelope ([:meta, index] for current data,
|
|
177
|
+
# [:agent_meta, index] for legacy data).
|
|
178
|
+
evidence_address = if Array === output_address
|
|
179
|
+
output_address + [format, index]
|
|
180
|
+
else
|
|
181
|
+
[output_address, format, index]
|
|
182
|
+
end
|
|
183
|
+
{
|
|
184
|
+
origin: :agent_meta,
|
|
185
|
+
meta: meta,
|
|
186
|
+
source: source ? source.to_s : nil,
|
|
187
|
+
output_address: output_address,
|
|
188
|
+
evidence_address: evidence_address,
|
|
189
|
+
call_id: call[:call_id],
|
|
190
|
+
tool_name: call[:name],
|
|
191
|
+
agent_meta_index: index,
|
|
192
|
+
raw_message: raw_message
|
|
193
|
+
}
|
|
194
|
+
end.compact
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# All meta evidence for a chat, with a shared `origin` field:
|
|
199
|
+
#
|
|
200
|
+
# * :chat_meta - normal persisted `meta:` messages of this chat
|
|
201
|
+
# (meta, source, meta_address in [source, index] form,
|
|
202
|
+
# message: the raw message);
|
|
203
|
+
# * :agent_meta - receipt entries from function_call_output envelopes,
|
|
204
|
+
# appended after the local records (see
|
|
205
|
+
# Chat.agent_meta_evidence).
|
|
206
|
+
#
|
|
207
|
+
# This is an analysis view only. Chat#meta, chat.role_messages(:meta), and
|
|
208
|
+
# Chat.token_totals([chat]) keep describing the local Chat and do not absorb
|
|
209
|
+
# delegated receipts.
|
|
210
|
+
#
|
|
211
|
+
# Validation asymmetry, intentional: ordinary persisted `meta:` messages are
|
|
212
|
+
# historical data with no enforced schema, so they stay permissive here (a
|
|
213
|
+
# record is included even when Chat.parse_meta yields an empty Hash) exactly
|
|
214
|
+
# as Chat.trace_chats and Chat.token_totals have always treated them. A
|
|
215
|
+
# receipt instead has a narrow, generated schema, so malformed receipt
|
|
216
|
+
# entries are skipped with a warning instead of being silently trusted.
|
|
217
|
+
def self.meta_evidence(chat, source: nil, warnings: nil)
|
|
218
|
+
local = chat.each_with_index.select do |message, _index|
|
|
219
|
+
message[:role].to_s == 'meta'
|
|
220
|
+
end.collect do |message, index|
|
|
221
|
+
{
|
|
222
|
+
origin: :chat_meta,
|
|
223
|
+
meta: parse_meta(message[:content].to_s),
|
|
224
|
+
source: source ? source.to_s : nil,
|
|
225
|
+
meta_address: source ? [source.to_s, index] : index,
|
|
226
|
+
message: message
|
|
227
|
+
}
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
local + agent_meta_evidence(chat, source: source, warnings: warnings)
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# agent_meta entries that carry a producer job reference (`job=`). Each
|
|
234
|
+
# returned record is the full evidence record with the reference added at
|
|
235
|
+
# the top level as `job:`, so both the reference value and its evidence
|
|
236
|
+
# location stay together. Job projection metas are producer references,
|
|
237
|
+
# not token events.
|
|
238
|
+
def self.agent_meta_job_references(chat, source: nil, warnings: nil)
|
|
239
|
+
agent_meta_evidence(chat, source: source, warnings: warnings)
|
|
240
|
+
.select { |record| record[:meta][:job] }
|
|
241
|
+
.collect { |record| record.merge(job: record[:meta][:job]) }
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# Uniform `reference` Hash handed to on_error for agent_meta provenance
|
|
245
|
+
# problems. Keys are always present; unavailable facts are nil. It retains
|
|
246
|
+
# the original reference/raw entry, the reason, the output and evidence
|
|
247
|
+
# addresses, the receipt entry index, the call id and tool name when known,
|
|
248
|
+
# and the chat path.
|
|
249
|
+
def self.agent_meta_error_reference(reason:, source:, output_address: nil, evidence_address: nil,
|
|
250
|
+
call_id: nil, tool_name: nil, agent_meta_index: nil,
|
|
251
|
+
raw_entry: nil, reference: nil)
|
|
252
|
+
{
|
|
253
|
+
reason: reason,
|
|
254
|
+
source: source ? source.to_s : nil,
|
|
255
|
+
output_address: output_address,
|
|
256
|
+
evidence_address: evidence_address,
|
|
257
|
+
call_id: call_id,
|
|
258
|
+
tool_name: tool_name,
|
|
259
|
+
agent_meta_index: agent_meta_index,
|
|
260
|
+
raw_entry: raw_entry,
|
|
261
|
+
reference: reference
|
|
262
|
+
}
|
|
263
|
+
end
|
|
264
|
+
end
|