pikuri-core 0.0.6 → 0.0.7

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.
@@ -10,6 +10,14 @@ module Pikuri
10
10
  # is fully constructed, and {#on_user_message} on every user turn
11
11
  # thereafter.
12
12
  #
13
+ # Each phase receives that phase's context object: +configure+
14
+ # gets the build-time {Configurator}; +bind+ and
15
+ # +on_user_message+ get the runtime {ExtensionContext} — the
16
+ # capability facade for everything that acts on the live agent
17
+ # (domain-event emission, raw tool registration, sub-agent
18
+ # listener derivation), with the agent itself readable via
19
+ # {ExtensionContext#agent}.
20
+ #
13
21
  # Mix this module into an extension class to inherit empty
14
22
  # default implementations of all three hooks; override the ones
15
23
  # you need. Extensions that don't +include+ this module still
@@ -59,25 +67,30 @@ module Pikuri
59
67
  def configure(c); end
60
68
 
61
69
  # Called by {Agent#initialize} after the block returns and the
62
- # chat is fully wired, with the live {Agent} as the argument.
63
- # Fires once per agent the extension was registered to via
64
- # {Configurator#add_extension} — in the typical setup that's
65
- # the parent agent only, since sub-agents do not inherit
66
- # extensions. The default is a no-op; override when you need
67
- # to install state keyed to the live agent object. Things
68
- # you typically do here:
70
+ # chat is fully wired, with the agent's {ExtensionContext} as
71
+ # the argument. Fires once per agent the extension was
72
+ # registered to via {Configurator#add_extension} — in the
73
+ # typical setup that's the parent agent only, since sub-agents
74
+ # do not inherit extensions. The default is a no-op; override
75
+ # when you need to install state keyed to the live agent.
76
+ # Things you typically do here:
69
77
  #
70
- # * register dynamic tools via {Agent#internal_add_tool}
78
+ # * register dynamic tools via {ExtensionContext#add_raw_tool}
71
79
  # (used by {Pikuri::Mcp::Extension} for +mcp_connect+,
72
- # whose +execute+ closure needs the live agent so
73
- # activations register on the right chat)
74
- # * register +on_close+ handlers via {Agent#on_close}
75
- # * stash an +@agent+ reference if the extension's tools need
76
- # to act on this specific agent later
80
+ # whose +execute+ closure needs the context so activations
81
+ # register on the right chat)
82
+ # * wire domain-event emission via
83
+ # {ExtensionContext#emit_event} (e.g. +Pikuri::Tasks::Extension+
84
+ # arms its list's +on_change+ here)
85
+ # * register per-agent +on_close+ handlers via
86
+ # {ExtensionContext#on_close}
87
+ # * stash the +ctx+ if the extension's tools need to act on
88
+ # this specific agent later
77
89
  #
78
- # @param agent [Agent] the live agent, fully wired
90
+ # @param ctx [ExtensionContext] capability facade for the
91
+ # live, fully wired agent
79
92
  # @return [void]
80
- def bind(agent); end
93
+ def bind(ctx); end
81
94
 
82
95
  # Optional per-turn hook fired by the {Agent} after a user-message
83
96
  # is added to the chat. The
@@ -97,12 +110,13 @@ module Pikuri
97
110
  # only — sub-agents do not inherit extensions, so a persona's turns are
98
111
  # never prefetched or recorded by the parent's memory.
99
112
  #
100
- # @param agent [Agent] the live agent whose turn this is
113
+ # @param ctx [ExtensionContext] capability facade for the live
114
+ # agent whose turn this is — same instance +bind+ received
101
115
  # @param content [String] the user message (initial or interloper) about
102
116
  # to be sent to the model
103
117
  # @return [String, nil] an optional block of text to be injected verbatim as
104
118
  # a system-role message (after the user message), or +nil+ to inject nothing
105
- def on_user_message(agent, content); end
119
+ def on_user_message(ctx, content); end
106
120
  end
107
121
  end
108
122
  end
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pikuri
4
+ class Agent
5
+ # Capability facade handed to {Extension#bind} and
6
+ # {Extension#on_user_message} — the runtime counterpart of
7
+ # {Configurator}. Where the Configurator collects *build-time*
8
+ # declarations (tools, listeners, prompt snippets), this object
9
+ # grants the *runtime* capabilities an extension needs once the
10
+ # agent is fully wired: emitting domain events onto the listener
11
+ # stream, registering raw per-agent tools, and deriving sub-agent
12
+ # listener lists.
13
+ #
14
+ # == Why a handed object, not a getter on Agent
15
+ #
16
+ # The {Agent} deliberately exposes NO public path to these
17
+ # capabilities — no +listeners+ reader, no +chat+ reader, no
18
+ # emit method. Holding an agent reference grants read access to
19
+ # its configuration ({Agent#tools}, {Agent#transport}, ...) and
20
+ # nothing more; the write capabilities live here, and the only
21
+ # way to obtain this object is to be an {Extension} receiving a
22
+ # +bind+ / +on_user_message+ call (or to be handed it onward by
23
+ # one, e.g. {Pikuri::SubAgent::SubAgentTool} and
24
+ # {Pikuri::Mcp::Servers::Connect} both capture the context their
25
+ # extension's +bind+ received). Capabilities flow by explicit
26
+ # handoff, never by fetching from a globally reachable object.
27
+ #
28
+ # The usual Ruby caveat applies: nothing here is mechanically
29
+ # sealed (+instance_variable_get+ exists). The boundary is the
30
+ # API contract — same as every seam in CLAUDE.md.
31
+ #
32
+ # == Boundary rule
33
+ #
34
+ # Operations that *act on* the live agent's wiring live here.
35
+ # Passive readers of constructor-given config (+transport+,
36
+ # +id+, +streaming+, +tools+, +cancellable+, ...) stay on
37
+ # {Agent}, reachable via {#agent}. Don't move readers in; don't
38
+ # add capabilities to Agent.
39
+ #
40
+ # == Audit
41
+ #
42
+ # One context per agent, constructed by {Agent#initialize} right
43
+ # before the extension +bind+ sweep. {ListenerList#emit} has
44
+ # exactly two callers: {Agent} (loop narration) and this class
45
+ # (extension domain events). The roster of capability users is
46
+ # +grep -rn 'emit_event\|add_raw_tool\|sub_agent_listeners'
47
+ # pikuri-*/lib/+.
48
+ class ExtensionContext
49
+ # @param agent [Agent] the live, fully wired agent.
50
+ # @param chat [RubyLLM::Chat] the agent's underlying chat —
51
+ # target of {#add_raw_tool}.
52
+ # @param listeners [ListenerList] the agent's listener list —
53
+ # target of {#emit_event} / {#sub_agent_listeners}.
54
+ # @param on_close_sink [Array<Proc>] the agent's live
55
+ # +@on_close_handlers+ array, which {#on_close} appends to —
56
+ # same live-sink shape as {Configurator}'s +on_close_sink:+.
57
+ def initialize(agent:, chat:, listeners:, on_close_sink:)
58
+ @agent = agent
59
+ @chat = chat
60
+ @listeners = listeners
61
+ @on_close_handlers = on_close_sink
62
+ end
63
+
64
+ # @return [Agent] the live agent, for read access to its
65
+ # configuration (tools, transport, id, streaming, ...).
66
+ attr_reader :agent
67
+
68
+ # Emit a domain event onto the agent's listener stream.
69
+ #
70
+ # Core {Event} variants narrate the chat loop and are emitted
71
+ # by {Agent} alone; gems define their own variants (e.g.
72
+ # +Pikuri::Tasks::ListChanged+) in their own namespace and
73
+ # emit them here. Listeners must no-op on variants they don't
74
+ # recognize — {Listener::Base#on_event}'s default and
75
+ # +case+-fallthrough give that for free.
76
+ #
77
+ # Called on the agent's thread (typically from inside a tool's
78
+ # +execute+, where the event lands between {Event::ToolCall}
79
+ # and {Event::ToolResult} in the stream). Listeners doing
80
+ # cross-thread handoff snapshot/serialize inside +on_event+.
81
+ #
82
+ # @param event [Object] an immutable event value (by
83
+ # convention a +Data+ instance).
84
+ # @return [void]
85
+ def emit_event(event)
86
+ @listeners.emit(event)
87
+ nil
88
+ end
89
+
90
+ # Register a raw +RubyLLM::Tool+ subclass on the agent's
91
+ # underlying chat, bypassing the {Pikuri::Tool}
92
+ # strict-validation seam — hence "raw": native pikuri tools
93
+ # should go through {Pikuri::Tool} (registered at build time
94
+ # via {Configurator#add_tool}) so they get {Tool::Parameters}
95
+ # validation and the LLM-actionable +"Error: ..."+ contract.
96
+ # Intended callers: {Pikuri::Mcp::Servers} (MCP tools
97
+ # deliberately bypass — see IDEAS.md §"MCP tools bypass
98
+ # +Pikuri::Tool+ entirely") and
99
+ # {Pikuri::SubAgent::Extension} (the +agent+ tool must
100
+ # register after the parent's tool list is final).
101
+ #
102
+ # The added tool does NOT enter {Agent#tools}, only the chat's
103
+ # tool list. Sub-agents therefore cannot snapshot it — which
104
+ # is the whole point: activation is strictly per-agent, see
105
+ # IDEAS.md §"Per-agent activation, no propagation".
106
+ #
107
+ # @param ruby_llm_tool [Class] subclass of +RubyLLM::Tool+
108
+ # @return [void]
109
+ def add_raw_tool(ruby_llm_tool)
110
+ @chat.with_tool(ruby_llm_tool)
111
+ nil
112
+ end
113
+
114
+ # Derive a listener list for a spawned sub-agent via
115
+ # {ListenerList#for_sub_agent}. Sole intended caller:
116
+ # {Pikuri::SubAgent::SubAgentTool}, once per spawn.
117
+ #
118
+ # The derived list deliberately aliases the parent's listener
119
+ # *instances* where a listener opts to share by reference
120
+ # (stateful sinks like {Listener::InMemoryEventList}) — see
121
+ # {ListenerList#for_sub_agent} for the per-listener semantics.
122
+ #
123
+ # @param params [Hash{Symbol => Object}] forwarded to each
124
+ # listener's +for_sub_agent+ hook (currently +id:+).
125
+ # @return [ListenerList]
126
+ def sub_agent_listeners(**params)
127
+ @listeners.for_sub_agent(**params)
128
+ end
129
+
130
+ # Register a handler called by {Agent#close}. Symmetric to
131
+ # {Configurator#on_close} — same LIFO + per-handler-rescue +
132
+ # idempotent semantics — but available post-construction, so
133
+ # an {Extension}'s +bind+ can install per-agent cleanup keyed
134
+ # to this specific agent (e.g. +Pikuri::Memory::Extension+
135
+ # arms its recorder's bounded flush here).
136
+ #
137
+ # @yield called with no arguments at close time
138
+ # @return [void]
139
+ def on_close(&blk)
140
+ raise ArgumentError, 'on_close requires a block' unless block_given?
141
+
142
+ @on_close_handlers << blk
143
+ nil
144
+ end
145
+ end
146
+ end
147
+ end
@@ -8,7 +8,8 @@ module Pikuri
8
8
  # Terminal renderer for the normalized event stream: dim grey
9
9
  # reasoning, assistant content printed raw (Markdown as-is),
10
10
  # cyan tool-call and tool-result lines, yellow fallback
11
- # notice, red cancelled notice. An {Event::SystemInjected} block (recalled
11
+ # notice, red cancelled notice, magenta model-switch notice.
12
+ # An {Event::SystemInjected} block (recalled
12
13
  # memory / context an extension injected) renders dim grey
13
14
  # with a +⊕+ marker. {Event::UserTurn} is intentionally silent
14
15
  # (the terminal user just typed the message, so re-rendering
@@ -131,6 +132,8 @@ module Pikuri
131
132
  println(indent(Rainbow("→ #{name}(#{args})").cyan))
132
133
  in Event::ToolResult(content:)
133
134
  println(indent(Rainbow("= #{truncate_tool_result(content)}").cyan))
135
+ in Event::ModelSwitched(from:, to:)
136
+ println(indent(Rainbow("⇄ model: #{from.model} → #{to.model}").magenta))
134
137
  in Event::FallbackNotice(reason:)
135
138
  println(indent(Rainbow("! #{reason}").yellow))
136
139
  in Event::Cancelled
@@ -234,12 +237,20 @@ module Pikuri
234
237
  # exactly this" from "tool returned much more, you're
235
238
  # seeing a slice."
236
239
  #
240
+ # Whitespace is flattened *first* (collapsing real tabs, CRs and
241
+ # newlines into single spaces — fine here, since this passive
242
+ # echo is a status line, not an approval artifact), then the
243
+ # result runs through {Pikuri::Sanitizer} so a tool observation
244
+ # can't smuggle an ESC or other control byte into this cyan line.
245
+ # The sanitizer's warnings are dropped — they belong at a
246
+ # confirmation prompt, not the stream narration.
247
+ #
237
248
  # @param content [String] tool observation
238
249
  # @return [String] single-line display form, possibly
239
250
  # truncated
240
251
  def truncate_tool_result(content)
241
252
  original_bytes = content.to_s.bytesize
242
- flattened = content.to_s.gsub(/\s+/, ' ').strip
253
+ flattened = Sanitizer.sanitize(content.to_s.gsub(/\s+/, ' ').strip).text
243
254
  return flattened if flattened.length <= MAX_TOOL_RESULT_CHARS
244
255
 
245
256
  "#{flattened[0, MAX_TOOL_RESULT_CHARS]}… (#{original_bytes} bytes total)"
@@ -6,8 +6,12 @@ module Pikuri
6
6
  # Logs the conversation's context-window consumption per
7
7
  # assistant turn via +Pikuri.logger_for('Tokens')+. Consumes
8
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.
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.
11
15
  #
12
16
  # Existence rationale: catch context-window growth before the
13
17
  # provider raises +RubyLLM::ContextLengthExceededError+.
@@ -96,12 +100,19 @@ module Pikuri
96
100
  # listener you get a fresh instance via {#for_sub_agent}.
97
101
  attr_reader :id
98
102
 
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
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
102
111
  # want to surface the current context-window snapshot in
103
112
  # their own UI (e.g. a TUI status footer) read this
104
- # instead of re-implementing the formatting.
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}.
105
116
  #
106
117
  # Thread safety: a single instance-variable read of a
107
118
  # +String+ — safe to read from any thread; readers may
@@ -112,6 +123,34 @@ module Pikuri
112
123
  # @return [String]
113
124
  attr_reader :status_line
114
125
 
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.
138
+ #
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.
148
+ #
149
+ # @return [Proc, nil] called with +(id, status_line)+ —
150
+ # the owning agent's id +String+ and the unprefixed
151
+ # {#status_line} +String+
152
+ attr_accessor :on_status_line
153
+
115
154
  # @param id [String] owning agent's id, prepended to each
116
155
  # log line as +[<id>] + when non-empty. Defaults to +""+
117
156
  # for the main agent.
@@ -122,6 +161,7 @@ module Pikuri
122
161
  @context_window_size = 0
123
162
  @context_window_cap = nil
124
163
  @status_line = ''
164
+ @on_status_line = nil
125
165
  end
126
166
 
127
167
  # Sub-agent variant: a fresh +TokenLog+ with a zeroed
@@ -132,12 +172,17 @@ module Pikuri
132
172
  # prefix; defaults to +""+ when absent. The cap is left
133
173
  # +nil+ here; the sub-agent's {Agent#initialize} emits a
134
174
  # fresh {Event::ContextCap} immediately after construction
135
- # and this listener picks it up off the stream.
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.
136
179
  #
137
180
  # @param id [String] sub-agent's id
138
181
  # @return [TokenLog]
139
182
  def for_sub_agent(id: '', **)
140
- self.class.new(id: id)
183
+ derived = self.class.new(id: id)
184
+ derived.on_status_line = @on_status_line
185
+ derived
141
186
  end
142
187
 
143
188
  # @param event [Agent::Event]
@@ -162,8 +207,9 @@ module Pikuri
162
207
 
163
208
  private
164
209
 
165
- # Update the snapshot and write one +INFO+ line to the
166
- # subsystem logger.
210
+ # Update the snapshot, write one +INFO+ line to the
211
+ # subsystem logger, and push the line to the status-line
212
+ # observer (when one was given at construction).
167
213
  #
168
214
  # @param tokens [Event::Tokens]
169
215
  # @return [void]
@@ -178,13 +224,14 @@ module Pikuri
178
224
  delta = @context_window_size - prev_ctx
179
225
 
180
226
  @status_line = format_line(input_now, tokens.output.to_i, delta)
181
- LOGGER.info(@status_line)
227
+ prefix = @id.empty? ? '' : "[#{@id}] "
228
+ LOGGER.info("#{prefix}#{@status_line}")
229
+ @on_status_line&.call(@id, @status_line)
182
230
  end
183
231
 
184
232
  def format_line(input, output, delta)
185
233
  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)}"
234
+ "msg ##{@msg}: ctx=#{format_ctx} Δ#{sign}#{format_k(delta.abs)} ↑#{format_k(input)} ↓#{format_k(output)}"
188
235
  end
189
236
 
190
237
  # +<used>+ when no cap is set, +<used>/<cap>+ when one is.
@@ -11,10 +11,12 @@ module Pikuri
11
11
  # == What lives here, what doesn't
12
12
  #
13
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}.
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}).
18
20
  #
19
21
  # Host-facing signal holders — step budget, cancellation flag,
20
22
  # mid-loop user input queue — are *controls*, not listeners.
@@ -45,7 +47,12 @@ module Pikuri
45
47
  # Event::ThinkingDelta, Event::Assistant,
46
48
  # Event::AssistantDelta, Event::ToolCall,
47
49
  # Event::ToolResult, Event::Tokens, Event::ContextCap,
48
- # Event::FallbackNotice, Event::Cancelled]
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
49
56
  # @return [void]
50
57
  def on_event(event); end
51
58
  end
@@ -24,10 +24,14 @@ module Pikuri
24
24
  end
25
25
 
26
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.
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.
29
31
  #
30
- # @param event [Agent::Event]
32
+ # @param event [Object] an {Agent::Event} variant or a
33
+ # gem-defined domain event (an immutable value, by
34
+ # convention a +Data+ instance)
31
35
  # @return [void]
32
36
  def emit(event)
33
37
  @listeners.each { |l| l.on_event(event) }
@@ -78,20 +82,6 @@ module Pikuri
78
82
  self.class.new(swapped)
79
83
  end
80
84
 
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
85
  # @example
96
86
  # list.to_s # => "[Terminal, TokenLog(ctx=0.0k)]"
97
87
  #
@@ -2,13 +2,13 @@
2
2
 
3
3
  module Pikuri
4
4
  class Agent
5
- # Step-exhaustion rescue. When an +Agent+'s
6
- # {Control::StepLimit} trips, +Agent#run_loop+ catches the
7
- # +Exceeded+ exception and hands off to {Synthesizer.run} so
8
- # the run still produces something useful — a tools-free
9
- # assistant turn that answers the user's question from
10
- # whatever evidence the failed agent collected before running
11
- # out of budget.
5
+ # Prompt builder for the step-exhaustion rescue. When an
6
+ # +Agent+'s {Control::StepLimit} trips with the +:synthesize+
7
+ # policy, +Agent#run_loop+ runs this module's prompt on a
8
+ # nested tools-free agent so the run still produces something
9
+ # useful — an assistant turn that answers the user's question
10
+ # from whatever evidence the failed agent collected before
11
+ # running out of budget.
12
12
  #
13
13
  # == Why this exists
14
14
  #
@@ -22,16 +22,24 @@ module Pikuri
22
22
  # answer is largely in the messages — it just needs a
23
23
  # tools-free pass to synthesize.
24
24
  #
25
+ # Salvage is the wrong move for some agents, which is why the
26
+ # policy lives on {Control::StepLimit} and defaults to
27
+ # +:raise+ — a coding agent's half-finished work can't be
28
+ # completed by a tools-free pass, only described. See
29
+ # {Control::StepLimit}'s class header.
30
+ #
25
31
  # == Seam discipline
26
32
  #
27
- # {Synthesizer.run} does not reference +RubyLLM::*+. +Agent+
28
- # constructs the synth chat itself (the one +RubyLLM.chat+
29
- # call lives in +lib/agent.rb+, same as the parent chat) and
30
- # passes it in. +Synthesizer+ only calls instance methods on
31
- # whatever +chat+ it receives +#with_instructions+,
32
- # +#ask+, +#messages+ and uses {Agent.wire_chat} for the
33
- # event-stream wiring so the synth chat emits events with
34
- # the same shape as the main chat.
33
+ # This module is pure prompt construction — no chat handling,
34
+ # no +RubyLLM.chat+ call, no event wiring. The execution side
35
+ # (constructing the nested agent, sharing the parent's
36
+ # listener stream and cancellable, capturing the answer) is
37
+ # +Agent#run_synthesizer+'s job: the synth is a regular
38
+ # tools-free +Agent+, the same construction shape the +agent+
39
+ # tool from +pikuri-subagents+ uses for sub-agents. The only
40
+ # +RubyLLM::*+ surface read here is the value-type
41
+ # +RubyLLM::Message+ / +ToolCall+ passthrough (per the
42
+ # value-type rule in CLAUDE.md).
35
43
  module Synthesizer
36
44
  # The synthesizer's system prompt. Strict and short: use
37
45
  # the evidence, don't apologize, admit gaps when present.
@@ -39,58 +47,6 @@ module Pikuri
39
47
  You are given evidence another agent collected before running out of steps. Answer the user's question using only this evidence. You have no tools. If the evidence is insufficient, state plainly what's missing and what partial answer you can give. Do not apologize or comment on the previous agent.
40
48
  PROMPT
41
49
 
42
- # Configure +chat+ for synthesis, run one turn against it,
43
- # and return the final assistant content. The chat is wired
44
- # for the event stream via {Agent.wire_chat} so the synth's
45
- # reasoning and answer flow through the same listener
46
- # surface the parent agent uses — terminal renders them
47
- # inline (padded under sub-agent), an in-memory recorder
48
- # picks them up, a TokenLog tags them with the synth id.
49
- #
50
- # @param chat [RubyLLM::Chat] a *fresh* chat with no tools.
51
- # The caller is responsible for constructing it with the
52
- # same model/provider configuration the parent used.
53
- # @param parent_messages [Array<RubyLLM::Message>] the
54
- # parent chat's full message history at the moment of
55
- # step exhaustion. Used to build the evidence transcript.
56
- # @param user_message [String] the user's original question
57
- # from the parent turn that exhausted.
58
- # @param listeners [Agent::ListenerList] listeners to wire
59
- # the synth chat into. Typically the parent agent's list
60
- # run through {ListenerList#for_sub_agent} with the
61
- # synth's +id:+ so any +TokenLog+ tags its lines with
62
- # the synth bracket and any +Terminal+ pads its output.
63
- # @param step_limit [Control::StepLimit, nil] defensive
64
- # step budget. The synth has no tools so it should never
65
- # trip +before_tool_call+, but a buggy provider that
66
- # somehow returned a tool call would loop without one.
67
- # Pass +nil+ to skip.
68
- # @param cancellable [Control::Cancellable, nil]
69
- # cancellation control. Typically the parent's instance,
70
- # shared by reference so a user cancel during synthesis
71
- # still works. Pass +nil+ to skip.
72
- # @param streaming [Boolean] mirror the parent agent's
73
- # +streaming+ flag. When +true+, {Agent.streaming_block}
74
- # is passed to +chat.ask+ so the synth's reasoning and
75
- # answer flow through the listener stream as deltas in
76
- # addition to the final {Event::Thinking} / {Event::Assistant}
77
- # bookends.
78
- # @return [String, nil] the synth's final assistant
79
- # content, or +nil+ if the synth somehow produced no
80
- # assistant message
81
- def self.run(chat:, parent_messages:, user_message:, listeners:,
82
- step_limit: nil, cancellable: nil, streaming: false)
83
- chat.with_instructions(SYSTEM_PROMPT)
84
- Agent.wire_chat(chat, listeners: listeners, step_limit: step_limit, cancellable: cancellable)
85
- prompt = build_prompt(parent_messages: parent_messages, user_message: user_message)
86
- if streaming
87
- chat.ask(prompt, &Agent.streaming_block(listeners: listeners, cancellable: cancellable))
88
- else
89
- chat.ask(prompt)
90
- end
91
- chat.messages.reverse.find { |m| m.role == :assistant }&.content
92
- end
93
-
94
50
  # Render the user's question plus an "Evidence gathered"
95
51
  # section built from +parent_messages+ as a single prompt
96
52
  # string. Pure function — no I/O, safe to test directly
@@ -140,6 +96,76 @@ module Pikuri
140
96
  lines.join("\n").rstrip
141
97
  end
142
98
  private_class_method :format_evidence
99
+
100
+ # The +:synthesize+ arm of the step-exhaustion policy (see the
101
+ # class header). Runs the {Synthesizer} prompt over the
102
+ # exhausted chat's history on a nested tools-free +Agent+ —
103
+ # the same construction shape the +agent+ tool from
104
+ # +pikuri-subagents+ uses for sub-agents, so the synth gets
105
+ # listener propagation, transport / context-window-cap /
106
+ # streaming inheritance, and teardown via +close+ for free.
107
+ # The synth's answer is returned.
108
+ #
109
+ # @param ctx [ExtensionContext]
110
+ # @param chat_messages [Array<RubyLLM::Message>] the
111
+ # exhausted chat's full message history, the evidence
112
+ # {.build_prompt} renders
113
+ # @param user_message [String] the user's original question
114
+ # from the turn that exhausted
115
+ # @raise [Control::Cancellable::Cancelled] when a cancel
116
+ # landed between the budget tripping and this rescue —
117
+ # cancellation wins over salvage
118
+ # @return [String] the synth answer
119
+ def self.run_synthesizer(ctx, chat_messages, user_message)
120
+ # Check the cancel flag *before* constructing the synth: the
121
+ # nested run_loop resets the shared cancellable at its turn
122
+ # boundary, which would erase a cancel requested in this
123
+ # window. The raise propagates without a parent-side
124
+ # {Event::Cancelled} — a cancel *during* synthesis emits it
125
+ # from the synth's own rescue (on the derived listener list)
126
+ # instead, so either way the stream sees at most one.
127
+ ctx.agent.cancellable&.check!
128
+
129
+ ctx.emit_event(Event::FallbackNotice.new(
130
+ reason: "agent exhausted #{ctx.agent.step_limit.max} steps; " \
131
+ 'synthesizing answer from gathered evidence'
132
+ ))
133
+
134
+ # Synth runs under this agent's identity but with a
135
+ # different system prompt, so it gets a distinct
136
+ # +_synthesizer+ suffix on the id — same +_+ separator the
137
+ # sub-agent generator uses, so main becomes +"synthesizer"+
138
+ # and a sub-agent +"researcher 0"+ becomes
139
+ # +"researcher 0_synthesizer"+. Any +TokenLog+ in the list
140
+ # tags the synth's prompt under that bracket so it's
141
+ # obvious from the log which turns were the rescue rather
142
+ # than the original loop.
143
+ synth_id = ctx.agent.id.empty? ? 'synthesizer' : "#{ctx.agent.id}_synthesizer"
144
+ synth = Agent.new(
145
+ # Carry the parent's resolved cap on the transport so the synth
146
+ # reuses it without a re-probe — the cap rides {ChatTransport}
147
+ # now, not an +Agent.new(context_window:)+ kwarg.
148
+ transport: ctx.agent.transport.with(context_window: ctx.agent.context_window_cap),
149
+ system_prompt: Synthesizer::SYSTEM_PROMPT,
150
+ # Defensive budget with the default :raise policy: the
151
+ # synth has no tools so it should never tick, but a buggy
152
+ # provider that somehow returns a tool call must not loop
153
+ # forever — and a synth that needs its own synth is a bug,
154
+ # not a rescue.
155
+ step_limit: Control::StepLimit.new(max: 1),
156
+ cancellable: ctx.agent.cancellable,
157
+ id: synth_id,
158
+ streaming: ctx.agent.streaming
159
+ ) { |c| c.add_listeners(ctx.sub_agent_listeners(id: synth_id)) }
160
+ begin
161
+ synth.run_loop(user_message: Synthesizer.build_prompt(
162
+ parent_messages: chat_messages, user_message: user_message
163
+ ))
164
+ synth.last_assistant_content
165
+ ensure
166
+ synth.close
167
+ end
168
+ end
143
169
  end
144
170
  end
145
171
  end