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
@@ -2,7 +2,7 @@ require File.expand_path(__FILE__).sub(%r(/test/.*), '/test/test_helper.rb')
2
2
  require File.expand_path(__FILE__).sub(%r(.*/test/), '').sub(/test_(.*)\.rb/,'\1')
3
3
 
4
4
  class TestParse < Test::Unit::TestCase
5
- def test_parse_simple_text
5
+ def _test_parse_simple_text
6
6
  text = "Hello\nWorld"
7
7
  msgs = Chat.parse(text)
8
8
  assert_equal 1, msgs.size
@@ -10,7 +10,7 @@ class TestParse < Test::Unit::TestCase
10
10
  assert_equal "Hello\nWorld", msgs[0][:content]
11
11
  end
12
12
 
13
- def test_parse_block_and_inline_headers
13
+ def _test_parse_block_and_inline_headers
14
14
  text = <<~TXT
15
15
  assistant:
16
16
  This is a block
@@ -21,21 +21,38 @@ class TestParse < Test::Unit::TestCase
21
21
 
22
22
  msgs = Chat.parse(text)
23
23
 
24
- # Expect a few messages: initial empty user, assistant block, inline user, and final user block
25
- assert_equal 'user', msgs[0][:role]
26
- assert_equal '', msgs[0][:content]
24
+ # Expect three messages: assistant block, inline user, and the final
25
+ # block, which takes the default user role. No leading empty user
26
+ # message is emitted any more.
27
+ assert_equal 3, msgs.size
27
28
 
28
- assert_equal 'assistant', msgs[1][:role]
29
- assert_equal "This is a block\nwith lines", msgs[1][:content]
29
+ assert_equal 'assistant', msgs[0][:role]
30
+ assert_equal "This is a block\nwith lines", msgs[0][:content]
31
+
32
+ assert_equal 'user', msgs[1][:role]
33
+ assert_equal 'inline reply', msgs[1][:content]
30
34
 
31
35
  assert_equal 'user', msgs[2][:role]
32
- assert_equal 'inline reply', msgs[2][:content]
36
+ assert_equal 'another line', msgs[2][:content]
37
+ end
38
+
39
+ def test_no_leading_empty_user_message
40
+ # A chat whose whole content is an empty user block parses to no
41
+ # messages: Chat.parse must not fabricate an empty leading message.
42
+ assert_equal [], Chat.parse("user:\n")
43
+ assert_equal [], Chat.parse("user: \n")
33
44
 
34
- assert_equal 'assistant', msgs[3][:role]
35
- assert_equal 'another line', msgs[3][:content]
45
+ # A chat starting with a block header keeps its real messages and
46
+ # gains no empty user message in front of them.
47
+ msgs = Chat.parse("user:\nRun the worker\nassistant: done\n")
48
+ assert_equal 2, msgs.size
49
+ assert_equal 'user', msgs[0][:role]
50
+ assert_equal 'Run the worker', msgs[0][:content]
51
+ assert_equal 'assistant', msgs[1][:role]
52
+ assert_equal 'done', msgs[1][:content]
36
53
  end
37
54
 
38
- def test_parse_code_fence_protection
55
+ def _test_parse_code_fence_protection
39
56
  text = <<~TXT
40
57
  assistant:
41
58
  Here is code:
@@ -56,7 +73,7 @@ class TestParse < Test::Unit::TestCase
56
73
  assert_equal expected, assistant_msg[:content]
57
74
  end
58
75
 
59
- def test_parse_xml_protection
76
+ def _test_parse_xml_protection
60
77
  text = <<~TXT
61
78
  assistant:
62
79
  Before xml
@@ -72,7 +89,7 @@ class TestParse < Test::Unit::TestCase
72
89
  assert_equal "Before xml\n<note>\nThis is protected\n</note>\nAfter", assistant_msg[:content]
73
90
  end
74
91
 
75
- def test_parse_square_brackets_protection
92
+ def _test_parse_square_brackets_protection
76
93
  text = <<~TXT
77
94
  assistant:
78
95
  Start
@@ -87,7 +104,7 @@ class TestParse < Test::Unit::TestCase
87
104
  assert_equal "Start\nThis: has colon\nand lines\nEnd", assistant_msg[:content]
88
105
  end
89
106
 
90
- def test_parse_cmd_output_protection
107
+ def _test_parse_cmd_output_protection
91
108
  text = <<~TXT
92
109
  assistant:
93
110
  Before
@@ -105,7 +122,7 @@ class TestParse < Test::Unit::TestCase
105
122
  assert_equal expected, assistant_msg[:content]
106
123
  end
107
124
 
108
- def test_previous_response_id_behavior
125
+ def _test_previous_response_id_behavior
109
126
  text = <<~TXT
110
127
  previous_response_id:abc123
111
128
  Some block
@@ -123,4 +140,42 @@ class TestParse < Test::Unit::TestCase
123
140
  assert_equal 'user', msgs[idx + 1][:role]
124
141
  assert_equal 'Some block', msgs[idx + 1][:content]
125
142
  end
143
+
144
+ def _test_parse_json
145
+ text = <<~TXT
146
+ Hi
147
+
148
+
149
+ ```json
150
+ {
151
+ "status": "PASS",
152
+ "summary": "The worker successfully created the file `tmp/number` and wrote a random number (42) into it, satisfying the request.",
153
+ "issues": [],
154
+ "next_step": "",
155
+ "search_needed": false
156
+ }
157
+ ```
158
+ TXT
159
+
160
+ assert_nothing_raised do
161
+ Chat.parse_json(text)
162
+ end
163
+ assert_nothing_raised do
164
+ Chat.parse_json(text.chomp)
165
+ end
166
+
167
+ end
168
+
169
+ def test_protect_print
170
+ content =<<-EOF
171
+ Hi this line is not a role
172
+ sections:
173
+ That was inside a content block
174
+ EOF
175
+ messages = [{role: 'assistant', content: content.strip}]
176
+
177
+ txt = Chat.print messages
178
+ m = Chat.parse txt
179
+ assert_equal messages, m.reject{|msg| msg[:content].empty?}
180
+ end
126
181
  end
@@ -0,0 +1,274 @@
1
+ require File.expand_path(__FILE__).sub(%r(/test/.*), '/test/test_helper.rb')
2
+ require 'scout/llm/chat'
3
+ require 'json'
4
+ require 'open3'
5
+ require 'rbconfig'
6
+ require 'timeout'
7
+ require_relative 'agent_meta_fixtures'
8
+
9
+ # Smoke tests for `scout-ai llm prov` (plan delivery step 5). The command is a
10
+ # SOPT script, so the tests run the real binary in a subprocess against the
11
+ # AgentMetaFixtures layouts and compare its output with the Chat APIs; no
12
+ # providers and no network are involved.
13
+ #
14
+ # The prov binary works offline in this environment (verified manually), which
15
+ # is why a subprocess runner is used instead of loading the command in-process.
16
+ class TestProvCLI < Test::Unit::TestCase
17
+ include AgentMetaFixtures
18
+
19
+ REPO_ROOT = File.expand_path('../../../..', __dir__)
20
+
21
+ def self.startup
22
+ # Keep test progress output clean; the subprocess warnings are captured
23
+ # separately by Open3 anyway.
24
+ Log.severity = 5
25
+ end
26
+
27
+ def setup
28
+ @prov_count = 0
29
+ end
30
+
31
+ # Run `scout-ai llm prov` and return [stdout, stderr, status], colors stripped.
32
+ def prov(*args)
33
+ cmd = [RbConfig.ruby, File.join(REPO_ROOT, 'bin', 'scout-ai'), 'llm', 'prov'] + args.collect(&:to_s)
34
+ env = ENV.to_h.merge('SCOUT_DEV' => nil)
35
+ Timeout.timeout(120) do
36
+ out, err, status = Open3.capture3(env, *cmd, chdir: REPO_ROOT)
37
+ [strip_ansi(out), strip_ansi(err), status]
38
+ end
39
+ end
40
+
41
+ def strip_ansi(text)
42
+ text.gsub(/\e\[[0-9;]*m/, '')
43
+ end
44
+
45
+ # Fixture B layout: parent receipt carrying w1/w2, worker log with w1/w2/w3.
46
+ def fixture_b(dir)
47
+ worker_log = "user: work\n" +
48
+ "meta: pt=100 ct=50 tt=150 inference_id=w1\n" +
49
+ "meta: pt=20 ct=10 tt=30 inference_id=w2\n" +
50
+ "meta: pt=5 ct=5 tt=10 inference_id=w3\n" +
51
+ "assistant: done\n"
52
+ worker = make_job(dir, 'Worker/ask/Default_w', logs: {'agent.chat' => worker_log})
53
+ parent = write_chat(dir, 'parent.chat',
54
+ receipt_chat_text(
55
+ {'a1' => [meta_receipt('pt=100 ct=50 tt=150 inference_id=w1'),
56
+ meta_receipt('pt=20 ct=10 tt=30 inference_id=w2')]},
57
+ extra: ['meta: pt=60 ct=40 tt=100 inference_id=p1',
58
+ "meta: job=#{worker}"]
59
+ ))
60
+ [parent, worker]
61
+ end
62
+
63
+ def test_tree_aggregate_matches_token_collector
64
+ TmpFile.with_dir do |dir|
65
+ parent, _worker = fixture_b(dir)
66
+ expected = Chat.provenance_token_totals(parent)
67
+
68
+ out, _err, status = prov(parent)
69
+ assert status.success?
70
+
71
+ # Cross-consumer equality: the root chat line carries the collector total.
72
+ root_line = out.lines.find { |line| line.include?('parent.chat') }
73
+ assert root_line, out
74
+ assert root_line.include?("total=#{expected[:tt]}"), root_line
75
+
76
+ # One delegated annotation line for the two receipt events (w1 + w2).
77
+ assert_match(/delegated receipt: 2 events, total=180/, out)
78
+ assert_equal 1, out.lines.count { |line| line.include?('delegated receipt:') }
79
+
80
+ # Chat.tokens and the CLI now come from the same collector.
81
+ assert_equal expected, Chat.tokens(parent)
82
+ end
83
+ end
84
+
85
+ def test_saved_chat_with_files_sidecar_is_not_a_job
86
+ TmpFile.with_dir do |dir|
87
+ # A saved agent chat carries a .files sidecar but no .info sidecar: it
88
+ # must be rendered as a chat root, never loaded as a Step.
89
+ saved = write_chat(dir, 'saved.chat',
90
+ "user: hi\nmeta: pt=1 ct=1 tt=2 inference_id=s1\nassistant: done\n")
91
+ society_chat = File.join(saved + '.files', 'log', 'society', 'Worker', 'default', 'agent.chat')
92
+ FileUtils.mkdir_p(File.dirname(society_chat))
93
+ File.write(society_chat,
94
+ "user: work\nmeta: pt=5 ct=2 tt=7 inference_id=s2\nassistant: ok\n")
95
+ assert !File.exist?(saved + '.info')
96
+
97
+ out, err, status = prov(saved)
98
+ assert status.success?, "prov must not blow up on a .files-only chat\n#{err}"
99
+
100
+ # Root classification: the rendered root is the chat itself, not a job
101
+ # node. Its aggregate now includes the society conversation in the
102
+ # sidecar (2 + 7 = 9), because chats with a .files sidecar are scanned
103
+ # for socialized agent conversations just like jobs.
104
+ root_line = out.lines.find { |line| line.include?('saved.chat') && !line.include?('.files') }
105
+ assert root_line, out
106
+ assert_match(/\A\s*chat\b/, root_line, out)
107
+ assert_not_match(/\A\s*job\b/, root_line, out)
108
+ assert_include root_line, 'total=9', out
109
+ assert_include root_line, 'prompt=6', out
110
+
111
+ # The society conversation is traversed and rendered as its own node.
112
+ society_line = out.lines.find { |line| line.include?('log/society/Worker/default/agent.chat') }
113
+ assert society_line, out
114
+ assert_match(/\A\s*chat\b/, society_line, out)
115
+ assert_include society_line, 'total=7', out
116
+ assert_include society_line, 'prompt=5', out
117
+ end
118
+ end
119
+
120
+ def test_evidence_lists_events_addresses_and_call_ids
121
+ TmpFile.with_dir do |dir|
122
+ parent, _worker = fixture_b(dir)
123
+
124
+ out, _err, status = prov('--evidence', parent)
125
+ assert status.success?
126
+ assert_include out, 'Direct inference events'
127
+
128
+ # Every direct event appears exactly once, with its id.
129
+ %w[w1 w2 w3 p1].each do |id|
130
+ rows = out.lines.count { |line| line =~ /^\s*#{id}\s/ }
131
+ assert_equal 1, rows, "#{id} should appear once, got #{rows}\n#{out}"
132
+ end
133
+
134
+ # Receipt evidence keeps its parent output address and call id.
135
+ assert_match(/agent_meta parent\.chat:2\[agent_meta,0\] call=a1/, out)
136
+ assert_match(/agent_meta parent\.chat:2\[agent_meta,1\] call=a1/, out)
137
+
138
+ # Chat-side evidence for the merged events.
139
+ assert_match(/chat_meta agent\.chat:3/, out)
140
+
141
+ # Status column; nothing is receipt-only, conflicting or duplicated here.
142
+ assert_include out, 'counted once'
143
+ assert_not_include out, 'Receipt-only events'
144
+ assert_not_include out, 'conflict'
145
+ assert_not_include out, 'Job projection references'
146
+ end
147
+ end
148
+
149
+ def test_tree_marks_delegated_jobs_only
150
+ TmpFile.with_dir do |dir|
151
+ parent, _worker, _critic, _dep = fixture_c(dir)
152
+
153
+ out, _err, status = prov(parent)
154
+ assert status.success?
155
+
156
+ # Worker is reached through a receipt (relation :agent_job) ...
157
+ assert_match(/delegated-job (Default_)?w\b/, out)
158
+ # ... while the Critic, referenced by an ordinary local meta job= line
159
+ # (relation :job), keeps the plain job rendering.
160
+ assert_match(/^\s*job\s+(Default_)?c\b/, out)
161
+ assert_not_match(/delegated-job (Default_)?c\b/, out)
162
+
163
+ # Delegated usage of the parent chat is annotated once.
164
+ assert_equal 1, out.lines.count { |line| line.include?('delegated receipt:') }
165
+ end
166
+ end
167
+
168
+ def test_flow_and_dot_render_delegated_result_without_extra_nodes
169
+ TmpFile.with_dir do |dir|
170
+ parent, _worker, _critic, _dep = fixture_c(dir)
171
+
172
+ out, _err, status = prov('--flow', parent)
173
+ assert status.success?
174
+ assert_include out, 'delegated_result'
175
+ kinds = out.lines.grep(/^\[\s*\d+\] (Job|Chat)/).collect { |line| line.strip[/\] (\w+)/, 1] }
176
+ assert kinds.all? { |kind| %w[Job Chat].include?(kind) }, out
177
+
178
+ dot_file = File.join(dir, 'prov.dot')
179
+ out2, _err2, status2 = prov('--dot', dot_file, parent)
180
+ assert status2.success?
181
+ dot = File.read(dot_file)
182
+
183
+ assert_include dot, 'delegated_result'
184
+ shapes = dot.scan(/^\s+n\d+ \[shape=(\w+)/).flatten
185
+ # Only chat/job node kinds exist: 3 jobs (box) + the parent chat (note).
186
+ assert shapes.all? { |shape| %w[box note].include?(shape) }, dot
187
+ assert_equal 4, shapes.length, dot
188
+ assert_not_include dot, 'agent_meta'
189
+ end
190
+ end
191
+
192
+ def test_component_mode_prints_scopes_only_with_receipts
193
+ TmpFile.with_dir do |dir|
194
+ parent, _worker = fixture_b(dir)
195
+
196
+ out, _err, status = prov('--component', parent)
197
+ assert status.success?
198
+ assert_include out, 'evidence deduplicated_total:'
199
+ assert_include out, 'evidence chat_evidence:'
200
+ assert_include out, 'evidence receipt_evidence:'
201
+ assert_include out, 'evidence receipt_only:'
202
+
203
+ expected = Chat.provenance_token_totals(parent)
204
+ assert_match(/evidence deduplicated_total: total=#{expected[:tt]}/, out)
205
+
206
+ # Coverage figures are explicitly labelled as overlapping; nothing in
207
+ # the output invites summing them.
208
+ assert_include out, 'overlaps chat_evidence', out
209
+ assert_include out, 'no saved chat/log', out
210
+
211
+ # No conflicts in this fixture: no non-authoritative label.
212
+ assert_not_include out, 'not exact', out
213
+ end
214
+
215
+ TmpFile.with_dir do |dir|
216
+ plain = write_chat(dir, 'plain.chat',
217
+ "user: hi\nmeta: pt=1 ct=1 tt=2 inference_id=x1\nassistant: done\n")
218
+
219
+ out, _err, status = prov('--component', plain)
220
+ assert status.success?
221
+ assert_not_include out, 'evidence deduplicated_total:', 'legacy chat must not gain scope lines'
222
+ assert_not_include out, 'evidence chat_evidence:'
223
+ assert_not_include out, 'evidence receipt_evidence:'
224
+ assert_not_include out, 'evidence receipt_only:'
225
+ assert_not_include out, 'delegated receipt:'
226
+ end
227
+ end
228
+
229
+ def test_malformed_receipt_renders_with_warning
230
+ TmpFile.with_dir do |dir|
231
+ payload = {'id' => 'bad1', 'result' => 'x', 'agent_meta' => 'oops'}
232
+ text = "user: hi\n" +
233
+ 'function_call: ' + %({"name":"ask","arguments":{},"id":"bad1"}) + "\n" +
234
+ 'function_call_output: ' + JSON.generate(payload) + "\n" +
235
+ "meta: pt=10 ct=5 tt=15 inference_id=p9\n" +
236
+ "assistant: done\n"
237
+ bad = write_chat(dir, 'bad.chat', text)
238
+
239
+ out, err, status = prov(bad)
240
+ assert status.success?, 'a malformed receipt must never abort rendering'
241
+
242
+ assert_include out, 'total=15', out
243
+ assert_include err, 'agent_meta receipt:', err
244
+ assert_include err, 'not_an_array', err
245
+ assert_include err, 'bad1', err
246
+ # The specific receipt line replaces the generic traversal warning, so a
247
+ # malformed receipt must not print both lines.
248
+ assert_not_include err, 'Incomplete provenance at', err
249
+ end
250
+ end
251
+
252
+ def test_evidence_compact_legacy_ids_and_zero_scope
253
+ TmpFile.with_dir do |dir|
254
+ # Receipt-only chat with a legacy (no inference_id) meta: local scope has
255
+ # no events at all.
256
+ legacy = write_chat(dir, 'legacy.chat',
257
+ receipt_chat_text({'r1' => [meta_receipt('pt=3 tt=5')]}))
258
+
259
+ out, _err, status = prov('--component', legacy)
260
+ assert status.success?
261
+ assert_include out, 'evidence chat_evidence: total=0', out
262
+ assert_include out, 'evidence receipt_evidence: total=5'
263
+ assert_include out, 'evidence receipt_only: total=5'
264
+
265
+ out, _err, status = prov('--evidence', legacy)
266
+ assert status.success?
267
+ # Legacy receipt events show their receipt location instead of the raw
268
+ # address array, and they are flagged as possibly overcounted.
269
+ assert_match(/receipt=legacy\.chat:2\[agent_meta,0\]/, out)
270
+ assert_include out, 'legacy unresolved'
271
+ assert_not_include out, 'receipt=["', out
272
+ end
273
+ end
274
+ end
@@ -0,0 +1,240 @@
1
+ require File.expand_path(__FILE__).sub(%r(/test/.*), '/test/test_helper.rb')
2
+ require 'scout/llm/chat'
3
+
4
+ class TestChatProvenance < Test::Unit::TestCase
5
+ def test_does_not_follow_imports
6
+ TmpFile.with_dir do |dir|
7
+ imported = File.join(dir, 'imported.chat')
8
+ root = File.join(dir, 'root.chat')
9
+ File.write(imported, "user: Imported\n")
10
+ File.write(root, "import: imported.chat\nuser: Root\n")
11
+
12
+ visits = Chat.traverse_provenance(root).to_a
13
+ # Only the root chat is visited; imports are not part of provenance.
14
+ assert_equal 1, visits.length
15
+ assert_equal :chat, visits.first[0]
16
+ assert_nil visits.first[4]
17
+ assert_equal File.expand_path(root), Chat.provenance_path(visits.first[0], visits.first[1])
18
+ end
19
+ end
20
+
21
+ def test_does_not_follow_continue_or_last
22
+ TmpFile.with_dir do |dir|
23
+ continued = File.join(dir, 'continued.chat')
24
+ last_chat = File.join(dir, 'last_chat.chat')
25
+ root = File.join(dir, 'root.chat')
26
+ File.write(continued, "user: Continued\n")
27
+ File.write(last_chat, "user: Last\n")
28
+ File.write(root, "continue: continued.chat\nlast: last_chat.chat\nuser: Root\n")
29
+
30
+ visits = Chat.traverse_provenance(root).to_a
31
+ # Only the root chat is visited; continue and last are not part of provenance.
32
+ assert_equal 1, visits.length
33
+ assert_equal :chat, visits.first[0]
34
+ assert_equal File.expand_path(root), Chat.provenance_path(visits.first[0], visits.first[1])
35
+ end
36
+ end
37
+
38
+ def test_provenance_relations_does_not_include_import
39
+ assert_not_include Chat::PROVENANCE_RELATIONS, :import
40
+ assert_not_include Chat::PROVENANCE_RELATIONS, :continue
41
+ assert_not_include Chat::PROVENANCE_RELATIONS, :last
42
+ assert_equal %i[job dependency log result agent_job], Chat::PROVENANCE_RELATIONS
43
+ end
44
+
45
+ def test_traverses_job_and_log_relations
46
+ TmpFile.with_dir do |dir|
47
+ root = File.join(dir, 'root.chat')
48
+ # A chat with a job reference. The job will not be loadable, so use on_error.
49
+ File.write(root, "user: Root\njob: Agent/Worker/ask/Default_abcd1234\n")
50
+
51
+ errors = []
52
+ visits = Chat.traverse_provenance(root, on_error: ->(*args) { errors << args }).to_a
53
+ # Root chat is always visited. The job reference is attempted but may fail.
54
+ chat_visits = visits.select { |kind, *_rest| kind == :chat }
55
+ assert chat_visits.any? { |kind, object, *_rest| Chat.provenance_path(kind, object) == File.expand_path(root) }
56
+ end
57
+ end
58
+
59
+ def test_imports_in_chat_do_not_produce_warnings
60
+ TmpFile.with_dir do |dir|
61
+ root = File.join(dir, 'root.chat')
62
+ File.write(root, "import: nonexistent.chat\nuser: Root\n")
63
+
64
+ errors = []
65
+ visits = Chat.traverse_provenance(root, on_error: ->(*args) { errors << args }).to_a
66
+ # Import references are never resolved during provenance, so no errors.
67
+ assert_equal 1, visits.length
68
+ assert_empty errors
69
+ end
70
+ end
71
+
72
+ end
73
+
74
+ require_relative 'agent_meta_fixtures'
75
+
76
+ # NEW save-layout (step 3) provenance expectations. The producer step changed
77
+ # the on-disk location of delegated agent chats:
78
+ #
79
+ # direct delegated chat <chat>.files/agent.chat (or <name>.chat)
80
+ # society chats <chat>.files/agent.society/<Agent>/<conv>/agent.chat
81
+ # legacy (read-only) <chat>.files/log/... and <chat>.files/log/society/...
82
+ #
83
+ # These tests only exercise the traversal/read side; writing the new layout is
84
+ # covered by test/scout/llm/agent/test_save.rb.
85
+ class TestNewLayoutProvenance < Test::Unit::TestCase
86
+ include AgentMetaFixtures
87
+
88
+ ROOT_CHAT = "user: hi\nmeta: pt=2 ct=1 tt=3 inference_id=s0\nassistant: done\n"
89
+
90
+ def visits_paths(root, root_type: nil)
91
+ opts = {}
92
+ opts[:root_type] = root_type if root_type
93
+ Chat.traverse_provenance(root, **opts).to_a.collect { |_k, object, _pk, _p, _r, _f| object.to_s }
94
+ end
95
+
96
+ # (a) new-layout society sidecar traversed with :log from a saved chat root
97
+ def test_new_layout_society_sidecar_is_traversed_with_log_relation
98
+ TmpFile.with_dir do |dir|
99
+ chat = write_chat(dir, 'saved.chat', ROOT_CHAT)
100
+
101
+ society = File.expand_path(File.join(chat + '.files', 'agent.society', 'Direct', 'harness_test'))
102
+ FileUtils.mkdir_p(society)
103
+ File.write(File.join(society, 'agent.chat'),
104
+ "user: work\nmeta: pt=10 ct=5 tt=15 inference_id=w1\nassistant: ok\n")
105
+
106
+ errors = []
107
+ visits = Chat.traverse_provenance(chat, on_error: ->(*args) { errors << args }).to_a
108
+ assert_empty errors
109
+
110
+ found = visits.find do |_kind, object, _pk, _parent, _relation, _first|
111
+ object.to_s == File.join(society, 'agent.chat')
112
+ end
113
+ assert found, 'new-layout society chat not visited'
114
+ kind, object, parent_kind, parent, relation, first = found
115
+ assert_equal :chat, kind
116
+ assert_equal :chat, parent_kind
117
+ assert_equal File.expand_path(chat), parent.to_s
118
+ assert_equal :log, relation
119
+ assert first
120
+
121
+ # Aggregation is preserved across layouts.
122
+ assert_equal({pt: 12, ct: 6, tt: 18, cct: 0, cwt: 0, rt: 0},
123
+ Chat.provenance_token_totals(chat))
124
+ end
125
+ end
126
+
127
+ # (b) new-layout root copy not traversed; legacy root copy still excluded
128
+ def test_new_layout_root_copy_is_not_traversed
129
+ TmpFile.with_dir do |dir|
130
+ chat = write_chat(dir, 'saved.chat', ROOT_CHAT)
131
+
132
+ files = File.expand_path(chat + '.files')
133
+ FileUtils.mkdir_p(File.join(files, 'agent.society', 'Direct', 'harness_test'))
134
+ FileUtils.mkdir_p(File.join(files, 'log', 'society', 'Worker', 'default'))
135
+ # Root copies in BOTH layouts: full copy of the root conversation.
136
+ File.write(File.join(files, 'agent.chat'), ROOT_CHAT)
137
+ File.write(File.join(files, 'log', 'agent.chat'), ROOT_CHAT)
138
+ # Independent society conversations in both layouts.
139
+ File.write(File.join(files, 'agent.society', 'Direct', 'harness_test', 'agent.chat'),
140
+ "user: work\nmeta: pt=10 ct=5 tt=15 inference_id=w1\nassistant: ok\n")
141
+ File.write(File.join(files, 'log', 'society', 'Worker', 'default', 'agent.chat'),
142
+ "user: work2\nmeta: pt=20 ct=10 tt=30 inference_id=w2\nassistant: ok\n")
143
+
144
+ paths = visits_paths(chat)
145
+
146
+ new_root_copy = File.join(files, 'agent.chat')
147
+ legacy_root_copy = File.join(files, 'log', 'agent.chat')
148
+ assert_not_include paths, new_root_copy,
149
+ 'new-layout root copy must not become its own node'
150
+ assert_not_include paths, legacy_root_copy,
151
+ 'legacy root copy must stay excluded'
152
+ assert_include paths, File.join(files, 'agent.society', 'Direct', 'harness_test', 'agent.chat')
153
+ assert_include paths, File.join(files, 'log', 'society', 'Worker', 'default', 'agent.chat')
154
+
155
+ edges = Chat.provenance_edges(chat).collect { |e| [e[:from].to_s, e[:to].to_s, e[:relation]] }
156
+ assert_empty edges.select { |_from, to, _r| to == new_root_copy }
157
+ assert_empty edges.select { |_from, to, _r| to == legacy_root_copy }
158
+ end
159
+ end
160
+
161
+ # (c) legacy log/ sidecar still traversed (compat) and (d) no duplicate
162
+ # visits when both layouts coexist.
163
+ def test_both_layouts_coexist_without_duplicates
164
+ TmpFile.with_dir do |dir|
165
+ chat = write_chat(dir, 'saved.chat', ROOT_CHAT)
166
+
167
+ files = File.expand_path(chat + '.files')
168
+ FileUtils.mkdir_p(File.join(files, 'agent.society', 'Direct', 'harness_test'))
169
+ FileUtils.mkdir_p(File.join(files, 'log', 'society', 'Worker', 'default'))
170
+
171
+ File.write(File.join(files, 'agent.society', 'Direct', 'harness_test', 'agent.chat'),
172
+ "user: work\nmeta: pt=10 ct=5 tt=15 inference_id=w1\nassistant: ok\n")
173
+ File.write(File.join(files, 'log', 'society', 'Worker', 'default', 'agent.chat'),
174
+ "user: work2\nmeta: pt=20 ct=10 tt=30 inference_id=w2\nassistant: ok\n")
175
+
176
+ visits = Chat.traverse_provenance(chat).to_a
177
+ paths = visits.collect { |_k, object, _pk, _p, _r, _f| object.to_s }
178
+ assert_equal paths.uniq.length, paths.length, 'duplicate visits when layouts coexist'
179
+
180
+ # Legacy relation is unchanged.
181
+ legacy = visits.find do |_kind, object, _pk, _parent, relation, _first|
182
+ relation == :log && object.to_s.end_with?('log/society/Worker/default/agent.chat')
183
+ end
184
+ assert legacy, 'legacy log/ sidecar must keep being traversed'
185
+ assert_equal :chat, legacy[0]
186
+
187
+ # Tokens are counted exactly once.
188
+ assert_equal({pt: 32, ct: 16, tt: 48, cct: 0, cwt: 0, rt: 0},
189
+ Chat.provenance_token_totals(chat))
190
+ end
191
+ end
192
+
193
+ # Jobs are the other root type that owns a .files sidecar.
194
+ def test_new_layout_job_files_are_traversed
195
+ TmpFile.with_dir do |dir|
196
+ job = make_job(dir, 'Worker/ask/Default_w')
197
+ files = job + '.files'
198
+ FileUtils.mkdir_p(File.join(files, 'agent.society', 'Critic', 'default'))
199
+
200
+ # A job's own top-level chat IS traversed (renderers hide it).
201
+ File.write(File.join(files, 'agent.chat'),
202
+ "user: work\nmeta: pt=10 ct=5 tt=15 inference_id=w1\nassistant: ok\n")
203
+ File.write(File.join(files, 'agent.society', 'Critic', 'default', 'agent.chat'),
204
+ "user: critic\nmeta: pt=4 ct=2 tt=6 inference_id=c1\nassistant: ok\n")
205
+
206
+ # A bare job path is only treated as a job with root_type: :job (the
207
+ # CLI decides job vs chat through the .info sidecar).
208
+ paths = visits_paths(job, root_type: :job)
209
+ assert_include paths, File.expand_path(File.join(files, 'agent.chat'))
210
+ assert_include paths, File.expand_path(File.join(files, 'agent.society', 'Critic', 'default', 'agent.chat'))
211
+
212
+ # The collector also keeps both (a bare job path needs root_type: :job
213
+ # here too; without it the traversal would treat it as a chat file).
214
+ assert_equal({pt: 14, ct: 7, tt: 21, cct: 0, cwt: 0, rt: 0},
215
+ Chat.provenance_token_totals(job, root_type: :job))
216
+ end
217
+ end
218
+
219
+ # Nothing outside the three glob families may be swept.
220
+ def test_files_outside_the_glob_families_are_not_swept
221
+ TmpFile.with_dir do |dir|
222
+ chat = write_chat(dir, 'saved.chat', ROOT_CHAT)
223
+
224
+ files = File.expand_path(chat + '.files')
225
+ resets = File.join(files, 'agent.chat.files', 'resets')
226
+ FileUtils.mkdir_p(resets)
227
+ File.write(File.join(resets, 'reset-1.chat'),
228
+ "user: reset\nmeta: pt=9 ct=9 tt=18 inference_id=r1\nassistant: ok\n")
229
+ # Second-order .files tree that must stay invisible.
230
+ other = File.join(files, 'other.files', 'agent.chat')
231
+ FileUtils.mkdir_p(File.dirname(other))
232
+ File.write(other, "user: other\nmeta: pt=9 ct=9 tt=18 inference_id=o1\nassistant: ok\n")
233
+
234
+ assert_not_include visits_paths(chat), File.join(resets, 'reset-1.chat')
235
+ assert_not_include visits_paths(chat), other
236
+ assert_equal({pt: 2, ct: 1, tt: 3, cct: 0, cwt: 0, rt: 0},
237
+ Chat.provenance_token_totals(chat))
238
+ end
239
+ end
240
+ end