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
data/lib/pikuri/agent.rb CHANGED
@@ -3,151 +3,174 @@
3
3
  require 'ruby_llm'
4
4
 
5
5
  module Pikuri
6
- # Thin wrapper around +RubyLLM::Chat+: pikuri owns the *extension
7
- # surface* (the event-stream listeners that consume normalized
8
- # chat events, plus the controls that signal back into the loop)
9
- # while ruby_llm owns the loop itself. The Thought / Tool-call /
10
- # Observation iteration lives in +Chat#complete+; pikuri's job
11
- # is wiring ruby_llm's three callbacks at construction time,
12
- # emitting {Event} variants from each, and forwarding control
13
- # signals (step-budget tick, cancellation check, mid-loop input
14
- # drain) to the appropriate {Control}.
6
+ # Thin wrapper around +RubyLLM::Chat+: ruby_llm owns the Thought /
7
+ # Tool-call / Observation loop (+Chat#complete+); pikuri owns the
8
+ # *extension surface* — it wires ruby_llm's three callbacks at
9
+ # construction, emits {Event} variants from each, and forwards control
10
+ # signals to the {Control}s.
15
11
  #
16
- # == Roles in this file
12
+ # Two seams meet here:
17
13
  #
18
- # Two seams are visible:
19
- #
20
- # 1. **Listeners** ({ListenerList} + {Listener::Base} subclasses)
21
- # — pure consumers of the event stream; they never write back.
22
- # The +Agent+ emits every loop-narration {Event} variant;
23
- # extensions emit their own domain events through the
24
- # {ExtensionContext} capability facade handed to
25
- # {Extension#bind}. There is no public path from an +Agent+
26
- # reference to emission — no +listeners+ reader, no +chat+
27
- # reader, no emit method — so holding an agent grants read
28
- # access to its configuration and nothing more. New rendering
29
- # or capture targets (a web sink, a structured log) are added
30
- # as listeners without touching {Agent}.
14
+ # 1. **Listeners** ({ListenerList} + {Listener::Base}) — pure consumers
15
+ # of the event stream, never writing back. The +Agent+ emits every
16
+ # loop-narration {Event}; extensions emit domain events through the
17
+ # {ExtensionContext} handed to {Extension#bind}. No public path leads
18
+ # from an +Agent+ reference to emission (no +listeners+/+chat+ reader,
19
+ # no emit method), so holding an agent grants read access and nothing
20
+ # more. New sinks are added as listeners without touching {Agent}.
31
21
  # 2. **Controls** ({Control::StepLimit}, {Control::Cancellable},
32
- # {Control::Interloper}) — host-facing signal holders. The
33
- # +Agent+ reads from them at well-defined boundaries:
34
- # {Control::StepLimit#tick!} on every +before_tool_call+ (and
35
- # {Control::StepLimit#reset!} at turn start),
36
- # {Control::Cancellable#check!} on every +before_tool_call+
37
- # (and {Control::Cancellable#reset!} at turn start),
38
- # {Control::Interloper#drain!} on every +after_tool_result+.
22
+ # {Control::Interloper}) — host-facing signal holders the +Agent+
23
+ # reads at fixed boundaries: +tick!+/+check!+ on every
24
+ # +before_tool_call+, +reset!+ at turn start, +drain!+ once each tool
25
+ # batch is answered.
39
26
  #
40
- # The two roles are named separately so "what fires when" is a
41
- # single grep for +@listeners.emit+ in this file (loop narration)
27
+ # "What fires when" is one grep for +@listeners.emit+ (loop narration)
42
28
  # plus the capability calls in {ExtensionContext} (domain events).
43
29
  #
44
30
  # == Step-exhaustion policy
45
31
  #
46
- # If the +step_limit:+ {Control::StepLimit} trips during
47
- # completion, {#run_loop} catches the +Exceeded+ exception and
48
- # applies the budget's {Control::StepLimit#on_exhausted} policy
49
- # (see that class header for how hosts pick):
32
+ # When +step_limit:+ trips during completion, {#run_loop} applies its
33
+ # {Control::StepLimit#on_exhausted} policy:
50
34
  #
51
- # - +:raise+ (the default) — re-raise to the host, same shape as
52
- # the cancellation rescue below. The chat history survives and
53
- # {Control::StepLimit#reset!} fires at the next turn boundary,
54
- # so a REPL user can simply say "continue".
35
+ # - +:raise+ (default) — re-raise to the host. History survives and
36
+ # {Control::StepLimit#reset!} fires next turn, so "continue" works.
55
37
  # - +:synthesize+ — emit an {Event::FallbackNotice} and run the
56
- # {Synthesizer} prompt on a nested tools-free +Agent+ (the
57
- # same construction shape the +agent+ tool from
58
- # +pikuri-subagents+ uses for sub-agents): parent's listener
59
- # stream derived via {ListenerList#for_sub_agent} (Terminal
60
- # padded, TokenLog zeroed, recorder shared by reference),
61
- # parent's +cancellable+ shared so a user cancel during
62
- # synthesis still works, a defensive +step_limit+ at +max: 1+
63
- # (the synth has no tools and shouldn't tick it). The synth's
64
- # answer becomes the value reported by
65
- # {#last_assistant_content}, so callers (notably the +agent+
66
- # tool from +pikuri-subagents+) still get a usable reply.
38
+ # {Synthesizer} prompt on a nested tools-free +Agent+ (listener stream
39
+ # via {ListenerList#for_sub_agent}, +cancellable+ shared so a user
40
+ # cancel still works, a defensive +step_limit+ at +max: 1+). Its answer
41
+ # becomes {#last_assistant_content}, so callers still get a reply.
67
42
  #
68
43
  # == Cancellation rescue
69
44
  #
70
- # If the +cancellable:+ {Control::Cancellable} trips during
71
- # +Chat#ask+, {#run_loop} catches the +Cancelled+ exception,
72
- # emits an {Event::Cancelled} to the listener stream, and
73
- # re-raises. No synthesizer fallback runs: cancellation means
74
- # the user asked the agent to drop everything, so salvaging a
75
- # partial answer would be the wrong move. The caller (typically
76
- # a REPL) rescues the re-raised exception and returns control
77
- # to the user; because {#run_loop} calls
78
- # {Control::Cancellable#reset!} at the start of every turn, the
79
- # same agent instance can take a fresh turn immediately
80
- # afterwards.
45
+ # When +cancellable:+ trips during +Chat#ask+, {#run_loop} emits an
46
+ # {Event::Cancelled} and re-raises — no synthesizer fallback (cancel
47
+ # means drop everything). {Control::Cancellable#reset!} at each turn
48
+ # start lets the same instance take a fresh turn afterwards.
49
+ #
50
+ # == Threading
51
+ #
52
+ # **Thread-unsafe** — and that is the whole guarantee, deliberately no
53
+ # stronger. +@chat+, the message-identity map and the +on_close+ list are
54
+ # plain unguarded state, and {#run_loop} is not re-entrant, so a second
55
+ # thread calling in mid-turn corrupts the conversation rather than raising.
56
+ # Preventing concurrent access is the caller's job, by any means: one thread
57
+ # per agent, a mutex around every call, an actor mailbox. Nothing here is
58
+ # *confined* to a particular thread — an +Agent+ may be driven from a
59
+ # different thread on each turn, so never key anything about it off
60
+ # +Thread.current+.
61
+ #
62
+ # Several agents in one VM is then the easy case, one thread each:
63
+ #
64
+ # agents.map { |a| Thread.new { a.run_loop(user_message: task_for(a)) } }.each(&:join)
65
+ #
66
+ # Two {Control}s are the sanctioned doors *in* from another thread, which is
67
+ # why they carry locks and the agent does not: {Control::Cancellable} (stop
68
+ # the turn) and {Control::Interloper} (queue a mid-turn message, delivered at
69
+ # the next tool-batch boundary). Every other method needs the caller's own
70
+ # serialization.
71
+ #
72
+ # Tools inherit nothing from this: an instance handed to two agents is
73
+ # reachable from both, so each declares its own posture (see
74
+ # {Pikuri::Tool}'s +== Sharing+).
81
75
  class Agent
82
76
  LOGGER = Pikuri.logger_for('Agent')
83
77
  private_constant :LOGGER
84
78
 
85
- # @param transport [ChatTransport] the model-resolution bundle
86
- # (+model+ / +provider+ / +assume_model_exists+ and, for a model
87
- # on a non-global server, +api_base+ / +api_key+) the chat is
88
- # built from. Bundled into one value object so every construction
89
- # site — this constructor, the synthesizer rescue below, a
90
- # mid-conversation switch — can forward it with one assignment
91
- # instead of loose kwargs (where dropping one would silently
92
- # route the chat elsewhere or raise
93
- # +RubyLLM::ModelNotFoundError+). If +transport.model+ is
94
- # +nil+, it's filled in from +RubyLLM.config.default_model+.
95
- # @param system_prompt [String] system message prepended to
96
- # the chat. Extensions append their advertisement blocks
97
- # (e.g. +<available_skills>+, +<available_mcps>+) onto this
98
- # base via {Configurator#append_system_prompt} during the
99
- # block.
100
- # @param step_limit [Control::StepLimit, nil] step budget
101
- # control. When set, {Control::StepLimit#tick!} fires on
102
- # every +before_tool_call+ and {Control::StepLimit#reset!}
103
- # at the start of each turn; the budget's
104
- # {Control::StepLimit#on_exhausted} policy decides what
105
- # {#run_loop} does when it trips (see "Step-exhaustion
106
- # policy" in the class header). +nil+ means "no step budget"
107
- # (the agent can loop indefinitely).
108
- # @param cancellable [Control::Cancellable, nil] cancellation
109
- # control. When set, {Control::Cancellable#check!} fires on
110
- # every +before_tool_call+ and
111
- # {Control::Cancellable#reset!} at the start of each turn.
112
- # +nil+ means "not cancellable" (the host has no way to
113
- # stop a running turn except by killing the process).
114
- # @param interloper [Control::Interloper, nil] mid-loop
115
- # user-input queue. When set, the queue is drained at
116
- # every +after_tool_result+ and each item becomes a
117
- # {Event::UserTurn} with +mid_loop: true+. +nil+ means
118
- # "no mid-loop injection" (the bundled CLIs default).
119
- # @param id [String] unique identifier for this agent. Empty
120
- # for the main agent; sub-agents get persona-rooted ids
121
- # like +"researcher 0"+, +"researcher 1"+, +"file_miner 0"+, ...
122
- # generated by the +agent+ tool from +pikuri-subagents+ from
123
- # the persona name + a per-persona counter. Forwarded to
124
- # listeners through {ListenerList#for_sub_agent} so id-aware
125
- # ones (notably {Listener::TokenLog}) can tag their output.
126
- # The word "id" is deliberate — "name" is reserved throughout
127
- # the codebase for the persona-name load (the value the LLM
128
- # picks in the +agent+ tool's +name:+ argument).
129
- # @param streaming [Boolean] opt into chunk-level streaming.
130
- # When +true+, {#run_loop} passes a per-chunk block to
131
- # +Chat#complete+, and ruby_llm requests
132
- # SSE responses from the provider — chunks are normalized
133
- # into {Event::ThinkingDelta} / {Event::AssistantDelta} on
134
- # the listener stream as they arrive. When +false+ (the
135
- # default), +Chat#ask+ runs in single-shot mode and only
136
- # the message-level {Event::Thinking} / {Event::Assistant}
137
- # bookends fire from +after_message+. Read by the +agent+
138
- # tool from +pikuri-subagents+ so spawned sub-agents inherit
139
- # the same mode without an extra kwarg.
140
- # @yield [Configurator] yields a {Configurator} that collects
141
- # tools (via {Configurator#add_tool} / {Configurator#add_tools}),
142
- # listeners (via {Configurator#add_listener} /
143
- # {Configurator#add_listeners}), system-prompt snippets (via
144
- # {Configurator#append_system_prompt}), extension instances
145
- # (via {Configurator#add_extension} — which fires +configure+
146
- # immediately), and close handlers (via
147
- # {Configurator#on_close}). The Configurator is the *only*
148
- # path for adding any of these — there are no parallel ctor
149
- # kwargs. The block is optional; an agent constructed without
150
- # one has no tools, no listeners, no extensions.
79
+ # Envelope wrapped around every injected reference block by
80
+ # {#append_reference_block}. +system-reminder+ is Claude Code's and
81
+ # grok-build's tag, deliberately — an in-distribution envelope the model has
82
+ # seen in training beats a pikuri-flavoured one it has to infer.
83
+ REFERENCE_TAG = 'system-reminder'
84
+ private_constant :REFERENCE_TAG
85
+
86
+ # Provider slugs (matched against +@chat.model.provider+) whose wire format
87
+ # carries system content in a top-level request field rather than as an
88
+ # element of +messages+ — Anthropic's +system+ param, Bedrock's +system+
89
+ # blocks. ruby_llm's provider splits it out at payload-render time
90
+ # (+messages.partition { |m| m.role == :system }+), so a conversation
91
+ # holding nothing but the system prompt renders +messages: []+ and the API
92
+ # 400s — see {EmptyTurnError}. Everywhere else +:system+ is an ordinary
93
+ # array element (OpenAI, Gemini) and that same conversation is a legal
94
+ # one-message request.
95
+ #
96
+ # Only +:system+ moves — a placement difference in the payload, not a
97
+ # rewrite of what pikuri appended, so the +:user+ blocks of
98
+ # {#append_reference_block} ride through untouched on every provider here.
99
+ SEPARATE_SYSTEM_FIELD_PROVIDERS = %w[anthropic bedrock].freeze
100
+ private_constant :SEPARATE_SYSTEM_FIELD_PROVIDERS
101
+
102
+ # A blank turn was requested against a conversation holding nothing but the
103
+ # assembled system prompt, on a provider that would render that as
104
+ # +messages: []+ (see {SEPARATE_SYSTEM_FIELD_PROVIDERS}):
105
+ #
106
+ # agent.run_loop(user_message: nil) # fresh chat, backend Anthropic
107
+ # # => EmptyTurnError; the host catches it and prompts for a real message
108
+ #
109
+ # Narrow by construction: every *injected* block lands as +role: :user+
110
+ # ({#append_reference_block}), so a skill-only or memory-only bare load has
111
+ # a message to complete against and never reaches here. What remains is a
112
+ # genuinely empty conversation.
113
+ #
114
+ # Content-neutral: {#provider} is all it carries — the host owns any
115
+ # user-facing wording.
116
+ class EmptyTurnError < StandardError
117
+ # @return [String] the resolved provider slug that can't complete the turn
118
+ attr_reader :provider
119
+
120
+ # @param provider [String] the resolved provider slug (e.g. +"anthropic"+)
121
+ def initialize(provider)
122
+ @provider = provider
123
+ super("#{provider} cannot complete a turn whose conversation holds only " \
124
+ ':system content (no user or assistant message): it would send an ' \
125
+ 'empty messages array. Add a user message.')
126
+ end
127
+ end
128
+
129
+ # What {#run_loop} answers a tool call with when its batch unwound — see
130
+ # +== Repairing an unwound tool batch+. Public so a renderer can tell one
131
+ # from a real observation, as {History::INTERRUPTED_RESULT} is for the
132
+ # on-disk shape.
133
+ #
134
+ # Paired per cause: the first unanswered call is the one that raised or was
135
+ # about to, the rest never started, and telling a skipped call "you failed"
136
+ # invites a pointless retry. A tool failure has no constant — it reports
137
+ # +"Error: <class>: <message>"+, the shape {Tool#run} already returns for a
138
+ # validation failure.
139
+ CANCELLED_RESULT = '[Cancelled by the user]'
140
+ # @see CANCELLED_RESULT
141
+ CANCELLED_SKIPPED_RESULT = '[Not executed — the user cancelled this turn]'
142
+ # @see CANCELLED_RESULT
143
+ STEP_BUDGET_RESULT = '[Not executed — step budget exhausted]'
144
+ # @see CANCELLED_RESULT
145
+ FAILED_SKIPPED_RESULT = '[Not executed — a previous tool call in this batch failed]'
146
+
147
+ # @param transport [ChatTransport] model-resolution bundle (+model+ /
148
+ # +provider+ / +assume_model_exists+ and, off the global server,
149
+ # +api_base+ / +api_key+). A +nil+ +transport.model+ is filled from
150
+ # +RubyLLM.config.default_model+.
151
+ # @param system_prompt [String] base system message; the chat sees
152
+ # this plus every extension's {Extension#system_prompt_snippets},
153
+ # assembled by {#assemble_system_prompt} at construction and on each
154
+ # {#clear_conversation}.
155
+ # @param step_limit [Control::StepLimit, nil] step budget, or +nil+ for
156
+ # no budget (unbounded looping). Its {Control::StepLimit#on_exhausted}
157
+ # policy drives {#run_loop} on trip (see the class header).
158
+ # @param cancellable [Control::Cancellable, nil] cancellation control,
159
+ # or +nil+ for none (no way to stop a running turn but killing the
160
+ # process).
161
+ # @param interloper [Control::Interloper, nil] mid-loop user-input queue
162
+ # drained once each tool batch is answered (each item becomes an
163
+ # {Event::UserTurn} with +mid_loop: true+), or +nil+ for none.
164
+ # @param id [String] unique id; empty for the main agent, persona-rooted
165
+ # for sub-agents (+"researcher 0"+, ...). Distinct from a persona's
166
+ # +name+ (the value the LLM picks in the +agent+ tool's +name:+ arg).
167
+ # @param streaming [Boolean] opt into chunk-level streaming — {#run_loop}
168
+ # passes a per-chunk block to +Chat#complete+ and normalizes chunks
169
+ # into {Event::ThinkingDelta}/{Event::AssistantDelta}. Default +false+
170
+ # (only the +after_message+ bookends fire).
171
+ # @yield [Configurator] the sole path for adding tools, listeners,
172
+ # extensions, and +on_close+ handlers — there are no parallel ctor
173
+ # kwargs. Optional; without it the agent has none of those.
151
174
  # @return [Agent]
152
175
  def initialize(transport:, system_prompt:,
153
176
  step_limit: nil, cancellable: nil, interloper: nil,
@@ -158,6 +181,11 @@ module Pikuri
158
181
  @cancellable = cancellable
159
182
  @closed = false
160
183
 
184
+ # Kept separate from the assembled +@system_prompt+ so
185
+ # {#assemble_system_prompt} can recompute (base + extensions'
186
+ # snippets) from scratch at construction and on each
187
+ # {#clear_conversation}, refreshing dynamic sections.
188
+ @system_prompt_base = system_prompt
161
189
  @system_prompt = system_prompt
162
190
  @step_limit = step_limit
163
191
  @interloper = interloper
@@ -165,127 +193,86 @@ module Pikuri
165
193
  @streaming = streaming
166
194
  @synth_answer = nil
167
195
  @on_close_handlers = []
168
- # Stashed for {#run_configure}, which runs the failure-prone
169
- # build phase below out of a separate method.
196
+ # Stashed for {#run_configure}, the failure-prone build phase.
170
197
  @block = block
171
198
 
172
- # Register *before* the build phase so a mid-construction raise
173
- # is still recoverable: extensions arm their cleanup via
174
- # +c.on_close+ (which writes straight to +@on_close_handlers+,
175
- # see {Configurator}), and the rescue below fires whatever was
176
- # armed before the failure. On the happy path this registration
177
- # is the at-exit backstop if the host forgets {#close}; an
178
- # explicit {#close} unregisters, so the agent isn't pinned alive
179
- # until process exit.
199
+ # Register *before* the build phase so a mid-construction raise is
200
+ # recoverable: extensions arm cleanup via +c.on_close+ (straight into
201
+ # +@on_close_handlers+), and the rescue below fires whatever was armed
202
+ # before the failure. On the happy path this is the at-exit backstop
203
+ # if the host forgets {#close}; an explicit {#close} unregisters.
180
204
  Pikuri::Finalizers.register(self)
181
205
 
182
206
  begin
183
207
  run_configure
184
208
  rescue StandardError
185
- # Half-built agent (e.g. an extension's +configure+ raised
186
- # Cancelled mid-spawn). Fire the handlers armed so far, drop
187
- # out of the registry, and re-raise — no partial state leaks.
209
+ # Half-built agent: fire the armed handlers, drop out of the
210
+ # registry, re-raise — no partial state leaks.
188
211
  close
189
212
  raise
190
213
  end
191
214
  end
192
215
 
193
- # @return [ChatTransport] the resolved transport bundle this
194
- # agent was constructed with — same model id / provider /
195
- # assume-model-exists flag passed to every +RubyLLM.chat+
196
- # call originating from this agent (the main chat, the
197
- # synthesizer rescue, the +agent+ tool from
198
- # +pikuri-subagents+). Read by extensions that need to spawn
199
- # their own ruby_llm calls (e.g. MCP description synthesis,
200
- # sub-agent delegation).
216
+ # @return [ChatTransport] the resolved transport this agent was built
217
+ # with — the model/provider/connection every +RubyLLM.chat+ from this
218
+ # agent uses (main chat, synthesizer, sub-agents).
201
219
  attr_reader :transport
202
220
 
203
- # @return [Array<Tool>] this agent's tool list in declaration
204
- # order. Read by extensions that filter against it (notably
205
- # the +agent+ tool from +pikuri-subagents+, which picks the
206
- # sub-agent's toolset from the parent's instances so any
207
- # already-bound workspace/confirmer wiring travels along).
208
- # Tools listed here are also the ones registered with
209
- # ruby_llm — the parent LLM can call any of them. Compare
210
- # with {#sub_agent_tools}.
221
+ # @return [Array<Tool>] tool list in declaration order. These are the
222
+ # tools registered with ruby_llm — the parent LLM can call any. Cf.
223
+ # {#sub_agent_tools}.
211
224
  attr_reader :tools
212
225
 
213
- # @return [Array<Tool>] tools registered via
214
- # {Configurator#add_sub_agent_tool}, in declaration order.
215
- # Invisible to the parent LLM (never sent to ruby_llm);
216
- # available only to sub-agents whose persona +tool_names+
217
- # match. See {Configurator}'s "Two tool pools" header for
218
- # the trifecta-defense rationale.
226
+ # @return [Array<Tool>] tools added via
227
+ # {Configurator#add_sub_agent_tool}, in declaration order. Never sent
228
+ # to ruby_llm (invisible to the parent LLM); available only to
229
+ # sub-agents whose persona +tool_names+ match — the trifecta defense,
230
+ # see {Configurator}.
219
231
  attr_reader :sub_agent_tools
220
232
 
221
233
  # @return [String] resolved model id from {#transport}.
222
- # Convenience delegator for callers that don't need the
223
- # full transport bundle.
224
234
  def model
225
235
  @transport.model
226
236
  end
227
237
 
228
- # @return [String] system prompt actually sent to the chat —
229
- # equal to the constructor's +system_prompt:+ argument plus
230
- # any snippets appended via {Configurator#append_system_prompt}
231
- # (extensions' +<available_skills>+ / +<available_mcps>+ /
232
- # +<available_agents>+, ...). Not inherited by sub-agents —
233
- # each persona owns its own system prompt verbatim.
238
+ # @return [String] system prompt actually sent — the base
239
+ # +system_prompt:+ plus every extension's
240
+ # {Extension#system_prompt_snippets}, re-assembled on
241
+ # {#clear_conversation} (so a dynamic section may differ from its
242
+ # construction value). Not inherited by sub-agents.
234
243
  attr_reader :system_prompt
235
244
 
236
- # @return [Control::StepLimit, nil] the step-budget control
237
- # this agent was constructed with, or +nil+ when none.
245
+ # @return [Control::StepLimit, nil] the step budget, or +nil+.
238
246
  attr_reader :step_limit
239
247
 
240
- # @return [Control::Cancellable, nil] the cancellation
241
- # control this agent was constructed with, or +nil+ when
242
- # none. Read by extensions that propagate cancellation to
243
- # their own LLM calls (e.g. the +agent+ tool from
244
- # +pikuri-subagents+ shares it with spawned sub-agents so
245
- # one Ctrl+C stops the tree).
248
+ # @return [Control::Cancellable, nil] the cancellation control, or
249
+ # +nil+. Extensions read it to propagate cancellation to their own
250
+ # LLM calls (the +agent+ tool shares it so one Ctrl+C stops the tree).
246
251
  attr_reader :cancellable
247
252
 
248
- # @return [Control::Interloper, nil] the mid-loop user-input
249
- # control this agent was constructed with, or +nil+ when
250
- # none.
253
+ # @return [Control::Interloper, nil] the mid-loop user-input control, or
254
+ # +nil+.
251
255
  attr_reader :interloper
252
256
 
253
- # @return [String] this agent's unique identifier — empty for
254
- # the main agent; for sub-agents, the persona-rooted id
255
- # assigned by the +agent+ tool from +pikuri-subagents+ (e.g.
256
- # +"researcher 0"+, +"researcher 1"+, +"file_miner 0"+).
257
- # Propagated to listeners via {ListenerList#for_sub_agent(id:)}
258
- # so id-aware ones can tag output. Distinct from the persona's
259
- # +name+ (the value the LLM picks in the +agent+ tool's
260
- # +name:+ argument).
257
+ # @return [String] this agent's id — empty for the main agent,
258
+ # persona-rooted for sub-agents (+"researcher 0"+). Distinct from the
259
+ # persona +name+; propagated to listeners via
260
+ # {ListenerList#for_sub_agent} so id-aware ones tag their output.
261
261
  attr_reader :id
262
262
 
263
- # @return [Boolean] +true+ when this agent opted into
264
- # chunk-level streaming (see the +streaming:+ kwarg on
265
- # {#initialize}); +false+ otherwise. Read by extensions that
266
- # spawn their own ruby_llm calls (notably the +agent+ tool
267
- # from +pikuri-subagents+, so spawned sub-agents inherit the
268
- # same mode).
263
+ # @return [Boolean] whether this agent opted into chunk-level streaming
264
+ # (see {#initialize}'s +streaming:+).
269
265
  attr_reader :streaming
270
266
 
271
- # @return [Array<Extension>] extension instances bound to this
272
- # agent — added via {Configurator#add_extension} inside the
273
- # +Agent.new+ block. Each instance's +configure+ runs during
274
- # the block and its +bind+ runs at the end of
275
- # {#initialize}, once per registration (so once per parent
276
- # agent in the typical setup; sub-agents do not inherit
277
- # extensions).
267
+ # @return [Array<Extension>] extensions bound to this agent. Each one's
268
+ # +configure+ ran during the +Agent.new+ block, its +bind+ at the end
269
+ # of {#initialize}. Not inherited by sub-agents.
278
270
  attr_reader :extensions
279
271
 
280
272
  # @return [Integer, nil] resolved context-window cap — the
281
- # {ChatTransport#context_window} if one was given, else what
282
- # {ContextWindowDetector} probed. +nil+ when neither produced
283
- # a value (a non-llama server with no explicit cap). Re-resolved
284
- # on every model switch (see {#run_loop}'s +transport:+). Read by
285
- # extensions that spawn their own ruby_llm calls (notably the
286
- # +agent+ tool from +pikuri-subagents+, which hands a sub-agent
287
- # +parent.transport.with(context_window: this)+ so the resolved
288
- # cap rides along without a re-probe).
273
+ # {ChatTransport#context_window} if given, else the
274
+ # {ContextWindowDetector} probe; +nil+ when neither yields one.
275
+ # Re-resolved on every model switch.
289
276
  attr_reader :context_window_cap
290
277
 
291
278
  # Final assistant message content for the most recent
@@ -302,85 +289,100 @@ module Pikuri
302
289
  last&.content
303
290
  end
304
291
 
305
- # Run the agent loop for a single user turn. Emits an
306
- # {Event::UserTurn} with +mid_loop: false+, resets the
307
- # step-budget and cancellation controls (so a stale state
308
- # from a prior turn doesn't poison this one), and forwards
309
- # +user_message+ to {#chat} via +ask+. Returns nil; rendering
310
- # and any other observable output is the listeners'
311
- # responsibility.
312
- #
313
- # If the +step_limit+ control trips during completion, the
314
- # rescue branch applies its {Control::StepLimit#on_exhausted}
315
- # policy: +:raise+ re-raises the +Exceeded+ exception to the
316
- # host (chat history intact — the next turn's +reset!+
317
- # refreshes the budget, so "continue" just works);
318
- # +:synthesize+ emits an {Event::FallbackNotice} and runs the
319
- # {Synthesizer} prompt on a nested tools-free agent, capturing
320
- # its answer for {#last_assistant_content}. See
321
- # "Step-exhaustion policy" in the class header.
322
- #
323
- # If the +cancellable+ control trips during +ask+, the rescue
324
- # branch emits an {Event::Cancelled} and re-raises the
325
- # +Cancelled+ exception. No synthesizer fallback runs — see
326
- # the "Cancellation rescue" section in the class header.
327
- #
328
- # Subsequent calls keep building on the same chat history, so
329
- # the model sees full multi-turn context.
292
+ # Run the agent loop for one turn. With a +user_message+, emits an
293
+ # {Event::UserTurn} (+mid_loop: false+) and appends it; with none, runs a
294
+ # *bare* turn against whatever the conversation + drained injections
295
+ # already hold — the case a host uses after loading a skill on its own
296
+ # initiative, so the agent reacts to it. Resets the step/cancellation
297
+ # controls, drains any pending {Control::Interloper} injections, then
298
+ # +complete+s the chat. Output is the listeners' job; subsequent calls
299
+ # build on the same history.
300
+ #
301
+ # A blank +user_message+ against a conversation holding nothing but the
302
+ # system prompt is provider-split: providers that can't complete it raise
303
+ # {EmptyTurnError}, the rest run the turn against that history. A blank turn
304
+ # *mid-conversation* always proceeds — real turns remain to answer, and a
305
+ # drained injection is itself one.
306
+ #
307
+ # Step-limit and cancellation trips are handled per the class header's
308
+ # "Step-exhaustion policy" / "Cancellation rescue".
330
309
  #
331
310
  # == Switching models mid-conversation
332
311
  #
333
- # Passing a +transport:+ that differs from the current one
334
- # switches the underlying chat to that model — via
335
- # +Chat#with_model+, so the history and the registered
336
- # callbacks survive — re-resolves the context-window cap, and
337
- # emits an {Event::ModelSwitched} followed by a fresh
338
- # {Event::ContextCap}. The switch is deliberately confined to
339
- # the top of this method (a private +apply_transport!+) rather
340
- # than exposed as a standalone setter: the chat is
341
- # single-thread-confined, so doing it here serializes the swap
342
- # with the turn on the loop's own thread — a background thread
343
- # mutating +with_model+'s connection mid-completion would tear
344
- # an in-flight stream. A +nil+ +transport:+ (the default) keeps
345
- # the current model. The conversation is *not* re-baselined: a
346
- # switch is the same conversation under a new model, so the
347
- # message count and running context size carry over (the next
348
- # turn's token report self-corrects to the new model's count).
349
- #
350
- # @param user_message [String] the user's request for this
351
- # turn; must not be +nil+, empty, or whitespace-only
352
- # @param transport [ChatTransport, nil] when non-+nil+ and
353
- # structurally different from the current transport, switch to
354
- # it before running the turn (see above); +nil+ keeps the
355
- # current model
356
- # @raise [ArgumentError] if +user_message+ is +nil+, empty,
357
- # or contains only whitespace — an empty turn would poison
358
- # the chat history and burn a step budget on nothing
359
- # @raise [Control::Cancellable::Cancelled] if the registered
360
- # {Control::Cancellable} was triggered during the turn;
361
- # the listener stream sees an {Event::Cancelled} first
362
- # @raise [Control::StepLimit::Exceeded] if the step budget
363
- # tripped and its policy is +:raise+ (the default)
364
- # @return [nil]
365
- def run_loop(user_message:, transport: nil)
366
- raise ArgumentError, "user_message must not be blank, got #{user_message.inspect}" \
367
- if user_message.nil? || user_message.to_s.strip.empty?
368
-
312
+ # A +transport:+ differing from the current one switches the chat to it
313
+ # (via {#apply_transport!}), re-resolves the cap, and emits
314
+ # {Event::ModelSwitched} + a fresh {Event::ContextCap}. The switch is
315
+ # confined to the top of this method because the chat is
316
+ # single-thread-confined — a background thread mutating the connection
317
+ # mid-completion would tear an in-flight stream. The conversation is
318
+ # *not* re-baselined: same conversation, new model, so message count and
319
+ # running context size carry over. +nil+ keeps the current model.
320
+ #
321
+ # == Repairing an unwound tool batch
322
+ #
323
+ # ruby_llm appends the assistant +tool_calls+ message *before* running any
324
+ # of them, so anything unwinding out of the batch — a tool raising, a
325
+ # {Control::Cancellable::Cancelled}, a tripped budget, an untrapped
326
+ # +Interrupt+ — leaves calls no +:tool+ message answers. OpenAI and
327
+ # Anthropic 400 on that; a local llama.cpp feeds it to the chat template
328
+ # and the model improvises. So an +ensure+ answers each dangling call
329
+ # ({#repair_dangling_tool_calls!}) and the exception then propagates: a
330
+ # host that keeps the conversation gets one it can still send.
331
+ #
332
+ # +ensure+ rather than a clause per +rescue+, because it also catches what
333
+ # nobody enumerated (+Interrupt+ is no +StandardError+) and it runs *after*
334
+ # the +:synthesize+ rescue body, leaving {Synthesizer.build_prompt} the
335
+ # unrepaired history its drop-calls-with-no-result pass expects.
336
+ #
337
+ # Unlike {History#repair_interrupted_tool_calls} — the load path, where a
338
+ # killed process leaves no clue and {History::INTERRUPTED_RESULT} has to be
339
+ # generic — the cause is in hand here and picks the wording.
340
+ #
341
+ # @param user_message [String, nil] the turn's request; +nil+/blank runs a
342
+ # bare turn (see above)
343
+ # @param transport [ChatTransport, nil] when non-+nil+ and structurally
344
+ # different, switch to it before the turn; +nil+ keeps the model
345
+ # @raise [Control::Cancellable::Cancelled] if cancelled mid-turn (an
346
+ # {Event::Cancelled} is emitted first)
347
+ # @raise [Control::StepLimit::Exceeded] if the budget tripped under the
348
+ # +:raise+ policy
349
+ # @raise [EmptyTurnError] on a blank turn against a prompt-only
350
+ # conversation when the provider carries +:system+ outside +messages+
351
+ # (see above)
352
+ # @return [nil] after a turn runs
353
+ def run_loop(user_message: nil, transport: nil)
369
354
  apply_transport!(transport) if transport
370
355
  @synth_answer = nil
371
356
  @step_limit&.reset!
372
357
  @cancellable&.reset!
373
- # Append the user turn, emit it, then run the memory dispatch — so
374
- # any <memory-context> the dispatch injects lands as a :system
375
- # message *after* the user turn it annotates (append-only at the
376
- # tail; see {#dispatch_ext_on_user_message}). `ask` would bundle the
377
- # user-message append with completion atomically, leaving no seam to
378
- # inject between them, so the two halves run explicitly here:
379
- # add_message + complete (the exact pair `ask` is sugar for). A raw
380
- # String content matches the interloper drain path.
381
- @chat.add_message(role: :user, content: user_message)
382
- @listeners.emit(Event::UserTurn.new(content: user_message, mid_loop: false))
383
- dispatch_ext_on_user_message(user_message)
358
+
359
+ # Deliver host-scheduled injections (e.g. a UI-loaded skill) before the
360
+ # first complete, so a freshly-scheduled block lands this turn rather
361
+ # than only after the next tool batch — ordered ahead of the user
362
+ # message appended below.
363
+ drain_interloper if @interloper
364
+
365
+ blank = user_message.nil? || user_message.to_s.strip.empty?
366
+ # These providers would send `messages: []` here (see EmptyTurnError).
367
+ # The drain above is what usually saves this case: an injected block is a
368
+ # :user message, so a skill-only bare load has something to complete
369
+ # against and falls through.
370
+ if blank && @chat.messages.all? { |m| m.role == :system } &&
371
+ SEPARATE_SYSTEM_FIELD_PROVIDERS.include?(@chat.model.provider.to_s)
372
+ raise EmptyTurnError, @chat.model.provider
373
+ end
374
+
375
+ unless blank
376
+ # Append the user turn, emit it, then run the memory dispatch — so any
377
+ # <memory-context> the dispatch injects lands as a :system message
378
+ # *after* the user turn it annotates. (+ask+ bundles append+complete
379
+ # atomically, leaving no seam to inject between; the halves run
380
+ # explicitly instead.)
381
+ track_message(@chat.add_message(role: :user, content: user_message))
382
+ @listeners.emit(Event::UserTurn.new(content: user_message, mid_loop: false))
383
+ dispatch_ext_on_user_message(user_message)
384
+ end
385
+
384
386
  if @streaming
385
387
  @chat.complete(&streaming_block)
386
388
  else
@@ -393,28 +395,154 @@ module Pikuri
393
395
  rescue Control::StepLimit::Exceeded => e
394
396
  raise unless @step_limit&.on_exhausted == :synthesize
395
397
 
398
+ # Ruby restores +$!+ once a rescue clause completes normally, so the
399
+ # +ensure+ below would see no cause for this one unwind. Assigned before
400
+ # the synth runs, so a synth that raises doesn't displace the real reason.
401
+ exhausted = e
396
402
  @synth_answer = Synthesizer.run_synthesizer(@extension_context, @chat.messages, user_message)
397
403
  nil
404
+ ensure
405
+ repair_dangling_tool_calls!(exhausted || $!)
398
406
  end
399
407
 
400
- # Release agent-owned resources. Fires every handler registered
401
- # via {Configurator#on_close} (during the +Agent.new+ block) and
402
- # {ExtensionContext#on_close} (during {Extension#bind} or any
403
- # later hook), in LIFO order — matches Ruby +ensure+-block semantics
404
- # so handlers registered later (which may depend on handlers
405
- # registered earlier) tear down first. Each handler runs inside
406
- # its own +rescue+; an exception is logged via
407
- # +Pikuri.logger_for+ but doesn't abort the rest. Idempotent —
408
- # subsequent calls are no-ops.
408
+ # Return the agent to a fresh-conversation state without tearing down
409
+ # its wiring — the host "/clear". Unlike {#close} (which fires the
410
+ # teardown handlers), everything stays alive; nothing is re-probed or
411
+ # re-connected. Resets, in order:
412
+ #
413
+ # 1. Chat history (+Chat#reset_messages!+, which also drops every injected
414
+ # reference block), then re-applies a freshly **re-assembled** prompt —
415
+ # {#assemble_system_prompt} re-pulls every extension's snippets, so
416
+ # dynamic sections refresh (a resident memory persona, a re-read
417
+ # +MACHINE.md+). Callbacks and tool registrations on the reused +@chat+
418
+ # are untouched.
419
+ # 2. Controls — step/cancellation reset, interloper queue drained (stale
420
+ # mid-loop input must not leak forward), cached synth answer dropped.
421
+ # 3. Listeners — a single {Event::Reset} so they reset display state.
422
+ # 4. Extension state — {Extension#on_conversation_reset} on each.
423
+ #
424
+ # Model/transport are left as-is (a clear is not a model switch). Runs on
425
+ # the agent's own thread; not designed to race an in-flight {#run_loop}.
426
+ #
427
+ # @return [void]
428
+ def clear_conversation
429
+ @chat.reset_messages!
430
+ @message_meta.clear
431
+ @system_prompt = assemble_system_prompt
432
+ @chat.with_instructions(@system_prompt)
433
+ @synth_answer = nil
434
+ @step_limit&.reset!
435
+ @cancellable&.reset!
436
+ @interloper&.drain!
437
+ @listeners.emit(Event::Reset.new)
438
+ @extensions.each { |ext| ext.on_conversation_reset(@extension_context) }
439
+ nil
440
+ end
441
+
442
+ # Snapshot the conversation as a {History} — the round-trip partner of
443
+ # {#load_history!}, and the supported way for a host to persist a
444
+ # conversation without reaching into the underlying chat.
445
+ #
446
+ # agent.export_history.save(dir) # a folder per conversation
447
+ # File.write(f, agent.export_history.to_json) # or one self-contained file
448
+ #
449
+ # The system prompt is excluded — {#load_history!} re-assembles it, so a
450
+ # resumed conversation gets today's, not the one that was current when it
451
+ # was saved.
452
+ #
453
+ # Attachment bytes are read here, eagerly, so the snapshot still shows
454
+ # what the model saw after the file has changed on disk.
455
+ #
456
+ # A thinking block withheld from the conversation because another model
457
+ # produced it is restored here from the message's metadata, so the record
458
+ # survives any number of load/save cycles under a switched model.
459
+ #
460
+ # @return [History]
461
+ def export_history
462
+ exported = @chat.messages.reject { |m| m.role == :system }.map do |msg|
463
+ meta = @message_meta[msg] || { id: History.mint_id, kind: 'user' }
464
+ exported_msg = History::Message.from_ruby_llm(msg, id: meta[:id], kind: meta[:kind])
465
+ meta[:thinking] ? exported_msg.with(thinking: meta[:thinking]) : exported_msg
466
+ end
467
+ History.new(messages: exported)
468
+ end
469
+
470
+ # Replace the conversation with a saved one, keeping every bit of wiring
471
+ # — tools, listeners, extensions, model — in place.
472
+ #
473
+ # agent.load_history!(History.load(dir))
474
+ #
475
+ # Runs {#clear_conversation} first, so controls reset and extensions see
476
+ # {Extension#on_conversation_reset} exactly as they would on a host
477
+ # "/clear"; the system prompt is re-assembled from current state. Then
478
+ # emits {Event::HistoryLoaded} — {Event::Reset} alone would tell listeners
479
+ # the conversation is empty, which it no longer is.
480
+ #
481
+ # Safe to call repeatedly on a long-lived agent: switching conversations
482
+ # in a shell is exactly this, once per switch, and nothing survives across
483
+ # one. Like the rest of {Agent}, it must not race a running {#run_loop}.
484
+ #
485
+ # == What is restored, and what is not
486
+ #
487
+ # Messages, tool calls and their results, thinking, attachments, per-message
488
+ # usage. **Not** state living outside the conversation: a workspace's
489
+ # read-before-edit marks, promoted skills, a task list. A resumed
490
+ # conversation whose transcript says a file was read will still be asked
491
+ # to read it again before editing — which is right, since the file may
492
+ # have changed while the conversation sat on disk.
493
+ #
494
+ # Two repairs happen on the way in, both because a stored conversation can
495
+ # describe something a provider will not accept:
496
+ #
497
+ # - A tool call whose result never arrived gets a synthesized one
498
+ # ({History#repair_interrupted_tool_calls}).
499
+ # - A turn produced by a *different* model loses its whole thinking block
500
+ # — a foreign one lands in a slot the receiving model reads as its own
501
+ # scratchpad. Withheld from the conversation only; {#export_history}
502
+ # still writes it. {#apply_transport!} repeats this for a switch that
503
+ # happens later. See +DECISIONS.md+ +D_thinking_block_replay+.
504
+ #
505
+ # @param history [History] from {#export_history}, {History.parse} or
506
+ # {History.load} — already validated by whichever door it came through.
507
+ # @return [Integer] how many messages were restored.
508
+ # @raise [ArgumentError] if given anything but a {History}; the parsing
509
+ # doors are where untrusted input belongs.
510
+ def load_history!(history)
511
+ unless history.is_a?(History)
512
+ raise ArgumentError, "expected a Pikuri::Agent::History, got #{history.class} — " \
513
+ 'use History.parse / History.load to build one from stored bytes'
514
+ end
515
+
516
+ clear_conversation
517
+ current_model = @chat.model&.id
518
+ repaired = history.repair_interrupted_tool_calls
519
+ repaired.messages.each do |m|
520
+ keep = keep_thinking?(m.thinking, m.model_id, current_model)
521
+ track_message(@chat.add_message(m.to_ruby_llm(keep_thinking: keep)),
522
+ kind: m.kind, id: m.id, thinking: (m.thinking unless keep))
523
+ end
524
+
525
+ last = repaired.messages.reverse.find { |m| m.role == 'assistant' && m.tokens }
526
+ @listeners.emit(Event::HistoryLoaded.new(
527
+ messages: repaired.messages.size,
528
+ tokens: last && Event::Tokens.new(**last.tokens.to_h, model_id: last.model_id)
529
+ ))
530
+ repaired.messages.size
531
+ end
532
+
533
+ # Release agent-owned resources: fire every handler registered via
534
+ # {Configurator#on_close} and {ExtensionContext#on_close} in LIFO order,
535
+ # so a handler that depends on an earlier one tears down first. Each runs
536
+ # in its own +rescue+ (an exception is logged, not propagated).
537
+ # Idempotent.
409
538
  #
410
539
  # @return [void]
411
540
  def close
412
541
  return if @closed
413
542
 
414
543
  @closed = true
415
- # Drop out of the process-global registry first: a deliberate
416
- # close means this agent no longer needs the at-exit fallback,
417
- # and removing the reference lets it be garbage-collected.
544
+ # Drop out of the registry first: a deliberate close no longer needs
545
+ # the at-exit fallback, and dropping the reference frees it for GC.
418
546
  Pikuri::Finalizers.unregister(self)
419
547
  @on_close_handlers.reverse_each do |handler|
420
548
  handler.call
@@ -435,17 +563,36 @@ module Pikuri
435
563
  "Agent(id=#{@id}, model=#{model}, tools=#{@tools.size}, listeners=#{@listeners})"
436
564
  end
437
565
 
566
+ # This agent's lethal-trifecta picture: which legs it holds, which its
567
+ # sub-agents hold, the verdict at every node.
568
+ #
569
+ # puts agent.trifecta.render # the map a bin script prints
570
+ # agent.trifecta.tree_verdict # => :soft
571
+ #
572
+ # Memoized and pure, so a UI may call it as often as it likes. Nothing
573
+ # reports this for you: an Agent computes the picture and stops, because a
574
+ # library writing to a log its embedder never asked for is noise. Surfacing
575
+ # it is the host's job — the bundled +bin/pikuri-*+ scripts print
576
+ # {Trifecta::Report#render} at boot.
577
+ #
578
+ # Valid only after construction (it reads the wired tool list), and it
579
+ # reflects the tools present *then*.
580
+ #
581
+ # @return [Trifecta::Report] root of the walked tree
582
+ def trifecta
583
+ @trifecta ||= Trifecta.walk(Trifecta.build(self))
584
+ end
585
+
438
586
  private
439
587
 
440
588
  # The failure-prone build phase, split out of {#initialize} so the
441
589
  # constructor can wrap it in a rescue and self-heal. Funnels the
442
- # +Agent.new+ block through a single {Configurator} — tools,
443
- # listeners, system-prompt snippets, extensions, and +on_close+
444
- # handlers — then wires the chat and runs the extension +bind+
445
- # sweep. The Configurator's +on_close_sink:+ is +@on_close_handlers+
446
- # itself, so a handler an extension arms via +c.on_close+ is live on
447
- # the agent the instant it's registered — that's what lets the
448
- # constructor's rescue close a half-built agent.
590
+ # +Agent.new+ block through one {Configurator} (tools, listeners,
591
+ # snippets, extensions, +on_close+), wires the chat, then runs the
592
+ # extension +bind+ sweep. The Configurator's +on_close_sink:+ is
593
+ # +@on_close_handlers+ itself, so a handler armed via +c.on_close+ is
594
+ # live the instant it's registered — what lets the rescue close a
595
+ # half-built agent.
449
596
  #
450
597
  # @return [void]
451
598
  def run_configure
@@ -465,25 +612,34 @@ module Pikuri
465
612
  @tools = configurator.tools.dup
466
613
  @sub_agent_tools = configurator.sub_agent_tools.dup
467
614
  @listeners = ListenerList.new(configurator.listeners)
468
- configurator.system_prompt_additions.each do |snippet|
469
- @system_prompt = "#{@system_prompt}\n\n#{snippet}"
470
- end
471
615
  @extensions = configurator.extensions.dup
616
+ # Assemble now that every extension's +configure+ has run (their
617
+ # snippets read populated state); re-runs on {#clear_conversation}.
618
+ @system_prompt = assemble_system_prompt
472
619
 
473
620
  @chat = build_chat(@transport)
474
621
  @chat.with_instructions(@system_prompt)
475
- @tools.each { |t| @chat.with_tool(t.to_ruby_llm_tool) }
476
-
477
- # Wire @chat for pikuri's event stream and controls — the
478
- # three message-level registered callbacks (+after_message+,
479
- # +before_tool_call+, +after_tool_result+). The per-chunk
480
- # streaming callback is separate because ruby_llm takes it as
481
- # a block to +Chat#complete+ rather than a registered hook —
482
- # see {#streaming_block}. Together with the +@listeners.emit+
483
- # calls in {#run_loop} / {#dispatch_ext_on_user_message} this
484
- # is the complete "which callback emits which event" map.
622
+ # One context per agent, so an opting-in +execute+ never has to nil-check
623
+ # what it was handed.
624
+ context = Tool::ExecuteContext.new(cancellable: @cancellable || Control::Cancellable::NEVER)
625
+ @tools.each { |t| @chat.with_tool(t.to_ruby_llm_tool(context: context)) }
626
+
627
+ # Wire @chat for the event stream and controls via ruby_llm's three
628
+ # registered callbacks. (The per-chunk streaming callback is separate
629
+ # — {#streaming_block} — since ruby_llm takes it as a block to
630
+ # +Chat#complete+, not a registered hook.) With the +@listeners.emit+
631
+ # calls in {#run_loop}/{#dispatch_ext_on_user_message}, this is the
632
+ # complete callback→event map.
633
+ # Identity-keyed, so nothing can drift out of alignment with
634
+ # +@chat.messages+ the way a parallel array would: a message pikuri
635
+ # never saw is simply absent here, and {#export_history} mints it an id
636
+ # rather than silently pairing it with someone else's.
637
+ @message_meta = {}.compare_by_identity
638
+
485
639
  @chat.after_message do |msg|
640
+ track_message(msg)
486
641
  emit_after_message(msg)
642
+ drain_interloper if @interloper && tool_batch_complete?(msg)
487
643
  end
488
644
  @chat.before_tool_call do |tc|
489
645
  @listeners.emit(Event::ToolCall.new(name: tc.name, arguments: tc.arguments))
@@ -492,54 +648,61 @@ module Pikuri
492
648
  end
493
649
  @chat.after_tool_result do |result|
494
650
  @listeners.emit(Event::ToolResult.new(content: result))
495
- drain_interloper if @interloper
496
651
  end
497
652
 
498
- # Context-window cap: lets every listener that cares (notably
499
- # TokenLog) pick the value off the stream before any Tokens
500
- # event arrives. Re-fires on each model switch (see
501
- # {#apply_transport!}).
653
+ # Context-window cap on the stream before any Tokens event; re-fires
654
+ # on each model switch (see {#apply_transport!}).
502
655
  detect_and_emit_context_cap!
503
656
 
504
- # The runtime capability facade — constructed once, after the
505
- # chat and listener list are final, and handed to every
506
- # extension's #bind / #on_user_message. The ONLY object that
507
- # grants emission / raw-tool-registration / close-handler
508
- # capabilities; the Agent itself exposes no public path to
509
- # them. See {ExtensionContext}.
657
+ # The runtime capability facade — the ONLY object granting
658
+ # emission / raw-tool / close-handler capabilities; the Agent exposes
659
+ # no public path to them. Handed to every extension's #bind /
660
+ # #on_user_message. See {ExtensionContext}.
510
661
  @extension_context = ExtensionContext.new(
511
662
  agent: self, chat: @chat, listeners: @listeners,
512
663
  on_close_sink: @on_close_handlers
513
664
  )
514
665
 
515
- # Bind sweep — each extension gets its chance to install
516
- # per-agent state (dynamic tools via
517
- # {ExtensionContext#add_raw_tool}, per-agent close hooks via
518
- # {ExtensionContext#on_close}, domain-event wiring via
519
- # {ExtensionContext#emit_event}, etc.) now that the chat is
520
- # fully wired. See IDEAS.md §"Extension protocol design" for
521
- # what #configure vs #bind are each for.
666
+ # Bind sweep — each extension installs per-agent state now the chat is
667
+ # fully wired (dynamic tools, close hooks, domain-event wiring). See
668
+ # {Extension} for #configure vs #bind.
522
669
  @extensions.each { |ext| ext.bind(@extension_context) }
523
670
  end
524
671
 
525
- # Resolve the context-window cap and announce it on the listener
526
- # stream as an {Event::ContextCap}. The transport's explicit
527
- # +context_window+ wins verbatim; otherwise {ContextWindowDetector}
528
- # probes the server (yielding +nil+ for a non-llama one). Because the
529
- # cap rides {ChatTransport}, a model switch resolves the *new*
530
- # transport's cap — explicit caps don't bleed across models. Shared
531
- # by {#run_configure} (construction) and {#apply_transport!} (each
532
- # model switch) so "how the cap is resolved and emitted" lives in
533
- # one place.
672
+ # Build the full prompt: base plus every extension's
673
+ # {Extension#system_prompt_snippets}, in registration order, joined by
674
+ # blank lines. Blank/nil sections drop (an extension contributing
675
+ # nothing leaves no stray separator). Pure function of the base + the
676
+ # extensions' current state, so it serves both construction and
677
+ # {#clear_conversation} (the latter refreshing dynamic sections).
678
+ #
679
+ # Those two call sites are the whole list, and that is a *price* rather
680
+ # than a missing feature: a stable prefix is free after turn one
681
+ # (llama.cpp slot prefix-reuse, Anthropic's prompt cache), where amending
682
+ # one invalidates every cached token after the change point and
683
+ # re-prefills. Hence there is no amend-the-prompt seam — mid-conversation
684
+ # blocks go through {#append_reference_block}, which appends and
685
+ # invalidates nothing. See `DECISIONS.md` `D_reference_text_as_user_turn`.
686
+ #
687
+ # @return [String]
688
+ def assemble_system_prompt
689
+ sections = @extensions.flat_map(&:system_prompt_snippets)
690
+ [@system_prompt_base, *sections]
691
+ .reject { |s| s.nil? || s.to_s.strip.empty? }
692
+ .join("\n\n")
693
+ end
694
+
695
+ # Resolve the context-window cap and announce it as an
696
+ # {Event::ContextCap}. The transport's explicit +context_window+ wins;
697
+ # else {ContextWindowDetector} probes the server (+nil+ for a non-llama
698
+ # one). The cap rides {ChatTransport}, so a switch resolves the *new*
699
+ # transport's cap. Shared by {#run_configure} and {#apply_transport!}.
534
700
  #
535
701
  # @return [void]
536
702
  def detect_and_emit_context_cap!
537
- # Probe the server this transport actually targets — after a
538
- # cross-server switch the chat's connection points at
539
- # +@transport.api_base+, but the process-global config the
540
- # detector defaults to still names the *old* server, so derive the
541
- # base from the transport (falling back to the global base for a
542
- # transport that rides it).
703
+ # Probe the server this transport targets: after a cross-server switch
704
+ # the chat points at +@transport.api_base+, but the global config still
705
+ # names the old server — so derive the base from the transport.
543
706
  @context_window_cap = @transport.context_window ||
544
707
  ContextWindowDetector.detect(
545
708
  @transport,
@@ -548,34 +711,27 @@ module Pikuri
548
711
  @listeners.emit(Event::ContextCap.new(cap: @context_window_cap))
549
712
  end
550
713
 
551
- # Switch the underlying chat to +transport+ when it differs from
552
- # the current one. Called only from the top of {#run_loop} — see
553
- # that method's "Switching models mid-conversation" section for why
554
- # the swap is confined to the loop's own thread.
555
- #
556
- # Mirrors {#initialize}'s +nil+-model fill before the structural
557
- # comparison, so a +transport+ that defers its model to the default
558
- # doesn't read as "different" and switch spuriously.
559
- # +Chat#with_model+ swaps only the model / provider / connection,
560
- # leaving +@chat+'s message history and registered callbacks
561
- # intact, so the conversation continues seamlessly under the new
562
- # model. Emits {Event::ModelSwitched} (the narration: old → new
563
- # transport, unformatted, for the chrome to present) then re-resolves
564
- # and re-emits the cap via {#detect_and_emit_context_cap!}.
714
+ # Switch the chat to +transport+ when it differs from the current one.
715
+ # Called only from the top of {#run_loop} (see its "Switching models"
716
+ # section for the thread-confinement reason). Mirrors {#initialize}'s
717
+ # +nil+-model fill before the structural compare, so a transport
718
+ # deferring to the default model doesn't switch spuriously.
719
+ # +Chat#with_model+ keeps history and callbacks. Emits
720
+ # {Event::ModelSwitched}, then re-resolves and re-emits the cap.
721
+ #
722
+ # Because the history is kept, every turn in it is now potentially from
723
+ # another model, so {#strip_foreign_thinking!} runs before the event —
724
+ # {#load_history!}'s repair only covers a conversation that arrives stale.
565
725
  #
566
726
  # == Cross-server switches
567
727
  #
568
- # +with_model+ alone re-resolves against the chat's *existing*
569
- # connection, so it can only move between models on one server. When
570
- # either the new or the old transport overrides the connection
571
- # (+ChatTransport#connection_overrides?+ — a different +api_base+ /
572
- # +api_key+), the swap first installs a fresh +RubyLLM::Context+ via
573
- # +Chat#with_context+ (which re-points the connection and re-resolves
574
- # the model in place, again preserving history + callbacks) before
575
- # +with_model+ lands the new model id. The "old overrode" half of
576
- # the guard handles switching *back* to a global-config model: the
577
- # rebuilt context dups the process-global config, resetting the
578
- # connection the previous override installed.
728
+ # +with_model+ re-resolves against the chat's existing connection, so it
729
+ # only moves between models on one server. When either transport
730
+ # overrides the connection (+ChatTransport#connection_overrides?+), a
731
+ # fresh +RubyLLM::Context+ is installed via +Chat#with_context+ first
732
+ # (re-pointing the connection, preserving history + callbacks). The "old
733
+ # overrode" half handles switching *back* to a global-config model: the
734
+ # rebuilt context dups the global config, resetting the prior override.
579
735
  #
580
736
  # @param transport [ChatTransport] the model to switch to
581
737
  # @return [void]
@@ -587,14 +743,14 @@ module Pikuri
587
743
  @chat.with_context(build_context(filled)) if filled.connection_overrides? || old.connection_overrides?
588
744
  @chat.with_model(filled.model, provider: filled.provider, assume_exists: filled.assume_model_exists)
589
745
  @transport = filled
746
+ strip_foreign_thinking!
590
747
  @listeners.emit(Event::ModelSwitched.new(from: old, to: filled))
591
748
  detect_and_emit_context_cap!
592
749
  end
593
750
 
594
- # Build the chat for +transport+: through a dedicated
595
- # +RubyLLM::Context+ when it overrides the connection, else through
596
- # the process-global +RubyLLM.chat+ (which the construction-time
597
- # path has always used).
751
+ # Build the chat for +transport+: through a dedicated +RubyLLM::Context+
752
+ # when it overrides the connection, else through the process-global
753
+ # +RubyLLM.chat+.
598
754
  #
599
755
  # @param transport [ChatTransport]
600
756
  # @return [RubyLLM::Chat]
@@ -606,14 +762,18 @@ module Pikuri
606
762
  end
607
763
  end
608
764
 
609
- # A +RubyLLM::Context+ carrying +transport+'s connection overrides
610
- # mapped onto the provider's ruby_llm config slots
611
- # (+#{provider}_api_base+ / +#{provider}_api_key+). +RubyLLM.context+
612
- # dups the process-global config, so an absent override inherits the
613
- # global value; a transport with no overrides yields a plain dup
614
- # (used by {#apply_transport!} to reset a prior override). The
615
- # +ChatTransport+ guarantees a non-+nil+ +provider+ whenever an
616
- # override is set, so the slot name is always resolvable.
765
+ # A +RubyLLM::Context+ carrying +transport+'s connection overrides on the
766
+ # provider's config slots (+#{provider}_api_base+ / +_api_key+).
767
+ # +RubyLLM.context+ dups the global config, so an absent override inherits
768
+ # the global value and a no-override transport yields a plain dup (used by
769
+ # {#apply_transport!} to reset a prior override). +ChatTransport+
770
+ # guarantees a non-+nil+ +provider+ whenever an override is set.
771
+ #
772
+ # A transport's +faraday_adapter+ (if any) is installed here too: the
773
+ # adapter rides *this* context's config, not the process-global
774
+ # +RubyLLM.config+, so it stays per-agent — the seam
775
+ # {Pikuri::Testing.fake_transport} uses to run the real ruby_llm loop
776
+ # against a scripted HTTP backend.
617
777
  #
618
778
  # @param transport [ChatTransport]
619
779
  # @return [RubyLLM::Context]
@@ -622,33 +782,24 @@ module Pikuri
622
782
  RubyLLM.context do |c|
623
783
  c.public_send("#{slug}_api_base=", transport.api_base) unless transport.api_base.nil?
624
784
  c.public_send("#{slug}_api_key=", transport.api_key) unless transport.api_key.nil?
785
+ c.faraday_adapter = transport.faraday_adapter if transport.faraday_adapter
786
+ # ruby_llm defaults an OpenAI-family `:system` message to the newer
787
+ # `developer` role. OpenAI treats the two alike, but a local
788
+ # llama.cpp hands the role straight to the model's chat template,
789
+ # and a Qwen/Llama template has never seen `developer` — the system
790
+ # prompt then arrives under a role token the model was not trained
791
+ # on. pikuri targets local servers first, so ask for `system`.
792
+ c.openai_use_system_role = true if c.respond_to?(:openai_use_system_role=)
625
793
  end
626
794
  end
627
795
 
628
- # Fire the per-turn {Extension#on_user_message} hook on every
629
- # extension that defines it, appending any returned
630
- # String text block to the chat as a +role: :system+
631
- # message right after the user turn it annotates (callers append
632
- # the +:user+ message first; this runs last). The system role is
633
- # load-bearing — it tags the block as recalled reference (not new
634
- # input) and keeps it excludable from a later extraction pass.
635
- # See {Extension#on_user_message}.
636
- #
637
- # Each injected block also emits an {Event::SystemInjected} at
638
- # this site, so the listener stream mirrors the log growth (the
639
- # Terminal renders it; otherwise an injection would be invisible
640
- # except as a downstream echo in the assistant's reasoning).
641
- #
642
- # Private and the single place the chat log grows by a memory
643
- # block — keeps "what mutates the log, when" one grep in this
644
- # file. Fired from {#run_loop} (initial turn) and from
645
- # {#drain_interloper} (mid-loop interlopers). Called on every
646
- # extension unconditionally — same as {Extension#configure} /
647
- # {Extension#bind}: the hook is part of the protocol and the
648
- # {Extension} module supplies a no-op default, so any extension
649
- # that includes the module responds. An extension is "opted out"
650
- # by leaving the default in place (it returns +nil+, injecting
651
- # nothing), not by omitting the method.
796
+ # Fire {Extension#on_user_message} on every extension, appending any
797
+ # returned text via {#append_reference_block} right after the user turn it
798
+ # annotates (the caller appends the +:user+ message first).
799
+ #
800
+ # Called unconditionally — the {Extension} no-op default returns +nil+
801
+ # (injects nothing), so opting out means leaving the default, not omitting
802
+ # the method.
652
803
  #
653
804
  # @param content [String] the incoming user message
654
805
  # @return [void]
@@ -657,40 +808,209 @@ module Pikuri
657
808
  message = ext.on_user_message(@extension_context, content)
658
809
  next unless message.is_a?(String) && !message.strip.empty?
659
810
 
660
- block = message.strip
661
- @chat.add_message(role: :system, content: block)
662
- @listeners.emit(Event::SystemInjected.new(content: block))
811
+ append_reference_block(message.strip)
812
+ end
813
+ nil
814
+ end
815
+
816
+ # Append host-originated reference text — a memory block, a loaded skill, a
817
+ # path-activation promotion, retrieved context — and emit
818
+ # {Event::SystemInjected}. Lands as a +role: :user+ message wrapped in
819
+ # +<system-reminder>+:
820
+ #
821
+ # append_reference_block('<memory-context>…</memory-context>')
822
+ # # the chat log grows by one :user message:
823
+ # # <system-reminder>
824
+ # # <memory-context>…</memory-context>
825
+ # # </system-reminder>
826
+ #
827
+ # The event carries the block *unwrapped* — the envelope is wire plumbing,
828
+ # and a listener rendering the tag would be showing the reader framing they
829
+ # did not write.
830
+ #
831
+ # == Implementation details
832
+ #
833
+ # +:user+ rather than +:system+ because a mid-conversation +:system+ message
834
+ # is not portable: Qwen3.6's chat template silently drops every one past the
835
+ # leading header, and providers carrying +:system+ in a separate field
836
+ # (Anthropic, Bedrock — see {SEPARATE_SYSTEM_FIELD_PROVIDERS}) merge it into
837
+ # the standing system prompt, so the block is presented as though it had
838
+ # always been there and stays in force. Neither failure is visible from
839
+ # anything pikuri can observe: the message is in the payload, the listener
840
+ # stream renders it, the history holds it. See +DECISIONS.md+
841
+ # +D_reference_text_as_user_turn+.
842
+ #
843
+ # A closing tag inside +content+ is escaped, so injected text (a file, a web
844
+ # page, an MCP tool's description) cannot close the envelope early and have
845
+ # what follows read as conversation.
846
+ #
847
+ # @param content [String] the block to inject, already stripped; callers
848
+ # with their own semantic tag (+<memory-context>+) keep it — the envelope
849
+ # nests around it
850
+ # @return [void]
851
+ def append_reference_block(content)
852
+ close = "</#{REFERENCE_TAG}>"
853
+ # Block form, not a replacement String: gsub reads `\&`, `\1` etc. out of
854
+ # the latter, so a tag carrying one would be mangled rather than escaped.
855
+ body = content.gsub(close) { "<\\/#{REFERENCE_TAG}>" }
856
+ # Tagged +reference+ so a restored conversation can tell pikuri's own
857
+ # injection from something the human typed — both land as +:user+.
858
+ track_message(@chat.add_message(role: :user, content: "<#{REFERENCE_TAG}>\n#{body}\n#{close}"),
859
+ kind: 'reference')
860
+ @listeners.emit(Event::SystemInjected.new(content: content))
861
+ nil
862
+ end
863
+
864
+ # Remember pikuri's own identity for a message just added to the chat, so
865
+ # {#export_history} can hand out a stable id, say whether a +:user+ message
866
+ # was typed or injected, and put back a thinking block the conversation is
867
+ # not replaying. None of it ever reaches a provider.
868
+ #
869
+ # Keyed on the message *object*, so a caller that rebuilds one must move
870
+ # its entry across — see {#strip_foreign_thinking!}.
871
+ #
872
+ # @param msg [RubyLLM::Message] as returned by +Chat#add_message+.
873
+ # @param kind [String] one of {History::KINDS}.
874
+ # @param id [String, nil] an existing id to preserve (restoring a saved
875
+ # conversation); +nil+ mints a fresh one.
876
+ # @param thinking [History::Thinking, nil] a block withheld from +msg+
877
+ # because another model produced it, kept so the export stays faithful.
878
+ # @return [RubyLLM::Message] +msg+, so call sites can wrap in place.
879
+ def track_message(msg, kind: 'user', id: nil, thinking: nil)
880
+ unless msg.role == :system
881
+ @message_meta[msg] = { id: id || History.mint_id, kind: kind, thinking: thinking }
882
+ end
883
+ msg
884
+ end
885
+
886
+ # Whether a turn's thinking may be replayed to +current+ — true only where
887
+ # this model demonstrably produced it.
888
+ #
889
+ # Unknown provenance counts as foreign: a +model_id+ nobody recorded is
890
+ # indistinguishable from another model's, and attributing one model's
891
+ # reasoning to another is the failure this guards against.
892
+ #
893
+ # The two ids come from different places — +current+ is what pikuri
894
+ # *requested*, +model_id+ what the server *reported* — so a backend that
895
+ # does not echo the requested id back makes every turn look foreign. That
896
+ # only ever over-drops (a model loses its own reasoning; none is ever
897
+ # attributed to another), which is why the weaker test is tolerated; the
898
+ # fakes in +Pikuri::Testing+ are themselves such a backend. Measured
899
+ # echoing verbatim on llama.cpp — see +DECISIONS.md+
900
+ # +D_thinking_block_replay+.
901
+ #
902
+ # @param thinking [History::Thinking, RubyLLM::Thinking, nil]
903
+ # @param model_id [String, nil] who produced the turn.
904
+ # @param current [String, nil] the agent's model now.
905
+ # @return [Boolean]
906
+ def keep_thinking?(thinking, model_id, current)
907
+ thinking.nil? || (!model_id.nil? && model_id == current)
908
+ end
909
+
910
+ # Withhold every thinking block the current model did not produce from the
911
+ # live conversation, parking it in +@message_meta+ so {#export_history}
912
+ # still writes it.
913
+ #
914
+ # {#load_history!} covers a conversation that arrives stale; this covers
915
+ # one that goes stale under a running agent, which +Chat#with_model+ leaves
916
+ # untouched. +RubyLLM::Message+ has no thinking writer, so a stripped turn
917
+ # is a rebuilt object and its metadata is re-keyed onto the replacement.
918
+ #
919
+ # @return [void]
920
+ def strip_foreign_thinking!
921
+ current = @chat.model&.id
922
+ @chat.messages.map! do |msg|
923
+ next msg if keep_thinking?(msg.thinking, msg.model_id, current)
924
+
925
+ meta = @message_meta.delete(msg) || { id: History.mint_id, kind: 'user' }
926
+ stripped = RubyLLM::Message.new(
927
+ role: msg.role, content: msg.content, tool_calls: msg.tool_calls,
928
+ tool_call_id: msg.tool_call_id, model_id: msg.model_id,
929
+ tokens: msg.tokens, raw: msg.raw, thinking: nil
930
+ )
931
+ @message_meta[stripped] = meta.merge(
932
+ thinking: History::Thinking.new(text: msg.thinking.text, signature: msg.thinking.signature)
933
+ )
934
+ stripped
935
+ end
936
+ end
937
+
938
+ # Answer every call the last assistant turn left unanswered, emitting each
939
+ # synthesized observation as an {Event::ToolResult} so a host can show what
940
+ # it appended. Rationale in {#run_loop}'s +== Repairing an unwound tool
941
+ # batch+.
942
+ #
943
+ # Order carries the first-vs-rest split ({CANCELLED_RESULT}): +tool_calls+
944
+ # is insertion-ordered and ruby_llm runs it with +each_value+, so the first
945
+ # gap is the culprit — true only while +tool_concurrency+ stays off, which
946
+ # {Pikuri::Tool}'s +== Sharing+ asks for on separate grounds.
947
+ #
948
+ # Not {History#repair_interrupted_tool_calls}: damage here is always the
949
+ # tail, and that walk is over +History::Message+, whose +role+ is a +String+
950
+ # where ruby_llm's is a +Symbol+.
951
+ #
952
+ # @param cause [Exception, nil] what the turn is unwinding with; +nil+ on
953
+ # the success path, where nothing dangles.
954
+ # @return [void]
955
+ # @raise [RuntimeError] if calls dangle with no +cause+ — pikuri lost a
956
+ # batch on a turn that succeeded. Safe to raise from an +ensure+ here
957
+ # precisely because there is no in-flight exception to clobber.
958
+ def repair_dangling_tool_calls!(cause)
959
+ messages = @chat.messages
960
+ opener_index = messages.rindex { |m| m.role == :assistant && m.tool_calls&.any? }
961
+ return if opener_index.nil?
962
+
963
+ answered = messages[(opener_index + 1)..].filter_map { |m| m.tool_call_id if m.role == :tool }
964
+ dangling = messages[opener_index].tool_calls.each_value.reject { |c| answered.include?(c.id) }
965
+ return if dangling.empty?
966
+
967
+ if cause.nil?
968
+ raise 'unanswered tool calls on a turn that did not unwind: ' \
969
+ "#{dangling.map { |c| "#{c.name}(#{c.id})" }.join(', ')}"
970
+ end
971
+
972
+ culprit, skipped = unanswered_results(cause)
973
+ dangling.each_with_index do |call, i|
974
+ content = i.zero? ? culprit : skipped
975
+ track_message(@chat.add_message(role: :tool, content: content, tool_call_id: call.id))
976
+ @listeners.emit(Event::ToolResult.new(content: content))
663
977
  end
978
+ LOGGER.warn { "answered #{dangling.size} dangling tool call(s) after #{cause.class}" }
664
979
  nil
665
980
  end
666
981
 
667
- # Build the per-chunk streaming block passed to +Chat#complete+.
668
- # Each invocation of the returned proc converts one
669
- # +RubyLLM::Chunk+ into zero, one, or two delta events
670
- # ({Event::ThinkingDelta} / {Event::AssistantDelta}) on
671
- # +@listeners+. Tool-call chunks are intentionally ignored —
672
- # partial JSON has no useful rendering; the assembled
673
- # +tool_calls+ surface through {Event::ToolCall} once the
674
- # message completes.
982
+ # @param cause [Exception]
983
+ # @return [Array(String, String)] what the call that raised (or was about
984
+ # to) is told, then what the calls behind it that never started are told.
985
+ # See {CANCELLED_RESULT}.
986
+ def unanswered_results(cause)
987
+ case cause
988
+ when Control::Cancellable::Cancelled then [CANCELLED_RESULT, CANCELLED_SKIPPED_RESULT]
989
+ # An exhausted budget singles no call out.
990
+ when Control::StepLimit::Exceeded then [STEP_BUDGET_RESULT, STEP_BUDGET_RESULT]
991
+ # Deliberately unsanitized: the message can carry untrusted bytes (a
992
+ # filename off a scraped page), which {Tool#run}'s own +"Error: ..."+
993
+ # already puts in the conversation — and a model cannot react to a
994
+ # failure it is not told about.
995
+ else ["Error: #{cause.class}: #{cause.message}", FAILED_SKIPPED_RESULT]
996
+ end
997
+ end
998
+
999
+ # Build the per-chunk block passed to +Chat#complete+. Each call converts
1000
+ # one +RubyLLM::Chunk+ into zero, one, or two delta events
1001
+ # ({Event::ThinkingDelta}/{Event::AssistantDelta}). Tool-call chunks are
1002
+ # ignored — partial JSON has no useful rendering; the assembled call
1003
+ # surfaces via {Event::ToolCall} once the message completes.
675
1004
  #
676
1005
  # == Cancellation polling
677
1006
  #
678
- # When +@cancellable+ is non-nil, {Control::Cancellable#check!}
679
- # fires *before* each chunk's emit. The +before_tool_call+
680
- # wiring in {#run_configure} only fires when the model requests a
681
- # tool, which leaves a no-tool turn (e.g. a plain greeting)
682
- # with zero cancellation points — Ctrl+C trips the flag but
683
- # nothing reads it. Polling on every streamed chunk closes
684
- # that gap: an in-flight Cancellation+check! raises on the
685
- # next chunk delivered after the flag flips, the exception
686
- # propagates out through ruby_llm's streaming path
687
- # (+Chat#complete+ doesn't rescue), and {#run_loop} catches it,
688
- # emits {Event::Cancelled}, and re-raises. The pre-emit
689
- # ordering is deliberate: a chunk that arrives after a cancel
690
- # request shouldn't render — the user has said stop.
691
- #
692
- # @return [Proc] a +-> (chunk) { ... }+ proc suitable for
693
- # passing to +Chat#complete+ with +&+
1007
+ # When +@cancellable+ is set, {Control::Cancellable#check!} fires *before*
1008
+ # each chunk's emit. The +before_tool_call+ wiring only fires on a tool
1009
+ # request, so a no-tool turn (a plain greeting) has zero cancellation
1010
+ # points; polling per chunk closes that gap. Pre-emit ordering is
1011
+ # deliberate — a chunk arriving after a cancel request shouldn't render.
1012
+ #
1013
+ # @return [Proc] a +->(chunk) { ... }+ for +Chat#complete+
694
1014
  def streaming_block
695
1015
  ->(chunk) {
696
1016
  @cancellable&.check!
@@ -698,21 +1018,39 @@ module Pikuri
698
1018
  }
699
1019
  end
700
1020
 
701
- # Normalize a +RubyLLM::Chat+ +after_message+ payload into
702
- # zero, one, or two {Event} variants (+Thinking+ and/or
703
- # +Assistant+) plus one {Event::Tokens} for the usage block.
704
- # Empty thinking / empty content are filtered here so
705
- # listeners never see vacuous events. Non-assistant roles
706
- # (e.g. tool-role messages echoed back through
707
- # +after_message+) are skipped entirely.
708
- #
709
- # +msg+ is a +RubyLLM::Message+. Beyond +role+, +content+,
710
- # +thinking+, and the +*_tokens+ accessors used here, it also
711
- # carries +msg.tool_calls+ on assistant turns that requested
712
- # one and +msg.raw+ for the unparsed provider payload.
1021
+ # Normalize an +after_message+ payload into zero, one, or two {Event}s
1022
+ # (+Thinking+ and/or +Assistant+) plus one {Event::Tokens}. Empty
1023
+ # thinking/content are filtered here so listeners never see vacuous
1024
+ # events; non-assistant roles are skipped.
713
1025
  #
714
1026
  # @param msg [RubyLLM::Message]
715
1027
  # @return [void]
1028
+ # Whether +msg+ closed a tool batch — the only mid-turn moment an injected
1029
+ # message may be appended.
1030
+ #
1031
+ # An assistant message carrying +tool_calls+ and the +:tool+ messages
1032
+ # answering it are one indivisible unit on the wire: OpenAI rejects a
1033
+ # +tool+ message that does not follow its call, Anthropic requires the
1034
+ # results to open the very next message, and a local chat template groups
1035
+ # them into one block. Anything appended between the two halves is
1036
+ # therefore either an error or, worse, silently mangled prose — which is
1037
+ # what draining at ruby_llm's +after_tool_result+ did, since that fires
1038
+ # *before* the result message is added.
1039
+ #
1040
+ # @param msg [RubyLLM::Message]
1041
+ # @return [Boolean] true when +msg+ is the last tool result its assistant
1042
+ # message was waiting for (so parallel calls drain once, at the end)
1043
+ def tool_batch_complete?(msg)
1044
+ return false unless msg.role == :tool
1045
+
1046
+ messages = @chat.messages
1047
+ opener_index = messages.rindex { |m| m.role == :assistant && m.tool_calls&.any? }
1048
+ return true if opener_index.nil?
1049
+
1050
+ answered = messages[(opener_index + 1)..].filter_map { |m| m.tool_call_id if m.role == :tool }
1051
+ messages[opener_index].tool_calls.each_key.all? { |id| answered.include?(id) }
1052
+ end
1053
+
716
1054
  def emit_after_message(msg)
717
1055
  return unless msg.role == :assistant
718
1056
 
@@ -732,16 +1070,9 @@ module Pikuri
732
1070
  ))
733
1071
  end
734
1072
 
735
- # Normalize a +RubyLLM::Chunk+ from a streaming completion
736
- # into zero, one, or two delta events
737
- # ({Event::ThinkingDelta} / {Event::AssistantDelta}). Empty
738
- # +thinking.text+ and empty +content+ are filtered here so
739
- # listeners never see vacuous fragments. Tool-call deltas are
740
- # intentionally skipped — see {#streaming_block}.
741
- #
742
- # +chunk+ is a +RubyLLM::Chunk+ (subclass of +RubyLLM::Message+),
743
- # so the same +.thinking+ / +.content+ accessors used in
744
- # {#emit_after_message} apply.
1073
+ # Normalize a streaming +RubyLLM::Chunk+ into zero, one, or two delta
1074
+ # events ({Event::ThinkingDelta}/{Event::AssistantDelta}). Empty
1075
+ # fragments are filtered; tool-call deltas skipped (see {#streaming_block}).
745
1076
  #
746
1077
  # @param chunk [RubyLLM::Chunk]
747
1078
  # @return [void]
@@ -753,26 +1084,37 @@ module Pikuri
753
1084
  @listeners.emit(Event::AssistantDelta.new(content: content)) if content.is_a?(String) && !content.empty?
754
1085
  end
755
1086
 
756
- # Drain the interloper queue: for each pending item, append a
757
- # +role: :user+ message to the chat history so the next
758
- # round-trip sees it, emit an {Event::UserTurn} with
759
- # +mid_loop: true+ to the listener stream so renderers see the
760
- # injection, then run the per-turn {Extension#on_user_message}
761
- # dispatch (so mid-loop injections are prefetched + recorded
762
- # exactly like initial turns).
1087
+ # Drain the interloper queue, landing each item per its
1088
+ # {Control::Interloper::Item#kind}:
1089
+ #
1090
+ # - +:user+ — append +role: :user+ verbatim, emit {Event::UserTurn}
1091
+ # (+mid_loop: true+), then run the {Extension#on_user_message} dispatch
1092
+ # (so mid-loop injections are prefetched + recorded like initial turns).
1093
+ # The dispatch runs *after* the append so an injected +<memory-context>+
1094
+ # lands right behind the turn it annotates.
1095
+ # - +:system+ — {#append_reference_block}: reference text, so no
1096
+ # +on_user_message+ dispatch and no +UserTurn+.
763
1097
  #
764
- # The dispatch runs *after* the +:user+ append so any
765
- # +<memory-context>+ it injects lands as a +:system+ message
766
- # right behind the user turn it annotates — the same
767
- # append-at-the-tail ordering {#run_loop} produces for initial
768
- # turns.
1098
+ # Both kinds reach the wire as +role: :user+ and differ in dispatch and in
1099
+ # the envelope — only the +:system+ kind is wrapped. That is the whole
1100
+ # distinction, and it is why the queue keeps two kinds rather than a
1101
+ # +wrap:+ flag.
769
1102
  #
770
1103
  # @return [void]
1104
+ # @raise [RuntimeError] on an unknown item kind (a bug — only the two
1105
+ # +inject_*+ producers exist)
771
1106
  def drain_interloper
772
- @interloper.drain!.each do |content|
773
- @chat.add_message(role: :user, content: content)
774
- @listeners.emit(Event::UserTurn.new(content: content, mid_loop: true))
775
- dispatch_ext_on_user_message(content)
1107
+ @interloper.drain!.each do |item|
1108
+ case item.kind
1109
+ when :user
1110
+ @chat.add_message(role: :user, content: item.content)
1111
+ @listeners.emit(Event::UserTurn.new(content: item.content, mid_loop: true))
1112
+ dispatch_ext_on_user_message(item.content)
1113
+ when :system
1114
+ append_reference_block(item.content)
1115
+ else
1116
+ raise "unknown interloper item kind: #{item.kind.inspect}"
1117
+ end
776
1118
  end
777
1119
  end
778
1120
  end