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
@@ -0,0 +1,76 @@
1
+ require 'json'
2
+
3
+ module Chat
4
+ def self.parse_tool_message(message)
5
+ JSON.parse(message[:content].to_s)
6
+ rescue JSON::ParserError, TypeError
7
+ nil
8
+ end
9
+
10
+ # Pair tool calls with their outputs within one persisted chat. This reports
11
+ # structural facts only; use tool_call_status for the common interpretation
12
+ # of exception and exit-status outputs.
13
+ def self.tool_calls(chat, source: nil)
14
+ calls = []
15
+ outputs = Hash.new { |hash, key| hash[key] = [] }
16
+
17
+ chat.each_with_index do |message, index|
18
+ role = message[:role].to_s
19
+ next unless %w[function_call mcp_call function_call_output].include?(role)
20
+ info = parse_tool_message(message)
21
+ next unless Hash === info
22
+
23
+ call_id = info['id'] || info['call_id'] || info['tool_call_id']
24
+ address = source ? [source.to_s, index] : index
25
+
26
+ if role == 'function_call_output'
27
+ outputs[call_id] << { address: address, index: index, message: message, info: info } if call_id
28
+ else
29
+ calls << {
30
+ call_id: call_id,
31
+ name: info['name'] || info.dig('function', 'name'),
32
+ arguments: info['arguments'] || info.dig('function', 'arguments'),
33
+ role: role.to_sym,
34
+ call_address: address,
35
+ call_index: index,
36
+ call_message: message,
37
+ call_info: info
38
+ }
39
+ end
40
+ end
41
+
42
+ calls.collect do |call|
43
+ output = call[:call_id] && outputs[call[:call_id]].shift
44
+ call.merge(
45
+ output_address: output && output[:address],
46
+ output_index: output && output[:index],
47
+ output_message: output && output[:message],
48
+ output_info: output && output[:info],
49
+ output: output && output[:info]['content']
50
+ )
51
+ end
52
+ end
53
+
54
+ # Common, deliberately separate interpretation of a paired tool output.
55
+ # Missing output is unknown, JSON exceptions fail, and command-style JSON
56
+ # results fail when exit_status is non-zero.
57
+ def self.tool_call_status(call)
58
+ return { success: nil, reason: :missing_output } unless call[:output_info]
59
+
60
+ content = call[:output]
61
+ parsed = begin
62
+ JSON.parse(content.to_s)
63
+ rescue JSON::ParserError, TypeError
64
+ nil
65
+ end
66
+
67
+ if Hash === parsed && parsed['exception']
68
+ { success: false, reason: :exception, exception: parsed['exception'] }
69
+ elsif Hash === parsed && parsed.include?('exit_status')
70
+ status = parsed['exit_status'].to_i
71
+ { success: status == 0, reason: :exit_status, exit_status: status }
72
+ else
73
+ { success: true, reason: :output }
74
+ end
75
+ end
76
+ end
@@ -4,6 +4,11 @@ require_relative 'tools'
4
4
  require_relative 'chat/annotation'
5
5
  require_relative 'chat/parse'
6
6
  require_relative 'chat/process'
7
+ require_relative 'chat/persist'
8
+ require_relative 'chat/prompt'
9
+ require_relative 'chat/provenance'
10
+ require_relative 'chat/tool_calls'
11
+ require_relative 'chat/agent_meta'
7
12
 
8
13
  module LLM
9
14
  def self.messages(question, role = nil)
@@ -21,10 +26,21 @@ module LLM
21
26
 
22
27
  Chat.parse question
23
28
  end
29
+
24
30
  def self.chat(file = [], original = nil)
25
31
  original ||= (String === file and Open.exists?(file)) ? file : Path.setup($0.dup)
26
32
  caller_lib_dir = Path.caller_lib_dir(nil, 'chats')
27
33
 
34
+ # ScoutCoder: anchor point for per-project library stores. The chat file
35
+ # is the object of the ask, so its libdir — not the framework's stack —
36
+ # defines "the project this chat belongs to". Guard against marker climbs
37
+ # that overshoot to nil/''/'/', and never clobber an explicit value: ENV
38
+ # propagates into job subprocesses (bwrap) while Dir.pwd under exec is the
39
+ # workflow's own directory, so ENV is the only reliable channel.
40
+ if ENV['SCOUT_CHAT_DIR'].to_s.empty? && caller_lib_dir && ! ['', '/'].include?(caller_lib_dir)
41
+ ENV['SCOUT_CHAT_DIR'] = Path.caller_lib_dir(original)
42
+ end
43
+
28
44
  if Path.is_filename? file
29
45
  messages = self.messages Open.read(file), file
30
46
  else
@@ -35,8 +51,9 @@ module LLM
35
51
  messages = Chat.imports messages, original, caller_lib_dir
36
52
 
37
53
  messages = Chat.clear messages
38
- messages = Chat.clean messages
54
+ messages = Chat.clean messages, :skip
39
55
 
56
+ messages = Chat.config messages
40
57
  messages = Chat.tasks messages
41
58
  messages = Chat.jobs messages
42
59
  messages = Chat.files messages, original, caller_lib_dir
@@ -64,4 +81,3 @@ module LLM
64
81
  Chat.purge(...)
65
82
  end
66
83
  end
67
-
@@ -3,13 +3,13 @@ require 'scout'
3
3
  module LLM
4
4
  def self.embed(text, options = {})
5
5
  endpoint = IndiferentHash.process_options options, :endpoint
6
- endpoint ||= Scout::Config.get :endpoint, :embed, :llm, env: 'EMBED_ENDPOINT,LLM_ENDPOINT', default: :openai
6
+ endpoint ||= Scout::Config.get :endpoint, :embed, :llm, env: 'EMBED_ENDPOINT,LLM_ENDPOINT', default: :embed
7
7
  if endpoint && Scout.etc.AI[endpoint].exists?
8
8
  options = IndiferentHash.add_defaults options, Scout.etc.AI[endpoint].yaml
9
9
  end
10
10
 
11
11
  backend = IndiferentHash.process_options options, :backend
12
- backend ||= Scout::Config.get :backend, :embed, :llm, env: 'EMBED_BACKEND,LLM_BACKEND', default: :openai
12
+ backend ||= Scout::Config.get :backend, :embed, :llm, env: 'EMBED_BACKEND,LLM_BACKEND', default: :embed
13
13
 
14
14
  case backend
15
15
  when :openai, "openai"
@@ -24,11 +24,19 @@ module LLM
24
24
  when :openwebui, "openwebui"
25
25
  require_relative 'backends/openwebui'
26
26
  LLM::OpenWebUI.embed(text, options)
27
+ when :huggingface, "huggingface"
28
+ require_relative 'backends/huggingface'
29
+ LLM::Huggingface.embed(text, options)
27
30
  when :relay, "relay"
28
31
  require_relative 'backends/relay'
29
32
  LLM::Relay.embed(text, options)
30
33
  else
31
- raise "Unknown backend: #{backend}"
34
+ # Fall back to the runtime backend registry (mirrors LLM.ask) so
35
+ # test-only or plugin backends registered with LLM.register_backend
36
+ # work for embeddings too, instead of only for ask.
37
+ mod = LLM::BACKENDS[backend]
38
+ raise "Unknown backend: #{backend}" if mod.nil?
39
+ mod.embed(text, options)
32
40
  end
33
41
  end
34
42
  end
@@ -0,0 +1,86 @@
1
+ module LLM
2
+
3
+ def self.image(question, options = {}, &block)
4
+ messages = LLM.chat(question)
5
+ options = IndiferentHash.add_defaults LLM.options(messages), options
6
+
7
+ endpoint, persist = IndiferentHash.process_options options, :endpoint, :persist, persist: true
8
+
9
+ endpoint ||= Scout::Config.get :endpoint, :image, :ask, :llm, env: 'IMAGE_ENDPOINT,ASK_ENDPOINT,LLM_ENDPOINT,ENDPOINT,LLM,ASK,IMAGE'
10
+ if endpoint && Scout.etc.AI[endpoint].find_with_extension(:yaml).exists?
11
+ options = IndiferentHash.add_defaults options, Scout.etc.AI[endpoint].yaml
12
+ elsif endpoint && endpoint != ""
13
+ raise "Endpoint not found #{endpoint}"
14
+ end
15
+
16
+ agent_name = IndiferentHash.process_options options, :agent
17
+ agent_name = nil if %(none false nil).include?(agent_name.to_s)
18
+ if agent_name
19
+ options[:endpoint] ||= endpoint
20
+ agent = LLM::Agent.load_agent agent_name
21
+ agent.follow messages
22
+ res = agent.ask options
23
+ return res
24
+ end
25
+
26
+ meta = Chat.meta(messages)
27
+ options[:current_meta] = meta if meta and meta.any?
28
+
29
+ if options[:backend].to_s == 'responses' && options[:previous_response].to_s != 'false'
30
+ messages = Chat.clear(messages, 'previous_response_id')
31
+ else
32
+ messages = Chat.clean(messages, 'previous_response_id')
33
+ options.delete :previous_response_id
34
+ end
35
+
36
+ Log.high Log.color :green, "Asking #{endpoint || options[:endpoint] || 'client'}: #{options[:previous_response_id]}\n" + Chat.print_brief(messages)
37
+ tools = options[:tools]
38
+ Log.medium "Tools: #{Log.fingerprint tools.keys}" if tools
39
+ Log.debug "#{Log.fingerprint tools}}" if tools
40
+
41
+ res = Persist.persist(endpoint, :json, prefix: "LLM image", other: options.merge(messages: messages), persist: persist, dir: Scout.var.cache.ask) do
42
+ backend = IndiferentHash.process_options options, :backend
43
+ backend ||= Scout::Config.get :backend, :ask, :llm, env: 'ASK_BACKEND,LLM_BACKEND', default: :responses
44
+
45
+ case backend
46
+ when :openai, "openai"
47
+ require_relative 'backends/openai'
48
+ LLM::OpenAI.image(messages, options, &block)
49
+ when :anthropic, "anthropic"
50
+ require_relative 'backends/anthropic'
51
+ LLM::Anthropic.image(messages, options, &block)
52
+ when :responses, "responses"
53
+ require_relative 'backends/responses'
54
+ LLM::Responses.image(messages, options, &block)
55
+ when :ollama, "ollama"
56
+ require_relative 'backends/ollama'
57
+ LLM::OLlama.image(messages, options, &block)
58
+ when :vllm, "vllm"
59
+ require_relative 'backends/vllm'
60
+ LLM::VLLM.image(messages, options, &block)
61
+ when :openwebui, "openwebui"
62
+ require_relative 'backends/openwebui'
63
+ LLM::OpenWebUI.image(messages, options, &block)
64
+ when :huggingface, "huggingface"
65
+ require_relative 'backends/huggingface'
66
+ LLM::Huggingface.image(messages, options, &block)
67
+ when :relay, "relay"
68
+ require_relative 'backends/relay'
69
+ LLM::Relay.image(messages, options, &block)
70
+ when :bedrock, "bedrock"
71
+ require_relative 'backends/bedrock'
72
+ LLM::Bedrock.image(messages, options, &block)
73
+ else
74
+ mod = BACKENDS[backend]
75
+ raise "Unknown backend: #{backend}" if mod.nil?
76
+ mod.ask(messages, options, &block)
77
+ end
78
+ end
79
+
80
+ Chat.setup res if Array === res
81
+
82
+ Log.high Log.color :blue, "Response:\n" + Chat.print_brief(res, %w(meta assistant)) if Array === res
83
+
84
+ res
85
+ end
86
+ end
data/lib/scout/llm/mcp.rb CHANGED
@@ -15,14 +15,22 @@ module Workflow
15
15
  annotations[:idempotent_hint] = true
16
16
  annotations[:open_world_hint] = false
17
17
  MCP::Tool.define(name:task, description: description, input_schema: input_schema, annotations:annotations) do |parameters,context|
18
- self.job(name, parameters)
18
+ self.job(name, parameters).run
19
19
  end
20
20
  end
21
21
 
22
+ version = "1.0.0"
22
23
  MCP::Server.new(
23
24
  name: self.name,
24
- version: "1.0.0",
25
+ version: version,
25
26
  tools: tools
26
27
  )
27
28
  end
29
+
30
+ def mcp_stdio(*tasks)
31
+ server = mcp(*tasks)
32
+ transport = MCP::Server::Transports::StdioTransport.new(server)
33
+ server.transport = transport
34
+ transport.open
35
+ end
28
36
  end
data/lib/scout/llm/rag.rb CHANGED
@@ -22,11 +22,11 @@ module LLM
22
22
  u
23
23
  end
24
24
 
25
- def self.top(texts, prompt, num = 10)
26
- data = texts.collect{|text| LLM.embed(text) }
25
+ def self.top(texts, prompt, num = 10, ...)
26
+ data = texts.collect{|text| LLM.embed(text, ...) }
27
27
 
28
28
  i = LLM::RAG.index(data)
29
- pos, scores = i.search_knn LLM.embed(prompt), num
29
+ pos, scores = i.search_knn LLM.embed(prompt, ...), num
30
30
 
31
31
  texts.values_at *pos.reverse
32
32
  end
@@ -1,3 +1,11 @@
1
+ # LLM::Agent is referenced below in content dispatch, but the require chain
2
+ # chat -> tools -> tools/call does not pull in scout/llm/agent (agent.rb
3
+ # requires ask.rb, which requires chat.rb, so loading agent from chat would
4
+ # be circular). Load it lazily on first use instead of at file load time.
5
+ module LLM
6
+ require 'scout/llm/agent' unless defined?(LLM::Agent)
7
+ end
8
+
1
9
  module LLM
2
10
  @max_content_length = Scout::Config.get(:max_content_length, :llm_tools, :tools, :llm, :ask, default: 100_000)
3
11
  self.singleton_class.attr_accessor :max_content_length
@@ -17,10 +25,31 @@ module LLM
17
25
  [tool_call_id, function_name, function_arguments]
18
26
  end
19
27
 
28
+ # Normalize serialized meta messages (the chat-message shape
29
+ # {role: 'meta', content: 'k=v k=v ...'}) into the receipt format: an Array
30
+ # of DESERIALIZED field Hashes, as emitted under the `meta` key of
31
+ # function_call_output envelopes.
32
+ #
33
+ # Non-Hash entries, non-meta roles, non-String contents and entries that
34
+ # parse to no fields at all are dropped. The reader side keeps the full
35
+ # malformed-entry warning taxonomy; the writer side simply never emits an
36
+ # entry that carries no evidence.
37
+ def self.meta_receipt_from_messages(messages)
38
+ Array(messages).collect do |msg|
39
+ next nil unless Hash === msg
40
+ role = msg[:role] || msg['role']
41
+ content = msg[:content] || msg['content']
42
+ next nil unless role.to_s == 'meta' && String === content
43
+ fields = Chat.parse_meta(content)
44
+ fields.empty? ? nil : fields
45
+ end.compact
46
+ end
47
+
20
48
  def self.process_calls(tools, calls, &block)
21
49
  max_content_length = LLM.max_content_length
22
50
  IndiferentHash.setup tools
23
51
 
52
+ start_timestamp = Chat.timestamp
24
53
  tool_call_content = calls.collect do |tool_call|
25
54
  tool_call = IndiferentHash.setup tool_call
26
55
  tool_call_id, function_name, function_arguments = call_id_name_and_arguments(tool_call)
@@ -40,6 +69,13 @@ module LLM
40
69
  function_response = case obj
41
70
  when Proc
42
71
  obj.call function_name, function_arguments
72
+ when String
73
+ if Kernel.const_defined? obj
74
+ wt = Kernel.const_get obj
75
+ else
76
+ wf = Workflow.require_workflow obj
77
+ end
78
+ call_workflow(wf, function_name, function_arguments)
43
79
  when Workflow
44
80
  call_workflow(obj, function_name, function_arguments)
45
81
  when KnowledgeBase
@@ -57,12 +93,28 @@ module LLM
57
93
  function_response
58
94
  when String
59
95
  function_response
96
+ when IO
97
+ function_response.read
98
+ when TSV::Dumper
99
+ function_response.read
100
+ when LLM::Agent
101
+ function_response
60
102
  when nil
61
103
  "success"
62
104
  when Exception
63
- {exception: function_response.message, stack: function_response.backtrace }.to_json
105
+ function_response
106
+ when Hash
107
+ IndiferentHash.setup(function_response)
64
108
  else
65
- function_response.to_json
109
+ begin
110
+ function_response.to_json
111
+ rescue Exception => e
112
+ begin
113
+ function_response.to_s
114
+ rescue
115
+ {exception: e.message, stack: e.backtrace }.to_json
116
+ end
117
+ end
66
118
  end
67
119
 
68
120
  content = content.to_s if Numeric === content
@@ -90,29 +142,103 @@ module LLM
90
142
  end
91
143
  end
92
144
 
145
+ agents = tool_call_content.collect{|p| p.last }.select{|c| LLM::Agent === c }
146
+
147
+ agent_answers = TSV.setup({}, key_field: 'Pos', fields: ['Content', 'Job path'], type: :list)
148
+
149
+ if agents.any?
150
+ cpus = Scout::Config.get(:cpus, :agent_ask, :agents, env: 'ASK_AGENTS', default: 3)
151
+ Open.traverse (0..agents.length-1).to_a, cpus: cpus, bar: 'Asking agents', type: :list, into: agent_answers do |i|
152
+ agent = agents[i]
153
+ res = agent.chat return_messages: true
154
+ path = Step === agent.job ? agent.job.path : nil
155
+ [i, [res, path]]
156
+ end
157
+ end
158
+
93
159
  tool_call_content.collect do |function_name,function_arguments,tool_call_id,tool_call,content|
160
+ error = false
161
+ stack = nil
162
+ meta = []
94
163
  if Step === content
95
164
  step = content
96
165
  if content.done?
97
- content = content.load.to_json if content.done?
166
+ content = content.load
98
167
  elsif content.error? && content.exception
99
- content = {exception: content.exception.message, stack: content.exception.backtrace }.to_json
168
+ error = :error
169
+ content = if String === content.exception
170
+ {exception: content.exception}.to_json
171
+ else
172
+ content = {exception: content.exception.message, exception_line: content.exception.backtrace&.first}.to_json
173
+ end
100
174
  else
101
175
  begin
102
- content = content.run.to_json
103
- rescue
104
- content = {exception: $!.message, stack: $!.backtrace }.to_json
176
+ content = content.run
177
+ rescue Exception
178
+ error = :error
179
+ stack = $!.backtrace
180
+ content = {exception: $!.message, exception_line: $!.backtrace&.first}.to_json
105
181
  end
106
182
  end
183
+ elsif LLM::Agent === content
184
+ res, path = agent_answers[agents.index(content)]
185
+
186
+ begin
187
+ Chat.allow_read_job Step.load(path)
188
+ rescue
189
+ end if path
190
+
191
+ content.current_chat.follow(res)
192
+ # Receipt format: the child agent's meta messages are DESERIALIZED
193
+ # into plain field Hashes and emitted under the `meta` key (the
194
+ # legacy serialized `agent_meta` array is no longer written).
195
+ # Entries that parse to no fields are dropped: a receipt entry
196
+ # exists to carry evidence fields, and an empty one carries none.
197
+ meta = LLM.meta_receipt_from_messages(Chat.find_role(res, :meta))
198
+ content = content.answer
199
+ elsif Exception === content
200
+ error = :error
201
+ stack = content.backtrace
202
+ content = {exception: content.message, exception_line: content.backtrace&.first}.to_json
107
203
  else
108
204
  step = nil
109
205
  end
110
206
 
207
+ content = case content
208
+ when Hash
209
+ # ScoutCoder: When the response of the function contains a
210
+ # Hash with only two keys, content and meta or content and
211
+ # agent_meta treat it as content with inference meta. Extract
212
+ # accordingly.
213
+ content = IndiferentHash.setup(content)
214
+ keys = content.keys.collect{|k| k.to_s }
215
+ if keys.sort == %w(meta content)
216
+ # New inbound shape: `meta` is already the deserialized
217
+ # receipt array; pass it through verbatim.
218
+ meta, content = content.values_at :meta, :content
219
+ content
220
+ elsif keys.sort == %w(agent_meta content)
221
+ # Legacy inbound shape: serialized meta messages;
222
+ # normalize them into the new deserialized form.
223
+ meta = LLM.meta_receipt_from_messages(content[:agent_meta])
224
+ content = content[:content]
225
+ else
226
+ content.to_json
227
+ end
228
+ when TSV
229
+ content.to_s
230
+ when String
231
+ content
232
+ else
233
+ content.to_json
234
+ end
235
+
111
236
  if (String === content) && content.length > max_content_length
112
- exception_msg = "Function #{function_name} #{tool_call_id} (#{Log.fingerprint function_arguments}) returned #{content.length} characters, which is more than the maximum of #{max_content_length}. To protect the model context window this result was not returned."
113
- exception_msg += " The results is made available at '#{step.path}'." if step
237
+ exception_msg = "Function #{function_name} #{tool_call_id} (#{Log.fingerprint function_arguments}) was executed successfully, but it returned #{content.length} characters, which is more than the maximum of #{max_content_length}. To protect the model context window this result was not returned. Here is a fingerprint of the content #{Log.fingerprint(content)}."
238
+ exception_msg += " The results was persisted at '#{step.path}'." if step
114
239
  Log.high exception_msg
115
240
  content = {exception: exception_msg, stack: caller}.to_json
241
+ error = :truncated
116
242
  end
117
243
 
118
244
  Log.high "Called #{function_name} #{tool_call_id} (#{Log.fingerprint function_arguments}): " + Log.fingerprint(content)
@@ -120,13 +246,36 @@ module LLM
120
246
  response_message = {
121
247
  name: function_name,
122
248
  content: content,
123
- id: tool_call_id
249
+ id: tool_call_id,
124
250
  }
125
251
 
252
+ response_message[:error] = error if error
253
+ response_message[:stack] = stack if stack
254
+ meta = [meta] if Hash === meta
255
+ response_message[:meta] = meta if meta && meta.any?
256
+
257
+ if step
258
+ response_message.merge!(
259
+ step: step.short_path,
260
+ start_timestamp: start_timestamp,
261
+ timestamp: Chat.timestamp
262
+ )
263
+ else
264
+ response_message.merge!(
265
+ start_timestamp: start_timestamp,
266
+ timestamp: Chat.timestamp
267
+ )
268
+ end
269
+
126
270
 
271
+ json_content = begin
272
+ response_message.to_json
273
+ rescue
274
+ "Error turning content into JSON (#{$!.message}): #{Log.fingerprint response_message}"
275
+ end
127
276
  [
128
277
  tool_call,
129
- IndiferentHash.setup({role: "function_call_output", content: response_message.to_json})
278
+ IndiferentHash.setup({role: "function_call_output", content: json_content})
130
279
  ]
131
280
  end.flatten
132
281
  end
@@ -85,7 +85,7 @@ Returns a list in the format source~target.
85
85
 
86
86
  if fields.length > 1
87
87
  description = <<-EOF
88
- Return details of association as a dictionary object.
88
+ Return details of a given list of association pairs as a dictionary object. Use the function #{database} to find the associations pairs.
89
89
  Each key is an association and the value is an array with the values of the different fields you asked for, or for all fields otherwise.
90
90
  The fields are: #{fields * ', '}.
91
91
  Multiple values may be present and use the charater ";" to separate them.
@@ -1,5 +1,15 @@
1
1
  require 'scout/workflow'
2
2
  module LLM
3
+ def self.scout_to_tool_input_type(type)
4
+ type = :text if type == :chat
5
+ type = :string if type == :text
6
+ type = :string if type == :select
7
+ type = :string if type == :path
8
+ type = :number if type == :float
9
+ type = :array if type.to_s.end_with?('_array')
10
+ type
11
+ end
12
+
3
13
  def self.task_tool_definition(workflow, task_name, inputs = nil)
4
14
  task_info = workflow.task_info(task_name)
5
15
  return nil if task_info.nil?
@@ -19,20 +29,18 @@ module LLM
19
29
 
20
30
  end
21
31
 
32
+
22
33
  properties = task_info[:inputs].inject({}) do |acc,input|
23
34
  next acc if names and not names.include?(input)
24
35
  type = task_info[:input_types][input]
25
36
  description = task_info[:input_descriptions][input]
26
37
 
27
- type = :string if type == :text
28
- type = :string if type == :select
29
- type = :string if type == :path
30
- type = :number if type == :float
38
+ type = scout_to_tool_input_type(type)
31
39
  type = :array if type.to_s.end_with?('_array')
32
40
 
33
41
  acc[input] = {
34
- "type": type,
35
- "description": description || ''
42
+ type: type,
43
+ description: description || ''
36
44
  }
37
45
 
38
46
  if type == :array
@@ -49,6 +57,13 @@ module LLM
49
57
  acc
50
58
  end
51
59
 
60
+ if not workflow.exec_exports.include?(task_name.to_sym)
61
+ properties[:return_path] = {
62
+ type: 'boolean',
63
+ description: 'Instead of the result of the job, return the path where it is persisted'
64
+ }
65
+ end
66
+
52
67
  required_inputs = task_info[:inputs].select do |input|
53
68
  next if names and not names.include?(input.to_sym)
54
69
  task_info[:input_options].include?(input) && task_info[:input_options][input][:required]
@@ -89,20 +104,21 @@ module LLM
89
104
 
90
105
  def self.call_workflow(workflow, task_name, parameters={})
91
106
  parameters = {} if parameters.nil?
92
- jobname = parameters.delete :jobname
107
+ jobname, return_path, exec_type, allow_recursive = IndiferentHash.process_options parameters, :jobname, :return_path, :exec_type, :allow_recursive
93
108
  begin
94
- exec_type = parameters[:exec_type]
95
109
  job = workflow.job(task_name.to_sym, jobname, parameters)
96
- if workflow.exec_exports.include?(task_name.to_sym) || parameters[:exec_type].to_s == 'exec'
110
+ if workflow.exec_exports.include?(task_name.to_sym) || exec_type.to_s == 'exec'
97
111
  job.exec
98
112
  else
99
- raise ScoutException, 'Potential recursive call' if parameters[:allow_recursive] != 'true' &&
100
- (job.running? and job.info[:pid] == Process.pid)
101
-
102
- #Workflow.produce(job)
103
- #job.join
104
- #job.load
105
- job
113
+ if return_path
114
+ job.run(true)
115
+ Chat.allow_read_job job
116
+ job.path
117
+ else
118
+ raise ScoutException, 'Potential recursive call' if allow_recursive != 'true' &&
119
+ (job.running? and job.info[:pid] == Process.pid)
120
+ job
121
+ end
106
122
  end
107
123
  rescue ScoutException
108
124
  return $!
@@ -7,11 +7,12 @@ class CausalModel < HuggingfaceModel
7
7
  self.eval do |messages,list|
8
8
  model, tokenizer = @state
9
9
  ScoutPython.call_method(
10
- "scout_ai.huggingface.eval", :eval_causal_lm_chat,
11
- model, tokenizer, messages,
10
+ "scout_ai.huggingface.eval", :eval_causal_lm_chat,
11
+ model, tokenizer, messages,
12
12
  options[:chat_template],
13
- options[:chat_template_kwargs],
14
- options[:generation_kwargs]
13
+ options[:chat_template_kwargs],
14
+ options[:generation_kwargs],
15
+ options[:tool_argument]
15
16
  )
16
17
  end
17
18
 
@@ -20,10 +21,27 @@ class CausalModel < HuggingfaceModel
20
21
  model, tokenizer = @state
21
22
 
22
23
  ScoutPython.call_method(
23
- "scout_ai.huggingface.rlhf", :train_rlhf,
24
+ "scout_ai.huggingface.rlhf", :train_rlhf,
24
25
  self.state_file, tokenizer, pairs, labels, options[:rlhf_config]
25
26
  )
26
27
  load_state
27
28
  end
28
29
  end
30
+
31
+ def chat(messages, tools = nil, runtime_options = {})
32
+ init unless @state
33
+ model, tokenizer = @state
34
+
35
+ runtime_options = IndiferentHash.setup(runtime_options)
36
+
37
+ ScoutPython.call_method(
38
+ "scout_ai.huggingface.eval", :eval_causal_lm_response,
39
+ model, tokenizer, messages, tools,
40
+ runtime_options[:chat_template] || options[:chat_template],
41
+ runtime_options[:chat_template_kwargs] || options[:chat_template_kwargs],
42
+ runtime_options[:generation_kwargs] || options[:generation_kwargs],
43
+ runtime_options[:tool_argument] || options[:tool_argument],
44
+ runtime_options[:response_parser] || options[:response_parser]
45
+ )
46
+ end
29
47
  end
@@ -34,7 +34,8 @@ class HuggingfaceModel < TorchModel
34
34
  self.options.except(
35
35
  :training_args, :tokenizer_args,
36
36
  :task, :checkpoint, :class_labels,
37
- :model_options, :return_logits
37
+ :model_options, :return_logits, :chat_template,
38
+ :chat_template_kwargs, :generation_kwargs
38
39
  ))))
39
40
 
40
41
  tokenizer_checkpoint = self.options[:tokenizer_args][:checkpoint] || checkpoint