scout-ai 1.2.5 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.vimproject +111 -31
- data/README.md +171 -320
- data/Rakefile +17 -1
- data/VERSION +1 -1
- data/doc/Improvements.md +325 -0
- data/doc/StartHere.md +110 -0
- data/doc/developer/Architecture.md +126 -0
- data/doc/developer/Backends.md +199 -0
- data/doc/developer/ChatLifecycle.md +183 -0
- data/doc/developer/DelegationInternals.md +295 -0
- data/doc/developer/DesignPrinciples.md +245 -0
- data/doc/developer/PromptProcessing.md +292 -0
- data/doc/developer/Provenance.md +317 -0
- data/doc/user/BuildingAgents.md +345 -0
- data/doc/user/Cookbook.md +333 -0
- data/doc/user/CoreConcepts.md +181 -0
- data/doc/user/Delegation.md +191 -0
- data/doc/user/GettingStarted.md +159 -0
- data/doc/user/ManagingContext.md +163 -0
- data/doc/user/MultiAgentWorkflows.md +256 -0
- data/doc/user/Python.md +159 -0
- data/doc/user/RunningInference.md +200 -0
- data/doc/user/ToolCalling.md +193 -0
- data/doc/user/WritingChats.md +197 -0
- data/lib/scout/llm/agent/chat.rb +62 -12
- data/lib/scout/llm/agent/delegate.rb +274 -65
- data/lib/scout/llm/agent/iterate.rb +2 -2
- data/lib/scout/llm/agent/save.rb +273 -0
- data/lib/scout/llm/agent/workflow.rb +164 -0
- data/lib/scout/llm/agent.rb +86 -61
- data/lib/scout/llm/ask.rb +36 -6
- data/lib/scout/llm/backends/anthropic.rb +9 -1
- data/lib/scout/llm/backends/bedrock.rb +15 -3
- data/lib/scout/llm/backends/default.rb +129 -89
- data/lib/scout/llm/backends/glm.rb +58 -0
- data/lib/scout/llm/backends/huggingface.rb +13 -1
- data/lib/scout/llm/backends/ollama.rb +13 -0
- data/lib/scout/llm/backends/openwebui.rb +8 -3
- data/lib/scout/llm/chat/agent_meta.rb +264 -0
- data/lib/scout/llm/chat/annotation.rb +37 -14
- data/lib/scout/llm/chat/parse.rb +28 -6
- data/lib/scout/llm/chat/persist.rb +25 -0
- data/lib/scout/llm/chat/process/clear.rb +19 -1
- data/lib/scout/llm/chat/process/files.rb +16 -1
- data/lib/scout/llm/chat/process/meta.rb +422 -32
- data/lib/scout/llm/chat/process/options.rb +21 -1
- data/lib/scout/llm/chat/process/tools.rb +49 -12
- data/lib/scout/llm/chat/process.rb +4 -0
- data/lib/scout/llm/chat/prompt/shorten_tools.rb +125 -0
- data/lib/scout/llm/chat/prompt/shorten_tools_epoch.rb +365 -0
- data/lib/scout/llm/chat/prompt.rb +48 -0
- data/lib/scout/llm/chat/provenance.rb +775 -0
- data/lib/scout/llm/chat/tool_calls.rb +76 -0
- data/lib/scout/llm/chat.rb +18 -2
- data/lib/scout/llm/embed.rb +8 -3
- data/lib/scout/llm/image.rb +86 -0
- data/lib/scout/llm/rag.rb +3 -3
- data/lib/scout/llm/tools/call.rb +159 -10
- data/lib/scout/llm/tools/knowledge_base.rb +1 -1
- data/lib/scout/llm/tools/workflow.rb +13 -5
- data/lib/scout-ai.rb +1 -0
- data/research/ChatAnalyst-required-changes.md +167 -0
- data/research/agent-delegation-analysis.md +810 -0
- data/research/agent-meta-provenance-integration-plan.md +622 -0
- data/research/agent-workflow-analysis.md +1120 -0
- data/research/backends-analysis.md +836 -0
- data/research/chat-core-analysis.md +946 -0
- data/research/chatanalyst-provenance/00-baseline.md +30 -0
- data/research/chatanalyst-provenance/01-repo-map.md +60 -0
- data/research/chatanalyst-provenance/02-event-reconstruction.md +55 -0
- data/research/chatanalyst-provenance/03-duplication-evidence.md +45 -0
- data/research/chatanalyst-provenance/04-tooling-root-cause.md +57 -0
- data/research/chatanalyst-provenance/05-fix-plan.md +46 -0
- data/research/chatanalyst-provenance/07-critic-review.md +25 -0
- data/research/chatanalyst-provenance/final-report.md +45 -0
- data/research/chatanalyst-provenance/resumption.md +37 -0
- data/research/coding-philosophy-analysis.md +928 -0
- data/research/commands-analysis.md +947 -0
- data/research/multi-agent-patterns-analysis.md +853 -0
- data/research/prompt-strategies-analysis.md +630 -0
- data/research/prov-verbosity-fix-notes.md +77 -0
- data/research/provenance-analysis.md +469 -0
- data/research/provenance-navigation-design.md +640 -0
- data/research/synthesis-report.md +487 -0
- data/research/tools-system-analysis.md +779 -0
- data/scout-ai.gemspec +97 -13
- data/scout_commands/agent/ask +13 -3
- data/scout_commands/agent/kb +2 -0
- data/scout_commands/llm/ask +11 -4
- data/scout_commands/llm/md +76 -0
- data/scout_commands/llm/prov +602 -0
- data/scout_commands/llm/word +71 -0
- data/share/word/reference.docx +0 -0
- data/test/etc/AI/mock.yaml +11 -0
- data/test/fixtures/backends/anthropic.json +19 -0
- data/test/fixtures/backends/anthropic_tool_use.json +24 -0
- data/test/fixtures/backends/bedrock.json +8 -0
- data/test/fixtures/backends/bedrock_embedding.json +3 -0
- data/test/fixtures/backends/bedrock_tool_use.json +17 -0
- data/test/fixtures/backends/ollama.json +16 -0
- data/test/fixtures/backends/ollama_tool_call.json +27 -0
- data/test/fixtures/backends/openai_chat.json +21 -0
- data/test/fixtures/backends/openai_chat_tool_call.json +31 -0
- data/test/fixtures/backends/responses.json +33 -0
- data/test/fixtures/backends/responses_tool_call.json +28 -0
- data/test/integration/README.md +32 -0
- data/test/integration/scout/llm/backends/test_endpoints.rb +34 -0
- data/test/integration/scout/llm/backends/test_openwebui.rb +61 -0
- data/test/integration/scout/llm/backends/test_relay.rb +52 -0
- data/test/integration/scout/llm/test_infrastructure.rb +74 -0
- data/test/{scout → integration/scout}/llm/test_mcp.rb +1 -1
- data/test/integration/scout/llm/tools/test_mcp.rb +42 -0
- data/test/integration/scout/model/test_base.rb +91 -0
- data/test/scout/llm/agent/test_chat.rb +8 -2
- data/test/scout/llm/agent/test_save.rb +413 -0
- data/test/scout/llm/agent/test_workflow.rb +110 -0
- data/test/scout/llm/backends/test_anthropic.rb +93 -10
- data/test/scout/llm/backends/test_bedrock.rb +118 -2
- data/test/scout/llm/backends/test_ollama.rb +70 -20
- data/test/scout/llm/backends/test_openwebui.rb +42 -40
- data/test/scout/llm/backends/test_relay.rb +4 -2
- data/test/scout/llm/chat/agent_meta_fixtures.rb +131 -0
- data/test/scout/llm/chat/process/test_meta.rb +518 -0
- data/test/scout/llm/chat/process/test_normalize_usage.rb +183 -0
- data/test/scout/llm/chat/test_agent_meta.rb +357 -0
- data/test/scout/llm/chat/test_agent_meta_provenance.rb +467 -0
- data/test/scout/llm/chat/test_agent_meta_tokens.rb +594 -0
- data/test/scout/llm/chat/test_parse.rb +70 -15
- data/test/scout/llm/chat/test_prov_cli.rb +274 -0
- data/test/scout/llm/chat/test_provenance.rb +240 -0
- data/test/scout/llm/chat/test_tool_calls.rb +38 -0
- data/test/scout/llm/test_agent.rb +13 -36
- data/test/scout/llm/test_ask.rb +75 -52
- data/test/scout/llm/test_chat.rb +107 -13
- data/test/scout/llm/test_embed.rb +48 -0
- data/test/scout/llm/test_rag.rb +23 -16
- data/test/scout/llm/test_tools.rb +12 -1
- data/test/scout/llm/tools/test_knowledge_base.rb +0 -1
- data/test/scout/llm/tools/test_mcp.rb +5 -3
- data/test/scout/llm/tools/test_workflow.rb +23 -2
- data/test/scout/model/python/huggingface/causal/test_next_token.rb +11 -5
- data/test/scout/model/python/huggingface/test_causal.rb +9 -3
- data/test/scout/model/python/huggingface/test_classification.rb +11 -2
- data/test/scout/model/python/test_torch.rb +2 -0
- data/test/scout/model/python/torch/test_helpers.rb +4 -0
- data/test/scout/model/test_base.rb +4 -2
- data/test/support/availability.rb +231 -0
- data/test/support/fake_clients.rb +138 -0
- data/test/support/fixtures.rb +21 -0
- data/test/support/infrastructure_probes.rb +136 -0
- data/test/support/mock_backend.rb +215 -0
- data/test/test_helper.rb +32 -2
- metadata +96 -12
- data/doc/Agent.md +0 -354
- data/doc/Chat.md +0 -481
- data/doc/LLM.md +0 -356
- data/doc/PythonAgentTasks.md +0 -333
- data/doc/RAG.md +0 -129
- data/doc/USER_GUIDE.md +0 -572
- data/scout_commands/documenter +0 -148
- data/test/scout/llm/backends/test_openai.rb +0 -192
- data/test/scout/llm/backends/test_responses.rb +0 -238
- data/test/scout/llm/test_parse.rb +0 -98
|
@@ -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
|
|
@@ -10,22 +10,105 @@ user: say hi
|
|
|
10
10
|
ppp LLM::Anthropic.ask prompt
|
|
11
11
|
end
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
# ScoutCoder: Anthropic has no embeddings endpoint; the backend raises
|
|
14
|
+
# from embed_query, so the offline contract is the exception, not a vector.
|
|
15
|
+
def test_embeddings
|
|
16
|
+
assert_raise(RuntimeError) { LLM::Anthropic.embed 'Some text', log_errors: false, model: 'embedding-model' }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def test_ask
|
|
20
|
+
client = TestFixtures.anthropic_client('backends/anthropic')
|
|
21
|
+
res = LLM::Anthropic.ask 'user: write a script that sorts files in a directory',
|
|
22
|
+
client: client, model: 'claude-sonnet-4-5', persist: false
|
|
23
|
+
|
|
24
|
+
assert_equal 'Mock answer from Anthropic messages', res
|
|
25
|
+
assert_equal 1, client.calls.length
|
|
26
|
+
assert_equal 'claude-sonnet-4-5', client.calls.first[:model]
|
|
27
|
+
assert client.calls.first[:messages].any? { |m| m[:role].to_s == 'user' }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def test_tool_call_output_weather
|
|
31
|
+
Log.severity = 0
|
|
14
32
|
prompt =<<-EOF
|
|
15
|
-
|
|
33
|
+
function_call:
|
|
34
|
+
|
|
35
|
+
{"name":"get_current_temperature", "arguments":{"location":"London","unit":"Celsius"},"id":"tNTnsQq2s6jGh0npOh43AwDD"}
|
|
36
|
+
|
|
37
|
+
function_call_output:
|
|
38
|
+
|
|
39
|
+
{"id":"tNTnsQq2s6jGh0npOh43AwDD", "content":"It's 15 degrees and raining."}
|
|
40
|
+
|
|
41
|
+
user:
|
|
42
|
+
|
|
43
|
+
should i take an umbrella?
|
|
16
44
|
EOF
|
|
17
|
-
|
|
18
|
-
|
|
45
|
+
client = TestFixtures.anthropic_client('backends/anthropic')
|
|
46
|
+
res = LLM::Anthropic.ask prompt, client: client, persist: false
|
|
47
|
+
|
|
48
|
+
assert_equal 'Mock answer from Anthropic messages', res
|
|
49
|
+
# ScoutCoder: the Anthropic backend rewrites the function_call /
|
|
50
|
+
# function_call_output pair into content array items (tool_use /
|
|
51
|
+
# tool_result) instead of separate messages.
|
|
52
|
+
sent = client.calls.first[:messages]
|
|
53
|
+
assert sent.any? { |m| m[:role].to_s == 'user' }
|
|
54
|
+
assert sent.inspect.include?('tool_result')
|
|
19
55
|
end
|
|
20
56
|
|
|
21
|
-
def
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
57
|
+
def test_tool
|
|
58
|
+
prompt =<<-EOF
|
|
59
|
+
user:
|
|
60
|
+
What is the weather in London. Should I take my umbrella?
|
|
25
61
|
EOF
|
|
26
|
-
emb = LLM::Anthropic.embed text, log_errors: true, model: 'embedding-model'
|
|
27
62
|
|
|
28
|
-
|
|
63
|
+
tools = [
|
|
64
|
+
{
|
|
65
|
+
"type": "custom",
|
|
66
|
+
"name": "get_current_temperature",
|
|
67
|
+
"description": "Get the current temperature and raining conditions for a specific location",
|
|
68
|
+
"parameters": {
|
|
69
|
+
"type": "object",
|
|
70
|
+
"properties": {
|
|
71
|
+
"location": {
|
|
72
|
+
"type": "string",
|
|
73
|
+
"description": "The city and state, e.g., San Francisco, CA"
|
|
74
|
+
},
|
|
75
|
+
"unit": {
|
|
76
|
+
"type": "string",
|
|
77
|
+
"enum": ["Celsius", "Fahrenheit"],
|
|
78
|
+
"description": "The temperature unit to use. Infer this from the user's location."
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
"required": ["location", "unit"]
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
]
|
|
85
|
+
|
|
86
|
+
client = TestFixtures.anthropic_client('backends/anthropic_tool_use', 'backends/anthropic')
|
|
87
|
+
respose = LLM::Anthropic.ask prompt, tools: tools,
|
|
88
|
+
client: client, log_errors: true, persist: false do |name,arguments|
|
|
89
|
+
"It's 15 degrees and raining."
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
assert_equal 'Mock answer from Anthropic messages', respose
|
|
93
|
+
assert_equal 2, client.calls.length
|
|
94
|
+
|
|
95
|
+
# ScoutCoder: LLM::Anthropic#format_tool_definitions renames
|
|
96
|
+
# `parameters` -> `input_schema` and forces type 'custom'.
|
|
97
|
+
sent_tools = client.calls.first[:tools]
|
|
98
|
+
assert sent_tools.any? { |t| (t[:name] || t['name']) == 'get_current_temperature' }
|
|
99
|
+
assert sent_tools.all? { |t| (t[:input_schema] || t['input_schema']) }
|
|
100
|
+
assert sent_tools.all? { |t| (t[:type] || t['type']).to_s == 'custom' }
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def test_json_output
|
|
104
|
+
client = TestFixtures.anthropic_client('backends/anthropic')
|
|
105
|
+
res = LLM::Anthropic.ask 'user: What other movies have the protagonists of the original gost busters played on, just the top.',
|
|
106
|
+
format: :json, client: client, persist: false
|
|
107
|
+
|
|
108
|
+
assert_equal 'Mock answer from Anthropic messages', res
|
|
109
|
+
# ScoutCoder: Anthropic takes a response_format hash, not the string key
|
|
110
|
+
# the OpenAI backends use.
|
|
111
|
+
assert client.calls.first.inspect.include?('json_object')
|
|
29
112
|
end
|
|
30
113
|
|
|
31
114
|
def _test_tool_call_output_2
|