pikuri-core 0.0.6 → 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 +6 -4
- data/lib/pikuri/agent/chat_transport.rb +128 -24
- data/lib/pikuri/agent/configurator.rb +47 -107
- data/lib/pikuri/agent/context_window_detector.rb +80 -70
- 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 +46 -30
- data/lib/pikuri/agent/control.rb +14 -34
- data/lib/pikuri/agent/event.rb +125 -163
- data/lib/pikuri/agent/extension.rb +121 -83
- data/lib/pikuri/agent/extension_context.rb +120 -0
- 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 +147 -110
- data/lib/pikuri/agent/listener/token_log.rb +116 -108
- data/lib/pikuri/agent/listener.rb +23 -36
- data/lib/pikuri/agent/listener_list.rb +26 -57
- data/lib/pikuri/agent/synthesizer.rb +76 -92
- data/lib/pikuri/agent.rb +939 -642
- 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 +157 -0
- 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 +70 -15
- data/lib/pikuri/tool/scraper.rb +55 -97
- data/lib/pikuri/tool/search/brave.rb +70 -84
- data/lib/pikuri/tool/search/duckduckgo.rb +65 -86
- data/lib/pikuri/tool/search/engines.rb +249 -93
- data/lib/pikuri/tool/search/exa.rb +75 -97
- 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 +121 -26
- 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 -86
- data/prompts/agent-loop.txt +5 -0
- data/prompts/pikuri-chat.txt +3 -12
- metadata +18 -8
|
@@ -3,115 +3,86 @@
|
|
|
3
3
|
module Pikuri
|
|
4
4
|
class Agent
|
|
5
5
|
module Listener
|
|
6
|
-
# Logs the conversation's context-window consumption per
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
# {Event::
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
# provider raises +RubyLLM::ContextLengthExceededError+.
|
|
14
|
-
# +ctx+ is the headline number — tokens consumed by the
|
|
15
|
-
# conversation *through* this turn: this turn's prompt plus
|
|
16
|
-
# its reply, both of which the model will re-process on the
|
|
17
|
-
# next turn. The +Δ+ field shows the climb between turns;
|
|
18
|
-
# +↑+ / +↓+ are the per-turn input / output sizes that drove
|
|
19
|
-
# it (matching Claude Code's convention: +↑+ is what's sent
|
|
20
|
-
# to the model, +↓+ is what comes back).
|
|
21
|
-
#
|
|
22
|
-
# == State and scope
|
|
23
|
-
#
|
|
24
|
-
# The latest snapshot lives on the instance, so one
|
|
25
|
-
# +TokenLog+ is per-conversation. Sub-agents (with their own
|
|
26
|
-
# +RubyLLM::Chat+) get a fresh instance via {#for_sub_agent}
|
|
27
|
-
# (dispatched by {ListenerList#for_sub_agent}) so their
|
|
28
|
-
# counts log against their own chat. The synthesizer rescue
|
|
29
|
-
# gets a derived list via the same hook because its synth
|
|
30
|
-
# chat is also fresh.
|
|
6
|
+
# Logs the conversation's context-window consumption per assistant
|
|
7
|
+
# turn via +Pikuri.logger_for('Tokens')+ — a running headline to catch
|
|
8
|
+
# growth before the provider raises
|
|
9
|
+
# +RubyLLM::ContextLengthExceededError+. Consumes {Event::Tokens} (one
|
|
10
|
+
# line each), {Event::ContextCap} (caches the cap, refreshed on a model
|
|
11
|
+
# switch), and {Event::Reset} (zero the snapshot, see {#reset_snapshot});
|
|
12
|
+
# every other variant is a no-op.
|
|
31
13
|
#
|
|
32
14
|
# == Log line shape
|
|
33
15
|
#
|
|
34
|
-
#
|
|
35
|
-
#
|
|
36
|
-
# msg #1: ctx=
|
|
37
|
-
# msg #2: ctx=8.4k Δ+1.6k ↑1.0k ↓0.6k
|
|
38
|
-
#
|
|
39
|
-
# With a cap of e.g. 32k (an {Event::ContextCap} with
|
|
40
|
-
# +cap: 32_768+ was emitted at {Agent#initialize}):
|
|
41
|
-
#
|
|
42
|
-
# msg #1: ctx=6.8k/32.0k Δ+6.8k ↑6.8k ↓0.0k
|
|
16
|
+
# msg #1: ctx=6.8k Δ+6.8k ↑6.8k ↓0.0k # no cap
|
|
17
|
+
# msg #2: ctx=8.4k/32.0k Δ+1.6k ↑1.0k ↓0.6k # cap 32k
|
|
18
|
+
# [researcher 0] msg #1: ctx=4.2k ... # sub-agent (Agent#id)
|
|
43
19
|
#
|
|
44
|
-
#
|
|
45
|
-
#
|
|
20
|
+
# +ctx+ is the through-this-turn snapshot (+input + cached +
|
|
21
|
+
# cache_creation + output+; see {Event::Tokens}), suffixed +/<cap>+ when
|
|
22
|
+
# {#context_window_cap} is set. +Δ+ is the signed climb between turns
|
|
23
|
+
# (equal to +ctx+ on msg #1); +↑+/+↓+ are this turn's input/output
|
|
24
|
+
# (Claude Code's convention). Sizes are 1024-scaled with a +k+ suffix.
|
|
46
25
|
#
|
|
47
|
-
#
|
|
48
|
-
#
|
|
49
|
-
#
|
|
50
|
-
# (+input + cached + cache_creation + output+; see
|
|
51
|
-
# {Event::Tokens}), optionally suffixed with +/<cap>+ when
|
|
52
|
-
# {#context_window_cap} is set so the operator can see how
|
|
53
|
-
# close the conversation is to the limit. Including +output+
|
|
54
|
-
# makes this turn's reply visible immediately — leaving it
|
|
55
|
-
# out would hide a long reply in the +ctx=+ headline until
|
|
56
|
-
# the next turn pulled it in as cached prompt. +Δ+ is
|
|
57
|
-
# signed: +Δ+X+ for growth, +Δ-X+ for shrinkage (legitimate
|
|
58
|
-
# if ruby_llm ever prunes between turns). On the first
|
|
59
|
-
# message the baseline is implicitly zero, so +Δ+ equals
|
|
60
|
-
# +ctx+. Sizes are scaled by 1024 and shown with one
|
|
61
|
-
# decimal + a +k+ suffix.
|
|
26
|
+
# One instance per conversation (the snapshot is instance state);
|
|
27
|
+
# sub-agents and the synthesizer get a fresh zeroed one via
|
|
28
|
+
# {#for_sub_agent}.
|
|
62
29
|
class TokenLog < Base
|
|
63
|
-
# Subsystem logger; the per-turn
|
|
64
|
-
#
|
|
65
|
-
# +PIKURI_LOG_TOKENS+ or the global +PIKURI_LOG+.
|
|
30
|
+
# Subsystem logger; the per-turn line is +INFO+. Level via
|
|
31
|
+
# +PIKURI_LOG_TOKENS+ or +PIKURI_LOG+.
|
|
66
32
|
#
|
|
67
33
|
# @return [Logger]
|
|
68
34
|
LOGGER = Pikuri.logger_for('Tokens')
|
|
69
35
|
|
|
70
|
-
# Tokens consumed by the conversation through the most
|
|
71
|
-
#
|
|
72
|
-
#
|
|
73
|
-
# re-process. Equals +input + cached + cache_creation +
|
|
74
|
-
# output+ from the latest {Event::Tokens}. Zero until the
|
|
75
|
-
# first {Event::Tokens} arrives.
|
|
36
|
+
# Tokens consumed by the conversation through the most recent turn
|
|
37
|
+
# (+input + cached + cache_creation + output+ from the latest
|
|
38
|
+
# {Event::Tokens}). Zero until the first arrives.
|
|
76
39
|
#
|
|
77
40
|
# @return [Integer]
|
|
78
41
|
attr_reader :context_window_size
|
|
79
42
|
|
|
80
|
-
# Model's context-window cap, or +nil+ if no source
|
|
81
|
-
#
|
|
82
|
-
#
|
|
83
|
-
#
|
|
84
|
-
# one-shot {Event::ContextCap} at construction; this
|
|
85
|
-
# listener picks the value off it and caches it here. An
|
|
86
|
-
# instance constructed bare (e.g. in tests, or by
|
|
87
|
-
# {#for_sub_agent}) defaults to +nil+.
|
|
43
|
+
# Model's context-window cap, or +nil+ if no source supplied one (see
|
|
44
|
+
# {Agent::ContextWindowDetector}). When set, +ctx+ renders as
|
|
45
|
+
# +<used>/<cap>+. Picked off the one-shot {Event::ContextCap}; a bare
|
|
46
|
+
# instance (tests, {#for_sub_agent}) defaults to +nil+.
|
|
88
47
|
#
|
|
89
48
|
# @return [Integer, nil]
|
|
90
49
|
attr_accessor :context_window_cap
|
|
91
50
|
|
|
92
|
-
# @return [String] owning agent's id ({Agent#id})
|
|
93
|
-
#
|
|
94
|
-
# sub-agent's generated id so the log lines can be
|
|
95
|
-
# prefixed with +[<id>] +. Read-only — for a sub-agent's
|
|
96
|
-
# listener you get a fresh instance via {#for_sub_agent}.
|
|
51
|
+
# @return [String] owning agent's id ({Agent#id}); empty for the main
|
|
52
|
+
# agent, set by {#for_sub_agent} so log lines get an +[<id>] + prefix.
|
|
97
53
|
attr_reader :id
|
|
98
54
|
|
|
99
|
-
# The most recent
|
|
100
|
-
# {LOGGER}
|
|
101
|
-
#
|
|
102
|
-
#
|
|
103
|
-
# their own UI (
|
|
104
|
-
#
|
|
55
|
+
# The most recent status line, *without* the +[<id>] + prefix (the
|
|
56
|
+
# prefix is {LOGGER}'s concern; consumers get the id separately via
|
|
57
|
+
# {#id} / {#on_status_line} and choose their own presentation). Empty
|
|
58
|
+
# until the first {Event::Tokens}. Hosts surfacing the snapshot in
|
|
59
|
+
# their own UI (a TUI footer) read this; hosts wanting to be pushed set
|
|
60
|
+
# {#on_status_line}.
|
|
105
61
|
#
|
|
106
|
-
#
|
|
107
|
-
#
|
|
108
|
-
#
|
|
109
|
-
# {#on_event} call, which is acceptable for a status
|
|
110
|
-
# display.
|
|
62
|
+
# Safe to read from any thread (a single +String+ ivar read); a reader
|
|
63
|
+
# may briefly see the previous turn's line mid-{#on_event}, acceptable
|
|
64
|
+
# for a status display.
|
|
111
65
|
#
|
|
112
66
|
# @return [String]
|
|
113
67
|
attr_reader :status_line
|
|
114
68
|
|
|
69
|
+
# Optional status-line observer, +nil+ by default. When callable, it's
|
|
70
|
+
# invoked with the owning agent's {#id} and the freshly formatted
|
|
71
|
+
# {#status_line} after every {Event::Tokens} — the push counterpart to
|
|
72
|
+
# polling {#status_line}. Fires on the agent's loop thread, so the
|
|
73
|
+
# callable must hand off to its sink thread-safely; it sits on the
|
|
74
|
+
# loop's path, so an exception it raises propagates into the
|
|
75
|
+
# conversation (handle dead connections inside it).
|
|
76
|
+
#
|
|
77
|
+
# {#for_sub_agent} copies the observer by reference, so one callable
|
|
78
|
+
# receives parent and sub-agent lines alike — the +id+ parameter routes
|
|
79
|
+
# each to the right row. An observer assigned *after* a sub-agent
|
|
80
|
+
# spawned doesn't reach that sub-agent's instance.
|
|
81
|
+
#
|
|
82
|
+
# @return [Proc, nil] called with +(id, status_line)+ — the agent's id
|
|
83
|
+
# and the unprefixed {#status_line}
|
|
84
|
+
attr_accessor :on_status_line
|
|
85
|
+
|
|
115
86
|
# @param id [String] owning agent's id, prepended to each
|
|
116
87
|
# log line as +[<id>] + when non-empty. Defaults to +""+
|
|
117
88
|
# for the main agent.
|
|
@@ -122,22 +93,21 @@ module Pikuri
|
|
|
122
93
|
@context_window_size = 0
|
|
123
94
|
@context_window_cap = nil
|
|
124
95
|
@status_line = ''
|
|
96
|
+
@on_status_line = nil
|
|
125
97
|
end
|
|
126
98
|
|
|
127
|
-
# Sub-agent variant: a fresh +TokenLog+ with a zeroed
|
|
128
|
-
#
|
|
129
|
-
#
|
|
130
|
-
#
|
|
131
|
-
#
|
|
132
|
-
# prefix; defaults to +""+ when absent. The cap is left
|
|
133
|
-
# +nil+ here; the sub-agent's {Agent#initialize} emits a
|
|
134
|
-
# fresh {Event::ContextCap} immediately after construction
|
|
135
|
-
# and this listener picks it up off the stream.
|
|
99
|
+
# Sub-agent variant: a fresh +TokenLog+ with a zeroed snapshot so the
|
|
100
|
+
# sub-agent tracks its own +RubyLLM::Chat+, carrying the sub-agent's
|
|
101
|
+
# +id:+ (for the +[<id>] + prefix) and the {#on_status_line} observer
|
|
102
|
+
# by reference. The cap stays +nil+ — the sub-agent's own
|
|
103
|
+
# {Event::ContextCap} refills it.
|
|
136
104
|
#
|
|
137
105
|
# @param id [String] sub-agent's id
|
|
138
106
|
# @return [TokenLog]
|
|
139
107
|
def for_sub_agent(id: '', **)
|
|
140
|
-
self.class.new(id: id)
|
|
108
|
+
derived = self.class.new(id: id)
|
|
109
|
+
derived.on_status_line = @on_status_line
|
|
110
|
+
derived
|
|
141
111
|
end
|
|
142
112
|
|
|
143
113
|
# @param event [Agent::Event]
|
|
@@ -148,6 +118,10 @@ module Pikuri
|
|
|
148
118
|
log_tokens(tokens)
|
|
149
119
|
in Event::ContextCap(cap:)
|
|
150
120
|
@context_window_cap = cap
|
|
121
|
+
in Event::Reset
|
|
122
|
+
reset_snapshot
|
|
123
|
+
in Event::HistoryLoaded(tokens:)
|
|
124
|
+
seed_snapshot(tokens)
|
|
151
125
|
else
|
|
152
126
|
# Every other variant: no-op.
|
|
153
127
|
end
|
|
@@ -162,8 +136,46 @@ module Pikuri
|
|
|
162
136
|
|
|
163
137
|
private
|
|
164
138
|
|
|
165
|
-
#
|
|
166
|
-
#
|
|
139
|
+
# Zero the running snapshot on {Event::Reset} (host cleared the
|
|
140
|
+
# conversation): message counter, running size, and status line reset;
|
|
141
|
+
# the cap stays (a property of the unchanged model). The blanked line
|
|
142
|
+
# is pushed to {#on_status_line} so a footer clears at once.
|
|
143
|
+
#
|
|
144
|
+
# @return [void]
|
|
145
|
+
def reset_snapshot
|
|
146
|
+
@msg = 0
|
|
147
|
+
@context_window_size = 0
|
|
148
|
+
@status_line = ''
|
|
149
|
+
@on_status_line&.call(@id, @status_line)
|
|
150
|
+
nil
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Restore the running size from a conversation just loaded off disk
|
|
154
|
+
# ({Event::HistoryLoaded}), which always arrives right after the
|
|
155
|
+
# {Event::Reset} that zeroed it. Without this the footer reads +0.0k+
|
|
156
|
+
# against 40k of restored history until the next reply corrects it.
|
|
157
|
+
#
|
|
158
|
+
# The message counter deliberately stays at zero: it counts replies
|
|
159
|
+
# *this* session, and restored turns did not happen here. A history
|
|
160
|
+
# exported without usage (+tokens+ +nil+) leaves the zeroed snapshot
|
|
161
|
+
# alone rather than inventing a number.
|
|
162
|
+
#
|
|
163
|
+
# @param tokens [Event::Tokens, nil] usage of the last restored
|
|
164
|
+
# assistant message.
|
|
165
|
+
# @return [void]
|
|
166
|
+
def seed_snapshot(tokens)
|
|
167
|
+
return if tokens.nil?
|
|
168
|
+
|
|
169
|
+
@context_window_size = tokens.input.to_i + tokens.cached.to_i +
|
|
170
|
+
tokens.cache_creation.to_i + tokens.output.to_i
|
|
171
|
+
@status_line = "restored: ctx=#{format_ctx}"
|
|
172
|
+
@on_status_line&.call(@id, @status_line)
|
|
173
|
+
nil
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Update the snapshot, write one +INFO+ line to the
|
|
177
|
+
# subsystem logger, and push the line to the status-line
|
|
178
|
+
# observer (when one was given at construction).
|
|
167
179
|
#
|
|
168
180
|
# @param tokens [Event::Tokens]
|
|
169
181
|
# @return [void]
|
|
@@ -178,19 +190,18 @@ module Pikuri
|
|
|
178
190
|
delta = @context_window_size - prev_ctx
|
|
179
191
|
|
|
180
192
|
@status_line = format_line(input_now, tokens.output.to_i, delta)
|
|
181
|
-
|
|
193
|
+
prefix = @id.empty? ? '' : "[#{@id}] "
|
|
194
|
+
LOGGER.info("#{prefix}#{@status_line}")
|
|
195
|
+
@on_status_line&.call(@id, @status_line)
|
|
182
196
|
end
|
|
183
197
|
|
|
184
198
|
def format_line(input, output, delta)
|
|
185
199
|
sign = delta.negative? ? '-' : '+'
|
|
186
|
-
|
|
187
|
-
"#{prefix}msg ##{@msg}: ctx=#{format_ctx} Δ#{sign}#{format_k(delta.abs)} ↑#{format_k(input)} ↓#{format_k(output)}"
|
|
200
|
+
"msg ##{@msg}: ctx=#{format_ctx} Δ#{sign}#{format_k(delta.abs)} ↑#{format_k(input)} ↓#{format_k(output)}"
|
|
188
201
|
end
|
|
189
202
|
|
|
190
|
-
# +<used>+
|
|
191
|
-
#
|
|
192
|
-
# reads the same in the log stream and in {Agent#to_s}
|
|
193
|
-
# banners.
|
|
203
|
+
# +<used>+ with no cap, +<used>/<cap>+ with one. Shared by
|
|
204
|
+
# {#format_line} and {#to_s}.
|
|
194
205
|
def format_ctx
|
|
195
206
|
base = format_k(@context_window_size)
|
|
196
207
|
return base if @context_window_cap.nil?
|
|
@@ -198,11 +209,8 @@ module Pikuri
|
|
|
198
209
|
"#{base}/#{format_k(@context_window_cap)}"
|
|
199
210
|
end
|
|
200
211
|
|
|
201
|
-
# Format a token count as a 1024-scaled +k+-suffixed
|
|
202
|
-
#
|
|
203
|
-
# Uniform format keeps lines easy to scan at the cost of
|
|
204
|
-
# looking odd for very small per-turn outputs (+↓0.0k+ on
|
|
205
|
-
# tool-call acks).
|
|
212
|
+
# Format a token count as a 1024-scaled +k+-suffixed string
|
|
213
|
+
# (+nil+/+0+ → +0.0k+, 12_453 → +12.2k+).
|
|
206
214
|
#
|
|
207
215
|
# @param n [Integer, nil]
|
|
208
216
|
# @return [String]
|
|
@@ -3,49 +3,36 @@
|
|
|
3
3
|
module Pikuri
|
|
4
4
|
class Agent
|
|
5
5
|
# Namespace for the +Agent+'s pure event consumers — {Terminal},
|
|
6
|
-
# {InMemoryEventList}, {TokenLog}, and any host
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
# everything else flows through unobserved.
|
|
6
|
+
# {InMemoryEventList}, {TokenLog}, and any host/test consumer. Each
|
|
7
|
+
# subclasses {Base} and overrides {Base#on_event} to pattern-match the
|
|
8
|
+
# {Event} variants it cares about; everything else flows through unobserved.
|
|
10
9
|
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
# callbacks. Both responsibilities live in {Agent}.
|
|
18
|
-
#
|
|
19
|
-
# Host-facing signal holders — step budget, cancellation flag,
|
|
20
|
-
# mid-loop user input queue — are *controls*, not listeners.
|
|
21
|
-
# They live under {Pikuri::Agent::Control} and reach {Agent}
|
|
22
|
-
# through dedicated kwargs on {Agent#initialize}; they never
|
|
23
|
-
# appear in the {ListenerList} and they never receive events.
|
|
10
|
+
# These are *pure consumers*: no listener writes back into the stream
|
|
11
|
+
# (emission is the +Agent+'s and {Agent::ExtensionContext#emit_event}'s job)
|
|
12
|
+
# or reaches into ruby_llm's callbacks (that's {Agent}'s). Host-facing signal
|
|
13
|
+
# holders (step budget, cancel flag, input queue) are *controls* under
|
|
14
|
+
# {Pikuri::Agent::Control}, not listeners — they never appear in a
|
|
15
|
+
# {ListenerList} or receive events.
|
|
24
16
|
module Listener
|
|
25
|
-
# Abstract base for event-stream consumers. Subclasses override
|
|
26
|
-
#
|
|
27
|
-
#
|
|
28
|
-
# single variant doesn't have to enumerate the rest.
|
|
17
|
+
# Abstract base for event-stream consumers. Subclasses override {#on_event}
|
|
18
|
+
# with a +case+ on the {Event} variant; the default is a no-op so a
|
|
19
|
+
# listener caring about one variant needn't enumerate the rest.
|
|
29
20
|
#
|
|
30
|
-
# Subclasses optionally define +for_sub_agent(**params)+
|
|
31
|
-
#
|
|
32
|
-
#
|
|
33
|
-
# +nil+ to opt out of propagation. See
|
|
34
|
-
# {ListenerList#for_sub_agent} for the dispatch and the
|
|
35
|
-
# per-listener semantics.
|
|
21
|
+
# Subclasses optionally define +for_sub_agent(**params)+ for a spawned
|
|
22
|
+
# sub-agent — a fresh instance, the same shared by reference, or +nil+ to
|
|
23
|
+
# opt out. See {ListenerList#for_sub_agent}.
|
|
36
24
|
class Base
|
|
37
25
|
# Single entry point for every event in the normalized stream.
|
|
38
|
-
#
|
|
39
|
-
#
|
|
40
|
-
#
|
|
41
|
-
# listener that only cares about a subset can match
|
|
42
|
-
# selectively and let everything else fall through.
|
|
26
|
+
# Subclasses override and dispatch on the variant (a +case event in
|
|
27
|
+
# Event::X(...)+); the default no-op lets a listener match a subset and
|
|
28
|
+
# ignore the rest.
|
|
43
29
|
#
|
|
44
|
-
# @param event [Event::UserTurn, Event::Thinking,
|
|
45
|
-
# Event::
|
|
46
|
-
# Event::AssistantDelta, Event::ToolCall,
|
|
30
|
+
# @param event [Event::UserTurn, Event::Thinking, Event::ThinkingDelta,
|
|
31
|
+
# Event::Assistant, Event::AssistantDelta, Event::ToolCall,
|
|
47
32
|
# Event::ToolResult, Event::Tokens, Event::ContextCap,
|
|
48
|
-
# Event::FallbackNotice, Event::Cancelled]
|
|
33
|
+
# Event::FallbackNotice, Event::Cancelled, Object] a core
|
|
34
|
+
# loop-narration variant, or a gem domain event via
|
|
35
|
+
# {Agent::ExtensionContext#emit_event} (e.g. +Pikuri::Tasks::ListChanged+)
|
|
49
36
|
# @return [void]
|
|
50
37
|
def on_event(event); end
|
|
51
38
|
end
|
|
@@ -2,20 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
module Pikuri
|
|
4
4
|
class Agent
|
|
5
|
-
# Listener-list value object
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
# caller of {#emit}.
|
|
5
|
+
# Listener-list value object an {Agent} owns: wraps an +Array+ of
|
|
6
|
+
# {Listener::Base} and fans {#emit} out to each. {Agent#initialize} stores
|
|
7
|
+
# one and is the sole caller of {#emit}.
|
|
9
8
|
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
# one-shot {Event::ContextCap}, and the sub-agent derivation
|
|
15
|
-
# rule lives in {#for_sub_agent}. Controls (step budget,
|
|
16
|
-
# cancellation flag, mid-loop input queue) are not listeners
|
|
17
|
-
# and do not appear in this list; their sub-agent derivation
|
|
18
|
-
# lives on each control class instead.
|
|
9
|
+
# One job: fan out. The +Agent+ wires ruby_llm's callbacks itself, the
|
|
10
|
+
# context cap rides the stream as a one-shot {Event::ContextCap}, and the
|
|
11
|
+
# sub-agent derivation rule lives in {#for_sub_agent}. Controls are not
|
|
12
|
+
# listeners and don't appear here.
|
|
19
13
|
class ListenerList
|
|
20
14
|
# @param listeners [Array<Listener::Base>] listeners that
|
|
21
15
|
# define +on_event(event)+
|
|
@@ -23,21 +17,21 @@ module Pikuri
|
|
|
23
17
|
@listeners = listeners.dup
|
|
24
18
|
end
|
|
25
19
|
|
|
26
|
-
# Dispatch one event to every listener, in registration order.
|
|
27
|
-
#
|
|
28
|
-
#
|
|
20
|
+
# Dispatch one event to every listener, in registration order. Exactly two
|
|
21
|
+
# callers: {Agent} (loop-narration {Event}s) and
|
|
22
|
+
# {ExtensionContext#emit_event} (domain events); listeners never call it —
|
|
23
|
+
# the stream is one-way.
|
|
29
24
|
#
|
|
30
|
-
# @param event [Agent::Event
|
|
25
|
+
# @param event [Object] an {Agent::Event} variant or a gem domain event
|
|
26
|
+
# (an immutable value, by convention a +Data+)
|
|
31
27
|
# @return [void]
|
|
32
28
|
def emit(event)
|
|
33
29
|
@listeners.each { |l| l.on_event(event) }
|
|
34
30
|
end
|
|
35
31
|
|
|
36
|
-
# Iterate
|
|
37
|
-
#
|
|
38
|
-
#
|
|
39
|
-
# +pikuri-subagents+ when seeding a sub-agent's Configurator
|
|
40
|
-
# from the parent's list).
|
|
32
|
+
# Iterate the wrapped listeners in registration order — so a ListenerList
|
|
33
|
+
# can be passed to {Configurator#add_listeners} (the +agent+ tool seeds a
|
|
34
|
+
# sub-agent's Configurator from the parent's list).
|
|
41
35
|
#
|
|
42
36
|
# @yield [listener]
|
|
43
37
|
# @yieldparam listener [Listener::Base]
|
|
@@ -46,28 +40,17 @@ module Pikuri
|
|
|
46
40
|
@listeners.each(&block)
|
|
47
41
|
end
|
|
48
42
|
|
|
49
|
-
#
|
|
50
|
-
#
|
|
51
|
-
# +
|
|
52
|
-
#
|
|
53
|
-
#
|
|
54
|
-
#
|
|
55
|
-
#
|
|
56
|
-
# stateful sinks continue to flow into the parent's instances).
|
|
57
|
-
#
|
|
58
|
-
# The dispatch lives on each listener so adding a new
|
|
59
|
-
# listener type with sub-agent-specific behavior doesn't
|
|
60
|
-
# change this class — see {Listener::Terminal#for_sub_agent}
|
|
61
|
-
# (fresh padded instance) and
|
|
62
|
-
# {Listener::TokenLog#for_sub_agent} (fresh, zeroed snapshot
|
|
63
|
-
# with the forwarded +id:+).
|
|
43
|
+
# A new {ListenerList} where every listener has been asked for its
|
|
44
|
+
# sub-agent variant: a listener defining +for_sub_agent(**params)+ returns
|
|
45
|
+
# +self+, a replacement, or +nil+ to opt out (compacted away); listeners
|
|
46
|
+
# without the method are kept by reference (stateful sinks keep flowing
|
|
47
|
+
# into the parent's instances). The dispatch lives on each listener so a
|
|
48
|
+
# new listener type needs no change here — see
|
|
49
|
+
# {Listener::Terminal#for_sub_agent} / {Listener::TokenLog#for_sub_agent}.
|
|
64
50
|
#
|
|
65
|
-
# +params+ is
|
|
66
|
-
#
|
|
67
|
-
#
|
|
68
|
-
# bundled listeners is +id:+ (used by {Listener::TokenLog} to
|
|
69
|
-
# prefix its log lines with the sub-agent's id). Calling with
|
|
70
|
-
# no params is always valid.
|
|
51
|
+
# +params+ is forwarded as kwargs; each listener picks what it needs. The
|
|
52
|
+
# only key bundled listeners consume is +id:+ ({Listener::TokenLog}); no
|
|
53
|
+
# params is valid.
|
|
71
54
|
#
|
|
72
55
|
# @param params [Hash{Symbol => Object}]
|
|
73
56
|
# @return [ListenerList]
|
|
@@ -78,20 +61,6 @@ module Pikuri
|
|
|
78
61
|
self.class.new(swapped)
|
|
79
62
|
end
|
|
80
63
|
|
|
81
|
-
# Return a new {ListenerList} containing this list's listeners
|
|
82
|
-
# plus the given extras, in order. Used by {Synthesizer} and
|
|
83
|
-
# other internal consumers to derive a list from an existing
|
|
84
|
-
# one. Returns +self+ when +extras+ is empty so the common
|
|
85
|
-
# no-op case allocates nothing.
|
|
86
|
-
#
|
|
87
|
-
# @param extras [Array<Listener::Base>] listeners to append
|
|
88
|
-
# @return [ListenerList]
|
|
89
|
-
def with(*extras)
|
|
90
|
-
return self if extras.empty?
|
|
91
|
-
|
|
92
|
-
self.class.new(@listeners + extras)
|
|
93
|
-
end
|
|
94
|
-
|
|
95
64
|
# @example
|
|
96
65
|
# list.to_s # => "[Terminal, TokenLog(ctx=0.0k)]"
|
|
97
66
|
#
|