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,594 @@
1
+ require File.expand_path(__FILE__).sub(%r(/test/.*), '/test/test_helper.rb')
2
+ require 'scout/llm/chat'
3
+ require 'json'
4
+ require_relative 'agent_meta_fixtures'
5
+
6
+ # Token accounting for agent_meta receipts (plan delivery step 3): the
7
+ # provenance-aware event collector. Fixtures A, B, D, F, G and H from the
8
+ # design record; traversal structure itself is covered by
9
+ # test_agent_meta_provenance.rb.
10
+ class TestChatAgentMetaTokens < Test::Unit::TestCase
11
+ include AgentMetaFixtures
12
+
13
+ ## Fixture A - receipt-only delegation
14
+
15
+ def test_receipt_only_events_are_counted_once
16
+ TmpFile.with_dir do |dir|
17
+ parent = write_chat(dir, 'parent.chat',
18
+ receipt_chat_text(
19
+ {'a1' => [meta_receipt('pt=100 ct=50 tt=150 inference_id=w1'),
20
+ meta_receipt('pt=20 ct=10 tt=30 inference_id=w2')]},
21
+ extra: ['meta: pt=60 ct=40 tt=100 inference_id=p1']
22
+ ))
23
+
24
+ events = Chat.provenance_token_events(parent)
25
+
26
+ # Both worker events plus the parent's own direct event.
27
+ assert_equal %w[p1 w1 w2], events.collect { |event| event[:inference_id] }.sort
28
+
29
+ worker = events.select { |event| %w[w1 w2].include?(event[:inference_id]) }
30
+ assert_equal [20, 100], worker.collect { |event| event[:tokens][:pt] }.sort
31
+ assert worker.all? { |event| event[:deduplication] == :inference_id }
32
+ assert worker.all? { |event| !event[:conflict] }
33
+ end
34
+ end
35
+
36
+ def test_receipt_only_evidence_addresses_and_call_ids
37
+ TmpFile.with_dir do |dir|
38
+ parent = write_chat(dir, 'parent.chat',
39
+ receipt_chat_text(
40
+ {'a1' => [meta_receipt('pt=100 ct=50 tt=150 inference_id=w1'),
41
+ meta_receipt('pt=20 ct=10 tt=30 inference_id=w2')]}
42
+ ))
43
+
44
+ evidence = Chat.provenance_token_events(parent)
45
+ .sort_by { |event| event[:inference_id] }
46
+ .collect { |event| event[:evidence].first }
47
+
48
+ assert_equal [parent, 2, :agent_meta, 0], evidence.first[:evidence_address]
49
+ assert_equal [parent, 2, :agent_meta, 1], evidence.last[:evidence_address]
50
+ assert_equal %w[a1 a1], evidence.collect { |record| record[:call_id] }
51
+ assert_equal %w[ask ask], evidence.collect { |record| record[:tool_name] }
52
+ assert_equal parent, evidence.first[:source]
53
+ assert_equal :agent_meta, evidence.first[:origin]
54
+ end
55
+ end
56
+
57
+ def test_receipt_only_scopes_and_follow_option
58
+ TmpFile.with_dir do |dir|
59
+ parent = write_chat(dir, 'parent.chat',
60
+ receipt_chat_text(
61
+ {'a1' => [meta_receipt('pt=100 ct=50 tt=150 inference_id=w1'),
62
+ meta_receipt('pt=20 ct=10 tt=30 inference_id=w2')]},
63
+ extra: ['meta: pt=60 ct=40 tt=100 inference_id=p1']
64
+ ))
65
+
66
+ aggregate = Chat.provenance_token_totals(parent)
67
+ assert_equal({pt: 180, ct: 100, tt: 280, cct: 0, cwt: 0, rt: 0}, aggregate)
68
+
69
+ # Receipt events are not local, but they are receipt evidence.
70
+ local = Chat.provenance_token_totals(parent, scope: :chat_evidence)
71
+ assert_equal({pt: 60, ct: 40, tt: 100, cct: 0, cwt: 0, rt: 0}, local)
72
+
73
+ receipt = Chat.provenance_token_totals(parent, scope: :receipt_evidence)
74
+ assert_equal({pt: 120, ct: 60, tt: 180, cct: 0, cwt: 0, rt: 0}, receipt)
75
+
76
+ # Totals keep working with a restricted traversal (no jobs to follow).
77
+ assert_equal aggregate, Chat.provenance_token_totals(parent, follow: [:job])
78
+
79
+ # Chat.tokens now uses the collector.
80
+ assert_equal aggregate, Chat.tokens(parent)
81
+ end
82
+ end
83
+
84
+ ## Fixture B - receipt plus saved worker log
85
+
86
+ def test_receipt_and_log_evidence_merge_into_one_event
87
+ TmpFile.with_dir do |dir|
88
+ worker_log = "user: work\n" +
89
+ "meta: pt=100 ct=50 tt=150 inference_id=w1\n" +
90
+ "meta: pt=20 ct=10 tt=30 inference_id=w2\n" +
91
+ "meta: pt=5 ct=5 tt=10 inference_id=w3\n" +
92
+ "assistant: done\n"
93
+ worker = make_job(dir, 'Worker/ask/Default_w', logs: {'agent.chat' => worker_log})
94
+
95
+ parent = write_chat(dir, 'parent.chat',
96
+ receipt_chat_text(
97
+ {'a1' => [meta_receipt('pt=100 ct=50 tt=150 inference_id=w1'),
98
+ meta_receipt('pt=20 ct=10 tt=30 inference_id=w2')]},
99
+ extra: ['meta: pt=60 ct=40 tt=100 inference_id=p1',
100
+ "meta: job=#{worker}"]
101
+ ))
102
+
103
+ events = Chat.provenance_token_events(parent)
104
+ assert_equal 4, events.length
105
+
106
+ merged = events.select { |event| %w[w1 w2].include?(event[:inference_id] )}.sort_by { |event| event[:inference_id] }
107
+ merged.each do |event|
108
+ assert_equal 2, event[:evidence].length
109
+ origins = event[:evidence].collect { |record| record[:origin] }
110
+ assert_include origins, :chat_meta
111
+ assert_include origins, :agent_meta
112
+
113
+ chat_side = event[:evidence].find { |record| record[:origin] == :chat_meta }
114
+ receipt_side = event[:evidence].find { |record| record[:origin] == :agent_meta }
115
+ assert_equal File.join(worker + '.files', 'log', 'agent.chat'), chat_side[:source]
116
+ assert_equal [parent, 2, :agent_meta, event[:inference_id] == 'w1' ? 0 : 1],
117
+ receipt_side[:evidence_address]
118
+ assert !event[:conflict]
119
+ end
120
+
121
+ # Aggregate is fixture A plus the worker-only event, never doubled.
122
+ assert_equal({pt: 185, ct: 105, tt: 290, cct: 0, cwt: 0, rt: 0},
123
+ Chat.provenance_token_totals(parent))
124
+
125
+ # Totals equal the sum over events.
126
+ summed = events.each_with_object(Hash.new(0)) do |event, hash|
127
+ event[:tokens].each { |key, value| hash[key] += value }
128
+ end
129
+ assert_equal Chat.provenance_token_totals(parent), summed
130
+
131
+ assert_equal Chat.provenance_token_totals(parent), Chat.tokens(parent)
132
+ end
133
+ end
134
+
135
+ def test_scope_selection_with_shared_evidence
136
+ TmpFile.with_dir do |dir|
137
+ worker_log = "user: work\n" +
138
+ "meta: pt=100 ct=50 tt=150 inference_id=w1\n" +
139
+ "meta: pt=5 ct=5 tt=10 inference_id=w3\n" +
140
+ "assistant: done\n"
141
+ worker = make_job(dir, 'Worker/ask/Default_w', logs: {'agent.chat' => worker_log})
142
+
143
+ parent = write_chat(dir, 'parent.chat',
144
+ receipt_chat_text(
145
+ {'a1' => [meta_receipt('pt=100 ct=50 tt=150 inference_id=w1')]},
146
+ extra: ['meta: pt=60 ct=40 tt=100 inference_id=p1',
147
+ "meta: job=#{worker}"]
148
+ ))
149
+
150
+ events = Chat.provenance_token_events(parent)
151
+ shared = events.select { |event| event[:evidence].any? { |r| r[:origin] == :chat_meta } &&
152
+ event[:evidence].any? { |r| r[:origin] == :agent_meta } }
153
+ assert_equal ['w1'], shared.collect { |event| event[:inference_id] }
154
+
155
+ # local and receipt both include the shared event; aggregate counts it once.
156
+ local = Chat.provenance_token_totals(parent, scope: :chat_evidence)
157
+ receipt = Chat.provenance_token_totals(parent, scope: :receipt_evidence)
158
+ aggregate = Chat.provenance_token_totals(parent)
159
+ shared_tokens = shared.first[:tokens]
160
+
161
+ local_events = events.select { |event| event[:evidence].any? { |r| r[:origin] == :chat_meta } }
162
+ receipt_events = events.select { |event| event[:evidence].any? { |r| r[:origin] == :agent_meta } }
163
+ assert_equal local_events.length + receipt_events.length - shared.length, events.length
164
+
165
+ summed = shared_tokens.each_with_object(Hash.new(0)) { |(k, v), h| h[k] = local[k] + receipt[k] - v }
166
+ assert_equal aggregate, summed
167
+ end
168
+ end
169
+
170
+ ## Fixture D - aggregate over the nested chain
171
+
172
+ def test_fixture_d_every_direct_event_counted_once
173
+ TmpFile.with_dir do |dir|
174
+ parent, _worker, _critic, _dep = fixture_c(dir)
175
+
176
+ events = Chat.provenance_token_events(parent)
177
+
178
+ # w1, w2 (worker log), c1 (critic receipt inside the worker log),
179
+ # shadow (parent receipt); the two job= projections count for nothing.
180
+ assert_equal %w[c1 shadow w1 w2], events.collect { |event| event[:inference_id] }.sort
181
+ assert_empty events.select { |event| event[:meta][:job] }
182
+
183
+ # By hand: parent receipt shadow (10/0/12) + worker log w1 (100/50/150)
184
+ # + w2 (20/10/30) + critic receipt c1 (30/10/40).
185
+ assert_equal({pt: 160, ct: 70, tt: 232, cct: 0, cwt: 0, rt: 0},
186
+ Chat.provenance_token_totals(parent))
187
+ end
188
+ end
189
+
190
+ ## Fixture F - identity conflicts
191
+
192
+ def test_conflicting_token_fields_count_only_canonical_evidence
193
+ TmpFile.with_dir do |dir|
194
+ log = "user: work\nmeta: pt=10 ct=5 tt=99 inference_id=f1\nassistant: done\n"
195
+ worker = make_job(dir, 'Worker/ask/Default_w', logs: {'agent.chat' => log})
196
+
197
+ parent = write_chat(dir, 'parent.chat',
198
+ receipt_chat_text(
199
+ {'a1' => [meta_receipt('pt=10 ct=5 tt=15 inference_id=f1')]},
200
+ extra: ["meta: job=#{worker}"]
201
+ ))
202
+
203
+ warnings = []
204
+ events = Chat.provenance_token_events(parent, warnings: warnings)
205
+
206
+ assert_equal 1, events.length
207
+ event = events.first
208
+ assert event[:conflict]
209
+
210
+ # Canonical is the chat side; tokens are neither the sum nor the receipt.
211
+ assert_equal 99, event[:tokens][:tt]
212
+ assert_equal({pt: 10, ct: 5, tt: 99, cct: 0, cwt: 0, rt: 0},
213
+ Chat.provenance_token_totals(parent))
214
+
215
+ # Both evidence records survive, each with its own meta.
216
+ assert_equal 2, event[:evidence].length
217
+ assert_equal 99, event[:evidence].find { |r| r[:origin] == :chat_meta }[:meta][:tt].to_i
218
+ assert_equal 15, event[:evidence].find { |r| r[:origin] == :agent_meta }[:meta][:tt].to_i
219
+
220
+ assert_equal 1, warnings.length
221
+ warning = warnings.first
222
+ assert_equal :identity_conflict, warning[:reason]
223
+ assert_equal 'f1', warning[:inference_id]
224
+ assert_equal [:inference_id, 'f1'], warning[:identity]
225
+ assert_equal [:tt], warning[:fields]
226
+ assert_equal [99, 15], warning[:values][:tt].collect(&:to_i)
227
+ assert_equal 2, warning[:evidence].length
228
+ end
229
+ end
230
+
231
+ def test_provider_response_id_disagreement_is_a_conflict
232
+ TmpFile.with_dir do |dir|
233
+ parent = write_chat(dir, 'p2.chat',
234
+ receipt_chat_text(
235
+ {'a1' => [meta_receipt('pt=1 tt=2 inference_id=g1 provider_response_id=ra'),
236
+ meta_receipt('pt=1 tt=2 inference_id=g1 provider_response_id=rb')]}
237
+ ))
238
+
239
+ warnings = []
240
+ events = Chat.provenance_token_events(parent, warnings: warnings)
241
+
242
+ assert_equal 1, events.length
243
+ assert events.first[:conflict]
244
+ assert_equal 2, events.first[:evidence].length
245
+ assert_equal 1, warnings.length
246
+ assert_equal [:provider_response_id], warnings.first[:fields]
247
+ assert_equal %w[ra rb], warnings.first[:values][:provider_response_id]
248
+ end
249
+ end
250
+
251
+ def test_provider_response_id_groups_receipt_and_log_without_inference_id
252
+ TmpFile.with_dir do |dir|
253
+ worker = make_job(dir, 'W3/ask/Default_3',
254
+ logs: {'agent.chat' => "user: w\nmeta: pt=1 tt=2 provider_response_id=rx\nassistant: done\n"})
255
+ parent = write_chat(dir, 'p4.chat',
256
+ receipt_chat_text(
257
+ {'a1' => [meta_receipt('pt=1 tt=2 provider_response_id=rx')]},
258
+ extra: ["meta: job=#{worker}"]
259
+ ))
260
+
261
+ events = Chat.provenance_token_events(parent)
262
+
263
+ assert_equal 1, events.length
264
+ assert_equal :provider_response_id, events.first[:deduplication]
265
+ assert_equal [:provider_response_id, 'rx'], events.first[:identity]
266
+ assert_equal 2, events.first[:evidence].length
267
+ assert_equal({pt: 1, ct: 0, tt: 2, cct: 0, cwt: 0, rt: 0},
268
+ Chat.provenance_token_totals(parent))
269
+ end
270
+ end
271
+
272
+ def test_conflicts_do_not_raise_without_a_warnings_array
273
+ TmpFile.with_dir do |dir|
274
+ parent = write_chat(dir, 'p2.chat',
275
+ receipt_chat_text(
276
+ {'a1' => [meta_receipt('pt=1 tt=2 inference_id=g1'),
277
+ meta_receipt('pt=1 tt=9 inference_id=g1')]}
278
+ ))
279
+
280
+ events = Chat.provenance_token_events(parent)
281
+ assert_equal 1, events.length
282
+ assert events.first[:conflict]
283
+ assert_equal 2, events.first[:evidence].length
284
+ end
285
+ end
286
+
287
+ ## Fixture G - legacy records without inference_id
288
+
289
+ def test_legacy_chat_metas_keep_lineage_dedup
290
+ TmpFile.with_dir do |dir|
291
+ legacy = "user: w\nmeta: pt=7 ct=3 tt=10\nassistant: done\n"
292
+ j1 = make_job(dir, 'J1/ask/Default_1', logs: {'agent.chat' => legacy})
293
+ j2 = make_job(dir, 'J2/ask/Default_2', logs: {'agent.chat' => legacy})
294
+ copied = write_chat(dir, 'copied.chat',
295
+ "user: go\nmeta: job=#{j1}\nmeta: job=#{j2}\nassistant: done\n")
296
+
297
+ events = Chat.provenance_token_events(copied)
298
+
299
+ assert_equal 1, events.length
300
+ assert_equal :legacy_lineage, events.first[:deduplication]
301
+ assert_equal [:lineage, events.first[:identity][1]], events.first[:identity]
302
+ assert_equal({pt: 7, ct: 3, tt: 10, cct: 0, cwt: 0, rt: 0},
303
+ Chat.provenance_token_totals(copied))
304
+ end
305
+ end
306
+
307
+ def test_legacy_receipt_metas_are_never_merged
308
+ TmpFile.with_dir do |dir|
309
+ twice = write_chat(dir, 'twice.chat',
310
+ receipt_chat_text(
311
+ {'r1' => [meta_receipt('pt=5 tt=6')],
312
+ 'r2' => [meta_receipt('pt=5 tt=6')]}
313
+ ))
314
+
315
+ events = Chat.provenance_token_events(twice)
316
+
317
+ # No exact rule exists for legacy receipts: identical content stays two
318
+ # events (documented possible overcount).
319
+ assert_equal 2, events.length
320
+ assert events.all? { |event| event[:deduplication] == :receipt_unresolved }
321
+ assert_equal 2, events.collect { |event| event[:identity] }.uniq.length
322
+ assert_equal({pt: 10, ct: 0, tt: 12, cct: 0, cwt: 0, rt: 0},
323
+ Chat.provenance_token_totals(twice))
324
+ end
325
+ end
326
+
327
+ def test_legacy_receipt_and_log_stay_two_events
328
+ TmpFile.with_dir do |dir|
329
+ worker = make_job(dir, 'LG/ask/Default_1',
330
+ logs: {'agent.chat' => "user: w\nmeta: pt=8 tt=9\nassistant: done\n"})
331
+ mixed = write_chat(dir, 'mixed.chat',
332
+ receipt_chat_text(
333
+ {'r1' => [meta_receipt('pt=8 tt=9')]},
334
+ extra: ["meta: job=#{worker}"]
335
+ ))
336
+
337
+ events = Chat.provenance_token_events(mixed)
338
+
339
+ assert_equal 2, events.length
340
+ assert_equal 1, events.count { |event| event[:deduplication] == :legacy_lineage }
341
+ assert_equal 1, events.count { |event| event[:deduplication] == :receipt_unresolved }
342
+ # Documented overcount for legacy data.
343
+ assert_equal({pt: 16, ct: 0, tt: 18, cct: 0, cwt: 0, rt: 0},
344
+ Chat.provenance_token_totals(mixed))
345
+ end
346
+ end
347
+
348
+ def test_chat_without_agent_meta_keeps_token_totals_semantics
349
+ TmpFile.with_dir do |dir|
350
+ path = write_chat(dir, 'plain.chat',
351
+ "user: hi\nmeta: pt=1 tt=2\nmeta: pt=3 tt=4\nassistant: done\n")
352
+
353
+ assert_equal Chat.token_totals([Chat.load(path)]),
354
+ Chat.provenance_token_totals(path)
355
+ assert_equal Chat.token_totals([Chat.load(path)]), Chat.tokens(path)
356
+ end
357
+ end
358
+
359
+ ## Fixture H - truncated parent output
360
+
361
+ def test_truncated_output_keeps_receipt_events
362
+ TmpFile.with_dir do |dir|
363
+ parent = write_chat(dir, 'parent.chat',
364
+ truncated_receipt_chat(
365
+ 'a1', [meta_receipt('pt=200 ct=100 tt=300 inference_id=t1')]
366
+ ))
367
+
368
+ warnings = []
369
+ events = Chat.provenance_token_events(parent, warnings: warnings)
370
+
371
+ assert_equal ['t1'], events.collect { |event| event[:inference_id] }
372
+ assert_equal({pt: 200, ct: 100, tt: 300, cct: 0, cwt: 0, rt: 0},
373
+ Chat.provenance_token_totals(parent))
374
+
375
+ # Truncation is a transport fact, not a token problem.
376
+ assert_empty warnings
377
+ status = Chat.tool_call_status(Chat.tool_calls(Chat.load(parent)).first)
378
+ assert_equal false, status[:success]
379
+ assert_match(/90,?000 characters/, status[:exception])
380
+
381
+ totals = Chat.provenance_token_totals(parent)
382
+ assert_equal 0, totals.values.select { |v| v > 300 }.length
383
+ end
384
+ end
385
+
386
+ ## Canonical rule
387
+
388
+ def test_canonical_evidence_prefers_chat_side_then_discovery_order
389
+ TmpFile.with_dir do |dir|
390
+ worker = make_job(dir, 'W/ask/Default_w',
391
+ logs: {'agent.chat' => "user: w\nmeta: pt=11 ct=7 tt=18 inference_id=k1\nassistant: done\n"})
392
+ parent = write_chat(dir, 'parent.chat',
393
+ receipt_chat_text(
394
+ {'a1' => [meta_receipt('pt=22 ct=14 tt=36 inference_id=k1')]},
395
+ extra: ["meta: job=#{worker}"]
396
+ ))
397
+
398
+ event = Chat.provenance_token_events(parent).first
399
+
400
+ # Chat side wins over the receipt.
401
+ assert_equal :chat_meta, event[:evidence].first[:origin]
402
+ assert_equal 11, event[:meta][:pt].to_i
403
+ assert_equal({pt: 11, ct: 7, tt: 18, cct: 0, cwt: 0, rt: 0}, event[:tokens])
404
+
405
+ # Within one origin, discovery order decides (first receipt of two).
406
+ other = write_chat(dir, 'two.chat',
407
+ receipt_chat_text(
408
+ {'a1' => [meta_receipt('pt=1 tt=2 inference_id=d1'),
409
+ meta_receipt('pt=1 tt=2 inference_id=d1')]}
410
+ ))
411
+ assert_equal 1, Chat.provenance_token_events(other).length
412
+ assert_equal 1, Chat.provenance_token_totals(other)[:pt]
413
+ end
414
+ end
415
+
416
+ ## Traversal-stage receipt problems vs the warnings Array
417
+
418
+ def test_traversal_stage_receipt_problems_go_to_the_warnings_array
419
+ TmpFile.with_dir do |dir|
420
+ missing = File.join(dir, 'Missing/ask/Default_zzz')
421
+ chat = write_chat(dir, 'bad.chat',
422
+ receipt_chat_text(
423
+ {'b1' => 'not-an-array',
424
+ 'b2' => [meta_receipt("job=#{missing}")]},
425
+ extra: ['meta: pt=5 tt=6 inference_id=ok1']
426
+ ))
427
+
428
+ warnings = []
429
+ events = Chat.provenance_token_events(chat, warnings: warnings)
430
+
431
+ # The good direct event still comes through; nothing raised.
432
+ assert_equal ['ok1'], events.collect { |event| event[:inference_id] }
433
+
434
+ # Totals use their own buffer (each API call reports its own warnings).
435
+ totals_warnings = []
436
+ assert_equal({pt: 5, ct: 0, tt: 6, cct: 0, cwt: 0, rt: 0},
437
+ Chat.provenance_token_totals(chat, warnings: totals_warnings))
438
+ assert_equal warnings.collect { |w| w[:reason] }, totals_warnings.collect { |w| w[:reason] }
439
+
440
+ # One warning per problem, in the agent_meta_error_reference shape plus
441
+ # the error message; the same problem is not reported twice.
442
+ reasons = warnings.collect { |warning| warning[:reason] }
443
+ assert_equal %i[not_an_array unresolved_job_reference], reasons
444
+ not_an_array = warnings.first
445
+ assert_equal chat, not_an_array[:source]
446
+ assert_equal [chat, 2], not_an_array[:output_address]
447
+ assert_equal 'b1', not_an_array[:call_id]
448
+ assert_equal 'ask', not_an_array[:tool_name]
449
+ assert_nil not_an_array[:evidence_address]
450
+ assert_match(/not_an_array/, not_an_array[:message])
451
+
452
+ unresolved = warnings.last
453
+ assert_equal missing, unresolved[:reference]
454
+ assert_equal [chat, 4, :agent_meta, 0], unresolved[:evidence_address]
455
+ assert_equal 'b2', unresolved[:call_id]
456
+ assert_match(/unresolved_job_reference/, unresolved[:message])
457
+ end
458
+ end
459
+
460
+ def test_traversal_stage_receipt_problems_raise_without_warnings_array
461
+ TmpFile.with_dir do |dir|
462
+ chat = write_chat(dir, 'bad.chat',
463
+ receipt_chat_text({'b1' => 'not-an-array'},
464
+ extra: ['meta: pt=5 tt=6 inference_id=ok1']))
465
+
466
+ error = assert_raise(ScoutException) do
467
+ Chat.provenance_token_events(chat)
468
+ end
469
+ assert_match(/not_an_array/, error.message)
470
+ assert_match(/#{Regexp.escape(chat)}/, error.message)
471
+ end
472
+ end
473
+
474
+ ## Reviewer cases: multi-location evidence, shared-job receipts, strict mode
475
+
476
+ # The same inference_id persisted in three ordinary chat/log locations plus
477
+ # one receipt must produce ONE event whose evidence array keeps all four
478
+ # addresses, counted once.
479
+ def test_same_identity_in_three_chat_locations_and_one_receipt
480
+ TmpFile.with_dir do |dir|
481
+ shared = 'meta: pt=100 ct=40 tt=140 inference_id=shared1'
482
+ j1 = make_job(dir, 'J1/ask/Default_1', logs: {'agent.chat' => "user: w\n#{shared}\nassistant: done\n"})
483
+ j2 = make_job(dir, 'J2/ask/Default_2', logs: {'agent.chat' => "user: w\n#{shared}\nassistant: done\n"})
484
+ parent = write_chat(dir, 'parent.chat',
485
+ receipt_chat_text(
486
+ {'a1' => [meta_receipt('pt=100 ct=40 tt=140 inference_id=shared1')]},
487
+ extra: [shared, "meta: job=#{j1}", "meta: job=#{j2}"]
488
+ ))
489
+
490
+ events = Chat.provenance_token_events(parent).select { |event| event[:inference_id] == 'shared1' }
491
+
492
+ assert_equal 1, events.length
493
+ event = events.first
494
+ assert_equal 4, event[:evidence].length
495
+
496
+ chat_side = event[:evidence].select { |record| record[:origin] == :chat_meta }
497
+ assert_equal 3, chat_side.length
498
+ expected_sources = [parent,
499
+ File.join(j1 + '.files', 'log', 'agent.chat'),
500
+ File.join(j2 + '.files', 'log', 'agent.chat')].collect(&:to_s).sort
501
+ assert_equal expected_sources, chat_side.collect { |record| record[:source].to_s }.sort
502
+
503
+ receipt_side = event[:evidence].select { |record| record[:origin] == :agent_meta }
504
+ assert_equal 1, receipt_side.length
505
+ assert_equal 'a1', receipt_side.first[:call_id]
506
+ assert !event[:conflict]
507
+
508
+ # Counted exactly once.
509
+ assert_equal({pt: 100, ct: 40, tt: 140, cct: 0, cwt: 0, rt: 0},
510
+ Chat.provenance_token_totals(parent))
511
+ end
512
+ end
513
+
514
+ # Two separate ask receipts pointing at the same job= keep both edge details
515
+ # (distinct call ids and receipt addresses) while the Step is visited once.
516
+ def test_two_receipts_to_the_same_job_keep_both_edge_details
517
+ TmpFile.with_dir do |dir|
518
+ worker = make_job(dir, 'Worker/ask/Default_w',
519
+ logs: {'agent.chat' => "user: w\nmeta: pt=1 tt=2 inference_id=wonly\nassistant: done\n"})
520
+ parent = write_chat(dir, 'parent.chat',
521
+ receipt_chat_text(
522
+ {'a1' => [meta_receipt("job=#{worker}")],
523
+ 'a2' => [meta_receipt("job=#{worker}")]}
524
+ ))
525
+
526
+ edges = Chat.provenance_edges(parent).select { |edge| edge[:relation] == :agent_job }
527
+ assert_equal 2, edges.length
528
+ details = edges.collect { |edge| edge[:detail] }
529
+ assert_equal %w[a1 a2], details.collect { |detail| detail[:call_id] }.sort
530
+ assert_equal 2, details.collect { |detail| detail[:evidence_address] }.uniq.length
531
+ assert(edges.all? { |edge| edge[:to].path.to_s == worker.to_s })
532
+ assert(edges.all? { |edge| edge[:from].to_s == parent.to_s })
533
+
534
+ # One unique Step node; each edge visits it, only the first expands it.
535
+ assert_equal 1, Chat.provenance_jobs(parent).length
536
+ job_visits = Chat.traverse_provenance(parent).to_a
537
+ .select { |kind, object, *_rest| kind == :job && object.path.to_s == worker.to_s }
538
+ assert_equal 2, job_visits.length
539
+ assert_equal 1, job_visits.count { |visit| visit[5] }
540
+ end
541
+ end
542
+
543
+ def test_strict_mode_raises_on_identity_conflict
544
+ TmpFile.with_dir do |dir|
545
+ parent = write_chat(dir, 'p5.chat',
546
+ receipt_chat_text(
547
+ {'a1' => [meta_receipt('pt=1 tt=2 inference_id=g1'),
548
+ meta_receipt('pt=1 tt=9 inference_id=g1')]}
549
+ ))
550
+
551
+ assert_raise(ScoutException) do
552
+ Chat.provenance_token_events(parent, strict: true)
553
+ end
554
+ end
555
+ end
556
+
557
+ # A provider_response_id present in one copy and absent in another is
558
+ # incomplete evidence (migration), never a conflict.
559
+ def test_missing_provider_response_id_is_incomplete_not_conflict
560
+ TmpFile.with_dir do |dir|
561
+ worker = make_job(dir, 'W4/ask/Default_4',
562
+ logs: {'agent.chat' => "user: w\nmeta: pt=3 tt=4 inference_id=h1 provider_response_id=rq\nassistant: done\n"})
563
+ parent = write_chat(dir, 'p6.chat',
564
+ receipt_chat_text(
565
+ {'a1' => [meta_receipt('pt=3 tt=4 inference_id=h1')]},
566
+ extra: ["meta: job=#{worker}"]
567
+ ))
568
+
569
+ warnings = []
570
+ events = Chat.provenance_token_events(parent, warnings: warnings)
571
+
572
+ event = events.find { |item| item[:inference_id] == 'h1' }
573
+ assert event
574
+ assert_equal 2, event[:evidence].length
575
+ assert event[:incomplete_evidence]
576
+ assert !event[:conflict]
577
+ assert_empty warnings.select { |warning| warning[:reason] == :identity_conflict }
578
+ assert_equal({pt: 3, ct: 0, tt: 4, cct: 0, cwt: 0, rt: 0},
579
+ Chat.provenance_token_totals(parent))
580
+ end
581
+ end
582
+
583
+ ## Scope validation
584
+
585
+ def test_unknown_scope_raises
586
+ TmpFile.with_dir do |dir|
587
+ path = write_chat(dir, 'any.chat', "user: hi\nassistant: done\n")
588
+
589
+ assert_raise(ParameterException) do
590
+ Chat.provenance_token_totals(path, scope: :bogus)
591
+ end
592
+ end
593
+ end
594
+ end