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,183 @@
1
+ require File.expand_path(__FILE__).sub(%r(/test/.*), '/test/test_helper.rb')
2
+
3
+ require 'scout/llm/chat'
4
+ require 'scout/llm/backends/responses'
5
+
6
+ class TestNormalizeUsage < Test::Unit::TestCase
7
+ def test_openai_chat_api
8
+ usage = {
9
+ "prompt_tokens" => 100,
10
+ "completion_tokens" => 50,
11
+ "total_tokens" => 150,
12
+ "prompt_tokens_details" => {"cached_tokens" => 20},
13
+ "completion_tokens_details" => {"reasoning_tokens" => 30}
14
+ }
15
+ result = Chat.normalize_usage(usage)
16
+ assert_equal 100, result['pt']
17
+ assert_equal 50, result['ct']
18
+ assert_equal 150, result['tt']
19
+ assert_equal 20, result['cct']
20
+ assert_nil result['cwt']
21
+ assert_equal 30, result['rt']
22
+ end
23
+
24
+ def test_openai_responses_api
25
+ usage = {
26
+ "input_tokens" => 9,
27
+ "input_tokens_details" => {"cache_write_tokens" => 0, "cached_tokens" => 0},
28
+ "output_tokens" => 174,
29
+ "output_tokens_details" => {"reasoning_tokens" => 128},
30
+ "total_tokens" => 183
31
+ }
32
+ result = Chat.normalize_usage(usage)
33
+ assert_equal 9, result['pt']
34
+ assert_equal 174, result['ct']
35
+ assert_equal 183, result['tt']
36
+ assert_equal 0, result['cct']
37
+ assert_equal 0, result['cwt']
38
+ assert_equal 128, result['rt']
39
+ end
40
+
41
+ def test_glm
42
+ usage = {
43
+ "completion_tokens" => 27,
44
+ "completion_tokens_details" => {"reasoning_tokens" => 92},
45
+ "prompt_tokens" => 8,
46
+ "prompt_tokens_details" => {"cached_tokens" => 0},
47
+ "total_tokens" => 105
48
+ }
49
+ result = Chat.normalize_usage(usage)
50
+ assert_equal 8, result['pt']
51
+ assert_equal 27, result['ct']
52
+ assert_equal 105, result['tt']
53
+ assert_equal 0, result['cct']
54
+ assert_nil result['cwt']
55
+ assert_equal 92, result['rt']
56
+ end
57
+
58
+ def test_anthropic_flat_fields
59
+ usage = {
60
+ "input_tokens" => 500,
61
+ "output_tokens" => 200,
62
+ "cache_read_input_tokens" => 150,
63
+ "cache_creation_input_tokens" => 50
64
+ }
65
+ result = Chat.normalize_usage(usage)
66
+ assert_equal 500, result['pt']
67
+ assert_equal 200, result['ct']
68
+ assert_equal 700, result['tt']
69
+ assert_equal 150, result['cct']
70
+ assert_equal 50, result['cwt']
71
+ assert_nil result['rt']
72
+ end
73
+
74
+ def test_simple_usage_without_cache
75
+ usage = {"prompt_tokens" => 10, "completion_tokens" => 5, "total_tokens" => 15}
76
+ result = Chat.normalize_usage(usage)
77
+ assert_equal 10, result['pt']
78
+ assert_equal 5, result['ct']
79
+ assert_equal 15, result['tt']
80
+ extras = result.select { |k,_v| !%w[pt ct tt].include?(k) }
81
+ assert_empty extras
82
+ end
83
+
84
+ def test_nil_usage
85
+ result = Chat.normalize_usage(nil)
86
+ assert_equal({}, result)
87
+ end
88
+
89
+ def test_empty_usage
90
+ result = Chat.normalize_usage({})
91
+ assert_equal({}, result)
92
+ end
93
+
94
+ def test_token_totals_with_cache
95
+ usage1 = {
96
+ "prompt_tokens" => 100,
97
+ "completion_tokens" => 50,
98
+ "total_tokens" => 150,
99
+ "prompt_tokens_details" => {"cached_tokens" => 20},
100
+ "completion_tokens_details" => {"reasoning_tokens" => 30}
101
+ }
102
+ meta_str1 = Chat.serialize_meta(Chat.normalize_usage(usage1))
103
+ chat1 = Chat.setup([
104
+ {role: :user, content: "hi"},
105
+ {role: :assistant, content: "hello"},
106
+ {role: :meta, content: meta_str1}
107
+ ])
108
+
109
+ totals = Chat.token_totals([chat1])
110
+ assert_equal 100, totals[:pt]
111
+ assert_equal 50, totals[:ct]
112
+ assert_equal 150, totals[:tt]
113
+ assert_equal 20, totals[:cct]
114
+ assert_equal 0, totals[:cwt]
115
+ assert_equal 30, totals[:rt]
116
+ end
117
+
118
+ def test_print_tokens_with_cache
119
+ tokens = {pt: 100, ct: 50, tt: 150, cct: 20, cwt: 0, rt: 30}
120
+ str = Chat.print_tokens(tokens)
121
+ assert str.include?("prompt=100")
122
+ assert str.include?("completion=50")
123
+ assert str.include?("total=150")
124
+ assert str.include?("cached=20")
125
+ assert !str.include?("cache_write")
126
+ assert str.include?("reasoning=30")
127
+ end
128
+
129
+ def test_token_keys_constant
130
+ assert_equal %w[pt ct tt cct cwt rt], Chat::TOKEN_KEYS
131
+ assert_equal %w[pt_c ct_c tt_c cct_c cwt_c rt_c], Chat::CUMULATIVE_KEYS
132
+ end
133
+
134
+ def test_update_meta_with_cache_fields
135
+ %w(pt_s ct_s tt_s cct_s cwt_s rt_s).each { |name| Thread.current[name] = 0 }
136
+
137
+ response = {
138
+ 'usage' => {
139
+ "prompt_tokens" => 100,
140
+ "completion_tokens" => 50,
141
+ "total_tokens" => 150,
142
+ "prompt_tokens_details" => {"cached_tokens" => 20},
143
+ "completion_tokens_details" => {"reasoning_tokens" => 30}
144
+ }
145
+ }
146
+ meta = LLM::Responses.update_meta(response)
147
+ assert_equal 100, meta['pt']
148
+ assert_equal 50, meta['ct']
149
+ assert_equal 150, meta['tt']
150
+ assert_equal 20, meta['cct']
151
+ assert_equal 30, meta['rt']
152
+ assert_equal 20, meta['cct_s']
153
+ assert_equal 30, meta['rt_s']
154
+ assert_equal 20, meta['cct_c']
155
+ assert_equal 30, meta['rt_c']
156
+ end
157
+
158
+ def test_update_meta_accumulates_cache_fields_across_requests
159
+ %w(pt_s ct_s tt_s cct_s cwt_s rt_s).each { |name| Thread.current[name] = 0 }
160
+
161
+ resp1 = { 'usage' => {
162
+ "prompt_tokens" => 10, "completion_tokens" => 5, "total_tokens" => 15,
163
+ "prompt_tokens_details" => {"cached_tokens" => 4},
164
+ "completion_tokens_details" => {"reasoning_tokens" => 3}
165
+ }}
166
+ meta1 = LLM::Responses.update_meta(resp1)
167
+ assert_equal 4, meta1['cct_s']
168
+ assert_equal 3, meta1['rt_s']
169
+ assert_equal 4, meta1['cct_c']
170
+ assert_equal 3, meta1['rt_c']
171
+
172
+ resp2 = { 'usage' => {
173
+ "prompt_tokens" => 20, "completion_tokens" => 10, "total_tokens" => 30,
174
+ "prompt_tokens_details" => {"cached_tokens" => 8},
175
+ "completion_tokens_details" => {"reasoning_tokens" => 6}
176
+ }}
177
+ meta2 = LLM::Responses.update_meta(resp2, meta1)
178
+ assert_equal 12, meta2['cct_s'] # 4 + 8
179
+ assert_equal 9, meta2['rt_s'] # 3 + 6
180
+ assert_equal 12, meta2['cct_c'] # 4 + 8
181
+ assert_equal 9, meta2['rt_c'] # 3 + 6
182
+ end
183
+ end
@@ -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