scout-ai 1.2.5 → 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 (164) hide show
  1. checksums.yaml +4 -4
  2. data/.vimproject +111 -31
  3. data/README.md +171 -320
  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 +62 -12
  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 +36 -6
  33. data/lib/scout/llm/backends/anthropic.rb +9 -1
  34. data/lib/scout/llm/backends/bedrock.rb +15 -3
  35. data/lib/scout/llm/backends/default.rb +129 -89
  36. data/lib/scout/llm/backends/glm.rb +58 -0
  37. data/lib/scout/llm/backends/huggingface.rb +13 -1
  38. data/lib/scout/llm/backends/ollama.rb +13 -0
  39. data/lib/scout/llm/backends/openwebui.rb +8 -3
  40. data/lib/scout/llm/chat/agent_meta.rb +264 -0
  41. data/lib/scout/llm/chat/annotation.rb +37 -14
  42. data/lib/scout/llm/chat/parse.rb +28 -6
  43. data/lib/scout/llm/chat/persist.rb +25 -0
  44. data/lib/scout/llm/chat/process/clear.rb +19 -1
  45. data/lib/scout/llm/chat/process/files.rb +16 -1
  46. data/lib/scout/llm/chat/process/meta.rb +422 -32
  47. data/lib/scout/llm/chat/process/options.rb +21 -1
  48. data/lib/scout/llm/chat/process/tools.rb +49 -12
  49. data/lib/scout/llm/chat/process.rb +4 -0
  50. data/lib/scout/llm/chat/prompt/shorten_tools.rb +125 -0
  51. data/lib/scout/llm/chat/prompt/shorten_tools_epoch.rb +365 -0
  52. data/lib/scout/llm/chat/prompt.rb +48 -0
  53. data/lib/scout/llm/chat/provenance.rb +775 -0
  54. data/lib/scout/llm/chat/tool_calls.rb +76 -0
  55. data/lib/scout/llm/chat.rb +18 -2
  56. data/lib/scout/llm/embed.rb +8 -3
  57. data/lib/scout/llm/image.rb +86 -0
  58. data/lib/scout/llm/rag.rb +3 -3
  59. data/lib/scout/llm/tools/call.rb +159 -10
  60. data/lib/scout/llm/tools/knowledge_base.rb +1 -1
  61. data/lib/scout/llm/tools/workflow.rb +13 -5
  62. data/lib/scout-ai.rb +1 -0
  63. data/research/ChatAnalyst-required-changes.md +167 -0
  64. data/research/agent-delegation-analysis.md +810 -0
  65. data/research/agent-meta-provenance-integration-plan.md +622 -0
  66. data/research/agent-workflow-analysis.md +1120 -0
  67. data/research/backends-analysis.md +836 -0
  68. data/research/chat-core-analysis.md +946 -0
  69. data/research/chatanalyst-provenance/00-baseline.md +30 -0
  70. data/research/chatanalyst-provenance/01-repo-map.md +60 -0
  71. data/research/chatanalyst-provenance/02-event-reconstruction.md +55 -0
  72. data/research/chatanalyst-provenance/03-duplication-evidence.md +45 -0
  73. data/research/chatanalyst-provenance/04-tooling-root-cause.md +57 -0
  74. data/research/chatanalyst-provenance/05-fix-plan.md +46 -0
  75. data/research/chatanalyst-provenance/07-critic-review.md +25 -0
  76. data/research/chatanalyst-provenance/final-report.md +45 -0
  77. data/research/chatanalyst-provenance/resumption.md +37 -0
  78. data/research/coding-philosophy-analysis.md +928 -0
  79. data/research/commands-analysis.md +947 -0
  80. data/research/multi-agent-patterns-analysis.md +853 -0
  81. data/research/prompt-strategies-analysis.md +630 -0
  82. data/research/prov-verbosity-fix-notes.md +77 -0
  83. data/research/provenance-analysis.md +469 -0
  84. data/research/provenance-navigation-design.md +640 -0
  85. data/research/synthesis-report.md +487 -0
  86. data/research/tools-system-analysis.md +779 -0
  87. data/scout-ai.gemspec +97 -13
  88. data/scout_commands/agent/ask +13 -3
  89. data/scout_commands/agent/kb +2 -0
  90. data/scout_commands/llm/ask +11 -4
  91. data/scout_commands/llm/md +76 -0
  92. data/scout_commands/llm/prov +602 -0
  93. data/scout_commands/llm/word +71 -0
  94. data/share/word/reference.docx +0 -0
  95. data/test/etc/AI/mock.yaml +11 -0
  96. data/test/fixtures/backends/anthropic.json +19 -0
  97. data/test/fixtures/backends/anthropic_tool_use.json +24 -0
  98. data/test/fixtures/backends/bedrock.json +8 -0
  99. data/test/fixtures/backends/bedrock_embedding.json +3 -0
  100. data/test/fixtures/backends/bedrock_tool_use.json +17 -0
  101. data/test/fixtures/backends/ollama.json +16 -0
  102. data/test/fixtures/backends/ollama_tool_call.json +27 -0
  103. data/test/fixtures/backends/openai_chat.json +21 -0
  104. data/test/fixtures/backends/openai_chat_tool_call.json +31 -0
  105. data/test/fixtures/backends/responses.json +33 -0
  106. data/test/fixtures/backends/responses_tool_call.json +28 -0
  107. data/test/integration/README.md +32 -0
  108. data/test/integration/scout/llm/backends/test_endpoints.rb +34 -0
  109. data/test/integration/scout/llm/backends/test_openwebui.rb +61 -0
  110. data/test/integration/scout/llm/backends/test_relay.rb +52 -0
  111. data/test/integration/scout/llm/test_infrastructure.rb +74 -0
  112. data/test/{scout → integration/scout}/llm/test_mcp.rb +1 -1
  113. data/test/integration/scout/llm/tools/test_mcp.rb +42 -0
  114. data/test/integration/scout/model/test_base.rb +91 -0
  115. data/test/scout/llm/agent/test_chat.rb +8 -2
  116. data/test/scout/llm/agent/test_save.rb +413 -0
  117. data/test/scout/llm/agent/test_workflow.rb +110 -0
  118. data/test/scout/llm/backends/test_anthropic.rb +93 -10
  119. data/test/scout/llm/backends/test_bedrock.rb +118 -2
  120. data/test/scout/llm/backends/test_ollama.rb +70 -20
  121. data/test/scout/llm/backends/test_openwebui.rb +42 -40
  122. data/test/scout/llm/backends/test_relay.rb +4 -2
  123. data/test/scout/llm/chat/agent_meta_fixtures.rb +131 -0
  124. data/test/scout/llm/chat/process/test_meta.rb +518 -0
  125. data/test/scout/llm/chat/process/test_normalize_usage.rb +183 -0
  126. data/test/scout/llm/chat/test_agent_meta.rb +357 -0
  127. data/test/scout/llm/chat/test_agent_meta_provenance.rb +467 -0
  128. data/test/scout/llm/chat/test_agent_meta_tokens.rb +594 -0
  129. data/test/scout/llm/chat/test_parse.rb +70 -15
  130. data/test/scout/llm/chat/test_prov_cli.rb +274 -0
  131. data/test/scout/llm/chat/test_provenance.rb +240 -0
  132. data/test/scout/llm/chat/test_tool_calls.rb +38 -0
  133. data/test/scout/llm/test_agent.rb +13 -36
  134. data/test/scout/llm/test_ask.rb +75 -52
  135. data/test/scout/llm/test_chat.rb +107 -13
  136. data/test/scout/llm/test_embed.rb +48 -0
  137. data/test/scout/llm/test_rag.rb +23 -16
  138. data/test/scout/llm/test_tools.rb +12 -1
  139. data/test/scout/llm/tools/test_knowledge_base.rb +0 -1
  140. data/test/scout/llm/tools/test_mcp.rb +5 -3
  141. data/test/scout/llm/tools/test_workflow.rb +23 -2
  142. data/test/scout/model/python/huggingface/causal/test_next_token.rb +11 -5
  143. data/test/scout/model/python/huggingface/test_causal.rb +9 -3
  144. data/test/scout/model/python/huggingface/test_classification.rb +11 -2
  145. data/test/scout/model/python/test_torch.rb +2 -0
  146. data/test/scout/model/python/torch/test_helpers.rb +4 -0
  147. data/test/scout/model/test_base.rb +4 -2
  148. data/test/support/availability.rb +231 -0
  149. data/test/support/fake_clients.rb +138 -0
  150. data/test/support/fixtures.rb +21 -0
  151. data/test/support/infrastructure_probes.rb +136 -0
  152. data/test/support/mock_backend.rb +215 -0
  153. data/test/test_helper.rb +32 -2
  154. metadata +96 -12
  155. data/doc/Agent.md +0 -354
  156. data/doc/Chat.md +0 -481
  157. data/doc/LLM.md +0 -356
  158. data/doc/PythonAgentTasks.md +0 -333
  159. data/doc/RAG.md +0 -129
  160. data/doc/USER_GUIDE.md +0 -572
  161. data/scout_commands/documenter +0 -148
  162. data/test/scout/llm/backends/test_openai.rb +0 -192
  163. data/test/scout/llm/backends/test_responses.rb +0 -238
  164. data/test/scout/llm/test_parse.rb +0 -98
@@ -0,0 +1,357 @@
1
+ require File.expand_path(__FILE__).sub(%r(/test/.*), '/test/test_helper.rb')
2
+ require 'scout/llm/chat'
3
+
4
+ class TestChatAgentMeta < Test::Unit::TestCase
5
+ def chat(text)
6
+ Chat.setup(LLM.messages(text))
7
+ end
8
+
9
+ # Build persisted-style chat text with one paired tool call whose output
10
+ # envelope carries a receipt. Legacy format uses the `agent_meta` key with
11
+ # serialized meta messages; the current format uses the `meta` key with an
12
+ # array of already-deserialized field Hashes (as LLM.process_calls now
13
+ # writes for delegated agents).
14
+ def receipt_text(agent_meta: nil, meta: nil, name: 'ask', call_id: 'call-1', content: 'child answer')
15
+ envelope = {name: name, content: content, id: call_id}
16
+ envelope[:agent_meta] = agent_meta unless agent_meta.nil?
17
+ envelope[:meta] = meta unless meta.nil?
18
+ <<-EOF
19
+ user: Run the worker
20
+ function_call: {"name":"#{name}","arguments":{"agent":"Worker"},"id":"#{call_id}"}
21
+ function_call_output: #{envelope.to_json}
22
+ EOF
23
+ end
24
+
25
+ # Current-format equivalent of `valid_receipt`
26
+ def valid_meta_receipt
27
+ receipt_text(meta: [
28
+ {'pt' => 100, 'ct' => 50, 'tt' => 150, 'inference_id' => 'aaa'},
29
+ {'job' => 'Worker/ask/Default_x'}
30
+ ])
31
+ end
32
+
33
+ def valid_receipt
34
+ receipt_text(agent_meta: [
35
+ {role: 'meta', content: 'pt=100 ct=50 tt=150 inference_id=aaa'},
36
+ {role: 'meta', content: 'job=Worker/ask/Default_x'}
37
+ ])
38
+ end
39
+
40
+ def test_extracts_valid_receipt_entries
41
+ evidence = Chat.agent_meta_evidence(chat(valid_receipt))
42
+ assert_equal 2, evidence.length
43
+
44
+ direct, projection = evidence
45
+
46
+ assert_equal :agent_meta, direct[:origin]
47
+ assert_equal 100, direct[:meta][:pt]
48
+ assert_equal 50, direct[:meta][:ct]
49
+ assert_equal 150, direct[:meta][:tt]
50
+ assert_equal 'aaa', direct[:meta][:inference_id]
51
+ assert_equal 'call-1', direct[:call_id]
52
+ assert_equal 'ask', direct[:tool_name]
53
+ assert_equal 0, direct[:agent_meta_index]
54
+ assert_equal 2, direct[:output_address]
55
+ assert_equal [2, :agent_meta, 0], direct[:evidence_address]
56
+ assert_nil direct[:source]
57
+ assert_equal({role: 'meta', content: 'pt=100 ct=50 tt=150 inference_id=aaa'}, direct[:raw_message])
58
+
59
+ assert_equal 1, projection[:agent_meta_index]
60
+ assert_equal [2, :agent_meta, 1], projection[:evidence_address]
61
+ assert_equal 'Worker/ask/Default_x', projection[:meta][:job]
62
+ end
63
+
64
+ def test_extracts_receipt_entries_from_persisted_file_with_source
65
+ TmpFile.with_dir do |dir|
66
+ path = File.join(dir, 'parent.chat')
67
+ Open.write(path, valid_receipt)
68
+ conversation = Chat.load(path)
69
+
70
+ evidence = Chat.agent_meta_evidence(conversation, source: path)
71
+ assert_equal 2, evidence.length
72
+
73
+ direct = evidence.first
74
+ assert_equal path, direct[:source]
75
+ assert_equal [path, 2], direct[:output_address]
76
+ assert_equal [path, 2, :agent_meta, 0], direct[:evidence_address]
77
+ assert_equal 'call-1', direct[:call_id]
78
+ assert_equal 'ask', direct[:tool_name]
79
+
80
+ assert_equal [path, 2, :agent_meta, 1], evidence.last[:evidence_address]
81
+ end
82
+ end
83
+
84
+ def test_absent_agent_meta_returns_empty_evidence
85
+ plain = receipt_text
86
+ assert_equal [], Chat.agent_meta_evidence(chat(plain))
87
+
88
+ without_calls = chat(<<-EOF)
89
+ user: Hello
90
+ assistant: Hi
91
+ EOF
92
+ assert_equal [], Chat.agent_meta_evidence(without_calls)
93
+ end
94
+
95
+ def test_agent_meta_not_an_array_is_skipped_with_warning
96
+ warnings = []
97
+ evidence = Chat.agent_meta_evidence(chat(receipt_text(agent_meta: 'oops')), warnings: warnings)
98
+ assert_equal [], evidence
99
+ assert_equal 1, warnings.length
100
+
101
+ warning = warnings.first
102
+ assert_equal :agent_meta, warning[:origin]
103
+ assert_equal :not_an_array, warning[:reason]
104
+ assert_equal 2, warning[:output_address]
105
+ assert_equal 'call-1', warning[:call_id]
106
+ assert_equal 'ask', warning[:tool_name]
107
+ assert_nil warning[:agent_meta_index]
108
+ assert_equal 'oops', warning[:raw_entry]
109
+ end
110
+
111
+ def test_non_hash_entry_is_skipped_with_warning
112
+ warnings = []
113
+ evidence = Chat.agent_meta_evidence(chat(receipt_text(agent_meta: ['nonsense'])), warnings: warnings)
114
+ assert_equal [], evidence
115
+ assert_equal 1, warnings.length
116
+ assert_equal :not_a_hash, warnings.first[:reason]
117
+ assert_equal 0, warnings.first[:agent_meta_index]
118
+ assert_equal 'nonsense', warnings.first[:raw_entry]
119
+ end
120
+
121
+ def test_entry_with_wrong_role_is_skipped_with_warning
122
+ warnings = []
123
+ agent_meta = [
124
+ {role: 'assistant', content: 'not a meta record'},
125
+ {role: 'meta', content: 'pt=1 tt=1 inference_id=keep'}
126
+ ]
127
+ evidence = Chat.agent_meta_evidence(chat(receipt_text(agent_meta: agent_meta)), warnings: warnings)
128
+ assert_equal 1, evidence.length
129
+ assert_equal 1, warnings.length
130
+ assert_equal :invalid_role, warnings.first[:reason]
131
+ assert_equal 0, warnings.first[:agent_meta_index]
132
+ assert_equal({ 'role' => 'assistant', 'content' => 'not a meta record' }, warnings.first[:raw_entry])
133
+ assert_equal [2, :agent_meta, 1], evidence.first[:evidence_address]
134
+ end
135
+
136
+ def test_entry_without_string_content_is_skipped_with_warning
137
+ warnings = []
138
+ agent_meta = [
139
+ {role: 'meta'},
140
+ {role: 'meta', content: 42},
141
+ {role: 'meta', content: 'pt=1 tt=1 inference_id=keep'}
142
+ ]
143
+ evidence = Chat.agent_meta_evidence(chat(receipt_text(agent_meta: agent_meta)), warnings: warnings)
144
+ assert_equal 1, evidence.length
145
+ assert_equal 2, warnings.length
146
+ assert_equal %i[invalid_content invalid_content], warnings.collect { |w| w[:reason] }
147
+ assert_equal 0, warnings.first[:agent_meta_index]
148
+ assert_equal 1, warnings.last[:agent_meta_index]
149
+ end
150
+
151
+ def test_unparseable_content_is_skipped_with_warning
152
+ warnings = []
153
+ evidence = Chat.agent_meta_evidence(chat(receipt_text(agent_meta: [{role: 'meta', content: 'no key value pairs here'}])), warnings: warnings)
154
+ assert_equal [], evidence
155
+ assert_equal 1, warnings.length
156
+ assert_equal :unparseable_meta, warnings.first[:reason]
157
+ assert_equal 0, warnings.first[:agent_meta_index]
158
+ end
159
+
160
+ def test_malformed_entries_are_skipped_without_warnings_kwarg
161
+ evidence = Chat.agent_meta_evidence(chat(receipt_text(agent_meta: [{role: 'assistant', content: 'x'}])))
162
+ assert_equal [], evidence
163
+ end
164
+
165
+ def test_meta_evidence_combines_both_origins
166
+ text = valid_receipt + "\nmeta: pt=10 tt=12 inference_id=local1\nassistant: done\n"
167
+ conversation = chat(text)
168
+
169
+ evidence = Chat.meta_evidence(conversation)
170
+ assert_equal 3, evidence.length
171
+
172
+ origins = evidence.collect { |record| record[:origin] }
173
+ assert_equal %i[chat_meta agent_meta agent_meta], origins
174
+
175
+ local = evidence.first
176
+ assert_equal 10, local[:meta][:pt]
177
+ assert_equal 12, local[:meta][:tt]
178
+ assert_equal 3, local[:meta_address]
179
+ assert_nil local[:source]
180
+ assert_equal 'meta', local[:message][:role].to_s
181
+ assert_equal 'pt=10 tt=12 inference_id=local1', local[:message][:content]
182
+
183
+ assert evidence.last(2).all? { |record| record[:origin] == :agent_meta }
184
+ end
185
+
186
+ def test_meta_evidence_preserves_source_addresses
187
+ TmpFile.with_dir do |dir|
188
+ path = File.join(dir, 'parent.chat')
189
+ Open.write(path, valid_receipt + "\nmeta: pt=10 tt=12 inference_id=local1\nassistant: done\n")
190
+ conversation = Chat.load(path)
191
+
192
+ evidence = Chat.meta_evidence(conversation, source: path)
193
+ assert_equal [path, 3], evidence.first[:meta_address]
194
+ assert_equal [path, 2, :agent_meta, 0], evidence[1][:evidence_address]
195
+ assert_equal [path, 2, :agent_meta, 1], evidence[2][:evidence_address]
196
+ assert evidence.all? { |record| record[:source] == path }
197
+ end
198
+ end
199
+
200
+ def test_extraction_does_not_change_chat_contents
201
+ conversation = chat(valid_receipt + "\nmeta: pt=10 tt=12 inference_id=local1\nassistant: done\n")
202
+ before = conversation.collect { |message| [message[:role].to_s, message[:content].to_s] }
203
+
204
+ Chat.agent_meta_evidence(conversation)
205
+ Chat.meta_evidence(conversation)
206
+ Chat.agent_meta_job_references(conversation)
207
+
208
+ after = conversation.collect { |message| [message[:role].to_s, message[:content].to_s] }
209
+ assert_equal before, after
210
+ assert_equal 5, conversation.length
211
+
212
+ roles = conversation.collect { |message| message[:role].to_s }
213
+ assert_equal %w[user function_call function_call_output meta assistant], roles
214
+ end
215
+
216
+ def test_agent_meta_job_references_returns_only_job_records
217
+ conversation = chat(valid_receipt)
218
+ references = Chat.agent_meta_job_references(conversation)
219
+
220
+ assert_equal 1, references.length
221
+ reference = references.first
222
+ assert_equal 'Worker/ask/Default_x', reference[:job]
223
+ assert_equal 'Worker/ask/Default_x', reference[:meta][:job]
224
+ assert_equal :agent_meta, reference[:origin]
225
+ assert_equal 'call-1', reference[:call_id]
226
+ assert_equal 'ask', reference[:tool_name]
227
+ assert_equal 1, reference[:agent_meta_index]
228
+ assert_equal [2, :agent_meta, 1], reference[:evidence_address]
229
+
230
+ TmpFile.with_dir do |dir|
231
+ path = File.join(dir, 'parent.chat')
232
+ Open.write(path, valid_receipt)
233
+ with_source = Chat.agent_meta_job_references(Chat.load(path), source: path)
234
+ assert_equal 1, with_source.length
235
+ assert_equal 'Worker/ask/Default_x', with_source.first[:job]
236
+ assert_equal [path, 2, :agent_meta, 1], with_source.first[:evidence_address]
237
+ end
238
+ end
239
+
240
+ def test_agent_meta_job_references_empty_without_receipts
241
+ assert_equal [], Chat.agent_meta_job_references(chat(receipt_text))
242
+ assert_equal [], Chat.agent_meta_job_references(chat("user: Hello\nassistant: Hi\n"))
243
+ end
244
+
245
+ def test_chat_without_agent_meta_shows_no_regression
246
+ conversation = chat(<<-EOF)
247
+ user: Hello
248
+ meta: pt=10 ct=5 tt=15 inference_id=solo
249
+ assistant: Hi
250
+ EOF
251
+
252
+ assert_equal [], Chat.agent_meta_evidence(conversation)
253
+
254
+ evidence = Chat.meta_evidence(conversation)
255
+ assert_equal 1, evidence.length
256
+ assert_equal :chat_meta, evidence.first[:origin]
257
+
258
+ assert_equal({pt: 10, ct: 5, tt: 15, cct: 0, cwt: 0, rt: 0}, Chat.token_totals([conversation]))
259
+ assert_equal 1, conversation.role_messages(:meta).length
260
+ end
261
+
262
+ def test_agent_meta_is_not_limited_to_ask_tool
263
+ agent_meta = [{role: 'meta', content: 'pt=7 tt=7 inference_id=other'}]
264
+ evidence = Chat.agent_meta_evidence(chat(receipt_text(agent_meta: agent_meta, name: 'chat_task', call_id: 'call-9')))
265
+ assert_equal 1, evidence.length
266
+ assert_equal 'chat_task', evidence.first[:tool_name]
267
+ assert_equal 'call-9', evidence.first[:call_id]
268
+ end
269
+
270
+ ## Current-format (`meta` key) receipts ##
271
+
272
+ def test_deserialized_meta_receipt_produces_evidence
273
+ evidence = Chat.agent_meta_evidence(chat(valid_meta_receipt))
274
+ assert_equal 2, evidence.length
275
+
276
+ first = evidence.first
277
+ assert_equal :agent_meta, first[:origin]
278
+ assert_equal({'pt' => 100, 'ct' => 50, 'tt' => 150, 'inference_id' => 'aaa'}, first[:meta].to_hash)
279
+ # Already deserialized: no re-parse of any role/content wrapper happened
280
+ assert_nil first[:raw_message]
281
+ # Address suffix mirrors the persisted key
282
+ assert_equal [first[:output_address], :meta, 0], first[:evidence_address]
283
+ assert_equal 0, first[:agent_meta_index]
284
+
285
+ assert_equal 'Worker/ask/Default_x', evidence.last[:meta][:job]
286
+ assert_equal [evidence.last[:output_address], :meta, 1], evidence.last[:evidence_address]
287
+ end
288
+
289
+ def test_deserialized_meta_receipt_job_references
290
+ references = Chat.agent_meta_job_references(chat(valid_meta_receipt))
291
+ assert_equal 1, references.length
292
+ assert_equal 'Worker/ask/Default_x', references.first[:job]
293
+ assert_equal :agent_meta, references.first[:origin]
294
+ end
295
+
296
+ def test_mixed_keys_meta_wins
297
+ text = receipt_text(meta: [{'tt' => 1}],
298
+ agent_meta: [{role: 'meta', content: 'tt=2'}])
299
+ evidence = Chat.agent_meta_evidence(chat(text))
300
+ assert_equal 1, evidence.length
301
+ assert_equal 1, evidence.first[:meta][:tt]
302
+ assert_equal :meta, evidence.first[:evidence_address][1]
303
+ end
304
+
305
+ def test_malformed_deserialized_meta_entries_warn_not_raise
306
+ warnings = []
307
+ text = receipt_text(meta: [{'pt' => 5}, 'nonsense', {}, nil])
308
+ evidence = Chat.agent_meta_evidence(chat(text), warnings: warnings)
309
+
310
+ assert_equal 1, evidence.length
311
+ assert_equal 5, evidence.first[:meta][:pt]
312
+
313
+ reasons = warnings.collect { |warning| [warning[:reason], warning[:agent_meta_index]] }
314
+ assert_include reasons, [:not_a_hash, 1]
315
+ assert_include reasons, [:empty_meta, 2]
316
+ assert_include reasons, [:not_a_hash, 3]
317
+ warnings.each do |warning|
318
+ assert_equal :agent_meta, warning[:origin]
319
+ assert_equal 'call-1', warning[:call_id]
320
+ assert_equal :meta, warning[:evidence_address][1] if warning[:evidence_address]
321
+ end
322
+ end
323
+
324
+ # End-to-end through the collector: a current-format receipt in a chat FILE
325
+ # feeds token events (origin :agent_meta) and job relations. The job=
326
+ # target is materialized on disk so the reference resolves.
327
+ def test_deserialized_receipt_feeds_token_events_and_problems
328
+ TmpFile.with_dir do |dir|
329
+ # Same layout as AgentMetaFixtures#make_job: the worker job file plus
330
+ # its .info sidecar exist on disk, so the receipt job= reference
331
+ # resolves through Chat.load_job_reference.
332
+ worker = File.join(dir, 'Worker', 'ask', 'Default_w')
333
+ Open.mkdir File.dirname(worker)
334
+ Open.write(worker, 'answer')
335
+ Open.write(worker + '.info', '{}')
336
+
337
+ parent = File.join(dir, 'parent.chat')
338
+ # Absolute job reference, as the fixtures suite writes it
339
+ Open.write(parent, receipt_text(
340
+ meta: [{'pt' => 100, 'ct' => 50, 'tt' => 150, 'inference_id' => 'aaa'},
341
+ {'job' => worker}],
342
+ call_id: 'call-1'))
343
+
344
+ warnings = []
345
+ events = Chat.provenance_token_events(parent, warnings: warnings)
346
+ receipt_events = events.select { |event| event[:evidence].any? { |item| item[:origin] == :agent_meta } }
347
+ assert_equal 1, receipt_events.length
348
+ assert_equal 'aaa', receipt_events.first[:inference_id]
349
+ assert_empty warnings
350
+
351
+ references = Chat.report_agent_meta_problems(Chat.load(parent), parent)
352
+ assert_equal 1, references.length
353
+ # The reference is kept verbatim (absolute, as written into the receipt)
354
+ assert_equal worker, references.first[:job]
355
+ end
356
+ end
357
+ end