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
data/lib/pikuri/agent.rb
CHANGED
|
@@ -3,151 +3,174 @@
|
|
|
3
3
|
require 'ruby_llm'
|
|
4
4
|
|
|
5
5
|
module Pikuri
|
|
6
|
-
# Thin wrapper around +RubyLLM::Chat+:
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
# is wiring ruby_llm's three callbacks at construction time,
|
|
12
|
-
# emitting {Event} variants from each, and forwarding control
|
|
13
|
-
# signals (step-budget tick, cancellation check, mid-loop input
|
|
14
|
-
# drain) to the appropriate {Control}.
|
|
6
|
+
# Thin wrapper around +RubyLLM::Chat+: ruby_llm owns the Thought /
|
|
7
|
+
# Tool-call / Observation loop (+Chat#complete+); pikuri owns the
|
|
8
|
+
# *extension surface* — it wires ruby_llm's three callbacks at
|
|
9
|
+
# construction, emits {Event} variants from each, and forwards control
|
|
10
|
+
# signals to the {Control}s.
|
|
15
11
|
#
|
|
16
|
-
#
|
|
12
|
+
# Two seams meet here:
|
|
17
13
|
#
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
#
|
|
21
|
-
#
|
|
22
|
-
#
|
|
23
|
-
#
|
|
24
|
-
#
|
|
25
|
-
# {Extension#bind}. There is no public path from an +Agent+
|
|
26
|
-
# reference to emission — no +listeners+ reader, no +chat+
|
|
27
|
-
# reader, no emit method — so holding an agent grants read
|
|
28
|
-
# access to its configuration and nothing more. New rendering
|
|
29
|
-
# or capture targets (a web sink, a structured log) are added
|
|
30
|
-
# as listeners without touching {Agent}.
|
|
14
|
+
# 1. **Listeners** ({ListenerList} + {Listener::Base}) — pure consumers
|
|
15
|
+
# of the event stream, never writing back. The +Agent+ emits every
|
|
16
|
+
# loop-narration {Event}; extensions emit domain events through the
|
|
17
|
+
# {ExtensionContext} handed to {Extension#bind}. No public path leads
|
|
18
|
+
# from an +Agent+ reference to emission (no +listeners+/+chat+ reader,
|
|
19
|
+
# no emit method), so holding an agent grants read access and nothing
|
|
20
|
+
# more. New sinks are added as listeners without touching {Agent}.
|
|
31
21
|
# 2. **Controls** ({Control::StepLimit}, {Control::Cancellable},
|
|
32
|
-
# {Control::Interloper}) — host-facing signal holders
|
|
33
|
-
#
|
|
34
|
-
#
|
|
35
|
-
#
|
|
36
|
-
# {Control::Cancellable#check!} on every +before_tool_call+
|
|
37
|
-
# (and {Control::Cancellable#reset!} at turn start),
|
|
38
|
-
# {Control::Interloper#drain!} on every +after_tool_result+.
|
|
22
|
+
# {Control::Interloper}) — host-facing signal holders the +Agent+
|
|
23
|
+
# reads at fixed boundaries: +tick!+/+check!+ on every
|
|
24
|
+
# +before_tool_call+, +reset!+ at turn start, +drain!+ once each tool
|
|
25
|
+
# batch is answered.
|
|
39
26
|
#
|
|
40
|
-
#
|
|
41
|
-
# single grep for +@listeners.emit+ in this file (loop narration)
|
|
27
|
+
# "What fires when" is one grep for +@listeners.emit+ (loop narration)
|
|
42
28
|
# plus the capability calls in {ExtensionContext} (domain events).
|
|
43
29
|
#
|
|
44
30
|
# == Step-exhaustion policy
|
|
45
31
|
#
|
|
46
|
-
#
|
|
47
|
-
#
|
|
48
|
-
# applies the budget's {Control::StepLimit#on_exhausted} policy
|
|
49
|
-
# (see that class header for how hosts pick):
|
|
32
|
+
# When +step_limit:+ trips during completion, {#run_loop} applies its
|
|
33
|
+
# {Control::StepLimit#on_exhausted} policy:
|
|
50
34
|
#
|
|
51
|
-
# - +:raise+ (
|
|
52
|
-
#
|
|
53
|
-
# {Control::StepLimit#reset!} fires at the next turn boundary,
|
|
54
|
-
# so a REPL user can simply say "continue".
|
|
35
|
+
# - +:raise+ (default) — re-raise to the host. History survives and
|
|
36
|
+
# {Control::StepLimit#reset!} fires next turn, so "continue" works.
|
|
55
37
|
# - +:synthesize+ — emit an {Event::FallbackNotice} and run the
|
|
56
|
-
# {Synthesizer} prompt on a nested tools-free +Agent+ (
|
|
57
|
-
#
|
|
58
|
-
# +
|
|
59
|
-
#
|
|
60
|
-
# padded, TokenLog zeroed, recorder shared by reference),
|
|
61
|
-
# parent's +cancellable+ shared so a user cancel during
|
|
62
|
-
# synthesis still works, a defensive +step_limit+ at +max: 1+
|
|
63
|
-
# (the synth has no tools and shouldn't tick it). The synth's
|
|
64
|
-
# answer becomes the value reported by
|
|
65
|
-
# {#last_assistant_content}, so callers (notably the +agent+
|
|
66
|
-
# tool from +pikuri-subagents+) still get a usable reply.
|
|
38
|
+
# {Synthesizer} prompt on a nested tools-free +Agent+ (listener stream
|
|
39
|
+
# via {ListenerList#for_sub_agent}, +cancellable+ shared so a user
|
|
40
|
+
# cancel still works, a defensive +step_limit+ at +max: 1+). Its answer
|
|
41
|
+
# becomes {#last_assistant_content}, so callers still get a reply.
|
|
67
42
|
#
|
|
68
43
|
# == Cancellation rescue
|
|
69
44
|
#
|
|
70
|
-
#
|
|
71
|
-
#
|
|
72
|
-
#
|
|
73
|
-
#
|
|
74
|
-
#
|
|
75
|
-
#
|
|
76
|
-
#
|
|
77
|
-
#
|
|
78
|
-
#
|
|
79
|
-
#
|
|
80
|
-
#
|
|
45
|
+
# When +cancellable:+ trips during +Chat#ask+, {#run_loop} emits an
|
|
46
|
+
# {Event::Cancelled} and re-raises — no synthesizer fallback (cancel
|
|
47
|
+
# means drop everything). {Control::Cancellable#reset!} at each turn
|
|
48
|
+
# start lets the same instance take a fresh turn afterwards.
|
|
49
|
+
#
|
|
50
|
+
# == Threading
|
|
51
|
+
#
|
|
52
|
+
# **Thread-unsafe** — and that is the whole guarantee, deliberately no
|
|
53
|
+
# stronger. +@chat+, the message-identity map and the +on_close+ list are
|
|
54
|
+
# plain unguarded state, and {#run_loop} is not re-entrant, so a second
|
|
55
|
+
# thread calling in mid-turn corrupts the conversation rather than raising.
|
|
56
|
+
# Preventing concurrent access is the caller's job, by any means: one thread
|
|
57
|
+
# per agent, a mutex around every call, an actor mailbox. Nothing here is
|
|
58
|
+
# *confined* to a particular thread — an +Agent+ may be driven from a
|
|
59
|
+
# different thread on each turn, so never key anything about it off
|
|
60
|
+
# +Thread.current+.
|
|
61
|
+
#
|
|
62
|
+
# Several agents in one VM is then the easy case, one thread each:
|
|
63
|
+
#
|
|
64
|
+
# agents.map { |a| Thread.new { a.run_loop(user_message: task_for(a)) } }.each(&:join)
|
|
65
|
+
#
|
|
66
|
+
# Two {Control}s are the sanctioned doors *in* from another thread, which is
|
|
67
|
+
# why they carry locks and the agent does not: {Control::Cancellable} (stop
|
|
68
|
+
# the turn) and {Control::Interloper} (queue a mid-turn message, delivered at
|
|
69
|
+
# the next tool-batch boundary). Every other method needs the caller's own
|
|
70
|
+
# serialization.
|
|
71
|
+
#
|
|
72
|
+
# Tools inherit nothing from this: an instance handed to two agents is
|
|
73
|
+
# reachable from both, so each declares its own posture (see
|
|
74
|
+
# {Pikuri::Tool}'s +== Sharing+).
|
|
81
75
|
class Agent
|
|
82
76
|
LOGGER = Pikuri.logger_for('Agent')
|
|
83
77
|
private_constant :LOGGER
|
|
84
78
|
|
|
85
|
-
#
|
|
86
|
-
#
|
|
87
|
-
#
|
|
88
|
-
#
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
#
|
|
93
|
-
#
|
|
94
|
-
#
|
|
95
|
-
#
|
|
96
|
-
#
|
|
97
|
-
#
|
|
98
|
-
#
|
|
99
|
-
#
|
|
100
|
-
#
|
|
101
|
-
#
|
|
102
|
-
#
|
|
103
|
-
#
|
|
104
|
-
#
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
#
|
|
109
|
-
#
|
|
110
|
-
#
|
|
111
|
-
#
|
|
112
|
-
#
|
|
113
|
-
#
|
|
114
|
-
#
|
|
115
|
-
#
|
|
116
|
-
#
|
|
117
|
-
#
|
|
118
|
-
#
|
|
119
|
-
#
|
|
120
|
-
#
|
|
121
|
-
#
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
#
|
|
136
|
-
#
|
|
137
|
-
#
|
|
138
|
-
#
|
|
139
|
-
#
|
|
140
|
-
#
|
|
141
|
-
#
|
|
142
|
-
#
|
|
143
|
-
#
|
|
144
|
-
#
|
|
145
|
-
|
|
146
|
-
#
|
|
147
|
-
|
|
148
|
-
#
|
|
149
|
-
|
|
150
|
-
#
|
|
79
|
+
# Envelope wrapped around every injected reference block by
|
|
80
|
+
# {#append_reference_block}. +system-reminder+ is Claude Code's and
|
|
81
|
+
# grok-build's tag, deliberately — an in-distribution envelope the model has
|
|
82
|
+
# seen in training beats a pikuri-flavoured one it has to infer.
|
|
83
|
+
REFERENCE_TAG = 'system-reminder'
|
|
84
|
+
private_constant :REFERENCE_TAG
|
|
85
|
+
|
|
86
|
+
# Provider slugs (matched against +@chat.model.provider+) whose wire format
|
|
87
|
+
# carries system content in a top-level request field rather than as an
|
|
88
|
+
# element of +messages+ — Anthropic's +system+ param, Bedrock's +system+
|
|
89
|
+
# blocks. ruby_llm's provider splits it out at payload-render time
|
|
90
|
+
# (+messages.partition { |m| m.role == :system }+), so a conversation
|
|
91
|
+
# holding nothing but the system prompt renders +messages: []+ and the API
|
|
92
|
+
# 400s — see {EmptyTurnError}. Everywhere else +:system+ is an ordinary
|
|
93
|
+
# array element (OpenAI, Gemini) and that same conversation is a legal
|
|
94
|
+
# one-message request.
|
|
95
|
+
#
|
|
96
|
+
# Only +:system+ moves — a placement difference in the payload, not a
|
|
97
|
+
# rewrite of what pikuri appended, so the +:user+ blocks of
|
|
98
|
+
# {#append_reference_block} ride through untouched on every provider here.
|
|
99
|
+
SEPARATE_SYSTEM_FIELD_PROVIDERS = %w[anthropic bedrock].freeze
|
|
100
|
+
private_constant :SEPARATE_SYSTEM_FIELD_PROVIDERS
|
|
101
|
+
|
|
102
|
+
# A blank turn was requested against a conversation holding nothing but the
|
|
103
|
+
# assembled system prompt, on a provider that would render that as
|
|
104
|
+
# +messages: []+ (see {SEPARATE_SYSTEM_FIELD_PROVIDERS}):
|
|
105
|
+
#
|
|
106
|
+
# agent.run_loop(user_message: nil) # fresh chat, backend Anthropic
|
|
107
|
+
# # => EmptyTurnError; the host catches it and prompts for a real message
|
|
108
|
+
#
|
|
109
|
+
# Narrow by construction: every *injected* block lands as +role: :user+
|
|
110
|
+
# ({#append_reference_block}), so a skill-only or memory-only bare load has
|
|
111
|
+
# a message to complete against and never reaches here. What remains is a
|
|
112
|
+
# genuinely empty conversation.
|
|
113
|
+
#
|
|
114
|
+
# Content-neutral: {#provider} is all it carries — the host owns any
|
|
115
|
+
# user-facing wording.
|
|
116
|
+
class EmptyTurnError < StandardError
|
|
117
|
+
# @return [String] the resolved provider slug that can't complete the turn
|
|
118
|
+
attr_reader :provider
|
|
119
|
+
|
|
120
|
+
# @param provider [String] the resolved provider slug (e.g. +"anthropic"+)
|
|
121
|
+
def initialize(provider)
|
|
122
|
+
@provider = provider
|
|
123
|
+
super("#{provider} cannot complete a turn whose conversation holds only " \
|
|
124
|
+
':system content (no user or assistant message): it would send an ' \
|
|
125
|
+
'empty messages array. Add a user message.')
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# What {#run_loop} answers a tool call with when its batch unwound — see
|
|
130
|
+
# +== Repairing an unwound tool batch+. Public so a renderer can tell one
|
|
131
|
+
# from a real observation, as {History::INTERRUPTED_RESULT} is for the
|
|
132
|
+
# on-disk shape.
|
|
133
|
+
#
|
|
134
|
+
# Paired per cause: the first unanswered call is the one that raised or was
|
|
135
|
+
# about to, the rest never started, and telling a skipped call "you failed"
|
|
136
|
+
# invites a pointless retry. A tool failure has no constant — it reports
|
|
137
|
+
# +"Error: <class>: <message>"+, the shape {Tool#run} already returns for a
|
|
138
|
+
# validation failure.
|
|
139
|
+
CANCELLED_RESULT = '[Cancelled by the user]'
|
|
140
|
+
# @see CANCELLED_RESULT
|
|
141
|
+
CANCELLED_SKIPPED_RESULT = '[Not executed — the user cancelled this turn]'
|
|
142
|
+
# @see CANCELLED_RESULT
|
|
143
|
+
STEP_BUDGET_RESULT = '[Not executed — step budget exhausted]'
|
|
144
|
+
# @see CANCELLED_RESULT
|
|
145
|
+
FAILED_SKIPPED_RESULT = '[Not executed — a previous tool call in this batch failed]'
|
|
146
|
+
|
|
147
|
+
# @param transport [ChatTransport] model-resolution bundle (+model+ /
|
|
148
|
+
# +provider+ / +assume_model_exists+ and, off the global server,
|
|
149
|
+
# +api_base+ / +api_key+). A +nil+ +transport.model+ is filled from
|
|
150
|
+
# +RubyLLM.config.default_model+.
|
|
151
|
+
# @param system_prompt [String] base system message; the chat sees
|
|
152
|
+
# this plus every extension's {Extension#system_prompt_snippets},
|
|
153
|
+
# assembled by {#assemble_system_prompt} at construction and on each
|
|
154
|
+
# {#clear_conversation}.
|
|
155
|
+
# @param step_limit [Control::StepLimit, nil] step budget, or +nil+ for
|
|
156
|
+
# no budget (unbounded looping). Its {Control::StepLimit#on_exhausted}
|
|
157
|
+
# policy drives {#run_loop} on trip (see the class header).
|
|
158
|
+
# @param cancellable [Control::Cancellable, nil] cancellation control,
|
|
159
|
+
# or +nil+ for none (no way to stop a running turn but killing the
|
|
160
|
+
# process).
|
|
161
|
+
# @param interloper [Control::Interloper, nil] mid-loop user-input queue
|
|
162
|
+
# drained once each tool batch is answered (each item becomes an
|
|
163
|
+
# {Event::UserTurn} with +mid_loop: true+), or +nil+ for none.
|
|
164
|
+
# @param id [String] unique id; empty for the main agent, persona-rooted
|
|
165
|
+
# for sub-agents (+"researcher 0"+, ...). Distinct from a persona's
|
|
166
|
+
# +name+ (the value the LLM picks in the +agent+ tool's +name:+ arg).
|
|
167
|
+
# @param streaming [Boolean] opt into chunk-level streaming — {#run_loop}
|
|
168
|
+
# passes a per-chunk block to +Chat#complete+ and normalizes chunks
|
|
169
|
+
# into {Event::ThinkingDelta}/{Event::AssistantDelta}. Default +false+
|
|
170
|
+
# (only the +after_message+ bookends fire).
|
|
171
|
+
# @yield [Configurator] the sole path for adding tools, listeners,
|
|
172
|
+
# extensions, and +on_close+ handlers — there are no parallel ctor
|
|
173
|
+
# kwargs. Optional; without it the agent has none of those.
|
|
151
174
|
# @return [Agent]
|
|
152
175
|
def initialize(transport:, system_prompt:,
|
|
153
176
|
step_limit: nil, cancellable: nil, interloper: nil,
|
|
@@ -158,6 +181,11 @@ module Pikuri
|
|
|
158
181
|
@cancellable = cancellable
|
|
159
182
|
@closed = false
|
|
160
183
|
|
|
184
|
+
# Kept separate from the assembled +@system_prompt+ so
|
|
185
|
+
# {#assemble_system_prompt} can recompute (base + extensions'
|
|
186
|
+
# snippets) from scratch at construction and on each
|
|
187
|
+
# {#clear_conversation}, refreshing dynamic sections.
|
|
188
|
+
@system_prompt_base = system_prompt
|
|
161
189
|
@system_prompt = system_prompt
|
|
162
190
|
@step_limit = step_limit
|
|
163
191
|
@interloper = interloper
|
|
@@ -165,127 +193,86 @@ module Pikuri
|
|
|
165
193
|
@streaming = streaming
|
|
166
194
|
@synth_answer = nil
|
|
167
195
|
@on_close_handlers = []
|
|
168
|
-
# Stashed for {#run_configure},
|
|
169
|
-
# build phase below out of a separate method.
|
|
196
|
+
# Stashed for {#run_configure}, the failure-prone build phase.
|
|
170
197
|
@block = block
|
|
171
198
|
|
|
172
|
-
# Register *before* the build phase so a mid-construction raise
|
|
173
|
-
#
|
|
174
|
-
# +
|
|
175
|
-
#
|
|
176
|
-
#
|
|
177
|
-
# is the at-exit backstop if the host forgets {#close}; an
|
|
178
|
-
# explicit {#close} unregisters, so the agent isn't pinned alive
|
|
179
|
-
# until process exit.
|
|
199
|
+
# Register *before* the build phase so a mid-construction raise is
|
|
200
|
+
# recoverable: extensions arm cleanup via +c.on_close+ (straight into
|
|
201
|
+
# +@on_close_handlers+), and the rescue below fires whatever was armed
|
|
202
|
+
# before the failure. On the happy path this is the at-exit backstop
|
|
203
|
+
# if the host forgets {#close}; an explicit {#close} unregisters.
|
|
180
204
|
Pikuri::Finalizers.register(self)
|
|
181
205
|
|
|
182
206
|
begin
|
|
183
207
|
run_configure
|
|
184
208
|
rescue StandardError
|
|
185
|
-
# Half-built agent
|
|
186
|
-
#
|
|
187
|
-
# out of the registry, and re-raise — no partial state leaks.
|
|
209
|
+
# Half-built agent: fire the armed handlers, drop out of the
|
|
210
|
+
# registry, re-raise — no partial state leaks.
|
|
188
211
|
close
|
|
189
212
|
raise
|
|
190
213
|
end
|
|
191
214
|
end
|
|
192
215
|
|
|
193
|
-
# @return [ChatTransport] the resolved transport
|
|
194
|
-
#
|
|
195
|
-
#
|
|
196
|
-
# call originating from this agent (the main chat, the
|
|
197
|
-
# synthesizer rescue, the +agent+ tool from
|
|
198
|
-
# +pikuri-subagents+). Read by extensions that need to spawn
|
|
199
|
-
# their own ruby_llm calls (e.g. MCP description synthesis,
|
|
200
|
-
# sub-agent delegation).
|
|
216
|
+
# @return [ChatTransport] the resolved transport this agent was built
|
|
217
|
+
# with — the model/provider/connection every +RubyLLM.chat+ from this
|
|
218
|
+
# agent uses (main chat, synthesizer, sub-agents).
|
|
201
219
|
attr_reader :transport
|
|
202
220
|
|
|
203
|
-
# @return [Array<Tool>]
|
|
204
|
-
#
|
|
205
|
-
#
|
|
206
|
-
# sub-agent's toolset from the parent's instances so any
|
|
207
|
-
# already-bound workspace/confirmer wiring travels along).
|
|
208
|
-
# Tools listed here are also the ones registered with
|
|
209
|
-
# ruby_llm — the parent LLM can call any of them. Compare
|
|
210
|
-
# with {#sub_agent_tools}.
|
|
221
|
+
# @return [Array<Tool>] tool list in declaration order. These are the
|
|
222
|
+
# tools registered with ruby_llm — the parent LLM can call any. Cf.
|
|
223
|
+
# {#sub_agent_tools}.
|
|
211
224
|
attr_reader :tools
|
|
212
225
|
|
|
213
|
-
# @return [Array<Tool>] tools
|
|
214
|
-
# {Configurator#add_sub_agent_tool}, in declaration order.
|
|
215
|
-
#
|
|
216
|
-
#
|
|
217
|
-
#
|
|
218
|
-
# the trifecta-defense rationale.
|
|
226
|
+
# @return [Array<Tool>] tools added via
|
|
227
|
+
# {Configurator#add_sub_agent_tool}, in declaration order. Never sent
|
|
228
|
+
# to ruby_llm (invisible to the parent LLM); available only to
|
|
229
|
+
# sub-agents whose persona +tool_names+ match — the trifecta defense,
|
|
230
|
+
# see {Configurator}.
|
|
219
231
|
attr_reader :sub_agent_tools
|
|
220
232
|
|
|
221
233
|
# @return [String] resolved model id from {#transport}.
|
|
222
|
-
# Convenience delegator for callers that don't need the
|
|
223
|
-
# full transport bundle.
|
|
224
234
|
def model
|
|
225
235
|
@transport.model
|
|
226
236
|
end
|
|
227
237
|
|
|
228
|
-
# @return [String] system prompt actually sent
|
|
229
|
-
#
|
|
230
|
-
#
|
|
231
|
-
# (
|
|
232
|
-
#
|
|
233
|
-
# each persona owns its own system prompt verbatim.
|
|
238
|
+
# @return [String] system prompt actually sent — the base
|
|
239
|
+
# +system_prompt:+ plus every extension's
|
|
240
|
+
# {Extension#system_prompt_snippets}, re-assembled on
|
|
241
|
+
# {#clear_conversation} (so a dynamic section may differ from its
|
|
242
|
+
# construction value). Not inherited by sub-agents.
|
|
234
243
|
attr_reader :system_prompt
|
|
235
244
|
|
|
236
|
-
# @return [Control::StepLimit, nil] the step
|
|
237
|
-
# this agent was constructed with, or +nil+ when none.
|
|
245
|
+
# @return [Control::StepLimit, nil] the step budget, or +nil+.
|
|
238
246
|
attr_reader :step_limit
|
|
239
247
|
|
|
240
|
-
# @return [Control::Cancellable, nil] the cancellation
|
|
241
|
-
#
|
|
242
|
-
#
|
|
243
|
-
# their own LLM calls (e.g. the +agent+ tool from
|
|
244
|
-
# +pikuri-subagents+ shares it with spawned sub-agents so
|
|
245
|
-
# one Ctrl+C stops the tree).
|
|
248
|
+
# @return [Control::Cancellable, nil] the cancellation control, or
|
|
249
|
+
# +nil+. Extensions read it to propagate cancellation to their own
|
|
250
|
+
# LLM calls (the +agent+ tool shares it so one Ctrl+C stops the tree).
|
|
246
251
|
attr_reader :cancellable
|
|
247
252
|
|
|
248
|
-
# @return [Control::Interloper, nil] the mid-loop user-input
|
|
249
|
-
#
|
|
250
|
-
# none.
|
|
253
|
+
# @return [Control::Interloper, nil] the mid-loop user-input control, or
|
|
254
|
+
# +nil+.
|
|
251
255
|
attr_reader :interloper
|
|
252
256
|
|
|
253
|
-
# @return [String] this agent's
|
|
254
|
-
#
|
|
255
|
-
#
|
|
256
|
-
#
|
|
257
|
-
# Propagated to listeners via {ListenerList#for_sub_agent(id:)}
|
|
258
|
-
# so id-aware ones can tag output. Distinct from the persona's
|
|
259
|
-
# +name+ (the value the LLM picks in the +agent+ tool's
|
|
260
|
-
# +name:+ argument).
|
|
257
|
+
# @return [String] this agent's id — empty for the main agent,
|
|
258
|
+
# persona-rooted for sub-agents (+"researcher 0"+). Distinct from the
|
|
259
|
+
# persona +name+; propagated to listeners via
|
|
260
|
+
# {ListenerList#for_sub_agent} so id-aware ones tag their output.
|
|
261
261
|
attr_reader :id
|
|
262
262
|
|
|
263
|
-
# @return [Boolean]
|
|
264
|
-
#
|
|
265
|
-
# {#initialize}); +false+ otherwise. Read by extensions that
|
|
266
|
-
# spawn their own ruby_llm calls (notably the +agent+ tool
|
|
267
|
-
# from +pikuri-subagents+, so spawned sub-agents inherit the
|
|
268
|
-
# same mode).
|
|
263
|
+
# @return [Boolean] whether this agent opted into chunk-level streaming
|
|
264
|
+
# (see {#initialize}'s +streaming:+).
|
|
269
265
|
attr_reader :streaming
|
|
270
266
|
|
|
271
|
-
# @return [Array<Extension>]
|
|
272
|
-
#
|
|
273
|
-
#
|
|
274
|
-
# the block and its +bind+ runs at the end of
|
|
275
|
-
# {#initialize}, once per registration (so once per parent
|
|
276
|
-
# agent in the typical setup; sub-agents do not inherit
|
|
277
|
-
# extensions).
|
|
267
|
+
# @return [Array<Extension>] extensions bound to this agent. Each one's
|
|
268
|
+
# +configure+ ran during the +Agent.new+ block, its +bind+ at the end
|
|
269
|
+
# of {#initialize}. Not inherited by sub-agents.
|
|
278
270
|
attr_reader :extensions
|
|
279
271
|
|
|
280
272
|
# @return [Integer, nil] resolved context-window cap — the
|
|
281
|
-
# {ChatTransport#context_window} if
|
|
282
|
-
# {ContextWindowDetector}
|
|
283
|
-
#
|
|
284
|
-
# on every model switch (see {#run_loop}'s +transport:+). Read by
|
|
285
|
-
# extensions that spawn their own ruby_llm calls (notably the
|
|
286
|
-
# +agent+ tool from +pikuri-subagents+, which hands a sub-agent
|
|
287
|
-
# +parent.transport.with(context_window: this)+ so the resolved
|
|
288
|
-
# cap rides along without a re-probe).
|
|
273
|
+
# {ChatTransport#context_window} if given, else the
|
|
274
|
+
# {ContextWindowDetector} probe; +nil+ when neither yields one.
|
|
275
|
+
# Re-resolved on every model switch.
|
|
289
276
|
attr_reader :context_window_cap
|
|
290
277
|
|
|
291
278
|
# Final assistant message content for the most recent
|
|
@@ -302,85 +289,100 @@ module Pikuri
|
|
|
302
289
|
last&.content
|
|
303
290
|
end
|
|
304
291
|
|
|
305
|
-
# Run the agent loop for
|
|
306
|
-
# {Event::UserTurn}
|
|
307
|
-
#
|
|
308
|
-
#
|
|
309
|
-
#
|
|
310
|
-
#
|
|
311
|
-
#
|
|
312
|
-
#
|
|
313
|
-
#
|
|
314
|
-
#
|
|
315
|
-
#
|
|
316
|
-
#
|
|
317
|
-
#
|
|
318
|
-
#
|
|
319
|
-
#
|
|
320
|
-
#
|
|
321
|
-
# "Step-exhaustion policy"
|
|
322
|
-
#
|
|
323
|
-
# If the +cancellable+ control trips during +ask+, the rescue
|
|
324
|
-
# branch emits an {Event::Cancelled} and re-raises the
|
|
325
|
-
# +Cancelled+ exception. No synthesizer fallback runs — see
|
|
326
|
-
# the "Cancellation rescue" section in the class header.
|
|
327
|
-
#
|
|
328
|
-
# Subsequent calls keep building on the same chat history, so
|
|
329
|
-
# the model sees full multi-turn context.
|
|
292
|
+
# Run the agent loop for one turn. With a +user_message+, emits an
|
|
293
|
+
# {Event::UserTurn} (+mid_loop: false+) and appends it; with none, runs a
|
|
294
|
+
# *bare* turn against whatever the conversation + drained injections
|
|
295
|
+
# already hold — the case a host uses after loading a skill on its own
|
|
296
|
+
# initiative, so the agent reacts to it. Resets the step/cancellation
|
|
297
|
+
# controls, drains any pending {Control::Interloper} injections, then
|
|
298
|
+
# +complete+s the chat. Output is the listeners' job; subsequent calls
|
|
299
|
+
# build on the same history.
|
|
300
|
+
#
|
|
301
|
+
# A blank +user_message+ against a conversation holding nothing but the
|
|
302
|
+
# system prompt is provider-split: providers that can't complete it raise
|
|
303
|
+
# {EmptyTurnError}, the rest run the turn against that history. A blank turn
|
|
304
|
+
# *mid-conversation* always proceeds — real turns remain to answer, and a
|
|
305
|
+
# drained injection is itself one.
|
|
306
|
+
#
|
|
307
|
+
# Step-limit and cancellation trips are handled per the class header's
|
|
308
|
+
# "Step-exhaustion policy" / "Cancellation rescue".
|
|
330
309
|
#
|
|
331
310
|
# == Switching models mid-conversation
|
|
332
311
|
#
|
|
333
|
-
#
|
|
334
|
-
#
|
|
335
|
-
# +
|
|
336
|
-
#
|
|
337
|
-
#
|
|
338
|
-
#
|
|
339
|
-
#
|
|
340
|
-
#
|
|
341
|
-
#
|
|
342
|
-
#
|
|
343
|
-
#
|
|
344
|
-
#
|
|
345
|
-
#
|
|
346
|
-
#
|
|
347
|
-
#
|
|
348
|
-
#
|
|
349
|
-
#
|
|
350
|
-
#
|
|
351
|
-
#
|
|
352
|
-
#
|
|
353
|
-
#
|
|
354
|
-
#
|
|
355
|
-
#
|
|
356
|
-
#
|
|
357
|
-
#
|
|
358
|
-
#
|
|
359
|
-
#
|
|
360
|
-
#
|
|
361
|
-
#
|
|
362
|
-
# @
|
|
363
|
-
#
|
|
364
|
-
# @
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
312
|
+
# A +transport:+ differing from the current one switches the chat to it
|
|
313
|
+
# (via {#apply_transport!}), re-resolves the cap, and emits
|
|
314
|
+
# {Event::ModelSwitched} + a fresh {Event::ContextCap}. The switch is
|
|
315
|
+
# confined to the top of this method because the chat is
|
|
316
|
+
# single-thread-confined — a background thread mutating the connection
|
|
317
|
+
# mid-completion would tear an in-flight stream. The conversation is
|
|
318
|
+
# *not* re-baselined: same conversation, new model, so message count and
|
|
319
|
+
# running context size carry over. +nil+ keeps the current model.
|
|
320
|
+
#
|
|
321
|
+
# == Repairing an unwound tool batch
|
|
322
|
+
#
|
|
323
|
+
# ruby_llm appends the assistant +tool_calls+ message *before* running any
|
|
324
|
+
# of them, so anything unwinding out of the batch — a tool raising, a
|
|
325
|
+
# {Control::Cancellable::Cancelled}, a tripped budget, an untrapped
|
|
326
|
+
# +Interrupt+ — leaves calls no +:tool+ message answers. OpenAI and
|
|
327
|
+
# Anthropic 400 on that; a local llama.cpp feeds it to the chat template
|
|
328
|
+
# and the model improvises. So an +ensure+ answers each dangling call
|
|
329
|
+
# ({#repair_dangling_tool_calls!}) and the exception then propagates: a
|
|
330
|
+
# host that keeps the conversation gets one it can still send.
|
|
331
|
+
#
|
|
332
|
+
# +ensure+ rather than a clause per +rescue+, because it also catches what
|
|
333
|
+
# nobody enumerated (+Interrupt+ is no +StandardError+) and it runs *after*
|
|
334
|
+
# the +:synthesize+ rescue body, leaving {Synthesizer.build_prompt} the
|
|
335
|
+
# unrepaired history its drop-calls-with-no-result pass expects.
|
|
336
|
+
#
|
|
337
|
+
# Unlike {History#repair_interrupted_tool_calls} — the load path, where a
|
|
338
|
+
# killed process leaves no clue and {History::INTERRUPTED_RESULT} has to be
|
|
339
|
+
# generic — the cause is in hand here and picks the wording.
|
|
340
|
+
#
|
|
341
|
+
# @param user_message [String, nil] the turn's request; +nil+/blank runs a
|
|
342
|
+
# bare turn (see above)
|
|
343
|
+
# @param transport [ChatTransport, nil] when non-+nil+ and structurally
|
|
344
|
+
# different, switch to it before the turn; +nil+ keeps the model
|
|
345
|
+
# @raise [Control::Cancellable::Cancelled] if cancelled mid-turn (an
|
|
346
|
+
# {Event::Cancelled} is emitted first)
|
|
347
|
+
# @raise [Control::StepLimit::Exceeded] if the budget tripped under the
|
|
348
|
+
# +:raise+ policy
|
|
349
|
+
# @raise [EmptyTurnError] on a blank turn against a prompt-only
|
|
350
|
+
# conversation when the provider carries +:system+ outside +messages+
|
|
351
|
+
# (see above)
|
|
352
|
+
# @return [nil] after a turn runs
|
|
353
|
+
def run_loop(user_message: nil, transport: nil)
|
|
369
354
|
apply_transport!(transport) if transport
|
|
370
355
|
@synth_answer = nil
|
|
371
356
|
@step_limit&.reset!
|
|
372
357
|
@cancellable&.reset!
|
|
373
|
-
|
|
374
|
-
#
|
|
375
|
-
#
|
|
376
|
-
#
|
|
377
|
-
#
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
358
|
+
|
|
359
|
+
# Deliver host-scheduled injections (e.g. a UI-loaded skill) before the
|
|
360
|
+
# first complete, so a freshly-scheduled block lands this turn rather
|
|
361
|
+
# than only after the next tool batch — ordered ahead of the user
|
|
362
|
+
# message appended below.
|
|
363
|
+
drain_interloper if @interloper
|
|
364
|
+
|
|
365
|
+
blank = user_message.nil? || user_message.to_s.strip.empty?
|
|
366
|
+
# These providers would send `messages: []` here (see EmptyTurnError).
|
|
367
|
+
# The drain above is what usually saves this case: an injected block is a
|
|
368
|
+
# :user message, so a skill-only bare load has something to complete
|
|
369
|
+
# against and falls through.
|
|
370
|
+
if blank && @chat.messages.all? { |m| m.role == :system } &&
|
|
371
|
+
SEPARATE_SYSTEM_FIELD_PROVIDERS.include?(@chat.model.provider.to_s)
|
|
372
|
+
raise EmptyTurnError, @chat.model.provider
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
unless blank
|
|
376
|
+
# Append the user turn, emit it, then run the memory dispatch — so any
|
|
377
|
+
# <memory-context> the dispatch injects lands as a :system message
|
|
378
|
+
# *after* the user turn it annotates. (+ask+ bundles append+complete
|
|
379
|
+
# atomically, leaving no seam to inject between; the halves run
|
|
380
|
+
# explicitly instead.)
|
|
381
|
+
track_message(@chat.add_message(role: :user, content: user_message))
|
|
382
|
+
@listeners.emit(Event::UserTurn.new(content: user_message, mid_loop: false))
|
|
383
|
+
dispatch_ext_on_user_message(user_message)
|
|
384
|
+
end
|
|
385
|
+
|
|
384
386
|
if @streaming
|
|
385
387
|
@chat.complete(&streaming_block)
|
|
386
388
|
else
|
|
@@ -393,28 +395,154 @@ module Pikuri
|
|
|
393
395
|
rescue Control::StepLimit::Exceeded => e
|
|
394
396
|
raise unless @step_limit&.on_exhausted == :synthesize
|
|
395
397
|
|
|
398
|
+
# Ruby restores +$!+ once a rescue clause completes normally, so the
|
|
399
|
+
# +ensure+ below would see no cause for this one unwind. Assigned before
|
|
400
|
+
# the synth runs, so a synth that raises doesn't displace the real reason.
|
|
401
|
+
exhausted = e
|
|
396
402
|
@synth_answer = Synthesizer.run_synthesizer(@extension_context, @chat.messages, user_message)
|
|
397
403
|
nil
|
|
404
|
+
ensure
|
|
405
|
+
repair_dangling_tool_calls!(exhausted || $!)
|
|
398
406
|
end
|
|
399
407
|
|
|
400
|
-
#
|
|
401
|
-
#
|
|
402
|
-
#
|
|
403
|
-
#
|
|
404
|
-
#
|
|
405
|
-
#
|
|
406
|
-
#
|
|
407
|
-
#
|
|
408
|
-
#
|
|
408
|
+
# Return the agent to a fresh-conversation state without tearing down
|
|
409
|
+
# its wiring — the host "/clear". Unlike {#close} (which fires the
|
|
410
|
+
# teardown handlers), everything stays alive; nothing is re-probed or
|
|
411
|
+
# re-connected. Resets, in order:
|
|
412
|
+
#
|
|
413
|
+
# 1. Chat history (+Chat#reset_messages!+, which also drops every injected
|
|
414
|
+
# reference block), then re-applies a freshly **re-assembled** prompt —
|
|
415
|
+
# {#assemble_system_prompt} re-pulls every extension's snippets, so
|
|
416
|
+
# dynamic sections refresh (a resident memory persona, a re-read
|
|
417
|
+
# +MACHINE.md+). Callbacks and tool registrations on the reused +@chat+
|
|
418
|
+
# are untouched.
|
|
419
|
+
# 2. Controls — step/cancellation reset, interloper queue drained (stale
|
|
420
|
+
# mid-loop input must not leak forward), cached synth answer dropped.
|
|
421
|
+
# 3. Listeners — a single {Event::Reset} so they reset display state.
|
|
422
|
+
# 4. Extension state — {Extension#on_conversation_reset} on each.
|
|
423
|
+
#
|
|
424
|
+
# Model/transport are left as-is (a clear is not a model switch). Runs on
|
|
425
|
+
# the agent's own thread; not designed to race an in-flight {#run_loop}.
|
|
426
|
+
#
|
|
427
|
+
# @return [void]
|
|
428
|
+
def clear_conversation
|
|
429
|
+
@chat.reset_messages!
|
|
430
|
+
@message_meta.clear
|
|
431
|
+
@system_prompt = assemble_system_prompt
|
|
432
|
+
@chat.with_instructions(@system_prompt)
|
|
433
|
+
@synth_answer = nil
|
|
434
|
+
@step_limit&.reset!
|
|
435
|
+
@cancellable&.reset!
|
|
436
|
+
@interloper&.drain!
|
|
437
|
+
@listeners.emit(Event::Reset.new)
|
|
438
|
+
@extensions.each { |ext| ext.on_conversation_reset(@extension_context) }
|
|
439
|
+
nil
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
# Snapshot the conversation as a {History} — the round-trip partner of
|
|
443
|
+
# {#load_history!}, and the supported way for a host to persist a
|
|
444
|
+
# conversation without reaching into the underlying chat.
|
|
445
|
+
#
|
|
446
|
+
# agent.export_history.save(dir) # a folder per conversation
|
|
447
|
+
# File.write(f, agent.export_history.to_json) # or one self-contained file
|
|
448
|
+
#
|
|
449
|
+
# The system prompt is excluded — {#load_history!} re-assembles it, so a
|
|
450
|
+
# resumed conversation gets today's, not the one that was current when it
|
|
451
|
+
# was saved.
|
|
452
|
+
#
|
|
453
|
+
# Attachment bytes are read here, eagerly, so the snapshot still shows
|
|
454
|
+
# what the model saw after the file has changed on disk.
|
|
455
|
+
#
|
|
456
|
+
# A thinking block withheld from the conversation because another model
|
|
457
|
+
# produced it is restored here from the message's metadata, so the record
|
|
458
|
+
# survives any number of load/save cycles under a switched model.
|
|
459
|
+
#
|
|
460
|
+
# @return [History]
|
|
461
|
+
def export_history
|
|
462
|
+
exported = @chat.messages.reject { |m| m.role == :system }.map do |msg|
|
|
463
|
+
meta = @message_meta[msg] || { id: History.mint_id, kind: 'user' }
|
|
464
|
+
exported_msg = History::Message.from_ruby_llm(msg, id: meta[:id], kind: meta[:kind])
|
|
465
|
+
meta[:thinking] ? exported_msg.with(thinking: meta[:thinking]) : exported_msg
|
|
466
|
+
end
|
|
467
|
+
History.new(messages: exported)
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
# Replace the conversation with a saved one, keeping every bit of wiring
|
|
471
|
+
# — tools, listeners, extensions, model — in place.
|
|
472
|
+
#
|
|
473
|
+
# agent.load_history!(History.load(dir))
|
|
474
|
+
#
|
|
475
|
+
# Runs {#clear_conversation} first, so controls reset and extensions see
|
|
476
|
+
# {Extension#on_conversation_reset} exactly as they would on a host
|
|
477
|
+
# "/clear"; the system prompt is re-assembled from current state. Then
|
|
478
|
+
# emits {Event::HistoryLoaded} — {Event::Reset} alone would tell listeners
|
|
479
|
+
# the conversation is empty, which it no longer is.
|
|
480
|
+
#
|
|
481
|
+
# Safe to call repeatedly on a long-lived agent: switching conversations
|
|
482
|
+
# in a shell is exactly this, once per switch, and nothing survives across
|
|
483
|
+
# one. Like the rest of {Agent}, it must not race a running {#run_loop}.
|
|
484
|
+
#
|
|
485
|
+
# == What is restored, and what is not
|
|
486
|
+
#
|
|
487
|
+
# Messages, tool calls and their results, thinking, attachments, per-message
|
|
488
|
+
# usage. **Not** state living outside the conversation: a workspace's
|
|
489
|
+
# read-before-edit marks, promoted skills, a task list. A resumed
|
|
490
|
+
# conversation whose transcript says a file was read will still be asked
|
|
491
|
+
# to read it again before editing — which is right, since the file may
|
|
492
|
+
# have changed while the conversation sat on disk.
|
|
493
|
+
#
|
|
494
|
+
# Two repairs happen on the way in, both because a stored conversation can
|
|
495
|
+
# describe something a provider will not accept:
|
|
496
|
+
#
|
|
497
|
+
# - A tool call whose result never arrived gets a synthesized one
|
|
498
|
+
# ({History#repair_interrupted_tool_calls}).
|
|
499
|
+
# - A turn produced by a *different* model loses its whole thinking block
|
|
500
|
+
# — a foreign one lands in a slot the receiving model reads as its own
|
|
501
|
+
# scratchpad. Withheld from the conversation only; {#export_history}
|
|
502
|
+
# still writes it. {#apply_transport!} repeats this for a switch that
|
|
503
|
+
# happens later. See +DECISIONS.md+ +D_thinking_block_replay+.
|
|
504
|
+
#
|
|
505
|
+
# @param history [History] from {#export_history}, {History.parse} or
|
|
506
|
+
# {History.load} — already validated by whichever door it came through.
|
|
507
|
+
# @return [Integer] how many messages were restored.
|
|
508
|
+
# @raise [ArgumentError] if given anything but a {History}; the parsing
|
|
509
|
+
# doors are where untrusted input belongs.
|
|
510
|
+
def load_history!(history)
|
|
511
|
+
unless history.is_a?(History)
|
|
512
|
+
raise ArgumentError, "expected a Pikuri::Agent::History, got #{history.class} — " \
|
|
513
|
+
'use History.parse / History.load to build one from stored bytes'
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
clear_conversation
|
|
517
|
+
current_model = @chat.model&.id
|
|
518
|
+
repaired = history.repair_interrupted_tool_calls
|
|
519
|
+
repaired.messages.each do |m|
|
|
520
|
+
keep = keep_thinking?(m.thinking, m.model_id, current_model)
|
|
521
|
+
track_message(@chat.add_message(m.to_ruby_llm(keep_thinking: keep)),
|
|
522
|
+
kind: m.kind, id: m.id, thinking: (m.thinking unless keep))
|
|
523
|
+
end
|
|
524
|
+
|
|
525
|
+
last = repaired.messages.reverse.find { |m| m.role == 'assistant' && m.tokens }
|
|
526
|
+
@listeners.emit(Event::HistoryLoaded.new(
|
|
527
|
+
messages: repaired.messages.size,
|
|
528
|
+
tokens: last && Event::Tokens.new(**last.tokens.to_h, model_id: last.model_id)
|
|
529
|
+
))
|
|
530
|
+
repaired.messages.size
|
|
531
|
+
end
|
|
532
|
+
|
|
533
|
+
# Release agent-owned resources: fire every handler registered via
|
|
534
|
+
# {Configurator#on_close} and {ExtensionContext#on_close} in LIFO order,
|
|
535
|
+
# so a handler that depends on an earlier one tears down first. Each runs
|
|
536
|
+
# in its own +rescue+ (an exception is logged, not propagated).
|
|
537
|
+
# Idempotent.
|
|
409
538
|
#
|
|
410
539
|
# @return [void]
|
|
411
540
|
def close
|
|
412
541
|
return if @closed
|
|
413
542
|
|
|
414
543
|
@closed = true
|
|
415
|
-
# Drop out of the
|
|
416
|
-
#
|
|
417
|
-
# and removing the reference lets it be garbage-collected.
|
|
544
|
+
# Drop out of the registry first: a deliberate close no longer needs
|
|
545
|
+
# the at-exit fallback, and dropping the reference frees it for GC.
|
|
418
546
|
Pikuri::Finalizers.unregister(self)
|
|
419
547
|
@on_close_handlers.reverse_each do |handler|
|
|
420
548
|
handler.call
|
|
@@ -435,17 +563,36 @@ module Pikuri
|
|
|
435
563
|
"Agent(id=#{@id}, model=#{model}, tools=#{@tools.size}, listeners=#{@listeners})"
|
|
436
564
|
end
|
|
437
565
|
|
|
566
|
+
# This agent's lethal-trifecta picture: which legs it holds, which its
|
|
567
|
+
# sub-agents hold, the verdict at every node.
|
|
568
|
+
#
|
|
569
|
+
# puts agent.trifecta.render # the map a bin script prints
|
|
570
|
+
# agent.trifecta.tree_verdict # => :soft
|
|
571
|
+
#
|
|
572
|
+
# Memoized and pure, so a UI may call it as often as it likes. Nothing
|
|
573
|
+
# reports this for you: an Agent computes the picture and stops, because a
|
|
574
|
+
# library writing to a log its embedder never asked for is noise. Surfacing
|
|
575
|
+
# it is the host's job — the bundled +bin/pikuri-*+ scripts print
|
|
576
|
+
# {Trifecta::Report#render} at boot.
|
|
577
|
+
#
|
|
578
|
+
# Valid only after construction (it reads the wired tool list), and it
|
|
579
|
+
# reflects the tools present *then*.
|
|
580
|
+
#
|
|
581
|
+
# @return [Trifecta::Report] root of the walked tree
|
|
582
|
+
def trifecta
|
|
583
|
+
@trifecta ||= Trifecta.walk(Trifecta.build(self))
|
|
584
|
+
end
|
|
585
|
+
|
|
438
586
|
private
|
|
439
587
|
|
|
440
588
|
# The failure-prone build phase, split out of {#initialize} so the
|
|
441
589
|
# constructor can wrap it in a rescue and self-heal. Funnels the
|
|
442
|
-
# +Agent.new+ block through
|
|
443
|
-
#
|
|
444
|
-
#
|
|
445
|
-
#
|
|
446
|
-
#
|
|
447
|
-
#
|
|
448
|
-
# constructor's rescue close a half-built agent.
|
|
590
|
+
# +Agent.new+ block through one {Configurator} (tools, listeners,
|
|
591
|
+
# snippets, extensions, +on_close+), wires the chat, then runs the
|
|
592
|
+
# extension +bind+ sweep. The Configurator's +on_close_sink:+ is
|
|
593
|
+
# +@on_close_handlers+ itself, so a handler armed via +c.on_close+ is
|
|
594
|
+
# live the instant it's registered — what lets the rescue close a
|
|
595
|
+
# half-built agent.
|
|
449
596
|
#
|
|
450
597
|
# @return [void]
|
|
451
598
|
def run_configure
|
|
@@ -465,25 +612,34 @@ module Pikuri
|
|
|
465
612
|
@tools = configurator.tools.dup
|
|
466
613
|
@sub_agent_tools = configurator.sub_agent_tools.dup
|
|
467
614
|
@listeners = ListenerList.new(configurator.listeners)
|
|
468
|
-
configurator.system_prompt_additions.each do |snippet|
|
|
469
|
-
@system_prompt = "#{@system_prompt}\n\n#{snippet}"
|
|
470
|
-
end
|
|
471
615
|
@extensions = configurator.extensions.dup
|
|
616
|
+
# Assemble now that every extension's +configure+ has run (their
|
|
617
|
+
# snippets read populated state); re-runs on {#clear_conversation}.
|
|
618
|
+
@system_prompt = assemble_system_prompt
|
|
472
619
|
|
|
473
620
|
@chat = build_chat(@transport)
|
|
474
621
|
@chat.with_instructions(@system_prompt)
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
#
|
|
481
|
-
#
|
|
482
|
-
#
|
|
483
|
-
#
|
|
484
|
-
#
|
|
622
|
+
# One context per agent, so an opting-in +execute+ never has to nil-check
|
|
623
|
+
# what it was handed.
|
|
624
|
+
context = Tool::ExecuteContext.new(cancellable: @cancellable || Control::Cancellable::NEVER)
|
|
625
|
+
@tools.each { |t| @chat.with_tool(t.to_ruby_llm_tool(context: context)) }
|
|
626
|
+
|
|
627
|
+
# Wire @chat for the event stream and controls via ruby_llm's three
|
|
628
|
+
# registered callbacks. (The per-chunk streaming callback is separate
|
|
629
|
+
# — {#streaming_block} — since ruby_llm takes it as a block to
|
|
630
|
+
# +Chat#complete+, not a registered hook.) With the +@listeners.emit+
|
|
631
|
+
# calls in {#run_loop}/{#dispatch_ext_on_user_message}, this is the
|
|
632
|
+
# complete callback→event map.
|
|
633
|
+
# Identity-keyed, so nothing can drift out of alignment with
|
|
634
|
+
# +@chat.messages+ the way a parallel array would: a message pikuri
|
|
635
|
+
# never saw is simply absent here, and {#export_history} mints it an id
|
|
636
|
+
# rather than silently pairing it with someone else's.
|
|
637
|
+
@message_meta = {}.compare_by_identity
|
|
638
|
+
|
|
485
639
|
@chat.after_message do |msg|
|
|
640
|
+
track_message(msg)
|
|
486
641
|
emit_after_message(msg)
|
|
642
|
+
drain_interloper if @interloper && tool_batch_complete?(msg)
|
|
487
643
|
end
|
|
488
644
|
@chat.before_tool_call do |tc|
|
|
489
645
|
@listeners.emit(Event::ToolCall.new(name: tc.name, arguments: tc.arguments))
|
|
@@ -492,54 +648,61 @@ module Pikuri
|
|
|
492
648
|
end
|
|
493
649
|
@chat.after_tool_result do |result|
|
|
494
650
|
@listeners.emit(Event::ToolResult.new(content: result))
|
|
495
|
-
drain_interloper if @interloper
|
|
496
651
|
end
|
|
497
652
|
|
|
498
|
-
# Context-window cap
|
|
499
|
-
#
|
|
500
|
-
# event arrives. Re-fires on each model switch (see
|
|
501
|
-
# {#apply_transport!}).
|
|
653
|
+
# Context-window cap on the stream before any Tokens event; re-fires
|
|
654
|
+
# on each model switch (see {#apply_transport!}).
|
|
502
655
|
detect_and_emit_context_cap!
|
|
503
656
|
|
|
504
|
-
# The runtime capability facade —
|
|
505
|
-
#
|
|
506
|
-
# extension's #bind /
|
|
507
|
-
#
|
|
508
|
-
# capabilities; the Agent itself exposes no public path to
|
|
509
|
-
# them. See {ExtensionContext}.
|
|
657
|
+
# The runtime capability facade — the ONLY object granting
|
|
658
|
+
# emission / raw-tool / close-handler capabilities; the Agent exposes
|
|
659
|
+
# no public path to them. Handed to every extension's #bind /
|
|
660
|
+
# #on_user_message. See {ExtensionContext}.
|
|
510
661
|
@extension_context = ExtensionContext.new(
|
|
511
662
|
agent: self, chat: @chat, listeners: @listeners,
|
|
512
663
|
on_close_sink: @on_close_handlers
|
|
513
664
|
)
|
|
514
665
|
|
|
515
|
-
# Bind sweep — each extension
|
|
516
|
-
#
|
|
517
|
-
# {
|
|
518
|
-
# {ExtensionContext#on_close}, domain-event wiring via
|
|
519
|
-
# {ExtensionContext#emit_event}, etc.) now that the chat is
|
|
520
|
-
# fully wired. See IDEAS.md §"Extension protocol design" for
|
|
521
|
-
# what #configure vs #bind are each for.
|
|
666
|
+
# Bind sweep — each extension installs per-agent state now the chat is
|
|
667
|
+
# fully wired (dynamic tools, close hooks, domain-event wiring). See
|
|
668
|
+
# {Extension} for #configure vs #bind.
|
|
522
669
|
@extensions.each { |ext| ext.bind(@extension_context) }
|
|
523
670
|
end
|
|
524
671
|
|
|
525
|
-
#
|
|
526
|
-
#
|
|
527
|
-
#
|
|
528
|
-
#
|
|
529
|
-
#
|
|
530
|
-
#
|
|
531
|
-
#
|
|
532
|
-
#
|
|
533
|
-
# one
|
|
672
|
+
# Build the full prompt: base plus every extension's
|
|
673
|
+
# {Extension#system_prompt_snippets}, in registration order, joined by
|
|
674
|
+
# blank lines. Blank/nil sections drop (an extension contributing
|
|
675
|
+
# nothing leaves no stray separator). Pure function of the base + the
|
|
676
|
+
# extensions' current state, so it serves both construction and
|
|
677
|
+
# {#clear_conversation} (the latter refreshing dynamic sections).
|
|
678
|
+
#
|
|
679
|
+
# Those two call sites are the whole list, and that is a *price* rather
|
|
680
|
+
# than a missing feature: a stable prefix is free after turn one
|
|
681
|
+
# (llama.cpp slot prefix-reuse, Anthropic's prompt cache), where amending
|
|
682
|
+
# one invalidates every cached token after the change point and
|
|
683
|
+
# re-prefills. Hence there is no amend-the-prompt seam — mid-conversation
|
|
684
|
+
# blocks go through {#append_reference_block}, which appends and
|
|
685
|
+
# invalidates nothing. See `DECISIONS.md` `D_reference_text_as_user_turn`.
|
|
686
|
+
#
|
|
687
|
+
# @return [String]
|
|
688
|
+
def assemble_system_prompt
|
|
689
|
+
sections = @extensions.flat_map(&:system_prompt_snippets)
|
|
690
|
+
[@system_prompt_base, *sections]
|
|
691
|
+
.reject { |s| s.nil? || s.to_s.strip.empty? }
|
|
692
|
+
.join("\n\n")
|
|
693
|
+
end
|
|
694
|
+
|
|
695
|
+
# Resolve the context-window cap and announce it as an
|
|
696
|
+
# {Event::ContextCap}. The transport's explicit +context_window+ wins;
|
|
697
|
+
# else {ContextWindowDetector} probes the server (+nil+ for a non-llama
|
|
698
|
+
# one). The cap rides {ChatTransport}, so a switch resolves the *new*
|
|
699
|
+
# transport's cap. Shared by {#run_configure} and {#apply_transport!}.
|
|
534
700
|
#
|
|
535
701
|
# @return [void]
|
|
536
702
|
def detect_and_emit_context_cap!
|
|
537
|
-
# Probe the server this transport
|
|
538
|
-
#
|
|
539
|
-
#
|
|
540
|
-
# detector defaults to still names the *old* server, so derive the
|
|
541
|
-
# base from the transport (falling back to the global base for a
|
|
542
|
-
# transport that rides it).
|
|
703
|
+
# Probe the server this transport targets: after a cross-server switch
|
|
704
|
+
# the chat points at +@transport.api_base+, but the global config still
|
|
705
|
+
# names the old server — so derive the base from the transport.
|
|
543
706
|
@context_window_cap = @transport.context_window ||
|
|
544
707
|
ContextWindowDetector.detect(
|
|
545
708
|
@transport,
|
|
@@ -548,34 +711,27 @@ module Pikuri
|
|
|
548
711
|
@listeners.emit(Event::ContextCap.new(cap: @context_window_cap))
|
|
549
712
|
end
|
|
550
713
|
|
|
551
|
-
# Switch the
|
|
552
|
-
#
|
|
553
|
-
#
|
|
554
|
-
#
|
|
555
|
-
#
|
|
556
|
-
#
|
|
557
|
-
#
|
|
558
|
-
#
|
|
559
|
-
#
|
|
560
|
-
#
|
|
561
|
-
#
|
|
562
|
-
# model. Emits {Event::ModelSwitched} (the narration: old → new
|
|
563
|
-
# transport, unformatted, for the chrome to present) then re-resolves
|
|
564
|
-
# and re-emits the cap via {#detect_and_emit_context_cap!}.
|
|
714
|
+
# Switch the chat to +transport+ when it differs from the current one.
|
|
715
|
+
# Called only from the top of {#run_loop} (see its "Switching models"
|
|
716
|
+
# section for the thread-confinement reason). Mirrors {#initialize}'s
|
|
717
|
+
# +nil+-model fill before the structural compare, so a transport
|
|
718
|
+
# deferring to the default model doesn't switch spuriously.
|
|
719
|
+
# +Chat#with_model+ keeps history and callbacks. Emits
|
|
720
|
+
# {Event::ModelSwitched}, then re-resolves and re-emits the cap.
|
|
721
|
+
#
|
|
722
|
+
# Because the history is kept, every turn in it is now potentially from
|
|
723
|
+
# another model, so {#strip_foreign_thinking!} runs before the event —
|
|
724
|
+
# {#load_history!}'s repair only covers a conversation that arrives stale.
|
|
565
725
|
#
|
|
566
726
|
# == Cross-server switches
|
|
567
727
|
#
|
|
568
|
-
# +with_model+
|
|
569
|
-
#
|
|
570
|
-
#
|
|
571
|
-
#
|
|
572
|
-
#
|
|
573
|
-
#
|
|
574
|
-
#
|
|
575
|
-
# +with_model+ lands the new model id. The "old overrode" half of
|
|
576
|
-
# the guard handles switching *back* to a global-config model: the
|
|
577
|
-
# rebuilt context dups the process-global config, resetting the
|
|
578
|
-
# connection the previous override installed.
|
|
728
|
+
# +with_model+ re-resolves against the chat's existing connection, so it
|
|
729
|
+
# only moves between models on one server. When either transport
|
|
730
|
+
# overrides the connection (+ChatTransport#connection_overrides?+), a
|
|
731
|
+
# fresh +RubyLLM::Context+ is installed via +Chat#with_context+ first
|
|
732
|
+
# (re-pointing the connection, preserving history + callbacks). The "old
|
|
733
|
+
# overrode" half handles switching *back* to a global-config model: the
|
|
734
|
+
# rebuilt context dups the global config, resetting the prior override.
|
|
579
735
|
#
|
|
580
736
|
# @param transport [ChatTransport] the model to switch to
|
|
581
737
|
# @return [void]
|
|
@@ -587,14 +743,14 @@ module Pikuri
|
|
|
587
743
|
@chat.with_context(build_context(filled)) if filled.connection_overrides? || old.connection_overrides?
|
|
588
744
|
@chat.with_model(filled.model, provider: filled.provider, assume_exists: filled.assume_model_exists)
|
|
589
745
|
@transport = filled
|
|
746
|
+
strip_foreign_thinking!
|
|
590
747
|
@listeners.emit(Event::ModelSwitched.new(from: old, to: filled))
|
|
591
748
|
detect_and_emit_context_cap!
|
|
592
749
|
end
|
|
593
750
|
|
|
594
|
-
# Build the chat for +transport+: through a dedicated
|
|
595
|
-
#
|
|
596
|
-
#
|
|
597
|
-
# path has always used).
|
|
751
|
+
# Build the chat for +transport+: through a dedicated +RubyLLM::Context+
|
|
752
|
+
# when it overrides the connection, else through the process-global
|
|
753
|
+
# +RubyLLM.chat+.
|
|
598
754
|
#
|
|
599
755
|
# @param transport [ChatTransport]
|
|
600
756
|
# @return [RubyLLM::Chat]
|
|
@@ -606,14 +762,18 @@ module Pikuri
|
|
|
606
762
|
end
|
|
607
763
|
end
|
|
608
764
|
|
|
609
|
-
# A +RubyLLM::Context+ carrying +transport+'s connection overrides
|
|
610
|
-
#
|
|
611
|
-
#
|
|
612
|
-
#
|
|
613
|
-
#
|
|
614
|
-
#
|
|
615
|
-
#
|
|
616
|
-
#
|
|
765
|
+
# A +RubyLLM::Context+ carrying +transport+'s connection overrides on the
|
|
766
|
+
# provider's config slots (+#{provider}_api_base+ / +_api_key+).
|
|
767
|
+
# +RubyLLM.context+ dups the global config, so an absent override inherits
|
|
768
|
+
# the global value and a no-override transport yields a plain dup (used by
|
|
769
|
+
# {#apply_transport!} to reset a prior override). +ChatTransport+
|
|
770
|
+
# guarantees a non-+nil+ +provider+ whenever an override is set.
|
|
771
|
+
#
|
|
772
|
+
# A transport's +faraday_adapter+ (if any) is installed here too: the
|
|
773
|
+
# adapter rides *this* context's config, not the process-global
|
|
774
|
+
# +RubyLLM.config+, so it stays per-agent — the seam
|
|
775
|
+
# {Pikuri::Testing.fake_transport} uses to run the real ruby_llm loop
|
|
776
|
+
# against a scripted HTTP backend.
|
|
617
777
|
#
|
|
618
778
|
# @param transport [ChatTransport]
|
|
619
779
|
# @return [RubyLLM::Context]
|
|
@@ -622,33 +782,24 @@ module Pikuri
|
|
|
622
782
|
RubyLLM.context do |c|
|
|
623
783
|
c.public_send("#{slug}_api_base=", transport.api_base) unless transport.api_base.nil?
|
|
624
784
|
c.public_send("#{slug}_api_key=", transport.api_key) unless transport.api_key.nil?
|
|
785
|
+
c.faraday_adapter = transport.faraday_adapter if transport.faraday_adapter
|
|
786
|
+
# ruby_llm defaults an OpenAI-family `:system` message to the newer
|
|
787
|
+
# `developer` role. OpenAI treats the two alike, but a local
|
|
788
|
+
# llama.cpp hands the role straight to the model's chat template,
|
|
789
|
+
# and a Qwen/Llama template has never seen `developer` — the system
|
|
790
|
+
# prompt then arrives under a role token the model was not trained
|
|
791
|
+
# on. pikuri targets local servers first, so ask for `system`.
|
|
792
|
+
c.openai_use_system_role = true if c.respond_to?(:openai_use_system_role=)
|
|
625
793
|
end
|
|
626
794
|
end
|
|
627
795
|
|
|
628
|
-
# Fire
|
|
629
|
-
#
|
|
630
|
-
#
|
|
631
|
-
#
|
|
632
|
-
#
|
|
633
|
-
#
|
|
634
|
-
#
|
|
635
|
-
# See {Extension#on_user_message}.
|
|
636
|
-
#
|
|
637
|
-
# Each injected block also emits an {Event::SystemInjected} at
|
|
638
|
-
# this site, so the listener stream mirrors the log growth (the
|
|
639
|
-
# Terminal renders it; otherwise an injection would be invisible
|
|
640
|
-
# except as a downstream echo in the assistant's reasoning).
|
|
641
|
-
#
|
|
642
|
-
# Private and the single place the chat log grows by a memory
|
|
643
|
-
# block — keeps "what mutates the log, when" one grep in this
|
|
644
|
-
# file. Fired from {#run_loop} (initial turn) and from
|
|
645
|
-
# {#drain_interloper} (mid-loop interlopers). Called on every
|
|
646
|
-
# extension unconditionally — same as {Extension#configure} /
|
|
647
|
-
# {Extension#bind}: the hook is part of the protocol and the
|
|
648
|
-
# {Extension} module supplies a no-op default, so any extension
|
|
649
|
-
# that includes the module responds. An extension is "opted out"
|
|
650
|
-
# by leaving the default in place (it returns +nil+, injecting
|
|
651
|
-
# nothing), not by omitting the method.
|
|
796
|
+
# Fire {Extension#on_user_message} on every extension, appending any
|
|
797
|
+
# returned text via {#append_reference_block} right after the user turn it
|
|
798
|
+
# annotates (the caller appends the +:user+ message first).
|
|
799
|
+
#
|
|
800
|
+
# Called unconditionally — the {Extension} no-op default returns +nil+
|
|
801
|
+
# (injects nothing), so opting out means leaving the default, not omitting
|
|
802
|
+
# the method.
|
|
652
803
|
#
|
|
653
804
|
# @param content [String] the incoming user message
|
|
654
805
|
# @return [void]
|
|
@@ -657,40 +808,209 @@ module Pikuri
|
|
|
657
808
|
message = ext.on_user_message(@extension_context, content)
|
|
658
809
|
next unless message.is_a?(String) && !message.strip.empty?
|
|
659
810
|
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
811
|
+
append_reference_block(message.strip)
|
|
812
|
+
end
|
|
813
|
+
nil
|
|
814
|
+
end
|
|
815
|
+
|
|
816
|
+
# Append host-originated reference text — a memory block, a loaded skill, a
|
|
817
|
+
# path-activation promotion, retrieved context — and emit
|
|
818
|
+
# {Event::SystemInjected}. Lands as a +role: :user+ message wrapped in
|
|
819
|
+
# +<system-reminder>+:
|
|
820
|
+
#
|
|
821
|
+
# append_reference_block('<memory-context>…</memory-context>')
|
|
822
|
+
# # the chat log grows by one :user message:
|
|
823
|
+
# # <system-reminder>
|
|
824
|
+
# # <memory-context>…</memory-context>
|
|
825
|
+
# # </system-reminder>
|
|
826
|
+
#
|
|
827
|
+
# The event carries the block *unwrapped* — the envelope is wire plumbing,
|
|
828
|
+
# and a listener rendering the tag would be showing the reader framing they
|
|
829
|
+
# did not write.
|
|
830
|
+
#
|
|
831
|
+
# == Implementation details
|
|
832
|
+
#
|
|
833
|
+
# +:user+ rather than +:system+ because a mid-conversation +:system+ message
|
|
834
|
+
# is not portable: Qwen3.6's chat template silently drops every one past the
|
|
835
|
+
# leading header, and providers carrying +:system+ in a separate field
|
|
836
|
+
# (Anthropic, Bedrock — see {SEPARATE_SYSTEM_FIELD_PROVIDERS}) merge it into
|
|
837
|
+
# the standing system prompt, so the block is presented as though it had
|
|
838
|
+
# always been there and stays in force. Neither failure is visible from
|
|
839
|
+
# anything pikuri can observe: the message is in the payload, the listener
|
|
840
|
+
# stream renders it, the history holds it. See +DECISIONS.md+
|
|
841
|
+
# +D_reference_text_as_user_turn+.
|
|
842
|
+
#
|
|
843
|
+
# A closing tag inside +content+ is escaped, so injected text (a file, a web
|
|
844
|
+
# page, an MCP tool's description) cannot close the envelope early and have
|
|
845
|
+
# what follows read as conversation.
|
|
846
|
+
#
|
|
847
|
+
# @param content [String] the block to inject, already stripped; callers
|
|
848
|
+
# with their own semantic tag (+<memory-context>+) keep it — the envelope
|
|
849
|
+
# nests around it
|
|
850
|
+
# @return [void]
|
|
851
|
+
def append_reference_block(content)
|
|
852
|
+
close = "</#{REFERENCE_TAG}>"
|
|
853
|
+
# Block form, not a replacement String: gsub reads `\&`, `\1` etc. out of
|
|
854
|
+
# the latter, so a tag carrying one would be mangled rather than escaped.
|
|
855
|
+
body = content.gsub(close) { "<\\/#{REFERENCE_TAG}>" }
|
|
856
|
+
# Tagged +reference+ so a restored conversation can tell pikuri's own
|
|
857
|
+
# injection from something the human typed — both land as +:user+.
|
|
858
|
+
track_message(@chat.add_message(role: :user, content: "<#{REFERENCE_TAG}>\n#{body}\n#{close}"),
|
|
859
|
+
kind: 'reference')
|
|
860
|
+
@listeners.emit(Event::SystemInjected.new(content: content))
|
|
861
|
+
nil
|
|
862
|
+
end
|
|
863
|
+
|
|
864
|
+
# Remember pikuri's own identity for a message just added to the chat, so
|
|
865
|
+
# {#export_history} can hand out a stable id, say whether a +:user+ message
|
|
866
|
+
# was typed or injected, and put back a thinking block the conversation is
|
|
867
|
+
# not replaying. None of it ever reaches a provider.
|
|
868
|
+
#
|
|
869
|
+
# Keyed on the message *object*, so a caller that rebuilds one must move
|
|
870
|
+
# its entry across — see {#strip_foreign_thinking!}.
|
|
871
|
+
#
|
|
872
|
+
# @param msg [RubyLLM::Message] as returned by +Chat#add_message+.
|
|
873
|
+
# @param kind [String] one of {History::KINDS}.
|
|
874
|
+
# @param id [String, nil] an existing id to preserve (restoring a saved
|
|
875
|
+
# conversation); +nil+ mints a fresh one.
|
|
876
|
+
# @param thinking [History::Thinking, nil] a block withheld from +msg+
|
|
877
|
+
# because another model produced it, kept so the export stays faithful.
|
|
878
|
+
# @return [RubyLLM::Message] +msg+, so call sites can wrap in place.
|
|
879
|
+
def track_message(msg, kind: 'user', id: nil, thinking: nil)
|
|
880
|
+
unless msg.role == :system
|
|
881
|
+
@message_meta[msg] = { id: id || History.mint_id, kind: kind, thinking: thinking }
|
|
882
|
+
end
|
|
883
|
+
msg
|
|
884
|
+
end
|
|
885
|
+
|
|
886
|
+
# Whether a turn's thinking may be replayed to +current+ — true only where
|
|
887
|
+
# this model demonstrably produced it.
|
|
888
|
+
#
|
|
889
|
+
# Unknown provenance counts as foreign: a +model_id+ nobody recorded is
|
|
890
|
+
# indistinguishable from another model's, and attributing one model's
|
|
891
|
+
# reasoning to another is the failure this guards against.
|
|
892
|
+
#
|
|
893
|
+
# The two ids come from different places — +current+ is what pikuri
|
|
894
|
+
# *requested*, +model_id+ what the server *reported* — so a backend that
|
|
895
|
+
# does not echo the requested id back makes every turn look foreign. That
|
|
896
|
+
# only ever over-drops (a model loses its own reasoning; none is ever
|
|
897
|
+
# attributed to another), which is why the weaker test is tolerated; the
|
|
898
|
+
# fakes in +Pikuri::Testing+ are themselves such a backend. Measured
|
|
899
|
+
# echoing verbatim on llama.cpp — see +DECISIONS.md+
|
|
900
|
+
# +D_thinking_block_replay+.
|
|
901
|
+
#
|
|
902
|
+
# @param thinking [History::Thinking, RubyLLM::Thinking, nil]
|
|
903
|
+
# @param model_id [String, nil] who produced the turn.
|
|
904
|
+
# @param current [String, nil] the agent's model now.
|
|
905
|
+
# @return [Boolean]
|
|
906
|
+
def keep_thinking?(thinking, model_id, current)
|
|
907
|
+
thinking.nil? || (!model_id.nil? && model_id == current)
|
|
908
|
+
end
|
|
909
|
+
|
|
910
|
+
# Withhold every thinking block the current model did not produce from the
|
|
911
|
+
# live conversation, parking it in +@message_meta+ so {#export_history}
|
|
912
|
+
# still writes it.
|
|
913
|
+
#
|
|
914
|
+
# {#load_history!} covers a conversation that arrives stale; this covers
|
|
915
|
+
# one that goes stale under a running agent, which +Chat#with_model+ leaves
|
|
916
|
+
# untouched. +RubyLLM::Message+ has no thinking writer, so a stripped turn
|
|
917
|
+
# is a rebuilt object and its metadata is re-keyed onto the replacement.
|
|
918
|
+
#
|
|
919
|
+
# @return [void]
|
|
920
|
+
def strip_foreign_thinking!
|
|
921
|
+
current = @chat.model&.id
|
|
922
|
+
@chat.messages.map! do |msg|
|
|
923
|
+
next msg if keep_thinking?(msg.thinking, msg.model_id, current)
|
|
924
|
+
|
|
925
|
+
meta = @message_meta.delete(msg) || { id: History.mint_id, kind: 'user' }
|
|
926
|
+
stripped = RubyLLM::Message.new(
|
|
927
|
+
role: msg.role, content: msg.content, tool_calls: msg.tool_calls,
|
|
928
|
+
tool_call_id: msg.tool_call_id, model_id: msg.model_id,
|
|
929
|
+
tokens: msg.tokens, raw: msg.raw, thinking: nil
|
|
930
|
+
)
|
|
931
|
+
@message_meta[stripped] = meta.merge(
|
|
932
|
+
thinking: History::Thinking.new(text: msg.thinking.text, signature: msg.thinking.signature)
|
|
933
|
+
)
|
|
934
|
+
stripped
|
|
935
|
+
end
|
|
936
|
+
end
|
|
937
|
+
|
|
938
|
+
# Answer every call the last assistant turn left unanswered, emitting each
|
|
939
|
+
# synthesized observation as an {Event::ToolResult} so a host can show what
|
|
940
|
+
# it appended. Rationale in {#run_loop}'s +== Repairing an unwound tool
|
|
941
|
+
# batch+.
|
|
942
|
+
#
|
|
943
|
+
# Order carries the first-vs-rest split ({CANCELLED_RESULT}): +tool_calls+
|
|
944
|
+
# is insertion-ordered and ruby_llm runs it with +each_value+, so the first
|
|
945
|
+
# gap is the culprit — true only while +tool_concurrency+ stays off, which
|
|
946
|
+
# {Pikuri::Tool}'s +== Sharing+ asks for on separate grounds.
|
|
947
|
+
#
|
|
948
|
+
# Not {History#repair_interrupted_tool_calls}: damage here is always the
|
|
949
|
+
# tail, and that walk is over +History::Message+, whose +role+ is a +String+
|
|
950
|
+
# where ruby_llm's is a +Symbol+.
|
|
951
|
+
#
|
|
952
|
+
# @param cause [Exception, nil] what the turn is unwinding with; +nil+ on
|
|
953
|
+
# the success path, where nothing dangles.
|
|
954
|
+
# @return [void]
|
|
955
|
+
# @raise [RuntimeError] if calls dangle with no +cause+ — pikuri lost a
|
|
956
|
+
# batch on a turn that succeeded. Safe to raise from an +ensure+ here
|
|
957
|
+
# precisely because there is no in-flight exception to clobber.
|
|
958
|
+
def repair_dangling_tool_calls!(cause)
|
|
959
|
+
messages = @chat.messages
|
|
960
|
+
opener_index = messages.rindex { |m| m.role == :assistant && m.tool_calls&.any? }
|
|
961
|
+
return if opener_index.nil?
|
|
962
|
+
|
|
963
|
+
answered = messages[(opener_index + 1)..].filter_map { |m| m.tool_call_id if m.role == :tool }
|
|
964
|
+
dangling = messages[opener_index].tool_calls.each_value.reject { |c| answered.include?(c.id) }
|
|
965
|
+
return if dangling.empty?
|
|
966
|
+
|
|
967
|
+
if cause.nil?
|
|
968
|
+
raise 'unanswered tool calls on a turn that did not unwind: ' \
|
|
969
|
+
"#{dangling.map { |c| "#{c.name}(#{c.id})" }.join(', ')}"
|
|
970
|
+
end
|
|
971
|
+
|
|
972
|
+
culprit, skipped = unanswered_results(cause)
|
|
973
|
+
dangling.each_with_index do |call, i|
|
|
974
|
+
content = i.zero? ? culprit : skipped
|
|
975
|
+
track_message(@chat.add_message(role: :tool, content: content, tool_call_id: call.id))
|
|
976
|
+
@listeners.emit(Event::ToolResult.new(content: content))
|
|
663
977
|
end
|
|
978
|
+
LOGGER.warn { "answered #{dangling.size} dangling tool call(s) after #{cause.class}" }
|
|
664
979
|
nil
|
|
665
980
|
end
|
|
666
981
|
|
|
667
|
-
#
|
|
668
|
-
#
|
|
669
|
-
#
|
|
670
|
-
#
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
982
|
+
# @param cause [Exception]
|
|
983
|
+
# @return [Array(String, String)] what the call that raised (or was about
|
|
984
|
+
# to) is told, then what the calls behind it that never started are told.
|
|
985
|
+
# See {CANCELLED_RESULT}.
|
|
986
|
+
def unanswered_results(cause)
|
|
987
|
+
case cause
|
|
988
|
+
when Control::Cancellable::Cancelled then [CANCELLED_RESULT, CANCELLED_SKIPPED_RESULT]
|
|
989
|
+
# An exhausted budget singles no call out.
|
|
990
|
+
when Control::StepLimit::Exceeded then [STEP_BUDGET_RESULT, STEP_BUDGET_RESULT]
|
|
991
|
+
# Deliberately unsanitized: the message can carry untrusted bytes (a
|
|
992
|
+
# filename off a scraped page), which {Tool#run}'s own +"Error: ..."+
|
|
993
|
+
# already puts in the conversation — and a model cannot react to a
|
|
994
|
+
# failure it is not told about.
|
|
995
|
+
else ["Error: #{cause.class}: #{cause.message}", FAILED_SKIPPED_RESULT]
|
|
996
|
+
end
|
|
997
|
+
end
|
|
998
|
+
|
|
999
|
+
# Build the per-chunk block passed to +Chat#complete+. Each call converts
|
|
1000
|
+
# one +RubyLLM::Chunk+ into zero, one, or two delta events
|
|
1001
|
+
# ({Event::ThinkingDelta}/{Event::AssistantDelta}). Tool-call chunks are
|
|
1002
|
+
# ignored — partial JSON has no useful rendering; the assembled call
|
|
1003
|
+
# surfaces via {Event::ToolCall} once the message completes.
|
|
675
1004
|
#
|
|
676
1005
|
# == Cancellation polling
|
|
677
1006
|
#
|
|
678
|
-
# When +@cancellable+ is
|
|
679
|
-
#
|
|
680
|
-
#
|
|
681
|
-
#
|
|
682
|
-
#
|
|
683
|
-
#
|
|
684
|
-
#
|
|
685
|
-
# next chunk delivered after the flag flips, the exception
|
|
686
|
-
# propagates out through ruby_llm's streaming path
|
|
687
|
-
# (+Chat#complete+ doesn't rescue), and {#run_loop} catches it,
|
|
688
|
-
# emits {Event::Cancelled}, and re-raises. The pre-emit
|
|
689
|
-
# ordering is deliberate: a chunk that arrives after a cancel
|
|
690
|
-
# request shouldn't render — the user has said stop.
|
|
691
|
-
#
|
|
692
|
-
# @return [Proc] a +-> (chunk) { ... }+ proc suitable for
|
|
693
|
-
# passing to +Chat#complete+ with +&+
|
|
1007
|
+
# When +@cancellable+ is set, {Control::Cancellable#check!} fires *before*
|
|
1008
|
+
# each chunk's emit. The +before_tool_call+ wiring only fires on a tool
|
|
1009
|
+
# request, so a no-tool turn (a plain greeting) has zero cancellation
|
|
1010
|
+
# points; polling per chunk closes that gap. Pre-emit ordering is
|
|
1011
|
+
# deliberate — a chunk arriving after a cancel request shouldn't render.
|
|
1012
|
+
#
|
|
1013
|
+
# @return [Proc] a +->(chunk) { ... }+ for +Chat#complete+
|
|
694
1014
|
def streaming_block
|
|
695
1015
|
->(chunk) {
|
|
696
1016
|
@cancellable&.check!
|
|
@@ -698,21 +1018,39 @@ module Pikuri
|
|
|
698
1018
|
}
|
|
699
1019
|
end
|
|
700
1020
|
|
|
701
|
-
# Normalize
|
|
702
|
-
#
|
|
703
|
-
#
|
|
704
|
-
#
|
|
705
|
-
# listeners never see vacuous events. Non-assistant roles
|
|
706
|
-
# (e.g. tool-role messages echoed back through
|
|
707
|
-
# +after_message+) are skipped entirely.
|
|
708
|
-
#
|
|
709
|
-
# +msg+ is a +RubyLLM::Message+. Beyond +role+, +content+,
|
|
710
|
-
# +thinking+, and the +*_tokens+ accessors used here, it also
|
|
711
|
-
# carries +msg.tool_calls+ on assistant turns that requested
|
|
712
|
-
# one and +msg.raw+ for the unparsed provider payload.
|
|
1021
|
+
# Normalize an +after_message+ payload into zero, one, or two {Event}s
|
|
1022
|
+
# (+Thinking+ and/or +Assistant+) plus one {Event::Tokens}. Empty
|
|
1023
|
+
# thinking/content are filtered here so listeners never see vacuous
|
|
1024
|
+
# events; non-assistant roles are skipped.
|
|
713
1025
|
#
|
|
714
1026
|
# @param msg [RubyLLM::Message]
|
|
715
1027
|
# @return [void]
|
|
1028
|
+
# Whether +msg+ closed a tool batch — the only mid-turn moment an injected
|
|
1029
|
+
# message may be appended.
|
|
1030
|
+
#
|
|
1031
|
+
# An assistant message carrying +tool_calls+ and the +:tool+ messages
|
|
1032
|
+
# answering it are one indivisible unit on the wire: OpenAI rejects a
|
|
1033
|
+
# +tool+ message that does not follow its call, Anthropic requires the
|
|
1034
|
+
# results to open the very next message, and a local chat template groups
|
|
1035
|
+
# them into one block. Anything appended between the two halves is
|
|
1036
|
+
# therefore either an error or, worse, silently mangled prose — which is
|
|
1037
|
+
# what draining at ruby_llm's +after_tool_result+ did, since that fires
|
|
1038
|
+
# *before* the result message is added.
|
|
1039
|
+
#
|
|
1040
|
+
# @param msg [RubyLLM::Message]
|
|
1041
|
+
# @return [Boolean] true when +msg+ is the last tool result its assistant
|
|
1042
|
+
# message was waiting for (so parallel calls drain once, at the end)
|
|
1043
|
+
def tool_batch_complete?(msg)
|
|
1044
|
+
return false unless msg.role == :tool
|
|
1045
|
+
|
|
1046
|
+
messages = @chat.messages
|
|
1047
|
+
opener_index = messages.rindex { |m| m.role == :assistant && m.tool_calls&.any? }
|
|
1048
|
+
return true if opener_index.nil?
|
|
1049
|
+
|
|
1050
|
+
answered = messages[(opener_index + 1)..].filter_map { |m| m.tool_call_id if m.role == :tool }
|
|
1051
|
+
messages[opener_index].tool_calls.each_key.all? { |id| answered.include?(id) }
|
|
1052
|
+
end
|
|
1053
|
+
|
|
716
1054
|
def emit_after_message(msg)
|
|
717
1055
|
return unless msg.role == :assistant
|
|
718
1056
|
|
|
@@ -732,16 +1070,9 @@ module Pikuri
|
|
|
732
1070
|
))
|
|
733
1071
|
end
|
|
734
1072
|
|
|
735
|
-
# Normalize a +RubyLLM::Chunk+
|
|
736
|
-
#
|
|
737
|
-
# (
|
|
738
|
-
# +thinking.text+ and empty +content+ are filtered here so
|
|
739
|
-
# listeners never see vacuous fragments. Tool-call deltas are
|
|
740
|
-
# intentionally skipped — see {#streaming_block}.
|
|
741
|
-
#
|
|
742
|
-
# +chunk+ is a +RubyLLM::Chunk+ (subclass of +RubyLLM::Message+),
|
|
743
|
-
# so the same +.thinking+ / +.content+ accessors used in
|
|
744
|
-
# {#emit_after_message} apply.
|
|
1073
|
+
# Normalize a streaming +RubyLLM::Chunk+ into zero, one, or two delta
|
|
1074
|
+
# events ({Event::ThinkingDelta}/{Event::AssistantDelta}). Empty
|
|
1075
|
+
# fragments are filtered; tool-call deltas skipped (see {#streaming_block}).
|
|
745
1076
|
#
|
|
746
1077
|
# @param chunk [RubyLLM::Chunk]
|
|
747
1078
|
# @return [void]
|
|
@@ -753,26 +1084,37 @@ module Pikuri
|
|
|
753
1084
|
@listeners.emit(Event::AssistantDelta.new(content: content)) if content.is_a?(String) && !content.empty?
|
|
754
1085
|
end
|
|
755
1086
|
|
|
756
|
-
# Drain the interloper queue
|
|
757
|
-
#
|
|
758
|
-
#
|
|
759
|
-
#
|
|
760
|
-
#
|
|
761
|
-
#
|
|
762
|
-
#
|
|
1087
|
+
# Drain the interloper queue, landing each item per its
|
|
1088
|
+
# {Control::Interloper::Item#kind}:
|
|
1089
|
+
#
|
|
1090
|
+
# - +:user+ — append +role: :user+ verbatim, emit {Event::UserTurn}
|
|
1091
|
+
# (+mid_loop: true+), then run the {Extension#on_user_message} dispatch
|
|
1092
|
+
# (so mid-loop injections are prefetched + recorded like initial turns).
|
|
1093
|
+
# The dispatch runs *after* the append so an injected +<memory-context>+
|
|
1094
|
+
# lands right behind the turn it annotates.
|
|
1095
|
+
# - +:system+ — {#append_reference_block}: reference text, so no
|
|
1096
|
+
# +on_user_message+ dispatch and no +UserTurn+.
|
|
763
1097
|
#
|
|
764
|
-
#
|
|
765
|
-
#
|
|
766
|
-
#
|
|
767
|
-
#
|
|
768
|
-
# turns.
|
|
1098
|
+
# Both kinds reach the wire as +role: :user+ and differ in dispatch and in
|
|
1099
|
+
# the envelope — only the +:system+ kind is wrapped. That is the whole
|
|
1100
|
+
# distinction, and it is why the queue keeps two kinds rather than a
|
|
1101
|
+
# +wrap:+ flag.
|
|
769
1102
|
#
|
|
770
1103
|
# @return [void]
|
|
1104
|
+
# @raise [RuntimeError] on an unknown item kind (a bug — only the two
|
|
1105
|
+
# +inject_*+ producers exist)
|
|
771
1106
|
def drain_interloper
|
|
772
|
-
@interloper.drain!.each do |
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
1107
|
+
@interloper.drain!.each do |item|
|
|
1108
|
+
case item.kind
|
|
1109
|
+
when :user
|
|
1110
|
+
@chat.add_message(role: :user, content: item.content)
|
|
1111
|
+
@listeners.emit(Event::UserTurn.new(content: item.content, mid_loop: true))
|
|
1112
|
+
dispatch_ext_on_user_message(item.content)
|
|
1113
|
+
when :system
|
|
1114
|
+
append_reference_block(item.content)
|
|
1115
|
+
else
|
|
1116
|
+
raise "unknown interloper item kind: #{item.kind.inspect}"
|
|
1117
|
+
end
|
|
776
1118
|
end
|
|
777
1119
|
end
|
|
778
1120
|
end
|