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,4 +1,5 @@
1
1
  require 'scout'
2
+ require 'securerandom'
2
3
  require_relative '../chat'
3
4
 
4
5
  module LLM
@@ -23,27 +24,31 @@ module LLM
23
24
  # methods (like `query` / `format_tool_call`) and Ruby will correctly dispatch
24
25
  # to the backend override.
25
26
  module Backend
27
+ class BackendException
28
+ attr_accessor :chat
29
+ end
30
+
26
31
  module ClassMethods
27
32
  #{{{ CLIENT
28
33
 
29
34
  def client(options)
30
- url, key, model, log_errors, request_timeout = IndiferentHash.process_options options,
31
- :url, :key, :model, :log_errors, :request_timeout,
32
- log_errors: true, request_timeout: 1200
35
+ url, key, model, api_version, log_errors, request_timeout = IndiferentHash.process_options options,
36
+ :url, :key, :model, :api_version, :log_errors, :request_timeout,
37
+ log_errors: true, request_timeout: 12000, api_version: 'v1'
33
38
 
34
- Object::OpenAI::Client.new(access_token: key, log_errors: log_errors, uri_base: url, request_timeout: request_timeout)
39
+ Object::OpenAI::Client.new(api_version: api_version, access_token: key, log_errors: log_errors, uri_base: url, request_timeout: request_timeout.to_i)
35
40
  end
36
41
 
37
42
  def client_options(options)
38
- url, key, model, tag, default_model, log_errors, request_timeout = IndiferentHash.process_options options,
39
- :url, :key, :model, :tag, :default_model, :log_errors, :request_timeout,
43
+ url, key, model, api_version, tag, default_model, log_errors, request_timeout = IndiferentHash.process_options options,
44
+ :url, :key, :model, :api_version, :tag, :default_model, :log_errors, :request_timeout,
40
45
  tag: self::TAG, default_model: self::DEFAULT_MODEL, log_errors: true, request_timeout: 1200
41
46
 
42
47
  url ||= Scout::Config.get(:url, "#{tag}_ask", :ask, tag, env: "#{tag.upcase}_URL")
43
48
  key ||= LLM.get_url_config(:key, url, "#{tag}_ask", :ask, tag, env: "#{tag.upcase}_KEY")
44
49
  model ||= LLM.get_url_config(:model, url, :openai_ask, :ask, :openai, env: "#{tag.upcase}_MODEL,MODEL", default: default_model)
45
50
 
46
- { url: url, key: key, model: model }.reject { |_k, v| v.nil? }
51
+ { url: url, key: key, model: model, api_version: api_version, request_timeout: request_timeout }.reject { |_k, v| v.nil? }
47
52
  end
48
53
 
49
54
  def extra_options(options, messages = nil)
@@ -215,6 +220,8 @@ module LLM
215
220
  definition = obj if Hash === obj
216
221
  definition
217
222
 
223
+ next definition if definition.keys.collect{|k| k.to_s } == ['type']
224
+
218
225
  definition = case definition[:function]
219
226
  when Hash
220
227
  definition.merge(definition.delete :function)
@@ -297,9 +304,14 @@ module LLM
297
304
  case tools
298
305
  when Array
299
306
  tools = tools.inject({}) do |acc, definition|
307
+ next definition unless Hash === definition
300
308
  IndiferentHash.setup definition
301
309
  name = definition.dig('name') || definition.dig('function', 'name')
302
- acc.merge(name => definition)
310
+ if name
311
+ acc.merge(name => definition)
312
+ else
313
+ acc.merge(definition[:type] => definition)
314
+ end
303
315
  end
304
316
  when nil
305
317
  tools = {}
@@ -361,8 +373,6 @@ module LLM
361
373
  end
362
374
 
363
375
  def process_response(messages, response, tools, options, &block)
364
- Log.debug "Response: #{Log.fingerprint response}"
365
-
366
376
  tool_calls = response['output'].collect do |output|
367
377
  case output['type']
368
378
  when 'function_call', 'mcp_call'
@@ -385,6 +395,8 @@ module LLM
385
395
  next
386
396
  when 'function_call', 'mcp_call'
387
397
  [tool_call_outputs.shift, tool_call_outputs.shift]
398
+ when 'image_generation_call'
399
+ {role: 'image', content: output}
388
400
  when 'web_search_call'
389
401
  next
390
402
  else
@@ -399,88 +411,183 @@ module LLM
399
411
  output
400
412
  end
401
413
 
402
- META = IndiferentHash.setup({})
403
- def log_response(response, current_meta = nil)
414
+ def update_meta(response, current_meta = nil)
404
415
  current_meta = {} if current_meta.nil?
416
+ IndiferentHash.setup current_meta
417
+
418
+ # Extract normalised token fields from the provider usage hash.
419
+ # Chat.normalize_usage handles all known backend response formats
420
+ # (OpenAI Chat, Responses API, GLM, Anthropic) and returns a flat
421
+ # hash keyed by the short names defined in Chat::TOKEN_KEYS.
422
+ tokens = Chat.normalize_usage(response.dig('usage')).reject { |_name, value| value.nil? }
423
+
424
+ # Keep the provider values for this request separate from aggregate
425
+ # values. In particular, do not add the running thread/session total
426
+ # to the chat total: doing that on every request produces the observed
427
+ # triangular (and, after aggregation, worse) growth.
428
+ meta = IndiferentHash.setup(tokens.dup)
429
+ # A lineage digest identifies copied conversational history, not an
430
+ # actual backend request. Persist a request identity so provenance can
431
+ # distinguish two genuinely repeated, otherwise identical inferences.
432
+ meta['inference_id'] = SecureRandom.uuid
433
+ meta['provider_response_id'] = response['id'] if response['id']
434
+ tokens.each do |name, value|
435
+ session_name = name + '_s'
436
+ Thread.current[session_name] = Thread.current[session_name].to_i + value.to_i
437
+ meta[session_name] = Thread.current[session_name]
438
+ end
405
439
 
406
- reasoning_content = response.dig('choices', 0, 'message', 'reasoning_content')
407
- Log.medium "Reasoning:\n" + reasoning_content if reasoning_content
440
+ Chat::TOKEN_KEYS.each do |name|
441
+ meta["#{name}_c"] = current_meta["#{name}_c"].to_i + tokens[name].to_i
442
+ end
408
443
 
409
- pattern = {
410
- 'pt+': [['usage','prompt_tokens']],
411
- 'ct+': [['usage','completion_tokens']],
412
- 'tt+': [['usage','total_tokens']],
413
- 'reas': [['choices', 0, 'message', 'reasoning_content']],
414
- }
444
+ Log.medium "Meta: #{Log.fingerprint meta}"
415
445
 
416
- meta = {}
417
- pattern.each do |name,key_list|
418
- name = name.to_s
419
- key_list.each do |keys|
420
- value = response.dig *keys
421
- next unless value
446
+ meta
447
+ end
422
448
 
423
- if name.end_with?('+')
424
- name = name[0..-2]
425
- meta[name] = value
426
- session_name = name + '_s'
449
+ def reasoning(response, current_meta = nil)
450
+ begin
451
+ reasoning_content = response.dig('choices', 0, 'message', 'reasoning_content')
452
+ reasoning_content = reasoning_content.gsub("\n", ' ') if String === reasoning_content
453
+ Log.medium "Reasoning:\n" + Log.color(:cyan, reasoning_content) if reasoning_content
454
+ reasoning_content
455
+ rescue
456
+ end
457
+ end
427
458
 
428
- meta[session_name] ||= Thread.current[session_name] || 0
429
- meta[session_name] += value
430
459
 
431
- Thread.current[session_name] = meta[session_name]
432
- meta.delete name if meta[name] == meta[session_name]
433
- else
434
- value = value.gsub(/\s+/,' ') if String === value
435
- meta[name] = value
436
- end
437
- end
460
+ def upload_messages(server, messages, options)
461
+ id = Misc.digest(messages)
462
+ messages.unshift({role: 'backend', content: self::TAG})
463
+ TmpFile.with_file [messages, options].to_json do |file|
464
+ CMD.cmd("scp #{file} #{server}:.scout/var/query/#{ id }.json")
438
465
  end
466
+ id
467
+ end
439
468
 
440
- new = {}
441
- meta.each do |name,value|
442
- if name.end_with?('_s')
443
- chat_name = name.sub(/_s$/,'_c')
444
- current_value = current_meta[chat_name] || current_meta[name] || 0
445
- new[chat_name] = current_value + value
469
+ def gather_response(server, id)
470
+ TmpFile.with_file do |file|
471
+ begin
472
+ CMD.cmd("scp #{server}:.scout/var/query/response/#{ id }.json #{ file }")
473
+ JSON.parse(Open.read(file))
474
+ rescue
475
+ sleep 1
476
+ retry
446
477
  end
447
478
  end
448
-
449
- meta.merge! new
450
-
451
- meta
452
479
  end
453
480
 
454
481
  def ask(question, options = {}, &block)
455
482
  original_options = options.dup
456
483
 
457
- return_messages, log_response, current_meta = IndiferentHash.process_options options,
458
- :return_messages, :log_response, :meta,
484
+ return_messages, log_response, current_meta, relay, process, prompt_strategies = IndiferentHash.process_options options,
485
+ :return_messages, :log_response, :current_meta, :relay, :process, :prompt_strategies,
459
486
  return_messages: false, log_response: true
460
487
 
461
488
  messages = self.messages question, options
489
+
490
+ if relay
491
+ id = upload_messages(relay, messages, options)
492
+ response = gather_response(relay, id)
493
+ IndiferentHash.setup(response)
494
+ formatted_prompt = format_messages(messages)
495
+ tools = tools(formatted_prompt, options)
496
+ else
462
497
 
463
- client = prepare_client options, messages
464
- formatted_messages = format_messages(messages)
465
- tools = tools(formatted_messages, options)
498
+ client = prepare_client options, messages
499
+ prompt = Chat.prepare_prompt(messages, prompt_strategies)
500
+ formatted_prompt = format_messages(prompt)
501
+ tools = tools(formatted_prompt, options)
502
+
503
+ response = begin
504
+ Log.medium "Calling #{self}: #{Log.fingerprint(options.except(:tools))}}"
505
+ query(client, formatted_prompt, tools, options)
506
+ rescue Exception => e
507
+ Log.debug 'Asking error. Options: ' + "\n" + JSON.pretty_generate(options.except(:tools))
508
+ begin
509
+ tmpfile = TmpFile.tmp_file
510
+ Open.write tmpfile + ".chat", Chat.print(messages)
511
+ Open.write tmpfile + ".options", options.except(:messages, :tools).to_json
512
+ Open.write tmpfile + ".meta", current_meta.to_json
513
+ Log.warn "Messages and options saved in #{tmpfile}"
514
+ e.extend LLM::Backend::BackendException
515
+ e.chat tmpfile
516
+ rescue
517
+ end
466
518
 
467
- response = begin
468
- Log.low "Calling #{self}: #{Log.fingerprint(options.except(:tools))}}"
469
- query(client, formatted_messages, tools, options)
470
- rescue Exception
471
- Log.debug 'Asking error. Options: ' + "\n" + JSON.pretty_generate(options.except(:tools))
472
- raise $!
473
- end
519
+ raise e
520
+ end
521
+ end
474
522
 
475
- raise 'No response' if response.nil?
523
+ timestamp = Chat.timestamp
524
+
525
+ if process
526
+ Scout.var.query.response[process].set_extension(:json).write response.to_json
527
+ return response
528
+ end
476
529
 
477
- output = process_response messages, response, tools, options, &block
530
+ Log.debug "Response: #{Log.fingerprint response}"
531
+
532
+ raise 'No response' if response.nil?
478
533
 
479
- meta = self.log_response response, current_meta if log_response
534
+ reasoning = reasoning response
535
+
536
+ output = begin
537
+ process_response messages, response, tools, options, &block
538
+ rescue Exception => e
539
+
540
+ Log.debug 'Processing response error. Options: ' + "\n" + JSON.pretty_generate(options.except(:tools))
541
+ begin
542
+ tmpfile = TmpFile.tmp_file
543
+ previous_response_id_error = options.delete
544
+ if previous_response_id_error
545
+ message.unshift IndiferentHash.setup({role: :previous_response_id, content: previous_response_id_error})
546
+ end
547
+ Open.write tmpfile + ".chat", Chat.print(messages)
548
+ Open.write tmpfile + ".options", options.except(:messages, :tools).to_json
549
+ Open.write tmpfile + ".meta", current_meta.to_json
550
+ Log.warn "Messages and options saved in #{tmpfile}"
551
+ e.extend LLM::Backend::BackendException
552
+ e.chat tmpfile
553
+ rescue
554
+ end
555
+ raise e
556
+ end
480
557
 
481
- output = chain_tools messages, output, tools, options.merge(client: client, tools: tools, log_response: log_response, meta: meta)
558
+ if log_response
559
+ meta = self.update_meta response, current_meta
560
+ meta['reas'] = reasoning if reasoning
561
+ meta['timestamp'] = timestamp
562
+ end
482
563
 
483
- output.unshift({role: :meta, content: Chat.serialize_meta(meta)}) if log_response
564
+ output = chain_tools messages, output, tools, options.merge(client: client, tools: tools, log_response: log_response, current_meta: meta, relay: relay)
565
+
566
+ if log_response && meta && meta.any?
567
+ # The meta is about to become the first message of a segment that
568
+ # runs until the next meta (a chained tool-call ask, whose own meta
569
+ # was already unshifted inside `output` by the recursive call) or
570
+ # the end of the response. A reasoning-only request produces no
571
+ # message at all, so its meta covers zero messages: `output` is
572
+ # empty, or starts with the nested meta. Such rounds are real cost
573
+ # (~7% of prompt tokens in one measured agent log) with nothing to
574
+ # show for it, so mark them explicitly instead of leaving a bare
575
+ # meta for the reader to puzzle over. `Chat.trace_indices` derives
576
+ # the same fact independently; the marker only makes the persisted
577
+ # meta self-explanatory and is inert for accounting.
578
+ #
579
+ # ScoutCoder: orphan is only meaningful for the meta that opens a
580
+ # segment. Chained tool calls recurse into `ask`, and their metas
581
+ # were already unshifted onto `output` before this point, so the
582
+ # first message of `output` decides: an empty list, or one starting
583
+ # with the nested meta, means this round covered nothing. Do not
584
+ # recompute this from `trace_indices` here: that helper sees the
585
+ # whole chat (parent copies included) and does not exist on this
586
+ # path.
587
+ meta['orphan'] = true if output.empty? || output.first[:role].to_s == 'meta'
588
+
589
+ output.unshift({role: :meta, content: Chat.serialize_meta(meta)})
590
+ end
484
591
 
485
592
  if output.last[:role] != :previous_response_id && options[:previous_response_id]
486
593
  output << { role: :previous_response_id, content: options[:previous_response_id] }
@@ -489,44 +596,21 @@ module LLM
489
596
  if return_messages
490
597
  Chat.setup output
491
598
  else
492
- output = LLM.purge(output)
493
- return '' if output.empty?
494
- LLM.purge(output).last['content']
599
+ output = Chat.clean(output)
600
+ return '' if output.nil? || output.empty?
601
+ Chat.purge(output).last['content']
495
602
  end
496
603
  end
497
604
 
498
605
  def image(question, options = {}, &block)
499
- messages = LLM.chat(question)
500
- options = options.merge LLM.options messages
501
- tools = LLM.tools messages
502
- associations = LLM.associations messages
503
-
504
- client, url, key, model, log_errors, return_messages, format = IndiferentHash.process_options options,
505
- :client, :url, :key, :model, :log_errors, :return_messages, :format,
506
- log_errors: true
507
-
508
- if client.nil?
509
- url ||= Scout::Config.get(:url, :openai_ask, :ask, :openai, env: 'OPENAI_URL')
510
- key ||= LLM.get_url_config(:key, url, :openai_ask, :ask, :openai, env: 'OPENAI_KEY')
511
- client = LLM::OpenAI.client url, key, log_errors
512
- end
606
+ messages = ask(question, options.merge({tools: [{type: 'image_generation'}], return_messages: true}), &block)
513
607
 
514
- if model.nil?
515
- url ||= Scout::Config.get(:url, :openai_ask, :ask, :openai, env: 'OPENAI_URL')
516
- model ||= LLM.get_url_config(:model, url, :openai_ask, :ask, :openai, env: 'OPENAI_MODEL', default: 'gpt-image-1')
517
- end
518
-
519
- messages = process_input messages
520
- input = []
521
- parameters = {}
522
- messages.each do |message|
523
- input << message
524
- end
525
- parameters[:prompt] = LLM.print(input)
526
-
527
- response = client.images.generate(parameters: parameters)
528
-
529
- response
608
+ base64_image = begin
609
+ messages.select{|info| info[:role] == 'image' }.first['content']['result']
610
+ rescue
611
+ Log.warn 'Image error: ' + "\n" + JSON.pretty_generate(messages)
612
+ raise $!
613
+ end
530
614
  end
531
615
 
532
616
  def embed_query(client, text, parameters = {})
@@ -0,0 +1,58 @@
1
+ require_relative 'default'
2
+ require_relative 'openai'
3
+ require 'openai'
4
+
5
+ module LLM
6
+ # GLM Chat Completions backend.
7
+ #
8
+ # Implemented as a module exposing singleton methods (`LLM::OpenAI.ask`, etc).
9
+ # We compose the backend by:
10
+ # - prepending GLMAIMethods into the singleton class (overrides)
11
+ # - including Backend::ClassMethods into the singleton class (shared logic)
12
+ module GLMAIMethods
13
+ def format_other(message)
14
+ role = message[:role]
15
+
16
+ case role.to_s
17
+ when 'image'
18
+ path = message[:content]
19
+ path = Chat.find_file path
20
+ if Open.remote?(path)
21
+ { role: :user, content: { type: :image_url, image_url: {url: path} } }
22
+ elsif Open.exists?(path)
23
+ path = encode_image(path)
24
+ { role: :user, content: [{ type: :image_url, image_url: {url: path} }] }
25
+ else
26
+ raise "Image does not exist in #{path}"
27
+ end
28
+ when 'pdf'
29
+ path = original_path = message[:content]
30
+ if Open.remote?(path)
31
+ { role: :user, content: { type: :input_file, file_url: path } }
32
+ elsif Open.exists?(path)
33
+ data = encode_pdf(path)
34
+ { role: :user, content: [{ type: :input_file, file_data: data, filename: File.basename(path) }] }
35
+ else
36
+ raise "PDF does not exist in #{path}"
37
+ end
38
+ when 'websearch'
39
+ { role: :tool, content: { type: 'web_search_preview' } }
40
+ when 'previous_response_id'
41
+ nil
42
+ else
43
+ message
44
+ end
45
+ end
46
+ end
47
+
48
+ module GLM
49
+ TAG = 'glm'
50
+ DEFAULT_MODEL = 'glm-turbo'
51
+
52
+ class << self
53
+ prepend OpenAIMethods
54
+ prepend GLMAIMethods
55
+ include Backend::ClassMethods
56
+ end
57
+ end
58
+ end