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