scout-ai 1.2.3 → 2.0.0

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 (174) hide show
  1. checksums.yaml +4 -4
  2. data/.vimproject +138 -50
  3. data/README.md +171 -290
  4. data/Rakefile +17 -1
  5. data/VERSION +1 -1
  6. data/doc/Improvements.md +325 -0
  7. data/doc/StartHere.md +110 -0
  8. data/doc/developer/Architecture.md +126 -0
  9. data/doc/developer/Backends.md +199 -0
  10. data/doc/developer/ChatLifecycle.md +183 -0
  11. data/doc/developer/DelegationInternals.md +295 -0
  12. data/doc/developer/DesignPrinciples.md +245 -0
  13. data/doc/developer/PromptProcessing.md +292 -0
  14. data/doc/developer/Provenance.md +317 -0
  15. data/doc/user/BuildingAgents.md +345 -0
  16. data/doc/user/Cookbook.md +333 -0
  17. data/doc/user/CoreConcepts.md +181 -0
  18. data/doc/user/Delegation.md +191 -0
  19. data/doc/user/GettingStarted.md +159 -0
  20. data/doc/user/ManagingContext.md +163 -0
  21. data/doc/user/MultiAgentWorkflows.md +256 -0
  22. data/doc/user/Python.md +159 -0
  23. data/doc/user/RunningInference.md +200 -0
  24. data/doc/user/ToolCalling.md +193 -0
  25. data/doc/user/WritingChats.md +197 -0
  26. data/lib/scout/llm/agent/chat.rb +61 -11
  27. data/lib/scout/llm/agent/delegate.rb +274 -65
  28. data/lib/scout/llm/agent/iterate.rb +2 -2
  29. data/lib/scout/llm/agent/save.rb +273 -0
  30. data/lib/scout/llm/agent/workflow.rb +164 -0
  31. data/lib/scout/llm/agent.rb +86 -61
  32. data/lib/scout/llm/ask.rb +62 -17
  33. data/lib/scout/llm/backends/anthropic.rb +9 -2
  34. data/lib/scout/llm/backends/bedrock.rb +15 -3
  35. data/lib/scout/llm/backends/default.rb +183 -99
  36. data/lib/scout/llm/backends/glm.rb +58 -0
  37. data/lib/scout/llm/backends/huggingface.rb +196 -26
  38. data/lib/scout/llm/backends/ollama.rb +13 -1
  39. data/lib/scout/llm/backends/openai.rb +0 -2
  40. data/lib/scout/llm/backends/openwebui.rb +20 -13
  41. data/lib/scout/llm/backends/relay.rb +22 -22
  42. data/lib/scout/llm/backends/responses.rb +1 -1
  43. data/lib/scout/llm/chat/agent_meta.rb +264 -0
  44. data/lib/scout/llm/chat/annotation.rb +39 -10
  45. data/lib/scout/llm/chat/parse.rb +28 -6
  46. data/lib/scout/llm/chat/persist.rb +25 -0
  47. data/lib/scout/llm/chat/process/clear.rb +41 -6
  48. data/lib/scout/llm/chat/process/files.rb +21 -6
  49. data/lib/scout/llm/chat/process/meta.rb +421 -34
  50. data/lib/scout/llm/chat/process/options.rb +21 -1
  51. data/lib/scout/llm/chat/process/tools.rb +56 -15
  52. data/lib/scout/llm/chat/process.rb +4 -0
  53. data/lib/scout/llm/chat/prompt/shorten_tools.rb +125 -0
  54. data/lib/scout/llm/chat/prompt/shorten_tools_epoch.rb +365 -0
  55. data/lib/scout/llm/chat/prompt.rb +48 -0
  56. data/lib/scout/llm/chat/provenance.rb +775 -0
  57. data/lib/scout/llm/chat/tool_calls.rb +76 -0
  58. data/lib/scout/llm/chat.rb +18 -2
  59. data/lib/scout/llm/embed.rb +11 -3
  60. data/lib/scout/llm/image.rb +86 -0
  61. data/lib/scout/llm/mcp.rb +10 -2
  62. data/lib/scout/llm/rag.rb +3 -3
  63. data/lib/scout/llm/tools/call.rb +160 -11
  64. data/lib/scout/llm/tools/knowledge_base.rb +1 -1
  65. data/lib/scout/llm/tools/workflow.rb +32 -16
  66. data/lib/scout/model/python/huggingface/causal.rb +23 -5
  67. data/lib/scout/model/python/huggingface.rb +2 -1
  68. data/lib/scout-ai.rb +1 -0
  69. data/python/README.md +197 -14
  70. data/python/scout_ai/huggingface/eval.py +245 -34
  71. data/python/tests/test_huggingface_eval.py +58 -0
  72. data/research/ChatAnalyst-required-changes.md +167 -0
  73. data/research/agent-delegation-analysis.md +810 -0
  74. data/research/agent-meta-provenance-integration-plan.md +622 -0
  75. data/research/agent-workflow-analysis.md +1120 -0
  76. data/research/backends-analysis.md +836 -0
  77. data/research/chat-core-analysis.md +946 -0
  78. data/research/chatanalyst-provenance/00-baseline.md +30 -0
  79. data/research/chatanalyst-provenance/01-repo-map.md +60 -0
  80. data/research/chatanalyst-provenance/02-event-reconstruction.md +55 -0
  81. data/research/chatanalyst-provenance/03-duplication-evidence.md +45 -0
  82. data/research/chatanalyst-provenance/04-tooling-root-cause.md +57 -0
  83. data/research/chatanalyst-provenance/05-fix-plan.md +46 -0
  84. data/research/chatanalyst-provenance/07-critic-review.md +25 -0
  85. data/research/chatanalyst-provenance/final-report.md +45 -0
  86. data/research/chatanalyst-provenance/resumption.md +37 -0
  87. data/research/coding-philosophy-analysis.md +928 -0
  88. data/research/commands-analysis.md +947 -0
  89. data/research/multi-agent-patterns-analysis.md +853 -0
  90. data/research/prompt-strategies-analysis.md +630 -0
  91. data/research/prov-verbosity-fix-notes.md +77 -0
  92. data/research/provenance-analysis.md +469 -0
  93. data/research/provenance-navigation-design.md +640 -0
  94. data/research/synthesis-report.md +487 -0
  95. data/research/tools-system-analysis.md +779 -0
  96. data/scout-ai.gemspec +100 -11
  97. data/scout_commands/agent/ask +13 -3
  98. data/scout_commands/agent/kb +2 -0
  99. data/scout_commands/llm/ask +11 -4
  100. data/scout_commands/llm/md +76 -0
  101. data/scout_commands/llm/process_queries +48 -0
  102. data/scout_commands/llm/prov +602 -0
  103. data/scout_commands/llm/word +71 -0
  104. data/scout_commands/workflow/mcp +43 -0
  105. data/share/word/reference.docx +0 -0
  106. data/test/etc/AI/mock.yaml +11 -0
  107. data/test/fixtures/backends/anthropic.json +19 -0
  108. data/test/fixtures/backends/anthropic_tool_use.json +24 -0
  109. data/test/fixtures/backends/bedrock.json +8 -0
  110. data/test/fixtures/backends/bedrock_embedding.json +3 -0
  111. data/test/fixtures/backends/bedrock_tool_use.json +17 -0
  112. data/test/fixtures/backends/ollama.json +16 -0
  113. data/test/fixtures/backends/ollama_tool_call.json +27 -0
  114. data/test/fixtures/backends/openai_chat.json +21 -0
  115. data/test/fixtures/backends/openai_chat_tool_call.json +31 -0
  116. data/test/fixtures/backends/responses.json +33 -0
  117. data/test/fixtures/backends/responses_tool_call.json +28 -0
  118. data/test/integration/README.md +32 -0
  119. data/test/integration/scout/llm/backends/test_endpoints.rb +34 -0
  120. data/test/integration/scout/llm/backends/test_openwebui.rb +61 -0
  121. data/test/integration/scout/llm/backends/test_relay.rb +52 -0
  122. data/test/integration/scout/llm/test_infrastructure.rb +74 -0
  123. data/test/{scout → integration/scout}/llm/test_mcp.rb +1 -1
  124. data/test/integration/scout/llm/tools/test_mcp.rb +42 -0
  125. data/test/integration/scout/model/test_base.rb +91 -0
  126. data/test/scout/llm/agent/test_chat.rb +8 -2
  127. data/test/scout/llm/agent/test_save.rb +413 -0
  128. data/test/scout/llm/agent/test_workflow.rb +110 -0
  129. data/test/scout/llm/backends/test_anthropic.rb +93 -10
  130. data/test/scout/llm/backends/test_bedrock.rb +118 -2
  131. data/test/scout/llm/backends/test_huggingface.rb +137 -42
  132. data/test/scout/llm/backends/test_ollama.rb +70 -20
  133. data/test/scout/llm/backends/test_openwebui.rb +42 -40
  134. data/test/scout/llm/backends/test_relay.rb +4 -2
  135. data/test/scout/llm/chat/agent_meta_fixtures.rb +131 -0
  136. data/test/scout/llm/chat/process/test_meta.rb +518 -0
  137. data/test/scout/llm/chat/process/test_normalize_usage.rb +183 -0
  138. data/test/scout/llm/chat/test_agent_meta.rb +357 -0
  139. data/test/scout/llm/chat/test_agent_meta_provenance.rb +467 -0
  140. data/test/scout/llm/chat/test_agent_meta_tokens.rb +594 -0
  141. data/test/scout/llm/chat/test_parse.rb +70 -15
  142. data/test/scout/llm/chat/test_prov_cli.rb +274 -0
  143. data/test/scout/llm/chat/test_provenance.rb +240 -0
  144. data/test/scout/llm/chat/test_tool_calls.rb +38 -0
  145. data/test/scout/llm/test_agent.rb +13 -36
  146. data/test/scout/llm/test_ask.rb +75 -52
  147. data/test/scout/llm/test_chat.rb +107 -13
  148. data/test/scout/llm/test_embed.rb +48 -0
  149. data/test/scout/llm/test_rag.rb +23 -16
  150. data/test/scout/llm/test_tools.rb +12 -1
  151. data/test/scout/llm/tools/test_knowledge_base.rb +0 -1
  152. data/test/scout/llm/tools/test_mcp.rb +5 -3
  153. data/test/scout/llm/tools/test_workflow.rb +23 -2
  154. data/test/scout/model/python/huggingface/causal/test_next_token.rb +11 -5
  155. data/test/scout/model/python/huggingface/test_causal.rb +9 -3
  156. data/test/scout/model/python/huggingface/test_classification.rb +11 -2
  157. data/test/scout/model/python/test_torch.rb +2 -0
  158. data/test/scout/model/python/torch/test_helpers.rb +4 -0
  159. data/test/scout/model/test_base.rb +4 -2
  160. data/test/support/availability.rb +231 -0
  161. data/test/support/fake_clients.rb +138 -0
  162. data/test/support/fixtures.rb +21 -0
  163. data/test/support/infrastructure_probes.rb +136 -0
  164. data/test/support/mock_backend.rb +215 -0
  165. data/test/test_helper.rb +32 -2
  166. metadata +99 -10
  167. data/doc/Agent.md +0 -327
  168. data/doc/Chat.md +0 -458
  169. data/doc/LLM.md +0 -340
  170. data/doc/RAG.md +0 -129
  171. data/scout_commands/documenter +0 -148
  172. data/test/scout/llm/backends/test_openai.rb +0 -192
  173. data/test/scout/llm/backends/test_responses.rb +0 -238
  174. data/test/scout/llm/test_parse.rb +0 -98
@@ -1,51 +1,438 @@
1
+ require 'set'
2
+
1
3
  module Chat
2
- def self.serialize_meta(meta)
3
- keys = meta.keys
4
+ # Canonical short keys for per-inference token fields written into meta
5
+ # messages. Every key has implicit <key>_s (session) and <key>_c (chat)
6
+ # cumulative variants computed by update_meta.
7
+ #
8
+ # pt - prompt / input tokens
9
+ # ct - completion / output tokens
10
+ # tt - total tokens
11
+ # cct - cached (cache-hit) input tokens
12
+ # cwt - cache-write input tokens
13
+ # rt - reasoning tokens
14
+ TOKEN_KEYS = %w[pt ct tt cct cwt rt].freeze
15
+
16
+ # Keys that carry cumulative totals across chat requests. Used by
17
+ # Chat.meta to restore the last checkpoint.
18
+ CUMULATIVE_KEYS = TOKEN_KEYS.map { |k| "#{k}_c" }.freeze
19
+
20
+ # Map known provider field names to the canonical short keys.
21
+ # Each entry is [field_path, short_key] where field_path is an array
22
+ # of keys suitable for IndiferentHash.dig.
23
+ USAGE_FIELD_MAP = {
24
+ # prompt / input
25
+ %w[prompt_tokens] => 'pt',
26
+ %w[input_tokens] => 'pt',
27
+ # completion / output
28
+ %w[completion_tokens] => 'ct',
29
+ %w[output_tokens] => 'ct',
30
+ # total
31
+ %w[total_tokens] => 'tt',
32
+ # cache-hit (GLM prompt_tokens_details, OpenAI input_tokens_details)
33
+ %w[prompt_tokens_details cached_tokens] => 'cct',
34
+ %w[input_tokens_details cached_tokens] => 'cct',
35
+ # cache-write (OpenAI input_tokens_details)
36
+ %w[input_tokens_details cache_write_tokens] => 'cwt',
37
+ # reasoning (GLM completion_tokens_details, OpenAI output_tokens_details)
38
+ %w[completion_tokens_details reasoning_tokens] => 'rt',
39
+ %w[output_tokens_details reasoning_tokens] => 'rt',
40
+ # Anthropic flat fields
41
+ %w[cache_read_input_tokens] => 'cct',
42
+ %w[cache_creation_input_tokens] => 'cwt',
43
+ }.freeze
4
44
 
5
- keys = keys.sort_by do |k|
6
- v = meta[k]
7
- String === v ? v.length : 0
45
+ # Normalise a provider usage hash into a flat hash keyed by TOKEN_KEYS.
46
+ # Handles OpenAI Chat API (prompt_tokens/completion_tokens), Responses API
47
+ # (input_tokens/output_tokens), GLM (prompt_tokens/completion_tokens), and
48
+ # Anthropic (cache_read_input_tokens/cache_creation_input_tokens).
49
+ #
50
+ # Missing fields are simply omitted from the result.
51
+ def self.normalize_usage(usage)
52
+ return {} if usage.nil? || usage.empty?
53
+
54
+ IndiferentHash.setup(usage) unless usage.respond_to?(:dig)
55
+
56
+ result = {}
57
+ USAGE_FIELD_MAP.each do |path, short_key|
58
+ next if result.include?(short_key) # first match wins
59
+ value = IndiferentHash.dig(usage, *path)
60
+ result[short_key] = value.to_i if value
8
61
  end
9
62
 
10
- keys.collect{|k| [k,meta[k]] * "="} * " "
11
- end
63
+ # Compute total if not provided but both prompt and completion are present
64
+ if !result.include?('tt')
65
+ pt = result['pt']
66
+ ct = result['ct']
67
+ result['tt'] = pt.to_i + ct.to_i if pt && ct
68
+ end
12
69
 
13
- def self.parse_meta(str)
14
- parts = str.split('=')
15
- meta = {}
16
- key = parts.shift
17
- while next_part = parts.shift
70
+ result
71
+ end
18
72
 
19
- if parts.any?
20
- rnext_part = next_part.reverse
21
- rkey,_, rvalue = rnext_part.partition(/\s+/)
22
- next_key = rkey.reverse
23
- value = rvalue.reverse
73
+ # Serialize a meta hash into a single space-separated string of key=value
74
+ # pairs. Values that contain '=' are wrapped in double quotes so that the
75
+ # '=' inside them is not mistaken for a key/value delimiter during parsing.
76
+ # Backslashes and double quotes inside quoted values are escaped.
77
+ #
78
+ # Keys are sorted by value string length (ascending) so that the longest
79
+ # free-text value appears last; this preserves backward compatibility with
80
+ # the unquoted parser path where the final value extends to end-of-string.
81
+ def self.serialize_meta(meta)
82
+ keys = meta.keys.sort_by { |key| String === meta[key] ? meta[key].length : 0 }
83
+ keys.collect do |key|
84
+ value = meta[key]
85
+ str_value = value.to_s
86
+ if str_value.include?('=')
87
+ escaped = str_value.gsub('\\') { '\\\\' }.gsub('"') { '\\"' }
88
+ %Q(#{key}="#{escaped}")
24
89
  else
25
- value = next_part
90
+ "#{key}=#{str_value}"
26
91
  end
92
+ end * ' '
93
+ end
94
+
95
+ # Parse a serialized meta string back into an IndiferentHash.
96
+ #
97
+ # Each token is key=value where value may be:
98
+ # * A double-quoted string (used when the value contains '='):
99
+ # key="some text with = inside"
100
+ # Backslash escapes inside quotes are unescaped.
101
+ # * An unquoted bare value that may contain spaces but not '=':
102
+ # key=some text here
103
+ # The value boundary is detected by a lookahead for the next
104
+ # ' key=' pattern or end-of-string.
105
+ #
106
+ # ScoutCoder: when the quoted value contains inner double quotes that were
107
+ # not escaped during serialization (e.g. reasoning text that embeds file
108
+ # paths like ["/path"]), the quoted-value alternative must not terminate at
109
+ # the first inner quote. The inner quote is only treated as the closing
110
+ # quote when it is followed by a new key= boundary or end-of-string. This
111
+ # is achieved with a negative lookahead inside the character class:
112
+ # "(?!\s+[^\s=]+=|\s*\z)
113
+ def self.parse_meta(str)
114
+ str = str.to_s
115
+ meta = IndiferentHash.setup({})
116
+ return meta if str.empty?
27
117
 
28
- case value
29
- when /^-?\d+$/
30
- meta[key] = value.to_i
31
- when /^-?\d+\.\d+$/
32
- meta[key] = value.to_f
33
- else
34
- meta[key] = value
118
+ str.scan(/([^\s=]+)=("(?:[^"\\]|\\.|"(?!\s+[^\s=]+=|\s*\z))*"|.*?)(?=\s+[^\s=]+=|\s*\z)/m).each do |key, raw|
119
+ value = if raw.start_with?('"') && raw.end_with?('"')
120
+ raw[1..-2].gsub(/\\(.)/) { $1 }
121
+ else
122
+ raw
35
123
  end
36
124
 
37
- key = next_key
38
- end
125
+ meta[key] = case value
126
+ when /^-?\d+$/ then value.to_i
127
+ when /^-?\d+\.\d+$/ then value.to_f
128
+ else value
129
+ end
130
+ end
39
131
 
40
132
  meta
41
- end
133
+ end
134
+
135
+ # Meta messages are local bookkeeping and are not sent to the provider.
136
+ # The last direct inference checkpoint supplies the linear chat total for
137
+ # the next request; job metadata deliberately contributes no token counts.
138
+ def self.meta(messages)
139
+ meta_messages = []
140
+ messages.reject! do |message|
141
+ match = message[:role].to_s == 'meta'
142
+ meta_messages << message if match
143
+ match
144
+ end
145
+ return nil if meta_messages.empty?
146
+
147
+ metas = meta_messages.collect { |message| parse_meta(message[:content]) }
148
+ current = IndiferentHash.setup(metas.last.dup)
149
+ checkpoint = metas.reverse.find do |meta|
150
+ CUMULATIVE_KEYS.any? { |name| meta.include?(name) }
151
+ end
152
+ if checkpoint
153
+ CUMULATIVE_KEYS.each do |name|
154
+ current[name] = checkpoint[name] if checkpoint.include?(name)
155
+ end
156
+ end
157
+ current
158
+ end
159
+
160
+ def add_meta(key, value)
161
+ meta_msg = role_messages(:meta).last
162
+ meta = meta_msg ? Chat.parse_meta(meta_msg[:content]) : {}
163
+ meta[key] = value
164
+ if meta_msg
165
+ meta_msg[:content] = Chat.serialize_meta(meta)
166
+ else
167
+ message :meta, Chat.serialize_meta(meta)
168
+ end
169
+ end
170
+
171
+ def meta
172
+ meta_msg = role_messages(:meta).last
173
+ return {} if meta_msg.nil?
174
+ Chat.parse_meta(meta_msg[:content])
175
+ end
176
+
177
+ def job_paths
178
+ role_messages(:meta).collect do |message|
179
+ Path.setup(Chat.parse_meta(message[:content])[:job])
180
+ end.compact.uniq
181
+ end
182
+
183
+ alias jobs job_paths
184
+
185
+ # Read a persisted chat without compiling it. Provenance inspection must not
186
+ # execute task, job, file, or import roles again.
187
+ def self.load(file)
188
+ Chat.setup(Chat.parse(Open.read(file)))
189
+ end
190
+
191
+ def self.job_agent_chat_files(job)
192
+ direct_job_chat_files(job)
193
+ end
194
+
195
+ # Return the result and logged chats for a job and all its dependencies.
196
+ # A job is visited only once, so shared dependencies and accidental cycles do
197
+ # not duplicate evidence or recurse forever.
198
+ def self.job_chat_files(job, seen = Set.new)
199
+ job = Step.load(job) unless Step === job
200
+ key = File.expand_path(job.path.to_s)
201
+ return [] if seen.include?(key)
202
+ seen << key
203
+
204
+ chats = []
205
+ chats << job.path if job.done? && job.type.to_s == 'chat'
206
+
207
+ chats.concat job_agent_chat_files(job)
208
+
209
+ job.dependencies.each do |dependency|
210
+ chats.concat(job_chat_files(dependency, seen))
211
+ end
212
+
213
+ chats.collect(&:to_s).uniq
214
+ rescue
215
+ []
216
+ end
217
+
218
+ def job_chat_files
219
+ jobs.flat_map { |job| Chat.job_chat_files(job) }.uniq
220
+ end
221
+
222
+ # ScoutCoder: DUAL-LAYOUT filter (see Chat::DIRECT_LOG_CHAT_GLOBS). Agent
223
+ # chats are the NEW layout files living under the job's own files dir
224
+ # (.files/<name>.chat and .files/<name>.society/**, which no longer have a
225
+ # log/ component) plus the LEGACY .files/log/ tree written by older
226
+ # scout-ai. Other jobs' second-order .files trees are excluded by requiring
227
+ # the file to be under THIS job's files dir.
228
+ def job_agent_chat_files
229
+ jobs.flat_map do |job|
230
+ job_files = job.path.to_s + '.files'
231
+ Chat.provenance_chat_files(job, root_type: :job).select do |file|
232
+ file.include?('.files/log/') || file.start_with?(job_files + '/')
233
+ end
234
+ end.uniq
235
+ end
42
236
 
43
- def self.meta(messages)
44
- meta_msg = messages.select{|info| info[:role].to_sym == :meta }.last
237
+ def job_chats
238
+ job_chat_files.collect { |file| Chat.load(file) }
239
+ end
45
240
 
46
- return nil if meta_msg.nil?
47
- meta_str = meta_msg[:content]
48
- messages.reject!{|info| info[:role].to_sym == :meta }
49
- parse_meta meta_str
241
+ def job_agent_chats
242
+ job_agent_chat_files.collect { |file| Chat.load(file) }
50
243
  end
244
+
245
+ # A lineage id identifies a message in its non-meta conversational history.
246
+ # Meta is deliberately excluded from that history: it starts a response
247
+ # segment but is not provider input.
248
+ def message_index(source: nil)
249
+ previous = nil
250
+ each_with_index.collect do |message, position|
251
+ role = message[:role].to_s
252
+ content = message[:content].to_s
253
+ id = Misc.digest([previous, role, content])
254
+ info = {
255
+ id: id,
256
+ role: role.to_sym,
257
+ prev: previous,
258
+ fingerprint: Log.truncate_string(content)
259
+ }
260
+ info[:address] = [source.to_s, position] if source
261
+ if role == 'meta'
262
+ info[:meta] = Chat.parse_meta(content)
263
+ else
264
+ previous = id
265
+ end
266
+ info
267
+ end
268
+ end
269
+
270
+ # A meta starts a response segment. The segment continues until another meta,
271
+ # a new user/system turn, or the end of the chat. Consecutive and final metas
272
+ # with no covered messages remain as orphan records.
273
+ # Trace one or more message indexes into segment entries. By default
274
+ # entries are globally deduplicated across every supplied index (an
275
+ # inference copied into two chats yields one entry), which is the semantics
276
+ # Chat.token_totals has always had. Pass `deduplicate: false` when the
277
+ # caller needs every persisted evidence location preserved, e.g.
278
+ # Chat.provenance_token_events, which performs its own identity grouping so
279
+ # an event can list all of its evidence records.
280
+ def self.trace_indices(indices, deduplicate: true)
281
+ seen = Set.new
282
+ trace = []
283
+ add = lambda do |pending|
284
+ return if pending.nil?
285
+ inference_id = pending[:meta][:inference_id]
286
+ deduplication = inference_id ? :inference_id : :legacy_lineage
287
+ dedup_key = inference_id ? [:inference_id, inference_id] : [:lineage, pending[:id]]
288
+ return if deduplicate && seen.include?(dedup_key)
289
+ seen << dedup_key
290
+ trace << {
291
+ id: pending[:id],
292
+ lineage_id: pending[:id],
293
+ inference_id: inference_id,
294
+ deduplication: deduplication,
295
+ meta_address: pending[:address],
296
+ meta: IndiferentHash.setup(pending[:meta].except(:reas)),
297
+ messages: pending[:messages],
298
+ message_addresses: pending[:message_addresses],
299
+ orphan: pending[:messages].empty?
300
+ }
301
+ end
302
+
303
+ indices.each do |index|
304
+ pending = nil
305
+ index.each do |info|
306
+ case info[:role]
307
+ when :meta
308
+ add.call(pending)
309
+ pending = {
310
+ id: info[:id], meta: info[:meta], address: info[:address],
311
+ messages: [], message_addresses: []
312
+ }
313
+ when :user, :system
314
+ add.call(pending)
315
+ pending = nil
316
+ else
317
+ if pending
318
+ pending[:messages] << info[:id]
319
+ pending[:message_addresses] << info[:address] if info[:address]
320
+ end
321
+ end
322
+ end
323
+ add.call(pending)
324
+ end
325
+
326
+ trace
327
+ end
328
+
329
+ def self.trace_chats(chats)
330
+ trace_indices(chats.collect(&:message_index))
331
+ end
332
+
333
+ # Trace chats while preserving the persisted address of every meta and
334
+ # covered message. Sources may be a Hash of path => Chat or an Array of
335
+ # [path, Chat] pairs. The optional second argument keeps one entry per
336
+ # persisted location instead of collapsing copies (see Chat.trace_indices);
337
+ # it is positional because `sources` is naturally a brace-less Hash at call
338
+ # sites, which Ruby 3 would otherwise turn into keywords.
339
+ def self.trace_chat_sources(sources, deduplicate = true)
340
+ pairs = sources.to_a
341
+ trace_indices(pairs.collect { |source, chat| chat.message_index(source: source) }, deduplicate: deduplicate)
342
+ end
343
+
344
+ # Select trace entries that carry direct token counts (not job projections).
345
+ def self.direct_entries(chat_list)
346
+ trace_chats(chat_list).select do |entry|
347
+ meta = entry[:meta]
348
+ next false if meta[:job]
349
+ TOKEN_KEYS.any? { |name| meta.include?(name) }
350
+ end
351
+ end
352
+
353
+ # Sum direct token fields across a set of chats. Returns a hash keyed
354
+ # by symbol for every key in TOKEN_KEYS.
355
+ def self.token_totals(chat_list)
356
+ totals = TOKEN_KEYS.each_with_object({}) { |k, h| h[k.to_sym] = 0 }
357
+ direct_entries(chat_list).each do |entry|
358
+ meta = entry[:meta]
359
+ TOKEN_KEYS.each { |name| totals[name.to_sym] += meta[name].to_i }
360
+ end
361
+ totals
362
+ end
363
+
364
+ # Human-readable token summary suitable for one-line CLI output.
365
+ def self.print_tokens(tokens)
366
+ tokens = tokens.transform_keys(&:to_sym) if Hash === tokens
367
+ parts = []
368
+ parts << "prompt=#{Misc.human_number(tokens[:pt])}" if tokens[:pt]
369
+ parts << "completion=#{Misc.human_number(tokens[:ct])}" if tokens[:ct]
370
+ parts << "total=#{Misc.human_number(tokens[:tt])}" if tokens[:tt]
371
+ if tokens[:cct] && tokens[:cct].to_i > 0
372
+ parts << "cached=#{Misc.human_number(tokens[:cct])}"
373
+ end
374
+ if tokens[:cwt] && tokens[:cwt].to_i > 0
375
+ parts << "cache_write=#{Misc.human_number(tokens[:cwt])}"
376
+ end
377
+ if tokens[:rt] && tokens[:rt].to_i > 0
378
+ parts << "reasoning=#{Misc.human_number(tokens[:rt])}"
379
+ end
380
+ parts * ' '
381
+ end
382
+
383
+ # Project a chat-task response from the job that produced it. The response
384
+ # gets exactly one producer marker ({job: <path>}) at its beginning, and the
385
+ # per-inference meta messages are kept inline, adjacent to the function
386
+ # calls they account for, so a projected chat carries the same token
387
+ # provenance as the original agent log.
388
+ #
389
+ # The marker and the inference metas are intentionally separate messages:
390
+ # direct_entries and token_totals exclude any meta carrying +job+, so
391
+ # merging the marker into an inference meta would drop that inference from
392
+ # direct accounting. Keeping both means the same inference can be seen twice
393
+ # in a parent chat (projected copy + agent log); trace_indices dedups those
394
+ # copies by inference_id.
395
+ #
396
+ # +reas+ (reasoning summaries) are dropped from projected copies unless
397
+ # Scout::Config key `chat.project.keep_reas` is truthy: they dominate the
398
+ # size of a projected chat while token attribution and deduplication only
399
+ # need inference_id and the token fields.
400
+ #
401
+ # The projection is idempotent: re-projecting an already projected response
402
+ # of the same job yields the same segments and token totals, without
403
+ # duplicating the marker or any inference meta.
404
+ #
405
+ # ScoutCoder: re-projection is not a corner case. `LLM::Agent#ask` feeds
406
+ # every consumed dependency job chat back through Chat.project, so a chat
407
+ # that has already been projected once is projected again when its parent
408
+ # answer is consumed. The seen_inference guard below is what keeps that
409
+ # path from emitting the same inference twice.
410
+ def self.project(job, messages)
411
+ return [] if Array(messages).empty?
412
+ keep_reas = %w(true TRUE True T 1).include?(Scout::Config.get(:keep_reas, :project, :chat, env: 'CHAT_PROJECT_KEEP_REAS').to_s)
413
+
414
+ seen_inference = {}
415
+ projected = Array(messages).collect do |message|
416
+ next message.dup unless message[:role].to_s == 'meta'
417
+
418
+ meta = parse_meta(message[:content])
419
+ if meta[:job]
420
+ next nil
421
+ end
422
+
423
+ identity = meta[:inference_id] || message[:content]
424
+ next nil if seen_inference.key?(identity)
425
+ seen_inference[identity] = true
426
+
427
+ if keep_reas
428
+ message.dup
429
+ else
430
+ { role: :meta, content: serialize_meta(meta.except(:reas)) }
431
+ end
432
+ end.compact
433
+
434
+ return [] if projected.empty?
435
+ [{ role: :meta, content: serialize_meta(job: job.to_s) }] + projected
436
+ end
437
+
51
438
  end
@@ -1,4 +1,22 @@
1
1
  module Chat
2
+ def self.config(chat)
3
+ new = []
4
+
5
+ chat.select do |info|
6
+ if Hash === info
7
+ role = info[:role].to_s
8
+ if role.to_s == 'config'
9
+ key, value, *tokens = info[:content].split(" ")
10
+ Scout::Config.set({key => value}, *tokens)
11
+ next
12
+ end
13
+ end
14
+ new << info
15
+ end
16
+
17
+ chat.replace new
18
+ end
19
+
2
20
  def self.options(chat)
3
21
  options = IndiferentHash.setup({})
4
22
  sticky_options = IndiferentHash.setup({})
@@ -47,6 +65,8 @@ module Chat
47
65
  new << info
48
66
  end
49
67
  chat.replace new
50
- sticky_options.merge options
68
+ options = sticky_options.merge options
69
+ options.delete_if{|k,v| v.nil? || v == 'nil' }
70
+ options
51
71
  end
52
72
  end
@@ -1,5 +1,32 @@
1
1
  module Chat
2
2
 
3
+ def self.allow_path(path)
4
+ Thread.current['allowed_paths'] ||= []
5
+ return if Thread.current['allowed_paths'].include?(path)
6
+ Log.medium "Allow #{path}"
7
+ Thread.current['allowed_paths'] << path
8
+ end
9
+
10
+ def self.allow_read_path(path)
11
+ Thread.current['allowed_read_paths'] ||= []
12
+ return if Thread.current['allowed_read_paths'].include?(path)
13
+ Log.medium "Allow read #{path}"
14
+ Thread.current['allowed_read_paths'] << path
15
+ end
16
+
17
+ def self.allow_job(job)
18
+ allow_path(job.path)
19
+ allow_path(job.info_file)
20
+ allow_path(job.files_dir)
21
+ end
22
+
23
+ def self.allow_read_job(job)
24
+ allow_read_path(job.path)
25
+ allow_read_path(job.info_file)
26
+ allow_read_path(job.files_dir)
27
+ end
28
+
29
+
3
30
  def self.load_workflow(workflow)
4
31
  workflow = begin
5
32
  Kernel.const_get workflow
@@ -33,11 +60,10 @@ module Chat
33
60
  jobs << job unless message[:role] == 'exec_task'
34
61
 
35
62
  if message[:role] == 'exec_task'
36
- begin
37
- {role: 'user', content: job.exec}
38
- rescue
39
- {role: 'exec_job', content: $!}
40
- end
63
+ result = job.exec
64
+ result = result.to_s if TSV === result
65
+ result = result.to_json unless String === result
66
+ {role: 'user', content: result}
41
67
  elsif message[:role] == 'inline_task'
42
68
  {role: 'inline_job', content: job.path.find}
43
69
  else
@@ -60,7 +86,7 @@ module Chat
60
86
 
61
87
  step = Step.load file
62
88
 
63
- id = step.short_path[0..39]
89
+ id = Log.truncate_string(step.short_path.sub('Default_', ''), 40)
64
90
  id = id.gsub('/','-')
65
91
 
66
92
  if message[:role] == 'inline_job'
@@ -72,15 +98,16 @@ module Chat
72
98
  function_name = step.full_task_name.sub('#', '-')
73
99
  function_name = step.task_name
74
100
  tool_call = {
75
- function: {
76
- name: function_name,
77
- arguments: step.provided_inputs
78
- },
101
+ name: function_name,
102
+ arguments: step.provided_inputs,
79
103
  id: id,
80
104
  }
81
105
 
82
106
  content = if step.done?
83
107
  Open.read(step.path)
108
+ elsif step.streaming?
109
+ step.join
110
+ step.load
84
111
  elsif step.error?
85
112
  Log.warn "Error in job #{step.path}"
86
113
  e = step.exception
@@ -151,7 +178,7 @@ module Chat
151
178
  end
152
179
  next
153
180
  elsif role == 'introduce'
154
- workflow_name = message[:content]
181
+ workflow_name = message[:content].to_s
155
182
  next if introduced_workflows.include? workflow_name
156
183
  introduced_workflows << workflow_name
157
184
  workflow = begin
@@ -169,7 +196,7 @@ module Chat
169
196
 
170
197
  raise "Workflow not found #{workflow_name}" if workflow.nil?
171
198
 
172
- next unless workflow.documentation.empty?
199
+ next if workflow.documentation.empty?
173
200
  content = <<-EOF
174
201
  You have access to tools from workflow '#{workflow.name}'.
175
202
  Below is the documentation of the workflow:
@@ -183,8 +210,14 @@ Below is the documentation of the workflow:
183
210
  elsif role == 'kb'
184
211
  knowledge_base_name, *databases = content_tokens(message)
185
212
  databases = nil if databases.empty?
213
+
186
214
  knowledge_base = KnowledgeBase.load knowledge_base_name
187
215
 
216
+ if knowledge_base.all_databases.empty?
217
+ agent = LLM.load_agent knowledge_base_name
218
+ knowledge_base = agent.knowledge_base if agent.knowledge_base && agent.knowledge_base.all_databases.any?
219
+ end
220
+
188
221
  knowledge_base_definition = LLM.knowledge_base_tool_definition(knowledge_base, databases)
189
222
  tool_definitions.merge!(knowledge_base_definition)
190
223
  next
@@ -203,10 +236,14 @@ Below is the documentation of the workflow:
203
236
  new = messages.collect do |message|
204
237
  role = message[:role]
205
238
  if role == 'association'
206
- name, path, *options = content_tokens(message)
207
-
239
+ name, path, *_ = content_tokens(message)
208
240
  kb ||= KnowledgeBase.new Scout.var.Agent.Chat.knowledge_base
209
- kb.register name, Path.setup(path), IndiferentHash.parse_options(message[:content])
241
+
242
+ options = IndiferentHash.parse_options(message[:content])
243
+ options[:fields] = options[:fields].split(/,\s*/) if String === options[:fields]
244
+ options[:type] = options[:type].to_sym if String === options[:type]
245
+
246
+ kb.register name, Path.setup(path), options
210
247
 
211
248
  tool_definitions.merge!(LLM.knowledge_base_tool_definition( kb, [name]))
212
249
  next
@@ -219,4 +256,8 @@ Below is the documentation of the workflow:
219
256
  messages.replace new
220
257
  tool_definitions
221
258
  end
259
+
260
+ def tooling
261
+ self.select{|msg| [:introduce, :tool, :mcp, :kb].include?(msg[:role].to_sym) }
262
+ end
222
263
  end
@@ -14,4 +14,8 @@ module Chat
14
14
  def self.indiferent(messages)
15
15
  messages.collect{|msg| IndiferentHash.setup msg }
16
16
  end
17
+
18
+ def self.find_role(messages, role)
19
+ messages.select{|m| m[:role].to_sym == role.to_sym }
20
+ end
17
21
  end