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
|
@@ -3,152 +3,84 @@
|
|
|
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
|
-
# next {Event::Tokens} self-corrects the headline to the new
|
|
14
|
-
# model's count.
|
|
15
|
-
#
|
|
16
|
-
# Existence rationale: catch context-window growth before the
|
|
17
|
-
# provider raises +RubyLLM::ContextLengthExceededError+.
|
|
18
|
-
# +ctx+ is the headline number — tokens consumed by the
|
|
19
|
-
# conversation *through* this turn: this turn's prompt plus
|
|
20
|
-
# its reply, both of which the model will re-process on the
|
|
21
|
-
# next turn. The +Δ+ field shows the climb between turns;
|
|
22
|
-
# +↑+ / +↓+ are the per-turn input / output sizes that drove
|
|
23
|
-
# it (matching Claude Code's convention: +↑+ is what's sent
|
|
24
|
-
# to the model, +↓+ is what comes back).
|
|
25
|
-
#
|
|
26
|
-
# == State and scope
|
|
27
|
-
#
|
|
28
|
-
# The latest snapshot lives on the instance, so one
|
|
29
|
-
# +TokenLog+ is per-conversation. Sub-agents (with their own
|
|
30
|
-
# +RubyLLM::Chat+) get a fresh instance via {#for_sub_agent}
|
|
31
|
-
# (dispatched by {ListenerList#for_sub_agent}) so their
|
|
32
|
-
# counts log against their own chat. The synthesizer rescue
|
|
33
|
-
# gets a derived list via the same hook because its synth
|
|
34
|
-
# 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.
|
|
35
13
|
#
|
|
36
14
|
# == Log line shape
|
|
37
15
|
#
|
|
38
|
-
#
|
|
39
|
-
#
|
|
40
|
-
# msg #1: ctx=
|
|
41
|
-
# msg #2: ctx=8.4k Δ+1.6k ↑1.0k ↓0.6k
|
|
42
|
-
#
|
|
43
|
-
# With a cap of e.g. 32k (an {Event::ContextCap} with
|
|
44
|
-
# +cap: 32_768+ was emitted at {Agent#initialize}):
|
|
45
|
-
#
|
|
46
|
-
# 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)
|
|
47
19
|
#
|
|
48
|
-
#
|
|
49
|
-
#
|
|
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.
|
|
50
25
|
#
|
|
51
|
-
#
|
|
52
|
-
#
|
|
53
|
-
#
|
|
54
|
-
# (+input + cached + cache_creation + output+; see
|
|
55
|
-
# {Event::Tokens}), optionally suffixed with +/<cap>+ when
|
|
56
|
-
# {#context_window_cap} is set so the operator can see how
|
|
57
|
-
# close the conversation is to the limit. Including +output+
|
|
58
|
-
# makes this turn's reply visible immediately — leaving it
|
|
59
|
-
# out would hide a long reply in the +ctx=+ headline until
|
|
60
|
-
# the next turn pulled it in as cached prompt. +Δ+ is
|
|
61
|
-
# signed: +Δ+X+ for growth, +Δ-X+ for shrinkage (legitimate
|
|
62
|
-
# if ruby_llm ever prunes between turns). On the first
|
|
63
|
-
# message the baseline is implicitly zero, so +Δ+ equals
|
|
64
|
-
# +ctx+. Sizes are scaled by 1024 and shown with one
|
|
65
|
-
# 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}.
|
|
66
29
|
class TokenLog < Base
|
|
67
|
-
# Subsystem logger; the per-turn
|
|
68
|
-
#
|
|
69
|
-
# +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+.
|
|
70
32
|
#
|
|
71
33
|
# @return [Logger]
|
|
72
34
|
LOGGER = Pikuri.logger_for('Tokens')
|
|
73
35
|
|
|
74
|
-
# Tokens consumed by the conversation through the most
|
|
75
|
-
#
|
|
76
|
-
#
|
|
77
|
-
# re-process. Equals +input + cached + cache_creation +
|
|
78
|
-
# output+ from the latest {Event::Tokens}. Zero until the
|
|
79
|
-
# 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.
|
|
80
39
|
#
|
|
81
40
|
# @return [Integer]
|
|
82
41
|
attr_reader :context_window_size
|
|
83
42
|
|
|
84
|
-
# Model's context-window cap, or +nil+ if no source
|
|
85
|
-
#
|
|
86
|
-
#
|
|
87
|
-
#
|
|
88
|
-
# one-shot {Event::ContextCap} at construction; this
|
|
89
|
-
# listener picks the value off it and caches it here. An
|
|
90
|
-
# instance constructed bare (e.g. in tests, or by
|
|
91
|
-
# {#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+.
|
|
92
47
|
#
|
|
93
48
|
# @return [Integer, nil]
|
|
94
49
|
attr_accessor :context_window_cap
|
|
95
50
|
|
|
96
|
-
# @return [String] owning agent's id ({Agent#id})
|
|
97
|
-
#
|
|
98
|
-
# sub-agent's generated id so the log lines can be
|
|
99
|
-
# prefixed with +[<id>] +. Read-only — for a sub-agent's
|
|
100
|
-
# 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.
|
|
101
53
|
attr_reader :id
|
|
102
54
|
|
|
103
|
-
# The most recent status line, *without* the +[<id>] +
|
|
104
|
-
# prefix
|
|
105
|
-
#
|
|
106
|
-
#
|
|
107
|
-
#
|
|
108
|
-
# {#on_status_line}
|
|
109
|
-
# themselves. Empty until the first {Event::Tokens} has
|
|
110
|
-
# been processed. Hosts that
|
|
111
|
-
# want to surface the current context-window snapshot in
|
|
112
|
-
# their own UI (e.g. a TUI status footer) read this
|
|
113
|
-
# instead of re-implementing the formatting. Hosts that
|
|
114
|
-
# want to be *pushed* each new line instead of polling
|
|
115
|
-
# set {#on_status_line}.
|
|
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}.
|
|
116
61
|
#
|
|
117
|
-
#
|
|
118
|
-
#
|
|
119
|
-
#
|
|
120
|
-
# {#on_event} call, which is acceptable for a status
|
|
121
|
-
# 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.
|
|
122
65
|
#
|
|
123
66
|
# @return [String]
|
|
124
67
|
attr_reader :status_line
|
|
125
68
|
|
|
126
|
-
# Optional status-line observer, +nil+ by default. When
|
|
127
|
-
#
|
|
128
|
-
#
|
|
129
|
-
#
|
|
130
|
-
#
|
|
131
|
-
#
|
|
132
|
-
#
|
|
133
|
-
# whatever thread runs the owning agent's loop, so the
|
|
134
|
-
# callable must hand off to its sink thread-safely; and
|
|
135
|
-
# like any listener it sits on the loop's path — an
|
|
136
|
-
# exception it raises propagates into the conversation,
|
|
137
|
-
# so handle dead connections inside the callable.
|
|
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).
|
|
138
76
|
#
|
|
139
|
-
#
|
|
140
|
-
#
|
|
141
|
-
#
|
|
142
|
-
#
|
|
143
|
-
# for a sub-agent) is what lets the UI route each line to
|
|
144
|
-
# the right status row — the line itself is unprefixed
|
|
145
|
-
# (see {#status_line}). An observer assigned *after*
|
|
146
|
-
# a sub-agent spawned doesn't reach that sub-agent's
|
|
147
|
-
# instance.
|
|
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.
|
|
148
81
|
#
|
|
149
|
-
# @return [Proc, nil] called with +(id, status_line)+ —
|
|
150
|
-
#
|
|
151
|
-
# {#status_line} +String+
|
|
82
|
+
# @return [Proc, nil] called with +(id, status_line)+ — the agent's id
|
|
83
|
+
# and the unprefixed {#status_line}
|
|
152
84
|
attr_accessor :on_status_line
|
|
153
85
|
|
|
154
86
|
# @param id [String] owning agent's id, prepended to each
|
|
@@ -164,18 +96,11 @@ module Pikuri
|
|
|
164
96
|
@on_status_line = nil
|
|
165
97
|
end
|
|
166
98
|
|
|
167
|
-
# Sub-agent variant: a fresh +TokenLog+ with a zeroed
|
|
168
|
-
#
|
|
169
|
-
#
|
|
170
|
-
#
|
|
171
|
-
#
|
|
172
|
-
# prefix; defaults to +""+ when absent. The cap is left
|
|
173
|
-
# +nil+ here; the sub-agent's {Agent#initialize} emits a
|
|
174
|
-
# fresh {Event::ContextCap} immediately after construction
|
|
175
|
-
# and this listener picks it up off the stream. The
|
|
176
|
-
# {#on_status_line} observer carries over by reference, so
|
|
177
|
-
# a host streaming the parent's lines sees the sub-agent's
|
|
178
|
-
# too — distinguished by the +id+ the callable receives.
|
|
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.
|
|
179
104
|
#
|
|
180
105
|
# @param id [String] sub-agent's id
|
|
181
106
|
# @return [TokenLog]
|
|
@@ -193,6 +118,10 @@ module Pikuri
|
|
|
193
118
|
log_tokens(tokens)
|
|
194
119
|
in Event::ContextCap(cap:)
|
|
195
120
|
@context_window_cap = cap
|
|
121
|
+
in Event::Reset
|
|
122
|
+
reset_snapshot
|
|
123
|
+
in Event::HistoryLoaded(tokens:)
|
|
124
|
+
seed_snapshot(tokens)
|
|
196
125
|
else
|
|
197
126
|
# Every other variant: no-op.
|
|
198
127
|
end
|
|
@@ -207,6 +136,43 @@ module Pikuri
|
|
|
207
136
|
|
|
208
137
|
private
|
|
209
138
|
|
|
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
|
+
|
|
210
176
|
# Update the snapshot, write one +INFO+ line to the
|
|
211
177
|
# subsystem logger, and push the line to the status-line
|
|
212
178
|
# observer (when one was given at construction).
|
|
@@ -234,10 +200,8 @@ module Pikuri
|
|
|
234
200
|
"msg ##{@msg}: ctx=#{format_ctx} Δ#{sign}#{format_k(delta.abs)} ↑#{format_k(input)} ↓#{format_k(output)}"
|
|
235
201
|
end
|
|
236
202
|
|
|
237
|
-
# +<used>+
|
|
238
|
-
#
|
|
239
|
-
# reads the same in the log stream and in {Agent#to_s}
|
|
240
|
-
# banners.
|
|
203
|
+
# +<used>+ with no cap, +<used>/<cap>+ with one. Shared by
|
|
204
|
+
# {#format_line} and {#to_s}.
|
|
241
205
|
def format_ctx
|
|
242
206
|
base = format_k(@context_window_size)
|
|
243
207
|
return base if @context_window_cap.nil?
|
|
@@ -245,11 +209,8 @@ module Pikuri
|
|
|
245
209
|
"#{base}/#{format_k(@context_window_cap)}"
|
|
246
210
|
end
|
|
247
211
|
|
|
248
|
-
# Format a token count as a 1024-scaled +k+-suffixed
|
|
249
|
-
#
|
|
250
|
-
# Uniform format keeps lines easy to scan at the cost of
|
|
251
|
-
# looking odd for very small per-turn outputs (+↓0.0k+ on
|
|
252
|
-
# tool-call acks).
|
|
212
|
+
# Format a token count as a 1024-scaled +k+-suffixed string
|
|
213
|
+
# (+nil+/+0+ → +0.0k+, 12_453 → +12.2k+).
|
|
253
214
|
#
|
|
254
215
|
# @param n [Integer, nil]
|
|
255
216
|
# @return [String]
|
|
@@ -3,56 +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
|
-
# {Agent::ExtensionContext#emit_event} (domain events) — and no
|
|
18
|
-
# listener reaches into ruby_llm's chat callbacks (that wiring
|
|
19
|
-
# lives in {Agent}).
|
|
20
|
-
#
|
|
21
|
-
# Host-facing signal holders — step budget, cancellation flag,
|
|
22
|
-
# mid-loop user input queue — are *controls*, not listeners.
|
|
23
|
-
# They live under {Pikuri::Agent::Control} and reach {Agent}
|
|
24
|
-
# through dedicated kwargs on {Agent#initialize}; they never
|
|
25
|
-
# 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.
|
|
26
16
|
module Listener
|
|
27
|
-
# Abstract base for event-stream consumers. Subclasses override
|
|
28
|
-
#
|
|
29
|
-
#
|
|
30
|
-
# 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.
|
|
31
20
|
#
|
|
32
|
-
# Subclasses optionally define +for_sub_agent(**params)+
|
|
33
|
-
#
|
|
34
|
-
#
|
|
35
|
-
# +nil+ to opt out of propagation. See
|
|
36
|
-
# {ListenerList#for_sub_agent} for the dispatch and the
|
|
37
|
-
# 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}.
|
|
38
24
|
class Base
|
|
39
25
|
# Single entry point for every event in the normalized stream.
|
|
40
|
-
#
|
|
41
|
-
#
|
|
42
|
-
#
|
|
43
|
-
# listener that only cares about a subset can match
|
|
44
|
-
# 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.
|
|
45
29
|
#
|
|
46
|
-
# @param event [Event::UserTurn, Event::Thinking,
|
|
47
|
-
# Event::
|
|
48
|
-
# Event::AssistantDelta, Event::ToolCall,
|
|
30
|
+
# @param event [Event::UserTurn, Event::Thinking, Event::ThinkingDelta,
|
|
31
|
+
# Event::Assistant, Event::AssistantDelta, Event::ToolCall,
|
|
49
32
|
# Event::ToolResult, Event::Tokens, Event::ContextCap,
|
|
50
|
-
# Event::FallbackNotice, Event::Cancelled, Object]
|
|
51
|
-
#
|
|
52
|
-
#
|
|
53
|
-
# {Agent::ExtensionContext#emit_event} (e.g.
|
|
54
|
-
# +Pikuri::Tasks::ListChanged+) — match the variants you
|
|
55
|
-
# know, let everything else fall through
|
|
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+)
|
|
56
36
|
# @return [void]
|
|
57
37
|
def on_event(event); end
|
|
58
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,25 +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
|
-
#
|
|
29
|
-
#
|
|
30
|
-
# stream is one-way.
|
|
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.
|
|
31
24
|
#
|
|
32
|
-
# @param event [Object] an {Agent::Event} variant or a
|
|
33
|
-
#
|
|
34
|
-
# convention a +Data+ instance)
|
|
25
|
+
# @param event [Object] an {Agent::Event} variant or a gem domain event
|
|
26
|
+
# (an immutable value, by convention a +Data+)
|
|
35
27
|
# @return [void]
|
|
36
28
|
def emit(event)
|
|
37
29
|
@listeners.each { |l| l.on_event(event) }
|
|
38
30
|
end
|
|
39
31
|
|
|
40
|
-
# Iterate
|
|
41
|
-
#
|
|
42
|
-
#
|
|
43
|
-
# +pikuri-subagents+ when seeding a sub-agent's Configurator
|
|
44
|
-
# 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).
|
|
45
35
|
#
|
|
46
36
|
# @yield [listener]
|
|
47
37
|
# @yieldparam listener [Listener::Base]
|
|
@@ -50,28 +40,17 @@ module Pikuri
|
|
|
50
40
|
@listeners.each(&block)
|
|
51
41
|
end
|
|
52
42
|
|
|
53
|
-
#
|
|
54
|
-
#
|
|
55
|
-
# +
|
|
56
|
-
#
|
|
57
|
-
#
|
|
58
|
-
#
|
|
59
|
-
#
|
|
60
|
-
# stateful sinks continue to flow into the parent's instances).
|
|
61
|
-
#
|
|
62
|
-
# The dispatch lives on each listener so adding a new
|
|
63
|
-
# listener type with sub-agent-specific behavior doesn't
|
|
64
|
-
# change this class — see {Listener::Terminal#for_sub_agent}
|
|
65
|
-
# (fresh padded instance) and
|
|
66
|
-
# {Listener::TokenLog#for_sub_agent} (fresh, zeroed snapshot
|
|
67
|
-
# 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}.
|
|
68
50
|
#
|
|
69
|
-
# +params+ is
|
|
70
|
-
#
|
|
71
|
-
#
|
|
72
|
-
# bundled listeners is +id:+ (used by {Listener::TokenLog} to
|
|
73
|
-
# prefix its log lines with the sub-agent's id). Calling with
|
|
74
|
-
# 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.
|
|
75
54
|
#
|
|
76
55
|
# @param params [Hash{Symbol => Object}]
|
|
77
56
|
# @return [ListenerList]
|