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
@@ -1,703 +1,67 @@
1
+ #require_relative 'parse'
1
2
  require_relative 'utils'
2
- require_relative 'parse'
3
3
  require_relative 'tools'
4
- require 'shellwords'
4
+ require_relative 'chat/annotation'
5
+ require_relative 'chat/parse'
6
+ require_relative 'chat/process'
5
7
 
6
8
  module LLM
7
- def self.content_tokens(message)
8
- Shellwords.split(message[:content].strip)
9
- end
10
-
11
9
  def self.messages(question, role = nil)
12
10
  default_role = "user"
13
11
 
14
12
  if Array === question
15
13
  return question.collect do |q|
16
14
  if String === q
17
- {role: default_role, content: q}
15
+ {role: role || default_role, content: q}
18
16
  else
19
17
  q
20
18
  end
21
19
  end
22
20
  end
23
21
 
24
- messages = []
25
- current_role = default_role
26
- current_content = ""
27
- in_protected_block = false
28
- protected_block_type = nil
29
- protected_stack = []
30
-
31
- role = default_role if role.nil?
32
-
33
- file_lines = question.split("\n")
34
-
35
- file_lines.each do |line|
36
- stripped = line.strip
37
-
38
- # Detect protected blocks
39
- if stripped.start_with?("```")
40
- if in_protected_block
41
- in_protected_block = false
42
- protected_block_type = nil
43
- current_content << "\n" << line unless line.strip.empty?
44
- else
45
- in_protected_block = true
46
- protected_block_type = :square
47
- current_content << "\n" << line unless line.strip.empty?
48
- end
49
- next
50
- elsif stripped.end_with?("]]") && in_protected_block && protected_block_type == :square
51
- in_protected_block = false
52
- protected_block_type = nil
53
- line = line.sub("]]", "")
54
- current_content << "\n" << line unless line.strip.empty?
55
- next
56
- elsif stripped.start_with?("[[")
57
- in_protected_block = true
58
- protected_block_type = :square
59
- line = line.sub("[[", "")
60
- current_content << "\n" << line unless line.strip.empty?
61
- next
62
- elsif stripped.end_with?("]]") && in_protected_block && protected_block_type == :square
63
- in_protected_block = false
64
- protected_block_type = nil
65
- line = line.sub("]]", "")
66
- current_content << "\n" << line unless line.strip.empty?
67
- next
68
- elsif stripped.match(/^.*:-- .* {{{/)
69
- in_protected_block = true
70
- protected_block_type = :square
71
- line = line.sub(/^.*:-- (.*) {{{/, '<cmd_output cmd="\1">')
72
- current_content << "\n" << line unless line.strip.empty?
73
- next
74
- elsif stripped.match(/^.*:--.* }}}/) && in_protected_block && protected_block_type == :square
75
- in_protected_block = false
76
- protected_block_type = nil
77
- line = line.sub(/^.*:-- .* }}}.*/, "</cmd_output>")
78
- current_content << "\n" << line unless line.strip.empty?
79
- next
80
- elsif in_protected_block
81
-
82
- if protected_block_type == :xml
83
- if stripped =~ %r{</(\w+)>}
84
- closing_tag = $1
85
- if protected_stack.last == closing_tag
86
- protected_stack.pop
87
- end
88
- if protected_stack.empty?
89
- in_protected_block = false
90
- protected_block_type = nil
91
- end
92
- end
93
- end
94
- current_content << "\n" << line
95
- next
96
- end
97
-
98
- # XML-style tag handling (protected content)
99
- if stripped =~ /^<(\w+)(\s+[^>]*)?>/
100
- tag = $1
101
- protected_stack.push(tag)
102
- in_protected_block = true
103
- protected_block_type = :xml
104
- end
105
-
106
- # Match a new message header
107
- if line =~ /^([a-z0-9_]+):(.*)$/
108
- role = $1
109
- inline_content = $2.strip
110
-
111
- current_content = current_content.strip if current_content
112
- # Save current message if any
113
- messages << { role: current_role, content: current_content }
114
-
115
- if inline_content.empty?
116
- # Block message
117
- current_role = role
118
- current_content = ""
119
- else
120
- # Inline message + next block is default role
121
- messages << { role: role, content: inline_content }
122
- current_role = 'user' if role == 'previous_response_id'
123
- current_content = ""
124
- end
125
- else
126
- if current_content.nil?
127
- current_content = line
128
- else
129
- current_content << "\n" << line
130
- end
131
- end
132
- end
133
-
134
- # Final message
135
- messages << { role: current_role || default_role, content: current_content.strip }
136
-
137
- messages
138
- end
139
-
140
- def self.find_file(file, original = nil, caller_lib_dir = Path.caller_lib_dir(nil, 'chats'))
141
- path = Scout.chats[file]
142
- original = original.find if Path === original
143
- if original
144
- relative = File.join(File.dirname(original), file)
145
- relative_lib = File.join(caller_lib_dir, file)
146
- end
147
-
148
- if Open.exist?(file)
149
- file
150
- elsif Open.remote?(file)
151
- file
152
- elsif relative && Open.exist?(relative)
153
- relative
154
- elsif relative_lib && Open.exist?(relative_lib)
155
- relative_lib
156
- elsif path.exists?
157
- path
158
- end
159
- end
160
-
161
- def self.imports(messages, original = nil, caller_lib_dir = Path.caller_lib_dir(nil, 'chats'))
162
- messages.collect do |message|
163
- if message[:role] == 'import' || message[:role] == 'continue' || message[:role] == 'last'
164
- file = message[:content].to_s.strip
165
- found_file = find_file(file, original, caller_lib_dir)
166
- raise "Import not found: #{file}" if found_file.nil?
167
-
168
- new = LLM.messages Open.read(found_file)
169
-
170
- new = if message[:role] == 'continue'
171
- [new.reject{|msg| msg[:content].nil? || msg[:content].strip.empty? }.last]
172
- elsif message[:role] == 'last'
173
- [LLM.purge(new).reject{|msg| msg[:content].empty?}.last]
174
- else
175
- LLM.purge(new)
176
- end
177
-
178
- LLM.chat new, found_file
179
- else
180
- message
181
- end
182
- end.flatten
183
- end
184
-
185
- def self.files(messages, original = nil, caller_lib_dir = Path.caller_lib_dir(nil, 'chats'))
186
- messages.collect do |message|
187
- if message[:role] == 'file' || message[:role] == 'directory'
188
- file = message[:content].to_s.strip
189
- found_file = find_file(file, original, caller_lib_dir)
190
- raise "File not found: #{file}" if found_file.nil?
191
-
192
- target = found_file
193
-
194
- if message[:role] == 'directory'
195
- Path.setup target
196
- target.glob('**/*').
197
- reject{|file|
198
- Open.directory?(file)
199
- }.collect{|file|
200
- files([{role: 'file', content: file}])
201
- }
202
- else
203
- new = LLM.tag :file, Open.read(target), file
204
- {role: 'user', content: new}
205
- end
206
- elsif message[:role] == 'pdf' || message[:role] == 'image'
207
- file = message[:content].to_s.strip
208
- found_file = find_file(file, original, caller_lib_dir)
209
- raise "File not found: #{file}" if found_file.nil?
210
-
211
- message[:content] = found_file
212
- message
213
- else
214
- message
215
- end
216
- end.flatten
217
- end
218
-
219
- def self.tasks(messages, original = nil)
220
- jobs = []
221
- new = messages.collect do |message|
222
- if message[:role] == 'task' || message[:role] == 'inline_task'
223
- info = message[:content].strip
224
-
225
- workflow, task = info.split(" ").values_at 0, 1
226
-
227
- options = IndiferentHash.parse_options info
228
- jobname = options.delete :jobname
229
-
230
- if String === workflow
231
- workflow = begin
232
- Kernel.const_get workflow
233
- rescue
234
- Workflow.require_workflow(workflow)
235
- end
236
- end
237
-
238
- job = workflow.job(task, jobname, options)
239
-
240
- jobs << job
241
-
242
- if message[:role] == 'inline_task'
243
- {role: 'inline_job', content: job.path.find}
244
- else
245
- {role: 'job', content: job.path.find}
246
- end
247
- else
248
- message
249
- end
250
- end.flatten
251
-
252
- Workflow.produce(jobs)
253
-
254
- new
255
- end
256
-
257
- def self.jobs(messages, original = nil)
258
- messages.collect do |message|
259
- if message[:role] == 'job' || message[:role] == 'inline_job'
260
- file = message[:content].strip
261
-
262
- step = Step.load file
263
-
264
- id = step.short_path[0..39]
265
- id = id.gsub('/','-')
266
-
267
-
268
- if message[:role] == 'inline_job'
269
- path = step.path
270
- path = path.find if Path === path
271
- {role: 'file', content: step.path}
272
- else
273
- tool_call = {
274
- type: "function",
275
- function: {
276
- name: step.full_task_name.sub('#', '-'),
277
- arguments: step.provided_inputs.to_json
278
- },
279
- id: id,
280
- }
281
-
282
- tool_output = {
283
- id: id,
284
- role: "tool",
285
- content: Open.read(step.path)
286
- }
287
-
288
- [
289
- {role: 'function_call', content: tool_call.to_json},
290
- {role: 'function_call_output', content: tool_output.to_json},
291
- ]
292
- end
293
- else
294
- message
295
- end
296
- end.flatten
297
- end
298
-
299
- def self.clear(messages)
300
- new = []
301
-
302
- messages.reverse.each do |message|
303
- if message[:role].to_s == 'clear'
304
- break
305
- elsif message[:role].to_s == 'previous_response_id'
306
- new << message
307
- break
308
- else
309
- new << message
310
- end
311
- end
312
-
313
- new.reverse
22
+ Chat.parse question
314
23
  end
315
-
316
- def self.clean(messages)
317
- messages.reject do |message|
318
- ((String === message[:content]) && message[:content].empty?) ||
319
- message[:role] == 'skip'
320
- end
321
- end
322
-
323
- def self.indiferent(messages)
324
- messages.collect{|msg| IndiferentHash.setup msg }
325
- end
326
-
327
24
  def self.chat(file, original = nil)
328
25
  original ||= (String === file and Open.exists?(file)) ? file : Path.setup($0.dup)
329
26
  caller_lib_dir = Path.caller_lib_dir(nil, 'chats')
330
27
 
331
- if Array === file
332
- messages = self.messages file
333
- messages = self.indiferent messages
334
- messages = self.imports messages, original, caller_lib_dir
335
- elsif Open.exists?(file)
28
+ if String === file && Open.exists?(file)
336
29
  messages = self.messages Open.read(file)
337
- messages = self.indiferent messages
338
- messages = self.imports messages, original, caller_lib_dir
339
30
  else
340
31
  messages = self.messages file
341
- messages = self.indiferent messages
342
- messages = self.imports messages, original, caller_lib_dir
343
32
  end
344
33
 
345
- messages = self.clear messages
346
- messages = self.clean messages
347
- messages = self.tasks messages
348
- messages = self.jobs messages
349
- messages = self.files messages, original, caller_lib_dir
350
-
351
- messages
352
- end
353
-
354
- def self.options(chat)
355
- options = IndiferentHash.setup({})
356
- sticky_options = IndiferentHash.setup({})
357
- new = []
358
-
359
- # Most options reset after an assistant reply, but not previous_response_id
360
- chat.each do |info|
361
- if Hash === info
362
- role = info[:role].to_s
363
- if %w(endpoint model backend persist agent).include? role.to_s
364
- options[role] = info[:content]
365
- next
366
- elsif %w(previous_response_id).include? role.to_s
367
- sticky_options[role] = info[:content]
368
- next
369
- elsif %w(format).include? role.to_s
370
- format = info[:content]
371
- if Path.is_filename?(format)
372
- file = find_file(format)
373
- if file
374
- format = Open.json(file)
375
- end
376
- end
377
- options[role] = format
378
- next
379
- end
34
+ messages = Chat.indiferent messages
35
+ messages = Chat.imports messages, original, caller_lib_dir
380
36
 
381
- if role.to_s == 'option'
382
- key, _, value = info[:content].partition(" ")
383
- options[key] = value
384
- next
385
- end
37
+ messages = Chat.clear messages
38
+ messages = Chat.clean messages
386
39
 
387
- if role.to_s == 'sticky_option'
388
- key, _, value = info[:content].partition(" ")
389
- sticky_options[key] = value
390
- next
391
- end
40
+ messages = Chat.tasks messages
41
+ messages = Chat.jobs messages
42
+ messages = Chat.files messages, original, caller_lib_dir
392
43
 
393
- if role == 'assistant'
394
- options.clear
395
- end
396
- end
397
- new << info
398
- end
399
- chat.replace new
400
- sticky_options.merge options
44
+ Chat.setup messages
401
45
  end
402
46
 
403
- def self.tools(messages)
404
- tool_definitions = IndiferentHash.setup({})
405
- new = messages.collect do |message|
406
- if message[:role] == 'mcp'
407
- url, *tools = content_tokens(message)
408
-
409
- if url == 'stdio'
410
- command = tools.shift
411
- mcp_tool_definitions = LLM.mcp_tools(url, command: command, url: nil, type: :stdio)
412
- else
413
- mcp_tool_definitions = LLM.mcp_tools(url)
414
- end
415
-
416
- if tools.any?
417
- tools.each do |tool|
418
- tool_definitions[tool] = mcp_tool_definitions[tool]
419
- end
420
- else
421
- tool_definitions.merge!(mcp_tool_definitions)
422
- end
423
- next
424
- elsif message[:role] == 'tool'
425
- workflow_name, task_name, *inputs = content_tokens(message)
426
- inputs = nil if inputs.empty?
427
- inputs = [] if inputs == ['none'] || inputs == ['noinputs']
428
- if Open.remote? workflow_name
429
- require 'rbbt'
430
- require 'scout/offsite/ssh'
431
- require 'rbbt/workflow/remote_workflow'
432
- workflow = RemoteWorkflow.new workflow_name
433
- else
434
- workflow = Workflow.require_workflow workflow_name
435
- end
436
-
437
- if task_name
438
- definition = LLM.task_tool_definition workflow, task_name, inputs
439
- tool_definitions[task_name] = [workflow, definition]
440
- else
441
- tool_definitions.merge!(LLM.workflow_tools(workflow))
442
- end
443
- next
444
- elsif message[:role] == 'kb'
445
- knowledge_base_name, *databases = content_tokens(message)
446
- databases = nil if databases.empty?
447
- knowledge_base = KnowledgeBase.load knowledge_base_name
448
-
449
- knowledge_base_definition = LLM.knowledge_base_tool_definition(knowledge_base, databases)
450
- tool_definitions.merge!(knowledge_base_definition)
451
- next
452
- elsif message[:role] == 'clear_tools'
453
- tool_definitions = {}
454
- else
455
- message
456
- end
457
- end.compact.flatten
458
- messages.replace new
459
- tool_definitions
460
- end
461
-
462
- def self.associations(messages, kb = nil)
463
- tool_definitions = {}
464
- new = messages.collect do |message|
465
- if message[:role] == 'association'
466
- name, path, *options = content_tokens(message)
467
-
468
- kb ||= KnowledgeBase.new Scout.var.Agent.Chat.knowledge_base
469
- kb.register name, Path.setup(path), IndiferentHash.parse_options(message[:content])
470
-
471
- tool_definitions.merge!(LLM.knowledge_base_tool_definition( kb, [name]))
472
- next
473
- elsif message[:role] == 'clear_associations'
474
- tool_definitions = {}
475
- else
476
- message
477
- end
478
- end.compact.flatten
479
- messages.replace new
480
- tool_definitions
481
- end
482
-
483
- def self.print(chat)
484
- return chat if String === chat
485
- "\n" + chat.collect do |message|
486
- IndiferentHash.setup message
487
- case message[:content]
488
- when Hash, Array
489
- message[:role].to_s + ":\n\n" + message[:content].to_json
490
- when nil, ''
491
- message[:role].to_s + ":"
492
- else
493
- if %w(option previous_response_id).include? message[:role].to_s
494
- message[:role].to_s + ": " + message[:content].to_s
495
- else
496
- message[:role].to_s + ":\n\n" + message[:content].to_s
497
- end
498
- end
499
- end * "\n\n"
500
- end
501
-
502
- def self.purge(chat)
503
- chat.reject do |msg|
504
- IndiferentHash.setup msg
505
- msg[:role].to_s == 'previous_response_id'
506
- end
507
- end
508
- end
509
-
510
- module Chat
511
- extend Annotation
512
-
513
- def message(role, content)
514
- self.append({role: role.to_s, content: content})
515
- end
516
-
517
- def user(content)
518
- message(:user, content)
47
+ def self.options(...)
48
+ Chat.options(...)
519
49
  end
520
50
 
521
- def system(content)
522
- message(:system, content)
51
+ def self.print(...)
52
+ Chat.print(...)
523
53
  end
524
54
 
525
- def assistant(content)
526
- message(:assistant, content)
55
+ def self.tools(...)
56
+ Chat.tools(...)
527
57
  end
528
58
 
529
- def import(file)
530
- message(:import, file)
59
+ def self.associations(...)
60
+ Chat.associations(...)
531
61
  end
532
62
 
533
- def import_last(file)
534
- message(:last, file)
535
- end
536
-
537
- def file(file)
538
- message(:file, file)
539
- end
540
-
541
- def directory(directory)
542
- message(:directory, directory)
543
- end
544
-
545
- def continue(file)
546
- message(:continue, file)
547
- end
548
-
549
- def format(format)
550
- message(:format, format)
551
- end
552
-
553
- def tool(*parts)
554
- content = parts * "\n"
555
- message(:tool, content)
556
- end
557
-
558
- def task(workflow, task_name, inputs = {})
559
- input_str = IndiferentHash.print_options inputs
560
- content = [workflow, task_name, input_str]*" "
561
- message(:task, content)
562
- end
563
-
564
- def inline_task(workflow, task_name, inputs = {})
565
- input_str = IndiferentHash.print_options inputs
566
- content = [workflow, task_name, input_str]*" "
567
- message(:inline_task, content)
568
- end
569
-
570
- def job(step)
571
- message(:job, step.path)
572
- end
573
-
574
- def inline_job(step)
575
- message(:inline_job, step.path)
576
- end
577
-
578
-
579
- def association(name, path, options = {})
580
- options_str = IndiferentHash.print_options options
581
- content = [name, path, options_str]*" "
582
- message(:association, name)
583
- end
584
-
585
- def tag(content, name=nil, tag=:file, role=:user)
586
- self.message role, LLM.tag(tag, content, name)
587
- end
588
-
589
-
590
- def ask(...)
591
- LLM.ask(LLM.chat(self), ...)
592
- end
593
-
594
- def chat(...)
595
- response = ask(...)
596
- if Array === response
597
- current_chat.concat(response)
598
- final(response)
599
- else
600
- current_chat.push({role: :assistant, content: response})
601
- response
602
- end
603
- end
604
-
605
- def json(...)
606
- self.format :json
607
- output = ask(...)
608
- obj = JSON.parse output
609
- if (Hash === obj) and obj.keys == ['content']
610
- obj['content']
611
- else
612
- obj
613
- end
614
- end
615
-
616
- def json_format(format, ...)
617
- self.format format
618
- output = ask(...)
619
- obj = JSON.parse output
620
- if (Hash === obj) and obj.keys == ['content']
621
- obj['content']
622
- else
623
- obj
624
- end
625
- end
626
-
627
- def branch
628
- self.annotate self.dup
629
- end
630
-
631
- def option(name, value)
632
- self.message 'option', [name, value] * " "
633
- end
634
-
635
- def endpoint(value)
636
- option :endpoint, value
637
- end
638
-
639
- def model(value)
640
- option :model, value
641
- end
642
-
643
- def image(file)
644
- self.message :image, file
645
- end
646
-
647
- # Reporting
648
-
649
- def print
650
- LLM.print LLM.chat(self)
651
- end
652
-
653
- def final
654
- LLM.purge(self).last
655
- end
656
-
657
- def purge
658
- Chat.setup(LLM.purge(self))
659
- end
660
-
661
- def shed
662
- self.annotate [final]
663
- end
664
-
665
- def answer
666
- final[:content]
667
- end
668
-
669
- # Write and save
670
-
671
- def save(path, force = true)
672
- path = path.to_s if Symbol === path
673
- if not (Open.exists?(path) || Path === path || Path.located?(path))
674
- path = Scout.chats.find[path]
675
- end
676
- return if Open.exists?(path) && ! force
677
- Open.write path, LLM.print(self)
678
- end
679
-
680
- def write(path, force = true)
681
- path = path.to_s if Symbol === path
682
- if not (Open.exists?(path) || Path === path || Path.located?(path))
683
- path = Scout.chats.find[path]
684
- end
685
- return if Open.exists?(path) && ! force
686
- Open.write path, self.print
687
- end
688
-
689
- def write_answer(path, force = true)
690
- path = path.to_s if Symbol === path
691
- if not (Open.exists?(path) || Path === path || Path.located?(path))
692
- path = Scout.chats.find[path]
693
- end
694
- return if Open.exists?(path) && ! force
695
- Open.write path, self.answer
696
- end
697
-
698
- # Image
699
- def create_image(file, ...)
700
- base64_image = LLM.image(LLM.chat(self), ...)
701
- Open.write(file, Base64.decode(file_content), mode: 'wb')
63
+ def self.purge(...)
64
+ Chat.purge(...)
702
65
  end
703
66
  end
67
+