pikuri-core 0.0.7 → 0.1.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/README.md +1 -1
- data/lib/pikuri/agent/chat_transport.rb +73 -93
- data/lib/pikuri/agent/configurator.rb +46 -106
- data/lib/pikuri/agent/context_window_detector.rb +44 -85
- data/lib/pikuri/agent/control/cancellable.rb +87 -66
- data/lib/pikuri/agent/control/interloper.rb +127 -105
- data/lib/pikuri/agent/control/step_limit.rb +25 -41
- data/lib/pikuri/agent/control.rb +14 -34
- data/lib/pikuri/agent/event.rb +123 -188
- data/lib/pikuri/agent/extension.rb +118 -94
- data/lib/pikuri/agent/extension_context.rb +50 -77
- data/lib/pikuri/agent/history.rb +653 -0
- data/lib/pikuri/agent/listener/rate_limited.rb +40 -66
- data/lib/pikuri/agent/listener/terminal.rb +143 -117
- data/lib/pikuri/agent/listener/token_log.rb +101 -140
- data/lib/pikuri/agent/listener.rb +23 -43
- data/lib/pikuri/agent/listener_list.rb +26 -47
- data/lib/pikuri/agent/synthesizer.rb +45 -87
- data/lib/pikuri/agent.rb +816 -474
- data/lib/pikuri/bundler_env.rb +68 -0
- data/lib/pikuri/extractor/html.rb +63 -110
- data/lib/pikuri/extractor/passthrough.rb +20 -30
- data/lib/pikuri/extractor.rb +93 -154
- data/lib/pikuri/file_type.rb +63 -135
- data/lib/pikuri/finalizers.rb +32 -47
- data/lib/pikuri/paths.rb +104 -13
- data/lib/pikuri/ruby_llm_patches.rb +106 -0
- data/lib/pikuri/sanitizer.rb +45 -67
- data/lib/pikuri/subprocess.rb +75 -119
- data/lib/pikuri/testing.rb +296 -0
- data/lib/pikuri/tool/calculator.rb +56 -66
- data/lib/pikuri/tool/execute_context.rb +42 -0
- data/lib/pikuri/tool/fetch.rb +51 -77
- data/lib/pikuri/tool/parameters.rb +21 -29
- data/lib/pikuri/tool/scraper.rb +55 -97
- data/lib/pikuri/tool/search/brave.rb +52 -80
- data/lib/pikuri/tool/search/duckduckgo.rb +59 -91
- data/lib/pikuri/tool/search/engines.rb +230 -97
- data/lib/pikuri/tool/search/exa.rb +56 -90
- data/lib/pikuri/tool/search/rate_limiter.rb +61 -38
- data/lib/pikuri/tool/search/result.rb +10 -15
- data/lib/pikuri/tool/trifecta_legs.rb +217 -0
- data/lib/pikuri/tool/web_scrape.rb +38 -54
- data/lib/pikuri/tool/web_search.rb +100 -24
- data/lib/pikuri/tool.rb +140 -65
- data/lib/pikuri/trifecta/contribution.rb +43 -0
- data/lib/pikuri/trifecta/node.rb +47 -0
- data/lib/pikuri/trifecta/report.rb +230 -0
- data/lib/pikuri/trifecta.rb +127 -0
- data/lib/pikuri/url_cache.rb +33 -49
- data/lib/pikuri/version.rb +1 -1
- data/lib/pikuri-core.rb +72 -88
- data/prompts/agent-loop.txt +5 -0
- data/prompts/pikuri-chat.txt +3 -12
- metadata +14 -3
|
@@ -0,0 +1,653 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'digest'
|
|
5
|
+
require 'pathname'
|
|
6
|
+
require 'securerandom'
|
|
7
|
+
|
|
8
|
+
module Pikuri
|
|
9
|
+
class Agent
|
|
10
|
+
# A conversation, as a value — what {Agent#export_history} hands out and
|
|
11
|
+
# {Agent#load_history!} takes back. Two encodings, one in-memory value:
|
|
12
|
+
#
|
|
13
|
+
# json = agent.export_history.to_json # self-contained, base64 inside
|
|
14
|
+
# agent.load_history!(History.parse(json))
|
|
15
|
+
#
|
|
16
|
+
# dir = agent.export_history.save('/tmp/conv') # dir/history.json + attachments/
|
|
17
|
+
# agent.load_history!(History.load(dir))
|
|
18
|
+
#
|
|
19
|
+
# Both round-trip to the same value, which is the property that keeps them
|
|
20
|
+
# from drifting into two formats:
|
|
21
|
+
#
|
|
22
|
+
# History.load(dir) == History.parse(json) # => true
|
|
23
|
+
#
|
|
24
|
+
# The system prompt is *not* here — {Agent#clear_conversation} re-assembles
|
|
25
|
+
# it from every extension on load, so a resumed conversation gets today's
|
|
26
|
+
# prompt (a re-read +MACHINE.md+, a fresh memory persona) rather than
|
|
27
|
+
# yesterday's.
|
|
28
|
+
#
|
|
29
|
+
# == The doors in are the validators
|
|
30
|
+
#
|
|
31
|
+
# {parse}, {from_h} and {load} are the only ways to build one from
|
|
32
|
+
# untrusted bytes, and each raises {Error} rather than returning something
|
|
33
|
+
# half-built. There is deliberately no "accepts a String or a Hash or a
|
|
34
|
+
# History" convenience: one way in means one place validation could be
|
|
35
|
+
# skipped, i.e. none.
|
|
36
|
+
#
|
|
37
|
+
# == Implementation details
|
|
38
|
+
#
|
|
39
|
+
# Attachment bytes are held **eagerly**, whichever door they came in
|
|
40
|
+
# through — never a lazy handle to a folder that can be deleted underneath
|
|
41
|
+
# it. So +==+ compares content, and a +History+ survives its folder being
|
|
42
|
+
# moved. The cost is memory bounded by attachment count; a lazy reference
|
|
43
|
+
# would reintroduce exactly the "the bytes changed since the model saw
|
|
44
|
+
# them" failure this format exists to avoid.
|
|
45
|
+
#
|
|
46
|
+
# The container is one JSON object, not JSONL. Should append ever be worth
|
|
47
|
+
# it, going to JSONL is a *sniff* (does the whole file parse as one
|
|
48
|
+
# object?) rather than a version bump — see +DECISIONS.md+ +D_history_container+.
|
|
49
|
+
class History < Data.define(:version, :messages)
|
|
50
|
+
# Bumped only for a change an old reader could not survive; an added
|
|
51
|
+
# optional key is not one (readers ignore unknown keys). A file claiming
|
|
52
|
+
# a higher version is refused, never best-effort loaded.
|
|
53
|
+
CURRENT_VERSION = 1
|
|
54
|
+
|
|
55
|
+
# Basename of the JSON written by {#save}, and read by {load}.
|
|
56
|
+
HISTORY_FILE = 'history.json'
|
|
57
|
+
|
|
58
|
+
# Subfolder {#save} writes attachment bytes into. Fixed, so
|
|
59
|
+
# "in the folder" is one comparison rather than a policy.
|
|
60
|
+
ATTACHMENTS_DIR = 'attachments'
|
|
61
|
+
|
|
62
|
+
# Message roles that may appear in an exported history. +:system+ cannot:
|
|
63
|
+
# the prompt is re-assembled, not replayed.
|
|
64
|
+
ROLES = %w[user assistant tool].freeze
|
|
65
|
+
|
|
66
|
+
# What a +user+-role message *is*: something the human typed
|
|
67
|
+
# (+"user"+), or reference text pikuri injected on its own initiative —
|
|
68
|
+
# recalled memory, a host-loaded skill, a path-activation promotion
|
|
69
|
+
# (+"reference"+, what {Agent#append_reference_block} appends).
|
|
70
|
+
#
|
|
71
|
+
# Local only: both land on the wire as +role: :user+, and no provider is
|
|
72
|
+
# told which is which. It exists so a resumed conversation can tell a
|
|
73
|
+
# stale skill listing from something the human meant.
|
|
74
|
+
KINDS = %w[user reference].freeze
|
|
75
|
+
|
|
76
|
+
# Raised by every door in: a malformed record, a dangling tool result, an
|
|
77
|
+
# attachment outside the folder, a version from the future.
|
|
78
|
+
class Error < StandardError; end
|
|
79
|
+
|
|
80
|
+
# One tool call the assistant asked for. +id+ is the provider's own
|
|
81
|
+
# identifier and is load-bearing — the +tool+ message answering this call
|
|
82
|
+
# repeats it, and providers reject a pair that does not match.
|
|
83
|
+
ToolCall = Data.define(:id, :name, :arguments)
|
|
84
|
+
|
|
85
|
+
# An assistant reasoning block, as the provider reported it.
|
|
86
|
+
#
|
|
87
|
+
# The two fields are not symmetric, and which one carries the substance
|
|
88
|
+
# flips by provider: on Anthropic +signature+ is the full reasoning,
|
|
89
|
+
# encrypted, and +text+ a summary that is empty by default; on an
|
|
90
|
+
# OpenAI-compatible server +text+ is the reasoning itself and
|
|
91
|
+
# +signature+ is usually absent. So the whole block, never one field, is
|
|
92
|
+
# what {Agent} withholds from a model that did not produce it — see
|
|
93
|
+
# +DECISIONS.md+ +D_thinking_block_replay+.
|
|
94
|
+
Thinking = Data.define(:text, :signature)
|
|
95
|
+
|
|
96
|
+
# Per-message token usage, as the provider reported it. Carried so a
|
|
97
|
+
# host's context readout can be reseeded after a load — nothing on the
|
|
98
|
+
# wire needs it.
|
|
99
|
+
Tokens = Data.define(:input, :output, :cached, :cache_creation, :thinking)
|
|
100
|
+
|
|
101
|
+
# A file the model was shown — an image from +read+, a pasted document.
|
|
102
|
+
# Holds the actual bytes; {#stored_name} is what {History#save} calls it
|
|
103
|
+
# on disk.
|
|
104
|
+
Attachment = Data.define(:mime, :filename, :bytes) do
|
|
105
|
+
# @param mime [String] e.g. +"image/png"+
|
|
106
|
+
# @param filename [String] the name the model was shown
|
|
107
|
+
# @param bytes [String] the file's contents; re-tagged binary, since
|
|
108
|
+
# the same image arrives as UTF-8 from a source file and as
|
|
109
|
+
# ASCII-8BIT from base64, and two differently-tagged copies of one
|
|
110
|
+
# PNG are not +==+ — which would quietly break the round-trip
|
|
111
|
+
# property this whole type exists to hold.
|
|
112
|
+
def initialize(mime:, filename:, bytes:)
|
|
113
|
+
super(mime: mime, filename: filename, bytes: bytes.b)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Content-addressed filename: the digest prefix makes it unique and
|
|
117
|
+
# self-verifying, so the same image read twice is stored once and a
|
|
118
|
+
# tampered file is caught at load.
|
|
119
|
+
#
|
|
120
|
+
# Attachment.new(mime: 'image/png', filename: 'shot.png', bytes: png).stored_name
|
|
121
|
+
# # => "3f9a1c8e2b-shot.png"
|
|
122
|
+
#
|
|
123
|
+
# @return [String] a bare basename, never a path.
|
|
124
|
+
def stored_name
|
|
125
|
+
ext = File.extname(filename.to_s)
|
|
126
|
+
stem = File.basename(filename.to_s, ext).gsub(/[^A-Za-z0-9._-]+/, '-')
|
|
127
|
+
stem = 'attachment' if stem.empty?
|
|
128
|
+
"#{digest[0, 10]}-#{stem}#{ext}"
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# @return [String] SHA-256 of {#bytes}, hex.
|
|
132
|
+
def digest
|
|
133
|
+
Digest::SHA256.hexdigest(bytes)
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# One message. +id+ and +kind+ are pikuri's own and never reach a
|
|
138
|
+
# provider; everything else is what the model saw.
|
|
139
|
+
#
|
|
140
|
+
# +content+ is always a String (empty on a pure tool-call turn, which is
|
|
141
|
+
# what ruby_llm stores); anything the model was *shown* beyond text is an
|
|
142
|
+
# {Attachment} in +attachments+.
|
|
143
|
+
Message = Data.define(
|
|
144
|
+
:id, :role, :kind, :content, :attachments,
|
|
145
|
+
:tool_calls, :tool_call_id, :model_id, :thinking, :tokens
|
|
146
|
+
) do
|
|
147
|
+
# @param id [String] see {History.mint_id}
|
|
148
|
+
# @param role [String] one of {ROLES}
|
|
149
|
+
# @param kind [String] one of {KINDS}; defaults to +"user"+
|
|
150
|
+
# @param content [String] the text the model saw; +""+ is legal
|
|
151
|
+
# @param attachments [Array<Attachment>]
|
|
152
|
+
# @param tool_calls [Array<ToolCall>] empty unless the assistant asked
|
|
153
|
+
# for tools
|
|
154
|
+
# @param tool_call_id [String, nil] set exactly on a +tool+ message
|
|
155
|
+
# @param model_id [String, nil] which model produced this
|
|
156
|
+
# @param thinking [Thinking, nil]
|
|
157
|
+
# @param tokens [Tokens, nil]
|
|
158
|
+
def initialize(id:, role:, content:, kind: 'user', attachments: [], tool_calls: [],
|
|
159
|
+
tool_call_id: nil, model_id: nil, thinking: nil, tokens: nil)
|
|
160
|
+
super
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# @return [Boolean] whether this message asked for tools.
|
|
164
|
+
def tool_call? = !tool_calls.empty?
|
|
165
|
+
|
|
166
|
+
# Snapshot a live +RubyLLM::Message+. Attachment bytes are read *now*
|
|
167
|
+
# — the file a tool showed the model may be gone or changed by the
|
|
168
|
+
# time anyone loads this back.
|
|
169
|
+
#
|
|
170
|
+
# @param msg [RubyLLM::Message] must not be +role: :system+.
|
|
171
|
+
# @param id [String] see {History.mint_id}.
|
|
172
|
+
# @param kind [String] one of {KINDS}.
|
|
173
|
+
# @return [Message]
|
|
174
|
+
def self.from_ruby_llm(msg, id:, kind: 'user')
|
|
175
|
+
text, attachments = split_content(msg.content)
|
|
176
|
+
new(
|
|
177
|
+
id: id, role: msg.role.to_s, kind: kind, content: text, attachments: attachments,
|
|
178
|
+
tool_calls: (msg.tool_calls || {}).values.map do |c|
|
|
179
|
+
ToolCall.new(id: c.id, name: c.name, arguments: c.arguments || {})
|
|
180
|
+
end,
|
|
181
|
+
tool_call_id: msg.tool_call_id,
|
|
182
|
+
model_id: msg.model_id,
|
|
183
|
+
thinking: msg.thinking && Thinking.new(text: msg.thinking.text, signature: msg.thinking.signature),
|
|
184
|
+
tokens: msg.tokens && Tokens.new(
|
|
185
|
+
input: msg.tokens.input, output: msg.tokens.output, cached: msg.tokens.cached,
|
|
186
|
+
cache_creation: msg.tokens.cache_creation, thinking: msg.tokens.thinking
|
|
187
|
+
)
|
|
188
|
+
)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# @param content [String, RubyLLM::Content, nil]
|
|
192
|
+
# @return [Array(String, Array<Attachment>)]
|
|
193
|
+
def self.split_content(content)
|
|
194
|
+
return ['', []] if content.nil?
|
|
195
|
+
return [content, []] if content.is_a?(String)
|
|
196
|
+
return [content.to_s, []] unless content.respond_to?(:attachments)
|
|
197
|
+
|
|
198
|
+
[content.text.to_s, content.attachments.map do |a|
|
|
199
|
+
Attachment.new(mime: a.mime_type, filename: a.filename.to_s, bytes: a.content)
|
|
200
|
+
end]
|
|
201
|
+
end
|
|
202
|
+
private_class_method :split_content
|
|
203
|
+
|
|
204
|
+
# Rebuild the +RubyLLM::Message+ this was taken from.
|
|
205
|
+
#
|
|
206
|
+
# @param keep_thinking [Boolean] +false+ replays the turn without its
|
|
207
|
+
# {Thinking} block at all — what {Agent} passes for a turn some
|
|
208
|
+
# *other* model produced, since a thinking block lands in a slot the
|
|
209
|
+
# receiving model reads as its own scratchpad. Dropped from the
|
|
210
|
+
# *message*, not from the record: {Agent#export_history} still
|
|
211
|
+
# writes it. See +DECISIONS.md+ +D_thinking_block_replay+.
|
|
212
|
+
# @return [RubyLLM::Message]
|
|
213
|
+
def to_ruby_llm(keep_thinking: true)
|
|
214
|
+
calls = tool_calls.to_h do |c|
|
|
215
|
+
[c.id, RubyLLM::ToolCall.new(id: c.id, name: c.name, arguments: c.arguments)]
|
|
216
|
+
end
|
|
217
|
+
replayed = thinking if keep_thinking
|
|
218
|
+
RubyLLM::Message.new(
|
|
219
|
+
role: role.to_sym,
|
|
220
|
+
content: ruby_llm_content,
|
|
221
|
+
tool_calls: calls.empty? ? nil : calls,
|
|
222
|
+
tool_call_id: tool_call_id,
|
|
223
|
+
model_id: model_id,
|
|
224
|
+
thinking: replayed && RubyLLM::Thinking.build(
|
|
225
|
+
text: replayed.text, signature: replayed.signature
|
|
226
|
+
),
|
|
227
|
+
tokens: tokens && RubyLLM::Tokens.build(**tokens.to_h)
|
|
228
|
+
)
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
private
|
|
232
|
+
|
|
233
|
+
def ruby_llm_content
|
|
234
|
+
return content if attachments.empty?
|
|
235
|
+
|
|
236
|
+
RubyLLM::Content.new(content).tap do |c|
|
|
237
|
+
attachments.each { |a| c.add_attachment(StringIO.new(a.bytes), filename: a.filename) }
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
class << self
|
|
243
|
+
# Parse the self-contained JSON form.
|
|
244
|
+
#
|
|
245
|
+
# History.parse(File.read('conv.json'))
|
|
246
|
+
#
|
|
247
|
+
# @param json [String] what {#to_json} produced.
|
|
248
|
+
# @return [History]
|
|
249
|
+
# @raise [Error] on malformed JSON, a bad record, a future version, or
|
|
250
|
+
# an attachment stored as a file — a String has no folder to resolve
|
|
251
|
+
# one against, so that case names {load} rather than guessing.
|
|
252
|
+
def parse(json)
|
|
253
|
+
raw = begin
|
|
254
|
+
JSON.parse(json)
|
|
255
|
+
rescue JSON::ParserError => e
|
|
256
|
+
raise Error, "history is not valid JSON: #{e.message}"
|
|
257
|
+
end
|
|
258
|
+
from_h(raw)
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# Read a folder written by {#save}.
|
|
262
|
+
#
|
|
263
|
+
# @param dir [String, Pathname] the folder holding {HISTORY_FILE}.
|
|
264
|
+
# @return [History]
|
|
265
|
+
# @raise [Error] as {from_h}, plus a missing, unreadable, or
|
|
266
|
+
# digest-mismatched attachment.
|
|
267
|
+
def load(dir)
|
|
268
|
+
dir = Pathname(dir)
|
|
269
|
+
file = dir / HISTORY_FILE
|
|
270
|
+
raise Error, "no #{HISTORY_FILE} in #{dir}" unless file.file?
|
|
271
|
+
|
|
272
|
+
raw = begin
|
|
273
|
+
JSON.parse(file.read)
|
|
274
|
+
rescue JSON::ParserError => e
|
|
275
|
+
raise Error, "#{file} is not valid JSON: #{e.message}"
|
|
276
|
+
end
|
|
277
|
+
from_h(raw, dir: dir)
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# Validate a plain Hash — the shared gate {parse} and {load} both run
|
|
281
|
+
# through, and the only place a record's shape is checked.
|
|
282
|
+
#
|
|
283
|
+
# Unknown *keys* are ignored, so a file written by a newer pikuri
|
|
284
|
+
# still loads; malformed *known* keys raise. That asymmetry is the
|
|
285
|
+
# whole forward-compatibility story, and it is why {CURRENT_VERSION}
|
|
286
|
+
# is expected to stay +1+.
|
|
287
|
+
#
|
|
288
|
+
# @param raw [Hash] parsed JSON, String keys.
|
|
289
|
+
# @param dir [Pathname, nil] folder to resolve file-backed attachments
|
|
290
|
+
# against; +nil+ refuses them.
|
|
291
|
+
# @return [History]
|
|
292
|
+
# @raise [Error] on a non-Hash, a version above {CURRENT_VERSION}, a
|
|
293
|
+
# message with a bad role/kind/id, a duplicate id, or a +tool+
|
|
294
|
+
# message whose +tool_call_id+ no preceding assistant message issued.
|
|
295
|
+
def from_h(raw, dir: nil)
|
|
296
|
+
raise Error, "expected a Hash, got #{raw.class}" unless raw.is_a?(Hash)
|
|
297
|
+
|
|
298
|
+
version = raw['version']
|
|
299
|
+
raise Error, "missing version (expected #{CURRENT_VERSION})" if version.nil?
|
|
300
|
+
if version.is_a?(Integer) && version > CURRENT_VERSION
|
|
301
|
+
raise Error, "history version #{version} is newer than this pikuri understands " \
|
|
302
|
+
"(#{CURRENT_VERSION}); upgrade pikuri to read it"
|
|
303
|
+
end
|
|
304
|
+
raise Error, "version must be #{CURRENT_VERSION}, got #{version.inspect}" unless version == CURRENT_VERSION
|
|
305
|
+
|
|
306
|
+
messages = raw['messages']
|
|
307
|
+
raise Error, "messages must be an Array, got #{messages.class}" unless messages.is_a?(Array)
|
|
308
|
+
|
|
309
|
+
built = messages.each_with_index.map { |m, i| message_from_h(m, i, dir) }
|
|
310
|
+
check_ids!(built)
|
|
311
|
+
check_tool_pairing!(built)
|
|
312
|
+
new(version: CURRENT_VERSION, messages: built)
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
# A fresh message id: lexicographically sortable, so ordering falls out
|
|
316
|
+
# of the id itself and a future tree format inherits identity for free.
|
|
317
|
+
#
|
|
318
|
+
# History.mint_id # => "0198c4f1a2b30001f3c9a2"
|
|
319
|
+
#
|
|
320
|
+
# Not a wire value — no provider is ever shown one.
|
|
321
|
+
#
|
|
322
|
+
# @return [String] 22 hex chars: 12 of millisecond timestamp, 4 of
|
|
323
|
+
# per-process counter (so two ids minted in one millisecond still
|
|
324
|
+
# sort), 6 random.
|
|
325
|
+
def mint_id
|
|
326
|
+
MINT_LOCK.synchronize do
|
|
327
|
+
@counter = ((@counter || -1) + 1) & 0xffff
|
|
328
|
+
format('%012x%04x%s', (Time.now.to_f * 1000).to_i, @counter, SecureRandom.hex(3))
|
|
329
|
+
end
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
private
|
|
333
|
+
|
|
334
|
+
def message_from_h(raw, index, dir)
|
|
335
|
+
raise Error, "message #{index}: expected a Hash, got #{raw.class}" unless raw.is_a?(Hash)
|
|
336
|
+
|
|
337
|
+
role = raw['role']
|
|
338
|
+
raise Error, "message #{index}: role must be one of #{ROLES.join('/')}, got #{role.inspect}" unless
|
|
339
|
+
ROLES.include?(role)
|
|
340
|
+
|
|
341
|
+
# Absent kind reads as "user": additive fields must not break an
|
|
342
|
+
# older file, and this is the only field with a sensible default.
|
|
343
|
+
kind = raw['kind'] || 'user'
|
|
344
|
+
raise Error, "message #{index}: kind must be one of #{KINDS.join('/')}, got #{kind.inspect}" unless
|
|
345
|
+
KINDS.include?(kind)
|
|
346
|
+
|
|
347
|
+
id = raw['id']
|
|
348
|
+
raise Error, "message #{index}: id must be a non-empty String, got #{id.inspect}" unless
|
|
349
|
+
id.is_a?(String) && !id.empty?
|
|
350
|
+
|
|
351
|
+
tool_call_id = raw['tool_call_id']
|
|
352
|
+
if role == 'tool' && !(tool_call_id.is_a?(String) && !tool_call_id.empty?)
|
|
353
|
+
raise Error, "message #{index} (#{id}): a tool message needs a tool_call_id, got #{tool_call_id.inspect}"
|
|
354
|
+
end
|
|
355
|
+
if role != 'tool' && !tool_call_id.nil?
|
|
356
|
+
raise Error, "message #{index} (#{id}): only a tool message may carry a tool_call_id"
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
Message.new(
|
|
360
|
+
id: id, role: role, kind: kind,
|
|
361
|
+
content: string_field(raw['content'], index, id, 'content'),
|
|
362
|
+
attachments: (raw['attachments'] || []).map { |a| attachment_from_h(a, index, id, dir) },
|
|
363
|
+
tool_calls: (raw['tool_calls'] || []).map { |t| tool_call_from_h(t, index, id) },
|
|
364
|
+
tool_call_id: tool_call_id,
|
|
365
|
+
model_id: raw['model_id'],
|
|
366
|
+
thinking: thinking_from_h(raw['thinking'], index, id),
|
|
367
|
+
tokens: tokens_from_h(raw['tokens'], index, id)
|
|
368
|
+
)
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
def string_field(value, index, id, name)
|
|
372
|
+
return value if value.is_a?(String)
|
|
373
|
+
return '' if value.nil?
|
|
374
|
+
|
|
375
|
+
raise Error, "message #{index} (#{id}): #{name} must be a String, got #{value.class}"
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
def tool_call_from_h(raw, index, id)
|
|
379
|
+
raise Error, "message #{index} (#{id}): each tool_call must be a Hash" unless raw.is_a?(Hash)
|
|
380
|
+
|
|
381
|
+
call_id = raw['id']
|
|
382
|
+
raise Error, "message #{index} (#{id}): a tool_call needs a non-empty id" unless
|
|
383
|
+
call_id.is_a?(String) && !call_id.empty?
|
|
384
|
+
raise Error, "message #{index} (#{id}): tool_call #{call_id} needs a name" unless raw['name'].is_a?(String)
|
|
385
|
+
|
|
386
|
+
args = raw['arguments'] || {}
|
|
387
|
+
raise Error, "message #{index} (#{id}): tool_call #{call_id} arguments must be a Hash" unless args.is_a?(Hash)
|
|
388
|
+
|
|
389
|
+
ToolCall.new(id: call_id, name: raw['name'], arguments: args)
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
def thinking_from_h(raw, index, id)
|
|
393
|
+
return nil if raw.nil?
|
|
394
|
+
raise Error, "message #{index} (#{id}): thinking must be a Hash" unless raw.is_a?(Hash)
|
|
395
|
+
|
|
396
|
+
Thinking.new(text: raw['text'], signature: raw['signature'])
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
def tokens_from_h(raw, index, id)
|
|
400
|
+
return nil if raw.nil?
|
|
401
|
+
raise Error, "message #{index} (#{id}): tokens must be a Hash" unless raw.is_a?(Hash)
|
|
402
|
+
|
|
403
|
+
Tokens.new(
|
|
404
|
+
input: raw['input'], output: raw['output'], cached: raw['cached'],
|
|
405
|
+
cache_creation: raw['cache_creation'], thinking: raw['thinking']
|
|
406
|
+
)
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
def attachment_from_h(raw, index, id, dir)
|
|
410
|
+
raise Error, "message #{index} (#{id}): each attachment must be a Hash" unless raw.is_a?(Hash)
|
|
411
|
+
|
|
412
|
+
filename = raw['filename'].to_s
|
|
413
|
+
bytes =
|
|
414
|
+
case raw['kind']
|
|
415
|
+
when 'inline' then decode_inline(raw['bytes'], index, id)
|
|
416
|
+
when 'file' then read_confined(dir, raw['file'], index, id)
|
|
417
|
+
else raise Error, "message #{index} (#{id}): attachment kind must be inline or file, " \
|
|
418
|
+
"got #{raw['kind'].inspect}"
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
Attachment.new(mime: raw['mime'], filename: filename, bytes: bytes)
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
def decode_inline(data, index, id)
|
|
425
|
+
raise Error, "message #{index} (#{id}): inline attachment needs base64 bytes" unless data.is_a?(String)
|
|
426
|
+
|
|
427
|
+
data.unpack1('m0')
|
|
428
|
+
rescue ArgumentError => e
|
|
429
|
+
raise Error, "message #{index} (#{id}): attachment base64 is malformed: #{e.message}"
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
# Read one attachment, refusing anything that is not a plain file
|
|
433
|
+
# directly inside +dir/attachments+.
|
|
434
|
+
#
|
|
435
|
+
# The comparison is on the **resolved** path, not the string: a file
|
|
436
|
+
# sitting in the folder may itself be a symlink to +~/.ssh/id_ed25519+,
|
|
437
|
+
# which every textual check passes. That is the case this method
|
|
438
|
+
# exists for.
|
|
439
|
+
def read_confined(dir, name, index, id)
|
|
440
|
+
where = "message #{index} (#{id})"
|
|
441
|
+
if dir.nil?
|
|
442
|
+
raise Error, "#{where}: this history references the external attachment #{name.inspect}; " \
|
|
443
|
+
'use History.load(dir) rather than History.parse'
|
|
444
|
+
end
|
|
445
|
+
raise Error, "#{where}: attachment file must be a String" unless name.is_a?(String)
|
|
446
|
+
unless !name.empty? && name == File.basename(name) && !name.start_with?('.')
|
|
447
|
+
raise Error, "#{where}: attachment file #{name.inspect} must be a bare filename"
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
root = dir / ATTACHMENTS_DIR
|
|
451
|
+
path = root / name
|
|
452
|
+
raise Error, "#{where}: attachment #{name.inspect} is missing from #{root}" unless path.file?
|
|
453
|
+
|
|
454
|
+
real = path.realpath
|
|
455
|
+
unless real.dirname == root.realpath
|
|
456
|
+
raise Error, "#{where}: attachment #{name.inspect} resolves to #{real}, outside #{root}"
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
bytes = real.binread
|
|
460
|
+
expected = name[/\A[0-9a-f]{10}/]
|
|
461
|
+
if expected && !Digest::SHA256.hexdigest(bytes).start_with?(expected)
|
|
462
|
+
raise Error, "#{where}: attachment #{name.inspect} does not match its content digest"
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
bytes
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
def check_ids!(messages)
|
|
469
|
+
seen = {}
|
|
470
|
+
messages.each_with_index do |m, i|
|
|
471
|
+
if (first = seen[m.id])
|
|
472
|
+
raise Error, "message #{i}: duplicate id #{m.id} (already used by message #{first})"
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
seen[m.id] = i
|
|
476
|
+
end
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
# Refuse a tool result nothing asked for, and one answered twice.
|
|
480
|
+
#
|
|
481
|
+
# The mirror case — a call with *no* result — is deliberately legal
|
|
482
|
+
# here: that is what a process killed mid-batch leaves behind, and
|
|
483
|
+
# {Agent#load_history!} repairs it by synthesizing an interrupted
|
|
484
|
+
# result. Refusing it would make the crash case the one thing that
|
|
485
|
+
# cannot be loaded.
|
|
486
|
+
def check_tool_pairing!(messages)
|
|
487
|
+
issued = {}
|
|
488
|
+
answered = {}
|
|
489
|
+
messages.each_with_index do |m, i|
|
|
490
|
+
m.tool_calls.each { |c| issued[c.id] = i }
|
|
491
|
+
next unless m.role == 'tool'
|
|
492
|
+
|
|
493
|
+
unless issued.key?(m.tool_call_id)
|
|
494
|
+
raise Error, "message #{i} (#{m.id}): tool_call_id #{m.tool_call_id} " \
|
|
495
|
+
'was never issued by a preceding assistant message'
|
|
496
|
+
end
|
|
497
|
+
if (prev = answered[m.tool_call_id])
|
|
498
|
+
raise Error, "message #{i} (#{m.id}): tool_call_id #{m.tool_call_id} " \
|
|
499
|
+
"was already answered by message #{prev}"
|
|
500
|
+
end
|
|
501
|
+
|
|
502
|
+
answered[m.tool_call_id] = i
|
|
503
|
+
end
|
|
504
|
+
end
|
|
505
|
+
end
|
|
506
|
+
|
|
507
|
+
MINT_LOCK = Mutex.new
|
|
508
|
+
private_constant :MINT_LOCK
|
|
509
|
+
|
|
510
|
+
# @param version [Integer] must be {CURRENT_VERSION}; defaulted so
|
|
511
|
+
# callers building one in Ruby never restate it.
|
|
512
|
+
# @param messages [Array<Message>]
|
|
513
|
+
def initialize(version: CURRENT_VERSION, messages: [])
|
|
514
|
+
super
|
|
515
|
+
end
|
|
516
|
+
|
|
517
|
+
# The self-contained Hash: attachments inline, as base64.
|
|
518
|
+
#
|
|
519
|
+
# @param inline_attachments [Boolean] +false+ renders each attachment as
|
|
520
|
+
# a reference to its {Attachment#stored_name}, which only {#save}
|
|
521
|
+
# wants — the bytes then live in the folder, not the Hash.
|
|
522
|
+
# @return [Hash{String => Object}] JSON-safe, String keys.
|
|
523
|
+
def to_h(inline_attachments: true)
|
|
524
|
+
{
|
|
525
|
+
'version' => version,
|
|
526
|
+
'messages' => messages.map { |m| message_to_h(m, inline_attachments) }
|
|
527
|
+
}
|
|
528
|
+
end
|
|
529
|
+
|
|
530
|
+
# @return [String] pretty-printed {#to_h}, so a stored conversation stays
|
|
531
|
+
# readable and diffs a turn at a time.
|
|
532
|
+
def to_json(*_args)
|
|
533
|
+
JSON.pretty_generate(to_h)
|
|
534
|
+
end
|
|
535
|
+
|
|
536
|
+
# Write +dir/history.json+ plus +dir/attachments/+, creating the folder if
|
|
537
|
+
# needed.
|
|
538
|
+
#
|
|
539
|
+
# agent.export_history.save('~/conversations/2026-08-27-abc')
|
|
540
|
+
#
|
|
541
|
+
# Attachments are written **first**: +history.json+ is the commit point,
|
|
542
|
+
# so an interrupted save leaves unreferenced bytes (harmless) rather than
|
|
543
|
+
# a reference to bytes that are not there.
|
|
544
|
+
#
|
|
545
|
+
# Never deletes. An attachment the history no longer references simply
|
|
546
|
+
# stays — pikuri writes into this folder but does not own everything in
|
|
547
|
+
# it, and removing a file a human put there is a worse failure than an
|
|
548
|
+
# orphan. Re-saving is idempotent: a content-addressed name that already
|
|
549
|
+
# exists is skipped after one +stat+, so cost tracks the history rather
|
|
550
|
+
# than the folder.
|
|
551
|
+
#
|
|
552
|
+
# @param dir [String, Pathname] destination; created if absent.
|
|
553
|
+
# @return [Pathname] +dir+, so a caller can chain.
|
|
554
|
+
def save(dir)
|
|
555
|
+
dir = Pathname(dir).expand_path
|
|
556
|
+
attachments = messages.flat_map(&:attachments)
|
|
557
|
+
|
|
558
|
+
unless attachments.empty?
|
|
559
|
+
root = dir / ATTACHMENTS_DIR
|
|
560
|
+
root.mkpath
|
|
561
|
+
attachments.each do |a|
|
|
562
|
+
path = root / a.stored_name
|
|
563
|
+
atomic_write(path, a.bytes) unless path.exist?
|
|
564
|
+
end
|
|
565
|
+
end
|
|
566
|
+
|
|
567
|
+
dir.mkpath
|
|
568
|
+
atomic_write(dir / HISTORY_FILE, JSON.pretty_generate(to_h(inline_attachments: false)))
|
|
569
|
+
dir
|
|
570
|
+
end
|
|
571
|
+
|
|
572
|
+
# A copy in which every tool call has an answer, synthesizing
|
|
573
|
+
# {INTERRUPTED_RESULT} for any the recorded conversation left hanging —
|
|
574
|
+
# what a process killed mid-batch leaves behind.
|
|
575
|
+
#
|
|
576
|
+
# Providers reject an assistant +tool_use+ with no matching result, so
|
|
577
|
+
# this has to happen before the history goes back into a chat. It runs
|
|
578
|
+
# at *load*, never at export: the file stays a faithful record of what
|
|
579
|
+
# happened, and the repair is applied on the way back in.
|
|
580
|
+
#
|
|
581
|
+
# Telling the model "you asked, it did not finish" beats dropping the
|
|
582
|
+
# assistant turn, which would erase that it ever decided to call the
|
|
583
|
+
# tool and leave it to re-derive the decision from nothing.
|
|
584
|
+
#
|
|
585
|
+
# @return [History] +self+ when nothing was dangling.
|
|
586
|
+
def repair_interrupted_tool_calls
|
|
587
|
+
out = []
|
|
588
|
+
pending = []
|
|
589
|
+
repaired = false
|
|
590
|
+
messages.each_with_index do |m, i|
|
|
591
|
+
# Hold synthetics until the batch's real results have gone by, so
|
|
592
|
+
# the answering run stays contiguous and in the order it happened.
|
|
593
|
+
unless pending.empty? || m.role == 'tool'
|
|
594
|
+
out.concat(pending)
|
|
595
|
+
pending = []
|
|
596
|
+
end
|
|
597
|
+
out << m
|
|
598
|
+
next unless m.tool_call?
|
|
599
|
+
|
|
600
|
+
answered = messages[(i + 1)..].take_while { |n| n.role == 'tool' }.map(&:tool_call_id)
|
|
601
|
+
pending = m.tool_calls.reject { |c| answered.include?(c.id) }.map do |call|
|
|
602
|
+
repaired = true
|
|
603
|
+
Message.new(id: History.mint_id, role: 'tool', tool_call_id: call.id, content: INTERRUPTED_RESULT)
|
|
604
|
+
end
|
|
605
|
+
end
|
|
606
|
+
out.concat(pending)
|
|
607
|
+
repaired ? with(messages: out) : self
|
|
608
|
+
end
|
|
609
|
+
|
|
610
|
+
# Observation synthesized for a tool call whose result never arrived.
|
|
611
|
+
INTERRUPTED_RESULT = '[Tool execution was interrupted]'
|
|
612
|
+
|
|
613
|
+
private
|
|
614
|
+
|
|
615
|
+
def message_to_h(msg, inline)
|
|
616
|
+
h = {
|
|
617
|
+
'id' => msg.id,
|
|
618
|
+
'role' => msg.role,
|
|
619
|
+
'kind' => msg.kind,
|
|
620
|
+
'content' => msg.content
|
|
621
|
+
}
|
|
622
|
+
h['attachments'] = msg.attachments.map { |a| attachment_to_h(a, inline) } unless msg.attachments.empty?
|
|
623
|
+
h['tool_calls'] = msg.tool_calls.map { |c| c.to_h.transform_keys(&:to_s) } unless msg.tool_calls.empty?
|
|
624
|
+
h['tool_call_id'] = msg.tool_call_id if msg.tool_call_id
|
|
625
|
+
h['model_id'] = msg.model_id if msg.model_id
|
|
626
|
+
h['thinking'] = compact_h(msg.thinking) if msg.thinking
|
|
627
|
+
h['tokens'] = compact_h(msg.tokens) if msg.tokens
|
|
628
|
+
h
|
|
629
|
+
end
|
|
630
|
+
|
|
631
|
+
def attachment_to_h(att, inline)
|
|
632
|
+
base = { 'mime' => att.mime, 'filename' => att.filename }
|
|
633
|
+
if inline
|
|
634
|
+
base.merge('kind' => 'inline', 'bytes' => [att.bytes].pack('m0'))
|
|
635
|
+
else
|
|
636
|
+
base.merge('kind' => 'file', 'file' => att.stored_name)
|
|
637
|
+
end
|
|
638
|
+
end
|
|
639
|
+
|
|
640
|
+
def compact_h(value)
|
|
641
|
+
value.to_h.compact.transform_keys(&:to_s)
|
|
642
|
+
end
|
|
643
|
+
|
|
644
|
+
# Write via a sibling temp file and rename, so a reader never sees a
|
|
645
|
+
# half-written file and a crash cannot leave one.
|
|
646
|
+
def atomic_write(path, content)
|
|
647
|
+
tmp = path.dirname / ".#{path.basename}.tmp"
|
|
648
|
+
tmp.binwrite(content)
|
|
649
|
+
tmp.rename(path.to_s)
|
|
650
|
+
end
|
|
651
|
+
end
|
|
652
|
+
end
|
|
653
|
+
end
|