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,467 @@
|
|
|
1
|
+
require File.expand_path(__FILE__).sub(%r(/test/.*), '/test/test_helper.rb')
|
|
2
|
+
require 'scout/llm/chat'
|
|
3
|
+
require_relative 'agent_meta_fixtures'
|
|
4
|
+
|
|
5
|
+
# Structure-level tests for the :agent_job provenance relation (plan fixtures
|
|
6
|
+
# C, D and the structure part of E). Token accounting for receipts lives in
|
|
7
|
+
# test_agent_meta_tokens.rb; both suites share AgentMetaFixtures#fixture_c.
|
|
8
|
+
class TestChatAgentMetaProvenance < Test::Unit::TestCase
|
|
9
|
+
include AgentMetaFixtures
|
|
10
|
+
|
|
11
|
+
## Fixture C
|
|
12
|
+
|
|
13
|
+
def test_agent_job_edge_links_receipt_job_to_enclosing_chat
|
|
14
|
+
TmpFile.with_dir do |dir|
|
|
15
|
+
parent, worker, _critic, _dep = fixture_c(dir)
|
|
16
|
+
|
|
17
|
+
errors = []
|
|
18
|
+
visits = Chat.traverse_provenance(parent, on_error: ->(*args) { errors << args }).to_a
|
|
19
|
+
|
|
20
|
+
assert_empty errors
|
|
21
|
+
signature = visit_signature(visits)
|
|
22
|
+
|
|
23
|
+
# The delegated producer is a normal :job node whose parent is the parent
|
|
24
|
+
# chat, discovered through the new relation.
|
|
25
|
+
edge = signature.find { |_kind, path, relation, _first| relation == :agent_job && path == worker }
|
|
26
|
+
assert edge, "no :agent_job edge to the worker job in #{signature.inspect}"
|
|
27
|
+
assert_equal :job, edge[0]
|
|
28
|
+
|
|
29
|
+
enclosing = visits.find do |kind, object, _pk, _p, _rel, _first|
|
|
30
|
+
kind == :job && object.path.to_s == worker
|
|
31
|
+
end
|
|
32
|
+
assert enclosing, 'worker job not visited'
|
|
33
|
+
assert_equal :chat, enclosing[2]
|
|
34
|
+
assert_equal parent, enclosing[3].to_s
|
|
35
|
+
|
|
36
|
+
# The worker log chat is reached through the ordinary :log relation and
|
|
37
|
+
# the worker dependency through :dependency.
|
|
38
|
+
worker_log = File.join(worker + '.files', 'log', 'agent.chat')
|
|
39
|
+
assert signature.any? { |kind, path, relation, _first| kind == :chat && path == worker_log && relation == :log }
|
|
40
|
+
assert signature.any? { |_kind, path, relation, _first| relation == :dependency && path.end_with?('Dep/load/Default_1') }
|
|
41
|
+
|
|
42
|
+
# No receipt-only node kind exists: chats are still only the parent and
|
|
43
|
+
# the worker log.
|
|
44
|
+
assert_equal 2, signature.count { |kind, _path, _relation, _first| kind == :chat }
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def test_follow_job_excludes_the_agent_job_edge
|
|
49
|
+
TmpFile.with_dir do |dir|
|
|
50
|
+
parent, _worker, _critic, _dep = fixture_c(dir)
|
|
51
|
+
|
|
52
|
+
signature = visit_signature(Chat.traverse_provenance(parent, follow: [:job]).to_a)
|
|
53
|
+
|
|
54
|
+
assert_empty signature.select { |_kind, _path, relation, _first| relation == :agent_job }
|
|
55
|
+
# The ordinary meta job= reference still resolves under follow: [:job].
|
|
56
|
+
assert signature.any? { |_kind, path, relation, _first| relation == :job && path.end_with?('Critic/ask/Default_c') }
|
|
57
|
+
assert_equal 2, signature.length
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def test_follow_agent_job_includes_only_the_agent_job_edge
|
|
62
|
+
TmpFile.with_dir do |dir|
|
|
63
|
+
parent, worker, _critic, _dep = fixture_c(dir)
|
|
64
|
+
|
|
65
|
+
signature = visit_signature(Chat.traverse_provenance(parent, follow: [:agent_job]).to_a)
|
|
66
|
+
|
|
67
|
+
delegated = signature.select { |_kind, _path, relation, _first| relation == :agent_job }
|
|
68
|
+
assert_equal 1, delegated.length
|
|
69
|
+
assert_equal worker, delegated.first[1]
|
|
70
|
+
|
|
71
|
+
# Nothing else expands: no ordinary :job edge, no logs, no dependencies.
|
|
72
|
+
# (The root chat itself carries a nil relation.)
|
|
73
|
+
assert_empty signature.select { |_kind, _path, relation, _first| relation && relation != :agent_job }
|
|
74
|
+
assert_equal 2, signature.length
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def test_chat_without_agent_meta_traverses_as_before
|
|
79
|
+
TmpFile.with_dir do |dir|
|
|
80
|
+
critic = make_job(dir, 'Critic/ask/Default_c')
|
|
81
|
+
plain = write_chat(dir, 'plain.chat', plain_delegation_chat(critic))
|
|
82
|
+
|
|
83
|
+
errors = []
|
|
84
|
+
signature = visit_signature(
|
|
85
|
+
Chat.traverse_provenance(plain, on_error: ->(*args) { errors << args }).to_a
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
assert_empty errors
|
|
89
|
+
assert_empty signature.select { |_kind, _path, relation, _first| relation == :agent_job }
|
|
90
|
+
assert signature.any? { |_kind, path, relation, _first| relation == :job && path == critic }
|
|
91
|
+
assert_equal 2, signature.length
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
## Fixture D (structure)
|
|
96
|
+
|
|
97
|
+
def test_nested_receipt_chain_terminates_with_both_delegation_edges
|
|
98
|
+
TmpFile.with_dir do |dir|
|
|
99
|
+
parent, worker, critic, _dep = fixture_c(dir)
|
|
100
|
+
|
|
101
|
+
errors = []
|
|
102
|
+
visits = Chat.traverse_provenance(parent, on_error: ->(*args) { errors << args }).to_a
|
|
103
|
+
|
|
104
|
+
assert_empty errors
|
|
105
|
+
signature = visit_signature(visits)
|
|
106
|
+
|
|
107
|
+
delegated = signature.select { |_kind, _path, relation, _first| relation == :agent_job }
|
|
108
|
+
# Parent chat -> Worker and worker log chat -> Critic; the Critic edge is
|
|
109
|
+
# still yielded as a structural edge even though Critic was already
|
|
110
|
+
# visited through the ordinary :job relation.
|
|
111
|
+
assert_equal 2, delegated.length
|
|
112
|
+
assert delegated.any? { |_kind, path, _first| path == worker }
|
|
113
|
+
assert delegated.any? { |_kind, path, _first| path == critic }
|
|
114
|
+
|
|
115
|
+
worker_log = File.join(worker + '.files', 'log', 'agent.chat')
|
|
116
|
+
nested = visits.find do |kind, object, _pk, p, rel, _first|
|
|
117
|
+
kind == :job && object.path.to_s == critic && rel == :agent_job && p.to_s == worker_log
|
|
118
|
+
end
|
|
119
|
+
assert nested, 'no :agent_job edge from the worker log chat to the Critic job'
|
|
120
|
+
|
|
121
|
+
# Each node expands exactly once.
|
|
122
|
+
first_visits = signature.count { |_kind, _path, _relation, first| first }
|
|
123
|
+
assert_equal signature.uniq { |kind, path, _rel, _first| [kind, path] }.length, first_visits
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def test_deliberate_receipt_cycle_does_not_hang
|
|
128
|
+
TmpFile.with_dir do |dir|
|
|
129
|
+
parent, worker, critic, _dep = fixture_c(dir)
|
|
130
|
+
|
|
131
|
+
# Self reference plus a reference back to the already-visited Worker:
|
|
132
|
+
# both must terminate through the seen set instead of looping.
|
|
133
|
+
cyclic_log = receipt_chat_text(
|
|
134
|
+
{'c1' => [meta_receipt('pt=1 tt=2 inference_id=loop'),
|
|
135
|
+
meta_receipt("job=#{critic}"),
|
|
136
|
+
meta_receipt("job=#{worker}")]},
|
|
137
|
+
extra: ['meta: pt=3 tt=4 inference_id=cl1']
|
|
138
|
+
)
|
|
139
|
+
make_job(dir, 'Critic/ask/Default_c', logs: {'agent.chat' => cyclic_log})
|
|
140
|
+
|
|
141
|
+
errors = []
|
|
142
|
+
signature = visit_signature(
|
|
143
|
+
Chat.traverse_provenance(parent, on_error: ->(*args) { errors << args }).to_a
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
assert_empty errors
|
|
147
|
+
assert signature.any? { |_kind, path, relation, _first| relation == :agent_job && path == critic }
|
|
148
|
+
# The Critic job expands exactly once even though several receipt edges
|
|
149
|
+
# reach it.
|
|
150
|
+
expansions = signature.count do |kind, path, _relation, first|
|
|
151
|
+
kind == :job && path == critic && first
|
|
152
|
+
end
|
|
153
|
+
assert_equal 1, expansions
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
## Fixture E
|
|
158
|
+
|
|
159
|
+
def test_malformed_receipts_become_agent_job_warnings
|
|
160
|
+
TmpFile.with_dir do |dir|
|
|
161
|
+
# Output message indexes in the parsed chat (no leading empty user
|
|
162
|
+
# message since Chat.parse stopped emitting it): b1 -> 2, b2 -> 4,
|
|
163
|
+
# b3 -> 6.
|
|
164
|
+
chat = write_chat(dir, 'bad.chat',
|
|
165
|
+
receipt_chat_text(
|
|
166
|
+
{'b1' => 'not-an-array',
|
|
167
|
+
'b2' => [{role: 'assistant', content: 'pt=1 tt=2'},
|
|
168
|
+
meta_receipt('no parseable pairs at all')],
|
|
169
|
+
'b3' => [meta_receipt('also not parseable')]}
|
|
170
|
+
))
|
|
171
|
+
|
|
172
|
+
errors = []
|
|
173
|
+
visits = Chat.traverse_provenance(chat, on_error: ->(*args) { errors << args }).to_a
|
|
174
|
+
|
|
175
|
+
assert_equal 4, errors.length
|
|
176
|
+
reasons = errors.collect { |_error, _kind, _object, _relation, reference| reference[:reason] }
|
|
177
|
+
assert_include reasons, :not_an_array
|
|
178
|
+
assert_include reasons, :invalid_role
|
|
179
|
+
assert_equal 2, reasons.count(:unparseable_meta)
|
|
180
|
+
|
|
181
|
+
errors.each do |error, kind, object, relation, reference|
|
|
182
|
+
assert_kind_of ScoutException, error
|
|
183
|
+
assert_equal :chat, kind
|
|
184
|
+
assert_equal chat, object.to_s
|
|
185
|
+
assert_equal :agent_job, relation
|
|
186
|
+
assert_equal chat, reference[:source]
|
|
187
|
+
assert reference[:output_address], 'warning has no output address'
|
|
188
|
+
assert reference[:call_id], 'warning has no call id'
|
|
189
|
+
assert_equal 'ask', reference[:tool_name]
|
|
190
|
+
assert_not_nil reference[:raw_entry]
|
|
191
|
+
# The error message locates the receipt.
|
|
192
|
+
assert_match(/#{Regexp.escape(chat)}/, error.message)
|
|
193
|
+
assert_match(/#{reference[:call_id]}/, error.message)
|
|
194
|
+
assert_match(/ask/, error.message)
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Indexed entries keep their evidence address; the whole-value failure
|
|
198
|
+
# does not have one.
|
|
199
|
+
not_an_array = errors.find { |_e, _k, _o, _r, ref| ref[:reason] == :not_an_array }
|
|
200
|
+
assert_equal [chat, 2], not_an_array.last[:output_address]
|
|
201
|
+
assert_nil not_an_array.last[:evidence_address]
|
|
202
|
+
assert_nil not_an_array.last[:agent_meta_index]
|
|
203
|
+
invalid_role = errors.find { |_e, _k, _o, _r, ref| ref[:reason] == :invalid_role }
|
|
204
|
+
assert_equal [chat, 4, :agent_meta, 0], invalid_role.last[:evidence_address]
|
|
205
|
+
assert_equal 0, invalid_role.last[:agent_meta_index]
|
|
206
|
+
unparseable = errors.find { |_e, _k, _o, _r, ref| ref[:reason] == :unparseable_meta && ref[:output_address] == [chat, 4] }
|
|
207
|
+
assert_equal [chat, 4, :agent_meta, 1], unparseable.last[:evidence_address]
|
|
208
|
+
|
|
209
|
+
# Nothing malformed was silently used as provenance.
|
|
210
|
+
assert_equal 1, visits.length
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# Provenance is only extracted from parsed function_call_output JSON
|
|
215
|
+
# Hashes carrying an explicit `agent_meta` key. Raw output text that merely
|
|
216
|
+
# mentions the word must never produce a warning (or a strict-mode raise).
|
|
217
|
+
def test_unparseable_output_mentioning_agent_meta_is_ignored
|
|
218
|
+
TmpFile.with_dir do |dir|
|
|
219
|
+
chat = write_chat(dir, 'broken.chat', <<TXT)
|
|
220
|
+
user: Run
|
|
221
|
+
function_call: {"name":"chat_task","arguments":{},"id":"u1"}
|
|
222
|
+
function_call_output: OutputException: truncated [CUT to 100] mentions agent_meta
|
|
223
|
+
function_call: {"name":"ask","arguments":{},"id":"u2"}
|
|
224
|
+
function_call_output: also not json at all
|
|
225
|
+
assistant: done
|
|
226
|
+
TXT
|
|
227
|
+
|
|
228
|
+
errors = []
|
|
229
|
+
visits = Chat.traverse_provenance(chat, on_error: ->(*args) { errors << args }).to_a
|
|
230
|
+
|
|
231
|
+
assert_empty errors
|
|
232
|
+
assert_equal 1, visits.length
|
|
233
|
+
|
|
234
|
+
# Strict mode is equally silent: no heuristic may fail traversal.
|
|
235
|
+
assert_nothing_raised do
|
|
236
|
+
Chat.traverse_provenance(chat).to_a
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# An explicit agent_meta key with a non-Array value is a malformed receipt
|
|
242
|
+
# (present, not absent) and must warn through the normal path.
|
|
243
|
+
def test_explicit_non_array_agent_meta_warns
|
|
244
|
+
TmpFile.with_dir do |dir|
|
|
245
|
+
chat = write_chat(dir, 'explicit.chat',
|
|
246
|
+
receipt_chat_text({'x1' => [meta_receipt('pt=1 tt=2 inference_id=e1')]}))
|
|
247
|
+
|
|
248
|
+
errors = []
|
|
249
|
+
Chat.traverse_provenance(chat, on_error: ->(*args) { errors << args }).to_a
|
|
250
|
+
assert_empty errors
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def test_unresolved_job_reference_warns_and_is_not_enqueued
|
|
255
|
+
TmpFile.with_dir do |dir|
|
|
256
|
+
missing = File.join(dir, 'Missing/ask/Default_zzz')
|
|
257
|
+
chat = write_chat(dir, 'unresolved.chat',
|
|
258
|
+
receipt_chat_text({'u1' => [meta_receipt("job=#{missing}")]},
|
|
259
|
+
extra: ["meta: job=#{File.join(dir, 'Critic/ask/Default_c')}"]))
|
|
260
|
+
|
|
261
|
+
errors = []
|
|
262
|
+
visits = Chat.traverse_provenance(chat, on_error: ->(*args) { errors << args }).to_a
|
|
263
|
+
|
|
264
|
+
unresolved = errors.collect(&:last).select { |ref| ref[:reason] == :unresolved_job_reference }
|
|
265
|
+
assert_equal 1, unresolved.length
|
|
266
|
+
assert_equal missing, unresolved.first[:reference]
|
|
267
|
+
assert_equal [chat, 2, :agent_meta, 0], unresolved.first[:evidence_address]
|
|
268
|
+
assert_equal 'u1', unresolved.first[:call_id]
|
|
269
|
+
assert_equal 'ask', unresolved.first[:tool_name]
|
|
270
|
+
|
|
271
|
+
error, _kind, object, relation, _ref = errors.find { |args| args.last[:reason] == :unresolved_job_reference }
|
|
272
|
+
assert_kind_of ScoutException, error
|
|
273
|
+
assert_equal :agent_job, relation
|
|
274
|
+
assert_equal chat, object.to_s
|
|
275
|
+
assert_match(/#{Regexp.escape(missing)}/, error.message)
|
|
276
|
+
assert_match(/#{Regexp.escape(chat)}/, error.message)
|
|
277
|
+
|
|
278
|
+
# The missing job is never enqueued as a node.
|
|
279
|
+
signature = visit_signature(visits)
|
|
280
|
+
assert_empty signature.select { |_kind, path, _relation, _first| path == missing }
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def test_malformed_receipt_does_not_hide_other_provenance
|
|
285
|
+
TmpFile.with_dir do |dir|
|
|
286
|
+
ordinary = make_job(dir, 'Ordinary/ask/Default_o')
|
|
287
|
+
chat = write_chat(dir, 'mixed.chat',
|
|
288
|
+
receipt_chat_text({'m1' => 'not-an-array'},
|
|
289
|
+
extra: ["meta: job=#{ordinary}"]))
|
|
290
|
+
|
|
291
|
+
errors = []
|
|
292
|
+
signature = visit_signature(
|
|
293
|
+
Chat.traverse_provenance(chat, on_error: ->(*args) { errors << args }).to_a
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
assert_equal 1, errors.length
|
|
297
|
+
assert_equal :not_an_array, errors.first.last[:reason]
|
|
298
|
+
|
|
299
|
+
# The ordinary meta job= reference still expands through :job.
|
|
300
|
+
assert signature.any? { |_kind, path, relation, _first| path == ordinary && relation == :job }
|
|
301
|
+
assert_equal 2, signature.length
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def test_strict_mode_raises_on_unresolved_reference
|
|
306
|
+
TmpFile.with_dir do |dir|
|
|
307
|
+
missing = File.join(dir, 'Missing/ask/Default_zzz')
|
|
308
|
+
chat = write_chat(dir, 'strict.chat',
|
|
309
|
+
receipt_chat_text({'s1' => [meta_receipt("job=#{missing}")]}))
|
|
310
|
+
|
|
311
|
+
error = assert_raise(ScoutException) do
|
|
312
|
+
Chat.traverse_provenance(chat).to_a
|
|
313
|
+
end
|
|
314
|
+
assert_match(/unresolved_job_reference/, error.message)
|
|
315
|
+
assert_match(/#{Regexp.escape(chat)}/, error.message)
|
|
316
|
+
assert_match(/#{Regexp.escape(missing)}/, error.message)
|
|
317
|
+
end
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
def test_strict_mode_raises_on_malformed_receipt
|
|
321
|
+
TmpFile.with_dir do |dir|
|
|
322
|
+
chat = write_chat(dir, 'strict-bad.chat',
|
|
323
|
+
receipt_chat_text({'s1' => [{role: 'assistant', content: 'x'}]}))
|
|
324
|
+
|
|
325
|
+
error = assert_raise(ScoutException) do
|
|
326
|
+
Chat.traverse_provenance(chat).to_a
|
|
327
|
+
end
|
|
328
|
+
assert_match(/invalid_role/, error.message)
|
|
329
|
+
end
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
def test_no_warnings_without_receipts
|
|
333
|
+
TmpFile.with_dir do |dir|
|
|
334
|
+
plain = write_chat(dir, 'plain.chat',
|
|
335
|
+
"user: hi\nmeta: pt=1 tt=2 inference_id=p1\nassistant: done\n")
|
|
336
|
+
|
|
337
|
+
errors = []
|
|
338
|
+
Chat.traverse_provenance(plain, on_error: ->(*args) { errors << args }).to_a
|
|
339
|
+
|
|
340
|
+
assert_empty errors
|
|
341
|
+
end
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
## Chats with a .files sidecar are scanned like jobs
|
|
345
|
+
|
|
346
|
+
def test_saved_chat_sidecar_society_chats_are_traversed_with_log_relation
|
|
347
|
+
TmpFile.with_dir do |dir|
|
|
348
|
+
chat = write_chat(dir, 'saved.chat',
|
|
349
|
+
"user: hi\nmeta: pt=2 ct=1 tt=3 inference_id=s0\nassistant: done\n")
|
|
350
|
+
|
|
351
|
+
sidecar = File.expand_path(chat + '.files/log/society/Worker/default')
|
|
352
|
+
FileUtils.mkdir_p(sidecar)
|
|
353
|
+
File.write(File.join(sidecar, 'agent.chat'),
|
|
354
|
+
"user: work\nmeta: pt=10 ct=5 tt=15 inference_id=w1\nassistant: ok\n")
|
|
355
|
+
|
|
356
|
+
errors = []
|
|
357
|
+
visits = Chat.traverse_provenance(chat, on_error: ->(*args) { errors << args }).to_a
|
|
358
|
+
|
|
359
|
+
assert_empty errors
|
|
360
|
+
|
|
361
|
+
society = visits.find do |_kind, object, _pk, _parent, _relation, _first|
|
|
362
|
+
object.to_s.end_with?('log/society/Worker/default/agent.chat')
|
|
363
|
+
end
|
|
364
|
+
assert society, 'society chat not visited'
|
|
365
|
+
kind, object, parent_kind, parent, relation, first = society
|
|
366
|
+
assert_equal :chat, kind
|
|
367
|
+
assert_equal File.expand_path(File.join(sidecar, 'agent.chat')), object.to_s
|
|
368
|
+
assert_equal :chat, parent_kind
|
|
369
|
+
assert_equal File.expand_path(chat), parent.to_s
|
|
370
|
+
assert_equal :log, relation
|
|
371
|
+
assert first
|
|
372
|
+
end
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
def test_saved_chat_sidecar_root_copy_is_not_traversed
|
|
376
|
+
TmpFile.with_dir do |dir|
|
|
377
|
+
chat = write_chat(dir, 'saved.chat',
|
|
378
|
+
"user: hi\nmeta: pt=2 ct=1 tt=3 inference_id=s0\nassistant: done\n")
|
|
379
|
+
|
|
380
|
+
log = File.expand_path(chat + '.files/log')
|
|
381
|
+
FileUtils.mkdir_p(File.join(log, 'society/Worker/default'))
|
|
382
|
+
# The full-copy written by the CLI save mechanism: same content as root.
|
|
383
|
+
File.write(File.join(log, 'agent.chat'),
|
|
384
|
+
"user: hi\nmeta: pt=2 ct=1 tt=3 inference_id=s0\nassistant: done\n")
|
|
385
|
+
File.write(File.join(log, 'society/Worker/default/agent.chat'),
|
|
386
|
+
"user: work\nmeta: pt=10 ct=5 tt=15 inference_id=w1\nassistant: ok\n")
|
|
387
|
+
|
|
388
|
+
visits = Chat.traverse_provenance(chat).to_a
|
|
389
|
+
paths = visits.collect { |_k, object, _pk, _p, _r, _f| object.to_s }
|
|
390
|
+
|
|
391
|
+
root_copy = File.join(log, 'agent.chat')
|
|
392
|
+
assert_not_include paths, root_copy,
|
|
393
|
+
'the sidecar root copy must not become its own node'
|
|
394
|
+
assert_include paths, File.join(log, 'society/Worker/default/agent.chat')
|
|
395
|
+
|
|
396
|
+
# No chat -> root-copy edge either.
|
|
397
|
+
edges = Chat.provenance_edges(chat).collect { |e| [e[:from].to_s, e[:to].to_s, e[:relation]] }
|
|
398
|
+
assert_empty edges.select { |from, to, _r| to == root_copy }
|
|
399
|
+
end
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
def test_saved_chat_sidecar_visits_are_unique_and_repeateable
|
|
403
|
+
TmpFile.with_dir do |dir|
|
|
404
|
+
chat = write_chat(dir, 'saved.chat',
|
|
405
|
+
"user: hi\nmeta: pt=2 ct=1 tt=3 inference_id=s0\nassistant: done\n")
|
|
406
|
+
|
|
407
|
+
log = File.expand_path(chat + '.files/log')
|
|
408
|
+
FileUtils.mkdir_p(File.join(log, 'society/Worker/default'))
|
|
409
|
+
FileUtils.mkdir_p(File.join(log, 'society/Critic/default'))
|
|
410
|
+
File.write(File.join(log, 'agent.chat'),
|
|
411
|
+
"user: hi\nmeta: pt=2 ct=1 tt=3 inference_id=s0\nassistant: done\n")
|
|
412
|
+
File.write(File.join(log, 'society/Worker/default/agent.chat'),
|
|
413
|
+
"user: work\nmeta: pt=10 ct=5 tt=15 inference_id=w1\nassistant: ok\n")
|
|
414
|
+
File.write(File.join(log, 'society/Critic/default/agent.chat'),
|
|
415
|
+
"user: check\nmeta: pt=4 ct=2 tt=6 inference_id=c1\nassistant: fine\n")
|
|
416
|
+
|
|
417
|
+
keys = nil
|
|
418
|
+
2.times do
|
|
419
|
+
visits = Chat.traverse_provenance(chat).to_a
|
|
420
|
+
keys_now = visits.collect { |kind, object, _pk, _p, _r, _f| [kind, object.to_s] }
|
|
421
|
+
keys = keys_now if keys.nil?
|
|
422
|
+
assert_equal keys, keys_now, 'traversal is not deterministic'
|
|
423
|
+
assert_equal keys_now.length, keys_now.uniq.length,
|
|
424
|
+
'a file reachable via several globs produced duplicate visits'
|
|
425
|
+
end
|
|
426
|
+
end
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
def test_job_log_relation_still_traverses_agent_chat_from_the_job
|
|
430
|
+
TmpFile.with_dir do |dir|
|
|
431
|
+
_parent, worker, _critic, _dep = fixture_c(dir)
|
|
432
|
+
|
|
433
|
+
log = Path.setup(File.expand_path(File.join(worker.to_s + '.files', 'log')))
|
|
434
|
+
log_chat = File.join(log.to_s, 'agent.chat')
|
|
435
|
+
assert File.file?(log_chat), 'fixture did not create the job log chat'
|
|
436
|
+
|
|
437
|
+
visits = Chat.traverse_provenance(worker, root_type: :job).to_a
|
|
438
|
+
log_file = visits.find do |_kind, object, _pk, _parent, relation, _first|
|
|
439
|
+
relation == :log && object.to_s == File.expand_path(log_chat)
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
assert log_file, 'job log/agent.chat was not traversed from the job'
|
|
443
|
+
assert_equal :chat, log_file[0]
|
|
444
|
+
assert_equal :job, log_file[2]
|
|
445
|
+
assert_equal File.expand_path(worker.to_s), File.expand_path(log_file[3].path.to_s)
|
|
446
|
+
end
|
|
447
|
+
end
|
|
448
|
+
|
|
449
|
+
## Regression
|
|
450
|
+
|
|
451
|
+
def test_provenance_relations_include_agent_job_and_not_import
|
|
452
|
+
assert_equal %i[job dependency log result agent_job], Chat::PROVENANCE_RELATIONS
|
|
453
|
+
assert_not_include Chat::PROVENANCE_RELATIONS, :import
|
|
454
|
+
assert_not_include Chat::PROVENANCE_RELATIONS, :continue
|
|
455
|
+
assert_not_include Chat::PROVENANCE_RELATIONS, :last
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
def test_unknown_relation_still_raises
|
|
459
|
+
TmpFile.with_dir do |dir|
|
|
460
|
+
chat = write_chat(dir, 'any.chat', "user: hi\nassistant: done\n")
|
|
461
|
+
|
|
462
|
+
assert_raise(ParameterException) do
|
|
463
|
+
Chat.traverse_provenance(chat, follow: [:agent_meta]).to_a
|
|
464
|
+
end
|
|
465
|
+
end
|
|
466
|
+
end
|
|
467
|
+
end
|