scout-ai 1.1.0 → 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.
@@ -1,715 +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' || message[:role] == 'exec_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 unless message[:role] == 'exec_task'
241
-
242
- if message[:role] == 'exec_task'
243
- begin
244
- {role: 'user', content: job.exec}
245
- rescue
246
- {role: 'exec_job', content: $!}
247
- end
248
- elsif message[:role] == 'inline_task'
249
- {role: 'inline_job', content: job.path.find}
250
- else
251
- {role: 'job', content: job.path.find}
252
- end
253
- else
254
- message
255
- end
256
- end.flatten
257
-
258
- Workflow.produce(jobs)
259
-
260
- new
261
- end
262
-
263
- def self.jobs(messages, original = nil)
264
- messages.collect do |message|
265
- if message[:role] == 'job' || message[:role] == 'inline_job'
266
- file = message[:content].strip
267
-
268
- step = Step.load file
269
-
270
- id = step.short_path[0..39]
271
- id = id.gsub('/','-')
272
-
273
- if message[:role] == 'inline_job'
274
- path = step.path
275
- path = path.find if Path === path
276
- {role: 'file', content: step.path}
277
- else
278
-
279
- function_name = step.full_task_name.sub('#', '-')
280
- function_name = step.task_name
281
- tool_call = {
282
- function: {
283
- name: function_name,
284
- arguments: step.provided_inputs
285
- },
286
- id: id,
287
- }
288
-
289
- content = if step.done?
290
- Open.read(step.path)
291
- elsif step.error?
292
- step.exception
293
- end
294
-
295
- tool_output = {
296
- id: id,
297
- content: content
298
- }
299
-
300
- [
301
- {role: 'function_call', content: tool_call.to_json},
302
- {role: 'function_call_output', content: tool_output.to_json},
303
- ]
304
- end
305
- else
306
- message
307
- end
308
- end.flatten
309
- end
310
-
311
- def self.clear(messages)
312
- new = []
313
-
314
- messages.reverse.each do |message|
315
- if message[:role].to_s == 'clear'
316
- break
317
- elsif message[:role].to_s == 'previous_response_id'
318
- new << message
319
- break
320
- else
321
- new << message
322
- end
323
- end
324
-
325
- new.reverse
326
- end
327
-
328
- def self.clean(messages)
329
- messages.reject do |message|
330
- ((String === message[:content]) && message[:content].empty?) ||
331
- message[:role] == 'skip'
332
- end
333
- end
334
-
335
- def self.indiferent(messages)
336
- messages.collect{|msg| IndiferentHash.setup msg }
22
+ Chat.parse question
337
23
  end
338
-
339
24
  def self.chat(file, original = nil)
340
25
  original ||= (String === file and Open.exists?(file)) ? file : Path.setup($0.dup)
341
26
  caller_lib_dir = Path.caller_lib_dir(nil, 'chats')
342
27
 
343
- if Array === file
344
- messages = self.messages file
345
- messages = self.indiferent messages
346
- messages = self.imports messages, original, caller_lib_dir
347
- elsif Open.exists?(file)
28
+ if String === file && Open.exists?(file)
348
29
  messages = self.messages Open.read(file)
349
- messages = self.indiferent messages
350
- messages = self.imports messages, original, caller_lib_dir
351
30
  else
352
31
  messages = self.messages file
353
- messages = self.indiferent messages
354
- messages = self.imports messages, original, caller_lib_dir
355
- end
356
-
357
- messages = self.clear messages
358
- messages = self.clean messages
359
- messages = self.tasks messages
360
- messages = self.jobs messages
361
- messages = self.files messages, original, caller_lib_dir
362
-
363
- messages
364
- end
365
-
366
- def self.options(chat)
367
- options = IndiferentHash.setup({})
368
- sticky_options = IndiferentHash.setup({})
369
- new = []
370
-
371
- # Most options reset after an assistant reply, but not previous_response_id
372
- chat.each do |info|
373
- if Hash === info
374
- role = info[:role].to_s
375
- if %w(endpoint model backend persist agent).include? role.to_s
376
- options[role] = info[:content]
377
- next
378
- elsif %w(previous_response_id).include? role.to_s
379
- sticky_options[role] = info[:content]
380
- next
381
- elsif %w(format).include? role.to_s
382
- format = info[:content]
383
- if Path.is_filename?(format)
384
- file = find_file(format)
385
- if file
386
- format = Open.json(file)
387
- end
388
- end
389
- options[role] = format
390
- next
391
- end
392
-
393
- if role.to_s == 'option'
394
- key, _, value = info[:content].partition(" ")
395
- options[key] = value
396
- next
397
- end
398
-
399
- if role.to_s == 'sticky_option'
400
- key, _, value = info[:content].partition(" ")
401
- sticky_options[key] = value
402
- next
403
- end
404
-
405
- if role == 'assistant'
406
- options.clear
407
- end
408
- end
409
- new << info
410
- end
411
- chat.replace new
412
- sticky_options.merge options
413
- end
414
-
415
- def self.tools(messages)
416
- tool_definitions = IndiferentHash.setup({})
417
- new = messages.collect do |message|
418
- if message[:role] == 'mcp'
419
- url, *tools = content_tokens(message)
420
-
421
- if url == 'stdio'
422
- command = tools.shift
423
- mcp_tool_definitions = LLM.mcp_tools(url, command: command, url: nil, type: :stdio)
424
- else
425
- mcp_tool_definitions = LLM.mcp_tools(url)
426
- end
427
-
428
- if tools.any?
429
- tools.each do |tool|
430
- tool_definitions[tool] = mcp_tool_definitions[tool]
431
- end
432
- else
433
- tool_definitions.merge!(mcp_tool_definitions)
434
- end
435
- next
436
- elsif message[:role] == 'tool'
437
- workflow_name, task_name, *inputs = content_tokens(message)
438
- inputs = nil if inputs.empty?
439
- inputs = [] if inputs == ['none'] || inputs == ['noinputs']
440
- if Open.remote? workflow_name
441
- require 'rbbt'
442
- require 'scout/offsite/ssh'
443
- require 'rbbt/workflow/remote_workflow'
444
- workflow = RemoteWorkflow.new workflow_name
445
- else
446
- workflow = Workflow.require_workflow workflow_name
447
- end
448
-
449
- if task_name
450
- definition = LLM.task_tool_definition workflow, task_name, inputs
451
- tool_definitions[task_name] = [workflow, definition]
452
- else
453
- tool_definitions.merge!(LLM.workflow_tools(workflow))
454
- end
455
- next
456
- elsif message[:role] == 'kb'
457
- knowledge_base_name, *databases = content_tokens(message)
458
- databases = nil if databases.empty?
459
- knowledge_base = KnowledgeBase.load knowledge_base_name
460
-
461
- knowledge_base_definition = LLM.knowledge_base_tool_definition(knowledge_base, databases)
462
- tool_definitions.merge!(knowledge_base_definition)
463
- next
464
- elsif message[:role] == 'clear_tools'
465
- tool_definitions = {}
466
- else
467
- message
468
- end
469
- end.compact.flatten
470
- messages.replace new
471
- tool_definitions
472
- end
473
-
474
- def self.associations(messages, kb = nil)
475
- tool_definitions = {}
476
- new = messages.collect do |message|
477
- if message[:role] == 'association'
478
- name, path, *options = content_tokens(message)
479
-
480
- kb ||= KnowledgeBase.new Scout.var.Agent.Chat.knowledge_base
481
- kb.register name, Path.setup(path), IndiferentHash.parse_options(message[:content])
482
-
483
- tool_definitions.merge!(LLM.knowledge_base_tool_definition( kb, [name]))
484
- next
485
- elsif message[:role] == 'clear_associations'
486
- tool_definitions = {}
487
- else
488
- message
489
- end
490
- end.compact.flatten
491
- messages.replace new
492
- tool_definitions
493
- end
494
-
495
- def self.print(chat)
496
- return chat if String === chat
497
- "\n" + chat.collect do |message|
498
- IndiferentHash.setup message
499
- case message[:content]
500
- when Hash, Array
501
- message[:role].to_s + ":\n\n" + message[:content].to_json
502
- when nil, ''
503
- message[:role].to_s + ":"
504
- else
505
- if %w(option previous_response_id function_call function_call_output).include? message[:role].to_s
506
- message[:role].to_s + ": " + message[:content].to_s
507
- else
508
- message[:role].to_s + ":\n\n" + message[:content].to_s
509
- end
510
- end
511
- end * "\n\n"
512
- end
513
-
514
- def self.purge(chat)
515
- chat.reject do |msg|
516
- IndiferentHash.setup msg
517
- msg[:role].to_s == 'previous_response_id'
518
- end
519
- end
520
- end
521
-
522
- module Chat
523
- extend Annotation
524
-
525
- def message(role, content)
526
- self.append({role: role.to_s, content: content})
527
- end
528
-
529
- def user(content)
530
- message(:user, content)
531
- end
532
-
533
- def system(content)
534
- message(:system, content)
535
- end
536
-
537
- def assistant(content)
538
- message(:assistant, content)
539
- end
540
-
541
- def import(file)
542
- message(:import, file)
543
- end
544
-
545
- def import_last(file)
546
- message(:last, file)
547
- end
548
-
549
- def file(file)
550
- message(:file, file)
551
- end
552
-
553
- def directory(directory)
554
- message(:directory, directory)
555
- end
556
-
557
- def continue(file)
558
- message(:continue, file)
559
- end
560
-
561
- def format(format)
562
- message(:format, format)
563
- end
564
-
565
- def tool(*parts)
566
- content = parts * "\n"
567
- message(:tool, content)
568
- end
569
-
570
- def task(workflow, task_name, inputs = {})
571
- input_str = IndiferentHash.print_options inputs
572
- content = [workflow, task_name, input_str]*" "
573
- message(:task, content)
574
- end
575
-
576
- def inline_task(workflow, task_name, inputs = {})
577
- input_str = IndiferentHash.print_options inputs
578
- content = [workflow, task_name, input_str]*" "
579
- message(:inline_task, content)
580
- end
581
-
582
- def job(step)
583
- message(:job, step.path)
584
- end
585
-
586
- def inline_job(step)
587
- message(:inline_job, step.path)
588
- end
589
-
590
-
591
- def association(name, path, options = {})
592
- options_str = IndiferentHash.print_options options
593
- content = [name, path, options_str]*" "
594
- message(:association, name)
595
- end
596
-
597
- def tag(content, name=nil, tag=:file, role=:user)
598
- self.message role, LLM.tag(tag, content, name)
599
- end
600
-
601
-
602
- def ask(...)
603
- LLM.ask(LLM.chat(self), ...)
604
- end
605
-
606
- def chat(...)
607
- response = ask(...)
608
- if Array === response
609
- current_chat.concat(response)
610
- final(response)
611
- else
612
- current_chat.push({role: :assistant, content: response})
613
- response
614
32
  end
615
- end
616
33
 
617
- def json(...)
618
- self.format :json
619
- output = ask(...)
620
- obj = JSON.parse output
621
- if (Hash === obj) and obj.keys == ['content']
622
- obj['content']
623
- else
624
- obj
625
- end
626
- end
34
+ messages = Chat.indiferent messages
35
+ messages = Chat.imports messages, original, caller_lib_dir
627
36
 
628
- def json_format(format, ...)
629
- self.format format
630
- output = ask(...)
631
- obj = JSON.parse output
632
- if (Hash === obj) and obj.keys == ['content']
633
- obj['content']
634
- else
635
- obj
636
- end
637
- end
638
-
639
- def branch
640
- self.annotate self.dup
641
- end
642
-
643
- def option(name, value)
644
- self.message 'option', [name, value] * " "
645
- end
646
-
647
- def endpoint(value)
648
- option :endpoint, value
649
- end
650
-
651
- def model(value)
652
- option :model, value
653
- end
654
-
655
- def image(file)
656
- self.message :image, file
657
- end
658
-
659
- # Reporting
660
-
661
- def print
662
- LLM.print LLM.chat(self)
663
- end
664
-
665
- def final
666
- LLM.purge(self).last
667
- end
37
+ messages = Chat.clear messages
38
+ messages = Chat.clean messages
668
39
 
669
- def purge
670
- Chat.setup(LLM.purge(self))
671
- end
40
+ messages = Chat.tasks messages
41
+ messages = Chat.jobs messages
42
+ messages = Chat.files messages, original, caller_lib_dir
672
43
 
673
- def shed
674
- self.annotate [final]
44
+ Chat.setup messages
675
45
  end
676
46
 
677
- def answer
678
- final[:content]
47
+ def self.options(...)
48
+ Chat.options(...)
679
49
  end
680
50
 
681
- # Write and save
682
-
683
- def save(path, force = true)
684
- path = path.to_s if Symbol === path
685
- if not (Open.exists?(path) || Path === path || Path.located?(path))
686
- path = Scout.chats.find[path]
687
- end
688
- return if Open.exists?(path) && ! force
689
- Open.write path, LLM.print(self)
51
+ def self.print(...)
52
+ Chat.print(...)
690
53
  end
691
54
 
692
- def write(path, force = true)
693
- path = path.to_s if Symbol === path
694
- if not (Open.exists?(path) || Path === path || Path.located?(path))
695
- path = Scout.chats.find[path]
696
- end
697
- return if Open.exists?(path) && ! force
698
- Open.write path, self.print
55
+ def self.tools(...)
56
+ Chat.tools(...)
699
57
  end
700
58
 
701
- def write_answer(path, force = true)
702
- path = path.to_s if Symbol === path
703
- if not (Open.exists?(path) || Path === path || Path.located?(path))
704
- path = Scout.chats.find[path]
705
- end
706
- return if Open.exists?(path) && ! force
707
- Open.write path, self.answer
59
+ def self.associations(...)
60
+ Chat.associations(...)
708
61
  end
709
62
 
710
- # Image
711
- def create_image(file, ...)
712
- base64_image = LLM.image(LLM.chat(self), ...)
713
- Open.write(file, Base64.decode(file_content), mode: 'wb')
63
+ def self.purge(...)
64
+ Chat.purge(...)
714
65
  end
715
66
  end
67
+