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,775 @@
|
|
|
1
|
+
require 'set'
|
|
2
|
+
require 'time'
|
|
3
|
+
|
|
4
|
+
module Chat
|
|
5
|
+
PROVENANCE_RELATIONS = %i[job dependency log result agent_job].freeze
|
|
6
|
+
|
|
7
|
+
# ScoutCoder: DUAL-LAYOUT note. Two on-disk layouts must be globbed for a
|
|
8
|
+
# job's own chat logs and for a saved chat's .files sidecar:
|
|
9
|
+
#
|
|
10
|
+
# new <x>.files/<name>.chat (agent.chat, worker.chat, ...)
|
|
11
|
+
# new <x>.files/<name>.society/**/*.chat (society tree; nested
|
|
12
|
+
# societies keep the plain 'society' basename deeper down)
|
|
13
|
+
# legacy <x>.files/log/**/*.chat (written by older
|
|
14
|
+
# scout-ai; read-only compatibility, never migrated)
|
|
15
|
+
#
|
|
16
|
+
# Exactly these three families may be swept: resets/, other *.files
|
|
17
|
+
# subdirectories and second-order .files trees stay invisible. Files matched
|
|
18
|
+
# by more than one pattern are deduplicated and results are sorted so the
|
|
19
|
+
# traversal order is deterministic.
|
|
20
|
+
DIRECT_LOG_CHAT_GLOBS = ['*.chat', '*.society/**/*.chat', 'log/**/*.chat'].freeze
|
|
21
|
+
|
|
22
|
+
# Glob DIRECT_LOG_CHAT_GLOBS under `files_dir` and return a de-duplicated,
|
|
23
|
+
# sorted list of existing chat files (Path objects). Both new and legacy
|
|
24
|
+
# layouts are returned together; callers that must exclude the root copy of
|
|
25
|
+
# the root conversation (Chat.direct_chat_sidecar_files) filter afterwards.
|
|
26
|
+
def self.direct_log_chat_glob(files_dir)
|
|
27
|
+
files_dir = files_dir.to_s
|
|
28
|
+
return [] unless File.directory?(files_dir)
|
|
29
|
+
DIRECT_LOG_CHAT_GLOBS
|
|
30
|
+
.flat_map { |pattern| Dir.glob(File.join(files_dir, pattern)) }
|
|
31
|
+
.collect { |file| File.expand_path(file) }
|
|
32
|
+
.select { |file| File.file?(file) }
|
|
33
|
+
.uniq
|
|
34
|
+
.sort
|
|
35
|
+
.collect { |file| Path.setup(file) }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# ScoutCoder: DUAL-LAYOUT globbing (see DIRECT_LOG_CHAT_GLOBS): a job's own
|
|
39
|
+
# chat logs live at <job>.files/<name>.chat (new), under
|
|
40
|
+
# <job>.files/<name>.society/** (new society tree) or under the legacy
|
|
41
|
+
# <job>.files/log/** (written by older scout-ai; read-only compatibility,
|
|
42
|
+
# never migrated). This method is deliberately not recursive; recursion
|
|
43
|
+
# belongs to traverse_provenance.
|
|
44
|
+
def self.direct_job_chat_files(job)
|
|
45
|
+
job = Step.load(job) unless Step === job
|
|
46
|
+
direct_log_chat_glob(job.files_dir)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Return only chat logs owned by this persisted chat's .files sidecar. The
|
|
50
|
+
# save mechanism writes saved agent conversations into the chat's .files dir
|
|
51
|
+
# exactly like a job does, so a chat with a sidecar is scanned the same way a
|
|
52
|
+
# job is. This method is deliberately not recursive; recursion belongs to
|
|
53
|
+
# traverse_provenance.
|
|
54
|
+
#
|
|
55
|
+
# ScoutCoder: DUAL-LAYOUT globbing (see DIRECT_LOG_CHAT_GLOBS) plus the
|
|
56
|
+
# root-copy exclusion. The save mechanism writes a full copy of the ROOT
|
|
57
|
+
# conversation at the TOP LEVEL of the files dir in BOTH layouts:
|
|
58
|
+
#
|
|
59
|
+
# new <path>.files/<name>.chat (agent.chat, worker.chat, ...)
|
|
60
|
+
# legacy <path>.files/log/agent.chat
|
|
61
|
+
#
|
|
62
|
+
# Those top-level copies must be excluded here or the chat would get a
|
|
63
|
+
# self-edge duplicating the root node. Note the asymmetry with
|
|
64
|
+
# direct_job_chat_files: a JOB's own top-level agent.chat IS traversed
|
|
65
|
+
# (renderers hide it), a CHAT's root copy is NOT. Only TOP-LEVEL *.chat
|
|
66
|
+
# files are excluded: society chats under <name>.society/ (new) and under
|
|
67
|
+
# log/society/ (legacy) are independent conversations and are included.
|
|
68
|
+
def self.direct_chat_sidecar_files(path)
|
|
69
|
+
files_dir = path.to_s + '.files'
|
|
70
|
+
return [] unless File.directory?(files_dir)
|
|
71
|
+
top_level = Dir.glob(File.join(files_dir, '*.chat')).collect { |file| File.expand_path(file) }
|
|
72
|
+
root_copy_legacy = File.expand_path(File.join(files_dir, 'log', 'agent.chat'))
|
|
73
|
+
direct_log_chat_glob(files_dir).reject do |file|
|
|
74
|
+
top_level.include?(file.to_s) || file.to_s == root_copy_legacy
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Return the persisted result as a chat file when the Step result type is
|
|
79
|
+
# chat. A job and its result chat may have the same path; traversal identities
|
|
80
|
+
# therefore always include the node kind.
|
|
81
|
+
def self.job_result_chat_file(job)
|
|
82
|
+
job = Step.load(job) unless Step === job
|
|
83
|
+
return nil unless job.type.to_s == 'chat' && File.file?(job.path.to_s)
|
|
84
|
+
Path.setup(File.expand_path(job.path.to_s))
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def self.provenance_path(kind, object)
|
|
88
|
+
path = File.expand_path(kind.to_sym == :job ? object.path.to_s : object.to_s)
|
|
89
|
+
File.realpath(path)
|
|
90
|
+
rescue SystemCallError
|
|
91
|
+
path
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def self.provenance_key(kind, object)
|
|
95
|
+
[kind.to_sym, provenance_path(kind, object)]
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def self.provenance_error(on_error, error, kind, object, relation, reference)
|
|
99
|
+
raise error unless on_error
|
|
100
|
+
on_error.call(error, kind, object, relation, reference)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Resolve a job reference from a chat meta message into a Step. References
|
|
104
|
+
# like "Planned/ask/Default_abc.chat" are relative workflow paths. Step.load
|
|
105
|
+
# may resolve them to a Scout-specific directory that does not contain the
|
|
106
|
+
# actual job data, so we fall back to checking Rbbt.var.jobs and Scout.var.jobs.
|
|
107
|
+
def self.load_job_reference(reference)
|
|
108
|
+
return reference if Step === reference
|
|
109
|
+
ref_str = reference.to_s
|
|
110
|
+
|
|
111
|
+
step = Step.load(ref_str)
|
|
112
|
+
return step if File.exist?(step.path.to_s) || File.exist?(step.path.to_s + '.info')
|
|
113
|
+
|
|
114
|
+
# Step.load resolves relative workflow paths (e.g. Planned/ask/Default_xyz.chat)
|
|
115
|
+
# via Path.find, which may point to a directory that does not contain the
|
|
116
|
+
# actual job data (e.g. ~/.scout/ instead of ~/.rbbt/var/jobs/). Try the
|
|
117
|
+
# standard Rbbt workflow storage location as a fallback.
|
|
118
|
+
rbbt_candidate = File.expand_path(File.join('~/.rbbt/var/jobs', ref_str))
|
|
119
|
+
return Step.load(rbbt_candidate) if File.exist?(rbbt_candidate) || File.exist?(rbbt_candidate + '.info')
|
|
120
|
+
|
|
121
|
+
step
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Resolve one job reference into a usable Step. Returns [step, nil] when the
|
|
125
|
+
# job data is loadable (path or .info sidecar exists) and [nil, step] when the
|
|
126
|
+
# reference resolves to a directory without job data, keeping the would-be
|
|
127
|
+
# step for diagnostics. Used by the :agent_job relation so unresolved
|
|
128
|
+
# receipt references are reported instead of enqueued.
|
|
129
|
+
def self.resolve_job_reference(reference)
|
|
130
|
+
step = load_job_reference(reference)
|
|
131
|
+
path = step.path.to_s
|
|
132
|
+
if File.exist?(path) || File.exist?(path + '.info')
|
|
133
|
+
[step, nil]
|
|
134
|
+
else
|
|
135
|
+
[nil, step]
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Diagnostics for agent_meta receipt problems found while expanding one chat
|
|
140
|
+
# node: malformed receipts collected by Chat.agent_meta_job_references.
|
|
141
|
+
# Provenance is only ever extracted from parsed function_call_output JSON
|
|
142
|
+
# Hashes carrying an explicit receipt key (current `meta`, legacy
|
|
143
|
+
# `agent_meta`); raw output text is never
|
|
144
|
+
# inspected, so output content that merely mentions "agent_meta" cannot
|
|
145
|
+
# produce a warning. Each malformed record becomes one Chat.provenance_error
|
|
146
|
+
# call with relation :agent_job and kind/object :chat + the chat path, so
|
|
147
|
+
# strict mode (no on_error) raises ScoutException while warning mode keeps
|
|
148
|
+
# going. Returns the valid job references so the caller can enqueue them
|
|
149
|
+
# afterwards.
|
|
150
|
+
def self.report_agent_meta_problems(chat, object, on_error = nil)
|
|
151
|
+
warnings = []
|
|
152
|
+
references = agent_meta_job_references(chat, source: object, warnings: warnings)
|
|
153
|
+
|
|
154
|
+
warnings.each do |warning|
|
|
155
|
+
error = ScoutException.new(
|
|
156
|
+
agent_meta_error_message(warning[:reason],
|
|
157
|
+
chat_path: object.to_s,
|
|
158
|
+
output_address: warning[:output_address],
|
|
159
|
+
call_id: warning[:call_id],
|
|
160
|
+
tool_name: warning[:tool_name],
|
|
161
|
+
reference: warning[:reference] || warning[:raw_entry])
|
|
162
|
+
)
|
|
163
|
+
reference = agent_meta_error_reference(reason: warning[:reason],
|
|
164
|
+
source: warning[:source] || object.to_s,
|
|
165
|
+
output_address: warning[:output_address],
|
|
166
|
+
evidence_address: warning[:evidence_address],
|
|
167
|
+
call_id: warning[:call_id],
|
|
168
|
+
tool_name: warning[:tool_name],
|
|
169
|
+
agent_meta_index: warning[:agent_meta_index],
|
|
170
|
+
raw_entry: warning[:raw_entry],
|
|
171
|
+
reference: warning[:reference])
|
|
172
|
+
provenance_error(on_error, error, :chat, object, :agent_job, reference)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
references
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Traverse the heterogeneous provenance graph using native Path and Step
|
|
179
|
+
# values. The block receives:
|
|
180
|
+
#
|
|
181
|
+
# kind, object, parent_kind, parent, relation, first_visit, detail
|
|
182
|
+
#
|
|
183
|
+
# `kind` is :chat or :job. Chat objects are persisted file Paths; jobs are
|
|
184
|
+
# Steps. The root has nil parent/relation. Every structural edge is yielded,
|
|
185
|
+
# including an edge to a node visited through another branch; first_visit is
|
|
186
|
+
# false in that case and the node is not expanded again.
|
|
187
|
+
#
|
|
188
|
+
# `detail` is nil for every ordinary edge. For an :agent_job edge it is the
|
|
189
|
+
# agent_meta receipt record (Chat.agent_meta_job_references entry) that
|
|
190
|
+
# produced the edge, so the originating function output address, receipt
|
|
191
|
+
# address, call id and tool name stay auditable without re-parsing the parent
|
|
192
|
+
# chat. Blocks accepting only the first six arguments keep working; the
|
|
193
|
+
# trailing detail is only passed when the block can receive it (or accepts
|
|
194
|
+
# any number of arguments).
|
|
195
|
+
#
|
|
196
|
+
# Relations describe root-outward discovery, not diagram arrow direction:
|
|
197
|
+
# chat -> producer job (:job), chat -> delegated-agent producer job
|
|
198
|
+
# (:agent_job, from agent_meta receipts), job -> dependency (:dependency),
|
|
199
|
+
# job -> log chat (:log), job -> result chat (:result), and chat -> log chat
|
|
200
|
+
# of its own .files sidecar (:log, saved agent conversations, excluding the
|
|
201
|
+
# root copy at <path>.files/log/agent.chat).
|
|
202
|
+
#
|
|
203
|
+
# Imported and continued chats are a chat-compilation concern, not a
|
|
204
|
+
# provenance concern. They are resolved during Chat.parse and their content
|
|
205
|
+
# is already inlined in the persisted chat file. Provenance traversal
|
|
206
|
+
# therefore never follows import, continue, or last references.
|
|
207
|
+
def self.traverse_provenance(root, root_type: nil, follow: :all, on_error: nil, &block)
|
|
208
|
+
return enum_for(__method__, root, root_type: root_type, follow: follow, on_error: on_error) unless block
|
|
209
|
+
# Lambda blocks have strict arity; keep six-argument callbacks compatible
|
|
210
|
+
# by only yielding the trailing detail when the block can receive it.
|
|
211
|
+
detail_arity = lambda do
|
|
212
|
+
return true if block.arity == -1
|
|
213
|
+
block.arity >= 7
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
relations = follow == :all ? PROVENANCE_RELATIONS : Array(follow).collect(&:to_sym)
|
|
217
|
+
unknown = relations - PROVENANCE_RELATIONS
|
|
218
|
+
raise ParameterException, "Unknown provenance relations: #{unknown * ', '}" if unknown.any?
|
|
219
|
+
|
|
220
|
+
root_kind = root_type && root_type.to_sym
|
|
221
|
+
root_kind ||= Step === root ? :job : :chat
|
|
222
|
+
root_object = if root_kind == :job
|
|
223
|
+
Step === root ? root : Step.load(root)
|
|
224
|
+
else
|
|
225
|
+
path = File.expand_path(root.to_s)
|
|
226
|
+
raise ParameterException, "Chat not found: #{root}" unless File.file?(path)
|
|
227
|
+
Path.setup(path)
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
queue = [[root_kind, root_object, nil, nil, nil]]
|
|
231
|
+
seen = Set.new
|
|
232
|
+
|
|
233
|
+
until queue.empty?
|
|
234
|
+
kind, object, parent_kind, parent, relation, detail = queue.shift
|
|
235
|
+
key = provenance_key(kind, object)
|
|
236
|
+
first_visit = !seen.include?(key)
|
|
237
|
+
if detail_arity.call
|
|
238
|
+
block.call(kind, object, parent_kind, parent, relation, first_visit, detail)
|
|
239
|
+
else
|
|
240
|
+
block.call(kind, object, parent_kind, parent, relation, first_visit)
|
|
241
|
+
end
|
|
242
|
+
next unless first_visit
|
|
243
|
+
seen << key
|
|
244
|
+
|
|
245
|
+
begin
|
|
246
|
+
if kind == :chat
|
|
247
|
+
chat = Chat.load(object)
|
|
248
|
+
|
|
249
|
+
if relations.include?(:job)
|
|
250
|
+
chat.jobs.each do |reference|
|
|
251
|
+
begin
|
|
252
|
+
job = load_job_reference(reference)
|
|
253
|
+
queue << [:job, job, :chat, object, :job]
|
|
254
|
+
rescue StandardError => error
|
|
255
|
+
provenance_error(on_error, error, :chat, object, :job, reference)
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
if relations.include?(:agent_job)
|
|
261
|
+
# Malformed receipts are diagnostics, never provenance; they are
|
|
262
|
+
# reported through provenance_error and skipped.
|
|
263
|
+
references = begin
|
|
264
|
+
report_agent_meta_problems(chat, object, on_error)
|
|
265
|
+
rescue StandardError => error
|
|
266
|
+
provenance_error(on_error, error, :chat, object, :agent_job, object)
|
|
267
|
+
[]
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
references.each do |record|
|
|
271
|
+
reference = record[:job]
|
|
272
|
+
begin
|
|
273
|
+
job, _unresolved = resolve_job_reference(reference)
|
|
274
|
+
if job
|
|
275
|
+
# The receipt record travels with the edge as its detail so
|
|
276
|
+
# the traversal keeps the originating output address, call
|
|
277
|
+
# id and receipt index.
|
|
278
|
+
queue << [:job, job, :chat, object, :agent_job, record]
|
|
279
|
+
else
|
|
280
|
+
error = ScoutException.new(
|
|
281
|
+
agent_meta_error_message(:unresolved_job_reference,
|
|
282
|
+
chat_path: object.to_s,
|
|
283
|
+
output_address: record[:output_address],
|
|
284
|
+
call_id: record[:call_id],
|
|
285
|
+
tool_name: record[:tool_name],
|
|
286
|
+
reference: reference)
|
|
287
|
+
)
|
|
288
|
+
provenance_error(on_error, error, :chat, object, :agent_job,
|
|
289
|
+
agent_meta_error_reference(reason: :unresolved_job_reference,
|
|
290
|
+
source: object.to_s,
|
|
291
|
+
output_address: record[:output_address],
|
|
292
|
+
evidence_address: record[:evidence_address],
|
|
293
|
+
call_id: record[:call_id],
|
|
294
|
+
tool_name: record[:tool_name],
|
|
295
|
+
reference: reference))
|
|
296
|
+
end
|
|
297
|
+
rescue StandardError => error
|
|
298
|
+
provenance_error(on_error, error, :chat, object, :agent_job,
|
|
299
|
+
agent_meta_error_reference(reason: :unresolved_job_reference,
|
|
300
|
+
source: object.to_s,
|
|
301
|
+
reference: reference,
|
|
302
|
+
call_id: record[:call_id],
|
|
303
|
+
tool_name: record[:tool_name]))
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
if relations.include?(:log)
|
|
309
|
+
# A saved chat owns its .files sidecar logs (society
|
|
310
|
+
# conversations) just like a job owns its job logs.
|
|
311
|
+
direct_chat_sidecar_files(object).each do |file|
|
|
312
|
+
queue << [:chat, file, :chat, object, :log]
|
|
313
|
+
end
|
|
314
|
+
end
|
|
315
|
+
else
|
|
316
|
+
if relations.include?(:dependency)
|
|
317
|
+
object.dependencies.each do |dependency|
|
|
318
|
+
queue << [:job, dependency, :job, object, :dependency]
|
|
319
|
+
end
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
if relations.include?(:log)
|
|
323
|
+
direct_job_chat_files(object).each do |file|
|
|
324
|
+
queue << [:chat, file, :job, object, :log]
|
|
325
|
+
end
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
if relations.include?(:result) && (file = job_result_chat_file(object))
|
|
329
|
+
queue << [:chat, file, :job, object, :result]
|
|
330
|
+
end
|
|
331
|
+
end
|
|
332
|
+
rescue StandardError => error
|
|
333
|
+
provenance_error(on_error, error, kind, object, relation, object)
|
|
334
|
+
end
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
nil
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
# Flat structural edges suitable for JSON reports and renderers. Objects are
|
|
341
|
+
# intentionally retained as native values; presentation code can shorten or
|
|
342
|
+
# serialize paths as needed.
|
|
343
|
+
#
|
|
344
|
+
# `:agent_job` edges carry the agent_meta receipt record that produced them
|
|
345
|
+
# under `detail:` (call id, tool name, output address, receipt address, job
|
|
346
|
+
# reference). Two distinct receipts pointing at the same job therefore remain
|
|
347
|
+
# two distinguishable edges instead of collapsing into one; ordinary edges
|
|
348
|
+
# keep `detail: nil`.
|
|
349
|
+
def self.provenance_edges(root, **options)
|
|
350
|
+
edges = []
|
|
351
|
+
traverse_provenance(root, **options) do |kind, object, parent_kind, parent, relation, _first, detail|
|
|
352
|
+
next unless parent
|
|
353
|
+
edge = {
|
|
354
|
+
from_kind: parent_kind,
|
|
355
|
+
from: parent,
|
|
356
|
+
relation: relation,
|
|
357
|
+
to_kind: kind,
|
|
358
|
+
to: object,
|
|
359
|
+
detail: detail
|
|
360
|
+
}
|
|
361
|
+
same_edge = lambda do |other|
|
|
362
|
+
other[:relation] == relation &&
|
|
363
|
+
provenance_key(other[:from_kind], other[:from]) == provenance_key(parent_kind, parent) &&
|
|
364
|
+
provenance_key(other[:to_kind], other[:to]) == provenance_key(kind, object) &&
|
|
365
|
+
(relation != :agent_job ||
|
|
366
|
+
(detail.nil? && other[:detail].nil?) ||
|
|
367
|
+
(detail && other[:detail] && detail[:evidence_address] == other[:detail][:evidence_address]))
|
|
368
|
+
end
|
|
369
|
+
edges << edge unless edges.any?(&same_edge)
|
|
370
|
+
end
|
|
371
|
+
edges
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
def self.provenance_chat_files(root, **options)
|
|
375
|
+
files = []
|
|
376
|
+
traverse_provenance(root, **options) do |kind, object, _pk, _parent, _relation, first|
|
|
377
|
+
files << provenance_path(kind, object) if first && kind == :chat
|
|
378
|
+
end
|
|
379
|
+
files
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
def self.provenance_jobs(root, **options)
|
|
383
|
+
jobs = []
|
|
384
|
+
traverse_provenance(root, **options) do |kind, object, _pk, _parent, _relation, first|
|
|
385
|
+
jobs << object if first && kind == :job
|
|
386
|
+
end
|
|
387
|
+
jobs
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
# Compatibility collector for the former chat-to-chat provenance Hash. New
|
|
391
|
+
# code should use traverse_provenance or provenance_edges, which retain job
|
|
392
|
+
# nodes and relation types.
|
|
393
|
+
def self.provenance(chat_file, prov = {})
|
|
394
|
+
traverse_provenance(chat_file) do |kind, object, parent_kind, parent, _relation, _first|
|
|
395
|
+
next unless parent && kind == :chat
|
|
396
|
+
parent_path = provenance_path(parent_kind, parent)
|
|
397
|
+
prov[parent_path] ||= []
|
|
398
|
+
path = provenance_path(kind, object)
|
|
399
|
+
prov[parent_path] << path unless prov[parent_path].include?(path)
|
|
400
|
+
end
|
|
401
|
+
prov
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
# ------------------------------------------------------------------
|
|
405
|
+
# Provenance-aware token accounting
|
|
406
|
+
# ------------------------------------------------------------------
|
|
407
|
+
|
|
408
|
+
# Canonical-evidence rule for grouped token events, applied in this order:
|
|
409
|
+
#
|
|
410
|
+
# 1. prefer :chat_meta evidence over :agent_meta evidence (a saved child
|
|
411
|
+
# log chat is the richer, primary representation of the same request);
|
|
412
|
+
# 2. within the same origin, the first evidence in discovery order
|
|
413
|
+
# (provenance_chat_files order, then message index, then receipt
|
|
414
|
+
# index).
|
|
415
|
+
#
|
|
416
|
+
# Event tokens always come from the canonical evidence only. Duplicate
|
|
417
|
+
# evidence records are retained (never summed), so a total can always be
|
|
418
|
+
# explained from the evidence list.
|
|
419
|
+
|
|
420
|
+
# One event Hash per deduplicated direct inference event reachable from
|
|
421
|
+
# `root`. A `job=` projection meta is never a token event, and only
|
|
422
|
+
# evidence carrying at least one TOKEN_KEYS field becomes an event.
|
|
423
|
+
#
|
|
424
|
+
# Warning routing: when the caller supplies a `warnings` Array, two kinds of
|
|
425
|
+
# problems are reported into it instead of raising:
|
|
426
|
+
# * traversal-stage agent_meta problems (malformed receipts and unresolved
|
|
427
|
+
# job references) arrive as the agent_meta_error_reference Hash plus
|
|
428
|
+
# `message:`; the traversal keeps going so one bad receipt never hides
|
|
429
|
+
# the rest of the provenance;
|
|
430
|
+
# * identity_conflict records (see below).
|
|
431
|
+
# Unrelated traversal errors keep strict semantics: they raise, both with and
|
|
432
|
+
# without a warnings Array. Without the Array, agent_meta problems raise
|
|
433
|
+
# exactly like Chat.traverse_provenance strict mode.
|
|
434
|
+
#
|
|
435
|
+
# Identity conflicts never raise here; pass `strict: true` to make them
|
|
436
|
+
# raise instead of being reported (see below).
|
|
437
|
+
#
|
|
438
|
+
# Event shape:
|
|
439
|
+
#
|
|
440
|
+
# {
|
|
441
|
+
# inference_id: <String or nil>,
|
|
442
|
+
# identity: <the Array used for grouping, e.g. [:inference_id, "w1"]>,
|
|
443
|
+
# deduplication: :inference_id | :provider_response_id |
|
|
444
|
+
# :legacy_lineage | :receipt_unresolved,
|
|
445
|
+
# meta: <canonical parsed meta>,
|
|
446
|
+
# tokens: {pt:, ct:, tt:, cct:, cwt:, rt:} (symbols, zero-filled,
|
|
447
|
+
# canonical evidence only),
|
|
448
|
+
# evidence: [
|
|
449
|
+
# {origin: :chat_meta, source:, meta_address:, meta:, call_id: nil,
|
|
450
|
+
# tool_name: nil},
|
|
451
|
+
# {origin: :agent_meta, source:, evidence_address:, output_address:,
|
|
452
|
+
# agent_meta_index:, call_id:, tool_name:, meta:}
|
|
453
|
+
# ],
|
|
454
|
+
# conflict: true|false,
|
|
455
|
+
# incomplete_evidence: true|false
|
|
456
|
+
# }
|
|
457
|
+
#
|
|
458
|
+
# `evidence` holds EVERY persisted location of the event (the same
|
|
459
|
+
# inference copied into several saved chats/logs appears once per chat plus
|
|
460
|
+
# once per receipt). Only the canonical evidence supplies `meta` and
|
|
461
|
+
# `tokens`.
|
|
462
|
+
#
|
|
463
|
+
# Identity (grouping) rules, in priority order:
|
|
464
|
+
#
|
|
465
|
+
# * meta[:inference_id] -> [:inference_id, id]
|
|
466
|
+
# * meta[:provider_response_id] otherwise -> [:provider_response_id, id]
|
|
467
|
+
# * chat-side legacy (neither) -> [:lineage, lineage_id], the
|
|
468
|
+
# existing global lineage dedup of Chat.trace_chat_sources is preserved
|
|
469
|
+
# * receipt-side legacy (neither) -> [:receipt, evidence_address];
|
|
470
|
+
# there is no exact rule, so a receipt legacy meta is never merged with
|
|
471
|
+
# anything (documented possible overcount for legacy data)
|
|
472
|
+
#
|
|
473
|
+
# Identity conflicts: when evidence sharing an identity disagree on any
|
|
474
|
+
# TOKEN_KEYS value (compared as integers, missing counts as 0) or on
|
|
475
|
+
# provider_response_id (two DIFFERENT non-empty values), the event keeps
|
|
476
|
+
# every evidence record, counts only the canonical one, sets conflict: true,
|
|
477
|
+
# and - when a warnings Array was supplied - appends one Hash per conflicting
|
|
478
|
+
# event:
|
|
479
|
+
#
|
|
480
|
+
# {reason: :identity_conflict, inference_id:, identity:,
|
|
481
|
+
# fields: <Array of disputed field symbols>,
|
|
482
|
+
# values: <Hash field => the raw meta values in evidence order>,
|
|
483
|
+
# evidence: <the event evidence records>}
|
|
484
|
+
#
|
|
485
|
+
# A missing provider_response_id in one record and a present one in another
|
|
486
|
+
# is INCOMPLETE EVIDENCE, not a conflict: one copy simply carries richer
|
|
487
|
+
# metadata (e.g. across a metadata-format migration). Such events set
|
|
488
|
+
# incomplete_evidence: true, are counted normally, and are never warned about
|
|
489
|
+
# as conflicts. The same leniency applies to optional detailed usage fields
|
|
490
|
+
# (cct/cwt/rt): only the immutable core (inference identity, pt/ct/tt,
|
|
491
|
+
# non-empty provider_response_id) can conflict.
|
|
492
|
+
#
|
|
493
|
+
# A conflicting event is counted once from its canonical evidence, but that
|
|
494
|
+
# number is NOT authoritative: reports must label any total containing
|
|
495
|
+
# conflicts as unresolved (see Chat.provenance_token_totals `conflicts:`).
|
|
496
|
+
# Pass `strict: true` to raise ScoutException on the first conflict instead.
|
|
497
|
+
#
|
|
498
|
+
# Checkpoint fields (*_c, *_s) are never read or summed here.
|
|
499
|
+
def self.provenance_token_events(root, warnings: nil, strict: false, **traversal_options)
|
|
500
|
+
# Route traversal-stage agent_meta problems into the caller's warnings
|
|
501
|
+
# Array instead of letting them raise. Other traversal errors stay strict
|
|
502
|
+
# (raise), and an explicitly supplied on_error keeps being called.
|
|
503
|
+
if Array === warnings
|
|
504
|
+
caller_on_error = traversal_options[:on_error]
|
|
505
|
+
traversal_options = traversal_options.merge(
|
|
506
|
+
on_error: lambda do |error, kind, object, relation, reference|
|
|
507
|
+
if relation == :agent_job && reference.is_a?(Hash) && reference[:reason] &&
|
|
508
|
+
error.message.to_s.include?('agent_meta receipt')
|
|
509
|
+
warnings << reference.merge(message: error.message)
|
|
510
|
+
caller_on_error.call(error, kind, object, relation, reference) if caller_on_error
|
|
511
|
+
elsif caller_on_error
|
|
512
|
+
caller_on_error.call(error, kind, object, relation, reference)
|
|
513
|
+
else
|
|
514
|
+
raise error
|
|
515
|
+
end
|
|
516
|
+
end
|
|
517
|
+
)
|
|
518
|
+
end
|
|
519
|
+
|
|
520
|
+
files = provenance_chat_files(root, **traversal_options)
|
|
521
|
+
sources = {}
|
|
522
|
+
files.each { |file| sources[file] = Chat.load(file) }
|
|
523
|
+
|
|
524
|
+
evidences = []
|
|
525
|
+
|
|
526
|
+
# Chat-side direct events. `deduplicate: false` keeps one entry per
|
|
527
|
+
# persisted location: an inference copied into several saved chats/logs
|
|
528
|
+
# (or projected into several places) yields several evidence records, one
|
|
529
|
+
# per address. Grouping by identity happens exactly once, below, so the
|
|
530
|
+
# resulting event can list every location.
|
|
531
|
+
trace_chat_sources(sources, false).each do |entry|
|
|
532
|
+
meta = entry[:meta]
|
|
533
|
+
next if meta[:job]
|
|
534
|
+
next unless TOKEN_KEYS.any? { |key| meta.include?(key) }
|
|
535
|
+
address = entry[:meta_address]
|
|
536
|
+
evidences << {
|
|
537
|
+
origin: :chat_meta,
|
|
538
|
+
discovery: evidences.length,
|
|
539
|
+
source: address ? address[0] : nil,
|
|
540
|
+
meta_address: address,
|
|
541
|
+
meta: meta,
|
|
542
|
+
call_id: nil,
|
|
543
|
+
tool_name: nil,
|
|
544
|
+
inference_id: entry[:inference_id],
|
|
545
|
+
lineage_id: entry[:lineage_id]
|
|
546
|
+
}
|
|
547
|
+
end
|
|
548
|
+
|
|
549
|
+
# Receipt-side direct events. Malformed receipts are reported through a
|
|
550
|
+
# private buffer and merged into the caller's warnings Array only when the
|
|
551
|
+
# traversal stage has not already reported the same problem (it reports
|
|
552
|
+
# with the richer reference + message shape whenever :agent_job is
|
|
553
|
+
# followed); without an Array, agent_meta_evidence skips them silently.
|
|
554
|
+
receipt_problems = Array === warnings ? [] : nil
|
|
555
|
+
sources.each do |path, chat|
|
|
556
|
+
agent_meta_evidence(chat, source: path, warnings: receipt_problems).each do |record|
|
|
557
|
+
meta = record[:meta]
|
|
558
|
+
next if meta[:job]
|
|
559
|
+
next unless TOKEN_KEYS.any? { |key| meta.include?(key) }
|
|
560
|
+
evidences << {
|
|
561
|
+
origin: :agent_meta,
|
|
562
|
+
discovery: evidences.length,
|
|
563
|
+
source: path,
|
|
564
|
+
evidence_address: record[:evidence_address],
|
|
565
|
+
output_address: record[:output_address],
|
|
566
|
+
agent_meta_index: record[:agent_meta_index],
|
|
567
|
+
meta: meta,
|
|
568
|
+
call_id: record[:call_id],
|
|
569
|
+
tool_name: record[:tool_name],
|
|
570
|
+
inference_id: meta[:inference_id],
|
|
571
|
+
lineage_id: nil
|
|
572
|
+
}
|
|
573
|
+
end
|
|
574
|
+
end
|
|
575
|
+
|
|
576
|
+
if receipt_problems && !receipt_problems.empty?
|
|
577
|
+
reported = Set.new(warnings.collect do |warning|
|
|
578
|
+
next if warning[:reason] == :identity_conflict
|
|
579
|
+
[warning[:reason], warning[:output_address], warning[:agent_meta_index]]
|
|
580
|
+
end)
|
|
581
|
+
receipt_problems.each do |problem|
|
|
582
|
+
key = [problem[:reason], problem[:output_address], problem[:agent_meta_index]]
|
|
583
|
+
warnings << problem unless reported.include?(key)
|
|
584
|
+
end
|
|
585
|
+
end
|
|
586
|
+
|
|
587
|
+
origin_rank = { chat_meta: 0, agent_meta: 1 }
|
|
588
|
+
events = []
|
|
589
|
+
by_identity = {}
|
|
590
|
+
|
|
591
|
+
evidences.each do |evidence|
|
|
592
|
+
meta = evidence[:meta]
|
|
593
|
+
if evidence[:inference_id]
|
|
594
|
+
identity = [:inference_id, evidence[:inference_id]]
|
|
595
|
+
deduplication = :inference_id
|
|
596
|
+
elsif meta[:provider_response_id]
|
|
597
|
+
identity = [:provider_response_id, meta[:provider_response_id]]
|
|
598
|
+
deduplication = :provider_response_id
|
|
599
|
+
elsif evidence[:origin] == :chat_meta
|
|
600
|
+
identity = [:lineage, evidence[:lineage_id]]
|
|
601
|
+
deduplication = :legacy_lineage
|
|
602
|
+
else
|
|
603
|
+
identity = [:receipt, evidence[:evidence_address]]
|
|
604
|
+
deduplication = :receipt_unresolved
|
|
605
|
+
end
|
|
606
|
+
|
|
607
|
+
event = by_identity[identity]
|
|
608
|
+
unless event
|
|
609
|
+
event = { identity: identity, deduplication: deduplication, _evidence: [] }
|
|
610
|
+
by_identity[identity] = event
|
|
611
|
+
events << event
|
|
612
|
+
end
|
|
613
|
+
event[:_evidence] << evidence
|
|
614
|
+
end
|
|
615
|
+
|
|
616
|
+
events.each do |event|
|
|
617
|
+
ordered = event.delete(:_evidence).sort_by do |evidence|
|
|
618
|
+
[origin_rank[evidence[:origin]], evidence[:discovery]]
|
|
619
|
+
end
|
|
620
|
+
canonical = ordered.first
|
|
621
|
+
|
|
622
|
+
event[:evidence] = ordered.collect do |evidence|
|
|
623
|
+
record = {
|
|
624
|
+
origin: evidence[:origin],
|
|
625
|
+
source: evidence[:source],
|
|
626
|
+
meta: evidence[:meta],
|
|
627
|
+
call_id: evidence[:call_id],
|
|
628
|
+
tool_name: evidence[:tool_name]
|
|
629
|
+
}
|
|
630
|
+
if evidence[:origin] == :chat_meta
|
|
631
|
+
record[:meta_address] = evidence[:meta_address]
|
|
632
|
+
else
|
|
633
|
+
record[:evidence_address] = evidence[:evidence_address]
|
|
634
|
+
record[:output_address] = evidence[:output_address]
|
|
635
|
+
record[:agent_meta_index] = evidence[:agent_meta_index]
|
|
636
|
+
end
|
|
637
|
+
record
|
|
638
|
+
end
|
|
639
|
+
|
|
640
|
+
event[:inference_id] = canonical[:inference_id]
|
|
641
|
+
event[:meta] = canonical[:meta]
|
|
642
|
+
event[:tokens] = TOKEN_KEYS.each_with_object({}) do |key, hash|
|
|
643
|
+
hash[key.to_sym] = canonical[:meta][key].to_i
|
|
644
|
+
end
|
|
645
|
+
|
|
646
|
+
# Immutable core only: pt/ct/tt and a genuinely different non-empty
|
|
647
|
+
# provider_response_id. Optional detail fields (cct, cwt, rt) and a
|
|
648
|
+
# missing-vs-present provider_response_id are incomplete evidence, not
|
|
649
|
+
# conflicts.
|
|
650
|
+
core_fields = %i[pt ct tt]
|
|
651
|
+
fields = []
|
|
652
|
+
values = {}
|
|
653
|
+
core_fields.each do |key|
|
|
654
|
+
next unless ordered.collect { |evidence| evidence[:meta][key].to_i }.uniq.length > 1
|
|
655
|
+
fields << key
|
|
656
|
+
values[key] = ordered.collect { |evidence| evidence[:meta][key] }
|
|
657
|
+
end
|
|
658
|
+
provider_ids = ordered.collect { |evidence| evidence[:meta][:provider_response_id].to_s }
|
|
659
|
+
.reject { |value| value.strip.empty? }.uniq
|
|
660
|
+
if provider_ids.length > 1
|
|
661
|
+
fields << :provider_response_id
|
|
662
|
+
values[:provider_response_id] = ordered.collect { |evidence| evidence[:meta][:provider_response_id] }
|
|
663
|
+
end
|
|
664
|
+
|
|
665
|
+
# Incomplete evidence: exactly one non-empty provider_response_id value
|
|
666
|
+
# while at least one copy lacks it (metadata-format migration), so there
|
|
667
|
+
# is nothing to disagree about.
|
|
668
|
+
present_ids = ordered.collect { |evidence| evidence[:meta][:provider_response_id].to_s }
|
|
669
|
+
.reject { |value| value.strip.empty? }
|
|
670
|
+
if present_ids.uniq.length <= 1 &&
|
|
671
|
+
present_ids.length != ordered.length && !present_ids.empty?
|
|
672
|
+
event[:incomplete_evidence] = true
|
|
673
|
+
end
|
|
674
|
+
|
|
675
|
+
next unless fields.any?
|
|
676
|
+
event[:conflict] = true
|
|
677
|
+
if strict
|
|
678
|
+
raise ScoutException, "agent_meta identity conflict on #{event[:identity] * '='}: disputed #{fields * ','}"
|
|
679
|
+
end
|
|
680
|
+
next unless Array === warnings
|
|
681
|
+
warnings << {
|
|
682
|
+
reason: :identity_conflict,
|
|
683
|
+
inference_id: event[:inference_id],
|
|
684
|
+
identity: event[:identity],
|
|
685
|
+
fields: fields,
|
|
686
|
+
values: values,
|
|
687
|
+
evidence: event[:evidence]
|
|
688
|
+
}
|
|
689
|
+
end
|
|
690
|
+
|
|
691
|
+
# Reorder keys readably; conflict defaults to false.
|
|
692
|
+
events.collect do |event|
|
|
693
|
+
{
|
|
694
|
+
inference_id: event[:inference_id],
|
|
695
|
+
identity: event[:identity],
|
|
696
|
+
deduplication: event[:deduplication],
|
|
697
|
+
meta: event[:meta],
|
|
698
|
+
tokens: event[:tokens],
|
|
699
|
+
evidence: event[:evidence],
|
|
700
|
+
conflict: event[:conflict] || false,
|
|
701
|
+
incomplete_evidence: event[:incomplete_evidence] || false
|
|
702
|
+
}
|
|
703
|
+
end
|
|
704
|
+
end
|
|
705
|
+
|
|
706
|
+
# Aggregate token totals over deduplicated provenance events. The result is
|
|
707
|
+
# symbol keyed and zero initialized exactly like Chat.token_totals:
|
|
708
|
+
# {pt:, ct:, tt:, cct:, cwt:, rt:}.
|
|
709
|
+
#
|
|
710
|
+
# Scopes. These are EVIDENCE COVERAGE descriptions, not a partition of cost:
|
|
711
|
+
# an event represented both in a saved child log and in a receipt belongs to
|
|
712
|
+
# :chat_evidence AND to :receipt_evidence, so the two are not additive and
|
|
713
|
+
# must never be summed together. Only :deduplicated_total and :receipt_only
|
|
714
|
+
# are safe to compare additively.
|
|
715
|
+
#
|
|
716
|
+
# * :deduplicated_total - every event counted once (default);
|
|
717
|
+
# * :chat_evidence - events with at least one :chat_meta evidence
|
|
718
|
+
# (physically stored in a chat/log file);
|
|
719
|
+
# * :receipt_evidence - events with at least one :agent_meta evidence
|
|
720
|
+
# (embedded in a function_call_output receipt);
|
|
721
|
+
# * :receipt_only - events with receipt evidence and NO saved-chat
|
|
722
|
+
# evidence (the disjoint delegated contribution).
|
|
723
|
+
#
|
|
724
|
+
# Conflict handling: a conflicting event still contributes its canonical
|
|
725
|
+
# tokens, so a total containing conflicts is a best-effort view, not an
|
|
726
|
+
# authoritative cost. The `conflicts:` keyword makes that explicit in
|
|
727
|
+
# machine readable form; with `strict: true` conflicts raise instead (the
|
|
728
|
+
# keyword is forwarded to provenance_token_events).
|
|
729
|
+
def self.provenance_token_totals(root, scope: :deduplicated_total, warnings: nil,
|
|
730
|
+
conflicts: nil, **traversal_options)
|
|
731
|
+
events = provenance_token_events(root, warnings: warnings, **traversal_options)
|
|
732
|
+
|
|
733
|
+
selected = case scope.to_sym
|
|
734
|
+
when :deduplicated_total
|
|
735
|
+
events
|
|
736
|
+
when :chat_evidence
|
|
737
|
+
events.select { |event| event[:evidence].any? { |e| e[:origin] == :chat_meta } }
|
|
738
|
+
when :receipt_evidence
|
|
739
|
+
events.select { |event| event[:evidence].any? { |e| e[:origin] == :agent_meta } }
|
|
740
|
+
when :receipt_only
|
|
741
|
+
events.select do |event|
|
|
742
|
+
event[:evidence].any? { |e| e[:origin] == :agent_meta } &&
|
|
743
|
+
event[:evidence].none? { |e| e[:origin] == :chat_meta }
|
|
744
|
+
end
|
|
745
|
+
else
|
|
746
|
+
raise ParameterException, "Unknown token scope: #{scope}"
|
|
747
|
+
end
|
|
748
|
+
|
|
749
|
+
totals = TOKEN_KEYS.each_with_object({}) { |key, hash| hash[key.to_sym] = 0 }
|
|
750
|
+
selected.each do |event|
|
|
751
|
+
TOKEN_KEYS.each { |key| totals[key.to_sym] += event[:tokens][key.to_sym] }
|
|
752
|
+
end
|
|
753
|
+
|
|
754
|
+
if conflicts.is_a?(Hash)
|
|
755
|
+
conflicting = events.select { |event| event[:conflict] }
|
|
756
|
+
incomplete = events.select { |event| event[:incomplete_evidence] }
|
|
757
|
+
conflicts[:events] = conflicting.length
|
|
758
|
+
conflicts[:incomplete_evidence_events] = incomplete.length
|
|
759
|
+
conflicts[:authoritative] = conflicting.empty?
|
|
760
|
+
end
|
|
761
|
+
|
|
762
|
+
totals
|
|
763
|
+
end
|
|
764
|
+
|
|
765
|
+
# Provenance token aggregate. Receipt (agent_meta) child usage is now
|
|
766
|
+
# included through the event collector, so child inference paid inside a
|
|
767
|
+
# parent tool call is no longer invisible when the child chat was not saved.
|
|
768
|
+
def self.tokens(root, **options)
|
|
769
|
+
provenance_token_totals(root, **options)
|
|
770
|
+
end
|
|
771
|
+
|
|
772
|
+
def self.timestamp
|
|
773
|
+
Time.now.utc.iso8601(3)
|
|
774
|
+
end
|
|
775
|
+
end
|