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,91 @@
1
+ require File.expand_path(__FILE__).sub(%r(/test/.*), '/test/test_helper.rb')
2
+
3
+ # Integration (real infrastructure) copy of test/scout/model/test_base.rb:
4
+ # test_R_model runs R code and needs the e1071 package. The trivial
5
+ # ScoutModel tests stay unit-side in test/scout/model/test_base.rb.
6
+ # Run with `rake test_integration`.
7
+ #
8
+ # Conditional omission: the probe checks for Rscript and the installed R
9
+ # packages before running, and omits with the detected reason otherwise.
10
+ class TestClass < Test::Unit::TestCase
11
+ def test_R_model
12
+ omit Availability.r_packages_reason('e1071') unless Availability.r_packages?('e1071')
13
+ require 'rbbt-util'
14
+ require 'rbbt/util/R'
15
+
16
+ text =<<-EOF
17
+ 1 0;1;1
18
+ 1 1;0;1
19
+ 1 1;1;1
20
+ 1 0;1;1
21
+ 1 1;;1
22
+ 0 0;1;0
23
+ 0 1;0;0
24
+ 0 0;1;0
25
+ 0 1;0;0
26
+ EOF
27
+
28
+ TmpFile.with_file do |dir|
29
+ Open.mkdir dir
30
+ model = ScoutModel.new dir
31
+
32
+ model.extract_features do |sample|
33
+ sample.split(";")
34
+ end
35
+
36
+ model.train do |list,labels|
37
+ TmpFile.with_file do |feature_file|
38
+ Open.write(feature_file, list.collect{|feats| feats * "\t"} * "\n")
39
+ Open.write(feature_file + '.class', labels * "\n")
40
+ R.run <<-EOF
41
+ features = read.table("#{ feature_file }", sep ="\\t", stringsAsFactors=FALSE);
42
+ labels = scan("#{ feature_file }.class", what=numeric());
43
+ features = cbind(features, class = labels);
44
+ rbbt.require('e1071')
45
+ model = svm(class ~ ., data = features)
46
+ save(model, file="#{ state_file }");
47
+ EOF
48
+ end
49
+ end
50
+
51
+ model.eval do |features|
52
+ TmpFile.with_file do |feature_file|
53
+ TmpFile.with_file do |results|
54
+ Open.write(feature_file, features * "\t")
55
+ R.run <<-EOF
56
+ features = read.table("#{ feature_file }", sep ="\\t", stringsAsFactors=FALSE);
57
+ library(e1071)
58
+ load(file="#{ state_file }")
59
+ label = predict(model, features);
60
+ cat(label, file="#{results}");
61
+ EOF
62
+
63
+ Open.read(results)
64
+ end
65
+ end
66
+ end
67
+
68
+ text.split(/\n/).each do |line|
69
+ label, sample = line.split(" ")
70
+ model.add(sample, label)
71
+ end
72
+
73
+ model.train
74
+
75
+ assert model.eval("1;1;1").to_f > 0.5
76
+ assert model.eval("0;0;0").to_f < 0.5
77
+
78
+ model.save
79
+
80
+ model = ScoutModel.new dir
81
+ assert model.eval("1;1;1").to_f > 0.5
82
+ assert model.eval("0;0;0").to_f < 0.5
83
+
84
+ model.post_process do |result|
85
+ result.to_f < 0.5 ? :bad : :good
86
+ end
87
+
88
+ assert_equal :bad, model.eval("0;0;0")
89
+ end
90
+ end
91
+ end
@@ -8,7 +8,13 @@ class TestAgent < Test::Unit::TestCase
8
8
  a = LLM::Agent.new
9
9
  a.start_chat.system 'you are a robot'
10
10
  a.user "hi"
11
- ppp a.print
11
+
12
+ output = a.print
13
+ assert_kind_of String, output
14
+
15
+ # ScoutCoder: Chat.print renders each message as "role:\n\ncontent" (roles in
16
+ # the meta list are inlined instead); blocks are joined by a blank line and
17
+ # the whole string starts with a newline.
18
+ assert_equal "\nsystem:\n\nyou are a robot\n\nuser:\n\nhi", output
12
19
  end
13
20
  end
14
-
@@ -0,0 +1,413 @@
1
+ require File.expand_path(__FILE__).sub(%r(/test/.*), '/test/test_helper.rb')
2
+ require 'scout/llm/agent'
3
+
4
+ class TestLLMAgentSave < Test::Unit::TestCase
5
+ def setup
6
+ super
7
+ @dir = File.join(tmpdir, 'save_tests')
8
+ FileUtils.mkdir_p @dir
9
+ end
10
+
11
+ def simple_agent(content = 'you are a robot')
12
+ agent = LLM::Agent.new
13
+ agent.start_chat.system content
14
+ agent
15
+ end
16
+
17
+ def chat_files_under(dir)
18
+ Dir.glob(File.join(dir, '**', '*.chat')).sort
19
+ end
20
+
21
+ def test_society_dir_for
22
+ assert_equal 'a/b/c.society',
23
+ LLM::Agent.society_dir_for('a/b/c.chat')
24
+ assert_equal 'a/b/worker.society',
25
+ LLM::Agent.society_dir_for('a/b/worker.chat')
26
+ assert_equal './c.society',
27
+ LLM::Agent.society_dir_for('c.chat')
28
+ agent = LLM::Agent.new
29
+ assert_equal 'a/b/c.society',
30
+ agent.society_dir_for('a/b/c.chat')
31
+ end
32
+
33
+ def test_society_dir_by_location
34
+ agent = LLM::Agent.new
35
+
36
+ nested = 'a/society/Worker/w_1/agent.chat'
37
+ # A nested agent.chat nests its society as a SIBLING directory, never a
38
+ # second `.files` tree of its own, however the save was triggered.
39
+ assert_equal 'a/society/Worker/w_1/society', agent.society_dir(nested)
40
+ assert agent.nested_save?(nested)
41
+
42
+ assert_equal 'a/b/c.society', agent.society_dir('a/b/c.chat')
43
+ assert !agent.nested_save?('a/b/c.chat')
44
+ end
45
+
46
+ def test_save_explicit_path_writes_only_the_chat_file
47
+ TmpFile.with_dir do |dir|
48
+ agent = simple_agent
49
+ agent.user 'hello'
50
+
51
+ chat_file = File.join(dir, 'conversation.chat')
52
+ written = agent.save(chat_file)
53
+
54
+ assert_equal [File.expand_path(chat_file)], written
55
+ assert Open.exist?(chat_file)
56
+ assert_include Open.read(chat_file), 'hello'
57
+
58
+ # Lazy: no society, so no .files sidecar tree at all
59
+ assert !Open.exist?(chat_file + '.files')
60
+ end
61
+ end
62
+
63
+ def test_save_without_path_uses_save_file
64
+ TmpFile.with_dir do |dir|
65
+ agent = simple_agent
66
+ agent.user 'stored'
67
+
68
+ chat_file = File.join(dir, 'auto.chat')
69
+ agent.save_file = chat_file
70
+
71
+ assert_equal [File.expand_path(chat_file)], agent.save
72
+ assert_include Open.read(chat_file), 'stored'
73
+ end
74
+ end
75
+
76
+ def test_save_without_path_raises_when_unconfigured
77
+ agent = simple_agent
78
+ agent.user 'orphan'
79
+
80
+ assert_raise ScoutException do
81
+ agent.save
82
+ end
83
+ end
84
+
85
+ def test_save_nested_society
86
+ TmpFile.with_dir do |dir|
87
+ root = simple_agent('root')
88
+ root.user 'root question'
89
+
90
+ child = simple_agent('child')
91
+ child.user 'child question'
92
+ root.chats = { 'Worker/w_A' => child }
93
+
94
+ grandchild = simple_agent('grandchild')
95
+ grandchild.user 'grandchild question'
96
+ child.chats = { 'Critic/c_1' => grandchild }
97
+
98
+ chat_file = File.join(dir, 'root.chat')
99
+ written = root.save(chat_file)
100
+
101
+ expected_child = File.join(chat_file.sub(/\.chat\z/, '.society'), 'Worker', 'w_A', 'agent.chat')
102
+ expected_grandchild = File.join(chat_file.sub(/\.chat\z/, '.society'), 'Worker', 'w_A', 'society', 'Critic', 'c_1', 'agent.chat')
103
+
104
+ assert_equal [chat_file, expected_child, expected_grandchild].collect { |p| File.expand_path(p) }.sort, written
105
+
106
+ assert_include Open.read(expected_child), 'child question'
107
+ assert_include Open.read(expected_grandchild), 'grandchild question'
108
+
109
+ # child learned its own save_file for later independent auto-saves
110
+ assert_equal expected_child, child.save_file
111
+ assert_equal expected_grandchild, grandchild.save_file
112
+ end
113
+ end
114
+
115
+ def test_save_cyclic_society_terminates
116
+ TmpFile.with_dir do |dir|
117
+ a = simple_agent('a')
118
+ a.user 'a question'
119
+ b = simple_agent('b')
120
+ b.user 'b question'
121
+
122
+ a.chats = { 'Other/current' => b }
123
+ b.chats = { 'First/current' => a }
124
+
125
+ chat_file = File.join(dir, 'cycle.chat')
126
+ written = a.save(chat_file)
127
+
128
+ expected_b = File.join(chat_file.sub(/\.chat\z/, '.society'), 'Other', 'current', 'agent.chat')
129
+ assert_equal [chat_file, expected_b].collect { |p| File.expand_path(p) }.sort, written
130
+
131
+ # The tree is finite: b was saved once and going back to a was cut by
132
+ # the seen-agent check, so nothing was written twice.
133
+ assert_equal 1, chat_files_under(chat_file.sub(/\.chat\z/, '.society')).length
134
+ end
135
+ end
136
+
137
+ def test_save_skips_dead_entries
138
+ TmpFile.with_dir do |dir|
139
+ agent = simple_agent
140
+ agent.chats = { 'Worker/w_1' => nil, 'no-slash' => simple_agent, '/lead' => simple_agent }
141
+
142
+ chat_file = File.join(dir, 'dead.chat')
143
+ written = agent.save(chat_file)
144
+
145
+ assert_equal [File.expand_path(chat_file)], written
146
+ end
147
+ end
148
+
149
+ def test_save_sanitizes_weird_names
150
+ TmpFile.with_dir do |dir|
151
+ agent = simple_agent
152
+ bad = simple_agent('weird')
153
+ bad.user 'weird question'
154
+
155
+ agent.chats = { '../evil/name' => bad }
156
+
157
+ chat_file = File.join(dir, 'sanitized.chat')
158
+ written = agent.save(chat_file)
159
+
160
+ # '..' collapses to '_' and the '/' inside the conversation is
161
+ # replaced, so the file stays inside the society directory
162
+ weird_file = File.join(chat_file.sub(/\.chat\z/, '.society'), '__', 'evil_name', 'agent.chat')
163
+ assert_include written.collect { |p| File.expand_path(p) },
164
+ File.expand_path(weird_file)
165
+ assert !Open.exist?(File.join(dir, 'name', 'agent.chat'))
166
+ end
167
+ end
168
+
169
+ def test_auto_save_after_ask
170
+ TmpFile.with_dir do |dir|
171
+ LLM::Mock.script('mock answer')
172
+
173
+ agent = simple_agent
174
+ chat_file = File.join(dir, 'auto_save.chat')
175
+ agent.save_file = chat_file
176
+
177
+ agent.user 'auto question'
178
+ res = agent.chat persist: false, endpoint: 'mock'
179
+
180
+ assert_equal 'mock answer', res
181
+ content = Open.read(chat_file)
182
+ assert_include content, 'auto question'
183
+ assert_include content, 'mock answer'
184
+ end
185
+ end
186
+
187
+ def test_child_auto_save_keeps_canonical_layout
188
+ TmpFile.with_dir do |dir|
189
+ LLM::Mock.script('mock answer')
190
+
191
+ root = simple_agent('root')
192
+ root.user 'root question'
193
+
194
+ child = simple_agent('child')
195
+ child.user 'child question'
196
+ root.chats = { 'Worker/w_A' => child }
197
+
198
+ chat_file = File.join(dir, 'root.chat')
199
+ root.save(chat_file)
200
+
201
+ child_file = File.join(chat_file.sub(/\.chat\z/, '.society'), 'Worker', 'w_A', 'agent.chat')
202
+ assert_equal child_file, child.save_file
203
+
204
+ # The child runs its own turn later (its own auto-save, no parent save):
205
+ # the answer must land in the child chat file and the grandchild must
206
+ # go to the SIBLING society dir, never to <child_file>.files/...
207
+ grandchild = simple_agent('grandchild')
208
+ grandchild.user 'grandchild question'
209
+ child.chats = { 'Critic/c_1' => grandchild }
210
+
211
+ child.chat persist: false, endpoint: 'mock'
212
+
213
+ assert_include Open.read(child_file), 'mock answer'
214
+
215
+ assert Open.exist?(File.join(File.dirname(child_file), 'society', 'Critic', 'c_1', 'agent.chat'))
216
+ assert !Open.exist?(child_file + '.files')
217
+ assert !Dir.glob(File.join(chat_file + '.files', '**', 'agent.chat.files')).any?
218
+ end
219
+ end
220
+
221
+ # A bare agent.ask round does not grow current_chat, so it must NOT
222
+ # auto-save: the auto-save hook fires on Agent#chat, not on ask.
223
+ def test_no_auto_save_after_bare_ask
224
+ TmpFile.with_dir do |dir|
225
+ # Plain-string script: no tool calls, so the round is a single
226
+ # assistant answer that never enters Agent#chat.
227
+ LLM::Mock.script('mock answer')
228
+
229
+ agent = simple_agent
230
+ chat_file = File.join(dir, 'ask_save.chat')
231
+ agent.save_file = chat_file
232
+ agent.user 'bare question'
233
+
234
+ assert_include agent.ask(agent.current_chat, persist: false, endpoint: :mock), 'mock answer'
235
+
236
+ # Bare ask: the chat was not grown by Agent#chat, so no file anywhere
237
+ assert !Open.exist?(chat_file)
238
+ assert_equal [], Dir.glob(File.join(dir, '**', '*.chat'))
239
+ end
240
+ end
241
+
242
+ # The chat path does persist growth: a round with a delegated tool call
243
+ # grows current_chat inside Agent#chat (function_call /
244
+ # function_call_output plus the answer), so the auto-save fires, and the
245
+ # delegated child conversation is saved with it into the society tree.
246
+ def test_auto_save_after_tool_round_via_chat
247
+ TmpFile.with_dir do |dir|
248
+ LLM::Mock.script([
249
+ {tool_calls: [{name: 'ask', arguments: {agent: 'Worker', prompt: 'hi'}}]},
250
+ 'final answer'
251
+ ])
252
+
253
+ agent = simple_agent('root')
254
+ worker = simple_agent('worker')
255
+ agent.society = { 'Worker' => worker }
256
+ agent.socialize
257
+
258
+ chat_file = File.join(dir, 'tool_round.chat')
259
+ agent.save_file = chat_file
260
+ agent.user 'tool question'
261
+
262
+ assert_equal 'final answer', agent.chat(persist: false, endpoint: 'mock')
263
+
264
+ # The parent chat file holds the tool round and its answer
265
+ content = Open.read(chat_file)
266
+ assert_include content, 'tool question'
267
+ assert_include content, 'function_call'
268
+ assert_include content, 'final answer'
269
+
270
+ # The delegated child conversation was saved with it, lazily, under the
271
+ # canonical society layout
272
+ child_file = File.join(chat_file.sub(/\.chat\z/, '.society'), 'Worker', 'default', 'agent.chat')
273
+ assert Open.exist?(child_file)
274
+ assert_include Open.read(child_file), 'hi'
275
+ end
276
+ end
277
+
278
+ def test_no_auto_save_without_save_file
279
+ TmpFile.with_dir do |dir|
280
+ LLM::Mock.script('mock answer')
281
+
282
+ agent = simple_agent
283
+ agent.user 'volatile question'
284
+ agent.chat persist: false, endpoint: 'mock'
285
+
286
+ assert !Open.exist?(File.join(dir, 'never.chat'))
287
+ assert_equal [], Dir.glob(File.join(dir, '**', '*.chat'))
288
+ end
289
+ end
290
+
291
+ def test_restart_snapshot
292
+ TmpFile.with_dir do |dir|
293
+ agent = simple_agent
294
+ chat_file = File.join(dir, 'restarts.chat')
295
+ agent.save_file = chat_file
296
+
297
+ agent.user 'before restart'
298
+ agent.save
299
+
300
+ resets_dir = chat_file + '.files/resets'
301
+ assert !Open.exist?(resets_dir)
302
+
303
+ agent.start
304
+
305
+ assert Open.exist?(resets_dir)
306
+ snapshots = Dir.glob(File.join(resets_dir, '*.chat'))
307
+ assert_equal 1, snapshots.length
308
+ assert_include File.basename(snapshots.first), '.chat'
309
+ assert_include Open.read(snapshots.first), 'before restart'
310
+ end
311
+ end
312
+
313
+ def test_no_restart_snapshot_without_prior_chat
314
+ TmpFile.with_dir do |dir|
315
+ agent = simple_agent
316
+ chat_file = File.join(dir, 'fresh.chat')
317
+ agent.save_file = chat_file
318
+
319
+ agent.start
320
+
321
+ assert !Open.exist?(chat_file + '.files/resets')
322
+ assert !Open.exist?(chat_file + '.files')
323
+ end
324
+ end
325
+ end
326
+
327
+ # Step 3b: Agent#society_save_file (private, delegate.rb) must derive the
328
+ # society directory with the CANONICAL rule (Agent.society_dir_for /
329
+ # Agent#society_dir), never by unanchored string substitution on save_file.
330
+ #
331
+ # The bug: `"cli.chat.files/agent.chat".sub(/\.chat/, '.society')` replaces
332
+ # the FIRST '.chat' and yields `cli.society.files/agent.chat/...`, growing a
333
+ # second `.files` tree instead of `cli.chat.files/agent.society/...`.
334
+ class TestLLMAgentSocietySaveFile < Test::Unit::TestCase
335
+ def simple_agent(content = 'you are a robot')
336
+ agent = LLM::Agent.new
337
+ agent.start_chat.system content
338
+ agent
339
+ end
340
+
341
+ # --- direct (private method, called through send) -----------------------
342
+
343
+ def test_society_save_file_is_nil_without_save_file
344
+ agent = LLM::Agent.new
345
+ # save.rb simply does not auto-save without a save_file; the delegation
346
+ # must mirror that instead of raising on nil (assigning nil to
347
+ # agent.save_file is harmless: it means "no auto-save").
348
+ assert_nil agent.send(:society_save_file, 'Direct', 'harness_test')
349
+ end
350
+
351
+ def test_society_save_file_cli_shaped_save_file
352
+ agent = LLM::Agent.new
353
+ agent.save_file = 'dir/cli.chat.files/agent.chat'
354
+ assert_equal 'dir/cli.chat.files/agent.society/Direct/harness_test/agent.chat',
355
+ agent.send(:society_save_file, 'Direct', 'harness_test')
356
+ end
357
+
358
+ def test_society_save_file_cli_shaped_named_agent
359
+ agent = LLM::Agent.new
360
+ agent.save_file = 'dir/job.chat.files/worker.chat'
361
+ assert_equal 'dir/job.chat.files/worker.society/Direct/harness_test/agent.chat',
362
+ agent.send(:society_save_file, 'Direct', 'harness_test')
363
+ end
364
+
365
+ def test_society_save_file_plain_root_chat
366
+ agent = LLM::Agent.new
367
+ agent.save_file = 'dir/root.chat'
368
+ assert_equal 'dir/root.society/Direct/harness_test/agent.chat',
369
+ agent.send(:society_save_file, 'Direct', 'harness_test')
370
+ end
371
+
372
+ def test_society_save_file_nested_child_save_file
373
+ agent = LLM::Agent.new
374
+ # A child chat already living inside a society tree resolves through the
375
+ # location rule: the society of a nested chat is a SIBLING 'society' dir.
376
+ agent.save_file = 'dir/root.chat.files/root.society/Direct/harness_test/agent.chat'
377
+ assert_equal 'dir/root.chat.files/root.society/Direct/harness_test/society/Nested/default/agent.chat',
378
+ agent.send(:society_save_file, 'Nested', 'default')
379
+ end
380
+
381
+ # --- public behavior ----------------------------------------------------
382
+
383
+ # load_chat hands the specialist its save_file derived from the PARENT's
384
+ # save_file: this is the path the CLI and chat_task jobs take.
385
+ def test_load_chat_assigns_canonical_child_save_file
386
+ TmpFile.with_dir do |dir|
387
+ root = simple_agent('root')
388
+ worker = simple_agent('worker')
389
+ root.society = {'Worker' => worker}
390
+ root.socialize
391
+
392
+ root.save_file = File.join(dir, 'cli.chat.files', 'agent.chat')
393
+ child = root.load_chat('Worker', {}, 'harness_test')
394
+
395
+ expected = File.join(dir, 'cli.chat.files', 'agent.society',
396
+ 'Worker', 'harness_test', 'agent.chat')
397
+ assert_equal expected, child.save_file
398
+
399
+ # Writing through the child must not grow any second `.files` tree and
400
+ # must not leave a stray `cli.society.files` directory behind.
401
+ LLM::Mock.script('mock answer')
402
+ child.user 'child question'
403
+ child.chat persist: false, endpoint: 'mock'
404
+
405
+ assert_include Open.read(expected), 'mock answer'
406
+ assert_empty Dir.glob(File.join(dir, '**', '*.society.files*'))
407
+ assert_empty Dir.glob(File.join(dir, '**', 'cli.society*'))
408
+ # The child auto-save is the only writer here (the root itself never
409
+ # ran a round), so the canonical child file is the ONLY chat file.
410
+ assert_equal [expected], Dir.glob(File.join(dir, '**', '*.chat')).sort
411
+ end
412
+ end
413
+ end
@@ -0,0 +1,110 @@
1
+ require File.expand_path(__FILE__).sub(%r(/test/.*), '/test/test_helper.rb')
2
+ require File.expand_path(__FILE__).sub(%r(.*/test/), '').sub(/test_(.*)\.rb/,'\1')
3
+
4
+ class TestWorkflowChatTaskSave < Test::Unit::TestCase
5
+ # NOTE on setup order: test_helper's `setup do` callback runs AFTER this
6
+ # `setup` method and does `Open.rm_rf tmpdir`, i.e. it deletes every
7
+ # directory under tmp/test_tmpdir — including any cwd we adopted here. So
8
+ # we must NOT Dir.chdir into a tmpdir-based directory, or every later
9
+ # FileUtils.pwd / Path.follow call raises Errno::ENOENT (getcwd) once the
10
+ # hook has removed it. The workflow does not depend on a particular cwd,
11
+ # so we only point the module's job directory at a fresh work dir (created
12
+ # with Open.mkdir so it exists regardless of Path semantics) and keep the
13
+ # process cwd untouched.
14
+ def setup
15
+ @tmp_work = Path.setup(File.join(TmpFile.tmpdir, 'workflow_chat_task_save'))
16
+ Open.mkdir @tmp_work
17
+ TestWorkflowChatTaskSaveWF.directory = @tmp_work
18
+ end
19
+
20
+ def teardown
21
+ FileUtils.rm_rf @tmp_work if File.exist?(@tmp_work)
22
+ end
23
+
24
+ def run_job(dir, task)
25
+ TestWorkflowChatTaskSaveWF.directory = Path.setup(dir)
26
+ job = TestWorkflowChatTaskSaveWF.job(task, chat: '')
27
+ job.produce
28
+ job
29
+ end
30
+
31
+ module TestWorkflowChatTaskSaveWF
32
+ extend Workflow
33
+ # `extend AgentWorkflow` alone does NOT copy the helper blocks into this
34
+ # module's helpers hash, so Workflow#job would extend the Step with an
35
+ # empty step_module and `log_agent` would be missing at run time.
36
+ # include_workflow merges the helpers (and tasks) properly.
37
+ include_workflow AgentWorkflow
38
+
39
+ self.name = 'TestWorkflowChatTaskSaveWF'
40
+
41
+ chat_task :plain do
42
+ LLM::Agent.new(start_chat: Chat.setup([{role: :system, content: 'You are a helper.'}, {role: :user, content: 'Say hi'}]))
43
+ end
44
+
45
+ chat_task :with_chat do
46
+ agent = LLM::Agent.new(start_chat: Chat.setup([{role: :system, content: 'You are a helper.'}]))
47
+ agent.user 'question one'
48
+ agent.current_chat.push({role: :assistant, content: 'answer one'})
49
+ agent
50
+ end
51
+ end
52
+
53
+ def test_no_files_without_society
54
+ job = run_job(TmpFile.tmpdir, :plain)
55
+ path = job.path
56
+ # The unified provenance save ALWAYS writes this job agent's own chat at
57
+ # `<path>.files/log/agent.chat` (that is the point of the mechanism);
58
+ # only the society subtree stays lazy and appears with a live society.
59
+ assert File.exist?("#{path}.files/agent.chat")
60
+ assert !File.exist?("#{path}.files/agent.society")
61
+ end
62
+
63
+ def test_canonical_layout_no_society
64
+ job = run_job(TmpFile.tmpdir, :with_chat)
65
+ path = job.path
66
+ # files_dir is the SUFFIXED sibling `<path>.files`, not a subdirectory
67
+ agent_chat = File.join("#{path}.files", 'agent.chat')
68
+ assert Open.exist?(agent_chat)
69
+ saved = LLM.chat(agent_chat)
70
+ assert_equal 'You are a helper.', saved.first[:content]
71
+ assert saved.any? { |m| m[:role] == 'assistant' && m[:content] == 'answer one' }
72
+ assert !File.exist?(File.join("#{path}.files", 'agent.society'))
73
+ end
74
+
75
+ def test_nested_society_layout
76
+ TmpFile.with_dir do |dir|
77
+ # ask_agent builds the specialist from this agent's society when the
78
+ # name is already there (LLM.load_agent would otherwise fail: there is
79
+ # no 'Worker' installed anywhere for these tests).
80
+ worker = LLM::Agent.new(start_chat: Chat.setup([{role: :system, content: 'child helper'}]))
81
+ LLM::Mock.script('child answer')
82
+
83
+ agent = LLM::Agent.new(start_chat: Chat.setup([{role: :system, content: 'parent'}]))
84
+ agent.society = { 'Worker' => worker }
85
+ agent.save_file = File.join(dir, 'agent.chat')
86
+ agent.user 'question'
87
+ child = agent.ask_agent('Worker', 'child prompt', options: {endpoint: 'mock'})
88
+ agent.current_chat.push({role: :assistant, content: 'final answer'})
89
+
90
+ written = agent.save
91
+ # ask_agent without a conversation argument lands in the 'default'
92
+ # conversation slot
93
+ nested = File.join(dir, 'agent.society', 'Worker', 'default', 'agent.chat')
94
+ assert_include written, File.expand_path(nested)
95
+ assert Open.exist?(nested)
96
+ assert !File.exist?(File.join(dir, 'nested.files'))
97
+ end
98
+ end
99
+
100
+ def test_chat_task_result_delta_preserved
101
+ job = run_job(TmpFile.tmpdir, :with_chat)
102
+ result = LLM.chat(job.path)
103
+ # The job result is a Chat projection: it legitimately starts with the
104
+ # provenance meta header (job=...). The delta assertions apply to the
105
+ # conversation messages themselves.
106
+ delta = result.reject { |m| m[:role].to_s == 'meta' }
107
+ assert_include delta.first[:content], 'question one'
108
+ assert_include delta.last[:content], 'answer one'
109
+ end
110
+ end