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.
Files changed (56) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/lib/pikuri/agent/chat_transport.rb +73 -93
  4. data/lib/pikuri/agent/configurator.rb +46 -106
  5. data/lib/pikuri/agent/context_window_detector.rb +44 -85
  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 +25 -41
  9. data/lib/pikuri/agent/control.rb +14 -34
  10. data/lib/pikuri/agent/event.rb +123 -188
  11. data/lib/pikuri/agent/extension.rb +118 -94
  12. data/lib/pikuri/agent/extension_context.rb +50 -77
  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 +143 -117
  16. data/lib/pikuri/agent/listener/token_log.rb +101 -140
  17. data/lib/pikuri/agent/listener.rb +23 -43
  18. data/lib/pikuri/agent/listener_list.rb +26 -47
  19. data/lib/pikuri/agent/synthesizer.rb +45 -87
  20. data/lib/pikuri/agent.rb +816 -474
  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 +45 -67
  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 +21 -29
  36. data/lib/pikuri/tool/scraper.rb +55 -97
  37. data/lib/pikuri/tool/search/brave.rb +52 -80
  38. data/lib/pikuri/tool/search/duckduckgo.rb +59 -91
  39. data/lib/pikuri/tool/search/engines.rb +230 -97
  40. data/lib/pikuri/tool/search/exa.rb +56 -90
  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 +100 -24
  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 -88
  54. data/prompts/agent-loop.txt +5 -0
  55. data/prompts/pikuri-chat.txt +3 -12
  56. 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
- # assistant turn via +Pikuri.logger_for('Tokens')+. Consumes
8
- # {Event::Tokens} (one log line per emission) and
9
- # {Event::ContextCap} (the cap, picked off and cached —
10
- # refreshed if a later {Event::ContextCap} arrives after a
11
- # model switch); every other event variant is a no-op. A
12
- # switch does not reset the running size or message count: the
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
- # Without a cap (no {Event::ContextCap} carrying a value):
39
- #
40
- # msg #1: ctx=6.8k Δ+6.8k ↑6.8k ↓0.0k
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
- # When the owning {Agent} has a non-empty {Agent#id} (i.e. a
49
- # 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.
50
25
  #
51
- # [researcher 0] msg #1: ctx=4.2k Δ+4.2k ↑4.2k ↓0.0k
52
- #
53
- # +ctx+ is the snapshot
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 context-window line is
68
- # emitted at +INFO+ level. Set its level with
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
- # recent turn — this turn's prompt plus its reply, which
76
- # together make up the bulk of what the next turn will
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 could
85
- # supply one (see {Agent::ContextWindowDetector}). When
86
- # set, the +ctx=+ field renders as +ctx=<used>/<cap>+
87
- # instead of just +ctx=<used>+. {Agent#initialize} emits a
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}). Empty by
97
- # default (main agent); set by {#for_sub_agent} from the
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 — the prefix is {LOGGER}'s concern (the log
105
- # stream interleaves agents, so its lines must carry the
106
- # id inline); consumers of this reader get the id
107
- # separately ({#id} here, the first callable parameter on
108
- # {#on_status_line}) and decide the presentation
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
- # Thread safety: a single instance-variable read of a
118
- # +String+ — safe to read from any thread; readers may
119
- # briefly see the previous turn's line during an in-flight
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
- # set to a callable, it is invoked with the owning
128
- # agent's {#id} and the freshly formatted {#status_line}
129
- # after every {Event::Tokens} — the push counterpart to
130
- # polling {#status_line}, for hosts that stream the line
131
- # onward (a Sinatra SSE endpoint pushing it to the
132
- # browser, a websocket, a TUI redraw trigger). Fires on
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
- # Instances derived via {#for_sub_agent} copy the
140
- # observer set at derivation time, so one callable
141
- # receives parent and sub-agent lines alike; the +id+
142
- # parameter (+""+ for the main agent, the generated id
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
- # the owning agent's id +String+ and the unprefixed
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
- # snapshot so the sub-agent's context-window readings
169
- # track its own +RubyLLM::Chat+ rather than continuing the
170
- # parent's. Picks the sub-agent's +id:+ out of the
171
- # forwarded params so its log lines carry the +[<id>] +
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>+ when no cap is set, +<used>/<cap>+ when one is.
238
- # Shared between {#format_line} and {#to_s} so the headline
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
- # string. +nil+ → +0.0k+; +0+ → +0.0k+; 12_453 → +12.2k+.
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- 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 already emitted. No listener
15
- # writes back into the stream — emission belongs to the +Agent+
16
- # (loop narration) and to extensions via
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
- # {#on_event} with a +case+ on the {Event} variant; the default
29
- # implementation is a no-op so a listener that cares about a
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)+ to
33
- # return a variant suitable for a spawned sub-agent — a fresh
34
- # zeroed instance, the same instance shared by reference, or
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
- # Concrete subclasses override this and dispatch on the
41
- # variant (typically with a +case event in Event::X(...)+
42
- # pattern). The default implementation is a no-op so a
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::ThinkingDelta, Event::Assistant,
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] one of
51
- # the core loop-narration variants, or a gem-defined
52
- # domain event emitted via
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 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,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
- # Exactly two callers: {Agent} (loop-narration {Event}
28
- # variants) and {ExtensionContext#emit_event} (extension
29
- # domain events). Listeners themselves never call this; the
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
- # gem-defined domain event (an immutable value, by
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 over the wrapped listeners in registration order. The
41
- # method exists so a ListenerList can be passed directly to
42
- # {Configurator#add_listeners} (used by the +agent+ tool from
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
- # Return a new {ListenerList} in which every listener has been
54
- # asked for its sub-agent variant. Each listener that defines
55
- # +for_sub_agent(**params)+ receives the forwarded +params+
56
- # and returns either +self+, a replacement instance, or +nil+
57
- # to opt out of propagation entirely — the resulting list
58
- # compacts +nil+ entries away. Listeners that don't define the
59
- # method are kept by reference (structured capture and other
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 a flat hash forwarded as kwargs to every
70
- # listener's hook; each listener picks the keys it cares about
71
- # and ignores the rest. The only key currently consumed by
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]