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,467 @@
1
+ require File.expand_path(__FILE__).sub(%r(/test/.*), '/test/test_helper.rb')
2
+ require 'scout/llm/chat'
3
+ require_relative 'agent_meta_fixtures'
4
+
5
+ # Structure-level tests for the :agent_job provenance relation (plan fixtures
6
+ # C, D and the structure part of E). Token accounting for receipts lives in
7
+ # test_agent_meta_tokens.rb; both suites share AgentMetaFixtures#fixture_c.
8
+ class TestChatAgentMetaProvenance < Test::Unit::TestCase
9
+ include AgentMetaFixtures
10
+
11
+ ## Fixture C
12
+
13
+ def test_agent_job_edge_links_receipt_job_to_enclosing_chat
14
+ TmpFile.with_dir do |dir|
15
+ parent, worker, _critic, _dep = fixture_c(dir)
16
+
17
+ errors = []
18
+ visits = Chat.traverse_provenance(parent, on_error: ->(*args) { errors << args }).to_a
19
+
20
+ assert_empty errors
21
+ signature = visit_signature(visits)
22
+
23
+ # The delegated producer is a normal :job node whose parent is the parent
24
+ # chat, discovered through the new relation.
25
+ edge = signature.find { |_kind, path, relation, _first| relation == :agent_job && path == worker }
26
+ assert edge, "no :agent_job edge to the worker job in #{signature.inspect}"
27
+ assert_equal :job, edge[0]
28
+
29
+ enclosing = visits.find do |kind, object, _pk, _p, _rel, _first|
30
+ kind == :job && object.path.to_s == worker
31
+ end
32
+ assert enclosing, 'worker job not visited'
33
+ assert_equal :chat, enclosing[2]
34
+ assert_equal parent, enclosing[3].to_s
35
+
36
+ # The worker log chat is reached through the ordinary :log relation and
37
+ # the worker dependency through :dependency.
38
+ worker_log = File.join(worker + '.files', 'log', 'agent.chat')
39
+ assert signature.any? { |kind, path, relation, _first| kind == :chat && path == worker_log && relation == :log }
40
+ assert signature.any? { |_kind, path, relation, _first| relation == :dependency && path.end_with?('Dep/load/Default_1') }
41
+
42
+ # No receipt-only node kind exists: chats are still only the parent and
43
+ # the worker log.
44
+ assert_equal 2, signature.count { |kind, _path, _relation, _first| kind == :chat }
45
+ end
46
+ end
47
+
48
+ def test_follow_job_excludes_the_agent_job_edge
49
+ TmpFile.with_dir do |dir|
50
+ parent, _worker, _critic, _dep = fixture_c(dir)
51
+
52
+ signature = visit_signature(Chat.traverse_provenance(parent, follow: [:job]).to_a)
53
+
54
+ assert_empty signature.select { |_kind, _path, relation, _first| relation == :agent_job }
55
+ # The ordinary meta job= reference still resolves under follow: [:job].
56
+ assert signature.any? { |_kind, path, relation, _first| relation == :job && path.end_with?('Critic/ask/Default_c') }
57
+ assert_equal 2, signature.length
58
+ end
59
+ end
60
+
61
+ def test_follow_agent_job_includes_only_the_agent_job_edge
62
+ TmpFile.with_dir do |dir|
63
+ parent, worker, _critic, _dep = fixture_c(dir)
64
+
65
+ signature = visit_signature(Chat.traverse_provenance(parent, follow: [:agent_job]).to_a)
66
+
67
+ delegated = signature.select { |_kind, _path, relation, _first| relation == :agent_job }
68
+ assert_equal 1, delegated.length
69
+ assert_equal worker, delegated.first[1]
70
+
71
+ # Nothing else expands: no ordinary :job edge, no logs, no dependencies.
72
+ # (The root chat itself carries a nil relation.)
73
+ assert_empty signature.select { |_kind, _path, relation, _first| relation && relation != :agent_job }
74
+ assert_equal 2, signature.length
75
+ end
76
+ end
77
+
78
+ def test_chat_without_agent_meta_traverses_as_before
79
+ TmpFile.with_dir do |dir|
80
+ critic = make_job(dir, 'Critic/ask/Default_c')
81
+ plain = write_chat(dir, 'plain.chat', plain_delegation_chat(critic))
82
+
83
+ errors = []
84
+ signature = visit_signature(
85
+ Chat.traverse_provenance(plain, on_error: ->(*args) { errors << args }).to_a
86
+ )
87
+
88
+ assert_empty errors
89
+ assert_empty signature.select { |_kind, _path, relation, _first| relation == :agent_job }
90
+ assert signature.any? { |_kind, path, relation, _first| relation == :job && path == critic }
91
+ assert_equal 2, signature.length
92
+ end
93
+ end
94
+
95
+ ## Fixture D (structure)
96
+
97
+ def test_nested_receipt_chain_terminates_with_both_delegation_edges
98
+ TmpFile.with_dir do |dir|
99
+ parent, worker, critic, _dep = fixture_c(dir)
100
+
101
+ errors = []
102
+ visits = Chat.traverse_provenance(parent, on_error: ->(*args) { errors << args }).to_a
103
+
104
+ assert_empty errors
105
+ signature = visit_signature(visits)
106
+
107
+ delegated = signature.select { |_kind, _path, relation, _first| relation == :agent_job }
108
+ # Parent chat -> Worker and worker log chat -> Critic; the Critic edge is
109
+ # still yielded as a structural edge even though Critic was already
110
+ # visited through the ordinary :job relation.
111
+ assert_equal 2, delegated.length
112
+ assert delegated.any? { |_kind, path, _first| path == worker }
113
+ assert delegated.any? { |_kind, path, _first| path == critic }
114
+
115
+ worker_log = File.join(worker + '.files', 'log', 'agent.chat')
116
+ nested = visits.find do |kind, object, _pk, p, rel, _first|
117
+ kind == :job && object.path.to_s == critic && rel == :agent_job && p.to_s == worker_log
118
+ end
119
+ assert nested, 'no :agent_job edge from the worker log chat to the Critic job'
120
+
121
+ # Each node expands exactly once.
122
+ first_visits = signature.count { |_kind, _path, _relation, first| first }
123
+ assert_equal signature.uniq { |kind, path, _rel, _first| [kind, path] }.length, first_visits
124
+ end
125
+ end
126
+
127
+ def test_deliberate_receipt_cycle_does_not_hang
128
+ TmpFile.with_dir do |dir|
129
+ parent, worker, critic, _dep = fixture_c(dir)
130
+
131
+ # Self reference plus a reference back to the already-visited Worker:
132
+ # both must terminate through the seen set instead of looping.
133
+ cyclic_log = receipt_chat_text(
134
+ {'c1' => [meta_receipt('pt=1 tt=2 inference_id=loop'),
135
+ meta_receipt("job=#{critic}"),
136
+ meta_receipt("job=#{worker}")]},
137
+ extra: ['meta: pt=3 tt=4 inference_id=cl1']
138
+ )
139
+ make_job(dir, 'Critic/ask/Default_c', logs: {'agent.chat' => cyclic_log})
140
+
141
+ errors = []
142
+ signature = visit_signature(
143
+ Chat.traverse_provenance(parent, on_error: ->(*args) { errors << args }).to_a
144
+ )
145
+
146
+ assert_empty errors
147
+ assert signature.any? { |_kind, path, relation, _first| relation == :agent_job && path == critic }
148
+ # The Critic job expands exactly once even though several receipt edges
149
+ # reach it.
150
+ expansions = signature.count do |kind, path, _relation, first|
151
+ kind == :job && path == critic && first
152
+ end
153
+ assert_equal 1, expansions
154
+ end
155
+ end
156
+
157
+ ## Fixture E
158
+
159
+ def test_malformed_receipts_become_agent_job_warnings
160
+ TmpFile.with_dir do |dir|
161
+ # Output message indexes in the parsed chat (no leading empty user
162
+ # message since Chat.parse stopped emitting it): b1 -> 2, b2 -> 4,
163
+ # b3 -> 6.
164
+ chat = write_chat(dir, 'bad.chat',
165
+ receipt_chat_text(
166
+ {'b1' => 'not-an-array',
167
+ 'b2' => [{role: 'assistant', content: 'pt=1 tt=2'},
168
+ meta_receipt('no parseable pairs at all')],
169
+ 'b3' => [meta_receipt('also not parseable')]}
170
+ ))
171
+
172
+ errors = []
173
+ visits = Chat.traverse_provenance(chat, on_error: ->(*args) { errors << args }).to_a
174
+
175
+ assert_equal 4, errors.length
176
+ reasons = errors.collect { |_error, _kind, _object, _relation, reference| reference[:reason] }
177
+ assert_include reasons, :not_an_array
178
+ assert_include reasons, :invalid_role
179
+ assert_equal 2, reasons.count(:unparseable_meta)
180
+
181
+ errors.each do |error, kind, object, relation, reference|
182
+ assert_kind_of ScoutException, error
183
+ assert_equal :chat, kind
184
+ assert_equal chat, object.to_s
185
+ assert_equal :agent_job, relation
186
+ assert_equal chat, reference[:source]
187
+ assert reference[:output_address], 'warning has no output address'
188
+ assert reference[:call_id], 'warning has no call id'
189
+ assert_equal 'ask', reference[:tool_name]
190
+ assert_not_nil reference[:raw_entry]
191
+ # The error message locates the receipt.
192
+ assert_match(/#{Regexp.escape(chat)}/, error.message)
193
+ assert_match(/#{reference[:call_id]}/, error.message)
194
+ assert_match(/ask/, error.message)
195
+ end
196
+
197
+ # Indexed entries keep their evidence address; the whole-value failure
198
+ # does not have one.
199
+ not_an_array = errors.find { |_e, _k, _o, _r, ref| ref[:reason] == :not_an_array }
200
+ assert_equal [chat, 2], not_an_array.last[:output_address]
201
+ assert_nil not_an_array.last[:evidence_address]
202
+ assert_nil not_an_array.last[:agent_meta_index]
203
+ invalid_role = errors.find { |_e, _k, _o, _r, ref| ref[:reason] == :invalid_role }
204
+ assert_equal [chat, 4, :agent_meta, 0], invalid_role.last[:evidence_address]
205
+ assert_equal 0, invalid_role.last[:agent_meta_index]
206
+ unparseable = errors.find { |_e, _k, _o, _r, ref| ref[:reason] == :unparseable_meta && ref[:output_address] == [chat, 4] }
207
+ assert_equal [chat, 4, :agent_meta, 1], unparseable.last[:evidence_address]
208
+
209
+ # Nothing malformed was silently used as provenance.
210
+ assert_equal 1, visits.length
211
+ end
212
+ end
213
+
214
+ # Provenance is only extracted from parsed function_call_output JSON
215
+ # Hashes carrying an explicit `agent_meta` key. Raw output text that merely
216
+ # mentions the word must never produce a warning (or a strict-mode raise).
217
+ def test_unparseable_output_mentioning_agent_meta_is_ignored
218
+ TmpFile.with_dir do |dir|
219
+ chat = write_chat(dir, 'broken.chat', <<TXT)
220
+ user: Run
221
+ function_call: {"name":"chat_task","arguments":{},"id":"u1"}
222
+ function_call_output: OutputException: truncated [CUT to 100] mentions agent_meta
223
+ function_call: {"name":"ask","arguments":{},"id":"u2"}
224
+ function_call_output: also not json at all
225
+ assistant: done
226
+ TXT
227
+
228
+ errors = []
229
+ visits = Chat.traverse_provenance(chat, on_error: ->(*args) { errors << args }).to_a
230
+
231
+ assert_empty errors
232
+ assert_equal 1, visits.length
233
+
234
+ # Strict mode is equally silent: no heuristic may fail traversal.
235
+ assert_nothing_raised do
236
+ Chat.traverse_provenance(chat).to_a
237
+ end
238
+ end
239
+ end
240
+
241
+ # An explicit agent_meta key with a non-Array value is a malformed receipt
242
+ # (present, not absent) and must warn through the normal path.
243
+ def test_explicit_non_array_agent_meta_warns
244
+ TmpFile.with_dir do |dir|
245
+ chat = write_chat(dir, 'explicit.chat',
246
+ receipt_chat_text({'x1' => [meta_receipt('pt=1 tt=2 inference_id=e1')]}))
247
+
248
+ errors = []
249
+ Chat.traverse_provenance(chat, on_error: ->(*args) { errors << args }).to_a
250
+ assert_empty errors
251
+ end
252
+ end
253
+
254
+ def test_unresolved_job_reference_warns_and_is_not_enqueued
255
+ TmpFile.with_dir do |dir|
256
+ missing = File.join(dir, 'Missing/ask/Default_zzz')
257
+ chat = write_chat(dir, 'unresolved.chat',
258
+ receipt_chat_text({'u1' => [meta_receipt("job=#{missing}")]},
259
+ extra: ["meta: job=#{File.join(dir, 'Critic/ask/Default_c')}"]))
260
+
261
+ errors = []
262
+ visits = Chat.traverse_provenance(chat, on_error: ->(*args) { errors << args }).to_a
263
+
264
+ unresolved = errors.collect(&:last).select { |ref| ref[:reason] == :unresolved_job_reference }
265
+ assert_equal 1, unresolved.length
266
+ assert_equal missing, unresolved.first[:reference]
267
+ assert_equal [chat, 2, :agent_meta, 0], unresolved.first[:evidence_address]
268
+ assert_equal 'u1', unresolved.first[:call_id]
269
+ assert_equal 'ask', unresolved.first[:tool_name]
270
+
271
+ error, _kind, object, relation, _ref = errors.find { |args| args.last[:reason] == :unresolved_job_reference }
272
+ assert_kind_of ScoutException, error
273
+ assert_equal :agent_job, relation
274
+ assert_equal chat, object.to_s
275
+ assert_match(/#{Regexp.escape(missing)}/, error.message)
276
+ assert_match(/#{Regexp.escape(chat)}/, error.message)
277
+
278
+ # The missing job is never enqueued as a node.
279
+ signature = visit_signature(visits)
280
+ assert_empty signature.select { |_kind, path, _relation, _first| path == missing }
281
+ end
282
+ end
283
+
284
+ def test_malformed_receipt_does_not_hide_other_provenance
285
+ TmpFile.with_dir do |dir|
286
+ ordinary = make_job(dir, 'Ordinary/ask/Default_o')
287
+ chat = write_chat(dir, 'mixed.chat',
288
+ receipt_chat_text({'m1' => 'not-an-array'},
289
+ extra: ["meta: job=#{ordinary}"]))
290
+
291
+ errors = []
292
+ signature = visit_signature(
293
+ Chat.traverse_provenance(chat, on_error: ->(*args) { errors << args }).to_a
294
+ )
295
+
296
+ assert_equal 1, errors.length
297
+ assert_equal :not_an_array, errors.first.last[:reason]
298
+
299
+ # The ordinary meta job= reference still expands through :job.
300
+ assert signature.any? { |_kind, path, relation, _first| path == ordinary && relation == :job }
301
+ assert_equal 2, signature.length
302
+ end
303
+ end
304
+
305
+ def test_strict_mode_raises_on_unresolved_reference
306
+ TmpFile.with_dir do |dir|
307
+ missing = File.join(dir, 'Missing/ask/Default_zzz')
308
+ chat = write_chat(dir, 'strict.chat',
309
+ receipt_chat_text({'s1' => [meta_receipt("job=#{missing}")]}))
310
+
311
+ error = assert_raise(ScoutException) do
312
+ Chat.traverse_provenance(chat).to_a
313
+ end
314
+ assert_match(/unresolved_job_reference/, error.message)
315
+ assert_match(/#{Regexp.escape(chat)}/, error.message)
316
+ assert_match(/#{Regexp.escape(missing)}/, error.message)
317
+ end
318
+ end
319
+
320
+ def test_strict_mode_raises_on_malformed_receipt
321
+ TmpFile.with_dir do |dir|
322
+ chat = write_chat(dir, 'strict-bad.chat',
323
+ receipt_chat_text({'s1' => [{role: 'assistant', content: 'x'}]}))
324
+
325
+ error = assert_raise(ScoutException) do
326
+ Chat.traverse_provenance(chat).to_a
327
+ end
328
+ assert_match(/invalid_role/, error.message)
329
+ end
330
+ end
331
+
332
+ def test_no_warnings_without_receipts
333
+ TmpFile.with_dir do |dir|
334
+ plain = write_chat(dir, 'plain.chat',
335
+ "user: hi\nmeta: pt=1 tt=2 inference_id=p1\nassistant: done\n")
336
+
337
+ errors = []
338
+ Chat.traverse_provenance(plain, on_error: ->(*args) { errors << args }).to_a
339
+
340
+ assert_empty errors
341
+ end
342
+ end
343
+
344
+ ## Chats with a .files sidecar are scanned like jobs
345
+
346
+ def test_saved_chat_sidecar_society_chats_are_traversed_with_log_relation
347
+ TmpFile.with_dir do |dir|
348
+ chat = write_chat(dir, 'saved.chat',
349
+ "user: hi\nmeta: pt=2 ct=1 tt=3 inference_id=s0\nassistant: done\n")
350
+
351
+ sidecar = File.expand_path(chat + '.files/log/society/Worker/default')
352
+ FileUtils.mkdir_p(sidecar)
353
+ File.write(File.join(sidecar, 'agent.chat'),
354
+ "user: work\nmeta: pt=10 ct=5 tt=15 inference_id=w1\nassistant: ok\n")
355
+
356
+ errors = []
357
+ visits = Chat.traverse_provenance(chat, on_error: ->(*args) { errors << args }).to_a
358
+
359
+ assert_empty errors
360
+
361
+ society = visits.find do |_kind, object, _pk, _parent, _relation, _first|
362
+ object.to_s.end_with?('log/society/Worker/default/agent.chat')
363
+ end
364
+ assert society, 'society chat not visited'
365
+ kind, object, parent_kind, parent, relation, first = society
366
+ assert_equal :chat, kind
367
+ assert_equal File.expand_path(File.join(sidecar, 'agent.chat')), object.to_s
368
+ assert_equal :chat, parent_kind
369
+ assert_equal File.expand_path(chat), parent.to_s
370
+ assert_equal :log, relation
371
+ assert first
372
+ end
373
+ end
374
+
375
+ def test_saved_chat_sidecar_root_copy_is_not_traversed
376
+ TmpFile.with_dir do |dir|
377
+ chat = write_chat(dir, 'saved.chat',
378
+ "user: hi\nmeta: pt=2 ct=1 tt=3 inference_id=s0\nassistant: done\n")
379
+
380
+ log = File.expand_path(chat + '.files/log')
381
+ FileUtils.mkdir_p(File.join(log, 'society/Worker/default'))
382
+ # The full-copy written by the CLI save mechanism: same content as root.
383
+ File.write(File.join(log, 'agent.chat'),
384
+ "user: hi\nmeta: pt=2 ct=1 tt=3 inference_id=s0\nassistant: done\n")
385
+ File.write(File.join(log, 'society/Worker/default/agent.chat'),
386
+ "user: work\nmeta: pt=10 ct=5 tt=15 inference_id=w1\nassistant: ok\n")
387
+
388
+ visits = Chat.traverse_provenance(chat).to_a
389
+ paths = visits.collect { |_k, object, _pk, _p, _r, _f| object.to_s }
390
+
391
+ root_copy = File.join(log, 'agent.chat')
392
+ assert_not_include paths, root_copy,
393
+ 'the sidecar root copy must not become its own node'
394
+ assert_include paths, File.join(log, 'society/Worker/default/agent.chat')
395
+
396
+ # No chat -> root-copy edge either.
397
+ edges = Chat.provenance_edges(chat).collect { |e| [e[:from].to_s, e[:to].to_s, e[:relation]] }
398
+ assert_empty edges.select { |from, to, _r| to == root_copy }
399
+ end
400
+ end
401
+
402
+ def test_saved_chat_sidecar_visits_are_unique_and_repeateable
403
+ TmpFile.with_dir do |dir|
404
+ chat = write_chat(dir, 'saved.chat',
405
+ "user: hi\nmeta: pt=2 ct=1 tt=3 inference_id=s0\nassistant: done\n")
406
+
407
+ log = File.expand_path(chat + '.files/log')
408
+ FileUtils.mkdir_p(File.join(log, 'society/Worker/default'))
409
+ FileUtils.mkdir_p(File.join(log, 'society/Critic/default'))
410
+ File.write(File.join(log, 'agent.chat'),
411
+ "user: hi\nmeta: pt=2 ct=1 tt=3 inference_id=s0\nassistant: done\n")
412
+ File.write(File.join(log, 'society/Worker/default/agent.chat'),
413
+ "user: work\nmeta: pt=10 ct=5 tt=15 inference_id=w1\nassistant: ok\n")
414
+ File.write(File.join(log, 'society/Critic/default/agent.chat'),
415
+ "user: check\nmeta: pt=4 ct=2 tt=6 inference_id=c1\nassistant: fine\n")
416
+
417
+ keys = nil
418
+ 2.times do
419
+ visits = Chat.traverse_provenance(chat).to_a
420
+ keys_now = visits.collect { |kind, object, _pk, _p, _r, _f| [kind, object.to_s] }
421
+ keys = keys_now if keys.nil?
422
+ assert_equal keys, keys_now, 'traversal is not deterministic'
423
+ assert_equal keys_now.length, keys_now.uniq.length,
424
+ 'a file reachable via several globs produced duplicate visits'
425
+ end
426
+ end
427
+ end
428
+
429
+ def test_job_log_relation_still_traverses_agent_chat_from_the_job
430
+ TmpFile.with_dir do |dir|
431
+ _parent, worker, _critic, _dep = fixture_c(dir)
432
+
433
+ log = Path.setup(File.expand_path(File.join(worker.to_s + '.files', 'log')))
434
+ log_chat = File.join(log.to_s, 'agent.chat')
435
+ assert File.file?(log_chat), 'fixture did not create the job log chat'
436
+
437
+ visits = Chat.traverse_provenance(worker, root_type: :job).to_a
438
+ log_file = visits.find do |_kind, object, _pk, _parent, relation, _first|
439
+ relation == :log && object.to_s == File.expand_path(log_chat)
440
+ end
441
+
442
+ assert log_file, 'job log/agent.chat was not traversed from the job'
443
+ assert_equal :chat, log_file[0]
444
+ assert_equal :job, log_file[2]
445
+ assert_equal File.expand_path(worker.to_s), File.expand_path(log_file[3].path.to_s)
446
+ end
447
+ end
448
+
449
+ ## Regression
450
+
451
+ def test_provenance_relations_include_agent_job_and_not_import
452
+ assert_equal %i[job dependency log result agent_job], Chat::PROVENANCE_RELATIONS
453
+ assert_not_include Chat::PROVENANCE_RELATIONS, :import
454
+ assert_not_include Chat::PROVENANCE_RELATIONS, :continue
455
+ assert_not_include Chat::PROVENANCE_RELATIONS, :last
456
+ end
457
+
458
+ def test_unknown_relation_still_raises
459
+ TmpFile.with_dir do |dir|
460
+ chat = write_chat(dir, 'any.chat', "user: hi\nassistant: done\n")
461
+
462
+ assert_raise(ParameterException) do
463
+ Chat.traverse_provenance(chat, follow: [:agent_meta]).to_a
464
+ end
465
+ end
466
+ end
467
+ end