llm_chain 0.4.0 → 0.5.1
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +51 -0
- data/README.md +516 -102
- data/examples/quick_demo.rb +93 -0
- data/examples/tools_example.rb +255 -0
- data/lib/llm_chain/chain.rb +24 -5
- data/lib/llm_chain/client_registry.rb +0 -1
- data/lib/llm_chain/clients/qwen.rb +13 -1
- data/lib/llm_chain/tools/base_tool.rb +81 -0
- data/lib/llm_chain/tools/calculator.rb +154 -0
- data/lib/llm_chain/tools/code_interpreter.rb +242 -0
- data/lib/llm_chain/tools/tool_manager.rb +204 -0
- data/lib/llm_chain/tools/web_search.rb +305 -0
- data/lib/llm_chain/version.rb +1 -1
- data/lib/llm_chain.rb +68 -18
- metadata +10 -2
@@ -0,0 +1,242 @@
|
|
1
|
+
require 'tempfile'
|
2
|
+
require 'timeout'
|
3
|
+
|
4
|
+
module LLMChain
|
5
|
+
module Tools
|
6
|
+
class CodeInterpreter < BaseTool
|
7
|
+
KEYWORDS = %w[
|
8
|
+
code run execute script program
|
9
|
+
ruby python javascript
|
10
|
+
calculate compute
|
11
|
+
def class function
|
12
|
+
].freeze
|
13
|
+
|
14
|
+
SUPPORTED_LANGUAGES = %w[ruby python javascript].freeze
|
15
|
+
|
16
|
+
DANGEROUS_PATTERNS = [
|
17
|
+
/system\s*\(/i,
|
18
|
+
/exec\s*\(/i,
|
19
|
+
/`[^`]*`/,
|
20
|
+
/File\.(delete|rm|unlink)/i,
|
21
|
+
/Dir\.(delete|rmdir)/i,
|
22
|
+
/require\s+['"]net\/http['"]/i,
|
23
|
+
/require\s+['"]open-uri['"]/i,
|
24
|
+
/eval\s*\(/i,
|
25
|
+
/instance_eval/i,
|
26
|
+
/class_eval/i
|
27
|
+
].freeze
|
28
|
+
|
29
|
+
def initialize(timeout: 30, allowed_languages: SUPPORTED_LANGUAGES)
|
30
|
+
@timeout = timeout
|
31
|
+
@allowed_languages = allowed_languages
|
32
|
+
|
33
|
+
super(
|
34
|
+
name: "code_interpreter",
|
35
|
+
description: "Executes code safely in an isolated environment",
|
36
|
+
parameters: {
|
37
|
+
code: {
|
38
|
+
type: "string",
|
39
|
+
description: "Code to execute"
|
40
|
+
},
|
41
|
+
language: {
|
42
|
+
type: "string",
|
43
|
+
description: "Programming language (ruby, python, javascript)",
|
44
|
+
enum: @allowed_languages
|
45
|
+
}
|
46
|
+
}
|
47
|
+
)
|
48
|
+
end
|
49
|
+
|
50
|
+
def match?(prompt)
|
51
|
+
contains_keywords?(prompt, KEYWORDS) ||
|
52
|
+
contains_code_blocks?(prompt) ||
|
53
|
+
contains_function_definitions?(prompt)
|
54
|
+
end
|
55
|
+
|
56
|
+
def call(prompt, context: {})
|
57
|
+
code = extract_code(prompt)
|
58
|
+
language = detect_language(code, prompt)
|
59
|
+
|
60
|
+
return "No code found to execute" if code.empty?
|
61
|
+
return "Unsupported language: #{language}" unless @allowed_languages.include?(language)
|
62
|
+
|
63
|
+
begin
|
64
|
+
if safe_to_execute?(code)
|
65
|
+
result = execute_code(code, language)
|
66
|
+
{
|
67
|
+
code: code,
|
68
|
+
language: language,
|
69
|
+
result: result,
|
70
|
+
formatted: format_execution_result(code, language, result)
|
71
|
+
}
|
72
|
+
else
|
73
|
+
{
|
74
|
+
code: code,
|
75
|
+
language: language,
|
76
|
+
error: "Code contains potentially dangerous operations",
|
77
|
+
formatted: "Cannot execute: Code contains potentially dangerous operations"
|
78
|
+
}
|
79
|
+
end
|
80
|
+
rescue => e
|
81
|
+
{
|
82
|
+
code: code,
|
83
|
+
language: language,
|
84
|
+
error: e.message,
|
85
|
+
formatted: "Execution error: #{e.message}"
|
86
|
+
}
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
def extract_parameters(prompt)
|
91
|
+
code = extract_code(prompt)
|
92
|
+
{
|
93
|
+
code: code,
|
94
|
+
language: detect_language(code, prompt)
|
95
|
+
}
|
96
|
+
end
|
97
|
+
|
98
|
+
private
|
99
|
+
|
100
|
+
def contains_code_blocks?(prompt)
|
101
|
+
prompt.include?('```') ||
|
102
|
+
prompt.match?(/^\s*def\s+\w+/m) ||
|
103
|
+
prompt.match?(/^\s*class\s+\w+/m)
|
104
|
+
end
|
105
|
+
|
106
|
+
def contains_function_definitions?(prompt)
|
107
|
+
prompt.match?(/\b(def|function|class)\s+\w+/i)
|
108
|
+
end
|
109
|
+
|
110
|
+
def extract_code(prompt)
|
111
|
+
# Ищем код в блоках ```
|
112
|
+
code_block = prompt.match(/```(?:ruby|python|javascript|js)?\s*\n(.*?)\n```/m)
|
113
|
+
return code_block[1].strip if code_block
|
114
|
+
|
115
|
+
# Ищем код после ключевых слов в той же строке (например, "Execute code: puts ...")
|
116
|
+
execute_match = prompt.match(/execute\s+code:\s*(.+)/i)
|
117
|
+
return execute_match[1].strip if execute_match
|
118
|
+
|
119
|
+
run_match = prompt.match(/run\s+code:\s*(.+)/i)
|
120
|
+
return run_match[1].strip if run_match
|
121
|
+
|
122
|
+
# Ищем код после ключевых слов в разных строках
|
123
|
+
KEYWORDS.each do |keyword|
|
124
|
+
if prompt.downcase.include?(keyword)
|
125
|
+
lines = prompt.split("\n")
|
126
|
+
keyword_line = lines.find_index { |line| line.downcase.include?(keyword) }
|
127
|
+
if keyword_line
|
128
|
+
# Берем строки после ключевого слова
|
129
|
+
code_lines = lines[(keyword_line + 1)..-1]
|
130
|
+
code = code_lines&.join("\n")&.strip
|
131
|
+
return code if code && !code.empty?
|
132
|
+
end
|
133
|
+
end
|
134
|
+
end
|
135
|
+
|
136
|
+
# Ищем строки, которые выглядят как код
|
137
|
+
code_lines = prompt.split("\n").select do |line|
|
138
|
+
line.strip.match?(/^(def|class|function|var|let|const|print|puts|console\.log)/i) ||
|
139
|
+
line.strip.match?(/^\w+\s*[=+\-*\/]\s*/) ||
|
140
|
+
line.strip.match?(/^\s*(if|for|while|return)[\s(]/i) ||
|
141
|
+
line.strip.match?(/puts\s+/) ||
|
142
|
+
line.strip.match?(/print\s*\(/)
|
143
|
+
end
|
144
|
+
|
145
|
+
code_lines.join("\n")
|
146
|
+
end
|
147
|
+
|
148
|
+
def detect_language(code, prompt)
|
149
|
+
# Явное указание языка
|
150
|
+
return 'ruby' if prompt.match?(/```ruby/i) || prompt.include?('Ruby')
|
151
|
+
return 'python' if prompt.match?(/```python/i) || prompt.include?('Python')
|
152
|
+
return 'javascript' if prompt.match?(/```(javascript|js)/i) || prompt.include?('JavaScript')
|
153
|
+
|
154
|
+
# Определение по синтаксису
|
155
|
+
return 'ruby' if code.include?('puts') || code.include?('def ') || code.match?(/\bend\b/)
|
156
|
+
return 'python' if code.include?('print(') || code.match?(/def \w+\(.*\):/) || code.include?('import ')
|
157
|
+
return 'javascript' if code.include?('console.log') || code.include?('function ') || code.include?('var ') || code.include?('let ')
|
158
|
+
|
159
|
+
'ruby' # default
|
160
|
+
end
|
161
|
+
|
162
|
+
def safe_to_execute?(code)
|
163
|
+
DANGEROUS_PATTERNS.none? { |pattern| code.match?(pattern) }
|
164
|
+
end
|
165
|
+
|
166
|
+
def execute_code(code, language)
|
167
|
+
case language
|
168
|
+
when 'ruby'
|
169
|
+
execute_ruby(code)
|
170
|
+
when 'python'
|
171
|
+
execute_python(code)
|
172
|
+
when 'javascript'
|
173
|
+
execute_javascript(code)
|
174
|
+
else
|
175
|
+
raise "Unsupported language: #{language}"
|
176
|
+
end
|
177
|
+
end
|
178
|
+
|
179
|
+
def execute_ruby(code)
|
180
|
+
Timeout.timeout(@timeout) do
|
181
|
+
# Создаем временный файл
|
182
|
+
Tempfile.create(['code', '.rb']) do |file|
|
183
|
+
file.write(code)
|
184
|
+
file.flush
|
185
|
+
|
186
|
+
# Выполняем код в отдельном процессе
|
187
|
+
result = `ruby #{file.path} 2>&1`
|
188
|
+
|
189
|
+
if $?.success?
|
190
|
+
result.strip
|
191
|
+
else
|
192
|
+
raise "Ruby execution failed: #{result}"
|
193
|
+
end
|
194
|
+
end
|
195
|
+
end
|
196
|
+
end
|
197
|
+
|
198
|
+
def execute_python(code)
|
199
|
+
Timeout.timeout(@timeout) do
|
200
|
+
Tempfile.create(['code', '.py']) do |file|
|
201
|
+
file.write(code)
|
202
|
+
file.flush
|
203
|
+
|
204
|
+
result = `python3 #{file.path} 2>&1`
|
205
|
+
|
206
|
+
if $?.success?
|
207
|
+
result.strip
|
208
|
+
else
|
209
|
+
raise "Python execution failed: #{result}"
|
210
|
+
end
|
211
|
+
end
|
212
|
+
end
|
213
|
+
end
|
214
|
+
|
215
|
+
def execute_javascript(code)
|
216
|
+
Timeout.timeout(@timeout) do
|
217
|
+
Tempfile.create(['code', '.js']) do |file|
|
218
|
+
file.write(code)
|
219
|
+
file.flush
|
220
|
+
|
221
|
+
# Пробуем node.js
|
222
|
+
result = `node #{file.path} 2>&1`
|
223
|
+
|
224
|
+
if $?.success?
|
225
|
+
result.strip
|
226
|
+
else
|
227
|
+
raise "JavaScript execution failed: #{result}"
|
228
|
+
end
|
229
|
+
end
|
230
|
+
end
|
231
|
+
end
|
232
|
+
|
233
|
+
def format_execution_result(code, language, result)
|
234
|
+
"Code execution (#{language}):\n\n```#{language}\n#{code}\n```\n\nOutput:\n```\n#{result}\n```"
|
235
|
+
end
|
236
|
+
|
237
|
+
def required_parameters
|
238
|
+
['code']
|
239
|
+
end
|
240
|
+
end
|
241
|
+
end
|
242
|
+
end
|
@@ -0,0 +1,204 @@
|
|
1
|
+
module LLMChain
|
2
|
+
module Tools
|
3
|
+
class ToolManager
|
4
|
+
attr_reader :tools
|
5
|
+
|
6
|
+
def initialize(tools: [])
|
7
|
+
@tools = {}
|
8
|
+
tools.each { |tool| register_tool(tool) }
|
9
|
+
end
|
10
|
+
|
11
|
+
# Регистрирует новый инструмент
|
12
|
+
def register_tool(tool)
|
13
|
+
unless tool.is_a?(BaseTool)
|
14
|
+
raise ArgumentError, "Tool must inherit from BaseTool"
|
15
|
+
end
|
16
|
+
@tools[tool.name] = tool
|
17
|
+
end
|
18
|
+
|
19
|
+
# Удаляет инструмент
|
20
|
+
def unregister_tool(name)
|
21
|
+
@tools.delete(name.to_s)
|
22
|
+
end
|
23
|
+
|
24
|
+
# Получает инструмент по имени
|
25
|
+
def get_tool(name)
|
26
|
+
@tools[name.to_s]
|
27
|
+
end
|
28
|
+
|
29
|
+
# Возвращает список всех инструментов
|
30
|
+
def list_tools
|
31
|
+
@tools.values
|
32
|
+
end
|
33
|
+
|
34
|
+
# Получает схемы всех инструментов для LLM
|
35
|
+
def get_tools_schema
|
36
|
+
@tools.values.map(&:to_schema)
|
37
|
+
end
|
38
|
+
|
39
|
+
# Находит подходящие инструменты для промпта
|
40
|
+
def find_matching_tools(prompt)
|
41
|
+
@tools.values.select { |tool| tool.match?(prompt) }
|
42
|
+
end
|
43
|
+
|
44
|
+
# Выполняет все подходящие инструменты
|
45
|
+
def execute_tools(prompt, context: {})
|
46
|
+
matching_tools = find_matching_tools(prompt)
|
47
|
+
|
48
|
+
results = {}
|
49
|
+
matching_tools.each do |tool|
|
50
|
+
begin
|
51
|
+
result = tool.call(prompt, context: context)
|
52
|
+
results[tool.name] = {
|
53
|
+
success: true,
|
54
|
+
result: result,
|
55
|
+
formatted: tool.format_result(result)
|
56
|
+
}
|
57
|
+
rescue => e
|
58
|
+
results[tool.name] = {
|
59
|
+
success: false,
|
60
|
+
error: e.message,
|
61
|
+
formatted: "Error in #{tool.name}: #{e.message}"
|
62
|
+
}
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
results
|
67
|
+
end
|
68
|
+
|
69
|
+
# Выполняет конкретный инструмент по имени
|
70
|
+
def execute_tool(name, prompt, context: {})
|
71
|
+
tool = get_tool(name)
|
72
|
+
raise ArgumentError, "Tool '#{name}' not found" unless tool
|
73
|
+
|
74
|
+
begin
|
75
|
+
result = tool.call(prompt, context: context)
|
76
|
+
{
|
77
|
+
success: true,
|
78
|
+
result: result,
|
79
|
+
formatted: tool.format_result(result)
|
80
|
+
}
|
81
|
+
rescue => e
|
82
|
+
{
|
83
|
+
success: false,
|
84
|
+
error: e.message,
|
85
|
+
formatted: "Error in #{name}: #{e.message}"
|
86
|
+
}
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
# Создает стандартный набор инструментов
|
91
|
+
def self.create_default_toolset
|
92
|
+
tools = [
|
93
|
+
Calculator.new,
|
94
|
+
WebSearch.new,
|
95
|
+
CodeInterpreter.new
|
96
|
+
]
|
97
|
+
|
98
|
+
new(tools: tools)
|
99
|
+
end
|
100
|
+
|
101
|
+
# Создает набор инструментов из конфигурации
|
102
|
+
def self.from_config(config)
|
103
|
+
tools = []
|
104
|
+
|
105
|
+
config.each do |tool_config|
|
106
|
+
tool_class = tool_config[:class] || tool_config['class']
|
107
|
+
tool_options = tool_config[:options] || tool_config['options'] || {}
|
108
|
+
|
109
|
+
case tool_class.to_s.downcase
|
110
|
+
when 'calculator'
|
111
|
+
tools << Calculator.new
|
112
|
+
when 'web_search', 'websearch'
|
113
|
+
tools << WebSearch.new(**tool_options)
|
114
|
+
when 'code_interpreter', 'codeinterpreter'
|
115
|
+
tools << CodeInterpreter.new(**tool_options)
|
116
|
+
else
|
117
|
+
raise ArgumentError, "Unknown tool class: #{tool_class}"
|
118
|
+
end
|
119
|
+
end
|
120
|
+
|
121
|
+
new(tools: tools)
|
122
|
+
end
|
123
|
+
|
124
|
+
# Форматирует результаты выполнения для включения в промпт
|
125
|
+
def format_tool_results(results)
|
126
|
+
return "" if results.empty?
|
127
|
+
|
128
|
+
formatted_results = results.map do |tool_name, result|
|
129
|
+
"#{tool_name}: #{result[:formatted]}"
|
130
|
+
end
|
131
|
+
|
132
|
+
"Tool Results:\n#{formatted_results.join("\n\n")}"
|
133
|
+
end
|
134
|
+
|
135
|
+
# Получает краткое описание доступных инструментов
|
136
|
+
def tools_description
|
137
|
+
descriptions = @tools.values.map do |tool|
|
138
|
+
"- #{tool.name}: #{tool.description}"
|
139
|
+
end
|
140
|
+
|
141
|
+
"Available tools:\n#{descriptions.join("\n")}"
|
142
|
+
end
|
143
|
+
|
144
|
+
# Проверяет, содержит ли промпт запрос на использование инструментов
|
145
|
+
def needs_tools?(prompt)
|
146
|
+
# Проверяем явные запросы на использование инструментов
|
147
|
+
return true if prompt.match?(/\b(use tool|call tool|execute|calculate|search|run code)\b/i)
|
148
|
+
|
149
|
+
# Проверяем, есть ли подходящие инструменты
|
150
|
+
find_matching_tools(prompt).any?
|
151
|
+
end
|
152
|
+
|
153
|
+
# Автоматически решает, какие инструменты использовать
|
154
|
+
def auto_execute(prompt, context: {})
|
155
|
+
return {} unless needs_tools?(prompt)
|
156
|
+
|
157
|
+
# Ограничиваем количество одновременно выполняемых инструментов
|
158
|
+
matching_tools = find_matching_tools(prompt)
|
159
|
+
selected_tools = select_best_tools(matching_tools, prompt)
|
160
|
+
|
161
|
+
results = {}
|
162
|
+
selected_tools.each do |tool|
|
163
|
+
begin
|
164
|
+
result = tool.call(prompt, context: context)
|
165
|
+
results[tool.name] = {
|
166
|
+
success: true,
|
167
|
+
result: result,
|
168
|
+
formatted: tool.format_result(result)
|
169
|
+
}
|
170
|
+
rescue => e
|
171
|
+
results[tool.name] = {
|
172
|
+
success: false,
|
173
|
+
error: e.message,
|
174
|
+
formatted: "Error in #{tool.name}: #{e.message}"
|
175
|
+
}
|
176
|
+
end
|
177
|
+
end
|
178
|
+
|
179
|
+
results
|
180
|
+
end
|
181
|
+
|
182
|
+
private
|
183
|
+
|
184
|
+
# Выбирает лучшие инструменты для выполнения (ограничение по количеству)
|
185
|
+
def select_best_tools(tools, prompt, limit: 3)
|
186
|
+
# Простая логика приоритизации
|
187
|
+
prioritized = tools.sort_by do |tool|
|
188
|
+
case tool.name
|
189
|
+
when 'calculator'
|
190
|
+
prompt.include?('calculate') || prompt.match?(/\d+\s*[+\-*\/]\s*\d+/) ? 0 : 2
|
191
|
+
when 'web_search'
|
192
|
+
prompt.include?('search') || prompt.match?(/\b(what|who|where|when)\b/i) ? 0 : 2
|
193
|
+
when 'code_interpreter'
|
194
|
+
prompt.include?('```') || prompt.include?('code') ? 0 : 2
|
195
|
+
else
|
196
|
+
1
|
197
|
+
end
|
198
|
+
end
|
199
|
+
|
200
|
+
prioritized.first(limit)
|
201
|
+
end
|
202
|
+
end
|
203
|
+
end
|
204
|
+
end
|