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,183 @@
|
|
|
1
|
+
require File.expand_path(__FILE__).sub(%r(/test/.*), '/test/test_helper.rb')
|
|
2
|
+
|
|
3
|
+
require 'scout/llm/chat'
|
|
4
|
+
require 'scout/llm/backends/responses'
|
|
5
|
+
|
|
6
|
+
class TestNormalizeUsage < Test::Unit::TestCase
|
|
7
|
+
def test_openai_chat_api
|
|
8
|
+
usage = {
|
|
9
|
+
"prompt_tokens" => 100,
|
|
10
|
+
"completion_tokens" => 50,
|
|
11
|
+
"total_tokens" => 150,
|
|
12
|
+
"prompt_tokens_details" => {"cached_tokens" => 20},
|
|
13
|
+
"completion_tokens_details" => {"reasoning_tokens" => 30}
|
|
14
|
+
}
|
|
15
|
+
result = Chat.normalize_usage(usage)
|
|
16
|
+
assert_equal 100, result['pt']
|
|
17
|
+
assert_equal 50, result['ct']
|
|
18
|
+
assert_equal 150, result['tt']
|
|
19
|
+
assert_equal 20, result['cct']
|
|
20
|
+
assert_nil result['cwt']
|
|
21
|
+
assert_equal 30, result['rt']
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def test_openai_responses_api
|
|
25
|
+
usage = {
|
|
26
|
+
"input_tokens" => 9,
|
|
27
|
+
"input_tokens_details" => {"cache_write_tokens" => 0, "cached_tokens" => 0},
|
|
28
|
+
"output_tokens" => 174,
|
|
29
|
+
"output_tokens_details" => {"reasoning_tokens" => 128},
|
|
30
|
+
"total_tokens" => 183
|
|
31
|
+
}
|
|
32
|
+
result = Chat.normalize_usage(usage)
|
|
33
|
+
assert_equal 9, result['pt']
|
|
34
|
+
assert_equal 174, result['ct']
|
|
35
|
+
assert_equal 183, result['tt']
|
|
36
|
+
assert_equal 0, result['cct']
|
|
37
|
+
assert_equal 0, result['cwt']
|
|
38
|
+
assert_equal 128, result['rt']
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def test_glm
|
|
42
|
+
usage = {
|
|
43
|
+
"completion_tokens" => 27,
|
|
44
|
+
"completion_tokens_details" => {"reasoning_tokens" => 92},
|
|
45
|
+
"prompt_tokens" => 8,
|
|
46
|
+
"prompt_tokens_details" => {"cached_tokens" => 0},
|
|
47
|
+
"total_tokens" => 105
|
|
48
|
+
}
|
|
49
|
+
result = Chat.normalize_usage(usage)
|
|
50
|
+
assert_equal 8, result['pt']
|
|
51
|
+
assert_equal 27, result['ct']
|
|
52
|
+
assert_equal 105, result['tt']
|
|
53
|
+
assert_equal 0, result['cct']
|
|
54
|
+
assert_nil result['cwt']
|
|
55
|
+
assert_equal 92, result['rt']
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def test_anthropic_flat_fields
|
|
59
|
+
usage = {
|
|
60
|
+
"input_tokens" => 500,
|
|
61
|
+
"output_tokens" => 200,
|
|
62
|
+
"cache_read_input_tokens" => 150,
|
|
63
|
+
"cache_creation_input_tokens" => 50
|
|
64
|
+
}
|
|
65
|
+
result = Chat.normalize_usage(usage)
|
|
66
|
+
assert_equal 500, result['pt']
|
|
67
|
+
assert_equal 200, result['ct']
|
|
68
|
+
assert_equal 700, result['tt']
|
|
69
|
+
assert_equal 150, result['cct']
|
|
70
|
+
assert_equal 50, result['cwt']
|
|
71
|
+
assert_nil result['rt']
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def test_simple_usage_without_cache
|
|
75
|
+
usage = {"prompt_tokens" => 10, "completion_tokens" => 5, "total_tokens" => 15}
|
|
76
|
+
result = Chat.normalize_usage(usage)
|
|
77
|
+
assert_equal 10, result['pt']
|
|
78
|
+
assert_equal 5, result['ct']
|
|
79
|
+
assert_equal 15, result['tt']
|
|
80
|
+
extras = result.select { |k,_v| !%w[pt ct tt].include?(k) }
|
|
81
|
+
assert_empty extras
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def test_nil_usage
|
|
85
|
+
result = Chat.normalize_usage(nil)
|
|
86
|
+
assert_equal({}, result)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def test_empty_usage
|
|
90
|
+
result = Chat.normalize_usage({})
|
|
91
|
+
assert_equal({}, result)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def test_token_totals_with_cache
|
|
95
|
+
usage1 = {
|
|
96
|
+
"prompt_tokens" => 100,
|
|
97
|
+
"completion_tokens" => 50,
|
|
98
|
+
"total_tokens" => 150,
|
|
99
|
+
"prompt_tokens_details" => {"cached_tokens" => 20},
|
|
100
|
+
"completion_tokens_details" => {"reasoning_tokens" => 30}
|
|
101
|
+
}
|
|
102
|
+
meta_str1 = Chat.serialize_meta(Chat.normalize_usage(usage1))
|
|
103
|
+
chat1 = Chat.setup([
|
|
104
|
+
{role: :user, content: "hi"},
|
|
105
|
+
{role: :assistant, content: "hello"},
|
|
106
|
+
{role: :meta, content: meta_str1}
|
|
107
|
+
])
|
|
108
|
+
|
|
109
|
+
totals = Chat.token_totals([chat1])
|
|
110
|
+
assert_equal 100, totals[:pt]
|
|
111
|
+
assert_equal 50, totals[:ct]
|
|
112
|
+
assert_equal 150, totals[:tt]
|
|
113
|
+
assert_equal 20, totals[:cct]
|
|
114
|
+
assert_equal 0, totals[:cwt]
|
|
115
|
+
assert_equal 30, totals[:rt]
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def test_print_tokens_with_cache
|
|
119
|
+
tokens = {pt: 100, ct: 50, tt: 150, cct: 20, cwt: 0, rt: 30}
|
|
120
|
+
str = Chat.print_tokens(tokens)
|
|
121
|
+
assert str.include?("prompt=100")
|
|
122
|
+
assert str.include?("completion=50")
|
|
123
|
+
assert str.include?("total=150")
|
|
124
|
+
assert str.include?("cached=20")
|
|
125
|
+
assert !str.include?("cache_write")
|
|
126
|
+
assert str.include?("reasoning=30")
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def test_token_keys_constant
|
|
130
|
+
assert_equal %w[pt ct tt cct cwt rt], Chat::TOKEN_KEYS
|
|
131
|
+
assert_equal %w[pt_c ct_c tt_c cct_c cwt_c rt_c], Chat::CUMULATIVE_KEYS
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def test_update_meta_with_cache_fields
|
|
135
|
+
%w(pt_s ct_s tt_s cct_s cwt_s rt_s).each { |name| Thread.current[name] = 0 }
|
|
136
|
+
|
|
137
|
+
response = {
|
|
138
|
+
'usage' => {
|
|
139
|
+
"prompt_tokens" => 100,
|
|
140
|
+
"completion_tokens" => 50,
|
|
141
|
+
"total_tokens" => 150,
|
|
142
|
+
"prompt_tokens_details" => {"cached_tokens" => 20},
|
|
143
|
+
"completion_tokens_details" => {"reasoning_tokens" => 30}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
meta = LLM::Responses.update_meta(response)
|
|
147
|
+
assert_equal 100, meta['pt']
|
|
148
|
+
assert_equal 50, meta['ct']
|
|
149
|
+
assert_equal 150, meta['tt']
|
|
150
|
+
assert_equal 20, meta['cct']
|
|
151
|
+
assert_equal 30, meta['rt']
|
|
152
|
+
assert_equal 20, meta['cct_s']
|
|
153
|
+
assert_equal 30, meta['rt_s']
|
|
154
|
+
assert_equal 20, meta['cct_c']
|
|
155
|
+
assert_equal 30, meta['rt_c']
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def test_update_meta_accumulates_cache_fields_across_requests
|
|
159
|
+
%w(pt_s ct_s tt_s cct_s cwt_s rt_s).each { |name| Thread.current[name] = 0 }
|
|
160
|
+
|
|
161
|
+
resp1 = { 'usage' => {
|
|
162
|
+
"prompt_tokens" => 10, "completion_tokens" => 5, "total_tokens" => 15,
|
|
163
|
+
"prompt_tokens_details" => {"cached_tokens" => 4},
|
|
164
|
+
"completion_tokens_details" => {"reasoning_tokens" => 3}
|
|
165
|
+
}}
|
|
166
|
+
meta1 = LLM::Responses.update_meta(resp1)
|
|
167
|
+
assert_equal 4, meta1['cct_s']
|
|
168
|
+
assert_equal 3, meta1['rt_s']
|
|
169
|
+
assert_equal 4, meta1['cct_c']
|
|
170
|
+
assert_equal 3, meta1['rt_c']
|
|
171
|
+
|
|
172
|
+
resp2 = { 'usage' => {
|
|
173
|
+
"prompt_tokens" => 20, "completion_tokens" => 10, "total_tokens" => 30,
|
|
174
|
+
"prompt_tokens_details" => {"cached_tokens" => 8},
|
|
175
|
+
"completion_tokens_details" => {"reasoning_tokens" => 6}
|
|
176
|
+
}}
|
|
177
|
+
meta2 = LLM::Responses.update_meta(resp2, meta1)
|
|
178
|
+
assert_equal 12, meta2['cct_s'] # 4 + 8
|
|
179
|
+
assert_equal 9, meta2['rt_s'] # 3 + 6
|
|
180
|
+
assert_equal 12, meta2['cct_c'] # 4 + 8
|
|
181
|
+
assert_equal 9, meta2['rt_c'] # 3 + 6
|
|
182
|
+
end
|
|
183
|
+
end
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
require File.expand_path(__FILE__).sub(%r(/test/.*), '/test/test_helper.rb')
|
|
2
|
+
require 'scout/llm/chat'
|
|
3
|
+
|
|
4
|
+
class TestChatAgentMeta < Test::Unit::TestCase
|
|
5
|
+
def chat(text)
|
|
6
|
+
Chat.setup(LLM.messages(text))
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
# Build persisted-style chat text with one paired tool call whose output
|
|
10
|
+
# envelope carries a receipt. Legacy format uses the `agent_meta` key with
|
|
11
|
+
# serialized meta messages; the current format uses the `meta` key with an
|
|
12
|
+
# array of already-deserialized field Hashes (as LLM.process_calls now
|
|
13
|
+
# writes for delegated agents).
|
|
14
|
+
def receipt_text(agent_meta: nil, meta: nil, name: 'ask', call_id: 'call-1', content: 'child answer')
|
|
15
|
+
envelope = {name: name, content: content, id: call_id}
|
|
16
|
+
envelope[:agent_meta] = agent_meta unless agent_meta.nil?
|
|
17
|
+
envelope[:meta] = meta unless meta.nil?
|
|
18
|
+
<<-EOF
|
|
19
|
+
user: Run the worker
|
|
20
|
+
function_call: {"name":"#{name}","arguments":{"agent":"Worker"},"id":"#{call_id}"}
|
|
21
|
+
function_call_output: #{envelope.to_json}
|
|
22
|
+
EOF
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Current-format equivalent of `valid_receipt`
|
|
26
|
+
def valid_meta_receipt
|
|
27
|
+
receipt_text(meta: [
|
|
28
|
+
{'pt' => 100, 'ct' => 50, 'tt' => 150, 'inference_id' => 'aaa'},
|
|
29
|
+
{'job' => 'Worker/ask/Default_x'}
|
|
30
|
+
])
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def valid_receipt
|
|
34
|
+
receipt_text(agent_meta: [
|
|
35
|
+
{role: 'meta', content: 'pt=100 ct=50 tt=150 inference_id=aaa'},
|
|
36
|
+
{role: 'meta', content: 'job=Worker/ask/Default_x'}
|
|
37
|
+
])
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def test_extracts_valid_receipt_entries
|
|
41
|
+
evidence = Chat.agent_meta_evidence(chat(valid_receipt))
|
|
42
|
+
assert_equal 2, evidence.length
|
|
43
|
+
|
|
44
|
+
direct, projection = evidence
|
|
45
|
+
|
|
46
|
+
assert_equal :agent_meta, direct[:origin]
|
|
47
|
+
assert_equal 100, direct[:meta][:pt]
|
|
48
|
+
assert_equal 50, direct[:meta][:ct]
|
|
49
|
+
assert_equal 150, direct[:meta][:tt]
|
|
50
|
+
assert_equal 'aaa', direct[:meta][:inference_id]
|
|
51
|
+
assert_equal 'call-1', direct[:call_id]
|
|
52
|
+
assert_equal 'ask', direct[:tool_name]
|
|
53
|
+
assert_equal 0, direct[:agent_meta_index]
|
|
54
|
+
assert_equal 2, direct[:output_address]
|
|
55
|
+
assert_equal [2, :agent_meta, 0], direct[:evidence_address]
|
|
56
|
+
assert_nil direct[:source]
|
|
57
|
+
assert_equal({role: 'meta', content: 'pt=100 ct=50 tt=150 inference_id=aaa'}, direct[:raw_message])
|
|
58
|
+
|
|
59
|
+
assert_equal 1, projection[:agent_meta_index]
|
|
60
|
+
assert_equal [2, :agent_meta, 1], projection[:evidence_address]
|
|
61
|
+
assert_equal 'Worker/ask/Default_x', projection[:meta][:job]
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def test_extracts_receipt_entries_from_persisted_file_with_source
|
|
65
|
+
TmpFile.with_dir do |dir|
|
|
66
|
+
path = File.join(dir, 'parent.chat')
|
|
67
|
+
Open.write(path, valid_receipt)
|
|
68
|
+
conversation = Chat.load(path)
|
|
69
|
+
|
|
70
|
+
evidence = Chat.agent_meta_evidence(conversation, source: path)
|
|
71
|
+
assert_equal 2, evidence.length
|
|
72
|
+
|
|
73
|
+
direct = evidence.first
|
|
74
|
+
assert_equal path, direct[:source]
|
|
75
|
+
assert_equal [path, 2], direct[:output_address]
|
|
76
|
+
assert_equal [path, 2, :agent_meta, 0], direct[:evidence_address]
|
|
77
|
+
assert_equal 'call-1', direct[:call_id]
|
|
78
|
+
assert_equal 'ask', direct[:tool_name]
|
|
79
|
+
|
|
80
|
+
assert_equal [path, 2, :agent_meta, 1], evidence.last[:evidence_address]
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def test_absent_agent_meta_returns_empty_evidence
|
|
85
|
+
plain = receipt_text
|
|
86
|
+
assert_equal [], Chat.agent_meta_evidence(chat(plain))
|
|
87
|
+
|
|
88
|
+
without_calls = chat(<<-EOF)
|
|
89
|
+
user: Hello
|
|
90
|
+
assistant: Hi
|
|
91
|
+
EOF
|
|
92
|
+
assert_equal [], Chat.agent_meta_evidence(without_calls)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def test_agent_meta_not_an_array_is_skipped_with_warning
|
|
96
|
+
warnings = []
|
|
97
|
+
evidence = Chat.agent_meta_evidence(chat(receipt_text(agent_meta: 'oops')), warnings: warnings)
|
|
98
|
+
assert_equal [], evidence
|
|
99
|
+
assert_equal 1, warnings.length
|
|
100
|
+
|
|
101
|
+
warning = warnings.first
|
|
102
|
+
assert_equal :agent_meta, warning[:origin]
|
|
103
|
+
assert_equal :not_an_array, warning[:reason]
|
|
104
|
+
assert_equal 2, warning[:output_address]
|
|
105
|
+
assert_equal 'call-1', warning[:call_id]
|
|
106
|
+
assert_equal 'ask', warning[:tool_name]
|
|
107
|
+
assert_nil warning[:agent_meta_index]
|
|
108
|
+
assert_equal 'oops', warning[:raw_entry]
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def test_non_hash_entry_is_skipped_with_warning
|
|
112
|
+
warnings = []
|
|
113
|
+
evidence = Chat.agent_meta_evidence(chat(receipt_text(agent_meta: ['nonsense'])), warnings: warnings)
|
|
114
|
+
assert_equal [], evidence
|
|
115
|
+
assert_equal 1, warnings.length
|
|
116
|
+
assert_equal :not_a_hash, warnings.first[:reason]
|
|
117
|
+
assert_equal 0, warnings.first[:agent_meta_index]
|
|
118
|
+
assert_equal 'nonsense', warnings.first[:raw_entry]
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def test_entry_with_wrong_role_is_skipped_with_warning
|
|
122
|
+
warnings = []
|
|
123
|
+
agent_meta = [
|
|
124
|
+
{role: 'assistant', content: 'not a meta record'},
|
|
125
|
+
{role: 'meta', content: 'pt=1 tt=1 inference_id=keep'}
|
|
126
|
+
]
|
|
127
|
+
evidence = Chat.agent_meta_evidence(chat(receipt_text(agent_meta: agent_meta)), warnings: warnings)
|
|
128
|
+
assert_equal 1, evidence.length
|
|
129
|
+
assert_equal 1, warnings.length
|
|
130
|
+
assert_equal :invalid_role, warnings.first[:reason]
|
|
131
|
+
assert_equal 0, warnings.first[:agent_meta_index]
|
|
132
|
+
assert_equal({ 'role' => 'assistant', 'content' => 'not a meta record' }, warnings.first[:raw_entry])
|
|
133
|
+
assert_equal [2, :agent_meta, 1], evidence.first[:evidence_address]
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def test_entry_without_string_content_is_skipped_with_warning
|
|
137
|
+
warnings = []
|
|
138
|
+
agent_meta = [
|
|
139
|
+
{role: 'meta'},
|
|
140
|
+
{role: 'meta', content: 42},
|
|
141
|
+
{role: 'meta', content: 'pt=1 tt=1 inference_id=keep'}
|
|
142
|
+
]
|
|
143
|
+
evidence = Chat.agent_meta_evidence(chat(receipt_text(agent_meta: agent_meta)), warnings: warnings)
|
|
144
|
+
assert_equal 1, evidence.length
|
|
145
|
+
assert_equal 2, warnings.length
|
|
146
|
+
assert_equal %i[invalid_content invalid_content], warnings.collect { |w| w[:reason] }
|
|
147
|
+
assert_equal 0, warnings.first[:agent_meta_index]
|
|
148
|
+
assert_equal 1, warnings.last[:agent_meta_index]
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def test_unparseable_content_is_skipped_with_warning
|
|
152
|
+
warnings = []
|
|
153
|
+
evidence = Chat.agent_meta_evidence(chat(receipt_text(agent_meta: [{role: 'meta', content: 'no key value pairs here'}])), warnings: warnings)
|
|
154
|
+
assert_equal [], evidence
|
|
155
|
+
assert_equal 1, warnings.length
|
|
156
|
+
assert_equal :unparseable_meta, warnings.first[:reason]
|
|
157
|
+
assert_equal 0, warnings.first[:agent_meta_index]
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def test_malformed_entries_are_skipped_without_warnings_kwarg
|
|
161
|
+
evidence = Chat.agent_meta_evidence(chat(receipt_text(agent_meta: [{role: 'assistant', content: 'x'}])))
|
|
162
|
+
assert_equal [], evidence
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def test_meta_evidence_combines_both_origins
|
|
166
|
+
text = valid_receipt + "\nmeta: pt=10 tt=12 inference_id=local1\nassistant: done\n"
|
|
167
|
+
conversation = chat(text)
|
|
168
|
+
|
|
169
|
+
evidence = Chat.meta_evidence(conversation)
|
|
170
|
+
assert_equal 3, evidence.length
|
|
171
|
+
|
|
172
|
+
origins = evidence.collect { |record| record[:origin] }
|
|
173
|
+
assert_equal %i[chat_meta agent_meta agent_meta], origins
|
|
174
|
+
|
|
175
|
+
local = evidence.first
|
|
176
|
+
assert_equal 10, local[:meta][:pt]
|
|
177
|
+
assert_equal 12, local[:meta][:tt]
|
|
178
|
+
assert_equal 3, local[:meta_address]
|
|
179
|
+
assert_nil local[:source]
|
|
180
|
+
assert_equal 'meta', local[:message][:role].to_s
|
|
181
|
+
assert_equal 'pt=10 tt=12 inference_id=local1', local[:message][:content]
|
|
182
|
+
|
|
183
|
+
assert evidence.last(2).all? { |record| record[:origin] == :agent_meta }
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def test_meta_evidence_preserves_source_addresses
|
|
187
|
+
TmpFile.with_dir do |dir|
|
|
188
|
+
path = File.join(dir, 'parent.chat')
|
|
189
|
+
Open.write(path, valid_receipt + "\nmeta: pt=10 tt=12 inference_id=local1\nassistant: done\n")
|
|
190
|
+
conversation = Chat.load(path)
|
|
191
|
+
|
|
192
|
+
evidence = Chat.meta_evidence(conversation, source: path)
|
|
193
|
+
assert_equal [path, 3], evidence.first[:meta_address]
|
|
194
|
+
assert_equal [path, 2, :agent_meta, 0], evidence[1][:evidence_address]
|
|
195
|
+
assert_equal [path, 2, :agent_meta, 1], evidence[2][:evidence_address]
|
|
196
|
+
assert evidence.all? { |record| record[:source] == path }
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def test_extraction_does_not_change_chat_contents
|
|
201
|
+
conversation = chat(valid_receipt + "\nmeta: pt=10 tt=12 inference_id=local1\nassistant: done\n")
|
|
202
|
+
before = conversation.collect { |message| [message[:role].to_s, message[:content].to_s] }
|
|
203
|
+
|
|
204
|
+
Chat.agent_meta_evidence(conversation)
|
|
205
|
+
Chat.meta_evidence(conversation)
|
|
206
|
+
Chat.agent_meta_job_references(conversation)
|
|
207
|
+
|
|
208
|
+
after = conversation.collect { |message| [message[:role].to_s, message[:content].to_s] }
|
|
209
|
+
assert_equal before, after
|
|
210
|
+
assert_equal 5, conversation.length
|
|
211
|
+
|
|
212
|
+
roles = conversation.collect { |message| message[:role].to_s }
|
|
213
|
+
assert_equal %w[user function_call function_call_output meta assistant], roles
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def test_agent_meta_job_references_returns_only_job_records
|
|
217
|
+
conversation = chat(valid_receipt)
|
|
218
|
+
references = Chat.agent_meta_job_references(conversation)
|
|
219
|
+
|
|
220
|
+
assert_equal 1, references.length
|
|
221
|
+
reference = references.first
|
|
222
|
+
assert_equal 'Worker/ask/Default_x', reference[:job]
|
|
223
|
+
assert_equal 'Worker/ask/Default_x', reference[:meta][:job]
|
|
224
|
+
assert_equal :agent_meta, reference[:origin]
|
|
225
|
+
assert_equal 'call-1', reference[:call_id]
|
|
226
|
+
assert_equal 'ask', reference[:tool_name]
|
|
227
|
+
assert_equal 1, reference[:agent_meta_index]
|
|
228
|
+
assert_equal [2, :agent_meta, 1], reference[:evidence_address]
|
|
229
|
+
|
|
230
|
+
TmpFile.with_dir do |dir|
|
|
231
|
+
path = File.join(dir, 'parent.chat')
|
|
232
|
+
Open.write(path, valid_receipt)
|
|
233
|
+
with_source = Chat.agent_meta_job_references(Chat.load(path), source: path)
|
|
234
|
+
assert_equal 1, with_source.length
|
|
235
|
+
assert_equal 'Worker/ask/Default_x', with_source.first[:job]
|
|
236
|
+
assert_equal [path, 2, :agent_meta, 1], with_source.first[:evidence_address]
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def test_agent_meta_job_references_empty_without_receipts
|
|
241
|
+
assert_equal [], Chat.agent_meta_job_references(chat(receipt_text))
|
|
242
|
+
assert_equal [], Chat.agent_meta_job_references(chat("user: Hello\nassistant: Hi\n"))
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def test_chat_without_agent_meta_shows_no_regression
|
|
246
|
+
conversation = chat(<<-EOF)
|
|
247
|
+
user: Hello
|
|
248
|
+
meta: pt=10 ct=5 tt=15 inference_id=solo
|
|
249
|
+
assistant: Hi
|
|
250
|
+
EOF
|
|
251
|
+
|
|
252
|
+
assert_equal [], Chat.agent_meta_evidence(conversation)
|
|
253
|
+
|
|
254
|
+
evidence = Chat.meta_evidence(conversation)
|
|
255
|
+
assert_equal 1, evidence.length
|
|
256
|
+
assert_equal :chat_meta, evidence.first[:origin]
|
|
257
|
+
|
|
258
|
+
assert_equal({pt: 10, ct: 5, tt: 15, cct: 0, cwt: 0, rt: 0}, Chat.token_totals([conversation]))
|
|
259
|
+
assert_equal 1, conversation.role_messages(:meta).length
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def test_agent_meta_is_not_limited_to_ask_tool
|
|
263
|
+
agent_meta = [{role: 'meta', content: 'pt=7 tt=7 inference_id=other'}]
|
|
264
|
+
evidence = Chat.agent_meta_evidence(chat(receipt_text(agent_meta: agent_meta, name: 'chat_task', call_id: 'call-9')))
|
|
265
|
+
assert_equal 1, evidence.length
|
|
266
|
+
assert_equal 'chat_task', evidence.first[:tool_name]
|
|
267
|
+
assert_equal 'call-9', evidence.first[:call_id]
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
## Current-format (`meta` key) receipts ##
|
|
271
|
+
|
|
272
|
+
def test_deserialized_meta_receipt_produces_evidence
|
|
273
|
+
evidence = Chat.agent_meta_evidence(chat(valid_meta_receipt))
|
|
274
|
+
assert_equal 2, evidence.length
|
|
275
|
+
|
|
276
|
+
first = evidence.first
|
|
277
|
+
assert_equal :agent_meta, first[:origin]
|
|
278
|
+
assert_equal({'pt' => 100, 'ct' => 50, 'tt' => 150, 'inference_id' => 'aaa'}, first[:meta].to_hash)
|
|
279
|
+
# Already deserialized: no re-parse of any role/content wrapper happened
|
|
280
|
+
assert_nil first[:raw_message]
|
|
281
|
+
# Address suffix mirrors the persisted key
|
|
282
|
+
assert_equal [first[:output_address], :meta, 0], first[:evidence_address]
|
|
283
|
+
assert_equal 0, first[:agent_meta_index]
|
|
284
|
+
|
|
285
|
+
assert_equal 'Worker/ask/Default_x', evidence.last[:meta][:job]
|
|
286
|
+
assert_equal [evidence.last[:output_address], :meta, 1], evidence.last[:evidence_address]
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def test_deserialized_meta_receipt_job_references
|
|
290
|
+
references = Chat.agent_meta_job_references(chat(valid_meta_receipt))
|
|
291
|
+
assert_equal 1, references.length
|
|
292
|
+
assert_equal 'Worker/ask/Default_x', references.first[:job]
|
|
293
|
+
assert_equal :agent_meta, references.first[:origin]
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def test_mixed_keys_meta_wins
|
|
297
|
+
text = receipt_text(meta: [{'tt' => 1}],
|
|
298
|
+
agent_meta: [{role: 'meta', content: 'tt=2'}])
|
|
299
|
+
evidence = Chat.agent_meta_evidence(chat(text))
|
|
300
|
+
assert_equal 1, evidence.length
|
|
301
|
+
assert_equal 1, evidence.first[:meta][:tt]
|
|
302
|
+
assert_equal :meta, evidence.first[:evidence_address][1]
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def test_malformed_deserialized_meta_entries_warn_not_raise
|
|
306
|
+
warnings = []
|
|
307
|
+
text = receipt_text(meta: [{'pt' => 5}, 'nonsense', {}, nil])
|
|
308
|
+
evidence = Chat.agent_meta_evidence(chat(text), warnings: warnings)
|
|
309
|
+
|
|
310
|
+
assert_equal 1, evidence.length
|
|
311
|
+
assert_equal 5, evidence.first[:meta][:pt]
|
|
312
|
+
|
|
313
|
+
reasons = warnings.collect { |warning| [warning[:reason], warning[:agent_meta_index]] }
|
|
314
|
+
assert_include reasons, [:not_a_hash, 1]
|
|
315
|
+
assert_include reasons, [:empty_meta, 2]
|
|
316
|
+
assert_include reasons, [:not_a_hash, 3]
|
|
317
|
+
warnings.each do |warning|
|
|
318
|
+
assert_equal :agent_meta, warning[:origin]
|
|
319
|
+
assert_equal 'call-1', warning[:call_id]
|
|
320
|
+
assert_equal :meta, warning[:evidence_address][1] if warning[:evidence_address]
|
|
321
|
+
end
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
# End-to-end through the collector: a current-format receipt in a chat FILE
|
|
325
|
+
# feeds token events (origin :agent_meta) and job relations. The job=
|
|
326
|
+
# target is materialized on disk so the reference resolves.
|
|
327
|
+
def test_deserialized_receipt_feeds_token_events_and_problems
|
|
328
|
+
TmpFile.with_dir do |dir|
|
|
329
|
+
# Same layout as AgentMetaFixtures#make_job: the worker job file plus
|
|
330
|
+
# its .info sidecar exist on disk, so the receipt job= reference
|
|
331
|
+
# resolves through Chat.load_job_reference.
|
|
332
|
+
worker = File.join(dir, 'Worker', 'ask', 'Default_w')
|
|
333
|
+
Open.mkdir File.dirname(worker)
|
|
334
|
+
Open.write(worker, 'answer')
|
|
335
|
+
Open.write(worker + '.info', '{}')
|
|
336
|
+
|
|
337
|
+
parent = File.join(dir, 'parent.chat')
|
|
338
|
+
# Absolute job reference, as the fixtures suite writes it
|
|
339
|
+
Open.write(parent, receipt_text(
|
|
340
|
+
meta: [{'pt' => 100, 'ct' => 50, 'tt' => 150, 'inference_id' => 'aaa'},
|
|
341
|
+
{'job' => worker}],
|
|
342
|
+
call_id: 'call-1'))
|
|
343
|
+
|
|
344
|
+
warnings = []
|
|
345
|
+
events = Chat.provenance_token_events(parent, warnings: warnings)
|
|
346
|
+
receipt_events = events.select { |event| event[:evidence].any? { |item| item[:origin] == :agent_meta } }
|
|
347
|
+
assert_equal 1, receipt_events.length
|
|
348
|
+
assert_equal 'aaa', receipt_events.first[:inference_id]
|
|
349
|
+
assert_empty warnings
|
|
350
|
+
|
|
351
|
+
references = Chat.report_agent_meta_problems(Chat.load(parent), parent)
|
|
352
|
+
assert_equal 1, references.length
|
|
353
|
+
# The reference is kept verbatim (absolute, as written into the receipt)
|
|
354
|
+
assert_equal worker, references.first[:job]
|
|
355
|
+
end
|
|
356
|
+
end
|
|
357
|
+
end
|