scout-ai 1.2.5 → 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 (164) hide show
  1. checksums.yaml +4 -4
  2. data/.vimproject +111 -31
  3. data/README.md +171 -320
  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 +62 -12
  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 +36 -6
  33. data/lib/scout/llm/backends/anthropic.rb +9 -1
  34. data/lib/scout/llm/backends/bedrock.rb +15 -3
  35. data/lib/scout/llm/backends/default.rb +129 -89
  36. data/lib/scout/llm/backends/glm.rb +58 -0
  37. data/lib/scout/llm/backends/huggingface.rb +13 -1
  38. data/lib/scout/llm/backends/ollama.rb +13 -0
  39. data/lib/scout/llm/backends/openwebui.rb +8 -3
  40. data/lib/scout/llm/chat/agent_meta.rb +264 -0
  41. data/lib/scout/llm/chat/annotation.rb +37 -14
  42. data/lib/scout/llm/chat/parse.rb +28 -6
  43. data/lib/scout/llm/chat/persist.rb +25 -0
  44. data/lib/scout/llm/chat/process/clear.rb +19 -1
  45. data/lib/scout/llm/chat/process/files.rb +16 -1
  46. data/lib/scout/llm/chat/process/meta.rb +422 -32
  47. data/lib/scout/llm/chat/process/options.rb +21 -1
  48. data/lib/scout/llm/chat/process/tools.rb +49 -12
  49. data/lib/scout/llm/chat/process.rb +4 -0
  50. data/lib/scout/llm/chat/prompt/shorten_tools.rb +125 -0
  51. data/lib/scout/llm/chat/prompt/shorten_tools_epoch.rb +365 -0
  52. data/lib/scout/llm/chat/prompt.rb +48 -0
  53. data/lib/scout/llm/chat/provenance.rb +775 -0
  54. data/lib/scout/llm/chat/tool_calls.rb +76 -0
  55. data/lib/scout/llm/chat.rb +18 -2
  56. data/lib/scout/llm/embed.rb +8 -3
  57. data/lib/scout/llm/image.rb +86 -0
  58. data/lib/scout/llm/rag.rb +3 -3
  59. data/lib/scout/llm/tools/call.rb +159 -10
  60. data/lib/scout/llm/tools/knowledge_base.rb +1 -1
  61. data/lib/scout/llm/tools/workflow.rb +13 -5
  62. data/lib/scout-ai.rb +1 -0
  63. data/research/ChatAnalyst-required-changes.md +167 -0
  64. data/research/agent-delegation-analysis.md +810 -0
  65. data/research/agent-meta-provenance-integration-plan.md +622 -0
  66. data/research/agent-workflow-analysis.md +1120 -0
  67. data/research/backends-analysis.md +836 -0
  68. data/research/chat-core-analysis.md +946 -0
  69. data/research/chatanalyst-provenance/00-baseline.md +30 -0
  70. data/research/chatanalyst-provenance/01-repo-map.md +60 -0
  71. data/research/chatanalyst-provenance/02-event-reconstruction.md +55 -0
  72. data/research/chatanalyst-provenance/03-duplication-evidence.md +45 -0
  73. data/research/chatanalyst-provenance/04-tooling-root-cause.md +57 -0
  74. data/research/chatanalyst-provenance/05-fix-plan.md +46 -0
  75. data/research/chatanalyst-provenance/07-critic-review.md +25 -0
  76. data/research/chatanalyst-provenance/final-report.md +45 -0
  77. data/research/chatanalyst-provenance/resumption.md +37 -0
  78. data/research/coding-philosophy-analysis.md +928 -0
  79. data/research/commands-analysis.md +947 -0
  80. data/research/multi-agent-patterns-analysis.md +853 -0
  81. data/research/prompt-strategies-analysis.md +630 -0
  82. data/research/prov-verbosity-fix-notes.md +77 -0
  83. data/research/provenance-analysis.md +469 -0
  84. data/research/provenance-navigation-design.md +640 -0
  85. data/research/synthesis-report.md +487 -0
  86. data/research/tools-system-analysis.md +779 -0
  87. data/scout-ai.gemspec +97 -13
  88. data/scout_commands/agent/ask +13 -3
  89. data/scout_commands/agent/kb +2 -0
  90. data/scout_commands/llm/ask +11 -4
  91. data/scout_commands/llm/md +76 -0
  92. data/scout_commands/llm/prov +602 -0
  93. data/scout_commands/llm/word +71 -0
  94. data/share/word/reference.docx +0 -0
  95. data/test/etc/AI/mock.yaml +11 -0
  96. data/test/fixtures/backends/anthropic.json +19 -0
  97. data/test/fixtures/backends/anthropic_tool_use.json +24 -0
  98. data/test/fixtures/backends/bedrock.json +8 -0
  99. data/test/fixtures/backends/bedrock_embedding.json +3 -0
  100. data/test/fixtures/backends/bedrock_tool_use.json +17 -0
  101. data/test/fixtures/backends/ollama.json +16 -0
  102. data/test/fixtures/backends/ollama_tool_call.json +27 -0
  103. data/test/fixtures/backends/openai_chat.json +21 -0
  104. data/test/fixtures/backends/openai_chat_tool_call.json +31 -0
  105. data/test/fixtures/backends/responses.json +33 -0
  106. data/test/fixtures/backends/responses_tool_call.json +28 -0
  107. data/test/integration/README.md +32 -0
  108. data/test/integration/scout/llm/backends/test_endpoints.rb +34 -0
  109. data/test/integration/scout/llm/backends/test_openwebui.rb +61 -0
  110. data/test/integration/scout/llm/backends/test_relay.rb +52 -0
  111. data/test/integration/scout/llm/test_infrastructure.rb +74 -0
  112. data/test/{scout → integration/scout}/llm/test_mcp.rb +1 -1
  113. data/test/integration/scout/llm/tools/test_mcp.rb +42 -0
  114. data/test/integration/scout/model/test_base.rb +91 -0
  115. data/test/scout/llm/agent/test_chat.rb +8 -2
  116. data/test/scout/llm/agent/test_save.rb +413 -0
  117. data/test/scout/llm/agent/test_workflow.rb +110 -0
  118. data/test/scout/llm/backends/test_anthropic.rb +93 -10
  119. data/test/scout/llm/backends/test_bedrock.rb +118 -2
  120. data/test/scout/llm/backends/test_ollama.rb +70 -20
  121. data/test/scout/llm/backends/test_openwebui.rb +42 -40
  122. data/test/scout/llm/backends/test_relay.rb +4 -2
  123. data/test/scout/llm/chat/agent_meta_fixtures.rb +131 -0
  124. data/test/scout/llm/chat/process/test_meta.rb +518 -0
  125. data/test/scout/llm/chat/process/test_normalize_usage.rb +183 -0
  126. data/test/scout/llm/chat/test_agent_meta.rb +357 -0
  127. data/test/scout/llm/chat/test_agent_meta_provenance.rb +467 -0
  128. data/test/scout/llm/chat/test_agent_meta_tokens.rb +594 -0
  129. data/test/scout/llm/chat/test_parse.rb +70 -15
  130. data/test/scout/llm/chat/test_prov_cli.rb +274 -0
  131. data/test/scout/llm/chat/test_provenance.rb +240 -0
  132. data/test/scout/llm/chat/test_tool_calls.rb +38 -0
  133. data/test/scout/llm/test_agent.rb +13 -36
  134. data/test/scout/llm/test_ask.rb +75 -52
  135. data/test/scout/llm/test_chat.rb +107 -13
  136. data/test/scout/llm/test_embed.rb +48 -0
  137. data/test/scout/llm/test_rag.rb +23 -16
  138. data/test/scout/llm/test_tools.rb +12 -1
  139. data/test/scout/llm/tools/test_knowledge_base.rb +0 -1
  140. data/test/scout/llm/tools/test_mcp.rb +5 -3
  141. data/test/scout/llm/tools/test_workflow.rb +23 -2
  142. data/test/scout/model/python/huggingface/causal/test_next_token.rb +11 -5
  143. data/test/scout/model/python/huggingface/test_causal.rb +9 -3
  144. data/test/scout/model/python/huggingface/test_classification.rb +11 -2
  145. data/test/scout/model/python/test_torch.rb +2 -0
  146. data/test/scout/model/python/torch/test_helpers.rb +4 -0
  147. data/test/scout/model/test_base.rb +4 -2
  148. data/test/support/availability.rb +231 -0
  149. data/test/support/fake_clients.rb +138 -0
  150. data/test/support/fixtures.rb +21 -0
  151. data/test/support/infrastructure_probes.rb +136 -0
  152. data/test/support/mock_backend.rb +215 -0
  153. data/test/test_helper.rb +32 -2
  154. metadata +96 -12
  155. data/doc/Agent.md +0 -354
  156. data/doc/Chat.md +0 -481
  157. data/doc/LLM.md +0 -356
  158. data/doc/PythonAgentTasks.md +0 -333
  159. data/doc/RAG.md +0 -129
  160. data/doc/USER_GUIDE.md +0 -572
  161. data/scout_commands/documenter +0 -148
  162. data/test/scout/llm/backends/test_openai.rb +0 -192
  163. data/test/scout/llm/backends/test_responses.rb +0 -238
  164. 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,15 +24,19 @@ 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
35
  url, key, model, api_version, log_errors, request_timeout = IndiferentHash.process_options options,
31
36
  :url, :key, :model, :api_version, :log_errors, :request_timeout,
32
- log_errors: true, request_timeout: 1200, api_version: 'v1'
37
+ log_errors: true, request_timeout: 12000, api_version: 'v1'
33
38
 
34
- Object::OpenAI::Client.new(api_version: api_version, 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)
@@ -43,7 +48,7 @@ module LLM
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, api_version: api_version }.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 = {}
@@ -308,7 +320,7 @@ module LLM
308
320
  tools.merge!(LLM.tools messages)
309
321
  tools.merge!(LLM.associations messages)
310
322
 
311
- Log.high "Tools: #{Log.fingerprint tools.keys}" if tools
323
+ Log.medium "Tools: #{Log.fingerprint tools.keys}" if tools
312
324
 
313
325
  tools
314
326
  end
@@ -383,6 +395,8 @@ module LLM
383
395
  next
384
396
  when 'function_call', 'mcp_call'
385
397
  [tool_call_outputs.shift, tool_call_outputs.shift]
398
+ when 'image_generation_call'
399
+ {role: 'image', content: output}
386
400
  when 'web_search_call'
387
401
  next
388
402
  else
@@ -399,63 +413,54 @@ module LLM
399
413
 
400
414
  def update_meta(response, current_meta = nil)
401
415
  current_meta = {} if current_meta.nil?
402
-
403
- pattern = {
404
- 'pt+': [['usage','prompt_tokens']],
405
- 'ct+': [['usage','completion_tokens']],
406
- 'tt+': [['usage','total_tokens']],
407
- }
408
-
409
- meta = {}
410
- pattern.each do |name,key_list|
411
- name = name.to_s
412
- key_list.each do |keys|
413
- value = response.dig *keys
414
- next unless value
415
-
416
- if name.end_with?('+')
417
- name = name[0..-2]
418
- meta[name] = value
419
- session_name = name + '_s'
420
-
421
- meta[session_name] ||= Thread.current[session_name] || 0
422
- meta[session_name] += value
423
-
424
- Thread.current[session_name] = meta[session_name]
425
- meta.delete name if meta[name] == meta[session_name]
426
- else
427
- value = value.gsub(/\s+/,' ') if String === value
428
- meta[name] = value
429
- end
430
- end
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]
431
438
  end
432
439
 
433
- new = {}
434
- meta.each do |name,value|
435
- if name.end_with?('_s')
436
- chat_name = name.sub(/_s$/,'_c')
437
- current_value = current_meta[chat_name] || current_meta[name] || 0
438
- new[chat_name] = current_value + value
439
- end
440
+ Chat::TOKEN_KEYS.each do |name|
441
+ meta["#{name}_c"] = current_meta["#{name}_c"].to_i + tokens[name].to_i
440
442
  end
441
443
 
442
- meta.merge! new
444
+ Log.medium "Meta: #{Log.fingerprint meta}"
443
445
 
444
446
  meta
445
447
  end
446
448
 
447
449
  def reasoning(response, current_meta = nil)
448
- reasoning_content = response.dig('choices', 0, 'message', 'reasoning_content')
449
- reasoning_content = reasoning_content.gsub("\n", ' ') if String === reasoning_content
450
- Log.medium "Reasoning:\n" + Log.color(:cyan, reasoning_content) if reasoning_content
451
- reasoning_content
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
452
457
  end
453
458
 
454
459
 
455
460
  def upload_messages(server, messages, options)
456
461
  id = Misc.digest(messages)
457
462
  messages.unshift({role: 'backend', content: self::TAG})
458
- TmpFile.with_file [messages, options.except(:client)].to_json do |file|
463
+ TmpFile.with_file [messages, options].to_json do |file|
459
464
  CMD.cmd("scp #{file} #{server}:.scout/var/query/#{ id }.json")
460
465
  end
461
466
  id
@@ -476,8 +481,8 @@ module LLM
476
481
  def ask(question, options = {}, &block)
477
482
  original_options = options.dup
478
483
 
479
- return_messages, log_response, current_meta, relay, process = IndiferentHash.process_options options,
480
- :return_messages, :log_response, :current_meta, :relay, :process,
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,
481
486
  return_messages: false, log_response: true
482
487
 
483
488
  messages = self.messages question, options
@@ -486,24 +491,37 @@ module LLM
486
491
  id = upload_messages(relay, messages, options)
487
492
  response = gather_response(relay, id)
488
493
  IndiferentHash.setup(response)
489
- client = prepare_client options, messages
490
- formatted_messages = format_messages(messages)
491
- tools = tools(formatted_messages, options)
494
+ formatted_prompt = format_messages(messages)
495
+ tools = tools(formatted_prompt, options)
492
496
  else
493
497
 
494
498
  client = prepare_client options, messages
495
- formatted_messages = format_messages(messages)
496
- tools = tools(formatted_messages, options)
499
+ prompt = Chat.prepare_prompt(messages, prompt_strategies)
500
+ formatted_prompt = format_messages(prompt)
501
+ tools = tools(formatted_prompt, options)
497
502
 
498
503
  response = begin
499
504
  Log.medium "Calling #{self}: #{Log.fingerprint(options.except(:tools))}}"
500
- query(client, formatted_messages, tools, options)
501
- rescue Exception
505
+ query(client, formatted_prompt, tools, options)
506
+ rescue Exception => e
502
507
  Log.debug 'Asking error. Options: ' + "\n" + JSON.pretty_generate(options.except(:tools))
503
- raise $!
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
518
+
519
+ raise e
504
520
  end
505
521
  end
506
522
 
523
+ timestamp = Chat.timestamp
524
+
507
525
  if process
508
526
  Scout.var.query.response[process].set_extension(:json).write response.to_json
509
527
  return response
@@ -515,16 +533,61 @@ module LLM
515
533
 
516
534
  reasoning = reasoning response
517
535
 
518
- output = process_response messages, response, tools, options, &block
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
519
557
 
520
558
  if log_response
521
559
  meta = self.update_meta response, current_meta
522
560
  meta['reas'] = reasoning if reasoning
561
+ meta['timestamp'] = timestamp
523
562
  end
524
563
 
525
564
  output = chain_tools messages, output, tools, options.merge(client: client, tools: tools, log_response: log_response, current_meta: meta, relay: relay)
526
565
 
527
- output.unshift({role: :meta, content: Chat.serialize_meta(meta)}) if log_response && meta && meta.any?
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
528
591
 
529
592
  if output.last[:role] != :previous_response_id && options[:previous_response_id]
530
593
  output << { role: :previous_response_id, content: options[:previous_response_id] }
@@ -540,37 +603,14 @@ module LLM
540
603
  end
541
604
 
542
605
  def image(question, options = {}, &block)
543
- messages = LLM.chat(question)
544
- options = options.merge LLM.options messages
545
- tools = LLM.tools messages
546
- associations = LLM.associations messages
606
+ messages = ask(question, options.merge({tools: [{type: 'image_generation'}], return_messages: true}), &block)
547
607
 
548
- client, url, key, model, log_errors, return_messages, format = IndiferentHash.process_options options,
549
- :client, :url, :key, :model, :log_errors, :return_messages, :format,
550
- log_errors: true
551
-
552
- if client.nil?
553
- url ||= Scout::Config.get(:url, :openai_ask, :ask, :openai, env: 'OPENAI_URL')
554
- key ||= LLM.get_url_config(:key, url, :openai_ask, :ask, :openai, env: 'OPENAI_KEY')
555
- client = LLM::OpenAI.client url, key, log_errors
556
- end
557
-
558
- if model.nil?
559
- url ||= Scout::Config.get(:url, :openai_ask, :ask, :openai, env: 'OPENAI_URL')
560
- model ||= LLM.get_url_config(:model, url, :openai_ask, :ask, :openai, env: 'OPENAI_MODEL', default: 'gpt-image-1')
561
- end
562
-
563
- messages = process_input messages
564
- input = []
565
- parameters = {}
566
- messages.each do |message|
567
- input << message
568
- end
569
- parameters[:prompt] = LLM.print(input)
570
-
571
- response = client.images.generate(parameters: parameters)
572
-
573
- 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
574
614
  end
575
615
 
576
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
@@ -1,3 +1,11 @@
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'
1
9
  require_relative 'default'
2
10
 
3
11
  module LLM
@@ -58,7 +66,11 @@ module LLM
58
66
  parameters[:chat_template] ||= IndiferentHash.pull_keys parameters, :chat_template
59
67
  parameters = parameters.keys_to_sym
60
68
  response = client.chat(messages, formatted_tools, parameters)
61
- response = ScoutPython.dict2hash(response)
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)
62
74
  IndiferentHash.setup({message: response})
63
75
  rescue
64
76
  Log.debug 'Input parameters: ' + "\n" + JSON.pretty_generate(parameters.except(:tools))
@@ -114,6 +114,19 @@ module LLM
114
114
  info['role'] = 'tool'
115
115
  info
116
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
+
117
130
  end
118
131
 
119
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,6 +40,9 @@ 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
48
  Misc.insist do