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
|
@@ -1,51 +1,438 @@
|
|
|
1
|
+
require 'set'
|
|
2
|
+
|
|
1
3
|
module Chat
|
|
2
|
-
|
|
3
|
-
|
|
4
|
+
# Canonical short keys for per-inference token fields written into meta
|
|
5
|
+
# messages. Every key has implicit <key>_s (session) and <key>_c (chat)
|
|
6
|
+
# cumulative variants computed by update_meta.
|
|
7
|
+
#
|
|
8
|
+
# pt - prompt / input tokens
|
|
9
|
+
# ct - completion / output tokens
|
|
10
|
+
# tt - total tokens
|
|
11
|
+
# cct - cached (cache-hit) input tokens
|
|
12
|
+
# cwt - cache-write input tokens
|
|
13
|
+
# rt - reasoning tokens
|
|
14
|
+
TOKEN_KEYS = %w[pt ct tt cct cwt rt].freeze
|
|
15
|
+
|
|
16
|
+
# Keys that carry cumulative totals across chat requests. Used by
|
|
17
|
+
# Chat.meta to restore the last checkpoint.
|
|
18
|
+
CUMULATIVE_KEYS = TOKEN_KEYS.map { |k| "#{k}_c" }.freeze
|
|
19
|
+
|
|
20
|
+
# Map known provider field names to the canonical short keys.
|
|
21
|
+
# Each entry is [field_path, short_key] where field_path is an array
|
|
22
|
+
# of keys suitable for IndiferentHash.dig.
|
|
23
|
+
USAGE_FIELD_MAP = {
|
|
24
|
+
# prompt / input
|
|
25
|
+
%w[prompt_tokens] => 'pt',
|
|
26
|
+
%w[input_tokens] => 'pt',
|
|
27
|
+
# completion / output
|
|
28
|
+
%w[completion_tokens] => 'ct',
|
|
29
|
+
%w[output_tokens] => 'ct',
|
|
30
|
+
# total
|
|
31
|
+
%w[total_tokens] => 'tt',
|
|
32
|
+
# cache-hit (GLM prompt_tokens_details, OpenAI input_tokens_details)
|
|
33
|
+
%w[prompt_tokens_details cached_tokens] => 'cct',
|
|
34
|
+
%w[input_tokens_details cached_tokens] => 'cct',
|
|
35
|
+
# cache-write (OpenAI input_tokens_details)
|
|
36
|
+
%w[input_tokens_details cache_write_tokens] => 'cwt',
|
|
37
|
+
# reasoning (GLM completion_tokens_details, OpenAI output_tokens_details)
|
|
38
|
+
%w[completion_tokens_details reasoning_tokens] => 'rt',
|
|
39
|
+
%w[output_tokens_details reasoning_tokens] => 'rt',
|
|
40
|
+
# Anthropic flat fields
|
|
41
|
+
%w[cache_read_input_tokens] => 'cct',
|
|
42
|
+
%w[cache_creation_input_tokens] => 'cwt',
|
|
43
|
+
}.freeze
|
|
4
44
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
45
|
+
# Normalise a provider usage hash into a flat hash keyed by TOKEN_KEYS.
|
|
46
|
+
# Handles OpenAI Chat API (prompt_tokens/completion_tokens), Responses API
|
|
47
|
+
# (input_tokens/output_tokens), GLM (prompt_tokens/completion_tokens), and
|
|
48
|
+
# Anthropic (cache_read_input_tokens/cache_creation_input_tokens).
|
|
49
|
+
#
|
|
50
|
+
# Missing fields are simply omitted from the result.
|
|
51
|
+
def self.normalize_usage(usage)
|
|
52
|
+
return {} if usage.nil? || usage.empty?
|
|
53
|
+
|
|
54
|
+
IndiferentHash.setup(usage) unless usage.respond_to?(:dig)
|
|
55
|
+
|
|
56
|
+
result = {}
|
|
57
|
+
USAGE_FIELD_MAP.each do |path, short_key|
|
|
58
|
+
next if result.include?(short_key) # first match wins
|
|
59
|
+
value = IndiferentHash.dig(usage, *path)
|
|
60
|
+
result[short_key] = value.to_i if value
|
|
8
61
|
end
|
|
9
62
|
|
|
10
|
-
|
|
11
|
-
|
|
63
|
+
# Compute total if not provided but both prompt and completion are present
|
|
64
|
+
if !result.include?('tt')
|
|
65
|
+
pt = result['pt']
|
|
66
|
+
ct = result['ct']
|
|
67
|
+
result['tt'] = pt.to_i + ct.to_i if pt && ct
|
|
68
|
+
end
|
|
12
69
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
meta = {}
|
|
16
|
-
key = parts.shift
|
|
17
|
-
while next_part = parts.shift
|
|
70
|
+
result
|
|
71
|
+
end
|
|
18
72
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
73
|
+
# Serialize a meta hash into a single space-separated string of key=value
|
|
74
|
+
# pairs. Values that contain '=' are wrapped in double quotes so that the
|
|
75
|
+
# '=' inside them is not mistaken for a key/value delimiter during parsing.
|
|
76
|
+
# Backslashes and double quotes inside quoted values are escaped.
|
|
77
|
+
#
|
|
78
|
+
# Keys are sorted by value string length (ascending) so that the longest
|
|
79
|
+
# free-text value appears last; this preserves backward compatibility with
|
|
80
|
+
# the unquoted parser path where the final value extends to end-of-string.
|
|
81
|
+
def self.serialize_meta(meta)
|
|
82
|
+
keys = meta.keys.sort_by { |key| String === meta[key] ? meta[key].length : 0 }
|
|
83
|
+
keys.collect do |key|
|
|
84
|
+
value = meta[key]
|
|
85
|
+
str_value = value.to_s
|
|
86
|
+
if str_value.include?('=')
|
|
87
|
+
escaped = str_value.gsub('\\') { '\\\\' }.gsub('"') { '\\"' }
|
|
88
|
+
%Q(#{key}="#{escaped}")
|
|
24
89
|
else
|
|
25
|
-
|
|
90
|
+
"#{key}=#{str_value}"
|
|
26
91
|
end
|
|
92
|
+
end * ' '
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Parse a serialized meta string back into an IndiferentHash.
|
|
96
|
+
#
|
|
97
|
+
# Each token is key=value where value may be:
|
|
98
|
+
# * A double-quoted string (used when the value contains '='):
|
|
99
|
+
# key="some text with = inside"
|
|
100
|
+
# Backslash escapes inside quotes are unescaped.
|
|
101
|
+
# * An unquoted bare value that may contain spaces but not '=':
|
|
102
|
+
# key=some text here
|
|
103
|
+
# The value boundary is detected by a lookahead for the next
|
|
104
|
+
# ' key=' pattern or end-of-string.
|
|
105
|
+
#
|
|
106
|
+
# ScoutCoder: when the quoted value contains inner double quotes that were
|
|
107
|
+
# not escaped during serialization (e.g. reasoning text that embeds file
|
|
108
|
+
# paths like ["/path"]), the quoted-value alternative must not terminate at
|
|
109
|
+
# the first inner quote. The inner quote is only treated as the closing
|
|
110
|
+
# quote when it is followed by a new key= boundary or end-of-string. This
|
|
111
|
+
# is achieved with a negative lookahead inside the character class:
|
|
112
|
+
# "(?!\s+[^\s=]+=|\s*\z)
|
|
113
|
+
def self.parse_meta(str)
|
|
114
|
+
str = str.to_s
|
|
115
|
+
meta = IndiferentHash.setup({})
|
|
116
|
+
return meta if str.empty?
|
|
27
117
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
else
|
|
34
|
-
meta[key] = value
|
|
118
|
+
str.scan(/([^\s=]+)=("(?:[^"\\]|\\.|"(?!\s+[^\s=]+=|\s*\z))*"|.*?)(?=\s+[^\s=]+=|\s*\z)/m).each do |key, raw|
|
|
119
|
+
value = if raw.start_with?('"') && raw.end_with?('"')
|
|
120
|
+
raw[1..-2].gsub(/\\(.)/) { $1 }
|
|
121
|
+
else
|
|
122
|
+
raw
|
|
35
123
|
end
|
|
36
124
|
|
|
37
|
-
key =
|
|
38
|
-
|
|
125
|
+
meta[key] = case value
|
|
126
|
+
when /^-?\d+$/ then value.to_i
|
|
127
|
+
when /^-?\d+\.\d+$/ then value.to_f
|
|
128
|
+
else value
|
|
129
|
+
end
|
|
130
|
+
end
|
|
39
131
|
|
|
40
132
|
meta
|
|
41
|
-
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Meta messages are local bookkeeping and are not sent to the provider.
|
|
136
|
+
# The last direct inference checkpoint supplies the linear chat total for
|
|
137
|
+
# the next request; job metadata deliberately contributes no token counts.
|
|
138
|
+
def self.meta(messages)
|
|
139
|
+
meta_messages = []
|
|
140
|
+
messages.reject! do |message|
|
|
141
|
+
match = message[:role].to_s == 'meta'
|
|
142
|
+
meta_messages << message if match
|
|
143
|
+
match
|
|
144
|
+
end
|
|
145
|
+
return nil if meta_messages.empty?
|
|
146
|
+
|
|
147
|
+
metas = meta_messages.collect { |message| parse_meta(message[:content]) }
|
|
148
|
+
current = IndiferentHash.setup(metas.last.dup)
|
|
149
|
+
checkpoint = metas.reverse.find do |meta|
|
|
150
|
+
CUMULATIVE_KEYS.any? { |name| meta.include?(name) }
|
|
151
|
+
end
|
|
152
|
+
if checkpoint
|
|
153
|
+
CUMULATIVE_KEYS.each do |name|
|
|
154
|
+
current[name] = checkpoint[name] if checkpoint.include?(name)
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
current
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def add_meta(key, value)
|
|
161
|
+
meta_msg = role_messages(:meta).last
|
|
162
|
+
meta = meta_msg ? Chat.parse_meta(meta_msg[:content]) : {}
|
|
163
|
+
meta[key] = value
|
|
164
|
+
if meta_msg
|
|
165
|
+
meta_msg[:content] = Chat.serialize_meta(meta)
|
|
166
|
+
else
|
|
167
|
+
message :meta, Chat.serialize_meta(meta)
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def meta
|
|
172
|
+
meta_msg = role_messages(:meta).last
|
|
173
|
+
return {} if meta_msg.nil?
|
|
174
|
+
Chat.parse_meta(meta_msg[:content])
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def job_paths
|
|
178
|
+
role_messages(:meta).collect do |message|
|
|
179
|
+
Path.setup(Chat.parse_meta(message[:content])[:job])
|
|
180
|
+
end.compact.uniq
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
alias jobs job_paths
|
|
184
|
+
|
|
185
|
+
# Read a persisted chat without compiling it. Provenance inspection must not
|
|
186
|
+
# execute task, job, file, or import roles again.
|
|
187
|
+
def self.load(file)
|
|
188
|
+
Chat.setup(Chat.parse(Open.read(file)))
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def self.job_agent_chat_files(job)
|
|
192
|
+
direct_job_chat_files(job)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Return the result and logged chats for a job and all its dependencies.
|
|
196
|
+
# A job is visited only once, so shared dependencies and accidental cycles do
|
|
197
|
+
# not duplicate evidence or recurse forever.
|
|
198
|
+
def self.job_chat_files(job, seen = Set.new)
|
|
199
|
+
job = Step.load(job) unless Step === job
|
|
200
|
+
key = File.expand_path(job.path.to_s)
|
|
201
|
+
return [] if seen.include?(key)
|
|
202
|
+
seen << key
|
|
203
|
+
|
|
204
|
+
chats = []
|
|
205
|
+
chats << job.path if job.done? && job.type.to_s == 'chat'
|
|
206
|
+
|
|
207
|
+
chats.concat job_agent_chat_files(job)
|
|
208
|
+
|
|
209
|
+
job.dependencies.each do |dependency|
|
|
210
|
+
chats.concat(job_chat_files(dependency, seen))
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
chats.collect(&:to_s).uniq
|
|
214
|
+
rescue
|
|
215
|
+
[]
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def job_chat_files
|
|
219
|
+
jobs.flat_map { |job| Chat.job_chat_files(job) }.uniq
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# ScoutCoder: DUAL-LAYOUT filter (see Chat::DIRECT_LOG_CHAT_GLOBS). Agent
|
|
223
|
+
# chats are the NEW layout files living under the job's own files dir
|
|
224
|
+
# (.files/<name>.chat and .files/<name>.society/**, which no longer have a
|
|
225
|
+
# log/ component) plus the LEGACY .files/log/ tree written by older
|
|
226
|
+
# scout-ai. Other jobs' second-order .files trees are excluded by requiring
|
|
227
|
+
# the file to be under THIS job's files dir.
|
|
228
|
+
def job_agent_chat_files
|
|
229
|
+
jobs.flat_map do |job|
|
|
230
|
+
job_files = job.path.to_s + '.files'
|
|
231
|
+
Chat.provenance_chat_files(job, root_type: :job).select do |file|
|
|
232
|
+
file.include?('.files/log/') || file.start_with?(job_files + '/')
|
|
233
|
+
end
|
|
234
|
+
end.uniq
|
|
235
|
+
end
|
|
42
236
|
|
|
43
|
-
def
|
|
44
|
-
|
|
237
|
+
def job_chats
|
|
238
|
+
job_chat_files.collect { |file| Chat.load(file) }
|
|
239
|
+
end
|
|
45
240
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
messages.reject!{|info| info[:role].to_sym == :meta }
|
|
49
|
-
parse_meta meta_str
|
|
241
|
+
def job_agent_chats
|
|
242
|
+
job_agent_chat_files.collect { |file| Chat.load(file) }
|
|
50
243
|
end
|
|
244
|
+
|
|
245
|
+
# A lineage id identifies a message in its non-meta conversational history.
|
|
246
|
+
# Meta is deliberately excluded from that history: it starts a response
|
|
247
|
+
# segment but is not provider input.
|
|
248
|
+
def message_index(source: nil)
|
|
249
|
+
previous = nil
|
|
250
|
+
each_with_index.collect do |message, position|
|
|
251
|
+
role = message[:role].to_s
|
|
252
|
+
content = message[:content].to_s
|
|
253
|
+
id = Misc.digest([previous, role, content])
|
|
254
|
+
info = {
|
|
255
|
+
id: id,
|
|
256
|
+
role: role.to_sym,
|
|
257
|
+
prev: previous,
|
|
258
|
+
fingerprint: Log.truncate_string(content)
|
|
259
|
+
}
|
|
260
|
+
info[:address] = [source.to_s, position] if source
|
|
261
|
+
if role == 'meta'
|
|
262
|
+
info[:meta] = Chat.parse_meta(content)
|
|
263
|
+
else
|
|
264
|
+
previous = id
|
|
265
|
+
end
|
|
266
|
+
info
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# A meta starts a response segment. The segment continues until another meta,
|
|
271
|
+
# a new user/system turn, or the end of the chat. Consecutive and final metas
|
|
272
|
+
# with no covered messages remain as orphan records.
|
|
273
|
+
# Trace one or more message indexes into segment entries. By default
|
|
274
|
+
# entries are globally deduplicated across every supplied index (an
|
|
275
|
+
# inference copied into two chats yields one entry), which is the semantics
|
|
276
|
+
# Chat.token_totals has always had. Pass `deduplicate: false` when the
|
|
277
|
+
# caller needs every persisted evidence location preserved, e.g.
|
|
278
|
+
# Chat.provenance_token_events, which performs its own identity grouping so
|
|
279
|
+
# an event can list all of its evidence records.
|
|
280
|
+
def self.trace_indices(indices, deduplicate: true)
|
|
281
|
+
seen = Set.new
|
|
282
|
+
trace = []
|
|
283
|
+
add = lambda do |pending|
|
|
284
|
+
return if pending.nil?
|
|
285
|
+
inference_id = pending[:meta][:inference_id]
|
|
286
|
+
deduplication = inference_id ? :inference_id : :legacy_lineage
|
|
287
|
+
dedup_key = inference_id ? [:inference_id, inference_id] : [:lineage, pending[:id]]
|
|
288
|
+
return if deduplicate && seen.include?(dedup_key)
|
|
289
|
+
seen << dedup_key
|
|
290
|
+
trace << {
|
|
291
|
+
id: pending[:id],
|
|
292
|
+
lineage_id: pending[:id],
|
|
293
|
+
inference_id: inference_id,
|
|
294
|
+
deduplication: deduplication,
|
|
295
|
+
meta_address: pending[:address],
|
|
296
|
+
meta: IndiferentHash.setup(pending[:meta].except(:reas)),
|
|
297
|
+
messages: pending[:messages],
|
|
298
|
+
message_addresses: pending[:message_addresses],
|
|
299
|
+
orphan: pending[:messages].empty?
|
|
300
|
+
}
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
indices.each do |index|
|
|
304
|
+
pending = nil
|
|
305
|
+
index.each do |info|
|
|
306
|
+
case info[:role]
|
|
307
|
+
when :meta
|
|
308
|
+
add.call(pending)
|
|
309
|
+
pending = {
|
|
310
|
+
id: info[:id], meta: info[:meta], address: info[:address],
|
|
311
|
+
messages: [], message_addresses: []
|
|
312
|
+
}
|
|
313
|
+
when :user, :system
|
|
314
|
+
add.call(pending)
|
|
315
|
+
pending = nil
|
|
316
|
+
else
|
|
317
|
+
if pending
|
|
318
|
+
pending[:messages] << info[:id]
|
|
319
|
+
pending[:message_addresses] << info[:address] if info[:address]
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
end
|
|
323
|
+
add.call(pending)
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
trace
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
def self.trace_chats(chats)
|
|
330
|
+
trace_indices(chats.collect(&:message_index))
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
# Trace chats while preserving the persisted address of every meta and
|
|
334
|
+
# covered message. Sources may be a Hash of path => Chat or an Array of
|
|
335
|
+
# [path, Chat] pairs. The optional second argument keeps one entry per
|
|
336
|
+
# persisted location instead of collapsing copies (see Chat.trace_indices);
|
|
337
|
+
# it is positional because `sources` is naturally a brace-less Hash at call
|
|
338
|
+
# sites, which Ruby 3 would otherwise turn into keywords.
|
|
339
|
+
def self.trace_chat_sources(sources, deduplicate = true)
|
|
340
|
+
pairs = sources.to_a
|
|
341
|
+
trace_indices(pairs.collect { |source, chat| chat.message_index(source: source) }, deduplicate: deduplicate)
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
# Select trace entries that carry direct token counts (not job projections).
|
|
345
|
+
def self.direct_entries(chat_list)
|
|
346
|
+
trace_chats(chat_list).select do |entry|
|
|
347
|
+
meta = entry[:meta]
|
|
348
|
+
next false if meta[:job]
|
|
349
|
+
TOKEN_KEYS.any? { |name| meta.include?(name) }
|
|
350
|
+
end
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
# Sum direct token fields across a set of chats. Returns a hash keyed
|
|
354
|
+
# by symbol for every key in TOKEN_KEYS.
|
|
355
|
+
def self.token_totals(chat_list)
|
|
356
|
+
totals = TOKEN_KEYS.each_with_object({}) { |k, h| h[k.to_sym] = 0 }
|
|
357
|
+
direct_entries(chat_list).each do |entry|
|
|
358
|
+
meta = entry[:meta]
|
|
359
|
+
TOKEN_KEYS.each { |name| totals[name.to_sym] += meta[name].to_i }
|
|
360
|
+
end
|
|
361
|
+
totals
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
# Human-readable token summary suitable for one-line CLI output.
|
|
365
|
+
def self.print_tokens(tokens)
|
|
366
|
+
tokens = tokens.transform_keys(&:to_sym) if Hash === tokens
|
|
367
|
+
parts = []
|
|
368
|
+
parts << "prompt=#{Misc.human_number(tokens[:pt])}" if tokens[:pt]
|
|
369
|
+
parts << "completion=#{Misc.human_number(tokens[:ct])}" if tokens[:ct]
|
|
370
|
+
parts << "total=#{Misc.human_number(tokens[:tt])}" if tokens[:tt]
|
|
371
|
+
if tokens[:cct] && tokens[:cct].to_i > 0
|
|
372
|
+
parts << "cached=#{Misc.human_number(tokens[:cct])}"
|
|
373
|
+
end
|
|
374
|
+
if tokens[:cwt] && tokens[:cwt].to_i > 0
|
|
375
|
+
parts << "cache_write=#{Misc.human_number(tokens[:cwt])}"
|
|
376
|
+
end
|
|
377
|
+
if tokens[:rt] && tokens[:rt].to_i > 0
|
|
378
|
+
parts << "reasoning=#{Misc.human_number(tokens[:rt])}"
|
|
379
|
+
end
|
|
380
|
+
parts * ' '
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
# Project a chat-task response from the job that produced it. The response
|
|
384
|
+
# gets exactly one producer marker ({job: <path>}) at its beginning, and the
|
|
385
|
+
# per-inference meta messages are kept inline, adjacent to the function
|
|
386
|
+
# calls they account for, so a projected chat carries the same token
|
|
387
|
+
# provenance as the original agent log.
|
|
388
|
+
#
|
|
389
|
+
# The marker and the inference metas are intentionally separate messages:
|
|
390
|
+
# direct_entries and token_totals exclude any meta carrying +job+, so
|
|
391
|
+
# merging the marker into an inference meta would drop that inference from
|
|
392
|
+
# direct accounting. Keeping both means the same inference can be seen twice
|
|
393
|
+
# in a parent chat (projected copy + agent log); trace_indices dedups those
|
|
394
|
+
# copies by inference_id.
|
|
395
|
+
#
|
|
396
|
+
# +reas+ (reasoning summaries) are dropped from projected copies unless
|
|
397
|
+
# Scout::Config key `chat.project.keep_reas` is truthy: they dominate the
|
|
398
|
+
# size of a projected chat while token attribution and deduplication only
|
|
399
|
+
# need inference_id and the token fields.
|
|
400
|
+
#
|
|
401
|
+
# The projection is idempotent: re-projecting an already projected response
|
|
402
|
+
# of the same job yields the same segments and token totals, without
|
|
403
|
+
# duplicating the marker or any inference meta.
|
|
404
|
+
#
|
|
405
|
+
# ScoutCoder: re-projection is not a corner case. `LLM::Agent#ask` feeds
|
|
406
|
+
# every consumed dependency job chat back through Chat.project, so a chat
|
|
407
|
+
# that has already been projected once is projected again when its parent
|
|
408
|
+
# answer is consumed. The seen_inference guard below is what keeps that
|
|
409
|
+
# path from emitting the same inference twice.
|
|
410
|
+
def self.project(job, messages)
|
|
411
|
+
return [] if Array(messages).empty?
|
|
412
|
+
keep_reas = %w(true TRUE True T 1).include?(Scout::Config.get(:keep_reas, :project, :chat, env: 'CHAT_PROJECT_KEEP_REAS').to_s)
|
|
413
|
+
|
|
414
|
+
seen_inference = {}
|
|
415
|
+
projected = Array(messages).collect do |message|
|
|
416
|
+
next message.dup unless message[:role].to_s == 'meta'
|
|
417
|
+
|
|
418
|
+
meta = parse_meta(message[:content])
|
|
419
|
+
if meta[:job]
|
|
420
|
+
next nil
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
identity = meta[:inference_id] || message[:content]
|
|
424
|
+
next nil if seen_inference.key?(identity)
|
|
425
|
+
seen_inference[identity] = true
|
|
426
|
+
|
|
427
|
+
if keep_reas
|
|
428
|
+
message.dup
|
|
429
|
+
else
|
|
430
|
+
{ role: :meta, content: serialize_meta(meta.except(:reas)) }
|
|
431
|
+
end
|
|
432
|
+
end.compact
|
|
433
|
+
|
|
434
|
+
return [] if projected.empty?
|
|
435
|
+
[{ role: :meta, content: serialize_meta(job: job.to_s) }] + projected
|
|
436
|
+
end
|
|
437
|
+
|
|
51
438
|
end
|
|
@@ -1,4 +1,22 @@
|
|
|
1
1
|
module Chat
|
|
2
|
+
def self.config(chat)
|
|
3
|
+
new = []
|
|
4
|
+
|
|
5
|
+
chat.select do |info|
|
|
6
|
+
if Hash === info
|
|
7
|
+
role = info[:role].to_s
|
|
8
|
+
if role.to_s == 'config'
|
|
9
|
+
key, value, *tokens = info[:content].split(" ")
|
|
10
|
+
Scout::Config.set({key => value}, *tokens)
|
|
11
|
+
next
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
new << info
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
chat.replace new
|
|
18
|
+
end
|
|
19
|
+
|
|
2
20
|
def self.options(chat)
|
|
3
21
|
options = IndiferentHash.setup({})
|
|
4
22
|
sticky_options = IndiferentHash.setup({})
|
|
@@ -47,6 +65,8 @@ module Chat
|
|
|
47
65
|
new << info
|
|
48
66
|
end
|
|
49
67
|
chat.replace new
|
|
50
|
-
sticky_options.merge options
|
|
68
|
+
options = sticky_options.merge options
|
|
69
|
+
options.delete_if{|k,v| v.nil? || v == 'nil' }
|
|
70
|
+
options
|
|
51
71
|
end
|
|
52
72
|
end
|
|
@@ -1,5 +1,32 @@
|
|
|
1
1
|
module Chat
|
|
2
2
|
|
|
3
|
+
def self.allow_path(path)
|
|
4
|
+
Thread.current['allowed_paths'] ||= []
|
|
5
|
+
return if Thread.current['allowed_paths'].include?(path)
|
|
6
|
+
Log.medium "Allow #{path}"
|
|
7
|
+
Thread.current['allowed_paths'] << path
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def self.allow_read_path(path)
|
|
11
|
+
Thread.current['allowed_read_paths'] ||= []
|
|
12
|
+
return if Thread.current['allowed_read_paths'].include?(path)
|
|
13
|
+
Log.medium "Allow read #{path}"
|
|
14
|
+
Thread.current['allowed_read_paths'] << path
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.allow_job(job)
|
|
18
|
+
allow_path(job.path)
|
|
19
|
+
allow_path(job.info_file)
|
|
20
|
+
allow_path(job.files_dir)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.allow_read_job(job)
|
|
24
|
+
allow_read_path(job.path)
|
|
25
|
+
allow_read_path(job.info_file)
|
|
26
|
+
allow_read_path(job.files_dir)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
|
|
3
30
|
def self.load_workflow(workflow)
|
|
4
31
|
workflow = begin
|
|
5
32
|
Kernel.const_get workflow
|
|
@@ -33,11 +60,10 @@ module Chat
|
|
|
33
60
|
jobs << job unless message[:role] == 'exec_task'
|
|
34
61
|
|
|
35
62
|
if message[:role] == 'exec_task'
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
end
|
|
63
|
+
result = job.exec
|
|
64
|
+
result = result.to_s if TSV === result
|
|
65
|
+
result = result.to_json unless String === result
|
|
66
|
+
{role: 'user', content: result}
|
|
41
67
|
elsif message[:role] == 'inline_task'
|
|
42
68
|
{role: 'inline_job', content: job.path.find}
|
|
43
69
|
else
|
|
@@ -60,7 +86,7 @@ module Chat
|
|
|
60
86
|
|
|
61
87
|
step = Step.load file
|
|
62
88
|
|
|
63
|
-
id = step.short_path
|
|
89
|
+
id = Log.truncate_string(step.short_path.sub('Default_', ''), 40)
|
|
64
90
|
id = id.gsub('/','-')
|
|
65
91
|
|
|
66
92
|
if message[:role] == 'inline_job'
|
|
@@ -72,15 +98,16 @@ module Chat
|
|
|
72
98
|
function_name = step.full_task_name.sub('#', '-')
|
|
73
99
|
function_name = step.task_name
|
|
74
100
|
tool_call = {
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
arguments: step.provided_inputs
|
|
78
|
-
},
|
|
101
|
+
name: function_name,
|
|
102
|
+
arguments: step.provided_inputs,
|
|
79
103
|
id: id,
|
|
80
104
|
}
|
|
81
105
|
|
|
82
106
|
content = if step.done?
|
|
83
107
|
Open.read(step.path)
|
|
108
|
+
elsif step.streaming?
|
|
109
|
+
step.join
|
|
110
|
+
step.load
|
|
84
111
|
elsif step.error?
|
|
85
112
|
Log.warn "Error in job #{step.path}"
|
|
86
113
|
e = step.exception
|
|
@@ -151,7 +178,7 @@ module Chat
|
|
|
151
178
|
end
|
|
152
179
|
next
|
|
153
180
|
elsif role == 'introduce'
|
|
154
|
-
workflow_name = message[:content]
|
|
181
|
+
workflow_name = message[:content].to_s
|
|
155
182
|
next if introduced_workflows.include? workflow_name
|
|
156
183
|
introduced_workflows << workflow_name
|
|
157
184
|
workflow = begin
|
|
@@ -169,7 +196,7 @@ module Chat
|
|
|
169
196
|
|
|
170
197
|
raise "Workflow not found #{workflow_name}" if workflow.nil?
|
|
171
198
|
|
|
172
|
-
next
|
|
199
|
+
next if workflow.documentation.empty?
|
|
173
200
|
content = <<-EOF
|
|
174
201
|
You have access to tools from workflow '#{workflow.name}'.
|
|
175
202
|
Below is the documentation of the workflow:
|
|
@@ -183,8 +210,14 @@ Below is the documentation of the workflow:
|
|
|
183
210
|
elsif role == 'kb'
|
|
184
211
|
knowledge_base_name, *databases = content_tokens(message)
|
|
185
212
|
databases = nil if databases.empty?
|
|
213
|
+
|
|
186
214
|
knowledge_base = KnowledgeBase.load knowledge_base_name
|
|
187
215
|
|
|
216
|
+
if knowledge_base.all_databases.empty?
|
|
217
|
+
agent = LLM.load_agent knowledge_base_name
|
|
218
|
+
knowledge_base = agent.knowledge_base if agent.knowledge_base && agent.knowledge_base.all_databases.any?
|
|
219
|
+
end
|
|
220
|
+
|
|
188
221
|
knowledge_base_definition = LLM.knowledge_base_tool_definition(knowledge_base, databases)
|
|
189
222
|
tool_definitions.merge!(knowledge_base_definition)
|
|
190
223
|
next
|
|
@@ -203,10 +236,14 @@ Below is the documentation of the workflow:
|
|
|
203
236
|
new = messages.collect do |message|
|
|
204
237
|
role = message[:role]
|
|
205
238
|
if role == 'association'
|
|
206
|
-
name, path, *
|
|
207
|
-
|
|
239
|
+
name, path, *_ = content_tokens(message)
|
|
208
240
|
kb ||= KnowledgeBase.new Scout.var.Agent.Chat.knowledge_base
|
|
209
|
-
|
|
241
|
+
|
|
242
|
+
options = IndiferentHash.parse_options(message[:content])
|
|
243
|
+
options[:fields] = options[:fields].split(/,\s*/) if String === options[:fields]
|
|
244
|
+
options[:type] = options[:type].to_sym if String === options[:type]
|
|
245
|
+
|
|
246
|
+
kb.register name, Path.setup(path), options
|
|
210
247
|
|
|
211
248
|
tool_definitions.merge!(LLM.knowledge_base_tool_definition( kb, [name]))
|
|
212
249
|
next
|
|
@@ -219,4 +256,8 @@ Below is the documentation of the workflow:
|
|
|
219
256
|
messages.replace new
|
|
220
257
|
tool_definitions
|
|
221
258
|
end
|
|
259
|
+
|
|
260
|
+
def tooling
|
|
261
|
+
self.select{|msg| [:introduce, :tool, :mcp, :kb].include?(msg[:role].to_sym) }
|
|
262
|
+
end
|
|
222
263
|
end
|