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
@@ -0,0 +1,775 @@
1
+ require 'set'
2
+ require 'time'
3
+
4
+ module Chat
5
+ PROVENANCE_RELATIONS = %i[job dependency log result agent_job].freeze
6
+
7
+ # ScoutCoder: DUAL-LAYOUT note. Two on-disk layouts must be globbed for a
8
+ # job's own chat logs and for a saved chat's .files sidecar:
9
+ #
10
+ # new <x>.files/<name>.chat (agent.chat, worker.chat, ...)
11
+ # new <x>.files/<name>.society/**/*.chat (society tree; nested
12
+ # societies keep the plain 'society' basename deeper down)
13
+ # legacy <x>.files/log/**/*.chat (written by older
14
+ # scout-ai; read-only compatibility, never migrated)
15
+ #
16
+ # Exactly these three families may be swept: resets/, other *.files
17
+ # subdirectories and second-order .files trees stay invisible. Files matched
18
+ # by more than one pattern are deduplicated and results are sorted so the
19
+ # traversal order is deterministic.
20
+ DIRECT_LOG_CHAT_GLOBS = ['*.chat', '*.society/**/*.chat', 'log/**/*.chat'].freeze
21
+
22
+ # Glob DIRECT_LOG_CHAT_GLOBS under `files_dir` and return a de-duplicated,
23
+ # sorted list of existing chat files (Path objects). Both new and legacy
24
+ # layouts are returned together; callers that must exclude the root copy of
25
+ # the root conversation (Chat.direct_chat_sidecar_files) filter afterwards.
26
+ def self.direct_log_chat_glob(files_dir)
27
+ files_dir = files_dir.to_s
28
+ return [] unless File.directory?(files_dir)
29
+ DIRECT_LOG_CHAT_GLOBS
30
+ .flat_map { |pattern| Dir.glob(File.join(files_dir, pattern)) }
31
+ .collect { |file| File.expand_path(file) }
32
+ .select { |file| File.file?(file) }
33
+ .uniq
34
+ .sort
35
+ .collect { |file| Path.setup(file) }
36
+ end
37
+
38
+ # ScoutCoder: DUAL-LAYOUT globbing (see DIRECT_LOG_CHAT_GLOBS): a job's own
39
+ # chat logs live at <job>.files/<name>.chat (new), under
40
+ # <job>.files/<name>.society/** (new society tree) or under the legacy
41
+ # <job>.files/log/** (written by older scout-ai; read-only compatibility,
42
+ # never migrated). This method is deliberately not recursive; recursion
43
+ # belongs to traverse_provenance.
44
+ def self.direct_job_chat_files(job)
45
+ job = Step.load(job) unless Step === job
46
+ direct_log_chat_glob(job.files_dir)
47
+ end
48
+
49
+ # Return only chat logs owned by this persisted chat's .files sidecar. The
50
+ # save mechanism writes saved agent conversations into the chat's .files dir
51
+ # exactly like a job does, so a chat with a sidecar is scanned the same way a
52
+ # job is. This method is deliberately not recursive; recursion belongs to
53
+ # traverse_provenance.
54
+ #
55
+ # ScoutCoder: DUAL-LAYOUT globbing (see DIRECT_LOG_CHAT_GLOBS) plus the
56
+ # root-copy exclusion. The save mechanism writes a full copy of the ROOT
57
+ # conversation at the TOP LEVEL of the files dir in BOTH layouts:
58
+ #
59
+ # new <path>.files/<name>.chat (agent.chat, worker.chat, ...)
60
+ # legacy <path>.files/log/agent.chat
61
+ #
62
+ # Those top-level copies must be excluded here or the chat would get a
63
+ # self-edge duplicating the root node. Note the asymmetry with
64
+ # direct_job_chat_files: a JOB's own top-level agent.chat IS traversed
65
+ # (renderers hide it), a CHAT's root copy is NOT. Only TOP-LEVEL *.chat
66
+ # files are excluded: society chats under <name>.society/ (new) and under
67
+ # log/society/ (legacy) are independent conversations and are included.
68
+ def self.direct_chat_sidecar_files(path)
69
+ files_dir = path.to_s + '.files'
70
+ return [] unless File.directory?(files_dir)
71
+ top_level = Dir.glob(File.join(files_dir, '*.chat')).collect { |file| File.expand_path(file) }
72
+ root_copy_legacy = File.expand_path(File.join(files_dir, 'log', 'agent.chat'))
73
+ direct_log_chat_glob(files_dir).reject do |file|
74
+ top_level.include?(file.to_s) || file.to_s == root_copy_legacy
75
+ end
76
+ end
77
+
78
+ # Return the persisted result as a chat file when the Step result type is
79
+ # chat. A job and its result chat may have the same path; traversal identities
80
+ # therefore always include the node kind.
81
+ def self.job_result_chat_file(job)
82
+ job = Step.load(job) unless Step === job
83
+ return nil unless job.type.to_s == 'chat' && File.file?(job.path.to_s)
84
+ Path.setup(File.expand_path(job.path.to_s))
85
+ end
86
+
87
+ def self.provenance_path(kind, object)
88
+ path = File.expand_path(kind.to_sym == :job ? object.path.to_s : object.to_s)
89
+ File.realpath(path)
90
+ rescue SystemCallError
91
+ path
92
+ end
93
+
94
+ def self.provenance_key(kind, object)
95
+ [kind.to_sym, provenance_path(kind, object)]
96
+ end
97
+
98
+ def self.provenance_error(on_error, error, kind, object, relation, reference)
99
+ raise error unless on_error
100
+ on_error.call(error, kind, object, relation, reference)
101
+ end
102
+
103
+ # Resolve a job reference from a chat meta message into a Step. References
104
+ # like "Planned/ask/Default_abc.chat" are relative workflow paths. Step.load
105
+ # may resolve them to a Scout-specific directory that does not contain the
106
+ # actual job data, so we fall back to checking Rbbt.var.jobs and Scout.var.jobs.
107
+ def self.load_job_reference(reference)
108
+ return reference if Step === reference
109
+ ref_str = reference.to_s
110
+
111
+ step = Step.load(ref_str)
112
+ return step if File.exist?(step.path.to_s) || File.exist?(step.path.to_s + '.info')
113
+
114
+ # Step.load resolves relative workflow paths (e.g. Planned/ask/Default_xyz.chat)
115
+ # via Path.find, which may point to a directory that does not contain the
116
+ # actual job data (e.g. ~/.scout/ instead of ~/.rbbt/var/jobs/). Try the
117
+ # standard Rbbt workflow storage location as a fallback.
118
+ rbbt_candidate = File.expand_path(File.join('~/.rbbt/var/jobs', ref_str))
119
+ return Step.load(rbbt_candidate) if File.exist?(rbbt_candidate) || File.exist?(rbbt_candidate + '.info')
120
+
121
+ step
122
+ end
123
+
124
+ # Resolve one job reference into a usable Step. Returns [step, nil] when the
125
+ # job data is loadable (path or .info sidecar exists) and [nil, step] when the
126
+ # reference resolves to a directory without job data, keeping the would-be
127
+ # step for diagnostics. Used by the :agent_job relation so unresolved
128
+ # receipt references are reported instead of enqueued.
129
+ def self.resolve_job_reference(reference)
130
+ step = load_job_reference(reference)
131
+ path = step.path.to_s
132
+ if File.exist?(path) || File.exist?(path + '.info')
133
+ [step, nil]
134
+ else
135
+ [nil, step]
136
+ end
137
+ end
138
+
139
+ # Diagnostics for agent_meta receipt problems found while expanding one chat
140
+ # node: malformed receipts collected by Chat.agent_meta_job_references.
141
+ # Provenance is only ever extracted from parsed function_call_output JSON
142
+ # Hashes carrying an explicit receipt key (current `meta`, legacy
143
+ # `agent_meta`); raw output text is never
144
+ # inspected, so output content that merely mentions "agent_meta" cannot
145
+ # produce a warning. Each malformed record becomes one Chat.provenance_error
146
+ # call with relation :agent_job and kind/object :chat + the chat path, so
147
+ # strict mode (no on_error) raises ScoutException while warning mode keeps
148
+ # going. Returns the valid job references so the caller can enqueue them
149
+ # afterwards.
150
+ def self.report_agent_meta_problems(chat, object, on_error = nil)
151
+ warnings = []
152
+ references = agent_meta_job_references(chat, source: object, warnings: warnings)
153
+
154
+ warnings.each do |warning|
155
+ error = ScoutException.new(
156
+ agent_meta_error_message(warning[:reason],
157
+ chat_path: object.to_s,
158
+ output_address: warning[:output_address],
159
+ call_id: warning[:call_id],
160
+ tool_name: warning[:tool_name],
161
+ reference: warning[:reference] || warning[:raw_entry])
162
+ )
163
+ reference = agent_meta_error_reference(reason: warning[:reason],
164
+ source: warning[:source] || object.to_s,
165
+ output_address: warning[:output_address],
166
+ evidence_address: warning[:evidence_address],
167
+ call_id: warning[:call_id],
168
+ tool_name: warning[:tool_name],
169
+ agent_meta_index: warning[:agent_meta_index],
170
+ raw_entry: warning[:raw_entry],
171
+ reference: warning[:reference])
172
+ provenance_error(on_error, error, :chat, object, :agent_job, reference)
173
+ end
174
+
175
+ references
176
+ end
177
+
178
+ # Traverse the heterogeneous provenance graph using native Path and Step
179
+ # values. The block receives:
180
+ #
181
+ # kind, object, parent_kind, parent, relation, first_visit, detail
182
+ #
183
+ # `kind` is :chat or :job. Chat objects are persisted file Paths; jobs are
184
+ # Steps. The root has nil parent/relation. Every structural edge is yielded,
185
+ # including an edge to a node visited through another branch; first_visit is
186
+ # false in that case and the node is not expanded again.
187
+ #
188
+ # `detail` is nil for every ordinary edge. For an :agent_job edge it is the
189
+ # agent_meta receipt record (Chat.agent_meta_job_references entry) that
190
+ # produced the edge, so the originating function output address, receipt
191
+ # address, call id and tool name stay auditable without re-parsing the parent
192
+ # chat. Blocks accepting only the first six arguments keep working; the
193
+ # trailing detail is only passed when the block can receive it (or accepts
194
+ # any number of arguments).
195
+ #
196
+ # Relations describe root-outward discovery, not diagram arrow direction:
197
+ # chat -> producer job (:job), chat -> delegated-agent producer job
198
+ # (:agent_job, from agent_meta receipts), job -> dependency (:dependency),
199
+ # job -> log chat (:log), job -> result chat (:result), and chat -> log chat
200
+ # of its own .files sidecar (:log, saved agent conversations, excluding the
201
+ # root copy at <path>.files/log/agent.chat).
202
+ #
203
+ # Imported and continued chats are a chat-compilation concern, not a
204
+ # provenance concern. They are resolved during Chat.parse and their content
205
+ # is already inlined in the persisted chat file. Provenance traversal
206
+ # therefore never follows import, continue, or last references.
207
+ def self.traverse_provenance(root, root_type: nil, follow: :all, on_error: nil, &block)
208
+ return enum_for(__method__, root, root_type: root_type, follow: follow, on_error: on_error) unless block
209
+ # Lambda blocks have strict arity; keep six-argument callbacks compatible
210
+ # by only yielding the trailing detail when the block can receive it.
211
+ detail_arity = lambda do
212
+ return true if block.arity == -1
213
+ block.arity >= 7
214
+ end
215
+
216
+ relations = follow == :all ? PROVENANCE_RELATIONS : Array(follow).collect(&:to_sym)
217
+ unknown = relations - PROVENANCE_RELATIONS
218
+ raise ParameterException, "Unknown provenance relations: #{unknown * ', '}" if unknown.any?
219
+
220
+ root_kind = root_type && root_type.to_sym
221
+ root_kind ||= Step === root ? :job : :chat
222
+ root_object = if root_kind == :job
223
+ Step === root ? root : Step.load(root)
224
+ else
225
+ path = File.expand_path(root.to_s)
226
+ raise ParameterException, "Chat not found: #{root}" unless File.file?(path)
227
+ Path.setup(path)
228
+ end
229
+
230
+ queue = [[root_kind, root_object, nil, nil, nil]]
231
+ seen = Set.new
232
+
233
+ until queue.empty?
234
+ kind, object, parent_kind, parent, relation, detail = queue.shift
235
+ key = provenance_key(kind, object)
236
+ first_visit = !seen.include?(key)
237
+ if detail_arity.call
238
+ block.call(kind, object, parent_kind, parent, relation, first_visit, detail)
239
+ else
240
+ block.call(kind, object, parent_kind, parent, relation, first_visit)
241
+ end
242
+ next unless first_visit
243
+ seen << key
244
+
245
+ begin
246
+ if kind == :chat
247
+ chat = Chat.load(object)
248
+
249
+ if relations.include?(:job)
250
+ chat.jobs.each do |reference|
251
+ begin
252
+ job = load_job_reference(reference)
253
+ queue << [:job, job, :chat, object, :job]
254
+ rescue StandardError => error
255
+ provenance_error(on_error, error, :chat, object, :job, reference)
256
+ end
257
+ end
258
+ end
259
+
260
+ if relations.include?(:agent_job)
261
+ # Malformed receipts are diagnostics, never provenance; they are
262
+ # reported through provenance_error and skipped.
263
+ references = begin
264
+ report_agent_meta_problems(chat, object, on_error)
265
+ rescue StandardError => error
266
+ provenance_error(on_error, error, :chat, object, :agent_job, object)
267
+ []
268
+ end
269
+
270
+ references.each do |record|
271
+ reference = record[:job]
272
+ begin
273
+ job, _unresolved = resolve_job_reference(reference)
274
+ if job
275
+ # The receipt record travels with the edge as its detail so
276
+ # the traversal keeps the originating output address, call
277
+ # id and receipt index.
278
+ queue << [:job, job, :chat, object, :agent_job, record]
279
+ else
280
+ error = ScoutException.new(
281
+ agent_meta_error_message(:unresolved_job_reference,
282
+ chat_path: object.to_s,
283
+ output_address: record[:output_address],
284
+ call_id: record[:call_id],
285
+ tool_name: record[:tool_name],
286
+ reference: reference)
287
+ )
288
+ provenance_error(on_error, error, :chat, object, :agent_job,
289
+ agent_meta_error_reference(reason: :unresolved_job_reference,
290
+ source: object.to_s,
291
+ output_address: record[:output_address],
292
+ evidence_address: record[:evidence_address],
293
+ call_id: record[:call_id],
294
+ tool_name: record[:tool_name],
295
+ reference: reference))
296
+ end
297
+ rescue StandardError => error
298
+ provenance_error(on_error, error, :chat, object, :agent_job,
299
+ agent_meta_error_reference(reason: :unresolved_job_reference,
300
+ source: object.to_s,
301
+ reference: reference,
302
+ call_id: record[:call_id],
303
+ tool_name: record[:tool_name]))
304
+ end
305
+ end
306
+ end
307
+
308
+ if relations.include?(:log)
309
+ # A saved chat owns its .files sidecar logs (society
310
+ # conversations) just like a job owns its job logs.
311
+ direct_chat_sidecar_files(object).each do |file|
312
+ queue << [:chat, file, :chat, object, :log]
313
+ end
314
+ end
315
+ else
316
+ if relations.include?(:dependency)
317
+ object.dependencies.each do |dependency|
318
+ queue << [:job, dependency, :job, object, :dependency]
319
+ end
320
+ end
321
+
322
+ if relations.include?(:log)
323
+ direct_job_chat_files(object).each do |file|
324
+ queue << [:chat, file, :job, object, :log]
325
+ end
326
+ end
327
+
328
+ if relations.include?(:result) && (file = job_result_chat_file(object))
329
+ queue << [:chat, file, :job, object, :result]
330
+ end
331
+ end
332
+ rescue StandardError => error
333
+ provenance_error(on_error, error, kind, object, relation, object)
334
+ end
335
+ end
336
+
337
+ nil
338
+ end
339
+
340
+ # Flat structural edges suitable for JSON reports and renderers. Objects are
341
+ # intentionally retained as native values; presentation code can shorten or
342
+ # serialize paths as needed.
343
+ #
344
+ # `:agent_job` edges carry the agent_meta receipt record that produced them
345
+ # under `detail:` (call id, tool name, output address, receipt address, job
346
+ # reference). Two distinct receipts pointing at the same job therefore remain
347
+ # two distinguishable edges instead of collapsing into one; ordinary edges
348
+ # keep `detail: nil`.
349
+ def self.provenance_edges(root, **options)
350
+ edges = []
351
+ traverse_provenance(root, **options) do |kind, object, parent_kind, parent, relation, _first, detail|
352
+ next unless parent
353
+ edge = {
354
+ from_kind: parent_kind,
355
+ from: parent,
356
+ relation: relation,
357
+ to_kind: kind,
358
+ to: object,
359
+ detail: detail
360
+ }
361
+ same_edge = lambda do |other|
362
+ other[:relation] == relation &&
363
+ provenance_key(other[:from_kind], other[:from]) == provenance_key(parent_kind, parent) &&
364
+ provenance_key(other[:to_kind], other[:to]) == provenance_key(kind, object) &&
365
+ (relation != :agent_job ||
366
+ (detail.nil? && other[:detail].nil?) ||
367
+ (detail && other[:detail] && detail[:evidence_address] == other[:detail][:evidence_address]))
368
+ end
369
+ edges << edge unless edges.any?(&same_edge)
370
+ end
371
+ edges
372
+ end
373
+
374
+ def self.provenance_chat_files(root, **options)
375
+ files = []
376
+ traverse_provenance(root, **options) do |kind, object, _pk, _parent, _relation, first|
377
+ files << provenance_path(kind, object) if first && kind == :chat
378
+ end
379
+ files
380
+ end
381
+
382
+ def self.provenance_jobs(root, **options)
383
+ jobs = []
384
+ traverse_provenance(root, **options) do |kind, object, _pk, _parent, _relation, first|
385
+ jobs << object if first && kind == :job
386
+ end
387
+ jobs
388
+ end
389
+
390
+ # Compatibility collector for the former chat-to-chat provenance Hash. New
391
+ # code should use traverse_provenance or provenance_edges, which retain job
392
+ # nodes and relation types.
393
+ def self.provenance(chat_file, prov = {})
394
+ traverse_provenance(chat_file) do |kind, object, parent_kind, parent, _relation, _first|
395
+ next unless parent && kind == :chat
396
+ parent_path = provenance_path(parent_kind, parent)
397
+ prov[parent_path] ||= []
398
+ path = provenance_path(kind, object)
399
+ prov[parent_path] << path unless prov[parent_path].include?(path)
400
+ end
401
+ prov
402
+ end
403
+
404
+ # ------------------------------------------------------------------
405
+ # Provenance-aware token accounting
406
+ # ------------------------------------------------------------------
407
+
408
+ # Canonical-evidence rule for grouped token events, applied in this order:
409
+ #
410
+ # 1. prefer :chat_meta evidence over :agent_meta evidence (a saved child
411
+ # log chat is the richer, primary representation of the same request);
412
+ # 2. within the same origin, the first evidence in discovery order
413
+ # (provenance_chat_files order, then message index, then receipt
414
+ # index).
415
+ #
416
+ # Event tokens always come from the canonical evidence only. Duplicate
417
+ # evidence records are retained (never summed), so a total can always be
418
+ # explained from the evidence list.
419
+
420
+ # One event Hash per deduplicated direct inference event reachable from
421
+ # `root`. A `job=` projection meta is never a token event, and only
422
+ # evidence carrying at least one TOKEN_KEYS field becomes an event.
423
+ #
424
+ # Warning routing: when the caller supplies a `warnings` Array, two kinds of
425
+ # problems are reported into it instead of raising:
426
+ # * traversal-stage agent_meta problems (malformed receipts and unresolved
427
+ # job references) arrive as the agent_meta_error_reference Hash plus
428
+ # `message:`; the traversal keeps going so one bad receipt never hides
429
+ # the rest of the provenance;
430
+ # * identity_conflict records (see below).
431
+ # Unrelated traversal errors keep strict semantics: they raise, both with and
432
+ # without a warnings Array. Without the Array, agent_meta problems raise
433
+ # exactly like Chat.traverse_provenance strict mode.
434
+ #
435
+ # Identity conflicts never raise here; pass `strict: true` to make them
436
+ # raise instead of being reported (see below).
437
+ #
438
+ # Event shape:
439
+ #
440
+ # {
441
+ # inference_id: <String or nil>,
442
+ # identity: <the Array used for grouping, e.g. [:inference_id, "w1"]>,
443
+ # deduplication: :inference_id | :provider_response_id |
444
+ # :legacy_lineage | :receipt_unresolved,
445
+ # meta: <canonical parsed meta>,
446
+ # tokens: {pt:, ct:, tt:, cct:, cwt:, rt:} (symbols, zero-filled,
447
+ # canonical evidence only),
448
+ # evidence: [
449
+ # {origin: :chat_meta, source:, meta_address:, meta:, call_id: nil,
450
+ # tool_name: nil},
451
+ # {origin: :agent_meta, source:, evidence_address:, output_address:,
452
+ # agent_meta_index:, call_id:, tool_name:, meta:}
453
+ # ],
454
+ # conflict: true|false,
455
+ # incomplete_evidence: true|false
456
+ # }
457
+ #
458
+ # `evidence` holds EVERY persisted location of the event (the same
459
+ # inference copied into several saved chats/logs appears once per chat plus
460
+ # once per receipt). Only the canonical evidence supplies `meta` and
461
+ # `tokens`.
462
+ #
463
+ # Identity (grouping) rules, in priority order:
464
+ #
465
+ # * meta[:inference_id] -> [:inference_id, id]
466
+ # * meta[:provider_response_id] otherwise -> [:provider_response_id, id]
467
+ # * chat-side legacy (neither) -> [:lineage, lineage_id], the
468
+ # existing global lineage dedup of Chat.trace_chat_sources is preserved
469
+ # * receipt-side legacy (neither) -> [:receipt, evidence_address];
470
+ # there is no exact rule, so a receipt legacy meta is never merged with
471
+ # anything (documented possible overcount for legacy data)
472
+ #
473
+ # Identity conflicts: when evidence sharing an identity disagree on any
474
+ # TOKEN_KEYS value (compared as integers, missing counts as 0) or on
475
+ # provider_response_id (two DIFFERENT non-empty values), the event keeps
476
+ # every evidence record, counts only the canonical one, sets conflict: true,
477
+ # and - when a warnings Array was supplied - appends one Hash per conflicting
478
+ # event:
479
+ #
480
+ # {reason: :identity_conflict, inference_id:, identity:,
481
+ # fields: <Array of disputed field symbols>,
482
+ # values: <Hash field => the raw meta values in evidence order>,
483
+ # evidence: <the event evidence records>}
484
+ #
485
+ # A missing provider_response_id in one record and a present one in another
486
+ # is INCOMPLETE EVIDENCE, not a conflict: one copy simply carries richer
487
+ # metadata (e.g. across a metadata-format migration). Such events set
488
+ # incomplete_evidence: true, are counted normally, and are never warned about
489
+ # as conflicts. The same leniency applies to optional detailed usage fields
490
+ # (cct/cwt/rt): only the immutable core (inference identity, pt/ct/tt,
491
+ # non-empty provider_response_id) can conflict.
492
+ #
493
+ # A conflicting event is counted once from its canonical evidence, but that
494
+ # number is NOT authoritative: reports must label any total containing
495
+ # conflicts as unresolved (see Chat.provenance_token_totals `conflicts:`).
496
+ # Pass `strict: true` to raise ScoutException on the first conflict instead.
497
+ #
498
+ # Checkpoint fields (*_c, *_s) are never read or summed here.
499
+ def self.provenance_token_events(root, warnings: nil, strict: false, **traversal_options)
500
+ # Route traversal-stage agent_meta problems into the caller's warnings
501
+ # Array instead of letting them raise. Other traversal errors stay strict
502
+ # (raise), and an explicitly supplied on_error keeps being called.
503
+ if Array === warnings
504
+ caller_on_error = traversal_options[:on_error]
505
+ traversal_options = traversal_options.merge(
506
+ on_error: lambda do |error, kind, object, relation, reference|
507
+ if relation == :agent_job && reference.is_a?(Hash) && reference[:reason] &&
508
+ error.message.to_s.include?('agent_meta receipt')
509
+ warnings << reference.merge(message: error.message)
510
+ caller_on_error.call(error, kind, object, relation, reference) if caller_on_error
511
+ elsif caller_on_error
512
+ caller_on_error.call(error, kind, object, relation, reference)
513
+ else
514
+ raise error
515
+ end
516
+ end
517
+ )
518
+ end
519
+
520
+ files = provenance_chat_files(root, **traversal_options)
521
+ sources = {}
522
+ files.each { |file| sources[file] = Chat.load(file) }
523
+
524
+ evidences = []
525
+
526
+ # Chat-side direct events. `deduplicate: false` keeps one entry per
527
+ # persisted location: an inference copied into several saved chats/logs
528
+ # (or projected into several places) yields several evidence records, one
529
+ # per address. Grouping by identity happens exactly once, below, so the
530
+ # resulting event can list every location.
531
+ trace_chat_sources(sources, false).each do |entry|
532
+ meta = entry[:meta]
533
+ next if meta[:job]
534
+ next unless TOKEN_KEYS.any? { |key| meta.include?(key) }
535
+ address = entry[:meta_address]
536
+ evidences << {
537
+ origin: :chat_meta,
538
+ discovery: evidences.length,
539
+ source: address ? address[0] : nil,
540
+ meta_address: address,
541
+ meta: meta,
542
+ call_id: nil,
543
+ tool_name: nil,
544
+ inference_id: entry[:inference_id],
545
+ lineage_id: entry[:lineage_id]
546
+ }
547
+ end
548
+
549
+ # Receipt-side direct events. Malformed receipts are reported through a
550
+ # private buffer and merged into the caller's warnings Array only when the
551
+ # traversal stage has not already reported the same problem (it reports
552
+ # with the richer reference + message shape whenever :agent_job is
553
+ # followed); without an Array, agent_meta_evidence skips them silently.
554
+ receipt_problems = Array === warnings ? [] : nil
555
+ sources.each do |path, chat|
556
+ agent_meta_evidence(chat, source: path, warnings: receipt_problems).each do |record|
557
+ meta = record[:meta]
558
+ next if meta[:job]
559
+ next unless TOKEN_KEYS.any? { |key| meta.include?(key) }
560
+ evidences << {
561
+ origin: :agent_meta,
562
+ discovery: evidences.length,
563
+ source: path,
564
+ evidence_address: record[:evidence_address],
565
+ output_address: record[:output_address],
566
+ agent_meta_index: record[:agent_meta_index],
567
+ meta: meta,
568
+ call_id: record[:call_id],
569
+ tool_name: record[:tool_name],
570
+ inference_id: meta[:inference_id],
571
+ lineage_id: nil
572
+ }
573
+ end
574
+ end
575
+
576
+ if receipt_problems && !receipt_problems.empty?
577
+ reported = Set.new(warnings.collect do |warning|
578
+ next if warning[:reason] == :identity_conflict
579
+ [warning[:reason], warning[:output_address], warning[:agent_meta_index]]
580
+ end)
581
+ receipt_problems.each do |problem|
582
+ key = [problem[:reason], problem[:output_address], problem[:agent_meta_index]]
583
+ warnings << problem unless reported.include?(key)
584
+ end
585
+ end
586
+
587
+ origin_rank = { chat_meta: 0, agent_meta: 1 }
588
+ events = []
589
+ by_identity = {}
590
+
591
+ evidences.each do |evidence|
592
+ meta = evidence[:meta]
593
+ if evidence[:inference_id]
594
+ identity = [:inference_id, evidence[:inference_id]]
595
+ deduplication = :inference_id
596
+ elsif meta[:provider_response_id]
597
+ identity = [:provider_response_id, meta[:provider_response_id]]
598
+ deduplication = :provider_response_id
599
+ elsif evidence[:origin] == :chat_meta
600
+ identity = [:lineage, evidence[:lineage_id]]
601
+ deduplication = :legacy_lineage
602
+ else
603
+ identity = [:receipt, evidence[:evidence_address]]
604
+ deduplication = :receipt_unresolved
605
+ end
606
+
607
+ event = by_identity[identity]
608
+ unless event
609
+ event = { identity: identity, deduplication: deduplication, _evidence: [] }
610
+ by_identity[identity] = event
611
+ events << event
612
+ end
613
+ event[:_evidence] << evidence
614
+ end
615
+
616
+ events.each do |event|
617
+ ordered = event.delete(:_evidence).sort_by do |evidence|
618
+ [origin_rank[evidence[:origin]], evidence[:discovery]]
619
+ end
620
+ canonical = ordered.first
621
+
622
+ event[:evidence] = ordered.collect do |evidence|
623
+ record = {
624
+ origin: evidence[:origin],
625
+ source: evidence[:source],
626
+ meta: evidence[:meta],
627
+ call_id: evidence[:call_id],
628
+ tool_name: evidence[:tool_name]
629
+ }
630
+ if evidence[:origin] == :chat_meta
631
+ record[:meta_address] = evidence[:meta_address]
632
+ else
633
+ record[:evidence_address] = evidence[:evidence_address]
634
+ record[:output_address] = evidence[:output_address]
635
+ record[:agent_meta_index] = evidence[:agent_meta_index]
636
+ end
637
+ record
638
+ end
639
+
640
+ event[:inference_id] = canonical[:inference_id]
641
+ event[:meta] = canonical[:meta]
642
+ event[:tokens] = TOKEN_KEYS.each_with_object({}) do |key, hash|
643
+ hash[key.to_sym] = canonical[:meta][key].to_i
644
+ end
645
+
646
+ # Immutable core only: pt/ct/tt and a genuinely different non-empty
647
+ # provider_response_id. Optional detail fields (cct, cwt, rt) and a
648
+ # missing-vs-present provider_response_id are incomplete evidence, not
649
+ # conflicts.
650
+ core_fields = %i[pt ct tt]
651
+ fields = []
652
+ values = {}
653
+ core_fields.each do |key|
654
+ next unless ordered.collect { |evidence| evidence[:meta][key].to_i }.uniq.length > 1
655
+ fields << key
656
+ values[key] = ordered.collect { |evidence| evidence[:meta][key] }
657
+ end
658
+ provider_ids = ordered.collect { |evidence| evidence[:meta][:provider_response_id].to_s }
659
+ .reject { |value| value.strip.empty? }.uniq
660
+ if provider_ids.length > 1
661
+ fields << :provider_response_id
662
+ values[:provider_response_id] = ordered.collect { |evidence| evidence[:meta][:provider_response_id] }
663
+ end
664
+
665
+ # Incomplete evidence: exactly one non-empty provider_response_id value
666
+ # while at least one copy lacks it (metadata-format migration), so there
667
+ # is nothing to disagree about.
668
+ present_ids = ordered.collect { |evidence| evidence[:meta][:provider_response_id].to_s }
669
+ .reject { |value| value.strip.empty? }
670
+ if present_ids.uniq.length <= 1 &&
671
+ present_ids.length != ordered.length && !present_ids.empty?
672
+ event[:incomplete_evidence] = true
673
+ end
674
+
675
+ next unless fields.any?
676
+ event[:conflict] = true
677
+ if strict
678
+ raise ScoutException, "agent_meta identity conflict on #{event[:identity] * '='}: disputed #{fields * ','}"
679
+ end
680
+ next unless Array === warnings
681
+ warnings << {
682
+ reason: :identity_conflict,
683
+ inference_id: event[:inference_id],
684
+ identity: event[:identity],
685
+ fields: fields,
686
+ values: values,
687
+ evidence: event[:evidence]
688
+ }
689
+ end
690
+
691
+ # Reorder keys readably; conflict defaults to false.
692
+ events.collect do |event|
693
+ {
694
+ inference_id: event[:inference_id],
695
+ identity: event[:identity],
696
+ deduplication: event[:deduplication],
697
+ meta: event[:meta],
698
+ tokens: event[:tokens],
699
+ evidence: event[:evidence],
700
+ conflict: event[:conflict] || false,
701
+ incomplete_evidence: event[:incomplete_evidence] || false
702
+ }
703
+ end
704
+ end
705
+
706
+ # Aggregate token totals over deduplicated provenance events. The result is
707
+ # symbol keyed and zero initialized exactly like Chat.token_totals:
708
+ # {pt:, ct:, tt:, cct:, cwt:, rt:}.
709
+ #
710
+ # Scopes. These are EVIDENCE COVERAGE descriptions, not a partition of cost:
711
+ # an event represented both in a saved child log and in a receipt belongs to
712
+ # :chat_evidence AND to :receipt_evidence, so the two are not additive and
713
+ # must never be summed together. Only :deduplicated_total and :receipt_only
714
+ # are safe to compare additively.
715
+ #
716
+ # * :deduplicated_total - every event counted once (default);
717
+ # * :chat_evidence - events with at least one :chat_meta evidence
718
+ # (physically stored in a chat/log file);
719
+ # * :receipt_evidence - events with at least one :agent_meta evidence
720
+ # (embedded in a function_call_output receipt);
721
+ # * :receipt_only - events with receipt evidence and NO saved-chat
722
+ # evidence (the disjoint delegated contribution).
723
+ #
724
+ # Conflict handling: a conflicting event still contributes its canonical
725
+ # tokens, so a total containing conflicts is a best-effort view, not an
726
+ # authoritative cost. The `conflicts:` keyword makes that explicit in
727
+ # machine readable form; with `strict: true` conflicts raise instead (the
728
+ # keyword is forwarded to provenance_token_events).
729
+ def self.provenance_token_totals(root, scope: :deduplicated_total, warnings: nil,
730
+ conflicts: nil, **traversal_options)
731
+ events = provenance_token_events(root, warnings: warnings, **traversal_options)
732
+
733
+ selected = case scope.to_sym
734
+ when :deduplicated_total
735
+ events
736
+ when :chat_evidence
737
+ events.select { |event| event[:evidence].any? { |e| e[:origin] == :chat_meta } }
738
+ when :receipt_evidence
739
+ events.select { |event| event[:evidence].any? { |e| e[:origin] == :agent_meta } }
740
+ when :receipt_only
741
+ events.select do |event|
742
+ event[:evidence].any? { |e| e[:origin] == :agent_meta } &&
743
+ event[:evidence].none? { |e| e[:origin] == :chat_meta }
744
+ end
745
+ else
746
+ raise ParameterException, "Unknown token scope: #{scope}"
747
+ end
748
+
749
+ totals = TOKEN_KEYS.each_with_object({}) { |key, hash| hash[key.to_sym] = 0 }
750
+ selected.each do |event|
751
+ TOKEN_KEYS.each { |key| totals[key.to_sym] += event[:tokens][key.to_sym] }
752
+ end
753
+
754
+ if conflicts.is_a?(Hash)
755
+ conflicting = events.select { |event| event[:conflict] }
756
+ incomplete = events.select { |event| event[:incomplete_evidence] }
757
+ conflicts[:events] = conflicting.length
758
+ conflicts[:incomplete_evidence_events] = incomplete.length
759
+ conflicts[:authoritative] = conflicting.empty?
760
+ end
761
+
762
+ totals
763
+ end
764
+
765
+ # Provenance token aggregate. Receipt (agent_meta) child usage is now
766
+ # included through the event collector, so child inference paid inside a
767
+ # parent tool call is no longer invisible when the child chat was not saved.
768
+ def self.tokens(root, **options)
769
+ provenance_token_totals(root, **options)
770
+ end
771
+
772
+ def self.timestamp
773
+ Time.now.utc.iso8601(3)
774
+ end
775
+ end