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
@@ -1,50 +1,220 @@
1
- require_relative '../chat'
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 Huggingface
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 self.model(model_options)
34
+ def model(model_options = {})
7
35
  require 'scout/model/python/huggingface'
8
36
  require 'scout/model/python/huggingface/causal'
9
37
 
10
- model, task, checkpoint, dir = IndiferentHash.process_options model_options, :model, :task, :checkpoint, :dir
11
- model ||= Scout::Config.get(:model, :huggingface, env: 'HUGGINGFACE_MODEL,HF_MODEL')
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
- CausalModel.new model, dir, model_options
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 self.ask(question, options = {}, &block)
17
- model_options = IndiferentHash.pull_keys options, :model
18
- model_options = IndiferentHash.add_defaults model_options, :task => "CausalLM"
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
- model = self.model model_options
154
+ tool_calls = Array(message[:tool_calls]).collect do |tool_call|
155
+ parse_tool_call(tool_call)
156
+ end.compact
21
157
 
22
- messages = LLM.messages(question)
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
- system = []
25
- prompt = []
26
- messages.each do |message|
27
- role, content = message.values_at :role, :content
28
- if role == 'system'
29
- system << content
30
- else
31
- prompt << content
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
- parameters = options.merge(messages: messages)
36
- Log.debug "Calling client with parameters: #{Log.fingerprint parameters}"
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
- model.eval(messages)
190
+ tools
39
191
  end
40
192
 
41
- def self.embed(text, options = {})
42
- model_options = IndiferentHash.pull_keys options, :model
43
- model_options = IndiferentHash.add_defaults model_options, :task => "Embedding"
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
- model = self.model model_options
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
@@ -82,8 +82,6 @@ module LLM
82
82
  end
83
83
 
84
84
  def process_response(messages, response, tools, options, &block)
85
- Log.debug "Response: #{Log.fingerprint response}"
86
-
87
85
  raise Exception, response['error'] if response['error']
88
86
 
89
87
  message = response.dig('choices', 0, 'message')
@@ -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
- response = case method.to_sym
44
- when :post
45
- RestClient.post(url, parameters.to_json, headers)
46
- else
47
- raise 'Get not supported'
48
- end
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
- if response.body.nil? || response.body == 'null'
51
- raise "No response body: #{JSON.pretty_generate(response)}"
52
- else
53
- JSON.parse(response.body)
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
- 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
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
- 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
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
- 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
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
- options[:question] = question
29
- TmpFile.with_file(options.to_json) do |file|
30
- id = upload(server, file)
31
- gather(server, id)
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
@@ -11,7 +11,7 @@ module LLM
11
11
  end
12
12
 
13
13
  module Responses
14
- TAG = 'openai'
14
+ TAG = 'responses'
15
15
  DEFAULT_MODEL = 'gpt-5-nano'
16
16
 
17
17
  class << self
@@ -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