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.
Files changed (56) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +6 -4
  3. data/lib/pikuri/agent/chat_transport.rb +128 -24
  4. data/lib/pikuri/agent/configurator.rb +47 -107
  5. data/lib/pikuri/agent/context_window_detector.rb +80 -70
  6. data/lib/pikuri/agent/control/cancellable.rb +87 -66
  7. data/lib/pikuri/agent/control/interloper.rb +127 -105
  8. data/lib/pikuri/agent/control/step_limit.rb +46 -30
  9. data/lib/pikuri/agent/control.rb +14 -34
  10. data/lib/pikuri/agent/event.rb +125 -163
  11. data/lib/pikuri/agent/extension.rb +121 -83
  12. data/lib/pikuri/agent/extension_context.rb +120 -0
  13. data/lib/pikuri/agent/history.rb +653 -0
  14. data/lib/pikuri/agent/listener/rate_limited.rb +40 -66
  15. data/lib/pikuri/agent/listener/terminal.rb +147 -110
  16. data/lib/pikuri/agent/listener/token_log.rb +116 -108
  17. data/lib/pikuri/agent/listener.rb +23 -36
  18. data/lib/pikuri/agent/listener_list.rb +26 -57
  19. data/lib/pikuri/agent/synthesizer.rb +76 -92
  20. data/lib/pikuri/agent.rb +939 -642
  21. data/lib/pikuri/bundler_env.rb +68 -0
  22. data/lib/pikuri/extractor/html.rb +63 -110
  23. data/lib/pikuri/extractor/passthrough.rb +20 -30
  24. data/lib/pikuri/extractor.rb +93 -154
  25. data/lib/pikuri/file_type.rb +63 -135
  26. data/lib/pikuri/finalizers.rb +32 -47
  27. data/lib/pikuri/paths.rb +104 -13
  28. data/lib/pikuri/ruby_llm_patches.rb +106 -0
  29. data/lib/pikuri/sanitizer.rb +157 -0
  30. data/lib/pikuri/subprocess.rb +75 -119
  31. data/lib/pikuri/testing.rb +296 -0
  32. data/lib/pikuri/tool/calculator.rb +56 -66
  33. data/lib/pikuri/tool/execute_context.rb +42 -0
  34. data/lib/pikuri/tool/fetch.rb +51 -77
  35. data/lib/pikuri/tool/parameters.rb +70 -15
  36. data/lib/pikuri/tool/scraper.rb +55 -97
  37. data/lib/pikuri/tool/search/brave.rb +70 -84
  38. data/lib/pikuri/tool/search/duckduckgo.rb +65 -86
  39. data/lib/pikuri/tool/search/engines.rb +249 -93
  40. data/lib/pikuri/tool/search/exa.rb +75 -97
  41. data/lib/pikuri/tool/search/rate_limiter.rb +61 -38
  42. data/lib/pikuri/tool/search/result.rb +10 -15
  43. data/lib/pikuri/tool/trifecta_legs.rb +217 -0
  44. data/lib/pikuri/tool/web_scrape.rb +38 -54
  45. data/lib/pikuri/tool/web_search.rb +121 -26
  46. data/lib/pikuri/tool.rb +140 -65
  47. data/lib/pikuri/trifecta/contribution.rb +43 -0
  48. data/lib/pikuri/trifecta/node.rb +47 -0
  49. data/lib/pikuri/trifecta/report.rb +230 -0
  50. data/lib/pikuri/trifecta.rb +127 -0
  51. data/lib/pikuri/url_cache.rb +33 -49
  52. data/lib/pikuri/version.rb +1 -1
  53. data/lib/pikuri-core.rb +72 -86
  54. data/prompts/agent-loop.txt +5 -0
  55. data/prompts/pikuri-chat.txt +3 -12
  56. 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
- # assistant turn via +Pikuri.logger_for('Tokens')+. Consumes
8
- # {Event::Tokens} (one log line per emission) and
9
- # {Event::ContextCap} (one-shot cap, picked off and cached);
10
- # every other event variant is a no-op.
11
- #
12
- # Existence rationale: catch context-window growth before the
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
- # Without a cap (no {Event::ContextCap} carrying a value):
35
- #
36
- # msg #1: ctx=6.8k Δ+6.8k ↑6.8k ↓0.0k
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
- # When the owning {Agent} has a non-empty {Agent#id} (i.e. a
45
- # sub-agent), the line is prefixed with +[id] +:
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
- # [researcher 0] msg #1: ctx=4.2k Δ+4.2k ↑4.2k ↓0.0k
48
- #
49
- # +ctx+ is the snapshot
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 context-window line is
64
- # emitted at +INFO+ level. Set its level with
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
- # recent turn — this turn's prompt plus its reply, which
72
- # together make up the bulk of what the next turn will
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 could
81
- # supply one (see {Agent::ContextWindowDetector}). When
82
- # set, the +ctx=+ field renders as +ctx=<used>/<cap>+
83
- # instead of just +ctx=<used>+. {Agent#initialize} emits a
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}). Empty by
93
- # default (main agent); set by {#for_sub_agent} from the
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 log line, in the exact format written to
100
- # {LOGGER} (including any +[<id>] + prefix). Empty until
101
- # the first {Event::Tokens} has been processed. Hosts that
102
- # want to surface the current context-window snapshot in
103
- # their own UI (e.g. a TUI status footer) read this
104
- # instead of re-implementing the formatting.
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
- # Thread safety: a single instance-variable read of a
107
- # +String+ — safe to read from any thread; readers may
108
- # briefly see the previous turn's line during an in-flight
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
- # snapshot so the sub-agent's context-window readings
129
- # track its own +RubyLLM::Chat+ rather than continuing the
130
- # parent's. Picks the sub-agent's +id:+ out of the
131
- # forwarded params so its log lines carry the +[<id>] +
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
- # Update the snapshot and write one +INFO+ line to the
166
- # subsystem logger.
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
- LOGGER.info(@status_line)
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
- prefix = @id.empty? ? '' : "[#{@id}] "
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>+ when no cap is set, +<used>/<cap>+ when one is.
191
- # Shared between {#format_line} and {#to_s} so the headline
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
- # string. +nil+ → +0.0k+; +0+ → +0.0k+; 12_453 → +12.2k+.
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- or test-defined
7
- # consumer. Each subclasses {Base} and overrides {Base#on_event}
8
- # to pattern-match on the {Event} variants it cares about;
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
- # == What lives here, what doesn't
12
- #
13
- # The directory holds *pure consumers*: code whose only side
14
- # effect is to react to events the +Agent+ has already emitted.
15
- # No listener writes back into the stream — the +Agent+ is the
16
- # only emitter — and no listener reaches into ruby_llm's chat
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
- # {#on_event} with a +case+ on the {Event} variant; the default
27
- # implementation is a no-op so a listener that cares about a
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)+ to
31
- # return a variant suitable for a spawned sub-agent — a fresh
32
- # zeroed instance, the same instance shared by reference, or
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
- # Concrete subclasses override this and dispatch on the
39
- # variant (typically with a +case event in Event::X(...)+
40
- # pattern). The default implementation is a no-op so a
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::ThinkingDelta, Event::Assistant,
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 that an {Agent} owns. Wraps an
6
- # +Array+ of {Listener::Base} instances and fans {#emit} out to
7
- # each; {Agent#initialize} stores one of these and is the sole
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
- # == What this is, what it isn't
11
- #
12
- # One job: fan out. The +Agent+ wires ruby_llm's callbacks
13
- # itself, the context-window cap rides the event stream as a
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
- # Called exclusively by {Agent} — listeners themselves never
28
- # call this; the 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.
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 over the wrapped listeners in registration order. The
37
- # method exists so a ListenerList can be passed directly to
38
- # {Configurator#add_listeners} (used by the +agent+ tool from
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
- # Return a new {ListenerList} in which every listener has been
50
- # asked for its sub-agent variant. Each listener that defines
51
- # +for_sub_agent(**params)+ receives the forwarded +params+
52
- # and returns either +self+, a replacement instance, or +nil+
53
- # to opt out of propagation entirely — the resulting list
54
- # compacts +nil+ entries away. Listeners that don't define the
55
- # method are kept by reference (structured capture and other
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 a flat hash forwarded as kwargs to every
66
- # listener's hook; each listener picks the keys it cares about
67
- # and ignores the rest. The only key currently consumed by
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
  #