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.
- checksums.yaml +4 -4
- data/.vimproject +138 -50
- data/README.md +171 -290
- 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 +61 -11
- 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 +62 -17
- data/lib/scout/llm/backends/anthropic.rb +9 -2
- data/lib/scout/llm/backends/bedrock.rb +15 -3
- data/lib/scout/llm/backends/default.rb +183 -99
- data/lib/scout/llm/backends/glm.rb +58 -0
- data/lib/scout/llm/backends/huggingface.rb +196 -26
- data/lib/scout/llm/backends/ollama.rb +13 -1
- data/lib/scout/llm/backends/openai.rb +0 -2
- data/lib/scout/llm/backends/openwebui.rb +20 -13
- data/lib/scout/llm/backends/relay.rb +22 -22
- data/lib/scout/llm/backends/responses.rb +1 -1
- data/lib/scout/llm/chat/agent_meta.rb +264 -0
- data/lib/scout/llm/chat/annotation.rb +39 -10
- 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 +41 -6
- data/lib/scout/llm/chat/process/files.rb +21 -6
- data/lib/scout/llm/chat/process/meta.rb +421 -34
- data/lib/scout/llm/chat/process/options.rb +21 -1
- data/lib/scout/llm/chat/process/tools.rb +56 -15
- 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 +11 -3
- data/lib/scout/llm/image.rb +86 -0
- data/lib/scout/llm/mcp.rb +10 -2
- data/lib/scout/llm/rag.rb +3 -3
- data/lib/scout/llm/tools/call.rb +160 -11
- data/lib/scout/llm/tools/knowledge_base.rb +1 -1
- data/lib/scout/llm/tools/workflow.rb +32 -16
- data/lib/scout/model/python/huggingface/causal.rb +23 -5
- data/lib/scout/model/python/huggingface.rb +2 -1
- data/lib/scout-ai.rb +1 -0
- data/python/README.md +197 -14
- data/python/scout_ai/huggingface/eval.py +245 -34
- data/python/tests/test_huggingface_eval.py +58 -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 +100 -11
- 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/process_queries +48 -0
- data/scout_commands/llm/prov +602 -0
- data/scout_commands/llm/word +71 -0
- data/scout_commands/workflow/mcp +43 -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_huggingface.rb +137 -42
- 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 +99 -10
- data/doc/Agent.md +0 -327
- data/doc/Chat.md +0 -458
- data/doc/LLM.md +0 -340
- data/doc/RAG.md +0 -129
- 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,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
|
-
|
|
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
|