Module: LLM
- Defined in:
- lib/scout/llm/tools/call.rb,
lib/scout/llm/ask.rb,
lib/scout/llm/rag.rb,
lib/scout/llm/chat.rb,
lib/scout/llm/agent.rb,
lib/scout/llm/embed.rb,
lib/scout/llm/image.rb,
lib/scout/llm/tools.rb,
lib/scout/llm/utils.rb,
lib/scout/llm/tools/mcp.rb,
lib/scout/llm/agent/chat.rb,
lib/scout/llm/agent/save.rb,
lib/scout/llm/tools/call.rb,
lib/scout/llm/backends/glm.rb,
lib/scout/llm/agent/iterate.rb,
lib/scout/llm/backends/vllm.rb,
lib/scout/llm/agent/delegate.rb,
lib/scout/llm/backends/relay.rb,
lib/scout/llm/tools/workflow.rb,
lib/scout/llm/backends/ollama.rb,
lib/scout/llm/backends/openai.rb,
lib/scout/llm/backends/bedrock.rb,
lib/scout/llm/backends/default.rb,
lib/scout/llm/backends/anthropic.rb,
lib/scout/llm/backends/openwebui.rb,
lib/scout/llm/backends/responses.rb,
lib/scout/llm/backends/huggingface.rb,
lib/scout/llm/tools/knowledge_base.rb
Overview
LLM::Agent is referenced below in content dispatch, but the require chain chat -> tools -> tools/call does not pull in scout/llm/agent (agent.rb requires ask.rb, which requires chat.rb, so loading agent from chat would be circular). Load it lazily on first use instead of at file load time.
Defined Under Namespace
Modules: Anthropic, AnthropicMethods, Backend, Bedrock, GLM, GLMAIMethods, Huggingface, HuggingfaceMethods, OLlama, OLlamaMethods, OpenAI, OpenAIMethods, OpenWebUI, OpenWebUIMethods, Relay, Responses, ResponsesMethods, VLLM, VLLMMethods Classes: Agent, RAG
Constant Summary collapse
- BACKENDS =
IndiferentHash.setup({})
Instance Attribute Summary collapse
-
#max_content_length ⇒ Object
Returns the value of attribute max_content_length.
Class Method Summary collapse
- .agent ⇒ Object
- .ask(question, options = {}, &block) ⇒ Object
- .associations ⇒ Object
- .call_id_name_and_arguments(tool_call) ⇒ Object
- .call_knowledge_base(knowledge_base, database, parameters = {}) ⇒ Object
- .call_tools(tool_calls, &block) ⇒ Object
- .call_workflow(workflow, task_name, parameters = {}) ⇒ Object
- .chat(file = [], original = nil) ⇒ Object
- .database_details_tool_definition(database, undirected, fields) ⇒ Object
- .database_tool_definition(database, undirected = false, database_description = nil) ⇒ Object
- .embed(text, options = {}) ⇒ Object
- .get_url_config(key, url = nil, *tokens) ⇒ Object
- .get_url_server_tokens(url, prefix = nil) ⇒ Object
- .image(question, options = {}, &block) ⇒ Object
- .knowledge_base_ask(knowledge_base, question, options = {}) ⇒ Object
- .knowledge_base_tool_definition(knowledge_base, databases = nil) ⇒ Object
- .load_agent ⇒ Object
- .mcp_tools(url, options = {}) ⇒ Object
- .messages(question, role = nil) ⇒ Object
-
.meta_receipt_from_messages(messages) ⇒ Object
Normalize serialized meta messages (the chat-message shape 'meta', content: 'k=v k=v ...') into the receipt format: an Array of DESERIALIZED field Hashes, as emitted under the
metakey of function_call_output envelopes. - .options ⇒ Object
- .print ⇒ Object
- .process_calls(tools, calls, &block) ⇒ Object
- .purge ⇒ Object
- .register_backend(name, mod) ⇒ Object
- .run_tools(messages) ⇒ Object
- .scout_to_tool_input_type(type) ⇒ Object
- .task_tool_definition(workflow, task_name, inputs = nil) ⇒ Object
- .tool_response(tool_call, &block) ⇒ Object
- .tools ⇒ Object
- .workflow_ask(workflow, question, options = {}) ⇒ Object
- .workflow_tools(workflow, tasks = nil) ⇒ Object
Instance Attribute Details
#max_content_length ⇒ Object
Returns the value of attribute max_content_length.
11 12 13 |
# File 'lib/scout/llm/tools/call.rb', line 11 def max_content_length @max_content_length end |
Class Method Details
.agent ⇒ Object
4 5 6 |
# File 'lib/scout/llm/agent.rb', line 4 def self.agent(...) LLM::Agent.new(...) end |
.ask(question, options = {}, &block) ⇒ Object
12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 |
# File 'lib/scout/llm/ask.rb', line 12 def self.ask(question, = {}, &block) = LLM.chat(question) = IndiferentHash.add_defaults , LLM.() endpoint, persist, agent_save_file = IndiferentHash. , :endpoint, :persist, :agent_save_file, persist: true persist ||= Scout::Config.get :persist, :ask, :llm, env: 'ASK_PERSIST,LLM_PERSIST,PERSIST' endpoint ||= Scout::Config.get :endpoint, :ask, :llm, env: 'ASK_ENDPOINT,LLM_ENDPOINT,ENDPOINT,LLM,ASK' if endpoint && Scout.etc.AI[endpoint].find_with_extension(:yaml).exists? = IndiferentHash.add_defaults , Scout.etc.AI[endpoint].yaml elsif endpoint && endpoint != "" raise "Endpoint not found #{endpoint}" end agent_name = IndiferentHash. , :agent agent_name = nil if %(none false nil).include?(agent_name.to_s) if agent_name [:endpoint] ||= endpoint agent = LLM::Agent.load_agent agent_name agent.save_file = agent_save_file if agent_save_file agent.follow res = agent.chat return res end job_paths = .job_paths = Chat.() [:current_meta] = if and .any? if [:backend].to_s == 'responses' && [:previous_response].to_s != 'false' = Chat.clear(, 'previous_response_id') else = Chat.clean(, 'previous_response_id') .delete :previous_response_id end tools = [:tools] if tools # ScoutCoder: options[:tools] may be either the internal Hash shape # ({name => [obj, definition]}) or a plain Array of provider-style # definitions (as in the tests and InfrastructureProbes); only the # Hash shape has #keys, so fingerprint accordingly. tool_names = Hash === tools ? tools.keys : tools.collect { |t| t[:name] || t.dig(:function, :name) } Log.high Log.color(:green, "Asking #{endpoint || options[:endpoint] || 'client'}: #{options[:previous_response_id]}\n" + Chat.print_brief()) Log.medium "Tools: #{Log.fingerprint tool_names}" if tool_names&.any? Log.debug "#{Log.fingerprint tools}}" else Log.high Log.color :green, "Asking #{endpoint || options[:endpoint] || 'client'}: #{options[:previous_response_id]}\n" + Chat.print_brief() end persist = false if persist.to_s.downcase == 'false' res = Persist.persist(endpoint, :json, prefix: "LLM ask", other: .merge(messages: ), persist: persist, dir: Scout.var.cache.ask) do backend = IndiferentHash. , :backend backend ||= Scout::Config.get :backend, :ask, :llm, env: 'ASK_BACKEND,LLM_BACKEND', default: :responses job_paths.each do |job_path| begin job = Step.load Path.setup(job_path) jobs = [job] + job.rec_dependencies.to_a jobs.each do |job| Chat.allow_read_job job end rescue Log.exception $! Log.warn "Could not load #{job_path}" end end case backend when :openai, "openai" require_relative 'backends/openai' LLM::OpenAI.ask(, , &block) when :anthropic, "anthropic" require_relative 'backends/anthropic' LLM::Anthropic.ask(, , &block) when :responses, "responses" require_relative 'backends/responses' LLM::Responses.ask(, , &block) when :ollama, "ollama" require_relative 'backends/ollama' LLM::OLlama.ask(, , &block) when :vllm, "vllm" require_relative 'backends/vllm' LLM::VLLM.ask(, , &block) when :openwebui, "openwebui" require_relative 'backends/openwebui' LLM::OpenWebUI.ask(, , &block) when :huggingface, "huggingface" require_relative 'backends/huggingface' LLM::Huggingface.ask(, , &block) when :relay, "relay" require_relative 'backends/relay' LLM::Relay.ask(, , &block) when :bedrock, "bedrock" require_relative 'backends/bedrock' LLM::Bedrock.ask(, , &block) when :glm, "glm" require_relative 'backends/glm' LLM::GLM.ask(, , &block) else mod = BACKENDS[backend] raise "Unknown backend: #{backend}" if mod.nil? mod.ask(, , &block) end end Chat.setup res if Array === res Log.high Log.color :blue, "Response:\n" + Chat.print_brief(res, %w(meta assistant)) if Array === res res end |
.associations ⇒ Object
76 77 78 |
# File 'lib/scout/llm/chat.rb', line 76 def self.associations(...) Chat.associations(...) end |
.call_id_name_and_arguments(tool_call) ⇒ Object
13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
# File 'lib/scout/llm/tools/call.rb', line 13 def self.call_id_name_and_arguments(tool_call) tool_call_id = tool_call.dig("call_id") || tool_call.dig("id") || tool_call.dig('tool_call_id') if tool_call['function'] function_name = tool_call.dig("function", "name") function_arguments = tool_call.dig("function", "arguments") else function_name = tool_call.dig("name") function_arguments = tool_call.dig("arguments") end function_arguments = JSON.parse(function_arguments, { symbolize_names: true }) if String === function_arguments [tool_call_id, function_name, function_arguments] end |
.call_knowledge_base(knowledge_base, database, parameters = {}) ⇒ Object
131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 |
# File 'lib/scout/llm/tools/knowledge_base.rb', line 131 def self.call_knowledge_base(knowledge_base, database, parameters={}) if database.end_with?('_association_details') database = database.sub('_association_details', '') associations, fields = IndiferentHash. parameters, :associations, :fields # Dumb associations = JSON.parse associations if String === associations index = knowledge_base.get_index(database) if fields field_pos = fields.collect{|f| index.identify_field f } associations.each_with_object({}) do |a,hash| values = index[a] next if values.nil? hash[a] = values.values_at *field_pos end else associations.each_with_object({}) do |a,hash| values = index[a] next if values.nil? hash[a] = values.to_hash end end else entities, reverse = IndiferentHash. parameters, :entities, :reverse # Dumb entities = JSON.parse entities if String === entities if reverse knowledge_base.parents(database, entities) else knowledge_base.children(database, entities) end end end |
.call_tools(tool_calls, &block) ⇒ Object
7 8 9 10 11 12 13 14 15 16 17 |
# File 'lib/scout/llm/tools.rb', line 7 def self.call_tools(tool_calls, &block) tool_calls.collect{|tool_call| = LLM.tool_response(tool_call, &block) function_call = tool_call function_call['id'] = tool_call.delete('call_id') if tool_call.dig('call_id') [ {role: "function_call", content: tool_call.to_json}, {role: "function_call_output", content: .to_json}, ] }.flatten end |
.call_workflow(workflow, task_name, parameters = {}) ⇒ Object
105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 |
# File 'lib/scout/llm/tools/workflow.rb', line 105 def self.call_workflow(workflow, task_name, parameters={}) parameters = {} if parameters.nil? jobname, return_path, exec_type, allow_recursive = IndiferentHash. parameters, :jobname, :return_path, :exec_type, :allow_recursive begin job = workflow.job(task_name.to_sym, jobname, parameters) if workflow.exec_exports.include?(task_name.to_sym) || exec_type.to_s == 'exec' job.exec else if return_path job.run(true) Chat.allow_read_job job job.path else raise ScoutException, 'Potential recursive call' if allow_recursive != 'true' && (job.running? and job.info[:pid] == Process.pid) job end end rescue ScoutException return $! end end |
.chat(file = [], original = nil) ⇒ Object
30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 |
# File 'lib/scout/llm/chat.rb', line 30 def self.chat(file = [], original = nil) original ||= (String === file and Open.exists?(file)) ? file : Path.setup($0.dup) caller_lib_dir = Path.caller_lib_dir(nil, 'chats') # ScoutCoder: anchor point for per-project library stores. The chat file # is the object of the ask, so its libdir — not the framework's stack — # defines "the project this chat belongs to". Guard against marker climbs # that overshoot to nil/''/'/', and never clobber an explicit value: ENV # propagates into job subprocesses (bwrap) while Dir.pwd under exec is the # workflow's own directory, so ENV is the only reliable channel. if ENV['SCOUT_CHAT_DIR'].to_s.empty? && caller_lib_dir && ! ['', '/'].include?(caller_lib_dir) ENV['SCOUT_CHAT_DIR'] = Path.caller_lib_dir(original) end if Path.is_filename? file = self. Open.read(file), file else = self. file end = Chat.indiferent = Chat.imports , original, caller_lib_dir = Chat.clear = Chat.clean , :skip = Chat.config = Chat.tasks = Chat.jobs = Chat.files , original, caller_lib_dir Chat.setup end |
.database_details_tool_definition(database, undirected, fields) ⇒ Object
61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 |
# File 'lib/scout/llm/tools/knowledge_base.rb', line 61 def self.database_details_tool_definition(database, undirected, fields) if undirected properties = { associations: { type: "array", items: { type: :string }, description: "Associations in the form of source~target or target~source" }, fields: { type: "array", items: { type: :string }, description: "Limit the response to these fields" }, } else properties = { associations: { type: "array", items: { type: :string }, description: "Associations in the form of source~target" }, } end if fields.length > 1 description = "Return details of a given list of association pairs as a dictionary object. Use the function \#{database} to find the associations pairs. \nEach 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.\nThe fields are: \#{fields * ', '}.\nMultiple values may be present and use the charater \";\" to separate them.\n EOF\n else\n properties.delete(:fields)\n description = <<-EOF\nReturn the \#{fields.first} of association.\nMultiple values may be present and use the charater \";\" to separate them.\n EOF\n end\n\n function = {\n name: database.to_s + '_association_details',\n description: description,\n parameters: {\n type: \"object\",\n properties: properties,\n required: ['associations']\n }\n }\n\n IndiferentHash.setup function\nend\n" |
.database_tool_definition(database, undirected = false, database_description = nil) ⇒ Object
4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 |
# File 'lib/scout/llm/tools/knowledge_base.rb', line 4 def self.database_tool_definition(database, undirected = false, database_description = nil) if undirected properties = { entities: { type: "array", items: { type: :string }, description: "Entities for which to find associations" }, } else properties = { entities: { type: "array", items: { type: :string }, description: 'Source entities in the association, or target entities if "reverse" is "true"' }, reverse: { type: "boolean", description: 'Look for targets instead of sources, defaults to "false"' } } end if database_description and not database_description.strip.empty? description = "Find associations for a list of entities in database \#{database}: \#{database_description}\n EOF\n else\n description = <<-EOF\nFind associations for a list of entities in database \#{database}.\n EOF\n end\n\n if undirected\n description += <<-EOF\nReturns a list in the format entity~partner.\n EOF\n else\n description += <<-EOF\nReturns a list in the format source~target.\n EOF\n end\n\n function = {\n name: database,\n description: description,\n parameters: {\n type: \"object\",\n properties: properties,\n required: ['entities']\n }\n }\n\n IndiferentHash.setup function.merge(type: 'function', function: function)\nend\n" |
.embed(text, options = {}) ⇒ Object
4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 |
# File 'lib/scout/llm/embed.rb', line 4 def self.(text, = {}) endpoint = IndiferentHash. , :endpoint endpoint ||= Scout::Config.get :endpoint, :embed, :llm, env: 'EMBED_ENDPOINT,LLM_ENDPOINT', default: :embed if endpoint && Scout.etc.AI[endpoint].exists? = IndiferentHash.add_defaults , Scout.etc.AI[endpoint].yaml end backend = IndiferentHash. , :backend backend ||= Scout::Config.get :backend, :embed, :llm, env: 'EMBED_BACKEND,LLM_BACKEND', default: :embed case backend when :openai, "openai" require_relative 'backends/openai' LLM::OpenAI.(text, ) when :responses, "responses" require_relative 'backends/responses' LLM::OpenAI.(text, ) when :ollama, "ollama" require_relative 'backends/ollama' LLM::OLlama.(text, ) when :openwebui, "openwebui" require_relative 'backends/openwebui' LLM::OpenWebUI.(text, ) when :huggingface, "huggingface" require_relative 'backends/huggingface' LLM::Huggingface.(text, ) when :relay, "relay" require_relative 'backends/relay' LLM::Relay.(text, ) else # Fall back to the runtime backend registry (mirrors LLM.ask) so # test-only or plugin backends registered with LLM.register_backend # work for embeddings too, instead of only for ask. mod = LLM::BACKENDS[backend] raise "Unknown backend: #{backend}" if mod.nil? mod.(text, ) end end |
.get_url_config(key, url = nil, *tokens) ⇒ Object
15 16 17 18 19 20 21 22 23 24 |
# File 'lib/scout/llm/utils.rb', line 15 def self.get_url_config(key, url = nil, *tokens) hash = tokens.pop if Hash === tokens.last if url url_tokens = tokens.inject([]){|acc,prefix| acc.concat(get_url_server_tokens(url, prefix))} all_tokens = url_tokens + tokens else all_tokens = tokens end Scout::Config.get(key, *all_tokens, hash) end |
.get_url_server_tokens(url, prefix = nil) ⇒ Object
2 3 4 5 6 7 8 9 10 11 12 13 |
# File 'lib/scout/llm/utils.rb', line 2 def self.get_url_server_tokens(url, prefix=nil) return get_url_server_tokens(url).collect{|e| prefix.to_s + "." + e } if prefix server = url.match(/(?:https?:\/\/)?([^\/:]*)/)[1] || "NOSERVER" parts = server.split(".") parts.pop if parts.last.length <= 3 combinations = [] (1..parts.length).each do |l| parts.each_cons(l){|p| combinations << p*"."} end (parts + combinations + [server]).uniq end |
.image(question, options = {}, &block) ⇒ Object
3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 |
# File 'lib/scout/llm/image.rb', line 3 def self.image(question, = {}, &block) = LLM.chat(question) = IndiferentHash.add_defaults LLM.(), endpoint, persist = IndiferentHash. , :endpoint, :persist, persist: true endpoint ||= Scout::Config.get :endpoint, :image, :ask, :llm, env: 'IMAGE_ENDPOINT,ASK_ENDPOINT,LLM_ENDPOINT,ENDPOINT,LLM,ASK,IMAGE' if endpoint && Scout.etc.AI[endpoint].find_with_extension(:yaml).exists? = IndiferentHash.add_defaults , Scout.etc.AI[endpoint].yaml elsif endpoint && endpoint != "" raise "Endpoint not found #{endpoint}" end agent_name = IndiferentHash. , :agent agent_name = nil if %(none false nil).include?(agent_name.to_s) if agent_name [:endpoint] ||= endpoint agent = LLM::Agent.load_agent agent_name agent.follow res = agent.ask return res end = Chat.() [:current_meta] = if and .any? if [:backend].to_s == 'responses' && [:previous_response].to_s != 'false' = Chat.clear(, 'previous_response_id') else = Chat.clean(, 'previous_response_id') .delete :previous_response_id end Log.high Log.color :green, "Asking #{endpoint || options[:endpoint] || 'client'}: #{options[:previous_response_id]}\n" + Chat.print_brief() tools = [:tools] Log.medium "Tools: #{Log.fingerprint tools.keys}" if tools Log.debug "#{Log.fingerprint tools}}" if tools res = Persist.persist(endpoint, :json, prefix: "LLM image", other: .merge(messages: ), persist: persist, dir: Scout.var.cache.ask) do backend = IndiferentHash. , :backend backend ||= Scout::Config.get :backend, :ask, :llm, env: 'ASK_BACKEND,LLM_BACKEND', default: :responses case backend when :openai, "openai" require_relative 'backends/openai' LLM::OpenAI.image(, , &block) when :anthropic, "anthropic" require_relative 'backends/anthropic' LLM::Anthropic.image(, , &block) when :responses, "responses" require_relative 'backends/responses' LLM::Responses.image(, , &block) when :ollama, "ollama" require_relative 'backends/ollama' LLM::OLlama.image(, , &block) when :vllm, "vllm" require_relative 'backends/vllm' LLM::VLLM.image(, , &block) when :openwebui, "openwebui" require_relative 'backends/openwebui' LLM::OpenWebUI.image(, , &block) when :huggingface, "huggingface" require_relative 'backends/huggingface' LLM::Huggingface.image(, , &block) when :relay, "relay" require_relative 'backends/relay' LLM::Relay.image(, , &block) when :bedrock, "bedrock" require_relative 'backends/bedrock' LLM::Bedrock.image(, , &block) else mod = BACKENDS[backend] raise "Unknown backend: #{backend}" if mod.nil? mod.ask(, , &block) end end Chat.setup res if Array === res Log.high Log.color :blue, "Response:\n" + Chat.print_brief(res, %w(meta assistant)) if Array === res res end |
.knowledge_base_ask(knowledge_base, question, options = {}) ⇒ Object
133 134 135 136 137 138 139 140 141 |
# File 'lib/scout/llm/ask.rb', line 133 def self.knowledge_base_ask(knowledge_base, question, = {}) knowledge_base_tools = LLM.knowledge_base_tool_definition(knowledge_base) self.ask(question, .merge(tools: knowledge_base_tools)) do |task_name,parameters| parameters = IndiferentHash.setup(parameters) database, entities = parameters.values_at "database", "entities" Log.info "Finding #{entities} children in #{database}" knowledge_base.children(database, entities).collect{|e| e.sub('~', '=>')} end end |
.knowledge_base_tool_definition(knowledge_base, databases = nil) ⇒ Object
115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 |
# File 'lib/scout/llm/tools/knowledge_base.rb', line 115 def self.knowledge_base_tool_definition(knowledge_base, databases = nil) databases ||= knowledge_base.all_databases databases.inject({}){|tool_definitions,database| database_description = knowledge_base.description(database) undirected = knowledge_base.undirected(database) definition = self.database_tool_definition(database, undirected, database_description) tool_definitions.merge!(database => [knowledge_base, definition]) if (fields = knowledge_base.get_database(database).fields).any? details_definition = self.database_details_tool_definition(database, undirected, fields) tool_definitions.merge!(database.to_s + '_association_details' => [knowledge_base, details_definition]) end tool_definitions } end |
.load_agent ⇒ Object
8 9 10 |
# File 'lib/scout/llm/agent.rb', line 8 def self.load_agent(...) LLM::Agent.load_agent(...) end |
.mcp_tools(url, options = {}) ⇒ Object
5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 |
# File 'lib/scout/llm/tools/mcp.rb', line 5 def self.mcp_tools(url, = {}) timeout = Scout::Config.get :timeout, :mcp, :tools = IndiferentHash.add_defaults , read_timeout: timeout.to_i if timeout && timeout != "" if url == 'stdio' client = MCPClient.create_client(mcp_server_configs: [.merge(type: 'stdio')]) else type = IndiferentHash. , :type, type: (Open.remote?(url) ? :http : :stdio) if url && Open.remote?(url) token ||= LLM.get_url_config(:key, url, :mcp) [:headers] = { 'Authorization' => "Bearer #{token}" } end client = MCPClient.create_client(mcp_server_configs: [.merge(type: 'http', url: url)]) end tools = client.list_tools tool_definitions = IndiferentHash.setup({}) tools.each do |tool| name = tool.name description = tool.description schema = tool.schema function = { name: name, description: description, parameters: schema } definition = IndiferentHash.setup function.merge(type: 'function', function: function) block = Proc.new do |name,params| res = tool.server.call_tool(name, params) if Hash === res && res['content'] res = res['content'] end if Array === res and res.length == 1 res = res.first end if Hash === res && res['content'] res = res['content'] end if Hash === res && res['text'] res = res['text'] end res end tool_definitions[name] = [block, definition] end tool_definitions end |
.messages(question, role = nil) ⇒ Object
14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
# File 'lib/scout/llm/chat.rb', line 14 def self.(question, role = nil) default_role = "user" if Array === question return question.collect do |q| if String === q {role: role || default_role, content: q} else q end end end Chat.parse question end |
.meta_receipt_from_messages(messages) ⇒ Object
Normalize serialized meta messages (the chat-message shape
'meta', content: 'k=v k=v ...') into the receipt format: an Array
of DESERIALIZED field Hashes, as emitted under the meta key of
function_call_output envelopes.
Non-Hash entries, non-meta roles, non-String contents and entries that parse to no fields at all are dropped. The reader side keeps the full malformed-entry warning taxonomy; the writer side simply never emits an entry that carries no evidence.
37 38 39 40 41 42 43 44 45 46 |
# File 'lib/scout/llm/tools/call.rb', line 37 def self.() Array().collect do |msg| next nil unless Hash === msg role = msg[:role] || msg['role'] content = msg[:content] || msg['content'] next nil unless role.to_s == 'meta' && String === content fields = Chat.(content) fields.empty? ? nil : fields end.compact end |
.options ⇒ Object
64 65 66 |
# File 'lib/scout/llm/chat.rb', line 64 def self.(...) Chat.(...) end |
.print ⇒ Object
68 69 70 |
# File 'lib/scout/llm/chat.rb', line 68 def self.print(...) Chat.print(...) end |
.process_calls(tools, calls, &block) ⇒ Object
48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 |
# File 'lib/scout/llm/tools/call.rb', line 48 def self.process_calls(tools, calls, &block) max_content_length = LLM.max_content_length IndiferentHash.setup tools = Chat. tool_call_content = calls.collect do |tool_call| tool_call = IndiferentHash.setup tool_call tool_call_id, function_name, function_arguments = call_id_name_and_arguments(tool_call) raise "No tool_call_id in #{ tool_call}" if tool_call_id.nil? function_arguments = IndiferentHash.setup function_arguments obj, definition = tools[function_name] definition = obj if Hash === obj defaults = definition[:parameters][:defaults] if definition && definition[:parameters] function_arguments = function_arguments.merge(defaults) if defaults Log.high "Calling #{function_name} (#{Log.fingerprint function_arguments}): " function_response = case obj when Proc obj.call function_name, function_arguments when String if Kernel.const_defined? obj wt = Kernel.const_get obj else wf = Workflow.require_workflow obj end call_workflow(wf, function_name, function_arguments) when Workflow call_workflow(obj, function_name, function_arguments) when KnowledgeBase call_knowledge_base(obj, function_name, function_arguments.dup) else if block_given? block.call function_name, function_arguments else ParameterException.new "Tool or function not found '#{function_name}'. Called with parameters #{Log.fingerprint function_arguments}" if obj.nil? && definition.nil? end end content = case function_response when Step function_response when String function_response when IO function_response.read when TSV::Dumper function_response.read when LLM::Agent function_response when nil "success" when Exception function_response when Hash IndiferentHash.setup(function_response) else begin function_response.to_json rescue Exception => e begin function_response.to_s rescue {exception: e., stack: e.backtrace }.to_json end end end content = content.to_s if Numeric === content function_call = tool_call.dup function_call = {'name' => tool_call['name']}.merge tool_call.except('name') function_call['id'] = function_call.delete('call_id') if function_call.dig('call_id') [ function_name, function_arguments, tool_call_id, IndiferentHash.setup({role: "function_call", content: function_call.to_json}), content ] end jobs = tool_call_content.collect{|p| p.last }.select{|c| Step === c } if jobs.reject{|job| job.done? }.any? begin Workflow.produce jobs rescue end end agents = tool_call_content.collect{|p| p.last }.select{|c| LLM::Agent === c } agent_answers = TSV.setup({}, key_field: 'Pos', fields: ['Content', 'Job path'], type: :list) if agents.any? cpus = Scout::Config.get(:cpus, :agent_ask, :agents, env: 'ASK_AGENTS', default: 3) Open.traverse (0..agents.length-1).to_a, cpus: cpus, bar: 'Asking agents', type: :list, into: agent_answers do |i| agent = agents[i] res = agent.chat return_messages: true path = Step === agent.job ? agent.job.path : nil [i, [res, path]] end end tool_call_content.collect do |function_name,function_arguments,tool_call_id,tool_call,content| error = false stack = nil = [] if Step === content step = content if content.done? content = content.load elsif content.error? && content.exception error = :error content = if String === content.exception {exception: content.exception}.to_json else content = {exception: content.exception., exception_line: content.exception.backtrace&.first}.to_json end else begin content = content.run rescue Exception error = :error stack = $!.backtrace content = {exception: $!., exception_line: $!.backtrace&.first}.to_json end end elsif LLM::Agent === content res, path = agent_answers[agents.index(content)] begin Chat.allow_read_job Step.load(path) rescue end if path content.current_chat.follow(res) # Receipt format: the child agent's meta messages are DESERIALIZED # into plain field Hashes and emitted under the `meta` key (the # legacy serialized `agent_meta` array is no longer written). # Entries that parse to no fields are dropped: a receipt entry # exists to carry evidence fields, and an empty one carries none. = LLM.(Chat.find_role(res, :meta)) content = content.answer elsif Exception === content error = :error stack = content.backtrace content = {exception: content., exception_line: content.backtrace&.first}.to_json else step = nil end content = case content when Hash # ScoutCoder: When the response of the function contains a # Hash with only two keys, content and meta or content and # agent_meta treat it as content with inference meta. Extract # accordingly. content = IndiferentHash.setup(content) keys = content.keys.collect{|k| k.to_s } if keys.sort == %w(meta content) # New inbound shape: `meta` is already the deserialized # receipt array; pass it through verbatim. , content = content.values_at :meta, :content content elsif keys.sort == %w(agent_meta content) # Legacy inbound shape: serialized meta messages; # normalize them into the new deserialized form. = LLM.(content[:agent_meta]) content = content[:content] else content.to_json end when TSV content.to_s when String content else content.to_json end if (String === content) && content.length > max_content_length 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)}." exception_msg += " The results was persisted at '#{step.path}'." if step Log.high exception_msg content = {exception: exception_msg, stack: caller}.to_json error = :truncated end Log.high "Called #{function_name} #{tool_call_id} (#{Log.fingerprint function_arguments}): " + Log.fingerprint(content) = { name: function_name, content: content, id: tool_call_id, } [:error] = error if error [:stack] = stack if stack = [] if Hash === [:meta] = if && .any? if step .merge!( step: step.short_path, start_timestamp: , timestamp: Chat. ) else .merge!( start_timestamp: , timestamp: Chat. ) end json_content = begin .to_json rescue "Error turning content into JSON (#{$!.message}): #{Log.fingerprint response_message}" end [ tool_call, IndiferentHash.setup({role: "function_call_output", content: json_content}) ] end.flatten end |
.purge ⇒ Object
80 81 82 |
# File 'lib/scout/llm/chat.rb', line 80 def self.purge(...) Chat.purge(...) end |
.register_backend(name, mod) ⇒ Object
8 9 10 |
# File 'lib/scout/llm/ask.rb', line 8 def self.register_backend(name, mod) BACKENDS[name] = mod end |
.run_tools(messages) ⇒ Object
57 58 59 60 61 62 63 64 65 66 67 68 69 70 |
# File 'lib/scout/llm/tools.rb', line 57 def self.run_tools() .collect do |info| IndiferentHash.setup(info) role = info[:role] if role == 'cmd' { role: 'tool', content: CMD.cmd(info[:content]).read } else info end end end |
.scout_to_tool_input_type(type) ⇒ Object
3 4 5 6 7 8 9 10 11 |
# File 'lib/scout/llm/tools/workflow.rb', line 3 def self.scout_to_tool_input_type(type) type = :text if type == :chat type = :string if type == :text type = :string if type == :select type = :string if type == :path type = :number if type == :float type = :array if type.to_s.end_with?('_array') type end |
.task_tool_definition(workflow, task_name, inputs = nil) ⇒ Object
13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 |
# File 'lib/scout/llm/tools/workflow.rb', line 13 def self.task_tool_definition(workflow, task_name, inputs = nil) task_info = workflow.task_info(task_name) return nil if task_info.nil? if inputs names = [] defaults = {} inputs.each do |i| if String === i && i.include?('=') name,_ , value = i.partition("=") defaults[name] = value else names << i.to_sym end end end properties = task_info[:inputs].inject({}) do |acc,input| next acc if names and not names.include?(input) type = task_info[:input_types][input] description = task_info[:input_descriptions][input] type = scout_to_tool_input_type(type) type = :array if type.to_s.end_with?('_array') acc[input] = { type: type, description: description || '' } if type == :array acc[input]['items'] = {type: :string} end if = task_info[:input_options][input] if = [:select_options] = .values if Hash === acc[input]["enum"] = end end acc end if not workflow.exec_exports.include?(task_name.to_sym) properties[:return_path] = { type: 'boolean', description: 'Instead of the result of the job, return the path where it is persisted' } end required_inputs = task_info[:inputs].select do |input| next if names and not names.include?(input.to_sym) task_info[:input_options].include?(input) && task_info[:input_options][input][:required] end function = { name: task_name, description: task_info[:description] || '', parameters: { type: "object", properties: properties, required: required_inputs, } } function[:parameters][:defaults] = defaults if defaults #IndiferentHash.setup function.merge(type: 'function', function: function) IndiferentHash.setup function end |
.tool_response(tool_call, &block) ⇒ Object
19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 |
# File 'lib/scout/llm/tools.rb', line 19 def self.tool_response(tool_call, &block) tool_call_id = tool_call.dig("call_id") || tool_call.dig("id") if tool_call['function'] function_name = tool_call.dig("function", "name") function_arguments = tool_call.dig("function", "arguments") else function_name = tool_call.dig("name") function_arguments = tool_call.dig("arguments") end function_arguments = JSON.parse(function_arguments, { symbolize_names: true }) if String === function_arguments Log.high "Calling function #{function_name} with arguments #{Log.fingerprint function_arguments}" function_response = begin block.call function_name, function_arguments rescue $! end content = case function_response when String function_response when nil "success" when Exception {exception: function_response., stack: function_response.backtrace }.to_json else function_response.to_json end content = content.to_s if Numeric === content { id: tool_call_id, role: "tool", content: content } end |
.tools ⇒ Object
72 73 74 |
# File 'lib/scout/llm/chat.rb', line 72 def self.tools(...) Chat.tools(...) end |
.workflow_ask(workflow, question, options = {}) ⇒ Object
126 127 128 129 130 131 |
# File 'lib/scout/llm/ask.rb', line 126 def self.workflow_ask(workflow, question, = {}) workflow_tools = LLM.workflow_tools(workflow) self.ask(question, .merge(tools: workflow_tools)) do |task_name,parameters| workflow.job(task_name, parameters).run end end |
.workflow_tools(workflow, tasks = nil) ⇒ Object
88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 |
# File 'lib/scout/llm/tools/workflow.rb', line 88 def self.workflow_tools(workflow, tasks = nil) if Array === workflow workflow.inject({}){|tool_definitions,wf| tool_definitions.merge(workflow_tools(wf, tasks)) } else tasks = workflow.all_exports if tasks.nil? tasks = workflow.all_tasks if tasks.empty? && workflow.all_tasks tasks = [] if tasks.nil? tasks.inject({}){|tool_definitions,task_name| definition = self.task_tool_definition(workflow, task_name) next if definition.nil? tool_definitions.merge(task_name => [workflow, definition]) } end end |