scout-ai 1.0.1 → 1.1.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.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/.vimproject +20 -2
  3. data/Rakefile +1 -0
  4. data/VERSION +1 -1
  5. data/bin/scout-ai +46 -0
  6. data/lib/scout/llm/agent/chat.rb +4 -7
  7. data/lib/scout/llm/agent/delegate.rb +12 -0
  8. data/lib/scout/llm/agent.rb +2 -2
  9. data/lib/scout/llm/ask.rb +18 -2
  10. data/lib/scout/llm/backends/huggingface.rb +0 -2
  11. data/lib/scout/llm/backends/ollama.rb +6 -6
  12. data/lib/scout/llm/backends/openai.rb +7 -4
  13. data/lib/scout/llm/backends/openwebui.rb +1 -4
  14. data/lib/scout/llm/backends/relay.rb +1 -3
  15. data/lib/scout/llm/backends/responses.rb +34 -18
  16. data/lib/scout/llm/chat/annotation.rb +195 -0
  17. data/lib/scout/llm/chat/parse.rb +139 -0
  18. data/lib/scout/llm/chat/process/clear.rb +29 -0
  19. data/lib/scout/llm/chat/process/files.rb +96 -0
  20. data/lib/scout/llm/chat/process/options.rb +52 -0
  21. data/lib/scout/llm/chat/process/tools.rb +173 -0
  22. data/lib/scout/llm/chat/process.rb +16 -0
  23. data/lib/scout/llm/chat.rb +26 -662
  24. data/lib/scout/llm/mcp.rb +1 -1
  25. data/lib/scout/llm/tools/call.rb +22 -1
  26. data/lib/scout/llm/tools/knowledge_base.rb +15 -14
  27. data/lib/scout/llm/tools/mcp.rb +4 -0
  28. data/lib/scout/llm/tools/workflow.rb +54 -15
  29. data/lib/scout/llm/tools.rb +42 -0
  30. data/lib/scout/llm/utils.rb +2 -17
  31. data/scout-ai.gemspec +13 -4
  32. data/scout_commands/agent/ask +36 -12
  33. data/scout_commands/llm/ask +17 -7
  34. data/scout_commands/llm/process +1 -1
  35. data/test/scout/llm/backends/test_anthropic.rb +2 -2
  36. data/test/scout/llm/backends/test_ollama.rb +1 -1
  37. data/test/scout/llm/backends/test_responses.rb +9 -9
  38. data/test/scout/llm/chat/test_parse.rb +126 -0
  39. data/test/scout/llm/chat/test_process.rb +123 -0
  40. data/test/scout/llm/test_agent.rb +2 -25
  41. data/test/scout/llm/test_chat.rb +2 -178
  42. metadata +25 -3
  43. data/lib/scout/llm/parse.rb +0 -91
@@ -0,0 +1,139 @@
1
+ module Chat
2
+ def self.parse(text, role = nil)
3
+ default_role = "user"
4
+
5
+ messages = []
6
+ current_role = role || default_role
7
+ current_content = ""
8
+ in_protected_block = false
9
+ protected_block_type = nil
10
+ protected_stack = []
11
+
12
+ role = default_role if role.nil?
13
+
14
+ file_lines = text.split("\n")
15
+
16
+ file_lines.each do |line|
17
+ stripped = line.strip
18
+
19
+ # Detect protected blocks
20
+ if stripped.start_with?("```")
21
+ if in_protected_block
22
+ in_protected_block = false
23
+ protected_block_type = nil
24
+ current_content << "\n" << line unless line.strip.empty?
25
+ else
26
+ in_protected_block = true
27
+ protected_block_type = :square
28
+ current_content << "\n" << line unless line.strip.empty?
29
+ end
30
+ next
31
+ elsif stripped.end_with?("]]") && in_protected_block && protected_block_type == :square
32
+ in_protected_block = false
33
+ protected_block_type = nil
34
+ line = line.sub("]]", "")
35
+ current_content << "\n" << line unless line.strip.empty?
36
+ next
37
+ elsif stripped.start_with?("[[")
38
+ in_protected_block = true
39
+ protected_block_type = :square
40
+ line = line.sub("[[", "")
41
+ current_content << "\n" << line unless line.strip.empty?
42
+ next
43
+ elsif stripped.end_with?("]]") && in_protected_block && protected_block_type == :square
44
+ in_protected_block = false
45
+ protected_block_type = nil
46
+ line = line.sub("]]", "")
47
+ current_content << "\n" << line unless line.strip.empty?
48
+ next
49
+ elsif stripped.match(/^.*:-- .* {{{/)
50
+ in_protected_block = true
51
+ protected_block_type = :square
52
+ line = line.sub(/^.*:-- (.*) {{{.*/, '<cmd_output cmd="\1">')
53
+ current_content << "\n" << line unless line.strip.empty?
54
+ next
55
+ elsif stripped.match(/^.*:--.* }}}/) && in_protected_block && protected_block_type == :square
56
+ in_protected_block = false
57
+ protected_block_type = nil
58
+ line = line.sub(/^.*:-- .* }}}.*/, "</cmd_output>")
59
+ current_content << "\n" << line unless line.strip.empty?
60
+ next
61
+ elsif in_protected_block
62
+
63
+ if protected_block_type == :xml
64
+ if stripped =~ %r{</(\w+)>}
65
+ closing_tag = $1
66
+ if protected_stack.last == closing_tag
67
+ protected_stack.pop
68
+ end
69
+ if protected_stack.empty?
70
+ in_protected_block = false
71
+ protected_block_type = nil
72
+ end
73
+ end
74
+ end
75
+ current_content << "\n" << line
76
+ next
77
+ end
78
+
79
+ # XML-style tag handling (protected content)
80
+ if stripped =~ /^<(\w+)(\s+[^>]*)?>/
81
+ tag = $1
82
+ protected_stack.push(tag)
83
+ in_protected_block = true
84
+ protected_block_type = :xml
85
+ end
86
+
87
+ # Match a new message header
88
+ if line =~ /^([a-z0-9_]+):(.*)$/
89
+ role = $1
90
+ inline_content = $2.strip
91
+
92
+ current_content = current_content.strip if current_content
93
+ # Save current message if any
94
+ messages << { role: current_role, content: current_content }
95
+
96
+ if inline_content.empty?
97
+ # Block message
98
+ current_role = role
99
+ current_content = ""
100
+ else
101
+ # Inline message + next block is default role
102
+ messages << { role: role, content: inline_content }
103
+ current_role = 'user' if role == 'previous_response_id'
104
+ current_content = ""
105
+ end
106
+ else
107
+ if current_content.nil?
108
+ current_content = line
109
+ else
110
+ current_content += "\n" + line
111
+ end
112
+ end
113
+ end
114
+
115
+ # Final message
116
+ messages << { role: current_role || default_role, content: current_content.strip }
117
+
118
+ messages
119
+ end
120
+
121
+ def self.print(chat)
122
+ return chat if String === chat
123
+ "\n" + chat.collect do |message|
124
+ IndiferentHash.setup message
125
+ case message[:content]
126
+ when Hash, Array
127
+ message[:role].to_s + ":\n\n" + message[:content].to_json
128
+ when nil, ''
129
+ message[:role].to_s + ":"
130
+ else
131
+ if %w(option previous_response_id function_call function_call_output).include? message[:role].to_s
132
+ message[:role].to_s + ": " + message[:content].to_s
133
+ else
134
+ message[:role].to_s + ":\n\n" + message[:content].to_s
135
+ end
136
+ end
137
+ end * "\n\n"
138
+ end
139
+ end
@@ -0,0 +1,29 @@
1
+ module Chat
2
+ def self.clear(messages, role = 'clear')
3
+ new = []
4
+
5
+ messages.reverse.each do |message|
6
+ if message[:role].to_s == role
7
+ break
8
+ else
9
+ new << message
10
+ end
11
+ end
12
+
13
+ new.reverse
14
+ end
15
+
16
+ def self.clean(messages, role = 'skip')
17
+ messages.reject do |message|
18
+ ((String === message[:content]) && message[:content].empty?) ||
19
+ message[:role] == role
20
+ end
21
+ end
22
+
23
+ def self.purge(chat)
24
+ chat.reject do |msg|
25
+ IndiferentHash.setup msg
26
+ msg[:role].to_s == 'previous_response_id'
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,96 @@
1
+ module Chat
2
+ def self.tag(tag, content, name = nil)
3
+ if name
4
+ <<-EOF.strip
5
+ <#{tag} name="#{name}">
6
+ #{content}
7
+ </#{tag}>
8
+ EOF
9
+ else
10
+ <<-EOF.strip
11
+ <#{tag}>
12
+ #{content}
13
+ </#{tag}>
14
+ EOF
15
+ end
16
+ end
17
+ def self.find_file(file, original = nil, caller_lib_dir = Path.caller_lib_dir(nil, 'chats'))
18
+ path = Scout.chats[file]
19
+ original = original.find if Path === original
20
+ if original
21
+ relative = File.join(File.dirname(original), file)
22
+ relative_lib = File.join(caller_lib_dir, file) if caller_lib_dir
23
+ end
24
+
25
+ if Open.exist?(file)
26
+ file
27
+ elsif Open.remote?(file)
28
+ file
29
+ elsif relative && Open.exist?(relative)
30
+ relative
31
+ elsif relative_lib && Open.exist?(relative_lib)
32
+ relative_lib
33
+ elsif path.exists?
34
+ path
35
+ end
36
+ end
37
+
38
+ def self.imports(messages, original = nil, caller_lib_dir = Path.caller_lib_dir(nil, 'chats'))
39
+ messages.collect do |message|
40
+ if message[:role] == 'import' || message[:role] == 'continue' || message[:role] == 'last'
41
+ file = message[:content].to_s.strip
42
+ found_file = find_file(file, original, caller_lib_dir)
43
+ raise "Import not found: #{file}" if found_file.nil?
44
+
45
+ new = LLM.messages Open.read(found_file)
46
+
47
+ new = if message[:role] == 'continue'
48
+ [new.reject{|msg| msg[:content].nil? || msg[:content].strip.empty? }.last]
49
+ elsif message[:role] == 'last'
50
+ [LLM.purge(new).reject{|msg| msg[:content].empty?}.last]
51
+ else
52
+ LLM.purge(new)
53
+ end
54
+
55
+ LLM.chat new, found_file
56
+ else
57
+ message
58
+ end
59
+ end.flatten
60
+ end
61
+
62
+ def self.files(messages, original = nil, caller_lib_dir = Path.caller_lib_dir(nil, 'chats'))
63
+ messages.collect do |message|
64
+ if message[:role] == 'file' || message[:role] == 'directory'
65
+ file = message[:content].to_s.strip
66
+ found_file = find_file(file, original, caller_lib_dir)
67
+ raise "File not found: #{file}" if found_file.nil?
68
+
69
+ target = found_file
70
+
71
+ if message[:role] == 'directory'
72
+ Path.setup target
73
+ target.glob('**/*').
74
+ reject{|file|
75
+ Open.directory?(file)
76
+ }.collect{|file|
77
+ files([{role: 'file', content: file}])
78
+ }
79
+ else
80
+ new = Chat.tag :file, Open.read(target), file
81
+ {role: 'user', content: new}
82
+ end
83
+ elsif message[:role] == 'pdf' || message[:role] == 'image'
84
+ file = message[:content].to_s.strip
85
+ found_file = find_file(file, original, caller_lib_dir)
86
+ raise "File not found: #{file}" if found_file.nil?
87
+
88
+ message[:content] = found_file
89
+ message
90
+ else
91
+ message
92
+ end
93
+ end.flatten
94
+ end
95
+
96
+ end
@@ -0,0 +1,52 @@
1
+ module Chat
2
+ def self.options(chat)
3
+ options = IndiferentHash.setup({})
4
+ sticky_options = IndiferentHash.setup({})
5
+ new = []
6
+
7
+ # Most options reset after an assistant reply, but not previous_response_id
8
+ chat.each do |info|
9
+ if Hash === info
10
+ role = info[:role].to_s
11
+ if %w(endpoint model backend agent).include? role.to_s
12
+ sticky_options[role] = info[:content]
13
+ next
14
+ elsif %w(persist).include? role.to_s
15
+ options[role] = info[:content]
16
+ next
17
+ elsif %w(previous_response_id).include? role.to_s
18
+ sticky_options[role] = info[:content]
19
+ elsif %w(format).include? role.to_s
20
+ format = info[:content]
21
+ if Path.is_filename?(format)
22
+ file = find_file(format)
23
+ if file
24
+ format = Open.json(file)
25
+ end
26
+ end
27
+ options[role] = format
28
+ next
29
+ end
30
+
31
+ if role.to_s == 'option'
32
+ key, _, value = info[:content].partition(" ")
33
+ options[key] = value
34
+ next
35
+ end
36
+
37
+ if role.to_s == 'sticky_option'
38
+ key, _, value = info[:content].partition(" ")
39
+ sticky_options[key] = value
40
+ next
41
+ end
42
+
43
+ if role == 'assistant'
44
+ options.clear
45
+ end
46
+ end
47
+ new << info
48
+ end
49
+ chat.replace new
50
+ sticky_options.merge options
51
+ end
52
+ end
@@ -0,0 +1,173 @@
1
+ module Chat
2
+ def self.tasks(messages, original = nil)
3
+ jobs = []
4
+ new = messages.collect do |message|
5
+ if message[:role] == 'task' || message[:role] == 'inline_task' || message[:role] == 'exec_task'
6
+ info = message[:content].strip
7
+
8
+ workflow, task = info.split(" ").values_at 0, 1
9
+
10
+ options = IndiferentHash.parse_options info
11
+ jobname = options.delete :jobname
12
+
13
+ if String === workflow
14
+ workflow = begin
15
+ Kernel.const_get workflow
16
+ rescue
17
+ Workflow.require_workflow(workflow)
18
+ end
19
+ end
20
+
21
+ job = workflow.job(task, jobname, options)
22
+
23
+ jobs << job unless message[:role] == 'exec_task'
24
+
25
+ if message[:role] == 'exec_task'
26
+ begin
27
+ {role: 'user', content: job.exec}
28
+ rescue
29
+ {role: 'exec_job', content: $!}
30
+ end
31
+ elsif message[:role] == 'inline_task'
32
+ {role: 'inline_job', content: job.path.find}
33
+ else
34
+ {role: 'job', content: job.path.find}
35
+ end
36
+ else
37
+ message
38
+ end
39
+ end.flatten
40
+
41
+ Workflow.produce(jobs)
42
+
43
+ new
44
+ end
45
+
46
+ def self.jobs(messages, original = nil)
47
+ messages.collect do |message|
48
+ if message[:role] == 'job' || message[:role] == 'inline_job'
49
+ file = message[:content].strip
50
+
51
+ step = Step.load file
52
+
53
+ id = step.short_path[0..39]
54
+ id = id.gsub('/','-')
55
+
56
+ if message[:role] == 'inline_job'
57
+ path = step.path
58
+ path = path.find if Path === path
59
+ {role: 'file', content: step.path}
60
+ else
61
+
62
+ function_name = step.full_task_name.sub('#', '-')
63
+ function_name = step.task_name
64
+ tool_call = {
65
+ function: {
66
+ name: function_name,
67
+ arguments: step.provided_inputs
68
+ },
69
+ id: id,
70
+ }
71
+
72
+ content = if step.done?
73
+ Open.read(step.path)
74
+ elsif step.error?
75
+ step.exception
76
+ end
77
+
78
+ tool_output = {
79
+ id: id,
80
+ content: content
81
+ }
82
+
83
+ [
84
+ {role: 'function_call', content: tool_call.to_json},
85
+ {role: 'function_call_output', content: tool_output.to_json},
86
+ ]
87
+ end
88
+ else
89
+ message
90
+ end
91
+ end.flatten
92
+ end
93
+
94
+ def self.tools(messages)
95
+ tool_definitions = IndiferentHash.setup({})
96
+ new = messages.collect do |message|
97
+ if message[:role] == 'mcp'
98
+ url, *tools = content_tokens(message)
99
+
100
+ if url == 'stdio'
101
+ command = tools.shift
102
+ mcp_tool_definitions = LLM.mcp_tools(url, command: command, url: nil, type: :stdio)
103
+ else
104
+ mcp_tool_definitions = LLM.mcp_tools(url)
105
+ end
106
+
107
+ if tools.any?
108
+ tools.each do |tool|
109
+ tool_definitions[tool] = mcp_tool_definitions[tool]
110
+ end
111
+ else
112
+ tool_definitions.merge!(mcp_tool_definitions)
113
+ end
114
+ next
115
+ elsif message[:role] == 'tool'
116
+ workflow_name, task_name, *inputs = content_tokens(message)
117
+ inputs = nil if inputs.empty?
118
+ inputs = [] if inputs == ['none'] || inputs == ['noinputs']
119
+ if Open.remote? workflow_name
120
+ require 'rbbt'
121
+ require 'scout/offsite/ssh'
122
+ require 'rbbt/workflow/remote_workflow'
123
+ workflow = RemoteWorkflow.new workflow_name
124
+ else
125
+ workflow = Workflow.require_workflow workflow_name
126
+ end
127
+
128
+ if task_name
129
+ definition = LLM.task_tool_definition workflow, task_name, inputs
130
+ tool_definitions[task_name] = [workflow, definition]
131
+ else
132
+ tool_definitions.merge!(LLM.workflow_tools(workflow))
133
+ end
134
+ next
135
+ elsif message[:role] == 'kb'
136
+ knowledge_base_name, *databases = content_tokens(message)
137
+ databases = nil if databases.empty?
138
+ knowledge_base = KnowledgeBase.load knowledge_base_name
139
+
140
+ knowledge_base_definition = LLM.knowledge_base_tool_definition(knowledge_base, databases)
141
+ tool_definitions.merge!(knowledge_base_definition)
142
+ next
143
+ elsif message[:role] == 'clear_tools'
144
+ tool_definitions = {}
145
+ else
146
+ message
147
+ end
148
+ end.compact.flatten
149
+ messages.replace new
150
+ tool_definitions
151
+ end
152
+
153
+ def self.associations(messages, kb = nil)
154
+ tool_definitions = {}
155
+ new = messages.collect do |message|
156
+ if message[:role] == 'association'
157
+ name, path, *options = content_tokens(message)
158
+
159
+ kb ||= KnowledgeBase.new Scout.var.Agent.Chat.knowledge_base
160
+ kb.register name, Path.setup(path), IndiferentHash.parse_options(message[:content])
161
+
162
+ tool_definitions.merge!(LLM.knowledge_base_tool_definition( kb, [name]))
163
+ next
164
+ elsif message[:role] == 'clear_associations'
165
+ tool_definitions = {}
166
+ else
167
+ message
168
+ end
169
+ end.compact.flatten
170
+ messages.replace new
171
+ tool_definitions
172
+ end
173
+ end
@@ -0,0 +1,16 @@
1
+ require_relative 'process/tools'
2
+ require_relative 'process/files'
3
+ require_relative 'process/clear'
4
+ require_relative 'process/options'
5
+
6
+ require 'shellwords'
7
+
8
+ module Chat
9
+ def self.content_tokens(message)
10
+ Shellwords.split(message[:content].strip)
11
+ end
12
+
13
+ def self.indiferent(messages)
14
+ messages.collect{|msg| IndiferentHash.setup msg }
15
+ end
16
+ end